extends Node

## L'orchestrateur de phases, eprouve SEUL.
##
## Aucun donjon, aucune creature, aucun temps reel: on pousse des deltas dans un
## etat et on lit la phase demandee. C'est ce qui permet de tester les grands
## pas de temps, les boucles infinies et les seuils de PV sans monter un combat,
## et de le faire en une seconde et demie plutot qu'en vingt.

const Director := preload("res://scripts/core/encounter_director.gd")

var failures := 0

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

func _ready() -> void:
	GameState.save_locked = true
	call_deferred("run")

## Le boss de reference du plan: une carapace de 6 s puis une ouverture de 3 s,
## en boucle infinie, et une ligne superieure qui prend la main sous 50 % de PV.
func _sample() -> Dictionary:
	return {
		"enabled": true, "kind": "boss", "start": "first_aggro",
		"engagement": "until_encounter_end", "default_phase": "recharge",
		"phases": [
			{"id": "shield", "display_name": "Carapace", "min_active_seconds": 0.0,
				"overrides": {"defense": {"invulnerable": true, "super_armor": true}}},
			{"id": "recharge", "display_name": "Recharge", "min_active_seconds": 3.0,
				"overrides": {"defense": {"damage_taken_multiplier": 1.5}, "attacks": []}},
			{"id": "laser", "display_name": "Balayage", "min_active_seconds": 0.0,
				"overrides": {"attack_needs_sight": false}},
		],
		"tracks": [
			{"id": "half_health", "label": "Sous 50 %", "enabled": true,
				"conditions": [{"path": "encounter.health_ratio", "operator": "<=", "value": 0.5}],
				"activation": "latched", "clock": "sequence", "on_preempted": "pause",
				"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
				"transition": {"mode": "finish_attack", "max_wait_seconds": 0.75, "urgent": false},
				"segments": [{"id": "beam", "phase": "laser", "start": 0.0, "end": 8.0}],
				"loop": {}, "after_end": "hold_last"},
			{"id": "cycle", "label": "Carapace", "enabled": true, "conditions": [],
				"activation": "latched", "clock": "sequence", "on_preempted": "pause",
				"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
				"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
				"segments": [
					{"id": "shell", "phase": "shield", "start": 0.0, "end": 6.0},
					{"id": "opening", "phase": "recharge", "start": 6.0, "end": 9.0}],
				"loop": {"start": 0.0, "end": 9.0, "iterations": null}, "after_end": "hold_last"},
		],
	}

## Pousse le temps par petits pas et rend la phase a la fin.
func _run_for(state: Dictionary, encounter: Dictionary, seconds: float, ratio: float, step: float = 0.05) -> String:
	var elapsed := 0.0
	var phase := ""
	while elapsed < seconds:
		var slice := minf(step, seconds - elapsed)
		phase = str(Director.advance(state, encounter, slice,
			{"encounter.health_ratio": ratio})["phase"])
		elapsed += slice
	return phase

