Transporter capacity per hub, and research to raise it

Forgejo #20, part 7 of #12 and the last of it. A hub stations
BASE_HUB_CAPACITY (2) transporters. Total berths are hubs x per-hub
capacity, so building another hub or researching "Freight Yards" both
raise it - which is the choice #12 asks the player to make.

Capacity follows the same shape as max_fleet_size() (sum a bonus over
`unlocked`) rather than being a second mechanism doing the same job, so
research applies to hubs already built, not only to ones placed
afterwards.

A queued-but-unbuilt transporter holds its berth. Without that a player
could order ten against two berths and only discover the problem when
they refused to appear. Ordering past the cap is refused outright, and
the two refusals - no hub at all, versus every berth taken - say which,
because "nothing happened" is exactly the bug reported against the
craft button.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-11 19:43:55 +02:00
commit f19807618a
3 changed files with 214 additions and 4 deletions

View file

@ -457,12 +457,14 @@ func _refresh_craft_status() -> void:
var queued: int = GameState.pending_orders(craft_building_id) var queued: int = GameState.pending_orders(craft_building_id)
var stock: int = GameState.building_stock(craft_building_id) var stock: int = GameState.building_stock(craft_building_id)
var queue_text: String = "" if queued == 0 else " Queued %d" % queued var queue_text: String = "" if queued == 0 else " Queued %d" % queued
craft_status.text = "Interceptor %d Transporter %d Here %d%s" % [ craft_status.text = "Interceptor %d Transporter %d Here %d%s Transporters %d/%d" % [
GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text GameState.SHIP_COST, GameState.TRANSPORTER_COST, stock, queue_text,
GameState.transporters_claimed(), GameState.transport_capacity()
] ]
# Nowhere to station one means the order would be refused, so say so # Nowhere to station one means the order would be refused, so say so
# rather than letting the button look available and do nothing. # rather than letting the button look available and do nothing.
craft_transporter_button.enabled = GameState.has_transport_hub() craft_transporter_button.enabled = GameState.has_transport_hub() \
and GameState.can_station_transporter()
craft_transporter_button.queue_redraw() craft_transporter_button.queue_redraw()
craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo craft_store_label.text = "Store ore here\nHold %d" % GameState.fleet_cargo
@ -527,7 +529,12 @@ func _on_craft_transporter() -> void:
if craft_building_id == 0: if craft_building_id == 0:
return return
if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER): if not GameState.queue_order(craft_building_id, GameState.PRODUCT_TRANSPORTER):
craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed" # Two different refusals, and the player needs to know WHICH.
if not GameState.has_transport_hub():
craft_status.text = "Build a Transport Hub first - a transporter needs somewhere to be stationed"
else:
craft_status.text = "All %d berths are taken - build another hub, or research Freight Yards" % \
GameState.transport_capacity()
return return
_refresh_craft_status() _refresh_craft_status()

View file

