""" VAULTCOM — Fallout: Venice of Wasteland Dashboard simulation. Schéma DB actuel (migration_003). Onglets: Simulation (par session) | Paramètres (config + restart) """ import os, json, subprocess import psycopg2 import psycopg2.extras from flask import Flask, render_template_string, jsonify, request, redirect, url_for app = Flask(__name__) DB_HOST = os.getenv("DB_HOST", "localhost") DB_PORT = int(os.getenv("DB_PORT", 5432)) DB_NAME = os.getenv("DB_NAME", "fallout") DB_USER = os.getenv("DB_USER", "fallout") DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!") SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config") def get_conn(): return psycopg2.connect( host=DB_HOST, port=DB_PORT, dbname=DB_NAME, user=DB_USER, password=DB_PASS ) def query(sql, params=None): conn = get_conn() try: with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur: cur.execute(sql, params) return [dict(r) for r in cur.fetchall()] finally: conn.close() def query_one(sql, params=None): rows = query(sql, params) return rows[0] if rows else None def execute(sql, params=None): conn = get_conn() try: with conn.cursor() as cur: cur.execute(sql, params) conn.commit() finally: conn.close() def load_sim_config(session_id: int) -> dict: path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") try: with open(path) as f: return json.load(f) except Exception: return {} def save_sim_config(session_id: int, cfg: dict): path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json") with open(path, "w") as f: json.dump(cfg, f, indent=2, ensure_ascii=False) def get_all_sessions(): return query("SELECT * FROM sessions ORDER BY id") # --------------------------------------------------------------------------- # HTML Template # --------------------------------------------------------------------------- TEMPLATE = r""" VAULTCOM — Fallout: Venice of Wasteland

⚡ VAULTCOM — VENICE OF WASTELAND ⚡

{% if flash_msg %}
{{ flash_msg }}
{% endif %} {% if tab == 'sim' %} {% if session %}
SESSION #{{ session.id }} — {{ session.name }} JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h {{ session.mode | upper }} STATUS: {{ session.status | upper }} SEED: {{ session.seed_global }} 🔄 Actualiser
{% endif %}

👥 PNJ ACTIFS ({{ alive_count }}/{{ total_count }})

