extends Node

# Le moteur de la D4 (phase 8 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. Un modificateur de dimension sous condition : éteint tant que ses
#    conditions ne tiennent pas, allumé dès qu'elles tiennent, sans rien poser
#    ni retirer. Décision de l'auteur (07/10) : l'endurance infinie de la D4,
#    les séances de Coach réussies, la plongée ne s'use plus (ni la marche ni le
#    temps), mais les coups et la ruée coûtent encore.
# 2. Les services imposés : un plat par tournée, dans l'ordre, jusqu'au dernier ;
#    une commande à deux assiettes sert deux fois le même service.
# 3. Les soirées spéciales, dans le vrai resto : au resto fermé, la soirée se
#    propose au lieu de partir ; refusée, elle attend la fermeture suivante ;
#    acceptée, elle part (ses effets font venir son invité). Tous les services
#    mangés, elle réussit (son crochet, sa scène) ; un plat lent ou un invité
#    qui part avant la fait rater, et le reste des invités s'en va. Une soirée
#    en cours survit à la sauvegarde ; les services éclair se comptent.
# 4. Le dîner aux chandelles de Jay (la vraie fiche, choix de l'auteur du
#    07/10) : la lettre parfumée à vingt services éclair ; la soirée, proposée
#    au resto fermé seulement quand les trois plats sont cuisinables ; les deux
#    Jay, trois services pour deux, sans un seul retard ; réussie, les semelles
#    de Jay (sans effet : l'auteur décidera), et elle ne se propose plus.
# 5. L'objet à protéger, dans le vrai donjon (choix de l'auteur du 07/10) : les
#    bêtes le visent et le sentent de partout ; leurs coups et leurs tirs
#    l'entament, pas ceux du héros ; frappée par le héros, une bête se retourne
#    contre lui, et l'ayant perdu de vue revient à la fleur. Tombé, il fait
#    perdre le chrono et ne rend rien ; tenu jusqu'au bout, il se cueille (son
#    butin, son crochet). L'objectif dit sa vie.
# 6. Recommencer sur place (choix de l'auteur du 07/10) : un chrono perdu dans
#    une salle qui le demande garde la porte close, remet la salle en place
#    après un court temps et relance le chrono ; réussi, la porte s'ouvre.
# 7. Le rythme (choix de l'auteur du 07/10) : la salle bat la mesure (un tic à
#    chaque temps, l'objectif qui le dit) ; un coup lancé sur le temps porte, à
#    contretemps il part sans rien faire. Le temps se juge à l'appui.
# 8. Le noir (choix de l'auteur du 07/10) : la salle s'éteint, sauf une lumière
#    autour du héros ; E (ou Y) gratte le berimbau : la salle s'éclaire un
#    instant et l'écho s'entend, puis le noir revient, avec une recharge. Le
#    pont 3D éteint le soleil et allume la lumière du héros.
# 9. L'IA-slop (choix de l'auteur du 07/10) : tant que le monde est corrompu,
#    l'écran glitche par salves (sans éblouissement, plus rien : les œillères
#    de Bob), les bêtes portent l'aspect slop, le décor saute ; guéri, tout se
#    tait. Dans le pont 3D, qui ne décide de rien.
# 10. L'apparition d'un élément (phase 9, la barricade du hall) : tant que ses
#    conditions ne tiennent pas, il attend hors de la salle (ni image, ni corps,
#    ni geste) ; elles tiennent, il entre, même en pleine salle ; ses conditions
#    de disparition jouent ensuite comme avant.
# 11. Le berimbau partout (phase 9, l'enquête) : hors du noir, sa touche ne joue
#    qu'avec l'instrument en poche ; elle révèle ce qui est caché alentour (un
#    élément endormi avec un crochet « quand le berimbau le révèle »), si ses
#    conditions de révélation tiennent, et pas au-delà de sa portée.
# 12. La fête de l'Abominable (phase 9), dans le vrai resto : une soirée à
#    plusieurs invités attend ceux dont les conditions tiennent, et eux seuls ;
#    ils entrent ensemble, chacun une seule fois, et elle réussit quand tous
#    ceux venus ont mangé. Un profil vient plus souvent tant que ses conditions
#    de fréquence bonifiée tiennent. Un bonus d'employé attend ses conditions,
#    relues à l'ouverture du service.
# 13. Le vote (phase 9, le référendum de MLD), dans le vrai resto : une soirée
#    qui vote compte les plats servis à ses invités ; réussie, le plus servi
#    devient le plat officiel, qui se vend plus cher, pour de bon.
# 14. La danse du berimbau (phase 9, après le concert du Saule), dans le vrai
#    donjon : sa portée nulle, rien ne danse ; donnée, les bêtes proches
#    dansent (ni marche ni attaque), pas les lointaines ; puis la recharge.
# 15. La fin de la dernière dimension (phase 10, 10A), dans le vrai donjon : la
#    D4, dernière du cycle, ne change plus de monde ; son boss vaincu, la plongée
#    s'achève sans rien effacer (la dimension et ses faits restent) ; le
#    générique défile, la distribution tirée du jeu (chaque nom une fois, une bête
#    nommée sans sa confrontation, une fiche d'essai qu'aucune salle ne place
#    absente), finit sur « Fin. » puis « … ou pas. », et le bilan habituel suit.
#    Le générique, c'est l'histoire qui le demande (10D) : celui de la vraie fin
#    finit sur « Fin. » seul ; sans demande, le toit en paix s'achève sur son
#    bilan calme, sans générique.
# 16. Un objet de salle décalé du centre (phase 10, 10B), dans le vrai donjon : il se
#    pose au centre de l'arène, écarté de ce que dit sa place (la table du toit,
#    Bob attablé à côté).
# 17. Le geste d'un compagnon (phase 10, 10C), dans le vrai donjon : un genre de
#    capacité neuf ; sans sa quête faite, rien ; faite, sa réplique en bandeau,
#    puis son geste : l'entité ne frappe plus (un effet), perd une part de sa vie
#    (tous les Jay), encaisse davantage un temps (le micro, qui pose son fait) ; le
#    héros ne prend plus rien (le bouclier).
# 18. Une réplique de bulle sous condition (phase 10, 10E) : tant que ses conditions
#    ne tiennent pas, elle se tait ; elles tiennent, elle sort.
# 19. Les souvenirs du mur (phase 10, 10F), au vrai resto : la photo de la fausse
#    fin et la fourchette de la vraie pendent au mur du fond, côté salle, chacune
#    vue quand son fait tient — même le jeu figé par un dialogue (l'épilogue
#    accroche la fourchette).

const PROBE_GATE := "probe_d4_gate"
const COURSES := ["probe_d4_course_a", "probe_d4_course_b", "probe_d4_course_c"]
const COURSE_GUEST := "probe_d4_course_guest"
const EVENING := "probe_d4_evening"
const EVENING_GUEST := "probe_d4_evening_guest"
const EVENING_SCENE := "probe_d4_evening_scene"
const DINNER_COURSES := ["jeremie_speed_bowl", "sirens_lucky_roll", "bobs_golden_trail_mix"]
const WARD := "probe_d4_ward"
const WARD_BEAST := "probe_d4_ward_beast"
const WARD_ROOMS := ["probe_d4_ward_falls", "probe_d4_ward_holds"]
const RETRY_ROOM := "probe_d4_retry"
const RHYTHM_ROOM := "probe_d4_rhythm"
const DARK_ROOM := "probe_d4_dark"
const SLOP_ROOM := "probe_d4_slop"
const APPEAR_ROOM := "probe_d4_appear"
const APPEARING := "probe_d4_appearing_gate"
const APPEAR_FACT := "probe_d4_appear"
const GONE_FACT := "probe_d4_gone"
const REVEAL_ROOM := "probe_d4_reveal"
const HIDDEN := "probe_d4_hidden_sign"
const REVEALED_FACT := "probe_d4_revealed"
const TAUGHT_FACT := "probe_d4_taught"
const BiomeDressing := preload("res://scripts/core/biome_dressing_3d.gd")
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("D4 ENGINE: " + message)

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

func _run() -> void:
	await _frames()
	check(DataCatalog.loaded_from_master, "master data not loaded: " + DataCatalog.last_load_error)
	GameState._reset_progress_state()
	GameState.story_triggers.enabled = false
	_conditional_dimension_modifier()
	_infinite_endurance()
	_jay_dinner()
	_employee_conditions()
	_bubble_conditions()
	_install_restaurant_probes()
	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("evening_guest_leaving"):
		check(false, "the restaurant is not there for the evenings check")
	else:
		GameState.close_dining("by_hand")
		GameState.close_takeout_counter("by_hand")
		await _clear_dining_room(restaurant)
		await _courses(restaurant)
		await _evenings(restaurant)
		await _party_evening(restaurant)
		_arrival_bonus(restaurant)
		await _vote_evening(restaurant)
		_keepsakes(restaurant)
	# 5. L'objet à protéger, dans le vrai donjon.
	_install_ward_probes()
	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("ward_percent"):
		check(false, "the dungeon is not there for the ward check")
	else:
		for creature: Node in get_tree().get_nodes_in_group("enemy"):
			creature.queue_free()
		await _ward_falls(dungeon)
		await _ward_holds(dungeon)
		await _retry_in_place(dungeon)
		await _rhythm(dungeon)
		await _darkness(dungeon)
		await _slop(dungeon)
		await _appearing(dungeon)
		await _berimbau(dungeon)
		await _berimbau_dance(dungeon)
		await _centre_offset(dungeon)
		await _gesture(dungeon)
		await _final_dive(dungeon)
	await _finish()

