Garrison laser vessels and gas bombers, on a shared ability seam

#105 shipped with interceptors as the only garrisonable vessel, and the
reason was a design fault rather than a decision: firing the laser and the
gas bomb lived in the player's fleet controllers, so a stationed laser would
have held its position and never shot.

The shot is now a thing in the world rather than something a controller
draws. LaserBeamEffect and GasBombEffect are self-contained, self-animating
and self-freeing, added to the tree root - the same shape EnemyBigLaserBeam
and ArtilleryShell already had on the enemy side - and they outlive the
vessel that fired them.

Above them sits one seam, on VesselCooldownFillComponent: ability_range(),
ability_ammo_resource(), ability_ammo_cost() and fire_ability(). A beam and a
bomb are now the same shape of thing to whoever pulls the trigger, so the
garrison AI never names an ability - the test asserts that literally, by
reading the AI's own source. A third armed vessel needs to implement the
seam and nothing else.

Ammunition is the CALLER's business, deliberately: the fleet spends from its
hold, a garrison from its outpost's store, and neither concern belongs in
the component. A garrison with an empty magazine holds fire rather than
falling back on the fleet's hold.

Outpost storage: 50 gas per stationed bomber and 50 energy per stationed
laser, off the VESSEL definitions, so an outpost never learns which vessel
needs what. It is a capacity, not a stock - hub transporters fill it exactly
as they fill a gas burner's buffer, because building_logistics_deficit now
takes the larger of the definition's static buffer and the garrison's
magazine. Every other resource is capped at zero: an outpost is not a depot,
and ore parked at one has nothing to do there and no transporter coming for
it.

Also restores two properties the Godot editor dropped from
GasBurnerBuildingDefinition.tres when it re-saved the file:
collected_outputs and capabilities. Without the first, nothing ever collects
the burner's energy - test_gas_burner caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Loïc Wisniewski 2026-08-23 19:23:35 +02:00
commit b50597b757
16 changed files with 809 additions and 209 deletions

View file

@ -221,6 +221,19 @@ func _inventory_for_record(record: Dictionary) -> RefCounted:
if inventory == null:
inventory = INVENTORY.new()
record.inventory = inventory
# An outpost holds exactly what its garrison can use, and the garrison
# changes. Re-applied on every access rather than when the crew changes,
# because there is no single place a crew change goes through - a vessel
# arriving, one being released and one being shot down are three.
if is_outpost(record):
# Every resource gets a ceiling, not only the ones a magazine wants:
# an outpost is not a depot, and ore parked at one has nothing to do
# there and no transporter coming for it.
var capacities: Dictionary = {}
var magazines: Dictionary = garrison_magazines(int(record.id))
for resource_type in resource_types():
capacities[resource_type] = int(magazines.get(resource_type, 0))
inventory.resource_capacities = capacities
# Compatibility migration for state made before #33. Erase each legacy
# field once absorbed so it cannot drift out of step with the inventory.
if record.has("stock"):
@ -787,13 +800,14 @@ const GARRISON_BASE_SLOTS: int = 2
# 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.
#
# "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"]
# #105 shipped with "weapon" only, because firing the laser and the gas bomb
# lived in the player's controllers rather than in the vessels' own
# components - a garrisoned laser would have held its position and never
# shot. Both effects now live in the world as LaserBeamEffect/GasBombEffect
# and both components expose the same fire_ability() seam, so a garrison can
# pull either trigger and the list is no longer a apology for a missing
# feature.
const GARRISON_CAPABILITIES: PackedStringArray = ["weapon", "laser", "gas_bomber"]
func outpost_slot_capacity() -> int:
@ -876,6 +890,56 @@ func garrison_crew(id: int) -> Array:
return b.garrison_crew
# --- what a garrison keeps beside it ---------------------------------------
#
# An outpost stores ammunition for the vessels actually stationed there: 50
# gas for each gas bomber, 50 energy for each laser. The numbers come off the
# VESSEL definitions, so the outpost never learns which vessel needs what -
# add an armed vessel with a magazine and its outpost storage follows.
#
# This is a CAPACITY, not a stock. Hub transporters fill it the same way they
# fill a gas burner's buffer (see building_logistics_deficit), and the
# garrison AI spends from it.
func garrison_magazine_capacity(id: int, resource_type: String) -> int:
var total: int = 0
for vessel_type in garrison_crew(id):
var definition: Resource = vessel_definition(String(vessel_type))
if definition == null:
continue
if String(definition.garrison_ammo_resource) != resource_type:
continue
total += int(definition.garrison_ammo_capacity)
return total
# Every resource this outpost wants held, as {resource_type: capacity}.
func garrison_magazines(id: int) -> Dictionary:
var out: Dictionary = {}
for vessel_type in garrison_crew(id):
var definition: Resource = vessel_definition(String(vessel_type))
if definition == null:
continue
var resource_type: String = String(definition.garrison_ammo_resource)
if resource_type == "" or int(definition.garrison_ammo_capacity) <= 0:
continue
out[resource_type] = int(out.get(resource_type, 0)) \
+ int(definition.garrison_ammo_capacity)
return out
# Spends from the outpost's own store. Returns false when it cannot pay in
# full - a half-priced shot would be worse than no shot.
func spend_garrison_ammo(id: int, resource_type: String, amount: int) -> bool:
if amount <= 0:
return true
var inventory: RefCounted = building_inventory(id)
if inventory == null or inventory.amount(resource_type) < amount:
return false
inventory.withdraw(resource_type, amount)
return true
func garrison_crew_of(id: int, vessel_type: String) -> int:
var n: int = 0
for crewed in garrison_crew(id):
@ -1862,10 +1926,14 @@ func _extractor_inventory(extractor: Dictionary) -> RefCounted:
func building_logistics_deficit(id: int, resource_type: String) -> int:
var building: Dictionary = find_building(id)
var target: int = 0
var profile: Resource = building_logistics_profile(building)
if profile == null:
return 0
var target: int = int(profile.input_buffers.get(resource_type, 0))
if profile != null:
target = int(profile.input_buffers.get(resource_type, 0))
# An outpost has no logistics profile at all - what it wants held depends
# on who is stationed there, which is not something a definition can
# state. Taking the larger of the two keeps the static case untouched.
target = maxi(target, garrison_magazine_capacity(id, resource_type))
return maxi(0, target - building_resource(id, resource_type))