extends Node

# Le moteur de la D3 (phase 6 du plan de l'histoire), sur des fiches d'essai
# quand le sujet est le moteur, sur la vraie fiche quand c'est une décision de
# l'histoire :
#
# 1. Le cadeau par un crochet : n'importe quel crochet accorde une amélioration
#    (ici la mort d'une créature d'essai), sans coût ni conditions. Au
#    changement de monde, une amélioration « meta » reste, une de portée
#    « dimension » part.
# 2. Le poste à épices, cadeau de Gars-Rond (décision 6 et choix de l'auteur du
#    07/10) : invisible et inachetable tant qu'il n'est pas donné ; donné, il
#    ouvre un poste et sa suite dans l'arbre, et suit le chef d'un monde à
#    l'autre.
# 3. La présence d'un client (Coach Chrome) : attablé, il relève l'allure des
#    serveurs, et eux seuls marchent plus vite ; elle tombe quand il part ou
#    quitte la scène, et une présence restée d'avant un changement de scène
#    s'efface quand le resto rassoit ses convives.
# 4. La carte du monde : sans elle, ni bouton ni écran ; avec, le menu de pause
#    l'offre, l'écran se referme sur le menu, et la touche de la carte l'ouvre
#    en jeu. La course montre toutes les cases du monde, une branche seulement
#    une fois visitée (sous sa case), et où l'on est. Les quêtes en cours à
#    leur étape ; une scène entendue inscrit sa note, une seule fois ; les
#    boutiques des salles visitées, avec ce qu'elles vendent.
# 5. La rame, dans le vrai donjon : réseau éteint, sa porte reste fermée et le
#    dit ; rallumé, elle ouvre la carte sur les stations déjà visitées du monde
#    (pas celle où l'on est, ni une jamais vue) ; en choisir une lance le
#    passage, et le choix ne sert qu'une fois ; « Rester ici » ne mène nulle part.
# 6. Le poste à épices (phase 7) : il refuse d'épicer sans piment de lave,
#    épice contre un piment, et rend le piment si on annule ; le plat épicé se
#    vend au coefficient du plat supérieur ; n'importe quel plat épicé se met
#    de côté à la console pour une quête qui l'attend, pas un plat ordinaire.
# 7. Les remplacements sous condition (phase 7, le sanctuaire du Saule) : une
#    ligne ne vaut que si ses conditions tiennent, la première ligne valide de
#    la case décide ; garantie tant qu'une étape reste à faire, la salle
#    l'emporte sur une rivale, puis redevient rare par sa seconde ligne.
# 8. L'employé de passage (phase 7, le Saule) : verrouillé, il ne vient pas ;
#    débloqué, il vient au retour d'une plongée, travaille au resto sans poste
#    ni salaire, ne s'embauche pas, reste dans la sauvegarde, et repart quand le
#    héros part en plongée.
# 9. Le chrono d'une salle (phase 7, les séances de Coach et la course de Jay),
#    dans le vrai donjon : « vider » gagné en abattant tout à temps, perdu au
#    bout du temps (les bêtes se retirent, la porte s'ouvre, sans récompense) ;
#    « tenir » garde la porte close même salle vide, et gagne au bout du temps ;
#    « les plots » se gagnent en les touchant tous ; quitter la salle avant la
#    fin, c'est perdre.

const META_GIFT := "probe_d3_meta_gift"
const WORLD_GIFT := "probe_d3_world_gift"
const GIVER := "probe_d3_giver"
const PRESENCE_GUEST := "probe_d3_presence_guest"
const HALL_HAND := "probe_d3_hall_hand"
const COOK_HAND := "probe_d3_cook_hand"
const MAP_SCENE := "probe_d3_map_scene"
const MAP_QUEST := "probe_d3_map_quest"
const MAP_SHOP := "probe_d3_map_shop"
const WorldMap := preload("res://scripts/core/world_map.gd")
const WorldMapScreen := preload("res://scripts/core/world_map_screen.gd")
## La salle de branche qui a reçu la boutique d'essai, à rendre à la fin.
var _shop_room := ""
const STATIONS := ["probe_d3_station_a", "probe_d3_station_b", "probe_d3_station_c"]
const SPICE_QUEST := "probe_d3_spice_quest"
const SPICE_ITEM := "probe_d3_spice_item"
const SPICE_SOURCE := "test:d3_spices"
const SALT_SOURCE := "test:d3_mountain_salt"
const GUARANTEED_ROOM := "probe_d3_guaranteed_room"
const RIVAL_ROOM := "probe_d3_rival_room"
const PASSING_HAND := "probe_d3_passing_hand"
const CHRONO_ROOMS := ["probe_d3_chrono_clear", "probe_d3_chrono_survive", "probe_d3_chrono_markers"]
const CHRONO_RUNNER := "probe_d3_chrono_runner"
const CHECKPOINT := "probe_d3_checkpoint"
var _loc_keys: Array[String] = []
var is_probe := false
var failures := 0

func _ready() -> void:
	if is_probe:
		return
	GameState.save_locked = true
	var probe := Node.new()
	probe.set_script(get_script())
	probe.set("is_probe", true)
	get_tree().root.add_child.call_deferred(probe)
	probe.call_deferred("_run")

func check(ok: bool, message: String) -> void:
	if not ok:
		failures += 1
		push_error("D3 ENGINE: " + message)

func _frames(count: int = 4) -> void:
	for frame: int in count:
		await get_tree().process_frame

func _run() -> void:
	check(DataCatalog.loaded_from_master, "master data not loaded: " + DataCatalog.last_load_error)
	GameState._reset_progress_state()
	GameState.story_triggers.enabled = false
	_install_fixtures()
	_spice_before_gift()
	_gifts_by_hook()
	_spice_given()
	GameState.complete_dimension()
	_after_world_change()
	await _presence()
	await _world_map()
	await _spices()
	await _passing_employee()
	await _rame()
	await _chrono()
	_conditional_replacement()
	await _finish()

