Skip to content

Ace Loadout-Aware Weapon Range Implementation Plan

Ace Loadout-Aware Weapon Range Implementation Plan

Section titled “Ace Loadout-Aware Weapon Range 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: Replace Ace’s fixed, loadout-blind positioning/aim constants with values computed from the pilot’s actual equipped weapons, so more of the loadout gets a chance to fire.

Architecture: A new ai/weapon_range.gd computes each weapon’s real effective range via a priority-ordered instance-property probe (plus a small, verified exception table for the 3 weapons whose range lives in a class const with no instance mirror). SkilledAutoPlayer uses the median across active weapons to drive engage/dash positioning, and WeaponAim’s own computed range to replace the old flat AIM_RADIUS.

Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6.0.

  • No changes to /sim — this is entirely ai/ code. No determinism re-pin expected.
  • Flee behavior (compute_move_for’s flee term, THREAT_RADIUS) stays completely untouched — only the engage force’s cutoff and the proactive-dash window become loadout-derived.
  • No change to which enemy an auto-weapon targets (that’s /sim-internal) — this only changes Ace’s own positioning/aim decisions.
  • Every weapon’s range must be read from the REAL instance field verified against the actual sim/weapon_*.gd source (below) — never guessed or hardcoded where a real field exists.

Task 1: ai/weapon_range.gd — per-weapon effective-range utility

Section titled “Task 1: ai/weapon_range.gd — per-weapon effective-range utility”

Files:

  • Create: ai/weapon_range.gd
  • Test: tests/test_weapon_range.gd

Interfaces:

  • Produces: WeaponRange.effective_range(weapon: Object) -> float.

Verified per-weapon range source (checked directly against every file in sim/weapon_*.gd — not guessed): WeaponAim/WeaponBlaster/WeaponScatter use proj_speed × proj_lifetime (all three expose both as instance vars); WeaponPulse/WeaponMomentumCore use their own max_range instance var (both genuinely populated from content in _init, confirmed no stale-default bug); WeaponRailgun uses lance_range; WeaponBeam uses beam_range; WeaponMelee/WeaponScythe use range; WeaponNova uses area; WeaponOrbit uses orbit_radius; WeaponChalice/ WeaponScarTissue use radius; WeaponWildfire uses hop_radius (its chain-spread radius — the best available proxy for reach, since it has no separate acquisition-range field). THREE weapons have NO instance-var mirror of their range at all (only a class-level const, which Object.get() reflection cannot see): WeaponTurret (const MAX_RANGE = PROJ_SPEED(500.0) * PROJ_LIFETIME(1.5) = 750.0), WeaponThunderWake (const SCAN_RADIUS = 900.0), WeaponChronowell (const CLUSTER_SCAN_RADIUS = 400.0) — these three need an explicit lookup table keyed by class name, verified against those three real files above.

  • Step 1: Write the failing tests

Create tests/test_weapon_range.gd:

