Compare commits

...

10 Commits

Author SHA1 Message Date
Corback 2a6ad68f73 roadmap: add Phase 9 multi-interface (admin/player/bug), mark fallout-visu unification done 2026-06-16 11:00:40 +00:00
Corback 20df05ff72 fix: add SSH tunnel startup script, docker-compose uses start.sh 2026-06-16 10:59:30 +00:00
Corback 3330931f30 unify: dashboard now lives in repo, docker-compose moved to root, credentials in .env 2026-06-16 10:54:02 +00:00
Corback 5957a059b6 sanitize: remove sensitive IPs/credentials from README, add INFRA_PRIVATE.md to .gitignore 2026-06-16 10:48:20 +00:00
Corback efe66026ad docs: README complet + ROADMAP phases 1-9, points critiques, décisions archi 2026-06-16 10:37:51 +00:00
Corback cf57c84dd1 fix: crash test timeout 180s, indent fix, Ollama via host.docker.internal + iptables 2026-06-16 09:48:38 +00:00
Corback b4847f03bb feat: PipBoy UI redesign, onglet OUTILS (stress test + lancement commandes), crash_results.json shared path 2026-06-16 09:10:11 +00:00
Corback bbfd2dc45f feat: PipBoy rename, /lore tab fix, run.py status check loop, crash test script 2026-06-16 08:53:02 +00:00
Corback a16d671db4 feat: lore enricher pipeline + /lore dashboard tab + fix chroma_collection name 2026-06-16 08:40:45 +00:00
Corback 99bd77e18b fix: params page cfg=None + hot-reload config chaque jour
dashboard/app.py:
  load_sim_config() retourne None (pas {}) quand le fichier est absent
  — {} est falsy en Python, masquait le formulaire même quand la config
  était chargeable mais vide. None est explicitement distinguable.
  load_all_modes() charge toujours depuis sim_001.json (référence 5 modes)
  — sim_002 n'a qu'un seul mode dans son bloc 'modes', le tableau comparatif
  était vide pour S2.
  {% if cfg is not none %} dans le template au lieu de {% if cfg %}
  Message d'avertissement sous le bouton save : prise en compte au prochain jour
  Message d'erreur précis si fichier introuvable (path + hint mount Docker)
  /params/save: vérification if cfg is None

run.py:
  Hot-reload config à chaque tick==0 (frontière de jour) :
    cfg.load(config_path, session_id=SESSION_ID)
    effective_mode mis à jour depuis la config rechargée
    tick_sleep recalculé (TICK_SLEEP_SEC env var prime toujours)
  → Changer mode/drain/encounter via dashboard prend effet le jour suivant
    sans redémarrer le process
