Initial commit: SpaceshipTest, a top-down space RTS in Godot 4.7.1

A fleet of neon-outlined ships explores a procedurally generated, seeded
infinite world: tap/joystick steering, pinch zoom, formation pivoting,
planet landings, an ore economy with extractors and haulers, and player
built structures (mothership, ship crafter, command centre).

All art is hand-drawn with draw_* calls rather than textures, and the
backgrounds are camera-relative shaders so they stay precise at large
world coordinates.

Includes a 35-test headless suite (tests/run_tests.sh) plus screenshot
tooling that renders real frames through Xvfb and Mesa softpipe, since
--headless has no GPU.

Excluded from version control: .godot/ (import cache, and it holds
export credentials), builds/, android/build/ and packaged APKs - all
regenerable, and ~325 MB combined.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-10 18:41:58 +02:00
commit 85814ce88c
192 changed files with 9602 additions and 0 deletions

View file

@ -0,0 +1,31 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
# build menu and shows a building's info popup, so the panel layout can be
# checked with real text in it. Uses the mothership-already-exists case on
# purpose: that is the longest message any option produces, so if it fits,
# the others do.
#
# bash tests/screenshot.sh res://tests/_build_popup_preview.tscn /tmp/out.png 40
const SHOW_POPUP: bool = true
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.rocks = 500
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-900.0, 0.0))
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
var controller: Node = main.get_node("BuildController")
controller._on_build_pressed()
# Flip SHOW_POPUP to check the menu underneath
# instead of the popup that covers it.
if SHOW_POPUP:
controller._on_place_mothership()

View file

@ -0,0 +1 @@
uid://wlijuo1j14d3

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_build_popup_preview.gd" id="1"]
[node name="BuildPopupPreview" type="Node2D"]
script = ExtResource("1")

41
tests/_crafter_preview.gd Normal file
View file

@ -0,0 +1,41 @@
extends Node2D
# Preview scene for eyeballing building art - NOT run by run_tests.sh
# (underscore prefix). Lays out the mothership and command centre alongside
# several phases of the ship crafter's assembly cycle, with the crafter's
# _process disabled and anim_time pinned, so a single softpipe render shows
# the whole animation at once (the renderer is far too slow here to time a
# live frame).
#
# bash tests/screenshot.sh res://tests/_crafter_preview.tscn /tmp/out.png 20
# Each cell is either a building type (drawn once) or a crafter phase.
const CELLS: Array = [
{"type": "mothership"},
{"type": "command_center"},
{"type": "mothership"},
{"phase": 0.10},
{"phase": 0.35},
{"phase": 0.60},
{"phase": 0.88},
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
var building_scene: PackedScene = load("res://Building.tscn")
for i in range(CELLS.size()):
var cell: Dictionary = CELLS[i]
var b: Node2D = building_scene.instantiate()
b.building_type = cell.get("type", GameState.BUILDING_SHIP_CRAFTER)
b.position = Vector2(-240.0 + float(i % 3) * 240.0, -105.0 + float(i / 3) * 215.0)
add_child(b)
if cell.has("phase"):
b.set_process(false)
b.anim_time = float(cell.phase) * b.CYCLE
b.queue_redraw()
var cam: Camera2D = Camera2D.new()
cam.zoom = Vector2(1.65, 1.65)
add_child(cam)
cam.make_current()

View file

@ -0,0 +1 @@
uid://ha7uto42cl6w

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_crafter_preview.gd" id="1"]
[node name="CrafterPreview" type="Node2D"]
script = ExtResource("1")

39
tests/_font_probe.gd Normal file
View file

@ -0,0 +1,39 @@
extends SceneTree
# Diagnostic - NOT run by run_tests.sh (underscore prefix). Prints what the
# project's font settings actually resolve to at runtime, and measures real
# string widths, so UI boxes can be sized from measurements instead of
# guessed character widths.
#
# godot --headless -s res://tests/_font_probe.gd
func _initialize() -> void:
print("gui/theme/default_font_size setting = %s" % ProjectSettings.get_setting("gui/theme/default_font_size", "<unset>"))
print("gui/theme/custom = '%s'" % ProjectSettings.get_setting("gui/theme/custom", ""))
print("gui/theme/custom_font = '%s'" % ProjectSettings.get_setting("gui/theme/custom_font", ""))
var scene: Node = load("res://Main.tscn").instantiate()
root.add_child(scene)
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
"UI/BuildMenu/MothershipLabel",
"UI/BuildMenu/TitleLabel",
"UI/NavLabel"]:
var label: Label = scene.get_node_or_null(path)
if label == null:
print("%s: MISSING" % path)
continue
# The theme item is "font_size"; "font" is the typeface item. Asking
# for the wrong one silently returns the engine fallback (16) and
# hides both the project setting and any per-node override.
var size: int = label.get_theme_font_size("font_size")
var font: Font = label.get_theme_font("font")
var sample: String = "Fleet home port. A wiped-out fleet rebuilds here."
var width: float = font.get_string_size(sample, HORIZONTAL_ALIGNMENT_LEFT, -1, size).x
print("%-46s effective size=%d box=%s 48-char sample is %.0fpx wide (%.1f px/char), line height %.0f" % [
path, size, label.size, width, width / float(sample.length()), font.get_height(size)
])
quit(0)

1
tests/_font_probe.gd.uid Normal file
View file

@ -0,0 +1 @@
uid://cn7j12klcr3ji

35
tests/_icon_preview.gd Normal file
View file

@ -0,0 +1,35 @@
extends Control
# Preview scene - NOT run by run_tests.sh (underscore prefix). Draws every
# IconButton glyph at the size the build menu uses, so the art can be
# checked without rendering the whole game scene.
#
# bash tests/screenshot.sh res://tests/_icon_preview.tscn /tmp/out.png 20
const BUTTON_SIZE: float = 190.0
const COLUMNS: int = 3
const SWATCHES: Array = [
{"icon": 8, "color": Color(0.45, 0.9, 0.55)}, # mothership
{"icon": 9, "color": Color(1.0, 0.72, 0.25)}, # ship crafter
{"icon": 10, "color": Color(0.55, 0.6, 1.0)}, # command centre
{"icon": 11, "color": Color(0.35, 0.9, 0.75)}, # extractor
{"icon": 11, "color": Color(0.35, 0.9, 0.75), "enabled": false},
{"icon": 5, "color": Color(0.25, 0.85, 1.0)}, # generic build, for contrast
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
for i in range(SWATCHES.size()):
var swatch: Dictionary = SWATCHES[i]
var b: IconButton = IconButton.new()
b.icon = swatch.icon
b.glow_color = swatch.color
b.enabled = swatch.get("enabled", true)
b.size = Vector2(BUTTON_SIZE, BUTTON_SIZE)
b.position = Vector2(
120.0 + float(i % COLUMNS) * (BUTTON_SIZE + 60.0),
100.0 + float(i / COLUMNS) * (BUTTON_SIZE + 60.0)
)
add_child(b)

View file

@ -0,0 +1 @@
uid://dfd33rtm5ick2

8
tests/_icon_preview.tscn Normal file
View file

@ -0,0 +1,8 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_icon_preview.gd" id="1"]
[node name="IconPreview" type="Control"]
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("1")

View file

@ -0,0 +1,27 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders the
# buildings through the REAL game path: records in GameState, spawned by
# Main.tscn's BuildingField, drawn under the game's own camera. The sibling
# _crafter_preview only proves Building.gd's art in isolation; this proves
# what actually reaches the screen in a running game.
#
# bash tests/screenshot.sh res://tests/_ingame_buildings_preview.tscn /tmp/out.png 40
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.reset_formation()
GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-230.0, 0.0))
GameState.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2(240.0, 0.0))
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("AsteroidField").set_process(false)
main.get_node("EnemySpawner").set_process(false)
# Park the fleet below the pair so the camera frames both, and zoom out
# far enough to see them whole.
main.get_node("Fleet").move_to(Vector2(0.0, 210.0))
var cam: Camera2D = main.get_node("Camera2D")
cam.zoom = Vector2(1.35, 1.35)

View file

@ -0,0 +1 @@
uid://bi1tbjjmkbjyb

View file

@ -0,0 +1,6 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://tests/_ingame_buildings_preview.gd" id="1"]
[node name="InGameBuildingsPreview" type="Node2D"]
script = ExtResource("1")

View file

@ -0,0 +1,40 @@
[remap]
importer="texture"
type="CompressedTexture2D"
uid="uid://dvk0oafywbooj"
path="res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex"
metadata={
"vram_texture": false
}
[deps]
source_file="res://tests/_lunar_check.png"
dest_files=["res://.godot/imported/_lunar_check.png-f10cc44a07f4a4fea5b976037f96d6c2.ctex"]
[params]
compress/mode=0
compress/high_quality=false
compress/lossy_quality=0.7
compress/uastc_level=0
compress/rdo_quality_loss=0.0
compress/hdr_compression=1
compress/normal_map=0
compress/channel_pack=0
mipmaps/generate=false
mipmaps/limit=-1
roughness/mode=0
roughness/src_normal=""
process/channel_remap/red=0
process/channel_remap/green=1
process/channel_remap/blue=2
process/channel_remap/alpha=3
process/fix_alpha_border=true
process/premult_alpha=false
process/normal_map_invert_y=false
process/hdr_as_srgb=false
process/hdr_clamp_exposure=false
process/size_limit=0
detect_3d/compress_to=1

53
tests/_screenshot_tool.gd Normal file
View file

@ -0,0 +1,53 @@
extends SceneTree
# Reusable screenshot tool - NOT run via run_tests.sh (underscore prefix).
# Loads a given scene, waits for a given number of frames to render, then
# saves a PNG and quits. Must be run WITHOUT --headless (that forces
# Godot's dummy/null renderer) - see tests/screenshot.sh for the full
# invocation (Xvfb + Mesa softpipe; llvmpipe's JIT crashes with SIGILL in
# this environment, softpipe's non-JIT rasterizer does not).
#
# Args (after `--`): <scene_res_path> <output_png_path> [wait_frames]
var wait_frames: int = 60
var frames: int = 0
var scene_path: String
var output_path: String
var loaded_scene: Node
func _initialize() -> void:
var args: PackedStringArray = OS.get_cmdline_user_args()
if args.size() < 2:
printerr("usage: -- <scene_res_path> <output_png_path> [wait_frames]")
quit(1)
return
scene_path = args[0]
output_path = args[1]
if args.size() >= 3:
wait_frames = int(args[2])
var packed: PackedScene = load(scene_path)
if packed == null:
printerr("could not load scene: %s" % scene_path)
quit(1)
return
loaded_scene = packed.instantiate()
root.add_child(loaded_scene)
func _process(_delta: float) -> bool:
frames += 1
if frames < wait_frames:
return false
var img: Image = root.get_viewport().get_texture().get_image()
var err: int = img.save_png(output_path)
if err != OK:
printerr("save_png failed with error %d" % err)
quit(1)
return true
print("SCREENSHOT_SAVED: %s (%s)" % [output_path, str(img.get_size())])
quit(0)
return true

View file

@ -0,0 +1 @@
uid://dbf48h8dn1m84

20
tests/run_tests.sh Normal file
View file

@ -0,0 +1,20 @@
#!/data/data/com.termux/files/usr/bin/bash
# Runs the headless GDScript gameplay tests from Termux, via the Godot
# 4.7.1 Linux arm64 build installed inside the proot-distro Ubuntu container.
# Usage: bash tests/run_tests.sh
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
status=0
for test_script in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$test_script")"
echo "=== Running $name ==="
if ! proot-distro login ubuntu -- bash -c "cd '$PROJECT_DIR' && '$GODOT_BIN' --headless -s res://tests/$name"; then
status=1
fi
echo
done
exit $status

View file

@ -0,0 +1 @@
uid://dxlt7qjmxbgi4

View file

@ -0,0 +1 @@
uid://dej0ijrlaa72o

View file

@ -0,0 +1 @@
uid://cd8yndydui6ml

38
tests/screenshot.sh Normal file
View file

@ -0,0 +1,38 @@
#!/data/data/com.termux/files/usr/bin/bash
# Renders a real screenshot of a Godot scene from Termux, via the Godot
# 4.7.1 Linux arm64 build inside the proot-distro Ubuntu container, using
# Xvfb (virtual display) + Mesa's softpipe software rasterizer.
#
# NOTE: Mesa's default software rasterizer, llvmpipe, crashes with
# SIGILL in this environment (confirmed via feasibility spike - its LLVM
# JIT appears to emit a CPU instruction this ARM64/proot/Android setup
# doesn't actually support, reproduced even with an empty project and no
# script). softpipe is Mesa's older non-JIT (pure C) rasterizer - slower,
# but doesn't hit this crash. Good enough for one-shot screenshots.
#
# Usage: bash tests/screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]
# Example: bash tests/screenshot.sh res://Main.tscn /tmp/out.png 60 720x1280
#
# The resolution matters for anything with UI in it: the game is authored
# for a 720x1280 portrait viewport, so rendering at the default landscape
# size stretches the layout and makes it useless for judging whether
# controls fit. Pass 720x1280 when checking UI.
set -e
PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
SCENE_PATH="${1:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
OUTPUT_PATH="${2:?usage: screenshot.sh <scene_res_path> <output_png_path> [wait_frames] [WxH]}"
WAIT_FRAMES="${3:-60}"
RESOLUTION="${4:-1280x720}"
proot-distro login ubuntu -- bash -c "
Xvfb :99 -screen 0 ${RESOLUTION}x24 &
XVFB_PID=\$!
sleep 2
mkdir -p /tmp/xdgrt && chmod 700 /tmp/xdgrt
cd '$PROJECT_DIR'
DISPLAY=:99 XDG_RUNTIME_DIR=/tmp/xdgrt GALLIUM_DRIVER=softpipe '$GODOT_BIN' --rendering-driver opengl3 --rendering-method gl_compatibility --resolution '$RESOLUTION' -s res://tests/_screenshot_tool.gd -- '$SCENE_PATH' '$OUTPUT_PATH' '$WAIT_FRAMES'
kill \$XVFB_PID
"

View file

