Remove the one-way ore API superseded by the transfer menu

The loose end from #24. withdraw_to_fleet, unload_into_building and
deliver_cargo were the old one-way moves behind "Load ore" and "Store
ore here"; every one of them is a special case of transfer_to_building /
transfer_to_fleet, and none had a caller left outside tests. The
cargo_delivered signal had no listeners at all.

Not just tidying: unload_into_building routed through
deposit_to_building, which does NOT special-case the mothership - it
writes b.stock, while building_holdings() returns `rocks` for a
mothership and ignores stock entirely. So unload_into_building(
mothership_id) would have parked ore in a field nothing reads, with no
error. Nothing called it that way, but it is a plausibly-named function
sitting there to be reached for later. transfer_to_building handles it.

CARGO_LOAD_BATCH stays - the transfer menu's batch buttons use it; only
its role as withdraw_to_fleet's default argument is gone.

The #24 note expected folding these in to be costly for
test_cargo_readout, which places no buildings. It is not: the transfer
primitives work off GameState records rather than spawned nodes, so a
single add_building() in setup is enough and it stays a display test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-12 13:07:52 +02:00
commit b6427b080d
27 changed files with 1085 additions and 275 deletions

View file

@ -1,8 +1,8 @@
extends Node2D
# Preview, not a test. Opens the CraftMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
# real viewport - label collisions and things falling off the bottom of
# shorter screens are what no headless test can see.
#
# bash tests/screenshot.sh res://tests/_craft_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
@ -17,11 +17,14 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0))
var crafter_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(crafter_id, 42)
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
bc.craft_building_id = crafter_id
# The shared header (#24) is the thing worth eyeballing here, so give it
# a building to report on - otherwise its HERE column hides itself.
for bar in bc.resource_bars:
bar.building_id = crafter_id
main.get_node("UI/CraftMenu").visible = true
if "Craft" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()
bc._refresh_craft_status()

View file

@ -16,10 +16,17 @@ func _ready() -> void:
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
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
# A level already bought, so the list shows both a researched and an
# unresearched line rather than a uniform page of zeroes.
GameState.vessel_levels["int_damage"] = 1
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
main.get_node("UI/LabMenu").visible = true
bc.lab_vessel = GameState.VESSEL_INTERCEPTOR
bc._refresh_lab()

View file

@ -1,8 +1,8 @@
extends Node2D
# Preview, not a test. Opens the SkillMenu so its layout can be checked on a
# real viewport - the mothership's "Load ore" label was falling off the bottom
# of shorter screens, which no headless test can see.
# real viewport - the tree lost vertical room to the shared resource header
# (#24), and whether it still fits is a thing only pixels can answer.
#
# bash tests/screenshot.sh res://tests/_skill_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
@ -17,15 +17,13 @@ func _ready() -> void:
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_TRANSPORT_HUB, Vector2(400.0, 0.0))
main.get_node("BuildingField").place(GameState.BUILDING_MOTHERSHIP, Vector2(400.0, 0.0))
var mothership_id: int = int(GameState.buildings[-1].id)
var bc: Node = main.get_node("BuildController")
bc.craft_building_id = 1
for bar in bc.resource_bars:
bar.building_id = mothership_id
main.get_node("UI/SkillMenu").visible = true
if "Skill" == "Craft":
bc._refresh_craft_status()
else:
bc._refresh_load_ore()
# Select a node so the description, price and status all have text -
# an empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")
# Select a node so the description, price and status all have text - an
# empty description cannot collide with anything.
bc.skill_tree.selected_id = "freight"
bc._refresh_skill_menu("freight")

View file