@ -145,6 +145,11 @@ const SKILL_TREE: Array = [
"x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB,
"desc": "Build transport hubs, which supply your other buildings with ore.", "desc": "Build transport hubs, which supply your other buildings with ore.",
}, },
{
"id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight",
"x": 0.05, "hub_bonus": 1,
"desc": "Permanent: +1 transporter stationed at every hub, including ones already built.",
},
{ {
"id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination", "id": "wing", "name": "Wing Doctrine", "cost": 30, "requires": "coordination",
"x": 0.82, "fleet_bonus": 1, "x": 0.82, "fleet_bonus": 1,
@ -357,6 +362,9 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
# Nowhere to station it. Refused rather than queued, so the player is # Nowhere to station it. Refused rather than queued, so the player is
# told now instead of watching an order sit forever. # told now instead of watching an order sit forever.
return false return false
if product == PRODUCT_TRANSPORTER and not can_station_transporter():
# Every berth is taken or spoken for (#20).
return false
(b.orders as Array).append(product) (b.orders as Array).append(product)
return true return true
@ -374,6 +382,11 @@ func _advance_production() -> void:
var cost: int = product_cost(product) var cost: int = product_cost(product)
if int(b.get("stock", 0)) < cost: if int(b.get("stock", 0)) < cost:
break break
if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity():
# A hub was demolished after this was ordered, so the berth it
# was going to use is gone. Hold the order rather than
# overfilling what is left.
break
if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty():
# Every hub was demolished while this sat in the queue. Leave # Every hub was demolished while this sat in the queue. Leave
# it queued and the stock untouched rather than producing a # it queued and the stock untouched rather than producing a
@ -548,6 +561,11 @@ func _process(delta: float) -> void:
# "from", "to"}. # "from", "to"}.
const TRANSPORTER_SPEED: float = 340.0 const TRANSPORTER_SPEED: float = 340.0
# How many transporters one hub can station. Raised for EVERY hub - built
# already or not - by research, which is the same shape as max_fleet_size()
# rather than a second mechanism doing the same job.
const BASE_HUB_CAPACITY: int = 2
const TRANSPORTER_IDLE: String = "idle" const TRANSPORTER_IDLE: String = "idle"
const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership" const TRANSPORTER_TO_MOTHERSHIP: String = "to_mothership"
const TRANSPORTER_TO_BUILDING: String = "to_building" const TRANSPORTER_TO_BUILDING: String = "to_building"
@ -556,6 +574,48 @@ const TRANSPORTER_RETURNING: String = "returning"
var transporters: Array = [] var transporters: Array = []
func hub_capacity() -> int:
# Per hub.
var n: int = BASE_HUB_CAPACITY
for id in unlocked:
n += int(skill_node(id).get("hub_bonus", 0))
return n
func hub_count() -> int:
var n: int = 0
for b in buildings:
if b.type == BUILDING_TRANSPORT_HUB:
n += 1
return n
func transport_capacity() -> int:
# Total berths: more hubs, or more capacity per hub. Either raises it,
# which is exactly the choice #12 asks the player to make.
return hub_count() * hub_capacity()
func pending_transporter_orders() -> int:
var n: int = 0
for b in buildings:
for o in b.get("orders", []):
if o == PRODUCT_TRANSPORTER:
n += 1
return n
func transporters_claimed() -> int:
# Built plus already ordered: an order in the queue has a berth reserved,
# or a player could queue ten of them against two berths and only find out
# when they refused to appear.
return transporters.size() + pending_transporter_orders()
func can_station_transporter() -> bool:
return transporters_claimed() < transport_capacity()
func has_transport_hub() -> bool: func has_transport_hub() -> bool:
return not nearest_hub_to(Vector2.ZERO).is_empty() return not nearest_hub_to(Vector2.ZERO).is_empty()

143
tests/test_hub_capacity.gd Normal file
View file

@ -0,0 +1,143 @@
extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_hub_capacity.gd
# Forgejo #20 (part 7 of #12): a hub stations a limited number of
# transporters. More hubs, or research, raise it.
#
# Verifies:
# 1. capacity is per hub, so a second hub raises the total
# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket
# is explicit that the player has to be told
# 3. a queued-but-unbuilt transporter still holds its berth, or ten could
# be ordered against two and only fail on arrival
# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not
# just ones placed afterwards
# 5. the extra berth is immediately usable
var game_state: Node
var scene: Node
var field: Node
var crafter_id: int = 0
var stage: int = 0
var elapsed: float = 0.0
const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0)
const HUB_A: Vector2 = Vector2(-500.0, 0.0)
const HUB_B: Vector2 = Vector2(-500.0, 600.0)
func _initialize() -> void:
game_state = root.get_node("GameState")
game_state.buildings.clear()
game_state.extractors.clear()
game_state.transporters.clear()
game_state.rocks = 900
game_state.fleet_cargo = 0
game_state.ships_owed = 0
game_state.unlocked = ["shipyards", "logistics", "freight"]
scene = load("res://Main.tscn").instantiate()
root.add_child(scene)
field = scene.get_node("BuildingField")
func _fill_berths(n: int) -> void:
# Queue and immediately fund n transporters.
for i in range(n):
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
scene.get_node("AsteroidField").set_process(false)
scene.get_node("EnemySpawner").set_process(false)
field.place(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT)
crafter_id = int(game_state.buildings[-1].id)
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.3:
return false
var per_hub: int = game_state.hub_capacity()
if game_state.transport_capacity() != per_hub:
print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [
game_state.transport_capacity(), per_hub
])
quit(1)
return true
print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub])
# --- 2 + 3. fill it, then try to exceed it ------------------
_fill_berths(per_hub)
if game_state.transporters_claimed() != per_hub:
print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [
game_state.transporters_claimed(), per_hub
])
quit(1)
return true
# Still only QUEUED at this point - the berth must already count.
if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub)
quit(1)
return true
print("cap enforced at %d, including orders not yet built" % per_hub)
# --- 1. a second hub raises the total -----------------------
field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B)
elapsed = 0.0
stage = 2
return false
2:
if elapsed < 0.3:
return false
if game_state.transport_capacity() != game_state.hub_capacity() * 2:
print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [
game_state.transport_capacity(), game_state.hub_capacity() * 2
])
quit(1)
return true
print("2 hubs -> %d berths" % game_state.transport_capacity())
if not game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)")
quit(1)
return true
# Fill the second hub too, so capacity is exhausted again.
_fill_berths(game_state.transport_capacity() - game_state.transporters_claimed())
if game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)")
quit(1)
return true
# --- 4. research raises it for hubs ALREADY built -----------
var before: int = game_state.transport_capacity()
game_state.unlocked.append("freight_yards")
var after: int = game_state.transport_capacity()
print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [
before, after, game_state.hub_count()
])
if after != before + game_state.hub_count():
print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before))
quit(1)
return true
# --- 5. and the new berth is usable straight away -----------
if not game_state.can_station_transporter():
print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)")
quit(1)
return true
if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER):
print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)")
quit(1)
return true
print("ordered into the newly researched berth")
print("TEST_HUB_CAPACITY: PASS")
quit(0)
return true
return false