# --- 5. L'objet à protéger --------------------------------------------------------------------
func _install_ward_probes() -> void:
	var beast: Dictionary = (DataCatalog.ENEMIES[DataCatalog.ENEMIES.keys()[0]] as Dictionary).duplicate(true)
	beast["health"] = 99999.0
	beast["attacks"] = []
	beast["direct_hit"] = true
	beast["direct_damage_min"] = 6.0
	beast["direct_damage_max"] = 6.0
	beast["direct_hit_cooldown"] = 0.2
	beast["direct_hit_angle"] = 360.0
	beast["aggro_on_damage"] = true
	beast["on_kill"] = []
	beast["drops"] = []
	beast["conditions"] = []
	beast["levels"] = []
	DataCatalog.ENEMIES[WARD_BEAST] = beast
	var ward: Dictionary = (DataCatalog.ROOM_ELEMENTS[DataCatalog.ROOM_ELEMENTS.keys()[0]] as Dictionary).duplicate(true)
	ward["kind"] = "ward"
	ward["health"] = 30.0
	ward["solid"] = true
	ward["coins"] = 0
	ward["damage_filter"] = []
	ward["vanish_conditions"] = []
	ward["required_item"] = ""
	ward["drops"] = [{"kind": "ingredient", "ingredient_id": str(DataCatalog.INGREDIENTS.keys()[0]), "item_id": "",
		"amount_min": 1, "amount_max": 1, "chance": 100.0, "trigger": "death", "every_hits": 5,
		"health_below": 50.0, "phase": "", "max_per_fight": 0}]
	ward["on_triggered"] = [{"op": "set", "path": "run.probe_bloom", "value": 1}]
	ward["on_destroyed"] = [{"op": "set", "path": "run.probe_ward_fell", "value": 1}]
	DataCatalog.ROOM_ELEMENTS[WARD] = ward
	_probe_name("room_elements", WARD, "Probe flower")
	for room_id: String in WARD_ROOMS:
		var room := _probe_room(room_id)
		room["chrono_seconds"] = 30.0 if room_id == WARD_ROOMS[0] else 1.0
		room["chrono_goal"] = "survive"
		DataCatalog.ROOMS[room_id] = room
		_probe_name("rooms", room_id, "Probe")

func _probe_room(id: String) -> Dictionary:
	var wave := {"count": 1, "enemies": {WARD_BEAST: 1.0}, "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": {WARD: {"chance": 1.0, "min": 1, "max": 1}},
		"behaviours": [], "replaces": [], "max_per_run": 0, "conditions": [], "retreat_locked": false,
		"retreat_conditions": [], "exit_offset": 0, "exit_level": 0, "exits": [], "retreat_band": false,
		"ends_dimension": false, "chrono_seconds": 0.0, "chrono_goal": "clear",
		"on_chrono_won": [{"op": "set", "path": "run.probe_chrono", "value": 1}],
		"on_chrono_lost": [{"op": "set", "path": "run.probe_chrono", "value": 2}],
		"on_cleared": [{"op": "set", "path": "run.probe_cleared", "value": 1}]}

func _probe_name(collection: String, id: String, text: String) -> void:
	var key := "%s.%s.display_name" % [collection, id]
	Loc.ENTRIES[key] = {"fr": text, "en": text, "default": text}
	_loc_keys.append(key)

## La salle courante devient `room_id`, entrée par l'ouest, sans la marche.
func _enter(dungeon: Node, room_id: String) -> void:
	for key: String in ["probe_chrono", "probe_cleared", "probe_bloom", "probe_ward_fell"]:
		GameState.set_scoped_value("run", key, 0)
	dungeon.set("reward_pending", false)
	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 _room_ward(dungeon: Node) -> RoomElement:
	for node: Node in GameState.scoped_nodes(dungeon, "ward"):
		if node is RoomElement and not node.is_queued_for_deletion():
			return node as RoomElement
	return null

func _room_beast(dungeon: Node) -> Enemy:
	for node: Node in GameState.scoped_nodes(dungeon, "enemy"):
		if node is Enemy and not node.is_queued_for_deletion():
			return node as Enemy
	return null

func _pickups() -> int:
	return get_tree().get_nodes_in_group("ingredient_pickup").size()

## Attend que `test` tienne, `seconds` au plus (en temps réel).
func _until(test: Callable, seconds: float) -> bool:
	var deadline := Time.get_ticks_msec() + int(seconds * 1000.0)
	while not bool(test.call()) and Time.get_ticks_msec() < deadline:
		await get_tree().process_frame
	return bool(test.call())

func _ward_falls(dungeon: Node) -> void:
	DataCatalog.ROOM_ELEMENTS[WARD]["health"] = 30.0
	_enter(dungeon, WARD_ROOMS[0])
	await _frames(2)
	var ward := _room_ward(dungeon)
	var beast := _room_beast(dungeon)
	var player := dungeon.get("player") as Player
	var hud: Node = dungeon.get("hud")
	if ward == null or beast == null or player == null:
		check(false, "the ward room has no ward, beast or hero (%s, %s, %s)" % [ward, beast, player])
		return
	var objective := str(hud.get("objective_label").text)
	# La bête peut l'avoir déjà entamée : seule compte la vie affichée.
	check(str(dungeon.get("chrono_state")) == "running" and dungeon.ward_percent() > 0
		and objective.contains(Loc.entity("room_elements", WARD, "display_name")) and objective.contains("%"),
		"the objective does not show the ward's health: " + objective)
	# La bête vise la fleur, qu'elle sent de partout.
	check(await _until(func() -> bool: return beast.active and beast.target == ward, 2.0),
		"a beast does not go for the ward: %s" % [beast.target])
	# Une munition de bête l'entame.
	beast.set_physics_process(false)
	player.global_position = ward.global_position + Vector2(0.0, 600.0)
	var before := ward.health
	var shot := EnemyProjectile.new()
	ward.get_parent().add_child(shot)
	shot.global_position = ward.global_position + Vector2(-140.0, 0.0)
	shot.setup({"direction": Vector2.RIGHT, "speed": 700.0, "damage": 5.0, "radius": 6.0, "lifetime": 2.0, "source": beast})
	# Par son identifiant : une lambda qui retiendrait la munition libérée
	# crierait à chaque appel.
	var shot_id := shot.get_instance_id()
	var landed: bool = await _until(func() -> bool: return not is_instance_id_valid(shot_id), 2.0)
	check(landed and ward.health < before, "a beast's shot does not hit the ward (%.1f -> %.1f)" % [before, ward.health])
	# Le héros ne l'abîme pas : ses coups la traversent.
	before = ward.health
	ward.take_damage(10.0, Vector2.ZERO, 0.0, player)
	check(is_equal_approx(ward.health, before) and not ward.can_be_damaged() and not player._is_live_attack_body(ward),
		"the hero's blows can hurt the ward")
	# Au contact, la bête l'entame.
	beast.set_physics_process(true)
	beast.global_position = ward.global_position + Vector2(ward.body_radius() + beast.body_radius() + 4.0, 0.0)
	var worn := ward.health
	check(await _until(func() -> bool: return ward.health < worn, 2.0), "a beast's blow does not wear the ward")
	# Frappée par le héros, elle se retourne contre lui ; le perdant de vue,
	# elle revient à la fleur.
	beast.take_damage(1.0, Vector2.RIGHT, 0.0, player)
	check(beast.target == player, "a beast hit by the hero does not turn on him")
	player.global_position = ward.global_position + Vector2(0.0, 3000.0)
	beast.awareness.state = "undetected"
	beast.ai_state = "undetected"
	check(await _until(func() -> bool: return beast.target == ward, 1.0), "a beast that lost the hero does not go back to the ward")
	# Elle l'achève : le chrono est perdu, et la fleur ne rend rien.
	var pickups := _pickups()
	check(await _until(func() -> bool: return str(dungeon.get("chrono_state")) == "lost", 6.0),
		"a fallen ward does not lose the chrono (health %.1f, state %s)" % [ward.health, dungeon.get("chrono_state")])
	await _frames(2)
	check(ward.fallen and int(GameState.get_scoped_value("run", "probe_ward_fell", 0)) == 1
		and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 2
		and int(GameState.get_scoped_value("run", "probe_bloom", 0)) == 0 and _pickups() == pickups,
		"a fallen ward gives something, or does not tell")
	check(GameState.scoped_nodes(dungeon, "enemy").is_empty() and dungeon.is_exit_open(),
		"the beasts stay, or the door stays shut, once the ward has fallen")

# --- 6. Recommencer sur place ------------------------------------------------------------------
func _retry_in_place(dungeon: Node) -> void:
	var room := _probe_room(RETRY_ROOM)
	room["elements"] = {}
	room["chrono_seconds"] = 0.5
	room["chrono_goal"] = "clear"
	room["chrono_retry"] = true
	DataCatalog.ROOMS[RETRY_ROOM] = room
	_probe_name("rooms", RETRY_ROOM, "Probe")
	# Le héros se tient à l'écart : la bête ne doit ni le voir ni le blesser.
	(dungeon.get("player") as Node2D).global_position = Vector2(-5000.0, -5000.0)
	_enter(dungeon, RETRY_ROOM)
	var hud: Node = dungeon.get("hud")
	check(await _until(func() -> bool: return str(dungeon.get("chrono_state")) == "retry", 2.0)
		and not dungeon.is_exit_open() and int(GameState.get_scoped_value("run", "probe_chrono", 0)) == 2,
		"a lost challenge that starts over opens its door, or does not tell (%s)" % dungeon.get("chrono_state"))
	check(str(hud.get("objective_label").text) == Loc.ui("ui.dungeon.chrono_retry") % "Probe",
		"the objective does not say the challenge starts over: " + str(hud.get("objective_label").text))
	check(await _until(func() -> bool: return str(dungeon.get("chrono_state")) == "running", 3.0)
		and not GameState.scoped_nodes(dungeon, "enemy").is_empty() and not dungeon.is_exit_open(),
		"a lost challenge does not start over in place")
	# Réussi cette fois : la porte s'ouvre.
	for creature: Node in GameState.scoped_nodes(dungeon, "enemy"):
		(creature as Enemy).take_damage(999999.0, Vector2.RIGHT, 0.0, null)
	check(await _until(func() -> bool: return str(dungeon.get("chrono_state")) == "won", 2.0) and dungeon.is_exit_open(),
		"a challenge won on a second try does not open the door")

