extends Node const SPEECH_BUBBLE_SCRIPT := preload("res://scripts/core/speech_bubble.gd") const STORY_SCENE_SCRIPT := preload("res://scripts/core/story_scene_player.gd") var failures: Array[String] = [] var had_save := false # La sauvegarde du joueur, telle quelle. Restaurer champ par champ laissait fuir # tout ce qu'on ajoutait ensuite au jeu — les employés embauchés, par exemple. var save_file_before := "" var snapshot: Dictionary func _ready() -> void: call_deferred("_run") func _check(condition: bool, message: String) -> void: if not condition: failures.append(message) push_error("SMOKE TEST: " + message) func _run() -> void: await get_tree().process_frame _check(int(ProjectSettings.get_setting("display/window/size/viewport_width")) == 1920 and int(ProjectSettings.get_setting("display/window/size/viewport_height")) == 1080, "Logical rendering canvas is not based on 1920x1080") # GameState garde le fichier tel qu il était au démarrage. Le relire ici # serait trop tard: la salle a déjà versé ses assiettes dans le sien. save_file_before = GameState.save_file_at_boot had_save = not save_file_before.is_empty() # Verrouillé dès le départ, pas à la fin. Un test qui plante ou qui dépasse # son temps n atteint jamais sa restauration: la seule protection qui tienne # est celle qui n a rien à rattraper. GameState.save_locked = true snapshot = { "coins": GameState.coins, "ingredients": GameState.ingredients.duplicate(true), "upgrades": GameState.purchased_upgrades.duplicate(), "entity_counters": GameState.entity_counters.duplicate(true), "variable_base_bonuses": GameState.variable_base_bonuses.duplicate(true), "variable_coefficient_bonuses": GameState.variable_coefficient_bonuses.duplicate(true), "fact_values": GameState.fact_values.duplicate(true), "pending_toast": GameState.pending_toast, "restaurant_operations": GameState.restaurant_operations.duplicate(true), "restaurant_plates": GameState.restaurant_plates.duplicate(true), "menu": GameState.menu_enabled_recipes.duplicate(), "takeout_recipe": GameState.takeout_recipe, "takeout_stock": GameState.takeout_stock, "takeout_cooking_remaining": GameState.takeout_cooking_remaining, "takeout_cooking_total": GameState.takeout_cooking_total, "takeout_restock_threshold": GameState.takeout_restock_threshold, "takeout_restock_target": GameState.takeout_restock_target, "takeout_restocking": GameState.takeout_restocking, } # Plates now persist through the save file, so a leftover plate from an earlier # run would otherwise occupy this run's plate budget before it starts. GameState.restaurant_plates.clear() # La salle entière survit au changement de scène depuis la phase 9: convives # attablés, ardoise et comptoir vivent donc dans la sauvegarde eux aussi. Une # exécution interrompue laissait trois convives derrière elle, la salle # démarrait pleine, et le convive posé dans la scène était libéré avant que # le test ne le cherche — six contrôles échouaient pour cette seule raison. GameState.restaurant_customers.clear() GameState.order_board.clear() GameState.takeout_recipe = "" GameState.takeout_stock = 0 GameState.takeout_cooking_remaining = 0.0 GameState.takeout_cooking_total = 0.0 GameState.takeout_restock_threshold = 2 GameState.takeout_restock_target = 4 GameState.takeout_restocking = false # La carte du jour vit dans la sauvegarde elle aussi, et le four n'y propose # plus que ce qu'elle contient: une carte vidée par une exécution précédente # laisserait la liste du four vide et le test cuisinerait dans le vide. GameState.menu_enabled_recipes.clear() GameState.ensure_menu_defaults() # Per-entity counters persist too, so they need the same isolation. GameState.entity_counters.clear() # Les modificateurs de portée `run` vivent aussi dans la sauvegarde: une # exécution interrompue en laisse derrière, et un +1 d'attaque venu d'une # salle fausse silencieusement toutes les vérifications de statistiques. GameState.remove_modifiers_by_source_prefix("run:") # Les sources `test:` vivent dans la sauvegarde exactement pareil. Une # exécution interrompue en laisse derrière, et un `test:table_count` à -3 a # suffi à faire croire que le restaurant démarrait sans aucune table. GameState.remove_modifiers_by_source_prefix("test:") GameState.clear_run_values() # `meta` traverse tout, y compris une exécution interrompue: un # `dimension_index` avancé par un test qui a planté faisait démarrer la # plongée suivante dans une colonne que personne n'a écrite. GameState.meta_values.clear() for id: String in GameState.hired_employees.duplicate(): GameState.dismiss_employee(id) GameState.coins = 0 GameState.ingredients.clear() GameState.purchased_upgrades.clear() GameState.variable_base_bonuses.clear() GameState.variable_coefficient_bonuses.clear() GameState._load_saved_variable_channels({"version": 3, "variable_values": {"attack_damage": 4.0}, "variable_coefficients": {"attack_damage": 1.25}}) _check(is_equal_approx(float(GameState.variable_base_bonuses.attack_damage), 4.0) and is_equal_approx(float(GameState.variable_coefficient_bonuses.attack_damage), 0.25), "Legacy save variable channels were not migrated to four-channel stats") GameState.variable_base_bonuses.clear() GameState.variable_coefficient_bonuses.clear() GameState.fact_values.clear() GameState._ensure_runtime_values() for capability: String in ["automated_cooking", "automated_service", "automated_cleanup", "automated_supply"]: GameState.restaurant_operations[capability] = false GameState.restaurant_operations.pending_simulation_seconds = 0.0 # Les deux rideaux et le plan de la salle sont maintenant persistants: un # magasin laissé fermé ou une salle réaménagée par une exécution précédente # fausserait tout ce qui suit, à commencer par l'arrivée du premier client. GameState.dining_close_reason = "" GameState.takeout_close_reason = "" GameState.restaurant_layout.clear() # Le coût du nœud central est une donnée d'auteur. Le smoke finance exactement # cette première étape au lieu de supposer qu'elle restera toujours gratuite. GameState.coins = int(DataCatalog.UPGRADES.get("big_egg_core", {}).get("cost", 0)) _check(GameState.get_upgrade_state("big_egg_core") == "available", "Central upgrade node was not initially available") _check(GameState.purchase_upgrade("big_egg_core"), "Central upgrade node could not be purchased") _check(GameState.get_upgrade_state("sharp_fork") == "insufficient", "Purchased central node did not reveal its unaffordable branch") # Give the initial restaurant customer one orderable recipe so this general # smoke flow starts open. Closed-door behavior is verified later. GameState.add_ingredient("egg", 1) var restaurant_scene: PackedScene = load("res://scenes/restaurant/restaurant.tscn") var restaurant := restaurant_scene.instantiate() get_tree().root.add_child(restaurant) await get_tree().process_frame var player: Player = restaurant.get_node("Player") _check(player != null, "Restaurant player did not spawn") _check(restaurant.get_node("Customer") is Customer, "Customer did not spawn") _check(get_tree().get_nodes_in_group("restaurant_wall").size() == 5 and restaurant.has_node("RestaurantDoor"), "Restaurant did not build four visible sides with a north door opening") var movement_speed: float = maxf(1.0, player.get_current_move_speed()) var plating_point: Vector2 = restaurant.get_plating_point() var longest_service_seconds := 0.0 for table: Dictionary in restaurant.tables: for seat: Vector2 in table.seats: var route_distance: float = restaurant.cooking_station.global_position.distance_to(plating_point) + plating_point.distance_to(seat) longest_service_seconds = maxf(longest_service_seconds, route_distance / movement_speed) _check(longest_service_seconds < 5.0, "At least one restaurant kitchen-to-table route takes five seconds or more at walking speed") var dishwasher: Node2D = restaurant.get_node("DishwasherStation") var shelf: Node2D = restaurant.get_node("ShelfStation") var trash: Node2D = restaurant.get_node("TrashStation") # The kitchen is one vertical column: stove, shelf, dishwasher, trash. # La cuisine tient deux colonnes: les postes de cuisson à gauche, le service # à droite. Chacune est alignée, et elles restent à portée l'une de l'autre. var service_column: Array[Node2D] = [shelf, dishwasher, trash] for station: Node2D in service_column: _check(absf(station.global_position.x - shelf.global_position.x) < 1.0, "A kitchen appliance is not aligned in the vertical column") for index: int in range(service_column.size() - 1): var station_gap: float = service_column[index + 1].global_position.y - service_column[index].global_position.y _check(station_gap >= 100.0 and station_gap <= 260.0, "Vertical kitchen column does not keep a practical appliance-to-appliance travel distance") var column_gap: float = absf(shelf.global_position.x - restaurant.cooking_station.global_position.x) _check(column_gap >= 90.0 and column_gap <= 260.0, "Les deux colonnes de cuisine sont trop proches ou trop éloignées: %.0f" % column_gap) var kitchen_column: Array[Node2D] = [restaurant.cooking_station, shelf, dishwasher, trash] _check(restaurant.get_node_or_null("InteractionFloorMarkers") == null, "La couche des tapis d'interaction existe encore") # Les tapis ont laissé place à une bordure intérieure sur chaque face # utilisable. La bordure est **dans** le meuble, donc elle ne peut plus # passer par-dessus un personnage, et elle dit où l'on peut se placer. for appliance: Node2D in kitchen_column: var interactive_appliance := appliance as InteractiveProp _check(interactive_appliance != null and interactive_appliance.has_usable_faces(), "%s n'expose aucune face utilisable" % appliance.name) if interactive_appliance == null: continue var appliance_rect := Rect2(interactive_appliance.global_position - interactive_appliance.size * 0.5, interactive_appliance.size) for face: int in interactive_appliance.usable_face_list(): var band: Rect2 = interactive_appliance.face_band_rect(face) var band_global := Rect2(interactive_appliance.to_global(band.position), band.size) _check(appliance_rect.grow(1.0).encloses(band_global), "La bordure d'une face de %s déborde du meuble" % appliance.name) var perpendicular: float = interactive_appliance.size.y if (face == InteractiveProp.FACE_TOP or face == InteractiveProp.FACE_BOTTOM) else interactive_appliance.size.x var depth: float = band.size.y if (face == InteractiveProp.FACE_TOP or face == InteractiveProp.FACE_BOTTOM) else band.size.x _check(absf(depth - perpendicular * 0.15) < 0.6, "La bordure d'une face de %s ne fait pas 15%% de la dimension perpendiculaire" % appliance.name) _check(not interactive_appliance.get_interaction_position().is_equal_approx(interactive_appliance.global_position), "%s utilise encore le centre du meuble comme zone de travail" % appliance.name) _check(restaurant.KITCHEN_RECT.has_point(interactive_appliance.get_interaction_position()), "La zone de travail de %s sort de la cuisine" % appliance.name) # Le four s'aborde par la droite seule, le service des quatre côtés. _check(restaurant.cooking_station.usable_faces == InteractiveProp.FACE_RIGHT, "Le four n'expose pas seulement sa face droite") for four_sided: Node2D in service_column: _check((four_sided as InteractiveProp).usable_face_list().size() == 4, "%s n'expose pas ses quatre faces" % four_sided.name) # Aborder l'évier par la gauche doit fonctionner comme par la droite: la # zone de travail suit la face la plus proche au lieu d'être figée à droite. var left_of_sink: Vector2 = dishwasher.global_position - Vector2(dishwasher.size.x, 0.0) var sink_from_left: Vector2 = (dishwasher as InteractiveProp).interaction_point_for(left_of_sink) _check(sink_from_left.x < dishwasher.global_position.x, "La plonge abordée par la gauche renvoie encore sa face droite") _check(restaurant.get_plating_point().is_equal_approx(restaurant.cooking_station.global_position), "A finished meal is not plated on the stove itself") # Les postes forment leur propre colonne, déduite comme celle des tables. _check(restaurant.stations.size() == restaurant.MAX_STATIONS, "La cuisine n'a pas préparé tous ses emplacements de poste") _check(restaurant.visible_station_count() == 1, "La cuisine ne démarre pas avec un seul four") for index: int in range(1, restaurant.MAX_STATIONS): var extra_prop: InteractiveProp = restaurant.stations[index].prop _check(not extra_prop.visible, "Un poste verrouillé est visible dès le départ") _check(not extra_prop.is_in_group("interactable"), "Un poste verrouillé reste interactif") var station_step: float = extra_prop.global_position.y - restaurant.stations[index - 1].prop.global_position.y _check(station_step >= 90.0 and station_step <= 200.0, "Les postes ne sont pas espacés de manière praticable") _check(restaurant.KITCHEN_RECT.has_point(extra_prop.position), "Un poste de cuisson sort de la cuisine") # The restaurant player is drawn a quarter larger, and the appliances stay # proportionate to that scaled body rather than to a fixed pixel budget. _check(is_equal_approx(player.scale.x, 1.25) and is_equal_approx(player.scale.y, 1.25), "The restaurant player is not 25 percent bigger") var player_diameter: float = 38.0 * player.scale.x _check(restaurant.cooking_station.size.x <= player_diameter * 2.2 and dishwasher.size.x <= player_diameter * 2.2, "Restaurant appliances are out of proportion with the player") _check(restaurant.cooking_station.size.x >= player_diameter * 1.6, "The kitchen appliances were not scaled up with the restaurant") # The dining room stays clear of the kitchen column without being remote. var kitchen_to_dining: float = restaurant.DINING_RECT.position.x - restaurant.KITCHEN_RECT.end.x _check(kitchen_to_dining >= 240.0 and kitchen_to_dining <= 520.0, "The kitchen is not a comfortable walk away from the dining area") _check(restaurant.ROOM_RECT.size.x >= 1700.0 and restaurant.ROOM_RECT.size.y >= 820.0, "The restaurant floor no longer fills the logical screen") _check(is_equal_approx(restaurant.ROOM_RECT.end.y, 1070.0), "La salle ne touche plus le bas de l'écran: la hauteur doit absorber le décalage, pas la position basse") # Le trottoir doit rester lisible: il tient entre le bas du bandeau du HUD et # le mur nord, sans qu'aucun des deux ne morde sur les passants. # La bande dessinée fait foi, pas le seul centre des passants: une marge d'un # cheveu se lit à l'écran comme un chevauchement. var top_bar: Control = restaurant.hud.get_node("Root/TopBar") _check(restaurant.hud.get_node_or_null("Root/InventoryPanel") == null, "La liste d'ingrédients encombre encore le HUD") _check(restaurant.hud.get_node_or_null("Root/DishesPanel") == null, "Le grand panneau de l'assiette portée existe encore") _check(restaurant.hud.get_node_or_null("Root/MovementLabel") == null, "Le HUD affiche encore la bascule marche/course") _check(restaurant.hud.objective_label.position.x < 40.0 and restaurant.hud.objective_label.position.y > 520.0 and restaurant.hud.objective_label.get_theme_font_size("font_size") <= 12, "L'activité courante reste un gros message au centre") _check(restaurant.hud.toast_label.position.x < 40.0 and restaurant.hud.toast_label.position.y > restaurant.hud.objective_label.position.y, "Les notifications ne sont pas rangées discrètement dans le coin") var sidewalk_top: float = restaurant.SIDEWALK_Y - restaurant.SIDEWALK_BAND * 0.5 var sidewalk_bottom: float = restaurant.SIDEWALK_Y + restaurant.SIDEWALK_BAND * 0.5 _check(top_bar.get_global_rect().end.y < sidewalk_top, "Le bandeau du HUD recouvre le trottoir") var north_wall_y: float = restaurant.ROOM_RECT.position.y - restaurant.WALL_THICKNESS * 0.5 _check(sidewalk_bottom < north_wall_y, "Le mur nord mord sur le trottoir") _check(restaurant.SIDEWALK_BAND >= 40.0, "La bande du trottoir est plus étroite qu'un passant") # Le joueur doit pouvoir traverser la salle par le haut: la première rangée # de tables laisse un couloir devant le mur nord. var wall_inside_y: float = restaurant.ROOM_RECT.position.y + restaurant.WALL_THICKNESS * 0.5 var first_table_top: float = restaurant.table_center(0).y - restaurant.TABLE_RADIUS var player_width: float = player.body_radius() * player.scale.x * 2.0 _check(first_table_top - wall_inside_y >= player_width * 1.5, "Le couloir au nord de la salle est trop étroit pour circuler: %.0f px pour un joueur de %.0f" % [first_table_top - wall_inside_y, player_width]) # Et la dernière rangée doit rester dans la salle, tabouret compris. var last_index: int = restaurant.MAX_TABLES - 1 var last_seat_y: float = restaurant.table_center(last_index).y + restaurant.SEAT_OFFSET + 20.0 _check(last_seat_y < restaurant.ROOM_RECT.end.y - restaurant.WALL_THICKNESS * 0.5, "Le dernier tabouret de la grille dépasse dans le mur sud") _check(is_equal_approx(restaurant.ENTRANCE.y, restaurant.SIDEWALK_Y), "La porte n'est plus sur le trottoir") for solid_station: Node2D in kitchen_column: _check(solid_station.get_node_or_null("PropBody") != null, "A restaurant appliance has no collision body") # Une table est un tabouret: une seule place, sous la table. _check(restaurant.tables.size() == restaurant.MAX_TABLES, "La salle ne prevoit pas les %d tabourets" % restaurant.MAX_TABLES) # Une table verrouillee n'existe pas: ni dessin, ni collision. var shown: Array = restaurant.visible_table_indices() _check(shown.size() == restaurant.get_unlocked_table_count(), "Le nombre de tables visibles ne suit pas le nombre ouvert") _check(shown.size() < restaurant.MAX_TABLES, "Toutes les tables sont visibles alors que la salle demarre a deux") for locked_index: int in range(restaurant.get_unlocked_table_count(), restaurant.MAX_TABLES): _check(not shown.has(locked_index), "Une table verrouillee est dessinee: %d" % locked_index) var locked_collision: CollisionShape2D = restaurant.tables[locked_index].get("collision") _check(locked_collision == null or locked_collision.disabled, "Une table verrouillee garde sa collision: %d" % locked_index) _check(restaurant.get_unlocked_table_count() == 2, "La salle ne demarre pas avec deux tabourets") for table: Dictionary in restaurant.tables: _check(table.get("collision") != null, "A customer table has no collision shape") _check((table.seats as Array).size() == 1 and (table.customers as Array).size() == 1, "Une table offre plus d'une place") var stool: Vector2 = table.seats[0] var table_center: Vector2 = table.center _check(is_equal_approx(stool.x, table_center.x) and stool.y > table_center.y, "Le tabouret n'est pas sous sa table") _check(restaurant.ROOM_RECT.has_point(stool) and restaurant.ROOM_RECT.has_point(table_center), "Une table de la grille sort de la salle") # Les tabourets ne se chevauchent pas: la grille doit rester praticable. var spacing_floor: float = float(restaurant.TABLE_RADIUS) + float(restaurant.CHAIR_RADIUS) for first: int in range(restaurant.tables.size()): for second: int in range(first + 1, restaurant.tables.size()): var gap: float = (restaurant.tables[first].center as Vector2).distance_to(restaurant.tables[second].center) _check(gap >= spacing_floor, "Deux tables se chevauchent: %.0f px" % gap) _check(restaurant.hud.restaurant_actions.visible, "Direct restaurant action buttons are not visible") player.attack_cooldown = 0.0 player._perform_contextual_action() _check(is_equal_approx(player.attack_cooldown, 0.0), "Unified restaurant action incorrectly triggered an attack") var gameplay_pause_menu: PauseMenu = restaurant.hud.get_node("PauseMenu") var gameplay_options: OptionsMenu = gameplay_pause_menu.options_menu gameplay_options.open() gameplay_options.display_menu.open() await get_tree().process_frame _check(gameplay_options.display_menu.visible and gameplay_options.display_menu.buttons.size() == 4, "Le menu Options ne donne pas accès aux quatre modes d'affichage") _check(restaurant.hud.get_node_or_null("DisplayMenu") == null and restaurant.hud.get_node_or_null("Root/DebugPanel") == null, "L'ancien accès Display ou le panneau debug encombre encore le HUD") gameplay_options.display_menu.close() gameplay_options.close() await get_tree().process_frame GameState.ingredients.clear() GameState.add_ingredient("egg", 2) GameState.add_ingredient("slime_jelly", 1) GameState.add_ingredient("mushroom", 1) GameState.add_ingredient("beast_meat", 1) GameState.add_ingredient("ember_pepper", 1) var customer: Customer = restaurant.get_node("Customer") customer.generosity = 0.5 customer.appetite = 1 customer.cash = 100 customer.force_waiting("slime_omelette") # Le four ne propose plus que la carte du jour: le test impose donc la carte # dont il dépend au lieu de supposer celle que la sauvegarde portait. GameState.menu_enabled_recipes.clear() for menu_id: String in ["fried_egg", "slime_omelette", "mushroom_omelette"]: GameState.set_recipe_on_menu(menu_id, true) restaurant.handle_interaction("cooking") await get_tree().process_frame _check(restaurant.recipe_menu.visible, "Cooking menu did not open") _check(restaurant.recipe_menu.mode == RecipeMenu.Mode.COOK, "Le four n'ouvre pas la liste en mode cuisson") _check(restaurant.recipe_menu.row_ids.size() == GameState.menu_enabled_recipes.size(), "Le four propose autre chose que la carte du jour") for offered_id: String in restaurant.recipe_menu.row_ids: _check(GameState.is_recipe_on_menu(offered_id), "Le four propose un plat retiré de la carte: %s" % offered_id) _check(not player.controls_enabled, "Player controls remained enabled behind the cooking menu") for navigation_step: int in range(2): await _tap_action("move_down") await get_tree().process_frame # La grille est devenue une liste: chaque descente avance d'une seule ligne. _check(restaurant.recipe_menu.row_buttons[2].has_focus(), "Down input did not walk down the recipe list") restaurant.recipe_menu._focus_first_available() await get_tree().process_frame var up_event := InputEventAction.new() up_event.action = "move_up" up_event.pressed = true Input.parse_input_event(up_event) await get_tree().process_frame up_event.pressed = false Input.parse_input_event(up_event) await get_tree().process_frame _check(restaurant.recipe_menu.close_button.has_focus(), "Up from the first recipe did not wrap focus to Close") var close_event := InputEventAction.new() close_event.action = "action" close_event.pressed = true Input.parse_input_event(close_event) await get_tree().process_frame close_event.pressed = false Input.parse_input_event(close_event) await get_tree().process_frame _check(not restaurant.recipe_menu.visible, "Focused Close button did not respond to action input") _check(player.controls_enabled, "Player controls were not restored after keyboard menu close") restaurant.handle_interaction("console") await get_tree().process_frame _check(restaurant.recipe_menu.visible, "Le pupitre n'a pas ouvert la console") _check(restaurant.recipe_menu.mode == RecipeMenu.Mode.CONSOLE, "Le pupitre ouvre la liste du four au lieu de la console") # La console s'ouvre sur le magasin: c'est là qu'est l'interrupteur. _check(restaurant.recipe_menu.section == RecipeMenu.Section.SHOP, "La console ne s'ouvre pas sur le magasin") _check(restaurant.recipe_menu.tab_buttons.size() == 5, "La console n'affiche pas ses cinq onglets: %d" % restaurant.recipe_menu.tab_buttons.size()) _check(restaurant.recipe_menu.icon != null and restaurant.recipe_menu.icon.kind == RecipeMenu.Section.SHOP, "La grosse icône ne suit pas l'onglet") # Le stock quitte le HUD permanent et devient un vrai onglet de gestion. restaurant.recipe_menu._select_section(RecipeMenu.Section.INGREDIENTS) await get_tree().process_frame _check(restaurant.recipe_menu.icon != null and restaurant.recipe_menu.icon.kind == RecipeMenu.Section.INGREDIENTS, "L'icône ne suit pas l'onglet Ingrédients") _check(restaurant.recipe_menu.ingredient_amount_labels.size() == DataCatalog.INGREDIENTS.size(), "L'onglet Ingrédients ne montre pas tout le stock") for ingredient_id: String in DataCatalog.INGREDIENTS: var stock_label := restaurant.recipe_menu.ingredient_amount_labels.get(ingredient_id) as Label _check(stock_label != null and stock_label.text.contains(str(GameState.get_ingredient_amount(ingredient_id))), "Le stock de %s n'est pas lisible dans la console" % ingredient_id) # Gauche/droite ne déplace plus le focus: sur la barre d'onglets ça change # d'onglet, sur une ligne ça l'actionne. Le four, lui, n'a rien à basculer. restaurant.recipe_menu._select_section(RecipeMenu.Section.MENU) await get_tree().process_frame _check(restaurant.recipe_menu.icon != null and restaurant.recipe_menu.icon.kind == RecipeMenu.Section.MENU, "L'icône n'a pas suivi le changement d'onglet") var toggled_row: int = restaurant.recipe_menu.row_kinds.find(RecipeMenu.ROW_RECIPE) _check(toggled_row >= 0, "La console n'affiche aucune ligne de recette") var toggled_id: String = restaurant.recipe_menu.row_ids[toggled_row] var was_on_menu := GameState.is_recipe_on_menu(toggled_id) restaurant.recipe_menu.row_buttons[toggled_row].grab_focus() await get_tree().process_frame var right_event := InputEventAction.new() right_event.action = "move_right" right_event.pressed = true Input.parse_input_event(right_event) await get_tree().process_frame right_event.pressed = false Input.parse_input_event(right_event) await get_tree().process_frame _check(GameState.is_recipe_on_menu(toggled_id) != was_on_menu, "Le raccourci horizontal n'a pas basculé la présence au menu") GameState.set_recipe_on_menu(toggled_id, was_on_menu) restaurant.recipe_menu.close() await get_tree().process_frame # La face utilisable, la hit zone et le prompt contextuel parlent du même point. _check(restaurant.console_prop.usable_faces == InteractiveProp.FACE_BOTTOM, "La console n'expose pas seulement sa face basse") player.held_dish = null player.global_position = restaurant.console_prop.default_stand_position() player.refresh_target() _check(player.current_target == restaurant.console_prop, "La face basse de la console n'est pas sa vraie hit zone") _check(restaurant.console_prop.get_interaction_position().y > restaurant.console_prop.global_position.y, "La console ne se travaille pas depuis le bas") restaurant.console_prop._process(0.0) _check(restaurant.console_prop.target_highlighted, "Le meuble ciblé n'affiche aucun contour") _check(restaurant.console_prop.target_highlight_color.a > 0.0 and is_zero_approx(restaurant.console_prop.target_highlight_margin), "Le contour de cible n'est pas collé au bord du meuble") restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible, "Le prompt Action reste caché devant la console") _check(restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.console"), "Le prompt de la console n'est pas contextuel") _check(restaurant.hud.action_key_label.text == Loc.ui("ui.prompt.action_key"), "Le prompt n'affiche pas la touche Action") _check(restaurant.hud.action_key_label.text == "(SPACE/A)", "La touche Action conserve des espaces inutiles") var concise_prompt_keys: Array[String] = [ "ui.prompt.console", "ui.prompt.cook", "ui.prompt.takeout_prepare", "ui.prompt.takeout_service_boost", "ui.prompt.spices_put", "ui.prompt.wash", "ui.prompt.shelf_put", "ui.prompt.trash_put", "ui.customer.serve", "ui.plate.pick_dirty", "ui.plate.pick_up", ] for prompt_key: String in concise_prompt_keys: var concise_prompt := Loc.ui(prompt_key) _check(concise_prompt.length() <= 8 and not concise_prompt.contains("%"), "Le prompt disponible %s reste trop verbeux: %s" % [prompt_key, concise_prompt]) _check(restaurant.hud.action_prompt_panel.position.y < restaurant.hud.hud_root.size.y - 80.0, "Le prompt reste fixé au bas de l'écran au lieu de suivre sa cible") # Le clic gauche déclenche aussi `action`. Sous un bouton de menu il # appartient au bouton: sans cette priorité, cliquer Dungeon ouvrait le # donjon **et** actionnait le meuble situé derrière. var dungeon_button: Button = restaurant.hud.dungeon_button _check(dungeon_button.visible, "Le bouton Dungeon n'est pas offert dans le restaurant") var hover_event := InputEventMouseMotion.new() hover_event.position = dungeon_button.get_global_rect().get_center() hover_event.global_position = hover_event.position get_viewport().push_input(hover_event, true) await get_tree().process_frame _check(player.hovered_gui_button() == dungeon_button, "Le curseur sur Dungeon ne désigne pas le bouton") var gui_click := InputEventMouseButton.new() gui_click.button_index = MOUSE_BUTTON_LEFT gui_click.position = hover_event.position gui_click.global_position = hover_event.position gui_click.pressed = true get_viewport().push_input(gui_click, true) await get_tree().process_frame _check(player.action_came_from_click, "La demande d'action ne retient pas qu'elle vient d'un clic") _check(player._action_belongs_to_gui(), "Un clic sur le bouton Dungeon déclenche encore l'objet derrière") # On relâche ailleurs: un bouton ne se déclenche qu'au relâchement sur lui, # et le test n'a aucune envie de partir vraiment en donjon. var away_event := InputEventMouseMotion.new() away_event.position = Vector2(6, 620) away_event.global_position = away_event.position get_viewport().push_input(away_event, true) await get_tree().process_frame gui_click.pressed = false gui_click.position = away_event.position gui_click.global_position = away_event.position get_viewport().push_input(gui_click, true) await get_tree().process_frame _check(player.hovered_gui_button() == null, "Un bouton reste survolé alors que le curseur est parti") _check(not player._action_belongs_to_gui(), "Le clic garde la main alors qu'il n'y a plus de bouton dessous") # Space garde son chemin: la garde ne regarde que le clic gauche. get_viewport().push_input(hover_event, true) await get_tree().process_frame var space_action := InputEventAction.new() space_action.action = "action" space_action.pressed = true get_viewport().push_input(space_action, true) await get_tree().process_frame _check(not player.action_came_from_click and not player._action_belongs_to_gui(), "La touche Action est bloquée par un bouton survolé") space_action.pressed = false get_viewport().push_input(space_action, true) get_viewport().push_input(away_event, true) await get_tree().process_frame # Et on revient au four pour cuisiner explicitement ce que le client attend. player.global_position = restaurant.cooking_station.default_stand_position() player.refresh_target() restaurant.handle_interaction("cooking") await get_tree().process_frame var omelette_index: int = restaurant.recipe_menu.row_ids.find("slime_omelette") _check(omelette_index >= 0, "La recette attendue n'apparaît pas dans la liste du four") restaurant.recipe_menu.row_buttons[omelette_index].grab_focus() await get_tree().process_frame var confirm_event := InputEventAction.new() confirm_event.action = "action" confirm_event.pressed = true Input.parse_input_event(confirm_event) await get_tree().process_frame confirm_event.pressed = false Input.parse_input_event(confirm_event) await get_tree().process_frame _check(not restaurant.recipe_menu.visible, "Action input did not select and close the cooking menu") _check(player.controls_enabled, "Player controls were not restored after closing the cooking menu") # --- La carte du restaurant -------------------------------------------- var menu_slots := GameState.get_menu_slots() _check(menu_slots >= 1, "La statistique de places au menu est absente") _check(GameState.menu_enabled_recipes.size() <= menu_slots, "Le menu par défaut dépasse le nombre de places") _check(not GameState.menu_enabled_recipes.is_empty(), "Le menu par défaut est vide au premier lancement") # Le menu refuse une recette de plus une fois toutes les places prises. GameState.menu_enabled_recipes.clear() var offered: Array[String] = [] for recipe_id: String in DataCatalog.RECIPES: if GameState.set_recipe_on_menu(recipe_id, true): offered.append(recipe_id) _check(offered.size() == menu_slots, "Le menu a accepté plus de recettes que de places disponibles") _check(GameState.menu_enabled_recipes.size() == menu_slots, "Le nombre de recettes au menu ne correspond pas aux places") # Une place de plus grâce à l'upgrade. GameState.purchased_upgrades.append("wider_menu") _check(GameState.get_menu_slots() == menu_slots + 1, "L'upgrade de menu n'a pas ajouté de place") var extra_added := false for recipe_id: String in DataCatalog.RECIPES: if not GameState.is_recipe_on_menu(recipe_id): extra_added = GameState.set_recipe_on_menu(recipe_id, true) break _check(extra_added, "La place supplémentaire du menu n'a pas pu être utilisée") GameState.purchased_upgrades.erase("wider_menu") # Une recette retirée du menu n'est plus jamais commandée. var withdrawn: String = GameState.menu_enabled_recipes[0] GameState.set_recipe_on_menu(withdrawn, false) _check(not GameState.is_recipe_on_menu(withdrawn), "Une recette retirée est restée au menu") var menu_customer := _seat_customer(restaurant, 2, 0, "fried_egg") menu_customer.cash = 100000 for attempt: int in range(30): _check(restaurant._choose_available_recipe(menu_customer) != withdrawn, "Un client a commandé une recette retirée du menu") # Libérer sans passer par le départ normal: le filet tree_exiting doit sortir # le client des listes tout seul. Une référence morte ferait échouer # l'assignation typée des boucles clients avant leur garde is_instance_valid. var customer_count_before_free: int = restaurant.customers.size() menu_customer.queue_free() await get_tree().process_frame _check(restaurant.customers.size() == customer_count_before_free - 1, "Un client libéré est resté dans la liste du restaurant") _check(restaurant.tables[2].customers[0] == null, "Un client libéré occupe encore son siège") # Les boucles clients doivent survivre à cette libération brutale. restaurant._release_unservable_waiting_customers() restaurant.evaluate_restaurant_closure() _check(true, "Les boucles clients ont survécu à la libération d'un client") # La liste affiche durée, prix, ingrédients colorés, et grise l'infaisable. GameState.ingredients.clear() GameState.add_ingredient("egg", 1) GameState.menu_enabled_recipes.clear() # La console montre tout le catalogue: c'est là qu'on décide de la carte, on # ne peut donc pas n'y voir que ce qui y figure déjà. restaurant.recipe_menu.open(RecipeMenu.Mode.CONSOLE, RecipeMenu.Section.MENU) await get_tree().process_frame var catalogue_rows := 0 for row_kind: String in restaurant.recipe_menu.row_kinds: if row_kind == RecipeMenu.ROW_RECIPE: catalogue_rows += 1 _check(catalogue_rows == DataCatalog.RECIPES.size(), "La console n'affiche pas tout le catalogue de recettes") # L'interrupteur est sur l'onglet du magasin, pas mêlé à la carte. _check(not restaurant.recipe_menu.row_kinds.has(RecipeMenu.ROW_SWITCH), "L'interrupteur traîne dans l'onglet de la carte") restaurant.recipe_menu.open(RecipeMenu.Mode.CONSOLE, RecipeMenu.Section.SHOP) await get_tree().process_frame _check(restaurant.recipe_menu.row_kinds.has(RecipeMenu.ROW_SWITCH), "L'onglet du magasin n'affiche pas son interrupteur d'ouverture") # Le grisé se vérifie au four, où la ligne *est* le bouton de cuisson. Les # deux plats choisis y sont donc mis à la carte, l'un faisable et l'autre pas. GameState.set_recipe_on_menu("fried_egg", true) GameState.set_recipe_on_menu("pepper_scramble", true) restaurant.recipe_menu.open(RecipeMenu.Mode.COOK) await get_tree().process_frame _check(restaurant.recipe_menu.panel.size.y < 500.0 and restaurant.recipe_menu.backdrop.color.a < 0.4, "Le sélecteur du four est encore un grand menu plein écran") var fried_row: int = restaurant.recipe_menu.row_ids.find("fried_egg") var pepper_row: int = restaurant.recipe_menu.row_ids.find("pepper_scramble") _check(fried_row >= 0 and pepper_row >= 0, "Des recettes manquent dans la liste du four") _check(restaurant.recipe_menu.row_buttons[fried_row].modulate.is_equal_approx(Color.WHITE), "Une recette réalisable est grisée") _check(not restaurant.recipe_menu.row_buttons[pepper_row].modulate.is_equal_approx(Color.WHITE), "Une recette irréalisable n'est pas grisée") _check(restaurant.recipe_menu.menu_count_label.text.contains("/"), "Le compteur de places du menu n'est pas affiché") # Les quantités d'ingrédients: vert si suffisant, rouge avec le stock sinon. var ingredient_colors := _collect_ingredient_labels(restaurant.recipe_menu, pepper_row) var found_sufficient := false var found_missing := false for entry: Label in ingredient_colors: _check(entry.text.contains("/"), "Une quantité d'ingrédient n'utilise pas le format possédé/requis") var color: Color = entry.get_theme_color("font_color") if color.is_equal_approx(RecipeMenu.SUFFICIENT_COLOR): found_sufficient = true elif color.is_equal_approx(RecipeMenu.MISSING_COLOR): found_missing = true _check(found_sufficient, "Aucun ingrédient suffisant n'est affiché en vert") _check(found_missing, "Aucun ingrédient manquant n'est affiché en rouge") restaurant.recipe_menu.close() await get_tree().process_frame _check(GameState.get_ingredient_amount("egg") == 1 and GameState.get_ingredient_amount("slime_jelly") == 0, "Menu cooking did not consume the selected recipe ingredients") player.global_position = restaurant.cooking_station.default_stand_position() await get_tree().physics_frame await get_tree().physics_frame restaurant.stations[0].remaining = 0.12 restaurant.stations[0].total = 0.12 await get_tree().create_timer(0.06).timeout _check(float(restaurant.stations[0].remaining) > 0.10, "Cooking advanced without the player holding Interact at the station") var cook_event := InputEventAction.new() cook_event.action = "action" cook_event.pressed = true Input.parse_input_event(cook_event) await get_tree().create_timer(0.22).timeout cook_event.pressed = false Input.parse_input_event(cook_event) await get_tree().process_frame var physical_dish: Dish var expected_payment := 12 for child: Node in restaurant.get_children(): if child is Dish: physical_dish = child break _check(physical_dish != null, "Held interaction did not produce a physical plate") if physical_dish != null: _check(restaurant._has_food_supply(), "A cooked dish in circulation was ignored by restaurant closure logic") player.global_position = physical_dish.global_position await get_tree().physics_frame await get_tree().physics_frame restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == physical_dish.get_prompt(), "Une assiette ciblée n'affiche pas son prompt de ramassage") await _tap_action("action") _check(player.held_dish == physical_dish, "Interact did not pick up the physical plate") _check(player.carried_dish_label.visible and player.carried_dish_label.text == DataCatalog.recipe_name(physical_dish.recipe_id), "Le nom du plat porté n'apparaît pas au-dessus du joueur") _check(player.carried_dish_label.position.y < -Player.BODY_RADIUS and player.carried_dish_label.get_theme_font_size("font_size") <= 12, "Le nom du plat porté n'est pas un petit texte au-dessus de la tête") # Plates can no longer be dropped on the floor: away from every station the # plate stays in hand, and the shelf is the place to put it down. player.global_position = Vector2(760, 260) await get_tree().process_frame await _tap_action("action") _check(player.held_dish == physical_dish, "A plate was released away from every station instead of staying in hand") player.global_position = shelf.global_position await get_tree().physics_frame await _tap_action("action") _check(player.held_dish == null and physical_dish.carrier == null, "The shelf did not accept the carried plate") _check(restaurant._plates_on_shelf() == 1, "A plate stored on the shelf was not counted in a shelf slot") player.global_position = physical_dish.global_position await get_tree().physics_frame await _tap_action("action") _check(player.held_dish == physical_dish, "Interact did not pick the shelved plate back up") player.global_position = customer.global_position + Vector2(-50, 0) await get_tree().process_frame await _tap_action("action") _check(player.held_dish == null, "Serving did not remove the plate from the player's hands") _check(not player.carried_dish_label.visible, "Le nom du plat reste au-dessus du joueur après le service") var served_center: Vector2 = restaurant.tables[customer.table_index].center var expected_plate := served_center + Vector2(0, restaurant.PLATE_TABLE_OFFSET) _check(physical_dish.global_position.is_equal_approx(expected_plate), "A served plate was not placed within reach of its customer") _check(expected_plate.distance_to(customer.seat_position) < served_center.distance_to(customer.seat_position), "A served plate is further from its customer than the middle of the table") _check(GameState.coins == 0, "Customer paid before the dirty plate was cleared") customer.consumption_remaining = 0.01 await get_tree().create_timer(0.04).timeout _check(physical_dish.state == Dish.State.DIRTY, "Consumed meal did not leave a dirty physical plate") _check(int(restaurant.tables[0].dirty_count) == 1, "Dirty plate did not block its table") player.global_position = physical_dish.global_position await get_tree().physics_frame await _tap_action("action") _check(player.held_dish == physical_dish, "Dirty plate could not be picked up") expected_payment = physical_dish.pending_payout + physical_dish.pending_tip _check(GameState.coins == expected_payment, "Dirty plate pickup did not immediately collect payment") _check(not get_tree().get_nodes_in_group("currency_popup").is_empty(), "Dirty plate pickup did not create a floating money popup") _check(physical_dish.pending_tip > 0, "Fast service did not generate a generosity tip for the fancy popup") player.global_position = restaurant.get_node("DishwasherStation").global_position await get_tree().physics_frame await _tap_action("action") await get_tree().process_frame _check(GameState.coins == expected_payment, "Washing changed payment that should already have been collected") _check(GameState.purchase_upgrade("sharp_fork"), "First sale could not purchase Sharp Fork") _check(is_equal_approx(player.get_attack_damage(), float(DataCatalog.active_player_config().attack_damage) + 8.0), "Attack upgrade did not apply immediately") var coefficient_test_id := "smoke_attack_coefficient" DataCatalog.UPGRADES[coefficient_test_id] = {"display_name": "Smoke coefficient", "modifiers": [{"variable": "attack_damage", "layer": "scaling", "operation": "add", "value": 0.25}]} GameState.purchased_upgrades.append(coefficient_test_id) _check(is_equal_approx(player.get_attack_damage(), (float(DataCatalog.active_player_config().attack_damage) + 8.0) * 1.25), "Coefficient upgrade did not multiply the source stat plus base upgrades") var attack_breakdown := GameState.get_game_variable_components("attack_damage", float(DataCatalog.active_player_config().attack_damage)) _check(is_equal_approx(float(attack_breakdown.base_value), float(DataCatalog.active_player_config().attack_damage)) and is_equal_approx(float(attack_breakdown.base_bonus), 8.0), "Stat resolver did not separate base value from base bonus") _check(is_equal_approx(float(attack_breakdown.base_coefficient), 1.0) and is_equal_approx(float(attack_breakdown.coefficient_bonus), 0.25), "Stat resolver did not separate base coefficient from coefficient bonus") _check(is_equal_approx(float(attack_breakdown.final_value), (float(attack_breakdown.base_value) + float(attack_breakdown.base_bonus)) * (float(attack_breakdown.base_coefficient) + float(attack_breakdown.coefficient_bonus))), "Four-channel stat formula returned an inconsistent final value") GameState.purchased_upgrades.erase(coefficient_test_id) DataCatalog.UPGRADES.erase(coefficient_test_id) GameState.purchased_upgrades.append("hot_pan") _check(is_equal_approx(float(GameState.resolve_game_variable("cook_speed")), 0.7), "Cooking duration stat did not apply its -30 percent coefficient bonus") GameState.purchased_upgrades.append("fancy_plating") GameState.purchased_upgrades.append("boss_trophy") _check(is_equal_approx(float(GameState.resolve_game_variable("payout")), 1.68), "Payout stat did not combine flat and coefficient bonuses with the four-channel formula") GameState.purchased_upgrades.erase("boss_trophy") GameState.purchased_upgrades.erase("fancy_plating") GameState.purchased_upgrades.erase("hot_pan") _check(GameState.get_upgrade_state("sturdy_shell") == "mystery", "Combat child did not use its mystery state before reveal conditions") GameState.set_fact_value("enemies_defeated", 3) _check(GameState.get_upgrade_state("sturdy_shell") == "locked", "Revealed combat child did not remain locked until its purchase conditions") GameState.set_fact_value("enemies_defeated", 5) _check(["available", "insufficient"].has(GameState.get_upgrade_state("sturdy_shell")), "Combat child did not progress beyond its locked state") GameState.add_coins(1000) _check(GameState.purchase_upgrade("quick_feet"), "Movement branch parent could not be purchased") _check(GameState.purchase_upgrade("deep_breath"), "Stamina upgrade could not be purchased") _check(GameState.purchase_upgrade("comfy_chairs"), "Restaurant branch parent could not be purchased") GameState.set_fact_value("dishes_served", 4) _check(GameState.purchase_upgrade("second_table"), "Second customer table could not be purchased") _check(GameState.purchase_upgrade("third_table"), "Third customer table could not be purchased after the second") await get_tree().process_frame _check(restaurant.get_unlocked_table_count() == 4, "Les upgrades de table n'ont pas ouvert deux tabourets de plus que la base") _check(GameState.purchase_upgrade("second_plate") and GameState.purchase_upgrade("third_plate"), "Reusable plate upgrades could not be purchased") _check(restaurant.get_plate_capacity() == 3, "Reusable plate upgrades did not increase plate capacity") var click_upgrade_id := "smoke_mouse_purchase" var click_upgrade_definition: Dictionary = DataCatalog.UPGRADES["hot_pan"].duplicate(true) click_upgrade_definition.display_name = "Mouse Purchase" click_upgrade_definition.node_text = "CLICK" click_upgrade_definition.cost = 7 click_upgrade_definition.modifiers = [] click_upgrade_definition.direction = "NE" click_upgrade_definition.distance = 135.0 DataCatalog.UPGRADES[click_upgrade_id] = click_upgrade_definition var upgrade_tree_scene: PackedScene = load("res://scenes/progression/upgrade_tree.tscn") var upgrade_tree := upgrade_tree_scene.instantiate() get_tree().root.add_child(upgrade_tree) await get_tree().process_frame await get_tree().process_frame var tree_view: UpgradeTreeView = upgrade_tree.get_node("TreeViewport") var tree_canvas: UpgradeTreeCanvas = tree_view.get_node("TreeCanvas") var root_position: Vector2 = tree_canvas.positions["big_egg_core"] var sharp_position: Vector2 = tree_canvas.positions["sharp_fork"] var sharp_definition: Dictionary = DataCatalog.UPGRADES["sharp_fork"] var expected_offset: Vector2 = UpgradeTreeCanvas.DIRECTIONS[sharp_definition.direction] * float(sharp_definition.distance) _check((sharp_position - root_position).is_equal_approx(expected_offset), "Upgrade tree did not calculate node position from parent + cardinal direction + distance") _check(tree_canvas.node_controls.size() > 1, "Dedicated upgrade page did not render the purchased tree branches") var click_upgrade_node: UpgradeTreeNode = tree_canvas.node_controls.get(click_upgrade_id) as UpgradeTreeNode _check(click_upgrade_node != null, "Available upgrade node was not rendered") if click_upgrade_node != null: _check(click_upgrade_node.get_cost_text() == "7 COINS", "Upgrade cost was not exposed on the node") var node_click := InputEventMouseButton.new() node_click.button_index = MOUSE_BUTTON_LEFT node_click.pressed = true click_upgrade_node._gui_input(node_click) await get_tree().process_frame _check(GameState.has_upgrade(click_upgrade_id), "Mouse click on an available node did not purchase the upgrade") _check(upgrade_tree.get_node("DetailsModal").visible, "Mouse click on an upgrade node did not open the detail modal") upgrade_tree._close_modal() _check(not upgrade_tree.get_node("DetailsModal").visible, "Upgrade detail modal did not close") # The "action" input is also bound to the left mouse button and _input runs # before GUI picking, so _input must ignore mouse clicks; otherwise it # consumes them and the QUIT button can never be pressed. var stray_click := InputEventMouseButton.new() stray_click.button_index = MOUSE_BUTTON_LEFT stray_click.pressed = true upgrade_tree._input(stray_click) await get_tree().process_frame _check(not upgrade_tree.get_node("DetailsModal").visible, "Upgrade page consumed a raw mouse click in _input instead of letting the GUI route it to buttons") _check(not upgrade_tree.returning_to_restaurant, "Upgrade page mishandled a raw mouse click in _input") # Hovering must reveal the compact panel, and leaving the node must hide it. upgrade_tree._show_hover_panel(click_upgrade_id) _check(upgrade_tree.get_node("DetailsModal").visible, "Hovering an upgrade node did not reveal the details panel") var hover_card: Control = upgrade_tree.get_node("DetailsModal/Card") _check(hover_card.size.x <= 400.0 and hover_card.size.y <= 400.0, "Upgrade details panel exceeded its 400x400 budget") upgrade_tree._hide_hover_panel(click_upgrade_id) _check(not upgrade_tree.get_node("DetailsModal").visible, "Leaving an upgrade node did not hide the details panel") # --- La fenêtre sur l'arbre: se déplacer et zoomer ---------------------- # Le plan est plus grand que l'écran, et il n'y avait ni l'un ni l'autre: # une branche entière pouvait rester hors de vue, injoignable à la souris. _check(tree_canvas.get_parent() == tree_view, "Le canevas n'est pas dans la fenêtre qui le déplace") _check(tree_view.mouse_filter == Control.MOUSE_FILTER_STOP, "La fenêtre ne reçoit pas la souris") _check(tree_view.clip_contents, "La fenêtre ne rogne pas ce qui déborde") var pan_before: Vector2 = tree_view.view_offset tree_view.pan(Vector2(120.0, -80.0)) _check(tree_canvas.position.is_equal_approx(pan_before + Vector2(120.0, -80.0)), "Le déplacement n'a pas bougé le plan") # Le zoom garde sous le curseur ce qui y était: sinon il déporterait ce # qu'on est justement en train de regarder. var cursor := Vector2(700.0, 400.0) var world_before: Vector2 = (cursor - tree_view.view_offset) / tree_view.view_zoom tree_view.zoom_at(cursor, 1.25) var world_after: Vector2 = (cursor - tree_view.view_offset) / tree_view.view_zoom _check(tree_view.view_zoom > 1.0, "Le zoom n'a pas grossi le plan") _check(tree_canvas.scale.x > 1.0, "Le zoom n'est pas appliqué au plan") _check(world_before.distance_to(world_after) < 0.5, "Le zoom a déporté ce qui était sous le curseur: %.1f px" % world_before.distance_to(world_after)) # Et il est borné des deux côtés. for _zoom_step: int in range(40): tree_view.zoom_at(cursor, 2.0) _check(tree_view.view_zoom <= UpgradeTreeView.MAX_ZOOM + 0.001, "Le zoom dépasse sa borne haute: %.2f" % tree_view.view_zoom) for _zoom_step: int in range(40): tree_view.zoom_at(cursor, 0.5) _check(tree_view.view_zoom >= UpgradeTreeView.MIN_ZOOM - 0.001, "Le zoom passe sous sa borne basse: %.2f" % tree_view.view_zoom) tree_view.view_zoom = 1.0 # La molette est bien ce qui déclenche le zoom, et le glissé le déplacement. tree_view.center_on(Vector2(400.0, 300.0)) var wheel_event := InputEventMouseButton.new() wheel_event.button_index = MOUSE_BUTTON_WHEEL_UP wheel_event.pressed = true wheel_event.position = cursor tree_view._gui_input(wheel_event) _check(tree_view.view_zoom > 1.0, "La molette ne zoome pas") tree_view.view_zoom = 1.0 tree_view.center_on(Vector2(400.0, 300.0)) var press_event := InputEventMouseButton.new() press_event.button_index = MOUSE_BUTTON_LEFT press_event.pressed = true press_event.position = cursor tree_view._gui_input(press_event) var drag_before: Vector2 = tree_view.view_offset # Sous le seuil, un clic reste un clic: sinon le moindre tremblement de main # transformerait un achat en déplacement. var tremor := InputEventMouseMotion.new() tremor.position = cursor + Vector2(2.0, 0.0) tremor.relative = Vector2(2.0, 0.0) tree_view._gui_input(tremor) _check(tree_view.view_offset.is_equal_approx(drag_before), "Un tremblement de main déplace déjà la vue") var stroke := InputEventMouseMotion.new() stroke.position = cursor + Vector2(40.0, 20.0) stroke.relative = Vector2(38.0, 20.0) tree_view._gui_input(stroke) _check(not tree_view.view_offset.is_equal_approx(drag_before), "Le glissé ne déplace pas la vue") # La vue suit la navigation: c'est ce qui rend l'arbre parcourable aux mêmes # touches que le déplacement, sans cesser de désigner un noeud. tree_view.view_zoom = 1.0 tree_view.center_on(Vector2.ZERO) var far_node := Vector2(tree_view.size.x + 600.0, tree_view.size.y * 0.5) tree_view.keep_visible(far_node) var on_screen: Vector2 = far_node * tree_view.view_zoom + tree_view.view_offset _check(on_screen.x >= 0.0 and on_screen.x <= tree_view.size.x, "Un noeud hors écran n'est pas ramené dans la fenêtre: %.0f" % on_screen.x) # Mais on ne recadre pas pour rien: un noeud déjà au centre ne fait pas # sauter tout l'arbre à chaque pas. var settled: Vector2 = tree_view.view_offset tree_view.keep_visible((tree_view.size * 0.5 - tree_view.view_offset) / tree_view.view_zoom) _check(tree_view.view_offset.is_equal_approx(settled), "La vue saute alors que le noeud est déjà visible") # Et c'est bien la navigation qui entraîne la vue: tester `keep_visible` ne # prouve rien si personne ne l'appelle. On passe par le vrai gestionnaire # d'entrée, et on essaie les quatre côtés — le noeud de départ n'a pas # forcément de voisin dans une direction choisie d'avance. var followed_view := false for walk_action: String in ["move_right", "move_down", "move_left", "move_up"]: tree_view.view_zoom = 1.0 tree_view.center_on(Vector2(-2200.0, -2200.0)) var before_walk: Vector2 = tree_view.view_offset var walk_event := InputEventAction.new() walk_event.action = walk_action walk_event.pressed = true upgrade_tree._input(walk_event) if not tree_view.view_offset.is_equal_approx(before_walk): followed_view = true break _check(followed_view, "La navigation clavier n'entraîne pas la vue") upgrade_tree.queue_free() await get_tree().process_frame GameState.purchased_upgrades.erase(click_upgrade_id) DataCatalog.UPGRADES.erase(click_upgrade_id) # A plate someone is actively eating is not available to anyone else: once the # last meal is handed over, whoever is still waiting stops waiting and leaves. _reset_dining_room(restaurant) await get_tree().process_frame var last_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() last_dish.setup("fried_egg") restaurant.add_child(last_dish) await get_tree().process_frame _check(restaurant._has_food_supply(), "The one remaining plated dish was not counted as supply") var eater := _seat_customer(restaurant, 0, 0, "fried_egg") var waiter := _seat_customer(restaurant, 1, 0, "fried_egg") await get_tree().process_frame _check(waiter.state == Customer.State.WAITING, "A customer stopped waiting while an orderable plate was still on the pass") restaurant.serve_customer(eater, last_dish) _check(eater.state == Customer.State.EATING and last_dish.state == Dish.State.SERVING, "Serving the last plate did not start the meal") _check(not restaurant._has_food_supply(), "A plate being eaten was still counted as available supply") await get_tree().process_frame await get_tree().process_frame _check(waiter.state == Customer.State.LEAVING, "A waiting customer kept waiting for a plate another customer was already eating") _check(eater.state == Customer.State.EATING, "Closing the restaurant cancelled a meal that was still being eaten") _reset_dining_room(restaurant) await get_tree().process_frame # --- Servir et débarrasser sont un seul geste ---------------------------- # Un client qui enchaîne un second repas a encore son assiette sale devant # lui. Poser le plat propre doit reprendre la sale dans le même mouvement, # sinon chaque service coûte un aller-retour à vide. _reset_dining_room(restaurant) await get_tree().process_frame var swap_guest := _seat_customer(restaurant, 0, 0, "fried_egg") var swap_dirty: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(swap_dirty) swap_dirty.setup("fried_egg") swap_dirty.make_dirty() swap_dirty.table_index = 0 swap_dirty.pending_payout = 7 swap_dirty.global_position = restaurant.get_serving_plate_position(swap_guest) restaurant.tables[0].dirty_count = int(restaurant.tables[0].dirty_count) + 1 var swap_clean: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(swap_clean) swap_clean.setup("fried_egg") await get_tree().process_frame player.held_dish = null swap_clean.pick_up(player) _check(player.held_dish == swap_clean, "Le plat propre n'est pas en main avant l'échange") var coins_before_swap: int = GameState.coins restaurant.serve_customer(swap_guest, swap_clean) _check(swap_guest.state == Customer.State.EATING and swap_clean.state == Dish.State.SERVING, "Le second repas n'a pas été servi") _check(player.held_dish == swap_dirty and swap_dirty.carrier == player, "Le joueur ne repart pas avec l'assiette sale de la table") _check(swap_dirty.payment_claimed and GameState.coins > coins_before_swap, "L'échange n'a pas encaissé l'assiette sale au ramassage") _check(int(restaurant.tables[0].dirty_count) == 0 and swap_dirty.table_index < 0, "L'échange n'a pas libéré la table") var coins_after_swap: int = GameState.coins swap_dirty._on_dirty_plate_taken() _check(GameState.coins == coins_after_swap, "L'assiette échangée peut être encaissée deux fois") player.held_dish = null swap_dirty.carrier = null _reset_dining_room(restaurant) await get_tree().process_frame # Même geste pour un serveur: il termine avec la vaisselle sale en main et # poursuit vers la plonge au lieu de repartir les mains vides. var swap_server_id := "" for candidate_id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[candidate_id].get("role", "")) == "server": swap_server_id = candidate_id break _check(not swap_server_id.is_empty(), "Aucun serveur au catalogue pour l'échange de vaisselle") var swap_server := Employee.new() swap_server.add_to_group("employee") restaurant.add_child(swap_server) swap_server.setup(swap_server_id, restaurant) var swap_guest_two := _seat_customer(restaurant, 0, 0, "fried_egg") var swap_dirty_two: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(swap_dirty_two) swap_dirty_two.setup("fried_egg") swap_dirty_two.make_dirty() swap_dirty_two.table_index = 0 swap_dirty_two.global_position = restaurant.get_serving_plate_position(swap_guest_two) restaurant.tables[0].dirty_count = int(restaurant.tables[0].dirty_count) + 1 var swap_clean_two: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(swap_clean_two) swap_clean_two.setup("fried_egg") await get_tree().process_frame swap_clean_two.carry_by(swap_server) swap_server.held = swap_clean_two swap_server.target_dish = swap_clean_two swap_server.task = Employee.Task.SERVE swap_server.stage = Employee.Stage.CARRY swap_server._finish_task() _check(swap_guest_two.state == Customer.State.EATING, "Le serveur n'a pas servi le second repas") _check(swap_server.held == swap_dirty_two and swap_dirty_two.carrier == swap_server, "Le serveur ne repart pas avec l'assiette sale") _check(swap_server.task == Employee.Task.BUS and swap_server.stage == Employee.Stage.CARRY, "Le serveur n'enchaîne pas sur la plonge après l'échange") _check(swap_server.target_position.is_equal_approx(restaurant.dishwasher_position()), "Le serveur ne se dirige pas vers la plonge avec la vaisselle échangée") swap_server._clear_task() swap_dirty_two.carrier = null swap_server.queue_free() _reset_dining_room(restaurant) await get_tree().process_frame # Production can be cancelled from the control beside the stove, and the stove # itself reports the production rather than a general caption. GameState.add_ingredient("egg", 1) restaurant.start_cooking("fried_egg") _check(restaurant.station_is_busy(0), "Cooking did not start for the cancel check") _check(GameState.get_ingredient_amount("egg") == 0, "Starting a dish did not reserve its ingredients") await get_tree().process_frame var stove_status: Dictionary = restaurant.get_indexed_station_status("cooking", 0) _check(not stove_status.is_empty() and stove_status.has("progress"), "The stove does not report its own production status") # Le poste rend le statut du sien, pas celui d'un voisin. _check(restaurant.get_indexed_station_status("cooking", 1).is_empty(), "Un poste libre rapporte la production d'un autre") _check(restaurant.cancel_button.visible, "The cancel control is hidden while a dish is cooking") _check(restaurant.cancel_button.global_position.x < restaurant.cooking_station.global_position.x and restaurant.cancel_button.global_position.y < restaurant.cooking_station.global_position.y, "The cancel control is not placed at the top-left corner of the stove") _check(CancelButton.RADIUS <= 14.0, "The cancel control is not a small marker") _check(restaurant.cancel_button.contains_point(restaurant.cancel_button.global_position), "The cancel control does not hit-test a click on itself") _check(not restaurant.cancel_button.contains_point(restaurant.cancel_button.global_position + Vector2(400, 0)), "The cancel control swallows clicks far away from it") _check(restaurant.cancel_cooking(), "Cooking could not be cancelled") _check(not restaurant.station_is_busy(0) and str(restaurant.stations[0].recipe).is_empty(), "Cancelling did not stop the production") _check(GameState.get_ingredient_amount("egg") == 1, "Cancelling a dish did not return its reserved ingredients") _check(restaurant.get_indexed_station_status("cooking", 0).is_empty(), "The stove still reports production after a cancel") # Le four autonome est une vitesse, pas un drapeau: à 0 il exige le joueur. GameState.purchased_upgrades.erase("auto_stove") _check(is_zero_approx(float(GameState.resolve_game_variable("cook_idle_rate"))), "Le four avance seul sans l'upgrade correspondante") _check(is_zero_approx(restaurant.get_cooking_rate(0)), "Le four progresse sans joueur et sans upgrade") GameState.add_ingredient("egg", 1) restaurant.start_cooking("fried_egg") var idle_remaining: float = float(restaurant.stations[0].remaining) restaurant._process(0.5) _check(is_equal_approx(float(restaurant.stations[0].remaining), idle_remaining), "Le four a progressé sans joueur alors que le taux est nul") # L'upgrade monte le taux: le four travaille seul, mais plus lentement. GameState.purchased_upgrades.append("auto_stove") _check(is_equal_approx(float(GameState.resolve_game_variable("cook_idle_rate")), 0.4), "L'upgrade du four autonome n'a pas relevé le taux inoccupé") _check(is_equal_approx(restaurant.get_cooking_rate(0), 0.4), "Le four autonome ne tourne pas au taux accordé") restaurant._process(1.0) _check(is_equal_approx(float(restaurant.stations[0].remaining), idle_remaining - 0.4), "Le four autonome n'a pas avancé au taux attendu") _check(str(restaurant.get_indexed_station_status("cooking", 0).lines[0]).begins_with("AUTO"), "Le four n'affiche pas son mode autonome") GameState.purchased_upgrades.erase("auto_stove") restaurant.cancel_cooking() # Une fois la cuisson finie, le four doit revenir a son libelle d'origine. _check(restaurant.get_indexed_station_status("cooking", 0).is_empty(), "Le four annonce encore une production a l'arret") restaurant.cooking_station._process(0.016) _check(not restaurant.cooking_station._had_status, "Le four n'a pas repris son affichage d'origine apres la cuisson") # --- Deux fours cuisent en parallèle ----------------------------------- # C'est tout l'intérêt du poste supplémentaire: sans ça, un four de plus ne # serait qu'un meuble. _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:stoves") _check(restaurant.visible_station_count() == 1, "La cuisine ne démarre pas avec un seul four") _check(restaurant.free_station("stove") == 0, "Le premier four n'est pas proposé comme libre") for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() # Assez d'assiettes pour que la vaisselle ne soit pas le facteur limitant. GameState.add_modifier("test:stoves", "plates", "flat", 8.0) restaurant.start_cooking("fried_egg") _check(restaurant.station_is_busy(0), "Le premier four n'a pas démarré") _check(restaurant.free_station("stove") < 0, "Un four occupé est encore proposé comme libre") # Avec un seul four ouvert, la deuxième commande n'a nulle part où aller. restaurant.start_cooking("fried_egg") _check(restaurant.busy_station_count() == 1, "Une cuisson a démarré sans poste libre") # L'upgrade ouvre le second: les deux avancent ensemble. GameState.add_modifier("test:stoves", "stove_count", "flat", 1.0) restaurant._refresh_stations() _check(restaurant.visible_station_count() == 2, "L'upgrade n'a pas ouvert le second four") _check(restaurant.stations[1].prop.visible, "Le second four ouvert reste invisible") _check(restaurant.free_station("stove") == 1, "Le second four n'est pas proposé comme libre") restaurant.start_cooking("slime_omelette") _check(restaurant.busy_station_count() == 2, "Les deux fours ne cuisent pas en même temps") var first_before: float = restaurant.stations[0].remaining var second_before: float = restaurant.stations[1].remaining # Le joueur tient le premier: lui seul accélère, l'autre suit son taux. player.global_position = (restaurant.stations[0].prop as InteractiveProp).default_stand_position() GameState.add_modifier("test:stoves", "cook_idle_rate", "flat", 0.5) restaurant._process(0.5) var first_step: float = first_before - float(restaurant.stations[0].remaining) var second_step: float = second_before - float(restaurant.stations[1].remaining) _check(second_step > 0.0, "Le four sans joueur n'avance pas à son taux inoccupé") _check(first_step >= second_step, "Le four tenu par le joueur n'avance pas au moins aussi vite que l'autre") # Les deux réservent chacun leur assiette. _check(restaurant._plates_in_use() >= 2, "Deux cuissons ne réservent pas deux assiettes") # Annuler un poste laisse l'autre tourner. _check(restaurant.cancel_cooking(0), "Le premier four n'a pas pu être annulé") _check(not restaurant.station_is_busy(0) and restaurant.station_is_busy(1), "Annuler un poste a arrêté l'autre") restaurant.cancel_cooking(1) # Refermer le second poste le retire de la salle. GameState.remove_modifiers_by_source("test:stoves") restaurant._refresh_stations() _check(not restaurant.stations[1].prop.visible, "Un four refermé reste accroché dans la cuisine") _check(not restaurant.stations[1].prop.is_in_group("interactable"), "Un four refermé reste interactif") # Une upgrade du catalogue ouvre bien un second four, sans nom codé en dur. var stove_opener := "" for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): if str(modifier.get("variable", "")) == "stove_count": stove_opener = node_id _check(not stove_opener.is_empty(), "Aucune upgrade du catalogue n'ouvre un second four") _reset_dining_room(restaurant) await get_tree().process_frame # --- La console de gestion --------------------------------------------- # Elle s'installe par upgrade: sans elle, le menu se règle au fourneau et la # fermeture reste subie. _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:console") # La console fait partie du restaurant: elle ne s'achète plus. C'est elle qui # porte la carte du jour, dont dépend maintenant tout ce qu'on peut cuisiner. _check(int(DataCatalog.GAME_VARIABLES.management_console.base_value) > 0, "La console n'est pas livrée avec le restaurant") _check(restaurant.console_is_installed(), "La console n'est pas là au premier jour") _check(restaurant.console_prop.visible, "La console livrée reste invisible") _check(restaurant.console_prop.is_in_group("interactable"), "La console livrée n'est pas interactive") var console_status: Dictionary = restaurant.get_station_status("console") _check(console_status.get("lines", []).size() == 3, "La console n'affiche pas son tableau de bord") # Aucune upgrade ne la vend plus: un noeud qui accorde ce qu'on possède déjà # est un piège, pas une récompense. for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): _check(str(modifier.get("variable", "")) != "management_console", "Une upgrade vend encore la console: %s" % node_id) # Retirée par la donnée, elle disparaît quand même: la statistique reste le # seul juge, et un auteur peut toujours écrire un restaurant sans pupitre. GameState.add_modifier("test:console", "management_console", "flat", -1.0) restaurant._refresh_console() _check(not restaurant.console_is_installed(), "La console reste installée malgré une statistique à zéro") _check(not restaurant.console_prop.visible, "Une console retirée s'affiche encore") _check(not restaurant.console_prop.is_in_group("interactable"), "Une console retirée reste interactive") _check(restaurant.get_station_status("console").is_empty(), "Une console retirée rapporte un statut") _check(not restaurant.toggle_open_closed(), "L'interrupteur répond sans console") GameState.remove_modifiers_by_source("test:console") restaurant._refresh_console() _check(restaurant.console_is_installed(), "La console n'est pas revenue une fois la statistique rendue") # Fermer volontairement ferme vraiment, même avec de quoi cuisiner. for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() _reopen_shop(restaurant) restaurant.evaluate_restaurant_closure() _check(not restaurant.dining_is_closed(), "Le magasin est fermé alors qu'il a de quoi servir") _check(restaurant.toggle_open_closed(), "L'interrupteur n'a pas répondu") _check(restaurant.dining_is_closed(), "Une fermeture volontaire ne ferme pas le magasin") _check(GameState.dining_close_reason == GameState.CLOSE_REASON_MANUAL, "Une fermeture volontaire n'est pas nommée comme telle") _check(not restaurant._can_accept_arrivals(), "Un magasin fermé à la main accepte encore des arrivées") # Rideau baissé, la rue continue de vivre: des clients potentiels passent # encore devant la vitrine, ils ne font que renoncer à entrer. var passersby_before: int = restaurant.customers.size() restaurant._spawn_passerby() var street_still_alive: bool = restaurant.customers.size() > passersby_before _check(street_still_alive, "Le trottoir s'arrête quand le magasin ferme") # Garde obligatoire: sans passant, lire `customers.back()` couperait le test # au lieu de le faire échouer — et un test qui bloque ne rapporte rien. if street_still_alive: _check(not bool(restaurant.evaluate_queue_join(restaurant.customers.back()).join), "Un passant entre encore dans un magasin fermé") for stray: Customer in restaurant.customers.duplicate(): if is_instance_valid(stray): stray.queue_free() restaurant.customers.clear() # Et rouvrir rouvre — mais seulement à la main. restaurant.evaluate_restaurant_closure() _check(restaurant.dining_is_closed(), "Une évaluation a rouvert le magasin toute seule") _check(restaurant.toggle_open_closed(), "L'interrupteur n'a pas répondu au retour") _check(not restaurant.dining_is_closed(), "L'interrupteur n'a pas rouvert le magasin") # Démonter la console rouvre: elle ne peut pas laisser le rideau baissé. restaurant.toggle_open_closed() _check(restaurant.dining_is_closed(), "Le test n'a pas réussi à refermer le magasin") GameState.add_modifier("test:console", "management_console", "flat", -1.0) restaurant._refresh_console() _check(not restaurant.dining_is_closed(), "Une console démontée a laissé le magasin fermé") GameState.remove_modifiers_by_source("test:console") restaurant._refresh_console() # --- Le panneau de la console: l'interrupteur et la brigade ------------- # L'interrupteur existait sans qu'aucun bouton ne l'atteigne, et l'embauche # n'avait aucune interface du tout: une upgrade de serveur ouvrait un poste # que personne ne pouvait pourvoir. GameState.remove_modifiers_by_source("test:hire") restaurant.handle_interaction("console") await get_tree().process_frame var switch_row: int = restaurant.recipe_menu.row_kinds.find(RecipeMenu.ROW_SWITCH) # Le voyant suit l'état du magasin: c'est ce qui le dit d'un coup d'oeil. _check(restaurant.recipe_menu.icon != null and restaurant.recipe_menu.icon.lit == not restaurant.dining_is_closed(), "Le voyant ne suit pas l'état du magasin") _check(switch_row >= 0, "Le panneau de la console ne porte pas d'interrupteur") var was_closed: bool = restaurant.dining_is_closed() restaurant.recipe_menu.row_buttons[switch_row].pressed.emit() await get_tree().process_frame _check(restaurant.dining_is_closed() != was_closed, "Le bouton de la console n'actionne pas l'interrupteur") _check(restaurant.recipe_menu.shop_closed == restaurant.dining_is_closed(), "Le panneau n'a pas suivi l'état du magasin") restaurant.recipe_menu.row_buttons[restaurant.recipe_menu.row_kinds.find(RecipeMenu.ROW_SWITCH)].pressed.emit() await get_tree().process_frame _check(restaurant.dining_is_closed() == was_closed, "L'interrupteur du panneau ne revient pas en arrière") # Le voyant s'éteint avec le magasin, et se rallume avec lui. restaurant._on_console_switch() _check(restaurant.recipe_menu.icon.lit == not restaurant.dining_is_closed(), "Le voyant n'a pas suivi la bascule") restaurant._on_console_switch() _check(restaurant.recipe_menu.icon.lit == not restaurant.dining_is_closed(), "Le voyant n'est pas revenu avec le magasin") # La brigade sépare les deux métiers dans des sous-onglets. restaurant.recipe_menu._select_section(RecipeMenu.Section.BRIGADE) await get_tree().process_frame var listed_staff := 0 for row_kind: String in restaurant.recipe_menu.row_kinds: if row_kind == RecipeMenu.ROW_EMPLOYEE: listed_staff += 1 var server_count := 0 var cook_count := 0 for staff_id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[staff_id].get("role", "")) == "server": server_count += 1 elif str(DataCatalog.EMPLOYEES[staff_id].get("role", "")) == "cook": cook_count += 1 _check(restaurant.recipe_menu.brigade_role_filter == "server" and listed_staff == server_count, "Le sous-onglet Serveurs mélange les métiers") _check(restaurant.recipe_menu.row_kinds.count(RecipeMenu.ROW_ROLE) == 2, "La brigade n'affiche pas les deux sous-onglets de métier") var preference_bars: Array[Node] = restaurant.recipe_menu.list.find_children("*", "ProgressBar", true, false) _check(preference_bars.size() == server_count * DataCatalog.SERVER_TASK_PREFERENCES.size(), "Les quatre préférences ne sont pas affichées visuellement sur chaque serveur à l'embauche") for preference_bar: ProgressBar in preference_bars: _check(is_equal_approx(preference_bar.max_value, 5.0), "Une préférence de serveur n'emploie pas l'échelle commune sur cinq") restaurant.recipe_menu._select_brigade_role("cook") await get_tree().process_frame listed_staff = restaurant.recipe_menu.row_kinds.count(RecipeMenu.ROW_EMPLOYEE) _check(restaurant.recipe_menu.brigade_role_filter == "cook" and listed_staff == cook_count, "Le sous-onglet Cuisiniers mélange les métiers") _check(restaurant.recipe_menu.list.find_children("*", "ProgressBar", true, false).is_empty(), "Les préférences de salle encombrent les fiches de cuisiniers") restaurant.recipe_menu._select_brigade_role("server") await get_tree().process_frame # Sans poste ouvert, presser refuse — et la salle reste vide de personnel. var recruit := "" for staff_id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[staff_id].get("role", "")) == "server": recruit = staff_id break _check(not recruit.is_empty(), "Le catalogue ne contient aucun serveur") var recruit_row: int = restaurant.recipe_menu.row_ids.find(recruit) GameState.coins = 100000 restaurant.recipe_menu.row_buttons[recruit_row].pressed.emit() await get_tree().process_frame _check(not GameState.employee_is_hired(recruit), "Un employé a été embauché sans poste ouvert") # Un poste ouvert, et le bouton embauche vraiment: l'employé entre en salle. GameState.add_modifier("test:hire", "server_count", "flat", 1.0) restaurant.recipe_menu.build_list() await get_tree().process_frame recruit_row = restaurant.recipe_menu.row_ids.find(recruit) restaurant.recipe_menu.row_buttons[recruit_row].pressed.emit() await get_tree().process_frame _check(GameState.employee_is_hired(recruit), "Le bouton d'embauche de la console n'embauche personne") _check(restaurant._employee_node(recruit) != null, "Un employé embauché n'apparaît pas dans la salle") # La même ligne renvoie: c'est le seul moyen de rendre une place. recruit_row = restaurant.recipe_menu.row_ids.find(recruit) restaurant.recipe_menu.row_buttons[recruit_row].pressed.emit() await get_tree().process_frame _check(not GameState.employee_is_hired(recruit), "Le bouton de renvoi de la console ne renvoie personne") _check(restaurant._employee_node(recruit) == null, "Un employé renvoyé reste dans la salle") # Une fiche fermée par ses conditions ne s'embauche pas: le champ existe dans # l'outil, il doit vouloir dire quelque chose. DataCatalog.EMPLOYEES[recruit]["conditions"] = [{"path": "fact.smoke_test_never", "operator": "truthy", "value": true}] _check(not GameState.hire_employee(recruit), "Une fiche fermée par ses conditions a quand même été embauchée") DataCatalog.EMPLOYEES[recruit]["conditions"] = [] _check(GameState.hire_employee(recruit), "Une fiche rouverte refuse encore l'embauche") GameState.dismiss_employee(recruit) GameState.remove_modifiers_by_source("test:hire") restaurant.recipe_menu.close() await get_tree().process_frame restaurant.evaluate_restaurant_closure() _reset_dining_room(restaurant) await get_tree().process_frame # --- L'étagère: des emplacements, et une cible sans équivoque ----------- _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:shelf") await get_tree().process_frame _check(restaurant.get_shelf_capacity() == 3, "L'étagère ne démarre pas avec trois emplacements") # La place sur l'étagère ne se confond plus avec le nombre d'assiettes. GameState.add_modifier("test:shelf", "plates", "flat", 6.0) _check(restaurant.get_shelf_capacity() == 3, "La capacité de l'étagère suit encore le nombre d'assiettes") GameState.add_modifier("test:shelf", "shelf_slots", "flat", 3.0) _check(restaurant.get_shelf_capacity() == 6, "L'upgrade n'a pas agrandi l'étagère") var slot_zero: Vector2 = restaurant._shelf_slot_position(0) var slot_one: Vector2 = restaurant._shelf_slot_position(1) _check(not slot_zero.is_equal_approx(slot_one), "Deux emplacements d'étagère se superposent") # L'étagère est dressée verticalement devant la colonne des fours: ses # emplacements s'empilent, ils ne s'alignent plus côte à côte. _check(absf(slot_zero.x - slot_one.x) < 1.0, "Les emplacements d'étagère ne sont pas dans la même colonne") _check(absf(slot_zero.y - slot_one.y) > 1.0, "Les emplacements d'étagère ne s'empilent pas") # Elle couvre la hauteur des deux premiers fours, et tous ses emplacements # tiennent sur le meuble au lieu de déborder dans la cuisine. var stove_top: float = restaurant.station_center(0).y var stove_bottom: float = restaurant.station_center(restaurant.SHELF_STOVE_SPAN - 1).y var shelf_top: float = restaurant.shelf_station.position.y - restaurant.shelf_station.size.y * 0.5 var shelf_bottom: float = restaurant.shelf_station.position.y + restaurant.shelf_station.size.y * 0.5 _check(shelf_top <= stove_top and shelf_bottom >= stove_bottom, "L'étagère ne couvre pas la hauteur des deux fours: %.0f..%.0f pour des fours en %.0f..%.0f" % [shelf_top, shelf_bottom, stove_top, stove_bottom]) _check(restaurant.shelf_station.size.y > restaurant.shelf_station.size.x, "L'étagère n'est pas dressée verticalement") for shelf_index: int in range(restaurant.get_shelf_capacity()): var slot_y: float = restaurant._shelf_slot_position(shelf_index).y - restaurant.global_position.y _check(slot_y >= shelf_top and slot_y <= shelf_bottom, "Un emplacement d'étagère déborde du meuble: %.0f hors de %.0f..%.0f" % [slot_y, shelf_top, shelf_bottom]) # La collision suit la nouvelle taille, sinon le corps mentirait sur le dessin. var shelf_shape: CollisionShape2D = null if restaurant.shelf_station.body != null: for shelf_child: Node in restaurant.shelf_station.body.get_children(): if shelf_child is CollisionShape2D: shelf_shape = shelf_child _check(shelf_shape != null and (shelf_shape.shape as RectangleShape2D).size.is_equal_approx(restaurant.shelf_station.size), "La collision de l'étagère n'a pas suivi sa taille") # Et le cuisinier attend entre son four et l'étagère: le va-et-vient tient # sur un axe, il ne traverse plus la cuisine. _check(restaurant.COOK_REST_OFFSET.x > 0.0, "Le cuisinier n'attend pas à droite de son four") GameState.remove_modifiers_by_source("test:shelf") GameState.add_modifier("test:shelf", "plates", "flat", 6.0) # Trois assiettes tiennent, la quatrième est refusée par la place, pas par # le nombre d'assiettes possédées. var stored_dishes: Array[Dish] = [] for index: int in range(3): var stored: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(stored) stored.setup("fried_egg") stored.place_on(restaurant._shelf_slot_position(index)) stored_dishes.append(stored) await get_tree().process_frame _check(restaurant._plates_on_shelf() == 3, "L'étagère ne compte pas ses assiettes posées: %d" % restaurant._plates_on_shelf()) _check(restaurant._free_shelf_slot() < 0, "Une étagère pleine annonce encore un emplacement libre") # Les emplacements sont **montrés** et **exacts**: le meuble publie ses cases, # et chaque assiette se tient au pixel près sur la sienne. var shelf_marks: Array = restaurant.get_station_slot_marks("shelf", -1) _check(shelf_marks.size() == restaurant.get_shelf_capacity(), "L'étagère ne montre pas une planche par emplacement") for mark_index: int in range(shelf_marks.size()): _check((shelf_marks[mark_index] as Vector2).is_equal_approx(restaurant._shelf_slot_position(mark_index)), "Une planche montrée ne tombe pas sur son emplacement") for stored_index: int in range(stored_dishes.size()): _check(stored_dishes[stored_index].global_position.is_equal_approx(restaurant._shelf_slot_position(stored_index)), "Une assiette rangée n'est pas à la position exacte de son emplacement") # Deux assiettes voisines sont à portée égale: c'est le curseur qui tranche, # et la manette garde le départage par proximité. var between_plates: Vector2 = (stored_dishes[0].global_position + stored_dishes[1].global_position) * 0.5 player.held_dish = null player.global_position = between_plates player.mouse_aim_enabled = true _check(player._find_interactable(stored_dishes[0].global_position) == stored_dishes[0], "Pointer l'assiette du haut ne la sélectionne pas") _check(player._find_interactable(stored_dishes[1].global_position) == stored_dishes[1], "Pointer l'assiette du milieu ne la sélectionne pas") player.mouse_aim_enabled = false var controller_plate: Node2D = player._find_interactable(Vector2(-9999.0, -9999.0)) _check(controller_plate == stored_dishes[0] or controller_plate == stored_dishes[1], "La manette perd le choix de l'assiette par proximité") player.mouse_aim_enabled = true # Agrandir l'étagère redécoupe ses cases: les assiettes suivent au lieu de # flotter entre deux rangs, sinon le compte et la visée divergeraient. GameState.add_modifier("test:shelf_resize", "shelf_slots", "flat", 2.0) restaurant._restack_shelf() _check(restaurant._plates_on_shelf() == 3, "Le compte de l'étagère a changé en la redécoupant") for resized_index: int in range(stored_dishes.size()): _check(stored_dishes[resized_index].global_position.is_equal_approx(restaurant._shelf_slot_position(resized_index)), "Une assiette n'a pas suivi la nouvelle découpe de l'étagère") GameState.remove_modifiers_by_source("test:shelf_resize") restaurant._restack_shelf() # Le joueur ne ramasse jamais qu'une assiette, et il sait laquelle. player.held_dish = null player.global_position = stored_dishes[0].global_position player.refresh_target() _check(player.current_target == stored_dishes[0], "Le joueur ne vise pas l'assiette la plus proche") stored_dishes[0]._process(0.016) _check(stored_dishes[0].targeted, "L'assiette visée ne se signale pas") _check(not stored_dishes[2].targeted or stored_dishes[2] == stored_dishes[0], "Une assiette non visée se signale quand même") player.global_position = stored_dishes[2].global_position player.refresh_target() _check(player.current_target == stored_dishes[2], "La cible ne suit pas le joueur d'un emplacement à l'autre") stored_dishes[0]._process(0.016) _check(not stored_dishes[0].targeted, "L'ancienne cible reste signalée") # Une assiette en main coupe la visée: on ne prend pas deux plats. Debout # devant l'étagère, ce qu'on vise est le meuble où la ranger, jamais un plat. player.held_dish = stored_dishes[1] player.refresh_target() _check(not (player.current_target is Dish), "Le joueur vise encore une assiette alors qu'il en porte une") _check(player.current_target != null and player.current_target.is_in_group("dish_receiver"), "Une assiette en main ne vise aucun meuble où la poser") player.held_dish = null for leftover: Dish in stored_dishes: if is_instance_valid(leftover): leftover.free() GameState.remove_modifiers_by_source("test:shelf") await get_tree().process_frame # --- L'épicerie: une étape de plus, un plat supérieur garanti ----------- _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:spices") for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() await get_tree().process_frame _check(restaurant.unlocked_spice_stations() == 0, "L'épicerie est installée dès le départ au lieu d'être une upgrade") _check(restaurant.free_station("spices") < 0, "Un poste d'épicerie est proposé sans avoir été acheté") _check(restaurant.get_superior_payout() > 1.0, "Le coefficient du plat supérieur ne rapporte rien") # Installée, elle prend le rang suivant sans déplacer les fours. GameState.add_modifier("test:spices", "spice_station_count", "flat", 1.0) restaurant._refresh_stations() _check(restaurant.station_kind(0) == "stove", "L'épicerie a pris la place du four") var spice_index: int = restaurant.free_station("spices") _check(spice_index == 1, "Le poste d'épicerie n'a pas pris le rang suivant: %d" % spice_index) _check(restaurant.stations[spice_index].prop.visible, "Le poste d'épicerie ouvert reste invisible") # Un plat cuit y entre, un plat supérieur en sort. var plain_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(plain_dish) plain_dish.setup("fried_egg") await get_tree().process_frame player.held_dish = plain_dish plain_dish.carrier = player _check(not plain_dish.superior, "Un plat sort du four déjà raffiné") restaurant.handle_station_interaction("cooking", spice_index) _check(restaurant.station_is_busy(spice_index), "Le poste d'épicerie n'a pas démarré") _check(bool(restaurant.stations[spice_index].refining), "Le poste d'épicerie ne se déclare pas en raffinage") _check(player.held_dish == null, "L'assiette déposée est restée dans les mains du joueur") # Le raffinage aboutit, et le plat rendu porte le drapeau. # L'assiette déposée n'est retirée du groupe qu'à la fin de la frame: la # compter avant fausserait le solde. await get_tree().process_frame var dishes_before: int = get_tree().get_nodes_in_group("dish").size() restaurant.stations[spice_index].remaining = 0.01 # En headless le joueur ne tient aucune touche: sans taux inoccupé, le poste # n'avancerait jamais et le test vérifierait le vide. GameState.add_modifier("test:spices", "cook_idle_rate", "flat", 1.0) restaurant._process(1.0) await get_tree().process_frame var refined: Dish = null for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and (node as Dish).superior: refined = node _check(refined != null, "Le raffinage n'a pas rendu de plat supérieur") _check(get_tree().get_nodes_in_group("dish").size() == dishes_before + 1, "Le raffinage n'a pas rendu exactement une assiette") # Le plat supérieur se vend au coefficient, pas au prix de base. if refined != null: var base_price: int = int(DataCatalog.RECIPES[refined.recipe_id].sale_price) var rich_guest := _seat_customer(restaurant, 0, 0, refined.recipe_id) rich_guest.cash = 100000 var coins_before: int = GameState.coins restaurant.serve_customer(rich_guest, refined) await get_tree().process_frame _check(rich_guest.state == Customer.State.EATING, "Le client n'a pas accepté le plat supérieur") var paid: int = 100000 - rich_guest.cash _check(paid > base_price, "Un plat supérieur s'est vendu au prix de base: %d pour %d" % [paid, base_price]) _check(paid == roundi(float(base_price) * restaurant.get_superior_payout()), "Le prix du plat supérieur ne suit pas son coefficient: %d" % paid) # L'upgrade du coefficient change bien la vente. var before_coefficient: float = restaurant.get_superior_payout() GameState.add_modifier("test:spices", "superior_payout", "flat", 0.5) _check(restaurant.get_superior_payout() > before_coefficient, "L'upgrade n'a pas relevé le coefficient du plat supérieur") # Jeter le repas d'une assiette supérieure la rend ordinaire. var refined_trash: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(refined_trash) refined_trash.setup("fried_egg") refined_trash.superior = true refined_trash.discard_meal() _check(not refined_trash.superior, "Une assiette jetée garde son raffinage") refined_trash.free() # Une upgrade du catalogue installe bien l'épicerie. var spice_opener := "" for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): if str(modifier.get("variable", "")) == "spice_station_count": spice_opener = node_id _check(not spice_opener.is_empty(), "Aucune upgrade du catalogue n'installe l'épicerie") GameState.remove_modifiers_by_source("test:spices") restaurant._refresh_stations() _reset_dining_room(restaurant) await get_tree().process_frame # --- Comptoir takeout: production, file et service automatique ------------ _reset_dining_room(restaurant) await get_tree().process_frame _check(not GameState.takeout_is_open(), "Le comptoir takeout est ouvert avant son upgrade") _check(not restaurant.takeout_prop.visible, "Le comptoir takeout fermé reste visible") _check(not restaurant.takeout_prepare_spot.visible and not restaurant.takeout_service_spot.visible, "Les deux zones takeout restent visibles avant l'installation") _check(not restaurant.takeout_prepare_spot.is_in_group("interactable") and not restaurant.takeout_service_spot.is_in_group("interactable"), "Une hit zone takeout fermée reste interactive") _check(not GameState.select_takeout_recipe(str(DataCatalog.RECIPES.keys()[0])), "Un comptoir fermé accepte une recette maître") # L'upgrade installe un poste près de la rue, avec sa réserve et sa file. GameState.add_modifier("test:takeout", "takeout_counter", "flat", 1.0) GameState.add_modifier("test:takeout", "takeout_appeal", "flat", 1.0) restaurant._refresh_takeout_counter() _check(GameState.takeout_is_open() and restaurant.takeout_prop.visible, "L'upgrade n'a pas installé le comptoir takeout") _check(not restaurant.takeout_prop.is_in_group("interactable"), "Le meuble takeout concurrence encore ses deux hit zones") _check(restaurant.takeout_prepare_spot.visible and restaurant.takeout_service_spot.visible, "Les deux zones takeout ne sont pas visibles") _check(restaurant.takeout_prepare_spot.is_in_group("interactable") and restaurant.takeout_service_spot.is_in_group("interactable"), "Une des deux hit zones takeout n'est pas interactive") _check(restaurant.takeout_prepare_spot.action == "takeout_prepare" and restaurant.takeout_service_spot.action == "takeout_service", "Les deux zones takeout ne portent pas deux actions stables") # Les deux zones sont deux moitiés **contiguës et disjointes** de la seule # face accessible du comptoir: on les voit toutes les deux, et pointer l'une # ne peut pas désigner l'autre. var counter_rect := Rect2(restaurant.takeout_prop.global_position - restaurant.takeout_prop.size * 0.5, restaurant.takeout_prop.size) var takeout_bands: Array[Rect2] = [] for zone: InteractiveProp in [restaurant.takeout_prepare_spot, restaurant.takeout_service_spot]: _check(zone.usable_faces == InteractiveProp.FACE_BOTTOM, "Une zone takeout n'est pas sur la face accessible du comptoir") var zone_band: Rect2 = zone.face_band_rect(InteractiveProp.FACE_BOTTOM) takeout_bands.append(Rect2(zone.to_global(zone_band.position), zone_band.size)) _check(counter_rect.grow(1.0).encloses(takeout_bands[0]) and counter_rect.grow(1.0).encloses(takeout_bands[1]), "Une zone takeout est dessinée hors du comptoir") _check(not takeout_bands[0].grow(-1.0).intersects(takeout_bands[1].grow(-1.0)), "Les deux zones takeout se superposent") _check(absf(takeout_bands[0].size.x + takeout_bands[1].size.x - restaurant.takeout_prop.size.x) < 1.0, "Les deux zones takeout ne couvrent pas toute la face accessible") _check(restaurant.takeout_prepare_spot.pointer_distance(takeout_bands[0].get_center()) <= 0.01 and restaurant.takeout_prepare_spot.pointer_distance(takeout_bands[1].get_center()) > 0.01, "Pointer la zone service désigne aussi la préparation") _check(restaurant.takeout_prepare_spot.target_highlight_size.is_equal_approx(restaurant.takeout_prop.size) and restaurant.takeout_service_spot.target_highlight_size.is_equal_approx(restaurant.takeout_prop.size), "Les zones takeout ne surlignent pas le comptoir physique") for interactable_node: Node in get_tree().get_nodes_in_group("interactable"): if interactable_node is InteractiveProp and interactable_node.visible: _check((interactable_node as InteractiveProp).has_usable_faces(), "%s est interactif dans le restaurant sans face utilisable" % interactable_node.name) _check(absf(restaurant.takeout_prop.position.y - restaurant.SIDEWALK_Y) < 100.0, "Le comptoir takeout n'est pas rapproché de la rue") _check(is_equal_approx(restaurant.TAKEOUT_QUEUE_Y, restaurant.SIDEWALK_Y), "La file takeout ne suit pas le trottoir devant le comptoir") _check(absf(restaurant._takeout_queue_slot(0).x - restaurant._queue_slot(0).x) > 200.0, "Les deux files utilisent encore le même point physique") _check(GameState.takeout_capacity() > 0 and GameState.takeout_queue_capacity() > 0, "Le stock ou la file takeout n'a aucune capacité") # La console choisit la recette maître sans lancer de cuisson. Au comptoir, # tenir Action travaille ensuite directement comme sur un four. var takeout_recipe_id := str(DataCatalog.RECIPES.keys()[0]) var takeout_recipe: Dictionary = DataCatalog.RECIPES[takeout_recipe_id] for ingredient_id: String in takeout_recipe.ingredients: GameState.ingredients[ingredient_id] = int(takeout_recipe.ingredients[ingredient_id]) * (GameState.takeout_capacity() + 4) restaurant.handle_interaction("console") restaurant.recipe_menu._select_section(RecipeMenu.Section.TAKEOUT) await get_tree().process_frame _check(restaurant.recipe_menu.visible and restaurant.recipe_menu.section == RecipeMenu.Section.TAKEOUT, "La console n'ouvre pas son nouvel onglet takeout") _check(restaurant.recipe_menu.icon != null and restaurant.recipe_menu.icon.kind == RecipeMenu.Section.TAKEOUT, "L'icône de console ne suit pas l'onglet takeout") _check(restaurant.recipe_menu.row_kinds.count(RecipeMenu.ROW_TAKEOUT_RULE) == 4, "La console n'expose pas les deux réglages X/Y") # La sélection takeout reste dans l'onglet: une ligne par recette, aucun # popup intermédiaire. Le geste testé est donc le clic sur la ligne. _check(restaurant.recipe_menu.get_node_or_null("TakeoutPicker") == null, "Le popup de sélection takeout existe encore") _check(restaurant.recipe_menu.row_kinds.count(RecipeMenu.ROW_RECIPE) == restaurant.recipe_menu.listed_recipes().size(), "L'onglet takeout n'affiche pas directement une ligne par recette") var takeout_recipe_row: int = restaurant.recipe_menu.row_ids.find(takeout_recipe_id) _check(takeout_recipe_row >= 0 and restaurant.recipe_menu.row_kinds[takeout_recipe_row] == RecipeMenu.ROW_RECIPE, "La recette takeout n'a pas sa propre ligne dans l'onglet") restaurant.recipe_menu.row_buttons[takeout_recipe_row].pressed.emit() await get_tree().process_frame _check(GameState.takeout_recipe == takeout_recipe_id and GameState.takeout_cooking_remaining <= 0.0, "Choisir la recette à la console a lancé une cuisson") # Un plat emporté se vend une fraction du prix de table: le comptoir ne rend # ni la salle ni le service. var takeout_full_price: int = int(DataCatalog.RECIPES[takeout_recipe_id].sale_price) _check(is_equal_approx(GameState.takeout_price_ratio(), 0.75), "Le coefficient de prix takeout n'est pas 0,75: %.2f" % GameState.takeout_price_ratio()) _check(GameState.takeout_sale_price() == maxi(1, roundi(float(takeout_full_price) * 0.75)), "Le prix takeout ne suit pas son coefficient: %d pour %d" % [GameState.takeout_sale_price(), takeout_full_price]) _check(GameState.takeout_sale_price() < takeout_full_price, "Le comptoir vend au même prix que la salle") # --- Les quatre états d'ouverture ---------------------------------------- # Salle et comptoir sont deux rideaux indépendants: leurs combinaisons font # les quatre états, et aucun ne se relève tout seul. _reopen_shop(restaurant) _check(restaurant.opening_state() == "all_open", "Les deux rideaux levés ne donnent pas l'état tout ouvert: %s" % restaurant.opening_state()) _check(restaurant.toggle_takeout_open_closed(), "L'interrupteur du comptoir n'a pas répondu") _check(restaurant.opening_state() == "dining_only" and restaurant.dining_is_open(), "Fermer le comptoir a aussi fermé la salle") restaurant.toggle_open_closed() _check(restaurant.opening_state() == "all_closed", "Les deux rideaux baissés ne donnent pas l'état tout fermé") restaurant.toggle_takeout_open_closed() _check(restaurant.opening_state() == "takeout_only" and restaurant.dining_is_closed(), "Rouvrir le comptoir a aussi rouvert la salle") restaurant.toggle_open_closed() _check(restaurant.opening_state() == "all_open", "Le retour aux deux rideaux levés ne se lit pas") # Le comptoir tient tant qu'il lui reste des plats à vendre, même sans un # seul ingrédient en réserve: c'est la réserve cuisinée qui le garde ouvert. var takeout_pantry: Dictionary = GameState.ingredients.duplicate() GameState.ingredients.clear() GameState.takeout_stock = 1 restaurant.evaluate_restaurant_closure() _check(restaurant.takeout_counter_is_serving(), "Le comptoir ferme alors qu'il lui reste un plat à vendre") GameState.takeout_stock = 0 restaurant.evaluate_restaurant_closure() _check(not restaurant.takeout_counter_is_serving(), "Le comptoir reste ouvert sans plat ni ingrédient") _check(GameState.takeout_close_reason == GameState.CLOSE_REASON_INGREDIENTS, "La fermeture du comptoir n'est pas nommée par le manque d'ingrédients") # Aucune recette choisie est aussi une raison de fermer. GameState.ingredients = takeout_pantry _reopen_shop(restaurant) var kept_takeout_recipe := GameState.takeout_recipe GameState.takeout_recipe = "" restaurant.evaluate_restaurant_closure() _check(not restaurant.takeout_counter_is_serving() and GameState.takeout_close_reason == GameState.CLOSE_REASON_NO_RECIPE, "Un comptoir sans recette choisie reste ouvert") GameState.takeout_recipe = kept_takeout_recipe _reopen_shop(restaurant) # Et une carte vide ferme la salle en le disant. var kept_menu: Array[String] = GameState.menu_enabled_recipes.duplicate() GameState.menu_enabled_recipes.clear() restaurant.evaluate_restaurant_closure() _check(restaurant.dining_is_closed() and GameState.dining_close_reason == GameState.CLOSE_REASON_NO_RECIPE, "Une carte vide ne ferme pas la salle") GameState.menu_enabled_recipes = kept_menu _reopen_shop(restaurant) restaurant.evaluate_restaurant_closure() _check(restaurant.opening_state() == "all_open", "Le magasin n'est pas revenu ouvert après la série d'états") # Les deux interrupteurs sont dans le premier onglet de la console, et le # geste testé est le clic sur la ligne, pas l'appel de la fonction. restaurant.handle_interaction("console") restaurant.recipe_menu._select_section(RecipeMenu.Section.SHOP) await get_tree().process_frame var counter_switch_row: int = restaurant.recipe_menu.row_kinds.find(RecipeMenu.ROW_TAKEOUT_SWITCH) _check(counter_switch_row >= 0, "La console n'offre aucun interrupteur pour le comptoir") var counter_switch_button: Button = restaurant.recipe_menu.row_buttons[counter_switch_row] _check(counter_switch_button.is_inside_tree(), "L'interrupteur du comptoir est enregistré avant d'être dans l'arbre") counter_switch_button.pressed.emit() await get_tree().process_frame _check(not restaurant.takeout_counter_is_serving() and restaurant.dining_is_open(), "Le bouton du comptoir n'a fermé que lui") _check(restaurant.recipe_menu.takeout_closed, "Le panneau n'a pas suivi l'état du comptoir") restaurant.recipe_menu.row_buttons[restaurant.recipe_menu.row_kinds.find(RecipeMenu.ROW_TAKEOUT_SWITCH)].pressed.emit() await get_tree().process_frame _check(restaurant.takeout_counter_is_serving(), "Le bouton du comptoir ne rouvre pas") # Un comptoir au rideau baissé se regarnit quand même: c'est la seule façon # de pouvoir le rouvrir avec quelque chose à vendre. restaurant.toggle_takeout_open_closed() _check(not restaurant.takeout_counter_is_serving() and restaurant.takeout_needs_cook() == GameState.takeout_should_restock(), "Un comptoir fermé refuse d'être regarni") restaurant.toggle_takeout_open_closed() restaurant.recipe_menu.close() await get_tree().process_frame # --- Mode construction: déplacer les meubles de la salle ----------------- # Le plan est une donnée, pas des constantes: chaque chose déplaçable a sa # fiche, et tout ce qui vise un meuble suit quand il bouge. _check(restaurant.element_of("stove_0") != null and restaurant.element_of("shelf") != null and restaurant.element_of("console") != null and restaurant.element_of("takeout_counter") != null and restaurant.element_of("order_board") != null and restaurant.element_of("table_0") != null, "Le plan de la salle ne couvre pas tous les genres de meubles") _check(restaurant.element_of("stove_0").node == restaurant.cooking_station, "La fiche du four ne pointe pas son meuble") # La disposition d'origine est valide: sinon le joueur ne pourrait pas # reposer un meuble là où il l'a pris. for live_element: RestaurantElement in restaurant.movable_elements(): _check(str(restaurant.placement_blocker(live_element, live_element.position)).is_empty(), "La position d'origine de %s est refusée: %s" % [live_element.id, restaurant.placement_blocker(live_element, live_element.position)]) # Un meuble ne se colle pas au mur, et ne se pose pas sur un autre. var build_stove: RestaurantElement = restaurant.element_of("stove_0") var against_wall := Vector2(restaurant.ROOM_RECT.position.x + restaurant.WALL_THICKNESS * 0.5 + build_stove.size().x * 0.5, build_stove.position.y) _check(restaurant.placement_blocker(build_stove, against_wall) == Loc.ui("ui.build.off_the_wall"), "Un meuble collé au mur est accepté") _check(restaurant.placement_blocker(build_stove, restaurant.element_of("dishwasher").position).begins_with(Loc.ui("ui.build.overlap").split("%")[0]), "Un meuble posé sur un autre est accepté") # Le comptoir takeout reste sur le mur du haut, à gauche de l'entrée. var build_counter: RestaurantElement = restaurant.element_of("takeout_counter") _check(build_counter.is_anchored(), "Le comptoir takeout n'est pas ancré à son mur") _check(restaurant.placement_blocker(build_counter, build_counter.position + Vector2(0, 96)) == Loc.ui("ui.build.stay_on_wall"), "Le comptoir takeout quitte son mur") _check(restaurant.placement_blocker(build_counter, Vector2(restaurant.DOOR_CENTER_X, build_counter.position.y)) == Loc.ui("ui.build.left_of_door"), "Le comptoir takeout passe à droite de l'entrée") _check(str(restaurant.placement_blocker(build_counter, build_counter.position - Vector2(64, 0))).is_empty(), "Le comptoir takeout ne glisse plus le long de son mur") # Le geste complet: entrer, prendre, tourner, poser. _check(restaurant.enter_build_mode(), "La console n'ouvre pas le mode construction") _check(restaurant.build_mode_is_active() and not restaurant.player.controls_enabled, "Le mode construction laisse le joueur jouer") var stove_home: Vector2 = build_stove.position var stove_faces_home: int = restaurant.cooking_station.usable_faces restaurant.build_mode._pick_at(build_stove.position) _check(restaurant.build_mode.held == build_stove, "Cliquer un meuble ne le met pas au curseur") restaurant.build_mode._rotate_held() _check(build_stove.rotation_steps == 1 and build_stove.size().is_equal_approx(Vector2(stove_faces_home * 0 + build_stove.base_size.y, build_stove.base_size.x)), "R ne tourne pas l'encombrement du meuble") # Une case verte est une case où le meuble tient vraiment. var build_target := Vector2(restaurant.STATION_ORIGIN.x, restaurant.STATION_ORIGIN.y + 3.0 * restaurant.STATION_SPACING + 96.0) _check(str(restaurant.placement_blocker(build_stove, build_target)).is_empty(), "L'emplacement visé par le test n'est pas libre") restaurant.build_mode.held.position = build_target restaurant.build_mode.held = null restaurant.apply_layout() restaurant.save_layout() await get_tree().process_frame # Le meuble a suivi, sa collision aussi, et sa face utilisable a tourné. _check(restaurant.cooking_station.position.is_equal_approx(build_target), "Le four posé n'a pas suivi son plan") _check(restaurant.station_center(0).is_equal_approx(build_target), "Le point de visée du four n'a pas suivi son plan") _check(restaurant.get_plating_point().is_equal_approx(build_target), "Le dressage n'a pas suivi le four déplacé") _check(restaurant.cooking_station.usable_faces == RestaurantElement.rotate_face_mask(stove_faces_home, 1), "La face utilisable n'a pas tourné avec le meuble") _check(restaurant.cooking_station.size.is_equal_approx(build_stove.size()), "La taille du meuble ne suit pas sa rotation") # Et le plan traverse la sauvegarde. var saved_stove: Dictionary = GameState.restaurant_layout_entry("stove_0") _check(not saved_stove.is_empty() and is_equal_approx(float(saved_stove.x), build_target.x) and int(saved_stove.rotation) == 1, "Le plan n'a pas été enregistré") var layout_round_trip: Variant = JSON.parse_string(JSON.stringify(GameState.restaurant_layout)) _check(layout_round_trip is Dictionary and (layout_round_trip as Dictionary).has("stove_0"), "Le plan ne survit pas à un aller-retour JSON") # Reposer avec Échap remet le meuble d'où il vient. restaurant.build_mode._pick_at(build_target) _check(restaurant.build_mode.held == build_stove, "Le four déplacé n'est plus attrapable") restaurant.build_mode.held.position = build_target + Vector2(0, 64) restaurant.build_mode._return_held() _check(build_stove.position.is_equal_approx(build_target) and restaurant.build_mode.held == null, "Annuler un déplacement ne repose pas le meuble à sa place") # Sortie du mode: le joueur reprend la main. restaurant.build_mode.close() await get_tree().process_frame _check(not restaurant.build_mode_is_active() and restaurant.player.controls_enabled, "Sortir du mode construction ne rend pas la main au joueur") # Remise en état pour la suite des tests. build_stove.position = stove_home build_stove.rotation_steps = 0 restaurant.apply_layout() GameState.restaurant_layout.clear() await get_tree().process_frame _check(restaurant.cooking_station.position.is_equal_approx(stove_home) and restaurant.cooking_station.usable_faces == stove_faces_home, "La salle n'est pas revenue à sa disposition d'origine") restaurant.recipe_menu._adjust_takeout_rule("target", -1) await get_tree().process_frame _check(GameState.takeout_restock_threshold_value() == 2 and GameState.takeout_restock_target_value() == 3, "Le réglage Restock up to Y de la console ne modifie pas la cible") restaurant.recipe_menu.close() await get_tree().process_frame # Une action temporairement impossible reste lisible, mais seulement sous la # forme d'une petite ligne rouge transparente près de sa face utilisable. var blocked_takeout_ingredient := str(takeout_recipe.ingredients.keys()[0]) var blocked_takeout_amount := GameState.get_ingredient_amount(blocked_takeout_ingredient) GameState.ingredients[blocked_takeout_ingredient] = 0 restaurant.player.global_position = restaurant.takeout_prepare_spot.default_stand_position() restaurant.player.refresh_target() restaurant.hud._process(0.0) _check(restaurant.player.current_target == restaurant.takeout_prepare_spot, "Le poste takeout disparaît au lieu d'expliquer son indisponibilité") _check(not restaurant.player.target_is_actionable(), "Le poste takeout sans ingrédient est encore annoncé disponible") restaurant.takeout_prepare_spot._process(0.0) _check(not restaurant.takeout_prepare_spot.target_highlighted, "Un poste indisponible reçoit le même contour qu'une action possible") _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.missing_ingredients"), "Le poste takeout ne précise pas l'ingrédient manquant") _check(restaurant.hud.prompt_label.get_theme_color("font_color") == Color("ff6b6b") and restaurant.hud.action_key_label.get_theme_color("font_color") == Color("ff6b6b"), "Une action impossible n'est pas affichée en rouge") Input.action_press("action") restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.missing_ingredients"), "Maintenir Action masque aussi la raison rouge d'une action impossible") Input.action_release("action") var prompt_style := restaurant.hud.action_prompt_panel.get_theme_stylebox("panel") as StyleBoxFlat _check(prompt_style != null and prompt_style.bg_color.a <= 0.01, "Le prompt discret conserve un panneau qui cache l'action") var blocked_messages: Array[String] = [] var blocked_probe := func(message: String) -> void: blocked_messages.append(message) GameState.toast_requested.connect(blocked_probe) restaurant.player.interact() GameState.toast_requested.disconnect(blocked_probe) _check(not blocked_messages.is_empty() and blocked_messages.back() == Loc.ui("ui.prompt.missing_ingredients"), "Appuyer sur une action impossible ne donne aucun refus précis") GameState.ingredients[blocked_takeout_ingredient] = blocked_takeout_amount restaurant.handle_interaction("takeout_prepare") _check(not restaurant.recipe_menu.visible, "Action au comptoir ouvre encore un menu de recette") _check(restaurant.player.controls_enabled, "Le comptoir a bloqué les contrôles du joueur") # Quand deux activités sont réellement à portée égale, la position du joueur # les rend admissibles mais le curseur décide laquelle devient la cible. Sans # curseur sur un repère, aucune des deux ne part par accident. var prepare_radius_before: float = restaurant.takeout_prepare_spot.interaction_radius var service_radius_before: float = restaurant.takeout_service_spot.interaction_radius restaurant.takeout_prepare_spot.interaction_radius = 72.0 restaurant.takeout_service_spot.interaction_radius = 72.0 # Le point ambigu est le milieu de la face accessible: les deux moitiés y # sont à la même distance. Les points de visée, eux, sont le centre de chaque # bordure dessinée — c'est cela que le curseur doit départager. var ambiguous_takeout_position: Vector2 = restaurant.takeout_prop.global_position + Vector2(0.0, restaurant.takeout_prop.size.y * 0.5 + restaurant.takeout_prepare_spot.face_stand_gap) var prepare_band_center: Vector2 = takeout_bands[0].get_center() var service_band_center: Vector2 = takeout_bands[1].get_center() restaurant.player.global_position = ambiguous_takeout_position restaurant.player.mouse_aim_enabled = true var cursor_prepare_target: Node2D = restaurant.player._find_interactable(prepare_band_center) var cursor_service_target: Node2D = restaurant.player._find_interactable(service_band_center) var cursor_misses_both: Node2D = restaurant.player._find_interactable(Vector2(-9999.0, -9999.0)) _check(cursor_prepare_target == restaurant.takeout_prepare_spot, "Le curseur ne départage pas l'activité préparation") _check(cursor_service_target == restaurant.takeout_service_spot, "Le curseur ne départage pas l'activité service") # Le curseur ne tranche que s'il désigne l'une des cibles. Ailleurs on # retombe sur la proximité: depuis les faces multiples, deux postes voisins # sont souvent à portée égale, et rendre « rien » rendait la cuisine muette. _check(cursor_misses_both == restaurant.takeout_prepare_spot or cursor_misses_both == restaurant.takeout_service_spot, "Un curseur qui ne désigne rien laisse le joueur sans action") restaurant.player.current_target = cursor_prepare_target Input.action_press("action") _check(restaurant.takeout_player_is_preparing() and not restaurant.takeout_player_is_helping_service(), "Pointer préparation lance aussi le service") Input.action_release("action") restaurant.player.current_target = cursor_service_target Input.action_press("action") _check(restaurant.takeout_player_is_helping_service() and not restaurant.takeout_player_is_preparing(), "Pointer service lance aussi la préparation") Input.action_release("action") restaurant.player.global_position = Vector2(-2000.0, -2000.0) _check(restaurant.player._find_interactable(prepare_band_center) == null, "Le curseur sélectionne une tâche sans que le joueur soit dans sa zone") restaurant.player.global_position = ambiguous_takeout_position restaurant.player.mouse_aim_enabled = false var controller_fallback: Node2D = restaurant.player._find_interactable(Vector2(-9999.0, -9999.0)) _check(controller_fallback == restaurant.takeout_prepare_spot or controller_fallback == restaurant.takeout_service_spot, "La manette perd le départage automatique par proximité") restaurant.player.mouse_aim_enabled = true restaurant.takeout_prepare_spot.interaction_radius = prepare_radius_before restaurant.takeout_service_spot.interaction_radius = service_radius_before # Tenir le poste produit en réserve sans assiette physique. var dishes_before_takeout := get_tree().get_nodes_in_group("dish").size() restaurant.player.global_position = restaurant.takeout_prepare_spot.default_stand_position() restaurant.player.refresh_target() _check(restaurant.player.current_target == restaurant.takeout_prepare_spot, "La zone préparation n'est pas la hit zone choisie") restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.takeout_prepare"), "La zone préparation n'affiche pas le verbe court attendu") _check(restaurant.player.target_is_actionable() and restaurant.hud.prompt_label.get_theme_color("font_color") != Color("ff6b6b"), "Une action disponible reste affichée comme un refus") restaurant.takeout_prepare_spot._process(0.0) _check(restaurant.takeout_prepare_spot.target_highlighted, "Le comptoir ne reçoit pas son contour quand la préparation est disponible") Input.action_press("action") restaurant.hud._process(0.0) _check(not restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text.is_empty(), "Le hint reste visible pendant que le joueur maintient Action pour préparer") restaurant._process_takeout(float(takeout_recipe.get("cook_time", 1.0)) + 0.1) Input.action_release("action") _check(GameState.takeout_stock == 1, "Le joueur n'a pas ajouté son plat au stock takeout") _check(get_tree().get_nodes_in_group("dish").size() == dishes_before_takeout, "La production takeout a créé une assiette de salle") var alternate_takeout_recipe := "" for candidate_recipe: String in DataCatalog.RECIPES: if candidate_recipe != takeout_recipe_id: alternate_takeout_recipe = candidate_recipe break if not alternate_takeout_recipe.is_empty(): _check(not GameState.select_takeout_recipe(alternate_takeout_recipe), "Le comptoir a mélangé deux recettes dans la réserve") # Sous X, un cuisinier choisit le takeout puis termine le lot à Y, sans aller # jusqu'à la capacité dure. À X exactement, il ne repart pas. _check(GameState.takeout_restock_threshold_value() == 2 and GameState.takeout_restock_target_value() == mini(3, GameState.takeout_capacity()), "La console n'a pas conservé la règle de restock X/Y") var takeout_cook_id := "" for employee_id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[employee_id].role) == "cook": takeout_cook_id = employee_id break _check(not takeout_cook_id.is_empty(), "Aucun cuisinier ne permet de tester l'autonomie takeout") var takeout_worker := Employee.new() restaurant.add_child(takeout_worker) takeout_worker.setup(takeout_cook_id, restaurant) _check(takeout_worker.move_speed() >= Employee.BASE_SPEED * Employee.MIN_COOK_PACE and takeout_worker.move_speed() > Employee.BASE_SPEED * Employee.MIN_SERVER_PACE, "La vitesse de base du cuisinier n'a pas été relevée") takeout_worker._choose_cook_task() _check(takeout_worker.task == Employee.Task.TAKEOUT and restaurant.takeout_cook_is_assigned(takeout_worker), "Le cuisinier n'a pas choisi le takeout selon sa priorité") takeout_worker.stage = Employee.Stage.TEND restaurant.player.global_position = restaurant.takeout_prepare_spot.default_stand_position() var cook_only_takeout_rate: float = restaurant.takeout_work_rate() Input.action_press("action") _check(restaurant.takeout_work_rate() > cook_only_takeout_rate, "Le cuisinier déjà en place annule le travail du joueur sur la zone préparation") Input.action_release("action") GameState.add_modifier("test:takeout", "takeout_cook_speed", "flat", 20.0) for fill_index: int in range(GameState.takeout_capacity() + 2): restaurant._process_takeout(float(takeout_recipe.get("cook_time", 1.0)) + 1.0) _check(GameState.takeout_stock == GameState.takeout_restock_target_value(), "Le cuisinier n'a pas arrêté le lot à la cible Y") _check(GameState.takeout_stock < GameState.takeout_capacity(), "Le cuisinier a ignoré Y et rempli jusqu'à la capacité dure") _check(GameState.takeout_cooking_remaining <= 0.0, "La cuisson continue au-delà de la cible Y") takeout_worker._process(0.01) _check(not restaurant.takeout_cook_is_assigned(takeout_worker), "Le cuisinier garde le comptoir réservé une fois arrivé à Y") GameState.sell_takeout_dish() _check(GameState.takeout_stock == GameState.takeout_restock_threshold_value() and not GameState.takeout_should_restock(), "Le cuisinier repart alors que le stock est exactement à X") GameState.sell_takeout_dish() _check(GameState.takeout_stock < GameState.takeout_restock_threshold_value() and GameState.takeout_should_restock(), "Le cuisinier ne repart pas quand le stock passe sous X") takeout_worker.queue_free() await get_tree().process_frame # La porte principale ne décide plus du takeout. Le passant doit réellement # croiser le comptoir, rejoindre sa deuxième file puis atteindre sa place. var menu_before_door_test: Array[String] = GameState.menu_enabled_recipes.duplicate() GameState.menu_enabled_recipes.clear() var door_walker: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(door_walker) door_walker.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X, restaurant.TAKEOUT_CENTER_X) door_walker.cash = 100000 door_walker.favorite_meal = takeout_recipe_id door_walker.favorite_chance = 1.0 restaurant.passerby_reached_door(door_walker) _check(door_walker.state == Customer.State.STROLLING and not restaurant.takeout_queue.has(door_walker), "La porte principale déclenche encore la vente takeout") door_walker.queue_free() GameState.menu_enabled_recipes = menu_before_door_test var takeout_walker: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(takeout_walker) restaurant.customers.append(takeout_walker) takeout_walker.setup_passerby("regular", restaurant, Vector2(restaurant.TAKEOUT_CENTER_X - 30.0, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X, restaurant.TAKEOUT_CENTER_X) takeout_walker.cash = 100000 takeout_walker.favorite_meal = takeout_recipe_id takeout_walker.favorite_chance = 1.0 takeout_walker.max_patience = 999.0 var dining_queue_before: int = restaurant.queue.size() var stock_before_takeout_sale := GameState.takeout_stock var coins_before_takeout := GameState.coins takeout_walker._process(0.2) _check(takeout_walker.state == Customer.State.TAKEOUT_QUEUED and takeout_walker.queue_kind == "takeout", "Le passant intéressé n'a pas rejoint la file takeout") _check(takeout_walker.takeout_decided and not takeout_walker.decided, "Le passant n'a pas décidé au point physique du comptoir") _check(restaurant.takeout_queue.has(takeout_walker) and restaurant.queue.size() == dining_queue_before and takeout_walker.table_index < 0, "Le client takeout a occupé la file ou une table de salle") _check(is_equal_approx(takeout_walker.queue_position.y, restaurant.TAKEOUT_QUEUE_Y), "La file takeout n'arrive pas depuis la rue") _check(not takeout_walker.shows_recipe_bubble(), "Le plat unique est encore écrit au-dessus du client takeout") var service_interval: float = restaurant._takeout_service_interval() _check(absf(service_interval - 30.0) < 0.05, "Le service takeout de base ne prend pas environ 30 secondes: %.2f" % service_interval) restaurant._process_takeout_service(service_interval * 2.0) _check(GameState.takeout_stock == stock_before_takeout_sale, "Le comptoir a servi avant que le client atteigne la tête de file") takeout_walker.global_position = takeout_walker.queue_position restaurant.takeout_service_timer = service_interval restaurant._process_takeout_service(1.0) _check(absf(restaurant.takeout_service_timer - (service_interval - 1.0)) < 0.01, "Le service de base n'avance pas à vitesse normale") var displayed_service_progress: float = float(restaurant.takeout_customer_service_progress(takeout_walker)) _check(absf(displayed_service_progress - 1.0 / service_interval) < 0.01, "La barre takeout n'affiche pas la progression réelle du service") var original_takeout_patience := takeout_walker.queue_patience takeout_walker.queue_patience = 0.1 _check(is_equal_approx(restaurant.takeout_customer_service_progress(takeout_walker), displayed_service_progress), "La barre takeout dépend encore de la patience") takeout_walker.queue_patience = original_takeout_patience # Un nouveau client ou le départ de quelqu'un situé derrière ne doit jamais # remettre à zéro le service déjà avancé sur la tête de file. GameState.takeout_stock = GameState.takeout_capacity() var second_takeout_walker: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(second_takeout_walker) second_takeout_walker.setup_passerby("regular", restaurant, Vector2(restaurant.TAKEOUT_CENTER_X - 20.0, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X, restaurant.TAKEOUT_CENTER_X) second_takeout_walker.cash = 100000 second_takeout_walker.favorite_meal = takeout_recipe_id second_takeout_walker.favorite_chance = 1.0 second_takeout_walker.max_patience = 999.0 var timer_before_second_join: float = float(restaurant.takeout_service_timer) restaurant.passerby_reached_takeout(second_takeout_walker) _check(restaurant.takeout_queue.has(second_takeout_walker) and restaurant.takeout_queue.size() == 2, "Le second client n'a pas rejoint la file takeout") _check(is_equal_approx(restaurant.takeout_service_timer, timer_before_second_join), "Un nouveau client à l'arrière remet le service de tête à zéro") restaurant.customer_left_queue(second_takeout_walker) _check(is_equal_approx(restaurant.takeout_service_timer, timer_before_second_join), "Le départ d'un client à l'arrière remet le service de tête à zéro") second_takeout_walker.queue_free() await get_tree().process_frame # Les deux zones gardent leur fonction quel que soit le stock. Préparer ne # touche jamais au service; la zone service donne +30 % même avant le plein. GameState.takeout_stock = GameState.takeout_capacity() - 1 restaurant.player.global_position = restaurant.takeout_prepare_spot.default_stand_position() Input.action_press("action") _check(restaurant.takeout_player_is_preparing(), "Tenir la zone préparation ne lance pas le travail") _check(is_equal_approx(restaurant.takeout_service_multiplier(), 1.0), "La zone préparation accélère aussi le service") Input.action_release("action") restaurant.player.global_position = restaurant.takeout_service_spot.default_stand_position() restaurant.player.refresh_target() _check(restaurant.player.current_target == restaurant.takeout_service_spot, "La zone service n'est pas sa propre hit zone") restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.takeout_service_boost"), "La zone service n'affiche pas le verbe court attendu") Input.action_press("action") _check(not restaurant.takeout_player_is_preparing(), "La zone service lance aussi une préparation") _check(absf(restaurant.takeout_service_multiplier() - 1.3) < 0.001, "La zone service n'ajoute pas 30 % avant que le stock soit plein") restaurant.takeout_service_timer = service_interval restaurant._process_takeout_service(1.0) _check(absf(restaurant.takeout_service_timer - (service_interval - 1.3)) < 0.01, "Le bonus joueur n'accélère pas réellement le chronomètre de service") var takeout_counter_status: Dictionary = restaurant.get_station_status("takeout") _check(takeout_counter_status.has("progress") and "x1.3" in " ".join(takeout_counter_status.get("lines", [])), "Le comptoir ne donne aucun feedback visible du bonus joueur") Input.action_release("action") GameState.takeout_stock = GameState.takeout_capacity() # Un serveur libre compare cette tâche aux priorités de salle, réserve un seul # emplacement au comptoir et ajoute son propre +30 % une fois arrivé. var takeout_server_id := "" for employee_id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[employee_id].get("role", "")) == "server": takeout_server_id = employee_id break _check(not takeout_server_id.is_empty(), "Aucun serveur ne permet de tester l'aide au takeout") var takeout_server_worker := Employee.new() restaurant.add_child(takeout_server_worker) takeout_server_worker.setup(takeout_server_id, restaurant) _check(DataCatalog.SERVER_TASK_PREFERENCES.size() == 4 and DataCatalog.SERVER_TASK_PREFERENCES.all(func(preference: String) -> bool: return DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.has(preference)), "Le serveur n'a pas ses quatre préférences unifiées") # Deux tâches sont réellement disponibles. Inverser uniquement leurs deux # préférences doit inverser le choix, sans modifier les compétences. var preference_guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(preference_guest) preference_guest.setup("regular", restaurant, 0, 0, restaurant.tables[0].seats[0], restaurant.ENTRANCE) preference_guest.state = Customer.State.WAITING preference_guest.global_position = takeout_server_worker.global_position preference_guest.requested_recipe = takeout_recipe_id restaurant.customers.append(preference_guest) var original_preferences: Dictionary = DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.duplicate(true) DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.take_order = 5.0 DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.takeout = 0.0 takeout_server_worker._choose_server_task() _check(takeout_server_worker.task == Employee.Task.TAKE_ORDER, "La préférence commande ne dirige pas le choix réel du serveur") takeout_server_worker._clear_task() DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.take_order = 0.0 DataCatalog.EMPLOYEES[takeout_server_id].task_preferences.takeout = 5.0 takeout_server_worker._choose_server_task() _check(takeout_server_worker.task == Employee.Task.TAKEOUT_SERVICE and restaurant.takeout_server_is_assigned(takeout_server_worker), "Le serveur libre ne choisit pas le service takeout") DataCatalog.EMPLOYEES[takeout_server_id].task_preferences = original_preferences restaurant.customers.erase(preference_guest) preference_guest.queue_free() takeout_server_worker.stage = Employee.Stage.TEND _check(absf(restaurant.takeout_service_multiplier() - 1.3) < 0.001, "Le serveur au comptoir n'ajoute pas 30 % au service") restaurant.player.refresh_target() _check(restaurant.player.current_target == restaurant.takeout_service_spot, "Le serveur déjà en place bloque la hit zone service du joueur") takeout_counter_status = restaurant.get_station_status("takeout") _check("x1.3" in " ".join(takeout_counter_status.get("lines", [])) and DataCatalog.employee_name(takeout_server_id) in " ".join(takeout_counter_status.get("lines", [])), "Le comptoir ne montre pas le serveur et son bonus") Input.action_press("action") _check(absf(restaurant.takeout_service_multiplier() - 1.6) < 0.001, "Les bonus joueur et serveur ne s'additionnent pas à 60 %") Input.action_release("action") restaurant.takeout_service_timer = service_interval stock_before_takeout_sale = GameState.takeout_stock coins_before_takeout = GameState.coins restaurant._process_takeout_service(service_interval / 1.3 * 0.5) _check(GameState.takeout_stock == stock_before_takeout_sale, "Le serveur a servi avant son délai accéléré") restaurant._process_takeout_service(service_interval / 1.3 * 0.6) _check(GameState.takeout_stock == stock_before_takeout_sale - 1 and GameState.coins > coins_before_takeout, "Le service automatique takeout n'a pas vendu") _check(takeout_walker.state == Customer.State.LEAVING and not restaurant.takeout_queue.has(takeout_walker), "Le client takeout reste bloqué après le service") takeout_server_worker._process(0.01) _check(not restaurant.takeout_server_is_assigned(takeout_server_worker), "Le serveur garde le comptoir réservé une fois la file vide") takeout_server_worker.queue_free() await get_tree().process_frame _check(get_tree().get_nodes_in_group("dish").size() == dishes_before_takeout, "Le takeout a sali de la vaisselle") var base_service_interval: float = restaurant._takeout_service_interval() GameState.add_modifier("test:takeout", "takeout_service_speed", "flat", 5.0) _check(restaurant._takeout_service_interval() < base_service_interval, "La vitesse de service ne réduit pas l'attente takeout") # L'état sérialisé et les ventes d'absence restent bornés par le stock. GameState.takeout_stock = GameState.takeout_restock_target_value() GameState._update_takeout_restocking() var takeout_round_trip: Variant = JSON.parse_string(JSON.stringify({ "takeout_recipe": GameState.takeout_recipe, "takeout_stock": GameState.takeout_stock, "takeout_cooking_remaining": GameState.takeout_cooking_remaining, "takeout_cooking_total": GameState.takeout_cooking_total, "takeout_restock_threshold": GameState.takeout_restock_threshold, "takeout_restock_target": GameState.takeout_restock_target, "takeout_restocking": GameState.takeout_restocking, })) _check(takeout_round_trip is Dictionary and str((takeout_round_trip as Dictionary).takeout_recipe) == takeout_recipe_id and int((takeout_round_trip as Dictionary).takeout_stock) == GameState.takeout_stock and int((takeout_round_trip as Dictionary).takeout_restock_threshold) == 2 and int((takeout_round_trip as Dictionary).takeout_restock_target) == 3, "L'état takeout et sa règle X/Y n'ont pas survécu à un aller-retour JSON") _check(GameState.restaurant_runs_unattended(), "Un comptoir takeout garni ne tourne pas en absence") GameState.restaurant_operations.pending_simulation_seconds = 600.0 var away_stock_before := GameState.takeout_stock var away_coins_before := GameState.coins var takeout_away_report := GameState.settle_unattended_sales() _check(int(takeout_away_report.dishes) > 0 and int(takeout_away_report.dishes) <= away_stock_before, "Les ventes d'absence ignorent le stock takeout") _check(GameState.coins == away_coins_before + int(takeout_away_report.coins) and GameState.takeout_stock == away_stock_before - int(takeout_away_report.dishes), "Les ventes d'absence n'ont pas été encaissées ou décomptées") # --- Le restaurant tourne aussi sans le joueur --------------------------- # La salle ne s'arrête pas pendant une plongée: la brigade sert, l'argent # rentre, les ingrédients baissent. Sans employé, rien ne sort: la # production hors scène ne devient jamais magique. var away_cook_id := "" var away_server_id := "" for staff_id: String in DataCatalog.EMPLOYEES: var staff_role := str(DataCatalog.EMPLOYEES[staff_id].get("role", "")) if staff_role == "cook" and away_cook_id.is_empty(): away_cook_id = staff_id if staff_role == "server" and away_server_id.is_empty(): away_server_id = staff_id _check(not away_cook_id.is_empty() and not away_server_id.is_empty(), "Le catalogue n'offre pas un cuisinier et un serveur pour la simulation") var away_menu_recipe := str(DataCatalog.RECIPES.keys()[0]) GameState.set_recipe_on_menu(away_menu_recipe, true) for away_ingredient: String in DataCatalog.RECIPES[away_menu_recipe].ingredients: GameState.ingredients[away_ingredient] = 500 _reopen_shop(restaurant) var away_staff_before: Array[String] = GameState.hired_employees.duplicate() GameState.hired_employees.clear() _check(is_zero_approx(GameState.simulated_dining_rate(away_menu_recipe)), "La salle produit sans le moindre employé") GameState.hired_employees.append(away_cook_id) _check(is_zero_approx(GameState.simulated_dining_rate(away_menu_recipe)), "Un cuisinier seul sert la salle sans serveur") GameState.hired_employees.append(away_server_id) _check(GameState.simulated_dining_rate(away_menu_recipe) > 0.0, "Une brigade complète ne fait rien sortir de la cuisine") var away_dining_coins := GameState.coins var away_dining_pantry: int = GameState.get_ingredient_amount(str(DataCatalog.RECIPES[away_menu_recipe].ingredients.keys()[0])) GameState.restaurant_operations.pending_simulation_seconds = 900.0 var away_full_report := GameState.settle_unattended_operations() _check(int(away_full_report.dining_dishes) > 0, "La brigade n'a servi personne pendant l'absence") _check(GameState.coins > away_dining_coins, "Une absence productive n'a rapporté aucune pièce") _check(GameState.get_ingredient_amount(str(DataCatalog.RECIPES[away_menu_recipe].ingredients.keys()[0])) < away_dining_pantry, "Les repas servis en absence n'ont coûté aucun ingrédient") _check(is_zero_approx(float(GameState.restaurant_operations.pending_simulation_seconds)), "Le temps d'absence n'a pas été consommé par la simulation") # Le temps ne s'arrête que dans le menu Échap. Hors scène il s'accumule. GameState.restaurant_scene_active = false GameState.restaurant_simulation_accumulator = 0.0 GameState.restaurant_operations.pending_simulation_seconds = 0.0 GameState._process(1.5) _check(float(GameState.restaurant_operations.pending_simulation_seconds) >= 1.0, "L'horloge du restaurant n'avance pas hors de la salle") # La salle à l'écran se simule elle-même: le relais se tait. GameState.restaurant_scene_active = true GameState.restaurant_operations.pending_simulation_seconds = 0.0 GameState.restaurant_simulation_accumulator = 0.0 GameState._process(1.5) _check(is_zero_approx(float(GameState.restaurant_operations.pending_simulation_seconds)), "Le temps est compté deux fois quand la salle est jouée") # Et l'arbre en pause arrête tout, c'est ce que fait le menu Échap. GameState.restaurant_scene_active = false var clock_before_pause := GameState.game_time get_tree().paused = true GameState._process(1.5) _check(is_equal_approx(GameState.game_time, clock_before_pause), "Le menu Échap ne suspend pas l'horloge du jeu") _check(is_zero_approx(float(GameState.restaurant_operations.pending_simulation_seconds)), "Le restaurant tourne encore pendant la pause Échap") get_tree().paused = false GameState.restaurant_scene_active = true GameState.hired_employees.assign(away_staff_before) GameState.restaurant_operations.pending_simulation_seconds = 0.0 GameState.restaurant_simulation_accumulator = 0.0 var takeout_opener := "" for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): if str(modifier.get("variable", "")) == "takeout_counter": takeout_opener = node_id _check(not takeout_opener.is_empty(), "Aucune upgrade n'installe le comptoir takeout") _check(DataCatalog.EMPLOYEE_STATION_KINDS.has("takeout"), "Les employés ne peuvent pas avoir d'affinité takeout") GameState.restaurant_operations.pending_simulation_seconds = 0.0 GameState.remove_modifiers_by_source("test:takeout") GameState.takeout_recipe = "" GameState.takeout_stock = 0 GameState.takeout_cooking_remaining = 0.0 GameState.takeout_cooking_total = 0.0 GameState.takeout_restock_threshold = 2 GameState.takeout_restock_target = 4 GameState.takeout_restocking = false restaurant._refresh_takeout_counter() _reset_dining_room(restaurant) await get_tree().process_frame # --- La brigade en données: embauche, slots, portée --------------------- _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:brigade") for id: String in GameState.hired_employees.duplicate(): GameState.dismiss_employee(id) await get_tree().process_frame _check(not DataCatalog.EMPLOYEES.is_empty(), "Le catalogue d'employés est vide") _check(GameState.employee_slots("server") == 0, "Un slot de serveur est ouvert dès le départ") _check(GameState.employee_slots("cook") == 0, "Un slot de cuisinier est ouvert dès le départ") # Choisir par capacité, jamais par indice: le catalogue est écrit à la main. var server_id := "" var cook_id := "" for id: String in DataCatalog.EMPLOYEES: var role := str(DataCatalog.EMPLOYEES[id].role) if role == "server" and server_id.is_empty(): server_id = id elif role == "cook" and cook_id.is_empty(): cook_id = id _check(not server_id.is_empty() and not cook_id.is_empty(), "Le catalogue ne propose pas un serveur et un cuisinier") # Sans slot, pas d'embauche — même avec l'argent. GameState.coins = 100000 _check(not GameState.hire_employee(server_id), "Un employé a été embauché sans slot ouvert") _check(not GameState.employee_is_hired(server_id), "Un employé refusé compte quand même comme embauché") # Slot ouvert mais bourse vide: refusé aussi. GameState.add_modifier("test:brigade", "server_count", "flat", 1.0) _check(GameState.employee_slots("server") == 1, "L'upgrade n'a pas ouvert de slot de serveur") GameState.coins = 0 _check(not GameState.hire_employee(server_id), "Un employé a été embauché sans argent") # Avec les deux: embauché, et payé. var hire_cost: int = int(DataCatalog.EMPLOYEES[server_id].hire_cost) GameState.coins = hire_cost + 25 _check(GameState.hire_employee(server_id), "L'embauche a échoué avec slot et argent") _check(GameState.employee_is_hired(server_id), "L'employé embauché n'est pas en poste") _check(GameState.coins == 25, "L'embauche n'a pas prélevé son coût: %d" % GameState.coins) _check(Registry.read("employees.%s.hired" % server_id) == true, "L'adresse employees..hired ne suit pas l'embauche") # Le slot est unique: le suivant est refusé. var other_server := "" for id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[id].role) == "server" and id != server_id: other_server = id if not other_server.is_empty(): GameState.coins = 100000 _check(not GameState.hire_employee(other_server), "Un second serveur est entré dans un slot unique") # Un cuisinier n'occupe pas le slot d'un serveur. GameState.add_modifier("test:brigade", "cook_count", "flat", 1.0) GameState.coins = 100000 _check(GameState.hire_employee(cook_id), "Le cuisinier n'a pas pu être embauché dans son propre slot") _check(GameState.hired_of_role("server").size() == 1 and GameState.hired_of_role("cook").size() == 1, "Les rôles ne comptent pas leurs slots séparément") # Ouvrir un slot de cuisinier ne doit pas faire de place à un second serveur. if not other_server.is_empty(): GameState.coins = 100000 _check(not GameState.hire_employee(other_server), "Un slot de cuisinier a fait de la place à un second serveur") # Les bonus passifs travaillent seuls, sous une source nommée. Le catalogue # livré peut n'en déclarer aucun: le test pose le sien plutôt que d'espérer. var bonus_before: Array = (DataCatalog.EMPLOYEES[cook_id].get("modifiers", []) as Array).duplicate(true) GameState.dismiss_employee(cook_id) DataCatalog.EMPLOYEES[cook_id]["modifiers"] = [{"variable": "payout", "layer": "flat", "operation": "add", "value": 0.5}] var payout_before: float = float(GameState.resolve_game_variable("payout")) GameState.coins = 100000 _check(GameState.hire_employee(cook_id), "Le cuisinier porteur de bonus n'a pas pu être embauché") _check(GameState.has_modifier_source("employee:%s" % cook_id), "Un employé embauché n'a pas posé ses bonus") _check(float(GameState.resolve_game_variable("payout")) > payout_before, "Le bonus d'un employé n'agit pas sur la statistique") # Renvoyer retire l'employé et ses bonus. _check(GameState.dismiss_employee(cook_id), "Le renvoi a échoué") _check(not GameState.employee_is_hired(cook_id), "Un employé renvoyé reste en poste") _check(not GameState.has_modifier_source("employee:%s" % cook_id), "Les bonus d'un employé renvoyé sont restés") _check(is_equal_approx(float(GameState.resolve_game_variable("payout")), payout_before), "La statistique garde le bonus d'un employé renvoyé") DataCatalog.EMPLOYEES[cook_id]["modifiers"] = bonus_before # Refermer un slot congédie l'employé en trop: pas de brigade fantôme. GameState.remove_modifiers_by_source("test:brigade") GameState.prune_employees() _check(GameState.hired_of_role("server").is_empty(), "Un slot refermé a gardé son employé") # Les compétences se lisent, et une absente vaut zéro. _check(GameState.employee_skill(server_id, "pace") > 0.0, "Le serveur du catalogue n'a aucune allure déclarée") _check(is_zero_approx(GameState.employee_skill(server_id, "craft")), "Une compétence absente ne vaut pas zéro") # Une upgrade du catalogue ouvre bien chaque genre de slot. var server_opener := "" var cook_opener := "" for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): if str(modifier.get("variable", "")) == "server_count": server_opener = node_id if str(modifier.get("variable", "")) == "cook_count": cook_opener = node_id _check(not server_opener.is_empty(), "Aucune upgrade du catalogue n'ouvre un slot de serveur") _check(not cook_opener.is_empty(), "Aucune upgrade du catalogue n'ouvre un slot de cuisinier") # La brigade traverse le fichier de sauvegarde. var brigade_round_trip: Variant = JSON.parse_string(JSON.stringify({"hired_employees": [server_id]})) _check((brigade_round_trip as Dictionary).hired_employees.size() == 1, "La brigade n'a pas survécu à un aller-retour JSON") for id: String in GameState.hired_employees.duplicate(): GameState.dismiss_employee(id) GameState.remove_modifiers_by_source("test:brigade") GameState.coins = 0 _reset_dining_room(restaurant) await get_tree().process_frame # --- Les serveurs et les cuisiniers travaillent ------------------------- # Deux boucles: le serveur repère, sert, débarrasse; le cuisinier lit # l'ardoise et rend une assiette. Le test impose chaque chemin. _reset_dining_room(restaurant) GameState.remove_modifiers_by_source("test:crew") for id: String in GameState.hired_employees.duplicate(): GameState.dismiss_employee(id) _force_table_count(restaurant, 3) _open_order_board(6) for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 40) GameState.ensure_menu_defaults() await get_tree().process_frame var crew_server := "" var crew_cook := "" for id: String in DataCatalog.EMPLOYEES: var crew_role := str(DataCatalog.EMPLOYEES[id].role) if crew_role == "server" and crew_server.is_empty(): crew_server = id elif crew_role == "cook" and crew_cook.is_empty(): crew_cook = id GameState.add_modifier("test:crew", "server_count", "flat", 1.0) GameState.add_modifier("test:crew", "cook_count", "flat", 1.0) GameState.coins = 100000 _check(GameState.hire_employee(crew_server), "Le serveur du test n'a pas pu être embauché") _check(GameState.hire_employee(crew_cook), "Le cuisinier du test n'a pas pu être embauché") restaurant._refresh_employees() await get_tree().process_frame _check(restaurant.employees.size() == 2, "La brigade embauchée n'est pas apparue dans la salle: %d" % restaurant.employees.size()) var server_node: Employee = restaurant._employee_node(crew_server) var cook_node: Employee = restaurant._employee_node(crew_cook) _check(server_node != null and cook_node != null, "Les employés embauchés n'ont pas de corps dans la salle") # 1. Le serveur repère une commande et l'inscrit — même appel que le joueur. var crew_guest := _seat_customer(restaurant, 0, 0, "fried_egg") player.global_position = Vector2(-4000, -4000) GameState.clear_order_line(0) _check(not GameState.board_has_line(0), "La ligne de contrôle n'a pas été effacée") server_node.global_position = crew_guest.global_position server_node._choose_task() _check(server_node.task == Employee.Task.TAKE_ORDER, "Le serveur n'a pas choisi de prendre la commande") server_node.action_remaining = 0.0 server_node._finish_task() _check(GameState.board_has_line(0), "Le serveur n'a pas inscrit la commande sur l'ardoise") _check(str(GameState.order_board_line(0).get("recipe", "")) == "fried_egg", "Le serveur a inscrit la mauvaise recette") # 2. Le cuisinier lit l'ardoise, réserve, et occupe un poste. _check(not GameState.order_line_is_claimed(0), "La ligne est réservée avant qu'un cuisinier la prenne") cook_node._choose_task() _check(cook_node.task == Employee.Task.COOK, "Le cuisinier n'a pas pris la commande de l'ardoise") _check(GameState.order_line_is_claimed(0), "Le cuisinier n'a pas réservé sa ligne") var rival_line: Dictionary = GameState.claimable_order_line(["fried_egg"]) _check(rival_line.is_empty(), "Une ligne réservée est encore proposée à un autre cuisinier") cook_node.global_position = cook_node.target_position cook_node.action_remaining = 0.0 cook_node._finish_task() _check(restaurant.station_is_busy(0), "Le cuisinier n'a pas lancé la cuisson") _check(restaurant.station_cooks.has(0), "Le cuisinier ne tient pas son poste") _check(restaurant.cook_rate_for_station(0) > 0.0, "Le cuisinier ne fait pas avancer son poste") _check(restaurant.get_cooking_rate(0) >= restaurant.cook_rate_for_station(0), "Le taux du poste ignore le cuisinier") var cooked_before: int = get_tree().get_nodes_in_group("dish").size() # Il tient son four: il ne repart pas à l'allumage. C'était le défaut — le # poste s'allumait, le cuisinier s'en allait, et le plat le rejoignait tout # seul sur l'étagère. _check(cook_node.task == Employee.Task.COOK and cook_node.stage == Employee.Stage.TEND, "Le cuisinier lâche son four dès l'allumage: tâche %d, temps %d" % [cook_node.task, cook_node.stage]) var stove_point: Vector2 = restaurant.get_plating_point(0) _check(cook_node.global_position.distance_to(stove_point) < 60.0, "Le cuisinier ne reste pas devant son four: %.0f px" % cook_node.global_position.distance_to(stove_point)) restaurant.stations[0].remaining = 0.01 restaurant._process(0.5) # Aucune frame ici: on veut voir le plat là où il sort, avant que quiconque # ait pu le prendre. _check(get_tree().get_nodes_in_group("dish").size() == cooked_before + 1, "Le cuisinier n'a rendu aucune assiette") var plated_dish: Dish = null for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and (node as Dish).state == Dish.State.FILLED: plated_dish = node _check(plated_dish != null, "Aucune assiette n'est sortie du four") if plated_dish != null: # Le plat sort **sur le four**. Il se téléportait sur l'étagère dès qu'un # cuisinier était affecté: les deux emplacements n'en faisaient plus # qu'un, et personne ne traversait la cuisine. _check(plated_dish.global_position.distance_to(stove_point) < 44.0, "Le plat n'est pas sorti sur le four: %.0f px" % plated_dish.global_position.distance_to(stove_point)) _check(plated_dish.global_position.distance_to(restaurant.shelf_station.global_position) > 80.0, "Le plat s'est téléporté sur l'étagère") _check(not GameState.order_line_is_claimed(0), "La ligne est restée réservée après la cuisson") _check(GameState.order_line_is_ready(0), "La ligne cuite n'est pas marquée prête") _check(not restaurant.station_cooks.has(0), "Le cuisinier n'a pas libéré son poste") # Une ligne déjà sortie n'est plus reprise: sans ça le même plat serait # cuisiné en boucle tant que le client n'est pas servi. _check(GameState.claimable_order_line(["fried_egg"]).is_empty(), "Une ligne déjà cuite est reproposée à un cuisinier") # 2b. Il prend le plat qu'il vient de sortir, le porte à l'étagère, et se # libère. Trois temps distincts, aucun raccourci. cook_node._tend_station() _check(cook_node.stage == Employee.Stage.CARRY, "Le cuisinier ne passe pas au portage une fois le plat sorti") _check(cook_node.held == plated_dish, "Le cuisinier ne prend pas le plat qu'il vient de sortir") _check(plated_dish != null and plated_dish.carrier == cook_node, "Le plat ne sait pas que le cuisinier le porte") var shelf_point: Vector2 = restaurant.shelf_station.global_position _check(cook_node.target_position.distance_to(shelf_point) < 110.0, "Le cuisinier ne se dirige pas vers l'étagère: %.0f px" % cook_node.target_position.distance_to(shelf_point)) # On l'arrête un peu à côté de sa cible: il s'arrête à `ARRIVAL_DISTANCE` # près, et une place a pu être prise pendant la marche. Poser « là où je # suis » donnerait donc une assiette de travers, hors emplacement. cook_node.global_position = cook_node.target_position + Vector2(0.0, 12.0) cook_node.action_remaining = 0.0 cook_node._finish_task() _check(cook_node.task == Employee.Task.NONE, "Le cuisinier ne se libère pas après avoir dressé") _check(plated_dish != null and plated_dish.carrier == null, "Le plat reste accroché au cuisinier") var landed_on_shelf := false if plated_dish != null: for shelf_slot: int in range(restaurant.get_shelf_capacity()): if plated_dish.global_position.distance_to(restaurant._shelf_slot_position(shelf_slot)) < 1.0: landed_on_shelf = true _check(landed_on_shelf, "Le plat n'a pas été posé sur un emplacement de l'étagère") # Et si le serveur ou le joueur a été plus rapide, il le constate au lieu # d'attendre devant un four vide. cook_node._clear_task() cook_node.task = Employee.Task.COOK cook_node.stage = Employee.Stage.TEND cook_node.target_station = 0 cook_node._tend_station() _check(cook_node.task == Employee.Task.NONE, "Le cuisinier attend devant un four dont le plat est déjà parti") await get_tree().process_frame # 3. Le serveur porte l'assiette prête jusqu'au client. var crew_dish: Dish = null for node: Node in get_tree().get_nodes_in_group("dish"): if node is Dish and (node as Dish).state == Dish.State.FILLED: crew_dish = node _check(crew_dish != null, "Aucune assiette prête à servir") if crew_dish != null: server_node._clear_task() server_node.global_position = crew_dish.global_position server_node._choose_task() _check(server_node.task == Employee.Task.SERVE, "Le serveur n'a pas choisi de servir l'assiette prête") # Premier temps: il prend l'assiette. Elle ne doit surtout pas être déjà # chez le client — c'était exactement le défaut: elle s'y téléportait. server_node.action_remaining = 0.0 server_node._finish_task() await get_tree().process_frame _check(server_node.task == Employee.Task.SERVE, "La tâche s'est terminée dès le ramassage") _check(server_node.stage == Employee.Stage.CARRY, "Le serveur ne passe pas au temps de portage") _check(server_node.held == crew_dish, "Le serveur n'a pas pris l'assiette en main") _check(crew_dish.carrier == server_node, "L'assiette ne sait pas qui la porte") _check(crew_guest.state != Customer.State.EATING, "Le client est servi avant que l'assiette ait bougé") _check(server_node.target_position.distance_to(crew_guest.global_position) < 1.0, "Le serveur ne se dirige pas vers le client") # L'assiette suit son porteur: c'est ce qui la fait traverser la salle. server_node.global_position = Vector2(200, 700) crew_dish._process(0.0) _check(crew_dish.global_position.distance_to(server_node.global_position) < 60.0, "L'assiette ne suit pas le serveur") # Second temps: arrivé, il sert. server_node.global_position = crew_guest.global_position server_node.action_remaining = 0.0 server_node._finish_task() await get_tree().process_frame _check(crew_guest.state == Customer.State.EATING, "Le serveur n'a pas servi le client") _check(not GameState.board_has_line(0), "La ligne est restée sur l'ardoise après le service") _check(server_node.task == Employee.Task.NONE, "La tâche de service ne s'est pas terminée") # 3b. Une tâche dont l objectif disparaît est abandonnée tout de suite. # Sans ça le serveur finissait son trajet pour ne rien faire, ce qui se voit. # Le convive est posé explicitement: chercher un client en attente aurait # rendu le contrôle creux dès que la salle n en a plus. var thief := _seat_customer(restaurant, 1, 0, "fried_egg") _check(thief != null and thief.state == Customer.State.WAITING, "Le convive de contrôle n attend pas sa commande") var stolen_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(stolen_dish) stolen_dish.setup("fried_egg") stolen_dish.global_position = Vector2(300, 600) await get_tree().process_frame server_node._clear_task() server_node.global_position = Vector2(1000, 600) server_node._choose_task() _check(server_node.task == Employee.Task.SERVE and server_node.target_dish == stolen_dish, "Le serveur n a pas visé l assiette posée") _check(server_node._task_still_valid(), "Un objectif intact est jugé perdu") # Le joueur la prend pendant le trajet. stolen_dish.carrier = player _check(not server_node._task_still_valid(), "Le serveur croit encore pouvoir prendre une assiette déjà en main") server_node._process(0.016) _check(server_node.target_dish != stolen_dish, "Le serveur continue vers une assiette qu il ne peut plus prendre") stolen_dish.carrier = null server_node._clear_task() if is_instance_valid(stolen_dish): stolen_dish.queue_free() if is_instance_valid(thief): thief.queue_free() await get_tree().process_frame # 4. Le débarrassage: une assiette sale part au lavage. var crew_dirty: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(crew_dirty) crew_dirty.setup("fried_egg") crew_dirty.make_dirty() crew_dirty.table_index = 1 restaurant.tables[1].dirty_count = 1 await get_tree().process_frame server_node._clear_task() server_node.global_position = crew_dirty.global_position server_node._choose_task() _check(server_node.task == Employee.Task.BUS, "Le serveur n'a pas choisi de débarrasser") # Premier temps: il ramasse. La table se libère ici, comme pour le joueur. server_node.action_remaining = 0.0 server_node._finish_task() await get_tree().process_frame _check(server_node.stage == Employee.Stage.CARRY, "Le débarrassage ne passe pas au temps de portage") _check(server_node.held == crew_dirty, "Le serveur n'a pas pris l'assiette sale") _check(is_instance_valid(crew_dirty) and not crew_dirty.is_queued_for_deletion(), "L'assiette sale est lavée avant d'atteindre la plonge") _check(int(restaurant.tables[1].dirty_count) == 0, "La table n'a pas été libérée par le ramassage") _check(server_node.target_position.distance_to(restaurant.dishwasher_position()) < 1.0, "Le serveur ne porte pas l'assiette vers la plonge") # Second temps: arrivé à la plonge, il lave. server_node.global_position = restaurant.dishwasher_position() server_node.action_remaining = 0.0 server_node._finish_task() await get_tree().process_frame _check(not is_instance_valid(crew_dirty) or crew_dirty.is_queued_for_deletion(), "L'assiette sale n'a pas été lavée") # 4b. Le verrou d écriture. Un contrôle qui ne ferait qu appeler save_game() # ne prouverait rien: on compare le contenu réel du fichier avant et après. _check(not GameState.save_file_at_boot.is_empty() or not FileAccess.file_exists(GameState.SAVE_PATH), "La sauvegarde de démarrage n a pas été gardée") if FileAccess.file_exists(GameState.SAVE_PATH): var before_lock := FileAccess.open(GameState.SAVE_PATH, FileAccess.READ) var text_before := before_lock.get_as_text() if before_lock != null else "" if before_lock != null: before_lock.close() GameState.coins += 9999 GameState.save_game() var after_lock := FileAccess.open(GameState.SAVE_PATH, FileAccess.READ) var text_after := after_lock.get_as_text() if after_lock != null else "" if after_lock != null: after_lock.close() _check(text_after == text_before, "Le verrou n empêche pas l écriture de la sauvegarde") GameState.coins -= 9999 # 5. Un employé sans compétence déclarée marche quand même: aucune fiche ne # peut immobiliser la brigade par omission. _check(server_node.move_speed() > 0.0, "Un serveur ne se déplace pas") _check(server_node.sight_range() > 0.0, "Un serveur ne voit rien") _check(cook_node.cooking_rate_for("stove", "fried_egg") > 0.0, "Un cuisinier ne cuisine à aucune vitesse") # 7. Deux cuisiniers, deux fours. `station_is_busy` ne devient vrai qu'à # l'allumage: pendant la marche, les deux visaient le premier four et le # second arrivait pour rien. Le poste se réserve comme une assiette. _reset_dining_room(restaurant) _force_table_count(restaurant, 3) _open_order_board(6) for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 40) # `_reset_dining_room` purge les sources `test:`: on repose ici tout ce dont # ce bloc dépend, plutôt que de compter sur ce qui précède. Deux places de # cuisinier, puisque le premier occupe déjà la sienne. GameState.add_modifier("test:crew", "stove_count", "flat", 1.0) GameState.add_modifier("test:crew", "server_count", "flat", 1.0) GameState.add_modifier("test:crew", "cook_count", "flat", 2.0) await get_tree().process_frame _check(GameState.employee_slots("cook") >= 2, "Le test n'a pas ouvert deux places de cuisinier: %d" % GameState.employee_slots("cook")) _check(restaurant.unlocked_stoves() >= 2, "Le test n'a pas ouvert deux fours: %d" % restaurant.unlocked_stoves()) var second_cook := "" for id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[id].role) == "cook" and id != crew_cook: second_cook = id break _check(not second_cook.is_empty(), "Le catalogue ne contient qu'un seul cuisinier") GameState.coins = 100000 _check(GameState.hire_employee(second_cook), "Le second cuisinier n'a pas pu être embauché") restaurant._refresh_employees() await get_tree().process_frame var cook_a: Employee = restaurant._employee_node(crew_cook) var cook_b: Employee = restaurant._employee_node(second_cook) _check(cook_a != null and cook_b != null, "Les deux cuisiniers n'ont pas de corps dans la salle") if cook_a != null and cook_b != null: # On fige ce dont le contrôle dépend: deux commandes distinctes, aucune # tâche en cours, aucun poste allumé. cook_a._abandon_task() cook_b._abandon_task() GameState.order_board.clear() GameState.write_order_line(0, "fried_egg") GameState.write_order_line(1, "fried_egg") cook_a._choose_task() cook_b._choose_task() _check(cook_a.task == Employee.Task.COOK and cook_b.task == Employee.Task.COOK, "Les deux cuisiniers n'ont pas pris de commande: %d et %d" % [cook_a.task, cook_b.task]) _check(cook_a.target_station != cook_b.target_station, "Deux cuisiniers visent le même four: %d" % cook_a.target_station) # Et ils n'attendent pas au même endroit non plus: chacun devant le sien. _check(not restaurant.employee_rest_position(cook_a).is_equal_approx(restaurant.employee_rest_position(cook_b)), "Deux cuisiniers attendent au même endroit") # Et un four allumé par le joueur n'est plus une cible valable. restaurant.stations[cook_a.target_station].remaining = 5.0 _check(not cook_a._task_still_valid(), "Un cuisinier marche encore vers un four que le joueur a allumé") restaurant.stations[cook_a.target_station].remaining = 0.0 cook_a._abandon_task() cook_b._abandon_task() GameState.dismiss_employee(second_cook) restaurant._refresh_employees() await get_tree().process_frame # 8. Sans tâche, on ne reste pas planté à côté d'un client qui mange: on # regagne le poste d'attente, entre la cuisine et les tables. _reset_dining_room(restaurant) await get_tree().process_frame var rest_spot: Vector2 = restaurant.employee_rest_position(server_node) _check(restaurant.ROOM_RECT.has_point(rest_spot), "Le poste d'attente est hors de la salle") _check(not restaurant.DINING_RECT.has_point(rest_spot), "Le poste d'attente d'un serveur est au milieu des tables") _check(not restaurant.KITCHEN_RECT.has_point(rest_spot), "Le poste d'attente d'un serveur est enfermé en cuisine") # Le cuisinier attend ailleurs: sa colonne de postes, pas le passage. _check(not restaurant.employee_rest_position(cook_node).is_equal_approx(rest_spot), "Cuisinier et serveur attendent au même endroit") # Et c'est bien la boucle de l'employé qui l'y ramène, pas seulement une # fonction appelable: la salle est vide, donc rien d'autre ne le déplace. server_node._abandon_task() server_node.global_position = rest_spot + Vector2(520, 0) var idle_before := server_node.global_position.distance_to(rest_spot) for idle_frame: int in range(6): await get_tree().process_frame _check(server_node.task == Employee.Task.NONE, "Le serveur a trouvé une tâche dans une salle vide") _check(server_node.global_position.distance_to(rest_spot) < idle_before - 1.0, "Un employé sans tâche reste planté: %.0f px du poste, contre %.0f au départ" % [server_node.global_position.distance_to(rest_spot), idle_before]) # 9. Le paiement suit le ramassage, quel que soit le porteur. Le joueur était # payé en prenant l'assiette, un employé faisait attendre jusqu'à la plonge. var payday: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(payday) payday.setup("fried_egg") payday.global_position = Vector2(1000, 600) payday.state = Dish.State.DIRTY payday.pending_payout = 30 payday.pending_tip = 5 payday.payment_claimed = false await get_tree().process_frame var purse := GameState.coins payday.carry_by(server_node) _check(payday.payment_claimed, "Une assiette sale ramassée par un employé n'est pas encaissée") _check(GameState.coins == purse + 35, "Le ramassage n'a pas versé le compte: %d au lieu de %d" % [GameState.coins, purse + 35]) # Le lavage ne paie pas une seconde fois: le filet reste inoffensif. var purse_after := GameState.coins restaurant.claim_dirty_plate_payment(payday) _check(GameState.coins == purse_after, "Le lavage a encaissé une seconde fois") payday.place_on(Vector2(-3000, -3000)) payday.queue_free() await get_tree().process_frame # 10. Revenir du menu d'upgrades ne renvoie plus la brigade au fond de la # cuisine: la position traverse le changement de scène, la tâche non — elle # se redéduit de la salle, ce qui vaut mieux qu'une tâche périmée. var posted := Vector2(1180, 690) server_node.global_position = posted restaurant._persist_room() _check(GameState.employee_positions.has(crew_server), "La position de l'employé n'a pas été capturée en quittant") restaurant.employees.erase(server_node) restaurant.remove_child(server_node) server_node.queue_free() await get_tree().process_frame restaurant._refresh_employees() await get_tree().process_frame var returned: Employee = restaurant._employee_node(crew_server) _check(returned != null, "L'employé n'a pas été recréé au retour") if returned != null: _check(returned.global_position.distance_to(posted) < 2.0, "Un employé ne reprend pas où il était: %.0f px de son poste" % returned.global_position.distance_to(posted)) _check(not GameState.employee_positions.has(crew_server), "La position reprise n'a pas été consommée") # 11. Une cuisson en cours traverse elle aussi. Trouvé en vérifiant le point # précédent: assiettes et convives passaient, les postes non — le plat et # les ingrédients déjà consommés disparaissaient à chaque visite du menu. restaurant.stations[0].recipe = "fried_egg" restaurant.stations[0].total = 4.0 restaurant.stations[0].remaining = 2.5 restaurant.stations[0].refining = false restaurant.stations[0].order_table = 1 restaurant._persist_room() _check(GameState.restaurant_stations.size() == 1, "La cuisson en cours n'a pas été capturée: %d" % GameState.restaurant_stations.size()) # On simule le retour: le poste est vidé comme le ferait une scène neuve. restaurant.stations[0].recipe = "" restaurant.stations[0].remaining = 0.0 restaurant.stations[0].total = 0.0 restaurant.stations[0].order_table = -1 restaurant._restore_stations() _check(str(restaurant.stations[0].recipe) == "fried_egg", "La cuisson n'a pas repris sa recette") _check(is_equal_approx(float(restaurant.stations[0].remaining), 2.5), "La cuisson a perdu son avancement: %.2f" % float(restaurant.stations[0].remaining)) _check(int(restaurant.stations[0].order_table) == 1, "La cuisson a perdu la commande qu'elle honore") _check(GameState.restaurant_stations.is_empty(), "La cuisson reprise n'a pas été consommée") # Un poste refermé — l'upgrade a pu être perdue en changeant de dimension — # ne reprend rien plutôt que de poser un plat sur un four qui n'existe plus. # Le poste visé **existe dans le tableau** mais n'est pas ouvert: viser # au-delà du tableau ne testait que la garde de bornes. restaurant.stations[0].recipe = "" restaurant.stations[0].remaining = 0.0 var locked_stove: int = restaurant.visible_station_count() _check(locked_stove < restaurant.stations.size(), "Le test n'a aucun poste verrouillé sous la main: %d ouverts sur %d" % [locked_stove, restaurant.stations.size()]) if locked_stove < restaurant.stations.size(): restaurant.stations[locked_stove].recipe = "" restaurant.stations[locked_stove].remaining = 0.0 GameState.capture_restaurant_stations([{"index": locked_stove, "recipe": "fried_egg", "remaining": 1.0, "total": 2.0, "refining": false, "order_table": -1}]) restaurant._restore_stations() _check(not restaurant.station_is_busy(locked_stove), "Une cuisson a repris sur un poste verrouillé") # Rien de stocké: le prochain embauché prend son poste d'attente. restaurant._refresh_employees() server_node = restaurant._employee_node(crew_server) GameState.take_restaurant_plates() GameState.take_restaurant_customers() _reset_dining_room(restaurant) await get_tree().process_frame # 6. Renvoyer retire le corps de la salle. GameState.dismiss_employee(crew_cook) restaurant._refresh_employees() await get_tree().process_frame _check(restaurant._employee_node(crew_cook) == null, "Un employé renvoyé garde son corps dans la salle") for id: String in GameState.hired_employees.duplicate(): GameState.dismiss_employee(id) restaurant._refresh_employees() GameState.remove_modifiers_by_source("test:crew") _close_order_board() _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # --- L'habillage visuel: résolution, repli, et collision intacte -------- # Une entité sans visuel déclaré retombe sur sa forme procédurale, et rien # de ce bloc ne doit changer une hitbox. # De l’art est livré: chaque visuel publié doit être réellement chargeable. # Un PNG sans `.import` voisin passe toutes les validations de données et # reste invisible en jeu — c’est ce défaut-là que ce contrôle attrape. for published_id: String in DataCatalog.VISUALS: _check(VisualCatalog.is_available(published_id), "Le visuel publié « %s » n’est pas chargeable" % published_id) _check(not DataCatalog.VISUAL_STATES.is_empty(), "Le vocabulaire d'états visuels est absent") _check(VisualCatalog.states_for("enemies").has("idle"), "Les créatures n'ont pas d'état de repos") _check(VisualCatalog.states_for("rewards").is_empty(), "Une collection sans habillage déclare des états") # La chaîne de repli: « zéro ou vide veut dire demande au-dessus ». _check(VisualCatalog.fallback_chain("enemies", "idle") == ["idle"], "Un état sans parent remonte quand même") _check(VisualCatalog.fallback_chain("enemies", "walk") == ["walk", "idle"], "La marche ne retombe pas sur le repos") _check(VisualCatalog.fallback_chain("enemies", "death") == ["death", "hurt", "idle"], "La mort ne passe pas par le coup reçu") _check(VisualCatalog.fallback_chain("enemies", "plated").is_empty(), "Un état hors vocabulaire produit une chaîne") _check(VisualCatalog.fallback_chain("enemies", "walk").size() < VisualCatalog.MAX_FALLBACK_DEPTH, "La chaîne de repli ne s'arrête pas") # Sans visuel déclaré, la résolution rend vide: l'appelant dessine sa forme. # Choisie par absence de visuel, jamais par index: le bestiaire en porte # maintenant, et le premier venu pourrait être habillé. var visual_enemy := "" for candidate: String in DataCatalog.ENEMIES: if (DataCatalog.ENEMIES[candidate] as Dictionary).get("visuals", {}).is_empty(): visual_enemy = candidate break _check(not visual_enemy.is_empty(), "Aucune créature sans visuel pour éprouver le repli") if visual_enemy.is_empty(): visual_enemy = str(DataCatalog.ENEMIES.keys()[0]) _check(VisualCatalog.resolve("enemies", visual_enemy, "idle").is_empty(), "Une créature sans visuel en résout un") _check(VisualCatalog.resolve_definition("enemies", visual_enemy, "walk").is_empty(), "Une créature sans visuel rend une définition") # Le test pose son propre catalogue par-dessus le publié, et le rend intact. var visuals_before: Dictionary = DataCatalog.VISUALS.duplicate(true) var enemy_before: Dictionary = (DataCatalog.ENEMIES[visual_enemy] as Dictionary).duplicate(true) DataCatalog.VISUALS["smoke_idle"] = { "display_name": "Smoke Idle", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } DataCatalog.VISUALS["smoke_pair"] = { "display_name": "Smoke Pair", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 2, "rows": 1, "count": 2, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } DataCatalog.VISUALS["smoke_partial"] = { "display_name": "Smoke Partial", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 2, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } # Un calage non neutre: il doit se multiplier avec l'agrandissement de la # créature, jamais le remplacer. DataCatalog.VISUALS["smoke_scaled"] = { "display_name": "Smoke Scaled", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 2.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } # Un chemin qui existe mais n'est pas une texture: le porteur doit refuser. DataCatalog.VISUALS["smoke_not_texture"] = { "display_name": "Smoke Not Texture", "description": "", "kind": "frames", "path": "res://scenes/restaurant/dish.tscn", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } DataCatalog.VISUALS["smoke_missing"] = { "display_name": "Smoke Missing", "description": "", "kind": "frames", "path": "res://assets/nowhere/does_not_exist.png", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } _check(VisualCatalog.is_available("smoke_idle"), "Un visuel dont le fichier existe est jugé indisponible") _check(not VisualCatalog.is_available("smoke_missing"), "Un visuel dont le fichier manque est jugé disponible") _check(not VisualCatalog.is_available("smoke_unknown"), "Un identifiant inconnu est jugé disponible") DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_idle"} _check(VisualCatalog.resolve("enemies", visual_enemy, "idle") == "smoke_idle", "Un état déclaré n'est pas résolu") _check(VisualCatalog.resolve("enemies", visual_enemy, "walk") == "smoke_idle", "Un état vide n'hérite pas de son parent") _check(VisualCatalog.resolve("enemies", visual_enemy, "death") == "smoke_idle", "La mort ne remonte pas jusqu'au repos") _check(not VisualCatalog.resolve_definition("enemies", visual_enemy, "walk").is_empty(), "La définition héritée est vide") # Un visuel déclaré dont le fichier manque ne bloque pas: la chaîne continue. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_idle", "walk": "smoke_missing"} _check(VisualCatalog.resolve("enemies", visual_enemy, "walk") == "smoke_idle", "Une référence morte bloque la chaîne au lieu de la laisser continuer") _check(VisualCatalog.broken_states("enemies", visual_enemy) == ["walk"], "L'état à référence morte n'est pas signalé") _check(VisualCatalog.missing_files().has("smoke_missing"), "Le visuel au fichier manquant n'est pas listé") # La carte d'une entité est validée à la lecture: un état hors vocabulaire ou # un visuel inconnu doit être refusé à la publication, pas découvert à l'écran. var visual_scope := {"smoke_idle": DataCatalog.VISUALS["smoke_idle"]} _check(DataCatalog._normalize_visual_map({"idle": "smoke_idle"}, "enemies", visual_scope) == {"idle": "smoke_idle"}, "Une carte valide est refusée") _check(DataCatalog._normalize_visual_map({}, "enemies", visual_scope) == {}, "Une carte vide est refusée") _check(DataCatalog._normalize_visual_map(null, "enemies", visual_scope) == {}, "Une carte absente est refusée") _check(DataCatalog._normalize_visual_map({"plated": "smoke_idle"}, "enemies", visual_scope) == null, "Un état hors du vocabulaire de la collection est accepté") _check(DataCatalog._normalize_visual_map({"idle": "smoke_unknown"}, "enemies", visual_scope) == null, "Un visuel inconnu est accepté") _check(DataCatalog._normalize_visual_map({"work": "smoke_idle"}, "employees", visual_scope) == {"work": "smoke_idle"}, "Un état propre aux employés est refusé") _check(DataCatalog._normalize_visual_map({"work": "smoke_idle"}, "enemies", visual_scope) == null, "Un état d'employé est accepté sur une créature") # --- Le porteur de visuel ---------------------------------------------- # Une seule ressource importée existe dans le projet: elle sert de feuille # d'essai. Le test vérifie le mécanisme, pas l'art. VisualHolder.clear_cache() DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_idle"} var holder_enemy: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() restaurant.add_child(holder_enemy) holder_enemy.setup(visual_enemy) await get_tree().process_frame _check(holder_enemy.visuals != null, "La créature n'a pas de porteur de visuel") _check(holder_enemy.visuals.is_showing(), "Le porteur ne montre rien alors qu'un visuel est déclaré") _check(holder_enemy.visuals.visual_id == "smoke_idle", "Le porteur montre le mauvais visuel") _check(holder_enemy.visuals.get_child_count() > 0, "Le porteur n'a créé aucun noeud d'affichage") # Le dessin suit le corps: la réciproque du contrôle de hitbox plus bas. # `size_multiplier` est tiré entre min et max à chaque apparition — sans # cette règle, deux créatures de rayons différents portent la même image et # l'agrandissement cesse de se voir dès qu'un art est déclaré. _check(is_equal_approx(holder_enemy.visuals.scale.x, holder_enemy.size_multiplier), "L'image ne suit pas l'agrandissement du corps") var built_sprite: AnimatedSprite2D = holder_enemy.visuals._sprite _check(built_sprite != null, "Une feuille de sprites n'a pas produit d'AnimatedSprite2D") if built_sprite != null: _check(built_sprite.sprite_frames.get_frame_count("default") == 1, "La découpe n'a pas produit le bon nombre d'images") _check(built_sprite.sprite_frames.has_animation("default"), "L'animation par défaut est absente") # La découpe suit ce qui est déclaré, et `count` peut être inférieur à # colonnes × rangées quand la dernière ligne est incomplète. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_pair"} holder_enemy.visuals.show_state("idle") _check(holder_enemy.visuals._sprite.sprite_frames.get_frame_count("default") == 2, "Une feuille à deux images n'en produit pas deux") DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_partial"} holder_enemy.visuals.show_state("idle") _check(holder_enemy.visuals._sprite.sprite_frames.get_frame_count("default") == 1, "Une dernière ligne incomplète n'est pas respectée") # Le calage de l'image et l'agrandissement de la créature se multiplient: ils # répondent à deux questions différentes, et aucun n'écrase l'autre. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_scaled"} holder_enemy.visuals.show_state("idle") _check(is_equal_approx(absf(holder_enemy.visuals._sprite.scale.x), 2.0), "Le calage du visuel n'est pas appliqué") _check(is_equal_approx(holder_enemy.visuals.scale.x * absf(holder_enemy.visuals._sprite.scale.x), holder_enemy.size_multiplier * 2.0), "Le calage et l'agrandissement ne se multiplient pas") DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_partial"} holder_enemy.visuals.show_state("idle") # Le corps procédural cède la place tant qu'une image est montrée. _check(not holder_enemy.should_draw_body(), "La forme procédurale se dessine par-dessus l'image") # Un fichier qui existe mais n'est pas une texture ne doit rien afficher. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_not_texture"} holder_enemy.visuals.show_state("idle") _check(not holder_enemy.visuals.is_showing(), "Un fichier qui n'est pas une texture est déclaré affiché") _check(holder_enemy.should_draw_body(), "La forme procédurale ne reprend pas la main sans image") DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_idle"} holder_enemy.visuals.show_state("idle") # L'état se déduit de ce que la créature fait: aucun drapeau neuf à tenir. # La créature erre d'elle-même: le test la fige, sinon il lirait un état que # la boucle physique vient de changer entre deux contrôles. holder_enemy.active = false holder_enemy.velocity = Vector2.ZERO holder_enemy.knockback_velocity = Vector2.ZERO holder_enemy.attack_state = "idle" holder_enemy.direct_flash_remaining = 0.0 _check(holder_enemy._current_visual_state() == "idle", "Une créature immobile n'est pas au repos") holder_enemy.velocity = Vector2(200, 0) _check(holder_enemy._current_visual_state() == "walk", "Une créature en mouvement n'est pas en marche") holder_enemy.velocity = Vector2.ZERO holder_enemy.attack_state = "windup" _check(holder_enemy._current_visual_state() == "attack", "Une créature qui attaque n'est pas en attaque") holder_enemy.attack_state = "idle" holder_enemy.direct_flash_remaining = 0.1 _check(holder_enemy._current_visual_state() == "hurt", "Une créature touchée n'est pas en état touché") holder_enemy.direct_flash_remaining = 0.0 holder_enemy.health = 0.0 _check(holder_enemy._current_visual_state() == "death", "Une créature morte n'est pas en état mort") holder_enemy.health = holder_enemy.max_health # Le repli marche jusque dans le porteur: la marche hérite du repos. holder_enemy.visuals.show_state("walk") _check(holder_enemy.visuals.visual_id == "smoke_idle", "Le porteur n'applique pas la chaîne de repli") # Rejouer le même visuel ne reconstruit rien: sinon l'animation repartirait # de zéro à chaque frame. var sprite_before: AnimatedSprite2D = holder_enemy.visuals._sprite holder_enemy.visuals.show_state("attack") _check(holder_enemy.visuals._sprite == sprite_before, "Un état qui résout au même visuel a reconstruit l'affichage") # Sans visuel déclaré, le porteur ne montre rien et la forme reprend la main. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {} holder_enemy.visuals.show_state("idle") _check(not holder_enemy.visuals.is_showing(), "Le porteur montre encore quelque chose sans visuel déclaré") _check(holder_enemy.visuals.get_child_count() == 0, "Le porteur a gardé son noeud d'affichage") _check(holder_enemy.should_draw_body(), "La forme procédurale ne revient pas quand le visuel disparaît") # Une référence morte ne fait apparaître aucun noeud. DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_missing"} holder_enemy.visuals.show_state("idle") _check(not holder_enemy.visuals.is_showing(), "Un visuel au fichier manquant est quand même affiché") holder_enemy.queue_free() VisualHolder.clear_cache() await get_tree().process_frame # Rien de tout ça n'a touché la collision: c'est la règle qui protège le jeu. # La créature de contrôle vit dans la salle: ce bloc tourne côté restaurant, # et le corps ne dépend de toute façon que de sa formule. var visual_probe_enemy: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() restaurant.add_child(visual_probe_enemy) visual_probe_enemy.setup(visual_enemy) visual_probe_enemy.size_multiplier = 2.0 visual_probe_enemy._apply_collision_shape() await get_tree().process_frame var radius_with_visual: float = visual_probe_enemy.body_radius() DataCatalog.ENEMIES[visual_enemy]["visuals"] = {} visual_probe_enemy._apply_collision_shape() _check(is_equal_approx(visual_probe_enemy.body_radius(), radius_with_visual), "Déclarer un visuel a changé le rayon du corps") _check(is_equal_approx(radius_with_visual, 36.0), "Le rayon du corps ne vient plus de sa formule: %.1f" % radius_with_visual) visual_probe_enemy.queue_free() # --- Le matériau -------------------------------------------------------- # Un shader n'est pas une entité de plus: c'est un matériau posé sur un # visuel. D'où `shader` sur l'entrée visuelle, jamais sur l'entité. var shader_path := "res://assets/shaders/tint.gdshader" _check(ResourceLoader.exists(shader_path), "Le shader d'essai n'est pas dans le projet") _check(VisualHolder.material_for({"shader": ""}) == null, "Un visuel sans shader reçoit quand même un matériau") _check(VisualHolder.material_for({"shader": "res://assets/nowhere.gdshader"}) == null, "Un shader absent produit un matériau") _check(VisualHolder.material_for({"shader": "res://scenes/restaurant/dish.tscn"}) == null, "Un fichier qui n'est pas un shader produit un matériau") var tinted := VisualHolder.material_for({ "shader": shader_path, "shader_params": {"intensity": 2.5, "tint": "#ff0000", "desaturate": true}, }) _check(tinted != null, "Un shader valide n'a produit aucun matériau") if tinted != null: _check(tinted.shader != null, "Le matériau n'a pas de shader") _check(is_equal_approx(float(tinted.get_shader_parameter("intensity")), 2.5), "Un paramètre numérique n'est pas transmis") _check(bool(tinted.get_shader_parameter("desaturate")), "Un paramètre booléen n'est pas transmis") var tint_value: Variant = tinted.get_shader_parameter("tint") _check(tint_value is Color and (tint_value as Color).is_equal_approx(Color("ff0000")), "Une couleur écrite en texte n'arrive pas en Color") # Un vecteur arrive du JSON en tableau: il doit devenir un vrai vecteur. var vectored := VisualHolder.material_for({"shader": shader_path, "shader_params": {"tint": [0.0, 1.0, 0.0, 1.0]}}) if vectored != null: var from_array: Variant = vectored.get_shader_parameter("tint") _check(from_array is Color and (from_array as Color).is_equal_approx(Color(0, 1, 0, 1)), "Un tableau de composantes n'arrive pas en couleur") # Le matériau se pose réellement sur ce qui s'affiche, pas seulement en théorie. DataCatalog.VISUALS["smoke_shaded"] = DataCatalog.VISUALS["smoke_idle"].duplicate(true) DataCatalog.VISUALS["smoke_shaded"]["shader"] = shader_path DataCatalog.ENEMIES[visual_enemy]["visuals"] = {"idle": "smoke_shaded"} var shaded_enemy: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() restaurant.add_child(shaded_enemy) shaded_enemy.setup(visual_enemy) await get_tree().process_frame _check(shaded_enemy.visuals != null and shaded_enemy.visuals._sprite != null, "Le visuel au shader n'affiche rien") if shaded_enemy.visuals != null and shaded_enemy.visuals._sprite != null: _check(shaded_enemy.visuals._sprite.material is ShaderMaterial, "Le matériau n'est pas posé sur l'affichage") shaded_enemy.queue_free() DataCatalog.ENEMIES[visual_enemy]["visuals"] = {} await get_tree().process_frame # --- L'ambiance --------------------------------------------------------- # Elle appartient au lieu, pas à la créature: le biome n'est plus qu'une # palette, c'est exactement là que ça vit. var ambient_biome := str(DataCatalog.BIOMES.keys()[0]) var ambient_before: Dictionary = (DataCatalog.BIOMES[ambient_biome] as Dictionary).duplicate(true) _check(DataCatalog.BIOMES[ambient_biome].has("ambient_color"), "Un biome ne déclare pas de teinte d'ambiance") var default_tint: Variant = DataCatalog.BIOMES[ambient_biome].get("ambient_color", Color.WHITE) _check(default_tint is Color and (default_tint as Color).is_equal_approx(Color.WHITE), "L'ambiance par défaut teinte quelque chose") # Les bornes ne se verifient qu en les depassant: une fiche hors bornes doit # etre refusee a la lecture, pas produire une salle noire a l ecran. var ambient_probe := { "display_name": "Sonde", "description": "", "floor_color": "#101010", "wall_color": "#202020", "accent_color": "#303030", } _check(not DataCatalog._normalize_biomes({"probe": ambient_probe.duplicate(true)}).is_empty(), "Un biome sans ambiance declaree est refuse") var probe_out_of_range := ambient_probe.duplicate(true) probe_out_of_range["ambient_energy"] = 99.0 _check(DataCatalog._normalize_biomes({"probe": probe_out_of_range}).is_empty(), "Une intensite hors bornes est acceptee") var probe_bad_color := ambient_probe.duplicate(true) probe_bad_color["ambient_color"] = "pas une couleur" _check(DataCatalog._normalize_biomes({"probe": probe_bad_color}).is_empty(), "Une teinte invalide est acceptee") var probe_ok := ambient_probe.duplicate(true) probe_ok["ambient_energy"] = 0.4 probe_ok["ambient_color"] = "#3366ff" var normalized_probe := DataCatalog._normalize_biomes({"probe": probe_ok}) _check(not normalized_probe.is_empty() and is_equal_approx(float(normalized_probe.probe.ambient_energy), 0.4), "Une intensite valide n est pas conservee") DataCatalog.BIOMES[ambient_biome] = ambient_before # --- Les effets ponctuels ----------------------------------------------- # Un effet visuel n'est pas un état, c'est un événement — et le vocabulaire # d'événements existe déjà. Ce qui est vérifié: la durée déduite, le jeu à # l'endroit voulu, la survie au déclencheur, et le repli sur le rendu actuel. DataCatalog.VISUALS["smoke_burst"] = { "display_name": "Smoke Burst", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 1, "rows": 1, "count": 4, "fps": 8.0, "loop": false, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "lifetime": 0.0, "shader": "", "shader_params": {}, "preview_image": "", } DataCatalog.VISUALS["smoke_timed"] = DataCatalog.VISUALS["smoke_burst"].duplicate(true) DataCatalog.VISUALS["smoke_timed"]["lifetime"] = 2.5 DataCatalog.VISUALS["smoke_looping"] = DataCatalog.VISUALS["smoke_burst"].duplicate(true) DataCatalog.VISUALS["smoke_looping"]["loop"] = true # Zéro déclaré veut dire « déduis-la », comme partout ailleurs. _check(is_equal_approx(Vfx.lifetime_of("smoke_burst"), 0.5), "La durée n'est pas déduite de la découpe: %.2f" % Vfx.lifetime_of("smoke_burst")) _check(is_equal_approx(Vfx.lifetime_of("smoke_timed"), 2.5), "Une durée déclarée n'est pas respectée") _check(is_equal_approx(Vfx.lifetime_of("smoke_looping"), Vfx.LOOPING_SECONDS), "Une feuille qui boucle n'a pas de durée de repli") _check(is_equal_approx(Vfx.lifetime_of("smoke_unknown"), 0.0), "Un visuel inconnu a une durée") var vfx_enemy_id := str(DataCatalog.ENEMIES.keys()[0]) var vfx_enemy_before: Dictionary = (DataCatalog.ENEMIES[vfx_enemy_id] as Dictionary).duplicate(true) _check(Vfx.visual_for("enemies", vfx_enemy_id, "on_kill").is_empty(), "Un crochet sans effet visuel en rend un") DataCatalog.ENEMIES[vfx_enemy_id]["vfx"] = {"on_kill": "smoke_burst"} _check(Vfx.visual_for("enemies", vfx_enemy_id, "on_kill") == "smoke_burst", "Le crochet déclaré ne rend pas son visuel") _check(Vfx.visual_for("enemies", vfx_enemy_id, "on_spawn").is_empty(), "Un crochet voisin hérite d'un effet qui ne le concerne pas") # Une référence morte ne joue rien plutôt que de casser. DataCatalog.ENEMIES[vfx_enemy_id]["vfx"] = {"on_kill": "smoke_missing"} _check(Vfx.visual_for("enemies", vfx_enemy_id, "on_kill").is_empty(), "Un visuel au fichier manquant est quand même joué") DataCatalog.ENEMIES[vfx_enemy_id]["vfx"] = {"on_kill": "smoke_burst"} # L'effet s'attache au parent, pas à l'origine: une gerbe de mort doit # survivre au cadavre qui la déclenche. var vfx_origin: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() restaurant.add_child(vfx_origin) vfx_origin.setup(vfx_enemy_id) vfx_origin.global_position = Vector2(640, 480) await get_tree().process_frame # Par le crochet, pas par un appel direct: une fonction juste qui n est # jamais appelée ne fait rien à l écran. var vfx_before := get_tree().get_nodes_in_group("vfx").size() Registry.run_hook("enemies", vfx_enemy_id, "on_kill", vfx_origin) var played := get_tree().get_nodes_in_group("vfx") _check(played.size() == vfx_before + 1, "Le crochet n a pas déclenché son effet visuel") var burst: Vfx = played.back() if played.size() > vfx_before else null _check(burst != null, "Le crochet déclaré n'a joué aucun effet") if burst != null: _check(burst.get_parent() == vfx_origin.get_parent(), "L'effet est accroché à son déclencheur: il mourra avec lui") _check(burst.global_position.is_equal_approx(vfx_origin.global_position), "L'effet ne se joue pas à l'endroit de l'événement") var burst_holder := burst.get_node_or_null("Visuals") as VisualHolder _check(burst_holder != null and burst_holder.is_showing(), "L'effet n'affiche rien") # Le déclencheur disparaît, l'effet reste. vfx_origin.queue_free() await get_tree().process_frame _check(is_instance_valid(burst), "L'effet est parti avec son déclencheur") # La garde n'est pas décorative: sans elle, un effet accroché au mauvais # noeud est déjà libéré ici, et l'appel suivant plante la suite entière # au lieu de rapporter un échec. if is_instance_valid(burst): burst._process(Vfx.lifetime_of("smoke_burst") + 0.1) await get_tree().process_frame _check(not is_instance_valid(burst), "L'effet ne se libère pas tout seul") else: vfx_origin.queue_free() # Sans origine, un crochet applique ses effets déclaratifs et rien de plus: # un achat d'upgrade n'a pas d'endroit où jouer quoi que ce soit. Les deux # portes sont contrôlées — celle du registre et celle de l effet lui-même, # qui est aussi une entrée publique. var vfx_count_before := get_tree().get_nodes_in_group("vfx").size() Registry.run_hook("enemies", vfx_enemy_id, "on_kill") _check(get_tree().get_nodes_in_group("vfx").size() == vfx_count_before, "Un crochet sans origine a quand même joué un effet") _check(Vfx.play("enemies", vfx_enemy_id, "on_kill", null) == null, "Un effet se joue sans origine") var detached := Node2D.new() _check(Vfx.play("enemies", vfx_enemy_id, "on_kill", detached) == null, "Un effet se joue depuis un noeud hors de l arbre") detached.free() # La carte de crochets se normalise comme celle des états, avec le même # refus net: un crochet hors du vocabulaire de la collection invalide la fiche. var vfx_scope := {"smoke_burst": DataCatalog.VISUALS["smoke_burst"]} _check(DataCatalog._normalize_vfx_map({"on_kill": "smoke_burst"}, "enemies", vfx_scope) == {"on_kill": "smoke_burst"}, "Une carte de crochets valide est refusée") _check(DataCatalog._normalize_vfx_map({}, "enemies", vfx_scope) == {}, "Une carte de crochets vide est refusée") _check(DataCatalog._normalize_vfx_map(null, "enemies", vfx_scope) == {}, "Une carte de crochets absente est refusée") _check(DataCatalog._normalize_vfx_map({"on_cooked": "smoke_burst"}, "enemies", vfx_scope) == null, "Un crochet d une autre collection est accepté") _check(DataCatalog._normalize_vfx_map({"on_kill": "smoke_unknown"}, "enemies", vfx_scope) == null, "Un visuel inconnu est accepté sur un crochet") _check(DataCatalog._normalize_vfx_map({"on_hurt": "smoke_burst"}, "player_configs", vfx_scope) == {"on_hurt": "smoke_burst"}, "Le joueur ne peut pas déclarer d effet à l impact") DataCatalog.ENEMIES[vfx_enemy_id] = vfx_enemy_before await get_tree().process_frame # --- Les autres porteurs ------------------------------------------------ # Quatre entités portent maintenant un visuel. Le mécanisme est le même; # ce qui change d'une à l'autre, c'est l'état déduit. On vérifie donc la # déduction, pas une seconde fois la découpe d'une feuille. # Volontairement pas le profil par défaut: `_ready` s'y relie tout seul, et # un test qui le choisit ne prouve rien sur le rebind après `setup`. var visual_customer_probe: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() var default_profile := visual_customer_probe.profile_id visual_customer_probe.free() var visual_profile := "" for candidate: String in DataCatalog.CUSTOMER_PROFILES.keys(): if candidate != default_profile: visual_profile = candidate break _check(visual_profile != "" and visual_profile != default_profile, "Aucun profil autre que celui par défaut: le test serait creux") var profile_before: Dictionary = (DataCatalog.CUSTOMER_PROFILES[visual_profile] as Dictionary).duplicate(true) DataCatalog.CUSTOMER_PROFILES[visual_profile]["visuals"] = {"idle": "smoke_idle"} var visual_customer: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(visual_customer) visual_customer.setup(visual_profile, restaurant, -1, -1, Vector2(400, 400), Vector2(200, 400)) await get_tree().process_frame _check(visual_customer.visuals != null and visual_customer.visuals.is_showing(), "Le client ne porte pas son visuel") _check(not visual_customer.should_draw_body(), "La silhouette du client se dessine par-dessus l'image") # Le client se déplace par `move_toward`: son pas réel est la seule mesure de # sa marche, et il doit retomber au repos dès qu'il n'avance plus. # Le pas est mesuré, pas déclaré: sans ça, la marche ne se verrait jamais. visual_customer.state = Customer.State.STROLLING visual_customer.global_position = Vector2(200, 400) visual_customer.stroll_target = Vector2(600, 400) visual_customer._last_step = Vector2.ZERO visual_customer._move_toward(visual_customer.stroll_target, 0.1) _check(visual_customer._last_step.length() > 0.5, "Le déplacement du client ne se mesure pas") _check(visual_customer._current_visual_state() == "walk", "Un pas mesuré ne produit pas la marche") visual_customer.state = Customer.State.WAITING visual_customer._last_step = Vector2.ZERO _check(visual_customer._current_visual_state() == "idle", "Un client immobile n'est pas au repos") visual_customer._last_step = Vector2(4, 0) _check(visual_customer._current_visual_state() == "walk", "Un client qui avance n'est pas en marche") visual_customer._last_step = Vector2.ZERO visual_customer.state = Customer.State.EATING _check(visual_customer._current_visual_state() == "eat", "Un client attablé ne mange pas") # Partir sans avoir été servi fâche; partir rassasié, non. visual_customer.state = Customer.State.LEAVING visual_customer.requested_recipe = "fried_egg" _check(visual_customer._current_visual_state() == "angry", "Un client qui part sans son plat n'est pas fâché") visual_customer.requested_recipe = "" _check(visual_customer._current_visual_state() != "angry", "Un client repu part quand même fâché") visual_customer.queue_free() DataCatalog.CUSTOMER_PROFILES[visual_profile] = profile_before # Le joueur: c'est le plus court qui passe devant, sinon un dash lancé en # pleine attaque laisserait l'image figée sur la frappe. var visual_player := restaurant.get_node_or_null("Player") as Player _check(visual_player != null, "Le restaurant n a pas de joueur: le bloc visuel se serait saute en silence") var player_config_id := DataCatalog.active_player_config_id var config_before: Dictionary = (DataCatalog.PLAYER_CONFIGS[player_config_id] as Dictionary).duplicate(true) # Le porteur doit exister avant qu'on le reconstruise: c'est `_ready` qui le # pose, et le test le vérifie avant d'appeler quoi que ce soit lui-même. _check(visual_player.visuals != null, "Le joueur n'a pas de porteur sans qu'on le lui demande") DataCatalog.PLAYER_CONFIGS[player_config_id]["visuals"] = {"idle": "smoke_idle"} visual_player._build_visual_holder() await get_tree().process_frame _check(visual_player.visuals != null and visual_player.visuals.is_showing(), "Le joueur ne porte pas son visuel") _check(not visual_player.should_draw_body(), "Le disque du joueur se dessine par-dessus l'image") visual_player.dash_remaining = 0.0 visual_player.invulnerability = 0.0 visual_player.knockback_velocity = Vector2.ZERO visual_player.attack_flash = 0.0 visual_player.velocity = Vector2.ZERO _check(visual_player._current_visual_state() == "idle", "Un joueur immobile n'est pas au repos") visual_player.velocity = Vector2(200, 0) _check(visual_player._current_visual_state() == "walk", "Un joueur qui court n'est pas en marche") visual_player.attack_flash = 0.1 _check(visual_player._current_visual_state() == "attack", "Un joueur qui frappe n'est pas en attaque") visual_player.invulnerability = 0.1 _check(visual_player._current_visual_state() == "hurt", "Un joueur touché reste en attaque") visual_player.dash_remaining = 0.1 _check(visual_player._current_visual_state() == "dash", "Le dash ne passe pas devant les autres états") visual_player.dash_remaining = 0.0 visual_player.invulnerability = 0.0 visual_player.attack_flash = 0.0 visual_player.velocity = Vector2.ZERO DataCatalog.PLAYER_CONFIGS[player_config_id] = config_before visual_player._build_visual_holder() var restored_player_visual := VisualCatalog.resolve("player_configs", player_config_id, "idle") _check(visual_player.visuals.visual_id == restored_player_visual, "Le joueur ne rétablit pas son visuel d'origine") _check(visual_player.should_draw_body() == restored_player_visual.is_empty(), "Le dessin procédural du joueur ne suit pas son visuel restauré") # L'assiette: une assiette sale n'est plus un plat, elle retombe sur le # dessin qui sait montrer la vaisselle. var visual_dish_probe: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() var default_recipe := visual_dish_probe.recipe_id visual_dish_probe.free() var visual_recipe := "" for candidate: String in DataCatalog.RECIPES.keys(): if candidate != default_recipe: visual_recipe = candidate break _check(visual_recipe != "" and visual_recipe != default_recipe, "Aucun plat autre que celui par défaut: le test serait creux") var recipe_before: Dictionary = (DataCatalog.RECIPES[visual_recipe] as Dictionary).duplicate(true) DataCatalog.RECIPES[visual_recipe]["visuals"] = {"plated": "smoke_idle"} var visual_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(visual_dish) visual_dish.setup(visual_recipe) await get_tree().process_frame _check(visual_dish.visuals != null and visual_dish.visuals.is_showing(), "L'assiette ne porte pas son visuel") _check(not visual_dish.should_draw_body(), "L'assiette dessinée se superpose à l'image du plat") visual_dish.make_dirty() visual_dish._refresh_visual_state() _check(not visual_dish.visuals.is_showing(), "Une assiette sale montre encore le plat") _check(visual_dish.should_draw_body(), "Une assiette sale ne retrouve pas son dessin") visual_dish.state = Dish.State.FILLED visual_dish._refresh_visual_state() _check(visual_dish.visuals.is_showing(), "Une assiette relavée ne retrouve pas son plat") visual_dish.queue_free() DataCatalog.RECIPES[visual_recipe] = recipe_before # Le ramassage: l'icône remplace la pastille, mais jamais la forme physique. var visual_pickup_probe: IngredientPickup = load("res://scenes/items/ingredient_pickup.tscn").instantiate() var default_ingredient := visual_pickup_probe.ingredient_id visual_pickup_probe.free() var visual_ingredient := "" for candidate: String in DataCatalog.INGREDIENTS.keys(): if candidate != default_ingredient: visual_ingredient = candidate break _check(visual_ingredient != "" and visual_ingredient != default_ingredient, "Aucun matériau autre que celui par défaut: le test serait creux") var ingredient_before: Dictionary = (DataCatalog.INGREDIENTS[visual_ingredient] as Dictionary).duplicate(true) DataCatalog.INGREDIENTS[visual_ingredient]["visuals"] = {"icon": "smoke_idle"} var visual_pickup: IngredientPickup = load("res://scenes/items/ingredient_pickup.tscn").instantiate() restaurant.add_child(visual_pickup) visual_pickup.setup(visual_ingredient) await get_tree().process_frame _check(visual_pickup.visuals != null and visual_pickup.visuals.is_showing(), "Le ramassage ne porte pas son icône") _check(not visual_pickup.should_draw_body(), "La pastille se dessine par-dessus l'icône") var pickup_collision := visual_pickup.get_node_or_null("CollisionShape2D") as CollisionShape2D var shape_with_visual: Shape2D = pickup_collision.shape if pickup_collision != null else null DataCatalog.INGREDIENTS[visual_ingredient]["visuals"] = {} visual_pickup._build_visual_holder() visual_pickup._apply_collision_shape() _check(visual_pickup.should_draw_body(), "La pastille ne revient pas quand l'icône disparaît") if pickup_collision != null and shape_with_visual != null: var radius_before := shape_with_visual.get_rect().size if shape_with_visual is RectangleShape2D else Vector2(13, 13) var radius_after := pickup_collision.shape.get_rect().size if pickup_collision.shape is RectangleShape2D else Vector2(13, 13) _check(radius_before.is_equal_approx(radius_after), "Retirer l'icône a changé la collision du ramassage") visual_pickup.queue_free() DataCatalog.INGREDIENTS[visual_ingredient] = ingredient_before VisualHolder.clear_cache() await get_tree().process_frame DataCatalog.ENEMIES[visual_enemy] = enemy_before DataCatalog.VISUALS = visuals_before await get_tree().process_frame # --- Un magasin vide vide la file, il ne la fait pas défiler ------------- # Sans ce garde-fou, chaque client de la file franchissait la porte puis # repartait aussitôt, un par un. _reset_dining_room(restaurant) _force_table_count(restaurant, 3) _reopen_shop(restaurant) restaurant.queue.clear() for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() await get_tree().process_frame var liners: Array[Customer] = [] for index: int in range(3): var liner: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(liner) liner.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) liner.join_queue(restaurant._queue_slot(index), 600.0) restaurant.queue.append(liner) liners.append(liner) _check(restaurant.queue.size() == 3, "La file de test n'a pas été constituée") # Le magasin se vide: plus personne ne doit franchir la porte. GameState.ingredients.clear() _check(not restaurant._can_accept_arrivals(), "Le restaurant accepte encore des arrivées sans ingrédient") restaurant._seat_from_queue() await get_tree().process_frame for liner: Customer in liners: _check(liner.state != Customer.State.ARRIVING, "Un client de la file est entré alors que le magasin est vide") _check(restaurant.queue.size() == 3, "La file a été vidée sans que le restaurant ferme") # À la fermeture, la file se disperse au lieu de défiler dans la salle. var reputation_at_close := float(GameState.get_fact_value("restaurant_popularity")) restaurant.evaluate_restaurant_closure() await get_tree().process_frame _check(restaurant.dining_is_closed(), "Le restaurant ne s'est pas fermé sans ingrédient") _check(restaurant.queue.is_empty(), "La file a survécu à la fermeture du magasin") for liner: Customer in liners: if is_instance_valid(liner): _check(liner.state == Customer.State.LEAVING, "Un client de la file n'est pas reparti à la fermeture") _check(liner.table_index < 0, "Un client dispersé s'était vu attribuer une table") # Quitter une file fermée n'est pas un abandon: aucune pénalité. _check(is_equal_approx(float(GameState.get_fact_value("restaurant_popularity")), reputation_at_close), "La fermeture du magasin a été facturée comme des abandons de file") for liner: Customer in liners: if is_instance_valid(liner): liner.queue_free() await get_tree().process_frame for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) restaurant.evaluate_restaurant_closure() _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # --- Une assiette sale libère sa table au ramassage --------------------- # Le client suivant n'a aucune raison d'attendre la plonge. _reset_dining_room(restaurant) _force_table_count(restaurant, 1) _reopen_shop(restaurant) restaurant.queue.clear() for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() await get_tree().process_frame var soiled: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(soiled) soiled.setup("fried_egg") soiled.make_dirty() soiled.table_index = 0 restaurant.tables[0].dirty_count = 1 await get_tree().process_frame _check(restaurant._find_open_table() == -1, "Une table portant une assiette sale est proposée") soiled.pick_up(player) await get_tree().process_frame _check(int(restaurant.tables[0].dirty_count) == 0, "Ramasser l'assiette n'a pas libéré la table") _check(restaurant._find_open_table() == 0, "La table reste bloquée après le ramassage de l'assiette") _check(soiled.table_index == -1, "L'assiette ramassée revendique encore sa table") # Reposer puis reprendre ne doit pas libérer une seconde fois. restaurant.tables[0].dirty_count = 1 soiled.pick_up(player) await get_tree().process_frame _check(int(restaurant.tables[0].dirty_count) == 1, "Reprendre une assiette déjà détachée a libéré une table de plus") restaurant.tables[0].dirty_count = 0 # Le lavage encaisse, sans toucher au compte de la table. player.global_position = restaurant.dishwasher_station.default_stand_position() player.refresh_target() _check(player.current_target == restaurant.dishwasher_station, "La face de la plonge n'est pas sa vraie hit zone") restaurant.hud._process(0.0) _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.wash"), "Une assiette sale à la plonge n'affiche pas l'action de lavage") _check(restaurant.hud.prompt_label.get_theme_color("font_color") != Color("ff6b6b"), "Le lavage disponible est affiché en rouge") restaurant.handle_interaction("dishwasher") await get_tree().process_frame _check(int(restaurant.tables[0].dirty_count) == 0, "Le lavage a modifié une table déjà libérée") player.refresh_target() restaurant.hud._process(0.0) _check(player.current_target == restaurant.dishwasher_station, "La plonge disparaît au lieu d'expliquer qu'elle est vide") _check(not player.target_is_actionable(), "Une plonge vide est encore annoncée disponible") _check(restaurant.hud.action_prompt_panel.visible and restaurant.hud.prompt_label.text == Loc.ui("ui.prompt.no_plate_to_wash"), "La plonge vide ne dit pas qu'il n'y a aucune assiette à laver") _check(restaurant.hud.prompt_label.get_theme_color("font_color") == Color("ff6b6b"), "Le refus de la plonge n'est pas rouge") _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # --- Trottoir, file d'attente et décision du passant -------------------- # Le passant décide seul; le restaurant applique. Chaque refus a sa raison, # et le test les impose une par une plutôt que d'espérer les rencontrer. _reset_dining_room(restaurant) _reopen_shop(restaurant) restaurant.queue.clear() restaurant.served_times.clear() # Le nombre de tabourets est une statistique: le test l'impose par un # modificateur plutôt qu'en devinant quelles upgrades ont été achetées. _force_table_count(restaurant, 1) for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 40) GameState.ensure_menu_defaults() await get_tree().process_frame var walker: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(walker) walker.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) _check(walker.state == Customer.State.STROLLING, "Un passant ne commence pas sur le trottoir") _check(walker.table_index == -1, "Un passant a déjà une table réservée") # Cas favorable: menu abordable, stock suffisant, aucune file. var verdict: Dictionary = restaurant.evaluate_queue_join(walker) _check(bool(verdict.join), "Un passant refuse un restaurant vide et approvisionné: %s" % str(verdict.reason)) # Restaurant fermé. GameState.close_dining(GameState.CLOSE_REASON_MANUAL) restaurant._apply_dining_closure() _check(str(restaurant.evaluate_queue_join(walker).reason) == "closed", "Un passant s'arrête devant un restaurant fermé") _reopen_shop(restaurant) # Budget insuffisant: rien sur le menu n'est à sa portée. var rich_cash := walker.cash walker.cash = 0 _check(restaurant.affordable_recipes(0).is_empty(), "Un plat gratuit fausse le test de budget") _check(str(restaurant.evaluate_queue_join(walker).reason) == "too_expensive", "Un passant sans le sou s'arrête quand même") walker.cash = rich_cash # Stock insuffisant pour lui: il compte les gens devant lui. var stock_backup := GameState.ingredients.duplicate(true) GameState.ingredients.clear() _check(str(restaurant.evaluate_queue_join(walker).reason) == "out_of_stock", "Un passant s'arrête alors que la cuisine est vide") GameState.ingredients = stock_backup # File pleine. var capacity := int(GameState.resolve_game_variable("queue_capacity")) var fillers: Array[Customer] = [] for index: int in range(capacity): var filler: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(filler) filler.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) fillers.append(filler) restaurant.queue.append(filler) _check(str(restaurant.evaluate_queue_join(walker).reason) == "queue_full", "Un passant se met dans une file déjà pleine") restaurant.queue.clear() for filler: Customer in fillers: filler.queue_free() await get_tree().process_frame # Service trop lent: le débit observé rend l'attente inacceptable. Deux repas # servis très anciens ne comptent pas; deux repas dans la fenêtre, si. restaurant.served_times.clear() restaurant.served_times.append(GameState.game_time) restaurant.served_times.append(GameState.game_time) var slow_wait: float = restaurant.estimated_wait_for(1) _check(slow_wait > 0.0, "Le débit observé n'a pas produit d'attente estimée") var patience_backup := walker.max_patience walker.max_patience = 0.001 _check(str(restaurant.evaluate_queue_join(walker).reason) == "too_slow", "Un passant accepte une attente au-delà de sa patience") walker.max_patience = patience_backup # Sans historique, le passant accorde le bénéfice du doute: sinon aucun # restaurant neuf ne verrait jamais son premier client. restaurant.served_times.clear() _check(is_equal_approx(restaurant.estimated_wait_for(3), 0.0), "Un restaurant sans historique décourage les passants") # Il s'installe, et la file le place. restaurant.passerby_reached_door(walker) await get_tree().process_frame var seated_directly: bool = walker.state == Customer.State.ARRIVING _check(seated_directly or walker.state == Customer.State.QUEUED, "Le passant décidé n'est ni assis ni en file") # Table libre: il est passé directement de la file au siège. _check(seated_directly and walker.table_index == 0, "Une table libre et propre n'a pas absorbé la file") _check(restaurant.queue.is_empty(), "La file garde un client déjà attablé") # Une table sale retient tout le monde dehors. restaurant.tables[0].customers = [null] restaurant.tables[0].dirty_count = 1 var queued_guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(queued_guest) queued_guest.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) restaurant.passerby_reached_door(queued_guest) await get_tree().process_frame _check(queued_guest.state == Customer.State.QUEUED, "Un client entre alors que la seule table est sale") _check(restaurant.queue.size() == 1, "La file n'a pas retenu le client") _check(queued_guest.global_position.y < restaurant.ROOM_RECT.position.y, "Un client en file a franchi la porte") # La table nettoyée, la file se vide d'elle-même. restaurant.tables[0].dirty_count = 0 restaurant._seat_from_queue() await get_tree().process_frame _check(queued_guest.state == Customer.State.ARRIVING and restaurant.queue.is_empty(), "Une table redevenue propre n'a pas fait entrer la file") # Renoncer à attendre coûte de la réputation. restaurant.tables[0].customers = [null] restaurant.tables[0].dirty_count = 1 var quitter: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(quitter) quitter.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) restaurant.passerby_reached_door(quitter) await get_tree().process_frame _check(restaurant.queue.size() == 1, "Le client de test n'est pas entré dans la file") # La réputation a un plancher à zéro: sans mise de départ, la pénalité serait # invisible et le test passerait sur une soustraction qui n'a rien fait. GameState.set_fact_value("restaurant_popularity", 25.0) var reputation_before := float(GameState.get_fact_value("restaurant_popularity")) quitter.queue_patience = 0.0 quitter._process(0.016) await get_tree().process_frame _check(quitter.state == Customer.State.LEAVING, "Un client à bout de patience reste dans la file") _check(restaurant.queue.is_empty(), "La file garde un client parti") _check(float(GameState.get_fact_value("restaurant_popularity")) < reputation_before, "Renoncer à attendre n'a rien coûté en réputation") restaurant.tables[0].dirty_count = 0 _release_table_count() _reset_dining_room(restaurant) restaurant.queue.clear() restaurant.served_times.clear() await get_tree().process_frame # --- Une table ne se partage pas entre inconnus ------------------------ # Les deux places ne servent qu'à un groupe arrivé ensemble. _reset_dining_room(restaurant) # Le restaurant refuse toute arrivée sans de quoi cuisiner: le test doit lui # fournir ce dont il dépend, sinon aucun client ne s'assoit et la # vérification passerait sur une salle restée vide. _reopen_shop(restaurant) # Le nombre de tabourets est une statistique: le test l'impose. _force_table_count(restaurant, 1) _check(restaurant.get_unlocked_table_count() == 1, "Le test n'a pas réussi à réduire la salle à un tabouret") for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 6) GameState.ensure_menu_defaults() await get_tree().process_frame _check(restaurant._can_accept_arrivals(), "Le restaurant refuse les arrivées alors qu'il a de quoi cuisiner") _check(restaurant._find_open_table() == 0, "Une salle entièrement vide n'offre aucune table") restaurant._spawn_customer(0, 0, 0) await get_tree().process_frame _check(restaurant._free_seats(0).is_empty(), "Un tabouret occupé est encore compté comme libre") _check(restaurant._find_open_table() == -1, "Un tabouret déjà occupé est encore proposé à un inconnu") var seated_before: int = restaurant.customers.size() restaurant._spawn_arrival_batch() await get_tree().process_frame _check(restaurant.customers.size() == seated_before, "Un inconnu s'est assis alors qu'aucun tabouret n'était libre") # Un tabouret de plus reçoit l'arrivée suivante. _force_table_count(restaurant, 2) _check(restaurant._find_open_table() == 1, "Le tabouret suivant n'est pas proposé alors qu'il est libre") restaurant._spawn_customer(1, 0, 0) await get_tree().process_frame _check(restaurant._find_open_table() == -1, "Un tabouret est proposé alors qu'aucun n'est libre") # Une table salie n'est pas proposée non plus, même sans client. restaurant.tables[0].customers = [null] restaurant.tables[0].dirty_count = 1 _check(restaurant._find_open_table() == -1, "Une table sale est proposée à un arrivant") restaurant.tables[0].dirty_count = 0 _check(restaurant._find_open_table() == 0, "Une table redevenue propre n'est pas proposée") _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # --- Le dash suit les touches, pas la visée ---------------------------- # On vise à l'opposé de la course: le dash doit partir vers la course. player.dash_cooldown = 0.0 player.dash_remaining = 0.0 player.movement_direction = Vector2.LEFT player.facing = Vector2.RIGHT player.mouse_aim_enabled = true var dashed: bool = player._try_dash() _check(dashed, "Le dash n'a pas pu se déclencher") _check(player.dash_direction.is_equal_approx(Vector2.LEFT), "Le dash a suivi la visée au lieu des touches: %s" % str(player.dash_direction)) _check(player.facing.is_equal_approx(Vector2.RIGHT), "Le dash a détourné la visée du joueur") # Sans touche pressée, on retombe sur la visée plutôt que de ne rien faire. player.dash_cooldown = 0.0 player.dash_remaining = 0.0 player.movement_direction = Vector2.ZERO player.facing = Vector2.UP _check(player._try_dash(), "Le dash immobile n'a pas pu se déclencher") _check(player.dash_direction.is_equal_approx(Vector2.UP), "Le dash immobile n'est pas parti vers la visée") player.dash_cooldown = 0.0 player.dash_remaining = 0.0 player.mouse_aim_enabled = false # La poubelle jette le repas, pas l'assiette: elle reste en main, sale. _reset_dining_room(restaurant) await get_tree().process_frame var junk_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(junk_dish) junk_dish.setup("fried_egg") junk_dish.pick_up(player) _check(player.held_dish == junk_dish, "The plate meant for the trash was not picked up") # On charge l'assiette d'une valeur en attente: jeter le repas doit l'effacer, # sinon le lavage paierait un plat qui n'a jamais été servi. junk_dish.pending_payout = 99 junk_dish.pending_tip = 7 restaurant.handle_interaction("trash") await get_tree().process_frame _check(is_instance_valid(junk_dish), "La poubelle a détruit l'assiette au lieu du repas") _check(player.held_dish == junk_dish, "L'assiette n'est pas restée dans les mains du joueur") _check(junk_dish.state == Dish.State.DIRTY, "L'assiette jetée n'est pas devenue sale") _check(int(junk_dish.pending_payout) == 0 and int(junk_dish.pending_tip) == 0, "Un plat jeté rapporte encore de l'argent") # Jeter une assiette déjà sale ne doit rien détruire non plus. restaurant.handle_interaction("trash") await get_tree().process_frame _check(is_instance_valid(junk_dish) and player.held_dish == junk_dish, "La poubelle a avalé une assiette déjà sale") # Elle se lave normalement, sans rapporter le repas jeté. var coins_before_wash := GameState.coins restaurant.handle_interaction("dishwasher") await get_tree().process_frame _check(GameState.coins == coins_before_wash, "Laver une assiette jetée a quand même payé le repas") _check(player.held_dish == null, "L'assiette jetée n'a pas pu être lavée") # Plate state and position survive leaving and re-entering the restaurant. _reset_dining_room(restaurant) await get_tree().process_frame var kept_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(kept_dish) kept_dish.setup("fried_egg") kept_dish.global_position = Vector2(742, 486) restaurant._persist_room() var stored_plates: Array = GameState.take_restaurant_plates() _check(stored_plates.size() == 1, "A plate standing in the restaurant was not captured for persistence") # The capture has to survive the save file, not just the scene change. var round_tripped: Variant = JSON.parse_string(JSON.stringify(stored_plates)) _check(round_tripped is Array and round_tripped.size() == 1 and is_equal_approx(float(round_tripped[0].position_x), 742.0) and str(round_tripped[0].recipe_id) == "fried_egg", "A persisted plate did not survive a JSON save round trip") kept_dish.free() await get_tree().process_frame restaurant._restore_plates() await get_tree().process_frame var restored_plates: Array = get_tree().get_nodes_in_group("dish") _check(restored_plates.size() == 1, "Persisted plates were not restored into the restaurant") if restored_plates.size() == 1: var restored_dish := restored_plates[0] as Dish _check(restored_dish.global_position.is_equal_approx(Vector2(742, 486)), "A restored plate lost its position") _check(restored_dish.recipe_id == "fried_egg" and restored_dish.state == Dish.State.FILLED, "A restored plate lost its recipe or its state") # --- La salle entière survit à un aller-retour de scène ---------------- # Ouvrir l'arbre d'upgrades trois secondes vidait la salle et laissait une # assiette sans mangeur. _reset_dining_room(restaurant) # Deux tabourets exactement: avec un troisième libre, le restaurant ferait # entrer le client de la file et le test ne vérifierait plus la file. _force_table_count(restaurant, 2) for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() await get_tree().process_frame var waiting_guest := _seat_customer(restaurant, 0, 0, "fried_egg") waiting_guest.patience = waiting_guest.max_patience * 0.4 var eating_guest := _seat_customer(restaurant, 1, 0, "fried_egg") eating_guest.begin_eating(6.0) eating_guest.consumption_remaining = 3.5 eating_guest.meals_eaten = 1 # Son assiette est posée devant lui, en cours de repas. var eaten_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(eaten_dish) eaten_dish.setup("fried_egg") eaten_dish.place_for_customer(eating_guest, 40, 5, restaurant.get_serving_plate_position(eating_guest)) # Et quelqu'un patiente encore dehors. var lined_guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(lined_guest) lined_guest.setup_passerby("regular", restaurant, Vector2(restaurant.SIDEWALK_LEFT, restaurant.SIDEWALK_Y), Vector2(restaurant.SIDEWALK_RIGHT, restaurant.SIDEWALK_Y), restaurant.DOOR_CENTER_X) lined_guest.join_queue(restaurant._queue_slot(0), 90.0) restaurant.queue.append(lined_guest) restaurant.customers.append(lined_guest) await get_tree().process_frame restaurant._persist_room() var stored_guests: Array = GameState.take_restaurant_customers() _check(stored_guests.size() == 3, "La salle n'a pas capturé ses trois convives: %d" % stored_guests.size()) # La capture doit survivre au fichier de sauvegarde, pas seulement au # changement de scène. var guests_round_trip: Variant = JSON.parse_string(JSON.stringify(stored_guests)) _check(guests_round_trip is Array and (guests_round_trip as Array).size() == 3, "Les convives n'ont pas survécu à un aller-retour JSON") # La salle est rasée comme le ferait un changement de scène. var eating_id := eating_guest.get_instance_id() for leaving_guest: Customer in restaurant.customers.duplicate(): if is_instance_valid(leaving_guest): leaving_guest.free() restaurant.customers.clear() restaurant.queue.clear() for wiped_table: Dictionary in restaurant.tables: wiped_table.customers = [null] wiped_table.dirty_count = 0 eaten_dish.free() await get_tree().process_frame restaurant._restore_customers() restaurant._restore_plates() await get_tree().process_frame _check(restaurant.customers.size() == 3, "Les convives ne sont pas revenus dans la salle: %d" % restaurant.customers.size()) _check(restaurant.queue.size() == 1, "La file d'attente n'a pas survécu à l'aller-retour") var back_waiting: Variant = restaurant.tables[0].customers[0] var back_eating: Variant = restaurant.tables[1].customers[0] _check(back_waiting != null and is_instance_valid(back_waiting), "Le client qui attendait sa commande n'a pas retrouvé sa table") _check(back_eating != null and is_instance_valid(back_eating), "Le client qui mangeait n'a pas retrouvé sa table") if back_waiting != null and is_instance_valid(back_waiting): var revived: Customer = back_waiting _check(revived.state == Customer.State.WAITING, "Le client attablé a changé d'état pendant l'aller-retour") _check(revived.requested_recipe == "fried_egg", "Le client attablé a perdu sa commande") _check(revived.patience < revived.max_patience * 0.6, "Le client attablé a récupéré toute sa patience: %.1f" % revived.patience) if back_eating != null and is_instance_valid(back_eating): var revived_eater: Customer = back_eating _check(revived_eater.state == Customer.State.EATING, "Le client qui mangeait a été interrompu par l'aller-retour") _check(revived_eater.meals_eaten == 1, "Le repas déjà avalé a été oublié") _check(revived_eater.consumption_remaining > 0.0 and revived_eater.consumption_remaining < 6.0, "Le repas en cours est reparti de zéro: %.1f" % revived_eater.consumption_remaining) _check(revived_eater.get_instance_id() != eating_id, "Le test n'a pas réellement recréé le convive") var restored_dishes: Array = get_tree().get_nodes_in_group("dish") _check(restored_dishes.size() == 1, "L'assiette du repas en cours n'est pas revenue: %d" % restored_dishes.size()) if restored_dishes.size() == 1 and back_eating != null and is_instance_valid(back_eating): var back_dish := restored_dishes[0] as Dish _check(back_dish.state == Dish.State.SERVING, "L'assiette en cours de repas est revenue sale alors que son mangeur est là") _check(back_dish.served_customer_id == (back_eating as Customer).get_instance_id(), "L'assiette n'a pas retrouvé son mangeur") _check(int(restaurant.tables[1].dirty_count) == 0, "La table du mangeur a été comptée sale alors qu'il mange encore") _check(restaurant._find_serving_dish(back_eating) == back_dish, "Le restaurant ne relie plus l'assiette à son mangeur") # Sans mangeur au retour, l'assiette doit redevenir ramassable: sinon elle # resterait posée hors d'atteinte pour toujours. restaurant._persist_room() GameState.capture_restaurant_customers([]) for orphaning: Customer in restaurant.customers.duplicate(): if is_instance_valid(orphaning): orphaning.free() restaurant.customers.clear() restaurant.queue.clear() for orphan_table: Dictionary in restaurant.tables: orphan_table.customers = [null] orphan_table.dirty_count = 0 for leftover_dish: Node in get_tree().get_nodes_in_group("dish"): leftover_dish.free() await get_tree().process_frame restaurant._restore_customers() restaurant._restore_plates() await get_tree().process_frame var orphan_dishes: Array = get_tree().get_nodes_in_group("dish") _check(orphan_dishes.size() == 1, "L'assiette orpheline a disparu") if orphan_dishes.size() == 1: var orphan := orphan_dishes[0] as Dish _check(orphan.state == Dish.State.DIRTY, "Une assiette sans mangeur est restée hors d'atteinte du joueur") _check(orphan.is_in_group("interactable"), "L'assiette orpheline n'est pas ramassable") _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # --- La commande ne se divulgue pas toute seule ------------------------ # Un texte au milieu de l'écran annonçait le plat dès que le client # s'asseyait: la prise de commande n'avait plus d'objet. _check(not Loc.UI_FALLBACKS.has("ui.restaurant.customer_ordered"), "L'ancien texte qui annonçait le plat est encore déclaré") _check(Loc.UI_FALLBACKS.has("ui.restaurant.customer_seated"), "Le texte d'arrivée à table n'est pas déclaré") # Une lambda capture par valeur: écrire dans une variable locale depuis # l'intérieur ne remonte pas. Le conteneur, lui, est partagé. var spoiler_seen: Array[String] = [] var spoiler_probe := func(message: String) -> void: spoiler_seen.append(message) GameState.toast_requested.connect(spoiler_probe) var discreet_customer: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(discreet_customer) discreet_customer.setup("regular", restaurant, -1, -1, Vector2(1200, 500), Vector2(1200, 200)) discreet_customer.assign_order("pepper_scramble") await get_tree().process_frame GameState.toast_requested.disconnect(spoiler_probe) _check(not spoiler_seen.is_empty(), "L'arrivée à table ne signale plus rien du tout") var seating_message := "" if spoiler_seen.is_empty() else spoiler_seen[0] _check(not seating_message.contains(DataCatalog.recipe_name("pepper_scramble")), "Le plat commandé est encore annoncé au joueur: %s" % seating_message) _check(seating_message.contains(discreet_customer.display_name), "Le signal d'arrivée ne dit même pas qui attend") discreet_customer.queue_free() await get_tree().process_frame # --- L'ardoise: repérer une commande l'inscrit, une fois pour toutes ---- # L'ancien système de mémorisation est remplacé: rien ne s'efface plus au # bout de quelques secondes, et la commande devient une donnée partagée. _reset_dining_room(restaurant) _force_table_count(restaurant, 3) await get_tree().process_frame var vision_range := float(GameState.resolve_game_variable("player_vision")) _check(vision_range > 0.0, "The player vision stat is missing from the master data") # --- Sans ardoise: le jeu de départ, sur la mémoire du joueur ----------- # L'ardoise est une upgrade. Tant qu'elle n'est pas achetée, une commande lue # s'oublie en s'éloignant, exactement comme avant. GameState.remove_modifiers_by_source("test:board") _check(int(DataCatalog.GAME_VARIABLES.order_board_slots.base_value) == 0, "L'ardoise est ouverte dès le départ au lieu d'être une upgrade") _check(not GameState.order_board_active(), "L'ardoise est active sans avoir été débloquée") var memory_seconds := float(GameState.resolve_game_variable("order_memory_seconds")) _check(memory_seconds > 0.0, "La statistique de mémoire de commande est absente") player.global_position = restaurant.tables[0].seats[0] + Vector2(vision_range + 400.0, 0) var memory_customer := _seat_customer(restaurant, 0, 0, "fried_egg") memory_customer._update_order_knowledge() _check(not memory_customer.player_can_read_order(), "Une commande était lisible avant toute approche") player.global_position = memory_customer.global_position memory_customer._update_order_knowledge(0.0) _check(memory_customer.player_can_read_order(), "Une commande n'est pas lisible au contact du client") _check(GameState.order_board.is_empty(), "Une ligne s'est inscrite alors qu'aucune ardoise n'existe") player.global_position = memory_customer.global_position + Vector2(vision_range + 400.0, 0) memory_customer._update_order_knowledge(memory_seconds * 0.5) _check(memory_customer.player_can_read_order(), "Une commande a été oubliée avant la fin de sa durée de mémoire") memory_customer._update_order_knowledge(memory_seconds) _check(not memory_customer.player_can_read_order(), "Une commande est restée affichée après sa durée de mémoire") player.global_position = memory_customer.global_position memory_customer._update_order_knowledge(0.0) _check(memory_customer.player_can_read_order(), "Une commande oubliée ne redevient pas lisible en approchant") # Une upgrade du catalogue ouvre bien l'ardoise, sans nom codé en dur. var board_opener := "" for node_id: String in DataCatalog.UPGRADES: for modifier: Dictionary in DataCatalog.UPGRADES[node_id].get("modifiers", []): if str(modifier.get("variable", "")) == "order_board_slots": board_opener = node_id _check(not board_opener.is_empty(), "Aucune upgrade du catalogue n'ouvre l'ardoise") memory_customer.queue_free() await get_tree().process_frame # --- Ardoise débloquée: ce qui est repéré est acquis -------------------- _open_order_board(6) _check(GameState.order_board_active(), "L'ardoise ne s'est pas ouverte") # Le joueur commence hors de portée: rien ne doit s'inscrire tout seul. player.global_position = restaurant.tables[0].seats[0] + Vector2(vision_range + 400.0, 0) var vision_customer := _seat_customer(restaurant, 0, 0, "fried_egg") vision_customer._update_order_knowledge() _check(GameState.order_board.is_empty(), "Une commande s'est inscrite sans que personne l'ait repérée") _check(not vision_customer.player_can_read_order(), "A customer order was legible before the player ever came close") # À portée, elle s'inscrit. player.global_position = vision_customer.global_position + Vector2(vision_range * 0.5, 0) vision_customer._update_order_knowledge() _check(GameState.board_has_line(0), "Repérer un client n'a pas inscrit sa commande sur l'ardoise") _check(vision_customer.player_can_read_order(), "A customer order was unreadable inside the player vision range") var written_line := GameState.order_board_line(0) _check(str(written_line.get("recipe", "")) == "fried_egg", "La ligne inscrite ne porte pas la bonne recette") _check(int(written_line.get("table", -1)) == 0, "La ligne inscrite ne désigne pas la bonne table") # Elle ne s'efface plus jamais, quelle que soit la distance ou le temps. player.global_position = vision_customer.global_position + Vector2(vision_range + 4000.0, 0) vision_customer._update_order_knowledge(9999.0) _check(vision_customer.player_can_read_order(), "Une commande inscrite a été oubliée en s'éloignant") _check(GameState.order_board.size() == 1, "L'ardoise a perdu sa ligne avec le temps") # Un même client repéré deux fois n'occupe qu'une ligne. player.global_position = vision_customer.global_position vision_customer._update_order_knowledge() vision_customer._update_order_knowledge() _check(GameState.order_board.size() == 1, "Un client repéré deux fois occupe deux lignes") # Une nouvelle commande retire la ligne: il faut la repérer à nouveau. player.global_position = vision_customer.global_position + Vector2(vision_range + 400.0, 0) vision_customer.assign_order("slime_omelette") _check(GameState.order_board.is_empty(), "Une nouvelle commande a laissé l'ancienne ligne sur l'ardoise") _check(not vision_customer.player_can_read_order(), "A brand new order was already known before being read") player.global_position = vision_customer.global_position vision_customer._update_order_knowledge() _check(str(GameState.order_board_line(0).get("recipe", "")) == "slime_omelette", "La ligne relue ne porte pas la nouvelle recette") # Une ardoise pleine refuse la commande suivante: c'est le seul frein. var board_guests: Array[Customer] = [] _open_order_board(1) _check(GameState.order_board_capacity() == 1, "Le test n'a pas réussi à réduire l'ardoise à une ligne") var second_guest := _seat_customer(restaurant, 1, 0, "fried_egg") board_guests.append(second_guest) player.global_position = second_guest.global_position second_guest._update_order_knowledge(0.0) _check(not GameState.board_has_line(1), "Une ardoise pleine a quand même accepté une ligne de plus") # Une ardoise saturée ne doit jamais rendre une commande moins lisible # qu'avant qu'on l'achète: elle retombe sur la mémoire. _check(second_guest.player_can_read_order(), "Une ardoise pleine a rendu une commande illisible au contact") player.global_position = second_guest.global_position + Vector2(vision_range + 400.0, 0) second_guest._update_order_knowledge(float(GameState.resolve_game_variable("order_memory_seconds")) + 1.0) _check(not second_guest.player_can_read_order(), "Une commande retombée sur la mémoire ne s'oublie plus") # La place libérée redevient inscriptible. GameState.clear_order_line(0) player.global_position = second_guest.global_position second_guest._update_order_knowledge(0.0) _check(GameState.board_has_line(1), "Une place libérée sur l'ardoise n'accepte pas de nouvelle ligne") _open_order_board(6) # Un départ sans service emporte la ligne. second_guest.begin_leaving() _check(not GameState.board_has_line(1), "Un client parti a laissé sa commande sur l'ardoise") for gone_guest: Customer in board_guests: if is_instance_valid(gone_guest): gone_guest.queue_free() await get_tree().process_frame # Une table perdue emporte la sienne. player.global_position = vision_customer.global_position vision_customer._update_order_knowledge() _check(GameState.board_has_line(0), "La ligne de contrôle n'a pas pu être réinscrite") _force_table_count(restaurant, 0) _check(GameState.order_board.is_empty(), "Une table disparue a laissé sa commande affichée") _force_table_count(restaurant, 3) # L'ardoise traverse le fichier de sauvegarde. player.global_position = vision_customer.global_position vision_customer._update_order_knowledge() var board_round_trip: Variant = JSON.parse_string(JSON.stringify(GameState.order_board)) _check(board_round_trip is Array and (board_round_trip as Array).size() == 1, "L'ardoise n'a pas survécu à un aller-retour JSON") if board_round_trip is Array and (board_round_trip as Array).size() == 1: _check(str((board_round_trip as Array)[0].recipe) == "slime_omelette", "La ligne a perdu sa recette dans la sauvegarde") # Le panneau existe, il est branché, et il montre ce que porte l'ardoise. var board_panel: OrderBoard = restaurant.order_board_panel var board_mounted := board_panel != null and is_instance_valid(board_panel) _check(board_mounted, "L'ardoise n'a pas été montée dans la salle") if board_mounted: _check(board_panel.visible, "L'ardoise ouverte reste invisible") GameState.remove_modifiers_by_source("test:board") board_panel._refresh() _check(not board_panel.visible, "L'ardoise s'affiche alors qu'elle n'est pas débloquée") _open_order_board(6) board_panel._refresh() # Sans le panneau, la suite lirait une référence nulle et le run se bloquerait # au lieu d'échouer proprement. if board_mounted: _check(restaurant.ROOM_RECT.has_point(board_panel.position), "L'ardoise est accrochée hors de la salle") _check(board_panel.board_height() > 0.0, "L'ardoise n'a aucune hauteur à dessiner") # `visible` ne suffit pas: le restaurant peint son sol sur toute la salle # depuis son propre `_draw`, et `z_as_relative` étant vrai, un enfant sous # zéro se dessine avant lui. L'ardoise était visible et recouverte. _check(board_panel.z_index >= 0, "L'ardoise passe derrière le sol du restaurant: z_index %d" % board_panel.z_index) # Une assiette prête marque sa ligne: l'état se déduit de la salle, il # n'est jamais recopié sur l'ardoise. _check(not board_panel._recipe_is_plated("slime_omelette"), "Une recette est signalée prête sans assiette") var board_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(board_dish) board_dish.setup("slime_omelette") await get_tree().process_frame _check(board_panel._recipe_is_plated("slime_omelette"), "Une assiette prête ne marque pas sa ligne") board_dish.free() await get_tree().process_frame # Servir raye la ligne. _reset_dining_room(restaurant) # La remise à zéro de la salle referme l'ardoise avec le reste des sources # de test: ce bloc la rouvre pour ce qu'il vérifie. _open_order_board(6) for supply_id: String in DataCatalog.INGREDIENTS: GameState.add_ingredient(supply_id, 20) GameState.ensure_menu_defaults() await get_tree().process_frame var served_guest := _seat_customer(restaurant, 0, 0, "fried_egg") player.global_position = served_guest.global_position served_guest._update_order_knowledge() _check(GameState.board_has_line(0), "La commande à servir n'est pas sur l'ardoise") var service_dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate() restaurant.add_child(service_dish) service_dish.setup("fried_egg") restaurant.serve_customer(served_guest, service_dish) await get_tree().process_frame _check(not GameState.board_has_line(0), "Un repas servi laisse sa commande sur l'ardoise") _close_order_board() _release_table_count() _reset_dining_room(restaurant) await get_tree().process_frame # Cote de popularité: monte au service, descend sur un départ non servi. GameState.set_fact_value("restaurant_popularity", 10.0) var gain := float(GameState.resolve_game_variable("popularity_gain")) var loss := float(GameState.resolve_game_variable("popularity_loss")) _check(gain > 0.0 and loss > 0.0, "Les statistiques de popularité sont absentes") _check(is_equal_approx(restaurant.adjust_popularity(gain), 10.0 + gain), "Le gain de popularité n'a pas été appliqué") var walkout := _seat_customer(restaurant, 1, 0, "fried_egg") walkout.meals_eaten = 0 restaurant.customer_left_unserved(walkout) _check(is_equal_approx(restaurant.get_popularity(), 10.0 + gain - loss), "Un départ non servi n'a pas coûté de popularité") # Un client qui a déjà mangé n'est pas un départ insatisfait. var satisfied := _seat_customer(restaurant, 2, 0, "fried_egg") satisfied.meals_eaten = 1 var before_satisfied: float = restaurant.get_popularity() restaurant.customer_left_unserved(satisfied) _check(is_equal_approx(restaurant.get_popularity(), before_satisfied), "Un client rassasié a été compté comme un départ insatisfait") # La popularité ne descend jamais sous zéro. restaurant.adjust_popularity(-100000.0) _check(is_zero_approx(restaurant.get_popularity()), "La popularité est passée sous zéro") GameState.set_fact_value("restaurant_popularity", 0.0) _reset_dining_room(restaurant) await get_tree().process_frame GameState.ingredients.clear() restaurant.evaluate_restaurant_closure() _check(restaurant.dining_is_closed() and restaurant.hud.restaurant_closed_label.visible, "Empty restaurant did not display the large closed-for-the-day panel") await get_tree().process_frame _check(not restaurant.door_collision.disabled, "Closed restaurant door did not activate its physical barrier") var customers_before_closed_arrival: int = restaurant.customers.size() restaurant._spawn_arrival_batch() _check(restaurant.customers.size() == customers_before_closed_arrival, "A customer crossed the restaurant door with no orderable recipe") var blocked_customer: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(blocked_customer) blocked_customer.setup("regular", restaurant, 0, 0, Vector2(1120, 480), Vector2(1190, 55)) blocked_customer._process(0.2) _check(blocked_customer.state == Customer.State.LEAVING and blocked_customer.global_position.y < 100.0, "An already-arriving customer crossed the door after recipes became unavailable") blocked_customer.queue_free() # Le rideau ne se relève plus tout seul: même automatisé, le magasin reste # fermé tant que personne ne l'a rouvert. Et la raison est restée lisible. _check(restaurant.hud.restaurant_closed_label.text.contains(Loc.ui("ui.restaurant.closed_no_ingredients")), "Le bandeau ne dit pas que la fermeture vient du manque d'ingrédients") _check(GameState.dining_close_reason == GameState.CLOSE_REASON_INGREDIENTS, "La fermeture par manque d'ingrédients n'est pas nommée") for capability: String in ["automated_cooking", "automated_service", "automated_cleanup", "automated_supply"]: GameState.restaurant_operations[capability] = true restaurant.evaluate_restaurant_closure() _check(restaurant.dining_is_closed(), "Le magasin s'est rouvert tout seul dès que l'automatisation est revenue") _reopen_shop(restaurant) restaurant.evaluate_restaurant_closure() _check(not restaurant.dining_is_closed() and not restaurant.hud.restaurant_closed_label.visible, "Complete automation could not keep restaurant operations") # Reproduce the former dungeon-transition crash: a state change had queued a # closure callback after the restaurant was detached from the SceneTree. GameState.close_dining(GameState.CLOSE_REASON_MANUAL) restaurant._apply_dining_closure() restaurant._refresh_tables() restaurant.queue_free() await get_tree().process_frame var dungeon_scene: PackedScene = load("res://scenes/dungeon/dungeon.tscn") var dungeon := dungeon_scene.instantiate() get_tree().root.add_child(dungeon) await get_tree().process_frame var simulated_seconds_before := float(GameState.restaurant_operations.pending_simulation_seconds) GameState._process(1.1) _check(float(GameState.restaurant_operations.pending_simulation_seconds) > simulated_seconds_before, "Automated restaurant simulation did not continue outside the restaurant scene") var enemies := get_tree().get_nodes_in_group("enemy") _check(get_tree().get_nodes_in_group("dungeon_stone_wall").size() == 4, "Dungeon did not build exactly four stone perimeter walls") # --- Salles, biomes, renforts et récompenses --------------------------- # La plongée devient une suite de salles groupées en biomes: la porte ne # s'ouvre qu'une fois la salle nette ET la récompense choisie. _check(dungeon.get_room_index() == 0 and DiveMap.level() == 1, "Une plongée ne commence pas à la première case de la colonne") _check(not DiveMap.current_room_id().is_empty(), "La première case n'a résolu aucune salle") _check(DiveMap.column().size() > 1, "La colonne de la dimension ne compte pas plusieurs cases") _check(DiveMap.current_dimension_id() == DiveMap.cycle_dimension_id(0), "La plongée ne se déroule pas dans la dimension du cycle courant") _check(not dungeon.ends_dimension(), "La première case de la colonne clôt déjà la dimension") _check(not dungeon.is_room_cleared(), "Une salle pleine est considérée comme nettoyée") var sealed_status: Dictionary = dungeon.get_station_status("next_room") _check(not sealed_status.is_empty() and str(sealed_status.lines[0]) == Loc.ui("ui.dungeon.door_sealed"), "La porte n'affiche pas son état scellé") _check(dungeon.get_interaction_prompt("next_room", "") == Loc.ui("ui.dungeon.door_blocked"), "La porte fermée n'explique pas ce qui manque") # Franchir une porte scellée ne fait pas avancer. dungeon.handle_interaction("next_room") _check(dungeon.get_room_index() == 0, "La porte scellée a laissé passer le joueur") # --- Le décor de salle -------------------------------------------------- # La règle habituelle: la case, puis son biome, puis la colonne. Ce qui est # vérifié ici, c'est l'ordre de la chaîne, pas la découpe d'une image. var decor_visuals_before: Dictionary = DataCatalog.VISUALS.duplicate(true) DataCatalog.VISUALS["smoke_decor"] = { "display_name": "Smoke Decor", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } var decor_room := DiveMap.current_room_id() var decor_biome := DiveMap.current_biome_id() var decor_dimension := DiveMap.current_dimension_id() var decor_room_before: Dictionary = (DataCatalog.ROOMS[decor_room] as Dictionary).duplicate(true) var decor_biome_before: Dictionary = (DataCatalog.BIOMES[decor_biome] as Dictionary).duplicate(true) if DataCatalog.BIOMES.has(decor_biome) else {} var decor_dimension_before: Dictionary = (DataCatalog.DIMENSIONS[decor_dimension] as Dictionary).duplicate(true) _check(dungeon.decor != null, "Le donjon n'a pas de porteur de décor") _check(not dungeon.decor.is_showing(), "Un décor s'affiche alors qu'aucun n'est déclaré") _check(dungeon.should_draw_floor(), "Le sol dessiné ne prend pas la main sans image de fond") # La colonne seule: le fond remonte jusqu'à elle. DataCatalog.DIMENSIONS[decor_dimension]["visuals"] = {"background": "smoke_decor"} dungeon._refresh_decor() _check(dungeon.decor.is_showing() and dungeon.decor.collection == "dimensions", "Le décor ne remonte pas jusqu'à la colonne") _check(not dungeon.should_draw_floor(), "Le sol se dessine par-dessus le fond") # Le biome passe devant la colonne. if not decor_biome_before.is_empty(): DataCatalog.BIOMES[decor_biome]["visuals"] = {"background": "smoke_decor"} dungeon._refresh_decor() _check(dungeon.decor.collection == "biomes", "Le biome ne passe pas devant la colonne") # La case passe devant tout le monde. DataCatalog.ROOMS[decor_room]["visuals"] = {"background": "smoke_decor"} dungeon._refresh_decor() _check(dungeon.decor.collection == "rooms", "La case ne passe pas devant son biome") # Le fond couvre l'arène: c'est le seul cas où l'auteur ne peut pas # dimensionner son image, l'arène ayant une taille fixe. var decor_sprite: AnimatedSprite2D = dungeon.decor._sprite _check(decor_sprite != null, "Le fond n'a produit aucun affichage") if decor_sprite != null: var decor_frame := decor_sprite.sprite_frames.get_frame_texture("default", 0) var covered := Vector2(decor_frame.get_size()) * decor_sprite.scale _check(covered.is_equal_approx(dungeon.ARENA_RECT.size), "Le fond ne couvre pas l'arène: %.0f x %.0f" % [covered.x, covered.y]) _check(decor_sprite.position.is_equal_approx(dungeon.ARENA_RECT.position + dungeon.ARENA_RECT.size * 0.5), "Le fond n'est pas centré sur l'arène") # Retirer le fond rend la main au dessin, sans laisser de noeud derrière. DataCatalog.ROOMS[decor_room] = decor_room_before if not decor_biome_before.is_empty(): DataCatalog.BIOMES[decor_biome] = decor_biome_before DataCatalog.DIMENSIONS[decor_dimension] = decor_dimension_before dungeon._refresh_decor() _check(not dungeon.decor.is_showing(), "Le fond reste affiché après retrait du visuel") _check(dungeon.decor.get_child_count() == 0, "Le porteur de décor a gardé son noeud") _check(dungeon.should_draw_floor(), "Le sol dessiné ne revient pas") DataCatalog.VISUALS = decor_visuals_before VisualHolder.clear_cache() # Le budget d'une salle est la statistique plus la marche du biome, et les # monstres tirés appartiennent tous à la table du biome. var room_one_enemies := enemies.size() # Le budget est un plafond, pas une garantie: quand les `spawn_max` du biome # ne peuvent pas l'absorber, une salle plus petite reste un résultat valide. var biome_budget := _achievable_population() _check(room_one_enemies == biome_budget, "La salle n'a pas été peuplée au budget du biome (%d au lieu de %d)" % [room_one_enemies, biome_budget]) var spawn_table: Dictionary = DiveMap.spawn_candidates() for spawned: Node in enemies: if spawned is Enemy: _check(spawn_table.has(spawned.enemy_id), "Un monstre absent de la table de la salle est apparu: %s" % spawned.enemy_id) # Renforts. La case décide du chemin emprunté, donc le test impose celui qu'il # vérifie plutôt que de supposer celui de la donnée publiée: # durée = 0 ils arrivent d'un bloc quand la vague d'ouverture faiblit # durée > 0 ils arrivent en vagues régulières sur la durée déclarée _check(dungeon.reinforcements_pending == DiveMap.reinforcement_population(), "Le budget de renfort n'a pas été mis de côté") for opener: Node in get_tree().get_nodes_in_group("enemy"): opener.queue_free() await get_tree().process_frame # Chemin minuté: rien n'arrive avant l'échéance, puis le budget sort en entier. var timed_budget := 4 dungeon.reinforcements_pending = timed_budget dungeon.reinforcement_waves_left = 2 dungeon.reinforcement_interval = 5.0 dungeon.reinforcement_timer = 5.0 dungeon.remaining_enemies = 1 dungeon._on_enemy_defeated(null) await get_tree().process_frame _check(dungeon.reinforcements_pending == timed_budget, "Une salle minutée a lâché ses renforts au seuil") dungeon._tick_reinforcements(1.0) _check(dungeon.reinforcements_pending == timed_budget, "Les renforts minutés sont arrivés avant l'échéance") dungeon._tick_reinforcements(5.0) await get_tree().process_frame _check(dungeon.reinforcements_pending < timed_budget, "La première vague minutée n'est pas arrivée") _check(dungeon.reinforcement_waves_left == 1, "La vague minutée n'a pas été décomptée") dungeon._tick_reinforcements(5.0) await get_tree().process_frame _check(dungeon.reinforcements_pending == 0, "La dernière vague minutée n'a pas épuisé le budget") _check(get_tree().get_nodes_in_group("enemy").size() == timed_budget or get_tree().get_nodes_in_group("enemy").size() > 0, "Les vagues minutées n'ont produit aucune créature") # Chemin au seuil: on remet le budget et on annule la durée. for spawned: Node in get_tree().get_nodes_in_group("enemy"): spawned.queue_free() await get_tree().process_frame dungeon.reinforcements_pending = DiveMap.reinforcement_population() dungeon.reinforcement_waves_left = 1 dungeon.reinforcement_interval = 0.0 dungeon.opening_wave_size = room_one_enemies dungeon.remaining_enemies = 1 dungeon._on_enemy_defeated(null) await get_tree().process_frame _check(dungeon.reinforcements_pending == 0, "Les renforts ne sont pas arrivés une fois la vague d'ouverture tombée") var reinforcement_wave := get_tree().get_nodes_in_group("enemy").size() _check(reinforcement_wave > 0, "Le budget de renfort n'a produit aucune créature") _check(not dungeon.is_room_cleared(), "La salle est déclarée nette alors que les renforts se battent encore") _check(not dungeon.is_awaiting_reward(), "Une récompense a été proposée avant la fin des renforts") # Salle nette: la récompense s'interpose avant la porte. for reinforcement: Node in get_tree().get_nodes_in_group("enemy"): reinforcement.queue_free() await get_tree().process_frame dungeon.remaining_enemies = 1 dungeon._on_enemy_defeated(null) await get_tree().process_frame _check(dungeon.is_room_cleared(), "Une salle vidée de ses renforts n'est pas considérée comme nettoyée") _check(dungeon.is_awaiting_reward(), "Aucune récompense n'a été proposée après une salle nette") _check(dungeon.get_interaction_prompt("next_room", "") == Loc.ui("ui.dungeon.reward_prompt"), "La porte n'invite pas à choisir une récompense") dungeon.handle_interaction("next_room") _check(dungeon.get_room_index() == 0, "La porte s'est ouverte avant le choix de récompense") var reward_offer: Array = dungeon.reward_menu.offered.duplicate() _check(reward_offer.size() == mini(DiveMap.reward_choices(), DataCatalog.REWARDS.size()), "L'offre ne compte pas le nombre de choix attendu") var distinct_offer := {} for offered_id: String in reward_offer: distinct_offer[offered_id] = true _check(distinct_offer.size() == reward_offer.size(), "La même récompense a été proposée deux fois dans une seule offre") var taken_reward: String = dungeon.choose_reward(0) _check(taken_reward == str(reward_offer[0]), "La récompense retenue n'est pas celle choisie") _check(not dungeon.is_awaiting_reward(), "Le choix de récompense n'a pas levé l'attente") var reward_modifiers := 0 for modifier: Dictionary in GameState.active_modifiers: if str(modifier.get("source", "")).begins_with("run:reward_"): reward_modifiers += 1 _check(reward_modifiers == (DataCatalog.REWARDS[taken_reward].modifiers as Array).size(), "La récompense choisie n'a pas posé ses modificateurs") _check(int(Registry.read("rewards.%s.total_taken" % taken_reward)) >= 1, "La récompense retenue n'a pas été comptée") _check(str(dungeon.get_station_status("next_room").lines[0]) == Loc.ui("ui.dungeon.door_open"), "La porte ne s'ouvre pas une fois la récompense choisie") _check(dungeon.get_interaction_prompt("next_room", "") == Loc.ui("ui.dungeon.door_prompt"), "La porte ouverte n'invite pas à entrer") # Le butin laissé au sol reste derrière. var abandoned: IngredientPickup = load("res://scenes/items/ingredient_pickup.tscn").instantiate() abandoned.setup("egg") dungeon.add_child(abandoned) await get_tree().process_frame _check(not get_tree().get_nodes_in_group("ingredient_pickup").is_empty(), "Le ramassable de test n'a pas rejoint son groupe") # Le decor se relit en entrant dans une case: declare maintenant, il doit # s allumer au franchissement sans qu on appelle quoi que ce soit. var wired_visuals_before: Dictionary = DataCatalog.VISUALS.duplicate(true) DataCatalog.VISUALS["smoke_decor"] = { "display_name": "Smoke Decor", "description": "", "kind": "frames", "path": "res://assets/ui/aim_cursor.svg", "columns": 1, "rows": 1, "count": 1, "fps": 8.0, "loop": true, "offset_x": 0.0, "offset_y": 0.0, "scale": 1.0, "flip_follows_facing": true, "shader": "", "shader_params": {}, "preview_image": "", } var wired_dimension := DiveMap.current_dimension_id() var wired_before: Dictionary = (DataCatalog.DIMENSIONS[wired_dimension] as Dictionary).duplicate(true) DataCatalog.DIMENSIONS[wired_dimension]["visuals"] = {"background": "smoke_decor"} _check(not dungeon.decor.is_showing(), "Le decor s allume avant meme le changement de case") var advanced_index: int = dungeon.advance_to_next_room() await get_tree().process_frame await get_tree().process_frame _check(advanced_index == 1 and DiveMap.level() == 2, "Le passage de porte n'a pas avancé d'une case dans la colonne") _check(dungeon.decor.is_showing(), "Entrer dans une case ne relit pas le decor") DataCatalog.DIMENSIONS[wired_dimension] = wired_before DataCatalog.VISUALS = wired_visuals_before dungeon._refresh_decor() VisualHolder.clear_cache() _check(get_tree().get_nodes_in_group("ingredient_pickup").is_empty(), "Le butin laissé au sol a suivi le joueur dans la salle suivante") _check(not dungeon.is_room_cleared(), "La salle suivante est arrivée déjà vide") enemies = get_tree().get_nodes_in_group("enemy") # Chaque case porte son propre effectif: comparer à la précédente supposerait # qu'elles sont réglées pareil, ce qui n'est vrai qu'avant équilibrage. _check(enemies.size() == _achievable_population(), "La salle suivante n'a pas été peuplée selon sa propre donnée") _check(dungeon.player.global_position.is_equal_approx(dungeon.ROOM_ENTRY), "Le joueur n'est pas replacé à l'entrée de la salle suivante") # Chaque case nomme son biome: traverser la colonne change de décor sans # qu'aucun code ne compte les biomes. _check(int(Registry.read("rooms.%s.total_entered" % DiveMap.current_room_id())) >= 1, "L'entrée dans une salle n'a pas été comptée") # Le contenu livré peut ne nommer qu'un seul biome — c'est le cas du filet de # secours, et de toute colonne encore à écrire. Le test peint le décor qu'il # vérifie plutôt que de dépendre de ce qui est publié. var alternate_biome := "" for biome_id: String in DataCatalog.BIOMES: if biome_id != DiveMap.current_biome_id(): alternate_biome = biome_id break _check(not alternate_biome.is_empty(), "Le catalogue ne contient qu'un seul biome") var repainted_rooms := {} var repaint_from := DiveMap.level() + 1 for column_room: String in DiveMap.column(): if int(DataCatalog.ROOMS[column_room].get("level", 0)) >= repaint_from: repainted_rooms[column_room] = str(DataCatalog.ROOMS[column_room].biome) DataCatalog.ROOMS[column_room]["biome"] = alternate_biome var column_biomes := {} for column_room: String in DiveMap.column(): column_biomes[str(DataCatalog.ROOMS[column_room].biome)] = true _check(column_biomes.size() > 1, "La colonne ne traverse qu'un seul biome") var biome_before := DiveMap.current_biome_id() var crossing := 0 while crossing < DiveMap.column().size() and DiveMap.current_biome_id() == biome_before: dungeon.remaining_enemies = 0 dungeon.reinforcements_pending = 0 dungeon.reward_pending = false dungeon.advance_to_next_room() await get_tree().process_frame crossing += 1 _check(DiveMap.current_biome_id() != biome_before, "La colonne n'a jamais changé de biome") _check(int(Registry.read("biomes.%s.total_entered" % DiveMap.current_biome_id())) >= 1, "L'entrée dans un biome n'a pas été comptée") for repainted: String in repainted_rooms: DataCatalog.ROOMS[repainted]["biome"] = str(repainted_rooms[repainted]) # La vague vivante est celle du nouveau biome: les vérifications de silhouette # plus bas travaillent dessus, et son budget se fige ici. enemies = get_tree().get_nodes_in_group("enemy") var live_wave_budget := _achievable_population() _check(enemies.size() == live_wave_budget, "La première salle du biome suivant n'a pas son budget") # La position dans la plongée appartient à la plongée: elle disparaît au retour. GameState.end_run() _check(dungeon.get_room_index() == 0 and DiveMap.level() == 1, "La position dans la plongée a survécu à la fin de la plongée") _check(GameState.get_scoped_value("run", "room_id", "") == "", "La salle courante a survécu à la fin de la plongée") _check(GameState.get_scoped_value("run", "dimension_id", "") == "", "La dimension de la plongée a survécu à son terme") var surviving_rewards := 0 for modifier: Dictionary in GameState.active_modifiers: if str(modifier.get("source", "")).begins_with("run:reward_"): surviving_rewards += 1 _check(surviving_rewards == 0, "Une récompense de plongée a survécu au retour au restaurant") GameState.set_scoped_value("run", "level", 2) for smoke_enemy: Node in enemies: if smoke_enemy is Enemy: smoke_enemy.health = maxf(smoke_enemy.health, 1000.0) for smoke_enemy: Node in enemies: if smoke_enemy is Enemy: smoke_enemy.health = maxf(smoke_enemy.health, 1000.0) var size_definition: Dictionary = DataCatalog.ENEMIES[smoke_enemy.enemy_id] _check(smoke_enemy.size_multiplier >= float(size_definition.min_size) and smoke_enemy.size_multiplier <= float(size_definition.max_size), "Enemy size was outside its configured min/max range") _check(smoke_enemy.size_multiplier >= 1.5, "Enemy was not at least 1.5 times its former body size") var dungeon_player: Player = dungeon.get_node("Player") dungeon_player.aim_at(dungeon_player.global_position + Vector2.RIGHT * 300.0) _check(dungeon_player.facing.is_equal_approx(Vector2.RIGHT), "Mouse-style world aiming did not update the player facing direction") dungeon_player.mouse_aim_enabled = false var stamina_before_attack: float = dungeon.stamina dungeon_player.attack_cooldown = 0.0 dungeon_player._perform_contextual_action() _check(dungeon_player.attack_cooldown > 0.0, "Unified dungeon action did not trigger an attack away from the exit") _check(stamina_before_attack - dungeon.stamina >= dungeon_player.attack_stamina_cost, "Dungeon attack did not spend its immediate stamina cost") dungeon_player.attack_cooldown = 0.0 var player_config: Dictionary = DataCatalog.active_player_config() _check(is_equal_approx(dungeon.max_stamina, float(player_config.starting_stamina) + 20.0), "Data-driven starting stamina or its upgrade was not applied") _check(is_equal_approx(dungeon_player.base_max_health, float(player_config.starting_hp)), "Player starting HP was not loaded from the active profile") _check(is_equal_approx(dungeon_player.base_move_speed, float(player_config.move_speed)), "La vitesse unique du profil joueur n'est pas chargée") _check(is_equal_approx(dungeon_player.get_current_move_speed(), dungeon_player.get_move_speed()), "Le déplacement applique encore un mode marche ou course caché") _check(not player_config.has("walk_speed") and not player_config.has("run_speed") and not InputMap.has_action("toggle_run"), "L'ancienne bascule marche/course existe encore dans les données ou les contrôles") _check(is_equal_approx(dungeon_player.dash_speed, float(player_config.dash_speed)) and is_equal_approx(dungeon_player.attack_range, float(player_config.attack_range)), "Player dash or attack configuration was not loaded from the active profile") var idle_stamina: float = dungeon.stamina await get_tree().create_timer(0.12).timeout _check(absf(dungeon.stamina - idle_stamina) < 0.02, "Standing still consumed dungeon stamina") var movement_start_stamina: float = dungeon.stamina var movement_event := InputEventAction.new() movement_event.action = "move_right" movement_event.pressed = true Input.parse_input_event(movement_event) await get_tree().create_timer(0.30).timeout movement_event.pressed = false Input.parse_input_event(movement_event) await get_tree().physics_frame var movement_consumption: float = movement_start_stamina - dungeon.stamina _check(movement_consumption > 0.02, "Le déplacement à vitesse unique ne consomme plus de stamina") # Master data can fill the dungeon with fast enemies, and a body pressed against # the player would mask the dash impulse. Clear the lane first: every check # below repositions the enemies it actually relies on. for parked_enemy: Node in enemies: (parked_enemy as Node2D).global_position = Vector2(-4000, -4000) dungeon_player.global_position = Vector2(500, 540) await get_tree().physics_frame await get_tree().physics_frame dungeon_player.facing = Vector2.RIGHT var dash_start_position: Vector2 = dungeon_player.global_position var dash_start_stamina: float = dungeon.stamina await _tap_action("dash") _check(dungeon_player.global_position.x > dash_start_position.x + 20.0, "Dash did not propel the player forward") _check(dash_start_stamina - dungeon.stamina >= dungeon_player.dash_stamina_cost, "Dash did not spend its immediate stamina cost") _check(dungeon_player.dash_cooldown > 0.0, "Dash did not start its cooldown") dungeon_player.dash_remaining = 0.0 _check(DataCatalog.loaded_from_master, "Game data fell back to built-in values instead of loading the master JSON") _check(DataCatalog.UPGRADE_THEMES.size() >= 5, "The upgrade tree did not load its preset themes") for upgrade_id: String in DataCatalog.UPGRADES: var upgrade_definition: Dictionary = DataCatalog.UPGRADES[upgrade_id] _check(DataCatalog.UPGRADE_THEMES.has(str(upgrade_definition.get("theme", ""))), "Upgrade '%s' references an unknown theme" % upgrade_id) _check(upgrade_definition.has("show_parent_link"), "Upgrade '%s' lost its parent-link visibility setting" % upgrade_id) var invalid_theme_style: Variant = DataCatalog._normalize_upgrade_theme_style({"background_color": "#ffffff"}) _check(invalid_theme_style == null, "An incomplete upgrade theme state was accepted") # --- Phase 1: espace d'adressage -------------------------------------- # Toute valeur du jeu se lit par une adresse; seules les cellules stockées # sont inscriptibles. _check(AddressCatalog.is_valid("fact.enemies_defeated"), "Une adresse de fait valide a été refusée") _check(AddressCatalog.is_valid("stat.attack_damage"), "Une adresse de statistique valide a été refusée") _check(AddressCatalog.is_valid("enemies.chicken_beast.spawn_weight"), "Une adresse d'entité valide a été refusée") _check(not AddressCatalog.is_valid("element.enemies.slime.total_killed"), "Le préfixe element. supprimé est encore accepté") _check(not AddressCatalog.is_valid("enemies.pas_un_monstre.spawn_weight"), "Une entité inexistante a été acceptée") _check(not AddressCatalog.is_valid("enemies.slime.pas_un_champ"), "Un champ inexistant a été accepté") _check(not AddressCatalog.is_valid("enemies.slime"), "Une adresse d'entité incomplète a été acceptée") _check(not AddressCatalog.is_valid("racine_inconnue.x"), "Une racine inconnue a été acceptée") # Les champs déjà définis dans l'outil deviennent adressables sans travail. _check(int(Registry.read("recipes.fried_egg.sale_price")) == 5, "Un champ d'auteur n'a pas été lu depuis le fichier maître") _check(int(Registry.read("enemies.chicken_beast.spawn_weight")) == 7, "Un champ d'auteur d'ennemi n'a pas été lu") _check(AddressCatalog.field_nature("recipes", "sale_price") == AddressCatalog.Nature.AUTHORED, "Un champ du fichier maître n'a pas été classé comme donnée d'auteur") # Champs calculés: une fonction, jamais une donnée stockée. _check(bool(Registry.read("upgrade_nodes.big_egg_core.purchased")) == GameState.has_upgrade("big_egg_core"), "Le champ calculé purchased ne suit pas l'état réel") _check(str(Registry.read("upgrade_nodes.sharp_fork.state")) == GameState.get_upgrade_state("sharp_fork"), "Le champ calculé state ne suit pas le résolveur d'upgrades") _check(bool(Registry.read("recipes.fried_egg.can_cook")) == GameState.can_cook("fried_egg"), "Le champ calculé can_cook ne suit pas l'inventaire") _check(int(Registry.read("ingredients.egg.owned")) == GameState.get_ingredient_amount("egg"), "Le champ calculé owned ne suit pas l'inventaire") _check(is_equal_approx(float(Registry.read("stat.attack_damage")), float(GameState.resolve_game_variable("attack_damage"))), "Une statistique lue par adresse diffère du résolveur") # Lecture uniforme, écriture étroite. _check(not AddressCatalog.is_writable("stat.attack_damage"), "Une statistique calculée est devenue inscriptible") _check(not AddressCatalog.is_writable("recipes.fried_egg.sale_price"), "Une donnée d'auteur est devenue inscriptible") _check(not AddressCatalog.is_writable("upgrade_nodes.big_egg_core.purchased"), "Un champ calculé est devenu inscriptible") _check(AddressCatalog.is_writable("fact.enemies_defeated"), "Un fait stocké n'est pas inscriptible") _check(AddressCatalog.is_writable("enemies.slime.total_killed"), "Un compteur vivant n'est pas inscriptible") _check(not Registry.write("stat.attack_damage", 999.0), "Une écriture vers une valeur calculée a été acceptée") # Compteurs par entité: aller-retour lecture/écriture. var counter_before := int(Registry.read("enemies.slime.total_killed")) _check(counter_before == 0, "Un compteur vivant ne démarre pas à zéro") GameState.add_entity_counter("enemies", "slime", "total_killed", 3) _check(int(Registry.read("enemies.slime.total_killed")) == 3, "Un compteur par entité n'a pas été incrémenté") _check(Registry.write("enemies.slime.total_killed", 0), "Un compteur vivant n'a pas accepté une écriture") _check(int(Registry.read("enemies.slime.total_killed")) == 0, "Un compteur par entité n'a pas été remis à zéro") # Phase 7: le runtime ne connaît plus que les adresses. _check(Registry.condition_path({"path": "fact.dishes_served"}) == "fact.dishes_served", "Une condition adressée n'a pas été lue telle quelle") _check(Registry.condition_path({"source": "fact", "id": "dishes_served"}).is_empty(), "Le runtime traduit encore l'ancien couple source/id") _check(not GameState.conditions_met([{"source": "fact", "id": "dishes_served", "operator": ">=", "value": 0}]), "Une condition au format hérité est encore évaluée par le runtime") _check(DataCatalog._normalize_upgrade_conditions([{"source": "fact", "id": "dishes_served", "operator": ">=", "value": 1}], DataCatalog.GAME_VARIABLES, DataCatalog.GAME_FACTS, DataCatalog.UPGRADES) == null, "Le validateur accepte encore une condition au format hérité") _check(ModifierPool.layer_of({"target": "coefficient_bonus"}) == "flat", "Le pool traduit encore l'ancien canal target") # Une adresse inconnue ne doit jamais faire planter une condition. _check(not GameState.conditions_met([{"path": "enemies.inexistant.total_killed", "operator": ">=", "value": 1}]), "Une adresse invalide dans une condition n'a pas été rejetée proprement") # --- Phase 2: compteurs unifiés --------------------------------------- # Le dictionnaire `statistics` a disparu: les faits sont l'unique magasin. _check(not ("statistics" in GameState), "Le magasin de compteurs en double statistics existe encore") # Migration v5 -> v6: le fait fait foi, la statistique héritée ne comble que # les faits absents (décision D8). # La séquence reproduit load_game(): les faits sauvegardés sont chargés, puis # la migration ne comble que ce qui manque. var legacy_save := {"version": 5, "statistics": {"enemies_defeated": 9, "dishes_served": 4}, "fact_values": {"enemies_defeated": 2}} GameState.fact_values = legacy_save.fact_values.duplicate() GameState._ensure_runtime_values() GameState._migrate_legacy_statistics(legacy_save) _check(int(GameState.get_fact_value("enemies_defeated")) == 2, "La migration v5 a écrasé un fait par la statistique héritée") _check(int(GameState.get_fact_value("dishes_served")) == 4, "La migration v5 n'a pas repris une statistique héritée sans fait correspondant") GameState._migrate_legacy_statistics({"version": 6, "statistics": {"enemies_defeated": 99}, "fact_values": {}}) _check(int(GameState.get_fact_value("enemies_defeated")) == 2, "Une sauvegarde déjà en version 6 a été migrée une seconde fois") GameState.fact_values.clear() GameState._ensure_runtime_values() # Les compteurs par entité survivent à un aller-retour de sauvegarde. On # n'efface pas la table: le donjon a déjà alimenté ses propres compteurs et # ils sont vérifiés plus loin. # --- Phase 3: schéma 27, butins, bulles, thèmes et conditions adressées ---- _check(DataCatalog.SCHEMA_VERSION == 27, "Le schéma de données n'est pas en version 27") var drop_probe: Variant = DataCatalog._normalize_drop_list([ {"ingredient_id": "egg", "chance": 100.0}, {"ingredient_id": "egg", "chance": 10.0}, ], DataCatalog.INGREDIENTS) _check(drop_probe is Array and drop_probe.size() == 2 and drop_probe[0].ingredient_id == "egg" and drop_probe[0].chance == 100.0 and drop_probe[1].ingredient_id == "egg" and drop_probe[1].chance == 10.0, "Deux jets de butin identiques sont fusionnés ou refusés") _check(DataCatalog._normalize_drop_list([{"ingredient_id": "egg", "chance": 100.1}], DataCatalog.INGREDIENTS) == null, "Une chance de butin au-dessus de 100 % est acceptée") _check(DataCatalog._normalize_drop_list([{"ingredient_id": "unknown", "chance": 20.0}], DataCatalog.INGREDIENTS) == null, "Un matériau de butin inconnu est accepté") _check(not Enemy.drop_roll_succeeds(0.0, 0.0), "Un butin à 0 % réussit") _check(Enemy.drop_roll_succeeds(25.0, 0.249), "Un jet sous la fréquence échoue") _check(not Enemy.drop_roll_succeeds(25.0, 0.25), "Un jet à la limite de la fréquence réussit") _check(Enemy.drop_roll_succeeds(100.0, 1.0), "Un butin à 100 % échoue") _check(is_equal_approx(float(GameState.resolve_game_variable("drop_count_multiplier")), 1.0), "Le multiplicateur de jets ne démarre pas à 1") _check(Enemy.drop_attempt_count(0.0, 0.0) == 0, "Un multiplicateur nul produit encore un jet") _check(Enemy.drop_attempt_count(0.5, 0.49) == 1 and Enemy.drop_attempt_count(0.5, 0.5) == 0, "Un multiplicateur 0,5 ne donne pas exactement 50 % de chance d'un jet") _check(Enemy.drop_attempt_count(1.25, 0.24) == 2 and Enemy.drop_attempt_count(1.25, 0.25) == 1, "Un multiplicateur 1,25 ne donne pas un jet plus 25 % d'un second") _check(Enemy.drop_attempt_count(2.0, 1.0) == 2, "Un multiplicateur entier 2 ne produit pas deux jets") _check(is_equal_approx(float(GameState.resolve_game_variable("drop_magnet_range")), float(DataCatalog.GAME_VARIABLES.drop_magnet_range.base_value)), "La portée magnétique publiée ne traverse pas le résolveur de statistiques") _check(is_equal_approx(float(GameState.resolve_game_variable("drop_magnet_speed")), float(DataCatalog.GAME_VARIABLES.drop_magnet_speed.base_value)), "La vitesse magnétique publiée ne traverse pas le résolveur de statistiques") var magnet_origin := Vector2.ZERO var magnet_target := Vector2(100.0, 0.0) _check(IngredientPickup.magnetized_position(magnet_origin, magnet_target, 0.0, 520.0, 1.0).is_equal_approx(magnet_origin), "Une portée nulle attire quand même un butin") _check(IngredientPickup.magnetized_position(magnet_origin, magnet_target, 99.0, 520.0, 1.0).is_equal_approx(magnet_origin), "Un butin hors de la portée de magnétisme se déplace") _check(IngredientPickup.magnetized_position(magnet_origin, magnet_target, 100.0, 520.0, 0.1).x > 0.0, "Un butin sur la limite de portée n'est pas attiré") _check(IngredientPickup.magnetized_position(magnet_origin, magnet_target, 100.0, 0.0, 1.0).is_equal_approx(magnet_origin), "Une vitesse magnétique nulle déplace encore le butin") _check(is_zero_approx(IngredientPickup.ballistic_height(0.0, 180.0, 34.0)) and is_equal_approx(IngredientPickup.ballistic_height(90.0, 180.0, 34.0), 34.0) and is_zero_approx(IngredientPickup.ballistic_height(180.0, 180.0, 34.0)), "La trajectoire magnétique ne forme pas une parabole sol-sommet-sol") var speech_collections := { "player_configs": DataCatalog.PLAYER_CONFIGS, "enemies": DataCatalog.ENEMIES, "customer_profiles": DataCatalog.CUSTOMER_PROFILES, "employees": DataCatalog.EMPLOYEES, } for collection: String in DataCatalog.SPEECH_BUBBLE_TRIGGERS: var actor_definitions: Dictionary = speech_collections[collection] for actor_id: String in actor_definitions: var speech: Dictionary = actor_definitions[actor_id].get("speech_bubbles", {}) _check(not speech.is_empty(), "Une configuration de bulles manque: %s / %s" % [collection, actor_id]) _check(speech.get("triggers", {}) is Dictionary, "Les declencheurs de bulles ne forment pas une table: %s / %s" % [collection, actor_id]) for trigger_id: String in DataCatalog.SPEECH_BUBBLE_TRIGGERS[collection]: _check(speech.get("triggers", {}).has(trigger_id), "Un declencheur de bulle manque: %s / %s / %s" % [collection, actor_id, trigger_id]) var bubble_source := Node2D.new() add_child(bubble_source) var bubble := SPEECH_BUBBLE_SCRIPT.new() bubble_source.add_child(bubble) bubble.call("bind_to_actor", bubble_source, { "check_interval": 8.0, "cooldown": 0.0, "duration": 1.0, "triggers": { "spawn": [{"text": "Certain", "chance": 100.0}], "hurt": [{"text": "Rare", "chance": 0.5}], "random": [], }, }, Vector2(0.0, -30.0)) var guaranteed_rolls: Array[float] = [99.999] var rare_success_rolls: Array[float] = [0.49] var rare_failure_rolls: Array[float] = [0.5] _check(bool(bubble.call("trigger_event", "spawn", guaranteed_rolls)), "Une bulle a 100 % n'est pas garantie") _check(str(bubble.get("last_message")) == "Certain" and bubble.visible, "La bulle garantie n'a pas affiche son texte") var bubble_rect: Rect2 = bubble.get("_bubble_rect") var bubble_text_rect: Rect2 = bubble.get("_text_rect") _check(bubble.get_child_count() == 0, "Le texte de la bulle utilise encore un Control dans un repere distinct") _check(bubble_rect.encloses(bubble_text_rect) and bubble_text_rect.position.y < 0.0, "Le texte n'est pas place dans le corps de la bulle") await get_tree().process_frame _check(bool(bubble.call("trigger_event", "hurt", rare_success_rolls)), "Une chance de 0,5 % refuse un jet de 0,49") _check(not bool(bubble.call("trigger_event", "hurt", rare_failure_rolls)), "Une chance de 0,5 % est traitee comme 50 % ou inclut sa borne") _check(DataCatalog._normalize_speech_bubbles({"triggers": {"hurt": [{"text": "Non", "chance": 100.1}]}}, "player_configs") == null, "Le validateur accepte une chance de bulle superieure a 100 %") bubble_source.queue_free() var conditions_seen := 0 for upgrade_id: String in DataCatalog.UPGRADES: for group: String in ["visibility_conditions", "reveal_conditions", "unlock_conditions"]: for condition: Dictionary in DataCatalog.UPGRADES[upgrade_id].get(group, []): conditions_seen += 1 _check(condition.has("path"), "Une condition chargée n'a pas d'adresse: %s / %s" % [upgrade_id, group]) _check(not condition.has("source"), "Une condition chargée conserve l'ancien couple source/id") _check(AddressCatalog.is_valid(str(condition.get("path", ""))), "Une condition chargée porte une adresse invalide: %s" % str(condition.get("path", ""))) _check(conditions_seen > 0, "Aucune condition n'a été chargée depuis le fichier maître") # --- Apparition pondérée ---------------------------------------------- # Un poids n'est plus une quantité: il décide de la nature des créatures, # le budget de salle décide de leur nombre. var plan_rng := RandomNumberGenerator.new() plan_rng.seed = 20260817 var plan_pool := { "a": {"spawn_weight": 10.0, "spawn_min": 0, "spawn_max": 99}, "b": {"spawn_weight": 1.0, "spawn_min": 0, "spawn_max": 99}, } var plan_result := SpawnPlanner.plan(plan_pool, 12, plan_rng) _check(plan_result.size() == 12, "Le plan d'apparition n'a pas rempli le budget de la salle") var count_a := 0 for id: String in plan_result: if id == "a": count_a += 1 _check(count_a > plan_result.size() / 2, "Le poids ne se traduit pas par une part plus grande des tirages") # Le plafond est respecté, même avec un poids énorme. plan_pool["a"]["spawn_max"] = 3 plan_result = SpawnPlanner.plan(plan_pool, 12, plan_rng) var capped_a := 0 for id: String in plan_result: if id == "a": capped_a += 1 _check(capped_a <= 3, "Un monstre a dépassé son plafond par salle") # Le minimum est garanti, même avec un poids nul. plan_pool["c"] = {"spawn_weight": 0.0, "spawn_min": 2, "spawn_max": 2} plan_result = SpawnPlanner.plan(plan_pool, 12, plan_rng) var forced_c := 0 for id: String in plan_result: if id == "c": forced_c += 1 _check(forced_c == 2, "Un minimum garanti n'a pas été honoré malgré un poids nul") # Conflit: quand les minimums dépassent le budget, le poids tranche. var crowded := { "heavy": {"spawn_weight": 50.0, "spawn_min": 6, "spawn_max": 9}, "light": {"spawn_weight": 1.0, "spawn_min": 6, "spawn_max": 9}, } plan_result = SpawnPlanner.plan(crowded, 6, plan_rng) _check(plan_result.size() == 6, "Le budget n'a pas été respecté quand les minimums le dépassent") var heavy_share := 0 for id: String in plan_result: if id == "heavy": heavy_share += 1 _check(heavy_share > 3, "Le poids n'a pas arbitré le conflit entre minimums") # spawn_max à 0 exclut totalement un monstre. plan_result = SpawnPlanner.plan({"never": {"spawn_weight": 99.0, "spawn_min": 0, "spawn_max": 0}}, 8, plan_rng) _check(plan_result.is_empty(), "Un monstre à spawn_max nul est quand même apparu") # Un budget nul ne produit rien, sans planter. _check(SpawnPlanner.plan(plan_pool, 0, plan_rng).is_empty(), "Un budget nul a produit des créatures") # Les données réelles restent planifiables. _check(SpawnPlanner.plan(DataCatalog.ENEMIES, 10, plan_rng).size() > 0, "Le catalogue réel ne produit aucune créature") for enemy_id: String in DataCatalog.ENEMIES: var definition: Dictionary = DataCatalog.ENEMIES[enemy_id] _check(definition.has("spawn_weight") and definition.has("spawn_min") and definition.has("spawn_max"), "Un monstre n'a pas ses champs d'apparition: %s" % enemy_id) _check(int(definition.spawn_max) >= int(definition.spawn_min), "Un monstre a un plafond sous son minimum: %s" % enemy_id) # --- Portées de progression ------------------------------------------- # Trois durées de vie: run (une plongée), world (un monde), meta (toujours). _check(DataCatalog.PROGRESS_SCOPES.has("dimension") and DataCatalog.PROGRESS_SCOPES.has("meta"), "Le vocabulaire de portée n'est pas déclaré") for upgrade_id: String in DataCatalog.UPGRADES: _check(DataCatalog.PROGRESS_SCOPES.has(str(DataCatalog.UPGRADES[upgrade_id].get("scope", ""))), "Un nœud d'upgrade n'a pas de portée valide: %s" % upgrade_id) for fact_id: String in DataCatalog.GAME_FACTS: _check(DataCatalog.PROGRESS_SCOPES.has(str(DataCatalog.GAME_FACTS[fact_id].get("scope", ""))), "Un fait n'a pas de portée valide: %s" % fact_id) # Une portée absente retombe sur `world`: un oubli ne rend jamais permanent. _check(DataCatalog._progress_scope({}) == "dimension", "Une portée absente n'est pas retombée sur dimension") _check(DataCatalog._progress_scope({"scope": "nawak"}) == "dimension", "Une portée inconnue n'est pas retombée sur dimension") # Fin de plongée: seul ce qui appartient à la plongée disparaît. GameState.set_scoped_value("run", "room_index", 3) GameState.add_modifier("run:lucky_charm", "attack_damage", "flat", 5.0) GameState.add_modifier("upgrade:permanent", "attack_damage", "flat", 2.0) GameState.end_run() _check(GameState.get_scoped_value("run", "room_index", -1) == -1, "La fin de plongée n'a pas vidé la portée run") _check(not GameState.has_modifier_source("run:lucky_charm"), "Un bonus de plongée a survécu au retour au restaurant") _check(GameState.has_modifier_source("upgrade:permanent"), "La fin de plongée a retiré un modificateur qui ne lui appartient pas") GameState.remove_modifiers_by_source("upgrade:permanent") # Fin de dimension: les acquis `meta` survivent, le reste repart à zéro. var meta_test_id := "smoke_meta_upgrade" DataCatalog.UPGRADES[meta_test_id] = {"display_name": "Smoke meta", "scope": "meta", "modifiers": [], "cost": 0} DataCatalog.GAME_FACTS["smoke_meta_fact"] = {"display_name": "Smoke meta fact", "type": "int", "default_value": 0, "category": "test", "description": "", "scope": "meta"} # complete_dimension() vide légitimement les compteurs, mais la vague du donjon a # déjà alimenté les siens et ils sont vérifiés plus loin: on les restaure. var counters_before := GameState.entity_counters.duplicate(true) var coins_before := GameState.coins var ingredients_before := GameState.ingredients.duplicate(true) var facts_before := GameState.fact_values.duplicate(true) var dimension_before := GameState.get_dimension_index() GameState.purchased_upgrades.append(meta_test_id) GameState.purchased_upgrades.append("sharp_fork") GameState.coins = 500 GameState.add_ingredient("egg", 4) GameState.set_fact_value("dishes_served", 12) GameState.fact_values["smoke_meta_fact"] = 7 GameState.add_entity_counter("enemies", "slime", "total_killed", 9) # La brigade suit la portée déclarée par sa fiche: un employé `dimension` se # rachète au cycle suivant, un employé `meta` reste. C'est la seule dépense # récurrente du système, et elle se vérifie sur la vraie fin de dimension. var brigade_scope_source := "test:brigade_scope" GameState.remove_modifiers_by_source(brigade_scope_source) GameState.add_modifier(brigade_scope_source, "server_count", "flat", 2.0) var temporary_hire := "" var permanent_hire := "" for id: String in DataCatalog.EMPLOYEES: if str(DataCatalog.EMPLOYEES[id].role) != "server": continue if temporary_hire.is_empty(): temporary_hire = id elif permanent_hire.is_empty(): permanent_hire = id var scope_backup := {} if not temporary_hire.is_empty(): scope_backup[temporary_hire] = str(DataCatalog.EMPLOYEES[temporary_hire].get("scope", "dimension")) DataCatalog.EMPLOYEES[temporary_hire]["scope"] = "dimension" if not permanent_hire.is_empty(): scope_backup[permanent_hire] = str(DataCatalog.EMPLOYEES[permanent_hire].get("scope", "dimension")) DataCatalog.EMPLOYEES[permanent_hire]["scope"] = "meta" GameState.coins = 100000 for id: String in scope_backup: _check(GameState.hire_employee(id), "L'employé %s n'a pas pu être embauché pour le test de portée" % id) var next_dimension := GameState.complete_dimension() if not temporary_hire.is_empty(): _check(not GameState.employee_is_hired(temporary_hire), "Un employé de portée dimension a survécu au changement de dimension") if not permanent_hire.is_empty(): _check(GameState.employee_is_hired(permanent_hire), "Un employé de portée meta a été renvoyé au changement de dimension") GameState.dismiss_employee(permanent_hire) for id: String in scope_backup: DataCatalog.EMPLOYEES[id]["scope"] = str(scope_backup[id]) GameState.remove_modifiers_by_source(brigade_scope_source) _check(GameState.purchased_upgrades.has(meta_test_id), "Une upgrade spéciale n'a pas survécu à la fin de la dimension") _check(not GameState.purchased_upgrades.has("sharp_fork"), "Une upgrade générale a survécu à la fin de la dimension") _check(int(GameState.get_fact_value("smoke_meta_fact")) == 7, "Un fait de portée meta a été remis à zéro") _check(int(GameState.get_fact_value("dishes_served")) == 0, "Un fait de portée dimension n'a pas été remis à zéro") _check(GameState.coins == 0 and GameState.get_ingredient_amount("egg") == 0, "L'économie de la dimension n'a pas été remise à zéro") _check(int(Registry.read("enemies.slime.total_killed")) == 0, "Les compteurs par entité ont survécu à la fin de la dimension") _check(next_dimension == dimension_before + 1 and GameState.get_dimension_index() == next_dimension, "Le numéro de dimension n'a pas avancé") _check(not GameState.menu_enabled_recipes.is_empty(), "Le menu n'a pas été regarni après la remise à zéro") DataCatalog.UPGRADES.erase(meta_test_id) DataCatalog.GAME_FACTS.erase("smoke_meta_fact") GameState.purchased_upgrades.clear() GameState.set_scoped_value("meta", "dimension_index", 0) GameState.entity_counters = counters_before GameState.coins = coins_before GameState.ingredients = ingredients_before GameState.fact_values = facts_before GameState.fact_values.erase("smoke_meta_fact") # --- Cycle des dimensions, verrou et épreuve --------------------------- # Tout ce bloc écrit dans les portées `run` et `meta`; il se referme sur une # remise à zéro explicite pour ne pas contaminer les vérifications suivantes. var cycle_ids := DiveMap.cycle_dimension_ids() _check(cycle_ids.size() >= 4, "Le cycle ne compte pas au moins quatre dimensions") var cycle_orders: Array[int] = [] for cycle_id: String in cycle_ids: cycle_orders.append(int(DataCatalog.DIMENSIONS[cycle_id].get("order", 1))) var orders_sorted := cycle_orders.duplicate() orders_sorted.sort() _check(cycle_orders == orders_sorted, "Les dimensions du cycle ne sont pas triées par ordre") for dimension_id: String in DataCatalog.DIMENSIONS: if int(DataCatalog.DIMENSIONS[dimension_id].get("order", 1)) == 0: _check(not cycle_ids.has(dimension_id), "Une dimension d'ordre 0 est entrée dans le cycle: %s" % dimension_id) # Le cycle boucle: passer la dernière dimension ramène à la première. _check(DiveMap.cycle_dimension_id(cycle_ids.size()) == cycle_ids[0], "Le cycle des dimensions ne reboucle pas") # La dimension de la plongée est figée à l'entrée. GameState.clear_run_values() GameState.set_scoped_value("meta", "gate_pending", "") GameState.set_scoped_value("meta", "dimension_index", 0) _check(DiveMap.next_dimension_id() == cycle_ids[0], "La prochaine plongée ne vise pas la dimension du cycle") DiveMap.begin_dive() _check(str(GameState.get_scoped_value("run", "dimension_id", "")) == cycle_ids[0], "La dimension n'a pas été figée dans la portée run") GameState.set_scoped_value("meta", "dimension_index", 2) _check(DiveMap.current_dimension_id() == cycle_ids[0], "La dimension de la plongée a changé en cours de route") GameState.set_scoped_value("meta", "dimension_index", 0) # Condition de salle: c'est ce qui remplace l'ancien verrou de dimension. Une # case scellée est une case dont les conditions ne sont pas remplies, et son # crochet `on_blocked` est ce qui convoque l'épreuve — sans code dédié. GameState.clear_run_values() GameState.set_scoped_value("meta", "gate_pending", "") GameState.set_scoped_value("run", "dimension_id", cycle_ids[0]) var gated_level := 3 var gated_room := DiveMap.base_room_id(cycle_ids[0], gated_level) _check(not gated_room.is_empty(), "La colonne n'a pas de case à sceller pour le test") var gated_before: Dictionary = (DataCatalog.ROOMS[gated_room] as Dictionary).duplicate(true) DataCatalog.ROOMS[gated_room]["conditions"] = [{"path": "fact.master_fork_owned", "operator": "truthy", "value": true}] DataCatalog.ROOMS[gated_room]["on_blocked"] = [{"op": "set", "path": "meta.gate_pending", "value": "fork_trial"}] GameState.set_fact_value("master_fork_owned", false) GameState.set_scoped_value("run", "level", gated_level - 1) GameState.set_scoped_value("run", "room_id", "") _check(not DiveMap.room_is_open(gated_room), "La case scellée est considérée ouverte") var blocked_step: Dictionary = DiveMap.advance() _check(int(blocked_step.step) == int(DiveMap.Step.BLOCKED), "La porte scellée a laissé passer la plongée") _check(DiveMap.level() == gated_level - 1, "La case scellée a quand même fait avancer la plongée") _check(DiveMap.gate_pending_id() == "fork_trial", "Le crochet on_blocked n'a pas convoqué l'épreuve") # L'épreuve passe devant le cycle à la plongée suivante. GameState.clear_run_values() _check(DiveMap.next_dimension_id() == "fork_trial", "La plongée suivante ne vise pas l'épreuve convoquée") Registry.run_hook("dimensions", "fork_trial", "on_complete") _check(bool(GameState.get_fact_value("master_fork_owned")), "L'épreuve n'a pas accordé son fait") DiveMap.clear_gate_trial() _check(DiveMap.gate_pending_id().is_empty(), "L'épreuve terminée reste convoquée") # Le fait acquis, la même porte s'ouvre. GameState.set_scoped_value("run", "dimension_id", cycle_ids[0]) GameState.set_scoped_value("run", "level", gated_level - 1) GameState.set_scoped_value("run", "room_id", "") _check(DiveMap.room_is_open(gated_room), "La case reste scellée malgré le fait acquis") _check(int(DiveMap.advance().step) == int(DiveMap.Step.ROOM), "La porte ouverte n'a pas mené à la case suivante") _check(DiveMap.level() == gated_level, "La porte ouverte n'a pas atteint la case scellée") DataCatalog.ROOMS[gated_room] = gated_before GameState.set_fact_value("master_fork_owned", false) # Sortie alternative: un tunnel secret saute des cases, ou vise une case # précise. C'est le même champ de sortie, avec deux façons de l'écrire. GameState.clear_run_values() GameState.set_scoped_value("run", "dimension_id", cycle_ids[0]) GameState.set_scoped_value("run", "level", 1) GameState.set_scoped_value("run", "room_id", "") var exit_room := DiveMap.current_room_id() var exit_before: Dictionary = (DataCatalog.ROOMS[exit_room] as Dictionary).duplicate(true) _check(DiveMap.exit_target_level() == 2, "Une salle sans sortie déclarée ne mène pas à la case suivante") # Un raccourci ne se démontre que sur une colonne assez longue, et la colonne # livrée peut être aussi courte que le filet de secours. Le test pose les # cases dont il a besoin au lieu de figer un numéro. var exit_padding: Array[String] = [] var padded_level := DiveMap.total_rooms() while DiveMap.total_rooms() < 10: padded_level += 1 var filler: Dictionary = exit_before.duplicate(true) filler["level"] = padded_level filler["ends_dimension"] = false filler["replaces"] = [] filler["conditions"] = [] filler["exit_offset"] = 1 filler["exit_level"] = 0 var filler_id := "smoke_exit_l%d" % padded_level DataCatalog.ROOMS[filler_id] = filler exit_padding.append(filler_id) DataCatalog.ROOMS[exit_room]["exit_offset"] = 5 _check(DiveMap.exit_target_level() == 6, "Le pas de sortie n'a pas été suivi") _check(int(DiveMap.advance().step) == int(DiveMap.Step.ROOM) and DiveMap.level() == 6, "Le raccourci n'a pas sauté les cases annoncées") GameState.set_scoped_value("run", "level", 1) GameState.set_scoped_value("run", "room_id", "") # Une adresse directe l'emporte sur le pas. DataCatalog.ROOMS[exit_room]["exit_level"] = 9 _check(DiveMap.exit_target_level() == 9, "L'adresse directe n'a pas primé sur le pas") _check(int(DiveMap.advance().step) == int(DiveMap.Step.ROOM) and DiveMap.level() == 9, "L'adresse directe n'a pas été atteinte") # Une sortie au-delà de la colonne termine la plongée plutôt que de sortir # de la grille. GameState.set_scoped_value("run", "level", 1) GameState.set_scoped_value("run", "room_id", "") DataCatalog.ROOMS[exit_room]["exit_level"] = DiveMap.total_rooms() + 5 _check(int(DiveMap.advance().step) == int(DiveMap.Step.END), "Une sortie hors colonne n'a pas terminé la plongée") _check(DiveMap.level() == 1, "Une sortie hors colonne a quand même déplacé la plongée") DataCatalog.ROOMS[exit_room] = exit_before for filler_id: String in exit_padding: DataCatalog.ROOMS.erase(filler_id) # Fin de dimension: c'est la salle qui la déclare, pas une salle de boss # codée ailleurs. GameState.clear_run_values() GameState.set_scoped_value("run", "dimension_id", cycle_ids[0]) var column_size := DiveMap.total_rooms() _check(column_size > 0, "La dimension du cycle n'a aucune case") var last_room := DiveMap.base_room_id(cycle_ids[0], column_size) # Le contenu écrit décide où se termine la colonne — et peut ne pas encore le # déclarer. Le test pose la fin qu'il vérifie, puis la retire: c'est la # mécanique qui est en cause ici, pas l'équilibrage. var closing_before := {} for room_id: String in DiveMap.column(): closing_before[room_id] = bool(DataCatalog.ROOMS[room_id].get("ends_dimension", false)) DataCatalog.ROOMS[room_id]["ends_dimension"] = room_id == last_room _check(DiveMap.ends_dimension(last_room), "La dernière case de la colonne ne clôt pas la dimension") var closing := 0 for room_id: String in DiveMap.column(): if DiveMap.ends_dimension(room_id): closing += 1 _check(closing == 1, "La colonne déclare %d fins de dimension au lieu d'une" % closing) for room_id: String in closing_before: DataCatalog.ROOMS[room_id]["ends_dimension"] = bool(closing_before[room_id]) # --- Salles écrites: la case dicte, le biome retombe -------------------- GameState.clear_run_values() GameState.set_scoped_value("run", "dimension_id", cycle_ids[0]) GameState.set_scoped_value("run", "level", 1) GameState.set_scoped_value("run", "room_id", "") var first_room := DiveMap.current_room_id() _check(not first_room.is_empty(), "La première case ne résout aucune salle") _check(int(DataCatalog.ROOMS[first_room].level) == 1 and str(DataCatalog.ROOMS[first_room].dimension) == cycle_ids[0], "La salle résolue n'occupe pas la case demandée") # Une case qui déclare ses nombres l'emporte sur la statistique. var authored: Dictionary = (DataCatalog.ROOMS[first_room] as Dictionary).duplicate(true) DataCatalog.ROOMS[first_room]["spawn_initial"] = 7 DataCatalog.ROOMS[first_room]["spawn_reinforcement"] = 3 DataCatalog.ROOMS[first_room]["reward_choices"] = 2 DataCatalog.ROOMS[first_room]["initial_enemies"] = {"slime": 5.0} DataCatalog.ROOMS[first_room]["reinforcement_enemies"] = {"boar_brute": 1.0} _check(DiveMap.room_population() == 7, "La salle n'impose pas son propre effectif") _check(DiveMap.reinforcement_population() == 3, "La salle n'impose pas son propre renfort") _check(DiveMap.reward_choices() == 2, "La salle n'impose pas son propre nombre de choix") _check(DiveMap.spawn_candidates().keys() == ["slime"], "La salle n'impose pas sa table d'ouverture") _check(DiveMap.reinforcement_candidates().keys() == ["boar_brute"], "La salle n'impose pas sa table de renfort") # Zéro et table vide veulent dire « demande au-dessus ». Il n'y a plus que # deux niveaux: la salle, puis le défaut global. Le biome n'intervient plus. DataCatalog.ROOMS[first_room]["spawn_initial"] = 0 DataCatalog.ROOMS[first_room]["initial_enemies"] = {} DataCatalog.ROOMS[first_room]["reinforcement_enemies"] = {} _check(DiveMap.room_population() == maxi(0, int(GameState.resolve_game_variable("room_spawn_initial"))), "Une salle sans effectif ne retombe pas exactement sur la statistique") _check(DiveMap.spawn_candidates().size() == DataCatalog.ENEMIES.size(), "Une salle sans table ne tire pas dans tout le bestiaire") _check(DiveMap.reinforcement_candidates().size() == DiveMap.spawn_candidates().size(), "Le renfort sans table ne reprend pas la table d'ouverture") # Un biome n'est plus qu'une palette: il ne porte plus ni créatures ni effectif. for biome_id: String in DataCatalog.BIOMES: var palette: Dictionary = DataCatalog.BIOMES[biome_id] _check(not palette.has("enemies") and not palette.has("population_bonus"), "Le biome '%s' porte encore une table ou une marche" % biome_id) _check(palette.has("floor_color") and palette.has("wall_color") and palette.has("accent_color"), "Le biome '%s' a perdu son habillage" % biome_id) DataCatalog.ROOMS[first_room] = authored # --- Salles spéciales: arbitrage et plafond par plongée ----------------- # Règle: la probabilité la plus haute décide s'il y a remplacement, puis le # choix se fait au poids proportionnel. La somme n'entre jamais en jeu, donc # un total au-delà de 100 % n'est pas une erreur. var special_a := "smoke_special_a" var special_b := "smoke_special_b" # La case de test se choisit parmi celles qu'aucune salle spéciale livrée ne # vise déjà: figer un numéro casserait le test au premier ajout de contenu. var target_level := 0 var spare_level := 0 for candidate_level: int in range(1, DiveMap.total_rooms() + 1): if not DiveMap.candidates_for(cycle_ids[0], candidate_level).is_empty(): continue if target_level == 0: target_level = candidate_level elif spare_level == 0: spare_level = candidate_level break _check(target_level > 0 and spare_level > 0, "Aucune case libre pour poser les salles spéciales de test") var special_template: Dictionary = (DataCatalog.ROOMS[first_room] as Dictionary).duplicate(true) special_template["dimension"] = "" special_template["level"] = 0 for special_id: String in [special_a, special_b]: DataCatalog.ROOMS[special_id] = special_template.duplicate(true) DataCatalog.ROOMS[special_id]["display_name"] = special_id DataCatalog.ROOMS[special_a]["replaces"] = [{"dimension": cycle_ids[0], "level": target_level, "chance": 0.8}] DataCatalog.ROOMS[special_b]["replaces"] = [{"dimension": cycle_ids[0], "level": target_level, "chance": 0.4}] DataCatalog.ROOMS[special_a]["max_per_run"] = 1 DataCatalog.ROOMS[special_b]["max_per_run"] = 0 GameState.set_scoped_value("run", "special_used", {}) var contenders := DiveMap.candidates_for(cycle_ids[0], target_level) _check(contenders.size() == 2, "Les deux salles spéciales ne se disputent pas la case") _check(is_equal_approx(DiveMap.replacement_chance(special_a, cycle_ids[0], target_level), 0.8), "La probabilité déclarée n'est pas relue") _check(DiveMap.candidates_for(cycle_ids[0], spare_level).is_empty(), "Une salle spéciale vise une case qu'elle ne déclare pas") # Sur mille tirages: environ 80 % de remplacements (le maximum, pas la somme), # et parmi eux environ deux tiers pour la salle à 0,8. var special_rng := RandomNumberGenerator.new() special_rng.seed = 20260818 var replaced := 0 var picked_a := 0 var base_id := DiveMap.base_room_id(cycle_ids[0], target_level) for _draw: int in range(1000): var resolved := DiveMap.resolve_room(cycle_ids[0], target_level, special_rng) if resolved != base_id: replaced += 1 if resolved == special_a: picked_a += 1 _check(replaced > 730 and replaced < 870, "Le déclenchement ne suit pas la probabilité la plus haute: %d/1000" % replaced) _check(float(picked_a) / float(maxi(1, replaced)) > 0.58 and float(picked_a) / float(maxi(1, replaced)) < 0.75, "Le partage entre prétendantes n'est pas proportionnel: %d/%d" % [picked_a, replaced]) # Plafond par plongée: une fois sortie, la salle à 1 exemplaire se retire. GameState.set_scoped_value("run", "special_used", {special_a: 1}) var remaining_contenders := DiveMap.candidates_for(cycle_ids[0], target_level) _check(remaining_contenders == [special_b], "Le plafond par plongée n'a pas retiré la salle déjà sortie") # Sans plafond, la salle reste éligible indéfiniment. GameState.set_scoped_value("run", "special_used", {special_b: 9}) _check(DiveMap.candidates_for(cycle_ids[0], target_level).has(special_b), "Un plafond à zéro a quand même limité la salle") # La salle traversée est figée: une relecture ne retire pas au sort. GameState.set_scoped_value("run", "special_used", {}) GameState.set_scoped_value("run", "level", target_level) GameState.set_scoped_value("run", "room_id", "") var frozen := DiveMap.current_room_id() for _repeat: int in range(20): _check(DiveMap.current_room_id() == frozen, "La salle courante a changé entre deux lectures") DataCatalog.ROOMS.erase(special_a) DataCatalog.ROOMS.erase(special_b) GameState.set_scoped_value("run", "special_used", {}) GameState.set_scoped_value("run", "room_id", "") # Le tirage de récompense respecte le nombre demandé et ne se répète pas. var reward_rng := RandomNumberGenerator.new() reward_rng.seed = 20260818 var drawn := DiveMap.draw_rewards(3, reward_rng) _check(drawn.size() == 3, "Le tirage n'a pas produit trois récompenses") var drawn_distinct := {} for drawn_id: String in drawn: drawn_distinct[drawn_id] = true _check(DataCatalog.REWARDS.has(drawn_id), "Le tirage a produit une récompense inconnue: %s" % drawn_id) _check(drawn_distinct.size() == 3, "Le tirage a répété une récompense dans la même offre") _check(DiveMap.draw_rewards(0, reward_rng).is_empty(), "Un tirage à zéro a produit une récompense") _check(DiveMap.draw_rewards(DataCatalog.REWARDS.size() + 5, reward_rng).size() == DataCatalog.REWARDS.size(), "Le tirage a dépassé le nombre de récompenses existantes") # Assurance: la fraction déclarée survit, le reste est perdu au hasard. var insurance_entry := {"egg": 2} GameState.ingredients = {"egg": 2, "mushroom": 8, "beast_meat": 2} GameState.coins = 140 GameState.add_modifier("run:smoke_insurance", "insurance_rate", "flat", 0.5) var covered: Dictionary = GameState.apply_dive_insurance(insurance_entry, 100) _check(is_equal_approx(float(covered.rate), 0.5), "Le taux d'assurance appliqué n'est pas celui de la statistique") # 10 unités amassées (8 champignons + 2 viandes), la moitié survit. _check(int(covered.materials) == 5 and int(covered.lost_materials) == 5, "L'assurance n'a pas gardé la bonne fraction des matériaux") var kept_total := 0 for kept_id: String in GameState.ingredients: kept_total += int(GameState.ingredients[kept_id]) _check(kept_total == 7, "L'inventaire après assurance ne vaut pas l'entrée plus la part couverte") _check(int(GameState.ingredients.get("egg", 0)) >= 2, "L'assurance a rogné sur l'inventaire d'entrée") _check(GameState.coins == 120 and int(covered.coins) == 20, "L'assurance n'a pas couvert la bonne part des pièces") # À taux nul, une mise hors service coûte exactement ce qui a été amassé. GameState.remove_modifiers_by_source("run:smoke_insurance") GameState.ingredients = {"egg": 2, "mushroom": 8} GameState.coins = 140 var uncovered: Dictionary = GameState.apply_dive_insurance(insurance_entry, 100) _check(int(uncovered.materials) == 0 and GameState.ingredients == insurance_entry and GameState.coins == 100, "Sans assurance, la plongée perdue n'a pas tout coûté") GameState.clear_run_values() GameState.set_scoped_value("meta", "gate_pending", "") GameState.set_scoped_value("meta", "dimension_index", 0) GameState.entity_counters = counters_before GameState.coins = coins_before GameState.ingredients = ingredients_before GameState.fact_values = facts_before # --- Textes multilingues ---------------------------------------------- # La clé d'une traduction est une adresse, donc le catalogue se dérive seul. _check(Loc.LANGUAGES.has("fr") and Loc.LANGUAGES.has("en"), "Les langues français et anglais ne sont pas déclarées") var loc_catalogue := Loc.catalogue() _check(loc_catalogue.size() > 100, "Le catalogue de textes est anormalement petit") _check(loc_catalogue.has("ingredients.egg.display_name"), "Un texte d'entité n'est pas catalogué") _check(loc_catalogue.has("stat.attack_damage.display_name"), "Un texte de statistique n'est pas catalogué sous sa racine d'adresse") _check(loc_catalogue.has("ui.restaurant.plates"), "Un texte d'interface n'est pas catalogué") _check(Loc.entity_key("game_variables", "attack_damage", "display_name") == "stat.attack_damage.display_name", "Le préfixe d'adresse des statistiques est faux") # Une traduction remplace le texte d'auteur, langue par langue. var previous_language := Loc.language Loc.ENTRIES["ingredients.egg.display_name"] = {"default": "Egg", "fr": "Œuf", "en": "Egg"} Loc.set_language("fr") _check(DataCatalog.ingredient_name("egg") == "Œuf", "Le nom d'ingrédient n'a pas suivi la langue courante") Loc.set_language("en") _check(DataCatalog.ingredient_name("egg") == "Egg", "Le nom d'ingrédient n'a pas suivi le retour à l'anglais") # Sans traduction, on retombe sur le texte d'auteur plutôt que sur la clé. Loc.ENTRIES["ingredients.egg.display_name"] = {"default": "Egg"} Loc.set_language("fr") _check(DataCatalog.ingredient_name("egg") == "Egg", "Une traduction manquante n'est pas retombée sur le texte d'auteur") # Un texte d'interface résout sa propre valeur de secours intégrée. Loc.ENTRIES.erase("ui.restaurant.shelf_full") _check(Loc.ui("ui.restaurant.shelf_full") == Loc.UI_FALLBACKS["ui.restaurant.shelf_full"], "Un texte d'interface sans donnée n'a pas utilisé sa valeur de secours") _check(not Loc.ui("ui.restaurant.shelf_full").begins_with("ui."), "Un texte d'interface affiche sa clé au lieu d'un texte") # Une langue inconnue est refusée plutôt qu'appliquée en silence. _check(not Loc.set_language("xx"), "Une langue non déclarée a été acceptée") # La couverture mesure ce qu'il reste à traduire. var fr_missing := Loc.missing_for("fr") _check(fr_missing.size() + roundi(Loc.coverage("fr") * float(loc_catalogue.size())) == loc_catalogue.size(), "La couverture et la liste des manques ne concordent pas") Loc.set_language(previous_language) # --- Phase 4: effets déclaratifs -------------------------------------- # Un effet écrit dans une cellule stockée, jamais dans une valeur calculée. GameState.set_fact_value("boss_defeated", false) _check(Registry.apply_effect({"op": "set", "path": "fact.boss_defeated", "value": true}), "Un effet set vers un fait a échoué") _check(bool(GameState.get_fact_value("boss_defeated")), "Un effet set n'a pas écrit dans le fait") _check(Registry.apply_effect({"op": "toggle", "path": "fact.boss_defeated", "value": true}), "Un effet toggle a échoué") _check(not bool(GameState.get_fact_value("boss_defeated")), "Un effet toggle n'a pas inversé le fait") GameState.entity_counters.erase("enemies.slime.total_killed") _check(Registry.apply_effect({"op": "add", "path": "enemies.slime.total_killed", "value": 5}), "Un effet add vers un compteur a échoué") _check(int(Registry.read("enemies.slime.total_killed")) == 5, "Un effet add n'a pas incrémenté le compteur") _check(Registry.apply_effect({"op": "max", "path": "enemies.slime.total_killed", "value": 2}), "Un effet max a échoué") _check(int(Registry.read("enemies.slime.total_killed")) == 5, "Un effet max a réduit une valeur déjà plus grande") _check(typeof(Registry.read("enemies.slime.total_killed")) == TYPE_INT, "Un compteur entier a dérivé vers un flottant") GameState.entity_counters.erase("enemies.slime.total_killed") # Refus symétrique de celui de PHP. _check(not Registry.apply_effect({"op": "add", "path": "stat.attack_damage", "value": 1}), "Un effet a pu écrire dans une statistique calculée") _check(not Registry.apply_effect({"op": "set", "path": "recipes.fried_egg.sale_price", "value": 99}), "Un effet a pu écrire dans une donnée d'auteur") _check(not Registry.apply_effect({"op": "nawak", "path": "fact.boss_defeated", "value": 1}), "Une opération d'effet inconnue a été acceptée") # Les crochets sont exposés par collection et ne sont pas adressables. _check(AddressCatalog.hooks_for("enemies").has("on_kill"), "Le crochet on_kill n'est pas déclaré pour les monstres") _check(not AddressCatalog.is_valid("enemies.slime.on_kill"), "Un crochet est devenu une adresse lisible") _check(Registry.run_hook("enemies", "slime", "on_kill") == 0, "Un monstre sans crochet a appliqué des effets") # Bout en bout: un crochet déclaré dans les données produit son effet en jeu. var hooked_enemy: Dictionary = DataCatalog.ENEMIES["slime"] hooked_enemy["on_kill"] = [ {"op": "set", "path": "fact.boss_defeated", "value": true}, {"op": "add", "path": "enemies.slime.total_spawned", "value": 10}, ] GameState.set_fact_value("boss_defeated", false) GameState.entity_counters.erase("enemies.slime.total_spawned") _check(Registry.run_hook("enemies", "slime", "on_kill") == 2, "Un crochet déclaré n'a pas appliqué ses deux effets") _check(bool(GameState.get_fact_value("boss_defeated")), "Le crochet on_kill n'a pas posé son fait") _check(int(Registry.read("enemies.slime.total_spawned")) == 10, "Le crochet on_kill n'a pas incrémenté son compteur") hooked_enemy["on_kill"] = [] GameState.set_fact_value("boss_defeated", false) GameState.entity_counters.erase("enemies.slime.total_spawned") # --- Phase 5: couches, sources et ordre déterministe ------------------- var layer_stat := "smoke_layer_stat" DataCatalog.GAME_VARIABLES[layer_stat] = {"display_name": "Smoke", "type": "float", "base_value": 10.0, "base_bonus": 0.0, "base_coefficient": 1.0, "coefficient_bonus": 0.0, "minimum": -100000.0, "maximum": 100000.0, "rounding": "none", "category": "test", "description": ""} GameState.variable_base_bonuses[layer_stat] = 0.0 GameState.variable_coefficient_bonuses[layer_stat] = 0.0 # L'exemple du document: (10 + 10) × (1 + 0.95) × (1 − 0.5) = 19.5 GameState.add_modifier("equipment:iron_pan", layer_stat, "flat", 10.0) GameState.add_modifier("upgrade:sharpen", layer_stat, "scaling", 0.75) GameState.add_modifier("consumable:spicy_meal", layer_stat, "scaling", 0.20) GameState.add_modifier("debuff:slime_curse", layer_stat, "global", -0.5) _check(is_equal_approx(float(GameState.resolve_game_variable(layer_stat)), 19.5), "Les quatre couches ne composent pas la formule attendue") var layered := GameState.get_game_variable_components(layer_stat) _check(is_equal_approx(float(layered.flat), 10.0) and is_equal_approx(float(layered.scaling), 0.95) and is_equal_approx(float(layered.global), -0.5), "La décomposition par couche est fausse") # Une source retirée emporte tout ce qu'elle avait posé, quelle que soit la couche. _check(GameState.remove_modifiers_by_source("consumable:spicy_meal") == 1, "Le retrait par source n'a rien retiré") _check(is_equal_approx(float(GameState.resolve_game_variable(layer_stat)), 17.5), "Le retrait d'une source n'a pas recalculé la valeur") # La couche override multiplie isolément au lieu de se diluer. GameState.add_modifier("boon:double", layer_stat, "override", 2.0) _check(is_equal_approx(float(GameState.resolve_game_variable(layer_stat)), 35.0), "La couche override ne multiplie pas isolément") GameState.remove_modifiers_by_source("boon:double") # Expiration: un modificateur temporaire cesse d'être compté sans être supprimé. GameState.add_modifier("debuff:short", layer_stat, "flat", 100.0, 5.0) # (10 + 10 + 100) × (1 + 0.75) × (1 − 0.5) = 105 _check(is_equal_approx(float(GameState.resolve_game_variable(layer_stat)), 105.0), "Un modificateur temporaire n'a pas été compté") GameState.game_time += 6.0 _check(is_equal_approx(float(GameState.resolve_game_variable(layer_stat)), 17.5), "Un modificateur expiré est encore compté à la lecture") GameState._expire_modifiers() _check(not GameState.has_modifier_source("debuff:short"), "Le tick n'a pas purgé un modificateur expiré") # Le correctif du bug d'ordre: le résultat ne dépend plus de l'ordre d'achat. GameState.remove_modifiers_by_source("equipment:iron_pan") GameState.remove_modifiers_by_source("upgrade:sharpen") GameState.remove_modifiers_by_source("debuff:slime_curse") GameState.add_modifier("upgrade:b_add", layer_stat, "flat", 5.0, 0.0, "add") GameState.add_modifier("upgrade:a_set", layer_stat, "flat", 100.0, 0.0, "set") var order_first := float(GameState.resolve_game_variable(layer_stat)) GameState.remove_modifiers_by_source("upgrade:b_add") GameState.remove_modifiers_by_source("upgrade:a_set") GameState.add_modifier("upgrade:a_set", layer_stat, "flat", 100.0, 0.0, "set") GameState.add_modifier("upgrade:b_add", layer_stat, "flat", 5.0, 0.0, "add") _check(is_equal_approx(order_first, float(GameState.resolve_game_variable(layer_stat))), "Le résultat dépend encore de l'ordre d'ajout des modificateurs") _check(is_equal_approx(order_first, 115.0), "L'ordre imposé set → add n'a pas été appliqué") GameState.active_modifiers.clear() DataCatalog.GAME_VARIABLES.erase(layer_stat) GameState.variable_base_bonuses.erase(layer_stat) GameState.variable_coefficient_bonuses.erase(layer_stat) # --- Phase 6: effets temporaires -------------------------------------- _check(DataCatalog.EFFECTS.has("burning") and DataCatalog.EFFECTS.has("paralyzed"), "La collection d'effets n'a pas été chargée") GameState.player_effects.clear() GameState.remove_modifiers_by_source_prefix("effect:") var holder := EffectHolder.new() var holder_carrier := Node2D.new() holder_carrier.add_child(holder) get_tree().root.add_child(holder_carrier) await get_tree().process_frame # Empilement par effet: trois applications = trois piles sur une seule clé. for application: int in range(3): holder.apply("burning", "enemy:fungling#%d" % application) _check(holder.stacks_of("burning") == 3, "L'empilement par effet n'a pas cumulé les applications") # Au plafond, une application de plus remplace la pile qui expirait le plus tôt. holder.apply("burning", "enemy:fungling#4") _check(holder.stacks_of("burning") == 3, "L'empilement a dépassé max_stacks") # Expiration par pile: chaque application porte sa propre échéance. holder.instances[0].expires_at = GameState.game_time - 1.0 holder._expire() _check(holder.stacks_of("burning") == 2, "Les piles n'expirent pas indépendamment les unes des autres") # Une politique refresh ne cumule jamais. holder.apply("hasted", "consumable:tea") holder.apply("hasted", "consumable:tea") _check(holder.stacks_of("hasted") == 1, "Une politique refresh a créé une seconde instance") # Drapeaux de capacité: refusés dès qu'un effet actif les nie. _check(not holder.has_flag("can_move"), "Un drapeau est actif sans effet qui le pose") holder.apply("paralyzed", "trap:spikes") _check(holder.has_flag("can_move") and holder.has_flag("can_attack"), "La paralysie n'a pas posé ses drapeaux de capacité") holder.clear() _check(not holder.has_flag("can_move"), "Les drapeaux survivent à la purge des effets") holder_carrier.queue_free() await get_tree().process_frame # Les effets du joueur survivent au changement de scène via GameState. GameState.player_effects.clear() GameState.remove_modifiers_by_source_prefix("effect:") var persistent_carrier := Node2D.new() var persistent_holder := EffectHolder.new() persistent_holder.persistent = true persistent_carrier.add_child(persistent_holder) get_tree().root.add_child(persistent_carrier) await get_tree().process_frame persistent_holder.apply("hasted", "consumable:tea") _check(GameState.player_effects.size() == 1, "Un effet du joueur n'a pas été confié à GameState") _check(GameState.has_modifier_source("effect:hasted"), "Un effet du joueur n'a pas publié son modificateur") var effect_payload: Variant = JSON.parse_string(JSON.stringify(GameState.player_effects)) _check(effect_payload is Array and effect_payload.size() == 1, "Les effets du joueur ne survivent pas au format de sauvegarde") persistent_carrier.queue_free() await get_tree().process_frame # Un nouveau porteur, comme après un changement de scène, retrouve l'effet. var rehydrated_carrier := Node2D.new() var rehydrated_holder := EffectHolder.new() rehydrated_holder.persistent = true rehydrated_carrier.add_child(rehydrated_holder) get_tree().root.add_child(rehydrated_carrier) await get_tree().process_frame _check(rehydrated_holder.has_effect("hasted"), "Un effet du joueur n'a pas été réhydraté après le changement de scène") rehydrated_holder.clear() rehydrated_carrier.queue_free() await get_tree().process_frame GameState.player_effects.clear() GameState.remove_modifiers_by_source_prefix("effect:") GameState.add_entity_counter("enemies", "boar_brute", "total_killed", 4) var counters_payload: Variant = JSON.parse_string(JSON.stringify(GameState.entity_counters)) _check(counters_payload is Dictionary and int(counters_payload["enemies.boar_brute.total_killed"]) == 4, "Les compteurs par entité ne survivent pas au format de sauvegarde") GameState.entity_counters.erase("enemies.boar_brute.total_killed") _check(DataCatalog.INGREDIENTS.size() == 5 and DataCatalog.RECIPES.size() == 5 and DataCatalog.ENEMIES.size() >= 5 and DataCatalog.ATTACK_PATTERNS.size() >= 5 and DataCatalog.MOVEMENT_PATTERNS.size() >= 5 and not DataCatalog.PLAYER_CONFIGS.is_empty() and DataCatalog.CUSTOMER_PROFILES.size() >= 3 and DataCatalog.UPGRADES.size() >= 13 and DataCatalog.GAME_VARIABLES.size() >= 8 and DataCatalog.GAME_FACTS.size() >= 5, "Expanded master data collections are missing") for enemy_definition: Dictionary in DataCatalog.ENEMIES.values(): _check(float(enemy_definition.knockback_resistance) <= 0.30, "An enemy knockback resistance is still too high") _check(enemy_definition.has("direct_hit") and enemy_definition.has("attacks"), "Enemy is missing the direct-hit/list attack model") for attack: Dictionary in enemy_definition.attacks: _check(DataCatalog.ATTACK_PATTERNS.has(str(attack.pattern)), "Enemy references a missing attack pattern") _check(float(attack.cooldown) >= 0.05 and float(attack.damage_max) >= float(attack.damage_min) and float(attack.weight) >= 0.0, "Enemy attack entry has invalid damage, cooldown, or weight") _check(DataCatalog.MOVEMENT_PATTERNS.has(str(enemy_definition.undetected_movement)) and DataCatalog.MOVEMENT_PATTERNS.has(str(enemy_definition.detected_movement)), "Enemy references a missing movement pattern") for pattern_definition: Dictionary in DataCatalog.ATTACK_PATTERNS.values(): _check(DataCatalog.ATTACK_MOVEMENT_TYPES.has(str(pattern_definition.movement_type)), "Attack pattern has an unknown actor movement") var weighted_probe := [{"pattern": "light", "weight": 1.0}, {"pattern": "heavy", "weight": 3.0}] _check(str(Enemy.choose_weighted_attack(weighted_probe, 0.10).pattern) == "light" and str(Enemy.choose_weighted_attack(weighted_probe, 0.50).pattern) == "heavy", "Weighted enemy attack selection ignores relative weights") # La vague vaut le budget de la salle — statistique plus marche du biome — # et non la somme des comptes déclarés sur chaque monstre. Le budget attendu a # été figé au moment du tirage: `end_run` a depuis remis la portée run à zéro. _check(enemies.size() == live_wave_budget, "La salle n'a pas été peuplée à hauteur de son budget") for enemy_node: Node in enemies: var shaped_enemy := enemy_node as Enemy var configured_shape := str(DataCatalog.ENEMIES[shaped_enemy.enemy_id].shape) var collision_shape: Shape2D = shaped_enemy.get_node("CollisionShape2D").shape var collision_matches := (configured_shape == "circle" and collision_shape is CircleShape2D) \ or (configured_shape == "square" and collision_shape is RectangleShape2D) \ or (not ["circle", "square"].has(configured_shape) and collision_shape is ConvexPolygonShape2D) _check(collision_matches, "Enemy collision did not match its data-defined shape: %s" % configured_shape) _check(dungeon.get_node("Camera2D").position == Vector2(960, 540) and dungeon.get_node("Camera2D").zoom == Vector2(0.9, 0.9), "Dungeon camera is not centered on the 1080p zoomed-out framing") if enemies.size() > 1: var distant_enemy: Enemy = enemies[1] distant_enemy.global_position = dungeon_player.global_position + Vector2(150, 0) var detection_offset := dungeon_player.global_position - distant_enemy.global_position distant_enemy.look_direction = Vector2.RIGHT _check(not distant_enemy._can_detect_target(detection_offset, detection_offset.length()), "Enemy detected the player behind its vision cone") distant_enemy.look_direction = Vector2.LEFT _check(distant_enemy._can_detect_target(detection_offset, detection_offset.length()), "Enemy failed to detect the player inside its vision cone") # Park the enemy beyond whatever sight range its data defines. Detection is # what gates chasing, and undetected roaming is random, so assert on the # detection gate rather than on a distance the roam could cover by luck. var out_of_sight_gap := distant_enemy.sight_range + 120.0 distant_enemy.global_position = dungeon_player.global_position + Vector2(out_of_sight_gap, 0) await get_tree().physics_frame var out_of_sight_offset := dungeon_player.global_position - distant_enemy.global_position distant_enemy.look_direction = out_of_sight_offset.normalized() _check(not distant_enemy._can_detect_target(out_of_sight_offset, out_of_sight_offset.length()), "Enemy detected the player from beyond its configured sight range") _check(distant_enemy._movement_for_behavior("static", Vector2.LEFT, false, 0.016).is_zero_approx(), "Static movement behavior produced movement") _check(distant_enemy._movement_for_behavior("linear", Vector2.LEFT, false, 0.016).length() > 0.0, "Linear movement behavior produced no movement") distant_enemy.hop_move_remaining = 0.0 distant_enemy.hop_rest_remaining = 0.0 _check(distant_enemy._movement_for_behavior("big_leap", Vector2.LEFT, true, 0.016).length() > distant_enemy.move_speed, "Big-leap behavior did not create an impulse") # Le combat a besoin de trois créatures distinctes. Une case écrite peut n'en # poser que deux: le test complète son effectif ici — après les contrôles de # peuplement, qui eux vérifient justement ce que la case déclare. _ensure_enemy_supply(dungeon, 3) enemies = get_tree().get_nodes_in_group("enemy") if not enemies.is_empty(): dungeon_player.global_position = Vector2(900, 600) await get_tree().physics_frame var enemy: Enemy var temporary_direct_enemy_id := "" for candidate: Node in enemies: var candidate_enemy := candidate as Enemy if candidate_enemy.direct_hit_enabled: enemy = candidate_enemy break if enemy == null: temporary_direct_enemy_id = "smoke_direct_enemy" var direct_definition: Dictionary = DataCatalog.ENEMIES.values()[0].duplicate(true) direct_definition.display_name = "Smoke Direct Enemy" direct_definition.spawn_weight = 0.0 direct_definition.spawn_min = 0 direct_definition.spawn_max = 0 direct_definition.direct_hit = true direct_definition.direct_damage_min = 90.0 direct_definition.direct_damage_max = 110.0 direct_definition.direct_hit_cooldown = 1.15 direct_definition.attacks = [] DataCatalog.ENEMIES[temporary_direct_enemy_id] = direct_definition enemy = load("res://scenes/enemies/enemy.tscn").instantiate() dungeon.add_child(enemy) enemy.setup(temporary_direct_enemy_id) var variance_observed := false for sample: int in range(8): var rolled_damage := enemy._roll_damage(90.0, 110.0) _check(rolled_damage >= 90.0 and rolled_damage <= 110.0, "Enemy min/max damage roll exceeded its configured bounds") variance_observed = variance_observed or not is_equal_approx(rolled_damage, 100.0) _check(variance_observed, "Enemy min/max damage rolls did not vary") # Jamais par indice: une case écrite peut ne peupler que deux créatures, # et un monstre abattu plus tôt reste dans le tableau. var rear_enemy: Enemy = _spare_enemy(enemies, [enemy]) _check(rear_enemy != null, "Aucune seconde créature disponible pour le test d'éventail") enemy.health = maxf(enemy.health, 1000.0) rear_enemy.health = maxf(rear_enemy.health, 1000.0) enemy.attack_cooldown_remaining = 999.0 enemy.direct_hit_cooldown_remaining = 999.0 rear_enemy.attack_cooldown_remaining = 999.0 rear_enemy.direct_hit_cooldown_remaining = 999.0 enemy.global_position = dungeon_player.global_position + Vector2(60, 0) rear_enemy.global_position = dungeon_player.global_position + Vector2(-60, 0) dungeon_player.facing = Vector2.RIGHT await get_tree().physics_frame await get_tree().physics_frame # Large data-driven enemy bodies can be separated slightly by physics. # Keep this fan/knockback check focused on attack direction and damage. enemy.global_position = dungeon_player.global_position + Vector2(60, 0) rear_enemy.global_position = dungeon_player.global_position + Vector2(-60, 0) var configured_attack_range := dungeon_player.attack_range dungeon_player.attack_range = maxf(configured_attack_range, 140.0) dungeon_player.attack_cooldown = 0.0 var enemy_health_before := enemy.health var rear_health_before := rear_enemy.health dungeon_player.attack() await get_tree().physics_frame _check(enemy.health < enemy_health_before, "Player fan attack did not damage an enemy in front") _check(is_equal_approx(rear_enemy.health, rear_health_before), "Player fan attack incorrectly hit an enemy behind the player") _check(enemy.knockback_velocity.length() > 0.0, "Player hit did not apply enemy knockback") dungeon_player.attack_range = configured_attack_range _check(not get_tree().get_nodes_in_group("damage_number").is_empty(), "Player hit did not spawn a colored damage number") # --- La portée se mesure au corps, pas au centre -------------------- # Un monstre agrandi a son centre bien plus loin que son flanc: mesurer # sur le centre rejetait des coups qui touchaient clairement le corps. rear_enemy.global_position = dungeon_player.global_position + Vector2(-900, 0) enemy.size_multiplier = 2.0 enemy._apply_collision_shape() _check(is_equal_approx(enemy.body_radius(), 36.0), "Le rayon du corps ne suit pas l'agrandissement: %.1f" % enemy.body_radius()) # Centre juste hors d'allonge, flanc largement dedans. var grazing := dungeon_player.attack_range + enemy.body_radius() * 0.5 dungeon_player.aim_at(dungeon_player.global_position + Vector2.RIGHT * 300.0) enemy.global_position = dungeon_player.global_position + Vector2(grazing, 0) await get_tree().physics_frame dungeon_player.attack_cooldown = 0.0 var grazed_before := enemy.health dungeon_player.attack() await get_tree().physics_frame _check(enemy.health < grazed_before, "Un coup qui touche le flanc d'un gros monstre n'est pas enregistré") # Hors de portée corps compris: le coup doit toujours manquer. enemy.global_position = dungeon_player.global_position + Vector2(dungeon_player.attack_range + enemy.body_radius() + 60.0, 0) await get_tree().physics_frame dungeon_player.attack_cooldown = 0.0 var distant_before := enemy.health dungeon_player.attack() await get_tree().physics_frame _check(is_equal_approx(enemy.health, distant_before), "Un monstre hors d'allonge a été touché") # Sur le flanc: un corps large occupe un secteur, pas une direction. Le # centre sort du cône, mais le corps y entre encore. var arc_edge := deg_to_rad(dungeon_player.attack_arc_degrees * 0.5) + 0.12 var close_reach := dungeon_player.attack_range * 0.55 enemy.global_position = dungeon_player.global_position + Vector2.RIGHT.rotated(arc_edge) * close_reach await get_tree().physics_frame dungeon_player.attack_cooldown = 0.0 var flank_before := enemy.health dungeon_player.attack() await get_tree().physics_frame _check(enemy.health < flank_before, "Un gros monstre au bord du cône n'est pas touché malgré son corps dedans") # Franchement derrière: l'éventail reste dirigé vers l'avant. enemy.global_position = dungeon_player.global_position + Vector2(-close_reach, 0) await get_tree().physics_frame dungeon_player.attack_cooldown = 0.0 var behind_before := enemy.health dungeon_player.attack() await get_tree().physics_frame _check(is_equal_approx(enemy.health, behind_before), "L'éventail a frappé derrière le joueur") var resistant_enemy: Enemy = _spare_enemy(enemies, [enemy, rear_enemy]) _check(resistant_enemy != null, "Aucune troisième créature disponible pour le test de recul") enemy.knockback_resistance = 0.0 resistant_enemy.knockback_resistance = 0.3 enemy.knockback_velocity = Vector2.ZERO resistant_enemy.knockback_velocity = Vector2.ZERO enemy.take_damage(1.0, Vector2.RIGHT, 400.0) resistant_enemy.take_damage(1.0, Vector2.RIGHT, 400.0) _check(resistant_enemy.knockback_velocity.length() < enemy.knockback_velocity.length(), "Enemy knockback resistance did not reduce applied force") rear_enemy.global_position = Vector2(900, 500) enemy.direct_hit_cooldown_remaining = 0.0 enemy.knockback_velocity = Vector2.ZERO enemy.global_position = dungeon_player.global_position + Vector2(35, 0) enemy.look_direction = Vector2.LEFT dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO var player_health_before := dungeon_player.health await get_tree().physics_frame await get_tree().physics_frame await get_tree().create_timer(0.12).timeout _check(dungeon_player.health < player_health_before, "Enemy did not approach and damage the player normally") _check(dungeon_player.knockback_velocity.length() > 0.0, "Contact hit did not knock the player back") _check(enemy.direct_hit_enabled and not enemy.telegraphing, "Direct-hit contact incorrectly left a warning active") await get_tree().create_timer(0.08).timeout _check(enemy.direct_flash_remaining <= 0.0, "Direct-hit proximity circle did not expire after its brief impact flash") enemy.global_position = Vector2(620, 330) var frontal_enemy: Enemy = _enemy_with_attack_mode(enemies, "burst") _check(frontal_enemy != null, "Aucun ennemi ne peut porter une attaque en cône") # La sonde de recul ci-dessous inflige des dégâts: sans plancher de vie, # une créature à 1 ou 2 points meurt et la suite lit une instance morte. frontal_enemy.health = maxf(frontal_enemy.health, 1000.0) frontal_enemy._interrupt_attack() frontal_enemy.attack_cooldown_remaining = 0.0 # Les sondes de contact précédentes peuvent avoir vidé la petite vie # de départ. Une cible déjà à zéro ignore légitimement tout autre coup. dungeon_player.health = maxf(dungeon_player.health, 100.0) dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO # Se placer dans la portée réelle du pattern choisi, pas à une distance fixe. var frontal_reach := frontal_enemy.attack_range * 0.7 frontal_enemy.global_position = dungeon_player.global_position + Vector2(frontal_reach, 0) frontal_enemy.look_direction = Vector2.LEFT await get_tree().physics_frame await get_tree().physics_frame _check(frontal_enemy.attack_type == "frontal" and frontal_enemy.telegraphing, "Frontal enemy did not begin its telegraphed attack") _check(DataCatalog.ATTACK_MOVEMENT_TYPES.has(frontal_enemy.attack_movement_type), "Attack did not load its actor movement from the pattern") frontal_enemy.take_damage(1.0, Vector2.RIGHT, dungeon_player.attack_knockback) _check(not frontal_enemy.telegraphing, "Knockback did not cancel the active enemy warning") _check(frontal_enemy.attack_cooldown_remaining >= frontal_enemy.attack_cooldown_duration * 0.6, "Knockback did not reset enemy attack recovery") frontal_enemy.knockback_velocity = Vector2.ZERO frontal_enemy.attack_cooldown_remaining = 0.0 frontal_enemy.global_position = dungeon_player.global_position + Vector2(frontal_reach, 0) frontal_enemy.look_direction = Vector2.LEFT await get_tree().physics_frame await get_tree().physics_frame _check(frontal_enemy.telegraphing, "Frontal enemy did not restart a warning after recovery reset") frontal_enemy.active = false var health_before_windup := dungeon_player.health frontal_enemy._advance_attack(frontal_enemy.windup_duration * 0.45) _check(is_equal_approx(dungeon_player.health, health_before_windup), "Frontal attack dealt damage before its warning completed") var frontal_move_time := frontal_enemy.attack_movement_distance / maxf(frontal_enemy.attack_movement_speed, 1.0) dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO frontal_enemy.global_position = dungeon_player.global_position + Vector2(frontal_reach, 0.0) frontal_enemy.attack_direction = (dungeon_player.global_position - frontal_enemy.global_position).normalized() frontal_enemy._advance_attack(frontal_enemy.windup_duration) frontal_enemy._advance_attack(frontal_move_time + 0.10) _check(dungeon_player.health < health_before_windup, "Frontal attack did not damage the player after its warning") _check(dungeon_player.knockback_velocity.length() > 0.0, "Frontal attack did not knock the player back") _check(not frontal_enemy.telegraphing, "Enemy warning cone remained active after the hit") frontal_enemy.active = true frontal_enemy.global_position = Vector2(920, 180) # --- Une créature frappe un corps, pas un centre -------------------- # Symétrique de la portée du joueur: une zone qui mord le flanc du héros # doit toucher, même si son centre reste hors d'atteinte. _check(is_equal_approx(dungeon_player.body_radius(), 19.0), "Le rayon du corps du joueur n'est plus celui de sa silhouette: %.1f" % dungeon_player.body_radius()) var probe_enemy: Enemy = frontal_enemy var probe_active := probe_enemy.active var probe_range := probe_enemy.attack_range var probe_angle := probe_enemy.attack_angle # Le test impose la géométrie dont il dépend: la donnée d'auteur peut # régler n'importe quelle portée, y compris un cône à 360°. probe_enemy.active = false probe_enemy.attack_range = 60.0 probe_enemy.attack_angle = 90.0 probe_enemy.target = dungeon_player var player_body := dungeon_player.body_radius() var probe_health := dungeon_player.health dungeon_player.health = 500.0 # Centre hors d'allonge, flanc dedans: le coup passe. var enemy_hit_before := dungeon_player.health _place_probe(probe_enemy, dungeon_player, Vector2.RIGHT, probe_enemy.attack_range + player_body * 0.5) probe_enemy._try_attack_hit(Vector2.ZERO, (dungeon_player.global_position - probe_enemy.global_position).normalized()) _check(dungeon_player.health < enemy_hit_before, "Une attaque qui touche le flanc du héros n'est pas enregistrée") # Corps compris, la portée manque: le coup doit toujours rater. enemy_hit_before = dungeon_player.health _place_probe(probe_enemy, dungeon_player, Vector2.RIGHT, probe_enemy.attack_range + player_body + 40.0) probe_enemy._try_attack_hit(Vector2.ZERO, (dungeon_player.global_position - probe_enemy.global_position).normalized()) _check(is_equal_approx(dungeon_player.health, enemy_hit_before), "Une attaque hors d'allonge a touché le héros") # Sur le flanc du cône: le centre en sort, le corps y entre encore. enemy_hit_before = dungeon_player.health var probe_reach := probe_enemy.attack_range * 0.55 _place_probe(probe_enemy, dungeon_player, Vector2.RIGHT, probe_reach) var probe_axis := (dungeon_player.global_position - probe_enemy.global_position).normalized() var probe_edge := probe_axis.rotated(deg_to_rad(probe_enemy.attack_angle * 0.5) + 0.12) probe_enemy._try_attack_hit(Vector2.ZERO, probe_edge) _check(dungeon_player.health < enemy_hit_before, "Le héros au bord du cône n'est pas touché malgré son corps dedans") # Franchement derrière: le cône reste dirigé. enemy_hit_before = dungeon_player.health _place_probe(probe_enemy, dungeon_player, Vector2.RIGHT, probe_reach) probe_enemy._try_attack_hit(Vector2.ZERO, -(dungeon_player.global_position - probe_enemy.global_position).normalized()) _check(is_equal_approx(dungeon_player.health, enemy_hit_before), "Un cône dirigé à l'opposé a touché le héros") probe_enemy.attack_range = probe_range probe_enemy.attack_angle = probe_angle probe_enemy.active = probe_active probe_enemy.attack_cooldown_remaining = probe_enemy.attack_cooldown_duration probe_enemy.global_position = Vector2(920, 180) # La décision d'attaquer se prend sur le corps elle aussi: une créature # posée contre le héros doit frapper, pas se croire hors d'atteinte. var contact_enemy: Enemy = enemy var contact_speed := contact_enemy.move_speed var contact_size := contact_enemy.size_multiplier # Immobile et de taille normale: le test veut mesurer la portée, pas la # course, et deux corps qui se chevauchent sont écartés par la physique. contact_enemy.move_speed = 0.0 contact_enemy.size_multiplier = 1.0 contact_enemy._apply_collision_shape() contact_enemy.target = dungeon_player contact_enemy._interrupt_attack() contact_enemy.direct_hit_enabled = true contact_enemy.direct_damage_min = 1.0 contact_enemy.direct_damage_max = 1.0 contact_enemy.direct_hit_cooldown_remaining = 0.0 contact_enemy.knockback_velocity = Vector2.ZERO dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO dungeon_player.health = 500.0 contact_enemy.global_position = dungeon_player.global_position - Vector2(contact_enemy.body_radius() + player_body - 1.0, 0) var contact_before := dungeon_player.health await get_tree().physics_frame await get_tree().physics_frame _check(dungeon_player.health < contact_before, "Une créature contre le héros ne le frappe pas: son allonge se mesure encore au centre") contact_enemy.move_speed = contact_speed contact_enemy.size_multiplier = contact_size contact_enemy._apply_collision_shape() contact_enemy.global_position = Vector2(620, 330) contact_enemy.direct_hit_cooldown_remaining = contact_enemy.direct_hit_cooldown_duration dungeon_player.health = probe_health dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO var swipe_enemy: Enemy = _enemy_with_attack_mode(enemies, "swipe") _check(swipe_enemy != null, "Aucun ennemi ne peut porter une attaque tournoyante") swipe_enemy.health = maxf(swipe_enemy.health, 1000.0) swipe_enemy._interrupt_attack() swipe_enemy.active = false swipe_enemy.target = dungeon_player swipe_enemy._apply_attack_definition({"pattern": "orbit_swipe", "damage_min": 1.0, "damage_max": 1.0, "cooldown": 1.6, "weight": 1.0}) swipe_enemy.global_position = dungeon_player.global_position + Vector2(swipe_enemy.attack_range * 0.7, 0) swipe_enemy.look_direction = Vector2.LEFT dungeon_player.invulnerability = 999.0 swipe_enemy._begin_attack(dungeon_player.global_position - swipe_enemy.global_position) _check(swipe_enemy.attack_mode == "swipe" and swipe_enemy.attack_state == "windup" and swipe_enemy.telegraphing, "Swipe pattern did not begin its warning") swipe_enemy._advance_attack(swipe_enemy.windup_duration + 0.01) var swipe_direction_before := swipe_enemy.attack_direction swipe_enemy._advance_attack(0.12) _check(swipe_enemy.attack_state == "active" and swipe_enemy.telegraphing, "Swipe did not keep its visual active during rotation") _check(not swipe_enemy.attack_direction.is_equal_approx(swipe_direction_before), "Swipe sector did not rotate") swipe_enemy.active = true swipe_enemy.take_damage(1.0, Vector2.RIGHT, dungeon_player.attack_knockback) _check(swipe_enemy.attack_state == "idle" and not swipe_enemy.telegraphing, "Knockback did not clear the continuous swipe visual") swipe_enemy.global_position = Vector2(980, 150) dungeon_player.invulnerability = 0.0 var sticky_definition: Dictionary = DataCatalog.ENEMIES.slime.duplicate(true) sticky_definition.display_name = "Smoke Sticky" sticky_definition.spawn_weight = 0.0 sticky_definition.spawn_min = 0 sticky_definition.spawn_max = 0 sticky_definition.direct_hit = false sticky_definition.attacks = [{"pattern": "sticky_circle", "damage_min": 1.0, "damage_max": 1.0, "cooldown": 2.4, "weight": 1.0}] DataCatalog.ENEMIES["smoke_sticky"] = sticky_definition var sticky_enemy: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() dungeon.add_child(sticky_enemy) sticky_enemy.setup("smoke_sticky") sticky_enemy.active = false sticky_enemy.target = dungeon_player sticky_enemy.global_position = dungeon_player.global_position + Vector2(40, 0) sticky_enemy.look_direction = Vector2.LEFT dungeon_player.invulnerability = 999.0 sticky_enemy._apply_attack_definition(sticky_definition.attacks[0]) sticky_enemy._begin_attack(dungeon_player.global_position - sticky_enemy.global_position) var sticky_origin_before := sticky_enemy.attack_origin_offset dungeon_player.global_position += Vector2(12, 0) sticky_enemy._advance_attack(0.01) _check(sticky_enemy.attack_mode == "sticky" and sticky_enemy.telegraphing, "Sticky pattern did not create its tracked warning") _check(not sticky_enemy.attack_origin_offset.is_equal_approx(sticky_origin_before), "Sticky warning did not follow the player") var sticky_move_time := sticky_enemy.attack_movement_distance / maxf(sticky_enemy.attack_movement_speed, 1.0) sticky_enemy._advance_attack(sticky_enemy.windup_duration + 0.01) sticky_enemy._advance_attack(sticky_move_time + 0.10) _check(sticky_enemy.attack_state == "idle" and not sticky_enemy.telegraphing and sticky_enemy.attack_origin_offset.is_zero_approx(), "Sticky warning remained after resolving") sticky_enemy.queue_free() DataCatalog.ENEMIES.erase("smoke_sticky") dungeon_player.invulnerability = 0.0 await get_tree().physics_frame await get_tree().physics_frame var enemy_death_position := enemy.global_position var killed_enemy_id: String = enemy.enemy_id var kills_before := int(Registry.read("enemies.%s.total_killed" % killed_enemy_id)) var defeated_before := int(GameState.get_fact_value("enemies_defeated")) enemy.take_damage(9999.0) await get_tree().create_timer(0.25).timeout # Le compteur par entité est alimenté par le jeu lui-même, sans branche # par monstre: c'est ce qui remplace l'ancien code en dur de dungeon.gd. _check(int(Registry.read("enemies.%s.total_killed" % killed_enemy_id)) == kills_before + 1, "Un monstre abattu n'a pas incrémenté son propre compteur") _check(int(GameState.get_fact_value("enemies_defeated")) == defeated_before + 1, "Un monstre abattu n'a pas incrémenté le fait global") # L'ennemi tué ci-dessus peut être un ennemi de test créé à la main, donc # total_spawned se vérifie sur la vague réellement générée par le donjon. var spawned_total := 0 for spawned_id: String in DataCatalog.ENEMIES: spawned_total += int(Registry.read("enemies.%s.total_spawned" % spawned_id)) _check(spawned_total > 0, "La vague du donjon n'a alimenté aucun compteur total_spawned") var found_pickup := false var closest_pickup_distance := INF for child: Node in dungeon.get_children(): if child is IngredientPickup: found_pickup = true closest_pickup_distance = minf(closest_pickup_distance, child.global_position.distance_to(enemy_death_position)) _check(found_pickup, "Defeated enemy did not create an ingredient pickup") _check(closest_pickup_distance < 60.0, "Ingredient pickup moved away from the enemy death position") if not temporary_direct_enemy_id.is_empty(): DataCatalog.ENEMIES.erase(temporary_direct_enemy_id) # Une portée upgradée attire réellement un drop libre après son animation # d'apparition, sans le téléporter ni remplacer la collecte au contact. var magnet_pickup: IngredientPickup = load("res://scenes/items/ingredient_pickup.tscn").instantiate() magnet_pickup.setup("egg") magnet_pickup.position = dungeon_player.position + Vector2(180.0, 0.0) dungeon.add_child(magnet_pickup) await get_tree().create_timer(0.22).timeout magnet_pickup.global_position = dungeon_player.global_position + Vector2(180.0, 0.0) var magnet_distance_before := magnet_pickup.global_position.distance_to(dungeon_player.global_position) GameState.add_modifier("smoke:drop_magnet", "drop_magnet_range", "flat", 240.0) await get_tree().create_timer(0.12).timeout _check(is_instance_valid(magnet_pickup) and magnet_pickup.global_position.distance_to(dungeon_player.global_position) < magnet_distance_before - 10.0 and magnet_pickup.magnet_visual_height > 1.0 and (magnet_pickup.get_node("CollisionShape2D") as CollisionShape2D).position.is_zero_approx(), "Le butin magnétique ne se rapproche pas en bondissant au-dessus de sa collision") GameState.remove_modifiers_by_source("smoke:drop_magnet") if is_instance_valid(magnet_pickup): magnet_pickup.queue_free() # Reproduce the real edge case: the pickup starts already overlapping the # player, so body_entered fires before its pop animation unlocks collection. for active_enemy: Node in get_tree().get_nodes_in_group("enemy"): if is_instance_valid(active_enemy): active_enemy.global_position = Vector2(180, 180) dungeon_player.knockback_velocity = Vector2.ZERO dungeon_player.invulnerability = 999.0 var overlap_pickup: IngredientPickup = load("res://scenes/items/ingredient_pickup.tscn").instantiate() overlap_pickup.setup("mushroom") overlap_pickup.position = dungeon_player.position dungeon.add_child(overlap_pickup) _check(overlap_pickup.get_node("CollisionShape2D").shape is ConvexPolygonShape2D, "Material pickup collision did not use its data-defined triangle") var mushrooms_before := GameState.get_ingredient_amount("mushroom") overlap_pickup._enable_pickup() overlap_pickup._try_collect(dungeon_player) await get_tree().process_frame _check(GameState.get_ingredient_amount("mushroom") == mushrooms_before + 1, "Overlapping ingredient pickup did not collect automatically") _check(not GameState.ingredients.is_empty(), "Ingredient pickup did not update persistent inventory") GameState.add_ingredient("egg", 2) var materials_at_exhaustion: Dictionary = GameState.ingredients.duplicate(true) dungeon.stamina = 0.5 _check(not dungeon.try_spend_stamina(1.0), "An unaffordable stamina action was incorrectly allowed") await get_tree().create_timer(0.08).timeout _check(dungeon.run_finished, "Dungeon round did not end when stamina reached zero") _check(GameState.ingredients == materials_at_exhaustion, "Stamina exhaustion discarded materials collected during the run") dungeon.queue_free() await get_tree().process_frame # --- Scènes narratives: séquence, branchement et donnée ----------------- var story_player: Node2D = STORY_SCENE_SCRIPT.new() story_player.set("auto_advance", true) get_tree().root.add_child(story_player) var story_events_seen: Array[String] = [] story_player.connect("event_started", func(event_id: String, _event_type: String) -> void: story_events_seen.append(event_id)) var story_definition := { "display_name": "Smoke story", "description": "", "backdrop_color": Color("18233a"), "backdrop_visual": "", "skippable": true, "actors": [ {"id": "hero", "kind": "hero", "source": DataCatalog.active_player_config_id, "display_name": "", "facing": "right", "visual": "", "color": Color("3a86ff"), "placement": {"mode": "scene", "anchor": "center", "relative_to": "", "direction": "E", "distance": 160.0, "offset_x": 0.0, "offset_y": 0.0}}, {"id": "guide", "kind": "other", "source": "", "display_name": "Guide", "facing": "left", "visual": "", "color": Color("6ec8a8"), "placement": {"mode": "actor", "anchor": "center", "relative_to": "hero", "direction": "E", "distance": 180.0, "offset_x": 0.0, "offset_y": 0.0}}, ], "events": [ {"id": "intro", "type": "text", "text": "Une scène.", "duration": 0.0}, {"id": "line", "type": "speech", "actor": "guide", "text": "Par ici.", "duration": 0.0}, {"id": "move", "type": "move", "actor": "hero", "duration": 0.01, "placement": {"mode": "actor", "anchor": "center", "relative_to": "guide", "direction": "S", "distance": 80.0, "offset_x": 0.0, "offset_y": 0.0}}, {"id": "choice", "type": "choice", "prompt": "Continuer ?", "choices": [{"text": "Oui", "goto": "set_flag"}]}, {"id": "skipped", "type": "text", "text": "Ne doit pas jouer", "duration": 0.0}, {"id": "set_flag", "type": "set_value", "path": "run.story_scene_smoke", "operation": "set", "value": 7}, {"id": "finish", "type": "end"}, ], } _check(await story_player.call("play_definition", "smoke_story", story_definition), "Le lecteur de scènes a refusé une définition valide") _check(story_events_seen.has("intro") and story_events_seen.has("line") and story_events_seen.has("move") and story_events_seen.has("set_flag"), "La scène n'a pas exécuté toute sa séquence") _check(not story_events_seen.has("skipped"), "Le choix n'a pas sauté vers son identifiant cible") _check(int(Registry.read("run.story_scene_smoke")) == 7, "L'événement de donnée n'a pas écrit sa valeur") _check(not bool(story_player.get("running")), "Le lecteur de scènes reste bloqué après sa fin") story_player.queue_free() await get_tree().process_frame _restore_progress() if failures.is_empty(): print("SMOKE TEST PASSED: customer arrivals, dining, dirty plates, dishwasher payment, closure, data editor content, combat, stamina, cooking, drops, and inventory") get_tree().quit(0) else: print("SMOKE TEST FAILED: %d failure(s)" % failures.size()) get_tree().quit(1) # Population réellement atteignable dans le biome courant: le budget de salle # borné par la somme des plafonds des monstres que ce biome autorise. func _achievable_population() -> int: var ceiling := 0 for candidate: Dictionary in DiveMap.spawn_candidates().values(): ceiling += maxi(0, int(candidate.get("spawn_max", 0))) return mini(DiveMap.room_population(), ceiling) func _restore_progress() -> void: GameState.coins = int(snapshot.coins) GameState.ingredients = snapshot.ingredients GameState.purchased_upgrades.clear() for id: String in snapshot.upgrades: GameState.purchased_upgrades.append(id) GameState.entity_counters = snapshot.entity_counters GameState.variable_base_bonuses = snapshot.variable_base_bonuses GameState.variable_coefficient_bonuses = snapshot.variable_coefficient_bonuses GameState.fact_values = snapshot.fact_values GameState.pending_toast = str(snapshot.pending_toast) GameState.restaurant_operations = snapshot.restaurant_operations.duplicate(true) GameState.restaurant_plates = snapshot.restaurant_plates.duplicate(true) GameState.menu_enabled_recipes = snapshot.menu.duplicate() GameState.takeout_recipe = str(snapshot.takeout_recipe) GameState.takeout_stock = int(snapshot.takeout_stock) GameState.takeout_cooking_remaining = float(snapshot.takeout_cooking_remaining) GameState.takeout_cooking_total = float(snapshot.takeout_cooking_total) GameState.takeout_restock_threshold = int(snapshot.takeout_restock_threshold) GameState.takeout_restock_target = int(snapshot.takeout_restock_target) GameState.takeout_restocking = bool(snapshot.takeout_restocking) GameState.restaurant_simulation_accumulator = 0.0 # Le fichier est réécrit tel qu'il était: c'est la seule forme d'isolation # qu'un champ ajouté plus tard ne peut pas contourner. # Plus personne n écrit après ce point: un appel différé passerait sinon # juste après la restauration et remettrait l état du test dans le fichier. GameState.save_locked = true if had_save and not save_file_before.is_empty(): var restored := FileAccess.open(GameState.SAVE_PATH, FileAccess.WRITE) if restored != null: restored.store_string(save_file_before) restored.close() elif not had_save and FileAccess.file_exists(GameState.SAVE_PATH): DirAccess.remove_absolute(GameState.SAVE_PATH) # La composition d'une salle est tirée au poids: choisir un ennemi par index # donnerait un type différent à chaque exécution. On prend donc n'importe quel # ennemi utilisable et on lui impose le pattern voulu — celui de plus grande # portée, pour que le test puisse le placer hors collision du joueur. # Pose une créature à une distance donnée du héros et remet le héros dans un # état propre: sans ça, le recul du coup précédent déplace la cible et # l'invulnérabilité avale la sonde suivante. func _place_probe(probe: Enemy, hero: Player, direction: Vector2, distance: float) -> void: hero.invulnerability = 0.0 hero.knockback_velocity = Vector2.ZERO probe.global_position = hero.global_position - direction.normalized() * distance # Une créature vivante du tableau qui n'est pas déjà réservée. Choisir par # capacité plutôt que par indice: la composition d'une salle est écrite à la # main et rien ne garantit sa taille. func _spare_enemy(pool: Array, reserved: Array) -> Enemy: for node: Node in pool: if not is_instance_valid(node) or not node is Enemy: continue var candidate := node as Enemy if not candidate.active or reserved.has(candidate): continue return candidate return null # Complète la salle jusqu'au nombre de créatures dont le test a besoin. func _ensure_enemy_supply(dungeon: Node, wanted: int) -> void: var living := 0 for node: Node in get_tree().get_nodes_in_group("enemy"): if is_instance_valid(node) and node is Enemy and (node as Enemy).active: living += 1 var spawn_id := str(DataCatalog.ENEMIES.keys()[0]) while living < wanted: var extra: Enemy = load("res://scenes/enemies/enemy.tscn").instantiate() dungeon.add_child(extra) extra.setup(spawn_id) extra.global_position = Vector2(320.0 + float(living) * 90.0, 220.0) living += 1 func _enemy_with_attack_mode(enemies: Array, mode: String) -> Enemy: var wanted_pattern := "" var widest_range := -1.0 for pattern_id: String in DataCatalog.ATTACK_PATTERNS: var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS[pattern_id] if str(pattern.type) == mode and float(pattern.range) > widest_range: widest_range = float(pattern.range) wanted_pattern = pattern_id if wanted_pattern.is_empty(): return null for candidate: Variant in enemies: var enemy := candidate as Enemy # Un ennemi abattu ou en cours de suppression reste dans le groupe une # frame de plus, et son _physics_process ne tourne plus: il n'attaquera # jamais. if enemy == null or not is_instance_valid(enemy) or enemy.is_queued_for_deletion() or not enemy.active: continue # setup() lit la liste depuis la définition: on l'échange le temps de # l'appel, les autres monstres du même type gardent le leur. var definition: Dictionary = DataCatalog.ENEMIES[enemy.enemy_id] var previous_attacks: Array = definition.attacks.duplicate(true) definition.attacks = [{"pattern": wanted_pattern, "damage_min": 1.0, "damage_max": 1.0, "cooldown": 1.15, "weight": 1.0}] enemy.setup(enemy.enemy_id) definition.attacks = previous_attacks return enemy return null func _collect_ingredient_labels(menu: RecipeMenu, row_index: int) -> Array[Label]: var result: Array[Label] = [] var button := menu.row_buttons[row_index] var row := button.get_parent() if row == null: return result for child: Node in row.get_children(): if child is HBoxContainer: for entry: Node in child.get_children(): if entry is Label: result.append(entry as Label) return result # L'ardoise est une upgrade: un test qui la veut ouverte la paie par un # modificateur, plutôt que de supposer qu'une upgrade a été achetée avant lui. func _open_order_board(slots: int) -> void: GameState.remove_modifiers_by_source("test:board") GameState.add_modifier("test:board", "order_board_slots", "flat", float(slots)) func _close_order_board() -> void: GameState.remove_modifiers_by_source("test:board") GameState.order_board.clear() # Le nombre de tabourets est une statistique: un test qui en dépend l'impose par # un modificateur, plutôt que de deviner quelles upgrades ont été achetées avant # lui. `_release_table_count` rend la salle à son état d'origine. func _force_table_count(restaurant: Node, wanted: int) -> void: GameState.remove_modifiers_by_source("test:table_count") var natural := int(GameState.resolve_game_variable("table_count")) GameState.add_modifier("test:table_count", "table_count", "flat", float(wanted - natural)) restaurant._refresh_tables() func _release_table_count() -> void: GameState.remove_modifiers_by_source("test:table_count") # Rouvrir est désormais un **geste**, jamais un effet de bord: un test qui veut # un magasin ouvert le rouvre explicitement, des deux côtés. func _reopen_shop(restaurant: Node) -> void: GameState.open_dining() GameState.open_takeout_counter() restaurant._apply_dining_opening() func _reset_dining_room(restaurant: Node) -> void: GameState.ingredients.clear() GameState.capture_restaurant_plates([]) GameState.capture_restaurant_customers([]) # Les sources de test ne doivent jamais franchir un bloc: un four ouvert ou # un magasin fermé à la main fausserait tout ce qui suit. for test_source: String in ["test:board", "test:stoves", "test:console", "test:shelf", "test:spices", "test:takeout", "test:away", "test:brigade", "test:crew"]: GameState.remove_modifiers_by_source(test_source) GameState.takeout_recipe = "" GameState.takeout_stock = 0 GameState.takeout_cooking_remaining = 0.0 GameState.takeout_cooking_total = 0.0 GameState.takeout_restock_threshold = 2 GameState.takeout_restock_target = 4 GameState.takeout_restocking = false _reopen_shop(restaurant) GameState.remove_modifiers_by_source("test:board") GameState.order_board.clear() restaurant.queue.clear() restaurant.takeout_queue.clear() restaurant.takeout_service_timer = 0.0 if restaurant.takeout_cook_is_assigned(): restaurant.release_takeout_cook(restaurant.takeout_cook) if restaurant.takeout_server_is_assigned(): restaurant.release_takeout_server(restaurant.takeout_server) for leftover: Node in get_tree().get_nodes_in_group("dish"): leftover.queue_free() for stale_customer: Customer in restaurant.customers.duplicate(): if is_instance_valid(stale_customer): stale_customer.queue_free() restaurant.customers.clear() for table: Dictionary in restaurant.tables: table.customers = [null] table.dirty_count = 0 for station: Dictionary in restaurant.stations: station.recipe = "" station.remaining = 0.0 station.total = 0.0 _reopen_shop(restaurant) func _seat_customer(restaurant: Node, table_index: int, seat_index: int, recipe_id: String) -> Customer: var new_customer: Customer = load("res://scenes/restaurant/customer.tscn").instantiate() restaurant.add_child(new_customer) var table: Dictionary = restaurant.tables[table_index] table.customers[seat_index] = new_customer restaurant.customers.append(new_customer) new_customer.setup("regular", restaurant, table_index, seat_index, table.seats[seat_index], Vector2(1190, 55)) new_customer.cash = 500 new_customer.appetite = 1 new_customer.force_waiting(recipe_id) return new_customer func _tap_action(action: String) -> void: var press_event := InputEventAction.new() press_event.action = action press_event.pressed = true Input.parse_input_event(press_event) await get_tree().create_timer(0.06).timeout var release_event := InputEventAction.new() release_event.action = action release_event.pressed = false Input.parse_input_event(release_event) await get_tree().physics_frame