#157, #158, #159: pay from the hold you flew in, and nothing is exempt from coming down

#157 was already satisfied - second_hull is quest 7 and extractor is quest
11 - so this pins the property rather than adding a quest: nothing costing
more than a single hold may be asked for before the chain has grown the
fleet or taught banking. It fails if either is ever moved behind it.

#158 - a building's local costs may now be paid out of the fleet's hold.
#12 made production cost resources physically DELIVERED rather than money
out of the bank, and that stands: the bank is still not raided here. The
hold is simply the other place resources physically are, and making the
player park, open the transfer menu and move cargo across by hand was
ceremony rather than a decision.

But only WHERE THE FLEET IS, and that qualifier is the whole design. The
first draft had no range on it and test_manual_supply_route caught it
immediately: a crafter three thousand units away reached into the hold and
spent ore the player had just withdrawn to carry somewhere else - which is
exactly the complaint that got automatic banking removed in #24, arriving
from the other direction. The range is Building.interact_radius, the same
distance that decides whether the transfer button appears, so the rule is
not a hidden one: if you could hand the cargo over, the building can take
it.

#159 - the mothership floored at 1hp, so a battered one sat there reading as
dead and never exploded. That exemption existed to prevent a soft-lock, and
the soft-lock turns out not to exist: the bank is GameState.bank_inventory
and the hangar is stored_vessels, both of which outlive any building; the
mothership is the one structure research never gates; it costs exactly what
a single interceptor's hold carries; and ore comes from asteroids, which do
not run out. So the way back is always mine, fly, rebuild - and that is what
test_building_health now asserts, in place of the exemption that used to
stand in for it.
This commit is contained in:
Loïc Wisniewski 2026-08-29 15:51:34 +02:00
commit 4c920f10b0
7 changed files with 397 additions and 39 deletions

View file

