Files
fallout-venice/dashboard/app.py
T

1050 lines
44 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()
_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>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>
<a href="/lore" class="{{ 'active' if tab=='lore' else '' }}">📜 LORE</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 == 'lore' %}
<!-- ===================================================== -->
<!-- ONGLET LORE -->
<!-- ===================================================== -->
<style>
.lore-card { background: var(--panel); border: 1px solid var(--border); padding: 10px; margin-bottom: 8px; }
.lore-card h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px; margin-bottom: 6px; }
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.diff-old { background: #1a0000; border: 1px solid #3a0000; padding: 6px; font-size: 10px; color: #ff8888; white-space: pre-wrap; }
.diff-new { background: #001a00; border: 1px solid #003a00; padding: 6px; font-size: 10px; color: #88ff88; white-space: pre-wrap; }
.diff-label { font-size: 10px; color: #666; margin-bottom: 2px; }
.lore-meta { font-size: 10px; color: #666; margin-bottom: 6px; display: flex; gap: 12px; flex-wrap: wrap; }
.lore-actions { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
.lore-actions textarea { background: #001500; color: var(--pip-green); border: 1px solid var(--border);
font-family: 'Courier New', monospace; font-size: 10px; padding: 4px; width: 100%; min-height: 60px; resize: vertical; }
.badge-pending { background: #2a2200; color: var(--pip-amber); }
.badge-accepted { background: #002a00; color: var(--pip-green); }
.badge-rejected { background: #2a0000; color: var(--pip-red); }
.badge-modified { background: #001a2a; color: var(--pip-blue); }
.lore-filters { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
.lore-filters label { font-size: 11px; color: #aaa; }
.lore-filters select { background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
font-family: 'Courier New', monospace; font-size: 11px; padding: 2px 6px; }
.lore-stat-bar { display: flex; gap: 12px; margin-bottom: 10px; font-size: 11px; flex-wrap: wrap; }
.lore-stat { color: #666; } .lore-stat span { color: var(--pip-amber); }
.change-type-badge { display: inline-block; padding: 1px 6px; font-size: 10px;
border: 1px solid #333; color: #888; margin-right: 4px; }
</style>
<div class="header-bar">
<span>📜 ENRICHISSEMENT LORE — VENICE OF WASTELAND</span>
<span class="lore-stat-bar">
<span class="lore-stat">En attente : <span>{{ lore_stats.pending }}</span></span>
<span class="lore-stat">Acceptées : <span style="color:var(--pip-green)">{{ lore_stats.accepted }}</span></span>
<span class="lore-stat">Rejetées : <span style="color:#ff4444">{{ lore_stats.rejected }}</span></span>
<span class="lore-stat">Modifiées : <span style="color:var(--pip-blue)">{{ lore_stats.modified }}</span></span>
</span>
</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:10px;color:#555">{{ proposals|length }} proposition(s) affichée(s)</span>
</div>
{% if not proposals %}
<div class="panel" style="color:#555;text-align:center;padding:20px">
Aucune proposition lore.<br>
<small style="color:#333">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 badge-{{ p.status }}">{{ p.status | upper }}</span>
<span class="change-type-badge">{{ p.change_type }}</span>
<span style="color:var(--pip-amber)">{{ p.faction_slug }}</span>
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
<span style="color:#444">{{ 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:4px">
<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:4px">✎ MODIFIER & ACCEPTER</button>
</form>
</div>
{% elif p.status == 'modified' and p.modified_text %}
<div style="margin-top:6px">
<div class="diff-label">TEXTE MODIFIÉ ACCEPTÉ</div>
<div class="diff-new" style="border-color:var(--pip-blue)">{{ p.modified_text }}</div>
</div>
{% endif %}
</div>
{% endfor %}
{% 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 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:6px;font-size:10px;color:#555">
⚠ 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:#ff4444;font-size:11px">
Fichier <code>sim_{{ '%03d' % current_sid }}.json</code> introuvable dans <code>{{ sim_configs_dir }}</code>
</p>
<p style="color:#555;font-size:10px;margin-top:4px">
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>
<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 = 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
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)