feat: encounters 4 tiers + fix actions roles

This commit is contained in:
Corback
2026-06-15 23:17:51 +00:00
parent 873106d5cb
commit 0a46655e36
5 changed files with 1039 additions and 2 deletions
+243
View File
@@ -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é"}
}
}
}
+5
View File
@@ -41,6 +41,11 @@ _ROLE_SKILL_BONUS: dict[str, dict[str, int]] = {
"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},
}
+84
View File
@@ -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")
+692
View File
@@ -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
+13
View File
@@ -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,