2026-08-15 21:22:10 +02:00
|
|
|
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:
|
#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
|
|
|
CAPTURE_POINT_SCRIPT = load("res://surface/CapturePoint.gd")
|
|
|
|
|
PLANET_SETUP_SCRIPT = load("res://surface/PlanetSetup.gd")
|
2026-08-15 21:22:10 +02:00
|
|
|
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
|
#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
|
|
|
var host1: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
|
2026-08-15 21:22:10 +02:00
|
|
|
host1.apply_setup(fake_setup)
|
|
|
|
|
game_state.mark_point_captured(restore_planet_id, 0) # simulates a completed prior visit
|
|
|
|
|
|
#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
|
|
|
var host2: Node = load("res://surface/PlanetSetupHost.tscn").instantiate()
|
2026-08-15 21:22:10 +02:00
|
|
|
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
|
|
|
|
|
|
#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:22:10 +02:00
|
|
|
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()
|
|
|
|
|
|
#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
|
|
|
point = load("res://surface/CapturePoint.tscn").instantiate()
|
2026-08-15 21:22:10 +02:00
|
|
|
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")
|
2026-08-15 22:47:44 +02:00
|
|
|
if point.z_index >= 0:
|
|
|
|
|
return _fail("capture point does not draw behind the fleet/enemies (z_index=%d)" % point.z_index)
|
2026-08-15 21:22:10 +02:00
|
|
|
|
|
|
|
|
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
|