extends SceneTree
## Headless difficulty simulator.
##
## Drives a kinematic "bot bull" against ONE real matador (the actual Matador.tscn
## + matador.gd AI) across a few behaviour profiles, many trials each, and reports
## how often the matador wins — i.e. gores the bull — versus how often the bull
## wins by ramming the matador. It exists so matador difficulty can be tuned from
## measurements instead of guesswork.
##
##   godot --headless --script tests/difficulty_sim.gd
##
## Read the CHARGER row: that models a player who just charges in. If the matador
## almost never wins there, blind charging is unpunished and the game is too easy.

const CHARGER_TRIALS := 16
const KITER_TRIALS   := 16
const PASSIVE_TRIALS := 8
const TRIAL_TIMEOUT  := 5.0
const SPAWN_SETTLE   := 0.4

const CHARGE_SPEED := 42.0   # a fast, committed horn-charge
const KITE_DIST    := 12.0
const KITE_SPEED   := 17.0   # a real bull easily out-paces the matador; only throws threaten
const START_DIST   := 12.0

var _scene: Node
var _dp: Node
var _mat_scene: PackedScene
var _player: CharacterBody3D
var _matador: Node3D
var _bull_dead := false
var _mat_dead := false
var _dt := 1.0 / 60.0

# Charger bull state
var _charge_dir: Vector3 = Vector3.ZERO
var _charge_timer: float = 0.0
var _recover_timer: float = 0.0
# Kiter juke state
var _juke_dir: float = 1.0
var _juke_timer: float = 0.0
var _juke_range: float = KITE_DIST


func _init() -> void:
	_run.call_deferred()


func _run() -> void:
	_dt = 1.0 / float(Engine.physics_ticks_per_second)
	_dp = root.get_node_or_null("/root/DP")
	_mat_scene = load("res://Matador.tscn") as PackedScene

	_scene = (load("res://scene.tscn") as PackedScene).instantiate()
	root.add_child(_scene)
	current_scene = _scene
	await create_timer(0.5).timeout

	var players := get_nodes_in_group(&"player")
	if players.is_empty():
		push_error("no player in scene"); quit(1); return
	_player = players[0]
	_player.set_physics_process(false)          # we drive the bull kinematically
	_player.died.connect(func(_cause: String) -> void: _bull_dead = true)

	_strip_match_machinery()                     # no HUD pause / spawner interference

	print("=".repeat(64))
	print("DIFFICULTY SIMULATION  (physics %d Hz)" % Engine.physics_ticks_per_second)
	print("=".repeat(64))

	var charger := await _run_profile("CHARGER  (fast straight charges)", CHARGER_TRIALS, &"charger")
	var kiter   := await _run_profile("KITER    (circles at ~11 m)",      KITER_TRIALS,   &"kiter")
	var passive := await _run_profile("PASSIVE  (holds at attack range)", PASSIVE_TRIALS, &"passive")

	print("\n" + "=".repeat(64))
	print("SUMMARY — matador win %% (bull gored)")
	print("=".repeat(64))
	_print_row("CHARGER", charger)
	_print_row("KITER  ", kiter)
	_print_row("PASSIVE", passive)
	print("=".repeat(64))
	quit(0)


var _state_hist: Dictionary = {}
var _throws: int = 0
var _diag_printed: bool = false


func _run_profile(label: String, trials: int, mode: StringName) -> Dictionary:
	var wins := 0
	var losses := 0
	var timeouts := 0
	var total_t := 0.0
	_state_hist = {}
	_throws = 0
	for _i in trials:
		var res := await _run_trial(mode)
		total_t += res[1] as float
		match res[0] as StringName:
			&"mat":  wins += 1
			&"bull": losses += 1
			_:       timeouts += 1
	print("\n-- %s --" % label)
	print("  matador wins: %d/%d   bull wins: %d   timeouts: %d   avg %.2fs" % [
		wins, trials, losses, timeouts, total_t / maxf(float(trials), 1.0)])
	print("  swords thrown: %d   states: %s" % [_throws, _fmt_hist()])
	return {"win": wins, "loss": losses, "timeout": timeouts, "n": trials}


func _fmt_hist() -> String:
	var parts: Array[String] = []
	var total := 0
	for k: String in _state_hist:
		total += _state_hist[k] as int
	for k: String in _state_hist:
		parts.append("%s %.0f%%" % [k, 100.0 * float(_state_hist[k]) / maxf(float(total), 1.0)])
	return ", ".join(parts)


func _count_thrown_swords() -> int:
	var n := 0
	for c: Node in _scene.get_children():
		if c is RigidBody3D:
			n += 1
	return n


