The previous ten tickets shipped with almost no coverage - only #109, #113, #115 and #117 touched a test. That was a gap, not a decision: everything before it had its behaviour pinned, and #116 in particular added a whole subsystem nothing was watching. Four new files and one extended case: test_spacial_workshop #116 - the repair economy end to end: the cost of a repair scales with the damage, the workshop asks logistics for that ore and stops at its buffer, a drone spends when it LEAVES so two cannot double-charge one wall, and a target levelled in flight sends its load home rather than swallowing it test_desktop_controls #118/#121 - both driven through the real routing, because the routing is what was missing; a test calling handle_mouse_button() itself would have passed against both reports test_enemy_bomber #114 - it flies, it faces where it flies, its bombs arrive, and it bombs structures with no fleet in sight test_menu_polish #111/#112/#119/#120 - what each panel shows test_wave_system (case G) #110 - the dev button, and that it does not exist outside dev mode One assertion worth recording: the craft caption is checked as an EXACT match against the vessel's display name rather than by hunting for resource words in it. The first version failed on "Gas Bomber" - a vessel named after a resource, and always will be. What #111 removed was the price spelled out underneath, not the name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
259 lines
10 KiB
GDScript
259 lines
10 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()])
|
|
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 button ---------------------------------------
|
|
var dev_button: Control = scene.get_node("UI/DevWaveButton")
|
|
game_state.dev_mode = false
|
|
dev_button._process(0.0)
|
|
if dev_button.visible:
|
|
return _fail("the dev wave 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 wave button is hidden in dev mode")
|
|
var number_before: int = game_state.wave_number
|
|
dev_button.button.pressed.emit()
|
|
if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE:
|
|
return _fail("the button 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 button: 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
|