From 821da05c9f6e3402718ed0a3d48116d72b1c2908 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Wed, 19 Aug 2026 12:15:00 +0200 Subject: [PATCH] Pay for everything from the bank and the fleet hold, not just buildings Construction already pooled the two; research, ship-lab upgrades and vessel unlocks still charged the bank alone. That split was arbitrary from the player's side - the resources are a couple of taps away from being banked either way, so refusing a purchase over where they happen to be sitting was ceremony. Worse, it was inconsistent: the same ore could buy a mothership and not a skill node. So the pooling moved down into can_afford/spend themselves, and the construction-specific pair is gone. can_afford_resources now pools per resource type, and a new spend_resources() charges bank first and hold for the shortfall, settling affordability in full before taking anything - a multi-resource price must not charge for the half it can cover and then fail. unlock_vessel goes through it instead of withdrawing from the bank directly. The skill menu's "you have %d" quoted the bank, which would have told the player they could not afford something they could. Ammo stays out of it. AimedFleetAbilityController reads the fleet hold directly, and should: a gas bomb is fired from what the ships are physically carrying, which is a fact about where the matter is rather than a convenience about where the money is. test_build_uses_fleet_hold became test_payments_use_fleet_hold and now walks every route - skill tree, ship lab, vessel research, multi-resource atomicity, and ammo staying unpooled. Two of its own setup steps were being silently clamped by the fleet hold's capacity, which is what made an affordable price look unaffordable; capacity is not what those checks are about, so it is taken out of the way explicitly. Full suite 106/106. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 27 +- GameState.gd | 92 +++--- tests/test_build_uses_fleet_hold.gd | 163 ---------- tests/test_payments_use_fleet_hold.gd | 278 ++++++++++++++++++ ...id => test_payments_use_fleet_hold.gd.uid} | 0 5 files changed, 343 insertions(+), 217 deletions(-) delete mode 100644 tests/test_build_uses_fleet_hold.gd create mode 100644 tests/test_payments_use_fleet_hold.gd rename tests/{test_build_uses_fleet_hold.gd.uid => test_payments_use_fleet_hold.gd.uid} (100%) diff --git a/BuildController.gd b/BuildController.gd index 63cebf1..e0169ad 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -705,8 +705,8 @@ func _show_building_info(type: String) -> void: # The same pooled test the build itself uses. Asking the bank here while # charging bank-plus-hold below would hide the build button for something # the player can in fact afford. - if can_build and not GameState.can_afford_construction(cost): - lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.construction_ore()]) + if can_build and not GameState.can_afford(cost): + lines.append("Not enough rocks - needs %d, you have %d." % [cost, GameState.available_ore()]) can_build = false info_title.text = definition.display_name @@ -753,17 +753,17 @@ func _confirm_build() -> void: # glyph/HUD rendering the moment it produced anything. build_status.text = "%s has nothing to extract" % planet.planet_name return - if not GameState.spend_construction(definition.cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.construction_ore()] + if not GameState.spend(definition.cost): + build_status.text = "Not enough rocks (need %d, have %d)" % [definition.cost, GameState.available_ore()] return GameState.add_extractor(planet.planet_name, planet.position, planet.resource_type, planet.planet_id) build_menu.visible = false return if definition.unique and GameState.has_building_of_type(type): - if not GameState.can_afford_construction(definition.cost): + if not GameState.can_afford(definition.cost): build_status.text = "Not enough rocks (need %d, have %d)" % [ - definition.cost, GameState.construction_ore() + definition.cost, GameState.available_ore() ] return # Only now is the old one destroyed - never before the new one is @@ -775,8 +775,8 @@ func _confirm_build() -> void: func _try_place(type: String, cost: int) -> void: - if not GameState.spend_construction(cost): - build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.construction_ore()] + if not GameState.spend(cost): + build_status.text = "Not enough rocks (need %d, have %d)" % [cost, GameState.available_ore()] return building_field.place(type, fleet.get_centroid()) build_menu.visible = false @@ -817,7 +817,10 @@ func _refresh_skill_menu(id: String = "") -> void: skill_status.text = "Needs %s first" % GameState.skill_node(node.requires).name skill_unlock_button.visible = false elif not GameState.can_afford(int(node.cost)): - skill_status.text = "Not enough rocks - you have %d" % GameState.rocks + # available_ore(), not the bank alone: research is paid for the same + # pooled way as everything else, so quoting the bank here would tell + # the player they cannot afford something they can. + skill_status.text = "Not enough rocks - you have %d" % GameState.available_ore() skill_unlock_button.visible = false elif GameState.research_points < points_needed: # #63: a distinct reason from the ore one above - conflating them @@ -1110,8 +1113,10 @@ func _transfer_place_name() -> String: if b.is_empty(): return "this building" if GameState.building_has_capability(b, "bank"): - # Worth naming as the bank rather than as a structure: this is the - # one building where unloading makes the ore spendable everywhere. + # Worth naming as the bank rather than as a structure: it is the + # shared pool every building's transporters draw from, not just + # another store. (It is no longer where ore has to be to be SPENT - + # purchases now draw on the fleet hold too.) return "the bank" return "this building" diff --git a/GameState.gd b/GameState.gd index b9df572..fcd1ce7 100644 --- a/GameState.gd +++ b/GameState.gd @@ -738,10 +738,8 @@ func unlock_vessel(vessel: String) -> bool: return false var costs: Dictionary = research_costs(definition.unlock_id) var points_needed: int = int(definition.research_point_cost) - if not can_afford_resources(costs) or research_points < points_needed: + if research_points < points_needed or not spend_resources(costs): return false - for resource_type in costs: - bank_inventory.withdraw(String(resource_type), int(costs[resource_type])) if points_needed > 0: research_points -= points_needed research_points_changed.emit(research_points) @@ -1800,55 +1798,63 @@ func _arrive(t: Dictionary) -> void: t.progress = 1.0 +# --- paying for things ------------------------------------------------------ +# +# EVERY payment draws on the bank AND on what the ships are carrying. The +# player can already move resources between the two by hand at any building +# that takes them, so refusing a purchase that a couple of taps would have +# made affordable was pure ceremony. +# +# In one case it was worse than ceremony. A fresh game starts with an empty +# bank, ore is mined into the fleet hold, and the only way to bank it is to +# deliver it to a mothership - so charging construction to the bank alone +# made the first mothership cost ore that could not yet exist. Mine forever, +# build nothing. +# +# Bank first, hold second. The hold is also the fleet's mining capacity, so +# leaving it as full as possible means the next trip out can still carry +# something back. +# +# The one deliberate exception is AMMO - see AimedFleetAbilityController, +# which checks fleet_resource() directly. A gas bomb is fired from what the +# ships are physically carrying; that is a fact about where the matter is, +# not a convenience about where the money is. + + +func available_resource(resource_type: String) -> int: + return bank_inventory.amount(resource_type) + fleet_inventory.amount(resource_type) + + +func available_ore() -> int: + return available_resource(RESOURCE_ORE) + + func can_afford(cost: int) -> bool: - return bank_inventory.amount(RESOURCE_ORE) >= cost + return available_ore() >= cost func can_afford_resources(costs: Dictionary) -> bool: for resource_type in costs: - if bank_inventory.amount(String(resource_type)) < int(costs[resource_type]): + if available_resource(String(resource_type)) < int(costs[resource_type]): return false return true func spend(cost: int) -> bool: - if not can_afford(cost): + return spend_resources({RESOURCE_ORE: cost}) + + +func spend_resources(costs: Dictionary) -> bool: + # Affordability is settled in full BEFORE anything is taken. Charging for + # part of a purchase that then fails is worse than refusing it, and with + # several resource types in one price that is a real possibility rather + # than a theoretical one. + if not can_afford_resources(costs): return false - bank_inventory.withdraw(RESOURCE_ORE, cost) - return true - - -# --- construction: the bank AND what the ships are carrying ----------------- -# -# A build is put up by the fleet, on the spot, so it can use ore the fleet is -# holding as well as ore already banked. That is not a convenience: a fresh -# game starts with an empty bank, and the ONLY way to bank ore is to deliver -# it to a mothership. Charging construction to the bank alone therefore made -# the first mothership cost ore that could not exist yet - the fleet could -# mine all it liked and the game was unwinnable. -# -# Research still spends from the bank alone (can_afford/spend above). That -# stays deliberate: hauling ore back to a base to fund science is the loop, -# and unlike construction it was never deadlocked, because by the time there -# is anything to research there is a mothership to bank into. - - -func construction_ore() -> int: - return bank_inventory.amount(RESOURCE_ORE) + fleet_inventory.amount(RESOURCE_ORE) - - -func can_afford_construction(cost: int) -> bool: - return construction_ore() >= cost - - -func spend_construction(cost: int) -> bool: - # Bank first, hold second. The hold is also the fleet's mining capacity, - # so leaving it as full as possible means the next trip out can still - # carry something back. - if not can_afford_construction(cost): - return false - var from_bank: int = bank_inventory.withdraw(RESOURCE_ORE, cost) - var remaining: int = cost - from_bank - if remaining > 0: - fleet_inventory.withdraw(RESOURCE_ORE, remaining) + for resource_type in costs: + var id: String = String(resource_type) + var owed: int = int(costs[resource_type]) + owed -= bank_inventory.withdraw(id, owed) + if owed > 0: + fleet_inventory.withdraw(id, owed) return true diff --git a/tests/test_build_uses_fleet_hold.gd b/tests/test_build_uses_fleet_hold.gd deleted file mode 100644 index 2a79f14..0000000 --- a/tests/test_build_uses_fleet_hold.gd +++ /dev/null @@ -1,163 +0,0 @@ -extends SceneTree - -# Headless gameplay test: run with -# godot --headless -s res://tests/test_build_uses_fleet_hold.gd -# A new game starts with an empty bank. Ore is mined into the FLEET HOLD, and -# the only way to move it into the bank is to deliver it to a mothership - so -# charging construction to the bank alone made the first mothership cost ore -# that could not exist yet. Mine forever, build nothing: the game was -# unwinnable from its own opening position. -# -# Construction now draws on the bank AND the hold. Covers: -# A. the deadlock premise still holds - a bank-only test rejects a build -# the fleet can plainly pay for, so this is guarding something real -# B. the pooled test accepts it, and paying takes from the bank first and -# the hold only for the shortfall -# C. an unaffordable build takes nothing at all (no partial charge) -# D. end to end: a fleet holding ore, with an empty bank, actually gets a -# mothership built through BuildController -# E. research is deliberately NOT pooled - it still spends from the bank - -# Deliberately more than one starting interceptor can carry (its hold is 20, -# which is exactly MOTHERSHIP_COST). The capacity is widened where this is -# used, so "the hold paid for it" is visible as a remainder rather than as -# the hold happening to empty to zero. -const HOLD_ORE: int = 40 - -var game_state: Node -var scene: Node -var build_controller: Node -var stage: int = 0 -var elapsed: float = 0.0 - - -func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.reset_progression() - game_state.buildings.clear() - game_state.extractors.clear() - - var cost: int = GameState.MOTHERSHIP_COST - - # --- A. the deadlock, exactly as a fresh game presents it --------------- - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) - game_state.fleet_inventory.total_capacity = HOLD_ORE - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) - if game_state.can_afford(cost): - _fail("the bank-only test passed with an empty bank, so it can no longer show the deadlock this fixes") - return - print("bank 0 + hold %d: bank-only affordability says no (the original deadlock)" % HOLD_ORE) - - # --- B. pooled, and paid bank-first ------------------------------------- - if not game_state.can_afford_construction(cost): - _fail("a fleet holding %d ore still could not afford a %d build" % [HOLD_ORE, cost]) - return - if not game_state.spend_construction(cost): - _fail("spend_construction refused a build it had just called affordable") - return - if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0: - _fail("paying from an empty bank somehow changed it") - return - if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != HOLD_ORE - cost: - _fail("the hold should have paid the whole %d, but holds %d of %d" \ - % [cost, game_state.fleet_inventory.amount(GameState.RESOURCE_ORE), HOLD_ORE]) - return - print("paid %d entirely from the hold: hold %d -> %d" % [cost, HOLD_ORE, HOLD_ORE - cost]) - - # Bank first when there is a bank to draw on: the hold is also the fleet's - # mining capacity, so it is spent last. - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8) - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30) - if not game_state.spend_construction(20): - _fail("a 20 build was refused with 8 banked and 30 held") - return - if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ - or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 18: - _fail("a split payment should empty the bank first: bank %d, hold %d (expected 0 and 18)" \ - % [game_state.bank_inventory.amount(GameState.RESOURCE_ORE), - game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) - return - print("split payment drained the bank first: bank 8 -> 0, hold 30 -> 18") - - # --- C. no partial charge ----------------------------------------------- - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5) - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5) - if game_state.spend_construction(50): - _fail("a build that cost more than everything owned was allowed") - return - if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \ - or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 5: - _fail("a rejected build still took ore: bank %d, hold %d (expected 5 and 5)" \ - % [game_state.bank_inventory.amount(GameState.RESOURCE_ORE), - game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) - return - print("a build nobody can afford takes nothing") - - # --- E. research stays banked ------------------------------------------- - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 500) - if game_state.can_afford(1): - _fail("research affordability started counting the fleet hold - it is meant to stay banked") - return - print("research still spends from the bank alone") - - # --- D. end to end, through the real build path -------------------------- - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - current_scene = scene - - -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - if elapsed < 0.3: - return false - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - build_controller = scene.get_node("BuildController") - - # Whatever else the scene did while starting up, the point of the - # test is a fleet with ore and an empty bank. - game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) - # Widened past what a starting interceptor can carry. Its real - # hold is 20 and a mothership costs exactly 20, so at the stock - # capacity a successful build empties the hold to zero - which is - # indistinguishable from the hold having been emptied by anything - # else. With room to spare, the remainder is the evidence. - game_state.fleet_inventory.total_capacity = HOLD_ORE - game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) - var before: int = game_state.buildings.size() - - build_controller.pending_build_type = GameState.BUILDING_MOTHERSHIP - build_controller._confirm_build() - - if game_state.buildings.size() != before + 1: - return _fail("building a mothership from the fleet's own hold did not place one (%d -> %d): %s" \ - % [before, game_state.buildings.size(), build_controller.build_status.text]) - # Checked as a TOTAL, not as the hold alone: a mothership standing - # where the fleet is starts taking delivery of the hold on the - # very next tick, so ore legitimately moves from hold to bank - # straight after the build. What must be true is that exactly the - # cost left the pool - and that it left, since the bank was empty - # and could not have paid. - var total: int = game_state.construction_ore() - if total != HOLD_ORE - GameState.MOTHERSHIP_COST: - return _fail("the mothership was placed but %d ore remains, expected %d" \ - % [total, HOLD_ORE - GameState.MOTHERSHIP_COST]) - print("built a mothership with an empty bank, paid out of the fleet hold (%d ore left, bank %d + hold %d)" \ - % [total, game_state.bank_inventory.amount(GameState.RESOURCE_ORE), - game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) - - print("TEST_BUILD_USES_FLEET_HOLD: PASS") - quit(0) - return true - return false - - -func _fail(message: String) -> bool: - print("TEST_BUILD_USES_FLEET_HOLD: FAIL (%s)" % message) - quit(1) - return true diff --git a/tests/test_payments_use_fleet_hold.gd b/tests/test_payments_use_fleet_hold.gd new file mode 100644 index 0000000..462932f --- /dev/null +++ b/tests/test_payments_use_fleet_hold.gd @@ -0,0 +1,278 @@ +extends SceneTree + +# Headless gameplay test: run with +# godot --headless -s res://tests/test_payments_use_fleet_hold.gd +# Every payment draws on the bank AND on what the ships are carrying. The +# player can already shuttle resources between the two by hand, so refusing a +# purchase that a couple of taps would have made affordable was ceremony. +# +# It was also a deadlock. A new game starts with an empty bank, ore is mined +# into the fleet hold, and the only way to bank it is to deliver it to a +# mothership - so charging construction to the bank alone made the first +# mothership cost ore that could not yet exist. +# +# Covers: +# A. the deadlock premise - a bank-only check rejects what the fleet can +# plainly pay for, so this is guarding something real +# B. paying takes from the bank first and the hold only for the shortfall +# C. an unaffordable purchase takes nothing at all +# D. EVERY payment route is pooled, not just building: the skill tree, ship +# lab upgrades, and multi-resource vessel research +# E. ammo is deliberately NOT pooled - a shot comes out of what the ships +# are physically carrying +# F. end to end: an empty bank and ore in the hold actually gets a +# mothership built through BuildController + +const HOLD_ORE: int = 40 + +var game_state: Node +var scene: Node +var build_controller: Node +var stage: int = 0 +var elapsed: float = 0.0 + + +func _initialize() -> void: + game_state = root.get_node("GameState") + game_state.reset_progression() + game_state.buildings.clear() + game_state.extractors.clear() + + var cost: int = GameState.MOTHERSHIP_COST + + # --- A. the deadlock, exactly as a fresh game presents it --------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + # Deliberately more than one starting interceptor can carry (its hold is + # 20, which is exactly MOTHERSHIP_COST), so "the hold paid" shows up as a + # remainder rather than as the hold happening to empty to zero. + game_state.fleet_inventory.total_capacity = HOLD_ORE + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) + if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) >= cost: + _fail("the bank was not actually empty, so nothing below tests the deadlock") + return + if not game_state.can_afford(cost): + _fail("a fleet holding %d ore could not afford a %d purchase" % [HOLD_ORE, cost]) + return + print("bank 0 + hold %d can afford a %d purchase" % [HOLD_ORE, cost]) + + # --- B. bank first, hold for the shortfall ------------------------------- + if not game_state.spend(cost): + _fail("spend refused a purchase it had just called affordable") + return + if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ + or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != HOLD_ORE - cost: + _fail("paying from an empty bank should have taken the whole %d from the hold: bank %d, hold %d" \ + % [cost, game_state.bank_inventory.amount(GameState.RESOURCE_ORE), + game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) + return + + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 8) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 30) + if not game_state.spend(20): + _fail("a 20 purchase was refused with 8 banked and 30 held") + return + if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 0 \ + or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 18: + _fail("a split payment should empty the bank first: bank %d, hold %d (expected 0 and 18)" \ + % [game_state.bank_inventory.amount(GameState.RESOURCE_ORE), + game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) + return + print("split payment drained the bank first: bank 8 -> 0, hold 30 -> 18") + + # --- C. no partial charge ----------------------------------------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 5) + if game_state.spend(50): + _fail("a purchase costing more than everything owned was allowed") + return + if game_state.bank_inventory.amount(GameState.RESOURCE_ORE) != 5 \ + or game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 5: + _fail("a rejected purchase still took resources: bank %d, hold %d (expected 5 and 5)" \ + % [game_state.bank_inventory.amount(GameState.RESOURCE_ORE), + game_state.fleet_inventory.amount(GameState.RESOURCE_ORE)]) + return + print("a purchase nobody can afford takes nothing") + + # The hold has a real capacity (one starting interceptor carries 20), and + # set_amount() is clamped by it - which silently gave the checks below + # less than they asked for and made an affordable price look unaffordable. + # Capacity is not what any of them are about, so take it out of the way; + # the end-to-end stage sets a concrete one again. + game_state.fleet_inventory.total_capacity = -1 + + if not _check_multi_resource_atomicity(): + return + if not _check_skill_tree(): + return + if not _check_ship_lab(): + return + if not _check_vessel_research(): + return + if not _check_ammo_is_not_pooled(): + return + + # --- F. end to end, through the real build path -------------------------- + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) + scene = load("res://Main.tscn").instantiate() + root.add_child(scene) + current_scene = scene + + +# A price in two resources where only one is coverable must take NEITHER. +func _check_multi_resource_atomicity() -> bool: + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 10) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, 10) + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 1) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 1) + if game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 40}): + return _fail("a two-resource price was charged with only one of them affordable") + if game_state.available_ore() != 20 or game_state.available_resource(GameState.RESOURCE_GAS) != 2: + return _fail("a refused two-resource purchase still took some ore or gas (ore %d, gas %d)" \ + % [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)]) + if not game_state.spend_resources({GameState.RESOURCE_ORE: 15, GameState.RESOURCE_GAS: 2}): + return _fail("an affordable two-resource price was refused") + if game_state.available_ore() != 5 or game_state.available_resource(GameState.RESOURCE_GAS) != 0: + return _fail("a two-resource purchase charged wrongly (ore %d left, gas %d left)" \ + % [game_state.available_ore(), game_state.available_resource(GameState.RESOURCE_GAS)]) + print("multi-resource prices are all-or-nothing, and pooled per resource") + return true + + +func _check_skill_tree() -> bool: + var id: String = "shipyards" + var node: Dictionary = game_state.skill_node(id) + var cost: int = int(node.cost) + game_state.unlocked.clear() + game_state.research_points = 999 + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) + if not game_state.can_unlock(id): + return _fail("the skill tree refused '%s' (cost %d) with the whole price in the fleet hold" % [id, cost]) + if not game_state.unlock(id): + return _fail("unlocking '%s' failed despite can_unlock saying yes" % id) + if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: + return _fail("the skill unlock did not charge the hold: %d left of %d" \ + % [game_state.fleet_inventory.amount(GameState.RESOURCE_ORE), cost]) + print("skill tree: unlocked '%s' for %d paid entirely from the hold" % [id, cost]) + return true + + +func _check_ship_lab() -> bool: + # Ship-lab upgrades need a lab standing; the subject is where the money + # comes from, not the prerequisite. + game_state.buildings.clear() + game_state.buildings.append({"id": 1, "type": GameState.BUILDING_SHIP_LAB, + "position": Vector2.ZERO}) + if not game_state.has_ship_lab(): + return _fail("could not place a ship lab, so the upgrade path cannot be tested") + var id: String = "" + for upgrade in game_state.upgrades_for(GameState.VESSEL_INTERCEPTOR): + if not game_state.upgrade_maxed(String(upgrade.id)): + id = String(upgrade.id) + break + if id == "": + return _fail("no ship-lab upgrade was available to buy") + var cost: int = game_state.upgrade_cost(id) + game_state.research_points = 999 + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) + if not game_state.can_buy_upgrade(id): + return _fail("the ship lab refused upgrade '%s' (cost %d) with the whole price in the hold" % [id, cost]) + if not game_state.buy_upgrade(id): + return _fail("buying '%s' failed despite can_buy_upgrade saying yes" % id) + if game_state.fleet_inventory.amount(GameState.RESOURCE_ORE) != 0: + return _fail("the upgrade did not charge the hold: %d left of %d" \ + % [game_state.fleet_inventory.amount(GameState.RESOURCE_ORE), cost]) + print("ship lab: bought '%s' for %d paid entirely from the hold" % [id, cost]) + return true + + +func _check_vessel_research() -> bool: + # Vessel unlocks are the multi-resource payment route. + var vessel: String = "" + var costs: Dictionary = {} + for definition in game_state.VESSEL_DEFINITIONS.all(): + if String(definition.unlock_id) == "" or game_state.vessel_is_unlocked(String(definition.definition_id)): + continue + vessel = String(definition.definition_id) + costs = game_state.research_costs(String(definition.unlock_id)) + if not costs.is_empty(): + break + if vessel == "" or costs.is_empty(): + return _fail("no locked vessel with a resource cost was available to research") + + game_state.research_points = 999 + for resource_type in costs: + game_state.bank_inventory.set_amount(String(resource_type), 0) + game_state.fleet_inventory.set_amount(String(resource_type), int(costs[resource_type])) + if not game_state.unlock_vessel(vessel): + return _fail("researching '%s' was refused with its whole price (%s) in the fleet hold" % [vessel, str(costs)]) + for resource_type in costs: + if game_state.available_resource(String(resource_type)) != 0: + return _fail("researching '%s' left %d %s unspent" \ + % [vessel, game_state.available_resource(String(resource_type)), resource_type]) + print("vessel research: unlocked '%s' for %s paid entirely from the hold" % [vessel, str(costs)]) + return true + + +func _check_ammo_is_not_pooled() -> bool: + # Firing is about where the matter physically IS, so it reads the hold + # directly rather than going through the pooled payment path. + game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 500) + game_state.fleet_inventory.set_amount(GameState.RESOURCE_GAS, 0) + if game_state.fleet_resource(GameState.RESOURCE_GAS) != 0: + return _fail("banked gas leaked into the fleet hold reading that ammo checks use") + print("ammo still reads the fleet hold alone, ignoring 500 banked gas") + return true + + +func _process(delta: float) -> bool: + # quit() takes effect on the NEXT frame, so a failure raised during + # _initialize still gets one _process call with nothing set up. Without + # this the test spent five minutes reporting a null scene instead of the + # reason it actually stopped. + if scene == null: + return true + elapsed += delta + match stage: + 0: + if elapsed < 0.3: + return false + scene.get_node("AsteroidField").set_process(false) + scene.get_node("EnemySpawner").set_process(false) + build_controller = scene.get_node("BuildController") + + game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) + game_state.fleet_inventory.total_capacity = HOLD_ORE + game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, HOLD_ORE) + var before: int = game_state.buildings.size() + + build_controller.pending_build_type = GameState.BUILDING_MOTHERSHIP + build_controller._confirm_build() + + if game_state.buildings.size() != before + 1: + return _fail("building a mothership from the fleet's own hold did not place one (%d -> %d): %s" \ + % [before, game_state.buildings.size(), build_controller.build_status.text]) + # Checked as a TOTAL, not as the hold alone: a mothership standing + # where the fleet is starts taking delivery of the hold on the + # very next tick, so ore legitimately moves from hold to bank + # straight after the build. What must be true is that exactly the + # cost left the pool - and that it left, since the bank was empty + # and could not have paid. + var total: int = game_state.available_ore() + if total != HOLD_ORE - GameState.MOTHERSHIP_COST: + return _fail("the mothership was placed but %d ore remains, expected %d" \ + % [total, HOLD_ORE - GameState.MOTHERSHIP_COST]) + print("built a mothership with an empty bank, paid out of the fleet hold") + + print("TEST_PAYMENTS_USE_FLEET_HOLD: PASS") + quit(0) + return true + return false + + +func _fail(message: String) -> bool: + print("TEST_PAYMENTS_USE_FLEET_HOLD: FAIL (%s)" % message) + quit(1) + return false diff --git a/tests/test_build_uses_fleet_hold.gd.uid b/tests/test_payments_use_fleet_hold.gd.uid similarity index 100% rename from tests/test_build_uses_fleet_hold.gd.uid rename to tests/test_payments_use_fleet_hold.gd.uid