Skip to content

Drone System — Phase 4C (Per-Class Novel Mechanics) Implementation Plan

Drone System — Phase 4C (Per-Class Novel Mechanics) Implementation Plan

Section titled “Drone System — Phase 4C (Per-Class Novel Mechanics) Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: superpowers:subagent-driven-development or executing-plans. Each task = one bh-dev-chunk cycle (TDD → --import → boot-check → full GUT suite → scripts/check-test-count.sh → determinism). Steps use - [ ].

Goal: Give each drone class its signature, build-defining mechanic (beyond the 4B power/area/ durability/endurance/speed scaling) so a drone loadout is a real tactical choice — Sentinel reflects + detonates, Bomber carpet-bombs, Interceptor chains + crits, Disruptor suppresses fire + weakens, Logistics overcharges.

Architecture: Each mechanic is driven by a new drone-<class>-<attr> shop node (category “Drones”, effect: "drone" so apply_to skips it). MetaState.build_drone_loadout folds each novel attr’s PURCHASED LEVEL into the deploy cfg under its own key (Task 4C.1); the /sim class behaviour reads cfg.get("<attr>", 0) and interprets the level via tunable consts. Everything is opt-in: no drone deploys in the determinism baseline, and every new cfg key / EnemyPool column / rng draw defaults to a no-op there.

Tech Stack: Godot 4.6 typed GDScript; /sim (pure RefCounted, no Node/Engine/File/JSON); GUT 9.6. Branch: feat/drone-system (worktree ~/Claude/bullet-heaven-drones).

  • Determinism byte-identical — survival 1405185210/3122397125, crystals 91572468/1173256610 (pinned in tests/test_determinism_checksum.gd + tests/test_determinism_crystals.gd). The baseline run deploys NO drones, sets NO new EnemyPool column, and draws NOTHING from drone_rng. Re-pin NEVER expected — a moved baseline = a leak into the no-drone path → STOP and investigate, do not re-pin.
  • /sim stays pure (no Node/Engine/Input/Time/OS/File/JSON). New EnemyPool columns MUST NOT be added to state_checksum()/snapshot_string() (mirror slow_timer, which is excluded). All magnitudes are tunable consts near the top of sim/sim.gd (playtest later).
  • Don’t touch CLAUDE.md; never git add -A (stage exact files). Coordinate merges with the UI agent at the 4C boundary (fetch + merge origin/main into the branch).
  • New drone-<class>-<attr> bible entries are HAND-EDITED into data/bible.json (the tab-indented python round-trip), NOT re-exported from seed.js (the bible has drifted ahead — re-export would drop content).

Task 4C.1: Novel-attr shop entries + cfg plumbing

Section titled “Task 4C.1: Novel-attr shop entries + cfg plumbing”

Files:

  • Modify: data/bible.json (data.meta_upgrades += 8 Drones-category novel entries)
  • Modify: sim/meta_state.gd (add _NOVEL_ATTRS const + fold loop in build_drone_loadout)
  • Test: tests/test_drone_shop.gd (add test_novel_attrs_exist), tests/test_drone_loadout.gd (add test_novel_attrs_fold_into_cfg)

Interfaces:

  • Consumes: MetaState.build_drone_loadout(sentinel_cfg: Dictionary, defs: Array = []) -> Array, MetaState.level_of(id: String) -> int, MetaState.owns_class(klass: String) -> bool (4B).

  • Produces: each built drone spec now carries integer keys for its class’s novel attrs (e.g. a bomber spec has spec["payload"] = level_of("drone-bomber-payload")). Novel attr → cfg key map: sentinel→reflect,shield; bomber→payload; interceptor→chain,crit; disruptor→suppress,weaken; logistics→overcharge. Each stores the raw purchased LEVEL (0 if unbought).

  • Step 1: Add the 8 novel bible entries. Run this exact python (tab-indented round-trip):

Terminal window
cd /Users/chris/Claude/bullet-heaven-drones && python3 - <<'PY'
import json
p = "data/bible.json"
d = json.load(open(p))
mu = d["data"]["meta_upgrades"]
have = {u["id"] for u in mu}
NOVEL = [
("drone-sentinel-reflect", "Reflective Plating", 4, "Sentinel returns a share of contact damage to attackers."),
("drone-sentinel-shield", "Shield Burst", 4, "Sentinel detonates an AoE shield pulse when it expires or dies."),
("drone-bomber-payload", "Cluster Payload", 4, "Bomber drops extra blasts in a ring each cycle."),
("drone-interceptor-chain", "Arc Chaining", 4, "Interceptor strikes chain to nearby enemies."),
("drone-interceptor-crit", "Targeting Crit", 4, "Interceptor strikes have a chance to critically hit."),
("drone-disruptor-suppress","Fire Suppression", 1, "Disruptor field stops ranged enemies inside it from firing."),
("drone-disruptor-weaken", "Weakening Field", 4, "Enemies in the Disruptor field take extra damage."),
("drone-logistics-overcharge","Overcharge Pulse", 4, "Logistics periodically vents a powerful repair burst."),
]
for cid, name, maxl, desc in NOVEL:
if cid in have:
continue
mu.append({
"id": cid, "name": name, "desc": desc, "category": "Drones",
"effect": "drone", "magnitude": 1, "base_cost": 50, "cost_growth": 1.5, "max_level": maxl,
})
json.dump(d, open(p, "w"), indent="\t", ensure_ascii=False)
open(p, "a").write("\n")
print("meta_upgrades now:", len(mu))
PY

Expected: prints meta_upgrades now: 53 (45 + 8). Re-running is idempotent (skips existing ids).

  • Step 2: Write the failing shop test. Add to tests/test_drone_shop.gd:
func test_novel_attrs_exist() -> void:
var defs := _meta_upgrades()
var novel := {
"sentinel": ["reflect", "shield"], "bomber": ["payload"],
"interceptor": ["chain", "crit"], "disruptor": ["suppress", "weaken"],
"logistics": ["overcharge"],
}
for klass in novel:
for attr in novel[klass]:
var d := _by_id(defs, "drone-%s-%s" % [klass, attr])
assert_false(d.is_empty(), "drone-%s-%s exists" % [klass, attr])
assert_eq(String(d.get("category", "")), "Drones")
assert_eq(String(d.get("effect", "")), "drone", "novel attrs use the drone effect (apply_to skips)")
assert_gt(int(d.get("max_level", 0)), 0)
  • Step 3: Run it — passes already (data added in Step 1):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_shop.gd -gexit Expected: PASS (the data already exists, so this test confirms the entries).

  • Step 4: Write the failing cfg-plumbing test. Add to tests/test_drone_loadout.gd:
func test_novel_attrs_fold_into_cfg() -> void:
var m := MetaState.new()
m.levels["unlock-drone-interceptor"] = 1
m.levels["drone-interceptor-chain"] = 2
m.levels["drone-interceptor-crit"] = 3
m.drone_loadout = ["interceptor"]
var built := m.build_drone_loadout({})
assert_eq(int(built[0].get("chain", -1)), 2, "purchased chain level folds into the cfg")
assert_eq(int(built[0].get("crit", -1)), 3, "purchased crit level folds into the cfg")
# An un-bought novel attr defaults to 0.
assert_eq(int(built[0].get("payload", 0)), 0, "interceptor has no payload key set above 0")
  • Step 5: Run it — fails (build_drone_loadout doesn’t fold novel attrs yet):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_loadout.gd -gexit Expected: FAIL — chain key absent, built[0].get("chain", -1) returns -1.

  • Step 6: Add the plumbing. In sim/meta_state.gd, after the _ATTR_FIELD const, add:
# Per-class NOVEL mechanic attributes (4C). Unlike _ATTR_FIELD (multiplicative cfg scales), these
# store the raw purchased LEVEL into cfg under the attr name; the /sim class behaviour interprets it.
const _NOVEL_ATTRS := {
"sentinel": ["reflect", "shield"], "bomber": ["payload"],
"interceptor": ["chain", "crit"], "disruptor": ["suppress", "weaken"],
"logistics": ["overcharge"],
}

In build_drone_loadout, change the loop body so that after _apply_class_upgrades(spec, k, defs) it also folds the novel levels:

_apply_class_upgrades(spec, k, defs)
for nattr in _NOVEL_ATTRS.get(k, []):
spec[nattr] = level_of("drone-%s-%s" % [k, nattr])
out.append(spec)
  • Step 7: Run both test files — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_loadout.gd -gtest=res://tests/test_drone_shop.gd -gexit Expected: PASS.

  • Step 8: Full gates + commit. Run boot-check, bash scripts/check-test-count.sh, confirm determinism unchanged. Then:
Terminal window
git add data/bible.json sim/meta_state.gd tests/test_drone_shop.gd tests/test_drone_loadout.gd
git commit -m "feat(drones): 4C.1 — novel-attr shop entries + cfg plumbing"

Task 4C.2: Sentinel — damage-reflect + shield-pulse on expire/death

Section titled “Task 4C.2: Sentinel — damage-reflect + shield-pulse on expire/death”

Files:

  • Modify: sim/sim.gd (consts; _damage_drones_from_enemies reflect; _drone_destroyed helper called at both removal sites — expire in _update_drones, death in _damage_drones_from_enemies)
  • Test: tests/test_drone_sentinel_novel.gd (new)

Interfaces:

  • Consumes: Sim.deploy_drones(loadout), Sim._damage_enemy(ei, amount, pierce_armor=false), Sim._pulse_at(pos, dmg, radius), Sim.DRONE_SENTINEL, Sim.DRONE_RADIUS, cfg keys reflect/shield (4C.1).

  • Produces: Sim._drone_destroyed(d: DroneState) (fires the Sentinel shield-pulse if cfg.shield > 0, then appends a death fx). Called wherever a drone leaves the pool.

  • Step 1: Write the failing tests. Create tests/test_drone_sentinel_novel.gd:

extends GutTest
# Drone Phase 4C.2 — Sentinel novel mechanics: contact-damage reflect + a shield-pulse AoE on
# expire/death. Opt-in (no sentinel in the determinism baseline).
func _sim() -> Sim:
var s := Sim.new(5, SimContentFixture.db())
s.player.pos = Vector2.ZERO
s.max_drone_slots = 1
return s
func _spec(reflect := 0, shield := 0) -> Dictionary:
return {"klass": "sentinel", "speed": 1.0, "dmg": 1.0, "radius": 1.0, "life": 1.0,
"durability": 1.0, "reflect": reflect, "shield": shield}
func test_reflect_damages_attackers() -> void:
var sim := _sim()
sim.deploy_drones([_spec(2, 0)]) # reflect level 2
sim.drones[0].pos = Vector2(300, 0)
sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 30.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._damage_drones_from_enemies(Sim_Const.DT)
assert_lt(sim.enemies.data[0], hp0, "a reflecting Sentinel damages the enemy chipping it")
func test_no_reflect_leaves_attackers_unhurt() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0, 0)]) # reflect level 0
sim.drones[0].pos = Vector2(300, 0)
sim.enemies.add(Vector2(300, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 30.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._damage_drones_from_enemies(Sim_Const.DT)
assert_eq(sim.enemies.data[0], hp0, "no reflect -> the enemy takes no return damage")
func test_shield_pulse_on_death() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0, 2)]) # shield level 2
sim.drones[0].pos = Vector2(300, 0)
sim.drones[0].hp = 0.1
# A bystander enemy inside the shield radius but with low contact so it survives the chip.
sim.enemies.add(Vector2(360, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 1.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._damage_drones_from_enemies(Sim_Const.DT)
assert_true(sim.drones.is_empty(), "the Sentinel died")
assert_lt(sim.enemies.data[0], hp0, "its shield-pulse hit the bystander")
func test_shield_pulse_on_expire() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0, 2)])
sim.drones[0].pos = Vector2(300, 0)
sim.drones[0].life = 0.001 # about to expire this tick
sim.enemies.add(Vector2(360, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 1.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._update_drones(InputState.new(), Sim_Const.DT)
assert_true(sim.drones.is_empty(), "the Sentinel expired")
assert_lt(sim.enemies.data[0], hp0, "its shield-pulse fired on expire too")
  • Step 2: Run — fails (no reflect, no _drone_destroyed):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_sentinel_novel.gd -gexit Expected: FAIL (reflect/shield not implemented; _drone_destroyed undefined).

  • Step 3: Add consts. Near the other drone consts in sim/sim.gd (after DRONE_RADIUS):
const SENTINEL_REFLECT_PER_LEVEL: float = 0.30 # fraction of incoming contact dmg returned, per reflect level
const SENTINEL_SHIELD_DMG: float = 45.0 # base shield-pulse damage (× shield level) on expire/death
const SENTINEL_SHIELD_RADIUS: float = 150.0 # shield-pulse AoE radius
  • Step 4: Add _drone_destroyed + reflect. Replace the body of _damage_drones_from_enemies with:
func _damage_drones_from_enemies(dt: float) -> void:
var i := drones.size() - 1
while i >= 0:
var d: DroneState = drones[i]
var reflect := 0.0
if d.klass == DRONE_SENTINEL:
reflect = float(d.cfg.get("reflect", 0)) * SENTINEL_REFLECT_PER_LEVEL
var dmg := 0.0
for ei in hash.query_circle(d.pos, DRONE_RADIUS, enemies):
var cd := enemies.contact_dmg[ei]
dmg += cd
if reflect > 0.0:
_damage_enemy(ei, cd * reflect * dt)
if dmg > 0.0:
d.hp -= dmg * dt
if d.hp <= 0.0:
_drone_destroyed(d)
drones.remove_at(i)
i -= 1
# A drone leaving the pool (expired or killed). Fires the Sentinel shield-pulse (if owned) then a
# death fx. Drone-only → never runs in the determinism baseline.
func _drone_destroyed(d: DroneState) -> void:
if d.klass == DRONE_SENTINEL:
var shield := int(d.cfg.get("shield", 0))
if shield > 0:
_pulse_at(d.pos, SENTINEL_SHIELD_DMG * float(shield), SENTINEL_SHIELD_RADIUS)
fx_events.append({"kind": "death", "pos": d.pos, "element": -1})
  • Step 5: Route the EXPIRE site through _drone_destroyed. In _update_drones, change the expire branch from:
d.life -= dt
if d.life <= 0.0:
drones.remove_at(i)
i -= 1
continue

to:

d.life -= dt
if d.life <= 0.0:
_drone_destroyed(d)
drones.remove_at(i)
i -= 1
continue
  • Step 6: Run — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_sentinel_novel.gd -gexit Expected: PASS (4 tests).

  • Step 7: Full gates + commit. Boot-check, count guard, determinism unchanged. Then:
Terminal window
git add sim/sim.gd tests/test_drone_sentinel_novel.gd
git commit -m "feat(drones): 4C.2 — Sentinel contact-reflect + shield-pulse on expire/death"

Task 4C.3: Bomber — cluster payload (multi-bomb)

Section titled “Task 4C.3: Bomber — cluster payload (multi-bomb)”

Files:

  • Modify: sim/sim.gd (const BOMBER_PAYLOAD_SPREAD; split _bomber_blast into _bomber_one_blast(d, center)
    • a payload ring loop)
  • Test: tests/test_drone_bomber_novel.gd (new)

Interfaces:

  • Consumes: cfg key payload (4C.1), Sim._damage_enemy, Sim._apply_element, Sim.hash.query_circle.

  • Produces: _bomber_one_blast(d: DroneState, center: Vector2) (a single blast at center); _bomber_blast now fires one central blast + payload extra blasts evenly around a ring.

  • Step 1: Write the failing test. Create tests/test_drone_bomber_novel.gd:

extends GutTest
# Drone Phase 4C.3 — Bomber cluster payload: a payload>0 Bomber lands extra blasts in a ring, so it
# damages enemies the single central blast can't reach.
func _sim() -> Sim:
var s := Sim.new(11, SimContentFixture.db())
s.player.pos = Vector2.ZERO
s.max_drone_slots = 1
return s
func _spec(payload := 0) -> Dictionary:
return {"klass": "bomber", "speed": 1.0, "dmg": 1.0, "radius": 1.0, "life": 1.0,
"durability": 1.0, "payload": payload}
# An enemy parked on the payload ring (beyond the central blast radius) is only hit when payload>0.
func _ring_enemy_hit(payload: int) -> bool:
var sim := _sim()
sim.deploy_drones([_spec(payload)])
sim.drones[0].pos = Vector2.ZERO
# Place an enemy at the ring distance to the +x side (payload index 0 lands at angle 0).
sim.enemies.add(Vector2(Sim.BOMBER_PAYLOAD_SPREAD, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._bomber_blast(sim.drones[0])
return sim.enemies.data[0] < hp0
func test_payload_blasts_reach_the_ring() -> void:
# Central blast radius (BOMBER_RADIUS=110) actually covers BOMBER_PAYLOAD_SPREAD too, so prove the
# ring blast adds coverage with an enemy placed beyond the central reach but within a ring blast.
assert_true(_ring_enemy_hit(1), "a payload-1 Bomber lands a ring blast that hits the ring enemy")
func test_payload_zero_is_single_blast() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0)])
sim.drones[0].pos = Vector2.ZERO
# An enemy FAR outside the central blast — never hit with payload 0.
sim.enemies.add(Vector2(Sim.BOMBER_PAYLOAD_SPREAD * 2.0 + 200.0, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
var hp0 := sim.enemies.data[0]
sim._bomber_blast(sim.drones[0])
assert_eq(sim.enemies.data[0], hp0, "payload 0 -> only the central blast, the far enemy is untouched")
  • Step 2: Run — fails (BOMBER_PAYLOAD_SPREAD + payload behaviour undefined):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_bomber_novel.gd -gexit Expected: FAIL — Sim.BOMBER_PAYLOAD_SPREAD not defined.

  • Step 3: Add the const. Near the Bomber consts in sim/sim.gd:
const BOMBER_PAYLOAD_SPREAD: float = 150.0 # ring radius for cluster-payload extra blasts
  • Step 4: Split _bomber_blast. Replace the current _bomber_blast(d) with:
func _bomber_blast(d: DroneState) -> void:
_bomber_one_blast(d, d.pos)
var payload := int(d.cfg.get("payload", 0))
for k in range(payload):
var a := TAU * float(k) / float(maxi(payload, 1))
_bomber_one_blast(d, d.pos + Vector2(cos(a), sin(a)) * BOMBER_PAYLOAD_SPREAD)
func _bomber_one_blast(d: DroneState, center: Vector2) -> void:
var radius := BOMBER_RADIUS * float(d.cfg.get("radius", 1.0))
var base := BOMBER_DMG * player.damage_mult * player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))
for ei in hash.query_circle(center, radius, enemies):
var dmg := base
if enemies.type_id[ei] in _KIND_HEAVY:
dmg *= BOMBER_HEAVY_BONUS
_damage_enemy(ei, dmg, true) # pierce_armor = the Bomber's signature
_apply_element(ei, blade_element_idx)
fx_events.append({"kind": "reaction", "pos": center, "element": blade_element_idx, "name": "BOMB"})
  • Step 5: Run — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_bomber_novel.gd -gexit Expected: PASS (2 tests).

  • Step 6: Full gates + commit.
Terminal window
git add sim/sim.gd tests/test_drone_bomber_novel.gd
git commit -m "feat(drones): 4C.3 — Bomber cluster payload (ring of extra blasts)"

Task 4C.4: Interceptor — arc chaining + crit (drone_rng stream)

Section titled “Task 4C.4: Interceptor — arc chaining + crit (drone_rng stream)”

Files:

  • Modify: sim/sim.gd (add drone_rng: SeededRng + seed it in _init; consts; _drone_interceptor crit + chain via a self-contained _drone_chain_strike)
  • Test: tests/test_drone_interceptor_novel.gd (new)

Interfaces:

  • Consumes: cfg keys chain/crit (4C.1), Sim._damage_enemy, Sim._apply_element, Sim.hash.query_circle, Sim.enemies, SeededRng.

  • Produces: Sim.drone_rng: SeededRng (isolated drone-randomness stream — never drawn in the baseline); _drone_chain_strike(from: Vector2, dmg: float, hops: int, already: Dictionary) (chains a strike to the nearest not-yet-hit enemies). NOTE: drone_rng MUST stay out of state_checksum/snapshot_string (verify in Step 7).

  • Step 1: Write the failing tests. Create tests/test_drone_interceptor_novel.gd:

extends GutTest
# Drone Phase 4C.4 — Interceptor novel mechanics: arc-chaining (a strike hops to extra nearby enemies)
# and crit (a chance-based bonus via the isolated drone_rng stream). Opt-in -> baseline byte-identical.
func _sim() -> Sim:
var s := Sim.new(13, SimContentFixture.db())
s.player.pos = Vector2.ZERO
s.max_drone_slots = 1
return s
func _spec(chain := 0, crit := 0) -> Dictionary:
return {"klass": "interceptor", "speed": 1.0, "dmg": 1.0, "radius": 1.0, "life": 1.0,
"durability": 1.0, "chain": chain, "crit": crit}
func test_drone_rng_is_isolated() -> void:
var sim := _sim()
assert_not_null(sim.drone_rng, "Sim has a dedicated drone_rng stream")
func test_chain_hits_extra_enemies() -> void:
var sim := _sim()
sim.deploy_drones([_spec(2, 0)]) # chain 2 -> two extra hops
sim.drones[0].pos = Vector2.ZERO
# One enemy in the strike radius, two more strung out beyond it within hop range.
sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0) # in-radius (INTERCEPTOR_RADIUS=70)
sim.enemies.add(Vector2(180, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0) # hop 1
sim.enemies.add(Vector2(320, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0) # hop 2
sim.hash.rebuild(sim.enemies)
var hp = [sim.enemies.data[0], sim.enemies.data[1], sim.enemies.data[2]]
# Force the strike: pulse_timer is 0 after deploy, so one interceptor tick fires.
sim._drone_interceptor(sim.drones[0], Sim_Const.DT)
assert_lt(sim.enemies.data[1], hp[1], "chain hop 1 enemy was hit")
assert_lt(sim.enemies.data[2], hp[2], "chain hop 2 enemy was hit")
func test_no_chain_leaves_distant_enemy() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0, 0)]) # no chain
sim.drones[0].pos = Vector2.ZERO
sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.enemies.add(Vector2(180, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
var far0 := sim.enemies.data[1]
sim._drone_interceptor(sim.drones[0], Sim_Const.DT)
assert_eq(sim.enemies.data[1], far0, "no chain -> the out-of-radius enemy is untouched")
  • Step 2: Run — fails (drone_rng + chain undefined):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_interceptor_novel.gd -gexit Expected: FAIL — sim.drone_rng is null / chain not implemented.

  • Step 3: Add the rng field + consts. Declare near var upgrade_rng: SeededRng (line ~392):
var drone_rng: SeededRng # isolated drone-randomness (crit etc.) — never drawn in the no-drone baseline

Seed it in _init right after upgrade_rng = SeededRng.new(seed_value + 2654435769) (line ~567):

drone_rng = SeededRng.new(seed_value + 374761393)

Add consts near the Interceptor consts:

const INTERCEPTOR_CRIT_PER_LEVEL: float = 0.12 # crit chance per crit level
const INTERCEPTOR_CRIT_MULT: float = 2.5 # crit damage multiplier
const INTERCEPTOR_CHAIN_RANGE: float = 200.0 # max distance between chain hops
const INTERCEPTOR_CHAIN_FALLOFF: float = 0.7 # damage retained each hop
  • Step 4: Implement crit + chain. Replace the pulse_timer fire block in _drone_interceptor with:
d.pulse_timer -= dt
if d.pulse_timer <= 0.0:
d.pulse_timer = INTERCEPTOR_CD
var radius := INTERCEPTOR_RADIUS * float(d.cfg.get("radius", 1.0))
var dmg := INTERCEPTOR_DMG * player.damage_mult * player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))
var crit_chance := float(d.cfg.get("crit", 0)) * INTERCEPTOR_CRIT_PER_LEVEL
var hit := {}
for ei in hash.query_circle(d.pos, radius, enemies):
var hd := dmg
if crit_chance > 0.0 and drone_rng.randf() < crit_chance:
hd *= INTERCEPTOR_CRIT_MULT
_damage_enemy(ei, hd)
_apply_element(ei, blade_element_idx)
hit[enemies.entity_id[ei]] = true
var hops := int(d.cfg.get("chain", 0))
if hops > 0:
_drone_chain_strike(d.pos, dmg * INTERCEPTOR_CHAIN_FALLOFF, hops, hit)

