From 663648d94918dd4a4f03951dafc62e57c2177acf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Fri, 14 Aug 2026 23:15:12 +0200 Subject: [PATCH 1/2] Refine context actions and preserve fleet landings --- BuildController.gd | 76 +++++++++++--------- Fleet.gd | 20 ++++-- GameState.gd | 1 + Main.tscn | 23 +++--- PlanetAtmosphereController.gd | 16 ++--- PlanetSurfaceController.gd | 10 +-- TransportHubBuildingVisual.tscn | 7 +- tests/_context_actions_preview.gd | 37 ++++++++++ tests/_context_actions_preview.gd.uid | 1 + tests/_context_actions_preview.tscn | 6 ++ tests/test_building_composition.gd | 8 ++- tests/test_crafter_feedback.gd | 5 +- tests/test_extractor_transfer.gd | 14 ++-- tests/test_hangar_management.gd | 16 +++-- tests/test_planet_atmosphere_transition.gd | 19 ++++- tests/test_planet_prompt_map_interaction.gd | 12 +--- tests/test_prompt_debounce.gd | 2 +- tests/test_resource_transfer.gd | 8 +-- tests/test_return_position_matches_planet.gd | 2 +- tests/test_transport_hub_building.gd | 17 +++++ 20 files changed, 197 insertions(+), 103 deletions(-) create mode 100644 tests/_context_actions_preview.gd create mode 100644 tests/_context_actions_preview.gd.uid create mode 100644 tests/_context_actions_preview.tscn diff --git a/BuildController.gd b/BuildController.gd index 18e81cd..7b7da87 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -6,10 +6,8 @@ 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 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. +# standing on (craft ships, configure formation, or land). One action +# fires directly; multiple actions unfold horizontally. # - rebuilding a wiped-out fleet at the mothership # # Buildings themselves live in GameState (they must outlive the trip to a @@ -23,6 +21,8 @@ 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") @@ -113,8 +113,9 @@ var lab_building_id: int = 0 # --- 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 button that works at every building and in both -# directions, so moving ore is never buried under an unrelated menu. +# 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. @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") @@ -165,6 +166,7 @@ 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 @@ -218,6 +220,7 @@ 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 @@ -239,10 +242,10 @@ func _configure_build_menu_entries() -> void: build_menu_entries.append([definition.definition_id, button, label, cost_number, definition]) -# True while a building prompt or panel owns the shared action slot, so the -# enter-atmosphere prompt knows to stay out of the way. +# 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. func is_showing_building_prompt() -> bool: - return current_building != null or build_menu.visible or craft_menu.visible \ + return build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible or transfer_menu.visible \ or hangar_menu.visible @@ -309,8 +312,10 @@ 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 @@ -332,28 +337,27 @@ 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 pending_extractor_index == current_extractor_index \ + and detected_landing_planet == current_landing_planet): 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 - action_button.visible = current_building != null or current_extractor_index >= 0 + landing_button.visible = false + transfer_button.visible = _has_transfer_context() + var actions: Array = _context_action_buttons() + action_button.visible = not actions.is_empty() 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() - 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 + if not actions.is_empty(): + action_button.icon = int(actions[0].icon) if actions.size() == 1 else IconButton.Icon.ACTION_MENU action_button.queue_redraw() _layout_context_buttons() @@ -381,33 +385,41 @@ func _layout_context_buttons() -> void: func _context_action_buttons() -> Array: - if current_extractor_index >= 0: - 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) + 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") + + func _button_for_building_action(action: String) -> IconButton: if action == "transfer": - return transfer_button + return null 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: + if current_building == null and current_extractor_index < 0 \ + and current_landing_planet == null: return var actions: Array = _context_action_buttons() if actions.size() == 1: - if actions[0] == transfer_button: - _on_transfer_pressed() + if actions[0] == landing_button: + landing_controller._on_enter_pressed() + elif actions[0] == hangar_button: + _on_hangar_pressed() else: _on_action_pressed() return diff --git a/Fleet.gd b/Fleet.gd index b108dd1..d613164 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -4,10 +4,9 @@ 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 - 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. +# 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. # 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 @@ -44,7 +43,8 @@ func _on_vessel_upgraded(_id: String) -> void: ship.apply_vessel_stats() -func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: +func spawn_ships(count: int, center: Vector2 = Vector2.ZERO, + vessel_types: PackedStringArray = PackedStringArray()) -> void: for ship in ships: if is_instance_valid(ship): ship.queue_free() @@ -55,6 +55,8 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: 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.position = center + offset ship.glow_color = ship_glow_color ship.core_color = ship_core_color @@ -67,6 +69,14 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: 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. diff --git a/GameState.gd b/GameState.gd index 03c35e4..ec36e3d 100644 --- a/GameState.gd +++ b/GameState.gd @@ -14,6 +14,7 @@ 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 diff --git a/Main.tscn b/Main.tscn index d6dd326..1be9bc7 100644 --- a/Main.tscn +++ b/Main.tscn @@ -148,20 +148,19 @@ digit_height = 38.0 [node name="PlanetNameLabel" type="Label" parent="UI"] visible = false offset_left = 24 -offset_top = 156 +offset_top = 480 offset_right = 696 -offset_bottom = 282 +offset_bottom = 532 text = "" horizontal_alignment = 1 +theme_override_font_sizes/font_size = 32 [node name="EnterAtmosphereButton" type="Control" parent="UI"] visible = false -anchor_top = 1.0 -anchor_bottom = 1.0 -offset_left = 24 -offset_right = 172 -offset_top = -720 -offset_bottom = -572 +offset_left = 188 +offset_top = 324 +offset_right = 336 +offset_bottom = 472 script = ExtResource("10") icon = 3 @@ -203,10 +202,10 @@ glow_color = Color(0.45, 0.9, 0.55, 1) [node name="TransferButton" type="Control" parent="UI"] visible = false -offset_left = 24 -offset_right = 172 -offset_top = 488 -offset_bottom = 636 +offset_left = 188 +offset_top = 160 +offset_right = 336 +offset_bottom = 308 script = ExtResource("10") icon = 14 glow_color = Color(1, 0.78, 0.25, 1) diff --git a/PlanetAtmosphereController.gd b/PlanetAtmosphereController.gd index 9363981..36ac960 100644 --- a/PlanetAtmosphereController.gd +++ b/PlanetAtmosphereController.gd @@ -34,7 +34,8 @@ 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) + fleet.spawn_ships(GameState.surviving_ship_count, GameState.return_world_position, + GameState.surviving_vessel_types) camera.position = GameState.return_world_position camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) GameState.has_return_position = false @@ -57,16 +58,12 @@ func _process(delta: float) -> void: return if world_map.visible or build_controller.is_showing_building_prompt(): - # 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. + # Context actions belong to BuildController. This controller owns the + # debounced planet selection and label, and suppresses both while a modal + # screen is open. 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 @@ -90,10 +87,8 @@ 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: @@ -105,6 +100,7 @@ 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 7c683d4..294f5f1 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 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, 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 # fades back out to Main.tscn, restoring the fleet at wherever it left # space from. @@ -25,7 +25,8 @@ 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) + fleet.spawn_ships(GameState.surviving_ship_count, Vector2.ZERO, + GameState.surviving_vessel_types) camera.position = Vector2.ZERO camera.zoom = Vector2(GameState.return_camera_zoom, GameState.return_camera_zoom) planet_name_label.text = GameState.planet_name @@ -57,6 +58,7 @@ 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/TransportHubBuildingVisual.tscn b/TransportHubBuildingVisual.tscn index 1426761..29dbb10 100644 --- a/TransportHubBuildingVisual.tscn +++ b/TransportHubBuildingVisual.tscn @@ -1,11 +1,6 @@ -[gd_scene load_steps=3 format=3] +[gd_scene load_steps=2 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/tests/_context_actions_preview.gd b/tests/_context_actions_preview.gd new file mode 100644 index 0000000..1957336 --- /dev/null +++ b/tests/_context_actions_preview.gd @@ -0,0 +1,37 @@ +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 new file mode 100644 index 0000000..aed6b62 --- /dev/null +++ b/tests/_context_actions_preview.gd.uid @@ -0,0 +1 @@ +uid://r4kyx6d4s60i diff --git a/tests/_context_actions_preview.tscn b/tests/_context_actions_preview.tscn new file mode 100644 index 0000000..a79a95b --- /dev/null +++ b/tests/_context_actions_preview.tscn @@ -0,0 +1,6 @@ +[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/test_building_composition.gd b/tests/test_building_composition.gd index 7cadf5d..616095d 100644 --- a/tests/test_building_composition.gd +++ b/tests/test_building_composition.gd @@ -21,8 +21,12 @@ func _initialize() -> void: ]) return var provider: Node = building.visual.get_node_or_null("ActionProvider") - if provider == null or not provider.actions.has("transfer"): - _fail("%s has no composed action provider" % definition.definition_id) + 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) return building.free() diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd index e104c27..ddd1882 100644 --- a/tests/test_crafter_feedback.gd +++ b/tests/test_crafter_feedback.gd @@ -96,10 +96,9 @@ func _process(delta: float) -> bool: print("TEST_CRAFTER_FEEDBACK: FAIL (fleet parked on the crafter never registered)") quit(1) return true - # A crafter now has both Craft and Transfer, so the shared action - # button unfolds those choices before the craft action is selected. + # Transfer has its own shortcut beside Build, leaving Craft as the + # single contextual action that fires directly. _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_transfer.gd b/tests/test_extractor_transfer.gd index 97151e4..5f5c838 100644 --- a/tests/test_extractor_transfer.gd +++ b/tests/test_extractor_transfer.gd @@ -48,11 +48,13 @@ func _process(delta: float) -> bool: 1: if elapsed < 1.2: return false - 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 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 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) @@ -61,7 +63,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 from extractor while landing remained available" % resource_type) + print("fleet fetched %s while landing remained the contextual action" % resource_type) print("TEST_EXTRACTOR_TRANSFER: PASS") quit(0) return true diff --git a/tests/test_hangar_management.gd b/tests/test_hangar_management.gd index 4de3cac..3bc5ff6 100644 --- a/tests/test_hangar_management.gd +++ b/tests/test_hangar_management.gd @@ -63,24 +63,30 @@ 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 has three - # choices: research, transfer cargo and hangar, which unfold in their - # own non-overlapping berths to the right only after expansion. + # 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. 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.transfer_button, controller.hangar_button] + var buttons: Array = [controller.building_action_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.transfer_button.visible or controller.hangar_button.visible: + if controller.building_action_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_planet_atmosphere_transition.gd b/tests/test_planet_atmosphere_transition.gd index ed3b10c..f972a8b 100644 --- a/tests/test_planet_atmosphere_transition.gd +++ b/tests/test_planet_atmosphere_transition.gd @@ -24,7 +24,13 @@ 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_TRANSPORTER, GameState.VESSEL_SHIELD, + GameState.VESSEL_INTERCEPTOR, +]) func _initialize() -> void: @@ -58,7 +64,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/EnterAtmosphereButton") + enter_button = main_scene.get_node("UI/BuildActionButton") func _process(delta: float) -> bool: @@ -85,6 +91,10 @@ 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 @@ -151,12 +161,13 @@ 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 no_asteroids and no_enemies): + if not (ship_count_ok and composition_ok and no_asteroids and no_enemies): print("TEST_PLANET_ATMOSPHERE_TRANSITION: FAIL (surface scene contents wrong)") quit(1) return true @@ -217,6 +228,7 @@ 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. @@ -236,12 +248,13 @@ 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 position_ok: + if ship_count_ok and composition_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 f5de4c4..a3a7cc9 100644 --- a/tests/test_planet_prompt_map_interaction.gd +++ b/tests/test_planet_prompt_map_interaction.gd @@ -2,14 +2,8 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_planet_prompt_map_interaction.gd -# 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"). +# Opening the map while standing on a planet must suppress the shared +# contextual action and planet label, then restore them after closing. var main_scene: Node var fleet: Node @@ -48,7 +42,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/EnterAtmosphereButton") + enter_button = main_scene.get_node("UI/BuildActionButton") func _process(delta: float) -> bool: diff --git a/tests/test_prompt_debounce.gd b/tests/test_prompt_debounce.gd index 12e2db7..f275903 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/EnterAtmosphereButton") + enter_button = main_scene.get_node("UI/BuildActionButton") 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 f5035bc..e33bdd9 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 button appears alongside the action button whenever the -# fleet is standing on a building, and opens the transfer panel bound to +# 1. the transfer shortcut appears beside Build whenever the fleet is +# standing on a transferable building, and opens the 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,8 +73,6 @@ 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) @@ -108,6 +106,8 @@ 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 d6cc6be..c7550c3 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/EnterAtmosphereButton") + enter_button = main_scene.get_node("UI/BuildActionButton") func _process(delta: float) -> bool: diff --git a/tests/test_transport_hub_building.gd b/tests/test_transport_hub_building.gd index dcb03a6..5f344f5 100644 --- a/tests/test_transport_hub_building.gd +++ b/tests/test_transport_hub_building.gd @@ -24,6 +24,7 @@ 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 @@ -101,11 +102,19 @@ 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 @@ -142,6 +151,14 @@ 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) 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 2/2] 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()):