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:
@@ -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"}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+192
-4
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user