func run() -> void:
	var encounter := _sample()
	var base_case := _sample()
	base_case.default_phase = "__base__"
	base_case.tracks[1].segments[0].phase = "__base__"
	var normalized: Variant = preload("res://scripts/core/encounter_contract.gd").normalize(base_case, {}, {})
	check(normalized != null, "Base profile cannot be selected as fallback or segment")
	var base_profile := {"speed": 123.0, "aggro_response": "avoid", "attacks": []}
	check(Director.resolve(base_profile, base_case, "__base__") == base_profile,
		"Base phase must restore the original profile")

	# --- Le repli occupe le terrain quand aucune ligne ne propose rien -------
	var lonely := {"enabled": true, "default_phase": "recharge", "phases": encounter["phases"], "tracks": []}
	var idle := Director.begin(lonely)
	check(_run_for(idle, lonely, 2.0, 1.0) == "recharge", "sans ligne, la phase par defaut tient le terrain")

	# --- La boucle infinie alterne sans cinquante copies --------------------
	var state := Director.begin(encounter)
	check(_run_for(state, encounter, 1.0, 1.0) == "shield", "la carapace ouvre le combat")
	check(_run_for(state, encounter, 6.0, 1.0) == "recharge", "a 7 s, l'ouverture a pris la main")
	check(_run_for(state, encounter, 3.0, 1.0) == "shield", "a 10 s, la boucle a reboucle sur la carapace")
	check(_run_for(state, encounter, 6.0, 1.0) == "recharge", "a 16 s, l'alternance continue")

	# --- La priorite: la ligne du HAUT gagne, sans second entier ------------
	var threshold := Director.begin(encounter)
	check(_run_for(threshold, encounter, 2.0, 1.0) == "shield", "au-dessus de 50 %, la carapace tourne")
	check(_run_for(threshold, encounter, 0.5, 0.4) == "laser",
		"sous 50 %, la ligne superieure prend la main immediatement")

	# --- L'armement memorise: un soin ne desarme pas un seuil franchi -------
	check(_run_for(threshold, encounter, 1.0, 0.9) == "laser",
		"une ligne memorisee reste armee malgre un soin")

	# La ligne du haut tient `hold_last`: passe ses 8 s, elle garde le laser et
	# ne rend jamais la main. C'est le defaut recommande par le plan.
	check(_run_for(threshold, encounter, 20.0, 0.4) == "laser",
		"une ligne en maintien garde la main apres sa fin dessinee")

	# --- Le curseur masque est SUSPENDU, pas perdu -------------------------
	# Avec une ligne du haut qui LIBERE, la ligne du bas doit reprendre la ou
	# elle s'etait arretee. Elle etait a 2 s dans une carapace qui dure 6 s:
	# elle doit donc revenir en carapace, pas repartir de zero ni sauter.
	var resuming := _sample()
	resuming["tracks"][0]["after_end"] = "release"
	var resumed := Director.begin(resuming)
	check(_run_for(resumed, resuming, 2.0, 1.0) == "shield", "la ligne du bas demarre en carapace")
	check(_run_for(resumed, resuming, 0.5, 0.4) == "laser", "le seuil de PV prend la main")
	check(_run_for(resumed, resuming, 8.0, 0.4) == "shield",
		"la ligne reprise repart ou elle s'etait arretee, pas a zero")
	check(_run_for(resumed, resuming, 4.5, 0.4) == "recharge",
		"et elle atteint son segment suivant au bon moment")

	# --- Le maintien final: la derniere phase JOUEE, sans rejouer l'entree --
	var holder := _sample()
	holder["tracks"] = [holder["tracks"][0]]
	holder["tracks"][0]["conditions"] = []
	var held := Director.begin(holder)
	check(_run_for(held, holder, 4.0, 1.0) == "laser", "la ligne unique joue son segment")
	check(_run_for(held, holder, 20.0, 1.0) == "laser",
		"apres la fin dessinee, la derniere phase jouee est maintenue")

	# --- `release` redevient transparent apres sa fin ----------------------
	var releaser := _sample()
	releaser["tracks"] = [releaser["tracks"][0]]
	releaser["tracks"][0]["conditions"] = []
	releaser["tracks"][0]["after_end"] = "release"
	var freed := Director.begin(releaser)
	check(_run_for(freed, releaser, 4.0, 1.0) == "laser", "avant sa fin, la ligne propose son segment")
	check(_run_for(freed, releaser, 10.0, 1.0) == "recharge",
		"apres sa fin, une ligne liberee laisse passer le repli")

	# --- Les trous sont transparents et ne figent pas le curseur -----------
	# Le vrai piege: une ligne sans segment courant doit CONTINUER d'avancer,
	# sinon elle reste bloquee dans son trou pendant que le repli joue.
	var holey := _sample()
	holey["tracks"] = [{"id": "gap", "label": "Trou", "enabled": true, "conditions": [],
		"activation": "latched", "clock": "sequence", "on_preempted": "pause",
		"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
		"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
		"segments": [
			{"id": "avant", "phase": "shield", "start": 0.0, "end": 2.0},
			{"id": "apres", "phase": "laser", "start": 5.0, "end": 9.0}],
		"loop": {}, "after_end": "release"}]
	var gap := Director.begin(holey)
	check(_run_for(gap, holey, 1.0, 1.0) == "shield", "le premier segment joue")
	check(_run_for(gap, holey, 2.0, 1.0) == "recharge", "le trou laisse passer le repli")
	check(_run_for(gap, holey, 3.0, 1.0) == "laser",
		"le curseur a traverse le trou et atteint le segment suivant")

	# --- Une ligne SUPERIEURE dans un trou doit continuer d'avancer ---------
	# C'est le piege que le plan signale nommement. Une ligne du haut qui n'a
	# rien a proposer laisse passer celle du bas — mais si on la met en pause
	# pour autant, son curseur reste fige dans le trou et elle n'atteint jamais
	# son segment suivant. « Seule la ligne gagnante avance » est donc faux.
	var layered := _sample()
	layered["tracks"] = [
		{"id": "haut", "label": "Haut", "enabled": true, "conditions": [],
			"activation": "latched", "clock": "sequence", "on_preempted": "pause",
			"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
			"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
			"segments": [
				{"id": "debut", "phase": "laser", "start": 0.0, "end": 2.0},
				{"id": "retour", "phase": "shield", "start": 5.0, "end": 9.0}],
			"loop": {}, "after_end": "release"},
		{"id": "bas", "label": "Bas", "enabled": true, "conditions": [],
			"activation": "latched", "clock": "sequence", "on_preempted": "pause",
			"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
			"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
			"segments": [{"id": "fond", "phase": "recharge", "start": 0.0, "end": 60.0}],
			"loop": {}, "after_end": "release"},
	]
	var stacked := Director.begin(layered)
	check(_run_for(stacked, layered, 1.0, 1.0) == "laser", "la ligne du haut joue son premier segment")
	check(_run_for(stacked, layered, 2.0, 1.0) == "recharge", "dans son trou, la ligne du haut laisse passer")
	check(_run_for(stacked, layered, 3.0, 1.0) == "shield",
		"la ligne du haut a continue d'avancer pendant son trou et reprend la main")

	# --- Les boucles sont ARITHMETIQUES: un grand delta ne deroule rien -----
	var huge := Director.begin(encounter)
	var started := Time.get_ticks_msec()
	Director.advance(huge, encounter, 900000.0, {"encounter.health_ratio": 1.0})
	var spent := Time.get_ticks_msec() - started
	check(spent < 200, "un delta de dix jours se resout sans derouler les tours (%d ms)" % spent)

	# --- Une boucle finie rend la main a la suite --------------------------
	var finite := _sample()
	finite["tracks"] = [{"id": "twice", "label": "Deux tours", "enabled": true, "conditions": [],
		"activation": "latched", "clock": "sequence", "on_preempted": "pause",
		"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
		"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
		"segments": [
			{"id": "a", "phase": "shield", "start": 0.0, "end": 2.0},
			{"id": "b", "phase": "laser", "start": 2.0, "end": 6.0}],
		"loop": {"start": 0.0, "end": 2.0, "iterations": 3}, "after_end": "release"}]
	var twice := Director.begin(finite)
	check(_run_for(twice, finite, 5.0, 1.0) == "shield", "les trois tours de boucle tiennent 6 s")
	check(_run_for(twice, finite, 3.0, 1.0) == "laser",
		"apres ses trois tours, la boucle rend la main au segment suivant")

	# --- Les surcharges: heriter, et un zero explicite reste un choix ------
	var base := {"speed": 120.0, "attacks": [{"pattern": "bite"}], "knockback_resistance": 0.1}
	var shielded := Director.resolve(base, encounter, "shield")
	check(float(shielded["speed"]) == 120.0, "un champ absent de la surcharge est herite")
	check((shielded["attacks"] as Array).size() == 1, "les attaques heritees sont conservees")
	var recharging := Director.resolve(base, encounter, "recharge")
	check((recharging["attacks"] as Array).is_empty(),
		"une liste d'attaques vide est un CHOIX, pas une absence")

	# --- La defense garde ses quatre notions distinctes --------------------
	var shield_defense := Director.defense_of(shielded)
	check(bool(shield_defense["invulnerable"]) and bool(shield_defense["super_armor"]),
		"la carapace est a la fois imprenable et ininterruptible")
	var open_defense := Director.defense_of(recharging)
	check(not bool(open_defense["invulnerable"]) and is_equal_approx(float(open_defense["damage_taken_multiplier"]), 1.5),
		"la recharge est vulnerable et encaisse davantage")

	# --- Une cible absente rend le predicat faux, jamais zero --------------
	var distant := _sample()
	distant["tracks"] = [distant["tracks"][0]]
	distant["tracks"][0]["conditions"] = [{"path": "encounter.target_distance", "operator": "<=", "value": 100.0}]
	var lost := Director.begin(distant)
	var phase := str(Director.advance(lost, distant, 0.5, {})["phase"])
	check(phase == "recharge", "sans cible, la condition de distance ne s'arme pas")

	# --- Une VRAIE creature, avec sa rencontre -----------------------------
	# Le moteur pur ne prouve pas que la creature l'applique. Ici on monte un
	# boss de fixture, on force son aggro et on verifie ce que le plan exige:
	# alternance reelle, protections composees, et surtout PV, recharges et
	# effets conserves — un changement de phase n'est pas une reapparition.
	var source_id: String = DataCatalog.ENEMIES.keys()[0]
	var fixture: Dictionary = DataCatalog.ENEMIES[source_id].duplicate(true)
	fixture["display_name"] = "Boss de controle"
	fixture["health"] = 500.0
	fixture["knockback_resistance"] = 0.0
	fixture["encounter"] = encounter
	DataCatalog.ENEMIES["encounter_probe"] = fixture
	var hero: Node2D = load("res://scenes/player/player.tscn").instantiate()
	add_child(hero)
	hero.set_physics_process(false)
	var boss = load("res://scenes/enemies/enemy.tscn").instantiate()
	add_child(boss)
	boss.set_physics_process(false)
	boss.setup("encounter_probe")
	boss.target = hero
	boss.active = true
	check(not boss.encounter_engaged, "une rencontre est LUE au setup, pas engagee")

	boss.awareness.provoke(hero.global_position, true)
	boss._sync_awareness_state()
	boss._begin_encounter()
	check(boss.encounter_engaged, "le premier aggro ouvre le combat")
	check(boss.encounter_phase == "shield", "la carapace ouvre, sans passer par le repli")

	# La carapace est imprenable ET ininterruptible.
	var before := float(boss.health)
	boss.take_damage(40.0, Vector2.RIGHT, 0.0, hero)
	check(is_equal_approx(float(boss.health), before), "la carapace refuse les degats")

	# On avance jusqu'a l'ouverture, puis on verifie la faiblesse.
	for _step: int in range(140):
		boss._advance_encounter(0.05)
	check(boss.encounter_phase == "recharge", "l'ouverture prend la main apres la carapace")
	var opened := float(boss.health)
	boss.take_damage(10.0, Vector2.RIGHT, 0.0, hero)
	check(is_equal_approx(float(boss.health), opened - 15.0),
		"la recharge encaisse une fois et demie (%.1f)" % (opened - float(boss.health)))

	# Les PV et les recharges TRAVERSENT le changement de phase.
	boss.attack_cooldowns["temoin"] = 2.5
	var carried := float(boss.health)
	for _step: int in range(80):
		boss._advance_encounter(0.05)
	check(boss.encounter_phase == "shield", "la boucle revient a la carapace")
	check(is_equal_approx(float(boss.health), carried), "les PV traversent le changement de phase")
	check(is_equal_approx(float(boss.attack_cooldowns.get("temoin", 0.0)), 2.5),
		"les recharges en cours traversent le changement de phase")
	check(boss.max_health > 0.0 and is_equal_approx(float(boss.max_health), 500.0),
		"le maximum de PV n'est pas remis a zero par une phase")

	# Une phase qui vide la liste d'attaques ne laisse rien lancer.
	boss._apply_phase("recharge")
	check((boss.attacks as Array).is_empty(), "une liste d'attaques vide est appliquee telle quelle")
	boss._apply_phase("shield")
	check(not (boss.attacks as Array).is_empty(), "et la phase suivante retrouve les attaques heritees")

	# L'engagement d'un boss survit a la perte de vue.
	check(boss.encounter_holds_engagement(), "un boss engage garde le droit de combattre sans voir")
	_check_catalogue()
	_check_phase_sounds(boss, hero)
	_check_presentation(boss)
	_check_diagnostic(boss)
	_check_engaged_movement(boss, hero)
	_check_exit_director()
	_check_segment_counts(boss, hero)

	boss.queue_free()
	hero.queue_free()
	DataCatalog.ENEMIES.erase("encounter_probe")
	# Monter une vraie creature joue son son d'apparition. Un flux compresse
	# n'est pas rendu par `stop()`: sa lecture vit encore quelques images sur
	# le fil audio, et quitter avant laisse « resources still in use at exit ».
	# On desactive d'abord, on libere ensuite — le piege du chantier sonore.
	AudioDirector.shutdown()
	await get_tree().process_frame
	await get_tree().process_frame

	print("RENCONTRE OK: ", "PASSED" if failures == 0 else "FAILED avec %d echec(s)" % failures)
	get_tree().quit(0 if failures == 0 else 1)


