diff --git a/.gitignore b/.gitignore index 5a88bda..9e512a5 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..de97133 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a16e780 --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..2e5b071 --- /dev/null +++ b/docs/README.md @@ -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 diff --git a/docs/adventure-data.md b/docs/adventure-data.md new file mode 100644 index 0000000..c4b259e --- /dev/null +++ b/docs/adventure-data.md @@ -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. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..444f9c4 --- /dev/null +++ b/docs/architecture.md @@ -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 diff --git a/docs/codebase-reference.md b/docs/codebase-reference.md new file mode 100644 index 0000000..a42b614 --- /dev/null +++ b/docs/codebase-reference.md @@ -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 diff --git a/docs/database-schema.md b/docs/database-schema.md new file mode 100644 index 0000000..1472292 --- /dev/null +++ b/docs/database-schema.md @@ -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 | diff --git a/docs/development-guide.md b/docs/development-guide.md new file mode 100644 index 0000000..bded49a --- /dev/null +++ b/docs/development-guide.md @@ -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 diff --git a/docs/game-mechanics.md b/docs/game-mechanics.md new file mode 100644 index 0000000..ef01888 --- /dev/null +++ b/docs/game-mechanics.md @@ -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). diff --git a/docs/setup-guide.md b/docs/setup-guide.md new file mode 100644 index 0000000..c0f48bc --- /dev/null +++ b/docs/setup-guide.md @@ -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 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://:/v1 +export DCC_LOCAL_KEY=... +export DCC_LOCAL_MODEL= +``` + +## 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 +``` diff --git a/input/screen.jpg b/input/screen.jpg new file mode 100644 index 0000000..97025d8 Binary files /dev/null and b/input/screen.jpg differ diff --git a/input/screen2.PNG b/input/screen2.PNG new file mode 100644 index 0000000..9f885ba Binary files /dev/null and b/input/screen2.PNG differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3f3e013 --- /dev/null +++ b/pyproject.toml @@ -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*"] diff --git a/src/engine/config.py b/src/engine/config.py index 77a210e..37e1b49 100644 --- a/src/engine/config.py +++ b/src/engine/config.py @@ -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: diff --git a/src/engine/dm.py b/src/engine/dm.py index f0b09db..17dbd52 100644 --- a/src/engine/dm.py +++ b/src/engine/dm.py @@ -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 diff --git a/src/engine/funnel.py b/src/engine/funnel.py new file mode 100644 index 0000000..65bebd6 --- /dev/null +++ b/src/engine/funnel.py @@ -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") diff --git a/src/engine/game_context.py b/src/engine/game_context.py index 095f6ca..60ede62 100644 --- a/src/engine/game_context.py +++ b/src/engine/game_context.py @@ -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: diff --git a/src/engine/llm.py b/src/engine/llm.py index ae465e4..4f1870d 100644 --- a/src/engine/llm.py +++ b/src/engine/llm.py @@ -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"(? 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__": diff --git a/src/engine/prompts.py b/src/engine/prompts.py index 132ef45..1baef72 100644 --- a/src/engine/prompts.py +++ b/src/engine/prompts.py @@ -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]) diff --git a/src/web/server.py b/src/web/server.py index 87d0abf..4d7a690 100644 --- a/src/web/server.py +++ b/src/web/server.py @@ -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, diff --git a/src/web/static/index.html b/src/web/static/index.html index 6101467..45338c6 100644 --- a/src/web/static/index.html +++ b/src/web/static/index.html @@ -552,7 +552,7 @@ html, body {