Files
fallout-venice/dashboard/app.py
T
Corback 1bf0f921b6 feat: dashboard VAULTCOM v4 — onglets session + paramètres
Onglet SIMULATION:
  - Switcher de session en haut (S1 / S2 / ...) avec indicateur status vert/rouge
  - Countdown JS 30s avant auto-refresh (au lieu de meta http-equiv)
  - Filtre cycle_tick des événements (trop verbeux)

Onglet PARAMÈTRES (/params?sid=N):
  - Formulaire config: nom session, mode, tick_sleep, drain_speed, fatigue_hp,
    encounter_rate, caps_multiplier, survie/rencontres activées
  - POST /params/save → écrit sim_001.json / sim_002.json + UPDATE sessions SET mode
  - POST /params/setstatus → active/pause/stop via DB (run.py sort au prochain tick)
  - POST /params/resetday → remet current_day/current_tick en DB
  - Tableau des modes avec drain/encounter/caps pour comparaison rapide
  - Snippet de commande SSH pour relancer chaque session depuis terminal

Docker: monté /home/ubuntu/fallout-venice/src/config en rw dans le container
  → /params/save peut écrire les JSON directement
2026-06-16 00:29:15 +00:00

774 lines
31 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.
"""
VAULTCOM — Fallout: Venice of Wasteland
Dashboard simulation. Schéma DB actuel (migration_003).
Onglets: Simulation (par session) | Paramètres (config + restart)
"""
import os, json, subprocess
import psycopg2
import psycopg2.extras
from flask import Flask, render_template_string, jsonify, request, redirect, url_for
app = Flask(__name__)
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()
def load_sim_config(session_id: int) -> dict:
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:
return {}
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>VAULTCOM — Fallout: Venice of Wasteland</title>
<style>
:root {
--pip-green: #00ff41; --pip-amber: #ffb347; --pip-red: #ff4444;
--pip-blue: #44aaff; --pip-dim: #003300; --bg: #0a0a0a;
--panel: #0d1a0d; --border: #1a3d1a;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--pip-green);
font-family: 'Courier New', monospace; font-size: 13px; padding: 8px; }
h1 { font-size: 18px; letter-spacing: 4px; text-align: center; padding: 8px 0;
border-bottom: 1px solid var(--pip-green); margin-bottom: 8px; }
/* ---- NAV TABS ---- */
.nav { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.nav a { padding: 4px 14px; text-decoration: none; color: #555;
border: 1px solid #222; font-size: 12px; letter-spacing: 1px; }
.nav a.active { color: var(--pip-amber); border-color: var(--pip-amber); background: #1a1000; }
.nav a:hover:not(.active) { color: var(--pip-green); border-color: var(--border); }
.nav-sep { flex-grow: 1; }
.nav .session-btn { border-color: var(--border); }
.nav .session-btn.active { color: var(--pip-green); border-color: var(--pip-green); background: #001a00; }
/* ---- LAYOUT ---- */
.header-bar { display: flex; justify-content: space-between; align-items: center;
padding: 4px 8px; background: var(--pip-dim); border: 1px solid var(--border);
margin-bottom: 8px; font-size: 11px; color: var(--pip-amber); flex-wrap: wrap; gap: 4px; }
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-top: 10px; }
.panel { background: var(--panel); border: 1px solid var(--border); padding: 8px; }
.panel h2 { font-size: 12px; letter-spacing: 2px; color: var(--pip-amber);
border-bottom: 1px solid var(--border); padding-bottom: 4px; margin-bottom: 6px; }
table { width: 100%; border-collapse: collapse; }
th { color: var(--pip-amber); font-size: 11px; padding: 3px 4px; text-align: left;
border-bottom: 1px solid var(--border); }
td { padding: 2px 4px; font-size: 11px; border-bottom: 1px dashed #0f2a0f; vertical-align: top; }
tr:hover td { background: #0f200f; }
.tier0 { color: #ff6600; } .tier1 { color: var(--pip-green); }
.tier2 { color: var(--pip-blue); } .tier3 { color: #aaaaaa; }
.dead { color: #444; text-decoration: line-through; }
.hp-bar { display: inline-block; width: 60px; height: 8px; background: #111; border: 1px solid #333; vertical-align: middle; }
.hp-fill { height: 100%; background: var(--pip-green); }
.hp-low { background: var(--pip-amber); }
.hp-crit { background: var(--pip-red); }
.badge { display: inline-block; padding: 1px 5px; font-size: 10px; border-radius: 2px; margin: 1px; }
.badge-union { background: #1a3a5c; color: #66aaff; }
.badge-cda { background: #3a1a1a; color: #ff6666; }
.badge-ecumeurs { background: #2a1a3a; color: #cc88ff; }
.badge-syndicat { background: #3a3a1a; color: #ffee66; }
.badge-default { background: #1a1a1a; color: #888; }
.event-combat { color: var(--pip-red); }
.event-encounter { color: var(--pip-amber); }
.event-survival { color: #88cc88; }
.event-death { color: #ff0000; font-weight: bold; }
.relation-hostile { color: var(--pip-red); }
.relation-neutral { color: #888; }
.relation-allie { color: var(--pip-green); }
.scrollable { max-height: 280px; overflow-y: auto; }
.world-zone { display: flex; justify-content: space-between; padding: 3px 0;
border-bottom: 1px dashed #0f2a0f; font-size: 11px; }
.zone-name { color: var(--pip-amber); min-width: 160px; }
.level-bar { display: inline-block; width: 40px; height: 6px; background: #111;
border: 1px solid #333; vertical-align: middle; }
.level-fill { height: 100%; background: var(--pip-green); }
.stat-block { display: inline-flex; gap: 4px; }
.stat-item { font-size: 10px; color: #888; }
.stat-item span { color: var(--pip-green); }
.footer { text-align: center; font-size: 10px; color: #333; padding: 10px 0; }
.refresh-note { font-size: 10px; color: #555; }
/* ---- PARAMS TAB ---- */
.cfg-block { margin-bottom: 12px; }
.cfg-block h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px;
border-bottom: 1px solid var(--border); padding-bottom: 3px; margin-bottom: 6px; }
.cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
.cfg-row label { min-width: 220px; font-size: 11px; color: #aaa; }
.cfg-row input[type=number], .cfg-row input[type=text], .cfg-row select {
background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
padding: 2px 6px; font-family: 'Courier New', monospace; font-size: 11px; width: 120px; }
.cfg-row input[type=checkbox] { accent-color: var(--pip-green); }
.btn { padding: 5px 16px; border: 1px solid var(--pip-amber); background: #1a1000;
color: var(--pip-amber); font-family: 'Courier New', monospace; font-size: 12px;
cursor: pointer; letter-spacing: 1px; margin-top: 4px; }
.btn:hover { background: #2a2000; }
.btn-red { border-color: var(--pip-red); color: var(--pip-red); background: #1a0000; }
.btn-red:hover { background: #2a0000; }
.btn-green { border-color: var(--pip-green); color: var(--pip-green); background: #001a00; }
.btn-green:hover { background: #002a00; }
.msg-ok { color: var(--pip-green); font-size: 11px; }
.msg-err { color: var(--pip-red); font-size: 11px; }
.mode-badge { display: inline-block; padding: 2px 8px; border: 1px solid var(--pip-amber);
color: var(--pip-amber); font-size: 11px; letter-spacing: 1px; }
</style>
</head>
<body>
<h1>⚡ VAULTCOM — VENICE OF WASTELAND ⚡</h1>
<!-- ===== NAVIGATION ===== -->
<div class="nav">
{% for s in all_sessions %}
<a href="/?sid={{ s.id }}" class="session-btn {{ 'active' if s.id == current_sid else '' }}">
S{{ s.id }} — {{ s.name[:22] }}
<span style="font-size:9px;color:{{ '#00ff41' if s.status=='active' else '#ff4444' }}">
{{ s.status[:3].upper() }}
</span>
</a>
{% endfor %}
<span class="nav-sep"></span>
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' else '' }}">📡 SIMULATION</a>
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' else '' }}">⚙ PARAMÈTRES</a>
</div>
{% if flash_msg %}
<div style="padding:6px 10px; margin-bottom:8px; border:1px solid {{ '#00ff41' if flash_ok else '#ff4444' }}; color:{{ '#00ff41' if flash_ok else '#ff4444' }}; font-size:11px;">
{{ flash_msg }}
</div>
{% endif %}
{% if tab == 'sim' %}
<!-- ===================================================== -->
<!-- ONGLET SIMULATION -->
<!-- ===================================================== -->
{% if session %}
<div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
<span class="mode-badge">{{ session.mode | upper }}</span>
<span>STATUS: <strong>{{ session.status | upper }}</strong></span>
<span>SEED: {{ session.seed_global }}</span>
<span class="refresh-note">🔄 <a href="?sid={{ session.id }}" style="color:#555;text-decoration:none">Actualiser</a>
<span id="countdown"></span></span>
</div>
{% endif %}
<div class="grid">
<!-- PNJ -->
<div class="panel">
<h2>👥 PNJ ACTIFS ({{ 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 %}
<tr class="{{ 'dead' if not c.is_alive else 'tier' ~ (c.tier or 1) }}">
<td>
{% if c.tier == 0 %}<span class="tier0">★BOSS</span>
{% elif c.tier == 2 %}<span class="tier2">◈ T2</span>
{% elif c.tier == 3 %}<span class="tier3">· T3</span>
{% else %}<span class="tier1">● T1</span>{% endif %}
</td>
<td>{{ c.name }}</td>
<td>
{% if c.is_alive %}
{% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
<div class="hp-bar"><div class="hp-fill {{ 'hp-crit' if pct < 25 else 'hp-low' if pct < 50 else '' }}" style="width:{{ pct }}%"></div></div>
<small> {{ c.hp }}/{{ c.max_hp }}</small>
{% else %}†{% endif %}
</td>
<td>
{% set f = c.faction_slug or 'sans' %}
<span class="badge badge-{{ 'union' if 'union' in f else 'cda' if 'cda' in f else 'ecumeurs' if 'ecumeur' in f else 'default' }}">
{{ f[:12] }}</span>
</td>
<td><small>{{ (c.location_slug or '')[:14] }}</small></td>
<td>{{ 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 class="tier3">{{ ev.day }}/{{ ev.tick }}</td>
<td class="event-{{ ev.event_type }}"><small>{{ ev.event_type }}</small></td>
<td><small>{{ ev.description[:80] }}{% if ev.description|length > 80 %}…{% endif %}</small></td>
</tr>
{% endfor %}
{% if not events %}<tr><td colspan="3" style="color:#444;text-align:center">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="stat-block">
<span class="stat-item">🍖<div class="level-bar"><div class="level-fill" style="width:{{ ws.food_level or 0 }}%"></div></div></span>
<span class="stat-item">💧<div class="level-bar"><div class="level-fill" style="width:{{ ws.water_level or 0 }}%"></div></div></span>
<span class="stat-item">🛡<div class="level-bar"><div class="level-fill" style="width:{{ ws.security_level or 0 }}%"></div></div></span>
<span class="stat-item">👤<span>{{ ws.pop_count or 0 }}</span></span>
</span>
</div>
{% endfor %}
{% if not world_state %}<p style="color:#444;font-size:11px">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 %}
<tr>
<td><small>{{ r.faction_a[:12] }}</small></td>
<td><small>{{ r.faction_b[:12] }}</small></td>
<td class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}">
{{ r.relation_score }}</td>
<td><small class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}">
{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
</small></td>
</tr>
{% endfor %}
{% if not faction_relations %}<tr><td colspan="4" style="color:#444">Pas de données</td></tr>{% endif %}
</table>
</div>
</div>
<!-- Stats -->
<div class="panel">
<h2>📊 STATS SIMULATION</h2>
<table>
<tr><th>MÉTRIQUE</th><th>VALEUR</th></tr>
{% for k, v in stats.items() %}
<tr><td>{{ k }}</td><td class="tier1">{{ v }}</td></tr>
{% endfor %}
</table>
<br>
<h2 style="margin-top:6px">🎭 PNJ PAR TIER</h2>
<table>
<tr><th>TIER</th><th>LABEL</th><th>VIVANTS</th><th>MORTS</th></tr>
{% for row in tier_stats %}
<tr class="tier{{ row.tier }}">
<td>{{ row.tier }}</td><td>{{ row.label }}</td>
<td>{{ row.alive }}</td><td class="tier3">{{ row.dead }}</td>
</tr>
{% endfor %}
</table>
<br>
<h2 style="margin-top:6px">🏙 PNJ PAR ZONE (TOP 5)</h2>
<table>
<tr><th>ZONE</th><th>PNJ</th></tr>
{% for row in zone_stats %}
<tr><td><small>{{ row.location_slug }}</small></td><td>{{ row.cnt }}</td></tr>
{% endfor %}
</table>
</div>
</div>
<!-- Encounters -->
<div class="panel" style="margin-top:10px">
<h2>⚡ RENCONTRES RÉCENTES</h2>
<div class="scrollable" style="max-height:160px">
<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 class="tier3">{{ ev.day }}/{{ ev.tick }}</td>
<td class="event-encounter"><small>{{ ev.event_type }}</small></td>
<td><small>{{ ev.actor_name or '' }}</small></td>
<td><small>{{ (ev.location_slug or '')[:14] }}</small></td>
<td><small>{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</small></td>
</tr>
{% endfor %}
{% if not encounters %}<tr><td colspan="5" style="color:#444;text-align:center">Aucune rencontre</td></tr>{% endif %}
</table>
</div>
</div>
<!-- Boss inventory -->
{% if boss_inventory %}
<div class="panel" style="margin-top:10px">
<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">{{ row.char_name }}</td>
<td>{{ row.item_name }}</td>
<td class="tier3"><small>{{ row.item_type }}</small></td>
<td>{{ row.quantity }}</td>
<td>{{ '' if row.is_equipped else '' }}</td>
</tr>
{% endfor %}
</table>
</div>
{% endif %}
<script>
// Countdown auto-refresh 30s
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 == 'params' %}
<!-- ===================================================== -->
<!-- ONGLET PARAMÈTRES -->
<!-- ===================================================== -->
{% if session %}
<div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span class="mode-badge">{{ session.mode | upper }}</span>
<span>STATUS: <strong style="color:{{ '#00ff41' if session.status=='active' else '#ff4444' }}">{{ session.status | upper }}</strong></span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</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 %}
<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>
{% else %}
<p style="color:#ff4444;font-size:11px">Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}</p>
{% endif %}
</form>
</div>
<!-- Actions session -->
<div class="panel">
<h2>🔧 GESTION SESSION #{{ current_sid }}</h2>
<div class="cfg-block">
<h3>STATUT DB</h3>
<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>
&nbsp;
<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>
&nbsp;
<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>
<p style="margin-top:6px;font-size:10px;color:#555">
Note: ne tue pas le process Python. Mettre "paused" ou "stopped" fait sortir run.py au prochain tick.
</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' if m_name == cfg.get('mode','') else '' }}">
{{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}</td>
<td class="tier3"><small>×{{ m_vals.get('survival',{}).get('drain_speed','') }}</small></td>
<td class="tier3"><small>×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','') }}</small></td>
<td class="tier3"><small>×{{ m_vals.get('economy',{}).get('caps_global_multiplier','') }}</small></td>
</tr>
{% endfor %}
</table>
</div>
<div class="cfg-block">
<h3>COMMANDES AMPÈRE</h3>
<p style="font-size:10px;color:#666;margin-bottom:6px">Copier-coller dans votre terminal SSH :</p>
<div style="background:#001000;border:1px solid var(--border);padding:6px;font-size:10px;color:#88aa88;">
<p style="color:#555"># Lancer/relancer S{{ current_sid }} :</p>
<p>cd /home/ubuntu/fallout-venice/src/engine</p>
<p>nohup env SESSION_ID={{ current_sid }} SIM_CONFIG=../config/sim_{{ '%03d' % current_sid }}.json TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 python3 -u run.py > ~/fallout_sim_s{{ current_sid }}.log 2>&1 &</p>
<br>
<p style="color:#555"># Voir les logs :</p>
<p>tail -f ~/fallout_sim_s{{ current_sid }}.log</p>
</div>
</div>
</div>
</div><!-- /grid -->
{% endif %}
<div class="footer">VAULT-TEC VAULTCOM v4.0 | S{{ current_sid }} | J={{ session.current_day if session else '?' }} T={{ session.current_tick if session else '?' }}h</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("/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 = cfg.get("modes", {})
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
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 not cfg:
return redirect(url_for("params", sid=sid, msg="Fichier config introuvable", 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))
@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)