spaceship/tests/test_artillery_enemy.gd
Loïc Wisniewski 2f161621c3 #84: move 215 root files into a folder hierarchy
Everything lived in one flat directory, so a diff gave no clue what area it
touched and finding the file you wanted meant knowing its exact name. Twelve
folders now, grouped by what a thing IS rather than what type it is - a
vessel's definition, its component scenes and its upgrade resources sit
together, because that is the set you read when you change one of them.

  buildings  core  controllers  enemies  logistics  planets
  resources  shaders  surface  ui  vessels  vfx  world

332 files moved and every res:// reference rewritten - 306 files touched,
including the autoload and main-scene paths in project.godot. Nothing renamed:
this is purely where files sit, so the diff is a pile of pure moves and no
behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at
the root, where Godot and the export preset expect them.

Verified three ways, because a suite alone would not have caught all of it:
the full 117 pass, the web export builds clean, and the game renders.

The export is why that matters. The FIRST export after the move failed with
"Attempt to open script 'res://GameState.gd'" - the old path - while
project.godot on disk plainly said res://core/GameState.gd. That is this
project's documented Android FUSE mtime trap: Godot served a cached copy of a
file whose content had changed. A `touch` fixed it. Nothing in the test suite
would have found that, because the suite runs from a copy on a native
filesystem - the one place the bug cannot happen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00

209 lines
8 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_artillery_enemy.gd
# Verifies:
# 1. the tower fires IMMEDIATELY - the shell is in the air on the frame it
# is launched - and the warning is the shell's flight plus the impact
# cross drawn at its target, not a charge-up before anything happens
# 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 origin: Vector2 = Vector2.ZERO
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://enemies/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://world/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)
origin = impact_point + Vector2(400, 400)
travel_time = origin.distance_to(impact_point) / 300.0
shell = load("res://enemies/ArtilleryShell.tscn").instantiate()
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:
# The shell must already be travelling - there is no charge-up
# phase to sit in any more - and it must have moved off the
# tower under its own speed.
if shell.phase != ARTILLERY_SHELL_SCRIPT.Phase.TRAVELING:
return _fail("the shell is not in the air on the frame after firing")
if elapsed < travel_time * 0.4:
return false
if shell.shell_pos.distance_to(shell.target) >= origin.distance_to(impact_point):
return _fail("the shell has not closed any distance on its target")
# The warning the player actually gets: a cross on the ground at
# the point of impact. Drawing cannot be asserted headlessly, so
# this checks what it is drawn FROM - the aim point is fixed and
# the marker has a real size.
if not shell.target.is_equal_approx(impact_point):
return _fail("the impact marker would not be drawn at the point the shell was aimed at")
if ARTILLERY_SHELL_SCRIPT.IMPACT_CROSS_RADIUS <= 0.0:
return _fail("the impact cross has no size, so nothing marks the landing point")
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 shell arrived")
# 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 reading off it from inside the wait condition
# below (evaluated every frame) would touch a freed object on
# any frame after that.
stage2_wait = travel_time + 1.5
elapsed = 0.0
stage = 2
return false
2:
# Enough time for the remaining 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://enemies/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.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.
# No charge-up to wait out any more - just flight time plus the
# explosion visual.
stage5_wait = (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