feat: système de config externe + multi-instance + modes simulation

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
This commit is contained in:
Corback
2026-06-16 00:16:17 +00:00
parent 5497f3b5d0
commit 8b32ea370a
9 changed files with 402 additions and 98 deletions
+111 -23
View File
@@ -1,11 +1,14 @@
{ {
"_comment": "Configuration de la simulation session 1 — Venice of Wasteland", "_comment": "Config session 1 — Venice of Wasteland. Sections 'modes.<mode>' font un deep-merge sur les valeurs de base.",
"session": { "session": {
"session_id": 1, "session_id": 1,
"name": "Session 001 — Louisiane post-apo", "name": "Session 001 — Louisiane post-apo",
"seed_global": 42, "seed_global": 42
"mode": "sandbox"
}, },
"mode": "pacifiste",
"tick": { "tick": {
"tick_sleep_sec": 60, "tick_sleep_sec": 60,
"ticks_per_day": 24, "ticks_per_day": 24,
@@ -13,33 +16,118 @@
"night_start_tick": 20, "night_start_tick": 20,
"llm_tick_interval": 6 "llm_tick_interval": 6
}, },
"difficulty": {
"preset": "normal", "survival": {
"survival_drain_food": 1, "_comment": "drain_speed : diviseur sur HUNGER/THIRST/SLEEP_TICKS (2.0 = 2x plus vite, 0.5 = 2x plus lent)",
"survival_drain_water": 1, "drain_speed": 1.0,
"survival_drain_sleep": 1, "fatigue_hp_per_2pts": 2,
"encounter_rate_multiplier": 1.0, "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, "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 }, "world": {
"normal": { "survival_drain_food": 1, "survival_drain_water": 1, "survival_drain_sleep": 1, "encounter_rate_multiplier": 1.0, "combat_lethality": 1.0 }, "_comment": "Populations réalistes pour un wasteland post-apo. 500 habitants c'est une grande cité en zone sûre.",
"hard": { "survival_drain_food": 2, "survival_drain_water": 2, "survival_drain_sleep": 1, "encounter_rate_multiplier": 1.5, "combat_lethality": 1.3 }, "pop_count_by_zone": {
"nightmare": { "survival_drain_food": 3, "survival_drain_water": 3, "survival_drain_sleep": 2, "encounter_rate_multiplier": 2.0, "combat_lethality": 1.8 } "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": { "llm": {
"enabled": false, "enabled": false,
"model_mj": "qwen2.5:14b", "model_mj": "qwen2.5:14b",
"model_pnj": "qwen2.5:7b", "model_pnj": "qwen2.5:7b",
"ollama_url": "http://localhost:11434", "ollama_url": "http://localhost:11434",
"chroma_url": "http://localhost:8000", "chroma_url": "http://localhost:8000",
"chroma_collection": "fallout_vst" "chroma_collection": "fallout_vst"
}, },
"multi_session": {
"allow_parallel": true, "modes": {
"max_sessions": 4, "pacifiste": {
"session_db_host": "localhost", "_comment": "Monde presque sûr, idéal pour tester la sim sans mort en cascade",
"session_db_port": 15432 "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 }
}
} }
} }
+85
View File
@@ -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 }
}
}
}
+13 -13
View File
@@ -29,79 +29,79 @@ _ROLES: dict[str, dict] = {
"description": "Pillard des terres désolées, violent et imprévisible", "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)}, "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], "faction_pool": ["ecumeurs", None],
"caps": (0, 100), "caps": (0, 60),
}, },
"marchand": { "marchand": {
"description": "Commerçant itinérant, prudent mais bien connecté", "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)}, "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], "faction_pool": ["consortium", "regie", None],
"caps": (200, 2000), "caps": (60, 400),
}, },
"garde": { "garde": {
"description": "Milicien ou garde d'une faction, entraîné et discipliné", "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)}, "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"], "faction_pool": ["union", "cda", "syndicat_capitole", "regie"],
"caps": (50, 300), "caps": (15, 100),
}, },
"eclaireur": { "eclaireur": {
"description": "Rôdeur solitaire des marais, discret et endurant", "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)}, "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"], "faction_pool": [None, "ecumeurs"],
"caps": (30, 200), "caps": (10, 80),
}, },
"docteur": { "docteur": {
"description": "Médecin de terrain, rare et précieux dans les terres désolées", "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)}, "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], "faction_pool": ["union", "consortium", None],
"caps": (100, 500), "caps": (40, 200),
}, },
"technicien": { "technicien": {
"description": "Bricoleur et réparateur, obsédé par les technologies de l'Ancien Monde", "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)}, "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], "faction_pool": ["consortium", "union", None],
"caps": (50, 400), "caps": (20, 150),
}, },
"raider_boss": { "raider_boss": {
"description": "Chef de bande, charismatique et dangereux", "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)}, "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], "faction_pool": ["ecumeurs", None],
"caps": (100, 1000), "caps": (30, 200),
}, },
"lieutenant": { "lieutenant": {
"description": "Bras droit d'un chef de faction, mieux équipé qu'un garde", "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)}, "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"], "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"],
"caps": (200, 800), "caps": (60, 300),
}, },
"chasseur_primes": { "chasseur_primes": {
"description": "Mercenaire spécialisé dans la traque et la capture", "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)}, "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"], "faction_pool": [None, "syndicat_capitole"],
"caps": (200, 800), "caps": (80, 350),
}, },
"passeur": { "passeur": {
"description": "Guide qui connaît les routes secrètes entre les zones", "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)}, "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"], "faction_pool": [None, "ecumeurs"],
"caps": (100, 600), "caps": (30, 180),
}, },
"scavenger": { "scavenger": {
"description": "Récupérateur de ferraille et de technologie pré-guerre", "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)}, "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], "faction_pool": [None],
"caps": (20, 200), "caps": (5, 70),
}, },
"chef_faction": { "chef_faction": {
"description": "Chef de faction — tier 0, hors simulation, décisions narratives LLM", "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)}, "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"], "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"],
"caps": (1000, 8000), "caps": (300, 1500),
}, },
"survivant_generique": { "survivant_generique": {
"description": "Habitant lambda des terres désolées, équilibré et débrouillard", "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)}, "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"], "faction_pool": [None, "union", "regie"],
"caps": (10, 200), "caps": (5, 60),
}, },
} }
+8 -4
View File
@@ -17,6 +17,7 @@ import random
import db import db
import actions as act_module import actions as act_module
import combat_engine import combat_engine
import sim_config
_DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") _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: if not profile:
return False 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 # Modificateurs selon l'état du PNJ
state = char.get("_pnj_state") or {} state = char.get("_pnj_state") or {}
stress = state.get("stress_level", 0) if isinstance(state, dict) else 0 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) 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_ratio = char.get("hp", 1) / max(char.get("max_hp", 1), 1)
hp_mod = 0.03 if hp_ratio < 0.5 else 0.0 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 return rng.random() < final_chance
+2 -1
View File
@@ -11,7 +11,8 @@ import character_creator as cc
import pnj_factory as factory import pnj_factory as factory
import db 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) # Tier 1 — ACTIFS SIM (15 PNJ, boucle survival + actions complète)
+58 -18
View File
@@ -1,50 +1,86 @@
""" """
Lanceur de simulation — Fallout Venice of Wasteland Lanceur de simulation — Fallout Venice of Wasteland
Usage : Usage :
python run.py # session 1, bot-speed python run.py # session 1, sim_001.json, bot-speed
SESSION_ID=1 python run.py SESSION_ID=2 python run.py # session 2, sim_002.json
TICK_SLEEP_SEC=2 python run.py # 1 tick toutes les 2 secondes 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 time, sys, os, argparse
import sys
import os
# Ajoute src/engine au path
sys.path.insert(0, os.path.dirname(__file__)) sys.path.insert(0, os.path.dirname(__file__))
import db import db
import sim_config as cfg
import tick as tick_engine 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) 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"] current_tick = session["current_tick"]
mode = session["mode"] db_mode = session["mode"]
status = session["status"] status = session["status"]
if status != "active": if status != "active":
print(f"Session {SESSION_ID} n'est pas active (status={status}). Abandon.") print(f"Session {SESSION_ID} n'est pas active (status={status}). Abandon.")
return return
# Mode : config.json prime sur la DB
effective_mode = active_mode or db_mode
print(f"=== Simulation Fallout : Venice of Wasteland ===") print(f"=== Simulation Fallout : Venice of Wasteland ===")
print(f"Session #{SESSION_ID}{session['name']}") 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(f"Seed globale : {session['seed_global']}")
print("=" * 50) print("=" * 50)
# Initialiser le world_state si vide
_init_world_state(SESSION_ID, day)
try: try:
while True: 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) db.update_session_tick(SESSION_ID, day, current_tick)
if TICK_SLEEP_SEC > 0: if tick_sleep > 0:
time.sleep(TICK_SLEEP_SEC) time.sleep(tick_sleep)
day, current_tick = tick_engine.next_tick(day, current_tick) day, current_tick = tick_engine.next_tick(day, current_tick)
# Réinit world_state au début de chaque nouveau jour
if current_tick == 0:
_init_world_state(SESSION_ID, day)
except KeyboardInterrupt: except KeyboardInterrupt:
print(f"\n[STOP] Simulation interrompue — Jour {day}, Tick {current_tick}h") print(f"\n[STOP] Simulation interrompue — Jour {day}, Tick {current_tick}h")
db.update_session_tick(SESSION_ID, day, current_tick) db.update_session_tick(SESSION_ID, day, current_tick)
@@ -52,4 +88,8 @@ def run():
if __name__ == "__main__": 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)
+77
View File
@@ -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.<mode>' é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
+35 -31
View File
@@ -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 db
import psycopg2.extras import psycopg2.extras
import sim_config
# Heures entre dégradations d'état (conditions normales, mode normal) # Tables 2D20 officielles (Manuel FR ch.5) — NE PAS MODIFIER, utiliser drain_speed pour tuner
HUNGER_TICKS = { _HUNGER_TICKS_BASE = {4: 1, 3: 4, 2: 8, 1: 16}
# de Repu(4) → Rassasié(3) après 1h _THIRST_TICKS_BASE = {3: 1, 2: 2, 1: 4}
4: 1, _SLEEP_TICKS_BASE = {3: 8, 2: 8, 1: 8}
# 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,
}
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 = { def _scaled_ticks(base: dict, speed: float) -> dict:
# de Reposé(3) → Fatigué(2) après 8h """Applique drain_speed : vitesse 2.0 = seuils divisés par 2 (dégrade 2x plus vite)."""
3: 8, if speed <= 0:
# de Fatigué(2) → Éreinté(1) après 8h speed = 1.0
2: 8, return {k: max(1, round(v / speed)) for k, v in base.items()}
# de Éreinté(1) → Épuisé(0) après 8h
1: 8,
} 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 # Noms lisibles
HUNGER_NAMES = {4: "repu", 3: "rassasié", 2: "petit_creux", 1: "faim", 0: "affamé"} 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. Met à jour faim/soif/sommeil/fatigue de chaque PNJ vivant.
Tue les PNJ dont les PV tombent à 0 ou en dessous. 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) characters = db.get_characters(session_id)
for char in characters: for char in characters:
@@ -100,7 +104,7 @@ def process_survival_tick(session_id: int, day: int, tick: int):
# --- Faim --- # --- Faim ---
new_hunger, new_hunger_ticks = _tick_stat( 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) # +1 Fatigue par jour si Affamé (tick 0 de chaque jour = tick 0)
if new_hunger == 0 and 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 --- # --- Soif ---
new_thirst, new_thirst_ticks = _tick_stat( 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 # Si déshydraté, +1 Fatigue toutes les 8h
if new_thirst == 0: if new_thirst == 0:
@@ -125,7 +129,7 @@ def process_survival_tick(session_id: int, day: int, tick: int):
# --- Sommeil --- # --- Sommeil ---
new_sleep, new_sleep_ticks = _tick_stat( 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 # Si Épuisé, +1 Fatigue toutes les 4h
if new_sleep == 0: 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 # 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. # à l'aube pour éviter la mort en cascade sur une seule nuit.
if tick == 0 and new_fatigue >= 2: 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) new_hp = max(0, char["hp"] - hp_loss)
else: else:
new_hp = char["hp"] new_hp = char["hp"]
+13 -8
View File
@@ -10,6 +10,7 @@ import db
import survival import survival
import actions import actions
import encounter import encounter
import sim_config
def is_daytime(tick: int) -> bool: 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. 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. Valeurs fines — 24 ticks = 1 jour, effets nets sur la journée.
""" """
# Consommation de base par tick (population divisée par 24) wcfg = sim_config.get("world")
pop = ws.get("pop_count", 0) 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)
# La nuit réduit la sécurité légèrement, le jour la restaure water_drain_pp = wcfg.get("water_drain_per_pop", 0.004)
security_delta = +0.2 if phase == "jour" else -0.3 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 base_water_drain = -(pop * water_drain_pp)
conflict_modifier = 0 if mode == "pacifique" else random.uniform(-0.5, 0.2) 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 { return {
"water": base_water_drain + (0.1 if phase == "jour" else 0), "water": base_water_drain + (0.1 if phase == "jour" else 0),