# --- Les fiches d'essai -------------------------------------------------------------
func _install_fixtures() -> void:
	var model: Dictionary = (DataCatalog.UPGRADES[DataCatalog.UPGRADES.keys()[0]] as Dictionary).duplicate(true)
	for id: String in [META_GIFT, WORLD_GIFT]:
		var node := model.duplicate(true)
		node["display_name"] = id
		node["parent"] = ""
		node["secondary_parents"] = []
		node["modifiers"] = []
		node["on_purchase"] = []
		node["costs"] = [{"resource": "coins", "amount": 999999}]
		# Jamais visible: seul un cadeau peut l'accorder.
		node["visibility_conditions"] = [{"path": "fact.probe_d3_never", "operator": "truthy", "value": true}]
		node["unlock_conditions"] = []
		node["scope"] = "meta" if id == META_GIFT else "dimension"
		DataCatalog.UPGRADES[id] = node
	var giver: Dictionary = (DataCatalog.ENEMIES[DataCatalog.ENEMIES.keys()[0]] as Dictionary).duplicate(true)
	giver["on_kill"] = [{"op": "set", "path": "upgrade_nodes.%s.purchased" % META_GIFT, "value": true},
		{"op": "set", "path": "upgrade_nodes.%s.purchased" % WORLD_GIFT, "value": true}]
	DataCatalog.ENEMIES[GIVER] = giver
	var guest: Dictionary = (DataCatalog.CUSTOMER_PROFILES[DataCatalog.CUSTOMER_PROFILES.keys()[0]] as Dictionary).duplicate(true)
	guest["presence_modifiers"] = [{"variable": "hall_staff_pace", "layer": "flat", "operation": "add", "value": 0.5}]
	guest["fixed_recipe"] = ""
	guest["character"] = ""
	guest["orders_at_once"] = 1
	DataCatalog.CUSTOMER_PROFILES[PRESENCE_GUEST] = guest
	# Un serveur et un cuisinier d'essai: seules leurs affinités comptent ici.
	for id: String in [HALL_HAND, COOK_HAND]:
		var hand: Dictionary = (DataCatalog.EMPLOYEES[DataCatalog.EMPLOYEES.keys()[0]] as Dictionary).duplicate(true)
		var tasks := {}
		for task: String in ["take_order", "serve", "bus", "takeout_service"]:
			tasks[task] = {"affinity": 5 if id == HALL_HAND else 0}
		for task: String in ["cook", "shelve", "season", "takeout_restock"]:
			tasks[task] = {"affinity": 0 if id == HALL_HAND else 5}
		hand["tasks"] = tasks
		DataCatalog.EMPLOYEES[id] = hand
	var scene: Dictionary = (DataCatalog.STORY_SCENES[DataCatalog.STORY_SCENES.keys()[0]] as Dictionary).duplicate(true)
	scene["map_note"] = "Probe note"
	scene["skippable"] = true
	DataCatalog.STORY_SCENES[MAP_SCENE] = scene
	var quest: Dictionary = (DataCatalog.QUESTS[DataCatalog.QUESTS.keys()[0]] as Dictionary).duplicate(true)
	quest["display_name"] = "Probe quest"
	quest["scope"] = "dimension"
	quest["stages"] = [{"title": "Step one", "description": ""}, {"title": "Step two", "description": "Then this"}]
	DataCatalog.QUESTS[MAP_QUEST] = quest
	DataCatalog.ROOM_ELEMENTS[MAP_SHOP] = {"kind": "shop", "display_name": "Probe stall", "description": "", "size": 1.0,
		"solid": true, "damage_filter": [], "drops": [], "vanish_conditions": [], "offers": [
			{"kind": "consumable", "id": str(DataCatalog.RECIPES.keys()[0]), "price": 7, "stock": 0, "conditions": []}]}

# --- 1. Le cadeau par un crochet ------------------------------------------------------
func _gifts_by_hook() -> void:
	check(GameState.get_upgrade_state(META_GIFT) == "invisible" and not GameState.purchase_upgrade(META_GIFT),
		"a gift-only upgrade can be seen or bought")
	Registry.run_hook("enemies", GIVER, "on_kill")
	check(GameState.has_upgrade(META_GIFT) and GameState.has_upgrade(WORLD_GIFT),
		"a hook does not grant its upgrades")
	check(GameState.get_upgrade_state(META_GIFT) == "purchased", "a granted upgrade does not read as owned")

# --- 2. Le poste à épices ---------------------------------------------------------------
func _spice_before_gift() -> void:
	var node: Dictionary = DataCatalog.UPGRADES.get("spice_station", {})
	check(str(node.get("scope", "")) == "meta", "the spice station does not follow the chef across worlds")
	check(GameState.get_upgrade_state("spice_station") == "invisible", "the spice station shows before Gars-Rond gives it")
	GameState.coins = 999999
	check(not GameState.purchase_upgrade("spice_station"), "the spice station can be bought")
	check(int(GameState.resolve_game_variable("spice_station_count")) == 0, "a spice station stands before the gift")
	GameState.coins = 0

func _spice_given() -> void:
	# La scène de Gars-Rond fera ces deux gestes, comme l'émissaire pour sa poche.
	GameState.set_fact_value("spice_station_given", true)
	GameState.grant_upgrade("spice_station")
	check(GameState.get_upgrade_state("spice_station") == "purchased", "the given spice station does not read as owned")
	check(int(GameState.resolve_game_variable("spice_station_count")) >= 1, "the given spice station opens no station")
	check(GameState.get_upgrade_state("rare_spices") in ["available", "insufficient"],
		"the rare spices stay closed once the station is given (%s)" % GameState.get_upgrade_state("rare_spices"))

