Compare commits

...
21 Commits
Author SHA1 Message Date
truetype 53fbb78905 fix: scrollbar su camp-list, pulite 155 campagne test
test / test (push) Has been cancelled
- camp-list con max-height:60vh + overflow-y:auto
- Rimosse 155 campagne test dal DB
- Rimossi 167 PG di test, 558 stati area
2026-07-01 11:49:08 +02:00
truetype 9b2fd0ee92 feat: rate limiting WS + cache risposte LLM
test / test (push) Has been cancelled
- Rate limiting: 1.5s tra azioni dello stesso client WS
- Cache LLM: SHA256 key, TTL 5 min, max 100 entry
- Cache riduce token spesi su azioni identiche ripetute
2026-07-01 11:29:05 +02:00
truetype 893823fa1d feat(ui): cronaca azioni nella sidebar
test / test (push) Has been cancelled
- Turn log mostra ultime 10 azioni con turno, azione e risultato
- Dati inclusi nello stato WS via ctx.log
- Ordinati dal piu recente
2026-07-01 11:25:51 +02:00
truetype e15951f362 feat(ui): anteprima area nella sidebar
test / test (push) Has been cancelled
- Aggiunta descrizione area corrente tra Missioni e Aree
- Mostra nome, descrizione e atmosfera dell'area
- Sezione visibile solo quando si e' in una area
- Dati arricchiti con description/atmosphere/features
2026-07-01 11:23:19 +02:00
truetype ea945a37f8 feat(ui): selettore PG nella chat
test / test (push) Has been cancelled
- Selettore personaggio sopra la barra input
- Solo PG vivi, nome abbreviato
- Click seleziona/deseleziona
- Nome preposto all azione: 'Druso: esploro...'
- Label azione mostra il nome del PG
2026-07-01 11:19:52 +02:00
truetype 90ba835868 feat: DeepSeek V4 Flash primario, Big Pickle fallback
test / test (push) Has been cancelled
- deepseek-v4-flash su opencode/go come modello primario
- big-pickle su opencode/zen come fallback (gratuito)
- Fallback automatico su censura "User Safety" o errore HTTP
- Aggiornati .env, config.py, start.sh, docker-compose.yml
2026-07-01 11:13:29 +02:00
truetype ffeb199ef5 fix: switch Big Pickle -> DeepSeek V4 Flash (censorship)
test / test (push) Has been cancelled
- Big Pickle (opencode/zen free) censura contenuti fantasy
  con "User Safety: safe" - blocca la narrazione del DM
- DeepSeek V4 Flash su opencode/go: $0.14/M token,
  nessuna censura, descrizioni fantasy corrette
- Aggiornati .env, start.sh, config.py
2026-07-01 10:13:41 +02:00
truetype 950b7c4672 fix: race condition su accessi concorrenti al DB
test / test (push) Has been cancelled
- Lock per-campagna in dm_cycle: serializza le richieste
  per la stessa campagna, evitando che due DM cycle
  leggano stato stale e si sovrascrivano
- begin_write()/end_write() in db.py per lock scritture
- _conn_lock finalmente usato (era definito ma mai acquisito)
- 72 test, tutti passano
2026-07-01 09:58:08 +02:00
truetype 08edeed8bb test: parser LLM + fix repair JSON
test / test (push) Has been cancelled
- test_llm_parser.py: 18 test per parse_tool_call, repair, strip
- Fix: repair raddoppiava i due punti nelle chiavi
- Fix: aggiunto passaggio per valori con apici singoli
- Copertura: JSON valido, apici singoli, misti, codeblock,
  virgole trailing, = invece di :, testo senza JSON
- 72 test totali, tutti passano
2026-07-01 09:44:49 +02:00
truetype f4cf649318 ✅ test: aggiunti test update_quest + integrità avventura
test / test (push) Has been cancelled
- test_tools.py: 4 test per update_quest (normale, status invalido,
  quest non trovata, rinomina)
- test_adventure.py: integrità dati (9 aree, connessioni bidirezionali,
  mostri con statblock, quest, items, features)
