extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_fleet_speed_bounded.gd # A reported movement bug: rotating the fleet and spamming a direction let # vessels fall out of sync and fly at wildly high speeds - "speed of an # individual ship is not bounded". # # The cause was in Fleet.stop(), which compensated for Ship.velocity reading # zero during cruise by doing `ship.velocity += ship.fleet_velocity`. That is # an accumulator: each release added a whole cruise velocity, the next steer # refilled fleet_velocity, and tapping faster than `acceleration` could bleed # it off stacked them without limit. Ships desynced because each bled off at # a rate set by its own distance to its slot. # # Covers: # A. no vessel ever exceeds its own top speed, however hard the direction # is spammed - the property that was actually violated # B. spamming cannot outrun an honestly-held stick: the fleet covers no # more ground than cruising for the same time would # C. the same holds while the formation is being pivoted, which is the # case the report named # D. ships stay in sync with each other rather than fanning out var game_state: Node var main: Node var fleet: Node var stage: int = 0 var elapsed: float = 0.0 var fastest_seen: float = 0.0 var worst_spread: float = 0.0 var start_centroid: Vector2 = Vector2.ZERO var spam_toggle: bool = false var pivot_step: int = 0 var honest_distance: float = 0.0 const SPAM_TIME: float = 3.0 # Every ship in the test fleet is an interceptor, so one limit covers them # all. Two percent of slack absorbs the single-tick overshoot inherent in # integrating at a finite step; the bug being caught overshot by multiples, # not by percents. const SPEED_TOLERANCE: float = 1.02 func _initialize() -> void: game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() game_state.transporters.clear() game_state.stored_vessels.clear() game_state.reset_progression() main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") func _process(delta: float) -> bool: elapsed += delta match stage: 0: if elapsed < 0.2: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) fleet.spawn_ships(4, Vector2.ZERO) # --- reference: what holding the stick honestly achieves -------- start_centroid = fleet.get_centroid() fleet.move_in_direction(Vector2(1.0, 0.0)) elapsed = 0.0 stage = 1 return false 1: if elapsed < SPAM_TIME: _watch_speeds() return false honest_distance = fleet.get_centroid().distance_to(start_centroid) print("held steady for %.1fs: travelled %.1f, fastest vessel %.1f" \ % [SPAM_TIME, honest_distance, fastest_seen]) if not _check_speed_bound("while simply holding a direction"): return true fleet.stop() fastest_seen = 0.0 elapsed = 0.0 stage = 2 return false 2: # Let the coast-to-rest finish so the spam leg starts from the # same standing start the honest leg did. if elapsed < 1.0: return false start_centroid = fleet.get_centroid() elapsed = 0.0 stage = 3 return false 3: # --- A + B. tap the same direction as fast as frames allow ------ spam_toggle = not spam_toggle if spam_toggle: fleet.move_in_direction(Vector2(1.0, 0.0)) else: fleet.stop() _watch_speeds() if elapsed < SPAM_TIME: return false var spam_distance: float = fleet.get_centroid().distance_to(start_centroid) print("spammed for %.1fs: travelled %.1f, fastest vessel %.1f" \ % [SPAM_TIME, spam_distance, fastest_seen]) if not _check_speed_bound("while spamming a direction"): return true # Tapping releases the stick half the time, so it should cover # LESS ground than holding it - certainly not more. if spam_distance > honest_distance * 1.1: return _fail_now("spamming a direction covered %.1f against %.1f for simply holding it - tapping is faster than flying" \ % [spam_distance, honest_distance]) fleet.stop() fastest_seen = 0.0 elapsed = 0.0 stage = 4 return false 4: if elapsed < 1.0: return false start_centroid = fleet.get_centroid() elapsed = 0.0 stage = 5 return false 5: # --- C + D. spam the direction WHILE pivoting the formation ----- # The reported case. A pivot re-seats every slot at once, so this # is where ships are most able to fall out of step with one # another. spam_toggle = not spam_toggle if spam_toggle: fleet.move_in_direction(Vector2(1.0, 0.0)) else: fleet.stop() pivot_step += 1 _pivot_formation(PI / 2.0 if pivot_step % 2 == 0 else -PI / 2.0) _watch_speeds() if elapsed < SPAM_TIME: return false var pivot_distance: float = fleet.get_centroid().distance_to(start_centroid) print("spammed with pivots for %.1fs: travelled %.1f, fastest vessel %.1f, worst pair spread %.1f" \ % [SPAM_TIME, pivot_distance, fastest_seen, worst_spread]) if not _check_speed_bound("while spamming a direction and pivoting"): return true if pivot_distance > honest_distance * 1.1: return _fail_now("spamming with pivots covered %.1f against %.1f for simply holding a direction" \ % [pivot_distance, honest_distance]) # Ships must remain a fleet. The formation is a wedge a couple of # hundred units across; a desync flings one vessel far beyond it. if worst_spread > 900.0: return _fail_now("vessels fell %.1f apart - the fleet came apart rather than moving together" % worst_spread) print("no vessel outran its own top speed, and none broke formation") print("TEST_FLEET_SPEED_BOUNDED: PASS") quit(0) return true return false func _watch_speeds() -> void: var boost: float = game_state.fleet_speed_multiplier for ship in fleet.ships: if not is_instance_valid(ship): continue fastest_seen = maxf(fastest_seen, ship.velocity.length() / (ship.max_speed * boost)) for other in fleet.ships: if is_instance_valid(other): worst_spread = maxf(worst_spread, ship.position.distance_to(other.position)) # fastest_seen is carried as a RATIO of each vessel's own limit, so a mixed # fleet with different top speeds would be judged per vessel rather than # against a single global number. func _check_speed_bound(context: String) -> bool: if fastest_seen > SPEED_TOLERANCE: _fail_now("a vessel reached %.2fx its own top speed %s - individual speed is not bounded" \ % [fastest_seen, context]) return false return true func _pivot_formation(angle: float) -> void: var rotated: Array = [] for offset in game_state.formation: rotated.append(Vector2(offset).rotated(angle)) game_state.formation = rotated fleet.apply_formation() func _fail_now(message: String) -> bool: print("TEST_FLEET_SPEED_BOUNDED: FAIL (%s)" % message) quit(1) return true