spaceship/tests/test_capture_points.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

212 lines
8 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_capture_points.gd
# Verifies:
# 1. PlanetSetupHost restores an already-captured point immediately on
# (re-)composition, instead of restarting its fill
# 2. a ship parked in range with no enemies nearby fills a point to
# CAPTURED in about capture_time seconds, and GameState reflects it
# (captured_point_count, is_planet_conquered - which is what
# BuildController's extractor gate reads, see test_planet_conquest_gate)
# 3. an enemy within enemy_clear_radius blocks further progress
# (state == BLOCKED) without erasing what was already filled
# 4. moving out of range drains progress gradually, not instantly, and
# never below what a completed point already holds
# 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 - and CapturePoint.gd
# references GameState in a function body, which GDScript resolves at
# compile time. preload()ing it from inside _initialize() defers that
# until after the engine has finished booting, same as every plain load()
# call elsewhere in this file.
var CAPTURE_POINT_SCRIPT: Script
var PLANET_SETUP_SCRIPT: Script
var game_state: Node
var main: Node
var fleet: Node
var point: Node
var enemy: Node
var planet_id: String = "test_capture_planet"
var stage: int = 0
var elapsed: float = 0.0
var progress_before_block: float = 0.0
var progress_before_drain: float = 0.0
func _initialize() -> void:
CAPTURE_POINT_SCRIPT = load("res://surface/CapturePoint.gd")
PLANET_SETUP_SCRIPT = load("res://surface/PlanetSetup.gd")
game_state = root.get_node("GameState")
game_state.planet_capture.clear()
# --- 1. restoration: an already-captured point stays captured -----
var restore_planet_id: String = "test_capture_restore_planet"
var fake_setup: Resource = PLANET_SETUP_SCRIPT.new()
fake_setup.setup_id = "test_restore"
fake_setup.surface_profile_id = "rocky"
fake_setup.capture_points = PackedVector2Array([Vector2(0, 0), Vector2(500, 0)])
game_state.planet_id = restore_planet_id
var host1: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
host1.apply_setup(fake_setup)
game_state.mark_point_captured(restore_planet_id, 0) # simulates a completed prior visit
var host2: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
host2.apply_setup(fake_setup)
var restored: Node = null
var fresh: Node = null
for child in host2.get_children():
if child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 0:
restored = child
elif child.get_script() == CAPTURE_POINT_SCRIPT and child.point_index == 1:
fresh = child
if restored == null or restored.state != CAPTURE_POINT_SCRIPT.State.CAPTURED or restored.progress < 0.999:
return _fail("re-landing did not restore an already-captured point")
if fresh == null or fresh.state == CAPTURE_POINT_SCRIPT.State.CAPTURED:
return _fail("an uncaptured point came back already CAPTURED")
# --- 2-4. capture mechanics, in a live scene with a real fleet -----
game_state.planet_capture.clear()
game_state.planet_id = planet_id
game_state.register_capture_points(planet_id, 1)
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 200
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()
point = load("res://surface/CapturePoint.tscn").instantiate()
point.point_index = 0
point.capture_time = 1.0
point.capture_radius = 200.0
point.enemy_clear_radius = 300.0
point.drain_rate = 0.5
point.position = Vector2(4000, 0) # well clear of spawn-time activity
main.add_child(point)
var far_away: Vector2 = point.position + Vector2(2500, 0)
for ship in fleet.ships:
ship.position = far_away
fleet.move_to(far_away)
elapsed = 0.0
stage = 1
return false
1:
# Out of range: no progress at all.
if elapsed < 0.3:
return false
if point.progress > 0.001 or point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
return _fail("progress rose while no ship was in capture_radius")
for ship in fleet.ships:
ship.position = point.position
fleet.move_to(point.position)
elapsed = 0.0
stage = 2
return false
2:
# Partway through capture_time: rising, not yet complete.
if elapsed < point.capture_time * 0.4:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURING or point.progress <= 0.05:
return _fail("point did not start filling with a ship in range (state=%d progress=%.2f)" % [
point.state, point.progress
])
# An enemy shows up within enemy_clear_radius: must block further
# fill without erasing what's already there. A bare "enemies"
# group member (not a real Enemy.tscn) is deliberate: CapturePoint
# only cares that something is in the group and in range, and
# this avoids a real chaser/weapon exchange destroying the fleet
# ships this test also needs alive in later stages.
enemy = Node2D.new()
enemy.add_to_group("enemies")
enemy.position = point.position + Vector2(50, 0)
main.add_child(enemy)
progress_before_block = point.progress
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.5:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.BLOCKED:
return _fail("point did not report BLOCKED with an enemy nearby (state=%d)" % point.state)
if point.progress > progress_before_block + 0.01:
return _fail("progress rose while an enemy was inside enemy_clear_radius")
# Enemy leaves and the ship backs off out of range: progress
# should drain, but gradually - not snap to zero.
enemy.queue_free()
var far_away: Vector2 = point.position + Vector2(2500, 0)
for ship in fleet.ships:
ship.position = far_away
fleet.move_to(far_away)
progress_before_drain = point.progress
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.3:
return false
if point.progress >= progress_before_drain - 0.001:
return _fail("progress did not drain while out of range")
if point.progress <= 0.001:
return _fail("progress dropped to zero instantly instead of draining gradually")
if point.state != CAPTURE_POINT_SCRIPT.State.NEUTRAL:
return _fail("point state did not return to NEUTRAL while draining out of range")
# Move back in and let it finish this time.
for ship in fleet.ships:
ship.position = point.position
fleet.move_to(point.position)
elapsed = 0.0
stage = 5
return false
5:
# Draining first means completion now takes longer than a bare
# capture_time - give it a generous margin.
if elapsed < point.capture_time * 2.0 + 0.5:
return false
if point.state != CAPTURE_POINT_SCRIPT.State.CAPTURED:
return _fail("point never reached CAPTURED (state=%d progress=%.2f after %.2fs)" % [
point.state, point.progress, elapsed
])
if game_state.captured_point_count(planet_id) != 1:
return _fail("GameState.captured_point_count did not record the capture")
# is_planet_conquered is exactly what BuildController's extractor
# gate reads (see test_planet_conquest_gate.gd) - not re-tested
# through the build UI here to avoid duplicating that coverage.
if not game_state.is_planet_conquered(planet_id):
return _fail("single-point planet was not marked conquered")
if point.z_index >= 0:
return _fail("capture point does not draw behind the fleet/enemies (z_index=%d)" % point.z_index)
print("TEST_CAPTURE_POINTS: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_CAPTURE_POINTS: FAIL (%s)" % message)
quit(1)
return true