# --- La sortie d'un segment (ligne 21) --------------------------------------------

## Une ligne : un rail qui sort a l'arrivee OU au deuxieme coup porte, un laser
## qui sort apres une seconde, puis une recharge.
func _exit_sample(clock: String = "sequence") -> Dictionary:
	return {"enabled": true, "default_phase": "recharge", "phases": _sample()["phases"],
		"tracks": [{"id": "run", "label": "Course", "enabled": true, "conditions": [],
			"activation": "latched", "clock": clock, "on_preempted": "pause",
			"activation_delay_seconds": 0.0, "rearm_delay_seconds": 0.25,
			"transition": {"mode": "interrupt", "max_wait_seconds": 0.0, "urgent": false},
			"segments": [
				{"id": "rail", "phase": "shield", "start": 0.0, "end": 6.0, "exit_conditions": [
					{"path": "encounter.movement_arrived", "operator": ">=", "value": 1.0},
					{"path": "encounter.segment_hero_hits", "operator": ">=", "value": 2.0}]},
				{"id": "beam", "phase": "laser", "start": 6.0, "end": 9.0, "exit_conditions": [
					{"path": "encounter.segment_elapsed", "operator": ">=", "value": 1.0}]},
				{"id": "rest", "phase": "recharge", "start": 9.0, "end": 12.0, "exit_conditions": []}],
			"loop": {}, "after_end": "hold_last"}]}

