196 lines
6.8 KiB
GDScript3
196 lines
6.8 KiB
GDScript3
|
|
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
|