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:
parent
0c595d775d
commit
b50597b757
16 changed files with 809 additions and 209 deletions
271
tests/test_garrison_abilities.gd
Normal file
271
tests/test_garrison_abilities.gd
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
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
|
||||
Loading…
Reference in a new issue