func _step(state: Dictionary, encounter: Dictionary, seconds: float, context: Dictionary = {}) -> Dictionary:
	var result := {}
	var elapsed := 0.0
	while elapsed < seconds - 0.0001:
		var slice := minf(0.05, seconds - elapsed)
		result = Director.advance(state, encounter, slice, context)
		elapsed += slice
	return result

func _check_exit_director() -> void:
	var encounter := _exit_sample()
	var normalized: Variant = preload("res://scripts/core/encounter_contract.gd").normalize(encounter, {}, {})
	check(normalized != null and ((normalized as Dictionary)["tracks"][0]["segments"][0]["exit_conditions"] as Array).size() == 2,
		"les conditions de sortie ne passent pas la normalisation")
	var state := Director.begin(encounter)
	check(str(_step(state, encounter, 1.0)["phase"]) == "shield", "le rail ouvre la ligne")
	check(str(_step(state, encounter, 0.05, {"encounter.segment_hero_hits": 1.0})["phase"]) == "shield",
		"un seul coup porte fait deja sortir du rail")
	var out := _step(state, encounter, 0.05, {"encounter.movement_arrived": 1.0})
	check(bool(out.get("exited", false)), "l'arrivee du rail ne fait pas sortir du segment")
	check(str(_step(state, encounter, 0.05)["phase"]) == "laser", "apres la sortie, le segment suivant ne prend pas la main")
	# Le laser sort apres une seconde a lui : la suite avance d'autant.
	check(str(_step(state, encounter, 0.8)["phase"]) == "laser", "le laser sort avant sa seconde")
	check(str(_step(state, encounter, 0.4)["phase"]) == "recharge", "le laser ne sort pas apres sa seconde")
	# OU : le deuxieme coup porte suffit, sans le rail.
	var either := Director.begin(encounter)
	_step(either, encounter, 0.5)
	_step(either, encounter, 0.05, {"encounter.segment_hero_hits": 2.0})
	check(str(_step(either, encounter, 0.05)["phase"]) == "laser", "une seule condition vraie ne suffit pas (OU)")
	# La fin dessinee reste un plafond.
	var capped := Director.begin(encounter)
	check(str(_step(capped, encounter, 6.1)["phase"]) == "laser", "sans condition vraie, le segment depasse sa fin dessinee")
	# L'horloge du combat ne saute pas.
	var combat := _exit_sample("combat")
	var steady := Director.begin(combat)
	_step(steady, combat, 1.0)
	_step(steady, combat, 0.05, {"encounter.movement_arrived": 1.0})
	check(str(_step(steady, combat, 0.5)["phase"]) == "shield", "une ligne a l'horloge du combat sort plus tot")