Add the chain helper after _drone_interceptor:

# Chain a strike from `from` to the nearest enemy not in `already` (by entity_id) within
# INTERCEPTOR_CHAIN_RANGE, up to `hops` times, damage falling off each hop. Drone-only.
func _drone_chain_strike(from: Vector2, dmg: float, hops: int, already: Dictionary) -> void:
var pos := from
for _h in range(hops):
var best := -1
var bd := INTERCEPTOR_CHAIN_RANGE * INTERCEPTOR_CHAIN_RANGE
for i in range(enemies.count):
if already.has(enemies.entity_id[i]):
continue
var dsq := pos.distance_squared_to(enemies.pos[i])
if dsq < bd:
bd = dsq
best = i
if best == -1:
return
_damage_enemy(best, dmg)
_apply_element(best, blade_element_idx)
already[enemies.entity_id[best]] = true
fx_events.append({"kind": "chain", "pos": enemies.pos[best], "element": blade_element_idx})
pos = enemies.pos[best]
dmg *= INTERCEPTOR_CHAIN_FALLOFF
  • Step 5: Run — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_interceptor_novel.gd -gexit Expected: PASS (3 tests).

  • Step 6: Verify the chain fx kind has a consumer. FxManager.consume already handles a chain kind (used by the pulse weapon’s chain). Confirm with:

Run: grep -n '"chain"' fx/fx_manager.gd Expected: a non-empty match (a chain case exists). If absent, add a minimal chain arm mirroring the bolt case so the new fx isn’t a silent-invisible event.

  • Step 7: Full gates + commit. Boot-check, count guard, and CRUCIALLY confirm determinism is byte-identical (adding drone_rng must NOT move the baseline — it isn’t drawn with no drones, and isn’t in the checksum/snapshot). Then:
Terminal window
git add sim/sim.gd tests/test_drone_interceptor_novel.gd
git commit -m "feat(drones): 4C.4 — Interceptor arc-chaining + crit (isolated drone_rng)"

Task 4C.5: Disruptor — weakening field (+damage taken)

Section titled “Task 4C.5: Disruptor — weakening field (+damage taken)”

Files:

  • Modify: sim/enemy_pool.gd (new weaken_timer column, mirror slow_timer at all 4 sites)
  • Modify: sim/sim.gd (const DISRUPTOR_WEAKEN_PER_LEVEL, DISRUPTOR_WEAKEN_S; set weaken_timer in _drone_disruptor when cfg.weaken > 0; a _weaken_mult(ei) factor folded into _damage_enemy; decay weaken_timer alongside slow_timer)
  • Test: tests/test_drone_disruptor_novel.gd (new — weaken section)

Interfaces:

  • Consumes: cfg key weaken (4C.1), EnemyPool column pattern (mirror slow_timer).

  • Produces: EnemyPool.weaken_timer: PackedFloat32Array; Sim._weaken_mult(ei) -> float (1.0 unless weakened). weaken_timer is NOT added to state_checksum/snapshot_string.

  • Step 1: Write the failing test. Create tests/test_drone_disruptor_novel.gd:

