1050 lines
44 KiB
Python
1050 lines
44 KiB
Python
"""
|
||
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"""<!DOCTYPE html>
|
||
<html lang="fr">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||
<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: 18px; letter-spacing: 4px; text-align: center; padding: 8px 0;
|
||
border-bottom: 1px solid var(--pip-green); margin-bottom: 8px; }
|
||
|
||
/* ---- NAV TABS ---- */
|
||
.nav { display: flex; gap: 4px; margin-bottom: 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
|
||
.nav a { padding: 4px 14px; text-decoration: none; color: #555;
|
||
border: 1px solid #222; font-size: 12px; letter-spacing: 1px; }
|
||
.nav a.active { color: var(--pip-amber); border-color: var(--pip-amber); background: #1a1000; }
|
||
.nav a:hover:not(.active) { color: var(--pip-green); border-color: var(--border); }
|
||
.nav-sep { flex-grow: 1; }
|
||
.nav .session-btn { border-color: var(--border); }
|
||
.nav .session-btn.active { color: var(--pip-green); border-color: var(--pip-green); background: #001a00; }
|
||
|
||
/* ---- LAYOUT ---- */
|
||
.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: 8px; font-size: 11px; color: var(--pip-amber); flex-wrap: wrap; gap: 4px; }
|
||
.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; }
|
||
.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; }
|
||
.refresh-note { font-size: 10px; color: #555; }
|
||
|
||
/* ---- PARAMS TAB ---- */
|
||
.cfg-block { margin-bottom: 12px; }
|
||
.cfg-block h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px;
|
||
border-bottom: 1px solid var(--border); padding-bottom: 3px; margin-bottom: 6px; }
|
||
.cfg-row { display: flex; align-items: center; gap: 8px; margin-bottom: 4px; }
|
||
.cfg-row label { min-width: 220px; font-size: 11px; color: #aaa; }
|
||
.cfg-row input[type=number], .cfg-row input[type=text], .cfg-row select {
|
||
background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
|
||
padding: 2px 6px; font-family: 'Courier New', monospace; font-size: 11px; width: 120px; }
|
||
.cfg-row input[type=checkbox] { accent-color: var(--pip-green); }
|
||
.btn { padding: 5px 16px; border: 1px solid var(--pip-amber); background: #1a1000;
|
||
color: var(--pip-amber); font-family: 'Courier New', monospace; font-size: 12px;
|
||
cursor: pointer; letter-spacing: 1px; margin-top: 4px; }
|
||
.btn:hover { background: #2a2000; }
|
||
.btn-red { border-color: var(--pip-red); color: var(--pip-red); background: #1a0000; }
|
||
.btn-red:hover { background: #2a0000; }
|
||
.btn-green { border-color: var(--pip-green); color: var(--pip-green); background: #001a00; }
|
||
.btn-green:hover { background: #002a00; }
|
||
.msg-ok { color: var(--pip-green); font-size: 11px; }
|
||
.msg-err { color: var(--pip-red); font-size: 11px; }
|
||
.mode-badge { display: inline-block; padding: 2px 8px; border: 1px solid var(--pip-amber);
|
||
color: var(--pip-amber); font-size: 11px; letter-spacing: 1px; }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<h1>⚡ VAULTCOM — VENICE OF WASTELAND ⚡</h1>
|
||
|
||
<!-- ===== NAVIGATION ===== -->
|
||
<div class="nav">
|
||
{% for s in all_sessions %}
|
||
<a href="/?sid={{ s.id }}" class="session-btn {{ 'active' if s.id == current_sid else '' }}">
|
||
S{{ s.id }} — {{ s.name[:22] }}
|
||
<span style="font-size:9px;color:{{ '#00ff41' if s.status=='active' else '#ff4444' }}">
|
||
● {{ s.status[:3].upper() }}
|
||
</span>
|
||
</a>
|
||
{% endfor %}
|
||
<span class="nav-sep"></span>
|
||
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' else '' }}">📡 SIMULATION</a>
|
||
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' else '' }}">⚙ PARAMÈTRES</a>
|
||
<a href="/lore" class="{{ 'active' if tab=='lore' else '' }}">📜 LORE</a>
|
||
</div>
|
||
|
||
{% if flash_msg %}
|
||
<div style="padding:6px 10px; margin-bottom:8px; border:1px solid {{ '#00ff41' if flash_ok else '#ff4444' }}; color:{{ '#00ff41' if flash_ok else '#ff4444' }}; font-size:11px;">
|
||
{{ flash_msg }}
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% if tab == 'sim' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET SIMULATION -->
|
||
<!-- ===================================================== -->
|
||
|
||
{% if session %}
|
||
<div class="header-bar">
|
||
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
|
||
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
|
||
<span class="mode-badge">{{ session.mode | upper }}</span>
|
||
<span>STATUS: <strong>{{ session.status | upper }}</strong></span>
|
||
<span>SEED: {{ session.seed_global }}</span>
|
||
<span class="refresh-note">🔄 <a href="?sid={{ session.id }}" style="color:#555;text-decoration:none">Actualiser</a>
|
||
<span id="countdown"></span></span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="grid">
|
||
<!-- PNJ -->
|
||
<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 -->
|
||
<div class="panel">
|
||
<h2>📡 ÉVÉNEMENTS RÉCENTS</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>
|
||
|
||
<div class="grid3">
|
||
<!-- World state -->
|
||
<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 factions -->
|
||
<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 -->
|
||
<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>
|
||
|
||
<!-- Encounters -->
|
||
<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>
|
||
|
||
<!-- Boss inventory -->
|
||
{% 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 %}
|
||
|
||
<script>
|
||
// Countdown auto-refresh 30s
|
||
var t = 30;
|
||
var el = document.getElementById('countdown');
|
||
if (el) {
|
||
setInterval(function() {
|
||
t--;
|
||
el.textContent = ' (' + t + 's)';
|
||
if (t <= 0) { location.reload(); }
|
||
}, 1000);
|
||
}
|
||
</script>
|
||
|
||
{% elif tab == 'lore' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET LORE -->
|
||
<!-- ===================================================== -->
|
||
<style>
|
||
.lore-card { background: var(--panel); border: 1px solid var(--border); padding: 10px; margin-bottom: 8px; }
|
||
.lore-card h3 { font-size: 11px; color: var(--pip-amber); letter-spacing: 1px; margin-bottom: 6px; }
|
||
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
|
||
.diff-old { background: #1a0000; border: 1px solid #3a0000; padding: 6px; font-size: 10px; color: #ff8888; white-space: pre-wrap; }
|
||
.diff-new { background: #001a00; border: 1px solid #003a00; padding: 6px; font-size: 10px; color: #88ff88; white-space: pre-wrap; }
|
||
.diff-label { font-size: 10px; color: #666; margin-bottom: 2px; }
|
||
.lore-meta { font-size: 10px; color: #666; margin-bottom: 6px; display: flex; gap: 12px; flex-wrap: wrap; }
|
||
.lore-actions { display: flex; gap: 6px; align-items: flex-start; flex-wrap: wrap; }
|
||
.lore-actions textarea { background: #001500; color: var(--pip-green); border: 1px solid var(--border);
|
||
font-family: 'Courier New', monospace; font-size: 10px; padding: 4px; width: 100%; min-height: 60px; resize: vertical; }
|
||
.badge-pending { background: #2a2200; color: var(--pip-amber); }
|
||
.badge-accepted { background: #002a00; color: var(--pip-green); }
|
||
.badge-rejected { background: #2a0000; color: var(--pip-red); }
|
||
.badge-modified { background: #001a2a; color: var(--pip-blue); }
|
||
.lore-filters { display: flex; gap: 8px; margin-bottom: 10px; align-items: center; flex-wrap: wrap; }
|
||
.lore-filters label { font-size: 11px; color: #aaa; }
|
||
.lore-filters select { background: #001a00; color: var(--pip-green); border: 1px solid var(--border);
|
||
font-family: 'Courier New', monospace; font-size: 11px; padding: 2px 6px; }
|
||
.lore-stat-bar { display: flex; gap: 12px; margin-bottom: 10px; font-size: 11px; flex-wrap: wrap; }
|
||
.lore-stat { color: #666; } .lore-stat span { color: var(--pip-amber); }
|
||
.change-type-badge { display: inline-block; padding: 1px 6px; font-size: 10px;
|
||
border: 1px solid #333; color: #888; margin-right: 4px; }
|
||
</style>
|
||
|
||
<div class="header-bar">
|
||
<span>📜 ENRICHISSEMENT LORE — VENICE OF WASTELAND</span>
|
||
<span class="lore-stat-bar">
|
||
<span class="lore-stat">En attente : <span>{{ lore_stats.pending }}</span></span>
|
||
<span class="lore-stat">Acceptées : <span style="color:var(--pip-green)">{{ lore_stats.accepted }}</span></span>
|
||
<span class="lore-stat">Rejetées : <span style="color:#ff4444">{{ lore_stats.rejected }}</span></span>
|
||
<span class="lore-stat">Modifiées : <span style="color:var(--pip-blue)">{{ lore_stats.modified }}</span></span>
|
||
</span>
|
||
</div>
|
||
|
||
<div class="lore-filters">
|
||
<label>Faction :</label>
|
||
<select onchange="location.href='/lore?faction='+this.value+'&status='+document.getElementById('filt-status').value">
|
||
<option value="" {{ 'selected' if not filt_faction else '' }}>— toutes —</option>
|
||
{% for f in all_factions %}
|
||
<option value="{{ f }}" {{ 'selected' if filt_faction==f else '' }}>{{ f }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<label>Statut :</label>
|
||
<select id="filt-status" onchange="location.href='/lore?faction='+document.querySelector('[onchange]').value+'&status='+this.value">
|
||
<option value="pending" {{ 'selected' if filt_status=='pending' else '' }}>pending</option>
|
||
<option value="accepted" {{ 'selected' if filt_status=='accepted' else '' }}>accepted</option>
|
||
<option value="rejected" {{ 'selected' if filt_status=='rejected' else '' }}>rejected</option>
|
||
<option value="modified" {{ 'selected' if filt_status=='modified' else '' }}>modified</option>
|
||
<option value="" {{ 'selected' if not filt_status else '' }}>— tous —</option>
|
||
</select>
|
||
<span style="font-size:10px;color:#555">{{ proposals|length }} proposition(s) affichée(s)</span>
|
||
</div>
|
||
|
||
{% if not proposals %}
|
||
<div class="panel" style="color:#555;text-align:center;padding:20px">
|
||
Aucune proposition lore.<br>
|
||
<small style="color:#333">Lancer : <code>python3 lore_enricher.py --faction grand_krewe</code> sur Ampère</small>
|
||
</div>
|
||
{% endif %}
|
||
|
||
{% for p in proposals %}
|
||
<div class="lore-card">
|
||
<div class="lore-meta">
|
||
<span>#{{ p.id }}</span>
|
||
<span class="badge badge-{{ p.status }}">{{ p.status | upper }}</span>
|
||
<span class="change-type-badge">{{ p.change_type }}</span>
|
||
<span style="color:var(--pip-amber)">{{ p.faction_slug }}</span>
|
||
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
|
||
<span style="color:#444">{{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}</span>
|
||
</div>
|
||
|
||
<h3>{{ p.rationale or '(pas de justification)' }}</h3>
|
||
|
||
<div class="lore-diff">
|
||
<div>
|
||
<div class="diff-label">ORIGINAL</div>
|
||
<div class="diff-old">{{ p.original_text or '(ajout — pas de texte original)' }}</div>
|
||
</div>
|
||
<div>
|
||
<div class="diff-label">PROPOSÉ</div>
|
||
<div class="diff-new">{{ p.proposed_text }}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{% if p.status == 'pending' %}
|
||
<div class="lore-actions">
|
||
<form method="POST" action="/lore/action" style="display:inline">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="accept">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<button type="submit" class="btn btn-green" onclick="return confirm('Accepter cette proposition ?')">✓ ACCEPTER</button>
|
||
</form>
|
||
<form method="POST" action="/lore/action" style="display:inline">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="reject">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<button type="submit" class="btn btn-red">✗ REJETER</button>
|
||
</form>
|
||
<form method="POST" action="/lore/action" style="width:100%;margin-top:4px">
|
||
<input type="hidden" name="id" value="{{ p.id }}">
|
||
<input type="hidden" name="action" value="modify">
|
||
<input type="hidden" name="filt_faction" value="{{ filt_faction }}">
|
||
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||
<textarea name="modified_text" placeholder="Texte modifié (sera utilisé à la place du proposé)...">{{ p.proposed_text }}</textarea>
|
||
<button type="submit" class="btn" style="margin-top:4px">✎ MODIFIER & ACCEPTER</button>
|
||
</form>
|
||
</div>
|
||
{% elif p.status == 'modified' and p.modified_text %}
|
||
<div style="margin-top:6px">
|
||
<div class="diff-label">TEXTE MODIFIÉ ACCEPTÉ</div>
|
||
<div class="diff-new" style="border-color:var(--pip-blue)">{{ p.modified_text }}</div>
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
|
||
{% elif tab == 'params' %}
|
||
<!-- ===================================================== -->
|
||
<!-- ONGLET PARAMÈTRES -->
|
||
<!-- ===================================================== -->
|
||
|
||
{% if session %}
|
||
<div class="header-bar">
|
||
<span>SESSION #{{ session.id }} — {{ session.name }}</span>
|
||
<span class="mode-badge">{{ session.mode | upper }}</span>
|
||
<span>STATUS: <strong style="color:{{ '#00ff41' if session.status=='active' else '#ff4444' }}">{{ session.status | upper }}</strong></span>
|
||
<span>JOUR {{ session.current_day }} / TICK {{ session.current_tick }}h</span>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<div class="grid">
|
||
|
||
<!-- Config JSON éditable -->
|
||
<div class="panel">
|
||
<h2>⚙ CONFIG SIM_{{ '%03d' % current_sid }}.JSON</h2>
|
||
<form method="POST" action="/params/save?sid={{ current_sid }}">
|
||
{% if cfg is not none %}
|
||
|
||
<div class="cfg-block">
|
||
<h3>GÉNÉRAL</h3>
|
||
<div class="cfg-row">
|
||
<label>Nom de la session</label>
|
||
<input type="text" name="name" value="{{ cfg.get('name','') }}" style="width:220px">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Mode actif</label>
|
||
<select name="mode">
|
||
{% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
|
||
<option value="{{ m }}" {{ 'selected' if cfg.get('mode')==m else '' }}>{{ m }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Tick sleep (secondes)</label>
|
||
<input type="number" name="tick_sleep_sec" value="{{ cfg.get('tick',{}).get('tick_sleep_sec',60) }}" step="1" min="0">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>SURVIE</h3>
|
||
<div class="cfg-row">
|
||
<label>drain_speed (multiplicateur)</label>
|
||
<input type="number" name="survival_drain_speed" value="{{ cfg.get('survival',{}).get('drain_speed',1.0) }}" step="0.1" min="0.1" max="5">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>fatigue_hp_per_2pts</label>
|
||
<input type="number" name="survival_fatigue_hp" value="{{ cfg.get('survival',{}).get('fatigue_hp_per_2pts',2) }}" step="1" min="0">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Survie activée</label>
|
||
<input type="checkbox" name="survival_enabled" {{ 'checked' if cfg.get('survival',{}).get('enabled',True) else '' }}>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>RENCONTRES</h3>
|
||
<div class="cfg-row">
|
||
<label>global_rate_multiplier</label>
|
||
<input type="number" name="encounter_rate" value="{{ cfg.get('encounter',{}).get('global_rate_multiplier',1.0) }}" step="0.1" min="0" max="5">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Rencontres activées</label>
|
||
<input type="checkbox" name="encounter_enabled" {{ 'checked' if cfg.get('encounter',{}).get('enabled',True) else '' }}>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>ÉCONOMIE</h3>
|
||
<div class="cfg-row">
|
||
<label>caps_global_multiplier</label>
|
||
<input type="number" name="caps_multiplier" value="{{ cfg.get('economy',{}).get('caps_global_multiplier',1.0) }}" step="0.1" min="0.1">
|
||
</div>
|
||
</div>
|
||
|
||
<button type="submit" class="btn btn-green">💾 SAUVEGARDER CONFIG</button>
|
||
<p style="margin-top:6px;font-size:10px;color:#555">
|
||
⚠ Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 }}).
|
||
Pour effet immédiat : STOP → relancer via terminal.
|
||
</p>
|
||
{% else %}
|
||
<p style="color:#ff4444;font-size:11px">
|
||
Fichier <code>sim_{{ '%03d' % current_sid }}.json</code> introuvable dans <code>{{ sim_configs_dir }}</code>
|
||
</p>
|
||
<p style="color:#555;font-size:10px;margin-top:4px">
|
||
Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config
|
||
</p>
|
||
{% endif %}
|
||
</form>
|
||
</div>
|
||
|
||
<!-- Actions session -->
|
||
<div class="panel">
|
||
<h2>🔧 GESTION SESSION #{{ current_sid }}</h2>
|
||
|
||
<div class="cfg-block">
|
||
<h3>STATUT DB</h3>
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="active">
|
||
<button type="submit" class="btn btn-green">▶ ACTIVER</button>
|
||
</form>
|
||
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="paused">
|
||
<button type="submit" class="btn">⏸ PAUSE</button>
|
||
</form>
|
||
|
||
<form method="POST" action="/params/setstatus?sid={{ current_sid }}" style="display:inline">
|
||
<input type="hidden" name="status" value="stopped">
|
||
<button type="submit" class="btn btn-red">■ STOP</button>
|
||
</form>
|
||
<p style="margin-top:6px;font-size:10px;color:#555">
|
||
Note: ne tue pas le process Python. Mettre "paused" ou "stopped" fait sortir run.py au prochain tick.
|
||
</p>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>RESET JOUR/TICK</h3>
|
||
<form method="POST" action="/params/resetday?sid={{ current_sid }}">
|
||
<div class="cfg-row">
|
||
<label>Remettre au Jour</label>
|
||
<input type="number" name="day" value="1" min="1">
|
||
</div>
|
||
<div class="cfg-row">
|
||
<label>Tick</label>
|
||
<input type="number" name="tick" value="0" min="0" max="23">
|
||
</div>
|
||
<button type="submit" class="btn">⏮ RESET POSITION</button>
|
||
</form>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>MODES DISPONIBLES</h3>
|
||
<table>
|
||
<tr><th>MODE</th><th>DRAIN</th><th>ENCOUNTERS</th><th>CAPS</th></tr>
|
||
{% for m_name, m_vals in modes.items() %}
|
||
<tr>
|
||
<td class="{{ 'tier1' if m_name == cfg.get('mode','') else '' }}">
|
||
{{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}</td>
|
||
<td class="tier3"><small>×{{ m_vals.get('survival',{}).get('drain_speed','—') }}</small></td>
|
||
<td class="tier3"><small>×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}</small></td>
|
||
<td class="tier3"><small>×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}</small></td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
</div>
|
||
|
||
<div class="cfg-block">
|
||
<h3>COMMANDES AMPÈRE</h3>
|
||
<p style="font-size:10px;color:#666;margin-bottom:6px">Copier-coller dans votre terminal SSH :</p>
|
||
<div style="background:#001000;border:1px solid var(--border);padding:6px;font-size:10px;color:#88aa88;">
|
||
<p style="color:#555"># Lancer/relancer S{{ current_sid }} :</p>
|
||
<p>cd /home/ubuntu/fallout-venice/src/engine</p>
|
||
<p>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 &</p>
|
||
<br>
|
||
<p style="color:#555"># Voir les logs :</p>
|
||
<p>tail -f ~/fallout_sim_s{{ current_sid }}.log</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
</div><!-- /grid -->
|
||
{% endif %}
|
||
|
||
<div class="footer">VAULT-TEC VAULTCOM v4.0 | S{{ current_sid }} | J={{ session.current_day if session else '?' }} T={{ session.current_tick if session else '?' }}h</div>
|
||
</body>
|
||
</html>"""
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# 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))
|
||
|
||
CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
|
||
CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
|
||
CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
|
||
|
||
def _chroma_post(path: str, body: dict):
|
||
import urllib.request
|
||
data = __import__("json").dumps(body).encode()
|
||
req = urllib.request.Request(f"{CHROMA_BASE}{path}", data=data, method="POST",
|
||
headers={"Content-Type": "application/json"})
|
||
try:
|
||
with urllib.request.urlopen(req, timeout=10) as r:
|
||
return __import__("json").loads(r.read())
|
||
except Exception:
|
||
return {}
|
||
|
||
def _chroma_get_enriched_col_id():
|
||
import urllib.request
|
||
try:
|
||
with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r:
|
||
cols = __import__("json").loads(r.read())
|
||
for c in cols:
|
||
if c["name"] == CHROMA_COL_OUT:
|
||
return c["id"]
|
||
except Exception:
|
||
pass
|
||
# Créer la collection si absente
|
||
res = _chroma_post("/collections", {"name": CHROMA_COL_OUT, "metadata": {"hnsw:space": "cosine"}})
|
||
return res.get("id")
|
||
|
||
def _upsert_to_chroma_enriched(proposal: dict):
|
||
col_id = _chroma_get_enriched_col_id()
|
||
if not col_id:
|
||
return
|
||
text = proposal.get("modified_text") or proposal.get("proposed_text", "")
|
||
doc_id = f"lore_proposal_{proposal['id']}"
|
||
_chroma_post(f"/collections/{col_id}/upsert", {
|
||
"ids": [doc_id],
|
||
"documents": [text],
|
||
"metadatas": [{
|
||
"faction_slug": proposal.get("faction_slug", ""),
|
||
"change_type": proposal.get("change_type", ""),
|
||
"field_path": proposal.get("field_path") or "",
|
||
"category": "lore_enriched",
|
||
"source": f"lore_proposal_{proposal['id']}",
|
||
}],
|
||
})
|
||
|
||
def get_lore_proposals(faction: str = "", status: str = "pending") -> list[dict]:
|
||
sql = "SELECT * FROM lore_proposals WHERE 1=1"
|
||
params = []
|
||
if faction:
|
||
sql += " AND faction_slug = %s"
|
||
params.append(faction)
|
||
if status:
|
||
sql += " AND status = %s"
|
||
params.append(status)
|
||
sql += " ORDER BY id DESC LIMIT 100"
|
||
return query(sql, params or None)
|
||
|
||
def get_lore_stats() -> dict:
|
||
rows = query("SELECT status, COUNT(*) as n FROM lore_proposals GROUP BY status")
|
||
m = {r["status"]: r["n"] for r in rows}
|
||
return {"pending": m.get("pending",0), "accepted": m.get("accepted",0),
|
||
"rejected": m.get("rejected",0), "modified": m.get("modified",0)}
|
||
|
||
def get_all_factions() -> list[str]:
|
||
rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug")
|
||
return [r["faction_slug"] for r in rows]
|
||
|
||
@app.route("/lore")
|
||
def lore():
|
||
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,))
|
||
filt_faction = request.args.get("faction", "")
|
||
filt_status = request.args.get("status", "pending")
|
||
proposals = get_lore_proposals(filt_faction, filt_status)
|
||
flash_msg = request.args.get("msg")
|
||
flash_ok = request.args.get("ok", "1") == "1"
|
||
return render_template_string(TEMPLATE,
|
||
tab="lore",
|
||
current_sid=sid,
|
||
all_sessions=all_sessions,
|
||
session=session,
|
||
proposals=proposals,
|
||
lore_stats=get_lore_stats(),
|
||
all_factions=get_all_factions(),
|
||
filt_faction=filt_faction,
|
||
filt_status=filt_status,
|
||
flash_msg=flash_msg,
|
||
flash_ok=flash_ok,
|
||
cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR,
|
||
characters=[], alive_count=0, total_count=0,
|
||
events=[], encounters=[], world_state=[],
|
||
faction_relations=[], tier_stats=[], zone_stats=[],
|
||
stats={}, boss_inventory=[],
|
||
)
|
||
|
||
@app.route("/lore/action", methods=["POST"])
|
||
def lore_action():
|
||
prop_id = int(request.form.get("id", 0))
|
||
action = request.form.get("action", "")
|
||
modified_text = request.form.get("modified_text", "").strip()
|
||
filt_faction = request.form.get("filt_faction", "")
|
||
filt_status = request.form.get("filt_status", "pending")
|
||
|
||
proposal = query_one("SELECT * FROM lore_proposals WHERE id = %s", (prop_id,))
|
||
if not proposal:
|
||
return redirect(url_for("lore", msg="Proposition introuvable", ok=0,
|
||
faction=filt_faction, status=filt_status))
|
||
try:
|
||
if action == "accept":
|
||
execute("UPDATE lore_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,))
|
||
_upsert_to_chroma_enriched({**proposal, "modified_text": None})
|
||
msg = f"Proposition #{prop_id} acceptée et indexée dans {CHROMA_COL_OUT}."
|
||
elif action == "reject":
|
||
execute("UPDATE lore_proposals SET status='rejected', reviewed_at=NOW() WHERE id=%s", (prop_id,))
|
||
msg = f"Proposition #{prop_id} rejetée."
|
||
elif action == "modify":
|
||
if not modified_text:
|
||
modified_text = proposal["proposed_text"]
|
||
execute("""UPDATE lore_proposals SET status='modified', modified_text=%s, reviewed_at=NOW()
|
||
WHERE id=%s""", (modified_text, prop_id))
|
||
_upsert_to_chroma_enriched({**proposal, "modified_text": modified_text})
|
||
msg = f"Proposition #{prop_id} modifiée et indexée dans {CHROMA_COL_OUT}."
|
||
else:
|
||
msg = "Action inconnue."
|
||
ok = 1
|
||
except Exception as e:
|
||
msg = f"Erreur: {e}"
|
||
ok = 0
|
||
|
||
return redirect(url_for("lore", msg=msg, ok=ok,
|
||
faction=filt_faction, status=filt_status))
|
||
|
||
@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)
|