Compare commits
15 Commits
dd985c868c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| bd8416dceb | |||
| e66af92f6a | |||
| 674adefe39 | |||
| ea8e24e238 | |||
| ae6356ded4 | |||
| 2a6ad68f73 | |||
| 20df05ff72 | |||
| 3330931f30 | |||
| 5957a059b6 | |||
| efe66026ad | |||
| cf57c84dd1 | |||
| b4847f03bb | |||
| bbfd2dc45f | |||
| a16d671db4 | |||
| 99bd77e18b |
@@ -0,0 +1,8 @@
|
|||||||
|
DB_HOST=127.0.0.1
|
||||||
|
DB_PORT=15432
|
||||||
|
DB_NAME=fallout
|
||||||
|
DB_USER=fallout
|
||||||
|
DB_PASS=
|
||||||
|
OLLAMA_URL=http://host.docker.internal:11434
|
||||||
|
MODEL_MJ=qwen2.5:14b
|
||||||
|
MODEL_PNJ=qwen2.5:7b
|
||||||
@@ -6,3 +6,11 @@ __pycache__/
|
|||||||
*.sqlite
|
*.sqlite
|
||||||
.DS_Store
|
.DS_Store
|
||||||
Thumbs.db
|
Thumbs.db
|
||||||
|
INFRA_PRIVATE.md
|
||||||
|
CLAUDE.md
|
||||||
|
ROADMAP.md
|
||||||
|
PDF/
|
||||||
|
deploy.sh
|
||||||
|
*.bak
|
||||||
|
*.bak_*
|
||||||
|
docker-compose.yml.bak_pre_refacto
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
# Fallout: Venice of Wasteland — Moteur de simulation JDR
|
||||||
|
|
||||||
|
Simulation autonome d'un univers **Fallout 2D20** situé en Louisiane post-nucléaire.
|
||||||
|
Le moteur simule des PNJ, des factions, des rencontres et une économie — sans MJ humain permanent.
|
||||||
|
Un LLM joue le rôle de MJ narrateur, un second LLM anime les PNJ.
|
||||||
|
|
||||||
|
**Dashboard** → [fallout.coyoteos.ovh](https://fallout.coyoteos.ovh) (PipBoy)
|
||||||
|
**Gitea** → [git.coyoteos.ovh](https://git.coyoteos.ovh) — repo `Corback/fallout-venice`
|
||||||
|
|
||||||
|
> ⚠️ Les informations de connexion (IPs, credentials, clés SSH) sont dans `INFRA_PRIVATE.md` (local uniquement, non commité).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture générale
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ AMPÈRE (Oracle ARM) │
|
||||||
|
│ │
|
||||||
|
│ ┌──────────────┐ ┌───────────────┐ ┌────────────┐ │
|
||||||
|
│ │ run.py S1 │ │ run.py S2 │ │ PipBoy │ │
|
||||||
|
│ │ (sim engine)│ │ (sim engine) │ │ Flask │ │
|
||||||
|
│ └──────┬───────┘ └───────┬───────┘ └─────┬──────┘ │
|
||||||
|
│ │ │ │ │
|
||||||
|
│ ┌──────▼───────────────────▼────────────────▼──────┐ │
|
||||||
|
│ │ Ollama (localhost:11434) │ │
|
||||||
|
│ │ qwen2.5:14b (MJ) + qwen2.5:7b (PNJ) │ │
|
||||||
|
│ └───────────────────────────────────────────────────┘ │
|
||||||
|
│ │
|
||||||
|
│ ┌────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ ChromaDB :8800 — collection fallout_lore │ │
|
||||||
|
│ │ 1403 chunks : règles 2D20, lore canon, ambiance │ │
|
||||||
|
│ └────────────────────────────────────────────────────┘ │
|
||||||
|
└────────────────────────┬────────────────────────────────┘
|
||||||
|
│ SSH tunnel :15432
|
||||||
|
┌────────────────────────▼────────────────────────────────┐
|
||||||
|
│ VIGILE (OVH) │
|
||||||
|
│ PostgreSQL — base "fallout" │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack technique
|
||||||
|
|
||||||
|
| Composant | Technologie | Détail |
|
||||||
|
|-----------|------------|--------|
|
||||||
|
| Moteur sim | Python 3.11 | `src/engine/` |
|
||||||
|
| Dashboard | Flask + Jinja2 | Docker `fallout-visu` |
|
||||||
|
| LLM MJ | qwen2.5:14b via Ollama | ~30s/réponse warm |
|
||||||
|
| LLM PNJ | qwen2.5:7b via Ollama | ~5s/réponse warm |
|
||||||
|
| Base de données | PostgreSQL 15 (Vigile) | SSH tunnel depuis Ampère |
|
||||||
|
| RAG | ChromaDB v1.0.0 (API v2) | collection `fallout_lore` |
|
||||||
|
| Proxy | nginx-proxy-manager | SSL Let's Encrypt auto |
|
||||||
|
| Gitea | git.coyoteos.ovh:2222 | repo Corback/fallout-venice |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure du projet
|
||||||
|
|
||||||
|
```
|
||||||
|
fallout-venice/
|
||||||
|
├── src/
|
||||||
|
│ ├── engine/
|
||||||
|
│ │ ├── run.py # Lanceur principal (hot-reload config, status check loop)
|
||||||
|
│ │ ├── tick.py # Orchestration d'un tick
|
||||||
|
│ │ ├── encounter.py # Rencontres (safe zones, raids, commerce PNJ)
|
||||||
|
│ │ ├── combat_engine.py # Résolution combats 2D20
|
||||||
|
│ │ ├── sim_config.py # Chargement + deep-merge config JSON
|
||||||
|
│ │ ├── init_pnj.py # Initialisation PNJ en DB
|
||||||
|
│ │ ├── lore_enricher.py # Enrichissement lore via LLM + RAG Chroma
|
||||||
|
│ │ └── db.py # Accès PostgreSQL
|
||||||
|
│ ├── config/
|
||||||
|
│ │ ├── sim_001.json # Config session 1 (référence des 5 modes)
|
||||||
|
│ │ ├── sim_002.json # Config session 2
|
||||||
|
│ │ └── crash_results.json # Résultats derniers tests LLM (lu par PipBoy)
|
||||||
|
│ └── data/
|
||||||
|
│ └── encounter_tables.json # Tables rencontres, zones sûres, pools entités
|
||||||
|
├── dashboard/
|
||||||
|
│ └── app.py # Source PipBoy — copier dans /home/ubuntu/fallout-visu/
|
||||||
|
├── tools/
|
||||||
|
│ ├── llm_crash_test.py # Stress test LLM (modes: simultane/decale/solo)
|
||||||
|
│ └── capacity_test.py # Test capacité N joueurs simultanés
|
||||||
|
├── README.md
|
||||||
|
└── ROADMAP.md
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ Le dashboard actif est `/home/ubuntu/fallout-visu/app.py` (monté dans Docker).
|
||||||
|
> `dashboard/app.py` est la source de vérité — toujours synchroniser les deux après modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Système de PNJ — 4 tiers
|
||||||
|
|
||||||
|
| Tier | Label | Comportement | LLM actuel |
|
||||||
|
|------|-------|-------------|-----------|
|
||||||
|
| 0 | BOSS | Immortel, chef de faction | Non (prévu 14b) |
|
||||||
|
| 1 | ACTIFS SIM | Simulés chaque tick, mémoire persistante | Non (prévu 7b) |
|
||||||
|
| 2 | PNJ+ | Réagissent aux événements importants | Non (prévu 7b) |
|
||||||
|
| 3 | PASSAGE | Décor narratif | Jamais |
|
||||||
|
|
||||||
|
Actuellement tous les PNJ sont animés par le **moteur Python seul**.
|
||||||
|
Le LLM PNJ sera déclenché uniquement sur **interaction joueur directe** (Phase 5).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Zones sûres
|
||||||
|
|
||||||
|
`independance` | `nola_vieux_carre` | `baton_rouge` | `laplace`
|
||||||
|
|
||||||
|
Règles : pas de rencontres lambda/groupe. Seuls les raids de faction (faible probabilité) et le commerce PNJ-à-PNJ sont possibles.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Modes de simulation
|
||||||
|
|
||||||
|
Configurables depuis le PipBoy — prise en compte au **prochain jour** (hot-reload).
|
||||||
|
|
||||||
|
| Mode | Rencontres | Drain | Économie | Usage |
|
||||||
|
|------|-----------|-------|---------|-------|
|
||||||
|
| `pacifiste` | ×0.4 | ×0.7 | normal | Test/debug |
|
||||||
|
| `politique` | ×0.6 | ×1.0 | normal | Intrigues factions |
|
||||||
|
| `guerre_commerciale` | ×1.2 | ×1.5 | ×1.5 caps | Blocus, routes coupées |
|
||||||
|
| `guerre` | ×2.0 | ×1.8 | pénurie | Front de guerre actif |
|
||||||
|
| `survie_extreme` | ×1.8 | ×2.5 | ×0.3 | Stress test pur |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ChromaDB — collection `fallout_lore`
|
||||||
|
|
||||||
|
**1403 chunks** — source de vérité en lecture seule.
|
||||||
|
|
||||||
|
| Catégorie | Chunks | Contenu |
|
||||||
|
|-----------|--------|---------|
|
||||||
|
| `regles_core` | ~934 | Règles 2D20 Fallout |
|
||||||
|
| `regles_supplement` | 279 | Suppléments et extensions |
|
||||||
|
| `lore_inspiration` | 86 | Inspiration univers Fallout |
|
||||||
|
| `ambiance` | 43 | Descriptions atmosphériques |
|
||||||
|
| `aventure` | 29 | Scénarios de référence |
|
||||||
|
| `lore_canon` | 32 | **Bible Venice of Wasteland v1.1 & v2.0** |
|
||||||
|
|
||||||
|
`fallout_lore_enriched` → reçoit les propositions lore **acceptées** depuis le PipBoy.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performances LLM mesurées (Ampère ARM)
|
||||||
|
|
||||||
|
| Joueurs simultanés | Tick complet | Actions/heure | Viable tick 10min |
|
||||||
|
|-------------------|-------------|---------------|-----------------|
|
||||||
|
| 1 | 35s | 103 | ✓ |
|
||||||
|
| 5 | 57s | 62 | ✓ |
|
||||||
|
| 10 | 1m17 | 46 | ✓ |
|
||||||
|
| 50 | 3m32 | 16 | ✓ (8 timeouts) |
|
||||||
|
|
||||||
|
**Plafond recommandé V1 : 10-15 joueurs actifs simultanés.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Lancement des simulations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# SSH sur Ampère (voir INFRA_PRIVATE.md pour la clé et l'IP)
|
||||||
|
cd /home/ubuntu/fallout-venice/src/engine
|
||||||
|
|
||||||
|
# Session 1
|
||||||
|
nohup env SESSION_ID=1 TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 \
|
||||||
|
python3 -u run.py > ~/fallout_sim_s1.log 2>&1 &
|
||||||
|
|
||||||
|
# Session 2
|
||||||
|
nohup env SESSION_ID=2 TICK_SLEEP_SEC=60 PYTHONUNBUFFERED=1 \
|
||||||
|
python3 -u run.py > ~/fallout_sim_s2.log 2>&1 &
|
||||||
|
|
||||||
|
tail -f ~/fallout_sim_s1.log
|
||||||
|
```
|
||||||
|
|
||||||
|
**Reset propre d'une session :**
|
||||||
|
1. PipBoy → Paramètres → **STOP** (attendre max 5 ticks)
|
||||||
|
2. PipBoy → Paramètres → **Reset Jour/Tick**
|
||||||
|
3. Relancer via terminal
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Enrichissement lore
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd /home/ubuntu/fallout-venice/src/engine
|
||||||
|
|
||||||
|
python3 lore_enricher.py --faction grand_krewe # une faction
|
||||||
|
python3 lore_enricher.py --faction all # toutes
|
||||||
|
python3 lore_enricher.py --list-factions # liste
|
||||||
|
```
|
||||||
|
|
||||||
|
Valider les propositions → PipBoy onglet **ENRICHISSEMENT**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Variables d'environnement clés
|
||||||
|
|
||||||
|
| Variable | Défaut | Description |
|
||||||
|
|----------|--------|-------------|
|
||||||
|
| `SESSION_ID` | 1 | Session simulée |
|
||||||
|
| `TICK_SLEEP_SEC` | 60 | Durée d'un tick (secondes) |
|
||||||
|
| `PYTHONUNBUFFERED` | — | `1` pour logs temps réel |
|
||||||
|
| `OLLAMA_URL` | `http://localhost:11434` | Endpoint Ollama |
|
||||||
|
| `MODEL_MJ` | `qwen2.5:14b` | Modèle MJ narrateur |
|
||||||
|
| `MODEL_PNJ` | `qwen2.5:7b` | Modèle animation PNJ |
|
||||||
|
| `DB_HOST` / `DB_PORT` | voir INFRA_PRIVATE.md | PostgreSQL via tunnel SSH |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ Points critiques — à ne jamais oublier
|
||||||
|
|
||||||
|
**ChromaDB**
|
||||||
|
- API v2 uniquement — `/api/v1/` retourne `{"error":"Unimplemented"}`
|
||||||
|
- La collection `fallout_vst` **n'existe pas** — c'est `fallout_lore`
|
||||||
|
- 299 chunks avaient le champ `type` au lieu de `category` — corrigé en juin 2026
|
||||||
|
|
||||||
|
**Dashboard**
|
||||||
|
- Le fichier actif est `/home/ubuntu/fallout-visu/app.py` (monté Docker), pas `dashboard/app.py`
|
||||||
|
- Toujours copier les deux après modification
|
||||||
|
|
||||||
|
**Simulation**
|
||||||
|
- `run.py` vérifie le statut DB toutes les **5 ticks** — STOP depuis PipBoy n'est pas instantané
|
||||||
|
- Hot-reload config JSON au **début de chaque jour** (tick 0), pas immédiatement
|
||||||
|
|
||||||
|
**Réseau Docker**
|
||||||
|
- Le container PipBoy accède à Ollama via `host.docker.internal:11434`
|
||||||
|
- Une règle iptables autorise le subnet Docker → port 11434. Voir INFRA_PRIVATE.md si disparaît après reboot.
|
||||||
|
|
||||||
|
**Infos de connexion**
|
||||||
|
- Voir `INFRA_PRIVATE.md` (local uniquement, dans `.gitignore`)
|
||||||
+13
-763
@@ -1,773 +1,23 @@
|
|||||||
"""
|
"""
|
||||||
VAULTCOM — Fallout: Venice of Wasteland
|
PipBoy — Fallout: Venice of Wasteland
|
||||||
Dashboard simulation. Schéma DB actuel (migration_003).
|
Dashboard unifié : Simulation | Outils | Lore enrichment | Paramètres
|
||||||
Onglets: Simulation (par session) | Paramètres (config + restart)
|
|
||||||
"""
|
"""
|
||||||
|
from flask import Flask
|
||||||
|
|
||||||
import os, json, subprocess
|
from .routes import sim, tools, lore, sim_proposals, params
|
||||||
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")
|
def create_app() -> Flask:
|
||||||
DB_PORT = int(os.getenv("DB_PORT", 5432))
|
app = Flask(__name__, template_folder="templates")
|
||||||
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")
|
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)
|
||||||
|
|
||||||
def get_conn():
|
return app
|
||||||
return psycopg2.connect(
|
|
||||||
host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
|
||||||
user=DB_USER, password=DB_PASS
|
|
||||||
)
|
|
||||||
|
|
||||||
def query(sql, params=None):
|
|
||||||
conn = get_conn()
|
|
||||||
try:
|
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
return [dict(r) for r in cur.fetchall()]
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def query_one(sql, params=None):
|
|
||||||
rows = query(sql, params)
|
|
||||||
return rows[0] if rows else None
|
|
||||||
|
|
||||||
def execute(sql, params=None):
|
|
||||||
conn = get_conn()
|
|
||||||
try:
|
|
||||||
with conn.cursor() as cur:
|
|
||||||
cur.execute(sql, params)
|
|
||||||
conn.commit()
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
def load_sim_config(session_id: int) -> dict:
|
|
||||||
path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json")
|
|
||||||
try:
|
|
||||||
with open(path) as f:
|
|
||||||
return json.load(f)
|
|
||||||
except Exception:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
def save_sim_config(session_id: int, cfg: dict):
|
|
||||||
path = os.path.join(SIM_CONFIGS_DIR, f"sim_{session_id:03d}.json")
|
|
||||||
with open(path, "w") as f:
|
|
||||||
json.dump(cfg, f, indent=2, ensure_ascii=False)
|
|
||||||
|
|
||||||
def get_all_sessions():
|
|
||||||
return query("SELECT * FROM sessions ORDER BY id")
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# HTML Template
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
TEMPLATE = r"""<!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>
|
|
||||||
</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 == '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 %}
|
|
||||||
|
|
||||||
<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>
|
|
||||||
{% else %}
|
|
||||||
<p style="color:#ff4444;font-size:11px">Fichier sim_{{ '%03d' % current_sid }}.json introuvable dans {{ sim_configs_dir }}</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 = cfg.get("modes", {})
|
|
||||||
flash_msg = request.args.get("msg")
|
|
||||||
flash_ok = request.args.get("ok", "1") == "1"
|
|
||||||
return render_template_string(TEMPLATE,
|
|
||||||
tab="params",
|
|
||||||
current_sid=sid,
|
|
||||||
all_sessions=all_sessions,
|
|
||||||
session=session,
|
|
||||||
cfg=cfg,
|
|
||||||
modes=modes,
|
|
||||||
sim_configs_dir=SIM_CONFIGS_DIR,
|
|
||||||
flash_msg=flash_msg,
|
|
||||||
flash_ok=flash_ok,
|
|
||||||
# unused in params tab but required by template
|
|
||||||
characters=[], alive_count=0, total_count=0,
|
|
||||||
events=[], encounters=[], world_state=[],
|
|
||||||
faction_relations=[], tier_stats=[], zone_stats=[],
|
|
||||||
stats={}, boss_inventory=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
@app.route("/params/save", methods=["POST"])
|
|
||||||
def params_save():
|
|
||||||
sid = int(request.args.get("sid", 1))
|
|
||||||
cfg = load_sim_config(sid)
|
|
||||||
if not cfg:
|
|
||||||
return redirect(url_for("params", sid=sid, msg="Fichier config introuvable", ok=0))
|
|
||||||
|
|
||||||
f = request.form
|
|
||||||
|
|
||||||
cfg["name"] = f.get("name", cfg.get("name", ""))
|
|
||||||
cfg["mode"] = f.get("mode", cfg.get("mode", "pacifiste"))
|
|
||||||
|
|
||||||
cfg.setdefault("tick", {})["tick_sleep_sec"] = int(f.get("tick_sleep_sec", 60))
|
|
||||||
|
|
||||||
cfg.setdefault("survival", {}).update({
|
|
||||||
"drain_speed": float(f.get("survival_drain_speed", 1.0)),
|
|
||||||
"fatigue_hp_per_2pts": int(f.get("survival_fatigue_hp", 2)),
|
|
||||||
"enabled": "survival_enabled" in f,
|
|
||||||
})
|
|
||||||
|
|
||||||
cfg.setdefault("encounter", {}).update({
|
|
||||||
"global_rate_multiplier": float(f.get("encounter_rate", 1.0)),
|
|
||||||
"enabled": "encounter_enabled" in f,
|
|
||||||
})
|
|
||||||
|
|
||||||
cfg.setdefault("economy", {})["caps_global_multiplier"] = float(f.get("caps_multiplier", 1.0))
|
|
||||||
|
|
||||||
try:
|
|
||||||
save_sim_config(sid, cfg)
|
|
||||||
execute("UPDATE sessions SET mode=%s WHERE id=%s", (cfg["mode"], sid))
|
|
||||||
msg = f"Config sim_{sid:03d}.json sauvegardée. Mode DB mis à jour → {cfg['mode']}."
|
|
||||||
ok = 1
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"Erreur: {e}"
|
|
||||||
ok = 0
|
|
||||||
|
|
||||||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
|
||||||
|
|
||||||
@app.route("/params/setstatus", methods=["POST"])
|
|
||||||
def params_setstatus():
|
|
||||||
sid = int(request.args.get("sid", 1))
|
|
||||||
status = request.form.get("status", "paused")
|
|
||||||
try:
|
|
||||||
execute("UPDATE sessions SET status=%s WHERE id=%s", (status, sid))
|
|
||||||
msg = f"Session {sid} → status '{status}'. Le process run.py sortira au prochain tick si arrêté."
|
|
||||||
ok = 1
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"Erreur: {e}"
|
|
||||||
ok = 0
|
|
||||||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
|
||||||
|
|
||||||
@app.route("/params/resetday", methods=["POST"])
|
|
||||||
def params_resetday():
|
|
||||||
sid = int(request.args.get("sid", 1))
|
|
||||||
day = int(request.form.get("day", 1))
|
|
||||||
tick = int(request.form.get("tick", 0))
|
|
||||||
try:
|
|
||||||
execute("UPDATE sessions SET current_day=%s, current_tick=%s WHERE id=%s", (day, tick, sid))
|
|
||||||
msg = f"Session {sid} remise à J{day} T{tick}h."
|
|
||||||
ok = 1
|
|
||||||
except Exception as e:
|
|
||||||
msg = f"Erreur: {e}"
|
|
||||||
ok = 0
|
|
||||||
return redirect(url_for("params", sid=sid, msg=msg, ok=ok))
|
|
||||||
|
|
||||||
@app.route("/api/state")
|
|
||||||
def api_state():
|
|
||||||
all_sessions = get_all_sessions()
|
|
||||||
sid = int(request.args.get("sid", all_sessions[-1]["id"] if all_sessions else 1))
|
|
||||||
session = query_one("SELECT * FROM sessions WHERE id=%s", (sid,))
|
|
||||||
alive = query("""
|
|
||||||
SELECT id, name, hp, max_hp, tier, faction_slug, location_slug
|
|
||||||
FROM characters WHERE session_id=%s AND is_alive=TRUE
|
|
||||||
""", (sid,))
|
|
||||||
return jsonify({"session": session, "alive_pnj": alive})
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
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)
|
||||||
|
|||||||
@@ -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']}",
|
||||||
|
}],
|
||||||
|
})
|
||||||
@@ -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", {})
|
||||||
@@ -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")
|
||||||
@@ -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
|
||||||
@@ -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))
|
||||||
@@ -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))
|
||||||
@@ -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/<mode>/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/<mode>/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/<mode>/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/<mode>/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})
|
||||||
@@ -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/<int:prop_id>/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})
|
||||||
@@ -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/<name>/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/<name>/status")
|
||||||
|
def proc_status(name):
|
||||||
|
return jsonify(pm.proc_get_state(name))
|
||||||
|
|
||||||
|
|
||||||
|
@bp.route("/api/proc/<name>/stop", methods=["POST"])
|
||||||
|
def proc_stop_route(name):
|
||||||
|
pm.proc_stop(name)
|
||||||
|
return jsonify({"ok": True})
|
||||||
Executable
+10
@@ -0,0 +1,10 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
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 -m dashboard.app
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>PipBoy — Fallout: Venice of Wasteland</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--green: #4ade80;
|
||||||
|
--red: #f87171;
|
||||||
|
--amber: #fbbf24;
|
||||||
|
--blue: #60a5fa;
|
||||||
|
--bg: #0f1117;
|
||||||
|
--panel: #161b27;
|
||||||
|
--panel2: #1a1f2e;
|
||||||
|
--border: #2a3040;
|
||||||
|
--text: #e8e8e8;
|
||||||
|
--muted: #8892a4;
|
||||||
|
--dim: #4a5568;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
body { background: var(--bg); color: var(--text); font-family: system-ui, 'Segoe UI', sans-serif; font-size: 14px; }
|
||||||
|
|
||||||
|
.topbar { display: flex; align-items: center; gap: 8px; padding: 10px 16px; background: #0a0d14; border-bottom: 1px solid var(--border); }
|
||||||
|
.topbar-title { font-size: 15px; font-weight: 600; color: var(--green); letter-spacing: 1px; margin-right: 8px; }
|
||||||
|
.session-pills { display: flex; gap: 6px; }
|
||||||
|
.session-pill { padding: 4px 12px; border-radius: 20px; border: 1px solid var(--border); color: var(--muted); text-decoration: none; font-size: 12px; font-weight: 500; display: inline-flex; align-items: center; gap: 5px; transition: all .15s; }
|
||||||
|
.session-pill:hover { border-color: var(--dim); color: var(--text); }
|
||||||
|
.session-pill.active { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
|
||||||
|
.session-pill.stopped { border-color: var(--red); color: var(--red); }
|
||||||
|
.session-pill .dot { width: 6px; height: 6px; border-radius: 50%; display: inline-block; }
|
||||||
|
.dot-green { background: var(--green); }
|
||||||
|
.dot-red { background: var(--red); }
|
||||||
|
.dot-amber { background: var(--amber); }
|
||||||
|
.nav-sep { flex-grow: 1; }
|
||||||
|
.nav-tabs { display: flex; gap: 2px; }
|
||||||
|
.nav-tabs a { padding: 6px 14px; text-decoration: none; color: var(--muted); font-size: 13px; font-weight: 500; border-radius: 6px 6px 0 0; border: 1px solid transparent; border-bottom: none; transition: all .15s; }
|
||||||
|
.nav-tabs a:hover:not(.active) { color: var(--text); background: var(--panel2); }
|
||||||
|
.nav-tabs a.active { color: var(--amber); background: var(--panel); border-color: var(--border); border-bottom-color: var(--panel); }
|
||||||
|
|
||||||
|
.main { padding: 16px; }
|
||||||
|
|
||||||
|
.flash { padding: 8px 12px; margin: 0 16px 12px; border-radius: 6px; font-size: 13px; }
|
||||||
|
.flash-ok { background: rgba(74,222,128,.1); border: 1px solid rgba(74,222,128,.3); color: var(--green); }
|
||||||
|
.flash-err { background: rgba(248,113,113,.1); border: 1px solid rgba(248,113,113,.3); color: var(--red); }
|
||||||
|
|
||||||
|
.info-bar { display: flex; align-items: center; gap: 16px; padding: 10px 14px; background: var(--panel); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 16px; font-size: 13px; flex-wrap: wrap; }
|
||||||
|
.info-bar .label { color: var(--muted); }
|
||||||
|
.info-bar .val { color: var(--text); font-weight: 500; }
|
||||||
|
.status-badge { display: inline-flex; align-items: center; gap: 5px; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; }
|
||||||
|
.status-active { background: rgba(74,222,128,.12); color: var(--green); }
|
||||||
|
.status-stopped { background: rgba(248,113,113,.12); color: var(--red); }
|
||||||
|
.status-paused { background: rgba(251,191,36,.12); color: var(--amber); }
|
||||||
|
.mode-tag { padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 600; background: rgba(96,165,250,.1); color: var(--blue); border: 1px solid rgba(96,165,250,.2); }
|
||||||
|
|
||||||
|
.metric-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); gap: 10px; margin-bottom: 16px; }
|
||||||
|
.metric-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; }
|
||||||
|
.metric-card .metric-label { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 4px; }
|
||||||
|
.metric-card .metric-value { font-size: 26px; font-weight: 700; line-height: 1; }
|
||||||
|
.metric-green { color: var(--green); }
|
||||||
|
.metric-red { color: var(--red); }
|
||||||
|
.metric-amber { color: var(--amber); }
|
||||||
|
.metric-blue { color: var(--blue); }
|
||||||
|
.metric-muted { color: var(--muted); }
|
||||||
|
|
||||||
|
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||||
|
.grid3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px; margin-top: 12px; }
|
||||||
|
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 12px; }
|
||||||
|
.panel h2 { font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 10px; }
|
||||||
|
|
||||||
|
table { width: 100%; border-collapse: collapse; }
|
||||||
|
th { color: var(--muted); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .5px; padding: 6px 10px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
td { padding: 6px 10px; font-size: 13px; border-bottom: 1px solid rgba(42,48,64,.5); vertical-align: middle; }
|
||||||
|
tr:nth-child(even) td { background: #1a1a2e; }
|
||||||
|
tr:hover td { background: rgba(74,222,128,.04) !important; }
|
||||||
|
.dead td { opacity: .45; text-decoration: line-through; }
|
||||||
|
|
||||||
|
.tier0-text { color: #fb923c; }
|
||||||
|
.tier1-text { color: var(--green); }
|
||||||
|
.tier2-text { color: var(--blue); }
|
||||||
|
.tier3-text { color: var(--muted); }
|
||||||
|
|
||||||
|
.hp-ok { color: var(--green); font-weight: 600; }
|
||||||
|
.hp-low { color: var(--amber); font-weight: 600; }
|
||||||
|
.hp-crit { color: var(--red); font-weight: 600; }
|
||||||
|
|
||||||
|
.faction-union { color: #60a5fa; }
|
||||||
|
.faction-cda { color: #f87171; }
|
||||||
|
.faction-ecumeurs { color: #c084fc; }
|
||||||
|
.faction-syndicat { color: #facc15; }
|
||||||
|
.faction-krewe { color: #34d399; }
|
||||||
|
.faction-oak { color: #fb923c; }
|
||||||
|
.faction-regie { color: #e2e8f0; }
|
||||||
|
.faction-default { color: var(--muted); }
|
||||||
|
|
||||||
|
.ev-combat { color: var(--red); }
|
||||||
|
.ev-encounter { color: var(--amber); }
|
||||||
|
.ev-survival { color: #86efac; }
|
||||||
|
.ev-death { color: #ff0000; font-weight: 700; }
|
||||||
|
|
||||||
|
.rel-hostile { color: var(--red); }
|
||||||
|
.rel-neutral { color: var(--muted); }
|
||||||
|
.rel-allie { color: var(--green); }
|
||||||
|
|
||||||
|
.scrollable { max-height: 300px; overflow-y: auto; }
|
||||||
|
|
||||||
|
.world-zone { display: flex; justify-content: space-between; align-items: center; padding: 5px 0; border-bottom: 1px solid rgba(42,48,64,.5); font-size: 13px; }
|
||||||
|
.zone-name { color: var(--amber); min-width: 150px; }
|
||||||
|
.zone-stats { display: flex; gap: 10px; font-size: 12px; color: var(--muted); }
|
||||||
|
.zone-stats span { color: var(--text); }
|
||||||
|
|
||||||
|
.cfg-block { margin-bottom: 16px; }
|
||||||
|
.cfg-block h3 { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 6px; margin-bottom: 10px; }
|
||||||
|
.cfg-row { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
|
||||||
|
.cfg-row label { min-width: 230px; font-size: 13px; color: var(--muted); }
|
||||||
|
.cfg-row input[type=number], .cfg-row input[type=text], .cfg-row select { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px; font-family: inherit; font-size: 13px; width: 140px; }
|
||||||
|
.cfg-row input[type=checkbox] { accent-color: var(--green); width: auto; }
|
||||||
|
.cfg-row select:focus, .cfg-row input:focus { outline: none; border-color: var(--amber); }
|
||||||
|
|
||||||
|
.btn { padding: 7px 18px; border: 1px solid var(--border); background: var(--panel2); color: var(--text); font-family: inherit; font-size: 13px; font-weight: 500; border-radius: 6px; cursor: pointer; transition: all .15s; }
|
||||||
|
.btn:hover { border-color: var(--dim); background: #222736; }
|
||||||
|
.btn-green { border-color: var(--green); color: var(--green); background: rgba(74,222,128,.08); }
|
||||||
|
.btn-green:hover { background: rgba(74,222,128,.15); }
|
||||||
|
.btn-red { border-color: var(--red); color: var(--red); background: rgba(248,113,113,.08); }
|
||||||
|
.btn-red:hover { background: rgba(248,113,113,.15); }
|
||||||
|
.btn-amber { border-color: var(--amber); color: var(--amber); background: rgba(251,191,36,.08); }
|
||||||
|
.btn-amber:hover { background: rgba(251,191,36,.15); }
|
||||||
|
|
||||||
|
.code-block { position: relative; margin: 8px 0; }
|
||||||
|
.code-block pre { background: #0a0d14; border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; font-family: 'Consolas', 'Courier New', monospace; font-size: 12px; color: #86efac; white-space: pre-wrap; word-break: break-all; margin: 0; padding-right: 80px; }
|
||||||
|
.copy-btn { position: absolute; top: 6px; right: 8px; padding: 3px 10px; font-size: 11px; border: 1px solid var(--border); background: var(--panel2); color: var(--muted); border-radius: 4px; cursor: pointer; font-family: inherit; }
|
||||||
|
.copy-btn:hover { color: var(--text); border-color: var(--dim); }
|
||||||
|
|
||||||
|
.lore-card { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 10px; }
|
||||||
|
.lore-card h3 { font-size: 13px; color: var(--text); margin-bottom: 8px; font-weight: 500; }
|
||||||
|
.lore-diff { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 10px; }
|
||||||
|
.diff-old { background: rgba(248,113,113,.06); border: 1px solid rgba(248,113,113,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #fca5a5; white-space: pre-wrap; font-family: 'Consolas', monospace; }
|
||||||
|
.diff-new { background: rgba(74,222,128,.06); border: 1px solid rgba(74,222,128,.2); border-radius:5px; padding: 8px; font-size: 12px; color: #86efac; white-space: pre-wrap; font-family: 'Consolas', monospace; }
|
||||||
|
.diff-label { font-size: 11px; color: var(--muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: .5px; }
|
||||||
|
.lore-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
|
||||||
|
.lore-actions { display: flex; gap: 8px; align-items: flex-start; flex-wrap: wrap; }
|
||||||
|
.lore-actions textarea { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; font-family: 'Consolas', monospace; font-size: 12px; padding: 6px 8px; width: 100%; min-height: 60px; resize: vertical; }
|
||||||
|
.lore-filters { display: flex; gap: 10px; margin-bottom: 12px; align-items: center; flex-wrap: wrap; }
|
||||||
|
.lore-filters label { font-size: 13px; color: var(--muted); }
|
||||||
|
.lore-filters select { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; font-family: inherit; font-size: 13px; padding: 4px 8px; }
|
||||||
|
.lore-stat-bar { display: flex; gap: 16px; margin-bottom: 12px; font-size: 13px; flex-wrap: wrap; }
|
||||||
|
.lore-stat { color: var(--muted); }
|
||||||
|
.lore-stat span { font-weight: 600; }
|
||||||
|
.badge-pending { background: rgba(251,191,36,.12); color: var(--amber); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge-accepted { background: rgba(74,222,128,.12); color: var(--green); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge-rejected { background: rgba(248,113,113,.12); color: var(--red); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge-modified { background: rgba(96,165,250,.12); color: var(--blue); padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.change-type-badge { font-size: 11px; color: var(--dim); border: 1px solid var(--border); padding: 1px 7px; border-radius: 4px; }
|
||||||
|
|
||||||
|
.tools-section { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 16px; margin-bottom: 14px; }
|
||||||
|
.tools-section h2 { font-size: 13px; font-weight: 600; text-transform: uppercase; letter-spacing: .8px; color: var(--amber); border-bottom: 1px solid var(--border); padding-bottom: 8px; margin-bottom: 14px; }
|
||||||
|
.tool-row { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||||
|
.tool-row label { font-size: 13px; color: var(--muted); min-width: 100px; }
|
||||||
|
.tool-row select, .tool-row input[type=number] { background: #0a0d14; color: var(--text); border: 1px solid var(--border); border-radius: 5px; padding: 5px 10px; font-family: inherit; font-size: 13px; }
|
||||||
|
.radio-group { display: flex; gap: 6px; flex-wrap: wrap; }
|
||||||
|
.radio-group label { display: flex; align-items: center; gap: 5px; padding: 4px 12px; border: 1px solid var(--border); border-radius: 5px; cursor: pointer; font-size: 13px; color: var(--muted); min-width: auto; transition: all .15s; }
|
||||||
|
.radio-group input[type=radio] { display: none; }
|
||||||
|
.radio-group input[type=radio]:checked + span { color: var(--amber); }
|
||||||
|
.radio-group label:has(input:checked) { border-color: var(--amber); background: rgba(251,191,36,.08); color: var(--amber); }
|
||||||
|
.enrich-card { background:var(--panel);border:1px solid var(--border);border-radius:6px;padding:12px 14px; }
|
||||||
|
.mini-console { background:#020408;border:1px solid var(--border);padding:8px 10px;max-height:200px;overflow-y:auto;font-size:11px;color:#a8d5a2;white-space:pre-wrap;border-radius:4px;margin-top:8px; }
|
||||||
|
.results-area { margin-top: 12px; }
|
||||||
|
.results-meta { font-size: 12px; color: var(--muted); margin-bottom: 8px; }
|
||||||
|
.diag-good { color: var(--green); font-weight: 600; }
|
||||||
|
.diag-bad { color: var(--red); font-weight: 600; }
|
||||||
|
|
||||||
|
.sim-tab-btn { background:none;border:none;border-bottom:2px solid transparent;color:var(--dim);padding:8px 18px;cursor:pointer;font-size:13px;font-family:inherit;transition:color .15s; }
|
||||||
|
.sim-tab-btn:hover { color:var(--text); }
|
||||||
|
.sim-tab-btn.active { color:var(--amber);border-bottom-color:var(--amber);font-weight:600; }
|
||||||
|
|
||||||
|
.refresh-note { font-size: 12px; color: var(--dim); margin-left: auto; }
|
||||||
|
.refresh-note a { color: var(--muted); text-decoration: none; }
|
||||||
|
.refresh-note a:hover { color: var(--text); }
|
||||||
|
|
||||||
|
.footer { text-align: center; font-size: 11px; color: var(--dim); padding: 16px; border-top: 1px solid var(--border); margin-top: 20px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div class="topbar">
|
||||||
|
<span class="topbar-title">📟 PIPBOY</span>
|
||||||
|
<div class="session-pills">
|
||||||
|
{% for s in all_sessions %}
|
||||||
|
{% set is_active = s.status == 'active' %}
|
||||||
|
<a href="/?sid={{ s.id }}"
|
||||||
|
class="session-pill {{ 'active' if s.id == current_sid and tab == 'sim' else ('stopped' if not is_active else '') }}">
|
||||||
|
<span class="dot {{ 'dot-green' if is_active else 'dot-red' }}"></span>
|
||||||
|
S{{ s.id }}
|
||||||
|
</a>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<span class="nav-sep"></span>
|
||||||
|
<div class="nav-tabs">
|
||||||
|
<a href="/?sid={{ current_sid }}" class="{{ 'active' if tab=='sim' }}">📡 SIM</a>
|
||||||
|
<a href="/tools?sid={{ current_sid }}" class="{{ 'active' if tab=='tools' }}">🔧 OUTILS</a>
|
||||||
|
<a href="/lore" class="{{ 'active' if tab=='lore' }}">📜 LORE</a>
|
||||||
|
<a href="/sim-proposals" class="{{ 'active' if tab=='sim_proposals' }}">🎲 RÈGLES SIM</a>
|
||||||
|
<a href="/params?sid={{ current_sid }}" class="{{ 'active' if tab=='params' }}">⚙ PARAMS</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if flash_msg %}
|
||||||
|
<div class="flash {{ 'flash-ok' if flash_ok else 'flash-err' }}">{{ flash_msg }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
{% block content %}{% endblock %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="footer">PipBoy v5.1 | SIM LIBRE • OUTILS • ENRICHISSEMENT • PARAMÈTRES</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="info-bar">
|
||||||
|
<span style="font-weight:600">📜 Enrichissement Lore — Venice of Wasteland</span>
|
||||||
|
<div class="lore-stat-bar" style="margin:0">
|
||||||
|
<span class="lore-stat">En attente : <span style="color:var(--amber)">{{ lore_stats.pending }}</span></span>
|
||||||
|
<span class="lore-stat">Acceptées : <span style="color:var(--green)">{{ lore_stats.accepted }}</span></span>
|
||||||
|
<span class="lore-stat">Rejetées : <span style="color:var(--red)">{{ lore_stats.rejected }}</span></span>
|
||||||
|
<span class="lore-stat">Modifiées : <span style="color:var(--blue)">{{ lore_stats.modified }}</span></span>
|
||||||
|
</div>
|
||||||
|
</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:12px;color:var(--dim)">{{ proposals|length }} proposition(s) affichée(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="panel" style="color:var(--dim);text-align:center;padding:24px">
|
||||||
|
Aucune proposition lore.<br>
|
||||||
|
<small style="color:var(--border)">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-{{ p.status }}">{{ p.status | upper }}</span>
|
||||||
|
<span class="change-type-badge">{{ p.change_type }}</span>
|
||||||
|
<span style="color:var(--amber)">{{ p.faction_slug }}</span>
|
||||||
|
{% if p.field_path %}<span>{{ p.field_path }}</span>{% endif %}
|
||||||
|
<span style="color:var(--dim)">{{ 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:6px">
|
||||||
|
<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é…">{{ p.proposed_text }}</textarea>
|
||||||
|
<button type="submit" class="btn" style="margin-top:6px">✎ Modifier & Accepter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% elif p.status == 'modified' and p.modified_text %}
|
||||||
|
<div style="margin-top:8px">
|
||||||
|
<div class="diff-label">Texte modifié accepté</div>
|
||||||
|
<div class="diff-new" style="border-color:rgba(96,165,250,.3)">{{ p.modified_text }}</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if p.status in ('modified', 'accepted') %}
|
||||||
|
<button type="button" class="btn" onclick="toggleDiscuss({{ p.id }})" style="font-size:12px;margin-top:8px">💬 Discuter / Modifier</button>
|
||||||
|
<div id="discuss-{{ p.id }}" style="display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:10px">
|
||||||
|
{% for msg in proposal_threads.get(p.id, []) %}
|
||||||
|
<div style="margin-bottom:8px;padding:6px 10px;border-radius:4px;{% if msg.role=='user' %}background:#0d1117;border-left:2px solid var(--amber){% else %}background:#050d05;border-left:2px solid var(--green){% endif %}">
|
||||||
|
<span style="font-size:10px;color:var(--dim);display:block;margin-bottom:3px">{{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}</span>
|
||||||
|
<span style="font-size:12px;white-space:pre-wrap">{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
|
||||||
|
<textarea id="discuss-msg-{{ p.id }}" placeholder="Ex: Garde l'idée mais réduis l'impact de 50%…" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:6px;border-radius:3px;height:54px;resize:vertical"></textarea>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<span style="font-size:11px;color:var(--dim)">temp: <input type="range" id="discuss-temp-{{ p.id }}" min="0" max="1" step="0.05" value="0.75" style="width:70px;vertical-align:middle" oninput="document.getElementById('discuss-temp-val-{{ p.id }}').textContent=this.value"> <span id="discuss-temp-val-{{ p.id }}">0.75</span></span>
|
||||||
|
<span style="font-size:11px;color:var(--dim)">tokens: <input type="number" id="discuss-np-{{ p.id }}" value="512" min="128" max="2048" step="128" style="width:58px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:2px 4px"></span>
|
||||||
|
<button class="btn btn-green" onclick="submitDiscuss({{ p.id }})" style="font-size:12px">▶ Envoyer</button>
|
||||||
|
<span id="discuss-spinner-{{ p.id }}" style="display:none;color:var(--amber);font-size:12px">⏳ Génération…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDiscuss(id) {
|
||||||
|
var el = document.getElementById('discuss-' + id);
|
||||||
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
function submitDiscuss(id) {
|
||||||
|
var msg = document.getElementById('discuss-msg-' + id).value.trim();
|
||||||
|
var temp = parseFloat(document.getElementById('discuss-temp-' + id).value);
|
||||||
|
var np = parseInt(document.getElementById('discuss-np-' + id).value);
|
||||||
|
if (!msg) { alert('Message vide'); return; }
|
||||||
|
var spin = document.getElementById('discuss-spinner-' + id);
|
||||||
|
spin.style.display = 'inline';
|
||||||
|
fetch('/lore/' + id + '/discuss', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({message: msg, temperature: temp, num_predict: np})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
spin.style.display = 'none';
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
location.reload();
|
||||||
|
}).catch(function(e){ spin.style.display = 'none'; alert('Erreur réseau : ' + e); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if session %}
|
||||||
|
<div class="info-bar">
|
||||||
|
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
|
||||||
|
<span class="mode-tag">{{ session.mode | upper }}</span>
|
||||||
|
{% set st = session.status %}
|
||||||
|
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
|
||||||
|
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
|
||||||
|
{{ st | upper }}
|
||||||
|
</span>
|
||||||
|
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> / <span class="label">Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
|
||||||
|
<!-- Config JSON -->
|
||||||
|
<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:8px;font-size:12px;color:var(--muted)">
|
||||||
|
Prise en compte au prochain jour (J{{ (session.current_day or 0) + 1 if session else '?' }}).
|
||||||
|
Pour effet immédiat : STOP → relancer via terminal.
|
||||||
|
</p>
|
||||||
|
{% else %}
|
||||||
|
<p style="color:var(--red);font-size:13px">
|
||||||
|
Fichier <code>sim_{{ '%03d' % current_sid }}.json</code> introuvable dans <code>{{ sim_configs_dir }}</code>
|
||||||
|
</p>
|
||||||
|
<p style="color:var(--muted);font-size:12px;margin-top:6px">
|
||||||
|
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>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p style="margin-top:8px;font-size:12px;color:var(--muted)">
|
||||||
|
STOP → la sim s'arrête dans les 5 ticks suivants.
|
||||||
|
</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-text' if cfg and m_name == cfg.get('mode','') else '' }}">
|
||||||
|
{{ m_name }}{% if cfg and m_name == cfg.get('mode','') %} ◀{% endif %}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('survival',{}).get('drain_speed','—') }}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('encounter',{}).get('global_rate_multiplier','—') }}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">×{{ m_vals.get('economy',{}).get('caps_global_multiplier','—') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="cfg-block">
|
||||||
|
<h3>Commandes SSH (référence)</h3>
|
||||||
|
<div class="code-block">
|
||||||
|
<pre id="ssh_logs_cmd">tail -f ~/fallout_sim_s{{ current_sid }}.log</pre>
|
||||||
|
<button class="copy-btn" onclick="copyText('ssh_logs_cmd')">Copier</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyText(elementId) {
|
||||||
|
var el = document.getElementById(elementId);
|
||||||
|
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
|
||||||
|
var btn = el.nextElementSibling;
|
||||||
|
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,402 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% if session %}
|
||||||
|
<div class="info-bar">
|
||||||
|
<span><span class="label">Session</span> <strong class="val">#{{ session.id }} — {{ session.name }}</strong></span>
|
||||||
|
<span><span class="label">Jour</span> <strong class="val">{{ session.current_day }}</strong> <span class="label">/ Tick</span> <strong class="val">{{ session.current_tick }}h</strong></span>
|
||||||
|
<span class="mode-tag">{{ session.mode | upper }}</span>
|
||||||
|
{% set st = session.status %}
|
||||||
|
<span class="status-badge {{ 'status-active' if st=='active' else 'status-stopped' if st=='stopped' else 'status-paused' }}">
|
||||||
|
<span class="dot {{ 'dot-green' if st=='active' else 'dot-red' if st=='stopped' else 'dot-amber' }}"></span>
|
||||||
|
{{ st | upper }}
|
||||||
|
</span>
|
||||||
|
<span class="label">Seed: <span class="val">{{ session.seed_global }}</span></span>
|
||||||
|
<span class="refresh-note">
|
||||||
|
<a href="?sid={{ session.id }}">↻ Actualiser</a>
|
||||||
|
<span id="countdown"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="metric-row">
|
||||||
|
<div class="metric-card"><div class="metric-label">PNJ Vivants</div><div class="metric-value metric-green">{{ alive_count }}</div></div>
|
||||||
|
<div class="metric-card"><div class="metric-label">PNJ Morts</div><div class="metric-value metric-red">{{ stats.get('PNJ morts', 0) }}</div></div>
|
||||||
|
<div class="metric-card"><div class="metric-label">Total PNJ</div><div class="metric-value metric-muted">{{ total_count }}</div></div>
|
||||||
|
<div class="metric-card"><div class="metric-label">Combats</div><div class="metric-value metric-amber">{{ stats.get('Combats', 0) }}</div></div>
|
||||||
|
<div class="metric-card"><div class="metric-label">Rencontres</div><div class="metric-value metric-blue">{{ stats.get('Rencontres', 0) }}</div></div>
|
||||||
|
<div class="metric-card"><div class="metric-label">Événements</div><div class="metric-value metric-muted">{{ stats.get('Événements total', 0) }}</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid">
|
||||||
|
<div class="panel">
|
||||||
|
<h2>👥 PNJ ({{ 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 %}
|
||||||
|
{% set pct = (c.hp / c.max_hp * 100) | int if c.max_hp else 0 %}
|
||||||
|
<tr class="{{ 'dead' if not c.is_alive else '' }}">
|
||||||
|
<td>
|
||||||
|
{% if c.tier == 0 %}<span class="tier0-text">★ BOSS</span>
|
||||||
|
{% elif c.tier == 2 %}<span class="tier2-text">T2</span>
|
||||||
|
{% elif c.tier == 3 %}<span class="tier3-text">T3</span>
|
||||||
|
{% else %}<span class="tier1-text">T1</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>{{ c.name }}</td>
|
||||||
|
<td>
|
||||||
|
{% if c.is_alive %}
|
||||||
|
<span class="{{ 'hp-crit' if pct < 25 else 'hp-low' if pct < 50 else 'hp-ok' }}">{{ c.hp }}/{{ c.max_hp }}</span>
|
||||||
|
{% else %}<span class="tier3-text">†</span>{% endif %}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set f = c.faction_slug or 'sans' %}
|
||||||
|
<span class="faction-{{ 'union' if 'union' in f else 'cda' if 'cda' in f else 'ecumeurs' if 'ecumeur' in f else 'krewe' if 'krewe' in f else 'oak' if 'oak' in f else 'regie' if 'regie' in f else 'syndicat' if 'syndicat' in f else 'default' }}">{{ f[:14] }}</span>
|
||||||
|
</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted)">{{ (c.location_slug or '—')[:16] }}</td>
|
||||||
|
<td style="color:var(--amber)">{{ c.caps }}¢</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 style="color:var(--muted);white-space:nowrap">{{ ev.day }}/{{ ev.tick }}</td>
|
||||||
|
<td class="ev-{{ ev.event_type }}" style="white-space:nowrap"><small>{{ ev.event_type }}</small></td>
|
||||||
|
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not events %}<tr><td colspan="3" style="color:var(--dim);text-align:center;padding:16px">Aucun événement</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid3">
|
||||||
|
<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="zone-stats">
|
||||||
|
<span>🍖 <span>{{ ws.food_level or 0 }}%</span></span>
|
||||||
|
<span>💧 <span>{{ ws.water_level or 0 }}%</span></span>
|
||||||
|
<span>🛡 <span>{{ ws.security_level or 0 }}%</span></span>
|
||||||
|
<span>👤 <span>{{ ws.pop_count or 0 }}</span></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not world_state %}<p style="color:var(--dim);padding:12px 0">Pas de données monde</p>{% endif %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<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 %}
|
||||||
|
{% set cls = 'rel-hostile' if r.relation_score < -20 else 'rel-allie' if r.relation_score > 20 else 'rel-neutral' %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px">{{ r.faction_a[:14] }}</td>
|
||||||
|
<td style="font-size:12px">{{ r.faction_b[:14] }}</td>
|
||||||
|
<td class="{{ cls }}">{{ r.relation_score }}</td>
|
||||||
|
<td class="{{ cls }}" style="font-size:12px">{{ r.relation_label or ('HOSTILE' if r.relation_score < -20 else 'ALLIÉ' if r.relation_score > 20 else 'NEUTRE') }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not faction_relations %}<tr><td colspan="4" style="color:var(--dim)">Pas de données</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h2>🎭 PNJ par tier</h2>
|
||||||
|
<table style="margin-bottom:12px">
|
||||||
|
<tr><th>Tier</th><th>Label</th><th>Vivants</th><th>Morts</th></tr>
|
||||||
|
{% for row in tier_stats %}
|
||||||
|
<tr>
|
||||||
|
<td class="tier{{ row.tier }}-text">{{ row.tier }}</td>
|
||||||
|
<td>{{ row.label }}</td>
|
||||||
|
<td class="metric-green">{{ row.alive }}</td>
|
||||||
|
<td style="color:var(--muted)">{{ row.dead }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
<h2 style="margin-top:4px">🏙 PNJ par zone (top 5)</h2>
|
||||||
|
<table>
|
||||||
|
<tr><th>Zone</th><th>PNJ</th></tr>
|
||||||
|
{% for row in zone_stats %}
|
||||||
|
<tr>
|
||||||
|
<td style="font-size:12px">{{ row.location_slug }}</td>
|
||||||
|
<td class="metric-green">{{ row.cnt }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" style="margin-top:12px">
|
||||||
|
<h2>⚡ Rencontres récentes</h2>
|
||||||
|
<div class="scrollable" style="max-height:180px">
|
||||||
|
<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 style="color:var(--muted);white-space:nowrap;font-size:12px">{{ ev.day }}/{{ ev.tick }}</td>
|
||||||
|
<td class="ev-encounter" style="font-size:12px;white-space:nowrap">{{ ev.event_type }}</td>
|
||||||
|
<td style="font-size:12px">{{ ev.actor_name or '—' }}</td>
|
||||||
|
<td style="font-size:12px;color:var(--muted)">{{ (ev.location_slug or '—')[:16] }}</td>
|
||||||
|
<td style="font-size:12px">{{ ev.description[:90] }}{% if ev.description|length > 90 %}…{% endif %}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
{% if not encounters %}<tr><td colspan="5" style="color:var(--dim);text-align:center;padding:12px">Aucune rencontre</td></tr>{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if boss_inventory %}
|
||||||
|
<div class="panel" style="margin-top:12px">
|
||||||
|
<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-text">{{ row.char_name }}</td>
|
||||||
|
<td>{{ row.item_name }}</td>
|
||||||
|
<td style="color:var(--muted);font-size:12px">{{ row.item_type }}</td>
|
||||||
|
<td>{{ row.quantity }}</td>
|
||||||
|
<td style="color:var(--green)">{{ '✓' if row.is_equipped else '' }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<!-- Contrôles simulation -->
|
||||||
|
<div style="margin-top:16px">
|
||||||
|
<div style="display:flex;align-items:center;gap:0;border-bottom:1px solid var(--border);flex-wrap:wrap">
|
||||||
|
<button class="sim-tab-btn active" id="stab-truth" onclick="showSimTab('truth')">📚 Vérité</button>
|
||||||
|
<button class="sim-tab-btn" id="stab-enriched" onclick="showSimTab('enriched')">✨ Enrichie</button>
|
||||||
|
<button class="sim-tab-btn" id="stab-mix" onclick="showSimTab('mix')">⚡ MIX</button>
|
||||||
|
<div style="margin-left:auto;display:flex;align-items:center;gap:8px;padding:4px 6px;flex-wrap:wrap">
|
||||||
|
<span style="font-size:11px;color:var(--dim)">Tick global :</span>
|
||||||
|
<div class="radio-group" style="margin:0">
|
||||||
|
<label><input type="radio" name="sim_tick" value="0"><span>INSTANT</span></label>
|
||||||
|
<label><input type="radio" name="sim_tick" value="5" checked><span>5s</span></label>
|
||||||
|
<label><input type="radio" name="sim_tick" value="30"><span>30s</span></label>
|
||||||
|
<label><input type="radio" name="sim_tick" value="60"><span>60s</span></label>
|
||||||
|
<label><input type="radio" name="sim_tick" value="3600"><span>1h</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="simtab-truth" class="sim-tab-content" style="display:block">
|
||||||
|
<div class="panel" style="margin-top:12px;padding:16px">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||||||
|
<span class="dot dot-red" id="dot-sim-truth"></span>
|
||||||
|
<span style="font-weight:600">📚 Sim Vérité</span>
|
||||||
|
<span style="font-size:10px;color:var(--dim)">sources : fallout_lore + fallout_sim_rules uniquement</span>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;align-items:center;gap:16px;flex-wrap:wrap;margin-bottom:12px">
|
||||||
|
<span style="font-size:12px;color:var(--dim)">Session : <strong style="color:var(--text)">#{{ current_sid }}</strong></span>
|
||||||
|
<label style="font-size:12px;color:var(--dim)">Mode :
|
||||||
|
<select id="truth-mode" onchange="setSessionMode(this.value)" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:2px 8px;margin-left:4px">
|
||||||
|
{% for m in ['pacifiste','politique','guerre_commerciale','guerre','survie_extreme'] %}
|
||||||
|
<option value="{{ m }}" {{ 'selected' if session and session.mode==m else '' }}>{{ m }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="sim-truth-status" style="font-size:12px;color:var(--dim);margin-bottom:10px">Arrêtée</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-green" id="btn-truth-start" onclick="simStart('truth')">▶ Démarrer</button>
|
||||||
|
<button class="btn btn-red" id="btn-truth-stop" onclick="simStop('truth')">■ Arrêter</button>
|
||||||
|
<button class="btn" onclick="simDownloadLog('truth')" style="font-size:11px">⬇ Log JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="console-sim-truth" class="mini-console" style="display:none;margin-top:12px;max-height:320px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="simtab-enriched" class="sim-tab-content" style="display:none">
|
||||||
|
<div class="panel" style="margin-top:12px;padding:16px">
|
||||||
|
<div style="display:flex;align-items:center;gap:10px;margin-bottom:12px">
|
||||||
|
<span class="dot dot-red" id="dot-sim-enriched"></span>
|
||||||
|
<span style="font-weight:600;color:var(--amber)">✨ Sim Enrichie</span>
|
||||||
|
<span style="font-size:10px;color:var(--dim)">+ fallout_lore_enriched + fallout_sim_enriched</span>
|
||||||
|
</div>
|
||||||
|
<div id="sim-enriched-status" style="font-size:12px;color:var(--dim);margin-bottom:10px">Arrêtée</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-green" id="btn-enriched-start" onclick="simStart('enriched')">▶ Démarrer</button>
|
||||||
|
<button class="btn btn-red" id="btn-enriched-stop" onclick="simStop('enriched')">■ Arrêter</button>
|
||||||
|
<button class="btn" onclick="simDownloadLog('enriched')" style="font-size:11px">⬇ Log JSON</button>
|
||||||
|
</div>
|
||||||
|
<div id="console-sim-enriched" class="mini-console" style="display:none;margin-top:12px;max-height:320px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="simtab-mix" class="sim-tab-content" style="display:none">
|
||||||
|
<div class="panel" style="margin-top:12px;padding:16px">
|
||||||
|
<div style="margin-bottom:12px">
|
||||||
|
<span style="font-weight:600;color:var(--green)">⚡ Mode MIX — Comparaison simultanée</span>
|
||||||
|
<p style="font-size:12px;color:var(--dim);margin:6px 0 0">Lance les deux simulations en même temps.</p>
|
||||||
|
</div>
|
||||||
|
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:14px">
|
||||||
|
<button class="btn btn-green" id="btn-mix-start" onclick="simMixStart()">▶ Lancer les 2</button>
|
||||||
|
<button class="btn btn-red" id="btn-mix-stop" onclick="simMixStop()">■ Arrêter les 2</button>
|
||||||
|
<button class="btn" onclick="simDownloadLog('truth')" style="font-size:11px">⬇ Log Vérité</button>
|
||||||
|
<button class="btn" onclick="simDownloadLog('enriched')" style="font-size:11px">⬇ Log Enrichie</button>
|
||||||
|
</div>
|
||||||
|
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--dim);margin-bottom:6px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="dot dot-red" id="dot-mix-truth"></span>📚 Vérité
|
||||||
|
</div>
|
||||||
|
<div id="console-mix-truth" class="mini-console" style="max-height:260px"></div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div style="font-size:11px;color:var(--amber);margin-bottom:6px;display:flex;align-items:center;gap:6px">
|
||||||
|
<span class="dot dot-red" id="dot-mix-enriched"></span>✨ Enrichie
|
||||||
|
</div>
|
||||||
|
<div id="console-mix-enriched" class="mini-console" style="max-height:260px"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var _simPolls = {truth: null, enriched: null};
|
||||||
|
var _simLastLines = {truth: 0, enriched: 0};
|
||||||
|
var _mixLastLines = {truth: 0, enriched: 0};
|
||||||
|
|
||||||
|
function showSimTab(tab) {
|
||||||
|
['truth','enriched','mix'].forEach(function(t) {
|
||||||
|
document.getElementById('simtab-' + t).style.display = (t === tab) ? 'block' : 'none';
|
||||||
|
document.getElementById('stab-' + t).classList.toggle('active', t === tab);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simStart(mode) {
|
||||||
|
var tickEl = document.querySelector('input[name="sim_tick"]:checked');
|
||||||
|
var tickSpeed = tickEl ? parseInt(tickEl.value) : 5;
|
||||||
|
fetch('/api/sim/' + mode + '/run', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({sid: {{ current_sid }}, tick_speed_s: tickSpeed})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
_simLastLines[mode] = 0;
|
||||||
|
var cons = document.getElementById('console-sim-' + mode);
|
||||||
|
cons.textContent = '';
|
||||||
|
cons.style.display = 'block';
|
||||||
|
document.getElementById('dot-sim-' + mode).className = 'dot dot-green';
|
||||||
|
document.getElementById('sim-' + mode + '-status').textContent = 'En cours — ' + (tickSpeed === 0 ? 'INSTANT' : tickSpeed + 's') + '/tick';
|
||||||
|
document.getElementById('btn-' + mode + '-start').disabled = true;
|
||||||
|
if (_simPolls[mode]) clearInterval(_simPolls[mode]);
|
||||||
|
_simPolls[mode] = setInterval(function(){ simPoll(mode); }, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simPoll(mode) {
|
||||||
|
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
var cons = document.getElementById('console-sim-' + mode);
|
||||||
|
var lines = d.output || [];
|
||||||
|
if (lines.length > _simLastLines[mode]) {
|
||||||
|
cons.textContent += lines.slice(_simLastLines[mode]).join('\n') + '\n';
|
||||||
|
cons.scrollTop = cons.scrollHeight;
|
||||||
|
_simLastLines[mode] = lines.length;
|
||||||
|
}
|
||||||
|
if (!d.running) {
|
||||||
|
clearInterval(_simPolls[mode]);
|
||||||
|
document.getElementById('dot-sim-' + mode).className = 'dot ' + (d.returncode === 0 ? 'dot-amber' : 'dot-red');
|
||||||
|
document.getElementById('sim-' + mode + '-status').textContent = d.error || ('Arrêtée (code ' + d.returncode + ')');
|
||||||
|
document.getElementById('btn-' + mode + '-start').disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simStop(mode) {
|
||||||
|
fetch('/api/sim/' + mode + '/stop', {method:'POST'}).then(function(){ simPoll(mode); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function simDownloadLog(mode) { window.location.href = '/api/sim/' + mode + '/log'; }
|
||||||
|
|
||||||
|
function simMixStart() {
|
||||||
|
var tickEl = document.querySelector('input[name="sim_tick"]:checked');
|
||||||
|
var tickSpeed = tickEl ? parseInt(tickEl.value) : 5;
|
||||||
|
['truth','enriched'].forEach(function(mode) {
|
||||||
|
fetch('/api/sim/' + mode + '/run', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({sid: {{ current_sid }}, tick_speed_s: tickSpeed})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.error) { console.warn('[MIX] ' + mode + ':', d.error); return; }
|
||||||
|
_mixLastLines[mode] = 0;
|
||||||
|
document.getElementById('console-mix-' + mode).textContent = '';
|
||||||
|
document.getElementById('dot-mix-' + mode).className = 'dot dot-green';
|
||||||
|
document.getElementById('btn-mix-start').disabled = true;
|
||||||
|
if (_simPolls[mode]) clearInterval(_simPolls[mode]);
|
||||||
|
_simPolls[mode] = setInterval(function(){ simMixPoll(mode); }, 2000);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simMixPoll(mode) {
|
||||||
|
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
var cons = document.getElementById('console-mix-' + mode);
|
||||||
|
var lines = d.output || [];
|
||||||
|
if (lines.length > _mixLastLines[mode]) {
|
||||||
|
cons.textContent += lines.slice(_mixLastLines[mode]).join('\n') + '\n';
|
||||||
|
cons.scrollTop = cons.scrollHeight;
|
||||||
|
_mixLastLines[mode] = lines.length;
|
||||||
|
}
|
||||||
|
if (!d.running) {
|
||||||
|
clearInterval(_simPolls[mode]);
|
||||||
|
document.getElementById('dot-mix-' + mode).className = 'dot ' + (d.returncode === 0 ? 'dot-amber' : 'dot-red');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function simMixStop() { ['truth','enriched'].forEach(function(mode){ simStop(mode); }); }
|
||||||
|
|
||||||
|
function setSessionMode(mode) {
|
||||||
|
fetch('/api/session/set-mode', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({sid: {{ current_sid }}, mode: mode})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.error) alert('Erreur : ' + d.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
['truth','enriched'].forEach(function(mode){
|
||||||
|
fetch('/api/sim/' + mode + '/status').then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.running) {
|
||||||
|
document.getElementById('dot-sim-' + mode).className = 'dot dot-green';
|
||||||
|
document.getElementById('sim-' + mode + '-status').textContent = 'En cours';
|
||||||
|
document.getElementById('btn-' + mode + '-start').disabled = true;
|
||||||
|
document.getElementById('console-sim-' + mode).style.display = 'block';
|
||||||
|
_simLastLines[mode] = (d.output||[]).length;
|
||||||
|
_simPolls[mode] = setInterval(function(){ simPoll(mode); }, 2000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
var t = 30;
|
||||||
|
var el = document.getElementById('countdown');
|
||||||
|
if (el) {
|
||||||
|
setInterval(function() {
|
||||||
|
t--;
|
||||||
|
el.textContent = ' (' + t + 's)';
|
||||||
|
if (t <= 0) { location.reload(); }
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<div class="info-bar">
|
||||||
|
<span style="font-weight:600">🎲 Règles Sim — Propositions LLM</span>
|
||||||
|
<div class="lore-stat-bar" style="margin:0">
|
||||||
|
<span class="lore-stat">En attente : <span style="color:var(--amber)">{{ sim_stats.pending }}</span></span>
|
||||||
|
<span class="lore-stat">Acceptées : <span style="color:var(--green)">{{ sim_stats.accepted }}</span></span>
|
||||||
|
<span class="lore-stat">Rejetées : <span style="color:var(--red)">{{ sim_stats.rejected }}</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="lore-filters">
|
||||||
|
<label>Type :</label>
|
||||||
|
<select onchange="location.href='/sim-proposals?type='+this.value+'&status='+document.getElementById('sp-status').value">
|
||||||
|
<option value="" {{ 'selected' if not filt_type else '' }}>— tous —</option>
|
||||||
|
{% for t in sim_types %}
|
||||||
|
<option value="{{ t }}" {{ 'selected' if filt_type==t else '' }}>{{ t }}</option>
|
||||||
|
{% endfor %}
|
||||||
|
</select>
|
||||||
|
<label>Statut :</label>
|
||||||
|
<select id="sp-status" onchange="location.href='/sim-proposals?type='+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="" {{ 'selected' if not filt_status else '' }}>— tous —</option>
|
||||||
|
</select>
|
||||||
|
<span style="font-size:12px;color:var(--dim)">{{ proposals|length }} proposition(s)</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if not proposals %}
|
||||||
|
<div class="panel" style="color:var(--dim);text-align:center;padding:24px">
|
||||||
|
Aucune proposition.<br>
|
||||||
|
<small style="color:var(--border)">Lancer : <code>python3 sim_enricher.py --type all</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-{{ p.status }}">{{ p.status | upper }}</span>
|
||||||
|
<span class="change-type-badge">{{ p.proposal_type }}</span>
|
||||||
|
<span style="color:var(--dim)">{{ p.created_at.strftime('%d/%m %H:%M') if p.created_at else '' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3>{{ p.title }}</h3>
|
||||||
|
<p style="color:var(--text);margin:6px 0;font-size:13px">{{ p.description }}</p>
|
||||||
|
|
||||||
|
{% if p.rationale %}
|
||||||
|
<p style="color:var(--dim);font-size:12px;font-style:italic">💡 {{ p.rationale }}</p>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if p.json_patch %}
|
||||||
|
<div class="diff-new" style="font-size:11px;white-space:pre-wrap;max-height:200px;overflow:auto">{{ p.json_patch | tojson(indent=2) }}</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if p.status == 'pending' %}
|
||||||
|
<div class="lore-actions" style="margin-top:10px">
|
||||||
|
<form method="POST" action="/sim-proposals/action" style="display:inline">
|
||||||
|
<input type="hidden" name="id" value="{{ p.id }}">
|
||||||
|
<input type="hidden" name="action" value="accept">
|
||||||
|
<input type="hidden" name="filt_type" value="{{ filt_type }}">
|
||||||
|
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||||||
|
<button type="submit" class="btn btn-green" onclick="return confirm('Accepter et indexer dans fallout_sim_enriched ?')">✓ Accepter</button>
|
||||||
|
</form>
|
||||||
|
<form method="POST" action="/sim-proposals/action" style="display:inline-block;vertical-align:top">
|
||||||
|
<input type="hidden" name="id" value="{{ p.id }}">
|
||||||
|
<input type="hidden" name="action" value="reject">
|
||||||
|
<input type="hidden" name="filt_type" value="{{ filt_type }}">
|
||||||
|
<input type="hidden" name="filt_status" value="{{ filt_status }}">
|
||||||
|
<textarea name="reject_reason" placeholder="Motif du rejet (optionnel — améliore le feedback futur)" style="display:block;width:260px;height:38px;margin-bottom:4px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:4px 6px;border-radius:3px;resize:vertical"></textarea>
|
||||||
|
<button type="submit" class="btn btn-red">✗ Rejeter</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<button type="button" class="btn" onclick="toggleDiscuss({{ p.id }})" style="font-size:12px;margin-top:8px">💬 Discuter</button>
|
||||||
|
<div id="discuss-{{ p.id }}" style="display:none;margin-top:12px;border-top:1px solid var(--border);padding-top:10px">
|
||||||
|
{% for msg in proposal_threads.get(p.id, []) %}
|
||||||
|
<div style="margin-bottom:8px;padding:6px 10px;border-radius:4px;{% if msg.role=='user' %}background:#0d1117;border-left:2px solid var(--amber){% else %}background:#050d05;border-left:2px solid var(--green){% endif %}">
|
||||||
|
<span style="font-size:10px;color:var(--dim);display:block;margin-bottom:3px">{{ '👤 Toi' if msg.role=='user' else '🤖 LLM' }} — {{ msg.created_at.strftime('%d/%m %H:%M') if msg.created_at else '' }}</span>
|
||||||
|
<span style="font-size:12px;white-space:pre-wrap">{{ msg.content }}</span>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
<div style="display:flex;flex-direction:column;gap:6px;margin-top:8px">
|
||||||
|
<textarea id="discuss-msg-{{ p.id }}" placeholder="Ex: Réduis l'impact de 50%…" style="background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:12px;padding:6px;border-radius:3px;height:54px;resize:vertical"></textarea>
|
||||||
|
<div style="display:flex;gap:10px;align-items:center;flex-wrap:wrap">
|
||||||
|
<span style="font-size:11px;color:var(--dim)">temp: <input type="range" id="discuss-temp-{{ p.id }}" min="0" max="1" step="0.05" value="0.75" style="width:70px;vertical-align:middle" oninput="document.getElementById('discuss-temp-val-{{ p.id }}').textContent=this.value"> <span id="discuss-temp-val-{{ p.id }}">0.75</span></span>
|
||||||
|
<span style="font-size:11px;color:var(--dim)">tokens: <input type="number" id="discuss-np-{{ p.id }}" value="512" min="128" max="2048" step="128" style="width:58px;background:var(--bg);border:1px solid var(--border);color:var(--text);font-size:11px;padding:2px 4px"></span>
|
||||||
|
<button class="btn btn-green" onclick="submitDiscuss({{ p.id }})" style="font-size:12px">▶ Envoyer</button>
|
||||||
|
<span id="discuss-spinner-{{ p.id }}" style="display:none;color:var(--amber);font-size:12px">⏳ Génération…</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function toggleDiscuss(id) {
|
||||||
|
var el = document.getElementById('discuss-' + id);
|
||||||
|
el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||||
|
}
|
||||||
|
function submitDiscuss(id) {
|
||||||
|
var msg = document.getElementById('discuss-msg-' + id).value.trim();
|
||||||
|
var temp = parseFloat(document.getElementById('discuss-temp-' + id).value);
|
||||||
|
var np = parseInt(document.getElementById('discuss-np-' + id).value);
|
||||||
|
if (!msg) { alert('Message vide'); return; }
|
||||||
|
var spin = document.getElementById('discuss-spinner-' + id);
|
||||||
|
spin.style.display = 'inline';
|
||||||
|
fetch('/sim-proposals/' + id + '/discuss', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({message: msg, temperature: temp, num_predict: np})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
spin.style.display = 'none';
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
location.reload();
|
||||||
|
}).catch(function(e){ spin.style.display = 'none'; alert('Erreur réseau : ' + e); });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyText(elementId) {
|
||||||
|
var el = document.getElementById(elementId);
|
||||||
|
navigator.clipboard.writeText(el.textContent.trim()).then(function() {
|
||||||
|
var btn = el.nextElementSibling;
|
||||||
|
if (btn) { btn.textContent = '✓ Copié'; setTimeout(function(){ btn.textContent = 'Copier'; }, 2000); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function genStressCmd() {
|
||||||
|
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||||||
|
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
|
||||||
|
var pnjRow = document.getElementById('stress_pnj_row');
|
||||||
|
var pnj = '1';
|
||||||
|
if (pnjRow.style.display !== 'none') {
|
||||||
|
pnj = document.querySelector('input[name="stress_pnj"]:checked').value;
|
||||||
|
}
|
||||||
|
var cmd = 'cd /home/ubuntu/fallout-venice/tools && python3 llm_crash_test.py --mode ' + mode + ' --rounds ' + rounds;
|
||||||
|
if (mode !== 'solo-mj' && mode !== 'solo-pnj') cmd += ' --pnj-count ' + pnj;
|
||||||
|
document.getElementById('stress_cmd_text').textContent = cmd;
|
||||||
|
document.getElementById('stress_cmd_out').style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
function togglePnjRow() {
|
||||||
|
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||||||
|
document.getElementById('stress_pnj_row').style.display =
|
||||||
|
(mode === 'solo-mj' || mode === 'solo-pnj') ? 'none' : 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStressResults() {
|
||||||
|
var area = document.getElementById('stress_results');
|
||||||
|
area.innerHTML = '<p style="color:var(--muted);padding:8px 0">Chargement…</p>';
|
||||||
|
fetch('/api/stress-test/results')
|
||||||
|
.then(function(r){ return r.json(); })
|
||||||
|
.then(function(data) {
|
||||||
|
if (data.error) { area.innerHTML = '<p style="color:var(--red);padding:8px 0">' + data.error + '</p>'; return; }
|
||||||
|
renderStressResults(area, data);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStressResults(area, data) {
|
||||||
|
var html = '<div class="results-meta">Fichier du ' + (data.file_date || '?') + '</div>';
|
||||||
|
html += '<table><tr><th>Modèle</th><th>Appels</th><th>Erreurs</th><th>Moy tok/s</th><th>Min tok/s</th><th>Max tok/s</th><th>Moy durée</th></tr>';
|
||||||
|
var models = data.models || {};
|
||||||
|
for (var model in models) {
|
||||||
|
var m = models[model];
|
||||||
|
var avgTok = parseFloat(m.avg_tok_s || 0);
|
||||||
|
var threshold = model.indexOf('14b') !== -1 ? 8 : 15;
|
||||||
|
var cls = avgTok >= threshold ? 'diag-good' : 'diag-bad';
|
||||||
|
html += '<tr><td class="' + cls + '">' + model + '</td><td>' + (m.calls||0) + '</td>';
|
||||||
|
html += '<td style="color:' + ((m.errors||0) > 0 ? 'var(--red)' : 'var(--muted)') + '">' + (m.errors||0) + '</td>';
|
||||||
|
html += '<td class="' + cls + '">' + avgTok.toFixed(1) + '</td>';
|
||||||
|
html += '<td style="color:var(--muted)">' + parseFloat(m.min_tok_s||0).toFixed(1) + '</td>';
|
||||||
|
html += '<td style="color:var(--muted)">' + parseFloat(m.max_tok_s||0).toFixed(1) + '</td>';
|
||||||
|
html += '<td style="color:var(--muted)">' + parseFloat(m.avg_duration||0).toFixed(2) + 's</td></tr>';
|
||||||
|
}
|
||||||
|
html += '</table>';
|
||||||
|
if (data.pipeline) {
|
||||||
|
var p = data.pipeline;
|
||||||
|
html += '<div style="margin-top:12px"><strong style="color:var(--amber)">Pipeline (décalé)</strong></div>';
|
||||||
|
html += '<table><tr><th>Latence moy</th><th>Min</th><th>Max</th></tr>';
|
||||||
|
html += '<tr><td>' + (p.avg_latency||'—') + '</td><td>' + (p.min_latency||'—') + '</td><td>' + (p.max_latency||'—') + '</td></tr></table>';
|
||||||
|
}
|
||||||
|
area.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
var _stressPollTimer = null;
|
||||||
|
var _stressLastLine = 0;
|
||||||
|
|
||||||
|
function runStress() {
|
||||||
|
var mode = document.querySelector('input[name="stress_mode"]:checked').value;
|
||||||
|
var rounds = document.querySelector('input[name="stress_rounds"]:checked').value;
|
||||||
|
var pnj = 2;
|
||||||
|
if (mode !== 'solo-mj' && mode !== 'solo-pnj')
|
||||||
|
pnj = parseInt(document.querySelector('input[name="stress_pnj"]:checked').value);
|
||||||
|
fetch('/api/stress-test/run', {
|
||||||
|
method: 'POST', headers: {'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify({mode:mode, rounds:parseInt(rounds), pnj_count:pnj})
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d) {
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
_stressLastLine = 0;
|
||||||
|
document.getElementById('stress_console').textContent = '';
|
||||||
|
document.getElementById('stress_console_wrap').style.display = 'block';
|
||||||
|
document.getElementById('stress_status_bar').style.display = 'block';
|
||||||
|
document.getElementById('stress_status_text').textContent = 'Test ' + mode + ' en cours (' + rounds + ' rounds)…';
|
||||||
|
document.getElementById('btn-run-stress').disabled = true;
|
||||||
|
document.getElementById('btn-stop-stress').style.display = 'inline-block';
|
||||||
|
document.getElementById('stress_results').innerHTML = '';
|
||||||
|
_stressPollTimer = setInterval(function() {
|
||||||
|
fetch('/api/stress-test/status').then(function(r){ return r.json(); }).then(function(d) {
|
||||||
|
var cons = document.getElementById('stress_console');
|
||||||
|
var lines = d.output || [];
|
||||||
|
if (lines.length > _stressLastLine) {
|
||||||
|
cons.textContent += lines.slice(_stressLastLine).join('\n') + '\n';
|
||||||
|
cons.scrollTop = cons.scrollHeight;
|
||||||
|
_stressLastLine = lines.length;
|
||||||
|
}
|
||||||
|
if (!d.running) {
|
||||||
|
clearInterval(_stressPollTimer);
|
||||||
|
document.getElementById('stress_status_text').textContent =
|
||||||
|
d.returncode === 0 ? '✓ Terminé avec succès' : '✗ Erreur (code ' + d.returncode + ')';
|
||||||
|
document.getElementById('stress_spinner').textContent = d.returncode === 0 ? '' : '⚠';
|
||||||
|
document.getElementById('btn-run-stress').disabled = false;
|
||||||
|
document.getElementById('btn-stop-stress').style.display = 'none';
|
||||||
|
if (d.returncode === 0) loadStressResults();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var _procPolls = {};
|
||||||
|
var _procLastLine = {};
|
||||||
|
|
||||||
|
function procRun(name, extra) {
|
||||||
|
fetch('/api/proc/' + name + '/run', {
|
||||||
|
method:'POST', headers:{'Content-Type':'application/json'},
|
||||||
|
body: JSON.stringify(extra)
|
||||||
|
}).then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.error) { alert(d.error); return; }
|
||||||
|
_procLastLine[name] = 0;
|
||||||
|
document.getElementById('console-' + name).textContent = '';
|
||||||
|
document.getElementById('console-' + name).style.display = 'block';
|
||||||
|
document.getElementById('btn-run-' + name).disabled = true;
|
||||||
|
document.getElementById('btn-stop-' + name).style.display = 'inline-block';
|
||||||
|
document.getElementById('dot-' + name).className = 'dot dot-green';
|
||||||
|
if (_procPolls[name]) clearInterval(_procPolls[name]);
|
||||||
|
_procPolls[name] = setInterval(function(){ procPoll(name); }, 1500);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function procPoll(name) {
|
||||||
|
fetch('/api/proc/' + name + '/status').then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
var cons = document.getElementById('console-' + name);
|
||||||
|
var lines = d.output || [];
|
||||||
|
if (lines.length > (_procLastLine[name]||0)) {
|
||||||
|
cons.textContent += lines.slice(_procLastLine[name]||0).join('\n') + '\n';
|
||||||
|
cons.scrollTop = cons.scrollHeight;
|
||||||
|
_procLastLine[name] = lines.length;
|
||||||
|
}
|
||||||
|
var iterEl = document.getElementById('iter-' + name);
|
||||||
|
if (iterEl && d.iteration) {
|
||||||
|
iterEl.textContent = 'Itération ' + d.iteration + ' / ' + (d.total === 0 ? '∞' : d.total);
|
||||||
|
iterEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
if (!d.running) {
|
||||||
|
clearInterval(_procPolls[name]);
|
||||||
|
document.getElementById('btn-run-' + name).disabled = false;
|
||||||
|
document.getElementById('btn-stop-' + name).style.display = 'none';
|
||||||
|
document.getElementById('dot-' + name).className = 'dot ' + (d.returncode === 0 ? 'dot-green' : 'dot-red');
|
||||||
|
if (iterEl) iterEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function procStop(name) {
|
||||||
|
fetch('/api/proc/' + name + '/stop', {method:'POST'}).then(function(){ procPoll(name); });
|
||||||
|
}
|
||||||
|
|
||||||
|
(function(){
|
||||||
|
['sim_enricher','lore_enricher','embed_fallout','embed_sim','mix'].forEach(function(name){
|
||||||
|
fetch('/api/proc/' + name + '/status').then(function(r){ return r.json(); }).then(function(d){
|
||||||
|
if (d.running) {
|
||||||
|
document.getElementById('btn-run-' + name).disabled = true;
|
||||||
|
document.getElementById('btn-stop-' + name).style.display = 'inline-block';
|
||||||
|
document.getElementById('dot-' + name).className = 'dot dot-green';
|
||||||
|
document.getElementById('console-' + name).style.display = 'block';
|
||||||
|
_procLastLine[name] = (d.output||[]).length;
|
||||||
|
_procPolls[name] = setInterval(function(){ procPoll(name); }, 1500);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Stress Test -->
|
||||||
|
<div class="tools-section">
|
||||||
|
<h2>⚡ Stress Test LLM</h2>
|
||||||
|
<div class="tool-row">
|
||||||
|
<label>Mode</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label><input type="radio" name="stress_mode" value="simultane" checked onchange="togglePnjRow()"><span>simultane</span></label>
|
||||||
|
<label><input type="radio" name="stress_mode" value="decale" onchange="togglePnjRow()"><span>decale</span></label>
|
||||||
|
<label><input type="radio" name="stress_mode" value="solo-mj" onchange="togglePnjRow()"><span>solo-mj</span></label>
|
||||||
|
<label><input type="radio" name="stress_mode" value="solo-pnj" onchange="togglePnjRow()"><span>solo-pnj</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-row">
|
||||||
|
<label>Rounds</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label><input type="radio" name="stress_rounds" value="3" checked><span>3</span></label>
|
||||||
|
<label><input type="radio" name="stress_rounds" value="5"><span>5</span></label>
|
||||||
|
<label><input type="radio" name="stress_rounds" value="10"><span>10</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-row" id="stress_pnj_row">
|
||||||
|
<label>PNJ count</label>
|
||||||
|
<div class="radio-group">
|
||||||
|
<label><input type="radio" name="stress_pnj" value="1" checked><span>1</span></label>
|
||||||
|
<label><input type="radio" name="stress_pnj" value="2"><span>2</span></label>
|
||||||
|
<label><input type="radio" name="stress_pnj" value="3"><span>3</span></label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="tool-row" style="gap:8px;flex-wrap:wrap">
|
||||||
|
<button class="btn btn-green" id="btn-run-stress" onclick="runStress()">▶ Exécuter maintenant</button>
|
||||||
|
<button class="btn btn-amber" onclick="genStressCmd()">Voir commande SSH</button>
|
||||||
|
<button class="btn" onclick="loadStressResults()">Charger derniers résultats</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-stress" style="display:none" onclick="clearInterval(_stressPollTimer);document.getElementById('btn-run-stress').disabled=false;this.style.display='none'">■ Arrêter le suivi</button>
|
||||||
|
</div>
|
||||||
|
<div id="stress_status_bar" style="display:none;margin-top:8px;padding:6px 10px;background:#0d1117;border:1px solid var(--border);border-radius:4px;font-size:13px">
|
||||||
|
<span id="stress_status_text" style="color:var(--amber)">En cours…</span>
|
||||||
|
<span id="stress_spinner" style="margin-left:8px">⏳</span>
|
||||||
|
</div>
|
||||||
|
<div class="cmd-output" id="stress_cmd_out" style="display:none">
|
||||||
|
<div class="code-block"><pre id="stress_cmd_text"></pre><button class="copy-btn" onclick="copyText('stress_cmd_text')">Copier</button></div>
|
||||||
|
</div>
|
||||||
|
<div id="stress_console_wrap" style="display:none;margin-top:10px">
|
||||||
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||||
|
<span style="font-size:12px;font-weight:600;color:var(--dim)">SORTIE EN DIRECT</span>
|
||||||
|
<button class="btn" style="padding:2px 8px;font-size:11px" onclick="document.getElementById('stress_console').innerHTML=''">Effacer</button>
|
||||||
|
</div>
|
||||||
|
<pre id="stress_console" style="background:#020408;border:1px solid var(--border);padding:10px;max-height:320px;overflow-y:auto;font-size:12px;color:#a8d5a2;white-space:pre-wrap;border-radius:4px"></pre>
|
||||||
|
</div>
|
||||||
|
<div class="results-area" id="stress_results"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enrichissement LLM -->
|
||||||
|
<div class="tools-section">
|
||||||
|
<h2>🧠 Enrichissement LLM</h2>
|
||||||
|
<div id="enrich-cards" style="display:flex;flex-direction:column;gap:12px">
|
||||||
|
|
||||||
|
<div class="enrich-card" id="card-sim_enricher">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="flex:1;font-weight:600;color:var(--amber)">🎲 Règles sim → sim_proposals</span>
|
||||||
|
<select id="sim_enricher_type" style="width:148px">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="nouveau_mode">nouveau_mode</option>
|
||||||
|
<option value="evenement_special">evenement_special</option>
|
||||||
|
<option value="regle_maison">regle_maison</option>
|
||||||
|
<option value="scenario">scenario</option>
|
||||||
|
</select>
|
||||||
|
<select id="count-sim_enricher" style="width:62px">
|
||||||
|
<option value="1">1×</option><option value="5">5×</option>
|
||||||
|
<option value="10" selected>10×</option><option value="50">50×</option>
|
||||||
|
<option value="100">100×</option><option value="0">∞</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-green" id="btn-run-sim_enricher" onclick="procRun('sim_enricher',{type:document.getElementById('sim_enricher_type').value,count:parseInt(document.getElementById('count-sim_enricher').value)})">▶ Lancer</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-sim_enricher" style="display:none" onclick="procStop('sim_enricher')">■ Stop</button>
|
||||||
|
<span class="dot dot-red" id="dot-sim_enricher" style="margin-left:4px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iter-sim_enricher" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
|
||||||
|
<div id="console-sim_enricher" class="mini-console" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="enrich-card" id="card-lore_enricher">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="flex:1;font-weight:600;color:var(--amber)">📜 Lore → lore_proposals</span>
|
||||||
|
<select id="lore_enricher_faction" style="width:148px">
|
||||||
|
<option value="all">all</option>
|
||||||
|
<option value="union">union</option><option value="cda">cda</option>
|
||||||
|
<option value="ecumeurs">ecumeurs</option><option value="grand_krewe">grand_krewe</option>
|
||||||
|
<option value="dynaste_oak">dynaste_oak</option><option value="regie">regie</option>
|
||||||
|
<option value="syndicat_capitole">syndicat_capitole</option><option value="consortium">consortium</option>
|
||||||
|
</select>
|
||||||
|
<select id="count-lore_enricher" style="width:62px">
|
||||||
|
<option value="1">1×</option><option value="5">5×</option>
|
||||||
|
<option value="10" selected>10×</option><option value="50">50×</option>
|
||||||
|
<option value="100">100×</option><option value="0">∞</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-green" id="btn-run-lore_enricher" onclick="procRun('lore_enricher',{faction:document.getElementById('lore_enricher_faction').value,count:parseInt(document.getElementById('count-lore_enricher').value)})">▶ Lancer</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-lore_enricher" style="display:none" onclick="procStop('lore_enricher')">■ Stop</button>
|
||||||
|
<span class="dot dot-red" id="dot-lore_enricher" style="margin-left:4px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iter-lore_enricher" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
|
||||||
|
<div id="console-lore_enricher" class="mini-console" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="enrich-card" id="card-mix" style="border-color:var(--green)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="flex:1;font-weight:600;color:var(--green)">🔄 Mode MIX — sim + lore en alternance</span>
|
||||||
|
<select id="count-mix" style="width:80px">
|
||||||
|
<option value="5">5× chaque</option><option value="10" selected>10× chaque</option>
|
||||||
|
<option value="50">50× chaque</option><option value="0">∞ chaque</option>
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-green" id="btn-run-mix" onclick="procRun('mix',{count:parseInt(document.getElementById('count-mix').value)})">▶ Lancer</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-mix" style="display:none" onclick="procStop('mix')">■ Stop</button>
|
||||||
|
<span class="dot dot-red" id="dot-mix" style="margin-left:4px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iter-mix" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
|
||||||
|
<div id="console-mix" class="mini-console" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Re-embedding -->
|
||||||
|
<div class="tools-section" style="opacity:0.82">
|
||||||
|
<h2 style="color:var(--dim)">🔁 Re-embedding <small style="font-size:11px;font-weight:400;margin-left:8px">Opération rare — seulement si nouveaux PDFs ou configs modifiées</small></h2>
|
||||||
|
<div style="display:flex;flex-direction:column;gap:10px">
|
||||||
|
|
||||||
|
<div class="enrich-card" id="card-embed_fallout" style="border-color:var(--border)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="flex:1;font-weight:600;color:var(--dim)">PDFs Fallout → fallout_lore</span>
|
||||||
|
<select id="count-embed_fallout" style="width:62px"><option value="1" selected>1×</option><option value="5">5×</option></select>
|
||||||
|
<button class="btn btn-green" id="btn-run-embed_fallout" onclick="procRun('embed_fallout',{count:parseInt(document.getElementById('count-embed_fallout').value)})">▶ Lancer</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-embed_fallout" style="display:none" onclick="procStop('embed_fallout')">■ Stop</button>
|
||||||
|
<span class="dot dot-red" id="dot-embed_fallout" style="margin-left:4px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iter-embed_fallout" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
|
||||||
|
<div id="console-embed_fallout" class="mini-console" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="enrich-card" id="card-embed_sim" style="border-color:var(--border)">
|
||||||
|
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||||
|
<span style="flex:1;font-weight:600;color:var(--dim)">Configs sim → fallout_sim_rules</span>
|
||||||
|
<select id="count-embed_sim" style="width:62px"><option value="1" selected>1×</option><option value="5">5×</option></select>
|
||||||
|
<button class="btn btn-green" id="btn-run-embed_sim" onclick="procRun('embed_sim',{count:parseInt(document.getElementById('count-embed_sim').value)})">▶ Lancer</button>
|
||||||
|
<button class="btn btn-red" id="btn-stop-embed_sim" style="display:none" onclick="procStop('embed_sim')">■ Stop</button>
|
||||||
|
<span class="dot dot-red" id="dot-embed_sim" style="margin-left:4px"></span>
|
||||||
|
</div>
|
||||||
|
<div id="iter-embed_sim" style="font-size:11px;color:var(--dim);margin-top:4px;display:none"></div>
|
||||||
|
<div id="console-embed_sim" class="mini-console" style="display:none"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
services:
|
||||||
|
fallout-visu:
|
||||||
|
image: python:3.11-alpine
|
||||||
|
container_name: fallout-visu
|
||||||
|
restart: unless-stopped
|
||||||
|
working_dir: /app
|
||||||
|
volumes:
|
||||||
|
- /opt/coyote/fallout/src/dashboard:/app/dashboard:ro
|
||||||
|
- /home/ubuntu/.ssh/vigile_backup:/ssh/vigile.key:ro
|
||||||
|
- /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:
|
||||||
|
- 'host.docker.internal:172.20.0.1'
|
||||||
|
environment:
|
||||||
|
- PYTHONUNBUFFERED=1
|
||||||
|
- LETSENCRYPT_HOST=fallout.coyoteos.ovh
|
||||||
|
- VIRTUAL_HOST=fallout.coyoteos.ovh
|
||||||
|
- DB_HOST=${DB_HOST}
|
||||||
|
- DB_PORT=${DB_PORT}
|
||||||
|
- DB_NAME=${DB_NAME}
|
||||||
|
- DB_USER=${DB_USER}
|
||||||
|
- DB_PASS=${DB_PASS}
|
||||||
|
- SIM_CONFIGS_DIR=/app/src/config
|
||||||
|
- OLLAMA_URL=${OLLAMA_URL}
|
||||||
|
- MODEL_MJ=${MODEL_MJ}
|
||||||
|
- MODEL_PNJ=${MODEL_PNJ}
|
||||||
|
- CHROMA_URL=http://chromadb:8000
|
||||||
|
|
||||||
|
networks:
|
||||||
|
proxy-nw:
|
||||||
|
external: true
|
||||||
@@ -0,0 +1,328 @@
|
|||||||
|
"""
|
||||||
|
embed_fallout.py — Ingestion PDFs Fallout JdR → ChromaDB (v3)
|
||||||
|
|
||||||
|
Fusion :
|
||||||
|
- Chunking structurel par paragraphes (fallout_ingest_v2, 15/06)
|
||||||
|
- Routing multi-collections selon README priorités (16/06)
|
||||||
|
|
||||||
|
Collections :
|
||||||
|
fallout_lore_canon ← lore_post_guerre/ (canonique, priorité max)
|
||||||
|
fallout_regles ← regles/core|supplements|fiches_de_jeu/
|
||||||
|
fallout_lore_contexte ← lore_pre_guerre/ + aventures/ + ambiance/radio/
|
||||||
|
|
||||||
|
Chemins container : PDF_DIR=/pdfs/jdr STATE=/pdfs/fallout_embed_state.json
|
||||||
|
Env vars : CHROMA_URL, OLLAMA_URL
|
||||||
|
"""
|
||||||
|
import json, os, re, subprocess, time, urllib.request
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
CHROMA_URL = os.getenv("CHROMA_URL", "http://chromadb:8000")
|
||||||
|
CHROMA_BASE = CHROMA_URL + "/api/v2/tenants/default_tenant/databases/default_database"
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://172.18.0.1:11434")
|
||||||
|
EMBED_MODEL = "nomic-embed-text"
|
||||||
|
|
||||||
|
PDF_DIR = Path(os.getenv("PDF_DIR", "/pdfs/jdr"))
|
||||||
|
STATE_FILE = Path(os.getenv("STATE_FILE", "/pdfs/fallout_embed_state.json"))
|
||||||
|
BATCH_SIZE = 50
|
||||||
|
|
||||||
|
ROUTING = [
|
||||||
|
("lore_post_guerre", "fallout_lore_canon", "lore_post_guerre", 450, 80, True),
|
||||||
|
("regles/core", "fallout_regles", "regles_core", 350, 60, False),
|
||||||
|
("regles/supplements", "fallout_regles", "regles_supplement", 350, 60, False),
|
||||||
|
("regles/fiches_de_jeu", "fallout_regles", "fiches_jeu", 350, 60, False),
|
||||||
|
("lore_pre_guerre", "fallout_lore_contexte", "lore_pre_guerre", 450, 80, False),
|
||||||
|
("aventures", "fallout_lore_contexte", "aventures", 450, 80, False),
|
||||||
|
("ambiance/radio", "fallout_lore_contexte", "ambiance_radio", 200, 40, False),
|
||||||
|
]
|
||||||
|
|
||||||
|
SKIP_DIRS = {
|
||||||
|
"assets_graphiques", "battlemaps", "fonts", "photoshop_psd",
|
||||||
|
"fonds_de_page", "images_ref", "croquis", "maps", "logos",
|
||||||
|
"couvertures", ".claude", "archive",
|
||||||
|
}
|
||||||
|
SKIP_SLUGS = {"archive__meta_data__compressed"}
|
||||||
|
MIN_WORDS = 40
|
||||||
|
|
||||||
|
|
||||||
|
# ── State ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def load_state():
|
||||||
|
try:
|
||||||
|
return json.loads(STATE_FILE.read_text())
|
||||||
|
except Exception:
|
||||||
|
return {"done": [], "failed": []}
|
||||||
|
|
||||||
|
def save_state(s):
|
||||||
|
STATE_FILE.write_text(json.dumps(s, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
# ── ChromaDB ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def _http(method, url, data=None, timeout=30):
|
||||||
|
body = json.dumps(data).encode() if data is not None else None
|
||||||
|
req = urllib.request.Request(url, data=body, method=method,
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def get_or_create_collection(name):
|
||||||
|
try:
|
||||||
|
r = _http("POST", f"{CHROMA_BASE}/collections",
|
||||||
|
{"name": name, "metadata": {"hnsw:space": "cosine"}})
|
||||||
|
return r["id"]
|
||||||
|
except urllib.error.HTTPError as e:
|
||||||
|
if e.code == 409:
|
||||||
|
cols = _http("GET", f"{CHROMA_BASE}/collections")
|
||||||
|
return next(c["id"] for c in cols if c["name"] == name)
|
||||||
|
raise
|
||||||
|
|
||||||
|
def upsert_batch(col_id, ids, embeddings, documents, metadatas):
|
||||||
|
_http("POST", f"{CHROMA_BASE}/collections/{col_id}/upsert", {
|
||||||
|
"ids": ids, "embeddings": embeddings,
|
||||||
|
"documents": documents, "metadatas": metadatas,
|
||||||
|
}, timeout=60)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Embeddings ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def embed(text):
|
||||||
|
safe = " ".join(text.split()[:280])
|
||||||
|
r = _http("POST", f"{OLLAMA_URL}/api/embeddings",
|
||||||
|
{"model": EMBED_MODEL, "prompt": safe}, timeout=60)
|
||||||
|
return r["embedding"]
|
||||||
|
|
||||||
|
|
||||||
|
# ── PDF extraction ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def extract_text(pdf_path):
|
||||||
|
"""pdftotext -layout en priorité, fallback pypdf, fallback fitz."""
|
||||||
|
text = ""
|
||||||
|
try:
|
||||||
|
r = subprocess.run(
|
||||||
|
["pdftotext", "-layout", str(pdf_path), "-"],
|
||||||
|
capture_output=True, timeout=180,
|
||||||
|
)
|
||||||
|
if r.returncode == 0:
|
||||||
|
text = r.stdout.decode("utf-8", errors="replace")
|
||||||
|
except (subprocess.TimeoutExpired, FileNotFoundError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
if len(text.strip()) < 200:
|
||||||
|
try:
|
||||||
|
from pypdf import PdfReader
|
||||||
|
reader = PdfReader(str(pdf_path))
|
||||||
|
text = "\n".join(p.extract_text() or "" for p in reader.pages)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
import fitz
|
||||||
|
doc = fitz.open(str(pdf_path))
|
||||||
|
text = "\n".join(p.get_text() for p in doc)
|
||||||
|
doc.close()
|
||||||
|
except Exception as e:
|
||||||
|
raise RuntimeError(f"Aucune méthode d'extraction n'a fonctionné : {e}")
|
||||||
|
|
||||||
|
return text.strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Chunking structurel ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def is_section_header(line):
|
||||||
|
l = line.strip()
|
||||||
|
if not l or len(l) > 80:
|
||||||
|
return False
|
||||||
|
if l.isupper() and len(l.split()) <= 8:
|
||||||
|
return True
|
||||||
|
if re.match(r'^(CHAPITRE|CHAPTER|PARTIE|PART|SECTION|\d+[\.\)])\s+\S', l, re.I):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
def chunk_structural(text, chunk_words=450, overlap_words=80):
|
||||||
|
raw_blocks = re.split(r'\n{2,}', text)
|
||||||
|
blocks = []
|
||||||
|
for b in raw_blocks:
|
||||||
|
b = b.strip()
|
||||||
|
if not b:
|
||||||
|
continue
|
||||||
|
if len(b.split()) > 800:
|
||||||
|
sub = [s.strip() for s in b.split('\n') if s.strip()]
|
||||||
|
blocks.extend(sub)
|
||||||
|
else:
|
||||||
|
blocks.append(b)
|
||||||
|
|
||||||
|
chunks = []
|
||||||
|
current_blocks, current_words = [], 0
|
||||||
|
|
||||||
|
for block in blocks:
|
||||||
|
bwords = len(block.split())
|
||||||
|
if bwords < MIN_WORDS and not is_section_header(block):
|
||||||
|
current_blocks.append(block)
|
||||||
|
current_words += bwords
|
||||||
|
continue
|
||||||
|
|
||||||
|
if current_words >= chunk_words:
|
||||||
|
chunk = "\n\n".join(current_blocks).strip()
|
||||||
|
if len(chunk.split()) >= MIN_WORDS:
|
||||||
|
chunks.append(chunk)
|
||||||
|
overlap_b, overlap_w = [], 0
|
||||||
|
for ob in reversed(current_blocks):
|
||||||
|
ow = len(ob.split())
|
||||||
|
if overlap_w + ow <= overlap_words:
|
||||||
|
overlap_b.insert(0, ob)
|
||||||
|
overlap_w += ow
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
current_blocks = overlap_b + [block]
|
||||||
|
current_words = overlap_w + bwords
|
||||||
|
else:
|
||||||
|
current_blocks.append(block)
|
||||||
|
current_words += bwords
|
||||||
|
|
||||||
|
if current_blocks:
|
||||||
|
chunk = "\n\n".join(current_blocks).strip()
|
||||||
|
if len(chunk.split()) >= MIN_WORDS:
|
||||||
|
chunks.append(chunk)
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
|
||||||
|
|
||||||
|
# ── Routing & métadonnées ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def route_pdf(rel_path):
|
||||||
|
rel = rel_path.replace("\\", "/")
|
||||||
|
for part in rel.split("/")[:-1]:
|
||||||
|
if part.lower() in SKIP_DIRS:
|
||||||
|
return None
|
||||||
|
rel_low = rel.lower()
|
||||||
|
for prefix, col, cat, cw, ov, canon in ROUTING:
|
||||||
|
if rel_low.startswith(prefix.lower()):
|
||||||
|
return col, cat, cw, ov, canon
|
||||||
|
return None
|
||||||
|
|
||||||
|
def detect_langue(filename):
|
||||||
|
n = filename.lower()
|
||||||
|
if re.search(r'(_fr[_\-\.])|(_fr$)|(manuel.*officiel)|(bestiaire)|(armes)|'
|
||||||
|
r'(special.*comp)|(regles_maison)|(frequence)|(pizza)', n):
|
||||||
|
return "fr"
|
||||||
|
if re.search(r'(_en[_\-\.])|(_en$)|(quickstart)|(booklet)|(toolkit)|(settlers)|'
|
||||||
|
r'(enclave)|(rust_devil)|(winter_of_atom)|(cascadia)|(hunted)|'
|
||||||
|
r'(showdown)|(fully_operational)|(last_boat)', n):
|
||||||
|
return "en"
|
||||||
|
return "fr"
|
||||||
|
|
||||||
|
def detect_version(filename):
|
||||||
|
m = re.search(r'v(\d+[\.\d]*)', filename.lower())
|
||||||
|
return m.group(1) if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=== embed_fallout v3 — chunking structurel + routing multi-collections ===\n")
|
||||||
|
|
||||||
|
all_pdfs = []
|
||||||
|
for root, dirs, files in os.walk(PDF_DIR):
|
||||||
|
dirs[:] = [d for d in dirs if d.lower() not in SKIP_DIRS]
|
||||||
|
for f in sorted(files):
|
||||||
|
if f.lower().endswith(".pdf"):
|
||||||
|
all_pdfs.append(Path(root) / f)
|
||||||
|
all_pdfs.sort()
|
||||||
|
|
||||||
|
to_process, skipped = [], []
|
||||||
|
for pdf_path in all_pdfs:
|
||||||
|
rel = str(pdf_path.relative_to(PDF_DIR))
|
||||||
|
slug = rel.replace(os.sep, "__").replace(" ", "_").lower()[:-4]
|
||||||
|
r = route_pdf(rel)
|
||||||
|
if r is None or slug in SKIP_SLUGS:
|
||||||
|
skipped.append(rel)
|
||||||
|
continue
|
||||||
|
col, cat, cw, ov, canon = r
|
||||||
|
to_process.append({
|
||||||
|
"path": pdf_path, "rel": rel, "slug": slug,
|
||||||
|
"col": col, "cw": cw, "ov": ov,
|
||||||
|
"meta": {
|
||||||
|
"source": pdf_path.name,
|
||||||
|
"categorie": cat,
|
||||||
|
"langue": detect_langue(pdf_path.name),
|
||||||
|
"canonique": "true" if canon else "false",
|
||||||
|
"version": detect_version(pdf_path.name),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
print(f"PDFs trouvés : {len(all_pdfs)}")
|
||||||
|
print(f" A ingérer : {len(to_process)}")
|
||||||
|
print(f" Ignorés : {len(skipped)}")
|
||||||
|
for s in skipped:
|
||||||
|
print(f" [SKIP] {s}")
|
||||||
|
|
||||||
|
col_ids = {}
|
||||||
|
print()
|
||||||
|
for name in sorted(set(p["col"] for p in to_process)):
|
||||||
|
col_ids[name] = get_or_create_collection(name)
|
||||||
|
print(f" Collection '{name}' : {col_ids[name][:8]}...")
|
||||||
|
|
||||||
|
state = load_state()
|
||||||
|
# Reset state pour repartir propre (collections wipées)
|
||||||
|
state = {"done": [], "failed": []}
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
total_chunks = 0
|
||||||
|
|
||||||
|
for item in to_process:
|
||||||
|
slug = item["slug"]
|
||||||
|
if slug in state["done"]:
|
||||||
|
print(f"[résumé] {item['rel']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
print(f"\n[->] {item['rel']}")
|
||||||
|
print(f" col={item['col']} cat={item['meta']['categorie']}"
|
||||||
|
f" lang={item['meta']['langue']} canon={item['meta']['canonique']}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
text = extract_text(item["path"])
|
||||||
|
print(f" {len(text)} chars extraits")
|
||||||
|
|
||||||
|
chunks = chunk_structural(text, item["cw"], item["ov"])
|
||||||
|
print(f" {len(chunks)} chunks (~{item['cw']}w cible, overlap {item['ov']}w)")
|
||||||
|
|
||||||
|
ids, embeddings, documents, metadatas = [], [], [], []
|
||||||
|
for i, chunk in enumerate(chunks):
|
||||||
|
t0 = time.time()
|
||||||
|
emb = embed(chunk)
|
||||||
|
wc = len(chunk.split())
|
||||||
|
ids.append(f"{slug}__{i:04d}")
|
||||||
|
embeddings.append(emb)
|
||||||
|
documents.append(chunk)
|
||||||
|
metadatas.append({**item["meta"],
|
||||||
|
"chunk": i, "total_chunks": len(chunks),
|
||||||
|
"word_count": wc})
|
||||||
|
print(f" chunk {i+1}/{len(chunks)} ({wc}w) {time.time()-t0:.1f}s")
|
||||||
|
|
||||||
|
if len(ids) >= BATCH_SIZE:
|
||||||
|
upsert_batch(col_ids[item["col"]], ids, embeddings, documents, metadatas)
|
||||||
|
ids, embeddings, documents, metadatas = [], [], [], []
|
||||||
|
|
||||||
|
if ids:
|
||||||
|
upsert_batch(col_ids[item["col"]], ids, embeddings, documents, metadatas)
|
||||||
|
|
||||||
|
total_chunks += len(chunks)
|
||||||
|
state["done"].append(slug)
|
||||||
|
state["failed"] = [f for f in state["failed"] if f.get("slug") != slug]
|
||||||
|
print(f" OK — {len(chunks)} chunks")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
state["failed"].append({"slug": slug, "error": str(e)})
|
||||||
|
print(f" ERREUR : {e}")
|
||||||
|
|
||||||
|
save_state(state)
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"TERMINÉ — {len(state['done'])}/{len(to_process)} PDFs")
|
||||||
|
print(f"Chunks totaux : {total_chunks}")
|
||||||
|
if state.get("failed"):
|
||||||
|
print("Échecs :")
|
||||||
|
for f in state["failed"]:
|
||||||
|
print(f" - {f['slug']}: {f['error']}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
pymupdf
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{
|
||||||
|
"test_type": "capacity",
|
||||||
|
"mode": "capacity_test",
|
||||||
|
"timestamp": "2026-06-16T10:19:26.345074",
|
||||||
|
"file_date": "16/06/2026 10:19:26",
|
||||||
|
"model_mj": "qwen2.5:14b",
|
||||||
|
"model_pnj": "qwen2.5:7b",
|
||||||
|
"stats": [
|
||||||
|
{
|
||||||
|
"n_players": 1,
|
||||||
|
"pnj_avg_wall_s": 4.8,
|
||||||
|
"pnj_avg_p50_s": 4.8,
|
||||||
|
"pnj_avg_p95_s": 4.8,
|
||||||
|
"mj_avg_elapsed_s": 29.9,
|
||||||
|
"tick_total_avg_s": 34.6,
|
||||||
|
"actions_per_hour": 103,
|
||||||
|
"errors": 0,
|
||||||
|
"viable_realtime_1h": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n_players": 5,
|
||||||
|
"pnj_avg_wall_s": 27.5,
|
||||||
|
"pnj_avg_p50_s": 17.2,
|
||||||
|
"pnj_avg_p95_s": 27.5,
|
||||||
|
"mj_avg_elapsed_s": 29.8,
|
||||||
|
"tick_total_avg_s": 57.2,
|
||||||
|
"actions_per_hour": 62,
|
||||||
|
"errors": 0,
|
||||||
|
"viable_realtime_1h": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n_players": 10,
|
||||||
|
"pnj_avg_wall_s": 47.4,
|
||||||
|
"pnj_avg_p50_s": 26.4,
|
||||||
|
"pnj_avg_p95_s": 47.4,
|
||||||
|
"mj_avg_elapsed_s": 29.9,
|
||||||
|
"tick_total_avg_s": 77.3,
|
||||||
|
"actions_per_hour": 46,
|
||||||
|
"errors": 0,
|
||||||
|
"viable_realtime_1h": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"n_players": 50,
|
||||||
|
"pnj_avg_wall_s": 180.2,
|
||||||
|
"pnj_avg_p50_s": 91.6,
|
||||||
|
"pnj_avg_p95_s": 172.2,
|
||||||
|
"mj_avg_elapsed_s": 31.9,
|
||||||
|
"tick_total_avg_s": 212.1,
|
||||||
|
"actions_per_hour": 16,
|
||||||
|
"errors": 8,
|
||||||
|
"viable_realtime_1h": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+130
-48
@@ -1,29 +1,24 @@
|
|||||||
{
|
{
|
||||||
"_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.",
|
"_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.",
|
||||||
|
|
||||||
"session": {
|
"session": {
|
||||||
"session_id": 1,
|
"session_id": 1,
|
||||||
"name": "Session 001 — Louisiane post-apo",
|
"name": "Session 001 — Louisiane post-apo",
|
||||||
"seed_global": 42
|
"seed_global": 42
|
||||||
},
|
},
|
||||||
|
|
||||||
"mode": "pacifiste",
|
"mode": "pacifiste",
|
||||||
|
|
||||||
"tick": {
|
"tick": {
|
||||||
"tick_sleep_sec": 60,
|
"tick_sleep_sec": 30,
|
||||||
"ticks_per_day": 24,
|
"ticks_per_day": 24,
|
||||||
"day_start_tick": 6,
|
"day_start_tick": 6,
|
||||||
"night_start_tick": 20,
|
"night_start_tick": 20,
|
||||||
"llm_tick_interval": 6
|
"llm_tick_interval": 6
|
||||||
},
|
},
|
||||||
|
|
||||||
"survival": {
|
"survival": {
|
||||||
"_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
|
"_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
|
||||||
"drain_speed": 1.0,
|
"drain_speed": 1.0,
|
||||||
"fatigue_hp_per_2pts": 2,
|
"fatigue_hp_per_2pts": 2,
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"economy": {
|
"economy": {
|
||||||
"_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)",
|
"_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)",
|
||||||
"caps_global_multiplier": 1.0,
|
"caps_global_multiplier": 1.0,
|
||||||
@@ -31,14 +26,12 @@
|
|||||||
"loot_caps_max": 25,
|
"loot_caps_max": 25,
|
||||||
"trade_price_variance": 0.2
|
"trade_price_variance": 0.2
|
||||||
},
|
},
|
||||||
|
|
||||||
"encounter": {
|
"encounter": {
|
||||||
"_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone",
|
"_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone",
|
||||||
"global_rate_multiplier": 1.0,
|
"global_rate_multiplier": 1.0,
|
||||||
"combat_lethality": 1.0,
|
"combat_lethality": 1.0,
|
||||||
"enabled": true
|
"enabled": true
|
||||||
},
|
},
|
||||||
|
|
||||||
"world": {
|
"world": {
|
||||||
"_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
|
"_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
|
||||||
"pop_count_by_zone": {
|
"pop_count_by_zone": {
|
||||||
@@ -57,77 +50,166 @@
|
|||||||
"food_drain_per_pop": 0.003,
|
"food_drain_per_pop": 0.003,
|
||||||
"security_day_regen": 0.2,
|
"security_day_regen": 0.2,
|
||||||
"security_night_drain": 0.3,
|
"security_night_drain": 0.3,
|
||||||
"conflict_security_range": [-0.5, 0.2]
|
"conflict_security_range": [
|
||||||
|
-0.5,
|
||||||
|
0.2
|
||||||
|
]
|
||||||
},
|
},
|
||||||
|
|
||||||
"faction_relations": {
|
"faction_relations": {
|
||||||
"initial_volatility": 0.05,
|
"initial_volatility": 0.05,
|
||||||
"drift_per_llm_tick": 1
|
"drift_per_llm_tick": 1
|
||||||
},
|
},
|
||||||
|
|
||||||
"llm": {
|
"llm": {
|
||||||
"enabled": false,
|
"enabled": false,
|
||||||
"model_mj": "qwen2.5:14b",
|
"model_mj": "qwen2.5:14b",
|
||||||
"model_pnj": "qwen2.5:7b",
|
"model_pnj": "qwen2.5:7b",
|
||||||
"ollama_url": "http://localhost:11434",
|
"ollama_url": "http://localhost:11434",
|
||||||
"chroma_url": "http://localhost:8000",
|
"chroma_url": "http://localhost:8000",
|
||||||
"chroma_collection": "fallout_vst"
|
"chroma_collection": "fallout_lore"
|
||||||
},
|
},
|
||||||
|
|
||||||
"modes": {
|
"modes": {
|
||||||
"pacifiste": {
|
"pacifiste": {
|
||||||
"_comment": "Monde presque sûr, idéal pour tester la sim sans mort en cascade",
|
"_comment": "Monde presque sûr, idéal pour tester la sim sans mort en cascade",
|
||||||
"encounter": { "global_rate_multiplier": 0.4, "combat_lethality": 0.5 },
|
"encounter": {
|
||||||
"survival": { "drain_speed": 0.7 },
|
"global_rate_multiplier": 0.4,
|
||||||
"world": { "security_day_regen": 0.4, "security_night_drain": 0.1, "conflict_security_range": [-0.1, 0.3] },
|
"combat_lethality": 0.5
|
||||||
"faction_relations": { "initial_volatility": 0.02, "drift_per_llm_tick": 1 }
|
},
|
||||||
|
"survival": {
|
||||||
|
"drain_speed": 0.7
|
||||||
|
},
|
||||||
|
"world": {
|
||||||
|
"security_day_regen": 0.4,
|
||||||
|
"security_night_drain": 0.1,
|
||||||
|
"conflict_security_range": [
|
||||||
|
-0.1,
|
||||||
|
0.3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faction_relations": {
|
||||||
|
"initial_volatility": 0.02,
|
||||||
|
"drift_per_llm_tick": 1
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"politique": {
|
"politique": {
|
||||||
"_comment": "Intrigues factions, peu de violence directe, mais relations volatiles",
|
"_comment": "Intrigues factions, peu de violence directe, mais relations volatiles",
|
||||||
"encounter": { "global_rate_multiplier": 0.6, "combat_lethality": 0.7 },
|
"encounter": {
|
||||||
"survival": { "drain_speed": 1.0 },
|
"global_rate_multiplier": 0.6,
|
||||||
"world": { "security_day_regen": 0.2, "security_night_drain": 0.3, "conflict_security_range": [-0.8, 0.1] },
|
"combat_lethality": 0.7
|
||||||
"faction_relations": { "initial_volatility": 0.3, "drift_per_llm_tick": 5 }
|
},
|
||||||
|
"survival": {
|
||||||
|
"drain_speed": 1.0
|
||||||
|
},
|
||||||
|
"world": {
|
||||||
|
"security_day_regen": 0.2,
|
||||||
|
"security_night_drain": 0.3,
|
||||||
|
"conflict_security_range": [
|
||||||
|
-0.8,
|
||||||
|
0.1
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faction_relations": {
|
||||||
|
"initial_volatility": 0.3,
|
||||||
|
"drift_per_llm_tick": 5
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"guerre_commerciale": {
|
"guerre_commerciale": {
|
||||||
"_comment": "Blocus, routes coupées, prix instables. Survie difficile mais pas de front de guerre.",
|
"_comment": "Blocus, routes coupées, prix instables. Survie difficile mais pas de front de guerre.",
|
||||||
"encounter": { "global_rate_multiplier": 1.2, "combat_lethality": 0.9 },
|
"encounter": {
|
||||||
"survival": { "drain_speed": 1.5 },
|
"global_rate_multiplier": 1.2,
|
||||||
"economy": { "caps_global_multiplier": 1.5, "trade_price_variance": 0.5 },
|
"combat_lethality": 0.9
|
||||||
"world": {
|
|
||||||
"pop_count_by_zone": { "nola_cbd": 15, "pearl_river": 6, "donaldsonville": 8 },
|
|
||||||
"security_day_regen": 0.1, "security_night_drain": 0.5, "conflict_security_range": [-1.5, 0.0]
|
|
||||||
},
|
},
|
||||||
"faction_relations": { "initial_volatility": 0.4, "drift_per_llm_tick": 8 }
|
"survival": {
|
||||||
|
"drain_speed": 1.5
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"caps_global_multiplier": 1.5,
|
||||||
|
"trade_price_variance": 0.5
|
||||||
},
|
},
|
||||||
|
|
||||||
"guerre": {
|
|
||||||
"_comment": "Front de guerre actif. Rencontres fréquentes, ressources rares, morts quotidiennes.",
|
|
||||||
"encounter": { "global_rate_multiplier": 2.0, "combat_lethality": 1.5 },
|
|
||||||
"survival": { "drain_speed": 1.8, "fatigue_hp_per_2pts": 3 },
|
|
||||||
"economy": { "caps_global_multiplier": 0.5, "loot_caps_max": 40, "trade_price_variance": 0.8 },
|
|
||||||
"world": {
|
"world": {
|
||||||
"pop_count_by_zone": {
|
"pop_count_by_zone": {
|
||||||
"independance": 80, "nola_vieux_carre": 50, "baton_rouge": 40,
|
"nola_cbd": 15,
|
||||||
"laplace": 30, "nola_fleuve": 20, "la_paroisse": 15,
|
"pearl_river": 6,
|
||||||
"nola_cbd": 8, "oak_plantation": 10, "donaldsonville": 6, "pearl_river": 4
|
"donaldsonville": 8
|
||||||
},
|
},
|
||||||
"security_day_regen": 0.05, "security_night_drain": 0.8, "conflict_security_range": [-2.0, -0.3]
|
"security_day_regen": 0.1,
|
||||||
|
"security_night_drain": 0.5,
|
||||||
|
"conflict_security_range": [
|
||||||
|
-1.5,
|
||||||
|
0.0
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"faction_relations": { "initial_volatility": 0.8, "drift_per_llm_tick": 15 }
|
"faction_relations": {
|
||||||
|
"initial_volatility": 0.4,
|
||||||
|
"drift_per_llm_tick": 8
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"guerre": {
|
||||||
|
"_comment": "Front de guerre actif. Rencontres fréquentes, ressources rares, morts quotidiennes.",
|
||||||
|
"encounter": {
|
||||||
|
"global_rate_multiplier": 2.0,
|
||||||
|
"combat_lethality": 1.5
|
||||||
|
},
|
||||||
|
"survival": {
|
||||||
|
"drain_speed": 1.8,
|
||||||
|
"fatigue_hp_per_2pts": 3
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"caps_global_multiplier": 0.5,
|
||||||
|
"loot_caps_max": 40,
|
||||||
|
"trade_price_variance": 0.8
|
||||||
|
},
|
||||||
|
"world": {
|
||||||
|
"pop_count_by_zone": {
|
||||||
|
"independance": 80,
|
||||||
|
"nola_vieux_carre": 50,
|
||||||
|
"baton_rouge": 40,
|
||||||
|
"laplace": 30,
|
||||||
|
"nola_fleuve": 20,
|
||||||
|
"la_paroisse": 15,
|
||||||
|
"nola_cbd": 8,
|
||||||
|
"oak_plantation": 10,
|
||||||
|
"donaldsonville": 6,
|
||||||
|
"pearl_river": 4
|
||||||
|
},
|
||||||
|
"security_day_regen": 0.05,
|
||||||
|
"security_night_drain": 0.8,
|
||||||
|
"conflict_security_range": [
|
||||||
|
-2.0,
|
||||||
|
-0.3
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faction_relations": {
|
||||||
|
"initial_volatility": 0.8,
|
||||||
|
"drift_per_llm_tick": 15
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"survie_extreme": {
|
"survie_extreme": {
|
||||||
"_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.",
|
"_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.",
|
||||||
"encounter": { "global_rate_multiplier": 1.8, "combat_lethality": 2.0 },
|
"encounter": {
|
||||||
"survival": { "drain_speed": 2.5, "fatigue_hp_per_2pts": 4 },
|
"global_rate_multiplier": 1.8,
|
||||||
"economy": { "caps_global_multiplier": 0.3, "loot_caps_max": 15 },
|
"combat_lethality": 2.0
|
||||||
"world": {
|
|
||||||
"security_day_regen": 0.05, "security_night_drain": 1.0, "conflict_security_range": [-3.0, -0.5]
|
|
||||||
},
|
},
|
||||||
"faction_relations": { "initial_volatility": 0.5, "drift_per_llm_tick": 10 }
|
"survival": {
|
||||||
|
"drain_speed": 2.5,
|
||||||
|
"fatigue_hp_per_2pts": 4
|
||||||
|
},
|
||||||
|
"economy": {
|
||||||
|
"caps_global_multiplier": 0.3,
|
||||||
|
"loot_caps_max": 15
|
||||||
|
},
|
||||||
|
"world": {
|
||||||
|
"security_day_regen": 0.05,
|
||||||
|
"security_night_drain": 1.0,
|
||||||
|
"conflict_security_range": [
|
||||||
|
-3.0,
|
||||||
|
-0.5
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"faction_relations": {
|
||||||
|
"initial_volatility": 0.5,
|
||||||
|
"drift_per_llm_tick": 10
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"name": ""
|
||||||
}
|
}
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
"model_pnj": "qwen2.5:7b",
|
"model_pnj": "qwen2.5:7b",
|
||||||
"ollama_url": "http://localhost:11434",
|
"ollama_url": "http://localhost:11434",
|
||||||
"chroma_url": "http://localhost:8000",
|
"chroma_url": "http://localhost:8000",
|
||||||
"chroma_collection": "fallout_vst"
|
"chroma_collection": "fallout_lore"
|
||||||
},
|
},
|
||||||
|
|
||||||
"modes": {
|
"modes": {
|
||||||
|
|||||||
@@ -0,0 +1,379 @@
|
|||||||
|
"""
|
||||||
|
Enrichissement de lore par LLM — Fallout: Venice of Wasteland
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python lore_enricher.py --faction grand_krewe
|
||||||
|
python lore_enricher.py --faction all
|
||||||
|
python lore_enricher.py --list-factions
|
||||||
|
|
||||||
|
Pipeline:
|
||||||
|
1. Récupère le lore actuel de la faction depuis Chroma (fallout_lore / lore_canon)
|
||||||
|
2. Récupère le contexte RAG pertinent (règles 2D20, lore inspiration)
|
||||||
|
3. Envoie au LLM 14b avec prompt structuré
|
||||||
|
4. Parse la réponse JSON → INSERT dans lore_proposals (status=pending)
|
||||||
|
5. L'utilisateur valide via le dashboard /lore
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, json, re, argparse
|
||||||
|
import psycopg2, psycopg2.extras
|
||||||
|
import urllib.request, urllib.error
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
CHROMA_URL = os.getenv("CHROMA_URL", "http://localhost:8800")
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
LLM_MODEL = os.getenv("LLM_MODEL", "qwen2.5:14b")
|
||||||
|
CHROMA_COL = os.getenv("CHROMA_COL", "fallout_lore")
|
||||||
|
CHROMA_COL_OUT = os.getenv("CHROMA_COL_OUT", "fallout_lore_enriched")
|
||||||
|
|
||||||
|
DB_HOST = os.getenv("DB_HOST", "127.0.0.1")
|
||||||
|
DB_PORT = int(os.getenv("DB_PORT", 15432))
|
||||||
|
DB_NAME = os.getenv("DB_NAME", "fallout")
|
||||||
|
DB_USER = os.getenv("DB_USER", "fallout")
|
||||||
|
DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
|
||||||
|
|
||||||
|
CHROMA_BASE = f"{CHROMA_URL}/api/v2/tenants/default_tenant/databases/default_database"
|
||||||
|
|
||||||
|
FACTIONS = [
|
||||||
|
"union", "cda", "ecumeurs", "grand_krewe", "dynaste_oak",
|
||||||
|
"regie", "syndicat_capitole", "consortium",
|
||||||
|
]
|
||||||
|
|
||||||
|
FACTION_LABELS = {
|
||||||
|
"union": "L'Union (milice populaire bayou)",
|
||||||
|
"cda": "La CdA — Confraternité de l'Acier (chapitre local)",
|
||||||
|
"ecumeurs": "Les Écumeurs (pirates fluviaux)",
|
||||||
|
"grand_krewe": "Le Grand Krewe (goules mystiques de La Paroisse)",
|
||||||
|
"dynaste_oak": "La Dynaste d'Oak (planteurs esclavagistes)",
|
||||||
|
"regie": "La Régie (bureaucratie survivaliste NOLA)",
|
||||||
|
"syndicat_capitole": "Le Syndicat du Capitole (négociants Baton Rouge)",
|
||||||
|
"consortium": "Le Consortium (marchands neutres)",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers HTTP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def http_post(url: str, body: dict) -> dict:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=120) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def http_get(url: str) -> dict:
|
||||||
|
with urllib.request.urlopen(url, timeout=30) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Chroma
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _get_collection_id(name: str) -> str | None:
|
||||||
|
cols = http_get(f"{CHROMA_BASE}/collections")
|
||||||
|
for c in cols:
|
||||||
|
if c["name"] == name:
|
||||||
|
return c["id"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
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,
|
||||||
|
"include": ["documents", "metadatas", "distances"],
|
||||||
|
}
|
||||||
|
if where:
|
||||||
|
body["where"] = where
|
||||||
|
try:
|
||||||
|
res = http_post(f"{CHROMA_BASE}/collections/{collection_id}/query", body)
|
||||||
|
docs = res["documents"][0]
|
||||||
|
metas = res["metadatas"][0]
|
||||||
|
dists = res["distances"][0]
|
||||||
|
return [{"text": d, "meta": m, "distance": s}
|
||||||
|
for d, m, s in zip(docs, metas, dists)]
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [chroma] Erreur query: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
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}},
|
||||||
|
"limit": 200,
|
||||||
|
"include": ["documents", "metadatas"],
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = http_post(f"{CHROMA_BASE}/collections/{collection_id}/get", body)
|
||||||
|
return [{"text": d, "meta": m}
|
||||||
|
for d, m in zip(res["documents"], res["metadatas"])]
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [chroma] Erreur get_by_category: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def chroma_upsert(collection_id: str, documents: list[dict]):
|
||||||
|
"""Insère ou met à jour des documents dans une collection."""
|
||||||
|
if not documents:
|
||||||
|
return
|
||||||
|
body = {
|
||||||
|
"ids": [d["id"] for d in documents],
|
||||||
|
"documents": [d["text"] for d in documents],
|
||||||
|
"metadatas": [d["meta"] for d in documents],
|
||||||
|
}
|
||||||
|
http_post(f"{CHROMA_BASE}/collections/{collection_id}/upsert", body)
|
||||||
|
|
||||||
|
def ensure_collection(name: str) -> str:
|
||||||
|
"""Crée la collection si elle n'existe pas, retourne son ID."""
|
||||||
|
col_id = _get_collection_id(name)
|
||||||
|
if col_id:
|
||||||
|
return col_id
|
||||||
|
body = {"name": name, "metadata": {"hnsw:space": "cosine"}}
|
||||||
|
res = http_post(f"{CHROMA_BASE}/collections", body)
|
||||||
|
return res["id"]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# PostgreSQL
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
return psycopg2.connect(host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
|
||||||
|
user=DB_USER, password=DB_PASS)
|
||||||
|
|
||||||
|
def insert_proposals(faction_slug: str, proposals: list[dict],
|
||||||
|
source_chunks: list[dict]) -> int:
|
||||||
|
conn = get_db()
|
||||||
|
inserted = 0
|
||||||
|
try:
|
||||||
|
with conn.cursor() as cur:
|
||||||
|
for p in proposals:
|
||||||
|
cur.execute("""
|
||||||
|
INSERT INTO lore_proposals
|
||||||
|
(faction_slug, change_type, field_path,
|
||||||
|
original_text, proposed_text, rationale, source_chunks)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
""", (
|
||||||
|
faction_slug,
|
||||||
|
p.get("change_type", "description"),
|
||||||
|
p.get("field_path"),
|
||||||
|
p.get("original_text"),
|
||||||
|
p["proposed_text"],
|
||||||
|
p.get("rationale"),
|
||||||
|
json.dumps([c["meta"].get("source","?") for c in source_chunks[:5]]),
|
||||||
|
))
|
||||||
|
inserted += 1
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SYSTEM_PROMPT = """Tu es un auteur expert en JDR post-apocalyptique. Tu travailles sur "Fallout: Venice of Wasteland", un univers Fallout 2D20 situé en Louisiane post-nucléaire.
|
||||||
|
|
||||||
|
Ton rôle : enrichir le lore d'une faction en proposant des ajouts SPÉCIFIQUES, cohérents avec les règles 2D20 et l'atmosphère Fallout.
|
||||||
|
|
||||||
|
Règles pour tes propositions :
|
||||||
|
- Rester fidèle à l'esprit Fallout (dark humour, critique sociale, espoir fragile)
|
||||||
|
- S'inspirer du cadre louisianais (vaudou, bayous, Mardi Gras, Créoles, Second Line)
|
||||||
|
- Être mécaniquement utilisable en JDR (perks, traits, objectifs jouables)
|
||||||
|
- Proposer du NOUVEAU contenu, pas reformuler l'existant
|
||||||
|
- Chaque proposition doit être autonome et validable individuellement
|
||||||
|
|
||||||
|
Format de réponse OBLIGATOIRE — JSON uniquement, sans texte autour :
|
||||||
|
{
|
||||||
|
"proposals": [
|
||||||
|
{
|
||||||
|
"change_type": "description|trait|objectif|relation|pnj_notable|rituel|territoire|rumeur",
|
||||||
|
"field_path": "chemin.du.champ.modifié",
|
||||||
|
"original_text": "texte existant si remplacement, null si ajout",
|
||||||
|
"proposed_text": "le nouveau contenu proposé",
|
||||||
|
"rationale": "pourquoi ce changement enrichit le lore (1 phrase)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}"""
|
||||||
|
|
||||||
|
|
||||||
|
def build_user_prompt(faction_slug: str, faction_label: str,
|
||||||
|
lore_chunks: list[dict], rule_chunks: list[dict]) -> str:
|
||||||
|
lore_text = "\n\n---\n\n".join(c["text"] for c in lore_chunks[:6])
|
||||||
|
rule_text = "\n\n---\n\n".join(c["text"] for c in rule_chunks[:4])
|
||||||
|
|
||||||
|
return f"""## FACTION À ENRICHIR : {faction_label}
|
||||||
|
|
||||||
|
### LORE ACTUEL (source de vérité — ne pas contredire) :
|
||||||
|
{lore_text if lore_text else "(aucun chunk lore_canon trouvé pour cette faction)"}
|
||||||
|
|
||||||
|
### CONTEXTE RÈGLES 2D20 PERTINENT :
|
||||||
|
{rule_text if rule_text else "(aucune règle trouvée)"}
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
Génère entre 5 et 10 propositions d'enrichissement pour la faction **{faction_slug}**.
|
||||||
|
Varie les change_type : au moins 2 "trait", 1 "objectif", 1 "pnj_notable", 1 "rituel" ou "rumeur".
|
||||||
|
Réponds UNIQUEMENT avec le JSON demandé."""
|
||||||
|
|
||||||
|
|
||||||
|
def call_llm(prompt: str) -> str:
|
||||||
|
body = {
|
||||||
|
"model": LLM_MODEL,
|
||||||
|
"messages": [
|
||||||
|
{"role": "system", "content": SYSTEM_PROMPT},
|
||||||
|
{"role": "user", "content": prompt},
|
||||||
|
],
|
||||||
|
"stream": False,
|
||||||
|
"options": {"temperature": 0.7, "num_predict": 3000},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
res = http_post(f"{OLLAMA_URL}/api/chat", body)
|
||||||
|
return res["message"]["content"]
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [llm] Erreur: {e}")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def parse_llm_json(raw: str) -> list[dict]:
|
||||||
|
"""Extrait le JSON de la réponse LLM (qui peut avoir du texte autour)."""
|
||||||
|
raw = raw.strip()
|
||||||
|
# Chercher le premier bloc JSON
|
||||||
|
match = re.search(r'\{[\s\S]*\}', raw)
|
||||||
|
if not match:
|
||||||
|
print(" [parse] Aucun JSON trouvé dans la réponse")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
data = json.loads(match.group())
|
||||||
|
proposals = data.get("proposals", [])
|
||||||
|
# Valider la structure minimale
|
||||||
|
valid = []
|
||||||
|
for p in proposals:
|
||||||
|
if "proposed_text" in p:
|
||||||
|
valid.append(p)
|
||||||
|
return valid
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
print(f" [parse] JSON invalide: {e}")
|
||||||
|
# Tentative de nettoyage
|
||||||
|
try:
|
||||||
|
cleaned = re.sub(r',\s*}', '}', match.group())
|
||||||
|
cleaned = re.sub(r',\s*]', ']', cleaned)
|
||||||
|
data = json.loads(cleaned)
|
||||||
|
return data.get("proposals", [])
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pipeline principal
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def enrich_faction(faction_slug: str) -> int:
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" ENRICHISSEMENT : {FACTION_LABELS.get(faction_slug, faction_slug)}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
col_id = _get_collection_id(CHROMA_COL)
|
||||||
|
if not col_id:
|
||||||
|
print(f" [ERR] Collection '{CHROMA_COL}' introuvable dans Chroma")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 1. Lore existant de la faction (lore_canon)
|
||||||
|
print(f" → Recherche lore_canon pour '{faction_slug}'...")
|
||||||
|
lore_chunks = chroma_query(col_id, f"faction {faction_slug} Venice of Wasteland Louisiane",
|
||||||
|
n=8, where={"category": {"$eq": "lore_canon"}})
|
||||||
|
print(f" {len(lore_chunks)} chunks lore_canon trouvés")
|
||||||
|
|
||||||
|
# 2. Contexte règles pertinent
|
||||||
|
print(f" → Recherche règles 2D20 pertinentes...")
|
||||||
|
rule_chunks = chroma_query(col_id,
|
||||||
|
f"faction organisation traits perks objectifs conflits Fallout 2D20",
|
||||||
|
n=6, where={"category": {"$eq": "regles_core"}})
|
||||||
|
print(f" {len(rule_chunks)} chunks règles trouvés")
|
||||||
|
|
||||||
|
# 3. Contexte inspiration
|
||||||
|
inspi_chunks = chroma_query(col_id,
|
||||||
|
f"{faction_slug} culture rituel organisation wasteland",
|
||||||
|
n=4, where={"category": {"$eq": "lore_inspiration"}})
|
||||||
|
print(f" {len(inspi_chunks)} chunks inspiration trouvés")
|
||||||
|
|
||||||
|
all_context = lore_chunks + rule_chunks + inspi_chunks
|
||||||
|
|
||||||
|
# 4. Build prompt
|
||||||
|
faction_label = FACTION_LABELS.get(faction_slug, faction_slug)
|
||||||
|
prompt = build_user_prompt(faction_slug, faction_label, lore_chunks,
|
||||||
|
rule_chunks + inspi_chunks)
|
||||||
|
|
||||||
|
# 5. Appel LLM
|
||||||
|
print(f" → Appel {LLM_MODEL}...")
|
||||||
|
raw_response = call_llm(prompt)
|
||||||
|
if not raw_response:
|
||||||
|
print(" [ERR] Réponse LLM vide")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 6. Parse
|
||||||
|
proposals = parse_llm_json(raw_response)
|
||||||
|
print(f" → {len(proposals)} propositions parsées")
|
||||||
|
|
||||||
|
if not proposals:
|
||||||
|
print(" [WARN] Réponse brute LLM:")
|
||||||
|
print(raw_response[:500])
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# 7. Afficher un résumé
|
||||||
|
for i, p in enumerate(proposals):
|
||||||
|
print(f" [{i+1}] {p.get('change_type','?'):15s} | {p.get('field_path','?')}")
|
||||||
|
print(f" {p['proposed_text'][:80]}...")
|
||||||
|
|
||||||
|
# 8. Insérer en DB
|
||||||
|
inserted = insert_proposals(faction_slug, proposals, all_context)
|
||||||
|
print(f"\n ✓ {inserted} propositions insérées (status=pending)")
|
||||||
|
|
||||||
|
# 9. Upsert dans Chroma enriched (textes acceptés = ceux insérés maintenant,
|
||||||
|
# la validation se fait via le dashboard)
|
||||||
|
out_col_id = ensure_collection(CHROMA_COL_OUT)
|
||||||
|
print(f" → Collection Chroma enriched: {CHROMA_COL_OUT} ({out_col_id[:8]}...)")
|
||||||
|
|
||||||
|
return inserted
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Enrichissement lore Fallout Venice")
|
||||||
|
parser.add_argument("--faction", default=None,
|
||||||
|
help="Slug faction (ex: grand_krewe) ou 'all'")
|
||||||
|
parser.add_argument("--list-factions", action="store_true")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.list_factions:
|
||||||
|
print("Factions disponibles:")
|
||||||
|
for slug, label in FACTION_LABELS.items():
|
||||||
|
print(f" {slug:20s} — {label}")
|
||||||
|
return
|
||||||
|
|
||||||
|
if not args.faction:
|
||||||
|
parser.print_help()
|
||||||
|
return
|
||||||
|
|
||||||
|
targets = FACTIONS if args.faction == "all" else [args.faction]
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for faction in targets:
|
||||||
|
if faction not in FACTIONS:
|
||||||
|
print(f"[WARN] Faction inconnue: {faction}. Disponibles: {', '.join(FACTIONS)}")
|
||||||
|
continue
|
||||||
|
n = enrich_faction(faction)
|
||||||
|
total += n
|
||||||
|
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f" TOTAL : {total} propositions créées (status=pending)")
|
||||||
|
print(f" → Aller sur fallout.coyoteos.ovh/lore pour valider")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
+22
-1
@@ -67,7 +67,10 @@ def run(config_path: str | None = None):
|
|||||||
# Initialiser le world_state si vide
|
# Initialiser le world_state si vide
|
||||||
_init_world_state(SESSION_ID, day)
|
_init_world_state(SESSION_ID, day)
|
||||||
|
|
||||||
|
STATUS_CHECK_INTERVAL = 5 # vérifier le statut DB toutes les N ticks
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
tick_count = 0
|
||||||
while True:
|
while True:
|
||||||
tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode)
|
tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode)
|
||||||
db.update_session_tick(SESSION_ID, day, current_tick)
|
db.update_session_tick(SESSION_ID, day, current_tick)
|
||||||
@@ -75,10 +78,28 @@ def run(config_path: str | None = None):
|
|||||||
if tick_sleep > 0:
|
if tick_sleep > 0:
|
||||||
time.sleep(tick_sleep)
|
time.sleep(tick_sleep)
|
||||||
|
|
||||||
|
# Vérification statut + reset DB (permet stop/pause/reset depuis le dashboard)
|
||||||
|
tick_count += 1
|
||||||
|
if tick_count % STATUS_CHECK_INTERVAL == 0:
|
||||||
|
live = db.get_session(SESSION_ID)
|
||||||
|
if live and live["status"] != "active":
|
||||||
|
print(f"\n[DASH] Statut changé → '{live['status']}' — arrêt propre.")
|
||||||
|
print(f" Dernier état : Jour {day}, Tick {current_tick}h")
|
||||||
|
return
|
||||||
|
# Détecter reset jour/tick : si la DB est en arrière de >1 jour, c'est un reset intentionnel
|
||||||
|
if live and live["current_day"] < day - 1:
|
||||||
|
print(f"\n[DASH] Reset détecté (DB: J{live['current_day']} vs sim: J{day}) — relecture position.")
|
||||||
|
day = live["current_day"]
|
||||||
|
current_tick = live["current_tick"]
|
||||||
|
|
||||||
day, current_tick = tick_engine.next_tick(day, current_tick)
|
day, current_tick = tick_engine.next_tick(day, current_tick)
|
||||||
|
|
||||||
# Réinit world_state au début de chaque nouveau jour
|
# Début d'un nouveau jour : hot-reload config depuis le JSON
|
||||||
if current_tick == 0:
|
if current_tick == 0:
|
||||||
|
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", "tick_sleep_sec", tick_sleep)))
|
||||||
_init_world_state(SESSION_ID, day)
|
_init_world_state(SESSION_ID, day)
|
||||||
|
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
|
|||||||
@@ -0,0 +1,343 @@
|
|||||||
|
"""
|
||||||
|
Capacity Test — Fallout Venice of Wasteland
|
||||||
|
Série de tests pour mesurer la capacité de charge LLM.
|
||||||
|
|
||||||
|
Simule N joueurs simultanés et mesure :
|
||||||
|
- Temps de réponse par joueur (P50, P95, max)
|
||||||
|
- Débit total (tokens/s global)
|
||||||
|
- Temps d'attente queue (joueur 1 vs joueur N)
|
||||||
|
- Viabilité pour le mode temps-réel (1h/tick)
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 capacity_test.py
|
||||||
|
python3 capacity_test.py --quick (skip 50-player test)
|
||||||
|
python3 capacity_test.py --output /path/to/results.json
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, time, json, argparse, threading, statistics
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b")
|
||||||
|
MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b")
|
||||||
|
OUTPUT = os.getenv("CRASH_RESULTS_PATH",
|
||||||
|
"/home/ubuntu/fallout-venice/src/config/crash_results.json")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompts variés pour éviter le cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
PNJ_PROMPTS = [
|
||||||
|
"Tu es un garde de La Régie. Réponds en 1 phrase à : 'Laisse-moi passer.'",
|
||||||
|
"Tu es un marchand créole de Pearl River. Réponds en 1 phrase à : 'T'as du carburant ?'",
|
||||||
|
"Tu es une goule du Grand Krewe. Réponds en 1 phrase à : 'Que veut le Baron ?'",
|
||||||
|
"Tu es un Écumeur. Réponds en 1 phrase à : 'On veut juste traverser le fleuve.'",
|
||||||
|
"Tu es Sœur Eulalie de L'Union. Réponds en 1 phrase à : 'J'ai été mordu.'",
|
||||||
|
"Tu es un technicien de la CdA. Réponds en 1 phrase à : 'Répare mon Pip-Boy.'",
|
||||||
|
"Tu es un scavenger neutre. Réponds en 1 phrase à : 'Combien pour cette armure ?'",
|
||||||
|
"Tu es un informateur du Syndicat. Réponds en 1 phrase à : 'Qui contrôle la Route 61 ?'",
|
||||||
|
"Tu es un fermier de Oak Plantation. Réponds en 1 phrase à : 'Vous travaillez pour qui ?'",
|
||||||
|
"Tu es un passeur de Laplace. Réponds en 1 phrase à : 'Combien pour traverser ?'",
|
||||||
|
]
|
||||||
|
|
||||||
|
MJ_PROMPTS = [
|
||||||
|
"En 2 phrases, décris la réaction de la foule quand les joueurs entrent dans le marché de NOLA.",
|
||||||
|
"En 2 phrases, décris l'ambiance de Pearl River à l'aube après une nuit de combat.",
|
||||||
|
"En 2 phrases, que se passe-t-il quand le Grand Krewe envoie un émissaire aux joueurs ?",
|
||||||
|
"En 2 phrases, décris les conséquences d'un vol de convoi de la Régie.",
|
||||||
|
"En 2 phrases, comment réagit L'Union quand elle apprend qu'un de ses membres a trahi ?",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HTTP
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def http_post(url: str, body: dict, timeout: int = 180) -> dict:
|
||||||
|
data = json.dumps(body).encode()
|
||||||
|
req = urllib.request.Request(url, data=data, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"})
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
return json.loads(r.read())
|
||||||
|
|
||||||
|
def call_llm(model: str, prompt: str, player_id: int = 0) -> dict:
|
||||||
|
t0 = time.time()
|
||||||
|
try:
|
||||||
|
res = http_post(f"{OLLAMA_URL}/api/generate", {
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 80, "temperature": 0.7, "seed": player_id},
|
||||||
|
})
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
tokens = res.get("eval_count", 0)
|
||||||
|
return {
|
||||||
|
"player_id": player_id,
|
||||||
|
"model": model,
|
||||||
|
"tokens": tokens,
|
||||||
|
"elapsed": round(elapsed, 2),
|
||||||
|
"tok_s": round(tokens / elapsed, 2) if elapsed > 0 else 0,
|
||||||
|
"queued_s": round(res.get("load_duration", 0) / 1e9, 2),
|
||||||
|
"error": None,
|
||||||
|
"enqueued_at": t0,
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"player_id": player_id, "model": model,
|
||||||
|
"tokens": 0, "elapsed": round(time.time() - t0, 2),
|
||||||
|
"tok_s": 0, "queued_s": 0,
|
||||||
|
"error": str(e)[:80], "enqueued_at": t0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scénarios
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def scenario_warmup() -> dict:
|
||||||
|
"""1 appel MJ + 1 appel PNJ pour chauffer les modèles."""
|
||||||
|
print(" [WARMUP] Chargement des modèles en RAM...")
|
||||||
|
r_mj = call_llm(MODEL_MJ, MJ_PROMPTS[0], 0)
|
||||||
|
r_pnj = call_llm(MODEL_PNJ, PNJ_PROMPTS[0], 0)
|
||||||
|
ok = "✓" if not r_mj["error"] and not r_pnj["error"] else "✗"
|
||||||
|
print(f" {ok} MJ: {r_mj['elapsed']}s ({r_mj['tok_s']} t/s) "
|
||||||
|
f"PNJ: {r_pnj['elapsed']}s ({r_pnj['tok_s']} t/s)")
|
||||||
|
return {"mj_warmup": r_mj, "pnj_warmup": r_pnj}
|
||||||
|
|
||||||
|
|
||||||
|
def scenario_n_players(n: int, rounds: int = 3) -> dict:
|
||||||
|
"""
|
||||||
|
Simule N joueurs envoyant une action simultanément.
|
||||||
|
Chaque joueur → 1 appel PNJ 7b (en parallèle via threads).
|
||||||
|
Après tous les PNJ → 1 appel MJ 14b (narrateur du tick).
|
||||||
|
"""
|
||||||
|
print(f"\n [{n} JOUEURS] {rounds} round(s) — {n} PNJ en parallèle + 1 MJ après")
|
||||||
|
all_rounds = []
|
||||||
|
|
||||||
|
for r in range(rounds):
|
||||||
|
round_results = {"pnj": [], "mj": None}
|
||||||
|
t_round_start = time.time()
|
||||||
|
|
||||||
|
# Phase 1 : N joueurs en parallèle
|
||||||
|
lock = threading.Lock()
|
||||||
|
threads = []
|
||||||
|
|
||||||
|
def player_action(pid: int):
|
||||||
|
prompt = PNJ_PROMPTS[pid % len(PNJ_PROMPTS)]
|
||||||
|
res = call_llm(MODEL_PNJ, prompt, pid)
|
||||||
|
with lock:
|
||||||
|
round_results["pnj"].append(res)
|
||||||
|
|
||||||
|
for i in range(n):
|
||||||
|
t = threading.Thread(target=player_action, args=(i,))
|
||||||
|
threads.append(t)
|
||||||
|
|
||||||
|
t_first = time.time()
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
|
||||||
|
t_all_pnj_done = time.time()
|
||||||
|
pnj_total_wall = round(t_all_pnj_done - t_first, 2)
|
||||||
|
|
||||||
|
# Phase 2 : MJ synthétise
|
||||||
|
mj_prompt = (
|
||||||
|
f"Tick {r+1}, {n} joueurs ont agi simultanément dans NOLA. "
|
||||||
|
+ MJ_PROMPTS[r % len(MJ_PROMPTS)]
|
||||||
|
)
|
||||||
|
mj_res = call_llm(MODEL_MJ, mj_prompt, -1)
|
||||||
|
round_results["mj"] = mj_res
|
||||||
|
|
||||||
|
t_round_total = round(time.time() - t_round_start, 2)
|
||||||
|
|
||||||
|
# Stats PNJ de ce round
|
||||||
|
pnj_ok = [x for x in round_results["pnj"] if not x["error"]]
|
||||||
|
pnj_elapsed = sorted([x["elapsed"] for x in pnj_ok])
|
||||||
|
p50 = statistics.median(pnj_elapsed) if pnj_elapsed else 0
|
||||||
|
p95 = pnj_elapsed[int(len(pnj_elapsed) * 0.95)] if len(pnj_elapsed) > 1 else (pnj_elapsed[0] if pnj_elapsed else 0)
|
||||||
|
|
||||||
|
status = "✓" if not mj_res["error"] and len(pnj_ok) == n else f"⚠ {n - len(pnj_ok)} erreurs"
|
||||||
|
print(f" R{r+1}: PNJ wall={pnj_total_wall}s P50={p50}s P95={p95}s | "
|
||||||
|
f"MJ={mj_res['elapsed']}s | total={t_round_total}s | {status}")
|
||||||
|
|
||||||
|
all_rounds.append({
|
||||||
|
"round": r + 1,
|
||||||
|
"pnj": round_results["pnj"],
|
||||||
|
"mj": mj_res,
|
||||||
|
"pnj_wall_time": pnj_total_wall,
|
||||||
|
"round_total_time": t_round_total,
|
||||||
|
"pnj_p50": p50,
|
||||||
|
"pnj_p95": p95,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {"n_players": n, "rounds": all_rounds}
|
||||||
|
|
||||||
|
|
||||||
|
def compute_stats(scenario_result: dict) -> dict:
|
||||||
|
n = scenario_result["n_players"]
|
||||||
|
rounds = scenario_result["rounds"]
|
||||||
|
|
||||||
|
all_pnj_elapsed = [x["elapsed"] for r in rounds for x in r["pnj"] if not x.get("error")]
|
||||||
|
all_pnj_wall = [r["pnj_wall_time"] for r in rounds]
|
||||||
|
all_mj_elapsed = [r["mj"]["elapsed"] for r in rounds if not r["mj"].get("error")]
|
||||||
|
all_total = [r["round_total_time"] for r in rounds]
|
||||||
|
all_p50 = [r["pnj_p50"] for r in rounds]
|
||||||
|
all_p95 = [r["pnj_p95"] for r in rounds]
|
||||||
|
errors = sum(1 for r in rounds for x in r["pnj"] if x.get("error"))
|
||||||
|
|
||||||
|
def avg(lst): return round(sum(lst) / len(lst), 2) if lst else 0
|
||||||
|
|
||||||
|
tick_duration_s = avg(all_total)
|
||||||
|
viable_realtime = tick_duration_s < 3600
|
||||||
|
viable_5min = tick_duration_s < 300
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n_players": n,
|
||||||
|
"pnj_avg_elapsed_s": avg(all_pnj_elapsed),
|
||||||
|
"pnj_avg_wall_s": avg(all_pnj_wall),
|
||||||
|
"pnj_avg_p50_s": avg(all_p50),
|
||||||
|
"pnj_avg_p95_s": avg(all_p95),
|
||||||
|
"mj_avg_elapsed_s": avg(all_mj_elapsed),
|
||||||
|
"tick_total_avg_s": tick_duration_s,
|
||||||
|
"tick_total_min_s": min(all_total) if all_total else 0,
|
||||||
|
"tick_total_max_s": max(all_total) if all_total else 0,
|
||||||
|
"errors": errors,
|
||||||
|
"viable_realtime_1h": viable_realtime,
|
||||||
|
"viable_5min_tick": viable_5min,
|
||||||
|
"actions_per_hour": int(3600 / tick_duration_s) if tick_duration_s > 0 else 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rapport
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
SEP = "=" * 70
|
||||||
|
|
||||||
|
def print_report(warmup: dict, scenarios: list[dict], stats_list: list[dict]):
|
||||||
|
print(f"\n{SEP}")
|
||||||
|
print(f" RAPPORT CAPACITÉ — FALLOUT VENICE — {datetime.now().strftime('%d/%m/%Y %H:%M')}")
|
||||||
|
print(SEP)
|
||||||
|
|
||||||
|
w_mj = warmup.get("mj_warmup", {})
|
||||||
|
w_pnj = warmup.get("pnj_warmup", {})
|
||||||
|
print(f"\n BASELINE (warm)")
|
||||||
|
print(f" MJ 14b : {w_mj.get('elapsed','?')}s | {w_mj.get('tok_s','?')} tok/s")
|
||||||
|
print(f" PNJ 7b : {w_pnj.get('elapsed','?')}s | {w_pnj.get('tok_s','?')} tok/s")
|
||||||
|
|
||||||
|
print(f"\n {'N':>4} {'PNJ wall':>10} {'P50':>7} {'P95':>7} {'MJ':>7} {'TICK total':>11} {'Actions/h':>10} {'1h viable':>10} ERR")
|
||||||
|
print(f" {'-'*4} {'-'*10} {'-'*7} {'-'*7} {'-'*7} {'-'*11} {'-'*10} {'-'*10} {'-'*4}")
|
||||||
|
|
||||||
|
for s in stats_list:
|
||||||
|
v1h = "✓" if s["viable_realtime_1h"] else "✗"
|
||||||
|
err = str(s["errors"]) if s["errors"] else "0"
|
||||||
|
print(f" {s['n_players']:>4} "
|
||||||
|
f"{s['pnj_avg_wall_s']:>9.1f}s "
|
||||||
|
f"{s['pnj_avg_p50_s']:>6.1f}s "
|
||||||
|
f"{s['pnj_avg_p95_s']:>6.1f}s "
|
||||||
|
f"{s['mj_avg_elapsed_s']:>6.1f}s "
|
||||||
|
f"{s['tick_total_avg_s']:>10.1f}s "
|
||||||
|
f"{s['actions_per_hour']:>10} "
|
||||||
|
f"{v1h:>10} "
|
||||||
|
f"{err:>4}")
|
||||||
|
|
||||||
|
print(f"\n LÉGENDE")
|
||||||
|
print(f" PNJ wall : durée réelle pour que TOUS les PNJ répondent (parallèle)")
|
||||||
|
print(f" P50 / P95 : médiane / 95e percentile du temps d'attente par joueur")
|
||||||
|
print(f" TICK total : PNJ wall + MJ (= durée d'un tick complet)")
|
||||||
|
print(f" Actions/h : combien de ticks complets tiennent en 1h réelle")
|
||||||
|
print(f" 1h viable : tick < 1h (compatible mode temps-réel)")
|
||||||
|
|
||||||
|
print(f"\n RECOMMANDATIONS")
|
||||||
|
for s in stats_list:
|
||||||
|
n = s["n_players"]
|
||||||
|
t = s["tick_total_avg_s"]
|
||||||
|
a = s["actions_per_hour"]
|
||||||
|
if a >= 6:
|
||||||
|
msg = f"✓ {n} joueurs → 1 action toutes les {round(t/60,1)} min — jouable"
|
||||||
|
elif a >= 1:
|
||||||
|
msg = f"⚠ {n} joueurs → 1 action toutes les {round(t/60,1)} min — lent mais viable en temps réel"
|
||||||
|
else:
|
||||||
|
msg = f"✗ {n} joueurs → tick de {round(t/60,1)} min — dépasse 1h, non viable en temps réel"
|
||||||
|
print(f" {msg}")
|
||||||
|
|
||||||
|
print(f"\n{SEP}")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Main
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Capacity test LLM Fallout Venice")
|
||||||
|
parser.add_argument("--quick", action="store_true", help="Skip 50-player test")
|
||||||
|
parser.add_argument("--output", default=OUTPUT)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"\n{SEP}")
|
||||||
|
print(f" CAPACITY TEST — FALLOUT VENICE OF WASTELAND")
|
||||||
|
print(f" Ollama : {OLLAMA_URL}")
|
||||||
|
print(f" MJ : {MODEL_MJ} | PNJ : {MODEL_PNJ}")
|
||||||
|
print(f" Début : {datetime.now().strftime('%H:%M:%S')}")
|
||||||
|
print(SEP)
|
||||||
|
|
||||||
|
# Vérifier Ollama
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r:
|
||||||
|
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
|
||||||
|
print(f"\n Modèles dispos : {', '.join(models)}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [ERR] Ollama inaccessible : {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# 1. Warmup (toujours)
|
||||||
|
print(f"\n{SEP}")
|
||||||
|
print(" PHASE 0 — WARMUP (chargement modèles en RAM)")
|
||||||
|
print(SEP)
|
||||||
|
warmup = scenario_warmup()
|
||||||
|
|
||||||
|
# 2. Série de tests
|
||||||
|
configs = [
|
||||||
|
{"n": 1, "rounds": 3}, # baseline solo
|
||||||
|
{"n": 5, "rounds": 3}, # petit groupe
|
||||||
|
{"n": 10, "rounds": 2}, # groupe moyen
|
||||||
|
]
|
||||||
|
if not args.quick:
|
||||||
|
configs.append({"n": 50, "rounds": 1}) # stress
|
||||||
|
|
||||||
|
scenarios = []
|
||||||
|
stats_list = []
|
||||||
|
|
||||||
|
for cfg in configs:
|
||||||
|
n, rounds = cfg["n"], cfg["rounds"]
|
||||||
|
print(f"\n{SEP}")
|
||||||
|
print(f" PHASE — {n} JOUEUR{'S' if n > 1 else ''} SIMULTANÉ{'S' if n > 1 else ''} ({rounds} round{'s' if rounds > 1 else ''})")
|
||||||
|
print(SEP)
|
||||||
|
result = scenario_n_players(n, rounds)
|
||||||
|
s = compute_stats(result)
|
||||||
|
scenarios.append(result)
|
||||||
|
stats_list.append(s)
|
||||||
|
|
||||||
|
# 3. Rapport terminal
|
||||||
|
print_report(warmup, scenarios, stats_list)
|
||||||
|
|
||||||
|
# 4. Export JSON
|
||||||
|
output = {
|
||||||
|
"test_type": "capacity",
|
||||||
|
"mode": "capacity_test",
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"ollama_url": OLLAMA_URL,
|
||||||
|
"model_mj": MODEL_MJ,
|
||||||
|
"model_pnj": MODEL_PNJ,
|
||||||
|
"warmup": warmup,
|
||||||
|
"scenarios": scenarios,
|
||||||
|
"stats": stats_list,
|
||||||
|
"file_date": datetime.now().strftime("%d/%m/%Y %H:%M:%S"),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
with open(args.output, "w") as f:
|
||||||
|
json.dump(output, f, indent=2, ensure_ascii=False)
|
||||||
|
print(f"\n Résultats écrits → {args.output}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f" [WARN] Impossible d'écrire le fichier : {e}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -0,0 +1,326 @@
|
|||||||
|
"""
|
||||||
|
LLM Crash Test — Fallout: Venice of Wasteland
|
||||||
|
Test de performance des deux LLM (MJ 14b + PNJ 7b) en conditions réelles.
|
||||||
|
|
||||||
|
Modes:
|
||||||
|
--mode simultane : les deux LLM tournent en parallèle (threads)
|
||||||
|
--mode decale : push progressif PNJ → MJ (pipeline producteur/consommateur)
|
||||||
|
--mode solo-mj : seulement le 14b
|
||||||
|
--mode solo-pnj : seulement le 7b
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python llm_crash_test.py --mode simultane --rounds 5
|
||||||
|
python llm_crash_test.py --mode decale --rounds 10 --pnj-count 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os, sys, time, json, argparse, threading, queue
|
||||||
|
import urllib.request
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
OLLAMA_URL = os.getenv("OLLAMA_URL", "http://localhost:11434")
|
||||||
|
MODEL_MJ = os.getenv("MODEL_MJ", "qwen2.5:14b")
|
||||||
|
MODEL_PNJ = os.getenv("MODEL_PNJ", "qwen2.5:7b")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Prompts de test réalistes (contexte Fallout Venice)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
PNJ_PROMPTS = [
|
||||||
|
"Tu es Remy Tureaud, marchand créole de Pearl River. Réponds en 2 phrases à : 'T'as des piles ?'",
|
||||||
|
"Tu es Sœur Eulalie, soigneuse de L'Union. Réponds en 2 phrases à : 'J'ai une blessure par balle.'",
|
||||||
|
"Tu es Jacques-Henri, garde de la Régie. Réponds en 2 phrases à : 'Laisse-moi passer le checkpoint.'",
|
||||||
|
"Tu es Mama Voodoo, goule du Grand Krewe. Réponds en 2 phrases à : 'C'est quoi ce tatouage ?'",
|
||||||
|
"Tu es un Écumeur anonyme. Réponds en 2 phrases à : 'On veut juste passer.'",
|
||||||
|
]
|
||||||
|
|
||||||
|
MJ_PROMPTS = [
|
||||||
|
"Résume en 3 phrases la situation politique entre L'Union et la CdA à New Orleans post-apo.",
|
||||||
|
"Décris en 3 phrases une rencontre de nuit dans les bayous de Pearl River.",
|
||||||
|
"En 3 phrases, quelles sont les conséquences d'un blocus sur la route de Baton Rouge ?",
|
||||||
|
"Décris en 3 phrases l'ambiance d'un marché noir dans le Vieux Carré de NOLA.",
|
||||||
|
"En 3 phrases, comment réagit le Grand Krewe si des étrangers fouillent leurs ruines ?",
|
||||||
|
]
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# LLM call
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def call_llm(model: str, prompt: str, timeout: int = 180) -> dict:
|
||||||
|
"""Appel Ollama, retourne {tokens, elapsed, error, text}."""
|
||||||
|
t0 = time.time()
|
||||||
|
body = json.dumps({
|
||||||
|
"model": model,
|
||||||
|
"prompt": prompt,
|
||||||
|
"stream": False,
|
||||||
|
"options": {"num_predict": 150, "temperature": 0.7},
|
||||||
|
}).encode()
|
||||||
|
req = urllib.request.Request(
|
||||||
|
f"{OLLAMA_URL}/api/generate", data=body, method="POST",
|
||||||
|
headers={"Content-Type": "application/json"}
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(req, timeout=timeout) as r:
|
||||||
|
data = json.loads(r.read())
|
||||||
|
elapsed = time.time() - t0
|
||||||
|
tokens = data.get("eval_count", 0)
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"tokens": tokens,
|
||||||
|
"elapsed": round(elapsed, 2),
|
||||||
|
"tok_s": round(tokens / elapsed, 1) if elapsed > 0 else 0,
|
||||||
|
"error": None,
|
||||||
|
"text": data.get("response", "")[:120],
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"model": model,
|
||||||
|
"tokens": 0,
|
||||||
|
"elapsed": round(time.time() - t0, 2),
|
||||||
|
"tok_s": 0,
|
||||||
|
"error": str(e)[:80],
|
||||||
|
"text": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Modes
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def run_solo(model: str, prompts: list[str], rounds: int) -> list[dict]:
|
||||||
|
results = []
|
||||||
|
for i in range(rounds):
|
||||||
|
prompt = prompts[i % len(prompts)]
|
||||||
|
print(f" [{model}] Round {i+1}/{rounds}...", end="", flush=True)
|
||||||
|
r = call_llm(model, prompt)
|
||||||
|
print(f" {r['tok_s']} tok/s | {r['elapsed']}s | {'ERR: '+r['error'] if r['error'] else 'OK'}")
|
||||||
|
results.append(r)
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_simultane(rounds: int, pnj_count: int = 2) -> dict:
|
||||||
|
"""Les deux LLM tournent en parallèle via threads."""
|
||||||
|
print(f"\n[SIMULTANE] {MODEL_MJ} + {MODEL_PNJ} x{pnj_count} PNJ — {rounds} rounds\n")
|
||||||
|
all_results = {"mj": [], "pnj": []}
|
||||||
|
lock = threading.Lock()
|
||||||
|
|
||||||
|
def mj_worker(round_idx: int):
|
||||||
|
prompt = MJ_PROMPTS[round_idx % len(MJ_PROMPTS)]
|
||||||
|
r = call_llm(MODEL_MJ, prompt)
|
||||||
|
with lock:
|
||||||
|
all_results["mj"].append(r)
|
||||||
|
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
|
||||||
|
print(f" [MJ 14b] R{round_idx+1} → {status} ({r['elapsed']}s)")
|
||||||
|
|
||||||
|
def pnj_worker(round_idx: int, pnj_idx: int):
|
||||||
|
prompt = PNJ_PROMPTS[(round_idx * pnj_count + pnj_idx) % len(PNJ_PROMPTS)]
|
||||||
|
r = call_llm(MODEL_PNJ, prompt)
|
||||||
|
with lock:
|
||||||
|
all_results["pnj"].append(r)
|
||||||
|
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
|
||||||
|
print(f" [PNJ 7b] R{round_idx+1} PNJ{pnj_idx+1} → {status} ({r['elapsed']}s)")
|
||||||
|
|
||||||
|
t_start = time.time()
|
||||||
|
for i in range(rounds):
|
||||||
|
threads = []
|
||||||
|
t = threading.Thread(target=mj_worker, args=(i,))
|
||||||
|
threads.append(t)
|
||||||
|
for j in range(pnj_count):
|
||||||
|
t = threading.Thread(target=pnj_worker, args=(i, j))
|
||||||
|
threads.append(t)
|
||||||
|
for t in threads:
|
||||||
|
t.start()
|
||||||
|
for t in threads:
|
||||||
|
t.join()
|
||||||
|
print()
|
||||||
|
|
||||||
|
total = time.time() - t_start
|
||||||
|
return {**all_results, "total_elapsed": round(total, 1)}
|
||||||
|
|
||||||
|
|
||||||
|
def run_decale(rounds: int, pnj_count: int = 3) -> dict:
|
||||||
|
"""Pipeline producteur/consommateur : les PNJ accumulent des infos → poussé vers le MJ."""
|
||||||
|
print(f"\n[DÉCALÉ] {pnj_count} PNJ 7b → buffer → MJ 14b — {rounds} rounds\n")
|
||||||
|
pnj_queue: queue.Queue = queue.Queue()
|
||||||
|
all_results = {"mj": [], "pnj": [], "latency_pnj_to_mj": []}
|
||||||
|
stop_flag = threading.Event()
|
||||||
|
|
||||||
|
def pnj_producer():
|
||||||
|
idx = 0
|
||||||
|
while not stop_flag.is_set():
|
||||||
|
prompt = PNJ_PROMPTS[idx % len(PNJ_PROMPTS)]
|
||||||
|
r = call_llm(MODEL_PNJ, prompt)
|
||||||
|
r["produced_at"] = time.time()
|
||||||
|
pnj_queue.put(r)
|
||||||
|
with threading.Lock():
|
||||||
|
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
|
||||||
|
print(f" [PNJ 7b ] prod #{idx+1} → {status} ({r['elapsed']}s) | queue={pnj_queue.qsize()}")
|
||||||
|
idx += 1
|
||||||
|
|
||||||
|
def mj_consumer():
|
||||||
|
consumed = 0
|
||||||
|
while consumed < rounds:
|
||||||
|
# Attendre qu'il y ait au moins 1 item dans la queue (ou timeout)
|
||||||
|
try:
|
||||||
|
pnj_result = pnj_queue.get(timeout=90)
|
||||||
|
except queue.Empty:
|
||||||
|
print(" [MJ 14b] Timeout attente PNJ")
|
||||||
|
break
|
||||||
|
|
||||||
|
latency = time.time() - pnj_result["produced_at"]
|
||||||
|
# Construire le prompt MJ à partir du résultat PNJ
|
||||||
|
pnj_text = pnj_result.get("text", "(vide)")
|
||||||
|
mj_prompt = (
|
||||||
|
f"Un PNJ vient de parler : \"{pnj_text}\"\n"
|
||||||
|
f"En tant que MJ, en 2 phrases, quelle est la conséquence narrative ?"
|
||||||
|
)
|
||||||
|
r = call_llm(MODEL_MJ, mj_prompt)
|
||||||
|
r["latency_from_pnj"] = round(latency, 2)
|
||||||
|
all_results["mj"].append(r)
|
||||||
|
all_results["latency_pnj_to_mj"].append(round(latency + r["elapsed"], 2))
|
||||||
|
status = f"ERR: {r['error']}" if r['error'] else f"{r['tok_s']} tok/s"
|
||||||
|
print(f" [MJ 14b ] cons #{consumed+1} → {status} ({r['elapsed']}s) | pipeline={round(latency+r['elapsed'],1)}s")
|
||||||
|
consumed += 1
|
||||||
|
pnj_queue.task_done()
|
||||||
|
print()
|
||||||
|
|
||||||
|
# Lancer N threads PNJ + 1 thread MJ
|
||||||
|
pnj_threads = [threading.Thread(target=pnj_producer, daemon=True) for _ in range(pnj_count)]
|
||||||
|
mj_thread = threading.Thread(target=mj_consumer)
|
||||||
|
|
||||||
|
t_start = time.time()
|
||||||
|
for t in pnj_threads:
|
||||||
|
t.start()
|
||||||
|
mj_thread.start()
|
||||||
|
mj_thread.join()
|
||||||
|
stop_flag.set()
|
||||||
|
|
||||||
|
# Récupérer tous les résultats PNJ dans la queue restante
|
||||||
|
while not pnj_queue.empty():
|
||||||
|
all_results["pnj"].append(pnj_queue.get())
|
||||||
|
|
||||||
|
total = time.time() - t_start
|
||||||
|
return {**all_results, "total_elapsed": round(total, 1)}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rapport
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def print_report(results: dict, mode: str):
|
||||||
|
def stats(lst: list[dict]) -> dict:
|
||||||
|
if not lst:
|
||||||
|
return {}
|
||||||
|
ok = [r for r in lst if not r.get("error")]
|
||||||
|
err = len(lst) - len(ok)
|
||||||
|
if not ok:
|
||||||
|
return {"calls": len(lst), "errors": err}
|
||||||
|
tok_s = [r["tok_s"] for r in ok]
|
||||||
|
elaps = [r["elapsed"] for r in ok]
|
||||||
|
return {
|
||||||
|
"calls": len(lst),
|
||||||
|
"errors": err,
|
||||||
|
"avg_tok_s": round(sum(tok_s) / len(tok_s), 1),
|
||||||
|
"min_tok_s": min(tok_s),
|
||||||
|
"max_tok_s": max(tok_s),
|
||||||
|
"avg_elapsed": round(sum(elaps) / len(elaps), 1),
|
||||||
|
"total_tokens": sum(r["tokens"] for r in ok),
|
||||||
|
}
|
||||||
|
|
||||||
|
sep = "=" * 60
|
||||||
|
print(f"\n{sep}")
|
||||||
|
print(f" RAPPORT — MODE {mode.upper()}")
|
||||||
|
print(f" {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
if "mj" in results and results["mj"]:
|
||||||
|
s = stats(results["mj"])
|
||||||
|
print(f"\n MJ 14b ({MODEL_MJ})")
|
||||||
|
for k, v in s.items():
|
||||||
|
print(f" {k:20s} : {v}")
|
||||||
|
|
||||||
|
if "pnj" in results and results["pnj"]:
|
||||||
|
s = stats(results["pnj"])
|
||||||
|
print(f"\n PNJ 7b ({MODEL_PNJ})")
|
||||||
|
for k, v in s.items():
|
||||||
|
print(f" {k:20s} : {v}")
|
||||||
|
|
||||||
|
if "latency_pnj_to_mj" in results and results["latency_pnj_to_mj"]:
|
||||||
|
lat = results["latency_pnj_to_mj"]
|
||||||
|
print(f"\n Pipeline PNJ→MJ (latence totale)")
|
||||||
|
print(f" avg_pipeline_s : {round(sum(lat)/len(lat), 1)}")
|
||||||
|
print(f" min_pipeline_s : {min(lat)}")
|
||||||
|
print(f" max_pipeline_s : {max(lat)}")
|
||||||
|
|
||||||
|
print(f"\n Durée totale : {results.get('total_elapsed', '?')}s")
|
||||||
|
|
||||||
|
# Score de viabilité
|
||||||
|
mj_ok = not any(r.get("error") for r in results.get("mj", []))
|
||||||
|
pnj_ok = not any(r.get("error") for r in results.get("pnj", []))
|
||||||
|
mj_speed = stats(results.get("mj", [])).get("avg_tok_s", 0)
|
||||||
|
pnj_speed = stats(results.get("pnj", [])).get("avg_tok_s", 0)
|
||||||
|
|
||||||
|
print(f"\n DIAGNOSTIC")
|
||||||
|
print(f" MJ stable : {'✓' if mj_ok else '✗ ERREURS'}")
|
||||||
|
print(f" PNJ stable : {'✓' if pnj_ok else '✗ ERREURS'}")
|
||||||
|
if mj_speed:
|
||||||
|
viable = "✓ VIABLE" if mj_speed > 8 else "⚠ LENT (< 8 tok/s)" if mj_speed > 3 else "✗ TROP LENT"
|
||||||
|
print(f" MJ débit : {mj_speed} tok/s → {viable}")
|
||||||
|
if pnj_speed:
|
||||||
|
viable = "✓ VIABLE" if pnj_speed > 15 else "⚠ LENT" if pnj_speed > 5 else "✗ TROP LENT"
|
||||||
|
print(f" PNJ débit : {pnj_speed} tok/s → {viable}")
|
||||||
|
print(sep)
|
||||||
|
|
||||||
|
# Export JSON
|
||||||
|
report_path = os.getenv("CRASH_RESULTS_PATH", "/home/ubuntu/fallout-venice/src/config/crash_results.json")
|
||||||
|
try:
|
||||||
|
with open(report_path, "w") as f:
|
||||||
|
json.dump({"mode": mode, "results": results,
|
||||||
|
"stats": {"mj": stats(results.get("mj",[])),
|
||||||
|
"pnj": stats(results.get("pnj",[]))}}, f, indent=2)
|
||||||
|
print(f"\n Rapport JSON : {report_path}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CLI
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="LLM Crash Test — Fallout Venice")
|
||||||
|
parser.add_argument("--mode", choices=["simultane","decale","solo-mj","solo-pnj"],
|
||||||
|
default="simultane")
|
||||||
|
parser.add_argument("--rounds", type=int, default=5, help="Nombre de rounds MJ")
|
||||||
|
parser.add_argument("--pnj-count", type=int, default=2, help="PNJ parallèles (modes simultane/decale)")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
print(f"=== LLM CRASH TEST — {args.mode.upper()} ===")
|
||||||
|
print(f"Ollama : {OLLAMA_URL}")
|
||||||
|
print(f"MJ : {MODEL_MJ}")
|
||||||
|
print(f"PNJ : {MODEL_PNJ}")
|
||||||
|
print(f"Rounds : {args.rounds} | PNJ parallèles : {args.pnj_count}")
|
||||||
|
|
||||||
|
# Vérifier que Ollama répond
|
||||||
|
try:
|
||||||
|
with urllib.request.urlopen(f"{OLLAMA_URL}/api/tags", timeout=5) as r:
|
||||||
|
models = [m["name"] for m in json.loads(r.read()).get("models", [])]
|
||||||
|
print(f"Modèles disponibles : {', '.join(models[:5])}")
|
||||||
|
except Exception as e:
|
||||||
|
print(f"[ERR] Ollama inaccessible : {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
t_global = time.time()
|
||||||
|
|
||||||
|
if args.mode == "simultane":
|
||||||
|
results = run_simultane(args.rounds, args.pnj_count)
|
||||||
|
elif args.mode == "decale":
|
||||||
|
results = run_decale(args.rounds, args.pnj_count)
|
||||||
|
elif args.mode == "solo-mj":
|
||||||
|
r = run_solo(MODEL_MJ, MJ_PROMPTS, args.rounds)
|
||||||
|
results = {"mj": r, "total_elapsed": round(time.time() - t_global, 1)}
|
||||||
|
elif args.mode == "solo-pnj":
|
||||||
|
r = run_solo(MODEL_PNJ, PNJ_PROMPTS, args.rounds)
|
||||||
|
results = {"pnj": r, "total_elapsed": round(time.time() - t_global, 1)}
|
||||||
|
|
||||||
|
print_report(results, args.mode)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user