extends GutTest
# Drone Phase 4C.5 — Disruptor Weakening Field: enemies in the field take extra damage (weaken_timer).
func _sim() -> Sim:
var s := Sim.new(17, SimContentFixture.db())
s.player.pos = Vector2.ZERO
s.max_drone_slots = 1
return s
func _spec(weaken := 0) -> Dictionary:
return {"klass": "disruptor", "speed": 1.0, "dmg": 1.0, "radius": 1.0, "life": 1.0,
"durability": 1.0, "suppress": 0, "weaken": weaken}
func test_weaken_field_sets_timer() -> void:
var sim := _sim()
sim.deploy_drones([_spec(1)])
sim.drones[0].pos = Vector2.ZERO
sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
sim._drone_disruptor(sim.drones[0], Sim_Const.DT)
assert_gt(sim.enemies.weaken_timer[0], 0.0, "an enemy in a weakening field is flagged weakened")
func test_weakened_enemy_takes_more_damage() -> void:
var sim := _sim()
sim.enemies.add(Vector2(500, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
# Baseline hit (no weaken).
var hp0 := sim.enemies.data[0]
sim._damage_enemy(0, 100.0)
var plain := hp0 - sim.enemies.data[0]
# Reset + weaken, same hit.
sim.enemies.data[0] = hp0
sim.enemies.weaken_timer[0] = 1.0
sim._damage_enemy(0, 100.0)
var weak := hp0 - sim.enemies.data[0]
assert_gt(weak, plain, "a weakened enemy takes more from the same hit")
func test_no_weaken_no_timer() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0)]) # weaken 0
sim.drones[0].pos = Vector2.ZERO
sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
sim._drone_disruptor(sim.drones[0], Sim_Const.DT)
assert_eq(sim.enemies.weaken_timer[0], 0.0, "no weaken upgrade -> no weaken flag")
  • Step 2: Run — fails (weaken_timer column undefined):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_disruptor_novel.gd -gexit Expected: FAIL — enemies.weaken_timer not defined.

  • Step 3: Add the weaken_timer column. In sim/enemy_pool.gd, mirror slow_timer at all 4 sites:

After var slow_timer: PackedFloat32Array (~line 101):

var weaken_timer: PackedFloat32Array

After slow_timer.resize(cap) (~line 132):

weaken_timer.resize(cap)

After slow_timer[i] = 0.0 in add (~line 167):

weaken_timer[i] = 0.0

After slow_timer[i] = slow_timer[last] in remove_at (~line 194):

weaken_timer[i] = weaken_timer[last]
  • Step 4: Add consts + the weaken factor + field application + decay in sim/sim.gd.

Consts near the Disruptor consts:

const DISRUPTOR_WEAKEN_PER_LEVEL: float = 0.20 # +damage-taken fraction per weaken level
const DISRUPTOR_WEAKEN_S: float = 0.5 # weaken duration refreshed each tick in-field

In _drone_disruptor, in the for ei in hash.query_circle(d.pos, radius, enemies): loop body (which sets slow_timer), also set the weaken flag:

var weaken_lvl := int(d.cfg.get("weaken", 0))
for ei in hash.query_circle(d.pos, radius, enemies):
enemies.slow_timer[ei] = DISRUPTOR_SLOW_S
if weaken_lvl > 0:
enemies.weaken_timer[ei] = DISRUPTOR_WEAKEN_S

(Compute weaken_lvl once before the loop; replace the existing for ei … slow_timer loop with this.)

Add the factor helper near _vuln_mult:

# Extra-damage-taken multiplier from a Disruptor weakening field. 1.0 unless weakened (0 in the
# no-drone baseline, so byte-identical). The per-level magnitude is the FULL field strength regardless
# of how long it's been applied (the timer is just an in-field membership flag).
func _weaken_mult(ei: int) -> float:
return 1.0 + DISRUPTOR_WEAKEN_PER_LEVEL if enemies.weaken_timer[ei] > 0.0 else 1.0

Fold it into _damage_enemy’s dealt line:

var dealt := effective * _vuln_mult(ei) * _decoy_synergy * _weaken_mult(ei)

In _update_zones (or wherever slow_timer decays — line ~3382), decay weaken_timer next to it:

enemies.slow_timer[i] = maxf(enemies.slow_timer[i] - dt, 0.0)
enemies.weaken_timer[i] = maxf(enemies.weaken_timer[i] - dt, 0.0)
  • Step 5: Run — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_disruptor_novel.gd -gexit Expected: PASS (3 tests).

  • Step 6: Full gates + commit. Confirm determinism byte-identical (new column NOT in checksum; _weaken_mult returns exactly 1.0 with no field). Then:
Terminal window
git add sim/enemy_pool.gd sim/sim.gd tests/test_drone_disruptor_novel.gd
git commit -m "feat(drones): 4C.5 — Disruptor weakening field (+damage taken)"

Files:

  • Modify: sim/enemy_pool.gd (new suppress_timer column, mirror slow_timer)
  • Modify: sim/sim.gd (const DISRUPTOR_SUPPRESS_S; set suppress_timer in _drone_disruptor when cfg.suppress > 0; gate the three projectile-fire passes; decay suppress_timer)
  • Test: tests/test_drone_disruptor_novel.gd (extend — suppress section)

Interfaces:

  • Consumes: cfg key suppress (4C.1), the weaken_timer column pattern (4C.5).

  • Produces: EnemyPool.suppress_timer: PackedFloat32Array; ranged enemies in a suppressing field skip firing in _update_shooters, _update_ranged, _update_lancers. Column NOT in checksum/snapshot.

  • Step 1: Write the failing test. Add to tests/test_drone_disruptor_novel.gd:

