#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
230 lines
8.6 KiB
GDScript
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
|