extends Node

# Le moteur de la D2 (phase 4 du plan de l'histoire, tâches 4.6 à 4.13), sur des
# fiches d'essai qui n'empruntent rien aux données de l'auteur :
#
# 1. Zéro emplacement de consommable : la statistique peut tomber à 0, et un
#    plat préparé se range quand même.
# 2. Le filtre de dégâts : un objet qui ne cède qu'à l'explosion refuse le coup
#    d'épée, cède au souffle, et le souffle du héros le compte parmi ses cibles.
# 3. La garde : intouchable tant qu'elle tient ; un effet de glace la brise ; ses
#    conditions la suspendent.
# 4. La fuite : la créature quitte le combat, compte comme vaincue pour la
#    porte, ne lâche rien, puis disparaît hors de l'arène. La capacité « fuir »
#    passe par le même chemin. Un coup mortel qui franchit son seuil la fait
#    fuir au lieu de tuer.
# 5. La boutique : offres visibles selon leurs conditions, achat payé par la
#    réserve, stock par plongée, refus (déjà à toi, trop cher, épuisé), et
#    l'écran qui s'ouvre en parlant à la boutique.
# 6. Les deux moments : « retour de plongée » à l'arrivée au resto, avec la façon
#    dont la plongée a fini, lu une fois par un déclencheur ; « resto fermé »
#    quand les deux rideaux sont baissés.
# 7. Le client capricieux : il change de plat tant qu'il n'est pas servi, et
#    paie au hasard entre ses deux bornes ; un client ordinaire paie juste.
# 8. La commande multiple (la pieuvre) : plusieurs plats choisis d'un coup,
#    servis dans n'importe quel ordre, posés à des places distinctes ; le repas
#    ne commence qu'au dernier ; chaque assiette devient sale, et seule la
#    dernière ramassée conclut la visite. Les attentes s'étirent d'autant, la
#    commande traverse une sauvegarde, et un départ en cours de service laisse
#    ses assiettes servies à débarrasser.
# 9. La vague alliée (la Serre assiégée, 15/10) : elle n'hérite ni ne lègue son
#    sac, échappe aux leviers de population et au quota de la porte, et ne
#    revient pas à la revisite ; son allié sort du groupe « enemy », chasse la
#    bête et la blesse, ne vise jamais le héros, et part avec la salle.