extends GutTest
# WeaponRange.effective_range() reads each weapon's REAL range-determining field at runtime
# (an instance property probe, not a hardcoded per-id table) so ship bonuses or future rebalances
# stay correct automatically. Three weapons (Turret/ThunderWake/Chronowell) have no instance-var
# mirror of their range -- only a class const -- and need the small explicit exception table.
func test_speed_times_lifetime_weapons() -> void:
var aim := WeaponAim.new()
assert_almost_eq(WeaponRange.effective_range(aim), aim.proj_speed * aim.proj_lifetime, 0.01)
var blaster := WeaponBlaster.new({"base_damage": 1.0, "cooldown_s": 1.0,
"projectile_speed": 720.0, "projectile_radius": 5.0, "lifetime_s": 1.0})
assert_almost_eq(WeaponRange.effective_range(blaster), 720.0 * 1.0, 0.01)
func test_explicit_max_range_field_weapons() -> void:
var pulse := WeaponPulse.new({"base_damage": 1.0, "cooldown_s": 1.0,
"projectile_speed": 500.0, "projectile_radius": 5.0, "lifetime_s": 1.0})
assert_almost_eq(WeaponRange.effective_range(pulse), 460.0, 0.01, "defaults to 460.0 per weapon_pulse.gd's own def.get default")
var momentum_core := WeaponMomentumCore.new({"base_damage": 1.0, "cooldown_s": 1.0,
"charge_cooldown_s": 0.8, "projectile_speed": 500.0, "projectile_radius": 5.0, "lifetime_s": 1.0})
assert_almost_eq(WeaponRange.effective_range(momentum_core), 300.0, 0.01, "defaults to 300.0 per weapon_momentum_core.gd's own def.get default")
func test_lance_range_and_beam_range() -> void:
var railgun := WeaponRailgun.new({"base_damage": 5.0, "cooldown_s": 2.2})
assert_almost_eq(WeaponRange.effective_range(railgun), 900.0, 0.01)
var beam := WeaponBeam.new({"base_damage": 1.0, "cooldown_s": 1.0})
assert_almost_eq(WeaponRange.effective_range(beam), 420.0, 0.01)
func test_melee_range() -> void:
var scythe := WeaponScythe.new({"cooldown_s": 1.0})
assert_almost_eq(WeaponRange.effective_range(scythe), 160.0, 0.01)
func test_self_centered_aoe_weapons() -> void:
var orbit := WeaponOrbit.new({"base_damage": 1.0})
assert_almost_eq(WeaponRange.effective_range(orbit), orbit.orbit_radius, 0.01)
var scar_tissue := WeaponScarTissue.new({})
assert_almost_eq(WeaponRange.effective_range(scar_tissue), scar_tissue.radius, 0.01)
func test_const_only_weapons_use_the_exception_table() -> void:
var turret := WeaponTurret.new({"base_damage": 1.0})
assert_almost_eq(WeaponRange.effective_range(turret), 750.0, 0.01, "MAX_RANGE = PROJ_SPEED(500) * PROJ_LIFETIME(1.5)")
var thunder_wake := WeaponThunderWake.new({"base_damage": 1.0, "cooldown_s": 1.0,
"strike_radius": 50.0, "puddle_duration": 2.0})
assert_almost_eq(WeaponRange.effective_range(thunder_wake), 900.0, 0.01)
var chronowell := WeaponChronowell.new({})
assert_almost_eq(WeaponRange.effective_range(chronowell), 400.0, 0.01)
  • Step 2: Run tests to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_range -gexit Expected: FAIL — WeaponRange does not exist yet. (If a test itself fails to even construct a weapon due to a missing required def key, read that weapon’s _init() to find the exact required keys and adjust the test’s def dict — the weapon constructors above were checked against their real _init() signatures, but double-check before assuming a step failure is this task’s own bug.)

  • Step 3: Write the implementation

Create ai/weapon_range.gd:

class_name WeaponRange
extends RefCounted
# Per-weapon effective-range lookup for AI positioning (2026-07-17, Chris: "make most use of his
# weapons by trying to kill off any enemies as fast as possible taking into account weapon
# range"). Reads each weapon INSTANCE's own field at runtime -- not a hardcoded per-id table --
# so ship bonuses or future content rebalances that touch these fields stay correct
# automatically. Every field name below was verified directly against the real sim/weapon_*.gd
# source, not guessed.
# Three weapons have NO instance-var mirror of their range -- only a class-level `const`, which
# Object.get() property reflection cannot see. Verified against sim/weapon_turret.gd (MAX_RANGE =
# PROJ_SPEED(500.0) * PROJ_LIFETIME(1.5)), sim/weapon_thunder_wake.gd (SCAN_RADIUS),
# sim/weapon_chronowell.gd (CLUSTER_SCAN_RADIUS).
const CONST_ONLY_RANGES := {
"WeaponTurret": 750.0,
"WeaponThunderWake": 900.0,
"WeaponChronowell": 400.0,
}
# Documented fallback for any weapon this can't classify (e.g. a future addition whose range
# field doesn't match any known name below) -- a mid-range estimate, not a guess at a specific
# weapon's real reach.
const DEFAULT_RANGE: float = 500.0
# Priority-ordered instance-property probe. Object.get(property) safely returns null for a
# missing property in Godot 4 (no error), so this works uniformly across every archetype that
# exposes its range as an instance var, without per-class `is WeaponXXX` type checks.
static func effective_range(weapon: Object) -> float:
var script := weapon.get_script()
var cls_name := String(script.get_global_name()) if script != null else ""
if CONST_ONLY_RANGES.has(cls_name):
return CONST_ONLY_RANGES[cls_name]
for field in ["lance_range", "max_range", "beam_range", "orbit_radius", "radius", "area", "hop_radius", "range"]:
var v = weapon.get(field)
if v != null:
return float(v)
var speed = weapon.get("proj_speed")
var lifetime = weapon.get("proj_lifetime")
if speed != null and lifetime != null:
return float(speed) * float(lifetime)
return DEFAULT_RANGE
  • Step 4: Run tests to verify they pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_range -gexit Expected: PASS — all 7 tests green. If test_const_only_weapons_use_the_exception_table fails specifically on the cls_name lookup (e.g. get_global_name() returns empty for one of these weapons), read that weapon’s actual class_name line to confirm the exact string used in CONST_ONLY_RANGES matches it exactly (case-sensitive).

  • Step 5: Commit
