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 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-19 12:15:00 +02:00
commit 821da05c9f
5 changed files with 341 additions and 215 deletions

View file

@ -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