Skip to content

P2 Upgrade Pipeline 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) level up, see their own upgrade choices, and apply them independently — completing the P2 progression pipeline (XP banking already works; nothing downstream of it does yet).

Architecture: Thread an explicit pilot: PlayerState parameter through sim/upgrade_system.gd (17 of its ~29 functions) and sim/sim.gd’s crystal-threshold auto-upgrade system, replacing every P1-only Sim forwarding accessor (sim.player, sim.active_weapon_ids, sim._weapon_by_id, the per-weapon *_element_idx fields) with reads/writes on the passed-in pilot’s own state (pilot/pilot.arsenal.*). Then make main.gd’s level-up trigger scan all pilots and chain panels sequentially, and make ui/level_up_panel.gd pilot-aware (device-gated input, P1/P2 label).

Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 test framework.

  • Single-player behaviour must stay byte-identical (pilots.size() == 1 collapses to today’s exact code path) — re-verify the determinism baseline in tests/test_determinism_survival.gd after every task, not because a change is expected, but as a matter of course (project convention).
  • sim.crystals (the crystal wallet) stays a single Sim-level shared field — do NOT make it per-pilot. Both pilots draw from one shared party wallet; only the weapons that benefit from threshold crossings are per-pilot.
  • sim.mods (ModState, transformative-mod tracking) stays Sim-level/shared — it’s already effectively dead code in the only live ruleset (RULESET_SURVIVAL; transformative mods are reactions-mode-only, and reactions mode is archived), kept only for a hypothetical future third ruleset per Chris’s explicit call. Do not thread a pilot param through it.
  • sim.max_weapon_slots stays a single Sim-level constant — both pilots already share one hull (fixed 2026-07-09), so this is correctly NOT per-pilot.
  • effective_dps(sim)/live_dps(sim) stay unchanged (no pilot param) — they read sim.dmg_dealt_total/sim.run_time/sim._dps_ring, which are already run-wide aggregates, not per-pilot state.
  • Out of scope: story mode (sim.story, _collect_weapon_pickups() in sim.gd) is single-player only and untouched by this plan. The in-play HUD weapon dock (ui/weapon_panel.gd) and the pre-run ship-config screen (ui/ship_config_panel.gd) keep showing P1’s loadout only — making co-op’s HUD dock P2-aware is a separate, undesigned feature; this plan only needs their existing calls to keep compiling against the new signatures (pass sim.player explicitly, no behaviour change).
  • A missed call site is NOT silent here: GDScript is statically typed, so calling a function with the wrong argument count is a load-time parse error naming the exact file and line. Still grep the whole repo as the fast, complete way to build each task’s change list (already done once below as of 2026-07-10 — re-run the same grep before starting each task in case new call sites landed since) — but treat the importer’s error output as the backstop, not the primary method.

Task 1: Thread pilot: PlayerState through sim/upgrade_system.gd + fix every existing call site

Section titled “Task 1: Thread pilot: PlayerState through sim/upgrade_system.gd + fix every existing call site”

Mechanical, behaviour-preserving. Every function that currently reads/writes a P1-only Sim forwarding accessor gains an explicit pilot: PlayerState second parameter (after sim: Sim). Every existing caller is updated to pass sim.player explicitly (or the equivalent local var name in tests), so single-player behaviour is untouched — this task does NOT yet make P2’s level-up panel appear; it only makes the underlying API able to operate on any pilot. Real co-op behaviour lands in Task 3.

Files:

  • Modify: sim/upgrade_system.gd (function signature + body changes, listed function-by-function below)
  • Modify: sim/sim.gd:997 (one grant_weapon call site, inside story-mode setup)
  • Modify: main.gd:541,1350,1738 (three call sites — these get touched AGAIN in Task 3; here they just gain sim.player to keep compiling)
  • Modify: marketing/capture/movie.gd:39, marketing/capture/shoot.gd:82
  • Modify: ui/level_up_panel.gd:111,113,114,581 (the panel’s calls INTO upgrade_system.* — the panel’s OWN show_for signature does not change until Task 3; here it keeps passing sim.player)
  • Modify: ui/weapon_panel.gd:239, ui/ship_config_panel.gd:202 (pass sim.player — no behaviour change, see Global Constraints)
  • Modify: 23 test files (exhaustive checklist below)
  • Test: full existing GUT suite (no new tests needed for this task — behaviour is unchanged; the existing suite passing IS the test)

Interfaces:

  • Produces (new signatures every later task and every external caller must use):

    • roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int) -> Array[String]
    • active_element_count(sim: Sim, pilot: PlayerState) -> int
    • has_projectile_weapon(sim: Sim, pilot: PlayerState) -> bool
    • mod_eligible(sim: Sim, pilot: PlayerState, u: Dictionary) -> bool
    • apply_upgrade(sim: Sim, pilot: PlayerState, id: String) -> void
    • apply_weapon_mod(sim: Sim, pilot: PlayerState, wid: String, kind: String) -> void
    • weapon_level(sim: Sim, pilot: PlayerState, wid: String) -> int
    • can_evolve(sim: Sim, pilot: PlayerState, wid: String) -> bool
    • evolve_weapon(sim: Sim, pilot: PlayerState, wid: String) -> void
    • grant_weapon(sim: Sim, pilot: PlayerState, wid: String) -> void
    • is_weapon_active(sim: Sim, pilot: PlayerState, wid: String) -> bool
    • upgrade_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionary
    • ship_stat_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionary
    • weapon_detail(sim: Sim, pilot: PlayerState, wid: String) -> Dictionary
    • upgrade_effects(sim: Sim, pilot: PlayerState, id: String) -> Dictionary
    • rank_upgrades(sim: Sim, pilot: PlayerState, ids: Array) -> Array[String]
    • active_weapon_views(sim: Sim, pilot: PlayerState) -> Array
    • Unchanged signatures (do NOT add a pilot param — verified they touch no per-pilot state): aim_mod_mag(kind), roll_crystal_grant(sim), weapon_mod_mag(sim, wid, kind), weapon_mod_name(sim, wid, kind), weapon_mod_label(sim, wid, kind), crystal_spec_label(sim, spec), upgrade_choice_display(sim, id), fmt_stat(sim, field, v), effective_dps(sim), live_dps(sim), weapon_path_label(sim, wid, kind), parse_crystal_spec(sim, spec).
  • Consumes: PlayerState (sim/player_state.gd, fields level, hp, max_hp, armor, speed, fire_rate_mult, pickup_radius, pending_levelups, arsenal: PilotArsenal) and PilotArsenal (sim/pilot_arsenal.gd, fields active_weapon_ids, weapon_by_id, locked_weapons, weapon_levels, thresholds_done, blade_element_idx/pulse_element_idx/ nova_element_idx/orbit_element_idx/beam_element_idx/scatter_element_idx/ scythe_element_idx/chalice_element_idx/thunder_wake_element_idx, orbit, aim). Both classes already exist and are unmodified by this task.

  • Step 1: Apply the following function-by-function replacements to sim/upgrade_system.gd

Each block below shows the exact current text (OLD) and its replacement (NEW). Apply them in file order. Do not touch any function not listed here.

roll_upgrade_choices — OLD:

func roll_upgrade_choices(sim: Sim, n: int) -> Array[String]:
# Eligible mods (transformative ones gated until reactions are possible) +
# weapon grants for un-owned weapons. While the player still has few weapons,
# GUARANTEE at least one weapon grant in the offer so weapons reliably drop.
var mod_ids: Array[String] = []
for u in sim.content.upgrades():
if u.get("effect", "") == "damage_mult":
continue # damage is now PER-WEAPON (WEAPON_MODS), not a global stat
if mod_eligible(sim, u):
mod_ids.append(u["id"])
var weapon_ids: Array[String] = []
# In story mode weapons are STORY rewards, not random picks — never offer them. Elsewhere,
# pace them by level (WEAPON_LEVEL_GAP) so the build forms gradually, not all at once.
if sim.story == null and sim.active_weapon_ids.size() < sim.max_weapon_slots \
and sim.player.level >= sim.active_weapon_ids.size() * WEAPON_LEVEL_GAP:
for wid in WEAPON_ORDER:
if sim.locked_weapons.has(wid):
continue # gated behind a meta unlock until purchased
if not sim.content.weapon_available_in(wid, sim.current_area):
continue # dimension-exclusive weapon, wrong (or no) Dimension
if sim._weapon_by_id.get(wid) != null and not is_weapon_active(sim, wid):
weapon_ids.append("weapon:" + wid)
# Per-weapon upgrades for the weapons you already own.
var wmod_ids: Array[String] = []
for wid in sim.active_weapon_ids:
for m in WEAPON_MODS.get(wid, []):
var kind: String = m[0]
if kind == "shard" and sim.orbit != null and sim.orbit.shards >= WeaponOrbit.MAX_SHARDS:
continue # orbit shards are capped
wmod_ids.append("wm:%s:%s" % [wid, kind])
# Weapon evolutions take priority — if one is available, always surface it.
var evolve_ids: Array[String] = []
for wid in sim.active_weapon_ids:
if can_evolve(sim, wid):
evolve_ids.append("evolve:" + wid)

NEW:

func roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int) -> Array[String]:
# Eligible mods (transformative ones gated until reactions are possible) +
# weapon grants for un-owned weapons. While the player still has few weapons,
# GUARANTEE at least one weapon grant in the offer so weapons reliably drop.
var mod_ids: Array[String] = []
for u in sim.content.upgrades():
if u.get("effect", "") == "damage_mult":
continue # damage is now PER-WEAPON (WEAPON_MODS), not a global stat
if mod_eligible(sim, pilot, u):
mod_ids.append(u["id"])
var weapon_ids: Array[String] = []
# In story mode weapons are STORY rewards, not random picks — never offer them. Elsewhere,
# pace them by level (WEAPON_LEVEL_GAP) so the build forms gradually, not all at once.
if sim.story == null and pilot.arsenal.active_weapon_ids.size() < sim.max_weapon_slots \
and pilot.level >= pilot.arsenal.active_weapon_ids.size() * WEAPON_LEVEL_GAP:
for wid in WEAPON_ORDER:
if pilot.arsenal.locked_weapons.has(wid):
continue # gated behind a meta unlock until purchased
if not sim.content.weapon_available_in(wid, sim.current_area):
continue # dimension-exclusive weapon, wrong (or no) Dimension
if pilot.arsenal.weapon_by_id.get(wid) != null and not is_weapon_active(sim, pilot, wid):
weapon_ids.append("weapon:" + wid)
# Per-weapon upgrades for the weapons you already own.
var wmod_ids: Array[String] = []
for wid in pilot.arsenal.active_weapon_ids:
for m in WEAPON_MODS.get(wid, []):
var kind: String = m[0]
if kind == "shard" and pilot.arsenal.orbit != null and pilot.arsenal.orbit.shards >= WeaponOrbit.MAX_SHARDS:
continue # orbit shards are capped
wmod_ids.append("wm:%s:%s" % [wid, kind])
# Weapon evolutions take priority — if one is available, always surface it.
var evolve_ids: Array[String] = []
for wid in pilot.arsenal.active_weapon_ids:
if can_evolve(sim, pilot, wid):
evolve_ids.append("evolve:" + wid)

