Supply a building by hand: withdraw at the mothership, unload on arrival

Forgejo #17, part 4 of #12. #16 lets an order queue waiting for ore, so
something has to be able to fulfil one. Until transporters exist (#19)
this is the only route, which is why it lands before them rather than
after: otherwise a player with a queued order and no Transport Hub is
stuck forever.

A "Load ore" button at the mothership moves a batch from the bank into
the fleet's hold, and opening a building hands it whatever the fleet is
carrying.

Delivery at the mothership is now EDGE triggered on arrival rather than
running every frame the fleet is parked there. Continuous delivery made
withdrawing impossible - ore loaded into the hold was banked again on the
very next frame, so the button would have looked broken. A batch rather
than the whole bank, because the hold is the part that can be lost and
emptying the bank in one tap would also strand research, which still
spends from it.

Both new logistics tests now clear existing asteroids: disabling
AsteroidField only stops new chunks spawning, and a drifting rock
destroying a parked ship changed the fleet size the tests assert on.
That was a real intermittent failure, seen once in a full run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 16:36:11 +02:00
commit ff162faef5
6 changed files with 296 additions and 3 deletions

View file

@ -39,6 +39,8 @@ extends Node2D
@onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton")
@onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton")
@onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel")
@onready var load_ore_button: IconButton = get_node("../UI/SkillMenu/LoadOreButton")
@onready var load_ore_label: Label = get_node("../UI/SkillMenu/LoadOreLabel")
@onready var skill_menu: Control = get_node("../UI/SkillMenu") @onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree") @onready var skill_tree: Control = get_node("../UI/SkillMenu/Tree")
@ -104,6 +106,7 @@ func _ready() -> void:
action_button.pressed.connect(_on_action_pressed) action_button.pressed.connect(_on_action_pressed)
craft_interceptor_button.pressed.connect(_on_craft_interceptor) craft_interceptor_button.pressed.connect(_on_craft_interceptor)
load_ore_button.pressed.connect(_on_load_ore)
craft_close_button.pressed.connect(func(): craft_menu.visible = false) craft_close_button.pressed.connect(func(): craft_menu.visible = false)
formation_close_button.pressed.connect(func(): formation_menu.visible = false) formation_close_button.pressed.connect(func(): formation_menu.visible = false)
@ -211,6 +214,7 @@ func _on_action_pressed() -> void:
return return
if current_building.building_type == GameState.BUILDING_MOTHERSHIP: if current_building.building_type == GameState.BUILDING_MOTHERSHIP:
skill_menu.visible = true skill_menu.visible = true
_refresh_load_ore()
_refresh_skill_menu() _refresh_skill_menu()
elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER: elif current_building.building_type == GameState.BUILDING_SHIP_CRAFTER:
craft_menu.visible = true craft_menu.visible = true
@ -454,6 +458,17 @@ func _on_craft_interceptor() -> void:
_refresh_craft_status() _refresh_craft_status()
func _refresh_load_ore() -> void:
load_ore_label.text = "Load ore\nBank %d Hold %d" % [GameState.rocks, GameState.fleet_cargo]
load_ore_button.enabled = GameState.rocks > 0
load_ore_button.queue_redraw()
func _on_load_ore() -> void:
GameState.withdraw_to_fleet()
_refresh_load_ore()
func _on_formation_changed() -> void: func _on_formation_changed() -> void:
fleet.apply_formation() fleet.apply_formation()

View file

@ -11,6 +11,9 @@ extends Node2D
var building_scene: PackedScene = preload("res://Building.tscn") var building_scene: PackedScene = preload("res://Building.tscn")
var spawned: Array = [] var spawned: Array = []
# Whether the fleet was already standing on the mothership last frame - see
# the edge-triggered delivery in _process().
var _was_on_mothership: bool = false
func _ready() -> void: func _ready() -> void:
@ -23,11 +26,16 @@ func _process(_delta: float) -> void:
# fleet standing on one?" - and it uses the fleet CENTROID, the same # fleet standing on one?" - and it uses the fleet CENTROID, the same
# stable single-number test Planet and Building already use, rather than # stable single-number test Planet and Building already use, rather than
# per-ship collision that flickers as ships jostle in formation. # per-ship collision that flickers as ships jostle in formation.
if GameState.fleet_cargo <= 0: # EDGE-triggered on arrival, not continuous. Delivering every frame the
return # fleet is parked on the mothership would make withdrawing impossible:
# ore loaded into the hold at the mothership (#17) would be banked again
# the very next frame, and the button would look broken.
var under: Node2D = get_building_fleet_is_on() var under: Node2D = get_building_fleet_is_on()
if under != null and under.building_type == GameState.BUILDING_MOTHERSHIP: var on_mothership: bool = under != null \
and under.building_type == GameState.BUILDING_MOTHERSHIP
if on_mothership and not _was_on_mothership and GameState.fleet_cargo > 0:
GameState.deliver_cargo() GameState.deliver_cargo()
_was_on_mothership = on_mothership
func rebuild_all() -> void: func rebuild_all() -> void:

View file

@ -45,6 +45,35 @@ func collect_cargo(amount: int = 1) -> void:
fleet_cargo += amount fleet_cargo += amount
# One press of "load ore" at the mothership. A batch rather than the whole
# bank: the hold is the part that can be lost, and emptying the bank in one
# tap would also strand research, which still spends from it.
const CARGO_LOAD_BATCH: int = 25
func withdraw_to_fleet(amount: int = CARGO_LOAD_BATCH) -> int:
# Banked ore back into the hold, so the fleet can carry it to a building
# that is waiting on it (#17).
var taken: int = mini(amount, rocks)
if taken <= 0:
return 0
rocks -= taken
fleet_cargo += taken
return taken
func unload_into_building(id: int) -> int:
# Everything the fleet is carrying, into this building's stock. Returns
# how much moved so a caller can tell the player something happened.
if fleet_cargo <= 0:
return 0
var moved: int = fleet_cargo
if deposit_to_building(id, moved) <= 0:
return 0
fleet_cargo = 0
return moved
func deliver_cargo() -> int: func deliver_cargo() -> int:
# Everything the fleet is carrying, banked at once. Returns how much moved # Everything the fleet is carrying, banked at once. Returns how much moved
# so a caller can react to a delivery actually having happened rather than # so a caller can react to a delivery actually having happened rather than

View file

@ -761,6 +761,28 @@ script = ExtResource("10")
icon = 5 icon = 5
glow_color = Color(0.45, 0.9, 0.55, 1) glow_color = Color(0.45, 0.9, 0.55, 1)
[node name="LoadOreButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -256.0
offset_top = 1090.0
offset_right = -108.0
offset_bottom = 1238.0
script = ExtResource("10")
icon = 4
glow_color = Color(1, 0.78, 0.25, 1)
[node name="LoadOreLabel" type="Label" parent="UI/SkillMenu"]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -300.0
offset_top = 1246.0
offset_right = -64.0
offset_bottom = 1296.0
horizontal_alignment = 1
autowrap_mode = 2
text = "Load ore"
[node name="CloseButton" type="Control" parent="UI/SkillMenu"] [node name="CloseButton" type="Control" parent="UI/SkillMenu"]
anchor_left = 1.0 anchor_left = 1.0
anchor_right = 1.0 anchor_right = 1.0

View file

@ -63,6 +63,7 @@ func _process(delta: float) -> bool:
0: 0:
scene.get_node("AsteroidField").set_process(false) scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false) scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
fleet.spawn_ships(2, Vector2.ZERO) fleet.spawn_ships(2, Vector2.ZERO)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id) crafter_id = int(game_state.buildings[-1].id)
@ -231,3 +232,16 @@ func _process(delta: float) -> bool:
quit(0) quit(0)
return true return true
return false return false
func _clear_hazards() -> void:
# set_process(false) on AsteroidField only stops NEW chunks spawning -
# rocks already in the world keep drifting and can destroy a parked ship,
# which silently changes the fleet size this test asserts on. This is a
# logistics test, not a survival one.
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()