2026-06-16 08:25:59 +00:00
14 changed files with 3090 additions and 306 deletions
+8
View File
@@ -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
+2
View File
@@ -6,3 +6,5 @@ __pycache__/
*.sqlite
.DS_Store
Thumbs.db
INFRA_PRIVATE.md
.env
+231
View File
@@ -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`)
+216
View File
@@ -0,0 +1,216 @@
# ROADMAP — Fallout: Venice of Wasteland
État au **16 juin 2026**. Projet : moteur de simulation JDR Fallout 2D20 autonome, Louisiane post-nucléaire.
---
## ✅ Phase 1 — Moteur de base (TERMINÉ)
- [x] Moteur de tick Python (survie, économie, rencontres)
- [x] Système PNJ 4 tiers (Tier 0 Boss / Tier 1 Actifs / Tier 2 PNJ+ / Tier 3 Passage)
- [x] Combats 2D20 (combat_engine.py)
- [x] Sessions multiples isolées (session_id sur toutes les tables)
- [x] Config JSON par session avec deep-merge par mode
- [x] Hot-reload config au début de chaque jour (sans redémarrer)
- [x] Vérification statut DB dans la boucle principale (stop depuis dashboard)
- [x] Zones sûres (safe zones) — pas de rencontres lambda, commerce PNJ-à-PNJ
- [x] Système de raids de faction dans les zones sûres (faible probabilité)
- [x] Relations factions avec score de volatilité
---
## ✅ Phase 2 — RAG & Lore (TERMINÉ)
- [x] ChromaDB avec 1403 chunks (règles 2D20 + lore canon Venice of Wasteland)
- [x] Bible Venice of Wasteland v1.1 & v2.0 indexée (lore_canon, 32 chunks)
- [x] Correction des 299 chunks avec métadonnée `type``category`
- [x] Pipeline d'enrichissement lore (lore_enricher.py)
- RAG depuis fallout_lore (lore_canon + regles_core + lore_inspiration)
- Appel qwen2.5:14b avec prompt structuré JDR
- Propositions stockées dans `lore_proposals` (PostgreSQL)
- Validation manuelle depuis le PipBoy
- Propositions acceptées → `fallout_lore_enriched` (Chroma)
---
## ✅ Phase 3 — Dashboard PipBoy (TERMINÉ)
- [x] Dashboard Flask accessible sur fallout.coyoteos.ovh
- [x] Onglet **SIM LIBRE** : état temps réel (PNJ, events, world state, factions, rencontres)
- [x] Onglet **OUTILS** : génération commandes + stress test LLM avec console live
- [x] Onglet **ENRICHISSEMENT** : validation propositions lore (accept/reject/modifier)
- [x] Onglet **PARAMÈTRES** : config session, modes, reset jour/tick, statut DB
- [x] Switcher de sessions (S1/S2)
- [x] Auto-refresh 30s sur l'onglet simulation
- [x] Métriques cards (PNJ vivants/morts, combats, rencontres)
- [x] Bouton "Exécuter" stress test avec console live (polling 1.5s)
- [x] Accès Ollama depuis le container Docker (iptables + host.docker.internal)
---
## ✅ Phase 4 — Tests de performance (TERMINÉ)
- [x] Script stress test LLM (llm_crash_test.py) — modes simultane/decale/solo
- [x] Script capacity test (capacity_test.py) — simulation N joueurs simultanés
- [x] Résultats mesurés : 1/5/10/50 joueurs, tick 35s → 3m32
- [x] Conclusion : 10-15 joueurs actifs simultanés recommandés sur l'infra actuelle
---
## 🔄 Phase 5 — LLM intégré à la simulation (EN COURS / PROCHAIN)
### 5a — Animation PNJ par LLM (priorité haute)
- [ ] Appel LLM 7b uniquement sur interaction joueur directe avec Tier 0/1
- [ ] Contexte RAG injecté : lore_canon faction + règles pertinentes
- [ ] Mémoire courte terme : 3 derniers échanges du PNJ en contexte
- [ ] Réponse stockée en DB (events table) avec attribution actor_id
- [ ] Limit : max N appels LLM PNJ simultanés par tick (configurable)
### 5b — MJ narrateur par LLM (priorité haute)
- [ ] Synthèse narrative du tick par le 14b (résumé de ce qui s'est passé)
- [ ] Stockée comme événement `narrative_tick` en DB
- [ ] Affichage dans PipBoy onglet SIM
- [ ] Contexte : world_state + top 10 événements du tick + relations factions
### 5c — Onglet SIM LLM dans PipBoy
- [ ] Vue dédiée pour les sessions avec LLM actif
- [ ] Feed narratif du MJ en temps réel
- [ ] Indicateur de charge LLM (requêtes en queue)
---
## 🔮 Phase 6 — Arbitre & Cohérence (CONÇU, NON IMPLÉMENTÉ)
Système de vérification automatique entre les ticks, utilisant la marge de 6m30 disponible.
### Architecture
```
Tick N (3m30) → Résultats en DB
Arbitre LLM (3m00) → Vérification cohérence + règles
├── Auto-correction (patterns connus)
└── File manuelle → PipBoy validation
Tick N+1 → Démarre sur une base cohérente
```
### Tables à créer
- `tick_validations` — anomalies détectées par tick
- `correction_patterns` — patterns appris, score de confiance
### Logique d'apprentissage
- Chaque validation manuelle augmente la confiance du pattern (+delta)
- Chaque rejet diminue la confiance (-delta)
- Seuil configurable (défaut 0.8) → bascule en auto-correction
- Types de patterns : `stat_check` | `location` | `economy` | `narrative`
### Ce que l'arbitre vérifie
- **Auto (confiance rapide)** : caps négatifs, HP > max, PNJ mort qui agit, localisation impossible
- **Manuel (confiance lente)** : cohérence narrative, comportement faction vs lore, conflits de traités
---
## 🔮 Phase 7 — Ticks temps réel (IDÉE VALIDÉE, NON IMPLÉMENTÉ)
Synchroniser les ticks sur l'heure réelle de la Louisiane (UTC-5/UTC-6).
### Principe
- 1 tick = 1 heure réelle (timezone `America/Chicago`)
- `run.py` calcule le tick courant depuis `datetime.now(tz)` au lieu d'incrémenter
- Résout la dérive en cas de redémarrage
- Nuit en jeu = vraiment la nuit (20h-8h Louisiana = 2h-14h France)
### Impact joueur
- Action possible toutes les X minutes réelles
- Le MJ et les PNJ "vivent" même quand le joueur est absent
- Journées réelles = journées en jeu
### Config à ajouter dans sim_XXX.json
```json
"tick": {
"mode": "realtime",
"realtime_timezone": "America/Chicago",
"fixed_tick_sleep_sec": 60
}
```
### Considération fuseau horaire
- France UTC+2 vs Louisiane UTC-5/6 → 7-8h de décalage
- Midi à NOLA = 18h-19h en France → créneau jouable en soirée
- Nuit à NOLA se passe pendant les heures de travail en France → cohérent narrativement
---
## 🔮 Phase 8 — Météo procédurale (IDÉE, NON IMPLÉMENTÉ)
Système météo calé sur la météo réelle de la Louisiane.
- API météo New Orleans → injectée dans le contexte LLM chaque jour
- Impact mécanique : pluie = -visibilité, chaleur extrême = drain eau ×1.5
- Événements saisonniers : saison des ouragans (juin-novembre), Mardi Gras
- Stocké dans `world_state` par zone avec colonne `weather_json`
---
## 🔮 Phase 9 — Interfaces multiples (VISION LONG TERME)
Trois interfaces distinctes, même backend, accès différenciés.
### 9a — Interface Admin (PipBoy v2, évolution de l'actuel)
- Accès restreint (auth basique ou token)
- Tout ce que fait le PipBoy actuel + logs bruts + contrôle fin des sessions
- Gestion des joueurs : création de compte, reset de personnage, ban
- Vue globale multi-sessions en temps réel
### 9b — Interface Joueur
- Flask léger ou SvelteKit (si JS est inévitable)
- Un joueur = un personnage = une session
- Actions possibles : se déplacer, interagir avec un PNJ (→ déclenche LLM 7b), commercer
- Réponse PNJ via LLM 7b dans la foulée (<10s)
- Résumé MJ 14b à la fin du tick (feed narratif)
- Statut du personnage : HP, inventaire, position, derniers events
- Gestion des absences : le PNJ joueur survit en mode automatique (Tier 1)
- **Brique à préparer dès Phase 5** : API REST `POST /player/action` + `GET /player/state`
### 9c — Interface Bug / Rapport
- Formulaire simple accessible depuis l'interface joueur
- Champ : description du bug + tick/session automatiquement attachés
- Stocké en table `bug_reports` (PostgreSQL) avec statut open/closed
- Visible depuis l'interface Admin
### Architecture cible
```
┌──────────────┐
│ API centrale │ (Flask ou FastAPI)
│ /admin/* │← Admin (PipBoy évolué)
│ /player/* │← Interface Joueur
│ /bug/* │← Rapports bugs
└──────┬───────┘
┌────────────┼────────────┐
DB Vigile Ollama ChromaDB
```
**Décision clé à anticiper** : séparer Admin et Joueur en deux Flask (ports différents, auth différente) ou un seul Flask avec blueprints + décorateur `@require_role`. La deuxième option est plus simple à l'échelle actuelle.
---
## Backlog technique (bugs connus / dettes)
- [x] `fallout-visu/app.py` et `dashboard/app.py` dupliqués → ✅ unifié en juin 2026 (docker-compose monte directement `dashboard/app.py`)
- [ ] Le container PipBoy se réinstalle à chaque restart (pip install au démarrage) → créer une image Docker dédiée avec les dépendances pré-installées
- [ ] `capacity_test.py` écrit en root depuis le container (permission denied) → monter le volume avec le bon uid
- [ ] 8 timeouts sur 50 joueurs simultanés → augmenter timeout à 300s ou implémenter une queue avec backpressure
- [ ] Pas de persistance de l'iptables après reboot complet du host → vérifier que `netfilter-persistent` est installé
---
## Décisions d'architecture à retenir
| Décision | Raison |
|----------|--------|
| LLM PNJ déclenché sur interaction seulement (pas chaque tick) | Évite de saturer Ollama sur les 100+ PNJ de la sim |
| `fallout_lore` en lecture seule | Source de vérité immuable — les enrichissements vont dans `fallout_lore_enriched` |
| Validation manuelle avant auto (Phase 6) | Constituer un corpus de corrections avant d'automatiser |
| Ticks temps réel en option (pas par défaut) | Handicap pour les joueurs français si activé sans réflexion |
| PostgreSQL sur Vigile (OVH) séparé d'Ampère | Résilience — si Ampère tombe, les données persistent |
| Sessions isolées par `session_id` | Permet tests en parallèle sans collision de données |
+1306 -227
View File
File diff suppressed because it is too large Load Diff
+11
View File
@@ -0,0 +1,11 @@
#!/bin/sh
set -e
# Tunnel SSH vers Vigile (DB)
cp /ssh/vigile.key /tmp/vigile.key
chmod 600 /tmp/vigile.key
ssh -fN -L 15432:localhost:5432 -i /tmp/vigile.key -o StrictHostKeyChecking=no -o ServerAliveInterval=30 ubuntu@79.72.30.231
echo '[start.sh] Tunnel SSH vers Vigile établi sur 127.0.0.1:15432'
exec python app.py
+34
View File
@@ -0,0 +1,34 @@
services:
fallout-visu:
image: python:3.11-alpine
container_name: fallout-visu
restart: unless-stopped
working_dir: /app
volumes:
- ./dashboard/app.py:/app/app.py:ro
- ./dashboard/start.sh:/app/start.sh:ro
- /home/ubuntu/.ssh/vigile_backup:/ssh/vigile.key:ro
- ./src/config:/app/src/config
- ./tools:/app/tools:ro
command: sh -c 'apk add --no-cache openssh-client && pip install flask psycopg2-binary --quiet && sh /app/start.sh'
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}
networks:
proxy-nw:
external: true
+54
View File
@@ -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
View File
@@ -1,29 +1,24 @@
{
"_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.",
"session": {
"session_id": 1,
"name": "Session 001 — Louisiane post-apo",
"seed_global": 42
},
"mode": "pacifiste",
"tick": {
"tick_sleep_sec": 60,
"tick_sleep_sec": 30,
"ticks_per_day": 24,
"day_start_tick": 6,
"night_start_tick": 20,
"llm_tick_interval": 6
},
"survival": {
"_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
"drain_speed": 1.0,
"fatigue_hp_per_2pts": 2,
"enabled": true
},
"economy": {
"_comment": "Multiplicateur global caps (appliqué à tous les caps_range des rôles)",
"caps_global_multiplier": 1.0,
@@ -31,14 +26,12 @@
"loot_caps_max": 25,
"trade_price_variance": 0.2
},
"encounter": {
"_comment": "global_rate_multiplier : appliqué sur chance_per_tick de chaque zone",
"global_rate_multiplier": 1.0,
"combat_lethality": 1.0,
"enabled": true
},
"world": {
"_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
"pop_count_by_zone": {
@@ -57,77 +50,166 @@
"food_drain_per_pop": 0.003,
"security_day_regen": 0.2,
"security_night_drain": 0.3,
"conflict_security_range": [-0.5, 0.2]
"conflict_security_range": [
-0.5,
0.2
]
},
"faction_relations": {
"initial_volatility": 0.05,
"drift_per_llm_tick": 1
},
"llm": {
"enabled": false,
"model_mj": "qwen2.5:14b",
"model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000",
"chroma_collection": "fallout_vst"
"chroma_collection": "fallout_lore"
},
"modes": {
"pacifiste": {
"_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 },
"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 }
"encounter": {
"global_rate_multiplier": 0.4,
"combat_lethality": 0.5
},
"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": {
"_comment": "Intrigues factions, peu de violence directe, mais relations volatiles",
"encounter": { "global_rate_multiplier": 0.6, "combat_lethality": 0.7 },
"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 }
"encounter": {
"global_rate_multiplier": 0.6,
"combat_lethality": 0.7
},
"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": {
"_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 },
"survival": { "drain_speed": 1.5 },
"economy": { "caps_global_multiplier": 1.5, "trade_price_variance": 0.5 },
"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]
"encounter": {
"global_rate_multiplier": 1.2,
"combat_lethality": 0.9
},
"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": {
"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
"nola_cbd": 15,
"pearl_river": 6,
"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": {
"_comment": "Pas de mode narration. Purement sim de survie hostile. Test de résistance.",
"encounter": { "global_rate_multiplier": 1.8, "combat_lethality": 2.0 },
"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]
"encounter": {
"global_rate_multiplier": 1.8,
"combat_lethality": 2.0
},
"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": ""
}
+1 -1
View File
@@ -67,7 +67,7 @@
"model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000",
"chroma_collection": "fallout_vst"
"chroma_collection": "fallout_lore"
},
"modes": {
+377
View File
@@ -0,0 +1,377 @@
"""
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: str, query_text: str, n: int = 8,
where: dict | None = None) -> list[dict]:
"""Recherche sémantique dans une collection Chroma."""
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: str, 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
View File
@@ -67,7 +67,10 @@ def run(config_path: str | None = None):
# Initialiser le world_state si vide
_init_world_state(SESSION_ID, day)
STATUS_CHECK_INTERVAL = 5 # vérifier le statut DB toutes les N ticks
try:
tick_count = 0
while True:
tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode)
db.update_session_tick(SESSION_ID, day, current_tick)
@@ -75,10 +78,28 @@ def run(config_path: str | None = None):
if tick_sleep > 0:
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)
# 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:
cfg.load(config_path, session_id=SESSION_ID)
effective_mode = cfg.mode() or db_mode
tick_sleep = float(os.getenv("TICK_SLEEP_SEC",
cfg.get("tick", {}).get("tick_sleep_sec", tick_sleep)))
_init_world_state(SESSION_ID, day)
except KeyboardInterrupt:
+343
View File
@@ -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()
+326
View File
@@ -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()