@ -25,6 +25,10 @@ var surviving_vessel_types: PackedStringArray = PackedStringArray()
# whenever the composition changes. GameState cannot see scene nodes, and the # whenever the composition changes. GameState cannot see scene nodes, and the
# census the Planning Center works from has to include the fleet. # census the Planning Center works from has to include the fleet.
var fleet_vessel_types: PackedStringArray = PackedStringArray() var fleet_vessel_types: PackedStringArray = PackedStringArray()
# Where the fleet is, written by Fleet each frame. Only #158 needs it: a
# building may draw on the hold when the hold is actually there, and this
# autoload has no other way to know.
var fleet_position: Vector2 = Vector2.ZERO
# #122: the prologue is running. Set by reset_new_game() so it plays once at # #122: the prologue is running. Set by reset_new_game() so it plays once at
# the start of a NEW game and never on a resumed one, and cleared by the # the start of a NEW game and never on a resumed one, and cleared by the
# director when the prologue hands over. # director when the prologue hands over.
@ -693,6 +697,7 @@ func reset_new_game() -> void:
surviving_ship_count = STARTING_SHIP_COUNT surviving_ship_count = STARTING_SHIP_COUNT
surviving_vessel_types = PackedStringArray() surviving_vessel_types = PackedStringArray()
fleet_vessel_types = PackedStringArray() fleet_vessel_types = PackedStringArray()
fleet_position = Vector2.ZERO
return_world_position = Vector2.ZERO return_world_position = Vector2.ZERO
return_camera_zoom = 1.0 return_camera_zoom = 1.0
planet_name = "" planet_name = ""
@ -854,8 +859,23 @@ func building_health_fraction(id: int) -> float:
# savings and their stored vessels with no route back, so it bottoms out at # savings and their stored vessels with no route back, so it bottoms out at
# one hit point instead of dying. Keyed on the capability rather than on the # one hit point instead of dying. Keyed on the capability rather than on the
# type: whatever building holds the bank is the one that cannot be lost. # type: whatever building holds the bank is the one that cannot be lost.
func building_is_indestructible(building: Dictionary) -> bool: # #159: "a building with no life should explode" - and nothing is exempt any
return building_has_capability(building, "bank") # more. The mothership used to floor at 1hp, so a battered one sat there
# reading as dead and never came down.
#
# That exemption was there to stop a soft-lock, and the soft-lock turns out
# not to exist. Losing the structure does not lose what is in it: the bank is
# GameState.bank_inventory and the hangar is stored_vessels, both of which
# outlive any building. And rebuilding is always available - the mothership
# is the one structure research never gates, it costs exactly what a single
# interceptor's hold carries, and ore comes from asteroids, which do not run
# out. So the way back is always mine, fly, rebuild.
#
# Kept as a function rather than deleted because the QUESTION is still a real
# one - something may well need to be un-killable later - and a caller asking
# it is clearer than a caller that has forgotten it was ever asked.
func building_is_indestructible(_building: Dictionary) -> bool:
return false
# Returns true when this killed the building. # Returns true when this killed the building.
@ -1489,6 +1509,68 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
return true return true
# What a building can actually pay a local cost with (#158).
#
# #12 made production cost resources physically DELIVERED to the building
# rather than money out of the bank, and that stands: the bank is still not
# raided from here. What #158 adds is the fleet's own hold - the one other
# place resources physically are. A hull that flew in carrying gas can hand
# it over, and making the player park, open the transfer menu and move it
# across by hand was ceremony rather than a decision.
#
# But only WHERE THE FLEET IS, and that qualifier is the whole design.
# Without it a crafter on the far side of the map reaches into the hold and
# spends ore the player withdrew to carry somewhere else - which is exactly
# the complaint that got automatic banking at the mothership removed in #24,
# arriving from the other direction. test_manual_supply_route caught it
# doing precisely that.
#
# The range is Building.interact_radius, the same distance that decides
# whether the transfer button is offered at all. So the rule is not a hidden
# one: if you could hand the cargo over by hand, the building can take it.
const LOCAL_PAY_RANGE: float = 150.0
func fleet_is_at(building: Dictionary) -> bool:
if building.is_empty():
return false
return fleet_position.distance_to(building.position) <= LOCAL_PAY_RANGE
func local_payment_available(building: Dictionary, inventory: RefCounted,
resource_type: String) -> int:
var here: int = 0 if inventory == null else inventory.amount(resource_type)
return here + (fleet_inventory.amount(resource_type) if fleet_is_at(building) else 0)
func can_pay_locally(building: Dictionary, inventory: RefCounted,
costs: Dictionary) -> bool:
for resource_type in costs:
if local_payment_available(building, inventory, String(resource_type)) \
< int(costs[resource_type]):
return false
return true
# All or nothing, settled in full before anything is taken - a part-charged
# order that then fails is worse than one that never started. The building's
# own stock goes first, so what was hauled in is spent before the hold the
# player is still flying around with.
func pay_locally(building: Dictionary, inventory: RefCounted,
costs: Dictionary) -> bool:
if not can_pay_locally(building, inventory, costs):
return false
var at_hand: bool = fleet_is_at(building)
for resource_type in costs:
var id: String = String(resource_type)
var owed: int = int(costs[resource_type])
if inventory != null:
owed -= inventory.withdraw(id, owed)
if owed > 0 and at_hand:
fleet_inventory.withdraw(id, owed)
return true
func _advance_production(delta: float) -> void: func _advance_production(delta: float) -> void:
for b in buildings: for b in buildings:
if not can_produce(b.type): if not can_produce(b.type):
@ -1508,15 +1590,9 @@ func _advance_production(delta: float) -> void:
var definition: Resource = vessel_definition(product) var definition: Resource = vessel_definition(product)
if not bool(order.started): if not bool(order.started):
var affordable := true # #158: the crafter's own stock, or the hold that flew in.
for resource_type in costs: if not pay_locally(b, inventory, costs):
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
if not affordable:
continue continue
for resource_type in costs:
inventory.withdraw(String(resource_type), int(costs[resource_type]))
order.started = true order.started = true
order.elapsed = float(order.elapsed) + delta order.elapsed = float(order.elapsed) + delta
@ -2038,11 +2114,8 @@ func _advance_lab_research(delta: float) -> void:
var costs: Dictionary = research_costs(id) var costs: Dictionary = research_costs(id)
var inventory: RefCounted = _inventory_for_record(lab) var inventory: RefCounted = _inventory_for_record(lab)
if not bool(order.started): if not bool(order.started):
var affordable := true # #158: the lab's own stock, or the hold that flew in.
for resource_type in costs: var affordable: bool = can_pay_locally(lab, inventory, costs)
if inventory.amount(String(resource_type)) < int(costs[resource_type]):
affordable = false
break
# #63: research points are a fleet-wide currency, not delivered # #63: research points are a fleet-wide currency, not delivered
# to the lab like ore/gas - checked and spent here too so a # to the lab like ore/gas - checked and spent here too so a
# shortfall stalls the order the same way an undelivered # shortfall stalls the order the same way an undelivered
@ -2052,8 +2125,8 @@ func _advance_lab_research(delta: float) -> void:
affordable = false affordable = false
if not affordable: if not affordable:
continue continue
for resource_type in costs: if not pay_locally(lab, inventory, costs):
inventory.withdraw(String(resource_type), int(costs[resource_type])) continue
if points_needed > 0: if points_needed > 0:
research_points -= points_needed research_points -= points_needed
research_points_changed.emit(research_points) research_points_changed.emit(research_points)

