fix: JSON repair, WS thread pool, switch to DeepSeek V4 Flash, funnel Lv0

LLM fixes:
- _try_repair_json() — ripara JSON malformato (apici, virgole trailing)
- _strip_json_from_text() — rimuove residui JSON dalla narration
- parse_tool_call() — rimuove troncamento 200 caratteri dal fallback

DM fixes:
- Fallback euristico quando params vuoti (dm.py)
- Estrazione narrazione da dm_override/narrate params.params.text

Prompt (prompts.py):
- Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...)
- Aggiunto vocabolario fantasy obbligatorio

Server (server.py):
- dm_cycle in ThreadPoolExecutor per non bloccare WS ping
- WebSocketDisconnect gestiti
- Rimosso import inesistente db_schema

Frontend (index.html):
- Nascosti tool name dalla chat
- Personaggio -> PG, connessioni aree con nomi

Infrastruttura:
- Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go
- pyproject.toml, Dockerfile, docker-compose.yml, start.sh
- .env auto-load (start.sh)
- Funnel Lv0 (funnel.py) — creazione PG livello 0

Docs: architecture, database, setup, game mechanics, codebase ref
This commit is contained in:
truetype committed 2026-06-28 08:18:24 +02:00
1 parent 6b048dc0cc
commit eacc946868
23 files changed
+1786 -61

No files matched your search

