From d4007aec00e03aa8c1790e4cda838bd4df14ad81 Mon Sep 17 00:00:00 2001 From: Corback Date: Mon, 15 Jun 2026 17:39:38 +0000 Subject: [PATCH] Add fallout.coyoteos.ovh Flask visu - VaultCom terminal UI --- fallout-visu/app.py | 687 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 687 insertions(+) create mode 100644 fallout-visu/app.py diff --git a/fallout-visu/app.py b/fallout-visu/app.py new file mode 100644 index 0000000..8af85d3 --- /dev/null +++ b/fallout-visu/app.py @@ -0,0 +1,687 @@ +""" +fallout.coyoteos.ovh — Venice of Wasteland — Visualisation simulation +Flask app sur Ampère, lit PostgreSQL Vigile via SSH tunnel +""" +import subprocess, time, threading, os, json +from flask import Flask, render_template_string, jsonify, abort + +app = Flask(__name__) + +# ── SSH tunnel → PostgreSQL Vigile ──────────────────────────────────────────── + +TUNNEL_LOCAL_PORT = 15432 +_tunnel_proc = None + +def start_tunnel(): + global _tunnel_proc + cmd = [ + "ssh", "-N", "-L", f"{TUNNEL_LOCAL_PORT}:localhost:5432", + "-i", "/ssh/vigile.key", + "-o", "StrictHostKeyChecking=no", + "-o", "ServerAliveInterval=30", + "-o", "ServerAliveCountMax=3", + "ubuntu@79.72.30.231" + ] + _tunnel_proc = subprocess.Popen(cmd) + time.sleep(3) + print(f"SSH tunnel → Vigile:5432 via local:{TUNNEL_LOCAL_PORT}") + +def get_conn(): + import psycopg2 + return psycopg2.connect( + host="localhost", port=TUNNEL_LOCAL_PORT, + dbname="fallout", user="fallout", + password="VeniceOfWasteland2026!", + connect_timeout=5 + ) + +def query(sql, params=None): + try: + conn = get_conn() + cur = conn.cursor() + cur.execute(sql, params or ()) + cols = [d[0] for d in cur.description] + rows = [dict(zip(cols, r)) for r in cur.fetchall()] + conn.close() + return rows + except Exception as e: + print(f"DB error: {e}") + return [] + +def execute(sql, params=None): + try: + conn = get_conn() + cur = conn.cursor() + cur.execute(sql, params or ()) + conn.commit() + conn.close() + return True + except Exception as e: + print(f"DB error: {e}") + return False + +# ── Templates ───────────────────────────────────────────────────────────────── + +BASE_CSS = """ + +""" + +def nav(active=""): + return f""" + +""" + +SHELL = """ + + + + +VaultCom — {{ title }} +""" + BASE_CSS + """ + + +
+ {{ nav | safe }} +
+
+

{{ title }}

+
+ {% if session %} + Jour {{ session.day_current }}/{{ session.day_total }} + {{ session.mode | upper }} + {% else %} + DB OFFLINE + {% endif %} + +
+
+ {{ content | safe }} +
+
+ + +""" + +def render(title, content, active="", session=None): + return render_template_string(SHELL, title=title, content=content, + nav=nav(active), session=session) + +# ── Helpers ─────────────────────────────────────────────────────────────────── + +def get_session(): + rows = query("SELECT * FROM sessions ORDER BY id DESC LIMIT 1") + return rows[0] if rows else None + +def hp_class(cur, mx): + if mx == 0: return "warning" + pct = cur / mx + return "danger" if pct < 0.33 else ("warning" if pct < 0.66 else "") + +def bar_class(cur, mx): + if mx == 0: return "warning" + pct = cur / mx + return "danger" if pct < 0.33 else ("warning" if pct < 0.66 else "") + +# ── Routes ──────────────────────────────────────────────────────────────────── + +@app.route("/") +def index(): + sess = get_session() + chars = query("SELECT * FROM characters WHERE session_id = %s ORDER BY faction, name", + (sess["id"],)) if sess else [] + recent = query("""SELECT e.*, c.name as char_name FROM events e + JOIN characters c ON c.id = e.character_id + WHERE e.session_id = %s ORDER BY e.day_in_game DESC, e.hour_in_game DESC LIMIT 12""", + (sess["id"],)) if sess else [] + factions = query("""SELECT DISTINCT faction, COUNT(*) as nb, + SUM(CASE WHEN status='alive' THEN 1 ELSE 0 END) as alive + FROM characters WHERE session_id = %s GROUP BY faction ORDER BY faction""", + (sess["id"],)) if sess else [] + pending = query("SELECT COUNT(*) as n FROM action_queue WHERE session_id = %s AND status='pending'", + (sess["id"],))[0]["n"] if sess else 0 + lore_last = query("""SELECT * FROM lore_avancee WHERE session_id = %s + ORDER BY day_start DESC LIMIT 1""", (sess["id"],)) if sess else [] + + # Characters grid + char_cards = "" + if chars: + for c in chars: + hp_pct = int(c["hp_current"] / max(c["hp_max"], 1) * 100) + bc = bar_class(c["hp_current"], c["hp_max"]) + nc = hp_class(c["hp_current"], c["hp_max"]) + st_cls = f"status-{c['status']}" + char_cards += f""" + +
+
+