const DamageTypes := preload("res://scripts/combat/damage_types.gd")
const FACT := "probe_d2_fact"
const ITEM := "probe_d2_item"
const DEBRIS := "probe_d2_debris"
const SHOP := "probe_d2_shop"
const ABILITY := "probe_d2_flee"
const ICE := "probe_d2_ice"
const GUEST := "probe_d2_guest"
const MULTI_GUEST := "probe_d2_multi_guest"
const DISHES := ["probe_d2_dish_a", "probe_d2_dish_b"]
const RETURN_TRIGGER := "probe_d2_return"
const CLOSED_TRIGGER := "probe_d2_closed"
const ALLY := "probe_d2_ally"
const BEAST := "probe_d2_beast"
const ALLIED_ROOM := "probe_d2_allied_room"
const SOURCE := "test:d2_engine"
var is_probe := false
var failures := 0
## Les déclencheurs d'auteur, mis de côté pendant que le contrôle lit les siens.
var _author_triggers: Dictionary = {}

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("D2 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()
	_zero_slots()
	_allied_planning()
	get_tree().change_scene_to_file("res://scenes/dungeon/dungeon.tscn")
	await _frames(20)
	var dungeon := get_tree().current_scene
	for enemy: Node in get_tree().get_nodes_in_group("enemy"):
		enemy.queue_free()
	dungeon.remaining_enemies = 0
	# Une vague qui dort toujours: la salle n'est jamais nette, donc aucune
	# récompense ne s'ouvre par-dessus le contrôle. L'endurance ne s'épuise pas.
	var sleeping: Array[Dictionary] = [{"count": 1, "enemies": {}, "delay": 0.0, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
		"conditions": [{"path": "fact.probe_d2_sleep", "operator": "truthy", "value": true}]}]
	dungeon.room_waves = sleeping
	dungeon.wave_index = 0
	GameState.debug_infinite_stamina = true
	await _frames(2)
	_damage_filter(dungeon)
	await _thrown_bomb(dungeon)
	await _guard(dungeon)
	await _flee(dungeon)
	await _shop(dungeon)
	await _allied_wave(dungeon)
	GameState.debug_infinite_stamina = false
	await _moments()
	await _whims()
	await _multi_orders()
	await _finish()

# --- Les fiches d'essai -------------------------------------------------------------
func _install_fixtures() -> void:
	DataCatalog.GAME_FACTS[FACT] = {"display_name": FACT, "type": "bool", "default_value": false,
		"category": "story", "description": "", "scope": "meta"}
	DataCatalog.KEY_ITEMS[ITEM] = {"display_name": "Probe Map", "description": "", "color": "#c9b458",
		"visual": "", "scope": "run", "consumed_on_use": false, "lost_on_ko": false, "max_owned": 1}
	DataCatalog.ROOM_ELEMENTS[DEBRIS] = {"kind": "destructible", "display_name": "Probe debris", "size": 1.0,
		"health": 10.0, "solid": true, "damage_filter": ["explosion"], "offers": [], "drops": []}
	var weapon := str(GameState.starting_weapon_id())
	var recipe := str(DataCatalog.RECIPES.keys()[0])
	DataCatalog.ROOM_ELEMENTS[SHOP] = {"kind": "shop", "display_name": "Probe stall", "description": "", "size": 1.0,
		"solid": true, "damage_filter": [], "drops": [], "offers": [
			{"kind": "consumable", "id": recipe, "price": 5, "stock": 2, "conditions": []},
			{"kind": "key_item", "id": ITEM, "price": 3, "stock": 0, "conditions": []},
			{"kind": "weapon", "id": weapon, "price": 1, "stock": 0, "conditions": []},
			{"kind": "key_item", "id": ITEM, "price": 0, "stock": 0,
				"conditions": [{"path": "fact." + FACT, "operator": "truthy", "value": true}]},
		]}
	DataCatalog.ABILITIES[ABILITY] = {"display_name": "Probe flee", "kind": "flee",
		"params": {"speed_multiplier": 3.0, "drops_loot": false}}
	# Un effet de la famille glace, bâti sur une fiche existante pour en garder la
	# forme: seule sa famille compte ici.
	var model: Dictionary = (DataCatalog.EFFECTS[DataCatalog.EFFECTS.keys()[0]] as Dictionary).duplicate(true)
	model["kind"] = "ice"
	model["movement_multiplier"] = 1.0
	model["flags"] = []
	model["duration"] = 30.0
	DataCatalog.EFFECTS[ICE] = model
	var guest: Dictionary = (DataCatalog.CUSTOMER_PROFILES[DataCatalog.CUSTOMER_PROFILES.keys()[0]] as Dictionary).duplicate(true)
	guest["whim_seconds"] = 1.0
	guest["pay_min"] = 0.0
	guest["pay_max"] = 2.0
	DataCatalog.CUSTOMER_PROFILES[GUEST] = guest
	var multi: Dictionary = (DataCatalog.CUSTOMER_PROFILES[DataCatalog.CUSTOMER_PROFILES.keys()[0]] as Dictionary).duplicate(true)
	multi["orders_at_once"] = 3
	multi["fixed_recipe"] = ""
	multi["character"] = ""
	multi["category_preferences"] = {}
	multi["whim_seconds"] = 0.0
	multi["pay_min"] = 1.0
	multi["pay_max"] = 1.0
	DataCatalog.CUSTOMER_PROFILES[MULTI_GUEST] = multi
	# Deux plats connus d'emblée: le client capricieux a besoin d'en changer, et
	# une partie neuve n'en connaît guère.
	for dish_id: String in DISHES:
		var dish: Dictionary = (DataCatalog.RECIPES[DataCatalog.RECIPES.keys()[0]] as Dictionary).duplicate(true)
		dish["conditions"] = []
		dish["display_name"] = dish_id
		DataCatalog.RECIPES[dish_id] = dish
	GameState._changed()
	DataCatalog.STORY_TRIGGERS[RETURN_TRIGGER] = {"display_name": "probe", "place": "dive_return", "repeat": true,
		"scene": "", "dialogue": "", "character": "", "conditions": [], "on_fired": []}
	DataCatalog.STORY_TRIGGERS[CLOSED_TRIGGER] = {"display_name": "probe", "place": "restaurant_closed", "repeat": true,
		"scene": "", "dialogue": "", "character": "", "conditions": [], "on_fired": []}
	# Deux corps qui frappent au contact, sans attaque, phase ni butin, bâtis sur
	# une fiche existante pour en garder la forme.
	for creature_id: String in [ALLY, BEAST]:
		var creature: Dictionary = (DataCatalog.ENEMIES[DataCatalog.ENEMIES.keys()[0]] as Dictionary).duplicate(true)
		creature["attacks"] = []
		creature["ability_triggers"] = []
		creature["encounter"] = {}
		creature["levels"] = []
		creature["conditions"] = []
		creature["drops"] = []
		creature["audio"] = {}
		creature["guard"] = {"enabled": false, "broken_by": [], "conditions": []}
		creature["damage_resistances"] = {}
		creature["direct_hit"] = true
		creature["direct_damage_min"] = 5.0
		creature["direct_damage_max"] = 5.0
		creature["direct_hit_cooldown"] = 0.1
		creature["direct_hit_angle"] = 360.0
		creature["entrance_effect"] = "instant"
		creature["spawn_weight"] = 0.0
		creature["health"] = 1000.0
		DataCatalog.ENEMIES[creature_id] = creature

# --- 1. Zéro emplacement ---------------------------------------------------------------
func _zero_slots() -> void:
	GameState.add_modifier(SOURCE, "consumable_slot_count", "override", 0.0)
	check(GameState.consumable_slot_count() == 0, "the consumable slots cannot drop to zero (got %d)" % GameState.consumable_slot_count())
	var recipe := str(DataCatalog.RECIPES.keys()[0])
	var before := GameState.get_consumable_amount(recipe)
	check(GameState.grant_prepared_consumable(recipe) and GameState.get_consumable_amount(recipe) == before + 1,
		"a prepared dish is not kept without a slot")
	check(GameState.equipped_consumable_slots.is_empty(), "a slot still exists at zero slots")
	GameState.remove_modifiers_by_source(SOURCE)
	GameState.owned_consumables.erase(recipe)

# --- 2. Le filtre de dégâts ---------------------------------------------------------------
func _element(dungeon: Node, id: String, at: Vector2) -> Node2D:
	var element: Node2D = load("res://scripts/combat/room_element.gd").new()
	dungeon.add_child(element)
	element.global_position = at
	element.call("setup", id)
	return element

func _damage_filter(dungeon: Node) -> void:
	var debris := _element(dungeon, DEBRIS, dungeon.arena.get_center())
	debris.take_damage(DamageTypes.apply(5.0, ["direct"], debris))
	check(is_equal_approx(float(debris.get("health")), 10.0), "debris gave way to a sword")
	debris.take_damage(DamageTypes.apply(5.0, ["explosion", "fire"], debris))
	check(is_equal_approx(float(debris.get("health")), 5.0), "debris did not give way to an explosion")
	var blast := CombatExplosion.new()
	blast.from_hero = true
	blast.affects = "targets"
	dungeon.add_child(blast)
	check(blast._candidates().has(debris), "the hero's blast does not reach what breaks")
	blast.from_hero = false
	check(not blast._candidates().has(debris), "a creature's blast breaks the room's objects")
	blast.queue_free()
	debris.queue_free()

## Un plat explosif lancé sur des débris y détone, au lieu de les traverser et
## de sauter plus loin: c'est le geste réel du joueur dans un tunnel.
func _thrown_bomb(dungeon: Node) -> void:
	var at: Vector2 = dungeon.arena.get_center() + Vector2(0, 220)
	var debris := _element(dungeon, DEBRIS, at)
	var shot: Node2D = load("res://scripts/combat/player_projectile.gd").new()
	shot.position = at - Vector2(260, 0)
	shot.call("setup", {
		"direction": Vector2.RIGHT, "speed": 640.0, "radius": 9.0, "lifetime": 2.5, "damage": 30.0,
		"knockback": 0.0, "piercing": 0, "respect_occlusion": true, "type": "orb", "color": Color("f59e0b"),
		"source": dungeon.player, "damage_types": ["explosion"], "impact_style": "blunt",
		"explosion": {"trigger": "on_hit", "radius": 90.0, "angle": 360.0, "expand_speed": 0.0, "falloff": 1.0,
			"damage_scale": 1.0, "affects": "targets", "color": Color("f59e0b"), "combat_effects": [],
			"damage_types": ["explosion"], "damage": 30.0},
	})
	dungeon.add_child(shot)
	var reached := false
	for frame: int in 240:
		await get_tree().physics_frame
		if not is_instance_valid(shot) or shot.is_queued_for_deletion():
			reached = true
			break
	check(reached, "the thrown dish never landed")
	# Le souffle balaie à l'image suivante: on le laisse passer.
	await _frames(6)
	check(is_instance_valid(debris) and (float(debris.get("health")) < 10.0 or bool(debris.get("spent"))),
		"a thrown explosive dish flies through the debris instead of blowing them up")
	if is_instance_valid(debris):
		debris.queue_free()
	await _frames(2)

# --- 3. La garde -------------------------------------------------------------------------------
func _creature(dungeon: Node) -> Enemy:
	for id: String in DataCatalog.ENEMIES:
		if GameState.pick_enemy_variant(id, true).is_empty():
			continue
		var creature: Enemy = dungeon.summon_creature(id, dungeon.arena.get_center() + Vector2(200, 0))
		if creature != null:
			creature.set_physics_process(false)
			return creature
	return null

func _guard(dungeon: Node) -> void:
	var creature := _creature(dungeon)
	check(creature != null, "no creature could be summoned for the guard")
	if creature == null:
		return
	creature.enemy_definition = creature.enemy_definition.duplicate(true)
	creature.enemy_definition["guard"] = {"enabled": true, "broken_by": ["ice"], "conditions": []}
	creature.max_health = 1000.0
	creature.health = 1000.0
	creature.take_damage(DamageTypes.apply(50.0, ["direct"], creature))
	check(is_equal_approx(creature.health, 1000.0) and creature.guard_holds(), "a guarded creature took the hit")
	creature.apply_combat_effects([{"effect": ICE, "chance": 100.0}], "test")
	check(not creature.guard_holds(), "an ice effect does not break the guard")
	creature.take_damage(DamageTypes.apply(50.0, ["direct"], creature))
	check(creature.health < 1000.0, "a chilled creature is still untouchable")
	creature.effects.clear()
	creature.enemy_definition["guard"]["conditions"] = [{"path": "fact." + FACT, "operator": "truthy", "value": true}]
	check(not creature.guard_holds(), "a guard holds while its conditions fail")
	GameState.set_fact_value(FACT, true)
	check(creature.guard_holds(), "a guard does not hold once its conditions are met")
	GameState.set_fact_value(FACT, false)
	creature.dismiss()
	await _frames(2)

# --- 4. La fuite --------------------------------------------------------------------------------
func _flee(dungeon: Node) -> void:
	var creature := _creature(dungeon)
	check(creature != null, "no creature could be summoned for the flight")
	if creature == null:
		return
	var id := creature.enemy_id
	var killed: int = dungeon.killed_in_room
	var fled := int(GameState.get_entity_counter("enemies", id, "total_fled", 0))
	var drops := get_tree().get_nodes_in_group("ingredient_pickup").size()
	check(_trigger_flee(creature), "the flee ability did not act")
	check(not creature.active and creature.fleeing, "a fleeing creature is still in the fight")
	check(dungeon.killed_in_room == killed + 1, "a fleeing creature does not count for the door")
	check(int(GameState.get_entity_counter("enemies", id, "total_fled", 0)) == fled + 1, "the flight is not counted")
	check(get_tree().get_nodes_in_group("ingredient_pickup").size() == drops, "a fleeing creature dropped its loot")
	creature.set_physics_process(true)
	var arena: Rect2 = dungeon.get_combat_arena_rect()
	var left := false
	for frame: int in 900:
		if not is_instance_valid(creature):
			break
		left = left or not arena.has_point(creature.global_position)
		await get_tree().physics_frame
	check(not is_instance_valid(creature), "a fleeing creature never left")
	check(left, "a fleeing creature vanished without leaving the arena")
	await _flee_instead_of_dying(dungeon)

## Fuir remplace mourir (billet 116): un coup mortel qui franchit le seuil d'une
## fuite la fait partir, au lieu de laisser le corps a terre.
func _flee_instead_of_dying(dungeon: Node) -> void:
	var creature := _creature(dungeon)
	check(creature != null, "no creature could be summoned for a fatal blow")
	if creature == null:
		return
	var id := creature.enemy_id
	creature.enemy_definition = creature.enemy_definition.duplicate(true)
	creature.enemy_definition["ability_triggers"] = [{"when": "health_below", "ability": ABILITY, "threshold": 40.0}]
	creature.max_health = 100.0
	creature.health = 100.0
	var killed := int(GameState.get_entity_counter("enemies", id, "total_killed", 0))
	creature.take_damage(DamageTypes.apply(150.0, ["direct"], creature))
	check(creature.fleeing and creature.health > 0.0, "a fatal blow past a flee threshold kills instead of fleeing")
	check(int(GameState.get_entity_counter("enemies", id, "total_killed", 0)) == killed, "a creature spared for its flight counts as killed")
	creature.queue_free()
	await _frames(2)

func _trigger_flee(creature: Enemy) -> bool:
	return bool(load("res://scripts/combat/abilities.gd").trigger(ABILITY, creature))

# --- 5. La boutique -------------------------------------------------------------------------------
func _shop(dungeon: Node) -> void:
	const Shop := preload("res://scripts/core/shop.gd")
	var recipe := str(DataCatalog.RECIPES.keys()[0])
	# De la place pour deux plats préparés: la capacité de départ n'en tient qu'un.
	GameState.add_modifier(SOURCE, "consumable_capacity", "flat", 5.0)
	GameState.coins = 10
	check(Shop.visible_offers(SHOP).size() == 3, "an offer whose conditions fail is on sale")
	GameState.set_fact_value(FACT, true)
	check(Shop.visible_offers(SHOP).size() == 4, "an offer whose conditions hold is hidden")
	GameState.set_fact_value(FACT, false)
	check(Shop.refusal(SHOP, 2) == Shop.REASON_OWNED, "the hero's own weapon is on sale")
	var dishes := GameState.get_consumable_amount(recipe)
	check(Shop.buy(SHOP, 0).is_empty() and GameState.coins == 5 and GameState.get_consumable_amount(recipe) == dishes + 1,
		"buying a prepared dish does not take 5 coins and give the dish")
	check(Shop.stock_left(SHOP, 0) == 1, "the stock does not go down")
	check(Shop.buy(SHOP, 0).is_empty() and GameState.coins == 0, "the second dish could not be bought")
	check(Shop.refusal(SHOP, 0) == Shop.REASON_SOLD_OUT, "a sold-out offer is still on sale")
	check(Shop.buy(SHOP, 1) == Shop.REASON_TOO_EXPENSIVE and not GameState.has_key_item(ITEM), "an empty reserve still buys")
	GameState.owned_consumables.erase(recipe)
	# Le geste du joueur: parler à la boutique ouvre son écran, qui achète.
	GameState.coins = 3
	var stall := _element(dungeon, SHOP, dungeon.arena.get_center() + Vector2(-200, 0))
	check(str(stall.call("get_prompt")).contains("Probe stall"), "the stall does not offer to browse its wares")
	stall.call("interact", dungeon.player)
	await _frames(2)
	var screen: Variant = stall.get("shop_screen")
	check(screen != null and is_instance_valid(screen) and get_tree().paused, "talking to the stall does not open its screen")
	if screen == null or not is_instance_valid(screen):
		return
	var buttons: Array = screen.get("buttons")
	check(buttons.size() == 3, "the screen does not list what is on sale (%d buttons)" % buttons.size())
	if buttons.size() == 3:
		check((buttons[0] as Button).disabled and not (buttons[1] as Button).disabled and (buttons[2] as Button).disabled,
			"the screen does not tell what can be bought")
		# Acheter se maintient (mapping des touches, étape 3).
		(buttons[1] as Button).button_down.emit()
		await get_tree().create_timer(0.8).timeout
		await _frames(2)
		check(GameState.has_key_item(ITEM) and GameState.coins == 0, "holding the screen's button did not buy")
	screen.call("close")
	await _frames(2)
	check(not get_tree().paused, "closing the shop leaves the game frozen")
	GameState.set_key_item_amount(ITEM, 0)
	GameState.remove_modifiers_by_source(SOURCE)
	stall.queue_free()

# --- 6. Les deux moments ------------------------------------------------------------------------
# --- 9. La vague alliée ----------------------------------------------------------------
## Le plan d'une salle : la vague alliée n'hérite ni ne lègue son sac, n'entre
## pas au quota de la porte, et la revisite ne refait venir que l'hostile.
func _allied_planning() -> void:
	var wave := func(count: int, enemies: Dictionary, delay: float, allied: bool) -> Dictionary:
		var entry := {"count": count, "enemies": enemies, "delay": delay, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
			"trigger": "any", "chance": 1.0, "conditions": []}
		if allied:
			entry["allied"] = true
		return entry
	DataCatalog.ROOMS[ALLIED_ROOM] = {"waves": [wave.call(2, {BEAST: 1.0}, 0.0, false),
		wave.call(1, {ALLY: 1.0}, 0.0, true), wave.call(2, {}, 5.0, false)]}
	var previous := str(GameState.get_scoped_value("run", "room_id", ""))
	GameState.set_scoped_value("run", "room_id", ALLIED_ROOM)
	var planned := DiveMap.planned_waves()
	check(planned.size() == 3, "an allied wave is not planned (%d waves)" % planned.size())
	if planned.size() == 3:
		var heir: Dictionary = planned[2].get("enemies", {})
		check(heir.has(BEAST) and not heir.has(ALLY), "a hostile wave inherits the allies' sack: %s" % heir)
		check(bool(planned[1].get("allied", false)) and int(planned[1].get("declared_count", -1)) == 0 and int(planned[1].get("count", 0)) == 1,
			"an allied wave enters the door quota, or is grown by the population levers")
		var revisit := DiveMap.revisit_waves(planned)
		check(revisit.size() == 1 and not bool(revisit[0].get("allied", false)), "a revisited room brings its allies back")
	GameState.set_scoped_value("run", "room_id", previous)
	DataCatalog.ROOMS.erase(ALLIED_ROOM)

func _allied_wave(dungeon: Node) -> void:
	var beast: Enemy = dungeon.summon_creature(BEAST, dungeon.arena.get_center() + Vector2(200, 0))
	check(beast != null, "no beast could be summoned for the allied wave")
	if beast == null:
		return
	beast.set_physics_process(false)
	var remaining: int = dungeon.remaining_enemies
	dungeon._release_wave({"count": 1, "enemies": {ALLY: 1.0}, "delay": 0.0, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
		"trigger": "any", "chance": 1.0, "conditions": [], "allied": true})
	var allies := get_tree().get_nodes_in_group("ally")
	check(allies.size() == 1, "an allied wave does not bring its creature (%d)" % allies.size())
	if allies.size() != 1:
		beast.queue_free()
		return
	var ally := allies[0] as Enemy
	check(ally.allied and not ally.is_in_group("enemy"), "an ally is still counted among the enemies")
	check(dungeon.remaining_enemies == remaining, "an ally holds the door")
	check(int(GameState.get_entity_counter("enemies", ALLY, "total_spawned", 0)) == 0, "an ally counts as a creature met")
	ally.global_position = beast.global_position + Vector2(20, 0)
	var health := beast.health
	for frame: int in 300:
		await get_tree().physics_frame
		if beast.health < health:
			break
	check(ally.target == beast, "an ally does not hunt the beast")
	check(beast.health < health, "an ally never hurts the beast it hunts")
	beast.queue_free()
	await _frames(3)
	check(ally.target == null, "an ally with no beast left turns on the hero")
	dungeon._clear_room_contents()
	await _frames(2)
	check(not is_instance_valid(ally), "an ally stays behind when the room is left")

func _moments() -> void:
	GameState.set_pending_return(GameState.RETURN_KNOCKED_OUT)
	get_tree().change_scene_to_file("res://scenes/restaurant/restaurant.tscn")
	# Le moment s'ouvre une fois la séquence de retour jouée, s'il y en a une:
	# elle se mesure en secondes, pas en images.
	var deadline := Time.get_ticks_msec() + 20000
	while GameState.dive_return_moment.is_empty() and Time.get_ticks_msec() < deadline:
		await get_tree().process_frame
	await _frames(4)
	var triggers: Node = GameState.story_triggers
	# Seuls les déclencheurs d'essai se lisent: ceux de l'auteur ne doivent ni
	# partir ni compter ici.
	_author_triggers = DataCatalog.STORY_TRIGGERS
	DataCatalog.STORY_TRIGGERS = {RETURN_TRIGGER: _author_triggers[RETURN_TRIGGER], CLOSED_TRIGGER: _author_triggers[CLOSED_TRIGGER]}
	check(str(GameState.get_scoped_value("meta", "last_return", "")) == GameState.RETURN_KNOCKED_OUT,
		"the way the dive ended is not kept")
	check(GameState.dive_return_moment == GameState.RETURN_KNOCKED_OUT, "arriving from a dive does not open the return moment")
	check(triggers.current_places().has("dive_return"), "the return moment is not a place")
	var fired := int(triggers.times_fired(RETURN_TRIGGER))
	triggers.evaluate()
	check(int(triggers.times_fired(RETURN_TRIGGER)) == fired + 1, "a return trigger did not fire")
	check(GameState.dive_return_moment.is_empty() and not triggers.current_places().has("dive_return"),
		"the return moment outlives its first reading")
	triggers.evaluate()
	check(int(triggers.times_fired(RETURN_TRIGGER)) == fired + 1, "a return trigger fired twice for one return")
	GameState.open_dining()
	GameState.open_takeout_counter()
	if GameState.dining_is_open():
		check(not triggers.current_places().has("restaurant_closed"), "an open restaurant reads as closed")
	GameState.close_dining("by_hand")
	GameState.close_takeout_counter("by_hand")
	check(triggers.current_places().has("restaurant_closed"), "both curtains down is not the closed moment")
	# Un resto neuf peut être déjà fermé à l'arrivée: on repart d'une lecture
	# blanche pour éprouver le front montant lui-même.
	triggers.reset()
	var closed_before := int(triggers.times_fired(CLOSED_TRIGGER))
	triggers.evaluate()
	check(int(triggers.times_fired(CLOSED_TRIGGER)) == closed_before + 1, "a closed-restaurant trigger did not fire")
	GameState.open_dining()
	if GameState.dining_is_open():
		check(not triggers.current_places().has("restaurant_closed"), "an open dining room still reads as closed")
	triggers.reset()
	DataCatalog.STORY_TRIGGERS = _author_triggers

# --- 7. Le client capricieux ------------------------------------------------------------------
func _whims() -> void:
	var guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate()
	add_child(guest)
	guest.profile_id = GUEST
	guest.table_index = -1
	var dishes := GameState.unlocked_recipes()
	check(dishes.size() >= 2, "the probe needs two known dishes")
	guest.state = Customer.State.WAITING
	if dishes.size() >= 2:
		guest.requested_recipe = dishes[0]
		guest.service_phase = Customer.ServicePhase.AWAITING_MEAL
		guest._advance_whim(0.5)
		check(guest.requested_recipe == dishes[0], "the guest changed their mind too early")
		guest._advance_whim(0.6)
		check(guest.requested_recipe != dishes[0] and dishes.has(guest.requested_recipe), "the capricious guest never changes their order")
	var factors: Array[float] = []
	for draw: int in 40:
		factors.append(guest.pay_factor())
	check(factors.min() >= 0.0 and factors.max() <= 2.0 and factors.max() - factors.min() > 0.2,
		"the capricious guest does not pay at random between their bounds")
	guest.profile_id = str(DataCatalog.CUSTOMER_PROFILES.keys()[0])
	check(is_equal_approx(guest.pay_factor(), 1.0), "an ordinary guest does not pay the fair price")
	guest.queue_free()
	await _frames(2)

# --- 8. La commande multiple (la pieuvre) -----------------------------------------------
func _multi_orders() -> void:
	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 multi-order guest")
		return
	# Rideaux baissés: aucun passant 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)
	var a: String = DISHES[0]
	var b: String = DISHES[1]
	# De quoi servir, plus un de chaque en réserve: la commande reste servable
	# jusqu'au dernier plat.
	var plates: Array[Dish] = []
	for recipe_id: String in [b, a, a, a, b]:
		var plate: Dish = load("res://scenes/restaurant/dish.tscn").instantiate()
		restaurant.add_child(plate)
		plate.setup(recipe_id)
		plate.global_position = Vector2(-2000.0, -2000.0)
		plates.append(plate)
	var guest := _seat_multi_guest(restaurant)
	check(guest.orders_at_once() == 3, "the multi-order profile is not read")
	# Dix plats (Ti-Gras, D3) : l'anneau d'assiettes se resserre, rien ne s'empile.
	var profile: Dictionary = DataCatalog.CUSTOMER_PROFILES[guest.profile_id]
	profile["orders_at_once"] = 10
	var spots := {}
	for rank: int in 10:
		spots[restaurant.serving_plate_position_for(guest, rank).round()] = true
	check(guest.orders_at_once() == 10 and spots.size() == 10, "ten plates at one table pile up: %d places" % spots.size())
	profile["orders_at_once"] = 3
	restaurant.customer_finished_thinking(guest)
	check(guest.state == Customer.State.WAITING and guest.remaining_orders().size() == 3 and guest.round_orders == 3,
		"the guest did not order three dishes at once: %s" % [guest.remaining_orders()])
	# Une commande connue: la suite ne dépend plus du tirage.
	guest.requested_recipe = a
	var rest: Array[String] = [b, a]
	guest.set_pending_orders(rest)
	guest.take_order_by()
	check(guest.service_phase == Customer.ServicePhase.AWAITING_MEAL, "taking the multi-order did not open the meal wait")
	check(is_equal_approx(guest.phase_allowed, _single_patience(guest, Customer.ServicePhase.AWAITING_MEAL) * 3.0),
		"three dishes do not stretch the meal wait threefold")
	check(not guest.accepts_dish(str(DataCatalog.RECIPES.keys()[0])), "the guest accepts a dish they did not order")
	check(guest.order_summary().contains("×2"), "the order does not read as a grouped list: " + guest.order_summary())
	restaurant.serve_customer(guest, plates[0])
	check(guest.state == Customer.State.WAITING and plates[0].state == Dish.State.SERVING and "|".join(guest.remaining_orders()) == "%s|%s" % [a, a],
		"a dish served out of order was refused, or started the meal")
	restaurant.serve_customer(guest, plates[1])
	check(guest.state == Customer.State.WAITING and "|".join(guest.remaining_orders()) == a, "the second dish was not taken")
	check(plates[0].global_position.distance_to(plates[1].global_position) > 4.0, "two plates of one order land on the same spot")
	restaurant.serve_customer(guest, plates[4])
	check(plates[4].state == Dish.State.FILLED, "a dish no longer expected was served")
	restaurant.serve_customer(guest, plates[2])
	check(guest.state == Customer.State.EATING, "the last dish did not start the meal")
	var served := 0
	for plate: Dish in plates.slice(0, 3):
		if plate.state == Dish.State.SERVING and plate.served_customer_id == guest.get_instance_id():
			served += 1
	check(served == 3, "not every dish of the order sits in front of the guest (%d of 3)" % served)
	restaurant.customer_finished_meal(guest)
	var dirty := 0
	for plate: Dish in plates.slice(0, 3):
		if plate.state == Dish.State.DIRTY:
			dirty += 1
	check(dirty == 3 and int(restaurant.tables[0].dirty_count) == 3, "the meal does not leave one dirty plate per dish")
	check(guest.state == Customer.State.BETWEEN_ORDERS
		and is_equal_approx(guest.phase_allowed, _single_patience(guest, Customer.ServicePhase.AWAITING_CLEANUP) * 3.0),
		"three plates do not stretch the cleanup wait threefold")
	# Débarrasser: chaque assiette paie son plat, la dernière conclut la visite.
	var hands := Node2D.new()
	restaurant.add_child(hands)
	plates[0].carry_by(hands)
	check(plates[0].payment_claimed and guest.state == Customer.State.BETWEEN_ORDERS and guest.reorder_delay_remaining < 0.0
		and int(restaurant.tables[0].dirty_count) == 2, "the first plate cleared ends the visit of a table still full of plates")
	plates[1].carry_by(hands)
	guest.forced_reorder_roll = 1.0
	plates[2].carry_by(hands)
	check(plates[2].payment_claimed and guest.state == Customer.State.LEAVING and int(restaurant.tables[0].dirty_count) == 0,
		"the last plate cleared does not end the visit")
	await _clear_dining_room(restaurant)
	# La commande traverse une sauvegarde, et un départ en cours de service
	# laisse ses assiettes servies à débarrasser.
	var plate_a: Dish = load("res://scenes/restaurant/dish.tscn").instantiate()
	restaurant.add_child(plate_a)
	plate_a.setup(a)
	var spare_b: Dish = load("res://scenes/restaurant/dish.tscn").instantiate()
	restaurant.add_child(spare_b)
	spare_b.setup(b)
	spare_b.global_position = Vector2(-2000.0, -2000.0)
	var leaver := _seat_multi_guest(restaurant)
	leaver.assign_order(a)
	var tail: Array[String] = [b]
	leaver.set_pending_orders(tail)
	leaver.take_order_by()
	restaurant.serve_customer(leaver, plate_a)
	var restored: Customer = load("res://scenes/restaurant/customer.tscn").instantiate()
	add_child(restored)
	restored.profile_id = MULTI_GUEST
	restored.apply_save_dictionary(leaver.to_save_dictionary())
	check(restored.pending_orders == leaver.pending_orders and restored.round_orders == 2 and restored.requested_recipe == b,
		"a multi-order does not survive a save")
	restored.queue_free()
	leaver.abandon_wait()
	check(leaver.state == Customer.State.LEAVING and plate_a.state == Dish.State.DIRTY and int(restaurant.tables[0].dirty_count) == 1,
		"a guest leaving mid-order strands the dishes already served")
	await _clear_dining_room(restaurant)
	hands.queue_free()
	GameState.open_dining()

func _seat_multi_guest(restaurant: Node) -> 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(MULTI_GUEST, restaurant, 0, 0, table.seats[0], restaurant.entrance_point())
	guest.cash = 5000
	guest.global_position = table.seats[0]
	guest.begin_thinking()
	return guest

## La même attente pour une commande d'un seul plat.
func _single_patience(guest: Customer, phase: int) -> float:
	var kept := guest.round_orders
	guest.round_orders = 1
	var single := guest.phase_patience(phase)
	guest.round_orders = kept
	return single

func _clear_dining_room(restaurant: Node) -> void:
	for leftover: Node in get_tree().get_nodes_in_group("dish"):
		leftover.queue_free()
	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.GAME_FACTS.erase(FACT)
	DataCatalog.KEY_ITEMS.erase(ITEM)
	DataCatalog.ROOM_ELEMENTS.erase(DEBRIS)
	DataCatalog.ROOM_ELEMENTS.erase(SHOP)
	DataCatalog.ABILITIES.erase(ABILITY)
	DataCatalog.EFFECTS.erase(ICE)
	DataCatalog.CUSTOMER_PROFILES.erase(GUEST)
	DataCatalog.CUSTOMER_PROFILES.erase(MULTI_GUEST)
	for dish_id: String in DISHES:
		DataCatalog.RECIPES.erase(dish_id)
	DataCatalog.STORY_TRIGGERS.erase(RETURN_TRIGGER)
	DataCatalog.STORY_TRIGGERS.erase(CLOSED_TRIGGER)
	DataCatalog.ENEMIES.erase(ALLY)
	DataCatalog.ENEMIES.erase(BEAST)
	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("D2 ENGINE CHECK: ", "OK" if failures == 0 else "FAILED (%d)" % failures)
	get_tree().quit(0 if failures == 0 else 1)
