Skip to content

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.

  • /sim purity: every sim/ file is pure logic (no Node/render/Input/Engine/Time/File/JSON APIs). New: mod_state.gd, sim_mods.gd. Elemental and SimMods take ModState (plain data) — no Sim dependency in Elemental.
  • Determinism: no RNG added. ModState defaults are no-ops (stack_bonus 0, all mults 1.0), so an un-modded run is byte-identical. tests/test_determinism.gd property 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’s pierce/split (known to neither) stay excluded.
  • GUT push_error gotcha: an un-asserted push_error fails a test. SimMods.apply on an unknown effect is a silent no-op (upstream-guarded); failure-path tests assert via return values, not by triggering push_error.
  • Data is genuine exporter output: the three new mods are added to seed.js and re-exported via node 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_name test seems dropped, run godot --headless --path . --import then 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 any StatEffects effect name.
  • 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" (expect 0)

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:

Terminal window
python3 -c "
import json
m={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
Terminal window
git add tools/design-bible/src/seed.js data/bible.json
git 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 (ModState not declared).

  • Step 3: Implement

Create sim/mod_state.gd:

class_name ModState
extends 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 application
var reaction_damage_mult: float = 1.0
var aura_duration_mult: float = 1.0
  • Step 4: Run — expect PASS.

  • Step 5: Commit

Terminal window
git add sim/mod_state.gd tests/test_mod_state.gd
git 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

Terminal window
git add sim/sim_mods.gd tests/test_sim_mods.gd
git 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

Terminal window
git add sim/content_db.gd tests/test_content_loader.gd tests/test_content_db.gd
git commit -m "feat(sim): ContentDB.upgrades() offers known transformative mods too"

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 Upgrades dispatch

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, after content = 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
Terminal window
git add sim/upgrades.gd sim/sim.gd tests/test_upgrades.gd
git 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_burst scales by mods.reaction_damage_mult.

  • Step 1: Capture the determinism baseline (before any change)

Create /tmp/trace_probe.gd:

extends SceneTree
func _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.apply arity / mods not read).

  • Step 4: Update Elemental.apply to read mods

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 — pass mods: change Elemental.apply(enemies, ei, pulse_element_idx, content) to Elemental.apply(enemies, ei, pulse_element_idx, content, mods).
  • _reaction_burst — scale the amount. Replace the amount line:
var amount := (GENERIC_REACTION_MAGNITUDE if generic else magnitude) * mods.reaction_damage_mult

In 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.gd direct 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
Terminal window
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
Terminal window
git add CLAUDE.md
git commit -m "docs: document transformative elemental mods (cycle 5) + mark done"
  • Step 4: Final whole-suite + boot confirmation
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit
godot --headless --path . --quit-after 240 2>&1 | grep -ci "SCRIPT ERROR"

Expected: suite exit 0; second prints 0.


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 Upgrades dispatch → Task 5. ✓
  • §3.5 Sim (mods, apply_upgrade, _reaction_burst) → Tasks 5 (mods/apply) + 6 (_reaction_burst). ✓
  • §3.6 Elemental.apply gains mods + callers → Task 6. ✓
  • §3.7 data edits → Task 1. ✓
  • §3.8 main/UI no change → confirmed (no task needed; choice_display/apply_upgrade signatures 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), /sim purity + 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:

  • ModState fields stack_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) and Sim.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 between StatEffects and SimMods. ✓
  • Offerable count 5 → 8 updated in test_content_loader.gd (T4). ✓

No issues found.

Plan complete and saved to docs/superpowers/plans/2026-06-23-transformative-elemental-mods.md.