{c['name']}

+ {c['status'].upper()} +
+
{c['faction'] or '—'} · {c['location'] or '?'}
+
+ +
+
+
+ CAPS: {c['caps']} · XP: {c['xp']} +
+
+
""" + else: + char_cards = '
Aucun agent initialisé
' + + # Recent events + ev_html = "" + if recent: + ev_html = '' + else: + ev_html = '
Journal vide — simulation non démarrée
' + + # Faction summary + fac_html = "" + for f in factions: + fac_html += f'{f["faction"] or "?"}{f["alive"]}{f["nb"]}' + + # Lore entry + lore_html = "" + if lore_last: + l = lore_last[0] + lore_html = f""" +
+
+

{l['title'] or f"Résumé jours {l['day_start']}–{l['day_end']}"}

+
Jours {l['day_start']}–{l['day_end']}
+
+
{l['content'][:600]}{'…' if len(l['content'])>600 else ''}
+
""" + else: + lore_html = '
Pas encore de lore synthétisé
' + + content = f""" +
+ ▌ VAULTCOM ACTIVE ▌ SESSION: {sess['name'] if sess else 'N/A'} ▌ + AGENTS: {len(chars)} ▌ ACTIONS EN ATTENTE: {pending} ▌ + +
+ +
{char_cards}
+ +
+
+

Journal récent

+ {ev_html} +
+
+
+

Factions actives

+ + + {fac_html if fac_html else ''} +
FactionVivantsTotal
+
+
+

Dernière synthèse narrative

+ {lore_html} +
+
+
+""" + return render("Mission Control", content, "home", sess) + + +@app.route("/characters") +def characters(): + sess = get_session() + chars = query("SELECT * FROM characters WHERE session_id = %s ORDER BY faction, name", + (sess["id"],)) if sess else [] + rows = "" + for c in chars: + hp_pct = int(c["hp_current"] / max(c["hp_max"], 1) * 100) + bc = bar_class(c["hp_current"], c["hp_max"]) + rows += f""" + + {c['name']} + {c['faction'] or '—'} + {c['location'] or '?'} + +
+
+
+ {c['hp_current']}/{c['hp_max']} + + {c['caps']} + {c['status'].upper()} + {c['xp']} XP +""" + content = f""" +
+ + + {rows if rows else ''} +
NomFactionLieuHPCapsStatusXP
Aucun personnage
+
""" + return render("Agents", content, "chars", sess) + + +@app.route("/character/") +def character(cid): + sess = get_session() + chars = query("SELECT * FROM characters WHERE id = %s", (cid,)) + if not chars: abort(404) + c = chars[0] + inv = query("SELECT * FROM inventory WHERE character_id = %s ORDER BY item_name", (cid,)) + events = query("""SELECT * FROM events WHERE character_id = %s + ORDER BY day_in_game, hour_in_game""", (cid,)) + + SPECIALS = [("S","strength"),("P","perception"),("E","endurance"), + ("C","charisma"),("I","intelligence"),("A","agility"),("L","luck")] + sp_html = "".join(f""" +
+
{c[key]}
+
{abbr}
+
""" for abbr, key in SPECIALS) + + hp_pct = int(c["hp_current"] / max(c["hp_max"], 1) * 100) + bc = bar_class(c["hp_current"], c["hp_max"]) + nc = hp_class(c["hp_current"], c["hp_max"]) + + inv_rows = "".join(f"{i['item_name']}{i.get('quantity',1)}{i.get('description','')}" + for i in inv) if inv else "Inventaire vide" + + ev_html = "" + if events: + ev_html = '' + else: + ev_html = '
Aucune action enregistrée
' + + bio_html = f'
{c["bio"] or "Dossier vide."}
' + + content = f""" +
+
+
+

{c['name']}

+
{c['faction'] or '—'}
+
Localisation: {c['location'] or '?'} · Caps: {c['caps']} · XP: {c['xp']}
+
+ {c['status'].upper()} +
+
+ {c['hp_current']} +
+
/{c['hp_max']} HP
+
+
+
+
{sp_html}
+ {bio_html} +
+ +
+
+

Inventaire

+ + + {inv_rows} +
ObjetQtéDescription
+
+
+

Journal d'actions

