diff --git a/Fleet.gd b/Fleet.gd index ef17702..dae7f48 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -238,33 +238,25 @@ func stop() -> void: # since that lands inside arrive_radius immediately). fleet_target = get_formation_origin() for ship in ships: - # A ship's own `velocity` is NOT how fast it is really travelling - # while the fleet cruises. Ship snaps to its slot and reports zero - # the moment it is within arrive_radius of it, even though the - # anchor is dragging that slot forward at cruise_speed underneath - # it. Its true motion is its catch-up velocity PLUS the fleet's, - # which it already carries as `fleet_velocity` (zero for a - # point-to-point move_to, where `velocity` alone is the whole - # story). + # A ship's `velocity` is already its true motion, including the + # fleet's travel, because Ship feeds the slot velocity forward into + # its own steering rather than carrying it in a separate field. So + # stopping is only a matter of telling it the slot has halted and + # aiming it at the point its own braking curve reaches. # - # Braking from `velocity` alone therefore stopped the fleet dead - # on any frame where the ships happened to be seated - and how - # often that happens depends on the frame rate, because the anchor - # advances cruise_speed * delta per frame. At ~25fps it steps far - # enough that ships always lag and always report motion, which is - # the only reason releasing the stick looked right. - var motion: Vector2 = ship.velocity + ship.fleet_velocity - # HAND THE FLEET'S MOTION TO THE SHIP. Until now the anchor was - # carrying it along and its own `velocity` was ~0, so aiming it at a - # braking point ahead made it start from REST: the fleet stopped - # dead, then accelerated into its own stopping distance and halted a - # second time. That is the "pause, then it moves again" stutter. - # Giving it the velocity it was actually travelling at makes the - # stop one continuous decelerating coast. - ship.velocity = motion - # Context that is no longer true: the fleet is not cruising any - # more, so leaving it set would have Ship face a heading it is not - # travelling on. + # This used to do `ship.velocity += ship.fleet_velocity` here, to + # compensate for `velocity` reading zero while the ship was in fact + # cruising (Ship snapped to its slot every tick and zeroed it). That + # compensation was an unbounded ACCUMULATOR: every release added a + # whole cruise velocity, the next steer refilled fleet_velocity, and + # tapping a direction repeatedly stacked them up faster than + # `acceleration` could bleed them off - so a vessel could be flung + # along at many times its own top speed, and ships fell out of sync + # because each bled off at a rate set by its own distance to its + # slot. Ship now bounds its total speed every tick as well, so + # neither half of that can come back. + var motion: Vector2 = ship.velocity + # The fleet is not cruising any more, so the slot is standing still. ship.fleet_velocity = Vector2.ZERO var speed: float = motion.length() if speed <= 0.001: diff --git a/Ship.gd b/Ship.gd index 074c6b4..08baa0c 100644 --- a/Ship.gd +++ b/Ship.gd @@ -13,7 +13,15 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") var definition: Resource var glow_color: Color = Color(0.25, 0.85, 1.0) var core_color: Color = Color.WHITE +# The ship's TRUE world velocity - what `position` is actually integrated +# from, and the single source of truth for how fast this vessel is moving. +# Nothing else may contribute motion; see the note in _physics_process. var velocity: Vector2 = Vector2.ZERO +# The velocity of this ship's formation SLOT (the fleet anchor's own travel). +# Fed forward into the steering below rather than added on top of it: matching +# the slot's speed is most of the job of holding formation, so supplying it +# directly leaves `velocity` free to be a small correction and lets the ship +# sit in its slot with no steady-state lag and no teleporting. var fleet_velocity: Vector2 = Vector2.ZERO var move_target: Vector2 @@ -109,22 +117,47 @@ func _physics_process(delta: float) -> void: # "snaps onto its slot" formation tightness at any boost level that # 1.0x already has. var boost: float = GameState.fleet_speed_multiplier + var effective_acceleration: float = acceleration * boost var to_target: Vector2 = move_target - position var distance: float = to_target.length() - if distance <= arrive_radius * boost: + + # Snap only when the slot is STANDING STILL. A stationary target is the + # one case where converging asymptotically would leave a permanent + # sub-pixel residue, and settling exactly is what "the fleet has arrived" + # means everywhere else in the game. While the slot is moving the ship + # steers continuously instead - a snap there would be a teleport, which + # is what used to make `velocity` read zero for a ship that was in fact + # travelling at cruise speed, and that lie is what the whole + # stop()/fleet_velocity bookkeeping tangle existed to paper over. + if fleet_velocity == Vector2.ZERO and distance <= arrive_radius * boost: position = move_target velocity = Vector2.ZERO else: - var effective_acceleration: float = acceleration * boost - var braking_speed: float = sqrt(2.0 * effective_acceleration * distance) - var desired_speed: float = min(max_speed * boost, braking_speed) - var desired_velocity: Vector2 = to_target.normalized() * desired_speed + # Feed-forward plus correction. The ship aims to travel at its slot's + # velocity, plus whatever extra closes the gap to the slot - so + # holding formation costs no error, and `velocity` stays the ship's + # real motion rather than a residual that has to be added to + # something else to mean anything. + var correction: Vector2 = Vector2.ZERO + if distance > 0.001: + var braking_speed: float = sqrt(2.0 * effective_acceleration * distance) + correction = to_target.normalized() * min(max_speed * boost, braking_speed) + var desired_velocity: Vector2 = fleet_velocity + correction + # HARD BOUND on how fast any single vessel may travel, applied to the + # total and re-applied every tick. Feed-forward plus correction can + # ask for more than a vessel can actually fly, and without this the + # excess was free speed: a ship that had been handed a large velocity + # kept it, decaying only at `acceleration`. Clamping the REQUEST is + # not enough - the clamp has to sit on the integrated result, so no + # sequence of commands can leave a vessel travelling faster than its + # own top speed. + desired_velocity = desired_velocity.limit_length(max_speed * boost) velocity = velocity.move_toward(desired_velocity, effective_acceleration * delta) + velocity = velocity.limit_length(max_speed * boost) position += velocity * delta - var motion: Vector2 = velocity + fleet_velocity - if motion.length() > 8.0: - var target_angle: float = motion.angle() + PI / 2.0 + if velocity.length() > 8.0: + var target_angle: float = velocity.angle() + PI / 2.0 rotation = lerp_angle(rotation, target_angle, rotation_speed * delta) diff --git a/tests/test_fleet_speed_bounded.gd b/tests/test_fleet_speed_bounded.gd new file mode 100644 index 0000000..3b69888 --- /dev/null +++ b/tests/test_fleet_speed_bounded.gd @@ -0,0 +1,196 @@ +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://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 diff --git a/tests/test_fleet_speed_bounded.gd.uid b/tests/test_fleet_speed_bounded.gd.uid new file mode 100644 index 0000000..43f0546 --- /dev/null +++ b/tests/test_fleet_speed_bounded.gd.uid @@ -0,0 +1 @@ +uid://bkcs1gl5h616f diff --git a/tests/test_space_time_distorder.gd b/tests/test_space_time_distorder.gd index 059321c..ca8d4cd 100644 --- a/tests/test_space_time_distorder.gd +++ b/tests/test_space_time_distorder.gd @@ -58,6 +58,17 @@ func _process(delta: float) -> bool: return false main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) + # Silencing the spawners is not enough: whatever Main.tscn already + # placed keeps moving under its own _physics_process, and this test + # flies the fleet several thousand units at up to 3x boost. Losing + # the distorder ship to a rock mid-run resets the multiplier - + # CORRECTLY, that is what _sync_composition_state is for - and the + # test then failed three stages later claiming the multiplier had + # not survived a scene swap. Same clean-slate idiom as + # test_ship_lab.gd. + for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): + if is_instance_valid(hazard): + hazard.queue_free() # --- A. absent, then appears ------------------------------------- if fader.visible: @@ -192,6 +203,13 @@ func _process(delta: float) -> bool: # scenario here since this fleet keeps its distorder ship the # whole way across the swap, via GameState.surviving_vessel_types # exactly like every other vessel type). + # Check the premise before relying on it. Everything below is about + # the multiplier surviving a scene swap, which is only meaningful + # while the fleet still crews a distorder vessel - so if the fleet + # lost one on the way here, say THAT rather than reporting a reset + # multiplier as if the swap were at fault. + if not fleet.has_capability("distorder"): + return _fail_now("the fleet lost its distorder vessel before the scene swap, so the multiplier reset is expected rather than a bug") game_state.surviving_ship_count = fleet.ships.size() game_state.surviving_vessel_types = fleet.vessel_types() game_state.planet_id = "test_space_time_distorder"