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. var heavy_kinds: Dictionary = {} for id in large: heavy_kinds[id] = true if heavy_kinds.size() < 2: return _fail("a large wave is %d copies of one enemy" % large.size()) if large.size() > GameState.WAVE_MAX_ENEMIES: return _fail("a wave of %d exceeds the %d cap" % [large.size(), GameState.WAVE_MAX_ENEMIES]) print("fleet 1 -> %d enemies, fleet 9 -> %d enemies of %d kinds" \ % [small.size(), large.size(), heavy_kinds.size()]) game_state.reset_waves() return false func _enemy_count() -> int: var n: int = 0 for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy) and not enemy.is_queued_for_deletion(): n += 1 return n func _process(delta: float) -> bool: elapsed += delta match stage: 0: if _state_cases(): return true scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) spawner = scene.get_node("EnemySpawner") fleet = scene.get_node("Fleet") alert = scene.get_node("UI/WaveAlert") elapsed = 0.0 stage = 1 return false 1: if elapsed < 0.3: return false scene.get_node("AsteroidField").set_process(false) for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"): if is_instance_valid(hazard): hazard.queue_free() # The director stays ENABLED for this test - it is the thing # under test. game_state.reset_waves() elapsed = 0.0 stage = 2 return false 2: # --- D. no trickle ------------------------------------------ # Comfortably longer than the old 3-6s spawn interval, so the # removed behaviour would have shown up several times over. if elapsed < 8.0: if _enemy_count() > 0: return _fail("an enemy appeared with no wave running - the trickle is still there") return false if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: return _fail("the peace should still be running after %.0fs" % elapsed) print("%.0fs of peace and not one enemy - the old trickle is gone" % elapsed) # --- F. the alert is up only during the warning -------------- if alert.visible: return _fail("the wave alert is showing during peacetime") game_state.wave_phase = GameState.WAVE_PHASE_WARNING game_state.wave_timer = 0.0 alert._process(0.0) if not alert.visible: return _fail("the wave alert did not appear for an incoming wave") var text: String = alert.label.text for c in text: if c >= "0" and c <= "9": return _fail("the alert reads \"%s\" - that is a timer" % text) print("the alert reads \"%s\" during the warning, with no digits in it" % text) elapsed = 0.0 stage = 3 return false 3: # --- E. the wave really arrives ----------------------------- game_state.wave_phase = GameState.WAVE_PHASE_ACTIVE game_state.wave_number += 1 game_state.wave_pending = true spawner._process(0.0) var arrived: int = _enemy_count() if arrived <= 0: return _fail("the wave turned active and nothing arrived") if game_state.wave_pending: return _fail("the wave was launched but is still owed") if alert.visible: alert._process(0.0) if alert.visible: return _fail("the alert is still up after the wave arrived") var centre: Vector2 = fleet.get_centroid() var nearest: float = INF for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy): nearest = minf(nearest, centre.distance_to(enemy.global_position)) if nearest < 200.0: return _fail("a wave enemy arrived %.0fpx from the fleet - that is on top of them" % nearest) print("wave %d arrived: %d enemies, nearest %.0fpx away" \ % [game_state.wave_number, arrived, nearest]) for enemy in get_nodes_in_group("enemies"): if is_instance_valid(enemy): enemy.queue_free() elapsed = 0.0 stage = 4 return false 4: if elapsed < 0.2: return false spawner._process(0.0) if game_state.wave_phase != GameState.WAVE_PHASE_PEACE: return _fail("killing the whole wave did not bring the quiet back") print("the wave died and the quiet came back") # --- G. the dev button --------------------------------------- var dev_button: Control = scene.get_node("UI/DevWaveButton") game_state.dev_mode = false dev_button._process(0.0) if dev_button.visible: return _fail("the dev wave button is offered in a normal game") game_state.dev_mode = true dev_button._process(0.0) if not dev_button.visible: return _fail("the dev wave button is hidden in dev mode") var number_before: int = game_state.wave_number dev_button.button.pressed.emit() if game_state.wave_phase != GameState.WAVE_PHASE_ACTIVE: return _fail("the button left the wave in \"%s\"" % game_state.wave_phase) if not game_state.wave_pending: return _fail("the button turned the wave active without owing it to anyone") if game_state.wave_number != number_before + 1: return _fail("the forced wave was not counted") # The point of the button is to SEE the wave, so it skips the # nine-second alert as well as the fifty-second peace. if game_state.wave_is_incoming(): return _fail("the button stopped at the warning - that is not \"now\"") spawner._process(0.0) if _enemy_count() <= 0: return _fail("the forced wave produced no enemies") print("dev button: wave %d on the board at once, %d enemies" \ % [game_state.wave_number, _enemy_count()]) game_state.dev_mode = false print("TEST_WAVE_SYSTEM: PASS") quit(0) return true return false