# --- 7. Le rythme -----------------------------------------------------------------------------
func _rhythm(dungeon: Node) -> void:
	var room := _probe_room(RHYTHM_ROOM)
	room["elements"] = {}
	room["rhythm_bpm"] = 60.0
	DataCatalog.ROOMS[RHYTHM_ROOM] = room
	_probe_name("rooms", RHYTHM_ROOM, "Probe")
	GameState.debug_infinite_stamina = true
	AudioDirector.begin_recording()
	_enter(dungeon, RHYTHM_ROOM)
	await _frames(2)
	var beast := _room_beast(dungeon)
	var player := dungeon.get("player") as Player
	if beast == null or player == null:
		check(false, "the rhythm room has no beast or hero")
		AudioDirector.end_recording()
		GameState.debug_infinite_stamina = false
		return
	check(await _until(func() -> bool: return beast.active, 2.0), "the rhythm room's beast never wakes")
	# Un mannequin : la bête ne bouge ni ne frappe, elle encaisse.
	beast.set_physics_process(false)
	var center: Vector2 = (dungeon.call("get_combat_arena_rect") as Rect2).get_center()
	player.global_position = center
	# La taille d'une bête est tirée au hasard : la placer au contact des deux
	# corps, sans chevauchement, sinon la physique écarte le héros et le coup manque.
	beast.global_position = center + Vector2(Player.BODY_RADIUS + beast.body_radius() + 6.0, 0.0)
	await _frames(2)
	check(str(dungeon.get("hud").get("objective_label").text).begins_with(Loc.ui("ui.dungeon.rhythm_caption") % "Probe"),
		"the objective does not say the room keeps a beat: " + str(dungeon.get("hud").get("objective_label").text))
	# Sur le temps, le coup porte.
	check(await _until(func() -> bool: return player.attack_cooldown <= 0.0 and float(dungeon.call("beat_distance")) < 0.03, 3.0),
		"the beat never comes")
	var before := beast.health
	player.aim_at(beast.global_position)
	check(player.attack() and not player.attack_off_beat, "a swing on the beat is refused, or judged off-beat")
	check(await _until(func() -> bool: return beast.health < before, 1.5), "a swing on the beat does not land (%.1f, gap %.1f, aim %s)"
		% [beast.health, player.global_position.distance_to(beast.global_position), player.attack_direction])
	# À contretemps, le geste part mais ne porte pas.
	# Une lambda d'une ligne ne se poursuit pas à la ligne suivante.
	var off_beat_ready := func() -> bool: return player.attack_state == "idle" and player.attack_cooldown <= 0.0 and float(dungeon.call("beat_distance")) > 0.4
	check(await _until(off_beat_ready, 3.0), "the off-beat moment never comes")
	before = beast.health
	player.aim_at(beast.global_position)
	check(player.attack() and player.attack_off_beat, "a swing off the beat is refused, or judged on the beat")
	await _until(func() -> bool: return player.attack_state == "idle", 1.5)
	check(is_equal_approx(beast.health, before), "a swing off the beat still lands (%.1f -> %.1f)" % [before, beast.health])
	# La mesure s'entend.
	var beats := AudioDirector.recorded_events("dungeon_rhythm_beat").size()
	AudioDirector.end_recording()
	check(beats >= 2, "the rhythm room does not tick its beats (%d)" % beats)
	GameState.debug_infinite_stamina = false

# --- 8. Le noir --------------------------------------------------------------------------------
func _darkness(dungeon: Node) -> void:
	var room := _probe_room(DARK_ROOM)
	room["elements"] = {}
	room["waves"] = []
	room["darkness_radius"] = 140.0
	DataCatalog.ROOMS[DARK_ROOM] = room
	_probe_name("rooms", DARK_ROOM, "Probe")
	AudioDirector.begin_recording()
	_enter(dungeon, DARK_ROOM)
	await _frames(3)
	var view: Dictionary = dungeon.call("darkness_view")
	check(bool(view.get("dark", false)) and is_equal_approx(float(view.get("radius", 0.0)), 140.0), "a dark room is not dark: %s" % view)
	var objective := str(dungeon.get("hud").get("objective_label").text)
	check(objective.begins_with(Loc.ui("ui.dungeon.echo_ready") % "Probe"), "the objective does not offer the echo: " + objective)
	check(_secondary_bound(), "the echo is not on the secondary action (right click, R)")
	if Render3D.active:
		var shown: Dictionary = Bridge3D.darkness_state()
		check(is_zero_approx(float(shown.get("sun", -1.0))) and is_equal_approx(float(shown.get("light", 0.0)), 140.0),
			"the 3D room is not dark around a lit hero: %s" % shown)
	# Le clic droit (l'action secondaire) : dans le noir, il joue l'écho même
	# sans l'instrument. La salle s'éclaire un instant, et l'écho s'entend.
	await _click_secondary()
	await _frames(3)
	view = dungeon.call("darkness_view")
	check(bool(view.get("echo", false)) and not bool(view.get("dark", true)), "a right click does not light the dark room: %s" % view)
	check(AudioDirector.recorded_events("dungeon_berimbau_echo").size() == 1, "the berimbau echo is not heard")
	if Render3D.active:
		check(float(Bridge3D.darkness_state().get("sun", 0.0)) > 0.0, "the echo does not light the 3D room")
	check(not bool(dungeon.call("try_echo")), "the echo does not wait for its cooldown")
	# Puis le noir revient.
	var dark_again := func() -> bool: return bool((dungeon.call("darkness_view") as Dictionary).get("dark", false))
	check(await _until(dark_again, 3.0), "the dark does not come back after the echo")
	AudioDirector.end_recording()

# --- 9. L'IA-slop ------------------------------------------------------------------------------
func _slop(dungeon: Node) -> void:
	if not Render3D.active:
		check(false, "the slop check needs the 3D view")
		return
	var room := _probe_room(SLOP_ROOM)
	room["elements"] = {}
	# Un biome habillé : son décor a de quoi sauter.
	for biome_id: String in DataCatalog.BIOMES:
		if BiomeDressing.has_dressing(biome_id):
			room["biome"] = biome_id
			break
	DataCatalog.ROOMS[SLOP_ROOM] = room
	_probe_name("rooms", SLOP_ROOM, "Probe")
	GameState.set_fact_value("world_is_corrupt", false)
	_enter(dungeon, SLOP_ROOM)
	await _frames(4)
	var beast := _room_beast(dungeon)
	if beast != null:
		beast.set_physics_process(false)
	check(not bool(Bridge3D.slop_state().get("active", false)), "the world glitches before it is corrupt")
	GameState.set_fact_value("world_is_corrupt", true)
	await _frames(3)
	var state: Dictionary = Bridge3D.slop_state()
	check(bool(state.get("active", false)) and int(state.get("marked", 0)) >= 1,
		"a corrupt world does not give its beasts the slop look: %s" % state)
	Bridge3D.slop_burst_now()
	await _frames(2)
	check(bool(Bridge3D.slop_state().get("screen", false)), "a slop burst does not show on screen")
	check(Bridge3D.slop_decor_now(), "the slop finds no decor to glitch")
	# Les œillères : sans éblouissement, l'écran ne glitche plus.
	# La couche « override » multiplie la valeur entière ; un « set » sur « flat »
	# ne remplacerait que le bonus.
	GameState.add_modifier("test:slop_glare", "slop_glare", "override", 0.0)
	Bridge3D.slop_burst_now()
	await _frames(2)
	check(not bool(Bridge3D.slop_state().get("screen", true)), "with no glare left, the screen still glitches")
	GameState.remove_modifiers_by_source("test:slop_glare")
	# Guéri : tout se tait.
	GameState.set_fact_value("world_is_corrupt", false)
	await _frames(3)
	state = Bridge3D.slop_state()
	check(not bool(state.get("active", true)) and int(state.get("marked", 1)) == 0 and not bool(state.get("screen", true)),
		"a healed world keeps glitching: %s" % state)

# --- 10. L'apparition d'un élément --------------------------------------------------------------
func _appearing(dungeon: Node) -> void:
	var gate: Dictionary = (DataCatalog.ROOM_ELEMENTS[DataCatalog.ROOM_ELEMENTS.keys()[0]] as Dictionary).duplicate(true)
	gate["kind"] = "gate"
	gate["solid"] = true
	gate["required_item"] = ""
	gate["health"] = 0.0
	gate["on_triggered"] = []
	# Des faits d'essai : c'est un fait posé par un crochet ou une scène qui réveille
	# l'élément en jeu (une valeur de plongée posée à la main ne signale rien).
	for fact: String in [APPEAR_FACT, GONE_FACT]:
		DataCatalog.GAME_FACTS[fact] = {"display_name": fact, "type": "bool", "default_value": false, "category": "story", "scope": "run", "description": ""}
	gate["appear_conditions"] = [{"path": "fact." + APPEAR_FACT, "operator": "truthy", "value": true}]
	gate["vanish_conditions"] = [{"path": "fact." + GONE_FACT, "operator": "truthy", "value": true}]
	DataCatalog.ROOM_ELEMENTS[APPEARING] = gate
	_probe_name("room_elements", APPEARING, "Probe gate")
	var room := _probe_room(APPEAR_ROOM)
	room["waves"] = []
	room["elements"] = {APPEARING: {"chance": 1.0, "min": 1, "max": 1}}
	DataCatalog.ROOMS[APPEAR_ROOM] = room
	_probe_name("rooms", APPEAR_ROOM, "Probe")
	GameState.set_fact_value(APPEAR_FACT, false)
	GameState.set_fact_value(GONE_FACT, false)
	_enter(dungeon, APPEAR_ROOM)
	await _frames(2)
	var element: RoomElement = null
	for node: Node in GameState.scoped_nodes(dungeon, "room_element"):
		if node is RoomElement and (node as RoomElement).element_id == APPEARING and not node.is_queued_for_deletion():
			element = node as RoomElement
	if element == null:
		check(false, "the appearing element is not placed in its room")
		return
	check(element.dormant and not element.visible and element.collision_layer == 0 and not element.is_in_group("interactable"),
		"an element waiting for its conditions is already in the room")
	# Ses conditions tiennent : il entre, en pleine salle.
	GameState.set_fact_value(APPEAR_FACT, true)
	await _frames(2)
	check(not element.dormant and element.visible and element.collision_layer != 0 and element.is_in_group("interactable"),
		"an element does not appear once its conditions hold")
	# Ses conditions de disparition jouent ensuite comme avant.
	GameState.set_fact_value(GONE_FACT, true)
	await _frames(2)
	check(not element.visible and element.spent, "an appeared element does not vanish on its own conditions")
	GameState.set_fact_value(APPEAR_FACT, false)
	GameState.set_fact_value(GONE_FACT, false)

