spaceship/tests/test_lancer_enemy.gd

273 lines
11 KiB
GDScript3
Raw Normal View History

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_lancer_enemy.gd
# Forgejo #96, the lancer: a ramming enemy that announces itself.
#
# "A new enemy vessel tries to suicide itself like the default vessel.
# Though this new enemy starts a bit far from the target, lock it and
# wait a bit. A small back and forth announce its departure to its
# target, in a straight line, quickly. 300ms before it actually starts
# going, a small line goes from it to its target to announce its
# trajectory."
#
# Covers:
# A. it is in the catalog, composes a lunge component and nothing that
# would fight it over position, and rams like the chaser does
# B. from beyond lock_range it does NOT charge - it closes slowly, so the
# player sees it coming
# C. once locked it holds station for the wind-up and rocks back and forth
# along the charge axis instead of drifting toward the target
# D. the trajectory line comes up during the wind-up and lasts about
# 300ms - not the whole wind-up, and not nothing
# E. the charge is straight, fast, and aimed at where the target WAS: a
# ship that moves after the lock is missed, which is what makes the
# telegraph worth reading
# F. it dies on contact, taking the vessel with it
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger and nothing to prepare for. Enemies now arrive in waves: a long quiet, a warning, an attack, and quiet again once the wave is dead. The clock lives in GameState, because Main.tscn is destroyed on every trip to a planet surface and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
# G. a wave can actually field one, and still fields chasers - a new
# enemy nobody meets is not a new enemy
var game_state: Node
var main: Node
var fleet: Node
var lancer: Node2D
var target_ship: Node2D
var victim: Node2D
var stage: int = 0
var elapsed: float = 0.0
var lunge: Node
var approach_start: float = 0.0
var windup_seen: bool = false
var windup_anchor: Vector2 = Vector2.ZERO
var max_sway: float = 0.0
var max_lateral: float = 0.0
var telegraph_time_seen: float = 0.0
var charge_start: Vector2 = Vector2.ZERO
var dodged_to: Vector2 = Vector2.ZERO
var charge_locked: Vector2 = Vector2.ZERO
var max_off_axis: float = 0.0
var charge_seen: bool = false
var destroyed_fired: bool = false
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
func _spawn_lancer(at: Vector2) -> Node2D:
var enemy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
enemy.enemy_id = "lancer"
enemy.position = at
main.add_child(enemy)
return enemy
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
if is_instance_valid(hazard):
hazard.queue_free()
# --- A. catalogued, composed, and a rammer -------------------
var definitions: Script = load("res://enemies/EnemyDefinitions.gd")
var definition: Resource = definitions.get_definition("lancer")
if definition.enemy_id != "lancer":
return _fail("EnemyDefinitions has no \"lancer\" - get_definition fell back")
if not bool(definition.dies_on_contact):
return _fail("the lancer is not marked dies_on_contact, so it is not a rammer")
fleet.spawn_ships(1, Vector2.ZERO, PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
target_ship = fleet.ships[0]
# Ship steers toward move_target every frame; pinning it there is
# how a vessel is held still (see test_sniper_enemy.gd).
target_ship.move_target = target_ship.position
lancer = _spawn_lancer(target_ship.global_position + Vector2(1300.0, 0.0))
lunge = lancer.get_component("lunge")
if lunge == null:
return _fail("the lancer composed no lunge component")
if lancer.has_capability("pursuit") or lancer.has_capability("standoff"):
return _fail("the lancer also carries a mover that would fight the lunge for position")
if 1300.0 <= lunge.lock_range:
return _fail("the test spawns inside lock_range, so case B proves nothing")
print("lancer: hp %d, lock_range %.0f, windup %.2fs, telegraph %.2fs" % [
lancer.max_hp, lunge.lock_range, lunge.windup_time, lunge.telegraph_time])
approach_start = lancer.global_position.distance_to(target_ship.global_position)
elapsed = 0.0
stage = 1
return false
1:
# --- B. it approaches rather than charging from out there ----
if elapsed < 0.6:
if lunge.phase == lunge.Phase.CHARGE:
return _fail("the lancer charged from beyond its lock_range")
return false
var closed: float = approach_start - lancer.global_position.distance_to(
target_ship.global_position)
if closed <= 1.0:
return _fail("the lancer never closed on the fleet from %.0f away" % approach_start)
# The stalk must be slower than the charge, or the "starts a bit
# far, waits" reading is lost.
var approach_speed: float = closed / elapsed
if approach_speed >= lunge.charge_speed * 0.5:
return _fail("it approached at %.0fpx/s, barely short of its %.0fpx/s charge" % [
approach_speed, lunge.charge_speed])
print("closed %.0fpx in %.1fs (%.0fpx/s) without charging" % [
closed, elapsed, approach_speed])
elapsed = 0.0
stage = 2
return false
2:
# --- C/D. wind-up: rocks in place, and puts the line up late --
if lunge.phase == lunge.Phase.WINDUP:
if not windup_seen:
windup_seen = true
windup_anchor = lunge.charge_anchor
charge_locked = lunge.locked_point
var offset: Vector2 = lancer.global_position - windup_anchor
max_sway = maxf(max_sway, absf(offset.dot(lunge.charge_direction)))
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
max_lateral = maxf(max_lateral, absf(offset.dot(across)))
if lunge.is_telegraphing():
telegraph_time_seen += delta
if dodged_to == Vector2.ZERO:
# What the warning is FOR: the moment the line shows,
# the ship steps off it. The lock is already frozen,
# so this must not drag the charge along with it.
var sidestep: Vector2 = Vector2(-lunge.charge_direction.y,
lunge.charge_direction.x) * 400.0
target_ship.position += sidestep
target_ship.move_target = target_ship.position
dodged_to = target_ship.position
return false
if not windup_seen:
if elapsed > 6.0:
return _fail("the lancer never entered a wind-up within 6s of locking range")
return false
if lunge.phase != lunge.Phase.CHARGE:
return _fail("the wind-up ended in phase %d rather than a charge" % lunge.phase)
if max_sway < 1.0:
return _fail("the lancer sat perfectly still through the wind-up - no back and forth")
if max_sway > lunge.windup_sway + 1.0:
return _fail("it moved %.0fpx along the charge axis, more than the %.0fpx sway - that is departing early" % [
max_sway, lunge.windup_sway])
if max_lateral > 1.0:
return _fail("it wandered %.0fpx sideways during the wind-up instead of holding station" % max_lateral)
print("wind-up: rocked %.0fpx back and forth, %.0fpx sideways" % [max_sway, max_lateral])
# 300ms of line, give or take the frames either side of the
# threshold. Both bounds matter: a line up for the whole wind-up
# is not a 300ms warning either.
if absf(telegraph_time_seen - lunge.telegraph_time) > 0.1:
return _fail("the trajectory line showed for %.2fs, not the %.2fs it promises" % [
telegraph_time_seen, lunge.telegraph_time])
if lunge.telegraph_time >= lunge.windup_time:
return _fail("the line covers the whole wind-up, so it announces nothing")
print("trajectory line up for %.2fs of the %.2fs wind-up" % [
telegraph_time_seen, lunge.windup_time])
charge_start = lancer.global_position
elapsed = 0.0
stage = 3
return false
3:
# --- E. straight, fast, and at the locked point --------------
if lunge.phase == lunge.Phase.CHARGE:
charge_seen = true
var travel: Vector2 = lancer.global_position - charge_start
var across: Vector2 = Vector2(-lunge.charge_direction.y, lunge.charge_direction.x)
max_off_axis = maxf(max_off_axis, absf(travel.dot(across)))
return false
if not charge_seen:
return _fail("the charge was over before a single frame of it was observed")
if dodged_to == Vector2.ZERO:
return _fail("the trajectory line never showed, so nothing was dodged")
if not is_instance_valid(target_ship) or target_ship.is_queued_for_deletion():
return _fail("the ship stepped off the telegraphed line and was hit anyway")
if max_off_axis > 1.0:
return _fail("the charge bent %.0fpx off its locked axis - it homed instead of flying straight" % max_off_axis)
var covered: float = charge_start.distance_to(lancer.global_position)
var speed: float = covered / maxf(elapsed, 0.001)
if speed < lunge.charge_speed * 0.8:
return _fail("the charge averaged %.0fpx/s against a stated %.0fpx/s" % [
speed, lunge.charge_speed])
# It aimed at the locked point, not at wherever the ship is now.
var passed_by: float = (charge_locked - target_ship.global_position).length()
if passed_by < 200.0:
return _fail("the charge followed the ship as it dodged - the lock is not frozen")
print("charged %.0fpx at %.0fpx/s, %.0fpx off axis, locked point %.0fpx from the ship" % [
covered, speed, max_off_axis, passed_by])
lancer.queue_free()
# --- F. contact kills both ----------------------------------
fleet.spawn_ships(1, Vector2(4000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
victim = fleet.ships[0]
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.2:
return false
lancer = _spawn_lancer(victim.global_position)
lancer.destroyed.connect(func(): destroyed_fired = true)
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.5:
return false
if is_instance_valid(lancer) and not lancer.is_queued_for_deletion():
return _fail("the lancer survived ramming a vessel")
if not destroyed_fired:
return _fail("the lancer vanished without firing `destroyed` - not routed through take_damage")
if is_instance_valid(victim) and not victim.is_queued_for_deletion():
return _fail("the rammed vessel survived, so the lancer cost nothing to stop")
print("lancer and vessel destroyed each other")
#100 (#97b): waves replace the roaming enemy spawner The old spawner put one enemy on the map every 3-6 seconds for ever. There was no rhythm to the danger and nothing to prepare for. Enemies now arrive in waves: a long quiet, a warning, an attack, and quiet again once the wave is dead. The clock lives in GameState, because Main.tscn is destroyed on every trip to a planet surface and a wave must not be cancelled, nor restarted from zero, by a landing. The spawning does not: GameState cannot see the world, so EnemySpawner reads the phase and puts the enemies in it. That also settles what happens when a wave turns while the player is on a surface - the wave is waiting in space when they come back. EnemySpawner keeps its name deliberately. It is still the thing that puts enemies in the world, and 88 test files already say `get_node("EnemySpawner").set_process(false)` to ask for a quiet map. Strength is a function of the FLEET, per the ticket, with the wave number as a slower second term so a player who never grows still meets escalation. The budget is spent most-expensive-first, so a large wave is heavier rather than merely longer, and min_strength keeps the first waves to chasers whatever the arithmetic says. "No timer should be visible" is enforced rather than merely observed: GameState offers wave_warning_pulse(), a 0..1 throb, and no remaining-seconds accessor at all, so the alert cannot drift into being a countdown. The test pins both - that no such accessor exists, and that the alert's own text contains no digits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 13:37:42 +02:00
# --- G. waves can field one ---------------------------------
# This originally checked the roaming spawner's share of lancers.
# #100 removed that spawner entirely: the way a lancer reaches
# the player now is a wave, so that is what has to be able to
# produce one - and it must not have displaced the chaser while
# doing it.
var kinds: Dictionary = {}
var lancer_at: int = 0
for size in range(1, 12):
for id in game_state.wave_composition(size):
kinds[id] = true
if id == "lancer" and lancer_at == 0:
lancer_at = size
if lancer_at == 0:
return _fail("no wave against a fleet of 1..11 contains a lancer")
if not kinds.has("chaser"):
return _fail("lancers displaced the chaser out of every wave")
print("waves field lancers from a fleet of %d up, alongside %s" \
% [lancer_at, str(kinds.keys())])
print("TEST_LANCER_ENEMY: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_LANCER_ENEMY: FAIL (%s)" % message)
quit(1)
return true