func _after_world_change() -> void:
	check(GameState.has_upgrade(META_GIFT) and not GameState.has_upgrade(WORLD_GIFT),
		"a world change does not keep the meta gift and drop the world one")
	check(GameState.has_upgrade("spice_station") and int(GameState.resolve_game_variable("spice_station_count")) >= 1,
		"the spice station stays behind in the previous world")
	check(bool(GameState.get_fact_value("spice_station_given")), "the world change forgets that the station was given")

# --- 3. La présence d'un client ------------------------------------------------------
func _presence() -> void:
	get_tree().change_scene_to_file("res://scenes/restaurant/restaurant.tscn")
	await _frames(20)
	var restaurant := get_tree().current_scene
	if restaurant == null or not restaurant.has_method("serve_customer") or restaurant.tables.is_empty():
		check(false, "the restaurant is not there for the presence check")
		return
	# Rideaux baissés: personne d'autre ne vient s'asseoir pendant le contrôle.
	GameState.close_dining("by_hand")
	GameState.close_takeout_counter("by_hand")
	await _frames(2)
	await _clear_dining_room(restaurant)
	check(is_equal_approx(_pace(), 1.0), "the hall staff pace is not 1 with nobody seated (%.2f)" % _pace())
	var server := _hand(restaurant, HALL_HAND)
	var cook := _hand(restaurant, COOK_HAND)
	var server_walk := server.move_speed()
	var cook_walk := cook.move_speed()
	var plain := _seat(restaurant, str(DataCatalog.CUSTOMER_PROFILES.keys()[0]))
	check(is_equal_approx(_pace(), 1.0), "a guest without a presence changes the hall staff pace")
	plain.begin_leaving()
	await _clear_dining_room(restaurant)
	var coach := _seat(restaurant, PRESENCE_GUEST)
	check(is_equal_approx(_pace(), 1.5), "a seated guest's presence does not raise the hall staff pace (%.2f)" % _pace())
	check(is_equal_approx(server.move_speed(), server_walk * 1.5), "the servers do not walk faster while the guest is there")
	check(is_equal_approx(cook.move_speed(), cook_walk), "the cooks walk faster too")
	coach.begin_leaving()
	check(is_equal_approx(_pace(), 1.0), "the presence outlives the guest's departure (%.2f)" % _pace())
	await _clear_dining_room(restaurant)
	var second := _seat(restaurant, PRESENCE_GUEST)
	check(second.is_present_at_table() and is_equal_approx(_pace(), 1.5), "a second visit brings no presence")
	await _clear_dining_room(restaurant)
	check(is_equal_approx(_pace(), 1.0), "the presence outlives a guest freed with the scene (%.2f)" % _pace())
	# Une présence restée d'avant un changement de scène (une sauvegarde, un
	# convive perdu) tombe quand le resto rassoit ses convives.
	GameState.add_modifier("presence:1", "hall_staff_pace", "flat", 0.5)
	restaurant._restore_customers()
	check(is_equal_approx(_pace(), 1.0), "a stale presence survives the restaurant seating its guests again")
	server.queue_free()
	cook.queue_free()
	await _frames(2)
	GameState.open_dining()

# --- 4. La carte du monde ------------------------------------------------------------
func _world_map() -> void:
	var restaurant := get_tree().current_scene
	var pause := _pause_menu()
	check(pause != null, "the restaurant has no pause menu")
	check(InputMap.has_action(WorldMapScreen.ACTION), "the map key is not declared")
	if pause == null:
		return
	GameState.set_key_item_amount("world_map", 0)
	pause.open()
	check(not pause.map_button.visible, "the pause menu offers a map the hero does not have")
	check(WorldMapScreen.open(restaurant) == null, "the map opens without the map")
	pause.close()
	await _frames(2)
	GameState.set_key_item_amount("world_map", 1)
	pause.open()
	check(pause.map_button.visible, "the pause menu does not offer the map")
	var screen := pause.open_world_map()
	check(screen != null and get_tree().paused, "the map does not open from the pause menu")
	if screen != null:
		screen.call("close")
	await _frames(2)
	check(pause.overlay.visible and get_tree().paused, "closing the map does not return to the pause menu")
	pause.close()
	await _frames(2)
	var press := InputEventAction.new()
	press.action = WorldMapScreen.ACTION
	press.pressed = true
	pause._unhandled_input(press)
	await _frames(1)
	var by_key: CanvasLayer = WorldMapScreen.current
	check(by_key != null and is_instance_valid(by_key), "the map key does not open the map")
	if by_key != null and is_instance_valid(by_key):
		by_key.call("close")
	await _frames(2)
	check(not get_tree().paused, "closing the map opened by its key leaves the game paused")
	_course()
	await _notes()
	_quests_and_shops()

## La course du monde courant, et une branche que la colonne ouvre.
func _course() -> void:
	var world := WorldMap.world_id()
	var course := WorldMap.course()
	check(not course.is_empty() and course.size() == DataCatalog.rooms_of(world).size(), "the map does not show every case of the world")
	var parent := ""
	var branch := ""
	for room_id: String in DataCatalog.rooms_of(world):
		for exit: Dictionary in DiveMap.exits_of(room_id):
			if str(exit.get("to", "")) == "room" and DiveMap.is_branch_room(str(exit.get("room", ""))):
				parent = room_id
				branch = str(exit.get("room", ""))
				break
		if not branch.is_empty():
			break
	check(not branch.is_empty(), "this world has no branch room to show")
	if branch.is_empty():
		return
	check(not WorldMap.visited_branch_rooms().has(branch), "an unvisited branch shows on the map")
	GameState.add_entity_counter("rooms", branch, "total_entered")
	var under := _case(parent)
	var listed := false
	for entry: Dictionary in under.get("branches", []):
		listed = listed or str(entry.room) == branch
	check(listed, "a visited branch does not show under its case")
	GameState.set_scoped_value("run", "dimension_id", world)
	GameState.set_scoped_value("run", "level", int(DataCatalog.ROOMS[parent].get("level", 0)))
	GameState.set_scoped_value("run", "room_id", parent)
	check(bool(_case(parent).get("here", false)), "the map does not show the case where the hero stands")
	GameState.set_scoped_value("run", "room_id", branch)
	var branch_here := false
	for entry: Dictionary in _case(parent).get("branches", []):
		branch_here = branch_here or (str(entry.room) == branch and bool(entry.here))
	check(branch_here and not bool(_case(parent).get("here", false)), "the map does not show the branch where the hero stands")
	GameState.clear_run_values()
	_shop_room = branch
	(DataCatalog.ROOMS[branch]["elements"] as Dictionary)[MAP_SHOP] = {"chance": 1.0, "min": 1, "max": 1}

