Initial commit
This commit is contained in:
@@ -0,0 +1,10 @@
|
|||||||
|
# OpenRouter API Configuration
|
||||||
|
OPENROUTER_API_KEY=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
|
||||||
|
OPENROUTER_MODEL=anthropic/claude-3-opus-20240229
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Game Configuration
|
||||||
|
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
# OpenRouter API Configuration
|
||||||
|
OPENROUTER_API_KEY=your_openrouter_api_key_here
|
||||||
|
OPENROUTER_MODEL=your_selected_model_here
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
PORT=3000
|
||||||
|
NODE_ENV=development
|
||||||
|
|
||||||
|
# Game Configuration
|
||||||
|
DEFAULT_WORLD_FILE=./data/worlds/example_world.yml
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"parser": "@typescript-eslint/parser",
|
||||||
|
"extends": [
|
||||||
|
"eslint:recommended",
|
||||||
|
"plugin:@typescript-eslint/recommended"
|
||||||
|
],
|
||||||
|
"parserOptions": {
|
||||||
|
"ecmaVersion": 2020,
|
||||||
|
"sourceType": "module"
|
||||||
|
},
|
||||||
|
"plugins": [
|
||||||
|
"@typescript-eslint"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"@typescript-eslint/no-explicit-any": "warn",
|
||||||
|
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||||
|
"no-console": "off"
|
||||||
|
},
|
||||||
|
"env": {
|
||||||
|
"node": true,
|
||||||
|
"jest": true
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
node_modules
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
# AI Interactive Fiction
|
||||||
|
|
||||||
|
A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
This application reimagines the classic text adventure game genre by replacing the traditional parser with an LLM. The system consists of:
|
||||||
|
|
||||||
|
1. **World Model**: A traditional game engine that manages rooms, objects, actions, and game state - similar to old-school Infocom games.
|
||||||
|
|
||||||
|
2. **LLM Interface**: An AI layer that processes natural language input from players and translates it into actions the game engine can understand.
|
||||||
|
|
||||||
|
3. **Narrative Generation**: The LLM converts the world state changes into rich, contextual prose for the player.
|
||||||
|
|
||||||
|
## Key Features
|
||||||
|
|
||||||
|
- **Natural Language Understanding**: Players can express their intent in plain language without worrying about specific command syntax.
|
||||||
|
- **Rich Narrative**: Dynamic descriptions that adapt to the current game state and player history.
|
||||||
|
- **Consistent World Model**: The underlying game engine enforces world rules to prevent hallucinations or inconsistencies.
|
||||||
|
- **Modular Design**: Easily swap between different world models, including YAML-based custom worlds or integrations with classic Z-machine games.
|
||||||
|
|
||||||
|
## How It Works
|
||||||
|
|
||||||
|
1. Player enters natural language input
|
||||||
|
2. LLM analyzes input and translates it into game actions
|
||||||
|
3. Game engine processes valid actions and updates the game state
|
||||||
|
4. LLM receives the state change information and generates narrative prose
|
||||||
|
5. Player receives the beautifully written response
|
||||||
|
|
||||||
|
## Technical Structure
|
||||||
|
|
||||||
|
- YAML-based world definition (rooms, objects, actions)
|
||||||
|
- OpenRouter API integration for accessing suitable LLMs
|
||||||
|
- Modular design allowing for Z-machine integration in the future
|
||||||
|
|
||||||
|
## Getting Started
|
||||||
|
|
||||||
|
[Installation and running instructions will be added here]
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 3.8 MiB |
@@ -0,0 +1,99 @@
|
|||||||
|
# Project Implementation Plan
|
||||||
|
|
||||||
|
## Phase 1: Project Setup and Basic Structure
|
||||||
|
- [x] Define project goals and specifications
|
||||||
|
- [x] Set up project structure
|
||||||
|
- [x] Create core directories (src, data, tests)
|
||||||
|
- [x] Initialize Node.js/npm project
|
||||||
|
- [x] Set up TypeScript configuration
|
||||||
|
- [ ] Configure ESLint and Prettier for code quality
|
||||||
|
- [ ] Choose and set up testing framework
|
||||||
|
- [x] Create basic documentation structure
|
||||||
|
|
||||||
|
## Phase 2: World Model Implementation
|
||||||
|
- [ ] Define YAML schema for world elements
|
||||||
|
- [ ] Room schema (description, exits, objects, characters)
|
||||||
|
- [ ] Object schema (description, properties, allowed actions)
|
||||||
|
- [ ] NPC schema (description, dialogue, behavior)
|
||||||
|
- [ ] Action schema (conditions, effects)
|
||||||
|
- [x] Implement YAML parser and validator
|
||||||
|
- [ ] Create the world model core
|
||||||
|
- [ ] Game state management
|
||||||
|
- [ ] Room navigation
|
||||||
|
- [ ] Object interaction
|
||||||
|
- [ ] NPC interaction
|
||||||
|
- [ ] Action processing logic
|
||||||
|
- [x] Create a simple test world in YAML format
|
||||||
|
- [ ] Implement unit tests for world model
|
||||||
|
|
||||||
|
## Phase 3: LLM Integration
|
||||||
|
- [x] Research and select appropriate OpenRouter model
|
||||||
|
- [x] Implement OpenRouter API client
|
||||||
|
- [x] Configuration and authentication
|
||||||
|
- [x] API request/response handling
|
||||||
|
- [ ] Rate limiting and error handling
|
||||||
|
- [ ] Design LLM prompting strategy
|
||||||
|
- [ ] System prompts for action translation
|
||||||
|
- [ ] System prompts for narrative generation
|
||||||
|
- [ ] Context management for conversation history
|
||||||
|
- [ ] Create adapter between LLM and world model
|
||||||
|
- [ ] Define the interface for action translation
|
||||||
|
- [ ] Define the interface for narrative generation
|
||||||
|
|
||||||
|
## Phase 4: Game Engine Core
|
||||||
|
- [x] Implement the game loop
|
||||||
|
- [x] Input handling
|
||||||
|
- [ ] Action processing via LLM
|
||||||
|
- [ ] World model updating
|
||||||
|
- [ ] Response generation via LLM
|
||||||
|
- [ ] Output formatting
|
||||||
|
- [ ] Implement saving/loading game state
|
||||||
|
- [ ] Add game configuration options
|
||||||
|
- [ ] Implement logging for debugging
|
||||||
|
|
||||||
|
## Phase 5: User Interface
|
||||||
|
- [x] Create a command-line interface
|
||||||
|
- [x] Input handling
|
||||||
|
- [x] Text output formatting
|
||||||
|
- [ ] Command history
|
||||||
|
- [x] Implement a simple web interface
|
||||||
|
- [x] Basic HTML/CSS structure
|
||||||
|
- [x] JavaScript for interaction
|
||||||
|
- [x] Responsive design
|
||||||
|
- [x] Text processing utilities
|
||||||
|
- [x] Implement smartypants.js for typographical improvements
|
||||||
|
- [ ] Add hyphenation support
|
||||||
|
|
||||||
|
## Phase 6: Advanced Features
|
||||||
|
- [ ] Implement integration layer for Z-machine
|
||||||
|
- [ ] Research Z-machine libraries
|
||||||
|
- [ ] Create adapter for Z-machine to world model interface
|
||||||
|
- [ ] Test with classic Infocom games
|
||||||
|
- [ ] Add advanced LLM features
|
||||||
|
- [ ] Character styles and narrative tones
|
||||||
|
- [ ] Memory and reference to past events
|
||||||
|
- [ ] Player character personality modeling
|
||||||
|
- [ ] Create plugin system for extending world model capabilities
|
||||||
|
|
||||||
|
## Phase 7: Testing and Refinement
|
||||||
|
- [ ] Comprehensive testing
|
||||||
|
- [ ] Unit tests for core components
|
||||||
|
- [ ] Integration tests for LLM integration
|
||||||
|
- [ ] End-to-end game flow tests
|
||||||
|
- [ ] User testing and feedback
|
||||||
|
- [ ] Performance optimization
|
||||||
|
- [ ] Minimize LLM token usage
|
||||||
|
- [ ] Optimize world model for larger games
|
||||||
|
- [ ] Refine prompting strategies based on testing
|
||||||
|
|
||||||
|
## Phase 8: Documentation and Release
|
||||||
|
- [x] Complete user documentation
|
||||||
|
- [x] Installation guide
|
||||||
|
- [ ] World creation guide
|
||||||
|
- [ ] Configuration reference
|
||||||
|
- [ ] Complete developer documentation
|
||||||
|
- [ ] Architecture overview
|
||||||
|
- [ ] API reference
|
||||||
|
- [ ] Extension guide
|
||||||
|
- [ ] Create example worlds and games
|
||||||
|
- [ ] Prepare for initial release
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"folders": [
|
||||||
|
{
|
||||||
|
"path": "."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "../ink.js"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* Script to copy required assets from ink.js project to AI Interactive Fiction
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// Define asset directories
|
||||||
|
const sourceDir = 'e:/Georg/vhosts/ink.js';
|
||||||
|
const targetDir = 'e:/Georg/vhosts/ai.interactive.fiction/public';
|
||||||
|
|
||||||
|
// Assets to copy
|
||||||
|
const assets = [
|
||||||
|
{ src: 'book-3057904.png', dest: 'images/book-3057904.png' },
|
||||||
|
{ src: 'brown-wooden-flooring.jpg', dest: 'images/brown-wooden-flooring.jpg' },
|
||||||
|
{ src: 'EBGaramond12-Regular.otf', dest: 'fonts/EBGaramond12-Regular.otf' },
|
||||||
|
{ src: 'EBGaramond12-Italic.otf', dest: 'fonts/EBGaramond12-Italic.otf' }
|
||||||
|
];
|
||||||
|
|
||||||
|
// Create necessary directories
|
||||||
|
const directories = ['images', 'fonts', 'js', 'css'].map(dir => path.join(targetDir, dir));
|
||||||
|
directories.forEach(dir => {
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
console.log(`Creating directory: ${dir}`);
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Copy each asset
|
||||||
|
assets.forEach(asset => {
|
||||||
|
const source = path.join(sourceDir, asset.src);
|
||||||
|
const destination = path.join(targetDir, asset.dest);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (fs.existsSync(source)) {
|
||||||
|
fs.copyFileSync(source, destination);
|
||||||
|
console.log(`Successfully copied ${source} to ${destination}`);
|
||||||
|
} else {
|
||||||
|
console.error(`Source file does not exist: ${source}`);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Error copying ${source}:`, error.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('Asset copying completed.');
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
title: The Mysterious Mansion
|
||||||
|
author: AI Interactive Fiction
|
||||||
|
version: 1.0.0
|
||||||
|
introduction: |
|
||||||
|
You find yourself standing outside an old, abandoned mansion on a hill.
|
||||||
|
Rain patters gently on the gravel path leading to the front door.
|
||||||
|
A strange letter in your pocket invited you here, but you can't remember who sent it.
|
||||||
|
Perhaps the answers lie within...
|
||||||
|
|
||||||
|
# Room definitions
|
||||||
|
rooms:
|
||||||
|
# Starting area
|
||||||
|
front_yard:
|
||||||
|
name: Front Yard
|
||||||
|
description: |
|
||||||
|
You stand on a gravel path leading to an imposing Victorian mansion.
|
||||||
|
The rain has softened to a drizzle, and moonlight peeks through gaps in the clouds.
|
||||||
|
Ancient oak trees frame the property, their branches swaying in the gentle breeze.
|
||||||
|
exits:
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: entrance_hall
|
||||||
|
description: large wooden doors lead into the mansion
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: street
|
||||||
|
description: wrought iron gates lead back to the street
|
||||||
|
objects:
|
||||||
|
- strange_letter
|
||||||
|
- garden_statue
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Main entrance
|
||||||
|
entrance_hall:
|
||||||
|
name: Entrance Hall
|
||||||
|
description: |
|
||||||
|
Grand chandeliers hang from the high ceiling, their crystals covered in cobwebs.
|
||||||
|
A wide staircase curves up to the second floor, and paintings of stern-faced
|
||||||
|
individuals watch you from ornate frames on the walls.
|
||||||
|
The floor is polished marble, though dusty from neglect.
|
||||||
|
exits:
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: front_yard
|
||||||
|
description: the main entrance doors
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: grand_staircase
|
||||||
|
description: the grand staircase
|
||||||
|
- direction: east
|
||||||
|
targetRoomId: dining_room
|
||||||
|
description: an archway leads to what appears to be a dining room
|
||||||
|
- direction: west
|
||||||
|
targetRoomId: library
|
||||||
|
description: a door marked 'Library'
|
||||||
|
objects:
|
||||||
|
- dusty_key
|
||||||
|
- umbrella_stand
|
||||||
|
characters:
|
||||||
|
- butler_ghost
|
||||||
|
|
||||||
|
# Library
|
||||||
|
library:
|
||||||
|
name: Library
|
||||||
|
description: |
|
||||||
|
Bookshelves line every wall, reaching from floor to ceiling.
|
||||||
|
A reading desk sits in the center of the room, a leather-bound book
|
||||||
|
open upon it. A gentle fire crackles in the fireplace, casting
|
||||||
|
dancing shadows across the room.
|
||||||
|
exits:
|
||||||
|
- direction: east
|
||||||
|
targetRoomId: entrance_hall
|
||||||
|
description: the door back to the entrance hall
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: secret_study
|
||||||
|
description: a hidden door in the bookshelf
|
||||||
|
isLocked: true
|
||||||
|
keyId: old_brass_key
|
||||||
|
objects:
|
||||||
|
- leather_book
|
||||||
|
- reading_glasses
|
||||||
|
- old_brass_key
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Dining Room
|
||||||
|
dining_room:
|
||||||
|
name: Dining Room
|
||||||
|
description: |
|
||||||
|
A long table dominates this room, set for a dinner party that never happened.
|
||||||
|
Fine china and silverware rest atop an elegant tablecloth, now gray with dust.
|
||||||
|
A chandelier hangs above, and a sideboard against the wall holds various serving dishes.
|
||||||
|
exits:
|
||||||
|
- direction: west
|
||||||
|
targetRoomId: entrance_hall
|
||||||
|
description: the archway back to the entrance hall
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: kitchen
|
||||||
|
description: a swinging door to what must be the kitchen
|
||||||
|
objects:
|
||||||
|
- silver_candlestick
|
||||||
|
- dusty_plate
|
||||||
|
characters:
|
||||||
|
- dining_ghost
|
||||||
|
|
||||||
|
# Kitchen
|
||||||
|
kitchen:
|
||||||
|
name: Kitchen
|
||||||
|
description: |
|
||||||
|
This once-busy kitchen now stands silent. Copper pots and pans hang from hooks,
|
||||||
|
and an old cast-iron stove sits cold against the wall. A large preparation table
|
||||||
|
occupies the center of the room, and a pantry door stands ajar.
|
||||||
|
exits:
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: dining_room
|
||||||
|
description: the swinging door back to the dining room
|
||||||
|
- direction: east
|
||||||
|
targetRoomId: pantry
|
||||||
|
description: the pantry door
|
||||||
|
objects:
|
||||||
|
- rusty_knife
|
||||||
|
- cookbook
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Pantry
|
||||||
|
pantry:
|
||||||
|
name: Pantry
|
||||||
|
description: |
|
||||||
|
Shelves line the walls of this small room, holding preserves in dusty jars
|
||||||
|
and sacks of long-expired ingredients. A small window provides minimal light,
|
||||||
|
and a musty smell permeates the air.
|
||||||
|
exits:
|
||||||
|
- direction: west
|
||||||
|
targetRoomId: kitchen
|
||||||
|
description: the door back to the kitchen
|
||||||
|
objects:
|
||||||
|
- dusty_jar
|
||||||
|
- strange_bottle
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Grand Staircase
|
||||||
|
grand_staircase:
|
||||||
|
name: Grand Staircase
|
||||||
|
description: |
|
||||||
|
The staircase curves gracefully upward, its wooden railings polished to a soft glow
|
||||||
|
despite the overall neglect of the mansion. Family portraits line the walls,
|
||||||
|
following your movement with their painted eyes.
|
||||||
|
exits:
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: entrance_hall
|
||||||
|
description: back down to the entrance hall
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: upper_landing
|
||||||
|
description: up to the second floor
|
||||||
|
objects:
|
||||||
|
- family_portrait
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Upper Landing
|
||||||
|
upper_landing:
|
||||||
|
name: Upper Landing
|
||||||
|
description: |
|
||||||
|
The upper landing connects several rooms on the second floor. A faded
|
||||||
|
carpet runs down the center of the hallway, and doors line both sides.
|
||||||
|
A large window at the end of the hall shows the rainy night outside.
|
||||||
|
exits:
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: grand_staircase
|
||||||
|
description: down the grand staircase
|
||||||
|
- direction: east
|
||||||
|
targetRoomId: master_bedroom
|
||||||
|
description: a door marked 'Master Bedroom'
|
||||||
|
- direction: west
|
||||||
|
targetRoomId: study
|
||||||
|
description: a door marked 'Study'
|
||||||
|
objects: []
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Master Bedroom
|
||||||
|
master_bedroom:
|
||||||
|
name: Master Bedroom
|
||||||
|
description: |
|
||||||
|
A large four-poster bed dominates this room, its once-luxurious hangings
|
||||||
|
now faded and torn. A vanity sits in the corner, its mirror clouded with age,
|
||||||
|
and a wardrobe stands against the far wall.
|
||||||
|
exits:
|
||||||
|
- direction: west
|
||||||
|
targetRoomId: upper_landing
|
||||||
|
description: the door back to the upper landing
|
||||||
|
objects:
|
||||||
|
- jewelry_box
|
||||||
|
- old_diary
|
||||||
|
characters:
|
||||||
|
- lady_ghost
|
||||||
|
|
||||||
|
# Study
|
||||||
|
study:
|
||||||
|
name: Study
|
||||||
|
description: |
|
||||||
|
This cozy room contains a large desk covered in papers, a comfortable
|
||||||
|
armchair, and a globe that seems to rotate slowly on its own. Bookshelves
|
||||||
|
line the walls, filled with volumes on various esoteric subjects.
|
||||||
|
exits:
|
||||||
|
- direction: east
|
||||||
|
targetRoomId: upper_landing
|
||||||
|
description: the door back to the upper landing
|
||||||
|
objects:
|
||||||
|
- strange_device
|
||||||
|
- important_letter
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Secret Study (hidden room)
|
||||||
|
secret_study:
|
||||||
|
name: Secret Study
|
||||||
|
description: |
|
||||||
|
Hidden behind the library bookshelf, this small room appears to be a
|
||||||
|
private study. A desk with a locked drawer sits against one wall, and
|
||||||
|
shelves hold unusual artifacts and rare books. A single candle provides
|
||||||
|
dim illumination.
|
||||||
|
exits:
|
||||||
|
- direction: south
|
||||||
|
targetRoomId: library
|
||||||
|
description: the hidden door back to the library
|
||||||
|
objects:
|
||||||
|
- ancient_tome
|
||||||
|
- crystal_key
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Street (exit area)
|
||||||
|
street:
|
||||||
|
name: Street
|
||||||
|
description: |
|
||||||
|
The quiet street outside the mansion is shrouded in fog. Streetlamps cast
|
||||||
|
pools of yellow light that barely penetrate the mist. The mansion's gates
|
||||||
|
loom behind you, while the way back to town lies ahead.
|
||||||
|
exits:
|
||||||
|
- direction: north
|
||||||
|
targetRoomId: front_yard
|
||||||
|
description: the mansion gates
|
||||||
|
objects: []
|
||||||
|
characters: []
|
||||||
|
|
||||||
|
# Object definitions
|
||||||
|
objects:
|
||||||
|
strange_letter:
|
||||||
|
name: Strange Letter
|
||||||
|
description: |
|
||||||
|
A weathered envelope containing an invitation to visit the mansion.
|
||||||
|
The handwriting is elegant but unfamiliar, and the letter is signed
|
||||||
|
simply with the initial "M".
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
garden_statue:
|
||||||
|
name: Garden Statue
|
||||||
|
description: |
|
||||||
|
A weathered stone statue of a weeping angel. Its face is covered by its hands,
|
||||||
|
and detailed wings spread out from its back. Something about it makes you uneasy.
|
||||||
|
traits:
|
||||||
|
- fixed
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- examine
|
||||||
|
|
||||||
|
dusty_key:
|
||||||
|
name: Dusty Key
|
||||||
|
description: |
|
||||||
|
An old iron key, covered in dust. It looks like it might fit an old door somewhere.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- key
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- examine
|
||||||
|
- use
|
||||||
|
|
||||||
|
umbrella_stand:
|
||||||
|
name: Umbrella Stand
|
||||||
|
description: |
|
||||||
|
A brass stand holding several antique umbrellas, all in various states of decay.
|
||||||
|
traits:
|
||||||
|
- fixed
|
||||||
|
- container
|
||||||
|
states:
|
||||||
|
open: true
|
||||||
|
containedObjects: []
|
||||||
|
allowedActions:
|
||||||
|
- examine
|
||||||
|
|
||||||
|
leather_book:
|
||||||
|
name: Leather Book
|
||||||
|
description: |
|
||||||
|
A thick tome bound in dark leather. The pages are filled with strange symbols
|
||||||
|
and diagrams that seem to shift slightly when you're not looking directly at them.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
reading_glasses:
|
||||||
|
name: Reading Glasses
|
||||||
|
description: |
|
||||||
|
A pair of wire-rimmed spectacles. The lenses have a slight blue tint to them.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- wearable
|
||||||
|
states:
|
||||||
|
worn: false
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- wear
|
||||||
|
- examine
|
||||||
|
|
||||||
|
old_brass_key:
|
||||||
|
name: Brass Key
|
||||||
|
description: |
|
||||||
|
A small brass key with intricate engravings. It seems to be quite old but well-maintained.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- key
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- examine
|
||||||
|
- use
|
||||||
|
|
||||||
|
silver_candlestick:
|
||||||
|
name: Silver Candlestick
|
||||||
|
description: |
|
||||||
|
A tarnished silver candlestick with an unlit candle. It feels heavy in your hand.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- light_source
|
||||||
|
states:
|
||||||
|
lit: false
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- light
|
||||||
|
- examine
|
||||||
|
|
||||||
|
dusty_plate:
|
||||||
|
name: Dusty Plate
|
||||||
|
description: |
|
||||||
|
A fine china plate covered in a layer of dust. Despite its age, the painted pattern is still vivid.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- examine
|
||||||
|
|
||||||
|
rusty_knife:
|
||||||
|
name: Rusty Knife
|
||||||
|
description: |
|
||||||
|
An old kitchen knife with a rusted blade. It's dull, but still might be useful.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- sharp
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- examine
|
||||||
|
- use
|
||||||
|
|
||||||
|
cookbook:
|
||||||
|
name: Cookbook
|
||||||
|
description: |
|
||||||
|
A yellowed cookbook filled with strange recipes. Some ingredients are unusual, and
|
||||||
|
the instructions sometimes reference phases of the moon or specific star alignments.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
dusty_jar:
|
||||||
|
name: Dusty Jar
|
||||||
|
description: |
|
||||||
|
A glass jar containing what might once have been fruit preserves, now unidentifiable.
|
||||||
|
Best not to open it.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- container
|
||||||
|
states:
|
||||||
|
open: false
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- examine
|
||||||
|
|
||||||
|
strange_bottle:
|
||||||
|
name: Strange Bottle
|
||||||
|
description: |
|
||||||
|
A small bottle containing a glowing blue liquid. The label is written in a language you don't recognize.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- drinkable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- drink
|
||||||
|
- examine
|
||||||
|
|
||||||
|
family_portrait:
|
||||||
|
name: Family Portrait
|
||||||
|
description: |
|
||||||
|
A large painting of a stern-looking family - a husband, wife, and three children.
|
||||||
|
The father's eyes seem to follow you, and there's something oddly familiar about his face.
|
||||||
|
traits:
|
||||||
|
- fixed
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- examine
|
||||||
|
|
||||||
|
jewelry_box:
|
||||||
|
name: Jewelry Box
|
||||||
|
description: |
|
||||||
|
An ornate wooden box inlaid with mother-of-pearl. Inside are several pieces of
|
||||||
|
antique jewelry, including a ruby necklace that catches the light strangely.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- container
|
||||||
|
states:
|
||||||
|
open: true
|
||||||
|
containedObjects:
|
||||||
|
- ruby_necklace
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- open
|
||||||
|
- close
|
||||||
|
- examine
|
||||||
|
|
||||||
|
ruby_necklace:
|
||||||
|
name: Ruby Necklace
|
||||||
|
description: |
|
||||||
|
A delicate gold chain with a large ruby pendant. The gem seems to glow with an inner light,
|
||||||
|
and it feels warm to the touch.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- wearable
|
||||||
|
states:
|
||||||
|
worn: false
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- wear
|
||||||
|
- examine
|
||||||
|
|
||||||
|
old_diary:
|
||||||
|
name: Old Diary
|
||||||
|
description: |
|
||||||
|
A leather-bound diary with yellowed pages. The entries detail the daily life of
|
||||||
|
the mansion's former mistress, and hint at a growing fear of something in the house.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
strange_device:
|
||||||
|
name: Strange Device
|
||||||
|
description: |
|
||||||
|
A brass contraption with gears, dials, and a glass dome. It's purpose isn't clear,
|
||||||
|
but it occasionally ticks and whirs on its own.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
states:
|
||||||
|
active: false
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- use
|
||||||
|
- examine
|
||||||
|
|
||||||
|
important_letter:
|
||||||
|
name: Important Letter
|
||||||
|
description: |
|
||||||
|
A sealed envelope addressed to "The Heir." The wax seal bears the same crest
|
||||||
|
that you've seen throughout the mansion.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
ancient_tome:
|
||||||
|
name: Ancient Tome
|
||||||
|
description: |
|
||||||
|
A massive book bound in what appears to be human skin. The title, "Liber Umbrarum,"
|
||||||
|
is embossed in gold on the spine. The pages contain rituals and incantations.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- readable
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- read
|
||||||
|
- examine
|
||||||
|
|
||||||
|
crystal_key:
|
||||||
|
name: Crystal Key
|
||||||
|
description: |
|
||||||
|
A key made of clear crystal that catches the light in mesmerizing ways. Despite
|
||||||
|
its appearance, it feels as solid as metal and cool to the touch.
|
||||||
|
traits:
|
||||||
|
- takeable
|
||||||
|
- key
|
||||||
|
states: {}
|
||||||
|
allowedActions:
|
||||||
|
- take
|
||||||
|
- use
|
||||||
|
- examine
|
||||||
|
|
||||||
|
# Character definitions
|
||||||
|
characters:
|
||||||
|
butler_ghost:
|
||||||
|
name: Ghostly Butler
|
||||||
|
description: |
|
||||||
|
The translucent figure of an elderly butler, dressed in formal attire from a bygone era.
|
||||||
|
He stands with perfect posture, hands clasped behind his back.
|
||||||
|
dialogue:
|
||||||
|
greeting: "Welcome to the mansion, sir/madam. We've been expecting you."
|
||||||
|
mansion: "This estate has belonged to the Montgomery family for generations. Such a shame what happened to them."
|
||||||
|
family: "The Montgomerys? All gone now, I'm afraid. The master, his wife, and their children. A tragedy."
|
||||||
|
tragedy: "I'm not at liberty to discuss the details, but the answers you seek may be found in the study."
|
||||||
|
yourself: "Me? I've served this house for longer than I care to remember. Even death couldn't release me from my duties."
|
||||||
|
defaultResponse: "I'm afraid I cannot help you with that particular inquiry."
|
||||||
|
inventory: []
|
||||||
|
mood: formal
|
||||||
|
|
||||||
|
dining_ghost:
|
||||||
|
name: Dining Guest
|
||||||
|
description: |
|
||||||
|
A spectral figure in elegant dinner attire, seated at the table. She appears to be
|
||||||
|
a young woman, and she plays absently with a spectral fork.
|
||||||
|
dialogue:
|
||||||
|
greeting: "Oh, a new guest! How delightful. Will you join us for dinner? It's been so long since we had fresh company."
|
||||||
|
dinner: "We've been waiting for the main course for... goodness, how long has it been now? Years, I suppose."
|
||||||
|
herself: "My name? It's... it's strange, I can't quite recall. I remember coming here for a dinner party, but then..."
|
||||||
|
party: "It was supposed to be a celebration. The master of the house had made some sort of discovery. Something important."
|
||||||
|
discovery: "In the secret study, I believe. Behind the library. The master was very excited about it."
|
||||||
|
defaultResponse: "I'm sorry, my mind isn't what it used to be. The years blur together when you're like this."
|
||||||
|
inventory: []
|
||||||
|
mood: wistful
|
||||||
|
|
||||||
|
lady_ghost:
|
||||||
|
name: Ghostly Lady
|
||||||
|
description: |
|
||||||
|
The elegant apparition of a woman in Victorian dress, her face partly obscured by a veil.
|
||||||
|
She sits at the vanity, brushing her long hair with a ghostly brush.
|
||||||
|
dialogue:
|
||||||
|
greeting: "A visitor? How unusual. Are you lost, or are you here for a purpose?"
|
||||||
|
purpose: "Everyone who comes to this house has a purpose, whether they know it or not."
|
||||||
|
herself: "I was the lady of this house once. Now I am bound to it, as are we all."
|
||||||
|
family: "My husband was obsessed with his research. My children... I tried to protect them. I failed."
|
||||||
|
research: "The barriers between worlds, the nature of reality itself. He found something, in the end. Something that should have remained hidden."
|
||||||
|
hidden: "In his secret study. The key is... well, I suppose you'll have to find that yourself. Some secrets reveal themselves only to those who seek them."
|
||||||
|
defaultResponse: "There are some things I cannot speak of. The house has its rules, even for the dead."
|
||||||
|
inventory: []
|
||||||
|
mood: melancholy
|
||||||
|
|
||||||
|
# Action definitions
|
||||||
|
actions:
|
||||||
|
look:
|
||||||
|
patterns:
|
||||||
|
- "look around"
|
||||||
|
- "look at [object]"
|
||||||
|
- "examine [object]"
|
||||||
|
- "check [object]"
|
||||||
|
- "inspect [object]"
|
||||||
|
- "observe [object]"
|
||||||
|
- "view [object]"
|
||||||
|
handler: "look"
|
||||||
|
|
||||||
|
go:
|
||||||
|
patterns:
|
||||||
|
- "go [direction]"
|
||||||
|
- "move [direction]"
|
||||||
|
- "walk [direction]"
|
||||||
|
- "head [direction]"
|
||||||
|
- "travel [direction]"
|
||||||
|
- "enter [direction]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "go"
|
||||||
|
|
||||||
|
take:
|
||||||
|
patterns:
|
||||||
|
- "take [object]"
|
||||||
|
- "get [object]"
|
||||||
|
- "pick up [object]"
|
||||||
|
- "grab [object]"
|
||||||
|
- "collect [object]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "take"
|
||||||
|
|
||||||
|
drop:
|
||||||
|
patterns:
|
||||||
|
- "drop [object]"
|
||||||
|
- "put down [object]"
|
||||||
|
- "discard [object]"
|
||||||
|
- "leave [object]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "drop"
|
||||||
|
|
||||||
|
inventory:
|
||||||
|
patterns:
|
||||||
|
- "inventory"
|
||||||
|
- "check inventory"
|
||||||
|
- "show inventory"
|
||||||
|
- "what am I carrying"
|
||||||
|
- "what do I have"
|
||||||
|
handler: "inventory"
|
||||||
|
|
||||||
|
use:
|
||||||
|
patterns:
|
||||||
|
- "use [object]"
|
||||||
|
- "use [object] on [target]"
|
||||||
|
- "use [object] with [target]"
|
||||||
|
- "apply [object] to [target]"
|
||||||
|
requiresObject: true
|
||||||
|
requiresTarget: false
|
||||||
|
handler: "use"
|
||||||
|
|
||||||
|
talk:
|
||||||
|
patterns:
|
||||||
|
- "talk to [object]"
|
||||||
|
- "speak to [object]"
|
||||||
|
- "ask [object] about [topic]"
|
||||||
|
- "tell [object] about [topic]"
|
||||||
|
- "converse with [object]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "talk"
|
||||||
|
|
||||||
|
read:
|
||||||
|
patterns:
|
||||||
|
- "read [object]"
|
||||||
|
- "read from [object]"
|
||||||
|
- "examine [object]"
|
||||||
|
- "look at [object]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "look"
|
||||||
|
|
||||||
|
help:
|
||||||
|
patterns:
|
||||||
|
- "help"
|
||||||
|
- "commands"
|
||||||
|
- "what can I do"
|
||||||
|
- "show help"
|
||||||
|
handler: "help"
|
||||||
|
|
||||||
|
wear:
|
||||||
|
patterns:
|
||||||
|
- "wear [object]"
|
||||||
|
- "put on [object]"
|
||||||
|
- "don [object]"
|
||||||
|
requiresObject: true
|
||||||
|
handler: "use"
|
||||||
|
|
||||||
|
# Initial game state
|
||||||
|
initialState:
|
||||||
|
currentRoomId: front_yard
|
||||||
|
inventory:
|
||||||
|
- strange_letter
|
||||||
|
visitedRooms: []
|
||||||
|
flags:
|
||||||
|
hasMetButler: false
|
||||||
|
hasFoundSecret: false
|
||||||
|
counters:
|
||||||
|
moveCount: 0
|
||||||
Vendored
+64
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Command-line interface for running the interactive fiction game
|
||||||
|
*/
|
||||||
|
export declare class GameRunner {
|
||||||
|
private engine;
|
||||||
|
private llmProvider;
|
||||||
|
private rl;
|
||||||
|
private gameContext;
|
||||||
|
private gameHistory;
|
||||||
|
private suggestedCommands;
|
||||||
|
constructor();
|
||||||
|
/**
|
||||||
|
* Initialize the game
|
||||||
|
*/
|
||||||
|
initialize(worldPath: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Start the game in CLI mode
|
||||||
|
*/
|
||||||
|
start(): Promise<void>;
|
||||||
|
/**
|
||||||
|
* The main game loop for CLI mode
|
||||||
|
*/
|
||||||
|
private gameLoop;
|
||||||
|
/**
|
||||||
|
* Process a player command and return the narrative response
|
||||||
|
* Used by both CLI and web interfaces
|
||||||
|
*/
|
||||||
|
processCommand(input: string): Promise<string>;
|
||||||
|
/**
|
||||||
|
* End the game
|
||||||
|
*/
|
||||||
|
end(): void;
|
||||||
|
/**
|
||||||
|
* Update the game context with new narrative
|
||||||
|
*/
|
||||||
|
private updateGameContext;
|
||||||
|
/**
|
||||||
|
* Get the current game state
|
||||||
|
* Used by web interface
|
||||||
|
*/
|
||||||
|
getGameState(): {
|
||||||
|
world: import("../interfaces/world-model").WorldModel;
|
||||||
|
currentRoomId: string;
|
||||||
|
inventory: string[];
|
||||||
|
visitedRooms: string[];
|
||||||
|
flags: Record<string, boolean>;
|
||||||
|
counters: Record<string, number>;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Get the current room description
|
||||||
|
* Used by web interface
|
||||||
|
*/
|
||||||
|
getCurrentRoomDescription(): string;
|
||||||
|
/**
|
||||||
|
* Get suggested actions for the current game state
|
||||||
|
* Used by web interface
|
||||||
|
*/
|
||||||
|
getSuggestions(): string[];
|
||||||
|
/**
|
||||||
|
* Load a saved game state
|
||||||
|
* Used by web interface
|
||||||
|
*/
|
||||||
|
loadGameState(savedState: any): void;
|
||||||
|
}
|
||||||
Vendored
+262
@@ -0,0 +1,262 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Command-line interface for running the interactive fiction game
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.GameRunner = void 0;
|
||||||
|
const readline = __importStar(require("readline"));
|
||||||
|
const path = __importStar(require("path"));
|
||||||
|
const dotenv = __importStar(require("dotenv"));
|
||||||
|
const game_engine_1 = require("../engine/game-engine");
|
||||||
|
const openrouter_provider_1 = require("../llm/openrouter-provider");
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
class GameRunner {
|
||||||
|
constructor() {
|
||||||
|
this.rl = null;
|
||||||
|
this.gameContext = '';
|
||||||
|
this.gameHistory = [];
|
||||||
|
this.suggestedCommands = [];
|
||||||
|
this.engine = new game_engine_1.TextAdventureEngine();
|
||||||
|
this.llmProvider = new openrouter_provider_1.OpenRouterProvider();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initialize the game
|
||||||
|
*/
|
||||||
|
async initialize(worldPath) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
// 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 = {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
gameLoop() {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async processCommand(input) {
|
||||||
|
try {
|
||||||
|
// Process player input
|
||||||
|
const 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 = {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
end() {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
updateGameContext(narrative) {
|
||||||
|
// 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
|
||||||
|
*/
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
getCurrentRoomDescription() {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
getSuggestions() {
|
||||||
|
return this.suggestedCommands;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Load a saved game state
|
||||||
|
* Used by web interface
|
||||||
|
*/
|
||||||
|
loadGameState(savedState) {
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.GameRunner = GameRunner;
|
||||||
|
//# sourceMappingURL=game-runner.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+77
@@ -0,0 +1,77 @@
|
|||||||
|
/**
|
||||||
|
* Core Game Engine
|
||||||
|
* Manages game state and processes actions
|
||||||
|
*/
|
||||||
|
import { GameEngine, ActionResult } from '../interfaces/engine';
|
||||||
|
import { WorldModel, GameState } from '../interfaces/world-model';
|
||||||
|
import { ActionResponse } from '../interfaces/llm';
|
||||||
|
export declare class TextAdventureEngine implements GameEngine {
|
||||||
|
private worldModel;
|
||||||
|
private gameState;
|
||||||
|
private actionHandlers;
|
||||||
|
constructor();
|
||||||
|
/**
|
||||||
|
* Load a world model from a file
|
||||||
|
*/
|
||||||
|
loadWorld(worldModelPath: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Get the current game state
|
||||||
|
*/
|
||||||
|
getCurrentState(): GameState;
|
||||||
|
/**
|
||||||
|
* Get the world model
|
||||||
|
*/
|
||||||
|
getWorldModel(): WorldModel;
|
||||||
|
/**
|
||||||
|
* Process an action from the player
|
||||||
|
*/
|
||||||
|
processAction(action: ActionResponse): ActionResult;
|
||||||
|
/**
|
||||||
|
* Save the current game state to a file
|
||||||
|
*/
|
||||||
|
saveGame(filename: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Load a game state from a save file
|
||||||
|
*/
|
||||||
|
loadGame(filename: string): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Get a list of available actions in the current context
|
||||||
|
*/
|
||||||
|
getAvailableActions(): string[];
|
||||||
|
/**
|
||||||
|
* Get a list of visible objects in the current room
|
||||||
|
*/
|
||||||
|
getVisibleObjects(): string[];
|
||||||
|
/**
|
||||||
|
* Get a list of visible characters in the current room
|
||||||
|
*/
|
||||||
|
getVisibleCharacters(): string[];
|
||||||
|
/**
|
||||||
|
* Get the description of the current room
|
||||||
|
*/
|
||||||
|
getCurrentRoomDescription(): string;
|
||||||
|
/**
|
||||||
|
* Start the game and return the introduction text
|
||||||
|
*/
|
||||||
|
start(): Promise<string>;
|
||||||
|
/**
|
||||||
|
* End the game (cleanup resources if needed)
|
||||||
|
*/
|
||||||
|
end(): void;
|
||||||
|
/**
|
||||||
|
* Get the current room object
|
||||||
|
*/
|
||||||
|
private getCurrentRoom;
|
||||||
|
/**
|
||||||
|
* Register default action handlers
|
||||||
|
*/
|
||||||
|
private registerDefaultActionHandlers;
|
||||||
|
/**
|
||||||
|
* Find an object by name in a list of object IDs
|
||||||
|
*/
|
||||||
|
private findObjectByName;
|
||||||
|
/**
|
||||||
|
* Find a character by name in a list of character IDs
|
||||||
|
*/
|
||||||
|
private findCharacterByName;
|
||||||
|
}
|
||||||
Vendored
+607
@@ -0,0 +1,607 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Core Game Engine
|
||||||
|
* Manages game state and processes actions
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.TextAdventureEngine = void 0;
|
||||||
|
const fs = __importStar(require("fs/promises"));
|
||||||
|
const yaml_parser_1 = require("../world-model/yaml-parser");
|
||||||
|
class TextAdventureEngine {
|
||||||
|
constructor() {
|
||||||
|
this.worldModel = null;
|
||||||
|
this.gameState = null;
|
||||||
|
this.actionHandlers = {};
|
||||||
|
this.registerDefaultActionHandlers();
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Load a world model from a file
|
||||||
|
*/
|
||||||
|
async loadWorld(worldModelPath) {
|
||||||
|
try {
|
||||||
|
this.worldModel = await yaml_parser_1.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
|
||||||
|
*/
|
||||||
|
getCurrentState() {
|
||||||
|
if (!this.gameState) {
|
||||||
|
throw new Error('Game state not initialized. Please load a world first.');
|
||||||
|
}
|
||||||
|
return { ...this.gameState };
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the world model
|
||||||
|
*/
|
||||||
|
getWorldModel() {
|
||||||
|
if (!this.worldModel) {
|
||||||
|
throw new Error('World model not initialized. Please load a world first.');
|
||||||
|
}
|
||||||
|
return this.worldModel;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Process an action from the player
|
||||||
|
*/
|
||||||
|
processAction(action) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async saveGame(filename) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async loadGame(filename) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
getAvailableActions() {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
getVisibleObjects() {
|
||||||
|
if (!this.worldModel || !this.gameState)
|
||||||
|
return [];
|
||||||
|
const currentRoom = this.getCurrentRoom();
|
||||||
|
if (!currentRoom)
|
||||||
|
return [];
|
||||||
|
const visibleObjects = [...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
|
||||||
|
*/
|
||||||
|
getVisibleCharacters() {
|
||||||
|
if (!this.worldModel || !this.gameState)
|
||||||
|
return [];
|
||||||
|
const currentRoom = this.getCurrentRoom();
|
||||||
|
return currentRoom ? currentRoom.characters : [];
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the description of the current room
|
||||||
|
*/
|
||||||
|
getCurrentRoomDescription() {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async start() {
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
end() {
|
||||||
|
// Cleanup could happen here if needed
|
||||||
|
console.log('Game ended');
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Get the current room object
|
||||||
|
*/
|
||||||
|
getCurrentRoom() {
|
||||||
|
if (!this.worldModel || !this.gameState)
|
||||||
|
return null;
|
||||||
|
const roomId = this.gameState.currentRoomId;
|
||||||
|
return this.worldModel.rooms[roomId] || null;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Register default action handlers
|
||||||
|
*/
|
||||||
|
registerDefaultActionHandlers() {
|
||||||
|
// Look action
|
||||||
|
this.actionHandlers['look'] = (state, world, action) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
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) => {
|
||||||
|
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'] = () => {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
findObjectByName(name, objectIds) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
findCharacterByName(name, characterIds) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.TextAdventureEngine = TextAdventureEngine;
|
||||||
|
//# sourceMappingURL=game-engine.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
/**
|
||||||
|
* Main entry point for the AI Interactive Fiction application
|
||||||
|
*/
|
||||||
|
export {};
|
||||||
Vendored
+110
@@ -0,0 +1,110 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Main entry point for the AI Interactive Fiction application
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
const dotenv = __importStar(require("dotenv"));
|
||||||
|
const game_runner_1 = require("./cli/game-runner");
|
||||||
|
// Import the server module and the startServer function for the web interface
|
||||||
|
const server_1 = require("./server");
|
||||||
|
// 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() {
|
||||||
|
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 game_runner_1.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 (0, server_1.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);
|
||||||
|
});
|
||||||
|
//# sourceMappingURL=index.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":";AAAA;;GAEG;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAGH,+CAAiC;AACjC,mDAA+C;AAC/C,8EAA8E;AAC9E,qCAAuC;AAEvC,6BAA6B;AAC7B,OAAO,CAAC,GAAG,CAAC,kCAAkC,CAAC,CAAC;AAChD,IAAI,CAAC;IACH,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,CAAC;IAC/B,IAAI,MAAM,CAAC,KAAK,EAAE,CAAC;QACjB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,MAAM,CAAC,KAAK,CAAC,CAAC;IAC1D,CAAC;SAAM,CAAC;QACN,OAAO,CAAC,GAAG,CAAC,2CAA2C,CAAC,CAAC;IAC3D,CAAC;AACH,CAAC;AAAC,OAAO,KAAK,EAAE,CAAC;IACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;AACtD,CAAC;AAED,KAAK,UAAU,IAAI;IACjB,IAAI,CAAC;QACH,OAAO,CAAC,GAAG,CAAC,gCAAgC,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,wEAAwE,CAAC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,EAAE,CAAC,CAAC;QAEhB,oEAAoE;QACpE,MAAM,SAAS,GAAG,OAAO,CAAC,GAAG,CAAC,kBAAkB,IAAI,iCAAiC,CAAC;QACtF,OAAO,CAAC,GAAG,CAAC,qBAAqB,SAAS,EAAE,CAAC,CAAC;QAC9C,OAAO,CAAC,GAAG,CAAC,uBAAuB,OAAO,CAAC,GAAG,CAAC,kBAAkB,CAAC,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,WAAW,EAAE,CAAC,CAAC;QAC/F,OAAO,CAAC,GAAG,CAAC,qBAAqB,OAAO,CAAC,GAAG,CAAC,gBAAgB,IAAI,iBAAiB,EAAE,CAAC,CAAC;QAEtF,qCAAqC;QACrC,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QACnC,MAAM,OAAO,GAAG,IAAI,CAAC,QAAQ,CAAC,OAAO,CAAC,IAAI,IAAI,CAAC,QAAQ,CAAC,IAAI,CAAC,CAAC;QAE9D,IAAI,OAAO,EAAE,CAAC;YACZ,WAAW;YACX,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YAEvC,oCAAoC;YACpC,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;YACvC,MAAM,UAAU,GAAG,IAAI,wBAAU,EAAE,CAAC;YAEpC,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,UAAU,CAAC,SAAS,CAAC,CAAC;YAEvC,qBAAqB;YACrB,OAAO,CAAC,GAAG,CAAC,sBAAsB,CAAC,CAAC;YACpC,MAAM,UAAU,CAAC,KAAK,EAAE,CAAC;QAC3B,CAAC;aAAM,CAAC;YACN,sEAAsE;YACtE,OAAO,CAAC,GAAG,CAAC,mCAAmC,CAAC,CAAC;YAEjD,yBAAyB;YACzB,MAAM,YAAY,GAAG,IAAI,CAAC;YAC1B,MAAM,IAAI,GAAG,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,YAAY,CAAC;YAC1E,MAAM,UAAU,GAAG,EAAE,CAAC;YAEtB,0CAA0C;YAC1C,OAAO,CAAC,GAAG,CAAC,wBAAwB,CAAC,CAAC;YACtC,MAAM,IAAA,oBAAW,EAAC,IAAI,EAAE,UAAU,CAAC,CAAC;QACtC,CAAC;IACH,CAAC;IAAC,OAAO,KAAK,EAAE,CAAC;QACf,OAAO,CAAC,KAAK,CAAC,kBAAkB,EAAE,KAAK,CAAC,CAAC;QACzC,IAAI,KAAK,YAAY,KAAK,EAAE,CAAC;YAC3B,OAAO,CAAC,KAAK,CAAC,aAAa,EAAE,KAAK,CAAC,IAAI,CAAC,CAAC;YACzC,OAAO,CAAC,KAAK,CAAC,gBAAgB,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YAC/C,OAAO,CAAC,KAAK,CAAC,cAAc,EAAE,KAAK,CAAC,KAAK,CAAC,CAAC;QAC7C,CAAC;QACD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IAClB,CAAC;AACH,CAAC;AAED,wBAAwB;AACxB,OAAO,CAAC,GAAG,CAAC,yBAAyB,CAAC,CAAC;AACvC,IAAI,EAAE,CAAC,KAAK,CAAC,KAAK,CAAC,EAAE;IACnB,OAAO,CAAC,KAAK,CAAC,0BAA0B,EAAE,KAAK,CAAC,CAAC;IACjD,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;AAClB,CAAC,CAAC,CAAC"}
|
||||||
Vendored
+39
@@ -0,0 +1,39 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
processAction(action: ActionResponse): ActionResult;
|
||||||
|
saveGame(filename: string): Promise<void>;
|
||||||
|
loadGame(filename: string): Promise<void>;
|
||||||
|
getAvailableActions(): string[];
|
||||||
|
getVisibleObjects(): string[];
|
||||||
|
getVisibleCharacters(): string[];
|
||||||
|
getCurrentRoomDescription(): string;
|
||||||
|
start(): Promise<string>;
|
||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Interfaces for the game engine
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=engine.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"engine.js","sourceRoot":"","sources":["../../src/interfaces/engine.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
||||||
Vendored
+46
@@ -0,0 +1,46 @@
|
|||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
export interface NarrativeResponse {
|
||||||
|
text: string;
|
||||||
|
suggestions?: string[];
|
||||||
|
}
|
||||||
|
export interface LlmProvider {
|
||||||
|
initialize(config: LlmConfig): Promise<void>;
|
||||||
|
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
||||||
|
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Interfaces for LLM integration
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=llm.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"llm.js","sourceRoot":"","sources":["../../src/interfaces/llm.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
||||||
Vendored
+61
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* Core interfaces for the interactive fiction world model
|
||||||
|
*/
|
||||||
|
export interface Room {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
exits: Exit[];
|
||||||
|
objects: string[];
|
||||||
|
characters: string[];
|
||||||
|
}
|
||||||
|
export interface Exit {
|
||||||
|
direction: string;
|
||||||
|
targetRoomId: string;
|
||||||
|
description?: string;
|
||||||
|
isLocked?: boolean;
|
||||||
|
keyId?: string;
|
||||||
|
}
|
||||||
|
export interface GameObject {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
traits: string[];
|
||||||
|
states: Record<string, boolean>;
|
||||||
|
containedObjects?: string[];
|
||||||
|
allowedActions: string[];
|
||||||
|
}
|
||||||
|
export interface Character {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
dialogue: Record<string, string>;
|
||||||
|
inventory: string[];
|
||||||
|
defaultResponse: string;
|
||||||
|
mood?: string;
|
||||||
|
}
|
||||||
|
export interface Action {
|
||||||
|
name: string;
|
||||||
|
patterns: string[];
|
||||||
|
requiresObject?: boolean;
|
||||||
|
requiresTarget?: boolean;
|
||||||
|
handler: string;
|
||||||
|
}
|
||||||
|
export interface GameState {
|
||||||
|
currentRoomId: string;
|
||||||
|
inventory: string[];
|
||||||
|
visitedRooms: string[];
|
||||||
|
flags: Record<string, boolean>;
|
||||||
|
counters: Record<string, number>;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
Vendored
+6
@@ -0,0 +1,6 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* Core interfaces for the interactive fiction world model
|
||||||
|
*/
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
//# sourceMappingURL=world-model.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"world-model.js","sourceRoot":"","sources":["../../src/interfaces/world-model.ts"],"names":[],"mappings":";AAAA;;GAEG"}
|
||||||
Vendored
+36
@@ -0,0 +1,36 @@
|
|||||||
|
/**
|
||||||
|
* OpenRouter LLM Provider
|
||||||
|
* Handles communication with OpenRouter API for LLM interactions
|
||||||
|
*/
|
||||||
|
import { LlmProvider, LlmConfig, ActionRequest, ActionResponse, NarrativeRequest, NarrativeResponse } from '../interfaces/llm';
|
||||||
|
export declare class OpenRouterProvider implements LlmProvider {
|
||||||
|
private apiKey;
|
||||||
|
private model;
|
||||||
|
private client;
|
||||||
|
private temperature;
|
||||||
|
private maxTokens;
|
||||||
|
/**
|
||||||
|
* Initialize the OpenRouter provider with configuration
|
||||||
|
*/
|
||||||
|
initialize(config: LlmConfig): Promise<void>;
|
||||||
|
/**
|
||||||
|
* Translate player input into a structured action for the game engine
|
||||||
|
*/
|
||||||
|
translateAction(request: ActionRequest): Promise<ActionResponse>;
|
||||||
|
/**
|
||||||
|
* Generate narrative prose based on game events
|
||||||
|
*/
|
||||||
|
generateNarrative(request: NarrativeRequest): Promise<NarrativeResponse>;
|
||||||
|
/**
|
||||||
|
* Build the system and user prompts for action translation
|
||||||
|
*/
|
||||||
|
private buildActionPrompt;
|
||||||
|
/**
|
||||||
|
* Build the system and user prompts for narrative generation
|
||||||
|
*/
|
||||||
|
private buildNarrativePrompt;
|
||||||
|
/**
|
||||||
|
* Validate and normalize the action response
|
||||||
|
*/
|
||||||
|
private validateActionResponse;
|
||||||
|
}
|
||||||
Vendored
+192
@@ -0,0 +1,192 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* OpenRouter LLM Provider
|
||||||
|
* Handles communication with OpenRouter API for LLM interactions
|
||||||
|
*/
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.OpenRouterProvider = void 0;
|
||||||
|
const axios_1 = __importDefault(require("axios"));
|
||||||
|
class OpenRouterProvider {
|
||||||
|
constructor() {
|
||||||
|
this.apiKey = '';
|
||||||
|
this.model = '';
|
||||||
|
this.temperature = 0.7;
|
||||||
|
this.maxTokens = 800;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Initialize the OpenRouter provider with configuration
|
||||||
|
*/
|
||||||
|
async initialize(config) {
|
||||||
|
this.apiKey = config.apiKey;
|
||||||
|
this.model = config.model;
|
||||||
|
this.temperature = config.temperature ?? 0.7;
|
||||||
|
this.maxTokens = config.maxTokens ?? 800;
|
||||||
|
this.client = axios_1.default.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
|
||||||
|
*/
|
||||||
|
async translateAction(request) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
async generateNarrative(request) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
buildActionPrompt(request) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
buildNarrativePrompt(request) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
validateActionResponse(response) {
|
||||||
|
const validatedResponse = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
return validatedResponse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.OpenRouterProvider = OpenRouterProvider;
|
||||||
|
//# sourceMappingURL=openrouter-provider.js.map
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
{"version":3,"file":"openrouter-provider.js","sourceRoot":"","sources":["../../src/llm/openrouter-provider.ts"],"names":[],"mappings":";AAAA;;;GAGG;;;;;;AAEH,kDAA6C;AAU7C,MAAa,kBAAkB;IAA/B;QACU,WAAM,GAAW,EAAE,CAAC;QACpB,UAAK,GAAW,EAAE,CAAC;QAEnB,gBAAW,GAAW,GAAG,CAAC;QAC1B,cAAS,GAAW,GAAG,CAAC;IA+LlC,CAAC;IA7LC;;OAEG;IACI,KAAK,CAAC,UAAU,CAAC,MAAiB;QACvC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC;QAC5B,IAAI,CAAC,KAAK,GAAG,MAAM,CAAC,KAAK,CAAC;QAC1B,IAAI,CAAC,WAAW,GAAG,MAAM,CAAC,WAAW,IAAI,GAAG,CAAC;QAC7C,IAAI,CAAC,SAAS,GAAG,MAAM,CAAC,SAAS,IAAI,GAAG,CAAC;QAEzC,IAAI,CAAC,MAAM,GAAG,eAAK,CAAC,MAAM,CAAC;YACzB,OAAO,EAAE,8BAA8B;YACvC,OAAO,EAAE;gBACP,eAAe,EAAE,UAAU,IAAI,CAAC,MAAM,EAAE;gBACxC,cAAc,EAAE,kBAAkB;aACnC;SACF,CAAC,CAAC;IACL,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,eAAe,CAAC,OAAsB;QACjD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,iBAAiB,CAAC,OAAO,CAAC,CAAC;YAE/C,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;gBAC3D,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,QAAQ,EAAE;oBACR;wBACE,IAAI,EAAE,QAAQ;wBACd,OAAO,EAAE,MAAM,CAAC,MAAM;qBACvB;oBACD;wBACE,IAAI,EAAE,MAAM;wBACZ,OAAO,EAAE,MAAM,CAAC,IAAI;qBACrB;iBACF;gBACD,WAAW,EAAE,GAAG,EAAE,mDAAmD;gBACrE,UAAU,EAAE,GAAG;gBACf,eAAe,EAAE,EAAE,IAAI,EAAE,aAAa,EAAE;aACzC,CAAC,CAAC;YAEH,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;YACzD,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;YAE3C,OAAO,IAAI,CAAC,sBAAsB,CAAC,cAAc,CAAC,CAAC;QACrD,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,2BAA2B,EAAE,KAAK,CAAC,CAAC;YAClD,uDAAuD;YACvD,OAAO;gBACL,MAAM,EAAE,MAAM;gBACd,UAAU,EAAE,GAAG;aAChB,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;OAEG;IACI,KAAK,CAAC,iBAAiB,CAAC,OAAyB;QACtD,IAAI,CAAC;YACH,MAAM,MAAM,GAAG,IAAI,CAAC,oBAAoB,CAAC,OAAO,CAAC,CAAC;YAElD,MAAM,QAAQ,GAAG,MAAM,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,mBAAmB,EAAE;gBAC3D,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,QAAQ,EAAE;oBACR;wBACE,IAAI,EAAE,QAAQ;wBACd,OAAO,EAAE,MAAM,CAAC,MAAM;qBACvB;oBACD;wBACE,IAAI,EAAE,MAAM;wBACZ,OAAO,EAAE,MAAM,CAAC,IAAI;qBACrB;iBACF;gBACD,WAAW,EAAE,IAAI,CAAC,WAAW;gBAC7B,UAAU,EAAE,IAAI,CAAC,SAAS;aAC3B,CAAC,CAAC;YAEH,MAAM,OAAO,GAAG,QAAQ,CAAC,IAAI,CAAC,OAAO,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC;YAEzD,iDAAiD;YACjD,IAAI,CAAC;gBACH,MAAM,cAAc,GAAG,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC;gBAC3C,OAAO;oBACL,IAAI,EAAE,cAAc,CAAC,IAAI;oBACzB,WAAW,EAAE,cAAc,CAAC,WAAW,IAAI,EAAE;iBAC9C,CAAC;YACJ,CAAC;YAAC,MAAM,CAAC;gBACP,qDAAqD;gBACrD,OAAO;oBACL,IAAI,EAAE,OAAO;iBACd,CAAC;YACJ,CAAC;QACH,CAAC;QAAC,OAAO,KAAK,EAAE,CAAC;YACf,OAAO,CAAC,KAAK,CAAC,6BAA6B,EAAE,KAAK,CAAC,CAAC;YACpD,OAAO;gBACL,IAAI,EAAE,wEAAwE,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,CAAC,KAAK,CAAC,GAAG;aACxI,CAAC;QACJ,CAAC;IACH,CAAC;IAED;;OAEG;IACK,iBAAiB,CAAC,OAAsB;QAC9C,MAAM,YAAY,GAAG;;;kCAGS,OAAO,CAAC,WAAW;mBAClC,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;sBAC9B,OAAO,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;aAC7C,OAAO,CAAC,SAAS,CAAC,IAAI,CAAC,IAAI,CAAC;qBACpB,OAAO,CAAC,eAAe,CAAC,IAAI,CAAC,IAAI,CAAC;;gBAEvC,OAAO,CAAC,WAAW;;;;;;;;;;;gMAW6J,CAAC;QAE7L,MAAM,UAAU,GAAG,OAAO,CAAC,WAAW,CAAC;QAEvC,OAAO;YACL,MAAM,EAAE,YAAY;YACpB,IAAI,EAAE,UAAU;SACjB,CAAC;IACJ,CAAC;IAED;;OAEG;IACK,oBAAoB,CAAC,OAAyB;QACpD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,aAAa,CAAC;QAE3C,MAAM,YAAY,GAAG;;;iBAGR,IAAI;;6BAEQ,OAAO,CAAC,eAAe;mBACjC,OAAO,CAAC,cAAc,CAAC,IAAI,CAAC,IAAI,CAAC;sBAC9B,OAAO,CAAC,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC;;EAExD,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,qBAAqB,OAAO,CAAC,eAAe,EAAE,CAAC,CAAC,CAAC,EAAE;;;kFAGG,CAAC;QAE/E,MAAM,UAAU,GAAG,0CAA0C,OAAO,CAAC,MAAM;gCAC/C,OAAO,CAAC,MAAM;4DACc,CAAC;QAEzD,OAAO;YACL,MAAM,EAAE,YAAY;YACpB,IAAI,EAAE,UAAU;SACjB,CAAC;IACJ,CAAC;IAED;;OAEG;IACK,sBAAsB,CAAC,QAAiC;QAC9D,MAAM,iBAAiB,GAAmB;YACxC,MAAM,EAAE,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC,MAAM;YACtE,UAAU,EAAE,OAAO,QAAQ,CAAC,UAAU,KAAK,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,UAAU,CAAC,CAAC,CAAC,GAAG;SAChF,CAAC;QAEF,IAAI,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACxC,iBAAiB,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC7C,CAAC;QAED,IAAI,OAAO,QAAQ,CAAC,MAAM,KAAK,QAAQ,EAAE,CAAC;YACxC,iBAAiB,CAAC,MAAM,GAAG,QAAQ,CAAC,MAAM,CAAC;QAC7C,CAAC;QAED,IAAI,QAAQ,CAAC,UAAU,IAAI,OAAO,QAAQ,CAAC,UAAU,KAAK,QAAQ,EAAE,CAAC;YACnE,iBAAiB,CAAC,UAAU,GAAG,QAAQ,CAAC,UAAoC,CAAC;QAC/E,CAAC;QAED,OAAO,iBAAiB,CAAC;IAC3B,CAAC;CACF;AApMD,gDAoMC"}
|
||||||
Vendored
+11
@@ -0,0 +1,11 @@
|
|||||||
|
/**
|
||||||
|
* AI Interactive Fiction - Web Server
|
||||||
|
* Serves the web UI and handles WebSocket communication
|
||||||
|
*/
|
||||||
|
import http from 'http';
|
||||||
|
import { Server as SocketIOServer } from 'socket.io';
|
||||||
|
declare const app: import("express-serve-static-core").Express;
|
||||||
|
declare const server: http.Server<typeof http.IncomingMessage, typeof http.ServerResponse>;
|
||||||
|
declare const io: SocketIOServer<import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, import("socket.io").DefaultEventsMap, any>;
|
||||||
|
export declare function startServer(initialPort: number, range: number): Promise<void>;
|
||||||
|
export { app, server, io };
|
||||||
Vendored
+252
@@ -0,0 +1,252 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* AI Interactive Fiction - Web Server
|
||||||
|
* Serves the web UI and handles WebSocket communication
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
||||||
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
||||||
|
};
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.io = exports.server = exports.app = void 0;
|
||||||
|
exports.startServer = startServer;
|
||||||
|
const path_1 = __importDefault(require("path"));
|
||||||
|
const express_1 = __importDefault(require("express"));
|
||||||
|
const http_1 = __importDefault(require("http"));
|
||||||
|
const socket_io_1 = require("socket.io");
|
||||||
|
const dotenv = __importStar(require("dotenv"));
|
||||||
|
const game_runner_1 = require("./cli/game-runner");
|
||||||
|
const fs_1 = require("fs");
|
||||||
|
// Load environment variables
|
||||||
|
dotenv.config();
|
||||||
|
// Create Express application
|
||||||
|
const app = (0, express_1.default)();
|
||||||
|
exports.app = app;
|
||||||
|
const server = http_1.default.createServer(app);
|
||||||
|
exports.server = server;
|
||||||
|
const io = new socket_io_1.Server(server);
|
||||||
|
exports.io = io;
|
||||||
|
// 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_1.default.static(path_1.default.join(__dirname, '../public')));
|
||||||
|
// Set up game sessions
|
||||||
|
const gameSessions = new Map();
|
||||||
|
// 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 game_runner_1.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_1.default.join(__dirname, '../public'),
|
||||||
|
path_1.default.join(__dirname, '../public/js'),
|
||||||
|
path_1.default.join(__dirname, '../public/css'),
|
||||||
|
path_1.default.join(__dirname, '../public/images'),
|
||||||
|
path_1.default.join(__dirname, '../public/fonts')
|
||||||
|
];
|
||||||
|
for (const dir of dirs) {
|
||||||
|
if (!(0, fs_1.existsSync)(dir)) {
|
||||||
|
(0, fs_1.mkdirSync)(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Copy kokoro-js library from node_modules if not already present
|
||||||
|
function ensureKokoroJs() {
|
||||||
|
const source = path_1.default.join(__dirname, '../node_modules/kokoro-js/dist/index.js');
|
||||||
|
const destination = path_1.default.join(__dirname, '../public/js/kokoro-js.js');
|
||||||
|
if ((0, fs_1.existsSync)(source) && !(0, fs_1.existsSync)(destination)) {
|
||||||
|
(0, fs_1.copyFileSync)(source, destination);
|
||||||
|
console.log(`Copied kokoro-js from ${source} to ${destination}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Start the server with port fallback
|
||||||
|
async function startServer(initialPort, range) {
|
||||||
|
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((resolve, reject) => {
|
||||||
|
server.listen(currentPort, () => {
|
||||||
|
console.log(`AI Interactive Fiction web server running on http://localhost:${currentPort}`);
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
server.on('error', (error) => {
|
||||||
|
// 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
//# sourceMappingURL=server.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
Vendored
+71
@@ -0,0 +1,71 @@
|
|||||||
|
/**
|
||||||
|
* YAML World Model Parser
|
||||||
|
* Loads and validates world definitions from YAML files
|
||||||
|
*/
|
||||||
|
import { WorldModel } from '../interfaces/world-model';
|
||||||
|
export declare class YamlWorldParser {
|
||||||
|
/**
|
||||||
|
* Load a world model from a YAML file
|
||||||
|
*/
|
||||||
|
static loadFromFile(filePath: string): Promise<WorldModel>;
|
||||||
|
/**
|
||||||
|
* Validate the loaded YAML data and transform it into a WorldModel
|
||||||
|
*/
|
||||||
|
private static validateAndTransform;
|
||||||
|
/**
|
||||||
|
* Validate that an object has all required fields
|
||||||
|
*/
|
||||||
|
private static validateRequiredFields;
|
||||||
|
/**
|
||||||
|
* Validate that a value is a string
|
||||||
|
*/
|
||||||
|
private static validateString;
|
||||||
|
/**
|
||||||
|
* Validate room definitions
|
||||||
|
*/
|
||||||
|
private static validateRooms;
|
||||||
|
/**
|
||||||
|
* Validate exit definitions
|
||||||
|
*/
|
||||||
|
private static validateExits;
|
||||||
|
/**
|
||||||
|
* Validate object definitions
|
||||||
|
*/
|
||||||
|
private static validateObjects;
|
||||||
|
/**
|
||||||
|
* Validate character definitions
|
||||||
|
*/
|
||||||
|
private static validateCharacters;
|
||||||
|
/**
|
||||||
|
* Validate action definitions
|
||||||
|
*/
|
||||||
|
private static validateActions;
|
||||||
|
/**
|
||||||
|
* Validate initial game state
|
||||||
|
*/
|
||||||
|
private static validateInitialState;
|
||||||
|
/**
|
||||||
|
* Validate object states (record of boolean values)
|
||||||
|
*/
|
||||||
|
private static validateObjectStates;
|
||||||
|
/**
|
||||||
|
* Validate dialogue (record of string values)
|
||||||
|
*/
|
||||||
|
private static validateDialogue;
|
||||||
|
/**
|
||||||
|
* Validate flags (record of boolean values)
|
||||||
|
*/
|
||||||
|
private static validateFlags;
|
||||||
|
/**
|
||||||
|
* Validate counters (record of number values)
|
||||||
|
*/
|
||||||
|
private static validateCounters;
|
||||||
|
/**
|
||||||
|
* Validate that an array of strings is valid
|
||||||
|
*/
|
||||||
|
private static validateStringArray;
|
||||||
|
/**
|
||||||
|
* Validate references between entities
|
||||||
|
*/
|
||||||
|
private static validateReferences;
|
||||||
|
}
|
||||||
Vendored
+399
@@ -0,0 +1,399 @@
|
|||||||
|
"use strict";
|
||||||
|
/**
|
||||||
|
* YAML World Model Parser
|
||||||
|
* Loads and validates world definitions from YAML files
|
||||||
|
*/
|
||||||
|
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
var desc = Object.getOwnPropertyDescriptor(m, k);
|
||||||
|
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
||||||
|
desc = { enumerable: true, get: function() { return m[k]; } };
|
||||||
|
}
|
||||||
|
Object.defineProperty(o, k2, desc);
|
||||||
|
}) : (function(o, m, k, k2) {
|
||||||
|
if (k2 === undefined) k2 = k;
|
||||||
|
o[k2] = m[k];
|
||||||
|
}));
|
||||||
|
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
||||||
|
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
||||||
|
}) : function(o, v) {
|
||||||
|
o["default"] = v;
|
||||||
|
});
|
||||||
|
var __importStar = (this && this.__importStar) || (function () {
|
||||||
|
var ownKeys = function(o) {
|
||||||
|
ownKeys = Object.getOwnPropertyNames || function (o) {
|
||||||
|
var ar = [];
|
||||||
|
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
||||||
|
return ar;
|
||||||
|
};
|
||||||
|
return ownKeys(o);
|
||||||
|
};
|
||||||
|
return function (mod) {
|
||||||
|
if (mod && mod.__esModule) return mod;
|
||||||
|
var result = {};
|
||||||
|
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
||||||
|
__setModuleDefault(result, mod);
|
||||||
|
return result;
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
Object.defineProperty(exports, "__esModule", { value: true });
|
||||||
|
exports.YamlWorldParser = void 0;
|
||||||
|
const fs = __importStar(require("fs/promises"));
|
||||||
|
const yaml = __importStar(require("js-yaml"));
|
||||||
|
class YamlWorldParser {
|
||||||
|
/**
|
||||||
|
* Load a world model from a YAML file
|
||||||
|
*/
|
||||||
|
static async loadFromFile(filePath) {
|
||||||
|
try {
|
||||||
|
const fileContents = await fs.readFile(filePath, 'utf8');
|
||||||
|
const worldData = yaml.load(fileContents);
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateAndTransform(data) {
|
||||||
|
if (!data || typeof data !== 'object') {
|
||||||
|
throw new Error('Invalid world data: must be an object');
|
||||||
|
}
|
||||||
|
const worldData = data;
|
||||||
|
// Validate required top-level fields
|
||||||
|
this.validateRequiredFields(worldData, ['title', 'author', 'version', 'introduction', 'rooms', 'initialState']);
|
||||||
|
// Transform and validate the world model
|
||||||
|
const 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
|
||||||
|
*/
|
||||||
|
static validateRequiredFields(data, requiredFields) {
|
||||||
|
for (const field of requiredFields) {
|
||||||
|
if (!(field in data)) {
|
||||||
|
throw new Error(`Missing required field: ${field}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Validate that a value is a string
|
||||||
|
*/
|
||||||
|
static validateString(value, fieldName) {
|
||||||
|
if (typeof value !== 'string') {
|
||||||
|
throw new Error(`Field ${fieldName} must be a string`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
/**
|
||||||
|
* Validate room definitions
|
||||||
|
*/
|
||||||
|
static validateRooms(rooms) {
|
||||||
|
if (!rooms || typeof rooms !== 'object') {
|
||||||
|
throw new Error('Rooms must be an object mapping room IDs to room definitions');
|
||||||
|
}
|
||||||
|
const roomsData = rooms;
|
||||||
|
const validatedRooms = {};
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateExits(exits, roomId) {
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateObjects(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;
|
||||||
|
const validatedObjects = {};
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateCharacters(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;
|
||||||
|
const validatedCharacters = {};
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateActions(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;
|
||||||
|
const validatedActions = {};
|
||||||
|
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;
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateInitialState(initialState) {
|
||||||
|
if (!initialState || typeof initialState !== 'object') {
|
||||||
|
throw new Error('Initial state must be an object');
|
||||||
|
}
|
||||||
|
const stateData = initialState;
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
static validateObjectStates(states, objectId) {
|
||||||
|
if (!states)
|
||||||
|
return {};
|
||||||
|
if (typeof states !== 'object') {
|
||||||
|
throw new Error(`States for object ${objectId} must be an object`);
|
||||||
|
}
|
||||||
|
const statesData = states;
|
||||||
|
const validatedStates = {};
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
static validateDialogue(dialogue, characterId) {
|
||||||
|
if (!dialogue || typeof dialogue !== 'object') {
|
||||||
|
throw new Error(`Dialogue for character ${characterId} must be an object`);
|
||||||
|
}
|
||||||
|
const dialogueData = dialogue;
|
||||||
|
const validatedDialogue = {};
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
static validateFlags(flags) {
|
||||||
|
if (!flags)
|
||||||
|
return {};
|
||||||
|
if (typeof flags !== 'object') {
|
||||||
|
throw new Error('Flags must be an object');
|
||||||
|
}
|
||||||
|
const flagsData = flags;
|
||||||
|
const validatedFlags = {};
|
||||||
|
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)
|
||||||
|
*/
|
||||||
|
static validateCounters(counters) {
|
||||||
|
if (!counters)
|
||||||
|
return {};
|
||||||
|
if (typeof counters !== 'object') {
|
||||||
|
throw new Error('Counters must be an object');
|
||||||
|
}
|
||||||
|
const countersData = counters;
|
||||||
|
const validatedCounters = {};
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateStringArray(arr, fieldName) {
|
||||||
|
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
|
||||||
|
*/
|
||||||
|
static validateReferences(worldModel) {
|
||||||
|
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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
exports.YamlWorldParser = YamlWorldParser;
|
||||||
|
//# sourceMappingURL=yaml-parser.js.map
|
||||||
Vendored
+1
File diff suppressed because one or more lines are too long
@@ -0,0 +1,12 @@
|
|||||||
|
module.exports = {
|
||||||
|
preset: 'ts-jest',
|
||||||
|
testEnvironment: 'node',
|
||||||
|
roots: ['<rootDir>/src'],
|
||||||
|
testMatch: ['**/__tests__/**/*.ts', '**/?(*.)+(spec|test).ts'],
|
||||||
|
transform: {
|
||||||
|
'^.+\\.tsx?$': 'ts-jest',
|
||||||
|
},
|
||||||
|
collectCoverage: true,
|
||||||
|
coverageDirectory: 'coverage',
|
||||||
|
collectCoverageFrom: ['src/**/*.ts', '!src/**/*.d.ts'],
|
||||||
|
};
|
||||||
Generated
+7635
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "ai.interactive.fiction",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "A modern take on classic text adventures that combines traditional world modeling with Large Language Models (LLMs) to create natural language interactive fiction experiences.",
|
||||||
|
"main": "index.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node dist/index.js",
|
||||||
|
"start:web": "node dist/index.js",
|
||||||
|
"start:cli": "node dist/index.js --cli",
|
||||||
|
"dev": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
|
||||||
|
"dev:web": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts'",
|
||||||
|
"dev:cli": "nodemon --watch 'src/**' --ext 'ts,json' --exec 'ts-node src/index.ts --cli'",
|
||||||
|
"build": "tsc",
|
||||||
|
"test": "jest",
|
||||||
|
"lint": "eslint --ext .ts src/",
|
||||||
|
"lint:fix": "eslint --ext .ts src/ --fix",
|
||||||
|
"copy-assets": "node copy-assets.js"
|
||||||
|
},
|
||||||
|
"keywords": [],
|
||||||
|
"author": "",
|
||||||
|
"license": "ISC",
|
||||||
|
"type": "commonjs",
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/express": "^5.0.1",
|
||||||
|
"@types/jest": "^29.5.14",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
|
"@types/node": "^22.13.14",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.29.0",
|
||||||
|
"@typescript-eslint/parser": "^8.29.0",
|
||||||
|
"eslint": "^9.23.0",
|
||||||
|
"jest": "^29.7.0",
|
||||||
|
"nodemon": "^3.1.9",
|
||||||
|
"ts-jest": "^29.3.1",
|
||||||
|
"ts-node": "^10.9.2",
|
||||||
|
"typescript": "^5.8.2"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^1.8.4",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.4.7",
|
||||||
|
"express": "^5.1.0",
|
||||||
|
"js-yaml": "^4.1.0",
|
||||||
|
"kokoro-js": "^1.2.0",
|
||||||
|
"openai": "^4.91.0",
|
||||||
|
"socket.io": "^4.8.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,460 @@
|
|||||||
|
/* AI Interactive Fiction - Web UI Styles */
|
||||||
|
|
||||||
|
/* Variables */
|
||||||
|
:root {
|
||||||
|
--text-color: #222;
|
||||||
|
--background-color: #f8f4e8;
|
||||||
|
--book-shadow: rgba(0, 0, 0, 0.3);
|
||||||
|
--highlight-color: #783422;
|
||||||
|
--control-color: #555;
|
||||||
|
--light-color: rgba(255, 240, 210, 0.6);
|
||||||
|
--viewport-aspect-ratio: 1.6;
|
||||||
|
--book-width: 1000px;
|
||||||
|
--book-height: 620px;
|
||||||
|
--input-bg: rgba(255, 255, 255, 0.6);
|
||||||
|
--img-aspect-ratio: 1.613;
|
||||||
|
--aspect-ratio: min(var(--viewport-aspect-ratio), var(--img-aspect-ratio));
|
||||||
|
font-size: calc(var(--book-height)/(34 * 1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Font faces */
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("../fonts/EBGaramond12-Regular.otf") format("opentype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
@font-face {
|
||||||
|
font-family: "EB Garamond";
|
||||||
|
src: url("../fonts/EBGaramond12-Italic.otf") format("opentype");
|
||||||
|
font-weight: normal;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Global styles */
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
height: 100%;
|
||||||
|
font-family: "EB Garamond", serif;
|
||||||
|
color: var(--text-color);
|
||||||
|
background-color: #222;
|
||||||
|
background-image: url(../images/brown-wooden-flooring.jpg);
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.switched {
|
||||||
|
transition: color 0.6s, background-color 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
margin-bottom: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
text-align: center;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header {
|
||||||
|
padding-top: 3rem;
|
||||||
|
padding-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.byline {
|
||||||
|
font-feature-settings: "smcp";
|
||||||
|
}
|
||||||
|
|
||||||
|
.separator {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
p, #ruler, #indent {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: rgba(0,0,0,0.9);
|
||||||
|
margin-block-end: 0;
|
||||||
|
margin-block-start: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
transition: color 0.6s;
|
||||||
|
color: #333;
|
||||||
|
font-style: italic;
|
||||||
|
text-decoration-thickness: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a:hover {
|
||||||
|
color: black;
|
||||||
|
transition: color 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: black;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .hide {
|
||||||
|
opacity: 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container .invisible {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container > *, .container > p > * {
|
||||||
|
opacity: 1.0;
|
||||||
|
transition: opacity 0.5s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#command_input {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 1rem;
|
||||||
|
left: 3rem;
|
||||||
|
right: 3rem;
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
#player_input {
|
||||||
|
flex-grow: 1;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border: 1px solid rgba(0,0,0,0.3);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit_command {
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 1.2rem;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border: 1px solid rgba(0,0,0,0.3);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background: rgba(255,255,255,0.9);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#submit_command:hover {
|
||||||
|
background: rgba(255,255,255,1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls {
|
||||||
|
z-index: 4;
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
left: 0;
|
||||||
|
top: 1rem;
|
||||||
|
padding-top: 1rem;
|
||||||
|
user-select: none;
|
||||||
|
transition: color 0.6s, background 0.6s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls [disabled] {
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls input[type=range] {
|
||||||
|
vertical-align: middle;
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
width: 5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
height: 0.5rem;
|
||||||
|
background-color: transparent;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid black;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls input::-webkit-slider-thumb {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 0.5rem;
|
||||||
|
width: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
background-color: rgba(0,0,0,0.9);
|
||||||
|
border: none;
|
||||||
|
box-shadow: -407px 0 0 400px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls input::-webkit-runnable-track {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
appearance: none;
|
||||||
|
height: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#controls>a:not(:last-of-type):after, #controls>span::after {
|
||||||
|
content: " | ";
|
||||||
|
}
|
||||||
|
|
||||||
|
#book {
|
||||||
|
position: relative;
|
||||||
|
width: var(--book-width);
|
||||||
|
height: var(--book-height);
|
||||||
|
background-image: url('../images/book-3057904.png');
|
||||||
|
background-size: contain;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
perspective: 500px;
|
||||||
|
perspective-origin: 50% 50%;
|
||||||
|
max-width: 90vw;
|
||||||
|
max-height: 90vh;
|
||||||
|
margin: 0 auto;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page_left, #page_right {
|
||||||
|
position: absolute;
|
||||||
|
top: 5%;
|
||||||
|
bottom: 10%;
|
||||||
|
width: 39%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 0 3rem 1rem 1rem;
|
||||||
|
overflow: visible;
|
||||||
|
overflow-y: scroll;
|
||||||
|
opacity: 0.95;
|
||||||
|
mix-blend-mode: darken;
|
||||||
|
}
|
||||||
|
|
||||||
|
#story {
|
||||||
|
overflow-x: visible;
|
||||||
|
margin-bottom: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page_left {
|
||||||
|
left: 11.5%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page_right {
|
||||||
|
right: 7%;
|
||||||
|
height: calc(28 * 1.2 * 1.2rem);
|
||||||
|
padding-bottom: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-input {
|
||||||
|
font-style: italic;
|
||||||
|
color: #555;
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.narrative {
|
||||||
|
margin-top: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Scrollbar CSS ===== */
|
||||||
|
/* Firefox */
|
||||||
|
* {
|
||||||
|
scrollbar-width: auto;
|
||||||
|
scrollbar-color: #000000 rgba(255,255,255,0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Chrome, Edge, and Safari */
|
||||||
|
*::-webkit-scrollbar {
|
||||||
|
width: calc(1rem/4);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-track {
|
||||||
|
background: rgba(255,255,255,0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
*::-webkit-scrollbar-thumb {
|
||||||
|
background-color: #000000;
|
||||||
|
border-radius: calc(1rem/4/2);
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fade-in {
|
||||||
|
animation: fadeIn ease 1s;
|
||||||
|
-webkit-animation: fadeIn ease 1s;
|
||||||
|
-moz-animation: fadeIn ease 1s;
|
||||||
|
-o-animation: fadeIn ease 1s;
|
||||||
|
-ms-animation: fadeIn ease 1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
0% {opacity:0;}
|
||||||
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-moz-keyframes fadeIn {
|
||||||
|
0% {opacity:0;}
|
||||||
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-webkit-keyframes fadeIn {
|
||||||
|
0% {opacity:0;}
|
||||||
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-o-keyframes fadeIn {
|
||||||
|
0% {opacity:0;}
|
||||||
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
@-ms-keyframes fadeIn {
|
||||||
|
0% {opacity:0;}
|
||||||
|
100% {opacity:1;}
|
||||||
|
}
|
||||||
|
|
||||||
|
#ruler, #indent {
|
||||||
|
visibility: hidden;
|
||||||
|
position: absolute;
|
||||||
|
top: -8000px;
|
||||||
|
width: auto;
|
||||||
|
display: inline;
|
||||||
|
left: -8000px;
|
||||||
|
text-indent: 0;
|
||||||
|
text-align: left;
|
||||||
|
hyphens: none;
|
||||||
|
margin-block-end: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
#lighting {
|
||||||
|
position: absolute;
|
||||||
|
top: -35%;
|
||||||
|
left: -35%;
|
||||||
|
width: 180%;
|
||||||
|
height: 180%;
|
||||||
|
animation: gradient-animation-shrink 1s 1;
|
||||||
|
background: radial-gradient(circle, rgba(255,240,182,0.1) 0%, rgba(255,237,165,0.2) 20%, rgba(0,0,0,0.9) 65%, rgba(0,0,0,0.9) 100%);
|
||||||
|
mix-blend-mode: color-burn;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 999;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-animation-grow {
|
||||||
|
0% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
||||||
|
100% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes gradient-animation-shrink {
|
||||||
|
0% { width: 170%; height: 170%; left: -33%; top: -33%; }
|
||||||
|
100% { width: 180%; height: 180%; left: -35%; top: -35%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-indicator {
|
||||||
|
display: inline-block;
|
||||||
|
position: relative;
|
||||||
|
width: 1.2rem;
|
||||||
|
height: 1.2rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
.loading-indicator div {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: block;
|
||||||
|
position: absolute;
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
border: 0.2rem solid #000;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: loading-indicator 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
|
||||||
|
border-color: #000 transparent transparent transparent;
|
||||||
|
}
|
||||||
|
.loading-indicator div:nth-child(1) {
|
||||||
|
animation-delay: -0.45s;
|
||||||
|
}
|
||||||
|
.loading-indicator div:nth-child(2) {
|
||||||
|
animation-delay: -0.3s;
|
||||||
|
}
|
||||||
|
.loading-indicator div:nth-child(3) {
|
||||||
|
animation-delay: -0.15s;
|
||||||
|
}
|
||||||
|
@keyframes loading-indicator {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Media queries for responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
:root {
|
||||||
|
font-size: calc(var(--book-height)/(40 * 1.5));
|
||||||
|
}
|
||||||
|
|
||||||
|
#book {
|
||||||
|
max-width: 95vw;
|
||||||
|
max-height: 95vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
#page_left, #page_right {
|
||||||
|
width: 38%;
|
||||||
|
padding: 0 1rem 1rem 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure responsive book sizing */
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.95);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.85);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.75);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.65);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Additional responsive fix to ensure book remains centered */
|
||||||
|
@media (max-height: 700px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.8);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 600px) {
|
||||||
|
#book {
|
||||||
|
transform: scale(0.7);
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 2.0 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 2.5 MiB |
@@ -0,0 +1,43 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>AI Interactive Fiction</title>
|
||||||
|
<link rel="stylesheet" href="css/style.css">
|
||||||
|
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
||||||
|
<script src="js/smartypants.js"></script>
|
||||||
|
<script src="js/tts-handler.js"></script>
|
||||||
|
<script src="js/ai-fiction.js"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="book">
|
||||||
|
<div id="lighting"></div>
|
||||||
|
<div id="page_left">
|
||||||
|
<div class="header">
|
||||||
|
<h1>AI Interactive Fiction</h1>
|
||||||
|
<h2 class="byline">A modern take on text adventures</h2>
|
||||||
|
</div>
|
||||||
|
<div class="separator">
|
||||||
|
<double class="separator">⋆</double>
|
||||||
|
</div>
|
||||||
|
<div id="story"></div>
|
||||||
|
</div>
|
||||||
|
<div id="page_right">
|
||||||
|
<div id="controls">
|
||||||
|
<a href="#" id="rewind">Restart</a>
|
||||||
|
<a href="#" id="speech">Speech</a>
|
||||||
|
<span>Speed <input type="range" id="speed" min="0" max="100" value="50"> <a href="#" id="speed_reset">Reset</a></span>
|
||||||
|
<a href="#" id="save">Save</a>
|
||||||
|
<a href="#" id="reload">Load</a>
|
||||||
|
</div>
|
||||||
|
<div id="command_history"></div>
|
||||||
|
<div id="command_input">
|
||||||
|
<input type="text" id="player_input" placeholder="Enter your command..." autocomplete="off">
|
||||||
|
<button id="submit_command">▶</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="ruler"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,440 @@
|
|||||||
|
/**
|
||||||
|
* AI Interactive Fiction
|
||||||
|
* Main client-side logic for web interface
|
||||||
|
*/
|
||||||
|
class AIFiction {
|
||||||
|
constructor() {
|
||||||
|
// DOM elements
|
||||||
|
this.storyContainer = document.getElementById('story');
|
||||||
|
this.commandHistoryContainer = document.getElementById('command_history');
|
||||||
|
this.playerInput = document.getElementById('player_input');
|
||||||
|
this.submitButton = document.getElementById('submit_command');
|
||||||
|
this.speechButton = document.getElementById('speech');
|
||||||
|
this.rewindButton = document.getElementById('rewind');
|
||||||
|
this.saveButton = document.getElementById('save');
|
||||||
|
this.loadButton = document.getElementById('reload');
|
||||||
|
this.speedSlider = document.getElementById('speed');
|
||||||
|
this.speedReset = document.getElementById('speed_reset');
|
||||||
|
|
||||||
|
// Game state
|
||||||
|
this.gameState = {
|
||||||
|
started: false,
|
||||||
|
currentRoomId: '',
|
||||||
|
textSpeed: 50
|
||||||
|
};
|
||||||
|
|
||||||
|
// Socket connection - ensure we're connecting to the right URL
|
||||||
|
this.socket = io(window.location.origin, {
|
||||||
|
reconnectionAttempts: 5,
|
||||||
|
timeout: 10000
|
||||||
|
});
|
||||||
|
|
||||||
|
// Typing effect configuration
|
||||||
|
this.typingSpeed = 30; // Default value, will be adjusted by slider
|
||||||
|
this.typingTimeout = null;
|
||||||
|
|
||||||
|
// Bind event handlers
|
||||||
|
this.bindEvents();
|
||||||
|
|
||||||
|
// Initialize socket communication
|
||||||
|
this.initializeSocket();
|
||||||
|
|
||||||
|
// Initialize UI
|
||||||
|
this.initializeUI();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the UI
|
||||||
|
*/
|
||||||
|
initializeUI() {
|
||||||
|
this.updateTypingSpeed();
|
||||||
|
this.updateSpeechButton();
|
||||||
|
|
||||||
|
// Disable buttons initially
|
||||||
|
this.rewindButton.setAttribute('disabled', 'disabled');
|
||||||
|
this.loadButton.setAttribute('disabled', 'disabled');
|
||||||
|
|
||||||
|
// Start the game
|
||||||
|
this.startGame();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event handlers to DOM elements
|
||||||
|
*/
|
||||||
|
bindEvents() {
|
||||||
|
// Submit command on button click
|
||||||
|
this.submitButton.addEventListener('click', () => this.submitCommand());
|
||||||
|
|
||||||
|
// Submit command on Enter key
|
||||||
|
this.playerInput.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
this.submitCommand();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Toggle speech
|
||||||
|
this.speechButton.addEventListener('click', () => {
|
||||||
|
if (tts.isReady()) {
|
||||||
|
const enabled = tts.toggle();
|
||||||
|
this.updateSpeechButton(enabled);
|
||||||
|
|
||||||
|
if (enabled) {
|
||||||
|
// Speak the last narrative if speech was just enabled
|
||||||
|
const lastNarrative = this.storyContainer.lastElementChild;
|
||||||
|
if (lastNarrative && lastNarrative.classList.contains('narrative')) {
|
||||||
|
tts.speak(lastNarrative.textContent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.log('TTS not ready yet');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Restart game
|
||||||
|
this.rewindButton.addEventListener('click', () => {
|
||||||
|
if (confirm('Are you sure you want to restart the game? All progress will be lost.')) {
|
||||||
|
this.startGame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save game
|
||||||
|
this.saveButton.addEventListener('click', () => {
|
||||||
|
this.socket.emit('saveGame');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Load game
|
||||||
|
this.loadButton.addEventListener('click', () => {
|
||||||
|
this.socket.emit('loadGame');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Adjust typing speed
|
||||||
|
this.speedSlider.addEventListener('input', () => {
|
||||||
|
this.updateTypingSpeed();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reset speed to default
|
||||||
|
this.speedReset.addEventListener('click', () => {
|
||||||
|
this.speedSlider.value = 50;
|
||||||
|
this.updateTypingSpeed();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize socket event handlers
|
||||||
|
*/
|
||||||
|
initializeSocket() {
|
||||||
|
// Connection established
|
||||||
|
this.socket.on('connect', () => {
|
||||||
|
console.log('Connected to server');
|
||||||
|
// Automatically start the game once connected
|
||||||
|
if (!this.gameState.started) {
|
||||||
|
this.startGame();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Connection error
|
||||||
|
this.socket.on('connect_error', (error) => {
|
||||||
|
console.error('Connection error:', error);
|
||||||
|
this.addSystemMessage('Connection error. Please check your network connection and try again.');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Game introduction received
|
||||||
|
this.socket.on('gameIntroduction', (data) => {
|
||||||
|
this.clearStory();
|
||||||
|
this.addNarrative(data.introduction);
|
||||||
|
this.addNarrative(data.initialRoomDescription);
|
||||||
|
|
||||||
|
this.gameState.started = true;
|
||||||
|
this.gameState.currentRoomId = data.currentRoomId;
|
||||||
|
|
||||||
|
// Enable buttons
|
||||||
|
this.rewindButton.removeAttribute('disabled');
|
||||||
|
|
||||||
|
// Focus on input field
|
||||||
|
this.playerInput.focus();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Narrative response received
|
||||||
|
this.socket.on('narrativeResponse', (data) => {
|
||||||
|
this.addNarrative(data.text);
|
||||||
|
|
||||||
|
if (data.suggestions && data.suggestions.length > 0) {
|
||||||
|
this.addSuggestions(data.suggestions);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update game state
|
||||||
|
if (data.gameState) {
|
||||||
|
this.gameState.currentRoomId = data.gameState.currentRoomId;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll to bottom and focus input
|
||||||
|
this.scrollToBottom();
|
||||||
|
this.playerInput.focus();
|
||||||
|
|
||||||
|
// Re-enable input (failsafe)
|
||||||
|
this.playerInput.disabled = false;
|
||||||
|
this.submitButton.disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Game saved confirmation
|
||||||
|
this.socket.on('gameSaved', () => {
|
||||||
|
this.addSystemMessage('Game saved successfully.');
|
||||||
|
// Enable load button
|
||||||
|
this.loadButton.removeAttribute('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Game loaded confirmation
|
||||||
|
this.socket.on('gameLoaded', (data) => {
|
||||||
|
this.clearStory();
|
||||||
|
this.addSystemMessage('Game loaded successfully.');
|
||||||
|
this.addNarrative(data.currentRoomDescription);
|
||||||
|
|
||||||
|
// Update game state
|
||||||
|
this.gameState.currentRoomId = data.currentRoomId;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Error messages
|
||||||
|
this.socket.on('error', (data) => {
|
||||||
|
this.addSystemMessage(`Error: ${data.message}`);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start a new game
|
||||||
|
*/
|
||||||
|
startGame() {
|
||||||
|
this.clearStory();
|
||||||
|
this.addSystemMessage('Starting a new game...');
|
||||||
|
this.socket.emit('startGame');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Submit a player command
|
||||||
|
*/
|
||||||
|
submitCommand() {
|
||||||
|
const command = this.playerInput.value.trim();
|
||||||
|
|
||||||
|
if (command === '') return;
|
||||||
|
|
||||||
|
// Disable input temporarily
|
||||||
|
this.playerInput.disabled = true;
|
||||||
|
this.submitButton.disabled = true;
|
||||||
|
|
||||||
|
// Add command to history
|
||||||
|
this.addUserCommand(command);
|
||||||
|
|
||||||
|
// Add a temporary "thinking" message
|
||||||
|
const thinkingId = this.addThinking();
|
||||||
|
|
||||||
|
// Send command to server
|
||||||
|
this.socket.emit('playerCommand', { command });
|
||||||
|
|
||||||
|
// Clear input
|
||||||
|
this.playerInput.value = '';
|
||||||
|
|
||||||
|
// Re-enable input field after a short delay (or after 8 seconds as failsafe)
|
||||||
|
const timeout = setTimeout(() => {
|
||||||
|
this.playerInput.disabled = false;
|
||||||
|
this.submitButton.disabled = false;
|
||||||
|
|
||||||
|
// Remove thinking indicator
|
||||||
|
const thinkingElement = document.getElementById(thinkingId);
|
||||||
|
if (thinkingElement) {
|
||||||
|
thinkingElement.remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add system message if no response was received (likely timeout)
|
||||||
|
if (document.getElementById(thinkingId)) {
|
||||||
|
this.addSystemMessage('The server is taking too long to respond. Please try again.');
|
||||||
|
}
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
// Store the timeout so it can be cleared if we get a response
|
||||||
|
this.currentCommandTimeout = timeout;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a user command to the story
|
||||||
|
*/
|
||||||
|
addUserCommand(command) {
|
||||||
|
const element = document.createElement('p');
|
||||||
|
element.className = 'user-input';
|
||||||
|
element.textContent = `> ${command}`;
|
||||||
|
this.storyContainer.appendChild(element);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a narrative response with typing effect
|
||||||
|
*/
|
||||||
|
addNarrative(text) {
|
||||||
|
const element = document.createElement('p');
|
||||||
|
element.className = 'narrative hide';
|
||||||
|
this.storyContainer.appendChild(element);
|
||||||
|
|
||||||
|
// Apply SmartyPants transformations for better typography
|
||||||
|
const processedText = SmartyPants.process(text);
|
||||||
|
|
||||||
|
// Clear any existing typing timeouts
|
||||||
|
if (this.typingTimeout) {
|
||||||
|
clearTimeout(this.typingTimeout);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add the text with a typing effect
|
||||||
|
this.typeText(element, processedText, 0);
|
||||||
|
|
||||||
|
// Read text aloud if speech is enabled
|
||||||
|
if (tts && tts.enabled) {
|
||||||
|
tts.speak(text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add suggestions to the story
|
||||||
|
*/
|
||||||
|
addSuggestions(suggestions) {
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.className = 'suggestions';
|
||||||
|
|
||||||
|
const heading = document.createElement('p');
|
||||||
|
heading.textContent = 'Suggestions:';
|
||||||
|
heading.style.fontStyle = 'italic';
|
||||||
|
heading.style.marginTop = '1rem';
|
||||||
|
element.appendChild(heading);
|
||||||
|
|
||||||
|
const list = document.createElement('ul');
|
||||||
|
suggestions.forEach(suggestion => {
|
||||||
|
const item = document.createElement('li');
|
||||||
|
item.textContent = suggestion;
|
||||||
|
|
||||||
|
// Make suggestions clickable
|
||||||
|
item.style.cursor = 'pointer';
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
this.playerInput.value = suggestion;
|
||||||
|
this.submitCommand();
|
||||||
|
});
|
||||||
|
|
||||||
|
list.appendChild(item);
|
||||||
|
});
|
||||||
|
element.appendChild(list);
|
||||||
|
|
||||||
|
this.storyContainer.appendChild(element);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a system message
|
||||||
|
*/
|
||||||
|
addSystemMessage(message) {
|
||||||
|
const element = document.createElement('p');
|
||||||
|
element.className = 'system-message';
|
||||||
|
element.textContent = message;
|
||||||
|
element.style.fontStyle = 'italic';
|
||||||
|
element.style.color = '#555';
|
||||||
|
this.storyContainer.appendChild(element);
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a thinking indicator
|
||||||
|
*/
|
||||||
|
addThinking() {
|
||||||
|
const id = 'thinking-' + Date.now();
|
||||||
|
const element = document.createElement('div');
|
||||||
|
element.id = id;
|
||||||
|
element.className = 'thinking';
|
||||||
|
element.innerHTML = '<p>Thinking<span class="loading-indicator"><div></div><div></div><div></div><div></div></span></p>';
|
||||||
|
element.style.fontStyle = 'italic';
|
||||||
|
element.style.color = '#777';
|
||||||
|
this.storyContainer.appendChild(element);
|
||||||
|
this.scrollToBottom();
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the story container
|
||||||
|
*/
|
||||||
|
clearStory() {
|
||||||
|
while (this.storyContainer.firstChild) {
|
||||||
|
this.storyContainer.removeChild(this.storyContainer.firstChild);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Type text into an element character by character
|
||||||
|
*/
|
||||||
|
typeText(element, text, index) {
|
||||||
|
// Show the element if it was hidden
|
||||||
|
if (index === 0) {
|
||||||
|
element.classList.remove('hide');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the current text
|
||||||
|
element.textContent = text.substring(0, index);
|
||||||
|
|
||||||
|
// If we haven't reached the end of the text
|
||||||
|
if (index < text.length) {
|
||||||
|
// Calculate delay (randomize slightly for more natural effect)
|
||||||
|
const delay = Math.max(10, 100 - this.gameState.textSpeed) / 5;
|
||||||
|
const randomDelay = delay * (0.8 + Math.random() * 0.4);
|
||||||
|
|
||||||
|
// Schedule the next character
|
||||||
|
this.typingTimeout = setTimeout(() => {
|
||||||
|
this.typeText(element, text, index + 1);
|
||||||
|
}, randomDelay);
|
||||||
|
} else {
|
||||||
|
// Finished typing
|
||||||
|
this.scrollToBottom();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the typing speed based on the slider value
|
||||||
|
*/
|
||||||
|
updateTypingSpeed() {
|
||||||
|
this.gameState.textSpeed = parseInt(this.speedSlider.value, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the speech button styling
|
||||||
|
*/
|
||||||
|
updateSpeechButton(enabled = false) {
|
||||||
|
if (enabled) {
|
||||||
|
this.speechButton.style.fontWeight = 'bold';
|
||||||
|
this.speechButton.style.color = '#000';
|
||||||
|
} else {
|
||||||
|
this.speechButton.style.fontWeight = 'normal';
|
||||||
|
this.speechButton.style.color = '#333';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scroll the story container to the bottom
|
||||||
|
*/
|
||||||
|
scrollToBottom() {
|
||||||
|
this.storyContainer.scrollTop = this.storyContainer.scrollHeight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the application when the DOM is fully loaded
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
// Set custom CSS variables based on viewport
|
||||||
|
const updateViewportVariables = () => {
|
||||||
|
const vw = window.innerWidth;
|
||||||
|
const vh = window.innerHeight;
|
||||||
|
document.documentElement.style.setProperty('--viewport-aspect-ratio', `${vw / vh}`);
|
||||||
|
|
||||||
|
// Adjust book size based on viewport
|
||||||
|
const bookWidth = Math.min(vw * 0.9, vh * 1.4);
|
||||||
|
const bookHeight = bookWidth / 1.613;
|
||||||
|
document.documentElement.style.setProperty('--book-width', `${bookWidth}px`);
|
||||||
|
document.documentElement.style.setProperty('--book-height', `${bookHeight}px`);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Update variables initially and on resize
|
||||||
|
updateViewportVariables();
|
||||||
|
window.addEventListener('resize', updateViewportVariables);
|
||||||
|
|
||||||
|
// Initialize the application
|
||||||
|
window.app = new AIFiction();
|
||||||
|
});
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* SmartyPants - Smart typography for web content
|
||||||
|
* Converts straight quotes to curly quotes, dashes to em-dashes, etc.
|
||||||
|
* Based on the original SmartyPants by John Gruber
|
||||||
|
*/
|
||||||
|
|
||||||
|
const SmartyPants = (function() {
|
||||||
|
// Regular expressions for matching
|
||||||
|
const quotes = {
|
||||||
|
double: {
|
||||||
|
opening: /(\s|^)"(\w)/g,
|
||||||
|
closing: /(\w)"/g,
|
||||||
|
openingNested: /(\s|^)'(\w)/g,
|
||||||
|
closingNested: /(\w)'/g
|
||||||
|
},
|
||||||
|
single: {
|
||||||
|
opening: /(\s|^)'(\w)/g,
|
||||||
|
closing: /(\w)'/g
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dashes = {
|
||||||
|
emDash: /--/g,
|
||||||
|
enDash: / - /g
|
||||||
|
};
|
||||||
|
|
||||||
|
const ellipses = /\.\.\./g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process text with SmartyPants transformations
|
||||||
|
*/
|
||||||
|
function process(text) {
|
||||||
|
if (!text) return text;
|
||||||
|
|
||||||
|
let result = text;
|
||||||
|
|
||||||
|
// Transform double quotes
|
||||||
|
result = result.replace(quotes.double.opening, '$1"$2');
|
||||||
|
result = result.replace(quotes.double.closing, '$1"');
|
||||||
|
|
||||||
|
// Transform single quotes
|
||||||
|
result = result.replace(quotes.single.opening, '$1\u2018$2');
|
||||||
|
result = result.replace(quotes.single.closing, '$1\u2019');
|
||||||
|
|
||||||
|
// Transform apostrophes (same as closing single quotes)
|
||||||
|
result = result.replace(/(\w)'(\w)/g, '$1\u2019$2');
|
||||||
|
|
||||||
|
// Transform dashes
|
||||||
|
result = result.replace(dashes.emDash, '—');
|
||||||
|
result = result.replace(dashes.enDash, ' – ');
|
||||||
|
|
||||||
|
// Transform ellipses
|
||||||
|
result = result.replace(ellipses, '…');
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
process: process
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Make available in browser and Node.js environments
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = SmartyPants;
|
||||||
|
}
|
||||||
@@ -0,0 +1,106 @@
|
|||||||
|
/**
|
||||||
|
* Text-to-Speech Handler for AI Interactive Fiction
|
||||||
|
* Uses Web Speech API for text-to-speech
|
||||||
|
*/
|
||||||
|
class TTSHandler {
|
||||||
|
constructor() {
|
||||||
|
this.enabled = false;
|
||||||
|
this.speaking = false;
|
||||||
|
this.queue = [];
|
||||||
|
this.synthesis = window.speechSynthesis;
|
||||||
|
this.utterance = null;
|
||||||
|
|
||||||
|
// Check if browser supports speech synthesis
|
||||||
|
if (this.synthesis) {
|
||||||
|
console.log('Speech synthesis is supported in this browser');
|
||||||
|
this.browserSupport = true;
|
||||||
|
} else {
|
||||||
|
console.warn('Speech synthesis is not supported in this browser');
|
||||||
|
this.browserSupport = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Toggle TTS on/off
|
||||||
|
*/
|
||||||
|
toggle() {
|
||||||
|
this.enabled = !this.enabled;
|
||||||
|
if (!this.enabled && this.speaking) {
|
||||||
|
this.stop();
|
||||||
|
}
|
||||||
|
return this.enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Speak the given text
|
||||||
|
*/
|
||||||
|
speak(text) {
|
||||||
|
if (!this.enabled || !this.browserSupport) return;
|
||||||
|
|
||||||
|
// Add to queue
|
||||||
|
this.queue.push(text);
|
||||||
|
|
||||||
|
// If not already speaking, start processing queue
|
||||||
|
if (!this.speaking) {
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process the speech queue
|
||||||
|
*/
|
||||||
|
processQueue() {
|
||||||
|
if (this.queue.length === 0 || this.speaking) return;
|
||||||
|
|
||||||
|
this.speaking = true;
|
||||||
|
const text = this.queue.shift();
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.utterance = new SpeechSynthesisUtterance(text);
|
||||||
|
|
||||||
|
// Configure speech options
|
||||||
|
this.utterance.rate = 1.0; // Speech rate (0.1 to 10)
|
||||||
|
this.utterance.pitch = 1.0; // Speech pitch (0 to 2)
|
||||||
|
|
||||||
|
// When speech ends, process the next item
|
||||||
|
this.utterance.onend = () => {
|
||||||
|
this.speaking = false;
|
||||||
|
this.processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
// If speech is interrupted or errors
|
||||||
|
this.utterance.onerror = (event) => {
|
||||||
|
console.error('TTS error:', event.error);
|
||||||
|
this.speaking = false;
|
||||||
|
this.processQueue();
|
||||||
|
};
|
||||||
|
|
||||||
|
this.synthesis.speak(this.utterance);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('TTS error:', error);
|
||||||
|
this.speaking = false;
|
||||||
|
this.processQueue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop current speech
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.synthesis && this.speaking) {
|
||||||
|
this.synthesis.cancel();
|
||||||
|
}
|
||||||
|
this.queue = [];
|
||||||
|
this.speaking = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if TTS is ready
|
||||||
|
*/
|
||||||
|
isReady() {
|
||||||
|
return this.browserSupport;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a global instance
|
||||||
|
const tts = new TTSHandler();
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +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);
|
||||||
|
});
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
@@ -0,0 +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>;
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
@@ -0,0 +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;
|
||||||
|
}
|
||||||
|
}
|
||||||
+246
@@ -0,0 +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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { app, server, io };
|
||||||
@@ -0,0 +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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "es2020",
|
||||||
|
"module": "commonjs",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"outDir": "./dist",
|
||||||
|
"strict": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"declaration": true,
|
||||||
|
"lib": ["es2020", "dom"],
|
||||||
|
"moduleResolution": "node"
|
||||||
|
},
|
||||||
|
"include": ["src/**/*"],
|
||||||
|
"exclude": ["node_modules", "**/*.test.ts"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user