From 19ac758da1a8a06c9946647c5be1f0df44ce526e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lo=C3=AFc=20Wisniewski?= Date: Tue, 11 Aug 2026 23:10:07 +0200 Subject: [PATCH] Typed owed vessels, ship lab building type, shield vessel gate Groundwork for #5. vessels_owed carries the VESSEL TYPE rather than a bare count, so a shield vessel finishing while the player is on a planet comes back as a shield vessel. Fleet.add_ship takes a type, and vessels already flying are re-statted when an upgrade is researched. Co-Authored-By: Claude Opus 5 --- BuildController.gd | 8 ++--- Fleet.gd | 25 +++++++++++--- GameState.gd | 53 +++++++++++++++++++++++++----- tests/test_building_stock_queue.gd | 12 +++---- tests/test_crafter_feedback.gd | 2 +- tests/test_crafter_feedback.gd.uid | 1 + tests/test_hub_capacity.gd | 2 +- tests/test_manual_supply_route.gd | 2 +- tests/test_transporter_supply.gd | 2 +- 9 files changed, 80 insertions(+), 27 deletions(-) create mode 100644 tests/test_crafter_feedback.gd.uid diff --git a/BuildController.gd b/BuildController.gd index 90e0798..2bc0179 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -557,7 +557,7 @@ func _refresh_craft_status() -> void: # A full fleet cannot take another vessel, so the order would be refused. # Showing that on the button is the difference between "not available yet" # and a button that looks live and silently does nothing when pressed. - var room: bool = fleet.ships.size() + GameState.ships_owed \ + var room: bool = fleet.ships.size() + GameState.vessels_owed.size() \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ < GameState.max_fleet_size() craft_interceptor_button.enabled = room @@ -568,7 +568,7 @@ func _refresh_craft_status() -> void: craft_status.text += " Fleet %d/%d" % [fleet.ships.size(), GameState.max_fleet_size()] else: var queued_ships: int = GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) - var owed: int = GameState.ships_owed + queued_ships + var owed: int = GameState.vessels_owed.size() + queued_ships var pending_note: String = "" if owed == 0 else " (%d already on order)" % owed craft_status.text = "No room in the fleet: %d/%d%s - research capacity at the mothership\n%s" % [ fleet.ships.size(), GameState.max_fleet_size(), pending_note, craft_status.text @@ -586,8 +586,8 @@ func _on_craft_interceptor() -> void: if craft_building_id == 0: return # Still refused when there is nowhere to put the vessel - queueing an - # order that can never be delivered would just bank ships_owed forever. - if fleet.ships.size() + GameState.ships_owed \ + # order that can never be delivered would just bank vessels_owed forever. + if fleet.ships.size() + GameState.vessels_owed.size() \ + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ >= GameState.max_fleet_size(): _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) diff --git a/Fleet.gd b/Fleet.gd index de7871b..384d9b7 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -32,6 +32,18 @@ var anchor: Vector2 = Vector2.ZERO var steer_direction: Vector2 = Vector2.ZERO +func _ready() -> void: + # Researching a vessel upgrade improves the ships already flying, not + # just the next one built (#5). + GameState.vessel_upgraded.connect(_on_vessel_upgraded) + + +func _on_vessel_upgraded(_id: String) -> void: + for ship in ships: + if is_instance_valid(ship): + ship.apply_vessel_stats() + + func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: for ship in ships: if is_instance_valid(ship): @@ -55,13 +67,16 @@ func spawn_ships(count: int, center: Vector2 = Vector2.ZERO) -> void: fleet_target = center -func add_ship() -> bool: +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(): return false var offset: Vector2 = GameState.formation[ships.size()] var ship: Area2D = ship_scene.instantiate() + # Before add_child, so Ship._ready() builds the right vessel - the shield + # ring is created there and only for a shield vessel. + ship.vessel_type = vessel_type # From the formation origin, not the centroid: with a lopsided layout the # centroid is already displaced by the average offset, so adding the new # ship's own offset to it would drop it well outside the wedge. @@ -128,12 +143,12 @@ func _process(delta: float) -> void: func _claim_owed_ships() -> void: # Production completes in the GameState autoload, which may well happen # while the player is on a planet's surface with no Fleet node in - # existence to hand a vessel to. Those ships wait as `ships_owed` until a + # existence to hand a vessel to. Those vessels wait in `vessels_owed` # fleet with room can take them. - while GameState.ships_owed > 0 and ships.size() < GameState.max_fleet_size(): - if not add_ship(): + while not GameState.vessels_owed.is_empty() and ships.size() < GameState.max_fleet_size(): + if not add_ship(String(GameState.vessels_owed[0])): return - GameState.ships_owed -= 1 + GameState.vessels_owed.pop_front() func _assign_slot_targets() -> void: diff --git a/GameState.gd b/GameState.gd index bd058ca..4dadbaa 100644 --- a/GameState.gd +++ b/GameState.gd @@ -104,10 +104,13 @@ const BUILDING_EXTRACTOR: String = "extractor" # Hosts the transport vessels that supply other buildings (#18). Built in # space like the rest; it is the transporters (#19) that do the moving. const BUILDING_TRANSPORT_HUB: String = "transport_hub" +# Where vessel types are researched and new ones unlocked (#5). +const BUILDING_SHIP_LAB: String = "ship_lab" const COMMAND_CENTER_COST: int = 15 const EXTRACTOR_COST: int = 25 const TRANSPORT_HUB_COST: int = 30 +const SHIP_LAB_COST: int = 35 var buildings: Array = [] @@ -145,6 +148,11 @@ const SKILL_TREE: Array = [ "x": 0.18, "unlocks": BUILDING_TRANSPORT_HUB, "desc": "Build transport hubs, which supply your other buildings with ore.", }, + { + "id": "vessel_science", "name": "Vessel Science", "cost": 35, "requires": "shipyards", + "x": 0.62, "unlocks": BUILDING_SHIP_LAB, + "desc": "Build ship labs, where each vessel type is upgraded and new ones unlocked.", + }, { "id": "freight_yards", "name": "Freight Yards", "cost": 80, "requires": "freight", "x": 0.05, "hub_bonus": 1, @@ -269,12 +277,12 @@ func reset_progression() -> void: # on the same spot. var next_building_id: int = 1 -# Ships that have been paid for and built but not yet handed to the fleet. -# Production is simulated here, in the autoload, the same way extractor -# haulers are - so an order can complete while the player is down on a -# planet's surface, where there is no Fleet node to hand a ship to. The fleet -# claims them when it next can. -var ships_owed: int = 0 +# Vessels that have been paid for and built but not yet handed to the fleet, +# as a list of VESSEL_* types - a shield vessel finishing while the player is +# on a planet must not come back as an interceptor. Production is simulated +# here, in the autoload, the same way extractor haulers are, so an order can +# complete where there is no Fleet node to hand a ship to. +var vessels_owed: Array = [] signal order_completed(building_id: int, type: String) @@ -372,6 +380,33 @@ var vessel_levels: Dictionary = {} signal vessel_upgraded(upgrade_id: String) +func has_ship_lab() -> bool: + for b in buildings: + if b.type == BUILDING_SHIP_LAB: + return true + return false + + +func shield_vessel_unlocked() -> bool: + # Unlocked AT the lab, by paying for it there - a vessel type rather than + # a stat, so it is a one-off rather than a level. + return int(vessel_levels.get(UNLOCK_SHIELD_VESSEL, 0)) > 0 + + +const UNLOCK_SHIELD_VESSEL: String = "unlock_shield" +const UNLOCK_SHIELD_COST: int = 45 + + +func unlock_shield_vessel() -> bool: + if shield_vessel_unlocked() or not has_ship_lab(): + return false + if not spend(UNLOCK_SHIELD_COST): + return false + vessel_levels[UNLOCK_SHIELD_VESSEL] = 1 + vessel_upgraded.emit(UNLOCK_SHIELD_VESSEL) + return true + + func upgrade_def(id: String) -> Dictionary: for u in VESSEL_UPGRADES: if u.id == id: @@ -404,7 +439,7 @@ func upgrade_cost(id: String) -> int: func can_buy_upgrade(id: String) -> bool: - return not upgrade_maxed(id) and can_afford(upgrade_cost(id)) + return has_ship_lab() and not upgrade_maxed(id) and can_afford(upgrade_cost(id)) func buy_upgrade(id: String) -> bool: @@ -531,6 +566,8 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: var b: Dictionary = find_building(id) if b.is_empty() or not can_produce(b.type): return false + if product == VESSEL_SHIELD and not shield_vessel_unlocked(): + return false if product == PRODUCT_TRANSPORTER 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. @@ -570,7 +607,7 @@ func _advance_production() -> void: if product == PRODUCT_TRANSPORTER: _station_transporter(b.position) else: - ships_owed += 1 + vessels_owed.append(product) order_completed.emit(int(b.id), product) diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd index d116fcb..a2beec4 100644 --- a/tests/test_building_stock_queue.gd +++ b/tests/test_building_stock_queue.gd @@ -45,7 +45,7 @@ func _initialize() -> void: game_state.extractors.clear() game_state.rocks = 500 game_state.fleet_cargo = 0 - game_state.ships_owed = 0 + game_state.vessels_owed.clear() # Room to grow: the default cap is BASE_FLEET_SIZE (5) and the fleet # starts at exactly 5, so without this every completed order would have # nowhere to put its vessel and the test would be measuring the cap @@ -180,7 +180,7 @@ func _process(delta: float) -> bool: # --- 6 + 7. no scene, no fleet: production still completes -- # The scene has to be GONE before the order is placed. With a # fleet still alive and under its cap it claims the vessel the - # same frame production finishes, which would leave ships_owed + # same frame production finishes, which would leave vessels_owed # back at 0 and prove nothing about orders surviving without it. ships_at_start = fleet.ships.size() scene.queue_free() @@ -207,11 +207,11 @@ func _process(delta: float) -> bool: print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") quit(1) return true - if game_state.ships_owed < 1: + if game_state.vessels_owed.is_empty(): print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") quit(1) return true - print("no scene at all -> order completed, %d vessel owed" % game_state.ships_owed) + print("no scene at all -> order completed, %d vessel owed" % game_state.vessels_owed.size()) # Come back to space: the fleet should claim what it is owed. scene = load("res://Main.tscn").instantiate() @@ -223,8 +223,8 @@ func _process(delta: float) -> bool: 8: if elapsed < 0.5: return false - if game_state.ships_owed != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.ships_owed) + if not game_state.vessels_owed.is_empty(): + print("TEST_BUILDING_STOCK_QUEUE: FAIL (returning fleet never claimed its %d owed vessel(s))" % game_state.vessels_owed.size()) quit(1) return true print("fleet returned and claimed the owed vessel (ships=%d)" % fleet.ships.size()) diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd index da3d34e..4d44d3c 100644 --- a/tests/test_crafter_feedback.gd +++ b/tests/test_crafter_feedback.gd @@ -38,7 +38,7 @@ func _initialize() -> void: game_state.unlocked = ["shipyards", "coordination", "wing"] game_state.rocks = 100 game_state.fleet_cargo = 0 - game_state.ships_owed = 0 + game_state.vessels_owed.clear() scene = load("res://Main.tscn").instantiate() root.add_child(scene) controller = scene.get_node("BuildController") diff --git a/tests/test_crafter_feedback.gd.uid b/tests/test_crafter_feedback.gd.uid new file mode 100644 index 0000000..ea9456a --- /dev/null +++ b/tests/test_crafter_feedback.gd.uid @@ -0,0 +1 @@ +uid://c33061yx51h5s diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index 46bae11..a072586 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -34,7 +34,7 @@ func _initialize() -> void: game_state.transporters.clear() game_state.rocks = 900 game_state.fleet_cargo = 0 - game_state.ships_owed = 0 + game_state.vessels_owed.clear() game_state.unlocked = ["shipyards", "logistics", "freight"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd index 338fd3e..c2f9c2a 100644 --- a/tests/test_manual_supply_route.gd +++ b/tests/test_manual_supply_route.gd @@ -50,7 +50,7 @@ func _initialize() -> void: game_state.extractors.clear() game_state.rocks = 40 game_state.fleet_cargo = 0 - game_state.ships_owed = 0 + game_state.vessels_owed.clear() game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index dbc43e4..2787cfc 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -56,7 +56,7 @@ func _initialize() -> void: game_state.transporters.clear() game_state.rocks = 200 game_state.fleet_cargo = 0 - game_state.ships_owed = 0 + game_state.vessels_owed.clear() game_state.unlocked = ["shipyards", "logistics", "freight", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene)