spaceship/tests/test_sniper_enemy.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

230 lines
8.6 KiB
GDScript

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
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 6)
main = load("res://world/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://enemies/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://enemies/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