diff --git a/src/data/archetypes.json b/src/data/archetypes.json new file mode 100644 index 0000000..494259f --- /dev/null +++ b/src/data/archetypes.json @@ -0,0 +1,159 @@ +{ + "_comment": "Bibliothèque d'archétypes PNJ. Le LLM pioche ici pour instancier un PNJ de passage (tier 3) ou nommé (tier 2). Chaque archétype définit le template — le LLM choisit le nom, la faction et la localisation.", + + "garde_faction": { + "role": "garde", + "origin": "survivant", + "tier": 3, + "description": "Garde d'une faction, posté à un point stratégique", + "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak"], + "location_hints": ["zone contrôlée par la faction d'appartenance"], + "caps_range": [50, 250], + "personality_traits": ["loyal", "méfiant envers étrangers", "obéit aux ordres"], + "motivations": ["protéger son territoire", "nourrir sa famille", "progresser dans la hiérarchie"], + "equipment_role": "garde" + }, + + "vendeur_ambulant": { + "role": "marchand", + "origin": "survivant", + "tier": 3, + "description": "Marchand itinérant qui circule entre les zones sûres", + "faction_pool": [null, "consortium", "regie"], + "location_hints": ["laplace", "nola_vieux_carre", "independance", "baton_rouge"], + "caps_range": [300, 1500], + "personality_traits": ["opportuniste", "bavard", "prudent", "connait les routes"], + "motivations": ["faire du profit", "survivre", "collecter des informations"], + "equipment_role": "marchand" + }, + + "habitant_nola": { + "role": "survivant_generique", + "origin": "survivant", + "tier": 3, + "description": "Habitant ordinaire du Vieux Carré ou d'Indépendance", + "faction_pool": [null, "union", "regie"], + "location_hints": ["nola_vieux_carre", "independance"], + "caps_range": [10, 100], + "personality_traits": ["peureux", "curieux", "communautaire", "se méfie des armés"], + "motivations": ["rester en vie", "protéger sa famille", "trouver de l'eau propre"], + "equipment_role": "survivant_generique" + }, + + "pillard_errant": { + "role": "pillard", + "origin": "survivant", + "tier": 3, + "description": "Pillard solitaire ou en petite bande, sans base fixe", + "faction_pool": ["ecumeurs", null], + "location_hints": ["nola_cbd", "pearl_river", "donaldsonville"], + "caps_range": [0, 150], + "personality_traits": ["impulsif", "violent", "opportuniste", "craintif devant la force"], + "motivations": ["piller", "manger", "dominer les faibles"], + "equipment_role": "pillard" + }, + + "passeur_bayou": { + "role": "passeur", + "origin": "survivant", + "tier": 3, + "description": "Guide qui connaît les routes secrètes à travers les bayous", + "faction_pool": [null, "ecumeurs"], + "location_hints": ["pearl_river", "donaldsonville", "oak_plantation"], + "caps_range": [100, 600], + "personality_traits": ["taciturne", "fiable si payé", "indépendant", "survivaliste"], + "motivations": ["caps", "indépendance totale", "connaître les secrets des factions"], + "equipment_role": "passeur" + }, + + "chasseur_primes": { + "role": "chasseur_primes", + "origin": "survivant", + "tier": 3, + "description": "Mercenaire qui traite les contrats d'élimination ou de capture", + "faction_pool": [null, "syndicat_capitole"], + "location_hints": ["baton_rouge", "laplace", "nola_fleuve"], + "caps_range": [200, 800], + "personality_traits": ["professionnel", "froid", "pragmatique", "loyauté achetable"], + "motivations": ["les caps", "la réputation", "accomplir le contrat"], + "equipment_role": "chasseur_primes" + }, + + "scavenger_freelance": { + "role": "scavenger", + "origin": "survivant", + "tier": 3, + "description": "Récupérateur de ferraille et de technologie pré-guerre", + "faction_pool": [null], + "location_hints": ["nola_cbd", "donaldsonville", "pearl_river"], + "caps_range": [20, 200], + "personality_traits": ["curieux", "bricoleur", "avare", "évite les conflits"], + "motivations": ["trouver des pièces rares", "vendre au Consortium", "survivre"], + "equipment_role": "scavenger" + }, + + "medecin_freelance": { + "role": "docteur", + "origin": "habitant_abri", + "tier": 3, + "description": "Médecin indépendant qui soigne sans question contre paiement", + "faction_pool": [null, "union", "consortium"], + "location_hints": ["laplace", "independance", "nola_vieux_carre"], + "caps_range": [200, 1000], + "personality_traits": ["neutre politiquement", "humaniste", "épuisé", "précieux"], + "motivations": ["soigner", "survivre", "garder la neutralité"], + "equipment_role": "docteur" + }, + + "messager_faction": { + "role": "passeur", + "origin": "survivant", + "tier": 3, + "description": "Courrier entre factions, transporte messages et petits colis", + "faction_pool": ["union", "consortium", "regie", "syndicat_capitole"], + "location_hints": ["toutes zones sûres"], + "caps_range": [50, 300], + "personality_traits": ["discret", "rapide", "mémorise sans noter", "refuse de lire les messages"], + "motivations": ["caps par livraison", "rester neutre", "éviter d'être intercepté"], + "equipment_role": "passeur" + }, + + "fugitif": { + "role": "survivant_generique", + "origin": "survivant", + "tier": 3, + "description": "Quelqu'un qui fuit une faction — esclave évadé, déserteur, criminel", + "faction_pool": [null], + "location_hints": ["pearl_river", "nola_cbd", "donaldsonville"], + "caps_range": [0, 50], + "personality_traits": ["terrorisé", "méfiant extrême", "désespéré", "potentiellement dangereux"], + "motivations": ["ne pas être retrouvé", "atteindre une zone sûre", "survivre une journée de plus"], + "equipment_role": "survivant_generique" + }, + + "technicien_freelance": { + "role": "technicien", + "origin": "initie_confrerie", + "tier": 3, + "description": "Ingénieur indépendant qui répare armes, générateurs et pip-boys", + "faction_pool": [null, "consortium"], + "location_hints": ["laplace", "independance", "baton_rouge"], + "caps_range": [100, 500], + "personality_traits": ["concentré", "peu bavard", "obsédé par les machines", "honnête sur les prix"], + "motivations": ["réparer des choses", "collecter des composants rares", "fuir le conflit"], + "equipment_role": "technicien" + }, + + "lieutenant_faction": { + "role": "lieutenant", + "origin": "survivant", + "tier": 2, + "description": "Officier de rang intermédiaire, bras droit opérationnel d'un chef", + "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"], + "location_hints": ["QG ou zone clé de la faction"], + "caps_range": [200, 800], + "personality_traits": ["ambitieux", "dévoué à la faction", "respecté des gardes", "jaloux du boss"], + "motivations": ["monter en grade", "protéger la faction", "accumuler des ressources personnelles"], + "equipment_role": "lieutenant" + } +} diff --git a/src/data/equipment_by_role.json b/src/data/equipment_by_role.json new file mode 100644 index 0000000..6c01860 --- /dev/null +++ b/src/data/equipment_by_role.json @@ -0,0 +1,248 @@ +{ + "_comment": "Loadouts d'équipement par rôle. Chaque entrée = un item inventory complet.", + + "garde": { + "_desc": "Milicien faction — armé, léger blindage", + "weapons": [ + {"item_slug": "pistolet_10mm", "item_name": "Pistolet 10mm", "item_type": "weapon_ranged", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "medium", "is_equipped": true} + ], + "armor": [ + {"item_slug": "veste_blindee", "item_name": "Veste blindée", "item_type": "armor", + "quantity": 1, "weight": 3.0, "armor_rating": 1, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 1, "hp_heal": 4}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_purifiee", "item_name": "Eau purifiée", "item_type": "drink", "quantity": 2} + ] + }, + + "marchand": { + "_desc": "Commerçant — défense minimale, stock commercial", + "weapons": [ + {"item_slug": "pistolet_10mm", "item_name": "Pistolet 10mm", "item_type": "weapon_ranged", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "medium", "is_equipped": false} + ], + "armor": [], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 2, "hp_heal": 4}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 5, "hp_heal": 2}, + {"item_slug": "eau_purifiee", "item_name": "Eau purifiée", "item_type": "drink", "quantity": 3} + ], + "stock": [ + {"item_slug": "igname_cuite", "item_name": "Igname cuite", "item_type": "food", "quantity": 6, "hp_heal": 3}, + {"item_slug": "outils_reparation","item_name": "Outils de répar.", "item_type": "misc", "quantity": 2}, + {"item_slug": "eau_purifiee_vrac","item_name": "Eau purifiée (lot)","item_type": "drink","quantity": 5} + ] + }, + + "eclaireur": { + "_desc": "Rôdeur marais — longue portée, discret, autonome", + "weapons": [ + {"item_slug": "fusil_chasse", "item_name": "Fusil de chasse", "item_type": "weapon_ranged", + "quantity": 1, "weight": 3.0, "damage_dice": 3, "damage_type": "physical", + "damage_range": "medium", "is_equipped": true}, + {"item_slug": "couteau_survie", "item_name": "Couteau de survie", "item_type": "weapon_melee", + "quantity": 1, "weight": 0.3, "damage_dice": 1, "damage_type": "physical", + "damage_range": "short", "is_equipped": false} + ], + "armor": [ + {"item_slug": "combinaison_renforcee", "item_name": "Combinaison renforcée", "item_type": "armor", + "quantity": 1, "weight": 2.0, "armor_rating": 1, + "properties": {"stealth_bonus": 1}, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 2, "hp_heal": 4}, + {"item_slug": "antidote", "item_name": "Antidote", "item_type": "drug", "quantity": 1}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 4, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 2, + "properties": {"irradiated": true, "rad_on_drink": 1}} + ] + }, + + "docteur": { + "_desc": "Médecin terrain — trousse complète, défense minimale", + "weapons": [ + {"item_slug": "pistolet_10mm", "item_name": "Pistolet 10mm", "item_type": "weapon_ranged", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "medium", "is_equipped": false} + ], + "armor": [], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 5, "hp_heal": 4}, + {"item_slug": "radaway", "item_name": "RadAway", "item_type": "drug", "quantity": 2, "rad_heal": 3}, + {"item_slug": "trousse_soin", "item_name": "Trousse de soin","item_type":"misc", "quantity": 2}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_purifiee", "item_name": "Eau purifiée", "item_type": "drink", "quantity": 3} + ] + }, + + "technicien": { + "_desc": "Ingénieur — arme énergie, outils de réparation", + "weapons": [ + {"item_slug": "pistolet_laser", "item_name": "Pistolet laser", "item_type": "weapon_energy", + "quantity": 1, "weight": 1.0, "damage_dice": 2, "damage_type": "energy", + "damage_range": "medium", "is_equipped": true} + ], + "armor": [], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 2, "hp_heal": 4}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 2, + "properties": {"irradiated": true, "rad_on_drink": 1}}, + {"item_slug": "outils_reparation","item_name": "Outils de répar.","item_type": "misc", "quantity": 1}, + {"item_slug": "composants_elec", "item_name": "Composants élec.", "item_type": "misc", "quantity": 5} + ] + }, + + "pillard": { + "_desc": "Pillard — violence rapide, armure bricole", + "weapons": [ + {"item_slug": "batte_cloutee", "item_name": "Batte cloutée", "item_type": "weapon_melee", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_bonus": 1, "damage_type": "physical", + "damage_range": "short", "properties": {"brutal": true}, "is_equipped": true}, + {"item_slug": "couteau_combat", "item_name": "Couteau de combat", "item_type": "weapon_melee", + "quantity": 1, "weight": 0.5, "damage_dice": 1, "damage_type": "physical", + "damage_range": "short", "is_equipped": false} + ], + "armor": [ + {"item_slug": "armure_planches", "item_name": "Armure de planches", "item_type": "armor", + "quantity": 1, "weight": 4.0, "armor_rating": 1, + "properties": {"homemade": true}, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "jet", "item_name": "Jet", "item_type": "drug", "quantity": 1, + "properties": {"speed_bonus": 1, "addictive": true}}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 2, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 1, + "properties": {"irradiated": true, "rad_on_drink": 1}} + ] + }, + + "survivant_generique": { + "_desc": "Habitant lambda — outil basique, survie quotidienne", + "weapons": [ + {"item_slug": "baton", "item_name": "Bâton", "item_type": "weapon_melee", + "quantity": 1, "weight": 1.0, "damage_dice": 1, "damage_type": "physical", + "damage_range": "short", "is_equipped": true} + ], + "armor": [], + "consumables": [ + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 2, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 2, + "properties": {"irradiated": true, "rad_on_drink": 1}} + ] + }, + + "raider_boss": { + "_desc": "Chef de bande — armé lourd, butin accumulé", + "weapons": [ + {"item_slug": "fusil_chasse", "item_name": "Fusil de chasse", "item_type": "weapon_ranged", + "quantity": 1, "weight": 3.0, "damage_dice": 3, "damage_type": "physical", + "damage_range": "medium", "is_equipped": true}, + {"item_slug": "batte_cloutee", "item_name": "Batte cloutée", "item_type": "weapon_melee", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_bonus": 1, "damage_type": "physical", + "damage_range": "short", "properties": {"brutal": true}, "is_equipped": false} + ], + "armor": [ + {"item_slug": "cuir_renforce", "item_name": "Cuir renforcé", "item_type": "armor", + "quantity": 1, "weight": 4.5, "armor_rating": 2, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 2, "hp_heal": 4}, + {"item_slug": "jet", "item_name": "Jet", "item_type": "drug", "quantity": 2, + "properties": {"speed_bonus": 1, "addictive": true}}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 2, + "properties": {"irradiated": true, "rad_on_drink": 1}} + ] + }, + + "lieutenant": { + "_desc": "Bras droit de faction — mieux équipé qu'un garde", + "weapons": [ + {"item_slug": "fusil_chasse", "item_name": "Fusil de chasse", "item_type": "weapon_ranged", + "quantity": 1, "weight": 3.0, "damage_dice": 3, "damage_type": "physical", + "damage_range": "medium", "is_equipped": true}, + {"item_slug": "pistolet_10mm", "item_name": "Pistolet 10mm", "item_type": "weapon_ranged", + "quantity": 1, "weight": 1.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "medium", "is_equipped": false} + ], + "armor": [ + {"item_slug": "cuir_renforce", "item_name": "Cuir renforcé", "item_type": "armor", + "quantity": 1, "weight": 4.5, "armor_rating": 2, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 2, "hp_heal": 4}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_purifiee", "item_name": "Eau purifiée", "item_type": "drink", "quantity": 2} + ] + }, + + "chasseur_primes": { + "_desc": "Mercenaire — équipé pour capturer ou tuer", + "weapons": [ + {"item_slug": "carabine_308", "item_name": "Carabine .308", "item_type": "weapon_ranged", + "quantity": 1, "weight": 3.5, "damage_dice": 3, "damage_bonus": 1, "damage_type": "physical", + "damage_range": "long", "properties": {"piercing": 1}, "is_equipped": true}, + {"item_slug": "couteau_combat", "item_name": "Couteau de combat", "item_type": "weapon_melee", + "quantity": 1, "weight": 0.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "short", "is_equipped": false} + ], + "armor": [ + {"item_slug": "cuir_renforce", "item_name": "Cuir renforcé", "item_type": "armor", + "quantity": 1, "weight": 4.5, "armor_rating": 2, "is_equipped": true} + ], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 3, "hp_heal": 4}, + {"item_slug": "mentats", "item_name": "Mentats", "item_type": "drug", "quantity": 1, + "properties": {"int_bonus": 2, "per_bonus": 1}}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_purifiee", "item_name": "Eau purifiée", "item_type": "drink", "quantity": 2} + ] + }, + + "passeur": { + "_desc": "Guide / passeur entre zones — mobile, discret", + "weapons": [ + {"item_slug": "couteau_combat", "item_name": "Couteau de combat", "item_type": "weapon_melee", + "quantity": 1, "weight": 0.5, "damage_dice": 2, "damage_type": "physical", + "damage_range": "short", "is_equipped": true} + ], + "armor": [], + "consumables": [ + {"item_slug": "stimpak", "item_name": "Stimpak", "item_type": "drug", "quantity": 1, "hp_heal": 4}, + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 3, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink", "quantity": 3, + "properties": {"irradiated": true, "rad_on_drink": 1}} + ] + }, + + "scavenger": { + "_desc": "Récupérateur de ferraille — outils, peu d'armes", + "weapons": [ + {"item_slug": "baton", "item_name": "Bâton", "item_type": "weapon_melee", + "quantity": 1, "weight": 1.0, "damage_dice": 1, "damage_type": "physical", + "damage_range": "short", "is_equipped": true} + ], + "armor": [], + "consumables": [ + {"item_slug": "cram", "item_name": "Cram", "item_type": "food", "quantity": 2, "hp_heal": 2}, + {"item_slug": "eau_sale", "item_name": "Eau sale", "item_type": "drink","quantity": 2, + "properties": {"irradiated": true, "rad_on_drink": 1}}, + {"item_slug": "outils_reparation", "item_name": "Outils de répar.", "item_type": "misc", "quantity": 1}, + {"item_slug": "ferraille", "item_name": "Ferraille", "item_type": "misc", "quantity": 5, + "weight": 2.0} + ] + }, + + "chef_faction": { + "_desc": "Chef de faction — équipement narratif unique, hors sim", + "weapons": [], + "armor": [], + "consumables": [] + } +} diff --git a/src/db/migration_003_pnj_tiers.sql b/src/db/migration_003_pnj_tiers.sql new file mode 100644 index 0000000..0c37cbb --- /dev/null +++ b/src/db/migration_003_pnj_tiers.sql @@ -0,0 +1,145 @@ +-- ============================================================================= +-- Migration 003 — Système de tiers PNJ + équipement boss + nouveaux rôles +-- +-- Tier 0 : BOSS (chefs de faction, hors boucle sim, LLM 14B uniquement) +-- Tier 1 : ACTIFS SIM (agents permanents, LLM 7B + déterministe) +-- Tier 2 : PNJ+ (personnages nommés secondaires, LLM 7B light) +-- Tier 3 : PASSAGE (instanciés depuis archétypes, recyclables) +-- ============================================================================= + +-- 1. Nouvelles colonnes sur characters +ALTER TABLE characters + ADD COLUMN IF NOT EXISTS tier INTEGER DEFAULT 1, + ADD COLUMN IF NOT EXISTS archetype_key TEXT DEFAULT NULL; + +CREATE INDEX IF NOT EXISTS idx_characters_tier ON characters(session_id, tier); + +-- 2. Les 5 boss existants passent en tier=0 et sont exclus de la sim +-- Ils n'ont pas de besoins biologiques dans la boucle (simulation narrative seule) +UPDATE characters +SET tier = 0, + needs_food = FALSE, + needs_water = FALSE, + needs_sleep = FALSE +WHERE session_id = 1 + AND name IN ('Kingfish Robichaux','Maître Beaumont','Mme LaVeau', + 'Commissaire Delacroix','Directeur Tran'); + +-- Assigner les rôles narratifs aux boss (pour la cohérence) +UPDATE characters SET role = 'chef_faction', origin = 'survivant' +WHERE session_id = 1 AND tier = 0; + +-- 3. Équipements distinctifs pour les boss (matching leurs personnalités) + +-- Kingfish Robichaux (Syndicat du Capitole) — gangster charismatique +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_bonus, damage_type, damage_range, + armor_rating, properties, is_equipped) +SELECT id, 1, + 'revolver_44', '.44 Magnumblast', 'weapon_ranged', + 1, 1.8, 3, 1, 'physical', 'medium', 0, '{"piercing": 1}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Kingfish Robichaux' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, armor_rating, properties, is_equipped) +SELECT id, 1, + 'costume_patron', 'Costume du Patron', 'armor', + 1, 2.0, 0, '{"intimidation_bonus": 2, "social": true}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Kingfish Robichaux' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +-- Maître Beaumont (Dynaste d'Oak) — aristocrate esclavagiste +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_type, damage_range, is_equipped) +SELECT id, 1, + 'fouet_serviteur', 'Fouet du Maître', 'weapon_melee', + 1, 0.8, 2, 'physical', 'short', TRUE +FROM characters WHERE session_id=1 AND name='Maître Beaumont' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_type, damage_range, is_equipped) +SELECT id, 1, + 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged', + 1, 1.5, 2, 'physical', 'medium', FALSE +FROM characters WHERE session_id=1 AND name='Maître Beaumont' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +-- Mme LaVeau (Grand Krewe) — prêtresse vaudou des goules +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_bonus, damage_type, damage_range, + properties, is_equipped) +SELECT id, 1, + 'baton_vaudou', 'Bâton Vaudou', 'weapon_melee', + 1, 1.2, 1, 2, 'radiation', 'short', + '{"rad_on_hit": 2, "fear": true}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Mme LaVeau' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, rad_heal, properties) +SELECT id, 1, + 'herbe_marais', 'Herbes des marais', 'drug', + 3, 0.1, 2, '{"goule_only_heal": true}'::jsonb +FROM characters WHERE session_id=1 AND name='Mme LaVeau' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +-- Commissaire Delacroix (Union) — dirigeante militaire +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_type, damage_range, is_equipped) +SELECT id, 1, + 'pistolet_10mm', 'Pistolet 10mm', 'weapon_ranged', + 1, 1.5, 2, 'physical', 'medium', TRUE +FROM characters WHERE session_id=1 AND name='Commissaire Delacroix' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, armor_rating, properties, is_equipped) +SELECT id, 1, + 'armure_union', 'Armure de l''Union', 'armor', + 1, 5.0, 2, '{"faction": "union", "authority": true}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Commissaire Delacroix' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, hp_heal) +SELECT id, 1, 'stimpak', 'Stimpak', 'drug', 3, 0.2, 4 +FROM characters WHERE session_id=1 AND name='Commissaire Delacroix' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +-- Directeur Tran (Consortium) — PDG technologue +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, damage_dice, damage_type, damage_range, properties, is_equipped) +SELECT id, 1, + 'pistolet_laser', 'Pistolet Laser Mk.II', 'weapon_energy', + 1, 1.0, 3, 'energy', 'medium', + '{"piercing_energy": 1, "consortium_tech": true}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Directeur Tran' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +INSERT INTO inventory + (character_id, session_id, item_slug, item_name, item_type, + quantity, weight, properties, is_equipped) +SELECT id, 1, + 'pip_boy_mk3', 'Pip-Boy MkIII', 'misc', + 1, 0.5, '{"data_device": true, "trade_bonus": 2}'::jsonb, TRUE +FROM characters WHERE session_id=1 AND name='Directeur Tran' +ON CONFLICT (character_id, session_id, item_slug) DO NOTHING; + +-- 4. Capsules des boss (pouvoir économique narratif) +UPDATE characters SET caps = 5000 WHERE session_id=1 AND name='Kingfish Robichaux'; +UPDATE characters SET caps = 3000 WHERE session_id=1 AND name='Maître Beaumont'; +UPDATE characters SET caps = 1500 WHERE session_id=1 AND name='Mme LaVeau'; +UPDATE characters SET caps = 4000 WHERE session_id=1 AND name='Commissaire Delacroix'; +UPDATE characters SET caps = 8000 WHERE session_id=1 AND name='Directeur Tran'; diff --git a/src/engine/character_creator.py b/src/engine/character_creator.py index b569e26..4bcd75d 100644 --- a/src/engine/character_creator.py +++ b/src/engine/character_creator.py @@ -67,6 +67,36 @@ _ROLES: dict[str, dict] = { "faction_pool": ["ecumeurs", None], "caps": (100, 1000), }, + "lieutenant": { + "description": "Bras droit d'un chef de faction, mieux équipé qu'un garde", + "special": {"STR": (6,8), "PER": (5,7), "END": (6,8), "CHA": (5,7), "INT": (5,7), "AGI": (5,7), "LCK": (4,6)}, + "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"], + "caps": (200, 800), + }, + "chasseur_primes": { + "description": "Mercenaire spécialisé dans la traque et la capture", + "special": {"STR": (5,8), "PER": (7,9), "END": (5,7), "CHA": (4,6), "INT": (5,7), "AGI": (6,8), "LCK": (5,7)}, + "faction_pool": [None, "syndicat_capitole"], + "caps": (200, 800), + }, + "passeur": { + "description": "Guide qui connaît les routes secrètes entre les zones", + "special": {"STR": (4,6), "PER": (6,8), "END": (5,7), "CHA": (5,7), "INT": (5,7), "AGI": (7,9), "LCK": (5,7)}, + "faction_pool": [None, "ecumeurs"], + "caps": (100, 600), + }, + "scavenger": { + "description": "Récupérateur de ferraille et de technologie pré-guerre", + "special": {"STR": (4,7), "PER": (6,8), "END": (5,7), "CHA": (3,5), "INT": (5,8), "AGI": (5,7), "LCK": (5,8)}, + "faction_pool": [None], + "caps": (20, 200), + }, + "chef_faction": { + "description": "Chef de faction — tier 0, hors simulation, décisions narratives LLM", + "special": {"STR": (5,8), "PER": (6,8), "END": (5,8), "CHA": (7,10), "INT": (7,10), "AGI": (4,7), "LCK": (5,8)}, + "faction_pool": ["union", "cda", "syndicat_capitole", "regie", "dynaste_oak", "ecumeurs", "consortium", "grand_krewe"], + "caps": (1000, 8000), + }, "survivant_generique": { "description": "Habitant lambda des terres désolées, équilibré et débrouillard", "special": {"STR": (5,7), "PER": (5,7), "END": (5,7), "CHA": (5,7), "INT": (5,7), "AGI": (5,7), "LCK": (5,7)}, @@ -166,13 +196,18 @@ ORIGINS: dict[str, dict] = { # Mapping rôle → origine probable (pour génération automatique PNJ) _ROLE_TO_ORIGIN: dict[str, list[str]] = { - "pillard": ["survivant"], - "marchand": ["survivant", "habitant_abri"], - "garde": ["survivant", "habitant_abri", "initie_confrerie"], - "eclaireur": ["survivant", "goule"], - "docteur": ["survivant", "habitant_abri"], - "technicien": ["initie_confrerie", "habitant_abri", "survivant"], - "raider_boss": ["survivant", "super_mutant"], + "pillard": ["survivant"], + "marchand": ["survivant", "habitant_abri"], + "garde": ["survivant", "habitant_abri", "initie_confrerie"], + "eclaireur": ["survivant", "goule"], + "docteur": ["survivant", "habitant_abri"], + "technicien": ["initie_confrerie", "habitant_abri", "survivant"], + "raider_boss": ["survivant", "super_mutant"], + "lieutenant": ["survivant", "habitant_abri"], + "chasseur_primes": ["survivant"], + "passeur": ["survivant", "goule"], + "scavenger": ["survivant", "goule"], + "chef_faction": ["survivant", "habitant_abri"], "survivant_generique": ["survivant", "habitant_abri"], } @@ -250,6 +285,8 @@ def generate_character( seed: int | None = None, caps: int | None = None, save_to_db: bool = True, + tier: int = 1, + archetype_key: str | None = None, ) -> dict: """ Génère un personnage complet. @@ -329,6 +366,8 @@ def generate_character( "rads": 0, "is_alive": True, "is_in_shelter": False, + "tier": tier, + "archetype_key": archetype_key, } if save_to_db: @@ -348,13 +387,15 @@ def _insert_character(char: dict) -> int: strength, perception, endurance, charisma, intelligence, agility, luck, hp, max_hp, caps, rads, is_alive, is_in_shelter, role, origin, needs_food, needs_water, needs_sleep, - immune_radiation, immune_poison + immune_radiation, immune_poison, + tier, archetype_key ) VALUES ( %(session_id)s, %(name)s, %(character_type)s, %(faction_slug)s, %(location_slug)s, %(seed_base)s, %(strength)s, %(perception)s, %(endurance)s, %(charisma)s, %(intelligence)s, %(agility)s, %(luck)s, %(hp)s, %(max_hp)s, %(caps)s, %(rads)s, %(is_alive)s, %(is_in_shelter)s, %(role)s, %(origin)s, %(needs_food)s, %(needs_water)s, %(needs_sleep)s, - %(immune_radiation)s, %(immune_poison)s + %(immune_radiation)s, %(immune_poison)s, + %(tier)s, %(archetype_key)s ) RETURNING id """, char, diff --git a/src/engine/db.py b/src/engine/db.py index c68e47a..ceef236 100644 --- a/src/engine/db.py +++ b/src/engine/db.py @@ -43,7 +43,18 @@ def update_session_tick(session_id: int, day: int, tick: int): # --- Personnages --- -def get_characters(session_id: int) -> list[dict]: +def get_characters(session_id: int, min_tier: int = 1) -> list[dict]: + """Retourne les PNJ vivants. min_tier=1 exclut les boss (tier=0) par défaut.""" + with cursor() as cur: + cur.execute( + "SELECT * FROM characters WHERE session_id=%s AND is_alive=TRUE AND COALESCE(tier,1) >= %s", + (session_id, min_tier) + ) + return [dict(r) for r in cur.fetchall()] + + +def get_all_characters(session_id: int) -> list[dict]: + """Retourne tous les PNJ vivants tous tiers confondus (y compris boss).""" with cursor() as cur: cur.execute( "SELECT * FROM characters WHERE session_id=%s AND is_alive=TRUE", diff --git a/src/engine/init_pnj.py b/src/engine/init_pnj.py index 8800eb3..07971e0 100644 --- a/src/engine/init_pnj.py +++ b/src/engine/init_pnj.py @@ -1,5 +1,6 @@ """ -Script one-shot : crée 10 PNJ de test en DB pour la pré-sim 3 jours pacifique. +Script one-shot : crée les PNJ tier 1 (actifs sim) et tier 2 (PNJ+ nommés). +Les boss tier 0 sont créés via fixtures.sql + migration_003. Lance avec : python init_pnj.py """ @@ -7,48 +8,105 @@ import sys, os sys.path.insert(0, os.path.dirname(__file__)) import character_creator as cc +import pnj_factory as factory import db SESSION_ID = 1 -PNJ_SPECS = [ +# --------------------------------------------------------------------------- +# Tier 1 — ACTIFS SIM (15 PNJ, boucle survival + actions complète) +# --------------------------------------------------------------------------- +TIER1_SPECS = [ # (role, origin, name, faction, location) - ("garde", "survivant", "Thibodaux 'Gator'", "union", "nola_cbd"), - ("garde", "initie_confrerie","Fontenot 'Ironhide'", "cda", "nola_cbd"), - ("marchand", "habitant_abri", "LaFleur 'Vieux'", "consortium", "nola_vieux_carre"), - ("marchand", "survivant", "Broussard 'Fil-de-Fer'", "regie", "nola_vieux_carre"), - ("eclaireur", "goule", "Mouton 'Marécage'", None, "pearl_river"), - ("eclaireur", "survivant", "Trahan 'Mudcat'", "union", "pearl_river"), - ("docteur", "habitant_abri", "Hébert 'Tripette'", "union", "nola_cbd"), - ("technicien", "initie_confrerie","Landry 'Rouille'", "consortium", "baton_rouge"), - ("pillard", "survivant", "Arceneaux 'Crapaud'", "ecumeurs", "pearl_river"), - ("survivant_generique","survivant", "Guidry 'Gris-Gris'", None, "nola_vieux_carre"), + ("garde", "survivant", "Thibodaux 'Gator'", "union", "nola_cbd"), + ("garde", "initie_confrerie","Fontenot 'Ironhide'", "cda", "nola_cbd"), + ("marchand", "habitant_abri", "LaFleur 'Vieux'", "consortium", "nola_vieux_carre"), + ("marchand", "survivant", "Broussard 'Fil-de-Fer'", "regie", "nola_vieux_carre"), + ("eclaireur", "goule", "Mouton 'Marécage'", None, "pearl_river"), + ("eclaireur", "survivant", "Trahan 'Mudcat'", "union", "pearl_river"), + ("docteur", "habitant_abri", "Hébert 'Tripette'", "union", "nola_cbd"), + ("technicien", "initie_confrerie","Landry 'Rouille'", "consortium", "baton_rouge"), + ("pillard", "survivant", "Arceneaux 'Crapaud'", "ecumeurs", "pearl_river"), + ("survivant_generique","survivant", "Guidry 'Gris-Gris'", None, "nola_vieux_carre"), ] +# --------------------------------------------------------------------------- +# Tier 2 — PNJ+ (personnages nommés secondaires, 1-2 par faction majeure) +# --------------------------------------------------------------------------- +TIER2_SPECS = [ + # (archetype, name, faction, location, seed) + ("lieutenant_faction", "Jean-Baptiste 'Fer' Caillouet", "union", "independance", 2001), + ("lieutenant_faction", "Ramon 'Cobra' Villaverde", "syndicat_capitole", "baton_rouge", 2002), + ("lieutenant_faction", "Lila 'Plomb' Moreau", "cda", "nola_fleuve", 2003), + ("lieutenant_faction", "Zuzu 'Os-Blanc' Delacourt", "grand_krewe", "la_paroisse", 2004), + ("lieutenant_faction", "Thérèse 'Circuit' Nguyen", "consortium", "laplace", 2005), + ("lieutenant_faction", "Edmond 'Sèche' Bautista", "regie", "nola_vieux_carre",2006), + ("lieutenant_faction", "Calhoun 'Fouet' Arceneaux", "dynaste_oak", "oak_plantation", 2007), + ("lieutenant_faction", "Roscoe 'Requiem' Fontenot", "ecumeurs", "pearl_river", 2008), +] + + +def equip_tier1(char_id: int, role: str): + """Équipe un PNJ tier 1 selon son rôle via pnj_factory.""" + items = factory.equip_character(char_id, role, SESSION_ID) + return items + + def main(): - print(f"=== Création de {len(PNJ_SPECS)} PNJ — Session {SESSION_ID} ===\n") - created = [] - for i, (role, origin, name, faction, location) in enumerate(PNJ_SPECS, 1): + print(f"=== Initialisation PNJ — Session {SESSION_ID} ===\n") + + # --- Tier 1 --- + print(f"--- Tier 1 : {len(TIER1_SPECS)} PNJ actifs ---\n") + tier1_created = [] + for i, (role, origin, name, faction, location) in enumerate(TIER1_SPECS, 1): char = cc.generate_character( - session_id=SESSION_ID, - role=role, - origin=origin, - name=name, - faction_slug=faction, - location_slug=location, - character_type="pnj_sim", - seed=1000 + i, - save_to_db=True, + session_id = SESSION_ID, + role = role, + origin = origin, + name = name, + faction_slug = faction, + location_slug = location, + character_type = "pnj_sim", + seed = 1000 + i, + save_to_db = True, + tier = 1, ) - created.append(char) + items = equip_tier1(char["id"], role) + tier1_created.append(char) print(cc.format_sheet(char)) - print() + print(f" Équipement : {', '.join(items) or '—'}\n") - print(f"✅ {len(created)} PNJ insérés en DB (session {SESSION_ID})") + # --- Tier 2 --- + print(f"\n--- Tier 2 : {len(TIER2_SPECS)} PNJ+ nommés ---\n") + tier2_created = [] + for archetype, name, faction, location, seed in TIER2_SPECS: + char = factory.spawn_from_archetype( + archetype_key = archetype, + session_id = SESSION_ID, + name = name, + faction_slug = faction, + location_slug = location, + seed = seed, + tier_override = 2, + ) + tier2_created.append(char) + print(f" [TIER2] {name:<40} {faction or 'sans faction':<20} @ {location}") + print(f" Équipement : {', '.join(char.get('items_equipped', []))}\n") + + # --- Résumé --- + total = len(tier1_created) + len(tier2_created) + all_chars = db.get_characters(SESSION_ID) + print(f"\n✅ {total} PNJ créés ({len(tier1_created)} tier1 + {len(tier2_created)} tier2)") + print(f" DB total (all tiers) : {len(all_chars)} PNJ vivants en session {SESSION_ID}") + + by_tier = {} + for c in all_chars: + t = c.get("tier", 1) + by_tier[t] = by_tier.get(t, 0) + 1 + for t in sorted(by_tier): + labels = {0: "BOSS", 1: "ACTIFS SIM", 2: "PNJ+", 3: "PASSAGE"} + print(f" Tier {t} ({labels.get(t,'?')}) : {by_tier[t]} PNJ") - # Vérification rapide - chars = db.get_characters(SESSION_ID) - print(f" DB confirme : {len(chars)} PNJ vivants en session {SESSION_ID}") if __name__ == "__main__": main() diff --git a/src/engine/pnj_factory.py b/src/engine/pnj_factory.py new file mode 100644 index 0000000..1520008 --- /dev/null +++ b/src/engine/pnj_factory.py @@ -0,0 +1,188 @@ +""" +PNJ Factory — Instancie des archétypes de la bibliothèque en PNJ réels en DB. + +Deux modes : + spawn_from_archetype(key, ...) — crée un PNJ tier 3 depuis la bibliothèque + equip_character(char_id, role, session_id) — équipe un PNJ existant selon son rôle +""" + +import json +import os +import random +import db +import character_creator as cc + +_DATA_DIR = os.path.join(os.path.dirname(__file__), "..", "data") + + +def _load_archetypes() -> dict: + path = os.path.join(_DATA_DIR, "archetypes.json") + with open(path, encoding="utf-8") as f: + data = json.load(f) + return {k: v for k, v in data.items() if not k.startswith("_")} + + +def _load_equipment() -> dict: + path = os.path.join(_DATA_DIR, "equipment_by_role.json") + with open(path, encoding="utf-8") as f: + data = json.load(f) + return {k: v for k, v in data.items() if not k.startswith("_")} + + +def equip_character(character_id: int, role: str, session_id: int, seed: int | None = None) -> list[str]: + """ + Insère l'équipement standard du rôle en DB pour un personnage. + Retourne la liste des item_slug insérés. + """ + equipment = _load_equipment() + role_equip = equipment.get(role, equipment["survivant_generique"]) + rng = random.Random(seed) + inserted = [] + + all_items = ( + role_equip.get("weapons", []) + + role_equip.get("armor", []) + + role_equip.get("consumables", []) + + role_equip.get("stock", []) + ) + + for item in all_items: + slug = item["item_slug"] + name = item["item_name"] + itype = item["item_type"] + qty = item.get("quantity", 1) + weight = item.get("weight", 0.0) + hp_heal = item.get("hp_heal", 0) + rad_heal = item.get("rad_heal", 0) + dmg_dice = item.get("damage_dice", 0) + dmg_bonus = item.get("damage_bonus", 0) + dmg_type = item.get("damage_type") + dmg_range = item.get("damage_range") + armor_r = item.get("armor_rating", 0) + props = json.dumps(item.get("properties", {})) + equipped = item.get("is_equipped", False) + + with db.cursor() as cur: + cur.execute( + """ + INSERT INTO inventory ( + character_id, session_id, item_slug, item_name, item_type, + quantity, weight, hp_heal, rad_heal, + damage_dice, damage_bonus, damage_type, damage_range, + armor_rating, properties, is_equipped + ) VALUES ( + %s, %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s, %s, %s, + %s, %s::jsonb, %s + ) + ON CONFLICT (character_id, session_id, item_slug) DO NOTHING + """, + ( + character_id, session_id, slug, name, itype, + qty, weight, hp_heal, rad_heal, + dmg_dice, dmg_bonus, dmg_type, dmg_range, + armor_r, props, equipped + ) + ) + if cur.rowcount: + inserted.append(slug) + + return inserted + + +def spawn_from_archetype( + archetype_key: str, + session_id: int, + name: str | None = None, + faction_slug: str | None = None, + location_slug: str | None = None, + seed: int | None = None, + tier_override: int | None = None, +) -> dict: + """ + Instancie un archétype en PNJ réel dans la DB. + + Args: + archetype_key : clé dans archetypes.json (ex: 'vendeur_ambulant') + session_id : ID de session + name : nom custom (généré si None) + faction_slug : faction (tirée de l'archétype si None) + location_slug : localisation de départ + seed : graine reproductible + tier_override : force un tier (ex: 2 pour un lieutenant nommé) + + Returns: + dict du personnage créé (avec id DB) + """ + archetypes = _load_archetypes() + if archetype_key not in archetypes: + raise ValueError(f"Archétype inconnu: {archetype_key}. Disponibles: {list(archetypes)}") + + arch = archetypes[archetype_key] + rng = random.Random(seed) + + role = arch["role"] + origin = arch.get("origin", "survivant") + tier = tier_override if tier_override is not None else arch.get("tier", 3) + + if faction_slug is None: + pool = [f for f in arch.get("faction_pool", [None])] + faction_slug = rng.choice(pool) + + char = cc.generate_character( + session_id = session_id, + role = role, + origin = origin, + character_type = "pnj_sim", + name = name, + faction_slug = faction_slug, + location_slug = location_slug, + seed = seed, + save_to_db = True, + tier = tier, + archetype_key = archetype_key, + ) + + equip_role = arch.get("equipment_role", role) + items = equip_character(char["id"], equip_role, session_id, seed=seed) + + char["items_equipped"] = items + return char + + +def list_archetypes() -> list[dict]: + """Retourne la liste des archétypes disponibles (pour le LLM).""" + archetypes = _load_archetypes() + return [ + { + "key": key, + "role": data["role"], + "tier": data.get("tier", 3), + "description": data["description"], + "faction_pool": data.get("faction_pool", []), + } + for key, data in archetypes.items() + ] + + +def spawn_batch(specs: list[dict], session_id: int) -> list[dict]: + """ + Instancie plusieurs PNJ depuis une liste de specs. + + specs: [{"archetype": "vendeur_ambulant", "name": "Jean", "faction": "consortium", + "location": "laplace", "seed": 42}, ...] + """ + created = [] + for spec in specs: + char = spawn_from_archetype( + archetype_key = spec["archetype"], + session_id = session_id, + name = spec.get("name"), + faction_slug = spec.get("faction"), + location_slug = spec.get("location"), + seed = spec.get("seed"), + tier_override = spec.get("tier"), + ) + created.append(char) + return created diff --git a/src/engine/tick.py b/src/engine/tick.py index e7e5390..d96ea23 100644 --- a/src/engine/tick.py +++ b/src/engine/tick.py @@ -41,7 +41,8 @@ def process_tick(session_id: int, day: int, tick: int, mode: str): - Logue le tick """ phase = get_phase(tick) - characters = db.get_characters(session_id) + characters = db.get_characters(session_id) # tier 1-3 : boucle survival/actions + all_characters = db.get_all_characters(session_id) # tous tiers : recalcul seeds world_states = db.get_world_state(session_id, day) world_map = {ws["location_slug"]: ws for ws in world_states} @@ -60,8 +61,8 @@ def process_tick(session_id: int, day: int, tick: int, mode: str): pop_count=ws.get("pop_count", 0) ) - # -- Recalcul seeds PNJ ---------------------------------------------- - for char in characters: + # -- Recalcul seeds PNJ (tous tiers, y compris boss tier 0) --------- + for char in all_characters: prev_state = db.get_pnj_state(session_id, char["id"], day) stress = prev_state["stress_level"] if prev_state else 0 new_seed = recalculate_seed(char["seed_base"], day, tick, stress) @@ -90,7 +91,7 @@ def process_tick(session_id: int, day: int, tick: int, mode: str): mechanical_effect={"phase": phase, "mode": mode} ) - print(f" [J{day:03d} T{tick:02d}h] {phase.upper():5s} | {len(characters)} PNJ actifs") + print(f" [J{day:03d} T{tick:02d}h] {phase.upper():5s} | {len(characters)} PNJ en sim (tier 1-3)") def _compute_location_tick_delta(ws: dict, phase: str, mode: str) -> dict: