Level-up reroll + skip (plan, 2026-10-20)
Level-up reroll + skip Implementation Plan
Section titled “Level-up reroll + skip Implementation Plan”For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a level-up Reroll (1 / 2 / 3 per run, bought from one new Utility shop item) and an always-available Skip (refunds a live-tunable share of the XP bar) to the glass level-up panel, on pad, keyboard and touch, without moving any determinism baseline.
Architecture: All rules live in pure sim/upgrade_system.gd (reroll_choices, skip_upgrade, action_state). Rerolls draw only from upgrade_rng through roll_upgrade_choices with a new optional exclude parameter. With the parameter empty, every pool and every draw is unchanged. The run budget is Sim.meta_rerolls, set by MetaRunSetup.apply_stats from MetaState.rerolls_per_run. Each pilot counts its own rerolls_used and levelup_skips on PlayerState, kept out of the checksum. LevelUpPanel gets the action state through a new LevelUpBase.set_actions and emits reroll_requested / skip_requested. main.gd resolves them, records them in the replay pick stream, the run timeline and telemetry, and re-opens or resumes through the existing chain logic.
Tech Stack: Godot 4.7, typed GDScript (warnings as errors), GUT, Node test runner for the console (control/), Python for the bible hand-edit.
Spec: docs/superpowers/specs/2026-10-20-levelup-reroll-skip-design.md (decisions for Chris’s veto: §10).
Global Constraints
Section titled “Global Constraints”- Determinism. Never draw from
sim.rng. Every new draw goes throughroll_upgrade_choicesonupgrade_rng. The defaultexclude = []must leave the pools and theupgrade_rngdraw order unchanged.tests/test_determinism_survival.gd,tests/test_determinism.gdandtests/test_determinism_coop.gdmust stay green with no re-pin. If one moves, stop and report it. Do not usebh-determinism-repin. /simpurity. No Node, Input, Engine or Time APIs insim/. New sim state is plain data.PlayerState.rerolls_usedandlevelup_skipsstay out ofstate_checksum()/net_checksum().- Per-pilot pipeline. Every new UpgradeSystem function takes an explicit
pilot. Never readsim.playerinside them. - Shop state only via
MetaRunSetup.apply. Do not setmeta_rerollsanywhere else outside tests. - Tunables. Every new tuning value is a registered
static var, gets aTunablesRegistry.ENTRIESrow and a docs.d label, and the generatedcontrol/src/tunable_docs.jsis regenerated, never hand-edited. Do not regenerateremote-config/manifest/build-*.json; the next BUILD bump or release writes it (scripts/release/release.sh). - UI. No colour literals, numeric font sizes or theme constants in
ui/(literal ratchet); useTokens/TokenBind/NeonTheme. UI fit tests run at theShopLayoutFixturedevice profiles (iphone, ipad, tv) with forced safe insets. Touch targets are at least 44 pt. - Tests. After adding a new test file, run
godot --headless --path . --importfirst, thengit checkout -- project.godot audio/bus_layout.tres. Run one file withgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<file_stem> -gexit. Never pull a whole GUT run into context: runbash scripts/check-test-count.shand grep$TMPDIR/bh-gut-last.log(setGUT_LOG=<own path>if another worktree runs gates at the same time). The per-task verify isbash scripts/gate.sh. - Commits. Use
git commit --file=<msgfile>. Each message ends with the session’s attribution lines. Nevercommit -ain the main checkout. Work only in this plan’s worktree. - Release. This targets 1.2, after the 1.1 freeze. No BUILD bump in this branch. Merge with
bh-merge-reviewed-branchafterbh-reviewerreturns MERGE. No remote flag gates the feature for store players (Apple 2.3.1).
File map
Section titled “File map”| File | Change |
|---|---|
sim/upgrade_system.gd |
exclude param, _top_up, REROLL_ID / SKIP_ID, 2 tunables, reroll/skip/action functions |
sim/player_state.gd |
rerolls_used, levelup_skips |
sim/sim.gd |
meta_rerolls; dev_reset_upgrades resets the two counters |
net/tunables_registry.gd, net/live_scope.gd |
3 registry rows, 1 LiveScope row |
tools/tunable_docs/docs.d/S5.json, .../level_up_cards.json, control/src/tunable_docs.js (generated) |
labels |
data/bible.json |
levelup-reroll meta upgrade |
sim/meta_state.gd, sim/meta_run_setup.gd |
rerolls_per_run, applied at run start |
ui/shop_icons.gd |
levelup_rerolls glyph |
net/levelup_telemetry.gd (new) |
LevelUpTelemetry payload builders |
ui/level_up_base.gd, ui/level_up_panel.gd |
signals, set_actions, prompts, input, touch buttons |
main.gd |
wiring, handlers, _after_levelup_resolved, replay/timeline/telemetry |
input/replay_driver.gd |
#reroll / #skip playback |
| tests | tests/test_levelup_reroll_skip.gd, tests/test_reroll_shop_item.gd, tests/test_levelup_telemetry.gd, tests/test_level_up_panel_actions.gd, tests/test_levelup_actions_main.gd, tests/test_replay_driver_actions.gd; edit tests/test_shop_categories.gd |
| docs | architecture docs, CLAUDE.md line, What’s New, economy research note |
Task 1: Sim core: exclude-aware roll, reroll, skip, tunables
Section titled “Task 1: Sim core: exclude-aware roll, reroll, skip, tunables”Model: opus (cross-cutting: /sim core + determinism)
Files:
- Modify:
sim/upgrade_system.gd,sim/player_state.gd,sim/sim.gd,net/tunables_registry.gd,tools/tunable_docs/docs.d/S5.json,control/src/tunable_docs.js(generated) - Create:
tests/test_levelup_reroll_skip.gd
Interfaces (produced, used by Tasks 2-6):
# UpgradeSystemconst REROLL_ID := "#reroll"const SKIP_ID := "#skip"static var SKIP_XP_REFUND_FRAC: float = 0.25static var REROLL_EXCLUDE_SHOWN: bool = truefunc roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int, exclude: Array[String] = []) -> Array[String]static func _top_up(out: Array[String], exclude: Array[String], n: int) -> voidfunc rerolls_left(sim: Sim, pilot: PlayerState) -> intfunc can_reroll(sim: Sim, pilot: PlayerState) -> boolfunc can_skip(sim: Sim, pilot: PlayerState) -> boolfunc reroll_choices(sim: Sim, pilot: PlayerState, n: int, shown: Array[String]) -> Array[String] # [] = refusedfunc skip_xp(sim: Sim, pilot: PlayerState) -> floatfunc skip_upgrade(sim: Sim, pilot: PlayerState) -> float # returns the refund; 0.0 when refusedfunc action_state(sim: Sim, pilot: PlayerState) -> Dictionary# {"offered": bool, "rerolls_left": int, "rerolls_max": int, "can_reroll": bool, "can_skip": bool, "skip_pct": int}# Simvar meta_rerolls: int = 0# PlayerStatevar rerolls_used: int = 0var levelup_skips: int = 0- Step 1: Write the failing tests. Create
tests/test_levelup_reroll_skip.gd:
extends GutTest
# Level-up reroll + skip (issue #77; spec docs/superpowers/specs/2026-10-20-levelup-reroll-skip-design.md# §5). Rerolls draw ONLY from upgrade_rng through roll_upgrade_choices, and the new `exclude` param is# draw-neutral when empty, so no determinism baseline moves.
const BIBLE := "res://data/bible.json"
func after_each() -> void: Tunables.reset()
func _sim(rerolls := 0) -> Sim: var sim := Sim.new(1234, ContentLoader.load_from_path(BIBLE)) sim.enable_survival() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") sim.meta_rerolls = rerolls sim.player.pending_levelups = 1 return sim
func test_default_exclude_is_draw_identical_to_an_explicit_empty_one() -> void: var a := _sim() var b := _sim() var ia := a.upgrade_system.roll_upgrade_choices(a, a.player, 3) var empty: Array[String] = [] var ib := b.upgrade_system.roll_upgrade_choices(b, b.player, 3, empty) assert_eq(ia, ib, "same cards") assert_eq(a.upgrade_rng.get_state(), b.upgrade_rng.get_state(), "same upgrade_rng draws")
func test_reroll_replaces_every_shown_card() -> void: var sim := _sim(1) var shown := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) var next := sim.upgrade_system.reroll_choices(sim, sim.player, 3, shown) assert_eq(next.size(), 3, "a full panel") for id in next: assert_false(shown.has(id), "%s was on the replaced panel" % id)
func test_reroll_never_draws_the_spawn_stream() -> void: var sim := _sim(1) var shown := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) var rng0 := sim.rng.get_state() var up0 := sim.upgrade_rng.get_state() sim.upgrade_system.reroll_choices(sim, sim.player, 3, shown) assert_eq(sim.rng.get_state(), rng0, "sim.rng untouched") assert_ne(sim.upgrade_rng.get_state(), up0, "the reroll drew from upgrade_rng")
func test_reroll_spends_the_budget_then_refuses() -> void: var sim := _sim(1) var shown := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) assert_eq(sim.upgrade_system.rerolls_left(sim, sim.player), 1) var next := sim.upgrade_system.reroll_choices(sim, sim.player, 3, shown) assert_eq(sim.player.rerolls_used, 1) assert_eq(sim.upgrade_system.rerolls_left(sim, sim.player), 0) var up0 := sim.upgrade_rng.get_state() assert_eq(sim.upgrade_system.reroll_choices(sim, sim.player, 3, next), [], "none left: refused") assert_eq(sim.player.rerolls_used, 1, "a refusal spends nothing") assert_eq(sim.upgrade_rng.get_state(), up0, "a refusal draws nothing")
func test_no_owned_rerolls_means_no_reroll() -> void: var sim := _sim(0) assert_false(sim.upgrade_system.can_reroll(sim, sim.player)) assert_true(sim.upgrade_system.can_skip(sim, sim.player), "skip is always there")
func test_actions_are_off_without_a_pending_level() -> void: var sim := _sim(2) sim.player.pending_levelups = 0 assert_false(sim.upgrade_system.can_reroll(sim, sim.player)) assert_false(sim.upgrade_system.can_skip(sim, sim.player)) assert_eq(sim.upgrade_system.skip_upgrade(sim, sim.player), 0.0) assert_eq(sim.player.levelup_skips, 0)
func test_actions_are_off_in_the_tutorial_and_the_app_clip() -> void: var tut := _sim(2) tut.tutorial = TutorialLessons.new() assert_false(tut.upgrade_system.can_reroll(tut, tut.player), "tutorial offers are scripted") assert_false(tut.upgrade_system.can_skip(tut, tut.player)) var clip := _sim(2) clip.clip_director = ClipDirector.new() assert_false(clip.upgrade_system.can_reroll(clip, clip.player), "App Clip offers are curated") assert_false(clip.upgrade_system.can_skip(clip, clip.player))
func test_each_pilot_has_the_full_budget() -> void: var sim := _sim(1) sim.add_pilot() sim.player2.pending_levelups = 1 var shown := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) sim.upgrade_system.reroll_choices(sim, sim.player, 3, shown) assert_eq(sim.upgrade_system.rerolls_left(sim, sim.player), 0) assert_eq(sim.upgrade_system.rerolls_left(sim, sim.player2), 1, "P2 counts its own rerolls")
func test_skip_refunds_a_share_of_the_bar_and_drains_the_level() -> void: var sim := _sim() sim.player.xp = 0.0 sim.player.xp_to_next = 200.0 var refund := sim.upgrade_system.skip_upgrade(sim, sim.player) assert_almost_eq(refund, 50.0, 0.001, "25% of 200") assert_almost_eq(sim.player.xp, 50.0, 0.001) assert_eq(sim.player.pending_levelups, 0) assert_eq(sim.player.levelup_skips, 1)
func test_skip_refund_reads_the_live_tunable() -> void: assert_eq(Tunables.apply({"UpgradeSystem.SKIP_XP_REFUND_FRAC": 0.5}), []) var sim := _sim() sim.player.xp = 0.0 sim.player.xp_to_next = 100.0 assert_almost_eq(sim.upgrade_system.skip_upgrade(sim, sim.player), 50.0, 0.001) assert_eq(int(sim.upgrade_system.action_state(sim, sim.player)["skip_pct"]), 50, "the label reads it too")
func test_dev_no_xp_skips_without_a_refund() -> void: var sim := _sim() sim.dev_no_xp = true sim.player.xp = 0.0 assert_eq(sim.upgrade_system.skip_upgrade(sim, sim.player), 0.0) assert_eq(sim.player.pending_levelups, 0, "the level is still given up")
# Real path: the refund that crosses the bar is turned into a level by the next tick's sweep.func test_a_refund_over_the_bar_levels_on_the_next_tick() -> void: var sim := _sim() sim.player.dev_invuln = true sim.enemies.count = 0 sim.player.xp = sim.player.xp_to_next * 0.9 var lvl := sim.player.level sim.upgrade_system.skip_upgrade(sim, sim.player) assert_eq(sim.player.level, lvl, "no level granted inline") sim.tick(InputState.new()) assert_eq(sim.player.level, lvl + 1, "the sweep levelled the pilot") assert_gte(sim.player.pending_levelups, 1)
func test_exclude_flag_off_rerolls_like_a_fresh_roll() -> void: assert_eq(Tunables.apply({"UpgradeSystem.REROLL_EXCLUDE_SHOWN": false}), []) var a := _sim(1) var b := _sim(1) var shown_a := a.upgrade_system.roll_upgrade_choices(a, a.player, 3) b.upgrade_system.roll_upgrade_choices(b, b.player, 3) var ra := a.upgrade_system.reroll_choices(a, a.player, 3, shown_a) var rb := b.upgrade_system.roll_upgrade_choices(b, b.player, 3) assert_eq(ra, rb, "flag off: no exclusion")
func test_top_up_refills_from_the_shown_cards_in_order() -> void: var out: Array[String] = ["armor"] var ex: Array[String] = ["max-hp", "armor", "crystals:fire=1", "regen"] UpgradeSystem._top_up(out, ex, 3) assert_eq(out, ["armor", "max-hp", "crystals:fire=1"] as Array[String]) var with_crystal: Array[String] = ["crystals:cold=1"] UpgradeSystem._top_up(with_crystal, ex, 3) assert_eq(with_crystal, ["crystals:cold=1", "max-hp", "armor"] as Array[String], "never a second crystal card") var none: Array[String] = ["a"] var empty: Array[String] = [] UpgradeSystem._top_up(none, empty, 3) assert_eq(none, ["a"] as Array[String], "empty exclude: no-op")
func test_dev_reset_upgrades_clears_the_counters() -> void: var sim := _sim(1) sim.player.rerolls_used = 1 sim.player.levelup_skips = 2 sim.dev_reset_upgrades() assert_eq(sim.player.rerolls_used, 0) assert_eq(sim.player.levelup_skips, 0)
func test_counters_stay_out_of_the_checksum() -> void: var sim := _sim(1) var c0 := sim.state_checksum() sim.player.rerolls_used = 3 sim.player.levelup_skips = 3 assert_eq(sim.state_checksum(), c0)- Step 2: Run the tests and watch them fail.
cd <worktree> && godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tresgodot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_levelup_reroll_skip -gexit 2>&1 | tail -20Expected: parse errors or failures on meta_rerolls, reroll_choices, rerolls_used.
- Step 3: PlayerState counters. In
sim/player_state.gd:
OLD:
# Level-up picks taken this run, per upgrade id (UpgradeSystem.apply_upgrade). Display-only: the# level-up cards read it for "Lv 2 → 3"; nothing in the tick reads it, so it stays out of the checksum.var upgrade_picks: Dictionary = {}NEW:
# Level-up picks taken this run, per upgrade id (UpgradeSystem.apply_upgrade). Display-only: the# level-up cards read it for "Lv 2 → 3"; nothing in the tick reads it, so it stays out of the checksum.var upgrade_picks: Dictionary = {}# Level-up reroll + skip (issue #77): rerolls spent against Sim.meta_rerolls, and levels skipped, this# run. Counters for the panel and telemetry only; nothing in the tick reads them (out of the checksum).var rerolls_used: int = 0var levelup_skips: int = 0- Step 4: Sim budget + dev reset. In
sim/sim.gd:
OLD:
# Greed is the account's upgrade. 1.0 = none (exact identity) → baseline-safe.var meta_gold_mult: float = 1.0NEW:
# Greed is the account's upgrade. 1.0 = none (exact identity) → baseline-safe.var meta_gold_mult: float = 1.0# INPUT from the shop Reroll upgrade (MetaState.rerolls_per_run, set by MetaRunSetup at run start): the# level-up rerolls EACH pilot may spend this run (PlayerState.rerolls_used counts them). Never read by# the tick; 0 = none bought.var meta_rerolls: int = 0And in dev_reset_upgrades:
OLD:
player.upgrade_picks = fresh.upgrade_picksNEW:
player.upgrade_picks = fresh.upgrade_picks player.rerolls_used = fresh.rerolls_used # the reset run's reroll budget starts over (issue #77) player.levelup_skips = fresh.levelup_skips- Step 5: UpgradeSystem constants + tunables. In
sim/upgrade_system.gd:
OLD:
static var CRYSTAL_GRANT_CHANCE: float = 0.65static var CRYSTAL_SECOND_GRANT_CHANCE: float = 0.0
func roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int) -> Array[String]:NEW:
static var CRYSTAL_GRANT_CHANCE: float = 0.65static var CRYSTAL_SECOND_GRANT_CHANCE: float = 0.0
# Level-up reroll + skip (issue #77, spec docs/superpowers/specs/2026-10-20-levelup-reroll-skip-design.md).# Action ids in the replay pick stream and the run timeline; "#" never starts a card id.const REROLL_ID := "#reroll"const SKIP_ID := "#skip"# A skip banks this share of the pilot's xp_to_next bar. TUNABLE, read at each skip (and by the label).static var SKIP_XP_REFUND_FRAC: float = 0.25# A reroll leaves out the cards it replaces (re-offered only if the pool runs short). TUNABLE flag.static var REROLL_EXCLUDE_SHOWN: bool = true
# `exclude` (reroll): ids left out of every pool. Empty (every other caller) = the pools and the# upgrade_rng draws are exactly the pre-reroll ones, so no baseline moves.func roll_upgrade_choices(sim: Sim, pilot: PlayerState, n: int, exclude: Array[String] = []) -> Array[String]:- Step 6: Filter each pool. Same file, five edits:
OLD:
if mod_eligible(sim, pilot, u): mod_ids.append(u["id"])NEW:
if mod_eligible(sim, pilot, u) and not exclude.has(String(u["id"])): mod_ids.append(u["id"])OLD:
if StasisWeb.card_offerable(sim, pilot): mod_ids.append(StasisWeb.CARD_ID)NEW:
if StasisWeb.card_offerable(sim, pilot) and not exclude.has(StasisWeb.CARD_ID): mod_ids.append(StasisWeb.CARD_ID)OLD:
if pilot.arsenal.weapon_by_id.get(wid) != null and not is_weapon_active(sim, pilot, wid): weapon_ids.append("weapon:" + wid)NEW:
if pilot.arsenal.weapon_by_id.get(wid) != null and not is_weapon_active(sim, pilot, wid) \ and not exclude.has("weapon:" + wid): weapon_ids.append("weapon:" + wid)OLD:
wmod_ids.append("wm:%s:%s" % [wid, kind])NEW:
var wm_id := "wm:%s:%s" % [wid, kind] if not exclude.has(wm_id): wmod_ids.append(wm_id)OLD:
if can_evolve(sim, pilot, wid): evolve_ids.append("evolve:" + wid)NEW:
if can_evolve(sim, pilot, wid) and not exclude.has("evolve:" + wid): evolve_ids.append("evolve:" + wid)- Step 7: Crystal grants and the top-up. Same file. The draws are unchanged (each
randf()still happens); only the append is filtered.
OLD:
var g1 := roll_live_crystal_grant(sim, pilot) if g1 != "": out.append(g1) if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE: var g2 := roll_live_crystal_grant(sim, pilot) if g2 != "": out.append(g2)NEW:
var g1 := roll_live_crystal_grant(sim, pilot) if g1 != "" and not exclude.has(g1): out.append(g1) if sim.upgrade_rng.randf() < CRYSTAL_SECOND_GRANT_CHANCE: var g2 := roll_live_crystal_grant(sim, pilot) if g2 != "" and not exclude.has(g2): out.append(g2)OLD:
var g3 := roll_live_crystal_grant(sim, pilot) if g3 == "": break # fewer cards beats a dud card (spec §2) out.append(g3) return outNEW:
var g3 := roll_live_crystal_grant(sim, pilot) if g3 == "" or exclude.has(g3): break # fewer cards beats a dud card (spec §2); a replaced grant again ends the fill out.append(g3) _top_up(out, exclude, n) return outOLD (classic branch tail):
for id in pool: if result.size() >= n: break if not result.has(id): result.append(id) return resultNEW:
for id in pool: if result.size() >= n: break if not result.has(id): result.append(id) _top_up(result, exclude, n) return resultThen add, directly above static func _has_crystal_card:
# Reroll short-pool fill (issue #77): when too few fresh cards are left, re-offer the replaced ones in# their shown order instead of showing fewer cards. No draws; a no-op for an empty `exclude`; never a# second crystal card (the at-most-one rule above).static func _top_up(out: Array[String], exclude: Array[String], n: int) -> void: for id in exclude: if out.size() >= n: return if out.has(id) or (id.begins_with("crystals:") and _has_crystal_card(out)): continue out.append(id)- Step 8: Reroll / skip / state functions. Same file, directly after
apply_upgrade(before# ── Per-weapon upgrades):
# ── Level-up reroll + skip (issue #77) ───────────────────────────────────────# Render-side resolvers of an open level-up, like apply_upgrade: main (and the replay driver) call them# between ticks, never the tick itself.func rerolls_left(sim: Sim, pilot: PlayerState) -> int: return maxi(sim.meta_rerolls - pilot.rerolls_used, 0)
# Offered only on a real pending level outside the Tutorial and the App Clip (their offers are scripted).func _actions_allowed(sim: Sim, pilot: PlayerState) -> bool: return pilot != null and pilot.pending_levelups > 0 and sim.tutorial == null and sim.clip_director == null
func can_reroll(sim: Sim, pilot: PlayerState) -> bool: return _actions_allowed(sim, pilot) and rerolls_left(sim, pilot) > 0
func can_skip(sim: Sim, pilot: PlayerState) -> bool: return _actions_allowed(sim, pilot)
# A fresh offer for the same level, without the `shown` cards (REROLL_EXCLUDE_SHOWN). Draws only from# upgrade_rng (via roll_upgrade_choices). [] = refused (none left / not allowed): nothing spent or drawn.func reroll_choices(sim: Sim, pilot: PlayerState, n: int, shown: Array[String]) -> Array[String]: if not can_reroll(sim, pilot): return [] pilot.rerolls_used += 1 var ex: Array[String] = [] if REROLL_EXCLUDE_SHOWN: ex.assign(shown) return roll_upgrade_choices(sim, pilot, n, ex)
func skip_xp(sim: Sim, pilot: PlayerState) -> float: return 0.0 if sim.dev_no_xp else maxf(SKIP_XP_REFUND_FRAC, 0.0) * pilot.xp_to_next
# Gives up this level's card for SKIP_XP_REFUND_FRAC of the bar. A refund that crosses the bar levels# the pilot on the next tick's _process_levelups (never inline). Returns the refund; 0.0 when refused.func skip_upgrade(sim: Sim, pilot: PlayerState) -> float: if not can_skip(sim, pilot): return 0.0 var refund := skip_xp(sim, pilot) pilot.xp += refund pilot.levelup_skips += 1 pilot.pending_levelups = maxi(pilot.pending_levelups - 1, 0) return refund
# What the level-up panel shows (LevelUpBase.set_actions). Read-only.func action_state(sim: Sim, pilot: PlayerState) -> Dictionary: var offered := _actions_allowed(sim, pilot) var left := rerolls_left(sim, pilot) return {"offered": offered, "rerolls_left": left, "rerolls_max": sim.meta_rerolls, "can_reroll": offered and left > 0, "can_skip": offered, "skip_pct": roundi(maxf(SKIP_XP_REFUND_FRAC, 0.0) * 100.0)}- Step 9: Register the tunables. In
net/tunables_registry.gd:
OLD:
{"key": "UpgradeSystem.CRYSTAL_SECOND_GRANT_CHANCE", "script": UPGRADE_SYSTEM, "name": "CRYSTAL_SECOND_GRANT_CHANCE", "min": 0.0, "max": 1.0},NEW:
{"key": "UpgradeSystem.CRYSTAL_SECOND_GRANT_CHANCE", "script": UPGRADE_SYSTEM, "name": "CRYSTAL_SECOND_GRANT_CHANCE", "min": 0.0, "max": 1.0}, {"key": "UpgradeSystem.SKIP_XP_REFUND_FRAC", "script": UPGRADE_SYSTEM, "name": "SKIP_XP_REFUND_FRAC", "min": 0.0, "max": 1.0}, {"key": "UpgradeSystem.REROLL_EXCLUDE_SHOWN", "script": UPGRADE_SYSTEM, "name": "REROLL_EXCLUDE_SHOWN", "type": "bool"},In tools/tunable_docs/docs.d/S5.json:
OLD:
"UpgradeSystem.CRYSTAL_SECOND_GRANT_CHANCE": {"label": "Second crystal card chance", "desc": "Chance a panel that already has a crystal card offers a second one. 0 = at most one.", "unit": "frac", "src": "comment"}}NEW:
"UpgradeSystem.CRYSTAL_SECOND_GRANT_CHANCE": {"label": "Second crystal card chance", "desc": "Chance a panel that already has a crystal card offers a second one. 0 = at most one.", "unit": "frac", "src": "comment"}, "UpgradeSystem.SKIP_XP_REFUND_FRAC": {"label": "Level-up skip: XP refund", "desc": "Share of the next level's XP bar a skipped level-up gives back. The SKIP label shows it. Read at each skip.", "unit": "frac", "src": "code"}, "UpgradeSystem.REROLL_EXCLUDE_SHOWN": {"label": "Level-up reroll: new cards only", "desc": "On: a reroll never re-offers the cards it replaces (unless too few others are left). Off: a reroll is a fresh roll and may repeat them.", "src": "code"}}Regenerate and check:
node tools/tunable_docs/generate.mjs && node --test control/test/tunable-docs.test.mjs 2>&1 | tail -5- Step 10: Run the tests and watch them pass.
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_levelup_reroll_skip -gexit 2>&1 | tail -8godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism -gexit 2>&1 | tail -6Expected: all pass. All three test_determinism* files pass with no re-pin.
-
Step 11: Verify.
bash scripts/gate.sh. Expected: green. -
Step 12: Commit.
git add sim/upgrade_system.gd sim/player_state.gd sim/sim.gd net/tunables_registry.gd tools/tunable_docs/docs.d/S5.json control/src/tunable_docs.js tests/test_levelup_reroll_skip.gd, messagesim: level-up reroll + skip core (exclude-aware roll, per-pilot budget, live refund tunable) #77.
Task 2: The Reroll shop item
Section titled “Task 2: The Reroll shop item”Model: sonnet
Files:
- Modify:
data/bible.json,sim/meta_state.gd,sim/meta_run_setup.gd,ui/shop_icons.gd,tests/test_shop_categories.gd - Create:
tests/test_reroll_shop_item.gd
Interfaces: MetaState.rerolls_per_run(defs: Array) -> int. It consumes Sim.meta_rerolls (Task 1).
- Step 1: Write the failing tests. Create
tests/test_reroll_shop_item.gd:
extends GutTest
# The Reroll shop item (issue #77, spec §4): one Utility upgrade, 3 tiers = 1 / 2 / 3 level-up rerolls# per run, applied ONLY through MetaRunSetup.apply (the same path main, soak and the economy sim use).
const BIBLE := "res://data/bible.json"
func _def(db: ContentDB) -> Dictionary: for d in db.meta_upgrades(): if d is Dictionary and String(d.get("id", "")) == "levelup-reroll": return d return {}
func _run(tier: int) -> Sim: var db := ContentLoader.load_from_path(BIBLE) var sim := Sim.new(1234, db) var meta := MetaState.new() if tier > 0: meta.levels = {"levelup-reroll": tier} MetaRunSetup.apply(sim, meta, ShipBonuses.DEFAULT_SHIP, false) return sim
func test_the_bible_defines_the_item() -> void: var d := _def(ContentLoader.load_from_path(BIBLE)) assert_false(d.is_empty(), "levelup-reroll exists") assert_eq(String(d["effect"]), "levelup_rerolls") assert_eq(int(d["max_level"]), 3) assert_eq(String(d["category"]), "Utility")
func test_tier_prices_are_150_300_600() -> void: var db := ContentLoader.load_from_path(BIBLE) var meta := MetaState.new() var d := _def(db) var prices: Array[int] = [] for lvl in 3: prices.append(int(meta.cost(d, lvl))) assert_eq(prices, [150, 300, 600] as Array[int])
func test_each_tier_adds_one_reroll_per_run() -> void: for tier in 4: var sim := _run(tier) assert_eq(sim.meta_rerolls, tier, "tier %d" % tier)
func test_a_fresh_save_has_none() -> void: assert_eq(_run(0).meta_rerolls, 0)
func test_a_bought_reroll_is_usable_on_a_real_level_up() -> void: var sim := _run(1) sim.enable_survival() sim.player.pending_levelups = 1 var shown := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3) assert_eq(sim.upgrade_system.reroll_choices(sim, sim.player, 3, shown).size(), 3)
func test_the_effect_does_not_touch_pilot_stats() -> void: var base := _run(0) var bought := _run(3) assert_eq(bought.player.max_hp, base.player.max_hp, "StatEffects ignores levelup_rerolls") assert_eq(bought.player.speed, base.player.speed)In tests/test_shop_categories.gd:
OLD:
# Greed moved to Pilot (2026-10-04), leaving Utility empty in the real bible: its tab must not show.func test_real_bible_utility_tab_is_hidden_while_empty() -> void: var db := ContentLoader.load_from_path("res://data/bible.json") assert_false("Utility" in ShopCategories.present(db.meta_upgrades()), "an empty category gets no tab") assert_true("Pilot" in ShopCategories.present(db.meta_upgrades()))NEW:
# Greed moved to Pilot (2026-10-04) and left Utility empty; the level-up Reroll item (issue #77) fills it# again, so its tab shows. An empty category still gets no tab (synthetic check below).func test_real_bible_utility_tab_shows_with_the_reroll_item() -> void: var db := ContentLoader.load_from_path("res://data/bible.json") assert_true("Utility" in ShopCategories.present(db.meta_upgrades()), "Reroll lives in Utility") assert_true("Pilot" in ShopCategories.present(db.meta_upgrades()))
func test_an_empty_category_gets_no_tab() -> void: var defs: Array = [{"id": "x", "category": "Pilot"}] assert_false("Utility" in ShopCategories.present(defs))Before writing that last test, read ShopCategories.present and confirm it accepts a plain Array of dicts with only id + category. If it needs more fields, add them from a real Pilot def. Keep the assertion.
-
Step 2: Run and watch fail. Run
godot --headless --path . --import(new file), restoreproject.godotandaudio/bus_layout.tres, then-gselect=test_reroll_shop_itemand-gselect=test_shop_categories. -
Step 3: Bible hand-edit (do not re-export; content-and-elemental.md):
python3 - <<'EOF'import jsonp = 'data/bible.json'd = json.load(open(p, encoding='utf-8'))m = d['data']['meta_upgrades']assert not any(x.get('id') == 'levelup-reroll' for x in m)m.append({"id": "levelup-reroll", "archetypes": ["utility"], "name": "Reroll", "effect": "levelup_rerolls", "magnitude": 1, "max_level": 3, "base_cost": 150, "cost_growth": 2.0, "desc": "+{mag} level-up reroll per run, per level", "category": "Utility"})open(p, 'w', encoding='utf-8').write(json.dumps(d, indent='\t', ensure_ascii=False) + '\n')EOFgit diff --stat data/bible.jsonExpected: one hunk with only the new object (about 13 lines). If the diff touches other lines, the original file’s formatting differs (trailing newline, separators). Revert and match it, so the diff is only the new entry.
- Step 4: MetaState. In
sim/meta_state.gd, directly aftergold_mult:
OLD:
func gold_mult(defs: Array) -> float: var m := 1.0 for d in defs: if d is Dictionary and String(d.get("effect", "")) == "gold_mult": m *= pow(float(d.get("magnitude", 1.0)), float(level_of(String(d.get("id", ""))))) return mNEW:
func gold_mult(defs: Array) -> float: var m := 1.0 for d in defs: if d is Dictionary and String(d.get("effect", "")) == "gold_mult": m *= pow(float(d.get("magnitude", 1.0)), float(level_of(String(d.get("id", ""))))) return m
# Level-up rerolls per run from every owned def whose effect is "levelup_rerolls" (the Reroll item,# issue #77): magnitude x level, summed. 0 with none bought. Fed into Sim.meta_rerolls by MetaRunSetup;# each pilot spends its own (PlayerState.rerolls_used). StatEffects skips the effect in apply_to.func rerolls_per_run(defs: Array) -> int: var n := 0.0 for d in defs: if d is Dictionary and String(d.get("effect", "")) == "levelup_rerolls": n += float(d.get("magnitude", 1.0)) * float(level_of(String(d.get("id", "")))) return maxi(roundi(n), 0)- Step 5: MetaRunSetup. In
sim/meta_run_setup.gd:
OLD:
sim.meta_gold_mult = meta.gold_mult(defs) # Greed: once per run, shared wallet (not per pilot)NEW:
sim.meta_gold_mult = meta.gold_mult(defs) # Greed: once per run, shared wallet (not per pilot) sim.meta_rerolls = meta.rerolls_per_run(defs) # Reroll: each pilot's level-up reroll budget (#77)Also update the function’s header comment, which ends “and the Greed run-gold multiplier.”, so it reads “the Greed run-gold multiplier, and the level-up Reroll budget.”
- Step 6: Shop icon. In
ui/shop_icons.gd:
OLD:
"gold_mult", "greed": _draw_gold_g(cx, cy)NEW:
"gold_mult", "greed": _draw_gold_g(cx, cy) "levelup_rerolls", "levelup-reroll": _draw_reroll(cx, cy)After _draw_gold_g:
func _draw_reroll(cx: float, cy: float) -> void: # Two curved arrows chasing each other round a circle: "roll again" (issue #77 Reroll item). var r := 9.0 draw_arc(Vector2(cx, cy), r, PI * 0.15, PI * 0.85, 14, accent, 3.5, true) draw_arc(Vector2(cx, cy), r, PI * 1.15, PI * 1.85, 14, accent, 3.5, true) for a: float in [PI * 0.85, PI * 1.85]: var tip := Vector2(cx, cy) + Vector2(cos(a), sin(a)) * r var fwd := Vector2(-sin(a), cos(a)) # tangent, the arc's direction of travel var side := Vector2(cos(a), sin(a)) draw_colored_polygon(PackedVector2Array([tip + fwd * 5.0, tip - side * 4.0, tip + side * 4.0]), accent)Also add "levelup_rerolls" to the header comment’s key list. Check that MetaShopPanel builds the icon from effect (icon_key = effect) by reading the call site. Then capture the Utility tab with tools/ui_capture (UI_CAP_SIZES=ipad,tv) and look at the glyph and the card. Ship-quality polish of the glyph belongs to the visual-QA pass (Task 7).
-
Step 7: Run and watch pass.
-gselect=test_reroll_shop_item,-gselect=test_shop_categories,-gselect=test_meta_shop,-gselect=test_archetype_tags,-gselect=test_economy_career. -
Step 8: Verify.
bash scripts/gate.sh. -
Step 9: Commit. Message:
shop: Reroll item (Utility, 150/300/600 gold, 1-3 level-up rerolls per run) #77.
Task 3: Telemetry builders
Section titled “Task 3: Telemetry builders”Model: sonnet
Files:
- Create:
net/levelup_telemetry.gd,tests/test_levelup_telemetry.gd - Modify:
main.gd(_send_run_endonly)
Interfaces:
class_name LevelUpTelemetrystatic func action_payload(action: String, run_time: float, level: int, pilot_idx: int, shown: Array, rerolls_left: int, xp_refund: float) -> Dictionarystatic func run_end_fields(sim: Sim) -> Dictionary # {"rerolls_used", "levelup_skips", "rerolls_owned"}- Step 1: Write the failing test.
tests/test_levelup_telemetry.gd:
extends GutTest
# EventQueue.push is a no-op headless, so the level-up action payloads are built by pure helpers and# pinned here (spec §8). `levelup_action` is a generic events row: no Worker route, no migration.
func test_action_payload_shape() -> void: var p := LevelUpTelemetry.action_payload("reroll", 123.456, 7, 1, ["armor", "weapon:nova", "max-hp"], 2, 0.0) assert_eq(p, {"action": "reroll", "run_time": 123.5, "level": 7, "pilot": 1, "shown": ["armor", "weapon:nova", "max-hp"], "rerolls_left": 2, "xp_refund": 0.0})
func test_skip_payload_rounds_the_refund() -> void: var p := LevelUpTelemetry.action_payload("skip", 10.0, 3, 0, ["armor"], 0, 33.3333) assert_eq(p["xp_refund"], 33.3) assert_eq(p["action"], "skip")
func test_shown_is_a_copy() -> void: var shown := ["armor"] var p := LevelUpTelemetry.action_payload("skip", 0.0, 1, 0, shown, 0, 0.0) shown.append("x") assert_eq((p["shown"] as Array).size(), 1)
func test_run_end_fields_sum_every_pilot() -> void: var sim := Sim.new(1234, SimContentFixture.db()) sim.add_pilot() sim.meta_rerolls = 2 sim.player.rerolls_used = 2 sim.player2.rerolls_used = 1 sim.player.levelup_skips = 1 sim.player2.levelup_skips = 3 assert_eq(LevelUpTelemetry.run_end_fields(sim), {"rerolls_used": 3, "levelup_skips": 4, "rerolls_owned": 2})
func test_run_end_fields_without_a_sim() -> void: assert_eq(LevelUpTelemetry.run_end_fields(null), {})-
Step 2: Run and watch fail (import first; new file + new class).
-
Step 3: Implement
net/levelup_telemetry.gd:
class_name LevelUpTelemetryextends RefCounted
# Level-up reroll + skip telemetry (issue #77, spec §8). Pure builders: main pushes the results through# EventQueue (a no-op headless), so the shapes are pinned by tests/test_levelup_telemetry.gd.# levelup_action: one per reroll or skip (a generic events row, like lan_run)# run_end gains rerolls_used / levelup_skips (summed over pilots) and rerolls_owned (Sim.meta_rerolls)
static func action_payload(action: String, run_time: float, level: int, pilot_idx: int, shown: Array, rerolls_left: int, xp_refund: float) -> Dictionary: return {"action": action, "run_time": snappedf(run_time, 0.1), "level": level, "pilot": pilot_idx, "shown": shown.duplicate(), "rerolls_left": rerolls_left, "xp_refund": snappedf(xp_refund, 0.1)}
static func run_end_fields(sim: Sim) -> Dictionary: if sim == null: return {} var used := 0 var skips := 0 for p in sim.pilots: used += p.rerolls_used skips += p.levelup_skips return {"rerolls_used": used, "levelup_skips": skips, "rerolls_owned": sim.meta_rerolls}- Step 4: Merge into run_end. In
main.gd_send_run_end:
OLD:
"difficulty": _difficulty_tag()}.merged( GameplayTelemetry.autopilot_fields(auto_player, _reports_auto())).merged( {"console_autopilot": true} if _console_autopilot_run else {})) if _net_run(): _lan_report_session(reason, false) # networked run: its pacing, to compare device runsNEW:
"difficulty": _difficulty_tag()}.merged( GameplayTelemetry.autopilot_fields(auto_player, _reports_auto())).merged( LevelUpTelemetry.run_end_fields(sim)).merged( {"console_autopilot": true} if _console_autopilot_run else {})) if _net_run(): _lan_report_session(reason, false) # networked run: its pacing, to compare device runsNote: "difficulty": _difficulty_tag()}.merged( also appears at about line 2865, in a different push. Edit only the one inside _send_run_end. Then check whether the Worker or a D1 view whitelists run_end fields: git grep -n "run_end" telemetry/src | head. If the Worker stores run_end as a JSON blob, nothing more is needed. If it maps named columns, add the three fields to that mapping in the same commit and note the needed telemetry/deploy.sh. Report which case it was.
-
Step 5: Run and watch pass.
-gselect=test_levelup_telemetry. -
Step 6: Verify.
bash scripts/gate.sh. -
Step 7: Commit. Message:
telemetry: levelup_action payload + run_end reroll/skip counts #77.
Task 4: Panel UI: prompts, input, touch buttons, fit tests
Section titled “Task 4: Panel UI: prompts, input, touch buttons, fit tests”Model: sonnet
Files:
- Modify:
ui/level_up_base.gd,ui/level_up_panel.gd,net/tunables_registry.gd,net/live_scope.gd,tools/tunable_docs/docs.d/level_up_cards.json,control/src/tunable_docs.js(generated),tests/test_level_up_panel.gd(only_open_atgets an optionalactionsarg) - Create:
tests/test_level_up_panel_actions.gd
Interfaces:
# LevelUpBasesignal reroll_requestedsignal skip_requestedfunc set_actions(_actions: Dictionary) -> void # UpgradeSystem.action_state(); {} = none (legacy, net)# LevelUpPanelstatic var SKIP_CONFIRM_S: float = 1.5func request_reroll() -> voidfunc request_skip() -> void- Step 1: Let
_open_attake actions. Intests/test_level_up_panel.gd:
OLD:
func _open_at(dev: String, weapons: Array, ids: Array) -> Array:NEW:
func _open_at(dev: String, weapons: Array, ids: Array, actions: Dictionary = {}) -> Array:OLD (inside _open_at):
var panel := LevelUpPanel.new() vp.add_child(panel) await get_tree().process_frame panel.show_for(sim, sim.player, ids)NEW:
var panel := LevelUpPanel.new() vp.add_child(panel) await get_tree().process_frame panel.set_actions(actions) panel.show_for(sim, sim.player, ids)- Step 2: Write the failing tests. Create
tests/test_level_up_panel_actions.gd. It reuses the fixture approach oftest_level_up_panel.gd(it does not extend that file; the two helpers are copied so each file stands alone):
extends GutTest
# Level-up REROLL / SKIP on the glass panel (issue #77, spec §2): X / R / touch REROLL, B / Backspace /# touch SKIP with a two-press confirm, both off while SHIP STATS is open and during the entry animation.# Fit tests run at every ShopLayoutFixture profile with its forced safe insets.
const ACTIONS := {"offered": true, "rerolls_left": 2, "rerolls_max": 3, "can_reroll": true, "can_skip": true, "skip_pct": 25}const NO_REROLLS := {"offered": true, "rerolls_left": 0, "rerolls_max": 0, "can_reroll": false, "can_skip": true, "skip_pct": 25}const SPENT := {"offered": true, "rerolls_left": 0, "rerolls_max": 2, "can_reroll": false, "can_skip": true, "skip_pct": 25}var _saved_platform := ""
func before_each() -> void: Tokens.ensure_loaded() _saved_platform = LiveTokens.platform()
func after_each() -> void: LiveTokens.set_platform(_saved_platform) Platform.force_safe_insets_pt = Vector4(-1, -1, -1, -1) Platform.force_screen_scale = 0.0 Platform.force_touch = false ButtonPrompt.force_style = "" Tunables.reset() await get_tree().process_frame
func _open(actions: Dictionary, ids: Array = ["armor", "max-hp", "weapon:nova"]) -> LevelUpPanel: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") var panel := LevelUpPanel.new() add_child_autofree(panel) await get_tree().process_frame panel.set_actions(actions) panel.show_for(sim, sim.player, ids) panel._finish_entry(panel._entry_gen) # past the entry animation await get_tree().process_frame return panel
func _open_at(dev: String, actions: Dictionary) -> Array: LiveTokens.set_platform("tv" if dev == "tv" else _saved_platform) TokenBind.flush_now() var pr := ShopLayoutFixture.profile(dev) var d: Dictionary = ShopLayoutFixture.DEVICES[dev] Platform.force_safe_insets_pt = d["insets_pt"] Platform.force_screen_scale = float(pr["upt"]) Platform.force_touch = bool(d["touch"]) var vp := SubViewport.new() vp.size = Vector2i((pr["vp"] as Vector2).round()) add_child_autofree(vp) var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() sim.upgrade_system.grant_weapon(sim, sim.player, "orbit") var panel := LevelUpPanel.new() vp.add_child(panel) await get_tree().process_frame panel.set_actions(actions) panel.show_for(sim, sim.player, ["armor", "max-hp", "weapon:nova"]) for f in 6: await get_tree().process_frame return [panel, pr, d]
func _pad(button: JoyButton) -> InputEventJoypadButton: var e := InputEventJoypadButton.new() e.button_index = button e.pressed = true return e
func _key(code: Key) -> InputEventKey: var e := InputEventKey.new() e.keycode = code e.pressed = true return e
func _hint_labels(panel: LevelUpPanel) -> String: var s := "" if panel._hint != null: for c in panel._hint.get_children(): if c is Label: s += (c as Label).text + "|" return s
func _hint_has(panel: LevelUpPanel, button: String) -> bool: if panel._hint == null: return false for c in panel._hint.get_children(): if c is ButtonGlyph and (c as ButtonGlyph).button == button: return true return false
func test_x_and_r_request_a_reroll() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open(ACTIONS) watch_signals(panel) panel._input(_pad(JOY_BUTTON_X)) assert_signal_emit_count(panel, "reroll_requested", 1) panel._input(_key(KEY_R)) assert_signal_emit_count(panel, "reroll_requested", 2) assert_true(_hint_has(panel, "x")) assert_string_contains(_hint_labels(panel), "REROLL (2)")
func test_reroll_is_hidden_until_a_tier_is_owned() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open(NO_REROLLS) watch_signals(panel) panel._input(_pad(JOY_BUTTON_X)) assert_signal_not_emitted(panel, "reroll_requested") assert_false(_hint_has(panel, "x"), "no teaser before the shop item is bought")
func test_spent_rerolls_show_zero_and_refuse() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open(SPENT) watch_signals(panel) panel._input(_pad(JOY_BUTTON_X)) assert_signal_not_emitted(panel, "reroll_requested") assert_string_contains(_hint_labels(panel), "REROLL (0)")
func test_skip_needs_a_second_press() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open(ACTIONS) watch_signals(panel) assert_string_contains(_hint_labels(panel), "SKIP +25% XP") panel._input(_pad(JOY_BUTTON_B)) assert_signal_not_emitted(panel, "skip_requested") assert_string_contains(_hint_labels(panel), "PRESS AGAIN TO SKIP") panel._input(_pad(JOY_BUTTON_B)) assert_signal_emit_count(panel, "skip_requested", 1) assert_true(panel.visible, "main hides the panel, not the panel itself")
func test_skip_disarms_after_the_window() -> void: assert_eq(Tunables.apply({"LevelUpPanel.SKIP_CONFIRM_S": 0.3}), []) ButtonPrompt.force_style = "key" var panel := await _open(ACTIONS) watch_signals(panel) panel._input(_key(KEY_BACKSPACE)) await get_tree().create_timer(0.45).timeout assert_string_contains(_hint_labels(panel), "SKIP +25% XP", "label back after the window") panel._input(_key(KEY_BACKSPACE)) assert_signal_not_emitted(panel, "skip_requested", "the first press of a new pair")
func test_b_still_closes_the_sheet_first_and_actions_are_off_meanwhile() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open(ACTIONS) watch_signals(panel) panel.toggle_sheet() assert_false(_hint_has(panel, "x"), "no REROLL prompt over the sheet") panel._input(_pad(JOY_BUTTON_X)) assert_signal_not_emitted(panel, "reroll_requested") panel._input(_pad(JOY_BUTTON_B)) assert_false(panel._sheet.is_open(), "B closed the sheet") assert_signal_not_emitted(panel, "skip_requested") assert_string_contains(_hint_labels(panel), "SKIP +25% XP", "and did not arm the skip")
func test_actions_wait_for_the_entry_animation() -> void: var sim := Sim.new(1, SimContentFixture.db()) sim.enable_survival() var panel := LevelUpPanel.new() add_child_autofree(panel) await get_tree().process_frame panel.set_actions(ACTIONS) panel.show_for(sim, sim.player, ["armor", "max-hp"]) watch_signals(panel) panel.request_reroll() panel.request_skip() panel.request_skip() assert_signal_not_emitted(panel, "reroll_requested") assert_signal_not_emitted(panel, "skip_requested")
func test_no_actions_keeps_the_old_panel() -> void: ButtonPrompt.force_style = "xbox" var panel := await _open({}) assert_false(_hint_has(panel, "x")) assert_false(_hint_has(panel, "b")) assert_null(panel._reroll_btn, "not a touch device: no buttons")
func test_touch_buttons_are_44pt_inside_the_safe_area_and_clear_of_the_strip() -> void: for dev: String in ["iphone", "ipad"]: var r: Array = await _open_at(dev, ACTIONS) var panel: LevelUpPanel = r[0] var pr: Dictionary = r[1] var upt := float(pr["upt"]) var ins: Vector4 = pr["insets"] var vp: Vector2 = pr["vp"] var rects: Array[Rect2] = [] for b: Button in [panel._reroll_btn, panel._skip_btn, panel._stats_btn]: assert_not_null(b, "%s builds every touch button" % dev) assert_true(b.visible, "%s %s visible" % [dev, b.text]) var rect := b.get_global_rect() assert_gte(rect.size.y, 44.0 * upt - 0.5, "%s %s >= 44 pt tall" % [dev, b.text]) assert_gte(rect.position.x, ins.x - 0.5, "%s %s left inside the safe area" % [dev, b.text]) assert_lte(rect.end.x, vp.x - ins.z + 0.5, "%s %s right inside the safe area" % [dev, b.text]) assert_lte(rect.end.y, vp.y - ins.w + 0.5, "%s %s above the bottom inset" % [dev, b.text]) rects.append(rect) for i in rects.size(): for j in range(i + 1, rects.size()): assert_false(rects[i].intersects(rects[j]), "%s touch buttons %d/%d overlap" % [dev, i, j]) var strip := panel._strip.get_global_rect() for rect in rects: assert_lte(strip.end.y, rect.position.y + 0.5, "%s strip sits above the buttons" % dev)
func test_touch_skip_button_arms_then_confirms() -> void: var r: Array = await _open_at("iphone", ACTIONS) var panel: LevelUpPanel = r[0] panel._finish_entry(panel._entry_gen) watch_signals(panel) panel._skip_btn.pressed.emit() assert_eq(panel._skip_btn.text, "TAP AGAIN") panel._skip_btn.pressed.emit() assert_signal_emit_count(panel, "skip_requested", 1) assert_eq(panel._reroll_btn.text, "REROLL 2")
func test_touch_hides_reroll_button_until_owned() -> void: var r: Array = await _open_at("ipad", NO_REROLLS) var panel: LevelUpPanel = r[0] assert_false(panel._reroll_btn.visible) assert_true(panel._skip_btn.visible)
# A pad on an iPhone/iPad: the hint row comes back, without the X/B parts the buttons already carry.func test_pad_on_touch_leaves_x_and_b_to_the_buttons() -> void: ButtonPrompt.force_style = "xbox" var r: Array = await _open_at("ipad", ACTIONS) var panel: LevelUpPanel = r[0] assert_false(_hint_has(panel, "x")) assert_false(_hint_has(panel, "b"))
# TV (and a Mac pad / keyboard): the full five-part row, armed label included, inside the title-safe area.func test_tv_hint_row_fits_in_every_style_and_state() -> void: for style: String in ["xbox", "playstation", "key"]: ButtonPrompt.force_style = style var r: Array = await _open_at("tv", ACTIONS) var panel: LevelUpPanel = r[0] var pr: Dictionary = r[1] var vp: Vector2 = pr["vp"] var ins: Vector4 = pr["insets"] for armed in [false, true]: if armed: panel._finish_entry(panel._entry_gen) panel.request_skip() await get_tree().process_frame var row := panel._hint.get_global_rect() assert_true(_hint_has(panel, "x"), "%s REROLL prompt" % style) assert_gte(row.position.x, ins.x - 0.5, "%s armed=%s hint left inside title-safe" % [style, armed]) assert_lte(row.end.x, vp.x - ins.z + 0.5, "%s armed=%s hint right inside title-safe" % [style, armed])-
Step 3: Run and watch fail (import first).
-
Step 4: LevelUpBase contract. In
ui/level_up_base.gd:
OLD:
signal sheet_closed
var audio: AudioManagerNEW:
signal sheet_closed# Level-up reroll / skip (issue #77): the player asked; main resolves it (UpgradeSystem.reroll_choices /# skip_upgrade) and re-opens or resumes. Only the glass panel emits them.signal reroll_requestedsignal skip_requested
var audio: AudioManager
# UpgradeSystem.action_state() for the next show_for ({} = no actions: networked runs, the legacy# panel). main calls this before every show_for. The legacy panel ignores it.func set_actions(_actions: Dictionary) -> void: pass- Step 5: Panel state + tunable. In
ui/level_up_panel.gd:
OLD:
static var STATS_BTN_CLEAR: float = 8.0 # touch: gap between the STATS button's top and the stripNEW:
static var STATS_BTN_CLEAR: float = 8.0 # touch: gap between the STATS button's top and the strip# Level-up SKIP (issue #77) needs a second press within this window (B sits next to A on every pad).static var SKIP_CONFIRM_S: float = 1.5OLD:
var _stats_btn: Buttonvar _sim: SimNEW:
var _stats_btn: Buttonvar _reroll_btn: Button # touch only (issue #77), bottom-left beside SKIPvar _skip_btn: Buttonvar _actions: Dictionary = {} # UpgradeSystem.action_state() from main; {} = no reroll/skipvar _skip_armed := falsevar _skip_gen := 0 # bumps on every arm/disarm, so a stale confirm timer does nothingvar _sim: Sim- Step 6: Touch buttons. In
_ready:
OLD:
_stats_btn.pressed.connect(toggle_sheet) add_child(_stats_btn) TokenBind.bind(_stats_btn, _apply_stats_btn_style)NEW:
_stats_btn.pressed.connect(toggle_sheet) add_child(_stats_btn) TokenBind.bind(_stats_btn, _apply_stats_btn_style) _reroll_btn = _make_action_btn(request_reroll) _skip_btn = _make_action_btn(request_skip) _refresh_actions()Add after _layout_stats_btn:
func _make_action_btn(on_press: Callable) -> Button: var b := Button.new() b.set_anchors_preset(Control.PRESET_BOTTOM_LEFT) b.theme = NeonTheme.get_theme() b.focus_mode = Control.FOCUS_NONE # the panel handles pads itself; no focus ring on tvOS b.pressed.connect(on_press) add_child(b) return b
# Touch REROLL / SKIP (issue #77): bottom-left, on the STATS button's baseline and height (44 pt,# inside the safe area), so strip_bottom's clearance is unchanged.func _layout_action_btns() -> void: if _reroll_btn == null: return var upt := Platform.units_per_pt(get_viewport()) var ins := Platform.safe_insets_units(get_viewport()) var h := maxf(38.0, 44.0 * upt) var w := maxf(124.0, 92.0 * upt) var x := ins.x + 24.0 for b: Button in [_reroll_btn, _skip_btn]: if not b.visible: continue b.offset_left = x b.offset_right = x + w b.offset_bottom = -(ins.w + 14.0) b.offset_top = b.offset_bottom - h x += w + 12.0Find every call of _layout_stats_btn() (grep -n "_layout_stats_btn()" ui/level_up_panel.gd) and add _layout_action_btns() directly after each one.
- Step 7: Strip clearance over every button.
OLD:
func strip_bottom() -> float: if _stats_btn == null or not _stats_btn.visible: return STRIP_BOTTOM return maxf(STRIP_BOTTOM, -_stats_btn.offset_top + STATS_BTN_CLEAR)NEW:
func strip_bottom() -> float: var b := STRIP_BOTTOM for btn: Button in [_stats_btn, _reroll_btn, _skip_btn]: if btn != null and btn.visible: b = maxf(b, -btn.offset_top + STATS_BTN_CLEAR) return bA typed for btn: Button in [...] over an array holding nulls is fine in Godot 4.7. If the warnings-as-errors parser rejects it, use an untyped loop with var bt := btn as Button.
- Step 8: Actions API. Add after
configure_tutorial:
func set_actions(actions: Dictionary) -> void: _actions = actions.duplicate() _set_skip_armed(false)
func _reroll_shown() -> bool: return bool(_actions.get("offered", false)) and int(_actions.get("rerolls_max", 0)) > 0
func _skip_shown() -> bool: return bool(_actions.get("offered", false))
func _actions_live() -> bool: return visible and _accepting and not _cards.is_empty() and not (_sheet != null and _sheet.is_open())
func _skip_text() -> String: return "PRESS AGAIN TO SKIP" if _skip_armed else "SKIP +%d%% XP" % int(_actions.get("skip_pct", 0))
func request_reroll() -> void: if not _reroll_shown() or not _actions_live(): return if not bool(_actions.get("can_reroll", false)): _flash_hint() # REROLL (0): say so, change nothing _ui_nav() return _set_skip_armed(false) _ui_nav() reroll_requested.emit()
func request_skip() -> void: if not _skip_shown() or not _actions_live() or not bool(_actions.get("can_skip", false)): return _ui_nav() if not _skip_armed: _set_skip_armed(true) var gen := _skip_gen if is_inside_tree(): get_tree().create_timer(maxf(SKIP_CONFIRM_S, 0.05), true, false, true).timeout.connect(func() -> void: if gen == _skip_gen: _set_skip_armed(false)) return _set_skip_armed(false) skip_requested.emit()
func _set_skip_armed(on: bool) -> void: _skip_armed = on _skip_gen += 1 _refresh_actions()
# Touch buttons' text/visibility/enabled + the hint row, from _actions and the sheet state.func _refresh_actions() -> void: if _reroll_btn != null: var sheet_open := _sheet != null and _sheet.is_open() var p2_gated := _gate_device != -1 and not _gate_p1 # P2's panel ignores the mouse/touch, as its cards do _reroll_btn.visible = _reroll_shown() and not sheet_open _reroll_btn.text = "REROLL %d" % int(_actions.get("rerolls_left", 0)) _reroll_btn.disabled = p2_gated or not bool(_actions.get("can_reroll", false)) _skip_btn.visible = _skip_shown() and not sheet_open _skip_btn.text = "TAP AGAIN" if _skip_armed else "SKIP" _skip_btn.disabled = p2_gated _layout_action_btns() _layout_strip() if _hint != null: _rebuild_hint()_rebuild_hint is defined later in the file and is called from _ready before the buttons exist; _refresh_actions checks both for null.
- Step 9: Hint row. In
_rebuild_hint:
OLD:
parts = [["a", "CHOOSE", "ENTER"]] if _stats_enabled: parts.append(["y", "SHIP STATS", "TAB"]) if _sheet != null and _sheet.is_open():NEW:
parts = [["a", "CHOOSE", "ENTER"]] if _stats_enabled: parts.append(["y", "SHIP STATS", "TAB"]) # Reroll / skip (issue #77): off while the sheet is open; on touch the buttons carry them. if not (_sheet != null and _sheet.is_open()) and _reroll_btn == null: if _reroll_shown(): parts.append(["x", "REROLL (%d)" % int(_actions.get("rerolls_left", 0)), "R"]) if _skip_shown(): parts.append(["b", _skip_text(), "BKSP"]) if _sheet != null and _sheet.is_open():- Step 10: Input. In
_input:
OLD:
if (jpress and jb.button_index == JOY_BUTTON_B) or (kpress and key.keycode == KEY_BACKSPACE): if _sheet.is_open(): toggle_sheet() # B only ever closes the sheet, never the level-up panel _handled() returnNEW:
if (jpress and jb.button_index == JOY_BUTTON_B) or (kpress and key.keycode == KEY_BACKSPACE): if _sheet.is_open(): toggle_sheet() # B closes the sheet first; it never closes the level-up panel else: request_skip() # sheet closed: SKIP, two presses within SKIP_CONFIRM_S (issue #77) _handled() return if (jpress and jb.button_index == JOY_BUTTON_X) or (kpress and key.keycode == KEY_R): request_reroll() # no-op while the sheet is open, before the entry ends, or with none owned _handled() return-
Step 11: Lifecycle hooks.
- In
show_for, after_accepting = false, add_set_skip_armed(false). - In
toggle_sheet, after_update_stats_pulse()and just before its final_ui_nav(), add_refresh_actions(), so the buttons hide or show with the sheet._rebuild_hintalready runs there; the extra rebuild is cheap. - In
hide_panel, after_gate_p1 = true, add_actions = {}and_skip_armed = false. - In
show_for, after_gate_p1 = pilot_idx == 0, add_refresh_actions(), so thedisabledstate follows the new gate.
- In
-
Step 12: Tunable + LiveScope + docs.
net/tunables_registry.gd. OLD:{"key": "LevelUpPanel.RECOMMEND_INSET_X", "script": LEVEL_UP_PANEL, "name": "RECOMMEND_INSET_X", "min": 0.0, "max": 60.0, "local": true},. NEW: the same line, followed by{"key": "LevelUpPanel.SKIP_CONFIRM_S", "script": LEVEL_UP_PANEL, "name": "SKIP_CONFIRM_S", "min": 0.3, "max": 5.0, "local": true},.net/live_scope.gd. OLD:"LevelUpPanel.RECOMMEND_INSET_X": [LIVE, HOOK_LEVELUP, "an open level-up panel is re-laid at once"],. NEW: the same line, followed by"LevelUpPanel.SKIP_CONFIRM_S": [LIVE, HOOK_LEVELUP, "read at each SKIP press; the re-lay is harmless"],. (test_layout_tunables_relayout_the_open_panel_liverequires everyLevelUpPanel.*key on HOOK_LEVELUP.)tools/tunable_docs/docs.d/level_up_cards.json: add, before the closing}, with a comma after the previous entry:
"LevelUpPanel.SKIP_CONFIRM_S": { "label": "Level-up SKIP: confirm window", "desc": "Seconds the second SKIP press (B / Backspace / tap) has to land before the skip disarms.", "unit": "s", "src": "code", "group": "Visuals & FX" }-
node tools/tunable_docs/generate.mjs && node --test control/test/tunable-docs.test.mjs. -
Step 13: Run and watch pass.
-gselect=test_level_up_panel_actions,-gselect=test_level_up_panel,-gselect=test_live_scope,-gselect=test_tunables_registry,-gselect=test_ui_sound_wiring, then the literal-ratchet test (git grep -ln "ratchet" tests | headto find its file name). -
Step 14: Look at it.
UI_CAP_SIZES=iphone,ipad,tvwithtools/ui_captureon a level-up with actions on. The README intools/ui_capture/gives the scene hook; if it cannot pass actions, capture through a Dev run in Task 5 instead. Check that the bottom-left buttons do not crowd the first card and the TV row reads at a distance. -
Step 15: Verify.
bash scripts/gate.sh. -
Step 16: Commit. Message:
ui(level-up): REROLL / SKIP prompts, pad + keyboard + touch, two-press skip #77.
Task 5: main.gd wiring
Section titled “Task 5: main.gd wiring”Model: opus (cross-cutting: main.gd)
Files:
- Modify:
main.gd - Create:
tests/test_levelup_actions_main.gd
Interfaces:
func _levelup_actions() -> Dictionaryfunc _on_panel_levelup_reroll() -> voidfunc _on_panel_levelup_skip() -> voidfunc _on_levelup_reroll() -> bool # false = refusedfunc _on_levelup_skip() -> boolfunc _after_levelup_resolved() -> void # the chain / dimension / resume tail of _on_upgrade_chosen- Step 1: Write the failing tests.
tests/test_levelup_actions_main.gd:
extends GutTest
# main's level-up reroll / skip (issue #77): the real panel path. _open_levelup rolls and shows; the# panel's reroll_requested / skip_requested reach _on_panel_levelup_*; main rerolls in place or resumes# through the same chain/resume tail a card pick uses, and records replay + timeline.
const DT := 1.0 / 60.0
func after_each() -> void: Tunables.reset() DevFlags.load_for(false, {})
func _boot_main() -> Node: var m = preload("res://main.tscn").instantiate() add_child_autofree(m) await get_tree().process_frame return m
func _start(m: Node, rerolls: int) -> void: m._on_mode_chosen("survival") m.dialogue_box.clear() m._survival_intro_showing = false m.sim.player.dev_invuln = true m._auto = false m.sim.meta_rerolls = rerolls # what MetaRunSetup.apply sets from a bought tier (Task 2 pins that path)
# Banks a full bar, ticks until the level-up freezes the run, and waits for the panel to show.func _level_up(m: Node) -> void: m.sim.player.xp = m.sim.player.xp_to_next for _i in 10: if m._sim_freeze_reason() == "levelup": break m._physics_process(DT) assert_eq(m._sim_freeze_reason(), "levelup") for _i in 10: if m.level_up.visible: break await get_tree().process_frame assert_true(m.level_up.visible, "the panel is up")
func test_reroll_replaces_the_offer_and_keeps_the_run_frozen() -> void: var m = await _boot_main() _start(m, 1) await _level_up(m) var before: Array = m._current_choice_ids.duplicate() m._on_panel_levelup_reroll() assert_eq(m.sim.player.rerolls_used, 1) assert_ne(m._current_choice_ids, before, "a fresh offer") assert_eq(m._sim_freeze_reason(), "levelup", "still choosing") assert_true(m.level_up.visible) var shown: Array = [] for c in m.level_up._cards: shown.append(String(c.get_meta("id"))) shown.sort() var expect: Array = m._current_choice_ids.duplicate() expect.sort() assert_eq(shown, expect, "the panel shows the new cards") assert_eq(int(m.level_up._actions["rerolls_left"]), 0, "the prompt now reads (0)")
func test_reroll_refused_without_a_tier() -> void: var m = await _boot_main() _start(m, 0) await _level_up(m) var before: Array = m._current_choice_ids.duplicate() assert_false(m._on_levelup_reroll()) assert_eq(m._current_choice_ids, before)
func test_skip_resumes_with_the_refund() -> void: var m = await _boot_main() _start(m, 0) await _level_up(m) var p: PlayerState = m.sim.player var xp0: float = p.xp var bar: float = p.xp_to_next m._on_panel_levelup_skip() assert_eq(p.pending_levelups, 0) assert_eq(p.levelup_skips, 1) assert_almost_eq(p.xp, xp0 + bar * UpgradeSystem.SKIP_XP_REFUND_FRAC, 0.01) assert_false(m.level_up.visible, "the panel closed") assert_null(m._leveling_pilot) for _i in 8: if m._sim_freeze_reason() != "levelup": break m._process(1.0) assert_ne(m._sim_freeze_reason(), "levelup", "the run resumes after the READY beat")
func test_actions_reach_the_timeline() -> void: var m = await _boot_main() _start(m, 1) await _level_up(m) m._on_panel_levelup_reroll() m._on_panel_levelup_skip() var rows: Array = m._timeline.levelups assert_eq(String(rows[-2][3]), UpgradeSystem.REROLL_ID, "the replaced offer is stamped #reroll") assert_eq(String(rows[-1][3]), UpgradeSystem.SKIP_ID, "the new offer is stamped #skip") assert_eq(rows[-1][1], rows[-2][1], "same level")
func test_actions_are_recorded_in_the_replay() -> void: var m = await _boot_main() DevFlags.load_for(true, {"record_replay": true}) _start(m, 1) await _level_up(m) m._on_panel_levelup_reroll() m._on_panel_levelup_skip() var ids: Array = [] for p: Dictionary in m._replay_rec.picks: ids.append(String(p["id"])) assert_eq(ids.slice(-2), [UpgradeSystem.REROLL_ID, UpgradeSystem.SKIP_ID])
func test_the_panel_gets_the_action_state_on_open() -> void: var m = await _boot_main() _start(m, 2) await _level_up(m) assert_true(bool(m.level_up._actions["offered"])) assert_eq(int(m.level_up._actions["rerolls_left"]), 2)If DevFlags.load_for(true, {"record_replay": true}) has to come before _on_mode_chosen for _replay_rec to exist (it does in test_replay_picks_main.gd), keep the order shown: the flag first, then _start.
-
Step 2: Run and watch fail (import first).
-
Step 3: Connect the signals. OLD:
level_up.chosen.connect(_on_panel_upgrade_chosen) level_up.sheet_opened.connect(_on_stats_sheet_opened)NEW:
level_up.chosen.connect(_on_panel_upgrade_chosen) level_up.reroll_requested.connect(_on_panel_levelup_reroll) level_up.skip_requested.connect(_on_panel_levelup_skip) level_up.sheet_opened.connect(_on_stats_sheet_opened)- Step 4: Pass the action state on open. In
_open_levelup, OLD:
_sync_levelup_hud() level_up.show_for(sim, _leveling_pilot, ids, _leveling_pilot_device(), _leveling_pilot_idx()) _levelup_opening = falseNEW:
_sync_levelup_hud() level_up.set_actions(_levelup_actions()) level_up.show_for(sim, _leveling_pilot, ids, _leveling_pilot_device(), _leveling_pilot_idx()) _levelup_opening = falseIn _net_levelup_ui, OLD:
_sync_levelup_hud() level_up.show_for(sim, _leveling_pilot, offer, -1, k)NEW:
_sync_levelup_hud() level_up.set_actions({}) # v1: no reroll/skip in networked runs (not SimCommands yet, spec §7) level_up.show_for(sim, _leveling_pilot, offer, -1, k)- Step 5: Extract the resolve tail. In
_on_upgrade_chosen, OLD:
sim.upgrade_system.apply_upgrade(sim, _leveling_pilot, id) if _next_leveling_pilot() != null and _levelup_gate_open():NEW:
sim.upgrade_system.apply_upgrade(sim, _leveling_pilot, id) _after_levelup_resolved()
# After a level-up is resolved (a card applied or the level skipped): open the next pilot's / level's# panel, or the dimension choice, or resume with the READY beat. Shared by _on_upgrade_chosen and# _on_levelup_skip (issue #77).func _after_levelup_resolved() -> void: if _next_leveling_pilot() != null and _levelup_gate_open():The rest of the old function body (from level_up.hide_panel(true) to pilot.iframe_timer = RESUME_IFRAMES) becomes the body of _after_levelup_resolved, unchanged and at the same indentation. Check that nothing in that tail reads the id parameter: sed -n '/^func _after_levelup_resolved/,/^func /p' main.gd | grep -n "\bid\b" must print nothing.
- Step 6: The handlers. Add after
_on_panel_upgrade_chosen:
# Level-up reroll / skip (issue #77). The panel signals are human presses: void a learning job first,# as a card pick does. The replay auto-pick path calls _on_levelup_reroll / _on_levelup_skip directly.func _on_panel_levelup_reroll() -> void: _cancel_persona_job() _on_levelup_reroll()
func _on_panel_levelup_skip() -> void: _cancel_persona_job() _on_levelup_skip()
func _levelup_actions() -> Dictionary: if sim == null or _leveling_pilot == null or _net_run(): return {} return sim.upgrade_system.action_state(sim, _leveling_pilot)
func _levelup_level(p: PlayerState) -> int: return p.level - p.pending_levelups + 1 # the level THIS panel belongs to (see _open_levelup)
# Rerolls the open offer in place: same pilot, same level, new cards; the run stays frozen.func _on_levelup_reroll() -> bool: if sim == null or _leveling_pilot == null or _net_run(): return false var shown: Array[String] = [] for v: Variant in _current_choice_ids: shown.append(String(v)) var ids := sim.upgrade_system.reroll_choices(sim, _leveling_pilot, 3, shown) if ids.is_empty(): return false var idx := maxi(_leveling_pilot_idx(), 0) var lvl := _levelup_level(_leveling_pilot) _timeline.note_picked(UpgradeSystem.REROLL_ID) _timeline.note_offered(sim.run_time, lvl, ids, idx) if _replay_rec != null: _replay_rec.note_pick(idx, UpgradeSystem.REROLL_ID) event_queue.push("levelup_action", LevelUpTelemetry.action_payload("reroll", sim.run_time, lvl, idx, shown, sim.upgrade_system.rerolls_left(sim, _leveling_pilot), 0.0)) _current_choice_ids = ids level_up.hide_panel(true) # keep the frozen-frame snapshot, as a chained open does level_up.set_actions(_levelup_actions()) level_up.show_for(sim, _leveling_pilot, ids, _leveling_pilot_device(), _leveling_pilot_idx()) if _auto or _capturing: _auto_pick_timer = 0.8 return true
# Gives up this level for the XP refund, then the same chain / dimension / resume tail as a pick.func _on_levelup_skip() -> bool: if sim == null or _leveling_pilot == null or _net_run(): return false if not sim.upgrade_system.can_skip(sim, _leveling_pilot): return false var idx := maxi(_leveling_pilot_idx(), 0) var lvl := _levelup_level(_leveling_pilot) var shown: Array = _current_choice_ids.duplicate() var refund := sim.upgrade_system.skip_upgrade(sim, _leveling_pilot) _timeline.note_picked(UpgradeSystem.SKIP_ID) if _replay_rec != null: _replay_rec.note_pick(idx, UpgradeSystem.SKIP_ID) event_queue.push("levelup_action", LevelUpTelemetry.action_payload("skip", sim.run_time, lvl, idx, shown, sim.upgrade_system.rerolls_left(sim, _leveling_pilot), refund)) _auto_pick_timer = 0.0 _after_levelup_resolved() return true_leveling_pilot_idx() must be read before skip_upgrade changes pending, and before _after_levelup_resolved clears _leveling_pilot. The code above does that.
- Step 7: Replay auto-pick accepts the actions. OLD:
var recorded := _replay_recorded_pick() _on_upgrade_chosen(recorded if recorded != "" else auto_player.pick_upgrade_for(sim, _leveling_pilot, _current_choice_ids))NEW:
var recorded := _replay_recorded_pick() var acted := false if recorded == UpgradeSystem.REROLL_ID: acted = _on_levelup_reroll() # re-arms the timer; the next recorded pick plays on the new cards elif recorded == UpgradeSystem.SKIP_ID: acted = _on_levelup_skip() if (recorded == UpgradeSystem.REROLL_ID or recorded == UpgradeSystem.SKIP_ID) and not acted: _replay_diverged("recorded %s at tick %d was refused: the run has diverged" % [recorded, _replay_tick]) recorded = "" if not acted: _on_upgrade_chosen(recorded if recorded != "" else auto_player.pick_upgrade_for(sim, _leveling_pilot, _current_choice_ids))In _replay_recorded_pick, OLD:
var id := String(p.get("id", "")) if int(p.get("tick", -1)) != _replay_tick or not _current_choice_ids.has(id): _replay_diverged("pick %d '%s' (tick %d) does not fit tick %d offer %s: the run has diverged" % [ _replay_pick_i - 1, id, int(p.get("tick", -1)), _replay_tick, _current_choice_ids]) return id if _current_choice_ids.has(id) else "" return idNEW:
var id := String(p.get("id", "")) # A recorded reroll / skip (issue #77) fits any offer; the caller reports it if it is refused. var fits := _current_choice_ids.has(id) or id == UpgradeSystem.REROLL_ID or id == UpgradeSystem.SKIP_ID if int(p.get("tick", -1)) != _replay_tick or not fits: _replay_diverged("pick %d '%s' (tick %d) does not fit tick %d offer %s: the run has diverged" % [ _replay_pick_i - 1, id, int(p.get("tick", -1)), _replay_tick, _current_choice_ids]) return id if fits else "" return id-
Step 8: Run and watch pass.
-gselect=test_levelup_actions_main, then the main-path neighbours:test_replay_picks_main,test_dimension_popup_delay,test_lan_coop_main,test_learning_autopilot_main,test_console_autopilot,test_main,test_run_timeline,test_objective_panel. -
Step 9: Verify.
bash scripts/gate.sh. -
Step 10: Commit. Message:
main: wire level-up reroll + skip (replay, timeline, telemetry, shared resolve tail) #77.
Task 6: Replay driver playback
Section titled “Task 6: Replay driver playback”Model: sonnet
Files:
-
Modify:
input/replay_driver.gd -
Create:
tests/test_replay_driver_actions.gd -
Step 1: Read the driver’s constructor and its existing test (
git grep -ln "ReplayDriver.new" tests). Build the new test with the same setup: how the driver is given a sim, apickslist and apicker. -
Step 2: Write the failing tests.
tests/test_replay_driver_actions.gdcovers:- A picks list
[{tick: T, pilot: 0, id: "#reroll"}, {tick: T, pilot: 0, id: <a card on the rerolled offer>}], played withmeta_rerolls = 1. Assert thatoffersholds two rows for the level, picked#rerollthen the card, thatpilot.rerolls_used == 1, and thatmismatchesis empty. Get the rerolled card id by running the same seed once with a twin sim: roll, thenreroll_choices, and take[0]. #skipplays as a skip:levelup_skips == 1, pending drained, no mismatch.#rerollwithmeta_rerolls = 0records a mismatch containing"#reroll"and applies the fallback card, so the run goes on.
Construct the driver exactly as the existing driver test does. Do not invent constructor arguments.
- A picks list
-
Step 3: Run and watch fail.
-
Step 4: Implement. In
resolve_between_ticks, OLD:
var id := _pick_for(pilot, ids) var lvl := pilot.level - pilot.pending_levelups + 1 offers.append({"tick": tick, "run_time": sim.run_time, "pilot": sim.pilots.find(pilot), "level": lvl, "ids": ids, "picked": id}) sim.upgrade_system.apply_upgrade(sim, pilot, id) leveled = trueNEW:
var id := _pick_for(pilot, ids) var lvl := pilot.level - pilot.pending_levelups + 1 # A recorded reroll (issue #77) re-rolls in place; the next recorded pick plays on the new cards. while id == UpgradeSystem.REROLL_ID: offers.append({"tick": tick, "run_time": sim.run_time, "pilot": sim.pilots.find(pilot), "level": lvl, "ids": ids, "picked": id}) var next := sim.upgrade_system.reroll_choices(sim, pilot, CHOICES, ids) if next.is_empty(): mismatches.append("pick %d '#reroll' refused at tick %d (no rerolls left)" % [picks_used - 1, tick]) id = _fallback(pilot, ids) break ids = next id = _pick_for(pilot, ids) if id == UpgradeSystem.SKIP_ID and not sim.upgrade_system.can_skip(sim, pilot): mismatches.append("pick %d '#skip' refused at tick %d" % [picks_used - 1, tick]) id = _fallback(pilot, ids) offers.append({"tick": tick, "run_time": sim.run_time, "pilot": sim.pilots.find(pilot), "level": lvl, "ids": ids, "picked": id}) if id == UpgradeSystem.SKIP_ID: sim.upgrade_system.skip_upgrade(sim, pilot) else: sim.upgrade_system.apply_upgrade(sim, pilot, id) leveled = trueIn _pick_for, OLD:
if ids.has(id): return idNEW:
if ids.has(id) or id == UpgradeSystem.REROLL_ID or id == UpgradeSystem.SKIP_ID: return id # a recorded reroll / skip (issue #77): the caller resolves (or refuses) itids is typed Array[String] from roll_upgrade_choices. reroll_choices returns the same type, so ids = next type-checks. The while loop ends: each pass spends a pick and a reroll, and a refusal breaks out.
-
Step 5: Run and watch pass. The new file, then
-gselect=test_replay_driverand-gselect=test_replay_picks_main. -
Step 6: Verify.
bash scripts/gate.sh. -
Step 7: Commit. Message:
replay: play back recorded level-up rerolls and skips #77.
Task 7: Docs, What’s New, economy note, visual QA
Section titled “Task 7: Docs, What’s New, economy note, visual QA”Model: sonnet
Files:
-
Modify:
docs/architecture/meta-and-story.md,docs/architecture/weapons-and-buildcraft.md,docs/architecture/sim-architecture.md,docs/architecture/remote-config.md,docs/architecture/dev-tools.md,CLAUDE.md,control/src/whats_new.js,docs/RELEASE-1.1.md(the 1.2-candidates line only) -
Create:
docs/research/2026-10-20-reroll-item-economy.md -
Step 1: CLAUDE.md data-flow rule. OLD:
Render/UI may ONLY read sim state; the sole mutations are `sim.tick(input)` and `sim.apply_upgrade(id)`.NEW:
Render/UI may ONLY read sim state; the sole mutations are `sim.tick(input)` and the level-up resolvers (`apply_upgrade`, `reroll_choices`, `skip_upgrade` on `sim.upgrade_system`).Before editing, run grep -n "sole mutations" CLAUDE.md and match the live text exactly.
-
Step 2: Architecture docs. Add a short paragraph to each, in the existing style:
meta-and-story.md: a Reroll bullet beside the Greed one (effectlevelup_rerolls,MetaState.rerolls_per_run→Sim.meta_rerolls, per-pilotrerolls_used, Utility tab back). Also update the “Utility stays inORDERfor future economy items” sentence to say it now holds Reroll.weapons-and-buildcraft.md: the level-up offer rules get theexcludeparam,_top_up, reroll / skip, and where they are hidden (Tutorial, App Clip, networked).sim-architecture.md: the resolvers list (see Step 1).remote-config.md: the three new keys in the tunables list, with the note that the liveness probe cannot reach them (the autopilot never rerolls or skips) and that the wiring tests throughTunables.applypin them instead.dev-tools.md§ Telemetry v2:levelup_actionin the event-types list, the three run_end fields, and “timeline_jsonpickedmay be#reroll/#skip”.
-
Step 3: What’s New. In
control/src/whats_new.js, add the item to the unbuilt entry (build: null, date: null,). If there is none, create it at the top ofWHATS_NEWwith exactly that first line:
{ build: null, date: null, title: 'Level-up Reroll and Skip', items: [ { text: 'Level-up: Reroll (new Utility shop item, 1-3 rerolls per run) and Skip (+25% XP). X / B on a pad, R / Backspace on a Mac keyboard, REROLL / SKIP buttons on iPhone and iPad. Skip asks for a second press.', where: 'Shop: Utility; any Survival or Story level-up' }, ], },Then run node --test control/test/whats-new.test.mjs.
-
Step 4: Privacy check. Read
~/Claude/bullet-heaven-site/privacy/(the page source) andmarketing/appstore/compliance.mdfor how gameplay telemetry is described. If they already cover level-up choices / gameplay events in general, change nothing and say so in the research note. If they list fields one by one, stop and report it. Do not edit the separate site repo from this branch. -
Step 5: Economy note. Run the career sim before (on
origin/main, in a scratch detached worktree) and after (this branch). Follow skillbh-economy-sim:
bash scripts/economy-careers.sh && python3 tools/economy_sim/summarize.py reports/economy/career-*.jsonWrite docs/research/2026-10-20-reroll-item-economy.md. Give it Starlight frontmatter (title: "Reroll item: economy impact (2026-10-20)"). Include: runs-to-full-shop before vs after, when the policy buys each Reroll tier, and the caveat that the autopilot never rerolls, so the item is all cost and no benefit in the sim. Do not commit reports/.
-
Step 6: Release list. In
docs/RELEASE-1.1.md, under the 1.2-candidates line that names “level-up reroll/skip”, append(spec + plan 2026-10-20; implemented on branch <name>). Do not tick it. -
Step 7: Visual QA. Dispatch
bh-visual-qaon the branch for the level-up panel with actions (iPhone, iPad, TV) and the shop Utility tab. Fix what it flags in this task. -
Step 8: Verify.
bash scripts/gate.sh(the docs-site tests check frontmatter and reachability). -
Step 9: Commit. Message:
docs: level-up reroll + skip (architecture, telemetry, What's New, economy note) #77.
Device check before merge (Chris)
Section titled “Device check before merge (Chris)”The headless gate cannot judge these. List them for Chris in the merge report:
- Apple TV with a pad: X rerolls, B arms then skips, and B still closes SHIP STATS first. The hint row reads from the couch.
- iPhone and iPad: the REROLL n / SKIP buttons sit clear of the home indicator and the first card. TAP AGAIN reads clearly.
- Shop: the Utility tab is back with Reroll and its icon. The 150 / 300 / 600 prices.
- Feel calls: the 25% refund and the 1.5 s confirm window. Both are live on the console (
UpgradeSystem.SKIP_XP_REFUND_FRAC,LevelUpPanel.SKIP_CONFIRM_S).
Self-review
Section titled “Self-review”- Spec coverage:
- §2 controls / labels: Task 4.
- §3 where offered: Tasks 1 (
_actions_allowed), 4 (legacy no-op) and 5 (net{}). - §4 shop item: Task 2.
- §5 sim: Task 1.
- §6 co-op: Task 1 test (P2 budget) and Task 4 (P2 gate disables touch).
- §7 replays: Tasks 5 and 6. Net hidden: Task 5.
- §8 telemetry: Tasks 3 and 5. Privacy check: Task 7.
- §9 tunables: Tasks 1 and 4.
- §10 What’s New: Task 7.
- Determinism:
- Every existing
roll_upgrade_choicescaller passes noexclude. - With
excludeempty, everyexclude.has(...)is false, every pool is unchanged, everyrandf/randi_rangestill runs in order, and_top_uploops over nothing. - Rerolls use only
upgrade_rng. - Pinned by
test_default_exclude_is_draw_identical_to_an_explicit_empty_one,test_reroll_never_draws_the_spawn_streamand the three baselines.
- Every existing
- Type consistency:
reroll_choicesandroll_upgrade_choicesboth returnArray[String].- main converts
_current_choice_ids(untypedArray) toArray[String]beforereroll_choices. - The driver’s
idsis already typed. - Action ids are
UpgradeSystem.REROLL_ID/SKIP_IDeverywhere, never string literals.
- Known judgement points for the reviewer:
- The
_after_levelup_resolvedextraction must not change the card-pick path. The existing main tests are the guard. - Task 3’s Worker check (JSON blob vs columns) must be reported.
- The
Execution handoff
Section titled “Execution handoff”- Chris’s decisions (2026-10-20, issue #77): rerolls come from ONE meta-shop item, tiers = 1 / 2 / 3 rerolls per run; skip is always available, free, with a small XP refund as a live tunable. Everything else is the spec author’s call, listed in spec §10 for Chris’s veto. None has been vetoed yet.
- Authorized: writing code on a branch, tests, local commits. Needs Chris’s same-day OK: merge to main, any push, Dev device installs, a remote-config patch, a telemetry Worker deploy (only if Task 3 finds named run_end columns).
- Done outside the plan: nothing. This branch (
spec-levelup-reroll, off origin/main 73328c66) holds only the spec and this plan. Nothing pushed. - Open questions for Chris: the §10 veto list, especially price (150/300/600), the Utility tab, the 25% refund and the two-press skip.
- Gotchas: zsh treats a bare
echo ==as an equals expansion, so quote it. Regeneratecontrol/src/tunable_docs.jswithnode tools/tunable_docs/generate.mjs, never by hand. Do not regenerateremote-config/manifest/build-*.json(release-time only). - Next action: execute this plan subagent-driven. Create a fresh implementation worktree off origin/main (or reuse this branch), then go Task 1 → 7 with
bh-implementer(opus for Tasks 1 and 5) and abh-reviewerpass per task.