Element Crystals Ruleset Implementation Plan
Element Crystals Ruleset Implementation Plan
Section titled “Element Crystals Ruleset 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. Also follow the project’sbh-dev-chunkritual (import → boot-check → full suite → count guard → determinism) on every sim-touching task.
Goal: Add an alternate “Crystals” element ruleset (elements as a build economy that auto-upgrades weapons) alongside the existing reactions system, behind a flag, both playable and telemetry-tagged for A/B comparison.
Architecture: A Sim.ruleset flag (null-object pattern, mirrors Sim.story) defaulting to reactions so the determinism baseline is byte-identical. In Crystals mode the reaction path short-circuits; a per-run CrystalState wallet fills from randomised level-up grants; a WeaponThresholds table auto-fires each weapon’s existing apply_mod()/evolve() when crystal counts cross thresholds (non-consuming → shared across weapons → synergy).
Tech Stack: Godot 4.6.3, typed GDScript, GUT 9.6 tests, CF Worker + D1 for telemetry.
Design spec: docs/superpowers/specs/2026-06-25-element-crystals-ruleset-design.md.
Global Constraints
Section titled “Global Constraints”/simis pure: every fileextends RefCounted; NO Node / Engine / Input / Time / OS / File / JSON APIs. Loaders/render/UI live outside/sim.- Determinism is the keystone:
Sim.rulesetMUST default toRULESET_REACTIONSand be flipped ONLY bymain(render-side) viaenable_crystals()— never inside the sim or any test that asserts the baseline. The survival baseline (the value currently pinned intests/test_determinism_checksum.gd— read it, don’t hardcode a remembered number; the other agent re-pins it often) must stay byte-identical. Re-run the determinism test after EVERY sim task. - Crystal randomness draws from
Sim.upgrade_rng, never the spawnrng(drawing fromrngdesyncs the spawn stream). - GUT gotchas: methods are
assert_lte/assert_gte(NOTassert_le/assert_ge— a typo silently drops the whole file). An un-consumedpush_errorFAILS the test — test the non-erroring seam or consume withassert_push_error("substr"). - After adding any
class_namein a new file: rungodot --headless --path . --importbefore tests (stale class cache → silently dropped tests + boot parse errors). - Trust the COUNT, not just “passed”:
bash scripts/check-test-count.shmust report N/N matchingtests/test_*.gd. - Timing: build on a CLEAN tree AFTER the other agent’s cycle-21
sim.gdwork has settled — this plan editssim.gdheavily and would otherwise conflict.git statusclean before starting. - Reuse, don’t reinvent: weapon upgrades go through each weapon’s existing
apply_mod(kind, mag)/evolve(); do not add a parallel mechanism.
Run/test quick reference
Section titled “Run/test quick reference”- 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 - Count guard:
bash scripts/check-test-count.sh - Import:
godot --headless --path . --import - Boot check:
godot --headless --path . --quit-after 90 2>&1 | grep "SCRIPT ERROR"(empty = good) - Tests build a sim via
Sim.new(seed, SimContentFixture.db()); add an enemy withsim.enemies.add(pos, vel, radius, hp, armor, speed, contact, xp, tid, base_el, beh, flank).
File Structure
Section titled “File Structure”- Create
sim/crystal_state.gd(CrystalState) — per-run crystal wallet (pure). - Create
sim/weapon_thresholds.gd(WeaponThresholds) — the threshold table + lookup (pure, mirrorsSimMods.TABLE/StatEffects.TABLE; chosen overbible.jsonfor testability + no ContentLoader churn + the bible.json-drift caveat). - Modify
sim/sim.gd—rulesetflag,enable_crystals(), reaction guard,crystals,_eval_thresholds(),roll_upgrade_choices/apply_upgrade/grant_weaponbranches. - Modify
net/gameplay_telemetry.gd+telemetry/src/worker.js+telemetry/schema.sql—rulesettag. - Modify
main.gd+ui/start_menu.gd— Crystals launch entry →enable_crystals(). - Create
tests/test_crystals_ruleset.gd,tests/test_crystal_state.gd,tests/test_weapon_thresholds.gd.
Task 1: Ruleset seam — flag + enable_crystals() + reaction guard
Section titled “Task 1: Ruleset seam — flag + enable_crystals() + reaction guard”Files:
- Modify:
sim/sim.gd(add ruleset consts/var near the other state ~line 175;enable_crystals(); guard in_apply_element) - Test:
tests/test_crystals_ruleset.gd
Interfaces:
-
Produces:
Sim.RULESET_REACTIONS(=0),Sim.RULESET_CRYSTALS(=1),Sim.ruleset: int(default RULESET_REACTIONS),Sim.enable_crystals() -> void. -
Step 1: Write the failing test —
tests/test_crystals_ruleset.gd
extends GutTest
# Crystals ruleset seam: reactions OFF, enemy elements cosmetic only.func _content() -> ContentDB: return SimContentFixture.db()
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)")
func test_enable_crystals_flips_flag() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() assert_eq(sim.ruleset, Sim.RULESET_CRYSTALS)
func test_crystals_mode_applies_aura_without_reacting() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() # enemy with a DIFFERENT innate element than the applied one -> would react in reactions mode var e := sim.enemies.add(Vector2(50, 0), Vector2.ZERO, 14.0, 100.0, 0.0, 70.0, 12.0, 1.0, EnemyPool.TYPE_SWARMER, -1, EnemyPool.BEHAVIOR_WALK) var foreign := 0 # element index 0 (any valid element) sim.zones.clear() sim._apply_element(e, foreign) assert_eq(sim.zones.size(), 0, "no reaction terrain zone is dropped in crystals mode") assert_eq(sim.enemies.aura_element[e], foreign, "aura tint IS set (cosmetic) in crystals mode") assert_eq(sim.enemies.primed[e], 0, "no priming in crystals mode")- Step 2: Run it to verify it fails
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_crystals_ruleset.gd -gexit
Expected: FAIL — Sim.RULESET_REACTIONS / enable_crystals not defined.
- Step 3: Add the flag + method to
sim/sim.gd(near the other run-state vars, e.g. just aftervar game_over)
const RULESET_REACTIONS := 0const RULESET_CRYSTALS := 1var ruleset: int = RULESET_REACTIONS # set ONLY by main.enable_crystals(); never in /sim or determinism tests
func enable_crystals() -> void: ruleset = RULESET_CRYSTALS- Step 4: Guard the reaction path in
_apply_element— keep the cosmetic aura, skip the reaction. Findfunc _apply_element(ei: int, element_idx: int)and wrap the reaction portion:
func _apply_element(ei: int, element_idx: int) -> void: if element_idx < 0 or enemies.data[ei] <= 0.0: return if ruleset == RULESET_CRYSTALS: # Pure economy: tint only, NO reaction / burst / zone / prime / status. enemies.aura_element[ei] = element_idx return # ... existing reactions-mode body unchanged (Elemental.apply -> _on_reaction -> _pop_primed) ...- Step 5: Run the test to verify it passes
Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_crystals_ruleset.gd -gexit
Expected: PASS (3/3).
-
Step 6: bh-dev-chunk gates —
--import(no new class_name yet, but cheap), boot-check empty, full suite green, count guard N/N, determinism baseline unchanged (default ruleset path untouched). -
Step 7: Commit
git add sim/sim.gd tests/test_crystals_ruleset.gdgit commit -m "feat(crystals): ruleset flag + reaction-path guard (seam)"Task 2: CrystalState — per-run crystal wallet
Section titled “Task 2: CrystalState — per-run crystal wallet”Files:
- Create:
sim/crystal_state.gd - Modify:
sim/sim.gd(addvar crystals := CrystalState.new()with the other state) - Test:
tests/test_crystal_state.gd
Interfaces:
-
Produces:
CrystalState.add(element_id: String, n: int),.count(element_id: String) -> int,.total() -> int,.to_dict() -> Dictionary,.from_dict(d: Dictionary).Sim.crystals: CrystalState. -
Step 1: Write the failing test —
tests/test_crystal_state.gd
extends GutTest
func test_add_accumulates_and_counts() -> void: var c := CrystalState.new() c.add("fire", 4) c.add("fire", 2) c.add("cold", 3) assert_eq(c.count("fire"), 6) assert_eq(c.count("cold"), 3) assert_eq(c.count("void"), 0, "unseen element is zero") assert_eq(c.total(), 9)
func test_dict_round_trip() -> void: var c := CrystalState.new() c.add("light", 5) var d := c.to_dict() var c2 := CrystalState.new() c2.from_dict(d) assert_eq(c2.count("light"), 5)-
Step 2: Run it to verify it fails Run:
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gtest=res://tests/test_crystal_state.gd -gexitExpected: FAIL —CrystalStatenot defined. -
Step 3: Create
sim/crystal_state.gd
class_name CrystalStateextends RefCounted
# Per-run element crystal wallet for the Crystals ruleset. Pure data; lost at game over.var counts: Dictionary = {} # element_id (String) -> int
func add(element_id: String, n: int) -> void: counts[element_id] = int(counts.get(element_id, 0)) + n
func count(element_id: String) -> int: return int(counts.get(element_id, 0))
func total() -> int: var t := 0 for k in counts: t += int(counts[k]) return t
func to_dict() -> Dictionary: return counts.duplicate()
func from_dict(d: Dictionary) -> void: counts = d.duplicate()-
Step 4: Add to
sim/sim.gd(with the other run-state):var crystals: CrystalState = CrystalState.new() -
Step 5: Run tests to verify they pass (same single-test command) — Expected PASS (2/2).
-
Step 6: gates —
--import(new class_nameCrystalState), boot-check, full suite, count guard, determinism unchanged. -
Step 7: Commit
git add sim/crystal_state.gd sim/sim.gd tests/test_crystal_state.gdgit commit -m "feat(crystals): CrystalState per-run wallet"Task 3: WeaponThresholds table + Sim._eval_thresholds()
Section titled “Task 3: WeaponThresholds table + Sim._eval_thresholds()”Files:
- Create:
sim/weapon_thresholds.gd - Modify:
sim/sim.gd(_eval_thresholds()+ a per-weapon applied-set_thresholds_done) - Test:
tests/test_weapon_thresholds.gd
Interfaces:
-
Produces:
WeaponThresholds.TABLE: Dictionary(weapon_id → Array of{element:String, amount:int, kind:String};kindis an existingapply_modkind or"evolve"),WeaponThresholds.rules_for(weapon_id) -> Array.Sim._eval_thresholds() -> void. -
Consumes: each weapon’s existing
apply_mod(kind: String, mag: float)andevolve();Sim.active_weapon_ids,Sim._weapon_by_id,Sim.crystals. -
Step 1: Write the failing test —
tests/test_weapon_thresholds.gd
extends GutTest
func _content() -> ContentDB: return SimContentFixture.db()
# Crossing a threshold fires the weapon's apply_mod exactly once (idempotent, non-consuming).func test_threshold_upgrades_weapon_once() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() # orbit is owned for the test; record its shard count sim.grant_weapon("orbit") var before: int = sim.orbit.shards # give enough COLD to cross orbit's first shard threshold (see WeaponThresholds.TABLE) sim.crystals.add("cold", 99) sim._eval_thresholds() var after: int = sim.orbit.shards assert_gt(after, before, "orbit gained shards from the COLD threshold") # non-consuming: crystals are not spent assert_eq(sim.crystals.count("cold"), 99, "thresholds do NOT consume crystals") # idempotent: a second eval with the same crystals does nothing further sim._eval_thresholds() assert_eq(sim.orbit.shards, after, "threshold applies once, not every eval")
func test_table_only_references_owned_weapons_safely() -> void: # eval with NO owned weapons must not error even if crystals are high var sim := Sim.new(1, _content()) sim.enable_crystals() sim.crystals.add("fire", 99) sim._eval_thresholds() # no crash, no-op assert_true(true)-
Step 2: Run it to verify it fails — Expected FAIL (
WeaponThresholds/_eval_thresholdsundefined). (Ifsim.orbit.shardsis the wrong accessor, fix the test to the real var — verify insim/weapon_orbit.gdat build time.) -
Step 3: Create
sim/weapon_thresholds.gd(starter table — TUNE later; uses real weapon ids + realapply_modkinds from cycle 17, and the 6 core elements)
class_name WeaponThresholdsextends RefCounted
# weapon_id -> [{element, amount, kind}] ; kind is an existing apply_mod kind or "evolve".# Non-consuming, shared across weapons. STARTER VALUES — balance via telemetry.const TABLE := { "blade": [{"element":"blood","amount":5,"kind":"arc"}, {"element":"fire","amount":10,"kind":"reach"}, {"element":"void","amount":18,"kind":"evolve"}], "pulse": [{"element":"lightning","amount":5,"kind":"chain"}, {"element":"void","amount":10,"kind":"range"}, {"element":"lightning","amount":18,"kind":"evolve"}], "nova": [{"element":"fire","amount":5,"kind":"radius"}, {"element":"fire","amount":12,"kind":"rate"}, {"element":"light","amount":18,"kind":"evolve"}], "orbit": [{"element":"cold","amount":5,"kind":"shards"}, {"element":"cold","amount":12,"kind":"spin"}, {"element":"lightning","amount":9,"kind":"reach"}, {"element":"void","amount":18,"kind":"evolve"}], "beam": [{"element":"light","amount":5,"kind":"width"}, {"element":"void","amount":10,"kind":"reach"}, {"element":"light","amount":18,"kind":"evolve"}], "turret": [{"element":"blood","amount":5,"kind":"count"}, {"element":"fire","amount":12,"kind":"rate"}, {"element":"blood","amount":18,"kind":"evolve"}], "scatter": [{"element":"blood","amount":5,"kind":"pellets"},{"element":"blood","amount":12,"kind":"spread"}, {"element":"fire","amount":18,"kind":"evolve"}],}
static func rules_for(weapon_id: String) -> Array: return TABLE.get(weapon_id, [])- Step 4: Add
_eval_thresholds()tosim/sim.gd(+ avar _thresholds_done: Dictionary = {}with the run state — keys"<weapon>:<ruleIndex>")
func _eval_thresholds() -> void: if ruleset != RULESET_CRYSTALS: return for wid in active_weapon_ids: var rules: Array = WeaponThresholds.rules_for(wid) var w = _weapon_by_id.get(wid, null) if w == null: continue for i in range(rules.size()): var key := "%s:%d" % [wid, i] if _thresholds_done.has(key): continue var rule: Dictionary = rules[i] if crystals.count(rule["element"]) >= int(rule["amount"]): _thresholds_done[key] = true if rule["kind"] == "evolve": if w.has_method("evolve") and not w.evolved: w.evolve() else: w.apply_mod(rule["kind"], _weapon_mod_mag(wid, rule["kind"]))(Use the existing _weapon_mod_mag(wid, kind) helper if present; otherwise pass the same magnitude the wm: path used. Verify the helper name at build time.)
-
Step 5: Run tests to verify they pass — Expected PASS (2/2). Fix accessor names (
sim.orbit.shards, weaponevolved) against the real source if needed. -
Step 6: gates —
--import(newWeaponThresholds), boot, suite, count, determinism unchanged (eval is guarded byruleset != RULESET_CRYSTALS). -
Step 7: Commit
git add sim/weapon_thresholds.gd sim/sim.gd tests/test_weapon_thresholds.gdgit commit -m "feat(crystals): weapon threshold table + _eval_thresholds (reuses apply_mod/evolve)"Task 4: Crystal grants at level-up (the economy) + RNG
Section titled “Task 4: Crystal grants at level-up (the economy) + RNG”Files:
- Modify:
sim/sim.gd(roll_upgrade_choicescrystals branch;apply_upgradecrystals:handler) - Test: append to
tests/test_crystals_ruleset.gd
Interfaces:
-
Produces: upgrade id form
"crystals:<el>=<n>,<el>=<n>"(e.g."crystals:fire=4,light=2");apply_upgradeparses it →crystals.add(...)then_eval_thresholds(). -
Consumes:
Sim.upgrade_rng(the upgrade RNG stream, NOT spawnrng),CrystalState,_eval_thresholds. -
Step 1: Write the failing test (append)
const CRYSTAL_ELEMENTS := ["fire", "cold", "lightning", "void", "blood", "light"]
func test_apply_crystal_grant_adds_and_evals() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() sim.grant_weapon("orbit") sim.apply_upgrade("crystals:cold=99") assert_eq(sim.crystals.count("cold"), 99, "crystal grant added to the wallet") assert_gt(sim.orbit.shards, 0, "applying crystals re-evaluated thresholds")
func test_crystal_offers_are_deterministic() -> void: var a := Sim.new(7, _content()); a.enable_crystals() var b := Sim.new(7, _content()); b.enable_crystals() # same seed -> identical upgrade_rng draws -> identical offers for i in range(5): assert_eq(a.roll_upgrade_choices(3), b.roll_upgrade_choices(3), "crystals offers are seed-deterministic")
func test_crystals_mode_excludes_weapon_mods() -> void: var sim := Sim.new(3, _content()); sim.enable_crystals() sim.grant_weapon("orbit") for i in range(8): for id in sim.roll_upgrade_choices(3): assert_false(id.begins_with("wm:"), "manual weapon-mods are not offered in crystals mode") assert_false(id.begins_with("evolve:"), "manual evolves are not offered (thresholds handle them)")-
Step 2: Run to verify it fails — Expected FAIL (
crystals:unhandled;wm:still offered). -
Step 3: Add
apply_upgradehandler (insim/sim.gd, in theapply_upgrade(id)dispatch)
if id.begins_with("crystals:"): var spec := id.substr("crystals:".length()) for pair in spec.split(",", false): var kv := pair.split("=") if kv.size() == 2: crystals.add(kv[0], int(kv[1])) _eval_thresholds() return- Step 4: Branch
roll_upgrade_choices— in crystals mode, excludewm:/evolve:from the pool and inject a crystal grant with ~1/3 weight usingupgrade_rng. Sketch (adapt to the real function body):
if ruleset == RULESET_CRYSTALS: var out: Array[String] = [] # ~1/3 chance the first slot is a crystal grant if upgrade_rng.randf() < 0.34: out.append(_roll_crystal_grant()) # fill remaining slots from weapon-grant + stat-mod + transformative pools (NO wm:/evolve:) # ... reuse existing pool-building but filter out ids starting with "wm:" / "evolve:" ... return out
func _roll_crystal_grant() -> String: const ELS := ["fire","cold","lightning","void","blood","light"] var n_types := upgrade_rng.randi_range(1, 3) var parts: Array[String] = [] for _i in range(n_types): var el: String = ELS[upgrade_rng.randi_range(0, ELS.size() - 1)] parts.append("%s=%d" % [el, upgrade_rng.randi_range(2, 5)]) return "crystals:" + ",".join(parts)-
Step 5: Run tests to verify they pass — Expected PASS. (If offer determinism fails, confirm no
Math/Time/randi()snuck in — onlyupgrade_rng.) -
Step 6: gates — boot, suite, count, determinism unchanged (crystals branch only taken when
ruleset == RULESET_CRYSTALS, which the baseline never sets). -
Step 7: Commit
git add sim/sim.gd tests/test_crystals_ruleset.gdgit commit -m "feat(crystals): randomised crystal grants at level-up; exclude wm:/evolve:"Task 5: Weapon-grant re-evaluates thresholds
Section titled “Task 5: Weapon-grant re-evaluates thresholds”Files:
- Modify:
sim/sim.gd(grant_weaponcalls_eval_thresholds()at the end) - Test: append to
tests/test_weapon_thresholds.gd
Interfaces: Consumes grant_weapon, _eval_thresholds.
- Step 1: Write the failing test (append)
func test_late_weapon_inherits_existing_crystals() -> void: var sim := Sim.new(1, _content()) sim.enable_crystals() sim.crystals.add("cold", 99) # pile exists BEFORE the weapon is owned sim._eval_thresholds() # nothing owns it yet -> no effect sim.grant_weapon("orbit") # granting must back-apply met thresholds assert_gt(sim.orbit.shards, 0, "a late weapon immediately benefits from the existing crystal pile")-
Step 2: Run to verify it fails — Expected FAIL (orbit not upgraded on grant).
-
Step 3: Add the call — at the end of
func grant_weapon(...)insim/sim.gd:
if ruleset == RULESET_CRYSTALS: _eval_thresholds()-
Step 4: Run to verify it passes — Expected PASS.
-
Step 5: gates — suite, count, determinism unchanged (guarded).
-
Step 6: Commit
git add sim/sim.gd tests/test_weapon_thresholds.gdgit commit -m "feat(crystals): grant_weapon back-applies met thresholds (synergy)"Task 6: Launch-menu Crystals entry + main wiring
Section titled “Task 6: Launch-menu Crystals entry + main wiring”Files:
- Modify:
ui/start_menu.gd(add a “Crystals” mode option),main.gd(_mode == "crystals"→sim.enable_crystals()in_new_run, afterSim.new) - Test: render/UI — verified by boot + play, not GUT (no determinism impact).
Interfaces: Consumes Sim.enable_crystals().
-
Step 1: Add the menu option — in
ui/start_menu.gd, add a third entry “CRYSTALS” alongside Survival/Story emittingmode_chosen.emit("crystals")(follow the existing option-building pattern; keep the tvOS-safe joypad nav). -
Step 2: Wire
main._new_run— aftersim = Sim.new(run_seed, content)and before render setup:
if _mode == "crystals": sim.enable_crystals()(Note: _is_story() stays false for “crystals”, so it runs as a survival-shaped run with the crystals element layer.)
-
Step 3: Boot-check —
godot --headless --path . --quit-after 120 2>&1 | grep "SCRIPT ERROR"empty. Full suite + count green (no test changes, must still match). Determinism unchanged. -
Step 4: Manual play check — open in editor, pick CRYSTALS, confirm: no reaction bursts/zones fire; the Element Crystals upgrade appears at level-ups; collecting crystals visibly upgrades weapons (e.g. orbit gains shards).
-
Step 5: Commit
git add ui/start_menu.gd main.gdgit commit -m "feat(crystals): launch-menu Crystals mode -> enable_crystals()"Task 7: Telemetry ruleset tag (A/B comparison)
Section titled “Task 7: Telemetry ruleset tag (A/B comparison)”Files:
- Modify:
net/gameplay_telemetry.gd(sendruleset),telemetry/schema.sql(+runs.ruleset),telemetry/src/worker.js(ingest + dashboard group-by) - Verify: live round-trip (no GUT test — render/worker side)
Interfaces: report_run body gains "ruleset": "reactions"|"crystals".
- Step 1: Send the tag — in
net/gameplay_telemetry.gd report_run, add to the body:
"ruleset": "crystals" if sim.ruleset == Sim.RULESET_CRYSTALS else "reactions",-
Step 2: Schema — add to
telemetry/schema.sqlrunstableruleset TEXT,and the one-time live migration:ALTER TABLE runs ADD COLUMN ruleset TEXT;(run once via wranglerd1 execute --remote --command; ALTER is not idempotent — expected to error if re-run). -
Step 3: Worker — in
telemetry/src/worker.js/gameplayINSERT, add therulesetcolumn +str(s.ruleset, 12)bind; add arunRulesetAggquery (GROUP BY ruleset, mode) and render a “By ruleset (A/B)” dashboard table (run length, level, kills, build variety per ruleset). HTML-esc()every value. -
Step 4: Deploy + round-trip verify —
cd telemetry && source ~/.secrets && CLOUDFLARE_API_TOKEN="$CF_LUMARA_DEPLOY_TOKEN" CLOUDFLARE_ACCOUNT_ID="$CF_ACCOUNT_ID" npx --yes wrangler@latest deploy; POST abuild=999 ruleset=crystalstest row, confirm it stores + appears in the A/B table, thenDELETE FROM runs WHERE build=999. -
Step 5: Commit
git add net/gameplay_telemetry.gd telemetry/schema.sql telemetry/src/worker.jsgit commit -m "feat(telemetry): tag runs by ruleset for the crystals-vs-reactions A/B"Task 8: Crystals-mode determinism pin + first balance pass (tuning)
Section titled “Task 8: Crystals-mode determinism pin + first balance pass (tuning)”Files:
-
Create:
tests/test_determinism_crystals.gd(pin a crystals-mode trace so the new path is regression-locked) -
Modify:
sim/weapon_thresholds.gd+ the crystal-grant magnitudes (tune from first telemetry) -
Step 1: Pin a crystals trace — mirror
tests/test_determinism_checksum.gdbut callsim.enable_crystals()after construction, drive 600 ticks with the same scripted input, and (first run) printstate_checksum(); then hardcode the printed value as the assertion. This locks the crystals path against accidental change WITHOUT touching the reactions baseline.
extends GutTestfunc 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() for i in range(600): var dir := Vector2(cos(float(i)*0.05), sin(float(i)*0.03)).normalized() var inp := InputState.new(dir) a.tick(inp); b.tick(inp) assert_eq(a.state_checksum(), b.state_checksum(), "crystals mode is deterministic (a==b)") # assert_eq(a.state_checksum(), <PASTE printed value>, "crystals baseline")-
Step 2: gates + Commit (commit the pinned value once stable).
-
Step 3: Balance pass (after first ATV/web telemetry in crystals mode) — adjust
WeaponThresholds.TABLEamounts, crystal bundle sizes, and (if needed) a crystals-mode enemy-HP scalar so run length/feel match reactions mode. Iterate against the dashboard “By ruleset” table. Commit per tune.
Self-Review
Section titled “Self-Review”Spec coverage: ruleset seam (T1), CrystalState (T2), thresholds + eval reusing apply_mod/evolve (T3), randomised level-up grants + wm:/evolve: exclusion (T4), late-weapon synergy (T5), launch menu (T6), telemetry A/B (T7), determinism pin + tuning (T8). Pure-economy/reactions-off = T1. ~6 core elements = T3/T4 constants. Non-consuming/shared = T3 test asserts it. All spec sections map to a task.
Placeholder scan: threshold magnitudes are explicit starter values (flagged TUNE, not TBD); accessor names (sim.orbit.shards, _weapon_mod_mag, weapon evolved) are called out to verify against source at build time (the other agent is actively changing sim.gd), which is honest given the concurrency rather than a placeholder.
Type consistency: id form crystals:<el>=<n> consistent across T4 producer and apply_upgrade handler; RULESET_REACTIONS/RULESET_CRYSTALS/ruleset/enable_crystals consistent T1→T6; _eval_thresholds consistent T3→T5; WeaponThresholds.rules_for/TABLE consistent T3. Determinism guard (ruleset default + main-only flip) repeated in every sim task.