class_name DataCatalog extends RefCounted const MASTER_DATA_PATH := "res://data/game_data_master.json" const SCHEMA_VERSION := 68 const ATTACK_PATTERN_TYPES := ["burst", "sticky", "swipe"] const ATTACK_AREA_SHAPES := ["sector", "rectangle"] const ATTACK_MOVEMENT_TYPES := ["stationary", "unaffected", "linear", "charge", "leap", "retreat", "strafe"] # La frise: un pattern s'ecrit soit a plat (simple), soit en blocs poses sur # cinq pistes (avance). Les deux se jouent par le meme lecteur, un pattern # simple etant compile en frise au chargement. const ATTACK_PATTERN_MODES := ["simple", "advanced"] # Un coup est un instant, une zone qui dure, ou une zone qui tourne. const ATTACK_HIT_MODES := ["instant", "continuous", "rotating"] # Ce qui remplace la zone poursuivante: une zone tient a l'acteur et le suit, # ou se pose dans le monde et y reste, en rampant vers la cible si on lui en # donne la vitesse. const ATTACK_TARGET_MODES := ["aim", "self", "forward", "nearest_enemy", "farthest_enemy", "random_enemy", "lowest_hp_enemy", "highest_hp_enemy", "lowest_hp_ratio_enemy", "highest_hp_ratio_enemy", "random_point", "random_direction"] const ATTACK_ZONE_ANCHORS := ["actor", "world"] # La maniere d'un deplacement; sa direction est un angle, pas un nom. const ATTACK_MOVE_MANNERS := ["linear", "leap", "charge", "shake"] const ATTACK_TIMELINE_TRACKS := ["hits", "warnings", "moves", "armor", "iframes"] const MOVEMENT_PATTERN_TYPES := ["linear", "patrol", "hop", "static"] const HERO_RESPONSES := ["chase", "avoid", "ignore"] # La livraison appartient au pattern d'attaque: le heros et la creature # partagent la meme regle, et une arme se contente de choisir son pattern. const ATTACK_DELIVERIES := ["direct", "projectile"] const PROJECTILE_TYPES := ["orb", "bolt", "disc", "needle"] const WEAPON_SLOT_TYPES := ["main", "secondary", "either"] const EQUIPMENT_TYPES := ["armor", "magic", "trinket"] const CONSUMABLE_MODES := ["eat", "throw"] const UPGRADE_SHAPES := ["circle", "square", "diamond", "hexagon"] const CONDITION_OPERATORS := ["==", "!=", ">=", "<=", ">", "<", "truthy", "falsy"] const VARIABLE_TYPES := ["bool", "int", "float", "string"] const STAT_TYPES := ["int", "float"] const STAT_ROUNDING := ["none", "round", "floor", "ceil"] const UPGRADE_STATES := ["invisible", "mystery", "locked", "available", "insufficient", "purchased"] const UPGRADE_CONNECTOR_PATTERNS := ["solid", "dashed", "dotted"] # Durée de vie d'une progression. `dimension` repart à zéro quand le boss final # de la dimension tombe; `meta` traverse tous les cycles. const PROGRESS_SCOPES := ["dimension", "meta"] const SPEECH_BUBBLE_TRIGGERS := { "player_configs": ["spawn", "hurt", "random"], "enemies": ["spawn", "death", "random"], "customer_profiles": ["arrive", "served", "meal_finished", "disappointed", "leave", "random"], "employees": ["arrive", "task_started", "task_ended", "random"], } const STORY_ACTOR_KINDS := ["hero", "creature", "customer", "employee", "other"] const STORY_ACTOR_COLLECTIONS := { "hero": "player_configs", "creature": "enemies", "customer": "customer_profiles", "employee": "employees", } const STORY_ANCHORS := ["top_left", "top", "top_right", "left", "center", "right", "bottom_left", "bottom", "bottom_right"] const STORY_DIRECTIONS := ["N", "NE", "E", "SE", "S", "SW", "W", "NW"] const STORY_FACINGS := ["left", "right", "up", "down"] const STORY_EVENT_TYPES := ["text", "speech", "wait", "choice", "vfx", "move", "set_value", "face", "visibility", "goto", "end"] # Natures d'un élément de salle. Le genre décide des champs qui comptent; la # validation les accepte tous pour qu'un changement de genre ne perde rien. const ROOM_ELEMENT_KINDS := ["destructible", "trap", "chest", "npc", "shop", "decor"] # Comportements spéciaux cochables sur une salle. Vocabulaire fermé: sans ça, # une faute de frappe deviendrait un comportement silencieusement inerte. const ROOM_BEHAVIOURS := ["treasure", "shop", "npc", "elite", "rest", "puzzle", "ambush", "story"] const DEFAULT_PLAYER_CONFIGS := { "default_player": {"display_name": "Default Player", "starting_hp": 100.0, "starting_stamina": 60.0, "move_speed": 174.0, "move_stamina_per_second": 0.35, "attack_damage": 18.0, "attack_rate": 0.45, "attack_pattern": "hero_fork_thrust", "starting_weapon": "restaurant_fork", "attack_knockback": 600.0, "attack_stamina_cost": 5.0, "damage_variance": 0.12, "dash_speed": 760.0, "dash_duration": 0.16, "dash_cooldown": 0.9, "dash_stamina_cost": 8.0, "dash_charges": 0.0, "dash_charge_cooldown": 0.1}, } # A food category is a shared label. A dish carries one or more; a customer # gives each a weight. It describes nothing else, so it has no bounds and no # behaviour of its own. const DEFAULT_FOOD_CATEGORIES := { "meat": {"display_name": "Viande", "description": "", "color": Color("d1495b")}, "vegetal": {"display_name": "Végétal", "description": "", "color": Color("6a994e")}, "mineral": {"display_name": "Minéral", "description": "", "color": Color("8d99ae")}, "seafood": {"display_name": "Fruits de mer", "description": "", "color": Color("00a5cf")}, "dessert": {"display_name": "Dessert", "description": "", "color": Color("ff9ebb")}, "drink": {"display_name": "Boisson", "description": "", "color": Color("48cae4")}, "trash": {"display_name": "Poubelle", "description": "", "color": Color("6b705c")}, "spicy": {"display_name": "Épicé", "description": "", "color": Color("f4703a")}, "street_food": {"display_name": "Malbouffe", "description": "", "color": Color("ffb703")}, "luxury": {"display_name": "Grande cuisine", "description": "", "color": Color("c9a227")}, } # A projectile is an entity of its own: several attack patterns may fire the # same munition, and a munition no longer belongs to a weapon. Ownership and # lifecycle stay outside it, exactly like an attack pattern. const DEFAULT_PROJECTILES := { "egg_launcher_shot": { "display_name": "Œuf mécanique", "description": "", "type": "orb", "speed": 680.0, "count": 1, "spread_angle": 8.0, "radius": 8.0, "lifetime": 1.8, "color": Color("7dd3fc"), }, } # An authored weapon owns its numbers, never its delivery. Whether a hit lands # as an area or as a volley is a property of the attack pattern it selects. const DEFAULT_WEAPONS := { "restaurant_fork": { "display_name": "Fourchette du restaurant", "description": "L'outil de départ, fiable à courte portée.", "slot_type": "main", "attack_pattern": "hero_fork_thrust", "purchase_cost": 0, "damage_bonus": 0.0, "cooldown_bonus": 0.0, "stamina_bonus": 0.0, "knockback_bonus": 0.0, "damage_multiplier": 1.0, "cooldown_multiplier": 1.0, "stamina_multiplier": 1.0, "knockback_multiplier": 1.0, "respect_occlusion": true, "piercing": 0, "color": Color("ffe64d"), }, "egg_launcher": { "display_name": "Lance-œuf", "description": "Projette une rafale d'œufs mécaniques.", "slot_type": "either", "attack_pattern": "hero_egg_volley", "purchase_cost": 40, "damage_bonus": 50.0, "cooldown_bonus": 0.0, "stamina_bonus": 0.0, "knockback_bonus": 0.0, "damage_multiplier": 0.75, "cooldown_multiplier": 1.2, "stamina_multiplier": 1.15, "knockback_multiplier": 0.55, "respect_occlusion": true, "piercing": 0, "color": Color("7dd3fc"), }, } const DEFAULT_EQUIPMENT := { "iron_apron": { "display_name": "Iron Apron", "description": "A simple armor that raises maximum health.", "type": "armor", "purchase_cost": 60, "color": Color("94a3b8"), "modifiers": [{"variable": "max_health", "layer": "flat", "operation": "add", "value": 25.0}], }, "frost_charm": { "display_name": "Frost Charm", "description": "A magic item that slightly raises attack speed.", "type": "magic", "purchase_cost": 90, "color": Color("7dd3fc"), "modifiers": [{"variable": "weapon_attack_speed_multiplier", "layer": "global", "operation": "add", "value": 0.12}], }, } const DEFAULT_MOVEMENT_PATTERNS := { "linear": {"display_name": "Linear", "type": "linear", "speed_multiplier": 1.0, "detected_speed_multiplier": 1.0, "move_duration": 0.0, "rest_duration": 0.0, "direction_change_min": 1.2, "direction_change_max": 3.2, "idle_chance": 0.0}, "patrol": {"display_name": "Random Patrol", "type": "patrol", "speed_multiplier": 0.38, "detected_speed_multiplier": 0.72, "move_duration": 0.0, "rest_duration": 0.0, "direction_change_min": 1.2, "direction_change_max": 3.2, "idle_chance": 0.22}, "big_leap": {"display_name": "Big Leap", "type": "hop", "speed_multiplier": 1.85, "detected_speed_multiplier": 1.85, "move_duration": 0.38, "rest_duration": 0.95, "direction_change_min": 1.2, "direction_change_max": 3.2, "idle_chance": 0.0}, "small_hop": {"display_name": "Small Hop", "type": "hop", "speed_multiplier": 1.25, "detected_speed_multiplier": 1.25, "move_duration": 0.18, "rest_duration": 0.42, "direction_change_min": 1.2, "direction_change_max": 3.2, "idle_chance": 0.0}, "static": {"display_name": "Static", "type": "static", "speed_multiplier": 0.0, "detected_speed_multiplier": 0.0, "move_duration": 0.0, "rest_duration": 0.0, "direction_change_min": 1.2, "direction_change_max": 3.2, "idle_chance": 0.0}, } const DEFAULT_INGREDIENTS := { "egg": {"display_name": "Egg", "description": "Warm dungeon egg.", "color": Color("ffd166"), "shape": "oval"}, "slime_jelly": {"display_name": "Slime Jelly", "description": "Oddly appetizing green jelly.", "color": Color("70e000"), "shape": "circle"}, "mushroom": {"display_name": "Mushroom", "description": "A plump cave mushroom.", "color": Color("c77dff"), "shape": "triangle"}, "beast_meat": {"display_name": "Beast Meat", "description": "A hearty cut from a dungeon brute.", "color": Color("d96c75"), "shape": "diamond"}, "ember_pepper": {"display_name": "Ember Pepper", "description": "A pepper that stays pleasantly warm.", "color": Color("ff4d00"), "shape": "hexagon"}, } const DEFAULT_RECIPES := { "fried_egg": {"display_name": "Fried Egg", "ingredients": {"egg": 1}, "cook_time": 2.0, "sale_price": 5, "conditions": []}, "slime_omelette": {"display_name": "Slime Omelette", "ingredients": {"egg": 1, "slime_jelly": 1}, "cook_time": 3.0, "sale_price": 12, "conditions": []}, "mushroom_omelette": {"display_name": "Mushroom Omelette", "ingredients": {"egg": 1, "mushroom": 1}, "cook_time": 3.5, "sale_price": 15, "conditions": []}, "beast_hash": {"display_name": "Beast Egg Hash", "ingredients": {"egg": 1, "beast_meat": 1}, "cook_time": 4.0, "sale_price": 20, "conditions": []}, "pepper_scramble": {"display_name": "Pepper Jelly Scramble", "ingredients": {"egg": 1, "slime_jelly": 1, "ember_pepper": 1}, "cook_time": 4.5, "sale_price": 26, "conditions": []}, } const DEFAULT_CUSTOMER_PROFILES := { "regular": {"display_name": "Regular", "color": Color("06d6a0"), "speed": 180.0, "cash": 30, "appetite": 1, "spending": 0.35, "popularity_weight": 5.0, "category_preferences": {}, "patience": 60.0, "meal_consumption_speed": 1.0, "generosity": 0.15, "arrival_weight": 1.0}, "foodie": {"display_name": "Foodie", "color": Color("ffd166"), "speed": 150.0, "cash": 65, "appetite": 3, "spending": 0.8, "popularity_weight": 5.0, "category_preferences": {"luxury": 2.0, "trash": 0.25}, "patience": 75.0, "meal_consumption_speed": 0.8, "generosity": 0.22, "arrival_weight": 0.55}, "generous_guest": {"display_name": "Generous Guest", "color": Color("c77dff"), "speed": 200.0, "cash": 90, "appetite": 2, "spending": 0.6, "popularity_weight": 5.0, "category_preferences": {"dessert": 1.6}, "patience": 48.0, "meal_consumption_speed": 1.3, "generosity": 0.65, "arrival_weight": 0.3}, } static var DEFAULT_GAME_VARIABLES: Dictionary = { "attack_damage": _stat("Attack Damage", "float", 0.0, 0.0, 1.0, 0.0, "combat", "Final player attack damage; its base value comes from the active player profile."), "weapon_damage_multiplier": _stat("Weapon Damage Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied after the equipped weapon's own damage multiplier.", 0.0, 100.0), "weapon_attack_speed_multiplier": _stat("Weapon Attack Speed Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global attack frequency multiplier. Two means cooldowns are divided by two.", 0.05, 100.0), "projectile_speed_multiplier": _stat("Projectile Speed Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied to every player projectile speed.", 0.05, 100.0), "projectile_count_bonus": _stat("Projectile Count Bonus", "int", 0.0, 0.0, 1.0, 0.0, "combat", "Extra projectiles fired by every projectile weapon.", 0.0, 64.0, "round"), "projectile_spread_multiplier": _stat("Projectile Spread Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied to projectile spread angles.", 0.0, 10.0), "projectile_radius_multiplier": _stat("Projectile Radius Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied to projectile collision radii.", 0.05, 10.0), "projectile_lifetime_multiplier": _stat("Projectile Lifetime Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied to projectile lifetimes.", 0.05, 10.0), "weapon_piercing_bonus": _stat("Weapon Piercing Bonus", "int", 0.0, 0.0, 1.0, 0.0, "combat", "Extra bodies crossed by direct and projectile weapon hits.", 0.0, 64.0, "round"), "weapon_knockback_multiplier": _stat("Weapon Knockback Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied after the equipped weapon's own knockback multiplier.", 0.0, 100.0), "weapon_stamina_cost_multiplier": _stat("Weapon Stamina Cost Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "combat", "Global multiplier applied to attack stamina costs.", 0.0, 100.0), "weapon_slot_count": _stat("Main Weapon Slots", "int", 1.0, 0.0, 1.0, 0.0, "combat", "The player equips exactly one main weapon.", 1.0, 1.0, "round"), "secondary_weapon_slot_count": _stat("Secondary Action Slot", "int", 0.0, 0.0, 1.0, 0.0, "combat", "Set to one to unlock the secondary weapon action.", 0.0, 1.0, "round"), "equipment_slot_count": _stat("Equipment Slots", "int", 0.0, 0.0, 1.0, 0.0, "combat", "Passive equipment slots available in the dungeon loadout.", 0.0, 4.0, "round"), "consumable_slot_count": _stat("Consumable Slots", "int", 1.0, 0.0, 1.0, 0.0, "combat", "Number of prepared dish types assignable to dungeon shortcuts.", 1.0, 8.0, "round"), "consumable_capacity": _stat("Consumable Capacity", "int", 1.0, 0.0, 1.0, 0.0, "combat", "Total prepared dishes that can be carried into the dungeon.", 1.0, 999.0, "round"), "max_health": _stat("Maximum Health", "float", 0.0, 0.0, 1.0, 0.0, "combat", "Final player health; its base value comes from the active player profile.", 1.0, 1000000.0), "move_speed": _stat("Movement Speed", "float", 0.0, 0.0, 1.0, 0.0, "movement", "Final player movement speed; its base value comes from the active player profile."), "dash_charges": _stat("Dash Charges", "int", 0.0, 0.0, 1.0, 0.0, "movement", "How many dashes fire before the long cooldown. Zero disables the dash entirely; its base value comes from the active player profile.", 0.0, 20.0, "round"), "dash_charge_cooldown": _stat("Dash Charge Cooldown", "float", 0.0, 0.0, 1.0, 0.0, "movement", "Wait between two dashes of the same burst, before the long cooldown takes over; its base value comes from the active player profile.", 0.0, 60.0), "cook_speed": _stat("Cooking Duration Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "kitchen", "Multiplier applied to recipe cooking duration. 0.70 means 30 percent shorter.", 0.1, 10.0), "payout": _stat("Meal Payout Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "restaurant", "Multiplier applied to each meal payout.", 0.0, 100.0), "patience": _stat("Customer Patience", "float", 0.0, 0.0, 1.0, 0.0, "restaurant", "Final patience; its base value comes from the customer profile."), "stamina": _stat("Dungeon Stamina", "float", 0.0, 0.0, 1.0, 0.0, "dungeon", "Final stamina; its base value comes from the active player profile."), "plates": _stat("Reusable Plates", "int", 1.0, 0.0, 1.0, 0.0, "restaurant", "Total reusable plate capacity.", 0.0, 1000.0, "round"), "player_vision": _stat("Player Vision", "float", 320.0, 0.0, 1.0, 0.0, "restaurant", "Distance in pixels within which the player can read a customer order.", 0.0, 4000.0), # A rate rather than a flag: at 0 the stove needs the player, and any upgrade # raising it lets the stove work alone at that fraction of the manual speed. "cook_idle_rate": _stat("Unattended Cooking Rate", "float", 0.0, 0.0, 1.0, 0.0, "kitchen", "Fraction of the manual cooking speed the stove keeps when nobody holds the action. 0 means it needs the player.", 0.0, 4.0), # How long a read order stays legible once the player walks away. Upgradable. "order_memory_seconds": _stat("Order Memory", "float", 8.0, 0.0, 1.0, 0.0, "restaurant", "Seconds a customer order stays readable after the player has moved out of vision range.", 0.0, 3600.0), # A complete takeout lane: installation, finished stock, production, service, # its own outside line, customer appeal and the cooks' task priority. "takeout_counter": _stat("Takeout Counter", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "Set to 1 once the takeout counter by the street is installed.", 0.0, 1.0, "round"), "takeout_stock_capacity": _stat("Takeout Stock Capacity", "int", 4.0, 0.0, 1.0, 0.0, "restaurant", "Maximum finished dishes accumulated for takeout.", 1.0, 100.0, "round"), "takeout_cook_speed": _stat("Takeout Cooking Speed", "float", 1.0, 0.0, 1.0, 0.0, "kitchen", "Production rate multiplier while the player or a cook works at the takeout counter.", 0.05, 100.0), "takeout_service_speed": _stat("Takeout Service Speed", "float", 0.0333333333, 0.0, 1.0, 0.0, "restaurant", "Takeout customers served per second while finished stock is available.", 0.001, 100.0), "takeout_queue_capacity": _stat("Takeout Queue Capacity", "int", 4.0, 0.0, 1.0, 0.0, "restaurant", "How many customers may wait in the takeout line outside.", 0.0, 50.0, "round"), "takeout_appeal": _stat("Takeout Appeal", "float", 0.35, 0.0, 1.0, 0.0, "restaurant", "Base chance that a passer-by wants the selected takeout dish.", 0.0, 1.0), # Un plat emporté se vend moins cher qu'à table: le comptoir ne sert ni la # salle ni le service. Le coefficient est une statistique, donc améliorable. # Le permis de chantier. Zero veut dire « on ne rearrange rien »: le mode # construction est une capacite qui s'achete, pas un outil offert d'emblee. "build_permit": _stat("Building Permit", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "Unlocks the build mode that rearranges the dining room.", 0.0, 1.0, "round"), "takeout_price_ratio": _stat("Takeout Price Ratio", "float", 0.75, 0.0, 1.0, 0.0, "restaurant", "Part of the dish sale price charged at the takeout counter.", 0.0, 5.0), "takeout_priority": _stat("Takeout Cook Priority", "float", 0.75, 0.0, 1.0, 0.0, "kitchen", "How strongly autonomous cooks prioritize replenishing takeout stock against dining-room orders.", 0.0, 10.0), # Postes d'épicerie. Zéro veut dire pas d'épicerie: le raffinage n'existe pas. "spice_station_count": _stat("Spice Stations", "int", 0.0, 0.0, 1.0, 0.0, "kitchen", "How many refining stations turn a cooked dish into a superior one.", 0.0, 2.0, "round"), # Ce que vaut un plat supérieur, en multiple de son prix de base. Une branche # d'upgrades monte ce seul nombre: une progression sans contenu neuf. "superior_payout": _stat("Superior Payout", "float", 1.5, 0.0, 1.0, 0.0, "restaurant", "Sale price multiplier for a dish that came out of the spice station.", 1.0, 10.0), # Emplacements de l'étagère. Distinct de `plates`: le nombre d'assiettes # possédées et la place pour les poser ne sont pas la même contrainte. "shelf_slots": _stat("Shelf Slots", "int", 3.0, 0.0, 1.0, 0.0, "kitchen", "How many plates the shelf can hold at once.", 1.0, 12.0, "round"), # Le meuble lui-même est une progression distincte de sa capacité. Zéro # retire l'étagère de la salle et de la palette jusqu'à son upgrade. "shelf_count": _stat("Open Shelves", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "How many service shelves are unlocked. Zero keeps the fixture unavailable.", 0.0, 1.0, "round"), # Slots de brigade. Zéro au départ: la brigade est une progression entière. "server_count": _stat("Servers", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "How many servers can work the floor at once.", 0.0, 6.0, "round"), "cook_count": _stat("Cooks", "int", 0.0, 0.0, 1.0, 0.0, "kitchen", "How many cooks can work the stations at once.", 0.0, 6.0, "round"), # Zéro veut dire que la fonction Employés n'existe pas encore. Le premier # point ouvre l'unique casier et révèle définitivement l'onglet Brigade. "employee_locker_count": _stat("Employee Lockers", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "How many employee lockers are unlocked. Zero keeps the employee function unavailable.", 0.0, 1.0, "round"), "market_count": _stat("Market Links", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "How many market links are unlocked. Zero keeps ingredient selling unavailable.", 0.0, 1.0, "round"), "wash_time": _stat("Wash Time", "float", 5.0, 0.0, 1.0, 0.0, "restaurant", "Seconds a dirty plate spends in the sink before it is clean again.", 0.1, 120.0, "none"), "wash_lines": _stat("Wash Lines", "int", 1.0, 0.0, 1.0, 0.0, "restaurant", "How many plates the sink scrubs at once. The rest wait in line.", 1.0, 8.0, "round"), # La console de gestion. Zéro veut dire qu'elle n'est pas installée: le menu # du jour se règle alors au fourneau, et la fermeture reste subie. "management_console": _stat("Management Console", "int", 1.0, 0.0, 1.0, 0.0, "restaurant", "The management console is part of the restaurant from the first day: it carries the day's menu, the open/closed switch and the hiring board. Set it to 0 to take the console away.", 0.0, 1.0, "round"), # Combien de postes de cuisson la cuisine compte. Un seul au départ: les # suivants s'ouvrent par upgrade, comme les tabourets. "stove_count": _stat("Stoves", "int", 1.0, 0.0, 1.0, 0.0, "kitchen", "How many cooking stations run in parallel.", 1.0, 4.0, "round"), # Combien de commandes l'ardoise peut porter. **Zéro veut dire pas d'ardoise**: # le restaurant démarre sans, et le jeu tourne sur la mémoire du joueur tant # qu'une upgrade n'en ouvre pas les lignes. "order_board_slots": _stat("Order Board Slots", "int", 0.0, 0.0, 1.0, 0.0, "restaurant", "Orders the board can hold at once. Zero means the restaurant has no board and orders live in the player's memory.", 0.0, 40.0, "round"), # La valeur du profil sert de base a ces statistiques: l'upgrade empile # par-dessus, exactement comme `patience` le fait deja. "customer_cash": _stat("Customer Cash", "float", 30.0, 0.0, 1.0, 0.0, "restaurant", "Budget d'un client a son arrivee. La fiche du profil fournit la base.", 0.0, 1000000.0), "customer_appetite": _stat("Customer Appetite", "int", 1.0, 0.0, 1.0, 0.0, "restaurant", "Nombre de repas commandes par visite. La fiche du profil fournit la base.", 1.0, 10.0, "round"), "customer_spending": _stat("Customer Spending", "float", 0.5, 0.0, 1.0, 0.0, "restaurant", "0 pousse vers le plat le moins cher accessible, 1 vers le plus cher.", 0.0, 1.0), "customer_generosity": _stat("Customer Generosity", "float", 0.15, 0.0, 1.0, 0.0, "restaurant", "Part du paiement laissee en pourboire, bonifiee par un service rapide.", 0.0, 1.0), "repeat_meal_payout_step": _stat("Repeat Meal Payout Step", "float", 0.15, 0.0, 1.0, 0.0, "restaurant", "Palier ajoute au paiement pour chaque plat deja mange par le meme client.", 0.0, 100.0), "customer_popularity_weight": _stat("Customer Popularity Weight", "float", 5.0, 0.0, 1.0, 0.0, "restaurant", "Ce que vaut un client pour la reputation. La fiche du profil fournit la base.", 0.0, 1000.0), # Chaque situation pondere ce poids. Le montant vaut toujours # `poids x coefficient x multiplicateur global`, et c'est le seul calcul. "popularity_coef_served": _stat("Reputation: Meal Served", "float", 1.0, 0.0, 1.0, 0.0, "restaurant", "Coefficient applique quand un plat est servi.", 0.0, 1000.0), "popularity_coef_served_fast": _stat("Reputation: Fast Service", "float", 1.1, 0.0, 1.0, 0.0, "restaurant", "Coefficient applique quand le plat arrive avant que la patience baisse.", 0.0, 1000.0), "popularity_coef_takeout_served": _stat("Reputation: Takeout Sale", "float", 0.5, 0.0, 1.0, 0.0, "restaurant", "Coefficient applique a une vente au comptoir.", 0.0, 1000.0), "popularity_coef_left_waiting": _stat("Reputation: Left Waiting", "float", 0.5, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand un client part sans avoir ete servi.", 0.0, 1000.0), "popularity_coef_left_closed": _stat("Reputation: Left On Closing", "float", 0.05, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand la fermeture renvoie un client de la salle.", 0.0, 1000.0), "popularity_coef_order_changed": _stat("Reputation: Order Changed", "float", 0.1, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand un client doit changer de commande.", 0.0, 1000.0), "popularity_coef_service_overtaken": _stat("Reputation: Served Out of Order", "float", 0.1, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire pour chaque client attendant son repas depuis plus longtemps quand un autre est servi. Un changement force de commande conserve son anciennete.", 0.0, 1000.0), "popularity_coef_queue_gave_up": _stat("Reputation: Queue Gave Up", "float", 0.2, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand quelqu'un renonce a attendre dans la file.", 0.0, 1000.0), "popularity_coef_queue_closed": _stat("Reputation: Queue Dispersed", "float", 0.025, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand le rideau tombe sur une file qui attendait.", 0.0, 1000.0), "popularity_coef_takeout_left": _stat("Reputation: Takeout Left", "float", 0.25, 0.0, 1.0, 0.0, "restaurant", "Coefficient retire quand un client quitte la file du comptoir.", 0.0, 1000.0), "popularity_gain_multiplier": _stat("Reputation Gain Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "restaurant", "Multiplie toutes les hausses de reputation, quelle que soit la situation.", 0.0, 1000.0), "popularity_loss_multiplier": _stat("Reputation Loss Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "restaurant", "Multiplie toutes les baisses de reputation, quelle que soit la situation.", 0.0, 1000.0), # Le curseur de permissivite des hurtbox, en pixels. Il elargit ce qu'une # attaque touche, jamais ce qu'elle porte. "hurtbox_margin": _stat("Hurtbox Margin", "float", 4.0, 0.0, 1.0, 0.0, "combat", "Pixels ajoutes au rayon de toute hurtbox. Le curseur de permissivite des coups.", 0.0, 64.0), "fast_service_ratio": _stat("Fast Service Threshold", "float", 0.6, 0.0, 1.0, 0.0, "restaurant", "Part de patience qui doit rester pour qu'un service compte comme rapide.", 0.0, 1.0), # How many recipes may sit on the menu at once. Upgradable. # Combien de créatures peuplent une salle. Le poids décide de leur nature, # cette statistique décide de leur nombre. "room_population_bonus": _stat("Opening Population Bonus", "int", 0.0, 0.0, 1.0, 0.0, "dungeon", "Creatures added to or removed from a room's opening wave.", -200.0, 200.0, "round"), "wave_population_bonus": _stat("Wave Population Bonus", "int", 0.0, 0.0, 1.0, 0.0, "dungeon", "Creatures added to or removed from every wave after the opening one.", -200.0, 200.0, "round"), "wave_spawn_clearance": _stat("Wave Spawn Clearance", "float", 260.0, 0.0, 1.0, 0.0, "dungeon", "How far a reinforcement wave keeps away from the player when it lands.", 0.0, 2000.0), "wave_trigger_bonus": _stat("Wave Trigger Bonus", "float", 0.0, 0.0, 1.0, 0.0, "dungeon", "Raises every declared thinning threshold, so waves arrive with more of the previous one still standing.", -1.0, 1.0), # Les renforts sont un second budget, pas une seconde vague codée en dur: à # zéro la salle se vide d'un coup, ce qui reste un comportement valide. "reward_choices": _stat("Reward Choices", "int", 3.0, 0.0, 1.0, 0.0, "dungeon", "How many rewards are offered after a room is cleared.", 0.0, 12.0, "round"), # Nombre moyen de jets par ligne de butin. La partie entière est certaine; # la fraction devient la chance d'un jet de plus (1,25 = 1 + 25 %). "drop_count_multiplier": _stat("Drop Count Multiplier", "float", 1.0, 0.0, 1.0, 0.0, "dungeon", "Average attempts made for every configured drop row. The whole part is guaranteed and the fraction is the chance of one extra attempt.", 0.0, 100.0), # Rayon autour du héros dans lequel un ramassable libre vient à lui. Zéro # conserve le ramassage manuel au contact du prototype de base. "drop_magnet_range": _stat("Drop Magnet Range", "float", 0.0, 0.0, 1.0, 0.0, "dungeon", "Distance in pixels within which free ingredient drops are attracted to the player.", 0.0, 4000.0), "drop_magnet_speed": _stat("Drop Magnet Speed", "float", 520.0, 0.0, 1.0, 0.0, "dungeon", "Speed in pixels per second at which attracted ingredient drops move toward the player.", 0.0, 5000.0), # Assurance: fraction de ce qui a été amassé pendant la plongée qui survit à # une mise hors service. À zéro, une mort coûte tout, comme avant. "insurance_rate": _stat("Dive Insurance", "float", 0.0, 0.0, 1.0, 0.0, "dungeon", "Fraction of the coins and materials gathered during a dive that survive a knockout.", 0.0, 1.0), # Trottoir et file d'attente. Le passant décide seul de s'arrêter; ces valeurs # décident du flux qu'il faut convaincre et de ce qu'il peut observer. # Nombre de tables ouvertes. Une statistique plutôt qu'une liste d'upgrades # nommées: passer à 25 tables ne demande alors aucune ligne de code. "table_count": _stat("Open Tables", "int", 2.0, 0.0, 1.0, 0.0, "restaurant", "How many single-stool tables are open for business.", 0.0, 5.0, "round"), "sidewalk_traffic": _stat("Sidewalk Traffic", "float", 10.0, 0.0, 1.0, 0.0, "restaurant", "How many people walk past the restaurant every minute.", 0.0, 600.0), "queue_capacity": _stat("Queue Capacity", "int", 4.0, 0.0, 1.0, 0.0, "restaurant", "How many people may wait in line outside before newcomers walk on.", 0.0, 50.0, "round"), "throughput_window": _stat("Service Window", "float", 90.0, 0.0, 1.0, 0.0, "restaurant", "Seconds of recent history a passer-by looks at to judge how fast this restaurant serves.", 5.0, 3600.0), } const EFFECT_STACKING := ["unique", "refresh", "stack", "stack_refresh", "strongest", "extend"] const EFFECT_FLAGS := ["can_move", "can_attack", "can_interact"] const EFFECT_KINDS := ["fire", "poison", "ice", "electricity", "buff", "other"] const EFFECT_STACK_SCALING := ["none", "damage", "duration", "application_chance", "control_chance", "slow"] # A temporary effect combines three things, which together cover every case: # stat modifiers pushed onto the target, periodic tick actions, and capability # flags. Paralysis is a flag rather than a speed of zero, because a null speed # stops neither an attack nor an animation already playing. const DEFAULT_EFFECTS := { "burning": { "display_name": "Brûlure", "description": "Dégâts de feu périodiques.", "kind": "fire", "duration": 5.0, "stacking": "stack", "max_stacks": 3, "stack_scaling": "damage", "stack_bonus": 1.0, "modifiers": [], "tick_interval": 1.0, "tick_actions": [{"op": "damage", "amount": 3.0, "per_stack": true}], "flags": [], "movement_multiplier": 1.0, "control_chance": 0.0, "control_duration": 0.0, "control_flags": [], "color": Color("ff6b35"), }, "poisoned": { "display_name": "Poison", "description": "Dégâts persistants; chaque dose prolonge la durée.", "kind": "poison", "duration": 6.0, "stacking": "extend", "max_stacks": 1, "stack_scaling": "duration", "stack_bonus": 1.0, "modifiers": [], "tick_interval": 1.0, "tick_actions": [{"op": "damage", "amount": 2.0, "per_stack": false}], "flags": [], "movement_multiplier": 1.0, "control_chance": 0.0, "control_duration": 0.0, "control_flags": [], "color": Color("65a30d"), }, "chilled": { "display_name": "Givre", "description": "Ralentit la cible et peut la geler complètement.", "kind": "ice", "duration": 4.0, "stacking": "stack_refresh", "max_stacks": 3, "stack_scaling": "control_chance", "stack_bonus": 0.15, "modifiers": [], "tick_interval": 0.0, "tick_actions": [], "flags": [], "movement_multiplier": 0.72, "control_chance": 0.15, "control_duration": 1.1, "control_flags": ["can_move"], "color": Color("67e8f9"), }, "electrified": { "display_name": "Électrisation", "description": "Peut paralyser brièvement la cible.", "kind": "electricity", "duration": 2.5, "stacking": "refresh", "max_stacks": 1, "stack_scaling": "none", "stack_bonus": 0.0, "modifiers": [], "tick_interval": 0.0, "tick_actions": [], "flags": [], "movement_multiplier": 1.0, "control_chance": 0.35, "control_duration": 1.25, "control_flags": ["can_move", "can_attack"], "color": Color("facc15"), }, "hasted": { "display_name": "Hâte", "description": "Accélère temporairement le joueur.", "kind": "buff", "duration": 6.0, "stacking": "refresh", "max_stacks": 1, "stack_scaling": "none", "stack_bonus": 0.0, "modifiers": [{"variable": "move_speed", "layer": "global", "operation": "add", "value": 0.35}], "tick_interval": 0.0, "tick_actions": [], "flags": [], "movement_multiplier": 1.0, "control_chance": 0.0, "control_duration": 0.0, "control_flags": [], "color": Color("4cc9f0"), }, "paralyzed": { "display_name": "Paralysie", "description": "Bloque le mouvement et les attaques.", "kind": "electricity", "duration": 2.0, "stacking": "refresh", "max_stacks": 1, "stack_scaling": "none", "stack_bonus": 0.0, "modifiers": [], "tick_interval": 0.0, "tick_actions": [], "flags": ["can_move", "can_attack"], "movement_multiplier": 1.0, "control_chance": 0.0, "control_duration": 0.0, "control_flags": [], "color": Color("b5179e"), }, } const DEFAULT_GAME_FACTS := { "enemies_defeated": {"display_name": "Enemies Defeated", "type": "int", "default_value": 0, "category": "combat", "description": "Total defeated enemies.", "persistent": true}, "slime_kills": {"display_name": "Slimes Defeated", "type": "int", "default_value": 0, "category": "combat", "description": "Total defeated slimes.", "persistent": true}, "dishes_served": {"display_name": "Dishes Served", "type": "int", "default_value": 0, "category": "restaurant", "description": "Total meals delivered.", "persistent": true}, "dungeon_runs": {"display_name": "Dungeon Runs", "type": "int", "default_value": 0, "category": "dungeon", "description": "Total dungeon entries.", "persistent": true}, "boss_defeated": {"display_name": "Boss Defeated", "type": "bool", "default_value": false, "category": "boss", "description": "Set when the first boss is defeated.", "persistent": true}, # A stored running total, not a computed stat: it accumulates from events. "restaurant_popularity": {"display_name": "Restaurant Popularity", "type": "float", "default_value": 0.0, "category": "restaurant", "description": "Running reputation of the restaurant. Rises on every meal served, falls when a customer leaves unserved.", "persistent": true}, # Portée meta: la fourchette maîtresse et le compte des dimensions traversées # survivent au cycle, tout le reste repart à zéro. "master_fork_owned": {"display_name": "Master Fork", "type": "bool", "default_value": false, "category": "dimension", "description": "Set when the trial dungeon has yielded the master fork.", "persistent": true, "scope": "meta"}, "dimensions_cleared": {"display_name": "Dimensions Cleared", "type": "int", "default_value": 0, "category": "dimension", "description": "How many dimensions have had their final boss defeated.", "persistent": true, "scope": "meta"}, } static var DEFAULT_UPGRADE_THEMES: Dictionary = { "core_cyan": _upgrade_theme("Cyan central", "hexagon", "#164e63", "#67e8f9", "#ecfeff"), "combat_crimson": _upgrade_theme("Combat cramoisi", "diamond", "#7f1d1d", "#fb7185", "#fff1f2"), "exploration_blue": _upgrade_theme("Exploration azur", "circle", "#1e3a8a", "#60a5fa", "#eff6ff"), "kitchen_amber": _upgrade_theme("Cuisine ambre", "square", "#78350f", "#fbbf24", "#fffbeb"), "service_green": _upgrade_theme("Service verdoyant", "circle", "#14532d", "#4ade80", "#f0fdf4"), "special_violet": _upgrade_theme("Spécial violet", "hexagon", "#581c87", "#c084fc", "#faf5ff"), } static var DEFAULT_UPGRADE_NODES: Dictionary = { "big_egg_core": _upgrade_node("BIG EGG", "BEGIN", "The central idea. Purchase it for free to reveal every progression branch.", "core", "", 0.0, 0.0, 0, [], [], [], [], "core_cyan"), "casier": _upgrade_node("Casier d'employés", "CASIER", "", "general", "big_egg_core", 90.0, 1600.0, 10, [{"variable": "employee_locker_count", "layer": "flat", "operation": "add", "value": 1.0}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [], "core_cyan", false), "sharp_fork": _upgrade_node("Sharp Fork", "+8 ATK", "Sharpen the restaurant fork into a dungeon weapon. Adds 8 attack damage.", "combat", "big_egg_core", 0.0, 175.0, 5, [{"variable": "attack_damage", "layer": "flat", "operation": "add", "value": 8.0}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], "combat_crimson"), "sturdy_shell": _upgrade_node("Sturdy Shell", "+30 HP", "A defensive shell. Requires combat experience and adds 30 maximum health.", "combat", "sharp_fork", 0.0, 165.0, 25, [{"variable": "max_health", "layer": "flat", "operation": "add", "value": 30.0}], [_upgrade_condition("upgrade_nodes.sharp_fork.purchased", "truthy", true)], [_upgrade_condition("fact.enemies_defeated", ">=", 3)], [_upgrade_condition("fact.enemies_defeated", ">=", 5)], "combat_crimson"), "quick_feet": _upgrade_node("Quick Feet", "+35 SPD", "Move faster in the restaurant and dungeon.", "movement", "big_egg_core", 270.0, 190.0, 30, [{"variable": "move_speed", "layer": "flat", "operation": "add", "value": 35.0}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], "exploration_blue"), "deep_breath": _upgrade_node("Deep Breath", "+20 STA", "Carry 20 more seconds of stamina into each dungeon round.", "dungeon", "quick_feet", 270.0, 170.0, 18, [{"variable": "stamina", "layer": "flat", "operation": "add", "value": 20.0}], [_upgrade_condition("upgrade_nodes.quick_feet.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.quick_feet.purchased", "truthy", true)], "exploration_blue"), "hot_pan": _upgrade_node("Hot Pan", "-30% COOK", "A hotter pan reduces cooking duration by 30 percent.", "kitchen", "big_egg_core", 90.0, 190.0, 25, [{"variable": "cook_speed", "layer": "scaling", "operation": "add", "value": -0.3}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], "kitchen_amber"), "first_shelf": _upgrade_node("Service Shelf", "+1 SHELF", "Install the first shelf for prepared plates.", "kitchen", "hot_pan", 135.0, 175.0, 1, [{"variable": "shelf_count", "layer": "flat", "operation": "add", "value": 1.0}], [_upgrade_condition("upgrade_nodes.hot_pan.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.hot_pan.purchased", "truthy", true)], "kitchen_amber"), "fancy_plating": _upgrade_node("Fancy Plating", "+40% PAY", "Presentation raises every meal payout by 40 percent through its coefficient.", "kitchen", "hot_pan", 90.0, 170.0, 35, [{"variable": "payout", "layer": "scaling", "operation": "add", "value": 0.4}], [_upgrade_condition("upgrade_nodes.hot_pan.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.hot_pan.purchased", "truthy", true)], "kitchen_amber"), "comfy_chairs": _upgrade_node("Comfy Chairs", "+30s PAT", "Customers wait 30 seconds longer for each order.", "service", "big_egg_core", 180.0, 175.0, 20, [{"variable": "patience", "layer": "flat", "operation": "add", "value": 30.0}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], "service_green"), "second_table": _upgrade_node("Second Table", "+1 TABLE", "Open one more stool once the restaurant has proven it can serve.", "service", "comfy_chairs", 225.0, 180.0, 30, [{"variable": "table_count", "layer": "flat", "operation": "add", "value": 1.0}], [_upgrade_condition("upgrade_nodes.comfy_chairs.purchased", "truthy", true)], [_upgrade_condition("fact.dishes_served", ">=", 2)], [_upgrade_condition("fact.dishes_served", ">=", 4)], "service_green"), "third_table": _upgrade_node("Third Table", "+1 TABLE", "Open one more stool.", "service", "second_table", 180.0, 170.0, 60, [{"variable": "table_count", "layer": "flat", "operation": "add", "value": 1.0}], [_upgrade_condition("upgrade_nodes.second_table.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.second_table.purchased", "truthy", true)], "service_green"), "second_plate": _upgrade_node("Second Plate", "PLATE II", "Add a second reusable plate to the restaurant.", "plates", "big_egg_core", 135.0, 185.0, 15, [{"variable": "plates", "layer": "flat", "operation": "add", "value": 1}], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.big_egg_core.purchased", "truthy", true)], "special_violet"), "third_plate": _upgrade_node("Third Plate", "PLATE III", "Add a third reusable plate to the restaurant.", "plates", "second_plate", 135.0, 165.0, 35, [{"variable": "plates", "layer": "flat", "operation": "add", "value": 1}], [_upgrade_condition("upgrade_nodes.second_plate.purchased", "truthy", true)], [], [_upgrade_condition("upgrade_nodes.second_plate.purchased", "truthy", true)], "special_violet"), "boss_trophy": _upgrade_node("Boss Trophy", "?", "A secret payout upgrade revealed by defeating a boss.", "boss", "sturdy_shell", 45.0, 180.0, 75, [{"variable": "payout", "layer": "flat", "operation": "add", "value": 0.2}], [_upgrade_condition("upgrade_nodes.sturdy_shell.purchased", "truthy", true)], [_upgrade_condition("fact.boss_defeated", "truthy", true)], [_upgrade_condition("fact.boss_defeated", "truthy", true)], "special_violet"), } const DEFAULT_ATTACK_PATTERNS := { "hero_fork_thrust": {"display_name": "Estoc de fourchette", "type": "burst", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 175.0, "trigger_range": 0.0, "angle": 15.0, "windup": 0.0, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "stationary", "movement_distance": 0.0, "movement_speed": 0.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "color": Color("ffe64d")}, "chicken_cone": {"display_name": "Coup de bec", "type": "burst", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 78.0, "trigger_range": 0.0, "angle": 72.0, "windup": 0.55, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "linear", "movement_distance": 32.0, "movement_speed": 190.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "color": Color("ff784f")}, "fungling_wide_cone": {"display_name": "Souffle de spores", "type": "burst", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 66.0, "trigger_range": 0.0, "angle": 108.0, "windup": 0.70, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "stationary", "movement_distance": 0.0, "movement_speed": 0.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "color": Color("c77dff")}, "boar_circle": {"display_name": "Onde circulaire", "type": "burst", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 46.0, "trigger_range": 0.0, "angle": 360.0, "windup": 0.65, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "charge", "movement_distance": 96.0, "movement_speed": 360.0, "movement_acceleration": 900.0, "movement_windup_distance": 14.0, "movement_arc_height": 0.0, "color": Color("ff6b35")}, "sticky_circle": {"display_name": "Cercle collant", "type": "sticky", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 48.0, "trigger_range": 0.0, "angle": 360.0, "windup": 0.9, "tracking_speed": 260.0, "escape_grace_duration": 0.45, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "retreat", "movement_distance": 48.0, "movement_speed": 150.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "color": Color("f72585")}, "orbit_swipe": {"display_name": "Balayage orbital", "type": "swipe", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 98.0, "angle": 58.0, "windup": 0.45, "active_duration": 1.8, "rotation_speed": 210.0, "tick_interval": 0.35, "movement_type": "strafe", "movement_distance": 120.0, "movement_speed": 95.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "color": Color("ff3c38")}, "hero_egg_volley": {"display_name": "Volée d'œufs", "type": "burst", "area_shape": "sector", "area_width": 0.0, "show_hit_warning": true, "show_movement_warning": true, "range": 175.0, "trigger_range": 0.0, "angle": 15.0, "windup": 0.0, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "movement_type": "stationary", "movement_distance": 0.0, "movement_speed": 0.0, "movement_acceleration": 0.0, "movement_windup_distance": 0.0, "movement_arc_height": 0.0, "delivery": "projectile", "projectile": "egg_launcher_shot", "color": Color("7dd3fc")}, } const DEFAULT_ENEMIES := { "slime": {"display_name": "Slime", "spawn_weight": 2, "spawn_min": 0, "spawn_max": 6, "min_size": 1.5, "max_size": 2.0, "shape": "circle", "health": 34.0, "speed": 72.0, "direct_hit": true, "direct_damage_min": 7.2, "direct_damage_max": 8.8, "direct_hit_cooldown": 1.15, "direct_hit_knockback": 340.0, "attacks": [], "detection_range": 190.0, "detection_angle": 230.0, "undetected_movement": "patrol", "detected_movement": "linear", "hero_response": "chase", "knockback_resistance": 0.0, "color": Color("ef476f"), "drops": [{"ingredient_id": "slime_jelly", "chance": 100.0}]}, "chicken_beast": {"display_name": "Chicken Beast", "spawn_weight": 2, "spawn_min": 0, "spawn_max": 6, "min_size": 1.5, "max_size": 2.0, "shape": "triangle", "health": 54.0, "speed": 104.0, "direct_hit": false, "direct_damage_min": 10.8, "direct_damage_max": 13.2, "direct_hit_cooldown": 1.0, "direct_hit_knockback": 460.0, "attacks": [{"pattern": "chicken_cone", "damage_min": 10.8, "damage_max": 13.2, "knockback": 460.0, "cooldown": 1.65, "weight": 1.0}], "detection_range": 235.0, "detection_angle": 175.0, "undetected_movement": "patrol", "detected_movement": "small_hop", "hero_response": "chase", "knockback_resistance": 0.12, "color": Color("ff7b00"), "drops": [{"ingredient_id": "egg", "chance": 100.0}]}, "fungling": {"display_name": "Fungling", "spawn_weight": 2, "spawn_min": 0, "spawn_max": 6, "min_size": 1.5, "max_size": 2.0, "shape": "hexagon", "health": 42.0, "speed": 82.0, "direct_hit": false, "direct_damage_min": 9.0, "direct_damage_max": 11.0, "direct_hit_cooldown": 1.0, "direct_hit_knockback": 400.0, "attacks": [{"pattern": "fungling_wide_cone", "damage_min": 9.0, "damage_max": 11.0, "knockback": 400.0, "cooldown": 2.0, "weight": 1.0}], "detection_range": 210.0, "detection_angle": 205.0, "undetected_movement": "small_hop", "detected_movement": "linear", "hero_response": "chase", "knockback_resistance": 0.05, "color": Color("9d4edd"), "drops": [{"ingredient_id": "mushroom", "chance": 100.0}]}, "boar_brute": {"display_name": "Boar Brute", "spawn_weight": 2, "spawn_min": 0, "spawn_max": 6, "min_size": 1.5, "max_size": 2.0, "shape": "square", "health": 82.0, "speed": 94.0, "direct_hit": false, "direct_damage_min": 14.4, "direct_damage_max": 17.6, "direct_hit_cooldown": 1.0, "direct_hit_knockback": 580.0, "attacks": [{"pattern": "boar_circle", "damage_min": 14.4, "damage_max": 17.6, "knockback": 580.0, "cooldown": 1.8, "weight": 1.0}], "detection_range": 220.0, "detection_angle": 145.0, "undetected_movement": "linear", "detected_movement": "big_leap", "hero_response": "chase", "knockback_resistance": 0.30, "color": Color("b5654d"), "drops": [{"ingredient_id": "beast_meat", "chance": 100.0}]}, "pepper_imp": {"display_name": "Pepper Imp", "spawn_weight": 2, "spawn_min": 0, "spawn_max": 6, "min_size": 1.5, "max_size": 2.0, "shape": "diamond", "health": 46.0, "speed": 116.0, "direct_hit": false, "direct_damage_min": 9.9, "direct_damage_max": 12.1, "direct_hit_cooldown": 1.0, "direct_hit_knockback": 370.0, "attacks": [{"pattern": "orbit_swipe", "damage_min": 9.9, "damage_max": 12.1, "knockback": 370.0, "cooldown": 1.6, "weight": 1.0}], "detection_range": 255.0, "detection_angle": 125.0, "undetected_movement": "patrol", "detected_movement": "small_hop", "hero_response": "chase", "knockback_resistance": 0.08, "color": Color("ff3c38"), "drops": [{"ingredient_id": "ember_pepper", "chance": 100.0}]}, } # --- Structure d'une plongée ------------------------------------------------ # # Une plongée traverse les biomes d'une dimension dans l'ordre, chacun comptant # un nombre de salles, puis se termine par la salle de boss de la dimension. # # Un biome est une palette, rien d'autre: de quoi la salle a l'air. Ni sa # population ni ses créatures n'en dépendent — chaque case les déclare, et ce # qu'elle ne déclare pas retombe sur la statistique et le bestiaire complet. const DEFAULT_BIOMES := { "mossy_cellar": {"display_name": "Mossy Cellar", "description": "The damp cellar below the restaurant.", "floor_color": Color("18233a"), "wall_color": Color("53617b"), "accent_color": Color("31466f"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "feather_roost": {"display_name": "Feather Roost", "description": "A nesting ground that never sleeps.", "floor_color": Color("2a2118"), "wall_color": Color("7b6a53"), "accent_color": Color("6f5a31"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "spore_gallery": {"display_name": "Spore Gallery", "description": "Long tunnels lit by breathing caps.", "floor_color": Color("21183a"), "wall_color": Color("6b537b"), "accent_color": Color("55316f"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "ember_burrow": {"display_name": "Ember Burrow", "description": "Heat rises through cracked stone.", "floor_color": Color("3a1818"), "wall_color": Color("7b5353"), "accent_color": Color("6f3131"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "frozen_pantry": {"display_name": "Frozen Pantry", "description": "A storeroom that outlived its cooks.", "floor_color": Color("18303a"), "wall_color": Color("53737b"), "accent_color": Color("316a6f"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "void_kitchen": {"display_name": "Void Kitchen", "description": "The last kitchen, beyond the fifth biome.", "floor_color": Color("120c1c"), "wall_color": Color("3d3352"), "accent_color": Color("2b1f45"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, "fork_sanctum": {"display_name": "Fork Sanctum", "description": "A single sealed biome holding the master fork.", "floor_color": Color("1c1230"), "wall_color": Color("6d53a0"), "accent_color": Color("46318f"), "ambient_color": Color("ffffff"), "ambient_energy": 1.0}, } # Une dimension est un cycle complet. `order >= 1` place la dimension dans le # cycle principal, `order == 0` en fait une plongée d'épreuve hors cycle. # Une dimension n'est plus qu'une colonne nommée. Tout ce qui la remplissait — # biomes traversés, boss, verrou — appartient désormais aux salles elles-mêmes. # `order >= 1` la place dans le cycle, `order = 0` en fait une épreuve hors cycle # atteinte seulement sur convocation. const DEFAULT_DIMENSIONS := { "dimension_one": {"display_name": "First Dimension", "description": "Where every cook starts.", "order": 1}, "dimension_two": {"display_name": "Second Dimension", "description": "The same recipes, harsher rooms.", "order": 2}, "dimension_three": {"display_name": "Third Dimension", "description": "Cold floors and long corridors.", "order": 3}, "dimension_four": {"display_name": "Fourth Dimension", "description": "The final cycle. Its last stretch stays sealed without the master fork.", "order": 4}, "fork_trial": {"display_name": "Trial of the Fork", "description": "One sealed run. Its keeper holds the master fork.", "order": 0}, } # Récompenses proposées entre deux salles. Chacune est une liste de # modificateurs posés en source `run:`, donc effacés au retour au restaurant. # Vocabulaire des compétences. Ajouter une compétence est une ligne ici et son # miroir dans l'outil — jamais un changement de schéma. Les valeurs sont libres: # c'est ce qui permet à la brigade de finir loin devant le joueur. # --- Habillage visuel --------------------------------------------------------- # Un visuel est une décoration posée par-dessus une entité. Il n'a JAMAIS # d'influence sur la collision ni sur la portée: `ShapeFactory` et # `Enemy.body_radius()` gardent la physique. Sans cette séparation, la moindre # retouche d'art deviendrait une session d'équilibrage. const VISUAL_KINDS := ["frames", "scene"] # États attendus par collection. Vocabulaire fixe, miroir des trois côtés. const VISUAL_STATES := { "enemies": ["idle", "walk", "attack", "hurt", "death", "special"], "employees": ["idle", "walk", "work"], "customer_profiles": ["idle", "walk", "eat", "angry"], "player_configs": ["idle", "walk", "attack", "dash", "hurt"], "room_elements": ["idle", "triggered", "destroyed"], "ingredients": ["icon"], "recipes": ["plated"], "biomes": ["background", "foreground"], "dimensions": ["background", "foreground"], "rooms": ["background"], # Un meuble a une image par quart de tour. « front » est sa pose d'origine; # les trois autres se replient dessus tant qu'elles ne sont pas dessinées, # et le tout se replie sur la forme procédurale. On peut donc livrer un # élément avec une seule image, ou aucune. "restaurant_elements": ["front", "right", "back", "left"], } # Les modeles animes utilisent exactement les etats de jeu deja exposes aux # visuels 2D. Les autres collections gardent leur volume procedural actuel. const MODEL_3D_STATES := { "enemies": ["idle", "walk", "attack", "hurt", "death", "special"], "employees": ["idle", "walk", "work"], "customer_profiles": ["idle", "walk", "eat", "angry"], "player_configs": ["idle", "walk", "attack", "dash", "hurt"], } # Chaîne de repli: la règle « zéro ou vide veut dire demande au-dessus », # appliquée aux images. Un état sans visuel emprunte celui de son parent, et # le dernier repli est la forme procédurale actuelle — donc le jeu reste # jouable pendant toute la migration, y compris sans le moindre fichier d'art. const VISUAL_FALLBACKS := { "walk": "idle", "attack": "idle", "hurt": "idle", "work": "idle", "eat": "idle", "angry": "idle", "death": "hurt", "special": "attack", "dash": "walk", "triggered": "idle", "destroyed": "idle", "foreground": "background", "right": "front", "back": "front", "left": "right", } const EMPLOYEE_SKILLS := [ "pace", "stamina", "sight", "attentiveness", "charisma", "tray", "tidiness", "poise", "craft", "plating", "thrift", "mise", "nose", ] const EMPLOYEE_ROLES := ["server", "cook"] const EMPLOYEE_STATION_KINDS := ["stove", "spices", "takeout"] const SERVER_TASK_PREFERENCES := ["take_order", "serve", "bus", "takeout"] # --- Meubles du restaurant ---------------------------------------------------- # Un meuble porte directement son comportement. Une collection intermédiaire de # « fonctions » a existé, pensée pour être clonable — un « four rapide » aurait # été un four à coefficients. Les coefficients n'ont jamais été branchés, et # sans eux deux fonctions de même `kind` étaient identiques: la collection ne # faisait que renommer un mot que le code connaît déjà. # # `kind` est le comportement, écrit en GDScript: il se choisit, il ne s'invente # pas. Tout le reste est de la donnée. const ELEMENT_KINDS := [ "stove", "spices", "takeout", "dishwasher", "trash", "shelf", "console", "table", "board", "employee_locker", "market", "none", ] const ELEMENT_ANCHORS := ["", "north_wall"] const ELEMENT_SHAPES := ["square", "circle", "diamond", "hexagon", "oval"] # Les meubles d'origine de la salle, devenus des entrées comme les autres. Un # « four en brique » se crée en copiant celui-ci: même comportement, autre # apparence, autre limite. Aucun code n'a à changer pour ça. const DEFAULT_RESTAURANT_ELEMENTS := { "basic_stove": { "display_name": "Four de base", "description": "Le poste de cuisson d'origine.", "width": 82.0, "height": 68.0, "kind": "stove", "usable_faces": 2, "anchor": "", "max_count": 4, "max_count_variable": "stove_count", "cost": 0, "fallback_shape": "square", "fallback_color": Color("ffbe0b"), }, "basic_shelf": { "display_name": "Étagère", "description": "Range les assiettes prêtes.", "width": 60.0, "height": 162.0, "kind": "shelf", "usable_faces": 15, "anchor": "", "max_count": 1, "max_count_variable": "shelf_count", "cost": 0, "fallback_shape": "square", "fallback_color": Color("c29a5c"), }, "basic_dishwasher": { "display_name": "Plonge", "description": "Lave la vaisselle sale.", "width": 82.0, "height": 68.0, "kind": "dishwasher", "usable_faces": 15, "anchor": "", "max_count": 1, "max_count_variable": "", "cost": 0, "fallback_shape": "square", "fallback_color": Color("40c7eb"), }, "basic_trash": { "display_name": "Poubelle", "description": "Jette un plat raté.", "width": 73.0, "height": 68.0, "kind": "trash", "usable_faces": 15, "anchor": "", "max_count": 1, "max_count_variable": "", "cost": 0, "fallback_shape": "square", "fallback_color": Color("8c5952"), }, "basic_console": { "display_name": "Console de gestion", "description": "Le tableau de commande du magasin.", "width": 88.0, "height": 65.0, "kind": "console", "usable_faces": 4, "anchor": "north_wall", "max_count": 1, "max_count_variable": "management_console", "cost": 0, "fallback_shape": "square", "fallback_color": Color("6cc4da"), }, "basic_takeout_counter": { "display_name": "Comptoir takeout", "description": "Vend à la rue, adossé au mur nord.", "width": 121.0, "height": 61.0, "kind": "takeout", "usable_faces": 4, "anchor": "north_wall", "max_count": 1, "max_count_variable": "takeout_counter", "cost": 0, "fallback_shape": "square", "fallback_color": Color("7fcea5"), }, "basic_order_board": { "display_name": "Ardoise", "description": "La file de travail du restaurant.", "width": 178.0, "height": 128.0, "kind": "board", "usable_faces": 0, "anchor": "", "max_count": 1, "max_count_variable": "order_board_slots", "cost": 0, "fallback_shape": "square", "fallback_color": Color("3d3049"), }, "basic_table": { "display_name": "Tabouret", "description": "Une place assise.", "width": 71.0, "height": 111.0, "kind": "table", "usable_faces": 0, "anchor": "", "max_count": 5, "max_count_variable": "table_count", "cost": 0, "fallback_shape": "circle", "fallback_color": Color("8a6f4e"), }, "employee_locker": { "display_name": "Casier d'employés", "description": "Rend l'embauche possible quand l'étagère et l'ardoise sont installées.", "width": 75.0, "height": 102.0, "kind": "employee_locker", "usable_faces": 0, "anchor": "", "max_count": 1, "max_count_variable": "employee_locker_count", "cost": 0, "fallback_shape": "square", "fallback_color": Color("718096"), "placement_requires": ["basic_shelf", "basic_order_board"], }, "internet_modem": { "display_name": "Modem internet", "description": "Ouvre les marchés internationaux: les matériaux en stock se vendent depuis la console.", "width": 46.0, "height": 38.0, "kind": "market", "usable_faces": 0, "anchor": "", "max_count": 1, "max_count_variable": "market_count", "cost": 0, "fallback_shape": "square", "fallback_color": Color("4cc9f0"), "placement_requires": [], }, } static var RESTAURANT_ELEMENTS: Dictionary = DEFAULT_RESTAURANT_ELEMENTS.duplicate(true) # Un employé est une donnée d'auteur. La spécialisation n'est pas un champ: un # cuisinier avec `station_affinity.spices` élevé *est* un épicier. const DEFAULT_EMPLOYEES := { "pablo": { "display_name": "Pablo", "description": "Marche vite, parle peu.", "color": Color("4cc9f0"), "role": "server", "hire_cost": 40, "scope": "dimension", "skills": {"pace": 1.4, "sight": 1.2, "tray": 1.0, "attentiveness": 1.1, "charisma": 0.8, "tidiness": 0.9}, "task_preferences": {"take_order": 4.0, "serve": 5.0, "bus": 2.0, "takeout": 3.0}, "station_affinity": {}, "recipe_affinity": {}, "modifiers": [], "conditions": [], "on_hired": [], }, "margot": { "display_name": "Margot", "description": "Fait payer le sourire.", "color": Color("f72585"), "role": "server", "hire_cost": 85, "scope": "dimension", "skills": {"pace": 0.9, "sight": 1.0, "tray": 1.0, "attentiveness": 1.0, "charisma": 1.8, "poise": 1.3}, "task_preferences": {"take_order": 5.0, "serve": 4.0, "bus": 1.0, "takeout": 2.0}, "station_affinity": {}, "recipe_affinity": {}, "modifiers": [], "conditions": [], "on_hired": [], }, "sacha": { "display_name": "Sacha", "description": "Ne laisse jamais une assiette traîner.", "color": Color("90be6d"), "role": "server", "hire_cost": 55, "scope": "dimension", "skills": {"pace": 1.1, "sight": 0.8, "tray": 2.0, "tidiness": 1.9, "charisma": 0.6}, "task_preferences": {"take_order": 2.0, "serve": 3.0, "bus": 5.0, "takeout": 3.0}, "station_affinity": {}, "recipe_affinity": {}, "modifiers": [], "conditions": [], "on_hired": [], }, "gaspard": { "display_name": "Gaspard", "description": "Solide au fourneau.", "color": Color("ffbe0b"), "role": "cook", "hire_cost": 50, "scope": "dimension", "skills": {"craft": 1.3, "mise": 1.1, "plating": 0.1, "thrift": 0.05, "nose": 0.0}, "task_preferences": {}, "station_affinity": {"stove": 1.2, "spices": 0.7}, "recipe_affinity": {}, "modifiers": [], "conditions": [], "on_hired": [], }, "iris": { "display_name": "Iris", "description": "Dresse comme personne.", "color": Color("b5179e"), "role": "cook", "hire_cost": 95, "scope": "dimension", "skills": {"craft": 0.9, "mise": 1.0, "plating": 0.45, "thrift": 0.1, "nose": 1.0}, "task_preferences": {}, "station_affinity": {"stove": 0.8, "spices": 1.6}, "recipe_affinity": {}, "modifiers": [], "conditions": [], "on_hired": [], }, } const DEFAULT_REWARDS := { "whetted_edge": {"display_name": "Whetted Edge", "description": "+20% attack damage for the rest of the dive.", "weight": 3.0, "modifiers": [{"variable": "attack_damage", "layer": "scaling", "operation": "add", "value": 0.2}]}, "second_wind": {"display_name": "Second Wind", "description": "+25 stamina for the rest of the dive.", "weight": 3.0, "modifiers": [{"variable": "stamina", "layer": "flat", "operation": "add", "value": 25.0}]}, "light_step": {"display_name": "Light Step", "description": "+15% movement speed for the rest of the dive.", "weight": 3.0, "modifiers": [{"variable": "move_speed", "layer": "scaling", "operation": "add", "value": 0.15}]}, "thick_hide": {"display_name": "Thick Hide", "description": "+30 maximum health for the rest of the dive.", "weight": 3.0, "modifiers": [{"variable": "max_health", "layer": "flat", "operation": "add", "value": 30.0}]}, "quiet_halls": {"display_name": "Quiet Halls", "description": "Rooms hold 3 fewer creatures.", "weight": 2.0, "modifiers": [{"variable": "room_population_bonus", "layer": "flat", "operation": "add", "value": -3.0}]}, "crowded_halls": {"display_name": "Crowded Halls", "description": "Rooms hold 4 more creatures, and every strike lands 35% harder.", "weight": 1.0, "modifiers": [{"variable": "room_population_bonus", "layer": "flat", "operation": "add", "value": 4.0}, {"variable": "attack_damage", "layer": "scaling", "operation": "add", "value": 0.35}]}, "deep_pockets": {"display_name": "Deep Pockets", "description": "Insurance covers a further 25% of the dive.", "weight": 2.0, "modifiers": [{"variable": "insurance_rate", "layer": "flat", "operation": "add", "value": 0.25}]}, "sharp_eyes": {"display_name": "Sharp Eyes", "description": "+120 vision, useful once back in the restaurant.", "weight": 1.0, "modifiers": [{"variable": "player_vision", "layer": "flat", "operation": "add", "value": 120.0}]}, } # Éléments réutilisables posés dans les salles. Un genre décide des champs qui # comptent: un coffre ignore `damage`, un piège ignore `coins`. const DEFAULT_ROOM_ELEMENTS := { "crate": {"display_name": "Crate", "description": "A breakable supply crate.", "kind": "destructible", "shape": "square", "color": Color("8a6a3d"), "size": 1.0, "health": 12.0, "solid": true, "damage": 0.0, "trigger_radius": 0.0, "rearm_delay": 0.0, "coins": 0, "drops": [{"ingredient_id": "egg", "chance": 100.0}]}, "spore_pod": {"display_name": "Spore Pod", "description": "Bursts into usable spores.", "kind": "destructible", "shape": "circle", "color": Color("7d5ba6"), "size": 0.9, "health": 8.0, "solid": false, "damage": 0.0, "trigger_radius": 0.0, "rearm_delay": 0.0, "coins": 0, "drops": [{"ingredient_id": "mushroom", "chance": 100.0}]}, "floor_spike": {"display_name": "Floor Spike", "description": "Springs when stepped on.", "kind": "trap", "shape": "triangle", "color": Color("b04a4a"), "size": 0.8, "health": 0.0, "solid": false, "damage": 12.0, "trigger_radius": 34.0, "rearm_delay": 2.5, "coins": 0, "drops": []}, "supply_chest": {"display_name": "Supply Chest", "description": "Holds coins and materials.", "kind": "chest", "shape": "square", "color": Color("d4a017"), "size": 1.1, "health": 0.0, "solid": true, "damage": 0.0, "trigger_radius": 0.0, "rearm_delay": 0.0, "coins": 25, "drops": [{"ingredient_id": "beast_meat", "chance": 100.0}]}, "wandering_cook": {"display_name": "Wandering Cook", "description": "A cook with something to say.", "kind": "npc", "shape": "hexagon", "color": Color("6ec8a8"), "size": 1.0, "health": 0.0, "solid": true, "damage": 0.0, "trigger_radius": 0.0, "rearm_delay": 0.0, "coins": 0, "drops": []}, "back_alley_stall": {"display_name": "Back Alley Stall", "description": "Sells what it found.", "kind": "shop", "shape": "square", "color": Color("c77dff"), "size": 1.3, "health": 0.0, "solid": true, "damage": 0.0, "trigger_radius": 0.0, "rearm_delay": 0.0, "coins": 0, "drops": []}, } static var INGREDIENTS: Dictionary = DEFAULT_INGREDIENTS.duplicate(true) static var RECIPES: Dictionary = DEFAULT_RECIPES.duplicate(true) static var ATTACK_PATTERNS: Dictionary = DEFAULT_ATTACK_PATTERNS.duplicate(true) static var MOVEMENT_PATTERNS: Dictionary = DEFAULT_MOVEMENT_PATTERNS.duplicate(true) static var WEAPONS: Dictionary = DEFAULT_WEAPONS.duplicate(true) static var PROJECTILES: Dictionary = DEFAULT_PROJECTILES.duplicate(true) static var FOOD_CATEGORIES: Dictionary = DEFAULT_FOOD_CATEGORIES.duplicate(true) static var EQUIPMENT: Dictionary = DEFAULT_EQUIPMENT.duplicate(true) static var PLAYER_CONFIGS: Dictionary = DEFAULT_PLAYER_CONFIGS.duplicate(true) static var CUSTOMER_PROFILES: Dictionary = DEFAULT_CUSTOMER_PROFILES.duplicate(true) static var EFFECTS: Dictionary = DEFAULT_EFFECTS.duplicate(true) static var GAME_VARIABLES: Dictionary = DEFAULT_GAME_VARIABLES.duplicate(true) static var GAME_FACTS: Dictionary = DEFAULT_GAME_FACTS.duplicate(true) static var UPGRADE_THEMES: Dictionary = DEFAULT_UPGRADE_THEMES.duplicate(true) ## Le fond de l'ecran d'upgrades. Une couleur pleine suffit; l'image, quand ## elle est declaree, se pose par-dessus. L'ecran etant unique, ce n'est pas ## une collection mais un seul reglage. const UPGRADE_SCREEN_FITS := ["cover", "contain", "stretch"] static var DEFAULT_UPGRADE_SCREEN: Dictionary = { "background_color": Color("09060e"), "background_path": "", "background_fit": "cover", "header_color": Color("120d1c"), "header_opacity": 0.98, "text_color": Color("ffd166"), } static var UPGRADE_SCREEN: Dictionary = DEFAULT_UPGRADE_SCREEN.duplicate(true) static var UPGRADES: Dictionary = DEFAULT_UPGRADE_NODES.duplicate(true) static var active_player_config_id := "default_player" static var ENEMIES: Dictionary = DEFAULT_ENEMIES.duplicate(true) static var BIOMES: Dictionary = DEFAULT_BIOMES.duplicate(true) static var DIMENSIONS: Dictionary = DEFAULT_DIMENSIONS.duplicate(true) # Aucun visuel livré: le jeu tourne sur ses formes procédurales tant que rien # n'est déclaré. Une collection vide est donc un état normal, pas une erreur — # la normalisation rend `null` pour signaler une donnée invalide. static var VISUALS: Dictionary = {} static var EMPLOYEES: Dictionary = DEFAULT_EMPLOYEES.duplicate(true) static var STORY_SCENES: Dictionary = {} static var REWARDS: Dictionary = DEFAULT_REWARDS.duplicate(true) static var ROOM_ELEMENTS: Dictionary = DEFAULT_ROOM_ELEMENTS.duplicate(true) # Les salles de secours ne sont pas écrites à la main: cent littéraux ne # seraient ni lisibles ni tenables. Elles se déduisent des biomes de chaque # dimension, exactement comme le bouton « générer la colonne » de l'outil. static var ROOMS: Dictionary = _generated_rooms(DEFAULT_DIMENSIONS, DEFAULT_BIOMES) static var metadata: Dictionary = {} static var loaded_from_master := false static var last_load_error := "" static func ingredient_name(id: String) -> String: return Loc.entity("ingredients", id, "display_name") static func recipe_name(id: String) -> String: return Loc.entity("recipes", id, "display_name") static func weapon_name(id: String) -> String: return Loc.entity("weapons", id, "display_name") static func equipment_name(id: String) -> String: return Loc.entity("equipment", id, "display_name") static func enemy_name(id: String) -> String: return Loc.entity("enemies", id, "display_name") static func customer_name(id: String) -> String: return Loc.entity("customer_profiles", id, "display_name") static func upgrade_name(id: String) -> String: return Loc.entity("upgrade_nodes", id, "display_name") static func variable_name(id: String) -> String: return Loc.entity("game_variables", id, "display_name") static func biome_name(id: String) -> String: return Loc.entity("biomes", id, "display_name") static func dimension_name(id: String) -> String: return Loc.entity("dimensions", id, "display_name") static func reward_name(id: String) -> String: return Loc.entity("rewards", id, "display_name") static func room_name(id: String) -> String: return Loc.entity("rooms", id, "display_name") static func room_element_name(id: String) -> String: return Loc.entity("room_elements", id, "display_name") static func active_player_config() -> Dictionary: return PLAYER_CONFIGS.get(active_player_config_id, DEFAULT_PLAYER_CONFIGS.default_player) static func load_master_data(path: String = MASTER_DATA_PATH) -> bool: _reset_to_defaults() if not FileAccess.file_exists(path): return _load_failed("Master data file is missing: %s" % path) var file := FileAccess.open(path, FileAccess.READ) if file == null: return _load_failed("Master data file could not be opened: %s" % path) var json := JSON.new() var parse_error := json.parse(file.get_as_text()) if parse_error != OK: return _load_failed("Master data JSON error at line %d: %s" % [json.get_error_line(), json.get_error_message()]) if not json.data is Dictionary: return _load_failed("Master data root must be a JSON object.") var source: Dictionary = json.data if int(source.get("schema_version", 0)) != SCHEMA_VERSION: return _load_failed("Unsupported master data schema version.") # Les visuels passent avant les entités qui les référencent: une carte qui # vise un identifiant inconnu doit être refusée à la lecture, pas à l'écran. var loaded_visuals: Variant = _normalize_visuals(source.get("visuals", {})) if loaded_visuals == null: return _load_failed("Master data contains an invalid visual entry.") var loaded_ingredients := _normalize_ingredients(source.get("ingredients", {}), loaded_visuals) if loaded_ingredients.is_empty(): return _load_failed("Master data contains no valid ingredients.") var loaded_variables := _normalize_named_values(source.get("game_variables", {}), false) var loaded_facts := _normalize_named_values(source.get("game_facts", {}), true) if loaded_variables.is_empty() or loaded_facts.is_empty(): return _load_failed("Master data contains no valid variables or facts.") var loaded_effects := _normalize_effects_collection(source.get("effects", null), loaded_variables) if loaded_effects.is_empty(): return _load_failed("Master data contains an invalid temporary effect.") var raw_food_categories: Variant = _normalize_food_categories(source.get("food_categories", {})) if not raw_food_categories is Dictionary: return _load_failed("Master data contains an invalid food category.") var loaded_food_categories: Dictionary = raw_food_categories var raw_projectiles: Variant = _normalize_projectiles(source.get("projectiles", {})) if not raw_projectiles is Dictionary: return _load_failed("Master data contains an invalid projectile.") var loaded_projectiles: Dictionary = raw_projectiles var loaded_attack_patterns := _normalize_attack_patterns(source.get("attack_patterns", {}), loaded_effects, loaded_projectiles) if loaded_attack_patterns.is_empty(): return _load_failed("Master data contains no valid attack patterns.") var loaded_weapons := _normalize_weapons(source.get("weapons", {}), loaded_attack_patterns) if loaded_weapons.is_empty(): return _load_failed("Master data contains no valid weapons.") var loaded_equipment := _normalize_equipment(source.get("equipment", {}), loaded_variables) if loaded_equipment.is_empty(): return _load_failed("Master data contains no valid equipment.") var loaded_movement_patterns := _normalize_movement_patterns(source.get("movement_patterns", {})) if loaded_movement_patterns.is_empty(): return _load_failed("Master data contains no valid movement patterns.") var loaded_player_configs := _normalize_player_configs(source.get("player_configs", {}), loaded_attack_patterns, loaded_weapons, loaded_visuals) var loaded_active_player_id := str(source.get("active_player_config", "")) if loaded_player_configs.is_empty() or not loaded_player_configs.has(loaded_active_player_id): return _load_failed("Master data contains no valid active player configuration.") var loaded_enemies := _normalize_enemies(source.get("enemies", {}), loaded_ingredients, loaded_attack_patterns, loaded_movement_patterns, loaded_visuals) if loaded_enemies.is_empty(): return _load_failed("Master data contains invalid enemies.") var total_spawn_weight := 0.0 for enemy_definition: Dictionary in loaded_enemies.values(): total_spawn_weight += float(enemy_definition.spawn_weight) if total_spawn_weight <= 0.0: return _load_failed("Master data has no enemy with a positive spawn weight.") var recipe_scope := source.duplicate(true) recipe_scope["ingredients"] = loaded_ingredients recipe_scope["game_variables"] = loaded_variables recipe_scope["game_facts"] = loaded_facts var loaded_recipes := _normalize_recipes(source.get("recipes", {}), loaded_ingredients, loaded_visuals, loaded_variables, loaded_facts, recipe_scope, loaded_effects, loaded_food_categories) if loaded_recipes.is_empty(): return _load_failed("Master data contains no valid recipes.") var loaded_customers := _normalize_customer_profiles(source.get("customer_profiles", {}), loaded_food_categories, loaded_visuals, loaded_variables, loaded_facts, recipe_scope) if loaded_customers.is_empty(): return _load_failed("Master data contains no valid customer profiles.") var loaded_upgrade_themes := _normalize_upgrade_themes(source.get("upgrade_themes", {})) var loaded_upgrade_screen := _normalize_upgrade_screen(source.get("upgrade_screen", {})) if loaded_upgrade_themes.is_empty(): return _load_failed("Master data contains no valid upgrade theme.") var loaded_biomes := _normalize_biomes(source.get("biomes", {}), loaded_visuals) if loaded_biomes.is_empty(): return _load_failed("Master data contains no valid biome.") var loaded_dimensions := _normalize_dimensions(source.get("dimensions", {}), loaded_visuals) if loaded_dimensions.is_empty(): return _load_failed("Master data contains no valid dimension.") var loaded_employees := _normalize_employees(source.get("employees", {}), loaded_variables, loaded_facts, loaded_recipes, loaded_visuals, source) if loaded_employees.is_empty(): loaded_employees = DEFAULT_EMPLOYEES.duplicate(true) var loaded_restaurant_elements := _normalize_restaurant_elements(source.get("restaurant_elements", {}), loaded_variables, loaded_visuals) if loaded_restaurant_elements.is_empty(): loaded_restaurant_elements = DEFAULT_RESTAURANT_ELEMENTS.duplicate(true) var loaded_rewards := _normalize_rewards(source.get("rewards", {}), loaded_variables) var loaded_room_elements := _normalize_room_elements(source.get("room_elements", {}), loaded_ingredients, loaded_visuals) # Aucune salle écrite n'est un cas normal, pas une erreur: la plongée retombe # alors sur une génération depuis les biomes de chaque dimension. # Les conditions d'une salle peuvent viser n'importe quelle adresse, donc la # portée doit contenir toutes les collections déjà normalisées. var room_scope := { "ingredients": loaded_ingredients, "attack_patterns": loaded_attack_patterns, "weapons": loaded_weapons, "projectiles": loaded_projectiles, "equipment": loaded_equipment, "food_categories": loaded_food_categories, "effects": loaded_effects, "movement_patterns": loaded_movement_patterns, "player_configs": loaded_player_configs, "enemies": loaded_enemies, "recipes": loaded_recipes, "customer_profiles": loaded_customers, "biomes": loaded_biomes, "dimensions": loaded_dimensions, "rewards": loaded_rewards, "room_elements": loaded_room_elements, "game_facts": loaded_facts, "game_variables": loaded_variables, } var loaded_rooms := _normalize_rooms(source.get("rooms", {}), loaded_dimensions, loaded_biomes, loaded_enemies, loaded_rewards, loaded_room_elements, loaded_variables, room_scope) if loaded_rooms.is_empty(): loaded_rooms = _generated_rooms(loaded_dimensions, loaded_biomes) var address_scope := { "ingredients": loaded_ingredients, "attack_patterns": loaded_attack_patterns, "weapons": loaded_weapons, "projectiles": loaded_projectiles, "equipment": loaded_equipment, "food_categories": loaded_food_categories, "effects": loaded_effects, "movement_patterns": loaded_movement_patterns, "player_configs": loaded_player_configs, "enemies": loaded_enemies, "recipes": loaded_recipes, "customer_profiles": loaded_customers, "biomes": loaded_biomes, "dimensions": loaded_dimensions, "rewards": loaded_rewards, "room_elements": loaded_room_elements, "rooms": loaded_rooms, "employees": loaded_employees, "visuals": loaded_visuals, "game_facts": loaded_facts, "game_variables": loaded_variables, } var story_actor_sources := { "player_configs": loaded_player_configs, "enemies": loaded_enemies, "customer_profiles": loaded_customers, "employees": loaded_employees, } var loaded_story_scenes: Variant = _normalize_story_scenes(source.get("story_scenes", {}), story_actor_sources, loaded_visuals, address_scope) if loaded_story_scenes == null: return _load_failed("Master data contains an invalid story scene.") address_scope["story_scenes"] = loaded_story_scenes var loaded_upgrades := _normalize_upgrade_nodes(source.get("upgrade_nodes", {}), loaded_upgrade_themes, loaded_variables, loaded_facts, address_scope) if loaded_upgrades.is_empty(): return _load_failed("Master data contains no valid upgrade tree.") var furniture_unlock_error := _restaurant_element_unlock_contract_error(loaded_restaurant_elements, loaded_variables, loaded_upgrades) if not furniture_unlock_error.is_empty(): return _load_failed(furniture_unlock_error) var loaded_languages := _normalize_languages(source.get("languages", null)) var loaded_localization: Variant = _normalize_localization(source.get("localization", null), loaded_languages) if loaded_localization == null: return _load_failed("Master data contains an invalid localization entry.") # Event hooks are resolved in a second pass: an effect may target any # collection, so every collection has to exist before they can be validated. var hook_scope := address_scope.duplicate() hook_scope["upgrade_nodes"] = loaded_upgrades hook_scope["game_facts"] = loaded_facts hook_scope["game_variables"] = loaded_variables var hook_targets := { "enemies": loaded_enemies, "recipes": loaded_recipes, "ingredients": loaded_ingredients, "customer_profiles": loaded_customers, "upgrade_nodes": loaded_upgrades, "biomes": loaded_biomes, "dimensions": loaded_dimensions, "rewards": loaded_rewards, "rooms": loaded_rooms, "room_elements": loaded_room_elements, } for collection: String in hook_targets: var loaded: Dictionary = hook_targets[collection] var raw: Dictionary = source.get(collection, {}) if source.get(collection, {}) is Dictionary else {} for id: String in loaded: var raw_definition: Dictionary = raw.get(id, {}) if raw.get(id, {}) is Dictionary else {} if not _apply_hooks(loaded[id], raw_definition, collection, hook_scope): return _load_failed("Master data contains an invalid event hook on %s '%s'." % [collection, id]) INGREDIENTS = loaded_ingredients ATTACK_PATTERNS = loaded_attack_patterns MOVEMENT_PATTERNS = loaded_movement_patterns WEAPONS = loaded_weapons PROJECTILES = loaded_projectiles FOOD_CATEGORIES = loaded_food_categories EQUIPMENT = loaded_equipment PLAYER_CONFIGS = loaded_player_configs active_player_config_id = loaded_active_player_id ENEMIES = loaded_enemies BIOMES = loaded_biomes DIMENSIONS = loaded_dimensions VISUALS = loaded_visuals EMPLOYEES = loaded_employees RESTAURANT_ELEMENTS = loaded_restaurant_elements STORY_SCENES = loaded_story_scenes REWARDS = loaded_rewards ROOM_ELEMENTS = loaded_room_elements ROOMS = loaded_rooms RECIPES = loaded_recipes CUSTOMER_PROFILES = loaded_customers EFFECTS = loaded_effects Loc.LANGUAGES = loaded_languages Loc.ENTRIES = loaded_localization GAME_VARIABLES = loaded_variables GAME_FACTS = loaded_facts UPGRADE_THEMES = loaded_upgrade_themes UPGRADE_SCREEN = loaded_upgrade_screen UPGRADES = loaded_upgrades metadata = source.get("metadata", {}).duplicate(true) if source.get("metadata", {}) is Dictionary else {} loaded_from_master = true last_load_error = "" print("BIG EGG data: loaded %d ingredients, %d enemies, %d recipes, %d customers, %d variables, %d facts and %d upgrade nodes from %s" % [INGREDIENTS.size(), ENEMIES.size(), RECIPES.size(), CUSTOMER_PROFILES.size(), GAME_VARIABLES.size(), GAME_FACTS.size(), UPGRADES.size(), path]) return true static func _reset_to_defaults() -> void: INGREDIENTS = DEFAULT_INGREDIENTS.duplicate(true) RECIPES = DEFAULT_RECIPES.duplicate(true) ATTACK_PATTERNS = DEFAULT_ATTACK_PATTERNS.duplicate(true) MOVEMENT_PATTERNS = DEFAULT_MOVEMENT_PATTERNS.duplicate(true) WEAPONS = DEFAULT_WEAPONS.duplicate(true) PROJECTILES = DEFAULT_PROJECTILES.duplicate(true) FOOD_CATEGORIES = DEFAULT_FOOD_CATEGORIES.duplicate(true) EQUIPMENT = DEFAULT_EQUIPMENT.duplicate(true) PLAYER_CONFIGS = DEFAULT_PLAYER_CONFIGS.duplicate(true) CUSTOMER_PROFILES = DEFAULT_CUSTOMER_PROFILES.duplicate(true) EFFECTS = DEFAULT_EFFECTS.duplicate(true) GAME_VARIABLES = DEFAULT_GAME_VARIABLES.duplicate(true) GAME_FACTS = DEFAULT_GAME_FACTS.duplicate(true) UPGRADE_THEMES = DEFAULT_UPGRADE_THEMES.duplicate(true) UPGRADE_SCREEN = DEFAULT_UPGRADE_SCREEN.duplicate(true) UPGRADES = DEFAULT_UPGRADE_NODES.duplicate(true) active_player_config_id = "default_player" ENEMIES = DEFAULT_ENEMIES.duplicate(true) BIOMES = DEFAULT_BIOMES.duplicate(true) DIMENSIONS = DEFAULT_DIMENSIONS.duplicate(true) VISUALS = {} EMPLOYEES = DEFAULT_EMPLOYEES.duplicate(true) RESTAURANT_ELEMENTS = DEFAULT_RESTAURANT_ELEMENTS.duplicate(true) STORY_SCENES = {} REWARDS = DEFAULT_REWARDS.duplicate(true) ROOM_ELEMENTS = DEFAULT_ROOM_ELEMENTS.duplicate(true) ROOMS = _generated_rooms(DEFAULT_DIMENSIONS, DEFAULT_BIOMES) metadata = {} loaded_from_master = false last_load_error = "" static func _load_failed(message: String) -> bool: last_load_error = message push_warning("BIG EGG data: %s Using built-in defaults." % message) return false static func _normalize_ingredients(value: Variant, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var display_name := str(definition.get("display_name", "")).strip_edges() var color_text := str(definition.get("color", "")) var shape_id := str(definition.get("shape", "circle")) if display_name.is_empty() or not Color.html_is_valid(color_text) or not ShapeFactory.SUPPORTED_SHAPES.has(shape_id): return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "ingredients", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "ingredients", valid_visuals) if visuals == null: return {} # Ce qu'une unite rapporte au marche. Zero veut dire invendable: le # bouton de vente ne s'affiche pas pour un materiau sans valeur. if not _whole_number_in(definition.get("sale_value", 0), 0, 1000000): return {} result[id] = { "display_name": display_name, "description": str(definition.get("description", "")), "color": Color.from_string(color_text, Color.WHITE), "shape": shape_id, "visuals": visuals, "vfx": hook_visuals, "sale_value": int(definition.get("sale_value", 0)), } return result static func _normalize_combat_effect_assignments(value: Variant, valid_effects: Dictionary) -> Variant: if not value is Array: return null var result: Array[Dictionary] = [] var seen := {} for raw_assignment: Variant in value: if not raw_assignment is Dictionary: return null var effect_id := str((raw_assignment as Dictionary).get("effect", "")) if not valid_effects.has(effect_id) or seen.has(effect_id) \ or not _number_in((raw_assignment as Dictionary).get("chance", 0.0), 0.0, 100.0): return null seen[effect_id] = true result.append({"effect": effect_id, "chance": float((raw_assignment as Dictionary).get("chance", 0.0))}) return result # An empty category catalogue is legitimate, so an empty dictionary and a # malformed one must be told apart: `null` marks the failure. static func _normalize_food_categories(value: Variant) -> Variant: if not value is Dictionary: return null var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return null var color_text := str(definition.get("color", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not Color.html_is_valid(color_text): return null result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "color": Color.from_string(color_text, Color("8ecae6")), } return result # La liste des categories d'un plat. Sans doublon, sans ordre significatif, et # vide reste valide: le plat est alors neutre pour tous les gouts. static func _normalize_food_category_list(value: Variant, valid_categories: Dictionary) -> Variant: if value == null: return [] if not value is Array: return null var result: Array[String] = [] for raw_id: Variant in value: var id := str(raw_id) if id.is_empty() or result.has(id): continue if not valid_categories.has(id): return null result.append(id) return result # A projectile catalogue may legitimately be empty: a game with no volley at all # is valid. An empty dictionary and a malformed one must therefore be told apart, # hence `null` on failure rather than `{}`. static func _normalize_projectiles(value: Variant) -> Variant: if not value is Dictionary: return null var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return null var projectile_type := str(definition.get("type", "orb")) var color_text := str(definition.get("color", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not PROJECTILE_TYPES.has(projectile_type) \ or not Color.html_is_valid(color_text) \ or not _number_in(definition.get("speed", 0.0), 1.0, 10000.0) \ or not _number_in(definition.get("count", 1), 1.0, 64.0) \ or not _number_in(definition.get("spread_angle", 0.0), 0.0, 360.0) \ or not _number_in(definition.get("radius", 1.0), 1.0, 128.0) \ or not _number_in(definition.get("lifetime", 1.0), 0.05, 30.0): return null result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "type": projectile_type, "speed": float(definition.speed), "count": int(definition.count), "spread_angle": float(definition.spread_angle), "radius": float(definition.radius), "lifetime": float(definition.lifetime), "color": Color.from_string(color_text, Color("7dd3fc")), } return result static func _normalize_attack_patterns(value: Variant, valid_effects: Dictionary = {}, valid_projectiles: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} if str(definition.get("target_mode", "aim")) not in ATTACK_TARGET_MODES \ or str(definition.get("target_fallback", "aim")) not in ["aim", "self", "forward"] \ or str(definition.get("anchor", "auto")) not in ["auto", "actor", "world"] \ or not _number_in(definition.get("target_range", 0.0), 0.0, 5000.0): return {} var pattern_type := str(definition.get("type", "")) var area_shape := str(definition.get("area_shape", "sector")) var movement_type := str(definition.get("movement_type", "stationary")) var show_hit_warning: Variant = definition.get("show_hit_warning", true) var show_movement_warning: Variant = definition.get("show_movement_warning", true) var color_text := str(definition.get("color", "")) var delivery := str(definition.get("delivery", "direct")) var projectile_id := str(definition.get("projectile", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not ATTACK_DELIVERIES.has(delivery) \ or (delivery == "projectile" and not valid_projectiles.has(projectile_id)) \ or not ATTACK_PATTERN_TYPES.has(pattern_type) \ or not ATTACK_AREA_SHAPES.has(area_shape) \ or not ATTACK_MOVEMENT_TYPES.has(movement_type) \ or not (show_hit_warning is bool) \ or not (show_movement_warning is bool) \ or not Color.html_is_valid(color_text) \ or not _number_in(definition.get("range"), 1.0, 5000.0) \ or not _number_in(definition.get("angle"), 1.0, 360.0) \ or not _number_in(definition.get("area_width", 0.0), 0.0, 5000.0) \ or not _number_in(definition.get("windup"), 0.0, 60.0) \ or not _number_in(definition.get("tracking_speed", 0.0), 0.0, 5000.0) \ or not _number_in(definition.get("escape_grace_duration", 0.0), 0.0, 60.0) \ or not _number_in(definition.get("active_duration", 0.0), 0.0, 60.0) \ or not _number_in(definition.get("rotation_speed", 0.0), -2000.0, 2000.0) \ or not _number_in(definition.get("tick_interval", 0.0), 0.0, 60.0) \ or not _number_in(definition.get("movement_distance", 0.0), 0.0, 5000.0) \ or not _number_in(definition.get("movement_speed", 0.0), 0.0, 5000.0) \ or not _number_in(definition.get("movement_acceleration", 0.0), 0.0, 20000.0) \ or not _number_in(definition.get("movement_windup_distance", 0.0), 0.0, 1000.0) \ or not _number_in(definition.get("movement_arc_height", 0.0), 0.0, 1000.0): return {} if pattern_type == "swipe" and (float(definition.get("active_duration", 0.0)) <= 0.0 or float(definition.get("tick_interval", 0.0)) <= 0.0): return {} if pattern_type == "sticky" and (float(definition.get("tracking_speed", 0.0)) <= 0.0 or float(definition.get("escape_grace_duration", 0.0)) <= 0.0): return {} if area_shape == "rectangle" and float(definition.get("area_width", 0.0)) <= 0.0: return {} if not movement_type in ["stationary", "unaffected"] and (float(definition.get("movement_distance", 0.0)) <= 0.0 or float(definition.get("movement_speed", 0.0)) <= 0.0): return {} var combat_effects: Variant = _normalize_combat_effect_assignments(definition.get("combat_effects", []), valid_effects) if combat_effects == null: return {} var pattern_mode := str(definition.get("mode", "simple")) var timeline: Variant = _normalize_attack_timeline(definition.get("timeline"), valid_projectiles) if not ATTACK_PATTERN_MODES.has(pattern_mode) or timeline == null \ or not (definition.get("free_movement", false) is bool): return {} # Un pattern avance sans un seul coup ne blesse jamais: c'est une frise # vide qui se lit comme une attaque, et rien ne le dirait en jeu. if pattern_mode == "advanced" and (timeline as Dictionary)["hits"].is_empty(): return {} result[id] = { "mode": pattern_mode, "timeline": timeline, # Hors frise, l'acteur garde-t-il sa course? C'est le seul reste utile # de l'ancien « n'influence pas le mouvement ». "free_movement": bool(definition.get("free_movement", movement_type == "unaffected")), "display_name": str(definition.display_name).strip_edges(), "type": pattern_type, "delivery": delivery, # A direct pattern never keeps a munition name: an inert setting is # worse than an error, it reads as if a volley were still fired. "projectile": projectile_id if delivery == "projectile" else "", "area_shape": area_shape, "area_width": float(definition.get("area_width", 0.0)), "show_hit_warning": bool(show_hit_warning), "show_movement_warning": bool(show_movement_warning), "movement_type": movement_type, "range": float(definition.range), "trigger_range": float(definition.get("trigger_range", 0.0)), "angle": float(definition.angle), "windup": float(definition.windup), "tracking_speed": float(definition.get("tracking_speed", 0.0)), "escape_grace_duration": float(definition.get("escape_grace_duration", 0.0)), "active_duration": float(definition.get("active_duration", 0.0)), "rotation_speed": float(definition.get("rotation_speed", 0.0)), "tick_interval": float(definition.get("tick_interval", 0.0)), "movement_distance": float(definition.get("movement_distance", 0.0)), "movement_speed": float(definition.get("movement_speed", 0.0)), "movement_acceleration": float(definition.get("movement_acceleration", 0.0)), "movement_windup_distance": float(definition.get("movement_windup_distance", 0.0)), "movement_arc_height": float(definition.get("movement_arc_height", 0.0)), "combat_effects": combat_effects, "color": Color.from_string(color_text, Color("ff4d6d")), } result[id]["target_mode"] = str(definition.get("target_mode", "aim")) result[id]["target_range"] = float(definition.get("target_range", 0.0)) result[id]["target_fallback"] = str(definition.get("target_fallback", "aim")) result[id]["anchor"] = str(definition.get("anchor", "auto")) return result # Bornes communes a tout bloc: il commence dans la sequence, il ne finit pas # avant d'avoir commence. static func _block_span(block: Dictionary) -> Array: if not _number_in(block.get("start", 0.0), 0.0, 600.0) \ or not _number_in(block.get("end", 0.0), 0.0, 600.0): return [] var start := float(block.get("start", 0.0)) var end := float(block.get("end", start)) if end < start: return [] return [start, end] # Une piste se lit de gauche a droite: deux blocs qui se chevauchent # demanderaient a l'acteur deux choses a la fois au meme instant. static func _blocks_overlap(blocks: Array) -> bool: var spans := [] for block: Dictionary in blocks: spans.append([float(block.get("start", 0.0)), float(block.get("end", 0.0))]) spans.sort_custom(func(a: Array, b: Array) -> bool: return float(a[0]) < float(b[0])) for index: int in range(1, spans.size()): if float(spans[index][0]) < float(spans[index - 1][1]): return true return false # La zone d'un bloc de coup ou d'avertissement. Meme vocabulaire que le # pattern a plat: une forme, une portee, un angle, et son ancrage. static func _normalize_zone_block(block: Dictionary) -> Dictionary: var span := _block_span(block) var area_shape := str(block.get("area_shape", "sector")) var anchor := str(block.get("anchor", "actor")) if span.is_empty() \ or not ATTACK_AREA_SHAPES.has(area_shape) \ or not ATTACK_ZONE_ANCHORS.has(anchor) \ or not _number_in(block.get("range", 0.0), 1.0, 5000.0) \ or not _number_in(block.get("angle", 0.0), 1.0, 360.0) \ or not _number_in(block.get("area_width", 0.0), 0.0, 5000.0) \ or not _number_in(block.get("tracking_speed", 0.0), 0.0, 5000.0) \ or not (block.get("locked", false) is bool) \ or not _whole_number_in(block.get("follow_hit", -1), -1, 63): return {} if area_shape == "rectangle" and float(block.get("area_width", 0.0)) <= 0.0: return {} return { "start": float(span[0]), "end": float(span[1]), "area_shape": area_shape, "anchor": anchor, "range": float(block.get("range", 0.0)), "angle": float(block.get("angle", 0.0)), "area_width": float(block.get("area_width", 0.0)), "tracking_speed": float(block.get("tracking_speed", 0.0)), "locked": bool(block.get("locked", false)), # -1 = ce bloc montre sa propre forme; sinon il suit ce coup. "follow_hit": int(block.get("follow_hit", -1)), } static func _normalize_hit_block(block: Dictionary, valid_projectiles: Dictionary) -> Dictionary: var zone := _normalize_zone_block(block) var hit_mode := str(block.get("mode", "instant")) var delivery := str(block.get("delivery", "direct")) var projectile_id := str(block.get("projectile", "")) if zone.is_empty() \ or not ATTACK_HIT_MODES.has(hit_mode) \ or not ATTACK_DELIVERIES.has(delivery) \ or (delivery == "projectile" and not valid_projectiles.has(projectile_id)) \ or not _number_in(block.get("rotation_speed", 0.0), -2000.0, 2000.0) \ or not _number_in(block.get("tick_interval", 0.0), 0.0, 60.0) \ or not _number_in(block.get("damage_scale", 1.0), 0.0, 20.0) \ or not _number_in(block.get("stop_after_hits", 0.0), 0.0, 99.0) \ or not _number_in(block.get("kill_cost", 1.0), 0.0, 4.0): return {} # Un instant n'a pas de longueur, une zone qui dure en a une. Confondre # les deux donnerait un coup qui ne part jamais ou qui ne s'arrete pas. if hit_mode == "instant" and float(zone["end"]) > float(zone["start"]): return {} if hit_mode != "instant" and float(zone["end"]) <= float(zone["start"]): return {} zone["mode"] = hit_mode zone["delivery"] = delivery zone["projectile"] = projectile_id if delivery == "projectile" else "" zone["rotation_speed"] = float(block.get("rotation_speed", 0.0)) zone["tick_interval"] = float(block.get("tick_interval", 0.0)) # Le coefficient laisse les degats sur la fiche du monstre ou de l'arme. # Sans lui, un pattern a cinq coups multiplierait par cinq les degats de # toutes les creatures qui le portent, sans qu'aucun ecran ne le dise. zone["damage_scale"] = float(block.get("damage_scale", 1.0)) # Ce que la lame encaisse avant d'etre arretee, et ce qu'un ennemi tue # lui coute. Zero laisse la lame aller au bout, comme avant. zone["stop_after_hits"] = float(block.get("stop_after_hits", 0.0)) zone["kill_cost"] = float(block.get("kill_cost", 1.0)) return zone static func _normalize_move_block(block: Dictionary) -> Dictionary: var span := _block_span(block) var manner := str(block.get("manner", "linear")) if span.is_empty() \ or float(span[1]) <= float(span[0]) \ or not ATTACK_MOVE_MANNERS.has(manner) \ or not _number_in(block.get("distance", 0.0), 0.0, 5000.0) \ or not _number_in(block.get("angle", 0.0), 0.0, 360.0) \ or not _number_in(block.get("arc_height", 0.0), 0.0, 1000.0) \ or not _number_in(block.get("frequency", 0.0), 0.0, 60.0) \ or not _number_in(block.get("amplitude", 0.0), 0.0, 500.0) \ or not (block.get("visual_only", false) is bool) \ or not (block.get("mirror", false) is bool): return {} return { "start": float(span[0]), "end": float(span[1]), "distance": float(block.get("distance", 0.0)), "angle": float(block.get("angle", 0.0)), "manner": manner, "arc_height": float(block.get("arc_height", 0.0)), "frequency": float(block.get("frequency", 0.0)), "amplitude": float(block.get("amplitude", 0.0)), "visual_only": bool(block.get("visual_only", false)), "mirror": bool(block.get("mirror", false)), } static func _normalize_span_block(block: Dictionary) -> Dictionary: var span := _block_span(block) if span.is_empty() or float(span[1]) <= float(span[0]): return {} return {"start": float(span[0]), "end": float(span[1])} # Les cinq pistes. Un refus renvoie `null`: une frise a moitie lue serait une # choregraphie muette la ou l'auteur en attend une. static func _normalize_attack_timeline(value: Variant, valid_projectiles: Dictionary) -> Variant: var empty := {} for track: String in ATTACK_TIMELINE_TRACKS: empty[track] = [] if value == null: return empty if not value is Dictionary: return null var source: Dictionary = value var result := {} for track: String in ATTACK_TIMELINE_TRACKS: var raw: Variant = source.get(track, []) if not raw is Array: return null var blocks: Array[Dictionary] = [] for entry: Variant in raw: if not entry is Dictionary: return null var block := {} match track: "hits": block = _normalize_hit_block(entry as Dictionary, valid_projectiles) "warnings": block = _normalize_zone_block(entry as Dictionary) "moves": block = _normalize_move_block(entry as Dictionary) _: block = _normalize_span_block(entry as Dictionary) if block.is_empty(): return null blocks.append(block) if _blocks_overlap(blocks): return null blocks.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return float(a["start"]) < float(b["start"])) result[track] = blocks return result static func _normalize_movement_patterns(value: Variant) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var pattern_type := str(definition.get("type", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not MOVEMENT_PATTERN_TYPES.has(pattern_type) \ or not _number_in(definition.get("speed_multiplier"), 0.0, 20.0) \ or not _number_in(definition.get("detected_speed_multiplier"), 0.0, 20.0) \ or not _number_in(definition.get("move_duration"), 0.0, 60.0) \ or not _number_in(definition.get("rest_duration"), 0.0, 60.0) \ or not _number_in(definition.get("direction_change_min"), 0.05, 60.0) \ or not _number_in(definition.get("direction_change_max"), 0.05, 60.0) \ or not _number_in(definition.get("idle_chance"), 0.0, 1.0): return {} if float(definition.direction_change_max) < float(definition.direction_change_min): return {} if pattern_type == "hop" and float(definition.move_duration) <= 0.0: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "type": pattern_type, "speed_multiplier": float(definition.speed_multiplier), "detected_speed_multiplier": float(definition.detected_speed_multiplier), "move_duration": float(definition.move_duration), "rest_duration": float(definition.rest_duration), "direction_change_min": float(definition.direction_change_min), "direction_change_max": float(definition.direction_change_max), "idle_chance": float(definition.idle_chance), } return result static func _normalize_weapons(value: Variant, valid_patterns: Dictionary) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var slot_type := str(definition.get("slot_type", "either")) var pattern_id := str(definition.get("attack_pattern", "")) var color_text := str(definition.get("color", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not WEAPON_SLOT_TYPES.has(slot_type) or not valid_patterns.has(pattern_id) \ or not Color.html_is_valid(color_text): return {} if not _number_in(definition.get("purchase_cost", 0), 0.0, 1000000.0) \ or not _number_in(definition.get("damage_bonus", 0.0), -100000.0, 100000.0) \ or not _number_in(definition.get("cooldown_bonus", 0.0), -60.0, 60.0) \ or not _number_in(definition.get("stamina_bonus", 0.0), -100000.0, 100000.0) \ or not _number_in(definition.get("knockback_bonus", 0.0), -10000.0, 10000.0) \ or not _number_in(definition.get("damage_multiplier", 1.0), 0.0, 100.0) \ or not _number_in(definition.get("cooldown_multiplier", 1.0), 0.01, 100.0) \ or not _number_in(definition.get("stamina_multiplier", 1.0), 0.0, 100.0) \ or not _number_in(definition.get("knockback_multiplier", 1.0), 0.0, 100.0) \ or not _number_in(definition.get("piercing", 0), 0.0, 64.0) \ or not definition.get("respect_occlusion", true) is bool: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "slot_type": slot_type, "attack_pattern": pattern_id, "purchase_cost": int(definition.get("purchase_cost", 0)), "damage_bonus": float(definition.get("damage_bonus", 0.0)), "cooldown_bonus": float(definition.get("cooldown_bonus", 0.0)), "stamina_bonus": float(definition.get("stamina_bonus", 0.0)), "knockback_bonus": float(definition.get("knockback_bonus", 0.0)), "damage_multiplier": float(definition.get("damage_multiplier", 1.0)), "cooldown_multiplier": float(definition.get("cooldown_multiplier", 1.0)), "stamina_multiplier": float(definition.get("stamina_multiplier", 1.0)), "knockback_multiplier": float(definition.get("knockback_multiplier", 1.0)), "respect_occlusion": bool(definition.get("respect_occlusion", true)), "piercing": int(definition.get("piercing", 0)), "color": Color.from_string(color_text, Color.WHITE), } return result static func _normalize_equipment(value: Variant, valid_variables: Dictionary) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var equipment_type := str(definition.get("type", "armor")) var color_text := str(definition.get("color", "#ffffff")) var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not EQUIPMENT_TYPES.has(equipment_type) or not Color.html_is_valid(color_text) \ or not _whole_number_in(definition.get("purchase_cost", 0), 0, 1000000) \ or modifiers == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "type": equipment_type, "purchase_cost": int(definition.get("purchase_cost", 0)), "color": Color.from_string(color_text, Color.WHITE), "modifiers": modifiers, } return result static func _normalize_player_configs(value: Variant, valid_patterns: Dictionary, valid_weapons: Dictionary, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var fields := { "starting_hp": Vector2(1.0, 100000.0), "starting_stamina": Vector2(1.0, 100000.0), "move_speed": Vector2(0.01, 5000.0), "move_stamina_per_second": Vector2(0.0, 10000.0), "attack_damage": Vector2(0.0, 100000.0), "attack_rate": Vector2(0.05, 60.0), "attack_knockback": Vector2(0.0, 10000.0), "attack_stamina_cost": Vector2(0.0, 10000.0), "damage_variance": Vector2(0.0, 1.0), "dash_speed": Vector2(0.0, 10000.0), "dash_duration": Vector2(0.01, 60.0), "dash_cooldown": Vector2(0.0, 60.0), "dash_stamina_cost": Vector2(0.0, 10000.0), "dash_charges": Vector2(0.0, 20.0), "dash_charge_cooldown": Vector2(0.0, 60.0), "hurtbox_length": Vector2(0.0, 256.0), "hurtbox_radius": Vector2(0.0, 256.0), } var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary or str(definition.get("display_name", "")).strip_edges().is_empty(): return {} var normalized := {"display_name": str(definition.display_name).strip_edges()} for field: String in fields: var limits: Vector2 = fields[field] if not _number_in(definition.get(field), limits.x, limits.y): return {} normalized[field] = float(definition[field]) var attack_pattern := str(definition.get("attack_pattern", "")) if not valid_patterns.has(attack_pattern): return {} normalized["attack_pattern"] = attack_pattern var starting_weapon := str(definition.get("starting_weapon", "")) if not valid_weapons.has(starting_weapon): return {} normalized["starting_weapon"] = starting_weapon var model_3d: Variant = _normalize_model_3d(definition.get("model_3d", {}), "player_configs") if model_3d == null: return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "player_configs", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "player_configs", valid_visuals) if visuals == null: return {} var speech_bubbles: Variant = _normalize_speech_bubbles(definition.get("speech_bubbles", {}), "player_configs") if speech_bubbles == null: return {} normalized["visuals"] = visuals normalized["vfx"] = hook_visuals normalized["model_3d"] = model_3d normalized["speech_bubbles"] = speech_bubbles result[id] = normalized return result static func _normalize_enemy_attacks(value: Variant, valid_patterns: Dictionary, legacy_knockback: float = 240.0) -> Variant: if not value is Array: return null var result: Array = [] for raw_attack: Variant in value: if not raw_attack is Dictionary: return null var pattern_id := str(raw_attack.get("pattern", "")) var animation := str(raw_attack.get("animation", "")).strip_edges() var animation_is_valid := animation.length() <= 160 for character_index: int in range(animation.length()): if animation.unicode_at(character_index) < 32: animation_is_valid = false break if not valid_patterns.has(pattern_id) \ or not animation_is_valid \ or not _number_in(raw_attack.get("damage_min", 0.0), 0.0, 100000.0) \ or not _number_in(raw_attack.get("damage_max", 0.0), 0.0, 100000.0) \ or float(raw_attack.get("damage_max", 0.0)) < float(raw_attack.get("damage_min", 0.0)) \ or not _number_in(raw_attack.get("knockback", legacy_knockback), 0.0, 10000.0) \ or not _number_in(raw_attack.get("interrupt_chance", 100.0), 0.0, 100.0) \ or not _number_in(raw_attack.get("cooldown", 1.0), 0.05, 60.0) \ or not _number_in(raw_attack.get("weight", 1.0), 0.0, 100000.0): return null result.append({ "pattern": pattern_id, "animation": animation, "damage_min": float(raw_attack.get("damage_min", 0.0)), "damage_max": float(raw_attack.get("damage_max", 0.0)), "knockback": float(raw_attack.get("knockback", legacy_knockback)), "interrupt_chance": float(raw_attack.get("interrupt_chance", 100.0)), "cooldown": float(raw_attack.get("cooldown", 1.0)), "cooldown_min": float(raw_attack.get("cooldown_min", raw_attack.get("cooldown", 1.0))), "weight": float(raw_attack.get("weight", 1.0)), }) return result static func _normalize_enemies(value: Variant, valid_ingredients: Dictionary, valid_attack_patterns: Dictionary, valid_movement_patterns: Dictionary, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var color_text := str(definition.get("color", "")) var shape_id := str(definition.get("shape", "circle")) var undetected_movement := str(definition.get("undetected_movement", "patrol")) var detected_movement := str(definition.get("detected_movement", "linear")) var hero_response := str(definition.get("hero_response", "chase")) var aggro_movement := str(definition.get("aggro_movement", detected_movement)) var aggro_response := str(definition.get("aggro_response", hero_response)) if not valid_movement_patterns.has(aggro_movement) or not HERO_RESPONSES.has(aggro_response): return {} for flag: String in ["aggro_on_detection", "aggro_on_damage"]: if not definition.get(flag, true) is bool: return {} if not _number_in(definition.get("aggro_detection_delay", 0.0), 0.0, 60.0): return {} if not _number_in(definition.get("aggro_memory_duration", 3.0), 0.0, 120.0): return {} if not _number_in(definition.get("state_cooldown", 0.25), 0.0, 60.0): return {} if not _number_in(definition.get("aggro_reentry_cooldown", 1.0), 0.0, 120.0): return {} if not _number_in(definition.get("detection_hysteresis", 20.0), 0.0, 5000.0): return {} var legacy_knockback := float(definition.get("knockback", 240.0)) if str(definition.get("display_name", "")).strip_edges().is_empty(): return {} if not Color.html_is_valid(color_text) or not ShapeFactory.SUPPORTED_SHAPES.has(shape_id) \ or not valid_movement_patterns.has(undetected_movement) \ or not valid_movement_patterns.has(detected_movement) \ or not HERO_RESPONSES.has(hero_response): return {} if not _number_in(definition.get("spawn_weight", 1.0), 0.0, 10000.0) \ or not _whole_number_in(definition.get("spawn_min", 0), 0, 999) \ or not _whole_number_in(definition.get("spawn_max", 6), 0, 999) \ or int(definition.get("spawn_max", 6)) < int(definition.get("spawn_min", 0)) \ or not _number_in(definition.get("min_size", 1.5), 0.25, 5.0) \ or not _number_in(definition.get("max_size", 2.0), 0.25, 5.0) \ or float(definition.get("max_size", 2.0)) < float(definition.get("min_size", 1.5)) \ or not _number_in(definition.get("health"), 1.0, 100000.0) \ or not _number_in(definition.get("speed"), 0.0, 5000.0) \ or not definition.get("direct_hit", false) is bool \ or not _number_in(definition.get("direct_damage_min", 0.0), 0.0, 100000.0) \ or not _number_in(definition.get("direct_damage_max", 0.0), 0.0, 100000.0) \ or float(definition.get("direct_damage_max", 0.0)) < float(definition.get("direct_damage_min", 0.0)) \ or not _number_in(definition.get("direct_hit_cooldown", 1.0), 0.05, 60.0) \ or not _number_in(definition.get("direct_hit_knockback", legacy_knockback), 0.0, 10000.0) \ or not _number_in(definition.get("detection_range"), 0.0, 5000.0) \ or not _number_in(definition.get("detection_angle"), 1.0, 360.0) \ or not _number_in(definition.get("knockback_resistance"), 0.0, 0.95) \ or not _number_in(definition.get("hurtbox_length", 0.0), 0.0, 256.0) \ or not _number_in(definition.get("hurtbox_radius", 0.0), 0.0, 256.0): return {} var drops: Variant = _normalize_drop_list(definition.get("drops", []), valid_ingredients) if drops == null: return {} var attacks: Variant = _normalize_enemy_attacks(definition.get("attacks", []), valid_attack_patterns, legacy_knockback) if attacks == null: return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "enemies", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "enemies", valid_visuals) if visuals == null: return {} var speech_bubbles: Variant = _normalize_speech_bubbles(definition.get("speech_bubbles", {}), "enemies") if speech_bubbles == null: return {} var model_3d: Variant = _normalize_model_3d(definition.get("model_3d", {}), "enemies") if model_3d == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "spawn_weight": float(definition.get("spawn_weight", 1.0)), "spawn_min": int(definition.get("spawn_min", 0)), "spawn_max": int(definition.get("spawn_max", 6)), "min_size": float(definition.get("min_size", 1.5)), "max_size": float(definition.get("max_size", 2.0)), "shape": shape_id, "health": float(definition.health), "speed": float(definition.speed), "direct_hit": bool(definition.get("direct_hit", false)), "direct_damage_min": float(definition.get("direct_damage_min", 0.0)), "direct_damage_max": float(definition.get("direct_damage_max", 0.0)), "direct_hit_cooldown": float(definition.get("direct_hit_cooldown", 1.0)), "direct_hit_knockback": float(definition.get("direct_hit_knockback", legacy_knockback)), "attacks": attacks, "detection_range": float(definition.detection_range), "detection_angle": float(definition.detection_angle), "undetected_movement": undetected_movement, "detected_movement": detected_movement, "hero_response": hero_response, "knockback_resistance": float(definition.knockback_resistance), "hurtbox_length": float(definition.get("hurtbox_length", 0.0)), "hurtbox_radius": float(definition.get("hurtbox_radius", 0.0)), "color": Color.from_string(color_text, Color.WHITE), "drops": drops, "model_3d": model_3d, "visuals": visuals, "vfx": hook_visuals, "speech_bubbles": speech_bubbles, } result[id]["aggro_movement"] = aggro_movement result[id]["aggro_response"] = aggro_response result[id]["aggro_on_detection"] = bool(definition.get("aggro_on_detection", true)) result[id]["aggro_on_damage"] = bool(definition.get("aggro_on_damage", true)) result[id]["aggro_detection_delay"] = float(definition.get("aggro_detection_delay", 0.0)) result[id]["aggro_memory_duration"] = float(definition.get("aggro_memory_duration", 3.0)) result[id]["state_cooldown"] = float(definition.get("state_cooldown", 0.25)) result[id]["aggro_reentry_cooldown"] = float(definition.get("aggro_reentry_cooldown", 1.0)) result[id]["detection_hysteresis"] = float(definition.get("detection_hysteresis", 20.0)) return result static func _normalize_recipe_consumable(value: Variant, valid_variables: Dictionary, valid_effects: Dictionary) -> Variant: if not value is Dictionary: return null var mode := str(value.get("mode", "eat")) var modifiers: Variant = _normalize_upgrade_modifiers(value.get("modifiers", []), valid_variables) var combat_effects: Variant = _normalize_combat_effect_assignments(value.get("combat_effects", []), valid_effects) if not CONSUMABLE_MODES.has(mode) or modifiers == null or combat_effects == null \ or not _number_in(value.get("heal", 0.0), 0.0, 100000.0) \ or not _number_in(value.get("stamina_restore", 0.0), 0.0, 100000.0) \ or not _number_in(value.get("duration", 0.0), 0.0, 3600.0) \ or not _number_in(value.get("throw_speed", 640.0), 1.0, 10000.0) \ or not _number_in(value.get("throw_range", 500.0), 1.0, 10000.0) \ or not _number_in(value.get("throw_radius", 72.0), 1.0, 1000.0) \ or not _number_in(value.get("throw_damage", 0.0), 0.0, 100000.0): return null return { "mode": mode, "heal": float(value.get("heal", 0.0)), "stamina_restore": float(value.get("stamina_restore", 0.0)), "duration": float(value.get("duration", 0.0)), "modifiers": modifiers, "throw_speed": float(value.get("throw_speed", 640.0)), "throw_range": float(value.get("throw_range", 500.0)), "throw_radius": float(value.get("throw_radius", 72.0)), "throw_damage": float(value.get("throw_damage", 0.0)), "combat_effects": combat_effects, } static func _normalize_recipes(value: Variant, valid_ingredients: Dictionary, valid_visuals: Dictionary = {}, valid_variables: Dictionary = {}, valid_facts: Dictionary = {}, scope: Dictionary = {}, valid_effects: Dictionary = {}, valid_categories: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var fields: Variant = _normalize_recipe_fields(definition, valid_ingredients, valid_visuals, valid_variables, valid_facts, scope, valid_effects, valid_categories) if fields == null: return {} var levels: Variant = _normalize_recipe_levels(definition.get("levels", []), valid_ingredients, valid_visuals, valid_variables, valid_facts, scope, valid_effects, valid_categories) if levels == null: return {} var record: Dictionary = fields record["levels"] = levels result[id] = record return result ## Les champs d'un plat, valides une fois pour la fiche et pour chaque niveau. ## Un niveau est une fiche complete: memes controles, meme forme. Deux listes ## de verifications auraient diverge des la premiere retouche. static func _normalize_recipe_fields(definition: Dictionary, valid_ingredients: Dictionary, valid_visuals: Dictionary, valid_variables: Dictionary, valid_facts: Dictionary, scope: Dictionary, valid_effects: Dictionary, valid_categories: Dictionary) -> Variant: if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not _number_in(definition.get("cook_time"), 0.05, 3600.0) \ or not _whole_number_in(definition.get("sale_price"), 0, 1000000): return null var amounts: Variant = _normalize_amounts(definition.get("ingredients", {}), valid_ingredients, false) var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "recipes", valid_visuals) var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "recipes", valid_visuals) var conditions: Variant = _normalize_upgrade_conditions( definition.get("conditions", []), valid_variables, valid_facts, scope.get("upgrade_nodes", {}), scope) var combat_consumable: Variant = _normalize_recipe_consumable(definition.get("combat_consumable", {}), valid_variables, valid_effects) var raw_categories: Variant = _normalize_food_category_list(definition.get("categories", []), valid_categories) if amounts == null or hook_visuals == null or visuals == null or conditions == null \ or combat_consumable == null or raw_categories == null: return null # `recipes..can_cook` calls back into these conditions. Allowing a # recipe to depend on that computed field would create a direct or indirect # recursion; counters and every other recipe field remain valid targets. for condition: Dictionary in conditions: var condition_path := str(condition.get("path", "")) if condition_path.begins_with("recipes.") and condition_path.ends_with(".can_cook"): return null return { "display_name": str(definition.display_name).strip_edges(), "ingredients": amounts, "visuals": visuals, "vfx": hook_visuals, "categories": raw_categories, "cook_time": float(definition.cook_time), "sale_price": int(definition.sale_price), "conditions": conditions, "combat_consumable": combat_consumable, } ## Les niveaux d'un plat. Le niveau 1 est le plat lui-meme; cette liste porte ## les suivants, chacun etant une **fiche complete** plus le nombre de plats ## servis qui l'ouvre. Les seuils montent strictement: un palier qui ## n'augmente pas serait atteint avec le precedent et n'existerait jamais. static func _normalize_recipe_levels(value: Variant, valid_ingredients: Dictionary, valid_visuals: Dictionary, valid_variables: Dictionary, valid_facts: Dictionary, scope: Dictionary, valid_effects: Dictionary, valid_categories: Dictionary) -> Variant: if not value is Array: return null var levels: Array = [] var previous_threshold := 0 for raw_level: Variant in value: if not raw_level is Dictionary: return null var level: Dictionary = raw_level var threshold := int(level.get("served_to_unlock", 0)) if threshold <= previous_threshold or threshold > 1000000: return null previous_threshold = threshold var entry: Variant = _normalize_recipe_fields(level, valid_ingredients, valid_visuals, valid_variables, valid_facts, scope, valid_effects, valid_categories) if entry == null: return null var fields_of_level: Dictionary = entry fields_of_level["served_to_unlock"] = threshold levels.append(fields_of_level) return levels static func _normalize_customer_profiles(value: Variant, valid_categories: Dictionary, valid_visuals: Dictionary = {}, valid_variables: Dictionary = {}, valid_facts: Dictionary = {}, scope: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var color_text := str(definition.get("color", "")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not Color.html_is_valid(color_text) \ or not _number_in(definition.get("speed"), 10.0, 1000.0) \ or not _whole_number_in(definition.get("cash"), 0, 1000000) \ or not _whole_number_in(definition.get("appetite"), 1, 10) \ or not _number_in(definition.get("spending", 0.5), 0.0, 1.0) \ or not _number_in(definition.get("popularity_weight", 5.0), 0.0, 1000.0) \ or not _number_in(definition.get("max_on_screen", 0.0), 0.0, 64.0) \ or not _number_in(definition.get("respawn_cooldown", 0.0), 0.0, 100000.0) \ or not _number_in(definition.get("patience"), 1.0, 3600.0) \ or not _number_in(definition.get("meal_consumption_speed"), 0.05, 20.0) \ or not _number_in(definition.get("generosity"), 0.0, 1.0) \ or not _number_in(definition.get("hunger", 0.5), 0.0, 1.0) \ or not _number_in(definition.get("queue_tolerance", 1.0), 0.0, 10.0) \ or not _number_in(definition.get("arrival_weight"), 0.01, 100.0): return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "customer_profiles", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "customer_profiles", valid_visuals) if visuals == null: return {} var speech_bubbles: Variant = _normalize_speech_bubbles(definition.get("speech_bubbles", {}), "customer_profiles") if speech_bubbles == null: return {} var model_3d: Variant = _normalize_model_3d(definition.get("model_3d", {}), "customer_profiles") if model_3d == null: return {} # 1 est le poids neutre: une categorie absente de la table n'a pas besoin # d'y figurer, et une table vide decrit un client sans preference. var preferences: Variant = _normalize_weight_map(definition.get("category_preferences", {}), valid_categories) if preferences == null: return {} var conditions: Variant = _normalize_upgrade_conditions( definition.get("conditions", []), valid_variables, valid_facts, scope.get("upgrade_nodes", {}), scope) if conditions == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "color": Color.from_string(color_text, Color.WHITE), "speed": float(definition.speed), "cash": int(definition.cash), "appetite": int(definition.appetite), "spending": float(definition.get("spending", 0.5)), "category_preferences": preferences, "popularity_weight": float(definition.get("popularity_weight", 5.0)), "max_on_screen": int(definition.get("max_on_screen", 0)), "respawn_cooldown": float(definition.get("respawn_cooldown", 0.0)), "conditions": conditions, "model_3d": model_3d, "visuals": visuals, "vfx": hook_visuals, "patience": float(definition.patience), "meal_consumption_speed": float(definition.meal_consumption_speed), "generosity": float(definition.generosity), "arrival_weight": float(definition.arrival_weight), # `hunger` pousse à entrer malgré la file, `queue_tolerance` dit combien # de fois sa patience un passant accepte d'attendre debout. "hunger": float(definition.get("hunger", 0.5)), "queue_tolerance": float(definition.get("queue_tolerance", 1.0)), "speech_bubbles": speech_bubbles, } return result # --- Salles ------------------------------------------------------------------ # # La salle est l'unité écrite: elle nomme son biome et choisit ses créatures. # Le biome n'est plus qu'une palette de secours — habillage, et table par défaut # quand la salle n'en déclare aucune. # # `dimension` vide fait une salle spéciale: elle n'occupe aucune case de la # grille et ne survient qu'en remplaçant une case normale, selon `replaces`. static func room_key(dimension_id: String, level: int) -> String: return "%s@%d" % [dimension_id, level] # Salles d'une dimension, triées par niveau. static func rooms_of(dimension_id: String, source: Dictionary = {}) -> Array[String]: var pool: Dictionary = source if not source.is_empty() else ROOMS var found: Array[String] = [] for id: String in pool: if str((pool[id] as Dictionary).get("dimension", "")) == dimension_id: found.append(id) found.sort_custom(func(a: String, b: String) -> bool: var level_a := int((pool[a] as Dictionary).get("level", 0)) var level_b := int((pool[b] as Dictionary).get("level", 0)) return level_a < level_b if level_a != level_b else a < b) return found # Salles spéciales, c'est-à-dire rattachées à aucune dimension. static func special_rooms(source: Dictionary = {}) -> Array[String]: var pool: Dictionary = source if not source.is_empty() else ROOMS var found: Array[String] = [] for id: String in pool: if str((pool[id] as Dictionary).get("dimension", "")).is_empty(): found.append(id) found.sort() return found # Colonne de secours. Sans salle écrite, une dimension reçoit quelques cases # minimales plutôt qu'une plongée vide: c'est un filet, pas un miroir du contenu # réel, qui vit dans le fichier maître. const FALLBACK_COLUMN_LENGTH := 4 static func _generated_rooms(dimensions: Dictionary, biomes: Dictionary) -> Dictionary: var result := {} var first_biome := "" for biome_id: String in biomes: first_biome = biome_id break for dimension_id: String in dimensions: for level: int in range(1, FALLBACK_COLUMN_LENGTH + 1): var room := _generated_room(dimension_id, level, first_biome, biomes.get(first_biome, {})) # La dernière case du filet clôt la dimension, sinon aucune plongée de # secours ne pourrait se terminer. room["ends_dimension"] = level == FALLBACK_COLUMN_LENGTH result["%s_l%d" % [dimension_id, level]] = room return result static func _generated_room(dimension_id: String, level: int, biome_id: String, biome: Dictionary) -> Dictionary: return { "display_name": "%s L%d" % [str(biome.get("display_name", biome_id)), level], "description": "", "dimension": dimension_id, "level": level, "biome": biome_id, # Zéro veut dire « prends la statistique »: une salle générée ne fige pas # des nombres que l'équilibrage devrait ensuite défaire un par un. "waves": [], "scroll_axis": "none", "scroll_extent": 1.0, "reward_choices": 0, "rewards": {}, "modifiers": [], "elements": {}, "behaviours": [], "replaces": [], "max_per_run": 0, "conditions": [], "exit_offset": 1, "exit_level": 0, "ends_dimension": false, } # Forme de la salle. `none` est l'arene 16:9 d'origine; les deux autres # l'etirent sur un seul axe, jamais les deux - une salle qui defile dans les # deux sens redeviendrait une carte, et la camera n'aurait plus de cadrage sur. const ROOM_SCROLL_AXES := ["none", "horizontal", "vertical"] # `any`: la première des deux conditions réalisée déclenche la vague. `all`: il # faut les deux. Miroir de `ROOM_WAVE_TRIGGERS` dans `config.php`. const ROOM_WAVE_TRIGGERS := ["any", "all"] # La valeur qui dit « cette vague ne regarde pas l'effectif ». Elle a besoin # d'exister: sans elle, une vague purement minutée serait indistinguable d'une # vague qui attend le plateau vide, qui est `0.0`. const WAVE_REMAINING_IGNORED := -1.0 const ROOM_SCROLL_MAX := 2.0 static func _normalize_room_elements(value: Variant, valid_ingredients: Dictionary, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var kind := str(definition.get("kind", "destructible")) var color_value: Variant = definition.get("color", Color("8a6a3d")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not ROOM_ELEMENT_KINDS.has(kind) \ or not (color_value is Color or Color.html_is_valid(str(color_value))) \ or not _number_in(definition.get("size", 1.0), 0.1, 10.0) \ or not _number_in(definition.get("health", 0.0), 0.0, 1000000.0) \ or not _number_in(definition.get("damage", 0.0), 0.0, 1000000.0) \ or not _number_in(definition.get("trigger_radius", 0.0), 0.0, 4000.0) \ or not _number_in(definition.get("rearm_delay", 0.0), 0.0, 3600.0) \ or not _whole_number_in(definition.get("coins", 0), 0, 1000000): return {} var drops: Variant = _normalize_drop_list(definition.get("drops", []), valid_ingredients) if drops == null: return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "room_elements", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "room_elements", valid_visuals) if visuals == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "kind": kind, "shape": str(definition.get("shape", "square")), "color": color_value if color_value is Color else Color.from_string(str(color_value), Color.WHITE), "size": float(definition.get("size", 1.0)), "health": float(definition.get("health", 0.0)), "solid": bool(definition.get("solid", true)), "damage": float(definition.get("damage", 0.0)), "trigger_radius": float(definition.get("trigger_radius", 0.0)), "rearm_delay": float(definition.get("rearm_delay", 0.0)), "coins": int(definition.get("coins", 0)), "drops": drops, "visuals": visuals, "vfx": hook_visuals, } return result static func _normalize_rooms(value: Variant, valid_dimensions: Dictionary, valid_biomes: Dictionary, valid_enemies: Dictionary, valid_rewards: Dictionary, valid_elements: Dictionary, valid_variables: Dictionary, scope: Dictionary, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary: return {} var result := {} # Deux salles ne peuvent pas occuper la même case: sans ce contrôle, l'ordre # du dictionnaire déciderait laquelle le joueur traverse. var occupied := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var dimension_id := str(definition.get("dimension", "")) var biome_id := str(definition.get("biome", "")) if not _whole_number_in(definition.get("level", 0), 0, 9999): return {} var level := int(definition.get("level", 0)) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not valid_biomes.has(biome_id) \ or not ROOM_SCROLL_AXES.has(str(definition.get("scroll_axis", "none"))) \ or not _number_in(definition.get("scroll_extent", 1.0), 1.0, ROOM_SCROLL_MAX) \ or not _whole_number_in(definition.get("reward_choices", 0), 0, 12) \ or not _whole_number_in(definition.get("max_per_run", 0), 0, 100) \ or not _whole_number_in(definition.get("exit_offset", 1), 1, 999) \ or not _whole_number_in(definition.get("exit_level", 0), 0, 9999): return {} if not dimension_id.is_empty(): if not valid_dimensions.has(dimension_id) or level < 1: return {} var key := room_key(dimension_id, level) if occupied.has(key): return {} occupied[key] = id var waves: Variant = _normalize_room_waves(definition.get("waves", []), valid_enemies) var rewards: Variant = _normalize_weight_map(definition.get("rewards", {}), valid_rewards) var elements: Variant = _normalize_room_element_placements(definition.get("elements", {}), valid_elements) var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) var replaces: Variant = _normalize_room_replacements(definition.get("replaces", []), valid_dimensions) # Les conditions d'une salle parlent le même langage que celles de l'arbre # d'upgrades: une adresse, un opérateur, une valeur. Rien de plus. var conditions: Variant = _normalize_upgrade_conditions(definition.get("conditions", []), scope.get("game_variables", {}), scope.get("game_facts", {}), {}, scope) if waves == null or rewards == null \ or elements == null or modifiers == null or replaces == null or conditions == null: return {} # Une salle spéciale qui ne remplace rien n'est atteignable par aucun # chemin: c'est une donnée morte, donc une erreur. if dimension_id.is_empty() and (replaces as Array).is_empty(): return {} var raw_behaviours: Variant = definition.get("behaviours", []) if not raw_behaviours is Array: return {} var behaviours: Array[String] = [] for raw_behaviour: Variant in raw_behaviours: var behaviour := str(raw_behaviour) if not ROOM_BEHAVIOURS.has(behaviour): return {} if not behaviours.has(behaviour): behaviours.append(behaviour) var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "rooms", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "rooms", valid_visuals) if visuals == null: return {} # Une salle qui ne défile pas ramène son étendue à 1: garder 2,5 sur un # axe `none` laisserait un nombre que plus personne ne lit. var scroll_axis := str(definition.get("scroll_axis", "none")) var scroll_extent := 1.0 if scroll_axis != "none": scroll_extent = clampf(float(definition.get("scroll_extent", 1.0)), 1.0, ROOM_SCROLL_MAX) result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "dimension": dimension_id, "level": level, "biome": biome_id, # Les vagues, dans l'ordre où elles arrivent. La première est celle qui # attend le joueur quand il pousse la porte. "waves": waves, "scroll_axis": scroll_axis, "scroll_extent": scroll_extent, "reward_choices": int(definition.get("reward_choices", 0)), "rewards": rewards, "visuals": visuals, "vfx": hook_visuals, "modifiers": modifiers, "elements": elements, "behaviours": behaviours, "replaces": replaces, "max_per_run": int(definition.get("max_per_run", 0)), "conditions": conditions, # Sortie: `exit_level` vise une case précise, sinon `exit_offset` compte # les cases à sauter. Un tunnel secret est une sortie à grand pas. "exit_offset": int(definition.get("exit_offset", 1)), "exit_level": int(definition.get("exit_level", 0)), # La dimension se termine sur la salle qui le déclare, pas sur une # salle de boss codée en dur ailleurs. "ends_dimension": bool(definition.get("ends_dimension", false)), } return result # Carte identifiant vers poids. Retourne null sur donnée invalide, pour # distinguer « aucun poids déclaré » d'« erreur de saisie ». static func _normalize_weight_map(value: Variant, valid_ids: Dictionary) -> Variant: if _is_empty_map(value): return {} if not value is Dictionary: return null var result := {} for raw_id: Variant in value: var id := str(raw_id) if not valid_ids.has(id) or not _number_in(value[raw_id], 0.0, 100000.0): return null result[id] = float(value[raw_id]) return result # Les vagues d'une salle. Miroir de `normalize_room_waves` côté PHP: une liste # ordonnée, chacune avec son effectif, son tirage de créatures et sa condition # d'arrivée. `null` refuse la salle entière — une vague illisible vaut mieux # qu'une salle qui se peuple au hasard. static func _normalize_room_waves(value: Variant, valid_enemies: Dictionary) -> Variant: if not value is Array: return null var waves: Array[Dictionary] = [] for raw: Variant in (value as Array): if not raw is Dictionary: return null var wave := raw as Dictionary if not _whole_number_in(wave.get("count", 0), 0, 500) \ or not _number_in(wave.get("delay", 0.0), 0.0, 3600.0) \ or not _number_in(wave.get("chance", 1.0), 0.01, 1.0) \ or not ROOM_WAVE_TRIGGERS.has(str(wave.get("trigger", "any"))): return null var remaining := float(wave.get("remaining", WAVE_REMAINING_IGNORED)) if not is_equal_approx(remaining, WAVE_REMAINING_IGNORED) and not _number_in(remaining, 0.0, 1.0): return null var enemies: Variant = _normalize_weight_map(wave.get("enemies", {}), valid_enemies) if enemies == null: return null waves.append({ "count": int(wave.get("count", 0)), "enemies": enemies, "delay": float(wave.get("delay", 0.0)), "remaining": remaining, "trigger": str(wave.get("trigger", "any")), "chance": float(wave.get("chance", 1.0)), }) return waves static func _normalize_room_element_placements(value: Variant, valid_elements: Dictionary) -> Variant: if _is_empty_map(value): return {} if not value is Dictionary: return null var result := {} for raw_id: Variant in value: var id := str(raw_id) var placement: Variant = value[raw_id] if not valid_elements.has(id) or not placement is Dictionary: return null if not _whole_number_in((placement as Dictionary).get("min", 1), 0, 200) \ or not _whole_number_in((placement as Dictionary).get("max", 1), 0, 200) \ or not _number_in((placement as Dictionary).get("chance", 1.0), 0.0, 1.0): return null var minimum := int((placement as Dictionary).get("min", 1)) var maximum := int((placement as Dictionary).get("max", 1)) if maximum < minimum: return null result[id] = {"chance": float((placement as Dictionary).get("chance", 1.0)), "min": minimum, "max": maximum} return result # Cases qu'une salle spéciale peut remplacer. La désignation est positionnelle: # « la case N de telle dimension », comme la grille de l'outil l'affiche. static func _normalize_room_replacements(value: Variant, valid_dimensions: Dictionary) -> Variant: if not value is Array: return null var result: Array[Dictionary] = [] for entry: Variant in value: if not entry is Dictionary: return null var dimension_id := str((entry as Dictionary).get("dimension", "")) if not valid_dimensions.has(dimension_id) \ or not _whole_number_in((entry as Dictionary).get("level", 0), 1, 9999) \ or not _number_in((entry as Dictionary).get("chance", 0.0), 0.0, 1.0): return null result.append({ "dimension": dimension_id, "level": int((entry as Dictionary).level), "chance": float((entry as Dictionary).chance), }) return result # --- Structure d'une plongée ------------------------------------------------ static func _normalize_biomes(value: Variant, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} if str(definition.get("display_name", "")).strip_edges().is_empty(): return {} var colors := {} for field: String in ["floor_color", "wall_color", "accent_color"]: var raw_color: Variant = definition.get(field, DEFAULT_BIOMES.mossy_cellar[field]) var color_text := str(raw_color) if raw_color is Color: colors[field] = raw_color elif Color.html_is_valid(color_text): colors[field] = Color.from_string(color_text, Color.WHITE) else: return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "biomes", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "biomes", valid_visuals) if visuals == null: return {} # L'ambiance: une teinte posée sur toute la salle. Le blanc pur ne teinte # rien — c'est le défaut, et c'est ce que le jeu affiche aujourd'hui. var ambient_raw: Variant = definition.get("ambient_color", "#ffffff") var ambient_color := Color.WHITE if ambient_raw is Color: ambient_color = ambient_raw elif Color.html_is_valid(str(ambient_raw)): ambient_color = Color.from_string(str(ambient_raw), Color.WHITE) else: return {} if not _number_in(definition.get("ambient_energy", 1.0), 0.0, 8.0): return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "floor_color": colors.floor_color, "wall_color": colors.wall_color, "accent_color": colors.accent_color, "ambient_color": ambient_color, "ambient_energy": float(definition.get("ambient_energy", 1.0)), "visuals": visuals, "vfx": hook_visuals, } return result static func _normalize_dimensions(value: Variant, valid_visuals: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not _whole_number_in(definition.get("order", 1), 0, 999): return {} var hook_visuals: Variant = _normalize_vfx_map(definition.get("vfx", {}), "dimensions", valid_visuals) if hook_visuals == null: return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "dimensions", valid_visuals) if visuals == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "order": int(definition.get("order", 1)), "visuals": visuals, "vfx": hook_visuals, } return result # Miroir de normalize_employees côté PHP. Un employé invalide fait retomber la # collection entière sur les valeurs de secours, comme partout ailleurs. static func _normalize_employees(value: Variant, valid_variables: Dictionary, valid_facts: Dictionary, valid_recipes: Dictionary, valid_visuals: Dictionary = {}, address_scope: Dictionary = {}) -> Dictionary: if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var role := str(definition.get("role", "server")) if not EMPLOYEE_ROLES.has(role) or str(definition.get("display_name", "")).strip_edges().is_empty(): return {} if not _number_in(definition.get("hire_cost", 0), 0.0, 1000000.0): return {} var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) if modifiers == null: return {} var skills := {} for raw_skill: Variant in definition.get("skills", {}): var skill := str(raw_skill) if not EMPLOYEE_SKILLS.has(skill) or not _number_in(definition.skills[raw_skill], 0.0, 1000.0): return {} skills[skill] = float(definition.skills[raw_skill]) var task_preferences := {} var raw_preferences: Variant = definition.get("task_preferences", {}) # PHP encode une carte associative vide en `[]`. C'est la forme normale # des preferences d'un cuisinier, qui n'en utilise aucune. if raw_preferences is Array and raw_preferences.is_empty(): raw_preferences = {} if not raw_preferences is Dictionary: return {} for raw_preference: Variant in raw_preferences: var preference := str(raw_preference) if not SERVER_TASK_PREFERENCES.has(preference) or not _number_in(raw_preferences[raw_preference], 0.0, 5.0): return {} if role == "server": for preference: String in SERVER_TASK_PREFERENCES: task_preferences[preference] = float(raw_preferences.get(preference, 1.0)) var station_affinity := {} for raw_kind: Variant in definition.get("station_affinity", {}): var kind := str(raw_kind) if not EMPLOYEE_STATION_KINDS.has(kind) or not _number_in(definition.station_affinity[raw_kind], 0.0, 1000.0): return {} station_affinity[kind] = float(definition.station_affinity[raw_kind]) var recipe_affinity := {} for raw_recipe: Variant in definition.get("recipe_affinity", {}): var recipe_id := str(raw_recipe) if not valid_recipes.has(recipe_id) or not _number_in(definition.recipe_affinity[raw_recipe], 0.0, 1000.0): return {} recipe_affinity[recipe_id] = float(definition.recipe_affinity[raw_recipe]) var color_value: Variant = definition.get("color", Color("6ec8a8")) var scope := str(definition.get("scope", "dimension")) if not ["dimension", "meta"].has(scope): return {} var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "employees", valid_visuals) if visuals == null: return {} var speech_bubbles: Variant = _normalize_speech_bubbles(definition.get("speech_bubbles", {}), "employees") if speech_bubbles == null: return {} var model_3d: Variant = _normalize_model_3d(definition.get("model_3d", {}), "employees") if model_3d == null: return {} var valid_upgrades: Dictionary = address_scope.get("upgrade_nodes", {}) if address_scope.get("upgrade_nodes", {}) is Dictionary else {} var conditions: Variant = _normalize_upgrade_conditions(definition.get("conditions", []), valid_variables, valid_facts, valid_upgrades, address_scope) if conditions == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "color": color_value if color_value is Color else Color(str(color_value)), "role": role, "hire_cost": int(definition.get("hire_cost", 0)), "scope": scope, "skills": skills, "task_preferences": task_preferences, "station_affinity": station_affinity, "recipe_affinity": recipe_affinity, "modifiers": modifiers, "conditions": conditions, "on_hired": [], "model_3d": model_3d, "visuals": visuals, "speech_bubbles": speech_bubbles, } return result # Un meuble de la salle. Son comportement peut être `none`: un élément purement # décoratif reste un élément, et ses modificateurs passifs comptent quand même. # `placement_requires` nomme les meubles qui doivent déjà être posés. Garder la # dépendance dans la fiche permet au mode construction de bloquer aussi bien la # pose du dépendant que le retrait de ses prérequis. static func _normalize_restaurant_elements(value: Variant, valid_variables: Dictionary, valid_visuals: Dictionary) -> Dictionary: if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} if str(definition.get("display_name", "")).strip_edges().is_empty(): return {} if not _number_in(definition.get("width", 0), 16.0, 512.0) or not _number_in(definition.get("height", 0), 16.0, 512.0): return {} var kind := str(definition.get("kind", "none")) if not ELEMENT_KINDS.has(kind): return {} if not _whole_number_in(definition.get("usable_faces", 0), 0, 15): return {} var anchor := str(definition.get("anchor", "")) if not ELEMENT_ANCHORS.has(anchor): return {} if not _whole_number_in(definition.get("max_count", 1), 0, 99): return {} # Vide veut dire « la limite écrite suffit ». Une statistique nommée la # remplace, et c'est ce qui rend la limite améliorable. var max_variable := str(definition.get("max_count_variable", "")) if not max_variable.is_empty() and not valid_variables.has(max_variable): return {} if not _whole_number_in(definition.get("cost", 0), 0, 1000000): return {} var shape := str(definition.get("fallback_shape", "square")) if not ELEMENT_SHAPES.has(shape): return {} var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) if modifiers == null: return {} var raw_requirements: Variant = definition.get("placement_requires", []) if not raw_requirements is Array: return {} var placement_requires: Array[String] = [] for raw_requirement: Variant in raw_requirements: var requirement := str(raw_requirement) if requirement == id or not value.has(requirement) or placement_requires.has(requirement): return {} placement_requires.append(requirement) var visuals: Variant = _normalize_visual_map(definition.get("visuals", {}), "restaurant_elements", valid_visuals) if visuals == null: return {} var color_value: Variant = definition.get("fallback_color", Color("ffbe0b")) result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "width": float(definition.width), "height": float(definition.height), "kind": kind, "usable_faces": int(definition.get("usable_faces", 0)), "anchor": anchor, "max_count": int(definition.get("max_count", 1)), "max_count_variable": max_variable, "cost": int(definition.get("cost", 0)), "fallback_shape": shape, "fallback_color": color_value if color_value is Color else Color(str(color_value)), "modifiers": modifiers, "conditions": [], "placement_requires": placement_requires, "visuals": visuals, } if not _restaurant_placement_dependencies_are_acyclic(result): return {} return result # Les fonctions portées par un meuble suivent une seule chaîne de progression : # compteur entier de quantité, base 0 si verrouillé, puis bonus plat d'upgrade. static func _restaurant_element_unlock_contract_error(elements: Dictionary, variables: Dictionary, upgrades: Dictionary) -> String: for source_id: String in elements: var element: Dictionary = elements[source_id] var variable_id := str(element.get("max_count_variable", "")) if variable_id.is_empty(): continue var variable: Dictionary = variables.get(variable_id, {}) if variable.is_empty(): continue if str(variable.get("type", "")) != "int" or float(variable.get("minimum", 0.0)) < 0.0: return "Restaurant element '%s': limit statistic '%s' must be a non-negative integer." % [source_id, variable_id] if float(variable.get("base_value", 0.0)) > 0.0 or int(element.get("max_count", 0)) <= 0: continue var has_unlock_upgrade := false for node_id: String in upgrades: var node: Dictionary = upgrades[node_id] for raw_modifier: Variant in node.get("modifiers", []): if not raw_modifier is Dictionary: continue var modifier: Dictionary = raw_modifier if str(modifier.get("variable", "")) == variable_id \ and str(modifier.get("layer", "")) == "flat" \ and str(modifier.get("operation", "")) == "add" \ and float(modifier.get("value", 0.0)) > 0.0: has_unlock_upgrade = true break if has_unlock_upgrade: break if not has_unlock_upgrade: return "Restaurant element '%s': '%s' starts at zero but no upgrade adds a positive flat bonus." % [source_id, variable_id] return "" static func _restaurant_placement_dependencies_are_acyclic(definitions: Dictionary) -> bool: var resolved := {} for source_id: String in definitions: if _restaurant_placement_dependency_has_cycle(source_id, definitions, {}, resolved): return false return true static func _restaurant_placement_dependency_has_cycle(source_id: String, definitions: Dictionary, visiting: Dictionary, resolved: Dictionary) -> bool: if resolved.has(source_id): return false if visiting.has(source_id): return true var branch := visiting.duplicate() branch[source_id] = true for requirement: String in definitions[source_id].get("placement_requires", []): if _restaurant_placement_dependency_has_cycle(requirement, definitions, branch, resolved): return true resolved[source_id] = true return false static func restaurant_element_name(id: String) -> String: return Loc.entity("restaurant_elements", id, "display_name") # Configuration commune, mais vocabulaire de declencheurs propre a chaque type # d'acteur. Chaque message conserve son propre jet de pourcentage. static func _normalize_speech_bubbles(value: Variant, collection: String) -> Variant: if not SPEECH_BUBBLE_TRIGGERS.has(collection) or not value is Dictionary: return null if not _number_in(value.get("check_interval", 8.0), 0.1, 3600.0) \ or not _number_in(value.get("cooldown", 10.0), 0.0, 3600.0) \ or not _number_in(value.get("duration", 3.0), 0.25, 30.0): return null var raw_triggers: Variant = value.get("triggers", {}) if not raw_triggers is Dictionary: return null var allowed: Array = SPEECH_BUBBLE_TRIGGERS[collection] for raw_trigger: Variant in raw_triggers: if not allowed.has(str(raw_trigger)): return null var triggers := {} for trigger_id: String in allowed: var raw_messages: Variant = raw_triggers.get(trigger_id, []) if not raw_messages is Array: return null var messages: Array[Dictionary] = [] for raw_message: Variant in raw_messages: if not raw_message is Dictionary: return null var text := str(raw_message.get("text", "")).strip_edges() if text.is_empty() or text.length() > 500 \ or not _number_in(raw_message.get("chance"), 0.0, 100.0): return null messages.append({"text": text, "chance": float(raw_message.chance)}) triggers[trigger_id] = messages return { "check_interval": float(value.get("check_interval", 8.0)), "cooldown": float(value.get("cooldown", 10.0)), "duration": float(value.get("duration", 3.0)), "triggers": triggers, } # Miroir de normalize_visuals côté PHP. Rend `null` sur une entrée invalide — # une collection vide est légitime et ne doit pas être confondue avec une erreur. # Scenes narratives volontairement simples: les acteurs ne sont que des # representations visuelles. Ils reutilisent les fiches existantes sans lancer # leur IA, leurs collisions ni leurs comportements de restaurant/donjon. static func _normalize_story_scenes(value: Variant, actor_sources: Dictionary, valid_visuals: Dictionary, address_scope: Dictionary) -> Variant: if not value is Dictionary: return null var result := {} for raw_scene_id: Variant in value: var scene_id := str(raw_scene_id) var raw_definition: Variant = value[raw_scene_id] if not _valid_id(scene_id) or not raw_definition is Dictionary: return null var definition: Dictionary = raw_definition var display_name := str(definition.get("display_name", "")).strip_edges() var backdrop_color_raw: Variant = definition.get("backdrop_color", "#18233a") var backdrop_color_text: String = backdrop_color_raw.to_html() if backdrop_color_raw is Color else str(backdrop_color_raw) var backdrop_visual := str(definition.get("backdrop_visual", "")) if display_name.is_empty() or not Color.html_is_valid(backdrop_color_text) \ or (not backdrop_visual.is_empty() and not valid_visuals.has(backdrop_visual)): return null var raw_actors: Variant = definition.get("actors", []) if not raw_actors is Array or raw_actors.is_empty() or raw_actors.size() > 64: return null var actor_ids: Array[String] = [] var actors: Array[Dictionary] = [] for raw_actor: Variant in raw_actors: if not raw_actor is Dictionary: return null var actor: Dictionary = raw_actor var actor_id := str(actor.get("id", "")) var kind := str(actor.get("kind", "other")) var source := str(actor.get("source", "")) var source_collection := str(STORY_ACTOR_COLLECTIONS.get(kind, "")) var actor_name := str(actor.get("display_name", "")).strip_edges() var facing := str(actor.get("facing", "right")) var visual := str(actor.get("visual", "")) var color_raw: Variant = actor.get("color", "#6ec8a8") var color_text: String = color_raw.to_html() if color_raw is Color else str(color_raw) if not _valid_id(actor_id) or actor_ids.has(actor_id) or not STORY_ACTOR_KINDS.has(kind) \ or not STORY_FACINGS.has(facing) or not Color.html_is_valid(color_text) \ or (not visual.is_empty() and not valid_visuals.has(visual)): return null if kind == "other": if actor_name.is_empty() or not source.is_empty(): return null elif source_collection.is_empty() or not (actor_sources.get(source_collection, {}) as Dictionary).has(source): return null actor_ids.append(actor_id) actors.append({ "id": actor_id, "kind": kind, "source": source, "display_name": actor_name, "facing": facing, "visual": visual, "color": Color.from_string(color_text, Color("6ec8a8")), }) var placements := {} var scene_anchor_count := 0 for actor_index: int in actors.size(): var raw_actor: Dictionary = raw_actors[actor_index] var placement: Variant = _normalize_story_placement(raw_actor.get("placement", {}), actor_ids, str(actors[actor_index].id)) if placement == null: return null actors[actor_index]["placement"] = placement placements[str(actors[actor_index].id)] = placement if str(placement.mode) == "scene": scene_anchor_count += 1 if scene_anchor_count == 0 or not _story_placements_reach_scene(actor_ids, placements): return null var raw_events: Variant = definition.get("events", []) if not raw_events is Array or raw_events.size() > 1000: return null var event_ids: Array[String] = [] for raw_event: Variant in raw_events: if not raw_event is Dictionary: return null var event_id := str(raw_event.get("id", "")) if not _valid_id(event_id) or event_ids.has(event_id): return null event_ids.append(event_id) var events: Array[Dictionary] = [] for raw_event: Dictionary in raw_events: var normalized_event: Variant = _normalize_story_event(raw_event, actor_ids, event_ids, valid_visuals, address_scope) if normalized_event == null: return null events.append(normalized_event) result[scene_id] = { "display_name": display_name, "description": str(definition.get("description", "")), "backdrop_color": Color.from_string(backdrop_color_text, Color("18233a")), "backdrop_visual": backdrop_visual, "skippable": bool(definition.get("skippable", true)), "actors": actors, "events": events, } return result static func _normalize_story_placement(value: Variant, actor_ids: Array[String], self_actor_id: String = "") -> Variant: if not value is Dictionary: return null var mode := str(value.get("mode", "scene")) if not ["scene", "actor"].has(mode) \ or not _number_in(value.get("offset_x", 0.0), -4096.0, 4096.0) \ or not _number_in(value.get("offset_y", 0.0), -4096.0, 4096.0): return null var result := { "mode": mode, "anchor": str(value.get("anchor", "center")), "relative_to": str(value.get("relative_to", "")), "direction": str(value.get("direction", "E")), "distance": float(value.get("distance", 160.0)), "offset_x": float(value.get("offset_x", 0.0)), "offset_y": float(value.get("offset_y", 0.0)), } if mode == "scene": if not STORY_ANCHORS.has(str(result.anchor)): return null result.relative_to = "" else: if not actor_ids.has(str(result.relative_to)) or str(result.relative_to) == self_actor_id \ or not STORY_DIRECTIONS.has(str(result.direction)) \ or not _number_in(value.get("distance", 160.0), 0.0, 4096.0): return null return result static func _story_placements_reach_scene(actor_ids: Array[String], placements: Dictionary) -> bool: for actor_id: String in actor_ids: var seen := {} var current := actor_id while true: if seen.has(current) or not placements.has(current): return false seen[current] = true var placement: Dictionary = placements[current] if str(placement.mode) == "scene": break current = str(placement.relative_to) return true static func _normalize_story_event(raw_event: Dictionary, actor_ids: Array[String], event_ids: Array[String], valid_visuals: Dictionary, address_scope: Dictionary) -> Variant: var event_id := str(raw_event.get("id", "")) var event_type := str(raw_event.get("type", "text")) if not STORY_EVENT_TYPES.has(event_type): return null var result := {"id": event_id, "type": event_type} match event_type: "text": var text := str(raw_event.get("text", "")).strip_edges() if text.is_empty() or text.length() > 2000 or not _number_in(raw_event.get("duration", 0.0), 0.0, 600.0): return null result.merge({"text": text, "duration": float(raw_event.get("duration", 0.0))}) "speech": var actor := str(raw_event.get("actor", "")) var text := str(raw_event.get("text", "")).strip_edges() if not actor_ids.has(actor) or text.is_empty() or text.length() > 500 \ or not _number_in(raw_event.get("duration", 3.0), 0.0, 30.0): return null result.merge({"actor": actor, "text": text, "duration": float(raw_event.get("duration", 3.0))}) "wait": if not _number_in(raw_event.get("duration", 1.0), 0.01, 600.0): return null result["duration"] = float(raw_event.get("duration", 1.0)) "choice": var prompt := str(raw_event.get("prompt", "")).strip_edges() var raw_choices: Variant = raw_event.get("choices", []) if prompt.length() > 1000 or not raw_choices is Array or raw_choices.is_empty() or raw_choices.size() > 12: return null var choices: Array[Dictionary] = [] for raw_choice: Variant in raw_choices: if not raw_choice is Dictionary: return null var choice_text := str(raw_choice.get("text", "")).strip_edges() var target := str(raw_choice.get("goto", "")) if choice_text.is_empty() or choice_text.length() > 500 or (not target.is_empty() and not event_ids.has(target)): return null choices.append({"text": choice_text, "goto": target}) result.merge({"prompt": prompt, "choices": choices}) "vfx": var actor := str(raw_event.get("actor", "")) var visual := str(raw_event.get("visual", "")) if (not actor.is_empty() and not actor_ids.has(actor)) or not valid_visuals.has(visual) \ or not _number_in(raw_event.get("duration", 0.0), 0.0, 600.0) \ or not _number_in(raw_event.get("offset_x", 0.0), -4096.0, 4096.0) \ or not _number_in(raw_event.get("offset_y", 0.0), -4096.0, 4096.0): return null result.merge({ "actor": actor, "visual": visual, "duration": float(raw_event.get("duration", 0.0)), "offset_x": float(raw_event.get("offset_x", 0.0)), "offset_y": float(raw_event.get("offset_y", 0.0)), }) "move": var actor := str(raw_event.get("actor", "")) if not actor_ids.has(actor) or not _number_in(raw_event.get("duration", 1.0), 0.01, 600.0): return null var placement: Variant = _normalize_story_placement(raw_event.get("placement", {}), actor_ids, actor) if placement == null: return null result.merge({"actor": actor, "duration": float(raw_event.get("duration", 1.0)), "placement": placement}) "set_value": var path := str(raw_event.get("path", "")).strip_edges() var operation := str(raw_event.get("operation", "set")) if not Registry.EFFECT_OPS.has(operation) or not AddressCatalog.path_error_against(path, address_scope).is_empty() \ or not AddressCatalog.is_writable_against(path, address_scope): return null result.merge({"path": path, "operation": operation, "value": raw_event.get("value", 0)}) "face": var actor := str(raw_event.get("actor", "")) var facing := str(raw_event.get("facing", "right")) if not actor_ids.has(actor) or not STORY_FACINGS.has(facing): return null result.merge({"actor": actor, "facing": facing}) "visibility": var actor := str(raw_event.get("actor", "")) if not actor_ids.has(actor): return null result.merge({"actor": actor, "visible": bool(raw_event.get("visible", true))}) "goto": var target := str(raw_event.get("goto", "")) if not event_ids.has(target): return null result["goto"] = target "end": pass return result static func _normalize_visuals(value: Variant) -> Variant: if not value is Dictionary: return null var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return null var kind := str(definition.get("kind", "frames")) if not VISUAL_KINDS.has(kind) or str(definition.get("display_name", "")).strip_edges().is_empty(): return null if not _number_in(definition.get("columns", 1), 1.0, 512.0) \ or not _number_in(definition.get("rows", 1), 1.0, 512.0) \ or not _number_in(definition.get("count", 1), 1.0, 4096.0) \ or not _number_in(definition.get("fps", 8.0), 0.0, 240.0) \ or not _number_in(definition.get("scale", 1.0), 0.01, 100.0) \ or not _number_in(definition.get("offset_x", 0.0), -4096.0, 4096.0) \ or not _number_in(definition.get("offset_y", 0.0), -4096.0, 4096.0) \ or not _number_in(definition.get("lifetime", 0.0), 0.0, 600.0): return null var params := {} for raw_param: Variant in definition.get("shader_params", {}): params[str(raw_param)] = definition.shader_params[raw_param] result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "kind": kind, "path": str(definition.get("path", "")), "columns": int(definition.get("columns", 1)), "rows": int(definition.get("rows", 1)), "count": int(definition.get("count", 1)), "fps": float(definition.get("fps", 8.0)), "loop": bool(definition.get("loop", true)), "offset_x": float(definition.get("offset_x", 0.0)), "offset_y": float(definition.get("offset_y", 0.0)), "scale": float(definition.get("scale", 1.0)), "flip_follows_facing": bool(definition.get("flip_follows_facing", true)), # Rangées = orientations, du sud au nord par l'est. Le miroir couvre # l'ouest, donc cinq rangées suffisent aux huit points cardinaux. "rows_are_directions": bool(definition.get("rows_are_directions", false)), # Durée de vie d'un effet ponctuel. 0 = déduite de la découpe et de la # cadence, ce qui couvre le cas courant sans rien saisir. "lifetime": float(definition.get("lifetime", 0.0)), "shader": str(definition.get("shader", "")), "shader_params": params, "preview_image": str(definition.get("preview_image", "")), } return result # Carte `crochet → identifiant de visuel`. Les clés viennent du vocabulaire # d'événements existant: un effet ponctuel se déclenche là où un effet déclaratif # se déclenche déjà, et il n'y a donc aucun déclencheur à inventer. static func _normalize_vfx_map(value: Variant, collection: String, valid_visuals: Dictionary) -> Variant: if value == null: return {} if not value is Dictionary: return null var allowed: Array = AddressCatalog.hooks_for(collection) var result := {} for raw_hook: Variant in value: var hook := str(raw_hook) var visual_id := str(value[raw_hook]) if not allowed.has(hook) or not valid_visuals.has(visual_id): return null result[hook] = visual_id return result # Carte `état → identifiant de visuel` portée par une entité. Un état inconnu ou # un visuel inexistant invalide la fiche: mieux vaut le voir à la publication # qu'à l'écran. static func _normalize_visual_map(value: Variant, collection: String, valid_visuals: Dictionary) -> Variant: if value == null: return {} if not value is Dictionary: return null var allowed: Array = VISUAL_STATES.get(collection, []) var result := {} for raw_state: Variant in value: var state := str(raw_state) var visual_id := str(value[raw_state]) if not allowed.has(state) or not valid_visuals.has(visual_id): return null result[state] = visual_id return result # Configuration optionnelle d'un avatar GLB. Le chemin et le calage n'ont # aucune incidence sur la physique: ils ne sont lus que par Bridge3D. static func _normalize_model_3d(value: Variant, collection: String) -> Variant: if value == null or (value is Array and value.is_empty()): return {} if not value is Dictionary: return null var path := str(value.get("path", "")).strip_edges() if path.is_empty(): return {} if path.length() > 300 or not path.begins_with("res://assets/") \ or not path.to_lower().ends_with(".glb") or path.contains("..") \ or path.contains("\\"): return null var bounds := { "scale": Vector2(0.05, 10.0), "rotation_x": Vector2(-360.0, 360.0), "rotation_y": Vector2(-360.0, 360.0), "rotation_z": Vector2(-360.0, 360.0), "offset_x": Vector2(-5000.0, 5000.0), "offset_y": Vector2(-5000.0, 5000.0), "offset_z": Vector2(-5000.0, 5000.0), } var defaults := {"scale": 1.0, "rotation_x": 0.0, "rotation_y": 0.0, "rotation_z": 0.0, "offset_x": 0.0, "offset_y": 0.0, "offset_z": 0.0} var result := {"path": path} for field: String in bounds: var limits: Vector2 = bounds[field] var number: Variant = value.get(field, defaults[field]) if not _number_in(number, limits.x, limits.y): return null result[field] = float(number) var upper_body_root_bone := str(value.get("upper_body_root_bone", "")).strip_edges() if upper_body_root_bone.length() > 160: return null for character_index: int in range(upper_body_root_bone.length()): if upper_body_root_bone.unicode_at(character_index) < 32: return null result["upper_body_root_bone"] = upper_body_root_bone var animations: Variant = value.get("animations", {}) if animations is Array and animations.is_empty(): animations = {} if not animations is Dictionary: return null var allowed: Array = MODEL_3D_STATES.get(collection, []) var normalized_animations := {} for raw_state: Variant in animations: var state := str(raw_state) var clip := str(animations[raw_state]).strip_edges() if not allowed.has(state) or clip.length() > 160: return null if not clip.is_empty(): normalized_animations[state] = clip result["animations"] = normalized_animations var animation_speeds: Variant = value.get("animation_speeds", {}) if animation_speeds is Array and animation_speeds.is_empty(): animation_speeds = {} if not animation_speeds is Dictionary: return null var normalized_speeds := {} for raw_state: Variant in animation_speeds: var state := str(raw_state) var speed: Variant = animation_speeds[raw_state] if not allowed.has(state) or not _number_in(speed, 0.05, 10.0): return null normalized_speeds[state] = float(speed) result["animation_speeds"] = normalized_speeds var finish_map: Variant = value.get("finish_before_transition", {}) if finish_map is Array and finish_map.is_empty(): finish_map = {} if not finish_map is Dictionary: return null var normalized_finish := {} for raw_state: Variant in finish_map: var state := str(raw_state) var finish_value: Variant = finish_map[raw_state] if not allowed.has(state) or typeof(finish_value) != TYPE_BOOL: return null normalized_finish[state] = bool(finish_value) result["finish_before_transition"] = normalized_finish var in_place_map: Variant = value.get("animation_in_place", {}) if in_place_map is Array and in_place_map.is_empty(): in_place_map = {} if not in_place_map is Dictionary: return null var normalized_in_place := {} for raw_state: Variant in in_place_map: var state := str(raw_state) var in_place_value: Variant = in_place_map[raw_state] if not allowed.has(state) or typeof(in_place_value) != TYPE_BOOL: return null normalized_in_place[state] = bool(in_place_value) result["animation_in_place"] = normalized_in_place var layer_map: Variant = value.get("animation_layers", {}) if layer_map is Array and layer_map.is_empty(): layer_map = {} if not layer_map is Dictionary: return null var normalized_layers := {} var uses_upper_body := false for raw_state: Variant in layer_map: var state := str(raw_state) var layer := str(layer_map[raw_state]) if not allowed.has(state) or not ["full_body", "upper_body"].has(layer): return null normalized_layers[state] = layer uses_upper_body = uses_upper_body or layer == "upper_body" if uses_upper_body and upper_body_root_bone.is_empty(): return null result["animation_layers"] = normalized_layers for field: String in ["animation_blend_in", "animation_blend_out"]: var blend_map: Variant = value.get(field, {}) if blend_map is Array and blend_map.is_empty(): blend_map = {} if not blend_map is Dictionary: return null var normalized_blends := {} for raw_state: Variant in blend_map: var state := str(raw_state) var duration: Variant = blend_map[raw_state] if not allowed.has(state) or not _number_in(duration, 0.0, 2.0): return null normalized_blends[state] = float(duration) result[field] = normalized_blends return result static func visual_name(id: String) -> String: return Loc.entity("visuals", id, "display_name") static func employee_name(id: String) -> String: return Loc.entity("employees", id, "display_name") static func _normalize_rewards(value: Variant, valid_variables: Dictionary) -> Dictionary: if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) if modifiers == null or (modifiers as Array).is_empty() \ or str(definition.get("display_name", "")).strip_edges().is_empty() \ or not _number_in(definition.get("weight", 1.0), 0.0, 100000.0): return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "weight": float(definition.get("weight", 1.0)), "modifiers": modifiers, } return result static func _normalize_named_values(value: Variant, is_fact: bool) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var value_type := str(definition.get("type", "")) if str(definition.get("display_name", "")).strip_edges().is_empty(): return {} if is_fact: var default_value: Variant = definition.get("default_value") if not VARIABLE_TYPES.has(value_type) or not _value_matches_type(default_value, value_type): return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "type": value_type, "default_value": default_value, "category": str(definition.get("category", "general")), "description": str(definition.get("description", "")), "scope": _progress_scope(definition), } continue var rounding := str(definition.get("rounding", "round" if value_type == "int" else "none")) if not STAT_TYPES.has(value_type) or not STAT_ROUNDING.has(rounding) \ or not _number_in(definition.get("base_value", 0.0), -1000000000.0, 1000000000.0) \ or not _number_in(definition.get("base_bonus", 0.0), -1000000000.0, 1000000000.0) \ or not _number_in(definition.get("base_coefficient", 1.0), 0.0, 100.0) \ or not _number_in(definition.get("coefficient_bonus", 0.0), -100.0, 100.0) \ or not _number_in(definition.get("minimum", -1000000000.0), -1000000000.0, 1000000000.0) \ or not _number_in(definition.get("maximum", 1000000000.0), -1000000000.0, 1000000000.0) \ or float(definition.get("maximum", 1000000000.0)) < float(definition.get("minimum", -1000000000.0)): return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "type": value_type, "base_value": float(definition.get("base_value", 0.0)), "base_bonus": float(definition.get("base_bonus", 0.0)), "base_coefficient": float(definition.get("base_coefficient", 1.0)), "coefficient_bonus": float(definition.get("coefficient_bonus", 0.0)), "minimum": float(definition.get("minimum", -1000000000.0)), "maximum": float(definition.get("maximum", 1000000000.0)), "rounding": rounding, "category": str(definition.get("category", "general")), "description": str(definition.get("description", "")), } return result static func _normalize_upgrade_screen(value: Variant) -> Dictionary: var item: Dictionary = value if value is Dictionary else {} var raw_color := str(item.get("background_color", "")) var fit := str(item.get("background_fit", "cover")) return { "background_color": Color.from_string(raw_color, Color("09060e")) if Color.html_is_valid(raw_color) else Color("09060e"), "background_path": str(item.get("background_path", "")), "background_fit": fit if UPGRADE_SCREEN_FITS.has(fit) else "cover", "header_color": _screen_color(item.get("header_color", ""), Color("120d1c")), "header_opacity": clampf(float(item.get("header_opacity", 0.98)), 0.0, 1.0), "text_color": _screen_color(item.get("text_color", ""), Color("ffd166")), } static func _screen_color(raw: Variant, fallback: Color) -> Color: var text := str(raw) return Color.from_string(text, fallback) if Color.html_is_valid(text) else fallback static func _normalize_upgrade_themes(value: Variant) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var display_name := str(definition.get("display_name", "")).strip_edges() var shape := str(definition.get("shape", "circle")) var raw_states: Variant = definition.get("states", {}) if display_name.is_empty() or not UPGRADE_SHAPES.has(shape) or not raw_states is Dictionary: return {} for raw_state: Variant in raw_states: if not UPGRADE_STATES.has(str(raw_state)): return {} var states := {} for state: String in UPGRADE_STATES: var normalized_style: Variant = _normalize_upgrade_theme_style(raw_states.get(state, null)) if normalized_style == null: return {} states[state] = normalized_style # Taille et arrondi dessinent le gabarit; les etats ne font que le peindre. if not _number_in(definition.get("size_scale", 1.0), 0.4, 2.5) \ or not _number_in(definition.get("corner_scale", 0.0), 0.0, 1.0): return {} result[id] = { "display_name": display_name, "shape": shape, "size_scale": float(definition.get("size_scale", 1.0)), "corner_scale": float(definition.get("corner_scale", 0.0)), "states": states, } return result static func _normalize_upgrade_theme_style(value: Variant) -> Variant: if not value is Dictionary: return null var result := {} for field: String in ["background_color", "border_color", "text_color", "connector_color"]: if not Color.html_is_valid(str(value.get(field, ""))): return null result[field] = Color.from_string(str(value[field]), Color.WHITE) if not _number_in(value.get("border_width", 0.0), 0.0, 12.0) \ or not _number_in(value.get("connector_width", 0.0), 0.0, 12.0): return null var connector_pattern := str(value.get("connector_pattern", "solid")) if not UPGRADE_CONNECTOR_PATTERNS.has(connector_pattern): return null if not _number_in(value.get("text_size", 13.0), 6.0, 40.0): return null for flag: String in ["text_bold", "text_italic", "text_underline"]: if not value.get(flag, false) is bool: return null result[flag] = bool(value.get(flag, false)) result["border_width"] = float(value.border_width) result["connector_width"] = float(value.connector_width) result["connector_pattern"] = connector_pattern result["text_size"] = float(value.get("text_size", 13.0)) return result static func _normalize_upgrade_nodes(value: Variant, valid_themes: Dictionary, valid_variables: Dictionary, valid_facts: Dictionary, scope: Dictionary = {}) -> Dictionary: if not value is Dictionary or value.is_empty(): return {} # Un prix peut nommer un ingrédient. Le catalogue vient du scope, qui porte # les collections **déjà chargées** — pas les statiques, pas encore assignées. var valid_ingredients: Dictionary = scope.get("ingredients", {}) var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var theme := str(definition.get("theme", "")) var parent := str(definition.get("parent", "")) var raw_secondary_parents: Variant = definition.get("secondary_parents", []) if not raw_secondary_parents is Array: return {} var secondary_parents: Array[String] = [] for raw_secondary_parent: Variant in raw_secondary_parents: var secondary_parent := str(raw_secondary_parent) if secondary_parent.is_empty() or secondary_parent == id or secondary_parent == parent \ or not value.has(secondary_parent) or secondary_parents.has(secondary_parent): return {} secondary_parents.append(secondary_parent) if parent.is_empty() and not secondary_parents.is_empty(): return {} if str(definition.get("display_name", "")).strip_edges().is_empty() \ or str(definition.get("node_text", "")).strip_edges().is_empty() \ or not _number_in(definition.get("angle", 0.0), 0.0, 360.0) \ or not _number_in(definition.get("distance", 0.0), 0.0, 2000.0) \ or _normalize_costs(definition, valid_ingredients) == null \ or not definition.get("show_parent_link", true) is bool \ or not valid_themes.has(theme): return {} var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) var visibility: Variant = _normalize_upgrade_conditions(definition.get("visibility_conditions", []), valid_variables, valid_facts, value, scope) var reveal: Variant = _normalize_upgrade_conditions(definition.get("reveal_conditions", []), valid_variables, valid_facts, value, scope) var unlock: Variant = _normalize_upgrade_conditions(definition.get("unlock_conditions", []), valid_variables, valid_facts, value, scope) if modifiers == null or visibility == null or reveal == null or unlock == null: return {} result[id] = { "display_name": str(definition.display_name).strip_edges(), "node_text": str(definition.node_text).strip_edges(), "detailed_text": str(definition.get("detailed_text", "")), "description": str(definition.get("detailed_text", "")), "category": str(definition.get("category", "general")), "parent": parent, "secondary_parents": secondary_parents, "angle": fposmod(float(definition.get("angle", 0.0)), 360.0), "distance": float(definition.get("distance", 0.0)), "costs": _normalize_costs(definition, valid_ingredients), "scope": _progress_scope(definition), "modifiers": modifiers, "visibility_conditions": visibility, "reveal_conditions": reveal, "unlock_conditions": unlock, "theme": theme, "image_path": str(definition.get("image_path", "")), "show_parent_link": bool(definition.get("show_parent_link", true)), } var roots := 0 for id: String in result: var parent := str(result[id].parent) if parent.is_empty(): roots += 1 elif not result.has(parent) or parent == id: return {} if roots != 1 or _upgrade_tree_has_cycle(result): return {} return result static func _normalize_upgrade_modifiers(value: Variant, valid_variables: Dictionary) -> Variant: if not value is Array: return null var result: Array[Dictionary] = [] for modifier: Variant in value: if not modifier is Dictionary: return null var variable_id := str(modifier.get("variable", "")) var operation := str(modifier.get("operation", "add")) # Modifiers now name a layer. The old two-channel `target` still resolves # to the first two layers so older master files keep loading. var layer := ModifierPool.layer_of(modifier) if not valid_variables.has(variable_id) or not ["int", "float"].has(str(valid_variables[variable_id].get("type", ""))) or not ModifierPool.is_valid_layer(layer) or not ["add", "set", "multiply"].has(operation) or not (modifier.get("value") is int or modifier.get("value") is float): return null result.append({"variable": variable_id, "layer": layer, "operation": operation, "value": float(modifier.value)}) return result # Languages and translations. Both are optional: a master file without them # simply runs on the authoring text, which is what development looks like. static func _normalize_languages(value: Variant) -> Array[String]: var result: Array[String] = [] if value is Array: for code: Variant in value: var text_code := str(code).strip_edges().to_lower() if not text_code.is_empty() and not result.has(text_code): result.append(text_code) if result.is_empty(): result = ["fr", "en"] return result static func _normalize_localization(value: Variant, languages: Array[String]) -> Variant: if value == null: return {} if not value is Dictionary: return null var result := {} for raw_key: Variant in value: var key := str(raw_key).strip_edges() var entry: Variant = value[raw_key] if key.is_empty() or not entry is Dictionary: return null var normalized := {} for raw_code: Variant in entry: var code := str(raw_code) # Anything that is neither a declared language nor the authoring # default is a typo, and a silent typo means a missing translation. if code != Loc.DEFAULT_KEY and not languages.has(code): return null var content := str(entry[raw_code]) if not content.is_empty(): normalized[code] = content result[key] = normalized return result static func _normalize_effects_collection(value: Variant, valid_variables: Dictionary) -> Dictionary: # An absent collection falls back to the built-ins rather than failing: the # effects collection is new and older master files simply do not carry it. if value == null: return DEFAULT_EFFECTS.duplicate(true) if not value is Dictionary: return {} var result := {} for raw_id: Variant in value: var id := str(raw_id) var definition: Variant = value[raw_id] if not _valid_id(id) or not definition is Dictionary: return {} var stacking := str(definition.get("stacking", "refresh")) var kind := str(definition.get("kind", "other")) var stack_scaling := str(definition.get("stack_scaling", "none")) if str(definition.get("display_name", "")).strip_edges().is_empty() \ or not EFFECT_STACKING.has(stacking) \ or not EFFECT_KINDS.has(kind) or not EFFECT_STACK_SCALING.has(stack_scaling) \ or not _number_in(definition.get("duration", 0.0), 0.0, 100000.0) \ or not _whole_number_in(definition.get("max_stacks", 1), 1, 999) \ or not _number_in(definition.get("tick_interval", 0.0), 0.0, 10000.0) \ or not _number_in(definition.get("stack_bonus", 0.0), 0.0, 1000.0) \ or not _number_in(definition.get("movement_multiplier", 1.0), 0.0, 1.0) \ or not _number_in(definition.get("control_chance", 0.0), 0.0, 1.0) \ or not _number_in(definition.get("control_duration", 0.0), 0.0, 1000.0): return {} var modifiers: Variant = _normalize_upgrade_modifiers(definition.get("modifiers", []), valid_variables) if modifiers == null: return {} var flags: Array[String] = [] for flag: Variant in definition.get("flags", []): if not EFFECT_FLAGS.has(str(flag)): return {} flags.append(str(flag)) var control_flags: Array[String] = [] for flag: Variant in definition.get("control_flags", []): if not EFFECT_FLAGS.has(str(flag)): return {} control_flags.append(str(flag)) var actions: Array[Dictionary] = [] for action: Variant in definition.get("tick_actions", []): if not action is Dictionary or not ["damage", "heal"].has(str(action.get("op", ""))) or not _number_in(action.get("amount", 0.0), -100000.0, 100000.0): return {} actions.append({"op": str(action.op), "amount": float(action.amount), "per_stack": bool(action.get("per_stack", false))}) var color_text := str(definition.get("color", "#ffffff")) result[id] = { "display_name": str(definition.display_name).strip_edges(), "description": str(definition.get("description", "")), "kind": kind, "duration": float(definition.get("duration", 0.0)), "stacking": stacking, "max_stacks": int(definition.get("max_stacks", 1)), "stack_scaling": stack_scaling, "stack_bonus": float(definition.get("stack_bonus", 0.0)), "modifiers": modifiers, "tick_interval": float(definition.get("tick_interval", 0.0)), "tick_actions": actions, "flags": flags, "control_flags": control_flags, "movement_multiplier": float(definition.get("movement_multiplier", 1.0)), "control_chance": float(definition.get("control_chance", 0.0)), "control_duration": float(definition.get("control_duration", 0.0)), "color": Color.from_string(color_text, Color.WHITE) if Color.html_is_valid(color_text) else Color.WHITE, } return result # Le prix d'un nœud: une liste de quantités possédées à déduire. `coins` est # une ressource comme une autre — un ingrédient l'est aussi. Une liste vide veut # dire gratuit, et c'est la forme normale d'un nœud structurel. # # La forme d'avant (`cost` + `cost_resource`) se convertit à la lecture: le # champ `cost_resource` était du texte libre que le jeu ignorait, il ne dépensait # que des pièces quoi qu'on y écrive. const COST_COINS := "coins" static func _normalize_costs(definition: Dictionary, valid_ingredients: Dictionary) -> Variant: var raw: Variant = definition.get("costs") if raw == null: var legacy := int(definition.get("cost", 0)) if legacy <= 0: return [] return [{"resource": COST_COINS, "amount": legacy}] if not raw is Array: return null var result: Array = [] for entry: Variant in raw: if not entry is Dictionary: return null var line: Dictionary = entry var resource := str(line.get("resource", COST_COINS)) if resource != COST_COINS and not valid_ingredients.has(resource): return null if not _whole_number_in(line.get("amount", 0), 0, 1000000): return null var amount := int(line.amount) if amount <= 0: continue result.append({"resource": resource, "amount": amount}) return result static func _normalize_upgrade_conditions(value: Variant, valid_variables: Dictionary, valid_facts: Dictionary, valid_upgrades: Dictionary, scope: Dictionary = {}) -> Variant: if not value is Array: return null # Conditions are addressed by path only. The tool republishes older files # into this format, so the runtime no longer carries a translation layer. var address_scope := scope.duplicate() address_scope["game_facts"] = valid_facts address_scope["game_variables"] = valid_variables address_scope["upgrade_nodes"] = valid_upgrades var result: Array[Dictionary] = [] for condition: Variant in value: if not condition is Dictionary: return null var operator := str(condition.get("operator", "")) if not CONDITION_OPERATORS.has(operator): return null var path := str(condition.get("path", "")).strip_edges() if path.is_empty() or not AddressCatalog.path_error_against(path, address_scope).is_empty(): return null result.append({"path": path, "operator": operator, "value": condition.get("value", true)}) return result # Hooks hold flat declarative effects. Returns null when any effect is invalid, # which makes the whole master file fall back rather than silently misbehave. static func _normalize_effects(value: Variant, scope: Dictionary) -> Variant: if value == null: return [] if not value is Array: return null var result: Array[Dictionary] = [] for effect: Variant in value: if not effect is Dictionary: return null var op := str(effect.get("op", "add")) var path := str(effect.get("path", "")).strip_edges() if not Registry.EFFECT_OPS.has(op): return null if not AddressCatalog.is_writable_against(path, scope): return null result.append({"op": op, "path": path, "value": effect.get("value", 0)}) return result # Adds the normalized hooks of one entity to its definition, or returns false # when a hook is malformed. static func _apply_hooks(target: Dictionary, definition: Dictionary, collection: String, scope: Dictionary) -> bool: for hook: String in AddressCatalog.hooks_for(collection): var effects: Variant = _normalize_effects(definition.get(hook, []), scope) if effects == null: return false target[hook] = effects return true static func _is_empty_map(value: Variant) -> bool: # JSON encoders that build maps from associative arrays (the PHP data manager) # emit [] rather than {} when the map is empty, and an empty list carries no # other meaning in these slots. return (value is Dictionary and value.is_empty()) or (value is Array and value.is_empty()) static func _upgrade_tree_has_cycle(nodes: Dictionary) -> bool: var visited := {} for id: String in nodes: if _upgrade_prerequisite_has_cycle(id, nodes, {}, visited): return true return false static func _upgrade_prerequisite_has_cycle(id: String, nodes: Dictionary, visiting: Dictionary, visited: Dictionary) -> bool: if visiting.has(id): return true if visited.has(id): return false visiting[id] = true var definition: Dictionary = nodes.get(id, {}) var prerequisites: Array[String] = [] var parent := str(definition.get("parent", "")) if not parent.is_empty(): prerequisites.append(parent) for raw_secondary_parent: Variant in definition.get("secondary_parents", []): prerequisites.append(str(raw_secondary_parent)) for prerequisite: String in prerequisites: if nodes.has(prerequisite) and _upgrade_prerequisite_has_cycle(prerequisite, nodes, visiting, visited): return true visiting.erase(id) visited[id] = true return false static func _value_matches_type(value: Variant, value_type: String) -> bool: match value_type: "bool": return value is bool "int": return value is int or (value is float and is_equal_approx(value, int(value))) "float": return value is int or value is float "string": return value is String return false static func _normalize_amounts(value: Variant, valid_ids: Dictionary, allow_empty: bool) -> Variant: if not value is Dictionary or (value.is_empty() and not allow_empty): return null var result := {} for raw_id: Variant in value: var id := str(raw_id) if not valid_ids.has(id) or not _whole_number_in(value[raw_id], 1, 999): return null result[id] = int(value[raw_id]) return result static func _normalize_drop_list(value: Variant, valid_ids: Dictionary) -> Variant: if not value is Array: return null var result: Array[Dictionary] = [] for raw_drop: Variant in value: if not raw_drop is Dictionary: return null var id := str(raw_drop.get("ingredient_id", "")) if not valid_ids.has(id) or not _number_in(raw_drop.get("chance"), 0.0, 100.0): return null result.append({"ingredient_id": id, "chance": float(raw_drop.chance)}) return result # Portée d'une progression, avec repli sur `dimension`: la valeur par défaut est # celle qui repart à zéro, pour qu'un oubli ne rende jamais un acquis permanent. static func _progress_scope(definition: Dictionary) -> String: var scope := str(definition.get("scope", "dimension")) return scope if PROGRESS_SCOPES.has(scope) else "dimension" static func _valid_id(id: String) -> bool: if id.is_empty(): return false var first_character := id.substr(0, 1) if first_character < "a" or first_character > "z": return false for character: String in id: if not (character >= "a" and character <= "z") and not (character >= "0" and character <= "9") and character != "_": return false return true static func _number_in(value: Variant, minimum: float, maximum: float) -> bool: return (value is int or value is float) and is_finite(float(value)) and float(value) >= minimum and float(value) <= maximum static func _whole_number_in(value: Variant, minimum: int, maximum: int) -> bool: return _number_in(value, minimum, maximum) and is_equal_approx(float(value), float(int(value))) static func _stat(display_name: String, type: String, base_value: float, base_bonus: float, base_coefficient: float, coefficient_bonus: float, category: String, description: String, minimum: float = 0.0, maximum: float = 1000000000.0, rounding: String = "none") -> Dictionary: return {"display_name": display_name, "type": type, "base_value": base_value, "base_bonus": base_bonus, "base_coefficient": base_coefficient, "coefficient_bonus": coefficient_bonus, "minimum": minimum, "maximum": maximum, "rounding": rounding, "category": category, "description": description} static func _upgrade_condition(path: String, operator: String, value: Variant) -> Dictionary: return {"path": path, "operator": operator, "value": value} static func _upgrade_theme(display_name: String, shape: String, background_color: String, accent_color: String, text_color: String) -> Dictionary: var background := Color.from_string(background_color, Color("374151")) var accent := Color.from_string(accent_color, Color("94a3b8")) var text := Color.from_string(text_color, Color.WHITE) return { "display_name": display_name, "shape": shape, "size_scale": 1.0, "corner_scale": 0.0, "states": { "invisible": _upgrade_theme_style(Color("0b101a"), Color("18202d"), Color("4b5563"), 1.0, Color("111827"), 1.0, "dotted"), "mystery": _upgrade_theme_style(Color("1f2937"), Color("64748b"), Color.WHITE, 3.0, Color("475569"), 2.0, "dotted"), "locked": _upgrade_theme_style(background.darkened(0.55), accent.darkened(0.3), Color("cbd5e1"), 3.0, accent.darkened(0.45), 2.0, "dashed"), "available": _upgrade_theme_style(background, accent, text, 4.0, accent, 4.0, "solid"), "insufficient": _upgrade_theme_style(background.darkened(0.3), Color("f97316"), text, 4.0, Color("f97316"), 3.0, "dashed"), "purchased": _upgrade_theme_style(background.lightened(0.1), Color("4ade80"), text, 5.0, Color("4ade80"), 5.0, "solid"), }, } static func _upgrade_theme_style(background_color: Color, border_color: Color, text_color: Color, border_width: float, connector_color: Color, connector_width: float, connector_pattern: String) -> Dictionary: return { "background_color": background_color, "border_color": border_color, "text_color": text_color, "border_width": border_width, "connector_color": connector_color, "connector_width": connector_width, "connector_pattern": connector_pattern, "text_size": 13.0, "text_bold": false, "text_italic": false, "text_underline": false, } static func _upgrade_node(display_name: String, node_text: String, detailed_text: String, category: String, parent: String, angle: float, distance: float, cost: int, modifiers: Array, visibility_conditions: Array, reveal_conditions: Array, unlock_conditions: Array, theme: String, show_parent_link: bool = true) -> Dictionary: return { "display_name": display_name, "node_text": node_text, "detailed_text": detailed_text, "description": detailed_text, "category": category, "parent": parent, "secondary_parents": [], "angle": angle, "distance": distance, "costs": [{"resource": "coins", "amount": cost}], "modifiers": modifiers, "visibility_conditions": visibility_conditions, "reveal_conditions": reveal_conditions, "unlock_conditions": unlock_conditions, "theme": theme, "image_path": "", "show_parent_link": show_parent_link, }