spaceship/tests/test_resource_transfer.gd
Loïc Wisniewski 1f5fecd371 Buy fleet berths with Fleet Offices
#219: fleet capacity is a built, supplied thing rather than a researched
one. The eight fleet-size doctrines are gone; capacity is one berth free
plus one per Fleet Office standing. An office charges every 25s for the
vessel in its berth, priced off that vessel's own build cost so an expensive
hull is expensive to keep, and drawn from its own store the way a gas burner
draws gas. An office that cannot pay goes dark, its vessel is scuttled, and
the player is told through the same queue quest lines use - which reaches
them on a planet surface too.

An empty berth is idle rather than starved: an office is only failing while
there is something in it to fail, and holding it disabled would leave a
berth that could never be crewed and so could never be paid for again.

Ships have no hit points and a shield absorbs a hit wholesale, so the lost
vessel is scuttled rather than damaged - otherwise a shielded hull would
survive its own eviction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
2026-09-05 17:09:27 +02:00

245 lines
9.5 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"]
# #219: fleet capacity is built now - one berth free, one per Fleet
# Office. This test flies more than one vessel, so it needs the offices
# standing before the fleet spawns into them.
# Eight, which is what every doctrine used to allow: the amounts below are
# hand-picked to sit under a hold that size (8 interceptors x 20 = 160).
load("res://tests/_berths.gd").grant(root.get_node("GameState"), 8)
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.
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 into every berth the
# offices above bought (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 --------------------
_expect(controller.transfer_menu.building_id == crafter_id,
"transfer scene did not own the selected building context")
# --- 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_menu.status.text,
"unloading an empty hold said: %s" % controller.transfer_menu.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()