spaceship/tests/test_resource_transfer.gd
Loïc Wisniewski 2f161621c3 #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

242 lines
9.3 KiB
GDScript

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 shortcut appears beside Build whenever the fleet is
# standing on a transferable building, and opens the 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 += 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.transporters.clear()
game_state.rocks = 100
game_state.fleet_cargo = 0
game_state.stored_vessels.clear()
game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"]
scene = load("res://world/Main.tscn").instantiate()
root.add_child(scene)
fleet = scene.get_node("Fleet")
# A new game now flies STARTING_SHIP_COUNT (1) vessel into BASE_FLEET_SIZE
# (3) slots. This test needs a crewed fleet, so it asks for one outright
# rather than inheriting whatever a fresh game happens to open with -
# which is exactly what changed underneath it.
root.get_node("GameState").unlocked.append_array(["wing", "squadron", "armada"])
fleet.spawn_ships(5, Vector2.ZERO)
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 _open_transfer() -> void:
_tap(controller.transfer_button)
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()
# #56 gave the hold a finite, composition-driven capacity. This
# test is about the transfer UI, not that cap - test_fleet_cargo_
# limit.gd owns the cap itself - so it spawns the largest fleet
# every unlocked doctrine allows (8 interceptors x 20 = 160),
# comfortably above every hand-picked amount below.
fleet.spawn_ships(game_state.max_fleet_size(), 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,
"transfer shortcut did not appear beside Build")
_open_transfer()
_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
_open_transfer()
_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
var resource_list: Node = scene.get_node("UI/TransferMenu/ResourceList")
# Default is ALL. One minus selects 25, proving the amount control
# affects a real transfer rather than only changing its caption.
_tap(resource_list.get_node("OreDecreaseButton"))
_tap(resource_list.get_node("OreToFleetButton"))
_expect(game_state.fleet_cargo == mini(GameState.CARGO_LOAD_BATCH, bank_at_tap),
"selected 25 but the real tap loaded %d" % game_state.fleet_cargo)
_expect(game_state.rocks == bank_at_tap - game_state.fleet_cargo,
"the bank did not pay the selected transfer amount")
# Back to ALL and move the remainder, preserving the original
# end-to-end all-transfer coverage as well.
_tap(resource_list.get_node("OreIncreaseButton"))
_tap(resource_list.get_node("OreToFleetButton"))
_expect(game_state.fleet_cargo == bank_at_tap,
"switching back to ALL did not load the remainder")
_expect(game_state.rocks == 0, "ALL left ore in the bank")
# --- 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()