Build the logistics crews, and let them burn energy

#216: transport hubs and spacial workshops no longer conjure their crews.
Transporters and repair drones are ordered at a ship crafter, assigned per
building through a new Station menu, flown out from the mothership hangar,
and spent by the work they do - 20 runs, 12 repairs, counted where the run
ENDS so nothing retires with cargo in the air or a building half repaired.
A spent vessel is gone, so the structure is short again and the player has
to build another. The menu shows what each one has left, worst first.

Craftability needed its own flag: "the crafter can make it" and "it can take
a berth in the fleet" stopped being the same question.

#217: transporters crawl at 120 rather than 150, and a Burn Drive lets them
carry energy and spend it to travel far faster. Fetching it is a leg of its
own, to the nearest store that has any - so a refuelling transporter cannot
be dispatched mid-errand - and the leg flown to COLLECT fuel is not charged
for it. Unresearched, nothing detours and nothing changes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019AJMdB7iuHuLFZM1aBu3GD
This commit is contained in:
Loïc Wisniewski 2026-09-05 19:09:35 +02:00
commit 265e121cb0
31 changed files with 1180 additions and 141 deletions

View file

@ -1,6 +1,11 @@
[gd_scene load_steps=2 format=3]
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://buildings/TransportHubBuildingVisual.gd" id="1"]
[ext_resource type="Script" path="res://buildings/BuildingActionProvider.gd" id="2"]
[node name="Visual" type="Node2D"]
script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("staff")

View file

@ -8,4 +8,4 @@ script = ExtResource("1")
[node name="ActionProvider" type="Node" parent="."]
script = ExtResource("2")
actions = PackedStringArray("transfer")
actions = PackedStringArray("staff", "transfer")

View file

@ -32,6 +32,8 @@ const HUD_ROW_GAP := 16.0
@onready var skill_menu: Control = get_node("../UI/SkillMenu")
@onready var planning_menu: Control = get_node("../UI/PlanningMenu")
@onready var garrison_menu: Control = get_node("../UI/GarrisonMenu")
# #216: where a hub or a workshop is told how many craft to keep.
@onready var station_menu: Control = get_node("../UI/StationMenu")
@onready var scanner_menu: Control = get_node("../UI/ScannerMenu")
var pending_build_type: String = ""
@ -111,7 +113,8 @@ func _seat_hud_columns() -> void:
func _menu_is_open() -> bool:
for menu in [build_menu, craft_menu, lab_menu, transfer_menu, hangar_menu,
formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu]:
formation_menu, skill_menu, planning_menu, garrison_menu, scanner_menu,
station_menu]:
if menu.visible:
return true
return false
@ -314,6 +317,8 @@ func _on_action_pressed() -> void:
elif building_action == "scanner":
scanner_building_id = building_id
scanner_menu.open_menu()
elif building_action == "staff":
station_menu.open_for(building_id)
func _on_scan_requested(definition_id: String) -> void:
@ -791,6 +796,7 @@ func _layout_hangar() -> void:
func _refresh_garrison() -> void:
garrison_menu.refresh()
station_menu.refresh()
func _refresh_planning() -> void:

View file

