Archive Reactions Mode + Rename Crystals Ruleset to Survival — Implementation Plan
Archive Reactions Mode + Rename Crystals Ruleset to Survival — Implementation Plan
Section titled “Archive Reactions Mode + Rename Crystals Ruleset to Survival — 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: Archive the reactions-only ruleset’s code out of the active codebase into _archive/reactions-mode/, then rename the remaining (crystals) ruleset’s identifiers to reclaim “survival” now that the old survival/reactions mode is gone.
Architecture: Two ordered commits, matching the spec’s own two-phase structure. Commit 1 archives (moves whole files, extracts reactions-only functions from shared files into inert .gd.txt reference copies, deletes them from the live files, flips Sim’s default ruleset). Commit 2 renames (only possible once commit 1 has removed the collision — main.gd’s _mode == "survival" string is currently claimed by the OLD reactions-mode menu card). Both commits happen inside the pre-existing isolated worktree at .claude/worktrees/reactions-archive-rename (branch reactions-archive-rename), verified with the full GUT suite + check-test-count.sh + determinism re-check after each, then fast-forwarded onto main.
Tech Stack: Godot 4.6.3 / GDScript, GUT 9.6.0 test framework.
Global Constraints
Section titled “Global Constraints”- Working directory for every step below:
/Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-rename(already exists, already hasmainmerged in as of 2026-07-09, already imported). - Determinism baseline at plan time:
snapshot_string().hash()=4217109746,state_checksum()=3312602286— read the literal pinned assertion in the relevant test file after every change, never trust this prose number. RULESET_REACTIONS = 0andRULESET_CRYSTALS = 1are plain typed-intconstants onSim(sim/sim.gd:329-330), not a GDScript enum. Per the approved design, both stay defined —RULESET_REACTIONSbecomes unreachable in practice (nothing ever setsrulesetto it once the default flips) but the name is not deleted from the file.- The elemental crystal currency (
sim/crystal_state.gd,crystals.counts,WeaponThresholds,tests/test_crystal_state.gd,tests/test_crystal_endless_tuning.gd) is never touched by this plan — it’s a real in-game collectible, not the ruleset’s name. - Correction from the design spec, found during planning: the spec’s Section 3 said the
CRYSTALS_DEAD_EFFECTSgating logic insim/upgrade_system.gd“becomes dead” and implied archival. This is wrong and has been corrected in this plan:CRYSTALS_DEAD_EFFECTS(reaction_damage_mult/aura_duration_mult/stack_bonus) filters 3 upgrade-card effects that remain technically pickable content (theirModStatefields still exist) but do nothing once the reaction system that reads them is archived. If this filter were deleted, those 3 cards would become offerable again with zero effect when picked — the exact “crit upgrade” dead-card bug already found and fixed elsewhere in this codebase (2026-07-09,sim/player_crit.gd’s commit history). This filter stays, unarchived, insim/upgrade_system.gd— only itssim.ruleset == sim.RULESET_CRYSTALScondition gets renamed in Task 2, not deleted. Task 1 does not touchCRYSTALS_DEAD_EFFECTSat all. AIM_MODS,aim_mod_mag(), and the"aim:"-id handling insideapply_upgrade()/upgrade_preview()/upgrade_choice_display()insim/upgrade_system.gdare not archived. Only the 3-line offer block insideroll_upgrade_choices()(the code that adds an"aim:"candidate to the level-up roll whenruleset != RULESET_CRYSTALS) is removed — the rest of the aim-mod machinery is generic infrastructure that’s harmless to keep and isn’t reactions-exclusive.
File Structure
Section titled “File Structure”New files:
_archive/reactions-mode/README.md— explains what’s archived, where each piece came from, that it’s inert reference material._archive/reactions-mode/ui/level_up_panel.gd— moved whole fromui/level_up_panel.gd._archive/reactions-mode/tests/test_level_up_panel.gd— moved whole fromtests/test_level_up_panel.gd._archive/reactions-mode/tests/test_determinism_checksum.gd— moved whole fromtests/test_determinism_checksum.gd._archive/reactions-mode/sim/elemental_system_reactions.gd.txt— extracted reaction-only functions fromsim/elemental_system.gd(inert reference,.txtso Godot never parses it)._archive/reactions-mode/sim/upgrade_system_reactions.gd.txt— extractedaim:-offer block fromsim/upgrade_system.gd(inert reference)._archive/reactions-mode/main_reactions.gd.txt— extracted old_mode == "survival"routing frommain.gd(inert reference)._archive/reactions-mode/start_menu_reactions.gd.txt— extracted old survival menu card fromui/start_menu.gd(inert reference).
Modified files (Task 1 — archival):
sim/elemental_system.gd— reaction functions/consts removed,apply_element()collapsed to its remaining unconditional behavior.sim/sim.gd—update_zones()caller removed fromtick(); defaultrulesetflips toRULESET_CRYSTALS.sim/upgrade_system.gd— the 3-lineaim:-offer block removed fromroll_upgrade_choices().main.gd— old_mode == "survival"reactions routing removed;LevelUpPanel/level_upvar + its branch in_open_levelup()removed.ui/start_menu.gd— old["survival", "SURVIVAL", ...]menu card entry removed from the full/non-gated array.tests/test_crystals_ruleset.gd—test_default_ruleset_is_reactions()updated (default is no longer reactions).
Modified files (Task 2 — rename):
sim/sim.gd—RULESET_CRYSTALS→RULESET_SURVIVAL,enable_crystals()→enable_survival().ui/crystals_levelup_panel.gd→ renamed toui/level_up_panel.gd, classCrystalsLevelUpPanel→LevelUpPanel.main.gd—_mode == "crystals"→_mode == "survival";crystals_level_up/CrystalsLevelUpPanelreferences updated;enable_crystals()call updated.ui/start_menu.gd— surviving menu card’s id"crystals"→"survival".net/gameplay_telemetry.gd— telemetry"ruleset"label"crystals"→"survival".sim/upgrade_system.gd—sim.RULESET_CRYSTALSreferences →sim.RULESET_SURVIVAL(theCRYSTALS_DEAD_EFFECTSname itself is NOT renamed — internal implementation detail, out of the spec’s explicit rename list).tests/test_crystals_ruleset.gd→ renamed totests/test_survival_ruleset.gd.tests/test_crystals_levelup_panel.gd→ renamed totests/test_level_up_panel.gd.tests/test_crystals_manual_progression.gd→ renamed totests/test_survival_manual_progression.gd.tests/test_determinism_crystals.gd→ renamed totests/test_determinism_survival.gd.CLAUDE.md,.claude/skills/bh-add-content/SKILL.md,.claude/skills/bh-determinism-repin/SKILL.md— doc updates.
Task 1: Archive reactions-only code, flip the default ruleset
Section titled “Task 1: Archive reactions-only code, flip the default ruleset”Files:
- Create:
_archive/reactions-mode/README.md,_archive/reactions-mode/ui/level_up_panel.gd(moved),_archive/reactions-mode/tests/test_level_up_panel.gd(moved),_archive/reactions-mode/tests/test_determinism_checksum.gd(moved),_archive/reactions-mode/sim/elemental_system_reactions.gd.txt,_archive/reactions-mode/sim/upgrade_system_reactions.gd.txt,_archive/reactions-mode/main_reactions.gd.txt,_archive/reactions-mode/start_menu_reactions.gd.txt - Modify:
sim/elemental_system.gd,sim/sim.gd,sim/upgrade_system.gd,main.gd,ui/start_menu.gd,tests/test_crystals_ruleset.gd - Test: full suite +
scripts/check-test-count.sh
Interfaces:
-
Consumes: nothing new — this task only removes code.
-
Produces:
Sim.new()’s defaultrulesetisRULESET_CRYSTALSfrom this task onward (wasRULESET_REACTIONS). Every later task (and any future work) must NOT assume the old default. -
Step 1: Tag the pre-archival commit for safety
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegit tag pre-reactions-archive-2026-07-09- Step 2: Create the archive directory structure and README
mkdir -p _archive/reactions-mode/ui _archive/reactions-mode/sim _archive/reactions-mode/testsWrite _archive/reactions-mode/README.md:
# Archived: Reactions Mode
This directory holds the code for Bullet Heaven's original "reactions" ruleset(`Sim.RULESET_REACTIONS`, internally `0`) — full elemental engine with auras,stacks, reactions, terrain zones, and aura chains — archived 2026-07-09 whenthe crystals ruleset (renamed to "Survival") became the game's only mode.
Nothing here is wired into any build. Files with a `.gd` extension are theexact original code (safe to copy back into a live location and re-wire ifthis mode is ever revived). Files with a `.gd.txt` extension are extractedreference SNIPPETS from files that still have live code in them — pasting a`.gd.txt` file back requires re-integrating it into its original host file,not just renaming the extension.
The commit immediately before this archival is tagged`pre-reactions-archive-2026-07-09` — the full original state (including afully wired, playable reactions mode) is always recoverable from there evenwithout this directory.
## What's here
- `ui/level_up_panel.gd` — the `LevelUpPanel` class, reactions-mode's level-up card UI. Moved whole from `ui/level_up_panel.gd`.- `tests/test_level_up_panel.gd` — its test. Moved whole from `tests/test_level_up_panel.gd`.- `tests/test_determinism_checksum.gd` — the reactions-mode determinism baseline test (pinned trace for `RULESET_REACTIONS`). Moved whole from `tests/test_determinism_checksum.gd`.- `sim/elemental_system_reactions.gd.txt` — the reaction/terrain/chain functions (`reaction_burst`, `on_reaction`, `pop_primed`, `seed_primed`, `spawn_zone`, `update_zones`) and their tuning consts, extracted from `sim/elemental_system.gd`.- `sim/upgrade_system_reactions.gd.txt` — the `aim:`-mod level-up-card offer block, extracted from `sim/upgrade_system.gd`'s `roll_upgrade_choices()`.- `main_reactions.gd.txt` — the old `_mode == "survival"` reactions-mode routing (menu music selection, the `LevelUpPanel` display branch), extracted from `main.gd`.- `start_menu_reactions.gd.txt` — the old `["survival", "SURVIVAL", ...]` menu card entry, extracted from `ui/start_menu.gd`.
## What's NOT here (deliberately)
The elemental crystal *currency* (`sim/crystal_state.gd`, `WeaponThresholds`)is untouched and still lives at its original location — it's a realin-game collectible in the (renamed) Survival ruleset, unrelated to thearchived reactions ruleset despite the naming proximity.- Step 3: Move the 3 whole files
git mv ui/level_up_panel.gd _archive/reactions-mode/ui/level_up_panel.gdgit mv tests/test_level_up_panel.gd _archive/reactions-mode/tests/test_level_up_panel.gdgit mv tests/test_determinism_checksum.gd _archive/reactions-mode/tests/test_determinism_checksum.gd- Step 4: Extract the reaction-only code from
sim/elemental_system.gdinto the archive reference file
Write _archive/reactions-mode/sim/elemental_system_reactions.gd.txt:
# Extracted from sim/elemental_system.gd, archived 2026-07-09 (reactions mode retired).# Inert reference only — NOT live GDScript, not wired into any build. To revive: paste# these consts + functions back into a live ElementalSystem-equivalent class, and restore# apply_element()'s original branching (see git tag pre-reactions-archive-2026-07-09 for# the exact original file).
# Elemental reactions — fired when a weapon hit applies a DIFFERENT element to an# already-aura'd enemy.const REACTION_BURST_RADIUS: float = 120.0const GENERIC_REACTION_RADIUS: float = 70.0const GENERIC_REACTION_MAGNITUDE: float = 8.0const REACTION_COOLDOWN: float = 0.45 # per-enemy gate so reactions don't fire every tickconst REACTION_DAMAGE_SCALE: float = 0.62 # reactions hit HARD — close elemental play out-damages poking from range# Reaction Terrain — a lingering elemental zone dropped at each reaction.const ZONE_RADIUS: float = 78.0const ZONE_DURATION: float = 2.4const ZONE_DPS: float = 2.0const ZONE_CAP: int = 64# Aura Chains — a reaction primes nearby enemies; a hit on a primed one pops a mini-burst.const CHAIN_RADIUS: float = 120.0const PRIMED_POP_MAGNITUDE: float = 6.0
func reaction_burst(sim: Sim, center: Vector2, magnitude: float, generic: bool, _element_idx: int) -> void: var radius := GENERIC_REACTION_RADIUS if generic else REACTION_BURST_RADIUS var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * REACTION_DAMAGE_SCALE * sim.mods.reaction_damage_mult var hits := sim.hash.query_circle(center, radius, sim.enemies) for ei in hits: damage_enemy(sim, ei, amount)
func on_reaction(sim: Sim, ei: int, ev: Dictionary, element_idx: int) -> void: if sim.enemies.react_cd[ei] > 0.0: return # rate-limit bursts/terrain/chains per enemy sim.enemies.react_cd[ei] = REACTION_COOLDOWN sim.reactions_total += 1 # telemetry (excluded from the checksum) var center: Vector2 = ev["center"] reaction_burst(sim, center, float(ev.get("magnitude", 0.0)), bool(ev.get("generic", true)), element_idx) spawn_zone(sim, center, element_idx) seed_primed(sim, center, ei) sim.fx_events.append({"kind": "reaction", "pos": center, "element": element_idx, "name": ev.get("name", "")})
func pop_primed(sim: Sim, ei: int, element_idx: int) -> void: sim.enemies.primed[ei] = 0 var center := sim.enemies.pos[ei] reaction_burst(sim, center, PRIMED_POP_MAGNITUDE, true, element_idx) sim.fx_events.append({"kind": "chain", "pos": center, "element": element_idx})
func seed_primed(sim: Sim, center: Vector2, source_ei: int) -> void: var hits := sim.hash.query_circle(center, CHAIN_RADIUS, sim.enemies) for ei in hits: if ei != source_ei and sim.enemies.data[ei] > 0.0: sim.enemies.primed[ei] = 1
func spawn_zone(sim: Sim, center: Vector2, element_idx: int) -> void: if sim.zones.size() >= ZONE_CAP: sim.zones.pop_front() sim.zones.append({"pos": center, "radius": ZONE_RADIUS, "remaining": ZONE_DURATION, "element": element_idx})
func update_zones(sim: Sim, dt: float) -> void: var z := sim.zones.size() - 1 while z >= 0: var zone: Dictionary = sim.zones[z] var hits := sim.hash.query_circle(zone["pos"], zone["radius"], sim.enemies) for ei in hits: damage_enemy(sim, ei, ZONE_DPS * dt) zone["remaining"] -= dt if zone["remaining"] <= 0.0: sim.zones.remove_at(z) z -= 1
# apply_element()'s ORIGINAL body (for reference — the live version after archival is# unconditional cosmetic-tint-only, see sim/elemental_system.gd):## func apply_element(sim: Sim, ei: int, element_idx: int) -> void:# if element_idx < 0 or sim.enemies.data[ei] <= 0.0:# return# if sim.ruleset == sim.RULESET_CRYSTALS:# sim.enemies.aura_element[ei] = element_idx# return# var was_primed := sim.enemies.primed[ei] == 1# var ev := Elemental.apply(sim.enemies, ei, element_idx, sim.content, sim.mods)# if not ev.is_empty() and sim.enemies.data[ei] > 0.0:# on_reaction(sim, ei, ev, element_idx)# if was_primed and sim.enemies.data[ei] > 0.0:# pop_primed(sim, ei, element_idx)- Step 5: Remove the extracted code from the live
sim/elemental_system.gd
Delete these exact lines (the reaction-only consts, lines 25-41 in the pre-archival file):
# Elemental reactions — fired when a weapon hit applies a DIFFERENT element to an# already-aura'd enemy.const REACTION_BURST_RADIUS: float = 120.0# Impact burst — the manual (kinetic) shot's baseline AoE splash on a direct hit.const BURST_FRAC: float = 0.5const GENERIC_REACTION_RADIUS: float = 70.0const GENERIC_REACTION_MAGNITUDE: float = 8.0const REACTION_COOLDOWN: float = 0.45 # per-enemy gate so reactions don't fire every tickconst REACTION_DAMAGE_SCALE: float = 0.62 # reactions hit HARD — close elemental play out-damages poking from range# Reaction Terrain — a lingering elemental zone dropped at each reaction.const ZONE_RADIUS: float = 78.0const ZONE_DURATION: float = 2.4const ZONE_DPS: float = 2.0const ZONE_CAP: int = 64# Aura Chains — a reaction primes nearby enemies; a hit on a primed one pops a mini-burst.const CHAIN_RADIUS: float = 120.0const PRIMED_POP_MAGNITUDE: float = 6.0Replace with (keeping BURST_FRAC, which is shared — the manual weapon’s impact-burst splash, unrelated to elemental reactions):
# Impact burst — the manual (kinetic) shot's baseline AoE splash on a direct hit.const BURST_FRAC: float = 0.5Replace apply_element()’s full body:
# All weapon-applied elements route through here so reactions, terrain, chains# and named VFX are handled in one place.func apply_element(sim: Sim, ei: int, element_idx: int) -> void: if element_idx < 0 or sim.enemies.data[ei] <= 0.0: return if sim.ruleset == sim.RULESET_CRYSTALS: # Cosmetic tint only: set the aura colour but skip Elemental.apply # so there are no reactions, bursts, terrain zones, status effects, or priming. sim.enemies.aura_element[ei] = element_idx return var was_primed := sim.enemies.primed[ei] == 1 var ev := Elemental.apply(sim.enemies, ei, element_idx, sim.content, sim.mods) if not ev.is_empty() and sim.enemies.data[ei] > 0.0: on_reaction(sim, ei, ev, element_idx) if was_primed and sim.enemies.data[ei] > 0.0: pop_primed(sim, ei, element_idx)with:
# All weapon-applied elements route through here. Reactions mode's terrain/burst/chain# behavior was archived 2026-07-09 (see _archive/reactions-mode/) — every enemy now just# gets the cosmetic aura tint set, unconditionally (no more ruleset branch: this was the# only remaining behavior once the reactions-only functions this used to call were# archived out of this file).func apply_element(sim: Sim, ei: int, element_idx: int) -> void: if element_idx < 0 or sim.enemies.data[ei] <= 0.0: return sim.enemies.aura_element[ei] = element_idxDelete reaction_burst(), on_reaction(), pop_primed(), seed_primed(), spawn_zone(), and update_zones() in full (their exact bodies are shown in Step 4’s extraction above — delete the live copies of all 6 functions from sim/elemental_system.gd).
- Step 6: Remove
update_zones()’s caller insim/sim.gd
update_zones() was called once per tick from Sim.tick(). Find and remove that call site:
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegrep -n "elemental_system.update_zones" sim/sim.gdRemove the matching line (e.g. elemental_system.update_zones(self, dt)) from wherever tick() calls it — sim.zones is now permanently empty (nothing populates it anymore since spawn_zone() is archived), so this call was about to become a guaranteed no-op loop over an empty array; removing it is a pure cleanup, not a behavior change.
- Step 7: Extract the
aim:-offer block fromsim/upgrade_system.gd
Write _archive/reactions-mode/sim/upgrade_system_reactions.gd.txt:
# Extracted from sim/upgrade_system.gd's roll_upgrade_choices(), archived 2026-07-09# (reactions mode retired). Inert reference only. This is the block that used to offer# an "aim:" mod card in the level-up roll whenever the run was NOT in crystals mode# (i.e. was in reactions mode). AIM_MODS/aim_mod_mag() themselves are NOT archived —# they're still live, generic infrastructure (apply_upgrade/upgrade_preview/# upgrade_choice_display all still handle "aim:"-prefixed ids) — only this OFFER site# is gone, since reactions mode (the only ruleset that ever triggered this branch) no# longer exists.## Original location: inside roll_upgrade_choices(sim, n), right after the wmod_ids loop:## if sim.ruleset != sim.RULESET_CRYSTALS:# var ak: String = AIM_MODS[sim.upgrade_rng.randi_range(0, AIM_MODS.size() - 1)][0]# wmod_ids.append("aim:" + ak)- Step 8: Remove the extracted block from the live
sim/upgrade_system.gd
Delete these exact lines from inside roll_upgrade_choices():
# The manual (aim) weapon is always owned → always offer-eligible; add at most ONE candidate # per roll so it can't crowd out arsenal upgrades. Draw from the upgrade_rng stream only. if sim.ruleset != sim.RULESET_CRYSTALS: var ak: String = AIM_MODS[sim.upgrade_rng.randi_range(0, AIM_MODS.size() - 1)][0] wmod_ids.append("aim:" + ak)(The blank line immediately after can stay or go — just don’t leave two blank lines in a row.)
- Step 9: Extract
main.gd’s old reactions-mode routing
Write _archive/reactions-mode/main_reactions.gd.txt:
# Extracted from main.gd, archived 2026-07-09 (reactions mode retired). Inert reference# only. This is the old-survival (reactions-mode) routing: playing the survival_loop# music for the (old) "survival" menu card, the story-load-failure fallback that used to# drop back into reactions mode, and the LevelUpPanel display branch inside# _open_levelup(). Original locations (line numbers as of the pre-archival commit,# tag pre-reactions-archive-2026-07-09):
# _on_mode_chosen(), lines 443-452 (survival/crystals both played survival_loop music —# after archival only "survival" exists, so this collapses, see live main.gd):# func _on_mode_chosen(mode: String) -> void:# if mode == "survival" or mode == "crystals":# audio.play_music("survival_loop")# else:# audio.stop_music()# _mode = mode# _new_run()
# _new_run()'s story-load-failure fallback, lines 539-541 (fell back to reactions mode# by setting _mode = "survival", which at the time meant RULESET_REACTIONS via the OLD# menu-card id "survival" — this string collided with the id the CRYSTALS ruleset's own# menu card would later reclaim, see the design spec's "collision" section):# else:# push_error("main: story.json failed to load — falling back to survival")# _mode = "survival"
# The var declarations + instantiation for the old LevelUpPanel (var level_up:# LevelUpPanel, constructed via `level_up = LevelUpPanel.new()` near the other panel# constructions early in _new_run()), and _open_levelup()'s ruleset-branching display# call:# if sim.ruleset == Sim.RULESET_CRYSTALS:# crystals_level_up.show_for(sim, ids)# else:# var choices: Array[Dictionary] = []# for id in ids:# choices.append(sim.upgrade_system.upgrade_preview(sim, id))# level_up.show_choices(choices, sim.player.level)- Step 10: Remove the extracted routing from the live
main.gd
10a. In _on_mode_chosen(), replace:
func _on_mode_chosen(mode: String) -> void: # Survival/Crystals (the only mode v0.1 ships) gets a gameplay loop instead of just cutting # to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence # there is unchanged behavior, not a regression (see SOUND_MAP.md Music section). if mode == "survival" or mode == "crystals": audio.play_music("survival_loop") else: audio.stop_music() _mode = mode _new_run()with:
func _on_mode_chosen(mode: String) -> void: # Survival (the only mode v0.1 ships) gets a gameplay loop instead of just cutting # to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence # there is unchanged behavior, not a regression (see SOUND_MAP.md Music section). if mode == "survival": audio.play_music("survival_loop") else: audio.stop_music() _mode = mode _new_run()10b. In _new_run()’s story-load-failure fallback, replace:
push_error("main: story.json failed to load — falling back to survival") _mode = "survival" if _mode == "crystals": sim.enable_crystals()with:
push_error("main: story.json failed to load — falling back to survival") _mode = "survival" if _mode == "survival": sim.enable_crystals()(This is a rename-shaped edit landing in the archival task because the fallback and the ruleset-enable check are adjacent — _mode is still "survival" after archival removes the old menu card that used to claim that string first; the enable_crystals() → enable_survival() rename itself happens in Task 2, not here. After this step, _mode == "survival" is the ONLY routing string that ever triggers enable_crystals(), which is exactly the collision-free state the design’s ordering was built around.)
10c. Remove main.gd’s old LevelUpPanel declaration and instantiation.
The declaration, near the other panel-variable declarations (exact line as of the pre-archival commit, main.gd:45):
var level_up: LevelUpPanelDelete this line entirely. (Line 46, var crystals_level_up: CrystalsLevelUpPanel, stays — it renames in Task 2, not here.)
The instantiation, inside _new_run()’s panel-setup block (exact lines as of the pre-archival commit, main.gd:816-824):
level_up = LevelUpPanel.new() add_child(level_up) level_up.chosen.connect(_on_upgrade_chosen) level_up.audio = audio # UI nav/select sound (Sound Phase 4, 2026-07-02)
crystals_level_up = CrystalsLevelUpPanel.new() add_child(crystals_level_up) crystals_level_up.chosen.connect(_on_upgrade_chosen) crystals_level_up.audio = audioReplace with (deleting the 4 level_up-only lines and the blank line that separated the two blocks, keeping the 4 crystals_level_up lines untouched — they rename in Task 2):
crystals_level_up = CrystalsLevelUpPanel.new() add_child(crystals_level_up) crystals_level_up.chosen.connect(_on_upgrade_chosen) crystals_level_up.audio = audio10d. In _open_levelup(), replace:
if sim.ruleset == Sim.RULESET_CRYSTALS: crystals_level_up.show_for(sim, ids) else: var choices: Array[Dictionary] = [] for id in ids: choices.append(sim.upgrade_system.upgrade_preview(sim, id)) level_up.show_choices(choices, sim.player.level)with:
crystals_level_up.show_for(sim, ids)(There is now only one ruleset, so the branch collapses to its RULESET_CRYSTALS arm unconditionally. crystals_level_up’s own class/variable name gets renamed in Task 2 — don’t rename it here, this task only removes the reactions arm.)
- Step 11: Extract
ui/start_menu.gd’s old survival menu card
Write _archive/reactions-mode/start_menu_reactions.gd.txt:
# Extracted from ui/start_menu.gd, archived 2026-07-09 (reactions mode retired). Inert# reference only. This was the old reactions-mode menu card in the full/non-gated modes# array (unreachable in the live v0.1 build even before archival, since# V01_CRYSTALS_ONLY=true always overwrote `modes` with just the crystals card — see the# live ui/start_menu.gd for what remains):## ["survival", "SURVIVAL", "Endless waves, score attack", NeonTheme.CYAN],- Step 12: Remove the extracted card from the live
ui/start_menu.gd
Replace:
var modes := [ ["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62)], ["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32)], ["survival", "SURVIVAL", "Endless waves, score attack", NeonTheme.CYAN], ["crystals", "CRYSTALS", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)], ]with:
var modes := [ ["tutorial", "TUTORIAL", "Learn the ropes, guided and paused", Color(0.5, 0.95, 0.62)], ["story", "STORY", "Ascend the Spectrum, the campaign", Color(1.0, 0.62, 0.32)], ["crystals", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)], ](The remaining card keeps its id as "crystals" for this task — the id itself renames to "survival" in Task 2, matching the ordering the design requires. The label was already “SURVIVAL”-equivalent in spirit in the V01_CRYSTALS_ONLY branch below this array; this makes the full/non-gated array consistent with that too, one card fewer.)
- Step 13: Flip
Sim’s default ruleset
In sim/sim.gd, replace:
# Ruleset controls which subsystems are active for a mode.# RULESET_REACTIONS (default): full elemental engine — auras, stacks, reactions.# RULESET_CRYSTALS: elements are cosmetic only — aura tint is set but NO reactions,# bursts, terrain zones, or priming. Call enable_crystals() to activate.const RULESET_REACTIONS := 0const RULESET_CRYSTALS := 1var ruleset: int = RULESET_REACTIONS # default keeps survival + determinism baseline byte-identicalwith:
# Ruleset controls which subsystems are active for a mode. RULESET_REACTIONS (retired# 2026-07-09, see _archive/reactions-mode/) is kept defined but unreachable -- nothing# ever sets `ruleset` to it anymore. RULESET_CRYSTALS is now the only live ruleset:# elements are cosmetic only -- aura tint is set but no reactions, bursts, terrain zones,# or priming (that machinery no longer exists in this file at all). Defaults ON now, so# every Sim.new() call is crystals-mode unless a future 3rd ruleset is ever added.const RULESET_REACTIONS := 0const RULESET_CRYSTALS := 1var ruleset: int = RULESET_CRYSTALS- Step 14: Fix the one test that explicitly asserted the old default
tests/test_crystals_ruleset.gd’s test_default_ruleset_is_reactions() will now fail — Sim.new() defaults to crystals, not reactions. Replace:
func test_default_ruleset_is_reactions() -> void: var sim := Sim.new(1, _content()) assert_eq(sim.ruleset, Sim.RULESET_REACTIONS, "default ruleset is reactions (determinism-safe)")with:
func test_default_ruleset_is_crystals() -> void: var sim := Sim.new(1, _content()) assert_eq(sim.ruleset, Sim.RULESET_CRYSTALS, "default ruleset is crystals, since reactions mode was archived 2026-07-09")(This test file gets renamed in Task 2 — don’t rename it here, only fix the assertion.)
- Step 15: Import, run the full suite, and confirm the count guard
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegodot --headless --path . --import 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -30bash scripts/check-test-count.shExpected: the count guard now reports one fewer test script than before this task (2 whole test files moved out to _archive/, so they’re no longer under tests/) — confirm the printed Scripts count matches ls tests/test_*.gd | wc -l exactly, and that it’s green (0 failing).
If anything besides tests/test_crystals_ruleset.gd (already fixed in Step 14) fails, investigate before continuing — a test failing here means either (a) it implicitly relied on the OLD default ruleset without realizing it (the exact risk this step exists to catch), or (b) a reactions-only code path this task didn’t identify is still being exercised. Do not proceed to Step 16 with a red suite.
- Step 16: Determinism check — the survival baseline is now the ONLY baseline (crystals one still pending rename)
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_crystals -gexit 2>&1 | tail -15Expected: still passes against the pinned 4217109746 / 3312602286 values — this task didn’t touch anything in the <10s/600-tick baseline window (the reactions code removed was never reachable from a crystals-ruleset run in the first place, since apply_element()’s crystals branch already skipped it before this task). If it fails, stop and investigate — this would mean the archival accidentally touched shared/crystals-reachable code, not truly reactions-exclusive code.
- Step 17: Commit
git add -Agit status --short # review before committing — confirm only the expected files changedgit commit -m "$(cat <<'EOF'refactor: archive reactions-mode code, flip Sim's default ruleset
Reactions mode (RULESET_REACTIONS) has been unreachable via the live menusince V01_CRYSTALS_ONLY=true (crystals-only). This moves its exclusive codeout of the active codebase into _archive/reactions-mode/ (whole files:ui/level_up_panel.gd + 2 test files; extracted-then-deleted from sharedfiles: elemental_system.gd's reaction/terrain/chain functions,upgrade_system.gd's aim:-card offer block, main.gd's old routing,start_menu.gd's old menu card) and flips Sim.new()'s default ruleset toRULESET_CRYSTALS (was RULESET_REACTIONS) now that nothing implements theold default anymore.
RULESET_REACTIONS itself stays defined (unreachable, not deleted) perdesign: docs/superpowers/specs/2026-07-09-reactions-mode-archive-and-rename-design.md.
Full suite green, test-count guard passing, determinism baselineunaffected (reactions code was already unreachable from any crystals-moderun before this change -- apply_element()'s crystals branch always skippedit).
Tagged pre-reactions-archive-2026-07-09 immediately before this commit.EOF)"Task 2: Rename crystals ruleset identifiers to “survival”
Section titled “Task 2: Rename crystals ruleset identifiers to “survival””Files:
- Modify:
sim/sim.gd,main.gd,ui/start_menu.gd,net/gameplay_telemetry.gd,sim/upgrade_system.gd,CLAUDE.md,.claude/skills/bh-add-content/SKILL.md,.claude/skills/bh-determinism-repin/SKILL.md - Rename (git mv):
ui/crystals_levelup_panel.gd→ui/level_up_panel.gd,tests/test_crystals_ruleset.gd→tests/test_survival_ruleset.gd,tests/test_crystals_levelup_panel.gd→tests/test_level_up_panel.gd,tests/test_crystals_manual_progression.gd→tests/test_survival_manual_progression.gd,tests/test_determinism_crystals.gd→tests/test_determinism_survival.gd - Test: full suite +
scripts/check-test-count.sh+ boot smoke test
Interfaces:
-
Consumes: Task 1’s flipped default ruleset (
RULESET_CRYSTALSis the only reachable value) and the removed_mode == "survival"collision. -
Produces:
Sim.RULESET_SURVIVAL(renamed fromRULESET_CRYSTALS),Sim.enable_survival()(renamed fromenable_crystals()),LevelUpPanelclass atui/level_up_panel.gd(renamed fromCrystalsLevelUpPanel/ui/crystals_levelup_panel.gd) — every later reference to any of these old names must use the new ones. -
Step 1: Rename the ruleset constant + activation method in
sim/sim.gd
Replace:
const RULESET_REACTIONS := 0const RULESET_CRYSTALS := 1var ruleset: int = RULESET_CRYSTALSwith:
const RULESET_REACTIONS := 0const RULESET_SURVIVAL := 1var ruleset: int = RULESET_SURVIVALReplace:
# ── Crystals mode ──────────────────────────────────────────────────────────# Sets ruleset = RULESET_CRYSTALS so elemental_system.apply_element skips reactions entirely.# Called by main.gd BEFORE the first tick; never called from /sim tests so the# survival determinism baseline is untouched.func enable_crystals() -> void: ruleset = RULESET_CRYSTALSwith:
# ── Survival mode (the game's only mode; renamed from "crystals" 2026-07-09 once# reactions mode was archived) ───────────────────────────────────────────────# Sets ruleset = RULESET_SURVIVAL so elemental_system.apply_element skips reactions# entirely. Called by main.gd BEFORE the first tick.func enable_survival() -> void: ruleset = RULESET_SURVIVAL_eval_thresholds()’s guard (if ruleset != RULESET_CRYSTALS:) also needs updating:
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegrep -n "RULESET_CRYSTALS" sim/sim.gdReplace every remaining RULESET_CRYSTALS occurrence in sim/sim.gd with RULESET_SURVIVAL (the const rename above already handles the declaration; this catches every other reference in the file, e.g. inside _eval_thresholds()).
- Step 2: Rename the crystals level-up panel class + file
git mv ui/crystals_levelup_panel.gd ui/level_up_panel.gdIn the newly-renamed ui/level_up_panel.gd, replace the class declaration:
class_name CrystalsLevelUpPanelextends CanvasLayer
# Full-screen level-up panel for the Crystals ruleset.with:
class_name LevelUpPanelextends CanvasLayer
# Full-screen level-up panel (renamed from CrystalsLevelUpPanel 2026-07-09 -- this is# now the game's only level-up panel; the old reactions-mode LevelUpPanel was archived# to _archive/reactions-mode/ui/level_up_panel.gd the same day).- Step 3: Update
main.gd’s_modestring + panel/ruleset references
Corrected from the original plan draft, 2026-07-09, after Task 1 landed: Task 1’s implementer found that collapsing these checks to _mode == "survival" alone (as this step originally specified) would have broken real gameplay, because the live menu card’s id was still "crystals" at the end of Task 1 (it only becomes "survival" in THIS task, below, at Step 4). Task 1 correctly left all 3 sites checking _mode == "crystals" or _mode == "survival" instead, with explanatory NOTE comments. Now that Step 4 (below) renames the menu card’s id to "survival", this task removes the "crystals" disjunct — the NOTE comments should go too, since the ambiguity they explained no longer exists once this step lands. Do this step and Step 4 in the same pass (order between them doesn’t matter within this single task/commit, unlike the Task 1/Task 2 ordering, which did matter).
Replace (the _on_mode_chosen() music-selection check, currently at main.gd:443-450):
# Survival/Crystals (the only mode v0.1 ships) gets a gameplay loop instead of just cutting # to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence # there is unchanged behavior, not a regression (see SOUND_MAP.md Music section). # NOTE: the live menu card's id is still "crystals" in this task (Task 1) -- it only # renames to "survival" in Task 2, once this old reactions-mode "survival" id (removed # from the menu in Step 12) is safely out of the way. Don't collapse this to a single # "survival" check before that rename lands, or real gameplay silently loses its music. if mode == "survival" or mode == "crystals": audio.play_music("survival_loop") else:with:
# Survival (the only mode v0.1 ships) gets a gameplay loop instead of just cutting # to silence; other modes (gated off for v0.1, story/tutorial) have no loop yet -- silence # there is unchanged behavior, not a regression (see SOUND_MAP.md Music section). if mode == "survival": audio.play_music("survival_loop") else:Replace (the _new_run() ruleset-activation check, currently at main.gd:545-552):
# NOTE: the live menu's crystals-ruleset card still has id "crystals" in this task # (Task 1) -- it only renames to "survival" in Task 2. The story-load-failure fallback # just above sets _mode to the literal string "survival" directly (never "crystals"), # so both strings must be checked here or a story-load failure would silently never # call enable_crystals() -- reactions mode's old "survival" menu card (now removed, # Step 12) used to make "survival" ambiguous; it no longer does, since that path is gone. if _mode == "crystals" or _mode == "survival": sim.enable_crystals()with:
if _mode == "survival": sim.enable_survival()Replace (the boss-music-swap check, currently at main.gd:1024-1028):
# Swap the gameplay music loop while any boss is alive (survival/crystals only -- the only # mode v0.1 ships with a loop at all; see _on_mode_chosen). Live menu id is still # "crystals" in this task (Task 1) -- see the enable_crystals() call above for why both # strings are checked. if _mode == "survival" or _mode == "crystals": var boss_alive: bool = sim.boss_rotation.any_boss_alive(sim)with:
# Swap the gameplay music loop while any boss is alive (survival only -- the only mode # v0.1 ships with a loop at all; see _on_mode_chosen). if _mode == "survival": var boss_alive: bool = sim.boss_rotation.any_boss_alive(sim)Find every reference to crystals_level_up (the panel variable) and CrystalsLevelUpPanel (the class) in main.gd:
grep -n "crystals_level_up\|CrystalsLevelUpPanel" main.gdRename the variable crystals_level_up → level_up throughout the file (its instantiation, e.g. crystals_level_up = CrystalsLevelUpPanel.new(), becomes level_up = LevelUpPanel.new(); every call site, e.g. crystals_level_up.show_for(sim, ids) from Task 1’s _open_levelup() simplification, becomes level_up.show_for(sim, ids); any add_child/signal-connection lines follow the same rename). The variable’s declared type also changes from CrystalsLevelUpPanel to LevelUpPanel.
- Step 4: Update
ui/start_menu.gd’s surviving menu card id
Replace:
if V01_CRYSTALS_ONLY: modes = [["crystals", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN]]with:
if V01_CRYSTALS_ONLY: modes = [["survival", "SURVIVAL", "Endless waves, build your run", NeonTheme.CYAN]]And in the full/non-gated array (already reduced to 3 entries in Task 1 Step 12), replace:
["crystals", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],with:
["survival", "SURVIVAL", "Harvest element crystals, build-craft", Color(0.72, 0.42, 1.0)],- Step 5: Update
net/gameplay_telemetry.gd’s telemetry label
Replace:
"ruleset": "crystals" if sim.ruleset == Sim.RULESET_CRYSTALS else "reactions",with:
"ruleset": "survival" if sim.ruleset == Sim.RULESET_SURVIVAL else "reactions",- Step 6: Update
sim/upgrade_system.gd’s remainingRULESET_CRYSTALSreferences
grep -n "RULESET_CRYSTALS" sim/upgrade_system.gdReplace every occurrence of sim.RULESET_CRYSTALS with sim.RULESET_SURVIVAL in this file — this includes both CRYSTALS_DEAD_EFFECTS guard sites (mod_eligible()’s two checks, upgrade_effects()’s one check) and roll_upgrade_choices()’s crystal-grant branch condition. Do not rename CRYSTALS_DEAD_EFFECTS itself (out of scope per this plan’s Global Constraints) — only the sim.RULESET_CRYSTALS → sim.RULESET_SURVIVAL token changes.
- Step 7: Rename the 4 ruleset-identity test files
git mv tests/test_crystals_ruleset.gd tests/test_survival_ruleset.gdgit mv tests/test_crystals_levelup_panel.gd tests/test_level_up_panel.gdgit mv tests/test_crystals_manual_progression.gd tests/test_survival_manual_progression.gdgit mv tests/test_determinism_crystals.gd tests/test_determinism_survival.gd(tests/test_crystal_state.gd and tests/test_crystal_endless_tuning.gd are NOT touched — they test the currency, not the ruleset.)
- Step 8: Fix the renamed test files’ internal references
In tests/test_survival_ruleset.gd: replace every Sim.RULESET_REACTIONS / Sim.RULESET_CRYSTALS / enable_crystals() reference:
grep -n "RULESET_CRYSTALS\|RULESET_REACTIONS\|enable_crystals" tests/test_survival_ruleset.gdReplace sim.enable_crystals() → sim.enable_survival() at every call site (11 occurrences per the plan’s research: lines 13, 31, 47, 58, 69, 83, 84, 90, 101, 117, 127, 137, 145, 156 in the pre-rename file — confirm exact count via the grep above, since line numbers will have shifted from Step 14 of Task 1’s edit). Replace Sim.RULESET_REACTIONS → Sim.RULESET_REACTIONS (unchanged — this const name doesn’t rename) and Sim.RULESET_CRYSTALS → Sim.RULESET_SURVIVAL (the two occurrences in test_default_ruleset_is_crystals(), already renamed from test_default_ruleset_is_reactions() in Task 1 Step 14, and test_enable_crystals_flips_flag()).
Also rename the test function test_enable_crystals_flips_flag() → test_enable_survival_flips_flag() for consistency:
func test_enable_crystals_flips_flag() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() assert_eq(sim.ruleset, Sim.RULESET_CRYSTALS)becomes:
func test_enable_survival_flips_flag() -> void: var sim := Sim.new(1, _content()) sim.enable_survival() assert_eq(sim.ruleset, Sim.RULESET_SURVIVAL)In tests/test_level_up_panel.gd (renamed from test_crystals_levelup_panel.gd): replace every CrystalsLevelUpPanel → LevelUpPanel and enable_crystals() → enable_survival():
grep -n "CrystalsLevelUpPanel\|enable_crystals" tests/test_level_up_panel.gd(3 occurrences of each per the plan’s research — lines 4/6, 20/22, 41/43 in the pre-rename file.)
In tests/test_survival_manual_progression.gd (renamed from test_crystals_manual_progression.gd): replace every enable_crystals() → enable_survival() (4 occurrences per the plan’s research — lines 12, 27, 39, 58 in the pre-rename file).
In tests/test_determinism_survival.gd (renamed from test_determinism_crystals.gd): replace every enable_crystals() → enable_survival() (2 occurrences, lines 5 and 7 in the pre-rename file). Also update the test function name and its comments for clarity — replace:
func test_crystals_trace_is_stable() -> void: var a := Sim.new(1234, SimContentFixture.db()) a.enable_crystals() var b := Sim.new(1234, SimContentFixture.db()) b.enable_crystals()with:
func test_survival_trace_is_stable() -> void: var a := Sim.new(1234, SimContentFixture.db()) a.enable_survival() var b := Sim.new(1234, SimContentFixture.db()) b.enable_survival()Leave the pinned assert_eq values and the dated comment history untouched in this step — Step 9 (determinism re-check) will confirm they’re still correct, and this file is now the sole surviving determinism baseline (the old test_determinism_checksum.gd was archived whole in Task 1), so its own comment trail is what future re-pins extend from.
- Step 9: Import, run the full suite, count guard, and determinism re-check
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegodot --headless --path . --import 2>&1 | tail -5godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -30bash scripts/check-test-count.shgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism_survival -gexit 2>&1 | tail -15Expected: full suite green, count guard passing (script count unchanged from Task 1’s end — this task only renames files, doesn’t add/remove any), test_determinism_survival still passes against 4217109746 / 3312602286. If godot --import reports a class-name resolution error for LevelUpPanel, this usually means the global script-class cache needs a moment to pick up the renamed file — re-run the import command once more before investigating further.
- Step 10: Headless boot smoke test
godot --headless --path . --quit-after 60 2>&1 | grep -i "SCRIPT ERROR"Expected: no output (grep finds nothing). If it does find SCRIPT ERROR lines, a stale reference to one of the renamed identifiers (CrystalsLevelUpPanel, RULESET_CRYSTALS, enable_crystals, crystals_level_up) survived somewhere outside the files this task’s steps covered — grep the whole tree for the offending name before proceeding:
grep -rn "CrystalsLevelUpPanel\|RULESET_CRYSTALS\|enable_crystals\|crystals_level_up" --include="*.gd" .- Step 11: Update docs
11a. In CLAUDE.md, find and reword every “survival AND crystals” / “crystals mode” phrasing to reflect there’s one mode now:
grep -n "crystals\|Crystals" CLAUDE.mdUpdate each match — for example, the Determinism baseline bullet:
- **Determinism baseline:** `snapshot_string().hash()=4217109746`, `state_checksum()=3312602286` (survival AND crystals, last re-pinned 2026-07-07). **Read the literal pinned assertion in `tests/test_determinism_checksum.gd` + `test_determinism_crystals.gd`, never a prose number** — this line has gone stale before. Re-verify after any spawn/rng-touching change; re-pinning is normal, not a break.becomes:
- **Determinism baseline:** `snapshot_string().hash()=4217109746`, `state_checksum()=3312602286` (last re-pinned 2026-07-07; the old separate reactions-mode baseline was archived 2026-07-09 along with reactions mode itself — see `_archive/reactions-mode/`). **Read the literal pinned assertion in `tests/test_determinism_survival.gd`, never a prose number** — this line has gone stale before. Re-verify after any spawn/rng-touching change; re-pinning is normal, not a break.And the StartMenu.V01_CRYSTALS_ONLY gate bullet — replace:
- `StartMenu.V01_CRYSTALS_ONLY=true` — menu shows only Survival.with:
- `StartMenu.V01_CRYSTALS_ONLY=true` — menu shows only Survival (the const name is a pre-2026-07-09-rename holdover; renaming it is optional cleanup, not required — see `_archive/reactions-mode/README.md` for the history).(Renaming the V01_CRYSTALS_ONLY const itself is explicitly NOT required by this plan — it’s a v0.1-launch gate flag name, not one of the identifiers the approved design listed for renaming. Leaving it as-is with an explanatory note is the correct, disciplined scope; don’t rename it as a drive-by.)
Also correct the stale sim/sim.gd line-count claim found during Task 2 planning research (the file is currently 2466 lines, not “~1,700” as the Architecture section states) — replace ~1,700-line "conductor" with ~2,400-line "conductor" in that bullet, or note it needs a fresh count next time that section is touched for real — whichever is faster; this is a minor drive-by fix, not the focus of this step.
11b. In .claude/skills/bh-add-content/SKILL.md, replace:
- **`WeaponThresholds.TABLE` — the CRYSTALS-mode ladder. REQUIRED if the weapon should progress in-run.** Crystals is the primary/shipping mode; `WEAPON_MODS` + level-up cards are the *survival* path only, so a weapon with no `TABLE` row is **dormant in the mode that actually ships** (the real bug from the 2026-07-05 manual-weapon work — see memory `bullet-heaven-crystals-is-primary-mode`). A crystals `"evolve"` rung calls the weapon's `evolve()` directly (no `WeaponEvolutions` row needed for the crystals path). An always-on weapon that is NOT in `active_weapon_ids` (e.g. the manual aim weapon) also needs its own `Sim._eval_thresholds` call via `_apply_weapon_thresholds(id, w)` — the loop only visits active ids.with:
- **`WeaponThresholds.TABLE` — the survival-mode crystal-progression ladder. REQUIRED if the weapon should progress in-run.** This is now the ONLY progression path (reactions mode, which used `WEAPON_MODS` + level-up cards instead, was archived 2026-07-09 — see `_archive/reactions-mode/`), so a weapon with no `TABLE` row is **permanently dormant**. A `"evolve"` rung calls the weapon's `evolve()` directly (no `WeaponEvolutions` row needed). An always-on weapon that is NOT in `active_weapon_ids` (e.g. the manual aim weapon) also needs its own `Sim._eval_thresholds` call via `_apply_weapon_thresholds(id, w)` — the loop only visits active ids.11c. In .claude/skills/bh-determinism-repin/SKILL.md, update every reference to the two old test filenames:
grep -n "test_determinism_checksum\|test_determinism_crystals\|survival\|crystals" .claude/skills/bh-determinism-repin/SKILL.mdReplace the frontmatter description field’s reference to both filenames with a single reference to tests/test_determinism_survival.gd (there’s now only one determinism test file, not two). Replace the “asserted in two files” prose (and its (survival)/(crystals mode) parentheticals) with prose describing the single surviving file. Replace both -gselect=test_determinism_crystals CLI invocation lines with -gselect=test_determinism_survival, and remove the now-inapplicable -gselect=test_determinism_checksum invocation lines entirely (that script no longer exists — it’s archived). Update the “Mention explicitly whether survival and crystals converged on the same pair or diverged” guidance to reflect there’s no second ruleset to converge/diverge against anymore — simplify to just describing the re-pin process for the single remaining baseline.
11d. Retire memory bullet-heaven-crystals-is-primary-mode — since crystals is no longer “primary” among alternatives, it’s the only mode. This is a memory-file change, not a repo file — note it in the task’s final report for whoever runs the memory-update step (memory isn’t part of this git-tracked plan’s file scope, but should be updated in the same session this plan lands).
- Step 12: Final full-suite + count-guard re-verification after doc edits
Doc-only edits don’t usually break tests, but .claude/skills/ files are gitignored/local and not verified by GUT — run the suite one more time purely as a safety net before committing, since Step 11’s main.gd/sim.gd edits in earlier steps are the ones that actually matter for this check:
cd /Users/chris/Claude/bullet-heaven/.claude/worktrees/reactions-archive-renamegodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -20bash scripts/check-test-count.sh- Step 13: Commit
git add -Agit status --short # review before committinggit commit -m "$(cat <<'EOF'refactor: rename crystals ruleset to survival
Now that reactions mode is archived (previous commit), the crystalsruleset is the game's only mode -- the "crystals" qualifier no longermakes sense as its identity. Renames the underlying identifiers to"survival", reclaiming the name the old (now-archived) reactions-mode menucard used to hold:
- Sim.RULESET_CRYSTALS -> Sim.RULESET_SURVIVAL, enable_crystals() -> enable_survival()- ui/crystals_levelup_panel.gd -> ui/level_up_panel.gd, CrystalsLevelUpPanel -> LevelUpPanel (reusing the name/path the archived old panel vacated)- main.gd's _mode == "crystals" -> "survival"; crystals_level_up var -> level_up- ui/start_menu.gd's surviving menu card id "crystals" -> "survival"- net/gameplay_telemetry.gd's ruleset label "crystals" -> "survival"- sim/upgrade_system.gd's RULESET_CRYSTALS references -> RULESET_SURVIVAL (CRYSTALS_DEAD_EFFECTS itself NOT renamed -- internal detail, out of scope)- 4 test files renamed to match (test_crystals_ruleset.gd -> test_survival_ruleset.gd, test_crystals_levelup_panel.gd -> test_level_up_panel.gd [vacated by archival], test_crystals_manual_progression.gd -> test_survival_manual_progression.gd, test_determinism_crystals.gd -> test_determinism_survival.gd)- CLAUDE.md, bh-add-content and bh-determinism-repin skills updated to match
The elemental crystal CURRENCY (sim/crystal_state.gd, WeaponThresholds)is untouched -- it's a real in-game collectible, unrelated to theruleset's name despite the naming proximity.
Full suite green, count guard passing, determinism baseline unchanged,boot smoke test clean.
Design: docs/superpowers/specs/2026-07-09-reactions-mode-archive-and-rename-design.mdEOF)"Task 3: Land on main
Section titled “Task 3: Land on main”Files: none (git operations only)
Interfaces: none
- Step 1: Confirm
mainis quiet before landing
cd /Users/chris/Claude/bullet-heavengit status --shortgit log --oneline -5git fetch origingit rev-list --left-right --count origin/main...mainExpected: clean working tree, no unfamiliar recent commits, main and origin/main at the same commit (0/0). If main has moved (either locally or on origin) since this plan’s worktree was branched, merge those changes into the worktree branch first (cd .claude/worktrees/reactions-archive-rename && git merge main), re-run the full suite there, and only then proceed — do not force-land over unreconciled changes.
- Step 2: Fast-forward
mainto the worktree branch’s tip
cd /Users/chris/Claude/bullet-heavengit fetch . reactions-archive-rename:mainIf this is refused with refusing to fetch into branch 'refs/heads/main' checked out at ..., that means main’s working tree has live uncommitted state — do NOT force it; wait and retry, or run git merge --ff-only reactions-archive-rename from inside the primary checkout instead (equivalent effect, doesn’t require the ref-update trick).
- Step 3: Verify + push
git rev-parse main reactions-archive-rename # should be identicalgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit 2>&1 | tail -10git push origin main- Step 4: Clean up the worktree
git worktree remove .claude/worktrees/reactions-archive-renamegit branch -d reactions-archive-rename(Only after confirming Step 3’s push succeeded — the worktree/branch are the working copy until then.)
Self-Review
Section titled “Self-Review”1. Spec coverage:
- Section 2 “In scope” (archive, flip default, rename, tag) — Tasks 1-2 cover all four.
- Section 2 “Out of scope” (ruleset field collapse, crystal currency, P2 pipeline, MB/MC worktree) — none of Tasks 1-3 touch any of these; confirmed by the Global Constraints section restating the currency/CRYSTALS_DEAD_EFFECTS boundary explicitly.
- Section 3 “Archive mechanics” (3 whole-file moves, 4 extraction targets, README, default flip, safety tag) — Task 1 Steps 1-14 cover all of them 1:1.
- Section 4 “Rename mechanics” (6 ordered items + test renames) — Task 2 Steps 1-8 cover all of them, in the same order the design specified.
- Section 5 “Docs to update” — Task 2 Step 11 covers CLAUDE.md, bh-add-content, bh-determinism-repin; explicitly notes the memory-file update as outside git-tracked scope for whoever runs it.
docs/ROADMAP.mdand the two architecture docs were confirmed (during plan research) to contain zero relevant references — no task needed for them. - Section 6 “Verification plan” (2 commits, full-suite + count-guard + determinism + boot-smoke,
--ff-onlyland) — Task 1 Step 15-17, Task 2 Step 9-13, Task 3 all match.
2. Placeholder scan: No TBD/TODO/“add appropriate”/“similar to Task N” patterns — every step shows exact before/after code or an exact shell command with expected output.
3. Type consistency: RULESET_SURVIVAL (Task 2 Step 1) is used consistently everywhere it’s referenced in later steps (main.gd, net/gameplay_telemetry.gd, sim/upgrade_system.gd) — no stray RULESET_CRYSTALS left in a later step. enable_survival() likewise consistent. LevelUpPanel (the renamed class) is used consistently in Task 2 Steps 2-3 without reintroducing CrystalsLevelUpPanel. level_up (the renamed main.gd variable) is used consistently in Step 3, matching the panel class rename in Step 2.
Correction applied during this self-review: the original design spec’s Section 3 implied CRYSTALS_DEAD_EFFECTS should be archived alongside the aim:-offer block. This plan’s Global Constraints section documents why that’s wrong (it would re-introduce a dead-upgrade-card bug) and none of Task 1’s or Task 2’s steps archive or delete it — only its RULESET_CRYSTALS condition gets renamed in Task 2 Step 6, matching the corrected constraint.