func test_suppress_field_sets_timer() -> void:
var sim := _sim()
var spec := _spec(0); spec["suppress"] = 1
sim.deploy_drones([spec])
sim.drones[0].pos = Vector2.ZERO
sim.enemies.add(Vector2(60, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 0.0, 5.0, 1.0)
sim.hash.rebuild(sim.enemies)
sim._drone_disruptor(sim.drones[0], Sim_Const.DT)
assert_gt(sim.enemies.suppress_timer[0], 0.0, "an enemy in a suppressing field is flagged suppressed")
func test_suppressed_zapper_does_not_fire() -> void:
var sim := _sim()
# A zapper that would fire this tick, but is suppressed.
var z := sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 70.0, 5.0, 1.0,
EnemyPool.TYPE_ZAPPER)
sim.enemies.suppress_timer[z] = 1.0
# Drive the fire timer past the interval so it WOULD fire if not suppressed.
for _k in range(600):
sim._update_ranged(Sim_Const.DT)
assert_eq(sim.enemy_proj.count, 0, "a suppressed zapper fires nothing")
func test_unsuppressed_zapper_fires() -> void:
var sim := _sim()
sim.enemies.add(Vector2(200, 0), Vector2.ZERO, 14.0, 999.0, 0.0, 70.0, 5.0, 1.0, EnemyPool.TYPE_ZAPPER)
for _k in range(600):
sim._update_ranged(Sim_Const.DT)
assert_gt(sim.enemy_proj.count, 0, "an un-suppressed zapper does fire (control)")
  • Step 2: Run — fails (suppress_timer undefined):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_disruptor_novel.gd -gexit Expected: FAIL — enemies.suppress_timer not defined.

  • Step 3: Add the suppress_timer column in sim/enemy_pool.gd, mirroring slow_timer at all 4 sites (declare, resize, add=0.0, remove_at swap) — identical pattern to 4C.5’s weaken_timer.

  • Step 4: Apply the field + gate fire + decay in sim/sim.gd.

Const near the Disruptor consts:

const DISRUPTOR_SUPPRESS_S: float = 0.5 # fire-suppression duration refreshed each tick in-field

In _drone_disruptor’s in-field loop (next to slow/weaken), add suppress:

var suppress_on := int(d.cfg.get("suppress", 0)) > 0
for ei in hash.query_circle(d.pos, radius, enemies):
enemies.slow_timer[ei] = DISRUPTOR_SLOW_S
if weaken_lvl > 0:
enemies.weaken_timer[ei] = DISRUPTOR_WEAKEN_S
if suppress_on:
enemies.suppress_timer[ei] = DISRUPTOR_SUPPRESS_S

Gate the three projectile-fire passes — at the TOP of each per-enemy loop body, after the type guard / var i/var eid are known, add if enemies.suppress_timer[i] > 0.0: continue:

  • _update_shooters (line ~1893) — after its for i in …/type guard.
  • _update_ranged (line ~1917) — right after var eid: int = enemies.entity_id[i] (so the timer still carries forward via next_timers[eid] = elapsed — set next_timers[eid] = float(_ranged_fire_timers.get(eid, 0.0)) before continue so a suppressed enemy doesn’t lose its accumulated timer). Concretely:
var eid: int = enemies.entity_id[i]
if enemies.suppress_timer[i] > 0.0:
next_timers[eid] = float(_ranged_fire_timers.get(eid, 0.0))
continue
  • _update_lancers (line ~2058) — at the top of its per-enemy loop (skip the beam wind-up/fire for a suppressed lancer).

Decay suppress_timer next to slow_timer/weaken_timer in _update_zones (~3382):

enemies.suppress_timer[i] = maxf(enemies.suppress_timer[i] - dt, 0.0)
  • Step 5: Run — pass. Read _update_lancers first to place the gate correctly (it tracks per-beam state by entity_id — gate before the state is advanced, like _update_ranged).

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_disruptor_novel.gd -gexit Expected: PASS (suppress + weaken sections).

  • Step 6: Full gates + commit. Confirm determinism byte-identical (suppress never set in the baseline → all gates always-false → byte-identical). Then:
Terminal window
git add sim/enemy_pool.gd sim/sim.gd tests/test_drone_disruptor_novel.gd
git commit -m "feat(drones): 4C.6 — Disruptor fire suppression (gates ranged-enemy fire)"

Files:

  • Modify: sim/sim.gd (consts LOGI_OVERCHARGE_CD, LOGI_OVERCHARGE_HEAL, LOGI_OVERCHARGE_DRONE; a separate overcharge timer on the drone — reuse DroneState.charge field — in _drone_logistics)
  • Test: tests/test_drone_logistics_novel.gd (new)

Interfaces:

  • Consumes: cfg key overcharge (4C.1), DroneState.charge (an unused float field on the drone state), Sim.LOGI_* consts.

  • Produces: a periodic strong heal burst from an overcharged Logistics drone (bigger than the normal pulse, also tops up nearby drones’ HP, not just life).

  • Step 1: Confirm DroneState.charge exists + is free. Run:

grep -n "var charge" sim/drone_state.gd Expected: var charge: float = 0.0 present (per the DroneState fields). It’s used as the overcharge clock here (Logistics doesn’t otherwise use charge).

  • Step 2: Write the failing test. Create tests/test_drone_logistics_novel.gd:
