diff --git a/dashboard/app.py b/dashboard/app.py
new file mode 100644
index 0000000..59f343a
--- /dev/null
+++ b/dashboard/app.py
@@ -0,0 +1,431 @@
+"""
+VAULTCOM — Fallout: Venice of Wasteland
+Dashboard simulation. Schéma DB actuel (migration_003).
+"""
+
+import os, json
+import psycopg2
+import psycopg2.extras
+from flask import Flask, render_template_string, jsonify
+
+app = Flask(__name__)
+
+DB_HOST = os.getenv("DB_HOST", "localhost")
+DB_PORT = int(os.getenv("DB_PORT", 5432))
+DB_NAME = os.getenv("DB_NAME", "fallout")
+DB_USER = os.getenv("DB_USER", "fallout")
+DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
+
+def get_conn():
+ return psycopg2.connect(
+ host=DB_HOST, port=DB_PORT, dbname=DB_NAME,
+ user=DB_USER, password=DB_PASS
+ )
+
+def query(sql, params=None):
+ conn = get_conn()
+ try:
+ with conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) as cur:
+ cur.execute(sql, params)
+ return [dict(r) for r in cur.fetchall()]
+ finally:
+ conn.close()
+
+def query_one(sql, params=None):
+ rows = query(sql, params)
+ return rows[0] if rows else None
+
+# ---------------------------------------------------------------------------
+# HTML template
+# ---------------------------------------------------------------------------
+
+TEMPLATE = r"""
+
+
+
+
+
+VAULTCOM — Fallout: Venice of Wasteland
+
+
+
+⚡ VAULTCOM — VENICE OF WASTELAND ⚡
+
+{% if session %}
+
+{% endif %}
+
+
+
+
+
+
👥 PNJ ACTIFS ({{ alive_count }}/{{ total_count }})
+
+
+
+
+
+
📡 ÉVÉNEMENTS RÉCENTS (J{{ session.current_day if session else '?' }})
+
+
+
+
+
+
+
+
+
+
+
+
+
⚔ RELATIONS FACTIONS
+
+
+
+
+
+
📊 STATS SIMULATION
+
+ | MÉTRIQUE | VALEUR |
+ {% for k, v in stats.items() %}
+ | {{ k }} | {{ v }} |
+ {% endfor %}
+
+
+
+
🎭 PNJ PAR TIER
+
+ | TIER | LABEL | VIVANTS | MORTS |
+ {% for row in tier_stats %}
+
+ | {{ row.tier }} |
+ {{ row.label }} |
+ {{ row.alive }} |
+ {{ row.dead }} |
+
+ {% endfor %}
+
+
+
+
🏙 PNJ PAR ZONE (TOP 5)
+
+ | ZONE | PNJ |
+ {% for row in zone_stats %}
+ | {{ row.location_slug }} | {{ row.cnt }} |
+ {% endfor %}
+
+
+
+
+
+
+
+
⚡ RENCONTRES RÉCENTES
+
+
+
+
+{% if boss_inventory %}
+
+
★ ÉQUIPEMENT CHEFS DE FACTION
+
+ | BOSS | ITEM | TYPE | QTÉ | ÉQUIPÉ |
+ {% for row in boss_inventory %}
+
+ | {{ row.char_name }} |
+ {{ row.item_name }} |
+ {{ row.item_type }} |
+ {{ row.quantity }} |
+ {{ '✓' if row.is_equipped else '' }} |
+
+ {% endfor %}
+
+
+{% endif %}
+
+
+
+"""
+
+# ---------------------------------------------------------------------------
+# Routes
+# ---------------------------------------------------------------------------
+
+@app.route("/")
+def index():
+ session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
+ sid = session["id"] if session else 1
+
+ characters = query("""
+ SELECT c.*, COALESCE(c.tier, 1) as tier
+ FROM characters c
+ WHERE c.session_id = %s
+ ORDER BY COALESCE(c.tier,1), c.is_alive DESC, c.name
+ """, (sid,))
+
+ alive_count = sum(1 for c in characters if c["is_alive"])
+ total_count = len(characters)
+
+ events = query("""
+ SELECT e.*, c.name as actor_name
+ FROM events e
+ LEFT JOIN characters c ON c.id = e.actor_id
+ WHERE e.session_id = %s
+ AND e.event_type NOT IN ('encounter_lambda','encounter_group','encounter_actif','encounter_boss')
+ ORDER BY e.day DESC, e.tick DESC, e.id DESC
+ LIMIT 50
+ """, (sid,))
+
+ encounters = query("""
+ SELECT e.*, c.name as actor_name
+ FROM events e
+ LEFT JOIN characters c ON c.id = e.actor_id
+ WHERE e.session_id = %s
+ AND e.event_type LIKE 'encounter%%'
+ ORDER BY e.day DESC, e.tick DESC, e.id DESC
+ LIMIT 30
+ """, (sid,))
+
+ world_state = query("""
+ SELECT DISTINCT ON (location_slug) *
+ FROM world_state
+ WHERE session_id = %s
+ ORDER BY location_slug, day DESC
+ """, (sid,))
+
+ faction_relations = query("""
+ SELECT * FROM faction_relations
+ WHERE session_id = %s
+ ORDER BY relation_score ASC
+ """, (sid,))
+
+ tier_labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"}
+ tier_raw = query("""
+ SELECT COALESCE(tier,1) as tier,
+ COUNT(*) FILTER (WHERE is_alive) as alive,
+ COUNT(*) FILTER (WHERE NOT is_alive) as dead
+ FROM characters WHERE session_id = %s
+ GROUP BY COALESCE(tier,1)
+ ORDER BY COALESCE(tier,1)
+ """, (sid,))
+ tier_stats = [{"tier": r["tier"], "label": tier_labels.get(r["tier"],"?"),
+ "alive": r["alive"], "dead": r["dead"]} for r in tier_raw]
+
+ zone_stats = query("""
+ SELECT location_slug, COUNT(*) as cnt
+ FROM characters
+ WHERE session_id = %s AND is_alive = TRUE
+ GROUP BY location_slug
+ ORDER BY cnt DESC
+ LIMIT 5
+ """, (sid,))
+
+ events_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s", (sid,))
+ combat_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='combat'", (sid,))
+ deaths_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type='death'", (sid,))
+ enc_total = query_one("SELECT COUNT(*) as n FROM events WHERE session_id=%s AND event_type LIKE 'encounter%%'", (sid,))
+
+ stats = {
+ "Événements total": events_total["n"] if events_total else 0,
+ "Combats": combat_total["n"] if combat_total else 0,
+ "Morts": deaths_total["n"] if deaths_total else 0,
+ "Rencontres": enc_total["n"] if enc_total else 0,
+ "PNJ vivants": alive_count,
+ "PNJ morts": total_count - alive_count,
+ }
+
+ boss_inventory = query("""
+ SELECT c.name as char_name, i.item_name, i.item_type, i.quantity, i.is_equipped
+ FROM inventory i
+ JOIN characters c ON c.id = i.character_id
+ WHERE c.session_id = %s AND COALESCE(c.tier,1) = 0
+ ORDER BY c.name, i.is_equipped DESC
+ """, (sid,))
+
+ return render_template_string(TEMPLATE,
+ session=session,
+ characters=characters,
+ alive_count=alive_count,
+ total_count=total_count,
+ events=events,
+ encounters=encounters,
+ world_state=world_state,
+ faction_relations=faction_relations,
+ tier_stats=tier_stats,
+ zone_stats=zone_stats,
+ stats=stats,
+ boss_inventory=boss_inventory,
+ )
+
+@app.route("/api/state")
+def api_state():
+ session = query_one("SELECT * FROM sessions ORDER BY id DESC LIMIT 1")
+ sid = session["id"] if session else 1
+ alive = query("SELECT id, name, hp, max_hp, tier, faction_slug, location_slug FROM characters WHERE session_id=%s AND is_alive=TRUE", (sid,))
+ return jsonify({"session": session, "alive_pnj": alive})
+
+if __name__ == "__main__":
+ app.run(host="0.0.0.0", port=5000, debug=False)
diff --git a/src/config/sim_001.json b/src/config/sim_001.json
new file mode 100644
index 0000000..b25ab10
--- /dev/null
+++ b/src/config/sim_001.json
@@ -0,0 +1,45 @@
+{
+ "_comment": "Configuration de la simulation session 1 — Venice of Wasteland",
+ "session": {
+ "session_id": 1,
+ "name": "Session 001 — Louisiane post-apo",
+ "seed_global": 42,
+ "mode": "sandbox"
+ },
+ "tick": {
+ "tick_sleep_sec": 60,
+ "ticks_per_day": 24,
+ "day_start_tick": 6,
+ "night_start_tick": 20,
+ "llm_tick_interval": 6
+ },
+ "difficulty": {
+ "preset": "normal",
+ "survival_drain_food": 1,
+ "survival_drain_water": 1,
+ "survival_drain_sleep": 1,
+ "encounter_rate_multiplier": 1.0,
+ "combat_lethality": 1.0,
+ "resource_scarcity": 1.0
+ },
+ "difficulty_presets": {
+ "easy": { "survival_drain_food": 0, "survival_drain_water": 0, "survival_drain_sleep": 0, "encounter_rate_multiplier": 0.5, "combat_lethality": 0.7 },
+ "normal": { "survival_drain_food": 1, "survival_drain_water": 1, "survival_drain_sleep": 1, "encounter_rate_multiplier": 1.0, "combat_lethality": 1.0 },
+ "hard": { "survival_drain_food": 2, "survival_drain_water": 2, "survival_drain_sleep": 1, "encounter_rate_multiplier": 1.5, "combat_lethality": 1.3 },
+ "nightmare": { "survival_drain_food": 3, "survival_drain_water": 3, "survival_drain_sleep": 2, "encounter_rate_multiplier": 2.0, "combat_lethality": 1.8 }
+ },
+ "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"
+ },
+ "multi_session": {
+ "allow_parallel": true,
+ "max_sessions": 4,
+ "session_db_host": "localhost",
+ "session_db_port": 15432
+ }
+}
diff --git a/src/engine/config.py b/src/engine/config.py
index 9eeaec0..0f4b374 100644
--- a/src/engine/config.py
+++ b/src/engine/config.py
@@ -11,7 +11,7 @@ DB_PASS = os.getenv("DB_PASS", "VeniceOfWasteland2026!")
TICKS_PER_DAY = 24 # 1 tick = 1 heure in-game
DAY_START_TICK = 6 # 06h00
NIGHT_START_TICK = 20 # 20h00
-TICK_SLEEP_SEC = 0 # 0 = bot-speed (pré-sim), sinon secondes entre ticks
+TICK_SLEEP_SEC = float(os.getenv("TICK_SLEEP_SEC", 0)) # 0=bot-speed, 60=1tick/min
# --- Session ---
SESSION_ID = int(os.getenv("SESSION_ID", 1))