# --- 11. Le berimbau partout ------------------------------------------------------------------
# --- 14. La danse du berimbau ------------------------------------------------------------------
const DANCE_ROOM := "probe_d4_dance_room"
const DANCE_SOURCE := "test:d4_dance"

func _berimbau_dance(dungeon: Node) -> void:
	var room := _probe_room(DANCE_ROOM)
	room["waves"] = [{"count": 2, "enemies": {WARD_BEAST: 1.0}, "delay": 0.0, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
		"chance": 1.0, "trigger": "any", "conditions": []}]
	room["elements"] = {}
	DataCatalog.ROOMS[DANCE_ROOM] = room
	_probe_name("rooms", DANCE_ROOM, "Probe")
	_enter(dungeon, DANCE_ROOM)
	await _frames(3)
	var player := dungeon.get("player") as Node2D
	var beasts: Array[Enemy] = []
	for node: Node in GameState.scoped_nodes(dungeon, "enemy"):
		if node is Enemy and not node.is_queued_for_deletion():
			beasts.append(node as Enemy)
	if beasts.size() < 2 or player == null:
		check(false, "the dance room has not two beasts and a hero (%d)" % beasts.size())
		return
	beasts[0].global_position = player.global_position + Vector2(120.0, 0.0)
	beasts[1].global_position = player.global_position + Vector2(900.0, 0.0)
	dungeon.set("dance_cooldown_left", 0.0)
	# Sa portée nulle (avant le concert), rien ne danse.
	check(int(dungeon.call("berimbau_dance")) == 0 and not beasts[0].effects.has_effect("berimbau_dance"),
		"beasts dance before the berimbau has learned the dance")
	# Donnée, les bêtes proches dansent, pas les lointaines ; puis la recharge.
	GameState.add_modifier(DANCE_SOURCE, "berimbau_dance_radius", "flat", 260.0)
	var danced := int(dungeon.call("berimbau_dance"))
	check(danced == 1 and beasts[0].effects.has_effect("berimbau_dance") and beasts[0].effects.has_flag("can_attack")
		and not beasts[1].effects.has_effect("berimbau_dance"),
		"the berimbau does not make the near beast dance, or makes the far one dance too (%d danced)" % danced)
	check(int(dungeon.call("berimbau_dance")) == 0 and float(dungeon.get("dance_cooldown_left")) > 0.0,
		"the berimbau dance has no recharge")
	GameState.remove_modifiers_by_source(DANCE_SOURCE)
	dungeon.set("dance_cooldown_left", 0.0)
	for beast: Enemy in beasts:
		if is_instance_valid(beast):
			beast.queue_free()
	await _frames(2)

# --- 17. Le geste d'un compagnon -------------------------------------------------------------
const ABILITIES := preload("res://scripts/combat/abilities.gd")
const GESTURE_ROOM := "probe_d4_gesture_room"
const GESTURE_FACT := "probe_d4_gesture_quest"
const GESTURE_MARK := "probe_d4_gesture_mark"

func _gesture(dungeon: Node) -> void:
	for fact: String in [GESTURE_FACT, GESTURE_MARK]:
		DataCatalog.GAME_FACTS[fact] = {"display_name": "Probe", "type": "bool", "default_value": false, "category": "story",
			"description": "", "scope": "dimension"}
		GameState.set_fact_value(fact, false)
	var room := _probe_room(GESTURE_ROOM)
	room["elements"] = {}
	DataCatalog.ROOMS[GESTURE_ROOM] = room
	_probe_name("rooms", GESTURE_ROOM, "Probe")
	_enter(dungeon, GESTURE_ROOM)
	await _frames(3)
	var beast := _room_beast(dungeon)
	var player := dungeon.get("player") as Node2D
	if beast == null or player == null:
		check(false, "the gesture room has no beast or no hero")
		return
	var toasts: Array[String] = []
	var listen := func(text: String) -> void: toasts.append(text)
	GameState.toast_requested.connect(listen)
	var base := {"requires": GESTURE_FACT, "marks": "none", "seconds": 8.0, "power": 0.5}
	# Sans sa quête faite, le compagnon laisse passer son seuil.
	var idle := bool(ABILITIES.companion_gesture(beast, base.merged({"gesture": "claw"}, true)))
	check(not idle and toasts.is_empty() and not beast.effects.has_effect("gesture_claw"), "a companion whose quest is missing still gestures")
	GameState.set_fact_value(GESTURE_FACT, true)
	ABILITIES.companion_gesture(beast, base.merged({"gesture": "claw"}, true))
	var claw := beast.effects.has_effect("gesture_claw") and beast.effects.has_flag("can_attack") \
		and toasts.has(Loc.ui("ui.roof.gesture.claw"))
	var before := beast.health
	ABILITIES.companion_gesture(beast, base.merged({"gesture": "jays", "power": 0.05}, true))
	var jays := is_equal_approx(before - beast.health, beast.max_health * 0.05)
	ABILITIES.companion_gesture(beast, base.merged({"gesture": "micro", "marks": GESTURE_MARK}, true))
	before = beast.health
	beast.take_damage(100.0)
	var micro := is_equal_approx(before - beast.health, 150.0) and bool(GameState.get_fact_value(GESTURE_MARK))
	player.set("invulnerability", 0.0)
	ABILITIES.companion_gesture(beast, base.merged({"gesture": "shield", "seconds": 4.0}, true))
	var shield := float(player.get("invulnerability")) >= 4.0
	# Bob s'interpose (la vraie fin) : le héros ne prend rien, lui non plus.
	player.set("invulnerability", 0.0)
	ABILITIES.companion_gesture(beast, base.merged({"gesture": "bob", "seconds": 3.0}, true))
	shield = shield and float(player.get("invulnerability")) >= 3.0
	GameState.toast_requested.disconnect(listen)
	check(claw and jays and micro and shield,
		"a companion's gesture does not act: claw %s, jays %s, micro %s, shield %s" % [claw, jays, micro, shield])
	player.set("invulnerability", 0.0)
	for fact: String in [GESTURE_FACT, GESTURE_MARK]:
		GameState.set_fact_value(fact, false)
		DataCatalog.GAME_FACTS.erase(fact)
	DataCatalog.ROOMS.erase(GESTURE_ROOM)
	beast.queue_free()
	await _frames(2)

# --- 16. Un objet décalé du centre ---------------------------------------------------------------
const OFFSET_ROOM := "probe_d4_offset_room"
const OFFSET_ELEMENT := "probe_d4_offset_element"

func _centre_offset(dungeon: Node) -> void:
	var probe: Dictionary = (DataCatalog.ROOM_ELEMENTS[WARD] as Dictionary).duplicate(true)
	probe["kind"] = "decor"
	probe["on_triggered"] = []
	probe["on_destroyed"] = []
	probe["drops"] = []
	DataCatalog.ROOM_ELEMENTS[OFFSET_ELEMENT] = probe
	_probe_name("room_elements", OFFSET_ELEMENT, "Probe table")
	var room := _probe_room(OFFSET_ROOM)
	room["waves"] = []
	room["elements"] = {OFFSET_ELEMENT: {"chance": 1.0, "min": 1, "max": 1, "anchor": "center", "offset_x": 120.0, "offset_y": -80.0}}
	DataCatalog.ROOMS[OFFSET_ROOM] = room
	_probe_name("rooms", OFFSET_ROOM, "Probe")
	_enter(dungeon, OFFSET_ROOM)
	await _frames(3)
	var placed: Node2D = null
	for node: Node in GameState.scoped_nodes(dungeon, "room_element"):
		if node is RoomElement and (node as RoomElement).element_id == OFFSET_ELEMENT and not node.is_queued_for_deletion():
			placed = node as Node2D
	var spot: Vector2 = dungeon.get_combat_arena_rect().get_center() + Vector2(120.0, -80.0)
	check(placed != null and placed.global_position.distance_to(spot) < 0.5,
		"an element anchored to the centre does not take its offset (at %s, wanted %s)" % [placed.global_position if placed != null else Vector2.INF, spot])
	DataCatalog.ROOMS.erase(OFFSET_ROOM)
	DataCatalog.ROOM_ELEMENTS.erase(OFFSET_ELEMENT)

# --- 15. La fin de la dernière dimension --------------------------------------------------------
const CREDITS := preload("res://scripts/core/credits_roll.gd")
const FINAL_FACT := "slop_machine_destroyed"
const CAST_BOSS := "probe_d4_cast_boss"
const CAST_TEST_BOSS := "probe_d4_cast_test_boss"
const CAST_ROOM := "probe_d4_cast_room"
const CAST_EMPLOYEE := "probe_d4_cast_employee"
const CAST_CHARACTER := "probe_d4_cast_character"