+ {ev_html} +
+
""" + return render(c["name"], content, "chars", sess) + + +@app.route("/actions") +def actions(): + sess = get_session() + rows = query(""" + SELECT aq.*, c.name as char_name, t.name as target_name + FROM action_queue aq + LEFT JOIN characters c ON c.id = aq.character_id + LEFT JOIN characters t ON t.id = aq.target_char_id + WHERE aq.session_id = %s + ORDER BY aq.day_in_game DESC, aq.hour_in_game DESC, aq.id DESC + LIMIT 100""", (sess["id"],)) if sess else [] + + def status_badge(s): + cls = {"pending":"amber","resolving":"green","resolved":"dim","cancelled":"red"}.get(s,"dim") + return f'{s.upper()}' + + trs = "".join(f""" + + J{r['day_in_game']}h{r['hour_in_game']:02d} + {r['char_name'] or '?'} + {r['location'] or '?'} + {r['action_text'][:80]}{'…' if len(r['action_text'])>80 else ''} + {r['target_name'] or '—'} + {r['scene_id'] or '—'} + {status_badge(r['status'])} + {(r['result_text'] or '')[:60]} +""" for r in rows) if rows else "Aucune action" + + content = f""" +
+ + + {trs} +
MomentAgentLieuActionCibleScèneStatusRésultat
+
""" + return render("File d'Actions", content, "actions", sess) + + +@app.route("/world") +def world(): + sess = get_session() + if not sess: + return render("État du Monde", '
DB offline
', "world") + day = sess["day_current"] + states = query("""SELECT * FROM world_state WHERE session_id = %s AND day_in_game = %s + ORDER BY location""", (sess["id"], day)) + factions = query("""SELECT * FROM faction_relations WHERE session_id = %s + ORDER BY faction_a, faction_b""", (sess["id"],)) + + def stab_color(s): + if s >= 70: return "var(--green)" + if s >= 40: return "var(--amber)" + return "var(--red)" + + world_rows = "".join(f""" + + {s['location']} + {s['controlling_faction'] or '—'} + +
+
+
+ {s['stability']}% + + {s['population'] or '?'} + {s['notes'] or ''} +""" for s in states) if states else "Aucune donnée" + + fac_rows = "".join(f""" + + {f['faction_a']} + {f['faction_b']} + {'+' if f['relation_score']>0 else ''}{f['relation_score']} + {f.get('notes','')} +""" for f in factions) if factions else "Aucune donnée" + + content = f""" +
+
+

Zones — Jour {day}

+ + + {world_rows} +
LieuFactionStabilitéPop.Notes
+
+
+

Relations entre factions

+ + + {fac_rows} +
Faction AFaction BScoreNotes
+
+
""" + return render("État du Monde", content, "world", sess) + + +@app.route("/lore") +def lore(): + sess = get_session() + entries = query("""SELECT * FROM lore_avancee WHERE session_id = %s + ORDER BY day_start DESC""", (sess["id"],)) if sess else [] + cards = "" + if entries: + for e in entries: + facs = ", ".join(e["factions_involved"] or []) or "—" + locs = ", ".join(e["locations_involved"] or []) or "—" + cards += f""" +
+
+

{e['title'] or f"Chronique jours {e['day_start']}–{e['day_end']}"}

+ J{e['day_start']}–{e['day_end']} +
+
+ Factions: {facs} · Lieux: {locs} +
+
{e['content']}
+
""" + else: + cards = '
Aucune synthèse narrative générée
Le MJ produira des entrées toutes les 24h de jeu simulé
' + return render("Lore Avancée", cards, "lore", sess) + + +@app.route("/events") +def events(): + sess = get_session() + evs = query("""SELECT e.*, c.name as char_name FROM events e + JOIN characters c ON c.id = e.character_id + WHERE e.session_id = %s + ORDER BY e.day_in_game DESC, e.hour_in_game DESC + LIMIT 200""", (sess["id"],)) if sess else [] + rows = "".join(f""" + + J{e['day_in_game']}h{e['hour_in_game']:02d} + {e['char_name']} + {e['location'] or '?'} + {e['action'][:80]}{'…' if len(e['action'])>80 else ''} + {(e['result'] or '')[:100]}{'…' if e['result'] and len(e['result'])>100 else ''} +""" for e in evs) if evs else "Journal vide" + + content = f""" +
+ + + {rows} +
MomentAgentLieuActionRésultat
+
""" + return render("Journal des Événements", content, "events", sess) + + +@app.route("/api/summary") +def api_summary(): + """Pour le widget dashboard dash.coyoteos.ovh""" + sess = get_session() + if not sess: + return jsonify({"ok": False}) + chars = query("SELECT status, COUNT(*) n FROM characters WHERE session_id=%s GROUP BY status", (sess["id"],)) + pending = query("SELECT COUNT(*) n FROM action_queue WHERE session_id=%s AND status='pending'", (sess["id"],)) + return jsonify({ + "ok": True, + "session": sess["name"], + "day": sess["day_current"], + "day_total": sess["day_total"], + "chars": {r["status"]: r["n"] for r in chars}, + "pending_actions": pending[0]["n"] if pending else 0, + }) + + +# ── Main ────────────────────────────────────────────────────────────────────── + +if __name__ == "__main__": + t = threading.Thread(target=start_tunnel, daemon=True) + t.start() + print("VaultCom :5000") + app.run(host="0.0.0.0", port=5000, debug=False)