extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_payments_use_fleet_hold.gd # A payment draws on the bank and, only at the place being paid, what the # ships are carrying. Fleet cargo is physical: a building across the map may # not reach into it (#180). # # 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 # G. no payment position, or a distant one, cannot drain the fleet hold const HOLD_ORE: int = 40 const PAYMENT_AT := Vector2(120.0, 60.0) 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() game_state.fleet_position = PAYMENT_AT var cost: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]) # --- 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 game_state.can_afford(cost): _fail("a location-free affordability check reached into the fleet hold") return if not game_state.can_afford(cost, PAYMENT_AT): _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, PAYMENT_AT): _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, PAYMENT_AT): _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, PAYMENT_AT): _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") # --- G. cargo does not teleport to a remote purchase ------------------ game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 0) game_state.fleet_inventory.set_amount(GameState.RESOURCE_ORE, cost) game_state.fleet_position = PAYMENT_AT + Vector2(9000.0, 0.0) if game_state.spend(cost, PAYMENT_AT): _fail("a remote purchase took resources from the fleet hold") return if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost: _fail("a refused remote payment still drained the hold") return if game_state.spend(cost): _fail("a purchase with no location reached into the fleet hold") return game_state.fleet_position = PAYMENT_AT print("distant and location-free payments leave fleet cargo untouched") # 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://world/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}, PAYMENT_AT): 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}, PAYMENT_AT): 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, PAYMENT_AT): 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, PAYMENT_AT): 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, PAYMENT_AT): 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, PAYMENT_AT): 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, PAYMENT_AT): 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_menu.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 - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE]): return _fail("the mothership was placed but %d ore remains, expected %d" \ % [total, HOLD_ORE - int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP)[GameState.RESOURCE_ORE])]) 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