diff --git a/.gitignore b/.gitignore
index 4601cd9..ed0ce6a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,6 @@ __pycache__/
Thumbs.db
INFRA_PRIVATE.md
.env
+*.bak
+*.bak_*
+docker-compose.yml.bak_pre_refacto
diff --git a/dashboard/__init__.py b/dashboard/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/app.py b/dashboard/app.py
index 3dca479..0a5f66a 100644
--- a/dashboard/app.py
+++ b/dashboard/app.py
@@ -1,1852 +1,23 @@
"""
PipBoy — Fallout: Venice of Wasteland
Dashboard unifié : Simulation | Outils | Lore enrichment | Paramètres
-(anciennement VAULTCOM)
"""
+from flask import Flask
-import os, json, subprocess, threading, time
-import psycopg2
-import psycopg2.extras
-from flask import Flask, render_template_string, jsonify, request, redirect, url_for
+from .routes import sim, tools, lore, sim_proposals, params
-app = Flask(__name__)
-# ---------------------------------------------------------------------------
-# Stress test — état global (un seul test à la fois)
-# ---------------------------------------------------------------------------
-_stress_state = {
- "running": False,
- "started_at": None,
- "mode": None,
- "rounds": None,
- "pnj_count": None,
- "output": [], # lignes de stdout
- "returncode": None,
- "error": None,
-}
-_stress_lock = threading.Lock()
+def create_app() -> Flask:
+ app = Flask(__name__, template_folder="templates")
-def _run_stress_bg(mode: str, rounds: int, pnj_count: int):
- global _stress_state
- ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
- model_mj = os.getenv("MODEL_MJ", "qwen2.5:14b")
- model_pnj = os.getenv("MODEL_PNJ", "qwen2.5:7b")
- results_path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
+ app.register_blueprint(sim.bp)
+ app.register_blueprint(tools.bp)
+ app.register_blueprint(lore.bp)
+ app.register_blueprint(sim_proposals.bp)
+ app.register_blueprint(params.bp)
- cmd = [
- "python3", "/app/tools/llm_crash_test.py",
- "--mode", mode,
- "--rounds", str(rounds),
- "--pnj-count", str(pnj_count),
- ]
- env = {**os.environ,
- "OLLAMA_URL": ollama_url,
- "MODEL_MJ": model_mj,
- "MODEL_PNJ": model_pnj,
- "CRASH_RESULTS_PATH": results_path,
- "PYTHONUNBUFFERED": "1"}
- try:
- proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
- text=True, env=env)
- for line in proc.stdout:
- with _stress_lock:
- _stress_state["output"].append(line.rstrip())
- proc.wait()
- with _stress_lock:
- _stress_state["returncode"] = proc.returncode
- _stress_state["running"] = False
- except Exception as e:
- with _stress_lock:
- _stress_state["error"] = str(e)
- _stress_state["running"] = False
+ return app
-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"""
-
-
-
-
-PipBoy — Fallout: Venice of Wasteland
-
-
-
-
-
-
-
📟 PIPBOY
-
- {% for s in all_sessions %}
- {% set is_active = s.status == 'active' %}
-
-
- S{{ s.id }}
-
- {% endfor %}
-
-
-
-
-
-{% 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 }}
- {% set st = session.status %}
-
-
- {{ st | upper }}
-
-
Seed: {{ session.seed_global }}
-
- ↻ Actualiser
-
-
-
-{% endif %}
-
-
-
-
-
PNJ Vivants
-
{{ alive_count }}
-
-
-
PNJ Morts
-
{{ stats.get('PNJ morts', 0) }}
-
-
-
Total PNJ
-
{{ total_count }}
-
-
-
Combats
-
{{ stats.get('Combats', 0) }}
-
-
-
Rencontres
-
{{ stats.get('Rencontres', 0) }}
-
-
-
Événements
-
{{ stats.get('Événements total', 0) }}
-
-
-
-
-
-
-
👥 PNJ ({{ alive_count }}/{{ total_count }})
-
-
-
-
-
-
📡 Événements récents
-
-
-
-
-
-
-
-
-
-
-
⚔ Relations factions
-
-
-
-
-
-
🎭 PNJ par tier
-
- Tier Label Vivants Morts
- {% for row in tier_stats %}
-
- {{ row.tier }}
- {{ row.label }}
- {{ row.alive }}
- {{ row.dead }}
-
- {% endfor %}
-
-
🏙 PNJ par zone (top 5)
-
- Zone PNJ
- {% for row in zone_stats %}
-
- {{ row.location_slug }}
- {{ row.cnt }}
-
- {% endfor %}
-
-
-
-
-
-
-
⚡ Rencontres récentes
-
-
-
-
-{% if boss_inventory %}
-
-
★ Équipement chefs de faction
-
- Boss Item Type Qté Équipé
- {% for row in boss_inventory %}
-
- {{ row.char_name }}
- {{ row.item_name }}
- {{ row.item_type }}
- {{ row.quantity }}
- {{ '✓' if row.is_equipped else '' }}
-
- {% endfor %}
-
-
-{% endif %}
-
-
-
-{% elif tab == 'tools' %}
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-{% elif tab == 'lore' %}
-
-
-
-
-
-
📜 Enrichissement Lore — Venice of Wasteland
-
- En attente : {{ lore_stats.pending }}
- Acceptées : {{ lore_stats.accepted }}
- Rejetées : {{ lore_stats.rejected }}
- Modifiées : {{ lore_stats.modified }}
-
-
-
-
- Faction :
-
- — toutes —
- {% for f in all_factions %}
- {{ f }}
- {% endfor %}
-
- Statut :
-
- pending
- accepted
- rejected
- modified
- — tous —
-
- {{ proposals|length }} proposition(s) affichée(s)
-
-
-{% if not proposals %}
-
- Aucune proposition lore.
- Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère
-
-{% endif %}
-
-{% for p in proposals %}
-
-
- #{{ p.id }}
- {{ p.status | upper }}
- {{ p.change_type }}
- {{ p.faction_slug }}
- {% if p.field_path %}{{ p.field_path }} {% endif %}
- {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}
-
-
-
{{ p.rationale or '(pas de justification)' }}
-
-
-
-
Original
-
{{ p.original_text or '(ajout — pas de texte original)' }}
-
-
-
Proposé
-
{{ p.proposed_text }}
-
-
-
- {% if p.status == 'pending' %}
-
-
-
-
-
- {% elif p.status == 'modified' and p.modified_text %}
-
-
Texte modifié accepté
-
{{ p.modified_text }}
-
- {% endif %}
-
-{% endfor %}
-
-{% elif tab == 'params' %}
-
-
-
-
-{% if session %}
-
- Session #{{ session.id }} — {{ session.name }}
- {{ session.mode | upper }}
- {% set st = session.status %}
-
-
- {{ st | upper }}
-
- Jour {{ session.current_day }} / Tick {{ session.current_tick }}h
-
-{% endif %}
-
-
-
-
-
-
⚙ Config sim_{{ '%03d' % current_sid }}.json
-
- {% if cfg is not none %}
-
-
-
Général
-
- Nom de la session
-
-
-
- Mode actif
-
- {% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
- {{ m }}
- {% endfor %}
-
-
-
- Tick sleep (secondes)
-
-
-
-
-
-
-
-
Rencontres
-
- global_rate_multiplier
-
-
-
- Rencontres activées
-
-
-
-
-
-
Économie
-
- caps_global_multiplier
-
-
-
-
- 💾 Sauvegarder config
-
- Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 }}).
- Pour effet immédiat : STOP → relancer via terminal.
-
- {% else %}
-
- Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}
-
-
- Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config
-
- {% endif %}
-
-
-
-
-
-
🔧 Gestion session #{{ current_sid }}
-
-
-
Statut DB
-
-
-
- ▶ Activer
-
-
-
- ⏸ Pause
-
-
-
- ■ Stop
-
-
-
- STOP → la sim s'arrête dans les 5 ticks suivants. Puis RESET JOUR/TICK → relancer via terminal.
-
-
-
-
-
-
-
Modes disponibles
-
- Mode Drain Encounters Caps
- {% for m_name, m_vals in modes.items() %}
-
-
- {{ m_name }}{% if m_name == cfg.get('mode','') %} ◀{% endif %}
- ×{{ m_vals.get('survival',{}).get('drain_speed','—') }}
- ×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}
- ×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}
-
- {% endfor %}
-
-
-
-
-
Commandes SSH (référence)
-
Copier-coller dans votre terminal SSH. Pour les commandes générées, utiliser l'onglet OUTILS.
-
-
tail -f ~/fallout_sim_s{{ current_sid }}.log Copier
-
-
-
-
-
-
-
-
-{% 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("/tools")
-def tools():
- 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,))
- return render_template_string(TEMPLATE,
- tab="tools",
- current_sid=sid,
- all_sessions=all_sessions,
- session=session,
- flash_msg=None,
- flash_ok=True,
- cfg=None, modes={}, sim_configs_dir=SIM_CONFIGS_DIR,
- proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0},
- all_factions=[], filt_faction="", filt_status="",
- characters=[], alive_count=0, total_count=0,
- events=[], encounters=[], world_state=[],
- faction_relations=[], tier_stats=[], zone_stats=[],
- stats={}, boss_inventory=[],
- )
-
-@app.route("/api/stress-test/results")
-def api_stress_test_results():
- path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
- if not os.path.exists(path):
- return jsonify({"error": "Aucun résultat. Lancez d'abord le test."})
- try:
- import datetime
- mtime = os.path.getmtime(path)
- file_date = datetime.datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
- with open(path) as f:
- data = json.load(f)
- data["file_date"] = file_date
- return jsonify(data)
- except Exception as e:
- return jsonify({"error": f"Erreur lecture : {e}"})
-
-@app.route("/api/stress-test/run", methods=["POST"])
-def api_stress_test_run():
- global _stress_state
- with _stress_lock:
- if _stress_state["running"]:
- return jsonify({"error": "Un test est déjà en cours.", "running": True})
- mode = request.json.get("mode", "simultane")
- rounds = int(request.json.get("rounds", 5))
- pnj_count = int(request.json.get("pnj_count", 2))
- _stress_state = {
- "running": True,
- "started_at": time.strftime("%H:%M:%S"),
- "mode": mode,
- "rounds": rounds,
- "pnj_count": pnj_count,
- "output": [],
- "returncode": None,
- "error": None,
- }
- t = threading.Thread(target=_run_stress_bg, args=(mode, rounds, pnj_count), daemon=True)
- t.start()
- return jsonify({"ok": True, "message": f"Test '{mode}' {rounds} rounds lancé."})
-
-@app.route("/api/stress-test/status")
-def api_stress_test_status():
- with _stress_lock:
- return jsonify({
- "running": _stress_state["running"],
- "started_at": _stress_state["started_at"],
- "mode": _stress_state["mode"],
- "rounds": _stress_state["rounds"],
- "output": _stress_state["output"][-60:], # 60 dernières lignes
- "returncode": _stress_state["returncode"],
- "error": _stress_state["error"],
- })
-
-@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
- proposals=[], lore_stats={"pending":0,"accepted":0,"rejected":0,"modified":0},
- all_factions=[], filt_faction="", filt_status="",
- 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)
+ create_app().run(host="0.0.0.0", port=5000, debug=False)
diff --git a/dashboard/chroma.py b/dashboard/chroma.py
new file mode 100644
index 0000000..48a249e
--- /dev/null
+++ b/dashboard/chroma.py
@@ -0,0 +1,97 @@
+import os
+import json
+import urllib.request
+
+CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
+CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
+CHROMA_SIM_COL = os.getenv("CHROMA_SIM_COL", "fallout_sim_enriched")
+CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
+
+
+def _chroma_post(path: str, body: dict) -> dict:
+ data = 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 json.loads(r.read())
+ except Exception:
+ return {}
+
+
+def _get_or_create_col(name: str) -> str | None:
+ try:
+ with urllib.request.urlopen(f"{CHROMA_BASE}/collections", timeout=5) as r:
+ cols = json.loads(r.read())
+ for c in cols:
+ if c["name"] == name:
+ return c["id"]
+ except Exception:
+ pass
+ res = _chroma_post("/collections", {"name": name, "metadata": {"hnsw:space": "cosine"}})
+ return res.get("id")
+
+
+def upsert_lore_enriched(proposal: dict):
+ col_id = _get_or_create_col(CHROMA_COL_OUT)
+ 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": doc_id,
+ }],
+ })
+
+
+def upsert_sim_enriched(proposal: dict):
+ col_id = _get_or_create_col(CHROMA_SIM_COL)
+ if not col_id:
+ return
+ patch_str = json.dumps(proposal.get("json_patch") or {}, ensure_ascii=False)
+ text = (
+ f"{proposal.get('title','')}\n"
+ f"{proposal.get('description','')}\n"
+ f"Patch: {patch_str}\n"
+ f"Rationale: {proposal.get('rationale','')}"
+ )
+ doc_id = f"sim_proposal_{proposal['id']}"
+ _chroma_post(f"/collections/{col_id}/upsert", {
+ "ids": [doc_id],
+ "documents": [text],
+ "metadatas": [{
+ "proposal_type": proposal.get("proposal_type", ""),
+ "title": proposal.get("title", ""),
+ "category": "sim_enriched",
+ "source": doc_id,
+ }],
+ })
+
+
+def upsert_sim_reject(proposal: dict, reason: str):
+ col_id = _get_or_create_col(CHROMA_SIM_COL + "_rejects")
+ if not col_id:
+ return
+ neg_text = (
+ f"REJET — {proposal.get('proposal_type','')}: {proposal.get('title','')}\n"
+ f"Motif: {reason}\n"
+ f"Description: {proposal.get('description','')}"
+ )
+ _chroma_post(f"/collections/{col_id}/upsert", {
+ "ids": [f"reject_{proposal['id']}"],
+ "documents": [neg_text],
+ "metadatas": [{
+ "proposal_type": proposal.get("proposal_type", ""),
+ "category": "negative_example",
+ "source": f"reject_{proposal['id']}",
+ }],
+ })
diff --git a/dashboard/config.py b/dashboard/config.py
new file mode 100644
index 0000000..ccf3842
--- /dev/null
+++ b/dashboard/config.py
@@ -0,0 +1,26 @@
+import os
+import json
+
+SIM_CONFIGS_DIR = os.getenv("SIM_CONFIGS_DIR", "/app/src/config")
+
+_REFERENCE_CONFIG_ID = 1
+
+
+def load_sim_config(session_id: int) -> dict | None:
+ 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 None
+
+
+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 load_all_modes() -> dict:
+ cfg = load_sim_config(_REFERENCE_CONFIG_ID) or {}
+ return cfg.get("modes", {})
diff --git a/dashboard/db.py b/dashboard/db.py
new file mode 100644
index 0000000..f14420e
--- /dev/null
+++ b/dashboard/db.py
@@ -0,0 +1,45 @@
+import os
+import psycopg2
+import psycopg2.extras
+
+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_PASSWORD", "VeniceOfWasteland2026!")
+
+
+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 get_all_sessions():
+ return query("SELECT * FROM sessions ORDER BY id")
diff --git a/dashboard/process_mgr.py b/dashboard/process_mgr.py
new file mode 100644
index 0000000..86d1d86
--- /dev/null
+++ b/dashboard/process_mgr.py
@@ -0,0 +1,152 @@
+import os
+import subprocess
+import threading
+
+_procs = {}
+_procs_lock = threading.Lock()
+
+_PROC_DEFS = {
+ "sim_enricher": {
+ "label": "Enrichissement règles sim (LLM → sim_proposals)",
+ "cmd": ["python3", "-u", "/opt/coyote/pipelines/sim_enricher.py", "--type", "all"],
+ "env_extra": {"CHROMA_URL": "http://chromadb:8000"},
+ },
+ "lore_enricher": {
+ "label": "Enrichissement lore (LLM → lore_proposals)",
+ "cmd": ["python3", "-u", "/app/src/engine/lore_enricher.py", "--faction", "all"],
+ "env_extra": {"CHROMA_URL": "http://chromadb:8000"},
+ },
+ "embed_fallout": {
+ "label": "Re-embed PDFs Fallout → fallout_lore",
+ "cmd": ["python3", "-u", "/opt/coyote/pipelines/embed_fallout.py"],
+ "env_extra": {"CHROMA_URL": "http://chromadb:8000"},
+ },
+ "embed_sim": {
+ "label": "Re-embed règles sim → fallout_sim_rules",
+ "cmd": ["python3", "-u", "/opt/coyote/pipelines/embed_sim.py"],
+ "env_extra": {"CHROMA_URL": "http://chromadb:8000"},
+ },
+}
+
+
+def _run_proc_loop_bg(name, cmd, env_extra=None, count=1):
+ env = {**os.environ, **(env_extra or {}), "PYTHONUNBUFFERED": "1"}
+ with _procs_lock:
+ _procs[name] = {"proc": None, "output": [], "running": True,
+ "returncode": None, "error": None, "iteration": 0, "total": count}
+ iteration = 0
+ try:
+ while count == 0 or iteration < count:
+ iteration += 1
+ sep = f"─── Itération {iteration}" + (f"/{count}" if count else "/∞") + " ───"
+ with _procs_lock:
+ _procs[name]["output"].append(sep)
+ _procs[name]["iteration"] = iteration
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, env=env)
+ with _procs_lock:
+ _procs[name]["proc"] = proc
+ for line in proc.stdout:
+ with _procs_lock:
+ _procs[name]["output"].append(line.rstrip())
+ proc.wait()
+ if proc.returncode is not None and proc.returncode < 0:
+ break
+ with _procs_lock:
+ if not _procs[name]["running"]:
+ break
+ rc = proc.returncode
+ if rc != 0:
+ with _procs_lock:
+ _procs[name]["returncode"] = rc
+ break
+ with _procs_lock:
+ if _procs[name]["returncode"] is None:
+ _procs[name]["returncode"] = 0
+ _procs[name]["running"] = False
+ except Exception as e:
+ with _procs_lock:
+ _procs[name]["error"] = str(e)
+ _procs[name]["running"] = False
+
+
+def _run_mix_bg(count=10):
+ name = "mix"
+ sim_cmd = list(_PROC_DEFS["sim_enricher"]["cmd"])
+ lore_cmd = list(_PROC_DEFS["lore_enricher"]["cmd"])
+ env = {**os.environ, "CHROMA_URL": "http://chromadb:8000", "PYTHONUNBUFFERED": "1"}
+ with _procs_lock:
+ _procs[name] = {"proc": None, "output": [], "running": True,
+ "returncode": None, "error": None, "iteration": 0, "total": count * 2}
+ iteration = 0
+ try:
+ for script_name, cmd in [("sim_enricher", sim_cmd), ("lore_enricher", lore_cmd)]:
+ i = 0
+ while count == 0 or i < count:
+ i += 1
+ iteration += 1
+ sep = f"─── MIX {script_name} {i}" + (f"/{count}" if count else "/∞") + " ───"
+ with _procs_lock:
+ if not _procs[name]["running"]:
+ raise StopIteration
+ _procs[name]["output"].append(sep)
+ _procs[name]["iteration"] = iteration
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, env=env)
+ with _procs_lock:
+ _procs[name]["proc"] = proc
+ for line in proc.stdout:
+ with _procs_lock:
+ _procs[name]["output"].append(line.rstrip())
+ proc.wait()
+ if proc.returncode is not None and proc.returncode < 0:
+ raise StopIteration
+ with _procs_lock:
+ if not _procs[name]["running"]:
+ raise StopIteration
+ with _procs_lock:
+ _procs[name]["returncode"] = 0
+ _procs[name]["running"] = False
+ except StopIteration:
+ with _procs_lock:
+ _procs[name]["returncode"] = 0
+ _procs[name]["running"] = False
+ except Exception as e:
+ with _procs_lock:
+ _procs[name]["error"] = str(e)
+ _procs[name]["running"] = False
+
+
+def proc_start(name: str, cmd: list, env_extra: dict, count: int):
+ t = threading.Thread(target=_run_proc_loop_bg,
+ args=(name, cmd, env_extra, count), daemon=True)
+ t.start()
+
+
+def proc_mix_start(count: int):
+ t = threading.Thread(target=_run_mix_bg, args=(count,), daemon=True)
+ t.start()
+
+
+def proc_get_state(name: str) -> dict:
+ with _procs_lock:
+ state = dict(_procs.get(name, {"running": False, "output": [], "returncode": None, "error": None}))
+ state.pop("proc", None)
+ return state
+
+
+def proc_is_running(name: str) -> bool:
+ with _procs_lock:
+ return _procs.get(name, {}).get("running", False)
+
+
+def proc_stop(name: str):
+ with _procs_lock:
+ p = _procs.get(name, {}).get("proc")
+ if name in _procs:
+ _procs[name]["running"] = False
+ if p:
+ try:
+ p.terminate()
+ except Exception:
+ pass
diff --git a/dashboard/routes/__init__.py b/dashboard/routes/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/dashboard/routes/lore.py b/dashboard/routes/lore.py
new file mode 100644
index 0000000..7aa468f
--- /dev/null
+++ b/dashboard/routes/lore.py
@@ -0,0 +1,95 @@
+from flask import Blueprint, render_template, jsonify, request, redirect, url_for
+
+from ..db import query, query_one, execute, get_all_sessions
+from ..chroma import upsert_lore_enriched, CHROMA_COL_OUT
+
+bp = Blueprint("lore", __name__)
+
+
+def _get_proposals(faction: str = "", status: str = "pending") -> list:
+ 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_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_factions() -> list:
+ rows = query("SELECT DISTINCT faction_slug FROM lore_proposals ORDER BY faction_slug")
+ return [r["faction_slug"] for r in rows]
+
+
+@bp.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_proposals(filt_faction, filt_status)
+ flash_msg = request.args.get("msg")
+ flash_ok = request.args.get("ok", "1") == "1"
+
+ # Threads de discussion
+ threads_raw = query("SELECT * FROM lore_proposal_threads ORDER BY created_at ASC") if proposals else []
+ proposal_threads = {}
+ for row in (threads_raw or []):
+ pid = row["proposal_id"]
+ proposal_threads.setdefault(pid, []).append(row)
+
+ return render_template("lore.html",
+ tab="lore", current_sid=sid, all_sessions=all_sessions, session=session,
+ proposals=proposals, lore_stats=_get_stats(),
+ all_factions=_get_factions(), filt_faction=filt_faction, filt_status=filt_status,
+ proposal_threads=proposal_threads, flash_msg=flash_msg, flash_ok=flash_ok,
+ )
+
+
+@bp.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.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_lore_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_lore_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.lore", msg=msg, ok=ok,
+ faction=filt_faction, status=filt_status))
diff --git a/dashboard/routes/params.py b/dashboard/routes/params.py
new file mode 100644
index 0000000..8a9fd33
--- /dev/null
+++ b/dashboard/routes/params.py
@@ -0,0 +1,83 @@
+from flask import Blueprint, render_template, request, redirect, url_for
+
+from ..db import query_one, execute, get_all_sessions
+from ..config import SIM_CONFIGS_DIR, load_sim_config, save_sim_config, load_all_modes
+
+bp = Blueprint("params", __name__)
+
+
+@bp.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()
+ flash_msg = request.args.get("msg")
+ flash_ok = request.args.get("ok", "1") == "1"
+ return render_template("params.html",
+ 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,
+ )
+
+
+@bp.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.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.params", sid=sid, msg=msg, ok=ok))
+
+
+@bp.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}'."
+ ok = 1
+ except Exception as e:
+ msg = f"Erreur: {e}"
+ ok = 0
+ return redirect(url_for("params.params", sid=sid, msg=msg, ok=ok))
+
+
+@bp.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.params", sid=sid, msg=msg, ok=ok))
diff --git a/dashboard/routes/sim.py b/dashboard/routes/sim.py
new file mode 100644
index 0000000..6272a34
--- /dev/null
+++ b/dashboard/routes/sim.py
@@ -0,0 +1,261 @@
+import os
+import subprocess
+import threading
+
+from flask import Blueprint, render_template, jsonify, request, send_file
+
+from ..db import query, query_one, execute, get_all_sessions
+from ..config import SIM_CONFIGS_DIR
+
+bp = Blueprint("sim", __name__)
+
+_sim_procs = {
+ "truth": {"proc": None, "output": [], "running": False, "returncode": None, "error": None, "sid": None},
+ "enriched": {"proc": None, "output": [], "running": False, "returncode": None, "error": None, "sid": None},
+}
+_sim_lock = threading.Lock()
+
+
+def _run_sim_bg(mode, sid, tick_speed_s):
+ env = {**os.environ,
+ "SESSION_ID": str(sid),
+ "TICK_SLEEP_SEC": str(tick_speed_s),
+ "PYTHONUNBUFFERED": "1"}
+ if mode == "enriched":
+ env["USE_ENRICHED"] = "1"
+ try:
+ row = query_one(
+ "INSERT INTO sim_runs (mode,session_id,tick_speed_s,status,started_at) "
+ "VALUES (%s,%s,%s,'running',NOW()) RETURNING id",
+ (mode, sid, tick_speed_s))
+ run_id = row["id"] if row else None
+ except Exception:
+ run_id = None
+ with _sim_lock:
+ _sim_procs[mode].update({"output": [], "running": True, "returncode": None, "error": None, "sid": sid})
+ try:
+ proc = subprocess.Popen(
+ ["python3", "-u", "/app/src/engine/run.py"],
+ stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, env=env)
+ with _sim_lock:
+ _sim_procs[mode]["proc"] = proc
+ for line in proc.stdout:
+ with _sim_lock:
+ _sim_procs[mode]["output"].append(line.rstrip())
+ proc.wait()
+ with _sim_lock:
+ _sim_procs[mode]["returncode"] = proc.returncode
+ _sim_procs[mode]["running"] = False
+ if run_id:
+ try:
+ execute("UPDATE sim_runs SET status='stopped',stopped_at=NOW() WHERE id=%s", (run_id,))
+ except Exception:
+ pass
+ except Exception as e:
+ with _sim_lock:
+ _sim_procs[mode]["error"] = str(e)
+ _sim_procs[mode]["running"] = False
+
+
+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,
+ )
+
+
+@bp.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("sim.html", tab="sim", current_sid=sid,
+ all_sessions=all_sessions, flash_msg=None, flash_ok=True, **data)
+
+
+@bp.route("/api/sim//run", methods=["POST"])
+def sim_run(mode):
+ if mode not in ("truth", "enriched"):
+ return jsonify({"error": "mode invalide"}), 400
+ with _sim_lock:
+ if _sim_procs[mode].get("running"):
+ return jsonify({"error": f"Simulation {mode} déjà en cours"}), 400
+ data = request.get_json(silent=True) or {}
+ sid = int(data.get("sid", 1))
+ tick_speed_s = int(data.get("tick_speed_s", 5))
+ t = threading.Thread(target=_run_sim_bg, args=(mode, sid, tick_speed_s), daemon=True)
+ t.start()
+ return jsonify({"ok": True, "sid": sid, "mode": mode})
+
+
+@bp.route("/api/sim//status")
+def sim_status(mode):
+ if mode not in ("truth", "enriched"):
+ return jsonify({"error": "mode invalide"}), 400
+ with _sim_lock:
+ state = {k: v for k, v in _sim_procs[mode].items() if k != "proc"}
+ return jsonify(state)
+
+
+@bp.route("/api/sim//stop", methods=["POST"])
+def sim_stop(mode):
+ if mode not in ("truth", "enriched"):
+ return jsonify({"error": "mode invalide"}), 400
+ with _sim_lock:
+ p = _sim_procs[mode].get("proc")
+ if p:
+ try:
+ p.terminate()
+ except Exception:
+ pass
+ return jsonify({"ok": True})
+
+
+@bp.route("/api/sim//log")
+def sim_log(mode):
+ if mode not in ("truth", "enriched"):
+ return jsonify({"error": "mode invalide"}), 400
+ import io
+ import json as _json
+ import datetime as _dt
+ run = query_one("SELECT * FROM sim_runs WHERE mode=%s ORDER BY id DESC LIMIT 1", (mode,))
+ if not run:
+ return jsonify({"error": "Aucun run trouvé — lancez d'abord une simulation"}), 404
+ sid = run["session_id"]
+ session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,))
+ events = query("SELECT * FROM events WHERE session_id=%s ORDER BY id DESC LIMIT 500", (sid,))
+ with _sim_lock:
+ output_lines = list(_sim_procs[mode].get("output", []))
+ log_data = {
+ "meta": {
+ "sim_mode": mode,
+ "session_id": sid,
+ "session_name": session.get("name", "") if session else "",
+ "world": "Venice of Wasteland, Louisiane post-apo",
+ "tick_speed_s": run["tick_speed_s"],
+ "started_at": run["started_at"].isoformat() if run.get("started_at") else None,
+ "stopped_at": run["stopped_at"].isoformat() if run.get("stopped_at") else None,
+ "status": run["status"],
+ "exported_at": _dt.datetime.now().isoformat(),
+ },
+ "console_output": output_lines,
+ "recent_events": [dict(e) for e in events],
+ }
+ buf = io.BytesIO(_json.dumps(log_data, ensure_ascii=False, indent=2, default=str).encode("utf-8"))
+ return send_file(buf, mimetype="application/json", as_attachment=True,
+ download_name=f"sim_{mode}_{sid}.json")
+
+
+@bp.route("/api/session/set-mode", methods=["POST"])
+def session_set_mode():
+ data = request.get_json(silent=True) or {}
+ sid = int(data.get("sid", 1))
+ mode = data.get("mode", "").strip()
+ valid = {"pacifiste", "politique", "guerre_commerciale", "guerre", "survie_extreme"}
+ if mode not in valid:
+ return jsonify({"error": f"Mode invalide: {mode}"}), 400
+ try:
+ execute("UPDATE sessions SET mode=%s WHERE id=%s", (mode, sid))
+ return jsonify({"ok": True, "sid": sid, "mode": mode})
+ except Exception as e:
+ return jsonify({"error": str(e)}), 500
+
+
+@bp.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})
diff --git a/dashboard/routes/sim_proposals.py b/dashboard/routes/sim_proposals.py
new file mode 100644
index 0000000..36b654a
--- /dev/null
+++ b/dashboard/routes/sim_proposals.py
@@ -0,0 +1,138 @@
+import os
+import json
+import urllib.request
+
+from flask import Blueprint, render_template, jsonify, request, redirect, url_for
+
+from ..db import query, query_one, execute, get_all_sessions
+from ..chroma import upsert_sim_enriched, upsert_sim_reject, CHROMA_SIM_COL
+
+bp = Blueprint("sim_proposals", __name__)
+
+
+def _get_proposals(ptype: str = "", status: str = "pending") -> list:
+ sql = "SELECT * FROM sim_proposals WHERE 1=1"
+ params = []
+ if ptype:
+ sql += " AND proposal_type = %s"
+ params.append(ptype)
+ if status:
+ sql += " AND status = %s"
+ params.append(status)
+ sql += " ORDER BY id DESC LIMIT 100"
+ return query(sql, params or None)
+
+
+def _get_stats() -> dict:
+ rows = query("SELECT status, COUNT(*) as n FROM sim_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)}
+
+
+def _get_types() -> list:
+ rows = query("SELECT DISTINCT proposal_type FROM sim_proposals ORDER BY proposal_type")
+ return [r["proposal_type"] for r in rows]
+
+
+@bp.route("/sim-proposals")
+def sim_proposals_view():
+ all_sessions = get_all_sessions()
+ sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
+ filt_type = request.args.get("type", "")
+ filt_status = request.args.get("status", "pending")
+ proposals = _get_proposals(filt_type, filt_status)
+ flash_msg = request.args.get("msg")
+ flash_ok = request.args.get("ok", "1") == "1"
+
+ threads_raw = query("SELECT * FROM sim_proposal_threads ORDER BY created_at ASC") if proposals else []
+ proposal_threads = {}
+ for row in (threads_raw or []):
+ pid = row["proposal_id"]
+ proposal_threads.setdefault(pid, []).append(row)
+
+ return render_template("sim_proposals.html",
+ tab="sim_proposals", current_sid=sid, all_sessions=all_sessions,
+ proposals=proposals, sim_stats=_get_stats(), sim_types=_get_types(),
+ filt_type=filt_type, filt_status=filt_status,
+ proposal_threads=proposal_threads, flash_msg=flash_msg, flash_ok=flash_ok,
+ )
+
+
+@bp.route("/sim-proposals/action", methods=["POST"])
+def sim_proposals_action():
+ prop_id = int(request.form.get("id", 0))
+ action = request.form.get("action", "")
+ filt_type = request.form.get("filt_type", "")
+ filt_status = request.form.get("filt_status", "pending")
+
+ proposal = query_one("SELECT * FROM sim_proposals WHERE id = %s", (prop_id,))
+ if not proposal:
+ return redirect(url_for("sim_proposals.sim_proposals_view", msg="Proposition introuvable", ok=0))
+ try:
+ if action == "accept":
+ execute("UPDATE sim_proposals SET status='accepted', reviewed_at=NOW() WHERE id=%s", (prop_id,))
+ upsert_sim_enriched(dict(proposal))
+ msg = f"Proposition #{prop_id} acceptée → indexée dans {CHROMA_SIM_COL}."
+ elif action == "reject":
+ reason = request.form.get("reject_reason", "").strip()
+ execute("UPDATE sim_proposals SET status='rejected', reviewed_at=NOW(), reject_reason=%s WHERE id=%s",
+ (reason or None, prop_id))
+ if reason:
+ try:
+ upsert_sim_reject(dict(proposal), reason)
+ except Exception:
+ pass
+ msg = f"Proposition #{prop_id} rejetée." + (" Motif indexé." if reason else "")
+ else:
+ msg = "Action inconnue."
+ ok = 1
+ except Exception as e:
+ msg = f"Erreur: {e}"
+ ok = 0
+
+ return redirect(url_for("sim_proposals.sim_proposals_view", msg=msg, ok=ok,
+ type=filt_type, status=filt_status))
+
+
+@bp.route("/sim-proposals//discuss", methods=["POST"])
+def sim_proposals_discuss(prop_id):
+ proposal = query_one("SELECT * FROM sim_proposals WHERE id=%s", (prop_id,))
+ if not proposal:
+ return jsonify({"error": "Proposition introuvable"}), 404
+ data = request.get_json(silent=True) or {}
+ user_msg = data.get("message", "").strip()
+ temperature = float(data.get("temperature", 0.75))
+ num_predict = int(data.get("num_predict", 512))
+ if not user_msg:
+ return jsonify({"error": "Message vide"}), 400
+
+ execute("INSERT INTO sim_proposal_threads (proposal_id,role,content,temperature,num_predict) VALUES (%s,'user',%s,%s,%s)",
+ (prop_id, user_msg, temperature, num_predict))
+
+ context = [
+ "Tu es un assistant de game design pour un JDR Fallout post-apocalyptique (Louisiane, 'Venice of Wasteland').",
+ f"Proposition de règle : [{proposal.get('proposal_type','')}] {proposal.get('title','')}",
+ f"Description : {proposal.get('description','')}",
+ f"Rationale : {proposal.get('rationale','')}",
+ "",
+ f"Le maître de jeu demande : {user_msg}",
+ "",
+ "Réponds de façon concise et pratique. Si tu proposes une version modifiée, structure-la clairement.",
+ ]
+ ollama_url = os.getenv("OLLAMA_URL", "http://10.8.0.2:11434")
+ model = os.getenv("MODEL_MJ", "qwen2.5:14b")
+ try:
+ payload = json.dumps({"model": model, "prompt": "\n".join(context), "stream": False,
+ "options": {"temperature": temperature, "num_predict": num_predict}}).encode()
+ req = urllib.request.Request(f"{ollama_url}/api/generate",
+ data=payload, headers={"Content-Type": "application/json"}, method="POST")
+ with urllib.request.urlopen(req, timeout=120) as resp:
+ result = json.loads(resp.read())
+ llm_response = result.get("response", "").strip()
+ except Exception as e:
+ return jsonify({"error": f"Erreur LLM : {e}"}), 500
+
+ execute("INSERT INTO sim_proposal_threads (proposal_id,role,content,temperature,num_predict) VALUES (%s,'llm',%s,%s,%s)",
+ (prop_id, llm_response, temperature, num_predict))
+ return jsonify({"ok": True, "response": llm_response})
diff --git a/dashboard/routes/tools.py b/dashboard/routes/tools.py
new file mode 100644
index 0000000..b274fbb
--- /dev/null
+++ b/dashboard/routes/tools.py
@@ -0,0 +1,150 @@
+import os
+import json
+import time
+import threading
+import subprocess
+
+from flask import Blueprint, render_template, jsonify, request
+
+from ..db import query_one, get_all_sessions
+from ..config import SIM_CONFIGS_DIR
+from .. import process_mgr as pm
+
+bp = Blueprint("tools", __name__)
+
+# ── Stress test state ─────────────────────────────────────────────────────────
+_stress_state = {
+ "running": False, "started_at": None, "mode": None,
+ "rounds": None, "pnj_count": None,
+ "output": [], "returncode": None, "error": None,
+}
+_stress_lock = threading.Lock()
+
+
+def _run_stress_bg(mode: str, rounds: int, pnj_count: int):
+ global _stress_state
+ ollama_url = os.getenv("OLLAMA_URL", "http://host.docker.internal:11434")
+ model_mj = os.getenv("MODEL_MJ", "qwen2.5:14b")
+ model_pnj = os.getenv("MODEL_PNJ", "qwen2.5:7b")
+ results_path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
+ cmd = [
+ "python3", "/app/tools/llm_crash_test.py",
+ "--mode", mode, "--rounds", str(rounds), "--pnj-count", str(pnj_count),
+ ]
+ env = {**os.environ,
+ "OLLAMA_URL": ollama_url, "MODEL_MJ": model_mj, "MODEL_PNJ": model_pnj,
+ "CRASH_RESULTS_PATH": results_path, "PYTHONUNBUFFERED": "1"}
+ try:
+ proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
+ text=True, env=env)
+ for line in proc.stdout:
+ with _stress_lock:
+ _stress_state["output"].append(line.rstrip())
+ proc.wait()
+ with _stress_lock:
+ _stress_state["returncode"] = proc.returncode
+ _stress_state["running"] = False
+ except Exception as e:
+ with _stress_lock:
+ _stress_state["error"] = str(e)
+ _stress_state["running"] = False
+
+
+# ── Routes ────────────────────────────────────────────────────────────────────
+
+@bp.route("/tools")
+def tools():
+ 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,))
+ return render_template("tools.html", tab="tools", current_sid=sid,
+ all_sessions=all_sessions, session=session,
+ flash_msg=None, flash_ok=True)
+
+
+@bp.route("/api/stress-test/run", methods=["POST"])
+def api_stress_run():
+ global _stress_state
+ with _stress_lock:
+ if _stress_state["running"]:
+ return jsonify({"error": "Un test est déjà en cours.", "running": True})
+ mode = request.json.get("mode", "simultane")
+ rounds = int(request.json.get("rounds", 5))
+ pnj_count = int(request.json.get("pnj_count", 2))
+ _stress_state = {
+ "running": True, "started_at": time.strftime("%H:%M:%S"),
+ "mode": mode, "rounds": rounds, "pnj_count": pnj_count,
+ "output": [], "returncode": None, "error": None,
+ }
+ t = threading.Thread(target=_run_stress_bg, args=(mode, rounds, pnj_count), daemon=True)
+ t.start()
+ return jsonify({"ok": True, "message": f"Test '{mode}' {rounds} rounds lancé."})
+
+
+@bp.route("/api/stress-test/status")
+def api_stress_status():
+ with _stress_lock:
+ return jsonify({
+ "running": _stress_state["running"],
+ "started_at": _stress_state["started_at"],
+ "mode": _stress_state["mode"],
+ "rounds": _stress_state["rounds"],
+ "output": _stress_state["output"][-60:],
+ "returncode": _stress_state["returncode"],
+ "error": _stress_state["error"],
+ })
+
+
+@bp.route("/api/stress-test/results")
+def api_stress_results():
+ import datetime
+ path = os.path.join(SIM_CONFIGS_DIR, "crash_results.json")
+ if not os.path.exists(path):
+ return jsonify({"error": "Aucun résultat. Lancez d'abord le test."})
+ try:
+ mtime = os.path.getmtime(path)
+ file_date = datetime.datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
+ with open(path) as f:
+ data = json.load(f)
+ data["file_date"] = file_date
+ return jsonify(data)
+ except Exception as e:
+ return jsonify({"error": f"Erreur lecture : {e}"})
+
+
+@bp.route("/api/proc//run", methods=["POST"])
+def proc_run(name):
+ if name == "mix":
+ if pm.proc_is_running("mix"):
+ return jsonify({"error": "Mix déjà en cours"}), 400
+ data = request.get_json(silent=True) or {}
+ count = int(data.get("count", 10))
+ pm.proc_mix_start(count)
+ return jsonify({"ok": True, "label": f"Mode MIX — {count if count else '∞'}× chaque"})
+ if name not in pm._PROC_DEFS:
+ return jsonify({"error": f"Processus inconnu: {name}"}), 400
+ if pm.proc_is_running(name):
+ return jsonify({"error": "Déjà en cours"}), 400
+ defn = pm._PROC_DEFS[name]
+ data = request.get_json(silent=True) or {}
+ count = int(data.get("count", 1))
+ cmd = list(defn["cmd"])
+ if name == "sim_enricher" and data.get("type"):
+ try: cmd[cmd.index("all")] = data["type"]
+ except ValueError: pass
+ if name == "lore_enricher" and data.get("faction"):
+ try: cmd[cmd.index("all")] = data["faction"]
+ except ValueError: pass
+ pm.proc_start(name, cmd, defn.get("env_extra", {}), count)
+ return jsonify({"ok": True, "label": defn["label"]})
+
+
+@bp.route("/api/proc//status")
+def proc_status(name):
+ return jsonify(pm.proc_get_state(name))
+
+
+@bp.route("/api/proc//stop", methods=["POST"])
+def proc_stop_route(name):
+ pm.proc_stop(name)
+ return jsonify({"ok": True})
diff --git a/dashboard/start.sh b/dashboard/start.sh
index 5b0cf38..6b2a020 100755
--- a/dashboard/start.sh
+++ b/dashboard/start.sh
@@ -1,11 +1,10 @@
#!/bin/sh
set -e
-# Tunnel SSH vers Vigile (DB)
cp /ssh/vigile.key /tmp/vigile.key
chmod 600 /tmp/vigile.key
ssh -fN -L 15432:localhost:5432 -i /tmp/vigile.key -o StrictHostKeyChecking=no -o ServerAliveInterval=30 ubuntu@79.72.30.231
echo '[start.sh] Tunnel SSH vers Vigile établi sur 127.0.0.1:15432'
-exec python app.py
+exec python -m dashboard.app
diff --git a/dashboard/templates/base.html b/dashboard/templates/base.html
new file mode 100644
index 0000000..4f40a0a
--- /dev/null
+++ b/dashboard/templates/base.html
@@ -0,0 +1,218 @@
+
+
+
+
+
+PipBoy — Fallout: Venice of Wasteland
+
+
+
+
+
+
📟 PIPBOY
+
+ {% for s in all_sessions %}
+ {% set is_active = s.status == 'active' %}
+
+
+ S{{ s.id }}
+
+ {% endfor %}
+
+
+
+
+
+{% if flash_msg %}
+{{ flash_msg }}
+{% endif %}
+
+
+{% block content %}{% endblock %}
+
+
+
+
+
diff --git a/dashboard/templates/lore.html b/dashboard/templates/lore.html
new file mode 100644
index 0000000..e48d64a
--- /dev/null
+++ b/dashboard/templates/lore.html
@@ -0,0 +1,141 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
📜 Enrichissement Lore — Venice of Wasteland
+
+ En attente : {{ lore_stats.pending }}
+ Acceptées : {{ lore_stats.accepted }}
+ Rejetées : {{ lore_stats.rejected }}
+ Modifiées : {{ lore_stats.modified }}
+
+
+
+
+ Faction :
+
+ — toutes —
+ {% for f in all_factions %}
+ {{ f }}
+ {% endfor %}
+
+ Statut :
+
+ pending
+ accepted
+ rejected
+ modified
+ — tous —
+
+ {{ proposals|length }} proposition(s) affichée(s)
+
+
+{% if not proposals %}
+
+ Aucune proposition lore.
+ Lancer : python3 lore_enricher.py --faction grand_krewe sur Ampère
+
+{% endif %}
+
+{% for p in proposals %}
+
+
+ #{{ p.id }}
+ {{ p.status | upper }}
+ {{ p.change_type }}
+ {{ p.faction_slug }}
+ {% if p.field_path %}{{ p.field_path }} {% endif %}
+ {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}
+
+
+
{{ p.rationale or '(pas de justification)' }}
+
+
+
+
Original
+
{{ p.original_text or '(ajout — pas de texte original)' }}
+
+
+
Proposé
+
{{ p.proposed_text }}
+
+
+
+ {% if p.status == 'pending' %}
+
+
+
+
+
+
+ ✓ Accepter
+
+
+
+
+
+
+ ✗ Rejeter
+
+
+
+
+
+
+ {{ p.proposed_text }}
+ ✎ Modifier & Accepter
+
+
+ {% elif p.status == 'modified' and p.modified_text %}
+
+
Texte modifié accepté
+
{{ p.modified_text }}
+
+ {% endif %}
+
+ {% if p.status in ('modified', 'accepted') %}
+
💬 Discuter / Modifier
+
+ {% for msg in proposal_threads.get(p.id, []) %}
+
+ {{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}
+ {{ msg.content }}
+
+ {% endfor %}
+
+
+ {% endif %}
+
+{% endfor %}
+
+
+{% endblock %}
diff --git a/dashboard/templates/params.html b/dashboard/templates/params.html
new file mode 100644
index 0000000..f7e7d0f
--- /dev/null
+++ b/dashboard/templates/params.html
@@ -0,0 +1,173 @@
+{% extends "base.html" %}
+{% block content %}
+
+{% if session %}
+
+ Session #{{ session.id }} — {{ session.name }}
+ {{ session.mode | upper }}
+ {% set st = session.status %}
+
+
+ {{ st | upper }}
+
+ Jour {{ session.current_day }} / Tick {{ session.current_tick }}h
+
+{% endif %}
+
+
+
+
+
+
⚙ Config sim_{{ '%03d' % current_sid }}.json
+
+ {% if cfg is not none %}
+
+
+
Général
+
+ Nom de la session
+
+
+
+ Mode actif
+
+ {% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
+ {{ m }}
+ {% endfor %}
+
+
+
+ Tick sleep (secondes)
+
+
+
+
+
+
+
+
Rencontres
+
+ global_rate_multiplier
+
+
+
+ Rencontres activées
+
+
+
+
+
+
Économie
+
+ caps_global_multiplier
+
+
+
+
+ 💾 Sauvegarder config
+
+ Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 if session else '?' }}).
+ Pour effet immédiat : STOP → relancer via terminal.
+
+ {% else %}
+
+ Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}
+
+
+ Vérifier le mount Docker : /home/ubuntu/fallout-venice/src/config → /app/src/config
+
+ {% endif %}
+
+
+
+
+
+
🔧 Gestion session #{{ current_sid }}
+
+
+
Statut DB
+
+
+
+ ▶ Activer
+
+
+
+ ⏸ Pause
+
+
+
+ ■ Stop
+
+
+
+ STOP → la sim s'arrête dans les 5 ticks suivants.
+
+
+
+
+
+
+
Modes disponibles
+
+ Mode Drain Encounters Caps
+ {% for m_name, m_vals in modes.items() %}
+
+
+ {{ m_name }}{% if cfg and m_name == cfg.get('mode','') %} ◀{% endif %}
+ ×{{ m_vals.get('survival',{}).get('drain_speed','—') }}
+ ×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}
+ ×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}
+
+ {% endfor %}
+
+
+
+
+
Commandes SSH (référence)
+
+
tail -f ~/fallout_sim_s{{ current_sid }}.log
+
Copier
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/dashboard/templates/sim.html b/dashboard/templates/sim.html
new file mode 100644
index 0000000..1957eb7
--- /dev/null
+++ b/dashboard/templates/sim.html
@@ -0,0 +1,402 @@
+{% extends "base.html" %}
+{% block content %}
+
+{% if session %}
+
+
Session #{{ session.id }} — {{ session.name }}
+
Jour {{ session.current_day }} / Tick {{ session.current_tick }}h
+
{{ session.mode | upper }}
+ {% set st = session.status %}
+
+
+ {{ st | upper }}
+
+
Seed: {{ session.seed_global }}
+
+ ↻ Actualiser
+
+
+
+{% endif %}
+
+
+
PNJ Vivants
{{ alive_count }}
+
PNJ Morts
{{ stats.get('PNJ morts', 0) }}
+
Total PNJ
{{ total_count }}
+
Combats
{{ stats.get('Combats', 0) }}
+
Rencontres
{{ stats.get('Rencontres', 0) }}
+
Événements
{{ stats.get('Événements total', 0) }}
+
+
+
+
+
👥 PNJ ({{ alive_count }}/{{ total_count }})
+
+
+
+
+
📡 Événements récents
+
+
+
+
+
+
+
+
+
⚔ Relations factions
+
+
+
+
+
🎭 PNJ par tier
+
+ Tier Label Vivants Morts
+ {% for row in tier_stats %}
+
+ {{ row.tier }}
+ {{ row.label }}
+ {{ row.alive }}
+ {{ row.dead }}
+
+ {% endfor %}
+
+
🏙 PNJ par zone (top 5)
+
+ Zone PNJ
+ {% for row in zone_stats %}
+
+ {{ row.location_slug }}
+ {{ row.cnt }}
+
+ {% endfor %}
+
+
+
+
+
+
⚡ Rencontres récentes
+
+
+
+{% if boss_inventory %}
+
+
★ Équipement chefs de faction
+
+ Boss Item Type Qté Équipé
+ {% for row in boss_inventory %}
+
+ {{ row.char_name }}
+ {{ row.item_name }}
+ {{ row.item_type }}
+ {{ row.quantity }}
+ {{ '✓' if row.is_equipped else '' }}
+
+ {% endfor %}
+
+
+{% endif %}
+
+
+
+
+
📚 Vérité
+
✨ Enrichie
+
⚡ MIX
+
+
+
+
+
+
+
+ 📚 Sim Vérité
+ sources : fallout_lore + fallout_sim_rules uniquement
+
+
+ Session : #{{ current_sid }}
+ Mode :
+
+ {% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
+ {{ m }}
+ {% endfor %}
+
+
+
+
Arrêtée
+
+ ▶ Démarrer
+ ■ Arrêter
+ ⬇ Log JSON
+
+
+
+
+
+
+
+
+
+ ✨ Sim Enrichie
+ + fallout_lore_enriched + fallout_sim_enriched
+
+
Arrêtée
+
+ ▶ Démarrer
+ ■ Arrêter
+ ⬇ Log JSON
+
+
+
+
+
+
+
+
+
⚡ Mode MIX — Comparaison simultanée
+
Lance les deux simulations en même temps.
+
+
+ ▶ Lancer les 2
+ ■ Arrêter les 2
+ ⬇ Log Vérité
+ ⬇ Log Enrichie
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/dashboard/templates/sim_proposals.html b/dashboard/templates/sim_proposals.html
new file mode 100644
index 0000000..97c7e3b
--- /dev/null
+++ b/dashboard/templates/sim_proposals.html
@@ -0,0 +1,121 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
🎲 Règles Sim — Propositions LLM
+
+ En attente : {{ sim_stats.pending }}
+ Acceptées : {{ sim_stats.accepted }}
+ Rejetées : {{ sim_stats.rejected }}
+
+
+
+
+ Type :
+
+ — tous —
+ {% for t in sim_types %}
+ {{ t }}
+ {% endfor %}
+
+ Statut :
+
+ pending
+ accepted
+ rejected
+ — tous —
+
+ {{ proposals|length }} proposition(s)
+
+
+{% if not proposals %}
+
+ Aucune proposition.
+ Lancer : python3 sim_enricher.py --type all sur Ampère
+
+{% endif %}
+
+{% for p in proposals %}
+
+
+ #{{ p.id }}
+ {{ p.status | upper }}
+ {{ p.proposal_type }}
+ {{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}
+
+
+
{{ p.title }}
+
{{ p.description }}
+
+ {% if p.rationale %}
+
💡 {{ p.rationale }}
+ {% endif %}
+
+ {% if p.json_patch %}
+
{{ p.json_patch | tojson(indent=2) }}
+ {% endif %}
+
+ {% if p.status == 'pending' %}
+
+
+
+
+
+
+ ✓ Accepter
+
+
+
+
+
+
+
+ ✗ Rejeter
+
+
+ {% endif %}
+
+
💬 Discuter
+
+ {% for msg in proposal_threads.get(p.id, []) %}
+
+ {{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}
+ {{ msg.content }}
+
+ {% endfor %}
+
+
+
+{% endfor %}
+
+
+{% endblock %}
diff --git a/dashboard/templates/tools.html b/dashboard/templates/tools.html
new file mode 100644
index 0000000..9a95cf1
--- /dev/null
+++ b/dashboard/templates/tools.html
@@ -0,0 +1,329 @@
+{% extends "base.html" %}
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock %}
diff --git a/docker-compose.yml b/docker-compose.yml
index c58877d..7715eb6 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,12 +5,11 @@ services:
restart: unless-stopped
working_dir: /app
volumes:
- - ./dashboard/app.py:/app/app.py:ro
- - ./dashboard/start.sh:/app/start.sh:ro
+ - /opt/coyote/fallout/src/dashboard:/app/dashboard:ro
- /home/ubuntu/.ssh/vigile_backup:/ssh/vigile.key:ro
- - ./src/config:/app/src/config
- - ./tools:/app/tools:ro
- command: sh -c 'apk add --no-cache openssh-client && pip install flask psycopg2-binary --quiet && sh /app/start.sh'
+ - /opt/coyote/fallout/src/config:/app/src/config
+ - /opt/coyote/fallout/tools:/app/tools:ro
+ command: sh -c 'apk add --no-cache openssh-client && pip install flask psycopg2-binary --quiet && sh /app/dashboard/start.sh'
networks:
- proxy-nw
extra_hosts:
@@ -28,6 +27,7 @@ services:
- OLLAMA_URL=${OLLAMA_URL}
- MODEL_MJ=${MODEL_MJ}
- MODEL_PNJ=${MODEL_PNJ}
+ - CHROMA_URL=http://chromadb:8000
networks:
proxy-nw:
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..eee9252
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1 @@
+pymupdf
diff --git a/src/engine/lore_enricher.py b/src/engine/lore_enricher.py
index 03cc606..08b1168 100644
--- a/src/engine/lore_enricher.py
+++ b/src/engine/lore_enricher.py
@@ -77,9 +77,11 @@ def _get_collection_id(name: str) -> str | None:
return c["id"]
return None
-def chroma_query(collection_id: str, query_text: str, n: int = 8,
+def chroma_query(collection_id, query_text: str, n: int = 8,
where: dict | None = None) -> list[dict]:
"""Recherche sémantique dans une collection Chroma."""
+ if not collection_id:
+ return []
body = {
"query_texts": [query_text],
"n_results": n,
@@ -98,7 +100,7 @@ def chroma_query(collection_id: str, query_text: str, n: int = 8,
print(f" [chroma] Erreur query: {e}")
return []
-def chroma_get_by_category(collection_id: str, category: str) -> list[dict]:
+def chroma_get_by_category(collection_id, category: str) -> list[dict]:
"""Récupère tous les chunks d'une catégorie."""
body = {
"where": {"category": {"$eq": category}},
diff --git a/src/engine/run.py b/src/engine/run.py
index ac41560..ffdcb35 100644
--- a/src/engine/run.py
+++ b/src/engine/run.py
@@ -99,7 +99,7 @@ def run(config_path: str | None = None):
cfg.load(config_path, session_id=SESSION_ID)
effective_mode = cfg.mode() or db_mode
tick_sleep = float(os.getenv("TICK_SLEEP_SEC",
- cfg.get("tick", {}).get("tick_sleep_sec", tick_sleep)))
+ cfg.get("tick", "tick_sleep_sec", tick_sleep)))
_init_world_state(SESSION_ID, day)
except KeyboardInterrupt: