Transformative Elemental Mods Implementation Plan
Transformative Elemental Mods Implementation Plan
Section titled “Transformative Elemental Mods 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: Add three stackable transformative mods (Overcharge, Catalyst, Lingering) that deepen the elemental engine, chosen from the level-up pool, driven by a run-global ModState.
Architecture: A pure ModState data holder on the Sim (no-op defaults) accumulates build modifiers. A SimMods table (sibling of StatEffects) maps mod effects to ModState mutations. Upgrades.apply dispatches stat effects → PlayerState and sim-mod effects → ModState. The elemental path reads ModState (extra stacks, longer auras, bigger bursts). No-op defaults keep un-modded runs byte-identical.
Tech Stack: Godot 4.6.3 / typed GDScript, GUT 9.6.0 (headless), the Cycle-3 ContentDB data pipeline, the design-bible JSON.
Global Constraints
Section titled “Global Constraints”/simpurity: everysim/file is pure logic (no Node/render/Input/Engine/Time/File/JSON APIs). New:mod_state.gd,sim_mods.gd.ElementalandSimModstakeModState(plain data) — noSimdependency inElemental.- Determinism: no RNG added.
ModStatedefaults are no-ops (stack_bonus 0, all mults1.0), so an un-modded run is byte-identical.tests/test_determinism.gdproperty assertions stay UNCHANGED and green; additionally verify a 600-tick trace hash is unchanged (the current post-Cycle-4 baseline is 2773002137 for seed 1234 — confirm before/after equality). - Two vocabularies, one dispatch:
StatEffects(player stats) +SimMods(sim modifiers). A mod is offerable iff its effect is known to one of them. The bible’spierce/split(known to neither) stay excluded. - GUT push_error gotcha: an un-asserted
push_errorfails a test.SimMods.applyon an unknown effect is a silent no-op (upstream-guarded); failure-path tests assert via return values, not by triggeringpush_error. - Data is genuine exporter output: the three new mods are added to
seed.jsand re-exported vianode tools/design-bible/scripts/export-seed.mjs > data/bible.json(never hand-edited). - Verify the test COUNT (stale-class-cache trap); if a new
class_nametest seems dropped, rungodot --headless --path . --importthen re-run. - TDD, DRY, YAGNI, frequent commits.
- Effect names (exact):
stack_bonus(add, int),reaction_damage_mult(mul),aura_duration_mult(mul). They do NOT collide with anyStatEffectseffect name.
Commands
Section titled “Commands”- Single test:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/<file>.gd -gexit - Full suite:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit - Boot smoke:
godot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR"(expect0)
File Structure
Section titled “File Structure”New: sim/mod_state.gd, sim/sim_mods.gd; tests test_mod_state.gd, test_sim_mods.gd, test_mods_in_sim.gd.
Modified: tools/design-bible/src/seed.js + data/bible.json (re-export); sim/content_db.gd (upgrades()); sim/upgrades.gd (dispatch); sim/sim.gd (mods field, apply_upgrade, _reaction_burst, _resolve_collisions call); sim/elemental.gd (apply gains mods); sim/weapon_nova.gd (apply call); tests test_elemental.gd, test_upgrades.gd, test_content_loader.gd (offerable count 5→8).
Task 1: Data — three transformative mods
Section titled “Task 1: Data — three transformative mods”Files: Modify tools/design-bible/src/seed.js; regenerate data/bible.json.
Interfaces: Produces three new entries in bible.json mods: overcharge/catalyst/lingering, each kind: "transformative".
- Step 1: Add the mods
In tools/design-bible/src/seed.js, in the mods array (the mod(...) helper is mod(id, name, kind, effect, magnitude, applies = [])), add after the existing mod('split', ...) line:
mod('overcharge', 'Overcharge', 'transformative', 'stack_bonus', 1), mod('catalyst', 'Catalyst', 'transformative', 'reaction_damage_mult', 1.5), mod('lingering', 'Lingering', 'transformative', 'aura_duration_mult', 1.5),- Step 2: Re-export
Run (repo root): node tools/design-bible/scripts/export-seed.mjs > data/bible.json
- Step 3: Verify
Run:
python3 -c "import jsonm={x['id']:x for x in json.load(open('data/bible.json'))['data']['mods']}for k in ('overcharge','catalyst','lingering'): e=m[k]; print(k, e['kind'], e['effect'], e['magnitude']) assert e['kind']=='transformative'print('ok')"Expected: three lines + ok (overcharge stack_bonus 1, catalyst reaction_damage_mult 1.5, lingering aura_duration_mult 1.5).
- Step 4: Loader still accepts it
Run the loader test: ... -gtest=res://tests/test_content_loader.gd -gexit. Expected: PASS (the new mods are valid; the db.upgrades().size() assertion is still 5 here because upgrades() does not yet offer transformative mods — that changes in Task 4).
- Step 5: Commit
git add tools/design-bible/src/seed.js data/bible.jsongit commit -m "feat(data): three transformative elemental mods (overcharge/catalyst/lingering)"Task 2: ModState — run-global build modifiers
Section titled “Task 2: ModState — run-global build modifiers”Files: Create sim/mod_state.gd; test tests/test_mod_state.gd.
Interfaces: Produces ModState extends RefCounted with stack_bonus: int = 0, reaction_damage_mult: float = 1.0, aura_duration_mult: float = 1.0.
- Step 1: Failing test
Create tests/test_mod_state.gd:
extends GutTest
func test_defaults_are_noops() -> void: var m := ModState.new() assert_eq(m.stack_bonus, 0) assert_almost_eq(m.reaction_damage_mult, 1.0, 0.0001) assert_almost_eq(m.aura_duration_mult, 1.0, 0.0001)-
Step 2: Run — expect FAIL (
ModStatenot declared). -
Step 3: Implement
Create sim/mod_state.gd:
class_name ModStateextends RefCounted
# Run-global build modifiers accumulated from transformative mods. Pure data.# Defaults are no-ops so an un-modded run behaves identically (determinism).var stack_bonus: int = 0 # extra element stacks added per applicationvar reaction_damage_mult: float = 1.0var aura_duration_mult: float = 1.0-
Step 4: Run — expect PASS.
-
Step 5: Commit
git add sim/mod_state.gd tests/test_mod_state.gdgit commit -m "feat(sim): ModState — run-global build modifiers (no-op defaults)"Task 3: SimMods — effect → ModState mutation
Section titled “Task 3: SimMods — effect → ModState mutation”Files: Create sim/sim_mods.gd; test tests/test_sim_mods.gd.
Interfaces:
-
Consumes
ModState(Task 2). -
Produces
SimMods.is_known(effect) -> bool;SimMods.apply(effect, magnitude, mods) -> void;SimMods.describe(effect, magnitude) -> String. -
Step 1: Failing test
Create tests/test_sim_mods.gd:
extends GutTest
func test_known_vocabulary() -> void: for e in ["stack_bonus", "reaction_damage_mult", "aura_duration_mult"]: assert_true(SimMods.is_known(e), e) assert_false(SimMods.is_known("damage_mult"), "stat effects are not sim mods") assert_false(SimMods.is_known("nope"))
func test_apply_add_and_mul() -> void: var m := ModState.new() SimMods.apply("stack_bonus", 1.0, m) SimMods.apply("stack_bonus", 1.0, m) assert_eq(m.stack_bonus, 2, "additive stacks") SimMods.apply("reaction_damage_mult", 1.5, m) SimMods.apply("reaction_damage_mult", 1.5, m) assert_almost_eq(m.reaction_damage_mult, 2.25, 0.0001, "multiplicative stacks") SimMods.apply("aura_duration_mult", 1.5, m) assert_almost_eq(m.aura_duration_mult, 1.5, 0.0001)
func test_unknown_is_noop() -> void: var m := ModState.new() SimMods.apply("nope", 9.0, m) assert_eq(m.stack_bonus, 0)
func test_describe() -> void: assert_eq(SimMods.describe("stack_bonus", 1.0), "+1 element stack per hit") assert_eq(SimMods.describe("reaction_damage_mult", 1.5), "+50% reaction damage") assert_eq(SimMods.describe("aura_duration_mult", 1.5), "+50% aura duration")-
Step 2: Run — expect FAIL.
-
Step 3: Implement
Create sim/sim_mods.gd:
class_name SimMods
# Maps a transformative mod `effect` name to a ModState field + op + label.# Sibling of StatEffects, but targets the run-global ModState instead of the# player. Data drives magnitude; this table drives mechanism. Pure.const TABLE := { "stack_bonus": {"field": "stack_bonus", "op": "add", "label": "element stack per hit"}, "reaction_damage_mult": {"field": "reaction_damage_mult", "op": "mul", "label": "reaction damage"}, "aura_duration_mult": {"field": "aura_duration_mult", "op": "mul", "label": "aura duration"},}
static func is_known(effect: String) -> bool: return TABLE.has(effect)
static func apply(effect: String, magnitude: float, mods: ModState) -> void: if not TABLE.has(effect): return var spec: Dictionary = TABLE[effect] var field: String = spec["field"] if spec["op"] == "mul": mods.set(field, float(mods.get(field)) * magnitude) else: # "add" mods.set(field, mods.get(field) + magnitude)
static func describe(effect: String, magnitude: float) -> String: if not TABLE.has(effect): return "" var spec: Dictionary = TABLE[effect] if spec["op"] == "mul": var pct := int(round((magnitude - 1.0) * 100.0)) return "+%d%% %s" % [pct, spec["label"]] return "+%s %s" % [_fmt(magnitude), spec["label"]]
static func _fmt(v: float) -> String: if absf(v - round(v)) < 0.0001: return str(int(round(v))) return str(v)-
Step 4: Run — expect PASS (4/4).
-
Step 5: Commit
git add sim/sim_mods.gd tests/test_sim_mods.gdgit commit -m "feat(sim): SimMods — transformative effect -> ModState mutation + label"Task 4: ContentDB.upgrades() offers transformative mods
Section titled “Task 4: ContentDB.upgrades() offers transformative mods”Files: Modify sim/content_db.gd; update tests/test_content_loader.gd (offerable count) and tests/test_content_db.gd (new case).
Interfaces: Consumes SimMods.is_known (Task 3). upgrades() now returns stat mods (known to StatEffects) AND transformative mods (known to SimMods), document order.
- Step 1: Update tests
In tests/test_content_loader.gd, the test_load_real_file test asserts db.upgrades().size() == 5. After this task the real bible offers 5 stat + 3 sim = 8. Change that assertion:
assert_eq(db.upgrades().size(), 8, "5 stat + 3 transformative offerable upgrades")In tests/test_content_db.gd, add a case proving transformative mods with a known SimMod effect are offered while pierce/split are not. Append:
func test_upgrades_includes_known_transformative() -> void: var db := ContentDB.new({ "mods": [ {"id": "damage", "name": "D", "kind": "stat", "effect": "damage_mult", "magnitude": 1.25}, {"id": "catalyst", "name": "C", "kind": "transformative", "effect": "reaction_damage_mult", "magnitude": 1.5}, {"id": "pierce", "name": "P", "kind": "transformative", "effect": "projectiles_pierce", "magnitude": 1}, ], }) var ids: Array = [] for u in db.upgrades(): ids.append(u["id"]) assert_eq(ids, ["damage", "catalyst"], "stat + known-transformative offered; pierce (unknown effect) excluded")-
Step 2: Run — expect FAIL (count is 5 / transformative not offered yet).
-
Step 3: Implement
Replace ContentDB.upgrades() in sim/content_db.gd with:
func upgrades() -> Array: # Offerable = a mod the engine can apply: a stat mod known to StatEffects, OR # a transformative mod known to SimMods. Excludes mods with no engine behavior # (e.g. crit_chance, projectiles_pierce). Document order -> deterministic rolls. var out: Array = [] for m in _entries("mods"): if not (m is Dictionary): continue var kind: String = m.get("kind", "") var effect: String = m.get("effect", "") if kind == "stat" and StatEffects.is_known(effect): out.append(m) elif kind == "transformative" and SimMods.is_known(effect): out.append(m) return out-
Step 4: Run — expect PASS (
test_content_loader.gd+test_content_db.gd). -
Step 5: Commit
git add sim/content_db.gd tests/test_content_loader.gd tests/test_content_db.gdgit commit -m "feat(sim): ContentDB.upgrades() offers known transformative mods too"Task 5: Upgrades dispatch + Sim.mods
Section titled “Task 5: Upgrades dispatch + Sim.mods”Files: Modify sim/upgrades.gd, sim/sim.gd; update tests/test_upgrades.gd.
Interfaces:
-
Consumes
ModState(T2),SimMods(T3),ContentDB.upgrades()(T4). -
Produces
Upgrades.apply(id, content, player, mods: ModState);Upgrades.choice_display(id, content)(dispatches describe).Sim.mods: ModState;Sim.apply_upgrade(id)threads it (signature unchanged externally). -
Step 1: Update
test_upgrades.gd
Upgrades.apply gains a ModState arg. Update the existing direct calls and add sim-mod cases. Replace the file’s body tests with (keep _content()/_unique() helpers):
func test_damage_upgrade_raises_mult() -> void: var p := PlayerState.new() Upgrades.apply("damage", _content(), p, ModState.new()) assert_almost_eq(p.damage_mult, 1.25, 0.001)
func test_max_hp_upgrade_raises_cap_and_heals_bonus() -> void: var p := PlayerState.new() var before := p.max_hp var hp_before := p.hp Upgrades.apply("max-hp", _content(), p, ModState.new()) assert_almost_eq(p.max_hp, before + 25.0, 0.001) assert_almost_eq(p.hp, hp_before + 25.0, 0.001)
func test_transformative_mod_mutates_modstate_not_player() -> void: var p := PlayerState.new() var m := ModState.new() Upgrades.apply("catalyst", _content(), p, m) assert_almost_eq(m.reaction_damage_mult, 1.5, 0.001, "catalyst mutates ModState") assert_almost_eq(p.damage_mult, 1.0, 0.001, "player untouched by a sim mod")
func test_choice_display_dispatches_describe() -> void: assert_eq(Upgrades.choice_display("damage", _content())["desc"], "+25% damage") assert_eq(Upgrades.choice_display("catalyst", _content())["desc"], "+50% reaction damage")
func test_roll_choices_distinct() -> void: var rng := SeededRng.new(3) var choices := Upgrades.roll_choices(rng, _content(), 3) assert_eq(choices.size(), 3) assert_eq(choices.size(), _unique(choices).size())
func test_sim_apply_upgrade_consumes_pending() -> void: var sim := Sim.new(1, _content()) sim.pending_levelups = 2 sim.apply_upgrade("move-speed") assert_eq(sim.pending_levelups, 1) assert_gt(sim.player.speed, 260.0)(Ensure the file keeps func _content() -> ContentDB: return SimContentFixture.db() and _unique.)
-
Step 2: Run — expect FAIL (apply arity / catalyst not handled).
-
Step 3: Implement
Upgradesdispatch
In sim/upgrades.gd, replace apply and choice_display:
static func apply(id: String, content: ContentDB, player: PlayerState, mods: ModState) -> void: var u := content.upgrade(id) if u.is_empty(): push_error("Upgrades.apply: unknown upgrade '%s'" % id) return var effect: String = u["effect"] var magnitude := float(u["magnitude"]) if StatEffects.is_known(effect): StatEffects.apply(effect, magnitude, player) elif SimMods.is_known(effect): SimMods.apply(effect, magnitude, mods) else: push_error("Upgrades.apply: effect '%s' (id '%s') is not applicable" % [effect, id])
static func choice_display(id: String, content: ContentDB) -> Dictionary: var u := content.upgrade(id) if u.is_empty(): return {"name": id, "desc": ""} var effect: String = u["effect"] var magnitude := float(u["magnitude"]) var desc := "" if StatEffects.is_known(effect): desc = StatEffects.describe(effect, magnitude) elif SimMods.is_known(effect): desc = SimMods.describe(effect, magnitude) return {"name": u.get("name", id), "desc": desc}- Step 4: Add
Sim.mods+ thread it
In sim/sim.gd:
- Add a field near the other content-derived vars:
var mods: ModState. - In
_init, aftercontent = content_db, add:mods = ModState.new(). - Replace
apply_upgrade:
func apply_upgrade(id: String) -> void: Upgrades.apply(id, content, player, mods) pending_levelups = maxi(pending_levelups - 1, 0)- Step 5: Run the full suite — expect PASS.
Run the full suite. test_upgrades.gd passes; nothing else regresses (the elemental hooks are NOT wired yet, so applying a sim mod mutates ModState but has no gameplay effect — that is Task 6). Determinism test still green (no-op ModState, no upgrades in the tick loop).
- Step 6: Commit
git add sim/upgrades.gd sim/sim.gd tests/test_upgrades.gdgit commit -m "feat(sim): Upgrades dispatch (stat vs sim mod) + Sim.mods (ModState)"Task 6: Wire ModState into the elemental path (make the mods functional)
Section titled “Task 6: Wire ModState into the elemental path (make the mods functional)”Files: Modify sim/elemental.gd, sim/sim.gd, sim/weapon_nova.gd, tests/test_elemental.gd; create tests/test_mods_in_sim.gd.
Interfaces:
-
Consumes
ModState(T2),Sim.mods(T5). -
Produces
Elemental.apply(pool, i, element_idx, content, mods: ModState) -> Dictionary(reaction-event shape unchanged).Sim._reaction_burstscales bymods.reaction_damage_mult. -
Step 1: Capture the determinism baseline (before any change)
Create /tmp/trace_probe.gd:
extends SceneTreefunc _init() -> void: var content := ContentLoader.load_from_path("res://data/bible.json") var sim := Sim.new(1234, content) var lines: Array[String] = [] for i in range(600): var d := Vector2(cos(float(i) * 0.05), sin(float(i) * 0.03)) if d.length() > 0.0: d = d.normalized() sim.tick(InputState.new(d)) lines.append(sim.snapshot_string()) print("TRACEHASH:", "\n".join(lines).hash()) quit()Run godot --headless --path . -s /tmp/trace_probe.gd 2>&1 | grep TRACEHASH and record the hash (expected 2773002137). This is the byte-for-byte baseline an un-modded run must still produce after the change.
- Step 2: Write the new failing test
Create tests/test_mods_in_sim.gd:
extends GutTest
func _content() -> ContentDB: return SimContentFixture.db()
# Overcharge: +1 stack per hit -> a single pulse hit leaves 2 stacks instead of 1.func test_overcharge_adds_a_stack_per_hit() -> void: var sim := Sim.new(1, _content()) sim.mods.stack_bonus = 1 var e := sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 1000.0) sim.proj_damage = 1.0 sim.projectiles.add(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions() assert_eq(sim.enemies.stacks[e], 2, "stack_bonus adds an extra stack on the hit")
# Catalyst: reaction_damage_mult scales the Plasma burst on a neighbor.func test_catalyst_scales_reaction_burst() -> void: var fire := _content().element_index("fire") # baseline burst damage to a neighbor (no catalyst) var base := Sim.new(2, _content()) _seed_fire_then_pulse(base, fire) var base_loss := 1000.0 - base.enemies.data[1] # with catalyst x2 var hot := Sim.new(2, _content()) hot.mods.reaction_damage_mult = 2.0 _seed_fire_then_pulse(hot, fire) var hot_loss := 1000.0 - hot.enemies.data[1] assert_gt(base_loss, 0.0, "neighbor took the baseline burst") assert_almost_eq(hot_loss, base_loss * 2.0, 0.01, "catalyst doubles reaction-burst damage")
func _seed_fire_then_pulse(sim: Sim, fire: int) -> void: var t := sim.enemies.add(Vector2(0, 0), Vector2.ZERO, 14.0, 1000.0) # index 0 sim.enemies.add(Vector2(40, 0), Vector2.ZERO, 14.0, 1000.0) # index 1 neighbor sim.enemies.aura_element[t] = fire sim.enemies.stacks[t] = 2 sim.proj_damage = 1.0 sim.projectiles.add(Vector2(0, 0), Vector2.ZERO, 6.0, 1.0) sim._resolve_collisions()
# Lingering: aura_duration_mult makes the aura survive more decay.func test_lingering_extends_aura_duration() -> void: var sim := Sim.new(1, _content()) sim.mods.aura_duration_mult = 2.0 var fire := sim.content.element_index("fire") var e := sim.enemies.add(Vector2(0, 0), Vector2.ZERO, 14.0, 1000.0) Elemental.apply(sim.enemies, e, fire, sim.content, sim.mods) # fire aura_decay_s is 4 -> with x2 the remaining starts at 8 assert_almost_eq(sim.enemies.aura_remaining[e], 8.0, 0.0001, "aura starts at decay * duration mult")-
Step 3: Run — expect FAIL (
Elemental.applyarity / mods not read). -
Step 4: Update
Elemental.applyto readmods
Replace Elemental.apply in sim/elemental.gd with:
static func apply(pool: EnemyPool, i: int, element_idx: int, content: ContentDB, mods: ModState) -> Dictionary: var cur := pool.aura_element[i] var el := content.element_at(element_idx) var decay := float(el.get("aura_decay_s", 0.0)) * mods.aura_duration_mult var smax := int(el.get("stacks_max", 1)) if cur == -1: pool.aura_element[i] = element_idx pool.stacks[i] = mini(1 + mods.stack_bonus, smax) pool.aura_remaining[i] = decay return {} if cur == element_idx: pool.stacks[i] = mini(pool.stacks[i] + 1 + mods.stack_bonus, smax) pool.aura_remaining[i] = decay return {} # Different element: react against the current aura, then replace. var aura_id: String = content.element_at(cur).get("id", "") var applied_id: String = el.get("id", "") var rx := content.reaction(aura_id, applied_id) var ev := {"center": pool.pos[i], "magnitude": 0.0, "generic": true} if not rx.is_empty() and rx.get("effect", "") == "burst": var base := float(rx.get("base_magnitude", 0.0)) var scale := float(rx.get("per_stack_scale", 1.0)) ev["magnitude"] = base * pow(scale, float(pool.stacks[i])) ev["generic"] = false # consume + replace with the applied element pool.aura_element[i] = element_idx pool.stacks[i] = mini(1 + mods.stack_bonus, smax) pool.aura_remaining[i] = decay return ev(decay/mini(1+stack_bonus, smax) reduce to the old values when mods is the default: aura_duration_mult 1.0, stack_bonus 0.)
- Step 5: Update the callers +
_reaction_burst
In sim/sim.gd:
_resolve_collisions— passmods: changeElemental.apply(enemies, ei, pulse_element_idx, content)toElemental.apply(enemies, ei, pulse_element_idx, content, mods)._reaction_burst— scale the amount. Replace theamountline:
var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * mods.reaction_damage_multIn sim/weapon_nova.gd — pass mods: change Elemental.apply(sim.enemies, ei, sim.nova_element_idx, sim.content) to Elemental.apply(sim.enemies, ei, sim.nova_element_idx, sim.content, sim.mods).
- Step 6: Update
test_elemental.gddirect calls
test_elemental.gd calls Elemental.apply directly (8 sites). Add a no-op ModState arg to each. Add a _mods() helper func _mods() -> ModState: return ModState.new() and change each Elemental.apply(p, 0, X, c) to Elemental.apply(p, 0, X, c, _mods()). Then ADD two tests for the modded behavior:
func test_stack_bonus_adds_extra_stacks_capped() -> void: var c := _content(); var p := _enemy() var m := ModState.new(); m.stack_bonus = 1 Elemental.apply(p, 0, 0, c, m) # fresh fire: 1 + 1 = 2 stacks assert_eq(p.stacks[0], 2) for n in range(10): Elemental.apply(p, 0, 0, c, m) # reinforce by 2 each, capped at stacks_max (6) assert_eq(p.stacks[0], 6, "capped at stacks_max")
func test_aura_duration_mult_extends_remaining() -> void: var c := _content(); var p := _enemy() var m := ModState.new(); m.aura_duration_mult = 2.0 Elemental.apply(p, 0, 0, c, m) # fire aura_decay_s 4 -> 8 assert_almost_eq(p.aura_remaining[0], 8.0, 0.0001)(Keep the existing _content()/_enemy() helpers in that file.)
- Step 7: Run the new + updated tests, then the full suite
-gtest=res://tests/test_mods_in_sim.gd and -gtest=res://tests/test_elemental.gd → PASS. Then the full suite → exit 0. Determinism property test green.
- Step 8: Determinism trace-hash check (after)
Run godot --headless --path . -s /tmp/trace_probe.gd 2>&1 | grep TRACEHASH. It MUST equal the Step-1 baseline (2773002137) — proving the elemental-path change is byte-for-byte trace-invariant for an un-modded run.
- Step 9: Boot smoke
godot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR" → 0.
- Step 10: Commit
git add sim/elemental.gd sim/sim.gd sim/weapon_nova.gd tests/git commit -m "feat(sim): wire ModState into the elemental path (overcharge/catalyst/lingering live)"Task 7: Documentation + final verification
Section titled “Task 7: Documentation + final verification”Files: Modify CLAUDE.md.
- Step 1: Full suite + boot, record totals
Full suite (exit 0; note totals — +5 test files since Cycle-4 merge: test_mod_state, test_sim_mods, test_mods_in_sim are new) and boot smoke (0). Confirm test_determinism + the new tests executed.
- Step 2: Update
CLAUDE.md
Under the “Elemental engine (M2 cycle 4, DONE)” section (or a new “Transformative mods (M2 cycle 5)” subsection), add:
## Transformative mods (M2 cycle 5, DONE)Run-global build modifiers chosen at level-up, deepening the elemental engine. Data-driven.- **`sim/mod_state.gd`** (`ModState`): pure data holder on `Sim` for the run's modifiers — `stack_bonus`, `reaction_damage_mult`, `aura_duration_mult`. NO-OP DEFAULTS (0 / 1.0) keep an un-modded run byte-identical (determinism). It is plain data, passed to `Elemental.apply` so that unit stays `Sim`-free.- **`sim/sim_mods.gd`** (`SimMods`): sibling of `StatEffects` — maps a transformative mod `effect` to a `ModState` mutation + label. The level-up dispatch (`Upgrades.apply`) routes a stat effect to `StatEffects`/player and a sim-mod effect to `SimMods`/`ModState`; `ContentDB.upgrades()` offers a mod iff it is known to one vocabulary (so the bible's `pierce`/`split`, known to neither, stay excluded).- **Mods:** Overcharge (`stack_bonus +1`), Catalyst (`reaction_damage_mult x1.5`), Lingering (`aura_duration_mult x1.5`), all `kind: transformative` in `bible.json`, stackable (pick repeatedly). The elemental path reads `ModState`: extra stacks (capped at `stacks_max`), longer auras (`aura_decay_s x mult`), bigger bursts (`_reaction_burst x reaction_damage_mult`).- **Adding a new sim mod = data + one table row:** add the mod to `seed.js` (re-export), add its effect to `SimMods.TABLE` (and a `ModState` field if new). No new dispatch.- **DEFERRED:** weapon evolutions (need this mod system first); projectile-mechanic mods (`pierce`/`split`).Also add a done line at the top of the “Milestone 2 backlog” section:
- ✅ **Transformative elemental mods (cycle 5) — DONE.** Overcharge/Catalyst/Lingering, stackable, via ModState + SimMods; see "Transformative mods" above. Next: weapon evolutions, projectile-mechanic mods, more sim mods.- Step 3: Commit
git add CLAUDE.mdgit commit -m "docs: document transformative elemental mods (cycle 5) + mark done"- Step 4: Final whole-suite + boot confirmation
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexitgodot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR"Expected: suite exit 0; second prints 0.
Self-Review
Section titled “Self-Review”1. Spec coverage (against 2026-06-23-transformative-elemental-mods-design.md):
- §3.1
ModState→ Task 2. ✓ - §3.2
SimMods→ Task 3. ✓ - §3.3
ContentDB.upgrades()offer transformative → Task 4. ✓ - §3.4
Upgradesdispatch → Task 5. ✓ - §3.5
Sim(mods,apply_upgrade,_reaction_burst) → Tasks 5 (mods/apply) + 6 (_reaction_burst). ✓ - §3.6
Elemental.applygainsmods+ callers → Task 6. ✓ - §3.7 data edits → Task 1. ✓
- §3.8 main/UI no change → confirmed (no task needed;
choice_display/apply_upgradesignatures stay external-compatible). ✓ - §4 tests (mod_state, sim_mods, mods_in_sim, elemental/upgrades/content_loader updates, determinism trace-hash) → Tasks 2–6. ✓
- §5 success criteria → mixed pool (T4/T5), three mods functional+stackable (T6, T3 stacking test), data-driven (T1), byte-identical un-modded run (T6 Step 8),
/simpurity + Elemental Sim-free (T6), pierce/split excluded (T4). ✓ - §6 out-of-scope respected (no evolutions, no pierce/split behavior, no radius mod). ✓
2. Placeholder scan: No TBD/TODO/“handle edge cases”/“similar to”. Full code in every code step. ✓
3. Type consistency:
ModStatefieldsstack_bonus/reaction_damage_mult/aura_duration_mult— identical in T2, T3 (TABLE), T6 (Elemental + _reaction_burst). ✓SimMods.apply(effect, magnitude, mods)/is_known/describe— consistent T3, T5. ✓Upgrades.apply(id, content, player, mods)— consistent in T5 (def + test) andSim.apply_upgrade. ✓Elemental.apply(pool, i, element_idx, content, mods)— consistent in T6 def,_resolve_collisions,nova.update,test_elemental.gd,test_mods_in_sim.gd. ✓ContentDB.upgrades()returns mod dicts;upgrade(id)unchanged; effect names don’t collide betweenStatEffectsandSimMods. ✓- Offerable count 5 → 8 updated in
test_content_loader.gd(T4). ✓
No issues found.
Execution Handoff
Section titled “Execution Handoff”Plan complete and saved to docs/superpowers/plans/2026-06-23-transformative-elemental-mods.md.