Skip to content

Remote Console v2 — implementation plan (No XP, reset all, crystal rollback, neon panel)

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 dev powers to the game (No XP, Reset everything, crystal removal that rolls back rungs) and rebuild the Remote Playtest Console panel as a full-width neon “mission control” with tooltips. The new caps and status fields drive it.

Architecture:

  • Game side. The changes are plain-data dev seams on Sim:
    • a dev_no_xp gate in _bank_xp;
    • a per-arsenal mod_log plus PilotArsenal.rebuild_weapon() for rollback;
    • CrystalState.remove. main.apply_dev_command gets four new arms, and build_caps / build_status publish the data the panel needs.
  • Panel side. The 879-line single template literal control/src/panel.html.js is split into string modules under control/src/panel/. That split must reproduce today’s HTML byte for byte. The neon layout, tooltips and Crystals card are then built on top of the split.

Tech Stack: Godot 4.7 typed GDScript (GUT tests), Cloudflare Worker (plain ES modules), Node 22 node:test, headless Chrome / Playwright MCP for the real-browser check.

Spec: docs/superpowers/specs/2026-10-01-remote-console-v2-design.md. Approved look: .superpowers/brainstorm/25548-1790883350/content/neon-look.html. The T7 CSS is derived from that file.

Defaults and determinism

  • Every game-side change is a dev seam that defaults OFF and draws no RNG.
  • The seed-1234 baselines in tests/test_determinism_survival.gd, tests/test_determinism.gd and tests/test_determinism_coop.gd must not move.
  • Never copy hash values into prose.

Sim rules

  • /sim stays pure: extends RefCounted, with no Node, Engine, Time or Input APIs.
  • XP is paid on biomass. The No XP test must go through the real kill path: enemies.add with biomass set, then resolve_collisions, then _sweep_dead. It must also cover gem pickup through _collect_gems. Never hand-set pending_levelups.