func _case(room_id: String) -> Dictionary:
	for entry: Dictionary in WorldMap.course():
		if str(entry.room) == room_id:
			return entry
	return {}

## Une scène entendue inscrit sa note, une fois; une scène sans note, rien.
func _notes() -> void:
	var before := WorldMap.notes().size()
	var player: Node = load("res://scripts/core/story_scene_player.gd").new()
	add_child(player)
	player.call("play", MAP_SCENE)
	player.call("skip")
	await _frames(4)
	var texts: Array[String] = []
	for note: Dictionary in WorldMap.notes():
		texts.append(str(note.text))
	check(texts.has("Probe note") and texts.size() == before + 1, "a scene heard does not write its note on the map")
	check(not GameState.note_map_scene(MAP_SCENE) and WorldMap.notes().size() == before + 1, "a scene heard twice writes its note twice")
	check(not GameState.note_map_scene(str(DataCatalog.STORY_SCENES.keys()[0])) or \
		not str(DataCatalog.STORY_SCENES[DataCatalog.STORY_SCENES.keys()[0]].get("map_note", "")).is_empty(),
		"a scene without a note writes on the map")
	player.queue_free()
	await _frames(2)

func _quests_and_shops() -> void:
	GameState.set_quest_value(MAP_QUEST, "state", 1)
	GameState.set_quest_value(MAP_QUEST, "stage", 1)
	var quest := {}
	for entry: Dictionary in WorldMap.quests():
		if str(entry.id) == MAP_QUEST:
			quest = entry
	check(str(quest.get("step", "")) == "Step two" and str(quest.get("detail", "")) == "Then this",
		"a quest in progress does not show its current step")
	GameState.set_quest_value(MAP_QUEST, "state", 2)
	for entry: Dictionary in WorldMap.quests():
		check(str(entry.id) != MAP_QUEST, "a finished quest stays on the map")
	if _shop_room.is_empty():
		return
	var shop := {}
	for entry: Dictionary in WorldMap.shops():
		if str(entry.id) == MAP_SHOP:
			shop = entry
	var priced := false
	for ware: Variant in shop.get("wares", []):
		priced = priced or str(ware).contains("7")
	check(not shop.is_empty() and (shop.get("where", []) as Array).has(Loc.entity("rooms", _shop_room, "display_name")) and priced,
		"a shop of a visited room does not show what it sells and where")

# --- 6. Le poste à épices ----------------------------------------------------------------
func _spices() -> void:
	var restaurant := get_tree().current_scene
	if restaurant == null or not restaurant.has_method("free_station"):
		check(false, "the restaurant is not there for the spice check")
		return
	DataCatalog.KEY_ITEMS[SPICE_ITEM] = {"display_name": "Probe spicy dish", "description": "", "color": "#e8552a",
		"visual": "", "scope": "dimension", "consumed_on_use": false, "lost_on_ko": false, "max_owned": 3}
	DataCatalog.QUESTS[SPICE_QUEST] = {"display_name": "Probe spice quest", "description": "", "scope": "dimension", "giver": "",
		"stages": [], "set_aside": {"recipe": "", "key_item": SPICE_ITEM, "spiced": true}}
	GameState.set_quest_value(SPICE_QUEST, "state", 1)
	GameState.add_modifier(SPICE_SOURCE, "spice_station_count", "flat", 1.0)
	GameState.add_modifier(SPICE_SOURCE, "cook_idle_rate", "flat", 1.0)
	restaurant._refresh_stations()
	var index: int = restaurant.free_station("spices")
	check(index >= 0, "no spice station to try")
	if index < 0:
		return
	var hero: Player = restaurant.player
	var recipe := str(DataCatalog.RECIPES.keys()[0])
	var plain := _plate(restaurant, recipe)
	hero.held_dish = plain
	plain.carrier = hero
	GameState.set_ingredient_amount("lava_pepper", 0)
	restaurant.handle_station_interaction("cooking", index)
	check(not restaurant.station_is_busy(index) and hero.held_dish == plain, "the spice station spices without a lava pepper")
	check(GameState.quest_wanting_dish(recipe, false) != SPICE_QUEST, "a plain dish is wanted as a spicy one")
	GameState.set_ingredient_amount("lava_pepper", 1)
	restaurant.handle_station_interaction("cooking", index)
	check(restaurant.station_is_busy(index) and GameState.get_ingredient_amount("lava_pepper") == 0,
		"the spice station does not take the dish and one lava pepper")
	# Annuler rend le plat tel quel, et le piment.
	restaurant.cancel_cooking(index)
	check(not restaurant.station_is_busy(index) and GameState.get_ingredient_amount("lava_pepper") == 1,
		"cancelling the spicing keeps the lava pepper")
	await _frames(2)
	# Le sel de la montagne ramène le piment à zéro : le poste épice sans en
	# prendre, et l'annulation n'en rend pas.
	GameState.add_modifier(SALT_SOURCE, "spice_pepper_cost", "override", 0.0, 0.0, "set")
	var salted := _dish_where(func(dish: Dish) -> bool: return dish.state == Dish.State.FILLED and not dish.spiced and dish.carrier == null)
	check(salted != null, "no plain dish back to spice with the mountain salt")
	if salted != null:
		hero.held_dish = salted
		salted.carrier = hero
		restaurant.handle_station_interaction("cooking", index)
		check(restaurant.station_is_busy(index) and GameState.get_ingredient_amount("lava_pepper") == 1,
			"with the mountain salt, the spice station still takes a lava pepper")
		restaurant.cancel_cooking(index)
		check(GameState.get_ingredient_amount("lava_pepper") == 1, "cancelling a free spicing hands a pepper back")
	GameState.remove_modifiers_by_source(SALT_SOURCE)
	await _frames(2)
	var back := _dish_where(func(dish: Dish) -> bool: return dish.state == Dish.State.FILLED and not dish.spiced and dish.carrier == null)
	check(back != null, "cancelling the spicing does not give the dish back")
	if back == null:
		return
	hero.held_dish = back
	back.carrier = hero
	restaurant.handle_station_interaction("cooking", index)
	restaurant.stations[index].remaining = 0.01
	restaurant._process(1.0)
	await _frames(2)
	var spicy := _dish_where(func(dish: Dish) -> bool: return dish.spiced)
	check(spicy != null and not spicy.superior, "the spice station does not give a spicy dish back")
	if spicy == null:
		return
	# Le resto propose aussitôt de le mettre de côté pour la quête qui l'attend :
	# on refuse ici, pour passer ensuite par la console.
	var offer: Variant = restaurant.get("_quest_dish_prompt")
	check(offer is Node and is_instance_valid(offer), "a spicy dish wanted by a quest is not offered to be set aside")
	if offer is Node and is_instance_valid(offer):
		(offer as Node).call("answer", false)
	await _frames(2)
	# Mis de côté à la console, pour la quête qui attend un plat épicé.
	hero.held_dish = spicy
	spicy.carrier = hero
	check(restaurant.can_use_interaction("console", 0, hero), "the console does not take a spicy dish for its quest")
	restaurant._set_aside_held_dish()
	check(GameState.key_item_amount(SPICE_ITEM) == 1, "a spicy dish set aside does not become the quest item")
	check(not get_tree().paused, "the spice check leaves the game paused")
	GameState.remove_modifiers_by_source(SPICE_SOURCE)
	restaurant._refresh_stations()

