"""
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"""
VAULTCOM — Fallout: Venice of Wasteland
⚡ VAULTCOM — VENICE OF WASTELAND ⚡
{% if flash_msg %}
{{ flash_msg }}
{% endif %}
{% if tab == 'sim' %}
{% if session %}
{% endif %}
👥 PNJ ACTIFS ({{ alive_count }}/{{ total_count }})
📊 STATS SIMULATION
| MÉTRIQUE | VALEUR |
{% for k, v in stats.items() %}
| {{ k }} | {{ v }} |
{% endfor %}
🎭 PNJ PAR TIER
| TIER | LABEL | VIVANTS | MORTS |
{% for row in tier_stats %}
| {{ row.tier }} | {{ row.label }} |
{{ row.alive }} | {{ row.dead }} |
{% endfor %}
🏙 PNJ PAR ZONE (TOP 5)
| ZONE | PNJ |
{% for row in zone_stats %}
| {{ row.location_slug }} | {{ row.cnt }} |
{% endfor %}
{% if boss_inventory %}
★ ÉQUIPEMENT CHEFS DE FACTION
| BOSS | ITEM | TYPE | QTÉ | ÉQUIPÉ |
{% for row in boss_inventory %}
| {{ row.char_name }} |
{{ row.item_name }} |
{{ row.item_type }} |
{{ row.quantity }} |
{{ '✓' if row.is_equipped else '' }} |
{% endfor %}
{% endif %}
{% elif tab == 'params' %}
{% if session %}
{% endif %}
⚙ CONFIG SIM_{{ '%03d' % current_sid }}.JSON
🔧 GESTION SESSION #{{ current_sid }}
STATUT DB
Note: ne tue pas le process Python. Mettre "paused" ou "stopped" fait sortir run.py au prochain tick.
MODES DISPONIBLES
| MODE | DRAIN | ENCOUNTERS | CAPS |
{% for m_name, m_vals in modes.items() %}
|
{{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %} |
×{{ m_vals.get('survival',{}).get('drain_speed','—') }} |
×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }} |
×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }} |
{% endfor %}
COMMANDES AMPÈRE
Copier-coller dans votre terminal SSH :
# Lancer/relancer S{{ current_sid }} :
cd /home/ubuntu/fallout-venice/src/engine
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 &
# Voir les logs :
tail -f ~/fallout_sim_s{{ current_sid }}.log
{% endif %}
"""
# ---------------------------------------------------------------------------
# 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))
@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)