spaceship/tests/test_intro_scene.gd

296 lines
11 KiB
GDScript3
Raw Normal View History

#108: the prologue - the convoy, the ambush, and the breach Landed as one piece across #122, #123 and #124: the frame is useless without a script, and the script has nowhere to run without the frame. The director drives the REAL scene rather than being a scene of its own. It wants the real fleet, the real enemies and the real asteroid field, and it makes "then the game starts" cost nothing - the director stops steering, clears the flag, and what is left running is the ordinary game. The script is a list of beats, each with a deadline as well as its own completion test, so a beat whose condition is never met - a player who refuses to fly at the ambush - runs out rather than wedging the prologue. skip() ends the whole thing at any point and lands in exactly the state playing it through does, so the game has one way to start rather than two that have to be kept in step. The test pins that equivalence. "No action button available, only movements" is enforced where the buttons are decided, not by the director reaching in: the ability controllers set their own visibility every frame and would have put them straight back, and the map button belongs to the map. The joystick, the pivot and the fader stay. The convoy is ten hulls where a new game's fleet is one, so it needs an explicit fleet-size override - a plain number rather than a fake doctrine, because nothing about the player's own progression should be bent to fit a cutscene. It is carriers rather than transporters: transporters belong to hubs and are not fleet-eligible, so a convoy of them would be silently filtered down to its escorts. The ambush is chasers and only chasers. "Easy enough" is the requirement, and a sniper against a convoy the player cannot yet fight properly is neither easy nor a good first impression. The breach takes the wheel through one check in GameController, covering the stick, the keyboard and the released-again path together - three separate gates would be three chances for one of them to keep steering. The shake is written to camera.offset, never to its position, because GameController owns where the camera is and would smooth a shake away. The vessel that survives is chosen as the interceptor rather than whichever happened to be first in the list, and the prologue ends on exactly a new game's opening position. Also: PlanetAtmosphereController no longer spawns the opening fleet while the prologue is arming, and the tests that are about what the GAME starts from now say so by turning the prologue off. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:59:12 +02:00
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_intro_scene.gd
# Forgejo #108 (#122, #123, #124): the prologue.
#
# The director drives the REAL scene rather than being a scene of its own, so
# everything below is checked against the real fleet, the real enemies and
# the real asteroid field.
#
# Covers:
# A. it runs on a NEW game and not on a resumed one
# B. "no action button available, only movements" - every pressable
# affordance is gone, and the joystick is not
# C. the convoy: bigger than the player's own ceiling, and mostly holds
# D. the guide speaks, and the banner never shows a line nobody set
# E. the ambush is easy: chasers only, and clearing them advances the beat
# F. the belt is dense, and it is across the lane rather than beside it
# G. the breach takes the wheel: player input stops moving the fleet
# H. the vortex leaves exactly one vessel, and it is the default interceptor
# I. the handover: the flag clears, the buttons come back, the spawner and
# the asteroid field are running again, and the fleet is a new game's
# J. skipping lands in the same state as playing it through
var game_state: Node
var scene: Node
var director: Node
var fleet: Node
var narration: Control
var stage: int = 0
var elapsed: float = 0.0
var convoy_size: int = 0
var skipped_props: Array = []
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.rocks = 0
func _fail(message: String) -> bool:
print("TEST_INTRO_SCENE: FAIL (%s)" % message)
quit(1)
return true
func _load_scene() -> void:
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
director = scene.get_node_or_null("IntroDirector")
narration = scene.get_node("UI/NarrationBanner")
func _drop_scene() -> void:
scene.queue_free()
scene = null
func _hidden(path: String) -> bool:
var node: Control = scene.get_node_or_null(path)
return node == null or not node.visible
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. a new game gets one ------------------------------
if not game_state.intro_active:
return _fail("reset_new_game() did not arm the prologue")
_load_scene()
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
if director == null:
return _fail("no IntroDirector in the scene")
if not director.running:
return _fail("the director did not start on a new game")
# --- C. the convoy --------------------------------------
convoy_size = fleet.ships.size()
if convoy_size <= GameState.BASE_FLEET_SIZE:
return _fail("the convoy is %d vessels, no more than a new game's %d slots" \
% [convoy_size, GameState.BASE_FLEET_SIZE])
var holds: int = 0
var escorts: int = 0
for ship in fleet.ships:
if String(ship.vessel_type) == GameState.VESSEL_INTERCEPTOR:
escorts += 1
else:
holds += 1
if holds <= escorts:
return _fail("the convoy is %d holds to %d escorts - it should be mostly cargo" \
% [holds, escorts])
if escorts <= 0:
return _fail("the convoy has no escorts at all")
print("convoy: %d vessels, %d holds and %d escorts" % [convoy_size, holds, escorts])
# --- B. movement and nothing else -----------------------
for pressable in ["UI/BuildButton", "UI/MapButton", "UI/BuildActionButton",
"UI/BuildingActionButton", "UI/EnterAtmosphereButton",
"UI/TransferButton", "UI/HangarButton", "UI/LaserButton",
"UI/GasBombButton"]:
if not _hidden(pressable):
return _fail("%s is offered during the prologue" % pressable)
var joystick: Control = scene.get_node("UI/Joystick")
if not joystick.visible:
return _fail("the joystick is hidden - the player cannot even fly")
print("every action button is away; the joystick is not")
# --- D. the guide speaks --------------------------------
if not narration.is_speaking():
return _fail("the prologue opened without a word from the guide")
if narration.label.text.strip_edges() == "":
return _fail("the banner is up with nothing on it")
print("guide: \"%s\"" % narration.label.text)
# --- E. the ambush --------------------------------------
director._begin(director.Beat.AMBUSH)
if director.ambush.is_empty():
return _fail("the ambush beat spawned nothing")
for enemy in director.ambush:
if String(enemy.enemy_id) != "chaser":
return _fail("the ambush includes a %s - it is meant to be easy" % enemy.enemy_id)
if director._beat_complete():
return _fail("the ambush counts as cleared while its raiders are alive")
for enemy in director.ambush:
enemy.queue_free()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
# The director advances itself, so the evidence that clearing the
# ambush ended the beat is that it has MOVED ON - asking
# _beat_complete() again would be asking about a beat it left.
if director.beat <= director.Beat.AMBUSH:
return _fail("killing every raider did not end the ambush beat")
print("%d raiders, all chasers, and clearing them ends the beat" % director.AMBUSH_COUNT)
# --- F. the belt ----------------------------------------
director._begin(director.Beat.GAUNTLET)
# Only the rocks still standing: the convoy flies into them, and
# a rock it has already broken is not evidence about the belt.
var standing: Array = []
for rock in director.rocks:
if is_instance_valid(rock) and not rock.is_queued_for_deletion():
standing.append(rock)
if standing.size() < 40:
return _fail("the \"pretty dense\" belt is %d rocks" % standing.size())
var ahead: int = 0
for rock in standing:
if rock.global_position.y < fleet.get_centroid().y:
ahead += 1
if ahead < standing.size() / 2:
return _fail("only %d of %d rocks are on the lane ahead" \
% [ahead, standing.size()])
print("belt: %d rocks, %d of them across the lane ahead" % [standing.size(), ahead])
# --- G. the breach takes the wheel ----------------------
director._begin(director.Beat.BREACH)
if not game_state.intro_locks_movement:
return _fail("the breach beat left the player in control")
# GameController.gd is the script on Main itself, not a child.
var before: Vector2 = fleet.fleet_target
scene._process(0.1)
if fleet.fleet_target != before:
return _fail("player steering still moved the fleet during the breach")
print("the breach has the wheel: steering input moves nothing")
# --- H. the vortex --------------------------------------
director._begin(director.Beat.VORTEX)
elapsed = 0.0
stage = 3
return false
3:
# Left to the director's own clock rather than ticked by hand:
# the vortex is a timed animation and driving it twice would not
# be the thing that ships.
if elapsed < 4.2:
return false
var alive: int = 0
for ship in fleet.ships:
if is_instance_valid(ship) and not ship.is_queued_for_deletion():
alive += 1
if alive != 1:
return _fail("the vortex left %d vessels, expected 1" % alive)
if String(fleet.ships[0].vessel_type) != GameState.VESSEL_INTERCEPTOR:
return _fail("the survivor is a %s, not the default interceptor" \
% fleet.ships[0].vessel_type)
print("the vortex took %d vessels and left one interceptor" % (convoy_size - 1))
# --- I. the handover ------------------------------------
director.finish()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("the prologue finished without clearing its own flags")
if game_state.intro_fleet_size != 0:
return _fail("the convoy's fleet-size override outlived the prologue")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("the fleet still holds %d slots after the prologue" \
% game_state.max_fleet_size())
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("the game starts with %d vessels, expected %d" \
% [fleet.ships.size(), GameState.STARTING_SHIP_COUNT])
if not scene.get_node("EnemySpawner").is_processing():
return _fail("waves are still switched off after the prologue")
if not scene.get_node("AsteroidField").is_processing():
return _fail("the asteroid field is still switched off after the prologue")
scene.get_node("BuildController")._process(0.1)
if _hidden("UI/BuildButton"):
return _fail("the build button never came back")
if narration.is_speaking():
return _fail("the guide is still talking after the handover")
print("handover: flags clear, 1 interceptor, waves and rocks running, buttons back")
_drop_scene()
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.3:
return false
# --- A (second half). a resumed game gets no prologue ----
game_state.intro_active = false
_load_scene()
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.4:
return false
if director != null and is_instance_valid(director) and director.running:
return _fail("the prologue ran again on a game that is past it")
if fleet.ships.size() > GameState.BASE_FLEET_SIZE:
return _fail("a resumed game opened with a convoy")
print("a game already past the prologue does not replay it")
_drop_scene()
elapsed = 0.0
stage = 7
return false
7:
if elapsed < 0.3:
return false
# --- J. skipping lands where playing it through does -----
game_state.reset_new_game()
_load_scene()
elapsed = 0.0
stage = 8
return false
8:
if elapsed < 0.4:
return false
# Skipped mid-prologue, with props on the board, so the check
# below is about clearing them rather than about an empty stage.
director._begin(director.Beat.AMBUSH)
director._begin(director.Beat.GAUNTLET)
skipped_props = director.ambush + director.rocks
if skipped_props.size() < 40:
return _fail("nothing was on the board to skip past")
director.skip()
elapsed = 0.0
stage = 9
return false
9:
if elapsed < 0.3:
return false
if game_state.intro_active or game_state.intro_locks_movement:
return _fail("skipping left the prologue's flags set")
if game_state.max_fleet_size() != GameState.BASE_FLEET_SIZE:
return _fail("skipping left the convoy's fleet size behind")
if fleet.ships.size() != GameState.STARTING_SHIP_COUNT:
return _fail("skipping left %d vessels flying" % fleet.ships.size())
for prop in skipped_props:
if is_instance_valid(prop) and not prop.is_queued_for_deletion():
return _fail("skipping left the prologue's own props on the board")
print("skipping clears %d props and lands where playing it through does" \
% skipped_props.size())
print("TEST_INTRO_SCENE: PASS")
quit(0)
return true
return false