P2 Upgrade Pipeline Implementation Plan
P2 Upgrade Pipeline Implementation Plan
Section titled “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.
Global Constraints
Section titled “Global Constraints”- Single-player behaviour 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, 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_slotsstays 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 readsim.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 (passsim.playerexplicitly, 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(onegrant_weaponcall site, inside story-mode setup) - Modify:
main.gd:541,1350,1738(three call sites — these get touched AGAIN in Task 3; here they just gainsim.playerto 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 INTOupgrade_system.*— the panel’s OWNshow_forsignature does not change until Task 3; here it keeps passingsim.player) - Modify:
ui/weapon_panel.gd:239,ui/ship_config_panel.gd:202(passsim.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) -> inthas_projectile_weapon(sim: Sim, pilot: PlayerState) -> boolmod_eligible(sim: Sim, pilot: PlayerState, u: Dictionary) -> boolapply_upgrade(sim: Sim, pilot: PlayerState, id: String) -> voidapply_weapon_mod(sim: Sim, pilot: PlayerState, wid: String, kind: String) -> voidweapon_level(sim: Sim, pilot: PlayerState, wid: String) -> intcan_evolve(sim: Sim, pilot: PlayerState, wid: String) -> boolevolve_weapon(sim: Sim, pilot: PlayerState, wid: String) -> voidgrant_weapon(sim: Sim, pilot: PlayerState, wid: String) -> voidis_weapon_active(sim: Sim, pilot: PlayerState, wid: String) -> boolupgrade_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionaryship_stat_preview(sim: Sim, pilot: PlayerState, id: String) -> Dictionaryweapon_detail(sim: Sim, pilot: PlayerState, wid: String) -> Dictionaryupgrade_effects(sim: Sim, pilot: PlayerState, id: String) -> Dictionaryrank_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, fieldslevel,hp,max_hp,armor,speed,fire_rate_mult,pickup_radius,pending_levelups,arsenal: PilotArsenal) andPilotArsenal(sim/pilot_arsenal.gd, fieldsactive_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 trueNEW:
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 trueapply_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_idsNEW:
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 outNEW:
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 outactive_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_idxNEW (representative excerpt — apply the same sim.<name>_element_idx → pilot.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 outAlso 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 INTOupgrade_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:
# BEFOREsim.upgrade_system.grant_weapon(sim, "nova")# AFTERsim.upgrade_system.grant_weapon(sim, sim.player, "nova")
# BEFOREvar ids := sim.upgrade_system.roll_upgrade_choices(sim, 3)# AFTERvar 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’sshow_forcalls, which are handled separately sinceLevelUpPanel.show_forisn’t touched until Task 3tests/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 varsaandb: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:
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 simEvery 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 3show_forcalls 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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: 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
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 previouslyread a P1-only Sim forwarding accessor now takes an explicitpilot: PlayerState and reads pilot/pilot.arsenal directly. Everyexisting call site updated to pass sim.player, so single-player isbyte-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-onlysim._thresholds_doneforwarding accessor — this task is the first thing to read/write it viapilot.arsenal.thresholds_donedirectly for a non-P1 pilot). -
Produces:
_eval_thresholds()keeps its existing zero-argument public signature (confirmed via repo-wide grep — every external caller, all intests/test_survival_manual_progression.gdandtests/test_weapon_thresholds.gd, calls it assim._eval_thresholds()with no arguments) — so NO existing call site needs to change. Only the private (_-prefixed, no external callers)_apply_weapon_thresholdsgains apilot: PlayerStatefirst 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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitExpected: 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
git add sim/sim.gd tests/test_weapon_thresholds.gdgit commit -m "fix(sim): crystal-threshold auto-upgrades apply per-pilot, not just P1
_eval_thresholds() now loops every pilot against the shared crystalwallet, applying threshold-crossing weapon upgrades to each pilot'sown arsenal independently. Previously P2 could receive crystal grantsbut never benefit from them — the primary progression pipeline wassilently 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_forsignature + device gating + label) - Modify:
tests/test_level_up_panel.gd(3show_forcalls updated to the new signature) - Test: new tests in
tests/test_coop.gd(co-op level-up sequencing) andtests/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 = nullAdd 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 -1Replace 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_IFRAMESNEW:
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 tovar _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 = nullNEW:
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.playerNEW:
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 3show_forcalls
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:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_coop -gexitgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_level_up_panel -gexitExpected: PASS on both.
- Step 8: 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 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
git add main.gd ui/level_up_panel.gd tests/test_coop.gd tests/test_level_up_panel.gd NEXT_TASKS.mdgit 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 panelssequentially (P1 first on a simultaneous level-up, sim stays pausedthroughout). LevelUpPanel is pilot-aware: shows the correct pilot'sown build/stats, gates input to that pilot's own device in co-op, andlabels which pilot the panel belongs to. Completes the P2 progressionpipeline — XP banking, upgrade rolling/applying, and now the UI areall 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_choicescallsmod_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.