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. 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