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-chunkcycle (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).
Global Constraints
Section titled “Global Constraints”- Determinism byte-identical — survival
1405185210/3122397125, crystals91572468/1173256610(pinned intests/test_determinism_checksum.gd+tests/test_determinism_crystals.gd). The baseline run deploys NO drones, sets NO new EnemyPool column, and draws NOTHING fromdrone_rng. Re-pin NEVER expected — a moved baseline = a leak into the no-drone path → STOP and investigate, do not re-pin. /simstays pure (no Node/Engine/Input/Time/OS/File/JSON). New EnemyPool columns MUST NOT be added tostate_checksum()/snapshot_string()(mirrorslow_timer, which is excluded). All magnitudes are tunable consts near the top ofsim/sim.gd(playtest later).- Don’t touch
CLAUDE.md; nevergit 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 intodata/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_ATTRSconst + fold loop inbuild_drone_loadout) - Test:
tests/test_drone_shop.gd(addtest_novel_attrs_exist),tests/test_drone_loadout.gd(addtest_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):
cd /Users/chris/Claude/bullet-heaven-drones && python3 - <<'PY'import jsonp = "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))PYExpected: 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_FIELDconst, 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:
git add data/bible.json sim/meta_state.gd tests/test_drone_shop.gd tests/test_drone_loadout.gdgit 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_enemiesreflect;_drone_destroyedhelper 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 keysreflect/shield(4C.1). -
Produces:
Sim._drone_destroyed(d: DroneState)(fires the Sentinel shield-pulse ifcfg.shield > 0, then appends adeathfx). 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(afterDRONE_RADIUS):
const SENTINEL_REFLECT_PER_LEVEL: float = 0.30 # fraction of incoming contact dmg returned, per reflect levelconst SENTINEL_SHIELD_DMG: float = 45.0 # base shield-pulse damage (× shield level) on expire/deathconst SENTINEL_SHIELD_RADIUS: float = 150.0 # shield-pulse AoE radius- Step 4: Add
_drone_destroyed+ reflect. Replace the body of_damage_drones_from_enemieswith:
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 continueto:
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:
git add sim/sim.gd tests/test_drone_sentinel_novel.gdgit 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(constBOMBER_PAYLOAD_SPREAD; split_bomber_blastinto_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 atcenter);_bomber_blastnow fires one central blast +payloadextra 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.
git add sim/sim.gd tests/test_drone_bomber_novel.gdgit 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(adddrone_rng: SeededRng+ seed it in_init; consts;_drone_interceptorcrit + 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_rngMUST stay out ofstate_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 baselineSeed 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 levelconst INTERCEPTOR_CRIT_MULT: float = 2.5 # crit damage multiplierconst INTERCEPTOR_CHAIN_RANGE: float = 200.0 # max distance between chain hopsconst INTERCEPTOR_CHAIN_FALLOFF: float = 0.7 # damage retained each hop- Step 4: Implement crit + chain. Replace the
pulse_timerfire block in_drone_interceptorwith:
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
chainfx kind has a consumer.FxManager.consumealready handles achainkind (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_rngmust NOT move the baseline — it isn’t drawn with no drones, and isn’t in the checksum/snapshot). Then:
git add sim/sim.gd tests/test_drone_interceptor_novel.gdgit 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(newweaken_timercolumn, mirrorslow_timerat all 4 sites) - Modify:
sim/sim.gd(constDISRUPTOR_WEAKEN_PER_LEVEL,DISRUPTOR_WEAKEN_S; setweaken_timerin_drone_disruptorwhencfg.weaken > 0; a_weaken_mult(ei)factor folded into_damage_enemy; decayweaken_timeralongsideslow_timer) - Test:
tests/test_drone_disruptor_novel.gd(new — weaken section)
Interfaces:
-
Consumes: cfg key
weaken(4C.1),EnemyPoolcolumn pattern (mirrorslow_timer). -
Produces:
EnemyPool.weaken_timer: PackedFloat32Array;Sim._weaken_mult(ei) -> float(1.0 unless weakened).weaken_timeris NOT added tostate_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_timercolumn 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_timercolumn. Insim/enemy_pool.gd, mirrorslow_timerat all 4 sites:
After var slow_timer: PackedFloat32Array (~line 101):
var weaken_timer: PackedFloat32ArrayAfter slow_timer.resize(cap) (~line 132):
weaken_timer.resize(cap)After slow_timer[i] = 0.0 in add (~line 167):
weaken_timer[i] = 0.0After 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 levelconst DISRUPTOR_WEAKEN_S: float = 0.5 # weaken duration refreshed each tick in-fieldIn _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.0Fold 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_multreturns exactly 1.0 with no field). Then:
git add sim/enemy_pool.gd sim/sim.gd tests/test_drone_disruptor_novel.gdgit commit -m "feat(drones): 4C.5 — Disruptor weakening field (+damage taken)"Task 4C.6: Disruptor — fire suppression
Section titled “Task 4C.6: Disruptor — fire suppression”Files:
- Modify:
sim/enemy_pool.gd(newsuppress_timercolumn, mirrorslow_timer) - Modify:
sim/sim.gd(constDISRUPTOR_SUPPRESS_S; setsuppress_timerin_drone_disruptorwhencfg.suppress > 0; gate the three projectile-fire passes; decaysuppress_timer) - Test:
tests/test_drone_disruptor_novel.gd(extend — suppress section)
Interfaces:
-
Consumes: cfg key
suppress(4C.1), theweaken_timercolumn 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_timerundefined):
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_timercolumn insim/enemy_pool.gd, mirroringslow_timerat all 4 sites (declare, resize,add=0.0,remove_atswap) — identical pattern to 4C.5’sweaken_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-fieldIn _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_SGate 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 itsfor i in …/type guard._update_ranged(line ~1917) — right aftervar eid: int = enemies.entity_id[i](so the timer still carries forward vianext_timers[eid] = elapsed— setnext_timers[eid] = float(_ranged_fire_timers.get(eid, 0.0))beforecontinueso 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_lancersfirst to place the gate correctly (it tracks per-beam state byentity_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:
git add sim/enemy_pool.gd sim/sim.gd tests/test_drone_disruptor_novel.gdgit commit -m "feat(drones): 4C.6 — Disruptor fire suppression (gates ranged-enemy fire)"Task 4C.7: Logistics — overcharge pulse
Section titled “Task 4C.7: Logistics — overcharge pulse”Files:
- Modify:
sim/sim.gd(constsLOGI_OVERCHARGE_CD,LOGI_OVERCHARGE_HEAL,LOGI_OVERCHARGE_DRONE; a separate overcharge timer on the drone — reuseDroneState.chargefield — 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.chargeexists + 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_CDundefined):
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 burstsconst LOGI_OVERCHARGE_HEAL: float = 25.0 # player HP per overcharge level per burstconst LOGI_OVERCHARGE_DRONE: float = 0.4 # fraction of a nearby drone's max HP restored per burstIn _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.
git add sim/sim.gd tests/test_drone_logistics_novel.gdgit commit -m "feat(drones): 4C.7 — Logistics overcharge repair burst"Task 4C.8: 4C review + boundary
Section titled “Task 4C.8: 4C review + boundary”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_rngundrawn in baseline),/simpurity (no Node/ Engine/File/JSON;drone_rngisSeededRng, OK), fx kinds all having aFxManager.consumearm (chain,reactionalready exist; no NEW kinds added), and that every new shop node actually changes behaviour (no dead entries). Optionally dispatch acode-reviewersubagent on the 4C commit range. - Step 2: Fetch + merge origin/main into the branch.
git fetch originthen, if origin advanced,git merge origin/main(resolve anymain.gd/sim.gdconflict — 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.mdwith 4C complete (commit range, green count) and flag Phase 4 (4A+4B+4C) deploy-ready for Chris to mergefeat/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.
Sequencing notes
Section titled “Sequencing notes”- 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_disruptorloop + 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_rngundrawn 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.
Self-Review
Section titled “Self-Review”- 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’scfg.get(...).drone_rngnamed consistently._drone_destroyed/_bomber_one_blast/_drone_chain_strike/_weaken_multdefined before use. EnemyPool columns (weaken_timer/suppress_timer) mirrorslow_timer’s 4 sites. - Placeholder scan: none — every code step shows full code; every run step shows the command + expected.