@ -695,10 +695,6 @@ func unlock(id: String, payment_position: Variant = null) -> bool:
research_points -= points_needed
research_points_changed.emit(research_points)
unlocked.append(id)
if int(node.get("hub_bonus", 0)) > 0:
_sync_hub_transporters()
if int(node.get("workshop_bonus", 0)) > 0:
_sync_workshop_drones()
return true
@ -1084,10 +1080,6 @@ func dev_unlock_all_skills() -> int:
continue
unlocked.append(id)
added += 1
# These permanent doctrines affect structures which may already exist.
# Sync once after the batch instead of once per node.
_sync_hub_transporters()
_sync_workshop_drones()
return added
@ -1134,11 +1126,6 @@ func add_building(type: String, at_position: Vector2) -> int:
"inventory": INVENTORY.new(), "orders": [],
"hp": building_max_hp(type),
})
var building: Dictionary = buildings[-1]
if building_has_capability(building, "transport_hub"):
_provision_hub_transporters(building)
if building_has_capability(building, "repair"):
_provision_workshop_drones(building)
return id
@ -1736,6 +1723,32 @@ func transporter_speed() -> float:
return vessel_stat(definition.definition_id, "transport_speed")
# #217: what a Burn Drive multiplies that by while there is energy aboard.
# 1.0 until the upgrade is researched, which is also the flag for whether a
# transporter should go looking for fuel at all - an unupgraded one has no
# use for it and must never detour.
func transporter_fuel_boost() -> float:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return maxf(vessel_stat(definition.definition_id, "fuel_boost"), 1.0)
func transporter_fuel_capacity() -> int:
var definition: Resource = VESSEL_DEFINITIONS.with_capability("transport")
return int(vessel_stat(definition.definition_id, "fuel_capacity"))
func transporter_burns_energy() -> bool:
return transporter_fuel_boost() > 1.0 and transporter_fuel_capacity() > 0
# The speed THIS transporter is making: the base crawl, multiplied while it
# still has something in the tank.
func transporter_leg_speed(t: Dictionary) -> float:
if int(t.get("fuel", 0)) > 0:
return transporter_speed() * transporter_fuel_boost()
return transporter_speed()
const PRODUCT_SHIP: String = "interceptor"
@ -1859,8 +1872,10 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool:
if b.is_empty() or not can_produce(b.type):
return false
var definition: Resource = vessel_definition(product)
if definition == null or not definition.fleet_eligible \
or not vessel_is_unlocked(product):
# #216: support craft are orderable here too. They never join the fleet,
# so "can the crafter make it" is its own question now.
if definition == null or not vessel_is_unlocked(product) \
or not (definition.fleet_eligible or definition.support_craftable):
return false
(b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false})
return true
@ -1962,9 +1977,10 @@ func _advance_production(delta: float) -> void:
continue
orders.pop_front()
# A finished combat hull launches from the crafter and physically flies
# to the mothership hangar. Hub transporters are provisioned by hubs and
# never enter this production path.
# A finished hull launches from the crafter and physically flies to the
# mothership hangar - support craft included since #216, which is what
# _advance_station_dispatch() then draws on to staff a hub or a
# workshop.
var mothership: Variant = get_mothership_position()
if mothership == null:
stored_vessels.append(product)
@ -2319,24 +2335,12 @@ func workshop_ore_demand(id: int) -> int:
const WORKSHOP_ORE_BUFFER: int = 120
func _provision_workshop_drones(workshop: Dictionary) -> void:
var stationed: int = 0
for drone in repair_drones:
if int(drone.get("workshop_id", 0)) == int(workshop.id):
stationed += 1
while stationed < workshop_drone_capacity():
repair_drones.append({
"workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0,
"from": workshop.position, "to": workshop.position, "progress": 1.0,
"carrying": 0,
})
stationed += 1
func _sync_workshop_drones() -> void:
for workshop in buildings:
if building_has_capability(workshop, "repair"):
_provision_workshop_drones(workshop)
func _add_workshop_drone(workshop: Dictionary) -> void:
repair_drones.append({
"workshop_id": int(workshop.id), "state": DRONE_IDLE, "target_id": 0,
"from": workshop.position, "to": workshop.position, "progress": 1.0,
"carrying": 0, "repairs_left": DRONE_REPAIRS,
})
func repair_drone_position(drone: Dictionary) -> Vector2:
@ -2355,7 +2359,9 @@ func _advance_repairs(delta: float) -> void:
if repair_drones.is_empty():
return
var speed: float = vessel_stat(REPAIR_DRONE, "transport_speed")
for drone in repair_drones:
# Duplicated because a drone that runs out of work is removed from the
# list below, and #216 made that reachable from inside this loop.
for drone in repair_drones.duplicate():
var workshop: Dictionary = find_building(int(drone.workshop_id))
if workshop.is_empty():
continue
@ -2382,6 +2388,11 @@ func _advance_repairs(delta: float) -> void:
_send_drone(drone, workshop.position, DRONE_RETURNING)
else:
drone.state = DRONE_IDLE
# #216: counted where the run ENDS, so a drone is never retired
# with a building half repaired behind it.
drone.repairs_left = int(drone.get("repairs_left", DRONE_REPAIRS)) - 1
if int(drone.repairs_left) <= 0:
_retire_drone(drone)
func _dispatch_drone(drone: Dictionary, workshop: Dictionary) -> void:
@ -3095,6 +3106,7 @@ func _process(delta: float) -> void:
_advance_lab_research(delta)
_advance_vessel_deliveries(delta)
_advance_garrison_dispatch()
_advance_station_dispatch()
_advance_planning()
_advance_repairs(delta)
_advance_resource_converters(delta)
@ -3132,8 +3144,14 @@ func _advance_vessel_deliveries(delta: float) -> void:
# destroyed by its destination going away, the same rule a
# transporter mid-delivery follows with its cargo.
var outpost_id: int = int(delivery.get("to_outpost", 0))
var station_id: int = int(delivery.get("to_station", 0))
if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)):
garrison_crew(outpost_id).append(String(delivery.type))
elif station_id != 0 and _station_still_wants(station_id):
# #216: same rule as an outpost's - a hub levelled or an order
# cancelled while the vessel was in the air lands it in the
# hangar rather than destroying it.
_station_arrived(station_id)
else:
stored_vessels.append(String(delivery.type))
@ -3165,6 +3183,10 @@ const TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: String = "extractor_to_mothershi
# `target_id` exactly like the ordinary supply path does, rather than
# `extractor_index`: the source is a building, already addressable that way.
const TRANSPORTER_TO_BUILDING_PICKUP: String = "to_building_pickup"
# #217: fetching its own fuel. A leg of its own rather than a flag on the
# others, so a transporter that is refuelling is visibly doing that and can
# never be dispatched onto a cargo run halfway there.
const TRANSPORTER_TO_FUEL: String = "to_fuel"
const TRANSPORTER_TO_MOTHERSHIP_FROM_BUILDING: String = "building_to_mothership"
var transporters: Array = []
@ -3186,30 +3208,19 @@ func hub_count() -> int:
return n
# #216: a hub used to conjure its own transporters, up to capacity, for free
# and forever. They are BUILT now - ordered at a ship crafter, flown out from
# the mothership, and worn out by the work they do - so this only ever adds
# one that has actually arrived.
func _add_hub_transporter(hub: Dictionary) -> void:
transporters.append({
"hub_id": int(hub.id), "state": TRANSPORTER_IDLE, "target_id": 0,
"extractor_index": -1, "carrying": 0, "resource_type": RESOURCE_ORE,
"progress": 1.0, "from": hub.position, "to": hub.position,
"trips_left": TRANSPORTER_TRIPS, "fuel": 0, "fuel_source_id": 0,
})
func _provision_hub_transporters(hub: Dictionary) -> void:
var stationed: int = 0
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == int(hub.id):
stationed += 1
while stationed < hub_capacity():
_add_hub_transporter(hub)
stationed += 1
func _sync_hub_transporters() -> void:
for hub in buildings:
if building_has_capability(hub, "transport_hub"):
_provision_hub_transporters(hub)
func transporter_position(t: Dictionary) -> Vector2:
return (t.from as Vector2).lerp(t.to as Vector2, clampf(t.progress, 0.0, 1.0))
@ -3257,10 +3268,50 @@ func _transporter_inbound_to_extractor(index: int) -> bool:
return false
# #217: the nearest place with energy in it, as {position, id} - id 0 meaning
# the bank at the mothership, which is where most of it sits. Nearest to the
# transporter itself rather than to its hub: it is the one making the trip.
func _nearest_energy_source(from: Vector2) -> Dictionary:
var best: Dictionary = {}
var best_distance: float = INF
var mothership: Variant = get_mothership_position()
if mothership != null and bank_inventory.amount(RESOURCE_ENERGY) > 0:
best = {"position": mothership, "id": 0}
best_distance = from.distance_squared_to(mothership)
for b in buildings:
if building_has_capability(b, "bank"):
continue # already considered, as the bank above
if building_resource(int(b.id), RESOURCE_ENERGY) <= 0:
continue
var distance: float = from.distance_squared_to(b.position)
if distance < best_distance:
best_distance = distance
best = {"position": b.position, "id": int(b.id)}
return best
# An empty tank is worth a detour only once a Burn Drive is researched, and
# only while something has energy to give. Runs before ordinary dispatch so a
# transporter tops up between jobs rather than crawling through the next one.
func _refuel_idle_transporters() -> void:
if not transporter_burns_energy():
return
for t in transporters:
if t.state != TRANSPORTER_IDLE or int(t.get("fuel", 0)) > 0:
continue
var here: Vector2 = transporter_position(t)
var source: Dictionary = _nearest_energy_source(here)
if source.is_empty():
continue
t.fuel_source_id = int(source.id)
_begin_leg(t, here, Vector2(source.position), TRANSPORTER_TO_FUEL)
func _dispatch_transporters() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return # nowhere to load from
_refuel_idle_transporters()
# Supply already-queued production and research before collecting passive
# extractor output. Extractors can refill forever, so servicing them first
# can otherwise consume every transporter each time one becomes idle and
@ -3346,19 +3397,26 @@ func _dispatch_transporters() -> void:
func _advance_transporters(delta: float) -> void:
_sync_hub_transporters()
if transporters.is_empty():
return
_dispatch_transporters()
for t in transporters:
# Duplicated for the same reason as the drones: a spent transporter is
# removed from this list on arrival.
for t in transporters.duplicate():
if t.state == TRANSPORTER_IDLE:
continue
var distance: float = (t.from as Vector2).distance_to(t.to as Vector2)
var leg: float = maxf(distance / transporter_speed(), 0.0001)
var leg: float = maxf(distance / transporter_leg_speed(t), 0.0001)
t.progress += delta / leg
if t.progress < 1.0:
continue
t.progress = 1.0
# #217: a unit of energy per leg flown under boost, spent on arrival
# so a leg is either paid for in full or not flown under boost at all.
# Refuelling legs are not charged - a transporter would otherwise burn
# the fuel it is on its way to collect.
if int(t.get("fuel", 0)) > 0 and String(t.state) != TRANSPORTER_TO_FUEL:
t.fuel = int(t.fuel) - 1
_arrive(t)
@ -3429,9 +3487,187 @@ func _arrive(t: Dictionary) -> void:
t.carrying = 0
t.target_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_TO_FUEL:
# Whatever is actually there, up to the tank. A store drained by
# something else while this was in the air simply means a shorter
# fill, not a wasted trip.
var tank: int = transporter_fuel_capacity() - int(t.get("fuel", 0))
var source_id: int = int(t.get("fuel_source_id", 0))
var drawn: int = 0
if source_id == 0:
drawn = mini(tank, bank_inventory.amount(RESOURCE_ENERGY))
bank_inventory.withdraw(RESOURCE_ENERGY, drawn)
else:
drawn = mini(tank, building_resource(source_id, RESOURCE_ENERGY))
var store: RefCounted = building_inventory(source_id)
if store != null:
store.withdraw(RESOURCE_ENERGY, drawn)
else:
drawn = 0
t.fuel = int(t.get("fuel", 0)) + drawn
t.fuel_source_id = 0
_begin_leg(t, here, hub_pos if hub_pos != null else here, TRANSPORTER_RETURNING)
TRANSPORTER_RETURNING:
t.state = TRANSPORTER_IDLE
t.progress = 1.0
# #216: a round trip is what wears one out, counted where the trip
# ENDS. Counting at dispatch would retire a vessel with its cargo
# still in the air.
t.trips_left = int(t.get("trips_left", TRANSPORTER_TRIPS)) - 1
if int(t.trips_left) <= 0:
_retire_transporter(t)
# --- staffing hubs and workshops (#216) ------------------------------------
#
# Transporters and repair drones used to appear with their building, free and
# permanent. They are ordinary craft now: ordered at a ship crafter, parked in
# the mothership hangar, flown out to whichever structure asked for them, and
# worn out by the work they do. What a structure HAS and what it was ASKED to
# have are separate, exactly as they are for an outpost garrison (#104), so a
# hub waiting on a hull it has not built yet reads as waiting rather than as
# broken.
#
# How much work one has in it before it is spent. Round numbers rather than a
# curve: the point the ticket makes is that they run out and have to be
# replaced, and a player has to be able to hold "about twenty runs" in their
# head while deciding whether to build another.
const TRANSPORTER_TRIPS: int = 20
const DRONE_REPAIRS: int = 12
# Both already exist above - VESSEL_TRANSPORTER with the other vessel ids,
# REPAIR_DRONE with the workshop - so they are used rather than redeclared.
# Which vessel staffs this structure, or "" for one that is not staffed.
func station_vessel_type(building: Dictionary) -> String:
if building_has_capability(building, "transport_hub"):
return VESSEL_TRANSPORTER
if building_has_capability(building, "repair"):
return REPAIR_DRONE
return ""
func station_capacity(building: Dictionary) -> int:
match station_vessel_type(building):
VESSEL_TRANSPORTER: return hub_capacity()
REPAIR_DRONE: return workshop_drone_capacity()
return 0
func station_desired(id: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty():
return 0
return mini(int(building.get("staff_desired", 0)), station_capacity(building))
# Returns what was actually set, so a caller can show a clamped request rather
# than silently doing something else - same contract as set_garrison_desired.
func set_station_desired(id: int, count: int) -> int:
var building: Dictionary = find_building(id)
if building.is_empty() or station_vessel_type(building) == "":
return 0
building.staff_desired = clampi(count, 0, station_capacity(building))
return int(building.staff_desired)
# How many are actually standing there. Counts the crew, not the order: one
# still in the air has not started working yet.
func station_crew(id: int) -> int:
var building: Dictionary = find_building(id)
var n: int = 0
match station_vessel_type(building):
VESSEL_TRANSPORTER:
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == id:
n += 1
REPAIR_DRONE:
for drone in repair_drones:
if int(drone.get("workshop_id", 0)) == id:
n += 1
return n
# What is left in each of them, worst first, for the readout in the building.
# A structure whose crew is nearly spent should look nearly spent.
func station_condition(id: int) -> Array:
var building: Dictionary = find_building(id)
var left: Array = []
match station_vessel_type(building):
VESSEL_TRANSPORTER:
for transporter in transporters:
if int(transporter.get("hub_id", 0)) == id:
left.append(int(transporter.get("trips_left", TRANSPORTER_TRIPS)))
REPAIR_DRONE:
for drone in repair_drones:
if int(drone.get("workshop_id", 0)) == id:
left.append(int(drone.get("repairs_left", DRONE_REPAIRS)))
left.sort()
return left
func station_work_capacity(id: int) -> int:
var building: Dictionary = find_building(id)
match station_vessel_type(building):
VESSEL_TRANSPORTER: return TRANSPORTER_TRIPS
REPAIR_DRONE: return DRONE_REPAIRS
return 0
# One in the air, so a structure short of crew does not order a second hull
# for the same berth while the first is still on its way.
func _station_inbound(id: int) -> int:
var n: int = 0
for delivery in vessel_deliveries:
if int(delivery.get("to_station", 0)) == id:
n += 1
return n
func _advance_station_dispatch() -> void:
var mothership: Variant = get_mothership_position()
if mothership == null:
return
for building in buildings:
var vessel_type: String = station_vessel_type(building)
if vessel_type == "":
continue
var id: int = int(building.id)
var short: int = station_desired(id) - station_crew(id) - _station_inbound(id)
while short > 0 and _take_stored_vessel(vessel_type):
vessel_deliveries.append({"type": vessel_type, "from": mothership,
"to": building.position, "progress": 0.0, "to_station": id})
short -= 1
func _station_still_wants(id: int) -> bool:
var building: Dictionary = find_building(id)
if building.is_empty() or station_vessel_type(building) == "":
return false
return station_crew(id) < station_desired(id)
func _station_arrived(id: int) -> void:
var building: Dictionary = find_building(id)
match station_vessel_type(building):
VESSEL_TRANSPORTER: _add_hub_transporter(building)
REPAIR_DRONE: _add_workshop_drone(building)
# A spent vessel is GONE, not returned to the hangar: it was used up. The
# structure is short again, and the dispatch above will send another as soon
# as the player has built one.
func _retire_transporter(t: Dictionary) -> void:
var index: int = transporters.find(t)
if index >= 0:
transporters.remove_at(index)
func _retire_drone(drone: Dictionary) -> void:
var index: int = repair_drones.find(drone)
if index >= 0:
repair_drones.remove_at(index)
# --- quests (#129, #131) ---------------------------------------------------

29
tests/_staff.gd Normal file
View file

@ -0,0 +1,29 @@
extends RefCounted
# #216 made hub and workshop crews built things: ordered at a ship crafter,
# flown out from the mothership hangar, and worn out by the work they do.
#
# A test that is about what a crew DOES should not have to build and fly one
# in first, so this puts them straight at their post. The assignment is still
# made, so the structure counts them as its own and reports them the way it
# would if they had arrived on their own - only the journey is skipped.
#
# test_transporter_supply.gd owns the journey itself, and does not use this.
# Tops the structure UP to `count`, rather than adding that many: a hub asked
# for three when it already holds two needs one more, and the order is clamped
# to what it can hold anyway.
static func staff(game_state: Node, building_id: int, count: int) -> void:
game_state.set_station_desired(building_id, count)
while game_state.station_crew(building_id) < game_state.station_desired(building_id):
game_state._station_arrived(building_id)
# Every hub and workshop already standing, filled to what it can hold. For
# tests that just need logistics to work at all.
static func staff_all(game_state: Node) -> void:
for building in game_state.buildings:
if game_state.station_vessel_type(building) == "":
continue
staff(game_state, int(building.id), game_state.station_capacity(building))

View file

@ -169,6 +169,10 @@ func _process(delta: float) -> bool:
game_state.unlocked.append(node.id)
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
# #216: a hub arrives empty now - its transporters are built and
# flown in. This test is about the ROUTE, so the crew is put
# straight at its post; test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(game_state)
game_state.add_extractor("Event Zero", BLACK_HOLE_AT, GameState.RESOURCE_DARK_MATTER, "test_black_hole_extractor")
extractor_index = game_state.extractor_index_at("test_black_hole_extractor")
if extractor_index < 0:

View file

@ -22,8 +22,14 @@ func _initialize() -> void:
return
var provider: Node = building.visual.get_node_or_null("ActionProvider")
if "transport_hub" in definition.capabilities:
if provider != null or not building.available_actions().is_empty():
_fail("transport hub composed a manual action")
# #216: a hub has exactly one thing to press - how many
# transporters to keep here. Its routes remain automatic, so a
# manual transfer would still be wrong.
if provider == null:
_fail("transport hub has no way to set its crew")
elif building.available_actions() != PackedStringArray(["staff"]):
_fail("transport hub composed %s, expected only the crew menu" \
% [building.available_actions()])
return
elif "planet_scanner" in definition.capabilities:
if provider == null or not provider.actions.has("scanner") \

View file

@ -146,6 +146,10 @@ func _run_state_cases() -> bool:
# --- 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))
# #216: crews are built and flown in now, not provisioned with the
# structure. This test is about what a crew DOES, so it is put
# straight at its post - test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff(game_state, hub_id, game_state.hub_capacity())
var hub_transporters: int = 0
for transporter in game_state.transporters:
if int(transporter.get("hub_id", 0)) == hub_id:

View file

@ -46,6 +46,10 @@ func _process(delta: float) -> bool:
game_state.extractor_stock(extractor_index), game_state.rocks])
game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
# #216: crews are built and flown in now, not provisioned with the
# structure. This test is about what a crew DOES, so it is put
# straight at its post - test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(game_state)
if game_state.transporters.size() != game_state.hub_capacity():
_fail("hub did not provision its transporters")
return true