## Les compteurs du segment sur la vraie creature : ils comptent, et repartent
## de zero au segment suivant ; sans rail, l'arrivee n'existe pas.
func _check_segment_counts(boss, hero: Node2D) -> void:
	boss._apply_phase("recharge")
	var context: Dictionary = boss._encounter_context()
	var before := float(context.get("encounter.segment_hits_taken", -1.0))
	boss.take_damage(5.0, Vector2.RIGHT, 0.0, hero)
	context = boss._encounter_context()
	check(float(context.get("encounter.segment_hits_taken", -1.0)) == before + 1.0
		and float(context.get("encounter.segment_damage_ratio", 0.0)) > 0.0, "un coup recu n'est pas compte dans le segment")
	check(context.has("encounter.segment_hero_hits") and context.has("encounter.segment_attacks_done"),
		"le contexte ne publie pas les compteurs du segment")
	check(not context.has("encounter.movement_arrived"), "une bete sans rail publie une arrivee")
	# Le directeur passe a une autre occurrence de segment (ici simulee : la
	# ligne « sous 50 % » de ce boss peut tenir son laser indefiniment).
	var key := str(boss.get("_segment_key"))
	var entry: Dictionary = (boss.encounter_state.get("entry", {}) as Dictionary).duplicate()
	entry["segment"] = str(entry.get("segment", "")) + "_suivant"
	boss.encounter_state["entry"] = entry
	context = boss._encounter_context()
	check(str(boss.get("_segment_key")) != key and float(context.get("encounter.segment_hits_taken", -1.0)) == 0.0,
		"les compteurs ne repartent pas de zero au segment suivant")