func _final_dive(dungeon: Node) -> void:
	_credits_cast()
	check(DiveMap.is_final_dimension("dimension_four") and not DiveMap.is_final_dimension("dimension_three")
		and not DiveMap.is_final_dimension("dimension_one"), "the fourth world is not the last of the cycle, or another one is")
	GameState.set_scoped_value("meta", "dimension_index", 3)
	GameState.set_scoped_value("run", "dimension_id", "dimension_four")
	GameState.set_fact_value(FINAL_FACT, true)
	dungeon.set("run_finished", false)
	GameState.set_scoped_value("run", "credits", 1)
	dungeon._complete_dimension_flow()
	# Sans la règle, le monde change et le donjon s'en va : on le tient du bout des doigts.
	var held: WeakRef = weakref(dungeon)
	var appeared := await _until(func() -> bool:
		var live: Node = held.get_ref()
		return live != null and live.get_node_or_null("CreditsRoll") != null, 3.0)
	var roll: Node = null
	if held.get_ref() != null:
		roll = (held.get_ref() as Node).get_node_or_null("CreditsRoll")
	check(appeared and GameState.get_dimension_index() == 3 and bool(GameState.get_fact_value(FINAL_FACT)),
		"the last world's end changes world or forgets it (world index %d, credits %s)" % [GameState.get_dimension_index(), appeared])
	if roll == null:
		GameState.set_fact_value(FINAL_FACT, false)
		return
	var cards: Array = roll.call("card_texts")
	check(cards.size() == 2 and str(cards[0]) == Loc.ui("ui.credits.end") and str(cards[1]) == Loc.ui("ui.credits.or_not"),
		"the satisfying credits do not end on « The End. » then « … or not. »")
	roll.set("card_seconds", 0.05)
	roll.call("skip")
	var closed := await _until(func() -> bool:
		var live: Node = held.get_ref()
		return live != null and live.get_node_or_null("CreditsRoll") == null, 3.0)
	var result: Node = dungeon.get("dive_result")
	var title: Label = null
	if result != null:
		title = result.get("_title") as Label
	check(closed and result != null and bool(result.get("visible")) and title != null and title.text == Loc.ui("ui.dive_result.title_roof"),
		"the credits do not give way to the usual dive result")
	# La vraie fin : « Fin. », sans « … ou pas. ».
	var true_roll: Node = CREDITS.new()
	true_roll.set("ending", CREDITS.ENDING_TRUE)
	var true_cards: Array = true_roll.call("card_texts")
	true_roll.free()
	check(true_cards.size() == 1 and str(true_cards[0]) == Loc.ui("ui.credits.end"), "the true end's credits do not end on « The End. » alone")
	# Sans générique demandé (le toit en paix, après la vraie fin) : le bilan calme, seul.
	if held.get_ref() != null and title != null:
		dungeon.set("run_finished", false)
		GameState.set_scoped_value("run", "credits", 0)
		dungeon._complete_dimension_flow()
		await _frames(3)
		check(held.get_ref() != null and (held.get_ref() as Node).get_node_or_null("CreditsRoll") == null
			and title.text == Loc.ui("ui.dive_result.title_roof_calm"), "the roof at peace rolls the credits, or does not end on its calm dive result")
	GameState.set_fact_value(FINAL_FACT, false)

## La distribution, sur des fiches sondes : un nom déjà dit, traduit ou tel qu'écrit,
## ne revient pas ; une bête se nomme sans sa confrontation ; une fiche qu'aucune
## salle ne place n'y est pas.
func _credits_cast() -> void:
	DataCatalog.ENEMIES[CAST_BOSS] = {"display_name": "Probe Boss — first round", "boss_bar": true}
	DataCatalog.ENEMIES[CAST_TEST_BOSS] = {"display_name": "Probe Test Boss", "boss_bar": true}
	var room := _probe_room(CAST_ROOM)
	room["waves"] = [{"count": 1, "enemies": {CAST_BOSS: 1.0}, "delay": 0.0, "remaining": DataCatalog.WAVE_REMAINING_IGNORED,
		"chance": 1.0, "trigger": "any", "conditions": []}]
	DataCatalog.ROOMS[CAST_ROOM] = room
	# Le même personnage, traduit dans une collection et pas dans l'autre.
	DataCatalog.EMPLOYEES[CAST_EMPLOYEE] = {"display_name": "Probe Cook"}
	_probe_name("employees", CAST_EMPLOYEE, "Probe Chef")
	DataCatalog.CHARACTERS[CAST_CHARACTER] = {"display_name": "Probe Cook"}
	var groups: Array = CREDITS.cast()
	var by_heading := {}
	var total := 0
	var distinct := {}
	for group: Dictionary in groups:
		by_heading[str(group["heading"])] = group["names"]
		for shown: String in group["names"]:
			total += 1
			distinct[shown.to_lower()] = true
	var brigade: Array = by_heading.get("ui.credits.brigade", [])
	var met: Array = by_heading.get("ui.credits.encounters", [])
	var foes: Array = by_heading.get("ui.credits.adversaries", [])
	check(groups.size() == 4 and brigade.has("Probe Chef") and not met.has("Probe Cook") and foes.has("Probe Boss")
		and not foes.has("Probe Test Boss") and total == distinct.size(),
		"the credits cast is not drawn from the game (%d groups, %d names, %d distinct)" % [groups.size(), total, distinct.size()])
	DataCatalog.ENEMIES.erase(CAST_BOSS)
	DataCatalog.ENEMIES.erase(CAST_TEST_BOSS)
	DataCatalog.ROOMS.erase(CAST_ROOM)
	DataCatalog.EMPLOYEES.erase(CAST_EMPLOYEE)
	DataCatalog.CHARACTERS.erase(CAST_CHARACTER)

func _berimbau(dungeon: Node) -> void:
	for fact: String in [REVEALED_FACT, TAUGHT_FACT]:
		DataCatalog.GAME_FACTS[fact] = {"display_name": fact, "type": "bool", "default_value": false, "category": "story", "scope": "run", "description": ""}
		GameState.set_fact_value(fact, false)
	var sign: Dictionary = (DataCatalog.ROOM_ELEMENTS[DataCatalog.ROOM_ELEMENTS.keys()[0]] as Dictionary).duplicate(true)
	sign["kind"] = "npc"
	sign["solid"] = false
	sign["character"] = ""
	sign["required_item"] = ""
	sign["health"] = 0.0
	sign["vanish_conditions"] = []
	sign["appear_conditions"] = [{"path": "fact." + REVEALED_FACT, "operator": "truthy", "value": true}]
	sign["reveal_conditions"] = [{"path": "fact." + TAUGHT_FACT, "operator": "truthy", "value": true}]
	sign["on_revealed"] = [{"op": "set", "path": "fact." + REVEALED_FACT, "value": true}]
	DataCatalog.ROOM_ELEMENTS[HIDDEN] = sign
	_probe_name("room_elements", HIDDEN, "Probe sign")
	var room := _probe_room(REVEAL_ROOM)
	room["waves"] = []
	room["elements"] = {HIDDEN: {"chance": 1.0, "min": 1, "max": 1, "anchor": "center"}}
	DataCatalog.ROOMS[REVEAL_ROOM] = room
	_probe_name("rooms", REVEAL_ROOM, "Probe")
	if GameState.has_key_item("berimbau"):
		GameState.add_key_item("berimbau", -1)
	_enter(dungeon, REVEAL_ROOM)
	await _frames(2)
	var hidden: RoomElement = null
	for node: Node in GameState.scoped_nodes(dungeon, "room_element"):
		if node is RoomElement and (node as RoomElement).element_id == HIDDEN and not node.is_queued_for_deletion():
			hidden = node as RoomElement
	var player := dungeon.get("player") as Node2D
	if hidden == null or player == null:
		check(false, "the berimbau room has no hidden sign or no hero")
		return
	player.global_position = hidden.global_position + Vector2(60.0, 0.0)
	AudioDirector.begin_recording()
	# Sans l'instrument, hors du noir : rien.
	check(not bool(dungeon.call("try_echo")) and AudioDirector.recorded_events("dungeon_berimbau_echo").is_empty(),
		"the berimbau plays without the instrument")
	# En poche, sans les instructions : une note, et rien ne se révèle.
	GameState.add_key_item("berimbau", 1)
	check(bool(dungeon.call("try_echo")) and AudioDirector.recorded_events("dungeon_berimbau_echo").size() == 1 and hidden.dormant,
		"the berimbau does not play, or reveals before the instructions")
	# Les instructions reçues : loin, rien ; tout près, il se révèle.
	GameState.set_fact_value(TAUGHT_FACT, true)
	dungeon.set("echo_cooldown_left", 0.0)
	player.global_position = hidden.global_position + Vector2(2000.0, 0.0)
	check(bool(dungeon.call("try_echo")) and hidden.dormant, "the berimbau reveals beyond its reach")
	dungeon.set("echo_cooldown_left", 0.0)
	player.global_position = hidden.global_position + Vector2(60.0, 0.0)
	check(bool(dungeon.call("try_echo")), "the berimbau does not play once taught")
	await _frames(2)
	check(not hidden.dormant and hidden.visible and bool(GameState.get_fact_value(REVEALED_FACT)),
		"the berimbau does not reveal what hides nearby")
	AudioDirector.end_recording()
	GameState.add_key_item("berimbau", -1)
	for fact: String in [REVEALED_FACT, TAUGHT_FACT]:
		GameState.set_fact_value(fact, false)

## Une touche physique, enfoncée puis relâchée, par la vraie file des entrées.
func _tap(physical: Key) -> void:
	var key := InputEventKey.new()
	key.physical_keycode = physical
	key.pressed = true
	Input.parse_input_event(key)
	await get_tree().process_frame
	var released := key.duplicate() as InputEventKey
	released.pressed = false
	Input.parse_input_event(released)

## Le joueur lit l'action secondaire dans son processus physique : le bouton
## reste enfoncé le temps de quelques pas physiques.
func _click_secondary() -> void:
	var click := InputEventMouseButton.new()
	click.button_index = MOUSE_BUTTON_RIGHT
	click.pressed = true
	click.position = get_viewport().get_visible_rect().size * 0.5
	Input.parse_input_event(click)
	for step: int in 3:
		await get_tree().physics_frame
	var released := click.duplicate() as InputEventMouseButton
	released.pressed = false
	Input.parse_input_event(released)

func _secondary_bound() -> bool:
	if not InputMap.has_action("secondary_action") or InputMap.has_action("berimbau_echo"):
		return false
	var clicked := false
	var padded := false
	for event: InputEvent in InputMap.action_get_events("secondary_action"):
		clicked = clicked or (event is InputEventMouseButton and (event as InputEventMouseButton).button_index == MOUSE_BUTTON_RIGHT)
		padded = padded or (event is InputEventJoypadButton and (event as InputEventJoypadButton).button_index == JOY_BUTTON_RIGHT_SHOULDER)
	return clicked and padded

