fix: JSON repair, WS thread pool, switch to DeepSeek V4 Flash, funnel Lv0
LLM fixes: - _try_repair_json() — ripara JSON malformato (apici, virgole trailing) - _strip_json_from_text() — rimuove residui JSON dalla narration - parse_tool_call() — rimuove troncamento 200 caratteri dal fallback DM fixes: - Fallback euristico quando params vuoti (dm.py) - Estrazione narrazione da dm_override/narrate params.params.text Prompt (prompts.py): - Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...) - Aggiunto vocabolario fantasy obbligatorio Server (server.py): - dm_cycle in ThreadPoolExecutor per non bloccare WS ping - WebSocketDisconnect gestiti - Rimosso import inesistente db_schema Frontend (index.html): - Nascosti tool name dalla chat - Personaggio -> PG, connessioni aree con nomi Infrastruttura: - Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go - pyproject.toml, Dockerfile, docker-compose.yml, start.sh - .env auto-load (start.sh) - Funnel Lv0 (funnel.py) — creazione PG livello 0 Docs: architecture, database, setup, game mechanics, codebase ref
This commit is contained in:
1 parent
6b048dc0cc
commit
eacc946868
23 files changed
+1786
-61
No files matched your search
+12
@@ -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
@@ -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"]
|
||||
@@ -0,0 +1,21 @@
|
||||
version: "3.9"
|
||||
|
||||
services:
|
||||
dcc-rpg:
|
||||
build: .
|
||||
container_name: dcc-rpg
|
||||
ports:
|
||||
- "8300:8300"
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
environment:
|
||||
- DCC_LLM_PROVIDER=${DCC_LLM_PROVIDER:-local}
|
||||
- DCC_OPENROUTER_KEY=${DCC_OPENROUTER_KEY:-}
|
||||
- DCC_OPENROUTER_MODEL=${DCC_OPENROUTER_MODEL:-openrouter/free}
|
||||
- DCC_OLLAMA_URL=${DCC_OLLAMA_URL:-http://192.168.0.50:11434}
|
||||
- DCC_OLLAMA_MODEL=${DCC_OLLAMA_MODEL:-gemma2:9b}
|
||||
- DCC_LOCAL_URL=${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}
|
||||
- DCC_LOCAL_KEY=${DCC_LOCAL_KEY:-}
|
||||
- DCC_LOCAL_MODEL=${DCC_LOCAL_MODEL:-deepseek-v4-flash}
|
||||
- DCC_LLM_TIMEOUT=${DCC_LLM_TIMEOUT:-180}
|
||||
restart: unless-stopped
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 |
|
||||
@@ -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
|
||||
@@ -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).
|
||||
@@ -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
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 462 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 503 KiB |
@@ -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*"]
|
||||
@@ -15,10 +15,10 @@ class ProviderConfig:
|
||||
openrouter_model: str = "openrouter/free"
|
||||
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
|
||||
openrouter_free_fallback: bool = True
|
||||
local_base_url: str = "http://192.168.0.240/v1"
|
||||
local_base_url: str = "https://opencode.ai/zen/go/v1"
|
||||
local_api_key: str = ""
|
||||
local_model: str = "big-pickle"
|
||||
timeout: int = 60
|
||||
local_model: str = "deepseek-v4-flash"
|
||||
timeout: int = 120
|
||||
|
||||
|
||||
def load_from_env() -> ProviderConfig:
|
||||
|
||||
@@ -31,6 +31,21 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
|
||||
log.info("LLM non ha prodotto tool valido, fallback heuristico")
|
||||
tool, params = fallback_parse(player_action, ctx.characters)
|
||||
|
||||
# Se il JSON era malformato e params sono vuoti, usa l'euristico per i parametri
|
||||
elif not params and tool not in ("dm_override",):
|
||||
log.info("Tool %s senza params, integro con fallback euristico", tool)
|
||||
_, fallback_params = fallback_parse(player_action, ctx.characters)
|
||||
params = fallback_params
|
||||
|
||||
# dm_override/narrate: il LLM spesso mette il testo in params.params.text
|
||||
# invece che nel campo narration JSON — lo estraiamo qui
|
||||
if tool == "dm_override" and not narration:
|
||||
inner = params.get("params", {}) if isinstance(params, dict) else {}
|
||||
if isinstance(inner, dict):
|
||||
text = inner.get("text", "")
|
||||
if text:
|
||||
narration = text
|
||||
|
||||
return tool, params, narration
|
||||
|
||||
|
||||
|
||||
@@ -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")
|
||||
@@ -45,9 +45,11 @@ class GameContext:
|
||||
if self.current_area_id:
|
||||
lines.append(f"Area corrente: {self.current_area_name or self.current_area_id}")
|
||||
for c in self.characters:
|
||||
cls = c.get("char_class") or ""
|
||||
lv = c.get("level", 0)
|
||||
cls = c.get("occupation") or c.get("char_class") or ""
|
||||
label = f"{cls} (Lv{lv})" if lv == 0 else f"{c.get('char_class', '')} Lv{lv}"
|
||||
lines.append(
|
||||
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
|
||||
f" [{c['id']}] {c['name']} — {label} "
|
||||
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
|
||||
)
|
||||
if self.quests:
|
||||
|
||||
+49
-4
@@ -27,6 +27,30 @@ def ask(prompt: str, system: str = "") -> str:
|
||||
return get_provider().ask(prompt, system=system)
|
||||
|
||||
|
||||
def _try_repair_json(text: str) -> str | None:
|
||||
"""Tenta di riparare errori JSON comuni prodotti dagli LLM."""
|
||||
m = re.search(r'(\{[\s\S]*\})', text)
|
||||
if not m:
|
||||
return None
|
||||
blob = m.group(1)
|
||||
|
||||
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params": ")
|
||||
blob = re.sub(r"(?<!\\)'(params|narration|tool|command|text|character_id|dc|modifier|die|count)"
|
||||
r"'(?=\s*[:=])", r'"\1":', blob)
|
||||
# Fix 2: qualsiasi altra chiave con apici singoli
|
||||
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1":', blob)
|
||||
# Fix 3: trailing comma prima di } o ]
|
||||
blob = re.sub(r',\s*}', '}', blob)
|
||||
blob = re.sub(r',\s*\]', ']', blob)
|
||||
# Fix 4: = al posto di : nelle chiavi
|
||||
blob = re.sub(r'"(\w+)"\s*=', r'"\1":', blob)
|
||||
try:
|
||||
json.loads(blob)
|
||||
return blob
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json(text: str) -> str | None:
|
||||
for pat in [
|
||||
r'```json\s*([\s\S]*?)```',
|
||||
@@ -40,8 +64,27 @@ def _extract_json(text: str) -> str | None:
|
||||
json.loads(candidate)
|
||||
return candidate
|
||||
except json.JSONDecodeError:
|
||||
# tenta riparazione
|
||||
repaired = _try_repair_json(candidate)
|
||||
if repaired:
|
||||
return repaired
|
||||
continue
|
||||
return None
|
||||
# try repair on full text too
|
||||
return _try_repair_json(text)
|
||||
|
||||
|
||||
def _strip_json_from_text(text: str) -> str:
|
||||
"""Rimuove qualsiasi contenuto simile a JSON dal testo narrativo."""
|
||||
# toglie blocchi {...} completi di tool call
|
||||
cleaned = re.sub(r'\{"tool":[\s\S]*?"narration":\s*"[^"]*"\s*\}', '', text)
|
||||
# toglie blocchi JSON parziali o incompleti
|
||||
cleaned = re.sub(r'\{"tool":[\s\S]*?\}', '', cleaned)
|
||||
cleaned = re.sub(r'\{\s*"[a-zA-Z_]+"\s*[:=][^}]*\}', '', cleaned)
|
||||
# pulisce spaziature multiple
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
# toglie virgolette pendenti all'inizio/fine
|
||||
cleaned = cleaned.strip('"\' ,.')
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_tool_call(text: str) -> dict:
|
||||
@@ -55,10 +98,12 @@ def parse_tool_call(text: str) -> dict:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
|
||||
# Fallback: estrae tool name via regex, ma PULISCE la narration dal JSON
|
||||
tool_match = re.search(r'\b(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
|
||||
narration = _strip_json_from_text(text) if text else ""
|
||||
if tool_match:
|
||||
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
|
||||
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
|
||||
return {"tool": tool_match.group(1), "params": {}, "narration": narration}
|
||||
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": narration or text[:500]}}, "narration": narration}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,9 @@ STILE:
|
||||
- Usa ITALIANO.
|
||||
- Dai sempre una scelta al giocatore alla fine.
|
||||
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
|
||||
- Usa VOCABOLARIO FANTASY: pietra, tenebra, fiamma, sangue, ferro, ossa, ombra, polvere, ruggine, muschio, crepitio, sussurro, pulsare, vibrare, brillare, incrinato, logoro, antico, dimenticato.
|
||||
- VIETATO usare: termini tecnici, scientifici, moderni, anacronistici (niente HPLC, laser, DNA, algoritmi, frequenze, ecc.).
|
||||
- Niente acronimi, niente linguaggio da laboratorio o ingegneria.
|
||||
|
||||
MECCANICHE:
|
||||
- CD base: 12 = facile, 15 = media, 18 = difficile
|
||||
@@ -88,7 +91,9 @@ def format_context(ctx: GameContext) -> str:
|
||||
|
||||
lines.append("PERSONAGGI:")
|
||||
for c in ctx.characters:
|
||||
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
|
||||
lv = c.get("level", 0)
|
||||
label = c.get("occupation", "") if lv == 0 else c.get("char_class", "")
|
||||
bl = f" [{c['id']}] {c['name']} — Lv{lv} {label} ({c.get('alignment', '?')})"
|
||||
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
|
||||
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
|
||||
lines.extend([bl, stat_str, hp_str])
|
||||
|
||||
+79
-27
@@ -1,13 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import asyncio
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
|
||||
import uvicorn # noqa: F401 — needed by main()
|
||||
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
from src.engine import db
|
||||
from src.engine.config import load_from_env
|
||||
@@ -51,11 +53,16 @@ def get_campaign_state(campaign_id: int):
|
||||
for sa in ctx.areas:
|
||||
aid = sa["area_id"]
|
||||
full = area_map.get(aid, {})
|
||||
raw_conns = full.get("connections", [])
|
||||
conn_names = []
|
||||
for cid in raw_conns:
|
||||
linked = area_map.get(cid)
|
||||
conn_names.append(linked["name"] if linked else cid)
|
||||
enriched.append({
|
||||
"id": aid,
|
||||
"name": full.get("name", aid),
|
||||
"description": full.get("description", "")[:200],
|
||||
"connections": full.get("connections", []),
|
||||
"connections": conn_names,
|
||||
"visited": bool(sa.get("visited")),
|
||||
"features": full.get("features", [])[:3],
|
||||
})
|
||||
@@ -78,32 +85,51 @@ def create_campaign(name: str = "Nuova Avventura", adventure: str = ""):
|
||||
areas = db.get_all_areas()
|
||||
if areas:
|
||||
db.set_area_state(camp_id, areas[0]["id"], visited=True)
|
||||
return {"campaign_id": camp_id}
|
||||
for pg in genera_funnel(4):
|
||||
_create_lv0_character(camp_id, pg)
|
||||
return {"campaign_id": camp_id, "funnel": True}
|
||||
|
||||
|
||||
def _create_lv0_character(campaign_id: int, pg: dict) -> int:
|
||||
char_id = db.create_character(
|
||||
campaign_id,
|
||||
name=pg["name"],
|
||||
char_class=f"Lv0 ({pg['occupation']})",
|
||||
occupation=pg["occupation"],
|
||||
)
|
||||
db.update_character(
|
||||
char_id,
|
||||
level=0,
|
||||
str=pg["str"],
|
||||
agi=pg["agi"],
|
||||
sta=pg["sta"],
|
||||
per=pg["per"],
|
||||
int=pg["int"],
|
||||
lck=pg["lck"],
|
||||
hp_current=pg["hp_current"],
|
||||
hp_max=pg["hp_max"],
|
||||
ac=pg["ac"],
|
||||
armor_type=pg["armor_type"],
|
||||
weapon=pg["weapon"],
|
||||
equipment=f'["{pg["weapon"]}", "{pg["belonging"]}"]',
|
||||
)
|
||||
return char_id
|
||||
|
||||
|
||||
@app.post("/api/campaign/{campaign_id}/character")
|
||||
def add_character(
|
||||
campaign_id: int,
|
||||
name: str = "Eroe",
|
||||
name: str = "",
|
||||
char_class: str = "",
|
||||
occupation: str = "",
|
||||
):
|
||||
char_id = db.create_character(campaign_id, name, char_class, occupation)
|
||||
# Default stats for a lv0/lv1 character
|
||||
db.update_character(
|
||||
char_id,
|
||||
level=1,
|
||||
str=12,
|
||||
agi=11,
|
||||
sta=12,
|
||||
per=10,
|
||||
int=10,
|
||||
lck=10,
|
||||
hp_current=8,
|
||||
hp_max=8,
|
||||
ac=12,
|
||||
)
|
||||
return {"character_id": char_id}
|
||||
if name and char_class:
|
||||
char_id = db.create_character(campaign_id, name, char_class, occupation)
|
||||
db.update_character(char_id, level=1, str=12, agi=11, sta=12, per=10, int=10, lck=10, hp_current=8, hp_max=8, ac=12)
|
||||
return {"character_id": char_id}
|
||||
pg = genera_pg_lv0(name)
|
||||
char_id = _create_lv0_character(campaign_id, pg)
|
||||
return {"character_id": char_id, "level": 0, "name": pg["name"], "occupation": pg["occupation"]}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -111,6 +137,9 @@ def add_character(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_executor = ThreadPoolExecutor(max_workers=4)
|
||||
|
||||
|
||||
@app.websocket("/ws/{campaign_id}")
|
||||
async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
await ws.accept()
|
||||
@@ -126,7 +155,11 @@ async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
|
||||
|
||||
while True:
|
||||
raw = await ws.receive_text()
|
||||
try:
|
||||
raw = await ws.receive_text()
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
try:
|
||||
msg = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
@@ -138,7 +171,12 @@ async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
continue
|
||||
|
||||
try:
|
||||
out = dm_cycle(campaign_id, player_action)
|
||||
# dm_cycle è bloccante (chiamata HTTP al LLM) —
|
||||
# la eseguiamo in un thread worker per non bloccare
|
||||
# il loop asyncio e tenere vivi i ping WebSocket
|
||||
out = await asyncio.get_event_loop().run_in_executor(
|
||||
_executor, dm_cycle, campaign_id, player_action
|
||||
)
|
||||
await ws.send_json({
|
||||
"type": "response",
|
||||
"turn": out["turn"],
|
||||
@@ -150,13 +188,22 @@ async def game_ws(ws: WebSocket, campaign_id: int):
|
||||
})
|
||||
except Exception as e:
|
||||
log.exception("DM cycle failed")
|
||||
await ws.send_json({"type": "error", "message": str(e)})
|
||||
try:
|
||||
await ws.send_json({"type": "error", "message": str(e)})
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
# Send updated state after action
|
||||
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
|
||||
try:
|
||||
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
elif msg.get("type") == "ping":
|
||||
await ws.send_json({"type": "pong"})
|
||||
try:
|
||||
await ws.send_json({"type": "pong"})
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
|
||||
def _build_state(campaign_id: int) -> dict:
|
||||
@@ -167,11 +214,16 @@ def _build_state(campaign_id: int) -> dict:
|
||||
for sa in ctx.areas:
|
||||
aid = sa["area_id"]
|
||||
full = area_map.get(aid, {})
|
||||
raw_conns = full.get("connections", [])
|
||||
conn_names = []
|
||||
for cid in raw_conns:
|
||||
linked = area_map.get(cid)
|
||||
conn_names.append(linked["name"] if linked else cid)
|
||||
enriched.append({
|
||||
"id": aid,
|
||||
"name": full.get("name", aid),
|
||||
"visited": bool(sa.get("visited")),
|
||||
"connections": full.get("connections", []),
|
||||
"connections": conn_names,
|
||||
})
|
||||
return {
|
||||
"turn": ctx.turn,
|
||||
|
||||
+28
-24
@@ -552,7 +552,7 @@ html, body {
|
||||
<!-- SIDEBAR -->
|
||||
<div id="sidebar">
|
||||
<div class="sidebar-section" id="char-section">
|
||||
<h2>Personaggio</h2>
|
||||
<h2>PG</h2>
|
||||
<div id="char-display"><div class="empty-state">Nessun PG</div></div>
|
||||
</div>
|
||||
<div class="sidebar-section" id="quest-section">
|
||||
@@ -750,9 +750,7 @@ function handleMessage(msg) {
|
||||
if (msg.mechanics) {
|
||||
bubble += `<div class="msg-dm-mechanics">${escapeHtml(msg.mechanics)}</div>`;
|
||||
}
|
||||
if (msg.tool) {
|
||||
bubble += `<div class="msg-dm-tool">${escapeHtml(msg.tool)}</div>`;
|
||||
}
|
||||
// Debug tool name — nascosto all'utente
|
||||
bubble += '</div>';
|
||||
div.innerHTML = bubble;
|
||||
chat.appendChild(div);
|
||||
@@ -788,26 +786,32 @@ function renderChar(chars) {
|
||||
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
|
||||
return;
|
||||
}
|
||||
const c = chars[0];
|
||||
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
|
||||
charDisplay.innerHTML = `
|
||||
<div class="char-name">${escapeHtml(c.name)}</div>
|
||||
<div class="char-class">${c.char_class || 'Lv0'} · Livello ${c.level}</div>
|
||||
<div class="hp-bar-wrapper">
|
||||
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
|
||||
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
|
||||
</div>
|
||||
<div class="char-ac">Armatura: <span>${c.ac||'?'}</span></div>
|
||||
<div class="char-weapon">Arma: <span>${c.weapon || 'nessuna'}</span></div>
|
||||
`;
|
||||
charDisplay.innerHTML = chars.map(c => {
|
||||
const isLv0 = c.level === 0;
|
||||
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
|
||||
const label = isLv0
|
||||
? `${c.occupation || 'Mestiere'} · Lv0`
|
||||
: `${c.char_class || ''} · Livello ${c.level}`;
|
||||
return `
|
||||
<div class="char-card" style="border-bottom:1px solid var(--border);padding:8px 0;">
|
||||
<div class="char-name">${escapeHtml(c.name)}</div>
|
||||
<div class="char-class">${escapeHtml(label)}</div>
|
||||
<div class="hp-bar-wrapper">
|
||||
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
|
||||
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
|
||||
</div>
|
||||
<div class="stat-grid">
|
||||
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">AGI</span><span class="stat-val">${c.agi||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">STA</span><span class="stat-val">${c.sta||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">PER</span><span class="stat-val">${c.per||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">INT</span><span class="stat-val">${c.int||'?'}</span></div>
|
||||
<div class="stat-row"><span class="stat-label">LCK</span><span class="stat-val">${c.lck||'?'}</span></div>
|
||||
</div>
|
||||
<div class="char-ac">Arma: <span>${escapeHtml(c.weapon || 'nessuna')}</span> · CA <span>${c.ac||'?'}</span></div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderQuests(quests) {
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
cd "$DIR"
|
||||
|
||||
# ── load .env if present ────────────────────────────────────────────
|
||||
if [[ -f .env ]]; then
|
||||
set -a; source .env; set +a
|
||||
fi
|
||||
|
||||
# ── provider: openrouter | ollama | local ──────────────────────────
|
||||
# Per OpenCode API: export DCC_LLM_PROVIDER=local + set DCC_LOCAL_KEY
|
||||
export DCC_LLM_PROVIDER="${DCC_LLM_PROVIDER:-local}"
|
||||
export DCC_OPENROUTER_KEY="${DCC_OPENROUTER_KEY:-}"
|
||||
export DCC_OPENROUTER_MODEL="${DCC_OPENROUTER_MODEL:-openrouter/free}"
|
||||
export DCC_OLLAMA_URL="${DCC_OLLAMA_URL:-http://192.168.0.50:11434}"
|
||||
export DCC_OLLAMA_MODEL="${DCC_OLLAMA_MODEL:-gemma2:9b}"
|
||||
export DCC_LOCAL_URL="${DCC_LOCAL_URL:-https://opencode.ai/zen/go/v1}"
|
||||
export DCC_LOCAL_KEY="${DCC_LOCAL_KEY:-}"
|
||||
export DCC_LOCAL_MODEL="${DCC_LOCAL_MODEL:-deepseek-v4-flash}"
|
||||
|
||||
# ── create data dir ────────────────────────────────────────────────
|
||||
mkdir -p data
|
||||
|
||||
# ── install deps if missing ────────────────────────────────────────
|
||||
if ! pip show fastapi &>/dev/null 2>&1; then
|
||||
echo "→ Installing Python dependencies..."
|
||||
pip install --quiet -e .
|
||||
fi
|
||||
|
||||
# ── start server ───────────────────────────────────────────────────
|
||||
echo "→ DCC RPG — AI Dungeon Master"
|
||||
echo " Provider: ${DCC_LLM_PROVIDER}"
|
||||
echo " http://0.0.0.0:8300"
|
||||
echo ""
|
||||
python -m src.web.server
|
||||
Reference in new issue
Block a user