Skill tree, node-based building animation, bigger UI, and a 19x faster test suite

Gameplay and UI
- Research tree at the mothership gates which structures can be built and
  grants permanent fleet-capacity upgrades (GameState.SKILL_TREE,
  SkillTreeView). Node depth is derived from prerequisites, so the layout
  cannot drift out of step with the dependencies.
- Building info popup before committing to a build, with per-building cost
  and description.
- Ship crafter redrawn as a space factory with an assembly animation;
  mothership redrawn as a radial flagship; command centre as a plotting hub.
- Keyboard and mouse support (WASD, Q/E pivot, wheel zoom) for the web build.
- UI and font scaled up for phone use, via a custom Theme resource -
  gui/theme/default_font_size does not reach the default theme.
- Asteroids no longer spawn on top of the fleet's start position.
- Fleet formation origin derived from the layout's own offsets rather than
  the centroid, fixing the lurch when a lopsided formation started moving.
- Fleet.stop() brakes from the ship's TRUE motion (velocity +
  fleet_velocity). Ship zeroes its velocity when seated in its slot even
  while the anchor drags that slot forward, so braking from velocity alone
  stopped the fleet dead instead of coasting - and whether it did depended
  on the frame rate.

Animation architecture (issue #10)
- Buildings, projectiles and explosion VFX no longer animate by redrawing.
  Static geometry is rasterised once in _draw(); moving parts are NeonPiece
  children animated through node properties, which reuse the cached
  geometry. Every NeonDraw call expands into four antialiased polylines, so
  redrawing to move something re-tessellated all of it.

Test suite (issue #8): 1075s -> ~55s
- Runs from a copy on Termux's own filesystem. The project lives on
  Android's FUSE bridge, read through proot, where load("res://Main.tscn")
  alone cost 8.5s per test against ~2s natively - and nearly every test
  loads it.
- Runs 6 tests at a time, in a single proot-distro login, with output
  buffered per test and printed only on failure.
- --fixed-fps 60 pins delta instead of measuring the wall clock, so a test's
  result no longer depends on how busy the machine is. Without it,
  parallelism made two tests fail on 2 of 3 runs.
- A test passes only if it both exits 0 and prints ": PASS".
- 38 tests, green on three consecutive runs.

Bundles Carrois Gothic SC (Apache 2.0) with its licence text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 15:00:41 +02:00
commit b641fa5891
48 changed files with 1985 additions and 303 deletions

View file

@ -33,7 +33,7 @@ func _ready() -> void:
if cell.has("phase"):
b.set_process(false)
b.anim_time = float(cell.phase) * b.CYCLE
b.queue_redraw()
b.apply_animation()
var cam: Camera2D = Camera2D.new()
cam.zoom = Vector2(1.65, 1.65)

View file

@ -19,8 +19,7 @@ func _initialize() -> void:
for path in ["UI/BuildMenu/InfoPopup/Panel/TitleLabel",
"UI/BuildMenu/InfoPopup/Panel/MessageLabel",
"UI/BuildMenu/MothershipLabel",
"UI/BuildMenu/TitleLabel",
"UI/NavLabel"]:
"UI/BuildMenu/TitleLabel"]:
var label: Label = scene.get_node_or_null(path)
if label == null:
print("%s: MISSING" % path)

40
tests/_profile_startup.gd Normal file
View file

@ -0,0 +1,40 @@
extends SceneTree
# Not a test - a stopwatch. Splits the fixed cost every scene-based test pays
# before it can assert anything, so tuning aims at the part that is actually
# expensive.
# godot --headless -s res://tests/_profile_startup.gd
var scene: Node
var t0: int = 0
var frames: int = 0
var frame_start: int = 0
func _initialize() -> void:
var boot: int = Time.get_ticks_msec()
print("engine boot to _initialize: %d ms" % boot)
t0 = Time.get_ticks_msec()
var packed: PackedScene = load("res://Main.tscn")
print("load(Main.tscn): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
scene = packed.instantiate()
print("instantiate(): %d ms" % (Time.get_ticks_msec() - t0))
t0 = Time.get_ticks_msec()
root.add_child(scene)
print("add_child() (_ready): %d ms" % (Time.get_ticks_msec() - t0))
frame_start = Time.get_ticks_msec()
func _process(_delta: float) -> bool:
frames += 1
if frames < 60:
return false
var span: int = Time.get_ticks_msec() - frame_start
print("60 frames: %d ms (%.1f fps)" % [span, 60000.0 / maxf(span, 1)])
quit(0)
return true

View file

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

93
tests/_run_inside.sh Normal file
View file

@ -0,0 +1,93 @@
#!/bin/bash
# Runs INSIDE the proot-distro Ubuntu container - do not call this directly,
# call tests/run_tests.sh from Termux instead.
#
# It exists as its own file so the whole suite runs in ONE `proot-distro
# login`: entering the container costs ~0.5s, which used to be paid 38 times
# over. Everything below is the container-side half of the runner.
#
# Args: <project_dir> <jobs> <verbose> <test_file_name>...
set -uo pipefail
PROJECT_DIR="$1"; shift
JOBS="$1"; shift
VERBOSE="$1"; shift
GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64"
cd "$PROJECT_DIR" || exit 2
tests=("$@")
total=${#tests[@]}
if (( total == 0 )); then
echo "no tests matched"
exit 2
fi
outdir="$(mktemp -d)"
trap 'rm -rf "$outdir"' EXIT
# Import once, serially, before anything runs in parallel. Godot writes to
# .godot/ while importing; several instances discovering a cold cache at the
# same moment would race on it. After this the workers only read it.
"$GODOT_BIN" --headless --import --quit >/dev/null 2>&1
run_one() {
local name="$1"
local log="$outdir/$name.log"
local start end
start=$(date +%s%N)
# --fixed-fps pins delta to exactly 1/60 instead of measuring the wall
# clock. Without it a test's `elapsed` counts REAL seconds, so running
# several at once stretches every frame and the gameplay under test sees
# deltas it never sees in the game: ships overshoot their braking curve,
# settle late, and tests time out for no reason but load. That made
# test_camera_settles and test_planet_prompt_map_interaction fail on 2 of
# 3 parallel runs. Pinned, a test simulates the same 60fps whatever else
# the phone is doing, and its result no longer depends on how busy the
# machine was.
"$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1
local rc=$?
end=$(date +%s%N)
local ms=$(( (end - start) / 1000000 ))
# Two independent criteria, because either one alone can lie: a test that
# crashes before reaching its verdict exits non-zero without ever printing
# FAIL, and a hypothetical `quit(0)` on a failing path would exit clean
# while the log says otherwise.
if (( rc == 0 )) && grep -q ": PASS" "$log"; then
printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status"
else
printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status"
fi
cat "$outdir/$name.status"
}
running=0
for name in "${tests[@]}"; do
run_one "$name" &
running=$(( running + 1 ))
if (( running >= JOBS )); then
wait -n
running=$(( running - 1 ))
fi
done
wait
failed=0
for name in "${tests[@]}"; do
if ! grep -q '^PASS' "$outdir/$name.status" 2>/dev/null; then
failed=$(( failed + 1 ))
echo
echo "=== $name FAILED, full output ==="
cat "$outdir/$name.log" 2>/dev/null
elif [[ "$VERBOSE" == "1" ]]; then
echo
echo "=== $name ==="
cat "$outdir/$name.log" 2>/dev/null
fi
done
echo
echo "$(( total - failed ))/$total passed"
(( failed == 0 ))

View file

@ -0,0 +1,29 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Opens the
# mothership's research tree with one branch already bought and a node
# selected, so the laid-out tree, its state colours and the detail panel can
# all be checked in one render.
#
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn out.png 30 720x1280
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.reset_progression()
GameState.rocks = 60
# One branch researched, so the render shows all three node states at
# once: done, reachable, and still locked further out.
GameState.unlock("shipyards")
GameState.unlock("coordination")
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.skill_menu.visible = true
controller.skill_tree.selected_id = "wing"
controller._refresh_skill_menu("wing")

View file

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

View file

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

47
tests/_vfx_preview.gd Normal file
View file

@ -0,0 +1,47 @@
extends Node2D
# Preview scene - NOT run by run_tests.sh (underscore prefix). Renders
# projectiles and every explosion type so the node-animation conversion
# (issue #10) can be checked by eye. Bolts once went INVISIBLE after a
# similar optimisation and headless tests did not catch it, because
# --headless has no GPU - so this exists to make that failure visible.
#
# bash tests/screenshot.sh res://tests/_vfx_preview.tscn out.png 20
const VFX_SCRIPTS: Array = [
"res://ShipExplosionVFX.gd", "res://EnemyExplosionVFX.gd",
"res://AsteroidExplosionVFX.gd", "res://LightBurstVFX.gd",
]
func _ready() -> void:
RenderingServer.set_default_clear_color(Color(0.02, 0.03, 0.06))
# Bolts at several headings, held still so they can be inspected.
var bolt_scene: PackedScene = load("res://Projectile.tscn")
for i in range(6):
var bolt: Area2D = bolt_scene.instantiate()
bolt.position = Vector2(-460.0 + float(i) * 90.0, -220.0)
bolt.rotation = float(i) * 0.35
add_child(bolt)
bolt.set_physics_process(false)
# One of each explosion, stepped forward by hand then frozen. Their real
# lifetime is under a second, and a softpipe frame takes far longer than
# that in wall-clock, so they would expire before the capture.
for i in range(VFX_SCRIPTS.size()):
var script: Script = load(VFX_SCRIPTS[i])
if script == null:
continue
var vfx: Node2D = Node2D.new()
vfx.set_script(script)
vfx.position = Vector2(-390.0 + float(i) * 260.0, 90.0)
vfx.lifetime = 999.0
add_child(vfx)
for step in range(8):
vfx._process(0.05)
vfx.set_process(false)
var cam: Camera2D = Camera2D.new()
add_child(cam)
cam.make_current()

View file

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

6
tests/_vfx_preview.tscn Normal file
View file

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

View file

@ -1,20 +1,78 @@
#!/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
#
# bash tests/run_tests.sh # everything
# bash tests/run_tests.sh formation # only tests matching a substring
# JOBS=1 bash tests/run_tests.sh # serial, for debugging a hang
# VERBOSE=1 bash tests/run_tests.sh # print passing tests' output too
# IN_PLACE=1 bash tests/run_tests.sh # run from the project dir itself
#
# Three things make this fast, in descending order of how much they matter
# (measured, not guessed - see tests/_profile_startup.gd):
#
# 1. It runs from a COPY on Termux's own filesystem, not from the project
# directory. The project lives under ~/storage/shared, which is Android's
# FUSE bridge; read through proot on top of that, `load("res://Main.tscn")`
# alone took 8.5s per test, against ~2s from a native path. Nearly every
# test loads Main.tscn, and none of that time is gameplay. The copy is
# ~1.4s for the whole suite and its .godot import cache is kept between
# runs.
# 2. Tests run JOBS at a time. Each is its own Godot process - they call
# quit() and stamp on the GameState autoload, so they cannot share one -
# which makes the suite a pile of independent, single-core-bound jobs on
# an 8-core phone.
# 3. The whole run happens in ONE `proot-distro login`. Entering the
# container costs ~0.5s, which used to be paid once per test.
#
# Output is buffered per test and printed only for failures; six interleaved
# logs are unreadable.
set -uo pipefail
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
# Leave headroom: Godot is memory-hungry and this is a phone, not a build
# server. Override with JOBS= if you know better.
JOBS="${JOBS:-6}"
VERBOSE="${VERBOSE:-0}"
IN_PLACE="${IN_PLACE:-0}"
WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}"
filter="${1:-}"
tests=()
for path in "$PROJECT_DIR"/tests/test_*.gd; do
name="$(basename "$path")"
if [[ -z "$filter" || "$name" == *"$filter"* ]]; then
tests+=("$name")
fi
done
if (( ${#tests[@]} == 0 )); then
echo "no tests match '$filter'"
exit 2
fi
start=$(date +%s)
run_dir="$PROJECT_DIR"
if [[ "$IN_PLACE" != "1" ]]; then
run_dir="$WORK_DIR"
mkdir -p "$run_dir"
# Clear last run's sources but keep .godot, so imports stay warm. Without
# the clear, a test deleted from the project would live on in the copy and
# keep being run.
find "$run_dir" -mindepth 1 -maxdepth 1 ! -name '.godot' -exec rm -rf {} +
# android/ and builds/ are export artefacts no test touches, and the .apk
# is 30MB of nothing useful here.
tar -C "$PROJECT_DIR" \
--exclude=./.godot --exclude=./android --exclude=./builds --exclude='*.apk' \
-cf - . 2>/dev/null | tar -C "$run_dir" -xf -
fi
proot-distro login ubuntu -- bash "$run_dir/tests/_run_inside.sh" \
"$run_dir" "$JOBS" "$VERBOSE" "${tests[@]}"
status=$?
echo "in $(( $(date +%s) - start ))s with JOBS=$JOBS"
exit $status

View file

@ -0,0 +1,114 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_asteroid_safe_spawn.gd
# Asteroid chunks are populated around wherever the fleet is - including the
# chunk it is standing in - and positions used to be uniform within a chunk.
# So an asteroid could be created directly on top of a ship and destroy it
# with no chance to react. Worst on the frame a scene loads: coming back from
# a planet's surface could cost a vessel instantly, which is also what made
# test_return_position_matches_planet fail intermittently.
#
# Verifies:
# 1. _safe_position never returns a point inside the bubble, for candidates
# all around and including the exact centre. This is checked directly
# rather than by looking at where asteroids happened to land: with ~54
# asteroids spread over 25 chunks, any single run only rarely produces a
# near miss, so an observational check passes just as happily with the
# guard removed and proves nothing.
# 2. candidates already outside the bubble are left exactly where they are,
# so the field is not distorted away from the fleet
# 3. the field still spawns a full complement, and a stationary fleet loses
# no ships
var scene: Node
var fleet: Node
var field: Node
var stage: int = 0
var elapsed: float = 0.0
var ships_at_start: int = 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")
field = scene.get_node("AsteroidField")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("EnemySpawner").set_process(false)
ships_at_start = fleet.ships.size()
elapsed = 0.0
stage = 1
return false
1:
# Let the field populate around the fleet a few times over.
if elapsed < 1.5:
return false
var centre: Vector2 = fleet.get_centroid()
var safe: float = field.safe_spawn_radius
# --- the guard itself, deterministically ----------------------
var worst_inside: float = 1e9
var moved_an_outside_one: bool = false
for i in range(72):
var a: float = (float(i) / 72.0) * TAU
var dir: Vector2 = Vector2(cos(a), sin(a))
# Candidates well inside, just inside, and exactly on centre.
for d in [0.0, 1.0, safe * 0.25, safe * 0.99]:
var got: Vector2 = field._safe_position(centre + dir * d, centre)
worst_inside = minf(worst_inside, got.distance_to(centre))
# Candidates already outside must be returned untouched.
var outside: Vector2 = centre + dir * (safe * 1.5)
if field._safe_position(outside, centre).distance_to(outside) > 0.001:
moved_an_outside_one = true
print("closest result for an inside candidate: %.1f (safe radius %.1f)" % [
worst_inside, safe
])
if worst_inside < safe - 0.001:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard let a spawn land inside the fleet's bubble)")
quit(1)
return true
if moved_an_outside_one:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)")
quit(1)
return true
var asteroids: Array = get_nodes_in_group("asteroids")
var closest: float = 1e9
for a in asteroids:
if is_instance_valid(a):
closest = minf(closest, a.position.distance_to(centre))
print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [
asteroids.size(), closest, safe
])
if asteroids.is_empty():
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)")
quit(1)
return true
# Asteroids drift, so one may have travelled inward since it was
# created. Allow for that, but nothing should be anywhere near
# on top of the fleet.
if closest < safe * 0.5:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)")
quit(1)
return true
var kept_ships: bool = fleet.ships.size() == ships_at_start
print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships])
if not kept_ships:
print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)")
quit(1)
return true
print("TEST_ASTEROID_SAFE_SPAWN: PASS")
quit(0)
return true
return false