func _ward_holds(dungeon: Node) -> void:
	DataCatalog.ROOM_ELEMENTS[WARD]["health"] = 99999.0
	_enter(dungeon, WARD_ROOMS[1])
	await _frames(2)
	var ward := _room_ward(dungeon)
	if ward == null:
		check(false, "the second ward room has no ward")
		return
	var pickups := _pickups()
	check(await _until(func() -> bool: return str(dungeon.get("chrono_state")) != "running", 3.0)
		and str(dungeon.get("chrono_state")) == "won", "holding the ward to the end does not win")
	await _frames(2)
	check(not ward.fallen and ward.spent and int(GameState.get_scoped_value("run", "probe_bloom", 0)) == 1
		and _pickups() > pickups and int(GameState.get_scoped_value("run", "probe_cleared", 0)) == 1,
		"a ward that held is not picked: no loot or no hook (pickups %d -> %d)" % [pickups, _pickups()])
	check(dungeon.ward_percent() == -1, "a picked ward still shows its health")

# --- 4. Le dîner aux chandelles de Jay -------------------------------------------------------
func _jay_dinner() -> void:
	var letter: Dictionary = DataCatalog.STORY_TRIGGERS.get("jay_perfumed_letter", {})
	var dinner: Dictionary = DataCatalog.STORY_TRIGGERS.get("jay_candlelight_dinner", {})
	var guests: Dictionary = DataCatalog.CUSTOMER_PROFILES.get("jay_date_guests", {})
	GameState.set_fact_value("jay_joined", true)
	GameState.set_fact_value("jay_letter_received", false)
	GameState.set_fact_value("jay_dinner_done", false)
	GameState.set_fact_value("fast_services", 19)
	check(not letter.is_empty() and not GameState.conditions_met(letter.get("conditions", [])),
		"the perfumed letter comes before twenty fast services")
	GameState.set_fact_value("fast_services", 20)
	check(GameState.conditions_met(letter.get("conditions", [])) and str(letter.get("place", "")) == "restaurant",
		"the perfumed letter never comes at the restaurant")
	Registry.run_hook("story_triggers", "jay_perfumed_letter", "on_fired")
	check(bool(GameState.get_fact_value("jay_letter_received")), "the perfumed letter is not remembered")
	check(GameState.is_evening("jay_candlelight_dinner") and str(dinner.get("place", "")) == "restaurant_closed"
		and bool(dinner.get("repeat", false)), "Jay's dinner is not an evening offered at the closed restaurant")
	check(not GameState.conditions_met(dinner.get("conditions", [])), "the dinner is offered before its three dishes can be cooked")
	for recipe: String in DINNER_COURSES:
		for condition: Variant in DataCatalog.RECIPES.get(recipe, {}).get("conditions", []):
			var parts := str((condition as Dictionary).get("path", "")).split(".")
			if parts.size() == 3:
				GameState.add_entity_counter(parts[0], parts[1], parts[2], int((condition as Dictionary).get("value", 1)))
	check(GameState.conditions_met(dinner.get("conditions", [])), "the dinner is never offered once its three dishes can be cooked")
	check(guests.get("courses", []) == DINNER_COURSES and int(guests.get("orders_at_once", 1)) == 2 and bool(guests.get("exclusive", false))
		and bool(GameState.evening_definition("jay_candlelight_dinner").get("fast_only", false)),
		"the two Jays do not dine three courses for two, without a single delay")
	Registry.run_hook("story_triggers", "jay_candlelight_dinner", "on_fired")
	check(GameState.conditions_met(guests.get("conditions", [])), "the two Jays do not come once the dinner starts")
	Registry.run_hook("story_triggers", "jay_candlelight_dinner", "on_evening_lost")
	check(not GameState.conditions_met(guests.get("conditions", [])) and not bool(GameState.get_fact_value("jay_dinner_done"))
		and GameState.conditions_met(dinner.get("conditions", [])), "a lost dinner keeps its guests, or is not offered again")
	Registry.run_hook("story_triggers", "jay_candlelight_dinner", "on_fired")
	Registry.run_hook("story_triggers", "jay_candlelight_dinner", "on_evening_won")
	check(bool(GameState.get_fact_value("jay_dinner_done")) and GameState.equipment_is_owned("jay_soles")
		and not GameState.conditions_met(dinner.get("conditions", [])), "a won dinner does not give Jay's soles, or is offered again")
	check((DataCatalog.EQUIPMENT.get("jay_soles", {}).get("modifiers", []) as Array).is_empty(),
		"Jay's soles have an effect the author has not chosen yet")
	GameState.set_fact_value("jay_dinner_done", false)
	GameState.set_fact_value("jay_letter_received", false)
	GameState.set_fact_value("jay_dinner_tonight", false)
	GameState.set_fact_value("fast_services", 0)

# --- Les fiches d'essai du resto -------------------------------------------------------------
func _install_restaurant_probes() -> void:
	var recipe: Dictionary = (DataCatalog.RECIPES[DataCatalog.RECIPES.keys()[0]] as Dictionary).duplicate(true)
	recipe["conditions"] = []
	for id: String in COURSES:
		var copy := recipe.duplicate(true)
		copy["display_name"] = id
		DataCatalog.RECIPES[id] = copy
	var profile: Dictionary = (DataCatalog.CUSTOMER_PROFILES[DataCatalog.CUSTOMER_PROFILES.keys()[0]] as Dictionary).duplicate(true)
	profile["cash"] = 999999
	profile["pay_min"] = 1.0
	profile["pay_max"] = 1.0
	profile["fixed_recipe"] = ""
	profile["character"] = ""
	profile["presence_modifiers"] = []
	profile["whim_seconds"] = 0.0
	profile["exclusive"] = false
	profile["conditions"] = []
	var courses_guest := profile.duplicate(true)
	courses_guest["courses"] = COURSES.duplicate()
	courses_guest["orders_at_once"] = 2
	DataCatalog.CUSTOMER_PROFILES[COURSE_GUEST] = courses_guest
	var evening_guest := profile.duplicate(true)
	evening_guest["courses"] = [COURSES[0], COURSES[1]]
	evening_guest["orders_at_once"] = 1
	evening_guest["exclusive"] = true
	evening_guest["conditions"] = [{"path": "run.probe_tonight", "operator": "truthy", "value": true}]
	DataCatalog.CUSTOMER_PROFILES[EVENING_GUEST] = evening_guest
	DataCatalog.STORY_SCENES[EVENING_SCENE] = (DataCatalog.STORY_SCENES[DataCatalog.STORY_SCENES.keys()[0]] as Dictionary).duplicate(true)

func _evening_trigger() -> Dictionary:
	return {"display_name": "Probe evening", "description": "", "place": "restaurant_closed", "repeat": true,
		"scene": "", "character": "", "dialogue": "",
		"conditions": [{"path": "run.probe_evening_ready", "operator": "truthy", "value": true}],
		"on_fired": [{"op": "set", "path": "run.probe_tonight", "value": true}],
		"on_evening_won": [{"op": "set", "path": "run.probe_result", "value": 1}, {"op": "set", "path": "run.probe_tonight", "value": false}],
		"on_evening_lost": [{"op": "set", "path": "run.probe_result", "value": 2}, {"op": "set", "path": "run.probe_tonight", "value": false}],
		"evening": {"guests": {EVENING_GUEST: 1}, "fast_only": true, "success_scene": EVENING_SCENE, "failure_scene": ""}}

# --- 2. Les services imposés -----------------------------------------------------------------
func _courses(restaurant: Node) -> void:
	var guest := _seat(restaurant, COURSE_GUEST)
	for step: int in COURSES.size():
		check(guest.planned_recipe() == COURSES[step], "course %d is not the planned dish: %s" % [step + 1, guest.planned_recipe()])
		restaurant.customer_finished_thinking(guest)
		check(guest.requested_recipe == COURSES[step] and "|".join(guest.remaining_orders()) == "%s|%s" % [COURSES[step], COURSES[step]],
			"course %d is not ordered twice for a table of two: %s" % [step + 1, guest.remaining_orders()])
		guest.meals_eaten = step + 1
		check(guest.wants_another_meal() == (step + 1 < COURSES.size()),
			"the guest stops or goes on at the wrong course (%d)" % (step + 1))
		guest.state = Customer.State.WAITING
	await _clear_dining_room(restaurant)

# --- 3. Les soirées spéciales ----------------------------------------------------------------
func _evenings(restaurant: Node) -> void:
	var triggers: Node = GameState.story_triggers
	var authored: Dictionary = DataCatalog.STORY_TRIGGERS
	DataCatalog.STORY_TRIGGERS = {EVENING: _evening_trigger()}
	triggers.reset()
	GameState.clear_run_values()
	GameState.set_scoped_value("run", "probe_evening_ready", true)
	# Au resto fermé, elle se propose au lieu de partir ; refusée, elle attend.
	triggers.evaluate()
	var prompt: Variant = triggers.get("evening_prompt")
	check(prompt is CanvasLayer and is_instance_valid(prompt) and not str((prompt as Node).get("question")).is_empty()
		and int(triggers.times_fired(EVENING)) == 0, "the evening does not offer itself, or starts on its own")
	if prompt is CanvasLayer and is_instance_valid(prompt):
		(prompt as Node).call("answer", false)
	await _frames(2)
	triggers.evaluate()
	check(triggers.get("evening_prompt") == null and GameState.active_evening.is_empty(),
		"a declined evening asks again before the next closing")
	# À la fermeture suivante, elle se repropose ; acceptée, elle part.
	await _accept_evening(restaurant, triggers)
	check(GameState.active_evening == EVENING and int(triggers.times_fired(EVENING)) == 1
		and bool(GameState.get_scoped_value("run", "probe_tonight", false)), "an accepted evening does not start")
	# Les deux services, vite : réussie, son crochet, sa scène.
	var fast_before := int(GameState.get_fact_value("fast_services"))
	var guest := _seat(restaurant, EVENING_GUEST)
	for step: int in 2:
		_serve_course(restaurant, guest, true)
		if step == 0:
			check(GameState.active_evening == EVENING, "the evening ends before its last course")
			restaurant.customer_reorder_delay_finished(guest)
	check(int(GameState.get_fact_value("fast_services")) == fast_before + 2, "fast services are not counted")
	var payload := GameState._build_save_payload().duplicate(true)
	check(str(payload.get("active_evening", "")) == EVENING, "the save forgets the evening in progress")
	guest.begin_leaving()
	check(GameState.active_evening.is_empty() and int(GameState.get_scoped_value("run", "probe_result", 0)) == 1
		and (triggers.get("_scene_queue") as Array).has(EVENING_SCENE), "a guest fed every course does not win the evening")
	# Une soirée en cours au moment de la sauvegarde reprend.
	GameState._apply_save_payload(payload)
	check(GameState.active_evening == EVENING, "a save made during the evening does not resume it")
	GameState.end_evening(false)
	await _clear_dining_room(restaurant)
	triggers.reset()
	# Un plat lent : ratée, et l'invité s'en va.
	await _accept_evening(restaurant, triggers)
	guest = _seat(restaurant, EVENING_GUEST)
	_serve_course(restaurant, guest, false)
	check(GameState.active_evening.is_empty() and int(GameState.get_scoped_value("run", "probe_result", 0)) == 2
		and guest.state in [Customer.State.LEAVING, Customer.State.STROLLING],
		"a slow course does not lose the evening, or the guest stays (evening %s, result %d, state %d, phase %d)" % [GameState.active_evening,
			int(GameState.get_scoped_value("run", "probe_result", 0)), guest.state, guest.service_phase])
	await _clear_dining_room(restaurant)
	# Un invité qui part avant son dernier service : ratée aussi.
	await _accept_evening(restaurant, triggers)
	guest = _seat(restaurant, EVENING_GUEST)
	_serve_course(restaurant, guest, true)
	guest.begin_leaving()
	check(GameState.active_evening.is_empty() and int(GameState.get_scoped_value("run", "probe_result", 0)) == 2,
		"a guest leaving before the last course does not lose the evening")
	await _clear_dining_room(restaurant)
	triggers.reset()
	DataCatalog.STORY_TRIGGERS = authored
	GameState.clear_run_values()

