feat: zones sûres + commerce PNJ-à-PNJ + raids factions

encounter_tables.json:
  safe_zones: [independance, nola_vieux_carre, baton_rouge, laplace]
  Zone safe: zone_type=safe, tier_weights sans lambda/group (actif+boss seulement)
  raid_chance: probabilité séparée par zone (0.4-0.8%) indépendante du roll normal
  entity_pools.raid_faction: commando éclaireur + milice faction hostile
  urban_trade_roles: rôles autorisés à faire du commerce en ville
  orientation_reactions: ajout réaction 'trade' pour marchands/doc/tech/passeur/scavenger
  outcomes.trade: success (+caps, stress-1) / failed (aucun effet)

encounter.py:
  _is_safe_zone(): vérifie si zone est dans safe_zones
  should_trigger_raid(): roll séparé par zone pour déclencher un raid
  generate_entity_trade(): cherche un PNJ compatible dans la même zone pour échange
  generate_entity_raid(): génère un commando de faction ennemie avec contexte faction
  _trade_roll(): jet CHA+LCK pour conclure une vente, caps_gain 10-60
  resolve_encounter(): branche 'trade' → caps gagnés si succès
  process_encounters_tick():
    - Roll de raid une fois par zone sûre (pas par PNJ)
    - PNJ en zone raidée → encounter_raid, skip encounter normal ce tick
    - Zone safe + rôle commerçant → priorité generate_entity_trade sur actif
    - Fallback boss en zone safe → None (pas de lambda)

Logique résultante:
  Pearl River (dangerous): alligators, goules, raiders → inchangé
  Independance (safe): un marchand croise un autre PNJ → vente possible
  Raid rare sur independance: commando Union/Ecumeurs → combat ou fuite
  Bêtes en ville: impossible
