Measured before changing anything. The ticket's two halves needed opposite
fixes, and the first one made the second worse.
TOO COMPLEX. Variety was gated on fleet size alone, so a player who had
grown early met two kinds of trouble in the first wave they ever saw - a
fleet of four opened on two lancers and three chasers. Each roster entry now
carries a min_wave as well, so the openings teach one enemy at a time
whatever the fleet already looks like.
That alone pushed wave 1 against a fleet of six from a mixed six to SEVEN
chasers: budget that used to buy a lancer now bought two more chasers. So an
early wave also has a size ceiling, 2 + wave_number * 2. Wave 1 is four
chasers at any fleet size; the cap stops binding by wave 6 and nothing about
later balance moves.
before wave 1 fleet 4 -> 2 lancers, 3 chasers
wave 1 fleet 6 -> 1 sniper, 2 lancers, 3 chasers
after wave 1 fleet 4 -> 4 chasers
wave 1 fleet 6 -> 4 chasers
ARRIVE AT THE SAME TIME was literal: the whole wave spawned inside one frame
as a closed ring around the fleet, which reads as a wall rather than as an
attack. The first hull still lands immediately so a wave is never felt as
late, and the rest follow every 0.45s. Same enemies, same bearing, same
total - they simply do not all exist at once.
Four existing cases objected, and each was right to: they encode intents
that still hold LATER - a large wave must be varied, fleet growth must open
more kinds, a wave can field a lancer at all, and killing a wave must bring
the quiet back. The first three now ask at a wave number where variety is
permitted rather than being weakened. The fourth runs the arrivals out
before killing anything, and gained an assertion that doing so ADDS enemies
- which fails if the staggering ever collapses back into one frame.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
343 lines
14 KiB
GDScript
343 lines
14 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 - but read at a
|
|
# wave number where variety is ALLOWED. #233 gates each enemy kind behind
|
|
# a minimum wave as well as a minimum fleet, so that the openings teach
|
|
# one enemy at a time however big the fleet already is; a fleet of nine
|
|
# meeting only chasers in wave 1 is now the intent, not the swarm this
|
|
# case is guarding against.
|
|
game_state.wave_number = before_number + 6
|
|
var varied: PackedStringArray = game_state.wave_composition(9)
|
|
game_state.wave_number = before_number
|
|
var heavy_kinds: Dictionary = {}
|
|
for id in varied:
|
|
heavy_kinds[id] = true
|
|
if heavy_kinds.size() < 2:
|
|
return _fail("a large wave at wave %d is %d copies of one enemy" \
|
|
% [before_number + 6, varied.size()])
|
|
|
|
# And the openings really are single-kind, whatever the fleet looks like.
|
|
game_state.wave_number = 1
|
|
var opener: PackedStringArray = game_state.wave_composition(9)
|
|
game_state.wave_number = before_number
|
|
var opening_kinds: Dictionary = {}
|
|
for id in opener:
|
|
opening_kinds[id] = true
|
|
if opening_kinds.size() != 1:
|
|
return _fail("wave 1 against a fleet of 9 already mixes %d kinds" % opening_kinds.size())
|
|
if opener.size() > game_state.wave_max_enemies():
|
|
return _fail("wave 1 sends %d enemies, past the %d an opening wave may" \
|
|
% [opener.size(), game_state.wave_max_enemies()])
|
|
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, 4):
|
|
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 3 or fewer meets chasers only, at every wave number")
|
|
|
|
# And each vessel past the starting three opens exactly one more kind, so
|
|
# the ramp is progressive rather than a cliff.
|
|
# Explicit sizes rather than BASE_FLEET_SIZE + n: #219 moved the starting
|
|
# capacity to 1, and this is about the roster's own thresholds (4, 5, 6),
|
|
# not about where the fleet happens to begin.
|
|
# Read past the opening waves: #233 also gates each kind behind a minimum
|
|
# WAVE, so that the first waves teach one enemy at a time whatever the
|
|
# fleet looks like. This case is about the roster's FLEET thresholds, so
|
|
# it asks at a wave number where every kind is already permitted.
|
|
var settled: int = game_state.wave_number
|
|
game_state.wave_number = 8
|
|
var previous_kinds: int = 1
|
|
for size in range(3, 8):
|
|
var kinds_here: Dictionary = {}
|
|
for id in game_state.wave_composition(size):
|
|
kinds_here[id] = true
|
|
if kinds_here.size() < previous_kinds:
|
|
game_state.wave_number = settled
|
|
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()
|
|
game_state.wave_number = settled
|
|
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])
|
|
|
|
# #233 lands a wave over several seconds rather than in one
|
|
# frame, so "the whole wave" is not on the field yet. Run the
|
|
# arrivals out first - otherwise this kills the one hull that
|
|
# has landed and calls that the wave cleared.
|
|
for i in range(40):
|
|
spawner._process(GameState.WAVE_MAX_SECONDS)
|
|
if spawner.arriving.is_empty():
|
|
break
|
|
if not spawner.arriving.is_empty():
|
|
return _fail("%d hulls never finished arriving" % spawner.arriving.size())
|
|
var landed: int = _enemy_count()
|
|
if landed <= arrived:
|
|
return _fail("running the arrivals out added nothing (%d then %d) - the wave is not staggered" \
|
|
% [arrived, landed])
|
|
print("the rest of the wave followed: %d -> %d enemies" % [arrived, landed])
|
|
|
|
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
|