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
This commit is contained in:
Corback
2026-06-16 00:29:15 +00:00
parent 62b0e25d1b
commit 1bf0f921b6
+437 -95
View File
@@ -1,12 +1,13 @@
""" """
VAULTCOM — Fallout: Venice of Wasteland VAULTCOM — Fallout: Venice of Wasteland
Dashboard simulation. Schéma DB actuel (migration_003). Dashboard simulation. Schéma DB actuel (migration_003).
Onglets: Simulation (par session) | Paramètres (config + restart)
""" """
import os, json import os, json, subprocess
import psycopg2 import psycopg2
import psycopg2.extras import psycopg2.extras
from flask import Flask, render_template_string, jsonify from flask import Flask, render_template_string, jsonify, request, redirect, url_for
app = Flask(__name__) app = Flask(__name__)
@@ -16,6 +17,8 @@ DB_NAME = os.getenv("DB_NAME", "fallout")
DB_USER = os.getenv("DB_USER", "fallout") DB_USER = os.getenv("DB_USER", "fallout")
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!") DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config")
def get_conn(): def get_conn():
return psycopg2.connect( return psycopg2.connect(
host=DB_HOST, port=DB_PORT, dbname=DB_NAME, host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
@@ -35,8 +38,33 @@ def query_one(sql, params=None):
rows = query(sql, params) rows = query(sql, params)
return rows[0] if rows else None 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 # HTML Template
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
TEMPLATE = r"""<!DOCTYPE html> TEMPLATE = r"""<!DOCTYPE html>
@@ -44,32 +72,33 @@ TEMPLATE = r"""<!DOCTYPE html>
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="30">
<title>VAULTCOM — Fallout: Venice of Wasteland</title> <title>VAULTCOM — Fallout: Venice of Wasteland</title>
<style> <style>
:root { :root {
--pip-green: #00ff41; --pip-green: #00ff41; --pip-amber: #ffb347; --pip-red: #ff4444;
--pip-amber: #ffb347; --pip-blue: #44aaff; --pip-dim: #003300; --bg: #0a0a0a;
--pip-red: #ff4444; --panel: #0d1a0d; --border: #1a3d1a;
--pip-blue: #44aaff;
--pip-dim: #003300;
--bg: #0a0a0a;
--panel: #0d1a0d;
--border: #1a3d1a;
} }
* { box-sizing: border-box; margin: 0; padding: 0; } * { box-sizing: border-box; margin: 0; padding: 0; }
body { body { background: var(--bg); color: var(--pip-green);
background: var(--bg); font-family: 'Courier New', monospace; font-size: 13px; padding: 8px; }
color: var(--pip-green); h1 { font-size: 18px; letter-spacing: 4px; text-align: center; padding: 8px 0;
font-family: 'Courier New', monospace; border-bottom: 1px solid var(--pip-green); margin-bottom: 8px; }
font-size: 13px;
padding: 8px; /* ---- NAV TABS ---- */
} .nav { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
h1 { font-size: 20px; letter-spacing: 4px; text-align: center; padding: 10px 0; .nav a { padding: 4px 14px; text-decoration: none; color: #555;
border-bottom: 1px solid var(--pip-green); margin-bottom: 10px; } 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; .header-bar { display: flex; justify-content: space-between; align-items: center;
padding: 4px 8px; background: var(--pip-dim); border: 1px solid var(--border); padding: 4px 8px; background: var(--pip-dim); border: 1px solid var(--border);
margin-bottom: 10px; font-size: 11px; color: var(--pip-amber); } 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; } .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 10px; margin-top: 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 { background: var(--panel); border: 1px solid var(--border); padding: 8px; }
@@ -80,10 +109,8 @@ TEMPLATE = r"""<!DOCTYPE html>
border-bottom: 1px solid var(--border); } border-bottom: 1px solid var(--border); }
td { padding: 2px 4px; font-size: 11px; border-bottom: 1px dashed #0f2a0f; vertical-align: top; } td { padding: 2px 4px; font-size: 11px; border-bottom: 1px dashed #0f2a0f; vertical-align: top; }
tr:hover td { background: #0f200f; } tr:hover td { background: #0f200f; }
.tier0 { color: #ff6600; } .tier0 { color: #ff6600; } .tier1 { color: var(--pip-green); }
.tier1 { color: var(--pip-green); } .tier2 { color: var(--pip-blue); } .tier3 { color: #aaaaaa; }
.tier2 { color: var(--pip-blue); }
.tier3 { color: #aaaaaa; }
.dead { color: #444; text-decoration: line-through; } .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-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-fill { height: 100%; background: var(--pip-green); }
@@ -99,7 +126,6 @@ TEMPLATE = r"""<!DOCTYPE html>
.event-encounter { color: var(--pip-amber); } .event-encounter { color: var(--pip-amber); }
.event-survival { color: #88cc88; } .event-survival { color: #88cc88; }
.event-death { color: #ff0000; font-weight: bold; } .event-death { color: #ff0000; font-weight: bold; }
.event-loot { color: #88aaff; }
.relation-hostile { color: var(--pip-red); } .relation-hostile { color: var(--pip-red); }
.relation-neutral { color: #888; } .relation-neutral { color: #888; }
.relation-allie { color: var(--pip-green); } .relation-allie { color: var(--pip-green); }
@@ -114,24 +140,75 @@ TEMPLATE = r"""<!DOCTYPE html>
.stat-item { font-size: 10px; color: #888; } .stat-item { font-size: 10px; color: #888; }
.stat-item span { color: var(--pip-green); } .stat-item span { color: var(--pip-green); }
.footer { text-align: center; font-size: 10px; color: #333; padding: 10px 0; } .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> </style>
</head> </head>
<body> <body>
<h1>⚡ VAULTCOM — VENICE OF WASTELAND ⚡</h1> <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 %} {% if session %}
<div class="header-bar"> <div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span> <span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}</span> <span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
<span>MODE: {{ session.mode | upper }} | STATUS: {{ session.status | upper }}</span> <span class="mode-badge">{{ session.mode | upper }}</span>
<span>STATUS: <strong>{{ session.status | upper }}</strong></span>
<span>SEED: {{ session.seed_global }}</span> <span>SEED: {{ session.seed_global }}</span>
<span>🔄 Actualisé toutes les 30s</span> <span class="refresh-note">🔄 <a href="?sid={{ session.id }}" style="color:#555;text-decoration:none">Actualiser</a>
<span id="countdown"></span></span>
</div> </div>
{% endif %} {% endif %}
<div class="grid"> <div class="grid">
<!-- PNJ -->
<!-- PNJ par tier -->
<div class="panel"> <div class="panel">
<h2>👥 PNJ ACTIFS ({{ alive_count }}/{{ total_count }})</h2> <h2>👥 PNJ ACTIFS ({{ alive_count }}/{{ total_count }})</h2>
<div class="scrollable"> <div class="scrollable">
@@ -156,8 +233,7 @@ TEMPLATE = r"""<!DOCTYPE html>
<td> <td>
{% set f = c.faction_slug or 'sans' %} {% 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' }}"> <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] }} {{ f[:12] }}</span>
</span>
</td> </td>
<td><small>{{ (c.location_slug or '')[:14] }}</small></td> <td><small>{{ (c.location_slug or '')[:14] }}</small></td>
<td>{{ c.caps }}¢</td> <td>{{ c.caps }}¢</td>
@@ -167,9 +243,9 @@ TEMPLATE = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Événements récents --> <!-- Événements -->
<div class="panel"> <div class="panel">
<h2>📡 ÉVÉNEMENTS RÉCENTS (J{{ session.current_day if session else '?' }})</h2> <h2>📡 ÉVÉNEMENTS RÉCENTS</h2>
<div class="scrollable"> <div class="scrollable">
<table> <table>
<tr><th>J/T</th><th>TYPE</th><th>DESCRIPTION</th></tr> <tr><th>J/T</th><th>TYPE</th><th>DESCRIPTION</th></tr>
@@ -184,12 +260,10 @@ TEMPLATE = r"""<!DOCTYPE html>
</table> </table>
</div> </div>
</div> </div>
</div>
</div><!-- /grid -->
<div class="grid3"> <div class="grid3">
<!-- World state -->
<!-- World state par zone -->
<div class="panel"> <div class="panel">
<h2>🗺 ÉTAT DU MONDE</h2> <h2>🗺 ÉTAT DU MONDE</h2>
<div class="scrollable"> <div class="scrollable">
@@ -208,7 +282,7 @@ TEMPLATE = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Relations faction --> <!-- Relations factions -->
<div class="panel"> <div class="panel">
<h2>⚔ RELATIONS FACTIONS</h2> <h2>⚔ RELATIONS FACTIONS</h2>
<div class="scrollable"> <div class="scrollable">
@@ -219,8 +293,7 @@ TEMPLATE = r"""<!DOCTYPE html>
<td><small>{{ r.faction_a[:12] }}</small></td> <td><small>{{ r.faction_a[:12] }}</small></td>
<td><small>{{ r.faction_b[: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' }}"> <td class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}">
{{ r.relation_score }} {{ r.relation_score }}</td>
</td>
<td><small class="{{ 'relation-hostile' if r.relation_score < -20 else 'relation-allie' if r.relation_score > 20 else 'relation-neutral' }}"> <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') }} {{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
</small></td> </small></td>
@@ -231,7 +304,7 @@ TEMPLATE = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Stats simulation --> <!-- Stats -->
<div class="panel"> <div class="panel">
<h2>📊 STATS SIMULATION</h2> <h2>📊 STATS SIMULATION</h2>
<table> <table>
@@ -240,21 +313,17 @@ TEMPLATE = r"""<!DOCTYPE html>
<tr><td>{{ k }}</td><td class="tier1">{{ v }}</td></tr> <tr><td>{{ k }}</td><td class="tier1">{{ v }}</td></tr>
{% endfor %} {% endfor %}
</table> </table>
<br> <br>
<h2 style="margin-top:6px">🎭 PNJ PAR TIER</h2> <h2 style="margin-top:6px">🎭 PNJ PAR TIER</h2>
<table> <table>
<tr><th>TIER</th><th>LABEL</th><th>VIVANTS</th><th>MORTS</th></tr> <tr><th>TIER</th><th>LABEL</th><th>VIVANTS</th><th>MORTS</th></tr>
{% for row in tier_stats %} {% for row in tier_stats %}
<tr class="tier{{ row.tier }}"> <tr class="tier{{ row.tier }}">
<td>{{ row.tier }}</td> <td>{{ row.tier }}</td><td>{{ row.label }}</td>
<td>{{ row.label }}</td> <td>{{ row.alive }}</td><td class="tier3">{{ row.dead }}</td>
<td>{{ row.alive }}</td>
<td class="tier3">{{ row.dead }}</td>
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
<br> <br>
<h2 style="margin-top:6px">🏙 PNJ PAR ZONE (TOP 5)</h2> <h2 style="margin-top:6px">🏙 PNJ PAR ZONE (TOP 5)</h2>
<table> <table>
@@ -264,10 +333,9 @@ TEMPLATE = r"""<!DOCTYPE html>
{% endfor %} {% endfor %}
</table> </table>
</div> </div>
</div>
</div><!-- /grid3 --> <!-- Encounters -->
<!-- Encounters récents -->
<div class="panel" style="margin-top:10px"> <div class="panel" style="margin-top:10px">
<h2>⚡ RENCONTRES RÉCENTES</h2> <h2>⚡ RENCONTRES RÉCENTES</h2>
<div class="scrollable" style="max-height:160px"> <div class="scrollable" style="max-height:160px">
@@ -287,7 +355,7 @@ TEMPLATE = r"""<!DOCTYPE html>
</div> </div>
</div> </div>
<!-- Inventaire boss (tier 0) --> <!-- Boss inventory -->
{% if boss_inventory %} {% if boss_inventory %}
<div class="panel" style="margin-top:10px"> <div class="panel" style="margin-top:10px">
<h2>★ ÉQUIPEMENT CHEFS DE FACTION</h2> <h2>★ ÉQUIPEMENT CHEFS DE FACTION</h2>
@@ -306,18 +374,189 @@ TEMPLATE = r"""<!DOCTYPE html>
</div> </div>
{% endif %} {% endif %}
<div class="footer">VAULT-TEC VAULTCOM v3.0 | Fallout: Venice of Wasteland | J={{ session.current_day if session else '?' }} T={{ session.current_tick if session else '?' }}</div> <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> </body>
</html>""" </html>"""
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Routes # Helpers data
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@app.route("/") def get_sim_data(sid: int) -> dict:
def index(): 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") session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
sid = session["id"] if session else 1
characters = query(""" characters = query("""
SELECT c.*, COALESCE(c.tier, 1) as tier SELECT c.*, COALESCE(c.tier, 1) as tier
@@ -325,40 +564,31 @@ def index():
WHERE c.session_id = %s WHERE c.session_id = %s
ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name
""", (sid,)) """, (sid,))
alive_count = sum(1 for c in characters if c["is_alive"]) alive_count = sum(1 for c in characters if c["is_alive"])
total_count = len(characters)
events = query(""" events = query("""
SELECT e.*, c.name as actor_name SELECT e.*, c.name as actor_name FROM events e
FROM events e
LEFT JOIN characters c ON c.id = e.actor_id LEFT JOIN characters c ON c.id = e.actor_id
WHERE e.session_id = %s WHERE e.session_id = %s
AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss') 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 ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 50
LIMIT 50
""", (sid,)) """, (sid,))
encounters = query(""" encounters = query("""
SELECT e.*, c.name as actor_name SELECT e.*, c.name as actor_name FROM events e
FROM events e
LEFT JOIN characters c ON c.id = e.actor_id LEFT JOIN characters c ON c.id = e.actor_id
WHERE e.session_id = %s WHERE e.session_id = %s AND e.event_type LIKE 'encounter%%'
AND e.event_type LIKE 'encounter%%' ORDER BY e.day DESC, e.tick DESC, e.id DESC LIMIT 30
ORDER BY e.day DESC, e.tick DESC, e.id DESC
LIMIT 30
""", (sid,)) """, (sid,))
world_state = query(""" world_state = query("""
SELECT DISTINCT ON (location_slug) * SELECT DISTINCT ON (location_slug) *
FROM world_state FROM world_state WHERE session_id = %s
WHERE session_id = %s
ORDER BY location_slug, day DESC ORDER BY location_slug, day DESC
""", (sid,)) """, (sid,))
faction_relations = query(""" faction_relations = query("""
SELECT * FROM faction_relations SELECT * FROM faction_relations WHERE session_id = %s
WHERE session_id = %s
ORDER BY relation_score ASC ORDER BY relation_score ASC
""", (sid,)) """, (sid,))
@@ -368,33 +598,29 @@ def index():
COUNT(*) FILTER (WHERE is_alive) as alive, COUNT(*) FILTER (WHERE is_alive) as alive,
COUNT(*) FILTER (WHERE NOT is_alive) as dead COUNT(*) FILTER (WHERE NOT is_alive) as dead
FROM characters WHERE session_id = %s FROM characters WHERE session_id = %s
GROUP BY COALESCE(tier,1) GROUP BY COALESCE(tier,1) ORDER BY COALESCE(tier,1)
ORDER BY COALESCE(tier,1)
""", (sid,)) """, (sid,))
tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"), tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"),
"alive": r["alive"], "dead": r["dead"]} for r in tier_raw] "alive": r["alive"], "dead": r["dead"]} for r in tier_raw]
zone_stats = query(""" zone_stats = query("""
SELECT location_slug, COUNT(*) as cnt SELECT location_slug, COUNT(*) as cnt FROM characters
FROM characters
WHERE session_id = %s AND is_alive = TRUE WHERE session_id = %s AND is_alive = TRUE
GROUP BY location_slug GROUP BY location_slug ORDER BY cnt DESC LIMIT 5
ORDER BY cnt DESC
LIMIT 5
""", (sid,)) """, (sid,))
events_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,)) ev_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,))
combat_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,)) cb_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,))
deaths_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (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,)) enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,))
stats = { stats = {
"Événements total": events_total["n"] if events_total else 0, "Événements total": ev_total["n"] if ev_total else 0,
"Combats": combat_total["n"] if combat_total else 0, "Combats": cb_total["n"] if cb_total else 0,
"Morts": deaths_total["n"] if deaths_total else 0, "Morts": dt_total["n"] if dt_total else 0,
"Rencontres": enc_total["n"] if enc_total else 0, "Rencontres": enc_total["n"] if enc_total else 0,
"PNJ vivants": alive_count, "PNJ vivants": alive_count,
"PNJ morts": total_count - alive_count, "PNJ morts": len(characters) - alive_count,
} }
boss_inventory = query(""" boss_inventory = query("""
@@ -405,11 +631,11 @@ def index():
ORDER BY c.name, i.is_equipped DESC ORDER BY c.name, i.is_equipped DESC
""", (sid,)) """, (sid,))
return render_template_string(TEMPLATE, return dict(
session=session, session=session,
characters=characters, characters=characters,
alive_count=alive_count, alive_count=alive_count,
total_count=total_count, total_count=len(characters),
events=events, events=events,
encounters=encounters, encounters=encounters,
world_state=world_state, world_state=world_state,
@@ -420,11 +646,127 @@ def index():
boss_inventory=boss_inventory, 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") @app.route("/api/state")
def api_state(): def api_state():
session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") all_sessions = get_all_sessions()
sid = session["id"] if session else 1 sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
alive = query("SELECT id, name, hp, max_hp, tier, faction_slug, location_slug FROM characters WHERE session_id=%s AND is_alive=TRUE", (sid,)) 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}) return jsonify({"session": session, "alive_pnj": alive})
if __name__ == "__main__": if __name__ == "__main__":