Both halves of the ticket, in the shape vessels have used since gas bombers and laser ships: a definition carries an ore figure and, when it wants more than ore, a dictionary that IS the price. `building_costs()` and `skill_costs()` answer it the way `product_costs()` already did, so there is one question with one answer whatever is being bought, and every payment path now goes through `spend_resources` rather than `spend`. Seven of ten structures and thirteen of seventeen doctrines now want gas, energy or dark matter alongside their rocks, and the totals climb with tree depth - structures 16/31/58/62/96 by depth, doctrines 15/30/73/96/143/258/530. The constraint that shaped the table is REACHABILITY, and it is the reason some prices stayed ore-only. Energy exists only where a gas burner stands, and a gas burner needs Power Systems - so nothing at or before that depth may be priced in energy, or it is a wall with no way through. Dark matter is rarer still and appears once, on the deepest node in the game. The extractor stays rocks-only despite its depth allowing gas, because the quest chain reaches it before it has sent the player for any. test_progressive_costs checks those as properties rather than transcribing the numbers, so rebalancing a price costs nothing while breaking the shape fails: nothing unreachable at its own depth, prices rising with depth, the ore field agreeing with the dictionary (repairs quote the field alone, since a repair drone carries ore and nothing else), and the quest chain never asking for a structure it has not funded. Two things cleaned up on the way: - The per-building cost constants in GameState are gone. Nothing but tests used them, and they were a second copy of a number the definitions already hold - exactly what would drift once a price gained a resource. - research_for_building() returns a display NAME, and the id-keyed half of the tree wants an id. That mix-up is silent - skill_depth() of a name finds no node and answers 0, so a deep structure reads as an ungated one, which is how the first draft of the test let an unreachable price through. research_node_for_building() is the id, and the name is expressed from it. PriceTag no longer rebuilds its labels when re-told the same price; it refreshes the held figures instead. The craft menu re-states prices every frame and the build menu now does too, so that was a dozen Labels being torn down and rebuilt sixty times a second.
278 lines
13 KiB
GDScript
278 lines
13 KiB
GDScript
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 = 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 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://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}):
|
|
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 - 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
|