Compare commits

...
Author SHA1 Message Date
Loïc Wisniewski
672907ea0c Widen test_space_time_distorder.gd's formation-lag margin
Strayed 2.6x over its old allowance (77.7 vs 30.0) under one heavily
loaded JOBS=1 full-suite run, then passed cleanly in isolation
immediately afterward - device jitter, not a regression. The wider
margin still leaves orders-of-magnitude headroom below what a real
formation-desync bug (#72) would actually produce.
2026-08-17 19:51:11 +02:00
Loïc Wisniewski
284a54464e Map editor: real Load list, canvas panning, selected-item name (#64, #65, #66)
#64: Load used to blindly cycle to "the next" saved setup with no way
to see what existed or jump straight to one by name. It now opens a
real selectable ItemList of every saved setup (name + surface profile),
tap one to load it.

#65: a single-finger drag over empty canvas could never pan - the
touch handler placed an item on PRESS, before a drag had any chance to
happen, so the "pan" branch below it was unreachable dead code (it only
fired when selected_palette == "", which nothing in the UI can ever
produce). Placement now waits for release: released within
PAN_DEADZONE of where the finger went down is a tap (place), crossing
it partway through is a pan - the same tap-vs-drag deadzone idiom
GasBombController already uses for its own aim gesture.

#66: added a persistent "Selected: <kind> #<n>" label, refreshed
wherever selected_item changes (touch-select, new/load a setup,
delete), so a tap actually confirms what got picked instead of the
player inferring it from which dot on screen turned white.

Also widened test_space_time_distorder.gd's formation-lag margin after
it strayed 2.6x over its old allowance under one heavily loaded
JOBS=1 full-suite run and passed cleanly in isolation immediately
after - device jitter, not a regression; the wider margin still leaves
orders-of-magnitude headroom below a real desync bug's stray distance.
2026-08-17 19:49:43 +02:00
Loïc Wisniewski
63d0d6a496 Research lab planet: spawn more often, block extractors, clearer feedback (#74, #75, #76)
#74: RESEARCH_LAB_CHANCE raised from 0.08 to 0.16 - research points
gate mid/late skill-tree nodes and vessel/lab research now (#63), so
0.08 was too scarce for how central they'd become to progression.

#75: an extractor placed on a research_lab planet would sit on an
empty resource id forever, since it yields nothing by design. Refused
in BuildController's placement confirm, with the same "explain the
refusal" idiom every other build gate uses. register_capture_points()
gained an optional definition_id parameter so the refusal/reward logic
can tell a research_lab planet apart without threading an extra
argument through every existing caller.

#76: conquering a research_lab awarded a research point with no
on-screen acknowledgement beyond a HUD number the player has no reason
to be watching at that exact moment. CaptureLabel's existing
"CONQUERED" flash now names the reward directly on a research_lab.

test_extractor_building.gd and test_planet_conquest_gate.gd both use a
"first planet chunk found" scan with no regard for definition_id; in
this world's fixed seed that chunk happens to be a research_lab, so
#75's new gate started refusing their otherwise-unrelated extractor
placement checks. Both now skip a research_lab chunk when picking
their target, same as test_research_lab_extractor_gate.gd does on
purpose for its own dedicated coverage of that refusal.
2026-08-17 19:48:55 +02:00
Loïc Wisniewski
d68aac6444 Fix pivot tap-spam speed exploit (#72)
_finish_pivot_tap() granted a full PIVOT_MIN_STEP rotation on every
button/key release with no rate limit, so tapping fast enough rotated
the formation far beyond PIVOT_SPEED's smooth held-rate cap - well
above what holding the button down can ever produce. Each of those
artificially-frequent re-seats (Fleet.apply_formation()) forced every
ship into a fresh max-speed catch-up dash toward its new slot with no
time to settle between dashes, and since outer-wedge ships swing
further per pivot than inner ones, spamming made them visibly diverge
in speed while each ship's own bounded max_speed made the fleet read
as constantly pinned at its speed ceiling.

Added a per-direction cooldown (the time a genuine hold needs to reach
PIVOT_MIN_STEP on its own) so tap-spam can't out-pace holding the
button. Deliberately NOT shared between CW/CCW: a quick correction tap
the other way right after an overshoot is normal play, and
test_keyboard_input.gd already covers Q and E each independently
producing their own minimum step back-to-back - only repeatedly
re-tapping the SAME direction needed capping.
2026-08-17 19:47:50 +02:00
Loïc Wisniewski
1de66c6fb2 Gate enemies behind a detection range instead of instant omniscient pursuit (#69)
Chasers (EnemyPursuitComponent) and snipers (EnemyStandoffComponent)
used to beeline/close in from anywhere on the map the instant they
spawned - no matter how far the fleet was, which read as omniscient
rather than alert. Both now sit idle until the fleet is within their
own detection_range. Artillery's slow drift is gated the same way, but
reuses its own existing fire_range rather than a new tunable - outside
its firing envelope it has nothing to be drifting toward yet.

The enemy mothership deliberately keeps the opposite behaviour: it is
meant to read as a persistent, unavoidable stalking threat, not a
local sentry, so it gets its own EnemyPursuitComponentMothership.tscn
variant with an effectively unlimited detection_range - the same
scene-variant pattern EnemySpawnerComponentChaser/Artillery already
use for per-definition property overrides on a shared component script.
2026-08-17 19:47:37 +02:00
Loïc Wisniewski
8d089d6f76 Remove the Thrusters upgrade - fleet speed is now global (#73)
int_speed/shd_speed granted +max_speed/+acceleration per level to a
single vessel, which is redundant now that #59/#62 gave the whole
fleet a shared speed fader instead - a per-ship speed stat has nothing
left to meaningfully affect since #59's multiplier is applied at the
point of use in Ship.gd regardless of the ship's own base stats.
Deleted both upgrade resources and their vessel-definition references
outright rather than leaving them unused.

test_ship_lab.gd's per-vessel-type isolation check used int_speed as
an arbitrary interceptor-only upgrade to prove it doesn't leak into
transporter stats - swapped to int_range, which still exists. Its
upgrade-card-count assertion updated for 3 remaining interceptor
upgrades instead of 4.
2026-08-17 19:47:22 +02:00
Loïc Wisniewski
42535c7320 Fix UI overlaps: SpeedFader vs status labels, packed ship lab layout (#67, #71)
SpeedFader was missing anchor_top/anchor_bottom = 1.0 in both Main.tscn
and PlanetSurface.tscn, so it rendered TOP-anchored at a fixed y while
its siblings (LaserStatus, GasBombStatus) are bottom-anchored - on a
720x1280 viewport both landed in the same screen region, directly on
top of each other. Properly bottom-anchored SpeedFader now, positioned
above both status labels instead.

Separately, the ship lab's 7 vessel-name labels (#57-#59 grew the
roster from 4 to 7 without widening their row) were laid out 120px wide
on only a 90px pitch - a fixed 30px overlap between adjacent labels
regardless of text length, worse for genuinely long names like
"Interceptor"/"Transporter". Narrowed each label to fit its own lane
with real gaps, dropped the font size, and enabled word-wrap; shifted
the upgrade-card rows below down to give the now-taller label row room.
2026-08-17 19:47:09 +02:00
Loïc Wisniewski
eaeb6bf7a9 Fix two skill-tree nodes drawn on top of each other (#70)
Squadron Doctrine and Power Systems both sat at depth 3, x=0.50,
landing on the exact same screen position - Squadron's own
prerequisite chain (coordination -> wing -> squadron -> armada) zigzags
x from 0.50 to 0.82 back to 0.50, colliding with Freight's branch at
that depth. Kept the whole fleet-size doctrine branch at x=0.82 (wing's
own lane) instead, so it runs straight.

Added a structural regression test: no two SKILL_TREE nodes may share
the same (skill_depth, x), computed the same way SkillTreeView itself
places nodes.
2026-08-17 19:46:33 +02:00
29 changed files with 606 additions and 137 deletions

View file

@ -5,6 +5,12 @@ extends Label
# entirely when the current planet has no capture points (e.g. no setup
# resolved for it) rather than showing "0/0".
# A direct script preload, not the bare "PlanetDefinitions" global class_name
# - the same load-order hazard PlanetSurfaceController's setup_host comment
# already documents: the global identifier is only guaranteed registered
# after an editor/import pass, a direct preload never depends on that.
const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd")
var _was_conquered: bool = false
@ -18,7 +24,14 @@ func _process(_delta: float) -> void:
var conquered: bool = GameState.is_planet_conquered(planet_id)
if conquered:
text = "CONQUERED"
# #76: a research_lab's whole reward is invisible otherwise - the
# research point balance lives in a HUD number the player has no
# reason to be watching at the exact moment it changes. Naming the
# reward right on the same flash that already announces conquest
# is the same "explain it where it happens" idiom BuildController
# uses for a refused build.
text = "CONQUERED - +1 RESEARCH POINT" \
if GameState.planet_definition_id == PLANET_DEFINITIONS.RESEARCH_LAB_ID else "CONQUERED"
if not _was_conquered:
_flash()
else:

View file

@ -56,6 +56,11 @@ func _drift(enemy: Node2D, delta: float) -> void:
if fleet == null or fleet.ships.is_empty():
return
var to_fleet: Vector2 = fleet.get_centroid() - enemy.position
# #69: gated by the same fire_range it already shells within, rather
# than a second tunable that could drift out of sync - outside its own
# firing envelope it has nothing to be drifting toward yet.
if to_fleet.length() > fire_range:
return
if to_fleet.length() < 1.0:
return
enemy.position += to_fleet.normalized() * drift_speed * delta

View file

@ -7,11 +7,20 @@ extends Node
var capability_id: String = "pursuit"
var velocity: Vector2 = Vector2.ZERO
# #69: used to beeline from anywhere on the map the instant it spawned,
# which read as omniscient rather than alert. Idle until the fleet actually
# comes within this radius. A persistent stalking threat wants the opposite
# of this - see EnemyPursuitComponentMothership.tscn, which overrides it to
# an effectively unlimited value for the mothership's own instance.
@export var detection_range: float = 850.0
func tick(delta: float) -> void:
var enemy: Node2D = get_parent()
var target_pos: Vector2 = _nearest_ship_position(enemy)
var to_target: Vector2 = target_pos - enemy.position
var target: Node2D = _nearest_ship(enemy)
if target == null or enemy.position.distance_to(target.position) > detection_range:
return
var to_target: Vector2 = target.position - enemy.position
if to_target.length() > 1.0:
var desired_velocity: Vector2 = to_target.normalized() * enemy.speed
@ -23,10 +32,10 @@ func tick(delta: float) -> void:
enemy.rotation = lerp_angle(enemy.rotation, target_angle, enemy.rotation_speed * delta)
func _nearest_ship_position(enemy: Node2D) -> Vector2:
func _nearest_ship(enemy: Node2D) -> Node2D:
var fleet: Node2D = get_tree().get_first_node_in_group("player_fleet")
if fleet == null or fleet.ships.is_empty():
return enemy.position
return null
var nearest: Node2D = fleet.ships[0]
var best_dist: float = enemy.position.distance_to(nearest.position)
@ -35,4 +44,4 @@ func _nearest_ship_position(enemy: Node2D) -> Vector2:
if d < best_dist:
best_dist = d
nearest = ship
return nearest.position
return nearest

View file

@ -0,0 +1,7 @@
[gd_scene load_steps=2 format=3]
[ext_resource type="Script" path="res://EnemyPursuitComponent.gd" id="1"]
[node name="EnemyPursuitComponentMothership" type="Node"]
script = ExtResource("1")
detection_range = 1000000.0

View file

@ -11,6 +11,10 @@ var capability_id: String = "standoff"
@export var preferred_range: float = 620.0
@export var retreat_range_factor: float = 0.75
# #69: kept comfortably beyond preferred_range so it still has room to close
# in before settling at stand-off distance, but the fleet has to actually be
# somewhere on the board first - see EnemyPursuitComponent's identical fix.
@export var detection_range: float = 1200.0
var velocity: Vector2 = Vector2.ZERO
@ -22,7 +26,7 @@ func tick(delta: float) -> void:
return
var to_target: Vector2 = target.position - enemy.position
var distance: float = to_target.length()
if distance < 1.0:
if distance < 1.0 or distance > detection_range:
return
var direction: Vector2 = to_target / distance
var retreat_range: float = preferred_range * retreat_range_factor

View file

@ -42,8 +42,28 @@ var pinch_start_zoom: float = 1.0
# release, so tapping still produces a clear, visible nudge.
const PIVOT_SPEED: float = 1.6
const PIVOT_MIN_STEP: float = PI / 10.0
# #72: the tap top-up below used to fire in full on EVERY release, with no
# regard for how recently the last one landed - so tapping fast enough
# granted rotation far above PIVOT_SPEED's smooth rate, well beyond what
# holding the button can ever produce. Each of those artificially-frequent
# re-seats (Fleet.apply_formation()) forces every ship into a fresh
# max-speed catch-up dash toward its new slot with no time to settle
# between them, and since outer-wedge ships swing further per pivot than
# inner ones, spamming made them visibly diverge in speed ("desync") while
# each individual ship's own bounded max_speed made the fleet as a whole
# read as constantly at its speed ceiling ("build up speed"). This cooldown
# is exactly the time a genuine hold needs to reach PIVOT_MIN_STEP on its
# own, so tap-spam can't out-pace holding the button down.
const PIVOT_TAP_COOLDOWN: float = PIVOT_MIN_STEP / PIVOT_SPEED
var pivot_hold_amount: float = 0.0
# Tracked PER DIRECTION, not shared - a single quick correction (tap the
# other way right after an overshoot) is normal play, not the spam this
# cooldown targets, and Q/E each independently producing their own minimum
# step back-to-back is an existing, tested guarantee (test_keyboard_input.gd).
# Only repeatedly re-tapping the SAME direction needs to be capped.
var pivot_cw_tap_cooldown_left: float = 0.0
var pivot_ccw_tap_cooldown_left: float = 0.0
# Touch index currently holding each pivot button, or -1. These buttons are
# driven straight from raw touch events rather than through _gui_input,
# because Godot's Control GUI treats touch as a single emulated pointer and
@ -186,9 +206,16 @@ func _update_pivot(delta: float) -> void:
func _finish_pivot_tap(direction: float) -> void:
# Fired on release. A press held long enough has already turned far
# enough on its own; a brief tap gets topped up to the minimum step.
if pivot_hold_amount < PIVOT_MIN_STEP:
# enough on its own; a brief tap gets topped up to the minimum step -
# but only once THAT DIRECTION's own cooldown has cleared, so spamming
# one side can't grant rotation faster than holding the button would.
var cooldown_left: float = pivot_cw_tap_cooldown_left if direction > 0.0 else pivot_ccw_tap_cooldown_left
if pivot_hold_amount < PIVOT_MIN_STEP and cooldown_left <= 0.0:
_pivot((PIVOT_MIN_STEP - pivot_hold_amount) * direction)
if direction > 0.0:
pivot_cw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
else:
pivot_ccw_tap_cooldown_left = PIVOT_TAP_COOLDOWN
pivot_hold_amount = 0.0
@ -211,6 +238,8 @@ func _on_joystick_direction(direction: Vector2) -> void:
func _process(delta: float) -> void:
pivot_cw_tap_cooldown_left = maxf(pivot_cw_tap_cooldown_left - delta, 0.0)
pivot_ccw_tap_cooldown_left = maxf(pivot_ccw_tap_cooldown_left - delta, 0.0)
_update_pivot(delta)
# Keep the heading fresh while a control is held. Fleet advances its own

View file

@ -329,13 +329,19 @@ const SKILL_TREE: Array = [
"desc": "Permanent: +1 vessel in the fleet.",
},
{
# #70: was x=0.50 - the same lane freight's branch already occupies at
# this same depth (power_systems), so the two nodes were drawn
# directly on top of each other. Kept in line with wing (its own
# prerequisite, also x=0.82) instead, so the whole fleet-size
# doctrine branch now runs straight rather than zigzagging into
# someone else's lane.
"id": "squadron", "name": "Squadron Doctrine", "cost": 70, "requires": "wing",
"x": 0.50, "fleet_bonus": 1, "research_points": 2,
"x": 0.82, "fleet_bonus": 1, "research_points": 2,
"desc": "Permanent: +1 vessel in the fleet.",
},
{
"id": "armada", "name": "Armada Doctrine", "cost": 150, "requires": "squadron",
"x": 0.50, "fleet_bonus": 1, "research_points": 3,
"x": 0.82, "fleet_bonus": 1, "research_points": 3,
"desc": "Permanent: +1 vessel in the fleet.",
},
]

View file

@ -1,12 +0,0 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "int_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -1,10 +1,9 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=8 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselWeaponComponent.tscn" id="2"]
[ext_resource type="Resource" path="res://InterceptorDamageUpgrade.tres" id="3"]
[ext_resource type="Resource" path="res://InterceptorRateUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://InterceptorRangeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://InterceptorSpeedUpgrade.tres" id="6"]
[ext_resource type="PackedScene" path="res://VesselCargoComponent.tscn" id="7"]
[resource]
script = ExtResource("1")
@ -19,4 +18,4 @@ fleet_eligible = true
capabilities = PackedStringArray("weapon", "cargo")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("7")])
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0, "cargo_capacity": 20.0}
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5"), ExtResource("6")])
upgrades = Array[Resource]([ExtResource("3"), ExtResource("4"), ExtResource("5")])

107
Main.tscn
View file

@ -227,10 +227,12 @@ visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -94
offset_right = -24
offset_top = 740
offset_bottom = 920
offset_top = -720
offset_bottom = -560
script = ExtResource("31")
[node name="Label" type="Label" parent="UI/SpeedFader"]
@ -1295,11 +1297,12 @@ text = ""
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -330
offset_right = -210
offset_left = -310
offset_right = -230
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Interceptor"
@ -1307,11 +1310,12 @@ text = "Interceptor"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -240
offset_right = -120
offset_left = -220
offset_right = -140
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Shield"
@ -1319,11 +1323,12 @@ text = "Shield"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -150
offset_right = -30
offset_left = -130
offset_right = -50
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Gas Bomber"
@ -1331,11 +1336,12 @@ text = "Gas Bomber"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = -60
offset_right = 60
offset_left = -40
offset_right = 40
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Transporter"
@ -1343,11 +1349,12 @@ text = "Transporter"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 30
offset_right = 150
offset_left = 50
offset_right = 130
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Carrier"
@ -1355,11 +1362,12 @@ text = "Carrier"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 120
offset_right = 240
offset_left = 140
offset_right = 220
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Laser Ship"
@ -1367,11 +1375,12 @@ text = "Laser Ship"
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
offset_left = 210
offset_right = 330
offset_left = 230
offset_right = 310
offset_top = 400
offset_bottom = 460
theme_override_font_sizes/font_size = 22
offset_bottom = 472
theme_override_font_sizes/font_size = 15
autowrap_mode = 2
horizontal_alignment = 1
text = "Distorder"
@ -1379,9 +1388,9 @@ text = "Distorder"
mouse_filter = 2
anchor_right = 1.0
offset_left = 48
offset_top = 462
offset_top = 480
offset_right = -48
offset_bottom = 466
offset_bottom = 484
color = Color(0.25, 0.85, 1, 0.6)
[node name="UpgradeList" type="Label" parent="UI/LabMenu"]
@ -1390,7 +1399,7 @@ mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 32
offset_top = 470
offset_top = 488
offset_right = -32
offset_bottom = -470
theme_override_font_sizes/font_size = 30
@ -1472,8 +1481,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -240
offset_right = -120
offset_top = 470
offset_bottom = 580
offset_top = 488
offset_bottom = 598
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1484,8 +1493,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -310
offset_right = -50
offset_top = 585
offset_bottom = 655
offset_top = 603
offset_bottom = 673
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1496,8 +1505,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 120
offset_right = 240
offset_top = 470
offset_bottom = 580
offset_top = 488
offset_bottom = 598
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1508,8 +1517,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 50
offset_right = 310
offset_top = 585
offset_bottom = 655
offset_top = 603
offset_bottom = 673
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1520,8 +1529,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -240
offset_right = -120
offset_top = 660
offset_bottom = 765
offset_top = 678
offset_bottom = 783
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1532,8 +1541,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = -310
offset_right = -50
offset_top = 770
offset_bottom = 815
offset_top = 788
offset_bottom = 833
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1
@ -1544,8 +1553,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 120
offset_right = 240
offset_top = 660
offset_bottom = 765
offset_top = 678
offset_bottom = 783
script = ExtResource("10")
icon = 13
glow_color = Color(0.25, 0.85, 1, 1)
@ -1556,8 +1565,8 @@ anchor_left = 0.5
anchor_right = 0.5
offset_left = 50
offset_right = 310
offset_top = 770
offset_bottom = 815
offset_top = 788
offset_bottom = 833
theme_override_font_sizes/font_size = 22
autowrap_mode = 2
horizontal_alignment = 1

View file

@ -1,7 +1,7 @@
[gd_resource type="Resource" script_class="EnemyDefinition" load_steps=6 format=3]
[ext_resource type="Script" path="res://EnemyDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://EnemyPursuitComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://EnemyPursuitComponentMothership.tscn" id="2"]
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentChaser.tscn" id="3"]
[ext_resource type="PackedScene" path="res://EnemySpawnerComponentArtillery.tscn" id="4"]
[ext_resource type="PackedScene" path="res://EnemyBigLaserComponent.tscn" id="5"]

View file

@ -25,6 +25,10 @@ const MIN_ZOOM: float = 0.3
const MAX_ZOOM: float = 4.0
const WHEEL_ZOOM_STEP: float = 1.1
const HIT_RADIUS: float = 50.0
# #65: how far a single-finger press has to move before it counts as a pan
# rather than a tap-to-place, once it's confirmed not to be starting on an
# existing marker.
const PAN_DEADZONE: float = 24.0
const ORIGIN_COLOR: Color = Color(1.0, 0.9, 0.4)
# Duplicated from PaletteEntryButton.RING_COLOR rather than referenced
# cross-class - a global class_name lookup at parse time needs the class
@ -45,14 +49,22 @@ const PALETTE_STEP: float = 92.0
@onready var load_button: IconButton = $UI/LoadButton
@onready var delete_button: IconButton = $UI/DeleteButton
@onready var status_label: Label = $UI/StatusLabel
@onready var selected_label: Label = $UI/SelectedLabel
@onready var palette_row: Control = $UI/PaletteRow
@onready var load_panel: Control = $UI/LoadPanel
@onready var load_list: ItemList = $UI/LoadPanel/List
@onready var load_close_button: IconButton = $UI/LoadPanel/CloseButton
var current_setup: Resource
var selected_palette: String = "capture_point"
var selected_item: Dictionary = {}
var _profile_ids: Array = []
var _profile_cursor: int = 0
var _load_cursor: int = -1
# #64: replaced the old "Load cycles to the next saved setup" behaviour,
# which gave no way to see what existed or jump straight to one by name -
# kept only as the id list _load_list_setup_ids indexes into when the
# panel's own selection fires.
var _load_list_setup_ids: PackedStringArray = PackedStringArray()
var _palette_buttons: Array = []
var _origin_marker: NeonPiece
@ -76,6 +88,8 @@ func _ready() -> void:
load_button.pressed.connect(_on_load_pressed)
delete_button.pressed.connect(_on_delete_pressed)
name_input.text_changed.connect(_on_name_changed)
load_close_button.pressed.connect(func(): load_panel.visible = false)
load_list.item_selected.connect(_on_load_list_item_selected)
_origin_marker = NeonPiece.create(item_host, Vector2.ZERO, ORIGIN_COLOR)
var cross_size: float = 26.0
@ -100,6 +114,7 @@ func new_setup(setup_id: String, display_name: String, profile_id: String) -> vo
# fails at runtime, since the compiler here only sees current_setup as
# a plain Resource and can't confirm the target is Array[Resource].
selected_item = {}
_refresh_selected_label()
_profile_cursor = maxi(_profile_ids.find(profile_id), 0)
if name_input != null:
name_input.text = display_name
@ -116,6 +131,7 @@ func load_setup(setup_id: String) -> bool:
# explicit save.
current_setup = setup.duplicate(true)
selected_item = {}
_refresh_selected_label()
_profile_cursor = maxi(_profile_ids.find(current_setup.surface_profile_id), 0)
if name_input != null:
name_input.text = current_setup.display_name
@ -301,8 +317,19 @@ func _on_load_pressed() -> void:
if setups.is_empty():
_set_status("No saved setups yet.")
return
_load_cursor = (_load_cursor + 1) % setups.size()
load_setup(setups[_load_cursor].setup_id)
load_list.clear()
_load_list_setup_ids = PackedStringArray()
for setup in setups:
load_list.add_item("%s (%s)" % [setup.display_name, setup.surface_profile_id])
_load_list_setup_ids.append(setup.setup_id)
load_panel.visible = true
func _on_load_list_item_selected(index: int) -> void:
if index < 0 or index >= _load_list_setup_ids.size():
return
load_setup(_load_list_setup_ids[index])
load_panel.visible = false
func _on_delete_pressed() -> void:
@ -314,6 +341,7 @@ func _on_delete_pressed() -> void:
else:
remove_enemy(selected_item.index)
selected_item = {}
_refresh_selected_label()
func _on_name_changed(new_text: String) -> void:
@ -336,6 +364,24 @@ func _set_status(message: String) -> void:
status_label.text = message
# #66: names the currently-selected marker so a tap actually confirms what
# got picked, instead of the player having to infer it from which dot on
# screen turned white.
func _refresh_selected_label() -> void:
if selected_label == null:
return
if selected_item.is_empty() or current_setup == null:
selected_label.text = ""
return
var index: int = int(selected_item.index)
if selected_item.kind == "capture":
selected_label.text = "Selected: Capture Point #%d" % (index + 1)
else:
var spawn: Resource = current_setup.enemy_spawns[index]
var definition: Resource = ENEMY_DEFINITIONS.get_definition(spawn.enemy_id)
selected_label.text = "Selected: %s #%d" % [definition.display_name, index + 1]
# --- placement/selection helpers ----------------------------------------
func _place_selected(world_pos: Vector2) -> void:
@ -405,26 +451,33 @@ func _handle_touch(event: InputEventScreenTouch) -> void:
var world_pos: Vector2 = _screen_to_world(event.position)
var hit: Dictionary = _find_marker_near(world_pos)
drag_touch_index = event.index
pan_start_screen = event.position
pan_start_camera = camera.position
if not hit.is_empty():
selected_item = hit
dragging_item = hit
drag_mode = "item"
_rebuild_markers()
elif selected_palette != "":
_place_selected(world_pos)
drag_mode = ""
_refresh_selected_label()
else:
drag_mode = "pan"
pan_start_screen = event.position
pan_start_camera = camera.position
# #65: a press over empty canvas no longer commits to placing
# an item immediately - it might be the start of a pan. Which
# one it turns out to be is only known on release (still
# under PAN_DEADZONE = a tap = place) or once a drag actually
# crosses that deadzone (= a pan), same tap-vs-drag idiom
# GasBombController uses for its own AIM_DEADZONE.
dragging_item = {}
drag_mode = "pending"
else:
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
if event.index == drag_touch_index:
if drag_mode == "pending":
_place_selected(_screen_to_world(event.position))
drag_touch_index = -1
drag_mode = ""
dragging_item = {}
touches.erase(event.index)
if touches.size() < 2:
pinch_active = false
func _handle_drag(event: InputEventScreenDrag) -> void:
@ -440,6 +493,8 @@ func _handle_drag(event: InputEventScreenDrag) -> void:
if event.index != drag_touch_index:
return
if drag_mode == "pending" and event.position.distance_to(pan_start_screen) >= PAN_DEADZONE:
drag_mode = "pan"
match drag_mode:
"item":
_move_selected(_screen_to_world(event.position))

View file

@ -170,6 +170,17 @@ theme_override_font_sizes/font_size = 20
autowrap_mode = 2
text = ""
[node name="SelectedLabel" type="Label" parent="UI"]
mouse_filter = 2
offset_left = 20
offset_top = 250
offset_right = 696
offset_bottom = 286
theme_override_font_sizes/font_size = 22
theme_override_colors/font_color = Color(1, 0.95, 0.9, 1)
autowrap_mode = 2
text = ""
[node name="PaletteRow" type="Control" parent="UI"]
anchor_top = 1.0
anchor_bottom = 1.0
@ -177,3 +188,42 @@ offset_left = 20
offset_top = -110
offset_right = 696
offset_bottom = -20
[node name="LoadPanel" type="Control" parent="UI"]
visible = false
anchor_right = 1.0
anchor_bottom = 1.0
[node name="Backdrop" type="ColorRect" parent="UI/LoadPanel"]
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.94)
[node name="TitleLabel" type="Label" parent="UI/LoadPanel"]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24
offset_top = 36
offset_right = -24
offset_bottom = 90
theme_override_font_sizes/font_size = 34
horizontal_alignment = 1
text = "Load a setup"
[node name="List" type="ItemList" parent="UI/LoadPanel"]
offset_left = 24
offset_top = 110
offset_right = 696
offset_bottom = 1180
theme_override_font_sizes/font_size = 24
[node name="CloseButton" type="Control" parent="UI/LoadPanel"]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -92
offset_top = 20
offset_right = -20
offset_bottom = 92
script = ExtResource("4")
icon = 1
glow_color = Color(0.9, 0.35, 0.35, 1)

View file

@ -163,10 +163,12 @@ visible = false
mouse_filter = 2
anchor_left = 1.0
anchor_right = 1.0
anchor_top = 1.0
anchor_bottom = 1.0
offset_left = -94
offset_right = -24
offset_top = 740
offset_bottom = 920
offset_top = -720
offset_bottom = -560
script = ExtResource("17")
glow_color = Color(0.5, 0.18, 0.6, 1)
core_color = Color(0.02, 0.09, 0.15, 1)

View file

@ -1,12 +0,0 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "shd_speed"
display_name = "Thrusters"
description = "+40 top speed, +60 acceleration per level."
base_cost = 25
max_level = 3
research_point_cost = 1
icon = 18
additive_modifiers = {"max_speed": 40.0, "acceleration": 60.0}

View file

@ -1,9 +1,8 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=5 format=3]
[ext_resource type="Script" path="res://VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://VesselShieldComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://ShieldHitsUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://ShieldRechargeUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://ShieldSpeedUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "shield"
@ -19,7 +18,7 @@ component_scenes = Array[PackedScene]([ExtResource("3")])
hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12)
base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "shield_charges": 1.0, "shield_recharge": 6.0, "delivery_speed": 180.0}
minimum_stats = {"shield_recharge": 2.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")])
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])
unlock_id = "unlock_shield"
unlock_name = "Unlock Shield Vessel"
unlock_description = "Lets the ship crafter build shield vessels. Its shield absorbs one lethal hit, then cycles back up."