Continuing in the SAME function, further down — OLD:

if result.size() < n and weapon_ids.size() > 0 and sim.active_weapon_ids.size() < sim.max_weapon_slots:

NEW:

if result.size() < n and weapon_ids.size() > 0 and pilot.arsenal.active_weapon_ids.size() < sim.max_weapon_slots:

(The rest of roll_upgrade_choices — the crys_pool/pool shuffling and the final return statements — reads no sim.player/sim.active_weapon_ids/etc directly and is unchanged.)

active_element_count — OLD:

func active_element_count(sim: Sim) -> int:
var seen: Dictionary = {}
for wid in sim.active_weapon_ids:
match wid:
"blade": seen[sim.blade_element_idx] = true
"pulse": seen[sim.pulse_element_idx] = true
"nova": seen[sim.nova_element_idx] = true
"orbit": seen[sim.orbit_element_idx] = true
"beam": seen[sim.beam_element_idx] = true
"turret": seen[sim.content.element_index("kinetic")] = true
"scatter": seen[sim.scatter_element_idx] = true
"blaster": seen[sim._weapon_by_id["blaster"].element_idx] = true
"scythe": seen[sim.scythe_element_idx] = true
"chalice": seen[sim.chalice_element_idx] = true
"thunder_wake": seen[sim.thunder_wake_element_idx] = true
"railgun": seen[sim._weapon_by_id["railgun"].element_idx] = true
"wildfire": seen[sim._weapon_by_id["wildfire"].element_idx] = true
"scar_tissue": seen[sim._weapon_by_id["scar_tissue"].element_idx] = true
"momentum_core": seen[sim._weapon_by_id["momentum_core"].element_idx] = true
"chronowell": seen[sim.content.element_index("time")] = true
return seen.size()
func has_projectile_weapon(sim: Sim) -> bool:
# Pierce/split only matter for weapons that fire pooled projectiles.
# The Blaster starter also forwards sim.mods.projectile_pierce/split (see WeaponBlaster.update),
# so pierce/split mods must be offerable in a fresh Blaster-only run too.
return is_weapon_active(sim, "scatter") or is_weapon_active(sim, "turret") or is_weapon_active(sim, "blaster") or is_weapon_active(sim, "momentum_core")
func mod_eligible(sim: Sim, u: Dictionary) -> bool:
var effect: String = u.get("effect", "")
# Fire-rate is capped (FIRE_RATE_CAP) — once at the ceiling further picks do nothing, so
# stop offering it (no dead picks). Meta haste can carry it to the cap without any in-run picks.
if effect == "fire_rate_mult" and sim.player.fire_rate_mult >= sim.FIRE_RATE_CAP:
return false
# Reaction/aura/stack mods are inert in crystals mode (reactions off) — never offer them.
if sim.ruleset == sim.RULESET_SURVIVAL and effect in CRYSTALS_DEAD_EFFECTS:
return false
# Pickup-radius ("Magnet Field") is near-useless in crystals — gems already
# auto-magnetize — so don't offer it there.
if sim.ruleset == sim.RULESET_SURVIVAL and effect == "pickup_radius":
return false
# Projectile mechanic mods are pointless without a projectile weapon to fire.
if effect == "projectiles_pierce" or effect == "split_on_hit":
return has_projectile_weapon(sim)
# Other transformative mods (reaction/aura modifiers) are pointless until you
# have two elements interacting — don't offer buffs for things you lack yet.
if u.get("kind", "") == "transformative":
return active_element_count(sim) >= 2
return true

NEW:

func active_element_count(sim: Sim, pilot: PlayerState) -> int:
var seen: Dictionary = {}
for wid in pilot.arsenal.active_weapon_ids:
match wid:
"blade": seen[pilot.arsenal.blade_element_idx] = true
"pulse": seen[pilot.arsenal.pulse_element_idx] = true
"nova": seen[pilot.arsenal.nova_element_idx] = true
"orbit": seen[pilot.arsenal.orbit_element_idx] = true
"beam": seen[pilot.arsenal.beam_element_idx] = true
"turret": seen[sim.content.element_index("kinetic")] = true
"scatter": seen[pilot.arsenal.scatter_element_idx] = true
"blaster": seen[pilot.arsenal.weapon_by_id["blaster"].element_idx] = true
"scythe": seen[pilot.arsenal.scythe_element_idx] = true
"chalice": seen[pilot.arsenal.chalice_element_idx] = true
"thunder_wake": seen[pilot.arsenal.thunder_wake_element_idx] = true
"railgun": seen[pilot.arsenal.weapon_by_id["railgun"].element_idx] = true
"wildfire": seen[pilot.arsenal.weapon_by_id["wildfire"].element_idx] = true
"scar_tissue": seen[pilot.arsenal.weapon_by_id["scar_tissue"].element_idx] = true
"momentum_core": seen[pilot.arsenal.weapon_by_id["momentum_core"].element_idx] = true
"chronowell": seen[sim.content.element_index("time")] = true
return seen.size()
func has_projectile_weapon(sim: Sim, pilot: PlayerState) -> bool:
# Pierce/split only matter for weapons that fire pooled projectiles.
# The Blaster starter also forwards sim.mods.projectile_pierce/split (see WeaponBlaster.update),
# so pierce/split mods must be offerable in a fresh Blaster-only run too.
return is_weapon_active(sim, pilot, "scatter") or is_weapon_active(sim, pilot, "turret") or is_weapon_active(sim, pilot, "blaster") or is_weapon_active(sim, pilot, "momentum_core")
func mod_eligible(sim: Sim, pilot: PlayerState, u: Dictionary) -> bool:
var effect: String = u.get("effect", "")
# Fire-rate is capped (FIRE_RATE_CAP) — once at the ceiling further picks do nothing, so
# stop offering it (no dead picks). Meta haste can carry it to the cap without any in-run picks.
if effect == "fire_rate_mult" and pilot.fire_rate_mult >= sim.FIRE_RATE_CAP:
return false
# Reaction/aura/stack mods are inert in crystals mode (reactions off) — never offer them.
if sim.ruleset == sim.RULESET_SURVIVAL and effect in CRYSTALS_DEAD_EFFECTS:
return false
# Pickup-radius ("Magnet Field") is near-useless in crystals — gems already
# auto-magnetize — so don't offer it there.
if sim.ruleset == sim.RULESET_SURVIVAL and effect == "pickup_radius":
return false
# Projectile mechanic mods are pointless without a projectile weapon to fire.
if effect == "projectiles_pierce" or effect == "split_on_hit":
return has_projectile_weapon(sim, pilot)
# Other transformative mods (reaction/aura modifiers) are pointless until you
# have two elements interacting — don't offer buffs for things you lack yet.
if u.get("kind", "") == "transformative":
return active_element_count(sim, pilot) >= 2
return true

apply_upgrade — OLD:

func apply_upgrade(sim: Sim, id: String) -> void:
if id.begins_with("wm:"):
var parts := id.split(":")
apply_weapon_mod(sim, parts[1], parts[2])
elif id.begins_with("aim:"):
sim.player.arsenal.aim.apply_mod(id.substr(4), aim_mod_mag(id.substr(4)))
elif id.begins_with("evolve:"):
evolve_weapon(sim, id.substr(7))
elif id.begins_with("weapon:"):
grant_weapon(sim, id.substr(7))
elif id.begins_with("crystals:"):
var spec := id.substr("crystals:".length())
for pair in spec.split(",", false):
var kv := pair.split("=")
if kv.size() == 2:
sim.crystals.add(kv[0], int(kv[1]))
sim._eval_thresholds()
else:
Upgrades.apply(id, sim.content, sim.player, sim.mods)
sim.pending_levelups = maxi(sim.pending_levelups - 1, 0)

NEW:

func apply_upgrade(sim: Sim, pilot: PlayerState, id: String) -> void:
if id.begins_with("wm:"):
var parts := id.split(":")
apply_weapon_mod(sim, pilot, parts[1], parts[2])
elif id.begins_with("aim:"):
pilot.arsenal.aim.apply_mod(id.substr(4), aim_mod_mag(id.substr(4)))
elif id.begins_with("evolve:"):
evolve_weapon(sim, pilot, id.substr(7))
elif id.begins_with("weapon:"):
grant_weapon(sim, pilot, id.substr(7))
elif id.begins_with("crystals:"):
var spec := id.substr("crystals:".length())
for pair in spec.split(",", false):
var kv := pair.split("=")
if kv.size() == 2:
sim.crystals.add(kv[0], int(kv[1]))
sim._eval_thresholds()
else:
Upgrades.apply(id, sim.content, pilot, sim.mods)
pilot.pending_levelups = maxi(pilot.pending_levelups - 1, 0)

apply_weapon_mod / weapon_level / can_evolve / evolve_weapon — OLD:

func apply_weapon_mod(sim: Sim, wid: String, kind: String) -> void:
var w: Variant = sim._weapon_by_id.get(wid)
if w == null:
return
w.apply_mod(kind, weapon_mod_mag(sim, wid, kind))
sim._weapon_levels[wid] = int(sim._weapon_levels.get(wid, 0)) + 1
func weapon_level(sim: Sim, wid: String) -> int:
return int(sim._weapon_levels.get(wid, 0))
# A weapon can evolve once it's active, not already evolved, has had enough
# mechanical upgrades invested (REQUIRES_LEVEL), and the run has taken a
# transformative mod — the "commit to a weapon + a transformative mod" payoff.
func can_evolve(sim: Sim, wid: String) -> bool:
if not is_weapon_active(sim, wid) or not WeaponEvolutions.has(wid):
return false
var w: Variant = sim._weapon_by_id.get(wid)
if w == null or w.evolved:
return false
return weapon_level(sim, wid) >= WeaponEvolutions.REQUIRES_LEVEL and sim.mods.has_any()
func evolve_weapon(sim: Sim, wid: String) -> void:
if not can_evolve(sim, wid):
return
sim._weapon_by_id[wid].evolve()