View file

@ -125,6 +125,10 @@ func _process(delta: float) -> bool:
game_state.gas = 200
mothership_id = game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT)
hub_id = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT)
# #216: a hub arrives empty now - its transporters are built and
# flown in. This test is about the ROUTE, so the crew is put
# straight at its post; test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(game_state)
burner_id = game_state.add_building(GameState.BUILDING_GAS_BURNER, BURNER_AT)
if game_state.transporters.size() != game_state.hub_capacity():
return _fail("hub did not provision its transporters")

View file

@ -90,6 +90,10 @@ func _process(delta: float) -> bool:
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(100, 0))
# #216: a hub arrives empty now - its transporters are built and
# flown in. This test is about the ROUTE, so the crew is put
# straight at its post; test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(game_state)
game_state.add_extractor("Aurelia", Vector2(400, 0), "gas")
game_state.extractors[0].stock = game_state.transporter_load_limit()
var transporter: Dictionary = game_state.transporters[0]

View file

@ -1,8 +1,13 @@
extends SceneTree
# A transport hub owns its logistics vessels directly. Building a hub
# provisions every researched slot immediately; Freight Yards provisions its
# extra slot on hubs that already exist.
# A transport hub holds a crew of logistics vessels, and Freight Yards widens
# how many it can hold.
#
# Forgejo #216 changed what the doctrine buys. A hub used to PROVISION every
# researched slot the moment it was built, free and forever; the slots are
# empty now, and the player fills them with transporters they have built. So
# what is checked here is the ROOM, the clamp on it, and that a hub taken down
# takes its crew with it rather than orphaning them.
func _initialize() -> void:
@ -17,28 +22,50 @@ func _initialize() -> void:
state.research_points = state.skill_research_point_cost("freight_yards")
state.unlocked = ["shipyards", "logistics", "freight"]
var staff: Script = load("res://tests/_staff.gd")
var hub_a: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 0))
var per_hub: int = state.hub_capacity()
if state.transporters.size() != per_hub or _at_hub(state, hub_a) != per_hub:
return _fail("first hub did not immediately provision %d transporters" % per_hub)
print("first hub provisioned %d transporters" % per_hub)
if not state.transporters.is_empty():
return _fail("a new hub arrived with %d transporters of its own" % state.transporters.size())
if state.station_capacity(state.find_building(hub_a)) != per_hub:
return _fail("a new hub reports room for %d, expected %d" % [
state.station_capacity(state.find_building(hub_a)), per_hub])
print("a new hub: room for %d, and nobody in it" % per_hub)
# The order is clamped to the room, so a hub cannot be asked for a crew it
# has nowhere to put.
if state.set_station_desired(hub_a, per_hub + 3) != per_hub:
return _fail("a hub accepted an order past its own capacity")
staff.staff(state, hub_a, per_hub)
if _at_hub(state, hub_a) != per_hub:
return _fail("the hub holds %d of its %d" % [_at_hub(state, hub_a), per_hub])
var hub_b: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(-500, 600))
if state.transporters.size() != per_hub * 2 or _at_hub(state, hub_b) != per_hub:
return _fail("second hub did not bring its own transporters")
print("second hub raised logistics fleet to %d" % state.transporters.size())
if _at_hub(state, hub_b) != 0:
return _fail("a second hub brought its own transporters")
staff.staff(state, hub_b, per_hub)
if state.transporters.size() != per_hub * 2:
return _fail("two crewed hubs hold %d transporters, expected %d" % [
state.transporters.size(), per_hub * 2])
print("two hubs, each crewed to %d" % per_hub)
# --- the doctrine buys ROOM, not vessels ---------------------------
var before: int = state.transporters.size()
if not state.unlock("freight_yards"):
return _fail("could not research Freight Yards")
var expected: int = state.hub_capacity() * state.hub_count()
if state.transporters.size() != expected or state.transporters.size() != before + 2:
return _fail("Freight Yards did not provision one transporter per existing hub")
if _at_hub(state, hub_a) != state.hub_capacity() \
or _at_hub(state, hub_b) != state.hub_capacity():
return _fail("researched transporters were assigned to the wrong hubs")
if state.hub_capacity() != per_hub + 1:
return _fail("Freight Yards moved capacity to %d, expected %d" % [
state.hub_capacity(), per_hub + 1])
if state.transporters.size() != before:
return _fail("Freight Yards conjured %d transporters" % (state.transporters.size() - before))
print("Freight Yards widened each hub to %d without crewing anything" % state.hub_capacity())
# And the new room is usable.
staff.staff(state, hub_a, state.hub_capacity())
if _at_hub(state, hub_a) != state.hub_capacity():
return _fail("the widened hub holds %d of its %d" % [
_at_hub(state, hub_a), state.hub_capacity()])
print("Freight Yards provisioned existing hubs: %d -> %d" % [before, expected])
if state.remove_buildings_of_type(state.BUILDING_TRANSPORT_HUB) != 2 \
or not state.transporters.is_empty():
return _fail("removing hubs left orphaned logistics transporters")

