Initial commit
This commit is contained in:
@@ -0,0 +1,661 @@
|
||||
/**
|
||||
* Core Game Engine
|
||||
* Manages game state and processes actions
|
||||
*/
|
||||
|
||||
import * as fs from 'fs/promises';
|
||||
import { GameEngine, ActionResult } from '../interfaces/engine';
|
||||
import { WorldModel, GameState, Room, GameObject, Character } from '../interfaces/world-model';
|
||||
import { ActionResponse } from '../interfaces/llm';
|
||||
import { YamlWorldParser } from '../world-model/yaml-parser';
|
||||
|
||||
export class TextAdventureEngine implements GameEngine {
|
||||
private worldModel: WorldModel | null = null;
|
||||
private gameState: GameState | null = null;
|
||||
private actionHandlers: Record<string, (state: GameState, world: WorldModel, action: ActionResponse) => ActionResult> = {};
|
||||
|
||||
constructor() {
|
||||
this.registerDefaultActionHandlers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a world model from a file
|
||||
*/
|
||||
public async loadWorld(worldModelPath: string): Promise<void> {
|
||||
try {
|
||||
this.worldModel = await YamlWorldParser.loadFromFile(worldModelPath);
|
||||
this.gameState = { ...this.worldModel.initialState };
|
||||
|
||||
// Mark the initial room as visited
|
||||
if (!this.gameState.visitedRooms.includes(this.gameState.currentRoomId)) {
|
||||
this.gameState.visitedRooms.push(this.gameState.currentRoomId);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Failed to load world from ${worldModelPath}:`, error);
|
||||
throw new Error(`Could not load world: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current game state
|
||||
*/
|
||||
public getCurrentState(): GameState {
|
||||
if (!this.gameState) {
|
||||
throw new Error('Game state not initialized. Please load a world first.');
|
||||
}
|
||||
return { ...this.gameState };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the world model
|
||||
*/
|
||||
public getWorldModel(): WorldModel {
|
||||
if (!this.worldModel) {
|
||||
throw new Error('World model not initialized. Please load a world first.');
|
||||
}
|
||||
return this.worldModel;
|
||||
}
|
||||
|
||||
/**
|
||||
* Process an action from the player
|
||||
*/
|
||||
public processAction(action: ActionResponse): ActionResult {
|
||||
if (!this.worldModel || !this.gameState) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Game not initialized',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const handler = this.actionHandlers[action.action.toLowerCase()];
|
||||
if (!handler) {
|
||||
return {
|
||||
success: false,
|
||||
message: `I don't know how to "${action.action}"`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
return handler(this.gameState, this.worldModel, action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the current game state to a file
|
||||
*/
|
||||
public async saveGame(filename: string): Promise<void> {
|
||||
if (!this.gameState || !this.worldModel) {
|
||||
throw new Error('Cannot save: game not initialized');
|
||||
}
|
||||
|
||||
const saveData = {
|
||||
worldModelName: this.worldModel.title,
|
||||
worldModelVersion: this.worldModel.version,
|
||||
timestamp: new Date().toISOString(),
|
||||
gameState: this.gameState
|
||||
};
|
||||
|
||||
try {
|
||||
await fs.writeFile(filename, JSON.stringify(saveData, null, 2), 'utf8');
|
||||
} catch (error) {
|
||||
console.error(`Failed to save game to ${filename}:`, error);
|
||||
throw new Error(`Could not save game: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load a game state from a save file
|
||||
*/
|
||||
public async loadGame(filename: string): Promise<void> {
|
||||
try {
|
||||
const fileContents = await fs.readFile(filename, 'utf8');
|
||||
const saveData = JSON.parse(fileContents);
|
||||
|
||||
// Check if the save file matches the current world model
|
||||
if (!this.worldModel) {
|
||||
throw new Error('World model not loaded');
|
||||
}
|
||||
|
||||
if (saveData.worldModelName !== this.worldModel.title ||
|
||||
saveData.worldModelVersion !== this.worldModel.version) {
|
||||
throw new Error('Save file is for a different world or version');
|
||||
}
|
||||
|
||||
// Load the game state
|
||||
this.gameState = saveData.gameState;
|
||||
} catch (error) {
|
||||
console.error(`Failed to load game from ${filename}:`, error);
|
||||
throw new Error(`Could not load save file: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of available actions in the current context
|
||||
*/
|
||||
public getAvailableActions(): string[] {
|
||||
if (!this.worldModel) return [];
|
||||
|
||||
// Common actions always available
|
||||
const availableActions = ['look', 'inventory', 'help'];
|
||||
|
||||
// Add movement actions based on current room exits
|
||||
const currentRoom = this.getCurrentRoom();
|
||||
if (currentRoom) {
|
||||
currentRoom.exits.forEach(exit => {
|
||||
availableActions.push(`go ${exit.direction.toLowerCase()}`);
|
||||
});
|
||||
}
|
||||
|
||||
// Add object interactions based on visible objects
|
||||
const visibleObjects = this.getVisibleObjects();
|
||||
const objects = this.worldModel.objects;
|
||||
|
||||
visibleObjects.forEach(objId => {
|
||||
const obj = objects[objId];
|
||||
if (obj) {
|
||||
obj.allowedActions.forEach(action => {
|
||||
availableActions.push(`${action} ${obj.name.toLowerCase()}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Add character interactions
|
||||
const visibleCharacters = this.getVisibleCharacters();
|
||||
visibleCharacters.forEach(charId => {
|
||||
availableActions.push(`talk to ${this.worldModel!.characters[charId].name.toLowerCase()}`);
|
||||
});
|
||||
|
||||
// Add inventory object actions
|
||||
this.gameState!.inventory.forEach(objId => {
|
||||
const obj = objects[objId];
|
||||
if (obj) {
|
||||
obj.allowedActions.forEach(action => {
|
||||
availableActions.push(`${action} ${obj.name.toLowerCase()}`);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return Array.from(new Set(availableActions)); // Remove duplicates
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of visible objects in the current room
|
||||
*/
|
||||
public getVisibleObjects(): string[] {
|
||||
if (!this.worldModel || !this.gameState) return [];
|
||||
|
||||
const currentRoom = this.getCurrentRoom();
|
||||
if (!currentRoom) return [];
|
||||
|
||||
const visibleObjects: string[] = [...currentRoom.objects];
|
||||
|
||||
// Add objects from open containers
|
||||
currentRoom.objects.forEach(objId => {
|
||||
const obj = this.worldModel!.objects[objId];
|
||||
if (obj && obj.traits.includes('container') && obj.states?.open && obj.containedObjects) {
|
||||
visibleObjects.push(...obj.containedObjects);
|
||||
}
|
||||
});
|
||||
|
||||
return visibleObjects;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a list of visible characters in the current room
|
||||
*/
|
||||
public getVisibleCharacters(): string[] {
|
||||
if (!this.worldModel || !this.gameState) return [];
|
||||
|
||||
const currentRoom = this.getCurrentRoom();
|
||||
return currentRoom ? currentRoom.characters : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the description of the current room
|
||||
*/
|
||||
public getCurrentRoomDescription(): string {
|
||||
const currentRoom = this.getCurrentRoom();
|
||||
if (!currentRoom) return 'You are in a void. Something has gone wrong.';
|
||||
|
||||
return currentRoom.description;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the game and return the introduction text
|
||||
*/
|
||||
public async start(): Promise<string> {
|
||||
if (!this.worldModel) {
|
||||
throw new Error('World not loaded. Please load a world before starting.');
|
||||
}
|
||||
|
||||
// Reset game state to initial state
|
||||
this.gameState = { ...this.worldModel.initialState };
|
||||
|
||||
return this.worldModel.introduction;
|
||||
}
|
||||
|
||||
/**
|
||||
* End the game (cleanup resources if needed)
|
||||
*/
|
||||
public end(): void {
|
||||
// Cleanup could happen here if needed
|
||||
console.log('Game ended');
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current room object
|
||||
*/
|
||||
private getCurrentRoom(): Room | null {
|
||||
if (!this.worldModel || !this.gameState) return null;
|
||||
|
||||
const roomId = this.gameState.currentRoomId;
|
||||
return this.worldModel.rooms[roomId] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register default action handlers
|
||||
*/
|
||||
private registerDefaultActionHandlers(): void {
|
||||
// Look action
|
||||
this.actionHandlers['look'] = (state, world, action): ActionResult => {
|
||||
const room = world.rooms[state.currentRoomId];
|
||||
|
||||
// If an object is specified, look at that object
|
||||
if (action.object) {
|
||||
// Try to find the object in the room or inventory
|
||||
const visibleObjects = this.getVisibleObjects();
|
||||
const objId = this.findObjectByName(action.object, [...visibleObjects, ...state.inventory]);
|
||||
|
||||
if (!objId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't see any ${action.object} here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const obj = world.objects[objId];
|
||||
return {
|
||||
success: true,
|
||||
message: obj.description,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Look at the room
|
||||
const objectDescriptions = room.objects
|
||||
.map(id => world.objects[id])
|
||||
.map(obj => `You can see ${obj.name.toLowerCase()} here.`);
|
||||
|
||||
const characterDescriptions = room.characters
|
||||
.map(id => world.characters[id])
|
||||
.map(char => `${char.name} is here.`);
|
||||
|
||||
const exitDescriptions = room.exits
|
||||
.map(exit => `There is an exit ${exit.direction.toLowerCase()}${exit.description ? ` (${exit.description})` : ''}.`);
|
||||
|
||||
const fullDescription = [
|
||||
room.description,
|
||||
...objectDescriptions,
|
||||
...characterDescriptions,
|
||||
...exitDescriptions
|
||||
].join('\n');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: fullDescription,
|
||||
stateChanged: false
|
||||
};
|
||||
};
|
||||
|
||||
// Go action
|
||||
this.actionHandlers['go'] = (state, world, action): ActionResult => {
|
||||
const room = world.rooms[state.currentRoomId];
|
||||
|
||||
if (!action.object) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Go where?',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Find the exit that matches the direction
|
||||
const direction = action.object.toLowerCase();
|
||||
const exit = room.exits.find(e => e.direction.toLowerCase() === direction);
|
||||
|
||||
if (!exit) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You can't go ${direction} from here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
if (exit.isLocked) {
|
||||
if (!exit.keyId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `The way ${direction} is locked.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
if (!state.inventory.includes(exit.keyId)) {
|
||||
return {
|
||||
success: false,
|
||||
message: `The way ${direction} is locked and you don't have the key.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Player has the key, unlock the exit
|
||||
exit.isLocked = false;
|
||||
return {
|
||||
success: true,
|
||||
message: `You unlock the way ${direction} and proceed.`,
|
||||
stateChanged: true,
|
||||
newState: {
|
||||
...state,
|
||||
currentRoomId: exit.targetRoomId,
|
||||
visitedRooms: state.visitedRooms.includes(exit.targetRoomId)
|
||||
? state.visitedRooms
|
||||
: [...state.visitedRooms, exit.targetRoomId]
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Exit is not locked, just move
|
||||
return {
|
||||
success: true,
|
||||
message: `You go ${direction}.`,
|
||||
stateChanged: true,
|
||||
newState: {
|
||||
...state,
|
||||
currentRoomId: exit.targetRoomId,
|
||||
visitedRooms: state.visitedRooms.includes(exit.targetRoomId)
|
||||
? state.visitedRooms
|
||||
: [...state.visitedRooms, exit.targetRoomId]
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Take action
|
||||
this.actionHandlers['take'] = (state, world, action): ActionResult => {
|
||||
if (!action.object) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Take what?',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Find the object in the current room
|
||||
const visibleObjects = this.getVisibleObjects();
|
||||
const objId = this.findObjectByName(action.object, visibleObjects);
|
||||
|
||||
if (!objId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't see any ${action.object} here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const obj = world.objects[objId];
|
||||
|
||||
// Check if the object can be taken
|
||||
if (!obj.traits.includes('takeable')) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You can't take the ${obj.name.toLowerCase()}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Remove object from room and add to inventory
|
||||
const room = world.rooms[state.currentRoomId];
|
||||
const newRoomObjects = room.objects.filter(id => id !== objId);
|
||||
room.objects = newRoomObjects;
|
||||
|
||||
// Update state
|
||||
return {
|
||||
success: true,
|
||||
message: `You take the ${obj.name.toLowerCase()}.`,
|
||||
stateChanged: true,
|
||||
newState: {
|
||||
...state,
|
||||
inventory: [...state.inventory, objId]
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Inventory action
|
||||
this.actionHandlers['inventory'] = (state, world): ActionResult => {
|
||||
if (state.inventory.length === 0) {
|
||||
return {
|
||||
success: true,
|
||||
message: 'Your inventory is empty.',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const items = state.inventory
|
||||
.map(id => world.objects[id])
|
||||
.map(obj => obj.name)
|
||||
.join(', ');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `You are carrying: ${items}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
};
|
||||
|
||||
// Drop action
|
||||
this.actionHandlers['drop'] = (state, world, action): ActionResult => {
|
||||
if (!action.object) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Drop what?',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Find the object in the inventory
|
||||
const objId = this.findObjectByName(action.object, state.inventory);
|
||||
|
||||
if (!objId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't have any ${action.object}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const obj = world.objects[objId];
|
||||
|
||||
// Remove object from inventory and add to room
|
||||
const room = world.rooms[state.currentRoomId];
|
||||
room.objects.push(objId);
|
||||
|
||||
// Update state
|
||||
return {
|
||||
success: true,
|
||||
message: `You drop the ${obj.name.toLowerCase()}.`,
|
||||
stateChanged: true,
|
||||
newState: {
|
||||
...state,
|
||||
inventory: state.inventory.filter(id => id !== objId)
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Use action
|
||||
this.actionHandlers['use'] = (state, world, action): ActionResult => {
|
||||
if (!action.object) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Use what?',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Find the object in inventory or visible objects
|
||||
const visibleObjects = this.getVisibleObjects();
|
||||
const objId = this.findObjectByName(action.object, [...state.inventory, ...visibleObjects]);
|
||||
|
||||
if (!objId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't see any ${action.object} here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const obj = world.objects[objId];
|
||||
|
||||
// Check if the object can be used
|
||||
if (!obj.allowedActions.includes('use')) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You can't use the ${obj.name.toLowerCase()}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Check if there's a target
|
||||
if (action.target) {
|
||||
const targetId = this.findObjectByName(action.target, [...state.inventory, ...visibleObjects]);
|
||||
|
||||
if (!targetId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't see any ${action.target} here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const target = world.objects[targetId];
|
||||
|
||||
// TODO: Implement object-specific use logic (could be extended with a more sophisticated system)
|
||||
return {
|
||||
success: true,
|
||||
message: `You use the ${obj.name.toLowerCase()} on the ${target.name.toLowerCase()}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Simple use without target
|
||||
return {
|
||||
success: true,
|
||||
message: `You use the ${obj.name.toLowerCase()}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
};
|
||||
|
||||
// Talk action
|
||||
this.actionHandlers['talk'] = (state, world, action): ActionResult => {
|
||||
if (!action.object) {
|
||||
return {
|
||||
success: false,
|
||||
message: 'Talk to whom?',
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// Find the character in the room
|
||||
const visibleCharacters = this.getVisibleCharacters();
|
||||
const charId = this.findCharacterByName(action.object, visibleCharacters);
|
||||
|
||||
if (!charId) {
|
||||
return {
|
||||
success: false,
|
||||
message: `You don't see anyone called ${action.object} here.`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
const character = world.characters[charId];
|
||||
|
||||
// If a topic is provided
|
||||
if (action.parameters?.topic) {
|
||||
const topic = action.parameters.topic.toLowerCase();
|
||||
const response = character.dialogue[topic] || character.defaultResponse;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `${character.name}: "${response}"`,
|
||||
stateChanged: false
|
||||
};
|
||||
}
|
||||
|
||||
// No specific topic
|
||||
return {
|
||||
success: true,
|
||||
message: `${character.name} looks ready to talk. You could ask about: ${Object.keys(character.dialogue).join(', ')}.`,
|
||||
stateChanged: false
|
||||
};
|
||||
};
|
||||
|
||||
// Help action
|
||||
this.actionHandlers['help'] = (): ActionResult => {
|
||||
return {
|
||||
success: true,
|
||||
message: [
|
||||
'Available commands:',
|
||||
'- look: Examine your surroundings or a specific object',
|
||||
'- go [direction]: Move in a direction',
|
||||
'- take [object]: Pick up an object',
|
||||
'- drop [object]: Put down an object',
|
||||
'- inventory: Check what you\'re carrying',
|
||||
'- use [object] (on [target]): Use an object, optionally on another object',
|
||||
'- talk to [character] (about [topic]): Speak with a character',
|
||||
'- help: Show this help text',
|
||||
'',
|
||||
'You can type commands in natural language. The AI will interpret your intent.'
|
||||
].join('\n'),
|
||||
stateChanged: false
|
||||
};
|
||||
};
|
||||
|
||||
// Examine action (alias for look)
|
||||
this.actionHandlers['examine'] = this.actionHandlers['look'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an object by name in a list of object IDs
|
||||
*/
|
||||
private findObjectByName(name: string, objectIds: string[]): string | null {
|
||||
if (!this.worldModel) return null;
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
|
||||
for (const id of objectIds) {
|
||||
const obj = this.worldModel.objects[id];
|
||||
if (obj && obj.name.toLowerCase() === normalizedName) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a character by name in a list of character IDs
|
||||
*/
|
||||
private findCharacterByName(name: string, characterIds: string[]): string | null {
|
||||
if (!this.worldModel) return null;
|
||||
|
||||
const normalizedName = name.toLowerCase();
|
||||
|
||||
for (const id of characterIds) {
|
||||
const character = this.worldModel.characters[id];
|
||||
if (character && character.name.toLowerCase() === normalizedName) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user