Files
Bullosseum/tests/logic_test.gd
T

340 lines
13 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.00.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()