View file

@ -16,6 +16,10 @@ func _initialize() -> void:
state.build_time_scale = 0.02
state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO)
state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0))
# #216: a hub arrives empty now - its transporters are built and
# flown in. This test is about the ROUTE, so the crew is put
# straight at its post; test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(state)
lab_id = state.add_building(state.BUILDING_SHIP_LAB, Vector2(100, 0))
if not state.queue_lab_research(lab_id, "int_damage"):
return _fail("could not queue lab research")

View file

@ -1,5 +1,14 @@
extends SceneTree
# Logistics travels at a readable pace rather than teleporting: the ceiling
# below is the rule, and each leg has to actually advance at the speed its own
# stat claims.
#
# The expected progress is DERIVED from that stat rather than written out,
# because the numbers are tuned: #217 slowed transporters from 150 to 120 and
# a hardcoded window failed for a change it was not about. What must not drift
# is the ceiling, and the agreement between the stat and the movement.
func _initialize() -> void:
var state: Node = root.get_node("GameState")
@ -16,16 +25,20 @@ func _initialize() -> void:
"to": Vector2(900, 0), "progress": 0.0})
state._advance_vessel_deliveries(1.0)
var delivery_progress: float = state.vessel_deliveries[0].progress
if delivery_progress < 0.18 or delivery_progress > 0.22:
return _fail("900-unit delivery advanced %.3f in one second" % delivery_progress)
var delivery_expected: float = delivery_speed / 900.0
if absf(delivery_progress - delivery_expected) > 0.01:
return _fail("900-unit delivery advanced %.3f in one second at %.0f u/s, expected %.3f" \
% [delivery_progress, delivery_speed, delivery_expected])
state.transporters.append({"hub_id": 0, "state": state.TRANSPORTER_RETURNING,
"target_id": 0, "carrying": 0, "progress": 0.0,
"from": Vector2.ZERO, "to": Vector2(600, 0)})
state._advance_transporters(1.0)
var transport_progress: float = state.transporters[0].progress
if transport_progress < 0.23 or transport_progress > 0.27:
return _fail("600-unit transport leg advanced %.3f in one second" % transport_progress)
var transport_expected: float = transport_speed / 600.0
if absf(transport_progress - transport_expected) > 0.01:
return _fail("600-unit transport leg advanced %.3f in one second at %.0f u/s, expected %.3f" \
% [transport_progress, transport_speed, transport_expected])
print("delivery %.0f u/s, transporter %.0f u/s" % [delivery_speed, transport_speed])
print("TEST_LOGISTICS_TRAVEL_SPEED: PASS")

View file

@ -72,7 +72,7 @@ func _process(delta: float) -> bool:
# exact match rather than by hunting for resource words in it: a
# Gas Bomber is named after a resource and always will be, and
# what #111 removed was the PRICE spelled out underneath.
var fleet_defs: Array = VesselDefinitions.fleet()
var fleet_defs: Array = VesselDefinitions.craftable()
for i in range(mini(controller.craft_menu.vessel_labels.size(), fleet_defs.size())):
var caption: String = String(controller.craft_menu.vessel_labels[i].text)
var expected: String = String(fleet_defs[i].display_name)
@ -98,7 +98,9 @@ func _process(delta: float) -> bool:
var priced: int = 0
var stacked: int = 0
for i in range(controller.craft_menu.price_tags.size()):
var definition: Resource = VesselDefinitions.fleet()[i]
# #216 added the two support craft to the crafter, so the rows
# are craftable() now - fleet hulls first, then those.
var definition: Resource = VesselDefinitions.craftable()[i]
var costs: Dictionary = game_state.product_costs(definition.definition_id)
var strip: Control = controller.craft_menu.price_tags[i]
if costs.is_empty():

View file

@ -15,6 +15,10 @@ func _initialize() -> void:
state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2.ZERO)
# #216: crews are built and flown in now, not provisioned with the
# structure. This test is about what a crew DOES, so it is put
# straight at its post - test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff_all(state)
var lab_id: int = state.add_building(GameState.BUILDING_SHIP_LAB, Vector2(120, 0))
if not state.queue_lab_research(lab_id, "unlock_gas_bomber"):
_fail("could not queue gas research")

View file

