extends SceneTree ## Headless logic tests — validates game logic without visuals. ## Run: godot --headless --script tests/logic_test.gd ## ## Exit code 0 = all passed, 1 = any failure. var _passed: int = 0 var _failed: int = 0 func _init() -> void: _run.call_deferred() func _run() -> void: print("=" .repeat(60)) print("LOGIC TESTS") print("=" .repeat(60)) test_debug_params_defaults() test_debug_params_clamp_values() test_debug_params_save_load_cycle() test_debug_params_flat_cache() test_debug_console_commands() test_matador_spawn_positions() test_matador_wander_target_in_bounds() test_matador_state_transitions() test_roll_damping() test_velocity_capping() test_kick_spread() test_gait_phase_crossing() test_tail_verlet_constraint() print("=" .repeat(60)) print("Results: %d passed, %d failed" % [_passed, _failed]) print("=" .repeat(60)) quit(1 if _failed > 0 else 0) # ── Test helpers ────────────────────────────────────────────────────────────── func _assert_true(condition: bool, desc: String) -> void: if condition: _passed += 1 print(" PASS: %s" % desc) else: _failed += 1 print(" FAIL: %s" % desc) func _assert_eq(a: Variant, b: Variant, desc: String) -> void: _assert_true(a == b, "%s (got %s, expected %s)" % [desc, str(a), str(b)]) # ── Player physics math ─────────────────────────────────────────────────────── func test_roll_damping() -> void: print("\n-- test_roll_damping --") var damping := 0.15 # default roll_damping var retain := pow(damping, 1.0 / 60.0) # One frame at 60 fps should retain ~96-99% of speed. _assert_in_range(retain, 0.90, 0.999, "single-frame retain factor is reasonable") # After exactly 1 s, speed should equal starting_speed * damping (pow identity). # With damping=0.15: pow(0.15, 1/60)^60 = 0.15, so 30 × 0.15 = 4.5. var speed := 30.0 for _i: int in 60: speed *= pow(damping, 1.0 / 60.0) var expected := 30.0 * damping _assert_in_range(speed, expected * 0.95, expected * 1.05, "speed after 1 s equals starting_speed × damping (±5%%)") # Lower damping value damps more aggressively per frame. var fast_retain := pow(0.01, 1.0 / 60.0) _assert_true(fast_retain < retain, "damping=0.01 retains less per frame than damping=0.15") func test_velocity_capping() -> void: print("\n-- test_velocity_capping --") var max_speed := 30.0 var impulse := 18.0 # Thrust from rest: should never exceed max_speed. var vel := Vector3.ZERO vel.x += impulse var flat := Vector2(vel.x, vel.z) if flat.length() > max_speed: vel.x *= max_speed / flat.length() vel.z *= max_speed / flat.length() _assert_in_range(Vector2(vel.x, vel.z).length(), 0.0, max_speed + 0.001, "thrust from rest: capped at max_speed") # Thrust while already at max_speed: still capped. vel = Vector3(max_speed, 0.0, 0.0) vel.x += impulse flat = Vector2(vel.x, vel.z) if flat.length() > max_speed: vel.x *= max_speed / flat.length() vel.z *= max_speed / flat.length() _assert_in_range(Vector2(vel.x, vel.z).length(), 0.0, max_speed + 0.001, "thrust from max_speed: still capped at max_speed") func test_kick_spread() -> void: print("\n-- test_kick_spread --") var max_deg := 10.0 # default kick_spread var forward := Vector3.FORWARD for _i: int in 100: var spread := deg_to_rad(randf_range(-max_deg, max_deg)) var kick_dir := forward.rotated(Vector3.UP, spread) var angle_deg := rad_to_deg(forward.angle_to(kick_dir)) _assert_in_range(angle_deg, 0.0, max_deg + 0.01, "kick direction stays within spread cone") func test_gait_phase_crossing() -> void: print("\n-- test_gait_phase_crossing --") # Wrap-around: phase 0.95 should be crossed when advancing from 0.9 → 0.1. _assert_true(_gait_phase_crossed(0.9, 0.1, 0.95), "wrap-around: 0.95 crossed from 0.9 to 0.1") # Normal advance: phase 0.05 is crossed from 0.0 → 0.1. _assert_true(_gait_phase_crossed(0.0, 0.1, 0.05), "normal: 0.05 crossed from 0.0 to 0.1") # Not crossed: target outside window. _assert_true(not _gait_phase_crossed(0.0, 0.1, 0.15), "not crossed: 0.15 outside window 0.0–0.1") _assert_true(not _gait_phase_crossed(0.9, 0.1, 0.5), "not crossed: 0.5 not in wrap range 0.9–>0.1") # All four leg phase offsets must be in [0, 1) and distinct. var phases := [0.0, 0.2, 0.5, 0.7] # LEG_PHASES values from bull_legs.gd var seen := {} for p: float in phases: _assert_in_range(p, 0.0, 0.9999, "leg phase in [0, 1)") _assert_true(not seen.has(p), "leg phase %.2f is unique" % p) seen[p] = true func _gait_phase_crossed(prev: float, curr: float, target: float) -> bool: if curr >= prev: return target >= prev and target < curr else: return target >= prev or target < curr func test_tail_verlet_constraint() -> void: print("\n-- test_tail_verlet_constraint --") var target_len := 0.3 # Constraint should snap an over-extended segment to exactly target_len. var a := Vector3.ZERO var b := Vector3(5.0, 0.0, 0.0) var dir := b - a var result: Vector3 = a + dir * (target_len / dir.length()) _assert_in_range(result.distance_to(a), target_len - 0.001, target_len + 0.001, "Verlet constraint enforces exact segment length") # Near-zero distance: code takes else branch — fallback should also be target_len. var b_near := Vector3(0.00005, 0.0, 0.0) var dir_near := b_near - a var fallback: Vector3 if dir_near.length() > 0.0001: fallback = a + dir_near * (target_len / dir_near.length()) else: fallback = a + Vector3(0.0, -target_len, 0.0) _assert_in_range(fallback.distance_to(a), target_len - 0.001, target_len + 0.001, "Verlet fallback (near-zero) preserves segment length") # Damping formula: velocity carries ~90-100% of a normal segment per step. var damping_factor := 1.0 - clampf(0.08, 0.0, 0.99) # default tail_damping _assert_in_range(damping_factor, 0.85, 1.0, "tail damping factor is reasonable") # ── Shared helpers ──────────────────────────────────────────────────────────── func _assert_in_range(val: float, lo: float, hi: float, desc: String) -> void: _assert_true(val >= lo and val <= hi, "%s (got %.4f, expected [%.4f, %.4f])" % [desc, val, lo, hi]) # ── Debug params tests ─────────────────────────────────────────────────────── func test_debug_params_defaults() -> void: print("\n-- test_debug_params_defaults --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return _assert_in_range(dp.f("mat_walk_speed"), 0.5, 10.0, "mat_walk_speed default in valid range") _assert_in_range(dp.f("mat_wander_radius"), 5.0, 50.0, "mat_wander_radius default in valid range") _assert_in_range(dp.f("mat_spawn_count"), 1.0, 30.0, "mat_spawn_count default in valid range") _assert_in_range(dp.f("mat_hit_threshold"), 1.0, 30.0, "mat_hit_threshold default in valid range") _assert_in_range(dp.f("mat_ragdoll_impulse"), 1.0, 50.0, "mat_ragdoll_impulse default in valid range") _assert_in_range(dp.f("mat_idle_min"), 0.0, 5.0, "mat_idle_min default in valid range") _assert_in_range(dp.f("mat_idle_max"), 0.5, 10.0, "mat_idle_max default in valid range") _assert_true(dp.f("mat_idle_min") <= dp.f("mat_idle_max"), "mat_idle_min <= mat_idle_max") func test_debug_params_clamp_values() -> void: print("\n-- test_debug_params_clamp_values --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return var all_params: Dictionary = dp.get_all() for key: String in all_params: var p: Dictionary = all_params[key] if p["type"] == TYPE_FLOAT: var val: float = p["value"] as float _assert_in_range(val, p["min"] as float, p["max"] as float, "param '%s' value within [min, max]" % key) func test_debug_params_save_load_cycle() -> void: print("\n-- test_debug_params_save_load_cycle --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return var original: float = dp.f("mat_walk_speed") dp.set_value("mat_walk_speed", 7.77) _assert_eq(dp.f("mat_walk_speed"), 7.77, "set_value updates immediately") dp.set_value("mat_walk_speed", original) _assert_eq(dp.f("mat_walk_speed"), original, "restore original value") # The hot-read cache (f/b) and the metadata mirror (get_all()[k].value) must never # diverge across set / reset / reset_all. func test_debug_params_flat_cache() -> void: print("\n-- test_debug_params_flat_cache --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return dp.set_value("mat_walk_speed", 6.25) _assert_eq(dp.f("mat_walk_speed"), 6.25, "flat cache reflects set_value") _assert_eq(dp.get_all()["mat_walk_speed"]["value"], 6.25, "metadata mirror agrees with cache") _assert_true(dp.is_modified("mat_walk_speed"), "is_modified true after set") dp.reset("mat_walk_speed") _assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"], "reset(key) restores default in cache") _assert_true(not dp.is_modified("mat_walk_speed"), "is_modified false after reset") # The debug console verbs (toggle / reset / set / diff / clear / help) must # mutate DP correctly and never crash on the read-only reporting commands. func test_debug_console_commands() -> void: print("\n-- test_debug_console_commands --") var dp: Node = root.get_node_or_null("/root/DP") var menu: Node = root.get_node_or_null("/root/Console") if dp == null or menu == null: _assert_true(false, "DP + Console autoloads should exist") return dp.set_value("show_bones", false) menu._execute("toggle show_bones") _assert_true(dp.b("show_bones"), "toggle flips bool on") menu._execute("toggle show_bones") _assert_true(not dp.b("show_bones"), "toggle flips bool off") dp.set_value("mat_walk_speed", 4.0) menu._execute("toggle mat_walk_speed") _assert_eq(dp.f("mat_walk_speed"), 4.0, "toggle refuses a float param") menu._execute("mat_walk_speed 6") _assert_eq(dp.f("mat_walk_speed"), 6.0, "console 'key value' sets the param") menu._execute("reset mat_walk_speed") _assert_eq(dp.f("mat_walk_speed"), dp.get_all()["mat_walk_speed"]["default"], "console 'reset ' restores default") # Read-only reporting verbs must not raise. menu._execute("diff") menu._execute("clear") menu._execute("help") menu._execute("nonsense_key_xyz") _assert_true(true, "diff / clear / help / unknown key survive") func test_matador_spawn_positions() -> void: print("\n-- test_matador_spawn_positions --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return var radius: float = dp.f("mat_wander_radius") # Simulate the spawn logic from matador_spawn.gd for i: int in 50: var angle := randf() * TAU var dist := randf_range(6.0, radius) var pos := Vector3(cos(angle) * dist, 1.0, sin(angle) * dist) var horiz_dist := Vector2(pos.x, pos.z).length() _assert_in_range(horiz_dist, 5.0, radius + 1.0, "spawn %d horizontal dist within bounds" % i) _assert_eq(pos.y, 1.0, "spawn %d Y position is 1.0" % i) func test_matador_wander_target_in_bounds() -> void: print("\n-- test_matador_wander_target_in_bounds --") var dp: Node = root.get_node_or_null("/root/DP") if dp == null: _assert_true(false, "DP autoload should exist") return var radius: float = dp.f("mat_wander_radius") # Simulate _pick_wander_target logic from matador.gd for i: int in 100: var angle := randf() * TAU var dist := randf_range(2.0, radius) var target := Vector3(cos(angle) * dist, 0.0, sin(angle) * dist) var horiz_dist := Vector2(target.x, target.z).length() _assert_in_range(horiz_dist, 1.0, radius + 1.0, "wander target %d within radius" % i) func test_matador_state_transitions() -> void: print("\n-- test_matador_state_transitions --") # Verify that the State enum values match expected constants # Load the matador scene to check its script var matador_scene := load("res://Matador.tscn") if matador_scene == null: _assert_true(false, "Matador.tscn should load") return _assert_true(true, "Matador.tscn loads successfully") var matador: Node = matador_scene.instantiate() _assert_true(matador is CharacterBody3D, "Matador root is CharacterBody3D") _assert_true(matador.has_method("_physics_process"), "Matador has _physics_process") _assert_true(matador.has_method("_ready"), "Matador has _ready") # Check initial state _assert_eq(matador._state, 0, "initial state is WANDER (0)") matador.free()