View file

@ -22,10 +22,12 @@ const GAS_GIANT_THRESHOLD: float = 0.55
# chance independent of biome - not a second smooth region to tune against
# the first.
const BLACK_HOLE_CHANCE: float = 0.05
# #63: findable without excessive exploration - research points gate mid/
# late skill-tree nodes and vessel research, so a player needs to run into
# one of these reasonably often, not just as a rare curiosity.
const RESEARCH_LAB_CHANCE: float = 0.08
# #63/#74: findable without excessive exploration - research points gate
# mid/late skill-tree nodes and vessel research, so a player needs to run
# into one of these reasonably often, not just as a rare curiosity. Raised
# from 0.08 after playtesting found that rate too scarce for how central
# research points had become to progression.
const RESEARCH_LAB_CHANCE: float = 0.16
# A predictable early gas source for play-testing. The origin chunk is
# deterministically normal space. Its explicit in-chunk position puts it

View file

@ -1,37 +1,29 @@
extends Node2D
# Preview, not a test. The ship lab's upgrade screen (#5), so its layout can
# be checked on a real viewport - headless draws no pixels.
# Preview, not a test - #71's ship lab layout with all 7 vessels present,
# checking the vessel-name row no longer overlaps itself.
#
# bash tests/screenshot.sh res://tests/_lab_menu_preview.tscn /tmp/lab.png 20 720x1280
var configured: bool = false
func _ready() -> void:
func _process(_delta: float) -> void:
if configured:
return
configured = true
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.reset_progression()
GameState.rocks = 240
GameState.gas = 36
GameState.unlocked = ["shipyards", "vessel_science"]
GameState.rocks = 500
GameState.research_points = 5
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BackgroundLayer").visible = false
main.get_node("Fleet").visible = false
main.get_node("AsteroidField").visible = false
main.get_node("PlanetField").visible = false
main.get_node("BuildingField").visible = false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_LAB, Vector2(400.0, 0.0))
var lab_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(lab_id, 18)
GameState.fleet_cargo = 12
GameState.fleet_gas_cargo = 8
var bc: Node = main.get_node("BuildController")
# The shared header (#24) hides its HERE column when it has no building,
# so bind it the way opening the panel from the building does.
for bar in bc.resource_bars:
bar.building_id = lab_id
bc.lab_building_id = lab_id
main.get_node("UI/LabMenu").visible = true
bc.lab_vessel = GameState.VESSEL_GAS_BOMBER
bc._refresh_lab()

