spaceship/tests/test_resource_transfer.gd

245 lines
9.5 KiB
GDScript3
Raw Permalink Normal View History

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:
2026-08-14 20:32:58 +02:00
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
2026-08-12 22:47:46 +02:00
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)
#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")
Rework how a game starts: dev mode, a bare opening, and fleet growth Adds a Dev Mode entry to the main menu and takes the play-testing conveniences out of a normal game, which turn out to be the same change from two directions. DEV MODE. Banks 2000 of every resource, grants 500 research points, and plants one of each planet type in the chunks around the spawn. Built ON TOP of reset_new_game() rather than beside it, so a dev game is a normal game plus a stock rather than a second definition of "fresh game" to keep in sync. Offered only when running from Godot - OS.has_feature("editor"), the tag an export clears - as is the map editor. Hidden AND guarded: pressing either anyway is refused, because visibility is presentation and the rule is the rule. The menu now lays its stack out in code. Hiding two of four entries with scene-authored offsets would leave holes in a shipped build - the same failure the HUD columns had - so it measures the entries that actually exist and centres on them. A NORMAL GAME NOW HANDS OVER NOTHING. No 500 banked ore (removed from both reset_new_game and the autoload's own default, so the two cannot disagree), and no gas giant guaranteed in the origin chunk. Both were play-testing conveniences that had become part of the real world's shape: every player got the same landmark in the same place. Play-testing wanted all four planet types anyway, and a normal game wanted none of them - different needs, now different mechanisms. WorldGen cannot read GameState back (an autoload is not resolvable as a global identifier that early, the hazard PlanetSetups.gd already works around), so GameState.dev_mode's setter PUSHES the flag down. World generation stays static and dependency-free, GameState stays the single source of truth, and dev planting is applied after every rng draw so a dev world and a normal one differ only in those four chunks. FLEET SIZE. BASE_FLEET_SIZE was doing two jobs - how many slots exist AND how many ships you start with - which forced a new game to open with a full fleet, leaving the first ship crafter nothing to do. Split into BASE_FLEET_SIZE (3 slots) and STARTING_SHIP_COUNT (1 vessel), so the opening has two empty slots to fill. A third copy of the old value turned up in PlanetAtmosphereController, hardcoded as spawn_ships(5) and silently degraded into "however many slots exist" once max_fleet_size() clamped it. The doctrine branch now runs 3 -> 10 slots. Wing (30 -> 15) and Squadron (70 -> 35, 2 -> 1 points) are cut hardest: they are no longer a mid-game luxury but the ordinary way out of the opening, and pricing them as a luxury made the early game feel stuck. Armada follows at 90/2. Two new tiers, Battlegroup and Grand Fleet, grant +2 each - seven single-vessel nodes in one lane would be a long monotonous chain for a branch that only ever does one thing. DEFAULT_FORMATION grows to 10 slots, because max_fleet_size() clamps to its length and a doctrine past that would charge real resources for nothing; test_fleet_growth.gd asserts that invariant directly. TEST FALLOUT, all of it from the deliberate change rather than a defect: - Tests that silently inherited the old 5-ship default now ask for the fleet they need. Several had to crew it from their first frame rather than _initialize(), where the engine's deferred tree-entry finalization undoes it - the same hazard those tests already documented for set_process(false). - test_planet_landing_proximity and test_return_position_matches_planet take the FIRST chunk holding a planet and land there. The guaranteed gas giant had been masking that this can now be a black hole, which destroys ships on contact. They exclude it, and clear the asteroids the field already placed. - test_pivot_controls measured rotation with angle_difference, which wraps at +/-pi, so a hold that turned a full circle read as no rotation. Latent flakiness that only ever showed under load; it now reads the controller's unwrapped pivot_hold_amount. - test_research_points was failing on ore, not points: can_unlock() checks the ore price too, and a fresh game banks nothing now. - test_gas_system's "a reachable gas source near the spawn" premise moved to dev mode, which is where that guarantee now lives. Full suite green at 97/97. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-18 12:10:14 +02:00
# 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)
2026-08-13 23:25:46 +02:00
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")
2026-08-13 23:25:46 +02:00
_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
2026-08-13 23:25:46 +02:00
_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"))
2026-08-13 23:25:46 +02:00
_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()