@ -89,6 +89,10 @@ func _process(_delta: float) -> bool:
game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO)
workshop_id = game_state.add_building(GameState.BUILDING_WORKSHOP, Vector2(600.0, 0.0))
# #216: crews are built and flown in now, not provisioned with the
# structure. This test is about what a crew DOES, so it is put
# straight at its post - test_transporter_supply.gd owns the journey.
load("res://tests/_staff.gd").staff(game_state, workshop_id, game_state.workshop_drone_capacity())
if _drones_of(workshop_id).size() != GameState.BASE_WORKSHOP_DRONES:
return _fail("a new workshop stationed %d drones, expected %d" \
% [_drones_of(workshop_id).size(), GameState.BASE_WORKSHOP_DRONES])
@ -187,6 +191,7 @@ func _process(_delta: float) -> bool:
if bonus_id == "":
return _fail("no skill node raises the drone count")
var before_count: int = _drones_of(workshop_id).size()
var before_room: int = game_state.station_capacity(game_state.find_building(workshop_id))
game_state.rocks = 5000
# #138: the deep doctrines want gas and energy too.
game_state.bank_inventory.set_amount(GameState.RESOURCE_GAS, 5000)
@ -194,10 +199,21 @@ func _process(_delta: float) -> bool:
game_state.research_points = 20
if not game_state.unlock(bonus_id):
return _fail("could not research \"%s\"" % bonus_id)
# #216: the doctrine buys ROOM, not drones. It used to provision one into
# every standing workshop for nothing; the extra berth is now something
# the player fills with a drone they have built.
if _drones_of(workshop_id).size() != before_count:
return _fail("researching \"%s\" conjured %d drones" \
% [bonus_id, _drones_of(workshop_id).size() - before_count])
var room: int = game_state.station_capacity(game_state.find_building(workshop_id))
if room != before_room + 1:
return _fail("the doctrine moved the workshop's room from %d to %d, expected %d" \
% [before_room, room, before_room + 1])
load("res://tests/_staff.gd").staff(game_state, workshop_id, room)
if _drones_of(workshop_id).size() != before_count + 1:
return _fail("a workshop built before the upgrade still has %d drones" \
return _fail("the berth the doctrine bought could not be crewed (%d drones)" \
% _drones_of(workshop_id).size())
print("\"%s\" raises an already-standing workshop from %d to %d drones" \
print("\"%s\" widens a standing workshop from %d to %d berths, crewed by hand" \
% [bonus_id, before_count, _drones_of(workshop_id).size()])
print("TEST_SPACIAL_WORKSHOP: PASS")

View file

@ -109,8 +109,13 @@ func _process(delta: float) -> bool:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (never spawned)")
quit(1)
return true
if not hub_building.available_actions().is_empty():
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (offers a manual resource transfer)")
# #216 gave the hub one action, and only one: saying how many
# transporters to keep here. Its ROUTES are still automatic -
# there is nothing to hand-carry - so a transfer action would put
# back exactly what this check was written to keep out.
if hub_building.available_actions() != PackedStringArray(["staff"]):
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (offers %s, expected only the crew menu)" \
% [hub_building.available_actions()])
quit(1)
return true
for ship in scene.get_node("Fleet").ships:
@ -152,9 +157,14 @@ func _process(delta: float) -> bool:
# --- 4. animates without ever redrawing (#10) ----------------
if elapsed < 2.0:
return false
# #216 gave the hub one button - how many transporters to keep
# here. The TRANSFER button stays absent: its routes are still
# automatic, and hand-carrying to a hub is what this check was
# written to keep out.
if controller.current_building != hub_building \
or controller.action_button.visible or controller.transfer_button.visible:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (actionless hub context current=%s expected=%s action=%s transfer=%s)" % [
or not controller.action_button.visible \
or controller.transfer_button.visible:
print("TEST_TRANSPORT_HUB_BUILDING: FAIL (hub context current=%s expected=%s action=%s transfer=%s)" % [
controller.current_building, hub_building, controller.action_button.visible,
controller.transfer_button.visible,
])

View file

@ -0,0 +1,127 @@
extends SceneTree
# Forgejo #217: transporters crawl by default, and a Burn Drive lets them
# carry energy and spend it to go far faster. The energy is theirs to find -
# they fetch it from the nearest store that has any - and it runs out, so the
# speed is something the player's energy supply pays for continuously rather
# than a one-off purchase.
#
# Covers:
# A. unresearched, a transporter never detours for fuel however much energy
# is lying about, and makes the base speed
# B. researched and empty, an idle one goes for the NEAREST store with
# energy in it, and takes what is there
# C. a fuelled transporter is measurably faster than an unfuelled one
# D. a leg burns a unit, and an empty tank drops it back to the crawl
# E. the leg it flies to collect fuel is not itself charged
const HUB_AT: Vector2 = Vector2(0.0, 0.0)
const NEAR_ENERGY_AT: Vector2 = Vector2(400.0, 0.0)
const FAR_ENERGY_AT: Vector2 = Vector2(-3000.0, 0.0)
var state: Node
var hub_id: int
var near_id: int
var far_id: int
func _initialize() -> void:
state = root.get_node("GameState")
state.buildings.clear()
state.extractors.clear()
state.transporters.clear()
state.stored_vessels.clear()
state.vessel_deliveries.clear()
state.reset_progression()
state.rocks = 500
state.unlocked = ["shipyards", "logistics", "freight"]
state.add_building(state.BUILDING_MOTHERSHIP, Vector2(0.0, 4000.0))
hub_id = state.add_building(state.BUILDING_TRANSPORT_HUB, HUB_AT)
near_id = state.add_building(state.BUILDING_SHIP_CRAFTER, NEAR_ENERGY_AT)
far_id = state.add_building(state.BUILDING_SHIP_LAB, FAR_ENERGY_AT)
load("res://tests/_staff.gd").staff(state, hub_id, 1)
func _initialize_done() -> void:
pass
func _process(_delta: float) -> bool:
# The bank is deliberately empty of energy, so the only sources are the
# two structures below and "nearest" is a real choice.
state.bank_inventory.set_amount(GameState.RESOURCE_ENERGY, 0)
state.deposit_resource_to_building(near_id, GameState.RESOURCE_ENERGY, 4)
state.deposit_resource_to_building(far_id, GameState.RESOURCE_ENERGY, 50)
var t: Dictionary = state.transporters[0]
# --- A. unresearched: no tank, no detour, base speed ----------------
if state.transporter_burns_energy():
return _fail("an unresearched transporter already burns energy")
var crawl: float = state.transporter_leg_speed(t)
if not is_equal_approx(crawl, state.transporter_speed()):
return _fail("an unfuelled transporter makes %.1f, expected the base %.1f" \
% [crawl, state.transporter_speed()])
state._refuel_idle_transporters()
if String(t.state) != state.TRANSPORTER_IDLE:
return _fail("an unresearched transporter went looking for fuel")
print("unresearched: no detour, %.0f base speed" % crawl)
# --- B. researched and empty: to the NEAREST store ------------------
state.vessel_levels["trn_burn"] = 1
if not state.transporter_burns_energy():
return _fail("a researched Burn Drive did not enable energy burning")
state._refuel_idle_transporters()
if String(t.state) != state.TRANSPORTER_TO_FUEL:
return _fail("an empty transporter did not go for fuel (state %s)" % t.state)
if not Vector2(t.to).is_equal_approx(NEAR_ENERGY_AT):
return _fail("it flew to %s, not the nearer store at %s" % [t.to, NEAR_ENERGY_AT])
print("researched and empty: flies to the nearest store, not the fullest")
# --- E. and the trip to collect is not itself charged ---------------
var before_fuel: int = int(t.get("fuel", 0))
state._advance_transporters(600.0)
if int(t.get("fuel", 0)) <= before_fuel:
return _fail("the transporter came back from the fuel store with nothing")
# The near store held 4, which is under the tank, so it took all of it.
if int(t.fuel) != 4:
return _fail("it drew %d energy, expected the 4 that were there" % int(t.fuel))
if state.building_resource(near_id, GameState.RESOURCE_ENERGY) != 0:
return _fail("the store it drew from still holds energy")
print("collected 4 - all the near store had - and was not charged for the trip")
# --- C. fuelled is faster -------------------------------------------
var boosted: float = state.transporter_leg_speed(t)
if boosted <= crawl:
return _fail("a fuelled transporter makes %.1f, no better than the crawl %.1f" \
% [boosted, crawl])
if not is_equal_approx(boosted, crawl * state.transporter_fuel_boost()):
return _fail("a fuelled transporter makes %.1f, expected %.1f" \
% [boosted, crawl * state.transporter_fuel_boost()])
print("fuelled: %.0f against %.0f, a %.1fx difference" \
% [boosted, crawl, state.transporter_fuel_boost()])
# --- D. and it burns down to the crawl ------------------------------
var tank: int = int(t.fuel)
for leg in range(tank):
t.state = state.TRANSPORTER_RETURNING
t.progress = 0.0
t.from = HUB_AT
t.to = NEAR_ENERGY_AT
state._advance_transporters(600.0)
if int(t.get("fuel", 0)) != 0:
return _fail("%d legs left %d energy aboard, expected an empty tank" \
% [tank, int(t.fuel)])
if not is_equal_approx(state.transporter_leg_speed(t), crawl):
return _fail("an emptied transporter did not drop back to the crawl")
print("%d legs emptied the tank and it is back to %.0f" % [tank, crawl])
print("TEST_TRANSPORTER_BURN: PASS")
quit(0)
return true
func _fail(message: String) -> bool:
print("TEST_TRANSPORTER_BURN: FAIL (%s)" % message)
quit(1)
return true

View file

@ -1,14 +1,26 @@
extends SceneTree
# Hub-owned transporters supply an unfunded combat-vessel order without ever
# being crafted, stored at the mothership, or added to the player fleet.
# Forgejo #216: a transport hub used to conjure its own transporters, free and
# permanent. They are built at a ship crafter now, flown out from the
# mothership hangar to whichever hub asked for them, and worn out by the runs
# they make.
#
# Covers:
# A. a new hub stands empty - nothing is provisioned with the structure
# B. the crafter accepts a transporter order, which it used to refuse
# C. one assigned and one in the hangar puts it at the hub, and a hub that
# was asked for nothing stays empty however many are sitting there
# D. the transporter then does the job the old free ones did: it supplies a
# queued order, without ore being created or destroyed
# E. it wears out, and the spent vessel is gone rather than returned
var state: Node
var crafter_id: int
var initial_transporters: int
var hub_id: int
var total_at_start: int
var consumed: int = 0
var elapsed: float = 0.0
var stage: int = 0
var conservation_broken: bool = false
@ -20,56 +32,129 @@ func _initialize() -> void:
state.stored_vessels.clear()
state.vessel_deliveries.clear()
state.reset_progression()
state.rocks = 200
state.rocks = 400
state.build_time_scale = 0.02
state.unlocked = ["shipyards", "logistics", "freight"]
state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO)
crafter_id = state.add_building(state.BUILDING_SHIP_CRAFTER, Vector2(900, -300))
if state.queue_order(crafter_id, state.VESSEL_TRANSPORTER):
return _fail("the crafter accepted a logistics transporter order")
var hub_id: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(600, 200))
initial_transporters = state.hub_capacity()
if state.transporters.size() != initial_transporters:
return _fail("hub provisioned %d transporters, expected %d" % [
state.transporters.size(), initial_transporters])
for transporter in state.transporters:
if int(transporter.hub_id) != hub_id or transporter.state != state.TRANSPORTER_IDLE:
return _fail("a provisioned transporter was not idle at its hub")
var bank_before: int = state.rocks
if not state.queue_order(crafter_id, state.PRODUCT_SHIP):
return _fail("crafter refused the combat-vessel order")
if state.rocks != bank_before:
return _fail("placing the order changed the bank before a transporter loaded")
state.order_completed.connect(_on_order_completed)
total_at_start = _total()
hub_id = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(600, 200))
func _process(delta: float) -> bool:
elapsed += delta
if _total() + consumed != total_at_start:
conservation_broken = true
if state.pending_orders(crafter_id) > 0:
if elapsed > 25.0:
return _fail("hub transporters never supplied the queued order")
return false
if conservation_broken:
return _fail("ore was created or destroyed on the automatic route")
if state.transporters.size() != initial_transporters:
return _fail("transporters accumulated during delivery")
if state.stored_vessels.has(state.VESSEL_TRANSPORTER):
return _fail("a transporter appeared in the mothership hangar")
print("%d hub-owned transporters supplied the crafter automatically" % initial_transporters)
print("TEST_TRANSPORTER_SUPPLY: PASS")
quit(0)
return true
match stage:
0:
# --- A. nothing comes with the structure ---------------------
if not state.transporters.is_empty():
return _fail("a new hub arrived with %d transporters" % state.transporters.size())
if state.station_desired(hub_id) != 0:
return _fail("a new hub already had a crew ordered")
print("a new hub stands empty")
# --- B. the crafter takes the order --------------------------
if not state.queue_order(crafter_id, state.VESSEL_TRANSPORTER):
return _fail("the crafter refused a transporter order")
state.find_building(crafter_id).orders.clear()
# --- C. assigned, and only then sent -------------------------
# One in the hangar and nothing asked for: it must stay there.
state.stored_vessels.append(state.VESSEL_TRANSPORTER)
state._advance_station_dispatch()
if not state.transporters.is_empty():
return _fail("a hub that asked for nothing took a transporter anyway")
if state.set_station_desired(hub_id, 1) != 1:
return _fail("the hub refused an order for one transporter")
state._advance_station_dispatch()
if state.stored_vessels.has(state.VESSEL_TRANSPORTER):
return _fail("the assigned transporter never left the hangar")
if state.vessel_deliveries.is_empty():
return _fail("nothing was sent to the hub")
elapsed = 0.0
stage = 1
return false
1:
if state.station_crew(hub_id) < 1:
if elapsed > 25.0:
return _fail("the transporter never reached the hub")
return false
var condition: Array = state.station_condition(hub_id)
if condition.size() != 1 or int(condition[0]) != GameState.TRANSPORTER_TRIPS:
return _fail("the arrived transporter reports %s trips, expected [%d]" \
% [condition, GameState.TRANSPORTER_TRIPS])
print("ordered, built and flown out: 1 at the hub with %d runs in it" \
% GameState.TRANSPORTER_TRIPS)
# --- D. and it does the work ---------------------------------
if not state.queue_order(crafter_id, state.PRODUCT_SHIP):
return _fail("crafter refused the combat-vessel order")
state.order_completed.connect(_on_order_completed)
total_at_start = _total()
elapsed = 0.0
stage = 2
return false
2:
if _total() + consumed != total_at_start:
conservation_broken = true
if state.pending_orders(crafter_id) > 0:
if elapsed > 25.0:
return _fail("the hub's transporter never supplied the queued order")
return false
if conservation_broken:
return _fail("ore was created or destroyed on the automatic route")
print("the hub's own transporter supplied the crafter")
elapsed = 0.0
stage = 3
return false
3:
# --- E. and it is used up by doing so ------------------------
# The run is spent when it gets HOME, not when the cargo lands, so
# this waits for the transporter to be idle at its hub again -
# which is also the rule that stops a vessel retiring with its
# cargo still in the air.
if state.transporters.is_empty():
return _fail("the transporter vanished before completing its run")
if String(state.transporters[0].state) != state.TRANSPORTER_IDLE:
if elapsed > 25.0:
return _fail("the transporter never got home")
return false
var worn: Array = state.station_condition(hub_id)
if worn.is_empty() or int(worn[0]) >= GameState.TRANSPORTER_TRIPS:
return _fail("a transporter that ran a supply route is still unworn (%s)" % [worn])
print("a completed run wore it to %d" % int(worn[0]))
# Wound down to its last run, so the next arrival home retires it.
state.transporters[0].trips_left = 1
elapsed = 0.0
stage = 4
return false
4:
if not state.transporters.is_empty():
if elapsed > 30.0:
return _fail("a transporter on its last run was never retired")
# Keep it working: an idle one at its hub never completes
# another leg, and it is the arrival home that spends the run.
state.deposit_resource_to_building(crafter_id, GameState.RESOURCE_ORE, 0)
state.queue_order(crafter_id, state.PRODUCT_SHIP)
return false
if state.stored_vessels.has(state.VESSEL_TRANSPORTER):
return _fail("a spent transporter went back to the hangar instead of being used up")
if state.station_crew(hub_id) != 0:
return _fail("the hub still counts a retired transporter")
print("spent after its last run: gone, and the hub is short again")
print("TEST_TRANSPORTER_SUPPLY: PASS")
quit(0)
return true
return false
func _on_order_completed(_building_id: int, product: String) -> void:
consumed += state.product_cost(product)
# Every place a rock can be while the route runs: the bank (the mothership's
# own store IS state.rocks, so counting the building too would double it), a
# building's local store, an order that has started and taken its cost, and a
# transporter's hold in mid-flight.
func _total() -> int:
var sum: int = state.rocks + state.fleet_cargo
for building in state.buildings:

View file

@ -0,0 +1,139 @@
extends SceneTree
# Forgejo #216, the workshop half. A spacial workshop used to conjure its own
# repair drones, free and permanent. They are built at a ship crafter now,
# flown out from the mothership hangar to whichever workshop asked for them,
# and spent by the repairs they make.
#
# test_transporter_supply.gd covers the same rules on the hub side. The two
# share a model but not their wear paths - a transporter spends a run getting
# home, a drone spends a repair getting home - so the drone's own path is
# exercised here rather than assumed from the transporter's.
#
# Covers:
# A. a new workshop stands empty, and takes nothing until it is asked to
# B. the crafter accepts a repair drone order, which it used to refuse
# C. an assigned workshop is sent one from the hangar
# D. it repairs a damaged structure and is worn by doing so
# E. its last repair uses it up, and the workshop is short again
var state: Node
var workshop_id: int
var damaged_id: int
var elapsed: float = 0.0
var stage: int = 0
func _initialize() -> void:
state = root.get_node("GameState")
state.buildings.clear()
state.extractors.clear()
state.transporters.clear()
state.repair_drones.clear()
state.stored_vessels.clear()
state.vessel_deliveries.clear()
state.reset_progression()
state.rocks = 600
state.unlocked = ["shipyards", "logistics"]
state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO)
workshop_id = state.add_building(GameState.BUILDING_WORKSHOP, Vector2(700.0, 0.0))
damaged_id = state.add_building(state.BUILDING_SHIP_CRAFTER, Vector2(900.0, 200.0))
state.deposit_resource_to_building(workshop_id, GameState.RESOURCE_ORE, 400)
func _drones() -> int:
return state.station_crew(workshop_id)
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
# --- A. nothing comes with the structure ---------------------
if not state.repair_drones.is_empty():
return _fail("a new workshop arrived with %d drones" % state.repair_drones.size())
state.stored_vessels.append(GameState.REPAIR_DRONE)
state._advance_station_dispatch()
if _drones() != 0:
return _fail("a workshop that asked for nothing took a drone anyway")
print("a new workshop stands empty, and takes nothing unasked")
# --- B. the crafter takes the order --------------------------
var crafter_id: int = state.add_building(state.BUILDING_SHIP_CRAFTER, Vector2(-900.0, 0.0))
if not state.queue_order(crafter_id, GameState.REPAIR_DRONE):
return _fail("the crafter refused a repair drone order")
state.find_building(crafter_id).orders.clear()
# --- C. assigned, then sent ----------------------------------
if state.set_station_desired(workshop_id, 1) != 1:
return _fail("the workshop refused an order for one drone")
state._advance_station_dispatch()
if state.vessel_deliveries.is_empty():
return _fail("nothing was sent to the workshop")
elapsed = 0.0
stage = 1
return false
1:
if _drones() < 1:
if elapsed > 25.0:
return _fail("the drone never reached the workshop")
return false
var fresh: Array = state.station_condition(workshop_id)
if fresh.size() != 1 or int(fresh[0]) != GameState.DRONE_REPAIRS:
return _fail("the arrived drone reports %s, expected [%d]" \
% [fresh, GameState.DRONE_REPAIRS])
print("ordered and flown out: 1 drone with %d repairs in it" % GameState.DRONE_REPAIRS)
# --- D. it does the work, and is worn by it ------------------
state.damage_building(damaged_id, state.building_max_hp(state.BUILDING_SHIP_CRAFTER) / 2)
elapsed = 0.0
stage = 2
return false
2:
if state.building_health_fraction(damaged_id) >= 0.999:
print("the workshop's own drone repaired the structure")
elapsed = 0.0
stage = 3
return false
if elapsed > 40.0:
return _fail("the drone never repaired the damaged structure")
return false
3:
# Spent on getting HOME, so this waits for it to be idle again.
if state.repair_drones.is_empty():
return _fail("the drone vanished mid-run")
if String(state.repair_drones[0].state) != GameState.DRONE_IDLE:
if elapsed > 25.0:
return _fail("the drone never got home")
return false
var worn: Array = state.station_condition(workshop_id)
if worn.is_empty() or int(worn[0]) >= GameState.DRONE_REPAIRS:
return _fail("a drone that ran a repair is still unworn (%s)" % [worn])
print("a completed repair wore it to %d" % int(worn[0]))
# --- E. and its last one uses it up --------------------------
state.repair_drones[0].repairs_left = 1
state.damage_building(damaged_id, state.building_max_hp(state.BUILDING_SHIP_CRAFTER) / 2)
elapsed = 0.0
stage = 4
return false
4:
if not state.repair_drones.is_empty():
if elapsed > 40.0:
return _fail("a drone on its last repair was never retired")
return false
if state.stored_vessels.has(GameState.REPAIR_DRONE):
return _fail("a spent drone went back to the hangar instead of being used up")
if _drones() != 0:
return _fail("the workshop still counts a retired drone")
print("spent after its last repair: gone, and the workshop is short again")
print("TEST_WORKSHOP_STAFFING: PASS")
quit(0)
return true
return false
func _fail(message: String) -> bool:
print("TEST_WORKSHOP_STAFFING: FAIL (%s)" % message)
quit(1)
return true

