#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
297 lines
12 KiB
GDScript
297 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, 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.
|
|
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:
|
|
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
|