extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_artillery_enemy.gd # Verifies: # 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it # is launched - and the warning is the shell's flight plus the impact # cross drawn at its target, not a charge-up before anything happens # 2. a ship that leaves the blast radius before impact takes no damage; # one that stays takes the full hit # 3. a shielded ship inside the blast survives, losing exactly one charge # 4. the impact point is fixed at fire time - moving the fleet after the # shot is launched does not redirect it (tested through the real # EnemyArtilleryComponent, since that's what could tempt "aim at the # fleet's live position" instead of its position at fire time) # Resolved via load() inside _initialize(), NOT a top-level `const ... # preload(...)` - a top-level preload in a script-mode ENTRY point (-s # this_file.gd) evaluates during this file's own early compilation, which # can happen before autoloads are registered. See test_capture_points.gd # for the full explanation. var ARTILLERY_SHELL_SCRIPT: Script var game_state: Node var main: Node var fleet: Node var shell: Node var ship_inside: Node var ship_leaves: Node var shield_ship: Node var far_ship: Node var artillery: Node var art_component: Node var live_shell: Node var recorded_target: Vector2 = Vector2.ZERO var impact_point: Vector2 = Vector2.ZERO var travel_time: float = 0.0 var origin: Vector2 = Vector2.ZERO var stage2_wait: float = 0.0 var stage5_wait: float = 0.0 var stage: int = 0 var elapsed: float = 0.0 func _initialize() -> void: ARTILLERY_SHELL_SCRIPT = load("res://ArtilleryShell.gd") game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() game_state.transporters.clear() game_state.stored_vessels.clear() game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 main = load("res://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) for a in get_nodes_in_group("asteroids"): a.queue_free() impact_point = Vector2(5000, 0) fleet.spawn_ships(1, impact_point) ship_inside = fleet.ships[0] ship_inside.position = impact_point fleet.add_ship(GameState.VESSEL_INTERCEPTOR) ship_leaves = fleet.ships[fleet.ships.size() - 1] ship_leaves.position = impact_point + Vector2(100, 0) fleet.add_ship(GameState.VESSEL_SHIELD) shield_ship = fleet.ships[fleet.ships.size() - 1] shield_ship.position = impact_point + Vector2(-100, 0) origin = impact_point + Vector2(400, 400) travel_time = origin.distance_to(impact_point) / 300.0 shell = load("res://ArtilleryShell.tscn").instantiate() shell.shell_speed = 300.0 shell.aoe_radius = 220.0 shell.damage = 3 main.add_child(shell) shell.launch(origin, impact_point) elapsed = 0.0 stage = 1 return false 1: # The shell must already be travelling - there is no charge-up # phase to sit in any more - and it must have moved off the # tower under its own speed. if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING: return _fail("the shell is not in the air on the frame after firing") if elapsed < travel_time * 0.4: return false if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point): return _fail("the shell has not closed any distance on its target") # The warning the player actually gets: a cross on the ground at # the point of impact. Drawing cannot be asserted headlessly, so # this checks what it is drawn FROM - the aim point is fixed and # the marker has a real size. if not shell.target.is_equal_approx(impact_point): return _fail("the impact marker would not be drawn at the point the shell was aimed at") if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0: return _fail("the impact cross has no size, so nothing marks the landing point") if not is_instance_valid(ship_inside) or not is_instance_valid(ship_leaves) \ or not is_instance_valid(shield_ship): return _fail("damage landed before the shell arrived") # Ship B clears the blast radius now, well before impact. ship_leaves.position = impact_point + Vector2(2000, 0) # Captured once, as a plain float: the shell frees itself once # it resolves, so reading off it from inside the wait condition # below (evaluated every frame) would touch a freed object on # any frame after that. stage2_wait = travel_time + 1.5 elapsed = 0.0 stage = 2 return false 2: # Enough time for the remaining travel and the full explosion # visual to resolve. if elapsed < stage2_wait: return false if is_instance_valid(ship_inside): return _fail("a ship that stayed inside the blast radius survived") if not is_instance_valid(ship_leaves): return _fail("a ship that left the blast radius before impact was still hit") if not is_instance_valid(shield_ship): return _fail("a shielded ship inside the blast was destroyed instead of absorbing the hit") if shield_ship.shield_charges != 0: return _fail("shield charges did not drop to 0 after absorbing the artillery hit (charges=%d)" % shield_ship.shield_charges) print("stayed=destroyed left=survived shielded=absorbed-one-charge") # --- Part B: the impact point does not home onto a fleet that # moves after the shot is fired --------------------------------- fleet.spawn_ships(1, Vector2(9000, 0)) far_ship = fleet.ships[0] artillery = load("res://Enemy.tscn").instantiate() artillery.enemy_id = "artillery" artillery.position = far_ship.position + Vector2(300, 0) main.add_child(artillery) art_component = artillery.get_component("artillery") art_component.cooldown_left = 0.0 art_component.fire_cooldown = 999.0 # only the one shot for this check elapsed = 0.0 stage = 3 return false 3: if elapsed < 0.15: return false # The shell is added to the tree root (a sibling of main, not # its child - see EnemyArtilleryComponent.tick()), so it has to # be found via its group, not main.get_children(). var shells: Array = get_nodes_in_group("artillery_shells") if shells.is_empty(): return _fail("artillery enemy did not fire when in range and off cooldown") live_shell = shells[0] recorded_target = live_shell.target far_ship.position += Vector2(4000, 0) # flee well clear of the recorded target elapsed = 0.0 stage = 4 return false 4: # Check the target field WHILE the shell is still alive - it # frees itself right after exploding, so this has to happen # before that, not after. if elapsed < 0.3: return false if not is_instance_valid(live_shell): return _fail("shell resolved implausibly fast - timing assumption broke") if live_shell.target != recorded_target: return _fail("shell target changed after the fleet moved - it homed instead of staying fixed") # Captured once, as a plain float: far_ship's own weapon can # destroy the artillery enemy (and with it art_component) well # before this wait ends, so the condition below must not keep # re-reading a property off a node that might already be freed. # No charge-up to wait out any more - just flight time plus the # explosion visual. stage5_wait = (300.0 / 180.0) + 1.5 elapsed = 0.0 stage = 5 return false 5: if elapsed < stage5_wait: return false if not is_instance_valid(far_ship): return _fail("fleeing ship was hit by a shot aimed at its old position - homing occurred") print("shell held its fire-time target and did not home onto the fleeing ship") print("TEST_ARTILLERY_ENEMY: PASS") quit(0) return true return false func _fail(message: String) -> bool: print("TEST_ARTILLERY_ENEMY: FAIL (%s)" % message) quit(1) return true