spaceship/tests/test_payments_use_fleet_hold.gd
Loïc Wisniewski 2f161621c3 #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

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 = 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://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 - 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