diff --git a/buildings/TransportHubBuildingVisual.tscn b/buildings/TransportHubBuildingVisual.tscn index 3d92a19..9896aa0 100644 --- a/buildings/TransportHubBuildingVisual.tscn +++ b/buildings/TransportHubBuildingVisual.tscn @@ -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") diff --git a/buildings/WorkshopBuildingVisual.tscn b/buildings/WorkshopBuildingVisual.tscn index 07b91bb..55484b6 100644 --- a/buildings/WorkshopBuildingVisual.tscn +++ b/buildings/WorkshopBuildingVisual.tscn @@ -8,4 +8,4 @@ script = ExtResource("1") [node name="ActionProvider" type="Node" parent="."] script = ExtResource("2") -actions = PackedStringArray("transfer") +actions = PackedStringArray("staff", "transfer") diff --git a/controllers/BuildController.gd b/controllers/BuildController.gd index 8fe1b33..2db5156 100644 --- a/controllers/BuildController.gd +++ b/controllers/BuildController.gd @@ -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: diff --git a/core/GameState.gd b/core/GameState.gd index fb53f26..fce5d73 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -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) --------------------------------------------------- diff --git a/tests/_staff.gd b/tests/_staff.gd new file mode 100644 index 0000000..52c5639 --- /dev/null +++ b/tests/_staff.gd @@ -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)) diff --git a/tests/test_black_hole.gd b/tests/test_black_hole.gd index 1f4783f..0404ba6 100644 --- a/tests/test_black_hole.gd +++ b/tests/test_black_hole.gd @@ -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: diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd index 5cc65b8..d6112ee 100644 --- a/tests/test_building_composition.gd +++ b/tests/test_building_composition.gd @@ -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") \ diff --git a/tests/test_building_health.gd b/tests/test_building_health.gd index 644e48a..6d098a9 100644 --- a/tests/test_building_health.gd +++ b/tests/test_building_health.gd @@ -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: diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index 6436473..149dbe8 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -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 diff --git a/tests/test_gas_burner.gd b/tests/test_gas_burner.gd index b753285..ae1c64b 100644 --- a/tests/test_gas_burner.gd +++ b/tests/test_gas_burner.gd @@ -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") diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index 81fc3e0..34dd368 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -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] diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 0f62d24..5d01247 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -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") diff --git a/tests/test_lab_transporter_delivery.gd b/tests/test_lab_transporter_delivery.gd index 6c1fe94..500bac3 100644 --- a/tests/test_lab_transporter_delivery.gd +++ b/tests/test_lab_transporter_delivery.gd @@ -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") diff --git a/tests/test_logistics_travel_speed.gd b/tests/test_logistics_travel_speed.gd index 092bad0..798093c 100644 --- a/tests/test_logistics_travel_speed.gd +++ b/tests/test_logistics_travel_speed.gd @@ -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") diff --git a/tests/test_menu_polish.gd b/tests/test_menu_polish.gd index 6c9f0c9..8df93b9 100644 --- a/tests/test_menu_polish.gd +++ b/tests/test_menu_polish.gd @@ -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(): diff --git a/tests/test_multi_resource_supply.gd b/tests/test_multi_resource_supply.gd index ecf956c..ca1dd20 100644 --- a/tests/test_multi_resource_supply.gd +++ b/tests/test_multi_resource_supply.gd @@ -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") diff --git a/tests/test_spacial_workshop.gd b/tests/test_spacial_workshop.gd index 3d173b8..1ac168a 100644 --- a/tests/test_spacial_workshop.gd +++ b/tests/test_spacial_workshop.gd @@ -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") diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index 740299d..f5e59bc 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -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, ]) diff --git a/tests/test_transporter_burn.gd b/tests/test_transporter_burn.gd new file mode 100644 index 0000000..76c326c --- /dev/null +++ b/tests/test_transporter_burn.gd @@ -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 diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index 3fc9c2d..90527eb 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -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: diff --git a/tests/test_workshop_staffing.gd b/tests/test_workshop_staffing.gd new file mode 100644 index 0000000..46b92d1 --- /dev/null +++ b/tests/test_workshop_staffing.gd @@ -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 diff --git a/ui/menus/CraftMenu.gd b/ui/menus/CraftMenu.gd index 0000008..c0bff46 100644 --- a/ui/menus/CraftMenu.gd +++ b/ui/menus/CraftMenu.gd @@ -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] diff --git a/ui/menus/CraftMenu.tscn b/ui/menus/CraftMenu.tscn index 8769b77..5a79c72 100644 --- a/ui/menus/CraftMenu.tscn +++ b/ui/menus/CraftMenu.tscn @@ -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 diff --git a/ui/menus/StationMenu.gd b/ui/menus/StationMenu.gd new file mode 100644 index 0000000..c639bbf --- /dev/null +++ b/ui/menus/StationMenu.gd @@ -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 diff --git a/ui/menus/StationMenu.tscn b/ui/menus/StationMenu.tscn new file mode 100644 index 0000000..965bfbe --- /dev/null +++ b/ui/menus/StationMenu.tscn @@ -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 diff --git a/vessels/RepairDroneVesselDefinition.tres b/vessels/RepairDroneVesselDefinition.tres index 30b0212..cbe1598 100644 --- a/vessels/RepairDroneVesselDefinition.tres +++ b/vessels/RepairDroneVesselDefinition.tres @@ -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) diff --git a/vessels/TransportBurnUpgrade.tres b/vessels/TransportBurnUpgrade.tres new file mode 100644 index 0000000..22f28ce --- /dev/null +++ b/vessels/TransportBurnUpgrade.tres @@ -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} diff --git a/vessels/TransporterVesselDefinition.tres b/vessels/TransporterVesselDefinition.tres index 4704f9a..9b68460 100644 --- a/vessels/TransporterVesselDefinition.tres +++ b/vessels/TransporterVesselDefinition.tres @@ -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")]) diff --git a/vessels/VesselDefinition.gd b/vessels/VesselDefinition.gd index b6aad36..3160b0a 100644 --- a/vessels/VesselDefinition.gd +++ b/vessels/VesselDefinition.gd @@ -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 diff --git a/vessels/VesselDefinitions.gd b/vessels/VesselDefinitions.gd index 58370ed..d1060a0 100644 --- a/vessels/VesselDefinitions.gd +++ b/vessels/VesselDefinitions.gd @@ -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): diff --git a/world/Main.tscn b/world/Main.tscn index 790dbb2..3274663 100644 --- a/world/Main.tscn +++ b/world/Main.tscn @@ -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