# --- 12. La fête de l'Abominable : le moteur ---------------------------------------------------
const PARTY_GUEST_B := "probe_d4_party_b"
const PARTY_GUEST_C := "probe_d4_party_c"
const EMPLOYEE_PROBE := "probe_d4_employee"

## Trois invités écrits, dont un qu'on n'a pas aidé : il ne vient pas et ne compte
## pas ; les deux autres entrent ensemble, chacun une fois.
func _party_evening(restaurant: Node) -> void:
	var triggers: Node = GameState.story_triggers
	var authored: Dictionary = DataCatalog.STORY_TRIGGERS
	var first_profile: Dictionary = DataCatalog.CUSTOMER_PROFILES.get(EVENING_GUEST, {})
	var unhelped := first_profile.duplicate(true)
	unhelped["conditions"] = (first_profile.get("conditions", []) as Array) + [{"path": "run.probe_never", "operator": "truthy", "value": true}]
	DataCatalog.CUSTOMER_PROFILES[PARTY_GUEST_B] = first_profile.duplicate(true)
	DataCatalog.CUSTOMER_PROFILES[PARTY_GUEST_C] = unhelped
	var party := _evening_trigger()
	party["evening"] = {"guests": {EVENING_GUEST: 1, PARTY_GUEST_B: 1, PARTY_GUEST_C: 1}, "fast_only": false,
		"success_scene": "", "failure_scene": ""}
	DataCatalog.STORY_TRIGGERS = {EVENING: party}
	triggers.reset()
	await _accept_evening(restaurant, triggers)
	check(GameState.active_evening == EVENING and GameState.evening_expected() == 2,
		"a party with an unhelped guest does not expect only the guests who can come (%d)" % GameState.evening_expected())
	check(not restaurant.customer_profile_is_available(PARTY_GUEST_C), "an unhelped guest still comes to the party")
	var first := _seat(restaurant, EVENING_GUEST)
	check(restaurant.customer_profile_is_available(PARTY_GUEST_B), "a second guest of the same party cannot come in with the first")
	var second := _seat(restaurant, PARTY_GUEST_B, mini(1, restaurant.tables.size() - 1))
	for guest: Customer in [first, second]:
		for step: int in 2:
			_serve_course(restaurant, guest, true)
			if step == 0:
				restaurant.customer_reorder_delay_finished(guest)
	first.begin_leaving()
	check(GameState.active_evening == EVENING and not restaurant.customer_profile_is_available(EVENING_GUEST),
		"the party ends before every guest who came has eaten, or a fed guest comes back")
	second.begin_leaving()
	check(GameState.active_evening.is_empty() and int(GameState.get_scoped_value("run", "probe_result", 0)) == 1,
		"the party is not won once every guest who came has eaten")
	await _clear_dining_room(restaurant)
	triggers.reset()
	DataCatalog.STORY_TRIGGERS = authored
	for probe: String in [PARTY_GUEST_B, PARTY_GUEST_C]:
		DataCatalog.CUSTOMER_PROFILES.erase(probe)
	GameState.clear_run_values()

## Un profil vient plus souvent tant que ses conditions de fréquence bonifiée tiennent.
func _arrival_bonus(restaurant: Node) -> void:
	var saved: Dictionary = (DataCatalog.CUSTOMER_PROFILES.get(EVENING_GUEST, {}) as Dictionary).duplicate(true)
	var profile: Dictionary = DataCatalog.CUSTOMER_PROFILES[EVENING_GUEST]
	profile["arrival_bonus_conditions"] = [{"path": "run.probe_regulars", "operator": "truthy", "value": true}]
	profile["arrival_bonus_multiplier"] = 2.0
	var base := float(profile.get("arrival_weight", 1.0))
	var before := float(restaurant.customer_arrival_weight(EVENING_GUEST))
	GameState.set_scoped_value("run", "probe_regulars", true)
	var after := float(restaurant.customer_arrival_weight(EVENING_GUEST))
	check(is_equal_approx(before, base) and is_equal_approx(after, base * 2.0),
		"a customer does not come twice as often once his bonus conditions hold (%.2f -> %.2f)" % [before, after])
	DataCatalog.CUSTOMER_PROFILES[EVENING_GUEST] = saved
	GameState.clear_run_values()

# --- 13. Le vote -------------------------------------------------------------------------------
const VOTER_A := "probe_d4_voter_a"
const VOTER_B := "probe_d4_voter_b"

## Deux électeurs : l'un mange deux fois le même plat, l'autre un autre, servi
## entre-temps ; le plus servi l'emporte, pas le premier.
func _vote_evening(restaurant: Node) -> void:
	var triggers: Node = GameState.story_triggers
	var authored: Dictionary = DataCatalog.STORY_TRIGGERS
	var voter: Dictionary = (DataCatalog.CUSTOMER_PROFILES.get(EVENING_GUEST, {}) as Dictionary).duplicate(true)
	voter["courses"] = [COURSES[1], COURSES[1]]
	DataCatalog.CUSTOMER_PROFILES[VOTER_A] = voter
	var other := voter.duplicate(true)
	other["courses"] = [COURSES[0]]
	DataCatalog.CUSTOMER_PROFILES[VOTER_B] = other
	var election := _evening_trigger()
	election["evening"] = {"guests": {VOTER_A: 1, VOTER_B: 1}, "fast_only": false, "vote": true, "success_scene": "", "failure_scene": ""}
	DataCatalog.STORY_TRIGGERS = {EVENING: election}
	triggers.reset()
	GameState.set_scoped_value("meta", "official_dish", "")
	await _accept_evening(restaurant, triggers)
	var first := _seat(restaurant, VOTER_A)
	var second := _seat(restaurant, VOTER_B, mini(1, restaurant.tables.size() - 1))
	# L'autre électeur est servi le premier : le premier servi n'est pas le plus servi.
	_serve_course(restaurant, second, true)
	second.begin_leaving()
	_serve_course(restaurant, first, true)
	restaurant.customer_reorder_delay_finished(first)
	_serve_course(restaurant, first, true)
	check(GameState.evening_vote_winner() == COURSES[1] and int(GameState.evening_votes.get(COURSES[0], 0)) == 1,
		"the vote does not count the dishes served to the voters (%s)" % [GameState.evening_votes])
	first.begin_leaving()
	var factor := float(restaurant.official_dish_factor(COURSES[1]))
	check(GameState.active_evening.is_empty() and GameState.official_dish() == COURSES[1]
		and is_equal_approx(factor, 1.0 + float(GameState.resolve_game_variable("official_dish_price_bonus"))) and factor > 1.0
		and is_equal_approx(float(restaurant.official_dish_factor(COURSES[0])), 1.0),
		"the won vote does not make the most served dish the official one, dearer for good (%s, %.2f)" % [GameState.official_dish(), factor])
	await _clear_dining_room(restaurant)
	triggers.reset()
	DataCatalog.STORY_TRIGGERS = authored
	for probe: String in [VOTER_A, VOTER_B]:
		DataCatalog.CUSTOMER_PROFILES.erase(probe)
	GameState.set_scoped_value("meta", "official_dish", "")
	GameState.clear_run_values()

## Un bonus d'employé attend ses conditions ; l'ouverture du service le relit.
# --- 19. Les souvenirs du mur -----------------------------------------------------------------
func _keepsakes(restaurant: Node) -> void:
	var world: Node = restaurant.get_node_or_null("Bridge3D")
	var holder: Node = world.get_node_or_null("RestaurantDressing") if world != null else null
	var photo: Node3D = null
	var fork: Node3D = null
	if holder != null:
		for child: Node in holder.get_children():
			match str(child.get_meta("keepsake", "")):
				"keepsake_photo":
					photo = child as Node3D
				"keepsake_fork":
					fork = child as Node3D
	if photo == null or fork == null:
		check(false, "the restaurant's back wall has no keepsakes (photo %s, fork %s)" % [photo != null, fork != null])
		return
	GameState.set_fact_value("big_egg_fake_end_seen", false)
	GameState.set_fact_value("fork_on_wall", false)
	var hidden := not photo.visible and not fork.visible
	get_tree().paused = true
	GameState.set_fact_value("big_egg_fake_end_seen", true)
	var photo_only := photo.visible and not fork.visible
	GameState.set_fact_value("fork_on_wall", true)
	var both := photo.visible and fork.visible
	get_tree().paused = false
	check(hidden and photo_only and both,
		"the keepsakes do not follow their facts on the back wall (hidden %s, photo %s, both %s)" % [hidden, photo_only, both])
	GameState.set_fact_value("big_egg_fake_end_seen", false)
	GameState.set_fact_value("fork_on_wall", false)

