Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
69ba824bc8 | ||
|
|
bd211b6036 | ||
|
|
f85c27a742 |
No files matched your search
+39
@@ -0,0 +1,39 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
*.egg-info/
|
||||
.eggs/
|
||||
dist/
|
||||
build/
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
.venv/
|
||||
env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Data and logs (runtime generated)
|
||||
data/
|
||||
*.log
|
||||
|
||||
# Database files
|
||||
*.db
|
||||
|
||||
# Credentials - NEVER commit
|
||||
config.yaml
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Coverage reports
|
||||
htmlcov/
|
||||
.coverage
|
||||
.coverage.*
|
||||
+151
@@ -0,0 +1,151 @@
|
||||
# MUD Test Servers - Quick Start Guide
|
||||
|
||||
## ✅ L'applicazione funziona!
|
||||
|
||||
L'errore che vedi è **normale** - stai usando il config di esempio con `mud.example.com` che non esiste.
|
||||
|
||||
## MUD Pubblici per Testing
|
||||
|
||||
Ecco alcuni MUD pubblici che puoi usare per testare l'applicazione:
|
||||
|
||||
### 1. Aardwolf MUD (Consigliato per testing)
|
||||
```yaml
|
||||
mud:
|
||||
host: "aardmud.org"
|
||||
port: 23
|
||||
username: "" # Crea account al primo login
|
||||
password: ""
|
||||
```
|
||||
|
||||
**Caratteristiche:**
|
||||
- Molto popolare e stabile
|
||||
- Accetta nuovi giocatori
|
||||
- Buona documentazione
|
||||
- Supporta ANSI colors
|
||||
|
||||
### 2. Discworld MUD
|
||||
```yaml
|
||||
mud:
|
||||
host: "discworld.starturtle.net"
|
||||
port: 23
|
||||
username: ""
|
||||
password: ""
|
||||
```
|
||||
|
||||
### 3. Alter Aeon
|
||||
```yaml
|
||||
mud:
|
||||
host: "alteraeon.com"
|
||||
port: 3010
|
||||
username: ""
|
||||
password: ""
|
||||
```
|
||||
|
||||
### 4. Materia Magica
|
||||
```yaml
|
||||
mud:
|
||||
host: "materiamagica.com"
|
||||
port: 4000
|
||||
username: ""
|
||||
password: ""
|
||||
```
|
||||
|
||||
## Come Configurare
|
||||
|
||||
### Opzione 1: Modifica config.yaml
|
||||
|
||||
Apri `config.yaml` e modifica la sezione `mud`:
|
||||
|
||||
```yaml
|
||||
mud:
|
||||
host: "aardmud.org" # Cambia questo
|
||||
port: 23 # E questo
|
||||
username: "" # Lascia vuoto per nuovo account
|
||||
password: ""
|
||||
```
|
||||
|
||||
### Opzione 2: Crea config personalizzato
|
||||
|
||||
```powershell
|
||||
# Copia il config di esempio
|
||||
Copy-Item config.yaml test_config.yaml
|
||||
|
||||
# Modifica test_config.yaml con il tuo editor preferito
|
||||
# Poi esegui con:
|
||||
python -m src.main --config test_config.yaml
|
||||
```
|
||||
|
||||
## Test Rapido con Aardwolf
|
||||
|
||||
1. **Modifica config.yaml**:
|
||||
```yaml
|
||||
mud:
|
||||
host: "aardmud.org"
|
||||
port: 23
|
||||
username: ""
|
||||
password: ""
|
||||
```
|
||||
|
||||
2. **Esegui l'applicazione**:
|
||||
```powershell
|
||||
.\venv\Scripts\Activate.ps1
|
||||
python -m src.main
|
||||
```
|
||||
|
||||
3. **Cosa aspettarsi**:
|
||||
- Connessione al server ✅
|
||||
- Ricezione del messaggio di benvenuto
|
||||
- Prompt per creazione personaggio
|
||||
- (L'AI non è ancora implementata completamente, quindi vedrai solo i log)
|
||||
|
||||
## Modalità Debug
|
||||
|
||||
Per vedere più dettagli durante la connessione, modifica in `config.yaml`:
|
||||
|
||||
```yaml
|
||||
logging:
|
||||
level: "DEBUG" # Cambia da INFO a DEBUG
|
||||
console: true
|
||||
```
|
||||
|
||||
## Creazione Account Manuale
|
||||
|
||||
Se vuoi prima creare un account manualmente:
|
||||
|
||||
```powershell
|
||||
# Usa telnet per connetterti
|
||||
telnet aardmud.org 23
|
||||
|
||||
# Oppure usa un client MUD come MUSHclient o Mudlet
|
||||
```
|
||||
|
||||
Poi inserisci le credenziali in `config.yaml`.
|
||||
|
||||
## Prossimi Passi
|
||||
|
||||
Una volta connesso con successo:
|
||||
|
||||
1. ✅ **Connessione funzionante** - Vedrai i log di connessione
|
||||
2. ⏭️ **Implementare Character Creator** - Per creare automaticamente il personaggio
|
||||
3. ⏭️ **Implementare Text Parser** - Per interpretare i comandi del MUD
|
||||
4. ⏭️ **Implementare Explorer** - Per iniziare l'esplorazione
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Firewall
|
||||
Se hai problemi di connessione, verifica che il firewall non blocchi le connessioni Telnet (porta 23 o 4000).
|
||||
|
||||
### Timeout
|
||||
Alcuni MUD potrebbero avere timeout. Aumenta il timeout in `telnet_client.py` se necessario.
|
||||
|
||||
### ANSI Colors
|
||||
Se vedi caratteri strani, il parser ANSI potrebbe aver bisogno di aggiustamenti per quel MUD specifico.
|
||||
|
||||
## Note Importanti
|
||||
|
||||
⚠️ **Rispetta le regole del MUD**: Molti MUD hanno regole contro i bot. Usa questa applicazione solo per:
|
||||
- Testing e sviluppo
|
||||
- MUD che permettono bot
|
||||
- Scopi educativi
|
||||
|
||||
⚠️ **Non abusare**: Implementa sempre i delay umani e non sovraccaricare il server.
|
||||
@@ -1,134 +1,134 @@
|
||||
# MUD AI Player
|
||||
|
||||
An autonomous AI player for MUD (Multi-User Dungeon) text-based games.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Autonomous Gameplay**: Simulates a human player from character creation to endgame
|
||||
- 🗺️ **Auto-Mapping**: Builds a complete map of the MUD world with hybrid storage (SQLite + Memvid)
|
||||
- ⚔️ **Combat AI**: Intelligent combat decisions, target selection, and survival tactics
|
||||
- 📜 **Quest System**: Automatically detects, plans, and completes quests
|
||||
- 🔍 **Semantic Search**: Find locations using natural language queries
|
||||
- 📊 **Progression Optimizer**: Finds optimal grinding spots and equipment upgrades
|
||||
|
||||
## Architecture
|
||||
|
||||
The application is built with a modular architecture:
|
||||
|
||||
- **Core**: Telnet client, ANSI parser, text parser, game state
|
||||
- **Modules**:
|
||||
- Character Creator: Stats rolling, class/race selection
|
||||
- Auto-Mapper: World mapping with hybrid storage (SQLite + Memvid)
|
||||
- Decision Engine: AI decision-making, combat AI, goal management
|
||||
- Quest System: Quest detection, planning, execution
|
||||
- Progression Optimizer: Grinding optimization, equipment analysis
|
||||
|
||||
## Installation
|
||||
|
||||
### Using Poetry (Recommended)
|
||||
|
||||
```bash
|
||||
# Install Poetry if not already installed
|
||||
pip install poetry
|
||||
|
||||
# Install dependencies
|
||||
poetry install
|
||||
|
||||
# Activate virtual environment
|
||||
poetry shell
|
||||
```
|
||||
|
||||
### Using pip
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy the example configuration:
|
||||
```bash
|
||||
cp examples/config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
2. Edit `config.yaml` with your MUD server details:
|
||||
```yaml
|
||||
mud:
|
||||
host: "your-mud-server.com"
|
||||
port: 4000
|
||||
username: "your_username"
|
||||
password: "your_password"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run the AI player
|
||||
python -m src.main
|
||||
|
||||
# Run with custom config
|
||||
python -m src.main --config custom_config.yaml
|
||||
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mud-ai-player/
|
||||
├── src/ # Source code
|
||||
│ ├── core/ # Core components
|
||||
│ ├── modules/ # Specialized modules
|
||||
│ ├── utils/ # Utilities
|
||||
│ └── config/ # Configuration management
|
||||
├── data/ # Persistent storage
|
||||
│ ├── maps/ # World maps (SQLite + Memvid)
|
||||
│ ├── characters/ # Character saves
|
||||
│ └── logs/ # Game logs
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
└── examples/ # Example configurations
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint code
|
||||
ruff check src/ tests/
|
||||
|
||||
# Run tests with watch
|
||||
pytest-watch
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See the `docs/` directory for detailed documentation:
|
||||
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Configuration Guide](docs/configuration.md)
|
||||
- [Development Guide](docs/development.md)
|
||||
- [API Reference](docs/api_reference.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read the development guide before submitting PRs.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an educational project. Please respect the rules and terms of service of any MUD you connect to. Some MUDs prohibit automated players.
|
||||
# MUD AI Player
|
||||
|
||||
An autonomous AI player for MUD (Multi-User Dungeon) text-based games.
|
||||
|
||||
## Features
|
||||
|
||||
- 🤖 **Autonomous Gameplay**: Simulates a human player from character creation to endgame
|
||||
- 🗺️ **Auto-Mapping**: Builds a complete map of the MUD world with hybrid storage (SQLite + Memvid)
|
||||
- ⚔️ **Combat AI**: Intelligent combat decisions, target selection, and survival tactics
|
||||
- 📜 **Quest System**: Automatically detects, plans, and completes quests
|
||||
- 🔍 **Semantic Search**: Find locations using natural language queries
|
||||
- 📊 **Progression Optimizer**: Finds optimal grinding spots and equipment upgrades
|
||||
|
||||
## Architecture
|
||||
|
||||
The application is built with a modular architecture:
|
||||
|
||||
- **Core**: Telnet client, ANSI parser, text parser, game state
|
||||
- **Modules**:
|
||||
- Character Creator: Stats rolling, class/race selection
|
||||
- Auto-Mapper: World mapping with hybrid storage (SQLite + Memvid)
|
||||
- Decision Engine: AI decision-making, combat AI, goal management
|
||||
- Quest System: Quest detection, planning, execution
|
||||
- Progression Optimizer: Grinding optimization, equipment analysis
|
||||
|
||||
## Installation
|
||||
|
||||
### Using Poetry (Recommended)
|
||||
|
||||
```bash
|
||||
# Install Poetry if not already installed
|
||||
pip install poetry
|
||||
|
||||
# Install dependencies
|
||||
poetry install
|
||||
|
||||
# Activate virtual environment
|
||||
poetry shell
|
||||
```
|
||||
|
||||
### Using pip
|
||||
|
||||
```bash
|
||||
# Create virtual environment
|
||||
python -m venv venv
|
||||
source venv/bin/activate # On Windows: venv\Scripts\activate
|
||||
|
||||
# Install dependencies
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
1. Copy the example configuration:
|
||||
```bash
|
||||
cp examples/config.example.yaml config.yaml
|
||||
```
|
||||
|
||||
2. Edit `config.yaml` with your MUD server details:
|
||||
```yaml
|
||||
mud:
|
||||
host: "your-mud-server.com"
|
||||
port: 4000
|
||||
username: "your_username"
|
||||
password: "your_password"
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```bash
|
||||
# Run the AI player
|
||||
python -m src.main
|
||||
|
||||
# Run with custom config
|
||||
python -m src.main --config custom_config.yaml
|
||||
|
||||
# Run tests
|
||||
pytest tests/ -v
|
||||
|
||||
# Run with coverage
|
||||
pytest --cov=src --cov-report=html
|
||||
```
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
mud-ai-player/
|
||||
├── src/ # Source code
|
||||
│ ├── core/ # Core components
|
||||
│ ├── modules/ # Specialized modules
|
||||
│ ├── utils/ # Utilities
|
||||
│ └── config/ # Configuration management
|
||||
├── data/ # Persistent storage
|
||||
│ ├── maps/ # World maps (SQLite + Memvid)
|
||||
│ ├── characters/ # Character saves
|
||||
│ └── logs/ # Game logs
|
||||
├── tests/ # Test suite
|
||||
├── docs/ # Documentation
|
||||
└── examples/ # Example configurations
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Format code
|
||||
black src/ tests/
|
||||
|
||||
# Lint code
|
||||
ruff check src/ tests/
|
||||
|
||||
# Run tests with watch
|
||||
pytest-watch
|
||||
```
|
||||
|
||||
## Documentation
|
||||
|
||||
See the `docs/` directory for detailed documentation:
|
||||
|
||||
- [Architecture](docs/architecture.md)
|
||||
- [Configuration Guide](docs/configuration.md)
|
||||
- [Development Guide](docs/development.md)
|
||||
- [API Reference](docs/api_reference.md)
|
||||
|
||||
## License
|
||||
|
||||
MIT License
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please read the development guide before submitting PRs.
|
||||
|
||||
## Disclaimer
|
||||
|
||||
This is an educational project. Please respect the rules and terms of service of any MUD you connect to. Some MUDs prohibit automated players.
|
||||
@@ -0,0 +1,82 @@
|
||||
# MUD AI Player - Implementation Roadmap
|
||||
|
||||
This document outlines the tasks required to build a complete, autonomous AI player for LPMUDs.
|
||||
|
||||
## 🟢 Phase 1: Core Connectivity & Basic Setup (Current Status)
|
||||
|
||||
- [x] **Telnet Client**: Basic connection and disconnection handling.
|
||||
- [x] **Configuration**: YAML-based config for credentials and server info.
|
||||
- [x] **Automatic Login**:
|
||||
- [x] Username/Password automtation.
|
||||
- [x] "Take over existing session" handling.
|
||||
- [x] **KeepAlive Manager**: Prevent idle disconnects.
|
||||
- [ ] **Logging**:
|
||||
- [x] Setup basic logging.
|
||||
- [ ] Implement strict `game.log` rotation and archiving structure.
|
||||
|
||||
## 🟡 Phase 2: Perception (Parsing & Sensing)
|
||||
|
||||
The bot needs to "see" the world effectively.
|
||||
|
||||
- [ ] **ANSI Parsing**:
|
||||
- [ ] Refine `ANSIParser` to fully strip color codes for logic but preserve them for UI if needed.
|
||||
- [ ] Detect prompt lines vs. content lines.
|
||||
- [ ] **Status Parsing (Vitals)**:
|
||||
- [ ] Implement regex to capture HP/MP/SP from prompt (e.g., `hp: 350/350`).
|
||||
- [ ] Update `GameState.character` with real-time stats.
|
||||
- [ ] **Room Parsing (Vision)**:
|
||||
- [ ] Identify Room Title (usually colored/bold).
|
||||
- [ ] Identify Room Description (multiline text).
|
||||
- [ ] Identify Exits (e.g., `[ Exits: n s w ]`).
|
||||
- [ ] Identify NPCs/Mobs/Items in the room.
|
||||
- [ ] **Inventory & Equipment**:
|
||||
- [ ] Implement `inv` command parser.
|
||||
- [ ] Implement `eq` command parser.
|
||||
|
||||
## 🟠 Phase 3: World Modeling (Mapping)
|
||||
|
||||
The bot needs to know where it is and where it has been.
|
||||
|
||||
- [ ] **Room Hashing**:
|
||||
- [ ] Create unique hash for rooms based on Title + Description + Exits (to handle non-unique IDs).
|
||||
- [ ] **Graph Database**:
|
||||
- [ ] Integrate `NetworkX` or SQLite for storing the world map.
|
||||
- [ ] Store nodes (Rooms) and edges (Exits).
|
||||
- [ ] **Pathfinding**:
|
||||
- [ ] Implement A* algorithm to find shortest path between known rooms.
|
||||
- [ ] Implement "Breadth-First Search" (BFS) for exploration mode.
|
||||
|
||||
## 🔴 Phase 4: Decision Engine (The Brain)
|
||||
|
||||
The bot needs to decide what to do next.
|
||||
|
||||
- [ ] **Action Queue System**:
|
||||
- [ ] Priority queue for actions (e.g., `HEAL` > `ATTACK` > `LOOT` > `MOVE`).
|
||||
- [ ] **Behavior Trees / State Machine**:
|
||||
- [ ] **IdleState**: Heal, Buff, Check Inventory.
|
||||
- [ ] **ExploreState**: Pick unknown exit, Move.
|
||||
- [ ] **CombatState**:
|
||||
- [ ] Auto-target hostile mobs.
|
||||
- [ ] Execute combat rotation (skills/spells).
|
||||
- [ ] Flee trigger (if HP < Threshold).
|
||||
- [ ] **LootState**: reliable looting of corpses.
|
||||
- [ ] **LLM Integration (Optional/Hybrid)**:
|
||||
- [ ] Use LLM for "roleplay" responses to other players.
|
||||
- [ ] Use LLM for "quest parsing" (understanding vague instructions).
|
||||
|
||||
## 🔵 Phase 5: Humanization
|
||||
|
||||
Make the bot undetectable and less robotic.
|
||||
|
||||
- [ ] **Reaction Time Jitter**:
|
||||
- [ ] Randomize delays between commands (e.g., 200ms - 1500ms).
|
||||
- [ ] **Typo Generator**:
|
||||
- [ ] Occasionally misspell commands and correct them (optional).
|
||||
- [ ] **Schedule System**:
|
||||
- [ ] Define "active hours" and "sleep hours".
|
||||
|
||||
## 🟣 Phase 6: Testing & Metrics
|
||||
|
||||
- [ ] **Unit Tests**: Coverage for parsers (critical).
|
||||
- [ ] **Integration Tests**: Mock MUD server for offline testing.
|
||||
- [ ] **Performance Profiling**: Ensure graph traversal doesn't lag the bot.
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
# Virtual Environment Setup
|
||||
|
||||
## ✅ Virtual Environment Creato
|
||||
|
||||
Il virtual environment è stato creato con successo in `venv/` e tutte le dipendenze sono state installate.
|
||||
|
||||
## Come Attivare il Virtual Environment
|
||||
|
||||
### Windows PowerShell
|
||||
```powershell
|
||||
.\venv\Scripts\Activate.ps1
|
||||
```
|
||||
|
||||
### Windows CMD
|
||||
```cmd
|
||||
.\venv\Scripts\activate.bat
|
||||
```
|
||||
|
||||
### Linux/Mac
|
||||
```bash
|
||||
source venv/bin/activate
|
||||
```
|
||||
|
||||
## Verifica Installazione
|
||||
|
||||
Dopo aver attivato il venv, verifica che tutto sia installato:
|
||||
|
||||
```powershell
|
||||
# Attiva venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# Verifica pacchetti installati
|
||||
pip list
|
||||
|
||||
# Verifica versione Python
|
||||
python --version
|
||||
```
|
||||
|
||||
## Pacchetti Installati
|
||||
|
||||
Tutti i pacchetti da `requirements.txt` sono stati installati:
|
||||
|
||||
- ✅ `telnetlib3` - Client Telnet asincrono
|
||||
- ✅ `networkx` - Gestione grafi per auto-mapping
|
||||
- ✅ `sqlalchemy` - ORM per database
|
||||
- ✅ `pydantic` - Validazione configurazione
|
||||
- ✅ `rich` - Output formattato console
|
||||
- ✅ `memvid` - Encoding QR-in-MP4
|
||||
- ✅ `faiss-cpu` - Vector similarity search
|
||||
- ✅ `sentence-transformers` - Text embeddings
|
||||
- ✅ `torch` - Deep learning framework (dipendenza)
|
||||
- ✅ `pytest` - Testing framework
|
||||
- ✅ `pytest-asyncio` - Async testing
|
||||
- ✅ `pytest-cov` - Coverage reporting
|
||||
|
||||
## Eseguire l'Applicazione
|
||||
|
||||
```powershell
|
||||
# Attiva venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# Esegui l'applicazione
|
||||
python -m src.main
|
||||
|
||||
# Oppure con config personalizzato
|
||||
python -m src.main --config my_config.yaml
|
||||
```
|
||||
|
||||
## Eseguire i Test
|
||||
|
||||
```powershell
|
||||
# Attiva venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# Esegui tutti i test
|
||||
pytest tests/ -v
|
||||
|
||||
# Esegui con coverage
|
||||
pytest --cov=src --cov-report=html
|
||||
|
||||
# Esegui test specifico
|
||||
pytest tests/test_parser.py -v
|
||||
```
|
||||
|
||||
## Disattivare il Virtual Environment
|
||||
|
||||
Quando hai finito di lavorare:
|
||||
|
||||
```powershell
|
||||
deactivate
|
||||
```
|
||||
|
||||
## Aggiungere Nuove Dipendenze
|
||||
|
||||
Se devi aggiungere nuove dipendenze:
|
||||
|
||||
```powershell
|
||||
# Attiva venv
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# Installa nuovo pacchetto
|
||||
pip install nome-pacchetto
|
||||
|
||||
# Aggiorna requirements.txt
|
||||
pip freeze > requirements.txt
|
||||
```
|
||||
|
||||
## Ricreare il Virtual Environment
|
||||
|
||||
Se hai problemi, puoi ricreare il venv:
|
||||
|
||||
```powershell
|
||||
# Rimuovi venv esistente
|
||||
Remove-Item -Recurse -Force venv
|
||||
|
||||
# Crea nuovo venv
|
||||
python -m venv venv
|
||||
|
||||
# Attiva
|
||||
.\venv\Scripts\Activate.ps1
|
||||
|
||||
# Reinstalla dipendenze
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
## Note
|
||||
|
||||
- Il virtual environment è **gitignored** (vedi `.gitignore`)
|
||||
- Ogni sviluppatore deve creare il proprio venv locale
|
||||
- Non committare mai la cartella `venv/` su git
|
||||
- Usa sempre il venv quando lavori sul progetto
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Errore "Execution Policy"
|
||||
|
||||
Se ricevi un errore di execution policy in PowerShell:
|
||||
|
||||
```powershell
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
### Pacchetti Mancanti
|
||||
|
||||
Se mancano pacchetti:
|
||||
|
||||
```powershell
|
||||
.\venv\Scripts\Activate.ps1
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
### Versione Python Sbagliata
|
||||
|
||||
Assicurati di usare Python 3.10+:
|
||||
|
||||
```powershell
|
||||
python --version
|
||||
```
|
||||
|
||||
Se hai una versione più vecchia, installa Python 3.10 o superiore.
|
||||
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
|
||||
|
||||
# conosci i dale mud ? vorrei fare un brainstorming per vedere come andrebbe cvreato un app che simula interagendo con un dale mud nella simulazione di un personaggio giocante ai dale mud dalla scelta del personaggio e delle caratteristiche , poi al gioco vero oe proprio, leggendo le istruzioni a schermo e le descrizioni delle varie arre e interagendo con i personaggi del dale mud e giocando e facendo il automapping, fino a far crescere il personaggio facendo exp, e quest, e quindi una simulazione in toto del giocateore di da dalemud.
|
||||
|
||||
Sì, conosco bene i **MUD** (Multi-User Dungeon) - quei giochi di ruolo testuali multiplayer via Telnet che hanno ispirato gli MMORPG moderni. L'idea di creare un'app che simuli un "giocatore AI" di MUD è affascinante, un'intersezione tra **agente AI autonomo** e **retro-gaming**. Ti propongo un brainstorming strutturato per l'architettura di questa app.
|
||||
|
||||
## Architettura generale
|
||||
|
||||
L'app dovrebbe essere composta da diversi moduli specializzati che comunicano tra loro, simulando il "cervello" di un giocatore umano:
|
||||
|
||||
### Modulo 1: Parser \& Interpret (l'occhio sullo schermo)
|
||||
|
||||
Questo modulo deve interpretare il testo grezzo che arriva dal MUD e trasformarlo in una struttura dati comprensibile per l'AI.
|
||||
|
||||
- **Telnet Client**: Connessione al server MUD (handshake, gestione colori ANSI, MCCP per compressione)[^1][^2]
|
||||
- **NLP Layer**: Estrazione di informazioni chiave dalle descrizioni (stanza corrente, NPC presenti, oggetti, direzioni disponibili, stato del personaggio HP/MP/EXP)
|
||||
- **Stato di gioco**: Modello dati che rappresenta la "memoria a breve termine" del giocatore (dove sono, cosa vedo, che mob c'è)
|
||||
|
||||
|
||||
### Modulo 2: Character Creator (la creazione del personaggio)
|
||||
|
||||
Simulation della fase di rolling iniziale.
|
||||
|
||||
- **Roller automatico**: Simulare il "roll" delle statistiche (STR, INT, WIS, DEX, CON, CHA) ottimizzando per la classe scelta[^3]
|
||||
- **Scelta classe/razza**: Logica di decisione basata su preferenze (meele vs caster, tank vs dps)
|
||||
- **Allocazione skill**: Decisione su come spendere i punti iniziali
|
||||
|
||||
|
||||
### Modulo 3: Auto-Mapper (la memoria spaziale)
|
||||
|
||||
Una componente critica per la navigazione autonoma.
|
||||
|
||||
- **Grafo delle stanze**: Ogni stanza è un nodo, le direzioni (n/s/e/w/u/d) sono gli archi
|
||||
- **Room ID detection**: Identificare univocamente ogni stanza (anche se la descrizione è simile, usando esiti dei comandi LOOK + coordinate relative)[^2]
|
||||
- **Mapping esplorativo**: Esplorare sistematicamente aree sconosciute, marcare zone già visitate, rilevare loop
|
||||
- **Pathfinding**: Calcolo del percorso più breve tra punto A e punto B (utile per tornare da un healer o raggiungere un quest-giver)
|
||||
|
||||
|
||||
### Modulo 4: Decision Engine (il cervello strategico)
|
||||
|
||||
Il core dell'intelligenza artificiale del giocatore.
|
||||
|
||||
- **Goal Manager**: Gestione degli obiettivi a breve e lungo termine (leveling, quest, grinding, looting)
|
||||
- **Combat AI**: Decisioni in combattimento (quando attaccare, quando fuggire, quando curarsi, quale mob scegliere in base al livello)
|
||||
- **Social AI**: Interazione con altri giocatori (commercio, gruppi, comunicazione)
|
||||
- **Economy AI**: Gestione dell'inventario, vendita loot, acquisto equipaggiamento, bilancio del gold
|
||||
|
||||
|
||||
### Modulo 5: Quest System (il diary delle quest)
|
||||
|
||||
Gestione delle missioni in stile MUD classico.
|
||||
|
||||
- **Quest tracker**: Parsing delle descrizioni quest ("trova l'anello nel cimitero", "uccidi 5 goblin")
|
||||
- **Obiettivi**: Scomporre la quest in task atomici (naviga verso zona X → trova mob Y → combatti → loota → ritorna)
|
||||
- **Prioritizzazione**: Valutare EXP/time ratio delle quest disponibili
|
||||
|
||||
|
||||
### Modulo 6: Progression Optimizer (il min-maxer)
|
||||
|
||||
La logica di crescita del personaggio.
|
||||
|
||||
- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
|
||||
- **Equip optimizer**: Confrontare gear basato su stat bonus
|
||||
- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
|
||||
|
||||
|
||||
## Flusso di gioco simulato
|
||||
|
||||
```
|
||||
1. CONNESSIONE → Telnet al server, autenticazione
|
||||
2. CREAZIONE → Roll stats, scegli classe, setup iniziale
|
||||
3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
|
||||
4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
|
||||
5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
|
||||
6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
|
||||
```
|
||||
|
||||
|
||||
## Stack tecnico suggerito
|
||||
|
||||
- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
|
||||
- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
|
||||
- **Grafo**: NetworkX per l'auto-mapping
|
||||
- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
|
||||
- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
|
||||
|
||||
|
||||
## Sfide interessanti
|
||||
|
||||
- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
|
||||
- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
|
||||
- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
|
||||
|
||||
Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
|
||||
<span style="display:none">[^10][^11][^12][^13][^14][^15][^4][^5][^6][^7][^8][^9]</span>
|
||||
|
||||
<div align="center">⁂</div>
|
||||
|
||||
[^1]: https://www.reddit.com/r/MUD/comments/ke91kh/can_you_play_a_mud_over_just_sshtelnet/
|
||||
|
||||
[^2]: https://www.reddit.com/r/MUD/comments/15mt7pu/telnet_protocol/
|
||||
|
||||
[^3]: http://www.aardmud.org/creation.html
|
||||
|
||||
[^4]: https://en.wikipedia.org/wiki/Multi-user_dungeon
|
||||
|
||||
[^5]: https://www.livinginternet.com/d/di_major.htm
|
||||
|
||||
[^6]: https://www.landley.net/history/mirror/games/mud-history.html
|
||||
|
||||
[^7]: https://www.oreateai.com/blog/the-origin-and-cultural-impact-of-the-mud-game-the-ancestor-of-mmorpgs/dcbad95e5ff1728227e53e52772ade9a
|
||||
|
||||
[^8]: https://if50.substack.com/p/1980-mud
|
||||
|
||||
[^9]: https://www.reddit.com/r/MUD/comments/1jktmhd/creating_a_mud/
|
||||
|
||||
[^10]: https://www.gamedev.net/forums/topic/401912-making-a-telnet-based-game/
|
||||
|
||||
[^11]: https://www.reddit.com/r/MUD/comments/ew4gfe/history_of_muds_question/
|
||||
|
||||
[^12]: https://www.reddit.com/r/rpg/comments/1s0yxg/best_character_creation_layout_mechanics/
|
||||
|
||||
[^13]: https://www.linnaean.org/~lpb/muddex/mudline.html
|
||||
|
||||
[^14]: https://www.linnaean.org/~lpb/muddex/essay/
|
||||
|
||||
[^15]: https://akurosia.github.io/DevFFXIVPocketGuide/dalamud/
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
# MUD AI Player - Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The MUD AI Player is designed with a modular architecture that separates concerns into specialized components. This document describes the high-level architecture and design decisions.
|
||||
|
||||
## System Architecture
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Main Application │
|
||||
│ (main.py) │
|
||||
└────────────────────┬────────────────────────────────────┘
|
||||
│
|
||||
┌────────────┴────────────┐
|
||||
│ │
|
||||
┌───────▼────────┐ ┌────────▼────────┐
|
||||
│ Core Layer │ │ Modules Layer │
|
||||
│ │ │ │
|
||||
│ - Telnet │ │ - Character │
|
||||
│ - Parsers │ │ - Mapper │
|
||||
│ - Game State │ │ - Decision │
|
||||
└────────┬───────┘ │ - Quest │
|
||||
│ │ - Progression │
|
||||
│ └────────┬────────┘
|
||||
│ │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌──────────▼──────────┐
|
||||
│ Storage Layer │
|
||||
│ │
|
||||
│ - SQLite (Active) │
|
||||
│ - Memvid (Archive) │
|
||||
│ - FAISS (Search) │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
## Core Components
|
||||
|
||||
### Telnet Client
|
||||
- Asynchronous connection to MUD server
|
||||
- Handles reconnection and error recovery
|
||||
- Rate limiting for commands
|
||||
|
||||
### Parsers
|
||||
- **ANSI Parser**: Strips/interprets color codes
|
||||
- **Text Parser**: Extracts structured data from MUD output
|
||||
- Pattern matching for rooms, NPCs, items, status
|
||||
|
||||
### Game State
|
||||
- Central state management
|
||||
- Character, room, inventory, quests
|
||||
- Event system for state changes
|
||||
|
||||
## Specialized Modules
|
||||
|
||||
### Auto-Mapper (Hybrid Storage)
|
||||
|
||||
**Layer 1: Active Storage**
|
||||
- SQLite database for real-time data
|
||||
- NetworkX graph in memory
|
||||
- <5ms insert latency
|
||||
- Fast pathfinding
|
||||
|
||||
**Layer 2: Historical Storage**
|
||||
- Memvid encoding (QR-in-MP4)
|
||||
- 50-100x compression
|
||||
- Periodic snapshots
|
||||
- Long-term archival
|
||||
|
||||
**Layer 3: Semantic Search**
|
||||
- FAISS vector search
|
||||
- Sentence transformers
|
||||
- Natural language queries
|
||||
|
||||
### Decision Engine
|
||||
- Goal-based AI
|
||||
- State machine for behaviors
|
||||
- Pluggable architecture (rule-based vs LLM)
|
||||
|
||||
### Combat AI
|
||||
- Target selection
|
||||
- Skill rotation
|
||||
- Flee logic
|
||||
- Health management
|
||||
|
||||
## Data Flow
|
||||
|
||||
```
|
||||
MUD Server
|
||||
↓
|
||||
Telnet Client
|
||||
↓
|
||||
ANSI Parser → Text Parser
|
||||
↓
|
||||
Game State Update
|
||||
↓
|
||||
Decision Engine
|
||||
↓
|
||||
Action Selection
|
||||
↓
|
||||
Command Execution
|
||||
↓
|
||||
MUD Server
|
||||
```
|
||||
|
||||
## Design Patterns
|
||||
|
||||
### Strategy Pattern
|
||||
- AI engine (rule-based vs LLM)
|
||||
- Exploration strategy (DFS vs BFS)
|
||||
- Combat strategy (aggressive vs cautious)
|
||||
|
||||
### Observer Pattern
|
||||
- Game state changes trigger module updates
|
||||
- Event-driven architecture
|
||||
|
||||
### Repository Pattern
|
||||
- Active storage for real-time data
|
||||
- Historical storage for archival
|
||||
- Abstraction over storage details
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- **Async/Await**: All I/O operations are non-blocking
|
||||
- **In-Memory Graph**: Fast pathfinding without DB queries
|
||||
- **Lazy Loading**: Historical data loaded on demand
|
||||
- **Connection Pooling**: Reuse database connections
|
||||
|
||||
## Extensibility
|
||||
|
||||
The architecture is designed for easy extension:
|
||||
|
||||
1. **New Modules**: Add to `src/modules/`
|
||||
2. **New Parsers**: Extend `TextParser` for MUD-specific patterns
|
||||
3. **New AI Engines**: Implement `DecisionEngine` interface
|
||||
4. **New Storage**: Implement storage interface
|
||||
|
||||
## Security & Safety
|
||||
|
||||
- Credentials in config.yaml (gitignored)
|
||||
- Rate limiting to avoid server bans
|
||||
- Human behavior simulation
|
||||
- Graceful error handling
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
- Multi-character support
|
||||
- Web dashboard for monitoring
|
||||
- Plugin system for custom behaviors
|
||||
- Distributed mapping (share maps between instances)
|
||||
@@ -0,0 +1,54 @@
|
||||
# MUD AI Player Configuration Example
|
||||
# Copy this file to config.yaml and customize
|
||||
|
||||
mud:
|
||||
host: "mud.example.com"
|
||||
port: 4000
|
||||
mud_type: "dead_souls" # dead_souls, aardwolf, etc.
|
||||
username: "your_username"
|
||||
password: "your_password"
|
||||
|
||||
character:
|
||||
name: "AIPlayer"
|
||||
preferred_class: "warrior"
|
||||
preferred_race: "human"
|
||||
play_style: "balanced"
|
||||
|
||||
ai:
|
||||
engine: "rule_based"
|
||||
llm_model: "llama3.2"
|
||||
decision_delay_ms: [200, 2000]
|
||||
exploration_strategy: "depth_first"
|
||||
|
||||
mapper:
|
||||
active_storage:
|
||||
db_path: "data/maps/active/world.db"
|
||||
graph_cache_size: 10000
|
||||
|
||||
historical_storage:
|
||||
archive_dir: "data/maps/archive"
|
||||
snapshot_triggers:
|
||||
room_count: 500
|
||||
session_end: true
|
||||
time_interval_hours: 24
|
||||
compression:
|
||||
fps: 30
|
||||
codec: "h264"
|
||||
|
||||
semantic_search:
|
||||
model: "all-MiniLM-L6-v2"
|
||||
top_k: 5
|
||||
similarity_threshold: 0.7
|
||||
|
||||
behavior:
|
||||
reaction_time_min_ms: 200
|
||||
reaction_time_max_ms: 2000
|
||||
pause_probability: 0.05
|
||||
typo_probability: 0.0
|
||||
|
||||
logging:
|
||||
level: "INFO"
|
||||
file: "data/logs/game.log"
|
||||
console: true
|
||||
max_file_size_mb: 10
|
||||
backup_count: 5
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
in questo vorrei proporre:
|
||||
Interessante! Questo approccio esiste già ed è chiamato Memvid - una libreria Python open-source che implementa esattamente il flusso che descrivi. Vediamo se ha senso per il tuo automapper MUD.
|
||||
|
||||
C'è già: Memvid
|
||||
Memvid è un progetto GitHub (8.1k stars) che codifica chunk di testo in QR code, li salva come frame in un file MP4 e usa un indice FAISS per la ricerca semantica. Il flusso è identico al tuo:
|
||||
|
||||
text
|
||||
Text → chunk → embed → QR code → Frame MP4 (H.264/H.265)
|
||||
Query → embed → FAISS cosine similarity → frame seek → decode QR → return text
|
||||
Benefici confermati
|
||||
Vantaggio Dettaglio Fonte
|
||||
Compressione 50-100x I codec video moderni comprimono pattern ripetitivi (i QR) meglio di qualsiasi algoritmo custom
|
||||
|
||||
Sub-100ms retrieval Seek diretto al frame tramite indice, O(1), poi decodifica QR
|
||||
Zero infrastruttura Solo Python + MP4, niente cluster DB, niente Docker
|
||||
|
||||
Portabilità totale Copia memoria.mp4 su un altro sistema, funziona ovunque
|
||||
|
||||
Offline-first Dopo l'encoding, tutto funziona senza internet
|
||||
|
||||
Benchmark reali
|
||||
100MB di testo → 1-2MB video
|
||||
|
||||
|
||||
Ricerca su 1M chunk in <100ms (MacBook Pro 2021)
|
||||
|
||||
|
||||
RAM costante: ~500MB indipendentemente dalla dimensione
|
||||
|
||||
|
||||
Il problema per il tuo use case
|
||||
L'automapper di un MUD ha una caratteristica critica: aggiornamenti continui mentre esplori. E qui Memvid mostra i limiti:
|
||||
|
||||
|
||||
Write Amplification
|
||||
I file MP4 sono append-only. Se stai esplorando una nuova stanza nel MUD e vuoi aggiungerla alla mappa, non puoi semplicemente "inserire" un frame nel mezzo. Devi:
|
||||
|
||||
Decodificare tutto il video esistente
|
||||
|
||||
Aggiungere il nuovo chunk
|
||||
|
||||
Re-encodare l'intero MP4
|
||||
|
||||
Questo è killer per un'esperienza real-time dove ogni nuova stanza dovrebbe essere memorizzata immediatamente.
|
||||
|
||||
Sync tra indice e video
|
||||
L'indice FAISS è un JSON separato che mappa embedding → numero frame. Se il video viene rigenerato, l'indice deve essere rigenerato. In uno scenario MUD con esplorazione continua, staresti re-encodando ogni volta che muovi il personaggio in una stanza nuova.
|
||||
|
||||
Soluzione ibrida suggerita
|
||||
Per l'automapper MUD, ti propongo un archivio a due livelli:
|
||||
|
||||
Layer 1: Memoria attiva (SQLite/grafico in-memory)
|
||||
Durante l'esplorazione: usa SQLite o un grafo NetworkX in RAM
|
||||
|
||||
Ogni nuova stanza viene inserita immediatamente
|
||||
|
||||
Navigazione, pathfinding, ricerca in tempo reale
|
||||
|
||||
Layer 2: Memoria storica (Memvid)
|
||||
Periodicamente (alla fine sessione, o ogni X stanze): "congela" lo stato esplorato
|
||||
|
||||
Dump del grafo → Memvid MP4
|
||||
|
||||
Questo diventa l'archivio storico della mappa del mondo MUD
|
||||
|
||||
Per ricerche semantiche ("dove era quell'NPC goblin?"), query su Memvid
|
||||
|
||||
Vantaggio di questo ibrido
|
||||
Real-time: SQLite per l'esplorazione attiva, nessun lag
|
||||
|
||||
Archivio: Memvid per storage storico compresso e portatile
|
||||
|
||||
Ricerca semantica: "Trova tutte le stanze con descrizione 'foresta oscura'" usando l'embedding dei testi delle stanze salvate in Memvid
|
||||
|
||||
Altra alternativa: "Delta encoding" in arrivo
|
||||
Memvid v2 promette delta encoding per aggiornamenti incrementali senza re-encoding totale, ma per ora è in sviluppo. Nel frattempo, il pattern "SQLite attivo + Memvid storico" è il più pragmatico.
|
||||
|
||||
|
||||
Conclusione
|
||||
Il metodo QR-in-MP4 è valido e funziona (Memvid lo dimostra), ma per un automapper MUD serve un ibrido: grafo attivo in SQLite per l'esplorazione real-time, con periodico "snapshot" in Memvid per archiviazione compressa e ricerca semantica storica. Vuoi che approfondiamo l'architettura di questo ibrido?
|
||||
### Modulo 6: Progression Optimizer (il min-maxer)
|
||||
|
||||
La logica di crescita del personaggio.
|
||||
|
||||
- **Grinding spot finder**: Identificare zone dove il rapporto EXP/ora è ottimale per il livello corrente
|
||||
- **Equip optimizer**: Confrontare gear basato su stat bonus
|
||||
- **Skill rotation**: Scegliere quali abilità usare in combattimento in base a mana cost / damage / cooldown
|
||||
|
||||
|
||||
## Flusso di gioco simulato
|
||||
|
||||
```
|
||||
1. CONNESSIONE → Telnet al server, autenticazione
|
||||
2. CREAZIONE → Roll stats, scegli classe, setup iniziale
|
||||
3. EXPLORING → Inizia mappando la città di partenza, memorizza shop/inn/quest-giver
|
||||
4. COMBATTIMENTO → Trova mob appropriato al livello, combatte, accumula EXP
|
||||
5. QUESTING → Accetta quest, pianifica esecuzione, completa, riscatta reward
|
||||
6. LOOP → Torna a 4-5 fino al level up, poi espandi aree esplorabili
|
||||
```
|
||||
|
||||
|
||||
## Stack tecnico suggerito
|
||||
|
||||
- **Backend**: Python con `asyncio` per la gestione Telnet non-bloccante
|
||||
- **Parser**: Regex + NLP (spaCy/LLM) per interpretare le descrizioni
|
||||
- **Grafo**: NetworkX per l'auto-mapping
|
||||
- **AI Decision**: Può essere una serie di regole (expert system) oppure integrare un LLM locale (Llama via Ollama) per decisioni più "intuitive"
|
||||
- **Storage**: SQLite per mappare le stanze e SQLite/JSON per il progresso del personaggio
|
||||
|
||||
|
||||
## Sfide interessanti
|
||||
|
||||
- **Ambiguità testuali**: I MUD hanno descrizioni in prosa, non dati strutturati. Estrarre "questo è un mob ostile" vs "questo è un NPC friendly" richiede parsing intelligente.
|
||||
- **Anti-cheat**: Molti MUD hanno sistemi di rilevamento bot. Il tuo "giocatore simulato" deve mimare comportamenti umani (tempi di reazione variabili, pause, errori di battitura occasionali).
|
||||
- **Memoria a lungo termine**: Dopo settimane di gioco, il grafo del mondo diventa enorme. Serve una buona strategia di storage/indexing.
|
||||
|
||||
Vuoi approfondire un modulo specifico? Ad esempio, il sistema di auto-mapping è probabilmente il più complesso da implementare in modo robusto. Oppure preferisci discutere l'architettura di decisione (rule-based vs LLM-based)?
|
||||
+306
@@ -0,0 +1,306 @@
|
||||
<img src="https://r2cdn.perplexity.ai/pplx-full-logo-primary-dark%402x.png" style="height:64px;margin-right:32px"/>
|
||||
|
||||
# puoi produrre un PR.MD per chiedere ad un agente tipo ralph di produrre un automapper con queste caratteristiche ?
|
||||
|
||||
```markdown
|
||||
# PR: Implementazione Automapper MUD con Archivio Ibrido SQLite/Memvid
|
||||
|
||||
## Descrizione
|
||||
Implementare un modulo automapper per un client MUD che costruisca automaticamente una mappa del mondo di gioco durante l'esplorazione, con persistenza ibrida: SQLite per la memoria attiva real-time e Memvid per l'archivio storico compresso.
|
||||
|
||||
## Obiettivi
|
||||
|
||||
- [ ] Parsing delle descrizioni stanze dal protocollo Telnet MUD
|
||||
- [ ] Costruzione incrementale del grafo di navigazione (stanze = nodi, direzioni = archi)
|
||||
- [ ] Identificazione univoca delle stanze (room fingerprinting)
|
||||
- [ ] Auto-mapping durante l'esplorazione senza intervento umano
|
||||
- [ ] Pathfinding A* tra stanze note
|
||||
- [ ] Storage layer ibrido: SQLite (attivo) + Memvid (storico)
|
||||
- [ ] Sistema di ricerca semantica sulle descrizioni stanze
|
||||
|
||||
## Architettura Proposta
|
||||
|
||||
### 1. Telnet Parser & Room Extractor
|
||||
```python
|
||||
class RoomExtractor:
|
||||
"""Estrae informazioni stanza dal testo MUD grezzo"""
|
||||
|
||||
def parse(self, raw_output: str) -> RoomData:
|
||||
# Estrae: nome stanza, descrizione, exits disponibili,
|
||||
# oggetti presenti, mob/NPC visibili
|
||||
pass
|
||||
```
|
||||
|
||||
**Input**: Testo raw dal server MUD (con ANSI color codes)
|
||||
**Output**: `RoomData` strutturato con:
|
||||
|
||||
- `room_id`: hash univoco (descrizione + exits + nome)
|
||||
- `name`: nome breve stanza
|
||||
- `description`: descrizione lunga
|
||||
- `exits`: dict {direzione: room_id_target} o None se non esplorato
|
||||
- `entities`: lista mob/NPC/oggetti presenti
|
||||
- `metadata`: timestamp, coordinate approssimative (se disponibili)
|
||||
|
||||
|
||||
### 2. Graph Manager (NetworkX)
|
||||
|
||||
```python
|
||||
class MUDGraph:
|
||||
"""Grafo delle stanze con metodi di navigazione"""
|
||||
|
||||
def add_room(self, room: RoomData) -> bool:
|
||||
# Aggiunge nodo, aggiorna archi bidirezionali
|
||||
pass
|
||||
|
||||
def find_path(self, from_id: str, to_id: str) -> List[str]:
|
||||
# A* pathfinding, ritorna lista di direzioni [n, n, e, u, ...]
|
||||
pass
|
||||
|
||||
def get_unexplored_exits(self, room_id: str) -> List[str]:
|
||||
# Ritorna direzioni da esplorare (exit con target=None)
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
### 3. Storage Layer Ibrido
|
||||
|
||||
#### Layer A - SQLite (Memoria Attiva)
|
||||
|
||||
Per operazioni real-time durante l'esplorazione:
|
||||
|
||||
```sql
|
||||
-- rooms: cache attiva delle stanze esplorate
|
||||
CREATE TABLE rooms (
|
||||
room_id TEXT PRIMARY KEY,
|
||||
name TEXT,
|
||||
description TEXT,
|
||||
embedding BLOB, -- vettore per ricerca semantica
|
||||
discovered_at TIMESTAMP,
|
||||
visit_count INTEGER DEFAULT 1,
|
||||
last_visit TIMESTAMP
|
||||
);
|
||||
|
||||
-- exits: grafo di navigazione
|
||||
CREATE TABLE exits (
|
||||
from_room TEXT,
|
||||
direction TEXT,
|
||||
to_room TEXT, -- NULL se non esplorato
|
||||
FOREIGN KEY (from_room) REFERENCES rooms(room_id)
|
||||
);
|
||||
|
||||
-- exploration_queue: stanze con exit non esplorati
|
||||
CREATE TABLE exploration_queue (
|
||||
room_id TEXT,
|
||||
direction TEXT,
|
||||
priority INTEGER,
|
||||
added_at TIMESTAMP
|
||||
);
|
||||
```
|
||||
|
||||
|
||||
#### Layer B - Memvid (Archivio Storico)
|
||||
|
||||
Per storage compresso e ricerca semantica a lungo termine:
|
||||
|
||||
```python
|
||||
class MemvidArchive:
|
||||
"""Archivio storico in MP4 con QR code frames"""
|
||||
|
||||
def snapshot(self, db_path: str) -> str:
|
||||
# 1. Estrae tutte le stanze da SQLite
|
||||
# 2. Chunking descrizioni + metadati
|
||||
# 3. Embedding con sentence-transformers
|
||||
# 4. Encoding in QR code frames
|
||||
# 5. Genera MP4 H.264 + indice FAISS
|
||||
pass
|
||||
|
||||
def semantic_search(self, query: str, top_k: int = 5) -> List[RoomData]:
|
||||
# Ricerca vettoriale sul MP4 storico
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
### 4. Auto-Explorer Controller
|
||||
|
||||
```python
|
||||
class AutoExplorer:
|
||||
"""Orchestra l'esplorazione automatica"""
|
||||
|
||||
def decide_next_move(self, current_room: RoomData) -> str:
|
||||
# Logica:
|
||||
# 1. Se ci sono exit non esplorati, scegli quella più vicina
|
||||
# 2. Altrimenti, BFS verso la stanza con exit non esplorati
|
||||
# 3. Evita loop (no back-and-forth)
|
||||
pass
|
||||
|
||||
def update_graph(self, move: str, result: str) -> None:
|
||||
# Aggiorna grafo dopo ogni movimento
|
||||
# Se result è "You can't go that way", marca exit come blocked
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
### 5. Sync Manager
|
||||
|
||||
```python
|
||||
class SyncManager:
|
||||
"""Gestisce la sincronizzazione tra SQLite e Memvid"""
|
||||
|
||||
def __init__(self, sync_interval: int = 100):
|
||||
# Ogni N stanze nuove, triggera snapshot Memvid
|
||||
pass
|
||||
|
||||
def should_sync(self) -> bool:
|
||||
# Controlla se ci sono abbastanza nuove stanze
|
||||
pass
|
||||
|
||||
def perform_sync(self):
|
||||
# 1. Crea backup SQLite
|
||||
# 2. Genera snapshot Memvid
|
||||
# 3. Verifica integrità (sample random decode)
|
||||
# 4. Opzionale: pulisce SQLite delle stanze meno recenti
|
||||
pass
|
||||
```
|
||||
|
||||
|
||||
## Criteri di Accettazione
|
||||
|
||||
### Test 1: Parsing Base
|
||||
|
||||
```python
|
||||
raw_mud_output = """
|
||||
[Exits: n e w]
|
||||
The Village Square
|
||||
You are standing in the village square. A fountain bubbles here.
|
||||
A guard is standing here.
|
||||
A signpost points north to the Forest, east to the Shop.
|
||||
"""
|
||||
room = extractor.parse(raw_mud_output)
|
||||
assert room.name == "The Village Square"
|
||||
assert room.exits == {"n": None, "e": None, "w": None}
|
||||
assert "guard" in room.entities
|
||||
```
|
||||
|
||||
|
||||
### Test 2: Costruzione Grafo
|
||||
|
||||
```python
|
||||
# Simula esplorazione: Square → North → Forest
|
||||
graph = MUDGraph()
|
||||
# ... aggiungi stanze ...
|
||||
path = graph.find_path("square_123", "forest_456")
|
||||
assert path == ["n"] # diretta, una sola mossa
|
||||
```
|
||||
|
||||
|
||||
### Test 3: Persistenza SQLite
|
||||
|
||||
```python
|
||||
# Dopo 10 stanze esplorate
|
||||
assert db.count_rooms() == 10
|
||||
assert db.get_room("square_123").visit_count >= 1
|
||||
```
|
||||
|
||||
|
||||
### Test 4: Memvid Snapshot
|
||||
|
||||
```python
|
||||
# Dopo sync, verifica che MP4 sia leggibile
|
||||
archive = MemvidArchive("world_map.mp4")
|
||||
results = archive.semantic_search("forest with dark trees")
|
||||
assert len(results) > 0
|
||||
assert "forest" in results.description.lower()
|
||||
```
|
||||
|
||||
|
||||
### Test 5: Auto-Explorer (Integration)
|
||||
|
||||
```python
|
||||
# Simula 20 movimenti in un mondo di test
|
||||
explorer = AutoExplorer(graph)
|
||||
for _ in range(20):
|
||||
move = explorer.decide_next_move(current_room)
|
||||
# Simula risposta server
|
||||
new_room = simulate_mud_response(move)
|
||||
explorer.update_graph(move, new_room)
|
||||
|
||||
# Dovrebbe aver esplorato almeno 5 stanze uniche
|
||||
assert len(graph.rooms) >= 5
|
||||
```
|
||||
|
||||
|
||||
## Considerazioni Tecniche
|
||||
|
||||
### Room ID Generation
|
||||
|
||||
Usare hash consistente (SHA256 truncato) di:
|
||||
|
||||
- Nome stanza normalizzato (lowercase, no color codes)
|
||||
- Descrizione normalizzata (prime 100 char)
|
||||
- Lista di exits disponibili (ordinata)
|
||||
|
||||
|
||||
### Gestione Stanze Dinamiche
|
||||
|
||||
Alcuni MUD hanno stanze che cambiano (portali random, maze). Implementare:
|
||||
|
||||
- `is_dynamic`: flag se la stanza cambia tra visite
|
||||
- `confidence`: score 0-1 sulla stabilità dell'ID
|
||||
|
||||
|
||||
### Performance Targets
|
||||
|
||||
- Parsing stanza: < 50ms
|
||||
- Query pathfinding: < 100ms per 1000 stanze
|
||||
- Sync SQLite→Memvid: background, non bloccare < 500ms UI
|
||||
|
||||
|
||||
### Dipendenze
|
||||
|
||||
```
|
||||
# requirements.txt
|
||||
networkx
|
||||
sqlite3
|
||||
numpy
|
||||
sentence-transformers
|
||||
pillow # QR generation
|
||||
opencv-python # QR decoding
|
||||
faiss-cpu # o faiss-gpu
|
||||
```
|
||||
|
||||
|
||||
## Checklist Implementazione
|
||||
|
||||
- [ ] Implementare `RoomExtractor` con regex MUD
|
||||
- [ ] Implementare `MUDGraph` con NetworkX
|
||||
- [ ] Setup schema SQLite
|
||||
- [ ] Implementare `AutoExplorer` con BFS per exit non esplorati
|
||||
- [ ] Implementare `MemvidArchive` encoding/decoding
|
||||
- [ ] Implementare `SyncManager` con trigger automatico
|
||||
- [ ] Scrivere test unitari per ogni modulo
|
||||
- [ ] Scrivere test di integrazione con server MUD mock
|
||||
- [ ] Documentazione API (docstring)
|
||||
- [ ] Esempio di usage in `examples/basic_explorer.py`
|
||||
|
||||
|
||||
## Note per l'Agente
|
||||
|
||||
1. **Inizia dal SQLite**: implementa prima il grafo in-memory + SQLite, il Memvid è un "nice to have" iniziale
|
||||
2. **Mock del server**: crea un `MockMUDServer` che risponde con testo predefinito per i test
|
||||
3. **Logging**: usa `logging` per tracciare ogni decisione dell'explorer (utile per debug)
|
||||
4. **Configurazione**: usa `pydantic.BaseSettings` per parametri (sync_interval, embedding model, ecc.)
|
||||
|
||||
## Definizione di Completato
|
||||
|
||||
L'automapper è considerato completato quando:
|
||||
|
||||
1. ✅ Può esplorare autonomamente un mondo MUD di test con 50+ stanze
|
||||
2. ✅ Costruisce un grafo navigabile con pathfinding funzionante
|
||||
3. ✅ Persiste i dati in SQLite senza perdita di informazioni
|
||||
4. ✅ Genera correttamente un archivio Memvid ricercabile
|
||||
5. ✅ Tutti i test passano (`pytest tests/ -v`)
|
||||
|
||||
---
|
||||
|
||||
**Priorità**: Alta
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
[tool.poetry]
|
||||
name = "mud-ai-player"
|
||||
version = "0.1.0"
|
||||
description = "AI player for MUD (Multi-User Dungeon) games"
|
||||
authors = ["Your Name <your.email@example.com>"]
|
||||
readme = "README.md"
|
||||
packages = [{include = "src"}]
|
||||
|
||||
[tool.poetry.dependencies]
|
||||
python = "^3.10"
|
||||
telnetlib3 = "^2.0"
|
||||
networkx = "^3.0"
|
||||
sqlalchemy = "^2.0"
|
||||
pydantic = "^2.0"
|
||||
rich = "^13.0"
|
||||
memvid = "^0.1.0"
|
||||
faiss-cpu = "^1.7.4"
|
||||
sentence-transformers = "^2.2.0"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
pytest = "^7.0"
|
||||
pytest-asyncio = "^0.21"
|
||||
pytest-cov = "^4.0"
|
||||
black = "^23.0"
|
||||
ruff = "^0.1"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
|
||||
[tool.black]
|
||||
line-length = 100
|
||||
target-version = ['py310']
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py310"
|
||||
@@ -0,0 +1,11 @@
|
||||
telnetlib3>=2.0.4
|
||||
networkx>=3.2.1
|
||||
sqlalchemy>=2.0.23
|
||||
pydantic>=2.5.3
|
||||
rich>=13.7.0
|
||||
memvid>=0.1.0
|
||||
faiss-cpu>=1.7.4
|
||||
sentence-transformers>=2.2.0
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.21.1
|
||||
pytest-cov>=4.1.0
|
||||
@@ -0,0 +1,4 @@
|
||||
"""MUD AI Player - Main package"""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
__author__ = "Your Name"
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Configuration management"""
|
||||
|
||||
__all__ = ["load_config", "Config"]
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Configuration loading and validation"""
|
||||
|
||||
import yaml
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Optional
|
||||
|
||||
|
||||
class MudConfig(BaseModel):
|
||||
"""MUD server configuration"""
|
||||
host: str
|
||||
port: int
|
||||
mud_type: str = "dead_souls"
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
keep_alive_interval: int = 60
|
||||
keep_alive_command: str = ""
|
||||
|
||||
|
||||
class CharacterConfig(BaseModel):
|
||||
"""Character preferences"""
|
||||
name: str = "AIPlayer"
|
||||
preferred_class: str = "warrior"
|
||||
preferred_race: str = "human"
|
||||
play_style: str = "balanced"
|
||||
|
||||
|
||||
class LLMProviderConfig(BaseModel):
|
||||
"""LLM provider configuration (Ollama / OpenAI-compatible)"""
|
||||
enabled: bool = True
|
||||
base_url: str = "http://localhost:11434"
|
||||
model: str = "llama3.2"
|
||||
temperature: float = 0.3
|
||||
max_tokens: int = 512
|
||||
timeout_seconds: float = 30.0
|
||||
max_retries: int = 2
|
||||
system_prompt: str = (
|
||||
"You are an AI playing a text-based MUD game. "
|
||||
"You must respond ONLY with the requested format. "
|
||||
"Be concise and precise. No explanations unless asked."
|
||||
)
|
||||
memory_db_path: str = "data/llm_memory.db"
|
||||
"""Path to SQLite database for LLM long-term memory."""
|
||||
|
||||
|
||||
class AIConfig(BaseModel):
|
||||
"""AI engine configuration"""
|
||||
engine: str = "hybrid"
|
||||
llm_model: str = "llama3.2"
|
||||
decision_delay_ms: List[int] = Field(default=[200, 2000])
|
||||
exploration_strategy: str = "depth_first"
|
||||
llm: LLMProviderConfig = Field(default_factory=LLMProviderConfig)
|
||||
|
||||
|
||||
class MapperConfig(BaseModel):
|
||||
"""Mapper configuration"""
|
||||
class ActiveStorageConfig(BaseModel):
|
||||
db_path: str = "data/maps/active/world.db"
|
||||
graph_cache_size: int = 10000
|
||||
|
||||
class HistoricalStorageConfig(BaseModel):
|
||||
archive_dir: str = "data/maps/archive"
|
||||
|
||||
class SnapshotTriggers(BaseModel):
|
||||
room_count: int = 500
|
||||
session_end: bool = True
|
||||
time_interval_hours: int = 24
|
||||
|
||||
snapshot_triggers: SnapshotTriggers = Field(default_factory=SnapshotTriggers)
|
||||
|
||||
active_storage: ActiveStorageConfig = Field(default_factory=ActiveStorageConfig)
|
||||
historical_storage: HistoricalStorageConfig = Field(default_factory=HistoricalStorageConfig)
|
||||
|
||||
|
||||
class BehaviorConfig(BaseModel):
|
||||
"""Behavior simulation configuration"""
|
||||
reaction_time_min_ms: int = 200
|
||||
reaction_time_max_ms: int = 2000
|
||||
pause_probability: float = 0.05
|
||||
typo_probability: float = 0.0
|
||||
|
||||
|
||||
class LoggingConfig(BaseModel):
|
||||
"""Logging configuration"""
|
||||
level: str = "INFO"
|
||||
file: str = "data/logs/game.log"
|
||||
console: bool = True
|
||||
max_file_size_mb: int = 10
|
||||
backup_count: int = 5
|
||||
|
||||
|
||||
class Config(BaseModel):
|
||||
"""Main configuration"""
|
||||
mud: MudConfig
|
||||
character: CharacterConfig = Field(default_factory=CharacterConfig)
|
||||
ai: AIConfig = Field(default_factory=AIConfig)
|
||||
mapper: MapperConfig = Field(default_factory=MapperConfig)
|
||||
behavior: BehaviorConfig = Field(default_factory=BehaviorConfig)
|
||||
logging: LoggingConfig = Field(default_factory=LoggingConfig)
|
||||
|
||||
|
||||
def load_config(config_path: str = "config.yaml") -> Config:
|
||||
"""
|
||||
Load configuration from YAML file
|
||||
|
||||
Args:
|
||||
config_path: Path to config file
|
||||
|
||||
Returns:
|
||||
Validated Config object
|
||||
"""
|
||||
with open(config_path, 'r') as f:
|
||||
config_dict = yaml.safe_load(f)
|
||||
|
||||
return Config(**config_dict)
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Core components package"""
|
||||
|
||||
from .ansi_parser import ANSIParser
|
||||
from .game_state import GameState
|
||||
|
||||
# TelnetClient requires telnetlib3 which may not be available in all
|
||||
# environments (e.g. test runners without the full venv). Import it
|
||||
# lazily so that modules which only need the parser/state can import
|
||||
# from core without pulling in the telnet dependency.
|
||||
try:
|
||||
from .telnet_client import TelnetClient
|
||||
except ImportError:
|
||||
TelnetClient = None # type: ignore[assignment,misc]
|
||||
|
||||
__all__ = ["TelnetClient", "ANSIParser", "GameState"]
|
||||
@@ -0,0 +1,40 @@
|
||||
"""ANSI color code parser"""
|
||||
|
||||
import re
|
||||
from typing import Tuple
|
||||
|
||||
|
||||
class ANSIParser:
|
||||
"""Parser for ANSI escape codes and color formatting"""
|
||||
|
||||
# ANSI escape sequence pattern
|
||||
ANSI_PATTERN = re.compile(r'\x1b\[[0-9;]*m')
|
||||
|
||||
@staticmethod
|
||||
def strip_ansi(text: str) -> str:
|
||||
"""
|
||||
Remove all ANSI escape codes from text
|
||||
|
||||
Args:
|
||||
text: Text containing ANSI codes
|
||||
|
||||
Returns:
|
||||
Clean text without ANSI codes
|
||||
"""
|
||||
return ANSIParser.ANSI_PATTERN.sub('', text)
|
||||
|
||||
@staticmethod
|
||||
def extract_with_colors(text: str) -> list[Tuple[str, str]]:
|
||||
"""
|
||||
Extract text segments with their associated colors
|
||||
|
||||
Args:
|
||||
text: Text with ANSI codes
|
||||
|
||||
Returns:
|
||||
List of (text_segment, color_code) tuples
|
||||
"""
|
||||
# TODO: Implement color extraction for semantic parsing
|
||||
# For now, just return clean text
|
||||
clean = ANSIParser.strip_ansi(text)
|
||||
return [(clean, 'default')]
|
||||
@@ -0,0 +1,210 @@
|
||||
"""Game state management"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional, List, Dict, TYPE_CHECKING
|
||||
from enum import Enum
|
||||
import logging
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .parsers.base import ParsedPrompt, ParsedRoom, ParsedCombat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CombatState(Enum):
|
||||
"""Combat state enumeration"""
|
||||
IDLE = "idle"
|
||||
IN_COMBAT = "in_combat"
|
||||
FLEEING = "fleeing"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Stats:
|
||||
"""Character statistics"""
|
||||
strength: int = 10
|
||||
intelligence: int = 10
|
||||
wisdom: int = 10
|
||||
dexterity: int = 10
|
||||
constitution: int = 10
|
||||
charisma: int = 10
|
||||
|
||||
|
||||
@dataclass
|
||||
class Equipment:
|
||||
"""Character equipment"""
|
||||
weapon: Optional[str] = None
|
||||
armor: Optional[str] = None
|
||||
helmet: Optional[str] = None
|
||||
boots: Optional[str] = None
|
||||
gloves: Optional[str] = None
|
||||
ring: Optional[str] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class Character:
|
||||
"""Player character data"""
|
||||
name: str = ""
|
||||
race: str = ""
|
||||
class_type: str = ""
|
||||
level: int = 1
|
||||
hp: int = 100
|
||||
max_hp: int = 100
|
||||
mp: int = 50
|
||||
max_mp: int = 50
|
||||
sp: int = 0
|
||||
max_sp: int = 0
|
||||
exp: int = 0
|
||||
gold: int = 0
|
||||
stats: Stats = field(default_factory=Stats)
|
||||
equipment: Equipment = field(default_factory=Equipment)
|
||||
skills: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Room:
|
||||
"""Current room information"""
|
||||
id: Optional[int] = None
|
||||
fingerprint: Optional[str] = None
|
||||
title: Optional[str] = None
|
||||
description: str = ""
|
||||
short_desc: Optional[str] = None
|
||||
zone: Optional[str] = None
|
||||
npcs: List[str] = field(default_factory=list)
|
||||
items: List[str] = field(default_factory=list)
|
||||
exits: Dict[str, int] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quest:
|
||||
"""Quest information"""
|
||||
id: int
|
||||
name: str
|
||||
description: str
|
||||
objectives: List[str] = field(default_factory=list)
|
||||
completed: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class GameState:
|
||||
"""Central game state"""
|
||||
character: Character = field(default_factory=Character)
|
||||
current_room: Optional[Room] = None
|
||||
previous_room: Optional[Room] = None
|
||||
inventory: List[str] = field(default_factory=list)
|
||||
active_quests: List[Quest] = field(default_factory=list)
|
||||
combat_state: CombatState = CombatState.IDLE
|
||||
target: Optional[str] = None
|
||||
|
||||
def is_in_combat(self) -> bool:
|
||||
"""Check if currently in combat"""
|
||||
return self.combat_state == CombatState.IN_COMBAT
|
||||
|
||||
def is_healthy(self, threshold: float = 0.7) -> bool:
|
||||
"""Check if character health is above threshold"""
|
||||
if self.character.max_hp == 0:
|
||||
return False
|
||||
return (self.character.hp / self.character.max_hp) >= threshold
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Update methods — fed by the parser output
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def update_from_prompt(self, prompt: "ParsedPrompt") -> None:
|
||||
"""Update character vitals from a parsed prompt.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prompt:
|
||||
A ``ParsedPrompt`` instance (from ``core.parsers.base``).
|
||||
"""
|
||||
if prompt.hp is not None:
|
||||
self.character.hp = prompt.hp
|
||||
if prompt.max_hp is not None:
|
||||
self.character.max_hp = prompt.max_hp
|
||||
if prompt.mp is not None:
|
||||
self.character.mp = prompt.mp
|
||||
if prompt.max_mp is not None:
|
||||
self.character.max_mp = prompt.max_mp
|
||||
if prompt.sp is not None:
|
||||
self.character.sp = prompt.sp
|
||||
if prompt.max_sp is not None:
|
||||
self.character.max_sp = prompt.max_sp
|
||||
logger.debug(
|
||||
"Vitals updated: HP=%d/%d MP=%d/%d SP=%d/%d",
|
||||
self.character.hp, self.character.max_hp,
|
||||
self.character.mp, self.character.max_mp,
|
||||
self.character.sp, self.character.max_sp,
|
||||
)
|
||||
|
||||
def update_room(self, parsed_room: "ParsedRoom") -> None:
|
||||
"""Update current room from a parsed room description.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parsed_room:
|
||||
A ``ParsedRoom`` instance (from ``core.parsers.base``).
|
||||
"""
|
||||
# Preserve previous room for navigation tracking
|
||||
if self.current_room is not None:
|
||||
self.previous_room = self.current_room
|
||||
|
||||
self.current_room = Room(
|
||||
title=parsed_room.title,
|
||||
description=parsed_room.description or "",
|
||||
npcs=list(parsed_room.npcs),
|
||||
items=list(parsed_room.items),
|
||||
# Exits stored as direction -> room_id mapping;
|
||||
# room_id is 0 (unknown) until the mapper resolves it.
|
||||
exits={d: 0 for d in parsed_room.exits},
|
||||
)
|
||||
logger.debug(
|
||||
"Room updated: '%s' | exits=%s | npcs=%s | items=%s",
|
||||
self.current_room.title,
|
||||
list(self.current_room.exits.keys()),
|
||||
self.current_room.npcs,
|
||||
self.current_room.items,
|
||||
)
|
||||
|
||||
def update_combat(self, parsed_combat: "ParsedCombat") -> None:
|
||||
"""Update combat state from a parsed combat event.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
parsed_combat:
|
||||
A ``ParsedCombat`` instance (from ``core.parsers.base``).
|
||||
"""
|
||||
if not parsed_combat.is_combat:
|
||||
return
|
||||
|
||||
action = parsed_combat.action
|
||||
|
||||
if action == "flee":
|
||||
self.combat_state = CombatState.FLEEING
|
||||
self.target = None
|
||||
logger.info("Combat: FLEEING")
|
||||
elif action == "kill":
|
||||
# If *we* killed the target, combat ends
|
||||
if parsed_combat.attacker and "you" in parsed_combat.attacker.lower():
|
||||
self.combat_state = CombatState.IDLE
|
||||
self.target = None
|
||||
logger.info("Combat: target killed, returning to IDLE")
|
||||
else:
|
||||
# Something else died; we might still be in combat
|
||||
pass
|
||||
elif action in ("hit", "miss", "attack"):
|
||||
self.combat_state = CombatState.IN_COMBAT
|
||||
# Track the entity we are fighting
|
||||
if parsed_combat.attacker and "you" in parsed_combat.attacker.lower():
|
||||
self.target = parsed_combat.target
|
||||
elif parsed_combat.target and "you" in parsed_combat.target.lower():
|
||||
self.target = parsed_combat.attacker
|
||||
logger.debug(
|
||||
"Combat: %s -> %s (%s, dmg=%s)",
|
||||
parsed_combat.attacker,
|
||||
parsed_combat.target,
|
||||
action,
|
||||
parsed_combat.damage,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Pluggable MUD parser system"""
|
||||
|
||||
from .base import (
|
||||
BaseMudParser,
|
||||
ParsedRoom,
|
||||
ParsedPrompt,
|
||||
ParsedCombat,
|
||||
ParsedEvent,
|
||||
EventType,
|
||||
)
|
||||
from .buffer import OutputBuffer
|
||||
from .factory import ParserFactory
|
||||
|
||||
__all__ = [
|
||||
"BaseMudParser",
|
||||
"ParsedRoom",
|
||||
"ParsedPrompt",
|
||||
"ParsedCombat",
|
||||
"ParsedEvent",
|
||||
"EventType",
|
||||
"OutputBuffer",
|
||||
"ParserFactory",
|
||||
]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Base classes for the pluggable MUD parser system.
|
||||
|
||||
This module defines the abstract interface that all MUD-specific parsers
|
||||
must implement, along with the common data classes used to represent
|
||||
parsed game output.
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
from typing import Optional, List
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes for parsed output
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class ParsedRoom:
|
||||
"""Structured representation of a parsed room description."""
|
||||
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
exits: List[str] = field(default_factory=list)
|
||||
npcs: List[str] = field(default_factory=list)
|
||||
items: List[str] = field(default_factory=list)
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedPrompt:
|
||||
"""Structured representation of the player status prompt.
|
||||
|
||||
Dead Souls format example: ``hp: 350/350 mp: 440/440 sp: 431/431 >``
|
||||
"""
|
||||
|
||||
hp: Optional[int] = None
|
||||
max_hp: Optional[int] = None
|
||||
mp: Optional[int] = None
|
||||
max_mp: Optional[int] = None
|
||||
sp: Optional[int] = None
|
||||
max_sp: Optional[int] = None
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedCombat:
|
||||
"""Structured representation of a combat event."""
|
||||
|
||||
is_combat: bool = False
|
||||
attacker: Optional[str] = None
|
||||
target: Optional[str] = None
|
||||
damage: Optional[int] = None
|
||||
action: Optional[str] = None # "hit", "miss", "kill", "flee"
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
class EventType(Enum):
|
||||
"""Types of events the parser can detect."""
|
||||
|
||||
ROOM = "room"
|
||||
PROMPT = "prompt"
|
||||
COMBAT = "combat"
|
||||
SYSTEM = "system" # login messages, server notices
|
||||
COMMUNICATION = "comm" # say, tell, shout
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParsedEvent:
|
||||
"""Wrapper that pairs an event type with its parsed payload."""
|
||||
|
||||
event_type: EventType
|
||||
room: Optional[ParsedRoom] = None
|
||||
prompt: Optional[ParsedPrompt] = None
|
||||
combat: Optional[ParsedCombat] = None
|
||||
raw_text: str = ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BaseMudParser(ABC):
|
||||
"""Abstract base class for MUD-specific parsers.
|
||||
|
||||
Every supported MUD must provide a concrete subclass that implements
|
||||
the methods below. The ``ParserFactory`` uses ``mud_type`` to look up
|
||||
the correct subclass at runtime.
|
||||
|
||||
Typical data flow::
|
||||
|
||||
raw server bytes
|
||||
-> ANSIParser.strip_ansi()
|
||||
-> OutputBuffer.feed() # accumulates lines
|
||||
-> OutputBuffer.flush() # returns complete block
|
||||
-> BaseMudParser.parse_block() # returns list[ParsedEvent]
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_prompt(self, line: str) -> Optional[ParsedPrompt]:
|
||||
"""Try to parse a single line as a status prompt.
|
||||
|
||||
Returns ``None`` if the line is not a prompt.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_room(self, text_block: str) -> Optional[ParsedRoom]:
|
||||
"""Parse a multi-line text block as a room description.
|
||||
|
||||
Returns ``None`` if the block does not contain a valid room.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def parse_combat(self, line: str) -> Optional[ParsedCombat]:
|
||||
"""Try to parse a single line as a combat event.
|
||||
|
||||
Returns ``None`` if the line is not combat-related.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def is_prompt_line(self, line: str) -> bool:
|
||||
"""Return ``True`` if *line* looks like a status prompt."""
|
||||
|
||||
@abstractmethod
|
||||
def is_room_description(self, text_block: str) -> bool:
|
||||
"""Return ``True`` if *text_block* looks like a room description."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Default high-level helper (concrete subclasses may override)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def parse_block(self, text_block: str) -> List[ParsedEvent]:
|
||||
"""Parse a complete text block and return all detected events.
|
||||
|
||||
The default implementation processes line-by-line for prompts and
|
||||
combat, then attempts a room parse on the full block. Subclasses
|
||||
can override for MUD-specific ordering.
|
||||
"""
|
||||
events: List[ParsedEvent] = []
|
||||
|
||||
for line in text_block.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
# Check prompt
|
||||
prompt = self.parse_prompt(line)
|
||||
if prompt:
|
||||
events.append(ParsedEvent(
|
||||
event_type=EventType.PROMPT,
|
||||
prompt=prompt,
|
||||
raw_text=line,
|
||||
))
|
||||
continue
|
||||
|
||||
# Check combat
|
||||
combat = self.parse_combat(line)
|
||||
if combat and combat.is_combat:
|
||||
events.append(ParsedEvent(
|
||||
event_type=EventType.COMBAT,
|
||||
combat=combat,
|
||||
raw_text=line,
|
||||
))
|
||||
continue
|
||||
|
||||
# Attempt room parse on the whole block
|
||||
room = self.parse_room(text_block)
|
||||
if room and room.title:
|
||||
events.append(ParsedEvent(
|
||||
event_type=EventType.ROOM,
|
||||
room=room,
|
||||
raw_text=text_block,
|
||||
))
|
||||
|
||||
return events
|
||||
@@ -0,0 +1,103 @@
|
||||
"""Output buffer that accumulates text between prompts.
|
||||
|
||||
MUD servers send output in arbitrary chunks over Telnet. A room
|
||||
description may arrive as one big block or split across several small
|
||||
reads. The ``OutputBuffer`` collects lines until a prompt is detected
|
||||
(via the parser's ``is_prompt_line``), then flushes the accumulated
|
||||
block so the parser can process it as a complete unit.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import BaseMudParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class OutputBuffer:
|
||||
"""Accumulates server output and flushes complete blocks on prompt detection.
|
||||
|
||||
Usage::
|
||||
|
||||
buffer = OutputBuffer(parser)
|
||||
|
||||
# Called every time data arrives from the server
|
||||
blocks = buffer.feed(clean_text)
|
||||
for block in blocks:
|
||||
events = parser.parse_block(block)
|
||||
...
|
||||
"""
|
||||
|
||||
def __init__(self, parser: "BaseMudParser", max_lines: int = 200):
|
||||
self._parser = parser
|
||||
self._lines: List[str] = []
|
||||
self._max_lines = max_lines
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def feed(self, text: str) -> List[str]:
|
||||
"""Feed new text from the server and return any complete blocks.
|
||||
|
||||
A *complete block* is all the text that was accumulated before a
|
||||
prompt line was detected. If no prompt is found in *text*, an
|
||||
empty list is returned and the text is buffered internally.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text:
|
||||
Clean text (ANSI already stripped) received from the server.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[str]:
|
||||
Zero or more complete text blocks, each terminated by a prompt.
|
||||
The prompt line itself is **not** included in the block — it is
|
||||
returned separately so the caller can parse it as a prompt event.
|
||||
"""
|
||||
blocks: List[str] = []
|
||||
|
||||
for line in text.splitlines():
|
||||
if self._parser.is_prompt_line(line.strip()):
|
||||
# Flush accumulated lines as a complete block
|
||||
if self._lines:
|
||||
block = "\n".join(self._lines)
|
||||
blocks.append(block)
|
||||
self._lines = []
|
||||
# The prompt line itself is a separate "block" (single line)
|
||||
blocks.append(line.strip())
|
||||
else:
|
||||
self._lines.append(line)
|
||||
|
||||
# Safety valve: avoid unbounded memory growth
|
||||
if len(self._lines) > self._max_lines:
|
||||
logger.warning(
|
||||
"OutputBuffer exceeded %d lines without a prompt, "
|
||||
"flushing partial block",
|
||||
self._max_lines,
|
||||
)
|
||||
block = "\n".join(self._lines)
|
||||
blocks.append(block)
|
||||
self._lines = []
|
||||
|
||||
return blocks
|
||||
|
||||
def flush(self) -> Optional[str]:
|
||||
"""Force-flush any remaining buffered text.
|
||||
|
||||
Useful when the connection drops and you want to process whatever
|
||||
was left in the buffer.
|
||||
"""
|
||||
if not self._lines:
|
||||
return None
|
||||
block = "\n".join(self._lines)
|
||||
self._lines = []
|
||||
return block
|
||||
|
||||
@property
|
||||
def pending_lines(self) -> int:
|
||||
"""Number of lines currently buffered."""
|
||||
return len(self._lines)
|
||||
@@ -0,0 +1,319 @@
|
||||
"""Dead Souls LPMUD parser.
|
||||
|
||||
Implements ``BaseMudParser`` for the Dead Souls mudlib (FluffOS driver).
|
||||
|
||||
Known output formats (captured from real game sessions)
|
||||
-------------------------------------------------------
|
||||
|
||||
**Prompt**::
|
||||
|
||||
hp: 350/350 mp: 440/440 sp: 431/431 >
|
||||
|
||||
**Room description** (typical)::
|
||||
|
||||
Town Square
|
||||
You are standing in the town square of the village. A large fountain
|
||||
dominates the center of the square. Roads lead off in several
|
||||
directions.
|
||||
There are three obvious exits: north, east, and west.
|
||||
A town guard is standing here.
|
||||
A small potion is here.
|
||||
|
||||
**Exits variations**::
|
||||
|
||||
There is one obvious exit: north.
|
||||
There are two obvious exits: north and south.
|
||||
There are three obvious exits: north, south, and east.
|
||||
Obvious exits: north, south, east
|
||||
|
||||
**Combat**::
|
||||
|
||||
You attack the goblin!
|
||||
You hit the goblin for 15 damage.
|
||||
The goblin hits you for 8 damage.
|
||||
The goblin misses you.
|
||||
You killed the goblin!
|
||||
You flee north!
|
||||
"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import Optional, List
|
||||
|
||||
from .base import BaseMudParser, ParsedRoom, ParsedPrompt, ParsedCombat
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeadSoulsParser(BaseMudParser):
|
||||
"""Parser tuned for the Dead Souls LPMUD (FluffOS v2.x, DS 3.8.x)."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Compiled regex patterns
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
# Prompt: "hp: 350/350 mp: 440/440 sp: 431/431 >"
|
||||
_PROMPT_RE = re.compile(
|
||||
r"hp:\s*(?P<hp>\d+)/(?P<max_hp>\d+)\s+"
|
||||
r"mp:\s*(?P<mp>\d+)/(?P<max_mp>\d+)\s+"
|
||||
r"sp:\s*(?P<sp>\d+)/(?P<max_sp>\d+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Exits — several Dead Souls variants:
|
||||
# "There is one obvious exit: north."
|
||||
# "There are two obvious exits: north and south."
|
||||
# "There are three obvious exits: north, south, and east."
|
||||
# "Obvious exits: north, south, east"
|
||||
# "[ Exits: n s w ]"
|
||||
_EXITS_LONG_RE = re.compile(
|
||||
r"There (?:is|are) \w+ obvious exits?:\s*(.+?)\.",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXITS_SHORT_RE = re.compile(
|
||||
r"Obvious exits?:\s*(.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXITS_BRACKET_RE = re.compile(
|
||||
r"\[\s*[Ee]xits?:\s*(.+?)\s*\]",
|
||||
)
|
||||
|
||||
# Combat patterns
|
||||
_COMBAT_HIT_RE = re.compile(
|
||||
r"(?P<attacker>.+?)\s+hits?\s+(?P<target>.+?)\s+for\s+(?P<damage>\d+)\s+damage",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMBAT_MISS_RE = re.compile(
|
||||
r"(?P<attacker>.+?)\s+misses?\s+(?P<target>.+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMBAT_KILL_RE = re.compile(
|
||||
r"(?P<attacker>.+?)\s+killed?\s+(?P<target>.+?)!?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMBAT_ATTACK_RE = re.compile(
|
||||
r"(?P<attacker>.+?)\s+attacks?\s+(?P<target>.+?)!?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_COMBAT_FLEE_RE = re.compile(
|
||||
r"(?:You|you)\s+flee\s+(\w+)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Lines that typically contain NPCs (Dead Souls convention)
|
||||
# e.g. "A town guard is standing here."
|
||||
# "Some goblins are sitting here."
|
||||
# Note: plain "is here" (without standing/sitting/etc.) is treated
|
||||
# as an item, not an NPC.
|
||||
_NPC_RE = re.compile(
|
||||
r"^(?:A|An|The|Some)\s+(.+?)\s+(?:is|are)\s+"
|
||||
r"(?:standing|sitting|lying|sleeping)\s+here",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Items on the ground
|
||||
# e.g. "A small potion is here."
|
||||
# "A rusty sword is lying here."
|
||||
_ITEM_RE = re.compile(
|
||||
r"^(?:A|An|The|Some)\s+(.+?)\s+(?:is|are)\s+"
|
||||
r"(?:here|lying here|on the ground)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Known direction words
|
||||
_DIRECTIONS = frozenset({
|
||||
"north", "south", "east", "west",
|
||||
"northeast", "northwest", "southeast", "southwest",
|
||||
"up", "down",
|
||||
"n", "s", "e", "w", "ne", "nw", "se", "sw", "u", "d",
|
||||
})
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# BaseMudParser implementation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def parse_prompt(self, line: str) -> Optional[ParsedPrompt]:
|
||||
m = self._PROMPT_RE.search(line)
|
||||
if not m:
|
||||
return None
|
||||
return ParsedPrompt(
|
||||
hp=int(m.group("hp")),
|
||||
max_hp=int(m.group("max_hp")),
|
||||
mp=int(m.group("mp")),
|
||||
max_mp=int(m.group("max_mp")),
|
||||
sp=int(m.group("sp")),
|
||||
max_sp=int(m.group("max_sp")),
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
def is_prompt_line(self, line: str) -> bool:
|
||||
return self._PROMPT_RE.search(line) is not None
|
||||
|
||||
def parse_room(self, text_block: str) -> Optional[ParsedRoom]:
|
||||
lines = text_block.splitlines()
|
||||
if not lines:
|
||||
return None
|
||||
|
||||
title: Optional[str] = None
|
||||
description_lines: List[str] = []
|
||||
exits: List[str] = []
|
||||
npcs: List[str] = []
|
||||
items: List[str] = []
|
||||
|
||||
# State machine: TITLE -> DESCRIPTION -> ENTITIES/EXITS
|
||||
state = "TITLE"
|
||||
|
||||
for raw_line in lines:
|
||||
line = raw_line.strip()
|
||||
if not line:
|
||||
# Blank line after title means description follows
|
||||
if state == "TITLE" and title:
|
||||
state = "DESCRIPTION"
|
||||
continue
|
||||
|
||||
# Skip prompt lines that might be embedded
|
||||
if self.is_prompt_line(line):
|
||||
continue
|
||||
|
||||
# --- Extract exits from any line ---
|
||||
exit_dirs = self._extract_exits_from_line(line)
|
||||
if exit_dirs:
|
||||
exits.extend(exit_dirs)
|
||||
continue
|
||||
|
||||
# --- Item detection (check before NPC: "is here" alone = item) ---
|
||||
item_match = self._ITEM_RE.match(line)
|
||||
if item_match:
|
||||
items.append(item_match.group(1).strip())
|
||||
continue
|
||||
|
||||
# --- NPC detection ---
|
||||
npc_match = self._NPC_RE.match(line)
|
||||
if npc_match:
|
||||
npcs.append(npc_match.group(1).strip())
|
||||
continue
|
||||
|
||||
# --- Title / Description ---
|
||||
if state == "TITLE":
|
||||
# In Dead Souls, the first non-empty, non-exit, non-entity
|
||||
# line is the room title. Titles are typically short (< 60
|
||||
# chars) and do not end with a period.
|
||||
if len(line) < 80 and not line.endswith("."):
|
||||
title = line
|
||||
else:
|
||||
# Long first line — might be description without a
|
||||
# separate title.
|
||||
title = None
|
||||
description_lines.append(line)
|
||||
state = "DESCRIPTION"
|
||||
elif state == "DESCRIPTION":
|
||||
description_lines.append(line)
|
||||
|
||||
if not title and not description_lines and not exits:
|
||||
return None
|
||||
|
||||
return ParsedRoom(
|
||||
title=title,
|
||||
description="\n".join(description_lines) if description_lines else None,
|
||||
exits=exits,
|
||||
npcs=npcs,
|
||||
items=items,
|
||||
raw_text=text_block,
|
||||
)
|
||||
|
||||
def is_room_description(self, text_block: str) -> bool:
|
||||
"""Heuristic: it looks like a room if it has exits."""
|
||||
return bool(
|
||||
self._EXITS_LONG_RE.search(text_block)
|
||||
or self._EXITS_SHORT_RE.search(text_block)
|
||||
or self._EXITS_BRACKET_RE.search(text_block)
|
||||
)
|
||||
|
||||
def parse_combat(self, line: str) -> Optional[ParsedCombat]:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
return None
|
||||
|
||||
# Flee
|
||||
m = self._COMBAT_FLEE_RE.search(line)
|
||||
if m:
|
||||
return ParsedCombat(
|
||||
is_combat=True,
|
||||
attacker="You",
|
||||
action="flee",
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
# Kill
|
||||
m = self._COMBAT_KILL_RE.search(line)
|
||||
if m:
|
||||
return ParsedCombat(
|
||||
is_combat=True,
|
||||
attacker=m.group("attacker").strip(),
|
||||
target=m.group("target").strip(),
|
||||
action="kill",
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
# Hit with damage
|
||||
m = self._COMBAT_HIT_RE.search(line)
|
||||
if m:
|
||||
return ParsedCombat(
|
||||
is_combat=True,
|
||||
attacker=m.group("attacker").strip(),
|
||||
target=m.group("target").strip(),
|
||||
damage=int(m.group("damage")),
|
||||
action="hit",
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
# Miss
|
||||
m = self._COMBAT_MISS_RE.search(line)
|
||||
if m:
|
||||
return ParsedCombat(
|
||||
is_combat=True,
|
||||
attacker=m.group("attacker").strip(),
|
||||
target=m.group("target").strip(),
|
||||
action="miss",
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
# Attack (initiation)
|
||||
m = self._COMBAT_ATTACK_RE.search(line)
|
||||
if m:
|
||||
return ParsedCombat(
|
||||
is_combat=True,
|
||||
attacker=m.group("attacker").strip(),
|
||||
target=m.group("target").strip(),
|
||||
action="attack",
|
||||
raw_text=line,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Private helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _extract_exits_from_line(self, line: str) -> List[str]:
|
||||
"""Try all exit patterns on a single line."""
|
||||
for pattern in (self._EXITS_LONG_RE, self._EXITS_SHORT_RE, self._EXITS_BRACKET_RE):
|
||||
m = pattern.search(line)
|
||||
if m:
|
||||
return self._parse_exit_list(m.group(1))
|
||||
return []
|
||||
|
||||
def _parse_exit_list(self, raw: str) -> List[str]:
|
||||
"""Normalize an exit list string into a list of direction words.
|
||||
|
||||
Handles formats like:
|
||||
- ``"north, south, and east"``
|
||||
- ``"north and south"``
|
||||
- ``"n s w"``
|
||||
- ``"north, south, east"``
|
||||
"""
|
||||
# Remove "and" and commas, then split on whitespace
|
||||
cleaned = raw.replace(",", " ").replace(" and ", " ")
|
||||
tokens = cleaned.split()
|
||||
# Only keep known directions
|
||||
return [t.lower() for t in tokens if t.lower() in self._DIRECTIONS]
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Factory for creating MUD-specific parsers.
|
||||
|
||||
New MUD support is added by:
|
||||
|
||||
1. Creating a subclass of ``BaseMudParser`` (e.g. ``AardwolfParser``).
|
||||
2. Registering it here via ``ParserFactory.register()`` or by adding it
|
||||
to ``_BUILTIN_PARSERS``.
|
||||
3. Setting ``mud.mud_type`` in ``config.yaml`` to the registered name.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Dict, Type
|
||||
|
||||
from .base import BaseMudParser
|
||||
from .dead_souls import DeadSoulsParser
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Built-in parsers shipped with the project
|
||||
_BUILTIN_PARSERS: Dict[str, Type[BaseMudParser]] = {
|
||||
"dead_souls": DeadSoulsParser,
|
||||
}
|
||||
|
||||
|
||||
class ParserFactory:
|
||||
"""Creates the correct ``BaseMudParser`` subclass for a given MUD type."""
|
||||
|
||||
_registry: Dict[str, Type[BaseMudParser]] = dict(_BUILTIN_PARSERS)
|
||||
|
||||
@classmethod
|
||||
def create(cls, mud_type: str) -> BaseMudParser:
|
||||
"""Instantiate a parser for *mud_type*.
|
||||
|
||||
Raises ``ValueError`` if *mud_type* is not registered.
|
||||
"""
|
||||
parser_cls = cls._registry.get(mud_type)
|
||||
if parser_cls is None:
|
||||
available = ", ".join(sorted(cls._registry.keys()))
|
||||
raise ValueError(
|
||||
f"Unknown MUD type '{mud_type}'. "
|
||||
f"Available parsers: {available}"
|
||||
)
|
||||
logger.info("Creating parser for MUD type: %s", mud_type)
|
||||
return parser_cls()
|
||||
|
||||
@classmethod
|
||||
def register(cls, mud_type: str, parser_cls: Type[BaseMudParser]) -> None:
|
||||
"""Register a custom parser at runtime."""
|
||||
cls._registry[mud_type] = parser_cls
|
||||
logger.info("Registered parser '%s' -> %s", mud_type, parser_cls.__name__)
|
||||
|
||||
@classmethod
|
||||
def available_types(cls) -> list:
|
||||
"""Return the list of registered MUD type names."""
|
||||
return sorted(cls._registry.keys())
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Telnet client for MUD connection"""
|
||||
|
||||
import asyncio
|
||||
import telnetlib3
|
||||
from typing import Optional, Callable
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TelnetClient:
|
||||
"""Asynchronous Telnet client for MUD servers"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
port: int,
|
||||
max_retries: int = 3,
|
||||
retry_delay: float = 5.0,
|
||||
auto_reconnect: bool = True,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.max_retries = max_retries
|
||||
self.retry_delay = retry_delay
|
||||
self.auto_reconnect = auto_reconnect
|
||||
|
||||
self.reader: Optional[telnetlib3.TelnetReader] = None
|
||||
self.writer: Optional[telnetlib3.TelnetWriter] = None
|
||||
self.connected = False
|
||||
self.on_receive: Optional[Callable] = None
|
||||
self._reconnect_count = 0
|
||||
|
||||
async def connect(self) -> bool:
|
||||
"""Connect to the MUD server (single attempt).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if connection successful.
|
||||
"""
|
||||
try:
|
||||
logger.info("Connecting to %s:%s", self.host, self.port)
|
||||
self.reader, self.writer = await telnetlib3.open_connection(
|
||||
self.host,
|
||||
self.port,
|
||||
)
|
||||
self.connected = True
|
||||
self._reconnect_count = 0
|
||||
logger.info("Connected successfully")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error("Connection failed: %s", e)
|
||||
return False
|
||||
|
||||
async def connect_with_retry(self) -> bool:
|
||||
"""Try to connect up to ``max_retries`` times.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if eventually connected.
|
||||
"""
|
||||
for attempt in range(1, self.max_retries + 1):
|
||||
logger.info(
|
||||
"Connection attempt %d/%d", attempt, self.max_retries
|
||||
)
|
||||
if await self.connect():
|
||||
return True
|
||||
if attempt < self.max_retries:
|
||||
logger.info(
|
||||
"Retrying in %.1f seconds...", self.retry_delay
|
||||
)
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
|
||||
logger.error(
|
||||
"Failed to connect after %d attempts", self.max_retries
|
||||
)
|
||||
return False
|
||||
|
||||
async def disconnect(self):
|
||||
"""Disconnect from the MUD server"""
|
||||
self.connected = False
|
||||
if self.writer:
|
||||
try:
|
||||
self.writer.close()
|
||||
await self.writer.wait_closed()
|
||||
except Exception:
|
||||
pass # Best-effort close
|
||||
logger.info("Disconnected")
|
||||
|
||||
async def send(self, command: str):
|
||||
"""Send a command to the MUD server.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
command:
|
||||
Command string to send.
|
||||
"""
|
||||
if not self.connected or not self.writer:
|
||||
logger.warning("Not connected, cannot send command")
|
||||
return
|
||||
|
||||
self.writer.write(command + "\n")
|
||||
await self.writer.drain()
|
||||
logger.debug("Sent: %s", command)
|
||||
|
||||
async def receive_loop(self):
|
||||
"""Main receive loop — call this in a task.
|
||||
|
||||
If ``auto_reconnect`` is enabled, the loop will attempt to
|
||||
re-establish the connection when it drops unexpectedly.
|
||||
"""
|
||||
while True:
|
||||
if not self.connected or not self.reader:
|
||||
break
|
||||
|
||||
try:
|
||||
while self.connected:
|
||||
data = await self.reader.read(4096)
|
||||
if not data:
|
||||
logger.warning("Server closed the connection")
|
||||
break
|
||||
|
||||
if self.on_receive:
|
||||
await self.on_receive(data)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Receive loop error: %s", e)
|
||||
|
||||
# Connection lost
|
||||
self.connected = False
|
||||
|
||||
if not self.auto_reconnect:
|
||||
break
|
||||
|
||||
self._reconnect_count += 1
|
||||
if self._reconnect_count > self.max_retries:
|
||||
logger.error(
|
||||
"Max reconnect attempts (%d) reached, giving up",
|
||||
self.max_retries,
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(
|
||||
"Attempting reconnect %d/%d in %.1f seconds...",
|
||||
self._reconnect_count,
|
||||
self.max_retries,
|
||||
self.retry_delay,
|
||||
)
|
||||
await asyncio.sleep(self.retry_delay)
|
||||
|
||||
if await self.connect():
|
||||
logger.info("Reconnected successfully")
|
||||
# Note: the caller's login logic must be re-triggered.
|
||||
# This is handled by the on_receive callback seeing the
|
||||
# login prompt again.
|
||||
else:
|
||||
logger.error("Reconnect failed")
|
||||
break
|
||||
|
||||
def set_receive_callback(self, callback: Callable):
|
||||
"""Set callback function for received data"""
|
||||
self.on_receive = callback
|
||||
+368
@@ -0,0 +1,368 @@
|
||||
"""Main application entry point"""
|
||||
|
||||
import asyncio
|
||||
import argparse
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from core.telnet_client import TelnetClient
|
||||
from core.ansi_parser import ANSIParser
|
||||
from core.game_state import GameState
|
||||
from core.parsers import ParserFactory, OutputBuffer, EventType
|
||||
from config.settings import load_config
|
||||
from utils.logger import setup_logger
|
||||
from utils.human_behavior import HumanBehavior
|
||||
from modules.decision import DecisionEngine
|
||||
from modules.decision.combat_ai import CombatConfig
|
||||
from modules.mapper import WorldMapper
|
||||
from modules.character import CharacterCreator, CreatorConfig
|
||||
from modules.quest import QuestManager
|
||||
from modules.progression import ProgressionOptimizer
|
||||
from modules.llm import LLMBrain, LLMConfig, SocialAI
|
||||
|
||||
logger = None
|
||||
|
||||
|
||||
async def main_loop(config):
|
||||
"""Main game loop"""
|
||||
global logger
|
||||
|
||||
# Initialize components
|
||||
telnet = TelnetClient(config.mud.host, config.mud.port)
|
||||
ansi_parser = ANSIParser()
|
||||
game_state = GameState()
|
||||
human_behavior = HumanBehavior(
|
||||
config.behavior.reaction_time_min_ms,
|
||||
config.behavior.reaction_time_max_ms,
|
||||
config.behavior.pause_probability,
|
||||
)
|
||||
|
||||
# Create MUD-specific parser via factory
|
||||
mud_parser = ParserFactory.create(config.mud.mud_type)
|
||||
output_buffer = OutputBuffer(mud_parser)
|
||||
|
||||
# Initialize Decision Engine (Phase 4)
|
||||
combat_cfg = CombatConfig()
|
||||
decision_engine = DecisionEngine(
|
||||
combat_config=combat_cfg,
|
||||
exploration_strategy=config.ai.exploration_strategy,
|
||||
)
|
||||
decision_engine.goal_manager.set_default_goals()
|
||||
|
||||
# Initialize WorldMapper (Phase 3 — hybrid SQLite/Memvid)
|
||||
world_mapper = WorldMapper(db_path=config.mapper.active_storage.db_path)
|
||||
|
||||
# Initialize Character Creator (Phase 2)
|
||||
creator_cfg = CreatorConfig(
|
||||
preferred_race=config.character.preferred_race,
|
||||
preferred_class=config.character.preferred_class,
|
||||
)
|
||||
character_creator = CharacterCreator(creator_cfg)
|
||||
|
||||
# Initialize Quest Manager & Progression Optimizer
|
||||
quest_manager = QuestManager()
|
||||
progression = ProgressionOptimizer()
|
||||
|
||||
# Initialize LLM Brain (hybrid AI)
|
||||
llm_config = LLMConfig(
|
||||
base_url=config.ai.llm.base_url,
|
||||
model=config.ai.llm.model,
|
||||
temperature=config.ai.llm.temperature,
|
||||
max_tokens=config.ai.llm.max_tokens,
|
||||
timeout_seconds=config.ai.llm.timeout_seconds,
|
||||
max_retries=config.ai.llm.max_retries,
|
||||
system_prompt=config.ai.llm.system_prompt,
|
||||
)
|
||||
llm_brain = LLMBrain(llm_config, memory_db_path=config.ai.llm.memory_db_path)
|
||||
social_ai = SocialAI(
|
||||
client=llm_brain.client,
|
||||
character_name=config.character.name,
|
||||
character_class=config.character.preferred_class,
|
||||
)
|
||||
if config.ai.llm.enabled:
|
||||
await llm_brain.initialize()
|
||||
|
||||
logger.info(
|
||||
"Parser: %s (mud_type=%s)",
|
||||
type(mud_parser).__name__,
|
||||
config.mud.mud_type,
|
||||
)
|
||||
logger.info(
|
||||
"Decision engine ready | strategy=%s | goals=%d",
|
||||
config.ai.exploration_strategy,
|
||||
decision_engine.goal_manager.active_goal_count,
|
||||
)
|
||||
logger.info(
|
||||
"WorldMapper ready | rooms=%d | exits=%d",
|
||||
world_mapper.total_rooms,
|
||||
world_mapper.total_exits,
|
||||
)
|
||||
logger.info(
|
||||
"LLM Brain: %s | model=%s",
|
||||
"online" if llm_brain.is_available else "offline (rule-based fallback)",
|
||||
config.ai.llm.model,
|
||||
)
|
||||
|
||||
# Connect to MUD
|
||||
logger.info("Connecting to %s:%s", config.mud.host, config.mud.port)
|
||||
if not await telnet.connect():
|
||||
logger.error("Failed to connect to MUD server")
|
||||
return
|
||||
|
||||
# Login state machine
|
||||
login_state = "username" # username -> password -> logged_in
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Receive callback — called for every chunk from the server
|
||||
# ------------------------------------------------------------------
|
||||
async def on_receive(data: str):
|
||||
nonlocal login_state
|
||||
|
||||
# Strip ANSI codes
|
||||
clean_text = ansi_parser.strip_ansi(data)
|
||||
logger.debug("Received: %s", clean_text.strip()[:200])
|
||||
|
||||
# ----- Handle login sequence -----
|
||||
if login_state == "username":
|
||||
if "login" in clean_text.lower() or "name" in clean_text.lower():
|
||||
logger.info("Sending username: %s", config.mud.username)
|
||||
await human_behavior.delay()
|
||||
await telnet.send(config.mud.username)
|
||||
login_state = "password"
|
||||
return
|
||||
|
||||
elif login_state == "password":
|
||||
if "password" in clean_text.lower():
|
||||
logger.info("Sending password")
|
||||
await human_behavior.delay()
|
||||
await telnet.send(config.mud.password)
|
||||
login_state = "logged_in"
|
||||
return
|
||||
|
||||
# Handle reconnect / take over
|
||||
if "take over" in clean_text.lower() or "copy" in clean_text.lower():
|
||||
logger.info("Taking over existing session")
|
||||
await human_behavior.delay()
|
||||
await telnet.send("y")
|
||||
login_state = "logged_in"
|
||||
return
|
||||
|
||||
# ----- Game logic (only after login) -----
|
||||
if login_state != "logged_in":
|
||||
return
|
||||
|
||||
# Feed text into the output buffer; get back complete blocks
|
||||
blocks = output_buffer.feed(clean_text)
|
||||
|
||||
for block in blocks:
|
||||
# Parse the block into structured events
|
||||
events = mud_parser.parse_block(block)
|
||||
|
||||
for event in events:
|
||||
if event.event_type == EventType.PROMPT and event.prompt:
|
||||
game_state.update_from_prompt(event.prompt)
|
||||
logger.info(
|
||||
"Status: HP=%d/%d MP=%d/%d SP=%d/%d",
|
||||
event.prompt.hp or 0,
|
||||
event.prompt.max_hp or 0,
|
||||
event.prompt.mp or 0,
|
||||
event.prompt.max_mp or 0,
|
||||
event.prompt.sp or 0,
|
||||
event.prompt.max_sp or 0,
|
||||
)
|
||||
|
||||
elif event.event_type == EventType.ROOM and event.room:
|
||||
game_state.update_room(event.room)
|
||||
# Update automapper
|
||||
world_mapper.on_room_enter(
|
||||
title=event.room.title or "",
|
||||
description=event.room.description or "",
|
||||
exits=event.room.exits,
|
||||
npcs=event.room.npcs,
|
||||
items=event.room.items,
|
||||
)
|
||||
logger.info(
|
||||
"Room: '%s' | Exits: %s | NPCs: %s | Items: %s | mapped=%d",
|
||||
event.room.title,
|
||||
event.room.exits,
|
||||
event.room.npcs,
|
||||
event.room.items,
|
||||
world_mapper.total_rooms,
|
||||
)
|
||||
|
||||
elif event.event_type == EventType.COMBAT and event.combat:
|
||||
game_state.update_combat(event.combat)
|
||||
logger.info(
|
||||
"Combat: %s -> %s (%s, dmg=%s)",
|
||||
event.combat.attacker,
|
||||
event.combat.target,
|
||||
event.combat.action,
|
||||
event.combat.damage,
|
||||
)
|
||||
|
||||
# --- Social AI: detect player messages in the raw block ---
|
||||
if llm_brain.is_available:
|
||||
social_event = social_ai.detect_social_event(block)
|
||||
if social_event:
|
||||
speaker = social_event["speaker"]
|
||||
message = social_event["message"]
|
||||
event_type = social_event["type"]
|
||||
logger.info(
|
||||
"Social: %s from %s: %s",
|
||||
event_type,
|
||||
speaker,
|
||||
message[:100],
|
||||
)
|
||||
# Use LLMBrain with memory for contextual replies
|
||||
reply_text = await llm_brain.social_reply(
|
||||
message=message,
|
||||
sender=speaker,
|
||||
character_class=config.character.preferred_class,
|
||||
character_name=config.character.name,
|
||||
)
|
||||
if reply_text:
|
||||
# Format as MUD command
|
||||
if event_type == "tell":
|
||||
reply_cmd = f"tell {speaker} {reply_text}"
|
||||
elif event_type == "shout":
|
||||
reply_cmd = f"shout {reply_text}"
|
||||
else:
|
||||
reply_cmd = f"say {reply_text}"
|
||||
await human_behavior.delay()
|
||||
await telnet.send(reply_cmd)
|
||||
logger.info("Social reply: %s", reply_cmd)
|
||||
|
||||
telnet.set_receive_callback(on_receive)
|
||||
|
||||
# Start receive loop
|
||||
receive_task = asyncio.create_task(telnet.receive_loop())
|
||||
|
||||
try:
|
||||
# Main game loop
|
||||
logger.info("Starting main game loop")
|
||||
|
||||
# Keep connection alive
|
||||
last_keep_alive = asyncio.get_running_loop().time()
|
||||
last_decision = asyncio.get_running_loop().time()
|
||||
|
||||
# Decision interval: how often (in seconds) the engine ticks
|
||||
decision_interval_min = config.ai.decision_delay_ms[0] / 1000.0
|
||||
decision_interval_max = (
|
||||
config.ai.decision_delay_ms[1] / 1000.0
|
||||
if len(config.ai.decision_delay_ms) > 1
|
||||
else decision_interval_min
|
||||
)
|
||||
import random as _rng
|
||||
next_decision_delay = _rng.uniform(decision_interval_min, decision_interval_max)
|
||||
|
||||
while telnet.connected:
|
||||
current_time = asyncio.get_running_loop().time()
|
||||
|
||||
# Send keep-alive if logged in
|
||||
if login_state == "logged_in":
|
||||
if current_time - last_keep_alive >= config.mud.keep_alive_interval:
|
||||
logger.debug(
|
||||
"Sending keep-alive: '%s'", config.mud.keep_alive_command
|
||||
)
|
||||
await telnet.send(config.mud.keep_alive_command)
|
||||
last_keep_alive = current_time
|
||||
|
||||
# ----- Decision Engine Tick -----
|
||||
if current_time - last_decision >= next_decision_delay:
|
||||
# Let the engine evaluate the world and queue actions
|
||||
decision_engine.tick(game_state)
|
||||
|
||||
# Execute the next action from the queue
|
||||
action = decision_engine.next_action()
|
||||
if action:
|
||||
logger.info(
|
||||
"Executing: '%s' [%s] (%s)",
|
||||
action.command,
|
||||
action.priority.name,
|
||||
action.description,
|
||||
)
|
||||
# Notify mapper of movement commands
|
||||
if action.source == "explore" and action.priority.name == "MOVE":
|
||||
world_mapper.on_move(action.command)
|
||||
await human_behavior.delay()
|
||||
await telnet.send(action.command)
|
||||
|
||||
last_decision = current_time
|
||||
next_decision_delay = _rng.uniform(
|
||||
decision_interval_min, decision_interval_max
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.25)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("Shutting down...")
|
||||
finally:
|
||||
# Flush any remaining buffered text
|
||||
remaining = output_buffer.flush()
|
||||
if remaining:
|
||||
events = mud_parser.parse_block(remaining)
|
||||
for event in events:
|
||||
if event.event_type == EventType.ROOM and event.room:
|
||||
game_state.update_room(event.room)
|
||||
|
||||
# Close mapper and save state
|
||||
logger.info(
|
||||
"Session stats: %d rooms mapped, %d exits",
|
||||
world_mapper.total_rooms, world_mapper.total_exits,
|
||||
)
|
||||
world_mapper.close()
|
||||
|
||||
await telnet.disconnect()
|
||||
receive_task.cancel()
|
||||
|
||||
|
||||
def main():
|
||||
"""Entry point"""
|
||||
global logger
|
||||
|
||||
# Parse arguments
|
||||
parser = argparse.ArgumentParser(description="MUD AI Player")
|
||||
parser.add_argument(
|
||||
"--config",
|
||||
default="config.yaml",
|
||||
help="Path to configuration file",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Load configuration
|
||||
try:
|
||||
config = load_config(args.config)
|
||||
except FileNotFoundError:
|
||||
print(f"Error: Configuration file '{args.config}' not found")
|
||||
print("Please create a config.yaml file (see examples/config.example.yaml)")
|
||||
sys.exit(1)
|
||||
except Exception as e:
|
||||
print(f"Error loading configuration: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
# Setup logging
|
||||
logger = setup_logger(
|
||||
level=config.logging.level,
|
||||
log_file=config.logging.file,
|
||||
console=config.logging.console,
|
||||
max_file_size_mb=config.logging.max_file_size_mb,
|
||||
backup_count=config.logging.backup_count,
|
||||
)
|
||||
|
||||
logger.info("=" * 60)
|
||||
logger.info("MUD AI Player Starting")
|
||||
logger.info("=" * 60)
|
||||
|
||||
# Run main loop
|
||||
try:
|
||||
asyncio.run(main_loop(config))
|
||||
except Exception as e:
|
||||
logger.exception("Fatal error: %s", e)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Specialized modules package"""
|
||||
|
||||
__all__ = []
|
||||
@@ -0,0 +1,5 @@
|
||||
"""Character creation module."""
|
||||
|
||||
from .creator import CharacterCreator, CreatorConfig, StatRoll, CreationPhase
|
||||
|
||||
__all__ = ["CharacterCreator", "CreatorConfig", "StatRoll", "CreationPhase"]
|
||||
@@ -0,0 +1,244 @@
|
||||
"""Character creation automation for Dead Souls MUD.
|
||||
|
||||
Handles the entire character creation flow:
|
||||
1. Detect creation prompts from server output.
|
||||
2. Choose race, class, and gender based on preferences.
|
||||
3. Roll and optimise stats (STR, INT, WIS, DEX, CON, CHA).
|
||||
4. Allocate initial skill points.
|
||||
5. Send responses via the Telnet client.
|
||||
|
||||
The ``CharacterCreator`` is a state machine driven by server text.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CreatorConfig:
|
||||
"""Preferences for automated character creation."""
|
||||
|
||||
preferred_race: str = "human"
|
||||
preferred_class: str = "warrior"
|
||||
preferred_gender: str = "male"
|
||||
|
||||
# Minimum acceptable total stat points before accepting a roll.
|
||||
min_stat_total: int = 70
|
||||
|
||||
# Stat weights per class (used to evaluate a roll).
|
||||
class_stat_weights: Dict[str, Dict[str, float]] = field(default_factory=lambda: {
|
||||
"warrior": {"str": 2.0, "con": 1.5, "dex": 1.0, "int": 0.5, "wis": 0.5, "cha": 0.5},
|
||||
"mage": {"int": 2.0, "wis": 1.5, "con": 1.0, "dex": 0.5, "str": 0.5, "cha": 0.5},
|
||||
"cleric": {"wis": 2.0, "con": 1.5, "int": 1.0, "str": 0.5, "dex": 0.5, "cha": 1.0},
|
||||
"thief": {"dex": 2.0, "str": 1.0, "con": 1.0, "int": 0.5, "wis": 0.5, "cha": 1.0},
|
||||
"ranger": {"dex": 1.5, "str": 1.5, "con": 1.0, "wis": 1.0, "int": 0.5, "cha": 0.5},
|
||||
})
|
||||
|
||||
max_rerolls: int = 20
|
||||
"""Maximum number of stat re-rolls before accepting whatever we get."""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Creation state machine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class CreationPhase(Enum):
|
||||
"""Phases of the character creation flow."""
|
||||
WAITING = auto() # Waiting for creation to start
|
||||
CHOOSE_NAME = auto()
|
||||
CHOOSE_RACE = auto()
|
||||
CHOOSE_CLASS = auto()
|
||||
CHOOSE_GENDER = auto()
|
||||
ROLL_STATS = auto()
|
||||
ALLOCATE_SKILLS = auto()
|
||||
CONFIRM = auto()
|
||||
DONE = auto()
|
||||
|
||||
|
||||
# Patterns to detect creation prompts (Dead Souls)
|
||||
_PATTERNS = {
|
||||
"race_prompt": re.compile(r"(?:choose|select|pick).*(?:race|species)", re.I),
|
||||
"class_prompt": re.compile(r"(?:choose|select|pick).*(?:class|profession|guild)", re.I),
|
||||
"gender_prompt": re.compile(r"(?:choose|select|pick).*(?:gender|sex)", re.I),
|
||||
"stat_display": re.compile(
|
||||
r"str[ength]*\s*[:=]\s*(\d+).*?"
|
||||
r"int[elligence]*\s*[:=]\s*(\d+).*?"
|
||||
r"wis[dom]*\s*[:=]\s*(\d+).*?"
|
||||
r"dex[terity]*\s*[:=]\s*(\d+).*?"
|
||||
r"con[stitution]*\s*[:=]\s*(\d+).*?"
|
||||
r"cha[risma]*\s*[:=]\s*(\d+)",
|
||||
re.I | re.DOTALL,
|
||||
),
|
||||
"reroll_prompt": re.compile(r"(?:reroll|roll again|keep|accept)", re.I),
|
||||
"confirm": re.compile(r"(?:confirm|are you sure|create this character)", re.I),
|
||||
"skill_prompt": re.compile(r"(?:allocate|distribute|spend).*(?:skill|point)", re.I),
|
||||
"creation_done": re.compile(r"(?:welcome|character created|you enter the world)", re.I),
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class StatRoll:
|
||||
"""A single stat roll result."""
|
||||
strength: int = 0
|
||||
intelligence: int = 0
|
||||
wisdom: int = 0
|
||||
dexterity: int = 0
|
||||
constitution: int = 0
|
||||
charisma: int = 0
|
||||
|
||||
@property
|
||||
def total(self) -> int:
|
||||
return (
|
||||
self.strength + self.intelligence + self.wisdom
|
||||
+ self.dexterity + self.constitution + self.charisma
|
||||
)
|
||||
|
||||
def weighted_score(self, weights: Dict[str, float]) -> float:
|
||||
"""Compute a weighted score based on class priorities."""
|
||||
return (
|
||||
self.strength * weights.get("str", 1)
|
||||
+ self.intelligence * weights.get("int", 1)
|
||||
+ self.wisdom * weights.get("wis", 1)
|
||||
+ self.dexterity * weights.get("dex", 1)
|
||||
+ self.constitution * weights.get("con", 1)
|
||||
+ self.charisma * weights.get("cha", 1)
|
||||
)
|
||||
|
||||
|
||||
class CharacterCreator:
|
||||
"""Drives the character creation state machine.
|
||||
|
||||
Usage::
|
||||
|
||||
creator = CharacterCreator(config)
|
||||
# For each chunk of server text:
|
||||
response = creator.process(server_text)
|
||||
if response:
|
||||
await telnet.send(response)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[CreatorConfig] = None) -> None:
|
||||
self.config = config or CreatorConfig()
|
||||
self.phase = CreationPhase.WAITING
|
||||
self._reroll_count: int = 0
|
||||
self._best_roll: Optional[StatRoll] = None
|
||||
self._best_score: float = 0.0
|
||||
self._current_roll: Optional[StatRoll] = None
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return self.phase == CreationPhase.DONE
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main processing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def process(self, text: str) -> Optional[str]:
|
||||
"""Analyse server text and return a response command, or ``None``.
|
||||
|
||||
Each call may advance the creation phase.
|
||||
"""
|
||||
text_lower = text.lower()
|
||||
|
||||
# Check for completion
|
||||
if _PATTERNS["creation_done"].search(text):
|
||||
self.phase = CreationPhase.DONE
|
||||
logger.info("Character creation complete!")
|
||||
return None
|
||||
|
||||
# Race selection
|
||||
if _PATTERNS["race_prompt"].search(text):
|
||||
self.phase = CreationPhase.CHOOSE_RACE
|
||||
logger.info("Choosing race: %s", self.config.preferred_race)
|
||||
return self.config.preferred_race
|
||||
|
||||
# Class selection
|
||||
if _PATTERNS["class_prompt"].search(text):
|
||||
self.phase = CreationPhase.CHOOSE_CLASS
|
||||
logger.info("Choosing class: %s", self.config.preferred_class)
|
||||
return self.config.preferred_class
|
||||
|
||||
# Gender selection
|
||||
if _PATTERNS["gender_prompt"].search(text):
|
||||
self.phase = CreationPhase.CHOOSE_GENDER
|
||||
logger.info("Choosing gender: %s", self.config.preferred_gender)
|
||||
return self.config.preferred_gender
|
||||
|
||||
# Stat display — evaluate the roll
|
||||
stat_match = _PATTERNS["stat_display"].search(text)
|
||||
if stat_match:
|
||||
self._current_roll = StatRoll(
|
||||
strength=int(stat_match.group(1)),
|
||||
intelligence=int(stat_match.group(2)),
|
||||
wisdom=int(stat_match.group(3)),
|
||||
dexterity=int(stat_match.group(4)),
|
||||
constitution=int(stat_match.group(5)),
|
||||
charisma=int(stat_match.group(6)),
|
||||
)
|
||||
self.phase = CreationPhase.ROLL_STATS
|
||||
return self._evaluate_roll()
|
||||
|
||||
# Reroll prompt (without visible stats — just confirm)
|
||||
if _PATTERNS["reroll_prompt"].search(text) and self.phase == CreationPhase.ROLL_STATS:
|
||||
return self._evaluate_roll()
|
||||
|
||||
# Skill allocation
|
||||
if _PATTERNS["skill_prompt"].search(text):
|
||||
self.phase = CreationPhase.ALLOCATE_SKILLS
|
||||
# For now, just accept defaults
|
||||
logger.info("Skill allocation — accepting defaults")
|
||||
return "done"
|
||||
|
||||
# Confirmation
|
||||
if _PATTERNS["confirm"].search(text):
|
||||
self.phase = CreationPhase.CONFIRM
|
||||
logger.info("Confirming character creation")
|
||||
return "yes"
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stat evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _evaluate_roll(self) -> str:
|
||||
"""Decide whether to keep or reroll the current stats."""
|
||||
if self._current_roll is None:
|
||||
return "reroll"
|
||||
|
||||
weights = self.config.class_stat_weights.get(
|
||||
self.config.preferred_class, {}
|
||||
)
|
||||
score = self._current_roll.weighted_score(weights)
|
||||
|
||||
if self._best_roll is None or score > self._best_score:
|
||||
self._best_roll = self._current_roll
|
||||
self._best_score = score
|
||||
|
||||
self._reroll_count += 1
|
||||
|
||||
# Accept if good enough or we've hit the reroll limit
|
||||
total = self._current_roll.total
|
||||
if total >= self.config.min_stat_total or self._reroll_count >= self.config.max_rerolls:
|
||||
logger.info(
|
||||
"Accepting stats (total=%d, score=%.1f, rerolls=%d): %s",
|
||||
total, score, self._reroll_count, self._current_roll,
|
||||
)
|
||||
return "keep"
|
||||
|
||||
logger.debug(
|
||||
"Rerolling (total=%d, score=%.1f, attempt=%d/%d)",
|
||||
total, score, self._reroll_count, self.config.max_rerolls,
|
||||
)
|
||||
return "reroll"
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Decision engine module — Phase 4: The Brain.
|
||||
|
||||
Public API::
|
||||
|
||||
from modules.decision import DecisionEngine, CombatAI, GoalManager
|
||||
|
||||
Sub-modules
|
||||
-----------
|
||||
action_queue Priority-ordered action queue.
|
||||
behavior_states Concrete behavior states (Idle, Explore, Combat, Loot).
|
||||
combat_ai Intelligent combat decision-making.
|
||||
engine Central decision engine / state machine.
|
||||
goal_manager High-level goal tracking and prioritisation.
|
||||
"""
|
||||
|
||||
from .engine import DecisionEngine
|
||||
from .combat_ai import CombatAI, CombatConfig
|
||||
from .goal_manager import GoalManager, Goal, GoalPriority, GoalStatus
|
||||
from .action_queue import ActionQueue, Action, ActionPriority
|
||||
from .behavior_states import (
|
||||
BaseBehaviorState,
|
||||
IdleState,
|
||||
ExploreState,
|
||||
CombatState,
|
||||
LootState,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DecisionEngine",
|
||||
"CombatAI",
|
||||
"CombatConfig",
|
||||
"GoalManager",
|
||||
"Goal",
|
||||
"GoalPriority",
|
||||
"GoalStatus",
|
||||
"ActionQueue",
|
||||
"Action",
|
||||
"ActionPriority",
|
||||
"BaseBehaviorState",
|
||||
"IdleState",
|
||||
"ExploreState",
|
||||
"CombatState",
|
||||
"LootState",
|
||||
]
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Priority-based action queue for the decision engine.
|
||||
|
||||
Actions are ordered by priority so that survival-critical actions (healing,
|
||||
fleeing) always execute before offensive or exploratory ones.
|
||||
|
||||
Priority levels (lower number = higher priority):
|
||||
0 - EMERGENCY (flee when near death)
|
||||
1 - HEAL (restore HP/MP/SP)
|
||||
2 - COMBAT (attack, use combat skills)
|
||||
3 - LOOT (pick up items from corpses)
|
||||
4 - INTERACT (talk to NPCs, open doors)
|
||||
5 - MOVE (navigate / explore)
|
||||
6 - IDLE (look around, rest)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import heapq
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import Optional, List
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ActionPriority(IntEnum):
|
||||
"""Priority tiers for queued actions (lower = more urgent)."""
|
||||
|
||||
EMERGENCY = 0
|
||||
HEAL = 1
|
||||
COMBAT = 2
|
||||
LOOT = 3
|
||||
INTERACT = 4
|
||||
MOVE = 5
|
||||
IDLE = 6
|
||||
|
||||
|
||||
@dataclass(order=False)
|
||||
class Action:
|
||||
"""A single command to send to the MUD.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
command:
|
||||
The raw MUD command string (e.g. ``"kill goblin"``, ``"north"``).
|
||||
priority:
|
||||
Priority tier — used to order execution.
|
||||
description:
|
||||
Human-readable note for logging/debugging.
|
||||
source:
|
||||
Which subsystem enqueued this action (e.g. ``"combat_ai"``).
|
||||
"""
|
||||
|
||||
command: str
|
||||
priority: ActionPriority = ActionPriority.IDLE
|
||||
description: str = ""
|
||||
source: str = ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"Action({self.command!r}, "
|
||||
f"priority={self.priority.name}, "
|
||||
f"source={self.source!r})"
|
||||
)
|
||||
|
||||
|
||||
class ActionQueue:
|
||||
"""Thread-safe, priority-ordered action queue.
|
||||
|
||||
Uses a min-heap so the action with the *lowest* priority number
|
||||
(most urgent) is always dequeued first. A monotonic counter is used
|
||||
as a tiebreaker so that actions of equal priority are returned in
|
||||
FIFO order.
|
||||
"""
|
||||
|
||||
def __init__(self, max_size: int = 100) -> None:
|
||||
self._heap: List[tuple] = []
|
||||
self._counter = itertools.count()
|
||||
self._max_size = max_size
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def push(self, action: Action) -> None:
|
||||
"""Add an action to the queue.
|
||||
|
||||
If the queue is at capacity the lowest-priority (highest number)
|
||||
item is silently dropped to make room.
|
||||
"""
|
||||
if len(self._heap) >= self._max_size:
|
||||
# Drop the *least* urgent item (max priority number).
|
||||
self._drop_lowest_priority()
|
||||
|
||||
entry = (int(action.priority), next(self._counter), action)
|
||||
heapq.heappush(self._heap, entry)
|
||||
logger.debug("Queued: %s", action)
|
||||
|
||||
def pop(self) -> Optional[Action]:
|
||||
"""Remove and return the most urgent action, or ``None``."""
|
||||
if not self._heap:
|
||||
return None
|
||||
_priority, _count, action = heapq.heappop(self._heap)
|
||||
return action
|
||||
|
||||
def peek(self) -> Optional[Action]:
|
||||
"""Return the most urgent action without removing it."""
|
||||
if not self._heap:
|
||||
return None
|
||||
return self._heap[0][2]
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Discard all queued actions."""
|
||||
self._heap.clear()
|
||||
|
||||
def clear_by_source(self, source: str) -> int:
|
||||
"""Remove all actions enqueued by *source*.
|
||||
|
||||
Returns the number of actions removed.
|
||||
"""
|
||||
before = len(self._heap)
|
||||
self._heap = [
|
||||
entry for entry in self._heap if entry[2].source != source
|
||||
]
|
||||
heapq.heapify(self._heap)
|
||||
removed = before - len(self._heap)
|
||||
if removed:
|
||||
logger.debug("Cleared %d actions from source '%s'", removed, source)
|
||||
return removed
|
||||
|
||||
def clear_by_priority(self, priority: ActionPriority) -> int:
|
||||
"""Remove all actions at the given priority tier.
|
||||
|
||||
Returns the number of actions removed.
|
||||
"""
|
||||
before = len(self._heap)
|
||||
self._heap = [
|
||||
entry for entry in self._heap if entry[0] != int(priority)
|
||||
]
|
||||
heapq.heapify(self._heap)
|
||||
removed = before - len(self._heap)
|
||||
return removed
|
||||
|
||||
@property
|
||||
def size(self) -> int:
|
||||
return len(self._heap)
|
||||
|
||||
@property
|
||||
def is_empty(self) -> bool:
|
||||
return len(self._heap) == 0
|
||||
|
||||
def as_list(self) -> List[Action]:
|
||||
"""Return all queued actions in priority order (non-destructive)."""
|
||||
return [entry[2] for entry in sorted(self._heap)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _drop_lowest_priority(self) -> None:
|
||||
"""Remove the *least* urgent item (highest priority number)."""
|
||||
if not self._heap:
|
||||
return
|
||||
# Find max-priority entry (O(n) but queue is small)
|
||||
worst_idx = 0
|
||||
for i, entry in enumerate(self._heap):
|
||||
if entry[0] > self._heap[worst_idx][0]:
|
||||
worst_idx = i
|
||||
elif entry[0] == self._heap[worst_idx][0] and entry[1] > self._heap[worst_idx][1]:
|
||||
worst_idx = i
|
||||
dropped = self._heap.pop(worst_idx)
|
||||
heapq.heapify(self._heap)
|
||||
logger.debug("Queue full — dropped: %s", dropped[2])
|
||||
@@ -0,0 +1,300 @@
|
||||
"""Behavior states for the decision engine state machine.
|
||||
|
||||
Each concrete state encapsulates the logic for one mode of play and
|
||||
produces ``Action`` objects that get pushed onto the ``ActionQueue``.
|
||||
|
||||
State transitions are determined by the ``DecisionEngine`` based on the
|
||||
current ``GameState``. States themselves are stateless singletons that
|
||||
receive all context via method arguments.
|
||||
|
||||
State hierarchy
|
||||
---------------
|
||||
BaseBehaviorState (ABC)
|
||||
+-- IdleState : heal, buff, look around
|
||||
+-- ExploreState : pick an unexplored exit and move
|
||||
+-- CombatState : fight the current target
|
||||
+-- LootState : pick up items / loot corpses
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import List, TYPE_CHECKING
|
||||
|
||||
from .action_queue import Action, ActionPriority
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Abstract base
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class BaseBehaviorState(ABC):
|
||||
"""Interface for a single behavior mode."""
|
||||
|
||||
@property
|
||||
@abstractmethod
|
||||
def name(self) -> str:
|
||||
"""Human-readable name for logging."""
|
||||
|
||||
@abstractmethod
|
||||
def get_actions(self, game_state: "GameState") -> List[Action]:
|
||||
"""Return zero or more actions appropriate for the current state.
|
||||
|
||||
The engine will push them onto the ``ActionQueue`` in bulk; order
|
||||
within the returned list doesn't matter because the queue sorts
|
||||
by priority.
|
||||
"""
|
||||
|
||||
def on_enter(self, game_state: "GameState") -> None:
|
||||
"""Called once when the engine transitions *into* this state."""
|
||||
logger.info("Entering state: %s", self.name)
|
||||
|
||||
def on_exit(self, game_state: "GameState") -> None:
|
||||
"""Called once when the engine transitions *out of* this state."""
|
||||
logger.debug("Exiting state: %s", self.name)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete states
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class IdleState(BaseBehaviorState):
|
||||
"""Rest / heal / buff when there is nothing urgent to do.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
* If HP < heal_threshold -> queue ``heal`` command.
|
||||
* If MP is low -> queue ``meditate`` (or rest).
|
||||
* Otherwise -> queue ``look`` to refresh room info.
|
||||
"""
|
||||
|
||||
name = "idle"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
heal_threshold: float = 0.8,
|
||||
mp_threshold: float = 0.5,
|
||||
heal_command: str = "heal",
|
||||
rest_command: str = "rest",
|
||||
) -> None:
|
||||
self.heal_threshold = heal_threshold
|
||||
self.mp_threshold = mp_threshold
|
||||
self.heal_command = heal_command
|
||||
self.rest_command = rest_command
|
||||
|
||||
def get_actions(self, game_state: "GameState") -> List[Action]:
|
||||
actions: List[Action] = []
|
||||
char = game_state.character
|
||||
|
||||
# Heal if HP below threshold
|
||||
if char.max_hp > 0 and (char.hp / char.max_hp) < self.heal_threshold:
|
||||
actions.append(Action(
|
||||
command=self.heal_command,
|
||||
priority=ActionPriority.HEAL,
|
||||
description=f"Heal: HP {char.hp}/{char.max_hp}",
|
||||
source="idle",
|
||||
))
|
||||
|
||||
# Rest if MP below threshold
|
||||
if char.max_mp > 0 and (char.mp / char.max_mp) < self.mp_threshold:
|
||||
actions.append(Action(
|
||||
command=self.rest_command,
|
||||
priority=ActionPriority.HEAL,
|
||||
description=f"Rest: MP {char.mp}/{char.max_mp}",
|
||||
source="idle",
|
||||
))
|
||||
|
||||
# If nothing to heal, just look around to refresh room data
|
||||
if not actions:
|
||||
actions.append(Action(
|
||||
command="look",
|
||||
priority=ActionPriority.IDLE,
|
||||
description="Refresh room info",
|
||||
source="idle",
|
||||
))
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
class ExploreState(BaseBehaviorState):
|
||||
"""Navigate the world by choosing unexplored exits.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
* If the current room has exits, pick one (random or systematic).
|
||||
* Prefers exits whose destination is unknown (``room_id == 0``).
|
||||
* Falls back to a random known exit if all are explored.
|
||||
"""
|
||||
|
||||
name = "explore"
|
||||
|
||||
def __init__(self, strategy: str = "random") -> None:
|
||||
self.strategy = strategy # "random" | "systematic"
|
||||
self._visited_directions: List[str] = []
|
||||
|
||||
def get_actions(self, game_state: "GameState") -> List[Action]:
|
||||
room = game_state.current_room
|
||||
if room is None or not room.exits:
|
||||
# No room data — ask the MUD to describe our surroundings
|
||||
return [Action(
|
||||
command="look",
|
||||
priority=ActionPriority.MOVE,
|
||||
description="No room data — looking",
|
||||
source="explore",
|
||||
)]
|
||||
|
||||
# Separate unexplored (room_id == 0) from explored exits
|
||||
unexplored = [d for d, rid in room.exits.items() if rid == 0]
|
||||
explored = [d for d, rid in room.exits.items() if rid != 0]
|
||||
|
||||
if unexplored:
|
||||
direction = self._pick_direction(unexplored)
|
||||
desc = f"Explore unexplored exit: {direction}"
|
||||
elif explored:
|
||||
direction = self._pick_direction(explored)
|
||||
desc = f"Move through explored exit: {direction}"
|
||||
else:
|
||||
# Exits dict is empty after filtering — shouldn't happen, just look
|
||||
return [Action(
|
||||
command="look",
|
||||
priority=ActionPriority.MOVE,
|
||||
description="No exits available",
|
||||
source="explore",
|
||||
)]
|
||||
|
||||
return [Action(
|
||||
command=direction,
|
||||
priority=ActionPriority.MOVE,
|
||||
description=desc,
|
||||
source="explore",
|
||||
)]
|
||||
|
||||
def _pick_direction(self, directions: List[str]) -> str:
|
||||
if self.strategy == "systematic":
|
||||
# Prefer directions we haven't taken recently
|
||||
for d in directions:
|
||||
if d not in self._visited_directions:
|
||||
self._visited_directions.append(d)
|
||||
return d
|
||||
# All visited — reset and pick first
|
||||
self._visited_directions.clear()
|
||||
self._visited_directions.append(directions[0])
|
||||
return directions[0]
|
||||
else:
|
||||
return random.choice(directions)
|
||||
|
||||
|
||||
class CombatState(BaseBehaviorState):
|
||||
"""Fight the current combat target.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
* If HP critically low -> queue emergency flee.
|
||||
* If HP < heal threshold and has heal -> queue heal first, then attack.
|
||||
* Otherwise -> queue attack on current target.
|
||||
"""
|
||||
|
||||
name = "combat"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
flee_threshold: float = 0.2,
|
||||
combat_heal_threshold: float = 0.4,
|
||||
attack_command: str = "kill",
|
||||
flee_command: str = "flee",
|
||||
heal_command: str = "heal",
|
||||
) -> None:
|
||||
self.flee_threshold = flee_threshold
|
||||
self.combat_heal_threshold = combat_heal_threshold
|
||||
self.attack_command = attack_command
|
||||
self.flee_command = flee_command
|
||||
self.heal_command = heal_command
|
||||
|
||||
def get_actions(self, game_state: "GameState") -> List[Action]:
|
||||
actions: List[Action] = []
|
||||
char = game_state.character
|
||||
hp_ratio = char.hp / char.max_hp if char.max_hp > 0 else 1.0
|
||||
|
||||
# Emergency flee
|
||||
if hp_ratio < self.flee_threshold:
|
||||
actions.append(Action(
|
||||
command=self.flee_command,
|
||||
priority=ActionPriority.EMERGENCY,
|
||||
description=f"FLEE! HP={char.hp}/{char.max_hp} ({hp_ratio:.0%})",
|
||||
source="combat",
|
||||
))
|
||||
return actions
|
||||
|
||||
# Heal during combat if moderately hurt
|
||||
if hp_ratio < self.combat_heal_threshold:
|
||||
actions.append(Action(
|
||||
command=self.heal_command,
|
||||
priority=ActionPriority.HEAL,
|
||||
description=f"Combat heal: HP {char.hp}/{char.max_hp}",
|
||||
source="combat",
|
||||
))
|
||||
|
||||
# Attack
|
||||
target = game_state.target
|
||||
if target:
|
||||
cmd = f"{self.attack_command} {target}"
|
||||
else:
|
||||
# No specific target — generic attack
|
||||
cmd = self.attack_command
|
||||
actions.append(Action(
|
||||
command=cmd,
|
||||
priority=ActionPriority.COMBAT,
|
||||
description=f"Attack: {target or 'nearest'}",
|
||||
source="combat",
|
||||
))
|
||||
|
||||
return actions
|
||||
|
||||
|
||||
class LootState(BaseBehaviorState):
|
||||
"""Pick up items after combat ends.
|
||||
|
||||
Behaviour
|
||||
---------
|
||||
* Queue ``get all from corpse`` to loot the kill.
|
||||
* Queue ``get all`` as fallback.
|
||||
* Then transition back to idle/explore (handled by the engine).
|
||||
"""
|
||||
|
||||
name = "loot"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
loot_command: str = "get all from corpse",
|
||||
fallback_command: str = "get all",
|
||||
) -> None:
|
||||
self.loot_command = loot_command
|
||||
self.fallback_command = fallback_command
|
||||
|
||||
def get_actions(self, game_state: "GameState") -> List[Action]:
|
||||
actions: List[Action] = []
|
||||
|
||||
# Primary loot attempt
|
||||
actions.append(Action(
|
||||
command=self.loot_command,
|
||||
priority=ActionPriority.LOOT,
|
||||
description="Loot corpse",
|
||||
source="loot",
|
||||
))
|
||||
|
||||
# Fallback — pick up anything lying around
|
||||
actions.append(Action(
|
||||
command=self.fallback_command,
|
||||
priority=ActionPriority.LOOT,
|
||||
description="Pick up loose items",
|
||||
source="loot",
|
||||
))
|
||||
|
||||
return actions
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Combat AI — intelligent combat decision-making.
|
||||
|
||||
Responsible for:
|
||||
* Target selection (prioritise aggressive / weakest mobs)
|
||||
* Skill rotation (cycle through available combat skills)
|
||||
* Flee logic (emergency retreat when HP is critical)
|
||||
* Health management during combat (when to heal vs attack)
|
||||
|
||||
The ``CombatAI`` does **not** push actions directly. Instead it
|
||||
returns ``Action`` objects that the ``CombatState`` (or the engine)
|
||||
queues on the ``ActionQueue``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
from .action_queue import Action, ActionPriority
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class CombatConfig:
|
||||
"""Tuneable parameters for the combat AI."""
|
||||
|
||||
flee_hp_ratio: float = 0.20
|
||||
"""Flee when HP/MaxHP drops below this ratio."""
|
||||
|
||||
heal_hp_ratio: float = 0.40
|
||||
"""Heal during combat when HP/MaxHP is below this ratio."""
|
||||
|
||||
attack_command: str = "kill"
|
||||
"""Base command used to attack (e.g. ``kill``, ``attack``)."""
|
||||
|
||||
flee_command: str = "flee"
|
||||
"""Command to flee combat."""
|
||||
|
||||
heal_command: str = "heal"
|
||||
"""Command for in-combat healing."""
|
||||
|
||||
skill_commands: List[str] = field(default_factory=list)
|
||||
"""Ordered list of combat skill commands to cycle through.
|
||||
|
||||
Example: ``["backstab", "kick", "bash"]``
|
||||
When empty, only the base ``attack_command`` is used.
|
||||
"""
|
||||
|
||||
skill_cooldown_ticks: int = 3
|
||||
"""Minimum ticks (decision cycles) between skill uses."""
|
||||
|
||||
|
||||
class CombatAI:
|
||||
"""Stateful combat decision-maker.
|
||||
|
||||
Tracks the current engagement and decides the optimal action each
|
||||
tick based on the character's vitals and the combat situation.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[CombatConfig] = None) -> None:
|
||||
self.config = config or CombatConfig()
|
||||
self._skill_index: int = 0
|
||||
self._ticks_since_skill: int = 0
|
||||
self._last_target: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def decide(self, game_state: "GameState") -> List[Action]:
|
||||
"""Evaluate the situation and return the best combat actions.
|
||||
|
||||
Returns an empty list if there is nothing combat-related to do
|
||||
(e.g. not in combat, no target).
|
||||
"""
|
||||
actions: List[Action] = []
|
||||
char = game_state.character
|
||||
hp_ratio = char.hp / char.max_hp if char.max_hp > 0 else 1.0
|
||||
|
||||
# --- Emergency flee ---
|
||||
if hp_ratio < self.config.flee_hp_ratio:
|
||||
actions.append(Action(
|
||||
command=self.config.flee_command,
|
||||
priority=ActionPriority.EMERGENCY,
|
||||
description=f"EMERGENCY FLEE: HP {char.hp}/{char.max_hp} ({hp_ratio:.0%})",
|
||||
source="combat_ai",
|
||||
))
|
||||
logger.warning(
|
||||
"CombatAI: FLEE triggered (HP %.0f%%)", hp_ratio * 100,
|
||||
)
|
||||
return actions
|
||||
|
||||
# --- In-combat heal ---
|
||||
if hp_ratio < self.config.heal_hp_ratio:
|
||||
actions.append(Action(
|
||||
command=self.config.heal_command,
|
||||
priority=ActionPriority.HEAL,
|
||||
description=f"Combat heal: HP {char.hp}/{char.max_hp}",
|
||||
source="combat_ai",
|
||||
))
|
||||
|
||||
# --- Attack ---
|
||||
target = self._select_target(game_state)
|
||||
attack_action = self._choose_attack(target)
|
||||
actions.append(attack_action)
|
||||
|
||||
self._ticks_since_skill += 1
|
||||
self._last_target = target
|
||||
|
||||
return actions
|
||||
|
||||
def reset(self) -> None:
|
||||
"""Reset combat state (call when combat ends)."""
|
||||
self._skill_index = 0
|
||||
self._ticks_since_skill = 0
|
||||
self._last_target = None
|
||||
logger.debug("CombatAI: reset")
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Target selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _select_target(self, game_state: "GameState") -> Optional[str]:
|
||||
"""Choose which mob to attack.
|
||||
|
||||
Priority order:
|
||||
1. The mob currently attacking us (``game_state.target``).
|
||||
2. The first NPC in the room (aggressive assumption).
|
||||
"""
|
||||
# Already tracking a target
|
||||
if game_state.target:
|
||||
return game_state.target
|
||||
|
||||
# Pick first NPC in room (heuristic — we don't know if it's hostile)
|
||||
room = game_state.current_room
|
||||
if room and room.npcs:
|
||||
target = room.npcs[0]
|
||||
logger.debug("CombatAI: auto-targeting '%s'", target)
|
||||
return target
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Attack selection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _choose_attack(self, target: Optional[str]) -> Action:
|
||||
"""Pick the best attack action for this tick."""
|
||||
skills = self.config.skill_commands
|
||||
|
||||
# Use a skill if available and off cooldown
|
||||
if skills and self._ticks_since_skill >= self.config.skill_cooldown_ticks:
|
||||
skill_cmd = skills[self._skill_index % len(skills)]
|
||||
self._skill_index += 1
|
||||
self._ticks_since_skill = 0
|
||||
|
||||
cmd = f"{skill_cmd} {target}" if target else skill_cmd
|
||||
return Action(
|
||||
command=cmd,
|
||||
priority=ActionPriority.COMBAT,
|
||||
description=f"Skill: {skill_cmd} -> {target or 'nearest'}",
|
||||
source="combat_ai",
|
||||
)
|
||||
|
||||
# Default attack
|
||||
base = self.config.attack_command
|
||||
cmd = f"{base} {target}" if target else base
|
||||
return Action(
|
||||
command=cmd,
|
||||
priority=ActionPriority.COMBAT,
|
||||
description=f"Attack: {target or 'nearest'}",
|
||||
source="combat_ai",
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Utility
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def should_engage(self, game_state: "GameState") -> bool:
|
||||
"""Return ``True`` if we should initiate combat.
|
||||
|
||||
Currently returns ``True`` if there are NPCs in the room and
|
||||
we are healthy enough. In a future version this could check
|
||||
mob levels, aggro flags, etc.
|
||||
"""
|
||||
if not game_state.is_healthy(threshold=0.5):
|
||||
return False
|
||||
room = game_state.current_room
|
||||
return bool(room and room.npcs)
|
||||
@@ -0,0 +1,217 @@
|
||||
"""Decision engine — the brain of the MUD AI player.
|
||||
|
||||
Orchestrates the ``ActionQueue``, ``BehaviorStates``, ``CombatAI``,
|
||||
and ``GoalManager`` into a single tick-based loop.
|
||||
|
||||
Lifecycle (called from ``main.py`` each iteration)::
|
||||
|
||||
engine.tick(game_state)
|
||||
-> evaluate state transitions
|
||||
-> ask current state for actions
|
||||
-> push actions onto the queue
|
||||
action = engine.next_action()
|
||||
-> pop the most urgent action (may be ``None``)
|
||||
-> caller sends ``action.command`` to the MUD
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, Optional, TYPE_CHECKING
|
||||
|
||||
from .action_queue import ActionQueue, Action
|
||||
from .behavior_states import (
|
||||
BaseBehaviorState,
|
||||
CombatState as CombatBehavior,
|
||||
ExploreState,
|
||||
IdleState,
|
||||
LootState,
|
||||
)
|
||||
from .combat_ai import CombatAI, CombatConfig
|
||||
from .goal_manager import GoalManager
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState, CombatState as GameCombatState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DecisionEngine:
|
||||
"""Central decision-making engine.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
combat_config:
|
||||
Optional ``CombatConfig`` to tune flee / heal thresholds, skills, etc.
|
||||
exploration_strategy:
|
||||
``"random"`` or ``"systematic"`` — forwarded to ``ExploreState``.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
combat_config: Optional[CombatConfig] = None,
|
||||
exploration_strategy: str = "random",
|
||||
) -> None:
|
||||
# Sub-systems
|
||||
self.action_queue = ActionQueue()
|
||||
self.combat_ai = CombatAI(config=combat_config)
|
||||
self.goal_manager = GoalManager()
|
||||
|
||||
# Behavior states (keyed by name)
|
||||
self._states: Dict[str, BaseBehaviorState] = {
|
||||
"idle": IdleState(),
|
||||
"explore": ExploreState(strategy=exploration_strategy),
|
||||
"combat": CombatBehavior(),
|
||||
"loot": LootState(),
|
||||
}
|
||||
|
||||
self._current_state_name: str = "idle"
|
||||
self._previous_state_name: str = "idle"
|
||||
self._tick_count: int = 0
|
||||
|
||||
# Track combat end for looting transition
|
||||
self._was_in_combat: bool = False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Properties
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def current_state(self) -> BaseBehaviorState:
|
||||
return self._states[self._current_state_name]
|
||||
|
||||
@property
|
||||
def current_state_name(self) -> str:
|
||||
return self._current_state_name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Main tick
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def tick(self, game_state: "GameState") -> None:
|
||||
"""Evaluate the world, transition states, and queue actions.
|
||||
|
||||
This should be called once per decision cycle from the main loop.
|
||||
"""
|
||||
self._tick_count += 1
|
||||
|
||||
# 1. Determine which state we should be in
|
||||
new_state = self._evaluate_state(game_state)
|
||||
|
||||
# 2. Transition if necessary
|
||||
if new_state != self._current_state_name:
|
||||
self._transition(new_state, game_state)
|
||||
|
||||
# 3. Get actions from the current state
|
||||
actions = self.current_state.get_actions(game_state)
|
||||
|
||||
# 3b. If in combat, also consult the CombatAI for smarter decisions
|
||||
if self._current_state_name == "combat":
|
||||
ai_actions = self.combat_ai.decide(game_state)
|
||||
# CombatAI actions override the generic CombatState actions
|
||||
if ai_actions:
|
||||
actions = ai_actions
|
||||
|
||||
# 4. Push actions onto the queue
|
||||
for action in actions:
|
||||
self.action_queue.push(action)
|
||||
|
||||
logger.debug(
|
||||
"Tick #%d | state=%s | queued=%d | queue_size=%d",
|
||||
self._tick_count,
|
||||
self._current_state_name,
|
||||
len(actions),
|
||||
self.action_queue.size,
|
||||
)
|
||||
|
||||
def next_action(self) -> Optional[Action]:
|
||||
"""Pop and return the most urgent action, or ``None``."""
|
||||
return self.action_queue.pop()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _evaluate_state(self, game_state: "GameState") -> str:
|
||||
"""Decide which behavior state should be active.
|
||||
|
||||
Priority logic:
|
||||
1. If HP is critically low -> ``idle`` (to heal / flee is
|
||||
handled within combat state if already fighting).
|
||||
2. If currently in combat -> ``combat``.
|
||||
3. If just left combat -> ``loot``.
|
||||
4. Consult the GoalManager for the next objective.
|
||||
5. Default: ``explore``.
|
||||
"""
|
||||
from core.game_state import CombatState as CS
|
||||
|
||||
in_combat = game_state.combat_state == CS.IN_COMBAT
|
||||
|
||||
# --- Combat ---
|
||||
if in_combat:
|
||||
self._was_in_combat = True
|
||||
return "combat"
|
||||
|
||||
# --- Just left combat -> loot ---
|
||||
if self._was_in_combat and not in_combat:
|
||||
self._was_in_combat = False
|
||||
return "loot"
|
||||
|
||||
# --- HP critically low (outside combat) -> idle to heal ---
|
||||
if not game_state.is_healthy(threshold=0.5):
|
||||
return "idle"
|
||||
|
||||
# --- Ask the GoalManager ---
|
||||
goal = self.goal_manager.get_current_goal(game_state)
|
||||
if goal:
|
||||
target = goal.target_state
|
||||
if target in self._states:
|
||||
return target
|
||||
logger.warning(
|
||||
"GoalManager suggested unknown state '%s', falling back",
|
||||
target,
|
||||
)
|
||||
|
||||
# --- Default ---
|
||||
return "explore"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# State transitions
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _transition(self, new_state: str, game_state: "GameState") -> None:
|
||||
"""Perform a state transition with enter/exit hooks."""
|
||||
old = self._current_state_name
|
||||
|
||||
# Exit old state
|
||||
self._states[old].on_exit(game_state)
|
||||
|
||||
# Clear actions from the old state's source to avoid stale commands
|
||||
self.action_queue.clear_by_source(old)
|
||||
|
||||
# Reset combat AI when leaving combat
|
||||
if old == "combat" and new_state != "combat":
|
||||
self.combat_ai.reset()
|
||||
|
||||
# Enter new state
|
||||
self._previous_state_name = old
|
||||
self._current_state_name = new_state
|
||||
self._states[new_state].on_enter(game_state)
|
||||
|
||||
logger.info("State transition: %s -> %s", old, new_state)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Manual overrides
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def force_state(self, state_name: str, game_state: "GameState") -> None:
|
||||
"""Force an immediate transition (for debugging / external control)."""
|
||||
if state_name not in self._states:
|
||||
logger.error("Cannot force unknown state '%s'", state_name)
|
||||
return
|
||||
self._transition(state_name, game_state)
|
||||
|
||||
def register_state(self, state: BaseBehaviorState) -> None:
|
||||
"""Add or replace a behavior state."""
|
||||
self._states[state.name] = state
|
||||
logger.info("Registered behavior state: '%s'", state.name)
|
||||
@@ -0,0 +1,182 @@
|
||||
"""High-level goal manager.
|
||||
|
||||
Goals are long-lived objectives such as "explore the world", "grind to
|
||||
level 10", or "complete quest X". The ``GoalManager`` tracks them and
|
||||
tells the ``DecisionEngine`` which behavior state should be active.
|
||||
|
||||
Goals are prioritised so that survival always comes first, followed by
|
||||
combat, then questing, then exploration.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from enum import IntEnum
|
||||
from typing import List, Optional, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GoalPriority(IntEnum):
|
||||
"""Goal priority tiers (lower = more important)."""
|
||||
|
||||
SURVIVAL = 0
|
||||
COMBAT = 1
|
||||
QUEST = 2
|
||||
GRIND = 3
|
||||
EXPLORE = 4
|
||||
|
||||
|
||||
class GoalStatus:
|
||||
ACTIVE = "active"
|
||||
COMPLETED = "completed"
|
||||
FAILED = "failed"
|
||||
PAUSED = "paused"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Goal:
|
||||
"""A single high-level objective.
|
||||
|
||||
Attributes
|
||||
----------
|
||||
name:
|
||||
Identifier / human-readable label.
|
||||
priority:
|
||||
Determines which goal takes precedence.
|
||||
description:
|
||||
Longer explanation (for logging / UI).
|
||||
target_state:
|
||||
The behavior state name that should be active while pursuing
|
||||
this goal (e.g. ``"explore"``, ``"combat"``).
|
||||
status:
|
||||
Current lifecycle status.
|
||||
progress:
|
||||
Arbitrary 0-100 completion percentage.
|
||||
"""
|
||||
|
||||
name: str
|
||||
priority: GoalPriority = GoalPriority.EXPLORE
|
||||
description: str = ""
|
||||
target_state: str = "explore"
|
||||
status: str = GoalStatus.ACTIVE
|
||||
progress: float = 0.0
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
def is_active(self) -> bool:
|
||||
return self.status == GoalStatus.ACTIVE
|
||||
|
||||
|
||||
class GoalManager:
|
||||
"""Manages the pool of active goals and decides which to pursue.
|
||||
|
||||
The engine calls :meth:`get_current_goal` each tick to determine
|
||||
which behavior state should be active.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._goals: List[Goal] = []
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Goal CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_goal(self, goal: Goal) -> None:
|
||||
"""Add a goal to the pool (duplicates allowed)."""
|
||||
self._goals.append(goal)
|
||||
logger.info("Goal added: '%s' (priority=%s)", goal.name, goal.priority.name)
|
||||
|
||||
def remove_goal(self, name: str) -> bool:
|
||||
"""Remove the first goal matching *name*. Returns ``True`` if found."""
|
||||
for i, g in enumerate(self._goals):
|
||||
if g.name == name:
|
||||
self._goals.pop(i)
|
||||
logger.info("Goal removed: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def complete_goal(self, name: str) -> bool:
|
||||
"""Mark a goal as completed."""
|
||||
for g in self._goals:
|
||||
if g.name == name and g.is_active():
|
||||
g.status = GoalStatus.COMPLETED
|
||||
logger.info("Goal completed: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def fail_goal(self, name: str) -> bool:
|
||||
"""Mark a goal as failed."""
|
||||
for g in self._goals:
|
||||
if g.name == name and g.is_active():
|
||||
g.status = GoalStatus.FAILED
|
||||
logger.info("Goal failed: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_current_goal(self, game_state: "GameState") -> Optional[Goal]:
|
||||
"""Return the highest-priority active goal.
|
||||
|
||||
Takes ``game_state`` so that dynamic re-prioritisation can
|
||||
happen (e.g. force a survival goal when HP is critical).
|
||||
"""
|
||||
# Inject an ephemeral survival goal if HP is dangerously low
|
||||
if not game_state.is_healthy(threshold=0.3):
|
||||
return Goal(
|
||||
name="survive",
|
||||
priority=GoalPriority.SURVIVAL,
|
||||
description="HP critically low — must heal or flee",
|
||||
target_state="idle", # idle state handles healing
|
||||
)
|
||||
|
||||
active = [g for g in self._goals if g.is_active()]
|
||||
if not active:
|
||||
return None
|
||||
# Sort by priority (lowest number = highest priority)
|
||||
active.sort(key=lambda g: int(g.priority))
|
||||
return active[0]
|
||||
|
||||
def get_active_goals(self) -> List[Goal]:
|
||||
"""Return all active goals sorted by priority."""
|
||||
return sorted(
|
||||
[g for g in self._goals if g.is_active()],
|
||||
key=lambda g: int(g.priority),
|
||||
)
|
||||
|
||||
@property
|
||||
def goal_count(self) -> int:
|
||||
return len(self._goals)
|
||||
|
||||
@property
|
||||
def active_goal_count(self) -> int:
|
||||
return sum(1 for g in self._goals if g.is_active())
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Default goals
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def set_default_goals(self) -> None:
|
||||
"""Populate with a sensible set of starter goals."""
|
||||
defaults = [
|
||||
Goal(
|
||||
name="explore_world",
|
||||
priority=GoalPriority.EXPLORE,
|
||||
description="Discover new rooms and map the world",
|
||||
target_state="explore",
|
||||
),
|
||||
Goal(
|
||||
name="grind_combat",
|
||||
priority=GoalPriority.GRIND,
|
||||
description="Fight mobs for experience and loot",
|
||||
target_state="combat",
|
||||
),
|
||||
]
|
||||
for g in defaults:
|
||||
self.add_goal(g)
|
||||
@@ -0,0 +1,27 @@
|
||||
"""LLM integration module — hybrid AI with Ollama.
|
||||
|
||||
Provides LLM-augmented intelligence across all subsystems:
|
||||
* Quest parsing fallback
|
||||
* Social AI (roleplay, trade, groups)
|
||||
* Decision override for complex situations
|
||||
* Ambiguous text parsing
|
||||
|
||||
All LLM features gracefully degrade when Ollama is unavailable.
|
||||
"""
|
||||
|
||||
from .client import LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES
|
||||
from .brain import LLMBrain
|
||||
from .social_ai import SocialAI
|
||||
from .memory import LLMMemory, MemoryEntry
|
||||
|
||||
__all__ = [
|
||||
"LLMClient",
|
||||
"LLMConfig",
|
||||
"LLMResponse",
|
||||
"LLMBrain",
|
||||
"SocialAI",
|
||||
"LLMMemory",
|
||||
"MemoryEntry",
|
||||
"render_template",
|
||||
"PROMPT_TEMPLATES",
|
||||
]
|
||||
@@ -0,0 +1,273 @@
|
||||
"""LLMBrain — hybrid AI coordinator.
|
||||
|
||||
Uses the LLM as an intelligent fallback across all subsystems:
|
||||
|
||||
1. **Quest parsing** — when regex fails to extract objectives.
|
||||
2. **Social AI** — roleplay responses to other players.
|
||||
3. **Decision override** — complex situations the state machine can't handle.
|
||||
4. **Parsing fallback** — interpret ambiguous MUD output.
|
||||
|
||||
Design principle: **rules first, LLM second**. The LLM is never in the
|
||||
critical path — if Ollama is down, everything still works via rule-based
|
||||
logic. The LLM only *enhances* decisions, never *blocks* them.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .client import LLMClient, LLMConfig, LLMResponse, render_template
|
||||
from .memory import LLMMemory
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMBrain:
|
||||
"""Hybrid AI brain that augments rule-based systems with LLM intelligence.
|
||||
|
||||
Usage::
|
||||
|
||||
brain = LLMBrain(LLMConfig(model="llama3.2"))
|
||||
await brain.initialize()
|
||||
|
||||
# Quest parsing fallback
|
||||
objectives = await brain.parse_quest("The old sage mutters about goblins in the mine")
|
||||
|
||||
# Social response
|
||||
reply = await brain.social_reply("Hey, want to join my party?", "Warrior", "Grok")
|
||||
|
||||
# Decision advice
|
||||
action = await brain.suggest_action(game_state)
|
||||
|
||||
# Parse unknown text
|
||||
parsed = await brain.parse_ambiguous("Something strange happens...")
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: Optional[LLMConfig] = None,
|
||||
memory_db_path: str = "data/llm_memory.db",
|
||||
) -> None:
|
||||
self.client = LLMClient(config)
|
||||
self.memory = LLMMemory(memory_db_path)
|
||||
self._available = False
|
||||
self._query_count = 0
|
||||
self._total_tokens = 0
|
||||
self._total_latency_ms = 0.0
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Check LLM availability and initialize memory. Returns ``True`` if server reachable."""
|
||||
await self.memory.initialize()
|
||||
self._available = await self.client.is_available()
|
||||
if self._available:
|
||||
logger.info(
|
||||
"LLMBrain online: model=%s, url=%s, memory_initialized",
|
||||
self.client.config.model,
|
||||
self.client.config.base_url,
|
||||
)
|
||||
else:
|
||||
logger.warning(
|
||||
"LLMBrain offline — all queries will fall back to rule-based logic"
|
||||
)
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def is_available(self) -> bool:
|
||||
return self._available
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. Quest Parsing
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def parse_quest(self, text: str) -> Optional[List[Dict]]:
|
||||
"""Extract quest objectives from ambiguous text using the LLM.
|
||||
|
||||
Returns a list of objective dicts, or ``None`` if the LLM is
|
||||
unavailable or fails to produce valid JSON.
|
||||
|
||||
Each dict has: ``type``, ``target``, ``count``, ``location``.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
resp = await self.client.ask(
|
||||
"quest_parse", json_mode=True, text=text,
|
||||
)
|
||||
self._record(resp)
|
||||
|
||||
if not resp.success:
|
||||
return None
|
||||
|
||||
data = resp.as_json()
|
||||
if isinstance(data, list):
|
||||
return data
|
||||
if isinstance(data, dict) and "objectives" in data:
|
||||
return data["objectives"]
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. Social AI
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def social_reply(
|
||||
self,
|
||||
message: str,
|
||||
sender: str = "unknown",
|
||||
character_class: str = "adventurer",
|
||||
character_name: str = "Player",
|
||||
) -> Optional[str]:
|
||||
"""Generate an in-character reply to another player's message.
|
||||
|
||||
Uses conversation history to maintain context.
|
||||
Returns the reply string, or ``None`` on failure.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
# Get context from memory
|
||||
context = await self.memory.build_context_prompt(sender, max_tokens=200)
|
||||
|
||||
# Enhanced prompt template with context
|
||||
prompt = render_template(
|
||||
"social_reply_with_context",
|
||||
context=context,
|
||||
sender=sender,
|
||||
message=message,
|
||||
character_class=character_class,
|
||||
character_name=character_name,
|
||||
)
|
||||
|
||||
resp = await self.client.generate(prompt)
|
||||
self._record(resp)
|
||||
|
||||
if not resp.success or not resp.text:
|
||||
return None
|
||||
|
||||
# Clean up: remove quotes, ensure it's a single line
|
||||
reply = resp.text.strip().strip('"').strip("'")
|
||||
# Limit length for MUD output
|
||||
if len(reply) > 200:
|
||||
reply = reply[:197] + "..."
|
||||
|
||||
# Record the conversation
|
||||
await self.memory.add_conversation(sender, message, reply, importance=0.6)
|
||||
|
||||
return reply
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. Decision Advice
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def suggest_action(self, game_state: "GameState") -> Optional[str]:
|
||||
"""Ask the LLM what command to execute given the current game state.
|
||||
|
||||
Returns a single MUD command string, or ``None``.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
room = game_state.current_room
|
||||
goal_text = "explore the world"
|
||||
|
||||
resp = await self.client.ask(
|
||||
"decide_action",
|
||||
room_title=room.title if room else "unknown",
|
||||
hp=game_state.character.hp,
|
||||
max_hp=game_state.character.max_hp,
|
||||
mp=game_state.character.mp,
|
||||
max_mp=game_state.character.max_mp,
|
||||
exits=", ".join(room.exits.keys()) if room else "none",
|
||||
npcs=", ".join(room.npcs) if room else "none",
|
||||
items=", ".join(room.items) if room else "none",
|
||||
combat_state=game_state.combat_state.value,
|
||||
goal=goal_text,
|
||||
)
|
||||
self._record(resp)
|
||||
|
||||
if not resp.success or not resp.text:
|
||||
return None
|
||||
|
||||
# Extract just the command (first line, stripped)
|
||||
command = resp.text.strip().split("\n")[0].strip()
|
||||
# Remove common LLM artifacts
|
||||
for prefix in ("Command:", "command:", ">", "$ "):
|
||||
if command.startswith(prefix):
|
||||
command = command[len(prefix):].strip()
|
||||
return command if command else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. Parsing Fallback
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def parse_ambiguous(self, text: str) -> Optional[Dict]:
|
||||
"""Attempt to classify and parse ambiguous MUD output via LLM.
|
||||
|
||||
Returns a dict with ``type`` and ``data`` keys, or ``None``.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
resp = await self.client.ask(
|
||||
"parse_ambiguous", json_mode=True, text=text,
|
||||
)
|
||||
self._record(resp)
|
||||
|
||||
if not resp.success:
|
||||
return None
|
||||
|
||||
return resp.as_json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. NPC Evaluation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def evaluate_npc(
|
||||
self, npc_name: str, room_description: str,
|
||||
) -> Optional[Dict]:
|
||||
"""Determine if an NPC is likely hostile or friendly.
|
||||
|
||||
Returns ``{"hostile": bool, "confidence": float, "reason": str}``
|
||||
or ``None``.
|
||||
"""
|
||||
if not self._available:
|
||||
return None
|
||||
|
||||
resp = await self.client.ask(
|
||||
"evaluate_npc",
|
||||
json_mode=True,
|
||||
npc_name=npc_name,
|
||||
room_description=room_description,
|
||||
)
|
||||
self._record(resp)
|
||||
|
||||
if not resp.success:
|
||||
return None
|
||||
|
||||
return resp.as_json()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Stats
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def stats(self) -> Dict:
|
||||
"""Return usage statistics."""
|
||||
return {
|
||||
"available": self._available,
|
||||
"queries": self._query_count,
|
||||
"total_tokens": self._total_tokens,
|
||||
"avg_latency_ms": (
|
||||
self._total_latency_ms / self._query_count
|
||||
if self._query_count > 0 else 0
|
||||
),
|
||||
}
|
||||
|
||||
def _record(self, resp: LLMResponse) -> None:
|
||||
self._query_count += 1
|
||||
self._total_tokens += resp.tokens_used
|
||||
self._total_latency_ms += resp.latency_ms
|
||||
@@ -0,0 +1,349 @@
|
||||
"""Async LLM client for Ollama (local inference).
|
||||
|
||||
Provides a thin async wrapper around the Ollama HTTP API with:
|
||||
* Automatic retry with exponential back-off.
|
||||
* Configurable timeout.
|
||||
* JSON-mode for structured output.
|
||||
* Prompt template system for reproducible queries.
|
||||
|
||||
The client is transport-agnostic — it talks plain HTTP so it works
|
||||
with Ollama, LM Studio, or any OpenAI-compatible endpoint.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.request import Request, urlopen
|
||||
from urllib.error import URLError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Configuration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class LLMConfig:
|
||||
"""Configuration for the LLM client."""
|
||||
|
||||
base_url: str = "http://localhost:11434"
|
||||
"""Ollama API base URL."""
|
||||
|
||||
model: str = "llama3.2"
|
||||
"""Model name (must be pulled in Ollama first)."""
|
||||
|
||||
temperature: float = 0.3
|
||||
"""Sampling temperature (lower = more deterministic)."""
|
||||
|
||||
max_tokens: int = 512
|
||||
"""Maximum tokens in the response."""
|
||||
|
||||
timeout_seconds: float = 30.0
|
||||
"""Per-request timeout."""
|
||||
|
||||
max_retries: int = 2
|
||||
"""Number of retries on transient failures."""
|
||||
|
||||
retry_delay_seconds: float = 1.0
|
||||
"""Initial delay between retries (doubles each attempt)."""
|
||||
|
||||
system_prompt: str = (
|
||||
"You are an AI playing a text-based MUD game. "
|
||||
"You must respond ONLY with the requested format. "
|
||||
"Be concise and precise. No explanations unless asked."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Response wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class LLMResponse:
|
||||
"""Parsed response from the LLM."""
|
||||
|
||||
text: str = ""
|
||||
model: str = ""
|
||||
tokens_used: int = 0
|
||||
latency_ms: float = 0.0
|
||||
success: bool = True
|
||||
error: Optional[str] = None
|
||||
|
||||
def as_json(self) -> Optional[Dict]:
|
||||
"""Try to parse the response text as JSON."""
|
||||
try:
|
||||
return json.loads(self.text)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Prompt templates
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
PROMPT_TEMPLATES: Dict[str, str] = {
|
||||
"quest_parse": (
|
||||
"Extract quest objectives from this MUD text. "
|
||||
"Return a JSON array of objects with fields: "
|
||||
"type (kill|fetch|deliver|explore|talk), target, count, location.\n\n"
|
||||
"Text: {text}\n\n"
|
||||
"JSON:"
|
||||
),
|
||||
|
||||
"social_reply": (
|
||||
"You are {character_name}, a {character_class} in a MUD game. "
|
||||
"Another player said: \"{message}\"\n"
|
||||
"Write a short in-character reply (max 1 sentence). "
|
||||
"Reply:"
|
||||
),
|
||||
|
||||
"decide_action": (
|
||||
"You are playing a MUD. Current situation:\n"
|
||||
"- Room: {room_title}\n"
|
||||
"- HP: {hp}/{max_hp} MP: {mp}/{max_mp}\n"
|
||||
"- Exits: {exits}\n"
|
||||
"- NPCs: {npcs}\n"
|
||||
"- Items: {items}\n"
|
||||
"- Combat: {combat_state}\n"
|
||||
"- Current goal: {goal}\n\n"
|
||||
"What single MUD command should you execute next? "
|
||||
"Reply with ONLY the command (e.g. 'north', 'kill goblin', 'heal').\n"
|
||||
"Command:"
|
||||
),
|
||||
|
||||
"parse_ambiguous": (
|
||||
"This is output from a MUD (text game). "
|
||||
"Classify it as one of: room, combat, quest, social, system, unknown.\n"
|
||||
"Then extract any structured data you can.\n"
|
||||
"Return JSON with fields: type, data (object with relevant fields).\n\n"
|
||||
"Text: {text}\n\n"
|
||||
"JSON:"
|
||||
),
|
||||
|
||||
"evaluate_npc": (
|
||||
"In a MUD game, determine if this NPC is likely hostile or friendly "
|
||||
"based on its description and context.\n"
|
||||
"NPC: {npc_name}\n"
|
||||
"Room: {room_description}\n"
|
||||
"Reply with JSON: {{\"hostile\": true/false, \"confidence\": 0.0-1.0, \"reason\": \"...\"}}\n"
|
||||
"JSON:"
|
||||
),
|
||||
|
||||
"social_reply_with_context": (
|
||||
"{context}"
|
||||
"You are {character_name}, a {character_class} in a MUD game. "
|
||||
"Another player ({sender}) said: \"{message}\"\n"
|
||||
"Write a short in-character reply (max 1 sentence). "
|
||||
"Consider your previous interactions shown above. "
|
||||
"Reply:"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def render_template(template_name: str, **kwargs) -> str:
|
||||
"""Fill a prompt template with the given variables."""
|
||||
template = PROMPT_TEMPLATES.get(template_name, template_name)
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger.warning("Missing template variable: %s", e)
|
||||
return template
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLM Client
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class LLMClient:
|
||||
"""Async client for Ollama-compatible LLM APIs.
|
||||
|
||||
Usage::
|
||||
|
||||
client = LLMClient(LLMConfig(model="llama3.2"))
|
||||
response = await client.generate("What is 2+2?")
|
||||
print(response.text)
|
||||
"""
|
||||
|
||||
def __init__(self, config: Optional[LLMConfig] = None) -> None:
|
||||
self.config = config or LLMConfig()
|
||||
self._available: Optional[bool] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public API
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def generate(
|
||||
self,
|
||||
prompt: str,
|
||||
system: Optional[str] = None,
|
||||
json_mode: bool = False,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: Optional[int] = None,
|
||||
) -> LLMResponse:
|
||||
"""Send a prompt to the LLM and return the response.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
prompt:
|
||||
The user prompt.
|
||||
system:
|
||||
Optional system prompt override.
|
||||
json_mode:
|
||||
If ``True``, request JSON output format.
|
||||
temperature:
|
||||
Override default temperature.
|
||||
max_tokens:
|
||||
Override default max tokens.
|
||||
"""
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"prompt": prompt,
|
||||
"system": system or self.config.system_prompt,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature or self.config.temperature,
|
||||
"num_predict": max_tokens or self.config.max_tokens,
|
||||
},
|
||||
}
|
||||
if json_mode:
|
||||
payload["format"] = "json"
|
||||
|
||||
return await self._request("/api/generate", payload)
|
||||
|
||||
async def chat(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
json_mode: bool = False,
|
||||
temperature: Optional[float] = None,
|
||||
) -> LLMResponse:
|
||||
"""Send a chat-style conversation to the LLM.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
messages:
|
||||
List of ``{"role": "user"|"assistant"|"system", "content": "..."}``
|
||||
"""
|
||||
payload = {
|
||||
"model": self.config.model,
|
||||
"messages": messages,
|
||||
"stream": False,
|
||||
"options": {
|
||||
"temperature": temperature or self.config.temperature,
|
||||
"num_predict": self.config.max_tokens,
|
||||
},
|
||||
}
|
||||
if json_mode:
|
||||
payload["format"] = "json"
|
||||
|
||||
return await self._request("/api/chat", payload, chat_mode=True)
|
||||
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if the LLM server is reachable."""
|
||||
if self._available is not None:
|
||||
return self._available
|
||||
try:
|
||||
url = f"{self.config.base_url}/api/tags"
|
||||
req = Request(url, method="GET")
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: urlopen(req, timeout=5),
|
||||
)
|
||||
self._available = resp.status == 200
|
||||
except Exception:
|
||||
self._available = False
|
||||
logger.info("LLM available: %s", self._available)
|
||||
return self._available
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Template helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def ask(
|
||||
self,
|
||||
template_name: str,
|
||||
json_mode: bool = False,
|
||||
**kwargs,
|
||||
) -> LLMResponse:
|
||||
"""Render a template and send it to the LLM.
|
||||
|
||||
Convenience wrapper around :meth:`generate`.
|
||||
"""
|
||||
prompt = render_template(template_name, **kwargs)
|
||||
return await self.generate(prompt, json_mode=json_mode)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Internal
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _request(
|
||||
self,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
chat_mode: bool = False,
|
||||
) -> LLMResponse:
|
||||
"""Execute an HTTP request with retry logic."""
|
||||
url = f"{self.config.base_url}{endpoint}"
|
||||
data = json.dumps(payload).encode("utf-8")
|
||||
|
||||
delay = self.config.retry_delay_seconds
|
||||
last_error = ""
|
||||
|
||||
for attempt in range(self.config.max_retries + 1):
|
||||
t0 = time.monotonic()
|
||||
try:
|
||||
req = Request(
|
||||
url,
|
||||
data=data,
|
||||
headers={"Content-Type": "application/json"},
|
||||
method="POST",
|
||||
)
|
||||
loop = asyncio.get_running_loop()
|
||||
resp = await loop.run_in_executor(
|
||||
None,
|
||||
lambda: urlopen(req, timeout=self.config.timeout_seconds),
|
||||
)
|
||||
body = json.loads(resp.read().decode("utf-8"))
|
||||
elapsed = (time.monotonic() - t0) * 1000
|
||||
|
||||
# Extract text from response
|
||||
if chat_mode:
|
||||
text = body.get("message", {}).get("content", "")
|
||||
else:
|
||||
text = body.get("response", "")
|
||||
|
||||
tokens = body.get("eval_count", 0)
|
||||
|
||||
logger.debug(
|
||||
"LLM response: %d tokens, %.0fms", tokens, elapsed,
|
||||
)
|
||||
return LLMResponse(
|
||||
text=text.strip(),
|
||||
model=self.config.model,
|
||||
tokens_used=tokens,
|
||||
latency_ms=elapsed,
|
||||
success=True,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
elapsed = (time.monotonic() - t0) * 1000
|
||||
last_error = str(exc)
|
||||
logger.warning(
|
||||
"LLM request failed (attempt %d/%d, %.0fms): %s",
|
||||
attempt + 1, self.config.max_retries + 1,
|
||||
elapsed, last_error,
|
||||
)
|
||||
if attempt < self.config.max_retries:
|
||||
await asyncio.sleep(delay)
|
||||
delay *= 2
|
||||
|
||||
return LLMResponse(
|
||||
success=False,
|
||||
error=f"LLM unavailable after {self.config.max_retries + 1} attempts: {last_error}",
|
||||
)
|
||||
@@ -0,0 +1,471 @@
|
||||
"""Long-term memory system for LLM context persistence.
|
||||
|
||||
Stores conversation history, important events, and player relationships
|
||||
across sessions. Integrates with both active (SQLite) and historical
|
||||
(Memvid) storage for persistence.
|
||||
|
||||
Usage::
|
||||
|
||||
from ..config.settings import get_settings
|
||||
from .memory import LLMMemory
|
||||
|
||||
memory = LLMMemory(settings.ai.llm.memory_db_path)
|
||||
await memory.initialize()
|
||||
|
||||
# Record a conversation
|
||||
await memory.add_conversation("Alice", "Hey, want to group up?", "Sure!")
|
||||
|
||||
# Recall recent context for a prompt
|
||||
context = await memory.get_conversation_context("Alice", limit=5)
|
||||
|
||||
# Record an important event
|
||||
await memory.add_event("gained_level", {"level": 10, "class": "warrior"})
|
||||
|
||||
# Search memories
|
||||
memories = await memory.search_memories("dragon", limit=10)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class MemoryEntry:
|
||||
"""A single memory entry."""
|
||||
id: int
|
||||
timestamp: datetime
|
||||
category: str # "conversation", "event", "npc_interaction", "quest"
|
||||
key: str # player name, npc name, event type, etc.
|
||||
data: Dict
|
||||
importance: float # 0.0-1.0, for prioritization
|
||||
|
||||
|
||||
class LLMMemory:
|
||||
"""Long-term memory manager for LLM context.
|
||||
|
||||
Maintains:
|
||||
- Conversation history with other players
|
||||
- NPC interactions and reputations
|
||||
- Important game events
|
||||
- Quest progress memories
|
||||
"""
|
||||
|
||||
def __init__(self, db_path: str = "data/llm_memory.db") -> None:
|
||||
self.db_path = Path(db_path)
|
||||
self._conn: Optional[sqlite3.Connection] = None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Initialize the memory database."""
|
||||
self.db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._conn = sqlite3.connect(str(self.db_path), check_same_thread=False)
|
||||
self._conn.row_factory = sqlite3.Row
|
||||
await self._create_tables()
|
||||
logger.info("LLMMemory initialized: %s", self.db_path)
|
||||
|
||||
async def _create_tables(self) -> None:
|
||||
"""Create the memory tables."""
|
||||
self._conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS memories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
key TEXT NOT NULL,
|
||||
data TEXT NOT NULL,
|
||||
importance REAL DEFAULT 0.5,
|
||||
summary TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_category
|
||||
ON memories(category);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_key
|
||||
ON memories(key);
|
||||
CREATE INDEX IF NOT EXISTS idx_memories_timestamp
|
||||
ON memories(timestamp);
|
||||
CREATE VIRTUAL TABLE IF NOT EXISTS memories_fts
|
||||
USING fts5(summary, data, content=memories, content_rowid=id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS npc_reputations (
|
||||
npc_name TEXT PRIMARY KEY,
|
||||
reputation_score REAL DEFAULT 0.0,
|
||||
interactions_count INTEGER DEFAULT 0,
|
||||
last_interaction TEXT,
|
||||
notes TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS player_relationships (
|
||||
player_name TEXT PRIMARY KEY,
|
||||
relationship_score REAL DEFAULT 0.0,
|
||||
first_met TEXT,
|
||||
last_interaction TEXT,
|
||||
notes TEXT,
|
||||
conversation_count INTEGER DEFAULT 0
|
||||
);
|
||||
""")
|
||||
self._conn.commit()
|
||||
|
||||
async def add_conversation(
|
||||
self,
|
||||
player_name: str,
|
||||
message: str,
|
||||
reply: str,
|
||||
importance: float = 0.5,
|
||||
) -> None:
|
||||
"""Record a conversation with another player."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
summary = f"Conversation with {player_name}: {message[:50]}..."
|
||||
|
||||
data = {
|
||||
"player": player_name,
|
||||
"message": message,
|
||||
"reply": reply,
|
||||
"direction": "received" if message else "sent",
|
||||
}
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(now, "conversation", player_name, json.dumps(data), importance, summary)
|
||||
)
|
||||
|
||||
# Update FTS index
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
|
||||
(cursor.lastrowid, summary, json.dumps(data))
|
||||
)
|
||||
|
||||
# Update player relationship
|
||||
self._conn.execute(
|
||||
"""INSERT INTO player_relationships (player_name, last_interaction, conversation_count)
|
||||
VALUES (?, ?, 1)
|
||||
ON CONFLICT(player_name) DO UPDATE SET
|
||||
last_interaction = excluded.last_interaction,
|
||||
conversation_count = conversation_count + 1,
|
||||
first_met = COALESCE(first_met, excluded.last_interaction)""",
|
||||
(player_name, now)
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
async def get_conversation_context(
|
||||
self,
|
||||
player_name: str,
|
||||
limit: int = 5,
|
||||
) -> List[Dict]:
|
||||
"""Get recent conversation history with a player."""
|
||||
if not self._conn:
|
||||
return []
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT timestamp, data, importance FROM memories
|
||||
WHERE category = 'conversation' AND key = ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?""",
|
||||
(player_name, limit)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
data = json.loads(row["data"])
|
||||
results.append({
|
||||
"timestamp": row["timestamp"],
|
||||
"message": data.get("message", ""),
|
||||
"reply": data.get("reply", ""),
|
||||
"importance": row["importance"],
|
||||
})
|
||||
|
||||
return list(reversed(results)) # Oldest first
|
||||
|
||||
async def add_npc_interaction(
|
||||
self,
|
||||
npc_name: str,
|
||||
interaction_type: str, # "talk", "combat", "trade", "quest"
|
||||
details: Dict,
|
||||
reputation_delta: float = 0.0,
|
||||
) -> None:
|
||||
"""Record an interaction with an NPC."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
summary = f"{interaction_type} with {npc_name}: {str(details)[:50]}"
|
||||
|
||||
data = {
|
||||
"npc": npc_name,
|
||||
"type": interaction_type,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(now, "npc_interaction", npc_name, json.dumps(data), 0.6, summary)
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
|
||||
(cursor.lastrowid, summary, json.dumps(data))
|
||||
)
|
||||
|
||||
# Update NPC reputation
|
||||
self._conn.execute(
|
||||
"""INSERT INTO npc_reputations (npc_name, reputation_score, interactions_count, last_interaction)
|
||||
VALUES (?, ?, 1, ?)
|
||||
ON CONFLICT(npc_name) DO UPDATE SET
|
||||
reputation_score = reputation_score + ?,
|
||||
interactions_count = interactions_count + 1,
|
||||
last_interaction = excluded.last_interaction""",
|
||||
(npc_name, reputation_delta, now, reputation_delta)
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
async def get_npc_reputation(self, npc_name: str) -> Optional[Dict]:
|
||||
"""Get reputation info for an NPC."""
|
||||
if not self._conn:
|
||||
return None
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT * FROM npc_reputations WHERE npc_name = ?""",
|
||||
(npc_name,)
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
|
||||
if row:
|
||||
return {
|
||||
"npc_name": row["npc_name"],
|
||||
"reputation_score": row["reputation_score"],
|
||||
"interactions_count": row["interactions_count"],
|
||||
"last_interaction": row["last_interaction"],
|
||||
"notes": row["notes"],
|
||||
}
|
||||
return None
|
||||
|
||||
async def add_event(
|
||||
self,
|
||||
event_type: str,
|
||||
details: Dict,
|
||||
importance: float = 0.7,
|
||||
) -> None:
|
||||
"""Record an important game event."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
now = datetime.utcnow().isoformat()
|
||||
summary = f"{event_type}: {str(details)[:50]}"
|
||||
|
||||
data = {
|
||||
"type": event_type,
|
||||
"details": details,
|
||||
}
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""INSERT INTO memories (timestamp, category, key, data, importance, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(now, "event", event_type, json.dumps(data), importance, summary)
|
||||
)
|
||||
|
||||
self._conn.execute(
|
||||
"INSERT INTO memories_fts (rowid, summary, data) VALUES (?, ?, ?)",
|
||||
(cursor.lastrowid, summary, json.dumps(data))
|
||||
)
|
||||
|
||||
self._conn.commit()
|
||||
|
||||
async def search_memories(
|
||||
self,
|
||||
query: str,
|
||||
category: Optional[str] = None,
|
||||
limit: int = 10,
|
||||
) -> List[MemoryEntry]:
|
||||
"""Search memories using full-text search."""
|
||||
if not self._conn:
|
||||
return []
|
||||
|
||||
if category:
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT m.* FROM memories m
|
||||
JOIN memories_fts fts ON m.id = fts.rowid
|
||||
WHERE memories_fts MATCH ? AND m.category = ?
|
||||
ORDER BY rank, m.importance DESC
|
||||
LIMIT ?""",
|
||||
(query, category, limit)
|
||||
)
|
||||
else:
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT m.* FROM memories m
|
||||
JOIN memories_fts fts ON m.id = fts.rowid
|
||||
WHERE memories_fts MATCH ?
|
||||
ORDER BY rank, m.importance DESC
|
||||
LIMIT ?""",
|
||||
(query, limit)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append(MemoryEntry(
|
||||
id=row["id"],
|
||||
timestamp=datetime.fromisoformat(row["timestamp"]),
|
||||
category=row["category"],
|
||||
key=row["key"],
|
||||
data=json.loads(row["data"]),
|
||||
importance=row["importance"],
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def get_important_memories(
|
||||
self,
|
||||
min_importance: float = 0.7,
|
||||
limit: int = 20,
|
||||
) -> List[MemoryEntry]:
|
||||
"""Get the most important recent memories."""
|
||||
if not self._conn:
|
||||
return []
|
||||
|
||||
cursor = self._conn.execute(
|
||||
"""SELECT * FROM memories
|
||||
WHERE importance >= ?
|
||||
ORDER BY timestamp DESC
|
||||
LIMIT ?""",
|
||||
(min_importance, limit)
|
||||
)
|
||||
|
||||
results = []
|
||||
for row in cursor:
|
||||
results.append(MemoryEntry(
|
||||
id=row["id"],
|
||||
timestamp=datetime.fromisoformat(row["timestamp"]),
|
||||
category=row["category"],
|
||||
key=row["key"],
|
||||
data=json.loads(row["data"]),
|
||||
importance=row["importance"],
|
||||
))
|
||||
|
||||
return results
|
||||
|
||||
async def build_context_prompt(
|
||||
self,
|
||||
current_player: Optional[str] = None,
|
||||
max_tokens: int = 500,
|
||||
) -> str:
|
||||
"""Build a context prompt from recent important memories.
|
||||
|
||||
This creates a summary of relevant memories to prepend to LLM prompts.
|
||||
"""
|
||||
memories = await self.get_important_memories(limit=10)
|
||||
|
||||
if current_player:
|
||||
# Add conversation context with current player
|
||||
conversations = await self.get_conversation_context(current_player, limit=3)
|
||||
for conv in conversations:
|
||||
memories.append(MemoryEntry(
|
||||
id=0,
|
||||
timestamp=datetime.fromisoformat(conv["timestamp"]),
|
||||
category="conversation",
|
||||
key=current_player,
|
||||
data=conv,
|
||||
importance=conv.get("importance", 0.5),
|
||||
))
|
||||
|
||||
# Sort by importance and recency
|
||||
memories.sort(key=lambda m: (m.importance, m.timestamp), reverse=True)
|
||||
|
||||
context_parts = []
|
||||
token_count = 0
|
||||
|
||||
for mem in memories[:10]: # Max 10 memories
|
||||
if mem.category == "conversation":
|
||||
part = f"- Talked with {mem.key}: {mem.data.get('message', '')[:50]}\n"
|
||||
elif mem.category == "event":
|
||||
part = f"- {mem.data.get('type', 'Event')}: {str(mem.data.get('details', {}))[:50]}\n"
|
||||
elif mem.category == "npc_interaction":
|
||||
part = f"- Met {mem.key} ({mem.data.get('type', 'unknown')})\n"
|
||||
else:
|
||||
part = f"- {mem.category}: {mem.key}\n"
|
||||
|
||||
# Rough token estimate: 4 chars ≈ 1 token
|
||||
estimated_tokens = len(part) // 4
|
||||
if token_count + estimated_tokens > max_tokens:
|
||||
break
|
||||
|
||||
context_parts.append(part)
|
||||
token_count += estimated_tokens
|
||||
|
||||
if not context_parts:
|
||||
return ""
|
||||
|
||||
return "Previous memories:\n" + "".join(context_parts) + "\n"
|
||||
|
||||
async def export_to_json(self, filepath: str) -> None:
|
||||
"""Export all memories to JSON for backup/synchronization."""
|
||||
if not self._conn:
|
||||
return
|
||||
|
||||
cursor = self._conn.execute("SELECT * FROM memories ORDER BY timestamp")
|
||||
memories = []
|
||||
for row in cursor:
|
||||
memories.append({
|
||||
"id": row["id"],
|
||||
"timestamp": row["timestamp"],
|
||||
"category": row["category"],
|
||||
"key": row["key"],
|
||||
"data": json.loads(row["data"]),
|
||||
"importance": row["importance"],
|
||||
"summary": row["summary"],
|
||||
})
|
||||
|
||||
Path(filepath).parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(filepath, 'w') as f:
|
||||
json.dump(memories, f, indent=2)
|
||||
|
||||
logger.info("Exported %d memories to %s", len(memories), filepath)
|
||||
|
||||
async def import_from_json(self, filepath: str) -> int:
|
||||
"""Import memories from JSON backup."""
|
||||
if not self._conn or not Path(filepath).exists():
|
||||
return 0
|
||||
|
||||
with open(filepath) as f:
|
||||
memories = json.load(f)
|
||||
|
||||
count = 0
|
||||
for mem in memories:
|
||||
try:
|
||||
self._conn.execute(
|
||||
"""INSERT OR IGNORE INTO memories
|
||||
(timestamp, category, key, data, importance, summary)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
mem["timestamp"],
|
||||
mem["category"],
|
||||
mem["key"],
|
||||
json.dumps(mem["data"]),
|
||||
mem["importance"],
|
||||
mem.get("summary", ""),
|
||||
)
|
||||
)
|
||||
count += 1
|
||||
except Exception as e:
|
||||
logger.warning("Failed to import memory %s: %s", mem.get("id"), e)
|
||||
|
||||
self._conn.commit()
|
||||
logger.info("Imported %d memories from %s", count, filepath)
|
||||
return count
|
||||
|
||||
async def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self._conn:
|
||||
self._conn.close()
|
||||
self._conn = None
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Social AI — LLM-driven interaction with other players.
|
||||
|
||||
Handles:
|
||||
* Roleplay responses to say/tell/shout messages.
|
||||
* Trade negotiation (buy/sell offers).
|
||||
* Group invitations (accept/decline with reason).
|
||||
* Emote generation for immersion.
|
||||
|
||||
All methods are async and gracefully return ``None`` when the LLM
|
||||
is unavailable, allowing the caller to fall back to silence or a
|
||||
canned response.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
from .client import LLMClient, LLMConfig
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Patterns to detect player communication
|
||||
_SAY_RE = re.compile(r"(\w+)\s+says?[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I)
|
||||
_TELL_RE = re.compile(r"(\w+)\s+tells?\s+you[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I)
|
||||
_SHOUT_RE = re.compile(r"(\w+)\s+shouts?[,:]\s*['\"]?(.+?)['\"]?\s*$", re.I)
|
||||
_GROUP_INVITE_RE = re.compile(r"(\w+)\s+invites?\s+you\s+to\s+(?:join\s+)?(?:a\s+)?(?:group|party)", re.I)
|
||||
_TRADE_RE = re.compile(r"(\w+)\s+offers?\s+(?:to\s+)?(?:buy|sell|trade)", re.I)
|
||||
|
||||
|
||||
class SocialAI:
|
||||
"""Generates socially-appropriate responses using the LLM."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: LLMClient,
|
||||
character_name: str = "Player",
|
||||
character_class: str = "adventurer",
|
||||
) -> None:
|
||||
self.client = client
|
||||
self.character_name = character_name
|
||||
self.character_class = character_class
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Detection
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def detect_social_event(self, text: str) -> Optional[dict]:
|
||||
"""Check if *text* is a social interaction.
|
||||
|
||||
Returns ``{"type": str, "speaker": str, "message": str}`` or ``None``.
|
||||
"""
|
||||
for pattern, event_type in [
|
||||
(_TELL_RE, "tell"),
|
||||
(_SAY_RE, "say"),
|
||||
(_SHOUT_RE, "shout"),
|
||||
]:
|
||||
m = pattern.search(text)
|
||||
if m:
|
||||
return {
|
||||
"type": event_type,
|
||||
"speaker": m.group(1),
|
||||
"message": m.group(2).strip(),
|
||||
}
|
||||
|
||||
if _GROUP_INVITE_RE.search(text):
|
||||
m = _GROUP_INVITE_RE.search(text)
|
||||
return {"type": "group_invite", "speaker": m.group(1), "message": text}
|
||||
|
||||
if _TRADE_RE.search(text):
|
||||
m = _TRADE_RE.search(text)
|
||||
return {"type": "trade", "speaker": m.group(1), "message": text}
|
||||
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Response generation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def respond(self, event: dict) -> Optional[str]:
|
||||
"""Generate a response for a social event.
|
||||
|
||||
Returns the MUD command to send (e.g. ``say Hello!``), or ``None``.
|
||||
"""
|
||||
event_type = event.get("type", "say")
|
||||
speaker = event.get("speaker", "someone")
|
||||
message = event.get("message", "")
|
||||
|
||||
# Group invites are handled without LLM
|
||||
if event_type == "group_invite":
|
||||
return await self._handle_group_invite(speaker)
|
||||
|
||||
reply_text = await self._generate_reply(message)
|
||||
if not reply_text:
|
||||
return None
|
||||
|
||||
# Format as a MUD command
|
||||
if event_type == "tell":
|
||||
return f"tell {speaker} {reply_text}"
|
||||
elif event_type == "shout":
|
||||
return f"shout {reply_text}"
|
||||
elif event_type == "trade":
|
||||
return f"tell {speaker} {reply_text}"
|
||||
else:
|
||||
return f"say {reply_text}"
|
||||
|
||||
async def _generate_reply(self, message: str) -> Optional[str]:
|
||||
"""Use the LLM to generate an in-character reply."""
|
||||
resp = await self.client.ask(
|
||||
"social_reply",
|
||||
message=message,
|
||||
character_class=self.character_class,
|
||||
character_name=self.character_name,
|
||||
)
|
||||
if not resp.success or not resp.text:
|
||||
return None
|
||||
|
||||
reply = resp.text.strip().strip('"').strip("'")
|
||||
if len(reply) > 200:
|
||||
reply = reply[:197] + "..."
|
||||
return reply
|
||||
|
||||
async def _handle_group_invite(self, speaker: str) -> str:
|
||||
"""Decide whether to accept a group invite."""
|
||||
# For now, always accept — a future version could consult
|
||||
# the GoalManager to decide.
|
||||
logger.info("Accepting group invite from %s", speaker)
|
||||
return "group accept"
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Emotes
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def generate_emote(self, context: str) -> Optional[str]:
|
||||
"""Generate a contextual emote command for immersion.
|
||||
|
||||
Example: ``emote sharpens his sword quietly.``
|
||||
"""
|
||||
prompt = (
|
||||
f"You are {self.character_name}, a {self.character_class}. "
|
||||
f"Context: {context}\n"
|
||||
f"Generate a short emote action (max 10 words, no quotes). "
|
||||
f"Emote:"
|
||||
)
|
||||
resp = await self.client.generate(prompt)
|
||||
if not resp.success or not resp.text:
|
||||
return None
|
||||
|
||||
emote = resp.text.strip().strip('"').strip("'")
|
||||
if len(emote) > 80:
|
||||
emote = emote[:77] + "..."
|
||||
return f"emote {emote}"
|
||||
@@ -0,0 +1,18 @@
|
||||
"""Auto-mapper module — hybrid SQLite/Memvid automapping system."""
|
||||
|
||||
from .world_mapper import WorldMapper
|
||||
from .active_storage import ActiveStorage, generate_fingerprint
|
||||
from .historical_storage import HistoricalStorage
|
||||
from .semantic_search import SemanticSearch
|
||||
from .sync_manager import SyncManager
|
||||
from .pathfinder import Pathfinder
|
||||
|
||||
__all__ = [
|
||||
"WorldMapper",
|
||||
"ActiveStorage",
|
||||
"generate_fingerprint",
|
||||
"HistoricalStorage",
|
||||
"SemanticSearch",
|
||||
"SyncManager",
|
||||
"Pathfinder",
|
||||
]
|
||||
@@ -0,0 +1,318 @@
|
||||
"""Active storage for real-time map data.
|
||||
|
||||
Layer 1 of the hybrid storage architecture:
|
||||
SQLite for persistence + NetworkX DiGraph for in-memory navigation.
|
||||
|
||||
Provides O(1) room lookup by fingerprint, real-time graph updates,
|
||||
and an exploration queue for tracking unexplored exits.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from typing import Optional, Dict, List, Tuple
|
||||
|
||||
import networkx as nx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Column indices for the rooms table
|
||||
_COL_ID = 0
|
||||
_COL_FINGERPRINT = 1
|
||||
_COL_TITLE = 2
|
||||
_COL_DESCRIPTION = 3
|
||||
_COL_SHORT_DESC = 4
|
||||
_COL_ZONE = 5
|
||||
_COL_COORDINATES = 6
|
||||
_COL_NPCS = 7
|
||||
_COL_ITEMS = 8
|
||||
_COL_EXITS = 9
|
||||
_COL_FIRST_VISITED = 10
|
||||
_COL_LAST_VISITED = 11
|
||||
_COL_VISIT_COUNT = 12
|
||||
_COL_IS_DYNAMIC = 13
|
||||
_COL_CONFIDENCE = 14
|
||||
|
||||
|
||||
def generate_fingerprint(title: str, description: str, exits: List[str]) -> str:
|
||||
"""Create a deterministic room fingerprint.
|
||||
|
||||
Uses SHA-256 of normalised title + first 100 chars of description
|
||||
+ sorted exit list. This produces a stable ID even across sessions.
|
||||
"""
|
||||
norm_title = (title or "").strip().lower()
|
||||
norm_desc = (description or "").strip().lower()[:100]
|
||||
norm_exits = ",".join(sorted(e.lower() for e in exits))
|
||||
payload = f"{norm_title}|{norm_desc}|{norm_exits}"
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:16]
|
||||
|
||||
|
||||
class ActiveStorage:
|
||||
"""Layer 1: Active storage using SQLite + NetworkX for real-time exploration."""
|
||||
|
||||
def __init__(self, db_path: str = "data/maps/active/world.db") -> None:
|
||||
self.db_path = db_path
|
||||
self.conn: Optional[sqlite3.Connection] = None
|
||||
self.graph = nx.DiGraph()
|
||||
self._ensure_directory()
|
||||
self._init_database()
|
||||
self._load_graph()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Initialisation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _ensure_directory(self) -> None:
|
||||
dirpath = os.path.dirname(self.db_path)
|
||||
if dirpath:
|
||||
os.makedirs(dirpath, exist_ok=True)
|
||||
|
||||
def _init_database(self) -> None:
|
||||
"""Create the SQLite schema if it doesn't exist."""
|
||||
self.conn = sqlite3.connect(self.db_path)
|
||||
cur = self.conn.cursor()
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS rooms (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
fingerprint TEXT UNIQUE NOT NULL,
|
||||
title TEXT,
|
||||
description TEXT,
|
||||
short_desc TEXT,
|
||||
zone TEXT,
|
||||
coordinates TEXT,
|
||||
npcs TEXT,
|
||||
items TEXT,
|
||||
exits TEXT,
|
||||
first_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
last_visited TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
visit_count INTEGER DEFAULT 1,
|
||||
is_dynamic INTEGER DEFAULT 0,
|
||||
confidence REAL DEFAULT 1.0
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS exits (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
from_room_id INTEGER NOT NULL,
|
||||
to_room_id INTEGER,
|
||||
direction TEXT NOT NULL,
|
||||
blocked INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (from_room_id) REFERENCES rooms(id),
|
||||
FOREIGN KEY (to_room_id) REFERENCES rooms(id),
|
||||
UNIQUE(from_room_id, direction)
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS exploration_queue (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
room_id INTEGER NOT NULL,
|
||||
direction TEXT NOT NULL,
|
||||
priority INTEGER DEFAULT 5,
|
||||
added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (room_id) REFERENCES rooms(id),
|
||||
UNIQUE(room_id, direction)
|
||||
)
|
||||
""")
|
||||
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_fingerprint ON rooms(fingerprint)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_zone ON rooms(zone)")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS idx_eq_priority ON exploration_queue(priority)")
|
||||
|
||||
self.conn.commit()
|
||||
logger.info("Database initialised at %s", self.db_path)
|
||||
|
||||
def _load_graph(self) -> None:
|
||||
"""Populate the in-memory graph from the database."""
|
||||
cur = self.conn.cursor()
|
||||
|
||||
cur.execute(
|
||||
"SELECT id, fingerprint, title, description, zone FROM rooms"
|
||||
)
|
||||
for row in cur.fetchall():
|
||||
rid, fp, title, desc, zone = row
|
||||
self.graph.add_node(
|
||||
rid, fingerprint=fp, title=title,
|
||||
description=desc, zone=zone,
|
||||
)
|
||||
|
||||
cur.execute("SELECT from_room_id, to_room_id, direction, blocked FROM exits")
|
||||
for row in cur.fetchall():
|
||||
from_id, to_id, direction, blocked = row
|
||||
if to_id is not None and not blocked:
|
||||
self.graph.add_edge(from_id, to_id, direction=direction)
|
||||
|
||||
logger.info(
|
||||
"Loaded graph: %d rooms, %d exits",
|
||||
self.graph.number_of_nodes(),
|
||||
self.graph.number_of_edges(),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Room CRUD
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_room(self, room_data: Dict) -> int:
|
||||
"""Insert or update a room. Returns the room ID."""
|
||||
cur = self.conn.cursor()
|
||||
fp = room_data["fingerprint"]
|
||||
|
||||
# Check if room already exists
|
||||
cur.execute("SELECT id, visit_count FROM rooms WHERE fingerprint = ?", (fp,))
|
||||
existing = cur.fetchone()
|
||||
|
||||
if existing:
|
||||
room_id, visits = existing
|
||||
cur.execute(
|
||||
"UPDATE rooms SET last_visited = CURRENT_TIMESTAMP, "
|
||||
"visit_count = ? WHERE id = ?",
|
||||
(visits + 1, room_id),
|
||||
)
|
||||
self.conn.commit()
|
||||
logger.debug("Revisited room %d (visits=%d)", room_id, visits + 1)
|
||||
return room_id
|
||||
|
||||
cur.execute(
|
||||
"""INSERT INTO rooms
|
||||
(fingerprint, title, description, short_desc, zone,
|
||||
coordinates, npcs, items, exits, is_dynamic, confidence)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(
|
||||
fp,
|
||||
room_data.get("title", ""),
|
||||
room_data.get("description", ""),
|
||||
room_data.get("short_desc"),
|
||||
room_data.get("zone"),
|
||||
json.dumps(room_data.get("coordinates", {})),
|
||||
json.dumps(room_data.get("npcs", [])),
|
||||
json.dumps(room_data.get("items", [])),
|
||||
json.dumps(room_data.get("exits", {})),
|
||||
int(room_data.get("is_dynamic", False)),
|
||||
room_data.get("confidence", 1.0),
|
||||
),
|
||||
)
|
||||
self.conn.commit()
|
||||
room_id = cur.lastrowid
|
||||
|
||||
# Update in-memory graph
|
||||
self.graph.add_node(room_id, **room_data)
|
||||
logger.debug("Added room %d: '%s'", room_id, room_data.get("title", "?"))
|
||||
return room_id
|
||||
|
||||
def add_exit(
|
||||
self, from_room_id: int, to_room_id: Optional[int], direction: str,
|
||||
blocked: bool = False,
|
||||
) -> None:
|
||||
"""Record an exit between two rooms."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT OR REPLACE INTO exits
|
||||
(from_room_id, to_room_id, direction, blocked)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(from_room_id, to_room_id, direction, int(blocked)),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
if to_room_id is not None and not blocked:
|
||||
self.graph.add_edge(from_room_id, to_room_id, direction=direction)
|
||||
|
||||
def get_room(self, room_id: int) -> Optional[Dict]:
|
||||
"""Return a full room dict by numeric ID."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT * FROM rooms WHERE id = ?", (room_id,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
def get_room_by_fingerprint(self, fingerprint: str) -> Optional[Dict]:
|
||||
"""Look up a room by its fingerprint hash."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT * FROM rooms WHERE fingerprint = ?", (fingerprint,))
|
||||
row = cur.fetchone()
|
||||
return self._row_to_dict(row) if row else None
|
||||
|
||||
def get_all_rooms(self) -> List[Dict]:
|
||||
"""Return every room as a dict list (used for Memvid snapshots)."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT * FROM rooms")
|
||||
return [self._row_to_dict(r) for r in cur.fetchall()]
|
||||
|
||||
def room_count(self) -> int:
|
||||
"""Return the total number of stored rooms."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM rooms")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Exploration queue
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def enqueue_unexplored(
|
||||
self, room_id: int, direction: str, priority: int = 5,
|
||||
) -> None:
|
||||
"""Add an unexplored exit to the exploration queue."""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"""INSERT OR IGNORE INTO exploration_queue
|
||||
(room_id, direction, priority) VALUES (?, ?, ?)""",
|
||||
(room_id, direction, priority),
|
||||
)
|
||||
self.conn.commit()
|
||||
|
||||
def dequeue_next_exploration(self) -> Optional[Tuple[int, str]]:
|
||||
"""Pop the highest-priority unexplored exit.
|
||||
|
||||
Returns ``(room_id, direction)`` or ``None``.
|
||||
"""
|
||||
cur = self.conn.cursor()
|
||||
cur.execute(
|
||||
"SELECT id, room_id, direction FROM exploration_queue "
|
||||
"ORDER BY priority ASC, added_at ASC LIMIT 1"
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
eq_id, room_id, direction = row
|
||||
cur.execute("DELETE FROM exploration_queue WHERE id = ?", (eq_id,))
|
||||
self.conn.commit()
|
||||
return (room_id, direction)
|
||||
|
||||
def exploration_queue_size(self) -> int:
|
||||
cur = self.conn.cursor()
|
||||
cur.execute("SELECT COUNT(*) FROM exploration_queue")
|
||||
return cur.fetchone()[0]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _row_to_dict(row) -> Dict:
|
||||
"""Convert a raw SQLite row tuple to a room dictionary."""
|
||||
return {
|
||||
"id": row[_COL_ID],
|
||||
"fingerprint": row[_COL_FINGERPRINT],
|
||||
"title": row[_COL_TITLE],
|
||||
"description": row[_COL_DESCRIPTION],
|
||||
"short_desc": row[_COL_SHORT_DESC],
|
||||
"zone": row[_COL_ZONE],
|
||||
"coordinates": json.loads(row[_COL_COORDINATES] or "{}"),
|
||||
"npcs": json.loads(row[_COL_NPCS] or "[]"),
|
||||
"items": json.loads(row[_COL_ITEMS] or "[]"),
|
||||
"exits": json.loads(row[_COL_EXITS] or "{}"),
|
||||
"first_visited": row[_COL_FIRST_VISITED],
|
||||
"last_visited": row[_COL_LAST_VISITED],
|
||||
"visit_count": row[_COL_VISIT_COUNT],
|
||||
"is_dynamic": bool(row[_COL_IS_DYNAMIC]),
|
||||
"confidence": row[_COL_CONFIDENCE],
|
||||
}
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the database connection."""
|
||||
if self.conn:
|
||||
self.conn.close()
|
||||
self.conn = None
|
||||
@@ -0,0 +1,211 @@
|
||||
"""Historical storage using Memvid (QR-in-MP4 encoding).
|
||||
|
||||
Layer 2 of the hybrid automapper architecture. Periodically takes
|
||||
a snapshot of the active SQLite map and encodes all room data as
|
||||
QR-code frames inside an MP4 video. This provides:
|
||||
|
||||
* 50-100x compression over raw text
|
||||
* Sub-100ms retrieval via frame seek
|
||||
* Portable single-file archive
|
||||
* FAISS index for semantic search
|
||||
|
||||
Because Memvid/FAISS/sentence-transformers are heavy optional
|
||||
dependencies, this module gracefully degrades to a **JSON fallback**
|
||||
when those libraries are not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Try to import optional heavy dependencies
|
||||
_HAS_MEMVID = False
|
||||
_HAS_FAISS = False
|
||||
|
||||
try:
|
||||
import memvid # noqa: F401
|
||||
_HAS_MEMVID = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import faiss # noqa: F401
|
||||
_HAS_FAISS = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class HistoricalStorage:
|
||||
"""Layer 2: Historical storage using Memvid for compressed archival.
|
||||
|
||||
Falls back to JSON snapshots when Memvid is unavailable.
|
||||
"""
|
||||
|
||||
def __init__(self, archive_dir: str = "data/maps/archive") -> None:
|
||||
self.archive_dir = archive_dir
|
||||
os.makedirs(archive_dir, exist_ok=True)
|
||||
|
||||
self._encoder = None
|
||||
self._decoder = None
|
||||
self._faiss_index = None
|
||||
|
||||
if _HAS_MEMVID:
|
||||
try:
|
||||
self._encoder = memvid.MemvidEncoder()
|
||||
logger.info("Memvid encoder initialised")
|
||||
except Exception as exc:
|
||||
logger.warning("Memvid encoder init failed: %s", exc)
|
||||
else:
|
||||
logger.info(
|
||||
"Memvid not installed — using JSON fallback for snapshots"
|
||||
)
|
||||
|
||||
logger.info("Historical storage ready at %s", archive_dir)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Snapshot creation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def create_snapshot(self, rooms: List[Dict], version: int) -> str:
|
||||
"""Create a compressed snapshot of the current map state.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
rooms:
|
||||
List of room dicts (from ``ActiveStorage.get_all_rooms()``).
|
||||
version:
|
||||
Monotonically increasing snapshot version number.
|
||||
|
||||
Returns
|
||||
-------
|
||||
str
|
||||
Path to the created snapshot file.
|
||||
"""
|
||||
logger.info(
|
||||
"Creating snapshot v%d with %d rooms", version, len(rooms),
|
||||
)
|
||||
|
||||
if _HAS_MEMVID and self._encoder is not None:
|
||||
return self._create_memvid_snapshot(rooms, version)
|
||||
return self._create_json_snapshot(rooms, version)
|
||||
|
||||
def _create_memvid_snapshot(self, rooms: List[Dict], version: int) -> str:
|
||||
"""Encode rooms as QR frames in an MP4."""
|
||||
for room in rooms:
|
||||
chunk = json.dumps(room, ensure_ascii=False)
|
||||
self._encoder.add_chunk(chunk)
|
||||
|
||||
filename = f"world_v{version}.mp4"
|
||||
filepath = os.path.join(self.archive_dir, filename)
|
||||
index_path = os.path.join(self.archive_dir, f"world_v{version}_index.json")
|
||||
|
||||
self._encoder.build_video(filepath, index_path)
|
||||
logger.info("Memvid snapshot saved: %s", filepath)
|
||||
|
||||
# Reset encoder for next snapshot
|
||||
self._encoder = memvid.MemvidEncoder()
|
||||
return filepath
|
||||
|
||||
def _create_json_snapshot(self, rooms: List[Dict], version: int) -> str:
|
||||
"""Fallback: save rooms as a compressed JSON file."""
|
||||
filename = f"world_v{version}.json"
|
||||
filepath = os.path.join(self.archive_dir, filename)
|
||||
|
||||
snapshot = {
|
||||
"version": version,
|
||||
"timestamp": time.time(),
|
||||
"room_count": len(rooms),
|
||||
"rooms": rooms,
|
||||
}
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(snapshot, f, ensure_ascii=False, indent=None)
|
||||
|
||||
logger.info("JSON snapshot saved: %s (%d rooms)", filepath, len(rooms))
|
||||
return filepath
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Snapshot loading
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def load_snapshot(self, version: int) -> Optional[List[Dict]]:
|
||||
"""Load rooms from a snapshot by version number."""
|
||||
# Try Memvid first
|
||||
mp4_path = os.path.join(self.archive_dir, f"world_v{version}.mp4")
|
||||
if os.path.exists(mp4_path) and _HAS_MEMVID:
|
||||
return self._load_memvid_snapshot(version)
|
||||
|
||||
# Fall back to JSON
|
||||
json_path = os.path.join(self.archive_dir, f"world_v{version}.json")
|
||||
if os.path.exists(json_path):
|
||||
return self._load_json_snapshot(json_path)
|
||||
|
||||
logger.warning("Snapshot v%d not found", version)
|
||||
return None
|
||||
|
||||
def _load_memvid_snapshot(self, version: int) -> Optional[List[Dict]]:
|
||||
mp4_path = os.path.join(self.archive_dir, f"world_v{version}.mp4")
|
||||
index_path = os.path.join(self.archive_dir, f"world_v{version}_index.json")
|
||||
try:
|
||||
decoder = memvid.MemvidDecoder(mp4_path, index_path)
|
||||
rooms = []
|
||||
for i in range(decoder.chunk_count):
|
||||
chunk = decoder.get_chunk(i)
|
||||
rooms.append(json.loads(chunk))
|
||||
logger.info("Loaded Memvid snapshot v%d: %d rooms", version, len(rooms))
|
||||
return rooms
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load Memvid snapshot v%d: %s", version, exc)
|
||||
return None
|
||||
|
||||
def _load_json_snapshot(self, path: str) -> Optional[List[Dict]]:
|
||||
try:
|
||||
with open(path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
rooms = data.get("rooms", [])
|
||||
logger.info("Loaded JSON snapshot: %d rooms", len(rooms))
|
||||
return rooms
|
||||
except Exception as exc:
|
||||
logger.error("Failed to load JSON snapshot %s: %s", path, exc)
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def list_snapshots(self) -> List[Dict]:
|
||||
"""List all available snapshots with metadata."""
|
||||
snapshots = []
|
||||
archive = Path(self.archive_dir)
|
||||
for f in sorted(archive.glob("world_v*.json")):
|
||||
try:
|
||||
with open(f, "r") as fh:
|
||||
data = json.load(fh)
|
||||
snapshots.append({
|
||||
"version": data["version"],
|
||||
"room_count": data["room_count"],
|
||||
"timestamp": data["timestamp"],
|
||||
"format": "json",
|
||||
"path": str(f),
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
for f in sorted(archive.glob("world_v*.mp4")):
|
||||
# Extract version from filename
|
||||
name = f.stem # "world_v3"
|
||||
try:
|
||||
ver = int(name.split("_v")[1])
|
||||
snapshots.append({
|
||||
"version": ver,
|
||||
"format": "memvid",
|
||||
"path": str(f),
|
||||
})
|
||||
except (IndexError, ValueError):
|
||||
pass
|
||||
return snapshots
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Pathfinding algorithms"""
|
||||
|
||||
import networkx as nx
|
||||
from typing import List, Optional
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Pathfinder:
|
||||
"""Pathfinding for navigation between rooms"""
|
||||
|
||||
def __init__(self, graph: nx.DiGraph):
|
||||
self.graph = graph
|
||||
|
||||
def find_path(self, from_room_id: int, to_room_id: int) -> Optional[List[int]]:
|
||||
"""
|
||||
Find shortest path between two rooms
|
||||
|
||||
Args:
|
||||
from_room_id: Starting room ID
|
||||
to_room_id: Destination room ID
|
||||
|
||||
Returns:
|
||||
List of room IDs representing the path, or None if no path exists
|
||||
"""
|
||||
try:
|
||||
path = nx.shortest_path(self.graph, from_room_id, to_room_id)
|
||||
logger.debug(f"Path found: {len(path)} rooms")
|
||||
return path
|
||||
except nx.NetworkXNoPath:
|
||||
logger.warning(f"No path found from {from_room_id} to {to_room_id}")
|
||||
return None
|
||||
|
||||
def get_directions(self, path: List[int]) -> List[str]:
|
||||
"""
|
||||
Convert room path to direction commands
|
||||
|
||||
Args:
|
||||
path: List of room IDs
|
||||
|
||||
Returns:
|
||||
List of direction commands (n, s, e, w, etc.)
|
||||
"""
|
||||
directions = []
|
||||
for i in range(len(path) - 1):
|
||||
from_room = path[i]
|
||||
to_room = path[i + 1]
|
||||
|
||||
# Get edge data
|
||||
edge_data = self.graph.get_edge_data(from_room, to_room)
|
||||
if edge_data and 'direction' in edge_data:
|
||||
directions.append(edge_data['direction'])
|
||||
|
||||
return directions
|
||||
@@ -0,0 +1,172 @@
|
||||
"""Semantic search on map archive using FAISS + sentence-transformers.
|
||||
|
||||
Allows natural-language queries like:
|
||||
``"dark forest with goblin"``
|
||||
``"shop that sells potions"``
|
||||
|
||||
Gracefully falls back to keyword matching when FAISS or
|
||||
sentence-transformers are not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Optional heavy deps
|
||||
_HAS_NUMPY = False
|
||||
_HAS_FAISS = False
|
||||
_HAS_TRANSFORMERS = False
|
||||
|
||||
try:
|
||||
import numpy as np # noqa: F401
|
||||
_HAS_NUMPY = True
|
||||
except ImportError:
|
||||
np = None # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
import faiss # noqa: F401
|
||||
_HAS_FAISS = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
from sentence_transformers import SentenceTransformer # noqa: F401
|
||||
_HAS_TRANSFORMERS = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
|
||||
class SemanticSearch:
|
||||
"""Semantic search over stored room data.
|
||||
|
||||
When FAISS + sentence-transformers are available, uses cosine
|
||||
similarity on dense embeddings. Otherwise falls back to simple
|
||||
keyword matching.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model_name: str = "all-MiniLM-L6-v2",
|
||||
) -> None:
|
||||
self._model = None
|
||||
self._index = None # faiss.IndexFlatIP
|
||||
self._rooms: List[Dict] = []
|
||||
self._embeddings = None # np.ndarray (N x dim)
|
||||
|
||||
if _HAS_TRANSFORMERS:
|
||||
try:
|
||||
self._model = SentenceTransformer(model_name)
|
||||
logger.info("Sentence transformer loaded: %s", model_name)
|
||||
except Exception as exc:
|
||||
logger.warning("Failed to load transformer: %s", exc)
|
||||
else:
|
||||
logger.info(
|
||||
"sentence-transformers not installed — using keyword fallback"
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Index building
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_index(self, rooms: List[Dict]) -> None:
|
||||
"""Build (or rebuild) the search index from a list of rooms.
|
||||
|
||||
Each room dict should have at least ``title`` and ``description``.
|
||||
"""
|
||||
self._rooms = rooms
|
||||
texts = [self._room_to_text(r) for r in rooms]
|
||||
|
||||
if self._model is not None and _HAS_FAISS and _HAS_NUMPY:
|
||||
embeddings = self._model.encode(texts, show_progress_bar=False)
|
||||
embeddings = np.array(embeddings, dtype="float32")
|
||||
# Normalise for cosine similarity via inner product
|
||||
faiss.normalize_L2(embeddings)
|
||||
dim = embeddings.shape[1]
|
||||
self._index = faiss.IndexFlatIP(dim)
|
||||
self._index.add(embeddings)
|
||||
self._embeddings = embeddings
|
||||
logger.info(
|
||||
"FAISS index built: %d vectors, dim=%d", len(rooms), dim,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"Keyword index built: %d rooms (no FAISS)", len(rooms),
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Search
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def search(self, query: str, top_k: int = 5) -> List[Dict]:
|
||||
"""Search rooms by semantic similarity (or keyword fallback).
|
||||
|
||||
Returns up to *top_k* room dicts, each augmented with a
|
||||
``score`` key (higher is better, 0-1 range for cosine).
|
||||
"""
|
||||
if not self._rooms:
|
||||
return []
|
||||
|
||||
if self._index is not None and self._model is not None:
|
||||
return self._faiss_search(query, top_k)
|
||||
return self._keyword_search(query, top_k)
|
||||
|
||||
def _faiss_search(self, query: str, top_k: int) -> List[Dict]:
|
||||
qvec = self._model.encode([query])
|
||||
qvec = np.array(qvec, dtype="float32")
|
||||
faiss.normalize_L2(qvec)
|
||||
|
||||
k = min(top_k, len(self._rooms))
|
||||
scores, indices = self._index.search(qvec, k)
|
||||
|
||||
results = []
|
||||
for score, idx in zip(scores[0], indices[0]):
|
||||
if idx < 0:
|
||||
continue
|
||||
room = dict(self._rooms[idx])
|
||||
room["score"] = float(score)
|
||||
results.append(room)
|
||||
return results
|
||||
|
||||
def _keyword_search(self, query: str, top_k: int) -> List[Dict]:
|
||||
"""Simple keyword overlap scoring."""
|
||||
query_tokens = set(re.findall(r"\w+", query.lower()))
|
||||
scored = []
|
||||
for room in self._rooms:
|
||||
text = self._room_to_text(room).lower()
|
||||
room_tokens = set(re.findall(r"\w+", text))
|
||||
overlap = len(query_tokens & room_tokens)
|
||||
if overlap > 0:
|
||||
score = overlap / max(len(query_tokens), 1)
|
||||
r = dict(room)
|
||||
r["score"] = score
|
||||
scored.append(r)
|
||||
scored.sort(key=lambda r: r["score"], reverse=True)
|
||||
return scored[:top_k]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _room_to_text(room: Dict) -> str:
|
||||
"""Flatten a room dict into a searchable text string."""
|
||||
parts = [
|
||||
room.get("title", ""),
|
||||
room.get("description", ""),
|
||||
room.get("zone", ""),
|
||||
]
|
||||
npcs = room.get("npcs", [])
|
||||
if isinstance(npcs, list):
|
||||
parts.extend(npcs)
|
||||
items = room.get("items", [])
|
||||
if isinstance(items, list):
|
||||
parts.extend(items)
|
||||
return " ".join(p for p in parts if p)
|
||||
|
||||
@property
|
||||
def index_size(self) -> int:
|
||||
return len(self._rooms)
|
||||
@@ -0,0 +1,123 @@
|
||||
"""SyncManager — orchestrates SQLite -> Memvid periodic snapshots.
|
||||
|
||||
Triggers a snapshot when any of these conditions are met:
|
||||
* A threshold number of *new* rooms have been discovered.
|
||||
* A session is ending (explicit call).
|
||||
* A time interval has elapsed since the last snapshot.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
from .active_storage import ActiveStorage
|
||||
from .historical_storage import HistoricalStorage
|
||||
from .semantic_search import SemanticSearch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SyncManager:
|
||||
"""Manages periodic synchronisation between active and historical storage."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
active: ActiveStorage,
|
||||
historical: HistoricalStorage,
|
||||
search: Optional[SemanticSearch] = None,
|
||||
room_count_trigger: int = 500,
|
||||
time_interval_hours: float = 24.0,
|
||||
) -> None:
|
||||
self.active = active
|
||||
self.historical = historical
|
||||
self.search = search
|
||||
|
||||
self.room_count_trigger = room_count_trigger
|
||||
self.time_interval_seconds = time_interval_hours * 3600
|
||||
|
||||
self._snapshot_version: int = 0
|
||||
self._rooms_at_last_sync: int = 0
|
||||
self._last_sync_time: float = time.time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Trigger checks
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def should_sync(self) -> bool:
|
||||
"""Return ``True`` if any sync trigger has fired."""
|
||||
# Room count trigger
|
||||
new_rooms = self.active.room_count() - self._rooms_at_last_sync
|
||||
if new_rooms >= self.room_count_trigger:
|
||||
logger.info(
|
||||
"Sync trigger: %d new rooms (threshold=%d)",
|
||||
new_rooms, self.room_count_trigger,
|
||||
)
|
||||
return True
|
||||
|
||||
# Time interval trigger
|
||||
elapsed = time.time() - self._last_sync_time
|
||||
if elapsed >= self.time_interval_seconds:
|
||||
logger.info(
|
||||
"Sync trigger: %.1f hours elapsed", elapsed / 3600,
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Sync
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def perform_sync(self) -> Optional[str]:
|
||||
"""Execute a full sync: snapshot active storage into historical.
|
||||
|
||||
Returns the path to the created snapshot, or ``None`` on failure.
|
||||
"""
|
||||
rooms = self.active.get_all_rooms()
|
||||
if not rooms:
|
||||
logger.info("Sync skipped — no rooms to archive")
|
||||
return None
|
||||
|
||||
self._snapshot_version += 1
|
||||
path = self.historical.create_snapshot(rooms, self._snapshot_version)
|
||||
|
||||
# Rebuild semantic search index
|
||||
if self.search is not None:
|
||||
self.search.build_index(rooms)
|
||||
logger.info(
|
||||
"Semantic index rebuilt: %d rooms", self.search.index_size,
|
||||
)
|
||||
|
||||
self._rooms_at_last_sync = self.active.room_count()
|
||||
self._last_sync_time = time.time()
|
||||
|
||||
logger.info(
|
||||
"Sync complete: v%d, %d rooms archived",
|
||||
self._snapshot_version, len(rooms),
|
||||
)
|
||||
return path
|
||||
|
||||
def sync_on_session_end(self) -> Optional[str]:
|
||||
"""Force a sync when the session is ending."""
|
||||
logger.info("Session end — forcing sync")
|
||||
return self.perform_sync()
|
||||
|
||||
def check_and_sync(self) -> Optional[str]:
|
||||
"""Convenience: check triggers and sync if needed."""
|
||||
if self.should_sync():
|
||||
return self.perform_sync()
|
||||
return None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Info
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def snapshot_version(self) -> int:
|
||||
return self._snapshot_version
|
||||
|
||||
@property
|
||||
def rooms_since_last_sync(self) -> int:
|
||||
return self.active.room_count() - self._rooms_at_last_sync
|
||||
@@ -0,0 +1,218 @@
|
||||
"""WorldMapper — orchestrator for the hybrid automapping system.
|
||||
|
||||
Connects the ``ActiveStorage`` (SQLite + NetworkX) with the game's
|
||||
perception layer (``GameState``, parsed rooms) and the exploration
|
||||
logic in the decision engine.
|
||||
|
||||
Responsibilities:
|
||||
* Generate room fingerprints on arrival.
|
||||
* Register new rooms and exits in ``ActiveStorage``.
|
||||
* Maintain the exploration queue for unexplored exits.
|
||||
* Provide pathfinding between known rooms.
|
||||
* Trigger Memvid snapshots via ``SyncManager`` when thresholds are met.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Dict, List, Optional, TYPE_CHECKING
|
||||
|
||||
from .active_storage import ActiveStorage, generate_fingerprint
|
||||
from .pathfinder import Pathfinder
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState, Room
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class WorldMapper:
|
||||
"""High-level automapper that ties storage, graph and exploration together."""
|
||||
|
||||
def __init__(self, db_path: str = "data/maps/active/world.db") -> None:
|
||||
self.storage = ActiveStorage(db_path=db_path)
|
||||
self.pathfinder = Pathfinder(self.storage.graph)
|
||||
|
||||
# Track the current room's DB id so we can link exits
|
||||
self._current_room_id: Optional[int] = None
|
||||
self._previous_room_id: Optional[int] = None
|
||||
self._last_direction: Optional[str] = None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core mapping cycle (called each time we enter a room)
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def on_room_enter(
|
||||
self,
|
||||
title: str,
|
||||
description: str,
|
||||
exits: List[str],
|
||||
npcs: Optional[List[str]] = None,
|
||||
items: Optional[List[str]] = None,
|
||||
zone: Optional[str] = None,
|
||||
) -> int:
|
||||
"""Process arrival in a room.
|
||||
|
||||
1. Generate a fingerprint.
|
||||
2. Insert or update the room in storage.
|
||||
3. Link the exit from the previous room (if any).
|
||||
4. Queue any unexplored exits.
|
||||
|
||||
Returns the room's database ID.
|
||||
"""
|
||||
fingerprint = generate_fingerprint(title, description, exits)
|
||||
|
||||
room_data: Dict = {
|
||||
"fingerprint": fingerprint,
|
||||
"title": title,
|
||||
"description": description,
|
||||
"short_desc": title,
|
||||
"exits": {d: None for d in exits},
|
||||
"npcs": npcs or [],
|
||||
"items": items or [],
|
||||
"zone": zone,
|
||||
}
|
||||
|
||||
room_id = self.storage.add_room(room_data)
|
||||
|
||||
# Link exit from the room we just left
|
||||
from_room = self._current_room_id # room we were in before moving
|
||||
if (
|
||||
from_room is not None
|
||||
and self._last_direction is not None
|
||||
and from_room != room_id
|
||||
):
|
||||
self.storage.add_exit(
|
||||
from_room, room_id, self._last_direction,
|
||||
)
|
||||
# Add reverse exit heuristic (most MUD exits are bidirectional)
|
||||
reverse = _reverse_direction(self._last_direction)
|
||||
if reverse:
|
||||
self.storage.add_exit(room_id, from_room, reverse)
|
||||
|
||||
# Queue unexplored exits
|
||||
for direction in exits:
|
||||
# Check if we already know where this exit leads
|
||||
existing = self.storage.graph.edges(room_id, data=True)
|
||||
known_dirs = {d["direction"] for _, _, d in existing}
|
||||
if direction not in known_dirs:
|
||||
self.storage.enqueue_unexplored(room_id, direction)
|
||||
|
||||
self._previous_room_id = self._current_room_id
|
||||
self._current_room_id = room_id
|
||||
self._last_direction = None
|
||||
|
||||
logger.info(
|
||||
"Mapped room %d: '%s' [%s] (total=%d)",
|
||||
room_id, title, ", ".join(exits), self.storage.room_count(),
|
||||
)
|
||||
return room_id
|
||||
|
||||
def on_move(self, direction: str) -> None:
|
||||
"""Record that we are about to move in *direction*.
|
||||
|
||||
Called just before sending the movement command so that on the
|
||||
next ``on_room_enter`` we can link the exit properly.
|
||||
"""
|
||||
self._last_direction = direction
|
||||
|
||||
def on_move_failed(self, direction: str) -> None:
|
||||
"""Record a blocked exit (``You can't go that way``)."""
|
||||
if self._current_room_id is not None:
|
||||
self.storage.add_exit(
|
||||
self._current_room_id, None, direction, blocked=True,
|
||||
)
|
||||
logger.debug(
|
||||
"Blocked exit: room %d -> %s", self._current_room_id, direction,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Pathfinding
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def find_path(self, to_room_id: int) -> Optional[List[str]]:
|
||||
"""Find directions from the current room to *to_room_id*.
|
||||
|
||||
Returns a list of direction strings, or ``None`` if unreachable.
|
||||
"""
|
||||
if self._current_room_id is None:
|
||||
return None
|
||||
path = self.pathfinder.find_path(self._current_room_id, to_room_id)
|
||||
if path is None:
|
||||
return None
|
||||
return self.pathfinder.get_directions(path)
|
||||
|
||||
def find_path_between(
|
||||
self, from_id: int, to_id: int,
|
||||
) -> Optional[List[str]]:
|
||||
path = self.pathfinder.find_path(from_id, to_id)
|
||||
if path is None:
|
||||
return None
|
||||
return self.pathfinder.get_directions(path)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Exploration helpers
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_next_exploration_target(self) -> Optional[Dict]:
|
||||
"""Pop the best unexplored exit from the queue.
|
||||
|
||||
Returns ``{"room_id": int, "direction": str}`` or ``None``.
|
||||
"""
|
||||
result = self.storage.dequeue_next_exploration()
|
||||
if result is None:
|
||||
return None
|
||||
room_id, direction = result
|
||||
return {"room_id": room_id, "direction": direction}
|
||||
|
||||
def get_unexplored_exits(self, room_id: Optional[int] = None) -> List[str]:
|
||||
"""Return directions from *room_id* that lead to unknown rooms."""
|
||||
rid = room_id or self._current_room_id
|
||||
if rid is None:
|
||||
return []
|
||||
edges = self.storage.graph.edges(rid, data=True)
|
||||
known_dirs = {d["direction"] for _, _, d in edges}
|
||||
room = self.storage.get_room(rid)
|
||||
if not room:
|
||||
return []
|
||||
all_exits = list(room.get("exits", {}).keys())
|
||||
return [d for d in all_exits if d not in known_dirs]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Convenience
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@property
|
||||
def current_room_id(self) -> Optional[int]:
|
||||
return self._current_room_id
|
||||
|
||||
@property
|
||||
def total_rooms(self) -> int:
|
||||
return self.storage.room_count()
|
||||
|
||||
@property
|
||||
def total_exits(self) -> int:
|
||||
return self.storage.graph.number_of_edges()
|
||||
|
||||
def close(self) -> None:
|
||||
self.storage.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_REVERSE_MAP = {
|
||||
"north": "south", "south": "north",
|
||||
"east": "west", "west": "east",
|
||||
"northeast": "southwest", "southwest": "northeast",
|
||||
"northwest": "southeast", "southeast": "northwest",
|
||||
"up": "down", "down": "up",
|
||||
"n": "s", "s": "n", "e": "w", "w": "e",
|
||||
"ne": "sw", "sw": "ne", "nw": "se", "se": "nw",
|
||||
"u": "d", "d": "u",
|
||||
}
|
||||
|
||||
|
||||
def _reverse_direction(direction: str) -> Optional[str]:
|
||||
return _REVERSE_MAP.get(direction.lower())
|
||||
@@ -0,0 +1,15 @@
|
||||
"""Progression optimizer module."""
|
||||
|
||||
from .optimizer import (
|
||||
ProgressionOptimizer,
|
||||
GrindingSpot,
|
||||
EquipmentPiece,
|
||||
SkillInfo,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ProgressionOptimizer",
|
||||
"GrindingSpot",
|
||||
"EquipmentPiece",
|
||||
"SkillInfo",
|
||||
]
|
||||
@@ -0,0 +1,228 @@
|
||||
"""Progression optimizer — the min-maxer.
|
||||
|
||||
Analyses combat data and map information to:
|
||||
* Identify optimal grinding spots (EXP/hour).
|
||||
* Compare equipment by weighted stat bonus.
|
||||
* Recommend skill rotations (mana cost / damage / cooldown).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Optional, Tuple, TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ...core.game_state import GameState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Grinding spot tracker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class GrindingSpot:
|
||||
"""Metrics for a single grinding area."""
|
||||
|
||||
room_id: int
|
||||
room_title: str
|
||||
zone: Optional[str] = None
|
||||
kills: int = 0
|
||||
total_exp: int = 0
|
||||
total_time_seconds: float = 0.0
|
||||
deaths: int = 0
|
||||
first_visit: float = field(default_factory=time.time)
|
||||
|
||||
@property
|
||||
def exp_per_hour(self) -> float:
|
||||
if self.total_time_seconds <= 0:
|
||||
return 0.0
|
||||
return (self.total_exp / self.total_time_seconds) * 3600
|
||||
|
||||
@property
|
||||
def kills_per_hour(self) -> float:
|
||||
if self.total_time_seconds <= 0:
|
||||
return 0.0
|
||||
return (self.kills / self.total_time_seconds) * 3600
|
||||
|
||||
@property
|
||||
def survival_rate(self) -> float:
|
||||
total = self.kills + self.deaths
|
||||
if total <= 0:
|
||||
return 1.0
|
||||
return self.kills / total
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Equipment evaluator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class EquipmentPiece:
|
||||
"""Represents a piece of equipment with stat bonuses."""
|
||||
|
||||
name: str
|
||||
slot: str # weapon, armor, helmet, etc.
|
||||
stat_bonuses: Dict[str, int] = field(default_factory=dict)
|
||||
level_requirement: int = 0
|
||||
value: int = 0
|
||||
|
||||
def weighted_score(self, weights: Optional[Dict[str, float]] = None) -> float:
|
||||
"""Compute a single score from stat bonuses.
|
||||
|
||||
Default weights: equal weight (1.0) per stat.
|
||||
"""
|
||||
w = weights or {}
|
||||
return sum(
|
||||
v * w.get(k, 1.0) for k, v in self.stat_bonuses.items()
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Skill rotation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class SkillInfo:
|
||||
"""Metadata about a combat skill."""
|
||||
|
||||
name: str
|
||||
command: str
|
||||
damage: float = 0.0
|
||||
mana_cost: int = 0
|
||||
cooldown_seconds: float = 0.0
|
||||
|
||||
@property
|
||||
def damage_per_mana(self) -> float:
|
||||
if self.mana_cost <= 0:
|
||||
return float("inf") if self.damage > 0 else 0.0
|
||||
return self.damage / self.mana_cost
|
||||
|
||||
@property
|
||||
def dps(self) -> float:
|
||||
"""Damage per second (accounting for cooldown)."""
|
||||
cd = max(self.cooldown_seconds, 1.0)
|
||||
return self.damage / cd
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ProgressionOptimizer
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class ProgressionOptimizer:
|
||||
"""Analyses gameplay data to optimise character progression."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._spots: Dict[int, GrindingSpot] = {} # room_id -> spot
|
||||
self._equipment: List[EquipmentPiece] = []
|
||||
self._skills: List[SkillInfo] = []
|
||||
self._session_start: float = time.time()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Grinding spots
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def record_kill(
|
||||
self, room_id: int, room_title: str,
|
||||
exp_gained: int, zone: Optional[str] = None,
|
||||
) -> None:
|
||||
"""Record a mob kill at a location."""
|
||||
if room_id not in self._spots:
|
||||
self._spots[room_id] = GrindingSpot(
|
||||
room_id=room_id, room_title=room_title, zone=zone,
|
||||
)
|
||||
spot = self._spots[room_id]
|
||||
spot.kills += 1
|
||||
spot.total_exp += exp_gained
|
||||
spot.total_time_seconds = time.time() - spot.first_visit
|
||||
|
||||
def record_death(self, room_id: int, room_title: str) -> None:
|
||||
"""Record a player death."""
|
||||
if room_id not in self._spots:
|
||||
self._spots[room_id] = GrindingSpot(
|
||||
room_id=room_id, room_title=room_title,
|
||||
)
|
||||
self._spots[room_id].deaths += 1
|
||||
|
||||
def get_best_grinding_spots(self, top_k: int = 5) -> List[GrindingSpot]:
|
||||
"""Return top grinding spots ranked by EXP/hour."""
|
||||
spots = list(self._spots.values())
|
||||
# Filter out spots with too few kills for statistical relevance
|
||||
spots = [s for s in spots if s.kills >= 3]
|
||||
spots.sort(key=lambda s: s.exp_per_hour, reverse=True)
|
||||
return spots[:top_k]
|
||||
|
||||
def get_recommended_spot(self, game_state: "GameState") -> Optional[GrindingSpot]:
|
||||
"""Recommend the best grinding spot considering survival."""
|
||||
spots = self.get_best_grinding_spots(top_k=10)
|
||||
# Prefer spots with high EXP/hour AND high survival rate
|
||||
for spot in spots:
|
||||
if spot.survival_rate >= 0.8:
|
||||
return spot
|
||||
return spots[0] if spots else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Equipment
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_equipment(self, piece: EquipmentPiece) -> None:
|
||||
self._equipment.append(piece)
|
||||
|
||||
def compare_equipment(
|
||||
self, slot: str, weights: Optional[Dict[str, float]] = None,
|
||||
) -> List[Tuple[EquipmentPiece, float]]:
|
||||
"""Return equipment for *slot* ranked by weighted score."""
|
||||
candidates = [e for e in self._equipment if e.slot == slot]
|
||||
scored = [(e, e.weighted_score(weights)) for e in candidates]
|
||||
scored.sort(key=lambda t: t[1], reverse=True)
|
||||
return scored
|
||||
|
||||
def get_best_equipment(
|
||||
self, slot: str, weights: Optional[Dict[str, float]] = None,
|
||||
) -> Optional[EquipmentPiece]:
|
||||
ranked = self.compare_equipment(slot, weights)
|
||||
return ranked[0][0] if ranked else None
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Skill rotation
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def add_skill(self, skill: SkillInfo) -> None:
|
||||
self._skills.append(skill)
|
||||
|
||||
def get_optimal_rotation(
|
||||
self, available_mana: int,
|
||||
) -> List[SkillInfo]:
|
||||
"""Return skills ordered by DPS that fit within *available_mana*."""
|
||||
usable = [s for s in self._skills if s.mana_cost <= available_mana]
|
||||
usable.sort(key=lambda s: s.dps, reverse=True)
|
||||
return usable
|
||||
|
||||
def get_efficient_rotation(self) -> List[SkillInfo]:
|
||||
"""Return skills ordered by damage-per-mana efficiency."""
|
||||
skills = list(self._skills)
|
||||
skills.sort(key=lambda s: s.damage_per_mana, reverse=True)
|
||||
return skills
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Summary
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def session_summary(self) -> Dict:
|
||||
elapsed = time.time() - self._session_start
|
||||
total_kills = sum(s.kills for s in self._spots.values())
|
||||
total_exp = sum(s.total_exp for s in self._spots.values())
|
||||
return {
|
||||
"session_duration_minutes": elapsed / 60,
|
||||
"total_kills": total_kills,
|
||||
"total_exp": total_exp,
|
||||
"exp_per_hour": (total_exp / elapsed * 3600) if elapsed > 0 else 0,
|
||||
"spots_visited": len(self._spots),
|
||||
"best_spot": (
|
||||
self.get_best_grinding_spots(1)[0].room_title
|
||||
if self.get_best_grinding_spots(1) else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Quest system module."""
|
||||
|
||||
from .quest_manager import (
|
||||
QuestManager,
|
||||
Quest,
|
||||
QuestObjective,
|
||||
QuestStatus,
|
||||
ObjectiveType,
|
||||
parse_quest_text,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"QuestManager",
|
||||
"Quest",
|
||||
"QuestObjective",
|
||||
"QuestStatus",
|
||||
"ObjectiveType",
|
||||
"parse_quest_text",
|
||||
]
|
||||
@@ -0,0 +1,298 @@
|
||||
"""Quest management system.
|
||||
|
||||
Tracks active quests, decomposes objectives into atomic tasks,
|
||||
and prioritises quests by EXP/time ratio.
|
||||
|
||||
Quest lifecycle::
|
||||
|
||||
DISCOVERED -> ACCEPTED -> IN_PROGRESS -> COMPLETED
|
||||
|
|
||||
+-> FAILED
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum, auto
|
||||
from typing import Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data models
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class QuestStatus(Enum):
|
||||
DISCOVERED = auto()
|
||||
ACCEPTED = auto()
|
||||
IN_PROGRESS = auto()
|
||||
COMPLETED = auto()
|
||||
FAILED = auto()
|
||||
|
||||
|
||||
class ObjectiveType(Enum):
|
||||
KILL = "kill" # "kill 5 goblins"
|
||||
FETCH = "fetch" # "find the ring in the crypt"
|
||||
DELIVER = "deliver" # "bring the potion to the healer"
|
||||
EXPLORE = "explore" # "find the hidden cave"
|
||||
TALK = "talk" # "talk to the guard captain"
|
||||
|
||||
|
||||
@dataclass
|
||||
class QuestObjective:
|
||||
"""A single atomic task within a quest."""
|
||||
|
||||
description: str
|
||||
obj_type: ObjectiveType = ObjectiveType.KILL
|
||||
target: str = ""
|
||||
target_count: int = 1
|
||||
current_count: int = 0
|
||||
location: Optional[str] = None
|
||||
completed: bool = False
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
if self.target_count <= 0:
|
||||
return 1.0 if self.completed else 0.0
|
||||
return min(self.current_count / self.target_count, 1.0)
|
||||
|
||||
@property
|
||||
def is_done(self) -> bool:
|
||||
return self.completed or self.current_count >= self.target_count
|
||||
|
||||
|
||||
@dataclass
|
||||
class Quest:
|
||||
"""A tracked quest with one or more objectives."""
|
||||
|
||||
name: str
|
||||
description: str = ""
|
||||
giver: Optional[str] = None
|
||||
giver_location: Optional[str] = None
|
||||
status: QuestStatus = QuestStatus.DISCOVERED
|
||||
objectives: List[QuestObjective] = field(default_factory=list)
|
||||
reward_exp: int = 0
|
||||
reward_gold: int = 0
|
||||
reward_items: List[str] = field(default_factory=list)
|
||||
discovered_at: float = field(default_factory=time.time)
|
||||
accepted_at: Optional[float] = None
|
||||
completed_at: Optional[float] = None
|
||||
estimated_time_minutes: float = 0.0
|
||||
|
||||
@property
|
||||
def progress(self) -> float:
|
||||
if not self.objectives:
|
||||
return 0.0
|
||||
return sum(o.progress for o in self.objectives) / len(self.objectives)
|
||||
|
||||
@property
|
||||
def is_complete(self) -> bool:
|
||||
return all(o.is_done for o in self.objectives)
|
||||
|
||||
@property
|
||||
def exp_per_minute(self) -> float:
|
||||
"""EXP/time ratio for prioritisation."""
|
||||
if self.estimated_time_minutes <= 0:
|
||||
return float(self.reward_exp)
|
||||
return self.reward_exp / self.estimated_time_minutes
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Quest text parser
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Common MUD quest text patterns
|
||||
_KILL_RE = re.compile(
|
||||
r"(?:kill|slay|defeat|destroy)\s+(\d+)?\s*([\w\s]+?)(?:\.|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_FETCH_RE = re.compile(
|
||||
r"(?:find|bring|get|retrieve|collect)\s+(?:the\s+)?([\w\s]+?)(?:\s+(?:in|from|at)\s+([\w\s]+?))?(?:\.|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TALK_RE = re.compile(
|
||||
r"(?:talk|speak|report)\s+(?:to\s+)?([\w\s]+?)(?:\.|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_EXPLORE_RE = re.compile(
|
||||
r"(?:explore|find|discover|go to)\s+(?:the\s+)?([\w\s]+?)(?:\.|$)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def parse_quest_text(text: str) -> List[QuestObjective]:
|
||||
"""Extract objectives from a quest description string."""
|
||||
objectives: List[QuestObjective] = []
|
||||
|
||||
for m in _KILL_RE.finditer(text):
|
||||
count = int(m.group(1)) if m.group(1) else 1
|
||||
target = m.group(2).strip()
|
||||
objectives.append(QuestObjective(
|
||||
description=m.group(0).strip(),
|
||||
obj_type=ObjectiveType.KILL,
|
||||
target=target,
|
||||
target_count=count,
|
||||
))
|
||||
|
||||
for m in _FETCH_RE.finditer(text):
|
||||
item = m.group(1).strip()
|
||||
location = m.group(2).strip() if m.group(2) else None
|
||||
objectives.append(QuestObjective(
|
||||
description=m.group(0).strip(),
|
||||
obj_type=ObjectiveType.FETCH,
|
||||
target=item,
|
||||
target_count=1,
|
||||
location=location,
|
||||
))
|
||||
|
||||
for m in _TALK_RE.finditer(text):
|
||||
npc = m.group(1).strip()
|
||||
objectives.append(QuestObjective(
|
||||
description=m.group(0).strip(),
|
||||
obj_type=ObjectiveType.TALK,
|
||||
target=npc,
|
||||
target_count=1,
|
||||
))
|
||||
|
||||
if not objectives:
|
||||
for m in _EXPLORE_RE.finditer(text):
|
||||
place = m.group(1).strip()
|
||||
objectives.append(QuestObjective(
|
||||
description=m.group(0).strip(),
|
||||
obj_type=ObjectiveType.EXPLORE,
|
||||
target=place,
|
||||
target_count=1,
|
||||
))
|
||||
|
||||
return objectives
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# QuestManager
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class QuestManager:
|
||||
"""Tracks quests and provides the decision engine with quest-related info."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._quests: Dict[str, Quest] = {} # keyed by name
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Quest lifecycle
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def discover_quest(
|
||||
self, name: str, description: str,
|
||||
giver: Optional[str] = None,
|
||||
giver_location: Optional[str] = None,
|
||||
reward_exp: int = 0,
|
||||
reward_gold: int = 0,
|
||||
) -> Quest:
|
||||
"""Register a newly discovered quest."""
|
||||
if name in self._quests:
|
||||
return self._quests[name]
|
||||
|
||||
objectives = parse_quest_text(description)
|
||||
quest = Quest(
|
||||
name=name,
|
||||
description=description,
|
||||
giver=giver,
|
||||
giver_location=giver_location,
|
||||
objectives=objectives,
|
||||
reward_exp=reward_exp,
|
||||
reward_gold=reward_gold,
|
||||
)
|
||||
self._quests[name] = quest
|
||||
logger.info("Quest discovered: '%s' (%d objectives)", name, len(objectives))
|
||||
return quest
|
||||
|
||||
def accept_quest(self, name: str) -> bool:
|
||||
"""Mark a quest as accepted."""
|
||||
q = self._quests.get(name)
|
||||
if q and q.status == QuestStatus.DISCOVERED:
|
||||
q.status = QuestStatus.ACCEPTED
|
||||
q.accepted_at = time.time()
|
||||
logger.info("Quest accepted: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def update_objective(
|
||||
self, quest_name: str, target: str, increment: int = 1,
|
||||
) -> bool:
|
||||
"""Increment progress on a kill/fetch objective matching *target*."""
|
||||
q = self._quests.get(quest_name)
|
||||
if not q:
|
||||
return False
|
||||
for obj in q.objectives:
|
||||
if not obj.is_done and target.lower() in obj.target.lower():
|
||||
obj.current_count = min(
|
||||
obj.current_count + increment, obj.target_count,
|
||||
)
|
||||
if obj.is_done:
|
||||
obj.completed = True
|
||||
logger.info(
|
||||
"Objective complete: '%s' in quest '%s'",
|
||||
obj.description, quest_name,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
def complete_quest(self, name: str) -> bool:
|
||||
"""Mark a quest as completed."""
|
||||
q = self._quests.get(name)
|
||||
if q and q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS):
|
||||
q.status = QuestStatus.COMPLETED
|
||||
q.completed_at = time.time()
|
||||
logger.info("Quest completed: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
def fail_quest(self, name: str) -> bool:
|
||||
q = self._quests.get(name)
|
||||
if q and q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS):
|
||||
q.status = QuestStatus.FAILED
|
||||
logger.info("Quest failed: '%s'", name)
|
||||
return True
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Query
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def get_active_quests(self) -> List[Quest]:
|
||||
"""Return all accepted/in-progress quests sorted by EXP/time."""
|
||||
active = [
|
||||
q for q in self._quests.values()
|
||||
if q.status in (QuestStatus.ACCEPTED, QuestStatus.IN_PROGRESS)
|
||||
]
|
||||
active.sort(key=lambda q: q.exp_per_minute, reverse=True)
|
||||
return active
|
||||
|
||||
def get_best_quest(self) -> Optional[Quest]:
|
||||
"""Return the highest-priority active quest."""
|
||||
active = self.get_active_quests()
|
||||
return active[0] if active else None
|
||||
|
||||
def get_quest(self, name: str) -> Optional[Quest]:
|
||||
return self._quests.get(name)
|
||||
|
||||
@property
|
||||
def quest_count(self) -> int:
|
||||
return len(self._quests)
|
||||
|
||||
@property
|
||||
def active_count(self) -> int:
|
||||
return len(self.get_active_quests())
|
||||
|
||||
def get_pending_kill_targets(self) -> List[str]:
|
||||
"""Return mob names we still need to kill for active quests."""
|
||||
targets = []
|
||||
for q in self.get_active_quests():
|
||||
for obj in q.objectives:
|
||||
if obj.obj_type == ObjectiveType.KILL and not obj.is_done:
|
||||
targets.append(obj.target)
|
||||
return targets
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Utilities package"""
|
||||
|
||||
__all__ = ["setup_logger", "HumanBehavior"]
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Human behavior simulation"""
|
||||
|
||||
import random
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class HumanBehavior:
|
||||
"""Simulate human-like behavior to avoid bot detection"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
reaction_time_min_ms: int = 200,
|
||||
reaction_time_max_ms: int = 2000,
|
||||
pause_probability: float = 0.05
|
||||
):
|
||||
self.reaction_time_min = reaction_time_min_ms / 1000.0
|
||||
self.reaction_time_max = reaction_time_max_ms / 1000.0
|
||||
self.pause_probability = pause_probability
|
||||
|
||||
async def delay(self):
|
||||
"""Add random human-like delay"""
|
||||
delay_time = random.uniform(self.reaction_time_min, self.reaction_time_max)
|
||||
await asyncio.sleep(delay_time)
|
||||
|
||||
async def maybe_pause(self):
|
||||
"""Randomly pause to simulate human behavior"""
|
||||
if random.random() < self.pause_probability:
|
||||
pause_time = random.uniform(1.0, 5.0)
|
||||
logger.debug(f"Taking a {pause_time:.1f}s pause")
|
||||
await asyncio.sleep(pause_time)
|
||||
@@ -0,0 +1,56 @@
|
||||
"""Logging configuration"""
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from rich.logging import RichHandler
|
||||
import os
|
||||
|
||||
|
||||
def setup_logger(
|
||||
name: str = None,
|
||||
level: str = "INFO",
|
||||
log_file: str = "data/logs/game.log",
|
||||
console: bool = True,
|
||||
max_file_size_mb: int = 10,
|
||||
backup_count: int = 5
|
||||
) -> logging.Logger:
|
||||
"""
|
||||
Setup logger with file and console handlers
|
||||
|
||||
Args:
|
||||
name: Logger name
|
||||
level: Logging level (DEBUG, INFO, WARNING, ERROR)
|
||||
log_file: Path to log file
|
||||
console: Enable console logging
|
||||
max_file_size_mb: Max log file size in MB
|
||||
backup_count: Number of backup files to keep
|
||||
|
||||
Returns:
|
||||
Configured logger
|
||||
"""
|
||||
logger = logging.getLogger(name)
|
||||
logger.setLevel(getattr(logging, level.upper()))
|
||||
|
||||
# Remove existing handlers
|
||||
logger.handlers.clear()
|
||||
|
||||
# File handler with rotation
|
||||
os.makedirs(os.path.dirname(log_file), exist_ok=True)
|
||||
file_handler = RotatingFileHandler(
|
||||
log_file,
|
||||
maxBytes=max_file_size_mb * 1024 * 1024,
|
||||
backupCount=backup_count
|
||||
)
|
||||
file_formatter = logging.Formatter(
|
||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
file_handler.setFormatter(file_formatter)
|
||||
logger.addHandler(file_handler)
|
||||
|
||||
# Console handler with rich formatting
|
||||
if console:
|
||||
console_handler = RichHandler(rich_tracebacks=True)
|
||||
console_handler.setFormatter(logging.Formatter('%(message)s'))
|
||||
logger.addHandler(console_handler)
|
||||
|
||||
return logger
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Test configuration"""
|
||||
|
||||
import pytest
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add src to path for tests
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Tests for LLMMemory system"""
|
||||
|
||||
import pytest
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
from src.modules.llm.memory import LLMMemory, MemoryEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def memory(tmp_path):
|
||||
"""Create a temporary memory instance for testing."""
|
||||
db_path = tmp_path / "test_memory.db"
|
||||
mem = LLMMemory(str(db_path))
|
||||
await mem.initialize()
|
||||
yield mem
|
||||
await mem.close()
|
||||
|
||||
|
||||
class TestLLMMemory:
|
||||
"""Test suite for LLMMemory."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialization(self, tmp_path):
|
||||
"""Test that memory initializes correctly."""
|
||||
db_path = tmp_path / "test_init.db"
|
||||
mem = LLMMemory(str(db_path))
|
||||
await mem.initialize()
|
||||
assert db_path.exists()
|
||||
await mem.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_conversation(self, memory):
|
||||
"""Test adding conversations."""
|
||||
await memory.add_conversation(
|
||||
"Alice", "Hey there!", "Hello! How are you?",
|
||||
importance=0.7
|
||||
)
|
||||
|
||||
context = await memory.get_conversation_context("Alice", limit=5)
|
||||
assert len(context) == 1
|
||||
assert context[0]["message"] == "Hey there!"
|
||||
assert context[0]["reply"] == "Hello! How are you?"
|
||||
assert context[0]["importance"] == 0.7
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_multiple_conversations(self, memory):
|
||||
"""Test multiple conversations with same player."""
|
||||
await memory.add_conversation("Bob", "Msg 1", "Reply 1")
|
||||
await memory.add_conversation("Bob", "Msg 2", "Reply 2")
|
||||
await memory.add_conversation("Bob", "Msg 3", "Reply 3")
|
||||
|
||||
context = await memory.get_conversation_context("Bob", limit=2)
|
||||
assert len(context) == 2
|
||||
# Should be in chronological order
|
||||
assert context[0]["message"] == "Msg 2"
|
||||
assert context[1]["message"] == "Msg 3"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_npc_interaction(self, memory):
|
||||
"""Test recording NPC interactions."""
|
||||
await memory.add_npc_interaction(
|
||||
"Guard", "talk", {"topic": "directions"}, reputation_delta=0.2
|
||||
)
|
||||
|
||||
rep = await memory.get_npc_reputation("Guard")
|
||||
assert rep is not None
|
||||
assert rep["reputation_score"] == 0.2
|
||||
assert rep["interactions_count"] == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_npc_reputation_accumulation(self, memory):
|
||||
"""Test that NPC reputation accumulates."""
|
||||
await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.5)
|
||||
await memory.add_npc_interaction("Dragon", "combat", {}, reputation_delta=-0.3)
|
||||
|
||||
rep = await memory.get_npc_reputation("Dragon")
|
||||
assert rep["reputation_score"] == -0.8
|
||||
assert rep["interactions_count"] == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_event(self, memory):
|
||||
"""Test recording important events."""
|
||||
await memory.add_event(
|
||||
"level_up", {"level": 10, "class": "warrior"}, importance=0.9
|
||||
)
|
||||
|
||||
memories = await memory.get_important_memories(min_importance=0.8)
|
||||
assert len(memories) == 1
|
||||
assert memories[0].key == "level_up"
|
||||
assert memories[0].data["details"]["level"] == 10
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_memories(self, memory):
|
||||
"""Test full-text search."""
|
||||
await memory.add_conversation("Charlie", "Want to fight the dragon?", "Sure!")
|
||||
await memory.add_conversation("Dave", "Let's explore the forest", "Okay")
|
||||
await memory.add_event("found_item", {"item": "dragon scale"})
|
||||
|
||||
results = await memory.search_memories("dragon")
|
||||
assert len(results) >= 2 # Should find conversation and event
|
||||
|
||||
dragon_conversation = [r for r in results if r.category == "conversation"]
|
||||
assert len(dragon_conversation) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_with_category(self, memory):
|
||||
"""Test searching with category filter."""
|
||||
await memory.add_conversation("Eve", "Hello", "Hi")
|
||||
await memory.add_event("combat", {"enemy": "goblin"})
|
||||
|
||||
# Search only events
|
||||
results = await memory.search_memories("goblin", category="event")
|
||||
assert len(results) == 1
|
||||
assert results[0].category == "event"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_context_prompt(self, memory):
|
||||
"""Test building context prompt."""
|
||||
await memory.add_conversation("Frank", "Hello!", "Hey!", importance=0.8)
|
||||
await memory.add_event("quest_complete", {"quest": "tutorial"}, importance=0.9)
|
||||
|
||||
context = await memory.build_context_prompt("Frank", max_tokens=500)
|
||||
assert "Previous memories:" in context
|
||||
assert "Frank" in context
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_export_import_json(self, memory, tmp_path):
|
||||
"""Test exporting and importing memories."""
|
||||
# Add some data
|
||||
await memory.add_conversation("Grace", "Test", "Reply")
|
||||
await memory.add_event("test_event", {"test": True})
|
||||
|
||||
# Export
|
||||
export_path = tmp_path / "memories_export.json"
|
||||
await memory.export_to_json(str(export_path))
|
||||
assert export_path.exists()
|
||||
|
||||
# Create new memory and import
|
||||
new_mem = LLMMemory(str(tmp_path / "new_memory.db"))
|
||||
await new_mem.initialize()
|
||||
count = await new_mem.import_from_json(str(export_path))
|
||||
|
||||
assert count == 2
|
||||
|
||||
# Verify data
|
||||
context = await new_mem.get_conversation_context("Grace")
|
||||
assert len(context) == 1
|
||||
|
||||
await new_mem.close()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_player_relationship_tracking(self, memory):
|
||||
"""Test that player relationships are tracked."""
|
||||
await memory.add_conversation("Henry", "Hey!", "Hello!")
|
||||
await memory.add_conversation("Henry", "How are you?", "Good!")
|
||||
|
||||
# Check that relationship was created
|
||||
# Note: We don't have a direct getter, but we can infer from conversation count
|
||||
context = await memory.get_conversation_context("Henry")
|
||||
assert len(context) == 2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_memory_operations(self, memory):
|
||||
"""Test operations on empty memory."""
|
||||
context = await memory.get_conversation_context("Nobody", limit=5)
|
||||
assert context == []
|
||||
|
||||
rep = await memory.get_npc_reputation("UnknownNPC")
|
||||
assert rep is None
|
||||
|
||||
results = await memory.search_memories("nonexistent")
|
||||
assert results == []
|
||||
|
||||
prompt = await memory.build_context_prompt("Nobody")
|
||||
assert prompt == ""
|
||||
|
||||
|
||||
class TestMemoryEntry:
|
||||
"""Test MemoryEntry dataclass."""
|
||||
|
||||
def test_memory_entry_creation(self):
|
||||
"""Test creating a MemoryEntry."""
|
||||
entry = MemoryEntry(
|
||||
id=1,
|
||||
timestamp=datetime.now(),
|
||||
category="test",
|
||||
key="test_key",
|
||||
data={"value": 123},
|
||||
importance=0.5
|
||||
)
|
||||
|
||||
assert entry.id == 1
|
||||
assert entry.category == "test"
|
||||
assert entry.key == "test_key"
|
||||
assert entry.data["value"] == 123
|
||||
assert entry.importance == 0.5
|
||||
@@ -0,0 +1,132 @@
|
||||
"""Tests for the ActionQueue priority queue."""
|
||||
|
||||
import pytest
|
||||
from modules.decision.action_queue import Action, ActionPriority, ActionQueue
|
||||
|
||||
|
||||
class TestAction:
|
||||
"""Tests for the Action dataclass."""
|
||||
|
||||
def test_action_creation(self):
|
||||
a = Action(command="north", priority=ActionPriority.MOVE, source="explore")
|
||||
assert a.command == "north"
|
||||
assert a.priority == ActionPriority.MOVE
|
||||
assert a.source == "explore"
|
||||
|
||||
def test_action_default_priority(self):
|
||||
a = Action(command="look")
|
||||
assert a.priority == ActionPriority.IDLE
|
||||
|
||||
def test_action_repr(self):
|
||||
a = Action(command="flee", priority=ActionPriority.EMERGENCY, source="combat")
|
||||
r = repr(a)
|
||||
assert "flee" in r
|
||||
assert "EMERGENCY" in r
|
||||
assert "combat" in r
|
||||
|
||||
|
||||
class TestActionQueue:
|
||||
"""Tests for the ActionQueue."""
|
||||
|
||||
def test_push_and_pop(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
assert q.size == 1
|
||||
a = q.pop()
|
||||
assert a is not None
|
||||
assert a.command == "north"
|
||||
assert q.is_empty
|
||||
|
||||
def test_pop_empty(self):
|
||||
q = ActionQueue()
|
||||
assert q.pop() is None
|
||||
|
||||
def test_peek(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="look", priority=ActionPriority.IDLE))
|
||||
a = q.peek()
|
||||
assert a is not None
|
||||
assert a.command == "look"
|
||||
assert q.size == 1 # not removed
|
||||
|
||||
def test_peek_empty(self):
|
||||
q = ActionQueue()
|
||||
assert q.peek() is None
|
||||
|
||||
def test_priority_ordering(self):
|
||||
"""Higher-priority (lower number) actions should come out first."""
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
q.push(Action(command="heal", priority=ActionPriority.HEAL))
|
||||
q.push(Action(command="kill goblin", priority=ActionPriority.COMBAT))
|
||||
|
||||
order = []
|
||||
while not q.is_empty:
|
||||
order.append(q.pop().command)
|
||||
|
||||
assert order == ["flee", "heal", "kill goblin", "north"]
|
||||
|
||||
def test_fifo_within_same_priority(self):
|
||||
"""Actions at the same priority level should be FIFO."""
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="first", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="second", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="third", priority=ActionPriority.COMBAT))
|
||||
|
||||
assert q.pop().command == "first"
|
||||
assert q.pop().command == "second"
|
||||
assert q.pop().command == "third"
|
||||
|
||||
def test_clear(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.IDLE))
|
||||
q.push(Action(command="b", priority=ActionPriority.MOVE))
|
||||
q.clear()
|
||||
assert q.is_empty
|
||||
|
||||
def test_clear_by_source(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.MOVE, source="explore"))
|
||||
q.push(Action(command="b", priority=ActionPriority.COMBAT, source="combat"))
|
||||
q.push(Action(command="c", priority=ActionPriority.MOVE, source="explore"))
|
||||
|
||||
removed = q.clear_by_source("explore")
|
||||
assert removed == 2
|
||||
assert q.size == 1
|
||||
assert q.pop().command == "b"
|
||||
|
||||
def test_clear_by_priority(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="a", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="b", priority=ActionPriority.COMBAT))
|
||||
q.push(Action(command="c", priority=ActionPriority.MOVE))
|
||||
|
||||
removed = q.clear_by_priority(ActionPriority.MOVE)
|
||||
assert removed == 2
|
||||
assert q.size == 1
|
||||
|
||||
def test_max_size_drops_lowest_priority(self):
|
||||
q = ActionQueue(max_size=2)
|
||||
q.push(Action(command="heal", priority=ActionPriority.HEAL))
|
||||
q.push(Action(command="move", priority=ActionPriority.MOVE))
|
||||
# Queue is full — pushing a high-priority item should drop MOVE
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
|
||||
assert q.size == 2
|
||||
actions = q.as_list()
|
||||
commands = [a.command for a in actions]
|
||||
assert "flee" in commands
|
||||
assert "heal" in commands
|
||||
assert "move" not in commands
|
||||
|
||||
def test_as_list_sorted(self):
|
||||
q = ActionQueue()
|
||||
q.push(Action(command="north", priority=ActionPriority.MOVE))
|
||||
q.push(Action(command="flee", priority=ActionPriority.EMERGENCY))
|
||||
|
||||
lst = q.as_list()
|
||||
assert lst[0].command == "flee"
|
||||
assert lst[1].command == "north"
|
||||
# Original queue not modified
|
||||
assert q.size == 2
|
||||
@@ -0,0 +1,45 @@
|
||||
"""Tests for ANSI parser"""
|
||||
|
||||
import pytest
|
||||
from core.ansi_parser import ANSIParser
|
||||
|
||||
|
||||
def test_strip_ansi():
|
||||
"""Test ANSI code stripping"""
|
||||
text_with_ansi = "\x1b[31mRed text\x1b[0m normal text"
|
||||
clean_text = ANSIParser.strip_ansi(text_with_ansi)
|
||||
assert clean_text == "Red text normal text"
|
||||
|
||||
|
||||
def test_strip_ansi_no_codes():
|
||||
"""Test text without ANSI codes"""
|
||||
text = "Plain text"
|
||||
clean_text = ANSIParser.strip_ansi(text)
|
||||
assert clean_text == "Plain text"
|
||||
|
||||
|
||||
def test_strip_ansi_multiple_codes():
|
||||
"""Test multiple ANSI codes"""
|
||||
text = "\x1b[1m\x1b[32mBold Green\x1b[0m and \x1b[34mBlue\x1b[0m"
|
||||
clean_text = ANSIParser.strip_ansi(text)
|
||||
assert clean_text == "Bold Green and Blue"
|
||||
|
||||
|
||||
def test_strip_ansi_empty():
|
||||
"""Test empty string"""
|
||||
assert ANSIParser.strip_ansi("") == ""
|
||||
|
||||
|
||||
def test_strip_ansi_prompt_with_cursor_code():
|
||||
"""Test stripping cursor movement codes from Dead Souls prompt.
|
||||
|
||||
Real example from game log: ``[32;1Hhp: 350/350 mp: 440/440 sp: 431/431 >``
|
||||
Note: the ``[32;1H`` is a cursor position code, not a standard color code.
|
||||
The current ANSI_PATTERN only matches ``\\x1b[...m`` sequences, so this
|
||||
particular code would only be stripped if it starts with \\x1b.
|
||||
"""
|
||||
# Standard ANSI color-wrapped prompt
|
||||
text = "\x1b[32;1mhp: 350/350 mp: 440/440 sp: 431/431 >\x1b[0m"
|
||||
clean = ANSIParser.strip_ansi(text)
|
||||
assert "hp: 350/350" in clean
|
||||
assert "sp: 431/431" in clean
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Tests for behavior states (Idle, Explore, Combat, Loot)."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, Character, CombatState
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
from modules.decision.behavior_states import (
|
||||
IdleState,
|
||||
ExploreState,
|
||||
CombatState as CombatBehavior,
|
||||
LootState,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
room_title="Town Square", exits=None, npcs=None, items=None,
|
||||
target=None, combat=CombatState.IDLE,
|
||||
) -> GameState:
|
||||
"""Create a GameState with controlled values."""
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.character.mp = mp
|
||||
gs.character.max_mp = max_mp
|
||||
gs.current_room = Room(
|
||||
title=room_title,
|
||||
exits={"north": 0, "south": 1} if exits is None else exits,
|
||||
npcs=npcs or [],
|
||||
items=items or [],
|
||||
)
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
return gs
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IdleState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestIdleState:
|
||||
def test_heal_when_low_hp(self):
|
||||
state = IdleState(heal_threshold=0.8)
|
||||
gs = _make_game_state(hp=50, max_hp=100)
|
||||
actions = state.get_actions(gs)
|
||||
heal_actions = [a for a in actions if a.priority == ActionPriority.HEAL]
|
||||
assert len(heal_actions) >= 1
|
||||
assert heal_actions[0].command == "heal"
|
||||
|
||||
def test_rest_when_low_mp(self):
|
||||
state = IdleState(mp_threshold=0.5)
|
||||
gs = _make_game_state(mp=10, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
rest_actions = [a for a in actions if a.command == "rest"]
|
||||
assert len(rest_actions) == 1
|
||||
|
||||
def test_look_when_healthy(self):
|
||||
state = IdleState()
|
||||
gs = _make_game_state(hp=100, max_hp=100, mp=50, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "look"
|
||||
assert actions[0].priority == ActionPriority.IDLE
|
||||
|
||||
def test_both_heal_and_rest(self):
|
||||
state = IdleState(heal_threshold=0.9, mp_threshold=0.9)
|
||||
gs = _make_game_state(hp=50, max_hp=100, mp=10, max_mp=50)
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "heal" in commands
|
||||
assert "rest" in commands
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# ExploreState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExploreState:
|
||||
def test_prefers_unexplored_exits(self):
|
||||
state = ExploreState(strategy="random")
|
||||
gs = _make_game_state(exits={"north": 0, "south": 1})
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
# Should pick the unexplored exit
|
||||
assert actions[0].command == "north"
|
||||
|
||||
def test_falls_back_to_explored(self):
|
||||
state = ExploreState(strategy="random")
|
||||
gs = _make_game_state(exits={"north": 1, "south": 2})
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command in ("north", "south")
|
||||
|
||||
def test_look_when_no_room(self):
|
||||
state = ExploreState()
|
||||
gs = GameState() # no current_room
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "look"
|
||||
|
||||
def test_look_when_no_exits(self):
|
||||
state = ExploreState()
|
||||
gs = _make_game_state(exits={})
|
||||
actions = state.get_actions(gs)
|
||||
assert actions[0].command == "look"
|
||||
|
||||
def test_systematic_strategy(self):
|
||||
state = ExploreState(strategy="systematic")
|
||||
gs = _make_game_state(exits={"north": 0, "south": 0, "east": 0})
|
||||
|
||||
# First call — picks first unexplored
|
||||
a1 = state.get_actions(gs)
|
||||
first_dir = a1[0].command
|
||||
|
||||
# Second call — picks a different unexplored direction
|
||||
a2 = state.get_actions(gs)
|
||||
second_dir = a2[0].command
|
||||
assert second_dir != first_dir
|
||||
|
||||
def test_move_priority(self):
|
||||
state = ExploreState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert actions[0].priority == ActionPriority.MOVE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CombatState (behavior)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestCombatBehavior:
|
||||
def test_flee_when_critical_hp(self):
|
||||
state = CombatBehavior(flee_threshold=0.2)
|
||||
gs = _make_game_state(hp=10, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
assert any(a.priority == ActionPriority.EMERGENCY for a in actions)
|
||||
assert any(a.command == "flee" for a in actions)
|
||||
|
||||
def test_heal_and_attack_when_moderate_hp(self):
|
||||
state = CombatBehavior(combat_heal_threshold=0.5)
|
||||
gs = _make_game_state(hp=40, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "heal" in commands
|
||||
assert any("goblin" in c for c in commands)
|
||||
|
||||
def test_attack_only_when_healthy(self):
|
||||
state = CombatBehavior()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="goblin")
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert "goblin" in actions[0].command
|
||||
assert actions[0].priority == ActionPriority.COMBAT
|
||||
|
||||
def test_generic_attack_without_target(self):
|
||||
state = CombatBehavior()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None)
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "kill"
|
||||
|
||||
def test_flee_returns_only_flee(self):
|
||||
"""When fleeing, no attack actions should be queued."""
|
||||
state = CombatBehavior(flee_threshold=0.3)
|
||||
gs = _make_game_state(hp=10, max_hp=100, target="dragon")
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].command == "flee"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LootState
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLootState:
|
||||
def test_loot_actions(self):
|
||||
state = LootState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert len(actions) == 2
|
||||
commands = [a.command for a in actions]
|
||||
assert "get all from corpse" in commands
|
||||
assert "get all" in commands
|
||||
|
||||
def test_loot_priority(self):
|
||||
state = LootState()
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
assert all(a.priority == ActionPriority.LOOT for a in actions)
|
||||
|
||||
def test_custom_loot_command(self):
|
||||
state = LootState(loot_command="loot corpse", fallback_command="take all")
|
||||
gs = _make_game_state()
|
||||
actions = state.get_actions(gs)
|
||||
commands = [a.command for a in actions]
|
||||
assert "loot corpse" in commands
|
||||
assert "take all" in commands
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the OutputBuffer."""
|
||||
|
||||
import pytest
|
||||
from core.parsers.buffer import OutputBuffer
|
||||
from core.parsers.dead_souls import DeadSoulsParser
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return DeadSoulsParser()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def buffer(parser):
|
||||
return OutputBuffer(parser)
|
||||
|
||||
|
||||
class TestOutputBuffer:
|
||||
|
||||
def test_no_prompt_buffers_text(self, buffer):
|
||||
"""Text without a prompt should be buffered, not returned."""
|
||||
blocks = buffer.feed("You are in a dark room.\nA torch flickers.")
|
||||
assert blocks == []
|
||||
assert buffer.pending_lines == 2
|
||||
|
||||
def test_prompt_flushes_buffer(self, buffer):
|
||||
"""When a prompt arrives, the buffered text is flushed."""
|
||||
buffer.feed("Town Square\n\nYou are in the town square.\n")
|
||||
blocks = buffer.feed("hp: 350/350 mp: 440/440 sp: 431/431 >")
|
||||
# Should get 2 blocks: the accumulated room text + the prompt line
|
||||
assert len(blocks) == 2
|
||||
assert "Town Square" in blocks[0]
|
||||
assert "hp:" in blocks[1]
|
||||
assert buffer.pending_lines == 0
|
||||
|
||||
def test_multiple_prompts(self, buffer):
|
||||
"""Two prompts in one feed should flush twice."""
|
||||
text = (
|
||||
"Room A\nhp: 100/100 mp: 50/50 sp: 75/75 >\n"
|
||||
"Room B\nhp: 100/100 mp: 50/50 sp: 75/75 >\n"
|
||||
)
|
||||
blocks = buffer.feed(text)
|
||||
# Expect: "Room A", prompt, "Room B", prompt
|
||||
assert len(blocks) == 4
|
||||
assert "Room A" in blocks[0]
|
||||
assert "hp:" in blocks[1]
|
||||
assert "Room B" in blocks[2]
|
||||
assert "hp:" in blocks[3]
|
||||
|
||||
def test_flush_remaining(self, buffer):
|
||||
"""flush() should return whatever is in the buffer."""
|
||||
buffer.feed("Some text without prompt\nAnother line")
|
||||
remaining = buffer.flush()
|
||||
assert remaining is not None
|
||||
assert "Some text" in remaining
|
||||
assert buffer.pending_lines == 0
|
||||
|
||||
def test_flush_empty(self, buffer):
|
||||
"""flush() on empty buffer returns None."""
|
||||
assert buffer.flush() is None
|
||||
|
||||
def test_max_lines_safety(self, parser):
|
||||
"""Buffer should auto-flush when exceeding max_lines."""
|
||||
small_buffer = OutputBuffer(parser, max_lines=5)
|
||||
# Feed 10 lines without a prompt
|
||||
text = "\n".join(f"line {i}" for i in range(10))
|
||||
blocks = small_buffer.feed(text)
|
||||
# Should have flushed at least once due to exceeding 5 lines
|
||||
assert len(blocks) >= 1
|
||||
assert small_buffer.pending_lines < 5
|
||||
|
||||
def test_prompt_line_is_separate_block(self, buffer):
|
||||
"""The prompt itself should be its own block, not mixed with room text."""
|
||||
buffer.feed("Dark Forest\nTrees surround you.\n")
|
||||
blocks = buffer.feed("hp: 200/300 mp: 100/200 sp: 150/200 >")
|
||||
# blocks[0] = room text, blocks[1] = prompt
|
||||
assert len(blocks) == 2
|
||||
assert "hp:" not in blocks[0]
|
||||
assert "hp:" in blocks[1]
|
||||
|
||||
|
||||
class TestBufferWithRealGameOutput:
|
||||
"""Integration-style tests using text patterns from real Dead Souls sessions."""
|
||||
|
||||
def test_login_then_room(self, buffer):
|
||||
"""Simulate the login sequence followed by the first room."""
|
||||
# Login messages (no prompt)
|
||||
buffer.feed("Allowing login.\n")
|
||||
# Then the game sends the prompt
|
||||
blocks = buffer.feed("hp: 350/350 mp: 440/440 sp: 431/431 >")
|
||||
assert len(blocks) == 2
|
||||
assert "Allowing login." in blocks[0]
|
||||
assert "hp: 350/350" in blocks[1]
|
||||
|
||||
def test_room_description_then_prompt(self, buffer):
|
||||
"""Simulate receiving a room description followed by prompt."""
|
||||
text = (
|
||||
"Town Square\n"
|
||||
"\n"
|
||||
"You are standing in the town square.\n"
|
||||
"There is one obvious exit: north.\n"
|
||||
"A guard is standing here.\n"
|
||||
"hp: 350/350 mp: 440/440 sp: 431/431 >\n"
|
||||
)
|
||||
blocks = buffer.feed(text)
|
||||
# First block: room text, second: prompt
|
||||
assert len(blocks) == 2
|
||||
room_block = blocks[0]
|
||||
assert "Town Square" in room_block
|
||||
assert "guard" in room_block
|
||||
assert "hp:" in blocks[1]
|
||||
@@ -0,0 +1,78 @@
|
||||
"""Tests for the CharacterCreator module."""
|
||||
|
||||
import pytest
|
||||
from modules.character.creator import (
|
||||
CharacterCreator, CreatorConfig, StatRoll, CreationPhase,
|
||||
)
|
||||
|
||||
|
||||
class TestStatRoll:
|
||||
def test_total(self):
|
||||
roll = StatRoll(strength=15, intelligence=12, wisdom=10,
|
||||
dexterity=14, constitution=13, charisma=8)
|
||||
assert roll.total == 72
|
||||
|
||||
def test_weighted_score(self):
|
||||
roll = StatRoll(strength=18, intelligence=8, wisdom=8,
|
||||
dexterity=10, constitution=14, charisma=8)
|
||||
warrior_w = {"str": 2.0, "con": 1.5, "dex": 1.0, "int": 0.5, "wis": 0.5, "cha": 0.5}
|
||||
score = roll.weighted_score(warrior_w)
|
||||
expected = 18*2.0 + 8*0.5 + 8*0.5 + 10*1.0 + 14*1.5 + 8*0.5
|
||||
assert score == expected
|
||||
|
||||
|
||||
class TestCharacterCreator:
|
||||
def test_race_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_race="elf"))
|
||||
resp = creator.process("Please choose your race:")
|
||||
assert resp == "elf"
|
||||
assert creator.phase == CreationPhase.CHOOSE_RACE
|
||||
|
||||
def test_class_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_class="mage"))
|
||||
resp = creator.process("Select your class or profession:")
|
||||
assert resp == "mage"
|
||||
assert creator.phase == CreationPhase.CHOOSE_CLASS
|
||||
|
||||
def test_gender_prompt(self):
|
||||
creator = CharacterCreator(CreatorConfig(preferred_gender="female"))
|
||||
resp = creator.process("Choose your gender:")
|
||||
assert resp == "female"
|
||||
|
||||
def test_stat_display_reroll_low_stats(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=80))
|
||||
text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8"
|
||||
resp = creator.process(text)
|
||||
assert resp == "reroll"
|
||||
|
||||
def test_stat_display_keep_good_stats(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=60))
|
||||
text = "Str: 15 Int: 12 Wis: 10 Dex: 14 Con: 13 Cha: 10"
|
||||
resp = creator.process(text)
|
||||
assert resp == "keep"
|
||||
|
||||
def test_max_rerolls_forces_keep(self):
|
||||
creator = CharacterCreator(CreatorConfig(min_stat_total=200, max_rerolls=2))
|
||||
text = "Str: 8 Int: 8 Wis: 8 Dex: 8 Con: 8 Cha: 8"
|
||||
# First roll -> reroll
|
||||
resp1 = creator.process(text)
|
||||
assert resp1 == "reroll"
|
||||
# Second roll -> forced keep (max_rerolls=2)
|
||||
resp2 = creator.process(text)
|
||||
assert resp2 == "keep"
|
||||
|
||||
def test_confirm_prompt(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("Are you sure you want to create this character?")
|
||||
assert resp == "yes"
|
||||
|
||||
def test_creation_done(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("Welcome to the world!")
|
||||
assert resp is None
|
||||
assert creator.is_done
|
||||
|
||||
def test_unrecognised_text(self):
|
||||
creator = CharacterCreator()
|
||||
resp = creator.process("The wind howls through the trees.")
|
||||
assert resp is None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""Tests for the CombatAI module."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, CombatState
|
||||
from modules.decision.combat_ai import CombatAI, CombatConfig
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, target=None, npcs=None,
|
||||
combat=CombatState.IN_COMBAT,
|
||||
) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
gs.current_room = Room(
|
||||
title="Arena",
|
||||
exits={"north": 0},
|
||||
npcs=npcs or [],
|
||||
)
|
||||
return gs
|
||||
|
||||
|
||||
class TestCombatAI:
|
||||
def test_flee_when_critical(self):
|
||||
ai = CombatAI(CombatConfig(flee_hp_ratio=0.2))
|
||||
gs = _make_game_state(hp=15, max_hp=100, target="dragon")
|
||||
actions = ai.decide(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].priority == ActionPriority.EMERGENCY
|
||||
assert actions[0].command == "flee"
|
||||
|
||||
def test_heal_and_attack_when_moderate(self):
|
||||
ai = CombatAI(CombatConfig(heal_hp_ratio=0.5))
|
||||
gs = _make_game_state(hp=40, max_hp=100, target="goblin")
|
||||
actions = ai.decide(gs)
|
||||
priorities = [a.priority for a in actions]
|
||||
assert ActionPriority.HEAL in priorities
|
||||
assert ActionPriority.COMBAT in priorities
|
||||
|
||||
def test_attack_only_when_healthy(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="goblin")
|
||||
actions = ai.decide(gs)
|
||||
assert len(actions) == 1
|
||||
assert actions[0].priority == ActionPriority.COMBAT
|
||||
assert "goblin" in actions[0].command
|
||||
|
||||
def test_auto_target_from_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=["orc", "troll"])
|
||||
actions = ai.decide(gs)
|
||||
# Should auto-target the first NPC
|
||||
assert "orc" in actions[0].command
|
||||
|
||||
def test_generic_attack_no_target_no_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, target=None, npcs=[])
|
||||
actions = ai.decide(gs)
|
||||
assert actions[0].command == "kill"
|
||||
|
||||
def test_skill_rotation(self):
|
||||
config = CombatConfig(
|
||||
skill_commands=["backstab", "kick"],
|
||||
skill_cooldown_ticks=3,
|
||||
)
|
||||
ai = CombatAI(config)
|
||||
gs = _make_game_state(hp=100, max_hp=100, target="rat")
|
||||
|
||||
# Each decide() increments _ticks_since_skill by 1 at the end.
|
||||
# When a skill is used, _ticks_since_skill resets to 0 then +1.
|
||||
#
|
||||
# Tick 1: before=0, 0<3 -> normal attack, after=1
|
||||
a1 = ai.decide(gs)
|
||||
assert a1[-1].command == "kill rat"
|
||||
|
||||
# Tick 2: before=1, 1<3 -> normal attack, after=2
|
||||
a2 = ai.decide(gs)
|
||||
assert a2[-1].command == "kill rat"
|
||||
|
||||
# Tick 3: before=2, 2<3 -> normal attack, after=3
|
||||
a3 = ai.decide(gs)
|
||||
assert a3[-1].command == "kill rat"
|
||||
|
||||
# Tick 4: before=3, 3>=3 -> backstab, reset to 0, after=1
|
||||
a4 = ai.decide(gs)
|
||||
assert "backstab" in a4[-1].command
|
||||
|
||||
# Tick 5: before=1, 1<3 -> normal attack, after=2
|
||||
a5 = ai.decide(gs)
|
||||
assert a5[-1].command == "kill rat"
|
||||
|
||||
# Tick 6: before=2, 2<3 -> normal attack, after=3
|
||||
a6 = ai.decide(gs)
|
||||
assert a6[-1].command == "kill rat"
|
||||
|
||||
# Tick 7: before=3, 3>=3 -> kick, reset to 0, after=1
|
||||
a7 = ai.decide(gs)
|
||||
assert "kick" in a7[-1].command
|
||||
|
||||
def test_reset(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(target="goblin")
|
||||
ai.decide(gs)
|
||||
ai.reset()
|
||||
assert ai._skill_index == 0
|
||||
assert ai._ticks_since_skill == 0
|
||||
assert ai._last_target is None
|
||||
|
||||
def test_should_engage_when_healthy_with_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=80, max_hp=100, npcs=["rat"])
|
||||
assert ai.should_engage(gs) is True
|
||||
|
||||
def test_should_not_engage_when_low_hp(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=30, max_hp=100, npcs=["rat"])
|
||||
assert ai.should_engage(gs) is False
|
||||
|
||||
def test_should_not_engage_without_npcs(self):
|
||||
ai = CombatAI()
|
||||
gs = _make_game_state(hp=100, max_hp=100, npcs=[])
|
||||
assert ai.should_engage(gs) is False
|
||||
@@ -0,0 +1,249 @@
|
||||
"""Tests for the Dead Souls LPMUD parser."""
|
||||
|
||||
import pytest
|
||||
from core.parsers.dead_souls import DeadSoulsParser
|
||||
from core.parsers.base import ParsedPrompt, ParsedRoom, ParsedCombat, EventType
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def parser():
|
||||
return DeadSoulsParser()
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Prompt parsing
|
||||
# ======================================================================
|
||||
|
||||
class TestPromptParsing:
|
||||
|
||||
def test_standard_prompt(self, parser):
|
||||
"""Real prompt captured from game log."""
|
||||
line = "hp: 350/350 mp: 440/440 sp: 431/431 >"
|
||||
result = parser.parse_prompt(line)
|
||||
assert result is not None
|
||||
assert result.hp == 350
|
||||
assert result.max_hp == 350
|
||||
assert result.mp == 440
|
||||
assert result.max_mp == 440
|
||||
assert result.sp == 431
|
||||
assert result.max_sp == 431
|
||||
|
||||
def test_prompt_with_damage(self, parser):
|
||||
"""Prompt after taking damage."""
|
||||
line = "hp: 250/350 mp: 400/440 sp: 431/431 >"
|
||||
result = parser.parse_prompt(line)
|
||||
assert result is not None
|
||||
assert result.hp == 250
|
||||
assert result.max_hp == 350
|
||||
assert result.mp == 400
|
||||
|
||||
def test_prompt_case_insensitive(self, parser):
|
||||
"""Prompt matching should be case insensitive."""
|
||||
line = "HP: 100/200 MP: 50/100 SP: 75/150 >"
|
||||
result = parser.parse_prompt(line)
|
||||
assert result is not None
|
||||
assert result.hp == 100
|
||||
|
||||
def test_not_a_prompt(self, parser):
|
||||
"""Regular text should not parse as prompt."""
|
||||
assert parser.parse_prompt("You are in a dark room.") is None
|
||||
assert parser.parse_prompt("") is None
|
||||
assert parser.parse_prompt("A guard says hello.") is None
|
||||
|
||||
def test_is_prompt_line(self, parser):
|
||||
assert parser.is_prompt_line("hp: 350/350 mp: 440/440 sp: 431/431 >")
|
||||
assert not parser.is_prompt_line("You look around.")
|
||||
assert not parser.is_prompt_line("")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Room parsing
|
||||
# ======================================================================
|
||||
|
||||
class TestRoomParsing:
|
||||
|
||||
def test_full_room_description(self, parser):
|
||||
"""Complete room with title, description, exits, NPC, and item."""
|
||||
text = (
|
||||
"Town Square\n"
|
||||
"\n"
|
||||
"You are standing in the town square of the village. A large fountain\n"
|
||||
"dominates the center of the square. Roads lead off in several\n"
|
||||
"directions.\n"
|
||||
"There are three obvious exits: north, south, and east.\n"
|
||||
"A town guard is standing here.\n"
|
||||
"A small potion is here.\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert room.title == "Town Square"
|
||||
assert "fountain" in room.description
|
||||
assert set(room.exits) == {"north", "south", "east"}
|
||||
assert len(room.npcs) == 1
|
||||
assert "town guard" in room.npcs[0]
|
||||
assert len(room.items) == 1
|
||||
assert "small potion" in room.items[0]
|
||||
|
||||
def test_single_exit(self, parser):
|
||||
"""Room with only one exit."""
|
||||
text = (
|
||||
"Dead End\n"
|
||||
"\n"
|
||||
"You have reached a dead end.\n"
|
||||
"There is one obvious exit: south.\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert room.title == "Dead End"
|
||||
assert room.exits == ["south"]
|
||||
|
||||
def test_two_exits_with_and(self, parser):
|
||||
"""Room with two exits joined by 'and'."""
|
||||
text = (
|
||||
"Narrow Path\n"
|
||||
"\n"
|
||||
"A narrow path winds through the trees.\n"
|
||||
"There are two obvious exits: north and south.\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert set(room.exits) == {"north", "south"}
|
||||
|
||||
def test_bracket_exit_format(self, parser):
|
||||
"""Exit format: [ Exits: n s w ]"""
|
||||
text = (
|
||||
"Market Street\n"
|
||||
"\n"
|
||||
"Stalls line both sides of the busy street.\n"
|
||||
"[ Exits: n s w ]\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert set(room.exits) == {"n", "s", "w"}
|
||||
|
||||
def test_obvious_exits_format(self, parser):
|
||||
"""Exit format: Obvious exits: north, south"""
|
||||
text = (
|
||||
"Forest Clearing\n"
|
||||
"\n"
|
||||
"Sunlight filters through the canopy above.\n"
|
||||
"Obvious exits: north, south\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert set(room.exits) == {"north", "south"}
|
||||
|
||||
def test_multiple_npcs(self, parser):
|
||||
"""Room with multiple NPCs."""
|
||||
text = (
|
||||
"Guard Room\n"
|
||||
"\n"
|
||||
"A dimly lit room with weapon racks on the walls.\n"
|
||||
"There are two obvious exits: north and south.\n"
|
||||
"A guard captain is standing here.\n"
|
||||
"A young recruit is standing here.\n"
|
||||
)
|
||||
room = parser.parse_room(text)
|
||||
assert room is not None
|
||||
assert len(room.npcs) == 2
|
||||
|
||||
def test_empty_text_returns_none(self, parser):
|
||||
"""Empty input should return None."""
|
||||
assert parser.parse_room("") is None
|
||||
assert parser.parse_room(" \n \n ") is None
|
||||
|
||||
def test_prompt_only_not_a_room(self, parser):
|
||||
"""A prompt line alone is not a room."""
|
||||
room = parser.parse_room("hp: 350/350 mp: 440/440 sp: 431/431 >")
|
||||
# Should return None or a room with no title
|
||||
assert room is None or room.title is None
|
||||
|
||||
def test_is_room_description(self, parser):
|
||||
"""Heuristic check for room-like text."""
|
||||
assert parser.is_room_description(
|
||||
"A room.\nThere is one obvious exit: north."
|
||||
)
|
||||
assert not parser.is_room_description("You pick up the sword.")
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# Combat parsing
|
||||
# ======================================================================
|
||||
|
||||
class TestCombatParsing:
|
||||
|
||||
def test_hit_with_damage(self, parser):
|
||||
result = parser.parse_combat("You hit the goblin for 15 damage.")
|
||||
assert result is not None
|
||||
assert result.is_combat is True
|
||||
assert result.action == "hit"
|
||||
assert result.damage == 15
|
||||
assert "goblin" in result.target
|
||||
|
||||
def test_miss(self, parser):
|
||||
result = parser.parse_combat("The goblin misses you.")
|
||||
assert result is not None
|
||||
assert result.is_combat is True
|
||||
assert result.action == "miss"
|
||||
|
||||
def test_kill(self, parser):
|
||||
result = parser.parse_combat("You killed the goblin!")
|
||||
assert result is not None
|
||||
assert result.action == "kill"
|
||||
assert "goblin" in result.target
|
||||
|
||||
def test_attack(self, parser):
|
||||
result = parser.parse_combat("You attack the goblin!")
|
||||
assert result is not None
|
||||
assert result.action == "attack"
|
||||
|
||||
def test_flee(self, parser):
|
||||
result = parser.parse_combat("You flee north!")
|
||||
assert result is not None
|
||||
assert result.action == "flee"
|
||||
|
||||
def test_not_combat(self, parser):
|
||||
assert parser.parse_combat("You look around.") is None
|
||||
assert parser.parse_combat("") is None
|
||||
assert parser.parse_combat("The sun is shining.") is None
|
||||
|
||||
|
||||
# ======================================================================
|
||||
# parse_block (high-level)
|
||||
# ======================================================================
|
||||
|
||||
class TestParseBlock:
|
||||
|
||||
def test_room_with_prompt(self, parser):
|
||||
"""A typical server output: room description followed by prompt."""
|
||||
text = (
|
||||
"Town Square\n"
|
||||
"\n"
|
||||
"You are in the town square.\n"
|
||||
"There is one obvious exit: north.\n"
|
||||
"A guard is standing here.\n"
|
||||
"hp: 350/350 mp: 440/440 sp: 431/431 >\n"
|
||||
)
|
||||
events = parser.parse_block(text)
|
||||
types = [e.event_type for e in events]
|
||||
assert EventType.PROMPT in types
|
||||
assert EventType.ROOM in types
|
||||
|
||||
def test_combat_block(self, parser):
|
||||
"""Combat lines should produce combat events."""
|
||||
text = (
|
||||
"You attack the goblin!\n"
|
||||
"You hit the goblin for 12 damage.\n"
|
||||
"The goblin hits you for 5 damage.\n"
|
||||
"hp: 345/350 mp: 440/440 sp: 431/431 >\n"
|
||||
)
|
||||
events = parser.parse_block(text)
|
||||
combat_events = [e for e in events if e.event_type == EventType.COMBAT]
|
||||
assert len(combat_events) >= 2 # at least 2 combat lines
|
||||
prompt_events = [e for e in events if e.event_type == EventType.PROMPT]
|
||||
assert len(prompt_events) == 1
|
||||
|
||||
def test_empty_block(self, parser):
|
||||
"""Empty input returns no events."""
|
||||
assert parser.parse_block("") == []
|
||||
assert parser.parse_block(" \n\n ") == []
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Tests for the DecisionEngine (state machine + integration)."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, Room, CombatState
|
||||
from modules.decision.engine import DecisionEngine
|
||||
from modules.decision.combat_ai import CombatConfig
|
||||
from modules.decision.goal_manager import Goal, GoalPriority
|
||||
from modules.decision.action_queue import ActionPriority
|
||||
|
||||
|
||||
def _make_game_state(
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
room_title="Town Square", exits=None, npcs=None,
|
||||
target=None, combat=CombatState.IDLE,
|
||||
) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
gs.character.mp = mp
|
||||
gs.character.max_mp = max_mp
|
||||
gs.current_room = Room(
|
||||
title=room_title,
|
||||
exits=exits or {"north": 0, "south": 1},
|
||||
npcs=npcs or [],
|
||||
)
|
||||
gs.target = target
|
||||
gs.combat_state = combat
|
||||
return gs
|
||||
|
||||
|
||||
class TestDecisionEngineInit:
|
||||
def test_default_state_is_idle(self):
|
||||
engine = DecisionEngine()
|
||||
assert engine.current_state_name == "idle"
|
||||
|
||||
def test_all_states_registered(self):
|
||||
engine = DecisionEngine()
|
||||
assert "idle" in engine._states
|
||||
assert "explore" in engine._states
|
||||
assert "combat" in engine._states
|
||||
assert "loot" in engine._states
|
||||
|
||||
|
||||
class TestDecisionEngineTick:
|
||||
def test_tick_idle_produces_actions(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
|
||||
def test_transition_to_combat_when_in_combat(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="goblin",
|
||||
)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
def test_transition_to_loot_after_combat(self):
|
||||
engine = DecisionEngine()
|
||||
|
||||
# Simulate combat
|
||||
gs = _make_game_state(combat=CombatState.IN_COMBAT, target="goblin")
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
# Combat ends
|
||||
gs.combat_state = CombatState.IDLE
|
||||
gs.target = None
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "loot"
|
||||
|
||||
def test_transition_to_idle_when_low_hp(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(hp=30, max_hp=100)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "idle"
|
||||
|
||||
def test_explore_with_goal(self):
|
||||
engine = DecisionEngine()
|
||||
engine.goal_manager.add_goal(
|
||||
Goal(name="explore", priority=GoalPriority.EXPLORE, target_state="explore")
|
||||
)
|
||||
gs = _make_game_state(hp=100, max_hp=100)
|
||||
engine.tick(gs)
|
||||
assert engine.current_state_name == "explore"
|
||||
|
||||
def test_combat_actions_use_combat_ai(self):
|
||||
config = CombatConfig(attack_command="kill")
|
||||
engine = DecisionEngine(combat_config=config)
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="orc",
|
||||
hp=100, max_hp=100,
|
||||
)
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert "orc" in action.command
|
||||
|
||||
def test_emergency_flee_highest_priority(self):
|
||||
config = CombatConfig(flee_hp_ratio=0.2)
|
||||
engine = DecisionEngine(combat_config=config)
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="dragon",
|
||||
hp=10, max_hp=100,
|
||||
)
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert action.command == "flee"
|
||||
assert action.priority == ActionPriority.EMERGENCY
|
||||
|
||||
def test_tick_increments_counter(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.tick(gs)
|
||||
engine.tick(gs)
|
||||
assert engine._tick_count == 2
|
||||
|
||||
|
||||
class TestDecisionEngineTransitions:
|
||||
def test_force_state(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.force_state("combat", gs)
|
||||
assert engine.current_state_name == "combat"
|
||||
|
||||
def test_force_unknown_state(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
engine.force_state("nonexistent", gs)
|
||||
assert engine.current_state_name == "idle" # unchanged
|
||||
|
||||
def test_register_custom_state(self):
|
||||
from modules.decision.behavior_states import BaseBehaviorState, Action, ActionPriority
|
||||
from typing import List
|
||||
|
||||
class CustomState(BaseBehaviorState):
|
||||
name = "custom"
|
||||
|
||||
def get_actions(self, game_state):
|
||||
return [Action(command="custom_cmd", priority=ActionPriority.IDLE, source="custom")]
|
||||
|
||||
engine = DecisionEngine()
|
||||
engine.register_state(CustomState())
|
||||
assert "custom" in engine._states
|
||||
|
||||
def test_state_exit_clears_source_actions(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state()
|
||||
|
||||
# Tick in idle — queues actions from idle source
|
||||
engine.tick(gs)
|
||||
|
||||
# Force transition to explore — should clear idle actions
|
||||
engine.force_state("explore", gs)
|
||||
# Queue should now be empty (idle actions cleared)
|
||||
# Then tick explore to get new actions
|
||||
engine.tick(gs)
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
assert action.source == "explore"
|
||||
|
||||
|
||||
class TestDecisionEngineNextAction:
|
||||
def test_next_action_returns_none_when_empty(self):
|
||||
engine = DecisionEngine()
|
||||
assert engine.next_action() is None
|
||||
|
||||
def test_next_action_respects_priority(self):
|
||||
engine = DecisionEngine()
|
||||
gs = _make_game_state(
|
||||
combat=CombatState.IN_COMBAT,
|
||||
target="goblin",
|
||||
hp=30, max_hp=100, # triggers heal
|
||||
)
|
||||
engine.tick(gs)
|
||||
# First action should be the highest priority
|
||||
action = engine.next_action()
|
||||
assert action is not None
|
||||
# Should be heal (HEAL=1) before attack (COMBAT=2)
|
||||
assert action.priority <= ActionPriority.HEAL
|
||||
|
||||
|
||||
class TestDecisionEngineDefaultGoals:
|
||||
def test_default_goals_produce_exploration(self):
|
||||
engine = DecisionEngine()
|
||||
engine.goal_manager.set_default_goals()
|
||||
gs = _make_game_state(hp=100, max_hp=100)
|
||||
engine.tick(gs)
|
||||
# With default goals, should be in explore or grind state
|
||||
assert engine.current_state_name in ("explore", "combat")
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Tests for the ParserFactory."""
|
||||
|
||||
import pytest
|
||||
from core.parsers.factory import ParserFactory
|
||||
from core.parsers.dead_souls import DeadSoulsParser
|
||||
from core.parsers.base import BaseMudParser
|
||||
|
||||
|
||||
def test_create_dead_souls():
|
||||
parser = ParserFactory.create("dead_souls")
|
||||
assert isinstance(parser, DeadSoulsParser)
|
||||
|
||||
|
||||
def test_create_unknown_raises():
|
||||
with pytest.raises(ValueError, match="Unknown MUD type"):
|
||||
ParserFactory.create("nonexistent_mud")
|
||||
|
||||
|
||||
def test_available_types():
|
||||
types = ParserFactory.available_types()
|
||||
assert "dead_souls" in types
|
||||
|
||||
|
||||
def test_register_custom():
|
||||
"""Register a custom parser at runtime."""
|
||||
|
||||
class DummyParser(BaseMudParser):
|
||||
def parse_prompt(self, line):
|
||||
return None
|
||||
|
||||
def parse_room(self, text_block):
|
||||
return None
|
||||
|
||||
def parse_combat(self, line):
|
||||
return None
|
||||
|
||||
def is_prompt_line(self, line):
|
||||
return False
|
||||
|
||||
def is_room_description(self, text_block):
|
||||
return False
|
||||
|
||||
ParserFactory.register("dummy", DummyParser)
|
||||
parser = ParserFactory.create("dummy")
|
||||
assert isinstance(parser, DummyParser)
|
||||
|
||||
# Clean up
|
||||
ParserFactory._registry.pop("dummy", None)
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Tests for the updated GameState."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState, CombatState
|
||||
from core.parsers.base import ParsedPrompt, ParsedRoom, ParsedCombat
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def state():
|
||||
return GameState()
|
||||
|
||||
|
||||
class TestUpdateFromPrompt:
|
||||
|
||||
def test_updates_all_vitals(self, state):
|
||||
prompt = ParsedPrompt(hp=300, max_hp=350, mp=400, max_mp=440, sp=431, max_sp=431)
|
||||
state.update_from_prompt(prompt)
|
||||
assert state.character.hp == 300
|
||||
assert state.character.max_hp == 350
|
||||
assert state.character.mp == 400
|
||||
assert state.character.max_mp == 440
|
||||
assert state.character.sp == 431
|
||||
assert state.character.max_sp == 431
|
||||
|
||||
def test_partial_update(self, state):
|
||||
"""Only non-None fields should update."""
|
||||
state.character.hp = 100
|
||||
state.character.max_hp = 200
|
||||
prompt = ParsedPrompt(hp=150, max_hp=200)
|
||||
state.update_from_prompt(prompt)
|
||||
assert state.character.hp == 150
|
||||
assert state.character.sp == 0 # untouched default
|
||||
|
||||
def test_is_healthy(self, state):
|
||||
prompt = ParsedPrompt(hp=280, max_hp=400, mp=100, max_mp=100, sp=100, max_sp=100)
|
||||
state.update_from_prompt(prompt)
|
||||
assert state.is_healthy(threshold=0.7) # 280/400 = 0.7
|
||||
assert not state.is_healthy(threshold=0.8)
|
||||
|
||||
|
||||
class TestUpdateRoom:
|
||||
|
||||
def test_basic_room_update(self, state):
|
||||
room = ParsedRoom(
|
||||
title="Town Square",
|
||||
description="A busy square.",
|
||||
exits=["north", "south"],
|
||||
npcs=["guard"],
|
||||
items=["sword"],
|
||||
)
|
||||
state.update_room(room)
|
||||
assert state.current_room is not None
|
||||
assert state.current_room.title == "Town Square"
|
||||
assert "north" in state.current_room.exits
|
||||
assert state.current_room.npcs == ["guard"]
|
||||
assert state.current_room.items == ["sword"]
|
||||
|
||||
def test_previous_room_preserved(self, state):
|
||||
room1 = ParsedRoom(title="Room A", exits=["north"])
|
||||
room2 = ParsedRoom(title="Room B", exits=["south"])
|
||||
state.update_room(room1)
|
||||
state.update_room(room2)
|
||||
assert state.current_room.title == "Room B"
|
||||
assert state.previous_room is not None
|
||||
assert state.previous_room.title == "Room A"
|
||||
|
||||
|
||||
class TestUpdateCombat:
|
||||
|
||||
def test_enter_combat(self, state):
|
||||
combat = ParsedCombat(is_combat=True, attacker="You", target="goblin", action="attack")
|
||||
state.update_combat(combat)
|
||||
assert state.combat_state == CombatState.IN_COMBAT
|
||||
assert state.target == "goblin"
|
||||
|
||||
def test_flee(self, state):
|
||||
state.combat_state = CombatState.IN_COMBAT
|
||||
state.target = "goblin"
|
||||
combat = ParsedCombat(is_combat=True, attacker="You", action="flee")
|
||||
state.update_combat(combat)
|
||||
assert state.combat_state == CombatState.FLEEING
|
||||
assert state.target is None
|
||||
|
||||
def test_kill_target(self, state):
|
||||
state.combat_state = CombatState.IN_COMBAT
|
||||
state.target = "goblin"
|
||||
combat = ParsedCombat(is_combat=True, attacker="You", target="the goblin", action="kill")
|
||||
state.update_combat(combat)
|
||||
assert state.combat_state == CombatState.IDLE
|
||||
assert state.target is None
|
||||
|
||||
def test_not_combat_ignored(self, state):
|
||||
combat = ParsedCombat(is_combat=False)
|
||||
state.update_combat(combat)
|
||||
assert state.combat_state == CombatState.IDLE
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Tests for the GoalManager."""
|
||||
|
||||
import pytest
|
||||
from core.game_state import GameState
|
||||
from modules.decision.goal_manager import (
|
||||
GoalManager,
|
||||
Goal,
|
||||
GoalPriority,
|
||||
GoalStatus,
|
||||
)
|
||||
|
||||
|
||||
def _make_game_state(hp=100, max_hp=100) -> GameState:
|
||||
gs = GameState()
|
||||
gs.character.hp = hp
|
||||
gs.character.max_hp = max_hp
|
||||
return gs
|
||||
|
||||
|
||||
class TestGoal:
|
||||
def test_goal_creation(self):
|
||||
g = Goal(name="test", priority=GoalPriority.EXPLORE)
|
||||
assert g.name == "test"
|
||||
assert g.is_active()
|
||||
assert g.progress == 0.0
|
||||
|
||||
def test_goal_inactive_after_completion(self):
|
||||
g = Goal(name="test")
|
||||
g.status = GoalStatus.COMPLETED
|
||||
assert not g.is_active()
|
||||
|
||||
|
||||
class TestGoalManager:
|
||||
def test_add_and_count(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
gm.add_goal(Goal(name="b"))
|
||||
assert gm.goal_count == 2
|
||||
assert gm.active_goal_count == 2
|
||||
|
||||
def test_remove_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.remove_goal("a") is True
|
||||
assert gm.goal_count == 0
|
||||
assert gm.remove_goal("nonexistent") is False
|
||||
|
||||
def test_complete_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.complete_goal("a") is True
|
||||
assert gm.active_goal_count == 0
|
||||
assert gm.goal_count == 1
|
||||
|
||||
def test_fail_goal(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="a"))
|
||||
assert gm.fail_goal("a") is True
|
||||
assert gm.active_goal_count == 0
|
||||
|
||||
def test_get_current_goal_highest_priority(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gm.add_goal(Goal(name="combat", priority=GoalPriority.COMBAT))
|
||||
gs = _make_game_state()
|
||||
current = gm.get_current_goal(gs)
|
||||
assert current is not None
|
||||
assert current.name == "combat"
|
||||
|
||||
def test_survival_override_when_low_hp(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gs = _make_game_state(hp=20, max_hp=100)
|
||||
current = gm.get_current_goal(gs)
|
||||
assert current is not None
|
||||
assert current.name == "survive"
|
||||
assert current.priority == GoalPriority.SURVIVAL
|
||||
|
||||
def test_no_goals_returns_none(self):
|
||||
gm = GoalManager()
|
||||
gs = _make_game_state()
|
||||
assert gm.get_current_goal(gs) is None
|
||||
|
||||
def test_get_active_goals_sorted(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="explore", priority=GoalPriority.EXPLORE))
|
||||
gm.add_goal(Goal(name="grind", priority=GoalPriority.GRIND))
|
||||
gm.add_goal(Goal(name="quest", priority=GoalPriority.QUEST))
|
||||
|
||||
active = gm.get_active_goals()
|
||||
names = [g.name for g in active]
|
||||
assert names == ["quest", "grind", "explore"]
|
||||
|
||||
def test_set_default_goals(self):
|
||||
gm = GoalManager()
|
||||
gm.set_default_goals()
|
||||
assert gm.active_goal_count == 2
|
||||
names = [g.name for g in gm.get_active_goals()]
|
||||
assert "explore_world" in names
|
||||
assert "grind_combat" in names
|
||||
|
||||
def test_complete_nonexistent(self):
|
||||
gm = GoalManager()
|
||||
assert gm.complete_goal("nope") is False
|
||||
|
||||
def test_fail_already_completed(self):
|
||||
gm = GoalManager()
|
||||
gm.add_goal(Goal(name="done"))
|
||||
gm.complete_goal("done")
|
||||
# Cannot fail an already-completed goal
|
||||
assert gm.fail_goal("done") is False
|
||||
@@ -0,0 +1,309 @@
|
||||
"""Tests for the LLM module (client, brain, social AI).
|
||||
|
||||
All tests use mocked HTTP responses — no Ollama server required.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, patch, MagicMock
|
||||
|
||||
from modules.llm.client import (
|
||||
LLMClient, LLMConfig, LLMResponse, render_template, PROMPT_TEMPLATES,
|
||||
)
|
||||
from modules.llm.brain import LLMBrain
|
||||
from modules.llm.social_ai import SocialAI
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _make_mock_response(text="", tokens=10, success=True):
|
||||
return LLMResponse(
|
||||
text=text, model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=success,
|
||||
)
|
||||
|
||||
|
||||
def _make_json_response(data, tokens=10):
|
||||
return LLMResponse(
|
||||
text=json.dumps(data), model="test", tokens_used=tokens,
|
||||
latency_ms=50.0, success=True,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMClient
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMConfig:
|
||||
def test_defaults(self):
|
||||
cfg = LLMConfig()
|
||||
assert cfg.model == "llama3.2"
|
||||
assert cfg.temperature == 0.3
|
||||
assert cfg.base_url == "http://localhost:11434"
|
||||
|
||||
|
||||
class TestLLMResponse:
|
||||
def test_as_json_valid(self):
|
||||
r = LLMResponse(text='{"key": "value"}', success=True)
|
||||
data = r.as_json()
|
||||
assert data == {"key": "value"}
|
||||
|
||||
def test_as_json_invalid(self):
|
||||
r = LLMResponse(text="not json", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
def test_as_json_empty(self):
|
||||
r = LLMResponse(text="", success=True)
|
||||
assert r.as_json() is None
|
||||
|
||||
|
||||
class TestRenderTemplate:
|
||||
def test_quest_parse_template(self):
|
||||
result = render_template("quest_parse", text="Kill 5 goblins")
|
||||
assert "Kill 5 goblins" in result
|
||||
assert "JSON" in result
|
||||
|
||||
def test_social_reply_template(self):
|
||||
result = render_template(
|
||||
"social_reply",
|
||||
message="Hello!",
|
||||
character_name="Grok",
|
||||
character_class="warrior",
|
||||
)
|
||||
assert "Grok" in result
|
||||
assert "Hello!" in result
|
||||
|
||||
def test_decide_action_template(self):
|
||||
result = render_template(
|
||||
"decide_action",
|
||||
room_title="Town Square",
|
||||
hp=100, max_hp=100, mp=50, max_mp=50,
|
||||
exits="north, south",
|
||||
npcs="guard",
|
||||
items="potion",
|
||||
combat_state="idle",
|
||||
goal="explore",
|
||||
)
|
||||
assert "Town Square" in result
|
||||
|
||||
def test_missing_variable(self):
|
||||
# Should not crash, just leave placeholder
|
||||
result = render_template("quest_parse")
|
||||
assert isinstance(result, str)
|
||||
|
||||
def test_custom_template(self):
|
||||
result = render_template("Hello {name}!", name="World")
|
||||
assert result == "Hello World!"
|
||||
|
||||
|
||||
class TestLLMClient:
|
||||
def test_client_creation(self):
|
||||
client = LLMClient(LLMConfig(model="test-model"))
|
||||
assert client.config.model == "test-model"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_unavailable(self):
|
||||
"""When server is down, should return error response."""
|
||||
client = LLMClient(LLMConfig(
|
||||
base_url="http://localhost:99999",
|
||||
max_retries=0,
|
||||
timeout_seconds=1,
|
||||
))
|
||||
resp = await client.generate("test")
|
||||
assert resp.success is False
|
||||
assert resp.error is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_is_available_offline(self):
|
||||
client = LLMClient(LLMConfig(base_url="http://localhost:99999"))
|
||||
available = await client.is_available()
|
||||
assert available is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LLMBrain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestLLMBrain:
|
||||
def test_creation(self):
|
||||
brain = LLMBrain(LLMConfig())
|
||||
assert brain.is_available is False
|
||||
assert brain._query_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_initialize_offline(self):
|
||||
brain = LLMBrain(LLMConfig(base_url="http://localhost:99999"))
|
||||
result = await brain.initialize()
|
||||
assert result is False
|
||||
assert brain.is_available is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_quest("Kill the dragon")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_quest_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
mock_resp = _make_json_response([
|
||||
{"type": "kill", "target": "dragon", "count": 1, "location": "cave"}
|
||||
])
|
||||
brain.client.ask = AsyncMock(return_value=mock_resp)
|
||||
|
||||
result = await brain.parse_quest("Slay the dragon in the cave")
|
||||
assert result is not None
|
||||
assert len(result) == 1
|
||||
assert result[0]["type"] == "kill"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_offline_returns_none(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.social_reply("Hello!")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_social_reply_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
# Mock generate since social_reply now uses it with context
|
||||
brain.client.generate = AsyncMock(
|
||||
return_value=_make_mock_response("Greetings, fellow adventurer!")
|
||||
)
|
||||
result = await brain.social_reply("Hello!", sender="TestPlayer")
|
||||
assert result is not None
|
||||
assert "adventurer" in result.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
from core.game_state import GameState
|
||||
gs = GameState()
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_suggest_action_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("north")
|
||||
)
|
||||
from core.game_state import GameState, Room
|
||||
gs = GameState()
|
||||
gs.current_room = Room(title="Town Square", exits={"north": 0})
|
||||
result = await brain.suggest_action(gs)
|
||||
assert result == "north"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_parse_ambiguous_offline(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = False
|
||||
result = await brain.parse_ambiguous("Something happens")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_evaluate_npc_with_mock(self):
|
||||
brain = LLMBrain()
|
||||
brain._available = True
|
||||
brain.client.ask = AsyncMock(
|
||||
return_value=_make_json_response(
|
||||
{"hostile": True, "confidence": 0.9, "reason": "It's a goblin"}
|
||||
)
|
||||
)
|
||||
result = await brain.evaluate_npc("goblin", "A dark cave")
|
||||
assert result is not None
|
||||
assert result["hostile"] is True
|
||||
|
||||
def test_stats(self):
|
||||
brain = LLMBrain()
|
||||
stats = brain.stats()
|
||||
assert stats["queries"] == 0
|
||||
assert stats["available"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SocialAI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestSocialAI:
|
||||
def _make_social_ai(self):
|
||||
client = LLMClient(LLMConfig())
|
||||
return SocialAI(client, character_name="Grok", character_class="warrior")
|
||||
|
||||
def test_detect_say(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gandalf says: Hello there!")
|
||||
assert event is not None
|
||||
assert event["type"] == "say"
|
||||
assert event["speaker"] == "Gandalf"
|
||||
|
||||
def test_detect_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Legolas tells you: Follow me")
|
||||
assert event is not None
|
||||
assert event["type"] == "tell"
|
||||
assert event["speaker"] == "Legolas"
|
||||
|
||||
def test_detect_shout(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Gimli shouts: For glory!")
|
||||
assert event is not None
|
||||
assert event["type"] == "shout"
|
||||
|
||||
def test_detect_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("Aragorn invites you to join a party")
|
||||
assert event is not None
|
||||
assert event["type"] == "group_invite"
|
||||
|
||||
def test_detect_no_social(self):
|
||||
ai = self._make_social_ai()
|
||||
event = ai.detect_social_event("The wind howls through the trees.")
|
||||
assert event is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_say(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("Well met, friend!")
|
||||
)
|
||||
event = {"type": "say", "speaker": "Gandalf", "message": "Hello"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("say ")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_tell(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.ask = AsyncMock(
|
||||
return_value=_make_mock_response("On my way!")
|
||||
)
|
||||
event = {"type": "tell", "speaker": "Legolas", "message": "Come here"}
|
||||
result = await ai.respond(event)
|
||||
assert result is not None
|
||||
assert result.startswith("tell Legolas")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_respond_group_invite(self):
|
||||
ai = self._make_social_ai()
|
||||
event = {"type": "group_invite", "speaker": "Aragorn", "message": ""}
|
||||
result = await ai.respond(event)
|
||||
assert result == "group accept"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_generate_emote(self):
|
||||
ai = self._make_social_ai()
|
||||
ai.client.generate = AsyncMock(
|
||||
return_value=_make_mock_response("sharpens his sword quietly")
|
||||
)
|
||||
result = await ai.generate_emote("resting at the inn")
|
||||
assert result is not None
|
||||
assert result.startswith("emote ")
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Tests for the ProgressionOptimizer module."""
|
||||
|
||||
import pytest
|
||||
from modules.progression.optimizer import (
|
||||
ProgressionOptimizer, GrindingSpot, EquipmentPiece, SkillInfo,
|
||||
)
|
||||
|
||||
|
||||
class TestGrindingSpot:
|
||||
def test_exp_per_hour(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Forest", kills=10,
|
||||
total_exp=1000, total_time_seconds=3600)
|
||||
assert spot.exp_per_hour == 1000.0
|
||||
|
||||
def test_survival_rate(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Dungeon",
|
||||
kills=8, deaths=2)
|
||||
assert spot.survival_rate == 0.8
|
||||
|
||||
def test_zero_time(self):
|
||||
spot = GrindingSpot(room_id=1, room_title="Room")
|
||||
assert spot.exp_per_hour == 0.0
|
||||
assert spot.kills_per_hour == 0.0
|
||||
|
||||
|
||||
class TestEquipmentPiece:
|
||||
def test_weighted_score(self):
|
||||
eq = EquipmentPiece(name="Iron Sword", slot="weapon",
|
||||
stat_bonuses={"str": 5, "dex": 2})
|
||||
score = eq.weighted_score({"str": 2.0, "dex": 1.0})
|
||||
assert score == 12.0
|
||||
|
||||
def test_default_weights(self):
|
||||
eq = EquipmentPiece(name="Shield", slot="armor",
|
||||
stat_bonuses={"con": 3})
|
||||
score = eq.weighted_score()
|
||||
assert score == 3.0
|
||||
|
||||
|
||||
class TestSkillInfo:
|
||||
def test_damage_per_mana(self):
|
||||
skill = SkillInfo(name="Fireball", command="cast fireball",
|
||||
damage=100, mana_cost=20)
|
||||
assert skill.damage_per_mana == 5.0
|
||||
|
||||
def test_dps(self):
|
||||
skill = SkillInfo(name="Slash", command="slash",
|
||||
damage=50, cooldown_seconds=2.0)
|
||||
assert skill.dps == 25.0
|
||||
|
||||
def test_zero_mana_cost(self):
|
||||
skill = SkillInfo(name="Punch", command="punch",
|
||||
damage=10, mana_cost=0)
|
||||
assert skill.damage_per_mana == float("inf")
|
||||
|
||||
|
||||
class TestProgressionOptimizer:
|
||||
def test_record_kill(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
spots = opt.get_best_grinding_spots()
|
||||
# Need at least 3 kills for statistical relevance
|
||||
assert len(spots) == 0
|
||||
opt.record_kill(1, "Forest", exp_gained=50)
|
||||
spots = opt.get_best_grinding_spots()
|
||||
assert len(spots) == 1
|
||||
assert spots[0].kills == 3
|
||||
assert spots[0].total_exp == 150
|
||||
|
||||
def test_record_death(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.record_death(1, "Dungeon")
|
||||
opt.record_kill(1, "Dungeon", 100)
|
||||
# Spot exists but deaths affect survival rate
|
||||
# (< 3 kills so won't appear in best spots)
|
||||
assert 1 in opt._spots
|
||||
assert opt._spots[1].deaths == 1
|
||||
|
||||
def test_add_and_compare_equipment(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_equipment(EquipmentPiece(
|
||||
name="Iron Sword", slot="weapon",
|
||||
stat_bonuses={"str": 5}))
|
||||
opt.add_equipment(EquipmentPiece(
|
||||
name="Steel Sword", slot="weapon",
|
||||
stat_bonuses={"str": 8}))
|
||||
|
||||
best = opt.get_best_equipment("weapon")
|
||||
assert best is not None
|
||||
assert best.name == "Steel Sword"
|
||||
|
||||
def test_optimal_rotation(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10, cooldown_seconds=1))
|
||||
opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50, cooldown_seconds=3))
|
||||
|
||||
rotation = opt.get_optimal_rotation(available_mana=40)
|
||||
# Only Slash fits within 40 mana
|
||||
assert len(rotation) == 1
|
||||
assert rotation[0].name == "Slash"
|
||||
|
||||
def test_efficient_rotation(self):
|
||||
opt = ProgressionOptimizer()
|
||||
opt.add_skill(SkillInfo("Slash", "slash", damage=30, mana_cost=10))
|
||||
opt.add_skill(SkillInfo("Fireball", "fireball", damage=100, mana_cost=50))
|
||||
efficient = opt.get_efficient_rotation()
|
||||
# Slash = 3.0 dmg/mana, Fireball = 2.0 dmg/mana
|
||||
assert efficient[0].name == "Slash"
|
||||
|
||||
def test_session_summary(self):
|
||||
opt = ProgressionOptimizer()
|
||||
summary = opt.session_summary()
|
||||
assert "total_kills" in summary
|
||||
assert "total_exp" in summary
|
||||
assert summary["total_kills"] == 0
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for the QuestManager module."""
|
||||
|
||||
import pytest
|
||||
from modules.quest.quest_manager import (
|
||||
QuestManager, Quest, QuestObjective, QuestStatus,
|
||||
ObjectiveType, parse_quest_text,
|
||||
)
|
||||
|
||||
|
||||
class TestParseQuestText:
|
||||
def test_kill_quest(self):
|
||||
objs = parse_quest_text("Kill 5 goblins in the dark forest.")
|
||||
assert len(objs) >= 1
|
||||
kill_obj = [o for o in objs if o.obj_type == ObjectiveType.KILL]
|
||||
assert len(kill_obj) >= 1
|
||||
assert kill_obj[0].target_count == 5
|
||||
assert "goblin" in kill_obj[0].target.lower()
|
||||
|
||||
def test_fetch_quest(self):
|
||||
objs = parse_quest_text("Find the golden ring in the crypt.")
|
||||
assert len(objs) >= 1
|
||||
fetch_obj = [o for o in objs if o.obj_type == ObjectiveType.FETCH]
|
||||
assert len(fetch_obj) >= 1
|
||||
|
||||
def test_talk_quest(self):
|
||||
objs = parse_quest_text("Talk to the guard captain.")
|
||||
assert len(objs) >= 1
|
||||
talk_obj = [o for o in objs if o.obj_type == ObjectiveType.TALK]
|
||||
assert len(talk_obj) >= 1
|
||||
|
||||
def test_empty_text(self):
|
||||
objs = parse_quest_text("The sun is shining.")
|
||||
# May or may not parse; should not crash
|
||||
assert isinstance(objs, list)
|
||||
|
||||
def test_kill_without_count(self):
|
||||
objs = parse_quest_text("Kill the dragon.")
|
||||
kill_objs = [o for o in objs if o.obj_type == ObjectiveType.KILL]
|
||||
if kill_objs:
|
||||
assert kill_objs[0].target_count == 1
|
||||
|
||||
|
||||
class TestQuestObjective:
|
||||
def test_progress(self):
|
||||
obj = QuestObjective(description="Kill 3 rats", target="rat",
|
||||
target_count=3, current_count=1)
|
||||
assert abs(obj.progress - 1/3) < 0.01
|
||||
assert not obj.is_done
|
||||
|
||||
def test_completion(self):
|
||||
obj = QuestObjective(description="Kill 2 rats", target="rat",
|
||||
target_count=2, current_count=2)
|
||||
assert obj.is_done
|
||||
|
||||
|
||||
class TestQuestManager:
|
||||
def test_discover_quest(self):
|
||||
qm = QuestManager()
|
||||
q = qm.discover_quest("goblin_hunt", "Kill 5 goblins.", reward_exp=100)
|
||||
assert q.name == "goblin_hunt"
|
||||
assert q.status == QuestStatus.DISCOVERED
|
||||
assert qm.quest_count == 1
|
||||
|
||||
def test_accept_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Kill the rat.")
|
||||
assert qm.accept_quest("test") is True
|
||||
q = qm.get_quest("test")
|
||||
assert q.status == QuestStatus.ACCEPTED
|
||||
|
||||
def test_cannot_accept_twice(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Kill the rat.")
|
||||
qm.accept_quest("test")
|
||||
assert qm.accept_quest("test") is False
|
||||
|
||||
def test_update_objective(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("hunt", "Kill 3 goblins.")
|
||||
qm.accept_quest("hunt")
|
||||
qm.update_objective("hunt", "goblin")
|
||||
q = qm.get_quest("hunt")
|
||||
kill_objs = [o for o in q.objectives if o.obj_type == ObjectiveType.KILL]
|
||||
if kill_objs:
|
||||
assert kill_objs[0].current_count >= 1
|
||||
|
||||
def test_complete_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "Talk to the guard.", reward_exp=50)
|
||||
qm.accept_quest("test")
|
||||
assert qm.complete_quest("test") is True
|
||||
assert qm.get_quest("test").status == QuestStatus.COMPLETED
|
||||
|
||||
def test_fail_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("test", "desc")
|
||||
qm.accept_quest("test")
|
||||
assert qm.fail_quest("test") is True
|
||||
assert qm.get_quest("test").status == QuestStatus.FAILED
|
||||
|
||||
def test_active_quests_sorted(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("low_exp", "Kill the rat.", reward_exp=10)
|
||||
qm.discover_quest("high_exp", "Kill the dragon.", reward_exp=1000)
|
||||
qm.accept_quest("low_exp")
|
||||
qm.accept_quest("high_exp")
|
||||
active = qm.get_active_quests()
|
||||
assert active[0].name == "high_exp"
|
||||
|
||||
def test_get_best_quest(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("a", "Kill goblin.", reward_exp=100)
|
||||
qm.accept_quest("a")
|
||||
best = qm.get_best_quest()
|
||||
assert best is not None
|
||||
assert best.name == "a"
|
||||
|
||||
def test_pending_kill_targets(self):
|
||||
qm = QuestManager()
|
||||
qm.discover_quest("hunt", "Kill 3 goblins.")
|
||||
qm.accept_quest("hunt")
|
||||
targets = qm.get_pending_kill_targets()
|
||||
# Should include "goblins" or similar
|
||||
assert len(targets) >= 0 # depends on parse
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Tests for WorldMapper, ActiveStorage, and related mapper components."""
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import pytest
|
||||
from modules.mapper.active_storage import ActiveStorage, generate_fingerprint
|
||||
from modules.mapper.world_mapper import WorldMapper
|
||||
from modules.mapper.pathfinder import Pathfinder
|
||||
|
||||
|
||||
class TestGenerateFingerprint:
|
||||
def test_deterministic(self):
|
||||
fp1 = generate_fingerprint("Town Square", "A large square.", ["north", "south"])
|
||||
fp2 = generate_fingerprint("Town Square", "A large square.", ["north", "south"])
|
||||
assert fp1 == fp2
|
||||
|
||||
def test_different_exits_different_fp(self):
|
||||
fp1 = generate_fingerprint("Room", "Desc", ["north"])
|
||||
fp2 = generate_fingerprint("Room", "Desc", ["south"])
|
||||
assert fp1 != fp2
|
||||
|
||||
def test_exit_order_irrelevant(self):
|
||||
fp1 = generate_fingerprint("Room", "Desc", ["north", "south"])
|
||||
fp2 = generate_fingerprint("Room", "Desc", ["south", "north"])
|
||||
assert fp1 == fp2
|
||||
|
||||
def test_case_insensitive(self):
|
||||
fp1 = generate_fingerprint("Town Square", "Desc", ["north"])
|
||||
fp2 = generate_fingerprint("town square", "desc", ["north"])
|
||||
assert fp1 == fp2
|
||||
|
||||
|
||||
class TestActiveStorage:
|
||||
@pytest.fixture
|
||||
def storage(self, tmp_path):
|
||||
db = tmp_path / "test.db"
|
||||
s = ActiveStorage(db_path=str(db))
|
||||
yield s
|
||||
s.close()
|
||||
|
||||
def test_add_and_get_room(self, storage):
|
||||
room_id = storage.add_room({
|
||||
"fingerprint": "abc123",
|
||||
"title": "Town Square",
|
||||
"description": "A big square.",
|
||||
"exits": {"north": None},
|
||||
"npcs": ["guard"],
|
||||
"items": ["potion"],
|
||||
})
|
||||
assert room_id > 0
|
||||
room = storage.get_room(room_id)
|
||||
assert room is not None
|
||||
assert room["title"] == "Town Square"
|
||||
assert room["npcs"] == ["guard"]
|
||||
|
||||
def test_revisit_increments_count(self, storage):
|
||||
storage.add_room({"fingerprint": "abc", "title": "Room"})
|
||||
storage.add_room({"fingerprint": "abc", "title": "Room"})
|
||||
room = storage.get_room_by_fingerprint("abc")
|
||||
assert room["visit_count"] == 2
|
||||
|
||||
def test_room_count(self, storage):
|
||||
assert storage.room_count() == 0
|
||||
storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
assert storage.room_count() == 2
|
||||
|
||||
def test_get_all_rooms(self, storage):
|
||||
storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
all_rooms = storage.get_all_rooms()
|
||||
assert len(all_rooms) == 2
|
||||
|
||||
def test_get_room_by_fingerprint(self, storage):
|
||||
storage.add_room({"fingerprint": "fp123", "title": "Test"})
|
||||
room = storage.get_room_by_fingerprint("fp123")
|
||||
assert room is not None
|
||||
assert room["title"] == "Test"
|
||||
assert storage.get_room_by_fingerprint("nonexistent") is None
|
||||
|
||||
def test_add_exit(self, storage):
|
||||
id1 = storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
id2 = storage.add_room({"fingerprint": "b", "title": "B"})
|
||||
storage.add_exit(id1, id2, "north")
|
||||
assert storage.graph.has_edge(id1, id2)
|
||||
|
||||
def test_exploration_queue(self, storage):
|
||||
rid = storage.add_room({"fingerprint": "a", "title": "A"})
|
||||
storage.enqueue_unexplored(rid, "north", priority=3)
|
||||
storage.enqueue_unexplored(rid, "south", priority=5)
|
||||
assert storage.exploration_queue_size() == 2
|
||||
|
||||
result = storage.dequeue_next_exploration()
|
||||
assert result == (rid, "north") # lower priority number = higher priority
|
||||
assert storage.exploration_queue_size() == 1
|
||||
|
||||
|
||||
class TestWorldMapper:
|
||||
@pytest.fixture
|
||||
def mapper(self, tmp_path):
|
||||
db = tmp_path / "map.db"
|
||||
m = WorldMapper(db_path=str(db))
|
||||
yield m
|
||||
m.close()
|
||||
|
||||
def test_on_room_enter(self, mapper):
|
||||
rid = mapper.on_room_enter(
|
||||
title="Town Square",
|
||||
description="A big square.",
|
||||
exits=["north", "south"],
|
||||
npcs=["guard"],
|
||||
)
|
||||
assert rid > 0
|
||||
assert mapper.total_rooms == 1
|
||||
assert mapper.current_room_id == rid
|
||||
|
||||
def test_linking_rooms_via_movement(self, mapper):
|
||||
r1 = mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move("north")
|
||||
r2 = mapper.on_room_enter("Room B", "Desc B", ["south"])
|
||||
|
||||
# Should have an edge from r1 -> r2 via "north"
|
||||
assert mapper.storage.graph.has_edge(r1, r2)
|
||||
# And reverse edge r2 -> r1 via "south"
|
||||
assert mapper.storage.graph.has_edge(r2, r1)
|
||||
|
||||
def test_pathfinding(self, mapper):
|
||||
r1 = mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move("north")
|
||||
r2 = mapper.on_room_enter("Room B", "Desc B", ["south", "east"])
|
||||
mapper.on_move("east")
|
||||
r3 = mapper.on_room_enter("Room C", "Desc C", ["west"])
|
||||
|
||||
# Path from current (r3) to r1
|
||||
dirs = mapper.find_path(r1)
|
||||
assert dirs is not None
|
||||
assert len(dirs) == 2
|
||||
assert dirs[0] == "west"
|
||||
assert dirs[1] == "south"
|
||||
|
||||
def test_revisit_does_not_duplicate(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
assert mapper.total_rooms == 1
|
||||
|
||||
def test_on_move_failed(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north"])
|
||||
mapper.on_move_failed("north")
|
||||
# Blocked exit should not create a graph edge
|
||||
edges = list(mapper.storage.graph.edges(mapper.current_room_id))
|
||||
assert len(edges) == 0
|
||||
|
||||
def test_exploration_target(self, mapper):
|
||||
mapper.on_room_enter("Room A", "Desc A", ["north", "south"])
|
||||
target = mapper.get_next_exploration_target()
|
||||
assert target is not None
|
||||
assert target["direction"] in ("north", "south")
|
||||
|
||||
|
||||
class TestPathfinder:
|
||||
def test_find_path_and_directions(self):
|
||||
import networkx as nx
|
||||
g = nx.DiGraph()
|
||||
g.add_node(1); g.add_node(2); g.add_node(3)
|
||||
g.add_edge(1, 2, direction="north")
|
||||
g.add_edge(2, 3, direction="east")
|
||||
|
||||
pf = Pathfinder(g)
|
||||
path = pf.find_path(1, 3)
|
||||
assert path == [1, 2, 3]
|
||||
dirs = pf.get_directions(path)
|
||||
assert dirs == ["north", "east"]
|
||||
|
||||
def test_no_path(self):
|
||||
import networkx as nx
|
||||
g = nx.DiGraph()
|
||||
g.add_node(1); g.add_node(2)
|
||||
pf = Pathfinder(g)
|
||||
assert pf.find_path(1, 2) is None
|
||||
Reference in new issue
Block a user