#104 (#97f): outposts are crewed automatically from the mothership

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. "Whenever available" means
"when one is sitting at the mothership", and an outpost asking for a hull
nobody has built simply waits.

They travel, down the same vessel_deliveries path a finished crafter order
already uses to reach the mothership, rather than teleporting.

What is already in the air counts against the shortfall. Without that the
dispatcher sends the whole hangar to fill one berth, because the crew does
not grow until the first arrival lands.

Two directions, not one: lowering the order flies the surplus home rather
than stranding it, and a vessel whose outpost is levelled under it lands in
the hangar instead of being destroyed - the same rule a transporter
mid-delivery already follows with its cargo.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 14:13:46 +02:00
commit 8122e280b7
2 changed files with 271 additions and 4 deletions

View file

@ -768,7 +768,14 @@ const GARRISON_BASE_SLOTS: int = 2
# much the player might want the shield out there. Written as capabilities # 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 # 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. # 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: 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: func garrison_shortfall(id: int) -> Dictionary:
var out: Dictionary = {} var out: Dictionary = {}
for vessel_type in garrison_desired(id): 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]) \ 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: if missing > 0:
out[vessel_type] = missing out[vessel_type] = missing
return out 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: func outpost_ids() -> Array:
var out: Array = [] var out: Array = []
for b in buildings: for b in buildings:
@ -1301,6 +1329,74 @@ func _advance_production(delta: float) -> void:
order_completed.emit(int(b.id), product) 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: func queue_lab_research(lab_id: int, upgrade_id: String) -> bool:
var lab: Dictionary = find_building(lab_id) var lab: Dictionary = find_building(lab_id)
if not building_has_capability(lab, "vessel_research") or upgrade_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_production(delta)
_advance_lab_research(delta) _advance_lab_research(delta)
_advance_vessel_deliveries(delta) _advance_vessel_deliveries(delta)
_advance_garrison_dispatch()
_advance_resource_converters(delta) _advance_resource_converters(delta)
_advance_distorder_drain(delta) _advance_distorder_drain(delta)
for e in extractors: for e in extractors:
@ -1918,9 +2015,19 @@ func _advance_vessel_deliveries(delta: float) -> void:
if delivery_speed <= 0.0: if delivery_speed <= 0.0:
delivery_speed = VESSEL_DELIVERY_SPEED delivery_speed = VESSEL_DELIVERY_SPEED
delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001) delivery.progress = float(delivery.progress) + delta / maxf(distance / delivery_speed, 0.001)
if float(delivery.progress) >= 1.0: if float(delivery.progress) < 1.0:
stored_vessels.append(String(delivery.type)) continue
vessel_deliveries.remove_at(i) 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))
# --- transporters (#19) --------------------------------------------------- # --- transporters (#19) ---------------------------------------------------

View file

@ -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.<instance member> 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