fix: JSON repair, WS thread pool, switch to DeepSeek V4 Flash, funnel Lv0
LLM fixes: - _try_repair_json() — ripara JSON malformato (apici, virgole trailing) - _strip_json_from_text() — rimuove residui JSON dalla narration - parse_tool_call() — rimuove troncamento 200 caratteri dal fallback DM fixes: - Fallback euristico quando params vuoti (dm.py) - Estrazione narrazione da dm_override/narrate params.params.text Prompt (prompts.py): - Vietati termini tecnici/scientifici/anacronistici (HPLC, laser, ...) - Aggiunto vocabolario fantasy obbligatorio Server (server.py): - dm_cycle in ThreadPoolExecutor per non bloccare WS ping - WebSocketDisconnect gestiti - Rimosso import inesistente db_schema Frontend (index.html): - Nascosti tool name dalla chat - Personaggio -> PG, connessioni aree con nomi Infrastruttura: - Switch da openrouter/free a DeepSeek V4 Flash via OpenCode Go - pyproject.toml, Dockerfile, docker-compose.yml, start.sh - .env auto-load (start.sh) - Funnel Lv0 (funnel.py) — creazione PG livello 0 Docs: architecture, database, setup, game mechanics, codebase ref
This commit is contained in:
1 parent
6b048dc0cc
commit
eacc946868
23 files changed
+1786
-61
No files matched your search
@@ -15,10 +15,10 @@ class ProviderConfig:
|
||||
openrouter_model: str = "openrouter/free"
|
||||
openrouter_key_env: str = "DCC_OPENROUTER_KEY"
|
||||
openrouter_free_fallback: bool = True
|
||||
local_base_url: str = "http://192.168.0.240/v1"
|
||||
local_base_url: str = "https://opencode.ai/zen/go/v1"
|
||||
local_api_key: str = ""
|
||||
local_model: str = "big-pickle"
|
||||
timeout: int = 60
|
||||
local_model: str = "deepseek-v4-flash"
|
||||
timeout: int = 120
|
||||
|
||||
|
||||
def load_from_env() -> ProviderConfig:
|
||||
|
||||
@@ -31,6 +31,21 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
|
||||
log.info("LLM non ha prodotto tool valido, fallback heuristico")
|
||||
tool, params = fallback_parse(player_action, ctx.characters)
|
||||
|
||||
# Se il JSON era malformato e params sono vuoti, usa l'euristico per i parametri
|
||||
elif not params and tool not in ("dm_override",):
|
||||
log.info("Tool %s senza params, integro con fallback euristico", tool)
|
||||
_, fallback_params = fallback_parse(player_action, ctx.characters)
|
||||
params = fallback_params
|
||||
|
||||
# dm_override/narrate: il LLM spesso mette il testo in params.params.text
|
||||
# invece che nel campo narration JSON — lo estraiamo qui
|
||||
if tool == "dm_override" and not narration:
|
||||
inner = params.get("params", {}) if isinstance(params, dict) else {}
|
||||
if isinstance(inner, dict):
|
||||
text = inner.get("text", "")
|
||||
if text:
|
||||
narration = text
|
||||
|
||||
return tool, params, narration
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
"""
|
||||
Funnel DCC — Generazione personaggi livello 0.
|
||||
|
||||
Ogni giocatore crea 3-4 PG lv0 da portare nel funnel.
|
||||
I sopravvissuti salgono a lv1 e scelgono la classe.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import random
|
||||
from typing import Any
|
||||
|
||||
# Tabella 1-3: Mestieri (d100)
|
||||
# (tiro_min, tiro_max, nome, arma, bene)
|
||||
OCCUPATIONS: list[tuple[int, int, str, str, str]] = [
|
||||
(1, 1, "Alchimista", "Bastone", "Olio, 1 fiaschetta"),
|
||||
(2, 2, "Addestratore di animali", "Mazza", "Pony"),
|
||||
(3, 3, "Armaiolo", "Mazza (martello)", "Elmo di ferro"),
|
||||
(4, 4, "Astrologo", "Pugnale", "Cannocchiale"),
|
||||
(5, 5, "Barbiere", "Pugnale (rasoio)", "Forbici"),
|
||||
(6, 6, "Sagrestano", "Bastone", "Simbolo sacro"),
|
||||
(7, 7, "Apicoltore", "Bastone", "Vasetto di miele"),
|
||||
(8, 8, "Fabbro", "Mazza (martello)", "Tenaglie d'acciaio"),
|
||||
(9, 9, "Macellaio", "Ascia (mannaia)", "Mezzena di manzo"),
|
||||
(10, 10, "Guardia di carovana", "Spada corta", "Lino, 1 iarda"),
|
||||
(11, 11, "Casaro", "Bastone (randello)", "Formaggio puzzolente"),
|
||||
(12, 12, "Calzolaio", "Pugnale (lesina)", "Calzascarpe"),
|
||||
(13, 13, "Truffatore", "Pugnale", "Mantello di qualità"),
|
||||
(14, 14, "Bottaio", "Mazza (piede di porco)", "Botte"),
|
||||
(15, 15, "Fruttivendolo ambulante", "Pugnale (coltello)", "Frutta"),
|
||||
(16, 16, "Tagliaborse", "Pugnale", "Piccolo forziere"),
|
||||
(17, 17, "Sterratore", "Bastone (pala)", "Terra fine, 1 libbra"),
|
||||
(18, 18, "Lavoratore portuale", "Bastone (palo)", "1 libro GdR in ritardo"),
|
||||
(19, 19, "Speziale nano", "Bastone (randello)", "Fiala d'acciaio"),
|
||||
(20, 20, "Fabbro nano", "Mazza (martello)", "Mithril, 1 oncia"),
|
||||
(21, 21, "Cassettaio nano", "Pugnale (scalpello)", "Legno, 10 libbre"),
|
||||
(22, 22, "Mandriano nano", "Bastone", "Scrofa"),
|
||||
(23, 24, "Minatore nano", "Mazza (piccone)", "Lanterna"),
|
||||
(25, 25, "Coltivatore di funghi nano", "Bastone (pala)", "Sacco"),
|
||||
(26, 26, "Acchiappatopi nano", "Mazza", "Rete"),
|
||||
(27, 28, "Scalpellino nano", "Martello", "Pietra pregiata, 10 libbre"),
|
||||
(29, 29, "Artigiano elfico", "Bastone", "Argilla, 1 libbra"),
|
||||
(30, 30, "Avvocato elfico", "Dardo (penna d'oca)", "Libro"),
|
||||
(31, 31, "Candelaio elfico", "Pugnale (forbici)", "Candele, 20"),
|
||||
(32, 32, "Falconiere elfico", "Pugnale", "Falco"),
|
||||
(33, 34, "Forestale elfico", "Bastone", "Erbe, 1 libbra"),
|
||||
(35, 35, "Vetraio elfico", "Mazza (martello)", "Perline di vetro"),
|
||||
(36, 36, "Navigatore elfico", "Arco corto", "Cannocchiale"),
|
||||
(37, 38, "Saggio elfico", "Pugnale", "Pergamena e penna d'oca"),
|
||||
(39, 47, "Contadino", "Lancia (forcone)", "Gallina"),
|
||||
(48, 48, "Indovino", "Pugnale", "Mazzo di tarocchi"),
|
||||
(49, 49, "Giocatore d'azzardo", "Mazza", "Dadi"),
|
||||
(50, 50, "Spurgatore di latrine", "Pugnale (cazzuola)", "Sacco di letame notturno"),
|
||||
(51, 52, "Becchino", "Bastone (pala)", "Cazzuola"),
|
||||
(53, 54, "Mendicante di gilda", "Fionda", "Stampelle"),
|
||||
(55, 55, "Macellaio di polli halfling", "Ascia da una mano", "Carne di pollo, 5 libbre"),
|
||||
(56, 57, "Tintore halfling", "Bastone", "Tessuto, 3 iarde"),
|
||||
(58, 58, "Guantaio halfling", "Pugnale (lesina)", "Guanti, 4 paia"),
|
||||
(59, 59, "Errante halfling", "Fionda", "Bambola maledetta"),
|
||||
(60, 60, "Merciaio halfling", "Pugnale (forbici)", "Vestiti pregiati, 3 completi"),
|
||||
(61, 61, "Marinaio halfling", "Pugnale (coltello)", "Tela da vele, 2 iarde"),
|
||||
(62, 62, "Usuraio halfling", "Spada corta", "5 mo, 10 ma, 200 mr"),
|
||||
(63, 63, "Mercante halfling", "Spada corta", "20 ma"),
|
||||
(64, 64, "Vagabondo halfling", "Mazza", "Ciotola dell'elemosina"),
|
||||
(65, 65, "Guaritore", "Mazza", "Acqua santa, 1 fiala"),
|
||||
(66, 66, "Erborista", "Mazza", "Erbe, 1 libbra"),
|
||||
(67, 67, "Mandriano", "Bastone", "Cane da pastore"),
|
||||
(68, 69, "Cacciatore", "Arco corto", "Pelle di cervo"),
|
||||
(70, 70, "Servo a contratto", "Bastone", "Medaglione"),
|
||||
(71, 71, "Buffone", "Dardo", "Vestiti di seta"),
|
||||
(72, 72, "Gioielliere", "Pugnale", "Gemma da 20 mo"),
|
||||
(73, 73, "Fabbro di serrature", "Pugnale", "Attrezzi di qualità"),
|
||||
(74, 74, "Frate mendicante", "Mazza", "Salsa di formaggio"),
|
||||
(75, 75, "Mercenario", "Spada lunga", "Armatura di cuoio"),
|
||||
(76, 76, "Mercante", "Pugnale", "4 mo, 14 ma, 27 mr"),
|
||||
(77, 77, "Mugnaio/fornaio", "Mazza", "Farina, 1 libbra"),
|
||||
(78, 78, "Menestrello", "Pugnale", "Ukulele"),
|
||||
(79, 79, "Nobile", "Spada lunga", "Anello d'oro da 10 mo"),
|
||||
(80, 80, "Orfano", "Mazza", "Bambola di pezza"),
|
||||
(81, 81, "Stalliere", "Bastone", "Briglia"),
|
||||
(82, 82, "Fuorilegge", "Spada corta", "Armatura di cuoio leggera"),
|
||||
(83, 83, "Cordaio", "Pugnale (coltello)", "Corda, 100 piedi"),
|
||||
(84, 84, "Scriba", "Dardo", "Pergamena, 10 fogli"),
|
||||
(85, 85, "Sciamano", "Mazza ferrata", "Erbe, 1 libbra"),
|
||||
(86, 86, "Schiavo", "Mazza", "Roccia dall'aspetto strano"),
|
||||
(87, 87, "Contrabbandiere", "Fionda", "Sacco impermeabile"),
|
||||
(88, 88, "Soldato", "Lancia", "Scudo"),
|
||||
(89, 90, "Scudiero", "Spada lunga", "Elmo d'acciaio"),
|
||||
(91, 91, "Esattore delle tasse", "Spada lunga", "100 mr"),
|
||||
(92, 93, "Cacciatore di pelli", "Fionda", "Pelle di tasso"),
|
||||
(94, 94, "Monello", "Mazza (bastoncino)", "Ciotola dell'elemosina"),
|
||||
(95, 95, "Carraio", "Mazza", "Carriola"),
|
||||
(96, 96, "Tessitore", "Pugnale", "Vestito pregiato"),
|
||||
(97, 97, "Apprendista mago", "Pugnale", "Grimorio nero"),
|
||||
(98, 100, "Boscaiolo", "Ascia da una mano", "Fascina di legna"),
|
||||
]
|
||||
|
||||
# Mappa arma → dado danno per livello 0
|
||||
WEAPON_DAMAGE: dict[str, str] = {
|
||||
"Ascia da una mano": "d6",
|
||||
"Ascia (mannaia)": "d6",
|
||||
"Arco corto": "d6",
|
||||
"Bastone": "d4",
|
||||
"Bastone (pala)": "d4",
|
||||
"Bastone (palo)": "d4",
|
||||
"Bastone (randello)": "d4",
|
||||
"Dardo": "d4",
|
||||
"Dardo (penna d'oca)": "d4",
|
||||
"Fionda": "d4",
|
||||
"Lancia": "d6",
|
||||
"Lancia (forcone)": "d6",
|
||||
"Mazza": "d4",
|
||||
"Mazza (martello)": "d4",
|
||||
"Mazza (piede di porco)": "d4",
|
||||
"Mazza (piccone)": "d4",
|
||||
"Mazza (bastoncino)": "d4",
|
||||
"Mazza ferrata": "d4",
|
||||
"Martello": "d4",
|
||||
"Pugnale": "d4",
|
||||
"Pugnale (rasoio)": "d4",
|
||||
"Pugnale (lesina)": "d4",
|
||||
"Pugnale (coltello)": "d4",
|
||||
"Pugnale (forbici)": "d4",
|
||||
"Pugnale (cazzuola)": "d4",
|
||||
"Pugnale (scalpello)": "d4",
|
||||
"Spada corta": "d6",
|
||||
"Spada lunga": "d8",
|
||||
}
|
||||
|
||||
NOMI_MASCHILI = [
|
||||
"Aldo", "Baldo", "Crispo", "Druso", "Elmo",
|
||||
"Ferro", "Gallo", "Ivo", "Lupo", "Marco",
|
||||
"Orso", "Pio", "Rufo", "Sergio", "Tito",
|
||||
"Ugo", "Vito", "Zeno", "Bruno", "Claudio",
|
||||
]
|
||||
|
||||
NOMI_FEMMINILI = [
|
||||
"Ada", "Berta", "Clara", "Dora", "Elsa",
|
||||
"Frida", "Greta", "Ida", "Leda", "Mara",
|
||||
"Nora", "Olga", "Pia", "Rosa", "Sara",
|
||||
"Tina", "Una", "Vera", "Zita", "Lena",
|
||||
]
|
||||
|
||||
# Pool di nomi nobili/guerrieri per pg che sopravvivono al funnel
|
||||
NOMI_EROICI = [
|
||||
"Lancelet", "Gawain", "Aldric", "Thorne", "Rowan",
|
||||
"Bran", "Finn", "Kael", "Dorn", "Soren",
|
||||
]
|
||||
|
||||
|
||||
def tiro_3d6() -> int:
|
||||
return sum(random.randint(1, 6) for _ in range(3))
|
||||
|
||||
|
||||
def tiro_statistiche() -> dict[str, int]:
|
||||
return {s: tiro_3d6() for s in ("str", "agi", "sta", "per", "int", "lck")}
|
||||
|
||||
|
||||
def cerca_mestiere(tiro: int) -> tuple[int, int, str, str, str]:
|
||||
for entry in OCCUPATIONS:
|
||||
if entry[0] <= tiro <= entry[1]:
|
||||
return entry
|
||||
return OCCUPATIONS[-1] # fallback
|
||||
|
||||
|
||||
def genera_pg_lv0(nome: str = "") -> dict[str, Any]:
|
||||
"""
|
||||
Genera un personaggio livello 0 completo per il funnel DCC.
|
||||
|
||||
Returns:
|
||||
dict con: nome, mestiere, statistiche, arma, dado_danno, bene, pf_max, pf
|
||||
"""
|
||||
tiro_mestiere = random.randint(1, 100)
|
||||
_, _, mestiere, arma, bene = cerca_mestiere(tiro_mestiere)
|
||||
|
||||
if not nome:
|
||||
nome = f"{random.choice(NOMI_MASCHILI + NOMI_FEMMINILI)} il {mestiere}"
|
||||
|
||||
stats = tiro_statistiche()
|
||||
dado_danno = WEAPON_DAMAGE.get(arma, "d4")
|
||||
|
||||
# Lv0 PF = d4 (o come da descrizione mestiere)
|
||||
pf_max = random.randint(1, 4)
|
||||
# Un po' di fortuna iniziale = 3d6 come le altre stats
|
||||
lck_mod = _modificatore_fortuna(stats["lck"])
|
||||
|
||||
return {
|
||||
"name": nome,
|
||||
"occupation": mestiere,
|
||||
"level": 0,
|
||||
"char_class": "",
|
||||
"weapon": arma,
|
||||
"weapon_damage": dado_danno,
|
||||
"belonging": bene,
|
||||
"str": stats["str"],
|
||||
"agi": stats["agi"],
|
||||
"sta": stats["sta"],
|
||||
"per": stats["per"],
|
||||
"int": stats["int"],
|
||||
"lck": stats["lck"],
|
||||
"luck_mod": lck_mod,
|
||||
"hp_current": pf_max,
|
||||
"hp_max": pf_max,
|
||||
"ac": 10,
|
||||
"armor_type": "none",
|
||||
}
|
||||
|
||||
|
||||
def genera_funnel(num_pg: int = 4) -> list[dict[str, Any]]:
|
||||
"""Genera N personaggi livello 0 per il funnel."""
|
||||
return [genera_pg_lv0() for _ in range(num_pg)]
|
||||
|
||||
|
||||
def _modificatore_fortuna(lck: int) -> int:
|
||||
"""Tabella 1-2: Modificatore Fortuna."""
|
||||
if lck == 3:
|
||||
return -3
|
||||
elif lck <= 5:
|
||||
return -2
|
||||
elif lck <= 8:
|
||||
return -1
|
||||
elif lck <= 12:
|
||||
return 0
|
||||
elif lck <= 15:
|
||||
return 1
|
||||
elif lck <= 17:
|
||||
return 2
|
||||
else: # 18
|
||||
return 3
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("=== FUNNEL DCC — 4 Personaggi Livello 0 ===\n")
|
||||
for i, pg in enumerate(genera_funnel(4), 1):
|
||||
print(f" {i}. {pg['name']}")
|
||||
print(f" Mestiere: {pg['occupation']}")
|
||||
print(f" Arma: {pg['weapon']} ({pg['weapon_damage']})")
|
||||
print(f" Bene: {pg['belonging']}")
|
||||
print(f" PF: {pg['hp_current']}")
|
||||
print(f" For:{pg['str']} Agi:{pg['agi']} Sta:{pg['sta']} "
|
||||
f"Per:{pg['per']} Int:{pg['int']} Luc:{pg['lck']}")
|
||||
print(f" CA: {pg['ac']}\n")
|
||||
@@ -45,9 +45,11 @@ class GameContext:
|
||||
if self.current_area_id:
|
||||
lines.append(f"Area corrente: {self.current_area_name or self.current_area_id}")
|
||||
for c in self.characters:
|
||||
cls = c.get("char_class") or ""
|
||||
lv = c.get("level", 0)
|
||||
cls = c.get("occupation") or c.get("char_class") or ""
|
||||
label = f"{cls} (Lv{lv})" if lv == 0 else f"{c.get('char_class', '')} Lv{lv}"
|
||||
lines.append(
|
||||
f" [{c['id']}] {c['name']} — Lv{c['level']} {cls} "
|
||||
f" [{c['id']}] {c['name']} — {label} "
|
||||
f"PF {c['hp_current']}/{c['hp_max']} PE {c.get('xp', 0)}"
|
||||
)
|
||||
if self.quests:
|
||||
|
||||
+49
-4
@@ -27,6 +27,30 @@ def ask(prompt: str, system: str = "") -> str:
|
||||
return get_provider().ask(prompt, system=system)
|
||||
|
||||
|
||||
def _try_repair_json(text: str) -> str | None:
|
||||
"""Tenta di riparare errori JSON comuni prodotti dagli LLM."""
|
||||
m = re.search(r'(\{[\s\S]*\})', text)
|
||||
if not m:
|
||||
return None
|
||||
blob = m.group(1)
|
||||
|
||||
# Fix 1: chiavi con apici singoli → doppi ("params' " → "params": ")
|
||||
blob = re.sub(r"(?<!\\)'(params|narration|tool|command|text|character_id|dc|modifier|die|count)"
|
||||
r"'(?=\s*[:=])", r'"\1":', blob)
|
||||
# Fix 2: qualsiasi altra chiave con apici singoli
|
||||
blob = re.sub(r"(?<!\\)'(\w+)'(?=\s*[:=])", r'"\1":', blob)
|
||||
# Fix 3: trailing comma prima di } o ]
|
||||
blob = re.sub(r',\s*}', '}', blob)
|
||||
blob = re.sub(r',\s*\]', ']', blob)
|
||||
# Fix 4: = al posto di : nelle chiavi
|
||||
blob = re.sub(r'"(\w+)"\s*=', r'"\1":', blob)
|
||||
try:
|
||||
json.loads(blob)
|
||||
return blob
|
||||
except json.JSONDecodeError:
|
||||
return None
|
||||
|
||||
|
||||
def _extract_json(text: str) -> str | None:
|
||||
for pat in [
|
||||
r'```json\s*([\s\S]*?)```',
|
||||
@@ -40,8 +64,27 @@ def _extract_json(text: str) -> str | None:
|
||||
json.loads(candidate)
|
||||
return candidate
|
||||
except json.JSONDecodeError:
|
||||
# tenta riparazione
|
||||
repaired = _try_repair_json(candidate)
|
||||
if repaired:
|
||||
return repaired
|
||||
continue
|
||||
return None
|
||||
# try repair on full text too
|
||||
return _try_repair_json(text)
|
||||
|
||||
|
||||
def _strip_json_from_text(text: str) -> str:
|
||||
"""Rimuove qualsiasi contenuto simile a JSON dal testo narrativo."""
|
||||
# toglie blocchi {...} completi di tool call
|
||||
cleaned = re.sub(r'\{"tool":[\s\S]*?"narration":\s*"[^"]*"\s*\}', '', text)
|
||||
# toglie blocchi JSON parziali o incompleti
|
||||
cleaned = re.sub(r'\{"tool":[\s\S]*?\}', '', cleaned)
|
||||
cleaned = re.sub(r'\{\s*"[a-zA-Z_]+"\s*[:=][^}]*\}', '', cleaned)
|
||||
# pulisce spaziature multiple
|
||||
cleaned = re.sub(r'\s+', ' ', cleaned).strip()
|
||||
# toglie virgolette pendenti all'inizio/fine
|
||||
cleaned = cleaned.strip('"\' ,.')
|
||||
return cleaned
|
||||
|
||||
|
||||
def parse_tool_call(text: str) -> dict:
|
||||
@@ -55,10 +98,12 @@ def parse_tool_call(text: str) -> dict:
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
tool_match = re.search(r'(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
|
||||
# Fallback: estrae tool name via regex, ma PULISCE la narration dal JSON
|
||||
tool_match = re.search(r'\b(?:tool|strumento|chiamo|uso)\s*[":]*\s*(\w+)', text, re.I)
|
||||
narration = _strip_json_from_text(text) if text else ""
|
||||
if tool_match:
|
||||
return {"tool": tool_match.group(1), "params": {}, "narration": text[:200]}
|
||||
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": text[:200]}}, "narration": ""}
|
||||
return {"tool": tool_match.group(1), "params": {}, "narration": narration}
|
||||
return {"tool": "dm_override", "params": {"command": "narrate", "params": {"text": narration or text[:500]}}, "narration": narration}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -44,6 +44,9 @@ STILE:
|
||||
- Usa ITALIANO.
|
||||
- Dai sempre una scelta al giocatore alla fine.
|
||||
- Mantieni il tono weird-fantasy di DCC: pericolo, meraviglia, corruzione.
|
||||
- Usa VOCABOLARIO FANTASY: pietra, tenebra, fiamma, sangue, ferro, ossa, ombra, polvere, ruggine, muschio, crepitio, sussurro, pulsare, vibrare, brillare, incrinato, logoro, antico, dimenticato.
|
||||
- VIETATO usare: termini tecnici, scientifici, moderni, anacronistici (niente HPLC, laser, DNA, algoritmi, frequenze, ecc.).
|
||||
- Niente acronimi, niente linguaggio da laboratorio o ingegneria.
|
||||
|
||||
MECCANICHE:
|
||||
- CD base: 12 = facile, 15 = media, 18 = difficile
|
||||
@@ -88,7 +91,9 @@ def format_context(ctx: GameContext) -> str:
|
||||
|
||||
lines.append("PERSONAGGI:")
|
||||
for c in ctx.characters:
|
||||
bl = f" [{c['id']}] {c['name']} — Lv{c['level']} {c.get('char_class', '')} ({c.get('alignment', '?')})"
|
||||
lv = c.get("level", 0)
|
||||
label = c.get("occupation", "") if lv == 0 else c.get("char_class", "")
|
||||
bl = f" [{c['id']}] {c['name']} — Lv{lv} {label} ({c.get('alignment', '?')})"
|
||||
stat_str = f" For{c.get('str','?')} Agi{c.get('agi','?')} Sta{c.get('sta','?')} Per{c.get('per','?')} Int{c.get('int','?')} Luc{c.get('lck','?')}"
|
||||
hp_str = f" PF {c['hp_current']}/{c['hp_max']} CA {c.get('ac', 10)} PE {c.get('xp', 0)}"
|
||||
lines.extend([bl, stat_str, hp_str])
|
||||
|
||||
Reference in new issue
Block a user