Terminal window
git add ai/weapon_range.gd tests/test_weapon_range.gd
git commit -m "feat(ai): WeaponRange.effective_range(), per-weapon reach for AI positioning"

Task 2: SkilledAutoPlayer — loadout-derived engage/dash/aim

Section titled “Task 2: SkilledAutoPlayer — loadout-derived engage/dash/aim”

Files:

  • Modify: ai/skilled_auto_player.gd
  • Modify: ai/auto_player.gd
  • Test: tests/test_skilled_auto_player.gd

Interfaces:

  • Consumes: WeaponRange.effective_range(weapon) (Task 1).

  • Produces: SkilledAutoPlayer._median_weapon_range(pilot) -> float. Modified compute_move_for, wants_dash, compute_aim_for (same external signatures, different internal thresholds).

  • Step 1: Write the failing tests

Read the current content of tests/test_skilled_auto_player.gd first — several existing tests assert on the OLD fixed constants (THREAT_RADIUS=300 for the engage cutoff, ENGAGE_DASH_MIN_RANGE/MAX_RANGE=150/400 for proactive dash, AIM_RADIUS=400 for aim fallback). These tests need their enemy positions adjusted to stay meaningful once those cutoffs become loadout-derived instead of fixed — the exact adjustment depends on what loadout each test gives the pilot, which requires reading the CURRENT file content before editing (per this project’s own convention: verify against the real file, don’t guess at line numbers).

Append these NEW tests to tests/test_skilled_auto_player.gd:

func test_median_weapon_range_excludes_the_manual_aim_weapon() -> void:
var sim := Sim.new(1, _content())
# Fresh pilot starts with only "blaster" active (see PilotArsenal.active_weapon_ids default).
var median := SkilledAutoPlayer.new()._median_weapon_range(sim.player)
var blaster: WeaponBlaster = sim.player.arsenal.weapon_by_id["blaster"]
assert_almost_eq(median, WeaponRange.effective_range(blaster), 0.01,
"a single-weapon loadout's median is just that weapon's own range")
func test_median_weapon_range_with_multiple_weapons() -> void:
var sim := Sim.new(1, _content())
sim.upgrade_system.apply_upgrade(sim, sim.player, "weapon:railgun")
var median := SkilledAutoPlayer.new()._median_weapon_range(sim.player)
var blaster: WeaponBlaster = sim.player.arsenal.weapon_by_id["blaster"]
var railgun: WeaponRailgun = sim.player.arsenal.weapon_by_id["railgun"]
var lo: float = minf(WeaponRange.effective_range(blaster), WeaponRange.effective_range(railgun))
var hi: float = maxf(WeaponRange.effective_range(blaster), WeaponRange.effective_range(railgun))
assert_true(median >= lo - 0.01 and median <= hi + 0.01,
"median of two weapons' ranges falls between them")
func test_aim_radius_now_reflects_weapon_aim_real_range() -> void:
var sim := Sim.new(1, _content())
sim.player.pos = Vector2.ZERO
var aim_range := WeaponRange.effective_range(sim.player.arsenal.aim)
# Place an enemy just inside the REAL aim range but outside the old flat 400px cutoff.
sim.enemies.add(Vector2(700, 0), Vector2.ZERO, 14.0, 5.0)
sim.hash.rebuild(sim.enemies)
var aim := SkilledAutoPlayer.new().compute_aim_for(sim, sim.player)
if aim_range > 700.0:
assert_almost_eq(aim.x, 1.0, 0.001, "an enemy at 700px is now a valid aim target since WeaponAim's real range exceeds it")
  • 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: FAIL — _median_weapon_range doesn’t exist yet, and the aim-radius test fails since AIM_RADIUS is still the old flat 400.

  • Step 3: Add compute_aim_for’s AIM_RADIUS replacement to ai/auto_player.gd