func _plate(restaurant: Node, recipe: String) -> Dish:
	var dish: Dish = load("res://scenes/restaurant/dish.tscn").instantiate()
	restaurant.add_child(dish)
	dish.setup(recipe)
	return dish

func _dish_where(test: Callable) -> Dish:
	for node: Node in get_tree().get_nodes_in_group("dish"):
		if node is Dish and not node.is_queued_for_deletion() and bool(test.call(node)):
			return node as Dish
	return null

# --- 5. La rame -------------------------------------------------------------------------
func _rame() -> void:
	var exits := [
		[{"side": "east", "to": "level", "room": "", "level": 2, "label": "", "conditions": []},
			{"side": "north", "to": "travel", "room": "", "level": 0, "label": "Rame", "conditions": []}],
		[{"side": "south", "to": "travel", "room": "", "level": 0, "label": "Rame", "conditions": []},
			{"side": "east", "to": "level", "room": "", "level": 3, "label": "", "conditions": []}],
		[{"side": "west", "to": "travel", "room": "", "level": 0, "label": "Rame", "conditions": []}],
	]
	for index: int in STATIONS.size():
		var id: String = STATIONS[index]
		DataCatalog.ROOMS[id] = _station(id, exits[index])
		Loc.ENTRIES["rooms.%s.display_name" % id] = {"fr": id, "en": id, "default": id}
		_loc_keys.append("rooms.%s.display_name" % id)
	GameState.set_fact_value("metro_powered", false)
	get_tree().change_scene_to_file("res://scenes/dungeon/dungeon.tscn")
	await _frames(20)
	var dungeon := get_tree().current_scene
	if dungeon == null or not dungeon.has_method("handle_station_interaction"):
		check(false, "the dungeon is not there for the train check")
		return
	for enemy: Node in get_tree().get_nodes_in_group("enemy"):
		enemy.queue_free()
	_enter(dungeon, STATIONS[0])
	_open_doors(dungeon)
	await _frames(2)
	check(int(DiveMap.peek_exit(1).step) == int(DiveMap.Step.LOCKED) and dungeon.door_missing(1) == Loc.ui("ui.dungeon.metro_unpowered"),
		"a train door runs, or does not say the network is dark")
	dungeon.handle_station_interaction("next_room", 1)
	check(not dungeon.is_passing() and WorldMapScreen.current == null, "a dark train door opens the map or leads somewhere")
	GameState.set_fact_value("metro_powered", true)
	GameState.add_entity_counter("rooms", STATIONS[1], "total_entered")
	check(int(DiveMap.peek_exit(1).step) == int(DiveMap.Step.TRAVEL) and dungeon.is_warp_open("next_room", 1),
		"a powered train door does not offer the train")
	var offered := DiveMap.travel_destinations()
	check(offered.has(STATIONS[1]) and not offered.has(STATIONS[0]) and not offered.has(STATIONS[2]),
		"the train offers the wrong stations: %s" % [offered])
	check(not DiveMap.choose_travel(STATIONS[2]), "the train goes to a station never visited")
	dungeon.handle_station_interaction("next_room", 1)
	var screen: CanvasLayer = WorldMapScreen.current
	check(screen != null and is_instance_valid(screen) and (screen.get("station_buttons") as Array).size() >= 1 and get_tree().paused,
		"the train door does not open the map on the stations")
	if screen == null or not is_instance_valid(screen):
		return
	screen.call("choose", STATIONS[1])
	check(not get_tree().paused and dungeon.is_passing() and str(dungeon.passage.get("room", "")) == STATIONS[1],
		"choosing a station does not start the train")
	for frame: int in 900:
		if not dungeon.is_passing():
			break
		await get_tree().process_frame
	check(DiveMap.current_room_id() == STATIONS[1], "the train does not arrive at the chosen station")
	check(str(GameState.get_scoped_value("run", "travel_to", "")) == "", "the train choice outlives the ride")
	_open_doors(dungeon)
	await _frames(2)
	dungeon.handle_station_interaction("next_room", 0)
	var back: CanvasLayer = WorldMapScreen.current
	check(back != null and is_instance_valid(back) and DiveMap.travel_destinations().has(STATIONS[0]),
		"the train does not offer the station just left")
	if back != null and is_instance_valid(back):
		back.call("close")
	await _frames(2)
	check(not get_tree().paused and not dungeon.is_passing() and DiveMap.current_room_id() == STATIONS[1],
		"staying put still moves the hero")
	GameState.set_fact_value("metro_powered", false)

