Skip to content

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).

  • /sim purity: every new sim file extends 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 via SeededRng. 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 projectile pos/vel (NOT the pool’s pierce/split/knockback/burst_radius columns — verified sim.gd:1988-1990), and the baseline input never aims (InputState.new(dir) only), so the WeaponAim object, the new burst_radius column, and all aim: mods are baseline-invisible.
  • Two RNG streams: sim.rng (spawns/sim) and sim.upgrade_rng (upgrade rolls). Aim-mod offering draws ONLY from upgrade_rng.
  • New class_name in /sim: run godot --headless --path . --import before 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-tick Sim._sweep_dead. Never remove an enemy mid-query.
  • Per-chunk ritual (bh-dev-chunk): TDD → --import (if new class) → boot-check (--quit-after 90, grep SCRIPT ERROR empty) → 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 with Sim.new(<seed>, SimContentFixture.db()) (the fixture loads the real data/bible.json, so the new blaster entry is visible); spawn a test enemy with sim.enemies.add(pos, vel, radius, hp, armor, speed) — pass speed = 0.0 to keep it stationary; set sim.player.pos = Vector2.ZERO when a shot’s geometry matters; InputState.new(move, aim) fires the manual weapon when aim clears the deadzone; a fresh Sim defaults to RULESET_REACTIONS (not crystals).
  • 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 the blaster weapon entry.
  • Modify sim/pilot_arsenal.gd — construct blaster + aim; seed active_weapon_ids = ["blaster"].
  • Modify sim/sim.gd — rewrite _fire_aim to read WeaponAim; repoint the aim_element_idx accessor → arsenal.aim.element_idx; drop the now-unused AIM_* consts.
  • Modify sim/proj_pool.gd — add the burst_radius column.
  • Modify sim/elemental_system.gd — impact-burst AoE in resolve_collisions; BURST_FRAC const.
  • Modify sim/upgrade_system.gdWEAPON_ORDER (+blade), WEAPON_MODS (+blaster), AIM_MODS, offer + route + preview for aim:.
  • 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 the blaster weapon 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 OWN element_idx: int = -1 (set by the arsenal in Task 2); Task 1’s standalone test leaves it -1, which add_proj accepts — 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(), fields base_damage/damage_mult/cooldown/proj_speed/proj_radius/proj_lifetime/shots/element_idx/evolved.

  • Step 1: Write the failing test

tests/test_weapon_blaster.gd
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 / WeaponBlaster not found.

  • Step 3: Implement sim/weapon_blaster.gd

