extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_artillery_enemy.gd # Verifies: # 1. the telegraph is visible (phase == TELEGRAPHING) for the whole # window before any damage lands # 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 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) var origin: Vector2 = impact_point + Vector2(400, 400) travel_time = origin.distance_to(impact_point) / 300.0 shell = load("res://ArtilleryShell.tscn").instantiate() shell.telegraph_time = 1.0 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: # Mid-telegraph: nobody hurt yet, still visibly telegraphing. if elapsed < shell.telegraph_time * 0.8: return false if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TELEGRAPHING: return _fail("shell left the telegraph phase too early") 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 telegraph window finished") # 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 re-reading shell.telegraph_time from inside # the wait condition below (evaluated every frame) would touch # a freed object on any frame after that. stage2_wait = shell.telegraph_time * 0.2 + travel_time + 1.5 elapsed = 0.0 stage = 2 return false 2: # Enough time for the remaining telegraph, 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.telegraph_time = 0.3 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. stage5_wait = art_component.telegraph_time + (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