extends SceneTree # Headless gameplay test: run with # godot --headless -s res://tests/test_asteroid_safe_spawn.gd # Asteroid chunks are populated around wherever the fleet is - including the # chunk it is standing in - and positions used to be uniform within a chunk. # So an asteroid could be created directly on top of a ship and destroy it # with no chance to react. Worst on the frame a scene loads: coming back from # a planet's surface could cost a vessel instantly, which is also what made # test_return_position_matches_planet fail intermittently. # # Verifies: # #126 widens the rule from "not on the fleet" to "not IN VIEW": the old # bubble was 420px, smaller than the screen, so a rock could and did # materialise in plain sight several hundred pixels away. # # Verifies: # 1. _safe_position never returns a point inside the keep-out area, for # candidates all around it and including its exact centre. Checked # directly rather than by looking at where asteroids happened to land: # with ~54 asteroids over 25 chunks, any single run only rarely # produces a near miss, so an observational check passes just as # happily with the guard removed and proves nothing. # 2. candidates already outside are left exactly where they are, so the # field is not distorted away from the fleet # 3. the keep-out area really is the visible rect and not the old bubble # 4. nothing the field actually placed is on screen # 5. the field still spawns a full complement, and a stationary fleet # loses no ships var scene: Node var fleet: Node var field: Node var stage: int = 0 var elapsed: float = 0.0 var ships_at_start: int = 0 func _initialize() -> void: root.get_node("GameState").reset_formation() scene = load("res://world/Main.tscn").instantiate() root.add_child(scene) fleet = scene.get_node("Fleet") field = scene.get_node("AsteroidField") func _process(delta: float) -> bool: elapsed += delta match stage: 0: scene.get_node("EnemySpawner").set_process(false) ships_at_start = fleet.ships.size() elapsed = 0.0 stage = 1 return false 1: # Let the field populate around the fleet a few times over. if elapsed < 1.5: return false var centre: Vector2 = fleet.get_centroid() var safe: float = field.safe_spawn_radius var keep_out: Rect2 = field._keep_out_rect(centre) # --- 3. the keep-out area is the screen, not the old bubble --- var on_screen: Rect2 = ViewUtils.get_visible_world_rect(field) if not keep_out.encloses(on_screen): print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area does not cover the visible rect)") quit(1) return true if keep_out.size.x < safe * 2.0 or keep_out.size.y < safe * 2.0: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the keep-out area shrank below the old bubble)") quit(1) return true print("keep-out %.0fx%.0f covers a %.0fx%.0f screen (old bubble was %.0f across)" % [ keep_out.size.x, keep_out.size.y, on_screen.size.x, on_screen.size.y, safe * 2.0 ]) # --- 1/2. the guard itself, deterministically ----------------- var half: Vector2 = keep_out.size * 0.5 var reach: float = maxf(half.x, half.y) var let_one_through: bool = false var moved_an_outside_one: bool = false for i in range(72): var a: float = (float(i) / 72.0) * TAU var dir: Vector2 = Vector2(cos(a), sin(a)) # Candidates well inside, just inside, and exactly on centre. for d in [0.0, 1.0, reach * 0.25, reach * 0.7]: var candidate: Vector2 = keep_out.get_center() + dir * d if not keep_out.has_point(candidate): continue if keep_out.has_point(field._safe_position(candidate, keep_out)): let_one_through = true # Candidates already outside must be returned untouched. var outside: Vector2 = keep_out.get_center() + dir * (reach * 3.0) if field._safe_position(outside, keep_out).distance_to(outside) > 0.001: moved_an_outside_one = true if let_one_through: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (the guard left a spawn inside the visible area)") quit(1) return true if moved_an_outside_one: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (guard displaced an asteroid that was already clear)") quit(1) return true print("every one of 288 in-view candidates was pushed out; none outside was touched") var asteroids: Array = get_nodes_in_group("asteroids") var closest: float = 1e9 for a in asteroids: if is_instance_valid(a): closest = minf(closest, a.position.distance_to(centre)) print("%d asteroids, closest is %.1f units from the fleet (safe radius %.1f)" % [ asteroids.size(), closest, safe ]) if asteroids.is_empty(): print("TEST_ASTEROID_SAFE_SPAWN: FAIL (field spawned nothing - test proves nothing)") quit(1) return true # Asteroids drift, so one may have travelled inward since it was # created. Allow for that, but nothing should be anywhere near # on top of the fleet. if closest < safe * 0.5: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (asteroid created on top of the fleet)") quit(1) return true var kept_ships: bool = fleet.ships.size() == ships_at_start print("ships %d -> %d (kept=%s)" % [ships_at_start, fleet.ships.size(), kept_ships]) if not kept_ships: print("TEST_ASTEROID_SAFE_SPAWN: FAIL (a stationary fleet lost a ship to a spawn)") quit(1) return true print("TEST_ASTEROID_SAFE_SPAWN: PASS") quit(0) return true return false