extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_enemy_mothership.gd # Forgejo #53: a big, slow capital enemy that spawns chasers/artillery on # their own intervals and fires a heavily telegraphed line beam. # # Split into two independent parts, each with its own fleet setup: # A. EnemyBigLaserBeam mechanics, tested directly (not through the # component's own tick/cooldown timing, which would make the precise # geometry assertions below timing-fragile): # 1. the telegraph holds for the charge - no damage, phase stays # CHARGING, for most of charge_time # 2. the locked aim direction never changes even if the fleet moves # away mid-charge # 3. three ships in a line all die from one shot; a ship that steps # outside beam_half_width before the shot lands survives # 4. a shielded ship in the beam survives, losing exactly one charge # B. The composed enemy_mothership definition, tested through a real # Enemy.tscn instance: # 5. structural composition - pursuit, two DISTINCT spawner presets # (chaser/artillery), big_laser, no type switch anywhere # 6. it launches both chaser and artillery spawns on their own # intervals # 7. it closes on the fleet without ever exceeding its own defined # speed - "never reaches ramming speed" # # Repo-wide lessons from #52, both apply again here: # - main.get_node(...).set_process(false) has to happen in _process()'s # first real tick, not _initialize() - a disable made before the node # has finished entering the tree doesn't reliably stick. # - overriding an @export interval on an already-ticking component does # NOT reset its in-progress countdown - reset the timer variable too. var game_state: Node var main: Node var fleet: Node var beam_script: Script var beam: Node2D var line_a: Node var line_b: Node var line_c: Node var shield_ship: Node var initial_aim: Vector2 var mothership: Node var chaser_spawner: Node var artillery_spawner: Node var mothership_start_position: Vector2 var closing_elapsed: float = 0.0 var stage: int = 0 var elapsed: float = 0.0 func _initialize() -> void: game_state = root.get_node("GameState") beam_script = load("res://enemies/EnemyBigLaserBeam.gd") # Four vessels are crewed below, past BASE_FLEET_SIZE's 3 slots, so the # capacity doctrines are granted rather than assumed. # #219: berths come from Fleet Offices now, not from research. Three of # them buy the same four-vessel fleet the doctrines used to. Left # unstocked deliberately - upkeep is not charged for the first # 25 seconds, and nothing here runs that long. for _berth in range(3): game_state.add_building(GameState.BUILDING_FLEET_OFFICE, Vector2(3000.0 + 400.0 * float(_berth), 3000.0)) 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 main.get_node("EnemySpawner").set_process(false) main.get_node("AsteroidField").set_process(false) main.get_node("BuildController").set_process(false) for ship in fleet.ships: if is_instance_valid(ship): ship.queue_free() fleet.ships.clear() # --- A: line-up three ships along +X, one shielded further out --- fleet.add_ship(game_state.VESSEL_INTERCEPTOR) line_a = fleet.ships[fleet.ships.size() - 1] _pin(line_a, Vector2(300, 0)) fleet.add_ship(game_state.VESSEL_INTERCEPTOR) line_b = fleet.ships[fleet.ships.size() - 1] _pin(line_b, Vector2(400, 0)) fleet.add_ship(game_state.VESSEL_INTERCEPTOR) line_c = fleet.ships[fleet.ships.size() - 1] _pin(line_c, Vector2(500, 0)) fleet.add_ship(game_state.VESSEL_SHIELD) shield_ship = fleet.ships[fleet.ships.size() - 1] _pin(shield_ship, Vector2(600, 0)) if not shield_ship.has_shield(): return _fail("shield ship has no shield right after being added") var beam_scene: PackedScene = load("res://enemies/EnemyBigLaserBeam.tscn") beam = beam_scene.instantiate() beam.charge_time = 0.5 beam.beam_range = 1000.0 beam.beam_half_width = 35.0 beam.damage = 3 root.add_child(beam) beam.launch(Vector2.ZERO, Vector2(1, 0)) initial_aim = beam.aim_direction elapsed = 0.0 stage = 1 return false 1: # --- 1. telegraph holds, no damage, for most of the charge --- if elapsed < beam.charge_time * 0.6: return false if beam.phase != beam_script.Phase.CHARGING: return _fail("beam left CHARGING before charge_time elapsed") for ship in [line_a, line_b, line_c, shield_ship]: if not is_instance_valid(ship): return _fail("a ship in the beam's path died before the charge finished") # Move line_b out of the beam's path - this alone also shifts # fleet.get_centroid() (line_b is part of the same fleet), which # is exactly the "the fleet moved mid-charge" event stage 2 # needs to prove the beam does NOT re-track. line_a/line_c/ # shield_ship stay exactly where they were. _pin(line_b, Vector2(400, 400)) elapsed = 0.0 stage = 2 return false 2: if elapsed < beam.charge_time * 0.6: return false if beam.aim_direction != initial_aim: return _fail("beam re-tracked the fleet mid-charge - aim_direction changed from %s to %s" % [ initial_aim, beam.aim_direction ]) elapsed = 0.0 stage = 3 return false 3: # --- 3 + 4. the shot has landed by now --- if is_instance_valid(line_a): return _fail("line_a (still in the beam) survived the shot") if is_instance_valid(line_c): return _fail("line_c (still in the beam) survived the shot") if not is_instance_valid(line_b): return _fail("line_b (moved out of the beam before it fired) was killed anyway") if not is_instance_valid(shield_ship): return _fail("the shielded ship died despite having a shield") if shield_ship.shield_charges != 0: return _fail("shield charges did not drop to exactly 1 lost (charges=%d)" % shield_ship.shield_charges) # --- B: reset to a clean single-ship fleet for the mothership --- for ship in fleet.ships: if is_instance_valid(ship): ship.queue_free() fleet.ships.clear() fleet.spawn_ships(1, Vector2(3000, 0)) mothership = load("res://enemies/Enemy.tscn").instantiate() mothership.enemy_id = "enemy_mothership" mothership.position = Vector2.ZERO main.add_child(mothership) elapsed = 0.0 stage = 4 return false 4: if elapsed < 0.15: return false # --- 5. structural composition, no type switch --- if mothership.max_hp != 60: return _fail("enemy_mothership max_hp is %d, expected 60" % mothership.max_hp) # Compared against the CHASER rather than a hardcoded 35.0. What # matters is that this thing lumbers - "never reaches ramming # speed", per the note at the top of this file - and that is a # statement about the two relative to each other. The literal was # a hidden dependency on one balance pass: #88 raised every speed # in the game by 1.5 and this failed while the mothership was # still exactly as slow, relatively, as it had always been. var definitions: Script = load("res://enemies/EnemyDefinitions.gd") var chaser_speed: float = float(definitions.get_definition("chaser").speed) if mothership.speed >= chaser_speed * 0.5: return _fail("enemy_mothership moves at %s against a chaser's %s - it is meant to lumber" \ % [mothership.speed, chaser_speed]) if not mothership.has_capability("pursuit"): return _fail("enemy_mothership has no pursuit component - it should close on the fleet") if not mothership.has_capability("big_laser"): return _fail("enemy_mothership has no big_laser component") var spawners: Array = [] for child in mothership.get_children(): if child.get("capability_id") == "spawner": spawners.append(child) if spawners.size() != 2: return _fail("enemy_mothership has %d spawner components, expected 2 (chaser + artillery)" % spawners.size()) for spawner in spawners: if spawner.spawned_enemy_id == "chaser": chaser_spawner = spawner elif spawner.spawned_enemy_id == "artillery": artillery_spawner = spawner if chaser_spawner == null or artillery_spawner == null: return _fail("the two spawner components are not one chaser + one artillery preset") # Speed up both intervals for the test, resetting the in-progress # timer too (#52's lesson: changing the interval alone does not # touch a countdown already running from the definition default). # Staggered slightly rather than both at 0.0: two sibling # spawners both instantiating Enemy.tscn on the exact same # physics frame hit a transient PackedScene.instantiate() # failure ("node count is 0") on this engine build - spreading # their first fire across different frames avoids it, and is # also what naturally happens in real play anyway. chaser_spawner.spawn_interval = 0.2 chaser_spawner.spawn_timer = 0.0 artillery_spawner.spawn_interval = 0.3 artillery_spawner.spawn_timer = 0.05 mothership_start_position = mothership.position elapsed = 0.0 closing_elapsed = 0.0 stage = 5 return false 5: # --- 6. launches both chasers and artillery on their own intervals --- closing_elapsed += delta if elapsed < 1.2: return false if chaser_spawner.spawned.is_empty(): return _fail("mothership never spawned a chaser") if artillery_spawner.spawned.is_empty(): return _fail("mothership never spawned an artillery") for spawn in chaser_spawner.spawned: if spawn.enemy_id != "chaser": return _fail("a chaser-spawner spawn has enemy_id '%s'" % spawn.enemy_id) for spawn in artillery_spawner.spawned: if spawn.enemy_id != "artillery": return _fail("an artillery-spawner spawn has enemy_id '%s'" % spawn.enemy_id) # --- 7. closes on the fleet, never exceeding its own speed --- var traveled: float = mothership.position.distance_to(mothership_start_position) var max_possible: float = mothership.speed * closing_elapsed + 5.0 # small integration slack if traveled > max_possible: return _fail("mothership traveled %.1f in %.2fs, more than speed(%.1f) allows (%.1f)" % [ traveled, closing_elapsed, mothership.speed, max_possible ]) var distance_to_fleet: float = mothership.position.distance_to(fleet.get_centroid()) var starting_distance: float = mothership_start_position.distance_to(Vector2(3000, 0)) if distance_to_fleet >= starting_distance: return _fail("mothership did not close on the fleet at all (distance %.1f >= starting %.1f)" % [ distance_to_fleet, starting_distance ]) print("TEST_ENEMY_MOTHERSHIP: PASS") quit(0) return true return false func _pin(ship: Node, at: Vector2) -> void: ship.position = at ship.move_target = at func _fail(message: String) -> bool: print("TEST_ENEMY_MOTHERSHIP: FAIL (%s)" % message) quit(1) return true