diff --git a/BuildController.gd b/BuildController.gd index 6cb587c..725dedd 100644 --- a/BuildController.gd +++ b/BuildController.gd @@ -42,6 +42,21 @@ extends Node2D @onready var lab_buy_button: IconButton = get_node("../UI/LabMenu/BuyButton") @onready var lab_close_button: IconButton = get_node("../UI/LabMenu/CloseButton") @onready var craft_shield_button: IconButton = get_node("../UI/CraftMenu/ShieldButton") +@onready var queue_slots: Array = [ + get_node("../UI/CraftMenu/QueueSlot0"), get_node("../UI/CraftMenu/QueueSlot1"), + get_node("../UI/CraftMenu/QueueSlot2"), get_node("../UI/CraftMenu/QueueSlot3"), +] +@onready var queue_detail: Label = get_node("../UI/CraftMenu/QueueDetail") +@onready var hangar_button: IconButton = get_node("../UI/HangarButton") +@onready var hangar_menu: Control = get_node("../UI/HangarMenu") +@onready var hangar_list: Label = get_node("../UI/HangarMenu/ListLabel") +@onready var hangar_status: Label = get_node("../UI/HangarMenu/StatusLabel") +@onready var hangar_next_button: IconButton = get_node("../UI/HangarMenu/NextButton") +@onready var hangar_crew_button: IconButton = get_node("../UI/HangarMenu/CrewButton") +@onready var hangar_close_button: IconButton = get_node("../UI/HangarMenu/CloseButton") + +# Which stored vessel the hangar is offering to crew. +var hangar_choice: int = 0 @onready var build_menu_entries: Array = [ [GameState.BUILDING_MOTHERSHIP, build_mothership_button, get_node("../UI/BuildMenu/MothershipLabel"), get_node("../UI/BuildMenu/MothershipCost")], @@ -199,6 +214,12 @@ func _ready() -> void: craft_close_button.pressed.connect(func(): craft_menu.visible = false) transfer_button.pressed.connect(_on_transfer_pressed) + hangar_button.pressed.connect(_on_hangar_pressed) + hangar_close_button.pressed.connect(func(): hangar_menu.visible = false) + hangar_next_button.pressed.connect(_on_hangar_next) + hangar_crew_button.pressed.connect(_on_hangar_crew) + for i in range(queue_slots.size()): + queue_slots[i].pressed.connect(_on_queue_slot.bind(i)) transfer_close_button.pressed.connect(func(): transfer_menu.visible = false) to_building_batch_button.pressed.connect(_on_transfer.bind(true, GameState.CARGO_LOAD_BATCH)) to_building_all_button.pressed.connect(_on_transfer.bind(true, ALL)) @@ -222,13 +243,16 @@ func _ready() -> void: transfer_menu.visible = false action_button.visible = false transfer_button.visible = false + hangar_button.visible = false + hangar_menu.visible = false # True while a building prompt or panel owns the shared action slot, so the # enter-atmosphere prompt knows to stay out of the way. func is_showing_building_prompt() -> bool: return current_building != null or build_menu.visible or craft_menu.visible \ - or formation_menu.visible or skill_menu.visible or transfer_menu.visible + or formation_menu.visible or skill_menu.visible or transfer_menu.visible \ + or hangar_menu.visible func _unhandled_input(event: InputEvent) -> void: @@ -254,7 +278,7 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return if build_menu.visible or craft_menu.visible or world_map.visible or lab_menu.visible \ - or transfer_menu.visible: + or transfer_menu.visible or hangar_menu.visible: # These panels have no raw-input handler of their own, but the world # behind them must still not react. get_viewport().set_input_as_handled() @@ -282,11 +306,12 @@ func _process(delta: float) -> void: if world_map.visible or build_menu.visible or craft_menu.visible \ or formation_menu.visible or skill_menu.visible or lab_menu.visible \ - or transfer_menu.visible: + or transfer_menu.visible or hangar_menu.visible: current_building = null pending_building = null action_button.visible = false transfer_button.visible = false + hangar_button.visible = false return # Debounced exactly like the enter-atmosphere prompt, so the action @@ -304,6 +329,10 @@ func _process(delta: float) -> void: current_building = pending_building action_button.visible = current_building != null + # The hangar is where finished vessels are delivered (#23), so it belongs + # at the mothership and nowhere else. + hangar_button.visible = current_building != null \ + and current_building.building_type == GameState.BUILDING_MOTHERSHIP # 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. @@ -662,27 +691,19 @@ func _refresh_craft_status() -> void: craft_shield_button.enabled = GameState.shield_vessel_unlocked() craft_shield_button.queue_redraw() - # 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.vessels_owed.size() \ - + GameState.pending_orders_of(craft_building_id, GameState.PRODUCT_SHIP) \ - < GameState.max_fleet_size() - craft_interceptor_button.enabled = room + # Fleet capacity no longer gates ORDERING (#23). A finished vessel waits + # at the mothership until the player crews it, so a full fleet is a + # reason not to collect one yet - not a reason the crafter cannot build. + # This is also what stopped the crafter being inert on a fresh save, + # where the fleet starts at exactly its cap. + craft_interceptor_button.enabled = true craft_interceptor_button.queue_redraw() - # One statement of the fleet count, either way round: the capacity line - # replaces the plain readout rather than being bolted in front of it. - if room: - 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.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 - ] + craft_status.text += " Fleet %d/%d Ready %d" % [ + fleet.ships.size(), GameState.max_fleet_size(), GameState.stored_vessels.size() + ] # LAST, so a message about what just happened is the first line the # player reads rather than being buried under the standing state. + _refresh_queue() if craft_message != "": if Time.get_ticks_msec() < craft_message_until: craft_status.text = "%s\n%s" % [craft_message, craft_status.text] @@ -694,13 +715,6 @@ func _on_craft_interceptor() -> void: if craft_building_id == 0: _say("No crafter selected - reopen this panel from the building") return - # Still refused when there is nowhere to put the vessel - queueing an - # 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()) - return # No affordability check: an order the crafter cannot pay for yet is # QUEUED, and completes by itself once ore is delivered here (#16). But # queueing silently looks identical to a dead button, so it says which @@ -839,10 +853,6 @@ func _on_craft_shield() -> void: if not GameState.shield_vessel_unlocked(): _say("Shield vessels must be unlocked at a ship lab first") return - if fleet.ships.size() + GameState.vessels_owed.size() \ - + GameState.pending_orders(craft_building_id) >= GameState.max_fleet_size(): - _say("Fleet is full (%d slots)" % GameState.max_fleet_size()) - return GameState.queue_order(craft_building_id, GameState.VESSEL_SHIELD) _report_order(GameState.VESSEL_SHIELD) @@ -927,6 +937,115 @@ func _refresh_transfer() -> void: button.queue_redraw() +func _refresh_queue() -> void: + # The queue down the right of the craft panel: one slot per pending + # vessel, the first being the one actually under construction. Tapping a + # slot cancels it (#23). + var orders: Array = GameState.order_list(craft_building_id) + for i in range(queue_slots.size()): + var slot: IconButton = queue_slots[i] + slot.visible = i < orders.size() + if not slot.visible: + continue + var order: Dictionary = orders[i] + slot.icon = IconButton.Icon.TRANSPORT_HUB if order.product == GameState.PRODUCT_TRANSPORTER \ + else IconButton.Icon.SHIP + slot.glow_color = _vessel_colour(String(order.product)) + # Only the one under construction reads as active; the rest are + # waiting, and greyed says so without needing a caption each. + slot.enabled = bool(order.started) + slot.queue_redraw() + + if orders.is_empty(): + queue_detail.text = "Nothing queued" + return + var head: Dictionary = orders[0] + if bool(head.started): + queue_detail.text = "Building %s %d%%\nTap to cancel" % [ + GameState.VESSEL_NAMES.get(head.product, head.product), + int(round(float(head.progress) * 100.0)), + ] + else: + queue_detail.text = "%s waiting for ore\nTap to cancel" % \ + GameState.VESSEL_NAMES.get(head.product, head.product) + + +func _vessel_colour(product: String) -> Color: + match product: + GameState.VESSEL_SHIELD: + return Color(0.45, 0.8, 1.0) + GameState.PRODUCT_TRANSPORTER: + return Color(0.95, 0.55, 0.85) + _: + return Color(0.25, 0.85, 1.0) + + +func _on_queue_slot(index: int) -> void: + var orders: Array = GameState.order_list(craft_building_id) + if index >= orders.size(): + return + var vessel: String = GameState.VESSEL_NAMES.get(orders[index].product, orders[index].product) + var refunded: bool = bool(orders[index].started) + if not GameState.cancel_order(craft_building_id, index): + return + _say("%s cancelled%s" % [vessel, " - ore refunded here" if refunded else ""]) + _refresh_queue() + + +# --- hangar (#23) --------------------------------------------------------- + +func _on_hangar_pressed() -> void: + hangar_choice = 0 + hangar_status.text = "" + hangar_menu.visible = true + _refresh_hangar() + + +func _on_hangar_next() -> void: + if GameState.stored_vessels.is_empty(): + return + hangar_choice = (hangar_choice + 1) % GameState.stored_vessels.size() + _refresh_hangar() + + +func _on_hangar_crew() -> void: + if GameState.stored_vessels.is_empty(): + hangar_status.text = "Nothing built yet - order vessels at a ship crafter" + return + if fleet.ships.size() >= GameState.max_fleet_size(): + hangar_status.text = "Fleet is full (%d/%d) - research capacity at the mothership" % [ + fleet.ships.size(), GameState.max_fleet_size() + ] + return + var product: String = GameState.claim_stored_vessel(hangar_choice) + if product == "" or not fleet.add_ship(product): + hangar_status.text = "Could not crew that vessel" + return + GameState.take_stored_vessel(hangar_choice) + hangar_status.text = "%s joined the fleet" % GameState.VESSEL_NAMES.get(product, product) + hangar_choice = 0 + _refresh_hangar() + + +func _refresh_hangar() -> void: + var stored: Array = GameState.stored_vessels + if stored.is_empty(): + hangar_list.text = "No vessels waiting.\n\nOrder them at a ship crafter - finished vessels are delivered here." + hangar_crew_button.enabled = false + hangar_next_button.enabled = false + else: + hangar_choice = clampi(hangar_choice, 0, stored.size() - 1) + var lines: Array = [] + for i in range(stored.size()): + var marker: String = ">" if i == hangar_choice else " " + lines.append("%s %s" % [marker, GameState.VESSEL_NAMES.get(stored[i], stored[i])]) + hangar_list.text = "\n".join(lines) + hangar_crew_button.enabled = fleet.ships.size() < GameState.max_fleet_size() + hangar_next_button.enabled = stored.size() > 1 + hangar_crew_button.queue_redraw() + hangar_next_button.queue_redraw() + + func _on_formation_changed() -> void: fleet.apply_formation() diff --git a/Fleet.gd b/Fleet.gd index 384d9b7..14497f3 100644 --- a/Fleet.gd +++ b/Fleet.gd @@ -128,9 +128,6 @@ func move_in_direction(direction: Vector2) -> void: func _process(delta: float) -> void: - # Before the steering early-out: a ship the crafter has already built and - # paid for is owed whether or not the fleet happens to be flying (#16). - _claim_owed_ships() if steer_direction == Vector2.ZERO: return # The anchor - the fleet's formation centre - travels at cruise_speed. @@ -140,17 +137,6 @@ func _process(delta: float) -> void: _assign_slot_targets() -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 vessels wait in `vessels_owed` - # fleet with room can take them. - 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.vessels_owed.pop_front() - - func _assign_slot_targets() -> void: for i in range(ships.size()): ships[i].set_move_target(fleet_target + formation_offsets[i]) diff --git a/GameState.gd b/GameState.gd index 25d796e..d56aa12 100644 --- a/GameState.gd +++ b/GameState.gd @@ -294,12 +294,15 @@ func reset_progression() -> void: # on the same spot. var next_building_id: int = 1 -# 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 = [] +# Finished vessels waiting at the mothership for the player to collect +# (#23), as a list of VESSEL_* types - a shield vessel must not come back as +# an interceptor. They are NOT handed to the fleet automatically: the player +# picks what to crew from the hangar, so a full fleet no longer stops a +# crafter from building. +# +# Production is simulated here, in the autoload, the same way extractor +# haulers are, so a build finishes whether or not the player is in space. +var stored_vessels: Array = [] signal order_completed(building_id: int, type: String) @@ -337,6 +340,24 @@ const VESSEL_NAMES: Dictionary = { const SHIELD_COST: int = 14 +# How long one vessel takes to build, once its ore is actually in the +# building. A queue you can watch and cancel needs time to exist in. +const BUILD_TIMES: Dictionary = { + VESSEL_INTERCEPTOR: 10.0, + VESSEL_SHIELD: 16.0, + VESSEL_TRANSPORTER: 8.0, +} + + +# Test seam. Headless tests need the timed path - queued, started, finishing +# - without sitting through a real 10-second build each, and a suite that +# slow stops being run. Left at 1.0 in the game. +var build_time_scale: float = 1.0 + + +func build_time(product: String) -> float: + return float(BUILD_TIMES.get(product, 10.0)) * build_time_scale + # Researched at the ship lab, per vessel type. Separate from SKILL_TREE on # purpose: that gates what the player may build at all and is a strategic # spine, while these are repeatable tuning on a vessel you already have. @@ -553,11 +574,44 @@ func pending_orders_of(id: int, product: String) -> int: return 0 var n: int = 0 for o in b.get("orders", []): - if o == product: + if String(o.product) == product: n += 1 return n +func order_list(id: int) -> Array: + # What the queue panel draws: product, and how far along the one being + # built is. Everything behind it reads as simply waiting. + var out: Array = [] + var b: Dictionary = find_building(id) + if b.is_empty(): + return out + for o in b.get("orders", []): + var product: String = String(o.product) + out.append({ + "product": product, + "started": bool(o.started), + "progress": clampf(float(o.elapsed) / maxf(build_time(product), 0.001), 0.0, 1.0), + }) + return out + + +func cancel_order(id: int, index: int) -> bool: + # Refunds whatever was actually taken: a build that has started has its + # ore in the building already, one still waiting never paid. + var b: Dictionary = find_building(id) + if b.is_empty(): + return false + var orders: Array = b.get("orders", []) + if index < 0 or index >= orders.size(): + return false + var order: Dictionary = orders[index] + if bool(order.started): + b.stock = int(b.get("stock", 0)) + product_cost(String(order.product)) + orders.remove_at(index) + return true + + func outstanding_cost(id: int) -> int: # What this building still needs delivered to clear everything queued. var b: Dictionary = find_building(id) @@ -565,7 +619,9 @@ func outstanding_cost(id: int) -> int: return 0 var need: int = 0 for o in b.get("orders", []): - need += product_cost(o) + # A started build has already paid, so it is not still owed. + if not bool(o.started): + need += product_cost(String(o.product)) return maxi(need - int(b.get("stock", 0)), 0) @@ -592,40 +648,69 @@ func queue_order(id: int, product: String = PRODUCT_SHIP) -> bool: if product == PRODUCT_TRANSPORTER and not can_station_transporter(): # Every berth is taken or spoken for (#20). return false - (b.orders as Array).append(product) + (b.orders as Array).append({"product": product, "elapsed": 0.0, "started": false}) return true -func _advance_production() -> void: +func _advance_production(delta: float) -> void: for b in buildings: if not can_produce(b.type): continue - # `while`, not `if`: a delivery big enough to cover several queued - # orders should clear them all rather than dribbling one out per - # frame. var orders: Array = b.get("orders", []) - while not orders.is_empty(): - var product: String = String(orders[0]) - var cost: int = product_cost(product) + if orders.is_empty(): + continue + + # ONE at a time, in order. A queue the player can watch and cancel + # has to have a thing being built and things waiting behind it - + # clearing the whole queue the instant the ore lands (which is what + # this did before #23) leaves nothing to see or cancel. + var order: Dictionary = orders[0] + var product: String = String(order.product) + var cost: int = product_cost(product) + + if not bool(order.started): if int(b.get("stock", 0)) < cost: - break + continue if product == PRODUCT_TRANSPORTER and transporters.size() >= transport_capacity(): # A hub was demolished after this was ordered, so the berth it - # was going to use is gone. Hold the order rather than - # overfilling what is left. - break + # was going to use is gone. Hold rather than overfill. + continue if product == PRODUCT_TRANSPORTER and nearest_hub_to(b.position).is_empty(): - # Every hub was demolished while this sat in the queue. Leave - # it queued and the stock untouched rather than producing a - # vessel with nowhere to live. - break + continue b.stock = int(b.stock) - cost - orders.pop_front() - if product == PRODUCT_TRANSPORTER: - _station_transporter(b.position) - else: - vessels_owed.append(product) - order_completed.emit(int(b.id), product) + order.started = true + + order.elapsed = float(order.elapsed) + delta + if float(order.elapsed) < build_time(product): + continue + + orders.pop_front() + if product == PRODUCT_TRANSPORTER: + # Transporters are unchanged: they take themselves to the nearest + # hub and wait for work (#19). + _station_transporter(b.position) + else: + # Everything else is delivered to the mothership and waits there + # until the player crews it (#23). + stored_vessels.append(product) + order_completed.emit(int(b.id), product) + + +func claim_stored_vessel(index: int) -> String: + # Hands one finished vessel over to be crewed. Returns its type, or "" + # when there is nothing there - the caller decides whether the fleet + # actually has room. + if index < 0 or index >= stored_vessels.size(): + return "" + return String(stored_vessels[index]) + + +func take_stored_vessel(index: int) -> String: + var product: String = claim_stored_vessel(index) + if product == "": + return "" + stored_vessels.remove_at(index) + return product func has_mothership() -> bool: @@ -756,7 +841,7 @@ func _process(delta: float) -> void: # Runs in the autoload, so extraction keeps earning - and queued orders # keep completing - while the player is on a planet's surface or nowhere # near either. - _advance_production() + _advance_production(delta) _advance_transporters(delta) if extractors.is_empty(): return @@ -827,7 +912,7 @@ func pending_transporter_orders() -> int: var n: int = 0 for b in buildings: for o in b.get("orders", []): - if o == PRODUCT_TRANSPORTER: + if String(o.product) == PRODUCT_TRANSPORTER: n += 1 return n diff --git a/Main.tscn b/Main.tscn index 18793fa..b1a9f02 100644 --- a/Main.tscn +++ b/Main.tscn @@ -145,6 +145,18 @@ offset_bottom = -572 script = ExtResource("10") icon = 2 +[node name="HangarButton" type="Control" parent="UI"] +visible = false +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = 172 +offset_top = -776 +offset_bottom = -628 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.9, 0.55, 1) + [node name="TransferButton" type="Control" parent="UI"] visible = false anchor_top = 1.0 @@ -631,6 +643,105 @@ script = ExtResource("10") icon = 1 glow_color = Color(0, 0.62, 0.7, 1) +[node name="HangarMenu" type="Control" parent="UI" groups=["modal_menus"]] +visible = false +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 + +[node name="Backdrop" type="ColorRect" parent="UI/HangarMenu"] +anchor_right = 1.0 +anchor_bottom = 1.0 +color = Color(0.03, 0.03, 0.08, 0.94) + +[node name="TitleLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +offset_left = 24 +offset_top = 60 +offset_right = -210 +offset_bottom = 170 +text = "Hangar" +horizontal_alignment = 1 + +[node name="ListLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_bottom = 1.0 +offset_left = 32 +offset_top = 200 +offset_right = -32 +offset_bottom = -420 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="StatusLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 32 +offset_top = -410 +offset_right = -32 +offset_bottom = -300 +theme_override_font_sizes/font_size = 30 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="NextButton" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = -230 +offset_right = -110 +offset_top = -280 +offset_bottom = -160 +script = ExtResource("10") +icon = 6 +glow_color = Color(0.55, 0.95, 0.9, 1) + +[node name="CrewButton" type="Control" parent="UI/HangarMenu"] +anchor_left = 0.5 +anchor_top = 1.0 +anchor_right = 0.5 +anchor_bottom = 1.0 +offset_left = 110 +offset_right = 230 +offset_top = -280 +offset_bottom = -160 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.45, 0.9, 0.55, 1) + +[node name="HintLabel" type="Label" parent="UI/HangarMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_top = -150 +offset_right = -24 +offset_bottom = -60 +theme_override_font_sizes/font_size = 28 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "Next Crew" + +[node name="CloseButton" type="Control" parent="UI/HangarMenu"] +anchor_left = 1.0 +anchor_right = 1.0 +offset_left = -172 +offset_top = 24 +offset_right = -24 +offset_bottom = 172 +script = ExtResource("10") +icon = 1 +glow_color = Color(0.45, 0.9, 0.55, 1) + [node name="LabMenu" type="Control" parent="UI" groups=["modal_menus"]] visible = false mouse_filter = 2 @@ -828,7 +939,7 @@ mouse_filter = 2 anchor_right = 1.0 offset_left = 24 offset_top = 274 -offset_right = -24 +offset_right = -200 offset_bottom = 420 theme_override_font_sizes/font_size = 36 autowrap_mode = 2 @@ -917,6 +1028,91 @@ autowrap_mode = 2 text = "Shield Vessel" horizontal_alignment = 1 +[node name="QueueLabel" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -200 +offset_right = -10 +offset_top = -450 +offset_bottom = -390 +theme_override_font_sizes/font_size = 26 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "Queue" + +[node name="QueueDetail" type="Label" parent="UI/CraftMenu"] +mouse_filter = 2 +anchor_right = 1.0 +anchor_top = 1.0 +anchor_bottom = 1.0 +offset_left = 24 +offset_right = -24 +offset_top = -150 +offset_bottom = -30 +theme_override_font_sizes/font_size = 26 +autowrap_mode = 2 +horizontal_alignment = 1 +text = "" + +[node name="QueueSlot0" type="Control" parent="UI/CraftMenu"] +visible = false +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -140 +offset_right = -30 +offset_top = -380 +offset_bottom = -270 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.25, 0.85, 1, 1) + +[node name="QueueSlot1" type="Control" parent="UI/CraftMenu"] +visible = false +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -140 +offset_right = -30 +offset_top = -262 +offset_bottom = -152 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.25, 0.85, 1, 1) + +[node name="QueueSlot2" type="Control" parent="UI/CraftMenu"] +visible = false +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -140 +offset_right = -30 +offset_top = -144 +offset_bottom = -34 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.25, 0.85, 1, 1) + +[node name="QueueSlot3" type="Control" parent="UI/CraftMenu"] +visible = false +anchor_left = 1.0 +anchor_top = 0.5 +anchor_right = 1.0 +anchor_bottom = 0.5 +offset_left = -140 +offset_right = -30 +offset_top = -26 +offset_bottom = 84 +script = ExtResource("10") +icon = 2 +glow_color = Color(0.25, 0.85, 1, 1) + [node name="CloseButton" type="Control" parent="UI/CraftMenu"] anchor_left = 1.0 anchor_right = 1.0 diff --git a/tests/_run_inside.sh b/tests/_run_inside.sh index c6f68e6..a63c666 100644 --- a/tests/_run_inside.sh +++ b/tests/_run_inside.sh @@ -13,6 +13,11 @@ set -uo pipefail PROJECT_DIR="$1"; shift JOBS="$1"; shift VERBOSE="$1"; shift +# A broken test can otherwise leave SceneTree alive after an initialization +# error, printing the same error every frame forever. Gameplay tests normally +# complete in seconds, even on a busy phone, so make that failure explicit +# rather than making the suite look hung. Override for deliberate long runs. +TEST_TIMEOUT_SECONDS="${TEST_TIMEOUT_SECONDS:-120}" GODOT_BIN="/root/Godot_v4.7.1-stable_linux.arm64" cd "$PROJECT_DIR" || exit 2 @@ -26,6 +31,8 @@ fi outdir="$(mktemp -d)" trap 'rm -rf "$outdir"' EXIT +REPORT_DIR="${TEST_REPORT_DIR:-$PROJECT_DIR/.test-results}" +mkdir -p "$REPORT_DIR" # Import once, serially, before anything runs in parallel. Godot writes to # .godot/ while importing; several instances discovering a cold cache at the @@ -46,7 +53,8 @@ run_one() { # 3 parallel runs. Pinned, a test simulates the same 60fps whatever else # the phone is doing, and its result no longer depends on how busy the # machine was. - "$GODOT_BIN" --headless --fixed-fps 60 -s "res://tests/$name" >"$log" 2>&1 + timeout "$TEST_TIMEOUT_SECONDS" "$GODOT_BIN" --headless --fixed-fps 60 \ + -s "res://tests/$name" >"$log" 2>&1 local rc=$? end=$(date +%s%N) local ms=$(( (end - start) / 1000000 )) @@ -57,6 +65,16 @@ run_one() { # while the log says otherwise. if (( rc == 0 )) && grep -q ": PASS" "$log"; then printf 'PASS %6dms %s\n' "$ms" "$name" > "$outdir/$name.status" + elif (( rc == 124 )); then + printf 'TIMEOUT %4dms %s (limit %ss)\n' \ + "$ms" "$name" "$TEST_TIMEOUT_SECONDS" > "$outdir/$name.status" + elif (( rc == 137 || rc == 139 )); then + # Killed, not failed - almost always the kernel reclaiming memory. Worth + # saying plainly: a killed worker and a failing one both simply produced + # no verdict before, so a whole suite dying to the OOM killer was + # indistinguishable from a hang. + printf 'KILLED %4dms %s (signal %d - out of memory? try JOBS=1)\n' \ + "$ms" "$name" "$(( rc - 128 ))" > "$outdir/$name.status" else printf 'FAIL %6dms %s (exit %d)\n' "$ms" "$name" "$rc" > "$outdir/$name.status" fi @@ -90,4 +108,9 @@ done echo echo "$(( total - failed ))/$total passed" +# Keep the concise verdicts after the temporary logs are removed. This is +# especially useful from Termux, where a long-running child can outlive the +# terminal command that launched it and its final buffered output is lost. +rm -f "$REPORT_DIR"/*.status +cp "$outdir"/*.status "$REPORT_DIR"/ (( failed == 0 )) diff --git a/tests/run_tests.sh b/tests/run_tests.sh index 6e5e7d8..4716917 100644 --- a/tests/run_tests.sh +++ b/tests/run_tests.sh @@ -32,9 +32,36 @@ set -uo pipefail PROJECT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -# Leave headroom: Godot is memory-hungry and this is a phone, not a build -# server. Override with JOBS= if you know better. -JOBS="${JOBS:-6}" +# Worker count comes from FREE MEMORY, not core count. Each Godot instance is +# ~400MB and this phone shares its RAM with everything else running on it. +# Getting it wrong is bad in both directions: +# - too many and the kernel SIGKILLs the runner mid-suite (exit 137), which +# looks exactly like a hang +# - too many under swap pressure and every worker thrashes: the same suite +# measured 100-190s with memory free, and 888s while 9GB sat in swap +# One worker per ~900MB available, capped at 6, and forced to 1 once swap is +# more than half consumed - past that, parallelism costs more than it buys. +# Override with JOBS= when you know better. +_pick_jobs() { + local avail swap_total swap_free n + avail=$(awk '/MemAvailable/ {print int($2 / 1024)}' /proc/meminfo 2>/dev/null) + swap_total=$(awk '/SwapTotal/ {print $2}' /proc/meminfo 2>/dev/null) + swap_free=$(awk '/SwapFree/ {print $2}' /proc/meminfo 2>/dev/null) + if [[ -z "$avail" ]]; then + echo 2 + return + fi + if [[ -n "$swap_total" ]] && (( swap_total > 0 )) \ + && (( (swap_total - swap_free) * 2 > swap_total )); then + echo 1 + return + fi + n=$(( avail / 900 )) + (( n < 1 )) && n=1 + (( n > 6 )) && n=6 + echo "$n" +} +JOBS="${JOBS:-$(_pick_jobs)}" VERBOSE="${VERBOSE:-0}" IN_PLACE="${IN_PLACE:-0}" WORK_DIR="${WORK_DIR:-$HOME/.cache/spaceship-tests}" diff --git a/tests/test_building_stock_queue.gd b/tests/test_building_stock_queue.gd index a2beec4..1ab1137 100644 --- a/tests/test_building_stock_queue.gd +++ b/tests/test_building_stock_queue.gd @@ -2,28 +2,29 @@ extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_building_stock_queue.gd -# Forgejo #16 (part 3 of #12): a building produces out of its OWN stock, and -# an order it cannot pay for yet is queued rather than refused. +# Forgejo #16 (part 3 of #12), updated for #23: a building produces out of +# its OWN stock, an order it cannot pay for yet is queued rather than +# refused, and building now TAKES TIME. # -# Before this, BuildController deducted from the bank at the moment of -# purchase and refused outright with "Not enough rocks", so nothing ever had -# to be delivered anywhere. +# What #23 changed here: orders no longer complete the instant the ore +# lands. The head of the queue consumes its ore, builds for build_time, and +# only then finishes - and it finishes into the mothership hangar rather +# than straight into the fleet. So a queue is something the player can watch +# and cancel, and a full fleet no longer stops a crafter working. # # Verifies: # 1. ordering with an empty stock queues instead of failing, and produces # nothing yet # 2. it does NOT quietly take the cost out of the bank - the whole point is # that the building has to be supplied -# 3. delivering enough stock completes the order on its own -# 4. one delivery covering several queued orders clears them all, rather -# than dribbling out one per frame +# 3. delivering enough stock starts the build, and it completes on its own +# 4. orders build ONE AT A TIME in order, not all at once the moment a big +# delivery covers them # 5. a part-delivery is not enough: stock below the cost leaves the order # queued and the ore untouched -# 6. stock and queue survive the space scene being torn down (the trip to -# a planet's surface) -# 7. production completes with NO fleet in existence, and the fleet picks -# the vessel up when it comes back - orders must not depend on which -# scene the player is standing in +# 6. stock and queue survive the space scene being torn down +# 7. a build completes with NO fleet in existence, into the hangar, and +# waits there - it is not lost, and not silently crewed either var game_state: Node var scene: Node @@ -34,6 +35,7 @@ var stage: int = 0 var elapsed: float = 0.0 var ships_at_start: int = 0 var bank_at_start: int = 0 +var stored_at_start: int = 0 var ticks: int = 0 const CRAFTER_AT: Vector2 = Vector2(900.0, 0.0) @@ -43,13 +45,12 @@ func _initialize() -> void: game_state = root.get_node("GameState") game_state.buildings.clear() game_state.extractors.clear() + game_state.stored_vessels.clear() game_state.rocks = 500 game_state.fleet_cargo = 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 - # rather than the queue. + # Real build times are 10s+; the timed path is what matters here, not the + # wall clock. See GameState.build_time_scale. + game_state.build_time_scale = 0.02 game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) @@ -57,6 +58,10 @@ func _initialize() -> void: field = scene.get_node("BuildingField") +func _build_time() -> float: + return game_state.build_time(GameState.PRODUCT_SHIP) + + func _process(delta: float) -> bool: elapsed += delta match stage: @@ -90,7 +95,7 @@ func _process(delta: float) -> bool: print("TEST_BUILDING_STOCK_QUEUE: FAIL (order did not stay queued: %d pending)" % game_state.pending_orders(crafter_id)) quit(1) return true - if fleet.ships.size() != ships_at_start: + if not game_state.stored_vessels.is_empty(): print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel appeared with an empty stock)") quit(1) return true @@ -98,8 +103,8 @@ func _process(delta: float) -> bool: print("TEST_BUILDING_STOCK_QUEUE: FAIL (queueing took %d out of the bank - the building is supposed to be supplied)" % (bank_at_start - game_state.rocks)) quit(1) return true - print("empty stock -> queued=%d ships=%d bank=%d (untouched)" % [ - game_state.pending_orders(crafter_id), fleet.ships.size(), game_state.rocks + print("empty stock -> queued=%d stored=%d bank=%d (untouched)" % [ + game_state.pending_orders(crafter_id), game_state.stored_vessels.size(), game_state.rocks ]) # --- 5. a part-delivery is not enough ----------------------- @@ -110,7 +115,7 @@ func _process(delta: float) -> bool: 3: if elapsed < 0.3: return false - if game_state.pending_orders(crafter_id) != 1 or fleet.ships.size() != ships_at_start: + if game_state.pending_orders(crafter_id) != 1 or not game_state.stored_vessels.is_empty(): print("TEST_BUILDING_STOCK_QUEUE: FAIL (produced on a part-delivery of %d against a cost of %d)" % [ GameState.SHIP_COST - 1, GameState.SHIP_COST ]) @@ -124,70 +129,95 @@ func _process(delta: float) -> bool: game_state.building_stock(crafter_id), GameState.SHIP_COST ]) - # --- 3. topping it up completes the order ------------------- + # --- 3. topping it up starts the build ---------------------- game_state.deposit_to_building(crafter_id, 1) elapsed = 0.0 stage = 4 return false 4: - if elapsed < 0.4: + # One tick: the build must have STARTED (ore taken) but not yet + # finished, or "takes time" means nothing. + ticks += 1 + if ticks < 2: return false - if game_state.pending_orders(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (order not completed once the stock arrived)") - quit(1) - return true - if fleet.ships.size() != ships_at_start + 1: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (no vessel delivered: %d -> %d)" % [ - ships_at_start, fleet.ships.size() - ]) + var queue: Array = game_state.order_list(crafter_id) + if queue.is_empty() or not bool(queue[0].started): + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a fully funded order never started building)") quit(1) return true if game_state.building_stock(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (stock not consumed: %d left)" % game_state.building_stock(crafter_id)) + print("TEST_BUILDING_STOCK_QUEUE: FAIL (starting the build did not consume the ore)") quit(1) return true - print("supplied -> ships %d->%d, stock spent" % [ships_at_start, fleet.ships.size()]) + if not game_state.stored_vessels.is_empty(): + print("TEST_BUILDING_STOCK_QUEUE: FAIL (the vessel appeared instantly - builds are supposed to take time)") + quit(1) + return true + print("funded -> building started, ore consumed, nothing delivered yet") + elapsed = 0.0 + stage = 5 + return false + 5: + if elapsed < _build_time() * 2.0: + return false + if game_state.pending_orders(crafter_id) != 0: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (build never finished)") + quit(1) + return true + if game_state.stored_vessels.size() != 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (finished build did not reach the hangar: %d stored)" % game_state.stored_vessels.size()) + quit(1) + return true + if fleet.ships.size() != ships_at_start: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (the vessel crewed itself - the player is supposed to pick it up)") + quit(1) + return true + print("built -> 1 vessel waiting in the hangar, fleet untouched at %d" % fleet.ships.size()) - # --- 4. one delivery clears ALL the orders it covers --------- - # Measured in TICKS, not seconds, and with more orders than the - # ticks allowed: production that handled one order per frame - # would still clear them all inside any generous wall-clock wait, - # so a timed check cannot tell "cleared at once" from "dribbled - # out". Three orders, two ticks - only clearing them together - # passes. - ships_at_start = fleet.ships.size() + # --- 4. one at a time, in order ----------------------------- + stored_at_start = game_state.stored_vessels.size() game_state.queue_order(crafter_id) game_state.queue_order(crafter_id) game_state.queue_order(crafter_id) game_state.deposit_to_building(crafter_id, GameState.SHIP_COST * 3) - ticks = 0 - stage = 5 - return false - 5: - ticks += 1 - if ticks < 2: - return false - if game_state.pending_orders(crafter_id) != 0 or fleet.ships.size() != ships_at_start + 3: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (a delivery covering 3 orders left %d queued after %d ticks, ships %d->%d)" % [ - game_state.pending_orders(crafter_id), ticks, ships_at_start, fleet.ships.size() - ]) - quit(1) - return true - print("one delivery cleared 3 orders in %d ticks -> ships %d->%d" % [ - ticks, ships_at_start, fleet.ships.size() - ]) - - # --- 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 vessels_owed - # back at 0 and prove nothing about orders surviving without it. - ships_at_start = fleet.ships.size() - scene.queue_free() elapsed = 0.0 stage = 6 return false 6: + # Enough time for exactly one build, and no more. Funding all + # three at once must NOT finish all three at once - that is the + # behaviour #23 replaced. + if elapsed < _build_time() * 1.4: + return false + var done: int = game_state.stored_vessels.size() - stored_at_start + if done != 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (after one build time, %d of 3 orders finished - they are not building one at a time)" % done) + quit(1) + return true + if game_state.pending_orders(crafter_id) != 2: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (%d orders left, expected 2 still queued)" % game_state.pending_orders(crafter_id)) + quit(1) + return true + print("one build time -> 1 of 3 done, 2 still queued (sequential)") + elapsed = 0.0 + stage = 7 + return false + 7: + if elapsed < _build_time() * 4.0: + return false + if game_state.stored_vessels.size() - stored_at_start != 3: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (the rest of the queue never drained: %d of 3)" % (game_state.stored_vessels.size() - stored_at_start)) + quit(1) + return true + print("queue drained in order -> 3 vessels waiting") + + # --- 6 + 7. no scene, no fleet ------------------------------ + stored_at_start = game_state.stored_vessels.size() + scene.queue_free() + elapsed = 0.0 + stage = 8 + return false + 8: if elapsed < 0.3: return false if is_instance_valid(fleet): @@ -195,39 +225,22 @@ func _process(delta: float) -> bool: game_state.queue_order(crafter_id) game_state.deposit_to_building(crafter_id, GameState.SHIP_COST) elapsed = 0.0 - stage = 61 + stage = 9 return false - 61: - if elapsed < 0.4: + 9: + if elapsed < _build_time() * 3.0: return false - # The queue and stock live in the autoload, so tearing the space - # scene down must not touch them - and the order must complete - # even with no Fleet node anywhere to hand the vessel to. + # The queue, the stock and the hangar live in the autoload, so + # tearing the space scene down must not touch them. if game_state.pending_orders(crafter_id) != 0: - print("TEST_BUILDING_STOCK_QUEUE: FAIL (order stalled once the space scene was gone)") + print("TEST_BUILDING_STOCK_QUEUE: FAIL (build stalled once the space scene was gone)") quit(1) return true - if game_state.vessels_owed.is_empty(): - print("TEST_BUILDING_STOCK_QUEUE: FAIL (vessel completed with no fleet present was lost, not owed)") + if game_state.stored_vessels.size() != stored_at_start + 1: + print("TEST_BUILDING_STOCK_QUEUE: FAIL (a vessel built with no fleet present was lost)") quit(1) return true - 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() - root.add_child(scene) - fleet = scene.get_node("Fleet") - elapsed = 0.0 - stage = 8 - return false - 8: - if elapsed < 0.5: - return false - 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()) + print("no scene at all -> build completed into the hangar (%d waiting)" % game_state.stored_vessels.size()) print("TEST_BUILDING_STOCK_QUEUE: PASS") quit(0) return true @@ -235,10 +248,6 @@ func _process(delta: float) -> bool: func _clear_hazards() -> void: - # set_process(false) on AsteroidField only stops NEW chunks spawning - - # rocks already in the world keep drifting and can destroy a parked ship, - # which silently changes the fleet size this test asserts on. This is a - # logistics test, not a survival one. for a in get_nodes_in_group("asteroids"): if is_instance_valid(a): a.queue_free() diff --git a/tests/test_crafter_feedback.gd b/tests/test_crafter_feedback.gd index 3e779fc..2cdf4a5 100644 --- a/tests/test_crafter_feedback.gd +++ b/tests/test_crafter_feedback.gd @@ -50,7 +50,8 @@ func _initialize() -> void: game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] game_state.rocks = 100 game_state.fleet_cargo = 0 - game_state.vessels_owed.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 scene = load("res://Main.tscn").instantiate() root.add_child(scene) controller = scene.get_node("BuildController") diff --git a/tests/test_hub_capacity.gd b/tests/test_hub_capacity.gd index a072586..4c795d9 100644 --- a/tests/test_hub_capacity.gd +++ b/tests/test_hub_capacity.gd @@ -34,7 +34,8 @@ func _initialize() -> void: game_state.transporters.clear() game_state.rocks = 900 game_state.fleet_cargo = 0 - game_state.vessels_owed.clear() + 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) diff --git a/tests/test_manual_supply_route.gd b/tests/test_manual_supply_route.gd index c9181d9..5ae541f 100644 --- a/tests/test_manual_supply_route.gd +++ b/tests/test_manual_supply_route.gd @@ -52,7 +52,8 @@ func _initialize() -> void: game_state.extractors.clear() game_state.rocks = 40 game_state.fleet_cargo = 0 - game_state.vessels_owed.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) @@ -161,20 +162,21 @@ func _process(delta: float) -> bool: stage = 6 return false 6: - if elapsed < 0.5: + # Long enough for the build itself, which #23 made take time. + if elapsed < game_state.build_time(GameState.PRODUCT_SHIP) * 3.0: return false # --- 5. the queued order completed off the fleet's own ore --- if game_state.pending_orders(crafter_id) != 0: print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (order still queued after a hand delivery)") quit(1) return true - if fleet.ships.size() != ships_before + 1: - print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel: %d -> %d)" % [ships_before, fleet.ships.size()]) + # Since #23 a finished vessel waits in the hangar rather than + # crewing itself, so this is where it lands. + if game_state.stored_vessels.size() != 1: + print("TEST_MANUAL_SUPPLY_ROUTE: FAIL (no vessel built: %d in the hangar)" % game_state.stored_vessels.size()) quit(1) return true - print("queued order fulfilled by hand, no transport hub: ships %d->%d" % [ - ships_before, fleet.ships.size() - ]) + print("queued order fulfilled by hand, no transport hub: %d vessel in the hangar" % game_state.stored_vessels.size()) # --- 6. conservation ---------------------------------------- var expected: int = total_before - GameState.SHIP_COST diff --git a/tests/test_resource_transfer.gd b/tests/test_resource_transfer.gd index 4f0d2bd..ea25cb1 100644 --- a/tests/test_resource_transfer.gd +++ b/tests/test_resource_transfer.gd @@ -50,7 +50,7 @@ func _initialize() -> void: game_state.transporters.clear() game_state.rocks = 100 game_state.fleet_cargo = 0 - game_state.vessels_owed.clear() + game_state.stored_vessels.clear() game_state.unlocked = ["shipyards", "coordination", "wing", "squadron", "armada"] scene = load("res://Main.tscn").instantiate() root.add_child(scene) diff --git a/tests/test_shield_vessel.gd b/tests/test_shield_vessel.gd index bab46c3..a902b40 100644 --- a/tests/test_shield_vessel.gd +++ b/tests/test_shield_vessel.gd @@ -34,7 +34,8 @@ func _initialize() -> void: game_state.buildings.clear() game_state.extractors.clear() game_state.transporters.clear() - game_state.vessels_owed.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 2000 scene = load("res://Main.tscn").instantiate() diff --git a/tests/test_ship_lab.gd b/tests/test_ship_lab.gd index 8c3b91c..84003f7 100644 --- a/tests/test_ship_lab.gd +++ b/tests/test_ship_lab.gd @@ -35,7 +35,8 @@ func _initialize() -> void: game_state.buildings.clear() game_state.extractors.clear() game_state.transporters.clear() - game_state.vessels_owed.clear() + game_state.stored_vessels.clear() + game_state.build_time_scale = 0.02 game_state.reset_progression() game_state.rocks = 1200 game_state.fleet_cargo = 0 @@ -154,16 +155,25 @@ func _process(delta: float) -> bool: stage = 2 return false 2: - if elapsed < 0.6: + if elapsed < game_state.build_time(GameState.VESSEL_SHIELD) * 3.0: return false + # Finished vessels wait at the mothership (#23); crew it here so + # the rest of the checks can look at a real ship. + if game_state.stored_vessels.size() != 1: + print("TEST_SHIP_LAB: FAIL (the shield order produced nothing: %s)" % [game_state.stored_vessels]) + quit(1) + return true + if String(game_state.stored_vessels[0]) != GameState.VESSEL_SHIELD: + print("TEST_SHIP_LAB: FAIL (a shield order came back as %s)" % game_state.stored_vessels[0]) + quit(1) + return true + fleet.add_ship(game_state.take_stored_vessel(0)) var shield_ship: Node = null for ship in fleet.ships: if is_instance_valid(ship) and ship.vessel_type == GameState.VESSEL_SHIELD: shield_ship = ship if shield_ship == null: - print("TEST_SHIP_LAB: FAIL (the shield order produced no shield vessel: %d ships, owed %s)" % [ - fleet.ships.size(), game_state.vessels_owed - ]) + print("TEST_SHIP_LAB: FAIL (crewing the stored shield vessel produced none: %d ships)" % fleet.ships.size()) quit(1) return true if not shield_ship.has_shield(): diff --git a/tests/test_transporter_supply.gd b/tests/test_transporter_supply.gd index 2787cfc..8130e16 100644 --- a/tests/test_transporter_supply.gd +++ b/tests/test_transporter_supply.gd @@ -46,6 +46,14 @@ func _total() -> int: sum += int(b.get("stock", 0)) 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 @@ -56,7 +64,8 @@ func _initialize() -> void: game_state.transporters.clear() game_state.rocks = 200 game_state.fleet_cargo = 0 - game_state.vessels_owed.clear() + 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) @@ -118,7 +127,7 @@ func _process(delta: float) -> bool: total_at_start = _total() return false 2: - if elapsed < 0.3: + 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())