Find this exact line (the existing AIM_RADIUS const):

const AIM_RADIUS: float = 400.0 # how far the manual/aim weapon will track an enemy

Replace with:

# 2026-07-17: was a flat 400.0, badly underestimating WeaponAim's real reach (~1012px =
# proj_speed(920) * proj_lifetime(1.1)) -- kept as the class-level FALLBACK default for
# AutoPlayer (which has no loadout-aware override), but SkilledAutoPlayer now computes the
# real value via WeaponRange.effective_range() instead of using this constant directly.
const AIM_RADIUS: float = 400.0 # how far the manual/aim weapon will track an enemy

(AutoPlayer itself is untouched otherwise — it stays the deliberately-simple baseline used by coverage soaks and unaffected by this loadout-awareness work, per this project’s standing “never touch AutoPlayer’s simplicity” convention.)

  • Step 4: Rewrite ai/skilled_auto_player.gd

Read the current file first (to confirm the exact text you’re replacing, since the earlier engage-force-gate fix already changed this file once tonight). Replace the whole file with:

class_name SkilledAutoPlayer
extends 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.
#
# 2026-07-17 (later same night): engage/dash positioning made LOADOUT-AWARE. Chris: "make most
# use of his weapons by trying to kill off any enemies as fast as possible taking into account
# weapon range". Investigated first (not guessed): the manual aim weapon has a real ~1012px
# range vs. the old flat 400px AIM_RADIUS cutoff, and auto-weapons' ranges vary 207-900px with
# zero AI influence over WHICH enemy they hit (that's /sim-internal) -- only over WHERE THE
# PILOT STANDS. `_median_weapon_range()` replaces the old fixed THREAT_RADIUS-reuse and fixed
# [150,400] dash window with the median WeaponRange.effective_range() across active weapons
# (median, not shortest/longest, per Chris's own call -- brings more of the loadout into use
# without forcing risky close range for one niche short-range weapon). Flee stays untouched --
# this only changes how eagerly Ace closes distance to USE its weapons, not how it evades danger.
const ELITE_DANGER_WEIGHT: float = 1.6 # elites are steered away from harder than trash
# Relative to the flee force's peak strength of 1.0 (at point-blank, decaying to 0 at
# THREAT_RADIUS=300). Unchanged from the earlier engage-force fix tonight -- still the right
# balance between "flee wins close-in" and "engage wins at range", independent of this task's
# loadout-derived DISTANCE (which now decides WHEN engage applies, not how hard it pulls).
const ENGAGE_SEEK_WEIGHT: float = 0.5
# Multipliers on the median weapon range, replacing the old fixed ENGAGE_DASH_MIN/MAX_RANGE
# (150/400) and the engage force's old fixed THREAT_RADIUS reuse.
const ENGAGE_RANGE_MULT: float = 1.0 # engage force applies beyond this multiple of median range
const DASH_WINDOW_MIN_MULT: float = 0.5 # proactive dash window: [median*MIN, median*MAX]
const DASH_WINDOW_MAX_MULT: float = 1.2
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
# Only engage a target BEYOND the loadout's typical range (still approaching, not yet in the
# flee zone). Inside it, undiluted flee (plus weapons and dash, both untouched by this gate)
# handles it; engage stays reserved for pulling the pilot toward a cluster still on approach.
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
var to_target_len := to_target.length()
if to_target_len > _median_weapon_range(pilot) * ENGAGE_RANGE_MULT:
engage = (to_target / to_target_len) * 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 within a loadout-derived window of ranges (replacing the old fixed
# [150,400]) -- 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)
var median := _median_weapon_range(pilot)
return dist >= median * DASH_WINDOW_MIN_MULT and dist <= median * DASH_WINDOW_MAX_MULT
# 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
# Aim at the worst threat cluster if it's within the manual weapon's OWN real range (not the
# old flat AIM_RADIUS); otherwise fall back to the base class's nearest-in-range behavior.
func compute_aim_for(sim: Sim, pilot: PlayerState) -> Vector2:
var aim_range := WeaponRange.effective_range(pilot.arsenal.aim)
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_range:
return to_target.normalized()
return super.compute_aim_for(sim, pilot)
# Median WeaponRange.effective_range() across ACTIVE weapons, excluding the always-equipped
# manual aim weapon (handled separately in compute_aim_for -- it doesn't benefit from "getting
# closer" the way auto-targeted weapons do, since it's player/AI-aimed, not proximity-gated).
func _median_weapon_range(pilot: PlayerState) -> float:
var ranges: Array[float] = []
for wid in pilot.arsenal.active_weapon_ids:
var w = pilot.arsenal.weapon_by_id.get(wid)
if w != null:
ranges.append(WeaponRange.effective_range(w))
if ranges.is_empty():
return THREAT_RADIUS # no weapons at all (shouldn't happen in practice) -- safe fallback
ranges.sort()
var mid := ranges.size() / 2
if ranges.size() % 2 == 1:
return ranges[mid]
return (ranges[mid - 1] + ranges[mid]) * 0.5
  • 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. If any PRE-EXISTING test (from before this task) now fails because it asserted on the old fixed 150/400/300-based thresholds, that’s an EXPECTED consequence of this task’s own change (loadout-derived thresholds replaced fixed ones) — update that test’s expected values or enemy distances to match the new loadout-derived behavior for the SAME loadout the test already sets up (a fresh pilot’s default blaster-only loadout), not a sign of a bug. Do not weaken an assertion’s intent, only its concrete numbers, and note every such change in your report.

  • 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: 0 unexpected failures elsewhere. If something outside ai/skilled_auto_player.gd/ ai/auto_player.gd/their tests fails, investigate before committing — this task should not touch anything else.

  • Step 7: Commit
Terminal window
git add ai/auto_player.gd ai/skilled_auto_player.gd tests/test_skilled_auto_player.gd
git commit -m "feat(ai): SkilledAutoPlayer positioning/aim become loadout-aware via WeaponRange"

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, same 4 pre-existing risky/pending.

  • Step 2: Count guard

Run: bash scripts/check-test-count.sh Expected: test-count guard OK (241 scripts — 240 existing + test_weapon_range.gd).

  • Step 3: Boot smoke

Run: godot --headless --path . --quit-after 120 Expected: no SCRIPT ERROR lines.

  • 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 (this feature never touches /sim).

  • Step 5: Soak A/B against the pre-change baseline

This is the real acceptance check — did loadout-aware positioning actually help. Run BOTH on the SAME seed set:

Terminal window
bash scripts/soak.sh --runs=20 --ticks=36000 --ships=cobalt,obsidian --coop=solo --player=skilled --seed0=9000

Compare the reported death_tick_distribution mean against this session’s most recent --player=skilled baseline soak (mean ≈104.6s, from the earlier engage-force fix tonight — same seed0 range is NOT required to match exactly, since that number is itself just a reference point, but a same-session same-seed0=9000 run gives the cleanest paired comparison if time allows running it once more against the CURRENT pre-Task-1 commit via git stash/a throwaway worktree). A result within noise of the current baseline (not a regression) is an acceptable outcome even if it doesn’t show a large further improvement — the point of this task is “use more of the loadout,” which may show up more in kill/DPS signals than survival time alone. Report BOTH survival stats and, if cheap, the run’s damage_by_context breakdown (how many distinct weapon ids actually dealt damage over the run, vs. before this change) as a rough “weapon uptime” proxy.

  • Step 6: Export a fresh macOS build and hand off
Terminal window
godot --headless --path . --import
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.app
git diff --stat project.godot

Expected: export succeeds; project.godot diff is empty. Then open builds/macos/BulletHeaven.app.

  • Step 7: Push
Terminal window
git push origin main