View file

@ -0,0 +1,205 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_manual_supply_route.gd
# Forgejo #17 (part 4 of #12): the player can supply a building by hand -
# withdraw banked ore into the fleet's hold at the mothership, fly it over,
# and have the building take it.
#
# This is what keeps the game unblockable. #16 lets an order queue waiting
# for ore, and until transporters exist (#19) there is otherwise no way to
# fulfil one - a player with a queued order and no Transport Hub would be
# stuck forever.
#
# Verifies:
# 1. withdrawing moves ore bank -> hold, and conserves the total
# 2. withdrawing is capped by what is actually banked (no overdraft)
# 3. arriving at the mothership banks the hold, but doing so is EDGE
# triggered - ore withdrawn while parked there is not instantly taken
# back, which would make the button look broken
# 4. a building takes the fleet's whole hold when opened
# 5. end to end: a queued order the bank cannot reach is fulfilled using
# only the fleet, with no Transport Hub in existence
# 6. nothing is created or destroyed anywhere along the route
var game_state: Node
var scene: Node
var fleet: Node
var field: Node
var controller: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var ships_before: int = 0
var total_before: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(3000.0, 0.0)
func _total() -> int:
var sum: int = game_state.rocks + game_state.fleet_cargo
for b in game_state.buildings:
sum += int(b.get("stock", 0))
return sum
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.rocks = 40
game_state.fleet_cargo = 0
game_state.ships_owed = 0
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
field = scene.get_node("BuildingField")
controller = scene.get_node("BuildController")
func _park_fleet_on(at: Vector2) -> void:
for ship in fleet.ships:
ship.position = at
fleet.move_to(at)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
_clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
total_before = _total()
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
# --- 5. an order the crafter cannot pay for -----------------
ships_before = fleet.ships.size()
game_state.queue_order(crafter_id)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if game_state.pending_orders(crafter_id) != 1:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order did not queue)")
quit(1)
return true
# --- 1 + 2. withdraw at the mothership ----------------------
_park_fleet_on(MOTHERSHIP_AT)
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 0.3:
return false
var bank_before: int = game_state.rocks
var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST)
if took != GameState.SHIP_COST or game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (withdrew %d into a hold of %d)" % [took, game_state.fleet_cargo])
quit(1)
return true
if game_state.rocks != bank_before - GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (bank did not pay for the withdrawal)")
quit(1)
return true
print("withdrew %d at the mothership -> bank=%d hold=%d" % [
took, game_state.rocks, game_state.fleet_cargo
])
elapsed = 0.0
stage = 4
return false
4:
# --- 3. still parked on the mothership ----------------------
if elapsed < 0.5:
return false
if game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was instantly banked again - delivery is not edge triggered)")
quit(1)
return true
print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo)
# --- 4. carry it to the crafter and open it -----------------
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.6:
return false
var moved: int = game_state.unload_into_building(crafter_id)
if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1)
return true
print("unloaded %d into the crafter" % moved)
elapsed = 0.0
stage = 6
return false
6:
if elapsed < 0.5:
return false
# --- 5. the queued order completed off the fleet's own ore ---
if game_state.pending_orders(crafter_id) != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)")
quit(1)
return true
if fleet.ships.size() != ships_before + 1:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()])
quit(1)
return true
print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [
ships_before, fleet.ships.size()
])
# --- 6. conservation ----------------------------------------
var expected: int = total_before - GameState.SHIP_COST
if _total() != expected:
print("bank=%d hold=%d stocks total=%d, expected %d" % [
game_state.rocks, game_state.fleet_cargo, _total(), expected
])
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore created or destroyed along the route)")
quit(1)
return true
print("ore conserved: %d -> %d, exactly the %d the vessel cost" % [
total_before, _total(), GameState.SHIP_COST
])
# --- 2. no overdraft ----------------------------------------
game_state.rocks = 3
var over: int = game_state.withdraw_to_fleet(999)
if over != 3 or game_state.rocks != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (overdrew: took %d from a bank of 3, leaving %d)" % [over, game_state.rocks])
quit(1)
return true
print("withdrawing 999 from a bank of 3 took %d" % over)
print("TEST_MANUAL_SUPPLY_ROUTE: PASS")
quit(0)
return true
return false
func _clear_hazards() -> void:
# set_process(false) on AsteroidField only stops NEW chunks spawning -
# rocks already in the world keep drifting and can destroy a parked ship,
# which silently changes the fleet size this test asserts on. This is a
# logistics test, not a survival one.
for a in get_nodes_in_group("asteroids"):
if is_instance_valid(a):
a.queue_free()
for e in get_nodes_in_group("enemies"):
if is_instance_valid(e):
e.queue_free()