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. 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