View file

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

View file

@ -39,6 +39,12 @@ func _initialize() -> void:
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
# Structures are gated behind the mothership's research tree. That gate
# is test_skill_tree's subject, not this one's - here every option needs
# to be available so the popup itself can be exercised.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)

View file

@ -0,0 +1,110 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_building_redraw_cost.gd
# Animated buildings must not re-run _draw() to animate (Forgejo issues #6
# and #10).
#
# Every NeonDraw call expands into four antialiased polylines, so redrawing
# a whole shipyard each frame to advance a crane re-tessellates all of it.
# The moving elements are now separate NeonPiece children, rasterised once
# and thereafter only moved, scaled, faded or hidden - operations that reuse
# the cached geometry. Ship.gd and Asteroid.gd already worked this way.
#
# Verifies:
# 1. an animated building's geometry is rasterised exactly ONCE, however
# long it animates
# 2. it is genuinely still animating (so this cannot be "passed" by
# freezing the animation)
# 3. the moving parts really do move - the crane travels and the
# mothership's satellites orbit - proving the animation reaches the
# nodes rather than being silently dropped
# 4. the static command centre needs no per-frame work either
var scene: Node
var field: Node
var crafter: Node2D
var mothership: Node2D
var command: Node2D
var stage: int = 0
var elapsed: float = 0.0
var crane_x_start: float = 0.0
var satellite_rotation_start: float = 0.0
func _initialize() -> void:
var game_state: Node = 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_SHIP_CRAFTER, Vector2(600.0, 0.0))
field.place(GameState.BUILDING_MOTHERSHIP, Vector2(-600.0, 0.0))
field.place(GameState.BUILDING_COMMAND_CENTER, Vector2(0.0, 600.0))
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
for b in field.spawned:
if not is_instance_valid(b):
continue
match b.building_type:
GameState.BUILDING_SHIP_CRAFTER:
crafter = b
GameState.BUILDING_MOTHERSHIP:
mothership = b
GameState.BUILDING_COMMAND_CENTER:
command = b
if crafter == null or mothership == null or command == null:
print("TEST_BUILDING_REDRAW_COST: FAIL (buildings never spawned)")
quit(1)
return true
crane_x_start = crafter._crane.position.x
satellite_rotation_start = mothership._satellites.rotation
elapsed = 0.0
stage = 2
return false
2:
# Long enough to cross several assembly phases and a good arc of
# the satellites' orbit.
if elapsed < 2.5:
return false
print("rasterisations after %.1fs of animation - crafter:%d mothership:%d command:%d" % [
elapsed, crafter.draw_count, mothership.draw_count, command.draw_count
])
for b in [crafter, mothership, command]:
if b.draw_count != 1:
print("TEST_BUILDING_REDRAW_COST: FAIL (%s redrew its geometry %d times)" % [
b.building_type, b.draw_count
])
quit(1)
return true
var animating: bool = crafter.anim_time > 0.0 and mothership.phase_time > 0.0
var crane_moved: bool = absf(crafter._crane.position.x - crane_x_start) > 1.0
var orbit_moved: bool = absf(mothership._satellites.rotation - satellite_rotation_start) > 0.05
print("still animating=%s crane travelled=%s satellites orbited=%s" % [
animating, crane_moved, orbit_moved
])
if not (animating and crane_moved and orbit_moved):
print("TEST_BUILDING_REDRAW_COST: FAIL (nothing actually moved - not a fix)")
quit(1)
return true
print("TEST_BUILDING_REDRAW_COST: PASS")
quit(0)
return true
return false