extends GutTest
# Drone Phase 4C.7 — Logistics Overcharge: a periodic strong repair burst (heals the player far more
# than the steady pulse, and restores nearby drones' HP).
func _sim() -> Sim:
var s := Sim.new(19, SimContentFixture.db())
s.player.pos = Vector2.ZERO
s.max_drone_slots = 1
return s
func _spec(overcharge := 0) -> Dictionary:
return {"klass": "logistics", "speed": 1.0, "dmg": 1.0, "radius": 1.0, "life": 1.0,
"durability": 1.0, "overcharge": overcharge}
func test_overcharge_bursts_heal_the_player() -> void:
var sim := _sim()
sim.deploy_drones([_spec(2)])
sim.drones[0].charge = Sim.LOGI_OVERCHARGE_CD # ready to vent
sim.player.max_hp = 200.0
sim.player.hp = 50.0
sim._drone_logistics(sim.drones[0], Sim_Const.DT)
assert_gt(sim.player.hp, 50.0 + Sim.LOGI_REPAIR, "an overcharge burst heals far more than a normal pulse")
func test_no_overcharge_no_burst() -> void:
var sim := _sim()
sim.deploy_drones([_spec(0)]) # overcharge 0
sim.drones[0].charge = 999.0
sim.player.max_hp = 200.0
sim.player.hp = 50.0
# Drive a normal pulse: pulse_timer is 0 after deploy.
sim._drone_logistics(sim.drones[0], Sim_Const.DT)
assert_lte(sim.player.hp, 50.0 + Sim.LOGI_REPAIR + 0.001, "no overcharge -> only the normal pulse heal")
  • Step 3: Run — fails (LOGI_OVERCHARGE_CD undefined):

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_logistics_novel.gd -gexit Expected: FAIL — Sim.LOGI_OVERCHARGE_CD not defined.

  • Step 4: Add consts + the overcharge block. Consts near the Logistics consts:
const LOGI_OVERCHARGE_CD: float = 8.0 # seconds between overcharge bursts
const LOGI_OVERCHARGE_HEAL: float = 25.0 # player HP per overcharge level per burst
const LOGI_OVERCHARGE_DRONE: float = 0.4 # fraction of a nearby drone's max HP restored per burst

In _drone_logistics, after the normal pulse block, add the overcharge clock + burst:

var oc := int(d.cfg.get("overcharge", 0))
if oc > 0:
d.charge += dt
if d.charge >= LOGI_OVERCHARGE_CD:
d.charge = 0.0
player.hp = minf(player.hp + LOGI_OVERCHARGE_HEAL * float(oc), player.max_hp)
var rr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0))
for other in drones:
if other != d and d.pos.distance_squared_to(other.pos) <= rr * rr:
other.hp = minf(other.hp + other.max_hp * LOGI_OVERCHARGE_DRONE, other.max_hp)
fx_events.append({"kind": "reaction", "pos": d.pos, "element": blade_element_idx, "name": "OVERCHARGE"})
  • Step 5: Run — pass.

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_drone_logistics_novel.gd -gexit Expected: PASS (2 tests).

  • Step 6: Full gates + commit.
Terminal window
git add sim/sim.gd tests/test_drone_logistics_novel.gd
git commit -m "feat(drones): 4C.7 — Logistics overcharge repair burst"

Files: none (review + memory + flag).

  • Step 1: Whole-sub-cycle review. Re-read every 4C diff for: determinism parity (each mechanic opt-in / new column out of the checksum / drone_rng undrawn in baseline), /sim purity (no Node/ Engine/File/JSON; drone_rng is SeededRng, OK), fx kinds all having a FxManager.consume arm (chain, reaction already exist; no NEW kinds added), and that every new shop node actually changes behaviour (no dead entries). Optionally dispatch a code-reviewer subagent on the 4C commit range.
  • Step 2: Fetch + merge origin/main into the branch. git fetch origin then, if origin advanced, git merge origin/main (resolve any main.gd/sim.gd conflict — disjoint with the UI agent’s visual work, so a clean 3-way merge is expected). Re-run the full suite + determinism after the merge.
  • Step 3: Update bullet-heaven-roadmap.md with 4C complete (commit range, green count) and flag Phase 4 (4A+4B+4C) deploy-ready for Chris to merge feat/drone-system→main + cut a build to playtest the full drone build-craft layer. Then Phase 5 (counter-tuning vs the biomass enemy taxonomy, with playtest data) is the next sub-cycle.

  • 4C.1 lands the shared plumbing (shop entries + cfg fold) once; 4C.2–4C.7 are independent per-class consumers (any order, but the numbered order keeps the diffs small). 4C.5 (weaken) precedes 4C.6 (suppress) only because they touch the same _drone_disruptor loop + the same EnemyPool column pattern — doing weaken first means suppress just adds a third in-loop assignment + decay line.
  • Every task is determinism-neutral by construction (drones opt-in; new columns default 0 and stay out of the checksum; drone_rng undrawn in the baseline). If any task moves the baseline, STOP — it means a new code path leaked into the no-drone run; fix the leak, do not re-pin.
  • All magnitudes are tunable consts at the top of sim/sim.gd — Phase 5 retunes them against playtest + telemetry data, so don’t agonise over exact values now.
  • Spec coverage: Sentinel reflect ✅ (4C.2) + shield-pulse ✅ (4C.2); Bomber multi-bomb ✅ (4C.3); Interceptor chaining ✅ + crit ✅ (4C.4); Disruptor effect-unlocks slow(base, 4B)→fire-suppress ✅ (4C.6) →weaken ✅ (4C.5); Logistics overcharge ✅ (4C.7), also-repairs-drones already shipped (P2.5) + extended to HP in the overcharge burst. Bomber armor-pen + Logistics repair-rate/radius + Sentinel taunt radius are the 4B scaling attrs (power/area) — covered there, not re-done here.
  • Type consistency: cfg novel keys (reflect/shield/payload/chain/crit/suppress/weaken/ overcharge) match between 4C.1 (_NOVEL_ATTRS + fold) and every consumer’s cfg.get(...). drone_rng named consistently. _drone_destroyed/_bomber_one_blast/_drone_chain_strike/_weaken_mult defined before use. EnemyPool columns (weaken_timer/suppress_timer) mirror slow_timer’s 4 sites.
  • Placeholder scan: none — every code step shows full code; every run step shows the command + expected.