class_name WeaponBlaster
extends 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: float
var damage_mult: float = 1.0
var cooldown: float
var proj_speed: float
var proj_radius: float
var proj_lifetime: float
var shots: int = 1
var element_idx: int = -1 # set by the arsenal after construction (Task 2); -1 = neutral
var evolved: bool = false
var _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 blaster entry to data/bible.json — in the top-level weapons list, mirroring the pulse entry’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
Terminal window
git add sim/weapon_blaster.gd tests/test_weapon_blaster.gd data/bible.json
git 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 its element_idx; seed active_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. its element_idx field), PilotArsenal.weapon_by_id, ContentDB.weapon("blaster"), ContentDB.element_index(name).

  • Produces: a fresh run’s active_weapon_ids == ["blaster"]; the arsenal’s blaster weapon has element_idx set; "blade" present in WEAPON_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_ids is ["blade"]; WEAPON_ORDER lacks blade.

  • Step 3: Construct the Blaster in PilotArsenal._init (sim/pilot_arsenal.gd) — add a field var blaster: WeaponBlaster near the other weapon fields, and in _init (before the weapon_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.gdWEAPON_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 (survival snapshot_string().hash() + state_checksum()) and tests/test_determinism_crystals.gd:29-30 (crystals snapshot_string().hash() + state_checksum()). Both currently share 2730172591/4075578713. They MAY now diverge — the Blaster’s aether element 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 in CLAUDE.md to the new survival pair (and note the crystals pair if it now differs).

  • Step 7: Verify — full suite green, bash scripts/check-test-count.sh OK, boot-check clean. If any offer-set/count test now fails because WEAPON_ORDER grew (grep failing output for offer counts), update it to include blade as offerable.

  • Step 8: Commit

Terminal window
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.md
git 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 (construct aim; aim_element_idx field moves onto it)
  • Modify: sim/sim.gd (_fire_aim reads the object; aim_element_idx accessor; drop the now-unused AIM_* 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 fields damage/cooldown/proj_speed/proj_radius/proj_lifetime/knockback/deadzone/element_idx/pierce/burst_radius/timer; arsenal.aim: WeaponAim. In THIS task pierce = 0 and burst_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 failsWeaponAim not found.

  • Step 3: Implement sim/weapon_aim.gd

class_name WeaponAim
extends 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.0
const DEF_COOLDOWN: float = 0.24
const DEF_PROJ_SPEED: float = 920.0
const DEF_PROJ_RADIUS: float = 10.0
const DEF_PROJ_LIFETIME: float = 1.1
const DEF_KNOCKBACK: float = 260.0
const DEF_DEADZONE: float = 0.35
var damage: float = DEF_DAMAGE
var cooldown: float = DEF_COOLDOWN
var proj_speed: float = DEF_PROJ_SPEED
var proj_radius: float = DEF_PROJ_RADIUS
var proj_lifetime: float = DEF_PROJ_LIFETIME
var knockback: float = DEF_KNOCKBACK
var deadzone: float = DEF_DEADZONE
var element_idx: int = -1
var pierce: int = 0 # Task 4 sets the baseline effect to 1
var burst_radius: float = 0.0 # Task 4 sets the baseline effect to 48
var timer: float = 0.0
  • Step 4: Wire it into PilotArsenal._init (sim/pilot_arsenal.gd) — add var aim: WeaponAim, and in _init replace the aim_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 4

Repoint 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.sh OK.

  • Step 7: Commit

Terminal window
git add sim/weapon_aim.gd sim/pilot_arsenal.gd sim/sim.gd tests/test_weapon_aim.gd
git 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 (add burst_radius column)
  • Modify: sim/elemental_system.gd (BURST_FRAC const; burst AoE in resolve_collisions)
  • Modify: sim/weapon_aim.gd (pierce = 1, burst_radius = 48.0)
  • Modify: sim/sim.gd (_fire_aim passes aim.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 trailing burst_radius_v: float = 0.0 param; burst applied in resolve_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_radius column to sim/proj_pool.gd — a new var burst_radius: PackedFloat32Array, burst_radius.resize(cap) in _init, a trailing param + assignment in add_proj, and the swap in remove_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 — a const BURST_FRAC: float = 0.5 near the other consts, and inside the if hit_ei != -1: block, right AFTER apply_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.gd set var pierce: int = 1 and var burst_radius: float = 48.0; in Sim._fire_aim add the trailing aim.burst_radius arg to the add_proj call:
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 (the burst_radius column is not hashed and the baseline never aims). check-test-count.sh OK.

  • Step 7: Commit

Terminal window
git add sim/proj_pool.gd sim/elemental_system.gd sim/weapon_aim.gd sim/sim.gd tests/test_weapon_aim.gd
git 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) and mod_now_after(kind: String, mag: float) -> Array for kinds power/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 failapply_mod not found on WeaponAim.

  • 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

Terminal window
git add sim/weapon_aim.gd tests/test_weapon_aim.gd
git 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 in roll_upgrade_choices, route in apply_upgrade, preview in upgrade_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 failapply_upgrade("aim:pierce") is a no-op / not routed.

  • Step 3: Add AIM_MODS + aim_mod_mag to sim/upgrade_system.gd (near WEAPON_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 the wmod_ids per-weapon loop (and OUTSIDE the crystals branch, i.e. it flows through the normal non-crystals selection like wmod_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 — in apply_upgrade (upgrade_system.gd:198), add a branch alongside wm::
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.gd passes. The full suite may fail an offer-set/count test now that the pool gained an aim: candidate — grep the failing output, find the test asserting an exact offer set/size (search tests/ for roll_upgrade_choices), and update its expectation to allow the aim candidate. Determinism baseline UNCHANGED (baseline never levels up → never rolls). check-test-count.sh OK.

  • Step 7: Commit

Terminal window
git add sim/upgrade_system.gd tests/test_aim_upgrades.gd tests/<any-updated-offer-test>.gd
git 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 for blaster; render the aim lance tint + burst fx_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 blaster a dock glyph/colour in the same LUT the other weapon ids use (grep main.gd for where pulse/scatter glyphs/colours are keyed; add a blaster entry — aether-tinted).

  • Step 2: Always-present Manual/Aim tile. In ui/weapon_panel.gd, render a fixed tile (leftmost, distinct from the active_weapon_views() tiles) labelled “Aim” that shows the aim weapon’s current stats on hover (damage, pierce, burst_radius), reading sim.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_collisions should emit an fx_events entry (e.g. {"kind": "burst", "pos": ppos, "radius": br}) and main.gd renders a short neon pop (mirror how nova/reaction FX events are drawn). Add the fx_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.app then 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

Terminal window
git add main.gd ui/weapon_panel.gd sim/elemental_system.gd
git commit -m "feat(ui): Blaster dock glyph + always-present Manual/Aim tile + aim lance/burst FX"

  • Full suite + scripts/check-test-count.sh green; determinism re-pinned once (Task 2) and stable thereafter.
  • This is a shippable gameplay change → bump Sim_Const.BUILD and deploy via bh-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.