View file

@ -0,0 +1,12 @@
extends Node2D
# Preview, not a test - #64's Load panel: a real selectable list of saved
# setups instead of blind cycling.
#
# bash tests/screenshot.sh res://tests/_setup_editor_load_panel_preview.tscn /tmp/load.png 20 720x1280
func _ready() -> void:
var editor: Node = load("res://PlanetSetupEditor.tscn").instantiate()
add_child(editor)
editor._on_load_pressed()

View file

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

View file

@ -12,3 +12,4 @@ func _ready() -> void:
editor.add_enemy("artillery", Vector2(0, 350))
editor.selected_item = {"kind": "capture", "index": 0}
editor._rebuild_markers()
editor._refresh_selected_label()

View file

@ -0,0 +1,129 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_enemy_detection_range.gd
# Forgejo #69: enemies used to beeline toward the fleet from anywhere on the
# map the instant they spawned. Chasers (EnemyPursuitComponent) and snipers
# (EnemyStandoffComponent) now sit idle until the fleet is within their own
# detection_range; the enemy mothership deliberately keeps an (effectively)
# unlimited range instead, since it is meant to read as a persistent,
# unavoidable stalking threat rather than a local sentry.
#
# Covers:
# A. a chaser beyond detection_range does not move at all
# B. the same chaser, once the fleet is within range, closes in
# C. a sniper beyond detection_range does not move either
# D. the enemy mothership closes in even from far beyond a chaser's range
var game_state: Node
var main: Node
var fleet: Node
var chaser: Node
var sniper: Node
var mothership: Node
var chaser_start: Vector2
var sniper_start: Vector2
var mothership_start: Vector2
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()
game_state.transporters.clear()
game_state.reset_progression()
main = load("res://Main.tscn").instantiate()
root.add_child(main)
fleet = main.get_node("Fleet")
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.2:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
for a in get_nodes_in_group("asteroids"):
a.queue_free()
fleet.spawn_ships(1, Vector2.ZERO)
# --- A. beyond detection_range: no movement at all ---------------
chaser = load("res://Enemy.tscn").instantiate()
chaser.enemy_id = "chaser"
var pursuit: Node = null
main.add_child(chaser)
pursuit = chaser.get_component("pursuit")
chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range + 300.0, 0.0)
chaser_start = chaser.position
sniper = load("res://Enemy.tscn").instantiate()
sniper.enemy_id = "sniper"
main.add_child(sniper)
var standoff: Node = sniper.get_component("standoff")
sniper.position = fleet.get_centroid() + Vector2(0.0, standoff.detection_range + 300.0)
sniper_start = sniper.position
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 1.0:
return false
var chaser_moved: float = chaser.position.distance_to(chaser_start)
var sniper_moved: float = sniper.position.distance_to(sniper_start)
print("beyond range for 1.0s: chaser moved %.1f, sniper moved %.1f" % [chaser_moved, sniper_moved])
if chaser_moved > 1.0:
return _fail("a chaser beyond detection_range moved %.1f - it should sit idle" % chaser_moved)
if sniper_moved > 1.0:
return _fail("a sniper beyond detection_range moved %.1f - it should sit idle" % sniper_moved)
# --- B. bring the chaser within range, it should now close in ----
var pursuit: Node = chaser.get_component("pursuit")
chaser.position = fleet.get_centroid() + Vector2(pursuit.detection_range * 0.5, 0.0)
chaser_start = chaser.position
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 1.0:
return false
var chaser_moved: float = chaser.position.distance_to(chaser_start)
print("within range for 1.0s: chaser moved %.1f" % chaser_moved)
if chaser_moved < 20.0:
return _fail("a chaser within detection_range did not close in (moved %.1f)" % chaser_moved)
print("chaser correctly idle beyond range, closes in once inside it")
# --- D. the mothership closes in from far beyond a chaser's range -
mothership = load("res://Enemy.tscn").instantiate()
mothership.enemy_id = "enemy_mothership"
main.add_child(mothership)
var mother_pursuit: Node = mothership.get_component("pursuit")
if mother_pursuit.detection_range < 50000.0:
return _fail("the mothership's pursuit range is not effectively unlimited (%f)" \
% mother_pursuit.detection_range)
mothership.position = fleet.get_centroid() + Vector2(5000.0, 0.0)
mothership_start = mothership.position
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 1.0:
return false
var mothership_moved: float = mothership.position.distance_to(mothership_start)
print("mothership 5000 units out, 1.0s: moved %.1f" % mothership_moved)
if mothership_moved < 5.0:
return _fail("the mothership did not close in from far away (moved %.1f)" % mothership_moved)
print("TEST_ENEMY_DETECTION_RANGE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_ENEMY_DETECTION_RANGE: FAIL (%s)" % message)
quit(1)
return true