View file

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

View file

@ -42,6 +42,11 @@ func _initialize() -> void:
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
# Extractors are gated behind research (see test_skill_tree). This test
# is about the planet-proximity rule, so grant the research up front.
game_state.reset_progression()
for node in GameState.SKILL_TREE:
game_state.unlocked.append(node.id)
main_scene = load("res://Main.tscn").instantiate()
root.add_child(main_scene)

View file

@ -14,7 +14,13 @@ extends SceneTree
var game_state: Node
var stage: int = 0
var elapsed: float = 0.0
var mothership_pos: Vector2 = Vector2(4000.0, 0.0)
# The test has to sit through a whole there-and-back hauler cycle, and the
# cycle length is distance/HAULER_SPEED - so this fixture distance IS the
# test's runtime. 600 units is ~4.6s of simulated round trip, against ~31s
# for the 4000 it used to be. Nothing here asserts on the distance itself,
# only on payout and drawn position tracking the same `progress`, so a short
# route exercises exactly the same code.
var mothership_pos: Vector2 = Vector2(600.0, 0.0)
var planet_pos: Vector2 = Vector2.ZERO
var rocks_before_arrival: int = 0

View file

@ -120,9 +120,17 @@ func _process(delta: float) -> bool:
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
# Not merely "moved forward at all": the fleet cruises at
# cruise_speed and brakes at Ship.acceleration, so the coast is
# cruise_speed^2 / (2 * acceleration) ~ 75 units. Asserting a
# real distance is what makes this a test of coasting - the old
# `> moving_centroid.x` was satisfied by any float wobble, and
# passed happily while the fleet stopped dead.
var coast: float = fleet.get_centroid().x - moving_centroid.x
var coasted: bool = coast > 40.0
print("after release: max_speed=%.3f stopped=%s coasted=%.1f units (expected ~%.0f)" % [
speed, stopped, coast,
(fleet.cruise_speed * fleet.cruise_speed) / (2.0 * fleet.ships[0].acceleration)
])
if stopped and coasted:
print("TEST_JOYSTICK_CONTROL: PASS")

