Compare commits

..
3 Commits
Author SHA1 Message Date
truetype 69ba824bc8 Add LLM long-term memory system
- Implement LLMMemory module with SQLite + FTS5
- Add conversation tracking with context awareness
- Track NPC reputation and interactions
- Integrate memory with LLMBrain for contextual replies
- Add comprehensive test suite (13 tests)
- Update settings with memory_db_path config
2026-02-08 21:06:01 +01:00
truetype bd211b6036 Implement phases 3-4 + all modules: decision engine, automapper, character creator, quest system, progression optimizer, and hybrid LLM integration
- Phase 4 (Decision Engine): ActionQueue with 7 priority tiers,
  4 behavior states (Idle/Explore/Combat/Loot), CombatAI with skill
  rotation and flee logic, GoalManager with survival override,
  DecisionEngine state machine integrated into the main game loop

- Phase 3 (Automapper): Complete hybrid SQLite/Memvid architecture
  with WorldMapper orchestrator, room fingerprinting (SHA-256),
  bidirectional exit linking, exploration queue, A* pathfinding,
  HistoricalStorage with JSON fallback, SemanticSearch with keyword
  fallback, SyncManager with configurable triggers

- Character Creator: State machine for automated character creation
  with stat rolling/optimization per class weights, race/class/gender
  selection, reroll logic with configurable thresholds

- Quest System: Quest lifecycle tracking (discover->accept->complete),
  NLP-based objective extraction (kill/fetch/talk/explore), EXP/time
  prioritization, pending kill target tracking

- Progression Optimizer: Grinding spot finder (EXP/hour), equipment
  comparison by weighted stats, skill rotation optimizer (DPS vs
  mana efficiency), session summary statistics

- LLM Integration (hybrid): Async Ollama client with retry/backoff,
  LLMBrain coordinator for quest parsing, social AI, decision advice,
  and ambiguous text parsing fallback. SocialAI for detecting and
  responding to player messages (say/tell/shout/group/trade).
  Graceful degradation when Ollama is unavailable.

All 214 tests passing.
2026-02-08 20:55:53 +01:00
truetype f85c27a742 Add pluggable parser system, perception loop, and project infrastructure
Phase 0 (housekeeping):
- Add .gitignore to protect credentials and runtime files
- Track all project source code, docs, and config

Phase 1 (perception loop):
- Implement pluggable parser architecture (BaseMudParser ABC + ParserFactory)
- Add DeadSoulsParser with regex for HP/MP/SP prompt, room descriptions,
  exits (multiple formats), NPC/item detection, and combat events
- Add OutputBuffer for accumulating text between prompts
- Update GameState with SP support and update_from_prompt/room/combat methods
- Rewire main loop to use parser + buffer for real-time game state updates
- Add reconnection logic to TelnetClient (retry with backoff)
- Remove old TextParser (replaced by pluggable system)
- Add mud_type config field for selecting parser at runtime
- Add comprehensive test suite (50 tests, all passing)
2026-02-08 19:08:59 +01:00
71 changed files with 9702 additions and 134 deletions

No files matched your search

+39
View File
@@ -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
View File
@@ -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.
+134 -134
View File
@@ -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.
+82
View File
@@ -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
View File
@@ -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.
BIN
View File
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/
+151
View File
@@ -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)
+54
View File
@@ -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
View File
@@ -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
View File
@@ -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
+41
View File
@@ -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"
+11
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
"""MUD AI Player - Main package"""
__version__ = "0.1.0"
__author__ = "Your Name"
+3
View File
@@ -0,0 +1,3 @@
"""Configuration management"""
__all__ = ["load_config", "Config"]
+114
View File
@@ -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)
+15
View File
@@ -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"]
+40
View File
@@ -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')]
+210
View File
@@ -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,
)
+23
View File
@@ -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",
]
+176
View File
@@ -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
+103
View File
@@ -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)
+319
View File
@@ -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]
+55
View File
@@ -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())
+166
View File
@@ -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
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
"""Specialized modules package"""
__all__ = []
+5
View File
@@ -0,0 +1,5 @@
"""Character creation module."""
from .creator import CharacterCreator, CreatorConfig, StatRoll, CreationPhase
__all__ = ["CharacterCreator", "CreatorConfig", "StatRoll", "CreationPhase"]
+244
View File
@@ -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"
+44
View File
@@ -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",
]
+175
View File
@@ -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])
+300
View File
@@ -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
+198
View File
@@ -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)
+217
View File
@@ -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)
+182
View File
@@ -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)
+27
View File
@@ -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",
]
+273
View File
@@ -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
+349
View File
@@ -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}",
)
+471
View File
@@ -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
+153
View File
@@ -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}"
+18
View File
@@ -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",
]
+318
View File
@@ -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
+211
View File
@@ -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
+55
View File
@@ -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
+172
View File
@@ -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)
+123
View File
@@ -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
+218
View File
@@ -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())
+15
View File
@@ -0,0 +1,15 @@
"""Progression optimizer module."""
from .optimizer import (
ProgressionOptimizer,
GrindingSpot,
EquipmentPiece,
SkillInfo,
)
__all__ = [
"ProgressionOptimizer",
"GrindingSpot",
"EquipmentPiece",
"SkillInfo",
]
+228
View File
@@ -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
),
}
+19
View File
@@ -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",
]
+298
View File
@@ -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
+3
View File
@@ -0,0 +1,3 @@
"""Utilities package"""
__all__ = ["setup_logger", "HumanBehavior"]
+33
View File
@@ -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)
+56
View File
@@ -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
+8
View File
@@ -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"))
+198
View File
@@ -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
+132
View File
@@ -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
+45
View File
@@ -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
+200
View File
@@ -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
+111
View File
@@ -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]
+78
View File
@@ -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
+125
View File
@@ -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
+249
View File
@@ -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 ") == []
+196
View File
@@ -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")
+48
View File
@@ -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)
+95
View File
@@ -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
+111
View File
@@ -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
+309
View File
@@ -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 ")
+116
View File
@@ -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
+124
View File
@@ -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
+179
View File
@@ -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