View file

@ -21,10 +21,15 @@ const PRICE_OFFSET := 154.0
@onready var status: Label = $StatusLabel
@onready var queue_detail: Label = $QueueDetail
@onready var resource_bar: Control = $Bar
# #216 added the two support craft at the end, in the order
# VESSEL_DEFINITIONS.craftable() returns them - fleet hulls first, so none of
# the rows above moved.
@onready var vessel_buttons: Array = [$InterceptorButton, $ShieldButton,
$GasBomberButton, $CarrierButton, $LaserButton, $DistorderButton]
$GasBomberButton, $CarrierButton, $LaserButton, $DistorderButton,
$TransporterButton, $RepairDroneButton]
@onready var vessel_labels: Array = [$InterceptorLabel, $ShieldLabel,
$GasBomberLabel, $CarrierLabel, $LaserLabel, $DistorderLabel]
$GasBomberLabel, $CarrierLabel, $LaserLabel, $DistorderLabel,
$TransporterLabel, $RepairDroneLabel]
@onready var queue_slots: Array = [$QueueSlot0, $QueueSlot1, $QueueSlot2, $QueueSlot3]
var building_id: int = 0
@ -36,8 +41,8 @@ var price_tags: Array = []
func _ready() -> void:
super._ready()
_build_price_tags()
for i in range(mini(vessel_buttons.size(), VESSEL_DEFINITIONS.fleet().size())):
var definition: Resource = VESSEL_DEFINITIONS.fleet()[i]
for i in range(mini(vessel_buttons.size(), VESSEL_DEFINITIONS.craftable().size())):
var definition: Resource = VESSEL_DEFINITIONS.craftable()[i]
vessel_buttons[i].pressed.connect(
func(): craft_requested.emit(String(definition.definition_id)))
for i in range(queue_slots.size()):
@ -65,7 +70,7 @@ func say(next_message: String) -> void:
func refresh() -> void:
status.text = ""
var definitions: Array = VESSEL_DEFINITIONS.fleet()
var definitions: Array = VESSEL_DEFINITIONS.craftable()
for i in range(mini(definitions.size(), vessel_buttons.size())):
var definition: Resource = definitions[i]
var button: Control = vessel_buttons[i]

View file

