2026-08-15 21:23:01 +02:00
|
|
|
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
|
2026-09-05 17:09:27 +02:00
|
|
|
# #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)
|
#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
|
|
|
main = load("res://world/Main.tscn").instantiate()
|
2026-08-15 21:23:01 +02:00
|
|
|
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]
|
|
|
|
|
|
#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
|
|
|
mover = load("res://enemies/Enemy.tscn").instantiate()
|
2026-08-15 21:23:01 +02:00
|
|
|
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]
|
#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
|
|
|
sniper = load("res://enemies/Enemy.tscn").instantiate()
|
2026-08-15 21:23:01 +02:00
|
|
|
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
|