From 0a46655e360ea5e81a8af4c872eb2941ba7a3208 Mon Sep 17 00:00:00 2001 From: Corback Date: Mon, 15 Jun 2026 23:17:51 +0000 Subject: [PATCH] feat: encounters 4 tiers + fix actions roles --- src/data/encounter_tables.json | 243 ++++++++++++ src/engine/actions.py | 9 +- src/engine/deploy_003.py | 84 ++++ src/engine/encounter.py | 692 +++++++++++++++++++++++++++++++++ src/engine/tick.py | 13 + 5 files changed, 1039 insertions(+), 2 deletions(-) create mode 100644 src/data/encounter_tables.json create mode 100644 src/engine/deploy_003.py create mode 100644 src/engine/encounter.py diff --git a/src/data/encounter_tables.json b/src/data/encounter_tables.json new file mode 100644 index 0000000..01f7fbb --- /dev/null +++ b/src/data/encounter_tables.json @@ -0,0 +1,243 @@ +{ + "_comment": "Tables d'encounters par zone. chance_per_tick = probabilité par tick. tier_weights = distribution des tiers d'encounter (lambda/group/actif/boss). Somme des weights doit = 1.0", + + "zone_profiles": { + "nola_cbd": { + "chance_per_tick": 0.12, + "tier_weights": {"lambda": 0.45, "group": 0.38, "actif": 0.15, "boss": 0.02}, + "flavor": "Gratte-ciels inondés, ruines industrielles, territoire Super Mutants" + }, + "pearl_river": { + "chance_per_tick": 0.09, + "tier_weights": {"lambda": 0.55, "group": 0.20, "actif": 0.22, "boss": 0.03}, + "flavor": "Bayou dense, territoire Écumeurs, créatures des marais" + }, + "la_paroisse": { + "chance_per_tick": 0.10, + "tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.25, "boss": 0.05}, + "flavor": "Zone radioactive, territoire Grand Krewe, goules partout" + }, + "donaldsonville": { + "chance_per_tick": 0.06, + "tier_weights": {"lambda": 0.60, "group": 0.28, "actif": 0.10, "boss": 0.02}, + "flavor": "Ville intermédiaire, sans faction, passage obligé" + }, + "oak_plantation": { + "chance_per_tick": 0.05, + "tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.28, "boss": 0.02}, + "flavor": "Plantation gardée, esclaves en fuite, patrouilles Dynaste" + }, + "baton_rouge": { + "chance_per_tick": 0.04, + "tier_weights": {"lambda": 0.55, "group": 0.25, "actif": 0.18, "boss": 0.02}, + "flavor": "Ville sous tension, présence Syndicat du Capitole" + }, + "nola_fleuve": { + "chance_per_tick": 0.05, + "tier_weights": {"lambda": 0.45, "group": 0.30, "actif": 0.22, "boss": 0.03}, + "flavor": "Port fluvial, passage de bateaux, CdA patrouille" + }, + "independance": { + "chance_per_tick": 0.02, + "tier_weights": {"lambda": 0.80, "group": 0.15, "actif": 0.04, "boss": 0.01}, + "flavor": "Zone protégée par l'Union, relativement sûre" + }, + "laplace": { + "chance_per_tick": 0.03, + "tier_weights": {"lambda": 0.65, "group": 0.25, "actif": 0.09, "boss": 0.01}, + "flavor": "Carrefour commercial, passage constant, relativement neutre" + }, + "nola_vieux_carre": { + "chance_per_tick": 0.025, + "tier_weights": {"lambda": 0.70, "group": 0.20, "actif": 0.09, "boss": 0.01}, + "flavor": "Zone tenue par la Régie, murée, patrouilles régulières" + } + }, + + "entity_pools": { + "lambda": [ + { + "key": "alligator_mutant", + "name": "Alligator mutant", + "description": "Reptile des bayous, irradié et agrandi, territorial et agressif", + "zones": ["pearl_river", "donaldsonville", "la_paroisse", "nola_cbd"], + "stats": {"hp": 14, "strength": 9, "agility": 5, "endurance": 8, "perception": 4, "luck": 3}, + "weapon": {"name": "Mâchoires", "damage_dice": 3, "damage_bonus": 1, "damage_type": "physical", "range": "short"}, + "armor_rating": 2, + "caps_loot": [0, 0], + "item_loot": ["cuir_brut", "viande_mutante"], + "xp": 3 + }, + { + "key": "goule_sauvage", + "name": "Goule sauvage", + "description": "Ancienne personne irradiée, perdue la raison, attaque tout ce qui bouge", + "zones": ["la_paroisse", "nola_cbd", "donaldsonville", "pearl_river"], + "stats": {"hp": 8, "strength": 6, "agility": 7, "endurance": 6, "perception": 3, "luck": 2}, + "weapon": {"name": "Griffes", "damage_dice": 2, "damage_bonus": 0, "damage_type": "physical", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 5], + "item_loot": ["lambeau_vetement", "cram"], + "xp": 1, + "immune_radiation": true + }, + { + "key": "meute_rats", + "name": "Meute de rats géants", + "description": "Rats des bayous, deux fois plus grands que la normale, attaquent en groupe", + "zones": ["nola_cbd", "donaldsonville", "baton_rouge", "pearl_river"], + "stats": {"hp": 5, "strength": 3, "agility": 8, "endurance": 4, "perception": 5, "luck": 4}, + "weapon": {"name": "Morsures multiples", "damage_dice": 1, "damage_bonus": 1, "damage_type": "physical", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 0], + "item_loot": ["rat_grille", "fourrure_rat"], + "xp": 1 + }, + { + "key": "essaim_guepes", + "name": "Essaim de guêpes mutantes", + "description": "Insectes irradiés, venin paralysant, nid dans les ruines", + "zones": ["pearl_river", "la_paroisse", "nola_cbd"], + "stats": {"hp": 6, "strength": 2, "agility": 9, "endurance": 3, "perception": 7, "luck": 5}, + "weapon": {"name": "Piqûres venimeuses", "damage_dice": 1, "damage_bonus": 0, "damage_type": "poison", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 0], + "item_loot": ["venin_guepe", "cire_mutante"], + "xp": 2, + "status_effect": {"type": "poison", "damage_per_tick": 1, "duration_ticks": 3} + }, + { + "key": "survivant_fou", + "name": "Survivant dérangé", + "description": "Humain ayant perdu la raison, attaque sans raison apparente", + "zones": ["donaldsonville", "nola_cbd", "baton_rouge", "pearl_river"], + "stats": {"hp": 7, "strength": 5, "agility": 6, "endurance": 5, "perception": 4, "luck": 3}, + "weapon": {"name": "Couteau rouillé", "damage_dice": 1, "damage_bonus": 0, "damage_type": "physical", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 30], + "item_loot": ["eau_sale", "cram", "lambeau_vetement"], + "xp": 1 + }, + { + "key": "chien_sauvage", + "name": "Chien des bayous", + "description": "Meute de chiens errants, maigres et agressifs, se défendent en groupe", + "zones": ["donaldsonville", "baton_rouge", "nola_vieux_carre", "laplace"], + "stats": {"hp": 6, "strength": 5, "agility": 8, "endurance": 5, "perception": 6, "luck": 3}, + "weapon": {"name": "Morsure", "damage_dice": 2, "damage_bonus": 0, "damage_type": "physical", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 0], + "item_loot": ["viande_chien", "fourrure_chien"], + "xp": 1 + } + ], + + "group": [ + { + "key": "bande_raiders", + "name": "Bande de Raiders", + "description": "Petit groupe de pillards organisés, cherchent à voler ou kidnapper", + "zones": ["nola_cbd", "donaldsonville", "pearl_river", "baton_rouge"], + "count_range": [2, 4], + "member_archetype": "pillard_errant", + "leader_archetype": "raider_boss", + "stats_base": {"hp": 10, "strength": 6, "agility": 6, "endurance": 6, "perception": 5, "luck": 4}, + "weapon": {"name": "Batte cloutée", "damage_dice": 2, "damage_bonus": 1, "damage_type": "physical", "range": "short"}, + "armor_rating": 1, + "caps_loot": [20, 150], + "item_loot": ["cram", "eau_sale", "batte_cloutee", "couteau_combat"], + "xp": 4, + "can_negotiate": true, + "negotiate_cost_caps": [30, 100] + }, + { + "key": "meute_super_mutants", + "name": "Meute de Super Mutants", + "description": "Groupe de Super Mutants sauvages, cherchent à dominer le territoire", + "zones": ["nola_cbd"], + "count_range": [2, 3], + "member_archetype": null, + "leader_archetype": null, + "stats_base": {"hp": 18, "strength": 11, "agility": 4, "endurance": 10, "perception": 4, "luck": 3}, + "weapon": {"name": "Massue renforcée", "damage_dice": 3, "damage_bonus": 2, "damage_type": "physical", "range": "short"}, + "armor_rating": 2, + "caps_loot": [0, 50], + "item_loot": ["ferraille", "cram", "eau_sale"], + "xp": 6, + "can_negotiate": false, + "immune_radiation": true, + "immune_poison": true + }, + { + "key": "pillards_organises", + "name": "Pillards organisés", + "description": "Groupe de pillards avec tactique basique, armés de fusils", + "zones": ["donaldsonville", "pearl_river", "baton_rouge", "nola_fleuve"], + "count_range": [2, 4], + "member_archetype": "pillard_errant", + "leader_archetype": "raider_boss", + "stats_base": {"hp": 11, "strength": 6, "agility": 6, "endurance": 6, "perception": 6, "luck": 4}, + "weapon": {"name": "Fusil calibre .22", "damage_dice": 2, "damage_bonus": 0, "damage_type": "physical", "range": "medium"}, + "armor_rating": 1, + "caps_loot": [30, 200], + "item_loot": ["fusil_calibre22", "cram", "jet", "eau_sale"], + "xp": 5, + "can_negotiate": true, + "negotiate_cost_caps": [50, 150] + }, + { + "key": "goules_en_meute", + "name": "Meute de goules sauvages", + "description": "Un groupe compact de goules perdues, attaquent ensemble", + "zones": ["la_paroisse", "nola_cbd", "donaldsonville"], + "count_range": [3, 6], + "member_archetype": null, + "leader_archetype": null, + "stats_base": {"hp": 7, "strength": 5, "agility": 7, "endurance": 6, "perception": 3, "luck": 2}, + "weapon": {"name": "Griffes", "damage_dice": 2, "damage_bonus": 0, "damage_type": "physical", "range": "short"}, + "armor_rating": 0, + "caps_loot": [0, 10], + "item_loot": ["lambeau_vetement"], + "xp": 3, + "can_negotiate": false, + "immune_radiation": true + } + ] + }, + + "orientation_reactions": { + "_comment": "Réaction par défaut selon le rôle du PNJ face à chaque tier d'encounter", + "garde": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat"}, + "eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite"}, + "marchand": {"lambda": "fuite", "group": "negociation","actif": "negociation","boss": "fuite"}, + "docteur": {"lambda": "fuite", "group": "fuite", "actif": "negociation","boss": "fuite"}, + "technicien": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"}, + "pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite"}, + "raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat"}, + "lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat"}, + "chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation","boss": "fuite"}, + "passeur": {"lambda": "evitement", "group": "evitement", "actif": "fuite", "boss": "fuite"}, + "scavenger": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"}, + "survivant_generique":{"lambda":"fuite", "group": "fuite", "actif": "negociation","boss": "fuite"} + }, + + "outcomes": { + "combat": { + "win": {"loot": true, "xp": true, "stress_delta": +2, "description": "Victoire au combat"}, + "loss": {"loot": false, "xp": false, "stress_delta": +5, "hp_loss_pct": 0.25, "description": "Défaite"}, + "draw": {"loot": false, "xp": true, "stress_delta": +3, "hp_loss_pct": 0.15, "description": "Combat indécis"} + }, + "fuite": { + "success": {"loot": false, "xp": false, "stress_delta": +1, "description": "Fuite réussie"}, + "failed": {"loot": false, "xp": false, "stress_delta": +3, "hp_loss_pct": 0.10, "description": "Fuite ratée, touché"} + }, + "evitement": { + "success": {"loot": false, "xp": true, "stress_delta": 0, "description": "Discrétion réussie"}, + "failed": {"loot": false, "xp": false, "stress_delta": +2, "description": "Repéré, doit fuir"} + }, + "negociation": { + "success": {"loot": false, "xp": true, "stress_delta": +1, "caps_cost": true, "description": "Négociation réussie"}, + "failed": {"loot": false, "xp": false, "stress_delta": +4, "hp_loss_pct": 0.15, "description": "Négociation échouée, attaqué"} + } + } +} diff --git a/src/engine/actions.py b/src/engine/actions.py index 1dde9ee..b99a512 100644 --- a/src/engine/actions.py +++ b/src/engine/actions.py @@ -40,8 +40,13 @@ _ROLE_SKILL_BONUS: dict[str, dict[str, int]] = { "eclaireur": {"arme_legere": 2, "survie": 1}, "marchand": {"troc": 2, "discours": 1}, "docteur": {"medecine": 2}, - "technicien": {}, - "survivant_generique":{"survie": 1}, + "technicien": {}, + "lieutenant": {"arme_legere": 2, "corps_a_corps": 1, "discours": 1}, + "chasseur_primes": {"arme_legere": 2, "survie": 1}, + "passeur": {"survie": 2, "athletisme": 1}, + "scavenger": {"survie": 1}, + "chef_faction": {"discours": 3, "corps_a_corps": 1}, + "survivant_generique": {"survie": 1}, } # --------------------------------------------------------------------------- diff --git a/src/engine/deploy_003.py b/src/engine/deploy_003.py new file mode 100644 index 0000000..b82716f --- /dev/null +++ b/src/engine/deploy_003.py @@ -0,0 +1,84 @@ +""" +Script de déploiement migration_003 + reset + init PNJ tiers. +Lance depuis /home/ubuntu/fallout-venice/src/engine sur Ampère. +Tunnel DB requis : ssh -i ~/vigile_backup -N -L 15432:localhost:5432 ubuntu@79.72.30.231 +""" +import sys, os +sys.path.insert(0, os.path.dirname(__file__)) + +import psycopg2 + +DB_ARGS = dict(host='localhost', port=15432, dbname='fallout', + user='fallout', password='VeniceOfWasteland2026!') + +def run_sql(cur, sql): + statements = [s.strip() for s in sql.split(';') if s.strip()] + for stmt in statements: + try: + cur.execute(stmt) + except Exception as e: + print(f" [WARN] {e} — stmt: {stmt[:80]}...") + +print("=== Deploy 003 : tiers PNJ ===\n") + +c = psycopg2.connect(**DB_ARGS) +c.autocommit = True +cur = c.cursor() + +# 1. Reset +print("1. Reset simulation...") +cur.execute("UPDATE characters SET is_alive=TRUE, hp=max_hp WHERE session_id=1") +print(f" PNJ ressuscités : {cur.rowcount}") +cur.execute("UPDATE sessions SET current_day=0, current_tick=0 WHERE id=1") +cur.execute("DELETE FROM pnj_state WHERE session_id=1") +cur.execute("DELETE FROM events WHERE session_id=1") + +# Supprimer les PNJ tier 1-3 existants (garder tier 0 = boss créés par fixtures) +cur.execute(""" + DELETE FROM characters + WHERE session_id=1 + AND name NOT IN ('Kingfish Robichaux','Maître Beaumont','Mme LaVeau', + 'Commissaire Delacroix','Directeur Tran') +""") +print(f" PNJ sim supprimés : {cur.rowcount}") +print(" Reset OK") + +# 2. Migration 003 +print("\n2. Migration 003...") +sql_path = os.path.join(os.path.dirname(__file__), '..', 'db', 'migration_003_pnj_tiers.sql') +with open(sql_path, encoding='utf-8') as f: + sql = f.read() +run_sql(cur, sql) +cur.execute("SELECT count(*) FROM characters WHERE session_id=1 AND tier=0") +boss_count = cur.fetchone()[0] +print(f" Boss tier=0 : {boss_count}") +cur.execute("SELECT column_name FROM information_schema.columns WHERE table_name='characters' AND column_name='tier'") +if cur.fetchone(): + print(" Colonne 'tier' : OK") +print(" Migration 003 OK") + +c.close() + +# 3. Init PNJ +print("\n3. Init PNJ (tier 1 + 2)...") +import init_pnj +init_pnj.main() + +# 4. Vérification finale +print("\n4. Vérification finale...") +c2 = psycopg2.connect(**DB_ARGS) +cur2 = c2.cursor() +cur2.execute("SELECT COALESCE(tier,1) as t, count(*) FROM characters WHERE session_id=1 AND is_alive=TRUE GROUP BY t ORDER BY t") +rows = cur2.fetchall() +labels = {0:'BOSS', 1:'ACTIFS SIM', 2:'PNJ+', 3:'PASSAGE'} +total = 0 +for t, n in rows: + print(f" Tier {t} ({labels.get(t,'?'):<12}) : {n} PNJ") + total += n +print(f" TOTAL : {total} PNJ vivants") + +cur2.execute("SELECT count(*) FROM inventory WHERE session_id=1") +print(f" Items inventaire : {cur2.fetchone()[0]}") +c2.close() + +print("\n✅ Déploiement terminé — tu peux lancer : python run.py") diff --git a/src/engine/encounter.py b/src/engine/encounter.py new file mode 100644 index 0000000..c10a0fa --- /dev/null +++ b/src/engine/encounter.py @@ -0,0 +1,692 @@ +""" +Système d'Encounters — Fallout : Venice of Wasteland + +Pipeline par tick : + 1. Pour chaque PNJ actif (tier 1-3), roll chance d'encounter selon zone + 2. Tier de l'encounter : lambda / group / actif / boss + 3. Génération de l'entité ennemie selon tier et zone + 4. Réaction du PNJ selon son orientation (rôle + faction) + 5. Résolution mécanique (combat / fuite / évitement / négociation) + 6. Narration (stub LLM-ready, retourne template texte pour l'instant) + 7. Log event + màj DB +""" + +import json +import os +import random +import db +import actions as act_module + +_DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + +# --------------------------------------------------------------------------- +# Chargement des tables +# --------------------------------------------------------------------------- + +def _load_tables() -> dict: + with open(os.path.join(_DATA_DIR, "encounter_tables.json"), encoding="utf-8") as f: + return json.load(f) + + +# --------------------------------------------------------------------------- +# Zones adjacentes (pour recherche actif/boss dans une zone voisine) +# --------------------------------------------------------------------------- + +_ZONE_ADJACENCY: dict[str, list[str]] = { + "independance": ["laplace", "donaldsonville"], + "laplace": ["independance", "baton_rouge", "donaldsonville"], + "baton_rouge": ["laplace", "oak_plantation", "donaldsonville"], + "oak_plantation": ["baton_rouge", "la_paroisse", "donaldsonville"], + "la_paroisse": ["oak_plantation", "nola_cbd", "pearl_river"], + "donaldsonville": ["independance", "laplace", "baton_rouge", "nola_vieux_carre"], + "nola_vieux_carre": ["donaldsonville", "nola_cbd", "nola_fleuve"], + "nola_cbd": ["nola_vieux_carre", "nola_fleuve", "la_paroisse"], + "nola_fleuve": ["nola_cbd", "nola_vieux_carre", "pearl_river"], + "pearl_river": ["nola_fleuve", "la_paroisse"], +} + + +# --------------------------------------------------------------------------- +# Hostilité entre factions (via DB faction_relations) +# --------------------------------------------------------------------------- + +_faction_relation_cache: dict[tuple, int] = {} + +def _get_faction_relation(session_id: int, faction_a: str, faction_b: str) -> int: + """Retourne le score de relation entre deux factions (-100 à +100).""" + key = (faction_a, faction_b) + if key in _faction_relation_cache: + return _faction_relation_cache[key] + a, b = sorted([faction_a, faction_b]) + with db.cursor() as cur: + cur.execute( + "SELECT relation_score FROM faction_relations WHERE session_id=%s AND faction_a=%s AND faction_b=%s", + (session_id, a, b) + ) + row = cur.fetchone() + score = row["relation_score"] if row else 0 + _faction_relation_cache[key] = score + return score + + +def _are_factions_hostile(session_id: int, f1: str | None, f2: str | None, threshold: int = -20) -> bool: + """True si les deux factions ont une relation score ≤ threshold.""" + if not f1 or not f2 or f1 == f2: + return False + return _get_faction_relation(session_id, f1, f2) <= threshold + + +# --------------------------------------------------------------------------- +# Déclenchement +# --------------------------------------------------------------------------- + +def should_trigger_encounter(char: dict, world_state: dict, rng: random.Random) -> bool: + """True si une encounter se déclenche ce tick pour ce PNJ.""" + tables = _load_tables() + zone = char.get("location_slug") + if not zone: + return False + profile = tables["zone_profiles"].get(zone) + if not profile: + return False + + base_chance = profile["chance_per_tick"] + + # 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 + return rng.random() < final_chance + + +# --------------------------------------------------------------------------- +# Détermination du tier +# --------------------------------------------------------------------------- + +def determine_tier(zone: str, rng: random.Random) -> str: + """Retourne 'lambda' | 'group' | 'actif' | 'boss' selon les poids de la zone.""" + tables = _load_tables() + profile = tables["zone_profiles"].get(zone, {}) + weights = profile.get("tier_weights", {"lambda": 1.0, "group": 0.0, "actif": 0.0, "boss": 0.0}) + tiers = list(weights.keys()) + probs = list(weights.values()) + return rng.choices(tiers, weights=probs, k=1)[0] + + +# --------------------------------------------------------------------------- +# Génération de l'entité ennemie +# --------------------------------------------------------------------------- + +def _pick_entity_from_pool(pool: list[dict], zone: str, rng: random.Random) -> dict | None: + """Choisit une entité depuis une pool filtrée par zone.""" + compatible = [e for e in pool if not e.get("zones") or zone in e.get("zones", [])] + if not compatible: + compatible = pool + return rng.choice(compatible) if compatible else None + + +def generate_entity_lambda(zone: str, rng: random.Random) -> dict | None: + tables = _load_tables() + pool = tables["entity_pools"]["lambda"] + entity = _pick_entity_from_pool(pool, zone, rng) + if not entity: + return None + e = dict(entity) + e["tier"] = "lambda" + e["encounter_id"] = f"enc_lambda_{rng.randint(1000,9999)}" + # HP légèrement variée + base_hp = e["stats"]["hp"] + e["hp"] = base_hp + rng.randint(-2, 2) + e["max_hp"] = e["hp"] + return e + + +def generate_entity_group(zone: str, rng: random.Random) -> dict | None: + tables = _load_tables() + pool = tables["entity_pools"]["group"] + entity = _pick_entity_from_pool(pool, zone, rng) + if not entity: + return None + e = dict(entity) + e["tier"] = "group" + e["encounter_id"] = f"enc_group_{rng.randint(1000,9999)}" + count_range = e.get("count_range", [2, 3]) + e["count"] = rng.randint(*count_range) + # HP cumulatif : représente la force totale du groupe + base_hp = e["stats_base"]["hp"] + e["hp"] = (base_hp + rng.randint(-1, 2)) * e["count"] + e["max_hp"] = e["hp"] + e["strength"] = e["stats_base"]["strength"] + e["agility"] = e["stats_base"]["agility"] + return e + + +def generate_entity_actif(char: dict, session_id: int, rng: random.Random) -> dict | None: + """ + Cherche un PNJ tier 1-2 hostile en DB dans la même zone ou une zone adjacente. + Si aucun trouvé, crée un ennemi de faction adverse temporaire. + """ + char_faction = char.get("faction_slug") + char_zone = char.get("location_slug") + if not char_zone: + return None + + search_zones = [char_zone] + _ZONE_ADJACENCY.get(char_zone, []) + + with db.cursor() as cur: + cur.execute( + """ + SELECT * FROM characters + WHERE session_id = %s + AND is_alive = TRUE + AND COALESCE(tier, 1) BETWEEN 1 AND 2 + AND location_slug = ANY(%s) + AND faction_slug IS NOT NULL + AND faction_slug != %s + """, + (session_id, search_zones, char_faction or "") + ) + candidates = [dict(r) for r in cur.fetchall()] + + # Filtrer sur l'hostilité réelle + hostile = [ + c for c in candidates + if _are_factions_hostile(session_id, char_faction, c.get("faction_slug")) + ] + + if hostile: + enemy = rng.choice(hostile) + return { + "tier": "actif", + "encounter_id": f"enc_actif_{enemy['id']}", + "key": "pnj_faction_hostile", + "name": enemy["name"], + "description": f"PNJ de faction hostile ({enemy.get('faction_slug')}) rencontré", + "hp": enemy["hp"], + "max_hp": enemy["max_hp"], + "strength": enemy["strength"], + "agility": enemy["agility"], + "endurance": enemy["endurance"], + "perception": enemy["perception"], + "luck": enemy["luck"], + "armor_rating": 1, + "weapon": { + "name": "Arme de faction", + "damage_dice": 2, "damage_bonus": 0, + "damage_type": "physical", "range": "medium" + }, + "faction_slug": enemy.get("faction_slug"), + "db_id": enemy["id"], + "caps_loot": [enemy.get("caps", 0) // 4, enemy.get("caps", 0) // 2], + "item_loot": [], + "xp": 4, + "can_negotiate": enemy.get("role") in ("marchand", "docteur", "technicien"), + } + + # Fallback : ennemi générique de faction adverse + hostile_factions = { + "union": ["ecumeurs", "dynaste_oak", "syndicat_capitole"], + "ecumeurs": ["union", "cda", "regie"], + "cda": ["ecumeurs", "dynaste_oak"], + "dynaste_oak": ["union", "grand_krewe", "cda"], + "grand_krewe": ["dynaste_oak", "syndicat_capitole"], + "consortium": [], + "regie": ["ecumeurs"], + "syndicat_capitole": ["union", "grand_krewe"], + } + enemy_factions = hostile_factions.get(char_faction or "", []) + if not enemy_factions: + return None + + ef = rng.choice(enemy_factions) + return { + "tier": "actif", + "encounter_id": f"enc_actif_generic_{rng.randint(1000,9999)}", + "key": "agent_faction_hostile", + "name": f"Agent {ef.replace('_',' ').title()}", + "description": f"Agent de la faction {ef} en patrouille", + "hp": 10 + rng.randint(-2, 3), + "max_hp": 12, + "strength": 6, "agility": 6, "endurance": 6, + "perception": 5, "luck": 4, + "armor_rating": 1, + "weapon": { + "name": "Pistolet 10mm", + "damage_dice": 2, "damage_bonus": 0, + "damage_type": "physical", "range": "medium" + }, + "faction_slug": ef, + "db_id": None, + "caps_loot": [30, 120], + "item_loot": ["cram", "eau_sale"], + "xp": 3, + "can_negotiate": False, + } + + +def generate_entity_boss(char: dict, session_id: int, rng: random.Random) -> dict | None: + """ + Cherche un boss tier=0 dont la faction est hostile au PNJ. + Rare — représente une confrontation narrative majeure. + """ + char_faction = char.get("faction_slug") + if not char_faction: + return None + + with db.cursor() as cur: + cur.execute( + "SELECT * FROM characters WHERE session_id=%s AND tier=0 AND is_alive=TRUE AND faction_slug != %s", + (session_id, char_faction) + ) + bosses = [dict(r) for r in cur.fetchall()] + + hostile_bosses = [ + b for b in bosses + if _are_factions_hostile(session_id, char_faction, b.get("faction_slug"), threshold=-40) + ] + if not hostile_bosses: + return None + + boss = rng.choice(hostile_bosses) + return { + "tier": "boss", + "encounter_id": f"enc_boss_{boss['id']}", + "key": "chef_faction_hostile", + "name": boss["name"], + "description": f"Chef de faction hostile ({boss.get('faction_slug')}) — rencontre majeure", + "hp": boss["hp"], + "max_hp": boss["max_hp"], + "strength": boss["strength"], + "agility": boss["agility"], + "endurance": boss["endurance"], + "perception": boss["perception"], + "luck": boss["luck"], + "armor_rating": 2, + "weapon": { + "name": "Arme du chef", + "damage_dice": 3, "damage_bonus": 1, + "damage_type": "physical", "range": "medium" + }, + "faction_slug": boss.get("faction_slug"), + "db_id": boss["id"], + "caps_loot": [100, 500], + "item_loot": ["stimpak", "eau_purifiee"], + "xp": 10, + "can_negotiate": True, + } + + +def generate_entity(tier: str, char: dict, session_id: int, rng: random.Random) -> dict | None: + if tier == "lambda": + return generate_entity_lambda(char.get("location_slug", ""), rng) + elif tier == "group": + return generate_entity_group(char.get("location_slug", ""), rng) + elif tier == "actif": + return generate_entity_actif(char, session_id, rng) + elif tier == "boss": + entity = generate_entity_boss(char, session_id, rng) + if entity is None: + return generate_entity_lambda(char.get("location_slug", ""), rng) + return entity + return None + + +# --------------------------------------------------------------------------- +# Orientation du PNJ → réaction +# --------------------------------------------------------------------------- + +def determine_reaction(char: dict, entity: dict, rng: random.Random) -> str: + """ + Détermine la réaction du PNJ à l'encounter selon son rôle et la situation. + """ + tables = _load_tables() + role = char.get("role", "survivant_generique") + tier = entity.get("tier", "lambda") + hp_ratio = char.get("hp", 1) / max(char.get("max_hp", 1), 1) + + reactions = tables["orientation_reactions"] + base_reaction = reactions.get(role, reactions["survivant_generique"]).get(tier, "fuite") + + # HP critique → force la fuite sauf si garde/raider_boss + if hp_ratio < 0.3 and base_reaction == "combat" and role not in ("garde", "raider_boss", "lieutenant"): + return "fuite" + + # Group ne peut pas être évité (trop nombreux) + if tier == "group" and base_reaction == "evitement": + return "fuite" + + # Boss → toujours proposer fuite si la négociation est possible, sinon fuite forcée + if tier == "boss" and base_reaction == "fuite_ou_combat": + return "combat" if hp_ratio > 0.7 else "fuite" + + # Entité non négociable + if not entity.get("can_negotiate", False) and base_reaction == "negociation": + return "combat" if role in ("garde", "raider_boss", "lieutenant", "pillard") else "fuite" + + return base_reaction + + +# --------------------------------------------------------------------------- +# Résolution mécanique +# --------------------------------------------------------------------------- + +def _combat_roll(char: dict, entity: dict, rng: random.Random) -> str: + """ + Jet de combat simplifié PNJ vs entité. + Retourne 'win' | 'loss' | 'draw'. + """ + random.seed(rng.randint(1, 9999)) + + # Jet attaquant (PNJ) + char_sr = char.get("strength", 5) + 2 # rang compétence par défaut + char_roll = [random.randint(1, 20) for _ in range(2)] + char_hits = sum(2 if r == 1 else 1 for r in char_roll if r <= char_sr) + + # Jet défenseur (entité) + ent_agi = entity.get("agility", 5) + ent_def = 2 if ent_agi >= 9 else 1 + char_net = max(0, char_hits - ent_def) + + # Jet entité + ent_str = entity.get("strength", 6) + ent_roll = [random.randint(1, 20) for _ in range(2)] + ent_hits = sum(2 if r == 1 else 1 for r in ent_roll if r <= ent_str + 2) + char_def = 2 if char.get("agility", 5) >= 9 else 1 + ent_net = max(0, ent_hits - char_def) + + if char_net > ent_net: + return "win" + elif ent_net > char_net: + return "loss" + else: + return "draw" + + +def _fuite_roll(char: dict, entity: dict, rng: random.Random) -> str: + """Jet de fuite basé sur AGI du PNJ vs AGI de l'entité.""" + random.seed(rng.randint(1, 9999)) + char_agi = char.get("agility", 5) + ent_agi = entity.get("agility", 5) + roll = random.randint(1, 20) + return "success" if roll <= char_agi + 2 - max(0, ent_agi - 5) else "failed" + + +def _evitement_roll(char: dict, rng: random.Random) -> str: + """Jet de discrétion basé sur AGI + PER du PNJ.""" + random.seed(rng.randint(1, 9999)) + char_agi = char.get("agility", 5) + char_per = char.get("perception", 5) + roll = random.randint(1, 20) + return "success" if roll <= (char_agi + char_per) // 2 + 2 else "failed" + + +def _negociation_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]: + """ + Jet de négociation (CHA + troc). + Retourne (résultat, caps_dépensés). + """ + random.seed(rng.randint(1, 9999)) + char_cha = char.get("charisma", 5) + roll = random.randint(1, 20) + success = roll <= char_cha + 2 + + cost_range = entity.get("negotiate_cost_caps", [20, 80]) + cost = rng.randint(*cost_range) if success else 0 + return ("success" if success else "failed"), cost + + +def _apply_damage(char_id: int, damage: int, session_id: int): + """Applique les dégâts à un PNJ en DB.""" + char = db.get_character_by_id(char_id) + if not char: + return + new_hp = max(0, char["hp"] - damage) + killed = new_hp <= 0 + db.update_character(char_id, hp=new_hp, is_alive=not killed) + + +def _loot_entity(char_id: int, session_id: int, entity: dict, rng: random.Random) -> list[str]: + """Ajoute le loot de l'entité à l'inventaire du PNJ.""" + looted = [] + caps_range = entity.get("caps_loot", [0, 0]) + caps_gain = rng.randint(*caps_range) + if caps_gain > 0: + char = db.get_character_by_id(char_id) + if char: + db.update_character(char_id, caps=char["caps"] + caps_gain) + looted.append(f"{caps_gain} caps") + + for item_slug in entity.get("item_loot", []): + if rng.random() < 0.6: + with db.cursor() as cur: + cur.execute( + """ + INSERT INTO inventory (character_id, session_id, item_slug, item_name, item_type, quantity) + VALUES (%s, %s, %s, %s, 'misc', 1) + ON CONFLICT (character_id, session_id, item_slug) DO UPDATE SET quantity = inventory.quantity + 1 + """, + (char_id, session_id, item_slug, item_slug.replace("_", " ").title()) + ) + looted.append(item_slug) + + return looted + + +# --------------------------------------------------------------------------- +# Narration (stub LLM-ready) +# --------------------------------------------------------------------------- + +def generate_narrative(char: dict, entity: dict, reaction: str, outcome: str) -> str: + """ + Génère une narration de l'encounter. + Actuellement : template texte. Sera remplacé par appel LLM quand activé. + """ + char_name = char.get("name", "???") + entity_name = entity.get("name", "???") + zone = char.get("location_slug", "zone inconnue").replace("_", " ") + tier = entity.get("tier", "lambda") + + templates = { + ("combat", "win"): f"{char_name} croise {entity_name} dans {zone} et l'élimine en combat.", + ("combat", "loss"): f"{char_name} affronte {entity_name} dans {zone} et reçoit des coups sévères.", + ("combat", "draw"): f"{char_name} et {entity_name} s'affrontent dans {zone} — issue indécise, les deux blessés.", + ("fuite", "success"): f"{char_name} détecte {entity_name} dans {zone} et prend la fuite avant d'être repéré.", + ("fuite", "failed"): f"{char_name} tente de fuir {entity_name} dans {zone} mais est rattrapé et touché.", + ("evitement", "success"): f"{char_name} repère {entity_name} dans {zone} et se fond dans l'ombre, invisible.", + ("evitement", "failed"): f"{char_name} essaie de passer inaperçu face à {entity_name} dans {zone} — repéré, doit fuir.", + ("negociation", "success"): f"{char_name} parle à {entity_name} dans {zone} et parvient à un accord.", + ("negociation", "failed"): f"{char_name} tente de négocier avec {entity_name} dans {zone} — l'ennemi refuse et attaque.", + } + + # Flavor supplémentaire pour les tiers élevés + boss_flavor = " [CONFRONTATION MAJEURE]" if tier == "boss" else "" + key = (reaction, outcome) + return templates.get(key, f"{char_name} rencontre {entity_name} dans {zone} ({reaction}/{outcome}).") + boss_flavor + + +# --------------------------------------------------------------------------- +# Résolution complète d'un encounter +# --------------------------------------------------------------------------- + +def resolve_encounter( + char: dict, + entity: dict, + session_id: int, + day: int, + tick: int, + rng: random.Random, +) -> dict: + """ + Orchestre la résolution complète d'un encounter. + Retourne un dict résultat avec narrative, outcome, effets appliqués. + """ + char_id = char["id"] + reaction = determine_reaction(char, entity, rng) + outcome = "none" + hp_delta = 0 + caps_delta = 0 + loot_gained = [] + + tables = _load_tables() + outcomes_cfg = tables["outcomes"] + + if reaction == "combat": + result = _combat_roll(char, entity, rng) + outcome = result + cfg = outcomes_cfg["combat"][result] + + if result in ("win", "draw"): + # Dégâts sur l'entité (narrative seulement, entité éphémère pour lambda/group) + if result == "win" and cfg.get("loot"): + loot_gained = _loot_entity(char_id, session_id, entity, rng) + # Dégâts sur le PNJ si draw ou boss + if result in ("draw", "loss") or entity.get("tier") == "boss": + weapon = entity.get("weapon", {}) + n_dice = weapon.get("damage_dice", 1) + weapon.get("damage_bonus", 0) + dmg_roll = act_module.roll_cd(n_dice) + hp_delta = -dmg_roll["damage"] + _apply_damage(char_id, dmg_roll["damage"], session_id) + + elif result == "loss": + weapon = entity.get("weapon", {}) + n_dice = weapon.get("damage_dice", 2) + weapon.get("damage_bonus", 0) + dmg_roll = act_module.roll_cd(n_dice) + # Dégâts amplifiés en cas de défaite + full_dmg = dmg_roll["damage"] + 1 + hp_delta = -full_dmg + _apply_damage(char_id, full_dmg, session_id) + + # Boss combat : l'entité (PNJ en DB) reçoit aussi des dégâts si victoire + if entity.get("db_id") and result == "win": + w_char = None + inv = [] + with db.cursor() as cur: + cur.execute("SELECT * FROM inventory WHERE character_id=%s AND session_id=%s", (char_id, session_id)) + inv = [dict(r) for r in cur.fetchall()] + weapon_char = act_module._get_equipped_weapon(inv) + n_dice = (weapon_char["damage_dice"] if weapon_char else 1) + (weapon_char.get("damage_bonus", 0) if weapon_char else 0) + dmg_roll = act_module.roll_cd(n_dice) + _apply_damage(entity["db_id"], dmg_roll["damage"], session_id) + + elif reaction == "fuite": + result = _fuite_roll(char, entity, rng) + outcome = result + cfg = outcomes_cfg["fuite"][result] + if result == "failed": + hp_delta = -int(char.get("max_hp", 10) * 0.10) + _apply_damage(char_id, abs(hp_delta), session_id) + + elif reaction == "evitement": + result = _evitement_roll(char, rng) + outcome = result + cfg = outcomes_cfg["evitement"][result] + + elif reaction == "negociation": + result, cost = _negociation_roll(char, entity, rng) + outcome = result + cfg = outcomes_cfg["negociation"][result] + if result == "success" and cost > 0: + current_caps = char.get("caps", 0) + actual_cost = min(cost, current_caps) + caps_delta = -actual_cost + db.update_character(char_id, caps=current_caps - actual_cost) + elif result == "failed": + # Négociation ratée → combat forcé + fight = _combat_roll(char, entity, rng) + if fight == "loss": + dmg = entity.get("weapon", {}).get("damage_dice", 2) + hp_delta = -dmg + _apply_damage(char_id, dmg, session_id) + + narrative = generate_narrative(char, entity, reaction, outcome) + + stress_delta = outcomes_cfg.get(reaction, {}).get(outcome, {}).get("stress_delta", 0) if reaction in outcomes_cfg else 0 + + # Mise à jour stress en pnj_state + prev_state = db.get_pnj_state(session_id, char_id, day) + if prev_state: + new_stress = max(0, min(10, prev_state["stress_level"] + stress_delta)) + db.upsert_pnj_state( + session_id, char_id, day, tick, + mood=prev_state["mood"], + stress_level=new_stress, + seed_current=prev_state["seed_current"], + last_actions=prev_state["last_actions"], + ) + + return { + "char_id": char_id, + "char_name": char.get("name"), + "entity_key": entity.get("key"), + "entity_name": entity.get("name"), + "entity_tier": entity.get("tier"), + "reaction": reaction, + "outcome": outcome, + "hp_delta": hp_delta, + "caps_delta": caps_delta, + "loot": loot_gained, + "narrative": narrative, + "stress_delta": stress_delta, + } + + +# --------------------------------------------------------------------------- +# Point d'entrée par tick +# --------------------------------------------------------------------------- + +def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int | None = None) -> list[dict]: + """ + Traite les encounters pour tous les PNJ actifs (tier 1-3) ce tick. + Appelé depuis tick.py. + """ + characters = db.get_characters(session_id, min_tier=1) + world_states = db.get_world_state(session_id, day) + world_map = {ws["location_slug"]: ws for ws in world_states} + + results = [] + for char in characters: + seed = (rng_seed or 0) + char["id"] + day * 1000 + tick * 100 + rng = random.Random(seed) + + zone = char.get("location_slug") + ws = world_map.get(zone, {}) + + if not should_trigger_encounter(char, ws, rng): + continue + + tier = determine_tier(zone, rng) + entity = generate_entity(tier, char, session_id, rng) + + if not entity: + continue + + result = resolve_encounter(char, entity, session_id, day, tick, rng) + results.append(result) + + db.log_event( + session_id = session_id, + day = day, + tick = tick, + event_type = f"encounter_{tier}", + character_id = char["id"], + location_slug = zone, + description = result["narrative"], + mechanical_effect = { + "entity": entity.get("key"), + "entity_name":entity.get("name"), + "reaction": result["reaction"], + "outcome": result["outcome"], + "hp_delta": result["hp_delta"], + "caps_delta": result["caps_delta"], + "loot": result["loot"], + } + ) + + return results diff --git a/src/engine/tick.py b/src/engine/tick.py index d96ea23..9f711ca 100644 --- a/src/engine/tick.py +++ b/src/engine/tick.py @@ -9,6 +9,7 @@ from config import TICKS_PER_DAY, DAY_START_TICK, NIGHT_START_TICK, SESSION_ID import db import survival import actions +import encounter def is_daytime(tick: int) -> bool: @@ -81,6 +82,18 @@ def process_tick(session_id: int, day: int, tick: int, mode: str): # -- Brique 5 : Actions PNJ ------------------------------------------ actions.process_actions_tick(session_id, day, tick, mode=mode, phase=phase) + # -- Encounters aléatoires ------------------------------------------- + enc_results = encounter.process_encounters_tick( + session_id, day, tick, + rng_seed=(day * 10000 + tick * 100) + ) + if enc_results: + enc_summary = ", ".join( + f"{r['char_name']} vs {r['entity_name']} ({r['reaction']}/{r['outcome']})" + for r in enc_results + ) + print(f" ⚔ Encounters: {enc_summary}") + # -- Log du tick ----------------------------------------------------- db.log_event( session_id=session_id,