+12
View File
@@ -26,6 +26,18 @@ node_modules/
package-lock.json
package.json
# Environment secrets
.env
.env.*
# Docker
.dockerignore
# Editor temp files
*.swp
*.swo
*~
# OS files
.DS_Store
Thumbs.db
+20
View File
@@ -0,0 +1,20 @@
FROM python:3.14-slim
WORKDIR /app
# System deps
RUN apt-get update && apt-get install -y --no-install-recommends \
sqlite3 \
&& rm -rf /var/lib/apt/lists/*
# Python deps
COPY pyproject.toml ./
RUN pip install --no-cache-dir .
# Project code
COPY src/ src/
COPY data/ data/
EXPOSE 8300
CMD ["python", "-m", "src.web.server"]
+21
View File
@@ -0,0 +1,21 @@
version: "3.9"
services:
dcc-rpg:
build: .
container_name: dcc-rpg
ports:
- "8300:8300"
volumes:
- ./data:/app/data
environment:
- DCC_LLM_PROVIDER=${DCC_LLM_PROVIDER:-local}
- DCC_OPENROUTER_KEY=${DCC_OPENROUTER_KEY:-}
- DCC_OPENROUTER_MODEL=${DCC_OPENROUTER_MODEL:-openrouter/free}
- DCC_OLLAMA_URL=${DCC_OLLAMA_URL:-http://192.168.0.50:11434}
- DCC_OLLAMA_MODEL=${DCC_OLLAMA_MODEL:-gemma2:9b}
- DCC_LOCAL_URL=${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}
- DCC_LOCAL_KEY=${DCC_LOCAL_KEY:-}
- DCC_LOCAL_MODEL=${DCC_LOCAL_MODEL:-deepseek-v4-flash}
- DCC_LLM_TIMEOUT=${DCC_LLM_TIMEOUT:-180}
restart: unless-stopped
+82
View File
@@ -0,0 +1,82 @@
# DCC RPG — AI Dungeon Master: Documentazione
## Panoramica
Questo progetto implementa un **Dungeon Master virtuale** per il gioco di ruolo
**Dungeon Crawl Classics** (regolamento introduttivo italiano). Il sistema usa un
LLM (Ollama/OpenRouter) per la narrazione e l'interpretazione delle azioni,
mentre un motore Python deterministico gestisce le meccaniche di gioco
(tiri, combattimento, inventario).
**Pattern architetturale**: Neon Scratch Lounge (separazione Reasoning/Execution)
**Avventura**: La Tomba del Mago Guerriero (9 aree)
**Target**: On-premise, CPU-only, singolo giocatore, browser
## Documenti
| File | Contenuto |
|---|---|
| `architecture.md` | Architettura, pattern NSL, data flow, flusso turno |
| `codebase-reference.md` | Tutti i moduli, API pubbliche, entry point |
| `database-schema.md` | Schema SQLite, tabelle, migrazioni, CRUD |
| `setup-guide.md` | Come avviare, configurare, connettere LLM |
| `adventure-data.md` | Struttura dati avventura, aree, mostri, enigmi |
| `game-mechanics.md` | Regole DCC implementate (dadi, classi, combattimento) |
| `development-guide.md` | Come estendere: nuovi tool, provider, aree |
## Stack Tecnologico
| Componente | Tecnologia |
|---|---|
| LLM | Ollama (gemma2:9b) / OpenRouter / OpenAI-compatible |
| Backend | Python 3.10+ + FastAPI + uvicorn |
| Frontend | HTML + CSS + JS vanilla |
| Database | SQLite (WAL mode) |
| HTTP | httpx (LLM calls) |
| WebSocket | FastAPI WebSocket (real-time game) |
## Scheletro del Turno
```
Giocatore "Cerco nella stanza"
→ LLM reasoning (prompt + contesto)
→ LLM risponde: {"tool": "ability_check", "params": {...}, "narration": "..."}
→ Esecuzione tool deterministico
→ Persistenza stato (SQLite)
→ Risposta al giocatore (narrazione + meccaniche)
```
## Struttura Directory
```
rpg/
├── src/
│ ├── engine/ # Core game engine (DM, LLM, tools, dice, DB)
│ └── web/ # FastAPI server + frontend statico
├── data/ # SQLite DB, avventura JSON, tabelle critici
├── docs/ # Design doc originale
├── docs_dev/ # Questa documentazione
├── knowledge/ # 15 file knowledge base DCC
├── input/ # PDF sorgente, reference booklet
├── ocr_full/ # OCR output files
├── graphify-out/ # Grafo concettuale (62 nodi)
└── tests/ # Test (da popolare)
```
## Stato Progetto (CHECKLIST.md)
**Fasi completate:**
- ✅ Pattern architetturale NSL analizzato e documentato
- ✅ OCR pipeline (22 screenshot + 41 pagine PDF = ~58K parole)
- ✅ Pulizia OCR (60 correzioni dadi, 11 testuali)
- ✅ Knowledge Base strutturata (15 file, ~3708 righe)
- ✅ Grafo concettuale (Graphify, 62 nodi, 27 archi)
- ✅ Avventura completata (9 aree + background)
**Fase 0-4 implementata (codice):**
- ✅ Backend core: DM cycle, tool registry, dice engine
- ✅ LLM integration: multi-provider, prompt template, parser
- ✅ Dati adventure machine-readable: JSON aree/mostri/quest
- ✅ DB schema: 8 tabelle, CRUD, migrazioni
- ✅ Frontend: WebSocket, chat, stato personaggio, mappa
- ✅ Heuristic fallback: keyword parser per quando LLM fallisce
+159
View File
@@ -0,0 +1,159 @@
# Adventure Data
**Avventura**: La Tomba del Mago Guerriero
**File**: `data/adventure/avventura.json` (~790 righe)
## Struttura JSON
```json
{
"meta": {
"name": "La Tomba del Mago Guerriero",
"description": "Avventura per personaggi di livello 0/1",
"setting": "dungeon sotterraneo di un antico mago guerriero",
"theme": "Weird Fantasy / sci-fantasy",
"level_range": "0-1",
"warrior_mage_name": "Vorkhul Ksh'tharan",
"warrior_mage_epithet": "Il Vincolato alle Stelle",
"structure": "9 aree numerate (1.1-1.9)",
"mortality_style": "DCC classico: alta mortalita, perdita media 7-8 PG su 15"
},
"areas": [...],
"monsters": [...],
"quests": [...],
"special_items": [...]
}
```
## Aree (9 totali)
| ID | Nome | Collegamenti | PG? | Mostri | Enigmi |
|---|---|---|---|---|---|
| 1.1 | Portale | 1.2 | Sì | - | Allineamento stellare |
| 1.2 | Sala del Guardiano | 1.1, 1.3 | Sì | Statua lanciafiamme | Nome mago guerriero |
| 1.3 | Sala Monumentale | 1.2, 1.4 | Sì | Guardiano spettrale | Colonna centrale (frammento nome) |
| 1.4 | Fucina del Demone-Serpente | 1.3, 1.5 | Sì | Demone-serpente | - |
| 1.5 | Camera della Mente Senza Tempo | 1.4, 1.6 | Sì | - | Prova mentale |
| 1.6 | La Forgia delle Anime | 1.5, 1.7 | Sì | Anima forgiata | - |
| 1.7 | Ossario | 1.6, 1.8 | Sì | Non-morti | - |
| 1.8 | Anticamera del Potere | 1.7, 1.9 | Sì | Golem guardiano | - |
| 1.9 | Sala del Tesoro | 1.8 | Sì | - | Tomo del nome completo |
## Ogni area contiene:
```json
{
"id": "1.1",
"name": "Portale",
"order": 1,
"description": "Corridoio d'ingresso visibile soltanto attraverso il portale stellare...",
"atmosphere": "Aria stantia, polvere millenaria, silenzio pressante...",
"connections": ["1.2"],
"features": [
"Porta rinforzata in ferro con gioielli incastonati",
"Lastricato di pietra",
"Iscrizioni stellari"
],
"puzzles": [
{
"name": "Apertura della Porta",
"description": "...",
"solution": "Attendere 2 ore l'allineamento",
"skill_checks": [
{"skill": "Intelligenza", "dc": 14, "description": "Accorgersi dell'allineamento"}
]
}
],
"traps": [
{
"name": "Luce Bruciante",
"trigger": "Forzare la porta",
"effect": "Luce accecante, 1d8 danni",
"detect_dc": 20,
"damage": "1d8",
"save": {"type": "Riflessi", "dc": 10, "effect_on_save": "danno dimezzato"}
}
],
"monsters": [{"ref_id": "statue_guardian", "count": 1, "tactics": "attacca chi entra"}],
"items": [],
"treasure": []
}
```
## Mostri (stat block)
```json
{
"monsters": [
{
"id": "statue_guardian",
"name": "Statua Guardiana",
"level": 2,
"stats": {
"ac": 16,
"hp": 25,
"attack": "d20+4",
"damage": "1d8+2",
"saves": {"Tempra": 5, "Riflessi": 2, "Volontà": 0},
"move": "nessuno (fissa)"
},
"special": "Lanciafiamme ogni 1d4 round (d8+2 danni, Riflessi CD 12 dimezza)",
"xp": 75,
"loot": []
}
]
}
```
## Quest
```json
{
"quests": [
{
"name": "Il Nome Perduto",
"description": "Scopri il vero nome del mago guerriero per placare le difese del dungeon",
"objectives": [
"Trova il frammento nella Colonna Centrale (area 1.3)",
"Trova il nome completo nel Tomo (area 1.9)",
"Pronuncia il nome nella Sala del Guardiano (area 1.2)"
]
}
]
}
```
## Special Items
```json
{
"special_items": [
{
"id": "tomo_antico",
"name": "Tomo dalla Rilegatura di Pelle",
"location": "1.9",
"description": "Contiene la seconda meta del nome del mago guerriero",
"type": "quest"
}
]
}
```
## Tabelle Critici / Fumble
**File**: `data/crit_fumble_tables.json` (207 righe)
### Tabella Fumble (`fumble`)
- Dado determinato da armatura: nessuna=d4, leggera=d8, media=d12, pesante=d16
- Modificatore: -Fortuna (sfortuna = peggior fumble)
- 17 entries (da ≤0 a 16+)
### Tabelle Critici (`crit_I` → `crit_V`)
- **crit_I**: Personaggi lv0 e Maghi (d4)
- **crit_II**: Ladri, Elfi (d6)
- **crit_III**: Chierici, Halfling, Guerrieri lv1-2, Nani lv1-3 (d8)
- **crit_IV**: Guerrieri lv3-4, Nani lv4-5 (d10)
- **crit_V**: Guerrieri lv5+, Nani lv6+ (d12)
- Modificatore: +Fortuna
Ogni tabella ha ~19 entries su range diverso.
+133
View File
@@ -0,0 +1,133 @@
# Architettura: Neon Scratch Lounge (On-Premise)
## Pattern: Separazione Reasoning/Execution
L'architettura separa nettamente ciò che fa il **LLM** (ragionamento, narrazione)
da ciò che fa il **motore Python** (meccaniche deterministiche, persistenza).
```
┌─────────────────────────────────────────────────┐
│ CLIENT (Web UI) │
│ Chat testuale + pannello stato laterale │
└──────────────────┬──────────────────────────────┘
│ WebSocket / HTTP REST
┌──────────────────▼──────────────────────────────┐
│ LLM ORCHESTRATOR (src/engine/dm.py) │
│ - Mantiene contesto sessione │
│ - Invia prompt: regole + stato + azione │
│ - Riceve: narrazione + tool calls │
│ - Fallback: heuristic parser su errore LLM │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
│ TOOL REGISTRY (src/engine/tools.py) │
│ @tool decorator → registro centralizzato │
│ 10 Approved Tools + dm-override │
└──────────────────┬──────────────────────────────┘
│
┌──────────────────▼──────────────────────────────┐
│ STATO PERSISTENTE (SQLite) │
│ src/engine/db.py — 8 tabelle, CRUD completo │
│ WAL mode, thread-local connections │
└─────────────────────────────────────────────────┘
```
## Flusso del Turno (dm_cycle)
```
dm_cycle(campaign_id, player_action)
│
├─ 1. GameContext.load(campaign_id)
│ Carica da DB: campagna, PG, log, quest, aree
│ → dataclass GameContext
│
├─ 2. _llm_decision(ctx, action)
│ build_prompt(ctx, action) → prompt enorme
│ llm_ask(prompt, system=SYSTEM_PROMPT)
│ parse_tool_call(raw_llm_response) → {tool, params, narration}
│ SE LLM fallisce → fallback_parse(action, chars)
│
├─ 3. tools.run(tool_name, **params)
│ TOOL_REGISTRY[tool_name](**params)
│ → ToolResult dict
│
├─ 4. Persistenza turno
│ db.advance_turn(campaign_id)
│ db.log_turn(campaign_id, turn, action, result)
│
└─ 5. Response dict
{turn, action, tool, params, narration, mechanics, full_output}
```
## Multi-Provider LLM
```
ProviderConfig (env vars)
│
├─ kind = "ollama" → OllamaProvider
│ POST {base_url}/api/generate
│ model: gemma2:9b
│
├─ kind = "openrouter" → OpenRouterProvider
│ POST openrouter.ai/api/v1/chat/completions
│ Fallback automatico a modelli free
│
└─ kind = "local" → LocalProvider
POST {base_url}/chat/completions (OpenAI-compatible)
Per inference server locali (vLLM, TGI, llama.cpp)
```
## Prompt Engineering
### System Prompt (SYSTEM_PROMPT)
- Stile narrazione: sword & sorcery, italiano, 2-3 frasi
- Regole base: CD, CA, tiri salvezza, mighty deed
- Tool disponibili: JSON con parametri + esempi
- Formato output obbligatorio: JSON `{tool, params, narration}`
### Context Builder (build_prompt)
- `format_context(ctx)`: campagna, PG, cronologia, quest, aree
- `format_area_context(area_id)`: descrizione area, atmosfera, features, mostri presenti
- Knowledge base completa via DB (non file statici)
## Heuristic Fallback
Quando il LLM non produce un tool valido:
1. `parse_tool_call()` fallisce → regex JSON + keyword extraction
2. Se ancora fallisce → `heuristic_fallback.parse_action()`
3. Keyword matching: "attacco" → combat, "muovo" → movement, "d20" → roll_dice
4. Fallback finale → dm_override (narrazione pura)
## Frontend
### WebSocket Protocol
- `state`: stato completo campagna (all'entrata + dopo ogni azione)
- `response`: risultato turno (narrazione + tool + meccaniche)
- `action`: azione giocatore (input → WebSocket → dm_cycle)
- `ping/pong`: keepalive
- `error`: messaggio errore
### REST API
- `GET /api/campaigns` — lista campagne
- `GET /api/campaign/{id}` — stato completo
- `POST /api/campaign` — crea nuova campagna
- `POST /api/campaign/{id}/character` — aggiunge PG
- `GET /` — frontend
- `GET /health` — health check
## Key Design Decisions
### Perché SQLite invece di DynamoDB
- Zero dipendenze, on-premise
- WAL mode per concorrenza thread-safe
- Thread-local connections via `threading.local()`
### Perché @tool decorator invece di enum
- Aggiungere nuovo tool = scrivere funzione + decorator
- Nessun'altra modifica richiesta
- Auto-registrazione in TOOL_REGISTRY
### Perché heuristic fallback
- Ollama su CPU è lento e talvolta non rispetta il formato JSON
- Il fallback garantisce che l'azione vada sempre a buon fine
- Keyword parser copre ~80% dei casi d'uso base
+216
View File
@@ -0,0 +1,216 @@
# Codebase Reference
## Directory Layout
```
rpg/
├── src/
│ ├── engine/
│ │ ├── __init__.py # Public API del modulo engine
│ │ ├── config.py # ProviderConfig, load_from_env()
│ │ ├── db.py # SQLite schema, CRUD, adventure data access
│ │ ├── dice.py # DCC dice system (roll, dice_chain)
│ │ ├── dm.py # DM cycle orchestrator + CLI interactive
│ │ ├── game_context.py # GameContext dataclass (campagna + stato)
│ │ ├── heuristic_fallback.py# Keyword parser per fallback LLM
│ │ ├── llm.py # LLM abstraction, ask(), parse_tool_call()
│ │ ├── prompts.py # System prompt, context builder, tool docs
│ │ ├── providers.py # Ollama/OpenRouter/Local providers
│ │ └── tools.py # @tool registry, 10 Approved Tools
│ │
│ └── web/
│ ├── __init__.py
│ ├── server.py # FastAPI app, REST, WebSocket, main()
│ └── static/
│ └── index.html # SPA frontend (888 righe CSS+JS+HTML)
│
├── data/
│ ├── dcc_rpg.db # SQLite database (auto-creato)
│ ├── adventure/
│ │ └── avventura.json # Avventura machine-readable (9 aree)
│ └── crit_fumble_tables.json # Tabelle critici e fumble (207 righe)
│
├── docs/
│ └── plans/
│ └── 2026-06-25-dcc-rpg-design.md # Design doc originale (314 righe)
│
├── docs_dev/ # Documentazione sviluppo (questo)
├── knowledge/ # 15 file KB (regole DCC, ~3708 righe)
├── input/ # PDF sorgente, reference booklet
├── ocr_full/ # OCR output (58K parole)
├── graphify-out/ # Grafo concettuale
└── tests/ # Test (pytest)
├── test_dice.py
├── test_tools.py
├── test_integration.py
└── test_websocket.py
```
## Modulo: `src/engine/config.py`
### `ProviderKind` = `"ollama" | "openrouter" | "local"`
### `ProviderConfig` (dataclass)
| Campo | Default | Env Var |
|---|---|---|
| `kind` | `"openrouter"` | `DCC_LLM_PROVIDER` |
| `ollama_base_url` | `http://192.168.0.50:11434` | `DCC_OLLAMA_URL` |
| `ollama_model` | `gemma2:9b` | `DCC_OLLAMA_MODEL` |
| `openrouter_api_key` | `""` | `DCC_OPENROUTER_KEY` |
| `openrouter_model` | `"openrouter/free"` | `DCC_OPENROUTER_MODEL` |
| `openrouter_free_fallback` | `True` | `DCC_OR_FREE_FALLBACK` |
| `local_base_url` | `http://192.168.0.240/v1` | `DCC_LOCAL_URL` |
| `local_api_key` | `""` | `DCC_LOCAL_KEY` |
| `local_model` | `"big-pickle"` | `DCC_LOCAL_MODEL` |
| `timeout` | `60` | `DCC_LLM_TIMEOUT` |
## Modulo: `src/engine/dice.py`
### `DICE_CHAIN`
```
d3 → d4 → d5 → d6 → d7 → d8 → d10 → d12 → d14 → d16 → d20 → d24 → d30
```
### `SPECIAL_DICE`
`d%` (100), `d100` (100), `d0` (0), `d1` (1), `d2` (2)
### API
| Funzione | Parametri | Returns |
|---|---|---|
| `roll(die, count=1, modifier=0)` | die: str | `RollResult {die, count, modifier, results[], total, crit, fumble, raw}` |
| `dice_chain(die, steps)` | steps: int (+=su, -=giù) | `DiceChainResult {input_die, steps, output_die, chain}` o `ValidationError` |
| `roll_str(raw)` | "2d20+3" | `RollResult` |
| `validate_die(die)` | die: str | `str | None` (normalizzato o None) |
### Regole crit/fumble
- Solo su `d20`: 20 = crit, 1 = fumble
- Critico: moltiplica danno, tiro su tabella critici per classe
- Fumble: tiro su tabella fumble (dado dipende da armatura)
## Modulo: `src/engine/tools.py`
### `@tool` decorator
Ogni funzione decorata viene automaticamente registrata in `TOOL_REGISTRY`.
### `run(tool_name, **kwargs)` — dispatcher centralizzato
Cerca in `TOOL_REGISTRY`, chiama la funzione, cattura eccezioni.
### 10 Approved Tools
| Tool | Funzione | Parametri Chiave |
|---|---|---|
| `roll_dice` | Tira dado/i | `die`, `count`, `modifier` |
| `dice_chain` | Risolve catena dadi | `die`, `steps` |
| `combat` | Ciclo combattimento completo | `character_id`, `target_ac`, `attack_bonus`, `damage_die`, `mighty_deed_die`, `monster_id`, `armor_type` |
| `ability_check` | Prova abilità | `character_id`, `dc`, `modifier` |
| `saving_throw` | Tiro salvezza | `character_id`, `save_type` (Tempra/Riflessi/Volontà), `dc` |
| `movement` | Movimento | `distance_m`, `terrain`, `stealth` |
| `spellcasting` | Incantesimo | `character_id`, `spell_name`, `spell_check`, `dc` |
| `update_inventory` | Gestione inventario | `character_id`, `item`, `action` (add/remove/equip/unequip/list) |
| `award_xp` | Assegna PE | `character_id`, `xp` |
| `dm_override` | DM override | `command`, `params` |
### Tool `combat` — Dettaglio flusso
1. Carica PG e mostro (con PF persistenti da combat_state)
2. Tiro iniziativa (d20)
3. Tiro attacco (d20 + bonus)
4. Mighty Deed (se guerriero, dado d3/d4/d5 aggiuntivo)
5. Crit/Fumble su 20/1 naturale
6. Lookup tabella critici (per classe/livello) o fumble (per armatura)
7. Calcolo danno + danno extra da critico
8. Persistenza PF mostro in combat_state
### Tool `spellcasting` — Tier
| Tiro | Risultato |
|---|---|
| 1 | Fallimento critico (corruzione!) |
| < DC | Fallimento |
| ≤ 11 | Successo minore (effetto base) |
| ≤ 17 | Successo moderato (effetto migliorato) |
| ≤ 19 | Successo maggiore (effetto potente) |
| 20+ | Successo massimo (effetto devastante) |
## Modulo: `src/engine/providers.py`
### Provider Classes
| Classe | Endpoint | Auth |
|---|---|---|
| `OllamaProvider` | `{base_url}/api/generate` | Nessuna |
| `OpenRouterProvider` | `openrouter.ai/api/v1/chat/completions` | Bearer API key |
| `LocalProvider` | `{base_url}/chat/completions` | Bearer key (opzionale) |
### `create_provider(cfg)` → Factory
Legge `cfg.kind`, istanzia provider corretto con parametri appropriati.
## Modulo: `src/engine/dm.py`
### `dm_cycle(campaign_id, player_action)` → ciclo principale
1. `GameContext.load(campaign_id)` — assembla contesto
2. `_llm_decision(ctx, action)` — LLM + fallback
3. `tools.run(tool_name, **params)` — esecuzione
4. `db.advance_turn()` + `db.log_turn()` — persistenza
5. Return response dict
### `interactive(campaign_id)` — CLI interattiva
Loop input/output con prompt `⚔️ », comandi: esci, stato
## Modulo: `src/engine/prompts.py`
### `SYSTEM_PROMPT` — Prompt di sistema (~60 righe)
- Stile narrazione
- Meccaniche base DCC
- Tool disponibili con parametri (json)
- Esempi di chiamata
- Formato risposta obbligatorio
### `build_prompt(ctx, player_action, current_area_id)` → prompt completo
- `format_context(ctx)`: contesto campagna
- `format_area_context(area_id)`: descrizione area attuale
- Azione giocatore
## Modulo: `src/engine/db.py`
Vedi `database-schema.md` per schema completo.
### Lifecycle
1. `init_db()` → init_schema + migrate + populate_from_json
2. Connessioni thread-local con `threading.local()`
3. WAL mode + busy_timeout per concorrenza
## Modulo: `src/web/server.py`
### Entry Point
```bash
python -m src.web.server
# oppure uvicorn src.web.server:app --host 0.0.0.0 --port 8300
```
### REST Endpoints
| Metodo | Path | Descrizione |
|---|---|---|
| GET | `/api/campaigns` | Lista campagne |
| GET | `/api/campaign/{id}` | Stato campagna completo |
| POST | `/api/campaign?name=...` | Crea campagna |
| POST | `/api/campaign/{id}/character?name=...&char_class=...` | Aggiunge PG default |
| GET | `/health` | Health check |
| GET | `/` | Frontend SPA |
### WebSocket `/ws/{campaign_id}`
| Messaggio | Direzione | Descrizione |
|---|---|---|
| `{"type": "action", "payload": "..."}` | Client→Server | Azione giocatore |
| `{"type": "ping"}` | Client→Server | Keepalive |
| `{"type": "state", "campaign": {...}}` | Server→Client | Stato completo |
| `{"type": "response", "turn": N, ...}` | Server→Client | Risultato turno |
| `{"type": "pong"}` | Server→Client | Keepalive response |
| `{"type": "error", "message": "..."}` | Server→Client | Errore |
## File: `src/web/static/index.html`
Single-page application (~888 righe) con:
- **Welcome screen**: lista campagne, creazione nuova
- **Sidebar** (320px): scheda PG (statistiche, HP bar), missioni, mappa aree
- **Chat area**: messaggi player/DM con stili differenziati
- **Input bar**: campo testo + bottone invio
- **WebSocket** con auto-reconnect ogni 3s
- Tema scuro "sword & sorcery" con font Cinzel + Cormorant Garamond
+202
View File
@@ -0,0 +1,202 @@
# Database Schema
**File**: `data/dcc_rpg.db` (auto-creato)
**Engine**: SQLite 3 con WAL mode
**Connessione**: Thread-local via `threading.local()`, check_same_thread=False
## Tabelle
### `campaigns` — Campagne di gioco
```sql
CREATE TABLE campaigns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
adventure TEXT,
turn INTEGER NOT NULL DEFAULT 0,
current_area_id TEXT DEFAULT '',
current_area_name TEXT DEFAULT '',
created_at TEXT NOT NULL DEFAULT (datetime('now'))
);
```
### `characters` — Personaggi giocanti
```sql
CREATE TABLE characters (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
level INTEGER NOT NULL DEFAULT 0,
char_class TEXT,
alignment TEXT CHECK(alignment IN ('Legge','Caos','Neutralità')),
occupation TEXT,
str INTEGER NOT NULL DEFAULT 10,
agi INTEGER NOT NULL DEFAULT 10,
sta INTEGER NOT NULL DEFAULT 10,
per INTEGER NOT NULL DEFAULT 10,
int INTEGER NOT NULL DEFAULT 10,
lck INTEGER NOT NULL DEFAULT 10,
hp_current INTEGER NOT NULL DEFAULT 1,
hp_max INTEGER NOT NULL DEFAULT 1,
ac INTEGER NOT NULL DEFAULT 10,
armor_type TEXT DEFAULT 'none',
luck_current INTEGER NOT NULL DEFAULT 10,
xp INTEGER NOT NULL DEFAULT 0,
weapon TEXT,
equipment TEXT DEFAULT '[]',
status TEXT NOT NULL DEFAULT 'alive'
CHECK(status IN ('alive','unconscious','dead'))
);
CREATE INDEX idx_char_campaign ON characters(campaign_id);
```
### `adventure_state` — Stato esplorazione aree
```sql
CREATE TABLE adventure_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
area_id TEXT NOT NULL,
visited INTEGER NOT NULL DEFAULT 0,
state TEXT DEFAULT '{}'
);
CREATE UNIQUE INDEX idx_area_campaign ON adventure_state(campaign_id, area_id);
```
Il campo `state` contiene JSON con lo stato di enigmi/trappole risolte per l'area.
### `combat_state` — Stato combattimento (PF persistenti mostri)
```sql
CREATE TABLE combat_state (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
monster_id TEXT NOT NULL,
monster_name TEXT NOT NULL DEFAULT '',
current_hp INTEGER NOT NULL,
max_hp INTEGER NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active', 'defeated', 'fled')),
UNIQUE(campaign_id, monster_id)
);
```
### `character_items` — Inventario PG
```sql
CREATE TABLE character_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
character_id INTEGER NOT NULL REFERENCES characters(id),
item_def_id TEXT,
name TEXT NOT NULL,
quantity INTEGER DEFAULT 1,
equipped INTEGER DEFAULT 0,
properties TEXT DEFAULT '{}'
);
```
### `quests` — Missioni attive/completate/fallite
```sql
CREATE TABLE quests (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
name TEXT NOT NULL,
description TEXT,
status TEXT NOT NULL DEFAULT 'active'
CHECK(status IN ('active','completed','failed'))
);
```
### `turn_log` — Log azioni e risultati
```sql
CREATE TABLE turn_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
campaign_id INTEGER NOT NULL REFERENCES campaigns(id),
turn INTEGER NOT NULL,
character_id INTEGER REFERENCES characters(id),
action TEXT,
result TEXT,
timestamp TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE INDEX idx_turnlog_campaign ON turn_log(campaign_id, turn);
```
### Tabelle di sistema: critici e fumble
```sql
CREATE TABLE crit_fumble_tables (
id TEXT PRIMARY KEY, -- "fumble", "crit_I"..."crit_V"
name TEXT NOT NULL,
description TEXT,
table_type TEXT NOT NULL CHECK(table_type IN ('crit', 'fumble')),
die TEXT,
modifier TEXT
);
CREATE TABLE crit_fumble_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
table_id TEXT NOT NULL REFERENCES crit_fumble_tables(id),
roll_min INTEGER,
roll_max INTEGER,
result_text TEXT NOT NULL
);
CREATE INDEX idx_entries_table ON crit_fumble_entries(table_id);
CREATE TABLE fumble_armor_die (
armor_type TEXT PRIMARY KEY, -- "none", "light", "medium", "heavy"
die TEXT NOT NULL
);
```
## Migrazioni
Le migrazioni sono gestite tramite `_migrate_schema()` chiamata a ogni avvio.
Ogni migrazione è un ALTER TABLE avvolto in try/except (se la colonna esiste già, skippa).
Migrazioni attuali:
```python
migrations = [
"ALTER TABLE campaigns ADD COLUMN current_area_id TEXT DEFAULT ''",
"ALTER TABLE campaigns ADD COLUMN current_area_name TEXT DEFAULT ''",
]
```
## Popolazione Iniziale
`populate_from_json()` carica i dati da `data/crit_fumble_tables.json`:
- 6 tabelle (fumble + crit_I → crit_V)
- 112 entries totali
- 4 mappature armatura→dado fumble
`seed_adventure_state()` e `seed_quests_from_adventure()` caricano aree e quest da `avventura.json`.
## CRUD Pubbliche (`db.py`)
| Funzione | Descrizione |
|---|---|
| `init_db()` | Schema + migrazioni + popolamento |
| `lookup_crit_fumble(table_id, roll, luck_mod=0)` | Cerca risultato crit/fumble con modificatore |
| `get_fumble_die(armor_type)` | Dado fumble per tipo armatura |
| `create_campaign(name, adventure)` | Nuova campagna |
| `get_campaign(id)` | Campagna by ID |
| `create_character(campaign_id, name, class, occupation)` | Nuovo PG |
| `get_characters(campaign_id)` | Tutti i PG di una campagna |
| `get_character(id)` | PG by ID |
| `update_character(id, **kwargs)` | Update qualsiasi campo |
| `advance_turn(campaign_id)` | Incrementa turno |
| `log_turn(...)` | Logga azione |
| `get_log(campaign_id, limit)` | Ultimi N turni |
| `set_area_state(...)` | Marca area visitata/stato |
| `set_puzzle_state(...)` | Stato enigma (solved/unsolved) |
| `is_puzzle_solved(...)` | Check enigma |
| `get_area_state(campaign_id)` | Stato tutte le aree |
| `set_current_area(...)` | Imposta area corrente PG |
| `get_current_area(campaign_id)` | Area corrente PG |
| `set_combat_state(...)` | Inizializza mostro in combattimento |
| `update_combat_hp(...)` | Danno mostro, status defeated |
| `get_combat_state(campaign_id)` | Stato combattimento |
| `add_character_item(...)` | Aggiunge oggetto (stacking) |
| `remove_character_item(...)` | Rimuove oggetto |
| `get_character_items(...)` | Inventario PG |
| `equip_character_item(...)` | Equipaggia/Unequipaggia |
| `get_all_areas()` | Tutte le aree da JSON |
| `get_area_by_id(id)` | Area specifica |
| `get_monster_by_id(id)` | Stat block mostro |
| `get_monsters_in_area(area_id)` | Mostri in area con stats |
| `format_area_context(area_id)` | Testo area per prompt LLM |
| `format_all_areas_context()` | Tutte le aree per prompt iniziale |
+212
View File
@@ -0,0 +1,212 @@
# Development Guide
## Come Aggiungere un Nuovo Tool
1. Apri `src/engine/tools.py`
2. Scrivi una funzione con `@tool` decorator:
```python
@tool
def perception_check(character_id: int, dc: int, modifier: int = 0) -> ToolResult:
char = storage.get_character(character_id)
if not char:
return {"success": False, "tool": "perception-check", "message": "PG non trovato."}
atk = dice.roll("d20", modifier=modifier)
passed = atk["total"] >= dc
return {
"success": True,
"tool": "perception-check",
"character_id": character_id,
"roll": atk["total"],
"dc": dc,
"passed": passed,
"message": f"Percezione: d20+{modifier} → {atk['total']} vs CD {dc} → {'Successo!' if passed else 'Fallimento.'}",
}
```
3. Aggiungi il tool a `_KNOWN_TOOLS` in `src/engine/prompts.py`:
```python
"perception_check": {"character_id": "id pg", "dc": "CD percezione", "modifier": "bonus/penalità"},
```
4. Aggiungi un esempio a `TOOL_EXAMPLES` in `prompts.py`:
```python
- Tiro percezione CD 12 → {"tool": "perception_check", "params": {"character_id": 1, "dc": 12}, "narration": "Tendi l'orecchio..."}
```
5. **Fatto.** Il tool viene automaticamente registrato in `TOOL_REGISTRY`.
## Come Aggiungere un Provider LLM
1. Apri `src/engine/providers.py`
2. Crea una nuova classe che estende `LLMProvider`:
```python
class AnthropicProvider(LLMProvider):
def __init__(self, api_key: str, model: str, timeout: int = 60):
self.api_key = api_key
self.model = model
self.timeout = timeout
def ask(self, prompt: str, system: str = "") -> str:
# Implementa chiamata API Anthropic qui
...
```
3. Registra in `_registry`:
```python
_registry = {
"ollama": OllamaProvider,
"openrouter": OpenRouterProvider,
"local": LocalProvider,
"anthropic": AnthropicProvider, # <-- aggiungi
}
```
4. Aggiungi il case in `create_provider()`:
```python
if cfg.kind == "anthropic":
return cls(cfg.anthropic_api_key, cfg.anthropic_model, cfg.timeout)
```
5. Aggiungi configurazione in `config.py`:
```python
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-20250514"
```
## Come Aggiungere una Nuova Area all'Avventura
1. Apri `data/adventure/avventura.json`
2. Aggiungi area all'array `areas`:
```json
{
"id": "1.10",
"name": "Biblioteca Arcana",
"order": 10,
"description": "...",
"atmosphere": "...",
"connections": ["1.9"],
"features": ["Scaffali polverosi", "Globo luminoso fluttuante"],
"puzzles": [...],
"traps": [],
"monsters": [{"ref_id": "ancient_spirit", "count": 1, "tactics": "...", "ambush": false}],
"items": [
{"id": "scroll_of_truth", "name": "Pergamena della Verità", "description": "...", "type": "consumable", "magical": true}
],
"treasure": []
}
```
3. Aggiungi stat block del mostro in `monsters`:
```json
{
"id": "ancient_spirit",
"name": "Spirito Antico",
"level": 3,
"stats": {"ac": 14, "hp": 18, "attack": "d20+3", "damage": "1d6+1", "saves": {"Tempra": 2, "Riflessi": 4, "Volontà": 6}, "move": "9m (volando)"},
"special": "Intangibile, colpito solo da magia o argento",
"xp": 100,
"loot": []
}
```
4. Riavvia il server. La nuova area viene caricata automaticamente.
## Come Aggiungere una Tabella Crit/Fumble
1. Aggiungi entry a `data/crit_fumble_tables.json`
2. Riavvia il server (ricarica da JSON se DB vuoto)
3. Se il DB è già popolato, cancella `data/dcc_rpg.db` (verrà ricreato)
## Test
```bash
# Test dadi
python src/engine/dice.py
# Test tools (con setup DB)
python src/engine/tools.py
# Test LLM parsing
python src/engine/llm.py
# Test DB completo
python src/engine/db.py
# Avvia CLI interattiva
python -c "
from src.engine import dm, db
db.init_db()
camp_id = db.create_campaign('Test', 'Avventura')
char_id = db.create_character(camp_id, 'Eroe', 'guerriero', 'soldato')
db.update_character(char_id, level=1, str=15, agi=11, sta=14, per=9, int=8, lck=10, hp_current=12, hp_max=12, ac=15)
dm.interactive(camp_id)
"
```
### Test esistenti (`tests/`)
- `test_dice.py` — test modulo dadi (pytest)
- `test_tools.py` — test tool execution
- `test_integration.py` — test flusso dm_cycle
- `test_websocket.py` — test WebSocket lifecycle
Per eseguire:
```bash
python -m pytest tests/ -v
```
## Known LSP Issues (non bloccanti)
Il LSP segnala questi errori, ma non impediscono l'esecuzione:
| File | Errore | Causa |
|---|---|---|
| `db.py:762` | `Object of type "None" is not subscriptable` | `cursor.fetchone()` in `get_current_area` può restituire None |
| `test_dice.py:47` | `output_die not in ValidationError` | `DiceChainResult` e `ValidationError` condividono TypedDict — meglio `@dataclass` |
| `tests/test_websocket.py:8` | `Import pytest could not be resolved` | pytest non installato nell'ambiente LSP |
| `game_context.py:29` | `get_current_area not in db` | LSP non riconosce funzioni dinamicamente caricate |
## Estendere il Frontend
Il frontend è un singolo file HTML (`src/web/static/index.html`) con:
- CSS: tema scuro sword & sorcery con variabili CSS
- JS: ~300 righe di vanilla JS (nessun framework)
- WebSocket: connessione persistente, auto-reconnect
Per modificare:
- **Stile**: modifica le variabili CSS in `:root` e classi dei componenti
- **Nuova sezione sidebar**: aggiungi div in HTML + funzione render in JS
- **Nuovo tipo di messaggio**: aggiungi case a `handleMessage()`
## Guida alla Conoscenza Base (knowledge/)
I 15 file in `knowledge/` contengono le regole DCC in formato markdown italiano.
Non sono usati direttamente dal runtime (i dati sono in DB/JSON/code),
ma sono la fonte di verità per lo sviluppo.
Per rigenerare prompt alla luce delle regole:
1. Consulta i file `knowledge/01_meccanica_base.md` → `knowledge/15_dadi.md`
2. Adatta `prompts.py` e `tools.py` di conseguenza
## Checklist Prossimi Miglioramenti
**Priorità alta:**
- [ ] Aggiungere requirements.txt o pyproject.toml
- [ ] Aggiungere Dockerfile + docker-compose.yml
- [ ] Script start.sh per avvio con un comando
- [ ] Supporto per più PG contemporaneamente
- [ ] Persistenza PF multipli mostri per area
**Priorità media:**
- [ ] Test completi per ogni tool
- [ ] Tavola incantesimi dal Reference Booklet
- [ ] Abilità ladro complete (dado dedicato per livello)
- [ ] Scacciare non-morti (Chierico)
- [ ] Equipaggiamento e armi dal knowledge base
**Priorità bassa:**
- [ ] OCR Core Rulebook (498 pagine immagini, ~8h stimate)
- [ ] RAG + embedding per knowledge base DCC
- [ ] Multi-giocatore (più PG in contemporanea)
- [ ] Inventory drag & drop nel frontend
- [ ] Mappa interattiva delle aree
+98
View File
@@ -0,0 +1,98 @@
# Game Mechanics (DCC RPG)
## Sistema Base
### D20 + CD
- CD 12 = facile, CD 15 = media, CD 18 = difficile
- Tiro = d20 + modificatore ≥ CD → successo
- 20 naturale = critico (successo automatico)
- 1 naturale = fumble (fallimento automatico)
### Dadi del Gioco (DCC Dice Chain)
```
d3 → d4 → d5 → d6 → d7 → d8 → d10 → d12 → d14 → d16 → d20 → d24 → d30
```
Usata per progressioni e meccaniche speciali.
## Classi Implementate (7)
| Classe | Dado Vita | Dado Cimento | Abilità Speciale | Critico |
|---|---|---|---|---|
| Guerriero | d8 | d3→d5 | Mighty Deed (d3→d5+), bonus attacco | IV/V |
| Nano | d10 | d3→d5 | Resistenza veleno,感知 trappole | III/IV/V |
| Elfo | d6 | d4→d7 | Infravisione, percezione porte segrete | II |
| Halfling | d6 | d4→d7 | Resistenza, abilità nascondersi | III |
| Ladro | d6 | d4→d7 | Abilità ladro (furtivo, scassinare, ecc.) | II |
| Chierico | d8 | d4→d7 | Scacciare non-morti, incantesimi divini | III |
| Mago | d4 | d4→d7 | Incantesimi arcani, familiar | I |
| Lv0 | d4 | — | Nessuna | I |
### Mighty Deed (Guerriero)
Prima dell'attacco, dichiara un'impresa (es. "sbalzare lo scudo", "accecare").
Tira d3 (lv1-4), poi d4/d5 a lv superiori.
Se deed ≥ 3, attacco colpisce automaticamente SE l'attacco normale colpisce.
Se deed = risultato massimo, effetto speciale narrativo.
## Combattimento
### Flusso
1. Iniziativa: d20 (più alto → primo)
2. Attacco: d20 + bonus attacco vs CA bersaglio
3. Danno: dado arma + bonus (modificatori)
4. PF: sottratti, mostro morto se PF ≤ 0
### CA e Armature
| Armatura | CA | Dado Fumble |
|---|---|---|
| Nessuna | 10 | d4 |
| Leggera (cuoio) | 12 | d8 |
| Media (maglia) | 14 | d12 |
| Pesante (piastre) | 16 | d16 |
| Scudo | +1 CA | — |
### Tiri Salvezza
- **Tempra**: veleno, malattia, resistenza fisica
- **Riflessi**: trappole, esplosioni, schivare
- **Volontà**: magia, paura, illusione
## Incantesimi
### Spell Check
Tiro = d20 + bonus (non modificatore Intelligenza — è un tiro speciale)
| Tiro | Tier | Effetto |
|---|---|---|
| 1 | Corruzione | Fallimento critico + corruzione/ritorsione |
| < CD | Fallimento | Nessun effetto |
| ≤ 11 | Minore | Effetto base |
| ≤ 17 | Moderato | Effetto migliorato |
| ≤ 19 | Maggiore | Effetto potente |
| 20+ | Massimo | Effetto devastante |
### Corruzione
- Su tiro 1 (sempre)
- Su tiro ≥ 12 se d20 subsequente = 1
- Può causare: mutazioni, danni, follia
## PE e Avanzamento
| Risultato | PE |
|---|---|
| Sconfiggere nemico | 50-75 |
| Superare prova difficile | 10-20 |
| Soluzione creativa | 25-50 |
| Sopravvivenza area | 30-50 |
| Avanzamento lv0 → lv1 | 10 PE (automatico) |
## Classi e Abilità Speciali
### Abilità Ladro (dado dedicato)
- Furtività, Scassinare, Percezione, Acrobazia
- Uso dado abilità (d4→d7 a seconda del livello)
### Scacciare Non-Morti (Chierico)
Tiro d20 + bonus (Volontà ?) vs dado vita del non-morto.
Successo: fuga o distruzione.
### Infravisione (Nano, Elfo, Halfling)
Vedere nel buio fino a 18m (Nano), 9m (Elfo/Halfling).
+145
View File
@@ -0,0 +1,145 @@
# Setup Guide
## Prerequisiti
- Python 3.10+
- Ollama (opzionale, per LLM locale) OPPURE una API key di OpenRouter
- Browser moderno
## Installazione
### 1. Clona il repository
```bash
git clone <repo-url> rpg
cd rpg
```
### 2. Crea ambiente virtuale
```bash
python3 -m venv .venv
source .venv/bin/activate
```
### 3. Installa dipendenze
```bash
pip install fastapi uvicorn httpx
```
## Configurazione LLM
### Opzione A: Ollama (locale, CPU-only)
```bash
# Installa Ollama (se non già presente)
curl -fsSL https://ollama.com/install.sh | sh
# Scarica il modello
ollama pull gemma2:9b
```
Configurazione (tramite env var):
```bash
export DCC_LLM_PROVIDER=ollama
export DCC_OLLAMA_URL=http://192.168.0.50:11434 # default
export DCC_OLLAMA_MODEL=gemma2:9b # default
```
### Opzione B: OpenRouter
```bash
export DCC_LLM_PROVIDER=openrouter
export DCC_OPENROUTER_KEY=sk-or-v1-...
export DCC_OPENROUTER_MODEL=openrouter/free # default
```
### Opzione C: OpenAI-compatible (LLM server locale)
```bash
export DCC_LLM_PROVIDER=local
export DCC_LOCAL_URL=http://<host>:<port>/v1
export DCC_LOCAL_KEY=...
export DCC_LOCAL_MODEL=<model-name>
```
## Avvio
### Sviluppo
```bash
python -m src.web.server
```
Il server parte su `http://0.0.0.0:8300`.
Oppure direttamente con uvicorn:
```bash
uvicorn src.web.server:app --host 0.0.0.0 --port 8300 --reload
```
### Prima campagna
1. Apri `http://localhost:8300` nel browser
2. Inserisci nome campagna e clicca "Crea"
3. Verrà creato automaticamente un PG di default (guerriero lv1)
4. Inizia a giocare! Scrivi "Cerco nella stanza" o "Attacco" o "Esploro"
## CLI Interattiva (senza browser)
Per test rapidi senza browser:
```bash
python -c "
from src.engine import dm, db
db.init_db()
camp_id = db.create_campaign('Test', 'Tomba del Mago-Guerriero')
char_id = db.create_character(camp_id, 'Marco il Fidato', 'guerriero', 'soldato')
db.update_character(char_id, level=1, str=15, agi=11, sta=14, per=9, int=8, lck=10, hp_current=12, hp_max=12, ac=15)
dm.interactive(camp_id)
"
```
## Health Check
```bash
curl http://localhost:8300/health
# → {"status":"ok","provider":"ollama"}
```
## Environment Variables (riepilogo)
| Variabile | Default | Descrizione |
|---|---|---|
| `DCC_LLM_PROVIDER` | `"openrouter"` | Provider LLM: ollama, openrouter, local |
| `DCC_OLLAMA_URL` | `http://192.168.0.50:11434` | URL server Ollama |
| `DCC_OLLAMA_MODEL` | `"gemma2:9b"` | Modello Ollama |
| `DCC_OPENROUTER_KEY` | `""` | API key OpenRouter |
| `DCC_OPENROUTER_MODEL` | `"openrouter/free"` | Modello OpenRouter |
| `DCC_OR_FREE_FALLBACK` | `"1"` | Fallback a modelli free |
| `DCC_LOCAL_URL` | `http://192.168.0.240/v1` | URL server locale |
| `DCC_LOCAL_KEY` | `""` | API key server locale |
| `DCC_LOCAL_MODEL` | `"big-pickle"` | Modello locale |
| `DCC_LLM_TIMEOUT` | `60` | Timeout chiamate LLM (s) |
## Troubleshooting
### "Nessuna campagna" all'avvio
- Creare una campagna dal frontend (form "Crea")
- Il database viene creato automaticamente
### LLM non risponde
- Verificare che Ollama sia in esecuzione: `ollama list`
- Verificare timeout: potrebbe servire `DCC_LLM_TIMEOUT=120`
- Il fallback heuristico interviene automaticamente se LLM fallisce
### WebSocket non si connette
- Verificare che il server sia raggiungibile
- Il frontend ha auto-reconnect ogni 3 secondi
### "pip install" bloccato da PEP 668
Usare ambiente virtuale (`.venv`) o:
```bash
pip install --break-system-packages fastapi uvicorn httpx
```
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 462 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 503 KiB

+23
View File
@@ -0,0 +1,23 @@
[project]
name = "dcc-rpg"
version = "0.1.0"
description = "Dungeon Crawl Classics RPG — AI Dungeon Master"
requires-python = ">=3.12"
license = "MIT"
authors = []
dependencies = [
"fastapi>=0.115",
"uvicorn>=0.30",
"httpx>=0.27",
"pydantic>=2.0",
"websockets>=12",
"jinja2>=3.0",
"sqlite-vec>=0.1",
]
[project.scripts]
dcc-rpg = "src.web.server:main"
[tool.setuptools.packages.find]
include = ["src*"]
+3 -3
View File
@@ -15,10 +15,10 @@ class ProviderConfig:
openrouter_model: str = "openrouter/free"
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
openrouter_free_fallback: bool = True
local_base_url: str = "http://192.168.0.240/v1"
local_base_url: str = "https://opencode.ai/zen/go/v1"
local_api_key: str = ""
local_model: str = "big-pickle"
timeout: int = 60
local_model: str = "deepseek-v4-flash"
timeout: int = 120
def load_from_env() -> ProviderConfig:
+15
View File
@@ -31,6 +31,21 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
log.info("LLM non ha prodotto tool valido, fallback heuristico")
tool, params = fallback_parse(player_action, ctx.characters)
# Se il JSON era malformato e params sono vuoti, usa l'euristico per i parametri
elif not params and tool not in ("dm_override",):
log.info("Tool %s senza params, integro con fallback euristico", tool)
_, fallback_params = fallback_parse(player_action, ctx.characters)
params = fallback_params
# dm_override/narrate: il LLM spesso mette il testo in params.params.text
# invece che nel campo narration JSON — lo estraiamo qui
if tool == "dm_override" and not narration:
inner = params.get("params", {}) if isinstance(params, dict) else {}
if isinstance(inner, dict):
text = inner.get("text", "")
if text:
narration = text
return tool, params, narration
+242
View File
@@ -0,0 +1,242 @@
"""
Funnel DCC — Generazione personaggi livello 0.
Ogni giocatore crea 3-4 PG lv0 da portare nel funnel.
I sopravvissuti salgono a lv1 e scelgono la classe.
"""
from __future__ import annotations
import random
from typing import Any
# Tabella 1-3: Mestieri (d100)
# (tiro_min, tiro_max, nome, arma, bene)
OCCUPATIONS: list[tuple[int, int, str, str, str]] = [
(1, 1, "Alchimista", "Bastone", "Olio, 1 fiaschetta"),
(2, 2, "Addestratore di animali", "Mazza", "Pony"),
(3, 3, "Armaiolo", "Mazza (martello)", "Elmo di ferro"),
(4, 4, "Astrologo", "Pugnale", "Cannocchiale"),
(5, 5, "Barbiere", "Pugnale (rasoio)", "Forbici"),
(6, 6, "Sagrestano", "Bastone", "Simbolo sacro"),
(7, 7, "Apicoltore", "Bastone", "Vasetto di miele"),
(8, 8, "Fabbro", "Mazza (martello)", "Tenaglie d'acciaio"),
(9, 9, "Macellaio", "Ascia (mannaia)", "Mezzena di manzo"),
(10, 10, "Guardia di carovana", "Spada corta", "Lino, 1 iarda"),
(11, 11, "Casaro", "Bastone (randello)", "Formaggio puzzolente"),
(12, 12, "Calzolaio", "Pugnale (lesina)", "Calzascarpe"),
(13, 13, "Truffatore", "Pugnale", "Mantello di qualità"),
(14, 14, "Bottaio", "Mazza (piede di porco)", "Botte"),
(15, 15, "Fruttivendolo ambulante", "Pugnale (coltello)", "Frutta"),
(16, 16, "Tagliaborse", "Pugnale", "Piccolo forziere"),
(17, 17, "Sterratore", "Bastone (pala)", "Terra fine, 1 libbra"),
(18, 18, "Lavoratore portuale", "Bastone (palo)", "1 libro GdR in ritardo"),
(19, 19, "Speziale nano", "Bastone (randello)", "Fiala d'acciaio"),
(20, 20, "Fabbro nano", "Mazza (martello)", "Mithril, 1 oncia"),
(21, 21, "Cassettaio nano", "Pugnale (scalpello)", "Legno, 10 libbre"),
(22, 22, "Mandriano nano", "Bastone", "Scrofa"),
(23, 24, "Minatore nano", "Mazza (piccone)", "Lanterna"),
(25, 25, "Coltivatore di funghi nano", "Bastone (pala)", "Sacco"),
(26, 26, "Acchiappatopi nano", "Mazza", "Rete"),
(27, 28, "Scalpellino nano", "Martello", "Pietra pregiata, 10 libbre"),
(29, 29, "Artigiano elfico", "Bastone", "Argilla, 1 libbra"),
(30, 30, "Avvocato elfico", "Dardo (penna d'oca)", "Libro"),
(31, 31, "Candelaio elfico", "Pugnale (forbici)", "Candele, 20"),
(32, 32, "Falconiere elfico", "Pugnale", "Falco"),
(33, 34, "Forestale elfico", "Bastone", "Erbe, 1 libbra"),
(35, 35, "Vetraio elfico", "Mazza (martello)", "Perline di vetro"),
(36, 36, "Navigatore elfico", "Arco corto", "Cannocchiale"),
(37, 38, "Saggio elfico", "Pugnale", "Pergamena e penna d'oca"),
(39, 47, "Contadino", "Lancia (forcone)", "Gallina"),
(48, 48, "Indovino", "Pugnale", "Mazzo di tarocchi"),
(49, 49, "Giocatore d'azzardo", "Mazza", "Dadi"),
(50, 50, "Spurgatore di latrine", "Pugnale (cazzuola)", "Sacco di letame notturno"),
(51, 52, "Becchino", "Bastone (pala)", "Cazzuola"),
(53, 54, "Mendicante di gilda", "Fionda", "Stampelle"),
(55, 55, "Macellaio di polli halfling", "Ascia da una mano", "Carne di pollo, 5 libbre"),
(56, 57, "Tintore halfling", "Bastone", "Tessuto, 3 iarde"),
(58, 58, "Guantaio halfling", "Pugnale (lesina)", "Guanti, 4 paia"),
(59, 59, "Errante halfling", "Fionda", "Bambola maledetta"),
(60, 60, "Merciaio halfling", "Pugnale (forbici)", "Vestiti pregiati, 3 completi"),
(61, 61, "Marinaio halfling", "Pugnale (coltello)", "Tela da vele, 2 iarde"),
(62, 62, "Usuraio halfling", "Spada corta", "5 mo, 10 ma, 200 mr"),
(63, 63, "Mercante halfling", "Spada corta", "20 ma"),
(64, 64, "Vagabondo halfling", "Mazza", "Ciotola dell'elemosina"),
(65, 65, "Guaritore", "Mazza", "Acqua santa, 1 fiala"),
(66, 66, "Erborista", "Mazza", "Erbe, 1 libbra"),
(67, 67, "Mandriano", "Bastone", "Cane da pastore"),
(68, 69, "Cacciatore", "Arco corto", "Pelle di cervo"),
(70, 70, "Servo a contratto", "Bastone", "Medaglione"),
(71, 71, "Buffone", "Dardo", "Vestiti di seta"),
(72, 72, "Gioielliere", "Pugnale", "Gemma da 20 mo"),
(73, 73, "Fabbro di serrature", "Pugnale", "Attrezzi di qualità"),
(74, 74, "Frate mendicante", "Mazza", "Salsa di formaggio"),
(75, 75, "Mercenario", "Spada lunga", "Armatura di cuoio"),
(76, 76, "Mercante", "Pugnale", "4 mo, 14 ma, 27 mr"),
(77, 77, "Mugnaio/fornaio", "Mazza", "Farina, 1 libbra"),
(78, 78, "Menestrello", "Pugnale", "Ukulele"),
(79, 79, "Nobile", "Spada lunga", "Anello d'oro da 10 mo"),
(80, 80, "Orfano", "Mazza", "Bambola di pezza"),
(81, 81, "Stalliere", "Bastone", "Briglia"),
(82, 82, "Fuorilegge", "Spada corta", "Armatura di cuoio leggera"),
(83, 83, "Cordaio", "Pugnale (coltello)", "Corda, 100 piedi"),
(84, 84, "Scriba", "Dardo", "Pergamena, 10 fogli"),
(85, 85, "Sciamano", "Mazza ferrata", "Erbe, 1 libbra"),
(86, 86, "Schiavo", "Mazza", "Roccia dall'aspetto strano"),
(87, 87, "Contrabbandiere", "Fionda", "Sacco impermeabile"),
(88, 88, "Soldato", "Lancia", "Scudo"),
(89, 90, "Scudiero", "Spada lunga", "Elmo d'acciaio"),
(91, 91, "Esattore delle tasse", "Spada lunga", "100 mr"),
(92, 93, "Cacciatore di pelli", "Fionda", "Pelle di tasso"),
(94, 94, "Monello", "Mazza (bastoncino)", "Ciotola dell'elemosina"),
(95, 95, "Carraio", "Mazza", "Carriola"),
(96, 96, "Tessitore", "Pugnale", "Vestito pregiato"),
(97, 97, "Apprendista mago", "Pugnale", "Grimorio nero"),
(98, 100, "Boscaiolo", "Ascia da una mano", "Fascina di legna"),
]
# Mappa arma → dado danno per livello 0
WEAPON_DAMAGE: dict[str, str] = {
"Ascia da una mano": "d6",
"Ascia (mannaia)": "d6",
"Arco corto": "d6",
"Bastone": "d4",
"Bastone (pala)": "d4",
"Bastone (palo)": "d4",
"Bastone (randello)": "d4",
"Dardo": "d4",
"Dardo (penna d'oca)": "d4",
"Fionda": "d4",
"Lancia": "d6",
"Lancia (forcone)": "d6",
"Mazza": "d4",
"Mazza (martello)": "d4",
"Mazza (piede di porco)": "d4",
"Mazza (piccone)": "d4",
"Mazza (bastoncino)": "d4",
"Mazza ferrata": "d4",
"Martello": "d4",
"Pugnale": "d4",
"Pugnale (rasoio)": "d4",
"Pugnale (lesina)": "d4",
"Pugnale (coltello)": "d4",
"Pugnale (forbici)": "d4",
"Pugnale (cazzuola)": "d4",
"Pugnale (scalpello)": "d4",
"Spada corta": "d6",
"Spada lunga": "d8",
}
NOMI_MASCHILI = [
"Aldo", "Baldo", "Crispo", "Druso", "Elmo",
"Ferro", "Gallo", "Ivo", "Lupo", "Marco",
"Orso", "Pio", "Rufo", "Sergio", "Tito",
"Ugo", "Vito", "Zeno", "Bruno", "Claudio",
]
NOMI_FEMMINILI = [
"Ada", "Berta", "Clara", "Dora", "Elsa",
"Frida", "Greta", "Ida", "Leda", "Mara",
"Nora", "Olga", "Pia", "Rosa", "Sara",
"Tina", "Una", "Vera", "Zita", "Lena",
]
# Pool di nomi nobili/guerrieri per pg che sopravvivono al funnel
NOMI_EROICI = [
"Lancelet", "Gawain", "Aldric", "Thorne", "Rowan",
"Bran", "Finn", "Kael", "Dorn", "Soren",
]
def tiro_3d6() -> int:
return sum(random.randint(1, 6) for _ in range(3))
def tiro_statistiche() -> dict[str, int]:
return {s: tiro_3d6() for s in ("str", "agi", "sta", "per", "int", "lck")}
def cerca_mestiere(tiro: int) -> tuple[int, int, str, str, str]:
for entry in OCCUPATIONS:
if entry[0] <= tiro <= entry[1]:
return entry
return OCCUPATIONS[-1] # fallback
def genera_pg_lv0(nome: str = "") -> dict[str, Any]:
"""
Genera un personaggio livello 0 completo per il funnel DCC.
Returns:
dict con: nome, mestiere, statistiche, arma, dado_danno, bene, pf_max, pf
"""
tiro_mestiere = random.randint(1, 100)
_, _, mestiere, arma, bene = cerca_mestiere(tiro_mestiere)
if not nome:
nome = f"{random.choice(NOMI_MASCHILI + NOMI_FEMMINILI)} il {mestiere}"
stats = tiro_statistiche()
dado_danno = WEAPON_DAMAGE.get(arma, "d4")
# Lv0 PF = d4 (o come da descrizione mestiere)
pf_max = random.randint(1, 4)
# Un po' di fortuna iniziale = 3d6 come le altre stats
lck_mod = _modificatore_fortuna(stats["lck"])
return {
"name": nome,
"occupation": mestiere,
"level": 0,
"char_class": "",
"weapon": arma,
"weapon_damage": dado_danno,
"belonging": bene,
"str": stats["str"],
"agi": stats["agi"],
"sta": stats["sta"],
"per": stats["per"],
"int": stats["int"],
"lck": stats["lck"],
"luck_mod": lck_mod,
"hp_current": pf_max,
"hp_max": pf_max,
"ac": 10,
"armor_type": "none",
}
def genera_funnel(num_pg: int = 4) -> list[dict[str, Any]]:
"""Genera N personaggi livello 0 per il funnel."""
return [genera_pg_lv0() for _ in range(num_pg)]
def _modificatore_fortuna(lck: int) -> int:
"""Tabella 1-2: Modificatore Fortuna."""
if lck == 3:
return -3
elif lck <= 5:
return -2
elif lck <= 8:
return -1
elif lck <= 12:
return 0
elif lck <= 15:
return 1
elif lck <= 17:
return 2
else: # 18
return 3
if __name__ == "__main__":
print("=== FUNNEL DCC — 4 Personaggi Livello 0 ===\n")
for i, pg in enumerate(genera_funnel(4), 1):
print(f" {i}. {pg['name']}")
print(f" Mestiere: {pg['occupation']}")
print(f" Arma: {pg['weapon']} ({pg['weapon_damage']})")
print(f" Bene: {pg['belonging']}")
print(f" PF: {pg['hp_current']}")
print(f" For:{pg['str']} Agi:{pg['agi']} Sta:{pg['sta']} "
f"Per:{pg['per']} Int:{pg['int']} Luc:{pg['lck']}")
print(f" CA: {pg['ac']}\n")
+4 -2
View File
@@ -45,9 +45,11 @@ class GameContext:
if self.current_area_id:
lines.append(f"Area corrente: {self.current_area_name or self.current_area_id}")
for c in self.characters:
cls = c.get("char_class") or ""
lv = c.get("level", 0)
cls = c.get("occupation") or c.get("char_class") or ""
label = f"{cls} (Lv{lv})" if lv == 0 else f"{c.get('char_class', '')} Lv{lv}"
lines.append(
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
f" [{c['id']}] {c['name']} — {label} "
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
)
if self.quests:
+49 -4
View File
@@ -27,6 +27,30 @@ def ask(prompt: str, system: str = "") -> str:
return get_provider().ask(prompt, system=system)
def _try_repair_json(text: str) -> str | None:
"""Tenta di riparare errori JSON comuni prodotti dagli LLM."""
m = re.search(r'(\{[\s\S]*\})', text)
if not m:
return None
blob = m.group(1)
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params": ")
blob = re.sub(r"(?<!\\)'(params|narration|tool|command|text|character_id|dc|modifier|die|count)"
r"'(?=\s*[:=])", r'"\1":', blob)
# Fix 2: qualsiasi altra chiave con apici singoli
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1":', blob)
# Fix 3: trailing comma prima di } o ]
blob = re.sub(r',\s*}', '}', blob)
blob = re.sub(r',\s*\]', ']', blob)
# Fix 4: = al posto di : nelle chiavi
blob = re.sub(r'"(\w+)"\s*=', r'"\1":', blob)
try:
json.loads(blob)
return blob
except json.JSONDecodeError:
return None
def _extract_json(text: str) -> str | None:
for pat in [
r'```json\s*([\s\S]*?)```',
@@ -40,8 +64,27 @@ def _extract_json(text: str) -> str | None:
json.loads(candidate)
return candidate
except json.JSONDecodeError:
# tenta riparazione
repaired = _try_repair_json(candidate)
if repaired:
return repaired
continue
return None
# try repair on full text too
return _try_repair_json(text)
def _strip_json_from_text(text: str) -> str:
"""Rimuove qualsiasi contenuto simile a JSON dal testo narrativo."""
# toglie blocchi {...} completi di tool call
cleaned = re.sub(r'\{"tool":[\s\S]*?"narration":\s*"[^"]*"\s*\}', '', text)
# toglie blocchi JSON parziali o incompleti
cleaned = re.sub(r'\{"tool":[\s\S]*?\}', '', cleaned)
cleaned = re.sub(r'\{\s*"[a-zA-Z_]+"\s*[:=][^}]*\}', '', cleaned)
# pulisce spaziature multiple
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
# toglie virgolette pendenti all'inizio/fine
cleaned = cleaned.strip('"\' ,.')
return cleaned
def parse_tool_call(text: str) -> dict:
@@ -55,10 +98,12 @@ def parse_tool_call(text: str) -> dict:
except json.JSONDecodeError:
pass
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
# Fallback: estrae tool name via regex, ma PULISCE la narration dal JSON
tool_match = re.search(r'\b(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
narration = _strip_json_from_text(text) if text else ""
if tool_match:
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
return {"tool": tool_match.group(1), "params": {}, "narration": narration}
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": narration or text[:500]}}, "narration": narration}
if __name__ == "__main__":
+6 -1
View File
@@ -44,6 +44,9 @@ STILE:
- Usa ITALIANO.
- Dai sempre una scelta al giocatore alla fine.
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
- Usa VOCABOLARIO FANTASY: pietra, tenebra, fiamma, sangue, ferro, ossa, ombra, polvere, ruggine, muschio, crepitio, sussurro, pulsare, vibrare, brillare, incrinato, logoro, antico, dimenticato.
- VIETATO usare: termini tecnici, scientifici, moderni, anacronistici (niente HPLC, laser, DNA, algoritmi, frequenze, ecc.).
- Niente acronimi, niente linguaggio da laboratorio o ingegneria.
MECCANICHE:
- CD base: 12 = facile, 15 = media, 18 = difficile
@@ -88,7 +91,9 @@ def format_context(ctx: GameContext) -> str:
lines.append("PERSONAGGI:")
for c in ctx.characters:
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
lv = c.get("level", 0)
label = c.get("occupation", "") if lv == 0 else c.get("char_class", "")
bl = f" [{c['id']}] {c['name']} — Lv{lv} {label} ({c.get('alignment', '?')})"
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
lines.extend([bl, stat_str, hp_str])
+79 -27
View File
@@ -1,13 +1,15 @@
from __future__ import annotations
import json
import asyncio
import logging
import os
from pathlib import Path
from concurrent.futures import ThreadPoolExecutor
import uvicorn # noqa: F401 — needed by main()
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from src.engine import db
from src.engine.config import load_from_env
@@ -51,11 +53,16 @@ def get_campaign_state(campaign_id: int):
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
raw_conns = full.get("connections", [])
conn_names = []
for cid in raw_conns:
linked = area_map.get(cid)
conn_names.append(linked["name"] if linked else cid)
enriched.append({
"id": aid,
"name": full.get("name", aid),
"description": full.get("description", "")[:200],
"connections": full.get("connections", []),
"connections": conn_names,
"visited": bool(sa.get("visited")),
"features": full.get("features", [])[:3],
})
@@ -78,32 +85,51 @@ def create_campaign(name: str = "Nuova Avventura", adventure: str = ""):
areas = db.get_all_areas()
if areas:
db.set_area_state(camp_id, areas[0]["id"], visited=True)
return {"campaign_id": camp_id}
for pg in genera_funnel(4):
_create_lv0_character(camp_id, pg)
return {"campaign_id": camp_id, "funnel": True}
def _create_lv0_character(campaign_id: int, pg: dict) -> int:
char_id = db.create_character(
campaign_id,
name=pg["name"],
char_class=f"Lv0 ({pg['occupation']})",
occupation=pg["occupation"],
)
db.update_character(
char_id,
level=0,
str=pg["str"],
agi=pg["agi"],
sta=pg["sta"],
per=pg["per"],
int=pg["int"],
lck=pg["lck"],
hp_current=pg["hp_current"],
hp_max=pg["hp_max"],
ac=pg["ac"],
armor_type=pg["armor_type"],
weapon=pg["weapon"],
equipment=f'["{pg["weapon"]}", "{pg["belonging"]}"]',
)
return char_id
@app.post("/api/campaign/{campaign_id}/character")
def add_character(
campaign_id: int,
name: str = "Eroe",
name: str = "",
char_class: str = "",
occupation: str = "",
):
char_id = db.create_character(campaign_id, name, char_class, occupation)
# Default stats for a lv0/lv1 character
db.update_character(
char_id,
level=1,
str=12,
agi=11,
sta=12,
per=10,
int=10,
lck=10,
hp_current=8,
hp_max=8,
ac=12,
)
return {"character_id": char_id}
if name and char_class:
char_id = db.create_character(campaign_id, name, char_class, occupation)
db.update_character(char_id, level=1, str=12, agi=11, sta=12, per=10, int=10, lck=10, hp_current=8, hp_max=8, ac=12)
return {"character_id": char_id}
pg = genera_pg_lv0(name)
char_id = _create_lv0_character(campaign_id, pg)
return {"character_id": char_id, "level": 0, "name": pg["name"], "occupation": pg["occupation"]}
# ---------------------------------------------------------------------------
@@ -111,6 +137,9 @@ def add_character(
# ---------------------------------------------------------------------------
_executor = ThreadPoolExecutor(max_workers=4)
@app.websocket("/ws/{campaign_id}")
async def game_ws(ws: WebSocket, campaign_id: int):
await ws.accept()
@@ -126,7 +155,11 @@ async def game_ws(ws: WebSocket, campaign_id: int):
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
while True:
raw = await ws.receive_text()
try:
raw = await ws.receive_text()
except WebSocketDisconnect:
break
try:
msg = json.loads(raw)
except json.JSONDecodeError:
@@ -138,7 +171,12 @@ async def game_ws(ws: WebSocket, campaign_id: int):
continue
try:
out = dm_cycle(campaign_id, player_action)
# dm_cycle è bloccante (chiamata HTTP al LLM) —
# la eseguiamo in un thread worker per non bloccare
# il loop asyncio e tenere vivi i ping WebSocket
out = await asyncio.get_event_loop().run_in_executor(
_executor, dm_cycle, campaign_id, player_action
)
await ws.send_json({
"type": "response",
"turn": out["turn"],
@@ -150,13 +188,22 @@ async def game_ws(ws: WebSocket, campaign_id: int):
})
except Exception as e:
log.exception("DM cycle failed")
await ws.send_json({"type": "error", "message": str(e)})
try:
await ws.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
break
# Send updated state after action
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
try:
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
except WebSocketDisconnect:
break
elif msg.get("type") == "ping":
await ws.send_json({"type": "pong"})
try:
await ws.send_json({"type": "pong"})
except WebSocketDisconnect:
break
def _build_state(campaign_id: int) -> dict:
@@ -167,11 +214,16 @@ def _build_state(campaign_id: int) -> dict:
for sa in ctx.areas:
aid = sa["area_id"]
full = area_map.get(aid, {})
raw_conns = full.get("connections", [])
conn_names = []
for cid in raw_conns:
linked = area_map.get(cid)
conn_names.append(linked["name"] if linked else cid)
enriched.append({
"id": aid,
"name": full.get("name", aid),
"visited": bool(sa.get("visited")),
"connections": full.get("connections", []),
"connections": conn_names,
})
return {
"turn": ctx.turn,
+28 -24
View File
@@ -552,7 +552,7 @@ html, body {
<!-- SIDEBAR -->
<div id="sidebar">
<div class="sidebar-section" id="char-section">
<h2>Personaggio</h2>
<h2>PG</h2>
<div id="char-display"><div class="empty-state">Nessun PG</div></div>
</div>
<div class="sidebar-section" id="quest-section">
@@ -750,9 +750,7 @@ function handleMessage(msg) {
if (msg.mechanics) {
bubble += `<div class="msg-dm-mechanics">${escapeHtml(msg.mechanics)}</div>`;
}
if (msg.tool) {
bubble += `<div class="msg-dm-tool">${escapeHtml(msg.tool)}</div>`;
}
// Debug tool name — nascosto all'utente
bubble += '</div>';
div.innerHTML = bubble;
chat.appendChild(div);
@@ -788,26 +786,32 @@ function renderChar(chars) {
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
return;
}
const c = chars[0];
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
charDisplay.innerHTML = `
<div class="char-name">${escapeHtml(c.name)}</div>
<div class="char-class">${c.char_class || 'Lv0'} · Livello ${c.level}</div>
<div class="hp-bar-wrapper">
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
</div>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
</div>
<div class="char-ac">Armatura: <span>${c.ac||'?'}</span></div>
<div class="char-weapon">Arma: <span>${c.weapon || 'nessuna'}</span></div>
`;
charDisplay.innerHTML = chars.map(c => {
const isLv0 = c.level === 0;
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
const label = isLv0
? `${c.occupation || 'Mestiere'} · Lv0`
: `${c.char_class || ''} · Livello ${c.level}`;
return `
<div class="char-card" style="border-bottom:1px solid var(--border);padding:8px 0;">
<div class="char-name">${escapeHtml(c.name)}</div>
<div class="char-class">${escapeHtml(label)}</div>
<div class="hp-bar-wrapper">
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
</div>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
</div>
<div class="char-ac">Arma: <span>${escapeHtml(c.weapon || 'nessuna')}</span> · CA <span>${c.ac||'?'}</span></div>
</div>
`;
}).join('');
}
function renderQuests(quests) {
Executable
+37
View File
@@ -0,0 +1,37 @@
#!/usr/bin/env bash
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
cd "$DIR"
# ── load .env if present ────────────────────────────────────────────
if [[ -f .env ]]; then
set -a; source .env; set +a
fi
# ── provider: openrouter | ollama | local ──────────────────────────
# Per OpenCode API: export DCC_LLM_PROVIDER=local + set DCC_LOCAL_KEY
export DCC_LLM_PROVIDER="${DCC_LLM_PROVIDER:-local}"
export DCC_OPENROUTER_KEY="${DCC_OPENROUTER_KEY:-}"
export DCC_OPENROUTER_MODEL="${DCC_OPENROUTER_MODEL:-openrouter/free}"
export DCC_OLLAMA_URL="${DCC_OLLAMA_URL:-http://192.168.0.50:11434}"
export DCC_OLLAMA_MODEL="${DCC_OLLAMA_MODEL:-gemma2:9b}"
export DCC_LOCAL_URL="${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}"
export DCC_LOCAL_KEY="${DCC_LOCAL_KEY:-}"
export DCC_LOCAL_MODEL="${DCC_LOCAL_MODEL:-deepseek-v4-flash}"
# ── create data dir ────────────────────────────────────────────────
mkdir -p data
# ── install deps if missing ────────────────────────────────────────
if ! pip show fastapi &>/dev/null 2>&1; then
echo "→ Installing Python dependencies..."
pip install --quiet -e .
fi
# ── start server ───────────────────────────────────────────────────
echo "→ DCC RPG — AI Dungeon Master"
echo " Provider: ${DCC_LLM_PROVIDER}"
echo " http://0.0.0.0:8300"
echo ""
python -m src.web.server