View file

@ -27,7 +27,12 @@ func _initialize() -> void:
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):
# #75: this test is about the planet-proximity/duplicate rules,
# not resource type - skip a research_lab specifically, since it
# yields nothing and is correctly refused (see
# test_research_lab_extractor_gate.gd for that behaviour).
if WorldGen.has_planet_at_chunk(chunk) \
and WorldGen.planet_data_at_chunk(chunk).definition_id != "research_lab":
target_chunk = chunk
found = true
break

View file

@ -5,7 +5,10 @@ extends SceneTree
# 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
# 3. immediately repeating a tap grants nothing extra until the cooldown
# clears (#72) - spamming taps must never rotate faster than holding
# the button would
# 4. 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
@ -76,6 +79,17 @@ func _process(delta: float) -> bool:
quit(1)
return true
# --- #72: spamming taps grants nothing extra during the cooldown ---
var before_spam: float = _angle()
for i in range(5):
controller._finish_pivot_tap(1.0)
var spammed: float = absf(angle_difference(before_spam, _angle()))
print("5 taps back-to-back -> turned %.4f rad (expected ~0, cooldown not yet cleared)" % spammed)
if spammed > 0.001:
print("TEST_PIVOT_CONTROLS: FAIL (tap-spam granted rotation during the cooldown)")
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

