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