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 # Clear the landed garrison before flying anywhere. The authored # enemies are legitimately there (checked above), but this test is # about the TRANSITION - it hands the fleet down, flies it, and # hands it back, then asserts every vessel survived the round # trip. A chaser destroying one on the way is a correct outcome of # a fight this test never meant to pick, and #82 made that outcome # more likely by making contact lethal to both. It surfaced when # #88 raised every speed by 1.5 and the same 2.5s flight started # covering enough ground to reach one. for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy): enemy.queue_free() # 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)