From 90f823fdb9988fdf647fa936b60c3abed55de260 Mon Sep 17 00:00:00 2001 From: truetype Date: Fri, 26 Jun 2026 20:31:34 +0200 Subject: [PATCH] feat: AI Dungeon Master engine + web frontend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Engine: stato SQLite, state machine, 10 approved tools, parser azioni - LLM: multi-provider (Ollama/OpenRouter/Local), system prompt DCC - DM cycle: LLM decide tool → engine esegue → narrazione + meccanica - Dati avventura: JSON machine-readable con 9 aree, mostri, enigmi, quest - Web: FastAPI + WebSocket backend, SPA frontend dark fantasy - Ref: architecture-patterns/neon-scratch-lounge (separazione reasoning/execution) --- .gitignore | 2 + data/adventure/avventura.json | 790 ++++++++++++++++++++++++++++++ data/crit_fumble_tables.json | 207 ++++++++ src/__init__.py | 0 src/engine/__init__.py | 17 + src/engine/config.py | 35 ++ src/engine/db.py | 557 +++++++++++++++++++++ src/engine/dice.py | 272 +++++++++++ src/engine/dm.py | 110 +++++ src/engine/llm.py | 111 +++++ src/engine/prompts.py | 144 ++++++ src/engine/providers.py | 141 ++++++ src/engine/state_machine.py | 160 ++++++ src/engine/tools.py | 421 ++++++++++++++++ src/web/__init__.py | 0 src/web/server.py | 227 +++++++++ src/web/static/index.html | 888 ++++++++++++++++++++++++++++++++++ 17 files changed, 4082 insertions(+) create mode 100644 data/adventure/avventura.json create mode 100644 data/crit_fumble_tables.json create mode 100644 src/__init__.py create mode 100644 src/engine/__init__.py create mode 100644 src/engine/config.py create mode 100644 src/engine/db.py create mode 100644 src/engine/dice.py create mode 100644 src/engine/dm.py create mode 100644 src/engine/llm.py create mode 100644 src/engine/prompts.py create mode 100644 src/engine/providers.py create mode 100644 src/engine/state_machine.py create mode 100644 src/engine/tools.py create mode 100644 src/web/__init__.py create mode 100644 src/web/server.py create mode 100644 src/web/static/index.html diff --git a/.gitignore b/.gitignore index 25f926e..fa2433f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ __pycache__/ *.pyc *.pyo *.db +*.db-shm +*.db-wal # Tool configuration (not part of project) .opencode/ diff --git a/data/adventure/avventura.json b/data/adventure/avventura.json new file mode 100644 index 0000000..bf202d5 --- /dev/null +++ b/data/adventure/avventura.json @@ -0,0 +1,790 @@ +{ + "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) piu una quest esterna nella foresta. Nessuna porta e chiusa a chiave.", + "mortality_style": "DCC classico: alta mortalita, progettato per 'setaccio' con 15-20 PG di livello 0 (2-3 per giocatore), perdita media 7-8 PG su 15, soluzioni creative > combattimento diretto", + "portal_condition": "Il portale d'ingresso appare solo una volta ogni mezzo secolo, quando la costellazione della Stella Vuota sorge alla giusta altezza nel cielo notturno" + }, + "areas": [ + { + "id": "1.1", + "name": "Portale", + "order": 1, + "description": "Corridoio d'ingresso visibile soltanto attraverso il portale stellare. Le pietre del lastricato appaiono solide sotto i piedi nonostante l'aspetto immateriale del passaggio dall'esterno. La luce stellare svanisce man mano che si procede, terminando contro una porta rinforzata in ferro. Sulla porta sono incastonati gioielli e cristalli in un'insolita disposizione a forma di stelle.", + "atmosphere": "Aria stantia, polvere millenaria, silenzio pressante. La luce stellare proietta forme contorte sui muri del corridoio. Un suono lontano e sibilante sembra provenire da oltre la porta (eco lontana del demone-serpente in area 1.4). La sensazione che il mondo conosciuto stia svanendo alle spalle: voltandosi, il portale appare ancora aperto, ma il villaggio non e piu riconoscibile sotto la luce stellare.", + "connections": ["1.2"], + "features": [ + "Porta rinforzata in ferro con gioielli e cristalli incastonati a forma di stelle", + "Lastricato di pietra che termina contro la porta", + "Iscrizioni stellari sulla porta che replicano il cielo notturno visibile dal lato opposto del corridoio" + ], + "puzzles": [ + { + "name": "Apertura della Porta (metodo regolare)", + "description": "La disposizione delle stelle incise sulla porta replica esattamente il cielo notturno visibile dal lato opposto del corridoio. Se i PG attendono 2 ore, il moto del cielo notturno fa si che la vista riveli lo stesso motivo della porta. Durante questo intervallo di 10 minuti la porta si apre facilmente. In ogni altro momento la porta non si muove di un dito.", + "solution": "Attendere 2 ore l'allineamento delle stelle e aprire la porta nell'intervallo di 10 minuti durante cui il motivo coincide", + "skill_checks": [ + {"skill": "Intelligenza", "dc": 14, "description": "Accorgersi dell'allineamento stellare necessario per aprire la porta"} + ] + }, + { + "name": "Forzare la porta (metodo rischioso)", + "description": "Trattare la porta come chiusa a chiave. Forzarla attiva la trappola della Luce Bruciante.", + "solution": "Forza CD 15 per buttarla giu, oppure Scassinare CD 15 per il ladro", + "skill_checks": [ + {"skill": "Forza", "dc": 15, "description": "Buttare giu la porta"}, + {"skill": "Scassinare", "dc": 15, "description": "Aprire la serratura (solo ladro)"} + ] + } + ], + "traps": [ + { + "name": "Luce Bruciante", + "trigger": "La porta viene forzata (Forza o Scassinare)", + "effect": "Una luce bruciante scaturisce dalle iscrizioni a forma di stella e colpisce il PG di testa. La trappola si scarica al primo innesco e non si riarma.", + "detect_dc": 20, + "damage": "1d8", + "save": {"type": "Riflessi", "dc": 10, "effect_on_save": "danno dimezzato"} + } + ], + "monsters": [], + "items": [], + "treasure": [] + }, + { + "id": "1.2", + "name": "Sala del Guardiano", + "order": 2, + "description": "Statua di pietra con dito proteso verso l'uscita. Difesa automatica del dungeon. La statua e troppo grande per essere spostata o danneggiata.", + "atmosphere": "Sala silenziosa dominata dalla statua di pietra incombente che punta verso le porte. L'eco della statua lanciafiamme si sente fino in area 1.3 quando attiva.", + "connections": ["1.1", "1.3"], + "features": [ + "Statua di pietra colossale con dito proteso verso l'uscita", + "Porte staccabili dai cardini utilizzabili come scudo improvvisato" + ], + "puzzles": [ + { + "name": "Placare la Statua", + "description": "La statua puo essere placata pronunciando il nome dimenticato del mago guerriero (Vorkhul Ksh'tharan). Il nome si scopre combinando i frammenti della Colonna Centrale in area 1.3 ('V-R-KH-L') con la seconda meta rivelata dal tomo dell'area 1.9 ('Ksh'tharan').", + "solution": "Pronunciare ad alta voce il nome completo: Vorkhul Ksh'tharan. La statua si inchina e cessa ogni attacco.", + "skill_checks": [] + }, + { + "name": "Usare le porte come scudo", + "description": "Staccare le porte dai cardini (aree 1 e 2) per usarle come scudi improvvisati contro il lanciafiamme.", + "solution": "Staccare le porte dai cardini: +4 CA, ma movimento dimezzato durante il trasporto", + "skill_checks": [] + } + ], + "traps": [ + { + "name": "Statua Lanciafiamme", + "trigger": "Muoversi per uscire dalla stanza (aprire una porta o tentare di andarsene)", + "effect": "La statua erutta fiamme. Attacco +6 a colpire, 1d6 danni immediati + 1d6 brucianti/round finche un Riflessi CD 10 estingue le fiamme sul personaggio. La trappola dura 5 round poi esaurisce combustibile. Non si placa con la forza: solo il nome del mago o le porte-scudo proteggono.", + "detect_dc": null, + "damage": "1d6 + 1d6 brucianti/round", + "save": {"type": "Riflessi", "dc": 10, "effect_on_save": "estingue le fiamme che ardono sul personaggio"} + } + ], + "monsters": [], + "items": [], + "treasure": [] + }, + { + "id": "1.3", + "name": "Sala Monumentale", + "order": 3, + "description": "Sala spaziosa con pavimento di marmo levigato, una porta su ogni parete (le porte collegano alle aree 1.2, 1.4 e a un corridoio cieco). Sette colossali colonne di basalto nero alte 9 metri si ergono in cerchio al centro della sala. Ognuna e incisa con scene delle conquiste del mago guerriero.", + "atmosphere": "L'aria e gelida nonostante l'assenza di spifferi. La luce delle torce si appiccica alle incisioni come miele scuro. Eco della statua lanciafiamme di area 1.2 percepibile come basso brontolio meccanico se la statua e ancora attiva.", + "connections": ["1.2", "1.4", "1.5"], + "features": [ + "Colonna 1 - Colonna del Patto: figure aliene scendono da un cielo senza stelle, donando bastoni di bronzo e bracieri di metallo a un guerriero in ginocchio", + "Colonna 2 - Colonna dell'Unione: orde barbariche dalle fronti prominenti si radunano sotto uno stendardo a forma di stella vuota", + "Colonna 3 - Colonna dell'Assedio: citta dalle alte torri viene espugnata; mura crollanti, difensori incatenati", + "Colonna 4 - Colonna della Battaglia Campale: eserciti scontrandosi nella nebbia; guerrieri straccio caduti a centinaia", + "Colonna 5 - Colonna del Sacrificio: prigionieri immolati sopra un altare ardente sotto una pioggia di meteore", + "Colonna 6 - Colonna dell'Incoronazione: il mago guerriero riceve dagli alieni una corona di pietra stellare", + "Colonna 7 - Colonna Centrale (piu alta): nome inciso in glifi alieni parzialmente eroso. Leggibili solo 'V-R-KH-L' su una riga e alcuni glifi incomprensibili sull'altra (frammento mancante recuperabile dal tomo in area 1.9)", + "Mosaico stellare al centro del pavimento: descrive il cielo notturno che sorgera fra ventimila anni, quando gli alieni torneranno" + ], + "puzzles": [ + { + "name": "Riconoscere il Mosaico Stellare", + "description": "Un grande mosaico stellare ricopre il centro della sala, descrivendo un cielo notturno mai visto. Le costellazioni sono le stesse che decorano il fondo della vasca in area 1.6.", + "solution": "Riconoscere le costellazioni come quelle della vasca in area 1.6 - il cielo che sorgera fra ventimila anni quando gli alieni torneranno", + "skill_checks": [ + {"skill": "Intelligenza", "dc": 14, "description": "Riconoscere le costellazioni del mosaico come future, non attuali"} + ] + }, + { + "name": "Decifrare il Nome sulla Colonna Centrale", + "description": "Il nome del mago guerriero e inciso in glifi alieni parzialmente erosi. Solo 'V-R-KH-L' e leggibile. Il resto si trova nel tomo dell'area 1.9.", + "solution": "Combinare il frammento 'Vorkhul' della Colonna Centrale con 'Ksh'tharan, il Vincolato alle Stelle' dal tomo dell'area 1.9 per ottenere il nome completo", + "skill_checks": [] + }, + { + "name": "Bandire la Sentinella della Cenere senza combattere", + "description": "Quattro metodi per evitare il combattimento con la Sentinella della Cenere.", + "solution": "1) Recitare il nome completo del mago guerriero (Vorkhul Ksh'tharan) - la sentinella si inchina e si dissolve in cenere argentea. 2) Toccare la Colonna del Patto (Colonna 1) e proferire una formula di vassallaggio sincera (es. 'Servo il patto stellare') - Volonta CD 12 per non vacillare - la sentinella riconosce il giuramento e si placa. 3) Mostrare il corno demoniaco (preso dall'area 1.4) - la sentinella esita per 1d4 round, dando al gruppo tempo per fuggire o aggirarla. 4) Distruggerla in combattimento (rischioso ma fattibile).", + "skill_checks": [ + {"skill": "Volonta", "dc": 12, "description": "Non vacillare proferendo la formula di vassallaggio toccando la Colonna del Patto"} + ] + }, + { + "name": "Trovare il Tesoro Nascosto", + "description": "Alla base della Colonna Centrale c'e una piastrella di marmo sciolta che nasconde un piccolo tesoro.", + "solution": "Cercare attentamente alla base della Colonna Centrale per trovare la piastrella sciolta", + "skill_checks": [ + {"skill": "Cercare", "dc": 12, "description": "Trovare la piastrella di marmo sciolta alla base della Colonna Centrale"} + ] + } + ], + "traps": [], + "monsters": [ + { + "ref_id": "sentinella_cenere", + "count": 1, + "tactics": "Si materializza tra le colonne quando i PG superano la meta della sala (al raggiungere la quarta colonna). Non parla, lancia solo giavellotti di gelida luce stellare a 9 m di distanza. I giavellotti spettrali si dissolvono dopo l'impatto e non sono recuperabili. Subisce solo meta danno da armi non magiche (arrotondato per difetto). Quando ridotta a 0 PF si dissolve in cenere argentea (1d4 mo recuperabili in fiala come componente per incantesimi di proiezione astrale)." + } + ], + "items": [ + { + "name": "Scalpello di bronzo con manico d'osso", + "description": "Utile per incidere o scheggiare iscrizioni antiche. Si trova sotto la piastrella di marmo sciolta alla base della Colonna Centrale.", + "effect": "+2 a prove di scassinare meccanismi di pietra. Strumento adatto per spezzare il cerchio di cristallo del pentacolo in area 1.9 (Forza CD 16)." + }, + { + "name": "Moneta stellare", + "description": "Moneta in metallo non identificabile, trovata sotto la piastrella di marmo sciolta. Vale 5 mo da un mercante curioso.", + "effect": "Uno stregone studiandola puo tirare prova d'incantesimo CD 15 per identificarla come gettone di passaggio extraplanare - un singolo uso per rievocare il portale stellare anche fuori dal ciclo di mezzo secolo." + } + ], + "treasure": [ + { + "name": "Scalpello di bronzo", + "value": "non commerciabile", + "location": "Sotto la piastrella di marmo sciolta alla base della Colonna Centrale (Cercare CD 12)" + }, + { + "name": "Moneta stellare", + "value": "5 mo (mercante curioso) o gettone di passaggio extraplanare se identificata", + "location": "Sotto la piastrella di marmo sciolta alla base della Colonna Centrale (Cercare CD 12)" + }, + { + "name": "Cenere argentea della Sentinella", + "value": "1d4 mo, componente per incantesimi di proiezione astrale", + "location": "Raccoglibile in fiala se la Sentinella della Cenere viene distrutta in combattimento" + } + ] + }, + { + "id": "1.4", + "name": "Sala delle Visioni", + "order": 4, + "description": "Sala con trono di metallo, tavolette di creta alle pareti (scritte in simboli alieni) e un portale magico sospeso dietro la porta.", + "atmosphere": "Sibili lontani del demone-serpente Ssisssuraaaaggg si percepiscono fino al portale d'ingresso. Il portale magico mostra stelle diverse, mai viste nel cielo notturno, con costellazioni che si muovono lentamente.", + "connections": ["1.3"], + "features": [ + "Trono di metallo", + "Tavolette di creta alle pareti scritte in simboli alieni", + "Portale magico sospeso dietro la porta che mostra stelle diverse con costellazioni che si muovono lentamente" + ], + "puzzles": [ + { + "name": "Usare il Trono e il Portale per vedere luoghi lontani", + "description": "Seduti sul trono con la porta chiusa, si guarda direttamente il portale. Un mago potente puo usarlo per vedere luoghi lontani.", + "solution": "Sedersi sul trono con la porta chiusa e usare un incantesimo per vedere luoghi lontani", + "skill_checks": [ + {"skill": "Prova d'incantesimo", "dc": 25, "description": "Usare il portale per vedere luoghi lontani (solo per maghi potenti)"} + ] + }, + { + "name": "Studiare le Tavolette di Creta", + "description": "Le tavolette di creta rivelano la storia di una razza aliena dalle stelle che diede poteri magici a una tribu di barbari. I barbari conquistarono molte terre. Gli alieni torneranno quando le stelle saranno allineate. Le tavolette prevedono eventi: siccita, piaghe, nascita/morte di re.", + "solution": "Studiare attentamente i simboli alieni delle tavolette", + "skill_checks": [] + } + ], + "traps": [], + "monsters": [ + { + "ref_id": "ssisssuraaaaggg", + "count": 1, + "tactics": "Demone-serpente immortale. Attacca con morso a +6. Quando ucciso, il corpo si dissolve in cenere lasciando il corno demoniaco." + } + ], + "items": [ + { + "name": "Corno demoniaco", + "description": "Lasciato da Ssisssuraaaaggg quando viene ucciso. Permette comunione con il demone meditando.", + "effect": "Meditando con il corno (prova d'incantesimo CD 12) si puo Invocare Patrono. Puo anche essere mostrato alla Sentinella della Cenere in area 1.3 per farla esitare 1d4 round." + }, + { + "name": "Tavolette di creta", + "description": "Tavolette scritte in simboli alieni che rivelano la storia di una razza aliena dalle stelle, dei loro doni magici a una tribu di barbari, delle conquiste di questi barbari, e profezie sul ritorno degli alieni quando le stelle saranno allineate. Includono previsioni di siccita, piaghe, nascita/morte di re.", + "effect": "Storia aliena e profezie - conoscenza narrativa preziosa per il giudice e per comprendere il patto stellare" + } + ], + "treasure": [ + { + "name": "Corno demoniaco", + "value": "non commerciabile, strumento magico", + "location": "Lasciato da Ssisssuraaaaggg quando ucciso" + } + ] + }, + { + "id": "1.5", + "name": "Sepoltura del Capo", + "order": 5, + "description": "Sala di sepoltura con 7 alcove velate, ognuna con ossa ammucchiate. Armi e armature funerarie alle pareti, maschere funerarie appese. Ogni scheletro era un generale dell'esercito del mago guerriero.", + "atmosphere": "Aria pesante con odore di polvere e cuoio antico. Le ossa sono morti viventi putrefatti che non si animano piu, ma i teschi schioccano e tentano di mordere chiunque passi vicino.", + "connections": ["1.3", "1.6"], + "features": [ + "7 alcove velate con ossa ammucchiate", + "Armi e armature funerarie alle pareti", + "Maschere funerarie appese", + "Ossa non completamente umane (arti tozzi, colonna corta, fronte prominente)" + ], + "puzzles": [ + { + "name": "Distruggere i Teschi", + "description": "Se i teschi vengono distrutti, gli spiriti dei guerrieri nell'area 1.8 vengono liberati, potenzialmente facilitando l'aggiramento dell'esercito di terracotta.", + "solution": "Distruggere i 7 teschi - gli spiriti dei guerrieri in area 1.8 si liberano, potenziale soluzione per superare l'esercito", + "skill_checks": [] + } + ], + "traps": [], + "monsters": [ + { + "ref_id": "mucchio_ossa_viventi", + "count": 7, + "tactics": "Teschi che schioccano e tentano di mordere. Facili da evitare o schiacciare. Le ossa non si animano piu - solo i teschi rappresentano una minaccia residua." + } + ], + "items": [ + { + "name": "Ascia", + "description": "Ascia vecchia e fragile recuperata dalle ossa", + "effect": "-1 a colpire (vecchia/fragile)" + }, + { + "name": "Ascia da battaglia", + "description": "Ascia da battaglia recuperata dalle ossa", + "effect": "-1 a colpire (vecchia/fragile)" + }, + { + "name": "Cotta di maglia", + "description": "Cotta di maglia funeraria recuperata dalle ossa", + "effect": "Bonus +4 CA anziche +5 (vecchia/fragile)" + } + ], + "treasure": [ + { + "name": "Armi funerarie e cotta di maglia", + "value": "uso pratico (con malus)", + "location": "Sulle pareti e tra le ossa delle alcove velate" + } + ] + }, + { + "id": "1.6", + "name": "Vasca d'Osservazione", + "order": 6, + "description": "Enorme salone con vasca rettangolare piena d'acqua al centro. Luce diffusa brilla verso l'alto dalla vasca. Migliaia di cristalli incastonati sul fondo formano un cielo notturno (il cielo tra 20.000 anni, le stesse costellazioni del mosaico in area 1.3).", + "atmosphere": "Luce calma e fredda che sale dalle profondita della vasca. Sei creature di cristallo si trascinano lentamente attratte dalla luce delle torce.", + "connections": ["1.5", "1.7", "1.8"], + "features": [ + "Vasca rettangolare profonda 1,5 m", + "Fondo dipinto di nero con migliaia di cristalli incastonati che rappresentano il cielo notturno tra 20.000 anni", + "Stesse costellazioni del mosaico stellare in area 1.3", + "Pavimento sotto la vasca collegato all'area 1.8 sottostante" + ], + "puzzles": [ + { + "name": "Estrarre i Cristalli", + "description": "Ogni cristallo vale 10 ma e richiede 2 minuti per essere estratto. Estrarre cristalli fa defluire l'acqua nell'area 1.8 sottostante. Progressione: dopo 10 cristalli corrente d'acqua, dopo 50 cristalli il pavimento inizia a cedere, dopo 100 cristalli il pavimento crolla.", + "solution": "Estrarre cristalli con cautela. Decidere se rischiare il crollo del pavimento per piu cristalli o limitarsi a un bottino sicuro.", + "skill_checks": [] + } + ], + "traps": [ + { + "name": "Crollo del Pavimento", + "trigger": "Estrazione di 100 o piu cristalli dalla vasca", + "effect": "Il pavimento crolla, facendo cadere i PG nell'area 1.8 sottostante", + "detect_dc": null, + "damage": "1d6", + "save": {"type": "Riflessi", "dc": 12, "effect_on_save": "danno dimezzato"} + } + ], + "monsters": [ + { + "ref_id": "creatura_cristallo", + "count": 6, + "tactics": "Forma umana, trasparenti, intrappolate in corpi di cristallo. Attratte dalla luce, si trascinano verso torce e lanterne. Non attaccano spontaneamente ma si difendono se provocate." + } + ], + "items": [ + { + "name": "Cristalli dal fondo della vasca", + "description": "Migliaia di cristalli incastonati che rappresentano costellazioni future", + "effect": "10 ma cad. 2 minuti per estrarre ciascuno. Rimuoverli causa il defluire dell'acqua e il crollo progressivo del pavimento." + } + ], + "treasure": [ + { + "name": "Cristalli", + "value": "10 ma cad (migliaia disponibili, ma rischio crollo dopo 100 estrazioni)", + "location": "Fondo della vasca rettangolare" + } + ] + }, + { + "id": "1.7", + "name": "Stanza della Strategia", + "order": 7, + "description": "Una scala a chiocciola porta a una stanza lunga e stretta. Ripiani lungo le pareti reggono soldatini di terracotta. Due tavoli ospitano eserciti in miniatura, posizionati come per simulare battaglie.", + "atmosphere": "Polvere fine ricopre i tavoli e i ripiani. I soldatini sembrano disposti con cura, come se qualcuno li avesse sistemati per pianificare una campagna militare.", + "connections": ["1.6"], + "features": [ + "Scala a chiocciola d'accesso", + "Ripiani con numerosi soldatini di terracotta", + "Due tavoli con eserciti in miniatura in formazione di battaglia" + ], + "puzzles": [ + { + "name": "Trovare i Soldatini d'Argento", + "description": "Tra i numerosi soldatini di terracotta sono nascosti 4 soldatini d'argento massiccio che rappresentano i generali dei 4 eserciti.", + "solution": "Esaminare attentamente i soldatini sui ripiani e sui tavoli", + "skill_checks": [ + {"skill": "Cercare", "dc": 10, "description": "Identificare i 4 soldatini d'argento tra i soldatini di terracotta"} + ] + } + ], + "traps": [], + "monsters": [], + "items": [ + { + "name": "Soldatini d'argento", + "description": "4 soldatini d'argento massiccio che rappresentano i generali dei 4 eserciti del mago guerriero", + "effect": "Vale 20 mo ciascuno (80 mo totali)" + } + ], + "treasure": [ + { + "name": "4 Soldatini d'argento", + "value": "20 mo ciascuno (80 mo totali)", + "location": "Nascosti tra i numerosi soldatini di terracotta sui ripiani e tavoli (Cercare CD 10)" + } + ] + }, + { + "id": "1.8", + "name": "Esercito di Terracotta", + "order": 8, + "description": "Salone enorme a tre livelli. Trono sovradimensionato all'altra estremita. Sul trono siede un signore della guerra di terracotta che somiglia alla statua gigante. Sul trono pulsa una sfera di cristallo. Sotto i gradini del trono, 7 statue di generali di terracotta. Nel pozzo sottostante, dozzine di soldati di terracotta in formazione di marcia.", + "atmosphere": "Silenzio assoluto rotto solo dal pulsare ritmico della sfera di cristallo sul trono. L'esercito di terracotta resta perfettamente immobile in formazione, gli occhi vuoti rivolti in avanti. La sfera di cristallo emana un debole bagliore.", + "connections": ["1.6", "1.9"], + "features": [ + "Salone enorme a tre livelli", + "Trono sovradimensionato con signore della guerra di terracotta", + "Sfera di cristallo pulsante sul trono", + "7 statue di generali di terracotta sotto i gradini del trono", + "Pozzo con esercito di soldati di terracotta in formazione di marcia (70 guerrieri, 7 generali, 1 signore della guerra)" + ], + "puzzles": [ + { + "name": "Aggirare l'Esercito di Terracotta", + "description": "L'esercito non e sconfiggibile in combattimento diretto. Servono soluzioni creative: distrarre, schivare, usare l'ambiente, scoprire il nome del mago. Se i teschi area 1.5 sono stati distrutti, gli spiriti dei guerrieri vengono liberati e possono aiutare ad aggirare l'esercito.", + "solution": "Soluzioni creative: 1) Distruggere i teschi in area 1.5 per liberare gli spiriti guerrieri. 2) Scoprire il nome completo del mago guerriero (Vorkhul Ksh'tharan) per placare l'esercito. 3) Distrarre o schivare i soldati. 4) Usare l'ambiente del salone a tre livelli.", + "skill_checks": [] + }, + { + "name": "Prendere la Sfera di Cristallo", + "description": "La sfera pulsante sul trono e l'oggetto chiave per la quest secondaria della driade. Va portata in area 1.9 e posata nella depressione concava del tavolo di pietra.", + "solution": "Avvicinarsi al trono (rischioso con l'esercito attivo) e prelevare la sfera di cristallo", + "skill_checks": [] + } + ], + "traps": [], + "monsters": [ + { + "ref_id": "soldato_terracotta", + "count": 70, + "tactics": "Guerrieri di terracotta in formazione di marcia nel pozzo. Non sconfiggibili in combattimento diretto. Attaccano con lancia in mischia." + }, + { + "ref_id": "soldato_terracotta", + "count": 7, + "tactics": "Generali di terracotta sotto i gradini del trono. Stesse statistiche dei guerrieri. Non sconfiggibili in combattimento diretto." + }, + { + "ref_id": "soldato_terracotta", + "count": 1, + "tactics": "Signore della guerra di terracotta seduto sul trono, somiglia alla statua gigante. Stesse statistiche dei guerrieri, non sconfiggibile in combattimento diretto." + } + ], + "items": [ + { + "name": "Sfera di cristallo", + "description": "Sfera di cristallo pulsante posata sul trono del signore della guerra di terracotta. Necessaria per attivare la quest secondaria della driade.", + "effect": "Posata nella depressione concava del tavolo di pietra in area 1.9, mostra la visione del patrono caprino che assegna la quest della driade. Dopo l'attivazione perde ogni magia. Vale 200 mo come opera d'arte se non attivata, 50 mo dopo la visione." + } + ], + "treasure": [ + { + "name": "Sfera di cristallo", + "value": "200 mo non attivata, 50 mo dopo la visione del patrono caprino", + "location": "Sul trono del signore della guerra di terracotta" + } + ] + }, + { + "id": "1.9", + "name": "Sala del Tesoro", + "order": 9, + "description": "Alla fine di un lungo corridoio dietro l'esercito di terracotta si apre una stanza spartana: scaffali di legno semplice, una sedia da campo, una branda con coperte di lino consumate. Al centro del pavimento e disegnato un grande pentacolo con un cerchio di cristallo perfetto al centro. Dentro il cerchio c'e un tavolo di pietra grezza su cui giace un corpo antico e rinsecchito, il cui volto corrisponde inequivocabilmente a quello del barbaro raffigurato nelle colonne dell'area 1.3 (e nella statua sul trono dell'area 1.8). Alla testa del tavolo c'e una depressione concava delle dimensioni di una sfera.", + "atmosphere": "Una debole luce stellare sgorga dalle pareti illuminando gli scaffali. Il corpo di Vorkhul Ksh'tharan giace perfettamente conservato da millenni, pelle coriacea ma intatta, tunica di lino fine, medaglione con stella vuota incisa. Le linee del pentacolo pulsano debolmente alla luce delle torce.", + "connections": ["1.8"], + "features": [ + "Scaffali di legno semplice illuminati da debole luce stellare", + "Sedia da campo e branda con coperte di lino consumate", + "Grande pentacolo disegnato sul pavimento", + "Cerchio di cristallo perfetto al centro del pentacolo", + "Tavolo di pietra grezza dentro il cerchio", + "Corpo antico e rinsecchito di Vorkhul Ksh'tharan sul tavolo, perfettamente conservato", + "Depressione concava alla testa del tavolo (per la sfera di cristallo dell'area 1.8)", + "Corpo di Vorkhul che indossa tunica di lino fine e medaglione con stella vuota incisa" + ], + "puzzles": [ + { + "name": "Studiare il Tomo Rilegato in Ottone", + "description": "Il tomo e scritto in una lingua antichissima, indecifrabile per i moderni. Permette due tipi di studio: rapido per apprendere un incantesimo, prolungato per scoprire il nome del mago.", + "solution": "Studio da parte di un mago (1 ora) + prova d'incantesimo CD 14 -> apprende un incantesimo a scelta del giudice. Studio prolungato (8 ore + prova Intelligenza CD 12) -> rivela la seconda meta del nome del mago guerriero: 'Ksh'tharan, il Vincolato alle Stelle'. Da affiancare al frammento 'Vorkhul' inciso nella Colonna Centrale dell'area 1.3 per il nome completo: VORKHUL KSH'THARAN il Vincolato alle Stelle.", + "skill_checks": [ + {"skill": "Prova d'incantesimo", "dc": 14, "description": "Apprendere un incantesimo dal tomo (1 ora di studio, solo maghi)"}, + {"skill": "Intelligenza", "dc": 12, "description": "Studio prolungato di 8 ore per decifrare la seconda meta del nome (Ksh'tharan)"} + ] + }, + { + "name": "Pronunciare il Nome Completo", + "description": "Conoscere e pronunciare ad alta voce 'VORKHUL KSH'THARAN' permette di placare le difese della tomba.", + "solution": "Pronunciare il nome completo prima di toccare il corpo o varcare il cerchio. Effetti: placa la statua lanciafiamme in area 1.2, disinnesca il pentacolo difensivo, bandisce la Sentinella della Cenere in area 1.3, conferisce +2 a interazioni sociali con entita collegate al patto stellare.", + "skill_checks": [] + }, + { + "name": "Attivare la Sfera di Cristallo", + "description": "Posando la sfera di cristallo (presa dal trono dell'area 1.8) nella depressione concava del tavolo di pietra, si vede una visione: un campo infinito di stelle luminose, un'immagine spettrale del mago guerriero immobile, poi una faccia caprina dura che riempie la sfera. Il patrono caprino chiede di prendere il posto del condottiero e assegna la quest della driade.", + "solution": "Posare la sfera di cristallo nella depressione. Ricevere la quest del patrono caprino: riempire il braciere di rame con legno dell'albero di una driade e accenderlo con scintilla di fuoco vivo per rivelare l'altra meta della verga del comando.", + "skill_checks": [] + }, + { + "name": "Tentare il Risveglio del Mago", + "description": "Un mago potrebbe tentare un incantesimo di Risveglio o Comunicazione con il corpo. E una sciocchezza pericolosa: nessuno spirito tornera nel corpo di Vorkhul. Lo spirito e perduto da tempo nelle stelle, bloccato in proiezione astrale, prigioniero del proprio patto.", + "solution": "Sconsigliato. Fallimento: il PG perde 1d4 punti di Intelligenza permanenti, sostituiti da un'eco aliena nella sua mente. Successo: comunicazione di un solo round con l'eco del mago guerriero che mormora 'Le stelle... sbagliate... mi hanno lasciato qui...' prima di svanire.", + "skill_checks": [ + {"skill": "Prova d'incantesimo", "dc": 25, "description": "Tentare il Risveglio o Comunicazione con il corpo del mago"} + ] + } + ], + "traps": [ + { + "name": "Pentacolo Difensivo", + "trigger": "Toccare il corpo del mago, o varcare il cerchio di cristallo, senza prima pronunciare ad alta voce il nome completo (Vorkhul Ksh'tharan)", + "effect": "Scariche stellari colpiscono ogni PG entro 3 m. Si attiva una volta al round per 3 round, poi si esaurisce per 1d4 ore. Disinnescabile pronunciando il nome completo, oppure spezzando il cerchio di cristallo (Forza CD 16 con strumento adatto come lo scalpello di bronzo dell'area 1.3).", + "detect_dc": 18, + "damage": "1d6 elettrici", + "save": {"type": "Riflessi", "dc": 12, "effect_on_save": "danno dimezzato"} + } + ], + "monsters": [], + "items": [ + { + "name": "Verga di bronzo del comando", + "description": "Verga di bronzo incisa con meta di un volto demoniaco. La meta mancante e l'obiettivo della missione del patrono caprino (quest della driade).", + "effect": "Vale 150 mo da un mercante. Completata con la meta mancante, presumibilmente diventa uno strumento di comando potente." + }, + { + "name": "Braciere di rame", + "description": "Braciere di rame necessario per la quest della driade.", + "effect": "Vale 10 mo. Va riempito di legna di driade e acceso con scintilla di fuoco vivo per rivelare la posizione dell'altra meta della verga del comando." + }, + { + "name": "Spada lunga", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Arco lungo + 40 frecce + faretra", + "description": "Arma di fattura antica ma intatta con munizioni e faretra", + "effect": "Arma in ottime condizioni con 40 frecce" + }, + { + "name": "Mazza", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Lancia", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Ascia da battaglia", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Daga", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Ascia ad una mano", + "description": "Arma di fattura antica ma intatta", + "effect": "Arma in ottime condizioni" + }, + { + "name": "Armatura a scaglie", + "description": "Armatura intatta smaltata con la stessa pietra nera delle statue dell'area 1.2", + "effect": "Bonus +5 CA" + }, + { + "name": "Tomo rilegato in ottone", + "description": "Tomo scritto in una lingua antichissima, indecifrabile per i moderni", + "effect": "Studio mago (1 ora + prova d'incantesimo CD 14) -> apprende un incantesimo a scelta del giudice. Studio prolungato (8 ore + Intelligenza CD 12) -> rivela la seconda meta del nome del mago guerriero (Ksh'tharan, il Vincolato alle Stelle)." + } + ], + "treasure": [ + { + "name": "Verga di bronzo del comando", + "value": "150 mo", + "location": "Sugli scaffali della Sala del Tesoro" + }, + { + "name": "Braciere di rame", + "value": "10 mo (necessario per quest driade)", + "location": "Sugli scaffali della Sala del Tesoro" + }, + { + "name": "Arsenale di pregio (7 armi)", + "value": "fattura antica ma intatta, in ottime condizioni", + "location": "Sugli scaffali della Sala del Tesoro" + }, + { + "name": "Armatura a scaglie", + "value": "+5 CA, smaltata con pietra nera", + "location": "Sugli scaffali della Sala del Tesoro" + }, + { + "name": "Tomo rilegato in ottone", + "value": "chiave per il nome completo del mago + 1 incantesimo apprendibile", + "location": "Sugli scaffali della Sala del Tesoro" + }, + { + "name": "Medaglione con stella vuota", + "value": "indossato dal corpo del mago", + "location": "Sul corpo di Vorkhul Ksh'tharan sul tavolo di pietra" + } + ] + } + ], + "monsters": [ + { + "id": "sentinella_cenere", + "name": "Sentinella della Cenere", + "area": "1.3", + "type": "spirito", + "stats": { + "init": 2, + "attack": "+3 (giavellotto spettrale, distanza 9 m)", + "damage": "1d6+1 gelido", + "ac": 13, + "hp": 12, + "mv": "6 m", + "az": "1d20" + }, + "saves": {"Temp": -1, "Rif": 3, "Vol": 4}, + "alignment": "N", + "description": "Guerriero spettrale dall'aspetto cinereo, era lo stendardiere personale del mago guerriero, vincolato a guardia della Sala Monumentale dal patto stellare. Si materializza tra le colonne quando i PG superano la meta della sala. Non parla.", + "notes": "Corpo parzialmente etereo. Subisce solo meta danno da armi non magiche (arrotondato per difetto). I giavellotti spettrali si dissolvono dopo l'impatto e non sono recuperabili. Si dissolve in cenere argentea a 0 PF.", + "loot": "1d4 mo di cenere argentea raccoglibile in fiala (componente per incantesimi di proiezione astrale)", + "banishment_methods": [ + "Recitare il nome completo del mago guerriero (Vorkhul Ksh'tharan)", + "Toccare la Colonna del Patto (Colonna 1) e proferire formula di vassallaggio sincera con Volonta CD 12", + "Mostrare il corno demoniaco (dall'area 1.4) - la sentinella esita 1d4 round", + "Distruggerla in combattimento (rischioso ma fattibile)" + ] + }, + { + "id": "ssisssuraaaaggg", + "name": "Ssisssuraaaaggg, Demone-Serpente Immortale", + "area": "1.4", + "type": "demone", + "stats": { + "init": 0, + "attack": "+6 (morso, mischia)", + "damage": "1d8", + "ac": 13, + "hp": 20, + "mv": "6 m", + "az": "1d20" + }, + "saves": {"Temp": 8, "Rif": 4, "Vol": 4}, + "alignment": "C", + "description": "Demone-serpente immortale custode della Sala delle Visioni. I suoi sibili lontani si percepiscono fino al portale d'ingresso (eco fino in area 1.1).", + "notes": "Quando ucciso il corpo si dissolve in cenere, lasciando il corno demoniaco.", + "loot": "Corno demoniaco (permette di Invocare Patrono meditando, prova d'incantesimo CD 12)" + }, + { + "id": "mucchio_ossa_viventi", + "name": "Mucchio di ossa viventi", + "area": "1.5", + "type": "non-morto", + "stats": { + "init": -2, + "attack": "+0 (morso)", + "damage": "1d4-1", + "ac": 8, + "hp": 9, + "mv": "1.5 m", + "az": "1d20" + }, + "saves": {"Temp": 0, "Rif": -4, "Vol": 1}, + "alignment": "C", + "description": "Resti di scheletri di generali dell'esercito del mago guerriero. Le ossa non sono completamente umane (arti tozzi, colonna corta, fronte prominente). Ogni scheletro era un generale dell'esercito.", + "notes": "Le ossa sono morti viventi putrefatti che non si animano piu. Solo i teschi schioccano e tentano di mordere, ma sono facili da evitare o schiacciare. Distruggere i teschi libera gli spiriti dei guerrieri in area 1.8.", + "loot": "Nessun loot diretto. Distruggerli pero libera gli spiriti in area 1.8 (utile per aggirare l'esercito di terracotta)." + }, + { + "id": "creatura_cristallo", + "name": "Creatura di cristallo", + "area": "1.6", + "type": "elementale", + "stats": { + "init": -2, + "attack": "+2 (pugna)", + "damage": "1d4", + "ac": 12, + "hp": 8, + "mv": "3 m", + "az": "1d20" + }, + "saves": {"Temp": -2, "Rif": -2, "Vol": 0}, + "alignment": "N", + "description": "Sei creature di forma umana, trasparenti, intrappolate in corpi di cristallo. Si trovano nell'area della Vasca d'Osservazione.", + "notes": "Attratte dalla luce, si trascinano verso torce e lanterne. Non attaccano spontaneamente ma si difendono se provocate.", + "loot": "Nessun loot specifico (frammenti di cristallo dai corpi)" + }, + { + "id": "soldato_terracotta", + "name": "Soldato di terracotta", + "area": "1.8", + "type": "costrutto", + "stats": { + "init": 0, + "attack": "+4 (lancia, mischia)", + "damage": "1d8", + "ac": 19, + "hp": 9, + "mv": "3 m", + "az": "1d20" + }, + "saves": {"Temp": 2, "Rif": 0, "Vol": 0}, + "alignment": "N", + "description": "Esercito di terracotta del mago guerriero. 70 guerrieri nel pozzo in formazione di marcia, 7 generali sotto i gradini del trono, 1 signore della guerra sul trono. Tutti condividono le stesse statistiche.", + "notes": "Non sconfiggibili in combattimento diretto. Servono soluzioni creative: distrarre, schivare, usare l'ambiente, scoprire il nome del mago, oppure liberare gli spiriti guerrieri distruggendo i teschi in area 1.5.", + "loot": "Nessun loot diretto dai costrutti. La sfera di cristallo sul trono e l'oggetto chiave dell'area." + } + ], + "quests": [ + { + "id": "main", + "name": "Scoprire il Nome del Mago Guerriero", + "description": "Esplorare la tomba e scoprire il nome dimenticato di Vorkhul Ksh'tharan, il Vincolato alle Stelle, per placare le sue difese millenarie e completare l'avventura.", + "type": "main", + "steps": [ + "Aprire il portale stellare sotto la Stella Vuota nei monoliti del tumulo dimenticato", + "Attraversare il corridoio dell'area 1.1 e aprire la porta rinforzata in ferro (attesa di 2 ore per l'allineamento stellare o forza/scassinare con rischio trappola)", + "Superare la Sala del Guardiano (area 1.2) evitando o disattivando la statua lanciafiamme", + "Raggiungere la Sala Monumentale (area 1.3) e leggere il frammento del nome 'V-R-KH-L' inciso nella Colonna Centrale", + "Affrontare o bandire la Sentinella della Cenere", + "Esplorare le altre aree (1.4-1.8) raccogliendo strumenti e indizi", + "Raggiungere la Sala del Tesoro (area 1.9) attraverso o aggirando l'Esercito di Terracotta", + "Studiare il tomo rilegato in ottone (8 ore + Intelligenza CD 12) per scoprire la seconda meta 'Ksh'tharan, il Vincolato alle Stelle'", + "Pronunciare ad alta voce il nome completo: VORKHUL KSH'THARAN il Vincolato alle Stelle", + "Disinnescare il pentacolo difensivo, placare le altre difese, recuperare il tesoro e tornare in superficie" + ], + "reward": "Avanzamento di livello 0 -> 1 al ritorno in superficie (1 PE per ogni stanza superata, 2 PE per area 1.8 aggirata, 3 PE per il nome scoperto). Tesoro recuperato dalle aree. Riconoscimento alieno: +2 a tutte le interazioni sociali con entita collegate al patto stellare (vale solo se il PG pronuncia il nome con sicurezza)." + }, + { + "id": "driade", + "name": "La Driade nella Foresta", + "description": "Quest secondaria opzionale assegnata dal patrono caprino attraverso la sfera di cristallo. Si attiva solo se i PG raggiungono l'area 1.9 e usano la sfera di cristallo nella depressione del tavolo di pietra. Obiettivo: trovare l'altra meta della verga del comando per il patrono caprino.", + "type": "secondary", + "steps": [ + "Raggiungere l'area 1.9 e posare la sfera di cristallo (presa dal trono dell'area 1.8) nella depressione concava del tavolo di pietra", + "Ricevere la visione del patrono caprino che chiede di prendere il posto del condottiero", + "Recuperare il braciere di rame dagli scaffali dell'area 1.9", + "Recarsi nelle foreste a est ('Dicono che una driade sia stata vista nelle foreste a est di qui')", + "Trovare la driade e raccogliere legno dal suo albero", + "Riempire il braciere di rame con la legna di driade", + "Accendere il braciere con la scintilla di un fuoco vivo", + "La fiamma rivelera il luogo dell'altra meta della verga del comando", + "Trovare e recuperare l'altra meta della verga e portarla al patrono caprino" + ], + "reward": "Ricompensa promessa dal patrono caprino (non specificata nei dettagli). Verga del comando completata. Possibili semi narrativi per campagne future con il patrono caprino come alleato." + } + ], + "special_items": [ + { + "id": "corno_demoniaco", + "name": "Corno demoniaco", + "area": "1.4", + "description": "Corno lasciato dal demone-serpente Ssisssuraaaaggg quando viene ucciso. Permette comunione con il demone meditando.", + "effect": "Invocare Patrono meditando con il corno (prova d'incantesimo CD 12). Puo anche essere mostrato alla Sentinella della Cenere in area 1.3 per farla esitare 1d4 round." + }, + { + "id": "soldatini_argento", + "name": "Soldatini d'argento", + "area": "1.7", + "description": "4 soldatini d'argento massiccio nascosti tra i soldatini di terracotta sui ripiani e tavoli della Stanza della Strategia. Rappresentano i generali dei 4 eserciti del mago guerriero.", + "effect": "Valore: 20 mo ciascuno (80 mo totali). Trovati con Cercare CD 10." + }, + { + "id": "cristalli_vasca", + "name": "Cristalli dalla vasca", + "area": "1.6", + "description": "Migliaia di cristalli incastonati nel fondo della vasca d'osservazione, rappresentano il cielo notturno tra 20.000 anni (le stesse costellazioni del mosaico in area 1.3).", + "effect": "Valore: 10 ma ciascuno. 2 minuti per estrarne uno. Estrarne troppi causa il defluire dell'acqua e il crollo del pavimento: dopo 10 corrente d'acqua, dopo 50 pavimento cede, dopo 100 crollo (1d6 danni, Rif CD 12 dimezza, caduta in area 1.8)." + }, + { + "id": "armi_antiche", + "name": "Armi antiche", + "area": "1.5", + "description": "Armi e armature funerarie recuperate dalle alcove velate della Sepoltura del Capo: 1 ascia (-1 colpire), 1 ascia da battaglia (-1 colpire), 1 cotta di maglia (+4 CA anziche +5). Tutte vecchie e fragili.", + "effect": "Asce -1 colpire (vecchie/fragili), cotta di maglia bonus +4 CA invece di +5" + }, + { + "id": "tavolette_creta", + "name": "Tavolette di creta", + "area": "1.4", + "description": "Tavolette di creta alle pareti della Sala delle Visioni, scritte in simboli alieni. Studiarle rivela la storia di una razza aliena dalle stelle che diede poteri magici a una tribu di barbari, le conquiste dei barbari, e profezie sul ritorno degli alieni quando le stelle saranno allineate.", + "effect": "Storia aliena e profezie: prevedono eventi specifici (siccita, piaghe, nascita/morte di re). Conoscenza narrativa preziosa, ganci per il giudice per collegare eventi del villaggio al riallinearsi delle stelle." + }, + { + "id": "sfera_cristallo", + "name": "Sfera di cristallo", + "area": "1.8", + "description": "Sfera di cristallo pulsante posata sul trono del signore della guerra di terracotta nell'area 1.8. Va portata in area 1.9 e posata nella depressione concava del tavolo di pietra per attivare la quest della driade.", + "effect": "Mostra la visione del patrono caprino che assegna la quest della driade (richiede legno dall'albero di una driade nelle foreste a est e braciere di rame acceso con scintilla di fuoco vivo per rivelare l'altra meta della verga del comando). Dopo l'attivazione perde ogni magia. Vale 200 mo come opera d'arte se non attivata, 50 mo dopo la visione." + } + ] +} diff --git a/data/crit_fumble_tables.json b/data/crit_fumble_tables.json new file mode 100644 index 0000000..ca41851 --- /dev/null +++ b/data/crit_fumble_tables.json @@ -0,0 +1,207 @@ +{ + "meta": { + "description": "Tabelle critici e fumble per DCC RPG tratte dal Reference Booklet. Traduzione italiana.", + "source": "Reference Booklet DCC RPG, pagg. 55-62 (Tabelle 4-2, Crit I-V)", + "dice_chain": ["d3","d4","d5","d6","d7","d8","d10","d12","d14","d16","d20","d24","d30"] + }, + + "fumble": { + "name": "Tabella 4-2: Fallimenti Critici", + "description": "Tirata quando si ottiene 1 naturale in attacco. Dado determinato dall'armatura: 1d4 (nessuna), 1d8 (leggera), 1d12 (media), 1d16 (pesante). Modificatore: -modificatore Fortuna.", + "modifier": "-LUCK", + "die_by_armor": { + "none": "d4", + "light": "d8", + "medium": "d12", + "heavy": "d16" + }, + "entries": [ + { "min": null, "max": 0, "result": "Manchi maldestramente, ma miracolosamente non causi alcun altro danno." }, + { "min": 1, "max": 1, "result": "Il tuo colpo da incompetente ti rende lo zimbello del gruppo, ma non causa alcun danno." }, + { "min": 2, "max": 2, "result": "Inciampi, ma puoi recuperare con un tiro salvezza su Riflessi con CD 10; altrimenti, devi trascorrere il round successivo prono." }, + { "min": 3, "max": 3, "result": "La tua arma ti scivola in mano. La afferri rapidamente, ma la presa è compromessa. Subisci una penalità di -2 al prossimo tiro per colpire." }, + { "min": 4, "max": 4, "result": "La tua arma si danneggia: la corda dell'arco si rompe, l'elsa della spada cade, oppure il meccanismo di scatto della balestra si inceppa. L'arma può essere riparata con 10 minuti di lavoro, ma per ora è inutilizzabile." }, + { "min": 5, "max": 5, "result": "Inciampi e cadi, sprecando questa azione. Sei prono e devi usare un'azione per rialzarti nel round successivo." }, + { "min": 6, "max": 6, "result": "La tua arma si impiglia nella tua armatura. Devi trascorrere il round successivo a districarle. Inoltre, il tuo bonus di armatura viene ridotto di 1 finché non avrai trascorso 10 minuti a mettere a posto fibbie e cinghie aggrovigliate." }, + { "min": 7, "max": 7, "result": "Lasci cadere l'arma. Devi recuperarla o estrarne un'altra con la tua azione successiva." }, + { "min": 8, "max": 8, "result": "Per sbaglio fai sbattere la tua arma contro un oggetto solido e immobile (una roccia, un muro, persino il terreno). Le armi comuni vengono distrutte; le armi magiche non subiscono alcun effetto." }, + { "min": 9, "max": 9, "result": "Inciampi e rimani completamente scoperto agli attacchi. Il prossimo nemico che ti attacca riceve un bonus di +2 al tiro per colpire." }, + { "min": 10, "max": 10, "result": "Avresti dovuto prenderti cura della tua armatura! Le giunture si bloccano, immobilizzandoti sul posto. Non puoi muoverti né attaccare per 1d3 round. I personaggi non corazzati non subiscono alcun effetto." }, + { "min": 11, "max": 11, "result": "Il tuo fendente scomposto ti lascia sbilanciato. Subisci una penalità di -4 al prossimo tiro per colpire." }, + { "min": 12, "max": 12, "result": "Inavvertitamente sferri un fendente contro un alleato determinato casualmente entro la gittata. Effettua un tiro per colpire contro quell'alleato usando lo stesso dado d'attacco che hai appena tentato di utilizzare." }, + { "min": 13, "max": 13, "result": "Inciampi malamente. Cadi pesantemente, subendo 1d3 danni nel processo. Sei prono e devi usare il round successivo per rialzarti." }, + { "min": 14, "max": 14, "result": "Come una tartaruga sulla schiena, scivoli e atterri a pancia in su, agitandoti senza riuscire a rimetterti dritto. Devi combattere da posizione prona per il prossimo round prima di poter recuperare l'equilibrio e rialzarti." }, + { "min": 15, "max": 15, "result": "Chissà come, riesci a ferirti, subendo i danni normali della tua arma." }, + { "min": 16, "max": null, "result": "Ti colpisci per sbaglio infliggendoti i danni normali più 1 punto ulteriore. Inoltre, cadi sulla schiena e non riesci a rialzarti finché non superi una prova di Agilità con CD 16." } + ] + }, + + "crit_I": { + "name": "Tabella dei Critici I — Personaggi lv0 e Maghi", + "description": "Tutti i personaggi di livello 0. Tutti i maghi indipendentemente dal livello. Dado base: 1d4 (lv0), variabile per maghi.", + "die": "d4", + "modifier": "+LUCK", + "entries": [ + { "min": null, "max": 0, "result": "La forza del colpo ti fa perdere la presa sull'arma. Infliggi +1d6 danni con questo colpo e sei disarmato." }, + { "min": 1, "max": 1, "result": "Colpo di opportunità. Infliggi +1d3 danni con questo colpo." }, + { "min": 2, "max": 2, "result": "Nemico colpito all'occhio! Brutti lividi; infliggi +1d4 danni con questo colpo." }, + { "min": 3, "max": 3, "result": "Botta stordente in fronte. Infliggi +1d3 danni con questo colpo, e il nemico va in fondo al conteggio dell'iniziativa nel round successivo." }, + { "min": 4, "max": 4, "result": "Colpo alla rotula del nemico. Infliggi +1d4 danni con questo colpo e il nemico subisce una penalità di -3 m alla velocità finché non viene guarito." }, + { "min": 5, "max": 5, "result": "Colpo solido al busto. Infliggi +1d6 danni con questo colpo." }, + { "min": 6, "max": 6, "result": "Un colpo fortunato disarma il nemico. Ottieni un attacco gratuito se il nemico si china a recuperare l'arma." }, + { "min": 7, "max": 7, "result": "Frantumi la mano del nemico. Infliggi +2d3 danni con questo colpo. Spezzi due dita del nemico." }, + { "min": 8, "max": 8, "result": "Colpo paralizzante! Imprecando per l'agonia, il nemico non può attaccare nel round successivo." }, + { "min": 9, "max": 9, "result": "Frantumi il naso del nemico. Infliggi +2d4 danni con questo colpo e il sangue cola lungo il volto del nemico." }, + { "min": 10, "max": 10, "result": "Il nemico inciampa nei propri piedi e cade prono per il resto del round." }, + { "min": 11, "max": 11, "result": "Colpo perforante. Infliggi +2d4 danni con questo colpo." }, + { "min": 12, "max": 12, "result": "Colpo all'inguine. Il nemico deve superare un tiro salvezza su Tempra (CD 15) o passare i prossimi due round a vomitare." }, + { "min": 13, "max": 13, "result": "Il colpo frantuma la caviglia del nemico; la sua velocità di movimento è dimezzata." }, + { "min": 14, "max": 14, "result": "Il colpo sfiora la tempia; il sangue acceca il nemico per 1d3 round." }, + { "min": 15, "max": 15, "result": "Trafiggi la mano armata del nemico. L'arma è persa e finisce a (1d10+5)×30 cm di distanza." }, + { "min": 16, "max": 16, "result": "Eviti per un soffio il contrattacco del nemico! Infliggi danni normali ed effettua un altro tiro per colpire. Se il secondo attacco va a segno, infliggi ulteriori +1d6 danni." }, + { "min": 17, "max": 17, "result": "Colpo alla gola. Il nemico barcolla per 2 round senza poter parlare, lanciare incantesimi o attaccare." }, + { "min": 18, "max": 18, "result": "Il nemico va incontro al tuo attacco. Subisce +2d6 danni dal colpo e maledice la tua fortuna." }, + { "min": 19, "max": 19, "result": "Colpo miracoloso. Il nemico deve superare un tiro salvezza su Tempra (CD 20) o cadere privo di sensi." }, + { "min": 20, "max": null, "result": "Un colpo fortunato ammacca il cranio del nemico! Infliggi +2d6 danni con questo colpo. Se il nemico non indossa un elmo, subisce una perdita permanente di 1d4 in Intelligenza." } + ] + }, + + "crit_II": { + "name": "Tabella dei Critici II — Ladri ed Elfi", + "description": "Tutti i ladri e tutti gli elfi, indipendentemente dal livello.", + "die": "d6", + "modifier": "+LUCK", + "entries": [ + { "min": null, "max": 0, "result": "Mancato! L'esitazione ti costa il colpo perfetto!" }, + { "min": 1, "max": 1, "result": "Il colpo manca gli organi vitali. Infliggi miseri +2d3 danni con questo colpo." }, + { "min": 2, "max": 2, "result": "Fendenti alla testa recidono l'orecchio del nemico. Infliggi +1d6 danni con questo colpo e lasci il nemico con una brutta cicatrice." }, + { "min": 3, "max": 3, "result": "Colpo netto alla schiena. Infliggi +2d6 danni con questo colpo." }, + { "min": 4, "max": 4, "result": "Un colpo al petto fa barcollare il nemico. Puoi effettuare un attacco gratuito immediato." }, + { "min": 5, "max": 5, "result": "Il colpo trafigge i reni del nemico. Infliggi +3d3 danni con questo colpo, e il nemico rimane stordito per 1 round." }, + { "min": 6, "max": 6, "result": "Il nemico è frastornato dall'attacco feroce; la sua velocità e il numero delle sue azioni sono dimezzati." }, + { "min": 7, "max": 7, "result": "Un colpo al petto sfiora un organo vitale. Infliggi +3d4 danni con questo colpo." }, + { "min": 8, "max": 8, "result": "Il colpo apre un taglio lungo il volto del nemico. Resta accecato dal sangue per 1d4 round." }, + { "min": 9, "max": 9, "result": "Il nemico inciampa nei propri arti, cadendo prono. Effettua un altro attacco." }, + { "min": 10, "max": 10, "result": "Colpo magistrale! Infliggi +2d6 danni con questo colpo." }, + { "min": 11, "max": 11, "result": "Il colpo recide la laringe. Il nemico è ridotto a emettere gorgoglii umidi da pesce." }, + { "min": 12, "max": 12, "result": "Colpo selvaggio! Il nemico deve superare un tiro salvezza su Tempra (CD 10 + livello del PG) o svenire per il dolore." }, + { "min": 13, "max": 13, "result": "Il nemico è disorientato dai tuoi colpi rapidi. Subisce una penalità di -4 ai tiri per colpire per 1d4 round." }, + { "min": 14, "max": 14, "result": "Colpo alla testa. Il nemico deve superare un tiro salvezza su Tempra (CD 10 + livello del PG) o cadere privo di sensi." }, + { "min": 15, "max": 15, "result": "Il colpo abbatte il nemico al suolo. Infliggi +2d6 danni con questo colpo, e il nemico finisce prono." }, + { "min": 16, "max": 16, "result": "Un colpo fulmineo al viso fa scoppiare l'occhio del nemico come un acino d'uva. Il nemico è permanentemente accecato da un occhio e non può compiere azioni per 1d3 round." }, + { "min": 17, "max": 17, "result": "Il colpo perfora un polmone. Infliggi +2d6 danni con questo colpo, e il nemico può compiere una sola azione nel suo prossimo turno." }, + { "min": 18, "max": 18, "result": "Colpo devastante alla nuca. Infliggi +1d8 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 10 + livello del PG) o cadere privo di sensi." }, + { "min": 19, "max": 19, "result": "L'attacco recide un'arteria importante. Infliggi +1d10 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 10 + livello del PG) o cadere privo di sensi per lo shock e la massiccia perdita di sangue." }, + { "min": 20, "max": 20, "result": "Gola squarciata! Infliggi +2d6 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 13 + livello del PG) o morire entro 1d4 round." }, + { "min": 21, "max": 21, "result": "Il colpo perfora la colonna vertebrale. Infliggi +3d6 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o subire paralisi." }, + { "min": 22, "max": 22, "result": "Il petto viene infilzato, trafiggendo diversi organi. Infliggi +2d6 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 13 + livello del PG) o morire entro 1d4 round." }, + { "min": 23, "max": 23, "result": "Il colpo penetra attraverso il canale uditivo fino al cervello. Cerume rimosso all'istante; il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o morire all'istante. In caso di tiro salvezza riuscito, infliggi comunque +2d6 danni aggiuntivi." }, + { "min": 24, "max": null, "result": "Colpo al cuore! Infliggi +3d6 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 20 + livello del PG) o morire all'istante." } + ] + }, + + "crit_III": { + "name": "Tabella dei Critici III — Chierici, Halfling, Guerrieri 1-2, Nani 1-3", + "description": "Chierici, halfling, guerrieri di 1°-2° livello, nani di 1°-3° livello.", + "die": "d8", + "modifier": "+LUCK", + "entries": [ + { "min": null, "max": 0, "result": "La furia combattiva rende amico e nemico indistinguibili. Il nemico è colpito subendo +1d12 danni, e anche l'alleato a lui più vicino viene investito da un colpo di rimbalzo per 1d4 danni." }, + { "min": 1, "max": 1, "result": "Attacco selvaggio! Infliggi +1d6 danni con questo colpo." }, + { "min": 2, "max": 2, "result": "L'attacco sbilancia il nemico. Nel round successivo, il nemico è prono." }, + { "min": 3, "max": 3, "result": "Il nemico va incontro all'attacco. Infliggi +1d8 danni con questo colpo." }, + { "min": 4, "max": 4, "result": "Un colpo potente martella il nemico costringendolo in ginocchio. Effettua un altro attacco." }, + { "min": 5, "max": 5, "result": "Frantumi il naso del nemico in un'esplosione di sangue. Infliggi +1d6 danni con questo colpo, e il nemico perde l'olfatto per 1d4 ore." }, + { "min": 6, "max": 6, "result": "Colpo brutale al busto. Infliggi +1d8 danni con questo colpo, e il nemico subisce fratture multiple alle costole." }, + { "min": 7, "max": 7, "result": "Un colpo alla mano fa volare l'arma in aria. L'arma cade a (1d20+5)×30 cm di distanza." }, + { "min": 8, "max": 8, "result": "Il colpo rimbalza dal cranio, assordando il nemico per 1d6 giorni. Infliggi +1d6 danni con questo colpo." }, + { "min": 9, "max": 9, "result": "Un colpo alla gamba scheggia il femore. Infliggi +2d6 danni con questo colpo e il nemico perde 3 m di movimento finché non viene guarito." }, + { "min": 10, "max": 10, "result": "Distruggi l'arma del nemico! Schegge di metallo riempiono l'aria." }, + { "min": 11, "max": 11, "result": "Il colpo martella il ventre del nemico provocando una massiccia emorragia interna. Senza guarigione magica, il nemico muore entro 1d5 ore." }, + { "min": 12, "max": 12, "result": "Un colpo al cranio stordisce il nemico. Il nemico deve superare un tiro salvezza su Tempra (CD 10 + livello del PG) o crollare a terra, privo di sensi." }, + { "min": 13, "max": 13, "result": "Il colpo spezza la mandibola del nemico. Sangue e denti frantumati colano sul suo volto. Infliggi +1d8 danni con questo colpo." }, + { "min": 14, "max": 14, "result": "L'attacco martella il busto del nemico. Infliggi +2d8 danni con questo colpo." }, + { "min": 15, "max": 15, "result": "Il colpo lussa la spalla! Infliggi +1d8 danni e il braccio dello scudo penzola sostenuto solo da muscoli e pelle; nessun bonus alla CA dallo scudo." }, + { "min": 16, "max": 16, "result": "L'attacco riduce la mano d'attacco del nemico a tessuto informe; penalità di -4 ai futuri attacchi." }, + { "min": 17, "max": 17, "result": "Colpi furiosi atterrano il bersaglio prono. Effettua un altro attacco." }, + { "min": 18, "max": 18, "result": "Il colpo conficca schegge ossee nel lobo frontale del nemico; la materia grigia cola fuori. Infliggi +1d8 danni con questo colpo, e il nemico subisce una perdita di 1d4 punti in Intelligenza e Personalità." }, + { "min": 19, "max": 19, "result": "Colpo devastante al petto. Infliggi +2d8 danni con questo colpo." }, + { "min": 20, "max": 20, "result": "Un colpo al petto stordisce il nemico per 1d3 round. Infliggi +1d8 danni con questo colpo." }, + { "min": 21, "max": 21, "result": "Un colpo alla gamba frantuma il femore, abbattendo il nemico al suolo. Il movimento del nemico è dimezzato. Infliggi +2d8 danni con questo colpo ed effettua un altro attacco." }, + { "min": 22, "max": 22, "result": "Il braccio armato è dilaniato dal colpo. L'arma è persa, insieme a ogni possibilità di attaccare con quel braccio." }, + { "min": 23, "max": 23, "result": "Il colpo apre un cratere nel cranio. Infliggi +2d8 danni con questo colpo, e il bersaglio perde permanentemente 1d4 in Intelligenza e Personalità." }, + { "min": 24, "max": 24, "result": "Colpo magistrale alla gola. Infliggi +2d8 danni con questo colpo e il nemico barcolla annaspando in cerca d'aria per 1d4 round." }, + { "min": 25, "max": 25, "result": "L'attacco conficca costole frantumate attraverso i polmoni. Il nemico perde il 50% dei suoi punti ferita rimanenti e vomita abbondanti quantità di sangue." }, + { "min": 26, "max": 26, "result": "L'attacco frantuma il volto del nemico, distruggendo entrambi gli occhi. Infliggi +2d8 danni con questo colpo, e il nemico è permanentemente accecato." }, + { "min": 27, "max": 27, "result": "Un colpo schiacciante martella il petto. Infliggi +3d8 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o cadere privo di sensi." }, + { "min": 28, "max": null, "result": "Il colpo distrugge la colonna vertebrale. Infliggi +3d8 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o subire paralisi." } + ] + }, + + "crit_IV": { + "name": "Tabella dei Critici IV — Guerrieri 3-4, Nani 4-5", + "description": "Guerrieri di 3°-4° livello, nani di 4°-5° livello.", + "die": "d10", + "modifier": "+LUCK", + "entries": [ + { "min": null, "max": 0, "result": "La furia combattiva rende amico e nemico indistinguibili. Il nemico è colpito subendo +2d8 danni, e anche l'alleato a lui più vicino viene investito da un colpo di rimbalzo per 1d4 danni." }, + { "min": 1, "max": 1, "result": "Colpo erculeo. Infliggi +2d12 danni con questo colpo." }, + { "min": 2, "max": 2, "result": "Un colpo feroce lascia la mano armata del nemico penzoloni dal moncherino del polso. Infliggi +1d12 danni con questo colpo." }, + { "min": 3, "max": 3, "result": "Il colpo abbatte il nemico al suolo. Infliggi +1d12 danni con questo colpo ed effettua un altro attacco contro il nemico prono." }, + { "min": 4, "max": 4, "result": "Un colpo martellante spinge la cartilagine del naso nel cervello. Infliggi +1d12 danni con questo colpo, e il nemico subisce una perdita di 1d6 in Intelligenza." }, + { "min": 5, "max": 5, "result": "L'arma del nemico è frantumata. Se il nemico non ha un'arma, infliggi +2d12 danni con questo colpo." }, + { "min": 6, "max": 6, "result": "Il colpo frantuma lo sterno del nemico. Il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o cadere privo di sensi mentre i suoi organi interni collassano." }, + { "min": 7, "max": 7, "result": "Il nemico è spinto indietro dall'assalto furioso. Infliggi +2d12 danni con questo colpo, e il nemico rinuncia al suo prossimo attacco." }, + { "min": 8, "max": 8, "result": "Un colpo da commozione lascia il nemico frastornato. Infliggi +1d8 danni con questo colpo ed effettua un secondo attacco." }, + { "min": 9, "max": 9, "result": "Un colpo alla gola arriva fino alla colonna vertebrale, riducendo in poltiglia tutto ciò che sta in mezzo. Infliggi +2d12 danni con questo colpo, e il nemico perde la parola per 1d4 settimane." }, + { "min": 10, "max": 10, "result": "Il colpo apre un cratere nella tempia. Il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o essere accecato dal dolore e dal sangue per 1d4 round." }, + { "min": 11, "max": 11, "result": "Il colpo riduce il volto a una massa informe di carne e frammenti d'osso. Infliggi +2d12 danni con questo colpo, e il nemico ha difficoltà a pronunciare le consonanti dure." }, + { "min": 12, "max": 12, "result": "Vedi rosso! Infliggi +1d12 danni con questo colpo, mentre sei sopraffatto dalla furia combattiva!" }, + { "min": 13, "max": 13, "result": "Un colpo martellante al busto schiaccia gli organi minori in poltiglia. Infliggi +2d12 danni con questo colpo." }, + { "min": 14, "max": 14, "result": "Un colpo alla colonna vertebrale intorpidisce gli arti inferiori del nemico. Il nemico subisce una penalità di -4 alla CA mentre impara di nuovo a camminare." }, + { "min": 15, "max": 15, "result": "Un colpo terrificante getta il nemico sul pavimento insanguinato. Il nemico si rannicchia per la paura, prono, per 1d4 round." }, + { "min": 16, "max": 16, "result": "Il colpo frantuma lo scudo. Infliggi +2d12 danni con questo colpo. Se il nemico non ha scudo, è stordito dal dolore per 1d4 round." }, + { "min": 17, "max": 17, "result": "La rotula del nemico esplode in una nube rossa. Il movimento del nemico scende a 0 ed effettui un altro attacco." }, + { "min": 18, "max": 18, "result": "Lobotomia frontale. Infliggi +1d12 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o subire amnesia. In ogni caso, il nemico è stordito per 1d4 round." }, + { "min": 19, "max": 19, "result": "Colpo al braccio armato. Il nemico subisce il triplo dei danni dalla propria arma, mentre gli viene martellata in faccia. Il nemico lascia cadere l'arma in stupefatta meraviglia." }, + { "min": 20, "max": 20, "result": "Il colpo schiaccia il midollo spinale. Infliggi +3d12 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o subire paralisi permanente." }, + { "min": 21, "max": 21, "result": "Il colpo riduce gli organi interni in gelatina. La morte è inevitabile entro 1d8 round." }, + { "min": 22, "max": 22, "result": "Il bersaglio viene sventrato, le sue viscere si riversano al suolo. Il nemico muore di shock entro 1d6 round." }, + { "min": 23, "max": 23, "result": "Un colpo al petto fa esplodere il cuore. Infliggi +3d12 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 15 + livello del PG) o morire all'istante." }, + { "min": 24, "max": null, "result": "Il cranio è schiacciato come un melone. Infliggi +3d12 danni con questo colpo, e il nemico deve superare un tiro salvezza su Tempra (CD 20 + livello del PG) o morire entro 1d3 round." } + ] + }, + + "crit_V": { + "name": "Tabella dei Critici V — Guerrieri 5+, Nani 6+", + "description": "Guerrieri di 5°+ livello, nani di 6°+ livello.", + "die": "d12", + "modifier": "+LUCK", + "entries": [ + { "min": null, "max": 0, "result": "La furia combattiva rende amico e nemico indistinguibili. Il nemico è colpito subendo +3d8 danni, e anche l'alleato a lui più vicino viene investito da un colpo di rimbalzo per 1d4 danni." }, + { "min": 1, "max": 1, "result": "L'arma del nemico è frantumata. Se il nemico non ha un'arma, infliggi +3d12 danni con questo colpo." }, + { "min": 2, "max": 2, "result": "Un assalto furioso scaglia il nemico indietro di (1d10)×30 cm. Eventuali nemici adiacenti colpiscono accidentalmente il bersaglio infliggendogli danni." }, + { "min": 3, "max": 3, "result": "Un colpo al cranio distrugge l'orecchio. Infliggi +1d12 danni con questo colpo, e il nemico subisce sordità permanente." }, + { "min": 4, "max": 4, "result": "Colpo allo stomaco! Il nemico deve superare un tiro salvezza su Tempra (CD 20 + livello del PG) o passare i prossimi 2 round vomitando bile da uno stomaco lacerato." }, + { "min": 5, "max": 5, "result": "Il nemico getta via la propria arma e implora pietà. Infliggi +1d12 danni con questo colpo ed effettua un altro attacco." }, + { "min": 6, "max": 6, "result": "Il colpo scalpa il nemico. Il sangue cola sul suo volto, e il nemico è di fatto accecato finché non viene guarito." }, + { "min": 7, "max": 7, "result": "Il nemico è impigliato nella tua arma, riducendo la sua CA di -6 finché rimane intrappolato. Effettua un altro attacco." }, + { "min": 8, "max": 12, "result": "Vedi rosso! Infliggi +1d12 danni con questo colpo, mentre sei sopraffatto dalla furia combattiva!" }, + { "min": 13, "max": 14, "result": "Colpo al braccio armato. Il nemico subisce il quadruplo dei danni dalla propria arma, mentre gli viene martellata in faccia. Il nemico lascia cadere l'arma in stupefatta meraviglia." }, + { "min": 15, "max": 15, "result": "Il colpo dilania lo scudo. Infliggi +2d12 danni con questo colpo. Se il nemico non ha scudo, deve superare un tiro salvezza su Tempra (CD 20 + livello del PG) o cadere privo di sensi per il dolore." }, + { "min": 16, "max": 16, "result": "Un colpo al sommo del cranio accorcia la colonna vertebrale, riducendo l'altezza del nemico di 15 cm. Il conseguente danno nervoso riduce la CA del nemico di -4." }, + { "min": 17, "max": 17, "result": "Il bersaglio viene sventrato, le sue viscere si riversano al suolo. Il nemico muore all'istante per lo shock." }, + { "min": 18, "max": 18, "result": "Il colpo distrugge il volto del bersaglio. Il nemico è immediatamente reso cieco e sordo, e ora è capace soltanto di emettere suoni umidi e gorgoglianti." }, + { "min": 19, "max": 19, "result": "Il colpo asporta la calotta cranica del bersaglio. Il nemico muore per esposizione della materia cerebrale entro 3d3 round." }, + { "min": 20, "max": 20, "result": "Il colpo recide il braccio dello scudo. Infliggi +2d12 danni con questo colpo. Tramontate le speranze del nemico di padroneggiare armi a due mani." }, + { "min": 21, "max": 21, "result": "Attacco divino. Infliggi +3d12 danni con questo colpo. Se il bersaglio muore, muoviti fino a 3 m ed effettua un altro attacco contro un qualsiasi nemico entro 3 m." }, + { "min": 22, "max": 22, "result": "Il colpo recide una gamba. Infliggi +2d12 danni con questo colpo, e il movimento del nemico scende a zero. Il nemico non fa altro che ululare di dolore per 1d4 round." }, + { "min": 23, "max": 23, "result": "Un colpo al cranio stordisce il nemico per 1d4+1 round e riduce permanentemente Intelligenza di 1d12. Effettua un altro attacco contro il nemico inerte." }, + { "min": 24, "max": 24, "result": "Il colpo recide il braccio armato. Infliggi +2d12 danni con questo colpo, e il nemico è disarmato — letteralmente e figurativamente." }, + { "min": 25, "max": 25, "result": "Un colpo devastante al busto svuota le viscere del nemico e schiaccia gli organi in poltiglia. Il nemico perde il 50% degli attuali punti ferita e ogni dignità." }, + { "min": 26, "max": 26, "result": "Il colpo schiaccia la gola. Il nemico inizia ad annegare nel proprio sangue e spira entro 1d4 round." }, + { "min": 27, "max": 27, "result": "Colpo paralizzante alla colonna vertebrale. Infliggi +4d12 danni con questo colpo, e il nemico subisce paralisi permanente." }, + { "min": 28, "max": null, "result": "Il nemico è decapitato con un solo colpo. Sei la Morte incarnata. Continua ad attaccare qualsiasi nemico entro 3 m finché non manchi un colpo." } + ] + } +} diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/engine/__init__.py b/src/engine/__init__.py new file mode 100644 index 0000000..5fa5cf2 --- /dev/null +++ b/src/engine/__init__.py @@ -0,0 +1,17 @@ +from src.engine.config import ProviderConfig, load_from_env, ProviderKind +from src.engine.dm import dm_cycle, interactive +from src.engine.llm import set_provider +from src.engine.state_machine import GameContext, process_turn +from src.engine.tools import run as run_tool + +__all__ = [ + "dm_cycle", + "interactive", + "GameContext", + "process_turn", + "run_tool", + "ProviderConfig", + "ProviderKind", + "load_from_env", + "set_provider", +] diff --git a/src/engine/config.py b/src/engine/config.py new file mode 100644 index 0000000..8be33de --- /dev/null +++ b/src/engine/config.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +import os +from dataclasses import dataclass +from typing import Literal + +ProviderKind = Literal["ollama", "openrouter", "local"] + +@dataclass +class ProviderConfig: + kind: ProviderKind = "openrouter" + ollama_base_url: str = "http://192.168.0.50:11434" + ollama_model: str = "gemma2:9b" + openrouter_api_key: str = "" + openrouter_model: str = "openrouter/free" + openrouter_free_fallback: bool = True + local_base_url: str = "http://192.168.0.240/v1" + local_api_key: str = "" + local_model: str = "big-pickle" + timeout: int = 60 + + +def load_from_env() -> ProviderConfig: + return ProviderConfig( + kind=os.getenv("DCC_LLM_PROVIDER", "openrouter"), # type: ignore + ollama_base_url=os.getenv("DCC_OLLAMA_URL", "http://192.168.0.50:11434"), + ollama_model=os.getenv("DCC_OLLAMA_MODEL", "gemma2:9b"), + openrouter_api_key=os.getenv("OPENROUTER_API_KEY", ""), + openrouter_model=os.getenv("DCC_OPENROUTER_MODEL", "openrouter/free"), + openrouter_free_fallback=os.getenv("DCC_OR_FREE_FALLBACK", "1") == "1", + local_base_url=os.getenv("DCC_LOCAL_URL", "http://192.168.0.240/v1"), + local_api_key=os.getenv("DCC_LOCAL_KEY", ""), + local_model=os.getenv("DCC_LOCAL_MODEL", "big-pickle"), + timeout=int(os.getenv("DCC_LLM_TIMEOUT", "60")), + ) diff --git a/src/engine/db.py b/src/engine/db.py new file mode 100644 index 0000000..d406e89 --- /dev/null +++ b/src/engine/db.py @@ -0,0 +1,557 @@ +""" +Database module per DCC RPG — Neon Scratch Lounge. + +Gestisce schema SQLite e popolazione iniziale dati. +""" + +import sqlite3 +import json +import os +from pathlib import Path + +DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db" +DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data" + + +def get_conn() -> sqlite3.Connection: + """Restituisce connessione al database, creandolo se non esiste.""" + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.row_factory = sqlite3.Row + return conn + + +def init_schema(conn: sqlite3.Connection) -> None: + conn.executescript(""" + -- ============================================================ + -- Tabelle di sistema (critici / fumble) + -- ============================================================ + CREATE TABLE IF NOT EXISTS crit_fumble_tables ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + description TEXT, + table_type TEXT NOT NULL CHECK(table_type IN ('crit', 'fumble')), + die TEXT, + modifier TEXT + ); + + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_entries_table + ON crit_fumble_entries(table_id); + + CREATE TABLE IF NOT EXISTS fumble_armor_die ( + armor_type TEXT PRIMARY KEY, + die TEXT NOT NULL + ); + + -- ============================================================ + -- Tabelle di stato gioco + -- ============================================================ + + CREATE TABLE IF NOT EXISTS campaigns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + adventure TEXT, + turn INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + ); + + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_char_campaign + ON characters(campaign_id); + + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_area_campaign + ON adventure_state(campaign_id, area_id); + + CREATE TABLE IF NOT EXISTS 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')) + ); + + CREATE TABLE IF NOT EXISTS 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 IF NOT EXISTS idx_turnlog_campaign + ON turn_log(campaign_id, turn); + """) + + +def populate_from_json(conn: sqlite3.Connection) -> None: + """Carica i dati da crit_fumble_tables.json se le tabelle sono vuote.""" + cursor = conn.execute("SELECT COUNT(*) FROM crit_fumble_tables") + if cursor.fetchone()[0] > 0: + return # già popolato + + json_path = DATA_DIR / "crit_fumble_tables.json" + with open(json_path, "r", encoding="utf-8") as f: + data = json.load(f) + + # Popola tabelle + table_map = {} + for table_id, table in data.items(): + if table_id == "meta": + continue + + conn.execute( + "INSERT INTO crit_fumble_tables (id, name, description, table_type, die, modifier) " + "VALUES (?, ?, ?, ?, ?, ?)", + ( + table_id, + table["name"], + table.get("description", ""), + table_id if table_id == "fumble" else "crit", + table.get("die"), + table.get("modifier"), + ), + ) + table_map[table_id] = table_id + + # Popola entries + for entry in table["entries"]: + conn.execute( + "INSERT INTO crit_fumble_entries (table_id, roll_min, roll_max, result_text) " + "VALUES (?, ?, ?, ?)", + (table_id, entry["min"], entry["max"], entry["result"]), + ) + + # Popola mappatura armatura→dado per fumble + if table_id == "fumble" and "die_by_armor" in table: + for armor, die in table["die_by_armor"].items(): + conn.execute( + "INSERT OR REPLACE INTO fumble_armor_die (armor_type, die) VALUES (?, ?)", + (armor, die), + ) + + conn.commit() + + +def init_db() -> sqlite3.Connection: + """Inizializza database completo. Chiamata all'avvio.""" + conn = get_conn() + init_schema(conn) + populate_from_json(conn) + return conn + + +# --------------------------------------------------------------------------- +# Query helpers +# --------------------------------------------------------------------------- + +def lookup_crit_fumble(table_id: str, roll: int, luck_mod: int = 0) -> dict | None: + """Cerca il risultato appropriato per un tiro su una tabella crit/fumble. + + Applica automaticamente il modificatore: + - crit: +luck_mod + - fumble: -luck_mod (opposto della Fortuna) + + Args: + table_id: "fumble", "crit_I", "crit_II", "crit_III", "crit_IV", "crit_V" + roll: Valore tirato (prima del modificatore) + luck_mod: Modificatore di Fortuna del PG + + Returns: + Dict con {table_id, table_name, roll_applied, result} oppure None + """ + conn = get_conn() + cursor = conn.execute( + "SELECT id, name, table_type, die, modifier FROM crit_fumble_tables WHERE id = ?", + (table_id,), + ) + table = cursor.fetchone() + if not table: + return None + + # Applica modificatore + if table["table_type"] == "fumble": + modified_roll = roll - luck_mod + else: + modified_roll = roll + luck_mod + + # Cerca entry corrispondente + cursor = conn.execute( + """SELECT result_text FROM crit_fumble_entries + WHERE table_id = ? + AND (roll_min IS NULL OR roll_min <= ?) + AND (roll_max IS NULL OR roll_max >= ?) + ORDER BY roll_min IS NOT NULL DESC, roll_min DESC + LIMIT 1""", + (table_id, modified_roll, modified_roll), + ) + entry = cursor.fetchone() + if not entry: + return None + + return { + "table_id": table["id"], + "table_name": table["name"], + "table_type": table["table_type"], + "die": table["die"], + "roll_raw": roll, + "luck_mod": luck_mod, + "roll_applied": modified_roll, + "result": entry["result_text"], + } + + +def get_fumble_die(armor_type: str) -> str | None: + """Restituisce il dado fumble in base al tipo di armatura.""" + conn = get_conn() + cursor = conn.execute( + "SELECT die FROM fumble_armor_die WHERE armor_type = ?", (armor_type,) + ) + row = cursor.fetchone() + return row["die"] if row else None + + +def get_all_tables() -> list[dict]: + """Restituisce metadati di tutte le tabelle crit/fumble.""" + conn = get_conn() + cursor = conn.execute( + "SELECT id, name, description, table_type, die, modifier FROM crit_fumble_tables ORDER BY id" + ) + return [dict(row) for row in cursor.fetchall()] + + +# --------------------------------------------------------------------------- +# CRUD — Game State +# --------------------------------------------------------------------------- + + +def create_campaign(name: str, adventure: str = "") -> int: + conn = get_conn() + cur = conn.execute( + "INSERT INTO campaigns (name, adventure) VALUES (?, ?)", (name, adventure) + ) + conn.commit() + return cur.lastrowid or 0 + + +def get_campaign(campaign_id: int) -> dict | None: + conn = get_conn() + row = conn.execute( + "SELECT * FROM campaigns WHERE id = ?", (campaign_id,) + ).fetchone() + return dict(row) if row else None + + +def create_character( + campaign_id: int, name: str, char_class: str = "", occupation: str = "" +) -> int: + conn = get_conn() + cur = conn.execute( + """INSERT INTO characters (campaign_id, name, char_class, occupation) + VALUES (?, ?, ?, ?)""", + (campaign_id, name, char_class, occupation), + ) + conn.commit() + return cur.lastrowid or 0 + + +def get_characters(campaign_id: int) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM characters WHERE campaign_id = ? ORDER BY id", + (campaign_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def get_character(char_id: int) -> dict | None: + conn = get_conn() + row = conn.execute( + "SELECT * FROM characters WHERE id = ?", (char_id,) + ).fetchone() + return dict(row) if row else None + + +def update_character(char_id: int, **kwargs) -> bool: + cols = ", ".join(f"{k} = ?" for k in kwargs) + vals = list(kwargs.values()) + [char_id] + conn = get_conn() + conn.execute(f"UPDATE characters SET {cols} WHERE id = ?", vals) + conn.commit() + return conn.total_changes > 0 + + +def advance_turn(campaign_id: int) -> int: + conn = get_conn() + conn.execute( + "UPDATE campaigns SET turn = turn + 1 WHERE id = ?", (campaign_id,) + ) + conn.commit() + row = conn.execute( + "SELECT turn FROM campaigns WHERE id = ?", (campaign_id,) + ).fetchone() + return row["turn"] if row else 0 + + +def log_turn(campaign_id: int, turn: int, action: str, result: str, character_id: int = 0) -> int: + conn = get_conn() + cur = conn.execute( + "INSERT INTO turn_log (campaign_id, turn, character_id, action, result) VALUES (?, ?, ?, ?, ?)", + (campaign_id, turn, character_id or None, action, result), + ) + conn.commit() + return cur.lastrowid or 0 + + +def get_log(campaign_id: int, limit: int = 20) -> list[dict]: + conn = get_conn() + rows = conn.execute( + """SELECT tl.*, c.name AS char_name + FROM turn_log tl + LEFT JOIN characters c ON tl.character_id = c.id + WHERE tl.campaign_id = ? + ORDER BY tl.id DESC LIMIT ?""", + (campaign_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + +def set_area_state(campaign_id: int, area_id: str, visited: bool = True, state: dict = {}) -> None: + conn = get_conn() + conn.execute( + """INSERT INTO adventure_state (campaign_id, area_id, visited, state) + VALUES (?, ?, ?, ?) + ON CONFLICT(campaign_id, area_id) DO UPDATE SET + visited = excluded.visited, + state = excluded.state""", + (campaign_id, area_id, int(visited), json.dumps(state)), + ) + conn.commit() + + +def get_area_state(campaign_id: int) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM adventure_state WHERE campaign_id = ? ORDER BY area_id", + (campaign_id,), + ).fetchall() + return [dict(r) for r in rows] + + +def add_quest(campaign_id: int, name: str, description: str) -> int: + conn = get_conn() + cur = conn.execute( + "INSERT INTO quests (campaign_id, name, description) VALUES (?, ?, ?)", + (campaign_id, name, description), + ) + conn.commit() + return cur.lastrowid or 0 + + +def update_quest(quest_id: int, status: str) -> bool: + conn = get_conn() + conn.execute("UPDATE quests SET status = ? WHERE id = ?", (status, quest_id)) + conn.commit() + return conn.total_changes > 0 + + +def get_quests(campaign_id: int) -> list[dict]: + conn = get_conn() + rows = conn.execute( + "SELECT * FROM quests WHERE campaign_id = ? ORDER BY id", (campaign_id,) + ).fetchall() + return [dict(r) for r in rows] + + +# --------------------------------------------------------------------------- +# Adventure data loading (from JSON) +# --------------------------------------------------------------------------- + +ADVENTURE_DATA_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "adventure" / "avventura.json" + + +def _load_adventure_json() -> dict: + """Carica il file JSON dei dati avventura.""" + with open(ADVENTURE_DATA_PATH, "r", encoding="utf-8") as f: + return json.load(f) + + +def get_adventure_meta() -> dict: + """Restituisce i metadati dell'avventura.""" + data = _load_adventure_json() + return data.get("meta", {}) + + +def get_all_areas() -> list[dict]: + """Restituisce tutte le aree dell'avventura.""" + data = _load_adventure_json() + return data.get("areas", []) + + +def get_area_by_id(area_id: str) -> dict | None: + """Cerca un'area per ID (es. '1.1', '1.5').""" + for a in get_all_areas(): + if a["id"] == area_id: + return a + return None + + +def get_monster_by_id(monster_id: str) -> dict | None: + """Restituisce il blocco statistiche di un mostro per ID.""" + data = _load_adventure_json() + for m in data.get("monsters", []): + if m["id"] == monster_id: + return m + return None + + +def get_monsters_in_area(area_id: str) -> list[dict]: + """Restituisce i mostri presenti in un'area (con stat block completi).""" + area = get_area_by_id(area_id) + if not area: + return [] + result = [] + for ref in area.get("monsters", []): + monster = get_monster_by_id(ref["ref_id"]) + if monster: + entry = dict(monster) + entry["count"] = ref.get("count", 1) + entry["tactics"] = ref.get("tactics", "") + result.append(entry) + return result + + +def get_all_quest_defs() -> list[dict]: + """Restituisce le quest definite nell'avventura.""" + data = _load_adventure_json() + return data.get("quests", []) + + +def get_special_items() -> list[dict]: + """Restituisce gli oggetti speciali dell'avventura.""" + data = _load_adventure_json() + return data.get("special_items", []) + + +def seed_adventure_state(campaign_id: int) -> None: + """Inizializza adventure_state per tutte le aree note dalla definizione JSON.""" + areas = get_all_areas() + if not areas: + return + for area in areas: + set_area_state(campaign_id, area["id"], visited=False, state={}) + + +def seed_quests_from_adventure(campaign_id: int) -> None: + """Inizializza le quest dalla definizione JSON.""" + quests = get_all_quest_defs() + for q in quests: + add_quest(campaign_id, q["name"], q.get("description", "")) + + +def format_area_context(area_id: str) -> str: + """Formatta descrizione area per contesto LLM. Necessario per prompt strutturare.""" + area = get_area_by_id(area_id) + if not area: + return "" + lines = [f"=== {area['name']} (Area {area['id']}) ==="] + lines.append(area.get("description", "")) + if area.get("atmosphere"): + lines.append(f"Atmosfera: {area['atmosphere']}") + if area.get("features"): + lines.append(f"Elementi: {', '.join(area['features'])}") + monsters = get_monsters_in_area(area_id) + if monsters: + lines.append("Creature presenti:") + for m in monsters: + count_str = f"x{m['count']} " if m.get("count", 1) > 1 else "" + stats = m.get("stats", {}) + lines.append(f" - {count_str}{m.get('name', '?')} (CA {stats.get('ac', '?')}, PF {stats.get('hp', '?')}, Att {stats.get('attack', '?')}, Dan {stats.get('damage', '?')})") + return "\n".join(lines) + + +def format_all_areas_context() -> str: + """Formatta TUTTE le aree per il contesto iniziale LLM.""" + parts = ["LUOGHI DELL'AVVENTURA:", ""] + for a in get_all_areas(): + block = f"Area {a['id']} — {a['name']}" + block += f"\n {a.get('description', '')[:150]}" + block += f"\n Collegate a: {', '.join(a.get('connections', []))}" + mons = get_monsters_in_area(a["id"]) + if mons: + names = [f"{m['name']}{' x'+str(m['count']) if m.get('count',1)>1 else ''}" for m in mons] + block += f"\n Creature: {', '.join(names)}" + parts.append(block) + return "\n\n".join(parts) + + +# --------------------------------------------------------------------------- +# CLI test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + conn = init_db() + + print("=== Tabelle caricate ===") + for t in get_all_tables(): + print(f" {t['id']}: {t['name']} ({t['table_type']}, dado={t['die']})") + + print("\n=== Test lookup fumble ===") + for roll_val in [-1, 1, 5, 10, 16, 20]: + r = lookup_crit_fumble("fumble", roll_val, luck_mod=1) + print(f" fumble roll={roll_val} luck=+1 → (applied={r['roll_applied']}) {r['result'][:60]}...") + + print("\n=== Test lookup crit ===") + for table in ["crit_I", "crit_II", "crit_III", "crit_IV", "crit_V"]: + r = lookup_crit_fumble(table, 15, luck_mod=0) + print(f" {table} roll=15 → (applied={r['roll_applied']}) {r['result'][:60]}...") + + print("\n=== Test fumble die by armor ===") + for armor in ["none", "light", "medium", "heavy"]: + die = get_fumble_die(armor) + print(f" {armor}: {die}") diff --git a/src/engine/dice.py b/src/engine/dice.py new file mode 100644 index 0000000..47d0fa8 --- /dev/null +++ b/src/engine/dice.py @@ -0,0 +1,272 @@ +""" +Modulo Dadi per DCC RPG — Neon Scratch Lounge. + +2 tool separati: + - roll(die, count, modifier) → risultato tiro + - dice_chain(die, steps) → dado risolto nella catena DCC + +La catena dadi DCC: d3 → d4 → d5 → d6 → d7 → d8 → d10 → d12 → d14 → d16 → d20 → d24 → d30 +""" + +from __future__ import annotations + +import random +import re +from typing import TypedDict + +# --------------------------------------------------------------------------- +# DCC Dice Chain +# --------------------------------------------------------------------------- + +DICE_CHAIN: list[str] = [ + "d3", "d4", "d5", "d6", "d7", "d8", + "d10", "d12", "d14", "d16", "d20", "d24", "d30", +] + +DICE_INDEX: dict[str, int] = {die: i for i, die in enumerate(DICE_CHAIN)} + +# Dadi speciali / extra DCC non nella chain lineare +SPECIAL_DICE: dict[str, int] = { + "d%": 100, + "d100": 100, + "d0": 0, + "d1": 1, + "d2": 2, +} + +# Unione per lookup veloce +ALL_DICE: dict[str, int] = {die: int(die[1:]) for die in DICE_CHAIN} +ALL_DICE.update(SPECIAL_DICE) + +# Regex per validazione input LLM +_DIE_PATTERN = re.compile(r"^d(\d+)$|^d%$") + +# --------------------------------------------------------------------------- +# Tipi di ritorno +# --------------------------------------------------------------------------- + + +class RollResult(TypedDict): + die: str + count: int + modifier: int + results: list[int] + total: int + crit: bool + fumble: bool + raw: str + + +class DiceChainResult(TypedDict): + input_die: str + steps: int + output_die: str + chain: list[str] + + +class ValidationError(TypedDict): + error: str + valid_dice: list[str] + + +# --------------------------------------------------------------------------- +# Validazione +# --------------------------------------------------------------------------- + + +def validate_die(die: str) -> str | None: + """Ritorna il dado normalizzato se valido, None altrimenti.""" + die = die.strip().lower() + if die in ALL_DICE: + return die + return None + + +def normalise_die(die: str) -> str: + """Normalizza alias → forma canonica.""" + d = die.strip().lower() + return d + + +# --------------------------------------------------------------------------- +# Tool 1 — roll +# --------------------------------------------------------------------------- + +def roll( + die: str, + count: int = 1, + modifier: int = 0, +) -> RollResult: + """Tira un dado DCC. + + Parametri + --------- + die : str + Nome dado (es. "d20", "d%", "d3", "d14"). + count : int + Numero di dadi da tirare (default 1). + modifier : int + Modificatore da sommare al totale (default 0). + + Ritorna + ------- + RollResult con results[], total, crit/fumble flags, raw string. + """ + die_n = normalise_die(die) + err = validate_die(die_n) + if err is None: + raise ValueError( + f"Dad '{die}' non valido. Usa: {', '.join(ALL_DICE.keys())}" + ) + + faces = ALL_DICE[die_n] + max_val = faces + + results: list[int] = [] + for _ in range(count): + if faces <= 0: + results.append(0) + elif faces == 1: + results.append(1) + else: + results.append(random.randint(1, faces)) + + total = sum(results) + modifier + + # Crit/fumble: solo per d20 standard (regola DCC) + crit = False + fumble = False + if die_n == "d20": + for r in results: + if r == 20: + crit = True + if r == 1: + fumble = True + + # Raw string per logging + raw_parts = [] + if count > 1: + raw_parts.append(f"{count}") + raw_parts.append(die_n) + if modifier: + raw_parts.append(f"{modifier:+d}") + raw = "".join(raw_parts) if len(raw_parts) == 1 else f"{count}{die_n}{modifier:+d}" if modifier else f"{count}{die_n}" + + return RollResult( + die=die_n, + count=count, + modifier=modifier, + results=results, + total=total, + crit=crit, + fumble=fumble, + raw=raw, + ) + + +# --------------------------------------------------------------------------- +# Tool 2 — dice_chain +# --------------------------------------------------------------------------- + +def dice_chain( + die: str, + steps: int, +) -> DiceChainResult | ValidationError: + """Risolve la catena dadi DCC. + + Prende un dado di partenza e lo sposta di N step lungo la DICE_CHAIN. + Step positivi = su (dado più grande), negativi = giù (dado più piccolo). + + Parametri + --------- + die : str + Dado di partenza (es. "d3", "d10"). + steps : int + Numero di step. Positivo = sale, negativo = scende. + + Ritorna + ------- + DiceChainResult con dado output + chain completa. + Se il dado non è nella chain, ritorna ValidationError. + """ + die_n = normalise_die(die) + + if die_n not in DICE_INDEX: + return ValidationError( + error=f"'{die}' non è nella catena dadi DCC. I dadi della chain sono: {', '.join(DICE_CHAIN)}", + valid_dice=list(DICE_CHAIN), + ) + + idx = DICE_INDEX[die_n] + new_idx = idx + steps + + # Clamping ai limiti della chain + if new_idx < 0: + new_idx = 0 + if new_idx >= len(DICE_CHAIN): + new_idx = len(DICE_CHAIN) - 1 + + output_die = DICE_CHAIN[new_idx] + + return DiceChainResult( + input_die=die_n, + steps=steps, + output_die=output_die, + chain=DICE_CHAIN.copy(), + ) + + +# --------------------------------------------------------------------------- +# Helper per testing / CLI +# --------------------------------------------------------------------------- + +def roll_str(raw: str) -> RollResult: + """Parser stile '2d20+3' → chiamata roll().""" + m = re.match(r"(\d+)?d(\d+|%)([+-]\d+)?$", raw.strip().lower()) + if not m: + raise ValueError(f"Formato non valido: '{raw}'. Usa tipo '2d20+3'.") + count = int(m.group(1)) if m.group(1) else 1 + die = f"d{m.group(2)}" + modifier = int(m.group(3)) if m.group(3) else 0 + return roll(die, count, modifier) + + +# --------------------------------------------------------------------------- +# Fire-and-forget test rapido (python src/engine/dice.py) +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + print("=== Test roll ===") + for d in ["d20", "d%", "d3", "d7", "d14"]: + r = roll(d) + print(f" {r['raw']}: {r['results']} (tot {r['total']}) crit={r['crit']} fumble={r['fumble']}") + + print("\n=== Test dice_chain ===") + test_cases = [ + ("d3", 2), # d3 +2 → d5 + ("d20", -3), # d20 -3 → d10 + ("d7", 0), # d7 → d7 + ("d3", 20), # overflow → d30 + ("d30", -30), # underflow → d3 + ] + for die, steps in test_cases: + res = dice_chain(die, steps) + if "error" in res: + print(f" dice_chain('{die}', {steps}) → ERRORE: {res['error']}") + else: + print(f" dice_chain('{die}', {steps}) → {res['output_die']}") + + print("\n=== Test roll_str ===") + for raw in ["d20", "2d6+2", "d%", "3d10-1"]: + r = roll_str(raw) + print(f" '{raw}' → {r['results']} tot {r['total']}") + + print("\n=== Validazione ===") + for bad in ["d9", "d15", "d21", "xyz"]: + res = dice_chain(bad, 1) + if isinstance(res, dict) and "error" in res: + print(f" dice_chain('{bad}', 1) → ERRORE: {res['error']}") + try: + roll(bad) + except ValueError as e: + print(f" roll('{bad}') → ERRORE: {e}") diff --git a/src/engine/dm.py b/src/engine/dm.py new file mode 100644 index 0000000..808af36 --- /dev/null +++ b/src/engine/dm.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +import logging + +from src.engine import db, tools +from src.engine.config import load_from_env +from src.engine.llm import ask as llm_ask, parse_tool_call +from src.engine.prompts import SYSTEM_PROMPT, build_prompt +from src.engine.state_machine import GameContext, _parse_action + +log = logging.getLogger(__name__) + + +def _llm_decision(ctx: GameContext, player_action: str) -> tuple[str, dict, str]: + prompt = build_prompt(ctx, player_action) + try: + raw = llm_ask(prompt, system=SYSTEM_PROMPT) + except Exception as e: + log.warning("LLM call failed (%s), fallback a _parse_action", e) + tool, params = _parse_action(player_action, ctx) + return tool, params, "" + + decision = parse_tool_call(raw) + + tool = decision.get("tool", "") + params = decision.get("params", {}) + narration = decision.get("narration", "") + + if not tool: + log.info("LLM non ha prodotto tool valido, fallback heuristico") + tool, params = _parse_action(player_action, ctx) + + return tool, params, narration + + +def dm_cycle(campaign_id: int, player_action: str) -> dict: + ctx = GameContext.load(campaign_id) + + tool_name, tool_params, narration = _llm_decision(ctx, player_action) + + result = tools.run(tool_name, **tool_params) + + mechanic_msg = result.get("message", "") + full_msg = f"{narration}\n{mechanic_msg}".strip() + + turn = db.advance_turn(campaign_id) + db.log_turn(campaign_id, turn, player_action, full_msg) + + return { + "turn": turn, + "action": player_action, + "tool": tool_name, + "params": tool_params, + "narration": narration, + "mechanics": mechanic_msg, + "full_output": full_msg, + } + + +def interactive(campaign_id: int): + cfg = load_from_env() + print("=" * 60) + print("DCC RPG — AI Dungeon Master") + print(f"Provider: {cfg.kind} | Modello: {getattr(cfg, f'{cfg.kind}_model', '?')}") + print("Scrivi 'esci' per uscire, 'stato' per vedere la scheda.") + print("=" * 60) + print() + + ctx = GameContext.load(campaign_id) + print(ctx.summary()) + print() + + while True: + try: + action = input("⚔️ » ").strip() + except (EOFError, KeyboardInterrupt): + print("\nFine partita.") + break + + if not action: + continue + if action.lower() in ("esci", "exit", "quit"): + print("Alla prossima avventura!") + break + if action.lower() in ("stato", "stat", "status"): + print(GameContext.load(campaign_id).summary()) + continue + + print("🎲 Elaborazione...") + out = dm_cycle(campaign_id, action) + print(f"\n📖 {out['narration'] or '(azione riconosciuta)'}") + if out['mechanics']: + print(f"⚙️ {out['mechanics']}") + print() + + +if __name__ == "__main__": + import sys + + db.init_db() + camp_id = db.create_campaign("Avventura 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, + armor_type="light", + ) + interactive(camp_id) diff --git a/src/engine/llm.py b/src/engine/llm.py new file mode 100644 index 0000000..7774abe --- /dev/null +++ b/src/engine/llm.py @@ -0,0 +1,111 @@ +from __future__ import annotations + +import json +import re + +from src.engine.config import load_from_env +from src.engine.providers import LLMProvider, create_provider + +DEFAULT_MODEL = "gemma2:9b" + +_KNOWN_TOOLS = { + "roll_dice": {"die": "str", "count": "int (default 1)", "modifier": "int (default 0)"}, + "dice_chain": {"die": "str", "steps": "int"}, + "combat": {"character_id": "int", "target_ac": "int", "attack_bonus": "int (default 0)", + "damage_die": "str (default d8)", "damage_bonus": "int (default 0)", + "mighty_deed_die": "str (default '')", "luck_mod": "int (default 0)", + "armor_type": "str (none/light/medium/heavy)"}, + "ability_check": {"character_id": "int", "dc": "int", "modifier": "int (default 0)"}, + "saving_throw": {"character_id": "int", "save_type": "str (Tempra/Riflessi/Volontà)", "dc": "int", "modifier": "int (default 0)"}, + "movement": {"distance_m": "int", "terrain": "str (normale/difficile/molto difficile)", "stealth": "bool (default false)"}, + "spellcasting": {"character_id": "int", "spell_name": "str", "spell_check": "int", "dc": "int (default 12)"}, + "update_inventory": {"character_id": "int", "item": "str", "action": "str (add/remove)"}, + "award_xp": {"character_id": "int", "xp": "int"}, + "dm_override": {"command": "str", "params": "dict (opzionale)"}, +} + +SYSTEM_PROMPT = f"""Sei il Dungeon Master di una partita di DCC RPG (Dungeon Crawl Classics). +Il giocatore scrive un'azione e TU decidi quale strumento chiamare. + +Strumenti disponibili: +{json.dumps(_KNOWN_TOOLS, indent=2)} + +Regole: +1. Scegli SEMPRE UNO strumento per rispondere all'azione del giocatore. +2. I parametri DEVONO essere realistici per il contesto. +3. Se il giocatore fa qualcosa di narrativo, usa dm_override con command="narrate". +4. Tira sempre i dadi quando il giocatore tenta un'azione rischiosa. +5. Rispondi SOLO con JSON in questo formato: +{{"tool": "nome_tool", "params": {{...}}, "narration": "breve descrizione di cosa succede"}} + + +""" + +_provider: LLMProvider | None = None + + +def get_provider() -> LLMProvider: + global _provider + if _provider is None: + _provider = create_provider(load_from_env()) + return _provider + + +def set_provider(provider: LLMProvider) -> None: + global _provider + _provider = provider + + +def ask(prompt: str, system: str = SYSTEM_PROMPT) -> str: + return get_provider().ask(prompt, system=system) + + +def _extract_json(text: str) -> str | None: + for pat in [ + r'```json\s*([\s\S]*?)```', + r'```\s*([\s\S]*?)```', + r'(\{[\s\S]*\})', + ]: + m = re.search(pat, text) + if m: + candidate = m.group(1).strip() + try: + json.loads(candidate) + return candidate + except json.JSONDecodeError: + continue + return None + + +def parse_tool_call(text: str) -> dict: + text = text.strip() + blob = _extract_json(text) + if blob: + try: + data = json.loads(blob) + if "tool" in data: + return data + except json.JSONDecodeError: + pass + + tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I) + 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": ""} + + +if __name__ == "__main__": + test_actions = [ + "Tiro un d20 per vedere se sento rumori", + "Attacco l'orco con la mia ascia!", + "Esploro il corridoio buio", + ] + for a in test_actions: + print(f"\n>>> Giocatore: {a}") + try: + resp = ask(a) + print(f"LLM: {resp[:300]}") + parsed = parse_tool_call(resp) + print(f"Parser: {json.dumps(parsed, indent=2)}") + except Exception as e: + print(f"ERRORE: {e}") diff --git a/src/engine/prompts.py b/src/engine/prompts.py new file mode 100644 index 0000000..9511224 --- /dev/null +++ b/src/engine/prompts.py @@ -0,0 +1,144 @@ +from __future__ import annotations + +import json +from typing import Any + +from src.engine import db as db_module +from src.engine.state_machine import GameContext + +_KNOWN_TOOLS: dict[str, dict[str, str]] = { + "roll_dice": {"die": "tipo dado (d3..d30, d%)", "count": "numero dadi (default 1)", "modifier": "modificatore (default 0)"}, + "dice_chain": {"die": "dado iniziale", "steps": "passi nella catena dadi DCC"}, + "combat": { + "character_id": "id pg", + "target_ac": "CA bersaglio", + "attack_bonus": "bonus attacco (default 0)", + "damage_die": "dado danno (default d8)", + "damage_bonus": "bonus danno (default 0)", + "mighty_deed_die": "dado deed guerriero (es. d3)", + "armor_type": "none/light/medium/heavy", + }, + "ability_check": {"character_id": "id pg", "dc": "CD prova", "modifier": "bonus/penalità"}, + "saving_throw": {"character_id": "id pg", "save_type": "Tempra|Riflessi|Volontà", "dc": "CD", "modifier": "bonus/penalità"}, + "movement": {"distance_m": "metri", "terrain": "normale|difficile|molto difficile"}, + "spellcasting": {"character_id": "id pg", "spell_name": "nome incantesimo", "spell_check": "valore tiro"}, + "update_inventory": {"character_id": "id pg", "item": "nome oggetto", "action": "add|remove"}, + "award_xp": {"character_id": "id pg", "xp": "quantità PE"}, + "dm_override": {"command": "narrate|set|force", "params": "parametri opzionali"}, +} + +TOOL_EXAMPLES = """ +Esempi: +- Attacco con spada → {"tool": "combat", "params": {"character_id": 1, "target_ac": 12, "attack_bonus": 0, "damage_die": "d8"}, "narration": "Sguaini la lama e colpisci il guardiano!"} +- Tiro d20 per sentire rumori → {"tool": "roll_dice", "params": {"die": "d20"}, "narration": "Tendi l'orecchio nel buio..."} +- Esploro la stanza → {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": "La stanza è polverosa..."}}, "narration": "Scrutando nell'oscurità, noti..."} +- Prova di percezione CD 12 → {"tool": "ability_check", "params": {"character_id": 1, "dc": 12}, "narration": "Cerchi indizi nella stanza..."} +""" + +SYSTEM_PROMPT = f"""Sei il Dungeon Master di DCC RPG (Dungeon Crawl Classics) — narrazione epico-oscura, tono sword & sorcery. + +STILE: +- 2-3 frasi per scena. Non essere prolisso. +- Descrivi suoni, odori, sensazioni tattili. +- Usa ITALIANO. +- Dai sempre una scelta al giocatore alla fine. +- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione. + +MECCANICHE: +- CD base: 12 = facile, 15 = media, 18 = difficile +- Tiri salvezza: Tempra (veleno/malattia), Riflessi (trappole/esplosioni), Volontà (magia/paura) +- Combattimento: iniziativa (d20), attacco (d20 + bonus vs CA), danni (dado arma) +- Mighty Deed: i Guerrieri possono fare imprese leggendarie con d3/d4/d5 aggiuntivo + +STRUMENTI DISPONIBILI: +{json.dumps(_KNOWN_TOOLS, indent=2, ensure_ascii=False)} +{TOOL_EXAMPLES} +FORMATO RISPOSTA — SOLO JSON, nient'altro: +{{"tool": "nome_strumento", "params": {{...}}, "narration": "descrizione narrativa"}} +Se l'azione è puramente narrativa (dialogo, esplorazione, interazione), usa dm_override con command="narrate". +""" + + +def _enrich_areas(ctx_areas: list[dict]) -> list[dict]: + area_map = {a["id"]: a for a in db_module.get_all_areas()} + enriched = [] + for sa in ctx_areas: + aid = sa["area_id"] + full = area_map.get(aid, {}) + enriched.append({ + "area_id": aid, + "name": full.get("name", aid), + "description": full.get("description", ""), + "connections": full.get("connections", []), + "features": full.get("features", []), + "visited": sa.get("visited", 0), + "monsters": [m.get("ref_id", "") for m in full.get("monsters", [])], + }) + return enriched + + +def format_context(ctx: GameContext) -> str: + lines = [] + camp = ctx.campaign or {"name": "?", "description": ""} + lines.append(f"Campagna: {camp['name']}") + lines.append(f"Descrizione: {camp.get('description', '')}") + lines.append(f"Turno: {ctx.turn}") + lines.append("") + + lines.append("PERSONAGGI:") + for c in ctx.characters: + bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({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]) + lines.append("") + + if ctx.log: + lines.append("CRONOLOGIA (ultimi 5 turni):") + for entry in ctx.log[-5:]: + a = entry.get("action", "?") + r = entry.get("result", "")[:80] + lines.append(f" «{a}» → {r}") + lines.append("") + + if ctx.quests: + lines.append("MISSIONI:") + for q in ctx.quests: + lines.append(f" [{q['status']}] {q['name']}") + lines.append("") + + enriched = _enrich_areas(ctx.areas) + if enriched: + lines.append("LUOGHI DELL'AVVENTURA (tutte le aree):") + visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")} + for a in enriched: + status = "visitata" if a["area_id"] in visited_ids else "inesplorata" + lines.append(f" [{a['area_id']}] {a['name']} [{status}]") + if a["visited"] and a.get("description"): + lines.append(f" {a['description'][:120]}") + lines.append(f" Collegate: {', '.join(a['connections']) if a['connections'] else 'nessuna'}") + if a.get("features"): + lines.append(f" Elementi: {' | '.join(a['features'][:3])}") + if a.get("monsters"): + names = [] + for mid in a["monsters"]: + m = db_module.get_monster_by_id(mid) + if m: + names.append(m["name"]) + if names: + lines.append(f" Creature: {', '.join(names)}") + lines.append("") + + return "\n".join(lines) + + +def build_prompt(ctx: GameContext, player_action: str, current_area_id: str = "") -> str: + area_block = "" + if current_area_id: + area_data = db_module.format_area_context(current_area_id) + if area_data: + area_block = f"\n\nAREA ATTUALE:\n{area_data}\n" + return f"""{format_context(ctx)}{area_block}--- +AZIONE GIOCATORE: "{player_action}" + +Rispondi con il JSON nel formato specificato (tool, params, narration).""" diff --git a/src/engine/providers.py b/src/engine/providers.py new file mode 100644 index 0000000..4bfbaca --- /dev/null +++ b/src/engine/providers.py @@ -0,0 +1,141 @@ +from __future__ import annotations + +import json +import os +from abc import ABC, abstractmethod +from typing import Any + +import httpx + +from src.engine.config import ProviderConfig, load_from_env + + +class LLMProvider(ABC): + @abstractmethod + def ask(self, prompt: str, system: str = "") -> str: + ... + + +class OllamaProvider(LLMProvider): + def __init__(self, base_url: str, model: str, timeout: int = 60): + self.base_url = base_url.rstrip("/") + self.model = model + self.timeout = timeout + + def ask(self, prompt: str, system: str = "") -> str: + body = {"model": self.model, "prompt": prompt, "stream": False} + if system: + body["system"] = system + resp = httpx.post( + f"{self.base_url}/api/generate", + json=body, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json().get("response", "") + + +class OpenRouterProvider(LLMProvider): + _FREE_FALLBACK = [ + "openrouter/free", + "google/gemma-4-31b-it:free", + "qwen/qwen3-coder:free", + ] + + def __init__(self, api_key: str, model: str, timeout: int = 60, free_fallback: bool = True): + self.api_key = api_key + self.model = model + self.timeout = timeout + self.free_fallback = free_fallback + + def _call(self, model: str, prompt: str, system: str) -> str: + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + body = {"model": model, "messages": messages, "stream": False} + + resp = httpx.post( + "https://openrouter.ai/api/v1/chat/completions", + json=body, + headers=headers, + timeout=self.timeout, + ) + resp.raise_for_status() + return resp.json()["choices"][0]["message"]["content"] + + def ask(self, prompt: str, system: str = "") -> str: + models = [self.model] + if self.free_fallback: + models.extend(self._FREE_FALLBACK) + last_err = None + for m in models: + try: + return self._call(m, prompt, system) + except Exception as e: + last_err = e + continue + raise last_err # type: ignore + + +class LocalProvider(LLMProvider): + def __init__(self, base_url: str, model: str, api_key: str = "", timeout: int = 60): + self.base_url = base_url.rstrip("/") + self.model = model + self.api_key = api_key + self.timeout = timeout + + def ask(self, prompt: str, system: str = "") -> str: + messages = [] + if system: + messages.append({"role": "system", "content": system}) + messages.append({"role": "user", "content": prompt}) + + headers = {"Content-Type": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + body = {"model": self.model, "messages": messages, "stream": False} + + resp = httpx.post( + f"{self.base_url}/chat/completions", + json=body, + headers=headers, + timeout=self.timeout, + ) + resp.raise_for_status() + data = resp.json() + return data["choices"][0]["message"]["content"] + + +_registry: dict[str, Any] = { + "ollama": OllamaProvider, + "openrouter": OpenRouterProvider, + "local": LocalProvider, +} + + +def create_provider(cfg: ProviderConfig | None = None) -> LLMProvider: + if cfg is None: + cfg = load_from_env() + + cls = _registry.get(cfg.kind) + if not cls: + raise ValueError(f"Provider sconosciuto: {cfg.kind} (disponibili: {list(_registry.keys())})") + + if cfg.kind == "ollama": + return cls(cfg.ollama_base_url, cfg.ollama_model, cfg.timeout) + elif cfg.kind == "openrouter": + key = cfg.openrouter_api_key or os.getenv("OPENROUTER_API_KEY", "") + if not key: + raise ValueError("OPENROUTER_API_KEY non configurata") + return cls(key, cfg.openrouter_model, cfg.timeout, cfg.openrouter_free_fallback) + elif cfg.kind == "local": + return cls(cfg.local_base_url, cfg.local_model, cfg.local_api_key, cfg.timeout) + else: + raise ValueError(f"Provider non implementato: {cfg.kind}") diff --git a/src/engine/state_machine.py b/src/engine/state_machine.py new file mode 100644 index 0000000..81d13c1 --- /dev/null +++ b/src/engine/state_machine.py @@ -0,0 +1,160 @@ +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field +from typing import Any + +from src.engine import db, tools + +# --------------------------------------------------------------------------- +# Stato del gioco (immutabile dentro un turno) +# --------------------------------------------------------------------------- + + +@dataclass +class GameContext: + campaign_id: int + turn: int + campaign: dict | None = None + characters: list[dict] = field(default_factory=list) + log: list[dict] = field(default_factory=list) + quests: list[dict] = field(default_factory=list) + areas: list[dict] = field(default_factory=list) + + @classmethod + def load(cls, campaign_id: int) -> GameContext: + camp = db.get_campaign(campaign_id) + if not camp: + raise ValueError(f"Campagna #{campaign_id} non trovata.") + chars = db.get_characters(campaign_id) + log = db.get_log(campaign_id, limit=10) + quests = db.get_quests(campaign_id) + areas = db.get_area_state(campaign_id) + return cls( + campaign_id=campaign_id, + turn=camp["turn"], + campaign=camp, + characters=chars, + log=log, + quests=quests, + areas=areas, + ) + + def summary(self) -> str: + camp = self.campaign or {"name": "?", "turn": 0} + lines = [f"Campagna: {camp['name']} | Turno {self.turn}"] + for c in self.characters: + cls = c.get("char_class") or "" + lines.append( + f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} " + f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}" + ) + if self.quests: + lines.append("Missioni:") + for q in self.quests: + lines.append(f" [{q['status']}] {q['name']}") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Decisore — sceglie quale tool chiamare in base all'azione testuale +# --------------------------------------------------------------------------- + +# Mappa euristica: parole chiave → tool +def _parse_action(raw_action: str, ctx: GameContext) -> tuple[str, dict]: + a = raw_action.lower().strip() + cd = re.search(r'(?:cd|dc)\s*(\d+)', a) + char_id = ctx.characters[0]["id"] if ctx.characters else 0 + + if any(k in a for k in ["tempra", "riflessi", "volontà", "salvezza"]): + for key in ["tempra", "riflessi", "volontà"]: + if key in a: + return "saving_throw", {"character_id": char_id, "save_type": key.capitalize(), "dc": int(cd.group(1)) if cd else 15} + return "saving_throw", {"character_id": char_id, "save_type": "Tempra", "dc": int(cd.group(1)) if cd else 15} + + if any(k in a for k in ["abilità", "prova", "percepire", "scassinare", "nascondersi"]): + return "ability_check", {"character_id": char_id, "dc": int(cd.group(1)) if cd else 12} + + if any(k in a for k in ["attacco", "attaccare", "colpire", "combattimento", "combat", "mischia"]): + mod = re.search(r'([+-]\s*\d+)', a) + ac = re.search(r'(?:ca|ac)\s*(\d+)', a) + return "combat", { + "character_id": char_id, + "target_ac": int(ac.group(1)) if ac else 12, + "attack_bonus": int(mod.group(1).replace(" ", "")) if mod else 0, + } + + if any(k in a for k in ["muovi", "muovo", "movimento", "esplora", "cammina", "cammino", "corri", "corro"]): + dist = re.search(r'(\d+)\s*m', a) + return "movement", {"distance_m": int(dist.group(1)) if dist else 10} + + if any(k in a for k in ["incantesimo", "magia", "lancio", "spell"]): + return "spellcasting", {"character_id": char_id, "spell_name": a, "spell_check": 10} + + if any(k in a for k in ["inventario", "prendi", "prendo", "lascia", "lascio", "usa", "uso", "raccogli", "raccolgo"]): + return "update_inventory", {"character_id": char_id, "item": raw_action.split()[-1], "action": "add"} + + if any(k in a for k in ["xp", "esperienza", "livello"]): + return "award_xp", {"character_id": char_id, "xp": 5} + + die_match = re.search(r'\bd(?:20|%|3|4|5|6|7|8|10|12|14|16|24|30)\b', a) + if die_match: + return "roll_dice", {"die": die_match.group()} + + return "dm_override", {"command": "narrate", "params": {"text": raw_action}} + + +def process_turn(campaign_id: int, raw_action: str) -> dict: + ctx = GameContext.load(campaign_id) + tool_name, tool_params = _parse_action(raw_action, ctx) + + result = tools.run(tool_name, **tool_params) + + turn = db.advance_turn(campaign_id) + db.log_turn(campaign_id, turn, raw_action, result.get("message", "")) + + return { + "turn": turn, + "action": raw_action, + "tool": tool_name, + "result": result, + "context": ctx.summary(), + } + + +# --------------------------------------------------------------------------- +# CLI test +# --------------------------------------------------------------------------- + +if __name__ == "__main__": + conn = db.init_db() + + camp_id = db.create_campaign("Avventura 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, + armor_type="light" + ) + + actions = [ + "Tiro un d20 per iniziativa", + "Attacco il guardiano con la spada", + "Prova di percezione CD 12", + "Tiro salvezza su Tempra CD 15", + "Mi muovo verso la porta", + "Lancio l'incantesimo Dardo Incantato", + "Prendo la pozione dalla mensola", + "dm forzo la scena", + ] + + for action in actions: + print(f"\n>>> {action}") + out = process_turn(camp_id, action) + print(f" Tool: {out['tool']} | Msg: {out['result'].get('message', '?')[:100]}") + + print(f"\n=== Cronologia ({len(actions)} turni) ===") + ctx = GameContext.load(camp_id) + print(ctx.summary()) diff --git a/src/engine/tools.py b/src/engine/tools.py new file mode 100644 index 0000000..3cda7f3 --- /dev/null +++ b/src/engine/tools.py @@ -0,0 +1,421 @@ +from __future__ import annotations + +import json +import re +from typing import Any + +from src.engine import dice +from src.engine import db as storage + + +ToolResult = dict[str, Any] + +TOOL_REGISTRY: dict[str, type] = {} + +def tool(fn): + TOOL_REGISTRY[fn.__name__] = fn + return fn + + +@tool +def roll_dice(die: str, count: int = 1, modifier: int = 0) -> ToolResult: + try: + r = dice.roll(die, count, modifier) + return { + "success": True, + "tool": "roll-dice", + "die": r["die"], + "count": r["count"], + "modifier": r["modifier"], + "results": r["results"], + "total": r["total"], + "crit": r["crit"], + "fumble": r["fumble"], + "message": f"Tiro: {r['raw']} → {r['results']} (totale: {r['total']})", + } + except ValueError as e: + return {"success": False, "tool": "roll-dice", "message": str(e)} + + +@tool +def dice_chain(die: str, steps: int) -> ToolResult: + res = dice.dice_chain(die, steps) + if "error" in res: + return {"success": False, "tool": "dice-chain", "message": res["error"]} + return { + "success": True, + "tool": "dice-chain", + "input_die": res["input_die"], + "steps": res["steps"], + "output_die": res["output_die"], + "message": f"Catena dadi: {die} {'+' if steps >= 0 else ''}{steps} → {res['output_die']}", + } + + +@tool +def combat( + character_id: int, + target_ac: int, + attack_bonus: int = 0, + damage_die: str = "d8", + damage_bonus: int = 0, + mighty_deed_die: str = "", + luck_mod: int = 0, + armor_type: str = "none", +) -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "combat", "message": "Personaggio non trovato."} + + init_roll = dice.roll("d20") + atk_roll = dice.roll("d20", modifier=attack_bonus) + raw_total = atk_roll["total"] + + + deed_roll = None + if mighty_deed_die: + deed_roll = dice.roll(mighty_deed_die) + raw_total += deed_roll["total"] + + hit = raw_total >= target_ac + result_parts = [] + crit_res = None + + if atk_roll["fumble"]: + fumble_die_name = storage.get_fumble_die(armor_type) or "d4" + fumble_roll = dice.roll(fumble_die_name) + fumble_res = storage.lookup_crit_fumble("fumble", fumble_roll["total"], luck_mod) + crit_res = fumble_res + result_parts.append( + f"Fallimento critico! Tiro fumble (d{armor_type}): {fumble_roll['total']} → " + + (fumble_res["result"] if fumble_res else "Nessun risultato trovato.") + ) + + if atk_roll["crit"]: + crit_table_id = _crit_table_for(char) + crit_die_name = _crit_die_for(char) + crit_roll = dice.roll(crit_die_name) + crit_res = storage.lookup_crit_fumble(crit_table_id, crit_roll["total"], luck_mod) + result_parts.append( + f"Colpo critico! Tiro crit ({crit_die_name}): {crit_roll['total']} → " + + (crit_res["result"] if crit_res else "Nessun risultato trovato.") + ) + + if hit and not atk_roll["fumble"]: + dmg = dice.roll(damage_die, modifier=damage_bonus) + crit_dmg_bonus = 0 + if atk_roll["crit"] and crit_res: + crit_dmg_bonus = _parse_extra_damage(crit_res.get("result", "")) + total_dmg = dmg["total"] + crit_dmg_bonus + result_parts.append(f"Colpito! Danni: {dmg['results']} + {crit_dmg_bonus} extra = {total_dmg}") + elif not atk_roll["fumble"]: + result_parts.append("Mancato!") + + deed_msg = f" Deed: {deed_roll['total']}" if deed_roll else "" + + return { + "success": True, + "tool": "combat", + "initiative": init_roll["total"], + "attack_raw": atk_roll["results"][0], + "attack_bonus": attack_bonus, + "attack_total": raw_total, + "target_ac": target_ac, + "hit": hit, + "crit": atk_roll["crit"], + "fumble": atk_roll["fumble"], + "deed": deed_roll["total"] if deed_roll else None, + "message": f"Iniziativa: {init_roll['total']} | Attacco d20+{attack_bonus}: {atk_roll['results'][0]}+{attack_bonus}={atk_roll['total']} vs CA {target_ac} | {'COLPI TO!' if hit else 'Mancato.'}{deed_msg} | " + " | ".join(result_parts), + } + + +def _crit_table_for(char: dict) -> str: + cls = (char.get("char_class") or "").lower() + lv = char.get("level", 0) + if cls in ("guerriero",): + return "crit_V" if lv >= 5 else "crit_IV" if lv >= 3 else "crit_III" + if cls in ("nano",): + return "crit_V" if lv >= 6 else "crit_IV" if lv >= 4 else "crit_III" + if cls in ("ladro", "elfo"): + return "crit_II" + if cls in ("chierico", "halfling"): + return "crit_III" + if cls in ("mago",) or lv == 0: + return "crit_I" + return "crit_I" + + +def _crit_die_for(char: dict) -> str: + cls = (char.get("char_class") or "").lower() + lv = char.get("level", 0) + if cls in ("guerriero",): + return "d12" if lv >= 5 else "d10" if lv >= 3 else "d8" + if cls in ("nano",): + return "d12" if lv >= 6 else "d10" if lv >= 4 else "d8" + if cls in ("ladro", "elfo"): + return "d6" + if cls in ("chierico", "halfling"): + return "d8" + if cls in ("mago",) or lv == 0: + return "d4" + return "d4" + + +def _parse_extra_damage(result_text: str) -> int: + matches = re.findall(r'(\+?)(\d+)d\d+', result_text) + total = 0 + for sign, num in matches: + if sign == "+": + total += int(num) + else: + total += int(num) + return total + + +@tool +def ability_check(character_id: int, dc: int, modifier: int = 0) -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "ability-check", "message": "Personaggio non trovato."} + atk = dice.roll("d20", modifier=modifier) + passed = atk["total"] >= dc + return { + "success": True, + "tool": "ability-check", + "roll": atk["results"][0], + "modifier": modifier, + "total": atk["total"], + "dc": dc, + "passed": passed, + "crit": atk["crit"], + "fumble": atk["fumble"], + "message": f"Prova abilità: d20+{modifier} → {atk['results'][0]}+{modifier}={atk['total']} vs CD {dc} → {'Successo!' if passed else 'Fallimento.'}", + } + + +@tool +def saving_throw(character_id: int, save_type: str, dc: int, modifier: int = 0) -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "saving-throw", "message": "Personaggio non trovato."} + valid = ("Tempra", "Riflessi", "Volontà") + if save_type not in valid: + return {"success": False, "tool": "saving-throw", "message": f"Tipo non valido: {save_type}. Usa: {', '.join(valid)}"} + atk = dice.roll("d20", modifier=modifier) + passed = atk["total"] >= dc + return { + "success": True, + "tool": "saving-throw", + "save_type": save_type, + "roll": atk["results"][0], + "modifier": modifier, + "total": atk["total"], + "dc": dc, + "passed": passed, + "crit": atk["crit"], + "fumble": atk["fumble"], + "message": f"Tiro Salvezza su {save_type}: d20+{modifier} → {atk['results'][0]}+{modifier}={atk['total']} vs CD {dc} → {'Superato!' if passed else 'Fallito.'}", + } + + +@tool +def movement(distance_m: int, terrain: str = "normale", stealth: bool = False) -> ToolResult: + speeds = {"normale": 12, "difficile": 6, "molto difficile": 3} + speed = speeds.get(terrain, 12) + rounds = max(1, -(-distance_m // speed)) + stealth_msg = " (in furtivo)" if stealth else "" + return { + "success": True, + "tool": "movement", + "distance_m": distance_m, + "terrain": terrain, + "speed_m_per_round": speed, + "rounds": rounds, + "stealth": stealth, + "message": f"Movimento: {distance_m}m su terreno {terrain}{stealth_msg} → {rounds} round ({speed}m/round).", + } + + +@tool +def spellcasting(character_id: int, spell_name: str, spell_check: int, dc: int = 12) -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "spellcasting", "message": "Personaggio non trovato."} + result_tier = "fallimento" + outcome = "" + if spell_check == 1: + outcome = "Fallimento critico! L'incantesimo si ritorce." + elif spell_check < dc: + outcome = f"L'incantesimo fallisce ({spell_check} < CD {dc})." + elif spell_check <= 11: + result_tier = "minore" + outcome = f"Successo minore ({spell_check}). Effetto base." + elif spell_check <= 17: + result_tier = "moderato" + outcome = f"Successo moderato ({spell_check}). Effetto migliorato." + elif spell_check <= 19: + result_tier = "maggiore" + outcome = f"Successo maggiore ({spell_check}). Effetto potente." + else: + result_tier = "massimo" + outcome = f"Successo massimo! ({spell_check}). Effetto devastante." + corruption_check = spell_check == 1 or (spell_check >= 12 and dice.roll("d20")["total"] <= 1) + return { + "success": True, + "tool": "spellcasting", + "spell": spell_name, + "spell_check": spell_check, + "dc": dc, + "result_tier": result_tier, + "corruption": corruption_check, + "message": f"Incantesimo: {spell_name} → Tiro {spell_check} vs CD {dc} → {outcome}" + (" (Corruzione!)" if corruption_check else ""), + } + + +@tool +def update_inventory(character_id: int, item: str, action: str = "add") -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "update-inventory", "message": "Personaggio non trovato."} + inv = json.loads(char.get("equipment", "[]")) + if action == "add": + inv.append(item) + elif action == "remove": + inv = [i for i in inv if i != item] + else: + return {"success": False, "tool": "update-inventory", "message": f"Azione '{action}' non valida. Usa 'add' o 'remove'."} + storage.update_character(character_id, equipment=json.dumps(inv)) + return { + "success": True, + "tool": "update-inventory", + "action": action, + "item": item, + "inventory": inv, + "message": f"Inventario: {action} '{item}' → {len(inv)} oggetti.", + } + + +@tool +def award_xp(character_id: int, xp: int) -> ToolResult: + char = storage.get_character(character_id) + if not char: + return {"success": False, "tool": "award-xp", "message": "Personaggio non trovato."} + new_xp = (char.get("xp", 0) or 0) + xp + storage.update_character(character_id, xp=new_xp) + messages = [f"{char['name']} guadagna {xp} PE (totale: {new_xp})."] + leveled = False + if new_xp >= 10 and char.get("level", 0) == 0: + storage.update_character(character_id, level=1) + messages.append(f"{char['name']} sale a livello 1!") + leveled = True + return { + "success": True, + "tool": "award-xp", + "xp_gained": xp, + "xp_total": new_xp, + "leveled_up": leveled, + "message": " ".join(messages), + } + + +@tool +def dm_override(command: str, params: dict | None = None) -> ToolResult: + manual = { + "set_hp": "Imposta PF: {character_id, value}", + "force_roll": "Forza risultato dado: {die, forced_value}", + "skip_combat": "Salta un combattimento: {area_id}", + "add_item": "Aggiunge oggetto: {character_id, item}", + "narrate": "Testo narrativo forzato: {text}", + } + if command == "help": + return { + "success": True, + "tool": "dm-override", + "commands": manual, + "message": "Comandi DM disponibili: " + ", ".join(manual.keys()), + } + return { + "success": True, + "tool": "dm-override", + "command": command, + "params": params or {}, + "message": f"Override DM: {command} applicato.", + } + + +def run(tool_name: str, **kwargs) -> ToolResult: + if tool_name not in TOOL_REGISTRY: + return { + "success": False, + "tool": tool_name, + "message": f"Tool '{tool_name}' sconosciuto. Disponibili: {', '.join(sorted(TOOL_REGISTRY))}", + } + try: + return TOOL_REGISTRY[tool_name](**kwargs) + except Exception as e: + return {"success": False, "tool": tool_name, "message": f"Errore: {e}"} + + + + +if __name__ == "__main__": + print("=== Tool Registry ===") + for name in sorted(TOOL_REGISTRY): + print(f" - {name}") + + print("\n=== Test roll-dice ===") + r = run("roll_dice", die="d20") + print(f" {r['message']}") + + print("\n=== Test dice-chain ===") + r = run("dice_chain", die="d3", steps=2) + print(f" {r['message']}") + + print("\n=== Test unknown tool ===") + r = run("non_existent") + print(f" {r['message']}") + + print("\n=== Init DB and setup ===") + conn = storage.init_db() + camp_id = storage.create_campaign("Test", "Avventura di Prova") + char_id = storage.create_character(camp_id, "Bob il Contadino", "guerriero", "contadino") + storage.update_character(char_id, + level=3, str=14, agi=12, sta=13, + per=10, int=8, lck=9, + hp_current=18, hp_max=18, + ac=16, armor_type="medium" + ) + print(f" Creata campagna #{camp_id} con personaggio #{char_id}") + + print("\n=== Test combat ===") + r = run("combat", character_id=char_id, target_ac=15, attack_bonus=2, + damage_die="d8", mighty_deed_die="d3", luck_mod=0, armor_type="medium") + print(f" {r['message']}") + + print("\n=== Test ability-check ===") + r = run("ability_check", character_id=char_id, dc=15, modifier=2) + print(f" {r['message']}") + + print("\n=== Test saving-throw ===") + r = run("saving_throw", character_id=char_id, save_type="Tempra", dc=15, modifier=1) + print(f" {r['message']}") + + print("\n=== Test movement ===") + r = run("movement", distance_m=30, terrain="difficile") + print(f" {r['message']}") + + print("\n=== Test spellcasting ===") + r = run("spellcasting", character_id=char_id, spell_name="Dardo Incantato", spell_check=18, dc=12) + print(f" {r['message']}") + + print("\n=== Test update-inventory ===") + r = run("update_inventory", character_id=char_id, item="Pozione di Cura") + print(f" {r['message']}") + + print("\n=== Test award-xp ===") + r = run("award_xp", character_id=char_id, xp=5) + print(f" {r['message']}") + + print("\n=== Test dm-override ===") + r = run("dm_override", command="narrate", params={"text": "Una luce accecante riempie la stanza."}) + print(f" {r['message']}") diff --git a/src/web/__init__.py b/src/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/web/server.py b/src/web/server.py new file mode 100644 index 0000000..4332365 --- /dev/null +++ b/src/web/server.py @@ -0,0 +1,227 @@ +from __future__ import annotations + +import json +import logging +import os +from pathlib import Path + +from fastapi import FastAPI, WebSocket, WebSocketDisconnect +from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.staticfiles import StaticFiles + +from src.engine import db +from src.engine.config import load_from_env +from src.engine.dm import dm_cycle +from src.engine.state_machine import GameContext + +log = logging.getLogger(__name__) + +STATIC_DIR = Path(__file__).resolve().parent / "static" + +app = FastAPI(title="DCC RPG — AI Dungeon Master") + + +# --------------------------------------------------------------------------- +# REST endpoints +# --------------------------------------------------------------------------- + + +@app.get("/api/campaigns") +def list_campaigns(): + conn = db.get_conn() + rows = conn.execute( + "SELECT id, name, adventure, turn, created_at FROM campaigns ORDER BY id DESC" + ).fetchall() + return [dict(r) for r in rows] + + +@app.get("/api/campaign/{campaign_id}") +def get_campaign_state(campaign_id: int): + try: + ctx = GameContext.load(campaign_id) + except ValueError: + return JSONResponse({"error": "Campagna non trovata"}, status_code=404) + + # Full area details from adventure data + all_areas = db.get_all_areas() + area_map = {a["id"]: a for a in all_areas} + visited_ids = {a["area_id"] for a in ctx.areas if a.get("visited")} + + enriched = [] + for sa in ctx.areas: + aid = sa["area_id"] + full = area_map.get(aid, {}) + enriched.append({ + "id": aid, + "name": full.get("name", aid), + "description": full.get("description", "")[:200], + "connections": full.get("connections", []), + "visited": bool(sa.get("visited")), + "features": full.get("features", [])[:3], + }) + + return { + "campaign": dict(ctx.campaign) if ctx.campaign else {}, + "characters": ctx.characters, + "quests": ctx.quests, + "areas": enriched, + "turn": ctx.turn, + } + + +@app.post("/api/campaign") +def create_campaign(name: str = "Nuova Avventura", adventure: str = ""): + camp_id = db.create_campaign(name, adventure) + db.seed_adventure_state(camp_id) + db.seed_quests_from_adventure(camp_id) + # Mark first area as visited + areas = db.get_all_areas() + if areas: + db.set_area_state(camp_id, areas[0]["id"], visited=True) + return {"campaign_id": camp_id} + + +@app.post("/api/campaign/{campaign_id}/character") +def add_character( + campaign_id: int, + name: str = "Eroe", + 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} + + +# --------------------------------------------------------------------------- +# WebSocket — real-time game loop +# --------------------------------------------------------------------------- + + +@app.websocket("/ws/{campaign_id}") +async def game_ws(ws: WebSocket, campaign_id: int): + await ws.accept() + + try: + ctx = GameContext.load(campaign_id) + except ValueError: + await ws.send_json({"type": "error", "message": "Campagna non trovata"}) + await ws.close() + return + + # Send initial state + await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)}) + + while True: + raw = await ws.receive_text() + try: + msg = json.loads(raw) + except json.JSONDecodeError: + msg = {"type": "action", "payload": raw} + + if msg.get("type") == "action": + player_action = msg.get("payload", "").strip() + if not player_action: + continue + + try: + out = dm_cycle(campaign_id, player_action) + await ws.send_json({ + "type": "response", + "turn": out["turn"], + "tool": out["tool"], + "params": out["params"], + "narration": out["narration"], + "mechanics": out["mechanics"], + "full_output": out["full_output"], + }) + except Exception as e: + log.exception("DM cycle failed") + await ws.send_json({"type": "error", "message": str(e)}) + + # Send updated state after action + await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)}) + + elif msg.get("type") == "ping": + await ws.send_json({"type": "pong"}) + + +def _build_state(campaign_id: int) -> dict: + ctx = GameContext.load(campaign_id) + all_areas = db.get_all_areas() + area_map = {a["id"]: a for a in all_areas} + enriched = [] + for sa in ctx.areas: + aid = sa["area_id"] + full = area_map.get(aid, {}) + enriched.append({ + "id": aid, + "name": full.get("name", aid), + "visited": bool(sa.get("visited")), + "connections": full.get("connections", []), + }) + return { + "turn": ctx.turn, + "characters": ctx.characters, + "quests": ctx.quests, + "areas": enriched, + } + + +# --------------------------------------------------------------------------- +# Serve frontend +# --------------------------------------------------------------------------- + + +@app.get("/health") +def health(): + return {"status": "ok", "provider": load_from_env().kind} + + +@app.get("/") +async def index(): + html_path = STATIC_DIR / "index.html" + if html_path.exists(): + content = html_path.read_text(encoding="utf-8") + return HTMLResponse(content) + return HTMLResponse("

Frontend non trovato

", status_code=404) + + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- + + +def main(): + logging.basicConfig( + level=logging.INFO, + format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", + ) + db.init_db() + cfg = load_from_env() + log.info( + "DM config: provider=%s model=%s", + cfg.kind, + getattr(cfg, f"{cfg.kind}_model", "?"), + ) + + import uvicorn + + uvicorn.run(app, host="0.0.0.0", port=8300, log_level="info") + + +if __name__ == "__main__": + main() diff --git a/src/web/static/index.html b/src/web/static/index.html new file mode 100644 index 0000000..6101467 --- /dev/null +++ b/src/web/static/index.html @@ -0,0 +1,888 @@ + + + + + +DCC RPG — AI Dungeon Master + + + +
+ +
+

DCC RPG

+

AI Dungeon Master — La Tomba del Mago Guerriero

+
+
+ + +
+
+ + + + + +
+ +
+
Benvenuto, avventuriero. Scrivi la tua azione per iniziare.
+
+
+ + +
+
+
+ +
Caricamento...
+ + + +