""" Brique 5 — Actions PNJ Décision IA basée sur priorités + résolution mécanique 2D20. Appelé chaque tick depuis tick.py après survival.process_survival_tick(). Règles (Résumé Écran MJ + Résumé des Règles FR) : Test : 2d20, réussite si dé ≤ Attribut + rang_compétence (1 = crit = 2 réussites) Difficulté : nombre de réussites requises (= défense cible pour attaque) Attaque CàC: FOR + Corps à corps | Distance légère: AGI + Arme légère Distance lourde: END + Armes lourdes | Énergie: PER + Arme à énergie Dés combat : 1→1dmg, 2→2dmg, 3-4→0, 5-6→1dmg+effet Déplacement: 1 zone (action mineure) ou 2 zones sprint (capitale) Encombrement >carry: pas de sprint, -1 init, FOR/AGI difficulté+1 """ import random import db import inventory as inv_module import combat_engine # --------------------------------------------------------------------------- # Rang de compétence par défaut (sans table skills complète en DB) # Seuil de réussite = attribut + rang # --------------------------------------------------------------------------- _DEFAULT_SKILL_RANK: dict[str, int] = { "corps_a_corps": 2, "arme_legere": 2, "armes_lourdes": 2, "arme_energie": 1, "discours": 2, "troc": 2, "survie": 2, "medecine": 1, "athletisme": 2, } _ROLE_SKILL_BONUS: dict[str, dict[str, int]] = { "pillard": {"corps_a_corps": 1, "arme_legere": 1}, "raider_boss": {"corps_a_corps": 2, "discours": 1}, "garde": {"arme_legere": 1, "corps_a_corps": 1}, "eclaireur": {"arme_legere": 2, "survie": 1}, "marchand": {"troc": 2, "discours": 1}, "docteur": {"medecine": 2}, "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}, } # --------------------------------------------------------------------------- # Tables de loot par action de survie # Chance de succès = food_level ou water_level de la zone (0-100) # divisé par un facteur de difficulté (plus la zone est riche, plus c'est facile) # # Format : (item_slug, item_name, item_type, weight, hp_heal, properties) # TODO : enrichir avec animaux/cadavres/types de zone # --------------------------------------------------------------------------- _FORAGE_LOOT: list[tuple] = [ # (slug, nom, type, poids, hp_heal, props) ("cram", "Cram", "food", 0.5, 5, {}), ("igname_sauvage","Igname sauvage", "food", 0.3, 3, {}), ("champignon", "Champignons des marais","food", 0.2, 2, {"cooked": False}), ("rat_grille", "Rat grillé", "food", 0.4, 4, {}), ("nuka_cola", "Nuka-Cola", "drink", 0.5, 2, {"thirst_gain": 1}), ("cram", "Cram", "food", 0.5, 5, {}), # double poids pour fréquence ] _WATER_LOOT: list[tuple] = [ ("eau_sale", "Eau sale", "drink", 0.5, 0, {"irradiated": True, "rad_on_drink": 1}), ("eau_sale", "Eau sale", "drink", 0.5, 0, {"irradiated": True, "rad_on_drink": 1}), ("eau_purifiee", "Eau purifiée", "drink", 0.5, 0, {}), ("nuka_cola", "Nuka-Cola", "drink", 0.5, 2, {"thirst_gain": 1}), ] # Seuils de chance de succès selon le niveau de ressource de la zone def _forage_chance(food_level: float) -> float: """0-100 → probabilité de trouver quelque chose (0.05 à 0.70)""" return max(0.05, min(0.70, food_level / 100 * 0.65 + 0.05)) def _water_chance(water_level: float) -> float: """0-100 → probabilité de trouver de l'eau (0.10 à 0.80)""" return max(0.10, min(0.80, water_level / 100 * 0.70 + 0.10)) # Paires de factions hostiles l'une envers l'autre _HOSTILE_PAIRS: set[frozenset] = { frozenset({"ecumeurs", "union"}), frozenset({"ecumeurs", "cda"}), frozenset({"ecumeurs", "regie"}), frozenset({"ecumeurs", "consortium"}), frozenset({"ecumeurs", "grand_krewe"}), } # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _are_hostile(char_a: dict, char_b: dict) -> bool: f1 = char_a.get("faction_slug") f2 = char_b.get("faction_slug") if not f1 or not f2 or f1 == f2: return False return frozenset({f1, f2}) in _HOSTILE_PAIRS def _skill_rank(char: dict, skill: str) -> int: role = char.get("role", "survivant_generique") base = _DEFAULT_SKILL_RANK.get(skill, 1) bonus = _ROLE_SKILL_BONUS.get(role, {}).get(skill, 0) return base + bonus def roll_2d20(attribute: int, skill_rank: int, n_dice: int = 2) -> dict: """ Lance n_dice d20. Réussite si dé ≤ attribute + skill_rank. Résultat 1 = réussite critique (compte double). Résultat 20 = complication. """ sr = attribute + skill_rank rolls = [random.randint(1, 20) for _ in range(n_dice)] successes = sum(2 if r == 1 else 1 for r in rolls if r <= sr) complications = sum(1 for r in rolls if r == 20) return { "rolls": rolls, "sr": sr, "successes": successes, "complications": complications, } def roll_cd(n_dice: int) -> dict: """ Lance n_dice dés de combat (CD, d6). 1 → 1 dégât | 2 → 2 dégâts | 3-4 → 0 | 5-6 → 1 dégât + 1 effet """ damage = 0 effects = 0 rolls = [] for _ in range(max(1, n_dice)): r = random.randint(1, 6) rolls.append(r) if r == 1: damage += 1 elif r == 2: damage += 2 elif r in (5, 6): damage += 1 effects += 1 return {"rolls": rolls, "damage": damage, "effects": effects} def _get_equipped_weapon(inventory: list[dict]) -> dict | None: """Retourne l'arme équipée, ou None (mains nues).""" for item in inventory: if item.get("is_equipped") and item.get("item_type") in ( "weapon_melee", "weapon_ranged", "weapon_energy" ): return item return None # --------------------------------------------------------------------------- # Résolution mécanique # --------------------------------------------------------------------------- def resolve_attack(attacker: dict, defender: dict, weapon: dict | None, session_id: int, day: int, tick: int) -> dict: """ Résout une attaque selon les règles 2D20. Modifie la DB (HP défenseur, is_alive) et logue l'événement. Retourne un résumé dict. """ # Attribut + compétence selon type d'arme if weapon is None or weapon.get("item_type") == "weapon_melee": attr_val = attacker["strength"] skill = "corps_a_corps" weapon_name = weapon["item_name"] if weapon else "mains nues" elif weapon.get("item_type") == "weapon_energy": attr_val = attacker["perception"] skill = "arme_energie" weapon_name = weapon["item_name"] else: attr_val = attacker["agility"] skill = "arme_legere" weapon_name = weapon["item_name"] rank = _skill_rank(attacker, skill) difficulty = 2 if defender["agility"] >= 9 else 1 # Manuel FR : AGI≥9→2, sinon→1 test = roll_2d20(attr_val, rank) hit = test["successes"] >= difficulty result: dict = { "hit": hit, "test": test, "damage": 0, "effects": 0, "weapon": weapon["item_slug"] if weapon else "mains_nues", "killed": False, } if hit: n_cd = weapon.get("damage_dice", 1) if weapon else 1 melee_bonus = attacker.get("melee_bonus_cd", 0) if skill == "corps_a_corps" else 0 cd = roll_cd(n_cd + melee_bonus) result["damage"] = cd["damage"] result["effects"] = cd["effects"] result["cd_rolls"] = cd["rolls"] new_hp = max(0, defender["hp"] - result["damage"]) db.update_character(defender["id"], hp=new_hp) if new_hp == 0: db.update_character(defender["id"], is_alive=False) result["killed"] = True suffix = " [MORT]" if result["killed"] else f" -> {new_hp}/{defender['max_hp']} PV" desc = ( f"{attacker['name']} touche {defender['name']} " f"({weapon_name}) : -{result['damage']} PV{suffix}" ) else: desc = ( f"{attacker['name']} attaque {defender['name']} ({weapon_name}) — " f"RATÉ (SR={test['sr']}, dés={test['rolls']})" ) db.log_event( session_id=session_id, day=day, tick=tick, event_type="combat", description=desc, actor_id=attacker["id"], location_slug=attacker.get("location_slug"), mechanical_effect=result, ) return result # --------------------------------------------------------------------------- # Décision IA # --------------------------------------------------------------------------- def _decide_action(char: dict, all_chars: list[dict], states: dict, inventories: dict, world_map: dict, rng: random.Random, phase: str, mode: str) -> dict: """ Retourne un dict {"action": str, "target_id": int|None, "description": str, ...}. Priorités : 1. Se soigner (PV ≤ 25% + stimpak dispo) 2. Attaquer ennemi dans même zone (si mode ≠ pacifique) 3. Manger (faim critique + nourriture dispo) 4. Boire (soif critique + eau dispo) 5. Dormir (épuisé + nuit) 6. Commerce (marchand proche, 15% chance) 7. Se déplacer (30% chance) 8. Idle """ char_id = char["id"] state = states.get(char_id) or {} # last_actions est un dict survival (hunger/thirst/sleep/fatigue) écrit par survival.py sv = state.get("last_actions") or {} if isinstance(sv, list): sv = {} inventory = inventories.get(char_id, []) fatigue = sv.get("fatigue", 0) hunger = sv.get("hunger", 4) thirst = sv.get("thirst", 3) hp_ratio = char["hp"] / max(1, char["max_hp"]) location = char.get("location_slug") sleep = sv.get("sleep", 3) # 1. Soins urgents if hp_ratio <= 0.25: stim = next((i for i in inventory if i["item_slug"] == "stimpak" and i["quantity"] >= 1), None) if stim: return {"action": "heal_self", "target_id": None, "description": f"{char['name']} se soigne (PV critiques: {char['hp']}/{char['max_hp']})"} # 2. Combat if mode != "pacifique": enemies = [ c for c in all_chars if c["id"] != char_id and c.get("location_slug") == location and c.get("is_alive", True) and _are_hostile(char, c) ] if enemies: target = rng.choice(enemies) return {"action": "attack", "target_id": target["id"], "description": f"{char['name']} attaque {target['name']}"} # 3. Nourriture critique (hunger ≤ 1 = Faim ou Affamé) if hunger <= 1: food = next((i for i in inventory if i["item_type"] == "food" and i["quantity"] >= 1), None) if food: return {"action": "eat", "target_id": None, "description": f"{char['name']} mange {food['item_name']} (faim critique)"} return {"action": "forage", "target_id": None, "description": f"{char['name']} cherche à manger"} # 4. Eau critique (thirst ≤ 1 = Soif ou Déshydraté) if thirst <= 1: drink = next((i for i in inventory if i["item_type"] == "drink" and i["quantity"] >= 1), None) if drink: return {"action": "drink", "target_id": None, "description": f"{char['name']} boit {drink['item_name']} (soif critique)"} return {"action": "find_water", "target_id": None, "description": f"{char['name']} cherche de l'eau"} # 5. Sommeil si épuisé la nuit (sleep=0 → Épuisé, ou fatigue élevée) if (sleep <= 0 or fatigue >= 3) and phase == "nuit": return {"action": "sleep", "target_id": None, "description": f"{char['name']} s'endort (fatigue={fatigue})"} # 6. Commerce (15%) if rng.random() < 0.15: merchants = [ c for c in all_chars if c["id"] != char_id and c.get("location_slug") == location and c.get("is_alive", True) and c.get("role") == "marchand" ] if merchants and char.get("caps", 0) > 10: m = rng.choice(merchants) return {"action": "trade", "target_id": m["id"], "description": f"{char['name']} commerce avec {m['name']}"} # 7. Déplacement (30%) locations = [loc for loc in world_map if loc != location] if locations and rng.random() < 0.30: dest = rng.choice(locations) return {"action": "move", "target_id": None, "description": f"{char['name']} se déplace vers {dest}", "destination": dest} # 8. Idle return {"action": "idle", "target_id": None, "description": f"{char['name']} en veille"} def _resolve_forage(char: dict, session_id: int, day: int, tick: int, world_map: dict, rng: random.Random | None) -> None: """Tente de trouver de la nourriture selon le food_level de la zone.""" import survival as sv_module rng = rng or random.Random() loc = char.get("location_slug", "") ws = world_map.get(loc, {}) food_lvl = ws.get("food_level", 20) chance = _forage_chance(food_lvl) if rng.random() < chance: item = rng.choice(_FORAGE_LOOT) slug, name, itype, weight, hp_heal, props = item db.add_item(char["id"], session_id, slug, name, itype, quantity=1, weight=weight, hp_heal=hp_heal, properties=props) # Consommer immédiatement si c'est de la nourriture if itype == "food": sv_module.feed_character(session_id, char["id"], day, tick, "normal") db.remove_item(char["id"], session_id, slug, 1) desc = f"{char['name']} trouve {name} en fouillant ({loc}, food={food_lvl:.0f})" effect = {"found": slug, "food_level": food_lvl, "chance": round(chance, 2)} else: desc = f"{char['name']} ne trouve rien à manger ({loc}, food={food_lvl:.0f})" effect = {"found": None, "food_level": food_lvl, "chance": round(chance, 2)} db.log_event(session_id=session_id, day=day, tick=tick, event_type="fouille_nourriture", description=desc, actor_id=char["id"], location_slug=loc, mechanical_effect=effect) def _resolve_find_water(char: dict, session_id: int, day: int, tick: int, world_map: dict, rng: random.Random | None) -> None: """Tente de trouver de l'eau selon le water_level de la zone.""" import survival as sv_module rng = rng or random.Random() loc = char.get("location_slug", "") ws = world_map.get(loc, {}) water_lvl = ws.get("water_level", 20) chance = _water_chance(water_lvl) if rng.random() < chance: item = rng.choice(_WATER_LOOT) slug, name, itype, weight, hp_heal, props = item db.add_item(char["id"], session_id, slug, name, itype, quantity=1, weight=weight, hp_heal=hp_heal, properties=props) # Consommer immédiatement sv_module.hydrate_character(session_id, char["id"], day, tick, "purified" if slug == "eau_purifiee" else "normal") db.remove_item(char["id"], session_id, slug, 1) desc = f"{char['name']} trouve {name} ({loc}, water={water_lvl:.0f})" effect = {"found": slug, "water_level": water_lvl, "chance": round(chance, 2)} else: desc = f"{char['name']} ne trouve pas d'eau ({loc}, water={water_lvl:.0f})" effect = {"found": None, "water_level": water_lvl, "chance": round(chance, 2)} db.log_event(session_id=session_id, day=day, tick=tick, event_type="fouille_eau", description=desc, actor_id=char["id"], location_slug=loc, mechanical_effect=effect) def _execute_action(char: dict, decision: dict, chars_map: dict, inventories: dict, session_id: int, day: int, tick: int) -> None: """Exécute l'action décidée et met à jour la DB.""" action = decision["action"] char_id = char["id"] inv = inventories.get(char_id, []) if action == "heal_self": stim = next((i for i in inv if i["item_slug"] == "stimpak" and i["quantity"] >= 1), None) if stim: heal = stim.get("hp_heal", 4) new_hp = min(char["max_hp"], char["hp"] + heal) db.update_character(char_id, hp=new_hp) db.remove_item(char_id, session_id, "stimpak", 1) db.log_event( session_id=session_id, day=day, tick=tick, event_type="soin", description=f"{char['name']} utilise un Stimpak (+{heal} PV → {new_hp}/{char['max_hp']})", actor_id=char_id, location_slug=char.get("location_slug"), mechanical_effect={"heal": heal, "hp_apres": new_hp}, ) elif action == "attack": target = chars_map.get(decision["target_id"]) if target and target.get("is_alive", True): weapon = _get_equipped_weapon(inv) combat_engine.resolve_attack(char, target, weapon, session_id, day, tick) elif action == "eat": food = next((i for i in inv if i["item_type"] == "food" and i["quantity"] >= 1), None) if food: inv_module.consume_food(session_id, char_id, day, tick, food["item_slug"]) elif action == "drink": drink = next((i for i in inv if i["item_type"] == "drink" and i["quantity"] >= 1), None) if drink: inv_module.consume_drink(session_id, char_id, day, tick, drink["item_slug"]) elif action == "move": dest = decision.get("destination") if dest: db.update_character(char_id, location_slug=dest) db.log_event( session_id=session_id, day=day, tick=tick, event_type="deplacement", description=decision["description"], actor_id=char_id, location_slug=dest, mechanical_effect={"from": char.get("location_slug"), "to": dest}, ) elif action == "trade": target = chars_map.get(decision["target_id"]) if target: # TODO B6/B7 : croiser inventaire vendeur + caps acheteur pour vrai échange amount = min(char.get("caps", 0) // 4, 50) if amount > 0: db.update_character(char_id, caps=char["caps"] - amount) db.update_character(target["id"], caps=target.get("caps", 0) + amount) db.log_event( session_id=session_id, day=day, tick=tick, event_type="commerce", description=f"{char['name']} achète à {target['name']} ({amount} ¢)", actor_id=char_id, location_slug=char.get("location_slug"), mechanical_effect={"caps": amount, "vendeur_id": target["id"]}, ) elif action == "sleep": import survival as sv_module sv_module.rest_character(session_id, char_id, day, tick, hours=1) db.log_event( session_id=session_id, day=day, tick=tick, event_type="survie_action", description=decision["description"], actor_id=char_id, location_slug=char.get("location_slug"), ) elif action == "forage": _resolve_forage(char, session_id, day, tick, world_map=decision.get("_world_map", {}), rng=decision.get("_rng")) elif action == "find_water": _resolve_find_water(char, session_id, day, tick, world_map=decision.get("_world_map", {}), rng=decision.get("_rng")) # idle : pas de log # --------------------------------------------------------------------------- # Point d'entrée principal # --------------------------------------------------------------------------- def process_actions_tick(session_id: int, day: int, tick: int, mode: str, phase: str) -> None: """ Brique 5 — appelé depuis tick.py après survival.process_survival_tick(). Charge tous les PNJ vivants, décide et exécute leurs actions. Trie par initiative (PER + AGI) descendant. """ characters = db.get_characters(session_id) if not characters: return world_states = db.get_world_state(session_id, day) world_map = {ws["location_slug"]: ws for ws in world_states} # Chargement batch états + inventaires states: dict[int, dict] = {} inventories: dict[int, list] = {} for char in characters: state = db.get_pnj_state(session_id, char["id"], day) states[char["id"]] = state or {} inventories[char["id"]] = db.get_inventory(char["id"], session_id) # Ordre d'initiative (PER + AGI, décroissant) ordered = sorted(characters, key=lambda c: c["perception"] + c["agility"], reverse=True) chars_map: dict[int, dict] = {c["id"]: c for c in characters} for char in ordered: if not char.get("is_alive", True): continue seed = ( states.get(char["id"], {}).get("seed_current") or char["seed_base"] + day * 100 + tick ) rng = random.Random(seed) decision = _decide_action( char, list(chars_map.values()), states, inventories, world_map, rng, phase, mode ) # Injecter world_map et rng pour les actions de fouille decision["_world_map"] = world_map decision["_rng"] = rng _execute_action(char, decision, chars_map, inventories, session_id, day, tick) # Mise à jour locale de chars_map si le PNJ est mort (évite une requête DB par PNJ) if decision["action"] == "attack" and decision.get("target_id"): target = chars_map.get(decision["target_id"]) if target: updated_target = db.get_character_by_id(decision["target_id"]) if updated_target: chars_map[decision["target_id"]] = updated_target