From d0a9f5b9715e9ae7c5e8dc782f07860d0118e904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Sat, 15 Aug 2026 00:09:10 +0200 Subject: [PATCH] Provision transporters with hubs --- BuildController.gd | 30 +-- Fleet.gd | 18 +- GameState.gd | 147 +++++------- InterceptorVesselDefinition.tres | 1 + Main.tscn | 132 +++------- ShieldVesselDefinition.tres | 1 + TransporterVesselDefinition.tres | 6 +- VesselDefinition.gd | 1 + VesselDefinitions.gd | 8 + tests/_hangar_menu_preview.gd | 23 ++ tests/_hangar_menu_preview.gd.uid | 1 + tests/_hangar_menu_preview.tscn | 6 + tests/_transporter_preview.gd | 25 +- tests/test_extractor_income.gd | 10 +- tests/test_gas_system.gd | 16 +- tests/test_hangar_management.gd | 7 +- tests/test_hub_capacity.gd | 182 ++++---------- tests/test_lab_transporter_delivery.gd | 5 +- tests/test_planet_atmosphere_transition.gd | 2 +- tests/test_transporter_supply.gd | 266 ++++++--------------- tests/test_vessel_composition.gd | 4 + 21 files changed, 306 insertions(+), 585 deletions(-) create mode 100644 tests/_hangar_menu_preview.gd create mode 100644 tests/_hangar_menu_preview.gd.uid create mode 100644 tests/_hangar_menu_preview.tscn diff --git a/BuildController.gd b/BuildController.gd index 7b7da87..ce9b43b 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -62,22 +62,18 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var hangar_available_slots: Array = [ get_node("../UI/HangarMenu/AvailableInterceptor"), get_node("../UI/HangarMenu/AvailableShield"), - get_node("../UI/HangarMenu/AvailableTransporter"), ] @onready var hangar_available_labels: Array = [ get_node("../UI/HangarMenu/AvailableInterceptorLabel"), get_node("../UI/HangarMenu/AvailableShieldLabel"), - get_node("../UI/HangarMenu/AvailableTransporterLabel"), ] @onready var hangar_fleet_slots: Array = [ get_node("../UI/HangarMenu/FleetInterceptor"), get_node("../UI/HangarMenu/FleetShield"), - get_node("../UI/HangarMenu/FleetTransporter"), ] @onready var hangar_fleet_labels: Array = [ get_node("../UI/HangarMenu/FleetInterceptorLabel"), get_node("../UI/HangarMenu/FleetShieldLabel"), - get_node("../UI/HangarMenu/FleetTransporterLabel"), ] var build_menu_entries: Array = [] @@ -97,17 +93,15 @@ var lab_building_id: int = 0 @onready var craft_menu: Control = get_node("../UI/CraftMenu") @onready var craft_interceptor_button: IconButton = get_node("../UI/CraftMenu/InterceptorButton") -@onready var craft_transporter_button: IconButton = get_node("../UI/CraftMenu/TransporterButton") @onready var craft_close_button: IconButton = get_node("../UI/CraftMenu/CloseButton") @onready var craft_status: Label = get_node("../UI/CraftMenu/StatusLabel") @onready var craft_interceptor_label: Label = get_node("../UI/CraftMenu/InterceptorLabel") -@onready var craft_transporter_label: Label = get_node("../UI/CraftMenu/TransporterLabel") @onready var craft_shield_label: Label = get_node("../UI/CraftMenu/ShieldLabel") @onready var craft_vessel_buttons: Array = [ - craft_interceptor_button, craft_shield_button, craft_transporter_button, + craft_interceptor_button, craft_shield_button, ] @onready var craft_vessel_labels: Array = [ - craft_interceptor_label, craft_shield_label, craft_transporter_label, + craft_interceptor_label, craft_shield_label, ] # --- the transfer panel (#24) --------------------------------------------- @@ -187,8 +181,8 @@ func _ready() -> void: action_button.pressed.connect(_on_context_action_pressed) building_action_button.pressed.connect(_on_action_pressed) - for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.all().size())): - var definition: Resource = VESSEL_DEFINITIONS.all()[i] + for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.fleet().size())): + var definition: Resource = VESSEL_DEFINITIONS.fleet()[i] craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id)) craft_close_button.pressed.connect(func(): craft_menu.visible = false) @@ -734,7 +728,7 @@ func _refresh_craft_status() -> void: # intentionally reserved for the shared resource bar and a short action # message; repeating fleet, queue and transport counts here buried the UI. craft_status.text = "" - var definitions: Array = VESSEL_DEFINITIONS.all() + var definitions: Array = VESSEL_DEFINITIONS.fleet() for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] @@ -742,9 +736,6 @@ func _refresh_craft_status() -> void: button.icon = definition.icon button.glow_color = definition.glow_color button.enabled = GameState.vessel_is_unlocked(definition.definition_id) - if definition.has_capability("transport"): - button.enabled = button.enabled and GameState.has_transport_hub() \ - and GameState.can_station_transporter() button.queue_redraw() _refresh_queue() if craft_message != "": @@ -768,11 +759,6 @@ func _on_craft_vessel(vessel: String) -> void: if not GameState.queue_order(craft_building_id, vessel): if not GameState.vessel_is_unlocked(vessel): _say("%s must be unlocked at a ship lab first" % definition.display_name) - elif definition.has_capability("transport") and not GameState.has_transport_hub(): - _say("Build a Transport Hub first - a transporter needs somewhere to be stationed") - elif definition.has_capability("transport"): - _say("All %d berths are taken - build another hub, or research Freight Yards" % - GameState.transport_capacity()) return _report_order(vessel) @@ -787,10 +773,6 @@ func _report_order(product: String) -> void: _say("%s ordered - building now" % GameState.vessel_name(product)) -func _on_craft_transporter() -> void: - _on_craft_vessel(GameState.VESSEL_TRANSPORTER) - - # --- ship lab (#5) -------------------------------------------------------- func _lab_options() -> Array: @@ -1075,7 +1057,7 @@ func _on_hangar_store_type(index: int) -> void: func _hangar_types() -> Array: var types: Array = [] - for definition in VESSEL_DEFINITIONS.all(): + for definition in VESSEL_DEFINITIONS.fleet(): types.append(definition.definition_id) return types diff --git a/Fleet.gd b/Fleet.gd index d613164..af83cdd 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -51,12 +51,21 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, ships.clear() formation_offsets.clear() - var n: int = clamp(count, 1, GameState.max_fleet_size()) + var spawn_types: PackedStringArray = PackedStringArray() + if vessel_types.is_empty(): + for i in range(count): + spawn_types.append(GameState.VESSEL_INTERCEPTOR) + else: + for vessel_type in vessel_types: + if GameState.vessel_is_fleet_eligible(vessel_type): + spawn_types.append(vessel_type) + if spawn_types.is_empty(): + spawn_types.append(GameState.VESSEL_INTERCEPTOR) + var n: int = clamp(spawn_types.size(), 1, GameState.max_fleet_size()) for i in range(n): var offset: Vector2 = GameState.formation[i] var ship: Area2D = ship_scene.instantiate() - if i < vessel_types.size(): - ship.vessel_type = vessel_types[i] + ship.vessel_type = spawn_types[i] ship.position = center + offset ship.glow_color = ship_glow_color ship.core_color = ship_core_color @@ -80,7 +89,8 @@ func vessel_types() -> PackedStringArray: func add_ship(vessel_type: String = GameState.VESSEL_INTERCEPTOR) -> bool: # Adds one vessel in the next free formation slot (bought from a ship # crafter). Returns false when the wedge is already full. - if ships.size() >= GameState.max_fleet_size(): + if not GameState.vessel_is_fleet_eligible(vessel_type) \ + or ships.size() >= GameState.max_fleet_size(): return false var offset: Vector2 = GameState.formation[ships.size()] var ship: Area2D = ship_scene.instantiate() diff --git a/GameState.gd b/GameState.gd index ec36e3d..cfc540a 100644 --- a/GameState.gd +++ b/GameState.gd @@ -230,7 +230,7 @@ const SKILL_TREE: Array = [ { "id": "freight", "name": "Freight Handling", "cost": 40, "requires": "logistics", "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, - "desc": "Build transport hubs, which supply your other buildings with ore.", + "desc": "Build transport hubs. Each hub includes its own automatic transporters.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", @@ -313,6 +313,8 @@ func unlock(id: String) -> bool: if not spend(int(skill_node(id).cost)): return false unlocked.append(id) + if int(skill_node(id).get("hub_bonus", 0)) > 0: + _sync_hub_transporters() return true @@ -381,6 +383,9 @@ func add_building(type: String, at_position: Vector2) -> int: "id": id, "type": type, "position": at_position, "inventory": INVENTORY.new(), "orders": [], }) + var building: Dictionary = buildings[-1] + if building_has_capability(building, "transport_hub"): + _provision_hub_transporters(building) return id @@ -400,9 +405,9 @@ func building_has_capability(building: Dictionary, capability: String) -> bool: # --- vessel types and their upgrades (#5) --------------------------------- -# The three things that can be flying around. The first and last double as -# production order ids, so a vessel type and the order that builds it are the -# same string rather than two that have to be kept in step. +# Combat vessels can join the player fleet. Transporter metadata lives in the +# same definition catalog so the lab can upgrade its cargo and travel stats, +# but transporters themselves belong to hubs and are never production orders. const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" const VESSEL_TRANSPORTER: String = "transporter" @@ -410,7 +415,6 @@ const VESSEL_TRANSPORTER: String = "transporter" # Compatibility constants for callers and old tests. Runtime metadata is # resolved from VesselDefinition below. const SHIELD_COST: int = 14 -const TRANSPORTER_COST: int = 10 const LAB_RESEARCH_TIME: float = 8.0 @@ -436,9 +440,6 @@ const SHIP_BASE_SPEED: float = 460.0 const SHIP_BASE_ACCEL: float = 450.0 const SHIELD_BASE_CHARGES: int = 1 const SHIELD_BASE_RECHARGE: float = 6.0 -const TRANSPORTER_BASE_LOAD: int = 20 -const TRANSPORTER_BASE_SPEED: float = 150.0 - var vessel_levels: Dictionary = {} signal vessel_upgraded(upgrade_id: String) @@ -477,6 +478,11 @@ func vessel_has_capability(vessel: String, capability: String) -> bool: return definition != null and definition.has_capability(capability) +func vessel_is_fleet_eligible(vessel: String) -> bool: + var definition: Resource = vessel_definition(vessel) + return definition != null and definition.fleet_eligible + + func vessel_is_unlocked(vessel: String) -> bool: var definition: Resource = vessel_definition(vessel) if definition == null: @@ -595,7 +601,6 @@ func transporter_speed() -> float: const PRODUCT_SHIP: String = "interceptor" -const PRODUCT_TRANSPORTER: String = "transporter" func product_cost(product: String) -> int: @@ -691,14 +696,8 @@ 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 vessel_is_unlocked(product): - return false - if definition.has_capability("transport") and not has_transport_hub(): - # Nowhere to station it. Refused rather than queued, so the player is - # told now instead of watching an order sit forever. - return false - if definition.has_capability("transport") and not can_station_transporter(): - # Every berth is taken or spoken for (#20). + if definition == null or not definition.fleet_eligible \ + or not vessel_is_unlocked(product): return false (b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false}) return true @@ -725,12 +724,6 @@ func _advance_production(delta: float) -> void: if not bool(order.started): if inventory.amount(RESOURCE_ORE) < cost: continue - if definition.has_capability("transport") and transporters.size() >= transport_capacity(): - # A hub was demolished after this was ordered, so the berth it - # was going to use is gone. Hold rather than overfill. - continue - if definition.has_capability("transport") and nearest_hub_to(b.position).is_empty(): - continue inventory.withdraw(RESOURCE_ORE, cost) order.started = true @@ -739,20 +732,15 @@ func _advance_production(delta: float) -> void: continue orders.pop_front() - if definition.has_capability("transport"): - # Transporters are unchanged: they take themselves to the nearest - # hub and wait for work (#19). - _station_transporter(b.position) + # 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. + var mothership: Variant = get_mothership_position() + if mothership == null: + stored_vessels.append(product) else: - # A finished hull launches from the crafter and physically flies to - # the mothership hangar. With no mothership there is nowhere to fly, - # so preserve the old safe fallback of holding it immediately. - var mothership: Variant = get_mothership_position() - if mothership == null: - stored_vessels.append(product) - else: - vessel_deliveries.append({"type": product, "from": b.position, - "to": mothership, "progress": 0.0}) + vessel_deliveries.append({"type": product, "from": b.position, + "to": mothership, "progress": 0.0}) order_completed.emit(int(b.id), product) @@ -838,13 +826,21 @@ func has_building_of_type(type: String) -> bool: func remove_buildings_of_type(type: String) -> int: var kept: Array = [] + var removed_ids: Array = [] var removed: int = 0 for b in buildings: if b.type == type: removed += 1 + removed_ids.append(int(b.id)) else: kept.append(b) buildings = kept + if type == BUILDING_TRANSPORT_HUB and not removed_ids.is_empty(): + var kept_transporters: Array = [] + for transporter in transporters: + if int(transporter.get("hub_id", 0)) not in removed_ids: + kept_transporters.append(transporter) + transporters = kept_transporters return removed @@ -1017,11 +1013,8 @@ func _advance_vessel_deliveries(delta: float) -> void: # # Entries are {"hub_id", "state", "target_id", "carrying", "progress", # "from", "to"}. -const TRANSPORTER_SPEED: float = 340.0 - -# How many transporters one hub can station. Raised for EVERY hub - built -# already or not - by research, which is the same shape as max_fleet_size() -# rather than a second mechanism doing the same job. +# How many transporters each hub owns. Raised for EVERY hub - built already +# or not - by research, and provisioned automatically rather than crafted. const BASE_HUB_CAPACITY: int = 2 const TRANSPORTER_IDLE: String = "idle" @@ -1050,61 +1043,30 @@ func hub_count() -> int: return n -func transport_capacity() -> int: - # Total berths: more hubs, or more capacity per hub. Either raises it, - # which is exactly the choice #12 asks the player to make. - return hub_count() * hub_capacity() - - -func pending_transporter_orders() -> int: - var n: int = 0 - for b in buildings: - for o in b.get("orders", []): - if vessel_has_capability(String(o.product), "transport"): - n += 1 - return n - - -func transporters_claimed() -> int: - # Built plus already ordered: an order in the queue has a berth reserved, - # or a player could queue ten of them against two berths and only find out - # when they refused to appear. - return transporters.size() + pending_transporter_orders() - - -func can_station_transporter() -> bool: - return transporters_claimed() < transport_capacity() - - -func has_transport_hub() -> bool: - return not nearest_hub_to(Vector2.ZERO).is_empty() - - -func nearest_hub_to(at: Vector2) -> Dictionary: - var best: Dictionary = {} - var best_d: float = INF - for b in buildings: - if not building_has_capability(b, "transport_hub"): - continue - var d: float = at.distance_squared_to(b.position) - if d < best_d: - best_d = d - best = b - return best - - -func _station_transporter(built_at: Vector2) -> void: - # Built at the crafter, then it flies itself to the nearest hub and waits - # there for orders (#12). - var hub: Dictionary = nearest_hub_to(built_at) - if hub.is_empty(): - return +func _add_hub_transporter(hub: Dictionary) -> void: transporters.append({ - "hub_id": int(hub.id), "state": TRANSPORTER_RETURNING, "target_id": 0, - "carrying": 0, "resource_type": "ore", "progress": 0.0, "from": built_at, "to": hub.position, + "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, }) +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)) @@ -1191,6 +1153,7 @@ func _dispatch_transporters() -> void: func _advance_transporters(delta: float) -> void: + _sync_hub_transporters() if transporters.is_empty(): return _dispatch_transporters() diff --git a/InterceptorVesselDefinition.tres b/InterceptorVesselDefinition.tres index 0a0d45b..1f0552a 100644 --- a/InterceptorVesselDefinition.tres +++ b/InterceptorVesselDefinition.tres @@ -14,6 +14,7 @@ build_cost = 6 build_time = 10.0 icon = 2 glow_color = Color(0.25, 0.85, 1, 1) +fleet_eligible = true capabilities = PackedStringArray("weapon") component_scenes = Array[PackedScene]([ExtResource("2")]) base_stats = {"damage": 1.0, "fire_cooldown": 0.5, "fire_range": 320.0, "max_speed": 460.0, "acceleration": 450.0, "delivery_speed": 180.0} diff --git a/Main.tscn b/Main.tscn index 1be9bc7..6700644 100644 --- a/Main.tscn +++ b/Main.tscn @@ -788,8 +788,8 @@ horizontal_alignment = 1 [node name="AvailableInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = -130 +offset_left = -180 +offset_right = -60 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -799,8 +799,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -300 -offset_right = -80 +offset_left = -250 +offset_right = 10 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -809,8 +809,8 @@ horizontal_alignment = 1 [node name="AvailableShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -60 -offset_right = 60 +offset_left = 60 +offset_right = 180 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -821,30 +821,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -110 -offset_right = 110 -offset_top = 380 -offset_bottom = 460 -theme_override_font_sizes/font_size = 22 -horizontal_alignment = 1 - -[node name="AvailableTransporter" type="Control" parent="UI/HangarMenu"] -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = 130 +offset_left = -10 offset_right = 250 -offset_top = 255 -offset_bottom = 375 -script = ExtResource("10") -icon = 12 -glow_color = Color(0.95, 0.55, 0.85, 1) - -[node name="AvailableTransporterLabel" type="Label" parent="UI/HangarMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = 80 -offset_right = 300 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -862,8 +840,8 @@ horizontal_alignment = 1 [node name="FleetInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = -130 +offset_left = -180 +offset_right = -60 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -873,8 +851,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -300 -offset_right = -80 +offset_left = -250 +offset_right = 10 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -883,8 +861,8 @@ horizontal_alignment = 1 [node name="FleetShield" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -60 -offset_right = 60 +offset_left = 60 +offset_right = 180 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -895,30 +873,8 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -110 -offset_right = 110 -offset_top = 690 -offset_bottom = 770 -theme_override_font_sizes/font_size = 22 -horizontal_alignment = 1 - -[node name="FleetTransporter" type="Control" parent="UI/HangarMenu"] -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = 130 +offset_left = -10 offset_right = 250 -offset_top = 565 -offset_bottom = 685 -script = ExtResource("10") -icon = 12 -glow_color = Color(0.95, 0.55, 0.85, 1) - -[node name="FleetTransporterLabel" type="Label" parent="UI/HangarMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -offset_left = 80 -offset_right = 300 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -1290,10 +1246,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -234 -offset_right = -86 -offset_top = 60 -offset_bottom = 208 +offset_left = -220 +offset_right = -72 +offset_top = -100 +offset_bottom = 48 script = ExtResource("10") icon = 2 @@ -1303,51 +1259,23 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -300 -offset_right = -20 -offset_top = 216 -offset_bottom = 296 +offset_left = -290 +offset_right = -2 +offset_top = 56 +offset_bottom = 136 theme_override_font_sizes/font_size = 32 text = "Interceptor" horizontal_alignment = 1 -[node name="TransporterButton" type="Control" parent="UI/CraftMenu"] -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = 86 -offset_right = 234 -offset_top = 60 -offset_bottom = 208 -script = ExtResource("10") -icon = 12 -glow_color = Color(0.95, 0.55, 0.85, 1) - -[node name="TransporterLabel" type="Label" parent="UI/CraftMenu"] -mouse_filter = 2 -anchor_left = 0.5 -anchor_right = 0.5 -anchor_top = 0.5 -anchor_bottom = 0.5 -offset_left = 20 -offset_right = 300 -offset_top = 216 -offset_bottom = 296 -theme_override_font_sizes/font_size = 32 -autowrap_mode = 2 -text = "Transporter" -horizontal_alignment = 1 - [node name="ShieldButton" type="Control" parent="UI/CraftMenu"] anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -74 -offset_right = 74 -offset_top = -200 -offset_bottom = -52 +offset_left = 72 +offset_right = 220 +offset_top = -100 +offset_bottom = 48 script = ExtResource("10") icon = 2 glow_color = Color(0.45, 0.8, 1, 1) @@ -1358,10 +1286,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -160 -offset_right = 160 -offset_top = -44 -offset_bottom = 16 +offset_left = 2 +offset_right = 290 +offset_top = 56 +offset_bottom = 136 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Shield Vessel" diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres index 26db397..0ca7bcc 100644 --- a/ShieldVesselDefinition.tres +++ b/ShieldVesselDefinition.tres @@ -13,6 +13,7 @@ build_cost = 14 build_time = 16.0 icon = 30 glow_color = Color(0.45, 0.8, 1, 1) +fleet_eligible = true capabilities = PackedStringArray("shield") component_scenes = Array[PackedScene]([ExtResource("3")]) hull_points = PackedVector2Array(0, -24, 20.7846, -12, 20.7846, 12, 0, 24, -20.7846, 12, -20.7846, -12) diff --git a/TransporterVesselDefinition.tres b/TransporterVesselDefinition.tres index 9e82f6c..b20fa98 100644 --- a/TransporterVesselDefinition.tres +++ b/TransporterVesselDefinition.tres @@ -8,9 +8,9 @@ script = ExtResource("1") definition_id = "transporter" display_name = "Transporter" -description = "Unarmed cargo vessel stationed at a transport hub to move resources." -build_cost = 10 -build_time = 8.0 +description = "Hub-owned logistics vessel that moves resources automatically." +build_cost = 0 +build_time = 0.0 icon = 12 glow_color = Color(0.95, 0.55, 0.85, 1) capabilities = PackedStringArray("cargo", "transport") diff --git a/VesselDefinition.gd b/VesselDefinition.gd index 0f6637f..f5e28c8 100644 --- a/VesselDefinition.gd +++ b/VesselDefinition.gd @@ -11,6 +11,7 @@ extends Resource @export var hull_points: PackedVector2Array = PackedVector2Array([ Vector2(0, -22), Vector2(-14, 16), Vector2(14, 16), ]) +@export var fleet_eligible: bool = false @export var capabilities: PackedStringArray = [] @export var component_scenes: Array[PackedScene] = [] @export var base_stats: Dictionary = {} diff --git a/VesselDefinitions.gd b/VesselDefinitions.gd index 2b33845..38d4734 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -17,6 +17,14 @@ static func all() -> Array: result.append(DEFINITIONS[definition_id]) return result + +static func fleet() -> Array: + var result: Array = [] + for definition in all(): + if 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/tests/_hangar_menu_preview.gd b/tests/_hangar_menu_preview.gd new file mode 100644 index 0000000..5801cdc --- /dev/null +++ b/tests/_hangar_menu_preview.gd @@ -0,0 +1,23 @@ +extends Node + +# Visual-only phone preview for the combat-fleet hangar. Logistics +# transporters deliberately have no card here. + + +func _ready() -> void: + GameState.buildings.clear() + GameState.extractors.clear() + GameState.transporters.clear() + GameState.stored_vessels = [ + GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_SHIELD, GameState.VESSEL_SHIELD, + ] + var main: Node = load("res://Main.tscn").instantiate() + add_child(main) + main.get_node("AsteroidField").set_process(false) + main.get_node("EnemySpawner").set_process(false) + main.get_node("Fleet").spawn_ships(3, Vector2.ZERO, PackedStringArray([ + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_INTERCEPTOR, + GameState.VESSEL_SHIELD, + ])) + main.get_node("BuildController")._on_hangar_pressed() diff --git a/tests/_hangar_menu_preview.gd.uid b/tests/_hangar_menu_preview.gd.uid new file mode 100644 index 0000000..e6cd971 --- /dev/null +++ b/tests/_hangar_menu_preview.gd.uid @@ -0,0 +1 @@ +uid://grp4nm3xvngn diff --git a/tests/_hangar_menu_preview.tscn b/tests/_hangar_menu_preview.tscn new file mode 100644 index 0000000..36fed3c --- /dev/null +++ b/tests/_hangar_menu_preview.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tests/_hangar_menu_preview.gd" id="1"] + +[node name="HangarMenuPreview" type="Node"] +script = ExtResource("1") diff --git a/tests/_transporter_preview.gd b/tests/_transporter_preview.gd index 68d2b55..5322f3c 100644 --- a/tests/_transporter_preview.gd +++ b/tests/_transporter_preview.gd @@ -15,17 +15,20 @@ func _ready() -> void: GameState.add_building(GameState.BUILDING_MOTHERSHIP, Vector2(-420.0, 0.0)) GameState.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(420.0, 0.0)) - # One laden, mid-leg, and one running home empty, so both states show. - GameState.transporters.append({ - "hub_id": 2, "state": GameState.TRANSPORTER_TO_BUILDING, "target_id": 2, - "carrying": 8, "progress": 0.45, - "from": Vector2(-420.0, 0.0), "to": Vector2(420.0, 0.0), - }) - GameState.transporters.append({ - "hub_id": 2, "state": GameState.TRANSPORTER_RETURNING, "target_id": 0, - "carrying": 0, "progress": 0.5, - "from": Vector2(-380.0, 150.0), "to": Vector2(420.0, 150.0), - }) + # The hub already provisioned these two. Put one laden mid-leg and one on + # its way home empty so both visual states show. + var laden: Dictionary = GameState.transporters[0] + laden.state = GameState.TRANSPORTER_TO_BUILDING + laden.target_id = 2 + laden.carrying = 8 + laden.progress = 0.45 + laden.from = Vector2(-420.0, 0.0) + laden.to = Vector2(420.0, 0.0) + var returning: Dictionary = GameState.transporters[1] + returning.state = GameState.TRANSPORTER_RETURNING + returning.progress = 0.5 + returning.from = Vector2(-380.0, 150.0) + returning.to = Vector2(420.0, 150.0) var main: Node = load("res://Main.tscn").instantiate() add_child(main) diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index 894324b..b89037e 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -39,12 +39,10 @@ func _process(delta: float) -> bool: print("extractor mined locally: stock=%d bank=%d" % [ game_state.extractor_stock(extractor_index), game_state.rocks]) game_state.add_building(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - var hub_id: int = game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) - # A manually seeded idle transporter is enough to exercise the same - # simulation used by a produced one. - game_state.transporters.append({"hub_id": hub_id, "state": GameState.TRANSPORTER_IDLE, - "target_id": 0, "extractor_index": -1, "carrying": 0, "progress": 1.0, - "from": HUB_AT, "to": HUB_AT}) + game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) + if game_state.transporters.size() != game_state.hub_capacity(): + _fail("hub did not provision its transporters") + return true base_load = game_state.transporter_load_limit() game_state.extractors[extractor_index].stock = base_load - 1 elapsed = 0.0 diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index 94edc1d..ef8234f 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -86,13 +86,19 @@ func _process(delta: float) -> bool: game_state.add_building(GameState.BUILDING_TRANSPORT_HUB, Vector2(100, 0)) game_state.add_extractor("Aurelia", Vector2(400, 0), "gas") game_state.extractors[0].stock = game_state.transporter_load_limit() - game_state.transporters.append({"hub_id": 2, "state": GameState.TRANSPORTER_TO_EXTRACTOR, - "target_id": 0, "extractor_index": 0, "carrying": 0, "resource_type": "gas", - "progress": 1.0, "from": Vector2(100, 0), "to": Vector2(400, 0)}) - game_state._arrive(game_state.transporters[0]) + var transporter: Dictionary = game_state.transporters[0] + transporter.state = GameState.TRANSPORTER_TO_EXTRACTOR + transporter.target_id = 0 + transporter.extractor_index = 0 + transporter.carrying = 0 + transporter.resource_type = "gas" + transporter.progress = 1.0 + transporter.from = Vector2(100, 0) + transporter.to = Vector2(400, 0) + game_state._arrive(transporter) if game_state.transporters[0].state != GameState.TRANSPORTER_TO_MOTHERSHIP_FROM_EXTRACTOR: return _fail("transporter did not collect gas from the extractor") - game_state._arrive(game_state.transporters[0]) + game_state._arrive(transporter) if game_state.gas != game_state.transporter_load_limit() or game_state.rocks != 0: return _fail("gas delivery banked gas=%d ore=%d" % [game_state.gas, game_state.rocks]) var mothership_id: int = int(game_state.buildings[0].id) diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index 3bc5ff6..52542b2 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -35,7 +35,8 @@ func _process(delta: float) -> bool: a.queue_free() fleet.spawn_ships(1, Vector2.ZERO) fleet.add_ship(GameState.VESSEL_SHIELD) - game_state.stored_vessels = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD] + game_state.stored_vessels = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, + GameState.VESSEL_TRANSPORTER] controller._on_hangar_pressed() elapsed = 0.0 stage = 1 @@ -48,6 +49,10 @@ func _process(delta: float) -> bool: if "Interceptor" not in available_label.text or "1 available" not in available_label.text: _fail("available interceptor card says '%s'" % available_label.text) return true + if controller._hangar_types().has(GameState.VESSEL_TRANSPORTER) \ + or fleet.add_ship(GameState.VESSEL_TRANSPORTER): + _fail("a logistics transporter was exposed as a fleet vessel") + return true controller._on_hangar_crew_type(0) if fleet.ships.size() != 3 or game_state.stored_vessels.has(GameState.VESSEL_INTERCEPTOR): _fail("tapping an available interceptor did not crew it") diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 4c795d9..0bc68ae 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -1,144 +1,56 @@ extends SceneTree -# Headless gameplay test: run with -# godot --headless -s res://tests/test_hub_capacity.gd -# Forgejo #20 (part 7 of #12): a hub stations a limited number of -# transporters. More hubs, or research, raise it. -# -# Verifies: -# 1. capacity is per hub, so a second hub raises the total -# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket -# is explicit that the player has to be told -# 3. a queued-but-unbuilt transporter still holds its berth, or ten could -# be ordered against two and only fail on arrival -# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not -# just ones placed afterwards -# 5. the extra berth is immediately usable - -var game_state: Node -var scene: Node -var field: Node -var crafter_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 - -const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0) -const HUB_A: Vector2 = Vector2(-500.0, 0.0) -const HUB_B: Vector2 = Vector2(-500.0, 600.0) +# 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. func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.transporters.clear() - game_state.rocks = 900 - game_state.fleet_cargo = 0 - game_state.stored_vessels.clear() - game_state.build_time_scale = 0.02 - game_state.unlocked = ["shipyards", "logistics", "freight"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - field = scene.get_node("BuildingField") + var state: Node = root.get_node("GameState") + state.buildings.clear() + state.extractors.clear() + state.transporters.clear() + state.reset_progression() + state.rocks = 900 + state.unlocked = ["shipyards", "logistics", "freight"] + + 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) + + 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()) + + 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") + + 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") + print("TEST_HUB_CAPACITY: PASS") + quit(0) -func _fill_berths(n: int) -> void: - # Queue and immediately fund n transporters. - for i in range(n): - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) +func _at_hub(state: Node, hub_id: int) -> int: + var count: int = 0 + for transporter in state.transporters: + if int(transporter.hub_id) == hub_id: + count += 1 + return count -func _process(delta: float) -> bool: - elapsed += delta - match stage: - 0: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - field.place(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_A) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.3: - return false - var per_hub: int = game_state.hub_capacity() - if game_state.transport_capacity() != per_hub: - print("TEST_HUB_CAPACITY: FAIL (one hub gives %d berths, per-hub capacity is %d)" % [ - game_state.transport_capacity(), per_hub - ]) - quit(1) - return true - print("1 hub -> %d berths (%d per hub)" % [game_state.transport_capacity(), per_hub]) - - # --- 2 + 3. fill it, then try to exceed it ------------------ - _fill_berths(per_hub) - if game_state.transporters_claimed() != per_hub: - print("TEST_HUB_CAPACITY: FAIL (claimed %d berths after ordering %d)" % [ - game_state.transporters_claimed(), per_hub - ]) - quit(1) - return true - # Still only QUEUED at this point - the berth must already count. - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_HUB_CAPACITY: FAIL (queued a transporter past the %d-berth cap)" % per_hub) - quit(1) - return true - print("cap enforced at %d, including orders not yet built" % per_hub) - - # --- 1. a second hub raises the total ----------------------- - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_B) - elapsed = 0.0 - stage = 2 - return false - 2: - if elapsed < 0.3: - return false - if game_state.transport_capacity() != game_state.hub_capacity() * 2: - print("TEST_HUB_CAPACITY: FAIL (2 hubs give %d berths, expected %d)" % [ - game_state.transport_capacity(), game_state.hub_capacity() * 2 - ]) - quit(1) - return true - print("2 hubs -> %d berths" % game_state.transport_capacity()) - if not game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (second hub did not free a berth)") - quit(1) - return true - - # Fill the second hub too, so capacity is exhausted again. - _fill_berths(game_state.transport_capacity() - game_state.transporters_claimed()) - if game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (still room after filling every berth)") - quit(1) - return true - - # --- 4. research raises it for hubs ALREADY built ----------- - var before: int = game_state.transport_capacity() - game_state.unlocked.append("freight_yards") - var after: int = game_state.transport_capacity() - print("researched Freight Yards: %d -> %d berths across %d existing hubs" % [ - before, after, game_state.hub_count() - ]) - if after != before + game_state.hub_count(): - print("TEST_HUB_CAPACITY: FAIL (research gave %d berths, expected +1 per existing hub)" % (after - before)) - quit(1) - return true - - # --- 5. and the new berth is usable straight away ----------- - if not game_state.can_station_transporter(): - print("TEST_HUB_CAPACITY: FAIL (research raised the number but left no usable berth)") - quit(1) - return true - if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_HUB_CAPACITY: FAIL (could not order into the berth research just added)") - quit(1) - return true - print("ordered into the newly researched berth") - print("TEST_HUB_CAPACITY: PASS") - quit(0) - return true - return false +func _fail(message: String) -> void: + print("TEST_HUB_CAPACITY: FAIL (%s)" % message) + quit(1) diff --git a/tests/test_lab_transporter_delivery.gd b/tests/test_lab_transporter_delivery.gd index 0096fe7..dff33b2 100644 --- a/tests/test_lab_transporter_delivery.gd +++ b/tests/test_lab_transporter_delivery.gd @@ -14,11 +14,8 @@ func _initialize() -> void: state.rocks = 100 state.build_time_scale = 0.02 state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO) - var hub_id: int = state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0)) + state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0)) lab_id = state.add_building(state.BUILDING_SHIP_LAB, Vector2(100, 0)) - state.transporters.append({"hub_id": hub_id, "state": state.TRANSPORTER_IDLE, - "target_id": 0, "extractor_index": -1, "carrying": 0, "progress": 1.0, - "from": Vector2(50, 0), "to": Vector2(50, 0)}) if not state.queue_lab_research(lab_id, "int_damage"): return _fail("could not queue lab research") if state.outstanding_cost(lab_id) != state.upgrade_cost("int_damage"): diff --git a/tests/test_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index f972a8b..84da9cb 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -28,7 +28,7 @@ var returned_vessel_types: PackedStringArray = PackedStringArray() var game_state: Node var expected_vessel_types: PackedStringArray = PackedStringArray([ GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_TRANSPORTER, GameState.VESSEL_SHIELD, + GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, GameState.VESSEL_INTERCEPTOR, ]) diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index ca9dcbb..3fc9c2d 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -1,217 +1,89 @@ extends SceneTree -# Headless gameplay test: run with -# godot --headless -s res://tests/test_transporter_supply.gd -# Forgejo #19 (part 6 of #12): transporters stationed at a hub fetch ore from -# the mothership and deliver it to whichever building is waiting on it, with -# no player input at all. -# -# Verifies: -# 1. a transporter is built at the CRAFTER and takes itself to the nearest -# hub, rather than appearing at the hub out of nowhere -# 2. a queued order the crafter cannot pay for is fulfilled end to end -# automatically - the whole point of the ticket -# 3. ore is conserved across the entire route (bank + hold + every -# building's stock), at every step, not just at the end -# 4. ore leaves the bank when it is LOADED, not when the order is placed - -# the vessel is carrying something real -# 5. transporters are not ordered when there is no hub to station them at -# 6. no unbounded node accumulation: the fleet of transporters is exactly -# what was built, however many runs they make +# Hub-owned transporters supply an unfunded combat-vessel order without ever +# being crafted, stored at the mothership, or added to the player fleet. -var game_state: Node -var scene: Node -var fleet: Node -var field: Node -var crafter_id: int = 0 -var hub_id: int = 0 -var stage: int = 0 -var elapsed: float = 0.0 -var total_at_start: int = 0 -var bank_when_ordered: int = 0 -var conservation_broken: bool = false -# Ore legitimately consumed by completed production. Conservation means -# "nothing vanishes in TRANSIT", not "the total never falls" - building a -# vessel spends ore, and that is the one way it is allowed to leave. +var state: Node +var crafter_id: int +var initial_transporters: int +var total_at_start: int var consumed: int = 0 - -const MOTHERSHIP_AT: Vector2 = Vector2(0.0, 0.0) -const HUB_AT: Vector2 = Vector2(600.0, 200.0) -const CRAFTER_AT: Vector2 = Vector2(900.0, -300.0) - - -func _total() -> int: - var sum: int = game_state.rocks + game_state.fleet_cargo - for b in game_state.buildings: - if b.type != GameState.BUILDING_MOTHERSHIP: - sum += game_state.building_resource(int(b.id), GameState.RESOURCE_ORE) - for t in game_state.transporters: - sum += int(t.get("carrying", 0)) - # Ore inside a build that has STARTED but not finished (#23). It has left - # the building's stock and has not yet been consumed by a completed - # vessel, so without counting it the total dips for the length of every - # build - which is a gap in this sum, not a leak in the game. - for b in game_state.buildings: - for o in b.get("orders", []): - if bool(o.started): - sum += game_state.product_cost(String(o.product)) - return sum +var elapsed: float = 0.0 +var conservation_broken: bool = false func _initialize() -> void: - game_state = root.get_node("GameState") - game_state.buildings.clear() - game_state.extractors.clear() - game_state.transporters.clear() - game_state.rocks = 200 - game_state.fleet_cargo = 0 - game_state.stored_vessels.clear() - game_state.build_time_scale = 0.02 - game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] - scene = load("res://Main.tscn").instantiate() - root.add_child(scene) - fleet = scene.get_node("Fleet") - field = scene.get_node("BuildingField") - game_state.order_completed.connect(_on_order_completed) + 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 = 200 + 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") -func _on_order_completed(_building_id: int, product: String) -> void: - consumed += game_state.product_cost(product) + 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() func _process(delta: float) -> bool: elapsed += delta - - # --- 3. conservation, checked EVERY tick ------------------------------ - # Ore in flight is the easy thing to lose: a leg that loads without - # debiting, or delivers without crediting, would show up here and nowhere - # else. Checked continuously rather than at the end, so it cannot be - # masked by a later correction. - if stage >= 2 and _total() + consumed != total_at_start: + 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: - scene.get_node("AsteroidField").set_process(false) - scene.get_node("EnemySpawner").set_process(false) - for a in get_nodes_in_group("asteroids"): - if is_instance_valid(a): - a.queue_free() - field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) - field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) - crafter_id = int(game_state.buildings[-1].id) +func _on_order_completed(_building_id: int, product: String) -> void: + consumed += state.product_cost(product) - # --- 5. no hub yet: a transporter cannot be ordered ---------- - if game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_TRANSPORTER_SUPPLY: FAIL (ordered a transporter with no hub to station it at)") - quit(1) - return true - print("no hub -> transporter order refused") - field.place(GameState.BUILDING_TRANSPORT_HUB, HUB_AT) - hub_id = int(game_state.buildings[-1].id) - elapsed = 0.0 - stage = 1 - return false - 1: - if elapsed < 0.2: - return false - # --- 1. build a transporter at the crafter ------------------ - if not game_state.queue_order(crafter_id, GameState.PRODUCT_TRANSPORTER): - print("TEST_TRANSPORTER_SUPPLY: FAIL (could not order a transporter with a hub built)") - quit(1) - return true - game_state.deposit_to_building(crafter_id, GameState.TRANSPORTER_COST) - elapsed = 0.0 - stage = 2 - total_at_start = _total() - return false - 2: - if elapsed < game_state.build_time(GameState.PRODUCT_TRANSPORTER) * 3.0: - return false - if game_state.transporters.size() != 1: - print("TEST_TRANSPORTER_SUPPLY: FAIL (built %d transporters from one order)" % game_state.transporters.size()) - quit(1) - return true - var t: Dictionary = game_state.transporters[0] - if int(t.hub_id) != hub_id: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter did not take the nearest hub)") - quit(1) - return true - # It must fly FROM the crafter TO the hub, not appear at the hub. - # Checked on the leg's endpoints rather than its current position, - # which is already partway there by now - and being partway is - # the correct behaviour. - if (t.from as Vector2).distance_to(CRAFTER_AT) > 1.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg starts at %s, not at the crafter that built it)" % t.from) - quit(1) - return true - if (t.to as Vector2).distance_to(HUB_AT) > 1.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (transporter's first leg does not end at the hub: %s)" % t.to) - quit(1) - return true - print("transporter built at the crafter, heading for hub %d" % hub_id) - elapsed = 0.0 - stage = 3 - return false - 3: - # Give it time to reach the hub and go idle. - if elapsed < 4.0: - return false - if game_state.transporters[0].state != GameState.TRANSPORTER_IDLE: - print("TEST_TRANSPORTER_SUPPLY: FAIL (never settled at its hub: state=%s)" % game_state.transporters[0].state) - quit(1) - return true - print("stationed at the hub, idle") +func _total() -> int: + var sum: int = state.rocks + state.fleet_cargo + for building in state.buildings: + if building.type != state.BUILDING_MOTHERSHIP: + sum += state.building_resource(int(building.id), state.RESOURCE_ORE) + for order in building.get("orders", []): + if bool(order.started): + sum += state.product_cost(String(order.product)) + for transporter in state.transporters: + sum += int(transporter.get("carrying", 0)) + return sum - # --- 2 + 4. an order it cannot pay for ---------------------- - game_state.queue_order(crafter_id, GameState.PRODUCT_SHIP) - bank_when_ordered = game_state.rocks - elapsed = 0.0 - stage = 4 - return false - 4: - # One tick: placing the order must not touch the bank. - if elapsed < 0.1: - return false - if game_state.rocks != bank_when_ordered: - print("TEST_TRANSPORTER_SUPPLY: FAIL (bank moved on placing the order, before anything was loaded)") - quit(1) - return true - print("order placed, bank untouched at %d" % game_state.rocks) - elapsed = 0.0 - stage = 5 - return false - 5: - # --- 2. the whole run, with no player input ----------------- - if game_state.pending_orders(crafter_id) > 0: - if elapsed > 25.0: - print("TEST_TRANSPORTER_SUPPLY: FAIL (order never fulfilled: transporter state=%s carrying=%d crafter stock=%d)" % [ - game_state.transporters[0].state, - int(game_state.transporters[0].carrying), - game_state.building_stock(crafter_id), - ]) - quit(1) - return true - return false - if conservation_broken: - print("TEST_TRANSPORTER_SUPPLY: FAIL (ore created or destroyed somewhere along the route)") - quit(1) - return true - print("order fulfilled automatically in %.1fs, no player input" % elapsed) - # --- 6. no vessel accumulation ------------------------------ - if game_state.transporters.size() != 1: - print("TEST_TRANSPORTER_SUPPLY: FAIL (%d transporters exist after the run, one was built)" % game_state.transporters.size()) - quit(1) - return true - print("still exactly 1 transporter after a full round trip") - print("bank=%d crafter stock=%d total=%d + %d consumed = %d (started %d)" % [ - game_state.rocks, game_state.building_stock(crafter_id), _total(), - consumed, _total() + consumed, total_at_start - ]) - print("TEST_TRANSPORTER_SUPPLY: PASS") - quit(0) - return true - return false +func _fail(message: String) -> bool: + print("TEST_TRANSPORTER_SUPPLY: FAIL (%s)" % message) + quit(1) + return true diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index f70f99c..20a2d6b 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -22,6 +22,10 @@ func _process(delta: float) -> bool: if elapsed < 0.1: return false var definitions: Array = VESSEL_DEFINITIONS.all() + var fleet_definitions: Array = VESSEL_DEFINITIONS.fleet() + if fleet_definitions.size() != 2 \ + or fleet_definitions.any(func(definition): return definition.has_capability("transport")): + return _fail("logistics transporters leaked into the combat-fleet catalog") if ships.size() != definitions.size(): return _fail("catalog and composed fleet differ in size") for i in range(definitions.size()):