diff --git a/core/GameState.gd b/core/GameState.gd index 72d0407..038cbf8 100644 --- a/core/GameState.gd +++ b/core/GameState.gd @@ -768,7 +768,14 @@ const GARRISON_BASE_SLOTS: int = 2 # much the player might want the shield out there. Written as capabilities # rather than as a list of types, so a new armed vessel is eligible the day # it is added and an unarmed one never is by accident. -const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser"] +# +# "weapon" only, and the laser deliberately left out (#105). A garrison +# vessel has to fight WITHOUT the player, and firing the laser lives in +# LaserController - a fleet ability driven by the laser button - rather than +# in VesselLaserComponent, which only tracks its cooldown and ammunition. A +# garrisoned laser vessel would therefore hold its position and never shoot. +# Offering a vessel that cannot fight is worse than a short list. +const GARRISON_CAPABILITIES: PackedStringArray = ["weapon"] func outpost_slot_capacity() -> int: @@ -865,13 +872,34 @@ func garrison_crew_of(id: int, vessel_type: String) -> int: func garrison_shortfall(id: int) -> Dictionary: var out: Dictionary = {} for vessel_type in garrison_desired(id): + # What is already on its way counts. Without this the dispatcher + # would send the whole hangar to fill one berth, because the crew + # does not grow until the first arrival lands. var missing: int = int(garrison_desired(id)[vessel_type]) \ - - garrison_crew_of(id, String(vessel_type)) + - garrison_crew_of(id, String(vessel_type)) \ + - garrison_inbound_of(id, String(vessel_type)) if missing > 0: out[vessel_type] = missing return out +func garrison_inbound_of(id: int, vessel_type: String) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if int(delivery.get("to_outpost", 0)) == id \ + and String(delivery.type) == vessel_type: + n += 1 + return n + + +func garrison_inbound(id: int) -> int: + var n: int = 0 + for delivery in vessel_deliveries: + if int(delivery.get("to_outpost", 0)) == id: + n += 1 + return n + + func outpost_ids() -> Array: var out: Array = [] for b in buildings: @@ -1301,6 +1329,74 @@ func _advance_production(delta: float) -> void: order_completed.emit(int(b.id), product) +# --- crewing an outpost from the mothership (#104) -------------------------- +# +# "the game should transfer, whenever available, ships from the mothership to +# the outpost to always fullfill the outpost conditions if possible." +# +# Vessels come from the HANGAR - stored_vessels - and never from the fleet. +# The fleet is what the player is flying; taking a vessel out of it to crew a +# building would be the game undoing the player's own arrangement. "Whenever +# available" therefore means "when one is sitting at the mothership", and an +# outpost asking for a hull nobody has built simply waits. +# +# They travel rather than teleporting, down the same vessel_deliveries path a +# finished crafter order already uses to reach the mothership. + +func _garrison_still_wants(id: int, vessel_type: String) -> bool: + var b: Dictionary = find_building(id) + if b.is_empty() or not is_outpost(b): + return false + return garrison_crew_of(id, vessel_type) < garrison_desired_of(id, vessel_type) + + +func _take_stored_vessel(vessel_type: String) -> bool: + var index: int = stored_vessels.find(vessel_type) + if index < 0: + return false + stored_vessels.remove_at(index) + return true + + +func _advance_garrison_dispatch() -> void: + var mothership: Variant = get_mothership_position() + if mothership == null: + return + for b in buildings: + if not is_outpost(b): + continue + var id: int = int(b.id) + # Over strength first: an order the player has just lowered should + # release the vessel back to the hangar rather than stranding it. + _return_surplus_garrison(b) + for vessel_type in garrison_shortfall(id): + var needed: int = int(garrison_shortfall(id)[vessel_type]) + for i in range(needed): + if not _take_stored_vessel(String(vessel_type)): + break + vessel_deliveries.append({ + "type": String(vessel_type), "from": mothership, + "to": b.position, "progress": 0.0, "to_outpost": id, + }) + + +func _return_surplus_garrison(b: Dictionary) -> void: + var id: int = int(b.id) + var mothership: Variant = get_mothership_position() + if mothership == null: + return + var crew: Array = garrison_crew(id) + for i in range(crew.size() - 1, -1, -1): + var vessel_type: String = String(crew[i]) + if garrison_crew_of(id, vessel_type) <= garrison_desired_of(id, vessel_type): + continue + crew.remove_at(i) + vessel_deliveries.append({ + "type": vessel_type, "from": b.position, "to": mothership, + "progress": 0.0, "to_outpost": 0, + }) + + func queue_lab_research(lab_id: int, upgrade_id: String) -> bool: var lab: Dictionary = find_building(lab_id) if not building_has_capability(lab, "vessel_research") or upgrade_id == "": @@ -1894,6 +1990,7 @@ func _process(delta: float) -> void: _advance_production(delta) _advance_lab_research(delta) _advance_vessel_deliveries(delta) + _advance_garrison_dispatch() _advance_resource_converters(delta) _advance_distorder_drain(delta) for e in extractors: @@ -1918,9 +2015,19 @@ func _advance_vessel_deliveries(delta: float) -> void: if delivery_speed <= 0.0: delivery_speed = VESSEL_DELIVERY_SPEED delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001) - if float(delivery.progress) >= 1.0: + if float(delivery.progress) < 1.0: + continue + vessel_deliveries.remove_at(i) + # #104: a vessel bound for an outpost joins its crew. If the outpost + # was levelled while it was in the air, or the order was cancelled + # under it, it lands in the hangar instead - the vessel is never + # destroyed by its destination going away, the same rule a + # transporter mid-delivery follows with its cargo. + var outpost_id: int = int(delivery.get("to_outpost", 0)) + if outpost_id != 0 and _garrison_still_wants(outpost_id, String(delivery.type)): + garrison_crew(outpost_id).append(String(delivery.type)) + else: stored_vessels.append(String(delivery.type)) - vessel_deliveries.remove_at(i) # --- transporters (#19) --------------------------------------------------- diff --git a/tests/test_outpost_crewing.gd b/tests/test_outpost_crewing.gd new file mode 100644 index 0000000..816d53e --- /dev/null +++ b/tests/test_outpost_crewing.gd @@ -0,0 +1,160 @@ +extends SceneTree + +# Headless test: run with +# godot --headless -s res://tests/test_outpost_crewing.gd +# Forgejo #104 (#97f): "When an outpost is configured to have a given amount +# of ship, the game should transfer, whenever available, ships from the +# mothership to the outpost to always fullfill the outpost conditions if +# possible." +# +# Vessels come from the HANGAR and never from the fleet: the fleet is what +# the player is flying, and taking a vessel out of it to crew a building +# would be the game undoing the player's own arrangement. +# +# Covers: +# A. an order with nothing in the hangar waits. It does not conjure a +# vessel, and it does not take one out of the fleet +# B. a matching vessel in the hangar is dispatched, travels, and joins the +# crew on arrival +# C. it dispatches exactly the shortfall - what is already in the air +# counts, or the whole hangar leaves to fill one berth +# D. lowering the order releases the surplus back to the hangar +# E. an outpost levelled while a vessel is inbound does not destroy it: +# the vessel lands in the hangar instead + +var game_state: Node +var outpost_id: int = 0 +var second_id: int = 0 +var stage: int = 0 +var elapsed: float = 0.0 +var fleet_before: int = 0 + + +func _initialize() -> void: + # GameState is an autoload: a bare GameState. does not + # compile in a SceneTree script, so the node is fetched by name instead. + game_state = root.get_node("GameState") + game_state.buildings.clear() + game_state.extractors.clear() + game_state.transporters.clear() + game_state.stored_vessels.clear() + game_state.vessel_deliveries.clear() + game_state.reset_progression() + game_state.reset_waves() + game_state.unlocked.append("forward_bases") + game_state.add_building(GameState.BUILDING_MOTHERSHIP, Vector2.ZERO) + outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(1200.0, 0.0)) + + +func _fail(message: String) -> bool: + print("TEST_OUTPOST_CREWING: FAIL (%s)" % message) + quit(1) + return true + + +# Runs the autoload's own clock without waiting out the real seconds: this +# test is about the dispatcher, not about how long a vessel takes to fly. +func _simulate(seconds: float) -> void: + var step: float = 1.0 / 60.0 + var ticks: int = int(seconds / step) + for i in range(ticks): + game_state._advance_vessel_deliveries(step) + game_state._advance_garrison_dispatch() + + +func _process(_delta: float) -> bool: + # --- A. an order nobody can fill waits ------------------------------ + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 2) + if game_state.garrison_desired_total(outpost_id) != 2: + return _fail("the order did not take") + _simulate(20.0) + if not game_state.garrison_crew(outpost_id).is_empty(): + return _fail("an outpost crewed itself out of an empty hangar") + if not game_state.vessel_deliveries.is_empty(): + return _fail("something was dispatched with nothing in the hangar") + if game_state.garrison_desired_total(outpost_id) != 2: + return _fail("the unfillable order was dropped instead of waiting") + print("an order of 2 with an empty hangar waits, and stays an order") + + # --- B. a hull in the hangar is sent --------------------------------- + game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR) + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("a vessel in the hangar produced %d deliveries" \ + % game_state.vessel_deliveries.size()) + if not game_state.stored_vessels.is_empty(): + return _fail("the vessel was dispatched and is still in the hangar too") + var delivery: Dictionary = game_state.vessel_deliveries[0] + if int(delivery.get("to_outpost", 0)) != outpost_id: + return _fail("the delivery is not bound for the outpost") + if float(delivery.progress) >= 1.0: + return _fail("the vessel teleported - it should have to fly there") + _simulate(30.0) + if game_state.garrison_crew_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: + return _fail("the vessel never arrived (crew=%s)" \ + % str(game_state.garrison_crew(outpost_id))) + print("one hull in the hangar flies out and joins the crew") + + # --- C. exactly the shortfall --------------------------------------- + for i in range(6): + game_state.stored_vessels.append(GameState.VESSEL_INTERCEPTOR) + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("one empty berth dispatched %d vessels" \ + % game_state.vessel_deliveries.size()) + # A second call before the first lands must not send another. + game_state._advance_garrison_dispatch() + if game_state.vessel_deliveries.size() != 1: + return _fail("the dispatcher sent a second vessel for a berth already in the air") + if game_state.stored_vessels.size() != 5: + return _fail("the hangar holds %d, expected 5 left" % game_state.stored_vessels.size()) + _simulate(30.0) + if game_state.garrison_crew(outpost_id).size() != 2: + return _fail("the outpost holds %d of its 2 berths" \ + % game_state.garrison_crew(outpost_id).size()) + game_state._advance_garrison_dispatch() + if not game_state.vessel_deliveries.is_empty(): + return _fail("a full outpost is still being sent vessels") + print("2 berths, 6 spare hulls: it sent exactly 1 and then stopped") + + # --- D. lowering the order releases the surplus ---------------------- + var hangar_before: int = game_state.stored_vessels.size() + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state._advance_garrison_dispatch() + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("lowering the order to 1 left %d aboard" \ + % game_state.garrison_crew(outpost_id).size()) + _simulate(30.0) + if game_state.stored_vessels.size() != hangar_before + 1: + return _fail("the released vessel did not come home (hangar %d -> %d)" \ + % [hangar_before, game_state.stored_vessels.size()]) + print("lowering the order flies the surplus back to the hangar") + + # --- E. a destination that stops existing --------------------------- + second_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(-1400.0, 0.0)) + game_state.set_garrison_desired(second_id, GameState.VESSEL_INTERCEPTOR, 1) + game_state._advance_garrison_dispatch() + if game_state.garrison_inbound(second_id) != 1: + return _fail("nothing was dispatched to the second outpost") + hangar_before = game_state.stored_vessels.size() + if not game_state.destroy_building(second_id): + return _fail("the second outpost could not be destroyed") + _simulate(30.0) + if game_state.stored_vessels.size() != hangar_before + 1: + return _fail("a vessel inbound to a destroyed outpost was lost") + print("a vessel whose outpost was levelled under it lands back in the hangar") + + # --- A (again). the fleet is never raided --------------------------- + game_state.stored_vessels.clear() + game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 3) + fleet_before = game_state.surviving_ship_count + _simulate(20.0) + if game_state.surviving_ship_count != fleet_before: + return _fail("the dispatcher took a vessel out of the fleet") + if game_state.garrison_crew(outpost_id).size() != 1: + return _fail("the outpost gained a vessel from nowhere") + print("with an empty hangar it takes nothing from the fleet") + + print("TEST_OUTPOST_CREWING: PASS") + quit(0) + return true