spaceship/tests/test_planet_atmosphere_transition.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

304 lines
13 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")
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
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.
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)