diff --git a/BuildController.gd b/BuildController.gd index ce9b43b..18e81cd 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -6,8 +6,10 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") # Space-side building UI. Owns: # - the build menu (place a mothership, ship crafter or command centre) # - the shared ACTION button, which adapts to whatever the fleet is -# standing on (craft ships, configure formation, or land). One action -# fires directly; multiple actions unfold horizontally. +# standing on (craft ships at a crafter, configure formation at a +# command centre). The enter-atmosphere prompt uses the same slot, and +# PlanetAtmosphereController suppresses itself while a building prompt +# is showing so the two can never fight over it. # - rebuilding a wiped-out fleet at the mothership # # Buildings themselves live in GameState (they must outlive the trip to a @@ -21,8 +23,6 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") @onready var build_button: IconButton = get_node("../UI/BuildButton") @onready var action_button: IconButton = get_node("../UI/BuildActionButton") @onready var building_action_button: IconButton = get_node("../UI/BuildingActionButton") -@onready var landing_button: IconButton = get_node("../UI/EnterAtmosphereButton") -@onready var landing_controller: Node2D = get_node("../LandingController") @onready var build_menu: Control = get_node("../UI/BuildMenu") @onready var build_close_button: IconButton = get_node("../UI/BuildMenu/CloseButton") @@ -62,18 +62,22 @@ 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 = [] @@ -93,23 +97,24 @@ 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_interceptor_button, craft_shield_button, craft_transporter_button, ] @onready var craft_vessel_labels: Array = [ - craft_interceptor_label, craft_shield_label, + craft_interceptor_label, craft_shield_label, craft_transporter_label, ] # --- the transfer panel (#24) --------------------------------------------- # Ore used to be moved from inside whichever panel happened to be open: "load # ore" lived in the research screen, "store ore here" in the ship crafter. -# Both are now one top-level shortcut beside Build at resource-bearing -# locations and work in both directions, so moving cargo is never buried -# under an unrelated action foldout. +# Both are now one top-level button that works at every building and in both +# directions, so moving ore is never buried under an unrelated menu. @onready var transfer_button: IconButton = get_node("../UI/TransferButton") @onready var transfer_menu: Control = get_node("../UI/TransferMenu") @onready var transfer_status: Label = get_node("../UI/TransferMenu/StatusLabel") @@ -160,7 +165,6 @@ var current_building: Node2D = null var pending_building: Node2D = null var current_extractor_index: int = -1 var pending_extractor_index: int = -1 -var current_landing_planet: Node2D = null var pending_time: float = 0.0 var context_actions_expanded: bool = false @@ -181,8 +185,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.fleet().size())): - var definition: Resource = VESSEL_DEFINITIONS.fleet()[i] + for i in range(mini(craft_vessel_buttons.size(), VESSEL_DEFINITIONS.all().size())): + var definition: Resource = VESSEL_DEFINITIONS.all()[i] craft_vessel_buttons[i].pressed.connect(_on_craft_vessel.bind(definition.definition_id)) craft_close_button.pressed.connect(func(): craft_menu.visible = false) @@ -214,7 +218,6 @@ func _ready() -> void: transfer_menu.visible = false action_button.visible = false building_action_button.visible = false - landing_button.visible = false transfer_button.visible = false hangar_button.visible = false hangar_menu.visible = false @@ -236,10 +239,10 @@ func _configure_build_menu_entries() -> void: build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) -# True while a modal panel owns the shared context controls. A plain building -# prompt is not modal: landing can coexist with its actions in the same foldout. +# True while a building prompt or panel owns the shared action slot, so the +# enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: - return build_menu.visible or craft_menu.visible \ + return current_building != null or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible or transfer_menu.visible \ or hangar_menu.visible @@ -306,10 +309,8 @@ func _process(delta: float) -> void: pending_building = null current_extractor_index = -1 pending_extractor_index = -1 - current_landing_planet = null action_button.visible = false building_action_button.visible = false - landing_button.visible = false transfer_button.visible = false hangar_button.visible = false context_actions_expanded = false @@ -331,27 +332,28 @@ func _process(delta: float) -> void: else: pending_time += delta - var detected_landing_planet: Node2D = landing_controller.current_planet if pending_time < prompt_debounce_time or (pending_building == current_building \ - and pending_extractor_index == current_extractor_index \ - and detected_landing_planet == current_landing_planet): + and pending_extractor_index == current_extractor_index): return current_building = pending_building current_extractor_index = pending_extractor_index - current_landing_planet = detected_landing_planet context_actions_expanded = false hangar_button.visible = false + # Every building can hold ore, so the transfer button rides alongside the + # action button rather than being one of the things the action button + # turns into - the two are independent choices at the same structure. + transfer_button.visible = false building_action_button.visible = false - landing_button.visible = false - transfer_button.visible = _has_transfer_context() - var actions: Array = _context_action_buttons() - action_button.visible = not actions.is_empty() + action_button.visible = current_building != null or current_extractor_index >= 0 if current_building != null: + var actions: Array = _context_action_buttons() building_action_button.icon = int(current_building.definition.action_icon) building_action_button.queue_redraw() - if not actions.is_empty(): - action_button.icon = int(actions[0].icon) if actions.size() == 1 else IconButton.Icon.ACTION_MENU + action_button.icon = building_action_button.icon if actions.size() == 1 else IconButton.Icon.ACTION_MENU + action_button.queue_redraw() + elif current_extractor_index >= 0: + action_button.icon = IconButton.Icon.TRANSFER action_button.queue_redraw() _layout_context_buttons() @@ -379,41 +381,33 @@ func _layout_context_buttons() -> void: func _context_action_buttons() -> Array: - var buttons: Array = [] - if current_building != null: - for action in current_building.available_actions(): - var button: IconButton = _button_for_building_action(action) - if button != null and button not in buttons: - buttons.append(button) - if current_landing_planet != null: - buttons.append(landing_button) - return buttons - - -func _has_transfer_context() -> bool: if current_extractor_index >= 0: - return true - return current_building != null and current_building.available_actions().has("transfer") + return [transfer_button] + if current_building == null: + return [] + var buttons: Array = [] + for action in current_building.available_actions(): + var button: IconButton = _button_for_building_action(action) + if button != null and button not in buttons: + buttons.append(button) + return buttons func _button_for_building_action(action: String) -> IconButton: if action == "transfer": - return null + return transfer_button if action == "hangar": return hangar_button return building_action_button func _on_context_action_pressed() -> void: - if current_building == null and current_extractor_index < 0 \ - and current_landing_planet == null: + if current_building == null and current_extractor_index < 0: return var actions: Array = _context_action_buttons() if actions.size() == 1: - if actions[0] == landing_button: - landing_controller._on_enter_pressed() - elif actions[0] == hangar_button: - _on_hangar_pressed() + if actions[0] == transfer_button: + _on_transfer_pressed() else: _on_action_pressed() return @@ -728,7 +722,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.fleet() + var definitions: Array = VESSEL_DEFINITIONS.all() for i in range(mini(definitions.size(), craft_vessel_buttons.size())): var definition: Resource = definitions[i] var button: IconButton = craft_vessel_buttons[i] @@ -736,6 +730,9 @@ 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 != "": @@ -759,6 +756,11 @@ 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) @@ -773,6 +775,10 @@ 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: @@ -1057,7 +1063,7 @@ func _on_hangar_store_type(index: int) -> void: func _hangar_types() -> Array: var types: Array = [] - for definition in VESSEL_DEFINITIONS.fleet(): + for definition in VESSEL_DEFINITIONS.all(): types.append(definition.definition_id) return types diff --git a/Fleet.gd b/Fleet.gd index af83cdd..b108dd1 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -4,9 +4,10 @@ extends Node2D # centre) rather than being fixed here, so a layout the player arranged # survives scene swaps and fleet rebuilds. Ships are always # spawned programmatically (spawn_ships), using however many of these -# offsets are needed. The optional vessel-type list preserves the exact -# mixed fleet through scene swaps; a fresh start still defaults each slot -# to an interceptor. +# offsets are needed - both for a fresh start (5 ships) and for restoring +# a possibly-smaller surviving fleet after returning from a planet's +# surface, so both cases share one code path instead of two that could +# drift out of sync. # The fleet's cruise speed. This is the speed of the FLEET as a whole: the # anchor below advances at exactly this rate, and individual ships are # allowed to fly faster than it (Ship.max_speed) so they can close on their @@ -43,29 +44,17 @@ func _on_vessel_upgraded(_id: String) -> void: ship.apply_vessel_stats() -func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, - vessel_types: PackedStringArray = PackedStringArray()) -> void: +func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: for ship in ships: if is_instance_valid(ship): ship.queue_free() ships.clear() formation_offsets.clear() - 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()) + var n: int = clamp(count, 1, GameState.max_fleet_size()) for i in range(n): var offset: Vector2 = GameState.formation[i] var ship: Area2D = ship_scene.instantiate() - ship.vessel_type = spawn_types[i] ship.position = center + offset ship.glow_color = ship_glow_color ship.core_color = ship_core_color @@ -78,19 +67,10 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, fleet_target = center -func vessel_types() -> PackedStringArray: - var result: PackedStringArray = PackedStringArray() - for ship in ships: - if is_instance_valid(ship): - result.append(String(ship.vessel_type)) - return result - - 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 not GameState.vessel_is_fleet_eligible(vessel_type) \ - or ships.size() >= GameState.max_fleet_size(): + if 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 cfc540a..03c35e4 100644 --- a/GameState.gd +++ b/GameState.gd @@ -14,7 +14,6 @@ const VESSEL_DEFINITIONS = preload("res://VesselDefinitions.gd") var has_return_position: bool = false var surviving_ship_count: int = 5 -var surviving_vessel_types: PackedStringArray = PackedStringArray() var return_world_position: Vector2 = Vector2.ZERO var return_camera_zoom: float = 1.0 @@ -230,7 +229,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. Each hub includes its own automatic transporters.", + "desc": "Build transport hubs, which supply your other buildings with ore.", }, { "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", @@ -313,8 +312,6 @@ 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 @@ -383,9 +380,6 @@ 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 @@ -405,9 +399,9 @@ func building_has_capability(building: Dictionary, capability: String) -> bool: # --- vessel types and their upgrades (#5) --------------------------------- -# 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. +# 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. const VESSEL_INTERCEPTOR: String = "interceptor" const VESSEL_SHIELD: String = "shield" const VESSEL_TRANSPORTER: String = "transporter" @@ -415,6 +409,7 @@ 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 @@ -440,6 +435,9 @@ 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) @@ -478,11 +476,6 @@ 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: @@ -601,6 +594,7 @@ func transporter_speed() -> float: const PRODUCT_SHIP: String = "interceptor" +const PRODUCT_TRANSPORTER: String = "transporter" func product_cost(product: String) -> int: @@ -696,8 +690,14 @@ 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): + 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). return false (b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false}) return true @@ -724,6 +724,12 @@ 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 @@ -732,15 +738,20 @@ 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. - var mothership: Variant = get_mothership_position() - if mothership == null: - stored_vessels.append(product) + if definition.has_capability("transport"): + # Transporters are unchanged: they take themselves to the nearest + # hub and wait for work (#19). + _station_transporter(b.position) else: - vessel_deliveries.append({"type": product, "from": b.position, - "to": mothership, "progress": 0.0}) + # 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}) order_completed.emit(int(b.id), product) @@ -826,21 +837,13 @@ 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 @@ -1013,8 +1016,11 @@ func _advance_vessel_deliveries(delta: float) -> void: # # Entries are {"hub_id", "state", "target_id", "carrying", "progress", # "from", "to"}. -# How many transporters each hub owns. Raised for EVERY hub - built already -# or not - by research, and provisioned automatically rather than crafted. +const TRANSPORTER_SPEED: float = 340.0 + +# How many transporters one hub can station. Raised for EVERY hub - built +# already or not - by research, which is the same shape as max_fleet_size() +# rather than a second mechanism doing the same job. const BASE_HUB_CAPACITY: int = 2 const TRANSPORTER_IDLE: String = "idle" @@ -1043,30 +1049,61 @@ func hub_count() -> int: return n -func _add_hub_transporter(hub: Dictionary) -> void: +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 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, + "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, }) -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)) @@ -1153,7 +1190,6 @@ 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 1f0552a..0a0d45b 100644 --- a/InterceptorVesselDefinition.tres +++ b/InterceptorVesselDefinition.tres @@ -14,7 +14,6 @@ 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 6700644..d6dd326 100644 --- a/Main.tscn +++ b/Main.tscn @@ -148,19 +148,20 @@ digit_height = 38.0 [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false offset_left = 24 -offset_top = 480 +offset_top = 156 offset_right = 696 -offset_bottom = 532 +offset_bottom = 282 text = "" horizontal_alignment = 1 -theme_override_font_sizes/font_size = 32 [node name="EnterAtmosphereButton" type="Control" parent="UI"] visible = false -offset_left = 188 -offset_top = 324 -offset_right = 336 -offset_bottom = 472 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -720 +offset_bottom = -572 script = ExtResource("10") icon = 3 @@ -202,10 +203,10 @@ glow_color = Color(0.45, 0.9, 0.55, 1) [node name="TransferButton" type="Control" parent="UI"] visible = false -offset_left = 188 -offset_top = 160 -offset_right = 336 -offset_bottom = 308 +offset_left = 24 +offset_right = 172 +offset_top = 488 +offset_bottom = 636 script = ExtResource("10") icon = 14 glow_color = Color(1, 0.78, 0.25, 1) @@ -788,8 +789,8 @@ horizontal_alignment = 1 [node name="AvailableInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -180 -offset_right = -60 +offset_left = -250 +offset_right = -130 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -799,8 +800,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = 10 +offset_left = -300 +offset_right = -80 offset_top = 380 offset_bottom = 460 theme_override_font_sizes/font_size = 22 @@ -809,8 +810,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 = 180 +offset_left = -60 +offset_right = 60 offset_top = 255 offset_bottom = 375 script = ExtResource("10") @@ -821,8 +822,30 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -10 +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_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 @@ -840,8 +863,8 @@ horizontal_alignment = 1 [node name="FleetInterceptor" type="Control" parent="UI/HangarMenu"] anchor_left = 0.5 anchor_right = 0.5 -offset_left = -180 -offset_right = -60 +offset_left = -250 +offset_right = -130 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -851,8 +874,8 @@ icon = 2 mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -250 -offset_right = 10 +offset_left = -300 +offset_right = -80 offset_top = 690 offset_bottom = 770 theme_override_font_sizes/font_size = 22 @@ -861,8 +884,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 = 180 +offset_left = -60 +offset_right = 60 offset_top = 565 offset_bottom = 685 script = ExtResource("10") @@ -873,8 +896,30 @@ glow_color = Color(0.45, 0.8, 1, 1) mouse_filter = 2 anchor_left = 0.5 anchor_right = 0.5 -offset_left = -10 +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_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 @@ -1246,10 +1291,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -220 -offset_right = -72 -offset_top = -100 -offset_bottom = 48 +offset_left = -234 +offset_right = -86 +offset_top = 60 +offset_bottom = 208 script = ExtResource("10") icon = 2 @@ -1259,23 +1304,51 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = -290 -offset_right = -2 -offset_top = 56 -offset_bottom = 136 +offset_left = -300 +offset_right = -20 +offset_top = 216 +offset_bottom = 296 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 = 72 -offset_right = 220 -offset_top = -100 -offset_bottom = 48 +offset_left = -74 +offset_right = 74 +offset_top = -200 +offset_bottom = -52 script = ExtResource("10") icon = 2 glow_color = Color(0.45, 0.8, 1, 1) @@ -1286,10 +1359,10 @@ anchor_left = 0.5 anchor_right = 0.5 anchor_top = 0.5 anchor_bottom = 0.5 -offset_left = 2 -offset_right = 290 -offset_top = 56 -offset_bottom = 136 +offset_left = -160 +offset_right = 160 +offset_top = -44 +offset_bottom = 16 theme_override_font_sizes/font_size = 32 autowrap_mode = 2 text = "Shield Vessel" diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index 36ac960..9363981 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -34,8 +34,7 @@ func _ready() -> void: enter_button.pressed.connect(_on_enter_pressed) if GameState.has_return_position: - fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position, - GameState.surviving_vessel_types) + fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position) camera.position = GameState.return_world_position camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false @@ -58,12 +57,16 @@ func _process(delta: float) -> void: return if world_map.visible or build_controller.is_showing_building_prompt(): - # Context actions belong to BuildController. This controller owns the - # debounced planet selection and label, and suppresses both while a modal - # screen is open. + # EnterAtmosphereButton shares one "action" slot with the map's + # RecenterButton and with BuildController's building prompt - it doesn't make sense to enter + # a planet's atmosphere while looking at the star map anyway, so + # suppress the prompt entirely rather than let it sit hidden + # behind the map's backdrop only to pop back in the instant the + # map closes. current_planet = null pending_planet = null planet_name_label.visible = false + enter_button.visible = false return # Debounced: only commit a visible/hidden change once the raw @@ -87,8 +90,10 @@ func _process(delta: float) -> void: if current_planet != null: planet_name_label.text = current_planet.planet_name planet_name_label.visible = true + enter_button.visible = true else: planet_name_label.visible = false + enter_button.visible = false func _on_enter_pressed() -> void: @@ -100,7 +105,6 @@ func _on_enter_pressed() -> void: GameState.has_return_position = true GameState.surviving_ship_count = fleet.ships.size() - GameState.surviving_vessel_types = fleet.vessel_types() # The planet's own position, not fleet.get_centroid() - the fleet only # needs to be somewhere within the planet's landing radius (not # centered exactly on it) for the prompt to show, so the centroid can diff --git a/PlanetSurfaceController.gd b/PlanetSurfaceController.gd index 294f5f1..7c683d4 100644 --- a/PlanetSurfaceController.gd +++ b/PlanetSurfaceController.gd @@ -3,9 +3,9 @@ extends Node2D const SURFACE_PROFILES = preload("res://PlanetSurfaceProfiles.gd") const PLANET_DEFINITIONS = preload("res://PlanetDefinitions.gd") -# Bootstraps the surviving fleet, including each vessel type, on this scene's -# own startup (from GameState, since this is a fresh scene tree - nothing here -# carries over automatically) and handles the always-available exit prompt that +# Bootstraps the surviving fleet on this scene's own startup (from +# GameState, since this is a fresh scene tree - nothing here carries +# over automatically) and handles the always-available exit prompt that # fades back out to Main.tscn, restoring the fleet at wherever it left # space from. @@ -25,8 +25,7 @@ var transitioning: bool = false func _ready() -> void: var planet_definition: Resource = PLANET_DEFINITIONS.get_definition(GameState.planet_definition_id) _compose_surface(SURFACE_PROFILES.get_profile(planet_definition.surface_profile_id)) - fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO, - GameState.surviving_vessel_types) + fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO) camera.position = Vector2.ZERO camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) planet_name_label.text = GameState.planet_name @@ -58,7 +57,6 @@ func _on_exit_pressed() -> void: transitioning = true GameState.surviving_ship_count = fleet.ships.size() - GameState.surviving_vessel_types = fleet.vessel_types() var tween: Tween = create_tween() tween.tween_property(fade_rect, "color:a", 1.0, fade_duration) diff --git a/ShieldVesselDefinition.tres b/ShieldVesselDefinition.tres index 0ca7bcc..26db397 100644 --- a/ShieldVesselDefinition.tres +++ b/ShieldVesselDefinition.tres @@ -13,7 +13,6 @@ 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/TransportHubBuildingVisual.tscn b/TransportHubBuildingVisual.tscn index 29dbb10..1426761 100644 --- a/TransportHubBuildingVisual.tscn +++ b/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://TransportHubBuildingVisual.gd" id="1"] +[ext_resource type="Script" path="res://BuildingActionProvider.gd" id="2"] [node name="Visual" type="Node2D"] script = ExtResource("1") + +[node name="ActionProvider" type="Node" parent="."] +script = ExtResource("2") +actions = PackedStringArray("transfer") diff --git a/TransporterVesselDefinition.tres b/TransporterVesselDefinition.tres index b20fa98..9e82f6c 100644 --- a/TransporterVesselDefinition.tres +++ b/TransporterVesselDefinition.tres @@ -8,9 +8,9 @@ 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 +description = "Unarmed cargo vessel stationed at a transport hub to move resources." +build_cost = 10 +build_time = 8.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 f5e28c8..0f6637f 100644 --- a/VesselDefinition.gd +++ b/VesselDefinition.gd @@ -11,7 +11,6 @@ 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 38d4734..2b33845 100644 --- a/VesselDefinitions.gd +++ b/VesselDefinitions.gd @@ -17,14 +17,6 @@ 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/_context_actions_preview.gd b/tests/_context_actions_preview.gd deleted file mode 100644 index 1957336..0000000 --- a/tests/_context_actions_preview.gd +++ /dev/null @@ -1,37 +0,0 @@ -extends Node - -# Visual-only phone preview for the top-level context controls. It composes a -# mothership action set with a landing action so the horizontal foldout and -# the independent Transfer shortcut can be inspected in one screenshot. - - -func _ready() -> void: - GameState.buildings.clear() - GameState.extractors.clear() - var main: Node2D = 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("LandingController").set_process(false) - - var building: Node2D = load("res://Building.tscn").instantiate() - building.definition_id = GameState.BUILDING_MOTHERSHIP - building.position = Vector2(0, 420) - main.add_child(building) - - var controller: Node2D = main.get_node("BuildController") - controller.set_process(false) - controller.current_building = building - var planet: Node2D = Node2D.new() - main.add_child(planet) - controller.current_landing_planet = planet - controller.building_action_button.icon = int(building.definition.action_icon) - controller.transfer_button.visible = controller._has_transfer_context() - controller.action_button.visible = true - controller.action_button.icon = IconButton.Icon.ACTION_MENU - controller.context_actions_expanded = true - controller._layout_context_buttons() - - var label: Label = main.get_node("UI/PlanetNameLabel") - label.text = "PLANET ACTIONS" - label.visible = true diff --git a/tests/_context_actions_preview.gd.uid b/tests/_context_actions_preview.gd.uid deleted file mode 100644 index aed6b62..0000000 --- a/tests/_context_actions_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://r4kyx6d4s60i diff --git a/tests/_context_actions_preview.tscn b/tests/_context_actions_preview.tscn deleted file mode 100644 index a79a95b..0000000 --- a/tests/_context_actions_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[gd_scene load_steps=2 format=3] - -[ext_resource type="Script" path="res://tests/_context_actions_preview.gd" id="1"] - -[node name="ContextActionsPreview" type="Node"] -script = ExtResource("1") diff --git a/tests/_hangar_menu_preview.gd b/tests/_hangar_menu_preview.gd deleted file mode 100644 index 5801cdc..0000000 --- a/tests/_hangar_menu_preview.gd +++ /dev/null @@ -1,23 +0,0 @@ -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 deleted file mode 100644 index e6cd971..0000000 --- a/tests/_hangar_menu_preview.gd.uid +++ /dev/null @@ -1 +0,0 @@ -uid://grp4nm3xvngn diff --git a/tests/_hangar_menu_preview.tscn b/tests/_hangar_menu_preview.tscn deleted file mode 100644 index 36fed3c..0000000 --- a/tests/_hangar_menu_preview.tscn +++ /dev/null @@ -1,6 +0,0 @@ -[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 5322f3c..68d2b55 100644 --- a/tests/_transporter_preview.gd +++ b/tests/_transporter_preview.gd @@ -15,20 +15,17 @@ 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)) - # 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) + # 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), + }) var main: Node = load("res://Main.tscn").instantiate() add_child(main) diff --git a/tests/test_building_composition.gd b/tests/test_building_composition.gd index 616095d..7cadf5d 100644 --- a/tests/test_building_composition.gd +++ b/tests/test_building_composition.gd @@ -21,12 +21,8 @@ func _initialize() -> void: ]) return var provider: Node = building.visual.get_node_or_null("ActionProvider") - if definition.definition_id == GameState.BUILDING_TRANSPORT_HUB: - if provider != null or not building.available_actions().is_empty(): - _fail("transport hub composed a manual action") - return - elif provider == null or not provider.actions.has("transfer"): - _fail("%s has no composed transfer action" % definition.definition_id) + if provider == null or not provider.actions.has("transfer"): + _fail("%s has no composed action provider" % definition.definition_id) return building.free() diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd index ddd1882..e104c27 100644 --- a/tests/test_crafter_feedback.gd +++ b/tests/test_crafter_feedback.gd @@ -96,9 +96,10 @@ func _process(delta: float) -> bool: print("TEST_CRAFTER_FEEDBACK: FAIL (fleet parked on the crafter never registered)") quit(1) return true - # Transfer has its own shortcut beside Build, leaving Craft as the - # single contextual action that fires directly. + # A crafter now has both Craft and Transfer, so the shared action + # button unfolds those choices before the craft action is selected. _tap(controller.action_button) + _tap(controller.building_action_button) if not controller.craft_menu.visible: print("TEST_CRAFTER_FEEDBACK: FAIL (the action button did not open the crafter panel)") quit(1) diff --git a/tests/test_extractor_income.gd b/tests/test_extractor_income.gd index b89037e..894324b 100644 --- a/tests/test_extractor_income.gd +++ b/tests/test_extractor_income.gd @@ -39,10 +39,12 @@ 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) - 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 + 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}) base_load = game_state.transporter_load_limit() game_state.extractors[extractor_index].stock = base_load - 1 elapsed = 0.0 diff --git a/tests/test_extractor_transfer.gd b/tests/test_extractor_transfer.gd index 5f5c838..97151e4 100644 --- a/tests/test_extractor_transfer.gd +++ b/tests/test_extractor_transfer.gd @@ -48,13 +48,11 @@ func _process(delta: float) -> bool: 1: if elapsed < 1.2: return false - if controller.current_extractor_index != extractor_index or not controller.transfer_button.visible: - return _fail("extractor did not expose the transfer shortcut") - if not controller.action_button.visible \ - or controller.action_button.icon != controller.landing_button.icon \ - or controller.landing_button.visible: - return _fail("landing was not folded into the shared action control") - controller._on_transfer_pressed() + if controller.current_extractor_index != extractor_index or not controller.action_button.visible: + return _fail("extractor did not expose a contextual action") + if not scene.get_node("UI/EnterAtmosphereButton").visible: + return _fail("extractor action hid the separate landing action") + controller._on_context_action_pressed() if not controller.transfer_menu.visible or controller.transfer_list.extractor_index != extractor_index: return _fail("transfer panel did not bind to the extractor") var before: int = state.fleet_resource(resource_type) @@ -63,7 +61,7 @@ func _process(delta: float) -> bool: return _fail("fleet did not fetch five resources from extractor") if state.extractor_resource(extractor_index, resource_type) < 7: return _fail("extractor transfer removed too much stock") - print("fleet fetched %s while landing remained the contextual action" % resource_type) + print("fleet fetched %s from extractor while landing remained available" % resource_type) print("TEST_EXTRACTOR_TRANSFER: PASS") quit(0) return true diff --git a/tests/test_gas_system.gd b/tests/test_gas_system.gd index ef8234f..94edc1d 100644 --- a/tests/test_gas_system.gd +++ b/tests/test_gas_system.gd @@ -86,19 +86,13 @@ 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() - 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) + 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]) 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(transporter) + game_state._arrive(game_state.transporters[0]) 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 52542b2..4de3cac 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -35,8 +35,7 @@ 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, - GameState.VESSEL_TRANSPORTER] + game_state.stored_vessels = [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD] controller._on_hangar_pressed() elapsed = 0.0 stage = 1 @@ -49,10 +48,6 @@ 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") @@ -68,30 +63,24 @@ func _process(delta: float) -> bool: _fail("hangar allowed the last deployed ship to be stored") return true - # Context actions are compact until expanded. A mothership unfolds - # research and hangar; transfer stays as a persistent shortcut beside - # Build and is not duplicated in the foldout. + # Context actions are compact until expanded. A mothership has three + # choices: research, transfer cargo and hangar, which unfold in their + # own non-overlapping berths to the right only after expansion. var mothership: Node2D = load("res://Building.tscn").instantiate() mothership.set("building_type", GameState.BUILDING_MOTHERSHIP) mothership._ready() controller.current_building = mothership controller.context_actions_expanded = true - controller.transfer_button.visible = controller._has_transfer_context() controller._layout_context_buttons() - var buttons: Array = [controller.building_action_button, controller.hangar_button] + var buttons: Array = [controller.building_action_button, controller.transfer_button, controller.hangar_button] for i in range(buttons.size() - 1): if buttons[i].offset_right > buttons[i + 1].offset_left: _fail("context buttons %d and %d overlap" % [i, i + 1]) return true controller._collapse_context_actions() - if controller.building_action_button.visible or controller.hangar_button.visible: + if controller.building_action_button.visible or controller.transfer_button.visible or controller.hangar_button.visible: _fail("collapsing the context button left an action visible") return true - if not controller.transfer_button.visible \ - or controller.transfer_button.offset_top != controller.build_button.offset_top \ - or controller.transfer_button.offset_left <= controller.build_button.offset_left: - _fail("transfer shortcut is not beside Build") - return true mothership.free() print("hangar crews and stores typed vessels; contextual actions expand and collapse") print("TEST_HANGAR_MANAGEMENT: PASS") diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 0bc68ae..4c795d9 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -1,56 +1,144 @@ 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. +# Headless gameplay test: run with +# godot --headless -s res://tests/test_hub_capacity.gd +# Forgejo #20 (part 7 of #12): a hub stations a limited number of +# transporters. More hubs, or research, raise it. +# +# Verifies: +# 1. capacity is per hub, so a second hub raises the total +# 2. ordering past the cap is REFUSED, not silently swallowed - the ticket +# is explicit that the player has to be told +# 3. a queued-but-unbuilt transporter still holds its berth, or ten could +# be ordered against two and only fail on arrival +# 4. researching Freight Yards raises capacity for hubs ALREADY BUILT, not +# just ones placed afterwards +# 5. the extra berth is immediately usable + +var game_state: Node +var scene: Node +var field: Node +var crafter_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 + +const CRAFTER_AT: Vector2 = Vector2(400.0, 0.0) +const HUB_A: Vector2 = Vector2(-500.0, 0.0) +const HUB_B: Vector2 = Vector2(-500.0, 600.0) func _initialize() -> void: - 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) + 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") -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 _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 _fail(message: String) -> void: - print("TEST_HUB_CAPACITY: FAIL (%s)" % message) - quit(1) +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 diff --git a/tests/test_lab_transporter_delivery.gd b/tests/test_lab_transporter_delivery.gd index dff33b2..0096fe7 100644 --- a/tests/test_lab_transporter_delivery.gd +++ b/tests/test_lab_transporter_delivery.gd @@ -14,8 +14,11 @@ func _initialize() -> void: state.rocks = 100 state.build_time_scale = 0.02 state.add_building(state.BUILDING_MOTHERSHIP, Vector2.ZERO) - state.add_building(state.BUILDING_TRANSPORT_HUB, Vector2(50, 0)) + var hub_id: int = 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 84da9cb..ed3b10c 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -24,13 +24,7 @@ var target_chunk: Vector2i var position_before_leaving: Vector2 var cached_fade_duration: float = 0.4 var returned_ship_count: int = -1 -var returned_vessel_types: PackedStringArray = PackedStringArray() var game_state: Node -var expected_vessel_types: PackedStringArray = PackedStringArray([ - GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_SHIELD, - GameState.VESSEL_INTERCEPTOR, -]) func _initialize() -> void: @@ -64,7 +58,7 @@ func _initialize() -> void: landing_controller = main_scene.get_node("LandingController") asteroid_field_ref = main_scene.get_node("AsteroidField") planet_name_label = main_scene.get_node("UI/PlanetNameLabel") - enter_button = main_scene.get_node("UI/BuildActionButton") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") func _process(delta: float) -> bool: @@ -91,10 +85,6 @@ func _process(delta: float) -> bool: # collide with a ship mid-test. Clear the existing ones too. for a in get_nodes_in_group("asteroids"): a.queue_free() - # A mixed fleet proves the handoff preserves composition rather - # than merely reconstructing the same number of interceptors. - fleet.spawn_ships(expected_vessel_types.size(), fleet.get_centroid(), - expected_vessel_types) # Get the fleet into the target chunk's vicinity first so # PlanetField's spawn radius covers it - the planet's own exact @@ -161,13 +151,12 @@ func _process(delta: float) -> bool: var surface_fleet: Node = current_scene.get_node("Fleet") var ship_count_ok: bool = surface_fleet.ships.size() == 5 - var composition_ok: bool = surface_fleet.vessel_types() == expected_vessel_types var no_asteroids: bool = get_nodes_in_group("asteroids").is_empty() var no_enemies: bool = get_nodes_in_group("enemies").is_empty() print("ship_count_ok=%s (count=%d) no_asteroids=%s no_enemies=%s" % [ ship_count_ok, surface_fleet.ships.size(), no_asteroids, no_enemies ]) - if not (ship_count_ok and composition_ok and no_asteroids and no_enemies): + if not (ship_count_ok and no_asteroids and no_enemies): print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)") quit(1) return true @@ -228,7 +217,6 @@ func _process(delta: float) -> bool: for a in get_nodes_in_group("asteroids"): a.queue_free() returned_ship_count = space_fleet.ships.size() - returned_vessel_types = space_fleet.vessel_types() # Ships respawn in a wedge and then steer into formation, so the # centroid only means anything once they have actually stopped. @@ -248,13 +236,12 @@ func _process(delta: float) -> bool: # across correctly and a fleet came back. var handoff_ok: bool = game_state.surviving_ship_count == 5 var ship_count_ok: bool = handoff_ok and returned_ship_count >= 1 - var composition_ok: bool = returned_vessel_types == expected_vessel_types var position_ok: bool = space_fleet.get_centroid().distance_to(position_before_leaving) < 50.0 print("handoff=%d returned_ships=%d ship_count_ok=%s position_ok=%s centroid=%s expected=%s" % [ game_state.surviving_ship_count, returned_ship_count, ship_count_ok, position_ok, space_fleet.get_centroid(), position_before_leaving ]) - if ship_count_ok and composition_ok and position_ok: + if ship_count_ok and position_ok: print("TEST_PLANET_ATMOSPHERE_TRANSITION: PASS") quit(0) else: diff --git a/tests/test_planet_prompt_map_interaction.gd b/tests/test_planet_prompt_map_interaction.gd index a3a7cc9..f5de4c4 100644 --- a/tests/test_planet_prompt_map_interaction.gd +++ b/tests/test_planet_prompt_map_interaction.gd @@ -2,8 +2,14 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_planet_prompt_map_interaction.gd -# Opening the map while standing on a planet must suppress the shared -# contextual action and planet label, then restore them after closing. +# Regression test for a real bug: EnterAtmosphereButton (shown while +# standing on a planet, bottom-center) and the world map's own +# RecenterButton (also bottom-center) occupy the exact same screen +# position. Opening the map while standing on a planet must suppress the +# enter-atmosphere prompt entirely - not just let it sit hidden behind +# the map's backdrop, since closing the map would then abruptly reveal it +# at the same instant RecenterButton disappears (which is what most +# plausibly explains "buttons blinking/disappearing"). var main_scene: Node var fleet: Node @@ -42,7 +48,7 @@ func _initialize() -> void: world_map = main_scene.get_node("UI/WorldMap") map_button = main_scene.get_node("UI/MapButton") planet_name_label = main_scene.get_node("UI/PlanetNameLabel") - enter_button = main_scene.get_node("UI/BuildActionButton") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") func _process(delta: float) -> bool: diff --git a/tests/test_prompt_debounce.gd b/tests/test_prompt_debounce.gd index f275903..12e2db7 100644 --- a/tests/test_prompt_debounce.gd +++ b/tests/test_prompt_debounce.gd @@ -47,7 +47,7 @@ func _initialize() -> void: root.add_child(main_scene) fleet = main_scene.get_node("Fleet") planet_field = main_scene.get_node("PlanetField") - enter_button = main_scene.get_node("UI/BuildActionButton") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") main_scene.get_node("AsteroidField").set_process(false) main_scene.get_node("EnemySpawner").set_process(false) diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index e33bdd9..f5035bc 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -7,8 +7,8 @@ extends SceneTree # research screen. # # Verifies: -# 1. the transfer shortcut appears beside Build whenever the fleet is -# standing on a transferable building, and opens the panel bound to +# 1. the transfer button appears alongside the action button whenever the +# fleet is standing on a building, and opens the transfer panel bound to # THAT building - the `== 0` guard failure that made every crafter # button look dead is the same trap here # 2. ore moves both ways at an ordinary building, capped by what is @@ -73,6 +73,8 @@ func _tap(button: Control) -> void: func _open_transfer() -> void: + # Contextual choices collapse into one button until it is opened. + _tap(controller.action_button) _tap(controller.transfer_button) @@ -106,8 +108,6 @@ func _process(delta: float) -> bool: return false _expect(controller.current_building != null, "parking on the crafter never registered") - _expect(controller.transfer_button.visible, - "transfer shortcut did not appear beside Build") _open_transfer() _expect(controller.transfer_menu.visible, "tapping the transfer button did not open the panel") diff --git a/tests/test_return_position_matches_planet.gd b/tests/test_return_position_matches_planet.gd index c7550c3..d6cc6be 100644 --- a/tests/test_return_position_matches_planet.gd +++ b/tests/test_return_position_matches_planet.gd @@ -44,7 +44,7 @@ func _initialize() -> void: fleet = main_scene.get_node("Fleet") planet_field = main_scene.get_node("PlanetField") landing_controller = main_scene.get_node("LandingController") - enter_button = main_scene.get_node("UI/BuildActionButton") + enter_button = main_scene.get_node("UI/EnterAtmosphereButton") func _process(delta: float) -> bool: diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index 5f344f5..dcb03a6 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -24,7 +24,6 @@ var scene: Node var field: Node var controller: Node var map_content: Node2D -var hub_building: Node2D var hub: Node2D var stage: int = 0 var elapsed: float = 0.0 @@ -102,19 +101,11 @@ func _process(delta: float) -> bool: return false for b in field.spawned: if is_instance_valid(b) and b.building_type == GameState.BUILDING_TRANSPORT_HUB: - hub_building = b hub = b.visual if hub == null: 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)") - quit(1) - return true - for ship in scene.get_node("Fleet").ships: - ship.position = HUB_AT - scene.get_node("Fleet").move_to(HUB_AT) # --- 3. drawn as itself, not via the crafter catch-all ------- # _hub_traffic only exists if _ready() took the transport-hub @@ -151,14 +142,6 @@ func _process(delta: float) -> bool: # --- 4. animates without ever redrawing (#10) ---------------- if elapsed < 2.0: return false - 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)" % [ - controller.current_building, hub_building, controller.action_button.visible, - controller.transfer_button.visible, - ]) - quit(1) - return true print("rasterisations after %.1fs: %d" % [elapsed, hub.draw_count]) if hub.draw_count != 1: print("TEST_TRANSPORT_HUB_BUILDING: FAIL (redrew its geometry %d times to animate)" % hub.draw_count) diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index 3fc9c2d..ca9dcbb 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -1,89 +1,217 @@ 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. +# 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 -var state: Node -var crafter_id: int -var initial_transporters: int -var total_at_start: int -var consumed: int = 0 +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 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 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 = 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)) + 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) - 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() +func _on_order_completed(_building_id: int, product: String) -> void: + consumed += game_state.product_cost(product) func _process(delta: float) -> bool: elapsed += delta - if _total() + consumed != total_at_start: + + # --- 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: 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() -func _on_order_completed(_building_id: int, product: String) -> void: - consumed += state.product_cost(product) + field.place(GameState.BUILDING_MOTHERSHIP, MOTHERSHIP_AT) + field.place(GameState.BUILDING_SHIP_CRAFTER, CRAFTER_AT) + crafter_id = int(game_state.buildings[-1].id) + # --- 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") -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 + 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") + # --- 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) -func _fail(message: String) -> bool: - print("TEST_TRANSPORTER_SUPPLY: FAIL (%s)" % message) - quit(1) - return true + # --- 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 diff --git a/tests/test_vessel_composition.gd b/tests/test_vessel_composition.gd index 20a2d6b..f70f99c 100644 --- a/tests/test_vessel_composition.gd +++ b/tests/test_vessel_composition.gd @@ -22,10 +22,6 @@ 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()):