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"]])