spaceship/tests/test_wave_system.gd

343 lines
14 KiB
GDScript3
Raw Normal View History

#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
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
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
# 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
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
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")
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
# 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
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
var heavy_kinds: Dictionary = {}
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
for id in varied:
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
heavy_kinds[id] = true
if heavy_kinds.size() < 2:
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
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()])
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
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.
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
# 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:
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
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()
#233: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
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)
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
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: open with fewer enemies, of one kind, arriving in sequence 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
2026-09-14 12:35:50 -04:00
# #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])
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
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")
2026-09-03 20:05:33 +02:00
# --- 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")
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
game_state.dev_mode = false
dev_button._process(0.0)
if dev_button.visible:
2026-09-03 20:05:33 +02:00
return _fail("the dev menu button is offered in a normal game")
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
game_state.dev_mode = true
dev_button._process(0.0)
if not dev_button.visible:
2026-09-03 20:05:33 +02:00
return _fail("the dev menu button is hidden in dev mode")
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
dev_button.button.pressed.emit()
2026-09-03 20:05:33 +02:00
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()
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE:
2026-09-03 20:05:33 +02:00
return _fail("the menu's wave tool left the wave in \"%s\"" % game_state.wave_phase)
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
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")
2026-09-03 20:05:33 +02:00
print("dev menu: wave %d on the board at once, %d enemies" \
Cover the last batch of tickets with tests 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>
2026-08-24 12:10:31 +02:00
% [game_state.wave_number, _enemy_count()])
game_state.dev_mode = false
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger 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 and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
print("TEST_WAVE_SYSTEM: PASS")
quit(0)
return true
return false