View file

@ -17,9 +17,12 @@ extends SceneTree
# record outright # record outright
# C. a record written before this existed reads as undamaged, not as # C. a record written before this existed reads as undamaged, not as
# rubble - an existing save must not open on a field of wrecks # rubble - an existing save must not open on a field of wrecks
# D. the mothership cannot be destroyed. The bank, the hangar and research # D. #159 - NOTHING is exempt from coming down, the mothership included.
# all live there and a wiped fleet rebuilds there, so losing it would # It used to floor at 1hp to avoid a soft-lock, and a battered one sat
# be a soft-lock; it bottoms out at 1hp instead # there reading as dead and never exploded. The soft-lock does not
# exist: what is IN the mothership outlives it, and rebuilding it is
# always reachable - so the way back is checked here rather than the
# exemption that used to stand in for it
# E. destroying a transport hub takes its transporters with it, and # E. destroying a transport hub takes its transporters with it, and
# nothing else keyed on the id is left pointing at a hole # nothing else keyed on the id is left pointing at a hole
# F. the Building node follows its record out of existence, and shows # F. the Building node follows its record out of existence, and shows
@ -96,22 +99,50 @@ func _run_state_cases() -> bool:
return _fail("one point of damage levelled a building carried over from an old save") return _fail("one point of damage levelled a building carried over from an old save")
print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full) print("a pre-#99 record reads as undamaged (%d hp), not as rubble" % lab_full)
# --- D. the mothership survives anything ---------------------------- # --- D. #159: the mothership comes down like anything else ----------
var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) var mothership_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
if not game_state.building_is_indestructible(game_state.find_building(mothership_id)): if game_state.building_is_indestructible(game_state.find_building(mothership_id)):
return _fail("the mothership is not marked indestructible") return _fail("the mothership is still exempt - it would sit at 1hp reading as dead")
if game_state.damage_building(mothership_id, 999999): # Stock the things that used to be the reason for the exemption, so
return _fail("the mothership was destroyed - the bank, hangar and research went with it") # their survival is observed rather than assumed.
if game_state.building_hp(mothership_id) != 1: game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 400)
return _fail("an overwhelmed mothership sits at %d hp, expected to floor at 1" \ game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR)
% game_state.building_hp(mothership_id)) if not game_state.damage_building(mothership_id, 999999):
if game_state.destroy_building(mothership_id): return _fail("enough damage did not destroy the mothership")
return _fail("destroy_building demolished the mothership outright") if not game_state.find_building(mothership_id).is_empty():
if game_state.repair_building(mothership_id, 999999) <= 0: return _fail("the mothership record outlived its own destruction")
# ...and the way back. This is what the exemption was protecting, so it
# is what has to be true without it.
if game_state.banked_resource(GameState.RESOURCE_ORE) != 400:
return _fail("the bank died with the building - it holds %d" \
% game_state.banked_resource(GameState.RESOURCE_ORE))
if game_state.stored_vessels.is_empty():
return _fail("the hangar died with the building")
if game_state.research_for_building(GameState.BUILDING_MOTHERSHIP) != "":
return _fail("rebuilding the mothership needs research, which needs a mothership")
var rebuild: int = int(game_state.building_costs(GameState.BUILDING_MOTHERSHIP).get(
GameState.RESOURCE_ORE, 0))
var one_hold: int = game_state.fleet_cargo_capacity(
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
if rebuild > one_hold:
return _fail("a rebuild costs %d ore and one surviving hull carries %d - unreachable with the bank gone" \
% [rebuild, one_hold])
print("the mothership comes down; bank, hangar and an ungated %d-ore rebuild all survive it" \
% rebuild)
# Repair, on a fresh one - the last was destroyed on purpose above.
var rebuilt_id: int = game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
var at_full: int = game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP)
if game_state.damage_building(rebuilt_id, at_full - 5):
return _fail("damage short of the last few points still destroyed it")
if game_state.building_hp(rebuilt_id) != 5:
return _fail("a battered mothership sits at %d hp, expected 5" \
% game_state.building_hp(rebuilt_id))
if game_state.repair_building(rebuilt_id, 999999) <= 0:
return _fail("a battered mothership could not be repaired") return _fail("a battered mothership could not be repaired")
if game_state.building_hp(mothership_id) != game_state.building_max_hp(GameState.BUILDING_MOTHERSHIP): if game_state.building_hp(rebuilt_id) != at_full:
return _fail("repair did not take the mothership back to full") return _fail("repair did not take the mothership back to full")
print("the mothership floors at 1hp and repairs to full") print("a rebuilt one takes damage down to %d and repairs back to %d" % [5, at_full])
# --- E. a hub takes its transporters with it ------------------------ # --- E. a hub takes its transporters with it ------------------------
var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0)) var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(800.0, 0.0))