@ -0,0 +1,120 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_animation_continuity.gd
# Regression test for the mothership's animation jumping.
#
# The reactor pulse and the orbiting satellites used to be driven from
# anim_time, which wraps at CYCLE (3.6s) because the CRAFTER's assembly
# sequence is expressed as a fraction of it. Neither of them completes a
# whole number of periods in 3.6s, so every wrap made them leap - the
# satellites snapped back 66 degrees, once every cycle, forever.
#
# Verifies, by stepping a mothership across two full wraps of the assembly
# clock in small frames:
# 1. the satellites keep advancing - no frame turns them backwards
# 2. no frame moves them much more than a steady frame would
# 3. the core pulse never jumps by more than a steady frame's worth
# 4. the assembly clock really does wrap during the test, so the test is
# actually exercising the boundary that used to break
const FRAME: float = 1.0 / 60.0
const FRAMES: int = 500 # 8.3s, comfortably past two wraps of 3.6s
var scene: Node
var game_state: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
var ship: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
ship = b
if ship == null:
print("TEST_ANIMATION_CONTINUITY: FAIL (mothership never spawned)")
quit(1)
return true
# Drive it by hand at a fixed frame time, so the measurement is
# not at the mercy of the headless tick rate.
ship.set_process(false)
var expected_orbit_step: float = FRAME * ship.ORBIT_SPEED
var prev_orbit: float = ship.orbit_angle()
var prev_pulse: float = ship.core_pulse()
var wrapped: bool = false
var prev_anim: float = ship.anim_time
var worst_orbit_step: float = 0.0
var min_orbit_step: float = 1e9
var worst_pulse_step: float = 0.0
# A steady frame of the pulse: derivative of a unit sine is at
# most 1, so 0.5 * speed * dt bounds one frame's change.
var expected_pulse_step: float = 0.5 * ship.PULSE_SPEED * FRAME
for i in range(FRAMES):
ship._process(FRAME)
if ship.anim_time < prev_anim:
wrapped = true
prev_anim = ship.anim_time
var orbit_step: float = ship.orbit_angle() - prev_orbit
worst_orbit_step = maxf(worst_orbit_step, orbit_step)
min_orbit_step = minf(min_orbit_step, orbit_step)
prev_orbit = ship.orbit_angle()
worst_pulse_step = maxf(worst_pulse_step, absf(ship.core_pulse() - prev_pulse))
prev_pulse = ship.core_pulse()
print("assembly clock wrapped during the run: %s" % wrapped)
print("orbit step per frame: min %.6f max %.6f rad (steady %.6f)" % [
min_orbit_step, worst_orbit_step, expected_orbit_step
])
print("worst pulse step: %.6f (steady bound %.6f)" % [
worst_pulse_step, expected_pulse_step
])
if not wrapped:
print("TEST_ANIMATION_CONTINUITY: FAIL (clock never wrapped - test proves nothing)")
quit(1)
return true
if min_orbit_step <= 0.0:
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites went backwards)")
quit(1)
return true
if worst_orbit_step > expected_orbit_step * 1.05:
print("TEST_ANIMATION_CONTINUITY: FAIL (satellites jumped forwards)")
quit(1)
return true
if worst_pulse_step > expected_pulse_step * 1.05:
print("TEST_ANIMATION_CONTINUITY: FAIL (reactor pulse jumped)")
quit(1)
return true
print("TEST_ANIMATION_CONTINUITY: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://bht2sd2fyla4a

View file

@ -0,0 +1,72 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_ship_collision.gd
# Verifies that a ship touching an asteroid destroys BOTH the ship (removed
# from the fleet) and the asteroid itself (mutual destruction), each
# spawning its own coherent explosion VFX: light-burst blue for the ship,
# debris brown for the asteroid.
const CHECK_DELAY: float = 0.05
var main_scene: Node
var fleet: Node
var target_ship: Node
var asteroid: Node2D
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# Wait one tick so Fleet._ready() has run and fleet.ships is
# populated before we grab a ship reference.
target_ship = fleet.ships[0]
var asteroid_scene: PackedScene = load("res://Asteroid.tscn")
asteroid = asteroid_scene.instantiate()
asteroid.position = target_ship.position
asteroid.velocity = Vector2.ZERO
main_scene.add_child(asteroid)
stage = 1
elapsed = 0.0
return false
1:
if elapsed < CHECK_DELAY:
return false
_check_results()
return true
return false
func _check_results() -> void:
var ship_destroyed: bool = not is_instance_valid(target_ship)
var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1
var asteroid_destroyed: bool = not is_instance_valid(asteroid)
var ship_vfx_ok: bool = false
var asteroid_vfx_ok: bool = false
for child in root.get_children():
if child is ShipExplosionVFX:
ship_vfx_ok = true
elif child is AsteroidExplosionVFX:
asteroid_vfx_ok = true
print("ship_destroyed=%s ship_removed_from_fleet=%s asteroid_destroyed=%s ship_vfx_ok=%s asteroid_vfx_ok=%s" % [
ship_destroyed, ship_removed_from_fleet, asteroid_destroyed, ship_vfx_ok, asteroid_vfx_ok
])
if ship_destroyed and ship_removed_from_fleet and asteroid_destroyed and ship_vfx_ok and asteroid_vfx_ok:
print("TEST_ASTEROID_SHIP_COLLISION: PASS")
quit(0)
else:
print("TEST_ASTEROID_SHIP_COLLISION: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://sa7a3btjwp4l

View file

@ -0,0 +1,115 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_spawning.gd
# Verifies AsteroidField spawns moving asteroids once the fleet enters an
# ASTEROID_FIELD biome chunk, and despawns them again once the fleet moves
# far enough away - guards against unbounded node accumulation over a long
# play session while still populating newly-entered regions on the fly.
var main_scene: Node
var fleet: Node
var asteroid_field: Node
var asteroid_chunk: Vector2i
var spawned_asteroids: Array = []
var elapsed: float = 0.0
var stage: int = 0
var ok: bool = true
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.biome_at_chunk(chunk) == WorldGen.Biome.ASTEROID_FIELD:
asteroid_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any ASTEROID_FIELD chunk to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
asteroid_field = main_scene.get_node("AsteroidField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# Wait one tick so Fleet._ready() has run and fleet.ships is
# populated before we try to teleport them - reposition
# attempted synchronously in _initialize() would silently
# no-op on an empty ships array.
var target_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = target_pos
fleet.move_to(target_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < asteroid_field.check_interval + 0.1:
return false
_check_spawned()
elapsed = 0.0
stage = 2
return false
2:
var far_pos: Vector2 = WorldGen.chunk_origin(asteroid_chunk) + Vector2(50000, 50000)
for ship in fleet.ships:
ship.position = far_pos
fleet.move_to(far_pos)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < asteroid_field.check_interval + 0.1:
return false
_check_despawned()
return true
return false
func _check_spawned() -> void:
var has_chunk: bool = asteroid_field.populated_chunks.has(asteroid_chunk)
if not has_chunk:
ok = false
print("FAIL: asteroid_chunk %s not populated after check_interval" % asteroid_chunk)
return
spawned_asteroids = asteroid_field.populated_chunks[asteroid_chunk]
var count_ok: bool = spawned_asteroids.size() == asteroid_field.asteroids_per_chunk
var moving_ok: bool = true
for asteroid in spawned_asteroids:
if asteroid.velocity.length() <= 0.1:
moving_ok = false
ok = ok and count_ok and moving_ok
print("spawned_count=%d expected=%d moving_ok=%s" % [spawned_asteroids.size(), asteroid_field.asteroids_per_chunk, moving_ok])
func _check_despawned() -> void:
var chunk_removed: bool = not asteroid_field.populated_chunks.has(asteroid_chunk)
var all_freed: bool = true
for asteroid in spawned_asteroids:
if is_instance_valid(asteroid):
all_freed = false
ok = ok and chunk_removed and all_freed
print("chunk_removed=%s all_freed=%s" % [chunk_removed, all_freed])
if ok:
print("TEST_ASTEROID_SPAWNING: PASS")
quit(0)
else:
print("TEST_ASTEROID_SPAWNING: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://xo183n5xkxi5

View file

@ -0,0 +1,38 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_biome_generation.gd
# Verifies WorldGen's biome-per-chunk function is deterministic (querying
# the same chunk twice gives the same biome) and that the seeded noise
# field actually produces both biome types within a reasonably large
# sampled area - a regression guard against a threshold tuned so one biome
# never appears at all.
func _initialize() -> void:
var deterministic_ok: bool = true
for i in range(20):
var chunk: Vector2i = Vector2i(i * 3 - 10, i * 2 - 5)
var a: int = WorldGen.biome_at_chunk(chunk)
var b: int = WorldGen.biome_at_chunk(chunk)
if a != b:
deterministic_ok = false
print("FAIL: chunk %s gave %d then %d" % [chunk, a, b])
var space_count: int = 0
var asteroid_count: int = 0
for x in range(-25, 25):
for y in range(-25, 25):
var biome: int = WorldGen.biome_at_chunk(Vector2i(x, y))
if biome == WorldGen.Biome.SPACE:
space_count += 1
elif biome == WorldGen.Biome.ASTEROID_FIELD:
asteroid_count += 1
print("deterministic_ok=%s space_count=%d asteroid_count=%d" % [deterministic_ok, space_count, asteroid_count])
if deterministic_ok and space_count > 0 and asteroid_count > 0:
print("TEST_BIOME_GENERATION: PASS")
quit(0)
else:
print("TEST_BIOME_GENERATION: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://mxq52bxdf0l1

View file

@ -0,0 +1,123 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_build_info_popup.gd
# Every build option explains itself before anything is spent. Picking a
# structure costs ore and, for a mothership, can destroy the one you already
# have, so no option may build straight off the menu press.
#
# Verifies, for EVERY option in the build menu:
# 1. pressing it opens the popup with that building's own title and a
# non-empty description, and shows its price
# 2. nothing is built and no ore is spent until the confirm is pressed
# 3. confirming builds exactly that structure and charges exactly its cost
# 4. closing the popup instead drops the pending build entirely - a later
# confirm press does nothing
var scene: Node
var game_state: Node
var controller: Node
var popup: Control
var title: Label
var message: Label
var cost_number: NeonNumber
var build_button: IconButton
var stage: int = 0
var elapsed: float = 0.0
# Extractor is excluded: it needs a planet underneath, which
# test_extractor_building covers in full.
const OPTIONS: Array = [
{"press": "_on_place_mothership", "type": "mothership", "title": "Mothership"},
{"press": "_on_place_crafter", "type": "ship_crafter", "title": "Ship Crafter"},
{"press": "_on_place_command_center", "type": "command_center", "title": "Command Center"},
]
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
popup = scene.get_node("UI/BuildMenu/InfoPopup")
title = popup.get_node("Panel/TitleLabel")
message = popup.get_node("Panel/MessageLabel")
cost_number = popup.get_node("Panel/CostNumber")
build_button = popup.get_node("Panel/BuildButton")
func _count(type: String) -> int:
var n: int = 0
for b in game_state.buildings:
if b.type == type:
n += 1
return n
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
for option in OPTIONS:
var type: String = option.type
var expected_cost: int = controller.cost_of(type)
var rocks_before: int = game_state.rocks
var built_before: int = _count(type)
controller._on_build_pressed()
controller.call(option.press)
var informed: bool = popup.visible \
and title.text == option.title \
and message.text.length() > 40 \
and cost_number.amount == expected_cost \
and build_button.visible
var untouched: bool = game_state.rocks == rocks_before and _count(type) == built_before
print("%s: popup=%s title='%s' cost=%d offered=%s nothing_spent_yet=%s" % [
type, popup.visible, title.text, cost_number.amount,
build_button.visible, untouched
])
if not (informed and untouched):
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not explain itself before building)" % type)
quit(1)
return true
# --- closing drops it, it must not build later ------------
controller._close_info()
controller._confirm_build()
if _count(type) != built_before or game_state.rocks != rocks_before:
print("TEST_BUILD_INFO_POPUP: FAIL (%s built after the popup was dismissed)" % type)
quit(1)
return true
# --- reopening and confirming builds it ------------------
controller._on_build_pressed()
controller.call(option.press)
controller._confirm_build()
var built: bool = _count(type) == built_before + 1
var charged: bool = game_state.rocks == rocks_before - expected_cost
print("%s: confirmed -> built=%s rocks %d->%d charged=%s" % [
type, built, rocks_before, game_state.rocks, charged
])
if not (built and charged):
print("TEST_BUILD_INFO_POPUP: FAIL (%s did not build on confirmation)" % type)
quit(1)
return true
print("TEST_BUILD_INFO_POPUP: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://mqhjheyn0hq4

View file

@ -0,0 +1,64 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_build_menu_icons.gd
# The build menu is where a structure is chosen, so each option has to look
# like the thing it builds. It used to pick from the generic icon set: the
# mothership and the command centre both drew a folded MAP glyph, the ship
# crafter drew a bare triangle and the extractor drew the same "+" as the
# button that opens the menu - so redrawing the placed buildings changed
# nothing about how they looked at the point of choosing one.
#
# Verifies, for every option in the menu:
# 1. it uses its own dedicated building glyph
# 2. no two options share an icon
# 3. `icon` is a real Icon value that _draw actually handles
var scene: Node
const EXPECTED: Dictionary = {
"MothershipButton": IconButton.Icon.MOTHERSHIP,
"CrafterButton": IconButton.Icon.CRAFTER,
"CommandButton": IconButton.Icon.COMMAND_CENTER,
"ExtractorButton": IconButton.Icon.EXTRACTOR,
}
func _initialize() -> void:
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
func _process(_delta: float) -> bool:
var menu: Node = scene.get_node("UI/BuildMenu")
var seen: Array = []
var ok: bool = true
for name in EXPECTED:
var button: IconButton = menu.get_node_or_null(name)
if button == null:
print("%s: MISSING" % name)
ok = false
continue
var expected: int = EXPECTED[name]
var matches: bool = button.icon == expected
# An out-of-range int would silently draw an empty frame, since
# _draw's match simply falls through.
var in_range: bool = button.icon >= 0 and button.icon < IconButton.Icon.size()
print("%s: icon=%d expected=%d ok=%s in_range=%s" % [
name, button.icon, expected, matches, in_range
])
if not (matches and in_range):
ok = false
if button.icon in seen:
print("%s: icon %d is already used by another option" % [name, button.icon])
ok = false
seen.append(button.icon)
if ok:
print("TEST_BUILD_MENU_ICONS: PASS")
quit(0)
else:
print("TEST_BUILD_MENU_ICONS: FAIL (build options are not using their own building glyphs)")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://c373ho7s2jyn2

View file

@ -0,0 +1,98 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_camera_settles.gd
# Regression test for a permanently-drifting camera. GameController followed
# the fleet with an exponential lerp and no snap threshold, so it approached
# the fleet centroid asymptotically and NEVER arrived - the camera kept
# moving by a fraction of a pixel every single frame, forever, even with the
# fleet completely stationary. That kept the background shaders' camera_pos
# uniform changing every frame, which made screen tearing permanently visible
# as horizontal bands of the background sliding sideways against each other.
#
# Verifies the camera reaches an EXACTLY stable position (bit-identical
# frame to frame) once the fleet has stopped, not merely a nearly-stable one.
var main_scene: Node
var fleet: Node
var camera: Camera2D
var elapsed: float = 0.0
var stage: int = 0
var last_pos: Vector2
var identical_frames: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
camera = main_scene.get_node("Camera2D")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
# Send the fleet somewhere well away from the camera so the
# follow actually has distance to close.
fleet.move_to(Vector2(1500, 900))
elapsed = 0.0
stage = 1
return false
1:
# Wait for the fleet to genuinely arrive AND the camera to close
# the remaining distance - a fixed delay is unreliable here, since
# the trip length depends on the ships' braking curve.
if elapsed > 30.0:
print("TEST_CAMERA_SETTLES: FAIL (fleet/camera never arrived)")
quit(1)
return true
for ship in fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
# Wait for the snap itself to fire, not merely for the camera to
# get close: the final approach is still an active lerp, and
# sampling during it would just measure normal convergence rather
# than the perpetual drift this test is about.
if camera.global_position.distance_to(fleet.get_centroid()) > 0.0001:
return false
last_pos = camera.global_position
identical_frames = 0
elapsed = 0.0
stage = 2
return false
2:
# With the fleet settled, the camera must be BIT-IDENTICAL frame
# to frame. Any residual drift (however small) means the lerp is
# still creeping and the tearing-visibility bug is back.
if camera.global_position == last_pos:
identical_frames += 1
else:
print("camera still drifting: %s -> %s (delta=%.9f)" % [
last_pos, camera.global_position,
last_pos.distance_to(camera.global_position)
])
print("TEST_CAMERA_SETTLES: FAIL (camera never stops moving)")
quit(1)
return true
last_pos = camera.global_position
if identical_frames < 30:
return false
var centroid: Vector2 = fleet.get_centroid()
var on_target: bool = camera.global_position.distance_to(centroid) <= 1.0
print("camera stable for %d frames at %s (fleet centroid %s)" % [
identical_frames, camera.global_position, centroid
])
if on_target:
print("TEST_CAMERA_SETTLES: PASS")
quit(0)
else:
print("TEST_CAMERA_SETTLES: FAIL (settled, but not on the fleet)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://c2it165t3pxvd

View file

@ -0,0 +1,118 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_crafter_animation.gd
# The ship crafter is drawn as a factory with a hull being assembled on the
# line. Verifies:
# 1. a crafter spawned the normal way (BuildingField, from GameState)
# actually animates - the animation is what tells the player what the
# building does. The mothership animates too (reactor core, orbiting
# satellites)
# 2. the command centre stays static, because per-frame redraws of art
# that never changes were a measurable framerate cost earlier on
# 3. the hull assembles progressively over the cycle rather than popping
# in whole, and every piece is on before the assembly phase ends
# 4. the cycle loops without anim_time growing without bound
var scene: Node
var game_state: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
var crafter: Node2D
var time_at_start: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _find(type: String) -> Node2D:
for b in field.spawned:
if is_instance_valid(b) and b.building_type == type:
return b
return null
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(500.0, 0.0))
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-500.0, 0.0))
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 500.0))
elapsed = 0.0
stage = 1
return false
1:
# Spawning is deferred, so give the tree a frame to attach them.
if elapsed < 0.2:
return false
crafter = _find(GameState.BUILDING_SHIP_CRAFTER)
if crafter == null:
print("TEST_CRAFTER_ANIMATION: FAIL (crafter never spawned)")
quit(1)
return true
var animated: bool = crafter.is_processing()
var mothership: Node2D = _find(GameState.BUILDING_MOTHERSHIP)
var mothership_animates: bool = mothership != null and mothership.is_processing()
var command: Node2D = _find(GameState.BUILDING_COMMAND_CENTER)
var command_static: bool = command != null and not command.is_processing()
print("crafter animates=%s mothership animates=%s command centre static=%s" % [
animated, mothership_animates, command_static
])
if not (animated and mothership_animates and command_static):
print("TEST_CRAFTER_ANIMATION: FAIL (wrong buildings are animating)")
quit(1)
return true
# --- the hull assembles piece by piece ------------------------
var total: int = crafter.SHIP_PARTS.size()
var counts: Array = []
for p in [0.0, 0.1, 0.35, 0.55, 0.9, 1.0]:
counts.append(crafter.attached_part_count(p))
var progressive: bool = counts[0] == 0 and counts[-1] == total
for i in range(1, counts.size()):
if counts[i] < counts[i - 1]:
progressive = false
# Nothing may still be missing when the welding phase ends, or
# the hull would launch half-built.
var complete_before_launch: bool = crafter.attached_part_count(1.0) == total
print("parts attached across the phase: %s (of %d) progressive=%s" % [
counts, total, progressive
])
if not (progressive and complete_before_launch and counts[1] < total):
print("TEST_CRAFTER_ANIMATION: FAIL (hull does not build up gradually)")
quit(1)
return true
time_at_start = crafter.anim_time
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
var advanced: bool = crafter.anim_time != time_at_start
var bounded: bool = crafter.anim_time >= 0.0 and crafter.anim_time < crafter.CYCLE
print("anim_time %.3f -> %.3f (cycle %.2f) advanced=%s bounded=%s" % [
time_at_start, crafter.anim_time, crafter.CYCLE, advanced, bounded
])
if advanced and bounded:
print("TEST_CRAFTER_ANIMATION: PASS")
quit(0)
else:
print("TEST_CRAFTER_ANIMATION: FAIL (animation clock stalled or ran away)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://qa1hoclxyhon

View file

@ -0,0 +1,65 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_collision.gd
# Verifies that an enemy touching a ship destroys that ship, is removed
# from the fleet, while the enemy itself survives the touch (enemies only
# die from being shot, not from ramming).
const CHECK_DELAY: float = 0.05
var main_scene: Node
var fleet: Node
var enemy: Node
var target_ship: Node
var elapsed: float = 0.0
var started: bool = false
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
func _physics_process(delta: float) -> bool:
if not started:
# Disabling EnemySpawner in _initialize() doesn't reliably stick -
# a node's set_process(false) call made before it has fully
# finished entering the tree can get silently reset once the
# engine's own (deferred) tree-entry finalization runs, which
# happens strictly after _ready(). Here, at the point _ready() is
# already confirmed complete, it's safe.
main_scene.get_node("EnemySpawner").set_process(false)
target_ship = fleet.ships[0]
var enemy_scene: PackedScene = load("res://Enemy.tscn")
enemy = enemy_scene.instantiate()
enemy.position = target_ship.position
main_scene.add_child(enemy)
started = true
return false
elapsed += delta
if elapsed >= CHECK_DELAY:
_finish()
return true
return false
func _finish() -> void:
var ship_destroyed: bool = not is_instance_valid(target_ship)
var ship_removed_from_fleet: bool = fleet.ships.find(target_ship) == -1
var fleet_count_ok: bool = fleet.ships.size() == 4
var enemy_survived: bool = is_instance_valid(enemy) and enemy.hp > 0
print("ship_destroyed=%s ship_removed_from_fleet=%s ships_remaining=%d enemy_survived=%s" % [
ship_destroyed, ship_removed_from_fleet, fleet.ships.size(), enemy_survived
])
if ship_destroyed and ship_removed_from_fleet and fleet_count_ok and enemy_survived:
print("TEST_ENEMY_COLLISION: PASS")
quit(0)
else:
print("TEST_ENEMY_COLLISION: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://bflxuyu0qjekl

View file

@ -0,0 +1,74 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_combat.gd
# Verifies the fleet auto-fires on an enemy within range and kills it after
# a few hits, without the enemy ever having to touch a ship, and that a
# coherent red light-burst explosion VFX spawns at the moment it dies.
const TEST_DURATION: float = 3.0
const SPAWN_OFFSET: Vector2 = Vector2(220, 0) # inside fire_range, outside collision radius
var main_scene: Node
var fleet: Node
var enemy: Node
var elapsed: float = 0.0
var started: bool = false
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
func _physics_process(delta: float) -> bool:
if not started:
# Disabling EnemySpawner in _initialize() doesn't reliably stick -
# a node's set_process(false) call made before it has fully
# finished entering the tree can get silently reset once the
# engine's own (deferred) tree-entry finalization runs, which
# happens strictly after _ready(). Here, at the point _ready() is
# already confirmed complete, it's safe.
main_scene.get_node("EnemySpawner").set_process(false)
var enemy_scene: PackedScene = load("res://Enemy.tscn")
enemy = enemy_scene.instantiate()
enemy.position = fleet.get_centroid() + SPAWN_OFFSET
main_scene.add_child(enemy)
started = true
return false
elapsed += delta
if not is_instance_valid(enemy):
_finish(true, "enemy destroyed by fleet fire after %.2fs" % elapsed)
return true
if elapsed >= TEST_DURATION:
_finish(false, "enemy still alive after %.2fs (hp=%d)" % [elapsed, enemy.hp])
return true
return false
func _finish(ok: bool, message: String) -> void:
print("ships_remaining=%d" % fleet.ships.size())
print(message)
var vfx_ok: bool = false
for child in root.get_children():
if child is EnemyExplosionVFX:
vfx_ok = true
print("enemy_vfx_ok=%s" % vfx_ok)
if ok and fleet.ships.size() == 5 and vfx_ok:
print("TEST_ENEMY_COMBAT: PASS")
quit(0)
else:
if fleet.ships.size() != 5:
print("FAIL: a ship was lost - enemy must have reached melee range instead of dying to fire")
if not vfx_ok:
print("FAIL: no matching red light-burst explosion VFX found for the destroyed enemy")
print("TEST_ENEMY_COMBAT: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://bvulrmqnxw84w

View file

@ -0,0 +1,59 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_exploration_tracker.gd
# Verifies ExplorationTracker marks the fleet's current chunk as explored
# once the fleet is there, while a distant chunk the fleet never visited
# stays unexplored - this is what lets the world map reveal only chunks
# actually seen instead of the whole generated world at once.
var main_scene: Node
var fleet: Node
var tracker: Node
var elapsed: float = 0.0
var teleported: bool = false
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
tracker = main_scene.get_node("ExplorationTracker")
func _process(delta: float) -> bool:
if not teleported:
# Wait one tick so Fleet._ready() has run and fleet.ships is
# populated before we try to teleport them - reposition attempted
# synchronously in _initialize() would silently no-op on an empty
# ships array.
var target_pos: Vector2 = Vector2(5000, -3000)
for ship in fleet.ships:
ship.position = target_pos
fleet.move_to(target_pos)
teleported = true
return false
elapsed += delta
if elapsed < 0.2:
return false
var visited_chunk: Vector2i = WorldGen.chunk_coords(Vector2(5000, -3000))
var far_chunk: Vector2i = Vector2i(9999, 9999)
var explored: Array = tracker.get_explored_chunks()
var visited_ok: bool = explored.has(visited_chunk)
var far_unexplored: bool = not explored.has(far_chunk)
print("visited_chunk=%s in_explored=%s far_chunk=%s in_explored=%s" % [
visited_chunk, visited_ok, far_chunk, not far_unexplored
])
if visited_ok and far_unexplored:
print("TEST_EXPLORATION_TRACKER: PASS")
quit(0)
else:
print("TEST_EXPLORATION_TRACKER: FAIL")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://ctwsrcwjw75ve

View file

@ -0,0 +1,161 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_extractor_building.gd
# Extractors are built from SPACE, over a planet - not from the planet's
# surface. Verifies:
# 1. the option is greyed out when no planet is in reach, but still
# pressable, and explains itself instead of silently failing
# 2. it lights up once the fleet is over a planet
# 3. its info popup names the planet and offers a confirm, and confirming
# charges the cost and registers the extractor against that planet
# 4. a second one on the same planet is refused, with no confirm offered
var main_scene: Node
var fleet: Node
var game_state: Node
var controller: Node
var button: IconButton
var popup: Control
var target_chunk: Vector2i
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: no planet chunk to test with")
quit(1)
return
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
controller = main_scene.get_node("BuildController")
button = main_scene.get_node("UI/BuildMenu/ExtractorButton")
popup = main_scene.get_node("UI/BuildMenu/InfoPopup")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
# Far from any planet: greyed, and pressing it explains why.
controller._on_build_pressed()
if button.enabled:
print("TEST_EXTRACTOR_BUILDING: FAIL (enabled with no planet in reach)")
quit(1)
return true
controller._on_place_extractor()
var explained: bool = popup.visible and "planet" in popup.get_node("Panel/MessageLabel").text.to_lower()
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("no planet: greyed=%s popup_shown=%s confirm_offered=%s" % [
not button.enabled, explained, offered
])
if not explained or offered:
print("TEST_EXTRACTOR_BUILDING: FAIL (greyed press did not explain itself, or offered a build)")
quit(1)
return true
if not game_state.extractors.is_empty():
print("TEST_EXTRACTOR_BUILDING: FAIL (built one without a planet)")
quit(1)
return true
popup.visible = false
# Fly over the planet.
var near: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = near
fleet.move_to(near)
elapsed = 0.0
stage = 1
return false
1:
var planet_field: Node = main_scene.get_node("PlanetField")
if elapsed < planet_field.check_interval + 0.2:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_EXTRACTOR_BUILDING: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
controller._on_build_pressed()
if not button.enabled:
print("TEST_EXTRACTOR_BUILDING: FAIL (still greyed while over a planet)")
quit(1)
return true
var rocks_before: int = game_state.rocks
controller._on_place_extractor()
var planet_field2: Node = main_scene.get_node("PlanetField")
var planet2: Node2D = planet_field2.populated_chunks[target_chunk][0]
# The popup must name the planet it is about to mine, and nothing
# may be spent until it is confirmed.
var names_planet: bool = planet2.planet_name in popup.get_node("Panel/MessageLabel").text
var spent_early: bool = game_state.rocks != rocks_before
print("popup: names_planet=%s confirm_offered=%s spent_before_confirm=%s" % [
names_planet, popup.get_node("Panel/BuildButton").visible, spent_early
])
if not names_planet or spent_early or not popup.get_node("Panel/BuildButton").visible:
print("TEST_EXTRACTOR_BUILDING: FAIL (popup did not offer an informed build)")
quit(1)
return true
controller._confirm_build()
var built: bool = game_state.has_extractor_at(planet2.planet_name)
var charged: bool = game_state.rocks == rocks_before - GameState.EXTRACTOR_COST
print("over planet '%s': enabled=%s built=%s rocks %d->%d charged=%s" % [
planet2.planet_name, button.enabled, built, rocks_before, game_state.rocks, charged
])
if not (built and charged):
print("TEST_EXTRACTOR_BUILDING: FAIL (did not build/charge over a planet)")
quit(1)
return true
# A second one on the same planet must be refused, not charged.
var rocks_now: int = game_state.rocks
controller._on_build_pressed()
controller._on_place_extractor()
var re_offered: bool = popup.get_node("Panel/BuildButton").visible
controller._confirm_build() # must do nothing even if pressed
var still_one: bool = game_state.extractors.size() == 1 and not re_offered
var not_charged: bool = game_state.rocks == rocks_now
print("second attempt: extractors=%d rocks=%d confirm_offered=%s refused=%s" % [
game_state.extractors.size(), game_state.rocks, re_offered, still_one and not_charged
])
if still_one and not_charged:
print("TEST_EXTRACTOR_BUILDING: PASS")
quit(0)
else:
print("TEST_EXTRACTOR_BUILDING: FAIL (allowed a duplicate extractor)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://5b2mac7kn3gs

View file

@ -0,0 +1,106 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_extractor_income.gd
# Covers the extractor's automated hauler:
# 1. income accrues with NOTHING about the route loaded (no scene showing
# it), because the simulation lives in the GameState autoload
# 2. ore is delivered exactly when the hauler reaches the mothership -
# the payout and the drawn arrival are the same event, driven by the
# same `progress` value, so they cannot drift apart
# 3. no mothership means no route and therefore no income
# 4. the hauler's drawn position tracks that progress along the route
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
var planet_pos: Vector2 = Vector2.ZERO
var rocks_before_arrival: int = 0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 0
game_state.buildings.clear()
game_state.extractors.clear()
# An extractor with NO mothership: there is nowhere to deliver, so it
# must not pay out.
game_state.add_extractor("Test Planet", planet_pos)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 1.0:
return false
if game_state.rocks != 0:
print("earned %d rocks with no mothership" % game_state.rocks)
print("TEST_EXTRACTOR_INCOME: FAIL (income without a delivery point)")
quit(1)
return true
print("no mothership -> no income (rocks=%d)" % game_state.rocks)
# Give it somewhere to deliver.
game_state.add_building(GameState.BUILDING_MOTHERSHIP, mothership_pos)
game_state.extractors[0].progress = 0.0
elapsed = 0.0
stage = 1
return false
1:
var e: Dictionary = game_state.extractors[0]
var cycle: float = game_state.get_hauler_route_time(e)
if cycle <= 0.0:
print("TEST_EXTRACTOR_INCOME: FAIL (route never established)")
quit(1)
return true
# Nothing in this scene tree draws the route at all - no Main
# scene, no HaulerField, no hauler node. Income must still tick.
if e.progress < GameState.HAULER_ARRIVAL:
rocks_before_arrival = game_state.rocks
# The drawn position must track progress along the route.
var pos: Variant = game_state.get_hauler_position(e)
var expected: Vector2 = planet_pos.lerp(mothership_pos, e.progress / GameState.HAULER_ARRIVAL)
if pos == null or pos.distance_to(expected) > 1.0:
print("hauler at %s, expected %s" % [pos, expected])
print("TEST_EXTRACTOR_INCOME: FAIL (drawn position does not follow the simulation)")
quit(1)
return true
return false
# Just crossed the arrival point: the payout must have landed on
# the very same step, and the hauler must be AT the mothership.
var paid: bool = game_state.rocks == rocks_before_arrival + GameState.HAULER_YIELD
var at_mothership: Variant = game_state.get_hauler_position(e)
var arrived: bool = at_mothership != null and at_mothership.distance_to(mothership_pos) < 40.0
print("arrival: rocks %d -> %d (paid=%s) hauler at %s (arrived=%s)" % [
rocks_before_arrival, game_state.rocks, paid, at_mothership, arrived
])
if not (paid and arrived):
print("TEST_EXTRACTOR_INCOME: FAIL (payout and arrival did not coincide)")
quit(1)
return true
rocks_before_arrival = game_state.rocks
elapsed = 0.0
stage = 2
return false
2:
# It must not pay again until it has been back and returned.
var e2: Dictionary = game_state.extractors[0]
if e2.progress > GameState.HAULER_ARRIVAL:
if game_state.rocks != rocks_before_arrival:
print("paid %d extra mid-return" % (game_state.rocks - rocks_before_arrival))
print("TEST_EXTRACTOR_INCOME: FAIL (paid while returning empty)")
quit(1)
return true
return false
# Wrapped past the planet and started a new outbound leg.
print("returned empty without paying again (rocks=%d)" % game_state.rocks)
print("TEST_EXTRACTOR_INCOME: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://doxpe0jsbc7dw

View file

@ -0,0 +1,77 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_fleet_formation.gd
# Verifies that issuing a fleet move order makes every ship converge on its
# formation slot (offset preserved) around the ordered point AND actually
# stops there - a regression guard for the arrival-overshoot bug where ships
# oscillated back and forth around the target forever instead of settling.
const CHECKPOINT_TIME: float = 4.0
const TEST_DURATION: float = 7.0
const ARRIVE_TOLERANCE: float = 2.0
const STABLE_TOLERANCE: float = 1.0
const VELOCITY_TOLERANCE: float = 1.0
var main_scene: Node
var fleet: Node
var target: Vector2 = Vector2(600.0, 300.0)
var elapsed: float = 0.0
var started: bool = false
var checkpoint_taken: bool = false
var checkpoint_positions: Array = []
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
func _physics_process(delta: float) -> bool:
if not started:
# Wait one physics tick so Fleet/Ship _ready() has run and
# formation_offsets are computed before we issue the order.
fleet.move_to(target)
started = true
return false
elapsed += delta
if not checkpoint_taken and elapsed >= CHECKPOINT_TIME:
checkpoint_taken = true
for ship in fleet.ships:
checkpoint_positions.append(ship.position)
if elapsed >= TEST_DURATION:
_finish()
return true
return false
func _finish() -> void:
var all_ok: bool = true
for i in range(fleet.ships.size()):
var ship: Node = fleet.ships[i]
var expected: Vector2 = target + fleet.formation_offsets[i]
var dist: float = ship.position.distance_to(expected)
var drift_since_checkpoint: float = ship.position.distance_to(checkpoint_positions[i])
var speed: float = ship.velocity.length()
var arrived: bool = dist <= ARRIVE_TOLERANCE
var stable: bool = drift_since_checkpoint <= STABLE_TOLERANCE
var stopped: bool = speed <= VELOCITY_TOLERANCE
var ok: bool = arrived and stable and stopped
all_ok = all_ok and ok
print("Ship %d expected=%s actual=%s dist=%.3f drift_since_t%.0fs=%.3f speed=%.3f %s" % [
i, expected, ship.position, dist, CHECKPOINT_TIME, drift_since_checkpoint, speed, "OK" if ok else "FAIL"
])
if all_ok:
print("TEST_FLEET_FORMATION: PASS (ships arrived and settled, no oscillation)")
quit(0)
else:
print("TEST_FLEET_FORMATION: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://cdn05ghel0emh

56
tests/test_icon_button.gd Normal file
View file

@ -0,0 +1,56 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_icon_button.gd
# Verifies IconButton correctly turns a touch-down + touch-up into its
# `pressed` signal, which the Map button is wired to open the world map -
# and that opening the map hides the Map button itself (avoiding it
# overlapping the Close button, which occupies the same corner).
#
# Calls _gui_input directly rather than routing a synthetic touch through
# the viewport's screen-position hit-testing, since headless mode's
# placeholder window makes exact on-screen coordinates unreliable to
# target (see test_world_map_ui.gd's pan test for the same issue) - this
# is a more targeted unit test of the button's own press logic anyway.
var main_scene: Node
var world_map: Control
var map_button: IconButton
var press_count: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
world_map = main_scene.get_node("UI/WorldMap")
map_button = main_scene.get_node("UI/MapButton")
map_button.pressed.connect(_on_pressed)
func _on_pressed() -> void:
press_count += 1
func _process(_delta: float) -> bool:
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.pressed = true
map_button._gui_input(down)
var up: InputEventScreenTouch = InputEventScreenTouch.new()
up.pressed = false
map_button._gui_input(up)
var press_fired: bool = press_count == 1
var map_opened: bool = world_map.visible
var map_button_hidden: bool = not map_button.visible
print("press_fired=%s map_opened=%s map_button_hidden=%s" % [press_fired, map_opened, map_button_hidden])
if press_fired and map_opened and map_button_hidden:
print("TEST_ICON_BUTTON: PASS")
quit(0)
else:
print("TEST_ICON_BUTTON: FAIL")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://ceqoknn4vn1n2

View file

@ -0,0 +1,56 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_icon_button_drag_cancel.gd
# Verifies IconButton only counts as pressed if the finger/cursor is still
# over the button when it lifts - dragging off the button before
# releasing (a standard way to "cancel" a touch/click) must NOT still
# trigger it, and releasing back inside the button's bounds must.
var main_scene: Node
var map_button: IconButton
var press_count: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
map_button = main_scene.get_node("UI/MapButton")
map_button.pressed.connect(_on_pressed)
func _process(_delta: float) -> bool:
var inside: Vector2 = map_button.size / 2.0
var far_outside: Vector2 = map_button.size * 10.0
# Press inside, release far outside the button's rect - must NOT fire.
_touch(true, inside)
_touch(false, far_outside)
var cancel_ok: bool = press_count == 0
print("after_drag_off press_count=%d cancel_ok=%s" % [press_count, cancel_ok])
# Press and release both inside - must fire normally.
_touch(true, inside)
_touch(false, inside)
var normal_press_ok: bool = press_count == 1
print("after_normal_press press_count=%d normal_press_ok=%s" % [press_count, normal_press_ok])
if cancel_ok and normal_press_ok:
print("TEST_ICON_BUTTON_DRAG_CANCEL: PASS")
quit(0)
else:
print("TEST_ICON_BUTTON_DRAG_CANCEL: FAIL")
quit(1)
return true
func _touch(is_pressed: bool, at_position: Vector2) -> void:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = is_pressed
ev.position = at_position
map_button._gui_input(ev)
func _on_pressed() -> void:
press_count += 1

View file

@ -0,0 +1 @@
uid://cl6hr8yuqdjvm

View file

@ -0,0 +1,134 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_joystick_control.gd
# Verifies the virtual joystick steers the fleet: holding the stick in a
# direction flies the fleet that way, and releasing brings it to a stop
# rather than freezing it mid-flight. Replaces the old tap-to-move test,
# since tapping no longer issues move orders.
#
# Touch events are handed to the joystick directly rather than through
# Input.parse_input_event: headless mode's placeholder window rescales
# synthetic touch coordinates by an environment-specific factor, which
# would land them outside the joystick's rect for reasons that have nothing
# to do with the behaviour under test.
var main_scene: Node
var fleet: Node
var joystick: TouchJoystick
var stage: int = 0
var elapsed: float = 0.0
var start_centroid: Vector2
var moving_centroid: Vector2
var stop_check_frames: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
joystick = main_scene.get_node("UI/Joystick")
func _press(local: Vector2) -> void:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.index = 0
ev.pressed = true
ev.position = joystick.global_position + local
joystick.handle_touch(ev)
func _drag(local: Vector2) -> void:
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
ev.index = 0
ev.position = joystick.global_position + local
joystick.handle_drag(ev)
func _release(local: Vector2) -> void:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.index = 0
ev.pressed = false
ev.position = joystick.global_position + local
joystick.handle_touch(ev)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
start_centroid = fleet.get_centroid()
# The joystick receives touches via GameController's
# _unhandled_input, which never fires for a Control that
# consumes them through the GUI system first. With Godot's
# default mouse_filter (STOP) the stick silently does nothing
# on a real device - it must be IGNORE. Checked structurally
# because the rest of this test drives handle_touch() directly
# and so cannot catch it.
if joystick.mouse_filter != Control.MOUSE_FILTER_IGNORE:
print("joystick mouse_filter=%d (needs IGNORE=%d)" % [
joystick.mouse_filter, Control.MOUSE_FILTER_IGNORE
])
print("TEST_JOYSTICK_CONTROL: FAIL (stick would swallow its own touches)")
quit(1)
return true
# Push the stick hard to the right.
var centre: Vector2 = joystick.size / 2.0
_press(centre)
_drag(centre + Vector2(joystick.max_radius, 0.0))
if joystick.direction.x < 0.9:
print("joystick direction wrong: %s" % joystick.direction)
print("TEST_JOYSTICK_CONTROL: FAIL (stick did not register)")
quit(1)
return true
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.5:
return false
moving_centroid = fleet.get_centroid()
var moved_right: bool = moving_centroid.x - start_centroid.x > 100.0
var stayed_level: bool = abs(moving_centroid.y - start_centroid.y) < 80.0
print("moved from %s to %s (right=%s level=%s)" % [
start_centroid, moving_centroid, moved_right, stayed_level
])
if not (moved_right and stayed_level):
print("TEST_JOYSTICK_CONTROL: FAIL (fleet did not follow the stick)")
quit(1)
return true
_release(joystick.size / 2.0)
if joystick.direction != Vector2.ZERO:
print("TEST_JOYSTICK_CONTROL: FAIL (direction not cleared on release)")
quit(1)
return true
elapsed = 0.0
stage = 2
return false
2:
# After release the fleet should coast to a halt, not freeze
# instantly and not keep flying.
if elapsed < 4.0:
return false
var speed: float = 0.0
for ship in fleet.ships:
speed = max(speed, ship.velocity.length())
var stopped: bool = speed < 1.0
var coasted: bool = fleet.get_centroid().x > moving_centroid.x
print("after release: max_speed=%.3f stopped=%s coasted_further=%s" % [
speed, stopped, coasted
])
if stopped and coasted:
print("TEST_JOYSTICK_CONTROL: PASS")
quit(0)
else:
print("TEST_JOYSTICK_CONTROL: FAIL (did not coast to a stop)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://dtgk6slv301ob

View file

@ -0,0 +1,145 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_keyboard_input.gd
# Desktop/web controls. The phone build is driven entirely by touch, but the
# web build is played with a keyboard and mouse, so WASD (and the arrows)
# must steer the fleet, Q/E must pivot the formation, and releasing the keys
# must stop - the stick has a centred-again signal to hook, the keyboard has
# nothing equivalent, so a missed release would leave the fleet flying off
# forever.
#
# Verifies:
# 1. W/A/S/D and the arrow keys each produce the right heading, and
# diagonals combine and normalise
# 2. holding a key actually moves the fleet that way
# 3. releasing every key stops it
# 4. Q and E turn the formation, in opposite directions
# 5. the on-screen stick is not taken away on a touch device
var scene: Node
var controller: Node
var fleet: Node
var joystick: Control
var stage: int = 0
var elapsed: float = 0.0
var start_centroid: Vector2 = Vector2.ZERO
var moving_centroid: Vector2 = Vector2.ZERO
func _initialize() -> void:
root.get_node("GameState").reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene
fleet = scene.get_node("Fleet")
joystick = scene.get_node("UI/Joystick")
func _key(code: int, pressed: bool) -> void:
var event: InputEventKey = InputEventKey.new()
event.keycode = code
event.pressed = pressed
controller._handle_key(event)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- headings -------------------------------------------------
var cases: Array = [
{"keys": [KEY_W], "expect": Vector2.UP},
{"keys": [KEY_S], "expect": Vector2.DOWN},
{"keys": [KEY_A], "expect": Vector2.LEFT},
{"keys": [KEY_D], "expect": Vector2.RIGHT},
{"keys": [KEY_UP], "expect": Vector2.UP},
{"keys": [KEY_RIGHT], "expect": Vector2.RIGHT},
{"keys": [KEY_W, KEY_D], "expect": Vector2(1, -1).normalized()},
{"keys": [KEY_W, KEY_S], "expect": Vector2.ZERO},
]
for case in cases:
for k in case.keys:
_key(k, true)
var got: Vector2 = controller.keyboard_direction()
var ok: bool = got.distance_to(case.expect) < 0.001
if not ok:
print("keys %s -> %s, expected %s" % [case.keys, got, case.expect])
print("TEST_KEYBOARD_INPUT: FAIL (wrong heading)")
quit(1)
return true
for k in case.keys:
_key(k, false)
print("all %d key combinations produced the right heading" % cases.size())
# The stick must survive on a touch device.
var stick_kept: bool = joystick.visible
print("stick still visible after keyboard use: %s (touchscreen=%s)" % [
stick_kept, DisplayServer.is_touchscreen_available()
])
if DisplayServer.is_touchscreen_available() and not stick_kept:
print("TEST_KEYBOARD_INPUT: FAIL (took the stick away on a touch device)")
quit(1)
return true
# --- holding a key flies the fleet ----------------------------
start_centroid = fleet.get_centroid()
_key(KEY_D, true)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.6:
return false
moving_centroid = fleet.get_centroid()
var travelled: float = moving_centroid.x - start_centroid.x
var sideways: float = absf(moving_centroid.y - start_centroid.y)
print("held D for 0.6s: moved x=%.1f y-drift=%.1f" % [travelled, sideways])
if travelled < 50.0 or sideways > 30.0:
print("TEST_KEYBOARD_INPUT: FAIL (holding a key did not fly the fleet that way)")
quit(1)
return true
# --- releasing stops ------------------------------------------
_key(KEY_D, false)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.9:
return false
var coasted: float = fleet.get_centroid().x - moving_centroid.x
var still_steering: bool = fleet.steer_direction != Vector2.ZERO
print("0.9s after release: drifted a further %.1f, still steering=%s" % [
coasted, still_steering
])
if still_steering:
print("TEST_KEYBOARD_INPUT: FAIL (fleet kept flying after the key was released)")
quit(1)
return true
# --- Q/E pivot ------------------------------------------------
var game_state: Node = root.get_node("GameState")
var before: float = game_state.formation[1].angle()
_key(KEY_E, true)
_key(KEY_E, false)
var after_cw: float = game_state.formation[1].angle()
_key(KEY_Q, true)
_key(KEY_Q, false)
var after_ccw: float = game_state.formation[1].angle()
var cw_turn: float = angle_difference(before, after_cw)
var ccw_turn: float = angle_difference(after_cw, after_ccw)
print("E turned %.3f rad, Q turned %.3f rad" % [cw_turn, ccw_turn])
if absf(cw_turn) < 0.01 or absf(ccw_turn) < 0.01 or signf(cw_turn) == signf(ccw_turn):
print("TEST_KEYBOARD_INPUT: FAIL (Q/E did not pivot in opposite directions)")
quit(1)
return true
print("TEST_KEYBOARD_INPUT: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://cdhlgvjrc41aq

View file

@ -0,0 +1,118 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_lopsided_formation_movement.gd
# Regression test for the fleet lurching the wrong way on the first frames
# of a move after the formation was edited at a command centre.
#
# Ships sit at anchor + offset, so the fleet CENTROID is anchor + the
# AVERAGE offset. Seeding the steering anchor from the centroid therefore
# displaced every slot by that average the moment the stick was pushed. The
# default wedge is balanced (average ~zero), so this was invisible; drag
# every slot into one corner and the average becomes large, and the fleet
# jerks towards that corner before the anchor catches up.
#
# Verifies, with every slot dragged to the top-right:
# 1. starting a move does NOT displace the ships' targets sideways
# 2. the fleet actually travels bottom-left from the outset - no frame in
# which the centroid moves the wrong way
# 3. releasing the stick leaves the formation origin where the ships are,
# so re-applying the layout does not teleport them
var scene: Node
var fleet: Node
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var samples: Array = []
var start_centroid: Vector2 = Vector2.ZERO
var worst_backwards: float = 0.0
const HEADING: Vector2 = Vector2(-0.7071, 0.7071) # bottom-left
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_formation()
# Every slot dragged into the top-right corner, as the command centre
# allows. The layout keeps its shape; only its centre is offset.
for i in range(game_state.formation.size()):
game_state.formation[i] += Vector2(90.0, -70.0)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
fleet.apply_formation()
elapsed = 0.0
stage = 1
return false
1:
# Let the ships settle into the lopsided layout first.
if elapsed < 1.0:
return false
start_centroid = fleet.get_centroid()
var origin_before: Vector2 = fleet.get_formation_origin()
fleet.move_in_direction(HEADING)
# Direct assertion of the bug: the anchor steering starts from
# must be the point the ships are already arranged around. Taking
# the centroid instead puts it a whole average-offset away, which
# is what yanked the fleet sideways.
var drift: float = fleet.anchor.distance_to(origin_before)
print("anchor is %.2f units from the formation origin (centroid is %.2f away)" % [
drift, fleet.get_centroid().distance_to(origin_before)
])
if drift > 1.0:
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (steering anchored off the formation)")
quit(1)
return true
elapsed = 0.0
stage = 2
return false
2:
# Watch the centroid every frame: it must make progress along the
# heading from the very first one, never against it.
var travelled: float = (fleet.get_centroid() - start_centroid).dot(HEADING)
worst_backwards = minf(worst_backwards, travelled)
samples.append(travelled)
if elapsed < 0.6:
return false
print("progress along heading: first=%.2f last=%.2f worst=%.2f" % [
samples[0], samples[-1], worst_backwards
])
if worst_backwards < -2.0:
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet moved backwards before setting off)")
quit(1)
return true
if samples[-1] < 50.0:
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (fleet did not travel along the heading)")
quit(1)
return true
# --- releasing the stick --------------------------------------
fleet.stop()
var origin: Vector2 = fleet.fleet_target
var expected: Vector2 = fleet.get_formation_origin()
var settled_ok: bool = origin.distance_to(expected) < 0.01
print("after stop: fleet_target=%s formation origin=%s ok=%s" % [
origin, expected, settled_ok
])
if settled_ok:
print("TEST_LOPSIDED_FORMATION_MOVEMENT: PASS")
quit(0)
else:
print("TEST_LOPSIDED_FORMATION_MOVEMENT: FAIL (stop left the anchor off the formation)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://dq2mnag071oth

View file

@ -0,0 +1,150 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_mothership_replacement.gd
# Only one mothership may exist. Building another replaces it, which moves
# where a wiped-out fleet respawns and re-routes every extractor hauler, so
# it must be confirmed rather than done silently. Verifies:
# 1. the first mothership's info popup carries no destruction warning, and
# confirming it builds one
# 2. with one already up, the popup DOES warn, and builds nothing until
# confirmed
# 3. dismissing the popup cancels - confirming afterwards does nothing
# 4. confirming replaces it: still exactly one, at the new position
# 5. an unaffordable replacement offers no confirm button at all, and
# cannot destroy the existing one
var main_scene: Node
var fleet: Node
var game_state: Node
var controller: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
var first_pos: Vector2 = Vector2(600.0, 200.0)
var second_pos: Vector2 = Vector2(-900.0, 400.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
controller = main_scene.get_node("BuildController")
popup = main_scene.get_node("UI/BuildMenu/InfoPopup")
func _place_fleet(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _count_motherships() -> int:
var n: int = 0
for b in game_state.buildings:
if b.type == GameState.BUILDING_MOTHERSHIP:
n += 1
return n
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
_place_fleet(first_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.2:
return false
# --- first one: informed, then built -------------------------
controller._on_build_pressed()
controller._on_place_mothership()
var first_text: String = popup.get_node("Panel/MessageLabel").text.to_lower()
var first_warned: bool = "destroy" in first_text
print("first: popup=%s warned=%s built_before_confirm=%d" % [
popup.visible, first_warned, _count_motherships()
])
if not popup.visible or first_warned or _count_motherships() != 0:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (first build warned, or built without confirming)")
quit(1)
return true
controller._confirm_build()
if _count_motherships() != 1:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (confirming the first one did not build it)")
quit(1)
return true
# --- second: warns, builds nothing ---------------------------
_place_fleet(second_pos)
controller._on_build_pressed()
controller._on_place_mothership()
var warned: bool = popup.visible and "destroy" in popup.get_node("Panel/MessageLabel").text.to_lower()
print("second press: warned=%s motherships=%d" % [warned, _count_motherships()])
if not warned or _count_motherships() != 1:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (no warning, or built without confirming)")
quit(1)
return true
# --- dismissing cancels --------------------------------------
controller._close_info()
controller._confirm_build()
print("after cancel, confirm pressed anyway: motherships=%d at %s" % [
_count_motherships(), game_state.get_mothership_position()
])
if _count_motherships() != 1 or game_state.get_mothership_position().distance_to(first_pos) > 60.0:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (cancel did not drop the pending build)")
quit(1)
return true
# --- confirming replaces -------------------------------------
var rocks_before: int = game_state.rocks
controller._on_place_mothership()
controller._confirm_build()
var only_one: bool = _count_motherships() == 1
var moved: bool = game_state.get_mothership_position().distance_to(second_pos) < 60.0
var charged: bool = game_state.rocks == rocks_before - GameState.MOTHERSHIP_COST
print("confirmed: motherships=%d moved=%s rocks %d->%d charged=%s" % [
_count_motherships(), moved, rocks_before, game_state.rocks, charged
])
if not (only_one and moved and charged):
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (replacement did not take effect cleanly)")
quit(1)
return true
# --- unaffordable replacement must not destroy the old one ---
game_state.rocks = GameState.MOTHERSHIP_COST - 1
_place_fleet(Vector2(2500.0, -800.0))
controller._on_build_pressed()
controller._on_place_mothership()
# With nothing affordable there is no confirm button to press;
# pressing it anyway must still do nothing.
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("unaffordable: confirm offered=%s" % offered)
if offered:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (offered a build it cannot pay for)")
quit(1)
return true
controller._confirm_build()
var survived: bool = _count_motherships() == 1
var still_there: bool = game_state.get_mothership_position() != null \
and game_state.get_mothership_position().distance_to(second_pos) < 60.0
print("unaffordable: motherships=%d kept_old_position=%s" % [
_count_motherships(), still_there
])
if survived and still_there:
print("TEST_MOTHERSHIP_REPLACEMENT: PASS")
quit(0)
else:
print("TEST_MOTHERSHIP_REPLACEMENT: FAIL (destroyed the old one without affording a new one)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://co6bbt3fo0b13

View file

@ -0,0 +1,70 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_no_popin_on_load.gd
# Verifies AsteroidField/PlanetField populate the area around the fleet's
# STARTING position immediately (synchronously, during Main.tscn's own
# _ready() chain), not only after their ~1s check_interval timer first
# elapses - otherwise the world visibly pops in a beat after the screen
# is already visible (most noticeable right after returning from a
# planet's surface, which is what this simulates via GameState).
var main_scene: Node
var asteroid_field: Node
var planet_field: Node
func _initialize() -> void:
var target_chunk: Vector2i = Vector2i.ZERO
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
# GameState (an autoload) isn't accessible as a bare global identifier
# from a script that extends SceneTree directly - that auto-injection
# only applies to Node-derived scripts (e.g. everything inside
# main_scene). Fetch it explicitly via the tree root instead.
var game_state: Node = root.get_node("GameState")
game_state.has_return_position = true
game_state.surviving_ship_count = 5
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
game_state.return_camera_zoom = 1.0
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
asteroid_field = main_scene.get_node("AsteroidField")
planet_field = main_scene.get_node("PlanetField")
func _process(_delta: float) -> bool:
# Check on literally the FIRST frame after the scene has entered the
# tree and _ready() chains have run - no waiting for any spawner's
# own check_interval timer.
var asteroids_populated: bool = not asteroid_field.populated_chunks.is_empty()
var planet_populated: bool = not planet_field.populated_chunks.is_empty()
print("asteroids_populated=%s (chunks=%d) planet_populated=%s (chunks=%d)" % [
asteroids_populated, asteroid_field.populated_chunks.size(),
planet_populated, planet_field.populated_chunks.size()
])
if asteroids_populated and planet_populated:
print("TEST_NO_POPIN_ON_LOAD: PASS")
quit(0)
else:
print("TEST_NO_POPIN_ON_LOAD: FAIL")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://cijpi8dij2nfo

94
tests/test_pinch_zoom.gd Normal file
View file

@ -0,0 +1,94 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_pinch_zoom.gd
# Injects synthetic two-finger touch/drag events (via Input.parse_input_event)
# to exercise the real GameController input-handling code path: spreading two
# fingers apart should zoom the camera IN (lower Camera2D.zoom value) and must
# not be misread as a single-finger fleet move order.
var main_scene: Node
var fleet: Node
var camera: Camera2D
var elapsed: float = 0.0
var stage: int = 0
var initial_zoom: float
var fleet_target_before: Vector2
var baseline_captured: bool = false
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
camera = main_scene.get_node("Camera2D")
fleet = main_scene.get_node("Fleet")
func _physics_process(delta: float) -> bool:
if not baseline_captured:
# Fleet._ready() runs on the first physics tick in `-s` script mode,
# not synchronously inside _initialize(), so capture baselines here.
initial_zoom = camera.zoom.x
fleet_target_before = fleet.fleet_target
baseline_captured = true
return false
elapsed += delta
match stage:
0:
_touch(0, true, Vector2(300, 600))
_touch(1, true, Vector2(340, 600))
stage = 1
1:
if elapsed > 0.1:
# Spread fingers apart: should zoom IN (Camera2D.zoom is a
# magnification, so zooming in means zoom.x increases), and
# must not move the fleet.
_drag(0, Vector2(100, 600))
_drag(1, Vector2(540, 600))
stage = 2
2:
if elapsed > 0.4:
_touch(0, false, Vector2(100, 600))
_touch(1, false, Vector2(540, 600))
stage = 3
3:
if elapsed > 0.7:
_finish()
return true
return false
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.index = index
ev.pressed = pressed
ev.position = pos
Input.parse_input_event(ev)
func _drag(index: int, pos: Vector2) -> void:
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
ev.index = index
ev.position = pos
Input.parse_input_event(ev)
func _finish() -> void:
var zoomed_in: bool = camera.zoom.x > initial_zoom + 0.01
var fleet_untouched: bool = fleet.fleet_target == fleet_target_before
print("initial_zoom=%.3f final_zoom=%.3f" % [initial_zoom, camera.zoom.x])
print("fleet_target_before=%s fleet_target_after=%s" % [fleet_target_before, fleet.fleet_target])
if zoomed_in and fleet_untouched:
print("TEST_PINCH_ZOOM: PASS")
quit(0)
else:
if not zoomed_in:
print("FAIL: camera did not zoom in on pinch-out gesture")
if not fleet_untouched:
print("FAIL: pinch gesture incorrectly triggered a fleet move order")
print("TEST_PINCH_ZOOM: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://c71vyg5655hdy

View file

@ -0,0 +1,153 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_pivot_controls.gd
# Covers the fleet-pivot buttons:
# 1. HOLDING one turns the formation continuously, not once per press
# 2. a brief tap still produces a clear minimum step
# 3. pivoting works while the fleet is under way, i.e. the new layout
# survives the joystick's per-frame steering updates instead of being
# overwritten by the old offsets
var scene: Node
var fleet: Node
var controller: Node
var cw: IconButton
var stage: int = 0
var elapsed: float = 0.0
var angle_at_hold_start: float = 0.0
var angle_after_hold: float = 0.0
# GameState (an autoload) is not available as a bare identifier from a
# script that extends SceneTree - fetch it via the tree root instead.
var game_state: Node
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
controller = scene
cw = scene.get_node("UI/PivotCWButton")
func _angle() -> float:
return game_state.formation[1].angle()
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# Hold the button down without releasing it.
angle_at_hold_start = _angle()
cw.pressed_down = true
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.6:
return false
angle_after_hold = _angle()
var turned: float = angle_difference(angle_at_hold_start, angle_after_hold)
# 0.6s at PIVOT_SPEED should be far beyond a single tap step.
var held_ok: bool = absf(turned) > controller.PIVOT_MIN_STEP * 1.5
print("held 0.6s -> turned %.3f rad (min step %.3f) held_ok=%s" % [
absf(turned), controller.PIVOT_MIN_STEP, held_ok
])
if not held_ok:
print("TEST_PIVOT_CONTROLS: FAIL (holding did not turn continuously)")
quit(1)
return true
cw.pressed_down = false
controller.pivot_hold_amount = 0.0
# --- brief tap ------------------------------------------------
var before_tap: float = _angle()
controller._finish_pivot_tap(1.0)
var tapped: float = absf(angle_difference(before_tap, _angle()))
var tap_ok: bool = tapped >= controller.PIVOT_MIN_STEP - 0.001
print("tap -> turned %.3f rad tap_ok=%s" % [tapped, tap_ok])
if not tap_ok:
print("TEST_PIVOT_CONTROLS: FAIL (tap did not produce the minimum step)")
quit(1)
return true
# --- pivot WHILE the joystick is held -------------------------
# Two fingers at once. This is routed through the same raw-touch
# path the real device uses, because Godot's Control GUI treats
# touch as one emulated pointer and would not deliver a second
# simultaneous finger to a button - the exact reason pivoting
# while steering used to do nothing.
var joy: TouchJoystick = scene.get_node("UI/Joystick")
var stick_down: InputEventScreenTouch = InputEventScreenTouch.new()
stick_down.index = 0
stick_down.pressed = true
stick_down.position = joy.global_position + joy.size / 2.0
controller._handle_touch(stick_down)
var stick_drag: InputEventScreenDrag = InputEventScreenDrag.new()
stick_drag.index = 0
stick_drag.position = joy.global_position + joy.size / 2.0 + Vector2(joy.max_radius, 0.0)
controller._handle_drag(stick_drag)
if joy.direction.x < 0.9:
print("TEST_PIVOT_CONTROLS: FAIL (stick did not engage)")
quit(1)
return true
# Second finger lands on the clockwise pivot button.
var pivot_down: InputEventScreenTouch = InputEventScreenTouch.new()
pivot_down.index = 1
pivot_down.pressed = true
pivot_down.position = cw.global_position + cw.size / 2.0
controller._handle_touch(pivot_down)
var both_ok: bool = cw.pressed_down and joy.direction.x > 0.9
print("simultaneous: stick=%s pivot_held=%s" % [joy.direction, cw.pressed_down])
if not both_ok:
print("TEST_PIVOT_CONTROLS: FAIL (cannot pivot while steering)")
quit(1)
return true
angle_at_hold_start = _angle()
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
# Both fingers still down: the formation must have kept turning
# while the stick kept re-issuing move orders every frame.
var turned_while_steering: float = absf(angle_difference(angle_at_hold_start, _angle()))
print("turned %.3f rad while steering" % turned_while_steering)
if turned_while_steering < 0.05:
print("TEST_PIVOT_CONTROLS: FAIL (no rotation while the stick was held)")
quit(1)
return true
# Steering is re-issued every frame; pivot in the middle of it.
fleet.move_in_direction(Vector2.RIGHT)
var before_moving_pivot: Vector2 = game_state.formation[1]
controller._pivot(PI / 2.0)
var after: Vector2 = game_state.formation[1]
fleet.move_in_direction(Vector2.RIGHT)
# The ship's target must reflect the NEW offset even though
# steering re-issued move orders straight afterwards.
var expected: Vector2 = fleet.fleet_target + after
var followed: bool = fleet.ships[1].move_target.distance_to(expected) < 0.01
var rotated: bool = after.distance_to(before_moving_pivot.rotated(PI / 2.0)) < 0.01
print("moving pivot: %s -> %s rotated=%s ship_target_ok=%s" % [
before_moving_pivot, after, rotated, followed
])
if rotated and followed:
print("TEST_PIVOT_CONTROLS: PASS")
quit(0)
else:
print("TEST_PIVOT_CONTROLS: FAIL (pivot lost while moving)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://blw63561fixnv

View file

@ -0,0 +1,260 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_atmosphere_transition.gd
# Verifies the full enter/exit atmosphere flow now that it's a genuine
# scene swap (Main.tscn <-> PlanetSurface.tscn) rather than an in-place
# overlay: standing on a planet shows the prompt, pressing it fades out
# and swaps to PlanetSurface.tscn with the SAME surviving ship count,
# where no asteroid/enemy ever exists (not hidden - structurally absent,
# since PlanetSurface.tscn has no such nodes at all) and the fleet is
# still fully flyable. Exiting fades back to Main.tscn with the fleet
# restored at the world position it left from.
var main_scene: Node
var fleet: Node
var planet_field: Node
var landing_controller: Node
var asteroid_field_ref: Node
var planet_name_label: Label
var enter_button: IconButton
var elapsed: float = 0.0
var stage: int = 0
var target_chunk: Vector2i
var position_before_leaving: Vector2
var cached_fade_duration: float = 0.4
var returned_ship_count: int = -1
var game_state: Node
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
game_state = root.get_node("GameState")
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
# change_scene_to_file() operates on current_scene - the real game
# gets this set automatically via project.godot's run/main_scene on
# boot, but a hand-built headless test harness has to do it itself.
current_scene = main_scene
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
landing_controller = main_scene.get_node("LandingController")
asteroid_field_ref = main_scene.get_node("AsteroidField")
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# target_chunk is guaranteed ASTEROID_FIELD biome (a
# prerequisite for having a planet), so regular drifting
# asteroids/enemies could also spawn nearby and interfere with
# a ship during this focused scenario - isolate it, same as
# test_enemy_combat.gd does for its own scenario.
#
# This has to happen here, not in _initialize(): a node's
# set_process(false) call made before it has fully finished
# entering the tree doesn't reliably stick - the engine's own
# (deferred) tree-entry finalization, which runs strictly
# after _ready(), can silently reset it back to enabled. Here,
# at the point _ready() is already confirmed complete
# (fleet.ships is reliably populated by now), it's safe.
asteroid_field_ref.set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
# Disabling the spawners stops NEW asteroids, but any already
# placed keep drifting under their own _process and can still
# collide with a ship mid-test. Clear the existing ones too.
for a in get_nodes_in_group("asteroids"):
a.queue_free()
# Get the fleet into the target chunk's vicinity first so
# PlanetField's spawn radius covers it - the planet's own exact
# position within the chunk is randomized, so this is only
# enough to trigger spawning, not yet close enough to land on.
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = vicinity_pos
fleet.move_to(vicinity_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.5:
return false
var prompt_ok: bool = enter_button.visible and planet_name_label.visible and planet_name_label.text != ""
print("prompt_ok=%s ship_count=%d name_text=%s" % [prompt_ok, fleet.ships.size(), planet_name_label.text])
if not prompt_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (prompt never appeared)")
quit(1)
return true
# Let the fleet finish settling onto the planet before recording
# where it left from. Capturing mid-settle made this test
# intermittently fail later: the baseline was a still-moving
# centroid, while the return always places the fleet exactly on
# the planet, so the two could differ by more than the tolerance.
for ship in fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
position_before_leaving = fleet.get_centroid()
cached_fade_duration = landing_controller.fade_duration
_press(enter_button)
elapsed = 0.0
stage = 3
return false
3:
# Fade-out + change_scene_to_file + fade-in. landing_controller
# gets freed once the scene swap completes, so the wait
# duration must be cached beforehand, not read live each frame.
if elapsed < cached_fade_duration + 0.3:
return false
var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface"
print("on_surface=%s current_scene=%s" % [on_surface, current_scene])
if not on_surface:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not swap to PlanetSurface)")
quit(1)
return true
var surface_fleet: Node = current_scene.get_node("Fleet")
var ship_count_ok: bool = surface_fleet.ships.size() == 5
var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty()
var no_enemies: bool = get_nodes_in_group("enemies").is_empty()
print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [
ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies
])
if not (ship_count_ok and no_asteroids and no_enemies):
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)")
quit(1)
return true
# Confirm the fleet is genuinely flyable on the surface.
var start_pos: Vector2 = surface_fleet.get_centroid()
surface_fleet.move_to(start_pos + Vector2(400, 0))
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 2.5:
return false
var surface_fleet: Node = current_scene.get_node("Fleet")
var moved_ok: bool = surface_fleet.get_centroid().x > 300.0
print("moved_ok=%s centroid=%s" % [moved_ok, surface_fleet.get_centroid()])
if not moved_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (fleet could not move on the surface)")
quit(1)
return true
var exit_button: IconButton = current_scene.get_node("UI/ExitButton")
var surface_controller: Node = current_scene.get_node("SurfaceController")
cached_fade_duration = surface_controller.fade_duration
_press(exit_button)
elapsed = 0.0
stage = 5
return false
5:
# Poll for the swap rather than waiting a fixed duration, so the
# ship count can be captured on the very FIRST frame back. The
# fresh Main.tscn populates asteroids during its own _ready(),
# and they can destroy a ship within a few frames - waiting out
# the fade first meant sometimes measuring a fleet that had
# already lost one to a collision, which says nothing about the
# transition being tested here.
var back_in_space: bool = current_scene != null and current_scene.name == "Main"
if not back_in_space:
if elapsed > cached_fade_duration + 3.0:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (did not return to Main)")
quit(1)
return true
return false
if returned_ship_count == -1:
print("back_in_space=%s current_scene=%s" % [back_in_space, current_scene])
# Returning builds a BRAND NEW Main.tscn, so the spawners
# silenced back in stage 0 are live again in this fresh scene.
# Silence them on the first frame back, and record the ship count
# right then: asteroids populate immediately on scene load and
# could destroy a ship during the settle wait below, which is a
# gameplay collision rather than anything about the transition
# this test is checking.
var space_fleet: Node = current_scene.get_node("Fleet")
if returned_ship_count == -1:
current_scene.get_node("AsteroidField").set_process(false)
current_scene.get_node("EnemySpawner").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
returned_ship_count = space_fleet.ships.size()
# Ships respawn in a wedge and then steer into formation, so the
# centroid only means anything once they have actually stopped.
# Sampling after a fixed delay made this test intermittently fail
# by measuring a fleet still on its way in.
for ship in space_fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
# Assert on the HANDOFF, not on how many ships survive the next
# few frames. Asteroids populate during the fresh scene's own
# _ready(), so one can spawn overlapping the returning fleet and
# destroy a ship on the first physics frame - before any test
# code can run. That is a gameplay collision, not a transition
# fault, and made this test fail roughly 1 run in 12. What the
# transition owes us is that the surviving count was carried
# across correctly and a fleet came back.
var handoff_ok: bool = game_state.surviving_ship_count == 5
var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1
var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0
print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [
game_state.surviving_ship_count, returned_ship_count,
ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving
])
if ship_count_ok and position_ok:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS")
quit(0)
else:
print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (return state wrong)")
quit(1)
return true
return false
func _press(button: IconButton) -> void:
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.pressed = true
button._gui_input(down)
var up: InputEventScreenTouch = InputEventScreenTouch.new()
up.pressed = false
button._gui_input(up)

View file

@ -0,0 +1 @@
uid://b8s3sfcv4u7qp

View file

@ -0,0 +1,122 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_identity_persists.gd
# Regression test for: leaving a planet's surface and coming back
# respawned Main.tscn's PlanetField from scratch, and since planet
# name/position/shape were picked with the unseeded global RNG at spawn
# time, the "same" planet (same chunk) would come back with a different
# name, a different position within the chunk, and a different outline
# shape every single time. Fixed by seeding planet identity purely from
# the chunk's own coordinates (mirrors WorldGen's own deterministic noise
# pattern), so this loads Main.tscn twice and checks the planet at the
# same chunk is identical both times.
var target_chunk: Vector2i
var first_name: String
var first_pos: Vector2
var first_shape: PackedVector2Array
var stage: int = 0
var main_scene: Node
var elapsed: float = 0.0
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
_load_main()
func _load_main() -> void:
# GameState (an autoload) isn't accessible as a bare global identifier
# from a script that extends SceneTree directly - fetch it via root.
var game_state: Node = root.get_node("GameState")
game_state.has_return_position = true
game_state.surviving_ship_count = 5
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
game_state.return_camera_zoom = 1.0
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
func _process(_delta: float) -> bool:
match stage:
0:
# Wait one tick for _ready() (and the forced synchronous
# update_chunks() call inside it) to have actually run.
stage = 1
return false
1:
var planet_field: Node = main_scene.get_node("PlanetField")
if not planet_field.populated_chunks.has(target_chunk):
print("FAIL: planet never spawned in target chunk on first load")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
first_name = planet.planet_name
first_pos = planet.position
first_shape = planet.shape_points.duplicate()
main_scene.queue_free()
stage = 2
return false
2:
# Give queue_free() a moment to actually clear the old tree
# before loading a fresh Main.tscn on top of it.
elapsed += 1.0
if elapsed < 3.0:
return false
_load_main()
elapsed = 0.0
stage = 3
return false
3:
stage = 4
return false
4:
var planet_field: Node = main_scene.get_node("PlanetField")
if not planet_field.populated_chunks.has(target_chunk):
print("FAIL: planet never spawned in target chunk on second load")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
var name_ok: bool = planet.planet_name == first_name
var pos_ok: bool = planet.position.distance_to(first_pos) < 0.01
var shape_ok: bool = planet.shape_points.size() == first_shape.size()
if shape_ok:
for i in range(first_shape.size()):
if planet.shape_points[i].distance_to(first_shape[i]) > 0.01:
shape_ok = false
break
print("name: %s vs %s (ok=%s)" % [first_name, planet.planet_name, name_ok])
print("pos: %s vs %s (ok=%s)" % [first_pos, planet.position, pos_ok])
print("shape_ok=%s" % shape_ok)
if name_ok and pos_ok and shape_ok:
print("TEST_PLANET_IDENTITY_PERSISTS: PASS")
quit(0)
else:
print("TEST_PLANET_IDENTITY_PERSISTS: FAIL")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://dwe8pssmin7pp

View file

@ -0,0 +1,122 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_landing_proximity.gd
# Verifies flying the fleet onto a planet's landing radius makes
# PlanetField.get_planet_fleet_is_on() return it, and that flying back
# away clears it - without the planet destroying the ships (planets are
# landable, not hazards).
var main_scene: Node
var fleet: Node
var planet_field: Node
var target_chunk: Vector2i
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# target_chunk is guaranteed ASTEROID_FIELD biome (a
# prerequisite for having a planet), so regular drifting
# asteroids/enemies could also spawn nearby and destroy a ship
# mid-test - isolate it, same as other focused tests do.
#
# This has to happen on the first tick, not in _initialize():
# a node's set_process(false) call made before it has fully
# finished entering the tree doesn't reliably stick - the
# engine's own (deferred) tree-entry finalization, which runs
# strictly after _ready(), can silently reset it back to
# enabled. Here, at the point _ready() is already confirmed
# complete (fleet.ships is reliably populated by now), it's safe.
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
# PlanetField only scans chunks near the fleet's CURRENT
# position, so it never even looks at target_chunk unless the
# fleet is already nearby - move it there first.
var near_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = near_pos
fleet.move_to(near_pos)
elapsed = 0.0
stage = 1
return false
1:
# Wait for PlanetField to spawn the target planet.
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (planet never spawned)")
quit(1)
return true
elapsed = 0.0
stage = 2
return false
2:
# Fly the fleet exactly onto the planet.
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.1:
return false
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
var query_ok: bool = planet_field.get_planet_fleet_is_on() == planet
var ships_alive: bool = fleet.ships.size() == 5
print("query_ok=%s ships_alive=%s" % [query_ok, ships_alive])
if not (query_ok and ships_alive):
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (landing detection)")
quit(1)
return true
var far_pos: Vector2 = planet.position + Vector2(5000, 5000)
for ship in fleet.ships:
ship.position = far_pos
fleet.move_to(far_pos)
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.1:
return false
var query_cleared_ok: bool = planet_field.get_planet_fleet_is_on() == null
print("query_cleared_ok=%s" % query_cleared_ok)
if query_cleared_ok:
print("TEST_PLANET_LANDING_PROXIMITY: PASS")
quit(0)
else:
print("TEST_PLANET_LANDING_PROXIMITY: FAIL (leaving detection)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://ba36mht630fb7

View file

@ -0,0 +1,69 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_map_marker_matches_spawn.gd
# The world map draws a planet marker for any explored chunk using
# WorldGen.planet_data_at_chunk(chunk) directly (so it can show a marker
# even for a chunk whose Planet node isn't currently spawned/loaded).
# This verifies that function returns exactly what PlanetField actually
# spawns, so the map marker is never in a different spot / has a
# different name than the real planet.
var main_scene: Node
var target_chunk: Vector2i
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var game_state: Node = root.get_node("GameState")
game_state.has_return_position = true
game_state.surviving_ship_count = 5
game_state.return_world_position = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
game_state.return_camera_zoom = 1.0
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
func _process(_delta: float) -> bool:
var planet_field: Node = main_scene.get_node("PlanetField")
if not planet_field.populated_chunks.has(target_chunk):
print("FAIL: planet never spawned in target chunk")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
var data: Dictionary = WorldGen.planet_data_at_chunk(target_chunk)
var expected_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + data.local_pos
var pos_ok: bool = planet.position.distance_to(expected_pos) < 0.01
var name_ok: bool = planet.planet_name == data.name
print("spawned_pos=%s expected_pos=%s pos_ok=%s" % [planet.position, expected_pos, pos_ok])
print("spawned_name=%s expected_name=%s name_ok=%s" % [planet.planet_name, data.name, name_ok])
if pos_ok and name_ok:
print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: PASS")
quit(0)
else:
print("TEST_PLANET_MAP_MARKER_MATCHES_SPAWN: FAIL")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://0cbwho5kdsiv

View file

@ -0,0 +1,123 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_prompt_map_interaction.gd
# Regression test for a real bug: EnterAtmosphereButton (shown while
# standing on a planet, bottom-center) and the world map's own
# RecenterButton (also bottom-center) occupy the exact same screen
# position. Opening the map while standing on a planet must suppress the
# enter-atmosphere prompt entirely - not just let it sit hidden behind
# the map's backdrop, since closing the map would then abruptly reveal it
# at the same instant RecenterButton disappears (which is what most
# plausibly explains "buttons blinking/disappearing").
var main_scene: Node
var fleet: Node
var planet_field: Node
var world_map: Control
var map_button: IconButton
var planet_name_label: Label
var enter_button: IconButton
var elapsed: float = 0.0
var stage: int = 0
var target_chunk: Vector2i
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
world_map = main_scene.get_node("UI/WorldMap")
map_button = main_scene.get_node("UI/MapButton")
planet_name_label = main_scene.get_node("UI/PlanetNameLabel")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = vicinity_pos
fleet.move_to(vicinity_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
for ship in fleet.ships:
ship.position = planet.position
fleet.move_to(planet.position)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.5:
return false
var prompt_shown: bool = enter_button.visible and planet_name_label.visible
print("prompt_shown_on_planet=%s" % prompt_shown)
if not prompt_shown:
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt never appeared)")
quit(1)
return true
world_map.open_map()
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.2:
return false
var prompt_suppressed: bool = not enter_button.visible and not planet_name_label.visible
print("prompt_suppressed_while_map_open=%s enter_visible=%s name_visible=%s" % [
prompt_suppressed, enter_button.visible, planet_name_label.visible
])
if not prompt_suppressed:
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt visible behind map)")
quit(1)
return true
world_map.close_map()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.2:
return false
var prompt_restored: bool = enter_button.visible and planet_name_label.visible
print("prompt_restored_after_close=%s" % prompt_restored)
if prompt_restored:
print("TEST_PLANET_PROMPT_MAP_INTERACTION: PASS")
quit(0)
else:
print("TEST_PLANET_PROMPT_MAP_INTERACTION: FAIL (prompt did not come back)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://by7edhk7h8tjg

View file

@ -0,0 +1,105 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_planet_spawning.gd
# Verifies PlanetField spawns a planet once the fleet enters a chunk where
# WorldGen says one belongs, and despawns it again once the fleet moves
# far enough away - mirrors test_asteroid_spawning.gd's lifecycle check.
var main_scene: Node
var fleet: Node
var planet_field: Node
var target_chunk: Vector2i
var spawned_planet: Node = null
var elapsed: float = 0.0
var stage: int = 0
var ok: bool = true
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
var target_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = target_pos
fleet.move_to(target_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
_check_spawned()
elapsed = 0.0
stage = 2
return false
2:
var far_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2(50000, 50000)
for ship in fleet.ships:
ship.position = far_pos
fleet.move_to(far_pos)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < planet_field.check_interval + 0.1:
return false
_check_despawned()
return true
return false
func _check_spawned() -> void:
var has_chunk: bool = planet_field.populated_chunks.has(target_chunk)
if not has_chunk:
ok = false
print("FAIL: target_chunk %s not populated after check_interval" % target_chunk)
return
var planets: Array = planet_field.populated_chunks[target_chunk]
spawned_planet = planets[0] if planets.size() > 0 else null
var count_ok: bool = planets.size() == 1
var name_ok: bool = spawned_planet != null and spawned_planet.planet_name != ""
ok = ok and count_ok and name_ok
print("spawned_count=%d name_ok=%s planet_name=%s" % [planets.size(), name_ok, spawned_planet.planet_name if spawned_planet else "<none>"])
func _check_despawned() -> void:
var chunk_removed: bool = not planet_field.populated_chunks.has(target_chunk)
var freed: bool = spawned_planet == null or not is_instance_valid(spawned_planet)
ok = ok and chunk_removed and freed
print("chunk_removed=%s freed=%s" % [chunk_removed, freed])
if ok:
print("TEST_PLANET_SPAWNING: PASS")
quit(0)
else:
print("TEST_PLANET_SPAWNING: FAIL")
quit(1)

View file

@ -0,0 +1 @@
uid://cvx8w0c8p7r1o

View file

@ -0,0 +1,107 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_prompt_debounce.gd
# Verifies the enter-atmosphere prompt is debounced: rapid flicker in the
# underlying landing-detection (planet_field.get_planet_fleet_is_on())
# must NOT cause the prompt to flicker on/off - only a raw signal that
# stays consistent for prompt_debounce_time in a row should commit a
# visible change. Simulates the worst-case flicker by teleporting the
# whole fleet back and forth across the planet's landing_radius boundary
# every single frame (bypassing actual ship steering, for a fully
# deterministic repro).
var main_scene: Node
var fleet: Node
var planet_field: Node
var enter_button: IconButton
var target_chunk: Vector2i
var planet_pos: Vector2
var landing_radius: float
var flicker_frames: int = 0
var visible_toggle_count: int = 0
var last_visible_state: bool = false
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = vicinity_pos
fleet.move_to(vicinity_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_PROMPT_DEBOUNCE: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
planet_pos = planet.position
landing_radius = planet.landing_radius
last_visible_state = enter_button.visible
elapsed = 0.0
stage = 2
return false
2:
# Teleport the fleet's centroid just inside, then just outside,
# the landing radius every single frame - the raw detection
# flips every frame, so the debounced prompt must never react
# to this at all.
var inside_pos: Vector2 = planet_pos + Vector2(landing_radius - 5.0, 0.0)
var outside_pos: Vector2 = planet_pos + Vector2(landing_radius + 5.0, 0.0)
var target_pos: Vector2 = inside_pos if (flicker_frames % 2 == 0) else outside_pos
for ship in fleet.ships:
ship.position = target_pos
flicker_frames += 1
if enter_button.visible != last_visible_state:
visible_toggle_count += 1
last_visible_state = enter_button.visible
if flicker_frames < 20:
return false
print("visible_toggle_count=%d (should be 0)" % visible_toggle_count)
if visible_toggle_count == 0:
print("TEST_PROMPT_DEBOUNCE: PASS")
quit(0)
else:
print("TEST_PROMPT_DEBOUNCE: FAIL (prompt flickered despite debounce)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://d3riakbwpwmtd

View file

@ -0,0 +1,222 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_resources_and_buildings.gd
# Covers the resource/building loop end to end:
# 1. flying a ship over a surface rock banks it in GameState
# 2. buildings are paid for, persist in GameState, and respawn with the
# space scene (they must survive the trip to a planet and back)
# 3. a ship crafter adds a vessel to the fleet for rocks
# 4. wiping out the fleet rebuilds one vessel at the mothership
var stage: int = 0
var elapsed: float = 0.0
var scene: Node
var fleet: Node
var game_state: Node
var rock: Area2D
var mothership_pos: Vector2 = Vector2(900.0, -400.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 0
game_state.buildings.clear()
game_state.has_return_position = false
scene = load("res://PlanetSurface.tscn").instantiate()
root.add_child(scene)
current_scene = scene
fleet = scene.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- gather a rock -------------------------------------------
scene.get_node("RockField").set_process(false)
rock = load("res://Rock.tscn").instantiate()
rock.build_shape(1)
# Placed OUTSIDE the pickup shape but inside attract_radius, so
# this also proves the magnetic pull actually reels it in rather
# than the ship simply happening to overlap it on spawn.
rock.position = fleet.ships[0].position + Vector2(150.0, 0.0)
scene.add_child(rock)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.5:
return false
if game_state.rocks != 1:
print("rocks=%d after flying a ship onto one" % game_state.rocks)
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (rock not gathered)")
quit(1)
return true
print("gathered rock -> rocks=%d" % game_state.rocks)
# Bank enough to build with, then move to the space scene.
game_state.rocks = 60
change_scene_to_file("res://Main.tscn")
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.4:
return false
if current_scene == null or current_scene.name != "Main":
return false
scene = current_scene
fleet = scene.get_node("Fleet")
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
# --- place both buildings -------------------------------------
var field: Node = scene.get_node("BuildingField")
var before: int = game_state.rocks
for ship in fleet.ships:
ship.position = mothership_pos
fleet.move_to(mothership_pos)
field.place(GameState.BUILDING_MOTHERSHIP, mothership_pos)
game_state.spend(GameState.MOTHERSHIP_COST)
field.place(GameState.BUILDING_SHIP_CRAFTER, Vector2(1400.0, 300.0))
game_state.spend(GameState.SHIP_CRAFTER_COST)
var paid: bool = game_state.rocks == before - GameState.MOTHERSHIP_COST - GameState.SHIP_CRAFTER_COST
var stored: bool = game_state.buildings.size() == 2
print("buildings=%d rocks %d->%d paid=%s" % [
game_state.buildings.size(), before, game_state.rocks, paid
])
if not (paid and stored):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (placing/paying wrong)")
quit(1)
return true
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
# --- crafter adds a ship --------------------------------------
fleet.spawn_ships(3, Vector2.ZERO)
var before_ships: int = fleet.ships.size()
var before_rocks: int = game_state.rocks
game_state.spend(GameState.SHIP_COST)
var added: bool = fleet.add_ship()
print("craft: ships %d->%d rocks %d->%d" % [
before_ships, fleet.ships.size(), before_rocks, game_state.rocks
])
if not (added and fleet.ships.size() == before_ships + 1 and game_state.rocks == before_rocks - GameState.SHIP_COST):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (crafting a ship)")
quit(1)
return true
# --- command centre: drag a formation slot ---------------------
var editor: Control = scene.get_node("UI/FormationMenu/Editor")
var menu: Control = scene.get_node("UI/FormationMenu")
menu.visible = true
editor.size = Vector2(520, 520)
# The editor is driven from BuildController._unhandled_input,
# which never fires for Controls that consume touches through
# the GUI system first. The menu AND its full-screen backdrop
# must both be IGNORE, or dragging a slot silently does nothing
# on a real device - which is exactly what happened.
for node in [menu, menu.get_node("Backdrop"), editor]:
if node.mouse_filter != Control.MOUSE_FILTER_IGNORE:
print("%s has mouse_filter=%d, needs IGNORE" % [node.name, node.mouse_filter])
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation menu would swallow drags)")
quit(1)
return true
var slot_before: Vector2 = game_state.formation[1]
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.index = 0
down.pressed = true
down.position = editor.global_position + editor._slot_screen_pos(1)
var grabbed: bool = editor.handle_touch(down)
var move: InputEventScreenDrag = InputEventScreenDrag.new()
move.index = 0
move.position = editor.global_position + editor._slot_screen_pos(1) + Vector2(40.0, 25.0)
editor.handle_drag(move)
var slot_after: Vector2 = game_state.formation[1]
menu.visible = false
print("formation slot 1: %s -> %s (grabbed=%s)" % [slot_before, slot_after, grabbed])
if not (grabbed and slot_after != slot_before):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (formation slot did not drag)")
quit(1)
return true
# Editing must re-seat the ships ALREADY in flight, not just
# ones built later - otherwise the edit reads as doing nothing.
var live_target: Vector2 = fleet.ships[1].move_target
var expected_target: Vector2 = fleet.fleet_target + slot_after
var live_ok: bool = live_target.distance_to(expected_target) < 0.01
print("live ship 1 target %s, expected %s (ok=%s)" % [
live_target, expected_target, live_ok
])
if not live_ok:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (existing ships ignored the edit)")
quit(1)
return true
# --- pivot the formation --------------------------------------
var before_pivot: Vector2 = game_state.formation[1]
game_state.rotate_formation(PI / 2.0)
fleet.apply_formation()
var after_pivot: Vector2 = game_state.formation[1]
var rotated_ok: bool = after_pivot.distance_to(before_pivot.rotated(PI / 2.0)) < 0.01
var length_kept: bool = abs(after_pivot.length() - before_pivot.length()) < 0.01
var ship_followed: bool = fleet.ships[1].move_target.distance_to(fleet.fleet_target + after_pivot) < 0.01
print("pivot: %s -> %s (rotated=%s shape_kept=%s ship_followed=%s)" % [
before_pivot, after_pivot, rotated_ok, length_kept, ship_followed
])
if not (rotated_ok and length_kept and ship_followed):
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (pivot)")
quit(1)
return true
game_state.rotate_formation(-PI / 2.0)
slot_after = game_state.formation[1]
# A rebuilt fleet must adopt the edited layout, which is the
# whole point of storing the formation in GameState.
fleet.spawn_ships(2, Vector2.ZERO)
var uses_layout: bool = fleet.ships[1].position.distance_to(slot_after) < 0.01
print("respawned ship 1 at %s, formation says %s (matches=%s)" % [
fleet.ships[1].position, slot_after, uses_layout
])
if not uses_layout:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet ignored the edited formation)")
quit(1)
return true
# --- wipe the fleet -------------------------------------------
for ship in fleet.ships.duplicate():
ship.queue_free()
fleet.ships.clear()
fleet.formation_offsets.clear()
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.5:
return false
# BuildController._process should have rebuilt one vessel, at
# the mothership rather than the origin.
var respawned: bool = fleet.ships.size() == 1
var at_mothership: bool = respawned and fleet.ships[0].position.distance_to(mothership_pos) < 120.0
print("after wipe: ships=%d at_mothership=%s (pos=%s expected~%s)" % [
fleet.ships.size(), at_mothership,
fleet.ships[0].position if respawned else Vector2.ZERO, mothership_pos
])
if respawned and at_mothership:
print("TEST_RESOURCES_AND_BUILDINGS: PASS")
quit(0)
else:
print("TEST_RESOURCES_AND_BUILDINGS: FAIL (fleet not rebuilt at the mothership)")
quit(1)
return true
return false

View file

@ -0,0 +1 @@
uid://cib2d4f5hu5mt

View file

@ -0,0 +1,174 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_return_position_matches_planet.gd
# Verifies that returning to space places the fleet exactly at the
# planet's position, not wherever the fleet's centroid happened to be
# when "enter atmosphere" was pressed - the fleet only needs to be
# somewhere within the planet's landing radius (not centered on it) for
# the prompt to appear, so those two positions can genuinely differ.
var main_scene: Node
var fleet: Node
var planet_field: Node
var landing_controller: Node
var enter_button: IconButton
var target_chunk: Vector2i
var planet_position: Vector2
var elapsed: float = 0.0
var stage: int = 0
var cached_fade_duration: float = 0.4
func _initialize() -> void:
var found: bool = false
for x in range(-15, 15):
for y in range(-15, 15):
var chunk: Vector2i = Vector2i(x, y)
if WorldGen.has_planet_at_chunk(chunk):
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("FAIL: could not find any chunk with a planet to test with")
quit(1)
return
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
current_scene = main_scene
fleet = main_scene.get_node("Fleet")
planet_field = main_scene.get_node("PlanetField")
landing_controller = main_scene.get_node("LandingController")
enter_button = main_scene.get_node("UI/EnterAtmosphereButton")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
main_scene.get_node("AsteroidField").set_process(false)
main_scene.get_node("EnemySpawner").set_process(false)
var vicinity_pos: Vector2 = WorldGen.chunk_origin(target_chunk) + Vector2.ONE * WorldGen.CHUNK_SIZE * 0.5
for ship in fleet.ships:
ship.position = vicinity_pos
fleet.move_to(vicinity_pos)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < planet_field.check_interval + 0.1:
return false
if not planet_field.populated_chunks.has(target_chunk):
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (planet never spawned)")
quit(1)
return true
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
planet_position = planet.position
# Deliberately land the fleet SOMEWHERE INSIDE the landing
# radius but NOT centered on the planet, simulating a player
# who pressed enter before the fleet fully settled onto it.
var offset_pos: Vector2 = planet.position + Vector2(60, 60)
for ship in fleet.ships:
ship.position = offset_pos
fleet.move_to(offset_pos)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if not enter_button.visible:
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (prompt did not appear from offset position)")
quit(1)
return true
cached_fade_duration = landing_controller.fade_duration
_press(enter_button)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < cached_fade_duration + 0.3:
return false
var on_surface: bool = current_scene != null and current_scene.name == "PlanetSurface"
if not on_surface:
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not swap to PlanetSurface)")
quit(1)
return true
var exit_button: IconButton = current_scene.get_node("UI/ExitButton")
var surface_controller: Node = current_scene.get_node("SurfaceController")
cached_fade_duration = surface_controller.fade_duration
_press(exit_button)
elapsed = 0.0
stage = 4
return false
4:
# Poll for the swap instead of waiting a fixed duration, so the
# fresh scene can be isolated on its very FIRST frame. It
# populates asteroids during its own _ready(), and one killing a
# ship shifts the centroid being measured here - losing the nose
# ship alone moves it 12 units, which is what made this test
# fail intermittently.
var back_in_space: bool = current_scene != null and current_scene.name == "Main"
if not back_in_space:
if elapsed > cached_fade_duration + 3.0:
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (did not return to Main)")
quit(1)
return true
return false
current_scene.get_node("AsteroidField").set_process(false)
current_scene.get_node("EnemySpawner").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
# A ship lost before isolation took hold would bias the centroid,
# and says nothing about the return position under test.
if current_scene.get_node("Fleet").ships.size() != 5:
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (lost a ship to a collision on arrival)")
quit(1)
return true
var space_fleet: Node = current_scene.get_node("Fleet")
# Ships spawn in a wedge around the return point and then steer
# into formation, so the centroid is only meaningful once they've
# actually stopped moving. Waiting a fixed duration made this
# test flaky (it intermittently sampled a still-settling fleet
# and measured a centroid several units off); wait for genuine
# arrival instead - each ship at its own move_target, not merely
# reporting zero velocity, since a ship that hasn't been physics
# -ticked yet also reads velocity == 0.
for ship in space_fleet.ships:
if ship.position.distance_to(ship.move_target) > ship.arrive_radius:
return false
var dist: float = space_fleet.get_centroid().distance_to(planet_position)
var position_matches_planet: bool = dist < 5.0
print("returned_centroid=%s planet_position=%s dist=%.2f position_matches_planet=%s" % [
space_fleet.get_centroid(), planet_position, dist, position_matches_planet
])
if position_matches_planet:
print("TEST_RETURN_POSITION_MATCHES_PLANET: PASS")
quit(0)
else:
print("TEST_RETURN_POSITION_MATCHES_PLANET: FAIL (returned somewhere other than the planet)")
quit(1)
return true
return false
func _press(button: IconButton) -> void:
var down: InputEventScreenTouch = InputEventScreenTouch.new()
down.pressed = true
button._gui_input(down)
var up: InputEventScreenTouch = InputEventScreenTouch.new()
up.pressed = false
button._gui_input(up)

View file

@ -0,0 +1 @@
uid://cyemjbd4xsjkh

104
tests/test_ship_facing.gd Normal file
View file

@ -0,0 +1,104 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_ship_facing.gd
# Regression test for ships freezing at a fixed heading.
#
# Ships holding formation sit INSIDE arrive_radius of their slot (the fleet
# anchor advances less than that per frame at cruise speed), so they snap
# to it with zero velocity. Facing used to be derived purely from a ship's
# own velocity behind a "> 8.0" gate, which meant a station-keeping ship
# had nothing to face along and stayed pointing whichever way it last
# happened to travel - it would not turn when the fleet changed direction.
var scene: Node
var fleet: Node
var stage: int = 0
var elapsed: float = 0.0
var heading_east: float = 0.0
func _initialize() -> void:
root.get_node("GameState").reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
func _angles_match(a: float, b: float, tolerance: float) -> bool:
return absf(angle_difference(a, b)) <= tolerance
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
fleet.move_in_direction(Vector2.RIGHT)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.5:
return false
# Structural check, because the behavioural ones below cannot
# reproduce the actual failure: on a real device at 60fps the
# anchor advances ~4.3 units per frame, LESS than arrive_radius,
# so a station-keeping ship snaps to its slot with zero velocity
# and has no heading of its own to face along. Headless runs
# _process uncapped against 60Hz physics, so ships here always
# pick up real velocity and look fine either way. Asserting the
# imposed heading is wired is what actually guards the fix.
var expected_fleet_velocity: Vector2 = Vector2.RIGHT * fleet.cruise_speed
for ship in fleet.ships:
if not ship.fleet_velocity.is_equal_approx(expected_fleet_velocity):
print("fleet_velocity=%s, expected %s" % [ship.fleet_velocity, expected_fleet_velocity])
print("TEST_SHIP_FACING: FAIL (fleet motion not supplied to ships)")
quit(1)
return true
# Flying east: every ship should point east (sprites are drawn
# nose-up, hence the +PI/2 convention in Ship._physics_process).
heading_east = Vector2.RIGHT.angle() + PI / 2.0
for ship in fleet.ships:
if not _angles_match(ship.rotation, heading_east, 0.25):
print("ship facing %.3f, expected ~%.3f while flying east" % [
ship.rotation, heading_east
])
print("TEST_SHIP_FACING: FAIL (not facing travel direction)")
quit(1)
return true
print("flying east: all ships facing %.3f" % fleet.ships[0].rotation)
# Turn by a SMALL angle. This is the discriminating case: a big
# turn shoves each ship sideways hard enough that its own
# velocity happens to point roughly the new way, so facing looks
# right even when broken. A gentle turn moves each ship only a
# few units, its velocity never clears the "> 8.0" gate, and a
# ship given no fleet motion simply stays pointing the old way.
fleet.move_in_direction(Vector2.RIGHT.rotated(-0.6))
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 1.5:
return false
var heading_north: float = Vector2.RIGHT.rotated(-0.6).angle() + PI / 2.0
for ship in fleet.ships:
if not _angles_match(ship.rotation, heading_north, 0.15):
print("ship stuck at %.3f, expected ~%.3f after the gentle turn" % [
ship.rotation, heading_north
])
print("TEST_SHIP_FACING: FAIL (did not turn with the fleet)")
quit(1)
return true
print("gentle turn: all ships facing %.3f (was %.3f)" % [
fleet.ships[0].rotation, heading_east
])
print("TEST_SHIP_FACING: PASS")
quit(0)
return true
return false

View file

@ -0,0 +1 @@
uid://dqilfnd6k24uw

View file

@ -0,0 +1,66 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_star_coverage.gd
# Verifies the shader-based starfield's uniforms actually track the camera:
# after moving/zooming the camera, camera_pos/camera_zoom fed to the
# starfield shader should match, at every zoom level (this replaces the old
# tile-symmetry check now that stars are a single procedural fragment
# shader pass with no CPU-side tiling to go asymmetric in the first place).
const TOLERANCE: float = 0.5
var main_scene: Node
var camera: Camera2D
var starfield: ColorRect
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
camera = main_scene.get_node("Camera2D")
starfield = main_scene.get_node("BackgroundLayer/Starfield")
func _process(_delta: float) -> bool:
var all_ok: bool = true
for zoom_level in [1.0, 3.0, 6.0]:
camera.zoom = Vector2(zoom_level, zoom_level)
camera.global_position = Vector2(zoom_level * 100.0, -zoom_level * 50.0)
# Let the Starfield's own _process (which reads the camera and pushes
# shader uniforms) run for this frame before checking.
starfield._process(0.016)
# The camera position is handed to the shader pre-split into a cell
# index plus a fraction within that cell (so the shader never handles
# a large absolute world coordinate - see Starfield.gd). Verify the
# two parts reconstruct the real camera position exactly, and that
# the fraction really is a fraction: if it ever left [0,1) the split
# would be wrong and the shader would sample the wrong cell.
var cell: Vector2 = starfield.material.get_shader_parameter("camera_cell")
var frac: Vector2 = starfield.material.get_shader_parameter("camera_frac")
var cell_size: float = starfield.material.get_shader_parameter("cell_size")
var uniform_zoom: float = starfield.material.get_shader_parameter("camera_zoom")
var reconstructed: Vector2 = (cell + frac) * cell_size
var pos_ok: bool = reconstructed.distance_to(camera.global_position) <= TOLERANCE
var frac_ok: bool = frac.x >= 0.0 and frac.x < 1.0 and frac.y >= 0.0 and frac.y < 1.0
var zoom_ok: bool = abs(uniform_zoom - (1.0 / camera.zoom.x)) <= 0.01
var ok: bool = pos_ok and frac_ok and zoom_ok
all_ok = all_ok and ok
print("zoom=%.1f camera_pos=%s reconstructed=%s frac=%s zoom=%.2f/%.2f %s" % [
zoom_level, camera.global_position, reconstructed, frac,
camera.zoom.x, uniform_zoom, "OK" if ok else "FAIL"
])
if all_ok:
print("TEST_STAR_COVERAGE: PASS")
quit(0)
else:
print("TEST_STAR_COVERAGE: FAIL")
quit(1)
return true

View file

@ -0,0 +1 @@
uid://dnc1u8diptger

136
tests/test_world_map_ui.gd Normal file
View file

@ -0,0 +1,136 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_world_map_ui.gd
# Verifies the map UI: hidden by default, opening it recenters on the
# fleet, a single-finger drag while the map is open pans the map content
# instead of leaking through to the gameplay fleet-move/zoom controls, and
# closing hides it again.
const POS_TOLERANCE: float = 3.0
var main_scene: Node
var fleet: Node
var camera: Camera2D
var world_map: Control
var content: Node2D
var fleet_target_before: Vector2
var camera_zoom_before: float
var content_pos_before_drag: Vector2
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
var packed: PackedScene = load("res://Main.tscn")
main_scene = packed.instantiate()
root.add_child(main_scene)
fleet = main_scene.get_node("Fleet")
camera = main_scene.get_node("Camera2D")
world_map = main_scene.get_node("UI/WorldMap")
content = main_scene.get_node("UI/WorldMap/MapContent")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.1:
return false
if world_map.visible:
print("TEST_WORLD_MAP_UI: FAIL (map visible by default)")
quit(1)
return true
world_map.open_map()
stage = 1
elapsed = 0.0
return false
1:
if elapsed < 0.1:
return false
var fleet_local: Vector2 = fleet.get_centroid() * content.WORLD_TO_LOCAL
var viewport_center: Vector2 = main_scene.get_viewport_rect().size / 2.0
var expected_content_pos: Vector2 = viewport_center - fleet_local * content.scale.x
var recenter_ok: bool = content.position.distance_to(expected_content_pos) <= POS_TOLERANCE
print("open_visible=%s content_pos=%s expected=%s recenter_ok=%s" % [
world_map.visible, content.position, expected_content_pos, recenter_ok
])
if not (world_map.visible and recenter_ok):
print("TEST_WORLD_MAP_UI: FAIL (open/recenter)")
quit(1)
return true
fleet_target_before = fleet.fleet_target
camera_zoom_before = camera.zoom.x
content_pos_before_drag = content.position
_touch(0, true, Vector2(200, 600))
stage = 2
elapsed = 0.0
return false
2:
if elapsed < 0.1:
return false
_drag(0, Vector2(260, 640))
stage = 3
elapsed = 0.0
return false
3:
if elapsed < 0.1:
return false
_touch(0, false, Vector2(260, 640))
# Headless mode's placeholder window doesn't match the configured
# viewport size, so Godot's own stretch transform rescales raw
# synthetic touch coordinates by some environment-specific factor
# before delivery - checking an exact pixel count here would be
# testing that artifact, not the pan logic. Instead verify the
# drag moved the map in the right DIRECTION and roughly the right
# PROPORTION (dragged twice as far on X as Y, so the pan should
# reflect that same ratio), which the scaling factor cancels out of.
var pan_delta: Vector2 = content.position - content_pos_before_drag
var moved_ok: bool = pan_delta.length() > 1.0
var direction_ok: bool = pan_delta.x > 0.0 and pan_delta.y > 0.0
var ratio_ok: bool = pan_delta.y != 0.0 and abs((pan_delta.x / pan_delta.y) - 1.5) <= 0.1
var pan_ok: bool = moved_ok and direction_ok and ratio_ok
var fleet_untouched: bool = fleet.fleet_target == fleet_target_before
var zoom_untouched: bool = camera.zoom.x == camera_zoom_before
print("pan_delta=%s moved_ok=%s direction_ok=%s ratio_ok=%s fleet_untouched=%s zoom_untouched=%s" % [
pan_delta, moved_ok, direction_ok, ratio_ok, fleet_untouched, zoom_untouched
])
if not (pan_ok and fleet_untouched and zoom_untouched):
print("TEST_WORLD_MAP_UI: FAIL (pan/isolation)")
quit(1)
return true
world_map.close_map()
stage = 4
elapsed = 0.0
return false
4:
if elapsed < 0.1:
return false
var closed_ok: bool = not world_map.visible
print("closed_ok=%s" % closed_ok)
if closed_ok:
print("TEST_WORLD_MAP_UI: PASS")
quit(0)
else:
print("TEST_WORLD_MAP_UI: FAIL (did not close)")
quit(1)
return true
return false
func _touch(index: int, pressed: bool, pos: Vector2) -> void:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.index = index
ev.pressed = pressed
ev.position = pos
Input.parse_input_event(ev)
func _drag(index: int, pos: Vector2) -> void:
var ev: InputEventScreenDrag = InputEventScreenDrag.new()
ev.index = index
ev.position = pos
Input.parse_input_event(ev)

View file

@ -0,0 +1 @@
uid://coa1sepq31622