func _check_engaged_movement(boss, hero: Node2D) -> void:
	# Separate movement profiles expose a loss of engagement without relying on
	# whichever authored enemy happens to be first in the catalogue.
	DataCatalog.MOVEMENT_PATTERNS["encounter_move_probe"] = {
		"type": "linear", "movement_freedom": "omni",
		"speed_multiplier": 1.0, "detected_speed_multiplier": 1.0,
	}
	DataCatalog.MOVEMENT_PATTERNS["encounter_idle_probe"] = {
		"type": "static", "movement_freedom": "facing",
	}
	boss.aggro_movement = "encounter_move_probe"
	boss.detected_movement = "encounter_idle_probe"
	boss.undetected_movement = "encounter_idle_probe"
	boss.move_speed = 100.0
	boss.hero_response = "ignore"
	boss.aggro_response = "chase"
	boss.ai_state = "aggroed"
	boss.awareness.state = "undetected"
	boss.attack_state = "recovery"
	boss._sync_awareness_state()
	check(boss.attack_state == "recovery",
		"un boss engage conserve son geste quand la perception retombe")
	check(boss._current_hero_response() == "chase",
		"un boss engage conserve sa reaction aggro sans detection")
	check(boss._current_movement_pattern().get("type") == "linear",
		"un boss engage conserve les contraintes du mouvement aggro")
	var moving: Vector2 = boss._desired_movement(0.016, false, Vector2.RIGHT * 400.0, 400.0)
	check(moving.is_equal_approx(Vector2.RIGHT * 100.0),
		"un boss engage poursuit son mouvement aggro sans detection")
	boss._interrupt_attack()

	# Perception-based encounters and ordinary enemies retain their old rules.
	boss.encounter["engagement"] = "perception"
	boss.ai_state = "aggroed"
	boss.awareness.state = "undetected"
	boss.attack_state = "recovery"
	boss._sync_awareness_state()
	check(boss.attack_state == "idle", "la politique perception interrompt encore le geste hors aggro")
	check(boss._desired_movement(0.016, false, Vector2.RIGHT * 400.0, 400.0).is_zero_approx(),
		"la politique perception retrouve le mouvement hors combat")
	boss.encounter = {}
	boss.encounter_engaged = false
	check(boss._current_hero_response() == "ignore" and boss._movement_freedom() == "facing",
		"une creature ordinaire conserve sa reaction et ses contraintes hors combat")

	# Exercise the actual physics branch that STARTS an attack: checking the
	# next windup frame alone misses the one-frame stop between repeated shots.
	var pattern: Dictionary = DataCatalog.ATTACK_PATTERNS.values()[0].duplicate(true)
	pattern["free_movement"] = true
	pattern["trigger_range"] = 10000.0
	pattern["attack_facing"] = "keep"
	DataCatalog.ATTACK_PATTERNS["encounter_shot_probe"] = pattern
	boss.attacks = [{"id": "probe_shot", "pattern": "encounter_shot_probe", "weight": 1.0}]
	boss.global_position = Vector2(500.0, 500.0)
	hero.global_position = Vector2(900.0, 500.0)
	boss.awareness.provoke(hero.global_position, true)
	boss._sync_awareness_state()
	boss.attack_needs_sight = false
	boss.direct_hit_enabled = false
	boss.clear_attack_cooldowns()
	boss._physics_process(0.016)
	check(boss.attack_state == "windup", "le controle lance reellement une attaque libre")
	check(boss.velocity.is_equal_approx(Vector2.RIGHT * 100.0),
		"une attaque libre conserve le mouvement des sa premiere image")
	boss._interrupt_attack()
	DataCatalog.ATTACK_PATTERNS["encounter_shot_probe"]["free_movement"] = false
	boss.clear_attack_cooldowns()
	boss._physics_process(0.016)
	check(boss.attack_state == "windup" and boss.velocity.is_zero_approx(),
		"une attaque sans mouvement libre arrete toujours le deplacement au depart")
	boss._interrupt_attack()
	DataCatalog.ATTACK_PATTERNS.erase("encounter_shot_probe")
	DataCatalog.MOVEMENT_PATTERNS.erase("encounter_move_probe")
	DataCatalog.MOVEMENT_PATTERNS.erase("encounter_idle_probe")


## Les sons de phase. Le piege nomme par le plan: ne pas rejouer l'entree
## pendant un maintien final, et ne pas la rejouer non plus a chaque tour d'une
## boucle qui revient sur la MEME phase.
func _check_phase_sounds(boss, hero: Node2D) -> void:
	AudioDirector.begin_recording()
	boss._apply_phase("shield")
	AudioDirector.begin_recording()
	boss._apply_phase("recharge")
	check(AudioDirector.recorded_events("enemy_phase_enter").size() == 1,
		"un changement de phase n'a pas sonne une entree et une seule")
	check(AudioDirector.recorded_events("enemy_phase_exit").size() == 1,
		"la phase quittee n'a pas sonne sa sortie")

	# Redemander la MEME phase est un maintien, pas une entree.
	AudioDirector.begin_recording()
	boss._apply_phase("recharge")
	check(AudioDirector.recorded_events("enemy_phase_enter").is_empty(),
		"un maintien de phase a rejoue l'entree")
	check(AudioDirector.recorded_events("enemy_phase_exit").is_empty(),
		"un maintien de phase a joue une sortie")

	# La meme phase dans une occurrence de boucle differente reste la meme
	# phase: l'entree de segment change, la creature ne redevient pas autre.
	AudioDirector.begin_recording()
	boss._apply_phase("recharge", {"track": "cycle", "segment": "opening", "iteration": 3})
	check(AudioDirector.recorded_events("enemy_phase_enter").is_empty(),
		"un nouveau tour de boucle sur la meme phase a rejoue l'entree")

	# Un coup refuse par la carapace s'ENTEND. Sans cela, le joueur croit avoir
	# rate son coup plutot que l'avoir vu bloquer.
	boss._apply_phase("shield")
	AudioDirector.begin_recording()
	var guarded := float(boss.health)
	boss.take_damage(25.0, Vector2.RIGHT, 0.0, hero)
	check(is_equal_approx(float(boss.health), guarded), "la carapace a laisse passer des degats")
	check(AudioDirector.recorded_events("enemy_shield_block").size() == 1,
		"un coup bloque par la carapace n'a pas sonne")
	check(AudioDirector.recorded_events("enemy_hurt").is_empty(),
		"un coup bloque a joue la blessure")

	# Un soin n'est pas un coup bloque, meme refuse par la meme porte.
	AudioDirector.begin_recording()
	boss.take_damage(-10.0, Vector2.ZERO, 0.0, null)
	check(AudioDirector.recorded_events("enemy_shield_block").is_empty(),
		"un soin refuse a sonne comme un coup bloque")

	# Vulnerable, le coup passe et c'est la blessure qui parle.
	boss._apply_phase("recharge")
	AudioDirector.begin_recording()
	boss.take_damage(5.0, Vector2.RIGHT, 0.0, hero)
	check(AudioDirector.recorded_events("enemy_shield_block").is_empty(),
		"un coup encaisse a sonne comme un coup bloque")
	check(AudioDirector.recorded_events("enemy_hurt").size() == 1,
		"un coup encaisse n'a pas joue la blessure")
	AudioDirector.end_recording()