NEW:

func apply_weapon_mod(sim: Sim, pilot: PlayerState, wid: String, kind: String) -> void:
var w: Variant = pilot.arsenal.weapon_by_id.get(wid)
if w == null:
return
w.apply_mod(kind, weapon_mod_mag(sim, wid, kind))
pilot.arsenal.weapon_levels[wid] = int(pilot.arsenal.weapon_levels.get(wid, 0)) + 1
func weapon_level(sim: Sim, pilot: PlayerState, wid: String) -> int:
return int(pilot.arsenal.weapon_levels.get(wid, 0))
# A weapon can evolve once it's active, not already evolved, has had enough
# mechanical upgrades invested (REQUIRES_LEVEL), and the run has taken a
# transformative mod — the "commit to a weapon + a transformative mod" payoff.
func can_evolve(sim: Sim, pilot: PlayerState, wid: String) -> bool:
if not is_weapon_active(sim, pilot, wid) or not WeaponEvolutions.has(wid):
return false
var w: Variant = pilot.arsenal.weapon_by_id.get(wid)
if w == null or w.evolved:
return false
return weapon_level(sim, pilot, wid) >= WeaponEvolutions.REQUIRES_LEVEL and sim.mods.has_any()
func evolve_weapon(sim: Sim, pilot: PlayerState, wid: String) -> void:
if not can_evolve(sim, pilot, wid):
return
pilot.arsenal.weapon_by_id[wid].evolve()

grant_weapon / is_weapon_active — OLD:

func grant_weapon(sim: Sim, wid: String) -> void:
if is_weapon_active(sim, wid) or sim.active_weapon_ids.size() >= sim.max_weapon_slots:
return
if sim._weapon_by_id.get(wid) == null:
return
sim.active_weapon_ids.append(wid)
if sim.ruleset == sim.RULESET_SURVIVAL:
sim._eval_thresholds()
func is_weapon_active(sim: Sim, wid: String) -> bool:
return sim.active_weapon_ids.has(wid)

NEW:

func grant_weapon(sim: Sim, pilot: PlayerState, wid: String) -> void:
if is_weapon_active(sim, pilot, wid) or pilot.arsenal.active_weapon_ids.size() >= sim.max_weapon_slots:
return
if pilot.arsenal.weapon_by_id.get(wid) == null:
return
pilot.arsenal.active_weapon_ids.append(wid)
if sim.ruleset == sim.RULESET_SURVIVAL:
sim._eval_thresholds()
func is_weapon_active(sim: Sim, pilot: PlayerState, wid: String) -> bool:
return pilot.arsenal.active_weapon_ids.has(wid)

upgrade_preview — OLD:

func upgrade_preview(sim: Sim, id: String) -> Dictionary:
if id.begins_with("evolve:"):
var evwid := id.substr(7)
return {
"id": id, "kind": "transformative", "name": "EVOLVE → %s" % WeaponEvolutions.evolved_name(evwid),
"glyph": evwid, "label": WeaponEvolutions.evolved_desc(evwid), "now": "", "after": "EVOLVED",
}
if id.begins_with("wm:"):
var parts := id.split(":")
var wmwid: String = parts[1]
var kind: String = parts[2]
var w: Variant = sim._weapon_by_id.get(wmwid)
var mag := weapon_mod_mag(sim, wmwid, kind)
var now := ""
var after := ""
if w != null:
var na: Array = w.mod_now_after(kind, mag)
now = na[0]
after = na[1]
return {"id": id, "kind": "weapon", "name": weapon_mod_name(sim, wmwid, kind), "glyph": wmwid,
"label": weapon_mod_label(sim, wmwid, kind), "now": now, "after": after}
if id.begins_with("aim:"):
var akind := id.substr(4)
var na: Array = sim.player.arsenal.aim.mod_now_after(akind, aim_mod_mag(akind))
return {"id": id, "kind": "weapon", "name": "Aim · " + akind.capitalize(), "glyph": "aim",
"label": "Manual shot: " + akind, "now": na[0], "after": na[1]}
if id.begins_with("weapon:"):
var wid := id.substr(7)
var w := sim.content.weapon(wid)
return {
"id": id, "kind": "weapon", "name": w.get("name", wid), "glyph": wid,
"label": "%s · %s" % [String(w.get("element", "")).capitalize(), w.get("archetype", "")],
"now": "", "after": "NEW WEAPON",
}
if id.begins_with("crystals:"):
return {"id": id, "kind": "transformative", "name": "Element Crystals", "glyph": "crystal",
"label": crystal_spec_label(sim, id.substr("crystals:".length())), "now": "", "after": "CRYSTALS"}
var u := sim.content.upgrade(id)
var effect: String = u.get("effect", "")
var mag := float(u.get("magnitude", 1.0))
var nm: String = u.get("name", id)
if StatEffects.is_known(effect):
var spec: Dictionary = StatEffects.TABLE[effect]
var f: String = spec["field"]
var cur := float(sim.player.get(f))
var nxt: float = (cur * mag) if spec["op"] == "mul" else (cur + mag)
return {"id": id, "kind": "stat", "name": nm, "glyph": "",
"label": StatEffects.describe(effect, mag), "now": fmt_stat(sim, f, cur), "after": fmt_stat(sim, f, nxt)}
if SimMods.is_known(effect):
var sp: Dictionary = SimMods.TABLE[effect]
var f2: String = sp["field"]
var cur2 := float(sim.mods.get(f2))
var nxt2: float = (cur2 * mag) if sp["op"] == "mul" else (cur2 + mag)
return {"id": id, "kind": "transformative", "name": nm, "glyph": "",
"label": SimMods.describe(effect, mag), "now": fmt_stat(sim, f2, cur2), "after": fmt_stat(sim, f2, nxt2)}
return {"id": id, "kind": "stat", "name": nm, "glyph": "", "label": "", "now": "", "after": ""}

NEW: (identical except the signature line, the sim._weapon_by_id.get(wmwid) line, the sim.player.arsenal.aim line, and the float(sim.player.get(f)) line)

func upgrade_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionary:
if id.begins_with("evolve:"):
var evwid := id.substr(7)
return {
"id": id, "kind": "transformative", "name": "EVOLVE → %s" % WeaponEvolutions.evolved_name(evwid),
"glyph": evwid, "label": WeaponEvolutions.evolved_desc(evwid), "now": "", "after": "EVOLVED",
}
if id.begins_with("wm:"):
var parts := id.split(":")
var wmwid: String = parts[1]
var kind: String = parts[2]
var w: Variant = pilot.arsenal.weapon_by_id.get(wmwid)
var mag := weapon_mod_mag(sim, wmwid, kind)
var now := ""
var after := ""
if w != null:
var na: Array = w.mod_now_after(kind, mag)
now = na[0]
after = na[1]
return {"id": id, "kind": "weapon", "name": weapon_mod_name(sim, wmwid, kind), "glyph": wmwid,
"label": weapon_mod_label(sim, wmwid, kind), "now": now, "after": after}
if id.begins_with("aim:"):
var akind := id.substr(4)
var na: Array = pilot.arsenal.aim.mod_now_after(akind, aim_mod_mag(akind))
return {"id": id, "kind": "weapon", "name": "Aim · " + akind.capitalize(), "glyph": "aim",
"label": "Manual shot: " + akind, "now": na[0], "after": na[1]}
if id.begins_with("weapon:"):
var wid := id.substr(7)
var w := sim.content.weapon(wid)
return {
"id": id, "kind": "weapon", "name": w.get("name", wid), "glyph": wid,
"label": "%s · %s" % [String(w.get("element", "")).capitalize(), w.get("archetype", "")],
"now": "", "after": "NEW WEAPON",
}
if id.begins_with("crystals:"):
return {"id": id, "kind": "transformative", "name": "Element Crystals", "glyph": "crystal",
"label": crystal_spec_label(sim, id.substr("crystals:".length())), "now": "", "after": "CRYSTALS"}
var u := sim.content.upgrade(id)
var effect: String = u.get("effect", "")
var mag := float(u.get("magnitude", 1.0))
var nm: String = u.get("name", id)
if StatEffects.is_known(effect):
var spec: Dictionary = StatEffects.TABLE[effect]
var f: String = spec["field"]
var cur := float(pilot.get(f))
var nxt: float = (cur * mag) if spec["op"] == "mul" else (cur + mag)
return {"id": id, "kind": "stat", "name": nm, "glyph": "",
"label": StatEffects.describe(effect, mag), "now": fmt_stat(sim, f, cur), "after": fmt_stat(sim, f, nxt)}
if SimMods.is_known(effect):
var sp: Dictionary = SimMods.TABLE[effect]
var f2: String = sp["field"]
var cur2 := float(sim.mods.get(f2))
var nxt2: float = (cur2 * mag) if sp["op"] == "mul" else (cur2 + mag)
return {"id": id, "kind": "transformative", "name": nm, "glyph": "",
"label": SimMods.describe(effect, mag), "now": fmt_stat(sim, f2, cur2), "after": fmt_stat(sim, f2, nxt2)}
return {"id": id, "kind": "stat", "name": nm, "glyph": "", "label": "", "now": "", "after": ""}

ship_stat_preview — OLD:

func ship_stat_preview(sim: Sim, id: String) -> Dictionary:
if id.begins_with("weapon:") or id.begins_with("wm:") \
or id.begins_with("evolve:") or id.begins_with("crystals:"):
return {}
var u := sim.content.upgrade(id)
var effect: String = u.get("effect", "")
if not StatEffects.is_known(effect):
return {}
var spec: Dictionary = StatEffects.TABLE[effect]
var f: String = spec["field"]
if not SHIP_PREVIEW_FIELDS.has(f):
return {}
var mag := float(u.get("magnitude", 1.0))
var cur := float(sim.player.get(f))
var nxt: float = (cur * mag) if spec["op"] == "mul" else (cur + mag)
return {"field": f, "now": cur, "after": nxt}

NEW:

func ship_stat_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionary:
if id.begins_with("weapon:") or id.begins_with("wm:") \
or id.begins_with("evolve:") or id.begins_with("crystals:"):
return {}
var u := sim.content.upgrade(id)
var effect: String = u.get("effect", "")
if not StatEffects.is_known(effect):
return {}
var spec: Dictionary = StatEffects.TABLE[effect]
var f: String = spec["field"]
if not SHIP_PREVIEW_FIELDS.has(f):
return {}
var mag := float(u.get("magnitude", 1.0))
var cur := float(pilot.get(f))
var nxt: float = (cur * mag) if spec["op"] == "mul" else (cur + mag)
return {"field": f, "now": cur, "after": nxt}

weapon_detail — OLD:

func weapon_detail(sim: Sim, wid: String) -> Dictionary:
var w := sim.content.weapon(wid)
var inst: Variant = sim._weapon_by_id.get(wid)
var path: Array = []
for rule: Dictionary in WeaponThresholds.rules_for(wid):
var el: String = rule["element"]
var amt: int = int(rule["amount"])
var kind: String = String(rule["kind"])
var ev: bool = kind == "evolve"
var effect: String = ("EVOLVE → %s" % WeaponEvolutions.evolved_name(wid)) if ev else weapon_path_label(sim, wid, kind)
var have: int = sim.crystals.count(el)
path.append({
"req": "%d %s" % [amt, el.capitalize()],
"have": have, "need": amt,
"effect": effect,
"met": have >= amt,
"evolve": ev,
})
return {
"id": wid,
"name": String(w.get("name", wid)),
"element": String(w.get("element", "")).capitalize(),
"archetype": String(w.get("archetype", "")),
"active": is_weapon_active(sim, wid),
"evolved": (inst != null and inst.evolved),
"evolved_name": WeaponEvolutions.evolved_name(wid),
"evolved_desc": WeaponEvolutions.evolved_desc(wid),
"path": path,
}

NEW:

func weapon_detail(sim: Sim, pilot: PlayerState, wid: String) -> Dictionary:
var w := sim.content.weapon(wid)
var inst: Variant = pilot.arsenal.weapon_by_id.get(wid)
var path: Array = []
for rule: Dictionary in WeaponThresholds.rules_for(wid):
var el: String = rule["element"]
var amt: int = int(rule["amount"])
var kind: String = String(rule["kind"])
var ev: bool = kind == "evolve"
var effect: String = ("EVOLVE → %s" % WeaponEvolutions.evolved_name(wid)) if ev else weapon_path_label(sim, wid, kind)
var have: int = sim.crystals.count(el)
path.append({
"req": "%d %s" % [amt, el.capitalize()],
"have": have, "need": amt,
"effect": effect,
"met": have >= amt,
"evolve": ev,
})
return {
"id": wid,
"name": String(w.get("name", wid)),
"element": String(w.get("element", "")).capitalize(),
"archetype": String(w.get("archetype", "")),
"active": is_weapon_active(sim, pilot, wid),
"evolved": (inst != null and inst.evolved),
"evolved_name": WeaponEvolutions.evolved_name(wid),
"evolved_desc": WeaponEvolutions.evolved_desc(wid),
"path": path,
}

upgrade_effects — OLD:

func upgrade_effects(sim: Sim, id: String) -> Dictionary:
if id.begins_with("crystals:"):
var spec := id.substr("crystals:".length())
var bundle := parse_crystal_spec(sim, spec)
# Simulate the wallet after this grant — copy only, never write back.
var after: Dictionary = sim.crystals.counts.duplicate()
for el in bundle:
after[el] = int(after.get(el, 0)) + int(bundle[el])
var changes: Array = []
var score := 0.0
# Nearest-unmet rung per (weapon, element) touched by this grant -- what a "dead" choice
# (crosses nothing yet) is actually building towards, so it doesn't just read as useless
# (Chris: crystal upgrades often looked like "no effect" one rung short of a threshold).
var progress: Array = []
var seen_progress: Dictionary = {}
for wid in sim.active_weapon_ids + ["aim"]: # "aim" is always-on but not in active_weapon_ids

NEW:

func upgrade_effects(sim: Sim, pilot: PlayerState, id: String) -> Dictionary:
if id.begins_with("crystals:"):
var spec := id.substr("crystals:".length())
var bundle := parse_crystal_spec(sim, spec)
# Simulate the wallet after this grant — copy only, never write back.
var after: Dictionary = sim.crystals.counts.duplicate()
for el in bundle:
after[el] = int(after.get(el, 0)) + int(bundle[el])
var changes: Array = []
var score := 0.0
# Nearest-unmet rung per (weapon, element) touched by this grant -- what a "dead" choice
# (crosses nothing yet) is actually building towards, so it doesn't just read as useless
# (Chris: crystal upgrades often looked like "no effect" one rung short of a threshold).
var progress: Array = []
var seen_progress: Dictionary = {}
for wid in pilot.arsenal.active_weapon_ids + ["aim"]: # "aim" is always-on but not in active_weapon_ids

(The rest of upgrade_effects — the if id.begins_with("weapon:") branch and the trailing stat/mod branch — reads no sim.player/sim.active_weapon_ids directly; leave it unchanged.)

rank_upgrades — OLD:

func rank_upgrades(sim: Sim, ids: Array) -> Array[String]:
var scored: Array = []
for id: Variant in ids:
scored.append({"id": String(id), "s": float(upgrade_effects(sim, String(id)).get("score", 0.0))})
scored.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["s"] > b["s"])
var out: Array[String] = []
for entry: Dictionary in scored:
out.append(entry["id"])
return out

NEW:

func rank_upgrades(sim: Sim, pilot: PlayerState, ids: Array) -> Array[String]:
var scored: Array = []
for id: Variant in ids:
scored.append({"id": String(id), "s": float(upgrade_effects(sim, pilot, String(id)).get("score", 0.0))})
scored.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return a["s"] > b["s"])
var out: Array[String] = []
for entry: Dictionary in scored:
out.append(entry["id"])
return out

active_weapon_views — OLD (only the signature line and the two sim. reads at the top change; the long match wid: block below stays exactly as-is except every sim.blade_element_idx /sim.pulse_element_idx/etc and every w := sim._weapon_by_id[wid] becomes pilot.arsenal.blade_element_idx/etc and pilot.arsenal.weapon_by_id[wid]):

func active_weapon_views(sim: Sim) -> Array:
var dm := sim.player.damage_mult
var out: Array = []
for wid in sim.active_weapon_ids:
var w: Variant = sim._weapon_by_id[wid]
var v := {"id": wid, "frac": w.cooldown_frac()}
match wid:
"blade":
v["name"] = "Blade"; v["glyph"] = "blade"; v["element"] = sim.blade_element_idx

NEW (representative excerpt — apply the same sim.<name>_element_idxpilot.arsenal.<name>_element_idx substitution to every one of the 15 match wid: branches, in order: blade, pulse, nova, orbit, beam, turret (stays sim.content.element_index("kinetic"), unchanged — shared content lookup, not per-pilot), scatter, blaster (stays w.element_idx, unchanged — reads the weapon instance itself, not a Sim/pilot field), scythe, chalice, thunder_wake, railgun (stays w.element_idx), wildfire (stays w.element_idx), scar_tissue (stays w.element_idx), momentum_core (stays w.element_idx), chronowell (stays sim.content.element_index("time"), unchanged)):

func active_weapon_views(sim: Sim, pilot: PlayerState) -> Array:
var dm := pilot.damage_mult
var out: Array = []
for wid in pilot.arsenal.active_weapon_ids:
var w: Variant = pilot.arsenal.weapon_by_id[wid]
var v := {"id": wid, "frac": w.cooldown_frac()}
match wid:
"blade":
v["name"] = "Blade"; v["glyph"] = "blade"; v["element"] = pilot.arsenal.blade_element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"pulse":
v["name"] = "Electricity"; v["glyph"] = "pulse"; v["element"] = pilot.arsenal.pulse_element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"nova":
v["name"] = "Fire Nova"; v["glyph"] = "nova"; v["element"] = pilot.arsenal.nova_element_idx
v["stat"] = "dmg %.1f r%.0f" % [w.base_damage * dm, w.area]
"orbit":
v["name"] = "Orbit"; v["glyph"] = "orbit"; v["element"] = pilot.arsenal.orbit_element_idx
v["stat"] = "%.1f dps" % (w.base_damage * dm * 3.0)
"beam":
v["name"] = "Beam"; v["glyph"] = "beam"; v["element"] = pilot.arsenal.beam_element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"turret":
v["name"] = "Turret"; v["glyph"] = "turret"; v["element"] = sim.content.element_index("kinetic")
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"scatter":
v["name"] = "Scatter"; v["glyph"] = "scatter"; v["element"] = pilot.arsenal.scatter_element_idx
v["stat"] = "dmg %.1f ×%d" % [w.base_damage * dm, w.pellets]
"blaster":
v["name"] = "Blaster"; v["glyph"] = "blaster"; v["element"] = w.element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"scythe":
# Placeholder glyph (reuses Blade's icon) until a dedicated Scythe asset exists.
v["name"] = "Scythe"; v["glyph"] = "blade"; v["element"] = pilot.arsenal.scythe_element_idx
v["stat"] = "%.2f%% hp" % (w.pct * 100.0)
"chalice":
# Placeholder glyph (reuses Scatter's icon) until a dedicated Chalice asset exists.
v["name"] = "Chalice"; v["glyph"] = "scatter"; v["element"] = pilot.arsenal.chalice_element_idx
v["stat"] = "%.1f dps r%.0f" % [w.base_dps * w.damage_mult, w.radius]
"thunder_wake":
# Placeholder glyph (reuses Pulse's icon) until a dedicated Thunder Wake asset exists.
v["name"] = "Thunder Wake"; v["glyph"] = "pulse"; v["element"] = pilot.arsenal.thunder_wake_element_idx
v["stat"] = "dmg %.1f r%.0f" % [w.base_damage * dm, w.strike_radius]
"railgun":
# Placeholder glyph (reuses Beam's icon) until a dedicated Railgun asset exists.
v["name"] = "Railgun"; v["glyph"] = "beam"; v["element"] = w.element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"wildfire":
# Placeholder glyph (reuses Nova's icon) until a dedicated Wildfire asset exists.
v["name"] = "Wildfire"; v["glyph"] = "nova"; v["element"] = w.element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"scar_tissue":
# Placeholder glyph (reuses Blade's icon) until a dedicated Scar Tissue asset exists.
v["name"] = "Scar Tissue"; v["glyph"] = "blade"; v["element"] = w.element_idx
v["stat"] = "%d/%d stacks" % [int(w.stacks), w.discharge_threshold]
"momentum_core":
# Placeholder glyph (reuses Blaster's icon) until a dedicated Momentum Core asset exists.
v["name"] = "Momentum Core"; v["glyph"] = "blaster"; v["element"] = w.element_idx
v["stat"] = "dmg %.1f" % (w.base_damage * dm)
"chronowell":
# Placeholder glyph (reuses Orbit's icon) until a dedicated Chronowell asset exists.
v["name"] = "Chronowell"; v["glyph"] = "orbit"; v["element"] = sim.content.element_index("time")
v["stat"] = "%.1fs slow-field" % w.duration
out.append(v)
return out