View file

@ -0,0 +1,191 @@
extends SceneTree
# Headless test: run with
# godot --headless -s res://tests/test_local_costs_use_hold.gd
# Forgejo #158: "any costs local to a building (creating a ship in the ship
# crafter, researching in the lab...) can also be paid thanks to the ship
# inventory."
#
# #12 made a building's production cost resources physically DELIVERED to it
# rather than money out of the bank, and that principle stands - the bank is
# still not raided from here. What changes is that the fleet's own hold
# counts as well, because it is the one other place resources physically
# are: a hull that flew in carrying gas can hand it over, and making the
# player park, open the transfer menu and move it across by hand was
# ceremony rather than a decision.
#
# Covers:
# A. a crafter with an empty stock builds out of the fleet's hold
# B. the building's OWN stock is spent first - what was hauled in goes
# before the hold the player is still flying around with
# C. a cost split across both is taken from both
# D. the bank is still not touched: banked ore alone does not start an
# order, which is the #12 rule this must not quietly undo
# E. all or nothing - an order that cannot be paid for takes nothing
# F. the same for lab research
# G. and only WHERE THE FLEET IS. A crafter across the map must not reach
# into the hold and spend ore the player withdrew to carry elsewhere -
# which is the same complaint that got automatic banking removed in #24,
# arriving from the other direction. test_manual_supply_route caught the
# first draft of this doing exactly that.
var game_state: Node
var crafter_id: int = 0
var lab_id: int = 0
func _fail(message: String) -> bool:
print("TEST_LOCAL_COSTS_USE_HOLD: FAIL (%s)" % message)
quit(1)
return true
# Emptied rather than replaced: GameState.INVENTORY is reached through the
# autoload's name, which an `extends SceneTree` script cannot compile.
func _reset() -> void:
for resource_type in game_state.resource_types():
var id: String = String(resource_type)
game_state.bank_inventory.set_amount(id, 0)
game_state.fleet_inventory.set_amount(id, 0)
game_state.building_inventory(crafter_id).set_amount(id, 0)
game_state.building_inventory(lab_id).set_amount(id, 0)
game_state.fleet_inventory.total_capacity = 500
for b in game_state.buildings:
b["orders"] = []
b["research_orders"] = []
func _initialize() -> void:
# GameState is an autoload: a bare GameState.<instance member> does not
# compile in a SceneTree script, so the node is fetched by name instead.
game_state = root.get_node("GameState")
game_state.reset_new_game()
game_state.intro_active = false
game_state.reset_quests()
game_state.build_time_scale = 0.02
crafter_id = game_state.add_building(GameState.BUILDING_SHIP_CRAFTER, Vector2.ZERO)
lab_id = game_state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(500.0, 0.0))
# Parked on the crafter. The hold only pays for what the fleet is
# standing at - see G below, which is the half of #158 that matters.
game_state.fleet_position = Vector2.ZERO
var cost: int = int(game_state.product_costs(GameState.VESSEL_INTERCEPTOR).get(
GameState.RESOURCE_ORE, 0))
if cost <= 1:
_fail("an interceptor costs %d ore - nothing here would be measurable" % cost)
return
# --- A. built out of the hold alone ---------------------------------
_reset()
game_state.fleet_inventory.deposit(GameState.RESOURCE_ORE, cost)
if not game_state.queue_order(crafter_id, GameState.VESSEL_INTERCEPTOR):
_fail("the order would not queue")
return
game_state._advance_production(0.01)
var order: Dictionary = game_state.order_list(crafter_id)[0]
if not bool(order.started):
_fail("a crafter with the fleet parked on it and %d ore aboard did not start" % cost)
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != 0:
_fail("the hold still holds %d after paying" \
% game_state.fleet_resource(GameState.RESOURCE_ORE))
return
print("an empty crafter built out of the fleet's hold (%d ore)" % cost)
# --- B and C. the building's own stock goes first -------------------
_reset()
for b in game_state.buildings:
b["orders"] = []
var in_building: int = cost - 1
game_state.building_inventory(crafter_id).set_amount(GameState.RESOURCE_ORE, in_building)
game_state.fleet_inventory.deposit(GameState.RESOURCE_ORE, 5)
game_state.queue_order(crafter_id, GameState.VESSEL_INTERCEPTOR)
game_state._advance_production(0.01)
if not bool(game_state.order_list(crafter_id)[0].started):
_fail("a cost split across the crafter and the hold did not start")
return
if game_state.building_resource(crafter_id, GameState.RESOURCE_ORE) != 0:
_fail("the crafter's own %d ore was not spent first - %d left" \
% [in_building, game_state.building_resource(crafter_id, GameState.RESOURCE_ORE)])
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != 4:
_fail("the hold paid %d, expected to cover the last 1" \
% (5 - game_state.fleet_resource(GameState.RESOURCE_ORE)))
return
print("stock first, then the hold: %d from the crafter and 1 from the fleet" % in_building)
# --- D. the bank is still not raided --------------------------------
_reset()
game_state.bank_inventory.set_amount(GameState.RESOURCE_ORE, 5000)
game_state.queue_order(crafter_id, GameState.VESSEL_INTERCEPTOR)
game_state._advance_production(0.01)
if bool(game_state.order_list(crafter_id)[0].started):
_fail("the order started out of the BANK - #12's rule is undone")
return
if game_state.banked_resource(GameState.RESOURCE_ORE) != 5000:
_fail("the bank was charged %d for an order that did not start" \
% (5000 - game_state.banked_resource(GameState.RESOURCE_ORE)))
return
print("banked ore alone does not start an order, and is not touched")
# --- E. all or nothing ----------------------------------------------
_reset()
game_state.fleet_inventory.deposit(GameState.RESOURCE_ORE, cost - 1)
game_state.queue_order(crafter_id, GameState.VESSEL_INTERCEPTOR)
game_state._advance_production(0.01)
if bool(game_state.order_list(crafter_id)[0].started):
_fail("an order started one ore short")
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost - 1:
_fail("an order that could not be paid for still took %d from the hold" \
% (cost - 1 - game_state.fleet_resource(GameState.RESOURCE_ORE)))
return
print("one short takes nothing at all")
# --- F. and the lab does the same -----------------------------------
_reset()
var upgrade: String = "int_damage"
var research: Dictionary = game_state.research_costs(upgrade)
if research.is_empty():
_fail("the upgrade %s costs nothing - F would prove nothing" % upgrade)
return
game_state.research_points = 99
game_state.fleet_position = Vector2(500.0, 0.0)
for resource_type in research:
game_state.fleet_inventory.deposit(String(resource_type), int(research[resource_type]))
if not game_state.queue_lab_research(lab_id, upgrade):
_fail("the research would not queue")
return
game_state._advance_lab_research(0.01)
if not bool(game_state.lab_research_order(lab_id).started):
_fail("research did not start from the fleet's hold")
return
for resource_type in research:
if game_state.fleet_resource(String(resource_type)) != 0:
_fail("the hold still holds %s after paying for research" % resource_type)
return
print("a lab researches out of the hold too (%s)" % str(research))
# --- G. and not from across the map ---------------------------------
_reset()
game_state.fleet_position = Vector2(9000.0, 9000.0)
game_state.fleet_inventory.deposit(GameState.RESOURCE_ORE, cost * 4)
game_state.queue_order(crafter_id, GameState.VESSEL_INTERCEPTOR)
game_state._advance_production(0.01)
if bool(game_state.order_list(crafter_id)[0].started):
_fail("a crafter on the far side of the map started out of the fleet's hold")
return
if game_state.fleet_resource(GameState.RESOURCE_ORE) != cost * 4:
_fail("a distant crafter took %d from the hold" \
% (cost * 4 - game_state.fleet_resource(GameState.RESOURCE_ORE)))
return
# ...and flying back is all it takes.
game_state.fleet_position = Vector2.ZERO
game_state._advance_production(0.01)
if not bool(game_state.order_list(crafter_id)[0].started):
_fail("the order did not start once the fleet was parked on the crafter")
return
print("out of range it takes nothing; parked on it, the same order starts")
print("TEST_LOCAL_COSTS_USE_HOLD: PASS")
quit(0)