# --- 18. Une réplique de bulle sous condition -------------------------------------------------
const BUBBLE := preload("res://scripts/core/speech_bubble.gd")
const BUBBLE_FACT := "probe_d4_bubble_fact"

func _bubble_conditions() -> void:
	DataCatalog.GAME_FACTS[BUBBLE_FACT] = {"display_name": "Probe", "type": "bool", "default_value": false, "category": "story",
		"description": "", "scope": "dimension"}
	GameState.set_fact_value(BUBBLE_FACT, false)
	var actor := Node2D.new()
	add_child(actor)
	var bubble: Node2D = BUBBLE.new()
	add_child(bubble)
	bubble.call("bind_to_actor", actor, {"check_interval": 8.0, "cooldown": 0.0, "duration": 1.0, "triggers": {"random": [
		{"text": "Probe hint", "chance": 100.0, "conditions": [{"path": "fact." + BUBBLE_FACT, "operator": "truthy", "value": true}]}]}},
		Vector2.ZERO)
	var rolls: Array[float] = [0.0]
	var silent := not bool(bubble.call("trigger_event", "random", rolls))
	GameState.set_fact_value(BUBBLE_FACT, true)
	var spoken := bool(bubble.call("trigger_event", "random", rolls)) and str(bubble.get("last_message")) == "Probe hint"
	check(silent and spoken, "a speech bubble line does not wait for its conditions (silent %s, spoken %s)" % [silent, spoken])
	GameState.set_fact_value(BUBBLE_FACT, false)
	DataCatalog.GAME_FACTS.erase(BUBBLE_FACT)
	bubble.queue_free()
	actor.queue_free()

func _employee_conditions() -> void:
	var employee: Dictionary = (DataCatalog.EMPLOYEES[DataCatalog.EMPLOYEES.keys()[0]] as Dictionary).duplicate(true)
	employee["modifiers"] = [{"variable": "move_speed", "layer": "flat", "operation": "add", "value": 7.0,
		"conditions": [{"path": "run.probe_party_done", "operator": "truthy", "value": true}]}]
	DataCatalog.EMPLOYEES[EMPLOYEE_PROBE] = employee
	var base := float(GameState.resolve_game_variable("move_speed"))
	GameState.working_employees.append(EMPLOYEE_PROBE)
	GameState.refresh_employee_modifiers()
	var waiting := float(GameState.resolve_game_variable("move_speed"))
	GameState.set_scoped_value("run", "probe_party_done", true)
	GameState.close_dining("by_hand")
	GameState.open_dining()
	var earned := float(GameState.resolve_game_variable("move_speed"))
	check(is_equal_approx(waiting, base) and is_equal_approx(earned, base + 7.0),
		"an employee's bonus does not wait for its conditions, or the opening of service does not read them again (%.1f, %.1f, %.1f)" % [base, waiting, earned])
	GameState.working_employees.erase(EMPLOYEE_PROBE)
	GameState.remove_modifiers_by_source("employee:%s" % EMPLOYEE_PROBE)
	DataCatalog.EMPLOYEES.erase(EMPLOYEE_PROBE)
	GameState.clear_run_values()

## La soirée se propose à une fermeture, et on l'accepte.
func _accept_evening(restaurant: Node, triggers: Node) -> void:
	GameState.set_scoped_value("run", "probe_result", 0)
	# Une sauvegarde rechargée ne garde pas les valeurs de plongée : la soirée
	# d'essai redevient possible ici.
	GameState.set_scoped_value("run", "probe_evening_ready", true)
	GameState.open_dining()
	triggers.evaluate()
	GameState.close_dining("by_hand")
	GameState.close_takeout_counter("by_hand")
	triggers.evaluate()
	var prompt: Variant = triggers.get("evening_prompt")
	check(prompt is CanvasLayer and is_instance_valid(prompt), "the evening does not offer itself again at the next closing")
	if prompt is CanvasLayer and is_instance_valid(prompt):
		(prompt as Node).call("answer", true)
	await _frames(2)
	check(not get_tree().paused, "the evening question leaves the game paused")

## Un service de la soirée : commandé, servi vite ou lentement, mangé.
func _serve_course(restaurant: Node, guest: Customer, fast: bool) -> void:
	restaurant.customer_finished_thinking(guest)
	var recipe := guest.requested_recipe
	guest.patience = guest.phase_allowed if fast else guest.phase_allowed * 0.1
	var plate := _plate(restaurant, recipe)
	restaurant.serve_customer(guest, plate)
	if guest.state == Customer.State.EATING:
		restaurant.customer_finished_meal(guest)

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 _seat(restaurant: Node, profile_id: String, table_index: int = 0) -> Customer:
	var guest: Customer = load("res://scenes/restaurant/customer.tscn").instantiate()
	restaurant.add_child(guest)
	var table: Dictionary = restaurant.tables[table_index]
	table.customers[0] = guest
	restaurant.customers.append(guest)
	guest.setup(profile_id, restaurant, table_index, 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
	for node: Node in get_tree().get_nodes_in_group("dish"):
		node.queue_free()
	GameState.order_board.clear()
	await _frames(2)

# --- 1. Un modificateur de dimension sous condition ------------------------------------------
func _conditional_dimension_modifier() -> void:
	GameState.set_scoped_value("meta", "dimension_index", 0)
	var dimension_id := GameState.cycle_dimension_id()
	var modifiers: Array = DataCatalog.DIMENSIONS[dimension_id]["modifiers"]
	var base := float(GameState.resolve_game_variable("attack_damage"))
	var probe := {"variable": "attack_damage", "layer": "flat", "operation": "add", "value": 7.0,
		"conditions": [{"path": "run.%s" % PROBE_GATE, "operator": "truthy", "value": true}]}
	modifiers.append(probe)
	GameState.set_scoped_value("run", PROBE_GATE, false)
	check(is_equal_approx(float(GameState.resolve_game_variable("attack_damage")), base),
		"a dimension modifier applies before its conditions hold")
	GameState.set_scoped_value("run", PROBE_GATE, true)
	check(is_equal_approx(float(GameState.resolve_game_variable("attack_damage")), base + 7.0),
		"a dimension modifier does not apply once its conditions hold")
	GameState.set_scoped_value("run", PROBE_GATE, false)
	check(is_equal_approx(float(GameState.resolve_game_variable("attack_damage")), base),
		"a dimension modifier stays on after its conditions stop holding")
	modifiers.erase(probe)
	GameState.clear_run_values()

# L'endurance infinie de la D4 : les séances de Coach réussies, la plongée ne
# s'use plus ; les coups et la ruée coûtent encore.
func _infinite_endurance() -> void:
	GameState.set_scoped_value("meta", "dimension_index", 3)
	check(GameState.cycle_dimension_id() == "dimension_four", "the fourth world is not dimension_four: " + GameState.cycle_dimension_id())
	GameState.set_fact_value("coach_sessions_done", false)
	var walk := float(GameState.resolve_game_variable("move_stamina_per_second", 1.0))
	var dive := float(GameState.resolve_game_variable("dive_stamina_per_second", 1.0))
	var blow := float(GameState.resolve_game_variable("attack_stamina_cost", 5.0))
	var dash := float(GameState.resolve_game_variable("dash_stamina_cost", 5.0))
	check(walk > 0.0 and dive > 0.0, "the dive does not wear in D4 before Coach's sessions (%.2f, %.2f)" % [walk, dive])
	GameState.set_fact_value("coach_sessions_done", true)
	check(is_zero_approx(float(GameState.resolve_game_variable("move_stamina_per_second", 1.0)))
		and is_zero_approx(float(GameState.resolve_game_variable("dive_stamina_per_second", 1.0))),
		"the dive still wears in D4 once Coach's sessions are done")
	check(is_equal_approx(float(GameState.resolve_game_variable("attack_stamina_cost", 5.0)), blow)
		and is_equal_approx(float(GameState.resolve_game_variable("dash_stamina_cost", 5.0)), dash),
		"blows or the dash stop costing stamina")
	GameState.set_scoped_value("meta", "dimension_index", 2)
	check(float(GameState.resolve_game_variable("move_stamina_per_second", 1.0)) > 0.0,
		"the endurance reward already works in D3")
	GameState.set_fact_value("coach_sessions_done", false)
	GameState.set_scoped_value("meta", "dimension_index", 0)

func _finish() -> void:
	for id: String in COURSES:
		DataCatalog.RECIPES.erase(id)
	DataCatalog.CUSTOMER_PROFILES.erase(COURSE_GUEST)
	DataCatalog.CUSTOMER_PROFILES.erase(EVENING_GUEST)
	DataCatalog.STORY_SCENES.erase(EVENING_SCENE)
	DataCatalog.ENEMIES.erase(WARD_BEAST)
	DataCatalog.ROOM_ELEMENTS.erase(WARD)
	for room_id: String in WARD_ROOMS:
		DataCatalog.ROOMS.erase(room_id)
	DataCatalog.ROOMS.erase(RETRY_ROOM)
	DataCatalog.ROOMS.erase(RHYTHM_ROOM)
	DataCatalog.ROOMS.erase(DARK_ROOM)
	DataCatalog.ROOMS.erase(SLOP_ROOM)
	DataCatalog.ROOMS.erase(APPEAR_ROOM)
	DataCatalog.ROOM_ELEMENTS.erase(APPEARING)
	DataCatalog.GAME_FACTS.erase(APPEAR_FACT)
	DataCatalog.GAME_FACTS.erase(GONE_FACT)
	DataCatalog.ROOMS.erase(REVEAL_ROOM)
	DataCatalog.ROOM_ELEMENTS.erase(HIDDEN)
	DataCatalog.GAME_FACTS.erase(REVEALED_FACT)
	DataCatalog.GAME_FACTS.erase(TAUGHT_FACT)
	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("D4 ENGINE CHECK: ", "OK" if failures == 0 else "FAILED (%d)" % failures)
	get_tree().quit(0 if failures == 0 else 1)
