spaceship/tests/test_wave_system.gd
Loïc Wisniewski 6e13b6397c Take planets by killing what defends them
#211 conquest is the fight now: a planet registers the garrison its setup
authors, each defender's own death counts against it, and the last one takes
the world. The capture points - stand in a ring, wait out a timer, once per
ring - are gone, along with the editor palette that placed them.
#212 conquest opens a dialog on the surface naming the research points
recovered; the award travels with the signal, after it is banked.
#209 the prologue's charted sectors go with the breach, so the map the
survivors keep is of where they now are - and "chart 30 sectors" is no
longer complete before they have flown anywhere.
#218 enemy variety is gated on the FLEET, not on wave strength: a starting
fleet meets chasers only at any wave number, and each vessel past three
opens one more kind.

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

295 lines
12 KiB
GDScript

extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_wave_system.gd
# Forgejo #100 (#97b): waves replace the roaming enemy spawner.
#
# The old EnemySpawner put one enemy on the map every 3-6 seconds for ever.
# There was no rhythm to it and nothing to prepare for. Enemies now arrive
# in waves: a long quiet, a warning, an attack, and quiet again once the
# wave is dead.
#
# The clock lives in GameState because Main.tscn is destroyed on every trip
# to a planet surface - a wave must not be cancelled, nor restarted from
# zero, by a landing.
#
# Covers:
# A. the phase machine runs peace -> warning -> active, and a wave that is
# killed puts it back to peace
# B. no timer is exposed and none is drawn. #97 asks for an alert with no
# visible countdown, so the alert's own text must carry no digits and
# GameState must not offer a remaining-seconds accessor at all
# C. waves get stronger with the FLEET, and with the wave number
# D. the trickle is gone: a live director with no wave running leaves the
# map empty, where the old spawner would have filled it
# E. a launched wave really arrives, around the fleet, and clearing it
# returns the world to peace
# F. the alert is up during the warning and at no other time
# G. #110 - a dev-mode button puts a wave on the board now, skipping both
# the peace and the warning, and it does not exist outside dev mode
var game_state: Node
var scene: Node
var spawner: Node2D
var fleet: Node2D
var alert: Control
var stage: int = 0
var elapsed: float = 0.0
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.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.reset_waves()
func _fail(message: String) -> bool:
print("TEST_WAVE_SYSTEM: FAIL (%s)" % message)
quit(1)
return true
func _state_cases() -> bool:
# --- A. the phase machine --------------------------------------------
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
return _fail("a reset game does not open in peace")
game_state.advance_waves(GameState.WAVE_PEACE_SECONDS - 0.1)
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
return _fail("the peace ended early")
game_state.advance_waves(0.2)
if not game_state.wave_is_incoming():
return _fail("the peace ran out without a warning")
if game_state.wave_number != 0:
return _fail("the wave was counted while it was still only a warning")
game_state.advance_waves(GameState.WAVE_WARNING_SECONDS - 0.1)
if not game_state.wave_is_incoming():
return _fail("the warning ended early")
game_state.advance_waves(0.2)
if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE:
return _fail("the warning did not turn into an attack")
if game_state.wave_number != 1 or not game_state.wave_pending:
return _fail("wave %d turned active without being owed to anyone" % game_state.wave_number)
game_state.wave_cleared()
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE or game_state.wave_pending:
return _fail("clearing the wave did not return the world to peace")
print("peace -> warning -> wave 1 -> peace")
# --- B. nothing that could be read as a countdown --------------------
for forbidden in ["wave_seconds_left", "wave_time_left", "wave_countdown",
"seconds_until_wave", "wave_remaining"]:
if game_state.has_method(forbidden):
return _fail("GameState.%s() exists - the alert could become a timer" % forbidden)
game_state.advance_waves(GameState.WAVE_PEACE_SECONDS + 0.1)
if not game_state.wave_is_incoming():
return _fail("could not get back into a warning for the pulse check")
var pulse: float = game_state.wave_warning_pulse()
if pulse < 0.0 or pulse > 1.0:
return _fail("the warning pulse is %f, outside 0..1" % pulse)
print("no remaining-seconds accessor exists; urgency is a %.2f pulse" % pulse)
# --- C. strength follows the fleet -----------------------------------
var small: PackedStringArray = game_state.wave_composition(1)
var large: PackedStringArray = game_state.wave_composition(9)
if small.is_empty():
return _fail("a wave against a single vessel is empty")
if large.size() <= small.size():
return _fail("a fleet of 9 draws %d enemies, no more than the %d a single vessel draws" \
% [large.size(), small.size()])
if game_state.wave_strength(9) <= game_state.wave_strength(1):
return _fail("wave strength does not rise with fleet size")
var before_number: int = game_state.wave_number
game_state.wave_number = before_number + 20
var later: PackedStringArray = game_state.wave_composition(1)
game_state.wave_number = before_number
if later.size() <= small.size():
return _fail("wave 21 against one vessel is no bigger than wave 1 - there is no escalation")
# A big wave must not be the same swarm at greater length.
var heavy_kinds: Dictionary = {}
for id in large:
heavy_kinds[id] = true
if heavy_kinds.size() < 2:
return _fail("a large wave is %d copies of one enemy" % large.size())
if large.size() > GameState.WAVE_MAX_ENEMIES:
return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES])
print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \
% [small.size(), large.size(), heavy_kinds.size()])
# --- #218: a starting fleet meets one kind of enemy, however long it
# takes. Strength alone could not promise this - it counts the wave
# NUMBER too, so simply surviving used to unlock lancers and snipers
# against a fleet that had grown by nothing.
var patient: int = game_state.wave_number
for wave in [1, 5, 20, 60]:
game_state.wave_number = wave
for size in range(1, GameState.BASE_FLEET_SIZE + 1):
for id in game_state.wave_composition(size):
if id != "chaser":
game_state.wave_number = patient
return _fail("wave %d against a fleet of %d fields a %s" \
% [wave, size, id])
game_state.wave_number = patient
print("a fleet of %d or fewer meets chasers only, at every wave number" \
% GameState.BASE_FLEET_SIZE)
# And each vessel past the starting three opens exactly one more kind, so
# the ramp is progressive rather than a cliff.
var previous_kinds: int = 1
for size in range(GameState.BASE_FLEET_SIZE, GameState.BASE_FLEET_SIZE + 4):
var kinds_here: Dictionary = {}
for id in game_state.wave_composition(size):
kinds_here[id] = true
if kinds_here.size() < previous_kinds:
return _fail("a fleet of %d meets %d kinds, fewer than the %d before it" \
% [size, kinds_here.size(), previous_kinds])
previous_kinds = kinds_here.size()
if previous_kinds < 3:
return _fail("a grown fleet still only meets %d kinds of enemy" % previous_kinds)
print("enemy variety climbs with the fleet, reaching %d kinds" % previous_kinds)
game_state.reset_waves()
return false
func _enemy_count() -> int:
var n: int = 0
for enemy in get_nodes_in_group("enemies"):
if is_instance_valid(enemy) and not enemy.is_queued_for_deletion():
n += 1
return n
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if _state_cases():
return true
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
spawner = scene.get_node("EnemySpawner")
fleet = scene.get_node("Fleet")
alert = scene.get_node("UI/WaveAlert")
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
scene.get_node("AsteroidField").set_process(false)
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
if is_instance_valid(hazard):
hazard.queue_free()
# The director stays ENABLED for this test - it is the thing
# under test.
game_state.reset_waves()
elapsed = 0.0
stage = 2
return false
2:
# --- D. no trickle ------------------------------------------
# Comfortably longer than the old 3-6s spawn interval, so the
# removed behaviour would have shown up several times over.
if elapsed < 8.0:
if _enemy_count() > 0:
return _fail("an enemy appeared with no wave running - the trickle is still there")
return false
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
return _fail("the peace should still be running after %.0fs" % elapsed)
print("%.0fs of peace and not one enemy - the old trickle is gone" % elapsed)
# --- F. the alert is up only during the warning --------------
if alert.visible:
return _fail("the wave alert is showing during peacetime")
game_state.wave_phase = GameState.WAVE_PHASE_WARNING
game_state.wave_timer = 0.0
alert._process(0.0)
if not alert.visible:
return _fail("the wave alert did not appear for an incoming wave")
var text: String = alert.label.text
for c in text:
if c >= "0" and c <= "9":
return _fail("the alert reads \"%s\" - that is a timer" % text)
print("the alert reads \"%s\" during the warning, with no digits in it" % text)
elapsed = 0.0
stage = 3
return false
3:
# --- E. the wave really arrives -----------------------------
game_state.wave_phase = GameState.WAVE_PHASE_ACTIVE
game_state.wave_number += 1
game_state.wave_pending = true
spawner._process(0.0)
var arrived: int = _enemy_count()
if arrived <= 0:
return _fail("the wave turned active and nothing arrived")
if game_state.wave_pending:
return _fail("the wave was launched but is still owed")
if alert.visible:
alert._process(0.0)
if alert.visible:
return _fail("the alert is still up after the wave arrived")
var centre: Vector2 = fleet.get_centroid()
var nearest: float = INF
for enemy in get_nodes_in_group("enemies"):
if is_instance_valid(enemy):
nearest = minf(nearest, centre.distance_to(enemy.global_position))
if nearest < 200.0:
return _fail("a wave enemy arrived %.0fpx from the fleet - that is on top of them" % nearest)
print("wave %d arrived: %d enemies, nearest %.0fpx away" \
% [game_state.wave_number, arrived, nearest])
for enemy in get_nodes_in_group("enemies"):
if is_instance_valid(enemy):
enemy.queue_free()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.2:
return false
spawner._process(0.0)
if game_state.wave_phase != GameState.WAVE_PHASE_PEACE:
return _fail("killing the whole wave did not bring the quiet back")
print("the wave died and the quiet came back")
# --- G. the dev menu keeps the immediate wave tool -----------
var dev_button: Control = scene.get_node("UI/DevMenuButton")
var dev_menu: Control = scene.get_node("UI/DevMenu")
game_state.dev_mode = false
dev_button._process(0.0)
if dev_button.visible:
return _fail("the dev menu button is offered in a normal game")
game_state.dev_mode = true
dev_button._process(0.0)
if not dev_button.visible:
return _fail("the dev menu button is hidden in dev mode")
dev_button.button.pressed.emit()
if not dev_menu.visible:
return _fail("the dev button did not open the dev menu")
var number_before: int = game_state.wave_number
dev_menu.wave_button.pressed.emit()
if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE:
return _fail("the menu's wave tool left the wave in \"%s\"" % game_state.wave_phase)
if not game_state.wave_pending:
return _fail("the button turned the wave active without owing it to anyone")
if game_state.wave_number != number_before + 1:
return _fail("the forced wave was not counted")
# The point of the button is to SEE the wave, so it skips the
# nine-second alert as well as the fifty-second peace.
if game_state.wave_is_incoming():
return _fail("the button stopped at the warning - that is not \"now\"")
spawner._process(0.0)
if _enemy_count() <= 0:
return _fail("the forced wave produced no enemies")
print("dev menu: wave %d on the board at once, %d enemies" \
% [game_state.wave_number, _enemy_count()])
game_state.dev_mode = false
print("TEST_WAVE_SYSTEM: PASS")
quit(0)
return true
return false