Panel string modules

  • No backticks inside the template string, other than the two that delimit it.
  • No ${.
  • No backslashes. A template literal would silently cook them.
  • Server data is written with textContent or setAttribute only, never innerHTML. The only innerHTML writes allowed are ='' clears and the constant TIP_SVGS panel markup.

Panel behaviour

  • Commands are written in the literal form {kind:'x',...}. A pending parity test greps for that form; see the Cross-branch hazard section.
  • Colours are exactly the ui/tokens.gd values from the spec: accent #33e6ff, text #d9f7ff, dim #99b8cc, danger #ff525c, warn #ff9e33, gold #ffd64d, HP #4dff80, XP #b3ff4d, success #66ff99.
  • Fonts are Orbitron and JetBrains Mono, loaded from Google Fonts.
  • Keep the flag-write hold-off (_flagHoldUntil, 1800 ms) for every new toggle.

Debug Settings parity. Every new toggle also gets a Debug Settings row. See memory bullet-heaven-debug-settings-console-parity.

Copy. Player-visible copy says “Decoy”, never “Sentinel”. These tooltips never mention drones, but the rule still applies.

Execution context (every implementer reads this)

Section titled “Execution context (every implementer reads this)”

Worktree and branch

  • Work in your own worktree: git worktree add .claude/worktrees/console-v2 -b console-v2 origin/main, from the main checkout.
  • Do all work inside .claude/worktrees/console-v2. Never branch inside the main checkout.

Running tests

  • The full gate is scripts/gate.sh.
  • Get the GUT count from scripts/check-test-count.sh. Never pull a raw GUT run into context; the full log is at $TMPDIR/bh-gut-last.log.
  • For a single GUT file, run this from the worktree root:
    Terminal window
    godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_x.gd -gexit > "$TMPDIR/bh-one.log" 2>&1; sed $'s/\x1b\\[[0-9;]*m//g' "$TMPDIR/bh-one.log" | grep -E '^Scripts|^Tests|^Passing Tests|\[Failed\]' | head -20
  • After adding a new class_name file, run godot --headless --path . --import first. This plan adds no new class_name files, but check anyway if you add one.
  • Run the Node tests from the worktree root with node --test control/test/*.test.mjs. This is the same form gate.sh uses; the directory form fails on Node 22.

Hygiene

  • After any windowed godot run, run git checkout -- project.godot audio/bus_layout.tres.
  • Commit messages: write the message to a file and use git commit -F <file>. End it with:
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01DfBoaoiWooNsPhhGXL6dE8
  • Merge to main only after a reviewer gate. Then run git push origin main immediately (CLAUDE.md rule).

Cross-branch hazard (check before T1 and again before T6)

Section titled “Cross-branch hazard (check before T1 and again before T6)”

Commit 08c68f07 adds tests/test_debug_console_parity.gd. It is on the unmerged branches perf-measure-fx and fx-v2-promote-railgun, and was not on origin/main when this plan was written. It changes three things:

  • apply_dev_command returns bool: true from every arm, with a _: arm returning false.
  • Every Debug Settings row gains a "console": {"kind": ...} descriptor.
  • CONSOLE_SRC := "res://control/src/panel.html.js" is regex-checked for kind\s*:\s*['"]<kind>['"].

Check with git ls-tree origin/main tests/test_debug_console_parity.gd. If it prints a line, apply these deltas:

  1. T1: the No XP row also gets "console": {"kind": "no_xp"}, and the new no_xp arm sits inside the bool-returning match.
  2. T2 and T4: the reset_all, remove_crystals and clear_crystals arms live in the bool-returning match. They need no extra return, because the match falls through to the function’s return true.
  3. T6: after the split, panel.html.js no longer holds the commands. Change the parity test’s console-source read to concatenate the modules:
    const CONSOLE_SRCS: Array[String] = [
    "res://control/src/panel/markup.js", "res://control/src/panel/client.js",
    ]
    static func _console_src() -> String:
    var s := ""
    for p: String in CONSOLE_SRCS:
    s += FileAccess.get_file_as_string(p)
    return s
    Replace each read of CONSOLE_SRC with _console_src().

If it prints nothing, skip these deltas. The plan is written for that case.

Q1: Does any render, ui, fx or main node cache a weapon object? No.

  • There are no typed Weapon* member vars outside /sim.
  • main.gd reads sim.orbit and sim.turret every frame (around lines 1961-1975).
  • fx/weapons/orbit_shards_look.gd entries_for_sim reads pilot.arsenal.orbit on every call.
  • render/zone_renderer.gd:166/172, ui/upgrade_copy.gd, ui/stats_sheet.gd and ui/level_up_panel_legacy.gd hold weapon refs only in locals.
  • Sim.weapon, Sim.orbit and the rest (sim.gd 425-525) are forwarding getters into player.arsenal, not caches.

So no weapon_rebuilt event is needed. The spec’s conditional is not triggered. The accepted cost: a rebuilt weapon loses its live runtime state (in-flight projectiles, orbit phase, turrets, cooldowns, and the Wildfire / Thunder Wake zones the instances hold).

Q2: Can mod_log move a hash? No.

  • state_checksum() and snapshot_string() (sim.gd 3068-3121) read pilot pos/hp/xp/level, enemies, projectiles, enemy_proj, gems and drones. They never read the arsenal.
  • The three determinism files hash only through those two functions.
  • mod_log is written on the baseline path (a rung that fires in the 600-tick survival run appends an entry), but it is never read there.

Q3: wid → member names (from sim/pilot_arsenal.gd):

  • Every wid maps to the member of the same name, except "pulse", which maps to weapon.
  • The full list: blade, scythe, chalice, thunder_wake, railgun, wildfire, scar_tissue, momentum_core, chronowell, blaster, pulse→weapon, nova, orbit, beam, turret, scatter, plus aim, which is not in weapon_by_id.
  • Construction always builds pulse, nova, blade, blaster and aim. The other eleven are built only if content.has_weapon(wid).
  • The element index lives on the weapon object for thunder_wake (mirrored into thunder_wake_element_idx), railgun, wildfire, scar_tissue, momentum_core, blaster and aim ("aether").
  • For pulse, nova, orbit, beam, scatter, blade, scythe and chalice it lives in the arsenal’s *_element_idx field.
  • turret and chronowell have neither.

Q4: Which real XP-path test to mirror. tests/test_collision_damage.gd:

var sim := Sim.new(1, SimContentFixture.db())
sim.player.pos = Vector2.ZERO
var e := sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 1.0)
sim.enemies.biomass[e] = 3
sim.projectiles.add_proj(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0, 1.0)
var xp0 := sim.player.xp
sim.elemental_system.resolve_collisions(sim)
sim._sweep_dead()
assert_almost_eq(sim.player.xp - xp0, 3.0 * Sim.XP_PER_BIOMASS, 0.001)

For the gem path: sim.gems.add(sim.player.pos, Vector2.ZERO, Sim.GEM_RADIUS, 50.0); sim._collect_gems(). Both paths bank only through Sim._bank_xp (sim.gd 2866).

Q5: Where the No XP Debug Settings row goes.

  • Put it in ui/debug_settings_panel.gd _build_rows(), right after _rows.append(_pause_spawns_row()) (line 370).
  • Use the same dict shape as _pause_spawns_row() (line 624).
  • The button text renders as "No XP: OFF" / "No XP: ON", which is the same pattern the Invincibility test asserts.

Spec corrections made in this plan (with evidence)

Section titled “Spec corrections made in this plan (with evidence)”
  1. The CSP must change.
    • control/src/worker.js serves / with default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; connect-src 'self'. That blocks Google Fonts.
    • T7 adds https://fonts.googleapis.com to style-src and font-src https://fonts.gstatic.com.
    • The worker’s routes are unchanged.
  2. weapon_levels is decremented, not recomputed.
    • Rungs never touch weapon_levels: _apply_weapon_thresholds doesn’t, and aim card mods skip apply_weapon_mod.
    • Several tools write it directly (perf_probe, insitu_capture, ingame.gd). Recomputing from the log would zero those writes.
    • So rebuild_weapon subtracts only the dropped card/dev mod entries. The crystal paths never drop any.
  3. Reset everything must clear gems explicitly. The clear arm (dev_clear_enemies, sim.gd 1801) does not clear gems, so T2 adds Sim.dev_clear_gems().
  4. No weapon_rebuilt event (see Q1).
  5. The live column needs xp and xp_to_next in status. Today’s status has neither. T1 adds them next to no_xp.
  6. crystal_rungs entries also carry effect. The value is a short label: UpgradeSystem.mod_noun(kind), or "EVOLVES". The ladder label needs it, and the panel must not hard-code mod nouns.
  7. Stale-build detection uses a new caps list.
    • reset_all and clear_crystals have no status key to probe, so caps gains dev_commands: ["no_xp","reset_all","remove_crystals","clear_crystals"].
    • The panel disables any gated tile whose kind is missing from that list, and hides the − buttons.
  8. dev_level_weapon logs with src "dev" through a new optional src param on UpgradeSystem.apply_weapon_mod. The default stays "card".
  9. The Node test command is node --test control/test/*.test.mjs. The spec’s cd control && node --test uses the directory form, which fails on Node 22.
  10. Time scale and Damage stay as chip rows in a “Pace” card under the Run tiles, not value tiles.
    • Each needs 4-6 choices, so a tile would need a second click target.
    • The chips keep today’s tested behaviour.
    • They are styled to the neon look.
  11. The rung ladder labels go in a chip list under the bar, not as text over each tick.
    • kinetic carries 9 rungs across 5 weapons in WeaponThresholds.TABLE (blaster, railgun, aim, …), and several share an amount. Labels over ticks would overprint.
    • The ticks stay on the bar.

Task 0: Queue the item in RELEASE-1.0.1.md

Section titled “Task 0: Queue the item in RELEASE-1.0.1.md”

Files:

  • Modify: docs/RELEASE-1.0.1.md. Insert the new entry as the last entry of §2, directly above the line ## 3 · Triage carry-over (currently line 332).

Interfaces: none.

  • Step 1: Insert the entry. Put it above ## 3 · Triage carry-over, with one blank line before the heading:
- [ ] **Remote Console v2 (Chris, 2026-10-01: "a toggle to turn upgrades off, reset everything,
remove crystals, bigger more graphical panel, better organised, graphical tooltips").** No XP
dev toggle (console + Debug Settings), Reset everything (build + enemies + gems), crystal
removal that rolls back rungs (mod log + weapon rebuild), and a full-width neon mission-control
panel with hover/long-press tooltips. Spec `docs/superpowers/specs/2026-10-01-remote-console-v2-design.md`,
plan `docs/superpowers/plans/2026-10-01-remote-console-v2.md`.
  • Step 2: Commit.
Terminal window
printf 'docs(release): queue Remote Console v2 on the 1.0.1 list\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01DfBoaoiWooNsPhhGXL6dE8\n' > "$TMPDIR/msg.txt"
git add docs/RELEASE-1.0.1.md && git commit -F "$TMPDIR/msg.txt"

Task 1: No XP: sim flag, dispatch, status, Debug Settings row

Section titled “Task 1: No XP: sim flag, dispatch, status, Debug Settings row”

Files:

  • Modify: sim/sim.gd. Add the var next to dev_suppress_spawns (line 643) and the gate in _bank_xp (line 2866).
  • Modify: main.gd. Add the apply_dev_command arm after "pause_spawns" (line 2773), and the build_status() fields (line 2919 onward).
  • Modify: ui/debug_settings_panel.gd. Add the row after _pause_spawns_row() in _build_rows (line 370), and the new func next to _pause_spawns_row (line 624).
  • Test: tests/test_dev_seams.gd, tests/test_main.gd, tests/test_debug_settings_panel.gd.

Interfaces:

  • Produces: Sim.dev_no_xp: bool.

  • Produces: the dispatch arm {"kind":"no_xp","on":bool}.

  • Produces: the status keys no_xp: bool, xp: float and xp_to_next: float.

  • Produces: the Debug Settings row labelled "No XP".

  • Step 1: Write the failing sim tests. Append to tests/test_dev_seams.gd:

# No XP (console v2): the flag gates Sim._bank_xp, the single place XP lands, so it must hold
# on the REAL kill path (biomass → _sweep_dead → _award_xp) and the gem-pickup path alike.
func _kill_one_for_xp(sim: Sim) -> void:
sim.player.pos = Vector2.ZERO
var e := sim.enemies.add(Vector2(10, 0), Vector2.ZERO, 14.0, 1.0)
sim.enemies.biomass[e] = 3
sim.projectiles.add_proj(Vector2(10, 0), Vector2.ZERO, 6.0, 1.0, 1.0) # (pos, vel, radius, lifetime, dmg)
sim.elemental_system.resolve_collisions(sim)
sim._sweep_dead()
func test_no_xp_off_awards_kill_xp_as_normal() -> void:
var sim := _sim()
var xp0 := sim.player.xp
_kill_one_for_xp(sim)
assert_gt(sim.player.xp - xp0, 0.0, "control: the path really pays XP when the flag is off")
func test_no_xp_on_awards_zero_kill_xp_and_no_level() -> void:
var sim := _sim()
sim.dev_no_xp = true
var xp0 := sim.player.xp
var lv0 := sim.player.level
_kill_one_for_xp(sim)
assert_eq(sim.player.xp, xp0, "a kill banks nothing while No XP is on")
assert_eq(sim.player.level, lv0)
assert_eq(sim.player.pending_levelups, 0)
assert_eq(sim.enemies.count, 0, "the kill itself still happened")
func test_no_xp_on_blocks_gem_pickup_xp() -> void:
var sim := _sim()
sim.dev_no_xp = true
sim.player.pos = Vector2.ZERO
sim.gems.add(sim.player.pos, Vector2.ZERO, Sim.GEM_RADIUS, 50.0)
var xp0 := sim.player.xp
sim._collect_gems()
assert_eq(sim.player.xp, xp0)
assert_eq(sim.gems.count, 0, "the gem is still picked up")
func test_no_xp_defaults_off() -> void:
assert_false(_sim().dev_no_xp)
  • Step 2: Run it and confirm it fails. Run the single-file command with -gselect=test_dev_seams.gd. Expected: a parse/invalid-access error on dev_no_xp.

  • Step 3: Implement the sim side. In sim/sim.gd, directly under var dev_suppress_spawns: bool = false (line 643):

# Dev-tool (Remote Console v2 / Debug Settings "No XP"): while set, _bank_xp drops every XP grant,
# so kills and gem pickups award nothing and no level-up is earned. Gems still drop and are picked
# up; console Level up / Grant / Crystals still work (none of them banks XP). Default false and
# draws no RNG → determinism baselines unmoved.
var dev_no_xp: bool = false

Replace _bank_xp (line 2866):

func _bank_xp(pilot: PlayerState, amount: float) -> void:
if dev_no_xp:
return
pilot.xp += amount
  • Step 4: Run it and confirm it passes. Same command. Expected: all test_dev_seams.gd tests pass.

  • Step 5: Write the failing main and panel tests.

Append to tests/test_main.gd, using the same boot pattern as the other dev-command tests (lines 409-612):

func test_no_xp_command_sets_the_flag_and_status_reports_it() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
m.apply_dev_command({"kind": "no_xp", "on": true})
assert_true(m.sim.dev_no_xp)
var s: Dictionary = m.build_status()
assert_true(bool(s["no_xp"]))
assert_true(s.has("xp") and s.has("xp_to_next"), "the live column's XP bar reads these")
m.apply_dev_command({"kind": "no_xp", "on": false})
assert_false(m.sim.dev_no_xp)

Append to tests/test_debug_settings_panel.gd, after test_invincibility_row_reads_live_sim_state_and_writes_via_signal:

func test_no_xp_row_reads_live_sim_state_and_writes_via_signal() -> void:
var p := _panel()
var sim := Sim.new(1, SimContentFixture.db())
p.open_panel(null, sim)
await get_tree().process_frame
watch_signals(p)
assert_false(_find_row(p, "No XP").is_empty(), "No XP row exists (console parity)")
assert_eq(_row_button(p, "No XP:").text, "No XP: OFF")
_row_button(p, "No XP:").emit_signal("pressed")
var params = get_signal_parameters(p, "dev_command_requested")
assert_eq(params[0], {"kind": "no_xp", "on": true})
  • Step 6: Run both files and confirm they fail. Use -gselect=test_main.gd, then -gselect=test_debug_settings_panel.gd. Expected: the two new tests fail. no_xp is missing from status, and the row is missing.

  • Step 7: Implement the main and panel side.

In main.gd apply_dev_command, after the "pause_spawns": arm:

"no_xp":
sim.dev_no_xp = bool(cmd.get("on", true))

In build_status(), change the line "level": sim.player.level, to:

"level": sim.player.level,
"xp": sim.player.xp, "xp_to_next": sim.player.xp_to_next,
"no_xp": sim.dev_no_xp,

In ui/debug_settings_panel.gd _build_rows, after _rows.append(_pause_spawns_row()):

_rows.append(_no_xp_row())

Below _pause_spawns_row():

func _no_xp_row() -> Dictionary:
return {
"label": "No XP",
"is_graphics": false,
"get_state": func() -> bool: return _sim.dev_no_xp,
"set_state": func(v: bool) -> void: dev_command_requested.emit({"kind": "no_xp", "on": v}),
}

If the parity test is on origin/main (hazard delta 1), also add "console": {"kind": "no_xp"}, to that dict.

  • Step 8: Run the tests and confirm they pass.

    • Re-run both files.
    • test_dev_power_rows_appear_with_a_sim lists labels and still passes, since an extra row is harmless.
    • The HBox-count test expects 6. A toggle row adds no HBox, so it stays 6.
  • Step 9: Commit.

Terminal window
printf 'feat(dev): No XP toggle (console no_xp + Debug Settings row + status xp fields)\n\nCo-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>\nClaude-Session: https://claude.ai/code/session_01DfBoaoiWooNsPhhGXL6dE8\n' > "$TMPDIR/msg.txt"
git add sim/sim.gd main.gd ui/debug_settings_panel.gd tests/test_dev_seams.gd tests/test_main.gd tests/test_debug_settings_panel.gd
git commit -F "$TMPDIR/msg.txt"

Files:

  • Modify: sim/sim.gd. Add dev_clear_gems() right after dev_clear_enemies() (ends near line 1823).
  • Modify: main.gd. Extract _dev_reset_build() from the "reset_upgrades" arm (line 2862), and add a "reset_all" arm.
  • Test: tests/test_dev_seams.gd, tests/test_main.gd.

Interfaces:

  • Consumes: Sim.dev_reset_upgrades() and Sim.dev_clear_enemies() (both existing).

  • Produces: Sim.dev_clear_gems() -> void.

  • Produces: main._dev_reset_build() -> void.

  • Produces: the dispatch arm {"kind":"reset_all"}.

  • Step 1: Write the failing tests.

Append to tests/test_dev_seams.gd:

func test_dev_clear_gems_empties_the_gem_pool() -> void:
var sim := _sim()
sim.gems.add(Vector2(500, 0), Vector2.ZERO, Sim.GEM_RADIUS, 5.0)
sim.gems.add(Vector2(-500, 0), Vector2.ZERO, Sim.GEM_RADIUS, 5.0)
sim.dev_clear_gems()
assert_eq(sim.gems.count, 0)

Append to tests/test_main.gd:

func test_reset_all_resets_the_build_and_clears_enemies_and_gems_but_keeps_gold() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
m.apply_dev_command({"kind": "grant_weapon", "wid": "nova"})
m.apply_dev_command({"kind": "add_crystals", "element": "kinetic", "n": 12})
m.apply_dev_command({"kind": "spawn_preset", "type": "swarmer", "count": 5})
m.sim.gems.add(m.sim.player.pos + Vector2(400, 0), Vector2.ZERO, Sim.GEM_RADIUS, 5.0)
m.apply_dev_command({"kind": "add_gold", "amount": 1000})
var gold: int = m.sim.run_gold
assert_gt(m.sim.enemies.count, 0, "precondition")
m.apply_dev_command({"kind": "reset_all"})
assert_eq(m.sim.enemies.count, 0)
assert_eq(m.sim.gems.count, 0)
assert_eq(m.sim.player.arsenal.active_weapon_ids, ["blaster"])
assert_eq(m.sim.crystals.total(), 0)
assert_eq(m.sim.player.arsenal.thresholds_done.size(), 0)
assert_eq(m.sim.run_gold, gold, "run gold is not part of the reset")

Before relying on the add_gold arm, check that it exists and that its param is amount: /usr/bin/grep -n '"add_gold"' -A2 main.gd. It was present at plan time; adjust the test only if the param name differs.

  • Step 2: Run both files and confirm they fail. Expected: dev_clear_gems is not found, and reset_all leaves enemies alive.

  • Step 3: Implement.

In sim/sim.gd, after dev_clear_enemies():

# Dev-tool (Remote Console v2 "Reset everything"): drop every XP gem on the floor. Kept separate
# from dev_clear_enemies on purpose -- the plain "Clear arena" keeps gems so you can still bank
# them; only the full reset wants a clean floor. Touches no RNG.
func dev_clear_gems() -> void:
for i in range(gems.count - 1, -1, -1):
gems.remove_at(i)

In main.gd, replace the whole "reset_upgrades": arm body with a call to a new helper, and add "reset_all" right after it:

"reset_upgrades":
_dev_reset_build()
"reset_all":
# One command, so there is no frame between the reset and the clear where enemies
# could hit the fresh (low-HP) ship.
_dev_reset_build()
sim.dev_clear_enemies()
sim.dev_clear_gems()

Add the helper right after apply_dev_command. Its body is the old arm moved verbatim, comments included:

func _dev_reset_build() -> void:
sim.dev_reset_upgrades()
# Re-run the exact same 3 calls _new_run() makes to build a fresh run's pilot --
# ship base stats/slots, meta-shop upgrades, the hull's headline bonus -- so the
# reset really does mean "as if this run just started," matching Toby's ask.
if meta != null:
MetaRunSetup.apply_stats(sim, meta, run_ship_for(_mode, meta.selected_ship)) # tutorial: starter hull, as _new_run
  • Step 4: Run the tests and confirm they pass. Re-run test_dev_seams.gd and test_main.gd, including the existing reset_upgrades tests.

  • Step 5: Commit with the message feat(dev): Reset everything (reset_all = build reset + clear enemies + gems), using the same -F pattern and attribution lines as above.


Task 3: Mod log + PilotArsenal.rebuild_weapon

Section titled “Task 3: Mod log + PilotArsenal.rebuild_weapon”

Files:

  • Modify: sim/pilot_arsenal.gd. Replace the construction body of _init with a per-wid _construct. Add mod_log, log_op, weapon_for, rebuild_weapon and the WIDS / _ALWAYS_BUILT consts.
  • Modify: sim/upgrade_system.gd. Log at apply_upgrade aim (line 295), apply_weapon_mod (line 320) and evolve_weapon (line 341). Add the src param and a mod_noun static.
  • Modify: sim/sim.gd. Log in _apply_weapon_thresholds (lines 979-995), and pass src "dev" from dev_level_weapon (line 1891).
  • Test: tests/test_pilot_arsenal.gd.

Interfaces:

  • Produces: PilotArsenal.mod_log: Dictionary. It maps wid → Array of {"op": "mod"|"evolve", "kind": String, "mag": float, "src": String}.

  • Produces: PilotArsenal.log_op(wid: String, op: String, kind: String, mag: float, src: String) -> void.

  • Produces: PilotArsenal.weapon_for(wid: String) -> Object. It returns aim for "aim", otherwise weapon_by_id.get(wid).

  • Produces: PilotArsenal.rebuild_weapon(wid: String, content: ContentDB, skip: Callable) -> bool.

    • skip is func(entry: Dictionary) -> bool; true means drop the entry.
    • Returns false when the wid is unknown or the weapon was never built.
  • Produces: PilotArsenal.WIDS: Array[String], every constructible wid including "aim".

  • Produces: UpgradeSystem.apply_weapon_mod(sim, pilot, wid, kind, src: String = "card").

  • Produces: static UpgradeSystem.mod_noun(kind: String) -> String.

  • The src values written: "card", "dev", "rung:<i>" and "evolve".

  • Step 1: Write the failing tests. Append to tests/test_pilot_arsenal.gd:

# ── Console v2: mod log + rebuild_weapon ─────────────────────────────────────
func _full_content() -> ContentDB:
return ContentLoader.load_from_path("res://data/bible.json") # every weapon present
func test_wids_covers_every_weapon_by_id_key_plus_aim() -> void:
var a := PilotArsenal.new(_full_content())
for wid: Variant in a.weapon_by_id:
assert_true(PilotArsenal.WIDS.has(String(wid)), "WIDS lists %s" % wid)
assert_true(PilotArsenal.WIDS.has("aim"))
assert_eq(PilotArsenal.WIDS.size(), a.weapon_by_id.size() + 1, "no stale extra wids")
func test_rebuild_reassigns_member_and_dict_to_the_same_fresh_object() -> void:
var content := _full_content()
var a := PilotArsenal.new(content)
for wid: String in PilotArsenal.WIDS:
var old: Object = a.weapon_for(wid)
assert_true(a.rebuild_weapon(wid, content, func(_e: Dictionary) -> bool: return false), wid)
var fresh: Object = a.weapon_for(wid)
assert_ne(fresh, old, "%s is a NEW instance" % wid)
if wid != "aim":
assert_eq(a.weapon_by_id[wid], fresh, "%s: dict entry is the member" % wid)
# The named members, spot-checked against the dict (the match is the only place that knows them).
assert_eq(a.weapon, a.weapon_by_id["pulse"])
assert_eq(a.nova, a.weapon_by_id["nova"])
assert_eq(a.blaster, a.weapon_by_id["blaster"])
assert_eq(a.chronowell, a.weapon_by_id["chronowell"])
func test_rebuild_keeps_element_indices() -> void:
var content := _full_content()
var a := PilotArsenal.new(content)
var blaster_el: int = a.blaster.element_idx
var thunder_el: int = a.thunder_wake_element_idx
var aim_el: int = a.aim.element_idx
a.rebuild_weapon("blaster", content, func(_e: Dictionary) -> bool: return false)
a.rebuild_weapon("thunder_wake", content, func(_e: Dictionary) -> bool: return false)
a.rebuild_weapon("aim", content, func(_e: Dictionary) -> bool: return false)
assert_eq(a.blaster.element_idx, blaster_el)
assert_eq(a.thunder_wake.element_idx, thunder_el)
assert_eq(a.thunder_wake_element_idx, thunder_el)
assert_eq(a.aim.element_idx, aim_el)
func test_rebuild_replays_kept_entries_and_drops_skipped_ones() -> void:
var content := _full_content()
var a := PilotArsenal.new(content)
var fresh_cd: float = a.blaster.cooldown
a.blaster.apply_mod("power", 1.5); a.log_op("blaster", "mod", "power", 1.5, "card")
a.blaster.apply_mod("multishot", 1.0); a.log_op("blaster", "mod", "multishot", 1.0, "rung:1")
a.blaster.evolve(); a.log_op("blaster", "evolve", "", 0.0, "rung:2")
a.rebuild_weapon("blaster", content, func(e: Dictionary) -> bool: return String(e["src"]).begins_with("rung:"))
assert_almost_eq(a.blaster.damage_mult, 1.5, 0.0001, "only the card mod survives")
assert_eq(a.blaster.shots, 1)
assert_false(a.blaster.evolved)
assert_almost_eq(a.blaster.cooldown, fresh_cd, 0.0001)
assert_eq((a.mod_log["blaster"] as Array).size(), 1, "dropped entries leave the log")
func test_rebuild_decrements_weapon_levels_only_for_dropped_card_or_dev_mods() -> void:
var content := _full_content()
var a := PilotArsenal.new(content)
a.weapon_levels["blaster"] = 2
a.log_op("blaster", "mod", "power", 1.5, "card")
a.log_op("blaster", "mod", "power", 1.5, "dev")
a.log_op("blaster", "mod", "multishot", 1.0, "rung:1")
a.rebuild_weapon("blaster", content, func(e: Dictionary) -> bool: return String(e["src"]) == "dev" or String(e["src"]) == "rung:1")
assert_eq(int(a.weapon_levels["blaster"]), 1)
func test_rebuild_unknown_or_unbuilt_wid_is_a_noop() -> void:
var a := PilotArsenal.new(SimContentFixture.db())
assert_false(a.rebuild_weapon("not_a_weapon", SimContentFixture.db(), func(_e: Dictionary) -> bool: return false))
func test_card_dev_rung_and_evolve_paths_all_log() -> void:
var sim := Sim.new(1234, SimContentFixture.db())
sim.upgrade_system.apply_weapon_mod(sim, sim.player, "blaster", "power")
sim.dev_level_weapon(sim.player, "blaster", 1)
sim.dev_add_crystals("kinetic", 5) # blaster rung 0 (power)
var srcs: Array = (sim.player.arsenal.mod_log["blaster"] as Array).map(func(e: Dictionary) -> String: return String(e["src"]))
assert_eq(srcs, ["card", "dev", "rung:0"])
var aim_before := (sim.player.arsenal.mod_log.get("aim", []) as Array).size()
sim.upgrade_system.apply_upgrade(sim, sim.player, "aim:power")
assert_eq((sim.player.arsenal.mod_log["aim"] as Array).size(), aim_before + 1)
assert_eq(String((sim.player.arsenal.mod_log["aim"] as Array).back()["src"]), "card")
  • Step 2: Run it and confirm it fails. Run -gselect=test_pilot_arsenal.gd. Expected: errors on WIDS, weapon_for, rebuild_weapon and log_op.

  • Step 3: Implement PilotArsenal. In sim/pilot_arsenal.gd, add below var thresholds_done ...:

# Console v2: every mutation applied to a weapon from OUTSIDE the weapon class, in order --
# wid → Array of {"op": "mod"|"evolve", "kind", "mag" (the value actually applied), "src"}.
# src: "card" (level-up card / aim card), "dev" (console level), "rung:<i>" (crystal rung),
# "evolve" (evolution card). rebuild_weapon replays it onto a fresh instance, which is how a
# crystal rung is undone. Plain data; no state hash reads the arsenal (sim.gd state_checksum /
# snapshot_string), so this can't move a determinism baseline.
var mod_log: Dictionary = {}
# Every wid _construct knows, in the ORIGINAL _init construction order (kept so spawn-time defaults
# are byte-identical). A test pins this against weapon_by_id's keys + "aim".
const WIDS: Array[String] = ["pulse", "nova", "orbit", "beam", "turret", "scatter", "blade",
"scythe", "chalice", "thunder_wake", "railgun", "wildfire", "scar_tissue", "momentum_core",
"chronowell", "blaster", "aim"]
# Built regardless of content.has_weapon (the old _init's unguarded lines).
const _ALWAYS_BUILT: Array[String] = ["pulse", "nova", "blade", "blaster", "aim"]

Replace the body of _init up to (but not including) the weapon_by_id = {...} line with:

func _init(content: ContentDB) -> void:
for wid: String in WIDS:
if _ALWAYS_BUILT.has(wid) or content.has_weapon(wid):
_construct(wid, content)
eye_element_idx = content.element_index("aether")

Leave everything from weapon_by_id = {"blade": blade, ...} to the end of _init unchanged, including the conditional weapon_by_id[...] adds and active_weapon_ids = ["blaster"].

Then add these functions:

# The ONE place that knows wid → member and how that weapon is built (verbatim from the old _init:
# same def lookups, same element_idx placement). _init and rebuild_weapon both go through it.
func _construct(wid: String, content: ContentDB) -> void:
var def: Dictionary = content.weapon(wid) if wid != "aim" else {}
var el: int = content.element_index(def.get("element", "")) if wid != "aim" else content.element_index("aether")
match wid:
"pulse":
weapon = WeaponPulse.new(def)
pulse_element_idx = el
"nova":
nova = WeaponNova.new(def)
nova_element_idx = el
"orbit":
orbit = WeaponOrbit.new(def)
orbit_element_idx = el
"beam":
beam = WeaponBeam.new(def)
beam_element_idx = el
"turret":
turret = WeaponTurret.new(def)
"scatter":
scatter = WeaponScatter.new(def)
scatter_element_idx = el
"blade":
blade = WeaponMelee.new(def)
blade_element_idx = el
"scythe":
scythe = WeaponScythe.new(def)
scythe_element_idx = el
"chalice":
chalice = WeaponChalice.new(def)
chalice_element_idx = el
"thunder_wake":
thunder_wake = WeaponThunderWake.new(def)
thunder_wake.element_idx = el
thunder_wake_element_idx = el
"railgun":
railgun = WeaponRailgun.new(def)
railgun.element_idx = el
"wildfire":
wildfire = WeaponWildfire.new(def)
wildfire.element_idx = el
"scar_tissue":
scar_tissue = WeaponScarTissue.new(def)
scar_tissue.element_idx = el
"momentum_core":
momentum_core = WeaponMomentumCore.new(def)
momentum_core.element_idx = el
"chronowell":
chronowell = WeaponChronowell.new(def)
"blaster":
blaster = WeaponBlaster.new(def)
blaster.element_idx = el
"aim":
aim = WeaponAim.new()
aim.element_idx = el
func weapon_for(wid: String) -> Object:
if wid == "aim":
return aim
return weapon_by_id.get(wid)
func log_op(wid: String, op: String, kind: String, mag: float, src: String) -> void:
if not mod_log.has(wid):
mod_log[wid] = []
(mod_log[wid] as Array).append({"op": op, "kind": kind, "mag": mag, "src": src})
# Console v2 crystal rollback: rebuild `wid` from scratch exactly as _init would, then replay its
# mod_log minus the entries `skip` rejects. The fresh instance replaces BOTH the named member and
# weapon_by_id[wid]. weapon_levels drops by the number of dropped card/dev mods (rungs never
# counted there; tool-written levels with no log entry are left alone). Runtime state on the old
# instance (cooldowns, in-flight shots, orbit phase, turrets, zones) is lost -- accepted cost.
func rebuild_weapon(wid: String, content: ContentDB, skip: Callable) -> bool:
if not WIDS.has(wid) or weapon_for(wid) == null:
return false
_construct(wid, content)
var w: Object = weapon_for(wid) if wid == "aim" else null
if wid != "aim":
w = _member(wid)
weapon_by_id[wid] = w
var kept: Array = []
var dropped_levels := 0
for e: Dictionary in mod_log.get(wid, []):
if skip.call(e):
if String(e["op"]) == "mod" and (String(e["src"]) == "card" or String(e["src"]) == "dev") and wid != "aim":
dropped_levels += 1
continue
kept.append(e)
if String(e["op"]) == "evolve":
if not w.evolved:
w.evolve()
else:
w.apply_mod(String(e["kind"]), float(e["mag"]))
mod_log[wid] = kept
if dropped_levels > 0:
weapon_levels[wid] = maxi(int(weapon_levels.get(wid, 0)) - dropped_levels, 0)
return true
func _member(wid: String) -> Object:
match wid:
"pulse": return weapon
"nova": return nova
"orbit": return orbit
"beam": return beam
"turret": return turret
"scatter": return scatter
"blade": return blade
"scythe": return scythe
"chalice": return chalice
"thunder_wake": return thunder_wake
"railgun": return railgun
"wildfire": return wildfire
"scar_tissue": return scar_tissue
"momentum_core": return momentum_core
"chronowell": return chronowell
"blaster": return blaster
"aim": return aim
return null

Note on rebuild_weapon: weapon_for(wid) reads weapon_by_id, which still holds the old instance until it is reassigned. That is why non-aim wids read the fresh object through _member. If you add a weapon to WIDS, it must get an arm in both matches; the coverage test catches a missing one.

  • Step 4: Log at every external mutation site.

In sim/upgrade_system.gd apply_upgrade, replace the aim: branch:

elif id.begins_with("aim:"):
var akind := id.substr(4)
var amag := aim_mod_mag(akind)
pilot.arsenal.aim.apply_mod(akind, amag)
pilot.arsenal.log_op("aim", "mod", akind, amag, "card")

Replace apply_weapon_mod:

func apply_weapon_mod(sim: Sim, pilot: PlayerState, wid: String, kind: String, src: String = "card") -> void:
var w: Variant = pilot.arsenal.weapon_by_id.get(wid)
if w == null:
return
var mag := weapon_mod_mag(sim, wid, kind)
w.apply_mod(kind, mag)
pilot.arsenal.log_op(wid, "mod", kind, mag, src)
pilot.arsenal.weapon_levels[wid] = int(pilot.arsenal.weapon_levels.get(wid, 0)) + 1

In evolve_weapon, replace the last line:

pilot.arsenal.weapon_by_id[wid].evolve()
pilot.arsenal.log_op(wid, "evolve", "", 0.0, "evolve")

Add a static next to _WMOD_NOUN. T5 uses it.

static func mod_noun(kind: String) -> String:
return String(_WMOD_NOUN.get(kind, kind.capitalize()))

In sim/sim.gd _apply_weapon_thresholds, replace the if rule["kind"] == "evolve": ... else: ... block:

if rule["kind"] == "evolve":
if not w.evolved:
w.evolve()
pilot.arsenal.log_op(wid, "evolve", "", 0.0, "rung:%d" % i)
else:
var mag: float = upgrade_system.aim_mod_mag(rule["kind"]) if wid == "aim" else upgrade_system.weapon_mod_mag(self, wid, rule["kind"])
w.apply_mod(rule["kind"], mag)
pilot.arsenal.log_op(wid, "mod", String(rule["kind"]), mag, "rung:%d" % i)

In dev_level_weapon, change the call to upgrade_system.apply_weapon_mod(self, pilot, wid, kind, "dev").

  • Step 5: Run the tests and confirm they pass.

    • Run -gselect=test_pilot_arsenal.gd, then -gselect=test_dev_seams.gd.
    • Run the three determinism files: -gselect=test_determinism_survival.gd, -gselect=test_determinism.gd and -gselect=test_determinism_coop.gd.
    • Expected: all pass with unchanged pinned values. If a determinism pin moves, stop. The refactor must not reorder anything, so diff _init’s construction order against WIDS.
  • Step 6: Run the full suite. Run scripts/check-test-count.sh. Expected: 0 failures, and the count goes up by the new tests.

  • Step 7: Commit with the message feat(sim): weapon mod log + PilotArsenal.rebuild_weapon (single wid→member match).


Task 4: Crystal removal with rung rollback

Section titled “Task 4: Crystal removal with rung rollback”

Files:

  • Modify: sim/crystal_state.gd. Add remove.
  • Modify: sim/sim.gd. Add dev_remove_crystals, dev_clear_crystals and _roll_back_unmet_rungs, after dev_add_crystals (line 1916).
  • Modify: main.gd. Add the remove_crystals and clear_crystals arms after "add_crystals" (line 2820).
  • Test: tests/test_crystal_state.gd, tests/test_dev_seams.gd, tests/test_main.gd.

Interfaces:

  • Consumes: from T3, PilotArsenal.rebuild_weapon(wid, content, skip) and the mod_log src "rung:<i>".

  • Produces: CrystalState.remove(element_id: String, n: int) -> void.

  • Produces: Sim.dev_remove_crystals(element_id: String, n: int) -> int, which returns the new count, or 0 for an unknown element.

  • Produces: Sim.dev_clear_crystals() -> void.

  • Produces: the dispatch arms {"kind":"remove_crystals","element":String,"n":int} and {"kind":"clear_crystals"}.

  • Step 1: Write the failing tests.

Append to tests/test_crystal_state.gd:

func test_remove_lowers_with_a_floor_of_zero() -> void:
var c := CrystalState.new()
c.add("fire", 6)
c.remove("fire", 4)
assert_eq(c.count("fire"), 2)
c.remove("fire", 99)
assert_eq(c.count("fire"), 0)
assert_false(c.counts.has("fire"), "a zeroed element leaves the map, like a never-seen one")
c.remove("cold", 3)
assert_eq(c.count("cold"), 0, "removing an unseen element is a no-op")
c.add("void", 2)
c.remove("void", 0)
c.remove("void", -5)
assert_eq(c.count("void"), 2, "non-positive n is a no-op")

Append to tests/test_dev_seams.gd:

# Console v2 rollback: removing crystals below a rung UNDOES that rung on the weapon.
func test_remove_crystals_rolls_back_mod_and_evolve_rungs_keeping_the_card_mod() -> void:
var sim := _sim()
var fresh := PilotArsenal.new(sim.content).blaster
sim.upgrade_system.apply_weapon_mod(sim, sim.player, "blaster", "power") # a card mod
sim.dev_add_crystals("kinetic", 12) # blaster rung 0 power (5), rung 1 multishot (12); aim rung 1 pierce (10)
sim.dev_add_crystals("void", 18) # blaster rung 2 evolve (18)
assert_true(sim.player.arsenal.blaster.evolved, "precondition")
assert_eq(sim.dev_remove_crystals("kinetic", 12), 0)
assert_eq(sim.dev_remove_crystals("void", 18), 0)
var b := sim.player.arsenal.blaster
assert_almost_eq(b.damage_mult, fresh.damage_mult * 1.5, 0.0001, "fresh weapon + only the card power mod")
assert_eq(b.shots, fresh.shots)
assert_almost_eq(b.cooldown, fresh.cooldown, 0.0001)
assert_false(b.evolved)
for k in ["blaster:0", "blaster:1", "blaster:2", "aim:1"]:
assert_false(sim.player.arsenal.thresholds_done.has(k), "%s rolled back" % k)
assert_eq(int(sim.player.arsenal.weapon_levels["blaster"]), 1, "the card level survives")
assert_eq(sim.player.arsenal.weapon_by_id["blaster"], sim.player.arsenal.blaster, "member and dict agree")
func test_partial_removal_keeps_rungs_still_met() -> void:
var sim := _sim()
sim.dev_add_crystals("kinetic", 12)
sim.dev_remove_crystals("kinetic", 2) # 10 left: blaster:1 (12) lost; blaster:0 (5) + aim:1 (10) kept
assert_false(sim.player.arsenal.thresholds_done.has("blaster:1"))
assert_true(sim.player.arsenal.thresholds_done.has("blaster:0"))
assert_true(sim.player.arsenal.thresholds_done.has("aim:1"))
assert_eq(sim.player.arsenal.blaster.shots, 1, "multishot undone")
assert_gt(sim.player.arsenal.blaster.damage_mult, 1.0, "power rung kept")
func test_re_adding_after_rollback_applies_the_rung_exactly_once() -> void:
var sim := _sim()
sim.dev_add_crystals("kinetic", 12)
var shots_met: int = sim.player.arsenal.blaster.shots
sim.dev_remove_crystals("kinetic", 2)
sim.dev_add_crystals("kinetic", 2)
assert_eq(sim.player.arsenal.blaster.shots, shots_met)
sim.tick(InputState.new()) # _eval_thresholds runs every tick -- must not double-apply
assert_eq(sim.player.arsenal.blaster.shots, shots_met)
func test_clear_crystals_empties_the_wallet_and_every_rung() -> void:
var sim := _sim()
sim.dev_add_crystals("kinetic", 12)
sim.dev_add_crystals("void", 18)
sim.dev_clear_crystals()
assert_eq(sim.crystals.total(), 0)
assert_eq(sim.player.arsenal.thresholds_done.size(), 0)
assert_false(sim.player.arsenal.blaster.evolved)
func test_remove_crystals_unknown_element_or_non_positive_n_is_a_noop() -> void:
var sim := _sim()
sim.dev_add_crystals("kinetic", 5)
assert_eq(sim.dev_remove_crystals("not_an_element", 3), 0)
assert_eq(sim.dev_remove_crystals("kinetic", 0), 5)
assert_eq(sim.dev_remove_crystals("kinetic", -2), 5)
assert_true(sim.player.arsenal.thresholds_done.has("blaster:0"))

Check how InputState is constructed: /usr/bin/grep -n "InputState.new" tests/*.gd | head -3. Use the same form the determinism tests use.

Append to tests/test_main.gd:

func test_remove_and_clear_crystals_commands_dispatch() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
m.apply_dev_command({"kind": "add_crystals", "element": "kinetic", "n": 12})
m.apply_dev_command({"kind": "remove_crystals", "element": "kinetic", "n": 5})
assert_eq(m.sim.crystals.count("kinetic"), 7)
m.apply_dev_command({"kind": "clear_crystals"})
assert_eq(m.sim.crystals.total(), 0)
  • Step 2: Run it and confirm it fails. Expected: remove, dev_remove_crystals and dev_clear_crystals are not found.

  • Step 3: Implement.

Add to sim/crystal_state.gd:

# Console v2 dev removal (the game never consumes crystals). Floor 0; a zeroed element leaves the
# map so it reads exactly like one never collected. Non-positive n: no-op.
func remove(element_id: String, n: int) -> void:
if n <= 0:
return
var left := maxi(count(element_id) - n, 0)
if left == 0:
counts.erase(element_id)
else:
counts[element_id] = left

Before writing this, read sim/crystal_state.gd. If counts values are not plain ints (for example, if add stores floats), match the type add uses.

Add to sim/sim.gd, after dev_add_crystals:

# Console v2: lower the wallet (floor 0) and UNDO every rung no longer met -- each pilot, each wid
# with a thresholds_done key whose rule WeaponThresholds.rules_met no longer returns: the key is
# erased and the weapon rebuilt from its mod_log minus those "rung:<i>" entries (an evolve rung
# lost drops the evolution). Touches no RNG stream; dev-only path → baselines unmoved.
# Unknown element: no-op returning 0 (mirrors dev_add_crystals).
func dev_remove_crystals(element_id: String, n: int) -> int:
if not dev_crystal_elements().has(element_id):
return 0
if n > 0:
crystals.remove(element_id, clampi(n, 0, DEV_CRYSTAL_MAX_STEP))
_roll_back_unmet_rungs()
return crystals.count(element_id)
func dev_clear_crystals() -> void:
crystals.counts.clear()
_roll_back_unmet_rungs()
func _roll_back_unmet_rungs() -> void:
for pilot in pilots:
var ars := pilot.arsenal
var lost := {} # wid → {"rung:<i>": true}
for key: Variant in ars.thresholds_done.keys():
var parts := String(key).rsplit(":", true, 1)
var wid := parts[0]
var i := int(parts[1])
if not WeaponThresholds.rules_met(wid, crystals.counts).has(i):
ars.thresholds_done.erase(key)
if not lost.has(wid):
lost[wid] = {}
lost[wid]["rung:%d" % i] = true
for wid: String in lost:
var drop: Dictionary = lost[wid]
ars.rebuild_weapon(wid, content, func(e: Dictionary) -> bool: return drop.has(String(e["src"])))

Note on rsplit: thresholds_done keys are "<wid>:<i>", and wids contain _ but never :. rsplit(":", true, 1) splits on the last colon only. Check that WeaponThresholds.rules_met returns Array[int] (it does at plan time). .has(i) with an int is correct.

Add to main.gd apply_dev_command, after the "add_crystals" arm:

"remove_crystals":
sim.dev_remove_crystals(String(cmd.get("element", "")), int(cmd.get("n", 1)))
"clear_crystals":
sim.dev_clear_crystals()
  • Step 4: Run the tests and confirm they pass. Run test_crystal_state.gd, test_dev_seams.gd, test_main.gd and the three determinism files.

  • Step 5: Run the full suite. Run scripts/check-test-count.sh. Expected: 0 failures.

  • Step 6: Commit with the message feat(dev): remove/clear crystals with real rung rollback.


Task 5: New caps (element_colors, crystal_rungs, descs, dev_commands)

Section titled “Task 5: New caps (element_colors, crystal_rungs, descs, dev_commands)”

Files:

  • Modify: ui/debug_settings_panel.gd. Add GRAPHICS_DESCS, TOUCH_DESCS, DEV_FLAG_DESCS and AUDIO_DESCS directly under the roster consts (lines 69-88).
  • Modify: main.gd. Add the build_caps() fields and the _crystal_rung_caps / _element_color_caps helpers.
  • Test: tests/test_main.gd.

Interfaces:

  • Consumes: from T3, UpgradeSystem.mod_noun(kind).

  • Produces these caps keys:

    • element_colors: {element: "#rrggbb"}
    • crystal_rungs: {element: [{amount:int, wid, weapon_name, kind, evolve:bool, effect}]}, ascending by amount
    • descs: {"gfx": {...}, "touch": {...}, "dev": {...}, "bus": {...}}. These prefixes are the ones the panel’s renderToggles already uses.
    • dev_commands: Array[String]
  • Step 1: Write the failing test. Append to tests/test_main.gd:

func test_build_caps_publishes_console_v2_data() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
var caps: Dictionary = m.build_caps() # no run started: must still work with sim == null
var colors: Dictionary = caps["element_colors"]
for el: String in Sim.dev_crystal_elements():
assert_true(colors.has(el), "colour for %s" % el)
assert_eq(String(colors[el]).length(), 7)
assert_true(String(colors[el]).begins_with("#"))
var rungs: Dictionary = caps["crystal_rungs"]
var total := 0
for wid: Variant in WeaponThresholds.TABLE:
total += (WeaponThresholds.TABLE[wid] as Array).size()
var seen := 0
for el: Variant in rungs:
var list: Array = rungs[el]
seen += list.size()
for k in range(1, list.size()):
assert_lte(int(list[k - 1]["amount"]), int(list[k]["amount"]), "ascending")
assert_eq(seen, total, "every rule appears exactly once")
var bp: Array = (rungs["kinetic"] as Array).filter(func(r: Dictionary) -> bool: return r["wid"] == "blaster" and int(r["amount"]) == 5)
assert_eq(bp.size(), 1)
assert_eq(String(bp[0]["kind"]), "power")
assert_eq(String(bp[0]["effect"]), "Power")
assert_eq(String(bp[0]["weapon_name"]), "Blaster")
assert_false(bool(bp[0]["evolve"]))
var ev: Array = (rungs["void"] as Array).filter(func(r: Dictionary) -> bool: return r["wid"] == "blaster" and bool(r["evolve"]))
assert_eq(int(ev[0]["amount"]), 18)
assert_eq(String(ev[0]["effect"]), "EVOLVES")
var aim: Array = (rungs["aether"] as Array).filter(func(r: Dictionary) -> bool: return r["wid"] == "aim")
assert_eq(String(aim[0]["weapon_name"]), "Rift Bolt")
var descs: Dictionary = caps["descs"]
for pair: Array in [["gfx", DebugSettingsPanel.GRAPHICS_TOGGLES], ["touch", DebugSettingsPanel.TOUCH_TOGGLES],
["dev", DebugSettingsPanel.DEV_FLAG_TOGGLES], ["bus", DebugSettingsPanel.AUDIO_BUSES]]:
var uncovered: Array[String] = []
for t: Array in pair[1]:
if String((descs[pair[0]] as Dictionary).get(String(t[0]), "")) == "":
uncovered.append(String(t[0]))
assert_eq(uncovered, [] as Array[String], "%s roster entries with no desc: %s" % [pair[0], uncovered])
assert_eq(Array(caps["dev_commands"]), ["no_xp", "reset_all", "remove_crystals", "clear_crystals"])
  • Step 2: Run it and confirm it fails. Run -gselect=test_main.gd. Expected: element_colors key missing.

  • Step 3: Add the desc dicts. Add to ui/debug_settings_panel.gd, right after const AUDIO_BUSES ....

    • Each line restates what the toggle’s code path does; the evidence is listed at the end of this step.
    • Before committing, open each cited path and confirm the sentence. If one is wrong, fix the sentence; never invent behaviour.
# One-line descriptions for the Remote Console's tooltips (main.build_caps publishes them as
# caps.descs). Parallel to the rosters above, keyed by roster id. A roster entry with no desc gets
# no tooltip -- tests/test_main.gd lists any uncovered id, so a new toggle can't silently ship bare.
const GRAPHICS_DESCS: Dictionary = {
"bloom": "World glow: the bloom pass on the WorldEnvironment.",
"halos": "Glow halos drawn behind entities.",
"grid": "Full arena background grid. Off = the low-detail grid.",
"damage_numbers": "Floating damage numbers on hits.",
"screen_fx": "Screen feedback: the hurt vignette shader and camera shake.",
"zones": "Full-detail damage zones. Off = the low-detail zone draw.",
"juice_fx": "The FxManager effects layer (hit and kill effects).",
"player_detail": "Extra visual detail on the player ship. Off = the plain hull.",
"nebula_overlay": "The nebula overlay on the arena background.",
"nova_look": "Swaps Nova between its two candidate looks (A/B).",
"glass_ui": "Liquid-glass UI skin. Applies when a screen next opens; the level-up panel on the next run.",
"plasma_nova": "Plasma Nova candidate look (A/B). Shows on the next pulse.",
"weapon_fx_v2": "Weapon FX v2 candidate visuals (A/B). Shows on the next shot.",
}
const TOUCH_DESCS: Dictionary = {
"slide_abilities": "Drag the aim thumb onto WARP or DRONE to fire it. Off by default.",
"haptics": "Touch haptic feedback.",
"drone_nudge": "A ready DRONE button glows while you take hits and flashes on low hull.",
}
const DEV_FLAG_DESCS: Dictionary = {
"perf_bench": "Autopilot sweeps the quality tiers as a perf bench. Takes effect next launch.",
"auto_showcase": "Hands-free invulnerable autopilot. Takes effect next launch.",
"record_replay": "Records runs to user://replays/.",
"audio_sfx_off": "Skips every sound effect; music keeps playing. Live.",
}
const AUDIO_DESCS: Dictionary = {
"Music": "Mutes or unmutes the Music bus.",
"SFX": "Mutes or unmutes the SFX bus.",
}

Evidence to re-check, per id. Use /usr/bin/grep -n "<id>" main.gd ui/debug_settings_panel.gd render/*.gd:

  • bloom: world_env glow

  • halos: halo_targets

  • grid: arena_bg.set_low, inverted

  • screen_fx: the ScreenFeedback vignette shader plus shake

  • zones: zone_renderer.low_detail, inverted

  • juice_fx: FxManager fx_layer

  • player_detail: inverted

  • nova_look: VisualLook.nova

  • glass_ui, plasma_nova, weapon_fx_v2: the comments in the roster itself

  • touch and dev flags: their setter comments in debug_settings_panel.gd

  • Step 4: Publish the new caps. In main.gd build_caps(), add inside the returned dict after "crystal_elements": ...:

# Console v2 (Crystals card + tooltips + stale-build gating).
"element_colors": _element_color_caps(caps_content),
"crystal_rungs": _crystal_rung_caps(caps_content),
"descs": {
"gfx": DebugSettingsPanel.GRAPHICS_DESCS, "touch": DebugSettingsPanel.TOUCH_DESCS,
"dev": DebugSettingsPanel.DEV_FLAG_DESCS, "bus": DebugSettingsPanel.AUDIO_DESCS,
},
# Command kinds a console may need to feature-detect: an older game lacks this key (or an
# entry), and the panel disables that control instead of sending into the void.
"dev_commands": ["no_xp", "reset_all", "remove_crystals", "clear_crystals"],

At the top of build_caps(), before var weapons, add:

# build_caps can run before any run exists (tests call it right after _ready), so fall back to
# the bible the game ships rather than reading sim.content off a null sim.
var caps_content: ContentDB = sim.content if sim != null else ContentLoader.load_from_path("res://data/bible.json")

Add the helpers after build_caps():

# element → "#rrggbb": the SAME vivid colour the game draws (ElementPalette.color_for), so the
# console never hard-codes the palette.
func _element_color_caps(content: ContentDB) -> Dictionary:
var out := {}
for el: String in Sim.dev_crystal_elements():
out[el] = "#" + ElementPalette.color_for(content, content.element_index(el)).to_html(false)
return out
# element → rungs ascending by amount, from WeaponThresholds.TABLE. effect is the short label the
# ladder shows ("Power", "+Shot", "EVOLVES") -- UpgradeSystem's own mod nouns, not panel copy.
func _crystal_rung_caps(content: ContentDB) -> Dictionary:
var out := {}
for wid: Variant in WeaponThresholds.TABLE:
var w := String(wid)
var wname := String(content.weapon(w).get("name", "")) if w != "aim" else ""
if wname == "":
wname = UpgradeCopy.weapon_name(null, w) # "Rift Bolt" for aim, capitalized id otherwise
for rule: Dictionary in WeaponThresholds.TABLE[wid]:
var el := String(rule["element"])
var kind := String(rule["kind"])
if not out.has(el):
out[el] = []
(out[el] as Array).append({
"amount": int(rule["amount"]), "wid": w, "weapon_name": wname, "kind": kind,
"evolve": kind == "evolve",
"effect": "EVOLVES" if kind == "evolve" else UpgradeSystem.mod_noun(kind),
})
for el: Variant in out:
(out[el] as Array).sort_custom(func(a: Dictionary, b: Dictionary) -> bool:
return int(a["amount"]) < int(b["amount"]) or (int(a["amount"]) == int(b["amount"]) and String(a["wid"]) < String(b["wid"])))
return out
  • Step 5: Run the tests and confirm they pass.

    • Run -gselect=test_main.gd.
    • If GDScript rejects the multi-line lambda inside sort_custom, hoist it into a static func _rung_lt(a: Dictionary, b: Dictionary) -> bool and pass _rung_lt.
  • Step 6: Run the full suite. Run scripts/check-test-count.sh. Expected: 0 failures.

  • Step 7: Commit with the message feat(console): caps element_colors, crystal_rungs, descs, dev_commands.


Task 6: Split the panel into string modules (no behaviour change)

Section titled “Task 6: Split the panel into string modules (no behaviour change)”

Files:

  • Create: control/src/panel/styles.js, control/src/panel/markup.js and control/src/panel/client.js.
  • Modify: control/src/panel.html.js. It becomes a composer.
  • Test: control/test/panel-modules.test.mjs (new).
  • If hazard delta 3 applies, also modify tests/test_debug_console_parity.gd.

Interfaces:

  • Produces: export const STYLES: string, export const MARKUP: string and export const CLIENT: string.

  • Produces: panelHtml() composes them, and T8 adds TOOLTIPS_JS.

  • panelHtml() still returns exactly one <script>…</script> pair. control/test/panel.test.mjs slices between the first <script> and the last </script>.

  • Step 1: Snapshot today’s output. From the worktree root:

Terminal window
node -e "import('./control/src/panel.html.js').then(m=>process.stdout.write(m.panelHtml()))" > "$TMPDIR/panel-before.html"; wc -c "$TMPDIR/panel-before.html"
  • Step 2: Write the failing module test. Create control/test/panel-modules.test.mjs:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, readdirSync } from 'node:fs';
import { panelHtml } from '../src/panel.html.js';
const dir = new URL('../src/panel/', import.meta.url);
test('each panel string module is one template literal with no ${, backslash or extra backtick', () => {
const files = readdirSync(dir).filter((f) => f.endsWith('.js'));
assert.ok(files.length >= 3, 'styles, markup, client (+ tooltips from T8)');
for (const f of files) {
const src = readFileSync(new URL(f, dir), 'utf8');
assert.equal((src.match(/`/g) || []).length, 2, f + ': exactly the two delimiting backticks');
assert.equal(src.includes('${'), false, f + ': no ${ interpolation');
assert.equal(src.includes('\\'), false, f + ': no backslash (a template literal would cook it)');
}
});
test('panelHtml composes to a single page with one script block', () => {
const html = panelHtml();
assert.ok(html.startsWith('<!doctype html>'));
assert.equal(html.split('<script>').length - 1, 1);
assert.equal(html.split('</script>').length - 1, 1);
assert.ok(html.endsWith('</body></html>'));
});
  • Step 3: Run it and confirm it fails. Run node --test control/test/panel-modules.test.mjs. Expected: ENOENT on control/src/panel/.

  • Step 4: Move the text verbatim. This is a mechanical recipe. The line numbers are from control/src/panel.html.js at 43dc4864; confirm them with grep -n '<style>\|</style>\|<script>\|</script>' control/src/panel.html.js.

New module Takes lines First line Last line
styles.js → STYLES 13-68 :root{color-scheme:dark;...` | ` #gate{...}
markup.js → MARKUP 70-299 <header> the blank line before <script>
client.js → CLIENT 301-877 const KEY = ... window.saveCustomPreset = saveCustomPreset;

Each module has this shape. Example for styles.js, where the body is the verbatim lines plus their trailing newline:

// Panel stylesheet (string module). Same rules as the old single template: no backtick, no
// interpolation, no backslash inside the string -- control/test/panel-modules.test.mjs enforces it.
export const STYLES = `<verbatim lines 13-68, each ending in a newline>`;

Then replace control/src/panel.html.js with:

// Returns the control-panel HTML. Caps-driven, one section per job. Every roster (presets, bosses,
// graphics toggles, touch + dev flags, backgrounds, tilt modes, dimensions, crystal elements, damage
// mults) comes from /caps, which publishes the SAME arrays the in-game Debug Settings panel builds
// its rows from -- so the two front ends stay in step without this file hardcoding game content.
// Live toggle state comes back on /status.flags. The page is composed from string modules in
// ./panel/ (styles, markup, client) so no single template grows unreadable.
import { STYLES } from './panel/styles.js';
import { MARKUP } from './panel/markup.js';
import { CLIENT } from './panel/client.js';
export function panelHtml() {
return '<!doctype html><html><head><meta charset="utf-8">\n'
+ '<meta name="viewport" content="width=device-width,initial-scale=1">\n'
+ '<title>Dark Cosmos · Console</title>\n'
+ '<style>\n' + STYLES + '</style></head><body>\n'
+ MARKUP + '<script>\n' + CLIENT + '</script></body></html>';
}
  • Step 5: Prove byte-identity.
Terminal window
node -e "import('./control/src/panel.html.js').then(m=>process.stdout.write(m.panelHtml()))" > "$TMPDIR/panel-after.html"; cmp "$TMPDIR/panel-before.html" "$TMPDIR/panel-after.html" && echo IDENTICAL

Expected: IDENTICAL. If cmp reports a difference, fix the newline joins at the module boundaries; never touch the content. diff shows which boundary is off.

  • Step 6: Run the Node tests. Run node --test control/test/*.test.mjs. Expected: everything passes, including all of the existing panel.test.mjs.

  • Step 7: Apply hazard delta 3 if tests/test_debug_console_parity.gd is on origin/main. Run -gselect=test_debug_console_parity.gd.

  • Step 8: Commit with the message refactor(console): split panel into styles/markup/client string modules (byte-identical).


Task 7: Neon mission-control layout + live column

Section titled “Task 7: Neon mission-control layout + live column”

Files:

  • Modify: control/src/panel/styles.js. Full replacement.
  • Modify: control/src/panel/markup.js. New skeleton, Run pane and Build pane, plus the live column.
  • Modify: control/src/panel/client.js. Update TABS, renderTabs, renderLive, applyStatus and loadCaps, and add toggleRun / updateRunTiles.
  • Modify: control/src/worker.js. CSP for Google Fonts.
  • Test: control/test/panel.test.mjs (update), control/test/worker.test.mjs (new).

Interfaces:

  • Consumes these status keys: no_xp, xp, xp_to_next (T1), plus the existing invuln, paused, timescale, crystals, alive and flags.

  • Consumes the caps key element_colors (T5).

  • Produces these element ids:

    • live column: live-col, live-fps, live-fps-line, live-hp, live-hp-bar, live-lv, live-xp-bar, live-gold, live-enemies, live-time, live-area, live-gems, live-flags, status
    • tiles: tile-invuln, tile-pause, tile-noxp, tile-reset-all
    • panes: tab-build replaces both tab-player and tab-weapons; tab-readout is removed
  • Produces these client functions:

    • toggleRun(id: 'invuln'|'pause'|'noxp')
    • updateRunTiles(status)
    • act(el, cmd), which sends unless the element has data-stale. T9 uses it.
    • globals _elColors (element → validated hex) and SAFE_HEX
  • Keeps every other existing id and function name. They are listed in the T6 client.

  • Step 1: Write the failing tests. In control/test/panel.test.mjs:

(a) Extend fakeEl so the new client can run. Add these to the object literal:

attrs: {},
setAttribute(k, v) { el.attrs[k] = String(v); },
getAttribute(k) { return k in el.attrs ? el.attrs[k] : null; },
removeAttribute(k) { delete el.attrs[k]; },

Then, after the literal is built, add el.style.setProperty = (k, v) => { el.style[k] = v; };.

(b) Add addEventListener: () => {} to the fake document.

(c) Add toggleRun, updateRunTiles, act to the loadPanel return list.

(d) Replace the test 'the live strip reads the status fields the game sends' with:

test('the live column reads the status fields the game sends', () => {
const { api, els } = loadPanel();
api.applyStatus({
fps: 59.6, run_time: 125, hp: 42.4, hp_max: 100, level: 7, xp: 50, xp_to_next: 100, gold: 320,
area: 'home', timescale: 2, alive: { swarmer: 3, brute: 1 }, paused: true, no_xp: true,
crystals: [{ id: 'fire', have: 12, need: 20 }],
});
assert.equal(els['live-fps'].textContent, '60');
assert.equal(els['live-time'].textContent, '2:05');
assert.equal(els['live-hp'].textContent, '42 / 100');
assert.equal(els['live-hp-bar'].style.width, '42.4%');
assert.equal(els['live-lv'].textContent, '7');
assert.equal(els['live-xp-bar'].style.width, '50%');
assert.equal(els['live-gold'].textContent, '320');
assert.equal(els['live-enemies'].textContent, '4');
assert.match(els['live-fps-line'].attrs.points, /^0,/);
const chips = els['live-flags'].children.map((c) => c.textContent);
assert.deepEqual(chips, ['Spawns paused', 'No XP', '×2 time']);
assert.equal(els['live-gems'].children.length, 1);
assert.equal(els['status'].textContent.includes('"no_xp": true'), true, 'raw drawer still gets the JSON');
});
test('run toggle tiles send their command and follow status', async () => {
const { api, els, sent } = loadPanel();
api.updateRunTiles({ invuln: false, paused: false, no_xp: false });
api.toggleRun('noxp');
api.toggleRun('pause');
api.toggleRun('invuln');
await new Promise((r) => setImmediate(r));
assert.deepEqual(sent, [{ kind: 'no_xp', on: true }, { kind: 'pause_spawns', on: true }, { kind: 'invuln', on: true }]);
assert.equal(els['tile-noxp'].classList.contains('on'), true);
});
test('a status without a run-toggle key leaves that tile alone', () => {
const { api, els } = loadPanel();
api.updateRunTiles({ invuln: true });
assert.equal(els['tile-invuln'].classList.contains('on'), true);
assert.equal(els['tile-noxp'].classList.contains('on'), false);
});

(e) In 'exactly one tab pane is visible at a time', keep the crystals/run asserts and add:

api.showTab('build');
assert.equal(els['tab-build'].style.display, 'block');
assert.equal(els['tab-crystals'].style.display, 'none');

(f) Create control/test/worker.test.mjs:

import { test } from 'node:test';
import assert from 'node:assert/strict';
import worker from '../src/worker.js';
test('panel CSP allows Google Fonts and nothing else new', async () => {
const res = await worker.fetch(new Request('https://x.test/?key=k'), { CONTROL_KEY: 'k' });
assert.equal(res.status, 200);
const csp = res.headers.get('Content-Security-Policy');
assert.match(csp, /style-src 'unsafe-inline' https:\/\/fonts\.googleapis\.com/);
assert.match(csp, /font-src https:\/\/fonts\.gstatic\.com/);
assert.match(csp, /connect-src 'self'/);
assert.match(csp, /script-src 'unsafe-inline'(;|$)/);
});

If importing worker.js pulls in something that fails in Node (it imports only ./queue.js and ./panel.html.js at plan time), drop the test and verify the header in T10 instead.

  • Step 2: Run it and confirm it fails. Run node --test control/test/*.test.mjs. Expected: the new tests fail (no live-fps text, toggleRun undefined, CSP mismatch).

  • Step 3: Replace styles.js. The whole string becomes the CSS below, derived from neon-look.html. It keeps every class the client still creates: .row, .grid, .chip, .count-chip, .place-chip, .elite-chip, .boss-btn, .danger, .hint, .card, .pane, .ent-row, .wep-row, .wep-name, .wep-state, .stat-row, .perf-*, label.tog, #status and #gate.

:root{color-scheme:dark;
--accent:#33e6ff;--text:#d9f7ff;--dim:#99b8cc;--danger:#ff525c;--warn:#ff9e33;
--gold:#ffd64d;--hp:#4dff80;--xp:#b3ff4d;--ok:#66ff99;
--bg:#020308;--fill:rgba(4,8,16,.66);--fill-hi:rgba(8,18,32,.78);
--line:rgba(51,230,255,.28);--line-lo:rgba(51,230,255,.14)}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;color:var(--text);font:14px "JetBrains Mono",ui-monospace,monospace;
background:radial-gradient(1200px 600px at 70% -10%,rgba(154,51,255,.16),transparent 60%),
radial-gradient(900px 500px at 10% 110%,rgba(51,230,255,.12),transparent 60%),var(--bg)}
body::before{content:"";position:fixed;inset:0;pointer-events:none;opacity:.35;
background-image:linear-gradient(rgba(51,230,255,.06) 1px,transparent 1px),linear-gradient(90deg,rgba(51,230,255,.06) 1px,transparent 1px);
background-size:48px 48px;mask-image:radial-gradient(circle at 50% 40%,#000 30%,transparent 80%)}
.app{position:relative;display:grid;grid-template-columns:86px minmax(0,1fr) 300px;
grid-template-areas:"head head head" "nav main live";grid-template-rows:auto 1fr;gap:14px;padding:14px;min-height:100vh}
.panel{background:var(--fill);border:1px solid var(--line);border-radius:12px;
box-shadow:0 0 10px rgba(51,230,255,.12),inset 0 0 24px rgba(51,230,255,.04);backdrop-filter:blur(6px)}
.title{font-family:Orbitron,sans-serif;font-weight:900;letter-spacing:.14em;text-transform:uppercase;color:var(--accent);
text-shadow:0 0 12px rgba(51,230,255,.8),0 0 28px rgba(51,230,255,.35)}
header{grid-area:head;display:flex;flex-wrap:wrap;align-items:center;gap:10px 16px;padding:10px 16px}
header .title{font-size:18px}
#code{font-family:Orbitron,sans-serif;font-weight:700;letter-spacing:.25em;width:8.5em;text-transform:uppercase}
#pairing-status{margin-left:auto;color:var(--ok);font-size:12px;text-shadow:0 0 8px rgba(102,255,153,.6)}
#tabs{grid-area:nav;display:flex;flex-direction:column;gap:6px;padding:8px 6px;align-self:start}
#tabs button{display:flex;flex-direction:column;align-items:center;gap:3px;padding:10px 4px;border-radius:10px;
color:var(--dim);font:600 10px Orbitron,sans-serif;letter-spacing:.1em;text-transform:uppercase;border:1px solid transparent;background:none;box-shadow:none}
#tabs button span{font-size:22px;font-family:system-ui}
#tabs button.active{color:var(--accent);border-color:var(--accent);background:rgba(51,230,255,.08);box-shadow:0 0 14px rgba(51,230,255,.35)}
main{grid-area:main;padding:16px 18px;min-width:0}
h2{font:700 13px Orbitron,sans-serif;letter-spacing:.16em;text-transform:uppercase;color:var(--accent);margin:4px 0 10px;text-shadow:0 0 10px rgba(51,230,255,.5)}
h2 small{font:11px "JetBrains Mono",monospace;color:var(--dim);letter-spacing:0;text-transform:none;text-shadow:none;margin-left:8px}
h3{font:600 11px Orbitron,sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--dim);margin:14px 0 6px}
.hint{font-size:12px;color:var(--dim);margin:0 0 10px;line-height:1.45}
input,button,select{font:inherit;color:var(--text);background:var(--fill-hi);border:1px solid var(--line-lo);border-radius:8px;padding:6px 10px}
button{cursor:pointer;user-select:none;transition:.15s}
button:hover{border-color:var(--accent)}
button:active,button.active{border-color:var(--accent);color:var(--accent);box-shadow:0 0 10px rgba(51,230,255,.35)}
button:disabled{opacity:.35;cursor:default;box-shadow:none}
.row{display:flex;flex-wrap:wrap;gap:6px;margin:6px 0;align-items:center}
.row .k{color:var(--dim);font-size:12px;width:84px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(130px,1fr));gap:8px;margin:6px 0}
.grid button{text-align:left}
.boss-btn,.danger{color:var(--danger);border-color:rgba(255,82,92,.45)}
.card{border:1px solid var(--line-lo);border-radius:12px;padding:12px 14px;margin:0 0 14px;background:rgba(8,18,32,.4)}
.pane{display:none}
.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(140px,1fr));gap:10px;margin-bottom:22px}
.tile{position:relative;height:96px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;border-radius:12px;
background:var(--fill-hi);border:1px solid rgba(51,230,255,.22);font:600 11px Orbitron,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--text)}
.tile b{font:400 28px system-ui;filter:drop-shadow(0 0 6px currentColor)}
.tile:hover{border-color:var(--accent);box-shadow:0 0 16px rgba(51,230,255,.35)}
.tile.on{border-color:var(--accent);color:var(--accent);box-shadow:0 0 18px rgba(51,230,255,.55),inset 0 0 18px rgba(51,230,255,.15)}
.tile.on::after{content:"ON";position:absolute;top:7px;right:9px;font-size:9px;color:var(--accent)}
.tile.danger{color:var(--danger);border-color:rgba(255,82,92,.45)}
.tile.danger:hover{box-shadow:0 0 16px rgba(255,82,92,.5)}
.tile[data-stale]{opacity:.35;cursor:default;box-shadow:none}
.chip,.count-chip,.place-chip,.elite-chip{padding:5px 12px;border-radius:999px;font-size:12px}
.elite-chip.active{color:var(--warn);border-color:var(--warn);box-shadow:0 0 10px rgba(255,158,51,.4)}
.ent-row,.wep-row{display:grid;grid-template-columns:1fr auto;gap:8px;align-items:center;margin:6px 0;padding:8px 10px;border-radius:10px;background:var(--fill-hi);border:1px solid var(--line-lo)}
.ent-row.on,.wep-row.on{border-color:var(--accent);box-shadow:0 0 10px rgba(51,230,255,.25)}
.wep-name{font:700 12px Orbitron,sans-serif;letter-spacing:.08em}
.wep-state{font-size:11px;color:var(--dim)}
.wep-row button,.ent-row button{padding:4px 9px;font-size:12px}
.stat-row{display:grid;grid-template-columns:132px 1fr 52px;gap:8px;align-items:center;margin:6px 0}
.stat-row label{font-size:12px;color:var(--dim)}
.stat-row span{font-size:12px;color:var(--accent);text-align:right}
input[type=range]{padding:0;height:6px;accent-color:var(--accent);width:100%}
select{min-width:130px}
label.tog{display:flex;align-items:center;gap:8px;cursor:pointer;font-size:12px;padding:8px 10px;border:1px solid var(--line-lo);border-radius:10px;background:var(--fill-hi);flex:1 1 220px}
.perf-fields{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:6px 0}
.perf-fields label{display:flex;flex-direction:column;gap:2px;font-size:11px;color:var(--dim);min-width:0}
.perf-fields input,.perf-fields select{width:100%;min-width:0}
.perf-fields .wide{grid-column:span 2}
#perf-table{overflow-x:auto;margin:6px 0}
.perf-tbl{border-collapse:collapse;font-size:12px;font-variant-numeric:tabular-nums;width:100%}
.perf-tbl th,.perf-tbl td{padding:4px 6px;text-align:right;white-space:nowrap;border-bottom:1px solid var(--line-lo)}
.perf-tbl th{color:var(--dim);font-weight:normal}
.perf-tbl th:first-child,.perf-tbl td:first-child{text-align:left}
.perf-tbl tr.sum td{color:var(--ok)}
.el{--c:var(--accent);padding:12px 14px;margin-bottom:10px;border-radius:12px;background:var(--fill-hi);
border:1px solid color-mix(in srgb,var(--c) 45%,transparent);box-shadow:0 0 12px color-mix(in srgb,var(--c) 18%,transparent)}
.el .hd{display:flex;align-items:center;gap:10px}
.el .gem{width:16px;height:16px;transform:rotate(45deg);background:var(--c);box-shadow:0 0 10px var(--c)}
.el .nm{font:700 12px Orbitron,sans-serif;letter-spacing:.12em;text-transform:uppercase;color:var(--c)}
.el .ct{margin-left:auto;font:600 20px "JetBrains Mono",monospace;color:var(--c);text-shadow:0 0 10px var(--c)}
.ladder{position:relative;height:8px;border-radius:4px;background:rgba(255,255,255,.07);margin:14px 0 8px}
.ladder i{position:absolute;inset:0 auto 0 0;border-radius:4px;background:var(--c);box-shadow:0 0 10px var(--c)}
.ladder s{position:absolute;top:-5px;width:3px;height:18px;border-radius:2px;background:#fff8;margin-left:-1px}
.ladder s.met{background:#fff;box-shadow:0 0 8px #fff}
.rungs{display:flex;flex-wrap:wrap;gap:4px 10px;margin:0 0 10px;font-size:11px;color:var(--dim)}
.rungs .rung.met{color:var(--text)}
.rungs .rung.met::after{content:" ✓";color:var(--c)}
.steps{display:flex;gap:6px}
.steps button{flex:0 0 52px;font:600 12px "JetBrains Mono",monospace;background:rgba(255,255,255,.04);border:1px solid rgba(255,255,255,.15);padding:6px 0}
.steps button.m{color:var(--danger);border-color:rgba(255,82,92,.4)}
.steps .sp{flex:1}
#live-col{grid-area:live;padding:14px 16px;display:flex;flex-direction:column;gap:12px;align-self:start;position:sticky;top:14px}
.lbl{font:600 10px Orbitron,sans-serif;letter-spacing:.14em;color:var(--dim);text-transform:uppercase;display:flex;justify-content:space-between;align-items:baseline;gap:8px}
.lbl span{font:600 15px "JetBrains Mono",monospace;color:var(--text);letter-spacing:0;text-transform:none}
.bar{height:8px;border-radius:4px;background:rgba(255,255,255,.07);overflow:hidden;margin-top:5px}
.bar i{display:block;height:100%;width:0;border-radius:4px;transition:width .3s}
.bar i.hp{background:var(--hp);box-shadow:0 0 8px var(--hp)}
.bar i.xp{background:var(--xp);box-shadow:0 0 8px var(--xp)}
#live-fps{color:var(--ok)}
#live-lv{color:var(--xp)}
#live-gold{color:var(--gold)}
#live-fps-line{filter:drop-shadow(0 0 4px #33e6ff)}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px}
.flag{font:600 10px Orbitron,sans-serif;letter-spacing:.08em;padding:4px 8px;border-radius:999px;border:1px solid var(--accent);color:var(--accent);box-shadow:0 0 8px rgba(51,230,255,.35)}
.mini{display:flex;gap:10px;flex-wrap:wrap;margin-top:6px}
.mini span{--c:var(--accent);display:flex;align-items:center;gap:5px;font-size:13px}
.mini i{width:9px;height:9px;transform:rotate(45deg);background:var(--c);box-shadow:0 0 6px var(--c)}
.mini b{font-weight:600}
details{font-size:11px;color:var(--dim)}
#status{white-space:pre-wrap;font-size:11px;color:var(--dim);max-height:40vh;overflow:auto;margin:6px 0 0}
#gate{color:var(--dim);font-size:13px;padding:18px 4px}
#tip{position:fixed;z-index:20;display:none;width:290px;padding:12px 14px;border-radius:12px;font:12px/1.45 "JetBrains Mono",monospace;
color:var(--text);background:rgba(6,14,26,.96);border:1px solid var(--accent);box-shadow:0 0 22px rgba(51,230,255,.4),0 12px 30px #000c;pointer-events:none}
#tip.show{display:block}
#tip h5{margin:0 0 6px;font:700 13px Orbitron,sans-serif;letter-spacing:.08em;color:var(--accent)}
#tip .k{color:var(--dim);margin-top:6px}
#tip .warn{color:var(--warn);margin-top:4px}
#tip svg{display:block;margin:8px 0 2px}
@media (max-width:900px){
.app{grid-template-columns:minmax(0,1fr);grid-template-areas:"head" "live" "nav" "main";gap:10px;padding:10px}
#tabs{flex-direction:row;overflow-x:auto;scrollbar-width:none}
#tabs button{flex:0 0 auto;min-width:64px}
#live-col{position:static;flex-direction:row;flex-wrap:wrap;gap:8px 16px;padding:10px 12px}
#live-col > *{flex:1 1 130px}
#live-col .title,#live-col details{flex-basis:100%}
.tiles{grid-template-columns:repeat(auto-fill,minmax(104px,1fr))}
.tile{height:84px}
}

The rule .el .ct uses a font-family list with a quoted family. That is fine; the string contains no backtick.

  • Step 4: Replace markup.js. Use a deterministic recipe. The new MARKUP string is, in order:

(a) Font link and skeleton opening. Both live in MARKUP so the head stays in the composer.

<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=JetBrains+Mono:wght@400;600&display=swap">
<div class="app">
<header class="panel">
<span class="title">Dark Cosmos · Remote Console</span>
<input id="code" maxlength="6" placeholder="AB12" aria-label="Pairing code">
<button onclick="loadCaps()" data-tip="connect">Connect</button>
<span id="pairing-status"></span>
</header>
<nav id="tabs" class="panel"></nav>
<main class="panel">
<div id="gate">Enter the pairing code shown in-game (Pause → Remote Control), then press Connect.</div>

(b) The new Run pane. It replaces the old tab-run.

<div class="pane" id="tab-run">
<h2>Run <small>arena · pacing · progression</small></h2>
<div class="tiles">
<button class="tile" id="tile-invuln" data-tip="run-invuln" onclick="toggleRun('invuln')"><b>🛡</b>Invincible</button>
<button class="tile" id="tile-pause" data-tip="run-pause" onclick="toggleRun('pause')"><b>⏸</b>Pause spawns</button>
<button class="tile" id="tile-noxp" data-tip="run-noxp" onclick="toggleRun('noxp')"><b>⊘</b>No XP</button>
<button class="tile" data-tip="run-clear" onclick="send({kind:'clear'})"><b>🧹</b>Clear arena</button>
<button class="tile" data-tip="run-levelup" onclick="send({kind:'force_levelup',n:1})"><b>⬆</b>Level up</button>
<button class="tile" data-tip="run-levelup5" onclick="send({kind:'force_levelup',n:5})"><b>⇈</b>5 level-ups</button>
<button class="tile" data-tip="run-gold" onclick="send({kind:'add_gold',amount:1000})"><b>✦</b>+1000 gold</button>
<button class="tile danger" data-tip="run-reset-upgrades" onclick="send({kind:'reset_upgrades'})"><b>↺</b>Reset upgrades</button>
<button class="tile danger" id="tile-reset-all" data-tip="run-reset-all" onclick="act(this,{kind:'reset_all'})"><b>⟲</b>Reset everything</button>
</div>
<div class="card">
<h3>Pace</h3>
<div class="row" id="ts-chips">
<span class="k">Time scale</span>
<button class="chip" data-tip="run-timescale" onclick="setTs(0.25,this)">0.25×</button>
<button class="chip" data-tip="run-timescale" onclick="setTs(0.5,this)">0.5×</button>
<button class="chip active" data-tip="run-timescale" onclick="setTs(1,this)">1×</button>
<button class="chip" data-tip="run-timescale" onclick="setTs(2,this)">2×</button>
<button class="chip" data-tip="run-timescale" onclick="setTs(4,this)">4×</button>
</div>
<div class="row" id="dmg-chips">
<span class="k">Damage ×</span>
</div>
</div>
<div class="card">
<h3>Dimension</h3>
<p class="hint">Jumps as if you had just gone through that dimension's portal.</p>
<div class="row">
<select id="dim-sel"></select>
<button data-tip="run-dimension" onclick="send({kind:'dimension',dest:document.getElementById('dim-sel').value})">Jump</button>
</div>
</div>
</div>

(c) Spawn pane. Copy the old <!-- ── SPAWN ── --> pane from the T6 markup verbatim, with these attribute-only edits:

  • Add data-tip="spawn-count" to each count-chip button, data-tip="spawn-place" to each place-chip, and data-tip="spawn-elite" to #elite-toggle.
  • Add data-tip="spawn-custom" to the Spawn custom button and data-tip="spawn-save" to the Save as preset button.
  • Replace style="color:#8ab;font-size:.85em;width:72px" on the three row labels with class="k".
  • Wrap the pane’s first line with <h2>Spawn <small>enemies · bosses · custom builder</small></h2> directly inside <div class="pane" id="tab-spawn">.

(d) Build pane. It replaces both the old Player and Weapons panes.

<div class="pane" id="tab-build">
<h2>Build <small>weapons · player stats</small></h2>
<div class="card">
<h3>Weapons (P1)</h3>
<p class="hint">Grant ignores the slot cap. Dev levels go up only and apply the weapon's normal mod. Live survival progression comes from crystal rungs; the status level includes both.</p>
<div id="weapon-rows"></div>
</div>
<div class="card">
<h3>Player stats</h3>
<p class="hint">Written straight onto P1 for this run. Not saved.</p>
<div id="player-sliders"></div>
</div>
</div>

(e) Crystals pane. T9 rewrites it; for now:

<div class="pane" id="tab-crystals">
<h2>Crystals <small>the live survival progression input</small></h2>
<p class="hint">Crossing a rung applies that weapon's mod or evolution immediately, exactly as a picked crystal card would.</p>
<div id="crystal-rows"></div>
</div>

(f) Visuals, Perf and Settings panes.

  • Copy them from the T6 markup verbatim, in the order Visuals, Perf, Settings.
  • Add <h2>Visuals</h2>, <h2>Perf</h2> and <h2>Settings</h2> as each pane’s first child, and turn the inner cards’ <h2> into <h3>.
  • Add data-tip="visuals-bg" to the background Apply button and data-tip="visuals-tilt" to the tilt Apply button.
  • Replace the two label spans’ inline style with class="k".
  • Drop the old Readout pane entirely.

(g) Close main, then the live column and the tooltip shell:

</main>
<aside id="live-col" class="panel">
<div class="title" style="font-size:12px">Live</div>
<div><div class="lbl">FPS <span id="live-fps">–</span></div>
<svg viewBox="0 0 260 44" width="100%" height="44" aria-hidden="true"><line x1="0" x2="260" y1="6" y2="6" stroke="rgba(255,255,255,.15)" stroke-dasharray="3 4"/><polyline id="live-fps-line" fill="none" stroke="#33e6ff" stroke-width="2" points=""/></svg></div>
<div><div class="lbl">HP <span id="live-hp">–</span></div><div class="bar"><i id="live-hp-bar" class="hp"></i></div></div>
<div data-tip="live-xp"><div class="lbl">Level <span id="live-lv">–</span></div><div class="bar"><i id="live-xp-bar" class="xp"></i></div></div>
<div class="lbl">Gold <span id="live-gold">–</span></div>
<div class="lbl">Enemies <span id="live-enemies">–</span></div>
<div class="lbl">Run time <span id="live-time">–</span></div>
<div class="lbl">Area <span id="live-area">–</span></div>
<div><div class="lbl">Crystals</div><div id="live-gems" class="mini"></div></div>
<div><div class="lbl">Active</div><div id="live-flags" class="chips"></div></div>
<details data-tip="live-raw"><summary>raw status JSON</summary><pre id="status">(not connected)</pre></details>
</aside>
</div>
<div id="tip" role="tooltip"><h5 id="tip-h"></h5><div id="tip-body"></div><div id="tip-svg"></div><div id="tip-k" class="k"></div><div id="tip-warn" class="warn"></div></div>

The old #live div and the invuln checkbox are gone; the tiles replace them. Every <button in MARKUP carries a data-tip. T8’s test enforces this.

  • Step 5: Update client.js.

(a) Replace applyStatus, LIVE_FIELDS and renderLive:

function applyStatus(s){
// textContent only — status data is attacker-influenceable, never innerHTML
document.getElementById('status').textContent = JSON.stringify(s,null,1);
updateWeaponRows(s.weapons);
updateCrystalRows(s.crystals);
updatePerf(s.perf_scenario);
updateFlags(s.flags);
updateRunTiles(s);
renderLive(s);
}
// ── Live column ─────────────────────────────────────────────────
const LIVE_HIST = 60;
const SAFE_HEX = /^#[0-9a-fA-F]{6}$/;
let _fpsHist = [];
let _elColors = {};
function setText(id, v){ document.getElementById(id).textContent = String(v); }
function pct(n, d){ const r = d > 0 ? n / d : 0; return Math.max(0, Math.min(1, r)) * 100; }
function mmss(v){ return Math.floor(v/60)+':'+String(Math.floor(v%60)).padStart(2,'0'); }
function renderLive(s){
if(s.fps !== undefined){
const f = Number(s.fps)||0;
_fpsHist.push(f);
if(_fpsHist.length > LIVE_HIST) _fpsHist.shift();
setText('live-fps', Math.round(f));
const step = 260 / (LIVE_HIST - 1);
document.getElementById('live-fps-line').setAttribute('points', _fpsHist
.map((v,i)=>Math.round(i*step)+','+Math.round(42 - Math.max(0, Math.min(60, v)) / 60 * 36)).join(' '));
}
if(s.hp !== undefined){
setText('live-hp', Math.round(s.hp)+' / '+Math.round(s.hp_max||0));
document.getElementById('live-hp-bar').style.width = pct(Number(s.hp), Number(s.hp_max)) + '%';
}
if(s.level !== undefined) setText('live-lv', s.level);
if(s.xp !== undefined) document.getElementById('live-xp-bar').style.width = pct(Number(s.xp), Number(s.xp_to_next)) + '%';
if(s.gold !== undefined) setText('live-gold', s.gold);
if(s.run_time !== undefined) setText('live-time', mmss(s.run_time));
if(s.area !== undefined) setText('live-area', s.area);
if(s.alive){ let n = 0; for(const k in s.alive) n += Number(s.alive[k])||0; setText('live-enemies', n); }
renderLiveGems(s.crystals);
renderLiveFlags(s);
}
function renderLiveGems(states){
const wrap = document.getElementById('live-gems');
wrap.innerHTML='';
if(!Array.isArray(states)) return;
for(const c of states){
const sp = document.createElement('span');
const col = _elColors[String(c.id)];
if(col) sp.style.setProperty('--c', col);
sp.title = String(c.id);
const gem = document.createElement('i');
const n = document.createElement('b');
n.textContent = String(Number(c.have)||0); // textContent — server data
sp.appendChild(gem); sp.appendChild(n);
wrap.appendChild(sp);
}
}
let _toggleLabels = {};
function renderLiveFlags(s){
const wrap = document.getElementById('live-flags');
wrap.innerHTML='';
const on = [];
if(s.invuln) on.push('Invincible');
if(s.paused) on.push('Spawns paused');
if(s.no_xp) on.push('No XP');
if(s.timescale !== undefined && Number(s.timescale) !== 1) on.push('×'+s.timescale+' time');
const flags = s.flags || {};
for(const key in flags) if(key.indexOf('dev:') === 0 && flags[key] && _toggleLabels[key]) on.push(_toggleLabels[key]);
for(const t of on){
const c = document.createElement('span');
c.className = 'flag';
c.textContent = t; // textContent — labels come from caps
wrap.appendChild(c);
}
}
// ── Run toggle tiles ────────────────────────────────────────────
// One tile per boolean dev seam; state comes back on /status (invuln / paused / no_xp) and a key the
// game doesn't report leaves its tile alone. Same 1.8s write hold-off as the toggle rows.
const RUN_TOGGLES = {
invuln: { tile:'tile-invuln', status:'invuln', cmd:(on)=>({kind:'invuln',on}) },
pause: { tile:'tile-pause', status:'paused', cmd:(on)=>({kind:'pause_spawns',on}) },
noxp: { tile:'tile-noxp', status:'no_xp', cmd:(on)=>({kind:'no_xp',on}) },
};
let _runState = {};
function toggleRun(id){
const t = RUN_TOGGLES[id];
const el = document.getElementById(t.tile);
if(el.getAttribute('data-stale')) return;
const on = !_runState[id];
_runState[id] = on;
el.classList.toggle('on', on);
send(t.cmd(on));
}
function updateRunTiles(s){
if(Date.now() < _flagHoldUntil) return;
for(const id in RUN_TOGGLES){
const t = RUN_TOGGLES[id];
if(!(t.status in s)) continue;
_runState[id] = !!s[t.status];
document.getElementById(t.tile).classList.toggle('on', _runState[id]);
}
}
// A gated control (data-stale = the game predates the command) does nothing on click.
function act(el, cmd){
if(el && el.getAttribute && el.getAttribute('data-stale')) return;
send(cmd);
}
window.toggleRun = toggleRun;
window.act = act;

Hold-off ordering in the test. toggleRun calls send, and send sets _flagHoldUntil. In the “run toggle tiles” test, updateRunTiles runs first, so the hold-off doesn’t swallow it.

Backslash check. SAFE_HEX contains no backslash. [0-9a-fA-F] is a plain character class.

(b) Replace TABS, renderTabs and showTab:

const TABS = [['run','Run','▶'],['spawn','Spawn','✦'],['build','Build','⚔'],['crystals','Crystals','◆'],
['visuals','Visuals','◐'],['perf','Perf','⏱'],['settings','Settings','⚙']];
let _tabBtns = {};
function renderTabs(){
const nav = document.getElementById('tabs');
nav.innerHTML='';
_tabBtns = {};
for(const [id,label,icon] of TABS){
const b = document.createElement('button');
const i = document.createElement('span');
i.textContent = icon;
b.appendChild(i);
b.appendChild(document.createTextNode ? document.createTextNode(label) : Object.assign(document.createElement('span'), {textContent: label}));
b.onclick = ()=>showTab(id);
nav.appendChild(b);
_tabBtns[id] = b;
}
}
function showTab(id){
for(const [tid] of TABS){
// 'block', not '' — the panes are display:none from a STYLESHEET rule, so clearing the
// inline style would just fall back to that rule and hide the tab you just picked.
document.getElementById('tab-'+tid).style.display = (tid===id) ? 'block' : 'none';
if(_tabBtns[tid]) _tabBtns[tid].classList.toggle('active', tid===id);
}
}
window.showTab = showTab;

The fake document in panel.test.mjs has no createTextNode, so the code falls back to a span with textContent. Leave that guard in.

(c) In renderToggles, record labels for the live chips. After txt.textContent = String(item[1]);, add:

_toggleLabels[prefix+':'+id] = String(item[1]);

(d) In loadCaps, right after const caps = await r.json();, add:

_elColors = {};
for(const k in (caps.element_colors||{})){
const v = String(caps.element_colors[k]);
if(SAFE_HEX.test(v)) _elColors[String(k)] = v; // only a plain hex ever reaches a CSS property
}

(e) Delete the LIVE_FIELDS const and the old renderLive body. They are replaced above.

  • Step 6: Update the CSP. In control/src/worker.js:
const CSP = "default-src 'self'; script-src 'unsafe-inline'; style-src 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self'";
  • Step 7: Run the tests and confirm they pass. Run node --test control/test/*.test.mjs. Expected: everything passes. That includes panel-modules.test.mjs, so confirm there is no backslash or extra backtick in the new strings.

If any onclick="..." string needs an apostrophe inside a single-quoted JS string, rephrase it. Never escape.

  • Step 8: Commit with the message feat(console): neon mission-control layout + live column; CSP allows Google Fonts.

Files:

  • Create: control/src/panel/tooltips.js.
  • Modify: control/src/panel.html.js. Compose TOOLTIPS_JS before CLIENT, inside the same <script>.
  • Modify: control/src/panel/client.js.
    • Add tip(...) calls on created buttons: renderWeapons, renderSpawnGrid, renderDamageMults, renderFunzoLab, renderLooks and renderPerf.
    • Give renderToggles a descs param and data-desc.
    • Pass caps.descs in loadCaps.
  • Test: control/test/panel-tooltips.test.mjs (new). Also extend the loadPanel return list in panel.test.mjs with TOOLTIPS, tipContent.

Interfaces:

  • Produces: export const TOOLTIPS_JS: string. It defines these globals:

    • TOOLTIPS: {[id]: {icon, title, body, touches?, warn?}}
    • TIP_SVGS: {[id]: string}, constant markup
    • tip(el, id) -> el
    • tipContent(el) -> {icon,title,body,touches?,warn?}|null
    • showTip(el), hideTip() and wireTips()
  • Consumes: the caps key descs (T5); the data-tip attributes (T7); the tip ids crystal-minus, crystal-plus, crystal-ladder and crystal-clear, which T9’s code uses and which are defined here.

  • Step 1: Write the failing test. Create control/test/panel-tooltips.test.mjs. It reuses loadPanel, so first move fakeEl and loadPanel from panel.test.mjs into a new control/test/_panel-harness.mjs:

    • Export both.
    • Have panel.test.mjs import them.
    • node --test control/test/*.test.mjs does not pick up _panel-harness.mjs, because it doesn’t match *.test.mjs.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { MARKUP } from '../src/panel/markup.js';
import { CLIENT } from '../src/panel/client.js';
import { loadPanel, fakeEl } from './_panel-harness.mjs';
test('every data-tip / tip() id has a TOOLTIPS entry and every entry is used', () => {
const { api } = loadPanel();
const used = new Set();
for (const m of MARKUP.matchAll(/data-tip="([a-z0-9-]+)"/g)) used.add(m[1]);
for (const m of CLIENT.matchAll(/\btip\([^,()]+,\s*'([a-z0-9-]+)'\)/g)) used.add(m[1]);
const keys = new Set(Object.keys(api.TOOLTIPS));
assert.deepEqual([...used].filter((k) => !keys.has(k)).sort(), [], 'data-tip with no TOOLTIPS entry');
assert.deepEqual([...keys].filter((k) => !used.has(k)).sort(), [], 'TOOLTIPS entry nothing uses');
});
test('every button in the static markup carries a data-tip', () => {
const bare = [...MARKUP.matchAll(/<button\b[^>]*>/g)].map((m) => m[0]).filter((t) => !t.includes('data-tip='));
assert.deepEqual(bare, []);
});
test('every TOOLTIPS entry has an icon, title and body, and SVG keys are real tooltips', () => {
const { api } = loadPanel();
for (const [id, t] of Object.entries(api.TOOLTIPS)) {
assert.ok(t.icon && t.title && t.body, id);
}
for (const id of Object.keys(api.TIP_SVGS)) assert.ok(id in api.TOOLTIPS, 'svg for unknown tip ' + id);
});
test('a caps roster row with a desc gets a tooltip built from text, never markup', () => {
const { api } = loadPanel();
const el = fakeEl('label');
el.setAttribute('data-desc', '<img src=x onerror=alert(1)>');
el.setAttribute('data-title', 'Bloom');
const t = api.tipContent(el);
assert.equal(t.title, 'Bloom');
assert.equal(t.body, '<img src=x onerror=alert(1)>', 'carried as a string; showTip writes it with textContent');
});
test('a stale control shows the needs-newer-build caveat', () => {
const { api } = loadPanel();
const el = fakeEl('button');
el.setAttribute('data-tip', 'run-noxp');
el.setAttribute('data-stale', '1');
assert.match(api.tipContent(el).warn, /newer game build/);
});
test('renderToggles tags rows with their caps desc', () => {
const { api, els } = loadPanel();
api.renderToggles('toggle-gfx', [['bloom', 'Bloom']], 'gfx', (id, on) => ({ kind: 'graphics', effect: id, on }),
{ bloom: 'World glow.' });
const row = els['toggle-gfx'].children[0];
assert.equal(row.getAttribute('data-desc'), 'World glow.');
assert.equal(row.getAttribute('data-title'), 'Bloom');
});

Add TOOLTIPS, TIP_SVGS, tipContent to loadPanel’s return list.

  • Step 2: Run it and confirm it fails. Run node --test control/test/*.test.mjs. Expected: TOOLTIPS undefined, and renderToggles doesn’t set data-desc.

  • Step 3: Create control/src/panel/tooltips.js. The string holds no backslash, no ${ and no backtick.

// Tooltip copy + popover (string module). Panel-defined controls carry data-tip="<id>" (static
// markup) or get tip(el,'<id>') (client-built); caps-driven roster rows carry data-desc from
// caps.descs. control/test/panel-tooltips.test.mjs fails on an id with no entry or an entry nothing
// uses. Every string is written with textContent; TIP_SVGS is constant panel markup, never server data.
export const TOOLTIPS_JS = `
const TOOLTIPS = {
'connect': {icon:'⇄', title:'Connect', body:'Pairs this browser with the game showing that code (Pause → Remote Control) and loads its controls.', touches:'Nothing in the game'},
'run-invuln': {icon:'🛡', title:'Invincible', body:'The ship takes no damage.', touches:'Player HP'},
'run-pause': {icon:'⏸', title:'Pause spawns', body:'Stops the spawn director adding enemies. Enemies already in the arena stay.', touches:'Normal enemy spawns'},
'run-noxp': {icon:'⊘', title:'No XP', body:'Kills and gems award no XP. Your level and the XP bar freeze where they are.', touches:'XP from kills and gems, level-up cards', warn:'Console Level up, Grant and Crystals still change your build.'},
'run-clear': {icon:'🧹', title:'Clear arena', body:'Removes every enemy, enemy shot, bomb and hazard zone right now.', touches:'Enemies and arena hazards. Gems stay.'},
'run-levelup': {icon:'⬆', title:'Level up', body:'Queues one level-up card pick, as if you had just levelled.', touches:'Pending level-ups (no XP needed)'},
'run-levelup5': {icon:'⇈', title:'5 level-ups', body:'Queues five level-up card picks.', touches:'Pending level-ups (no XP needed)'},
'run-gold': {icon:'✦', title:'+1000 gold', body:'Adds 1000 gold to this run. It banks into the meta shop at game over.', touches:'Run gold'},
'run-reset-upgrades': {icon:'↺', title:'Reset upgrades', body:'Wipes this run back to a fresh build: weapons, stat mods, crystal wallet, level and HP. The arena is kept.', touches:'Your build only', warn:'Cannot be undone.'},
'run-reset-all': {icon:'⟲', title:'Reset everything', body:'Reset upgrades, then clear the arena and its gems, in one command so nothing hits the fresh ship first.', touches:'Build, enemies, gems. Run gold, dev toggles and the run clock are kept.', warn:'Cannot be undone.'},
'run-timescale': {icon:'⏩', title:'Time scale', body:'Runs more or fewer fixed 60 Hz sim ticks per frame, so the whole game speeds up or slows down.', touches:'Game speed'},
'run-damage': {icon:'✸', title:'Damage multiplier', body:'Multiplies all damage the player deals.', touches:'Player damage out'},
'run-dimension': {icon:'◎', title:'Jump to dimension', body:'Jumps as if you had just gone through that dimension portal.', touches:'Current area'},
'spawn-count': {icon:'#', title:'Spawn count', body:'How many enemies each spawn button adds.'},
'spawn-place': {icon:'⌖', title:'Placement', body:'Where spawns appear: a ring around the ship, ahead of it, or random.'},
'spawn-elite': {icon:'★', title:'Elite', body:'Spawns the elite variant of the chosen enemy.'},
'spawn-enemy': {icon:'✦', title:'Spawn enemy', body:'Spawns this enemy with the count, placement and elite settings above.'},
'spawn-boss': {icon:'☠', title:'Spawn boss', body:'Spawns this boss now.'},
'spawn-custom': {icon:'⚡', title:'Spawn custom', body:'Spawns an enemy built from the shape, movement, attack and stats above.'},
'spawn-save': {icon:'💾', title:'Save as preset', body:'Adds the custom enemy as a preset button for this browser session.'},
'build-grant': {icon:'+', title:'Grant weapon', body:'Gives the ship this weapon, ignoring the slot cap.', touches:'Active weapons'},
'build-remove': {icon:'-', title:'Remove weapon', body:'Takes this weapon off the ship.', touches:'Active weapons'},
'build-level': {icon:'⬆', title:'Dev level', body:'Applies this weapon normal level mod that many times. Up only.', touches:'Weapon stats and level'},
'crystal-minus': {icon:'◇', title:'Remove crystals', body:'Takes crystals out of the wallet. Dropping below a rung undoes that rung mod or evolution.', touches:'Wallet, and every weapon on a lost rung', warn:'A rolled-back weapon is rebuilt: its shots in flight, orbit phase, turrets and cooldowns reset.'},
'crystal-plus': {icon:'◆', title:'Add crystals', body:'Adds crystals to the wallet. Crossing a rung applies that weapon mod or evolution at once, as a picked crystal card would.', touches:'Wallet, weapons on crossed rungs'},
'crystal-ladder': {icon:'┃', title:'Rung ladder', body:'Each tick is a rung on some weapon. A lit rung is met by the wallet; it only applies to weapons you own.'},
'crystal-clear': {icon:'⌫', title:'Clear all crystals', body:'Empties every element to 0 and rolls back every crystal rung.', touches:'Wallet and every rung-upgraded weapon', warn:'Rolled-back weapons are rebuilt and lose their live state.'},
'visuals-bg': {icon:'◐', title:'Background', body:'Swaps the arena background variant.'},
'visuals-tilt': {icon:'⟋', title:'Ship tilt', body:'Changes how the ship banks as it turns.'},
'visuals-look': {icon:'◑', title:'Weapon look', body:'Switches this weapon between its candidate looks. Letters keep a blind comparison blind.'},
'funzo-treatment': {icon:'✺', title:'Funzo treatment', body:'Attaches this VFX treatment to the Funzo sandbox.'},
'perf-run': {icon:'⏱', title:'Run perf scenario', body:'Runs on the device: resets P1, clears and pauses spawns, grants the preset, then alternates hiding the arm. Results land below and in D1.', warn:'Resets your build.'},
'perf-cancel': {icon:'■', title:'Cancel', body:'Stops a running perf scenario.'},
'live-xp': {icon:'⬆', title:'Level and XP', body:'Your level and the progress to the next. Freezes while No XP is on.'},
'live-raw': {icon:'{}', title:'Raw status', body:'The full status the game last sent, about once a second.'},
};
const TIP_SVGS = {
'run-noxp': '<svg viewBox="0 0 250 26" width="250" height="26"><rect x="0" y="9" width="250" height="8" rx="4" fill="rgba(255,255,255,.08)"/><rect x="0" y="9" width="140" height="8" rx="4" fill="#b3ff4d"/><text x="146" y="17" fill="#ff525c" font-size="12" font-family="JetBrains Mono">✕ frozen</text></svg>',
'run-reset-all': '<svg viewBox="0 0 250 30" width="250" height="30"><circle cx="20" cy="15" r="9" fill="none" stroke="#33e6ff" stroke-width="2"/><text x="36" y="19" fill="#d9f7ff" font-size="11" font-family="JetBrains Mono">build</text><text x="84" y="19" fill="#99b8cc" font-size="11">+</text><circle cx="104" cy="15" r="5" fill="#ff525c"/><circle cx="118" cy="11" r="4" fill="#ff525c"/><text x="130" y="19" fill="#d9f7ff" font-size="11" font-family="JetBrains Mono">enemies + gems</text></svg>',
'crystal-minus': '<svg viewBox="0 0 250 30" width="250" height="30"><rect x="0" y="12" width="250" height="6" rx="3" fill="rgba(255,255,255,.08)"/><rect x="0" y="12" width="90" height="6" rx="3" fill="#33e6ff"/><rect x="88" y="7" width="3" height="16" fill="#fff"/><rect x="160" y="7" width="3" height="16" fill="#ff525c"/><text x="168" y="27" fill="#ff525c" font-size="10" font-family="JetBrains Mono">undone</text></svg>',
'run-timescale': '<svg viewBox="0 0 250 26" width="250" height="26"><g fill="#33e6ff"><rect x="0" y="8" width="6" height="10"/><rect x="40" y="8" width="6" height="10"/><rect x="80" y="8" width="6" height="10"/></g><g fill="#ffd64d"><rect x="130" y="8" width="6" height="10"/><rect x="145" y="8" width="6" height="10"/><rect x="160" y="8" width="6" height="10"/><rect x="175" y="8" width="6" height="10"/><rect x="190" y="8" width="6" height="10"/><rect x="205" y="8" width="6" height="10"/></g><text x="96" y="17" fill="#99b8cc" font-size="11">→</text></svg>',
};
const STALE_WARN = 'Needs a newer game build: this one predates the command, so the control is off.';
let _tipTimer = 0;
function tip(el, id){ el.setAttribute('data-tip', id); return el; }
function tipContent(el){
const id = el.getAttribute('data-tip');
let t = id ? TOOLTIPS[id] : null;
if(!t){
const d = el.getAttribute('data-desc');
if(!d) return null;
t = {icon:'ⓘ', title: el.getAttribute('data-title') || '', body: d};
}
if(el.getAttribute('data-stale')) t = Object.assign({}, t, {warn: STALE_WARN});
return t;
}
function tipTarget(node){
return node && node.closest ? node.closest('[data-tip],[data-desc]') : null;
}
function showTip(el){
const t = tipContent(el);
if(!t) return;
const box = document.getElementById('tip');
document.getElementById('tip-h').textContent = t.icon + ' ' + t.title;
document.getElementById('tip-body').textContent = t.body;
document.getElementById('tip-svg').innerHTML = TIP_SVGS[el.getAttribute('data-tip')] || '';
document.getElementById('tip-k').textContent = t.touches ? 'Touches: ' + t.touches : '';
document.getElementById('tip-warn').textContent = t.warn || '';
box.classList.add('show');
const r = el.getBoundingClientRect();
const w = box.offsetWidth || 290;
const h = box.offsetHeight || 160;
box.style.left = Math.max(8, Math.min(window.innerWidth - w - 8, r.left)) + 'px';
box.style.top = (r.bottom + 8 + h > window.innerHeight ? Math.max(8, r.top - h - 8) : r.bottom + 8) + 'px';
}
function hideTip(){
clearTimeout(_tipTimer);
const box = document.getElementById('tip');
if(box) box.classList.remove('show');
}
function wireTips(){
document.addEventListener('mouseover', (e)=>{ const el = tipTarget(e.target); if(el) showTip(el); else hideTip(); });
document.addEventListener('mouseout', (e)=>{ if(!tipTarget(e.relatedTarget)) hideTip(); });
document.addEventListener('touchstart', (e)=>{
const el = tipTarget(e.target);
clearTimeout(_tipTimer);
if(el) _tipTimer = setTimeout(()=>showTip(el), 400);
}, {passive:true});
document.addEventListener('touchend', ()=>{ clearTimeout(_tipTimer); setTimeout(hideTip, 1500); });
document.addEventListener('touchmove', hideTip, {passive:true});
document.addEventListener('scroll', hideTip, true);
}
wireTips();
`;

Copy note: apostrophes are avoided inside the single-quoted JS strings (“dimension portal”, “this weapon normal level mod”) so nothing needs escaping. If a reviewer wants the possessive back, switch that string to double quotes. Never use \'.

  • Step 4: Compose and tag.

(a) In control/src/panel.html.js, add import { TOOLTIPS_JS } from './panel/tooltips.js'; and change the script part to '<script>\n' + TOOLTIPS_JS + CLIENT + '</script></body></html>'.

(b) Tag client-built buttons. The literal-id form tip(x,'id') is what the coverage regex reads, so always pass a string literal:

  • In renderWeapons: tip(grant,'build-grant');, tip(remove,'build-remove');, and inside the LEVEL_STEPS loop, tip(b,'build-level');. Remove the old b.title = ... line; the tooltip replaces it.
  • In renderSpawnGrid: tip(btn,'spawn-enemy'); in the presets loop and tip(btn,'spawn-boss'); in the bosses loop.
  • In renderDamageMults: tip(b,'run-damage');.
  • In renderFunzoLab: tip(btn,'funzo-treatment');.
  • In renderLooks: tip(b,'visuals-look');.
  • In renderPerf: tip(run,'perf-run'); and tip(stop,'perf-cancel');.
  • In saveCustomPreset: tip(btn,'spawn-enemy');.

(c) Give renderToggles a descs param:

function renderToggles(wrapId, items, prefix, cmdFor, descs){

After row.className='tog';, add:

const d = descs && descs[id];
if(d){ row.setAttribute('data-desc', String(d)); row.setAttribute('data-title', String(item[1])); }

(d) In loadCaps, pass (caps.descs||{}).gfx, .bus, .touch and .dev as the 5th argument to the four renderToggles calls.

  • Step 5: Run the tests and confirm they pass. Run node --test control/test/*.test.mjs. Expected: everything passes. If the coverage test lists an id, add the entry or fix the typo. Never delete the test.

  • Step 6: Commit with the message feat(console): hover/long-press tooltips with full coverage test.


Task 9: Crystals card, new Run tiles gating, stale-build disabling

Section titled “Task 9: Crystals card, new Run tiles gating, stale-build disabling”

Files:

  • Modify: control/src/panel/markup.js. Crystals pane header gains the Clear all crystals tile.
  • Modify: control/src/panel/client.js. Rewrite renderCrystals / updateCrystalRows, add applyCapsGates, and update loadCaps.
  • Test: control/test/panel.test.mjs. Replace the two crystal tests and add the gating tests.

Interfaces:

  • Consumes these caps keys: crystal_rungs, element_colors and dev_commands (T5).

  • Consumes the dispatch arms remove_crystals / clear_crystals (T4) and reset_all (T2).

  • Consumes act(), SAFE_HEX and _elColors (T7), and tip() (T8).

  • Produces: renderCrystals(elements, rungs, colors, canRemove) and applyCapsGates(caps).

  • Card children order: [hd, ladder, rungs, steps, state].

    • hd.children = [gem, nm, ct].
    • steps.children = [−10, −5, −1, sp, +1, +5, +10], or [sp, +1, +5, +10] when removal is unsupported.
  • Step 1: Write the failing tests. In control/test/panel.test.mjs, replace 'crystal rows come from caps and grant through add_crystals' and 'crystal rows show the wallet and the next rung from status' with:

const RUNGS = { kinetic: [
{ amount: 5, wid: 'blaster', weapon_name: 'Blaster', kind: 'power', evolve: false, effect: 'Power' },
{ amount: 12, wid: 'blaster', weapon_name: 'Blaster', kind: 'multishot', evolve: false, effect: '+Shot' },
] };
const cardSteps = (card) => card.children[3].children.filter((b) => b.textContent);
test('crystal cards come from caps with − and + steps that send remove/add', async () => {
const { api, els, sent } = loadPanel();
api.renderCrystals(['kinetic', 'void'], RUNGS, { kinetic: '#c9d1d9', void: 'red;}' }, true);
const [kinetic, voidCard] = els['crystal-rows'].children;
assert.deepEqual(cardSteps(kinetic).map((b) => b.textContent), ['−10', '−5', '−1', '+1', '+5', '+10']);
assert.equal(kinetic.style['--c'], '#c9d1d9');
assert.equal(voidCard.style['--c'], undefined, 'a non-hex colour never reaches CSS');
cardSteps(kinetic).find((b) => b.textContent === '−5').onclick();
cardSteps(kinetic).find((b) => b.textContent === '+10').onclick();
await new Promise((r) => setImmediate(r));
assert.deepEqual(sent, [
{ kind: 'remove_crystals', element: 'kinetic', n: 5 },
{ kind: 'add_crystals', element: 'kinetic', n: 10 },
]);
});
test('an older game without remove support gets + only', () => {
const { api, els } = loadPanel();
api.renderCrystals(['kinetic'], RUNGS, {}, false);
assert.deepEqual(cardSteps(els['crystal-rows'].children[0]).map((b) => b.textContent), ['+1', '+5', '+10']);
});
test('the rung ladder lights met rungs and labels each with weapon and effect', () => {
const { api, els } = loadPanel();
api.renderCrystals(['kinetic'], RUNGS, {}, true);
api.updateCrystalRows([{ id: 'kinetic', have: 6, need: 12 }]);
const card = els['crystal-rows'].children[0];
assert.equal(card.children[0].children[2].textContent, '6');
const chips = card.children[2].children;
assert.deepEqual(chips.map((c) => c.textContent), ['5 · Blaster Power', '12 · Blaster +Shot']);
assert.equal(chips[0].classList.contains('met'), true);
assert.equal(chips[1].classList.contains('met'), false);
assert.equal(card.children[1].children[0].style.width, '50%', 'fill = have / top rung');
assert.match(card.children[4].textContent, /have 6 · next rung 12 \(\+6\)/);
});
test('caps gates disable commands an older game lacks', () => {
const { api, els } = loadPanel();
api.applyCapsGates({});
assert.equal(els['tile-noxp'].getAttribute('data-stale'), '1');
assert.equal(els['tile-reset-all'].getAttribute('data-stale'), '1');
assert.equal(els['tile-clear-crystals'].getAttribute('data-stale'), '1');
api.applyCapsGates({ dev_commands: ['no_xp', 'reset_all', 'remove_crystals', 'clear_crystals'] });
assert.equal(els['tile-noxp'].getAttribute('data-stale'), null);
assert.equal(els['tile-clear-crystals'].getAttribute('data-stale'), null);
});
test('a stale tile does not send', async () => {
const { api, els, sent } = loadPanel();
api.applyCapsGates({});
api.toggleRun('noxp');
api.act(els['tile-reset-all'], { kind: 'reset_all' });
await new Promise((r) => setImmediate(r));
assert.deepEqual(sent, []);
});

Add applyCapsGates to the loadPanel return list in _panel-harness.mjs.

  • Step 2: Run it and confirm it fails. Run node --test control/test/*.test.mjs.

  • Step 3: Implement.

(a) In markup.js, replace the Crystals pane from T7 with:

<div class="pane" id="tab-crystals">
<h2>Crystals <small>the live survival progression input</small></h2>
<div class="tiles">
<button class="tile danger" id="tile-clear-crystals" data-tip="crystal-clear" onclick="act(this,{kind:'clear_crystals'})"><b>⌫</b>Clear all crystals</button>
</div>
<p class="hint">Crossing a rung applies that weapon's mod or evolution immediately, exactly as a picked crystal card would. Dropping below a rung undoes it.</p>
<div id="crystal-rows"></div>
</div>

(b) In client.js, replace the whole Crystals block (CRYSTAL_STEPS, renderCrystals, updateCrystalRows):

// ── Crystals ────────────────────────────────────────────────────
// One card per element in its game colour (caps.element_colors, hex-validated), −/+ steps, and a
// rung ladder from caps.crystal_rungs: ticks on the bar, "amount · weapon effect" chips below
// (lit when the wallet meets them). Everything server-supplied is written with textContent.
const CRYSTAL_STEPS = [1, 5, 10];
let _crystalRows = {};
function renderCrystals(elements, rungs, colors, canRemove){
const wrap = document.getElementById('crystal-rows');
wrap.innerHTML='';
_crystalRows = {};
rungs = rungs || {};
colors = colors || {};
for(const el of elements){
const id = String(el);
const card = document.createElement('div');
card.className = 'el';
const col = String(colors[id] || '');
if(SAFE_HEX.test(col)) card.style.setProperty('--c', col);
const hd = document.createElement('div'); hd.className = 'hd';
const gem = document.createElement('div'); gem.className = 'gem';
const nm = document.createElement('span'); nm.className = 'nm'; nm.textContent = id;
const ct = document.createElement('span'); ct.className = 'ct'; ct.textContent = '–';
hd.appendChild(gem); hd.appendChild(nm); hd.appendChild(ct);
const list = Array.isArray(rungs[id]) ? rungs[id] : [];
const top = list.reduce((m, r)=>Math.max(m, Number(r.amount)||0), 0) || 1;
const ladder = document.createElement('div'); ladder.className = 'ladder';
tip(ladder,'crystal-ladder');
const fill = document.createElement('i');
ladder.appendChild(fill);
const chipWrap = document.createElement('div'); chipWrap.className = 'rungs';
const ticks = [];
for(const r of list){
const amt = Number(r.amount)||0;
const s = document.createElement('s');
s.style.left = (amt / top * 100) + '%';
ladder.appendChild(s);
const c = document.createElement('span'); c.className = 'rung';
c.textContent = amt + ' · ' + String(r.weapon_name || r.wid) + ' ' + String(r.effect || r.kind);
chipWrap.appendChild(c);
ticks.push({amt, s, c});
}
const steps = document.createElement('div'); steps.className = 'steps';
if(canRemove){
for(const n of [10, 5, 1]){
const b = document.createElement('button');
b.className = 'm';
b.textContent = '−' + n;
tip(b,'crystal-minus');
b.onclick = ()=>send({kind:'remove_crystals',element:id,n});
steps.appendChild(b);
}
}
const sp = document.createElement('span'); sp.className = 'sp';
steps.appendChild(sp);
for(const n of CRYSTAL_STEPS){
const b = document.createElement('button');
b.textContent = '+' + n;
tip(b,'crystal-plus');
b.onclick = ()=>send({kind:'add_crystals',element:id,n});
steps.appendChild(b);
}
const state = document.createElement('div'); state.className = 'wep-state';
state.textContent = 'waiting for status…';
card.appendChild(hd); card.appendChild(ladder); card.appendChild(chipWrap); card.appendChild(steps); card.appendChild(state);
wrap.appendChild(card);
_crystalRows[id] = {card, ct, fill, ticks, top, state};
}
}
function updateCrystalRows(states){
if(!Array.isArray(states)) return;
for(const s of states){
const r = _crystalRows[String(s.id)];
if(!r) continue;
const have = Number(s.have)||0;
const need = Number(s.need)||0;
r.ct.textContent = String(have);
r.fill.style.width = Math.min(have / r.top, 1) * 100 + '%';
for(const t of r.ticks){
const met = have >= t.amt;
t.s.classList.toggle('met', met);
t.c.classList.toggle('met', met);
}
r.state.textContent = 'have ' + have + (need ? ' · next rung ' + need + ' (+' + (need-have) + ')' : ' · no rung ahead');
r.card.classList.toggle('on', have > 0);
}
}
// ── Stale-build gating ──────────────────────────────────────────
// caps.dev_commands lists the command kinds a console must feature-detect. A game that predates
// one (key or entry missing) gets that control marked data-stale: dimmed, inert, and its tooltip
// says why -- instead of a button that silently does nothing (the "empty section = stale build" trap).
const GATED = [['tile-noxp','no_xp'],['tile-reset-all','reset_all'],['tile-clear-crystals','clear_crystals']];
function applyCapsGates(caps){
const have = Array.isArray(caps.dev_commands) ? caps.dev_commands.map(String) : [];
for(const [id, kind] of GATED){
const el = document.getElementById(id);
if(have.indexOf(kind) === -1) el.setAttribute('data-stale', '1');
else el.removeAttribute('data-stale');
}
return have.indexOf('remove_crystals') !== -1;
}

(c) In loadCaps, replace renderCrystals(caps.crystal_elements||[]); with:

const canRemove = applyCapsGates(caps);
renderCrystals(caps.crystal_elements||[], caps.crystal_rungs||{}, _elColors, canRemove);

Keep these after the _elColors block from T7, so the colours are already validated.

The (+' + (need-have) + ')' text contains no backslash. In the test, the regex \(\+6\) lives in the .mjs file, not in a string module.

  • Step 4: Run the tests and confirm they pass. Run node --test control/test/*.test.mjs. Expected: everything passes.

  • Step 5: Commit with the message feat(console): crystal cards with rung ladder and removal; stale-build gating.


Files:

  • Create (scratch, NOT committed): $TMPDIR/console-stub.mjs.
  • Modify: docs/architecture/dev-tools.md. Add a v2 bullet to § Remote Playtest Console, after the “Candidate looks” bullet.
  • Modify: docs/RELEASE-1.0.1.md. Tick the T0 entry.

Interfaces:

  • Consumes everything from T1-T9.

  • Step 1: Write the stub server. Write it to $TMPDIR/console-stub.mjs with the worktree path filled in. It uses the real worker.js, so the CSP is exercised too.

import http from 'node:http';
import worker from '/ABS/PATH/.claude/worktrees/console-v2/control/src/worker.js';
const caps = {
build: 999, weapons: [{ id: 'blaster', level_kind: 'power' }, { id: 'nova', level_kind: 'radius' }],
crystal_elements: ['fire', 'kinetic', 'void'],
element_colors: { fire: '#ff6a4d', kinetic: '#c9d1d9', void: '#b06aff' },
crystal_rungs: {
kinetic: [{ amount: 5, wid: 'blaster', weapon_name: 'Blaster', kind: 'power', evolve: false, effect: 'Power' },
{ amount: 10, wid: 'aim', weapon_name: 'Rift Bolt', kind: 'pierce', evolve: false, effect: 'Pierce' },
{ amount: 12, wid: 'blaster', weapon_name: 'Blaster', kind: 'multishot', evolve: false, effect: '+Shot' }],
void: [{ amount: 18, wid: 'blaster', weapon_name: 'Blaster', kind: 'evolve', evolve: true, effect: 'EVOLVES' }],
fire: [],
},
descs: { gfx: { bloom: 'World glow: the bloom pass on the WorldEnvironment.' }, bus: {}, touch: {}, dev: {} },
dev_commands: ['no_xp', 'reset_all', 'remove_crystals', 'clear_crystals'],
graphics_toggles: [['bloom', 'Bloom'], ['halos', 'Halos']], audio_buses: [['Music', 'Music']],
touch_toggles: [], dev_flag_toggles: [['perf_bench', 'Dev: perf bench (next launch)']],
presets: ['swarmer', 'brute'], bosses: ['warden'], damage_mults: [1, 2, 5], dimensions: [{ id: 'home', name: 'Home' }],
backgrounds: ['grid'], tilt_modes: ['bank'], weapon_looks: [{ wid: 'pulse', looks: ['v1', 'A'] }],
perf_presets: [{ name: 'light' }], perf_arms: ['none'], shapes: [], behaviors: [], attacks: [], player_stats: [], funzo_treatments: [],
};
let t = 0;
const status = () => ({ code: 'AB12CD', fps: 55 + 5 * Math.sin(t++ / 3), run_time: 252, hp: 84, hp_max: 100, level: 12,
xp: 56, xp_to_next: 100, no_xp: true, invuln: true, paused: false, timescale: 2, gold: 2340, area: 'The Chasm',
alive: { swarmer: 140, brute: 3 }, weapons: [{ id: 'blaster', name: 'Blaster', active: true, level: 3, evolved: false }],
crystals: [{ id: 'fire', have: 0, need: 0 }, { id: 'kinetic', have: 12, need: 0 }, { id: 'void', have: 7, need: 18 }],
flags: { 'gfx:bloom': true, 'dev:perf_bench': false }, perf_scenario: null });
http.createServer(async (req, res) => {
const u = new URL(req.url, 'http://127.0.0.1:8789');
let body; let type = 'application/json'; let headers = {};
if (u.pathname === '/') {
const r = await worker.fetch(new Request(u.href), { CONTROL_KEY: 'k' });
body = await r.text(); type = 'text/html'; headers = { 'Content-Security-Policy': r.headers.get('Content-Security-Policy') };
} else if (u.pathname === '/caps') body = JSON.stringify(caps);
else if (u.pathname === '/status') body = JSON.stringify(status());
else if (u.pathname === '/cmd') { let b = ''; for await (const c of req) b += c; console.log('CMD', b); body = '{}'; }
else { res.writeHead(404); return res.end(); }
res.writeHead(200, { 'Content-Type': type, ...headers });
res.end(body);
}).listen(8789, () => console.log('stub on http://127.0.0.1:8789/?key=k'));

Run it with the Bash tool’s run_in_background: node "$TMPDIR/console-stub.mjs".

  • Step 2: Drive the page in a real browser.
    • Prefer the Playwright MCP tools (mcp__playwright__browser_navigate, browser_type, browser_click, browser_hover, browser_take_screenshot, browser_resize and browser_console_messages). Load them via ToolSearch.
    • If Playwright is unavailable, use headless Chrome for the layout screenshots:
      Terminal window
      "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome" --headless=new --window-size=1440,900 --screenshot="$TMPDIR/c-1440.png" "http://127.0.0.1:8789/?key=k"
      Static screenshots only show the gate before Connect, so the interaction checks need Playwright.

Checklist. Each item is pass/fail, and every one must pass:

  1. Navigate to http://127.0.0.1:8789/?key=k, type AB12CD in #code and click Connect. Expected: #pairing-status reads connected ✓ build 999.
  2. At 1440×900, take a screenshot. Expected:
    • three columns: icon sidebar, workspace and live column;
    • the Orbitron title is rendered, so fonts loaded under the CSP;
    • no console CSP errors (browser_console_messages).
  3. Click through each of the 7 sidebar sections. Expected: each shows rows and tiles. No empty section.
  4. Run tab:
    • Expected: the Invincible and No XP tiles show ON after the first poll.
    • Click Reset everything. The stub logs CMD … "kind":"reset_all".
  5. Crystals tab. Expected:
    • the kinetic card shows 12 and all 3 rung chips lit;
    • the void card shows 7 with 18 · Blaster EVOLVES unlit.
    • Click −5 on kinetic; the stub logs remove_crystals.
  6. Hover the No XP tile. Expected: the popover shows the title, body, the frozen-bar SVG, the Touches line and the amber caveat. Hover the Bloom toggle on Visuals; expected: its caps desc shows.
  7. Live column, over at least 5 s. Expected:
    • the FPS sparkline grows;
    • the HP bar is at 84%;
    • the Level bar is at 56%;
    • three crystal mini-gems appear in fire, kinetic and void colours;
    • the chips read Invincible, No XP and ×2 time;
    • the raw JSON drawer opens.
  8. Resize to 390×844 and screenshot. Expected:
    • the live strip sits under the header;
    • the sidebar is a horizontal icon row;
    • there is no horizontal page scroll (document.documentElement.scrollWidth <= 390 via browser_evaluate).
  9. Stale build: change the stub’s dev_commands to [], restart it and reconnect. Expected:
    • the No XP, Reset everything and Clear all crystals tiles are dimmed and send nothing;
    • their tooltips show the needs-newer-build line;
    • the crystal cards have no − buttons.
  10. Put the 1440 screenshot side by side with .superpowers/brainstorm/25548-1790883350/content/neon-look.html, opened in the same browser. Expected: the same palette, panel style and tile shape.

Stop the stub afterwards with TaskStop. If any check fails, fix it in the owning task’s files, re-run node --test control/test/*.test.mjs, and re-check.

  • Step 3: Run the full gate. Run scripts/gate.sh. Expected: green. Then run git status. If the gate left project.godot or audio/bus_layout.tres dirty, restore them.

  • Step 4: Update the docs.

In docs/architecture/dev-tools.md § Remote Playtest Console, add after the “Candidate looks (2026-10-01)” bullet:

- **Console v2 (2026-10-01): No XP, Reset everything, crystal rollback, neon mission-control panel.**
`Sim.dev_no_xp` gates `Sim._bank_xp` (kills + gems award nothing; console Level up/Grant/Crystals
still work), also a Debug Settings row. `reset_all` = the `reset_upgrades` body (`main._dev_reset_build`)
+ `dev_clear_enemies` + `dev_clear_gems` in one command. `remove_crystals`/`clear_crystals` really
undo rungs: every external weapon mutation appends to `PilotArsenal.mod_log` (src card/dev/rung:<i>/evolve),
and `Sim._roll_back_unmet_rungs` erases unmet `thresholds_done` keys then `PilotArsenal.rebuild_weapon`s
the weapon from `_construct` (the ONE wid→member match) replaying the log minus those rungs -- the
weapon's live state (cooldowns, shots in flight, orbit phase, turrets, zones) is lost by design.
No render/ui node caches a weapon object, so no rebuild event exists. New caps: `element_colors`,
`crystal_rungs` (with `effect` labels), `descs` (`DebugSettingsPanel.*_DESCS`), `dev_commands`
(feature-detect list; the panel marks absent ones `data-stale`). Status adds `no_xp`, `xp`, `xp_to_next`.
Panel source is now string modules `control/src/panel/{styles,markup,client,tooltips}.js` composed
by `panelHtml()` (no backtick/`${`/backslash inside -- `panel-modules.test.mjs`); every `data-tip`
/ `tip(el,'id')` must have a `TOOLTIPS` entry and vice versa (`panel-tooltips.test.mjs`). CSP now
allows Google Fonts (`fonts.googleapis.com` styles, `fonts.gstatic.com` fonts). Real-browser check:
the stub-server recipe in plan `docs/superpowers/plans/2026-10-01-remote-console-v2.md` T10.

In docs/RELEASE-1.0.1.md, change the T0 entry’s - [ ] to - [x] and append ✅ DONE (<merge sha>) at the end of its last line. Use the real sha after the merge, in a follow-up docs commit if needed.

Update memory bullet-heaven-remote-playtest-console’s verification recipe so it points at the T10 stub (Playwright MCP / headless Chrome, not agent-browser). This edit happens outside the repo, in the session that runs the plan.

  • Step 5: Commit with the message docs(console): v2 architecture notes; tick RELEASE-1.0.1 item.

  • Step 6: Deliver.

    • After the reviewer gate, merge console-v2 into main and push at once.
    • The Worker deploy and the device dev build are outward-facing. List them for Chris’s same-day confirmation (CLAUDE.md default policy) rather than doing them silently:
      • the Worker deploy, per docs/architecture/dev-tools.md;
      • a new dev build, via scripts/dev/install-dev.sh.
    • Device check: on Chris’s ATV, connect and exercise No XP, Reset everything, and removing kinetic below 12 with void at 18 (evolve rung).

Spec coverage

Spec item Where
§1.1 No XP T1
§1.2 Reset everything T2
§1.3 mod log / rebuild T3
§1.3 rollback, CrystalState.remove, dispatch T4
§1.3 render-cache risk Q1 (none)
§1.4 caps T5
§2.1 layout and live column T7
§2.2 organisation: Run/Spawn/Build/Visuals/Perf/Settings T7
§2.2 Crystals T9
§2.3 tooltips T8
§2.4 look T7 CSS
§2.5 split T6
§3 error handling: no-ops T4
§3 stale build T9
§3 hold-off T7 updateRunTiles
§4 GUT T1-T5
§4 Node T6-T9
§4 real browser T10
§4 device T10 step 6
§5 delivery Execution context, T0, T10

Placeholder scan

  • Each recipe step (T6 step 4, T7 step 4 c/f) is a mechanical move, with exact line ranges and attribute edits. The ranges were verified against 43dc4864.
  • The “verify” notes (the T2 add_gold param, the T4 InputState constructor, the T5 desc wording) are fact checks with the exact grep to run. They are not open design.

Type consistency. The names match across tasks:

  • rebuild_weapon(wid, content, skip), log_op, weapon_for and mod_noun;
  • dev_remove_crystals / dev_clear_crystals / dev_clear_gems;
  • _dev_reset_build;
  • the caps keys element_colors / crystal_rungs / descs / dev_commands;
  • the client’s act, toggleRun, updateRunTiles, applyCapsGates, renderCrystals(elements, rungs, colors, canRemove), tip, tipContent, TOOLTIPS and TIP_SVGS;
  • the element ids tile-noxp, tile-reset-all, tile-clear-crystals and live-*.

The tip ids used in T9 (crystal-minus, crystal-plus, crystal-ladder, crystal-clear) are defined in T8’s TOOLTIPS. T9 runs after T8, so the coverage test stays green at every commit. T7’s markup already carries data-tip, and T8 is the first task that checks them.

  • Planning made no other changes. No code, deploys, tags or memory edits were done outside this plan file. The spec (43dc4864) was already committed.
  • Chris has authorised building and merging to main behind a reviewer gate. These still need his same-day OK:
    • the Worker deploy;
    • device dev-build installs.
  • Open conditional: tests/test_debug_console_parity.gd (commit 08c68f07) lives on the unmerged branches perf-measure-fx and fx-v2-promote-railgun. Re-check it against origin/main before T1 and again before T6 (see the “Cross-branch hazard” section).
  • Exact next action: create the worktree from the “Execution context” section, then start at T0. Use subagent-driven mode with bh-implementer per task (model: "opus") and bh-reviewer before merge.