# --- 8. L'employé de passage -----------------------------------------------------------------
func _passing_employee() -> void:
	var restaurant := get_tree().current_scene
	if restaurant == null or not restaurant.has_method("handle_interaction"):
		check(false, "the restaurant is not there for the passing hand check")
		return
	var hand: Dictionary = (DataCatalog.EMPLOYEES[HALL_HAND] as Dictionary).duplicate(true)
	hand["visit_chance"] = 1.0
	hand["salary_percent"] = 40.0
	hand["conditions"] = [{"path": "fact.probe_d3_never", "operator": "truthy", "value": true}]
	DataCatalog.EMPLOYEES[PASSING_HAND] = hand
	GameState.end_run()
	check(not GameState.employee_is_visiting(PASSING_HAND), "a passing hand comes before being unlocked")
	hand["conditions"] = []
	var payroll := GameState.payroll_percent()
	var posts := GameState.working_employees.size()
	GameState.end_run()
	await _frames(2)
	check(GameState.employee_is_visiting(PASSING_HAND) and GameState.present_employees().has(PASSING_HAND),
		"a passing hand does not come on the way back from a dive")
	check(restaurant._employee_node(PASSING_HAND) != null, "the passing hand is not in the restaurant")
	check(is_equal_approx(GameState.payroll_percent(), payroll) and GameState.working_employees.size() == posts,
		"a passing hand takes a post or a salary")
	check(not GameState.hire_employee(PASSING_HAND) and not GameState.employee_is_hired(PASSING_HAND),
		"a passing hand can be hired")
	# Figée comme sur disque : le payload tient les tableaux vivants de l'état.
	var payload := GameState._build_save_payload().duplicate(true)
	# Le départ en plongée, par le geste du joueur : il repart avec le héros.
	restaurant.handle_interaction("dungeon")
	for frame: int in 900:
		if get_tree().current_scene != restaurant:
			break
		await get_tree().process_frame
	await _frames(4)
	check(not GameState.employee_is_visiting(PASSING_HAND) and not GameState.present_employees().has(PASSING_HAND),
		"the passing hand stays after the hero leaves for a dive")
	# Une partie sauvegardée pendant son passage le retrouve au resto.
	GameState._apply_save_payload(payload)
	check(GameState.employee_is_visiting(PASSING_HAND), "a save made during the visit forgets the passing hand")
	GameState.end_employee_visits()