{% for c in characters %} {% endfor %}
TIERNOMPVFACTIONZONECAPS
{% if c.tier == 0 %}★BOSS {% elif c.tier == 2 %}◈ T2 {% elif c.tier == 3 %}· T3 {% else %}● T1{% endif %} {{ c.name }} {% if c.is_alive %} {% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
{{ c.hp }}/{{ c.max_hp }} {% else %}†{% endif %}
{% set f = c.faction_slug or 'sans' %} {{ f[:12] }} {{ (c.location_slug or '—')[:14] }} {{ c.caps }}¢

📡 ÉVÉNEMENTS RÉCENTS

{% for ev in events %} {% endfor %} {% if not events %}{% endif %}
J/TTYPEDESCRIPTION
{{ ev.day }}/{{ ev.tick }} {{ ev.event_type }} {{ ev.description[:80] }}{% if ev.description|length > 80 %}…{% endif %}
Aucun événement

🗺 ÉTAT DU MONDE

{% for ws in world_state %}
{{ ws.location_slug }} 🍖
💧
🛡
👤{{ ws.pop_count or 0 }}
{% endfor %} {% if not world_state %}

Pas de données monde

{% endif %}

⚔ RELATIONS FACTIONS

{% for r in faction_relations %} {% endfor %} {% if not faction_relations %}{% endif %}
FACTION AFACTION BSCOREÉTAT
{{ r.faction_a[:12] }} {{ r.faction_b[:12] }} {{ r.relation_score }} {{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}
Pas de données

📊 STATS SIMULATION

{% for k, v in stats.items() %} {% endfor %}
MÉTRIQUEVALEUR
{{ k }}{{ v }}

🎭 PNJ PAR TIER

{% for row in tier_stats %} {% endfor %}
TIERLABELVIVANTSMORTS
{{ row.tier }}{{ row.label }} {{ row.alive }}{{ row.dead }}

🏙 PNJ PAR ZONE (TOP 5)

{% for row in zone_stats %} {% endfor %}
ZONEPNJ
{{ row.location_slug }}{{ row.cnt }}

⚡ RENCONTRES RÉCENTES

{% for ev in encounters %} {% endfor %} {% if not encounters %}{% endif %}
J/TTYPEACTEURZONEDESCRIPTION
{{ ev.day }}/{{ ev.tick }} {{ ev.event_type }} {{ ev.actor_name or '—' }} {{ (ev.location_slug or '—')[:14] }} {{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}
Aucune rencontre
{% if boss_inventory %}

★ ÉQUIPEMENT CHEFS DE FACTION

{% for row in boss_inventory %} {% endfor %}
BOSSITEMTYPEQTÉÉQUIPÉ
{{ row.char_name }} {{ row.item_name }} {{ row.item_type }} {{ row.quantity }} {{ '✓' if row.is_equipped else '' }}
{% endif %} {% elif tab == 'params' %} {% if session %}
SESSION #{{ session.id }} — {{ session.name }} {{ session.mode | upper }} STATUS: {{ session.status | upper }} JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h
{% endif %}

⚙ CONFIG SIM_{{ '%03d' % current_sid }}.JSON

{% if cfg %}

GÉNÉRAL

SURVIE

RENCONTRES

ÉCONOMIE

{% else %}

Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}

{% endif %}

🔧 GESTION SESSION #{{ current_sid }}

STATUT DB

 
 

Note: ne tue pas le process Python. Mettre "paused" ou "stopped" fait sortir run.py au prochain tick.

RESET JOUR/TICK

MODES DISPONIBLES

{% for m_name, m_vals in modes.items() %} {% endfor %}
MODEDRAINENCOUNTERSCAPS
{{ 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','—') }}

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 = cfg.get("modes", {}) flash_msg = request.args.get("msg") flash_ok = request.args.get("ok", "1") == "1" return render_template_string(TEMPLATE, tab="params", current_sid=sid, all_sessions=all_sessions, session=session, cfg=cfg, modes=modes, sim_configs_dir=SIM_CONFIGS_DIR, flash_msg=flash_msg, flash_ok=flash_ok, # unused in params tab but required by template characters=[], alive_count=0, total_count=0, events=[], encounters=[], world_state=[], faction_relations=[], tier_stats=[], zone_stats=[], stats={}, boss_inventory=[], ) @app.route("/params/save", methods=["POST"]) def params_save(): sid = int(request.args.get("sid", 1)) cfg = load_sim_config(sid) if not cfg: return redirect(url_for("params", sid=sid, msg="Fichier config introuvable", ok=0)) f = request.form cfg["name"] = f.get("name", cfg.get("name", "")) cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste")) cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60)) cfg.setdefault("survival", {}).update({ "drain_speed": float(f.get("survival_drain_speed", 1.0)), "fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)), "enabled": "survival_enabled" in f, }) cfg.setdefault("encounter", {}).update({ "global_rate_multiplier": float(f.get("encounter_rate", 1.0)), "enabled": "encounter_enabled" in f, }) cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0)) try: save_sim_config(sid, cfg) execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid)) msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}." ok = 1 except Exception as e: msg = f"Erreur: {e}" ok = 0 return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) @app.route("/params/setstatus", methods=["POST"]) def params_setstatus(): sid = int(request.args.get("sid", 1)) status = request.form.get("status", "paused") try: execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid)) msg = f"Session {sid} → status '{status}'. Le process run.py sortira au prochain tick si arrêté." ok = 1 except Exception as e: msg = f"Erreur: {e}" ok = 0 return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) @app.route("/params/resetday", methods=["POST"]) def params_resetday(): sid = int(request.args.get("sid", 1)) day = int(request.form.get("day", 1)) tick = int(request.form.get("tick", 0)) try: execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid)) msg = f"Session {sid} remise à J{day} T{tick}h." ok = 1 except Exception as e: msg = f"Erreur: {e}" ok = 0 return redirect(url_for("params", sid=sid, msg=msg, ok=ok)) @app.route("/api/state") def api_state(): all_sessions = get_all_sessions() sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1)) session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,)) alive = query(""" SELECT id, name, hp, max_hp, tier, faction_slug, location_slug FROM characters WHERE session_id=%s AND is_alive=TRUE """, (sid,)) return jsonify({"session": session, "alive_pnj": alive}) if __name__ == "__main__": app.run(host="0.0.0.0", port=5000, debug=False)