Threat Radar + Aggressive AI Pilot Implementation Plan
Threat Radar + Aggressive AI Pilot Implementation Plan
Section titled “Threat Radar + Aggressive AI Pilot Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Make the AI pilot (SkilledAutoPlayer, informally “Ace” in chat/docs only) actively hunt
the swarm, dash offensively through clusters, deploy drones the instant they’re charged, and aim
the manual weapon at the worst threat — driven by a new shared ThreatMap utility that also feeds
a permanent, always-on red-glow screen-edge radar showing where the danger is.
Architecture: One new pure utility (ai/threat_map.gd, ThreatMap) buckets every enemy into
16 angular sectors around a pilot, scoring each by count + elite/boss + remaining-HP-fraction. Two
consumers read it: SkilledAutoPlayer (movement/dash/decoy/aim decisions) and a new
ui/threat_radar.gd (ThreatRadar, modeled directly on the existing ui/boss_edge_indicator.gd
pattern) that draws glow blobs at the screen edge. A small refactor pulls the aim-targeting logic
that was previously duplicated in tools/soak/soak_run.gd and main.gd into AutoPlayer itself.
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0.
Global Constraints
Section titled “Global Constraints”- No changes to
/sim— this is entirelyai/(controller) andui/(render) code. No determinism re-pin expected; verify the pinned assertion intests/test_determinism_survival.gdis unchanged at the end. - HUD label stays exactly
"● AUTOPILOT ACTIVE"— do not rename it or any class. “Ace” is a conversational nickname only (Chris is considering a real player-facing autopilot feature later and wants the name free). ai/auto_player.gd(AutoPlayer) behavior must stay identical for every method NOT explicitly listed as changing in this plan — it’s load-bearing for coverage soaks and must not regress.- Full
bh-dev-chunk-style gate at the end: full GUT suite,scripts/check-test-count.sh, a headless boot smoke (godot --headless --path . --quit-after 120, grep forSCRIPT ERROR). - Every new file/method needs GUT tests before being considered done — this project is TDD’d throughout.
Task 1: ThreatMap — shared threat-scoring utility
Section titled “Task 1: ThreatMap — shared threat-scoring utility”Files:
- Create:
ai/threat_map.gd - Test:
tests/test_threat_map.gd
Interfaces:
-
Produces:
ThreatMap.SECTORS: int(const,16).ThreatMap.compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary]— one dict per sector:{"score": float, "angle": float, "target_pos": Vector2, "has_target": bool}(plus an internal"_best_weight"key callers should ignore).ThreatMap.best(slices: Array[Dictionary]) -> Dictionary— the single highest-scoring slice, or{"score": 0.0, "has_target": false}if every slice is empty. -
Step 1: Write the failing tests
Create tests/test_threat_map.gd:
extends GutTest
# ThreatMap buckets every alive enemy into an angular sector around a pilot and scores each# sector by count + elite/boss + remaining-HP-fraction. Shared by SkilledAutoPlayer (movement/# dash/decoy/aim decisions) and ThreatRadar (what to draw) so both agree on "where's the danger."
func _content() -> ContentDB: return SimContentFixture.db()
func _only_nonzero_slice(slices: Array) -> Dictionary: for slice in slices: if float(slice["score"]) > 0.0: return slice return {}
func test_sectors_count_matches_constant() -> void: var sim := Sim.new(1, _content()) assert_eq(ThreatMap.compute(sim, sim.player).size(), ThreatMap.SECTORS)
func test_empty_arena_all_slices_zero() -> void: var sim := Sim.new(1, _content()) var slices := ThreatMap.compute(sim, sim.player) for slice in slices: assert_eq(float(slice["score"]), 0.0) assert_false(bool(slice["has_target"])) var top := ThreatMap.best(slices) assert_eq(float(top["score"]), 0.0) assert_false(bool(top["has_target"]))
func test_single_normal_enemy_scores_base_plus_full_hp() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) # full HP at spawn var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player)) assert_true(bool(hit["has_target"]), "exactly one slice should carry the enemy") assert_almost_eq(float(hit["score"]), 2.0, 0.001, "1.0 base + 1.0 full-HP fraction, no elite/boss bonus") assert_eq(hit["target_pos"], Vector2(100, 50))
func test_elite_enemy_gets_elite_bonus() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) sim.enemies.is_elite[0] = 1 var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player)) assert_almost_eq(float(hit["score"]), 4.0, 0.001, "1.0 base + 2.0 elite bonus + 1.0 full-HP fraction")
func test_boss_type_enemy_gets_boss_bonus_not_elite_bonus() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 40.0, 500.0, 0.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_BOSS) var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player)) assert_almost_eq(float(hit["score"]), 8.0, 0.001, "1.0 base + 6.0 boss bonus + 1.0 full-HP fraction, no elite bonus")
func test_partial_hp_contributes_less_than_full_hp() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 50), Vector2.ZERO, 14.0, 20.0) sim.enemies.data[0] = 10.0 # half of max_hp (20.0) var hit := _only_nonzero_slice(ThreatMap.compute(sim, sim.player)) assert_almost_eq(float(hit["score"]), 1.5, 0.001, "1.0 base + 0.5 half-HP fraction")
func test_two_well_separated_enemies_land_in_different_slices() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) sim.enemies.add(Vector2(-100, 0), Vector2.ZERO, 14.0, 20.0) var slices := ThreatMap.compute(sim, sim.player) var nonzero := 0 for slice in slices: if float(slice["score"]) > 0.0: nonzero += 1 assert_eq(nonzero, 2, "two enemies on opposite sides of the player must land in two different sectors")
func test_best_picks_the_higher_scoring_cluster() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 20.0) # weak, east sim.enemies.add(Vector2(-100, 0), Vector2.ZERO, 14.0, 20.0) # west sim.enemies.is_elite[1] = 1 # west enemy is elite -> higher score var top := ThreatMap.best(ThreatMap.compute(sim, sim.player)) assert_true(bool(top["has_target"])) assert_eq(top["target_pos"], Vector2(-100, 0), "the elite-weighted west cluster outranks the plain east one")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit
Expected: FAIL — ThreatMap class does not exist yet.
- Step 3: Write the implementation
Create ai/threat_map.gd:
class_name ThreatMapextends RefCounted
# Buckets every enemy into an angular sector around a pilot and scores danger per sector# (count + elite + boss + remaining-HP-fraction). A pure, stateless utility -- every call# recomputes from current Sim state. Shared by SkilledAutoPlayer (movement/dash/decoy/aim# decisions) and ThreatRadar (what to draw), so both are provably looking at the same picture.
const SECTORS: int = 16const ELITE_BONUS: float = 2.0const BOSS_BONUS: float = 6.0const HP_LIVE_WEIGHT: float = 1.0 # scales the 0..1 remaining-HP-fraction term
# Slice index increases with Vector2.angle() (atan2(y,x)), starting at due east; since screen Y# points down this sweeps clockwise on screen. Callers never need to know this -- they only ever# read a slice's own "angle" field back, never assume meaning from its array index.static func compute(sim: Sim, pilot: PlayerState, sectors: int = SECTORS) -> Array[Dictionary]: var slices: Array[Dictionary] = [] var slice_width := TAU / float(sectors) for s in range(sectors): slices.append({ "score": 0.0, "angle": (float(s) + 0.5) * slice_width, # slice CENTER angle, for edge projection "target_pos": Vector2.ZERO, "has_target": false, "_best_weight": 0.0, }) for i in range(sim.enemies.count): var d: Vector2 = sim.enemies.pos[i] - pilot.pos var ang: float = fposmod(d.angle(), TAU) var idx: int = clampi(int(ang / slice_width), 0, sectors - 1) var weight := 1.0 if sim.enemies.is_elite[i] == 1: weight += ELITE_BONUS if sim.boss_rotation.is_boss_type(sim.enemies.type_id[i]): weight += BOSS_BONUS var max_hp: float = maxf(sim.enemies.max_hp[i], 1.0) weight += (sim.enemies.data[i] / max_hp) * HP_LIVE_WEIGHT var slice: Dictionary = slices[idx] slice["score"] = float(slice["score"]) + weight if weight > float(slice["_best_weight"]): slice["_best_weight"] = weight slice["target_pos"] = sim.enemies.pos[i] slice["has_target"] = true return slices
# The single highest-scoring slice, or {"score": 0.0, "has_target": false} if the arena is empty# (every slice score is 0). Convenience for callers that only care about the ONE worst direction.static func best(slices: Array[Dictionary]) -> Dictionary: var top: Dictionary = {"score": 0.0, "has_target": false} for slice in slices: if float(slice["score"]) > float(top["score"]): top = slice return top- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_map -gexit
Expected: PASS — all 7 tests green.
- Step 5: Commit
git add ai/threat_map.gd tests/test_threat_map.gdgit commit -m "feat(ai): add ThreatMap, shared enemy-cluster danger scoring"Task 2: AutoPlayer.compute_aim_for() — de-duplicate aim targeting
Section titled “Task 2: AutoPlayer.compute_aim_for() — de-duplicate aim targeting”Files:
- Modify:
ai/auto_player.gd - Modify:
tools/soak/soak_run.gd:87-93(the_input_forfunction) - Modify:
main.gd(attract-mode input block added in the previous commit,c5e8167) - Test:
tests/test_auto_player.gd
Interfaces:
-
Consumes: none new.
-
Produces:
AutoPlayer.AIM_RADIUS: float(const,400.0).AutoPlayer.compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2— nearest enemy withinAIM_RADIUS, orVector2.ZERO.SkilledAutoPlayeroverrides this in Task 4; this task only touches the base class and its two call sites. -
Step 1: Write the failing tests
Add to tests/test_auto_player.gd (append at the end of the file):
func test_compute_aim_for_nearest_enemy_in_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 5.0) sim.hash.rebuild(sim.enemies) var aim := AutoPlayer.new().compute_aim_for(sim, sim.player) assert_almost_eq(aim.x, 1.0, 0.001, "aims directly at the only enemy in range") assert_almost_eq(aim.y, 0.0, 0.001)
func test_compute_aim_for_returns_zero_when_nothing_in_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(1000, 0), Vector2.ZERO, 14.0, 5.0) # outside AIM_RADIUS (400) sim.hash.rebuild(sim.enemies) var aim := AutoPlayer.new().compute_aim_for(sim, sim.player) assert_eq(aim, Vector2.ZERO)- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_auto_player -gexit
Expected: FAIL — compute_aim_for does not exist on AutoPlayer yet.
- Step 3: Add
compute_aim_fortoai/auto_player.gd
Add this constant near the top of the file, alongside the existing DASH_PANIC_RADIUS/DECOY_THREAT_RADIUS consts:
const AIM_RADIUS: float = 400.0 # how far the manual/aim weapon will track an enemyAdd this method, placed after wants_decoy (end of the file):
# Aim direction for the manual/aim weapon: the nearest enemy within AIM_RADIUS, or ZERO if# nothing's in range. Pulled out of tools/soak/soak_run.gd and main.gd's attract-mode input# block, which both inlined this identically. SkilledAutoPlayer overrides it with a threat-aware# version and falls back to this when nothing dangerous is in range.func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2: var near := sim.hash.query_circle(pilot.pos, AIM_RADIUS, sim.enemies) if near.is_empty(): return Vector2.ZERO return (sim.enemies.pos[near[0]] - pilot.pos).normalized()- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_auto_player -gexit
Expected: PASS.
- Step 5: Update
tools/soak/soak_run.gd’s call site
Find this exact block (currently lines 87-93):
func _input_for(pilot: PlayerState) -> InputState: if pilot == null or pilot.hp <= 0.0: return InputState.new() var move := auto.compute_move_for(sim, pilot) # Aim at the nearest enemy so the manual right-stick weapon is exercised. var aim := Vector2.ZERO var near := sim.hash.query_circle(pilot.pos, 400.0, sim.enemies) if not near.is_empty(): aim = (sim.enemies.pos[near[0]] - pilot.pos).normalized() return InputState.new(move, aim, auto.wants_decoy(sim, pilot), auto.wants_dash(sim, pilot))Replace with:
func _input_for(pilot: PlayerState) -> InputState: if pilot == null or pilot.hp <= 0.0: return InputState.new() var move := auto.compute_move_for(sim, pilot) var aim := auto.compute_aim_for(sim, pilot) return InputState.new(move, aim, auto.wants_decoy(sim, pilot), auto.wants_dash(sim, pilot))- Step 6: Update
main.gd’s attract-mode input block
Find this exact block (added in commit c5e8167, inside _physics_process):
var input := human if _auto: # Mirror tools/soak/soak_run.gd's _input_for: aim + dash + decoy, not just movement -- # a move-only InputState meant the attract-mode demo could never dash or deploy drones, # no matter how capable the underlying AutoPlayer was. var auto_move := auto_player.compute_move(sim) var auto_aim := Vector2.ZERO var auto_near := sim.hash.query_circle(sim.player.pos, 400.0, sim.enemies) if not auto_near.is_empty(): auto_aim = (sim.enemies.pos[auto_near[0]] - sim.player.pos).normalized() input = InputState.new(auto_move, auto_aim, auto_player.wants_decoy(sim, sim.player), auto_player.wants_dash(sim, sim.player))Replace with:
var input := human if _auto: # Mirror tools/soak/soak_run.gd's _input_for: aim + dash + decoy, not just movement -- # a move-only InputState meant the attract-mode demo could never dash or deploy drones, # no matter how capable the underlying AutoPlayer was. var auto_move := auto_player.compute_move(sim) var auto_aim := auto_player.compute_aim_for(sim, sim.player) input = InputState.new(auto_move, auto_aim, auto_player.wants_decoy(sim, sim.player), auto_player.wants_dash(sim, sim.player))- Step 7: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as before this task (2140 passing, 4 pre-existing risky/pending) plus
the 2 new tests from Step 1 (2142 passing).
- Step 8: Commit
git add ai/auto_player.gd tools/soak/soak_run.gd main.gd tests/test_auto_player.gdgit commit -m "refactor(ai): pull duplicated aim-targeting into AutoPlayer.compute_aim_for"Task 3: SkilledAutoPlayer — engage movement, proactive dash, always-deploy drones
Section titled “Task 3: SkilledAutoPlayer — engage movement, proactive dash, always-deploy drones”Files:
- Modify:
ai/skilled_auto_player.gd - Modify:
tests/test_skilled_auto_player.gd
Interfaces:
-
Consumes:
ThreatMap.compute/ThreatMap.best(Task 1).AutoPlayer.wants_dash/AutoPlayer.DASH_PANIC_RADIUS(existing, viasuper). -
Produces:
SkilledAutoPlayer.ENGAGE_SEEK_WEIGHT,ENGAGE_DASH_MIN_RANGE,ENGAGE_DASH_MAX_RANGE(new consts). Overriddencompute_move_for,wants_dash,wants_decoy(Task 4 will add acompute_aim_foroverride in this same class). -
Step 1: Update the existing dash test’s stale docstring
tests/test_skilled_auto_player.gd currently has:
func test_inherits_dash_behavior_from_auto_player() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 5.0) # inside DASH_PANIC_RADIUS sim.hash.rebuild(sim.enemies) assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "dash/decoy timing is unchanged from AutoPlayer for v1 (no evidence yet it's a bottleneck)")Its docstring is about to become false (this task adds a real proactive dash layer and a changed decoy policy). Replace the whole function with:
func test_panic_dash_still_wins_with_proactive_layer() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 5.0) # inside DASH_PANIC_RADIUS sim.hash.rebuild(sim.enemies) assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "point-blank panic dash still fires even though a new proactive-dash layer exists")- Step 2: Write the new failing tests
Append to tests/test_skilled_auto_player.gd:
func test_proactive_dash_toward_midrange_cluster() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(250, 0), Vector2.ZERO, 14.0, 5.0) # inside the engage-dash window sim.hash.rebuild(sim.enemies) assert_true(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "dashes toward a mid-range cluster to close distance, not just away from a point-blank one")
func test_no_proactive_dash_when_cooldown_active() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.player.dash_cd = 1.0 sim.enemies.add(Vector2(250, 0), Vector2.ZERO, 14.0, 5.0) sim.hash.rebuild(sim.enemies) assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player), "still on cooldown -> no proactive dash request")
func test_no_proactive_dash_when_target_too_far() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(800, 0), Vector2.ZERO, 14.0, 5.0) # beyond ENGAGE_DASH_MAX_RANGE sim.hash.rebuild(sim.enemies) assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player))
func test_no_dash_when_arena_empty() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO assert_false(SkilledAutoPlayer.new().wants_dash(sim, sim.player))
func test_wants_decoy_ignores_proximity_when_charged() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.player.drone_charge = 1.0 # no enemies anywhere -- base AutoPlayer would withhold (needs a nearby threat too), Ace deploys anyway assert_true(SkilledAutoPlayer.new().wants_decoy(sim, sim.player), "deploys the instant it's charged, regardless of nearby danger")
func test_wants_decoy_withheld_until_fully_charged() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.player.drone_charge = 0.99 assert_false(SkilledAutoPlayer.new().wants_decoy(sim, sim.player))
func test_engage_force_pulls_toward_distant_cluster() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 5.0) # far east, outside THREAT_RADIUS (300) sim.hash.rebuild(sim.enemies) var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player) assert_gt(move.x, 0.0, "moves toward the distant cluster instead of standing still")
func test_flee_still_dominates_at_close_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 5.0) # deep inside THREAT_RADIUS sim.hash.rebuild(sim.enemies) var move := SkilledAutoPlayer.new().compute_move_for(sim, sim.player) assert_lt(move.x, 0.0, "close range: still flees away, the engage force doesn't override survival")- Step 3: Run tests to verify the new ones fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: the renamed panic test still passes (behavior unchanged so far); the 7 new tests FAIL
(no engage force, no proactive dash, decoy still proximity-gated).
- Step 4: Rewrite
ai/skilled_auto_player.gd
Replace the whole file with:
class_name SkilledAutoPlayerextends AutoPlayer
# A more capable soak-testing AI than the base AutoPlayer, used ONLY for balance-signal runs and# the attract-mode demo (never coverage soaks -- AutoPlayer's deliberate simplicity there is# untouched by this file). 2026-07-17: Chris watched the demo play too passively (only reactive# flee, dash/drones barely used) and asked for active hunting -- fly toward the swarm as soon as# it arrives, dash offensively through clusters (using the existing i-frame window to avoid# damage while weapons do the killing), and deploy drones the instant they're charged.
const ELITE_DANGER_WEIGHT: float = 1.6 # elites are steered away from harder than trash
# How strongly the "engage the worst threat cluster" force pulls, relative to the existing flee# force's peak strength of 1.0 (at point-blank range, decaying to 0 at THREAT_RADIUS=300 away).# 0.5 means flee still wins inside ~90px of an enemy (near DASH_PANIC_RADIUS=70, so panic-dash and# "still fleeing" agree at true close range) while engage wins at any longer range -- including# far outside THREAT_RADIUS entirely, where flee contributes nothing at all. Only the ANGLE of the# final normalized move direction is affected by this weight, not overall movement speed.const ENGAGE_SEEK_WEIGHT: float = 0.5const ENGAGE_DASH_MIN_RANGE: float = 150.0 # closer than this, panic-dash (if triggered) already covers itconst ENGAGE_DASH_MAX_RANGE: float = 400.0 # farther than this, dashing wouldn't meaningfully close distance
func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2: var p := pilot.pos var flee := Vector2.ZERO var near := sim.hash.query_circle(p, THREAT_RADIUS, sim.enemies) for i in near: var d := p - sim.enemies.pos[i] var dist := d.length() if dist > 0.001: var w := (THREAT_RADIUS - dist) / THREAT_RADIUS var danger := ELITE_DANGER_WEIGHT if sim.enemies.is_elite[i] == 1 else 1.0 flee += d / dist * w * w * danger
var seek := Vector2.ZERO var best := -1 var best_d2 := INF for gi in range(sim.gems.count): var gd2 := p.distance_squared_to(sim.gems.pos[gi]) if gd2 < best_d2: best_d2 = gd2 best = gi if best != -1: seek = (sim.gems.pos[best] - p).normalized() * GEM_SEEK_WEIGHT
var wall := Vector2.ZERO var lim := Sim_Const.ARENA_HALF - WALL_MARGIN if p.x > lim: wall.x -= (p.x - lim) / WALL_MARGIN if p.x < -lim: wall.x += (-lim - p.x) / WALL_MARGIN if p.y > lim: wall.y -= (p.y - lim) / WALL_MARGIN if p.y < -lim: wall.y += (-lim - p.y) / WALL_MARGIN
var engage := Vector2.ZERO var top := ThreatMap.best(ThreatMap.compute(sim, pilot)) if bool(top["has_target"]): var target_pos: Vector2 = top["target_pos"] var to_target: Vector2 = target_pos - p if to_target.length() > 0.001: engage = to_target.normalized() * ENGAGE_SEEK_WEIGHT
var dir := flee + seek + wall * WALL_WEIGHT + engage if dir.length() < 0.01: return Vector2.ZERO return dir.normalized()
# Score offered choices instead of AutoPlayer's "first weapon, else first" — id# prefixes are a stable contract (sim/upgrade_system.gd apply_upgrade): "weapon:"# grants a new slot, "evolve:" evolves an owned weapon, "wm:"/"aim:" are# per-weapon mods, anything else is a generic content-db stat/transformative pick.func pick_upgrade(choice_ids: Array) -> String: if choice_ids.is_empty(): return "" var best_id := String(choice_ids[0]) var best_score := -1 for raw in choice_ids: var id := String(raw) var score := 0 if id.begins_with("weapon:"): score = 3 elif id.begins_with("evolve:"): score = 2 elif id.begins_with("wm:") or id.begins_with("aim:"): score = 1 if score > best_score: best_score = score best_id = id return best_id
# Panic (point-blank, inherited from AutoPlayer) always wins. Otherwise, dash TOWARD the worst# threat cluster when it's at a range dashing would meaningfully close (compute_move_for's engage# force already points move_dir at this same target, so the dash carries the pilot toward it with# no separate direction calculation needed) -- using the existing i-frame window to punch through# the leading edge of a cluster instead of only ever dashing away from one.func wants_dash(sim: Sim, pilot: PlayerState) -> bool: if super.wants_dash(sim, pilot): return true if pilot.dash_cd > 0.0: return false var top := ThreatMap.best(ThreatMap.compute(sim, pilot)) if not bool(top["has_target"]): return false var target_pos: Vector2 = top["target_pos"] var dist := pilot.pos.distance_to(target_pos) return dist >= ENGAGE_DASH_MIN_RANGE and dist <= ENGAGE_DASH_MAX_RANGE
# Deploy the instant it's charged, regardless of nearby danger -- drones deal real AoE damage (and# some classes heal), so there's no reason to hold a full charge waiting for a threat to appear.func wants_decoy(sim: Sim, pilot: PlayerState) -> bool: return pilot.drone_charge >= 1.0- Step 5: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: PASS — all tests (the renamed panic test + 7 new ones + the pre-existing pick_upgrade/elite-weighting tests) green.
- Step 6: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as after Task 2, plus these new tests.
- Step 7: Commit
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit commit -m "feat(ai): SkilledAutoPlayer actively engages threats, dashes offensively, spams drones"Task 4: SkilledAutoPlayer.compute_aim_for() — threat-aware manual weapon targeting
Section titled “Task 4: SkilledAutoPlayer.compute_aim_for() — threat-aware manual weapon targeting”Files:
- Modify:
ai/skilled_auto_player.gd - Modify:
tests/test_skilled_auto_player.gd
Interfaces:
-
Consumes:
ThreatMap.compute/ThreatMap.best(Task 1),AutoPlayer.compute_aim_for/AutoPlayer.AIM_RADIUS(Task 2, viasuper). -
Produces: overridden
SkilledAutoPlayer.compute_aim_for(sim, pilot) -> Vector2. -
Step 1: Write the failing tests
Append to tests/test_skilled_auto_player.gd:
func test_aim_targets_the_worst_threat_in_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0) # plain, east, closer sim.enemies.add(Vector2(0, 200), Vector2.ZERO, 14.0, 5.0) # elite, south, farther but worse sim.enemies.is_elite[1] = 1 sim.hash.rebuild(sim.enemies) var aim := SkilledAutoPlayer.new().compute_aim_for(sim, sim.player) assert_almost_eq(aim.y, 1.0, 0.001, "aims south at the elite even though the plain east enemy is closer") assert_almost_eq(aim.x, 0.0, 0.001)
func test_aim_falls_back_to_nearest_when_worst_threat_out_of_range() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(150, 0), Vector2.ZERO, 14.0, 5.0) # plain, in aim range sim.enemies.add(Vector2(0, 900), Vector2.ZERO, 14.0, 5.0) # elite, worst overall but far out of aim range sim.enemies.is_elite[1] = 1 sim.hash.rebuild(sim.enemies) var aim := SkilledAutoPlayer.new().compute_aim_for(sim, sim.player) assert_almost_eq(aim.x, 1.0, 0.001, "falls back to the in-range plain enemy, not the far-off elite") assert_almost_eq(aim.y, 0.0, 0.001)
func test_aim_returns_zero_when_nothing_anywhere() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO assert_eq(SkilledAutoPlayer.new().compute_aim_for(sim, sim.player), Vector2.ZERO)- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: the 3 new tests FAIL — compute_aim_for still resolves to the inherited nearest-only version.
- Step 3: Add the override to
ai/skilled_auto_player.gd
Append this method at the end of the file (after wants_decoy):
# Aim at the worst threat cluster if it's within aim range; otherwise fall back to the base# class's nearest-in-range behavior so the manual weapon still tracks SOMETHING when the worst# cluster is too far away to hit.func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2: var top := ThreatMap.best(ThreatMap.compute(sim, pilot)) if bool(top["has_target"]): var target_pos: Vector2 = top["target_pos"] var to_target: Vector2 = target_pos - pilot.pos if to_target.length() > 0.001 and to_target.length() <= AIM_RADIUS: return to_target.normalized() return super.compute_aim_for(sim, pilot)- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_skilled_auto_player -gexit
Expected: PASS — all tests green.
- Step 5: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as after Task 3, plus 3 new tests.
- Step 6: Commit
git add ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit commit -m "feat(ai): SkilledAutoPlayer aims the manual weapon at the worst threat"Task 5: ThreatRadar — always-on screen-edge danger glow
Section titled “Task 5: ThreatRadar — always-on screen-edge danger glow”Files:
- Create:
ui/threat_radar.gd - Modify:
main.gd - Test:
tests/test_threat_radar.gd
Interfaces:
-
Consumes:
ThreatMap.compute(Task 1). -
Produces:
ThreatRadar.MARGIN/SCORE_NORMALIZE/MIN_RADIUS/MAX_RADIUS/MAX_ALPHA/DANGER_COLOR(consts).ThreatRadar.edge_point(dir: Vector2, vp: Vector2, margin: float) -> Vector2(static, pure).ThreatRadar.update_radar(sim: Sim, pilot: PlayerState) -> void(instance method, called once per frame).ThreatRadar.blob_count() -> int(test seam). -
Step 1: Write the failing tests
Create tests/test_threat_radar.gd:
extends GutTest
# ThreatRadar: an always-on screen-edge glow showing where the worst enemy threat is, in every# direction at once (unlike BossEdgeIndicator, which points at one specific off-camera boss and# hides once it's visible). 2026-07-17, Chris: wants a permanent player feature, not a debug# overlay -- "I was thinking of doing something like this anyway."## Same split as tests/test_boss_edge_indicator.gd: the pure edge-projection math is tested# directly against ThreatRadar.edge_point() (no camera/viewport dependency); update_radar() is# only checked for transform-independent behaviour (how many blobs get staged).
const VP := Vector2(1280, 720) # project.godot window/size/viewport_width|heightconst MARGIN := ThreatRadar.MARGIN
func _content() -> ContentDB: return SimContentFixture.db()
func _radar() -> ThreatRadar: var r := ThreatRadar.new() add_child_autofree(r) return r
func _ready_radar() -> ThreatRadar: var r := _radar() await get_tree().process_frame return r
# ── Pure edge geometry ──────────────────────────────────────────────────────
func test_edge_point_due_east() -> void: var p := ThreatRadar.edge_point(Vector2.RIGHT, VP, MARGIN) assert_almost_eq(p.x, VP.x - MARGIN, 0.01) assert_almost_eq(p.y, VP.y * 0.5, 0.01)
func test_edge_point_due_south() -> void: var p := ThreatRadar.edge_point(Vector2.DOWN, VP, MARGIN) assert_almost_eq(p.y, VP.y - MARGIN, 0.01) assert_almost_eq(p.x, VP.x * 0.5, 0.01)
func test_edge_point_due_west() -> void: var p := ThreatRadar.edge_point(Vector2.LEFT, VP, MARGIN) assert_almost_eq(p.x, MARGIN, 0.01)
func test_edge_point_due_north() -> void: var p := ThreatRadar.edge_point(Vector2.UP, VP, MARGIN) assert_almost_eq(p.y, MARGIN, 0.01)
func test_edge_point_diagonal_stays_within_box() -> void: var p := ThreatRadar.edge_point(Vector2(1, 1).normalized(), VP, MARGIN) assert_true(p.x <= VP.x - MARGIN + 0.5 and p.x >= MARGIN - 0.5) assert_true(p.y <= VP.y - MARGIN + 0.5 and p.y >= MARGIN - 0.5)
# ── update_radar() — transform-independent behaviours only ────────────────
func test_no_blobs_when_arena_empty() -> void: var sim := Sim.new(1, _content()) var r := await _ready_radar() r.update_radar(sim, sim.player) assert_eq(r.blob_count(), 0, "no enemies -> nothing to glow")
func test_one_blob_when_one_enemy_present() -> void: var sim := Sim.new(1, _content()) sim.player.pos = Vector2.ZERO sim.enemies.add(Vector2(100, 0), Vector2.ZERO, 14.0, 5.0) var r := await _ready_radar() r.update_radar(sim, sim.player) assert_eq(r.blob_count(), 1, "one enemy -> exactly one glowing sector")- Step 2: Run tests to verify they fail
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: FAIL — ThreatRadar class does not exist yet.
- Step 3: Write
ui/threat_radar.gd
class_name ThreatRadarextends CanvasLayer
# An always-on screen-edge glow toward the worst enemy threat, in every direction the swarm is# in. Same render pattern as ui/boss_edge_indicator.gd (a CanvasLayer + inner Control doing# custom _draw(), pure static geometry kept separate from any viewport dependency) but always# visible everywhere at once, rather than a single pop-in arrow toward one specific target.# 2026-07-17, Chris: a real permanent player feature, not a debug overlay.
const MARGIN: float = 24.0const SCORE_NORMALIZE: float = 8.0 # a sector's score at/above this renders at full intensityconst MIN_RADIUS: float = 10.0const MAX_RADIUS: float = 46.0const MAX_ALPHA: float = 0.5const DANGER_COLOR := Color(1.0, 0.35, 0.1) # distinct from the low-HP vignette's Color(1.0, 0.1, 0.15)
class _Radar extends Control: var blobs: Array[Dictionary] = [] # {"pos": Vector2, "radius": float, "alpha": float}
func _draw() -> void: for b in blobs: var pos: Vector2 = b["pos"] var r: float = b["radius"] var a: float = b["alpha"] draw_circle(pos, r, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a * 0.35)) draw_circle(pos, r * 0.6, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a * 0.6)) draw_circle(pos, r * 0.3, Color(DANGER_COLOR.r, DANGER_COLOR.g, DANGER_COLOR.b, a))
var _radar: _Radar
func _ready() -> void: layer = 5 # same tier as Hud/BossEdgeIndicator _radar = _Radar.new() _radar.mouse_filter = Control.MOUSE_FILTER_IGNORE _radar.set_anchors_preset(Control.PRESET_FULL_RECT) add_child(_radar)
# Pure: given a direction and viewport size, the point on the inset edge box along that ray from# screen-centre. Same ray-box math as BossEdgeIndicator.clamp_to_edge, minus its on-screen check# (the radar always renders at the edge for every sector, regardless of what's actually visible).static func edge_point(dir: Vector2, vp: Vector2, margin: float) -> Vector2: var half := Vector2(vp.x * 0.5 - margin, vp.y * 0.5 - margin) var tx: float = (half.x / absf(dir.x)) if absf(dir.x) > 0.0001 else INF var ty: float = (half.y / absf(dir.y)) if absf(dir.y) > 0.0001 else INF return vp * 0.5 + dir * minf(tx, ty)
func update_radar(sim: Sim, pilot: PlayerState) -> void: var slices := ThreatMap.compute(sim, pilot) var vp := get_viewport().get_visible_rect().size var blobs: Array[Dictionary] = [] for slice in slices: var score: float = slice["score"] if score <= 0.0: continue var intensity := clampf(score / SCORE_NORMALIZE, 0.0, 1.0) var angle: float = slice["angle"] var dir := Vector2(cos(angle), sin(angle)) var pos := edge_point(dir, vp, MARGIN) blobs.append({ "pos": pos, "radius": lerpf(MIN_RADIUS, MAX_RADIUS, intensity), "alpha": intensity * MAX_ALPHA, }) _radar.blobs = blobs _radar.queue_redraw()
# Test seam.func blob_count() -> int: return _radar.blobs.size() if _radar != null else 0- Step 4: Run tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_threat_radar -gexit
Expected: PASS — all 7 tests green.
- Step 5: Wire
ThreatRadarintomain.gd
Add the field declaration near the existing boss_edge_indicator declaration (main.gd:85):
var boss_edge_indicator: BossEdgeIndicator # points at the active boss when it's off-cameravar threat_radar: ThreatRadar # always-on screen-edge glow toward the worst enemy threatAdd instantiation right after boss_edge_indicator’s (main.gd:323-324):
boss_edge_indicator = BossEdgeIndicator.new() add_child(boss_edge_indicator) threat_radar = ThreatRadar.new() add_child(threat_radar)Add threat_radar to BOTH persistent-node exclusion lists (_return_to_menu() around line 494,
and _new_run() around line 558) — find each occurrence of this exact line:
or c == weapon_panel or c == drone_dock or c == boss_edge_indicator \Replace EACH of the two occurrences with:
or c == weapon_panel or c == drone_dock or c == boss_edge_indicator or c == threat_radar \Hide it alongside boss_edge_indicator in _return_to_menu() (this exact line, around 502):
boss_edge_indicator.visible = falseReplace with:
boss_edge_indicator.visible = false threat_radar.visible = falseUpdate it once per frame right after the existing boss indicator update (this exact line, around 1429):
boss_edge_indicator.update_indicator(hud_boss_info)Replace with:
boss_edge_indicator.update_indicator(hud_boss_info) threat_radar.update_radar(sim, sim.player)(P1-only for v1, matching the existing single-pilot-only pattern of several other HUD elements in this codebase, e.g. the drone bay — extending to co-op’s second pilot is a separate, later decision, not part of this plan.)
- Step 6: Run the full suite to check for regressions
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: same pass count as after Task 4, plus 7 new test_threat_radar tests.
- Step 7: Commit
git add ui/threat_radar.gd main.gd tests/test_threat_radar.gdgit commit -m "feat(hud): add ThreatRadar, an always-on screen-edge glow toward the worst threat"Task 6: Full-feature verification
Section titled “Task 6: Full-feature verification”Files: none (verification only).
Interfaces: none.
- Step 1: Full suite
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
Expected: all scripts run, 0 failing (pre-existing 4 risky/pending unchanged; total passing count
should be the Task 1 baseline + 2 (Task 2) + 10 (Task 3) + 3 (Task 4) + 7 (Task 5) + 7 (Task 1
itself) new tests).
- Step 2: Count guard
Run: bash scripts/check-test-count.sh
Expected: test-count guard OK with the new total script count (238 — 236 existing + test_threat_map.gd + test_threat_radar.gd).
- Step 3: Boot smoke
Run: godot --headless --path . --quit-after 120
Expected: no SCRIPT ERROR lines in the output (teardown noise like leaked RIDs/ERROR: N resources still in use at exit is expected and pre-existing).
- Step 4: Determinism baseline unaffected
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit
Expected: PASS with the SAME pinned hash/checksum as before this plan (this feature never
touches /sim, so no re-pin should be needed — if this fails, stop and investigate before
touching the pinned values, per bh-determinism-repin).
- Step 5: Re-run the instrumented dash/decoy measurement to confirm the fix landed
This mirrors the ad-hoc measurement done earlier in this session (which found dash wanted 246x but fired only 8x, and drone charge topping out at 46%) — confirming the new behavior actually moves those numbers, not just that the code compiles.
cat > /tmp/ace_check.gd << 'EOF'extends SceneTree
func _init() -> void: var defs: ContentDB = ContentLoader.load_from_path("res://data/bible.json") var sim: Sim = Sim.new(2000, defs) sim.enable_survival() var meta := MetaState.new() ShipBonuses.apply_base("cobalt", sim, meta) ShipBonuses.apply_to("cobalt", sim.player, sim.mods) var auto: SkilledAutoPlayer = SkilledAutoPlayer.new() var dash_wanted := 0 var dash_fired := 0 var decoy_wanted := 0 var decoy_fired := 0 var t := 0 while t < 36000 and not sim.game_over: var pilot: PlayerState = sim.pilots[0] var move: Vector2 = auto.compute_move_for(sim, pilot) var aim: Vector2 = auto.compute_aim_for(sim, pilot) var wd: bool = auto.wants_dash(sim, pilot) var wc: bool = auto.wants_decoy(sim, pilot) if wd: dash_wanted += 1 if wc: decoy_wanted += 1 var pre_cd: float = pilot.dash_cd var pre_charge: float = pilot.drone_charge sim.tick_single(InputState.new(move, aim, wc, wd)) if wd and pre_cd <= 0.0: dash_fired += 1 if wc and pre_charge >= 1.0: decoy_fired += 1 t += 1 if sim.pending_levelups > 0: var ids: Array = sim.upgrade_system.roll_upgrade_choices(sim, pilot, 3) sim.upgrade_system.apply_upgrade(sim, pilot, auto.pick_upgrade(ids)) print("ticks=%d died=%s dash_wanted=%d dash_fired=%d decoy_wanted=%d decoy_fired=%d hp=%.1f drone_charge=%.2f" \ % [t, sim.game_over, dash_wanted, dash_fired, decoy_wanted, decoy_fired, sim.pilots[0].hp, sim.pilots[0].drone_charge]) quit()EOFgodot --headless --path . -s /tmp/ace_check.gd 2>&1 | grep -v "^Godot Engine\|^Vulkan\|^WARNING\|^ at:"rm -f /tmp/ace_check.gdExpected: dash_fired and decoy_fired counts are meaningfully non-trivial across the run (not a
strict pass/fail assertion — this is a sanity read, not a unit test — but if decoy_fired is
still 0-1 or dash_fired is still single digits over a full 600-tick run, something in Tasks 3/4
isn’t wired the way this plan intends; investigate before moving on).
- Step 6: Soak sanity check
Run: bash scripts/soak.sh --runs=4 --ticks=3600 --ships=cobalt --coop=solo --player=skilled --seed0=3000
Expected: clean JSON report under reports/, no invariant failures, no anomalies flagged.
- Step 7: Export a fresh macOS build and hand off
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.appgit diff --stat project.godotExpected: export succeeds; project.godot diff is empty (headless export never touches it —
confirm anyway per this project’s standing gotcha about windowed runs corrupting it). Then
open builds/macos/BulletHeaven.app so Chris can watch Ace play with the new behavior and the
threat radar live.
- Step 8: Push
git push origin main