# --- 9. Le chrono d'une salle -----------------------------------------------------------------
func _chrono() -> void:
	var dungeon := get_tree().current_scene
	if dungeon == null or not dungeon.has_method("checkpoint_progress"):
		check(false, "the dungeon is not there for the chrono check")
		return
	var runner: Dictionary = (DataCatalog.ENEMIES[DataCatalog.ENEMIES.keys()[0]] as Dictionary).duplicate(true)
	runner["health"] = 1.0
	runner["on_kill"] = []
	runner["drops"] = []
	DataCatalog.ENEMIES[CHRONO_RUNNER] = runner
	var marker: Dictionary = (DataCatalog.ROOM_ELEMENTS[DataCatalog.ROOM_ELEMENTS.keys()[0]] as Dictionary).duplicate(true)
	marker["kind"] = "checkpoint"
	marker["solid"] = false
	marker["trigger_radius"] = 0.0
	marker["vanish_conditions"] = []
	marker["on_triggered"] = []
	marker["drops"] = []
	DataCatalog.ROOM_ELEMENTS[CHECKPOINT] = marker
	var goals := ["clear", "survive", "checkpoints"]
	for index: int in CHRONO_ROOMS.size():
		var room := _station(CHRONO_ROOMS[index], [])
		var wave: Dictionary = (room["waves"] as Array)[0]
		wave["count"] = 0 if goals[index] == "checkpoints" else 2
		wave["enemies"] = {} if goals[index] == "checkpoints" else {CHRONO_RUNNER: 1.0}
		if goals[index] == "checkpoints":
			room["elements"] = {CHECKPOINT: {"chance": 1.0, "min": 2, "max": 2}}
		room["chrono_seconds"] = 1.0
		room["chrono_goal"] = goals[index]
		room["on_chrono_won"] = [{"op": "set", "path": "run.probe_chrono", "value": 1}]
		room["on_chrono_lost"] = [{"op": "set", "path": "run.probe_chrono", "value": 2}]
		room["on_cleared"] = [{"op": "set", "path": "run.probe_cleared", "value": 1}]
		DataCatalog.ROOMS[CHRONO_ROOMS[index]] = room
		Loc.ENTRIES["rooms.%s.display_name" % CHRONO_ROOMS[index]] = {"fr": "Probe", "en": "Probe", "default": "Probe"}
		_loc_keys.append("rooms.%s.display_name" % CHRONO_ROOMS[index])
	var hud: Node = dungeon.get("hud")
	# Vider à temps : gagné, la salle se solde comme toujours.
	_chrono_enter(dungeon, CHRONO_ROOMS[0])
	check(str(dungeon.get("chrono_state")) == "running" and str(hud.get("objective_label").text).contains(" s"),
		"a chrono room does not start its chrono, or the HUD does not show it")
	for creature: Node in GameState.scoped_nodes(dungeon, "enemy"):
		(creature as Enemy).take_damage(99999.0, Vector2.RIGHT, 0.0, null)
	check(await _settles(dungeon), "the chrono does not end once the room is cleared")
	check(str(dungeon.get("chrono_state")) == "won" and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 1
		and int(GameState.get_scoped_value("run", "probe_cleared", 0)) == 1 and dungeon.is_exit_open(),
		"clearing in time does not win the chrono")
	# Le temps écoulé : perdu, les bêtes se retirent, la porte s'ouvre, sans solde.
	_chrono_enter(dungeon, CHRONO_ROOMS[0])
	check(await _settles(dungeon), "the chrono never runs out")
	check(str(dungeon.get("chrono_state")) == "lost" and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 2
		and int(GameState.get_scoped_value("run", "probe_cleared", 0)) == 0 and dungeon.is_exit_open()
		and GameState.scoped_nodes(dungeon, "enemy").is_empty() and int(dungeon.get("remaining_enemies")) == 0,
		"a chrono that runs out does not withdraw the creatures and open the door")
	# Tenir : salle vide avant la fin, la porte reste close ; au bout, gagné et soldé.
	_chrono_enter(dungeon, CHRONO_ROOMS[1])
	for creature: Node in GameState.scoped_nodes(dungeon, "enemy"):
		(creature as Enemy).take_damage(99999.0, Vector2.RIGHT, 0.0, null)
	await _frames(2)
	check(str(dungeon.get("chrono_state")) == "running" and not dungeon.is_exit_open()
		and int(GameState.get_scoped_value("run", "probe_cleared", 0)) == 0,
		"holding on lets the door open, or settles the room, before the end")
	check(await _settles(dungeon), "holding on never ends")
	check(str(dungeon.get("chrono_state")) == "won" and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 1
		and int(GameState.get_scoped_value("run", "probe_cleared", 0)) == 1 and dungeon.is_exit_open(),
		"holding on to the end does not win and settle the room")
	# Les plots : la porte close tant qu'il en reste ; tous touchés, gagné.
	_chrono_enter(dungeon, CHRONO_ROOMS[2])
	check(dungeon.checkpoint_progress() == Vector2i(0, 2) and not dungeon.is_exit_open(),
		"the markers are not counted, or the door opens before the race")
	var player: Node2D = dungeon.get("player")
	for node: Node in GameState.scoped_nodes(dungeon, "room_element"):
		if str(node.get("kind")) == "checkpoint":
			player.global_position = (node as Node2D).global_position
			await _frames(2)
	check(str(dungeon.get("chrono_state")) == "won" and dungeon.checkpoint_progress() == Vector2i(2, 2)
		and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 1 and dungeon.is_exit_open(),
		"touching every marker in time does not win the race")
	var lit := false
	for node: Node in GameState.scoped_nodes(dungeon, "room_element"):
		lit = lit or (str(node.get("kind")) == "checkpoint" and (node as Node2D).visible)
	check(not lit, "a touched marker stays on the ground")
	# Partir avant la fin, c'est perdre.
	_chrono_enter(dungeon, CHRONO_ROOMS[2])
	dungeon._leave_room()
	check(str(dungeon.get("chrono_state")) == "lost" and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 2,
		"leaving a room before its chrono ends does not lose it")
	GameState.set_scoped_value("run", "probe_chrono", 0)
	GameState.set_scoped_value("run", "probe_cleared", 0)

func _chrono_enter(dungeon: Node, room_id: String) -> void:
	GameState.set_scoped_value("run", "probe_chrono", 0)
	GameState.set_scoped_value("run", "probe_cleared", 0)
	dungeon.set("reward_pending", false)
	_enter(dungeon, room_id)

## Attend la fin du chrono (gagné ou perdu), trois secondes au plus.
func _settles(dungeon: Node) -> bool:
	var deadline := Time.get_ticks_msec() + 3000
	while str(dungeon.get("chrono_state")) == "running" and Time.get_ticks_msec() < deadline:
		await get_tree().process_frame
	return str(dungeon.get("chrono_state")) != "running"

# --- 7. Les remplacements sous condition -----------------------------------------------------
func _conditional_replacement() -> void:
	const DIMENSION := "dimension_three"
	const LEVEL := 3
	var step := [{"path": "run.probe_d3_step", "operator": "truthy", "value": true}]
	var guaranteed := _station(GUARANTEED_ROOM, [])
	guaranteed["replaces"] = [{"dimension": DIMENSION, "level": LEVEL, "chance": 1.0, "conditions": step},
		{"dimension": DIMENSION, "level": LEVEL, "chance": 0.25, "conditions": []}]
	DataCatalog.ROOMS[GUARANTEED_ROOM] = guaranteed
	var rival := _station(RIVAL_ROOM, [])
	rival["replaces"] = [{"dimension": DIMENSION, "level": LEVEL, "chance": 0.9, "conditions": []}]
	DataCatalog.ROOMS[RIVAL_ROOM] = rival
	GameState.clear_run_values()
	GameState.set_scoped_value("run", "probe_d3_step", true)
	check(is_equal_approx(DiveMap.replacement_chance(GUARANTEED_ROOM, DIMENSION, LEVEL), 1.0),
		"a replacement line whose conditions hold does not decide")
	var rng := RandomNumberGenerator.new()
	var always := true
	for seed: int in 40:
		rng.seed = seed
		always = always and DiveMap.resolve_room(DIMENSION, LEVEL, rng) == GUARANTEED_ROOM
	check(always, "a guaranteed room loses its slot to a rival")
	GameState.set_scoped_value("run", "probe_d3_step", false)
	check(is_equal_approx(DiveMap.replacement_chance(GUARANTEED_ROOM, DIMENSION, LEVEL), 0.25),
		"the room does not become rare once its step is done")
	var outcomes := {}
	for seed: int in 200:
		rng.seed = seed
		outcomes[DiveMap.resolve_room(DIMENSION, LEVEL, rng)] = true
	check(outcomes.has(RIVAL_ROOM) and outcomes.has(GUARANTEED_ROOM),
		"once rare, the room no longer shares its slot: %s" % [outcomes.keys()])
	guaranteed["replaces"] = [{"dimension": DIMENSION, "level": LEVEL, "chance": 1.0, "conditions": step}]
	check(not DiveMap.candidates_for(DIMENSION, LEVEL).has(GUARANTEED_ROOM),
		"a room whose only line fails still replaces the slot")
	GameState.clear_run_values()
	DataCatalog.ROOMS.erase(GUARANTEED_ROOM)
	DataCatalog.ROOMS.erase(RIVAL_ROOM)

