feat: dashboard VAULTCOM v3 + config.py TICK_SLEEP_SEC + sim_config

- dashboard/app.py: Nouveau dashboard Flask (schéma DB migration_003)
  Affiche PNJ par tier (0/1/2/3), events, rencontres, world_state,
  faction_relations, inventaire boss. Auto-refresh 30s.
  Connecté via tunnel SSH interne vers Vigile:5432
- src/engine/config.py: Fix TICK_SLEEP_SEC depuis env (était hardcodé 0)
- src/config/sim_001.json: Paramètres simulation centralisés
  (tick_speed, difficulty presets, LLM config, multi-session)

Accessible: fallout.coyoteos.ovh
This commit is contained in:
Corback
2026-06-15 23:54:40 +00:00
parent 45f7abc402
commit 96d2df92b2
3 changed files with 477 additions and 1 deletions
+431
View File
@@ -0,0 +1,431 @@
"""
VAULTCOM — Fallout: Venice of Wasteland
Dashboard simulation. Schéma DB actuel (migration_003).
"""
import os, json
import psycopg2
import psycopg2.extras
from flask import Flask, render_template_string, jsonify
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!")
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
# ---------------------------------------------------------------------------
# HTML template
# ---------------------------------------------------------------------------
TEMPLATE = r"""<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="30">
<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: 20px; letter-spacing: 4px; text-align: center; padding: 10px 0;
border-bottom: 1px solid var(--pip-green); margin-bottom: 10px; }
.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: 10px; font-size: 11px; color: var(--pip-amber); }
.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; }
.event-loot { color: #88aaff; }
.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; }
</style>
</head>
<body>
<h1>⚡ VAULTCOM — VENICE OF WASTELAND ⚡</h1>
{% if session %}
<div class="header-bar">
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}</span>
<span>MODE: {{ session.mode | upper }} | STATUS: {{ session.status | upper }}</span>
<span>SEED: {{ session.seed_global }}</span>
<span>🔄 Actualisé toutes les 30s</span>
</div>
{% endif %}
<div class="grid">
<!-- PNJ par tier -->
<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 récents -->
<div class="panel">
<h2>📡 ÉVÉNEMENTS RÉCENTS (J{{ session.current_day if session else '?' }})</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><!-- /grid -->
<div class="grid3">
<!-- World state par zone -->
<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 faction -->
<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 simulation -->
<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><!-- /grid3 -->
<!-- Encounters récents -->
<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>
<!-- Inventaire boss (tier 0) -->
{% 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 %}
<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>
</body>
</html>"""
# ---------------------------------------------------------------------------
# Routes
# ---------------------------------------------------------------------------
@app.route("/")
def index():
session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
sid = session["id"] if session else 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"])
total_count = len(characters)
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')
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,))
events_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,))
deaths_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": events_total["n"] if events_total else 0,
"Combats": combat_total["n"] if combat_total else 0,
"Morts": deaths_total["n"] if deaths_total else 0,
"Rencontres": enc_total["n"] if enc_total else 0,
"PNJ vivants": alive_count,
"PNJ morts": total_count - 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 render_template_string(TEMPLATE,
session=session,
characters=characters,
alive_count=alive_count,
total_count=total_count,
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,
)
@app.route("/api/state")
def api_state():
session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
sid = session["id"] if session 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,))
return jsonify({"session": session, "alive_pnj": alive})
if __name__ == "__main__":
app.run(host="0.0.0.0", port=5000, debug=False)