Also update the file’s own header comment (lines 4-13) — OLD:

# ── Upgrade / build-craft system director ───────────────────────────────────
# Owns level-up offer rolling (mods/weapons/evolutions/crystal grants), applying
# a chosen upgrade, per-weapon mechanical mods, weapon evolutions, weapon-grant
# bookkeeping, and every read-only display/preview helper the UI reads to show
# the level-up cards, weapon dock, and weapon-detail panel (DPS calc included).
# Tuning consts exclusive to this system live here (below); the rest of the
# mutable state (content, player, mods, crystals, active_weapon_ids,
# _weapon_by_id, upgrade_rng, ruleset, etc.) stays on `Sim`, reached here via
# `sim.field_name`. Static tables (Upgrades/StatEffects/SimMods/WeaponEvolutions/
# WeaponThresholds/WeaponOrbit) are referenced directly — they need no `sim.` prefix.

NEW:

# ── Upgrade / build-craft system director ───────────────────────────────────
# Owns level-up offer rolling (mods/weapons/evolutions/crystal grants), applying
# a chosen upgrade, per-weapon mechanical mods, weapon evolutions, weapon-grant
# bookkeeping, and every read-only display/preview helper the UI reads to show
# the level-up cards, weapon dock, and weapon-detail panel (DPS calc included).
# Tuning consts exclusive to this system live here (below). Sim-level SHARED state
# (content, mods, crystals, upgrade_rng, ruleset, max_weapon_slots, etc.) stays on
# `Sim`, reached via `sim.field_name`. PER-PILOT state (active_weapon_ids,
# weapon_by_id, per-weapon element indices, weapon_levels, thresholds_done,
# pending_levelups) lives on the explicit `pilot: PlayerState` param almost every
# function below takes, reached via `pilot.field_name` / `pilot.arsenal.field_name`
# — never via `sim.player`/`sim.active_weapon_ids` (P1-only forwarding accessors
# that would silently ignore whichever pilot is actually leveling up). Static
# tables (Upgrades/StatEffects/SimMods/WeaponEvolutions/WeaponThresholds/WeaponOrbit)
# are referenced directly — they need no `sim.`/`pilot.` prefix.
  • Step 2: Fix the 6 non-test external call sites

sim/sim.gd:997 — OLD:

upgrade_system.grant_weapon(self, String(pk["weapon"]))

NEW:

upgrade_system.grant_weapon(self, player, String(pk["weapon"]))

(Story mode is single-player only — player here is unambiguous.)

main.gd:541 — OLD:

sim.upgrade_system.grant_weapon(sim, target)

NEW:

sim.upgrade_system.grant_weapon(sim, sim.player, target)

main.gd:1350 — OLD:

var ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)

NEW:

var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3)

main.gd:1738 — OLD:

sim.upgrade_system.apply_upgrade(sim, id)

NEW:

sim.upgrade_system.apply_upgrade(sim, sim.player, id)

(main.gd’s calls change AGAIN in Task 3 to use _leveling_pilot instead of sim.player — this step only needs the file to compile and single-player to behave identically in the meantime.)

marketing/capture/movie.gd:39 and marketing/capture/shoot.gd:82 — both are:

s.upgrade_system.grant_weapon(s, w)

NEW (both files):

s.upgrade_system.grant_weapon(s, s.player, w)

ui/weapon_panel.gd:239 — OLD:

var views := sim.upgrade_system.active_weapon_views(sim)

NEW:

var views := sim.upgrade_system.active_weapon_views(sim, sim.player)

ui/ship_config_panel.gd:202 — OLD:

var owned := sim.upgrade_system.active_weapon_views(sim) if sim != null else [{"glyph": "blade", "name": "Blade", "stat": ""}]

NEW:

var owned := sim.upgrade_system.active_weapon_views(sim, sim.player) if sim != null else [{"glyph": "blade", "name": "Blade", "stat": ""}]
  • Step 3: Fix ui/level_up_panel.gd’s 4 calls INTO upgrade_system.*

Lines 111-114 — OLD:

var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, ids)
for id: String in ranked:
_effects_cache[id] = sim.upgrade_system.upgrade_effects(sim, id)
_ship_cache[id] = sim.upgrade_system.ship_stat_preview(sim, id)

NEW:

var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, sim.player, ids)
for id: String in ranked:
_effects_cache[id] = sim.upgrade_system.upgrade_effects(sim, sim.player, id)
_ship_cache[id] = sim.upgrade_system.ship_stat_preview(sim, sim.player, id)

Line 581 — OLD:

_detail_box.add_child(WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _weapon_wids[_wsel])))

NEW:

_detail_box.add_child(WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _sim.player, _weapon_wids[_wsel])))

(show_for’s own signature and every OTHER sim.player/sim.active_weapon_ids/sim._weapon_by_id read inside this file changes in Task 3, not here — this step only fixes the 4 calls that go through upgrade_system.* and would otherwise fail to compile.)

  • Step 4: Fix every test-file call site

