extends Node var failures: Array[String] = [] var had_save := false 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") had_save = FileAccess.file_exists(GameState.SAVE_PATH) 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), } # 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() # Per-entity counters persist too, so they need the same isolation. GameState.entity_counters.clear() 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 _check(GameState.get_upgrade_state("big_egg_core") == "available", "Central upgrade node was not initially available") _check(GameState.purchase_upgrade("big_egg_core"), "Free 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 walk_speed: float = maxf(1.0, player.get_move_speed() * player.walk_speed_multiplier) 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 / walk_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. var kitchen_column: Array[Node2D] = [restaurant.cooking_station, shelf, dishwasher, trash] for station: Node2D in kitchen_column: _check(absf(station.global_position.x - restaurant.cooking_station.global_position.x) < 1.0, "A kitchen appliance is not aligned in the vertical column") for index: int in range(kitchen_column.size() - 1): var station_gap: float = kitchen_column[index + 1].global_position.y - kitchen_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") _check(restaurant.get_plating_point().is_equal_approx(restaurant.cooking_station.global_position), "A finished meal is not plated on the stove itself") # 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 >= 900.0, "The restaurant floor no longer fills the logical screen") for solid_station: Node2D in kitchen_column: _check(solid_station.get_node_or_null("PropBody") != null, "A restaurant appliance has no collision body") for table: Dictionary in restaurant.tables: _check(table.get("collision") != null, "A customer table has no collision shape") # Les chaises sont a gauche et a droite: une table est large, pas haute. var seat_left: Vector2 = table.seats[0] var seat_right: Vector2 = table.seats[1] _check(is_equal_approx(seat_left.y, seat_right.y) and seat_right.x > seat_left.x, "Les sieges d'une table ne sont pas disposes horizontalement") _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") restaurant.hud.display_menu.open() await get_tree().process_frame _check(restaurant.hud.display_menu.visible and restaurant.hud.display_menu.buttons.size() == 4, "Display menu does not expose all resolutions through native") restaurant.hud.display_menu.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") restaurant.handle_interaction("cooking") await get_tree().process_frame _check(restaurant.recipe_menu.visible, "Cooking menu did not open") _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 _check(restaurant.recipe_menu.recipe_buttons[4].has_focus(), "Down input did not traverse the complete recipe grid") _check(restaurant.recipe_menu.scroll.scroll_vertical > 0, "Keyboard recipe focus did not scroll to the bottom grid card") 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("cooking") 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(restaurant.recipe_menu.recipe_buttons[1].has_focus(), "Right input did not move recipe focus across the grid") 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") _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.global_position await get_tree().physics_frame await get_tree().physics_frame restaurant.cooking_remaining = 0.12 await get_tree().create_timer(0.06).timeout _check(restaurant.cooking_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 await _tap_action("action") _check(player.held_dish == physical_dish, "Interact did not pick up the physical plate") # 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") var served_center: Vector2 = restaurant.tables[customer.table_index].center var near_side := 1.0 if customer.seat_index == 1 else -1.0 var expected_plate := served_center + Vector2(restaurant.PLATE_TABLE_OFFSET * near_side, 0) _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() == 3, "Table upgrades did not unlock three dining tables") _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_canvas: UpgradeTreeCanvas = upgrade_tree.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") 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, 0, 1, "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 # 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.cooking_remaining > 0.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_station_status("cooking") _check(not stove_status.is_empty() and stove_status.has("progress"), "The stove does not report its own production status") _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(restaurant.cooking_remaining <= 0.0 and restaurant.cooking_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_station_status("cooking").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()), "Le four progresse sans joueur et sans upgrade") GameState.add_ingredient("egg", 1) restaurant.start_cooking("fried_egg") var idle_remaining: float = restaurant.cooking_remaining restaurant._process(0.5) _check(is_equal_approx(restaurant.cooking_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.4), "Le four autonome ne tourne pas au taux accordé") restaurant._process(1.0) _check(is_equal_approx(restaurant.cooking_remaining, idle_remaining - 0.4), "Le four autonome n'a pas avancé au taux attendu") _check(str(restaurant.get_station_status("cooking").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_station_status("cooking").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") # Unwanted plates go in the trash instead of staying in circulation. _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") restaurant.handle_interaction("trash") await get_tree().process_frame _check(player.held_dish == null and get_tree().get_nodes_in_group("dish").is_empty(), "The trash can did not destroy the carried plate") # 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_plates() 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") # Customer orders are only legible within the player vision stat. _reset_dining_room(restaurant) 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") # Park the player out of range before seating so the order starts unknown. 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(not vision_customer.player_can_read_order(), "A customer order was legible before the player ever came close") player.global_position = vision_customer.global_position + Vector2(vision_range * 0.5, 0) vision_customer._update_order_knowledge() _check(vision_customer.player_can_read_order(), "A customer order was unreadable inside the player vision range") # Once read, an order stays known however far the player walks away. player.global_position = vision_customer.global_position + Vector2(vision_range + 400.0, 0) vision_customer._update_order_knowledge() _check(vision_customer.player_can_read_order(), "A known customer order was forgotten when the player walked away") vision_customer.assign_order("fried_egg") _check(not vision_customer.player_can_read_order(), "A brand new order was already known before being read") # La commande lue s'efface après la durée de mémoire, et se relit de près. 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 = vision_customer.global_position vision_customer._update_order_knowledge(0.0) _check(vision_customer.player_can_read_order(), "Une commande n'est pas lisible au contact du client") player.global_position = vision_customer.global_position + Vector2(vision_range + 400.0, 0) vision_customer._update_order_knowledge(memory_seconds * 0.5) _check(vision_customer.player_can_read_order(), "Une commande a été oubliée avant la fin de sa durée de mémoire") vision_customer._update_order_knowledge(memory_seconds) _check(not vision_customer.player_can_read_order(), "Une commande est restée affichée après sa durée de mémoire") player.global_position = vision_customer.global_position vision_customer._update_order_knowledge(0.0) _check(vision_customer.player_can_read_order(), "Une commande oubliée ne redevient pas lisible en approchant") # 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, 1, 1, "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.day_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() for capability: String in ["automated_cooking", "automated_service", "automated_cleanup", "automated_supply"]: GameState.restaurant_operations[capability] = true restaurant.evaluate_restaurant_closure() _check(not restaurant.day_closed and not restaurant.hud.restaurant_closed_label.visible, "Complete automation could not keep or reopen restaurant operations") # Reproduce the former dungeon-transition crash: a state change had queued a # closure callback after the restaurant was detached from the SceneTree. restaurant.day_closed = true 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") 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.run_speed)), "Player run speed was not loaded from the active profile") _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 walk_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 walk_consumption: float = walk_start_stamina - dungeon.stamina _check(walk_consumption > 0.02, "Walking did not consume stamina") await _tap_action("toggle_run") _check(dungeon_player.is_running, "Run/walk input did not toggle running on") var run_start_stamina: float = dungeon.stamina 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 run_consumption: float = run_start_stamina - dungeon.stamina _check(run_consumption > walk_consumption * 2.0, "Running did not consume substantially more stamina than walking") # 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") # The PHP publisher encodes an empty associative array as [], so an empty # state_styles map must not invalidate the whole upgrade tree. _check(DataCatalog._normalize_upgrade_state_styles([]) == {}, "An empty state style list published as [] was rejected instead of read as an empty map") _check(DataCatalog._normalize_upgrade_state_styles({"locked": []}) == {"locked": {}}, "An empty per-state style published as [] was rejected instead of read as an empty map") _check(DataCatalog._normalize_upgrade_state_styles({"nonsense_state": {}}) == null, "State style validation stopped rejecting unknown upgrade states") # --- 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_count"), "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_count"), "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_count")) == 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 8, conditions adressées --------------------------- _check(DataCatalog.SCHEMA_VERSION == 8, "Le schéma de données n'est pas en version 8") 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") # --- 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() >= 6 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(DataCatalog.ATTACK_PATTERNS.has(str(enemy_definition.attack_pattern)), "Enemy references a missing attack pattern") _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(float(pattern_definition.cooldown) >= 1.15, "An enemy attack cadence is still too frequent") var configured_enemy_count := 0 for enemy_definition: Dictionary in DataCatalog.ENEMIES.values(): configured_enemy_count += int(enemy_definition.spawn_count) _check(enemies.size() == configured_enemy_count, "Dungeon did not create the complete data-driven enemy wave") 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") 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 := "" var temporary_direct_pattern_id := "" for candidate: Node in enemies: var candidate_enemy := candidate as Enemy if candidate_enemy.attack_mode == "direct_hit": enemy = candidate_enemy break if enemy == null: var direct_pattern_id := "" for pattern_id: String in DataCatalog.ATTACK_PATTERNS: if str(DataCatalog.ATTACK_PATTERNS[pattern_id].type) == "direct_hit": direct_pattern_id = pattern_id break if direct_pattern_id.is_empty(): direct_pattern_id = "smoke_direct_pattern" temporary_direct_pattern_id = direct_pattern_id DataCatalog.ATTACK_PATTERNS[direct_pattern_id] = {"display_name": "Smoke Direct", "type": "direct_hit", "range": 40.0, "angle": 360.0, "windup": 0.0, "cooldown": 1.15, "active_duration": 0.0, "rotation_speed": 0.0, "tick_interval": 0.0, "color": Color("ff4d6d")} 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_count = 0 direct_definition.attack_pattern = direct_pattern_id 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(100.0) _check(rolled_damage >= 90.0 and rolled_damage <= 110.0, "Enemy damage variance exceeded its configured percentage") variance_observed = variance_observed or not is_equal_approx(rolled_damage, 100.0) _check(variance_observed, "Enemy damage rolls did not vary") var rear_enemy: Enemy = enemies[2] enemy.health = maxf(enemy.health, 1000.0) rear_enemy.health = maxf(rear_enemy.health, 1000.0) enemy.contact_cooldown = 999.0 rear_enemy.contact_cooldown = 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") var resistant_enemy: Enemy = enemies[3] 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.contact_cooldown = 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.attack_mode == "direct_hit" and not enemy.telegraphing, "Direct-hit pattern 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 = enemies[1] frontal_enemy._interrupt_attack() frontal_enemy.contact_cooldown = 0.0 dungeon_player.invulnerability = 0.0 dungeon_player.knockback_velocity = Vector2.ZERO frontal_enemy.global_position = dungeon_player.global_position + Vector2(50, 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") var original_detected_movement := frontal_enemy.detected_movement frontal_enemy.detected_movement = "linear" var movement_during_attack := frontal_enemy._desired_movement(0.016, true, dungeon_player.global_position - frontal_enemy.global_position, frontal_enemy.global_position.distance_to(dungeon_player.global_position)) _check(movement_during_attack.length() > 0.0, "Non-static enemy stopped moving while its attack was active") frontal_enemy.detected_movement = original_detected_movement 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.contact_cooldown >= frontal_enemy.attack_cooldown_duration * 0.6, "Knockback did not reset enemy attack recovery") frontal_enemy.knockback_velocity = Vector2.ZERO frontal_enemy.contact_cooldown = 0.0 frontal_enemy.global_position = dungeon_player.global_position + Vector2(50, 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") var health_before_windup := dungeon_player.health await get_tree().create_timer(frontal_enemy.windup_duration * 0.45).timeout _check(is_equal_approx(dungeon_player.health, health_before_windup), "Frontal attack dealt damage before its warning completed") await get_tree().create_timer(frontal_enemy.windup_duration * 0.70).timeout _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.global_position = Vector2(920, 180) var swipe_enemy: Enemy = enemies[4] swipe_enemy._interrupt_attack() swipe_enemy.contact_cooldown = 0.0 swipe_enemy.global_position = dungeon_player.global_position + Vector2(70, 0) swipe_enemy.look_direction = Vector2.LEFT dungeon_player.invulnerability = 999.0 await get_tree().physics_frame await get_tree().physics_frame _check(swipe_enemy.attack_mode == "swipe" and swipe_enemy.attack_state == "windup" and swipe_enemy.telegraphing, "Swipe pattern did not begin its warning") await get_tree().create_timer(swipe_enemy.windup_duration + 0.08).timeout var swipe_direction_before := swipe_enemy.attack_direction await get_tree().create_timer(0.12).timeout _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.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_count = 0 sticky_definition.attack_pattern = "sticky_circle" 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.target = dungeon_player sticky_enemy.global_position = dungeon_player.global_position + Vector2(40, 0) sticky_enemy.look_direction = Vector2.LEFT sticky_enemy.contact_cooldown = 0.0 dungeon_player.invulnerability = 999.0 await get_tree().physics_frame await get_tree().physics_frame var sticky_origin_before := sticky_enemy.attack_origin_offset dungeon_player.global_position += Vector2(12, 0) await get_tree().physics_frame _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") await get_tree().create_timer(sticky_enemy.windup_duration + 0.08).timeout _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) if not temporary_direct_pattern_id.is_empty(): DataCatalog.ATTACK_PATTERNS.erase(temporary_direct_pattern_id) # 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") await get_tree().create_timer(0.35).timeout _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 _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) 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.restaurant_simulation_accumulator = 0.0 if had_save: GameState.save_game() elif FileAccess.file_exists(GameState.SAVE_PATH): DirAccess.remove_absolute(GameState.SAVE_PATH) func _reset_dining_room(restaurant: Node) -> void: GameState.ingredients.clear() GameState.capture_restaurant_plates([]) 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, null] table.dirty_count = 0 restaurant.cooking_recipe = "" restaurant.cooking_remaining = 0.0 restaurant.day_closed = false 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