spaceship/tests/test_animation_continuity.gd
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

120 lines
4 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_animation_continuity.gd
# Regression test for the mothership's animation jumping.
#
# The reactor pulse and the orbiting satellites used to be driven from
# anim_time, which wraps at CYCLE (3.6s) because the CRAFTER's assembly
# sequence is expressed as a fraction of it. Neither of them completes a
# whole number of periods in 3.6s, so every wrap made them leap - the
# satellites snapped back 66 degrees, once every cycle, forever.
#
# Verifies, by stepping a mothership across two full wraps of the assembly
# clock in small frames:
# 1. the satellites keep advancing - no frame turns them backwards
# 2. no frame moves them much more than a steady frame would
# 3. the core pulse never jumps by more than a steady frame's worth
# 4. the assembly clock really does wrap during the test, so the test is
# actually exercising the boundary that used to break
const FRAME: float = 1.0 / 60.0
const FRAMES: int = 500 # 8.3s, comfortably past two wraps of 3.6s
var scene: Node
var game_state: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
var ship: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
ship = b.visual
if ship == null:
print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)")
quit(1)
return true
# Drive it by hand at a fixed frame time, so the measurement is
# not at the mercy of the headless tick rate.
ship.set_process(false)
var expected_orbit_step: float = FRAME * ship.ORBIT_SPEED
var prev_orbit: float = ship.orbit_angle()
var prev_pulse: float = ship.core_pulse()
var wrapped: bool = false
var prev_anim: float = ship.anim_time
var worst_orbit_step: float = 0.0
var min_orbit_step: float = 1e9
var worst_pulse_step: float = 0.0
# A steady frame of the pulse: derivative of a unit sine is at
# most 1, so 0.5 * speed * dt bounds one frame's change.
var expected_pulse_step: float = 0.5 * ship.PULSE_SPEED * FRAME
for i in range(FRAMES):
ship._process(FRAME)
if ship.anim_time < prev_anim:
wrapped = true
prev_anim = ship.anim_time
var orbit_step: float = ship.orbit_angle() - prev_orbit
worst_orbit_step = maxf(worst_orbit_step, orbit_step)
min_orbit_step = minf(min_orbit_step, orbit_step)
prev_orbit = ship.orbit_angle()
worst_pulse_step = maxf(worst_pulse_step, absf(ship.core_pulse() - prev_pulse))
prev_pulse = ship.core_pulse()
print("assembly clock wrapped during the run: %s" % wrapped)
print("orbit step per frame: min %.6f max %.6f rad (steady %.6f)" % [
min_orbit_step, worst_orbit_step, expected_orbit_step
])
print("worst pulse step: %.6f (steady bound %.6f)" % [
worst_pulse_step, expected_pulse_step
])
if not wrapped:
print("TEST_ANIMATION_CONTINUITY: FAIL (clock never wrapped - test proves nothing)")
quit(1)
return true
if min_orbit_step <= 0.0:
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites went backwards)")
quit(1)
return true
if worst_orbit_step > expected_orbit_step * 1.05:
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites jumped forwards)")
quit(1)
return true
if worst_pulse_step > expected_pulse_step * 1.05:
print("TEST_ANIMATION_CONTINUITY: FAIL (reactor pulse jumped)")
quit(1)
return true
print("TEST_ANIMATION_CONTINUITY: PASS")
quit(0)
return true
return false