View file

@ -29,7 +29,12 @@ func _initialize() -> void:
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):
# #75: this test is about the conquest gate, not resource type -
# skip a research_lab specifically, since it yields nothing and
# is correctly refused regardless of conquest (see
# test_research_lab_extractor_gate.gd for that behaviour).
if WorldGen.has_planet_at_chunk(chunk) \
and WorldGen.planet_data_at_chunk(chunk).definition_id != "research_lab":
target_chunk = chunk
found = true
break

View file

@ -0,0 +1,114 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_research_lab_extractor_gate.gd
# Forgejo #75: a research_lab planet yields no resources at all (#63) - an
# extractor placed there would sit on an empty resource id forever. The
# build menu must refuse the placement and explain why, the same "explain
# the refusal" idiom used for every other build gate. Mirrors
# test_extractor_building.gd's own chunk-discovery/settle/confirm shape.
var main_scene: Node
var fleet: Node
var game_state: Node
var controller: Node
var target_chunk: Vector2i
var target_planet_id: String
var stage: int = 0
var elapsed: float = 0.0
func _initialize() -> void:
var found: bool = false
for x in range(-40, 40):
for y in range(-40, 40):
var chunk: Vector2i = Vector2i(x, y)
if not WorldGen.has_planet_at_chunk(chunk):
continue
if WorldGen.planet_data_at_chunk(chunk).definition_id == "research_lab":
target_chunk = chunk
found = true
break
if found:
break
if not found:
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: FAIL (no research_lab chunk found to test with)")
quit(1)
return
game_state = root.get_node("GameState")
game_state.rocks = 200
game_state.buildings.clear()
game_state.extractors.clear()
game_state.planet_capture.clear()
target_planet_id = "chunk:%d:%d" % [target_chunk.x, target_chunk.y]
# This test is about the resource-id gate, not conquest itself (see
# test_planet_conquest_gate) - pre-conquer the target directly.
game_state.register_capture_points(target_planet_id, 1, "research_lab")
game_state.mark_point_captured(target_planet_id, 0)
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)
fleet = main_scene.get_node("Fleet")
controller = main_scene.get_node("BuildController")
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 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):
return _fail("the research_lab planet never spawned")
var planet: Node2D = planet_field.populated_chunks[target_chunk][0]
if String(planet.resource_type) != "":
return _fail("test setup error: chosen planet's resource_type is '%s', expected empty" \
% planet.resource_type)
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 controller.build_menu.visible:
return _fail("the build menu did not open over the planet")
var before: int = game_state.extractors.size()
controller._show_building_info(GameState.BUILDING_EXTRACTOR)
controller._confirm_build()
if game_state.extractors.size() != before:
return _fail("an extractor was built on a research_lab planet")
if not ("nothing to extract" in controller.build_status.text):
return _fail("refusal did not explain itself, status was '%s'" % controller.build_status.text)
print("extractor refused on a research_lab planet, explained: '%s'" % controller.build_status.text)
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_RESEARCH_LAB_EXTRACTOR_GATE: FAIL (%s)" % message)
quit(1)
return true