Below is the exhaustive, grep-verified (2026-07-10) list of every remaining call site in tests/*.gd. Group them by which local variable holds the Sim instance in that file/test (shown in parentheses) — the pilot argument to insert is always <that var>.player.

For EVERY line below, insert <sim var>.player as the argument immediately after the sim argument (i.e. right after sim,/a,/b,/s,/clean, and before whatever argument currently follows). Two examples of the transform:

# BEFORE
sim.upgrade_system.grant_weapon(sim, "nova")
# AFTER
sim.upgrade_system.grant_weapon(sim, sim.player, "nova")
# BEFORE
var ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)
# AFTER
var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3)

Full checklist (file — line numbers of calls needing the insertion):

  • tests/test_story_npc.gd — 24, 31, 51, 61 (sim var: sim)
  • tests/test_ship_class_slots.gd — 14, 15, 17, 25 (sim var: sim)
  • tests/test_story_director.gd — 204, 207 (sim var: sim)
  • tests/test_evolutions.gd — 10, 15, 19, 20, 21, 22, 27, 32, 38, 45, 52, 61, 62, 72, 75, 77 (sim var: sim)
  • tests/test_upgrades.gd — 39 (sim var: sim)
  • tests/test_survival_manual_progression.gd — 72 (sim var: sim)
  • tests/test_loadout.gd — 14, 15, 16, 17, 21, 22, 27, 28, 34, 41, 42, 54, 62, 74, 81, 93 (sim var: sim)
  • tests/test_story_walls.gd — 42, 45, 48 (sim var: sim)
  • tests/test_weapon_unlock.gd — 14, 21, 29, 68 (sim var: sim)
  • tests/test_buildcraft.gd — 12, 19, 22, 28, 33, 57, 66, 69, 76, 78, 98, 105, 107, 114, 116, 123, 125, 128, 133 (sim var: sim)
  • tests/test_level_up_panel.gd — 5, 21, 42 (sim var: sim) — see Step 5 below for this file’s show_for calls, which are handled separately since LevelUpPanel.show_for isn’t touched until Task 3
  • tests/test_determinism.gd — 47 (sim var: b, i.e. b.upgrade_system.roll_upgrade_choices(b, b.player, 3))
  • tests/test_weapon_panel.gd — 39, 51 (sim var: sim)
  • tests/test_weapon_thresholds.gd — 11, 43, 52 (sim var: sim); 41 (sim var: clean, i.e. clean.upgrade_system.grant_weapon(clean, clean.player, "orbit"))
  • tests/test_upgrade_effects.gd — 7, 8, 25, 26, 40, 42, 55, 56, 66, 67, 76, 77, 78, 85, 98, 104, 119, 127, 135, 136, 141, 143, 149, 150, 157 (sim var: sim)
  • tests/test_survival_ruleset.gd — 42, 44, 56, 67, 85, 87, 96, 102, 105, 118, 122, 155 (sim var: sim); line 81 has TWO calls on one line, sim vars a and b: assert_eq(a.upgrade_system.roll_upgrade_choices(a, a.player, 3), b.upgrade_system.roll_upgrade_choices(b, b.player, 3), "crystals offers are seed-deterministic")
  • tests/test_projectile_mods.gd — 14, 19, 81, 82, 91, 92, 99 (sim var: sim)
  • tests/test_leech.gd — 10, 47 (sim var: sim)
  • tests/test_weapon_unlock_offer.gd — 12, 23 (sim var: sim)
  • tests/test_fire_rate_cap.gd — 33, 42 (sim var: s)
  • tests/test_weapon_scatter.gd — 43, 45, 58, 63, 64, 66, 72 (sim var: sim)
  • tests/test_aim_upgrades.gd — 10 (sim var: sim)
  • tests/test_story_secret.gd — 33, 37 (sim var: sim)

After editing, re-run the same grep to confirm zero remaining old-signature calls:

Terminal window
grep -rn "upgrade_system\.\(roll_upgrade_choices\|apply_upgrade\|apply_weapon_mod\|weapon_level\|can_evolve\|evolve_weapon\|grant_weapon\|is_weapon_active\|upgrade_preview\|ship_stat_preview\|weapon_detail\|upgrade_effects\|rank_upgrades\|active_weapon_views\|active_element_count\|has_projectile_weapon\|mod_eligible\)(" --include="*.gd" main.gd marketing tests ui sim

Every remaining match must already show the pilot argument you just added — if any line looks exactly like the “BEFORE” shape above, you missed it.

  • Step 5: Fix tests/test_level_up_panel.gd’s 3 show_for calls to match Step 3’s intermediate (still-2-arg-show_for) state

This file’s 3 panel.show_for(...) calls are unaffected by this task (Task 1 does not change LevelUpPanel.show_for’s signature — that happens in Task 3). Only its 3 grant_weapon calls (lines 5, 21, 42, already listed in Step 4’s checklist) need the sim.player insertion. Leave the show_for calls exactly as they are; Task 3 updates them.

  • Step 6: Run the full test suite and confirm it’s unchanged

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: the same Scripts count as before this task (no import failures — a missing call-site fix shows up here as a GDScript parse error naming the file/line), 0 failing. Godot SIGABRTs (exit 134) at teardown even on a clean run — read the printed “Run Summary” block, not the exit code (see CLAUDE.md’s Running & testing section).

  • Step 7: Verify the determinism baseline is unchanged

Read the literal pinned assertion in tests/test_determinism_survival.gd and confirm the full suite run in Step 6 still passes that specific test (this task should not change the numbers — roll_upgrade_choices/apply_upgrade are not part of the raw spawn/rng determinism trace, only the pinned test itself asserts the hash/checksum).

  • Step 8: Commit
Terminal window
git add sim/upgrade_system.gd sim/sim.gd main.gd marketing/capture/movie.gd marketing/capture/shoot.gd ui/level_up_panel.gd ui/weapon_panel.gd ui/ship_config_panel.gd tests/
git commit -m "refactor(upgrade): thread explicit pilot param through upgrade_system.gd
Mechanical, behaviour-preserving sweep — every function that previously
read a P1-only Sim forwarding accessor now takes an explicit
pilot: PlayerState and reads pilot/pilot.arsenal directly. Every
existing call site updated to pass sim.player, so single-player is
byte-identical. Lays the groundwork for P2's own level-up panel."

Task 2: Make crystal-threshold auto-upgrades apply per-pilot

Section titled “Task 2: Make crystal-threshold auto-upgrades apply per-pilot”

sim/sim.gd’s _eval_thresholds()/_apply_weapon_thresholds() (the auto-upgrade-on-crystal- threshold system — the ONLY reachable weapon-progression pipeline now that reactions mode is archived) currently loop over the P1-only active_weapon_ids/_weapon_by_id forwarding accessors. Crystals are a shared party wallet (sim.crystals, stays Sim-level — see Global Constraints), but the weapon upgrades a threshold crossing triggers must apply against EACH pilot’s own arsenal, or P2’s weapons never benefit from crystal grants even though P2 can receive them.

Files:

  • Modify: sim/sim.gd:848-873 (_eval_thresholds/_apply_weapon_thresholds)
  • Test: tests/test_weapon_thresholds.gd (new test appended)

Interfaces:

  • Consumes: sim.pilots: Array[PlayerState] (already exists), PilotArsenal.thresholds_done (already exists, was previously reached only via the P1-only sim._thresholds_done forwarding accessor — this task is the first thing to read/write it via pilot.arsenal.thresholds_done directly for a non-P1 pilot).

  • Produces: _eval_thresholds() keeps its existing zero-argument public signature (confirmed via repo-wide grep — every external caller, all in tests/test_survival_manual_progression.gd and tests/test_weapon_thresholds.gd, calls it as sim._eval_thresholds() with no arguments) — so NO existing call site needs to change. Only the private (_-prefixed, no external callers) _apply_weapon_thresholds gains a pilot: PlayerState first parameter.

  • Step 1: Write the failing test

Append to tests/test_weapon_thresholds.gd:

# P2 co-op: crystals are a shared party wallet, but threshold-triggered weapon upgrades must
# apply against EACH pilot's own arsenal, not just P1's.
func test_threshold_upgrades_p2_own_weapon_independently_of_p1() -> void:
var sim := Sim.new(1, _content())
sim.enable_survival()
sim.add_pilot() # P2 joins
# P1 owns orbit; P2 does NOT.
sim.upgrade_system.grant_weapon(sim, sim.player, "orbit")
var p1_before: int = sim.player.arsenal.orbit.shards
# P2 owns nova instead — should be untouched by an orbit-only threshold crossing.
sim.upgrade_system.grant_weapon(sim, sim.player2, "nova")
sim.crystals.add("cold", 99) # crosses orbit's cold shard threshold (see WeaponThresholds.TABLE)
sim._eval_thresholds()
assert_gt(sim.player.arsenal.orbit.shards, p1_before, "P1's orbit gained shards")
# Now give P2 their own orbit — the ALREADY-MET threshold must back-apply to P2's copy too,
# independently of P1's (each pilot's thresholds_done is tracked separately).
var p2_shards_before_grant: int = 0
sim.upgrade_system.grant_weapon(sim, sim.player2, "orbit")
assert_gt(sim.player2.arsenal.orbit.shards, p2_shards_before_grant,
"P2's own orbit instance also back-applies the already-met COLD threshold, independent of P1's")
  • Step 2: Run the test to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_thresholds -gexit Expected: FAIL — sim.player2.arsenal.orbit.shards stays at the un-upgraded baseline, since _eval_thresholds() currently only ever touches active_weapon_ids/_weapon_by_id (P1-only forwarding accessors), so P2’s orbit is never evaluated.

  • Step 3: Implement

sim/sim.gd — OLD:

# Auto-upgrades owned weapons when crystal counts cross their thresholds.
# Reads crystal counts but never decrements them (non-consuming).
# Each rule fires at most once per run (_thresholds_done guard = idempotent).
# No-op in any ruleset other than RULESET_SURVIVAL.
func _eval_thresholds() -> void:
if ruleset != RULESET_SURVIVAL:
return
for wid in active_weapon_ids:
_apply_weapon_thresholds(wid, _weapon_by_id.get(wid))
# The manual aim weapon is always owned (never in active_weapon_ids) — progress it too.
_apply_weapon_thresholds("aim", player.arsenal.aim)
# Shared by _eval_thresholds for both arsenal weapons (active_weapon_ids) and the
# always-on aim weapon (never in active_weapon_ids, so it needs its own call site).
func _apply_weapon_thresholds(wid: String, w: Variant) -> void:
if w == null:
return
var rules: Array = WeaponThresholds.rules_for(wid)
for i in WeaponThresholds.rules_met(wid, crystals.counts):
var key := "%s:%d" % [wid, i]
if _thresholds_done.has(key):
continue
_thresholds_done[key] = true
var rule: Dictionary = rules[i]
if rule["kind"] == "evolve":
if not w.evolved:
w.evolve()
else:
var mag: float = upgrade_system.aim_mod_mag(rule["kind"]) if wid == "aim" else upgrade_system.weapon_mod_mag(self, wid, rule["kind"])
w.apply_mod(rule["kind"], mag)

NEW:

# Auto-upgrades owned weapons when crystal counts cross their thresholds.
# Reads crystal counts but never decrements them (non-consuming). Crystals are a shared party
# wallet (co-op), so this evaluates EVERY pilot against the same wallet — each pilot's own
# weapons upgrade independently (PilotArsenal.thresholds_done is tracked per-pilot).
# Each rule fires at most once per pilot per run (thresholds_done guard = idempotent).
# No-op in any ruleset other than RULESET_SURVIVAL.
func _eval_thresholds() -> void:
if ruleset != RULESET_SURVIVAL:
return
for pilot in pilots:
for wid in pilot.arsenal.active_weapon_ids:
_apply_weapon_thresholds(pilot, wid, pilot.arsenal.weapon_by_id.get(wid))
# The manual aim weapon is always owned (never in active_weapon_ids) — progress it too.
_apply_weapon_thresholds(pilot, "aim", pilot.arsenal.aim)
# Shared by _eval_thresholds for both arsenal weapons (active_weapon_ids) and the
# always-on aim weapon (never in active_weapon_ids, so it needs its own call site).
func _apply_weapon_thresholds(pilot: PlayerState, wid: String, w: Variant) -> void:
if w == null:
return
var rules: Array = WeaponThresholds.rules_for(wid)
for i in WeaponThresholds.rules_met(wid, crystals.counts):
var key := "%s:%d" % [wid, i]
if pilot.arsenal.thresholds_done.has(key):
continue
pilot.arsenal.thresholds_done[key] = true
var rule: Dictionary = rules[i]
if rule["kind"] == "evolve":
if not w.evolved:
w.evolve()
else:
var mag: float = upgrade_system.aim_mod_mag(rule["kind"]) if wid == "aim" else upgrade_system.weapon_mod_mag(self, wid, rule["kind"])
w.apply_mod(rule["kind"], mag)
  • Step 4: Run the test to verify it passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_weapon_thresholds -gexit Expected: PASS, including the pre-existing single-player tests in the same file (they only ever create one pilot, so for pilot in pilots: iterates exactly once, unchanged behaviour).

  • Step 5: Run the full suite + determinism check

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: same Scripts count as Task 1’s Step 6, 0 failing, determinism baseline (tests/test_determinism_survival.gd) still passes. (Per the crystals-is-primary-mode memory, crystals.counts only grows via an applied crystal-grant card, which the raw 600-tick baseline never triggers — so this is a structural no-op in the baseline window either way, single- or multi-pilot.)

  • Step 6: Commit
Terminal window
git add sim/sim.gd tests/test_weapon_thresholds.gd
git commit -m "fix(sim): crystal-threshold auto-upgrades apply per-pilot, not just P1
_eval_thresholds() now loops every pilot against the shared crystal
wallet, applying threshold-crossing weapon upgrades to each pilot's
own arsenal independently. Previously P2 could receive crystal grants
but never benefit from them — the primary progression pipeline was
silently P1-only."

Task 3: Sequential co-op level-up trigger in main.gd + pilot-aware LevelUpPanel

Section titled “Task 3: Sequential co-op level-up trigger in main.gd + pilot-aware LevelUpPanel”

The real, player-visible feature: main.gd now scans all pilots for pending level-ups and chains panels sequentially (P1 first on a simultaneous level-up, then P2, sim staying paused throughout). ui/level_up_panel.gd becomes pilot-aware: it shows the correct pilot’s own build/stats, gates input to that pilot’s own device in co-op, and labels which pilot the panel is for.

Files:

  • Modify: main.gd (new field, new helper functions, _open_levelup/_on_upgrade_chosen/the trigger check rewritten)
  • Modify: ui/level_up_panel.gd (show_for signature + device gating + label)
  • Modify: tests/test_level_up_panel.gd (3 show_for calls updated to the new signature)
  • Test: new tests in tests/test_coop.gd (co-op level-up sequencing) and tests/test_level_up_panel.gd (device gating + label)

Interfaces:

  • Consumes: Task 1’s upgrade_system.*(sim, pilot, ...) signatures; sim.pilots: Array[PlayerState]; InputRouter.playable_joypads() -> Array (already exists, returns device indices in join order — index 0 is always P1’s device, index 1 is P2’s).

  • Produces: LevelUpPanel.show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> void

  • Step 1: Write the failing co-op sequencing test

Add to tests/test_coop.gd. This test is deliberately scoped to the upgrade pipeline, not XP banking (XP-to-level conversion is Sim._bank_xp, private, and already covered by other tests) — it sets pending_levelups directly rather than awarding XP and waiting for the real curve to cross a threshold:

func test_p2_levels_up_independently_of_p1() -> void:
var s := _sim()
s.enable_survival()
s.add_pilot()
s.player.pending_levelups = 1
s.player2.pending_levelups = 1
# Apply a choice for P1 only — P2's own arsenal must be untouched.
var p1_ids := s.upgrade_system.roll_upgrade_choices(s, s.player, 3)
s.upgrade_system.apply_upgrade(s, s.player, p1_ids[0])
assert_eq(s.player.pending_levelups, 0, "P1's pending count dropped")
assert_eq(s.player2.pending_levelups, 1, "P2's pending count is untouched by P1's pick")
# P2 picks their own choice independently.
var p2_ids := s.upgrade_system.roll_upgrade_choices(s, s.player2, 3)
s.upgrade_system.apply_upgrade(s, s.player2, p2_ids[0])
assert_eq(s.player2.pending_levelups, 0, "P2's pending count dropped from P2's own pick")
  • Step 2: Run the test — it should already PASS

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop -gexit Expected: PASS. This test only exercises upgrade_system.* directly (already fully pilot-parameterized as of Task 1), not main.gd’s trigger — so it isn’t a red/green gate for this task’s own deliverable. Its purpose here is as a permanent regression guard proving “P2’s upgrade pipeline is independent of P1’s” stays true once Task 3’s main.gd/LevelUpPanel changes land below. If it fails, something in Task 1/2 regressed — stop and fix that before continuing.

  • Step 3: Implement main.gd’s sequential trigger

Add a new field near the existing _paused_for_levelup/_levelup_opened_this_tick fields (around line 103-107):

# Which pilot currently owns the open level-up panel (sequential co-op level-ups — see
# _next_leveling_pilot). Always `player` outside co-op; null when no panel is open.
var _leveling_pilot: PlayerState = null

Add two new helper functions (place them near _open_levelup):

# Sequential co-op level-ups: whichever pilot has pending choices first (P1 before P2 on a
# simultaneous level-up — the common case, since XP is shared and both pilots level in lockstep
# unless one has died) gets the panel; the sim stays paused for the other pilot until both have
# chosen. Single-player always returns `player` (or null once it has no pending levels).
func _next_leveling_pilot() -> PlayerState:
for pilot in sim.pilots:
if pilot.pending_levelups > 0:
return pilot
return null
func _leveling_pilot_idx() -> int:
return sim.pilots.find(_leveling_pilot)
# The joypad device bound to the leveling pilot, or -1 outside co-op (unrestricted panel input —
# LevelUpPanel treats -1 as "no gating", matching today's single-player behaviour).
func _leveling_pilot_device() -> int:
if sim.pilots.size() <= 1:
return -1
var idx := _leveling_pilot_idx()
var playable := input_router.playable_joypads()
return playable[idx] if idx >= 0 and idx < playable.size() else -1

Replace the trigger check — OLD (around line 1037-1039):

if sim.pending_levelups > 0:
_levelup_opened_this_tick = true
_open_levelup()

NEW:

if _next_leveling_pilot() != null:
_levelup_opened_this_tick = true
_open_levelup()

Replace _open_levelup — OLD (around line 1341-1354):

func _open_levelup() -> void:
_paused_for_levelup = true
if screen_fx != null:
screen_fx.set_suppressed(true) # clear the red damage overlay so the panel reads clearly
audio.level_up()
if fx_layer != null:
fx_layer.consume([{"kind": "level_up", "pos": player_node.position}])
# Always 3 choices per level-up (Chris: "change the upgrade to 3 per lvl" — crystals mode
# previously got a 4th slot; unified with every other mode).
var ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)
_current_choice_ids = ids
if _auto:
_auto_pick_timer = 0.8 # let the choice show briefly, then the AI picks
level_up.show_for(sim, ids)

NEW:

func _open_levelup() -> void:
_paused_for_levelup = true
_leveling_pilot = _next_leveling_pilot()
if screen_fx != null:
screen_fx.set_suppressed(true) # clear the red damage overlay so the panel reads clearly
audio.level_up()
if fx_layer != null:
fx_layer.consume([{"kind": "level_up", "pos": player_node.position}])
# Always 3 choices per level-up (Chris: "change the upgrade to 3 per lvl" — crystals mode
# previously got a 4th slot; unified with every other mode).
var ids := sim.upgrade_system.roll_upgrade_choices(sim, _leveling_pilot, 3)
_current_choice_ids = ids
if _auto:
_auto_pick_timer = 0.8 # let the choice show briefly, then the AI picks
level_up.show_for(sim, _leveling_pilot, ids, _leveling_pilot_device(), _leveling_pilot_idx())

Replace _on_upgrade_chosen — OLD (around line 1736-1742):

func _on_upgrade_chosen(id: String) -> void:
_applied_upgrades.append(id)
sim.upgrade_system.apply_upgrade(sim, id)
level_up.hide_panel()
if sim.pending_levelups > 0:
_open_levelup()
else:
_resume_grace = RESUME_GRACE
sim.player.iframe_timer = RESUME_IFRAMES

NEW:

func _on_upgrade_chosen(id: String) -> void:
_applied_upgrades.append(id)
sim.upgrade_system.apply_upgrade(sim, _leveling_pilot, id)
level_up.hide_panel()
if _next_leveling_pilot() != null:
_open_levelup()
else:
_leveling_pilot = null
_resume_grace = RESUME_GRACE
# Both pilots were frozen for the panel(s) — grant the brief post-level-up grace to
# everyone re-entering play together, not just P1.
for pilot in sim.pilots:
pilot.iframe_timer = RESUME_IFRAMES
  • Step 4: Implement LevelUpPanel’s pilot-awareness

Add new fields near the top of ui/level_up_panel.gd (alongside the existing _sim: Sim field):

var _pilot: PlayerState # the pilot this open panel belongs to
var _gate_device: int = -1 # -1 = unrestricted (single-player); otherwise the only
# joypad device id allowed to drive this panel (co-op)
var _gate_p1: bool = true # true when the gated pilot is P1 (keyboard/mouse allowed)

show_for — OLD:

func show_for(sim: Sim, ids: Array) -> void:
# Clear previous content
for c in _left.get_children():
c.queue_free()
for c in _right.get_children():
c.queue_free()
_cards.clear()
_effects_cache.clear()
_ship_cache.clear()
_stat_labels.clear()
_weapon_labels.clear()
_weapon_wids.clear()
_col = 0
_wsel = 0
_detail_box = null
_sim = sim
# Pre-rank ids and cache effects + ship preview (avoids repeat sim calls during focus changes)
var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, ids)
for id: String in ranked:
_effects_cache[id] = sim.upgrade_system.upgrade_effects(sim, id)
_ship_cache[id] = sim.upgrade_system.ship_stat_preview(sim, id)
_build_left(ranked)
_build_right(sim)
if not ranked.is_empty():
_render_ship_preview(ranked[0]) # preview the BEST card's ship change up-front
_sel = 0
visible = true
_last_nav_ms = 0
if not _cards.is_empty():
_cards[0].grab_focus.call_deferred()

NEW:

func show_for(sim: Sim, pilot: PlayerState, ids: Array, allowed_device: int = -1, pilot_idx: int = 0) -> void:
# Clear previous content
for c in _left.get_children():
c.queue_free()
for c in _right.get_children():
c.queue_free()
_cards.clear()
_effects_cache.clear()
_ship_cache.clear()
_stat_labels.clear()
_weapon_labels.clear()
_weapon_wids.clear()
_col = 0
_wsel = 0
_detail_box = null
_sim = sim
_pilot = pilot
_gate_device = allowed_device
_gate_p1 = pilot_idx == 0
# Pre-rank ids and cache effects + ship preview (avoids repeat sim calls during focus changes)
var ranked: Array[String] = sim.upgrade_system.rank_upgrades(sim, pilot, ids)
for id: String in ranked:
_effects_cache[id] = sim.upgrade_system.upgrade_effects(sim, pilot, id)
_ship_cache[id] = sim.upgrade_system.ship_stat_preview(sim, pilot, id)
_build_left(ranked)
_build_right(sim, pilot)
if not ranked.is_empty():
_render_ship_preview(ranked[0]) # preview the BEST card's ship change up-front
_sel = 0
visible = true
_last_nav_ms = 0
if not _cards.is_empty():
_cards[0].grab_focus.call_deferred()

hide_panel — OLD:

func hide_panel() -> void:
visible = false
_cards.clear()
_effects_cache.clear()
_ship_cache.clear()
_stat_labels.clear()
_weapon_labels.clear()
_weapon_wids.clear()
_col = 0
_wsel = 0
_sim = null
_detail_box = null

NEW:

func hide_panel() -> void:
visible = false
_cards.clear()
_effects_cache.clear()
_ship_cache.clear()
_stat_labels.clear()
_weapon_labels.clear()
_weapon_wids.clear()
_col = 0
_wsel = 0
_sim = null
_pilot = null
_gate_device = -1
_gate_p1 = true
_detail_box = null

_build_left — OLD (title block only):

func _build_left(ranked: Array[String]) -> void:
# Title
var title := Label.new()
title.text = "LEVEL UP · Lv %d" % _sim.player.level
title.add_theme_font_override("font", NeonTheme.title_font())
title.add_theme_font_size_override("font_size", 38)
title.add_theme_color_override("font_color", NeonTheme.CYAN)
title.add_theme_color_override("font_outline_color", Color(0.1, 0.5, 0.9, 0.5))
title.add_theme_constant_override("outline_size", 8)
_left.add_child(title)

NEW:

func _build_left(ranked: Array[String]) -> void:
# Co-op only: which pilot this panel belongs to (both pilots are frozen while either one
# chooses, so this disambiguates whose turn it is).
if _sim.pilots.size() > 1:
var pilot_lbl := Label.new()
pilot_lbl.text = "PLAYER 1 LEVEL UP" if _gate_p1 else "PLAYER 2 LEVEL UP"
pilot_lbl.add_theme_font_override("font", NeonTheme.mono_font())
pilot_lbl.add_theme_font_size_override("font_size", 15)
pilot_lbl.add_theme_color_override("font_color",
Color(1.0, 0.75, 0.2) if _gate_p1 else Color(0.55, 0.85, 1.0))
_left.add_child(pilot_lbl)
# Title
var title := Label.new()
title.text = "LEVEL UP · Lv %d" % _pilot.level
title.add_theme_font_override("font", NeonTheme.title_font())
title.add_theme_font_size_override("font_size", 38)
title.add_theme_color_override("font_color", NeonTheme.CYAN)
title.add_theme_color_override("font_outline_color", Color(0.1, 0.5, 0.9, 0.5))
title.add_theme_constant_override("outline_size", 8)
_left.add_child(title)

_build_right — OLD (signature + the two weapon-loop lines + the p := sim.player line):

func _build_right(sim: Sim) -> void:

_right.add_child(_section_label("WEAPONS (→ inspect)"))
_weapon_labels.clear()
_weapon_wids.clear()
for wid in sim.active_weapon_ids:
var w: Variant = sim._weapon_by_id.get(wid)

_right.add_child(_section_label("PLAYER STATS"))
var p := sim.player

NEW:

func _build_right(sim: Sim, pilot: PlayerState) -> void:

_right.add_child(_section_label("WEAPONS (→ inspect)"))
_weapon_labels.clear()
_weapon_wids.clear()
for wid in pilot.arsenal.active_weapon_ids:
var w: Variant = pilot.arsenal.weapon_by_id.get(wid)

_right.add_child(_section_label("PLAYER STATS"))
var p := pilot

(Every other line inside _build_right — the DPS readout, the crystal wallet section — is unchanged; effective_dps(sim) stays a Sim-level call per the Global Constraints, and sim.crystals stays shared.)

_render_ship_preview — OLD (the _fmt_field call):

if field == pf and _sim != null:
var after_str := _fmt_field(field, _sim.player, float(preview.get("after", 0.0)))

NEW:

if field == pf and _pilot != null:
var after_str := _fmt_field(field, _pilot, float(preview.get("after", 0.0)))

_render_weapon_detail — OLD:

func _render_weapon_detail() -> void:
if _detail_box == null or not is_instance_valid(_detail_box) or _sim == null:
return
for c in _detail_box.get_children():
c.queue_free()
if _weapon_wids.is_empty():
return
_detail_box.add_child(WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _weapon_wids[_wsel])))

NEW:

func _render_weapon_detail() -> void:
if _detail_box == null or not is_instance_valid(_detail_box) or _sim == null:
return
for c in _detail_box.get_children():
c.queue_free()
if _weapon_wids.is_empty():
return
_detail_box.add_child(WeaponDetailView.build(_sim.upgrade_system.weapon_detail(_sim, _pilot, _weapon_wids[_wsel])))

_input — OLD (top of the function):

func _input(event: InputEvent) -> void:
if not visible or _cards.is_empty():
return
# Confirm: from the choices column it picks the card; from the weapon-inspect
# column it returns to the choices (so A is never a dead end).

NEW:

func _input(event: InputEvent) -> void:
if not visible or _cards.is_empty():
return
if _gate_device != -1 and not _event_allowed(event):
return
# Confirm: from the choices column it picks the card; from the weapon-inspect
# column it returns to the choices (so A is never a dead end).

Add a new helper function (place it near _input):

# Co-op device gating: only the leveling pilot's own device may drive this panel (so P1 can't
# hijack P2's pick, or vice versa). Keyboard/mouse are always allowed for P1 (conventional
# single-device play); P2 is always a joypad (co-op join requires a second controller — see
# InputRouter/main.gd's join handling), so keyboard/mouse never drive a P2 panel.
func _event_allowed(event: InputEvent) -> bool:
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
return event.device == _gate_device
return _gate_p1
  • Step 5: Update tests/test_level_up_panel.gd’s 3 show_for calls

tests/test_level_up_panel.gd:10 — OLD:

panel.show_for(sim, ids)

NEW:

panel.show_for(sim, sim.player, ids)

tests/test_level_up_panel.gd:25 — OLD:

panel.show_for(sim, ["crystals:cold=1"])

NEW:

panel.show_for(sim, sim.player, ["crystals:cold=1"])

tests/test_level_up_panel.gd:46 — OLD:

panel.show_for(sim, ["crystals:cold=5", "weapon:nova", "crystals:fire=3"])

NEW:

panel.show_for(sim, sim.player, ["crystals:cold=5", "weapon:nova", "crystals:fire=3"])
  • Step 6: Write new tests for the panel’s co-op behaviour

Append to tests/test_level_up_panel.gd:

func test_single_player_panel_has_no_pilot_label_and_unrestricted_input() -> void:
var sim := Sim.new(1, SimContentFixture.db()); sim.enable_survival()
var panel := LevelUpPanel.new()
add_child_autofree(panel)
await get_tree().process_frame
panel.show_for(sim, sim.player, ["armor"])
await get_tree().process_frame
var found_label := false
for c in panel._left.get_children():
if c is Label and String((c as Label).text).contains("LEVEL UP") and String((c as Label).text) != "LEVEL UP · Lv %d" % sim.player.level:
found_label = true
assert_false(found_label, "single-player shows no PLAYER 1/2 label")
assert_eq(panel._gate_device, -1, "single-player panel is unrestricted")
panel.hide_panel()
func test_coop_panel_shows_pilot_label_and_gates_device() -> void:
var sim := Sim.new(1, SimContentFixture.db()); sim.enable_survival()
sim.add_pilot()
var panel := LevelUpPanel.new()
add_child_autofree(panel)
await get_tree().process_frame
panel.show_for(sim, sim.player2, ["armor"], 7, 1) # P2, gated to device 7
await get_tree().process_frame
var found_label := false
for c in panel._left.get_children():
if c is Label and String((c as Label).text) == "PLAYER 2 LEVEL UP":
found_label = true
assert_true(found_label, "co-op P2 panel shows the PLAYER 2 label")
assert_eq(panel._gate_device, 7, "gated to P2's device")
assert_false(panel._gate_p1, "gate_p1 is false for a P2 panel")
panel.hide_panel()
func test_device_gating_rejects_wrong_device_and_keyboard_for_p2() -> void:
var sim := Sim.new(1, SimContentFixture.db()); sim.enable_survival()
sim.add_pilot()
var panel := LevelUpPanel.new()
add_child_autofree(panel)
await get_tree().process_frame
panel.show_for(sim, sim.player2, ["armor"], 7, 1)
await get_tree().process_frame
var own_device := InputEventJoypadButton.new()
own_device.device = 7
own_device.button_index = JOY_BUTTON_A
assert_true(panel._event_allowed(own_device), "P2's own device is allowed")
var other_device := InputEventJoypadButton.new()
other_device.device = 0
other_device.button_index = JOY_BUTTON_A
assert_false(panel._event_allowed(other_device), "a different device is rejected for a P2 panel")
var kbd := InputEventKey.new()
kbd.keycode = KEY_ENTER
assert_false(panel._event_allowed(kbd), "keyboard is rejected for a P2 panel")
panel.hide_panel()
func test_device_gating_allows_keyboard_for_p1() -> void:
var sim := Sim.new(1, SimContentFixture.db()); sim.enable_survival()
sim.add_pilot()
var panel := LevelUpPanel.new()
add_child_autofree(panel)
await get_tree().process_frame
panel.show_for(sim, sim.player, ["armor"], 3, 0) # P1, gated to device 3
await get_tree().process_frame
var kbd := InputEventKey.new()
kbd.keycode = KEY_ENTER
assert_true(panel._event_allowed(kbd), "keyboard is always allowed for a P1 panel")
var wrong_device := InputEventJoypadButton.new()
wrong_device.device = 5
wrong_device.button_index = JOY_BUTTON_A
assert_false(panel._event_allowed(wrong_device), "a non-P1 device is still rejected")
panel.hide_panel()
  • Step 7: Run the new/updated tests

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop -gexit
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_level_up_panel -gexit

Expected: PASS on both.

  • Step 8: Run the full suite + determinism check

Run:

Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit

Expected: same Scripts count as Task 2’s Step 5 plus the new tests added in this task, 0 failing, determinism baseline (tests/test_determinism_survival.gd) still passes.

  • Step 9: Manual verification (not automatable in CI — note in the task report as owed)

This step cannot be executed by the implementer subagent; note it as an item owed to Chris/Toby in the task report rather than attempting it. A real 2-controller co-op playtest is needed to confirm: level-up panels chain correctly on a real level-up, P2’s controller can’t be hijacked by P1 mashing buttons, and the P1/P2 label reads clearly during actual gameplay.

  • Step 10: Update NEXT_TASKS.md

Find the “Kill-XP/upgrade routing — HALF DONE” bullet under the 2026-07-08 overnight-review entry and mark it done in place (matching the file’s own “Mark DONE (commit X) in place” convention), noting the design (spec docs/superpowers/specs/2026-07-10-p2-upgrade-pipeline-design.md) and that a real 2-controller playtest is still owed.

  • Step 11: Commit
Terminal window
git add main.gd ui/level_up_panel.gd tests/test_coop.gd tests/test_level_up_panel.gd NEXT_TASKS.md
git commit -m "feat(coop): sequential P2 level-up panel with device gating + pilot label
main.gd now scans all pilots for pending level-ups and chains panels
sequentially (P1 first on a simultaneous level-up, sim stays paused
throughout). LevelUpPanel is pilot-aware: shows the correct pilot's
own build/stats, gates input to that pilot's own device in co-op, and
labels which pilot the panel belongs to. Completes the P2 progression
pipeline — XP banking, upgrade rolling/applying, and now the UI are
all per-pilot."

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 (upgrade_system.gd threading) in full. Task 2 covers a gap found during planning that wasn’t in the original spec text but was explicitly approved by Chris as in-scope for this same plan (crystal-threshold auto-upgrades). Task 3 covers spec Sections 2 and 3 (main.gd sequencing, LevelUpPanel device gating + label) together, since they’re compile-coupled (main.gd’s calls and show_for’s signature must change atomically).
  • Determinism: every task ends with a full-suite run including the pinned determinism test. None of the changed code paths (roll_upgrade_choices, apply_upgrade, _eval_thresholds) run inside the raw 600-tick spawn/rng baseline window under single-player, single-pilot conditions, so no re-pin is expected — but the plan does not assume this, it re-verifies every time.
  • Task boundaries: Task 1 cannot be split further — upgrade_system.gd’s 17 changed functions call each other internally (e.g. roll_upgrade_choices calls mod_eligible/can_evolve/ is_weapon_active), so a partially-migrated file would not compile. Landing it as one atomic task (with all external call sites fixed in the same task) is the smallest unit that leaves the project in a working, testable state.