P2 Drone/Decoy System Implementation Plan
P2 Drone/Decoy System Implementation Plan
Section titled “P2 Drone/Decoy System 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: Let a second local-co-op pilot (P2) recharge and deploy their own drone independently of P1 — closing the last item in the co-op sweep backlog — and remove the now-unneeded decoy-proximity damage bonus.
Architecture: Give each DroneState an owner: PlayerState field so a shared drone pool
(Sim.drones, unchanged — enemies already target it as one flat list) can still tell which
pilot’s stats/input/charge govern each drone. Move drone_charge off Sim onto PlayerState
(per-pilot, with a P1-only forwarding accessor for back-compat). Thread Sim.tick()’s single
update_drones call into a per-pilot loop mirroring the existing weapon-firing loop’s
_pilot_inputs pattern. Separately, delete the decoy-proximity damage-synergy mechanic outright.
Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 test framework.
Global Constraints
Section titled “Global Constraints”- Single-player behavior must stay byte-identical (
pilots.size() == 1collapses to today’s exact code path) — re-verify the determinism baseline intests/test_determinism_survival.gdafter every task. sim.drones: Array[DroneState]stays ONE shared flat pool — do NOT split it into per-pilot arrays. Enemy AI (boss_warden.gd,enemy_behaviors.gd) already treats it as one target list vianearest_drone_pos; this is deliberate (either pilot’s drone can distract/tank for the other) and needs zero changes to any enemy-side code.decoy_type,max_drone_slots,_pending_loadoutstay Sim-level shared, unchanged — both pilots’ starter loadout/slot cap mirror the one sharedmetasave profile, same as ship hull.- The mid-run Drone Bay (
main.gd’spause_menu.bay_requested/_open_bay_direct,drone_director.deploy_now/set_loadout) stays P1-only and untouched in behavior — only its one call intodeploy_dronesneeds asim.playerargument added to keep compiling. - The in-play HUD decoy-charge indicator stays wired to P1’s charge only
(
decoy_render_info/drones_render_inforeturn shapes are unchanged) — a P2-specific HUD element is a separate, undesigned feature. - The decoy-proximity damage-synergy mechanic (
Sim._decoy_synergy,DECOY_SYNERGY_RADIUS/DECOY_SYNERGY_BONUS) is being DELETED, not fixed or preserved (Chris’s explicit call, 2026-07-11) — it’s a reactions-mode-era leftover, and a drone’s own attacks/abilities are sufficient value regardless of which pilot stands near it. - A missed call site fails LOUD at load time (GDScript’s static arg-count check), not silently — grep the whole repo to build each task’s change list, then let the importer’s error output catch anything missed.
Task 1: owner/drone_charge per-pilot data model + mechanical signature threading
Section titled “Task 1: owner/drone_charge per-pilot data model + mechanical signature threading”Behavior-preserving for single-player. DroneState gains an owner field; drone_charge moves
to PlayerState; drone_director.gd’s functions that take a DroneState read d.owner instead
of sim.player; deploy_drones/update_drones/decoy_step gain an explicit pilot param; every
existing call site (the one production call site in sim.gd, plus ~50 test call sites) is updated
to keep compiling and single-player byte-identical. Real co-op wiring (calling update_drones once
PER PILOT instead of once with P1 only) is Task 2, not this one.
Files:
- Modify:
sim/drone_state.gd(addownerfield) - Modify:
sim/player_state.gd(adddrone_chargefield) - Modify:
sim/sim.gd(turndrone_chargeinto a forwarding accessor; fix the oneupdate_dronescall site to passplayerexplicitly — NOT yet the real per-pilot loop) - Modify:
sim/drone_director.gd(signatures + internalsim.player→d.owner/pilotsubstitutions, listed function-by-function below) - Modify: 13 test files (exhaustive checklist below)
- Test: full existing GUT suite (behavior is unchanged — the existing suite passing IS the test)
Interfaces:
-
Produces:
DroneState.owner: PlayerState(new field,sim/drone_state.gd)PlayerState.drone_charge: float(new field,sim/player_state.gd, default0.0)DroneDirector.deploy_drones(sim: Sim, pilot: PlayerState, loadout: Array) -> voidDroneDirector.update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> voidDroneDirector.decoy_step(sim: Sim, pilot: PlayerState, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> ArrayDroneDirector.pilot_has_drone(sim: Sim, pilot: PlayerState) -> bool(new helper)- Unchanged signatures:
drone_behavior(sim, d, dt),drone_logistics(sim, d, dt),drone_disruptor(sim, d, dt),drone_bomber(sim, d, dt),bomber_blast(sim, d),bomber_one_blast(sim, d, center),drone_interceptor(sim, d, dt),drone_chain_strike(sim, from, dmg, hops, already),drone_sentinel(sim, d, dt),drone_kinds_for(sim, klass),nearest_enemy_of_kinds(sim, pos, kinds),damage_drones_from_enemies(sim, dt),drone_destroyed(sim, d),drone_pulse(sim, d),pulse_at(sim, pos, dmg, radius),decoy_positions(sim),drones_active(sim),nearest_drone_pos(sim, p)(body changes, signature doesn’t),decoy_render_info(sim),drones_render_info(sim),deploy_now(sim),sentinel_cfg(sim),current_loadout(sim),enemy_speed_scale(sim, i).
-
Consumes:
PlayerState(sim/player_state.gd, existing fieldspos,hp,max_hp,damage_mult,decoy_power_mult,decoy_life_mult),Sim._nearest_pilot(from: Vector2) -> PlayerState(already exists, used bynearest_drone_pos’s new fallback). -
Step 1: Add the
ownerfield toDroneState
sim/drone_state.gd — OLD:
var klass: String = "sentinel" # drone class id (Phase 2 adds bomber/interceptor/disruptor/logistics)var policy: int = 0 # targeting policy (0 = auto; the priority enum lands in Phase 3)NEW:
var owner: PlayerState = null # which pilot fielded this drone (co-op, M-A task, 2026-07-11)var klass: String = "sentinel" # drone class id (Phase 2 adds bomber/interceptor/disruptor/logistics)var policy: int = 0 # targeting policy (0 = auto; the priority enum lands in Phase 3)- Step 2: Add
drone_chargetoPlayerState
sim/player_state.gd — OLD:
var decoy_power_mult: float = 1.0 # meta: scales decoy pulse damagevar decoy_life_mult: float = 1.0 # meta: scales decoy durationNEW:
var decoy_power_mult: float = 1.0 # meta: scales decoy pulse damagevar decoy_life_mult: float = 1.0 # meta: scales decoy duration# Per-pilot launch charge, 0..1 (recharges over time + faster on damage taken; consumed on# deploy). Sim.drone_charge delegates here for P1/single-player back-compat, same pattern as# pending_levelups (co-op, M-A task, 2026-07-11).var drone_charge: float = 0.0- Step 3: Turn
Sim.drone_chargeinto a forwarding accessor
sim/sim.gd — OLD:
var drones: Array[DroneState] = [] # deployed drones (empty = none; Phase 1 = Sentinels)var drone_charge: float = 0.0 # 0..1 shared launch charge (recharges over time + on damage)var drone_recharge_mult: float = 1.0 # INPUT from the shop "drone-recharge" upgrade (1.0 = none → baseline-safe)NEW:
var drones: Array[DroneState] = [] # deployed drones (empty = none; Phase 1 = Sentinels)# Per-pilot forwarding accessor (co-op, M-A task, 2026-07-11) — delegates to player.drone_charge# (P1) for single-player/back-compat callers; player2.drone_charge accrues independently.var drone_charge: float: get: return player.drone_charge set(value): player.drone_charge = valuevar drone_recharge_mult: float = 1.0 # INPUT from the shop "drone-recharge" upgrade (1.0 = none → baseline-safe)- Step 4: Apply the following function-by-function replacements to
sim/drone_director.gd
update_drones — OLD:
func update_drones(sim: Sim, input: InputState, dt: float) -> void: if sim.drones.is_empty(): sim.drone_charge = minf(sim.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0) if input.decoy and sim.drone_charge >= 1.0: deploy_drones(sim, current_loadout(sim)) sim.drone_charge = 0.0 return var i := sim.drones.size() - 1 while i >= 0: var d: DroneState = sim.drones[i] d.life -= dt if d.life <= 0.0: drone_destroyed(sim, d) sim.drones.remove_at(i) i -= 1 continue drone_behavior(sim, d, dt) i -= 1NEW:
func update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> void: if not pilot_has_drone(sim, pilot): pilot.drone_charge = minf(pilot.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0) if input.decoy and pilot.drone_charge >= 1.0: deploy_drones(sim, pilot, current_loadout(sim)) pilot.drone_charge = 0.0 return var i := sim.drones.size() - 1 while i >= 0: var d: DroneState = sim.drones[i] if d.owner != pilot: i -= 1 continue d.life -= dt if d.life <= 0.0: drone_destroyed(sim, d) sim.drones.remove_at(i) i -= 1 continue drone_behavior(sim, d, dt) i -= 1
# True if any drone in the shared pool belongs to this pilot. Gates a pilot's own# recharge-and-deploy cycle independently of any drone the OTHER pilot may already have out.func pilot_has_drone(sim: Sim, pilot: PlayerState) -> bool: for d in sim.drones: if d.owner == pilot: return true return falseNote the gate changed from “is the WHOLE shared pool empty” to “does THIS pilot own a drone” — in single-player these are always equivalent (only one possible owner), so this is behavior-preserving for 1P, but it’s a REQUIRED correctness fix for the shared-pool co-op case landing in Task 2 (otherwise P1 having a drone out would block P2 from ever recharging/deploying their own).
drone_logistics — OLD:
func drone_logistics(sim: Sim, d: DroneState, dt: float) -> void: var to := sim.player.pos - d.pos var dist := to.length() if dist > 1.0: d.pos += (to / dist) * LOGI_SPEED * float(d.cfg.get("speed", 1.0)) * dt d.pulse_timer -= dt if d.pulse_timer <= 0.0: d.pulse_timer = LOGI_CD var power := float(d.cfg.get("dmg", 1.0)) if sim.player.hp < sim.player.max_hp: sim.player.hp = minf(sim.player.hp + LOGI_REPAIR * power, sim.player.max_hp) var rr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0)) for other in sim.drones: if other != d and d.pos.distance_squared_to(other.pos) <= rr * rr: other.life += LOGI_DRONE_REPAIR sim.fx_events.append({"kind": "drone_pulse", "pos": d.pos, "element": sim.blade_element_idx}) var oc := int(d.cfg.get("overcharge", 0)) if oc > 0: d.charge += dt if d.charge >= LOGI_OVERCHARGE_CD: d.charge = 0.0 sim.player.hp = minf(sim.player.hp + LOGI_OVERCHARGE_HEAL * float(oc), sim.player.max_hp) var orr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0)) for other in sim.drones: if other != d and d.pos.distance_squared_to(other.pos) <= orr * orr: other.hp = minf(other.hp + other.max_hp * LOGI_OVERCHARGE_DRONE, other.max_hp) sim.fx_events.append({"kind": "reaction", "pos": d.pos, "element": sim.blade_element_idx, "name": "OVERCHARGE"})NEW: (only the sim.player → d.owner substitutions; the for other in sim.drones:
nearby-drone-repair loops are UNCHANGED and deliberately still iterate the whole shared pool — a
Logistics drone repairs/overcharges any nearby drone regardless of owner, matching the shared-pool
design)
func drone_logistics(sim: Sim, d: DroneState, dt: float) -> void: var to := d.owner.pos - d.pos var dist := to.length() if dist > 1.0: d.pos += (to / dist) * LOGI_SPEED * float(d.cfg.get("speed", 1.0)) * dt d.pulse_timer -= dt if d.pulse_timer <= 0.0: d.pulse_timer = LOGI_CD var power := float(d.cfg.get("dmg", 1.0)) if d.owner.hp < d.owner.max_hp: d.owner.hp = minf(d.owner.hp + LOGI_REPAIR * power, d.owner.max_hp) var rr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0)) for other in sim.drones: if other != d and d.pos.distance_squared_to(other.pos) <= rr * rr: other.life += LOGI_DRONE_REPAIR sim.fx_events.append({"kind": "drone_pulse", "pos": d.pos, "element": sim.blade_element_idx}) var oc := int(d.cfg.get("overcharge", 0)) if oc > 0: d.charge += dt if d.charge >= LOGI_OVERCHARGE_CD: d.charge = 0.0 d.owner.hp = minf(d.owner.hp + LOGI_OVERCHARGE_HEAL * float(oc), d.owner.max_hp) var orr := LOGI_RADIUS * float(d.cfg.get("radius", 1.0)) for other in sim.drones: if other != d and d.pos.distance_squared_to(other.pos) <= orr * orr: other.hp = minf(other.hp + other.max_hp * LOGI_OVERCHARGE_DRONE, other.max_hp) sim.fx_events.append({"kind": "reaction", "pos": d.pos, "element": sim.blade_element_idx, "name": "OVERCHARGE"})drone_disruptor — OLD line:
var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos(the FIRST occurrence, inside drone_disruptor) NEW:
var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.posdrone_bomber — OLD line (SECOND occurrence of the same pattern, inside drone_bomber):
var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.posNEW:
var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.posbomber_one_blast — OLD:
var base := BOMBER_DMG * sim.player.damage_mult * sim.player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))NEW:
var base := BOMBER_DMG * d.owner.damage_mult * d.owner.decoy_power_mult * float(d.cfg.get("dmg", 1.0))drone_interceptor — OLD (the anchor line, THIRD occurrence of the pattern, plus the damage line):
var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.pos var to := anchor - d.pos var dist := to.length() if dist > 1.0: d.pos += (to / dist) * INTERCEPTOR_SPEED * float(d.cfg.get("speed", 1.0)) * dt 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 * sim.player.damage_mult * sim.player.decoy_power_mult * float(d.cfg.get("dmg", 1.0))NEW:
var anchor := sim.enemies.pos[ti] if ti != -1 else d.owner.pos var to := anchor - d.pos var dist := to.length() if dist > 1.0: d.pos += (to / dist) * INTERCEPTOR_SPEED * float(d.cfg.get("speed", 1.0)) * dt 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 * d.owner.damage_mult * d.owner.decoy_power_mult * float(d.cfg.get("dmg", 1.0))drone_sentinel — OLD:
func drone_sentinel(sim: Sim, d: DroneState, dt: float) -> void: d.phase += dt var sp := float(d.cfg.get("speed", 1.0)) var r := decoy_step(sim, d.pos, d.vel, d.phase, dt, sp) d.pos = r[0] d.vel = r[1] for e in d.extra: e["phase"] = float(e["phase"]) + dt var er := decoy_step(sim, e["pos"], e["vel"], float(e["phase"]), dt, sp) e["pos"] = er[0] e["vel"] = er[1] d.pulse_timer -= dt if d.pulse_timer <= 0.0: d.pulse_timer = DECOY_PULSE_INTERVAL drone_pulse(sim, d)NEW:
func drone_sentinel(sim: Sim, d: DroneState, dt: float) -> void: d.phase += dt var sp := float(d.cfg.get("speed", 1.0)) var r := decoy_step(sim, d.owner, d.pos, d.vel, d.phase, dt, sp) d.pos = r[0] d.vel = r[1] for e in d.extra: e["phase"] = float(e["phase"]) + dt var er := decoy_step(sim, d.owner, e["pos"], e["vel"], float(e["phase"]), dt, sp) e["pos"] = er[0] e["vel"] = er[1] d.pulse_timer -= dt if d.pulse_timer <= 0.0: d.pulse_timer = DECOY_PULSE_INTERVAL drone_pulse(sim, d)deploy_drones — OLD:
func deploy_drones(sim: Sim, loadout: Array) -> void: var n := mini(loadout.size(), sim.max_drone_slots) for idx in range(n): var spec: Dictionary = loadout[idx] var d := DroneState.new() d.klass = String(spec.get("klass", DRONE_SENTINEL)) d.policy = int(spec.get("policy", 0)) d.cfg = spec d.active = true d.pos = sim.player.pos d.vel = Vector2.ZERO d.phase = 0.0 d.pulse_timer = 0.0 d.max_hp = DRONE_BASE_HP * float(spec.get("durability", 1.0)) d.hp = d.max_hp d.life = DECOY_LIFE * sim.player.decoy_life_mult * float(spec.get("life", 1.0)) d.extra = [] var extras := int(spec.get("extras", 0)) # carrier/ultimate companions for k in range(extras): var a := TAU * float(k) / float(maxi(extras, 1)) d.extra.append({"pos": sim.player.pos + Vector2(cos(a), sin(a)) * 90.0, "vel": Vector2.ZERO, "phase": a}) sim.drones.append(d) if not sim.drones.is_empty(): sim.fx_events.append({"kind": "reaction", "pos": sim.player.pos, "element": sim.blade_element_idx, "name": "DRONE"})NEW:
func deploy_drones(sim: Sim, pilot: PlayerState, loadout: Array) -> void: var n := mini(loadout.size(), sim.max_drone_slots) var deployed_any := false for idx in range(n): var spec: Dictionary = loadout[idx] var d := DroneState.new() d.owner = pilot d.klass = String(spec.get("klass", DRONE_SENTINEL)) d.policy = int(spec.get("policy", 0)) d.cfg = spec d.active = true d.pos = pilot.pos d.vel = Vector2.ZERO d.phase = 0.0 d.pulse_timer = 0.0 d.max_hp = DRONE_BASE_HP * float(spec.get("durability", 1.0)) d.hp = d.max_hp d.life = DECOY_LIFE * pilot.decoy_life_mult * float(spec.get("life", 1.0)) d.extra = [] var extras := int(spec.get("extras", 0)) # carrier/ultimate companions for k in range(extras): var a := TAU * float(k) / float(maxi(extras, 1)) d.extra.append({"pos": pilot.pos + Vector2(cos(a), sin(a)) * 90.0, "vel": Vector2.ZERO, "phase": a}) sim.drones.append(d) deployed_any = true if deployed_any: sim.fx_events.append({"kind": "reaction", "pos": pilot.pos, "element": sim.blade_element_idx, "name": "DRONE"})Note the if not sim.drones.is_empty(): → if deployed_any: change: the old check relied on the
pool ALWAYS being empty before deploy_drones runs (true in the old single-owner world — the
recharge path only calls it when the pool is empty, and the Bay’s deploy_now clears the pool
immediately first). That invariant breaks once the pool is shared: if P1 already has a drone out
and P2 deploys an EMPTY loadout, sim.drones.is_empty() would still read false (P1’s drone is
still there) even though P2 deployed nothing — deployed_any is a required correctness fix, not
an optional style change.
decoy_step — OLD:
func decoy_step(sim: Sim, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> Array: var ti := sim._nearest_enemy_to(pos) var anchor := sim.enemies.pos[ti] if ti != -1 else sim.player.posNEW:
func decoy_step(sim: Sim, pilot: PlayerState, pos: Vector2, vel: Vector2, phase: float, dt: float, speed_mult: float = 1.0) -> Array: var ti := sim._nearest_enemy_to(pos) var anchor := sim.enemies.pos[ti] if ti != -1 else pilot.pos(The rest of the function body — the wander/steering math — reads no sim.player/pilot state and
is unchanged.)
drone_pulse — OLD:
func drone_pulse(sim: Sim, d: DroneState) -> void: var cfg: Dictionary = d.cfg var dmg := DECOY_BASE_DAMAGE * sim.player.damage_mult * DECOY_POWER * sim.player.decoy_power_mult * float(cfg.get("dmg", 1.0)) var radius := DECOY_PULSE_RADIUS * float(cfg.get("radius", 1.0)) pulse_at(sim, d.pos, dmg, radius) for e in d.extra: pulse_at(sim, e["pos"], dmg, radius) var heal := float(cfg.get("heal", 0.0)) if heal > 0.0: sim.player.hp = minf(sim.player.hp + heal, sim.player.max_hp) # logistics/carrier mend the playerNEW:
func drone_pulse(sim: Sim, d: DroneState) -> void: var cfg: Dictionary = d.cfg var dmg := DECOY_BASE_DAMAGE * d.owner.damage_mult * DECOY_POWER * d.owner.decoy_power_mult * float(cfg.get("dmg", 1.0)) var radius := DECOY_PULSE_RADIUS * float(cfg.get("radius", 1.0)) pulse_at(sim, d.pos, dmg, radius) for e in d.extra: pulse_at(sim, e["pos"], dmg, radius) var heal := float(cfg.get("heal", 0.0)) if heal > 0.0: d.owner.hp = minf(d.owner.hp + heal, d.owner.max_hp) # logistics/carrier mend ITS OWNERnearest_drone_pos — OLD:
func nearest_drone_pos(sim: Sim, p: Vector2) -> Vector2: var best := sim.player.pos var bd := INFNEW:
func nearest_drone_pos(sim: Sim, p: Vector2) -> Vector2: var best := sim._nearest_pilot(p).pos # fallback (no drones out): nearest PILOT, not just P1 var bd := INFBoth of this function’s current callers (boss_warden.gd:147, enemy_behaviors.gd:427/441/493/556)
already guard with if not sim.drones.is_empty() before calling it, so this fallback path is
currently unreachable via any live call site — fix it anyway for correctness/consistency, since
it’s a one-line change matching the established _nearest_pilot convention used everywhere else
in this codebase for exactly this kind of P1-only fallback.
deploy_now — OLD:
func deploy_now(sim: Sim) -> void: sim.drones.clear() deploy_drones(sim, current_loadout(sim)) sim.drone_charge = 0.0NEW:
func deploy_now(sim: Sim) -> void: sim.drones.clear() deploy_drones(sim, sim.player, current_loadout(sim)) sim.drone_charge = 0.0(Stays P1-only per the Bay non-goal — only the new required pilot argument is added.
sim.drone_charge = 0.0 still works correctly unchanged: it’s now the forwarding accessor from
Step 3, which resolves to sim.player.drone_charge = 0.0.)
- Step 5: Fix the one production call site in
sim/sim.gd
sim/sim.gd:795 — OLD:
# NOTE: drone/decoy is still P1-only — `drones`/`drone_charge` are singular Sim fields, not # per-pilot, and only P1's `input` is passed here. P2's decoy edge is computed correctly # (M-A task 11's per-device edge machine) but has no effect. See CLAUDE.md M-A known gaps. drone_director.update_drones(self, input, dt) drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while pausedNEW:
# Still P1-only in THIS task — Task 2 replaces this with a real per-pilot loop. This step # only keeps the project compiling against update_drones's new pilot param. drone_director.update_drones(self, player, input, dt) drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused- Step 6: Fix every test-file call site
Below is the exhaustive, grep-verified (2026-07-11) list. For EVERY line, insert <sim var>.player
as the argument immediately after the sim argument. Two examples:
# BEFOREsim.drone_director.deploy_drones(sim, [_spec()])# AFTERsim.drone_director.deploy_drones(sim, sim.player, [_spec()])
# BEFOREsim.drone_director.update_drones(sim, InputState.new(Vector2.ZERO), Sim_Const.DT)# AFTERsim.drone_director.update_drones(sim, sim.player, InputState.new(Vector2.ZERO), Sim_Const.DT)deploy_drones call sites (sim var shown in parentheses):
tests/test_drone_cfg_scales.gd— 23, 38, 51 (sim)tests/test_drone_classes.gd— 17, 28, 63, 76, 89, 102, 116, 148, 156, 166 (sim)tests/test_drone_sentinel_novel.gd— 18, 28, 38, 53 (sim)tests/test_drone_hp.gd— 15, 24, 35, 45 (sim); 19 (sim2)tests/test_drone_logistics_novel.gd— 20 (s); 33, 42 (sim); 52 (sim, multi-line call — the args span two lines:sim.drone_director.deploy_drones(sim, [_spec(2),then a continuation line{"klass": "sentinel", ...}])— insertsim.playerright after the firstsim,on the first line, same as every other call)tests/test_drone_disruptor_novel.gd— 20 (s); 32, 56, 67 (sim)tests/test_drone_dock.gd— 19 (s)tests/test_drone_bomber_novel.gd— 20, 39 (sim)tests/test_drones_render.gd— 18, 26 (sim)tests/test_drone_interceptor_novel.gd— 22, 37, 55 (sim)tests/test_drones.gd— 45, 66, 85, 93, 103, 114, 120 (sim)tests/test_boss.gd— 161 (sim)
update_drones call sites:
tests/test_drone_sentinel_novel.gd— 59 (sim)tests/test_drones.gd— 17, 34, 37, 53, 57, 87, 88 (sim)tests/test_decoy_types.gd— 13 (sim)
After editing, re-run this grep to confirm zero remaining old-signature calls:
grep -rn "drone_director\.\(deploy_drones\|update_drones\)(" --include="*.gd" main.gd tests sim marketing uiEvery remaining match must already show the .player argument you just added.
- Step 7: Run the full test suite and confirm it’s unchanged
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: the same Scripts count as before this task, 0 failing (a missing call-site fix shows
up here as a GDScript parse error naming the file/line). Godot SIGABRTs (exit 134) at teardown
even on a clean run — read the printed “Run Summary” block, not the exit code.
- Step 8: Verify the determinism baseline is unchanged
Confirm the full suite run in Step 7 still passes tests/test_determinism_survival.gd’s pinned
assertion (this task shouldn’t move the numbers — drones are already documented as never active
in the raw baseline window).
- Step 9: Commit
git add sim/drone_state.gd sim/player_state.gd sim/sim.gd sim/drone_director.gd tests/git commit -m "refactor(drones): thread owner: PlayerState through the drone system
Mechanical, behaviour-preserving sweep — DroneState gains an ownerfield, drone_charge moves to PlayerState (P1-only forwarding accessorkept on Sim), and every drone_director.gd function that readsim.player now reads d.owner or an explicit pilot param. Everyexisting call site updated to pass sim.player, so single-player isbyte-identical. Lays the groundwork for P2's own drone deploy/recharge."Task 2: Sim.tick() co-op wiring — per-pilot drone update + per-pilot damage recharge
Section titled “Task 2: Sim.tick() co-op wiring — per-pilot drone update + per-pilot damage recharge”The real, player-visible feature: each pilot recharges and deploys their own drone independently,
mirroring the existing weapon-firing loop’s _pilot_inputs pattern. The damage-driven recharge
boost (fighting takes damage → extra charge) also becomes per-pilot instead of reading only P1’s
hp_before/hp delta.
Files:
- Modify:
sim/sim.gd(theupdate_dronescall site from Task 1’s Step 5;hp_before; the post-hit-check recharge block) - Test: new file
tests/test_drone_coop.gd
Interfaces:
-
Consumes: Task 1’s
DroneDirector.update_drones(sim, pilot, input, dt),DroneDirector.pilot_has_drone(sim, pilot);Sim._pilot_inputs: Array[InputState](already exists, built once near the top oftick(), index-aligned withpilots). -
Step 1: Write the failing tests
Create tests/test_drone_coop.gd:
extends GutTest
# Co-op drone/decoy: P2's decoy tap (already computing a real per-device edge via InputRouter)# reaches the drone system independently of P1 (M-A co-op task, 2026-07-11). Companion to# tests/test_drones.gd (single-player) and tests/test_coop.gd (general co-op mechanics).
func _sim() -> Sim: return Sim.new(3, SimContentFixture.db())
func _spec() -> Dictionary: return {"klass": "sentinel", "life": 1.0, "dmg": 1.0, "radius": 1.0, "heal": 0.0, "extras": 0}
func test_p2_charges_drone_independently_of_p1() -> void: var sim := _sim() sim.add_pilot() sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) # P1 already has a drone out var c0 := sim.player2.drone_charge for _i in range(60): sim.drone_director.update_drones(sim, sim.player2, InputState.new(Vector2.ZERO), Sim_Const.DT) assert_gt(sim.player2.drone_charge, c0, "P2 still accrues their own charge even though P1's drone is already fielded")
func test_p2_decoy_tap_deploys_a_drone_owned_by_p2() -> void: var sim := _sim() sim.add_pilot() sim.player2.drone_charge = 1.0 sim.drone_director.update_drones(sim, sim.player2, InputState.new(Vector2.ZERO, Vector2.ZERO, true), Sim_Const.DT) assert_eq(sim.drones.size(), 1, "P2's tap deployed a drone") assert_eq(sim.drones[0].owner, sim.player2, "the drone is owned by P2, not P1") assert_almost_eq(sim.player2.drone_charge, 0.0, 0.001, "P2's own charge was consumed")
func test_p1_and_p2_can_both_have_drones_simultaneously() -> void: var sim := _sim() sim.add_pilot() sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) sim.drone_director.deploy_drones(sim, sim.player2, [_spec()]) assert_eq(sim.drones.size(), 2, "both pilots' drones are in the shared pool") assert_eq(sim.drones[0].owner, sim.player) assert_eq(sim.drones[1].owner, sim.player2)
func test_dead_pilot_drone_charge_stops_accruing() -> void: var sim := _sim() sim.add_pilot() sim.player2.hp = 0.0 var c0 := sim.player2.drone_charge for _i in range(60): sim.tick_single(InputState.new(Vector2.ZERO), InputState.new(Vector2.ZERO)) assert_eq(sim.player2.drone_charge, c0, "a downed pilot's drone charge does not accrue")
func test_damage_taken_recharge_is_per_pilot() -> void: var sim := _sim() sim.add_pilot() sim.player.pos = Vector2.ZERO sim.player2.pos = Vector2(500, 500) # far from P1, won't take the same contact hit sim.enemies.add(Vector2.ZERO, Vector2.ZERO, 14.0, 100.0, 0.0, 0.0, 20.0, 1.0) # sits on P1 var p1_c0 := sim.player.drone_charge var p2_c0 := sim.player2.drone_charge sim.tick_single(InputState.new(Vector2.ZERO), InputState.new(Vector2.ZERO)) assert_gt(sim.player.drone_charge - p1_c0, DroneDirector.DECOY_RECHARGE_RATE * Sim_Const.DT, "P1 took damage -> extra charge beyond the time-based trickle") assert_almost_eq(sim.player2.drone_charge, p2_c0 + DroneDirector.DECOY_RECHARGE_RATE * Sim_Const.DT, 0.001, "P2 took no damage -> only the ordinary time-based trickle, unaffected by P1's hit")- Step 2: Run the tests to verify they fail (or pass for the right reason)
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_drone_coop -gexit
test_p2_charges_drone_independently_of_p1 and test_p2_decoy_tap_deploys_a_drone_owned_by_p2
should already PASS — they only exercise drone_director.update_drones(sim, pilot, ...) directly,
already fully pilot-parameterized as of Task 1. test_dead_pilot_drone_charge_stops_accruing and
test_damage_taken_recharge_is_per_pilot should FAIL — they call sim.tick_single, which still
routes drone updates through Task 1’s Step 5 (P1-only update_drones call, and the unmodified
Sim-level hp_before/recharge-on-damage block), so P2 never gets a tick-driven update at all.
- Step 3: Implement the per-pilot drone update loop
sim/sim.gd — OLD (from Task 1’s Step 5):
# Still P1-only in THIS task — Task 2 replaces this with a real per-pilot loop. This step # only keeps the project compiling against update_drones's new pilot param. drone_director.update_drones(self, player, input, dt) drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while pausedNEW:
# Every pilot recharges/deploys their own drone independently (M-A co-op task, 2026-07-11). # Mirrors the weapon-firing loop above: index-aligned _pilot_inputs, dead pilots skipped. var _pi2 := 0 for pilot in pilots: if pilot.hp > 0.0: var pilot_input: InputState = _pilot_inputs[_pi2] if _pi2 < _pilot_inputs.size() else null if pilot_input != null: drone_director.update_drones(self, pilot, pilot_input, dt) _pi2 += 1 drone_director.damage_drones_from_enemies(self, dt) # drones stay destroyable even while paused- Step 4: Make the damage-driven recharge boost per-pilot
sim/sim.gd — OLD:
var hp_before := player.hp # for the decoy's damage-driven recharge (end of tick)NEW:
# Per-pilot damage-driven recharge (M-A co-op task, 2026-07-11) — index-aligned with `pilots`, # same convention as _pilot_inputs. var _hp_before_pilots: Array[float] = [] for pilot in pilots: _hp_before_pilots.append(pilot.hp)sim/sim.gd — OLD:
# Decoy recharges faster the more damage the player took this tick. var dmg_taken := hp_before - player.hp if dmg_taken > 0.0 and drones.is_empty(): drone_charge = minf(drone_charge + dmg_taken * DECOY_RECHARGE_ON_DMG, 1.0)NEW:
# Decoy recharges faster the more damage EACH pilot took this tick — independent per pilot, # gated on that pilot not already having a drone of their own out (not on the shared pool # being globally empty, which would block P2's recharge purely because P1 has a drone out). for pi in range(pilots.size()): var pilot: PlayerState = pilots[pi] var dmg_taken: float = (_hp_before_pilots[pi] if pi < _hp_before_pilots.size() else pilot.hp) - pilot.hp if dmg_taken > 0.0 and not drone_director.pilot_has_drone(self, pilot): pilot.drone_charge = minf(pilot.drone_charge + dmg_taken * DECOY_RECHARGE_ON_DMG, 1.0)- Step 5: Run the tests to verify they pass
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_drone_coop -gexit
Expected: PASS, all 5 tests.
- Step 6: Run the full suite + determinism check
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: same Scripts count as Task 1’s Step 7 plus this task’s new file, 0 failing, determinism
baseline still passes. Single-player collapses to exactly today’s behavior: pilots.size() == 1
means the new loops in Steps 3-4 each run their body exactly once, for player, with
_pilot_inputs[0] == input — identical to Task 1’s Step 5 single call.
- Step 7: Commit
git add sim/sim.gd tests/test_drone_coop.gdgit commit -m "feat(coop): P2 recharges and deploys their own drone independently
Sim.tick() now loops every pilot for drone recharge/deploy (mirroringthe existing weapon-firing loop's _pilot_inputs pattern) and trackseach pilot's own damage-taken recharge boost. Closes the last item inthe co-op sweep backlog — P2's decoy input edge (already computedcorrectly) now actually reaches the drone system."Task 3: Remove the decoy-proximity damage-synergy mechanic
Section titled “Task 3: Remove the decoy-proximity damage-synergy mechanic”Deletes Sim._decoy_synergy and its consts/computation entirely (Chris’s explicit call,
2026-07-11) — a reactions-mode-era mechanic no longer needed now that a drone’s own
attacks/abilities are sufficient value regardless of which pilot stands near it.
Files:
- Modify:
sim/sim.gd(remove 2 consts, 1 field, 1 computation block) - Modify:
sim/elemental_system.gd(remove the multiplier fromdamage_enemy) - Modify:
tests/test_drones.gd(delete the 2 test functions that test the removed mechanic) - Modify:
docs/architecture/meta-and-story.md(update the stale “Decoy overhaul” bullet)
Interfaces: None — pure removal, no new interfaces produced or consumed.
- Step 1: Remove the consts
sim/sim.gd — OLD:
const DECOY_RECHARGE_ON_DMG: float = 0.012 # extra charge per HP of damage takenconst DECOY_SYNERGY_RADIUS: float = 240.0 # fight THIS close to your decoy for a damage bonusconst DECOY_SYNERGY_BONUS: float = 1.0 # up to +100% damage when right on top of it (working closely)var decoy_type: String = "basic" # set by main from the shop selection (meta.selected_decoy)NEW:
const DECOY_RECHARGE_ON_DMG: float = 0.012 # extra charge per HP of damage takenvar decoy_type: String = "basic" # set by main from the shop selection (meta.selected_decoy)- Step 2: Remove the field
sim/sim.gd — OLD:
var _dmgnum_count: int = 0 # per-tick budget for floating damage numbers (reset each tick)var _decoy_synergy: float = 1.0 # damage multiplier from fighting close to your active decoyvar mods: ModStateNEW:
var _dmgnum_count: int = 0 # per-tick budget for floating damage numbers (reset each tick)var mods: ModState- Step 3: Remove the computation block
sim/sim.gd — OLD:
# Decoy synergy: the closer you fight to your active decoy, the more damage you do # (up to +100% on top of it). 1.0 when no decoy -> determinism-safe (never active in baseline). _decoy_synergy = 1.0 if not drones.is_empty(): var dd := player.pos.distance_to(drone_director.nearest_drone_pos(self, player.pos)) if dd < DECOY_SYNERGY_RADIUS: _decoy_synergy = 1.0 + DECOY_SYNERGY_BONUS * (1.0 - dd / DECOY_SYNERGY_RADIUS) if story != null:NEW:
if story != null:- Step 4: Remove the multiplier from
damage_enemy
⚠️ Check this exact line against the live file before editing — a concurrent commit
(dev-tools work, landed 2026-07-11 after this plan was written) may have appended a further
multiplier to the same line, e.g. * sim.dev_damage_mult. If so, preserve every OTHER multiplier
in the chain exactly as found and remove ONLY the sim._decoy_synergy factor — do not revert the
line to the plan’s literal OLD text if the live file has evolved since. As of the last check
(2026-07-11, post-merge with origin/main), the live line reads:
sim/elemental_system.gd — OLD (confirmed current, includes the dev-tools cheat multiplier):
var dealt := effective * vuln_mult(sim, ei) * sim._decoy_synergy * weaken_mult(sim, ei) * mark_mult(sim, ei) * sim.dev_damage_multNEW:
var dealt := effective * vuln_mult(sim, ei) * weaken_mult(sim, ei) * mark_mult(sim, ei) * sim.dev_damage_mult- Step 5: Delete the 2 tests covering the removed mechanic
tests/test_drones.gd — delete test_synergy_scales_with_closeness and
test_synergy_increases_damage_dealt in their entirety (currently lines 63-81, but re-locate by
function name rather than trusting the line numbers, since earlier tasks may have shifted them):
func test_synergy_scales_with_closeness() -> void: var sim := _sim() sim.player.pos = Vector2.ZERO sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) sim.drones[0].life = 10.0 sim.drones[0].pos = Vector2.ZERO # right on the player -> max synergy sim.tick_single(InputState.new(Vector2.ZERO)) assert_gt(sim._decoy_synergy, 1.5, "fighting on top of a drone gives a big damage bonus") sim.drones[0].pos = Vector2(2000, 0) # far away -> no synergy sim.tick_single(InputState.new(Vector2.ZERO)) assert_almost_eq(sim._decoy_synergy, 1.0, 0.05, "drone far away -> no bonus")
func test_synergy_increases_damage_dealt() -> void: var sim := _sim() sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 1000.0, 0.0, 0.0, 5.0, 1.0) sim._decoy_synergy = 2.0 var hp0 := sim.enemies.data[0] sim.elemental_system.damage_enemy(sim, 0, 10.0) assert_almost_eq(hp0 - sim.enemies.data[0], 20.0, 0.5, "synergy doubles the damage dealt (big numbers)")Remove both functions (and the blank line between them) entirely — do not replace with anything.
Note: these two functions already contain a sim.player/deploy_drones call in the OLD form
shown above with the Task-1 fix already applied (sim.player inserted) — by the time Task 3 runs,
that line will already read sim.drone_director.deploy_drones(sim, sim.player, [_spec()]) per
Task 1’s Step 6 fix; delete the whole function regardless of that intermediate state.
- Step 6: Update the stale architecture doc
docs/architecture/meta-and-story.md — OLD (one bullet, currently mid-file — locate by content,
not line number):
- **Decoy overhaul:** flies an organic wander-seek (not an orbit); **synergy** `Sim._decoy_synergy` (in `_damage_enemy`: fight within `DECOY_SYNERGY_RADIUS` of your decoy → up to +100% ALL damage; 1.0 when no decoy so baseline-safe); boss missiles + walk-mobs chase the NEAREST decoy. **Selectable decoy TYPES** (`Sim.DECOY_TYPES` + `Sim.decoy_type`): basic / damage(Striker) / tank(Bulwark) / healer(Mender, heals you per pulse) / ultimate(Swarm — spawns companion decoys in `DecoyState.extra`, all pull aggro + pulse). Decoy stat upgrades go through `StatEffects` (`decoy_power`/`decoy_life` → `player.decoy_*_mult`). Chosen in the shop: `MetaState.selected_decoy` + `owns_decoy()`; tapping an OWNED `decoy:<type>` unlock card EQUIPS it. HUD decoy bar crackles + "DECOY READY" flash when full.NEW:
- **Decoy overhaul:** flies an organic wander-seek (not an orbit); boss missiles + walk-mobs chase the NEAREST decoy. (The proximity damage-synergy bonus this section used to describe was removed 2026-07-11 — a reactions-mode-era mechanic no longer needed once drones carry their own value; see `docs/superpowers/specs/2026-07-11-p2-drone-decoy-design.md`.) **Selectable decoy TYPES** (`Sim.DECOY_TYPES` + `Sim.decoy_type`): basic / damage(Striker) / tank(Bulwark) / healer(Mender, heals you per pulse) / ultimate(Swarm — spawns companion decoys in `DecoyState.extra`, all pull aggro + pulse). Decoy stat upgrades go through `StatEffects` (`decoy_power`/`decoy_life` → `player.decoy_*_mult`). Chosen in the shop: `MetaState.selected_decoy` + `owns_decoy()`; tapping an OWNED `decoy:<type>` unlock card EQUIPS it. HUD decoy bar crackles + "DECOY READY" flash when full.- Step 7: Run the full suite + determinism check
Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: Scripts count is 2 LOWER than Task 2’s Step 6 only in terms of test FUNCTIONS removed
(same script/file count — test_drones.gd still exists, just with 2 fewer functions in it), 0
failing, determinism baseline still passes (this mechanic was already documented as inert in the
baseline window — “1.0 when no decoy so baseline-safe” — so removing it changes nothing there).
- Step 8: Commit
git add sim/sim.gd sim/elemental_system.gd tests/test_drones.gd docs/architecture/meta-and-story.mdgit commit -m "refactor(sim): remove the decoy-proximity damage-synergy mechanic
Sim._decoy_synergy (fight near your decoy -> +damage) is areactions-mode-era mechanic that's no longer needed now that dronescarry their own value regardless of pilot proximity — and it can't bemade correctly per-pilot without threading attacker identity throughthe entire anonymous damage_enemy() pipeline. Chris's call."Self-Review Notes (for whoever executes this plan)
Section titled “Self-Review Notes (for whoever executes this plan)”- Spec coverage: Task 1 covers spec Section 1 (data model). Task 2 covers Section 2 (tick
threading) plus the damage-recharge half of Section 3 that the spec’s Non-goals didn’t
explicitly call out but is required for correctness (found during planning: the old
hp_before/recharge-on-damage block was Sim-level/P1-only, same class of gap as the rest of this system). Task 3 covers Section 4 (decoy-synergy removal) in full, including the architecture-doc update the spec didn’t explicitly list as a file to touch but which the project’s own convention requires (“keep docs current when you change the code”). - Determinism: every task ends with a full-suite run including the pinned determinism test. Drones (and the removed synergy mechanic) are documented as inert within the raw 600-tick single-player baseline window, so no re-pin is expected — but the plan re-verifies every time rather than assuming this.
- Task boundaries: Task 1 cannot be split further —
drone_director.gd’s changed functions call each other internally (update_drones→deploy_drones,drone_sentinel→decoy_step), so a partially-migrated file would not compile. Task 3 (synergy removal) is fully independent of Tasks 1-2 and could theoretically run first, but is sequenced last since it was discovered during this plan’s own research rather than being the spec’s primary subject.