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.
Global Constraints
Section titled “Global Constraints”- No changes to
/sim— this is entirelyai/code. No determinism re-pin expected. - Flee behavior (
compute_move_for’sfleeterm,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_*.gdsource (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 WeaponRangeextends 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
git add ai/weapon_range.gd tests/test_weapon_range.gdgit 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. Modifiedcompute_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 toai/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 enemyReplace 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 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.## 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 rangeconst 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
git add ai/auto_player.gd ai/skilled_auto_player.gd tests/test_skilled_auto_player.gdgit commit -m "feat(ai): SkilledAutoPlayer positioning/aim become loadout-aware via WeaponRange"Task 3: Full-feature verification
Section titled “Task 3: 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, 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:
bash scripts/soak.sh --runs=20 --ticks=36000 --ships=cobalt,obsidian --coop=solo --player=skilled --seed0=9000Compare 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
godot --headless --path . --importgodot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.appgit diff --stat project.godotExpected: export succeeds; project.godot diff is empty. Then open builds/macos/BulletHeaven.app.
- Step 7: Push
git push origin main