spaceship/tests/test_manual_supply_route.gd

248 lines
9.3 KiB
GDScript3
Raw Permalink Normal View History

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 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
#157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down #157 was already satisfied - second_hull is quest 7 and extractor is quest 11 - so this pins the property rather than adding a quest: nothing costing more than a single hold may be asked for before the chain has grown the fleet or taught banking. It fails if either is ever moved behind it. #158 - a building's local costs may now be paid out of the fleet's hold. #12 made production cost resources physically DELIVERED rather than money out of the bank, and that stands: the bank is still not raided here. The hold is simply the other place resources physically are, and making the player park, open the transfer menu and move cargo across by hand was ceremony rather than a decision. But only WHERE THE FLEET IS, and that qualifier is the whole design. The first draft had no range on it and test_manual_supply_route caught it immediately: a crafter three thousand units away reached into the hold and spent ore the player had just withdrawn to carry somewhere else - which is exactly the complaint that got automatic banking removed in #24, arriving from the other direction. The range is Building.interact_radius, the same distance that decides whether the transfer button appears, so the rule is not a hidden one: if you could hand the cargo over, the building can take it. #159 - the mothership floored at 1hp, so a battered one sat there reading as dead and never exploded. That exemption existed to prevent a soft-lock, and the soft-lock turns out not to exist: the bank is GameState.bank_inventory and the hangar is stored_vessels, both of which outlive any building; the mothership is the one structure research never gates; it costs exactly what a single interceptor's hold carries; and ore comes from asteroids, which do not run out. So the way back is always mine, fly, rebuild - and that is what test_building_health now asserts, in place of the exemption that used to stand in for it.
2026-08-29 15:51:34 +02:00
# 4. a building takes the fleet's whole hold when opened - and since #158
# it does not need to be asked: a queued order at a building the fleet
# is PARKED ON pays itself out of the hold, so the hand-off happens on
# arrival rather than through the transfer menu. The manual move still
# works and is still what stocks a building ahead of an order, which is
# checked separately here
# 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 mothership_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:
2026-08-14 20:32:58 +02:00
if b.type != GameState.BUILDING_MOTHERSHIP:
sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE)
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
2026-08-12 22:47:46 +02:00
game_state.stored_vessels.clear()
game_state.build_time_scale = 0.02
game_state.unlocked = ["shipyards", "coordination"]
#84: move 215 root files into a folder hierarchy Everything lived in one flat directory, so a diff gave no clue what area it touched and finding the file you wanted meant knowing its exact name. Twelve folders now, grouped by what a thing IS rather than what type it is - a vessel's definition, its component scenes and its upgrade resources sit together, because that is the set you read when you change one of them. buildings core controllers enemies logistics planets resources shaders surface ui vessels vfx world 332 files moved and every res:// reference rewritten - 306 files touched, including the autoload and main-scene paths in project.godot. Nothing renamed: this is purely where files sit, so the diff is a pile of pure moves and no behaviour is buried in it. ui_theme.tres, icon.svg and web_shell.html stay at the root, where Godot and the export preset expect them. Verified three ways, because a suite alone would not have caught all of it: the full 117 pass, the web export builds clean, and the game renders. The export is why that matters. The FIRST export after the move failed with "Attempt to open script 'res://GameState.gd'" - the old path - while project.godot on disk plainly said res://core/GameState.gd. That is this project's documented Android FUSE mtime trap: Godot served a cached copy of a file whose content had changed. A `touch` fixed it. Nothing in the test suite would have found that, because the suite runs from a copy on a native filesystem - the one place the bug cannot happen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 19:44:14 +02:00
scene = load("res://world/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)
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:
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.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)
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. 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 banked again by itself)")
quit(1)
return true
# 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)
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 0.6:
return false
#157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down #157 was already satisfied - second_hull is quest 7 and extractor is quest 11 - so this pins the property rather than adding a quest: nothing costing more than a single hold may be asked for before the chain has grown the fleet or taught banking. It fails if either is ever moved behind it. #158 - a building's local costs may now be paid out of the fleet's hold. #12 made production cost resources physically DELIVERED rather than money out of the bank, and that stands: the bank is still not raided here. The hold is simply the other place resources physically are, and making the player park, open the transfer menu and move cargo across by hand was ceremony rather than a decision. But only WHERE THE FLEET IS, and that qualifier is the whole design. The first draft had no range on it and test_manual_supply_route caught it immediately: a crafter three thousand units away reached into the hold and spent ore the player had just withdrawn to carry somewhere else - which is exactly the complaint that got automatic banking removed in #24, arriving from the other direction. The range is Building.interact_radius, the same distance that decides whether the transfer button appears, so the rule is not a hidden one: if you could hand the cargo over, the building can take it. #159 - the mothership floored at 1hp, so a battered one sat there reading as dead and never exploded. That exemption existed to prevent a soft-lock, and the soft-lock turns out not to exist: the bank is GameState.bank_inventory and the hangar is stored_vessels, both of which outlive any building; the mothership is the one structure research never gates; it costs exactly what a single interceptor's hold carries; and ore comes from asteroids, which do not run out. So the way back is always mine, fly, rebuild - and that is what test_building_health now asserts, in place of the exemption that used to stand in for it.
2026-08-29 15:51:34 +02:00
# #158: parked on the crafter with the ore aboard, the queued
# order takes it - the flight IS the delivery now, and the hold
# is empty because it has been spent rather than because nothing
# happened.
if game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (parked on the crafter with %d still in the hold)" % game_state.fleet_cargo)
quit(1)
return true
#157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down #157 was already satisfied - second_hull is quest 7 and extractor is quest 11 - so this pins the property rather than adding a quest: nothing costing more than a single hold may be asked for before the chain has grown the fleet or taught banking. It fails if either is ever moved behind it. #158 - a building's local costs may now be paid out of the fleet's hold. #12 made production cost resources physically DELIVERED rather than money out of the bank, and that stands: the bank is still not raided here. The hold is simply the other place resources physically are, and making the player park, open the transfer menu and move cargo across by hand was ceremony rather than a decision. But only WHERE THE FLEET IS, and that qualifier is the whole design. The first draft had no range on it and test_manual_supply_route caught it immediately: a crafter three thousand units away reached into the hold and spent ore the player had just withdrawn to carry somewhere else - which is exactly the complaint that got automatic banking removed in #24, arriving from the other direction. The range is Building.interact_radius, the same distance that decides whether the transfer button appears, so the rule is not a hidden one: if you could hand the cargo over, the building can take it. #159 - the mothership floored at 1hp, so a battered one sat there reading as dead and never exploded. That exemption existed to prevent a soft-lock, and the soft-lock turns out not to exist: the bank is GameState.bank_inventory and the hangar is stored_vessels, both of which outlive any building; the mothership is the one structure research never gates; it costs exactly what a single interceptor's hold carries; and ore comes from asteroids, which do not run out. So the way back is always mine, fly, rebuild - and that is what test_building_health now asserts, in place of the exemption that used to stand in for it.
2026-08-29 15:51:34 +02:00
# Orders run at build_time_scale 0.02 here, so by now it has not
# only started on the hold's ore but finished.
if game_state.pending_orders(crafter_id) != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (the hold emptied but the order is still queued)")
quit(1)
return true
print("parked on the crafter: the queued order took the hold's %d itself" % GameState.SHIP_COST)
# ...and the manual move still works, which is what stocks a
# building AHEAD of an order rather than paying for one.
game_state.collect_cargo(4)
var moved: int = game_state.transfer_to_building(crafter_id, 4)
if moved != 4 or game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (hand-unloaded %d of 4, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1)
return true
print("and a hand transfer still moves %d into the crafter" % moved)
elapsed = 0.0
stage = 6
return false
6:
# Long enough for both construction and the physical delivery trip
# from this remote crafter to the mothership hangar.
var delivery_time: float = CRAFTER_AT.distance_to(MOTHERSHIP_AT) \
/ GameState.VESSEL_DELIVERY_SPEED
if elapsed < game_state.build_time(GameState.PRODUCT_SHIP) * 3.0 + delivery_time + 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
2026-08-12 22:47:46 +02:00
# Since #23 a finished vessel waits in the hangar rather than
# crewing itself, so this is where it lands.
if game_state.stored_vessels.size() != 1:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel built: %d in the hangar)" % game_state.stored_vessels.size())
quit(1)
return true
2026-08-12 22:47:46 +02:00
print("queued order fulfilled by hand, no transport hub: %d vessel in the hangar" % game_state.stored_vessels.size())
# --- 6. conservation ----------------------------------------
#157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down #157 was already satisfied - second_hull is quest 7 and extractor is quest 11 - so this pins the property rather than adding a quest: nothing costing more than a single hold may be asked for before the chain has grown the fleet or taught banking. It fails if either is ever moved behind it. #158 - a building's local costs may now be paid out of the fleet's hold. #12 made production cost resources physically DELIVERED rather than money out of the bank, and that stands: the bank is still not raided here. The hold is simply the other place resources physically are, and making the player park, open the transfer menu and move cargo across by hand was ceremony rather than a decision. But only WHERE THE FLEET IS, and that qualifier is the whole design. The first draft had no range on it and test_manual_supply_route caught it immediately: a crafter three thousand units away reached into the hold and spent ore the player had just withdrawn to carry somewhere else - which is exactly the complaint that got automatic banking removed in #24, arriving from the other direction. The range is Building.interact_radius, the same distance that decides whether the transfer button appears, so the rule is not a hidden one: if you could hand the cargo over, the building can take it. #159 - the mothership floored at 1hp, so a battered one sat there reading as dead and never exploded. That exemption existed to prevent a soft-lock, and the soft-lock turns out not to exist: the bank is GameState.bank_inventory and the hangar is stored_vessels, both of which outlive any building; the mothership is the one structure research never gates; it costs exactly what a single interceptor's hold carries; and ore comes from asteroids, which do not run out. So the way back is always mine, fly, rebuild - and that is what test_building_health now asserts, in place of the exemption that used to stand in for it.
2026-08-29 15:51:34 +02:00
# The 4 mined above to exercise the hand transfer are still in
# the crafter's stock, so they are part of the total.
var expected: int = total_before - GameState.SHIP_COST + 4
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.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)
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()