Files
fallout-venice/dashboard/app.py
T

1853 lines
71 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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)