Files

688 lines
27 KiB
Python
Raw Permalink 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.
"""
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 = """
<style>
@import url('https://fonts.googleapis.com/css2?family=VT323&family=Share+Tech+Mono&display=swap');
:root {
--green: #39ff14;
--green-dim: #1a7a08;
--green-dark: #0d3d04;
--amber: #ffb000;
--red: #ff3c3c;
--bg: #050a05;
--card-bg: #0a150a;
--border: #1f4d1f;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
background: var(--bg);
color: var(--green);
font-family: 'Share Tech Mono', monospace;
font-size: 14px;
min-height: 100vh;
position: relative;
}
body::before {
content: '';
position: fixed; inset: 0; pointer-events: none; z-index: 9999;
background: repeating-linear-gradient(0deg, transparent, transparent 2px, rgba(0,0,0,0.08) 2px, rgba(0,0,0,0.08) 4px);
}
h1, h2, h3, .title { font-family: 'VT323', monospace; letter-spacing: 2px; }
h1 { font-size: 2.4rem; color: var(--green); text-shadow: 0 0 10px var(--green); }
h2 { font-size: 1.6rem; color: var(--green); margin-bottom: 12px; }
h3 { font-size: 1.2rem; color: var(--amber); }
a { color: var(--green-dim); text-decoration: none; }
a:hover { color: var(--green); text-shadow: 0 0 6px var(--green); }
.layout { display: flex; min-height: 100vh; }
.sidebar {
width: 220px; flex-shrink: 0;
background: var(--card-bg); border-right: 1px solid var(--border);
padding: 20px 0;
}
.sidebar .logo { padding: 0 20px 20px; border-bottom: 1px solid var(--border); }
.sidebar .logo .sub { font-size: 11px; color: var(--green-dim); }
.sidebar nav a {
display: block; padding: 10px 20px;
color: var(--green-dim); font-size: 13px;
border-left: 3px solid transparent;
transition: all 0.15s;
}
.sidebar nav a:hover, .sidebar nav a.active {
color: var(--green); border-left-color: var(--green);
background: rgba(57,255,20,0.04);
text-shadow: 0 0 6px var(--green);
}
.sidebar nav .section-label {
padding: 16px 20px 4px; font-size: 10px;
color: var(--green-dim); letter-spacing: 2px; text-transform: uppercase;
}
.main { flex: 1; padding: 30px; overflow: auto; }
.topbar {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 28px; padding-bottom: 16px;
border-bottom: 1px solid var(--border);
}
.badge {
display: inline-block; padding: 3px 10px;
border: 1px solid; border-radius: 2px;
font-size: 11px; letter-spacing: 1px; text-transform: uppercase;
}
.badge-green { border-color: var(--green); color: var(--green); }
.badge-amber { border-color: var(--amber); color: var(--amber); }
.badge-red { border-color: var(--red); color: var(--red); }
.badge-dim { border-color: var(--border); color: var(--green-dim); }
.card {
background: var(--card-bg); border: 1px solid var(--border);
border-radius: 2px; padding: 20px; margin-bottom: 16px;
}
.card-header { border-bottom: 1px solid var(--border); padding-bottom: 10px; margin-bottom: 14px; }
.grid { display: grid; gap: 16px; }
.grid-2 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr 1fr; }
.grid-5 { grid-template-columns: repeat(5, 1fr); }
.stat-bar { margin: 6px 0; }
.stat-bar label { display: flex; justify-content: space-between; margin-bottom: 3px; font-size: 12px; }
.bar-track { background: var(--green-dark); border-radius: 1px; height: 6px; }
.bar-fill { height: 6px; background: var(--green); border-radius: 1px; transition: width 0.3s; }
.bar-fill.danger { background: var(--red); }
.bar-fill.warning { background: var(--amber); }
table { width: 100%; border-collapse: collapse; }
th { text-align: left; padding: 8px 12px; color: var(--green-dim); font-size: 11px; letter-spacing: 1px; text-transform: uppercase; border-bottom: 1px solid var(--border); }
td { padding: 8px 12px; border-bottom: 1px solid rgba(31,77,31,0.4); font-size: 13px; }
tr:hover td { background: rgba(57,255,20,0.03); }
.timeline { list-style: none; }
.timeline li {
position: relative; padding: 10px 10px 10px 24px;
border-left: 1px solid var(--border); margin-left: 8px;
}
.timeline li::before {
content: ''; position: absolute; left: -8px;
background: var(--bg); color: var(--green-dim); font-size: 11px;
}
.timeline .meta { font-size: 11px; color: var(--green-dim); margin-bottom: 4px; }
.timeline .action { color: var(--amber); }
.timeline .result { color: var(--green); margin-top: 4px; font-size: 13px; }
.special-grid { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; margin: 12px 0; }
.special-box { text-align: center; padding: 10px 6px; border: 1px solid var(--border); }
.special-box .label { font-size: 10px; color: var(--green-dim); }
.special-box .val { font-size: 1.8rem; font-family: 'VT323', monospace; color: var(--green); }
.hp-display { display: flex; align-items: center; gap: 12px; margin: 12px 0; }
.hp-num { font-family: 'VT323', monospace; font-size: 2rem; }
.hp-num.danger { color: var(--red); }
.hp-num.warning { color: var(--amber); }
.empty { text-align: center; padding: 40px; color: var(--green-dim); }
.empty .ico { font-size: 2rem; margin-bottom: 8px; }
.status-alive { color: var(--green); }
.status-injured { color: var(--amber); }
.status-dead { color: var(--red); }
.status-pending { color: var(--amber); }
.status-resolving { color: #00bfff; }
.status-resolved { color: var(--green-dim); }
.ticker {
background: var(--card-bg); border: 1px solid var(--border);
padding: 8px 14px; margin-bottom: 16px; font-size: 12px;
color: var(--green-dim); white-space: nowrap; overflow: hidden;
}
.blink { animation: blink 1s step-end infinite; }
@keyframes blink { 50% { opacity: 0; } }
@media (max-width: 900px) {
.grid-5 { grid-template-columns: 1fr 1fr; }
.grid-3 { grid-template-columns: 1fr 1fr; }
.sidebar { display: none; }
}
</style>
"""
def nav(active=""):
return f"""
<div class="sidebar">
<div class="logo">
<h1 style="font-size:1.4rem">☢ VAULTCOM</h1>
<div class="sub">Venice of Wasteland</div>
<div class="sub">La Corboration</div>
</div>
<nav>
<div class="section-label">Simulation</div>
<a href="/" class="{'active' if active=='home' else ''}">⬡ Mission Control</a>
<a href="/world" class="{'active' if active=='world' else ''}">◈ État du Monde</a>
<div class="section-label">Agents</div>
<a href="/characters" class="{'active' if active=='chars' else ''}">◉ Personnages</a>
<a href="/actions" class="{'active' if active=='actions' else ''}">▶ File d'Actions</a>
<div class="section-label">Archives</div>
<a href="/lore" class="{'active' if active=='lore' else ''}">◎ Lore Avancée</a>
<a href="/events" class="{'active' if active=='events' else ''}">⊟ Journal</a>
</nav>
</div>
"""
SHELL = """<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>VaultCom — {{ title }}</title>
""" + BASE_CSS + """
</head>
<body>
<div class="layout">
{{ nav | safe }}
<div class="main">
<div class="topbar">
<h2>{{ title }}</h2>
<div style="display:flex;gap:10px;align-items:center">
{% if session %}
<span class="badge badge-green">Jour {{ session.day_current }}/{{ session.day_total }}</span>
<span class="badge badge-dim">{{ session.mode | upper }}</span>
{% else %}
<span class="badge badge-red">DB OFFLINE</span>
{% endif %}
<span style="font-size:11px;color:var(--green-dim)" id="clock"></span>
</div>
</div>
{{ content | safe }}
</div>
</div>
<script>
function tick() { document.getElementById('clock').textContent = new Date().toLocaleTimeString('fr-FR'); }
tick(); setInterval(tick, 1000);
</script>
</body>
</html>"""
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"""
<a href="/character/{c['id']}" style="text-decoration:none">
<div class="card" style="cursor:pointer">
<div class="card-header" style="display:flex;justify-content:space-between">
<h3>{c['name']}</h3>
<span class="badge badge-dim {st_cls}">{c['status'].upper()}</span>
</div>
<div style="font-size:11px;color:var(--green-dim);margin-bottom:8px">{c['faction'] or ''} · {c['location'] or '?'}</div>
<div class="stat-bar">
<label><span>HP</span><span class="{nc}">{c['hp_current']}/{c['hp_max']}</span></label>
<div class="bar-track"><div class="bar-fill {bc}" style="width:{hp_pct}%"></div></div>
</div>
<div style="font-size:11px;color:var(--green-dim);margin-top:6px">
CAPS: {c['caps']} · XP: {c['xp']}
</div>
</div>
</a>"""
else:
char_cards = '<div class="empty"><div class="ico">◉</div>Aucun agent initialisé</div>'
# Recent events
ev_html = ""
if recent:
ev_html = '<ul class="timeline">'
for e in recent:
ev_html += f"""
<li>
<div class="meta">Jour {e['day_in_game']}h{e['hour_in_game']:02d} · {e['char_name']} · {e['location'] or '?'}</div>
<div class="action">▶ {e['action'][:120]}{'' if len(e['action'])>120 else ''}</div>
{'<div class="result">⟹ ' + (e['result'][:150] if e['result'] else '') + '</div>' if e['result'] else ''}
</li>"""
ev_html += '</ul>'
else:
ev_html = '<div class="empty"><div class="ico">⊟</div>Journal vide — simulation non démarrée</div>'
# Faction summary
fac_html = ""
for f in factions:
fac_html += f'<tr><td>{f["faction"] or "?"}</td><td>{f["alive"]}</td><td>{f["nb"]}</td></tr>'
# Lore entry
lore_html = ""
if lore_last:
l = lore_last[0]
lore_html = f"""
<div class="card">
<div class="card-header">
<h3>{l['title'] or f"Résumé jours {l['day_start']}{l['day_end']}"}</h3>
<div style="font-size:11px;color:var(--green-dim)">Jours {l['day_start']}{l['day_end']}</div>
</div>
<div style="line-height:1.7;color:var(--green)">{l['content'][:600]}{'' if len(l['content'])>600 else ''}</div>
</div>"""
else:
lore_html = '<div class="empty"><div class="ico">◎</div>Pas encore de lore synthétisé</div>'
content = f"""
<div class="ticker">
▌ VAULTCOM ACTIVE ▌ SESSION: {sess['name'] if sess else 'N/A'}
AGENTS: {len(chars)} ▌ ACTIONS EN ATTENTE: {pending}
<span class="blink">█</span>
</div>
<div class="grid grid-5" style="margin-bottom:20px">{char_cards}</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Journal récent</h3></div>
{ev_html}
</div>
<div>
<div class="card" style="margin-bottom:16px">
<div class="card-header"><h3>Factions actives</h3></div>
<table>
<tr><th>Faction</th><th>Vivants</th><th>Total</th></tr>
{fac_html if fac_html else '<tr><td colspan="3" style="color:var(--green-dim);text-align:center">—</td></tr>'}
</table>
</div>
<div>
<h3 style="margin-bottom:10px">Dernière synthèse narrative</h3>
{lore_html}
</div>
</div>
</div>
"""
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"""
<tr>
<td><a href="/character/{c['id']}">{c['name']}</a></td>
<td style="color:var(--amber)">{c['faction'] or ''}</td>
<td>{c['location'] or '?'}</td>
<td>
<div class="bar-track" style="width:80px;display:inline-block">
<div class="bar-fill {bc}" style="width:{hp_pct}%"></div>
</div>
<span style="font-size:11px;margin-left:6px">{c['hp_current']}/{c['hp_max']}</span>
</td>
<td>{c['caps']}</td>
<td class="status-{c['status']}">{c['status'].upper()}</td>
<td style="font-size:11px;color:var(--green-dim)">{c['xp']} XP</td>
</tr>"""
content = f"""
<div class="card">
<table>
<tr><th>Nom</th><th>Faction</th><th>Lieu</th><th>HP</th><th>Caps</th><th>Status</th><th>XP</th></tr>
{rows if rows else '<tr><td colspan="7" class="empty">Aucun personnage</td></tr>'}
</table>
</div>"""
return render("Agents", content, "chars", sess)
@app.route("/character/<int:cid>")
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"""
<div class="special-box">
<div class="val">{c[key]}</div>
<div class="label">{abbr}</div>
</div>""" 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"<tr><td>{i['item_name']}</td><td>{i.get('quantity',1)}</td><td style='color:var(--green-dim);font-size:12px'>{i.get('description','')}</td></tr>"
for i in inv) if inv else "<tr><td colspan='3' style='color:var(--green-dim)'>Inventaire vide</td></tr>"
ev_html = ""
if events:
ev_html = '<ul class="timeline">'
for e in events:
ev_html += f"""
<li>
<div class="meta">Jour {e['day_in_game']}h{e['hour_in_game']:02d} · {e['location'] or '?'}</div>
<div class="action">▶ {e['action']}</div>
{'<div class="result">⟹ ' + (e['result'] or '') + '</div>' if e['result'] else ''}
</li>"""
ev_html += '</ul>'
else:
ev_html = '<div class="empty">Aucune action enregistrée</div>'
bio_html = f'<div style="color:var(--green-dim);line-height:1.7;margin-top:8px">{c["bio"] or "Dossier vide."}</div>'
content = f"""
<div class="card" style="margin-bottom:20px">
<div style="display:flex;justify-content:space-between;align-items:flex-start">
<div>
<h2 style="font-size:2rem">{c['name']}</h2>
<div style="color:var(--amber);margin:4px 0">{c['faction'] or ''}</div>
<div style="color:var(--green-dim);font-size:12px">Localisation: {c['location'] or '?'} · Caps: {c['caps']} · XP: {c['xp']}</div>
</div>
<span class="badge badge-dim status-{c['status']}">{c['status'].upper()}</span>
</div>
<div class="hp-display">
<span class="hp-num {nc}">{c['hp_current']}</span>
<div style="flex:1">
<div style="font-size:11px;color:var(--green-dim);margin-bottom:4px">/{c['hp_max']} HP</div>
<div class="bar-track"><div class="bar-fill {bc}" style="width:{hp_pct}%"></div></div>
</div>
</div>
<div class="special-grid">{sp_html}</div>
{bio_html}
</div>
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Inventaire</h3></div>
<table>
<tr><th>Objet</th><th>Qté</th><th>Description</th></tr>
{inv_rows}
</table>
</div>
<div class="card">
<div class="card-header"><h3>Journal d'actions</h3></div>
{ev_html}
</div>
</div>"""
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'<span class="badge badge-{cls}">{s.upper()}</span>'
trs = "".join(f"""
<tr>
<td>J{r['day_in_game']}h{r['hour_in_game']:02d}</td>
<td><a href="/character/{r['character_id']}">{r['char_name'] or '?'}</a></td>
<td>{r['location'] or '?'}</td>
<td>{r['action_text'][:80]}{'' if len(r['action_text'])>80 else ''}</td>
<td>{r['target_name'] or ''}</td>
<td>{r['scene_id'] or ''}</td>
<td>{status_badge(r['status'])}</td>
<td style="font-size:12px;color:var(--green-dim)">{(r['result_text'] or '')[:60]}</td>
</tr>""" for r in rows) if rows else "<tr><td colspan='8' class='empty'>Aucune action</td></tr>"
content = f"""
<div class="card">
<table>
<tr><th>Moment</th><th>Agent</th><th>Lieu</th><th>Action</th><th>Cible</th><th>Scène</th><th>Status</th><th>Résultat</th></tr>
{trs}
</table>
</div>"""
return render("File d'Actions", content, "actions", sess)
@app.route("/world")
def world():
sess = get_session()
if not sess:
return render("État du Monde", '<div class="empty">DB offline</div>', "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"""
<tr>
<td style="color:var(--amber)">{s['location']}</td>
<td>{s['controlling_faction'] or ''}</td>
<td>
<div class="bar-track" style="width:80px;display:inline-block">
<div class="bar-fill" style="width:{s['stability']}%;background:{stab_color(s['stability'])}"></div>
</div>
<span style="font-size:11px;margin-left:6px;color:{stab_color(s['stability'])}">{s['stability']}%</span>
</td>
<td>{s['population'] or '?'}</td>
<td style="font-size:12px;color:var(--green-dim)">{s['notes'] or ''}</td>
</tr>""" for s in states) if states else "<tr><td colspan='5' class='empty'>Aucune donnée</td></tr>"
fac_rows = "".join(f"""
<tr>
<td style="color:var(--amber)">{f['faction_a']}</td>
<td>{f['faction_b']}</td>
<td style="color:{'var(--green)' if f['relation_score']>0 else 'var(--red)' if f['relation_score']<0 else 'var(--green-dim)'}">{'+' if f['relation_score']>0 else ''}{f['relation_score']}</td>
<td style="font-size:12px;color:var(--green-dim)">{f.get('notes','')}</td>
</tr>""" for f in factions) if factions else "<tr><td colspan='4' class='empty'>Aucune donnée</td></tr>"
content = f"""
<div class="grid grid-2">
<div class="card">
<div class="card-header"><h3>Zones — Jour {day}</h3></div>
<table>
<tr><th>Lieu</th><th>Faction</th><th>Stabilité</th><th>Pop.</th><th>Notes</th></tr>
{world_rows}
</table>
</div>
<div class="card">
<div class="card-header"><h3>Relations entre factions</h3></div>
<table>
<tr><th>Faction A</th><th>Faction B</th><th>Score</th><th>Notes</th></tr>
{fac_rows}
</table>
</div>
</div>"""
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"""
<div class="card">
<div class="card-header" style="display:flex;justify-content:space-between">
<h3>{e['title'] or f"Chronique jours {e['day_start']}{e['day_end']}"}</h3>
<span class="badge badge-dim">J{e['day_start']}{e['day_end']}</span>
</div>
<div style="font-size:11px;color:var(--green-dim);margin-bottom:12px">
Factions: {facs} · Lieux: {locs}
</div>
<div style="line-height:1.8;color:var(--green)">{e['content']}</div>
</div>"""
else:
cards = '<div class="empty"><div class="ico">◎</div>Aucune synthèse narrative générée<br><span style="font-size:12px">Le MJ produira des entrées toutes les 24h de jeu simulé</span></div>'
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"""
<tr>
<td>J{e['day_in_game']}h{e['hour_in_game']:02d}</td>
<td><a href="/character/{e['character_id']}">{e['char_name']}</a></td>
<td style="color:var(--green-dim)">{e['location'] or '?'}</td>
<td style="color:var(--amber)">{e['action'][:80]}{'' if len(e['action'])>80 else ''}</td>
<td style="font-size:12px">{(e['result'] or '')[:100]}{'' if e['result'] and len(e['result'])>100 else ''}</td>
</tr>""" for e in evs) if evs else "<tr><td colspan='5' class='empty'>Journal vide</td></tr>"
content = f"""
<div class="card">
<table>
<tr><th>Moment</th><th>Agent</th><th>Lieu</th><th>Action</th><th>Résultat</th></tr>
{rows}
</table>
</div>"""
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)