func _station(id: String, exits: Array) -> Dictionary:
	var wave := {"count": 1, "enemies": {}, "delay": 0.0, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
		"chance": 1.0, "trigger": "any", "conditions": []}
	return {"display_name": id, "description": "", "dimension": "", "level": 0, "biome": "",
		"waves": [wave], "scroll_axis": "none", "scroll_extent": 1.0, "reward_choices": 0, "rewards": {},
		"visuals": {}, "vfx": {}, "modifiers": [], "elements": {}, "behaviours": [], "replaces": [],
		"max_per_run": 0, "conditions": [], "retreat_locked": false, "retreat_conditions": [],
		"exit_offset": 0, "exit_level": 0, "exits": exits, "retreat_band": false, "ends_dimension": false}

## La salle courante devient `room_id`, entrée par l'ouest : comme un passage,
## sans la marche.
func _enter(dungeon: Node, room_id: String) -> void:
	dungeon._clear_room_contents()
	GameState.set_scoped_value("run", "room_id", room_id)
	GameState.set_scoped_value("run", "arrival_side", "west")
	dungeon._enter_room()
	dungeon._spawn_wave()

func _open_doors(dungeon: Node) -> void:
	dungeon.remaining_enemies = 0
	dungeon.killed_in_room = 999
	dungeon.wave_index = dungeon.room_waves.size()
	dungeon.reward_pending = false

func _pause_menu() -> PauseMenu:
	for node: Node in get_tree().root.find_children("*", "CanvasLayer", true, false):
		if node is PauseMenu:
			return node as PauseMenu
	return null

func _pace() -> float:
	return float(GameState.resolve_game_variable("hall_staff_pace"))

func _hand(restaurant: Node, id: String) -> Employee:
	var hand := Employee.new()
	restaurant.add_child(hand)
	hand.setup(id, restaurant)
	return hand

func _seat(restaurant: Node, profile_id: String) -> Customer:
	var guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate()
	restaurant.add_child(guest)
	var table: Dictionary = restaurant.tables[0]
	table.customers[0] = guest
	restaurant.customers.append(guest)
	guest.setup(profile_id, restaurant, 0, 0, table.seats[0], restaurant.entrance_point())
	guest.global_position = table.seats[0]
	guest.begin_thinking()
	return guest

func _clear_dining_room(restaurant: Node) -> void:
	for stale: Customer in restaurant.customers.duplicate():
		if is_instance_valid(stale):
			stale.queue_free()
	restaurant.customers.clear()
	for table: Dictionary in restaurant.tables:
		table.customers = [null]
		table.dirty_count = 0
	GameState.order_board.clear()
	await _frames(2)

func _finish() -> void:
	DataCatalog.UPGRADES.erase(META_GIFT)
	DataCatalog.UPGRADES.erase(WORLD_GIFT)
	DataCatalog.ENEMIES.erase(GIVER)
	DataCatalog.CUSTOMER_PROFILES.erase(PRESENCE_GUEST)
	DataCatalog.EMPLOYEES.erase(HALL_HAND)
	DataCatalog.EMPLOYEES.erase(COOK_HAND)
	GameState.end_employee_visits()
	DataCatalog.EMPLOYEES.erase(PASSING_HAND)
	for id: String in CHRONO_ROOMS:
		DataCatalog.ROOMS.erase(id)
	DataCatalog.ENEMIES.erase(CHRONO_RUNNER)
	DataCatalog.ROOM_ELEMENTS.erase(CHECKPOINT)
	DataCatalog.STORY_SCENES.erase(MAP_SCENE)
	DataCatalog.QUESTS.erase(MAP_QUEST)
	if not _shop_room.is_empty():
		(DataCatalog.ROOMS[_shop_room]["elements"] as Dictionary).erase(MAP_SHOP)
	DataCatalog.ROOM_ELEMENTS.erase(MAP_SHOP)
	for id: String in STATIONS:
		DataCatalog.ROOMS.erase(id)
	DataCatalog.QUESTS.erase(SPICE_QUEST)
	DataCatalog.KEY_ITEMS.erase(SPICE_ITEM)
	for key: String in _loc_keys:
		Loc.ENTRIES.erase(key)
	get_tree().paused = false
	get_tree().change_scene_to_file("res://scenes/main/main_menu.tscn")
	await _frames(4)
	AudioDirector.shutdown()
	await get_tree().create_timer(1.0).timeout
	print("D3 ENGINE CHECK: ", "OK" if failures == 0 else "FAILED (%d)" % failures)
	get_tree().quit(0 if failures == 0 else 1)
