spaceship/tests/test_planet_atmosphere_transition.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

288 lines
12 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_atmosphere_transition.gd
# Verifies the full enter/exit atmosphere flow now that it's a genuine
# scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place
# overlay: standing on a planet shows the prompt, pressing it fades out
# and swaps to PlanetSurface.tscn with the SAME surviving ship count,
# where no asteroid ever exists and no space-side EnemySpawner/AsteroidField
# node carries over (structurally absent - PlanetSurface.tscn has no such
# nodes at all), and the fleet is still fully flyable. Landed enemies MAY
# still exist post-#43 (PlanetSetupHost places authored ones to guard
# capture points), which is a different thing from the space-side spawner
# leaking across the swap - see the structural check below. Exiting fades
# back to Main.tscn with the fleet restored at the world position it left
# from.
var main_scene: Node
var fleet: Node
var planet_field: Node
var landing_controller: Node
var asteroid_field_ref: Node
var planet_name_label: Label
var enter_button: IconButton
var elapsed: float = 0.0
var stage: int = 0
var target_chunk: Vector2i
var position_before_leaving: Vector2
var cached_fade_duration: float = 0.4
var returned_ship_count: int = -1
var returned_vessel_types: PackedStringArray = PackedStringArray()
var game_state: Node
var expected_vessel_types: PackedStringArray = PackedStringArray([
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD,
GameState.VESSEL_INTERCEPTOR,
])
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
game_state = root.get_node("GameState")
var packed: PackedScene = load("res://world/Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
# change_scene_to_file() operates on current_scene - the real game
# gets this set automatically via project.godot's run/main_scene on
# boot, but a hand-built headless test harness has to do it itself.
current_scene = main_scene
fleet = main_scene.get_node("Fleet")
# A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE
# (3) slots. This test needs a crewed fleet, so it asks for one outright
# rather than inheriting whatever a fresh game happens to open with -
# which is exactly what changed underneath it.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
planet_field = main_scene.get_node("PlanetField")
landing_controller = main_scene.get_node("LandingController")
asteroid_field_ref = main_scene.get_node("AsteroidField")
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
enter_button = main_scene.get_node("UI/BuildActionButton")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# target_chunk is guaranteed ASTEROID_FIELD biome (a
# prerequisite for having a planet), so regular drifting
# asteroids/enemies could also spawn nearby and interfere with
# a ship during this focused scenario - isolate it, same as
# test_enemy_combat.gd does for its own scenario.
#
# This has to happen here, not in _initialize(): a node's
# set_process(false) call made before it has fully finished
# entering the tree doesn't reliably stick - the engine's own
# (deferred) tree-entry finalization, which runs strictly
# after _ready(), can silently reset it back to enabled. Here,
# at the point _ready() is already confirmed complete
# (fleet.ships is reliably populated by now), it's safe.
asteroid_field_ref.set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
# Disabling the spawners stops NEW asteroids, but any already
# placed keep drifting under their own _process and can still
# collide with a ship mid-test. Clear the existing ones too.
for a in get_nodes_in_group("asteroids"):
a.queue_free()
# A mixed fleet proves the handoff preserves composition rather
# than merely reconstructing the same number of interceptors.
fleet.spawn_ships(expected_vessel_types.size(), fleet.get_centroid(),
expected_vessel_types)
# Get the fleet into the target chunk's vicinity first so
# PlanetField's spawn radius covers it - the planet's own exact
# position within the chunk is randomized, so this is only
# enough to trigger spawning, not yet close enough to land on.
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = vicinity_pos
fleet.move_to(vicinity_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.5:
return false
var prompt_ok: bool = enter_button.visible and planet_name_label.visible and planet_name_label.text != ""
print("prompt_ok=%s ship_count=%d name_text=%s" % [prompt_ok, fleet.ships.size(), planet_name_label.text])
if not prompt_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (prompt never appeared)")
quit(1)
return true
# Let the fleet finish settling onto the planet before recording
# where it left from. Capturing mid-settle made this test
# intermittently fail later: the baseline was a still-moving
# centroid, while the return always places the fleet exactly on
# the planet, so the two could differ by more than the tolerance.
for ship in fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
position_before_leaving = fleet.get_centroid()
cached_fade_duration = landing_controller.fade_duration
_press(enter_button)
elapsed = 0.0
stage = 3
return false
3:
# Fade-out + change_scene_to_file + fade-in. landing_controller
# gets freed once the scene swap completes, so the wait
# duration must be cached beforehand, not read live each frame.
if elapsed < cached_fade_duration + 0.3:
return false
var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface"
print("on_surface=%s current_scene=%s" % [on_surface, current_scene])
if not on_surface:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not swap to PlanetSurface)")
quit(1)
return true
var surface_fleet: Node = current_scene.get_node("Fleet")
var ship_count_ok: bool = surface_fleet.ships.size() == 5
var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
# Landed enemies from an authored PlanetSetup are expected
# post-#43 (they guard capture points) - what this test actually
# owes is that the SPACE-SIDE spawner nodes never carry across,
# checked structurally rather than by assuming zero enemies.
var no_space_spawners: bool = current_scene.get_node_or_null("EnemySpawner") == null \
and current_scene.get_node_or_null("AsteroidField") == null
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_space_spawners=%s" % [
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_space_spawners
])
if not (ship_count_ok and composition_ok and no_asteroids and no_space_spawners):
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
quit(1)
return true
# Confirm the fleet is genuinely flyable on the surface.
var start_pos: Vector2 = surface_fleet.get_centroid()
surface_fleet.move_to(start_pos + Vector2(400, 0))
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 2.5:
return false
var surface_fleet: Node = current_scene.get_node("Fleet")
var moved_ok: bool = surface_fleet.get_centroid().x > 300.0
print("moved_ok=%s centroid=%s" % [moved_ok, surface_fleet.get_centroid()])
if not moved_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (fleet could not move on the surface)")
quit(1)
return true
var exit_button: IconButton = current_scene.get_node("UI/ExitButton")
var surface_controller: Node = current_scene.get_node("SurfaceController")
cached_fade_duration = surface_controller.fade_duration
_press(exit_button)
elapsed = 0.0
stage = 5
return false
5:
# Poll for the swap rather than waiting a fixed duration, so the
# ship count can be captured on the very FIRST frame back. The
# fresh Main.tscn populates asteroids during its own _ready(),
# and they can destroy a ship within a few frames - waiting out
# the fade first meant sometimes measuring a fleet that had
# already lost one to a collision, which says nothing about the
# transition being tested here.
var back_in_space: bool = current_scene != null and current_scene.name == "Main"
if not back_in_space:
if elapsed > cached_fade_duration + 3.0:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not return to Main)")
quit(1)
return true
return false
if returned_ship_count == -1:
print("back_in_space=%s current_scene=%s" % [back_in_space, current_scene])
# Returning builds a BRAND NEW Main.tscn, so the spawners
# silenced back in stage 0 are live again in this fresh scene.
# Silence them on the first frame back, and record the ship count
# right then: asteroids populate immediately on scene load and
# could destroy a ship during the settle wait below, which is a
# gameplay collision rather than anything about the transition
# this test is checking.
var space_fleet: Node = current_scene.get_node("Fleet")
if returned_ship_count == -1:
current_scene.get_node("AsteroidField").set_process(false)
current_scene.get_node("EnemySpawner").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
returned_ship_count = space_fleet.ships.size()
returned_vessel_types = space_fleet.vessel_types()
# Ships respawn in a wedge and then steer into formation, so the
# centroid only means anything once they have actually stopped.
# Sampling after a fixed delay made this test intermittently fail
# by measuring a fleet still on its way in.
for ship in space_fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
# Assert on the HANDOFF, not on how many ships survive the next
# few frames. Asteroids populate during the fresh scene's own
# _ready(), so one can spawn overlapping the returning fleet and
# destroy a ship on the first physics frame - before any test
# code can run. That is a gameplay collision, not a transition
# fault, and made this test fail roughly 1 run in 12. What the
# transition owes us is that the surviving count was carried
# across correctly and a fleet came back.
var handoff_ok: bool = game_state.surviving_ship_count == 5
var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1
var composition_ok: bool = returned_vessel_types == expected_vessel_types
var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0
print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [
game_state.surviving_ship_count, returned_ship_count,
ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving
])
if ship_count_ok and composition_ok and position_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS")
quit(0)
else:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (return state wrong)")
quit(1)
return true
return false
func _press(button: IconButton) -> void:
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.pressed = true
button._gui_input(down)
var up: InputEventScreenTouch = InputEventScreenTouch.new()
up.pressed = false
button._gui_input(up)