extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_turret_spawner_enemy.gd # Forgejo #52: a stationary, high-HP enemy that continuously spawns chasers # beside itself. Verifies: # 1. registered in EnemyDefinitions - the map editor palette (#48) is # generated from EnemyDefinitions.all(), so this alone is what makes # it placeable, with no editor code change # 2. spawns nothing while there is no fleet (or an empty one) # 3. once a fleet exists, spawns one enemy per spawn_interval, stopping # at max_alive # 4. a spawn dying frees its slot, and the next interval fills it again # 5. the turret itself never moves # 6. survives 19 damage, dies on the 20th # 7. its already-spawned children survive its death - they are siblings # under its own parent, not descendants, so killing it can't free them # # spawn_interval/max_alive are overridden to small test-friendly values # directly on the instantiated component, the same way tests/ # _artillery_telegraph_rocky_preview.gd pins ArtilleryShell's telegraph_time - # the definition's real defaults (4.0s / 6 alive) are only asserted once, # structurally, before the override. var game_state: Node var main: Node var fleet: Node var turret: Node var spawner: Node var stage: int = 0 var elapsed: float = 0.0 var turret_start_position: Vector2 var one_spawn_ref: Node func _initialize() -> void: game_state = root.get_node("GameState") # --- 1. registered, with the definition's real defaults intact --- if not EnemyDefinitions.ORDER.has("turret_spawner"): _fail("turret_spawner is not in EnemyDefinitions.ORDER - the map editor palette would never show it") return var definition: Resource = EnemyDefinitions.get_definition("turret_spawner") if definition.max_hp != 20: _fail("turret_spawner max_hp is %d, expected 20 (a decent amount of life)" % definition.max_hp) return if definition.speed != 0.0: _fail("turret_spawner speed is %s, expected 0.0 (stationary)" % definition.speed) return main = load("res://world/Main.tscn").instantiate() root.add_child(main) fleet = main.get_node("Fleet") func _process(delta: float) -> bool: elapsed += delta match stage: 0: if elapsed < 0.1: return false # This has to happen here, not in _initialize() - a node's # set_process(false) call made before it has fully finished # entering the tree doesn't reliably stick, since the engine's # own deferred tree-entry finalization can reset it back to # enabled. By now _ready() cascades are confirmed complete. main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) # BuildController._check_fleet_wipe() runs every _process() tick # and rebuilds a single ship the moment fleet.ships is empty - # the exact "empty fleet" state stage 2 needs to hold for a while. main.get_node("BuildController").set_process(false) # Empty the fleet (gotcha: spawn_ships(0,...) still leaves one # default interceptor - clear explicitly). for ship in fleet.ships: if is_instance_valid(ship): ship.queue_free() fleet.ships.clear() turret = load("res://enemies/Enemy.tscn").instantiate() turret.enemy_id = "turret_spawner" turret.position = Vector2(500, 500) turret_start_position = turret.position main.add_child(turret) elapsed = 0.0 stage = 1 return false 1: if elapsed < 0.15: return false spawner = turret.get_component("spawner") if spawner == null: return _fail("turret_spawner has no 'spawner' capability component") # --- 2. no fleet -> no spawns, even past several intervals --- spawner.spawn_interval = 0.1 spawner.max_alive = 3 spawner.spawn_radius = 50.0 # spawn_timer already started counting down from the definition's # real default (4.0s) on the component's very first tick, before # this override could reach it - changing spawn_interval alone # only affects the NEXT reset, not the countdown already running. # Reset it explicitly so the override takes effect immediately. spawner.spawn_timer = 0.0 elapsed = 0.0 stage = 2 return false 2: if elapsed < 0.6: return false if spawner.spawned.size() > 0: return _fail("spawner spawned %d enemies with no fleet present" % spawner.spawned.size()) # --- give it a fleet to spawn against --- fleet.spawn_ships(1, Vector2(-2000, -2000)) elapsed = 0.0 stage = 3 return false 3: # --- 3. spawns, capped at max_alive --- if elapsed < spawner.spawn_interval * (spawner.max_alive + 2) + 0.3: return false if spawner.spawned.size() != spawner.max_alive: return _fail("spawner has %d alive after plenty of time, expected max_alive=%d" % [ spawner.spawned.size(), spawner.max_alive ]) for spawn in spawner.spawned: if spawn.enemy_id != "chaser": return _fail("a spawn has enemy_id '%s', expected 'chaser'" % spawn.enemy_id) if spawn.get_parent() != turret.get_parent(): return _fail("a spawn is not parented under the turret's own parent - it would not survive the turret's death correctly") # --- 5. never moves --- if turret.position != turret_start_position: return _fail("turret_spawner moved from %s to %s" % [turret_start_position, turret.position]) # --- 4. a dead spawn frees its slot and gets refilled --- one_spawn_ref = spawner.spawned[0] one_spawn_ref.take_damage(999) elapsed = 0.0 stage = 4 return false 4: # --- 4. a dead spawn frees its slot, and the interval refills it --- # spawn_interval is short enough (0.1s, overridden above) that the # refill can land within a couple of frames of the kill - there is # no reliably observable moment where the count sits at max_alive-1 # and stays there, so this checks the net effect once things have # settled: the killed spawn specifically is gone, and a fresh one # took its slot back up to max_alive, rather than checking for an # intermediate dip that a fast interval can blow straight through. if elapsed < spawner.spawn_interval + 0.3: return false if is_instance_valid(one_spawn_ref): return _fail("killed spawn is still valid after take_damage(999)") if spawner.spawned.has(one_spawn_ref): return _fail("the killed spawn is still tracked in spawner.spawned") if spawner.spawned.size() != spawner.max_alive: return _fail("spawner did not refill the freed slot (has %d, expected %d)" % [ spawner.spawned.size(), spawner.max_alive ]) # --- 6 + 7. damage, death, and spawns surviving the death --- var surviving_spawns: Array = spawner.spawned.duplicate() turret.take_damage(19) if not is_instance_valid(turret): return _fail("turret_spawner died from 19 damage - should take 20 to kill") turret.take_damage(1) elapsed = 0.0 stage = 6 one_spawn_ref = surviving_spawns[0] return false 6: if elapsed < 0.15: return false if is_instance_valid(turret): return _fail("turret_spawner survived 20 total damage") if not is_instance_valid(one_spawn_ref): return _fail("a spawn was freed when its spawner died - they are siblings, not descendants") if not one_spawn_ref.has_capability("pursuit"): return _fail("a surviving spawn lost its pursuit component - it should still chase the fleet") print("TEST_TURRET_SPAWNER_ENEMY: PASS") quit(0) return true return false func _fail(message: String) -> bool: print("TEST_TURRET_SPAWNER_ENEMY: FAIL (%s)" % message) quit(1) return true