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
+108 -20
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_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,21 +16,55 @@
"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",
@@ -36,10 +73,61 @@
"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 }
}
}
}
+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",
"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),
},
}
+8 -4
View File
@@ -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
+2 -1
View File
@@ -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)
+56 -16
View File
@@ -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)
if not session:
print(f"Session {SESSION_ID} introuvable en DB.")
return
day = session["current_day"]
current_tick = session["current_tick"]
mode = session["mode"]
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)
+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 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"]
+12 -7
View File
@@ -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)
wcfg = sim_config.get("world")
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
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),