From 8b32ea370a9e5eaf046903387a7facedb05fdc3b Mon Sep 17 00:00:00 2001 From: Corback Date: Tue, 16 Jun 2026 00:16:17 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20syst=C3=A8me=20de=20config=20externe=20?= =?UTF-8?q?+=20multi-instance=20+=20modes=20simulation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sim_config.py (NOUVEAU): - Chargeur config JSON avec deep-merge des modes (sim_001/002.json) - sim_config.get('survival'), get('encounter'), get('world') depuis n'importe quel module - SIM_CONFIG env var ou --config CLI arg run.py: - --config/-c arg, SIM_CONFIG env var, SESSION_ID env var - _init_world_state() initialise pop_count depuis config au démarrage - Mode actif depuis sim_config (prime sur la DB) survival.py: - drain_speed: multiplicateur sur HUNGER/THIRST/SLEEP_TICKS (2.0=2x plus vite) - fatigue_hp_per_2pts: dégâts fatigue depuis config - survival.enabled: peut désactiver la survie entièrement tick.py / encounter.py: - world params (water/food drain, security delta, pop counts) depuis sim_config - encounter.global_rate_multiplier appliqué sur chance_per_tick - encounter.enabled: peut désactiver les rencontres character_creator.py — Caps réalistes (divise par ~4-5): pillard 0-60, garde 15-100, marchand 60-400, eclaireur 10-80 docteur 40-200, technicien 20-150, lieutenant 60-300 raider_boss 30-200, chasseur_primes 80-350, passeur 30-180 scavenger 5-70, chef_faction 300-1500, survivant_generique 5-60 sim_001.json: mode=pacifiste (rencontres x0.4, drain x0.7, sécurité stable) sim_002.json: mode=guerre_commerciale (rencontres x1.2, drain x1.5, caps x1.5) Modes disponibles: pacifiste, politique, guerre_commerciale, guerre, survie_extreme init_pnj.py: SESSION_ID depuis os.getenv (était hardcodé à 1) Multi-instance: SESSION_ID=1 sim_001.json + SESSION_ID=2 sim_002.json en parallèle --- src/config/sim_001.json | 134 ++++++++++++++++++++++++++------ src/config/sim_002.json | 85 ++++++++++++++++++++ src/engine/character_creator.py | 26 +++---- src/engine/encounter.py | 12 ++- src/engine/init_pnj.py | 3 +- src/engine/run.py | 76 +++++++++++++----- src/engine/sim_config.py | 77 ++++++++++++++++++ src/engine/survival.py | 66 ++++++++-------- src/engine/tick.py | 21 +++-- 9 files changed, 402 insertions(+), 98 deletions(-) create mode 100644 src/config/sim_002.json create mode 100644 src/engine/sim_config.py diff --git a/src/config/sim_001.json b/src/config/sim_001.json index b25ab10..caf6036 100644 --- a/src/config/sim_001.json +++ b/src/config/sim_001.json @@ -1,11 +1,14 @@ { - "_comment": "Configuration de la simulation session 1 — Venice of Wasteland", + "_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.' font un deep-merge sur les valeurs de base.", + "session": { "session_id": 1, "name": "Session 001 — Louisiane post-apo", - "seed_global": 42, - "mode": "sandbox" + "seed_global": 42 }, + + "mode": "pacifiste", + "tick": { "tick_sleep_sec": 60, "ticks_per_day": 24, @@ -13,33 +16,118 @@ "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, + + "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, + "loot_caps_min": 3, + "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, - "resource_scarcity": 1.0 + "enabled": true }, - "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 } + + "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": { + "independance": 120, + "nola_vieux_carre": 90, + "baton_rouge": 70, + "laplace": 60, + "nola_fleuve": 40, + "la_paroisse": 35, + "nola_cbd": 25, + "oak_plantation": 20, + "donaldsonville": 15, + "pearl_river": 10 + }, + "water_drain_per_pop": 0.004, + "food_drain_per_pop": 0.003, + "security_day_regen": 0.2, + "security_night_drain": 0.3, + "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_mj": "qwen2.5:14b", "model_pnj": "qwen2.5:7b", - "ollama_url": "http://localhost:11434", - "chroma_url": "http://localhost:8000", + "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 + + "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 } + }, + + "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 } + }, + + "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] + }, + "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] + }, + "faction_relations": { "initial_volatility": 0.5, "drift_per_llm_tick": 10 } + } } } diff --git a/src/config/sim_002.json b/src/config/sim_002.json new file mode 100644 index 0000000..f3b16a3 --- /dev/null +++ b/src/config/sim_002.json @@ -0,0 +1,85 @@ +{ + "_comment": "Config session 2 — Test guerre commerciale. Seed différente, mode plus hostile.", + + "session": { + "session_id": 2, + "name": "Session 002 — Guerre commerciale", + "seed_global": 137 + }, + + "mode": "guerre_commerciale", + + "tick": { + "tick_sleep_sec": 60, + "ticks_per_day": 24, + "day_start_tick": 6, + "night_start_tick": 20, + "llm_tick_interval": 6 + }, + + "survival": { + "drain_speed": 1.0, + "fatigue_hp_per_2pts": 2, + "enabled": true + }, + + "economy": { + "caps_global_multiplier": 1.0, + "loot_caps_min": 3, + "loot_caps_max": 25, + "trade_price_variance": 0.2 + }, + + "encounter": { + "global_rate_multiplier": 1.0, + "combat_lethality": 1.0, + "enabled": true + }, + + "world": { + "pop_count_by_zone": { + "independance": 120, + "nola_vieux_carre": 90, + "baton_rouge": 70, + "laplace": 60, + "nola_fleuve": 40, + "la_paroisse": 35, + "nola_cbd": 25, + "oak_plantation": 20, + "donaldsonville": 15, + "pearl_river": 10 + }, + "water_drain_per_pop": 0.004, + "food_drain_per_pop": 0.003, + "security_day_regen": 0.2, + "security_night_drain": 0.3, + "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" + }, + + "modes": { + "guerre_commerciale": { + "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] + }, + "faction_relations": { "initial_volatility": 0.4, "drift_per_llm_tick": 8 } + } + } +} diff --git a/src/engine/character_creator.py b/src/engine/character_creator.py index 491c82f..fcb93ff 100644 --- a/src/engine/character_creator.py +++ b/src/engine/character_creator.py @@ -29,79 +29,79 @@ _ROLES: dict[str, dict] = { "description": "Pillard des terres désolées, violent et imprévisible", "special": {"STR": (5,8), "PER": (4,6), "END": (5,7), "CHA": (3,5), "INT": (3,5), "AGI": (5,7), "LCK": (3,6)}, "faction_pool": ["ecumeurs", None], - "caps": (0, 100), + "caps": (0, 60), }, "marchand": { "description": "Commerçant itinérant, prudent mais bien connecté", "special": {"STR": (4,6), "PER": (5,7), "END": (4,6), "CHA": (7,9), "INT": (6,8), "AGI": (4,6), "LCK": (5,8)}, "faction_pool": ["consortium", "regie", None], - "caps": (200, 2000), + "caps": (60, 400), }, "garde": { "description": "Milicien ou garde d'une faction, entraîné et discipliné", "special": {"STR": (6,8), "PER": (5,7), "END": (6,8), "CHA": (4,6), "INT": (4,6), "AGI": (5,7), "LCK": (4,6)}, "faction_pool": ["union", "cda", "syndicat_capitole", "regie"], - "caps": (50, 300), + "caps": (15, 100), }, "eclaireur": { "description": "Rôdeur solitaire des marais, discret et endurant", "special": {"STR": (5,7), "PER": (7,9), "END": (6,8), "CHA": (3,5), "INT": (5,7), "AGI": (7,9), "LCK": (5,7)}, "faction_pool": [None, "ecumeurs"], - "caps": (30, 200), + "caps": (10, 80), }, "docteur": { "description": "Médecin de terrain, rare et précieux dans les terres désolées", "special": {"STR": (4,6), "PER": (6,8), "END": (5,7), "CHA": (6,8), "INT": (8,10), "AGI": (5,7), "LCK": (5,7)}, "faction_pool": ["union", "consortium", None], - "caps": (100, 500), + "caps": (40, 200), }, "technicien": { "description": "Bricoleur et réparateur, obsédé par les technologies de l'Ancien Monde", "special": {"STR": (4,6), "PER": (6,8), "END": (4,6), "CHA": (4,6), "INT": (8,10), "AGI": (6,8), "LCK": (4,7)}, "faction_pool": ["consortium", "union", None], - "caps": (50, 400), + "caps": (20, 150), }, "raider_boss": { "description": "Chef de bande, charismatique et dangereux", "special": {"STR": (7,9), "PER": (5,7), "END": (6,8), "CHA": (6,8), "INT": (4,6), "AGI": (5,7), "LCK": (5,7)}, "faction_pool": ["ecumeurs", None], - "caps": (100, 1000), + "caps": (30, 200), }, "lieutenant": { "description": "Bras droit d'un chef de faction, mieux équipé qu'un garde", "special": {"STR": (6,8), "PER": (5,7), "END": (6,8), "CHA": (5,7), "INT": (5,7), "AGI": (5,7), "LCK": (4,6)}, "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"], - "caps": (200, 800), + "caps": (60, 300), }, "chasseur_primes": { "description": "Mercenaire spécialisé dans la traque et la capture", "special": {"STR": (5,8), "PER": (7,9), "END": (5,7), "CHA": (4,6), "INT": (5,7), "AGI": (6,8), "LCK": (5,7)}, "faction_pool": [None, "syndicat_capitole"], - "caps": (200, 800), + "caps": (80, 350), }, "passeur": { "description": "Guide qui connaît les routes secrètes entre les zones", "special": {"STR": (4,6), "PER": (6,8), "END": (5,7), "CHA": (5,7), "INT": (5,7), "AGI": (7,9), "LCK": (5,7)}, "faction_pool": [None, "ecumeurs"], - "caps": (100, 600), + "caps": (30, 180), }, "scavenger": { "description": "Récupérateur de ferraille et de technologie pré-guerre", "special": {"STR": (4,7), "PER": (6,8), "END": (5,7), "CHA": (3,5), "INT": (5,8), "AGI": (5,7), "LCK": (5,8)}, "faction_pool": [None], - "caps": (20, 200), + "caps": (5, 70), }, "chef_faction": { "description": "Chef de faction — tier 0, hors simulation, décisions narratives LLM", "special": {"STR": (5,8), "PER": (6,8), "END": (5,8), "CHA": (7,10), "INT": (7,10), "AGI": (4,7), "LCK": (5,8)}, "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"], - "caps": (1000, 8000), + "caps": (300, 1500), }, "survivant_generique": { "description": "Habitant lambda des terres désolées, équilibré et débrouillard", "special": {"STR": (5,7), "PER": (5,7), "END": (5,7), "CHA": (5,7), "INT": (5,7), "AGI": (5,7), "LCK": (5,7)}, "faction_pool": [None, "union", "regie"], - "caps": (10, 200), + "caps": (5, 60), }, } diff --git a/src/engine/encounter.py b/src/engine/encounter.py index d3cdc32..9a7442c 100644 --- a/src/engine/encounter.py +++ b/src/engine/encounter.py @@ -17,6 +17,7 @@ import random import db import actions as act_module import combat_engine +import sim_config _DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") @@ -91,19 +92,22 @@ def should_trigger_encounter(char: dict, world_state: dict, rng: random.Random) if not profile: return False - base_chance = profile["chance_per_tick"] + enc_cfg = sim_config.get("encounter") + if not enc_cfg.get("enabled", True): + return False + + rate_multiplier = enc_cfg.get("global_rate_multiplier", 1.0) + base_chance = profile["chance_per_tick"] * rate_multiplier # Modificateurs selon l'état du PNJ state = char.get("_pnj_state") or {} stress = state.get("stress_level", 0) if isinstance(state, dict) else 0 - # Stress élevé → PNJ moins attentif → légèrement plus de chances d'encounter stress_mod = min(0.05, stress * 0.005) - # HP bas → PNJ plus vulnérable, attire prédateurs hp_ratio = char.get("hp", 1) / max(char.get("max_hp", 1), 1) hp_mod = 0.03 if hp_ratio < 0.5 else 0.0 - final_chance = base_chance + stress_mod + hp_mod + final_chance = min(0.95, base_chance + stress_mod + hp_mod) return rng.random() < final_chance diff --git a/src/engine/init_pnj.py b/src/engine/init_pnj.py index 07971e0..943d509 100644 --- a/src/engine/init_pnj.py +++ b/src/engine/init_pnj.py @@ -11,7 +11,8 @@ import character_creator as cc import pnj_factory as factory import db -SESSION_ID = 1 +import os +SESSION_ID = int(os.getenv("SESSION_ID", 1)) # --------------------------------------------------------------------------- # Tier 1 — ACTIFS SIM (15 PNJ, boucle survival + actions complète) diff --git a/src/engine/run.py b/src/engine/run.py index d56b174..c27b6fe 100644 --- a/src/engine/run.py +++ b/src/engine/run.py @@ -1,50 +1,86 @@ """ Lanceur de simulation — Fallout Venice of Wasteland Usage : - python run.py # session 1, bot-speed - SESSION_ID=1 python run.py - TICK_SLEEP_SEC=2 python run.py # 1 tick toutes les 2 secondes + python run.py # session 1, sim_001.json, bot-speed + SESSION_ID=2 python run.py # session 2, sim_002.json + SIM_CONFIG=src/config/sim_002.json python run.py + python run.py --config src/config/sim_002.json + TICK_SLEEP_SEC=60 python run.py # override tick speed (priorité sur config) """ -import time -import sys -import os - -# Ajoute src/engine au path +import time, sys, os, argparse sys.path.insert(0, os.path.dirname(__file__)) import db +import sim_config as cfg import tick as tick_engine -from config import SESSION_ID, TICK_SLEEP_SEC +from config import SESSION_ID -def run(): +def _init_world_state(session_id: int, day: int): + """Initialise le world_state avec les pop_count de la config si vide.""" + existing = db.get_world_state(session_id, day) + if existing: + return # déjà initialisé + pop_counts = cfg.get("world").get("pop_count_by_zone", {}) + for location_slug, pop in pop_counts.items(): + db.upsert_world_state( + session_id, location_slug, day, + water_level=80, food_level=70, security_level=60, + pop_count=pop + ) + + +def run(config_path: str | None = None): + # Charger la config + cfg.load(config_path, session_id=SESSION_ID) + tick_cfg = cfg.get("tick") + active_mode = cfg.mode() + + # TICK_SLEEP_SEC env var override (compatibilité) + tick_sleep = float(os.getenv("TICK_SLEEP_SEC", tick_cfg.get("tick_sleep_sec", 0))) + session = db.get_session(SESSION_ID) - day = session["current_day"] + if not session: + print(f"Session {SESSION_ID} introuvable en DB.") + return + + day = session["current_day"] current_tick = session["current_tick"] - mode = session["mode"] - status = session["status"] + db_mode = session["mode"] + status = session["status"] if status != "active": print(f"Session {SESSION_ID} n'est pas active (status={status}). Abandon.") return + # Mode : config.json prime sur la DB + effective_mode = active_mode or db_mode + print(f"=== Simulation Fallout : Venice of Wasteland ===") print(f"Session #{SESSION_ID} — {session['name']}") - print(f"Mode : {mode} | Départ : Jour {day}, Tick {current_tick}h") + print(f"Mode : {effective_mode} | Config : {cfg.get()['_active_mode']}") + print(f"Départ : Jour {day}, Tick {current_tick}h | Tick sleep : {tick_sleep}s") print(f"Seed globale : {session['seed_global']}") print("=" * 50) + # Initialiser le world_state si vide + _init_world_state(SESSION_ID, day) + try: while True: - tick_engine.process_tick(SESSION_ID, day, current_tick, mode) + tick_engine.process_tick(SESSION_ID, day, current_tick, effective_mode) db.update_session_tick(SESSION_ID, day, current_tick) - if TICK_SLEEP_SEC > 0: - time.sleep(TICK_SLEEP_SEC) + if tick_sleep > 0: + time.sleep(tick_sleep) day, current_tick = tick_engine.next_tick(day, current_tick) + # Réinit world_state au début de chaque nouveau jour + if current_tick == 0: + _init_world_state(SESSION_ID, day) + except KeyboardInterrupt: print(f"\n[STOP] Simulation interrompue — Jour {day}, Tick {current_tick}h") db.update_session_tick(SESSION_ID, day, current_tick) @@ -52,4 +88,8 @@ def run(): if __name__ == "__main__": - run() + parser = argparse.ArgumentParser(description="Simulation Fallout Venice of Wasteland") + parser.add_argument("--config", "-c", default=None, + help="Chemin vers le fichier de config JSON (ex: src/config/sim_002.json)") + args = parser.parse_args() + run(config_path=args.config) diff --git a/src/engine/sim_config.py b/src/engine/sim_config.py new file mode 100644 index 0000000..944bafd --- /dev/null +++ b/src/engine/sim_config.py @@ -0,0 +1,77 @@ +""" +SimConfig — Gestionnaire de configuration de simulation. + +Charge src/config/sim_{SESSION_ID:03d}.json (ou SIM_CONFIG env var). +Les sections 'modes.' écrasent les valeurs de base (deep merge). +Usage : + import sim_config + sim_config.load() # charge sim_001.json (SESSION_ID=1) + sim_config.load("src/config/sim_002.json") + sim_config.get("survival")["drain_speed"] + sim_config.get("economy")["caps_global_multiplier"] +""" + +import json +import os + +_CONFIG_DIR = os.path.join(os.path.dirname(__file__), "..", "config") +_cfg: dict = {} +_loaded_path: str = "" + + +def load(path: str | None = None, session_id: int | None = None) -> dict: + """Charge et retourne la config. Priorité : path > SIM_CONFIG env > sim_{id:03d}.json""" + global _cfg, _loaded_path + + if path is None: + path = os.getenv("SIM_CONFIG") + if path is None: + sid = session_id or int(os.getenv("SESSION_ID", 1)) + path = os.path.join(_CONFIG_DIR, f"sim_{sid:03d}.json") + + with open(path, encoding="utf-8") as f: + base = json.load(f) + + # Merge des overrides du mode actif + mode = base.get("mode", "pacifiste") + modes = base.get("modes", {}) + if mode in modes: + _cfg = _deep_merge(base, modes[mode]) + else: + _cfg = dict(base) + + _cfg["_active_mode"] = mode + _loaded_path = path + return _cfg + + +def get(section: str | None = None, key: str | None = None, default=None): + """ + Accès à la config chargée. + sim_config.get() → dict complet + sim_config.get("survival") → section survival + sim_config.get("survival","drain_speed") → valeur scalaire + """ + if not _cfg: + load() # autoload avec défauts + + if section is None: + return _cfg + + sec = _cfg.get(section, {}) + if key is None: + return sec + return sec.get(key, default) + + +def mode() -> str: + return _cfg.get("_active_mode", "pacifiste") + + +def _deep_merge(base, override): + if not isinstance(base, dict) or not isinstance(override, dict): + return override if override is not None else base + out = dict(base) + for k, v in override.items(): + out[k] = _deep_merge(out.get(k), v) + return out diff --git a/src/engine/survival.py b/src/engine/survival.py index 50d745c..9bc2105 100644 --- a/src/engine/survival.py +++ b/src/engine/survival.py @@ -13,36 +13,33 @@ Fatigue : accumulée, réduit PA et inflige -2 PV par tranche de 2 pts au début import db import psycopg2.extras +import sim_config -# Heures entre dégradations d'état (conditions normales, mode normal) -HUNGER_TICKS = { - # de Repu(4) → Rassasié(3) après 1h - 4: 1, - # de Rassasié(3) → Petit creux(2) après 4h - 3: 4, - # de Petit creux(2) → Faim(1) après 8h - 2: 8, - # de Faim(1) → Affamé(0) après 16h → +1 Fatigue/jour = +1 toutes les 24h depuis Faim - 1: 16, -} +# Tables 2D20 officielles (Manuel FR ch.5) — NE PAS MODIFIER, utiliser drain_speed pour tuner +_HUNGER_TICKS_BASE = {4: 1, 3: 4, 2: 8, 1: 16} +_THIRST_TICKS_BASE = {3: 1, 2: 2, 1: 4} +_SLEEP_TICKS_BASE = {3: 8, 2: 8, 1: 8} -THIRST_TICKS = { - # de Désaltéré(3) → Hydraté(2) après 1h - 3: 1, - # de Hydraté(2) → Soif(1) après 2h - 2: 2, - # de Soif(1) → Déshydraté(0) après 4h → +1 Fatigue/8h - 1: 4, -} -SLEEP_TICKS = { - # de Reposé(3) → Fatigué(2) après 8h - 3: 8, - # de Fatigué(2) → Éreinté(1) après 8h - 2: 8, - # de Éreinté(1) → Épuisé(0) après 8h - 1: 8, -} +def _scaled_ticks(base: dict, speed: float) -> dict: + """Applique drain_speed : vitesse 2.0 = seuils divisés par 2 (dégrade 2x plus vite).""" + if speed <= 0: + speed = 1.0 + return {k: max(1, round(v / speed)) for k, v in base.items()} + + +def _get_thresholds(): + speed = sim_config.get("survival", "drain_speed") or 1.0 + return ( + _scaled_ticks(_HUNGER_TICKS_BASE, speed), + _scaled_ticks(_THIRST_TICKS_BASE, speed), + _scaled_ticks(_SLEEP_TICKS_BASE, speed), + ) + + +HUNGER_TICKS = _HUNGER_TICKS_BASE +THIRST_TICKS = _THIRST_TICKS_BASE +SLEEP_TICKS = _SLEEP_TICKS_BASE # Noms lisibles HUNGER_NAMES = {4: "repu", 3: "rassasié", 2: "petit_creux", 1: "faim", 0: "affamé"} @@ -89,6 +86,13 @@ def process_survival_tick(session_id: int, day: int, tick: int): Met à jour faim/soif/sommeil/fatigue de chaque PNJ vivant. Tue les PNJ dont les PV tombent à 0 ou en dessous. """ + sv_cfg = sim_config.get("survival") + if not sv_cfg.get("enabled", True): + return + + hunger_thr, thirst_thr, sleep_thr = _get_thresholds() + fatigue_dmg_per_2pts = sv_cfg.get("fatigue_hp_per_2pts", 2) + characters = db.get_characters(session_id) for char in characters: @@ -100,7 +104,7 @@ def process_survival_tick(session_id: int, day: int, tick: int): # --- Faim --- new_hunger, new_hunger_ticks = _tick_stat( - sv["hunger"], sv["hunger_ticks"], HUNGER_TICKS + sv["hunger"], sv["hunger_ticks"], hunger_thr ) # +1 Fatigue par jour si Affamé (tick 0 de chaque jour = tick 0) if new_hunger == 0 and tick == 0: @@ -110,7 +114,7 @@ def process_survival_tick(session_id: int, day: int, tick: int): # --- Soif --- new_thirst, new_thirst_ticks = _tick_stat( - sv["thirst"], sv["thirst_ticks"], THIRST_TICKS + sv["thirst"], sv["thirst_ticks"], thirst_thr ) # Si déshydraté, +1 Fatigue toutes les 8h if new_thirst == 0: @@ -125,7 +129,7 @@ def process_survival_tick(session_id: int, day: int, tick: int): # --- Sommeil --- new_sleep, new_sleep_ticks = _tick_stat( - sv["sleep"], sv["sleep_ticks"], SLEEP_TICKS + sv["sleep"], sv["sleep_ticks"], sleep_thr ) # Si Épuisé, +1 Fatigue toutes les 4h if new_sleep == 0: @@ -144,7 +148,7 @@ def process_survival_tick(session_id: int, day: int, tick: int): # Règle 2D20 : "début de scène" ≠ chaque heure. On applique une seule fois # à l'aube pour éviter la mort en cascade sur une seule nuit. if tick == 0 and new_fatigue >= 2: - hp_loss = (new_fatigue // 2) * 2 + hp_loss = (new_fatigue // 2) * fatigue_dmg_per_2pts new_hp = max(0, char["hp"] - hp_loss) else: new_hp = char["hp"] diff --git a/src/engine/tick.py b/src/engine/tick.py index 9f711ca..8a46292 100644 --- a/src/engine/tick.py +++ b/src/engine/tick.py @@ -10,6 +10,7 @@ import db import survival import actions import encounter +import sim_config def is_daytime(tick: int) -> bool: @@ -112,16 +113,20 @@ def _compute_location_tick_delta(ws: dict, phase: str, mode: str) -> dict: Calcule les deltas passifs sur les ressources d'un lieu pour un tick. Valeurs fines — 24 ticks = 1 jour, effets nets sur la journée. """ - # Consommation de base par tick (population divisée par 24) - pop = ws.get("pop_count", 0) - base_water_drain = -(pop * 0.004) # ~10% de consommation/jour pour 250 pop - base_food_drain = -(pop * 0.003) + wcfg = sim_config.get("world") + pop = ws.get("pop_count", 0) - # La nuit réduit la sécurité légèrement, le jour la restaure - security_delta = +0.2 if phase == "jour" else -0.3 + water_drain_pp = wcfg.get("water_drain_per_pop", 0.004) + food_drain_pp = wcfg.get("food_drain_per_pop", 0.003) + sec_day_regen = wcfg.get("security_day_regen", 0.2) + sec_night_drain = wcfg.get("security_night_drain", 0.3) + conflict_rng = wcfg.get("conflict_security_range", [-0.5, 0.2]) - # En mode pacifique, pas de dégradation supplémentaire - conflict_modifier = 0 if mode == "pacifique" else random.uniform(-0.5, 0.2) + base_water_drain = -(pop * water_drain_pp) + base_food_drain = -(pop * food_drain_pp) + security_delta = sec_day_regen if phase == "jour" else -sec_night_drain + + conflict_modifier = 0.0 if mode in ("pacifiste", "pacifique") else random.uniform(*conflict_rng) return { "water": base_water_drain + (0.1 if phase == "jour" else 0),