1853 lines
71 KiB
Python
1853 lines
71 KiB
Python
"""
|
||
PipBoy — Fallout: Venice of Wasteland
|
||
Dashboard unifié : Simulation | Outils | Lore enrichment | Paramètres
|
||
(anciennement VAULTCOM)
|
||
"""
|
||
|
||
import os, json, subprocess, threading, time
|
||
import psycopg2
|
||
import psycopg2.extras
|
||
from flask import Flask, render_template_string, jsonify, request, redirect, url_for
|
||
|
||
app = Flask(__name__)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Stress test — état global (un seul test à la fois)
|
||
# ---------------------------------------------------------------------------
|
||
_stress_state = {
|
||
"running": False,
|
||
"started_at": None,
|
||
"mode": None,
|
||
"rounds": None,
|
||
"pnj_count": None,
|
||
"output": [], # lignes de stdout
|
||
"returncode": None,
|
||
"error": None,
|
||
}
|
||
_stress_lock = threading.Lock()
|
||
|
||
def _run_stress_bg(mode: str, rounds: int, pnj_count: int):
|
||
global _stress_state
|
||
ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
|
||
model_mj = os.getenv("MODEL_MJ", "qwen2.5:14b")
|
||
model_pnj = os.getenv("MODEL_PNJ", "qwen2.5:7b")
|
||
results_path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
|
||
|
||
cmd = [
|
||
"python3", "/app/tools/llm_crash_test.py",
|
||
"--mode", mode,
|
||
"--rounds", str(rounds),
|
||
"--pnj-count", str(pnj_count),
|
||
]
|
||
env = {**os.environ,
|
||
"OLLAMA_URL": ollama_url,
|
||
"MODEL_MJ": model_mj,
|
||
"MODEL_PNJ": model_pnj,
|
||
"CRASH_RESULTS_PATH": results_path,
|
||
"PYTHONUNBUFFERED": "1"}
|
||
try:
|
||
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
|
||
text=True, env=env)
|
||
for line in proc.stdout:
|
||
with _stress_lock:
|
||
_stress_state["output"].append(line.rstrip())
|
||
proc.wait()
|
||
with _stress_lock:
|
||
_stress_state["returncode"] = proc.returncode
|
||
_stress_state["running"] = False
|
||
except Exception as e:
|
||
with _stress_lock:
|
||
_stress_state["error"] = str(e)
|
||
_stress_state["running"] = False
|
||
|
||
DB_HOST = os.getenv("DB_HOST", "localhost")
|
||
DB_PORT = int(os.getenv("DB_PORT", 5432))
|
||
DB_NAME = os.getenv("DB_NAME", "fallout")
|
||
DB_USER = os.getenv("DB_USER", "fallout")
|
||
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
|
||
|
||
SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config")
|
||
|
||
def get_conn():
|
||
return psycopg2.connect(
|
||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||
user=DB_USER, password=DB_PASS
|
||
)
|
||
|
||
def query(sql, params=None):
|
||
conn = get_conn()
|
||
try:
|
||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
||
cur.execute(sql, params)
|
||
return [dict(r) for r in cur.fetchall()]
|
||
finally:
|
||
conn.close()
|
||
|
||
def query_one(sql, params=None):
|
||
rows = query(sql, params)
|
||
return rows[0] if rows else None
|
||
|
||
def execute(sql, params=None):
|
||
conn = get_conn()
|
||
try:
|
||
with conn.cursor() as cur:
|
||
cur.execute(sql, params)
|
||
conn.commit()
|
||
finally:
|
||
conn.close()
|
||
|
||
_REFERENCE_CONFIG_ID = 1 # sim_001.json = référence pour la liste des modes
|
||
|
||
def load_sim_config(session_id: int) -> dict | None:
|
||
"""Retourne le cfg JSON ou None si introuvable (None est falsy ET distinguable de {})."""
|
||
path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json")
|
||
try:
|
||
with open(path) as f:
|
||
return json.load(f)
|
||
except Exception as e:
|
||
return None
|
||
|
||
def load_all_modes() -> dict:
|
||
"""Charge les modes depuis sim_001.json (référence complète des 5 modes)."""
|
||
cfg = load_sim_config(_REFERENCE_CONFIG_ID) or {}
|
||
return cfg.get("modes", {})
|
||
|
||
def save_sim_config(session_id: int, cfg: dict):
|
||
path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json")
|
||
with open(path, "w") as f:
|
||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
||
|
||
def get_all_sessions():
|
||
return query("SELECT * FROM sessions ORDER BY id")
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# HTML Template
|
||
# ---------------------------------------------------------------------------
|
||
|
||
TEMPLATE = r"""<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<title>PipBoy — Fallout: Venice of Wasteland</title>
|
||
<style>
|
||
:root {
|
||
--green: #4ade80;
|
||
--red: #f87171;
|
||
--amber: #fbbf24;
|
||
--blue: #60a5fa;
|
||
--bg: #0f1117;
|
||
--panel: #161b27;
|
||
--panel2: #1a1f2e;
|
||
--border: #2a3040;
|
||
--text: #e8e8e8;
|
||
--muted: #8892a4;
|
||
--dim: #4a5568;
|
||
}
|
||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||
body {
|
||
background: var(--bg);
|
||
color: var(--text);
|
||
font-family: system-ui, 'Segoe UI', sans-serif;
|
||
font-size: 14px;
|
||
padding: 0;
|
||
}
|
||
|
||
/* ---- TOP BAR ---- */
|
||
.topbar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 10px 16px;
|
||
background: #0a0d14;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
.topbar-title {
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: var(--green);
|
||
letter-spacing: 1px;
|
||
margin-right: 8px;
|
||
}
|
||
.session-pills { display: flex; gap: 6px; }
|
||
.session-pill {
|
||
padding: 4px 12px;
|
||
border-radius: 20px;
|
||
border: 1px solid var(--border);
|
||
color: var(--muted);
|
||
text-decoration: none;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
transition: all .15s;
|
||
}
|
||
.session-pill:hover { border-color: var(--dim); color: var(--text); }
|
||
.session-pill.active { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
|
||
.session-pill.stopped { border-color: var(--red); color: var(--red); }
|
||
.session-pill .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||
.dot-green { background: var(--green); }
|
||
.dot-red { background: var(--red); }
|
||
.dot-amber { background: var(--amber); }
|
||
.nav-sep { flex-grow: 1; }
|
||
|
||
/* ---- NAV TABS ---- */
|
||
.nav-tabs { display: flex; gap: 2px; }
|
||
.nav-tabs a {
|
||
padding: 6px 14px;
|
||
text-decoration: none;
|
||
color: var(--muted);
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
border-radius: 6px 6px 0 0;
|
||
border: 1px solid transparent;
|
||
border-bottom: none;
|
||
transition: all .15s;
|
||
}
|
||
.nav-tabs a:hover:not(.active) { color: var(--text); background: var(--panel2); }
|
||
.nav-tabs a.active {
|
||
color: var(--amber);
|
||
background: var(--panel);
|
||
border-color: var(--border);
|
||
border-bottom-color: var(--panel);
|
||
}
|
||
|
||
/* ---- MAIN CONTENT ---- */
|
||
.main { padding: 16px; }
|
||
|
||
/* ---- FLASH ---- */
|
||
.flash {
|
||
padding: 8px 12px;
|
||
margin: 0 16px 12px;
|
||
border-radius: 6px;
|
||
font-size: 13px;
|
||
}
|
||
.flash-ok { background: rgba(74,222,128,.1); border: 1px solid rgba(74,222,128,.3); color: var(--green); }
|
||
.flash-err { background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.3); color: var(--red); }
|
||
|
||
/* ---- SESSION INFO BAR ---- */
|
||
.info-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 16px;
|
||
padding: 10px 14px;
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
margin-bottom: 16px;
|
||
font-size: 13px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.info-bar .label { color: var(--muted); }
|
||
.info-bar .val { color: var(--text); font-weight: 500; }
|
||
.status-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 2px 10px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.status-active { background: rgba(74,222,128,.12); color: var(--green); }
|
||
.status-stopped { background: rgba(248,113,113,.12); color: var(--red); }
|
||
.status-paused { background: rgba(251,191,36,.12); color: var(--amber); }
|
||
.mode-tag {
|
||
padding: 2px 10px;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: rgba(96,165,250,.1);
|
||
color: var(--blue);
|
||
border: 1px solid rgba(96,165,250,.2);
|
||
}
|
||
|
||
/* ---- METRIC CARDS ---- */
|
||
.metric-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
|
||
gap: 10px;
|
||
margin-bottom: 16px;
|
||
}
|
||
.metric-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px 14px;
|
||
}
|
||
.metric-card .metric-label {
|
||
font-size: 11px;
|
||
color: var(--muted);
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
margin-bottom: 4px;
|
||
}
|
||
.metric-card .metric-value {
|
||
font-size: 26px;
|
||
font-weight: 700;
|
||
line-height: 1;
|
||
}
|
||
.metric-green { color: var(--green); }
|
||
.metric-red { color: var(--red); }
|
||
.metric-amber { color: var(--amber); }
|
||
.metric-blue { color: var(--blue); }
|
||
.metric-muted { color: var(--muted); }
|
||
|
||
/* ---- LAYOUT GRIDS ---- */
|
||
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-top: 12px; }
|
||
|
||
/* ---- PANELS ---- */
|
||
.panel {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 12px;
|
||
}
|
||
.panel h2 {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .8px;
|
||
color: var(--amber);
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 8px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
/* ---- TABLES ---- */
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th {
|
||
color: var(--muted);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .5px;
|
||
padding: 6px 10px;
|
||
text-align: left;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
td {
|
||
padding: 6px 10px;
|
||
font-size: 13px;
|
||
border-bottom: 1px solid rgba(42,48,64,.5);
|
||
vertical-align: middle;
|
||
}
|
||
tr:nth-child(even) td { background: #1a1a2e; }
|
||
tr:hover td { background: rgba(74,222,128,.04) !important; }
|
||
.dead td { opacity: .45; text-decoration: line-through; }
|
||
|
||
/* ---- TIER COLORS ---- */
|
||
.tier0-text { color: #fb923c; }
|
||
.tier1-text { color: var(--green); }
|
||
.tier2-text { color: var(--blue); }
|
||
.tier3-text { color: var(--muted); }
|
||
|
||
/* ---- HP TEXT ---- */
|
||
.hp-ok { color: var(--green); font-weight: 600; }
|
||
.hp-low { color: var(--amber); font-weight: 600; }
|
||
.hp-crit { color: var(--red); font-weight: 600; }
|
||
|
||
/* ---- FACTION BADGES (text only) ---- */
|
||
.faction-union { color: #60a5fa; }
|
||
.faction-cda { color: #f87171; }
|
||
.faction-ecumeurs { color: #c084fc; }
|
||
.faction-syndicat { color: #facc15; }
|
||
.faction-krewe { color: #34d399; }
|
||
.faction-oak { color: #fb923c; }
|
||
.faction-regie { color: #e2e8f0; }
|
||
.faction-default { color: var(--muted); }
|
||
|
||
/* ---- EVENT COLORS ---- */
|
||
.ev-combat { color: var(--red); }
|
||
.ev-encounter { color: var(--amber); }
|
||
.ev-survival { color: #86efac; }
|
||
.ev-death { color: #ff0000; font-weight: 700; }
|
||
|
||
/* ---- RELATION COLORS ---- */
|
||
.rel-hostile { color: var(--red); }
|
||
.rel-neutral { color: var(--muted); }
|
||
.rel-allie { color: var(--green); }
|
||
|
||
/* ---- SCROLLABLE ---- */
|
||
.scrollable { max-height: 300px; overflow-y: auto; }
|
||
|
||
/* ---- WORLD ZONE ---- */
|
||
.world-zone {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
padding: 5px 0;
|
||
border-bottom: 1px solid rgba(42,48,64,.5);
|
||
font-size: 13px;
|
||
}
|
||
.zone-name { color: var(--amber); min-width: 150px; }
|
||
.zone-stats { display: flex; gap: 10px; font-size: 12px; color: var(--muted); }
|
||
.zone-stats span { color: var(--text); }
|
||
|
||
/* ---- PARAMS TAB ---- */
|
||
.cfg-block { margin-bottom: 16px; }
|
||
.cfg-block h3 {
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .8px;
|
||
color: var(--amber);
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 6px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.cfg-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||
.cfg-row label { min-width: 230px; font-size: 13px; color: var(--muted); }
|
||
.cfg-row input[type=number],
|
||
.cfg-row input[type=text],
|
||
.cfg-row select {
|
||
background: #0a0d14;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
padding: 5px 10px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
width: 140px;
|
||
}
|
||
.cfg-row input[type=checkbox] { accent-color: var(--green); width: auto; }
|
||
.cfg-row select:focus,
|
||
.cfg-row input:focus { outline: none; border-color: var(--amber); }
|
||
|
||
/* ---- BUTTONS ---- */
|
||
.btn {
|
||
padding: 7px 18px;
|
||
border: 1px solid var(--border);
|
||
background: var(--panel2);
|
||
color: var(--text);
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: all .15s;
|
||
}
|
||
.btn:hover { border-color: var(--dim); background: #222736; }
|
||
.btn-green { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
|
||
.btn-green:hover { background: rgba(74,222,128,.15); }
|
||
.btn-red { border-color: var(--red); color: var(--red); background: rgba(248,113,113,.08); }
|
||
.btn-red:hover { background: rgba(248,113,113,.15); }
|
||
.btn-amber { border-color: var(--amber); color: var(--amber); background: rgba(251,191,36,.08); }
|
||
.btn-amber:hover { background: rgba(251,191,36,.15); }
|
||
|
||
/* ---- CODE BLOCKS ---- */
|
||
.code-block { position: relative; margin: 8px 0; }
|
||
.code-block pre {
|
||
background: #0a0d14;
|
||
border: 1px solid var(--border);
|
||
border-radius: 6px;
|
||
padding: 10px 14px;
|
||
font-family: 'Consolas', 'Courier New', monospace;
|
||
font-size: 12px;
|
||
color: #86efac;
|
||
white-space: pre-wrap;
|
||
word-break: break-all;
|
||
margin: 0;
|
||
padding-right: 80px;
|
||
}
|
||
.copy-btn {
|
||
position: absolute;
|
||
top: 6px;
|
||
right: 8px;
|
||
padding: 3px 10px;
|
||
font-size: 11px;
|
||
border: 1px solid var(--border);
|
||
background: var(--panel2);
|
||
color: var(--muted);
|
||
border-radius: 4px;
|
||
cursor: pointer;
|
||
font-family: inherit;
|
||
}
|
||
.copy-btn:hover { color: var(--text); border-color: var(--dim); }
|
||
|
||
/* ---- LORE TAB ---- */
|
||
.lore-card {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 14px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.lore-card h3 { font-size: 13px; color: var(--text); margin-bottom: 8px; font-weight: 500; }
|
||
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
|
||
.diff-old { background: rgba(248,113,113,.06); border: 1px solid rgba(248,113,113,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #fca5a5; white-space: pre-wrap; font-family: 'Consolas', monospace; }
|
||
.diff-new { background: rgba(74,222,128,.06); border: 1px solid rgba(74,222,128,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #86efac; white-space: pre-wrap; font-family: 'Consolas', monospace; }
|
||
.diff-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
|
||
.lore-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||
.lore-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
|
||
.lore-actions textarea {
|
||
background: #0a0d14;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
font-family: 'Consolas', monospace;
|
||
font-size: 12px;
|
||
padding: 6px 8px;
|
||
width: 100%;
|
||
min-height: 60px;
|
||
resize: vertical;
|
||
}
|
||
.lore-filters { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
|
||
.lore-filters label { font-size: 13px; color: var(--muted); }
|
||
.lore-filters select {
|
||
background: #0a0d14;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
padding: 4px 8px;
|
||
}
|
||
.lore-stat-bar { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; flex-wrap: wrap; }
|
||
.lore-stat { color: var(--muted); }
|
||
.lore-stat span { font-weight: 600; }
|
||
.badge-pending { background: rgba(251,191,36,.12); color: var(--amber); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.badge-accepted { background: rgba(74,222,128,.12); color: var(--green); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.badge-rejected { background: rgba(248,113,113,.12); color: var(--red); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.badge-modified { background: rgba(96,165,250,.12); color: var(--blue); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||
.change-type-badge { font-size: 11px; color: var(--dim); border: 1px solid var(--border); padding: 1px 7px; border-radius: 4px; }
|
||
|
||
/* ---- TOOLS TAB ---- */
|
||
.tools-section {
|
||
background: var(--panel);
|
||
border: 1px solid var(--border);
|
||
border-radius: 8px;
|
||
padding: 16px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.tools-section h2 {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
text-transform: uppercase;
|
||
letter-spacing: .8px;
|
||
color: var(--amber);
|
||
border-bottom: 1px solid var(--border);
|
||
padding-bottom: 8px;
|
||
margin-bottom: 14px;
|
||
}
|
||
.tool-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||
.tool-row label { font-size: 13px; color: var(--muted); min-width: 100px; }
|
||
.tool-row select,
|
||
.tool-row input[type=number] {
|
||
background: #0a0d14;
|
||
color: var(--text);
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
padding: 5px 10px;
|
||
font-family: inherit;
|
||
font-size: 13px;
|
||
}
|
||
.tool-row select:focus { outline: none; border-color: var(--amber); }
|
||
.radio-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||
.radio-group label {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 4px 12px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 5px;
|
||
cursor: pointer;
|
||
font-size: 13px;
|
||
color: var(--muted);
|
||
min-width: auto;
|
||
transition: all .15s;
|
||
}
|
||
.radio-group input[type=radio] { display: none; }
|
||
.radio-group input[type=radio]:checked + span { color: var(--amber); }
|
||
.radio-group label:has(input:checked) { border-color: var(--amber); background: rgba(251,191,36,.08); color: var(--amber); }
|
||
.cmd-output { margin-top: 12px; display: none; }
|
||
.cmd-note { font-size: 11px; color: var(--muted); margin-top: 6px; font-family: 'Consolas', monospace; }
|
||
|
||
/* Stress test results */
|
||
.results-area { margin-top: 12px; }
|
||
.results-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
|
||
.diag-good { color: var(--green); font-weight: 600; }
|
||
.diag-bad { color: var(--red); font-weight: 600; }
|
||
|
||
/* ---- FOOTER ---- */
|
||
.footer {
|
||
text-align: center;
|
||
font-size: 11px;
|
||
color: var(--dim);
|
||
padding: 16px;
|
||
border-top: 1px solid var(--border);
|
||
margin-top: 20px;
|
||
}
|
||
|
||
/* ---- REFRESH NOTE ---- */
|
||
.refresh-note { font-size: 12px; color: var(--dim); margin-left: auto; }
|
||
.refresh-note a { color: var(--muted); text-decoration: none; }
|
||
.refresh-note a:hover { color: var(--text); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<!-- ===== TOP BAR ===== -->
|
||
<div class="topbar">
|
||
<span class="topbar-title">📟 PIPBOY</span>
|
||
<div class="session-pills">
|
||
{% for s in all_sessions %}
|
||
{% set is_active = s.status == 'active' %}
|
||
<a href="/?sid={{ s.id }}"
|
||
class="session-pill {{ 'active' if s.id == current_sid and tab == 'sim' else ('stopped' if not is_active else '') }}">
|
||
<span class="dot {{ 'dot-green' if is_active else 'dot-red' }}"></span>
|
||
S{{ s.id }}
|
||
</a>
|
||
{% endfor %}
|
||
</div>
|
||
<span class="nav-sep"></span>
|
||
<div class="nav-tabs">
|
||
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' else '' }}">📡 SIM</a>
|
||
<a href="/tools?sid={{ current_sid }}" class="{{ 'active' if tab=='tools' else '' }}">🔧 OUTILS</a>
|
||
<a href="/lore" class="{{ 'active' if tab=='lore' else '' }}">📜 LORE</a>
|
||
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' else '' }}">⚙ PARAMS</a>
|
||
</div>
|
||
</div>
|
||
|
||
{% if flash_msg %}
|
||
<div class="flash {{ 'flash-ok' if flash_ok else 'flash-err' }}">{{ flash_msg }}</div>
|
||
{% endif %}
|
||
|
||
<div class="main">
|
||
|
||
{% if tab == 'sim' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET SIMULATION -->
|
||
<!-- ===================================================== -->
|
||
|
||
{% if session %}
|
||
<div class="info-bar">
|
||
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
|
||
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> <span class="label">/ Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
|
||
<span class="mode-tag">{{ session.mode | upper }}</span>
|
||
{% set st = session.status %}
|
||
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
|
||
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
|
||
{{ st | upper }}
|
||
</span>
|
||
<span class="label">Seed: <span class="val">{{ session.seed_global }}</span></span>
|
||
<span class="refresh-note">
|
||
<a href="?sid={{ session.id }}">↻ Actualiser</a>
|
||
<span id="countdown"></span>
|
||
</span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<!-- Metric cards -->
|
||
<div class="metric-row">
|
||
<div class="metric-card">
|
||
<div class="metric-label">PNJ Vivants</div>
|
||
<div class="metric-value metric-green">{{ alive_count }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">PNJ Morts</div>
|
||
<div class="metric-value metric-red">{{ stats.get('PNJ morts', 0) }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Total PNJ</div>
|
||
<div class="metric-value metric-muted">{{ total_count }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Combats</div>
|
||
<div class="metric-value metric-amber">{{ stats.get('Combats', 0) }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Rencontres</div>
|
||
<div class="metric-value metric-blue">{{ stats.get('Rencontres', 0) }}</div>
|
||
</div>
|
||
<div class="metric-card">
|
||
<div class="metric-label">Événements</div>
|
||
<div class="metric-value metric-muted">{{ stats.get('Événements total', 0) }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid">
|
||
<!-- PNJ -->
|
||
<div class="panel">
|
||
<h2>👥 PNJ ({{ alive_count }}/{{ total_count }})</h2>
|
||
<div class="scrollable">
|
||
<table>
|
||
<tr><th>Tier</th><th>Nom</th><th>PV</th><th>Faction</th><th>Zone</th><th>Caps</th></tr>
|
||
{% for c in characters %}
|
||
{% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
|
||
<tr class="{{ 'dead' if not c.is_alive else '' }}">
|
||
<td>
|
||
{% if c.tier == 0 %}<span class="tier0-text">★ BOSS</span>
|
||
{% elif c.tier == 2 %}<span class="tier2-text">T2</span>
|
||
{% elif c.tier == 3 %}<span class="tier3-text">T3</span>
|
||
{% else %}<span class="tier1-text">T1</span>{% endif %}
|
||
</td>
|
||
<td>{{ c.name }}</td>
|
||
<td>
|
||
{% if c.is_alive %}
|
||
<span class="{{ 'hp-crit' if pct < 25 else 'hp-low' if pct < 50 else 'hp-ok' }}">{{ c.hp }}/{{ c.max_hp }}</span>
|
||
{% else %}<span class="tier3-text">†</span>{% endif %}
|
||
</td>
|
||
<td>
|
||
{% set f = c.faction_slug or 'sans' %}
|
||
<span class="faction-{{ 'union' if 'union' in f else 'cda' if 'cda' in f else 'ecumeurs' if 'ecumeur' in f else 'krewe' if 'krewe' in f else 'oak' if 'oak' in f else 'regie' if 'regie' in f else 'syndicat' if 'syndicat' in f else 'default' }}">{{ f[:14] }}</span>
|
||
</td>
|
||
<td style="font-size:12px;color:var(--muted)">{{ (c.location_slug or '—')[:16] }}</td>
|
||
<td style="color:var(--amber)">{{ c.caps }}¢</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Événements -->
|
||
<div class="panel">
|
||
<h2>📡 Événements récents</h2>
|
||
<div class="scrollable">
|
||
<table>
|
||
<tr><th>J/T</th><th>Type</th><th>Description</th></tr>
|
||
{% for ev in events %}
|
||
<tr>
|
||
<td style="color:var(--muted);white-space:nowrap">{{ ev.day }}/{{ ev.tick }}</td>
|
||
<td class="ev-{{ ev.event_type }}" style="white-space:nowrap"><small>{{ ev.event_type }}</small></td>
|
||
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not events %}<tr><td colspan="3" style="color:var(--dim);text-align:center;padding:16px">Aucun événement</td></tr>{% endif %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="grid3">
|
||
<!-- World state -->
|
||
<div class="panel">
|
||
<h2>🗺 État du monde</h2>
|
||
<div class="scrollable">
|
||
{% for ws in world_state %}
|
||
<div class="world-zone">
|
||
<span class="zone-name">{{ ws.location_slug }}</span>
|
||
<span class="zone-stats">
|
||
<span>🍖 <span>{{ ws.food_level or 0 }}%</span></span>
|
||
<span>💧 <span>{{ ws.water_level or 0 }}%</span></span>
|
||
<span>🛡 <span>{{ ws.security_level or 0 }}%</span></span>
|
||
<span>👤 <span>{{ ws.pop_count or 0 }}</span></span>
|
||
</span>
|
||
</div>
|
||
{% endfor %}
|
||
{% if not world_state %}<p style="color:var(--dim);padding:12px 0">Pas de données monde</p>{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Relations factions -->
|
||
<div class="panel">
|
||
<h2>⚔ Relations factions</h2>
|
||
<div class="scrollable">
|
||
<table>
|
||
<tr><th>Faction A</th><th>Faction B</th><th>Score</th><th>État</th></tr>
|
||
{% for r in faction_relations %}
|
||
{% set cls = 'rel-hostile' if r.relation_score < -20 else 'rel-allie' if r.relation_score > 20 else 'rel-neutral' %}
|
||
<tr>
|
||
<td style="font-size:12px">{{ r.faction_a[:14] }}</td>
|
||
<td style="font-size:12px">{{ r.faction_b[:14] }}</td>
|
||
<td class="{{ cls }}">{{ r.relation_score }}</td>
|
||
<td class="{{ cls }}" style="font-size:12px">{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not faction_relations %}<tr><td colspan="4" style="color:var(--dim)">Pas de données</td></tr>{% endif %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Stats par tier + zone -->
|
||
<div class="panel">
|
||
<h2>🎭 PNJ par tier</h2>
|
||
<table style="margin-bottom:12px">
|
||
<tr><th>Tier</th><th>Label</th><th>Vivants</th><th>Morts</th></tr>
|
||
{% for row in tier_stats %}
|
||
<tr>
|
||
<td class="tier{{ row.tier }}-text">{{ row.tier }}</td>
|
||
<td>{{ row.label }}</td>
|
||
<td class="metric-green">{{ row.alive }}</td>
|
||
<td style="color:var(--muted)">{{ row.dead }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
<h2 style="margin-top:4px">🏙 PNJ par zone (top 5)</h2>
|
||
<table>
|
||
<tr><th>Zone</th><th>PNJ</th></tr>
|
||
{% for row in zone_stats %}
|
||
<tr>
|
||
<td style="font-size:12px">{{ row.location_slug }}</td>
|
||
<td class="metric-green">{{ row.cnt }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Encounters -->
|
||
<div class="panel" style="margin-top:12px">
|
||
<h2>⚡ Rencontres récentes</h2>
|
||
<div class="scrollable" style="max-height:180px">
|
||
<table>
|
||
<tr><th>J/T</th><th>Type</th><th>Acteur</th><th>Zone</th><th>Description</th></tr>
|
||
{% for ev in encounters %}
|
||
<tr>
|
||
<td style="color:var(--muted);white-space:nowrap;font-size:12px">{{ ev.day }}/{{ ev.tick }}</td>
|
||
<td class="ev-encounter" style="font-size:12px;white-space:nowrap">{{ ev.event_type }}</td>
|
||
<td style="font-size:12px">{{ ev.actor_name or '—' }}</td>
|
||
<td style="font-size:12px;color:var(--muted)">{{ (ev.location_slug or '—')[:16] }}</td>
|
||
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
{% if not encounters %}<tr><td colspan="5" style="color:var(--dim);text-align:center;padding:12px">Aucune rencontre</td></tr>{% endif %}
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Boss inventory -->
|
||
{% if boss_inventory %}
|
||
<div class="panel" style="margin-top:12px">
|
||
<h2>★ Équipement chefs de faction</h2>
|
||
<table>
|
||
<tr><th>Boss</th><th>Item</th><th>Type</th><th>Qté</th><th>Équipé</th></tr>
|
||
{% for row in boss_inventory %}
|
||
<tr>
|
||
<td class="tier0-text">{{ row.char_name }}</td>
|
||
<td>{{ row.item_name }}</td>
|
||
<td style="color:var(--muted);font-size:12px">{{ row.item_type }}</td>
|
||
<td>{{ row.quantity }}</td>
|
||
<td style="color:var(--green)">{{ '✓' if row.is_equipped else '' }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
var t = 30;
|
||
var el = document.getElementById('countdown');
|
||
if (el) {
|
||
setInterval(function() {
|
||
t--;
|
||
el.textContent = ' (' + t + 's)';
|
||
if (t <= 0) { location.reload(); }
|
||
}, 1000);
|
||
}
|
||
</script>
|
||
|
||
{% elif tab == 'tools' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET OUTILS -->
|
||
<!-- ===================================================== -->
|
||
|
||
<script>
|
||
function copyText(elementId) {
|
||
var el = document.getElementById(elementId);
|
||
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
|
||
var btn = el.nextElementSibling;
|
||
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
|
||
});
|
||
}
|
||
|
||
function genSimCmd() {
|
||
var sid = document.querySelector('input[name="sim_sid"]:checked').value;
|
||
var sleep = document.getElementById('sim_sleep').value;
|
||
var cmd = 'nohup env SESSION_ID=' + sid + ' TICK_SLEEP_SEC=' + sleep + ' PYTHONUNBUFFERED=1 python3 -u /home/ubuntu/fallout-venice/src/engine/run.py > ~/fallout_sim_s' + sid + '.log 2>&1 &';
|
||
document.getElementById('sim_cmd_text').textContent = cmd;
|
||
document.getElementById('sim_cmd_out').style.display = 'block';
|
||
}
|
||
|
||
function genLoreCmd() {
|
||
var faction = document.getElementById('lore_faction').value;
|
||
var cmd = 'cd /home/ubuntu/fallout-venice/src/engine && python3 lore_enricher.py --faction ' + faction;
|
||
document.getElementById('lore_cmd_text').textContent = cmd;
|
||
document.getElementById('lore_cmd_out').style.display = 'block';
|
||
}
|
||
|
||
function genStressCmd() {
|
||
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
|
||
var pnjRow = document.getElementById('stress_pnj_row');
|
||
var pnj = '1';
|
||
if (pnjRow.style.display !== 'none') {
|
||
pnj = document.querySelector('input[name="stress_pnj"]:checked').value;
|
||
}
|
||
var cmd = 'cd /home/ubuntu/fallout-venice/tools && python3 llm_crash_test.py --mode ' + mode + ' --rounds ' + rounds;
|
||
if (mode !== 'solo-mj' && mode !== 'solo-pnj') {
|
||
cmd += ' --pnj-count ' + pnj;
|
||
}
|
||
document.getElementById('stress_cmd_text').textContent = cmd;
|
||
document.getElementById('stress_cmd_out').style.display = 'block';
|
||
}
|
||
|
||
function togglePnjRow() {
|
||
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||
var pnjRow = document.getElementById('stress_pnj_row');
|
||
pnjRow.style.display = (mode === 'solo-mj' || mode === 'solo-pnj') ? 'none' : 'flex';
|
||
}
|
||
|
||
function loadStressResults() {
|
||
var area = document.getElementById('stress_results');
|
||
area.innerHTML = '<p style="color:var(--muted);padding:8px 0">Chargement…</p>';
|
||
fetch('/api/stress-test/results')
|
||
.then(function(r){ return r.json(); })
|
||
.then(function(data) {
|
||
if (data.error) {
|
||
area.innerHTML = '<p style="color:var(--red);padding:8px 0">' + data.error + '</p>';
|
||
return;
|
||
}
|
||
renderStressResults(area, data);
|
||
})
|
||
.catch(function(e) {
|
||
area.innerHTML = '<p style="color:var(--red);padding:8px 0">Erreur réseau : ' + e + '</p>';
|
||
});
|
||
}
|
||
|
||
function renderStressResults(area, data) {
|
||
var html = '<div class="results-meta">Fichier du ' + (data.file_date || '?') + '</div>';
|
||
html += '<table><tr><th>Modèle</th><th>Appels</th><th>Erreurs</th><th>Moy tok/s</th><th>Min tok/s</th><th>Max tok/s</th><th>Moy durée</th></tr>';
|
||
|
||
var models = data.models || {};
|
||
for (var model in models) {
|
||
var m = models[model];
|
||
var avgTok = parseFloat(m.avg_tok_s || 0);
|
||
var minTok = parseFloat(m.min_tok_s || 0);
|
||
var maxTok = parseFloat(m.max_tok_s || 0);
|
||
var avgDur = parseFloat(m.avg_duration || 0);
|
||
var calls = m.calls || 0;
|
||
var errors = m.errors || 0;
|
||
|
||
var threshold = model.indexOf('14b') !== -1 ? 8 : 15;
|
||
var diagClass = avgTok >= threshold ? 'diag-good' : 'diag-bad';
|
||
|
||
html += '<tr>';
|
||
html += '<td class="' + diagClass + '">' + model + '</td>';
|
||
html += '<td>' + calls + '</td>';
|
||
html += '<td style="color:' + (errors > 0 ? 'var(--red)' : 'var(--muted)') + '">' + errors + '</td>';
|
||
html += '<td class="' + diagClass + '">' + avgTok.toFixed(1) + '</td>';
|
||
html += '<td style="color:var(--muted)">' + minTok.toFixed(1) + '</td>';
|
||
html += '<td style="color:var(--muted)">' + maxTok.toFixed(1) + '</td>';
|
||
html += '<td style="color:var(--muted)">' + avgDur.toFixed(2) + 's</td>';
|
||
html += '</tr>';
|
||
}
|
||
html += '</table>';
|
||
|
||
if (data.pipeline) {
|
||
var p = data.pipeline;
|
||
html += '<div style="margin-top:12px"><strong style="color:var(--amber)">Pipeline (décalé)</strong></div>';
|
||
html += '<table><tr><th>Latence moy</th><th>Min</th><th>Max</th></tr>';
|
||
html += '<tr><td>' + (p.avg_latency || '—') + '</td><td>' + (p.min_latency || '—') + '</td><td>' + (p.max_latency || '—') + '</td></tr>';
|
||
html += '</table>';
|
||
}
|
||
|
||
area.innerHTML = html;
|
||
}
|
||
</script>
|
||
|
||
<!-- Section 1: Lancer une simulation -->
|
||
<div class="tools-section">
|
||
<h2>🚀 Lancer une simulation</h2>
|
||
<div class="tool-row">
|
||
<label>Session</label>
|
||
<div class="radio-group">
|
||
{% for s in all_sessions %}
|
||
<label>
|
||
<input type="radio" name="sim_sid" value="{{ s.id }}" {{ 'checked' if s.id == current_sid else '' }}>
|
||
<span>S{{ s.id }}</span>
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
<div class="tool-row">
|
||
<label>Mode</label>
|
||
<select id="sim_mode">
|
||
<option value="pacifiste">pacifiste</option>
|
||
<option value="politique">politique</option>
|
||
<option value="guerre_commerciale">guerre_commerciale</option>
|
||
<option value="guerre">guerre</option>
|
||
<option value="survie_extreme">survie_extreme</option>
|
||
</select>
|
||
</div>
|
||
<div class="tool-row">
|
||
<label>Tick sleep</label>
|
||
<div class="radio-group">
|
||
<label><input type="radio" name="sim_sleep_r" value="1" id="sl1" onchange="document.getElementById('sim_sleep').value=this.value"><span>1s</span></label>
|
||
<label><input type="radio" name="sim_sleep_r" value="5" id="sl5" onchange="document.getElementById('sim_sleep').value=this.value"><span>5s</span></label>
|
||
<label><input type="radio" name="sim_sleep_r" value="30" id="sl30" onchange="document.getElementById('sim_sleep').value=this.value"><span>30s</span></label>
|
||
<label><input type="radio" name="sim_sleep_r" value="60" id="sl60" checked onchange="document.getElementById('sim_sleep').value=this.value"><span>60s</span></label>
|
||
</div>
|
||
<input type="hidden" id="sim_sleep" value="60">
|
||
</div>
|
||
<div class="tool-row">
|
||
<button class="btn btn-amber" onclick="genSimCmd()">Générer commande</button>
|
||
</div>
|
||
<div class="cmd-output" id="sim_cmd_out">
|
||
<div class="code-block">
|
||
<pre id="sim_cmd_text"></pre><button class="copy-btn" onclick="copyText('sim_cmd_text')">Copier</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 2: Lancer un enrichissement lore -->
|
||
<div class="tools-section">
|
||
<h2>📚 Lancer un enrichissement lore</h2>
|
||
<div class="tool-row">
|
||
<label>Faction</label>
|
||
<select id="lore_faction">
|
||
<option value="all">all</option>
|
||
<option value="union">union</option>
|
||
<option value="cda">cda</option>
|
||
<option value="ecumeurs">ecumeurs</option>
|
||
<option value="grand_krewe">grand_krewe</option>
|
||
<option value="dynaste_oak">dynaste_oak</option>
|
||
<option value="regie">regie</option>
|
||
<option value="syndicat_capitole">syndicat_capitole</option>
|
||
<option value="consortium">consortium</option>
|
||
</select>
|
||
</div>
|
||
<div class="tool-row">
|
||
<button class="btn btn-amber" onclick="genLoreCmd()">Générer commande</button>
|
||
</div>
|
||
<div class="cmd-output" id="lore_cmd_out">
|
||
<div class="code-block">
|
||
<pre id="lore_cmd_text"></pre><button class="copy-btn" onclick="copyText('lore_cmd_text')">Copier</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Section 3: Stress Test LLM -->
|
||
<div class="tools-section">
|
||
<h2>⚡ Stress Test LLM</h2>
|
||
<div class="tool-row">
|
||
<label>Mode</label>
|
||
<div class="radio-group">
|
||
<label><input type="radio" name="stress_mode" value="simultane" checked onchange="togglePnjRow()"><span>simultane</span></label>
|
||
<label><input type="radio" name="stress_mode" value="decale" onchange="togglePnjRow()"><span>decale</span></label>
|
||
<label><input type="radio" name="stress_mode" value="solo-mj" onchange="togglePnjRow()"><span>solo-mj</span></label>
|
||
<label><input type="radio" name="stress_mode" value="solo-pnj" onchange="togglePnjRow()"><span>solo-pnj</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="tool-row">
|
||
<label>Rounds</label>
|
||
<div class="radio-group">
|
||
<label><input type="radio" name="stress_rounds" value="3" checked><span>3</span></label>
|
||
<label><input type="radio" name="stress_rounds" value="5"><span>5</span></label>
|
||
<label><input type="radio" name="stress_rounds" value="10"><span>10</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="tool-row" id="stress_pnj_row">
|
||
<label>PNJ count</label>
|
||
<div class="radio-group">
|
||
<label><input type="radio" name="stress_pnj" value="1" checked><span>1</span></label>
|
||
<label><input type="radio" name="stress_pnj" value="2"><span>2</span></label>
|
||
<label><input type="radio" name="stress_pnj" value="3"><span>3</span></label>
|
||
</div>
|
||
</div>
|
||
<div class="tool-row" style="gap:8px;flex-wrap:wrap">
|
||
<button class="btn btn-green" id="btn-run-stress" onclick="runStress()">▶ Exécuter maintenant</button>
|
||
<button class="btn btn-amber" onclick="genStressCmd()">Voir commande SSH</button>
|
||
<button class="btn" onclick="loadStressResults()">Charger derniers résultats</button>
|
||
<button class="btn btn-red" id="btn-stop-stress" style="display:none" onclick="stopPoll()">■ Arrêter le suivi</button>
|
||
</div>
|
||
<div id="stress_status_bar" style="display:none;margin-top:8px;padding:6px 10px;background:#0d1117;border:1px solid var(--border);border-radius:4px;font-size:13px">
|
||
<span id="stress_status_text" style="color:var(--amber)">En cours…</span>
|
||
<span id="stress_spinner" style="margin-left:8px">⏳</span>
|
||
</div>
|
||
<div class="cmd-output" id="stress_cmd_out" style="display:none">
|
||
<div class="code-block">
|
||
<pre id="stress_cmd_text"></pre><button class="copy-btn" onclick="copyText('stress_cmd_text')">Copier</button>
|
||
</div>
|
||
</div>
|
||
<!-- Console live -->
|
||
<div id="stress_console_wrap" style="display:none;margin-top:10px">
|
||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||
<span style="font-size:12px;font-weight:600;color:var(--dim)">SORTIE EN DIRECT</span>
|
||
<button class="btn" style="padding:2px 8px;font-size:11px" onclick="document.getElementById('stress_console').innerHTML=''">Effacer</button>
|
||
</div>
|
||
<pre id="stress_console" style="background:#020408;border:1px solid var(--border);padding:10px;max-height:320px;overflow-y:auto;font-size:12px;color:#a8d5a2;white-space:pre-wrap;border-radius:4px"></pre>
|
||
</div>
|
||
<!-- Résultats structurés -->
|
||
<div class="results-area" id="stress_results"></div>
|
||
</div>
|
||
|
||
<script>
|
||
var _stressPollTimer = null;
|
||
var _stressLastLine = 0;
|
||
|
||
function runStress() {
|
||
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
|
||
var pnj = 2;
|
||
if (mode !== 'solo-mj' && mode !== 'solo-pnj') {
|
||
pnj = parseInt(document.querySelector('input[name="stress_pnj"]:checked').value);
|
||
}
|
||
fetch('/api/stress-test/run', {
|
||
method: 'POST',
|
||
headers: {'Content-Type':'application/json'},
|
||
body: JSON.stringify({mode:mode, rounds:parseInt(rounds), pnj_count:pnj})
|
||
}).then(function(r){ return r.json(); }).then(function(d) {
|
||
if (d.error) {
|
||
alert(d.error); return;
|
||
}
|
||
_stressLastLine = 0;
|
||
document.getElementById('stress_console').textContent = '';
|
||
document.getElementById('stress_console_wrap').style.display = 'block';
|
||
document.getElementById('stress_status_bar').style.display = 'block';
|
||
document.getElementById('stress_status_text').textContent = 'Test ' + mode + ' en cours (' + rounds + ' rounds)…';
|
||
document.getElementById('btn-run-stress').disabled = true;
|
||
document.getElementById('btn-stop-stress').style.display = 'inline-block';
|
||
document.getElementById('stress_results').innerHTML = '';
|
||
startPoll();
|
||
});
|
||
}
|
||
|
||
function startPoll() {
|
||
_stressPollTimer = setInterval(function() {
|
||
fetch('/api/stress-test/status').then(function(r){ return r.json(); }).then(function(d) {
|
||
var cons = document.getElementById('stress_console');
|
||
var lines = d.output || [];
|
||
if (lines.length > _stressLastLine) {
|
||
var newLines = lines.slice(_stressLastLine);
|
||
cons.textContent += newLines.join('\n') + '\n';
|
||
cons.scrollTop = cons.scrollHeight;
|
||
_stressLastLine = lines.length;
|
||
}
|
||
if (!d.running) {
|
||
clearInterval(_stressPollTimer);
|
||
document.getElementById('stress_status_text').textContent =
|
||
d.returncode === 0 ? '✓ Terminé avec succès' : '✗ Erreur (code ' + d.returncode + ')';
|
||
document.getElementById('stress_spinner').textContent = d.returncode === 0 ? '' : '⚠';
|
||
document.getElementById('btn-run-stress').disabled = false;
|
||
document.getElementById('btn-stop-stress').style.display = 'none';
|
||
if (d.returncode === 0) { loadStressResults(); }
|
||
}
|
||
});
|
||
}, 1500);
|
||
}
|
||
|
||
function stopPoll() {
|
||
if (_stressPollTimer) clearInterval(_stressPollTimer);
|
||
document.getElementById('btn-run-stress').disabled = false;
|
||
document.getElementById('btn-stop-stress').style.display = 'none';
|
||
}
|
||
</script>
|
||
|
||
{% elif tab == 'lore' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET LORE -->
|
||
<!-- ===================================================== -->
|
||
|
||
<div class="info-bar">
|
||
<span style="font-weight:600">📜 Enrichissement Lore — Venice of Wasteland</span>
|
||
<div class="lore-stat-bar" style="margin:0">
|
||
<span class="lore-stat">En attente : <span style="color:var(--amber)">{{ lore_stats.pending }}</span></span>
|
||
<span class="lore-stat">Acceptées : <span style="color:var(--green)">{{ lore_stats.accepted }}</span></span>
|
||
<span class="lore-stat">Rejetées : <span style="color:var(--red)">{{ lore_stats.rejected }}</span></span>
|
||
<span class="lore-stat">Modifiées : <span style="color:var(--blue)">{{ lore_stats.modified }}</span></span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="lore-filters">
|
||
<label>Faction :</label>
|
||
<select onchange="location.href='/lore?faction='+this.value+'&status='+document.getElementById('filt-status').value">
|
||
<option value="" {{ 'selected' if not filt_faction else '' }}>— toutes —</option>
|
||
{% for f in all_factions %}
|
||
<option value="{{ f }}" {{ 'selected' if filt_faction==f else '' }}>{{ f }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Statut :</label>
|
||
<select id="filt-status" onchange="location.href='/lore?faction='+document.querySelector('[onchange]').value+'&status='+this.value">
|
||
<option value="pending" {{ 'selected' if filt_status=='pending' else '' }}>pending</option>
|
||
<option value="accepted" {{ 'selected' if filt_status=='accepted' else '' }}>accepted</option>
|
||
<option value="rejected" {{ 'selected' if filt_status=='rejected' else '' }}>rejected</option>
|
||
<option value="modified" {{ 'selected' if filt_status=='modified' else '' }}>modified</option>
|
||
<option value="" {{ 'selected' if not filt_status else '' }}>— tous —</option>
|
||
</select>
|
||
<span style="font-size:12px;color:var(--dim)">{{ proposals|length }} proposition(s) affichée(s)</span>
|
||
</div>
|
||
|
||
{% if not proposals %}
|
||
<div class="panel" style="color:var(--dim);text-align:center;padding:24px">
|
||
Aucune proposition lore.<br>
|
||
<small style="color:var(--border)">Lancer : <code>python3 lore_enricher.py --faction grand_krewe</code> sur Ampère</small>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% for p in proposals %}
|
||
<div class="lore-card">
|
||
<div class="lore-meta">
|
||
<span>#{{ p.id }}</span>
|
||
<span class="badge-{{ p.status }}">{{ p.status | upper }}</span>
|
||
<span class="change-type-badge">{{ p.change_type }}</span>
|
||
<span style="color:var(--amber)">{{ p.faction_slug }}</span>
|
||
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
|
||
<span style="color:var(--dim)">{{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}</span>
|
||
</div>
|
||
|
||
<h3>{{ p.rationale or '(pas de justification)' }}</h3>
|
||
|
||
<div class="lore-diff">
|
||
<div>
|
||
<div class="diff-label">Original</div>
|
||
<div class="diff-old">{{ p.original_text or '(ajout — pas de texte original)' }}</div>
|
||
</div>
|
||
<div>
|
||
<div class="diff-label">Proposé</div>
|
||
<div class="diff-new">{{ p.proposed_text }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if p.status == 'pending' %}
|
||
<div class="lore-actions">
|
||
<form method="POST" action="/lore/action" style="display:inline">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="accept">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<button type="submit" class="btn btn-green" onclick="return confirm('Accepter cette proposition ?')">✓ Accepter</button>
|
||
</form>
|
||
<form method="POST" action="/lore/action" style="display:inline">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="reject">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<button type="submit" class="btn btn-red">✗ Rejeter</button>
|
||
</form>
|
||
<form method="POST" action="/lore/action" style="width:100%;margin-top:6px">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="modify">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<textarea name="modified_text" placeholder="Texte modifié (sera utilisé à la place du proposé)...">{{ p.proposed_text }}</textarea>
|
||
<button type="submit" class="btn" style="margin-top:6px">✎ Modifier & Accepter</button>
|
||
</form>
|
||
</div>
|
||
{% elif p.status == 'modified' and p.modified_text %}
|
||
<div style="margin-top:8px">
|
||
<div class="diff-label">Texte modifié accepté</div>
|
||
<div class="diff-new" style="border-color:rgba(96,165,250,.3)">{{ p.modified_text }}</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
|
||
{% elif tab == 'params' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET PARAMÈTRES -->
|
||
<!-- ===================================================== -->
|
||
|
||
{% if session %}
|
||
<div class="info-bar">
|
||
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
|
||
<span class="mode-tag">{{ session.mode | upper }}</span>
|
||
{% set st = session.status %}
|
||
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
|
||
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
|
||
{{ st | upper }}
|
||
</span>
|
||
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> / <span class="label">Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="grid">
|
||
|
||
<!-- Config JSON éditable -->
|
||
<div class="panel">
|
||
<h2>⚙ Config sim_{{ '%03d' % current_sid }}.json</h2>
|
||
<form method="POST" action="/params/save?sid={{ current_sid }}">
|
||
{% if cfg is not none %}
|
||
|
||
<div class="cfg-block">
|
||
<h3>Général</h3>
|
||
<div class="cfg-row">
|
||
<label>Nom de la session</label>
|
||
<input type="text" name="name" value="{{ cfg.get('name','') }}" style="width:220px">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Mode actif</label>
|
||
<select name="mode">
|
||
{% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
|
||
<option value="{{ m }}" {{ 'selected' if cfg.get('mode')==m else '' }}>{{ m }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Tick sleep (secondes)</label>
|
||
<input type="number" name="tick_sleep_sec" value="{{ cfg.get('tick',{}).get('tick_sleep_sec',60) }}" step="1" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Survie</h3>
|
||
<div class="cfg-row">
|
||
<label>drain_speed (multiplicateur)</label>
|
||
<input type="number" name="survival_drain_speed" value="{{ cfg.get('survival',{}).get('drain_speed',1.0) }}" step="0.1" min="0.1" max="5">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>fatigue_hp_per_2pts</label>
|
||
<input type="number" name="survival_fatigue_hp" value="{{ cfg.get('survival',{}).get('fatigue_hp_per_2pts',2) }}" step="1" min="0">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Survie activée</label>
|
||
<input type="checkbox" name="survival_enabled" {{ 'checked' if cfg.get('survival',{}).get('enabled',True) else '' }}>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Rencontres</h3>
|
||
<div class="cfg-row">
|
||
<label>global_rate_multiplier</label>
|
||
<input type="number" name="encounter_rate" value="{{ cfg.get('encounter',{}).get('global_rate_multiplier',1.0) }}" step="0.1" min="0" max="5">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Rencontres activées</label>
|
||
<input type="checkbox" name="encounter_enabled" {{ 'checked' if cfg.get('encounter',{}).get('enabled',True) else '' }}>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Économie</h3>
|
||
<div class="cfg-row">
|
||
<label>caps_global_multiplier</label>
|
||
<input type="number" name="caps_multiplier" value="{{ cfg.get('economy',{}).get('caps_global_multiplier',1.0) }}" step="0.1" min="0.1">
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-green">💾 Sauvegarder config</button>
|
||
<p style="margin-top:8px;font-size:12px;color:var(--muted)">
|
||
Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 }}).
|
||
Pour effet immédiat : STOP → relancer via terminal.
|
||
</p>
|
||
{% else %}
|
||
<p style="color:var(--red);font-size:13px">
|
||
Fichier <code>sim_{{ '%03d' % current_sid }}.json</code> introuvable dans <code>{{ sim_configs_dir }}</code>
|
||
</p>
|
||
<p style="color:var(--muted);font-size:12px;margin-top:6px">
|
||
Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config
|
||
</p>
|
||
{% endif %}
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Actions session -->
|
||
<div class="panel">
|
||
<h2>🔧 Gestion session #{{ current_sid }}</h2>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Statut DB</h3>
|
||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="active">
|
||
<button type="submit" class="btn btn-green">▶ Activer</button>
|
||
</form>
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="paused">
|
||
<button type="submit" class="btn">⏸ Pause</button>
|
||
</form>
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="stopped">
|
||
<button type="submit" class="btn btn-red">■ Stop</button>
|
||
</form>
|
||
</div>
|
||
<p style="margin-top:8px;font-size:12px;color:var(--muted)">
|
||
STOP → la sim s'arrête dans les 5 ticks suivants. Puis RESET JOUR/TICK → relancer via terminal.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Reset jour/tick</h3>
|
||
<form method="POST" action="/params/resetday?sid={{ current_sid }}">
|
||
<div class="cfg-row">
|
||
<label>Remettre au Jour</label>
|
||
<input type="number" name="day" value="1" min="1">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Tick</label>
|
||
<input type="number" name="tick" value="0" min="0" max="23">
|
||
</div>
|
||
<button type="submit" class="btn">⏮ Reset position</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Modes disponibles</h3>
|
||
<table>
|
||
<tr><th>Mode</th><th>Drain</th><th>Encounters</th><th>Caps</th></tr>
|
||
{% for m_name, m_vals in modes.items() %}
|
||
<tr>
|
||
<td class="{{ 'tier1-text' if m_name == cfg.get('mode','') else '' }}">
|
||
{{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}</td>
|
||
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('survival',{}).get('drain_speed','—') }}</td>
|
||
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}</td>
|
||
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>Commandes SSH (référence)</h3>
|
||
<p style="font-size:12px;color:var(--muted);margin-bottom:8px">Copier-coller dans votre terminal SSH. Pour les commandes générées, utiliser l'onglet OUTILS.</p>
|
||
<div class="code-block">
|
||
<pre id="ssh_logs_cmd">tail -f ~/fallout_sim_s{{ current_sid }}.log</pre><button class="copy-btn" onclick="copyText('ssh_logs_cmd')">Copier</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /grid -->
|
||
|
||
<script>
|
||
function copyText(elementId) {
|
||
var el = document.getElementById(elementId);
|
||
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
|
||
var btn = el.nextElementSibling;
|
||
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
|
||
});
|
||
}
|
||
</script>
|
||
|
||
{% endif %}
|
||
|
||
</div><!-- /main -->
|
||
|
||
<div class="footer">PipBoy v5.0 | SIM LIBRE • OUTILS • ENRICHISSEMENT • PARAMÈTRES</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Helpers data
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def get_sim_data(sid: int) -> dict:
|
||
session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
|
||
if not session:
|
||
session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
|
||
|
||
characters = query("""
|
||
SELECT c.*, COALESCE(c.tier, 1) as tier
|
||
FROM characters c
|
||
WHERE c.session_id = %s
|
||
ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name
|
||
""", (sid,))
|
||
alive_count = sum(1 for c in characters if c["is_alive"])
|
||
|
||
events = query("""
|
||
SELECT e.*, c.name as actor_name FROM events e
|
||
LEFT JOIN characters c ON c.id = e.actor_id
|
||
WHERE e.session_id = %s
|
||
AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss','cycle_tick')
|
||
ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 50
|
||
""", (sid,))
|
||
|
||
encounters = query("""
|
||
SELECT e.*, c.name as actor_name FROM events e
|
||
LEFT JOIN characters c ON c.id = e.actor_id
|
||
WHERE e.session_id = %s AND e.event_type LIKE 'encounter%%'
|
||
ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 30
|
||
""", (sid,))
|
||
|
||
world_state = query("""
|
||
SELECT DISTINCT ON (location_slug) *
|
||
FROM world_state WHERE session_id = %s
|
||
ORDER BY location_slug, day DESC
|
||
""", (sid,))
|
||
|
||
faction_relations = query("""
|
||
SELECT * FROM faction_relations WHERE session_id = %s
|
||
ORDER BY relation_score ASC
|
||
""", (sid,))
|
||
|
||
tier_labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"}
|
||
tier_raw = query("""
|
||
SELECT COALESCE(tier,1) as tier,
|
||
COUNT(*) FILTER (WHERE is_alive) as alive,
|
||
COUNT(*) FILTER (WHERE NOT is_alive) as dead
|
||
FROM characters WHERE session_id = %s
|
||
GROUP BY COALESCE(tier,1) ORDER BY COALESCE(tier,1)
|
||
""", (sid,))
|
||
tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"),
|
||
"alive": r["alive"], "dead": r["dead"]} for r in tier_raw]
|
||
|
||
zone_stats = query("""
|
||
SELECT location_slug, COUNT(*) as cnt FROM characters
|
||
WHERE session_id = %s AND is_alive = TRUE
|
||
GROUP BY location_slug ORDER BY cnt DESC LIMIT 5
|
||
""", (sid,))
|
||
|
||
ev_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,))
|
||
cb_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,))
|
||
dt_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (sid,))
|
||
enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,))
|
||
|
||
stats = {
|
||
"Événements total": ev_total["n"] if ev_total else 0,
|
||
"Combats": cb_total["n"] if cb_total else 0,
|
||
"Morts": dt_total["n"] if dt_total else 0,
|
||
"Rencontres": enc_total["n"] if enc_total else 0,
|
||
"PNJ vivants": alive_count,
|
||
"PNJ morts": len(characters) - alive_count,
|
||
}
|
||
|
||
boss_inventory = query("""
|
||
SELECT c.name as char_name, i.item_name, i.item_type, i.quantity, i.is_equipped
|
||
FROM inventory i
|
||
JOIN characters c ON c.id = i.character_id
|
||
WHERE c.session_id = %s AND COALESCE(c.tier,1) = 0
|
||
ORDER BY c.name, i.is_equipped DESC
|
||
""", (sid,))
|
||
|
||
return dict(
|
||
session=session,
|
||
characters=characters,
|
||
alive_count=alive_count,
|
||
total_count=len(characters),
|
||
events=events,
|
||
encounters=encounters,
|
||
world_state=world_state,
|
||
faction_relations=faction_relations,
|
||
tier_stats=tier_stats,
|
||
zone_stats=zone_stats,
|
||
stats=stats,
|
||
boss_inventory=boss_inventory,
|
||
)
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Routes
|
||
# ---------------------------------------------------------------------------
|
||
|
||
@app.route("/")
|
||
def index():
|
||
all_sessions = get_all_sessions()
|
||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
||
data = get_sim_data(sid)
|
||
return render_template_string(TEMPLATE,
|
||
tab="sim",
|
||
current_sid=sid,
|
||
all_sessions=all_sessions,
|
||
flash_msg=None,
|
||
flash_ok=True,
|
||
modes={},
|
||
cfg={},
|
||
sim_configs_dir=SIM_CONFIGS_DIR,
|
||
**data,
|
||
)
|
||
|
||
@app.route("/tools")
|
||
def tools():
|
||
all_sessions = get_all_sessions()
|
||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
||
session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
|
||
return render_template_string(TEMPLATE,
|
||
tab="tools",
|
||
current_sid=sid,
|
||
all_sessions=all_sessions,
|
||
session=session,
|
||
flash_msg=None,
|
||
flash_ok=True,
|
||
cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR,
|
||
proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0},
|
||
all_factions=[], filt_faction="", filt_status="",
|
||
characters=[], alive_count=0, total_count=0,
|
||
events=[], encounters=[], world_state=[],
|
||
faction_relations=[], tier_stats=[], zone_stats=[],
|
||
stats={}, boss_inventory=[],
|
||
)
|
||
|
||
@app.route("/api/stress-test/results")
|
||
def api_stress_test_results():
|
||
path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
|
||
if not os.path.exists(path):
|
||
return jsonify({"error": "Aucun résultat. Lancez d'abord le test."})
|
||
try:
|
||
import datetime
|
||
mtime = os.path.getmtime(path)
|
||
file_date = datetime.datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
|
||
with open(path) as f:
|
||
data = json.load(f)
|
||
data["file_date"] = file_date
|
||
return jsonify(data)
|
||
except Exception as e:
|
||
return jsonify({"error": f"Erreur lecture : {e}"})
|
||
|
||
@app.route("/api/stress-test/run", methods=["POST"])
|
||
def api_stress_test_run():
|
||
global _stress_state
|
||
with _stress_lock:
|
||
if _stress_state["running"]:
|
||
return jsonify({"error": "Un test est déjà en cours.", "running": True})
|
||
mode = request.json.get("mode", "simultane")
|
||
rounds = int(request.json.get("rounds", 5))
|
||
pnj_count = int(request.json.get("pnj_count", 2))
|
||
_stress_state = {
|
||
"running": True,
|
||
"started_at": time.strftime("%H:%M:%S"),
|
||
"mode": mode,
|
||
"rounds": rounds,
|
||
"pnj_count": pnj_count,
|
||
"output": [],
|
||
"returncode": None,
|
||
"error": None,
|
||
}
|
||
t = threading.Thread(target=_run_stress_bg, args=(mode, rounds, pnj_count), daemon=True)
|
||
t.start()
|
||
return jsonify({"ok": True, "message": f"Test '{mode}' {rounds} rounds lancé."})
|
||
|
||
@app.route("/api/stress-test/status")
|
||
def api_stress_test_status():
|
||
with _stress_lock:
|
||
return jsonify({
|
||
"running": _stress_state["running"],
|
||
"started_at": _stress_state["started_at"],
|
||
"mode": _stress_state["mode"],
|
||
"rounds": _stress_state["rounds"],
|
||
"output": _stress_state["output"][-60:], # 60 dernières lignes
|
||
"returncode": _stress_state["returncode"],
|
||
"error": _stress_state["error"],
|
||
})
|
||
|
||
@app.route("/params")
|
||
def params():
|
||
all_sessions = get_all_sessions()
|
||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
||
session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
|
||
cfg = load_sim_config(sid)
|
||
modes = load_all_modes() # toujours les 5 modes depuis sim_001 (référence)
|
||
flash_msg = request.args.get("msg")
|
||
flash_ok = request.args.get("ok", "1") == "1"
|
||
return render_template_string(TEMPLATE,
|
||
tab="params",
|
||
current_sid=sid,
|
||
all_sessions=all_sessions,
|
||
session=session,
|
||
cfg=cfg,
|
||
modes=modes,
|
||
sim_configs_dir=SIM_CONFIGS_DIR,
|
||
flash_msg=flash_msg,
|
||
flash_ok=flash_ok,
|
||
# unused in params tab but required by template
|
||
proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0},
|
||
all_factions=[], filt_faction="", filt_status="",
|
||
characters=[], alive_count=0, total_count=0,
|
||
events=[], encounters=[], world_state=[],
|
||
faction_relations=[], tier_stats=[], zone_stats=[],
|
||
stats={}, boss_inventory=[],
|
||
)
|
||
|
||
@app.route("/params/save", methods=["POST"])
|
||
def params_save():
|
||
sid = int(request.args.get("sid", 1))
|
||
cfg = load_sim_config(sid)
|
||
if cfg is None:
|
||
return redirect(url_for("params", sid=sid, msg=f"Fichier sim_{sid:03d}.json introuvable dans {SIM_CONFIGS_DIR}", ok=0))
|
||
|
||
f = request.form
|
||
|
||
cfg["name"] = f.get("name", cfg.get("name", ""))
|
||
cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste"))
|
||
|
||
cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60))
|
||
|
||
cfg.setdefault("survival", {}).update({
|
||
"drain_speed": float(f.get("survival_drain_speed", 1.0)),
|
||
"fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)),
|
||
"enabled": "survival_enabled" in f,
|
||
})
|
||
|
||
cfg.setdefault("encounter", {}).update({
|
||
"global_rate_multiplier": float(f.get("encounter_rate", 1.0)),
|
||
"enabled": "encounter_enabled" in f,
|
||
})
|
||
|
||
cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0))
|
||
|
||
try:
|
||
save_sim_config(sid, cfg)
|
||
execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid))
|
||
msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}."
|
||
ok = 1
|
||
except Exception as e:
|
||
msg = f"Erreur: {e}"
|
||
ok = 0
|
||
|
||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
||
|
||
@app.route("/params/setstatus", methods=["POST"])
|
||
def params_setstatus():
|
||
sid = int(request.args.get("sid", 1))
|
||
status = request.form.get("status", "paused")
|
||
try:
|
||
execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid))
|
||
msg = f"Session {sid} → status '{status}'. Le process run.py sortira au prochain tick si arrêté."
|
||
ok = 1
|
||
except Exception as e:
|
||
msg = f"Erreur: {e}"
|
||
ok = 0
|
||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
||
|
||
@app.route("/params/resetday", methods=["POST"])
|
||
def params_resetday():
|
||
sid = int(request.args.get("sid", 1))
|
||
day = int(request.form.get("day", 1))
|
||
tick = int(request.form.get("tick", 0))
|
||
try:
|
||
execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid))
|
||
msg = f"Session {sid} remise à J{day} T{tick}h."
|
||
ok = 1
|
||
except Exception as e:
|
||
msg = f"Erreur: {e}"
|
||
ok = 0
|
||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
||
|
||
CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
|
||
CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
|
||
CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
|
||
|
||
def _chroma_post(path: str, body: dict):
|
||
import urllib.request
|
||
data = __import__("json").dumps(body).encode()
|
||
req = urllib.request.Request(f"{CHROMA_BASE}{path}", data=data, method="POST",
|
||
headers={"Content-Type": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=10) as r:
|
||
return __import__("json").loads(r.read())
|
||
except Exception:
|
||
return {}
|
||
|
||
def _chroma_get_enriched_col_id():
|
||
import urllib.request
|
||
try:
|
||
with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r:
|
||
cols = __import__("json").loads(r.read())
|
||
for c in cols:
|
||
if c["name"] == CHROMA_COL_OUT:
|
||
return c["id"]
|
||
except Exception:
|
||
pass
|
||
# Créer la collection si absente
|
||
res = _chroma_post("/collections", {"name": CHROMA_COL_OUT, "metadata": {"hnsw:space": "cosine"}})
|
||
return res.get("id")
|
||
|
||
def _upsert_to_chroma_enriched(proposal: dict):
|
||
col_id = _chroma_get_enriched_col_id()
|
||
if not col_id:
|
||
return
|
||
text = proposal.get("modified_text") or proposal.get("proposed_text", "")
|
||
doc_id = f"lore_proposal_{proposal['id']}"
|
||
_chroma_post(f"/collections/{col_id}/upsert", {
|
||
"ids": [doc_id],
|
||
"documents": [text],
|
||
"metadatas": [{
|
||
"faction_slug": proposal.get("faction_slug", ""),
|
||
"change_type": proposal.get("change_type", ""),
|
||
"field_path": proposal.get("field_path") or "",
|
||
"category": "lore_enriched",
|
||
"source": f"lore_proposal_{proposal['id']}",
|
||
}],
|
||
})
|
||
|
||
def get_lore_proposals(faction: str = "", status: str = "pending") -> list[dict]:
|
||
sql = "SELECT * FROM lore_proposals WHERE 1=1"
|
||
params = []
|
||
if faction:
|
||
sql += " AND faction_slug = %s"
|
||
params.append(faction)
|
||
if status:
|
||
sql += " AND status = %s"
|
||
params.append(status)
|
||
sql += " ORDER BY id DESC LIMIT 100"
|
||
return query(sql, params or None)
|
||
|
||
def get_lore_stats() -> dict:
|
||
rows = query("SELECT status, COUNT(*) as n FROM lore_proposals GROUP BY status")
|
||
m = {r["status"]: r["n"] for r in rows}
|
||
return {"pending": m.get("pending",0), "accepted": m.get("accepted",0),
|
||
"rejected": m.get("rejected",0), "modified": m.get("modified",0)}
|
||
|
||
def get_all_factions() -> list[str]:
|
||
rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug")
|
||
return [r["faction_slug"] for r in rows]
|
||
|
||
@app.route("/lore")
|
||
def lore():
|
||
all_sessions = get_all_sessions()
|
||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
||
session = query_one("SELECT * FROM sessions WHERE id = %s", (sid,))
|
||
filt_faction = request.args.get("faction", "")
|
||
filt_status = request.args.get("status", "pending")
|
||
proposals = get_lore_proposals(filt_faction, filt_status)
|
||
flash_msg = request.args.get("msg")
|
||
flash_ok = request.args.get("ok", "1") == "1"
|
||
return render_template_string(TEMPLATE,
|
||
tab="lore",
|
||
current_sid=sid,
|
||
all_sessions=all_sessions,
|
||
session=session,
|
||
proposals=proposals,
|
||
lore_stats=get_lore_stats(),
|
||
all_factions=get_all_factions(),
|
||
filt_faction=filt_faction,
|
||
filt_status=filt_status,
|
||
flash_msg=flash_msg,
|
||
flash_ok=flash_ok,
|
||
cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR,
|
||
characters=[], alive_count=0, total_count=0,
|
||
events=[], encounters=[], world_state=[],
|
||
faction_relations=[], tier_stats=[], zone_stats=[],
|
||
stats={}, boss_inventory=[],
|
||
)
|
||
|
||
@app.route("/lore/action", methods=["POST"])
|
||
def lore_action():
|
||
prop_id = int(request.form.get("id", 0))
|
||
action = request.form.get("action", "")
|
||
modified_text = request.form.get("modified_text", "").strip()
|
||
filt_faction = request.form.get("filt_faction", "")
|
||
filt_status = request.form.get("filt_status", "pending")
|
||
|
||
proposal = query_one("SELECT * FROM lore_proposals WHERE id = %s", (prop_id,))
|
||
if not proposal:
|
||
return redirect(url_for("lore", msg="Proposition introuvable", ok=0,
|
||
faction=filt_faction, status=filt_status))
|
||
try:
|
||
if action == "accept":
|
||
execute("UPDATE lore_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,))
|
||
_upsert_to_chroma_enriched({**proposal, "modified_text": None})
|
||
msg = f"Proposition #{prop_id} acceptée et indexée dans {CHROMA_COL_OUT}."
|
||
elif action == "reject":
|
||
execute("UPDATE lore_proposals SET status='rejected', reviewed_at=NOW() WHERE id=%s", (prop_id,))
|
||
msg = f"Proposition #{prop_id} rejetée."
|
||
elif action == "modify":
|
||
if not modified_text:
|
||
modified_text = proposal["proposed_text"]
|
||
execute("""UPDATE lore_proposals SET status='modified', modified_text=%s, reviewed_at=NOW()
|
||
WHERE id=%s""", (modified_text, prop_id))
|
||
_upsert_to_chroma_enriched({**proposal, "modified_text": modified_text})
|
||
msg = f"Proposition #{prop_id} modifiée et indexée dans {CHROMA_COL_OUT}."
|
||
else:
|
||
msg = "Action inconnue."
|
||
ok = 1
|
||
except Exception as e:
|
||
msg = f"Erreur: {e}"
|
||
ok = 0
|
||
|
||
return redirect(url_for("lore", msg=msg, ok=ok,
|
||
faction=filt_faction, status=filt_status))
|
||
|
||
@app.route("/api/state")
|
||
def api_state():
|
||
all_sessions = get_all_sessions()
|
||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
||
session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,))
|
||
alive = query("""
|
||
SELECT id, name, hp, max_hp, tier, faction_slug, location_slug
|
||
FROM characters WHERE session_id=%s AND is_alive=TRUE
|
||
""", (sid,))
|
||
return jsonify({"session": session, "alive_pnj": alive})
|
||
|
||
if __name__ == "__main__":
|
||
app.run(host="0.0.0.0", port=5000, debug=False)
|