Signature Manual Weapon (Pieces 1+2) — Implementation Plan
Signature Manual Weapon (Pieces 1+2) — Implementation Plan
Section titled “Signature Manual Weapon (Pieces 1+2) — 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 the melee-blade starter with a new simple auto-shooter (Blaster); promote the always-on aimed attack into a first-class WeaponAim object with baseline pierce + impact burst + knockback and ~1.5× the Blaster’s damage; and let that aimed weapon grow on its own in-run level-up path.
Architecture: Two firing systems stay separate — arsenal weapons (active_weapon_ids, auto-targeted, one per slot) and the always-on manual aim attack (Sim._fire_aim, no slot). Piece 1 adds one arsenal weapon (WeaponBlaster) and turns the aim attack’s loose AIM_* consts into a WeaponAim object. Piece 2 offers per-attribute aim: mods through the existing roguelite level-up choice system. Every new sim file is pure RefCounted.
Tech Stack: Godot 4.6 / typed GDScript; GUT 9.6.0 headless; data-oriented ProjPool + SpatialHash; content in data/bible.json (hand-edited).
Global Constraints
Section titled “Global Constraints”/simpurity: every new sim fileextends RefCounted; NO Node/Engine/Input/Time/OS/File/JSON APIs. Render/UI only read sim state.- Determinism: the sim ticks on constant
Sim_Const.DT; all randomness viaSeededRng. The 600-tick baseline is re-pinned exactly ONCE, in Task 2 (the starter swap). Tasks 3–7 must leave it unchanged.state_checksum()hashes only projectilepos/vel(NOT the pool’spierce/split/knockback/burst_radiuscolumns — verifiedsim.gd:1988-1990), and the baseline input never aims (InputState.new(dir)only), so theWeaponAimobject, the newburst_radiuscolumn, and allaim:mods are baseline-invisible. - Two RNG streams:
sim.rng(spawns/sim) andsim.upgrade_rng(upgrade rolls). Aim-mod offering draws ONLY fromupgrade_rng. - New
class_namein/sim: rungodot --headless --path . --importbefore tests/boot, or the stale class cache silently drops the new type. - Damage is float end-to-end; it routes through
elemental_system.damage_enemy(subtract only); removal is the single end-of-tickSim._sweep_dead. Never remove an enemy mid-query. - Per-chunk ritual (
bh-dev-chunk): TDD →--import(if new class) → boot-check (--quit-after 90, grepSCRIPT ERRORempty) → full suite →bash scripts/check-test-count.sh→ determinism assertion → commit. - Names (“Blaster”/“Volt Repeater”) and every stat are provisional/tunable.
- Run a single test:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit. Full suite: swap-gtest=...for-gdir=res://tests -ginclude_subdirs. - Test idiom (copy from
tests/test_weapon_scatter.gd): build the sim withSim.new(<seed>, SimContentFixture.db())(the fixture loads the realdata/bible.json, so the newblasterentry is visible); spawn a test enemy withsim.enemies.add(pos, vel, radius, hp, armor, speed)— passspeed = 0.0to keep it stationary; setsim.player.pos = Vector2.ZEROwhen a shot’s geometry matters;InputState.new(move, aim)fires the manual weapon whenaimclears the deadzone; a fresh Sim defaults toRULESET_REACTIONS(not crystals).
File Structure
Section titled “File Structure”- Create
sim/weapon_blaster.gd(WeaponBlaster) — the auto-shooter arsenal weapon. - Create
sim/weapon_aim.gd(WeaponAim) — the promoted manual attack object. - Create
tests/test_weapon_blaster.gd,tests/test_weapon_aim.gd. - Modify
data/bible.json— add theblasterweapon entry. - Modify
sim/pilot_arsenal.gd— construct blaster + aim; seedactive_weapon_ids = ["blaster"]. - Modify
sim/sim.gd— rewrite_fire_aimto readWeaponAim; repoint theaim_element_idxaccessor →arsenal.aim.element_idx; drop the now-unusedAIM_*consts. - Modify
sim/proj_pool.gd— add theburst_radiuscolumn. - Modify
sim/elemental_system.gd— impact-burst AoE inresolve_collisions;BURST_FRACconst. - Modify
sim/upgrade_system.gd—WEAPON_ORDER(+blade),WEAPON_MODS(+blaster),AIM_MODS, offer + route + preview foraim:. - Modify
tests/test_determinism_checksum.gd,tests/test_determinism_crystals.gd— re-pin (Task 2). - Modify
main.gd/ui/weapon_panel.gd— Blaster dock glyph + always-present Manual/Aim tile (Task 7). - Modify
CLAUDE.md— note the new determinism baseline (Task 2).
Task 1: WeaponBlaster — auto-shooter that fires at the nearest enemy
Section titled “Task 1: WeaponBlaster — auto-shooter that fires at the nearest enemy”Files:
- Create:
sim/weapon_blaster.gd - Modify:
data/bible.json(add theblasterweapon object) - Test:
tests/test_weapon_blaster.gd
Interfaces:
-
Consumes:
Sim.projectiles.add_proj(pos, vel, r, lifetime, dmg, el),Sim.effective_fire_rate(pilot). The weapon carries its OWNelement_idx: int = -1(set by the arsenal in Task 2); Task 1’s standalone test leaves it-1, whichadd_projaccepts — so Task 1 needs no arsenal/Sim change. -
Produces:
WeaponBlaster.new(def: Dictionary),.update(sim: Sim, pilot: PlayerState, dt: float),.apply_mod(kind, mag),.mod_now_after(kind, mag) -> Array,.evolve(), fieldsbase_damage/damage_mult/cooldown/proj_speed/proj_radius/proj_lifetime/shots/element_idx/evolved. -
Step 1: Write the failing test
extends GutTest
func _sim_with_enemy(at: Vector2) -> Sim: var sim := Sim.new(1234, SimContentFixture.db()) sim.player.pos = Vector2.ZERO sim.enemies.add(at, Vector2.ZERO, 12.0, 3.0) # EnemyPool.add(pos, vel, radius, hp) return sim
func test_fires_one_projectile_toward_nearest_enemy() -> void: var sim := _sim_with_enemy(Vector2(100, 0)) var b := WeaponBlaster.new({"base_damage": 4, "cooldown_s": 0.45, "projectile_speed": 720, "projectile_radius": 8, "lifetime_s": 1.0, "projectile_count": 1}) b.update(sim, sim.player, Sim_Const.DT) assert_eq(sim.projectiles.count, 1, "one bullet spawned") assert_gt(sim.projectiles.vel[0].x, 0.0, "aimed toward the +x enemy") assert_almost_eq(sim.projectiles.vel[0].length(), 720.0, 1.0, "muzzle speed") assert_almost_eq(sim.projectiles.damage[0], 4.0, 0.001, "base damage")
func test_respects_cooldown() -> void: var sim := _sim_with_enemy(Vector2(100, 0)) var b := WeaponBlaster.new({"base_damage": 4, "cooldown_s": 0.45, "projectile_speed": 720, "projectile_radius": 8, "lifetime_s": 1.0, "projectile_count": 1}) b.update(sim, sim.player, Sim_Const.DT) # fires b.update(sim, sim.player, Sim_Const.DT) # still cooling down assert_eq(sim.projectiles.count, 1, "no second shot inside the cooldown")
func test_multishot_fires_several() -> void: var sim := _sim_with_enemy(Vector2(100, 0)) var b := WeaponBlaster.new({"base_damage": 4, "cooldown_s": 0.45, "projectile_speed": 720, "projectile_radius": 8, "lifetime_s": 1.0, "projectile_count": 1}) b.apply_mod("multishot", 1.0) b.update(sim, sim.player, Sim_Const.DT) assert_eq(sim.projectiles.count, 2, "multishot fires 2")-
Step 2: Run it to verify it fails —
... -gtest=res://tests/test_weapon_blaster.gd -gexit. Expected: parse error /WeaponBlasternot found. -
Step 3: Implement
sim/weapon_blaster.gd
class_name WeaponBlasterextends RefCounted
# Simple auto-shooter (the starter): on cooldown, fire one (or `shots`) projectile(s) at the# nearest enemy. Uses the projectile pool → collisions/reactions come free from# Sim.elemental_system.resolve_collisions (same pattern as scatter/turret).
var base_damage: floatvar damage_mult: float = 1.0var cooldown: floatvar proj_speed: floatvar proj_radius: floatvar proj_lifetime: floatvar shots: int = 1var element_idx: int = -1 # set by the arsenal after construction (Task 2); -1 = neutralvar evolved: bool = falsevar _timer: float = 0.0
const SPREAD: float = 0.10 # radians between multishot pellets
func _init(def: Dictionary) -> void: base_damage = float(def["base_damage"]) cooldown = float(def["cooldown_s"]) proj_speed = float(def["projectile_speed"]) proj_radius = float(def["projectile_radius"]) proj_lifetime = float(def["lifetime_s"]) shots = int(def.get("projectile_count", 1))
func nearest_enemy_index(sim: Sim, pilot: PlayerState) -> int: var best := -1 var best_d2 := INF for i in range(sim.enemies.count): var d2 := pilot.pos.distance_squared_to(sim.enemies.pos[i]) if d2 < best_d2: best_d2 = d2 best = i return best
func update(sim: Sim, pilot: PlayerState, dt: float) -> void: _timer -= dt if _timer > 0.0: return var target := nearest_enemy_index(sim, pilot) if target == -1: return _timer = cooldown / sim.effective_fire_rate(pilot) var base_a := (sim.enemies.pos[target] - pilot.pos).angle() var dmg := base_damage * damage_mult var n := maxi(shots, 1) for k in range(n): var off: float = 0.0 if n == 1 else (float(k) - float(n - 1) * 0.5) * SPREAD var a := base_a + off sim.projectiles.add_proj(pilot.pos, Vector2(cos(a), sin(a)) * proj_speed, proj_radius, proj_lifetime, dmg, element_idx)
func cooldown_frac() -> float: return clampf(1.0 - _timer / maxf(cooldown, 0.001), 0.0, 1.0)
func apply_mod(kind: String, mag: float) -> void: match kind: "power": damage_mult *= mag "rate": cooldown *= mag # mag < 1 = faster (matches nova/turret "rate") "multishot": shots += 1
func mod_now_after(kind: String, mag: float) -> Array: match kind: "power": return ["×%.2f" % damage_mult, "×%.2f" % (damage_mult * mag)] "rate": return ["%.2fs" % cooldown, "%.2fs" % (cooldown * mag)] "multishot": return ["%d" % shots, "%d" % (shots + 1)] return ["", ""]
func evolve() -> void: # → "Auto-Cannon" evolved = true shots += 1 cooldown *= 0.7 damage_mult *= 1.4- Step 4: Add the
blasterentry todata/bible.json— in the top-levelweaponslist, mirroring thepulseentry’s keys:
{ "id": "blaster", "name": "Blaster", "archetype": "projectile", "element": "aether", "base_damage": 4, "cooldown_s": 0.45, "projectile_speed": 720, "projectile_radius": 8, "lifetime_s": 1.0, "projectile_count": 1, "area": 0, "pierce": 0, "level_max": 8, "evolution": "Auto-Cannon", "tags": ["projectile"], "live": true, "max_range": 9999}- Step 5: Import + run the test to verify it passes
Run: godot --headless --path . --import then ... -gtest=res://tests/test_weapon_blaster.gd -gexit. Expected: PASS. (If sim.enemies.add(...) signature differs, copy the exact enemy-spawn helper another weapon test uses — grep tests/test_weapon_scatter.gd for its enemy setup.)
- Step 6: Commit
git add sim/weapon_blaster.gd tests/test_weapon_blaster.gd data/bible.jsongit commit -m "feat(weapon): WeaponBlaster auto-shooter (fires bullet at nearest enemy)"Task 2: Make Blaster the starter, keep blade acquirable, re-pin determinism
Section titled “Task 2: Make Blaster the starter, keep blade acquirable, re-pin determinism”Files:
- Modify:
sim/pilot_arsenal.gd(construct blaster + set itselement_idx; seedactive_weapon_ids) - Modify:
sim/upgrade_system.gd:24(WEAPON_ORDER),:27(WEAPON_MODS) - Modify:
tests/test_determinism_checksum.gd,tests/test_determinism_crystals.gd(re-pin) - Modify:
CLAUDE.md(baseline note) - Test: extend
tests/test_weapon_blaster.gd
Interfaces:
-
Consumes:
WeaponBlaster(Task 1, incl. itselement_idxfield),PilotArsenal.weapon_by_id,ContentDB.weapon("blaster"),ContentDB.element_index(name). -
Produces: a fresh run’s
active_weapon_ids == ["blaster"]; the arsenal’sblasterweapon haselement_idxset;"blade"present inWEAPON_ORDER. -
Step 1: Write the failing tests (append to
tests/test_weapon_blaster.gd)
func test_fresh_run_starts_with_blaster() -> void: var sim := Sim.new(1234, SimContentFixture.db()) assert_eq(sim.active_weapon_ids, ["blaster"] as Array[String], "starter is the Blaster, not the blade")
func test_blade_is_still_offerable() -> void: assert_true(UpgradeSystem.WEAPON_ORDER.has("blade"), "blade stays acquirable at level-up") assert_false(UpgradeSystem.WEAPON_ORDER.has("blaster"), "the starter is not offered as a grant")-
Step 2: Run to verify they fail — Expected:
active_weapon_idsis["blade"];WEAPON_ORDERlacksblade. -
Step 3: Construct the Blaster in
PilotArsenal._init(sim/pilot_arsenal.gd) — add a fieldvar blaster: WeaponBlasternear the other weapon fields, and in_init(before theweapon_by_id = {...}line):
blaster = WeaponBlaster.new(content.weapon("blaster")) blaster.element_idx = content.element_index(content.weapon("blaster").get("element", ""))Add "blaster": blaster to the weapon_by_id dict literal, and change the seed line:
active_weapon_ids = ["blaster"] # start with the auto-shooter; blade is now acquirable, not the starter(No Sim accessor is needed — the element lives on the weapon, set above.)
- Step 4: Update
upgrade_system.gd—WEAPON_ORDER(L24) add"blade"(keep blaster OUT):
const WEAPON_ORDER: Array[String] = ["blade", "pulse", "nova", "orbit", "beam", "turret", "scatter"]and add a WEAPON_MODS row for the Blaster (L27 dict):
"blaster": [["power", 1.25], ["rate", 0.80], ["multishot", 1.0]],- Step 5: Import, boot-check, run the full suite to capture the NEW baseline
Run: godot --headless --path . --import then the full suite. tests/test_determinism_checksum.gd and tests/test_determinism_crystals.gd will FAIL, printing the actual new snapshot_string().hash(), state_checksum() (survival) and the crystals state_checksum(). Record those exact printed integers.
-
Step 6: Re-pin the baseline — the literal assertions are
tests/test_determinism_checksum.gd:41-42(survivalsnapshot_string().hash()+state_checksum()) andtests/test_determinism_crystals.gd:29-30(crystalssnapshot_string().hash()+state_checksum()). Both currently share2730172591/4075578713. They MAY now diverge — the Blaster’saetherelement applies reactions in survival mode but not in crystals mode — so pin EACH file to ITS OWN observed values from Step 5; do not assume they still match. Update the “Determinism baseline” line inCLAUDE.mdto the new survival pair (and note the crystals pair if it now differs). -
Step 7: Verify — full suite green,
bash scripts/check-test-count.shOK, boot-check clean. If any offer-set/count test now fails becauseWEAPON_ORDERgrew (grep failing output for offer counts), update it to includebladeas offerable. -
Step 8: Commit
git add sim/pilot_arsenal.gd sim/upgrade_system.gd tests/test_determinism_checksum.gd tests/test_determinism_crystals.gd tests/test_weapon_blaster.gd CLAUDE.mdgit commit -m "feat(weapon): Blaster is the starter; blade now acquirable; re-pin determinism"Task 3: Promote the manual attack to a WeaponAim object (behaviour-identical refactor)
Section titled “Task 3: Promote the manual attack to a WeaponAim object (behaviour-identical refactor)”Files:
- Create:
sim/weapon_aim.gd - Modify:
sim/pilot_arsenal.gd(constructaim;aim_element_idxfield moves onto it) - Modify:
sim/sim.gd(_fire_aimreads the object;aim_element_idxaccessor; drop the now-unusedAIM_*consts) - Test:
tests/test_weapon_aim.gd
Interfaces:
-
Consumes:
Sim.projectiles.add_proj(...),Sim.effective_fire_rate(pilot),InputState.aim_dir. -
Produces:
WeaponAim.new()with fieldsdamage/cooldown/proj_speed/proj_radius/proj_lifetime/knockback/deadzone/element_idx/pierce/burst_radius/timer;arsenal.aim: WeaponAim. In THIS taskpierce = 0andburst_radius = 0.0(byte-identical to the old_fire_aim). Task 4 raises them. -
Step 1: Write the failing test (
tests/test_weapon_aim.gd)
extends GutTest
func _aiming_sim() -> Sim: var sim := Sim.new(1234, SimContentFixture.db()) return sim
func test_aim_fires_projectile_in_aim_direction() -> void: var sim := _aiming_sim() var inp := InputState.new(Vector2.ZERO, Vector2(1, 0)) # move zero, aim +x sim.tick_single(inp) assert_eq(sim.projectiles.count, 1, "aiming past the deadzone fires one shot") assert_gt(sim.projectiles.vel[0].x, 0.0, "shot travels along the aim dir") assert_almost_eq(sim.projectiles.damage[0], 6.0, 0.001, "aim base damage")
func test_aim_object_defaults_are_behaviour_identical() -> void: var aim := WeaponAim.new() assert_almost_eq(aim.damage, 6.0, 0.001) assert_almost_eq(aim.cooldown, 0.24, 0.001) assert_almost_eq(aim.proj_speed, 920.0, 0.001) assert_almost_eq(aim.knockback, 260.0, 0.001) assert_eq(aim.pierce, 0, "Task 3 refactor keeps pierce 0 (Task 4 raises it)") assert_almost_eq(aim.burst_radius, 0.0, 0.001, "Task 3 keeps burst off")(Confirm InputState.new(move, aim) signature at sim/input_state.gd:16 — if aim is a second positional arg, the above is correct.)
-
Step 2: Run to verify it fails —
WeaponAimnot found. -
Step 3: Implement
sim/weapon_aim.gd
class_name WeaponAimextends RefCounted
# The always-on MANUAL (aimed) attack, promoted from loose AIM_* consts on Sim into a first-class# object so it can carry upgradeable state (Piece 2). NOT an arsenal weapon (no slot); fired by# Sim._fire_aim whenever the aim input is pushed past `deadzone`.
const DEF_DAMAGE: float = 6.0const DEF_COOLDOWN: float = 0.24const DEF_PROJ_SPEED: float = 920.0const DEF_PROJ_RADIUS: float = 10.0const DEF_PROJ_LIFETIME: float = 1.1const DEF_KNOCKBACK: float = 260.0const DEF_DEADZONE: float = 0.35
var damage: float = DEF_DAMAGEvar cooldown: float = DEF_COOLDOWNvar proj_speed: float = DEF_PROJ_SPEEDvar proj_radius: float = DEF_PROJ_RADIUSvar proj_lifetime: float = DEF_PROJ_LIFETIMEvar knockback: float = DEF_KNOCKBACKvar deadzone: float = DEF_DEADZONEvar element_idx: int = -1var pierce: int = 0 # Task 4 sets the baseline effect to 1var burst_radius: float = 0.0 # Task 4 sets the baseline effect to 48var timer: float = 0.0- Step 4: Wire it into
PilotArsenal._init(sim/pilot_arsenal.gd) — addvar aim: WeaponAim, and in_initreplace theaim_element_idx = content.element_index("aether")line with:
aim = WeaponAim.new() aim.element_idx = content.element_index("aether")Remove the standalone aim_timer field (the timer now lives on aim). Keep aim_element_idx ONLY if other code reads it directly — otherwise delete it and let the Sim.aim_element_idx accessor point at aim.element_idx (next step).
- Step 5: Rewrite
Sim._fire_aim(sim/sim.gd:1491) to read the object, and repoint the accessor:
func _fire_aim(pilot: PlayerState, pilot_input: InputState, dt: float) -> void: var aim := pilot.arsenal.aim aim.timer -= dt if pilot_input.aim_dir.length() < aim.deadzone: return if aim.timer > 0.0: return aim.timer = aim.cooldown / effective_fire_rate(pilot) var dir := pilot_input.aim_dir.normalized() projectiles.add_proj(pilot.pos, dir * aim.proj_speed, aim.proj_radius, aim.proj_lifetime, aim.damage * pilot.damage_mult, aim.element_idx, aim.pierce, 0, aim.knockback) # burst_radius arg added in Task 4Repoint aim_element_idx (sim.gd ~L360) to player.arsenal.aim.element_idx (get/set). Delete the now-unused AIM_DMG/AIM_COOLDOWN/AIM_PROJ_SPEED/AIM_PROJ_RADIUS/AIM_PROJ_LIFETIME/AIM_KNOCKBACK/AIM_DEADZONE consts (grep confirms _fire_aim was their only reader).
-
Step 6: Import + run tests + determinism —
--import, the aim test passes, boot-check clean, full suite green, and the determinism baseline from Task 2 is UNCHANGED (the values are byte-identical; the baseline never aims).check-test-count.shOK. -
Step 7: Commit
git add sim/weapon_aim.gd sim/pilot_arsenal.gd sim/sim.gd tests/test_weapon_aim.gdgit commit -m "refactor(weapon): promote the manual aim attack to a WeaponAim object (no behaviour change)"Task 4: Baseline aim effects — pierce 1 + impact burst + keep knockback
Section titled “Task 4: Baseline aim effects — pierce 1 + impact burst + keep knockback”Files:
- Modify:
sim/proj_pool.gd(addburst_radiuscolumn) - Modify:
sim/elemental_system.gd(BURST_FRACconst; burst AoE inresolve_collisions) - Modify:
sim/weapon_aim.gd(pierce = 1,burst_radius = 48.0) - Modify:
sim/sim.gd(_fire_aimpassesaim.burst_radius) - Test: extend
tests/test_weapon_aim.gd
Interfaces:
-
Consumes:
ProjPool.add_proj(..., knockback_v, burst_radius_v)(extended here),elemental_system.damage_enemy,sim.hash.query_circle(center, radius, enemies). -
Produces:
ProjPool.burst_radius: PackedFloat32Array;add_proj’s new trailingburst_radius_v: float = 0.0param; burst applied inresolve_collisions. -
Step 1: Write the failing tests (append to
tests/test_weapon_aim.gd)
func test_aim_shot_pierces_one_enemy() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.player.pos = Vector2.ZERO sim.player.arsenal.active_weapon_ids.clear() # isolate the manual weapon (no Blaster auto-fire) # add(pos, vel, radius, hp, armor=0, speed=0 → stationary) sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 20.0, 100.0, 0.0, 0.0) # near sim.enemies.add(Vector2(120, 0), Vector2.ZERO, 20.0, 100.0, 0.0, 0.0) # behind, lined up (60px from #0 → outside the 48 burst) sim.tick_single(InputState.new(Vector2.ZERO, Vector2(1, 0))) # fire an aim shot +x for _i in range(20): sim.tick_single(InputState.new(Vector2.ZERO, Vector2.ZERO)) # let it travel through both assert_lt(sim.enemies.data[0], 100.0, "first enemy damaged") assert_lt(sim.enemies.data[1], 100.0, "second enemy damaged too — the shot pierced through")
func test_aim_shot_bursts_neighbours() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.player.pos = Vector2.ZERO sim.player.arsenal.active_weapon_ids.clear() # isolate the manual weapon (no Blaster auto-fire) sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 20.0, 100.0, 0.0, 0.0) # struck directly sim.enemies.add(Vector2(60, 40), Vector2.ZERO, 20.0, 100.0, 0.0, 0.0) # 40px away: outside direct hit (r 20+10=30), inside burst (48) sim.tick_single(InputState.new(Vector2.ZERO, Vector2(1, 0))) for _i in range(20): sim.tick_single(InputState.new(Vector2.ZERO, Vector2.ZERO)) assert_lt(sim.enemies.data[1], 100.0, "neighbour took burst splash (not a direct hit)")
func test_aim_defaults_now_have_effects() -> void: var aim := WeaponAim.new() assert_eq(aim.pierce, 1) assert_almost_eq(aim.burst_radius, 48.0, 0.001)-
Step 2: Run to verify they fail — pierce is 0 / no burst column yet.
-
Step 3: Add the
burst_radiuscolumn tosim/proj_pool.gd— a newvar burst_radius: PackedFloat32Array,burst_radius.resize(cap)in_init, a trailing param + assignment inadd_proj, and the swap inremove_at:
var burst_radius: PackedFloat32Array # >0 = pop a small AoE on impact (the manual shot). 0 = none.# ... in _init: burst_radius.resize(cap)func add_proj(p: Vector2, v: Vector2, r: float, lifetime: float, dmg: float, el: int = -1, pierce_v: int = 0, split_v: int = 0, knockback_v: float = 0.0, burst_radius_v: float = 0.0) -> int: var i := super.add(p, v, r, lifetime) if i != -1: damage[i] = dmg; element_idx[i] = el; pierce[i] = pierce_v split[i] = split_v; knockback[i] = knockback_v; burst_radius[i] = burst_radius_v return i# ... in remove_at (the i != last block): burst_radius[i] = burst_radius[last]- Step 4: Add the burst AoE to
elemental_system.resolve_collisions— aconst BURST_FRAC: float = 0.5near the other consts, and inside theif hit_ei != -1:block, right AFTERapply_element(sim, hit_ei, el)(before the knockback/pierce lines):
var br: float = sim.projectiles.burst_radius[pi] if br > 0.0: var bdmg := dmg * BURST_FRAC for bei in sim.hash.query_circle(ppos, br, sim.enemies): if bei != hit_ei: damage_enemy(sim, bei, bdmg)(The hash was already rebuilt at the top of resolve_collisions, and damage_enemy only subtracts HP — safe mid-loop under the deferred-death rule.)
- Step 5: Turn the effects on — in
sim/weapon_aim.gdsetvar pierce: int = 1andvar burst_radius: float = 48.0; inSim._fire_aimadd the trailingaim.burst_radiusarg to theadd_projcall:
projectiles.add_proj(pilot.pos, dir * aim.proj_speed, aim.proj_radius, aim.proj_lifetime, aim.damage * pilot.damage_mult, aim.element_idx, aim.pierce, 0, aim.knockback, aim.burst_radius)-
Step 6: Import + test + determinism —
--import, the pierce/burst tests pass, boot-check clean, full suite green, baseline UNCHANGED from Task 2 (theburst_radiuscolumn is not hashed and the baseline never aims).check-test-count.shOK. -
Step 7: Commit
git add sim/proj_pool.gd sim/elemental_system.gd sim/weapon_aim.gd sim/sim.gd tests/test_weapon_aim.gdgit commit -m "feat(weapon): aim shot gains baseline pierce + impact burst (knockback kept)"Task 5: WeaponAim upgrade mods — apply_mod + mod_now_after
Section titled “Task 5: WeaponAim upgrade mods — apply_mod + mod_now_after”Files:
- Modify:
sim/weapon_aim.gd - Test: extend
tests/test_weapon_aim.gd
Interfaces:
-
Produces:
WeaponAim.apply_mod(kind: String, mag: float)andmod_now_after(kind: String, mag: float) -> Arrayfor kindspower/pierce/burst/knockback/firerate/velocity. -
Step 1: Write the failing tests (append)
func test_aim_mods_change_the_right_field() -> void: var aim := WeaponAim.new() aim.apply_mod("power", 1.25); assert_almost_eq(aim.damage, 7.5, 0.001) aim.apply_mod("pierce", 1.0); assert_eq(aim.pierce, 2) aim.apply_mod("burst", 1.20); assert_almost_eq(aim.burst_radius, 57.6, 0.01) aim.apply_mod("knockback", 1.30); assert_almost_eq(aim.knockback, 338.0, 0.01) aim.apply_mod("velocity", 1.20); assert_almost_eq(aim.proj_speed, 1104.0, 0.01) var before := aim.cooldown aim.apply_mod("firerate", 0.85); assert_almost_eq(aim.cooldown, before * 0.85, 0.001)
func test_aim_mod_now_after_preview() -> void: var aim := WeaponAim.new() var na := aim.mod_now_after("pierce", 1.0) assert_eq(na[0], "1"); assert_eq(na[1], "2")-
Step 2: Run to verify they fail —
apply_modnot found onWeaponAim. -
Step 3: Add the methods to
sim/weapon_aim.gd
func apply_mod(kind: String, mag: float) -> void: match kind: "power": damage *= mag "pierce": pierce += 1 "burst": burst_radius *= mag "knockback": knockback *= mag "firerate": cooldown *= mag # mag < 1 = faster "velocity": proj_speed *= mag
func mod_now_after(kind: String, mag: float) -> Array: match kind: "power": return ["%.1f" % damage, "%.1f" % (damage * mag)] "pierce": return ["%d" % pierce, "%d" % (pierce + 1)] "burst": return ["%.0f" % burst_radius, "%.0f" % (burst_radius * mag)] "knockback": return ["%.0f" % knockback, "%.0f" % (knockback * mag)] "firerate": return ["%.2fs" % cooldown, "%.2fs" % (cooldown * mag)] "velocity": return ["%.0f" % proj_speed, "%.0f" % (proj_speed * mag)] return ["", ""]-
Step 4: Run the test to verify it passes. Full suite green; determinism unchanged.
-
Step 5: Commit
git add sim/weapon_aim.gd tests/test_weapon_aim.gdgit commit -m "feat(weapon): WeaponAim.apply_mod/mod_now_after for the six aim attributes"Task 6: Offer + route the aim: mods through the level-up system
Section titled “Task 6: Offer + route the aim: mods through the level-up system”Files:
- Modify:
sim/upgrade_system.gd(AIM_MODS,aim_mod_mag, offer inroll_upgrade_choices, route inapply_upgrade, preview inupgrade_preview+ name/label) - Test:
tests/test_aim_upgrades.gd; update any offer-set/count test the new pool breaks
Interfaces:
-
Consumes:
sim.upgrade_rng,sim.player.arsenal.aim(WeaponAim),Sim.roll_upgrade_choices,Sim.apply_upgrade. -
Produces:
UpgradeSystem.AIM_MODS; ids of the form"aim:<kind>";aim_mod_mag(kind) -> float. -
Step 1: Write the failing tests (
tests/test_aim_upgrades.gd)
extends GutTest
func test_apply_aim_mod_upgrades_the_aim_weapon() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.apply_upgrade("aim:pierce") assert_eq(sim.player.arsenal.aim.pierce, 2, "aim:pierce routed to the aim weapon")
func test_aim_mods_are_offer_eligible_but_capped_one_per_roll() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.player.level = 1 var saw_aim_over_many_rolls := false for _r in range(40): var choices := sim.roll_upgrade_choices(3) var aim_count := 0 for c in choices: if c.begins_with("aim:"): aim_count += 1 assert_lte(aim_count, 1, "never more than one aim mod in a single roll") if aim_count == 1: saw_aim_over_many_rolls = true assert_true(saw_aim_over_many_rolls, "aim mods do get offered (always eligible)")-
Step 2: Run to verify they fail —
apply_upgrade("aim:pierce")is a no-op / not routed. -
Step 3: Add
AIM_MODS+aim_mod_magtosim/upgrade_system.gd(nearWEAPON_MODS):
# The manual (aim) weapon's upgradeable attributes. Always offer-eligible (the aim weapon is always# "owned"); roll_upgrade_choices adds at most ONE per 3-choice roll so it can't flood arsenal picks.const AIM_MODS := [["power", 1.25], ["pierce", 1.0], ["burst", 1.20], ["knockback", 1.30], ["firerate", 0.85], ["velocity", 1.20]]
func aim_mod_mag(kind: String) -> float: for m in AIM_MODS: if m[0] == kind: return float(m[1]) return 1.0- Step 4: Offer one aim mod per roll — in
roll_upgrade_choices, right after thewmod_idsper-weapon loop (and OUTSIDE the crystals branch, i.e. it flows through the normal non-crystals selection likewmod_ids), add:
# The manual (aim) weapon is always owned → always offer-eligible; add at most ONE candidate # per roll so it can't crowd out arsenal upgrades. Draw from the upgrade_rng stream only. if sim.ruleset != sim.RULESET_CRYSTALS: var ak: String = AIM_MODS[sim.upgrade_rng.randi() % AIM_MODS.size()][0] wmod_ids.append("aim:" + ak)(Confirm wmod_ids is concatenated into the candidate pool that gets shuffled + sliced to n in the non-crystals path; if the pool variable has a different name at the assembly point, append there instead.)
- Step 5: Route + preview the
aim:prefix — inapply_upgrade(upgrade_system.gd:198), add a branch alongsidewm::
elif id.begins_with("aim:"): sim.player.arsenal.aim.apply_mod(id.substr(4), aim_mod_mag(id.substr(4)))In upgrade_preview (~L322) and the name/label helper (~L300), add an aim: branch mirroring wm::
if id.begins_with("aim:"): var akind := id.substr(4) var na: Array = sim.player.arsenal.aim.mod_now_after(akind, aim_mod_mag(akind)) return {"id": id, "kind": "weapon", "name": "Aim · " + akind.capitalize(), "glyph": "aim", "label": "Manual shot: " + akind, "now": na[0], "after": na[1]}(Match the exact Dictionary keys the sibling wm: branch returns in each function.)
-
Step 6: Import + run the new test + full suite —
--import;tests/test_aim_upgrades.gdpasses. The full suite may fail an offer-set/count test now that the pool gained anaim:candidate — grep the failing output, find the test asserting an exact offer set/size (searchtests/forroll_upgrade_choices), and update its expectation to allow the aim candidate. Determinism baseline UNCHANGED (baseline never levels up → never rolls).check-test-count.shOK. -
Step 7: Commit
git add sim/upgrade_system.gd tests/test_aim_upgrades.gd tests/<any-updated-offer-test>.gdgit commit -m "feat(upgrades): offer + route aim: mods (<=1 per roll) into the level-up system"Task 7: UI — Blaster dock glyph + always-present Manual/Aim tile
Section titled “Task 7: UI — Blaster dock glyph + always-present Manual/Aim tile”Files:
- Modify:
main.gd(weapon render LUT / glyph forblaster; render the aim lance tint + burstfx_event) - Modify:
ui/weapon_panel.gd(a fixed, always-present “Manual/Aim” tile showing the aim weapon’s state)
Interfaces:
- Consumes:
sim.active_weapon_views()(dock data),sim.player.arsenal.aim(aim state),sim.fx_events(burst pops).
This task is render/feel — verified by playing, not a checksum. Keep it determinism-inert (render only reads sim state).
-
Step 1: Blaster glyph. Give
blastera dock glyph/colour in the same LUT the other weapon ids use (grepmain.gdfor wherepulse/scatterglyphs/colours are keyed; add ablasterentry — aether-tinted). -
Step 2: Always-present Manual/Aim tile. In
ui/weapon_panel.gd, render a fixed tile (leftmost, distinct from theactive_weapon_views()tiles) labelled “Aim” that shows the aim weapon’s current stats on hover (damage,pierce,burst_radius), readingsim.player.arsenal.aim. It is always shown (the manual weapon is always available), unlike the acquired-weapon tiles. -
Step 3: Aim lance + burst FX. Render the aim projectile with a brighter/longer tint than auto-shots (a neon lance); when a burst fires,
resolve_collisionsshould emit anfx_eventsentry (e.g.{"kind": "burst", "pos": ppos, "radius": br}) andmain.gdrenders a short neon pop (mirror how nova/reaction FX events are drawn). Add thefx_events.append(...)in the Task-4 burst block if you want the pop (FX is render-only, excluded from the checksum — safe to add without re-pinning). -
Step 4: Play-verify. Export a local macOS build (
godot --headless --path . --export-release "macOS" builds/macos/BulletHeaven.appthen run it) or press F5: confirm the run starts with the Blaster auto-firing, the aimed shot pierces + pops a burst + shoves enemies, and a level-up sometimes offers an “Aim · …” card. Boot-check clean; full suite + count guard green; determinism unchanged. -
Step 5: Commit
git add main.gd ui/weapon_panel.gd sim/elemental_system.gdgit commit -m "feat(ui): Blaster dock glyph + always-present Manual/Aim tile + aim lance/burst FX"After all tasks
Section titled “After all tasks”- Full suite +
scripts/check-test-count.shgreen; determinism re-pinned once (Task 2) and stable thereafter. - This is a shippable gameplay change → bump
Sim_Const.BUILDand deploy viabh-deploy(a separate step, coordinated with the live co-agent). - Update the roadmap memory (
bullet-heaven-roadmap) with the new build + the re-pinned baseline.