From dd985c868c770fc3c7459a4de4b905340fbc5828 Mon Sep 17 00:00:00 2001 From: Corback Date: Tue, 16 Jun 2026 08:10:05 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20zones=20s=C3=BBres=20+=20commerce=20PNJ?= =?UTF-8?q?-=C3=A0-PNJ=20+=20raids=20factions?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit encounter_tables.json: safe_zones: [independance, nola_vieux_carre, baton_rouge, laplace] Zone safe: zone_type=safe, tier_weights sans lambda/group (actif+boss seulement) raid_chance: probabilité séparée par zone (0.4-0.8%) indépendante du roll normal entity_pools.raid_faction: commando éclaireur + milice faction hostile urban_trade_roles: rôles autorisés à faire du commerce en ville orientation_reactions: ajout réaction 'trade' pour marchands/doc/tech/passeur/scavenger outcomes.trade: success (+caps, stress-1) / failed (aucun effet) encounter.py: _is_safe_zone(): vérifie si zone est dans safe_zones should_trigger_raid(): roll séparé par zone pour déclencher un raid generate_entity_trade(): cherche un PNJ compatible dans la même zone pour échange generate_entity_raid(): génère un commando de faction ennemie avec contexte faction _trade_roll(): jet CHA+LCK pour conclure une vente, caps_gain 10-60 resolve_encounter(): branche 'trade' → caps gagnés si succès process_encounters_tick(): - Roll de raid une fois par zone sûre (pas par PNJ) - PNJ en zone raidée → encounter_raid, skip encounter normal ce tick - Zone safe + rôle commerçant → priorité generate_entity_trade sur actif - Fallback boss en zone safe → None (pas de lambda) Logique résultante: Pearl River (dangerous): alligators, goules, raiders → inchangé Independance (safe): un marchand croise un autre PNJ → vente possible Raid rare sur independance: commando Union/Ecumeurs → combat ou fuite Bêtes en ville: impossible --- src/data/encounter_tables.json | 122 +++++++++++++------ src/engine/encounter.py | 210 +++++++++++++++++++++++++++++++-- 2 files changed, 286 insertions(+), 46 deletions(-) diff --git a/src/data/encounter_tables.json b/src/data/encounter_tables.json index 1a3163a..b6f8526 100644 --- a/src/data/encounter_tables.json +++ b/src/data/encounter_tables.json @@ -1,56 +1,72 @@ { - "_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", + "_comment": "Tables d'encounters par zone. chance_per_tick = probabilité par tick. tier_weights = distribution des tiers. zone_type: safe = pas de bêtes/raiders, seulement PNJ-à-PNJ et raids rares. raid_chance = probabilité séparée d'un raid faction (ignorée en zone dangereuse).", + + "safe_zones": ["independance", "nola_vieux_carre", "baton_rouge", "laplace"], "zone_profiles": { + "independance": { + "zone_type": "safe", + "chance_per_tick": 0.15, + "raid_chance": 0.004, + "tier_weights": {"actif": 0.92, "boss": 0.08}, + "flavor": "Zone protégée par l'Union, relativement sûre. Marché actif, patrouilles régulières." + }, + "nola_vieux_carre": { + "zone_type": "safe", + "chance_per_tick": 0.12, + "raid_chance": 0.005, + "tier_weights": {"actif": 0.94, "boss": 0.06}, + "flavor": "Zone tenue par la Régie, murée, patrouilles régulières. Quartier commerçant fortifié." + }, + "baton_rouge": { + "zone_type": "safe", + "chance_per_tick": 0.10, + "raid_chance": 0.008, + "tier_weights": {"actif": 0.90, "boss": 0.10}, + "flavor": "Ville sous tension Syndicat du Capitole. Sûre mais surveillance constante." + }, + "laplace": { + "zone_type": "safe", + "chance_per_tick": 0.13, + "raid_chance": 0.006, + "tier_weights": {"actif": 0.93, "boss": 0.07}, + "flavor": "Carrefour commercial, passage constant, relativement neutre. Beaucoup d'échanges." + }, "nola_cbd": { + "zone_type": "dangerous", "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": { + "zone_type": "dangerous", "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": { + "zone_type": "dangerous", "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": { + "zone_type": "dangerous", "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é" + "flavor": "Ville intermédiaire, sans faction, passage obligé. Pas de protection réelle." }, "oak_plantation": { + "zone_type": "dangerous", "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": { + "zone_type": "dangerous", "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" } }, @@ -202,23 +218,55 @@ "can_negotiate": false, "immune_radiation": true } + ], + + "raid_faction": [ + { + "key": "raid_eclaireurs", + "name": "Commando éclaireur", + "description": "Petit groupe armé d'une faction ennemie qui teste les défenses de la zone", + "count_range": [2, 4], + "stats_base": {"hp": 12, "strength": 7, "agility": 7, "endurance": 6, "perception": 6, "luck": 4}, + "weapon": {"name": "Fusil automatique", "damage_dice": 2, "damage_bonus": 1, "damage_type": "physical", "range": "medium"}, + "armor_rating": 1, + "caps_loot": [40, 180], + "item_loot": ["cram", "stimpak", "eau_purifiee"], + "xp": 6, + "can_negotiate": false + }, + { + "key": "raid_milice", + "name": "Milice faction hostile", + "description": "Force paramilitaire d'une faction cherchant à prendre le contrôle du quartier", + "count_range": [3, 6], + "stats_base": {"hp": 14, "strength": 7, "agility": 6, "endurance": 7, "perception": 6, "luck": 4}, + "weapon": {"name": "Fusil de combat", "damage_dice": 3, "damage_bonus": 0, "damage_type": "physical", "range": "medium"}, + "armor_rating": 2, + "caps_loot": [60, 250], + "item_loot": ["stimpak", "eau_purifiee", "couteau_combat"], + "xp": 8, + "can_negotiate": true, + "negotiate_cost_caps": [100, 400] + } ] }, + "urban_trade_roles": ["marchand", "docteur", "technicien", "passeur", "scavenger"], + "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"} + "_comment": "Réaction par défaut selon le rôle du PNJ face à chaque tier d'encounter. 'trade' = interaction commerciale PNJ-à-PNJ en zone safe.", + "garde": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"}, + "eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite", "raid": "fuite"}, + "marchand": {"lambda": "fuite", "group": "negociation","actif": "trade", "boss": "fuite", "raid": "fuite"}, + "docteur": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"}, + "technicien": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"}, + "pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite", "raid": "fuite"}, + "raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat", "raid": "combat"}, + "lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"}, + "chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation", "boss": "fuite", "raid": "fuite"}, + "passeur": {"lambda": "evitement", "group": "evitement", "actif": "trade", "boss": "fuite", "raid": "fuite"}, + "scavenger": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"}, + "survivant_generique":{"lambda": "fuite", "group": "fuite", "actif": "negociation", "boss": "fuite", "raid": "fuite"} }, "outcomes": { @@ -238,6 +286,10 @@ "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é"} + }, + "trade": { + "success": {"loot": false, "xp": true, "stress_delta": -1, "caps_gain": true, "description": "Vente conclue"}, + "failed": {"loot": false, "xp": false, "stress_delta": 0, "description": "Pas d'accord, chacun repart"} } } } diff --git a/src/engine/encounter.py b/src/engine/encounter.py index 9a7442c..3f41186 100644 --- a/src/engine/encounter.py +++ b/src/engine/encounter.py @@ -29,6 +29,10 @@ def _load_tables() -> dict: with open(os.path.join(_DATA_DIR, "encounter_tables.json"), encoding="utf-8") as f: return json.load(f) +def _is_safe_zone(zone: str) -> bool: + tables = _load_tables() + return zone in tables.get("safe_zones", []) + # --------------------------------------------------------------------------- # Zones adjacentes (pour recherche actif/boss dans une zone voisine) @@ -116,15 +120,26 @@ def should_trigger_encounter(char: dict, world_state: dict, rng: random.Random) # --------------------------------------------------------------------------- def determine_tier(zone: str, rng: random.Random) -> str: - """Retourne 'lambda' | 'group' | 'actif' | 'boss' selon les poids de la zone.""" + """Retourne 'lambda' | 'group' | 'actif' | 'boss' | 'raid' selon le type de zone.""" tables = _load_tables() profile = tables["zone_profiles"].get(zone, {}) + + # Zone sûre : seuls actif et boss sont possibles via tier_weights + # Un raid faction peut se déclencher séparément (voir process_encounters_tick) 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] +def should_trigger_raid(zone: str, rng: random.Random) -> bool: + """True si un raid faction se déclenche ce tick en zone sûre (roll séparé, rare).""" + tables = _load_tables() + profile = tables["zone_profiles"].get(zone, {}) + raid_chance = profile.get("raid_chance", 0.0) + return rng.random() < raid_chance + + # --------------------------------------------------------------------------- # Génération de l'entité ennemie # --------------------------------------------------------------------------- @@ -328,18 +343,123 @@ def generate_entity_boss(char: dict, session_id: int, rng: random.Random) -> dic } +def generate_entity_trade(char: dict, session_id: int, rng: random.Random) -> dict | None: + """ + Cherche un PNJ commerçant dans la même zone pour un échange. + Retourne None si aucun PNJ compatible trouvé. + """ + tables = _load_tables() + trade_roles = tables.get("urban_trade_roles", ["marchand", "docteur", "technicien"]) + zone = char.get("location_slug") + char_id = char["id"] + + with db.cursor() as cur: + cur.execute( + """ + SELECT * FROM characters + WHERE session_id = %s AND is_alive = TRUE + AND location_slug = %s AND id != %s + AND role = ANY(%s) + ORDER BY RANDOM() LIMIT 1 + """, + (session_id, zone, char_id, trade_roles) + ) + row = cur.fetchone() + + if not row: + return None + partner = dict(row) + return { + "tier": "actif", + "encounter_id": f"enc_trade_{partner['id']}", + "key": "commerce_urbain", + "name": partner["name"], + "description": f"{partner['role']} disponible à l'échange", + "hp": partner["hp"], + "max_hp": partner["max_hp"], + "strength": partner.get("strength", 5), + "agility": partner.get("agility", 5), + "endurance": partner.get("endurance", 5), + "perception": partner.get("perception", 5), + "luck": partner.get("luck", 5), + "armor_rating": 0, + "weapon": None, + "faction_slug": partner.get("faction_slug"), + "db_id": partner["id"], + "partner_role": partner["role"], + "caps_loot": [0, 0], + "item_loot": [], + "xp": 1, + "can_negotiate": True, + "is_trade": True, + } + + +def generate_entity_raid(char: dict, session_id: int, rng: random.Random) -> dict | None: + """ + Génère un raid de faction ennemie en zone sûre. + Choisit dans entity_pools.raid_faction et assigne une faction hostile. + """ + tables = _load_tables() + pool = tables["entity_pools"].get("raid_faction", []) + if not pool: + return None + + char_faction = char.get("faction_slug") + entity = rng.choice(pool) + e = dict(entity) + + # Cherche une faction hostile pour contextualiser le raid + hostile_factions = { + "union": ["ecumeurs", "dynaste_oak"], + "ecumeurs": ["union", "cda"], + "cda": ["ecumeurs", "dynaste_oak"], + "dynaste_oak": ["union", "grand_krewe", "cda"], + "grand_krewe": ["dynaste_oak", "syndicat_capitole"], + "syndicat_capitole": ["union", "grand_krewe"], + "regie": ["ecumeurs"], + "consortium": [], + } + enemies = hostile_factions.get(char_faction or "", []) + raiding_faction = rng.choice(enemies) if enemies else "ecumeurs" + + e["tier"] = "raid" + e["encounter_id"] = f"enc_raid_{rng.randint(1000, 9999)}" + e["faction_slug"] = raiding_faction + e["name"] = f"Commando {raiding_faction.replace('_', ' ').title()}" + e["description"] = f"Raid armé de la faction {raiding_faction} en zone sûre" + count = rng.randint(*e.get("count_range", [2, 3])) + e["hp"] = e["stats_base"]["hp"] * count + e["max_hp"] = e["hp"] + e["strength"] = e["stats_base"]["strength"] + e["agility"] = e["stats_base"]["agility"] + e["db_id"] = None + return e + + def generate_entity(tier: str, char: dict, session_id: int, rng: random.Random) -> dict | None: + zone = char.get("location_slug", "") if tier == "lambda": - return generate_entity_lambda(char.get("location_slug", ""), rng) + return generate_entity_lambda(zone, rng) elif tier == "group": - return generate_entity_group(char.get("location_slug", ""), rng) + return generate_entity_group(zone, rng) elif tier == "actif": + # En zone sûre : priorité au commerce si le rôle s'y prête + tables = _load_tables() + trade_roles = tables.get("urban_trade_roles", []) + if _is_safe_zone(zone) and char.get("role") in trade_roles: + trade = generate_entity_trade(char, session_id, rng) + if trade: + return trade 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) + # En zone sûre, pas de fallback sur lambda + return None if _is_safe_zone(zone) else generate_entity_lambda(zone, rng) return entity + elif tier == "raid": + return generate_entity_raid(char, session_id, rng) return None @@ -432,6 +552,21 @@ def _evitement_roll(char: dict, rng: random.Random) -> str: return "success" if roll <= (char_agi + char_per) // 2 + 2 else "failed" +def _trade_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]: + """ + Jet de commerce entre deux PNJ en zone sûre. + Retourne (résultat, caps_gagnés). + Succès si CHA + LCK du vendeur > seuil aléatoire. + """ + random.seed(rng.randint(1, 9999)) + char_cha = char.get("charisma", 5) + char_lck = char.get("luck", 5) + roll = random.randint(1, 20) + success = roll <= (char_cha + char_lck) // 2 + 2 + caps_gain = rng.randint(10, 60) if success else 0 + return ("success" if success else "failed"), caps_gain + + def _negociation_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]: """ Jet de négociation (CHA + troc). @@ -498,6 +633,8 @@ def generate_narrative(char: dict, entity: dict, reaction: str, outcome: str) -> zone = char.get("location_slug", "zone inconnue").replace("_", " ") tier = entity.get("tier", "lambda") + partner_role = entity.get("partner_role", "") + raid_faction = entity.get("faction_slug", "") if entity.get("tier") == "raid" else "" 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.", @@ -508,6 +645,10 @@ def generate_narrative(char: dict, entity: dict, reaction: str, outcome: str) -> ("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.", + ("trade", "success"): f"{char_name} croise {entity_name} ({partner_role}) à {zone} et conclut une vente.", + ("trade", "failed"): f"{char_name} aborde {entity_name} à {zone} pour un échange — pas d'accord cette fois.", + ("combat", "raid_repousse"): f"RAID [{raid_faction}] — {char_name} aide à repousser l'attaque sur {zone}.", + ("fuite", "raid"): f"RAID [{raid_faction}] sur {zone} — {char_name} se met à l'abri.", } # Flavor supplémentaire pour les tiers élevés @@ -605,6 +746,15 @@ def resolve_encounter( outcome = result cfg = outcomes_cfg["evitement"][result] + elif reaction == "trade": + result, caps_gain = _trade_roll(char, entity, rng) + outcome = result + cfg = outcomes_cfg["trade"][result] + if result == "success" and caps_gain > 0: + current_caps = char.get("caps", 0) + db.update_character(char_id, caps=current_caps + caps_gain) + caps_delta = caps_gain + elif reaction == "negociation": result, cost = _negociation_roll(char, entity, rng) outcome = result @@ -668,6 +818,21 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int world_map = {ws["location_slug"]: ws for ws in world_states} results = [] + # Zones sûres où un raid peut frapper ce tick (roll une fois par zone, pas par PNJ) + raided_zones: set[str] = set() + safe_zone_chars: dict[str, list] = {} # zone → liste de PNJ présents + + for char in characters: + z = char.get("location_slug") + if z and _is_safe_zone(z): + safe_zone_chars.setdefault(z, []).append(char) + + for zone, zone_chars in safe_zone_chars.items(): + zone_seed = (rng_seed or 0) + hash(zone) % 10000 + day * 1000 + tick + zone_rng = random.Random(zone_seed) + if should_trigger_raid(zone, zone_rng): + raided_zones.add(zone) + for char in characters: seed = (rng_seed or 0) + char["id"] + day * 1000 + tick * 100 rng = random.Random(seed) @@ -675,6 +840,29 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int zone = char.get("location_slug") ws = world_map.get(zone, {}) + # --- Raid en zone sûre (event indépendant du roll normal) --- + if zone in raided_zones: + entity = generate_entity_raid(char, session_id, rng) + if entity: + 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="encounter_raid", + actor_id=char["id"], + location_slug=zone, + description=result["narrative"], + mechanical_effect={ + "entity": entity.get("key"), + "entity_name": entity.get("name"), + "faction": entity.get("faction_slug"), + "reaction": result["reaction"], + "outcome": result["outcome"], + "hp_delta": result["hp_delta"], + } + ) + continue # Pas d'autre encounter ce tick si raid + if not should_trigger_encounter(char, ws, rng): continue @@ -696,13 +884,13 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int 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"], + "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"], } )