Skip to content

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).

  • Determinism. Never draw from sim.rng. Every new draw goes through roll_upgrade_choices on upgrade_rng. The default exclude = [] must leave the pools and the upgrade_rng draw order unchanged. tests/test_determinism_survival.gd, tests/test_determinism.gd and tests/test_determinism_coop.gd must stay green with no re-pin. If one moves, stop and report it. Do not use bh-determinism-repin.
  • /sim purity. No Node, Input, Engine or Time APIs in sim/. New sim state is plain data. PlayerState.rerolls_used and levelup_skips stay out of state_checksum() / net_checksum().
  • Per-pilot pipeline. Every new UpgradeSystem function takes an explicit pilot. Never read sim.player inside them.
  • Shop state only via MetaRunSetup.apply. Do not set meta_rerolls anywhere else outside tests.
  • Tunables. Every new tuning value is a registered static var, gets a TunablesRegistry.ENTRIES row and a docs.d label, and the generated control/src/tunable_docs.js is regenerated, never hand-edited. Do not regenerate remote-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); use Tokens / TokenBind / NeonTheme. UI fit tests run at the ShopLayoutFixture device 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 . --import first, then git checkout -- project.godot audio/bus_layout.tres. Run one file with godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=<file_stem> -gexit. Never pull a whole GUT run into context: run bash scripts/check-test-count.sh and grep $TMPDIR/bh-gut-last.log (set GUT_LOG=<own path> if another worktree runs gates at the same time). The per-task verify is bash scripts/gate.sh.
  • Commits. Use git commit --file=<msgfile>. Each message ends with the session’s attribution lines. Never commit -a in 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-branch after bh-reviewer returns MERGE. No remote flag gates the feature for store players (Apple 2.3.1).
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):

# UpgradeSystem
const REROLL_ID := "#reroll"
const SKIP_ID := "#skip"
static var SKIP_XP_REFUND_FRAC: float = 0.25
static var REROLL_EXCLUDE_SHOWN: bool = true
func 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) -> void
func rerolls_left(sim: Sim, pilot: PlayerState) -> int
func can_reroll(sim: Sim, pilot: PlayerState) -> bool
func can_skip(sim: Sim, pilot: PlayerState) -> bool
func reroll_choices(sim: Sim, pilot: PlayerState, n: int, shown: Array[String]) -> Array[String] # [] = refused
func skip_xp(sim: Sim, pilot: PlayerState) -> float
func skip_upgrade(sim: Sim, pilot: PlayerState) -> float # returns the refund; 0.0 when refused
func action_state(sim: Sim, pilot: PlayerState) -> Dictionary
# {"offered": bool, "rerolls_left": int, "rerolls_max": int, "can_reroll": bool, "can_skip": bool, "skip_pct": int}
# Sim
var meta_rerolls: int = 0
# PlayerState
var rerolls_used: int = 0
var 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.
Terminal window
cd <worktree> && godot --headless --path . --import >/dev/null 2>&1; git checkout -- project.godot audio/bus_layout.tres
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_levelup_reroll_skip -gexit 2>&1 | tail -20

Expected: 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 = 0
var 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.0

NEW:

# 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 = 0

And in dev_reset_upgrades:

OLD:

player.upgrade_picks = fresh.upgrade_picks

NEW:

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.65
static 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.65
static 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 out

NEW:

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 out

OLD (classic branch tail):

for id in pool:
if result.size() >= n:
break
if not result.has(id):
result.append(id)
return result

NEW:

for id in pool:
if result.size() >= n:
break
if not result.has(id):
result.append(id)
_top_up(result, exclude, n)
return result

Then 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:

Terminal window
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.
Terminal window
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_levelup_reroll_skip -gexit 2>&1 | tail -8
godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gselect=test_determinism -gexit 2>&1 | tail -6

Expected: 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, message sim: level-up reroll + skip core (exclude-aware roll, per-pilot budget, live refund tunable) #77.


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), restore project.godot and audio/bus_layout.tres, then -gselect=test_reroll_shop_item and -gselect=test_shop_categories.

  • Step 3: Bible hand-edit (do not re-export; content-and-elemental.md):

Terminal window
python3 - <<'EOF'
import json
p = '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')
EOF
git diff --stat data/bible.json

Expected: 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 after gold_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 m

NEW:

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.


Model: sonnet

Files:

  • Create: net/levelup_telemetry.gd, tests/test_levelup_telemetry.gd
  • Modify: main.gd (_send_run_end only)

Interfaces:

class_name LevelUpTelemetry
static func action_payload(action: String, run_time: float, level: int, pilot_idx: int, shown: Array,
rerolls_left: int, xp_refund: float) -> Dictionary
static 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 LevelUpTelemetry
extends 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 runs