func _run_trial(mode: StringName) -> Array:
	if is_instance_valid(_matador):
		_matador.queue_free()
	await physics_frame

	_matador = _mat_scene.instantiate()
	_scene.add_child(_matador)
	_matador.global_position = Vector3(0.0, 1.0, 0.0)
	_matador.killed.connect(func() -> void: _mat_dead = true)

	var ang := randf() * TAU
	_player.global_position = Vector3(cos(ang), 0.0, sin(ang)) * START_DIST + Vector3(0.0, 1.0, 0.0)
	_player.velocity = Vector3.ZERO
	_player._dead = false
	_bull_dead = false
	_mat_dead = false
	_charge_dir = Vector3.ZERO
	_charge_timer = 0.0
	_recover_timer = 0.0
	_juke_dir = 1.0 if randf() < 0.5 else -1.0
	_juke_timer = 0.0
	_juke_range = KITE_DIST

	# Let the matador's _ready (ragdoll rig, sword) settle before the duel counts.
	var settle := SPAWN_SETTLE
	while settle > 0.0:
		await physics_frame
		settle -= _dt
	_bull_dead = false
	_mat_dead = false
	_player._dead = false

	if not _diag_printed:
		_diag_printed = true
		print("  [diag] matador has_sword=%s in_hand=%s throw_range=[%.0f,%.0f]" % [
			is_instance_valid(_matador._sword_node), _matador._sword_in_hand,
			_dp.f("mat_throw_min_dist"), _dp.f("mat_throw_range")])

	var t := 0.0
	var max_swords := 0
	while t < TRIAL_TIMEOUT:
		_drive(mode)
		await physics_frame
		t += _dt
		var s: String = _matador.ai_state_name()
		_state_hist[s] = int(_state_hist.get(s, 0)) + 1
		max_swords = maxi(max_swords, _count_thrown_swords())
		if _bull_dead:                 # the bull was gored → matador win
			_throws += max_swords
			return [&"mat", t]
		if _mat_dead:                  # the matador was rammed → bull win
			_throws += max_swords
			return [&"bull", t]
	_throws += max_swords
	return [&"timeout", t]


func _drive(mode: StringName) -> void:
	var to_mat := _matador.global_position - _player.global_position
	to_mat.y = 0.0
	var dist := to_mat.length()
	var aim := to_mat.normalized() if dist > 0.05 else Vector3.FORWARD
	var vel := Vector3.ZERO

	match mode:
		&"charger":
			if _recover_timer > 0.0:
				_recover_timer -= _dt
				vel = -aim * 14.0
			else:
				if _charge_timer <= 0.0:
					_charge_dir = aim               # commit a straight line (dodgeable)
					_charge_timer = 0.6
				_charge_timer -= _dt
				vel = _charge_dir * CHARGE_SPEED
				if dist < 0.8 or _charge_timer <= 0.0:
					_recover_timer = 0.35
					_charge_timer = 0.0
		&"kiter":
			# Juke: flip strafe direction and shift the hold distance at random so the
			# matador can't perfectly lead the throw — models an evasive player.
			_juke_timer -= _dt
			if _juke_timer <= 0.0:
				_juke_timer = randf_range(0.4, 0.9)
				_juke_dir = -_juke_dir if randf() < 0.6 else _juke_dir
				_juke_range = randf_range(8.0, 13.0)
			var inward := aim * clampf(dist - _juke_range, -1.0, 1.0)
			var tangent := Vector3(-aim.z, 0.0, aim.x) * _juke_dir
			vel = (tangent + inward).normalized() * KITE_SPEED
		&"passive":
			# Sit just inside the matador's attack range so it commits to a strike.
			if dist > 6.5:
				vel = aim * 6.0
			elif dist < 5.0:
				vel = -aim * 4.0

	_player.velocity = Vector3(vel.x, 0.0, vel.z)
	_player.move_and_slide()
	var p := _player.global_position
	p.y = 1.0
	_player.global_position = p


func _strip_match_machinery() -> void:
	for m: Node in get_nodes_in_group(&"matador"):
		m.queue_free()
	for s: Node in get_nodes_in_group(&"matador_spawn"):
		s.queue_free()
	var hud := _find_hud(_scene)
	if hud != null:
		hud.queue_free()


func _find_hud(n: Node) -> Node:
	if n is CanvasLayer and n.has_method(&"_show_game_over"):
		return n
	for c: Node in n.get_children():
		var r := _find_hud(c)
		if r != null:
			return r
	return null


func _print_row(label: String, r: Dictionary) -> void:
	var n: int = r["n"]
	var win: int = r["win"]
	var pct := 100.0 * float(win) / maxf(float(n), 1.0)
	print("  %s   %5.1f%%   (%d win / %d loss / %d timeout)" % [
		label, pct, win, r["loss"], r["timeout"]])