## Le diagnostic: il doit nommer la VRAIE raison d'une attente. En nommer une
## autre enverrait l'auteur corriger un reglage qui n'y est pour rien.
func _check_diagnostic(boss) -> void:
	var report: Dictionary = boss.encounter_diagnostic()
	check(bool(report.get("active", false)), "le diagnostic d'un boss engage se dit inactif")
	check((report.get("tracks", []) as Array).size() == 2, "le diagnostic ne rend pas les deux lignes")

	# Expliquer ne doit RIEN changer: deux lectures de suite sont identiques.
	var twice: Dictionary = boss.encounter_diagnostic()
	check(twice == report, "lire le diagnostic a modifie l'etat de la rencontre")

	# Une ligne dont la condition n'est pas remplie se dit en attente, et ne
	# pretend pas etre desactivee: ce sont deux corrections differentes.
	var half: Dictionary = (report["tracks"] as Array)[0]
	check(str(half["id"]) == "half_health", "l'ordre des lignes du diagnostic ne suit pas la priorite")
	check(str(half["reason"]) == Director.REASON_WAITING,
		"une ligne non armee n'est pas dite en attente de condition: " + str(half["reason"]))
	check(not bool(half["winner"]), "une ligne non armee est donnee gagnante")
	var cycle: Dictionary = (report["tracks"] as Array)[1]
	check(bool(cycle["winner"]), "la ligne qui propose n'est pas donnee gagnante")
	check(str(cycle["proposes"]) != "", "la ligne gagnante ne nomme pas la phase qu'elle propose")

	# Une ligne eteinte se dit eteinte, pas « en attente ».
	(boss.encounter["tracks"] as Array)[1]["enabled"] = false
	var off: Dictionary = boss.encounter_diagnostic()
	check(str(((off["tracks"] as Array)[1] as Dictionary)["reason"]) == Director.REASON_DISABLED,
		"une ligne eteinte n'est pas dite eteinte")
	(boss.encounter["tracks"] as Array)[1]["enabled"] = true

	# Une ligne superieure qui prend la main MASQUE l'autre: le diagnostic doit
	# le dire, sinon l'auteur croit sa ligne cassee alors qu'elle est couverte.
	boss.health = boss.max_health * 0.4
	boss._advance_encounter(0.05)
	var masked: Dictionary = boss.encounter_diagnostic()
	check(str(((masked["tracks"] as Array)[0] as Dictionary)["reason"]) != Director.REASON_WAITING,
		"la ligne armee par le seuil de PV se dit encore en attente")
	check(str(((masked["tracks"] as Array)[1] as Dictionary)["reason"]) == Director.REASON_MASKED,
		"la ligne couverte n'est pas dite masquee: "
			+ str(((masked["tracks"] as Array)[1] as Dictionary)["reason"]))

	# Et l'attente d'une transition nomme ce qui la retient. La recharge garde
	# 3 s d'exposition minimale: juste apres y etre entre, c'est ELLE qui
	# retient, pas le geste en cours.
	boss.health = boss.max_health
	boss._apply_phase("recharge")
	boss.encounter_pending = "shield"
	boss.encounter_wait = 0.1
	var held: Dictionary = boss.encounter_diagnostic()
	check(str((held["transition"] as Dictionary).get("reason", "")) == "minimum_exposure",
		"l'exposition minimale n'est pas nommee comme cause de l'attente: "
			+ str((held["transition"] as Dictionary).get("reason", "")))
	check(float((held["transition"] as Dictionary).get("remaining", 0.0)) > 0.0,
		"l'attente ne dit pas combien de temps il reste")

	# Exposition passee, c'est le geste engage qui retient.
	boss.encounter_phase_elapsed = 10.0
	boss.attack_state = "windup"
	boss.attack_definition_key = "temoin"
	var busy: Dictionary = boss.encounter_diagnostic()
	check(str((busy["transition"] as Dictionary).get("reason", "")) == "finishing_attack",
		"le geste engage n'est pas nomme comme cause de l'attente: "
			+ str((busy["transition"] as Dictionary).get("reason", "")))
	check(str((busy["transition"] as Dictionary).get("attack", "")) == "temoin",
		"l'attente ne nomme pas le geste qui la retient")
	boss.attack_state = "idle"
	boss.attack_definition_key = ""
	boss.encounter_pending = ""

	# Rien en attente: le diagnostic le dit plutot que d'inventer une cause.
	check(str((boss.encounter_diagnostic()["transition"] as Dictionary).get("reason", "")) == "settled",
		"une rencontre sans attente invente une cause d'attente")