NEW:

"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 runs

Note: "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_at gets an optional actions arg)
  • Create: tests/test_level_up_panel_actions.gd

Interfaces:

# LevelUpBase
signal reroll_requested
signal skip_requested
func set_actions(_actions: Dictionary) -> void # UpgradeSystem.action_state(); {} = none (legacy, net)
# LevelUpPanel
static var SKIP_CONFIRM_S: float = 1.5
func request_reroll() -> void
func request_skip() -> void
  • Step 1: Let _open_at take actions. In tests/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 of test_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: AudioManager

NEW:

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_requested
signal 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 strip

NEW:

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.5

OLD:

var _stats_btn: Button
var _sim: Sim

NEW:

var _stats_btn: Button
var _reroll_btn: Button # touch only (issue #77), bottom-left beside SKIP
var _skip_btn: Button
var _actions: Dictionary = {} # UpgradeSystem.action_state() from main; {} = no reroll/skip
var _skip_armed := false
var _skip_gen := 0 # bumps on every arm/disarm, so a stale confirm timer does nothing
var _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.0

Find 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 b

A 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()
return

NEW:

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_hint already 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 the disabled state follows the new gate.
  • 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_live requires every LevelUpPanel.* 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 | head to find its file name).

  • Step 14: Look at it. UI_CAP_SIZES=iphone,ipad,tv with tools/ui_capture on a level-up with actions on. The README in tools/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.


Model: opus (cross-cutting: main.gd)

Files:

  • Modify: main.gd
  • Create: tests/test_levelup_actions_main.gd

Interfaces:

func _levelup_actions() -> Dictionary
func _on_panel_levelup_reroll() -> void
func _on_panel_levelup_skip() -> void
func _on_levelup_reroll() -> bool # false = refused
func _on_levelup_skip() -> bool
func _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 = false

NEW:

_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 = false

In _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 id

NEW:

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.


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, a picks list and a picker.

  • Step 2: Write the failing tests. tests/test_replay_driver_actions.gd covers:

    1. A picks list [{tick: T, pilot: 0, id: "#reroll"}, {tick: T, pilot: 0, id: <a card on the rerolled offer>}], played with meta_rerolls = 1. Assert that offers holds two rows for the level, picked #reroll then the card, that pilot.rerolls_used == 1, and that mismatches is empty. Get the rerolled card id by running the same seed once with a twin sim: roll, then reroll_choices, and take [0].
    2. #skip plays as a skip: levelup_skips == 1, pending drained, no mismatch.
    3. #reroll with meta_rerolls = 0 records 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.

  • 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 = true

NEW:

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 = true

In _pick_for, OLD:

if ids.has(id):
return id

NEW:

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) it

ids 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_driver and -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 (effect levelup_rerolls, MetaState.rerolls_per_run → Sim.meta_rerolls, per-pilot rerolls_used, Utility tab back). Also update the “Utility stays in ORDER for future economy items” sentence to say it now holds Reroll.
    • weapons-and-buildcraft.md: the level-up offer rules get the exclude param, _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 through Tunables.apply pin them instead.
    • dev-tools.md § Telemetry v2: levelup_action in the event-types list, the three run_end fields, and “timeline_json picked may 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 of WHATS_NEW with 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) and marketing/appstore/compliance.md for 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 skill bh-economy-sim:

Terminal window
bash scripts/economy-careers.sh && python3 tools/economy_sim/summarize.py reports/economy/career-*.json

Write 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-qa on 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.


The headless gate cannot judge these. List them for Chris in the merge report:

  1. 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.
  2. iPhone and iPad: the REROLL n / SKIP buttons sit clear of the home indicator and the first card. TAP AGAIN reads clearly.
  3. Shop: the Utility tab is back with Reroll and its icon. The 150 / 300 / 600 prices.
  4. 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).
  • 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_choices caller passes no exclude.
    • With exclude empty, every exclude.has(...) is false, every pool is unchanged, every randf / randi_range still runs in order, and _top_up loops 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_stream and the three baselines.
  • Type consistency:
    • reroll_choices and roll_upgrade_choices both return Array[String].
    • main converts _current_choice_ids (untyped Array) to Array[String] before reroll_choices.
    • The driver’s ids is already typed.
    • Action ids are UpgradeSystem.REROLL_ID / SKIP_ID everywhere, never string literals.
  • Known judgement points for the reviewer:
    • The _after_levelup_resolved extraction 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.
  • 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. Regenerate control/src/tunable_docs.js with node tools/tunable_docs/generate.mjs, never by hand. Do not regenerate remote-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 a bh-reviewer pass per task.