340 lines
13 KiB
GDScript
340 lines
13 KiB
GDScript
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 <name> / 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 <name>' 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()
|