View file

@ -0,0 +1 @@
uid://b0dsxrl5b47a7

View file

@ -18,7 +18,12 @@ extends SceneTree
# triggered on arrival (#14); #24 removed that entirely, because an # triggered on arrival (#14); #24 removed that entirely, because an
# automatic one-way move overrides a player who has just loaded the # automatic one-way move overrides a player who has just loaded the
# hold in order to carry ore somewhere else # hold in order to carry ore somewhere else
# 4. a building takes the fleet's whole hold when opened # 4. a building takes the fleet's whole hold when opened - and since #158
# it does not need to be asked: a queued order at a building the fleet
# is PARKED ON pays itself out of the hold, so the hand-off happens on
# arrival rather than through the transfer menu. The manual move still
# works and is still what stocks a building ahead of an order, which is
# checked separately here
# 5. end to end: a queued order the bank cannot reach is fulfilled using # 5. end to end: a queued order the bank cannot reach is fulfilled using
# only the fleet, with no Transport Hub in existence # only the fleet, with no Transport Hub in existence
# 6. nothing is created or destroyed anywhere along the route # 6. nothing is created or destroyed anywhere along the route
@ -153,12 +158,31 @@ func _process(delta: float) -> bool:
5: 5:
if elapsed < 0.6: if elapsed < 0.6:
return false return false
var moved: int = game_state.transfer_to_building(crafter_id, game_state.fleet_cargo) # #158: parked on the crafter with the ore aboard, the queued
if moved != GameState.SHIP_COST or game_state.fleet_cargo != 0: # order takes it - the flight IS the delivery now, and the hold
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (unloaded %d, hold left at %d)" % [moved, game_state.fleet_cargo]) # is empty because it has been spent rather than because nothing
# happened.
if game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (parked on the crafter with %d still in the hold)" % game_state.fleet_cargo)
quit(1) quit(1)
return true return true
print("unloaded %d into the crafter" % moved) # Orders run at build_time_scale 0.02 here, so by now it has not
# only started on the hold's ore but finished.
if game_state.pending_orders(crafter_id) != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (the hold emptied but the order is still queued)")
quit(1)
return true
print("parked on the crafter: the queued order took the hold's %d itself" % GameState.SHIP_COST)
# ...and the manual move still works, which is what stocks a
# building AHEAD of an order rather than paying for one.
game_state.collect_cargo(4)
var moved: int = game_state.transfer_to_building(crafter_id, 4)
if moved != 4 or game_state.fleet_cargo != 0:
print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (hand-unloaded %d of 4, hold left at %d)" % [moved, game_state.fleet_cargo])
quit(1)
return true
print("and a hand transfer still moves %d into the crafter" % moved)
elapsed = 0.0 elapsed = 0.0
stage = 6 stage = 6
return false return false
@ -183,7 +207,9 @@ func _process(delta: float) -> bool:
print("queued order fulfilled by hand, no transport hub: %d vessel in the hangar" % game_state.stored_vessels.size()) print("queued order fulfilled by hand, no transport hub: %d vessel in the hangar" % game_state.stored_vessels.size())
# --- 6. conservation ---------------------------------------- # --- 6. conservation ----------------------------------------
var expected: int = total_before - GameState.SHIP_COST # The 4 mined above to exercise the hand transfer are still in
# the crafter's stock, so they are part of the total.
var expected: int = total_before - GameState.SHIP_COST + 4
if _total() != expected: if _total() != expected:
print("bank=%d hold=%d stocks total=%d, expected %d" % [ print("bank=%d hold=%d stocks total=%d, expected %d" % [
game_state.rocks, game_state.fleet_cargo, _total(), expected game_state.rocks, game_state.fleet_cargo, _total(), expected

View file

@ -31,6 +31,8 @@ extends SceneTree
# a quest of its own, so research is taught rather than assumed # a quest of its own, so research is taught rather than assumed
# R. #145 - every structure quest is actually finishable: building the # R. #145 - every structure quest is actually finishable: building the
# thing it asks for, the way the game builds it, completes it # thing it asks for, the way the game builds it, completes it
# S. #157 - nothing costing more than a single hold is asked for before
# the chain has grown the fleet or taught banking
# I. lines survive having nobody to say them: a quest finished with no # I. lines survive having nobody to say them: a quest finished with no
# banner alive is read out by the next one that is # banner alive is read out by the next one that is
# J. a planet surface carries the chain too - a quest finished down there # J. a planet surface carries the chain too - a quest finished down there
@ -196,6 +198,35 @@ func _catalogue_cases() -> bool:
print("%d ore is asked for before the %s that costs %d, and the opening hold carries %d" \ print("%d ore is asked for before the %s that costs %d, and the opening hold carries %d" \
% [gathered, build_type, build_price, hold]) % [gathered, build_type, build_price, hold])
# --- S. #157: a second hull before anything one hull cannot pay for --
# A structure costing more than a single hold cannot be bought in one
# trip, so the chain has to have grown the fleet - or taught banking -
# before it asks for one. Both are in front of the extractor today; this
# fails if either is ever moved behind it.
var one_hold: int = game_state.fleet_cargo_capacity(
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
var grew_fleet: int = -1
var taught_banking: int = -1
for i in range(QUESTS.count()):
var goal_s: Dictionary = QUESTS.at(i).goal
if String(goal_s.kind) == QUESTS.GOAL_FLEET_VESSEL and int(goal_s.count) > 1:
grew_fleet = i if grew_fleet < 0 else grew_fleet
if String(goal_s.kind) == QUESTS.GOAL_BANKED:
taught_banking = i if taught_banking < 0 else taught_banking
if String(goal_s.kind) != QUESTS.GOAL_BUILDING:
continue
var price: int = int(game_state.building_costs(String(goal_s.building)).get(
GameState.RESOURCE_ORE, 0))
if price <= one_hold:
continue
if grew_fleet < 0 and taught_banking < 0:
return _fail("quest %s wants a %s at %d ore, more than the %d one hull carries, and the chain has neither grown the fleet nor taught banking yet" \
% [QUESTS.at(i).id, goal_s.building, price, one_hold])
if grew_fleet < 0:
return _fail("no quest ever asks for a second hull")
print("a second hull at %d and banking at %d, both before anything costing more than one %d-unit hold" \
% [grew_fleet, taught_banking, one_hold])
# --- Q. #141: research is taught before it is assumed ---------------- # --- Q. #141: research is taught before it is assumed ----------------
# The first structure in the chain that needs a doctrine must have that # The first structure in the chain that needs a doctrine must have that
# doctrine as a quest of its own first. Only the FIRST one: once the # doctrine as a quest of its own first. Only the FIRST one: once the

View file

@ -228,6 +228,11 @@ func move_in_direction(direction: Vector2) -> void:
func _process(delta: float) -> void: func _process(delta: float) -> void:
# #158: a building may pay a local cost out of the hold when the fleet is
# actually standing at it, and the autoload has no other way to know
# where that is. Written before the early return below - a parked fleet
# is exactly the case that matters.
GameState.fleet_position = get_centroid()
if steer_direction == Vector2.ZERO: if steer_direction == Vector2.ZERO:
return return
# The anchor - the fleet's formation centre - travels at cruise_speed. # The anchor - the fleet's formation centre - travels at cruise_speed.