spaceship/tests/test_planet_atmosphere_transition.gd

288 lines
12 KiB
GDScript3
Raw Normal View History

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,
2026-08-15 00:09:10 +02:00
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://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")
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# 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)