560 lines
22 KiB
Python
560 lines
22 KiB
Python
"""
|
|
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
|