feat: encounters 4 tiers + fix actions roles
This commit is contained in:
@@ -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é"}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user