@ -212,6 +212,62 @@ autowrap_mode = 2
text = "Distorder"
horizontal_alignment = 1
[node name="TransporterButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 215
offset_right = 280
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="TransporterLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 195
offset_right = 300
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
autowrap_mode = 2
text = "Transporter"
horizontal_alignment = 1
[node name="RepairDroneButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 295
offset_right = 360
offset_top = -100
offset_bottom = 48
script = ExtResource("10")
icon = 43
glow_color = Color(0.5, 0.95, 0.8, 1)
[node name="RepairDroneLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 0.5
anchor_right = 0.5
anchor_top = 0.5
anchor_bottom = 0.5
offset_left = 275
offset_right = 380
offset_top = 56
offset_bottom = 136
theme_override_font_sizes/font_size = 24
autowrap_mode = 2
text = "Repair Drone"
horizontal_alignment = 1
[node name="QueueLabel" type="Label" parent="."]
mouse_filter = 2
anchor_left = 1.0

110
ui/menus/StationMenu.gd Normal file
View file

@ -0,0 +1,110 @@
extends "res://ui/menus/ModalMenu.gd"
# Forgejo #216: transport hubs and spacial workshops used to conjure their own
# crews, free and permanent. The player orders them at a ship crafter now, and
# this is where they say how many they want standing HERE.
#
# The menu edits the ORDER, not the crew - the same split GarrisonPanel makes
# for outposts, and for the same reason: a hub that has asked for three and
# been sent one is waiting, not broken, and has to read that way.
#
# It also answers "how long will these last". Each vessel is spent by the work
# it does, so the bar row below shows what each of them has left, worst first,
# which is the number that decides whether another needs building.
const BAR_WIDTH: float = 54.0
const BAR_HEIGHT: float = 18.0
const BAR_GAP: float = 10.0
const BAR_TOP: float = 470.0
const WORN_COLOR: Color = Color(1.0, 0.35, 0.25)
const FRESH_COLOR: Color = Color(0.5, 1.0, 0.55)
const PLATE_COLOR: Color = Color(0.02, 0.02, 0.06, 0.9)
const FRAME_COLOR: Color = Color(0.85, 0.96, 1.0, 0.85)
@onready var title: Label = $TitleLabel
@onready var assigned_label: Label = $AssignedLabel
@onready var status: Label = $StatusLabel
@onready var condition_label: Label = $ConditionLabel
@onready var less_button: IconButton = $LessButton
@onready var more_button: IconButton = $MoreButton
var building_id: int = 0
func _ready() -> void:
super._ready()
# ModalMenu already owns $CloseButton and wires it.
less_button.pressed.connect(func(): _step(-1))
more_button.pressed.connect(func(): _step(1))
func open_for(next_building_id: int) -> void:
building_id = next_building_id
open_menu()
refresh()
func close_menu() -> void:
super.close_menu()
building_id = 0
func _step(delta: int) -> void:
if building_id == 0:
return
GameState.set_station_desired(building_id,
GameState.station_desired(building_id) + delta)
refresh()
func refresh() -> void:
if building_id == 0:
return
var building: Dictionary = GameState.find_building(building_id)
if building.is_empty():
close_menu()
return
var vessel_type: String = GameState.station_vessel_type(building)
var wanted: int = GameState.station_desired(building_id)
var here: int = GameState.station_crew(building_id)
var capacity: int = GameState.station_capacity(building)
title.text = String(building.get("type", "")).to_upper().replace("_", " ")
assigned_label.text = "%d / %d ASSIGNED (room for %d)" % [here, wanted, capacity]
less_button.enabled = wanted > 0
more_button.enabled = wanted < capacity
var name: String = GameState.vessel_name(vessel_type)
if wanted == 0:
status.text = "None assigned. Build %s at a ship crafter, then ask for them here." % name.to_lower()
elif here >= wanted:
status.text = "Fully crewed. Each one wears out with use and is not replaced by itself."
else:
status.text = "%d on order - sent from the mothership hangar as they are built." % (wanted - here)
var left: Array = GameState.station_condition(building_id)
condition_label.text = "CONDITION" if not left.is_empty() else ""
queue_redraw()
func _draw() -> void:
if building_id == 0:
return
# One bar per vessel standing here, worst first: what is left of the work
# it was built to do. Drawn rather than built from nodes because the count
# changes as vessels arrive and wear out, and a row of bars is cheaper to
# draw than to keep a pool of Controls in step with.
var left: Array = GameState.station_condition(building_id)
if left.is_empty():
return
var full: int = maxi(GameState.station_work_capacity(building_id), 1)
var total: float = float(left.size()) * BAR_WIDTH + float(left.size() - 1) * BAR_GAP
var x: float = (size.x - total) * 0.5
for remaining in left:
var fraction: float = clampf(float(remaining) / float(full), 0.0, 1.0)
draw_rect(Rect2(x - 2.0, BAR_TOP - 2.0, BAR_WIDTH + 4.0, BAR_HEIGHT + 4.0),
PLATE_COLOR)
draw_rect(Rect2(x, BAR_TOP, BAR_WIDTH * fraction, BAR_HEIGHT),
WORN_COLOR.lerp(FRESH_COLOR, fraction))
draw_rect(Rect2(x, BAR_TOP, BAR_WIDTH, BAR_HEIGHT), FRAME_COLOR, false, 1.5)
x += BAR_WIDTH + BAR_GAP

99
ui/menus/StationMenu.tscn Normal file
View file

@ -0,0 +1,99 @@
[gd_scene load_steps=3 format=3]
[ext_resource type="Script" path="res://ui/IconButton.gd" id="icon"]
[ext_resource type="Script" path="res://ui/menus/StationMenu.gd" id="menu"]
[node name="StationMenu" type="Control" groups=["modal_menus"]]
visible = false
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
script = ExtResource("menu")
[node name="Backdrop" type="ColorRect" parent="."]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
color = Color(0.03, 0.03, 0.08, 0.96)
[node name="TitleLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 24.0
offset_top = 24.0
offset_right = -180.0
offset_bottom = 100.0
theme_override_font_sizes/font_size = 40
text = "TRANSPORT HUB"
horizontal_alignment = 1
[node name="CloseButton" type="Control" parent="."]
anchor_left = 1.0
anchor_right = 1.0
offset_left = -140.0
offset_top = 24.0
offset_right = -24.0
offset_bottom = 140.0
script = ExtResource("icon")
icon = 1
glow_color = Color(0.55, 0.6, 1, 1)
[node name="AssignedLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 36.0
offset_top = 190.0
offset_right = -36.0
offset_bottom = 250.0
theme_override_font_sizes/font_size = 30
text = "0 / 0 ASSIGNED"
horizontal_alignment = 1
[node name="LessButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = -180.0
offset_top = 280.0
offset_right = -64.0
offset_bottom = 396.0
script = ExtResource("icon")
icon = 28
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="MoreButton" type="Control" parent="."]
anchor_left = 0.5
anchor_right = 0.5
offset_left = 64.0
offset_top = 280.0
offset_right = 180.0
offset_bottom = 396.0
script = ExtResource("icon")
icon = 29
glow_color = Color(0.95, 0.55, 0.85, 1)
[node name="ConditionLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
offset_left = 36.0
offset_top = 420.0
offset_right = -36.0
offset_bottom = 462.0
theme_override_font_sizes/font_size = 22
theme_override_colors/font_color = Color(0.55, 0.78, 0.86, 1)
text = ""
horizontal_alignment = 1
[node name="StatusLabel" type="Label" parent="."]
mouse_filter = 2
anchor_right = 1.0
anchor_bottom = 1.0
offset_left = 40.0
offset_top = -300.0
offset_right = -40.0
offset_bottom = -160.0
theme_override_font_sizes/font_size = 24
theme_override_colors/font_color = Color(0.55, 0.9, 1, 1)
text = ""
horizontal_alignment = 1
vertical_alignment = 1
autowrap_mode = 2

View file

@ -7,8 +7,12 @@ script = ExtResource("1")
definition_id = "repair_drone"
display_name = "Repair Drone"
description = "Workshop-owned tender. Flies out to damaged structures and puts them back together, spending ore delivered to its workshop. Cannot be crafted - a workshop brings its own."
build_cost = 0
build_time = 0.0
build_cost = 10
build_resource_costs = {
"ore": 10
}
support_craftable = true
build_time = 11.0
icon = 43
glow_color = Color(0.5, 0.95, 0.8, 1)
hull_points = PackedVector2Array(0, -14, 9, -4, 7, 10, -7, 10, -9, -4)

View file

@ -0,0 +1,12 @@
[gd_resource type="Resource" script_class="VesselUpgradeDefinition" load_steps=2 format=3]
[ext_resource type="Script" path="res://vessels/VesselUpgradeDefinition.gd" id="1"]
[resource]
script = ExtResource("1")
upgrade_id = "trn_burn"
display_name = "Burn Drive"
description = "Transporters carry energy and burn it in flight, travelling far faster while it lasts. They fetch it themselves from the nearest store that has any."
base_cost = 45
max_level = 2
research_point_cost = 1
icon = 18
multiplicative_modifiers = {"fuel_boost": 1.9}

View file

@ -1,19 +1,24 @@
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=6 format=3]
[gd_resource type="Resource" script_class="VesselDefinition" load_steps=7 format=3]
[ext_resource type="Script" path="res://vessels/VesselDefinition.gd" id="1"]
[ext_resource type="PackedScene" path="res://vessels/VesselCargoComponent.tscn" id="2"]
[ext_resource type="PackedScene" path="res://vessels/VesselTransportComponent.tscn" id="3"]
[ext_resource type="Resource" path="res://vessels/TransportCapacityUpgrade.tres" id="4"]
[ext_resource type="Resource" path="res://vessels/TransportSpeedUpgrade.tres" id="5"]
[ext_resource type="Resource" path="res://vessels/TransportBurnUpgrade.tres" id="6"]
[resource]
script = ExtResource("1")
definition_id = "transporter"
display_name = "Transporter"
description = "Hub-owned logistics vessel that moves resources automatically."
build_cost = 0
build_time = 0.0
build_cost = 8
build_resource_costs = {
"ore": 8
}
build_time = 9.0
support_craftable = true
icon = 12
glow_color = Color(0.95, 0.55, 0.85, 1)
capabilities = PackedStringArray("cargo", "transport")
component_scenes = Array[PackedScene]([ExtResource("2"), ExtResource("3")])
base_stats = {"max_speed": 690.0, "acceleration": 2025.0, "cargo_capacity": 20.0, "transport_speed": 150.0, "delivery_speed": 180.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5")])
base_stats = {"max_speed": 690.0, "acceleration": 2025.0, "cargo_capacity": 20.0, "fuel_capacity": 6.0, "fuel_boost": 1.0, "transport_speed": 120.0, "delivery_speed": 180.0}
upgrades = Array[Resource]([ExtResource("4"), ExtResource("5"), ExtResource("6")])

View file

@ -24,6 +24,12 @@ extends Resource
Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16),
])
@export var fleet_eligible: bool = false
# #216: orderable at a ship crafter without joining the fleet. Transporters
# and repair drones used to appear with their building for nothing; they are
# built now, and they staff a structure rather than flying with the player.
# Separate from fleet_eligible because "the crafter can make it" and "it can
# take a berth in the fleet" stopped being the same question.
@export var support_craftable: bool = false
# What one of these keeps beside it when stationed at an outpost, and which
# resource that is. Data here rather than a table in the outpost, so a new
# armed vessel brings its own magazine with it and the outpost never has to

View file

@ -30,6 +30,16 @@ static func fleet() -> Array:
result.append(definition)
return result
# Everything a ship crafter can be asked for: the fleet hulls, plus #216's
# support craft. Ordered fleet-first so the craft menu's own rows do not move
# when a support vessel is added.
static func craftable() -> Array:
var result: Array = fleet()
for definition in all():
if definition.support_craftable and not definition.fleet_eligible:
result.append(definition)
return result
static func with_capability(capability: String) -> Resource:
for definition in all():
if definition.has_capability(capability):

View file

@ -10,6 +10,7 @@
[ext_resource type="PackedScene" path="res://ui/menus/SkillMenu.tscn" id="menu_skill"]
[ext_resource type="PackedScene" path="res://ui/menus/DevMenu.tscn" id="menu_dev"]
[ext_resource type="PackedScene" path="res://ui/menus/ScannerMenu.tscn" id="menu_scanner"]
[ext_resource type="PackedScene" path="res://ui/menus/StationMenu.tscn" id="menu_station"]
[ext_resource type="PackedScene" path="res://ui/ScannerGuidance.tscn" id="scanner_guidance"]
@ -527,6 +528,8 @@ icon = 1
[node name="SkillMenu" parent="UI" instance=ExtResource("menu_skill")]
[node name="DevMenu" parent="UI" instance=ExtResource("menu_dev")]
[node name="ScannerMenu" parent="UI" instance=ExtResource("menu_scanner")]
[node name="StationMenu" parent="UI" instance=ExtResource("menu_station")]
[node name="QuestPanel" type="Control" parent="UI" groups=["modal_menus"]]
visible = false
mouse_filter = 2