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": {
|
"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": {
|
"nola_cbd": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.12,
|
"chance_per_tick": 0.12,
|
||||||
"tier_weights": {"lambda": 0.45, "group": 0.38, "actif": 0.15, "boss": 0.02},
|
"tier_weights": {"lambda": 0.45, "group": 0.38, "actif": 0.15, "boss": 0.02},
|
||||||
"flavor": "Gratte-ciels inondés, ruines industrielles, territoire Super Mutants"
|
"flavor": "Gratte-ciels inondés, ruines industrielles, territoire Super Mutants"
|
||||||
},
|
},
|
||||||
"pearl_river": {
|
"pearl_river": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.09,
|
"chance_per_tick": 0.09,
|
||||||
"tier_weights": {"lambda": 0.55, "group": 0.20, "actif": 0.22, "boss": 0.03},
|
"tier_weights": {"lambda": 0.55, "group": 0.20, "actif": 0.22, "boss": 0.03},
|
||||||
"flavor": "Bayou dense, territoire Écumeurs, créatures des marais"
|
"flavor": "Bayou dense, territoire Écumeurs, créatures des marais"
|
||||||
},
|
},
|
||||||
"la_paroisse": {
|
"la_paroisse": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.10,
|
"chance_per_tick": 0.10,
|
||||||
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.25, "boss": 0.05},
|
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.25, "boss": 0.05},
|
||||||
"flavor": "Zone radioactive, territoire Grand Krewe, goules partout"
|
"flavor": "Zone radioactive, territoire Grand Krewe, goules partout"
|
||||||
},
|
},
|
||||||
"donaldsonville": {
|
"donaldsonville": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.06,
|
"chance_per_tick": 0.06,
|
||||||
"tier_weights": {"lambda": 0.60, "group": 0.28, "actif": 0.10, "boss": 0.02},
|
"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": {
|
"oak_plantation": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.05,
|
"chance_per_tick": 0.05,
|
||||||
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.28, "boss": 0.02},
|
"tier_weights": {"lambda": 0.50, "group": 0.20, "actif": 0.28, "boss": 0.02},
|
||||||
"flavor": "Plantation gardée, esclaves en fuite, patrouilles Dynaste"
|
"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": {
|
"nola_fleuve": {
|
||||||
|
"zone_type": "dangerous",
|
||||||
"chance_per_tick": 0.05,
|
"chance_per_tick": 0.05,
|
||||||
"tier_weights": {"lambda": 0.45, "group": 0.30, "actif": 0.22, "boss": 0.03},
|
"tier_weights": {"lambda": 0.45, "group": 0.30, "actif": 0.22, "boss": 0.03},
|
||||||
"flavor": "Port fluvial, passage de bateaux, CdA patrouille"
|
"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,
|
"can_negotiate": false,
|
||||||
"immune_radiation": true
|
"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": {
|
"orientation_reactions": {
|
||||||
"_comment": "Réaction par défaut selon le rôle du PNJ face à chaque tier d'encounter",
|
"_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"},
|
"garde": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"},
|
||||||
"eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite"},
|
"eclaireur": {"lambda": "evitement", "group": "fuite", "actif": "fuite", "boss": "fuite", "raid": "fuite"},
|
||||||
"marchand": {"lambda": "fuite", "group": "negociation","actif": "negociation","boss": "fuite"},
|
"marchand": {"lambda": "fuite", "group": "negociation","actif": "trade", "boss": "fuite", "raid": "fuite"},
|
||||||
"docteur": {"lambda": "fuite", "group": "fuite", "actif": "negociation","boss": "fuite"},
|
"docteur": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
|
||||||
"technicien": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"},
|
"technicien": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
|
||||||
"pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite"},
|
"pillard": {"lambda": "combat", "group": "negociation","actif": "combat", "boss": "fuite", "raid": "fuite"},
|
||||||
"raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat"},
|
"raider_boss": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "combat", "raid": "combat"},
|
||||||
"lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat"},
|
"lieutenant": {"lambda": "combat", "group": "combat", "actif": "combat", "boss": "fuite_ou_combat", "raid": "combat"},
|
||||||
"chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation","boss": "fuite"},
|
"chasseur_primes": {"lambda": "combat", "group": "combat", "actif": "negociation", "boss": "fuite", "raid": "fuite"},
|
||||||
"passeur": {"lambda": "evitement", "group": "evitement", "actif": "fuite", "boss": "fuite"},
|
"passeur": {"lambda": "evitement", "group": "evitement", "actif": "trade", "boss": "fuite", "raid": "fuite"},
|
||||||
"scavenger": {"lambda": "fuite", "group": "fuite", "actif": "fuite", "boss": "fuite"},
|
"scavenger": {"lambda": "fuite", "group": "fuite", "actif": "trade", "boss": "fuite", "raid": "fuite"},
|
||||||
"survivant_generique":{"lambda":"fuite", "group": "fuite", "actif": "negociation","boss": "fuite"}
|
"survivant_generique":{"lambda": "fuite", "group": "fuite", "actif": "negociation", "boss": "fuite", "raid": "fuite"}
|
||||||
},
|
},
|
||||||
|
|
||||||
"outcomes": {
|
"outcomes": {
|
||||||
@@ -238,6 +286,10 @@
|
|||||||
"negociation": {
|
"negociation": {
|
||||||
"success": {"loot": false, "xp": true, "stress_delta": 1, "caps_cost": true, "description": "Négociation réussie"},
|
"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é"}
|
"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"}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+193
-5
@@ -29,6 +29,10 @@ def _load_tables() -> dict:
|
|||||||
with open(os.path.join(_DATA_DIR, "encounter_tables.json"), encoding="utf-8") as f:
|
with open(os.path.join(_DATA_DIR, "encounter_tables.json"), encoding="utf-8") as f:
|
||||||
return json.load(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)
|
# 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:
|
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()
|
tables = _load_tables()
|
||||||
profile = tables["zone_profiles"].get(zone, {})
|
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})
|
weights = profile.get("tier_weights", {"lambda": 1.0, "group": 0.0, "actif": 0.0, "boss": 0.0})
|
||||||
tiers = list(weights.keys())
|
tiers = list(weights.keys())
|
||||||
probs = list(weights.values())
|
probs = list(weights.values())
|
||||||
return rng.choices(tiers, weights=probs, k=1)[0]
|
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
|
# 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:
|
def generate_entity(tier: str, char: dict, session_id: int, rng: random.Random) -> dict | None:
|
||||||
|
zone = char.get("location_slug", "")
|
||||||
if tier == "lambda":
|
if tier == "lambda":
|
||||||
return generate_entity_lambda(char.get("location_slug", ""), rng)
|
return generate_entity_lambda(zone, rng)
|
||||||
elif tier == "group":
|
elif tier == "group":
|
||||||
return generate_entity_group(char.get("location_slug", ""), rng)
|
return generate_entity_group(zone, rng)
|
||||||
elif tier == "actif":
|
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)
|
return generate_entity_actif(char, session_id, rng)
|
||||||
elif tier == "boss":
|
elif tier == "boss":
|
||||||
entity = generate_entity_boss(char, session_id, rng)
|
entity = generate_entity_boss(char, session_id, rng)
|
||||||
if entity is None:
|
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
|
return entity
|
||||||
|
elif tier == "raid":
|
||||||
|
return generate_entity_raid(char, session_id, rng)
|
||||||
return None
|
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"
|
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]:
|
def _negociation_roll(char: dict, entity: dict, rng: random.Random) -> tuple[str, int]:
|
||||||
"""
|
"""
|
||||||
Jet de négociation (CHA + troc).
|
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("_", " ")
|
zone = char.get("location_slug", "zone inconnue").replace("_", " ")
|
||||||
tier = entity.get("tier", "lambda")
|
tier = entity.get("tier", "lambda")
|
||||||
|
|
||||||
|
partner_role = entity.get("partner_role", "")
|
||||||
|
raid_faction = entity.get("faction_slug", "") if entity.get("tier") == "raid" else ""
|
||||||
templates = {
|
templates = {
|
||||||
("combat", "win"): f"{char_name} croise {entity_name} dans {zone} et l'élimine en combat.",
|
("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", "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.",
|
("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", "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.",
|
("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
|
# Flavor supplémentaire pour les tiers élevés
|
||||||
@@ -605,6 +746,15 @@ def resolve_encounter(
|
|||||||
outcome = result
|
outcome = result
|
||||||
cfg = outcomes_cfg["evitement"][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":
|
elif reaction == "negociation":
|
||||||
result, cost = _negociation_roll(char, entity, rng)
|
result, cost = _negociation_roll(char, entity, rng)
|
||||||
outcome = result
|
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}
|
world_map = {ws["location_slug"]: ws for ws in world_states}
|
||||||
|
|
||||||
results = []
|
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:
|
for char in characters:
|
||||||
seed = (rng_seed or 0) + char["id"] + day * 1000 + tick * 100
|
seed = (rng_seed or 0) + char["id"] + day * 1000 + tick * 100
|
||||||
rng = random.Random(seed)
|
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")
|
zone = char.get("location_slug")
|
||||||
ws = world_map.get(zone, {})
|
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):
|
if not should_trigger_encounter(char, ws, rng):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@@ -697,7 +885,7 @@ def process_encounters_tick(session_id: int, day: int, tick: int, rng_seed: int
|
|||||||
description = result["narrative"],
|
description = result["narrative"],
|
||||||
mechanical_effect = {
|
mechanical_effect = {
|
||||||
"entity": entity.get("key"),
|
"entity": entity.get("key"),
|
||||||
"entity_name":entity.get("name"),
|
"entity_name": entity.get("name"),
|
||||||
"reaction": result["reaction"],
|
"reaction": result["reaction"],
|
||||||
"outcome": result["outcome"],
|
"outcome": result["outcome"],
|
||||||
"hp_delta": result["hp_delta"],
|
"hp_delta": result["hp_delta"],
|
||||||
|
|||||||
Reference in New Issue
Block a user