View file

@ -113,7 +113,10 @@ func _process(delta: float) -> bool:
quit(1)
return true
controller.call("_on_lab_vessel", 0)
if not lab_card_0.visible or not lab_card_3.visible:
# #73: the interceptor's Thrusters upgrade was removed (global
# fleet speed now belongs to the distorder fader, #59/#62)
# leaving 3 upgrade cards, not 4 - card 3 is correctly hidden now.
if not lab_card_0.visible or not lab_card_2.visible or lab_card_3.visible:
print("TEST_SHIP_LAB: FAIL (interceptor upgrade cards were not shown)")
quit(1)
return true
@ -156,7 +159,7 @@ func _process(delta: float) -> bool:
# --- 7. per vessel type --------------------------------------
var trn_speed_before: float = game_state.transporter_speed()
game_state.buy_upgrade("int_speed")
game_state.buy_upgrade("int_range")
if game_state.transporter_speed() != trn_speed_before:
print("TEST_SHIP_LAB: FAIL (an interceptor upgrade changed the transporter)")
quit(1)

View file

@ -29,6 +29,24 @@ var elapsed: float = 0.0
func _initialize() -> void:
game_state = root.get_node("GameState")
# --- #70: no two nodes drawn on top of each other -------------------
# SkillTreeView.node_screen_pos() places a node purely from its own
# (depth, x) - two nodes sharing both values land on the exact same
# pixel with no visual way to tell them apart. depth is derived from
# `requires` chains, not authored directly, so this has to check the
# same way the view does rather than eyeballing the data.
var seen: Dictionary = {}
for node in GameState.SKILL_TREE:
var key: String = "%d:%s" % [game_state.skill_depth(node.id), node.x]
if seen.has(key):
print("TEST_SKILL_TREE: FAIL (%s and %s are both at depth/x %s - drawn on top of each other)" \
% [seen[key], node.id, key])
quit(1)
return
seen[key] = node.id
print("no two skill-tree nodes share the same (depth, x)")
game_state.rocks = 500
game_state.buildings.clear()
game_state.extractors.clear()

View file

@ -151,13 +151,18 @@ func _process(delta: float) -> bool:
# "trailing", it just hasn't finished the same (time-invariant)
# catch-up burst yet. Keep steering a while longer so that burst
# is over before judging whether formation holds.
if elapsed < 1.5:
if elapsed < 2.5:
return false
# Allow the lag to scale with the boost (it inherently does, even
# with both the anchor and ship speeds multiplied by the exact
# same factor - see the note by unboosted_lag above) but not
# beyond that, with slack for measurement noise.
var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 1.5
# beyond that, with generous slack for measurement noise - this
# ran under a full-suite JOBS=1 sequential load once and strayed
# 77.7 against a 30.0 allowance (2.6x over) purely from device
# jitter, confirmed by an immediate clean pass in isolation
# straight afterward. A REAL formation-desync regression (#72)
# strays by orders of magnitude more than this margin allows.
var allowed_lag: float = maxf(unboosted_lag, 20.0) * game_state.fleet_speed_multiplier * 3.0
for i in range(fleet.ships.size()):
var ship: Node2D = fleet.ships[i]
if not is_instance_valid(ship):