@ -0,0 +1,27 @@
extends Node2D
# Preview, not a test. Opens the TransferMenu (#24) so its layout can be
# checked on a real viewport - four buttons, two captions and the shared
# header have to coexist without collisions, which no headless test can see.
#
# bash tests/screenshot.sh res://tests/_transfer_menu_preview.tscn /tmp/m.png 20 720x1280
# ... and again at 720x960 to check a shorter screen format.
func _ready() -> void:
GameState.buildings.clear()
GameState.extractors.clear()
GameState.transporters.clear()
GameState.rocks = 180
GameState.fleet_cargo = 24
GameState.unlocked = ["shipyards", "logistics", "freight"]
var main: Node = load("res://Main.tscn").instantiate()
add_child(main)
main.get_node("BuildingField").place(GameState.BUILDING_SHIP_CRAFTER, Vector2(400.0, 0.0))
var crafter_id: int = int(GameState.buildings[-1].id)
GameState.deposit_to_building(crafter_id, 42)
var bc: Node = main.get_node("BuildController")
bc.transfer_building_id = crafter_id
main.get_node("UI/TransferMenu").visible = true
bc._refresh_transfer()
bc.transfer_status.text = "Moved 25 ore into this building"

View file

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

View file

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

View file

@ -27,6 +27,7 @@ var scene: Node
var banked: Control
var cargo: Control
var stage: int = 0
var mothership_id: int = 0
var elapsed: float = 0.0
@ -36,6 +37,11 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.rocks = 42
game_state.fleet_cargo = 0
# Banking is a transfer TO the mothership now (#24), and a transfer needs
# a building to aim at. Only the record is required - the transfer
# primitives work off GameState, not off the spawned node - so this stays
# a display test rather than becoming one about the building system.
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
banked = scene.get_node("UI/ResourceCounter")
@ -95,7 +101,7 @@ func _process(delta: float) -> bool:
])
# --- 4. delivery moves it across ----------------------------
game_state.deliver_cargo()
game_state.transfer_to_building(mothership_id, game_state.fleet_cargo)
elapsed = 0.0
stage = 2
return false

View file