This commit is contained in:
Corback
2026-06-16 08:10:05 +00:00
parent 1bf0f921b6
commit dd985c868c
2 changed files with 286 additions and 46 deletions
+87 -35
View File
@@ -1,56 +1,72 @@
{
"_comment": "Tables d'encounters par zone. chance_per_tick = probabilité par tick. tier_weights = distribution des tiers d'encounter (lambda/group/actif/boss). Somme des weights doit = 1.0",
"_comment": "Tables d'encounters par zone. chance_per_tick = probabilité par tick. tier_weights = distribution des tiers. zone_type: safe = pas de bêtes/raiders, seulement PNJ-à-PNJ et raids rares. raid_chance = probabilité séparée d'un raid faction (ignorée en zone dangereuse).",
"safe_zones": ["independance", "nola_vieux_carre", "baton_rouge", "laplace"],
"zone_profiles": {
"independance": {
"zone_type": "safe",
"chance_per_tick": 0.15,
"raid_chance": 0.004,
"tier_weights": {"actif": 0.92, "boss": 0.08},
"flavor": "Zone protégée par l'Union, relativement sûre. Marché actif, patrouilles régulières."
},
"nola_vieux_carre": {
"zone_type": "safe",
"chance_per_tick": 0.12,
"raid_chance": 0.005,
"tier_weights": {"actif": 0.94, "boss": 0.06},
"flavor": "Zone tenue par la Régie, murée, patrouilles régulières. Quartier commerçant fortifié."
},
"baton_rouge": {
"zone_type": "safe",
"chance_per_tick": 0.10,
"raid_chance": 0.008,
"tier_weights": {"actif": 0.90, "boss": 0.10},
"flavor": "Ville sous tension Syndicat du Capitole. Sûre mais surveillance constante."
},
"laplace": {
"zone_type": "safe",
"chance_per_tick": 0.13,
"raid_chance": 0.006,
"tier_weights": {"actif": 0.93, "boss": 0.07},
"flavor": "Carrefour commercial, passage constant, relativement neutre. Beaucoup d'échanges."
},
"nola_cbd": {
"zone_type": "dangerous",
"chance_per_tick": 0.12,
"tier_weights": {"lambda": 0.45, "group": 0.38, "actif": 0.15, "boss": 0.02},
"flavor": "Gratte-ciels inondés, ruines industrielles, territoire Super Mutants"
},
"pearl_river": {
"zone_type": "dangerous",
"chance_per_tick": 0.09,
"tier_weights": {"lambda": 0.55, "group": 0.20, "actif": 0.22, "boss": 0.03},
"flavor": "Bayou dense, territoire Écumeurs, créatures des marais"
},
"la_paroisse": {
"zone_type": "dangerous",
"chance_per_tick": 0.10,
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.25, "boss": 0.05},
"flavor": "Zone radioactive, territoire Grand Krewe, goules partout"
},
"donaldsonville": {
"zone_type": "dangerous",
"chance_per_tick": 0.06,
"tier_weights": {"lambda": 0.60, "group": 0.28, "actif": 0.10, "boss": 0.02},
"flavor": "Ville intermédiaire, sans faction, passage obligé"
"flavor": "Ville intermédiaire, sans faction, passage obligé. Pas de protection réelle."
},
"oak_plantation": {
"zone_type": "dangerous",
"chance_per_tick": 0.05,
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.28, "boss": 0.02},
"flavor": "Plantation gardée, esclaves en fuite, patrouilles Dynaste"
},
"baton_rouge": {
"chance_per_tick": 0.04,
"tier_weights": {"lambda": 0.55, "group": 0.25, "actif": 0.18, "boss": 0.02},
"flavor": "Ville sous tension, présence Syndicat du Capitole"
},
"nola_fleuve": {
"zone_type": "dangerous",
"chance_per_tick": 0.05,
"tier_weights": {"lambda": 0.45, "group": 0.30, "actif": 0.22, "boss": 0.03},
"flavor": "Port fluvial, passage de bateaux, CdA patrouille"
},
"independance": {
"chance_per_tick": 0.02,
"tier_weights": {"lambda": 0.80, "group": 0.15, "actif": 0.04, "boss": 0.01},
"flavor": "Zone protégée par l'Union, relativement sûre"
},
"laplace": {
"chance_per_tick": 0.03,
"tier_weights": {"lambda": 0.65, "group": 0.25, "actif": 0.09, "boss": 0.01},
"flavor": "Carrefour commercial, passage constant, relativement neutre"
},
"nola_vieux_carre": {
"chance_per_tick": 0.025,
"tier_weights": {"lambda": 0.70, "group": 0.20, "actif": 0.09, "boss": 0.01},
"flavor": "Zone tenue par la Régie, murée, patrouilles régulières"
}
},
@@ -202,23 +218,55 @@
"can_negotiate": false,
"immune_radiation": true
}
],
"raid_faction": [
{
"key": "raid_eclaireurs",
"name": "Commando éclaireur",
"description": "Petit groupe armé d'une faction ennemie qui teste les défenses de la zone",
"count_range": [2, 4],
"stats_base": {"hp": 12, "strength": 7, "agility": 7, "endurance": 6, "perception": 6, "luck": 4},
"weapon": {"name": "Fusil automatique", "damage_dice": 2, "damage_bonus": 1, "damage_type": "physical", "range": "medium"},
"armor_rating": 1,
"caps_loot": [40, 180],
"item_loot": ["cram", "stimpak", "eau_purifiee"],
"xp": 6,
"can_negotiate": false
},
{
"key": "raid_milice",
"name": "Milice faction hostile",
"description": "Force paramilitaire d'une faction cherchant à prendre le contrôle du quartier",
"count_range": [3, 6],
"stats_base": {"hp": 14, "strength": 7, "agility": 6, "endurance": 7, "perception": 6, "luck": 4},
"weapon": {"name": "Fusil de combat", "damage_dice": 3, "damage_bonus": 0, "damage_type": "physical", "range": "medium"},
"armor_rating": 2,
"caps_loot": [60, 250],
"item_loot": ["stimpak", "eau_purifiee", "couteau_combat"],
"xp": 8,
"can_negotiate": true,
"negotiate_cost_caps": [100, 400]
}
]
},
"urban_trade_roles": ["marchand", "docteur", "technicien", "passeur", "scavenger"],
"orientation_reactions": {
"_comment": "Réaction par défaut selon le rôle du PNJ face à chaque tier d'encounter",
"garde": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat"},
"eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite"},
"marchand": {"lambda": "fuite", "group": "negociation","actif": "negociation","boss": "fuite"},
"docteur": {"lambda": "fuite", "group": "fuite", "actif": "negociation","boss": "fuite"},
"technicien": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"},
"pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite"},
"raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat"},
"lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat"},
"chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation","boss": "fuite"},
"passeur": {"lambda": "evitement", "group": "evitement", "actif": "fuite", "boss": "fuite"},
"scavenger": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"},
"survivant_generique":{"lambda":"fuite", "group": "fuite", "actif": "negociation","boss": "fuite"}
"_comment": "Réaction par défaut selon le rôle du PNJ face à chaque tier d'encounter. 'trade' = interaction commerciale PNJ-à-PNJ en zone safe.",
"garde": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"},
"eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite", "raid": "fuite"},
"marchand": {"lambda": "fuite", "group": "negociation","actif": "trade", "boss": "fuite", "raid": "fuite"},
"docteur": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
"technicien": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
"pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite", "raid": "fuite"},
"raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat", "raid": "combat"},
"lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"},
"chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation", "boss": "fuite", "raid": "fuite"},
"passeur": {"lambda": "evitement", "group": "evitement", "actif": "trade", "boss": "fuite", "raid": "fuite"},
"scavenger": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
"survivant_generique":{"lambda": "fuite", "group": "fuite", "actif": "negociation", "boss": "fuite", "raid": "fuite"}
},
"outcomes": {
@@ -238,6 +286,10 @@
"negociation": {
"success": {"loot": false, "xp": true, "stress_delta": 1, "caps_cost": true, "description": "Négociation réussie"},
"failed": {"loot": false, "xp": false, "stress_delta": 4, "hp_loss_pct": 0.15, "description": "Négociation échouée, attaqué"}
},
"trade": {
"success": {"loot": false, "xp": true, "stress_delta": -1, "caps_gain": true, "description": "Vente conclue"},
"failed": {"loot": false, "xp": false, "stress_delta": 0, "description": "Pas d'accord, chacun repart"}
}
}
}
+199 -11
View File
@@ -29,6 +29,10 @@ def _load_tables() -> dict:
with open(os.path.join(_DATA_DIR, "encounter_tables.json"), encoding="utf-8") as f:
return json.load(f)
def _is_safe_zone(zone: str) -> bool:
tables = _load_tables()
return zone in tables.get("safe_zones", [])
# ---------------------------------------------------------------------------
# Zones adjacentes (pour recherche actif/boss dans une zone voisine)
@@ -116,15 +120,26 @@ def should_trigger_encounter(char: dict, world_state: dict, rng: random.Random)
# ---------------------------------------------------------------------------
def determine_tier(zone: str, rng: random.Random) -> str:
"""Retourne 'lambda' | 'group' | 'actif' | 'boss' selon les poids de la zone."""
"""Retourne 'lambda' | 'group' | 'actif' | 'boss' | 'raid' selon le type de zone."""
tables = _load_tables()
profile = tables["zone_profiles"].get(zone, {})
# Zone sûre : seuls actif et boss sont possibles via tier_weights
# Un raid faction peut se déclencher séparément (voir process_encounters_tick)
weights = profile.get("tier_weights", {"lambda": 1.0, "group": 0.0, "actif": 0.0, "boss": 0.0})
tiers = list(weights.keys())
probs = list(weights.values())
return rng.choices(tiers, weights=probs, k=1)[0]
def should_trigger_raid(zone: str, rng: random.Random) -> bool:
"""True si un raid faction se déclenche ce tick en zone sûre (roll séparé, rare)."""
tables = _load_tables()
profile = tables["zone_profiles"].get(zone, {})
raid_chance = profile.get("raid_chance", 0.0)
return rng.random() < raid_chance
# ---------------------------------------------------------------------------
# Génération de l'entité ennemie
# ---------------------------------------------------------------------------
@@ -328,18 +343,123 @@ def generate_entity_boss(char: dict, session_id: int, rng: random.Random) -> dic
}
def generate_entity_trade(char: dict, session_id: int, rng: random.Random) -> dict | None:
"""
Cherche un PNJ commerçant dans la même zone pour un échange.
Retourne None si aucun PNJ compatible trouvé.
"""
tables = _load_tables()
trade_roles = tables.get("urban_trade_roles", ["marchand", "docteur", "technicien"])
zone = char.get("location_slug")
char_id = char["id"]
with db.cursor() as cur:
cur.execute(
"""
SELECT * FROM characters
WHERE session_id = %s AND is_alive = TRUE
AND location_slug = %s AND id != %s
AND role = ANY(%s)
ORDER BY RANDOM() LIMIT 1
""",
(session_id, zone, char_id, trade_roles)
)
row = cur.fetchone()
if not row:
return None
partner = dict(row)
return {
"tier": "actif",
"encounter_id": f"enc_trade_{partner['id']}",
"key": "commerce_urbain",
"name": partner["name"],
"description": f"{partner['role']} disponible à l'échange",
"hp": partner["hp"],
"max_hp": partner["max_hp"],
"strength": partner.get("strength", 5),
"agility": partner.get("agility", 5),
"endurance": partner.get("endurance", 5),
"perception": partner.get("perception", 5),
"luck": partner.get("luck", 5),
"armor_rating": 0,
"weapon": None,
"faction_slug": partner.get("faction_slug"),
"db_id": partner["id"],
"partner_role": partner["role"],
"caps_loot": [0, 0],
"item_loot": [],
"xp": 1,
"can_negotiate": True,
"is_trade": True,
}
def generate_entity_raid(char: dict, session_id: int, rng: random.Random) -> dict | None:
"""
Génère un raid de faction ennemie en zone sûre.
Choisit dans entity_pools.raid_faction et assigne une faction hostile.
"""
tables = _load_tables()
pool = tables["entity_pools"].get("raid_faction", [])
if not pool:
return None
char_faction = char.get("faction_slug")
entity = rng.choice(pool)
e = dict(entity)
# Cherche une faction hostile pour contextualiser le raid
hostile_factions = {
"union": ["ecumeurs", "dynaste_oak"],
"ecumeurs": ["union", "cda"],
"cda": ["ecumeurs", "dynaste_oak"],
"dynaste_oak": ["union", "grand_krewe", "cda"],
"grand_krewe": ["dynaste_oak", "syndicat_capitole"],
"syndicat_capitole": ["union", "grand_krewe"],
"regie": ["ecumeurs"],
"consortium": [],
}
enemies = hostile_factions.get(char_faction or "", [])
raiding_faction = rng.choice(enemies) if enemies else "ecumeurs"
e["tier"] = "raid"
e["encounter_id"] = f"enc_raid_{rng.randint(1000, 9999)}"
e["faction_slug"] = raiding_faction
e["name"] = f"Commando {raiding_faction.replace('_', ' ').title()}"
e["description"] = f"Raid armé de la faction {raiding_faction} en zone sûre"
count = rng.randint(*e.get("count_range", [2, 3]))
e["hp"] = e["stats_base"]["hp"] * count
e["max_hp"] = e["hp"]
e["strength"] = e["stats_base"]["strength"]
e["agility"] = e["stats_base"]["agility"]
e["db_id"] = None
return e
def generate_entity(tier: str, char: dict, session_id: int, rng: random.Random) -> dict | None:
zone = char.get("location_slug", "")
if tier == "lambda":
return generate_entity_lambda(char.get("location_slug", ""), rng)
return generate_entity_lambda(zone, rng)
elif tier == "group":
return generate_entity_group(char.get("location_slug", ""), rng)
return generate_entity_group(zone, rng)
elif tier == "actif":
# En zone sûre : priorité au commerce si le rôle s'y prête
tables = _load_tables()
trade_roles = tables.get("urban_trade_roles", [])
if _is_safe_zone(zone) and char.get("role") in trade_roles:
trade = generate_entity_trade(char, session_id, rng)
if trade:
return trade
return generate_entity_actif(char, session_id, rng)
elif tier == "boss":
entity = generate_entity_boss(char, session_id, rng)
if entity is None:
return generate_entity_lambda(char.get("location_slug", ""), rng)
# En zone sûre, pas de fallback sur lambda
return None if _is_safe_zone(zone) else generate_entity_lambda(zone, rng)
return entity
elif tier == "raid":
return generate_entity_raid(char, session_id, rng)
return None
@@ -432,6 +552,21 @@ def _evitement_roll(char: dict, rng: random.Random) -> str:
return "success" if roll <= (char_agi + char_per) // 2 + 2 else "failed"
def _trade_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]:
"""
Jet de commerce entre deux PNJ en zone sûre.
Retourne (résultat, caps_gagnés).
Succès si CHA + LCK du vendeur > seuil aléatoire.
"""
random.seed(rng.randint(1, 9999))
char_cha = char.get("charisma", 5)
char_lck = char.get("luck", 5)
roll = random.randint(1, 20)
success = roll <= (char_cha + char_lck) // 2 + 2
caps_gain = rng.randint(10, 60) if success else 0
return ("success" if success else "failed"), caps_gain
def _negociation_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]:
"""
Jet de négociation (CHA + troc).
@@ -498,6 +633,8 @@ def generate_narrative(char: dict, entity: dict, reaction: str, outcome: str) ->
zone = char.get("location_slug", "zone inconnue").replace("_", " ")
tier = entity.get("tier", "lambda")
partner_role = entity.get("partner_role", "")
raid_faction = entity.get("faction_slug", "") if entity.get("tier") == "raid" else ""
templates = {
("combat", "win"): f"{char_name} croise {entity_name} dans {zone} et l'élimine en combat.",
("combat", "loss"): f"{char_name} affronte {entity_name} dans {zone} et reçoit des coups sévères.",
@@ -508,6 +645,10 @@ def generate_narrative(char: dict, entity: dict, reaction: str, outcome: str) ->
("evitement", "failed"): f"{char_name} essaie de passer inaperçu face à {entity_name} dans {zone} — repéré, doit fuir.",
("negociation", "success"): f"{char_name} parle à {entity_name} dans {zone} et parvient à un accord.",
("negociation", "failed"): f"{char_name} tente de négocier avec {entity_name} dans {zone} — l'ennemi refuse et attaque.",
("trade", "success"): f"{char_name} croise {entity_name} ({partner_role}) à {zone} et conclut une vente.",
("trade", "failed"): f"{char_name} aborde {entity_name} à {zone} pour un échange — pas d'accord cette fois.",
("combat", "raid_repousse"): f"RAID [{raid_faction}] — {char_name} aide à repousser l'attaque sur {zone}.",
("fuite", "raid"): f"RAID [{raid_faction}] sur {zone}{char_name} se met à l'abri.",
}
# Flavor supplémentaire pour les tiers élevés
@@ -605,6 +746,15 @@ def resolve_encounter(
outcome = result
cfg = outcomes_cfg["evitement"][result]
elif reaction == "trade":
result, caps_gain = _trade_roll(char, entity, rng)
outcome = result
cfg = outcomes_cfg["trade"][result]
if result == "success" and caps_gain > 0:
current_caps = char.get("caps", 0)
db.update_character(char_id, caps=current_caps + caps_gain)
caps_delta = caps_gain
elif reaction == "negociation":
result, cost = _negociation_roll(char, entity, rng)
outcome = result
@@ -668,6 +818,21 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int
world_map = {ws["location_slug"]: ws for ws in world_states}
results = []
# Zones sûres où un raid peut frapper ce tick (roll une fois par zone, pas par PNJ)
raided_zones: set[str] = set()
safe_zone_chars: dict[str, list] = {} # zone → liste de PNJ présents
for char in characters:
z = char.get("location_slug")
if z and _is_safe_zone(z):
safe_zone_chars.setdefault(z, []).append(char)
for zone, zone_chars in safe_zone_chars.items():
zone_seed = (rng_seed or 0) + hash(zone) % 10000 + day * 1000 + tick
zone_rng = random.Random(zone_seed)
if should_trigger_raid(zone, zone_rng):
raided_zones.add(zone)
for char in characters:
seed = (rng_seed or 0) + char["id"] + day * 1000 + tick * 100
rng = random.Random(seed)
@@ -675,6 +840,29 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int
zone = char.get("location_slug")
ws = world_map.get(zone, {})
# --- Raid en zone sûre (event indépendant du roll normal) ---
if zone in raided_zones:
entity = generate_entity_raid(char, session_id, rng)
if entity:
result = resolve_encounter(char, entity, session_id, day, tick, rng)
results.append(result)
db.log_event(
session_id=session_id, day=day, tick=tick,
event_type="encounter_raid",
actor_id=char["id"],
location_slug=zone,
description=result["narrative"],
mechanical_effect={
"entity": entity.get("key"),
"entity_name": entity.get("name"),
"faction": entity.get("faction_slug"),
"reaction": result["reaction"],
"outcome": result["outcome"],
"hp_delta": result["hp_delta"],
}
)
continue # Pas d'autre encounter ce tick si raid
if not should_trigger_encounter(char, ws, rng):
continue
@@ -696,13 +884,13 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int
location_slug = zone,
description = result["narrative"],
mechanical_effect = {
"entity": entity.get("key"),
"entity_name":entity.get("name"),
"reaction": result["reaction"],
"outcome": result["outcome"],
"hp_delta": result["hp_delta"],
"caps_delta": result["caps_delta"],
"loot": result["loot"],
"entity": entity.get("key"),
"entity_name": entity.get("name"),
"reaction": result["reaction"],
"outcome": result["outcome"],
"hp_delta": result["hp_delta"],
"caps_delta": result["caps_delta"],
"loot": result["loot"],
}
)