169
tests/test_skill_tree.gd Normal file
View file

@ -0,0 +1,169 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_skill_tree.gd
# The mothership's research tree (Forgejo issue #4). It gates which
# structures may be built at all, and carries permanent upgrades that have
# no building of their own.
#
# Verifies:
# 1. from a fresh start only the mothership is buildable - the others are
# greyed, and pressing one explains which research opens it rather than
# silently failing
# 2. a locked option cannot be built even if the confirm is forced
# 3. research costs rocks, needs its prerequisite, and unlocks its building
# 4. the mothership's action button opens the tree (it used to have no
# action at all)
# 5. fleet capacity upgrades raise the cap, and the extra vessel can
# actually be crafted afterwards
var scene: Node
var game_state: Node
var controller: Node
var fleet: Node
var field: Node
var popup: Control
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()
game_state.reset_progression()
game_state.reset_formation()
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
controller = scene.get_node("BuildController")
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
popup = scene.get_node("UI/BuildMenu/InfoPopup")
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
# --- everything but the mothership starts locked --------------
var mothership_free: bool = game_state.is_building_unlocked(GameState.BUILDING_MOTHERSHIP)
var crafter_locked: bool = not game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("fresh start: mothership buildable=%s crafter locked=%s" % [
mothership_free, crafter_locked
])
if not (mothership_free and crafter_locked):
print("TEST_SKILL_TREE: FAIL (wrong things buildable at the start)")
quit(1)
return true
controller._on_build_pressed()
var greyed: bool = not scene.get_node("UI/BuildMenu/CrafterButton").enabled
controller._on_place_crafter()
var explains: bool = popup.visible \
and "research" in popup.get_node("Panel/MessageLabel").text.to_lower()
var offered: bool = popup.get_node("Panel/BuildButton").visible
print("locked crafter: greyed=%s explains=%s confirm_offered=%s" % [
greyed, explains, offered
])
if not (greyed and explains) or offered:
print("TEST_SKILL_TREE: FAIL (locked option did not explain itself)")
quit(1)
return true
# Forcing the confirm must still build nothing.
var before: int = game_state.buildings.size()
controller.pending_build_type = GameState.BUILDING_SHIP_CRAFTER
controller._confirm_build()
if game_state.buildings.size() != before:
print("TEST_SKILL_TREE: FAIL (built a locked structure)")
quit(1)
return true
controller._close_info()
# --- the mothership opens the tree ----------------------------
var mothership: Node2D = null
for b in field.spawned:
if is_instance_valid(b) and b.building_type == GameState.BUILDING_MOTHERSHIP:
mothership = b
controller.current_building = mothership
controller._on_action_pressed()
var tree_open: bool = scene.get_node("UI/SkillMenu").visible
print("mothership action opens research: %s" % tree_open)
if not tree_open:
print("TEST_SKILL_TREE: FAIL (mothership has no research action)")
quit(1)
return true
# --- prerequisites --------------------------------------------
if game_state.can_unlock("wing"):
print("TEST_SKILL_TREE: FAIL (deep node unlockable with no prerequisite)")
quit(1)
return true
# --- research costs rocks and unlocks its building ------------
var rocks_before: int = game_state.rocks
var cost: int = int(game_state.skill_node("shipyards").cost)
var ok: bool = game_state.unlock("shipyards")
var charged: bool = game_state.rocks == rocks_before - cost
var now_buildable: bool = game_state.is_building_unlocked(GameState.BUILDING_SHIP_CRAFTER)
print("research shipyards: ok=%s rocks %d->%d charged=%s crafter unlocked=%s" % [
ok, rocks_before, game_state.rocks, charged, now_buildable
])
if not (ok and charged and now_buildable):
print("TEST_SKILL_TREE: FAIL (research did not unlock its building)")
quit(1)
return true
# The menu must show it as buildable now, without reopening.
controller._on_build_pressed()
if not scene.get_node("UI/BuildMenu/CrafterButton").enabled:
print("TEST_SKILL_TREE: FAIL (build menu still greys an unlocked option)")
quit(1)
return true
controller._close_info()
scene.get_node("UI/BuildMenu").visible = false
# --- fleet capacity -------------------------------------------
var base_cap: int = game_state.max_fleet_size()
game_state.unlock("coordination")
var wing_ok: bool = game_state.unlock("wing")
var raised_cap: int = game_state.max_fleet_size()
print("fleet cap %d -> %d after Wing Doctrine (unlocked=%s)" % [
base_cap, raised_cap, wing_ok
])
if not (wing_ok and raised_cap == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (capacity upgrade did not raise the cap)")
quit(1)
return true
# And the extra vessel must actually be craftable.
fleet.spawn_ships(base_cap, Vector2.ZERO)
var added: bool = fleet.add_ship()
print("fleet %d ships, add one more -> %s (now %d)" % [
base_cap, added, fleet.ships.size()
])
if not (added and fleet.ships.size() == base_cap + 1):
print("TEST_SKILL_TREE: FAIL (could not crew the extra slot)")
quit(1)
return true
if fleet.add_ship():
print("TEST_SKILL_TREE: FAIL (cap not enforced beyond the upgrade)")
quit(1)
return true
print("TEST_SKILL_TREE: PASS")
quit(0)
return true
return false

View file

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