spaceship/tests/test_garrison_abilities.gd
Loïc Wisniewski b50597b757 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>
2026-08-23 19:23:35 +02:00

271 lines
11 KiB
GDScript

extends SceneTree
# Headless gameplay test: run with
# godot --headless -s res://tests/test_garrison_abilities.gd
#
# Laser vessels and gas bombers can be garrisoned, and fight from an outpost
# using the outpost's own ammunition.
#
# #105 shipped with interceptors only, because firing the laser and the bomb
# lived in the player's controllers rather than in the vessels' components -
# a garrisoned laser would have held its position and never shot. Both
# effects now live in the world (LaserBeamEffect / GasBombEffect) and both
# components expose the same fire_ability() seam, so the garrison AI pulls
# either trigger without knowing which it is.
#
# Covers:
# A. both are garrisonable, and the unarmed vessels still are not
# B. the outpost's magazine is 50 per vessel of the right resource, and it
# is the CREW that decides it - two bombers want 100 gas
# C. it cannot be overfilled, by a transporter or by hand
# D. an outpost short of ammunition asks for it, so hub logistics fill it
# E. a garrisoned laser fires on an enemy, damages it, and pays out of the
# OUTPOST's store rather than the fleet's hold
# F. with an empty store it holds fire - no free shots
# G. a garrisoned gas bomber lands a bomb, and pays in gas
# H. one abstraction, not two: the AI never names an ability
var game_state: Node
var main: Node
var fleet: Node
var field: Node
var outpost_id: int = 0
var guard: Node2D
var enemy: Node2D
var stage: int = 0
var elapsed: float = 0.0
var energy_before: int = 0
var gas_before: int = 0
var hp_before: int = 0
var fleet_energy_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.vessel_levels["unlock_laser"] = 1
game_state.vessel_levels["unlock_gas_bomber"] = 1
main = load("res://world/Main.tscn").instantiate()
root.add_child(main)
current_scene = main
fleet = main.get_node("Fleet")
field = main.get_node("GarrisonField")
func _fail(message: String) -> bool:
print("TEST_GARRISON_ABILITIES: FAIL (%s)" % message)
quit(1)
return true
func _dummy(at: Vector2) -> Node2D:
# Pinned and effectively immortal: the point is whether the garrison
# SHOOTS, and an enemy that died to the first hit would end the
# observation before the amount spent could be read.
var dummy: Node2D = load("res://enemies/Enemy.tscn").instantiate()
dummy.enemy_id = "chaser"
dummy.position = at
main.add_child(dummy)
dummy.speed = 0.0
dummy.hp = 100000
return dummy
func _station(vessel_type: String) -> void:
game_state.set_garrison_desired(outpost_id, vessel_type,
game_state.garrison_desired_of(outpost_id, vessel_type) + 1)
game_state.garrison_crew(outpost_id).append(vessel_type)
func _state_cases() -> bool:
# --- A. who may hold a position -------------------------------------
var eligible: PackedStringArray = game_state.garrison_vessel_types()
for armed in [GameState.VESSEL_INTERCEPTOR, GameState.VESSEL_LASER,
GameState.VESSEL_GAS_BOMBER]:
if not (armed in eligible):
return _fail("%s cannot be garrisoned" % armed)
for unarmed in [GameState.VESSEL_SHIELD, GameState.VESSEL_TRANSPORTER,
GameState.VESSEL_CARRIER, GameState.VESSEL_DISTORDER]:
if game_state.vessel_can_garrison(unarmed):
return _fail("%s can be garrisoned - it does not attack" % unarmed)
print("garrisonable: %s" % str(eligible))
# --- B. the magazine follows the crew --------------------------------
outpost_id = game_state.add_building(GameState.BUILDING_OUTPOST, Vector2(2000.0, 0.0))
if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 0:
return _fail("an empty outpost already wants gas")
_station(GameState.VESSEL_GAS_BOMBER)
if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 50:
return _fail("one bomber asks for %d gas, expected 50" \
% game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS))
_station(GameState.VESSEL_GAS_BOMBER)
if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS) != 100:
return _fail("two bombers ask for %d gas, expected 100" \
% game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_GAS))
if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 0:
return _fail("bombers ask for energy they cannot use")
_station(GameState.VESSEL_LASER)
if game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY) != 50:
return _fail("one laser asks for %d energy, expected 50" \
% game_state.garrison_magazine_capacity(outpost_id, GameState.RESOURCE_ENERGY))
print("2 bombers + 1 laser -> 100 gas, 50 energy")
# --- C. it cannot be overfilled --------------------------------------
game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 400)
if game_state.building_resource(outpost_id, GameState.RESOURCE_GAS) != 100:
return _fail("400 gas into a 100-gas outpost left %d" \
% game_state.building_resource(outpost_id, GameState.RESOURCE_GAS))
# An interceptor needs no magazine, so it must not create one.
game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ORE, 50)
if game_state.building_resource(outpost_id, GameState.RESOURCE_ORE) != 0:
return _fail("the outpost accepted ore nothing stationed there can use")
print("capped at 100 gas, and refuses what nobody there can use")
# --- D. and it asks for what it is short of --------------------------
game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_GAS, 20)
var wanted: int = game_state.building_requested_resource(outpost_id, GameState.RESOURCE_GAS)
if wanted != 80:
return _fail("an outpost holding 20 of 100 gas asks for %d" % wanted)
print("holding 20 of 100, it asks hub logistics for the other 80")
return false
func _process(delta: float) -> bool:
elapsed += delta
match stage:
0:
if elapsed < 0.3:
return false
main.get_node("EnemySpawner").set_process(false)
main.get_node("AsteroidField").set_process(false)
for hazard in get_nodes_in_group("asteroids") + get_nodes_in_group("enemies"):
if is_instance_valid(hazard):
hazard.queue_free()
fleet.spawn_ships(1, Vector2(-9000.0, 0.0),
PackedStringArray([GameState.VESSEL_INTERCEPTOR]))
fleet.ships[0].move_target = fleet.ships[0].position
if _state_cases():
return true
# A fresh outpost with a single laser, in the world this time.
game_state.buildings.clear()
main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO)
outpost_id = int(game_state.buildings[-1].id)
_station(GameState.VESSEL_LASER)
game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_ENERGY, 50)
# The fleet hold is deliberately full of energy: if the garrison
# spent from THERE, this is what would move.
game_state.deposit_fleet_resource(GameState.RESOURCE_ENERGY, 100)
elapsed = 0.0
stage = 1
return false
1:
if elapsed < 0.4:
return false
var here: Array = field.ships_at(outpost_id)
if here.size() != 1:
return _fail("the laser outpost put %d vessels in the world" % here.size())
guard = here[0]
if guard.get_component("laser") == null:
return _fail("the stationed vessel has no laser component")
enemy = _dummy(Vector2(320.0, 0.0))
hp_before = enemy.hp
energy_before = game_state.building_resource(outpost_id, GameState.RESOURCE_ENERGY)
fleet_energy_before = game_state.fleet_resource(GameState.RESOURCE_ENERGY)
elapsed = 0.0
stage = 2
return false
2:
# --- E. it fires, hits, and pays from the outpost -------------
if elapsed < 2.0:
return false
if not is_instance_valid(enemy):
return _fail("the target dummy is gone")
if enemy.hp >= hp_before:
return _fail("a garrisoned laser never fired on an enemy 320px away")
var spent: int = energy_before - game_state.building_resource(
outpost_id, GameState.RESOURCE_ENERGY)
if spent <= 0:
return _fail("the laser fired without spending any energy")
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before:
return _fail("the garrison spent %d energy out of the FLEET's hold" \
% (fleet_energy_before - game_state.fleet_resource(GameState.RESOURCE_ENERGY)))
print("garrisoned laser: %d damage dealt, %d energy taken from the outpost, fleet hold untouched" \
% [hp_before - enemy.hp, spent])
# --- F. an empty store holds fire ---------------------------
game_state.building_inventory(outpost_id).set_amount(GameState.RESOURCE_ENERGY, 0)
hp_before = enemy.hp
elapsed = 0.0
stage = 3
return false
3:
if elapsed < 2.0:
return false
if enemy.hp < hp_before:
return _fail("the laser kept firing with an empty magazine")
if game_state.fleet_resource(GameState.RESOURCE_ENERGY) != fleet_energy_before:
return _fail("an empty outpost fell back on the fleet's hold")
print("out of energy: it holds fire rather than taking the fleet's")
# --- G. the bomber, through the same seam --------------------
enemy.queue_free()
game_state.buildings.clear()
main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2(6000.0, 0.0))
outpost_id = int(game_state.buildings[-1].id)
_station(GameState.VESSEL_GAS_BOMBER)
game_state.deposit_resource_to_building(outpost_id, GameState.RESOURCE_GAS, 50)
elapsed = 0.0
stage = 4
return false
4:
if elapsed < 0.4:
return false
var crew: Array = field.ships_at(outpost_id)
if crew.size() != 1:
return _fail("the bomber outpost put %d vessels in the world" % crew.size())
guard = crew[0]
if guard.get_component("gas_bomber") == null:
return _fail("the stationed vessel has no gas bomber component")
enemy = _dummy(Vector2(6000.0, 320.0))
hp_before = enemy.hp
gas_before = game_state.building_resource(outpost_id, GameState.RESOURCE_GAS)
elapsed = 0.0
stage = 5
return false
5:
if elapsed < 3.0:
return false
if not is_instance_valid(enemy):
return _fail("the bomber's target is gone")
if enemy.hp >= hp_before:
return _fail("a garrisoned gas bomber never landed a bomb")
var gas_spent: int = gas_before - game_state.building_resource(
outpost_id, GameState.RESOURCE_GAS)
if gas_spent <= 0:
return _fail("the bomb was thrown without spending gas")
print("garrisoned bomber: %d damage dealt, %d gas taken from the outpost" \
% [hp_before - enemy.hp, gas_spent])
# --- H. the AI names no ability ------------------------------
var source: String = FileAccess.get_file_as_string(
"res://vessels/VesselGarrisonComponent.gd")
for named in ["laser", "gas_bomber", "LaserBeam", "GasBomb"]:
if named in source:
return _fail("the garrison AI mentions \"%s\" - the seam is not doing its job" \
% named)
print("the garrison AI never names an ability - it asks components what they can do")
print("TEST_GARRISON_ABILITIES: PASS")
quit(0)
return true
return false