@ -5,29 +5,28 @@ extends SceneTree
# Every press in the ship crafter must say what happened. Reported twice from
# play-testing as "the buttons do nothing".
#
# There is no silent-failure path left to press: storing with an empty hold,
# ordering something the crafter cannot pay for yet, and storing successfully
# all report. The queue in particular is invisible by nature - an order that
# cannot be afforded is accepted and waits (#16), which looks exactly like a
# dead button unless it says so.
# The queue is invisible by nature - an order that cannot be afforded is
# accepted and waits (#16), which looks exactly like a dead button unless it
# says so.
#
# Verifies:
# 1. storing with an empty hold explains where ore comes from
# 2. ordering against an empty stock says it is queued AND how much more
# 1. ordering against an empty stock says it is queued AND how much more
# ore the building needs
# 3. a successful store reports the amount
# 4. the message survives the per-frame status refresh - it used to be
# 2. the message survives the per-frame status refresh - it used to be
# overwritten on the very next frame, which is what made a working
# button look dead
# 5. the message is the FIRST line, not buried under the standing state
# 6. the panel is opened the way the GAME opens it - park the fleet on the
# crafter and press the action button - and every button then works
# 3. the message is the FIRST line, not buried under the standing state
# 4. the panel is opened the way the GAME opens it - park the fleet on the
# crafter and press the action button - and its buttons then work
#
# Point 6 exists because this test used to set craft_building_id by hand.
# Point 4 exists because this test used to set craft_building_id by hand.
# That skipped the one step that was actually broken: opening the panel
# stopped recording WHICH crafter, so every handler bailed out at its `== 0`
# guard while the panel still opened. Reported four times as "the buttons do
# nothing", and invisible to every test that set the id itself.
#
# Storing ore used to be tested here too. It moved out of this panel entirely
# in #24 - see test_resource_transfer.gd.
var game_state: Node
var scene: Node
@ -44,7 +43,11 @@ func _initialize() -> void:
game_state.extractors.clear()
game_state.transporters.clear()
game_state.reset_progression()
game_state.unlocked = ["shipyards", "coordination", "wing"]
# Every capacity node, so the interceptor button stays available for the
# real-tap checks below. A full fleet correctly DISABLES it, and a
# disabled button not reacting to a tap is not the bug those checks are
# looking for.
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.vessels_owed.clear()
@ -104,17 +107,7 @@ func _process(delta: float) -> bool:
return true
print("panel opened from the building, bound to crafter %d" % controller.craft_building_id)
# --- 1. store with an empty hold ----------------------------
controller._on_store_ore()
var empty_msg: String = _first_line()
if "carrying nothing" not in empty_msg:
print("got: %s" % empty_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (storing with an empty hold said nothing useful)")
quit(1)
return true
print("empty hold -> %s" % empty_msg)
# --- 2. order against an empty stock ------------------------
# --- 1. order against an empty stock ------------------------
controller._on_craft_interceptor()
var queued_msg: String = _first_line()
if "queued" not in queued_msg.to_lower() or "more ore" not in queued_msg:
@ -127,71 +120,47 @@ func _process(delta: float) -> bool:
quit(1)
return true
print("unaffordable order -> %s" % queued_msg)
# --- 3. a successful store ----------------------------------
game_state.withdraw_to_fleet(30)
controller._on_store_ore()
var stored_msg: String = _first_line()
if "Stored" not in stored_msg:
print("got: %s" % stored_msg)
print("TEST_CRAFTER_FEEDBACK: FAIL (a successful store did not report)")
quit(1)
return true
print("successful store -> %s" % stored_msg)
elapsed = 0.0
stage = 2
return false
2:
# --- 4 + 5. it survives the refresh, and stays first --------
# --- 2 + 3. it survives the refresh, and stays first --------
if elapsed < 0.5:
return false
controller._refresh_craft_status()
var still: String = _first_line()
if "Stored" not in still:
if "queued" not in still.to_lower():
print("first line half a second later: %s" % still)
print("TEST_CRAFTER_FEEDBACK: FAIL (the message was overwritten by the per-frame refresh)")
quit(1)
return true
print("half a second later, still first: %s" % still)
# --- 6. a REAL tap, through the input path ------------------
# --- 4. a REAL tap, through the input path ------------------
# Everything above calls the handlers directly, which is how a
# whole class of bug kept getting missed: the crafter logic was
# fine and the BUTTON was inert, blocked by a modal guard. This
# taps the control the way a touch does.
game_state.withdraw_to_fleet(20)
var stock_before: int = game_state.building_stock(crafter_id)
var store_button: Control = scene.get_node("UI/CraftMenu/StoreOreButton")
for pressed in [true, false]:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = pressed
ev.position = store_button.size / 2.0
store_button._gui_input(ev)
if game_state.building_stock(crafter_id) <= stock_before:
print("stock %d -> %d after tapping Store ore" % [
stock_before, game_state.building_stock(crafter_id)
var queued_before: int = game_state.pending_orders(crafter_id)
_tap(scene.get_node("UI/CraftMenu/InterceptorButton"))
if game_state.pending_orders(crafter_id) <= queued_before:
print("orders %d -> %d after tapping Interceptor" % [
queued_before, game_state.pending_orders(crafter_id)
])
print("TEST_CRAFTER_FEEDBACK: FAIL (tapping the store button did nothing)")
print("TEST_CRAFTER_FEEDBACK: FAIL (tapping the interceptor button did nothing)")
quit(1)
return true
print("real tap on Store ore moved %d ore into the crafter" % [
game_state.building_stock(crafter_id) - stock_before
])
print("real tap on Interceptor queued another order")
# And the same tap must still work after the build menu has been
# opened and closed, which used to block every button forever.
controller._on_build_pressed()
controller._show_building_info(GameState.BUILDING_SHIP_CRAFTER)
scene.get_node("UI/BuildMenu").visible = false
game_state.withdraw_to_fleet(10)
var stock_mid: int = game_state.building_stock(crafter_id)
for pressed2 in [true, false]:
var ev2: InputEventScreenTouch = InputEventScreenTouch.new()
ev2.pressed = pressed2
ev2.position = store_button.size / 2.0
store_button._gui_input(ev2)
if game_state.building_stock(crafter_id) <= stock_mid:
print("TEST_CRAFTER_FEEDBACK: FAIL (store button dead after the build menu was opened and closed)")
var queued_mid: int = game_state.pending_orders(crafter_id)
_tap(scene.get_node("UI/CraftMenu/InterceptorButton"))
if game_state.pending_orders(crafter_id) <= queued_mid:
print("TEST_CRAFTER_FEEDBACK: FAIL (craft button dead after the build menu was opened and closed)")
quit(1)
return true
print("still works after opening and closing the build menu")

View file

@ -12,7 +12,10 @@ extends SceneTree
#
# Verifies:
# 1. mining fills the hold and leaves the bank alone
# 2. reaching the mothership banks the lot, and empties the hold
# 2. unloading at the mothership banks the lot and empties the hold - but
# only when the player asks. Arriving used to do it by itself; #24
# removed that, so this now checks BOTH halves: parking changes
# nothing, and the explicit transfer moves everything
# 3. with no mothership there is nowhere to deliver, so cargo just
# accumulates - it is never quietly banked anyway
# 4. sitting on a DIFFERENT building is not a delivery
@ -27,6 +30,7 @@ var field: Node
var stage: int = 0
var elapsed: float = 0.0
var banked_before: int = 0
var mothership_id: int = 0
const MOTHERSHIP_AT: Vector2 = Vector2(1200.0, 0.0)
const CRAFTER_AT: Vector2 = Vector2(-4000.0, 0.0)
@ -117,6 +121,7 @@ func _process(delta: float) -> bool:
# --- 4. a different building is not a delivery point --------
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
_park_fleet_on(CRAFTER_AT)
elapsed = 0.0
stage = 2
@ -139,6 +144,14 @@ func _process(delta: float) -> bool:
if elapsed < 0.4:
return false
var total_before: int = banked_before + 3
# Parking alone must do nothing now (#24).
if game_state.fleet_cargo != 3 or game_state.rocks != banked_before:
print("TEST_FLEET_CARGO: FAIL (arriving at the mothership banked the hold by itself)")
quit(1)
return true
print("parked on the mothership -> hold still %d (no auto-banking)" % game_state.fleet_cargo)
game_state.transfer_to_building(mothership_id, 999)
print("delivered: hold=%d bank=%d (expected hold=0 bank=%d)" % [
game_state.fleet_cargo, game_state.rocks, total_before
])
@ -156,10 +169,6 @@ func _process(delta: float) -> bool:
# Not simulated by loading PlanetSurface.tscn here: the point is
# that cargo lives in the autoload rather than in Main.tscn, so
# tearing Main.tscn down must not touch it.
# Fly off the mothership first, or this new cargo is (correctly)
# banked the moment BuildingField next runs, and the teardown
# check below would be measuring a delivery instead.
_park_fleet_on(CRAFTER_AT)
game_state.collect_cargo(5)
scene.queue_free()
elapsed = 0.0

View file

@ -14,9 +14,10 @@ extends SceneTree
# 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
# 3. arriving at the mothership does NOT bank the hold. It used to, edge
# triggered on arrival (#14); #24 removed that entirely, because an
# automatic one-way move overrides a player who has just loaded the
# hold in order to carry ore somewhere else
# 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
@ -28,6 +29,7 @@ var fleet: Node
var field: Node
var controller: Node
var crafter_id: int = 0
var mothership_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var ships_before: int = 0
@ -74,6 +76,7 @@ func _process(delta: float) -> bool:
_clear_hazards()
fleet.spawn_ships(2, MOTHERSHIP_AT)
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
total_before = _total()
@ -107,7 +110,7 @@ func _process(delta: float) -> bool:
if elapsed < 0.3:
return false
var bank_before: int = game_state.rocks
var took: int = game_state.withdraw_to_fleet(GameState.SHIP_COST)
var took: int = game_state.transfer_to_fleet(mothership_id, 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)
@ -123,14 +126,22 @@ func _process(delta: float) -> bool:
stage = 4
return false
4:
# --- 3. still parked on the mothership ----------------------
# --- 3. parked on the mothership, and arriving again --------
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)")
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (ore withdrawn while parked on the mothership was banked again by itself)")
quit(1)
return true
print("still parked -> hold kept its %d (delivery is edge triggered)" % game_state.fleet_cargo)
# Leaving and coming back is the case the old edge-triggered
# delivery fired on, so it is the one worth re-entering.
_park_fleet_on(CRAFTER_AT)
_park_fleet_on(MOTHERSHIP_AT)
if game_state.fleet_cargo != GameState.SHIP_COST:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (arriving at the mothership auto-banked the hold - #24 removed that)")
quit(1)
return true
print("hold kept its %d across an arrival (no auto-banking)" % game_state.fleet_cargo)
# --- 4. carry it to the crafter and open it -----------------
_park_fleet_on(CRAFTER_AT)
@ -140,7 +151,7 @@ func _process(delta: float) -> bool:
5:
if elapsed < 0.6:
return false
var moved: int = game_state.unload_into_building(crafter_id)
var moved: int = game_state.transfer_to_building(crafter_id, game_state.fleet_cargo)
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)
@ -180,7 +191,7 @@ func _process(delta: float) -> bool:
# --- 2. no overdraft ----------------------------------------
game_state.rocks = 3
var over: int = game_state.withdraw_to_fleet(999)
var over: int = game_state.transfer_to_fleet(mothership_id, 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)

View file

@ -0,0 +1,219 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_resource_transfer.gd
# Forgejo #24: moving ore between the fleet and a building is its own
# top-level action, not something buried inside the ship crafter or the
# research screen.
#
# Verifies:
# 1. the transfer button appears alongside the action button whenever the
# fleet is standing on a building, and opens the transfer panel bound to
# THAT building - the `== 0` guard failure that made every crafter
# button look dead is the same trap here
# 2. ore moves both ways at an ordinary building, capped by what is
# actually at each end (no overdraft in either direction)
# 3. at a mothership the building's holdings ARE the bank, so unloading
# there banks the ore and loading there draws on it
# 4. a REAL tap through the input path moves ore, not just the handler
# 5. pressing against an empty end reports why instead of doing nothing
# 6. the shared resource bar reports the building the panel was opened from
# 7. ore is conserved across the whole exercise
var game_state: Node
var scene: Node
var fleet: Node
var controller: Node
var crafter_id: int = 0
var mothership_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
var total_before: int = 0
var failures: PackedStringArray = PackedStringArray()
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:
if b.type != GameState.BUILDING_MOTHERSHIP:
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.transporters.clear()
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.vessels_owed.clear()
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
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 _tap(button: Control) -> void:
for pressed in [true, false]:
var ev: InputEventScreenTouch = InputEventScreenTouch.new()
ev.pressed = pressed
ev.position = button.size / 2.0
button._gui_input(ev)
func _expect(condition: bool, message: String) -> void:
if not condition:
failures.append("stage %d: %s" % [stage, message])
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)
var field: Node = scene.get_node("BuildingField")
field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
mothership_id = int(game_state.buildings[-1].id)
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:
# --- 1. the button appears, and binds to this building ------
if elapsed < 1.2:
return false
_expect(controller.current_building != null,
"parking on the crafter never registered")
_expect(controller.transfer_button.visible,
"the transfer button did not appear at a building")
_tap(controller.transfer_button)
_expect(controller.transfer_menu.visible,
"tapping the transfer button did not open the panel")
_expect(controller.transfer_building_id == crafter_id,
"panel bound to building %d, crafter is %d"
% [controller.transfer_building_id, crafter_id])
# --- 6. the shared bar follows the panel --------------------
for bar in controller.resource_bars:
_expect(bar.building_id == crafter_id,
"a resource bar is reporting building %d, not the crafter %d"
% [bar.building_id, crafter_id])
# --- 5. an empty hold explains itself -----------------------
game_state.fleet_cargo = 0
controller._on_transfer(true, GameState.CARGO_LOAD_BATCH)
_expect("carrying nothing" in controller.transfer_status.text,
"unloading an empty hold said: %s" % controller.transfer_status.text)
# --- 2. both directions at an ordinary building -------------
# Baseline taken AFTER the last hand-set figure: the conservation
# check at the end is about the transfers, and ore conjured into
# the hold to set the scene would otherwise read as a leak.
game_state.fleet_cargo = 40
total_before = _total()
controller._on_transfer(true, GameState.CARGO_LOAD_BATCH)
_expect(game_state.building_stock(crafter_id) == GameState.CARGO_LOAD_BATCH,
"a batch into the crafter left stock at %d"
% game_state.building_stock(crafter_id))
_expect(game_state.fleet_cargo == 40 - GameState.CARGO_LOAD_BATCH,
"the hold paid %d, not the batch" % (40 - game_state.fleet_cargo))
controller._on_transfer(false, 999)
_expect(game_state.building_stock(crafter_id) == 0,
"loading ALL left %d in the crafter" % game_state.building_stock(crafter_id))
_expect(game_state.fleet_cargo == 40,
"the hold came back to %d, not 40" % game_state.fleet_cargo)
# Asking for more than exists moves only what is there.
var over: int = game_state.transfer_to_fleet(crafter_id, 999)
_expect(over == 0, "overdrew %d from an empty crafter" % over)
_park_fleet_on(MOTHERSHIP_AT)
controller.transfer_menu.visible = false
elapsed = 0.0
stage = 2
return false
2:
# --- 3. the mothership's holdings are the bank --------------
if elapsed < 1.2:
return false
_expect(controller.transfer_button.visible,
"the transfer button did not appear at the mothership")
_tap(controller.transfer_button)
_expect(controller.transfer_building_id == mothership_id,
"panel bound to %d, mothership is %d"
% [controller.transfer_building_id, mothership_id])
_expect(game_state.building_holdings(mothership_id) == game_state.rocks,
"mothership holdings %d != bank %d"
% [game_state.building_holdings(mothership_id), game_state.rocks])
var bank_before: int = game_state.rocks
var hold_before: int = game_state.fleet_cargo
controller._on_transfer(true, 999)
_expect(game_state.fleet_cargo == 0,
"unloading ALL at the mothership left %d in the hold" % game_state.fleet_cargo)
_expect(game_state.rocks == bank_before + hold_before,
"the bank went to %d, expected %d" % [game_state.rocks, bank_before + hold_before])
# --- 4. a REAL tap, through the input path ------------------
# The handler working while the BUTTON is inert is the exact bug
# class this project keeps hitting, so one press goes the long way.
var bank_at_tap: int = game_state.rocks
_tap(scene.get_node("UI/TransferMenu/ToFleetBatchButton"))
_expect(game_state.fleet_cargo == GameState.CARGO_LOAD_BATCH,
"a real tap loaded %d, expected a batch of %d"
% [game_state.fleet_cargo, GameState.CARGO_LOAD_BATCH])
_expect(game_state.rocks == bank_at_tap - GameState.CARGO_LOAD_BATCH,
"the bank did not pay for the tapped withdrawal")
# --- 7. conservation ----------------------------------------
_expect(_total() == total_before,
"ore created or destroyed: %d -> %d" % [total_before, _total()])
_finish()
return true
return false
func _finish() -> void:
if failures.is_empty():
print("TEST_RESOURCE_TRANSFER: PASS")
quit(0)
else:
print("bank=%d hold=%d crafter=%d" % [
game_state.rocks, game_state.fleet_cargo, game_state.building_stock(crafter_id)
])
for f in failures:
print("FAIL: %s" % f)
print("TEST_RESOURCE_TRANSFER: FAIL")
quit(1)
func _clear_hazards() -> void:
# Drifting rocks can destroy a parked ship and change the fleet size out
# from under a logistics test. Same reason as test_manual_supply_route.
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 @@
uid://j27f3sjneewj