Compare commits

...
7 Commits
Author SHA1 Message Date
truetype 634625dbfb feat(ui): supporto funnel - morte PG e rimpiazzo
test / test (push) Has been cancelled
- Card personaggi morti: grigia, teschio, 0 PF, filtro grayscale
- Bottone Nuovo PG quando ci sono caduti
- replaceFunnelDead() chiama POST /api/campaign/{id}/character
- Stato aggiornato dopo rimpiazzo
2026-06-30 17:25:43 +02:00
truetype e3edb87fac feat: backup DB automatico e logging strutturato
- backup_db() in db.py: copia in data/backups/*.db
- Backup a ogni avvio + ogni 5 turni
- Mantiene max 20 backup, rimuove i piu vecchi
- Logging strutturato: [LLM] latenza, raw_len
- Logging strutturato: [CYCLE] turn, tool, latenza, success
- Fix: import mancante threading in db.py
2026-06-29 19:07:33 +02:00
truetype 8b6620cd3d fix(mobile): input bar non visibile - usare 100dvh
- #app usa 100dvh su mobile (viewport dinamico)
- Input piu visibile anche se disabilitato (opacity 0.7)
- Touch target 44px per input e bottone
- flex-shrink:0 su btn-send
- safe-area padding aumentato
2026-06-29 18:47:37 +02:00
truetype 54c9acb0d6 fix(mobile): layout input visibile, hamburger menu
- Rimosso height:100dvh da #main (causava layout rotto)
- #main si affida a flex:1 per l'altezza
- hamburger ☰ sempre visibile nell'header su mobile
- Safe-area per notch iOS
2026-06-29 17:29:46 +02:00
truetype 33ba2a78b2 feat(ui): adattamento mobile responsive
- Hamburger button (☰) nell'header per aprire sidebar
- Sidebar come drawer overlay su mobile (<768px)
- Sidebar backdrop con chiusura al tap
- Swipe a sinistra per chiudere sidebar
- Safe-area per iPhone notch
- Font-size 16px su input (previene zoom iOS)
- dvh per altezza viewport mobile
- Auto-focus input solo su desktop
2026-06-29 16:38:52 +02:00
truetype 886df7429f feat(ui): pannello DM-Override nella sidebar
- Handler WS type:dm — esegue dm_override bypassando il LLM
- Pannello collassabile con selettore comandi e campi dinamici
- Comandi: narrate, set_hp, add_item, force_roll, skip_combat
- Stato aggiornato dopo DM override
- Implementato narrate con testo personalizzato
2026-06-29 16:28:34 +02:00
truetype e9c559c001 feat(ui): mappa aree nella sidebar
- Aggiunto current_area_id allo stato WS
- Mappa ordinata per ID area (1.1-1.9)
- Area corrente evidenziata con ✧ e bordo dorato
- Aree visitate con pallino pieno, non visitate vuoto
- Connessioni mostrate con nomi (non ID)
- CSS rinnovato per area-item
2026-06-28 23:35:36 +02:00
6 changed files with 539 additions and 28 deletions

No files matched your search

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+34 -1
View File
@@ -4,15 +4,21 @@ Database module per DCC RPG — Neon Scratch Lounge.
Gestisce schema SQLite e popolazione iniziale dati.
"""
import sqlite3
import json
import logging
import os
import shutil
import sqlite3
import threading
import time
from datetime import datetime
from pathlib import Path
DB_PATH = Path(__file__).resolve().parent.parent.parent / "data" / "dcc_rpg.db"
DATA_DIR = Path(__file__).resolve().parent.parent.parent / "data"
log = logging.getLogger(__name__)
_conn_lock = threading.Lock()
_local = threading.local()
@@ -239,6 +245,33 @@ def init_db() -> sqlite3.Connection:
return conn
# ---------------------------------------------------------------------------
# Backup
# ---------------------------------------------------------------------------
BACKUP_DIR = DB_PATH.parent / "backups"
def backup_db() -> str:
"""Copia il database in data/backups/dcc_rpg_YYYY-MM-DD_HHMMSS.db.
Mantiene al massimo 20 backup, rimuove i piu vecchi."""
BACKUP_DIR.mkdir(parents=True, exist_ok=True)
ts = datetime.now().strftime("%Y-%m-%d_%H%M%S")
dest = BACKUP_DIR / f"dcc_rpg_{ts}.db"
try:
shutil.copy2(str(DB_PATH), str(dest))
log.info("Backup DB creato: %s", dest.name)
except FileNotFoundError:
log.warning("Nessun DB da backupare (file non trovato)")
return ""
# Pulisce backup vecchi (mantiene 20)
backups = sorted(BACKUP_DIR.glob("dcc_rpg_*.db"), reverse=True)
for old in backups[20:]:
old.unlink()
log.info("Rimosso backup vecchio: %s", old.name)
return str(dest)
# ---------------------------------------------------------------------------
# Query helpers
# ---------------------------------------------------------------------------
+19 -7
View File
@@ -1,6 +1,7 @@
from __future__ import annotations
import logging
import time
from src.engine import db, tools
from src.engine.config import load_from_env
@@ -14,10 +15,14 @@ log = logging.getLogger(__name__)
def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "") -> tuple[str, dict, str]:
prompt = build_prompt(ctx, player_action, current_area_id=current_area_id)
t0 = time.perf_counter()
try:
raw = llm_ask(prompt, system=SYSTEM_PROMPT)
elapsed = time.perf_counter() - t0
log.info("[LLM] azione=%s tool=? latenza=%.1fs raw_len=%d", player_action[:40], elapsed, len(raw))
except Exception as e:
log.warning("LLM call failed (%s), fallback a heuristic parser", e)
elapsed = time.perf_counter() - t0
log.warning("[LLM] fallback heuristico dopo %.1fs: %s", elapsed, e)
tool, params = fallback_parse(player_action, ctx.characters)
return tool, params, ""
@@ -28,17 +33,13 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
narration = decision.get("narration", "")
if not tool:
log.info("LLM non ha prodotto tool valido, fallback heuristico")
log.info("[DM] tool non valido, fallback heuristico")
tool, params = fallback_parse(player_action, ctx.characters)
# 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)
log.info("[DM] tool=%s senza params, integro 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):
@@ -50,6 +51,7 @@ def _llm_decision(ctx: GameContext, player_action: str, current_area_id: str = "
def dm_cycle(campaign_id: int, player_action: str) -> dict:
t0 = time.perf_counter()
ctx = GameContext.load(campaign_id)
tool_name, tool_params, narration = _llm_decision(ctx, player_action, current_area_id=ctx.current_area_id)
@@ -66,6 +68,16 @@ def dm_cycle(campaign_id: int, player_action: str) -> dict:
turn = db.advance_turn(campaign_id)
db.log_turn(campaign_id, turn, player_action, full_msg)
# Backup ogni 5 turni
if turn > 0 and turn % 5 == 0:
db.backup_db()
elapsed = time.perf_counter() - t0
log.info(
"[CYCLE] turn=%d tool=%s action=%s latenza=%.1fs success=%s",
turn, tool_name, player_action[:40], elapsed, result.get("success", True),
)
return {
"turn": turn,
"action": player_action,
+11
View File
@@ -425,6 +425,17 @@ def dm_override(command: str, params: dict | None = None) -> ToolResult:
"commands": manual,
"message": "Comandi DM disponibili: " + ", ".join(manual.keys()),
}
if command == "narrate":
text = (params or {}).get("text", "")
return {
"success": True,
"tool": "dm-override",
"command": command,
"params": params or {},
"message": text or "Override DM: narrate applicato.",
}
return {
"success": True,
"tool": "dm-override",
+29 -1
View File
@@ -15,6 +15,7 @@ from src.engine import db
from src.engine.config import load_from_env
from src.engine.dm import dm_cycle
from src.engine.game_context import GameContext
from src.engine.tools import run as run_tool
log = logging.getLogger(__name__)
@@ -205,6 +206,30 @@ async def game_ws(ws: WebSocket, campaign_id: int):
except WebSocketDisconnect:
break
elif msg.get("type") == "dm":
command = msg.get("command", "help")
params = msg.get("params", {})
try:
result = run_tool("dm_override", command=command, params=params)
await ws.send_json({
"type": "response",
"tool": "dm-override",
"narration": result.get("message", ""),
"mechanics": "",
"turn": 0,
})
# Send updated state after DM command (e.g. set_hp changes HP)
try:
await ws.send_json({"type": "state", "campaign": _build_state(campaign_id)})
except WebSocketDisconnect:
break
except Exception as e:
log.exception("DM override failed")
try:
await ws.send_json({"type": "error", "message": str(e)})
except WebSocketDisconnect:
break
def _build_state(campaign_id: int) -> dict:
ctx = GameContext.load(campaign_id)
@@ -230,6 +255,8 @@ def _build_state(campaign_id: int) -> dict:
"characters": ctx.characters,
"quests": ctx.quests,
"areas": enriched,
"current_area_id": ctx.current_area_id,
"current_area_name": ctx.current_area_name,
}
@@ -260,9 +287,10 @@ async def index():
def main():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
format="%(asctime)s [%(levelname)-5s] %(name)s: %(message)s",
)
db.init_db()
db.backup_db()
cfg = load_from_env()
log.info(
"DM config: provider=%s model=%s",
+446 -19
View File
@@ -233,27 +233,84 @@ html, body {
margin-top: 2px;
}
/* Funnel - char dead */
.char-card.dead {
opacity: 0.4;
filter: grayscale(0.8);
}
.char-card.dead .char-name { color: var(--red); }
.funnel-actions {
padding: 8px 0;
}
.btn-funnel {
width: 100%;
background: var(--bg-elevated);
border: 1px dashed var(--border-light);
border-radius: var(--radius);
padding: 8px;
color: var(--text-secondary);
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
text-transform: uppercase;
letter-spacing: 1px;
transition: background 0.2s, color 0.2s;
}
.btn-funnel:hover {
background: var(--gold-dim);
color: #fff;
border-style: solid;
}
/* Area map */
.area-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 6px;
padding: 5px 6px;
font-size: 13px;
color: var(--text-muted);
border-left: 2px solid transparent;
transition: border-color 0.2s, background 0.2s;
}
.area-item.visited { color: var(--text-secondary); }
.area-item.current {
color: var(--gold-bright);
border-left-color: var(--gold);
background: rgba(201,168,76,0.06);
}
.area-item.visited { color: var(--text-primary); }
.area-marker {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text-muted);
flex-shrink: 0;
margin-top: 5px;
}
.area-item.visited .area-marker { background: var(--text-secondary); }
.area-item.current .area-marker { background: var(--gold); box-shadow: 0 0 6px var(--gold); }
.area-id {
font-family: var(--font-display);
font-size: 10px;
color: var(--gold-dim);
min-width: 24px;
font-weight: 700;
}
.area-name {
flex: 1;
font-size: 13px;
}
.area-here {
font-size: 12px;
color: var(--gold);
margin-left: auto;
}
.area-item.visited .area-marker { background: var(--gold); box-shadow: 0 0 4px var(--gold); }
.area-conns {
width: 100%;
font-size: 11px;
color: var(--text-muted);
margin-left: 14px;
padding-left: 36px;
opacity: 0.7;
}
/* Zone vuote */
@@ -263,6 +320,65 @@ html, body {
font-style: italic;
}
/* DM Override panel */
.dm-toggle {
cursor: pointer;
user-select: none;
opacity: 0.6;
font-size: 12px;
transition: opacity 0.2s;
}
.dm-toggle:hover { opacity: 1; }
.dm-panel {
padding: 6px 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.dm-panel.hidden { display: none; }
.dm-panel select,
.dm-panel input,
.dm-panel textarea {
width: 100%;
background: var(--bg-elevated);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 6px 8px;
color: var(--text-primary);
font-family: var(--font-body);
font-size: 12px;
outline: none;
}
.dm-panel select:focus,
.dm-panel input:focus,
.dm-panel textarea:focus {
border-color: var(--gold-dim);
}
.dm-panel textarea {
min-height: 60px;
resize: vertical;
}
#btn-dm-exec {
background: var(--red);
border: none;
border-radius: var(--radius);
padding: 6px;
color: #fff;
font-family: var(--font-display);
font-size: 11px;
cursor: pointer;
transition: background 0.2s;
text-transform: uppercase;
letter-spacing: 1px;
}
#btn-dm-exec:hover { background: #c04040; }
.dm-result {
font-size: 11px;
color: var(--text-muted);
padding: 4px 0;
min-height: 18px;
}
/* ============================================================
Chat Area
============================================================ */
@@ -388,7 +504,7 @@ html, body {
}
#input-area input::placeholder { color: var(--text-muted); }
#input-area input:disabled {
opacity: 0.4;
opacity: 0.7;
cursor: not-allowed;
}
#btn-send {
@@ -534,6 +650,160 @@ html, body {
margin-right: 12px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Hamburger menu button */
#btn-menu {
display: none;
background: none;
border: none;
color: var(--text-secondary);
font-size: 22px;
cursor: pointer;
padding: 4px 6px;
line-height: 1;
}
/* Sidebar backdrop (mobile) */
#sidebar-backdrop {
display: none;
position: fixed;
inset: 0;
background: rgba(0,0,0,0.5);
z-index: 8;
}
/* ============================================================
Mobile Responsive
============================================================ */
@media (max-width: 768px) {
#btn-menu { display: block; }
#sidebar {
position: fixed;
left: -320px;
top: 0;
bottom: 0;
width: 300px;
min-width: 300px;
z-index: 9;
transition: left 0.25s ease;
box-shadow: 4px 0 20px rgba(0,0,0,0.5);
}
#sidebar.open { left: 0; }
#sidebar-backdrop.open { display: block; }
#app { flex-direction: column; height: 100dvh; }
#header {
padding: 0 12px;
gap: 10px;
min-height: 48px;
height: 48px;
}
#header h1 { font-size: 14px; letter-spacing: 1px; }
#header .turn-badge { font-size: 11px; }
#main {
width: 100%;
min-height: 0;
}
#chat {
padding: 12px;
gap: 10px;
font-size: 15px;
}
#input-area {
padding: 10px 12px;
padding-bottom: max(12px, env(safe-area-inset-bottom));
gap: 8px;
}
#input-area input {
font-size: 16px;
padding: 12px;
min-height: 44px;
}
#btn-send {
width: 44px;
height: 44px;
font-size: 20px;
flex-shrink: 0;
}
/* Welcome screen */
#welcome h1 { font-size: 28px; }
#welcome .subtitle { font-size: 14px; }
.camp-list { max-width: 100%; padding: 0 12px; }
#new-camp-form { width: 100%; max-width: 100%; padding: 0 12px; }
/* Sidebar content */
.sidebar-section { padding: 10px 12px; }
.sidebar-section h2 { font-size: 10px; }
.char-name { font-size: 14px; }
.stat-grid { gap: 3px; }
.stat-row { font-size: 12px; }
.area-item { font-size: 12px; }
}
#sidebar.open { left: 0; }
#sidebar-backdrop.open { display: block; }
#app { flex-direction: column; }
#header {
padding: 0 12px;
gap: 10px;
min-height: 48px;
height: 48px;
}
#header h1 { font-size: 14px; letter-spacing: 1px; }
#header .turn-badge { font-size: 11px; }
#main {
width: 100%;
min-height: 0;
}
#chat {
padding: 12px;
gap: 10px;
font-size: 15px;
}
#input-area {
padding: 8px 10px;
padding-bottom: max(8px, env(safe-area-inset-bottom));
}
#input-area input {
font-size: 16px;
padding: 10px 12px;
}
#btn-send {
width: 44px;
height: 44px;
font-size: 20px;
}
/* Welcome screen */
#welcome h1 { font-size: 28px; }
#welcome .subtitle { font-size: 14px; }
.camp-list { max-width: 100%; padding: 0 12px; }
#new-camp-form { width: 100%; max-width: 100%; padding: 0 12px; }
/* Sidebar content */
.sidebar-section { padding: 10px 12px; }
.sidebar-section h2 { font-size: 10px; }
.char-name { font-size: 14px; }
.stat-grid { gap: 3px; }
.stat-row { font-size: 12px; }
.area-item { font-size: 12px; }
}
@media (max-width: 480px) {
#sidebar { width: 260px; min-width: 260px; left: -260px; }
#header h1 { font-size: 12px; }
.msg-bubble { font-size: 14px; }
}
</style>
</head>
<body>
@@ -563,11 +833,28 @@ html, body {
<h2>Aree</h2>
<div id="area-display"><div class="empty-state">Nessuna area</div></div>
</div>
<div class="sidebar-section" id="dm-section">
<h2 class="dm-toggle" onclick="toggleDM()">DM +</h2>
<div id="dm-panel" class="dm-panel hidden">
<select id="dm-cmd" onchange="onDMCommand()">
<option value="narrate">Narrate (testo)</option>
<option value="set_hp">Set HP</option>
<option value="add_item">Add Item</option>
<option value="force_roll">Force Roll</option>
<option value="skip_combat">Skip Combat</option>
</select>
<div id="dm-fields"></div>
<button id="btn-dm-exec" onclick="execDM()">Esegui</button>
<div id="dm-result" class="dm-result"></div>
</div>
</div>
</div>
<div id="sidebar-backdrop" class="hidden" onclick="toggleSidebar()"></div>
<!-- MAIN -->
<div id="main">
<div id="header">
<button id="btn-menu" onclick="toggleSidebar()">☰</button>
<div id="conn-indicator" class="conn-status disconnected"></div>
<h1>Tomba del Mago Guerriero</h1>
<div class="turn-badge">Turno <span id="turn-counter">0</span></div>
@@ -592,6 +879,7 @@ let campaignId = null;
let ws = null;
let turn = 0;
let reconnectTimer = null;
let currentAreaId = '';
let state = { characters: [], quests: [], areas: [] };
const HOST = window.location.host;
@@ -699,7 +987,7 @@ function connectWS() {
connIndicator.className = 'conn-status connected';
input.disabled = false;
btnSend.disabled = false;
input.focus();
if (window.innerWidth > 768) input.focus();
if (reconnectTimer) { clearInterval(reconnectTimer); reconnectTimer = null; }
};
@@ -773,6 +1061,7 @@ function applyState(s) {
turn = s.turn;
turnCounter.textContent = turn;
}
if (s.current_area_id) currentAreaId = s.current_area_id;
renderChar(s.characters);
renderQuests(s.quests);
renderAreas(s.areas);
@@ -786,19 +1075,22 @@ function renderChar(chars) {
charDisplay.innerHTML = '<div class="empty-state">Nessun PG</div>';
return;
}
const alive = chars.filter(c => c.status !== 'dead');
const dead = chars.filter(c => c.status === 'dead');
charDisplay.innerHTML = chars.map(c => {
const isDead = c.status === 'dead';
const isLv0 = c.level === 0;
const hpPct = Math.round((c.hp_current / c.hp_max) * 100);
const label = isLv0
? `${c.occupation || 'Mestiere'} · Lv0`
: `${c.char_class || ''} · Livello ${c.level}`;
return `
<div class="char-card" style="border-bottom:1px solid var(--border);padding:8px 0;">
<div class="char-name">${escapeHtml(c.name)}</div>
<div class="char-card ${isDead ? 'dead' : ''}" style="border-bottom:1px solid var(--border);padding:8px 0;">
<div class="char-name">${isDead ? '💀 ' : ''}${escapeHtml(c.name)}</div>
<div class="char-class">${escapeHtml(label)}</div>
<div class="hp-bar-wrapper">
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${hpPct}%"></div></div>
<span class="hp-text">${c.hp_current}/${c.hp_max}</span>
<div class="hp-bar-bg"><div class="hp-bar-fill" style="width:${isDead ? 0 : hpPct}%"></div></div>
<span class="hp-text">${isDead ? '✝ morto' : c.hp_current+'/'+c.hp_max}</span>
</div>
<div class="stat-grid">
<div class="stat-row"><span class="stat-label">FOR</span><span class="stat-val">${c.str||'?'}</span></div>
@@ -812,6 +1104,16 @@ function renderChar(chars) {
</div>
`;
}).join('');
if (dead.length > 0) {
charDisplay.innerHTML += `<div class="funnel-actions"><button onclick="replaceFunnelDead()" class="btn-funnel">➕ Nuovo PG (rimpiazza caduti)</button></div>`;
}
}
async function replaceFunnelDead() {
const resp = await fetch(`${BASE}/api/campaign/${campaignId}/character`, { method: 'POST' });
const data = await resp.json();
const s = await (await fetch(`${BASE}/api/campaign/${campaignId}`)).json();
applyState(s);
}
function renderQuests(quests) {
@@ -832,15 +1134,140 @@ function renderAreas(areas) {
areaDisplay.innerHTML = '<div class="empty-state">Nessuna area</div>';
return;
}
areaDisplay.innerHTML = areas.map(a => `
<div class="area-item ${a.visited ? 'visited' : ''}">
<span class="area-marker"></span>
<span>${escapeHtml(a.name)}</span>
</div>
${a.connections && a.connections.length ? `<div class="area-conns">→ ${a.connections.join(', ')}</div>` : ''}
`).join('');
// Sort by area id (1.1, 1.2, ..., 1.9)
const sorted = [...areas].sort((a, b) => (a.id || '').localeCompare(b.id || ''));
areaDisplay.innerHTML = sorted.map(a => {
const isCurrent = a.id === currentAreaId;
const status = isCurrent ? 'current' : (a.visited ? 'visited' : 'unvisited');
const conns = (a.connections || []).join(' → ');
return `
<div class="area-item ${status}">
<span class="area-marker"></span>
<span class="area-id">${escapeHtml(a.id)}</span>
<span class="area-name">${escapeHtml(a.name)}</span>
${isCurrent ? '<span class="area-here">✧</span>' : ''}
${conns ? `<div class="area-conns">→ ${escapeHtml(conns)}</div>` : ''}
</div>`;
}).join('');
}
// ================================================================
// Mobile sidebar
// ================================================================
function toggleSidebar() {
const sb = document.getElementById('sidebar');
const backdrop = document.getElementById('sidebar-backdrop');
const open = !sb.classList.contains('open');
sb.classList.toggle('open', open);
backdrop.classList.toggle('open', open);
backdrop.classList.toggle('hidden', !open);
}
// Close sidebar when swiping left on mobile
let touchStartX = 0;
document.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; });
document.addEventListener('touchend', e => {
const sb = document.getElementById('sidebar');
if (!sb.classList.contains('open')) return;
const dx = touchStartX - e.changedTouches[0].clientX;
if (dx > 80) toggleSidebar();
});
// ================================================================
// DM Override
// ================================================================
let dmOpen = false;
function toggleDM() {
dmOpen = !dmOpen;
const panel = $('dm-panel');
panel.classList.toggle('hidden', !dmOpen);
if (dmOpen) onDMCommand();
}
function onDMCommand() {
const cmd = $('dm-cmd').value;
const fields = $('dm-fields');
$('dm-result').textContent = '';
switch (cmd) {
case 'narrate':
fields.innerHTML = '<textarea id="dm-narrate-text" placeholder="Testo narrativo..."></textarea>';
break;
case 'set_hp':
fields.innerHTML = `
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
<input id="dm-hp-value" type="number" placeholder="PF" min="0" />
`;
break;
case 'add_item':
fields.innerHTML = `
<input id="dm-char-id" type="number" placeholder="ID PG" min="1" />
<input id="dm-item-name" placeholder="Nome oggetto" />
`;
break;
case 'force_roll':
fields.innerHTML = `
<input id="dm-die" placeholder="dado (es. d20)" />
<input id="dm-value" type="number" placeholder="Valore forzato" min="1" />
`;
break;
case 'skip_combat':
fields.innerHTML = '<input id="dm-area-id" placeholder="ID area (es. 1.4)" />';
break;
default:
fields.innerHTML = '';
}
}
function execDM() {
if (!ws || ws.readyState !== WebSocket.OPEN) return;
const cmd = $('dm-cmd').value;
let params = {};
const resultDiv = $('dm-result');
resultDiv.textContent = 'Invio...';
switch (cmd) {
case 'narrate': {
const text = $('dm-narrate-text')?.value?.trim();
if (!text) { resultDiv.textContent = 'Inserisci testo.'; return; }
params = { text };
break;
}
case 'set_hp': {
const cid = parseInt($('dm-char-id')?.value);
const hp = parseInt($('dm-hp-value')?.value);
if (!cid || isNaN(hp)) { resultDiv.textContent = 'Specifica ID PG e PF.'; return; }
params = { character_id: cid, value: hp };
break;
}
case 'add_item': {
const cid = parseInt($('dm-char-id')?.value);
const item = $('dm-item-name')?.value?.trim();
if (!cid || !item) { resultDiv.textContent = 'Specifica ID PG e oggetto.'; return; }
params = { character_id: cid, item };
break;
}
case 'force_roll': {
const die = $('dm-die')?.value?.trim();
const val = parseInt($('dm-value')?.value);
if (!die || isNaN(val)) { resultDiv.textContent = 'Specifica dado e valore.'; return; }
params = { die, forced_value: val };
break;
}
case 'skip_combat': {
const aid = $('dm-area-id')?.value?.trim();
if (!aid) { resultDiv.textContent = 'Specifica ID area.'; return; }
params = { area_id: aid };
break;
}
}
ws.send(JSON.stringify({ type: 'dm', command: cmd, params }));
resultDiv.textContent = '✧ Comando inviato.';
}
// ================================================================
// Send action
// ================================================================