extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_outpost_garrison_patrol.gd # Forgejo #105 (#97g): "When a vessel is assigned to an outpost, it # navigates around the outpost by itself, attacking nearby ennemies if any # are detected." # # Covers: # A. a crewed outpost puts a real vessel in the world, and that vessel is # NOT in the fleet - it must not follow the formation, must not eat a # fleet slot, and must not fly off when the player leaves # B. left alone it WANDERS its post: it visits genuinely different places, # it is actually travelling rather than sliding, and it faces where it # is going (#109 - it used to walk a circle in steps shorter than # Ship's arrive_radius, so every frame it snapped and zeroed its # velocity, and Ship only turns while it is moving) # C. an enemy inside its detection range is engaged and shot at # D. it will not be walked away: an enemy beyond the leash is ignored, and # an engagement never drags it further than the leash from its post # E. it goes back to patrolling once the enemy is gone # F. losing a garrison vessel frees the berth, so it gets crewed again # G. enemies fight back - a garrison vessel is a target like any other # H. #186 - with no hostile nearby, the same weapon-target contract lets a # garrison shoot asteroids for ore var game_state: Node var main: Node var fleet: Node var field: Node var outpost_id: int = 0 var guard: Node2D var component: Node var enemy: Node2D var asteroid: Node2D var stage: int = 0 var elapsed: float = 0.0 var orbit_min: float = INF var orbit_max: float = 0.0 var visited: Array = [] var top_speed: float = 0.0 var moving_frames: int = 0 var facing_frames: int = 0 var max_from_post: float = 0.0 var dummy_hp_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") 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_OUTPOST_GARRISON_PATROL: FAIL (%s)" % message) quit(1) return true # A pinned target that neither shoots back nor dies. The point of these # stages is where the GARRISON goes and whether it fires, and a sniper that # killed the guard mid-check, or a chaser that died to it, would end the # observation before the thing being measured had happened. func _dummy(at: Vector2) -> Node2D: 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 _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() # The fleet is parked a long way off, so nothing here is the # fleet's doing. fleet.spawn_ships(1, Vector2(-9000.0, 0.0), PackedStringArray([GameState.VESSEL_INTERCEPTOR])) fleet.ships[0].move_target = fleet.ships[0].position main.get_node("BuildingField").place(GameState.BUILDING_OUTPOST, Vector2.ZERO) outpost_id = int(game_state.buildings[-1].id) game_state.set_garrison_desired(outpost_id, GameState.VESSEL_INTERCEPTOR, 1) game_state.garrison_crew(outpost_id).append(GameState.VESSEL_INTERCEPTOR) elapsed = 0.0 stage = 1 return false 1: if elapsed < 0.3: return false # --- A. a real vessel, and not a fleet one ------------------- var here: Array = field.ships_at(outpost_id) if here.size() != 1: return _fail("a crewed outpost put %d vessels in the world" % here.size()) guard = here[0] component = guard.get_component("garrison") if component == null: return _fail("the garrison vessel has no garrison component steering it") if guard in fleet.ships: return _fail("the garrison vessel joined the fleet") if fleet.ships.size() != 1: return _fail("stationing a vessel changed the fleet to %d ships" % fleet.ships.size()) print("outpost crewed with 1 vessel, fleet still %d and untouched" % fleet.ships.size()) elapsed = 0.0 stage = 2 return false 2: # --- B. it wanders its post --------------------------------- var from_post: float = guard.global_position.length() orbit_min = minf(orbit_min, from_post) orbit_max = maxf(orbit_max, from_post) visited.append(guard.global_position) var speed: float = guard.velocity.length() top_speed = maxf(top_speed, speed) if speed > 40.0: # Facing is only meaningful while actually travelling, which # is the whole of what #109 was about. Measured as a FRACTION # of the travel rather than as a worst instant: a vessel that # has just been given a new heading is legitimately still # swinging onto it at its own rotation_speed, and one frame # mid-turn says nothing about whether it flies nose-first. moving_frames += 1 var drift: float = absf(angle_difference(guard.rotation, guard.velocity.angle() + PI / 2.0)) if drift <= PI / 4.0: facing_frames += 1 if elapsed < 8.0: return false if component.is_engaging(): return _fail("the garrison thinks it is fighting with no enemy on the map") if orbit_max > component.leash_range: return _fail("patrolling took it %.0fpx from its post, past the %.0fpx leash" \ % [orbit_max, component.leash_range]) if top_speed < 60.0: return _fail("the picket never got above %.0fpx/s - it is sliding, not flying" \ % top_speed) if moving_frames <= 0: return _fail("it never travelled at all") var aligned: float = float(facing_frames) / float(moving_frames) if aligned < 0.7: return _fail("it was pointing where it was going for only %.0f%% of its travel" \ % (aligned * 100.0)) # Genuinely different places, not one path walked over and over. var spread: float = 0.0 for a in visited: for b in visited: spread = maxf(spread, a.distance_to(b)) if spread < 120.0: return _fail("everywhere it went was within %.0fpx - that is not wandering" % spread) print("wandered %.0fpx across, %.0f-%.0fpx from its post, up to %.0fpx/s, nose-first %.0f%% of the way" \ % [spread, orbit_min, orbit_max, top_speed, aligned * 100.0]) # --- D (first half). an enemy beyond the leash is ignored ---- enemy = _dummy(Vector2(component.leash_range + component.engage_distance + 900.0, 0.0)) elapsed = 0.0 stage = 3 return false 3: if elapsed < 1.0: return false if component.is_engaging(): return _fail("the garrison went after an enemy well beyond its leash") print("an enemy %.0fpx out is left alone" % enemy.global_position.length()) enemy.queue_free() # --- C. an enemy in reach is engaged and shot at ------------- enemy = _dummy(Vector2(300.0, 0.0)) dummy_hp_before = enemy.hp max_from_post = 0.0 elapsed = 0.0 stage = 4 return false 4: if not is_instance_valid(guard): return _fail("the garrison vessel was destroyed mid-engagement") max_from_post = maxf(max_from_post, guard.global_position.length()) if elapsed < 2.5: return false if not component.is_engaging(): return _fail("an enemy 300px from the outpost was not engaged") # Damage taken, not projectiles in flight: a bolt that has already # connected has freed itself, so counting live ones at an instant # reads zero exactly when the gun is working best. if enemy.hp >= dummy_hp_before: return _fail("the garrison engaged but the enemy took no damage") # --- D (second half). the leash holds during a fight --------- if max_from_post > component.leash_range + 60.0: return _fail("the fight pulled it %.0fpx from its post, past a %.0fpx leash" \ % [max_from_post, component.leash_range]) print("engaged, took %d hp off it, and stayed within %.0fpx of its post" \ % [dummy_hp_before - enemy.hp, max_from_post]) # --- G. and it can be fought back --------------------------- var targets: Script = load("res://enemies/EnemyTargets.gd") var picked: Node2D = targets.nearest_ship(main, enemy.global_position) if picked != guard: return _fail("an enemy beside the garrison picks %s instead of it - it cannot be fought back" \ % ("nothing" if picked == null else str(picked.name))) print("enemies see the garrison as a target too") if is_instance_valid(enemy): enemy.queue_free() elapsed = 0.0 stage = 5 return false 5: # --- E. back to the patrol ---------------------------------- if elapsed < 1.5: return false if component.is_engaging(): return _fail("the garrison is still fighting an enemy that is gone") print("stood down and went back to its patrol") # --- H. asteroids are valid lower-priority targets ----------- asteroid = load("res://world/Asteroid.tscn").instantiate() asteroid.position = Vector2(300.0, 0.0) asteroid.velocity = Vector2.ZERO main.add_child(asteroid) asteroid.hp = 100000 dummy_hp_before = asteroid.hp elapsed = 0.0 stage = 6 return false 6: if elapsed < 2.5: return false if not component.is_engaging(): return _fail("an asteroid beside the outpost was not engaged") if asteroid.hp >= dummy_hp_before: return _fail("the garrison engaged an asteroid but never shot it") print("garrison shot a nearby asteroid through the shared target contract") asteroid.queue_free() elapsed = 0.0 stage = 7 return false 7: if elapsed < 0.5: return false # --- F. a lost vessel frees its berth ------------------------ if game_state.garrison_crew(outpost_id).size() != 1: return _fail("the crew is not 1 before the kill") guard.take_damage(1) elapsed = 0.0 stage = 8 return false 8: if elapsed < 0.3: return false if not game_state.garrison_crew(outpost_id).is_empty(): return _fail("a destroyed garrison vessel still holds its berth") if game_state.garrison_desired_of(outpost_id, GameState.VESSEL_INTERCEPTOR) != 1: return _fail("losing the vessel cancelled the order as well") if field.ships_at(outpost_id).size() != 0: return _fail("the dead vessel is still stationed") print("losing the vessel frees the berth and leaves the order standing") print("TEST_OUTPOST_GARRISON_PATROL: PASS") quit(0) return true return false