extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_sniper_enemy.gd # Verifies: # 1. against a stationary fleet, a sniper closes to roughly its standoff # component's preferred_range and never reaches contact range # 2. its ranged weapon only fires within fire_range, and respects cooldown # 3. the projectile is slow enough that a ship moving away right after the # shot clears the impact point unharmed # 4. a hit (ship held in place) destroys an unshielded ship and is # absorbed by a shielded one, losing exactly one charge # # Movement (1) and combat (2-4) use SEPARATE sniper instances: a stationary # ship left in weapon range for many seconds would otherwise take stray # hits during the movement check and die before the later stages run, which # is a test-harness artifact, not anything about the sniper's own design. var game_state: Node var main: Node var fleet: Node var mover: Node # movement-only: its ranged_weapon is removed var sniper: Node # combat-only: pinned in place via speed = 0 var target_ship: Node var interceptor: Node var shield_ship: Node var min_distance: float = INF var cooldown_watermark: float = 0.0 var projectiles_before: int = 0 var stage: int = 0 var elapsed: float = 0.0 var settle_elapsed: float = 0.0 func _initialize() -> void: 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 _projectile_count() -> int: return get_nodes_in_group("enemy_projectiles").size() 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() fleet.spawn_ships(1, Vector2.ZERO) target_ship = fleet.ships[0] mover = load("res://Enemy.tscn").instantiate() mover.enemy_id = "sniper" mover.position = target_ship.position + Vector2(1000, 0) main.add_child(mover) mover.get_component("ranged_weapon").queue_free() elapsed = 0.0 settle_elapsed = 0.0 stage = 1 return false 1: # --- standoff: close in, never reach contact, settle near # preferred_range ----------------------------------------- settle_elapsed += delta min_distance = minf(min_distance, mover.position.distance_to(target_ship.position)) if min_distance < 100.0: return _fail("sniper reached near-contact range (%.1f) against a stationary fleet" % min_distance) if settle_elapsed < 8.0: return false var standoff: Node = mover.get_component("standoff") var final_distance: float = mover.position.distance_to(target_ship.position) var preferred: float = standoff.preferred_range print("min_distance=%.1f final_distance=%.1f preferred=%.1f" % [min_distance, final_distance, preferred]) if final_distance < preferred * 0.6 or final_distance > preferred * 1.4: return _fail("sniper did not settle near its preferred_range (final=%.1f preferred=%.1f)" % [ final_distance, preferred ]) mover.queue_free() # --- fire range gating: a fresh, pinned sniper out of range --- fleet.spawn_ships(1, target_ship.position + Vector2(-2000, 0)) interceptor = fleet.ships[0] sniper = load("res://Enemy.tscn").instantiate() sniper.enemy_id = "sniper" main.add_child(sniper) sniper.speed = 0.0 sniper.position = interceptor.position + Vector2(2000, 0) # beyond fire_range projectiles_before = _projectile_count() elapsed = 0.0 stage = 2 return false 2: var ranged: Node = sniper.get_component("ranged_weapon") if elapsed < ranged.fire_cooldown + 0.5: return false if _projectile_count() != projectiles_before: return _fail("sniper fired while beyond fire_range") # --- move into range: it should fire --- sniper.position = interceptor.position + Vector2(400, 0) projectiles_before = _projectile_count() elapsed = 0.0 stage = 3 return false 3: if elapsed < 0.4: return false if _projectile_count() <= projectiles_before: return _fail("sniper did not fire once in range") # The already-fired shot is still travelling toward interceptor's # stationary position and would otherwise connect and vanish - # sidestep it (staying within fire_range, so the next stage's # wait for a fresh shot still has a target). Also re-pin # move_target to match - otherwise Ship's own steering # immediately drives it back toward the stale target. interceptor.position += Vector2(0, 150) interceptor.move_target = interceptor.position # --- cooldown: fire_timer must not jump back up before it's # counted all the way down, which is what a second shot would # do. Read from the component's own state rather than inferring # it from projectile counts against a wall-clock wait: physics # ticks can run in catch-up bursts relative to this SceneTree's # own _process() under device load, so an EnemyProjectile's age # and this script's `elapsed` are not guaranteed to stay in # lockstep even with --fixed-fps. cooldown_watermark = sniper.get_component("ranged_weapon").fire_timer elapsed = 0.0 stage = 4 return false 4: # fire_timer can never be caught externally at exactly <= 0: the # component decrements-checks-refires atomically in one tick, so # by the time this poll runs it has either not reached zero yet # or has already fired and jumped back up to fire_cooldown. A # RISE from the last observed value is therefore the reliable # externally-visible signal for "it just fired again" - within # the first 0.6s that is a bug (still cooling down from the # first shot), afterward it is the legitimate next shot to dodge. var current: float = sniper.get_component("ranged_weapon").fire_timer if current > cooldown_watermark: if elapsed < 0.6: return _fail("sniper's cooldown timer jumped back up (a second shot fired) before it finished counting down") interceptor.position += Vector2(0, 900) # perpendicular, well clear interceptor.move_target = interceptor.position elapsed = 0.0 stage = 6 return false cooldown_watermark = current if elapsed > 6.0: return _fail("sniper never fired its next shot") return false 6: # Give the projectile its full lifetime to have resolved either way. if elapsed < 3.5: return false if not is_instance_valid(interceptor): return _fail("a slow sniper shot still hit a ship that moved away immediately") print("dodge held: interceptor survived after moving off the impact point") # --- direct hit: ship held in place, unshielded then shielded --- interceptor.position = sniper.position + Vector2(300, 0) interceptor.move_target = interceptor.position fleet.add_ship(GameState.VESSEL_SHIELD) shield_ship = fleet.ships[fleet.ships.size() - 1] shield_ship.position = Vector2(20000, 0) # far from the line of fire shield_ship.move_target = shield_ship.position elapsed = 0.0 stage = 7 return false 7: if elapsed < 0.3: return false if not shield_ship.has_shield(): return _fail("a fresh shield vessel has no shield") elapsed = 0.0 stage = 8 return false 8: if is_instance_valid(interceptor): if elapsed > 4.0: return _fail("held interceptor was never hit by the sniper") return false print("stationary unshielded interceptor destroyed by sniper fire") shield_ship.position = sniper.position + Vector2(300, 0) shield_ship.move_target = shield_ship.position elapsed = 0.0 stage = 9 return false 9: if elapsed < 4.0: return false if not is_instance_valid(shield_ship): return _fail("a shielded ship was destroyed by sniper fire") if shield_ship.shield_charges != 0: return _fail("shield charges did not drop to 0 after absorbing one hit (charges=%d)" % shield_ship.shield_charges) print("shielded ship absorbed a sniper hit and lost exactly one charge") print("TEST_SNIPER_ENEMY: PASS") quit(0) return true return false func _fail(message: String) -> bool: print("TEST_SNIPER_ENEMY: FAIL (%s)" % message) quit(1) return true