Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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,5 @@ __pycache__/
|
||||
*.sqlite
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
INFRA_PRIVATE.md
|
||||
.env
|
||||
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
Executable
+11
@@ -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
|
||||
@@ -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
|
||||
@@ -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.",
|
||||
|
||||
"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": ""
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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