extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_fleet_agility.gd # The fleet handled with too much inertia: flipping the stick left while # cruising right took most of a second to take effect, and letting go coasted # the ships several lengths further. # # The cause was every vessel's acceleration sitting at roughly its own top # speed - a time constant of about a second to change velocity at all, while # the formation anchor the ships chase turns instantly. Acceleration is now # ~3x top speed, measured rather than guessed: past 3x the reversal time stops # improving, because max_speed becomes the limit instead of acceleration. # # Feel is not directly assertable, but the three things that PRODUCE it are, # so this measures them: # A. how long after the stick reverses before a ship is actually travelling # the new way # B. how far past the turn it drifts first # C. how far it coasts after the stick is released # # Plus D: the same measurement at the OLD acceleration must BREACH these # bounds. Without that, quietly restoring the sluggish handling would still # pass, and so would bounds so loose they mean nothing. # # The bounds are roughly double what the tuned values measure. This suite runs # several Godot processes at once on a phone, and a physics-timing measurement # under that load strays (see the note in test_space_time_distorder.gd); real # regression here means going back towards a second, which is far outside this # margin. const MAX_REVERSE_TIME: float = 0.35 const MAX_OVERSHOOT: float = 45.0 const MAX_STOP_COAST: float = 60.0 # What the acceleration was before, as a fraction of what it is now. const OLD_ACCELERATION_FRACTION: float = 1.0 / 3.0 var main: Node var fleet: Node var elapsed: float = 0.0 var stage: int = 0 var flip_x: float = 0.0 var reverse_time: float = -1.0 var overshoot: float = 0.0 var stop_x: float = 0.0 # Second pass re-runs the identical measurement with the old acceleration. var measuring_old: bool = false var tuned: Dictionary = {} func _initialize() -> void: var game_state: Node = root.get_node("GameState") game_state.reset_progression() main = load("res://world/Main.tscn").instantiate() root.add_child(main) current_scene = main fleet = main.get_node("Fleet") func _process(delta: float) -> bool: elapsed += delta match stage: 0: if elapsed < 0.3: return false main.get_node("AsteroidField").set_process(false) main.get_node("EnemySpawner").set_process(false) # A rock or an enemy destroying the ship being measured would # read as a handling failure. Same clean-slate idiom as # test_space_time_distorder.gd. for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): if is_instance_valid(hazard): hazard.queue_free() _begin_pass() return false 1: # Reach steady state travelling right before disturbing anything. if elapsed < 2.0: return false if fleet.ships.is_empty(): return _fail("the fleet lost its ships before anything could be measured") flip_x = fleet.ships[0].position.x fleet.move_in_direction(Vector2(-1.0, 0.0)) stage = 2 elapsed = 0.0 return false 2: var ship: Node2D = fleet.ships[0] overshoot = maxf(overshoot, ship.position.x - flip_x) if reverse_time < 0.0 and ship.velocity.x < 0.0: reverse_time = elapsed if elapsed < 2.0: return false if reverse_time < 0.0: return _fail("the fleet never reversed at all within 2s of the stick flipping") # Settle going one way again, so the coast below is measured from # a steady cruise rather than mid-catch-up. fleet.move_in_direction(Vector2(1.0, 0.0)) stage = 3 elapsed = 0.0 return false 3: if elapsed < 2.0: return false stop_x = fleet.ships[0].position.x fleet.stop() stage = 4 elapsed = 0.0 return false 4: if elapsed < 2.5: return false var result: Dictionary = { "reverse_time": reverse_time, "overshoot": overshoot, "coast": fleet.ships[0].position.x - stop_x, } if not measuring_old: tuned = result if not _check_tuned(result): return true # --- D: the same measurement, sluggish again ---------------- measuring_old = true _begin_pass() return false return _check_old_breaches(result) return false func _begin_pass() -> void: reverse_time = -1.0 overshoot = 0.0 for ship in fleet.ships: # apply_vessel_stats() has already put the definition's value on the # ship, so the old handling is reproduced by scaling that field # directly - no need to edit a resource mid-test. if measuring_old: ship.acceleration *= OLD_ACCELERATION_FRACTION ship.position = Vector2.ZERO ship.velocity = Vector2.ZERO fleet.move_to(Vector2.ZERO) fleet.move_in_direction(Vector2(1.0, 0.0)) stage = 1 elapsed = 0.0 func _check_tuned(r: Dictionary) -> bool: print("tuned (acceleration %.0f): reversed in %.3fs, overshot %.1f, coasted %.1f" \ % [fleet.ships[0].acceleration, r.reverse_time, r.overshoot, r.coast]) if r.reverse_time > MAX_REVERSE_TIME: return _fail("the fleet took %.3fs to obey a reversed stick (allowed %.2f)" \ % [r.reverse_time, MAX_REVERSE_TIME]) if r.overshoot > MAX_OVERSHOOT: return _fail("the fleet drifted %.1f units past the turn (allowed %.1f)" \ % [r.overshoot, MAX_OVERSHOOT]) if r.coast > MAX_STOP_COAST: return _fail("the fleet coasted %.1f units after the stick was released (allowed %.1f)" \ % [r.coast, MAX_STOP_COAST]) return true func _check_old_breaches(r: Dictionary) -> bool: print("old (acceleration %.0f): reversed in %.3fs, overshot %.1f, coasted %.1f" \ % [fleet.ships[0].acceleration, r.reverse_time, r.overshoot, r.coast]) var breaches: int = 0 if r.reverse_time > MAX_REVERSE_TIME: breaches += 1 if r.overshoot > MAX_OVERSHOOT: breaches += 1 if r.coast > MAX_STOP_COAST: breaches += 1 if breaches == 0: return _fail("the OLD, sluggish acceleration passed every bound too (reversed %.3fs, overshot %.1f, coasted %.1f) - these thresholds do not discriminate and would not catch a regression" \ % [r.reverse_time, r.overshoot, r.coast]) print("bounds discriminate: the old acceleration breaches %d of 3" % breaches) print("TEST_FLEET_AGILITY: PASS") quit(0) return true func _fail(message: String) -> bool: print("TEST_FLEET_AGILITY: FAIL (%s)" % message) quit(1) return true