- Tutti 54 test passano in 0.69s
2026-07-01 09:41:35 +02:00
truetype ee0d5f8f7c 🐛 fix: CSS mobile rotto - rimosso CSS orfano fuori media query
test / test (push) Has been cancelled
- Rimosse 50 righe di CSS duplicate senza @media wrapper
- css rompeva layout desktop applicandosi a tutti gli schermi
- Chat msg piu larghe su mobile (95%)
- Touch target piu grandi (camp-btn 14px padding, input 44px)
- Stat grid piu compatto (font 11px)
- Welcome screen input/bottoni touch-friendly
2026-07-01 09:35:11 +02:00
truetype 634625dbfb feat(ui): supporto funnel - morte PG e rimpiazzo
test / test (push) Has been cancelled
- Card personaggi morti: grigia, teschio, 0 PF, filtro grayscale
- Bottone Nuovo PG quando ci sono caduti
- replaceFunnelDead() chiama POST /api/campaign/{id}/character
- Stato aggiornato dopo rimpiazzo
2026-06-30 17:25:43 +02:00
truetype e3edb87fac feat: backup DB automatico e logging strutturato
- backup_db() in db.py: copia in data/backups/*.db
- Backup a ogni avvio + ogni 5 turni
- Mantiene max 20 backup, rimuove i piu vecchi
- Logging strutturato: [LLM] latenza, raw_len
- Logging strutturato: [CYCLE] turn, tool, latenza, success
- Fix: import mancante threading in db.py
2026-06-29 19:07:33 +02:00
truetype 8b6620cd3d fix(mobile): input bar non visibile - usare 100dvh
- #app usa 100dvh su mobile (viewport dinamico)
- Input piu visibile anche se disabilitato (opacity 0.7)
- Touch target 44px per input e bottone
- flex-shrink:0 su btn-send
- safe-area padding aumentato
2026-06-29 18:47:37 +02:00
truetype 54c9acb0d6 fix(mobile): layout input visibile, hamburger menu
- Rimosso height:100dvh da #main (causava layout rotto)
- #main si affida a flex:1 per l'altezza
- hamburger ☰ sempre visibile nell'header su mobile
- Safe-area per notch iOS
2026-06-29 17:29:46 +02:00
truetype 33ba2a78b2 feat(ui): adattamento mobile responsive
- Hamburger button (☰) nell'header per aprire sidebar
- Sidebar come drawer overlay su mobile (<768px)
- Sidebar backdrop con chiusura al tap
- Swipe a sinistra per chiudere sidebar
- Safe-area per iPhone notch
- Font-size 16px su input (previene zoom iOS)
- dvh per altezza viewport mobile
- Auto-focus input solo su desktop
2026-06-29 16:38:52 +02:00
truetype 886df7429f feat(ui): pannello DM-Override nella sidebar
- Handler WS type:dm — esegue dm_override bypassando il LLM
- Pannello collassabile con selettore comandi e campi dinamici
- Comandi: narrate, set_hp, add_item, force_roll, skip_combat
- Stato aggiornato dopo DM override
- Implementato narrate con testo personalizzato
2026-06-29 16:28:34 +02:00
truetype e9c559c001 feat(ui): mappa aree nella sidebar
- Aggiunto current_area_id allo stato WS
- Mappa ordinata per ID area (1.1-1.9)
- Area corrente evidenziata con ✧ e bordo dorato
- Aree visitate con pallino pieno, non visitate vuoto
- Connessioni mostrate con nomi (non ID)
- CSS rinnovato per area-item
2026-06-28 23:35:36 +02:00
truetype 7fcc2927c4 fix(prompt): istruzioni esplicite per update_quest
test / test (push) Has been cancelled
- Aggiunta istruzione: usa update_quest quando missione completata/fallita
- Esempio update_quest spostato in cima negli esempi
- Quest ID nel contesto per permettere al LLM di referenziarle
2026-06-28 10:06:14 +02:00
truetype b985df0017 feat(tools): add update-quest tool, switch to Big Pickle (opencode/zen)
- update_quest: aggiorna stato/nome/descrizione missioni
- db: get_quest(), update_quest con nome+desc
- prompt: quest_id nel contesto, esempio nei tool examples
- Switch da deepseek-v4-flash (go) a big-pickle (zen) — gratuito
2026-06-28 10:01:33 +02:00
truetype eacc946868 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
2026-06-28 08:18:24 +02:00
31 changed files with 2926 additions and 93 deletions

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"]
+23
View File
@@ -0,0 +1,23 @@
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_LOCAL_FALLBACK_URL=${DCC_LOCAL_FALLBACK_URL:-https://opencode.ai/zen/v1}
- DCC_LOCAL_FALLBACK_MODEL=${DCC_LOCAL_FALLBACK_MODEL:-big-pickle}
- DCC_LLM_TIMEOUT=${DCC_LLM_TIMEOUT:-120}
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: 43 KiB

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

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 250 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*"]
+10 -6
View File
@@ -15,22 +15,26 @@ 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"
local_fallback_url: str = "https://opencode.ai/zen/v1"
local_fallback_model: str = "big-pickle"
timeout: int = 120
def load_from_env() -> ProviderConfig:
return ProviderConfig(
kind=os.getenv("DCC_LLM_PROVIDER", "openrouter"), # type: ignore
kind=os.getenv("DCC_LLM_PROVIDER", "local"), # type: ignore
ollama_base_url=os.getenv("DCC_OLLAMA_URL", "http://192.168.0.50:11434"),
ollama_model=os.getenv("DCC_OLLAMA_MODEL", "gemma2:9b"),
openrouter_api_key=os.getenv("DCC_OPENROUTER_KEY", os.getenv("OPENROUTER_API_KEY", "")),
openrouter_model=os.getenv("DCC_OPENROUTER_MODEL", "openrouter/free"),
openrouter_free_fallback=os.getenv("DCC_OR_FREE_FALLBACK", "1") == "1",
local_base_url=os.getenv("DCC_LOCAL_URL", "http://192.168.0.240/v1"),
local_base_url=os.getenv("DCC_LOCAL_URL", "https://opencode.ai/zen/v1"),
local_api_key=os.getenv("DCC_LOCAL_KEY", ""),
local_model=os.getenv("DCC_LOCAL_MODEL", "big-pickle"),
timeout=int(os.getenv("DCC_LLM_TIMEOUT", "60")),
local_fallback_url=os.getenv("DCC_LOCAL_FALLBACK_URL", "https://opencode.ai/zen/go/v1"),
local_fallback_model=os.getenv("DCC_LOCAL_FALLBACK_MODEL", "deepseek-v4-flash"),
timeout=int(os.getenv("DCC_LLM_TIMEOUT", "120")),
)
+84 -3
View File
@@ -4,15 +4,21 @@ Database module per DCC RPG — Neon Scratch Lounge.
Gestisce schema SQLite e popolazione iniziale dati.
"""
import sqlite3
import json
import logging
import os
import shutil
import sqlite3
import threading
import time
from datetime import datetime
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db"
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
log = logging.getLogger(__name__)
_conn_lock = threading.Lock()
_local = threading.local()
@@ -37,6 +43,39 @@ def get_conn() -> sqlite3.Connection:
return conn
def begin_write():
"""Acquisisce lock di scrittura e avvia transazione IMMEDIATE.
SQLite permette un solo writer per volta, anche in WAL mode.
Questo lock serializza le scritture tra thread diversi,
evitando race condition quando due richieste modificano
lo stesso stato contemporaneamente.
"""
_conn_lock.acquire()
conn = get_conn()
conn.execute("BEGIN IMMEDIATE")
def end_write():
"""Commuta la transazione e rilascia il lock di scrittura."""
conn = get_conn()
conn.commit()
_conn_lock.release()
def _write(sql: str, params: tuple = ()) -> sqlite3.Cursor:
"""Esegue una scrittura SQL con lock."""
begin_write()
try:
cur = get_conn().execute(sql, params)
end_write()
return cur
except Exception:
get_conn().rollback()
_conn_lock.release()
raise
def init_schema(conn: sqlite3.Connection) -> None:
conn.executescript("""
-- ============================================================
@@ -239,6 +278,33 @@ def init_db() -> sqlite3.Connection:
return conn
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
BACKUP_DIR = DB_PATH.parent / "backups"
def backup_db() -> str:
"""Copia il database in data/backups/dcc_rpg_YYYY-MM-DD_HHMMSS.db.
Mantiene al massimo 20 backup, rimuove i piu vecchi."""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
dest = BACKUP_DIR / f"dcc_rpg_{ts}.db"
try:
shutil.copy2(str(DB_PATH), str(dest))
log.info("Backup DB creato: %s", dest.name)
except FileNotFoundError:
log.warning("Nessun DB da backupare (file non trovato)")
return ""
# Pulisce backup vecchi (mantiene 20)
backups = sorted(BACKUP_DIR.glob("dcc_rpg_*.db"), reverse=True)
for old in backups[20:]:
old.unlink()
log.info("Rimosso backup vecchio: %s", old.name)
return str(dest)
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
@@ -477,9 +543,24 @@ def add_quest(campaign_id: int, name: str, description: str) -> int:
return cur.lastrowid or 0
def update_quest(quest_id: int, status: str) -> bool:
def get_quest(quest_id: int) -> dict | None:
conn = get_conn()
conn.execute("UPDATE quests SET status = ? WHERE id = ?", (status, quest_id))
row = conn.execute(
"SELECT * FROM quests WHERE id = ?", (quest_id,)
).fetchone()
return dict(row) if row else None
def update_quest(quest_id: int, status: str, name: str = "", description: str = "") -> bool:
updates = {"status": status}
if name:
updates["name"] = name
if description:
updates["description"] = description
cols = ", ".join(f"{k} = ?" for k in updates)
vals = list(updates.values()) + [quest_id]
conn = get_conn()
conn.execute(f"UPDATE quests SET {cols} WHERE id = ?", vals)
conn.commit()
return conn.total_changes > 0
+52 -2
View File
@@ -1,6 +1,8 @@
from __future__ import annotations
import logging
import threading
import time
from src.engine import db, tools
from src.engine.config import load_from_env
@@ -11,13 +13,30 @@ from src.engine.prompts import SYSTEM_PROMPT, build_prompt
log = logging.getLogger(__name__)
# Lock per-campagna: evita race condition quando due richieste
# arrivano simultaneamente per la stessa campagna.
# La chiave è campaign_id, il valore è un threading.Lock.
_campaign_locks: dict[int, threading.Lock] = {}
_campaign_locks_lock = threading.Lock()
def _get_campaign_lock(campaign_id: int) -> threading.Lock:
with _campaign_locks_lock:
if campaign_id not in _campaign_locks:
_campaign_locks[campaign_id] = threading.Lock()
return _campaign_locks[campaign_id]
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
t0 = time.perf_counter()
try:
raw = llm_ask(prompt, system=SYSTEM_PROMPT)
elapsed = time.perf_counter() - t0
log.info("[LLM] azione=%s tool=? latenza=%.1fs raw_len=%d", player_action[:40], elapsed, len(raw))
except Exception as e:
log.warning("LLM call failed (%s), fallback a heuristic parser", e)
elapsed = time.perf_counter() - t0
log.warning("[LLM] fallback heuristico dopo %.1fs: %s", elapsed, e)
tool, params = fallback_parse(player_action, ctx.characters)
return tool, params, ""
@@ -28,13 +47,34 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
narration = decision.get("narration", "")
if not tool:
log.info("LLM non ha prodotto tool valido, fallback heuristico")
log.info("[DM] tool non valido, fallback heuristico")
tool, params = fallback_parse(player_action, ctx.characters)
elif not params and tool not in ("dm_override",):
log.info("[DM] tool=%s senza params, integro euristico", tool)
_, fallback_params = fallback_parse(player_action, ctx.characters)
params = fallback_params
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
def dm_cycle(campaign_id: int, player_action: str) -> dict:
lock = _get_campaign_lock(campaign_id)
lock.acquire()
try:
return _dm_cycle_impl(campaign_id, player_action)
finally:
lock.release()
def _dm_cycle_impl(campaign_id: int, player_action: str) -> dict:
t0 = time.perf_counter()
ctx = GameContext.load(campaign_id)
tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id)
@@ -51,6 +91,16 @@ def dm_cycle(campaign_id: int, player_action: str) -> dict:
turn = db.advance_turn(campaign_id)
db.log_turn(campaign_id, turn, player_action, full_msg)
# Backup ogni 5 turni
if turn > 0 and turn % 5 == 0:
db.backup_db()
elapsed = time.perf_counter() - t0
log.info(
"[CYCLE] turn=%d tool=%s action=%s latenza=%.1fs success=%s",
turn, tool_name, player_action[:40], elapsed, result.get("success", True),
)
return {
"turn": turn,
"action": player_action,
+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:
+87 -5
View File
@@ -1,14 +1,43 @@
from __future__ import annotations
import contextvars
import hashlib
import json
import re
import time
from src.engine.providers import LLMProvider, create_provider
_provider_var: contextvars.ContextVar[LLMProvider | None] = contextvars.ContextVar("provider", default=None)
# Cache risposte LLM: {cache_key: (timestamp, response)}
_cache: dict[str, tuple[float, str]] = {}
_CACHE_TTL = 300 # 5 minuti
def _cache_key(prompt: str, system: str) -> str:
return hashlib.sha256(f"{system}||{prompt}".encode()).hexdigest()
def _cache_get(prompt: str, system: str) -> str | None:
key = _cache_key(prompt, system)
entry = _cache.get(key)
if entry and time.monotonic() - entry[0] < _CACHE_TTL:
return entry[1]
if entry:
del _cache[key]
return None
def _cache_set(prompt: str, system: str, response: str) -> None:
key = _cache_key(prompt, system)
_cache[key] = (time.monotonic(), response)
# Mantieni cache sotto 100 entry
if len(_cache) > 100:
oldest = min(_cache.keys(), key=lambda k: _cache[k][0])
del _cache[oldest]
def get_provider() -> LLMProvider:
provider = _provider_var.get()
@@ -24,7 +53,39 @@ def set_provider(provider: LLMProvider) -> None:
def ask(prompt: str, system: str = "") -> str:
return get_provider().ask(prompt, system=system)
cached = _cache_get(prompt, system)
if cached is not None:
return cached
response = get_provider().ask(prompt, system=system)
_cache_set(prompt, system, response)
return response
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)
# Fix 5: valori con apici singoli → doppi (dopo aver riparato le chiavi)
# 'valore' → "valore"
blob = re.sub(r"'([^']*?)'(?=\s*[,}\]])", r'"\1"', blob)
try:
json.loads(blob)
return blob
except json.JSONDecodeError:
return None
def _extract_json(text: str) -> str | None:
@@ -40,8 +101,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 +135,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__":
+11 -2
View File
@@ -25,11 +25,13 @@ _KNOWN_TOOLS: dict[str, dict[str, str]] = {
"spellcasting": {"character_id": "id pg", "spell_name": "nome incantesimo", "spell_check": "valore tiro"},
"update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove|equip|unequip|list", "quantity": "quantità (default 1)"},
"award_xp": {"character_id": "id pg", "xp": "quantità PE"},
"update_quest": {"quest_id": "id missione", "status": "active|completed|failed", "name": "nuovo nome (opzionale)", "description": "nuova descrizione (opzionale)"},
"dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"},
}
TOOL_EXAMPLES = """
Esempi:
- Missione completata → {"tool": "update_quest", "params": {"quest_id": 1, "status": "completed"}, "narration": "La missione è stata completata con successo!"}
- Attacco con spada → {"tool": "combat", "params": {"character_id": 1, "target_ac": 12, "attack_bonus": 0, "damage_die": "d8"}, "narration": "Sguaini la lama e colpisci il guardiano!"}
- Tiro d20 per sentire rumori → {"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tendi l'orecchio nel buio..."}
- Esploro la stanza → {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "La stanza è polverosa..."}}, "narration": "Scrutando nell'oscurità, noti..."}
@@ -44,6 +46,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
@@ -53,6 +58,8 @@ MECCANICHE:
STRUMENTI DISPONIBILI:
{json.dumps(_KNOWN_TOOLS, indent=2, ensure_ascii=False)}
IMPORTANTE: quando il giocatore COMPLETA o FALLISCE una missione, USA update_quest con l'ID della missione mostrato nel contesto.
{TOOL_EXAMPLES}
FORMATO RISPOSTA — SOLO JSON, nient'altro:
{{"tool": "nome_strumento", "params": {{...}}, "narration": "descrizione narrativa"}}
@@ -88,7 +95,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])
@@ -105,7 +114,7 @@ def format_context(ctx: GameContext) -> str:
if ctx.quests:
lines.append("MISSIONI:")
for q in ctx.quests:
lines.append(f" [{q['status']}] {q['name']}")
lines.append(f" [ID:{q['id']}] {q['name']} ({q['status']})")
lines.append("")
enriched = _enrich_areas(ctx.areas)
+25 -5
View File
@@ -84,13 +84,15 @@ class OpenRouterProvider(LLMProvider):
class LocalProvider(LLMProvider):
def __init__(self, base_url: str, model: str, api_key: str = "", timeout: int = 60):
def __init__(self, base_url: str, model: str, api_key: str = "",
timeout: int = 60, fallbacks: list[tuple[str, str]] | None = None):
self.base_url = base_url.rstrip("/")
self.model = model
self.api_key = api_key
self.timeout = timeout
self.fallbacks = fallbacks or []
def ask(self, prompt: str, system: str = "") -> str:
def _call(self, base_url: str, model: str, prompt: str, system: str) -> str:
messages = []
if system:
messages.append({"role": "system", "content": system})
@@ -100,10 +102,10 @@ class LocalProvider(LLMProvider):
if self.api_key:
headers["Authorization"] = f"Bearer {self.api_key}"
body = {"model": self.model, "messages": messages, "stream": False}
body = {"model": model, "messages": messages, "stream": False}
resp = httpx.post(
f"{self.base_url}/chat/completions",
f"{base_url}/chat/completions",
json=body,
headers=headers,
timeout=self.timeout,
@@ -112,6 +114,21 @@ class LocalProvider(LLMProvider):
data = resp.json()
return data["choices"][0]["message"]["content"]
def ask(self, prompt: str, system: str = "") -> str:
candidates = [(self.base_url, self.model)] + self.fallbacks
last_err = None
for base_url, model in candidates:
try:
text = self._call(base_url, model, prompt, system)
# Rileva risposte di censura del modello gratuito
if "User Safety" in text and "safe" in text.lower():
raise ValueError(f"Modello '{model}' ha censurato la risposta: {text[:60]}")
return text
except Exception as e:
last_err = e
continue
raise last_err # type: ignore
_registry: dict[str, Any] = {
"ollama": OllamaProvider,
@@ -136,6 +153,9 @@ def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider:
raise ValueError("DCC_OPENROUTER_KEY non configurata")
return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback)
elif cfg.kind == "local":
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout)
fallbacks = []
if cfg.local_fallback_url and cfg.local_fallback_model:
fallbacks.append((cfg.local_fallback_url, cfg.local_fallback_model))
return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout, fallbacks=fallbacks)
else:
raise ValueError(f"Provider non implementato: {cfg.kind}")
+44
View File
@@ -376,6 +376,39 @@ def award_xp(character_id: int, xp: int) -> ToolResult:
}
@tool
def update_quest(quest_id: int, status: str = "active", name: str = "", description: str = "") -> ToolResult:
VALID_STATUSES = ["active", "completed", "failed"]
if status not in VALID_STATUSES:
return {
"success": False,
"tool": "update-quest",
"message": f"Status '{status}' non valido. Usa: {', '.join(VALID_STATUSES)}",
}
quest = storage.get_quest(quest_id)
if not quest:
return {"success": False, "tool": "update-quest", "message": f"Missione #{quest_id} non trovata."}
changed = []
if status != quest["status"]:
changed.append(f"stato: {quest['status']}→{status}")
if name and name != quest.get("name"):
changed.append(f"nome: {quest.get('name')}→{name}")
if description and description != quest.get("description"):
changed.append("descrizione aggiornata")
storage.update_quest(quest_id, status=status, name=name, description=description)
display_name = name or quest["name"]
return {
"success": True,
"tool": "update-quest",
"quest_id": quest_id,
"name": display_name,
"status": status,
"message": f"Missione '{display_name}' → {status}." + (f" ({', '.join(changed)})" if changed else ""),
}
@tool
def dm_override(command: str, params: dict | None = None) -> ToolResult:
manual = {
@@ -392,6 +425,17 @@ def dm_override(command: str, params: dict | None = None) -> ToolResult:
"commands": manual,
"message": "Comandi DM disponibili: " + ", ".join(manual.keys()),
}
if command == "narrate":
text = (params or {}).get("text", "")
return {
"success": True,
"tool": "dm-override",
"command": command,
"params": params or {},
"message": text or "Override DM: narrate applicato.",
}
return {
"success": True,
"tool": "dm-override",
+133 -28
View File
@@ -1,18 +1,22 @@
from __future__ import annotations
import json
import asyncio
import logging
import os
import time
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
from src.engine.dm import dm_cycle
from src.engine.game_context import GameContext
from src.engine.tools import run as run_tool
log = logging.getLogger(__name__)
@@ -51,11 +55,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 +87,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 +139,22 @@ def add_character(
# ---------------------------------------------------------------------------
_executor = ThreadPoolExecutor(max_workers=4)
# Rate limiting: almeno 1.5s tra azioni dello stesso client
_last_action: dict[str, float] = {}
_RATE_LIMIT = 1.5
def _check_rate(client_id: str) -> str | None:
now = time.monotonic()
last = _last_action.get(client_id, 0)
if now - last < _RATE_LIMIT:
return f"Troppo veloce! Aspetta {_RATE_LIMIT - (now - last):.1f}s"
_last_action[client_id] = now
return None
@app.websocket("/ws/{campaign_id}")
async def game_ws(ws: WebSocket, campaign_id: int):
await ws.accept()
@@ -126,7 +170,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:
@@ -137,8 +185,20 @@ async def game_ws(ws: WebSocket, campaign_id: int):
if not player_action:
continue
# Rate limiting
if ws.client:
err = _check_rate(str(ws.client))
if err:
await ws.send_json({"type": "error", "message": err})
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 +210,46 @@ 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
elif msg.get("type") == "dm":
command = msg.get("command", "help")
params = msg.get("params", {})
try:
result = run_tool("dm_override", command=command, params=params)
await ws.send_json({
"type": "response",
"tool": "dm-override",
"narration": result.get("message", ""),
"mechanics": "",
"turn": 0,
})
# Send updated state after DM command (e.g. set_hp changes HP)
try:
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
except WebSocketDisconnect:
break
except Exception as e:
log.exception("DM override failed")
try:
await ws.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
break
def _build_state(campaign_id: int) -> dict:
@@ -167,17 +260,28 @@ 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,
"description": full.get("description", "")[:300],
"atmosphere": full.get("atmosphere", ""),
"features": full.get("features", []),
})
return {
"turn": ctx.turn,
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
"current_area_id": ctx.current_area_id,
"current_area_name": ctx.current_area_name,
"log": ctx.log,
}
@@ -208,9 +312,10 @@ async def index():
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
)
db.init_db()
db.backup_db()
cfg = load_from_env()
log.info(
"DM config: provider=%s model=%s",
+594 -40
View File
@@ -233,27 +233,118 @@ html, body {
margin-top: 2px;
}
/* Funnel - char dead */
.char-card.dead {
opacity: 0.4;
filter: grayscale(0.8);
}
.char-card.dead .char-name { color: var(--red); }
.funnel-actions {
padding: 8px 0;
}
.btn-funnel {
width: 100%;
background: var(--bg-elevated);
border: 1px dashed var(--border-light);
border-radius: var(--radius);
padding: 8px;
color: var(--text-secondary);
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: background 0.2s, color 0.2s;
}
.btn-funnel:hover {
background: var(--gold-dim);
color: #fff;
border-style: solid;
}
/* Area map */
.area-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
padding: 5px 6px;
font-size: 13px;
color: var(--text-muted);
border-left: 2px solid transparent;
transition: border-color 0.2s, background 0.2s;
}
/* Area preview */
#area-preview-section { display: none; }
#area-preview-section.visible { display: block; }
#area-preview-title {
font-family: var(--font-display);
font-size: 13px;
font-weight: 700;
color: var(--gold);
margin-bottom: 4px;
}
#area-preview-desc {
font-size: 13px;
color: var(--text-primary);
line-height: 1.4;
}
/* Turn log */
#log-display {
max-height: 200px;
overflow-y: auto;
scrollbar-width: thin;
font-size: 12px;
}
.log-entry {
padding: 4px 0;
border-bottom: 1px solid var(--border);
line-height: 1.3;
}
.log-entry:last-child { border-bottom: none; padding-bottom: 0; }
.log-turn { color: var(--gold-dim); font-weight: 700; font-size: 10px; }
.log-action { color: var(--text-primary); }
.log-result { color: var(--text-muted); font-size: 11px; }
.area-item.visited { color: var(--text-secondary); }
.area-item.current {
color: var(--gold-bright);
border-left-color: var(--gold);
background: rgba(201,168,76,0.06);
}
.area-item.visited { color: var(--text-primary); }
.area-marker {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
margin-top: 5px;
}
.area-item.visited .area-marker { background: var(--text-secondary); }
.area-item.current .area-marker { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.area-id {
font-family: var(--font-display);
font-size: 10px;
color: var(--gold-dim);
min-width: 24px;
font-weight: 700;
}
.area-name {
flex: 1;
font-size: 13px;
}
.area-here {
font-size: 12px;
color: var(--gold);
margin-left: auto;
}
.area-item.visited .area-marker { background: var(--gold); box-shadow: 0 0 4px var(--gold); }
.area-conns {
width: 100%;
font-size: 11px;
color: var(--text-muted);
margin-left: 14px;
padding-left: 36px;
opacity: 0.7;
}
/* Zone vuote */
@@ -263,6 +354,65 @@ html, body {
font-style: italic;
}
/* DM Override panel */
.dm-toggle {
cursor: pointer;
user-select: none;
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s;
}
.dm-toggle:hover { opacity: 1; }
.dm-panel {
padding: 6px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.dm-panel.hidden { display: none; }
.dm-panel select,
.dm-panel input,
.dm-panel textarea {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
}
.dm-panel select:focus,
.dm-panel input:focus,
.dm-panel textarea:focus {
border-color: var(--gold-dim);
}
.dm-panel textarea {
min-height: 60px;
resize: vertical;
}
#btn-dm-exec {
background: var(--red);
border: none;
border-radius: var(--radius);
padding: 6px;
color: #fff;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
transition: background 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
#btn-dm-exec:hover { background: #c04040; }
.dm-result {
font-size: 11px;
color: var(--text-muted);
padding: 4px 0;
min-height: 18px;
}
/* ============================================================
Chat Area
============================================================ */
@@ -360,6 +510,44 @@ html, body {
text-align: center;
}
/* ============================================================
Character selector (above input)
============================================================ */
.char-selector {
display: flex;
gap: 6px;
padding: 6px 20px;
background: var(--bg-surface);
border-top: 1px solid var(--border);
overflow-x: auto;
scrollbar-width: none;
}
.char-selector::-webkit-scrollbar { display: none; }
.char-selector.hidden { display: none; }
.char-chip {
flex-shrink: 0;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: 12px;
padding: 3px 10px;
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
transition: border-color 0.2s, color 0.2s, background 0.2s;
white-space: nowrap;
}
.char-chip:hover { border-color: var(--gold-dim); color: var(--text-primary); }
.char-chip.active {
background: var(--gold-dim);
border-color: var(--gold);
color: #fff;
}
.char-chip.dead {
opacity: 0.3;
text-decoration: line-through;
cursor: not-allowed;
}
/* ============================================================
Input Area
============================================================ */
@@ -388,7 +576,7 @@ html, body {
}
#input-area input::placeholder { color: var(--text-muted); }
#input-area input:disabled {
opacity: 0.4;
opacity: 0.7;
cursor: not-allowed;
}
#btn-send {
@@ -447,9 +635,14 @@ html, body {
#welcome .camp-list {
width: 100%;
max-width: 420px;
max-height: 60vh;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 8px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
padding-right: 4px;
}
#welcome .camp-btn {
background: var(--bg-card);
@@ -534,6 +727,132 @@ html, body {
margin-right: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Hamburger menu button */
#btn-menu {
display: none;
background: none;
border: none;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
padding: 4px 6px;
line-height: 1;
}
/* Sidebar backdrop (mobile) */
#sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 8;
}
/* ============================================================
Mobile Responsive
============================================================ */
@media (max-width: 768px) {
#btn-menu { display: block; }
#sidebar {
position: fixed;
left: -320px;
top: 0;
bottom: 0;
width: 300px;
min-width: 300px;
z-index: 9;
transition: left 0.25s ease;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
}
#sidebar.open { left: 0; }
#sidebar-backdrop.open { display: block; }
#app { flex-direction: column; height: 100dvh; }
#header {
padding: 0 12px;
gap: 10px;
min-height: 48px;
height: 48px;
}
#header h1 { font-size: 14px; letter-spacing: 1px; }
#header .turn-badge { font-size: 11px; }
#main {
width: 100%;
min-height: 0;
}
#chat {
padding: 12px;
gap: 10px;
font-size: 15px;
}
.msg { max-width: 95%; }
.msg-dm-narration { font-size: 15px; }
.char-selector {
padding: 4px 12px;
gap: 4px;
}
.char-chip {
font-size: 11px;
padding: 3px 8px;
}
#input-area {
padding: 10px 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
gap: 8px;
}
#input-area input {
font-size: 16px;
padding: 12px;
min-height: 44px;
}
#btn-send {
width: 44px;
height: 44px;
font-size: 20px;
flex-shrink: 0;
}
/* Welcome screen */
#welcome h1 { font-size: 28px; }
#welcome .subtitle { font-size: 14px; }
.camp-list { max-width: 100%; padding: 0 12px; }
.camp-btn {
font-size: 15px;
padding: 14px 16px;
}
#new-camp-form input { font-size: 16px; padding: 12px; }
#new-camp-form button { font-size: 14px; padding: 12px 20px; }
/* Sidebar content */
.sidebar-section { padding: 10px 12px; }
.sidebar-section h2 { font-size: 10px; }
.char-name { font-size: 14px; }
.stat-grid { gap: 3px; }
.stat-row { font-size: 11px; padding: 2px 4px; }
.area-item { font-size: 12px; }
.char-card { padding: 6px 0; }
.char-class { font-size: 12px; margin-bottom: 6px; }
.hp-bar-bg { height: 8px; }
.hp-text { font-size: 12px; min-width: 40px; }
.char-ac { font-size: 12px; }
.quest-name { font-size: 13px; }
.quest-desc { font-size: 12px; }
.btn-funnel { padding: 10px; font-size: 10px; }
}
@media (max-width: 480px) {
#sidebar { width: 260px; min-width: 260px; left: -260px; }
#header h1 { font-size: 12px; }
.msg-bubble { font-size: 14px; }
}
</style>
</head>
<body>
@@ -552,22 +871,48 @@ 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">
<h2>Missioni</h2>
<div id="quest-display"><div class="empty-state">Nessuna missione</div></div>
</div>
<div class="sidebar-section" id="area-preview-section" style="display:none;">
<h2 id="area-preview-title">Luogo</h2>
<div id="area-preview-desc"></div>
<div id="area-preview-atmo" style="font-size:12px;color:var(--text-muted);font-style:italic;margin-top:4px;"></div>
</div>
<div class="sidebar-section" id="log-section">
<h2>Cronaca</h2>
<div id="log-display"><div class="empty-state">Nessuna azione</div></div>
</div>
<div class="sidebar-section" id="area-section">
<h2>Aree</h2>
<div id="area-display"><div class="empty-state">Nessuna area</div></div>
</div>
<div class="sidebar-section" id="dm-section">
<h2 class="dm-toggle" onclick="toggleDM()">DM +</h2>
<div id="dm-panel" class="dm-panel hidden">
<select id="dm-cmd" onchange="onDMCommand()">
<option value="narrate">Narrate (testo)</option>
<option value="set_hp">Set HP</option>
<option value="add_item">Add Item</option>
<option value="force_roll">Force Roll</option>
<option value="skip_combat">Skip Combat</option>
</select>
<div id="dm-fields"></div>
<button id="btn-dm-exec" onclick="execDM()">Esegui</button>
<div id="dm-result" class="dm-result"></div>
</div>
</div>
</div>
<div id="sidebar-backdrop" class="hidden" onclick="toggleSidebar()"></div>
<!-- MAIN -->
<div id="main">
<div id="header">
<button id="btn-menu" onclick="toggleSidebar()">☰</button>
<div id="conn-indicator" class="conn-status disconnected"></div>
<h1>Tomba del Mago Guerriero</h1>
<div class="turn-badge">Turno <span id="turn-counter">0</span></div>
@@ -575,6 +920,7 @@ html, body {
<div id="chat">
<div class="msg system"><div class="msg-bubble">Benvenuto, avventuriero. Scrivi la tua azione per iniziare.</div></div>
</div>
<div id="char-selector" class="char-selector hidden"></div>
<div id="input-area">
<input type="text" id="input" placeholder="Cosa vuoi fare?" disabled />
<button id="btn-send" disabled>➤</button>
@@ -592,6 +938,8 @@ let campaignId = null;
let ws = null;
let turn = 0;
let reconnectTimer = null;
let currentAreaId = '';
let selectedCharId = null;
let state = { characters: [], quests: [], areas: [] };
const HOST = window.location.host;
@@ -699,7 +1047,7 @@ function connectWS() {
connIndicator.className = 'conn-status connected';
input.disabled = false;
btnSend.disabled = false;
input.focus();
if (window.innerWidth > 768) input.focus();
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
};
@@ -750,9 +1098,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);
@@ -775,9 +1121,12 @@ function applyState(s) {
turn = s.turn;
turnCounter.textContent = turn;
}
if (s.current_area_id) currentAreaId = s.current_area_id;
renderChar(s.characters);
renderQuests(s.quests);
renderAreas(s.areas);
renderAreaPreview(s.areas, s.current_area_id);
renderLog(s.log);
}
// ================================================================
@@ -788,26 +1137,98 @@ 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>
const alive = chars.filter(c => c.status !== 'dead');
const dead = chars.filter(c => c.status === 'dead');
charDisplay.innerHTML = chars.map(c => {
const isDead = c.status === 'dead';
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 ${isDead ? 'dead' : ''}" style="border-bottom:1px solid var(--border);padding:8px 0;">
<div class="char-name">${isDead ? '💀 ' : ''}${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:${isDead ? 0 : hpPct}%"></div></div>
<span class="hp-text">${isDead ? '✝ morto' : 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('');
if (dead.length > 0) {
charDisplay.innerHTML += `<div class="funnel-actions"><button onclick="replaceFunnelDead()" class="btn-funnel">➕ Nuovo PG (rimpiazza caduti)</button></div>`;
}
renderCharSelector(chars);
}
function renderCharSelector(chars) {
const container = document.getElementById('char-selector');
const alive = chars.filter(c => c.status !== 'dead');
if (alive.length <= 1) {
container.classList.add('hidden');
selectedCharId = null;
return;
}
container.classList.remove('hidden');
container.innerHTML = alive.map(c => `
<button class="char-chip ${c.id === selectedCharId ? 'active' : ''}"
onclick="selectChar(${c.id})">${escapeHtml(c.name).split(' il ')[0]}</button>
`).join('');
}
function selectChar(id) {
selectedCharId = selectedCharId === id ? null : id;
renderCharSelector(state.characters);
input.focus();
}
function renderAreaPreview(areas, currentId) {
const section = document.getElementById('area-preview-section');
const title = document.getElementById('area-preview-title');
const desc = document.getElementById('area-preview-desc');
const atmo = document.getElementById('area-preview-atmo');
const area = areas?.find(a => a.id === currentId);
if (area) {
section.classList.add('visible');
title.textContent = area.name || currentId;
desc.textContent = area.description || '';
atmo.textContent = area.atmosphere || '';
} else {
section.classList.remove('visible');
}
}
function renderLog(log) {
const container = document.getElementById('log-display');
if (!log || log.length === 0) {
container.innerHTML = '<div class="empty-state">Nessuna azione</div>';
return;
}
container.innerHTML = [...log].reverse().slice(0, 10).map(e => `
<div class="log-entry">
<div class="log-turn">Turno ${e.turn}</div>
<div class="log-action">${escapeHtml(e.action? e.action.slice(0, 80) : '?')}</div>
<div class="log-result">${escapeHtml(e.result? e.result.slice(0, 60) : '')}</div>
</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>
`;
`).join('');
}
async function replaceFunnelDead() {
const resp = await fetch(`${BASE}/api/campaign/${campaignId}/character`, { method: 'POST' });
const data = await resp.json();
const s = await (await fetch(`${BASE}/api/campaign/${campaignId}`)).json();
applyState(s);
}
function renderQuests(quests) {
@@ -828,15 +1249,140 @@ function renderAreas(areas) {
areaDisplay.innerHTML = '<div class="empty-state">Nessuna area</div>';
return;
}
areaDisplay.innerHTML = areas.map(a => `
<div class="area-item ${a.visited ? 'visited' : ''}">
<span class="area-marker"></span>
<span>${escapeHtml(a.name)}</span>
</div>
${a.connections && a.connections.length ? `<div class="area-conns">→ ${a.connections.join(', ')}</div>` : ''}
`).join('');
// Sort by area id (1.1, 1.2, ..., 1.9)
const sorted = [...areas].sort((a, b) => (a.id || '').localeCompare(b.id || ''));
areaDisplay.innerHTML = sorted.map(a => {
const isCurrent = a.id === currentAreaId;
const status = isCurrent ? 'current' : (a.visited ? 'visited' : 'unvisited');
const conns = (a.connections || []).join(' → ');
return `
<div class="area-item ${status}">
<span class="area-marker"></span>
<span class="area-id">${escapeHtml(a.id)}</span>
<span class="area-name">${escapeHtml(a.name)}</span>
${isCurrent ? '<span class="area-here">✧</span>' : ''}
${conns ? `<div class="area-conns">→ ${escapeHtml(conns)}</div>` : ''}
</div>`;
}).join('');
}
// ================================================================
// Mobile sidebar
// ================================================================
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
const open = !sb.classList.contains('open');
sb.classList.toggle('open', open);
backdrop.classList.toggle('open', open);
backdrop.classList.toggle('hidden', !open);
}
// Close sidebar when swiping left on mobile
let touchStartX = 0;
document.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; });
document.addEventListener('touchend', e => {
const sb = document.getElementById('sidebar');
if (!sb.classList.contains('open')) return;
const dx = touchStartX - e.changedTouches[0].clientX;
if (dx > 80) toggleSidebar();
});
// ================================================================
// DM Override
// ================================================================
let dmOpen = false;
function toggleDM() {
dmOpen = !dmOpen;
const panel = $('dm-panel');
panel.classList.toggle('hidden', !dmOpen);
if (dmOpen) onDMCommand();
}
function onDMCommand() {
const cmd = $('dm-cmd').value;
const fields = $('dm-fields');
$('dm-result').textContent = '';
switch (cmd) {
case 'narrate':
fields.innerHTML = '<textarea id="dm-narrate-text" placeholder="Testo narrativo..."></textarea>';
break;
case 'set_hp':
fields.innerHTML = `
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
<input id="dm-hp-value" type="number" placeholder="PF" min="0" />
`;
break;
case 'add_item':
fields.innerHTML = `
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
<input id="dm-item-name" placeholder="Nome oggetto" />
`;
break;
case 'force_roll':
fields.innerHTML = `
<input id="dm-die" placeholder="dado (es. d20)" />
<input id="dm-value" type="number" placeholder="Valore forzato" min="1" />
`;
break;
case 'skip_combat':
fields.innerHTML = '<input id="dm-area-id" placeholder="ID area (es. 1.4)" />';
break;
default:
fields.innerHTML = '';
}
}
function execDM() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const cmd = $('dm-cmd').value;
let params = {};
const resultDiv = $('dm-result');
resultDiv.textContent = 'Invio...';
switch (cmd) {
case 'narrate': {
const text = $('dm-narrate-text')?.value?.trim();
if (!text) { resultDiv.textContent = 'Inserisci testo.'; return; }
params = { text };
break;
}
case 'set_hp': {
const cid = parseInt($('dm-char-id')?.value);
const hp = parseInt($('dm-hp-value')?.value);
if (!cid || isNaN(hp)) { resultDiv.textContent = 'Specifica ID PG e PF.'; return; }
params = { character_id: cid, value: hp };
break;
}
case 'add_item': {
const cid = parseInt($('dm-char-id')?.value);
const item = $('dm-item-name')?.value?.trim();
if (!cid || !item) { resultDiv.textContent = 'Specifica ID PG e oggetto.'; return; }
params = { character_id: cid, item };
break;
}
case 'force_roll': {
const die = $('dm-die')?.value?.trim();
const val = parseInt($('dm-value')?.value);
if (!die || isNaN(val)) { resultDiv.textContent = 'Specifica dado e valore.'; return; }
params = { die, forced_value: val };
break;
}
case 'skip_combat': {
const aid = $('dm-area-id')?.value?.trim();
if (!aid) { resultDiv.textContent = 'Specifica ID area.'; return; }
params = { area_id: aid };
break;
}
}
ws.send(JSON.stringify({ type: 'dm', command: cmd, params }));
resultDiv.textContent = '✧ Comando inviato.';
}
// ================================================================
// Send action
// ================================================================
@@ -844,14 +1390,22 @@ function sendAction() {
const text = input.value.trim();
if (!text || !ws || ws.readyState !== WebSocket.OPEN) return;
const chars = state.characters || [];
const sel = selectedCharId ? chars.find(c => c.id === selectedCharId) : null;
// Prepend selected character name if any
let actionText = text;
if (sel) actionText = `${sel.name}: ${text}`;
// Optimistic display
const div = document.createElement('div');
div.className = 'msg player';
div.innerHTML = `<div class="msg-label">⚔️ Azione</div><div class="msg-bubble">${escapeHtml(text)}</div>`;
const label = sel ? `⚔️ ${sel.name}` : '⚔️ Azione';
div.innerHTML = `<div class="msg-label">${label}</div><div class="msg-bubble">${escapeHtml(text)}</div>`;
chat.appendChild(div);
scrollChat();
ws.send(JSON.stringify({ type: 'action', payload: text }));
ws.send(JSON.stringify({ type: 'action', payload: actionText }));
input.value = '';
input.focus();
}
Executable
+39
View File
@@ -0,0 +1,39 @@
#!/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}"
export DCC_LOCAL_FALLBACK_URL="${DCC_LOCAL_FALLBACK_URL:-https://opencode.ai/zen/v1}"
export DCC_LOCAL_FALLBACK_MODEL="${DCC_LOCAL_FALLBACK_MODEL:-big-pickle}"
# ── 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
+103
View File
@@ -0,0 +1,103 @@
"""Test integrità dati avventura — aree, mostri, quest, oggetti."""
import sys
sys.path.insert(0, "src")
from src.engine.db import (
get_all_areas, get_area_by_id,
get_monster_by_id, get_monsters_in_area,
get_all_quest_defs, get_special_items,
)
def _load_data():
"""Load data once per session."""
# init_db is called automatically by _load_adventure_json
return {
"areas": get_all_areas(),
"quests": get_all_quest_defs(),
"items": get_special_items(),
}
def test_all_nine_areas_present():
data = _load_data()
assert len(data["areas"]) == 9
ids = [a["id"] for a in data["areas"]]
expected = [f"1.{i}" for i in range(1, 10)]
for e in expected:
assert e in ids, f"Manca area {e}"
def test_each_area_has_name_and_description():
for a in get_all_areas():
assert a.get("name"), f"Area {a['id']} senza nome"
assert a.get("description"), f"Area {a['id']} senza descrizione"
def test_each_area_has_connections():
for a in get_all_areas():
conns = a.get("connections", [])
assert len(conns) > 0, f"Area {a['id']} non ha connessioni"
for c in conns:
linked = get_area_by_id(c)
assert linked is not None, (
f"Area {a['id']} connette a '{c}' che non esiste"
)
def test_connections_are_bidirectional():
"""Se A connette a B, B deve connettere ad A."""
for a in get_all_areas():
for c in a.get("connections", []):
linked = get_area_by_id(c)
assert linked is not None
back = c in linked.get("connections", [])
if not back:
print(
f"⚠ Area {a['id']}→{c} non ha connessione di ritorno"
)
def test_all_monsters_have_statblocks():
monsters_seen = set()
for a in get_all_areas():
for mref in a.get("monsters", []):
mid = mref.get("ref_id")
assert mid, f"Area {a['id']} ha ref_id mostro vuoto"
monster = get_monster_by_id(mid)
assert monster is not None, (
f"Mostro '{mid}' (in area {a['id']}) non trovato"
)
assert monster.get("name"), f"Mostro '{mid}' senza nome"
stats = monster.get("stats", {})
assert stats.get("ac"), f"Mostro '{mid}' senza CA"
assert stats.get("hp"), f"Mostro '{mid}' senza PF"
monsters_seen.add(mid)
assert len(monsters_seen) >= 1, "Nessun mostro trovato nelle aree"
def test_areas_have_no_duplicate_ids():
ids = [a["id"] for a in get_all_areas()]
assert len(ids) == len(set(ids)), "Trovati ID area duplicati"
def test_quests_have_names():
for q in get_all_quest_defs():
assert q.get("name"), f"Quest senza nome: {q}"
assert q.get("description"), f"Quest '{q['name']}' senza descrizione"
def test_special_items_have_location():
for i in get_special_items():
assert i.get("name"), f"Item senza nome: {i}"
loc = i.get("location", "")
if loc in ("?", ""):
print(f"⚠ Item '{i.get('name')}' senza posizione")
def test_area_features_format():
for a in get_all_areas():
features = a.get("features", [])
assert isinstance(features, list), (
f"Area {a['id']}: features non è una lista"
)
+140
View File
@@ -0,0 +1,140 @@
"""Test parsing risposte LLM — JSON extraction, repair, strip."""
import sys
sys.path.insert(0, "src")
from src.engine.llm import parse_tool_call, _try_repair_json, _strip_json_from_text
def test_parse_valid_json():
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tiro il dado."}'
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
assert "Tiro" in r["narration"]
def test_parse_valid_json_with_all_params():
r = parse_tool_call(
'{"tool": "combat", "params": {"character_id": 1, "target_ac": 15}, "narration": "Attacco!"}'
)
assert r["tool"] == "combat"
assert r["params"]["character_id"] == 1
assert r["params"]["target_ac"] == 15
def test_parse_dm_override():
r = parse_tool_call(
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Scena epica."}}, "narration": "Il mago appare."}'
)
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
def test_parse_plain_text_fallback():
"""Testo senza JSON deve cascare sul fallback dm_override narrate."""
r = parse_tool_call("Esploro la stanza buia con cautela")
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
assert "stanza" in r["narration"]
def test_parse_text_containing_json_residue():
"""Testo con JSON completo + testo extra dopo.
Il JSON viene parsato correttamente, il testo extra nella narrazione
e gestito da dm.py (_llm_decision estrae da params.params.text)."""
r = parse_tool_call(
'{"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "Cripta oscura."}}} La stanza e buia...'
)
assert r["tool"] == "dm_override"
assert r["params"]["command"] == "narrate"
def test_parse_single_quotes():
"""JSON con apici singoli (errore comune LLM)."""
r = parse_tool_call(
"{'tool': 'roll_dice', 'params': {'die': 'd20'}, 'narration': 'Provo fortuna.'}"
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
def test_parse_mixed_quotes():
"""Mix di apici singoli e doppi."""
r = parse_tool_call(
'{"tool": "ability_check", \'params\': {"character_id": 1, "dc": 12}, "narration": "Cerco tracce."}'
)
assert r["tool"] == "ability_check"
assert r["params"]["character_id"] == 1
def test_parse_trailing_commas():
"""Virgole prima di } o ] (errore comune)."""
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die": "d20", "count": 2,}, "narration": "Doppio tiro.",}'
)
assert r["tool"] == "roll_dice"
assert r["params"]["count"] == 2
def test_parse_equals_instead_of_colon():
"""= invece di : nelle chiavi."""
r = parse_tool_call(
'{"tool": "roll_dice", "params": {"die" = "d20"}, "narration": "Tiro."}'
)
assert r["tool"] == "roll_dice"
def test_parse_backtick_code_block():
r = parse_tool_call(
"```json\n{\"tool\": \"roll_dice\", \"params\": {\"die\": \"d20\"}, \"narration\": \"Tiro.\"}\n```"
)
assert r["tool"] == "roll_dice"
assert r["params"]["die"] == "d20"
def test_parse_unknown_tool_uses_fallback():
"""Tool sconosciuto deve cascare su dm_override narrate."""
r = parse_tool_call(
'{"tool": "danza_elfica", "params": {}, "narration": "Ballano."}'
)
assert r["tool"] in ("dm_override", "danza_elfica")
def test_repair_json_single_quotes():
result = _try_repair_json("{'tool': 'test', 'params': {}}")
assert result is not None
assert '"tool"' in result
def test_repair_json_trailing_comma():
result = _try_repair_json('{"tool": "test", "params": {},}')
assert result is not None
def test_repair_json_equals_sign():
result = _try_repair_json('{"tool" = "test", "params" = {}}')
assert result is not None
def test_repair_json_invalid_returns_none():
result = _try_repair_json("non è json")
assert result is None
def test_strip_json_removes_tool_call():
cleaned = _strip_json_from_text(
'{"tool": "ability_check", "params": {}, "narration": "Testo"} Druso si muove.'
)
assert "Druso" in cleaned
assert "tool" not in cleaned
def test_strip_json_partial_block():
cleaned = _strip_json_from_text('{"tool": "test"} Avanza.')
assert "Avanza" in cleaned
def test_strip_json_empty_input():
assert _strip_json_from_text("") == ""
assert _strip_json_from_text(" ") == ""
+33
View File
@@ -123,6 +123,39 @@ def test_dm_override_help():
assert "commands" in r
def test_update_quest_tool():
camp_id, _ = _setup_char()
import src.engine.db as db
qid = db.add_quest(camp_id, "Missione Test", "Descrizione test")
r = run("update_quest", quest_id=qid, status="completed")
assert r["success"] is True
assert r["status"] == "completed"
assert "Missione Test" in r["message"]
def test_update_quest_invalid_status():
camp_id, _ = _setup_char()
import src.engine.db as db
qid = db.add_quest(camp_id, "Missione", "")
r = run("update_quest", quest_id=qid, status="impossibile")
assert r["success"] is False
def test_update_quest_not_found():
r = run("update_quest", quest_id=99999, status="completed")
assert r["success"] is False
assert "non trovata" in r["message"]
def test_update_quest_with_name():
camp_id, _ = _setup_char()
import src.engine.db as db
qid = db.add_quest(camp_id, "Vecchio Nome", "")
r = run("update_quest", quest_id=qid, status="active", name="Nuovo Nome")
assert r["success"] is True
assert "Nuovo Nome" in r["name"]
def test_combat():
_, char_id = _setup_char()
r = run(