## Ce que la phase MONTRE. Le piege nomme par le plan: ne pas compter sur la
## seule couleur. Les trois etats doivent donc se distinguer par une valeur que
## l'affichage traduit en FORME, pas par une teinte.
func _check_presentation(boss) -> void:
	boss._apply_phase("shield")
	var sealed: Dictionary = boss.encounter_presentation()
	check(bool(sealed["shielded"]), "la carapace ne se dit pas imprenable")
	check(str(sealed["shell"]) == "sealed", "la carapace ne publie pas de coque fermee")
	check(str(sealed["phase"]) == "shield", "la presentation ne nomme pas la phase active")

	# Imprenable et ininterruptible ne sont pas la meme chose: une creature
	# qu'on peut blesser mais pas interrompre doit se lire autrement.
	boss.encounter_defense = {"invulnerable": false, "super_armor": true,
		"damage_taken_multiplier": 1.0}
	var braced: Dictionary = boss.encounter_presentation()
	check(str(braced["shell"]) == "braced",
		"une creature seulement ininterruptible affiche la meme coque qu'une imprenable")
	check(not bool(braced["shielded"]), "une cuirasse sans invulnerabilite se dit imprenable")

	# La rupture: protege, puis plus. C'est l'instant que le joueur attend, et
	# il doit s'annoncer une fois — pas rester allume, pas passer inapercu.
	boss._apply_phase("shield")
	check(float(boss.encounter_presentation()["break_flash"]) <= 0.0,
		"entrer dans une carapace a declenche le signal de rupture")
	boss._apply_phase("recharge")
	check(float(boss.encounter_presentation()["break_flash"]) > 0.0,
		"la chute de la carapace n'a pas annonce l'ouverture")
	check(bool(boss.encounter_presentation()["weakened"]),
		"une phase qui encaisse plus ne se dit pas affaiblie")
	check(str(boss.encounter_presentation()["shell"]).is_empty(),
		"une phase sans protection affiche encore une coque")

	# Le signal s'eteint TOUT SEUL en jouant le temps: un eclat qui resterait
	# allume dirait « frappe maintenant » pendant tout le combat.
	for _step: int in range(20):
		boss._physics_process(0.05)
	check(float(boss.encounter_presentation()["break_flash"]) <= 0.0,
		"le signal de rupture ne s'eteint jamais")

	# Passer d'une phase NUE a une autre phase nue n'est pas une rupture: il n'y
	# avait rien a rompre.
	boss._apply_phase("laser")
	check(float(boss.encounter_presentation()["break_flash"]) <= 0.0,
		"un changement entre deux phases sans protection a annonce une rupture")
	boss._apply_phase("shield")


## Ce que le CATALOGUE a vraiment charge.
##
## Une rencontre ecrite, publiee et validee par PHP peut encore disparaitre au
## chargement: le miroir Godot rend `null` sur une structure qu'il refuse, et
## `data_catalog` laisse alors la fiche sans rencontre — sans un mot. La
## creature se comporte comme une bete ordinaire et on cherche le bug dans la
## frise. Ce controle compare les deux bouts.
func _check_catalogue() -> void:
	var declared := 0
	var lost: Array[String] = []
	var raw: Variant = JSON.parse_string(FileAccess.get_file_as_string("res://data/game_data_master.json"))
	if not raw is Dictionary:
		check(false, "le maitre est illisible depuis le controle")
		return
	var beasts: Variant = (raw as Dictionary).get("enemies", {})
	for id: Variant in (beasts as Dictionary):
		var written: Variant = ((beasts as Dictionary)[id] as Dictionary).get("encounter", {})
		if not (written is Dictionary) or not bool((written as Dictionary).get("enabled", false)):
			continue
		declared += 1
		var loaded: Variant = DataCatalog.ENEMIES.get(str(id), {})
		var kept: Variant = (loaded as Dictionary).get("encounter", {})
		if not (kept is Dictionary) or (kept as Dictionary).is_empty():
			lost.append(str(id))
			continue
		# Une rencontre chargee sans phase ni ligne serait une coquille vide:
		# publiee, acceptee, et sans effet.
		if ((kept as Dictionary).get("phases", []) as Array).is_empty():
			lost.append(str(id) + " (sans phase)")
	check(lost.is_empty(),
		"des rencontres publiees ont ete perdues au chargement: " + ", ".join(lost))
	print("RENCONTRES PUBLIEES: %d declaree(s), %d perdue(s)" % [declared, lost.size()])
