Skip to content

Tutorial Stage Gating 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: Make Tutorial mode teach one mechanic at a time — nothing on the ship or interface is usable/visible before its own teaching beat fires — instead of today’s Tutorial, which narrates every mechanic but leaves them all live from tick 1.

Architecture: One new primitive, StoryState.tutorial_locked(id: String) -> bool (does not exist yet — despite the design doc citing it as “already exists,” it was never built; Task 1 adds it), used identically at every gate site: if story != null and story.tutorial_locked(id): <block/hide>. Every gate is scoped by tutorial_cap, not bare story != null, so ordinary (currently hidden) Story mode and Survival (story == null always) are both unaffected by construction — no special-casing needed anywhere. Two structural fixes ship alongside the gates: a solo_spawn wave-advance mode so Tutorial’s first fight never has more than one enemy alive at once (removes a level-up multi-panel-stack risk at the root), and a required fix to tools/story_board/capture.gd’s pre-existing deadlock on the level-up panel (a live risk today, independent of this work).

Tech Stack: Godot 4.7.1, typed GDScript, GUT 9.7.1 test framework.

  • /sim files (sim/story_state.gd, sim/sim.gd, sim/story_director.gd, sim/drone_director.gd) must stay pure — no Node/render/Input/Engine/Time APIs. All mode-awareness flows in as data (story.tutorial_cap, story.seen_dialogue), never read main._mode from /sim.
  • Every gate site uses sim.story.tutorial_locked(id) — never a hand-rolled seen_dialogue.has(id) check. tutorial_locked() folds in tutorial_cap; a hand-rolled check would leak new gating into ordinary (non-capped) Story mode, whose story is also non-null. The one deliberate exception is Task 12 (_roll_dimension_choice), which gates on story != null alone — called out explicitly there.
  • Every gate site outside /sim (main.gd, ui/*.gd) uses the full guarded form: if sim.story != null and sim.story.tutorial_locked(id): ... — these files run in every mode including Survival, where sim.story is always null. No shorthand.
  • Never call MetaStore.save_state() in a test. Writes the real user://meta.json file — no test-sandboxed path. Set meta = null first if a test path would otherwise trigger _bank_run()’s save call (see tests/test_meta_store_debug.gd’s header comment).
  • Full design context: docs/superpowers/specs/2026-08-10-tutorial-stage-gating-design.md. That doc’s own “Decisions confirmed by Chris” section and per-stage detail are the source of truth for why; this plan is the source of truth for exact steps, and corrects several stale line-number/API citations the design doc got wrong (flagged inline below where relevant).
  • After the whole plan lands: run the full GUT suite + scripts/check-test-count.sh + both determinism baselines (Task 13) before considering this done.

Task 1: StoryState.tutorial_locked() — the one gate primitive every other task depends on

Section titled “Task 1: StoryState.tutorial_locked() — the one gate primitive every other task depends on”

Files:

  • Modify: sim/story_state.gd (add method, after mark_dialogue_seen())
  • Test: tests/test_story_director.gd

Interfaces:

  • Produces: StoryState.tutorial_locked(id: String) -> bool. Every later task in this plan calls this — it does not exist in the codebase today (confirmed via repo-wide grep: zero hits outside the design doc’s own prose).

  • Step 1: Write the failing tests

In tests/test_story_director.gd, add at the end of the file:

func test_tutorial_locked_true_when_tutorial_cap_and_not_seen():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true) # tutorial_cap=true
assert_true(sim.story.tutorial_locked("some_beat"), "locked: tutorial_cap on, beat unseen")
func test_tutorial_locked_false_once_seen():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.story.mark_dialogue_seen("some_beat")
assert_false(sim.story.tutorial_locked("some_beat"), "unlocked once the beat has fired")
func test_tutorial_locked_false_outside_tutorial_cap():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map()) # tutorial_cap defaults false -- ordinary Story mode
assert_false(sim.story.tutorial_locked("some_beat"), "never locked outside the capped tutorial")
  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Expected: FAIL — tutorial_locked is not a member of StoryState.

  • Step 3: Add the method

In sim/story_state.gd, find:

func mark_dialogue_seen(id: String) -> bool:
# returns true the FIRST time (so callers can fire-once)
if seen_dialogue.has(id):
return false
seen_dialogue[id] = true
return true

Add immediately after:

# The single gate primitive every tutorial-stage-gating call site uses (see
# docs/superpowers/specs/2026-08-10-tutorial-stage-gating-design.md). True only for the capped
# Tutorial (tutorial_cap) AND only until the matching teaching beat has fired -- ordinary Story
# mode (tutorial_cap == false) is always unlocked here, so re-exposing Story later is unaffected.
func tutorial_locked(id: String) -> bool:
return tutorial_cap and not seen_dialogue.has(id)
  • Step 4: Run to verify they pass

Same command as Step 2. Expected: PASS, all three.

  • Step 5: Commit
Terminal window
git add sim/story_state.gd tests/test_story_director.gd
git commit -m "feat(story): add StoryState.tutorial_locked() gate primitive"

Task 2: Stage 0 — block local co-op join for the duration of Tutorial

Section titled “Task 2: Stage 0 — block local co-op join for the duration of Tutorial”

Files:

  • Modify: main.gd:1133-1137 (the P2-join condition; extract a pure decision helper, matching this repo’s own established pattern — see main._should_force_tutorial() in the 2026-07-21 tutorial-onboarding plan, and tests/test_local_coop_input.gd’s header comment on why joypad hardware reads (Input.is_joy_button_pressed) aren’t simulated headlessly and the pure seam behind them is tested directly instead)
  • Test: tests/test_main.gd

Interfaces:

  • Consumes: main._mode, Sim.pilots, main.V01_LOCK_COOP (pre-existing const, main.gd:155).

  • Produces: main._can_p2_join(sim: Sim, mode: String) -> bool (new, pure, instance method — the real decision seam the actual join code calls, so tests exercise the same logic production code runs rather than a duplicated copy of the condition).

  • Step 1: Write the failing tests

In tests/test_main.gd, add at the end of the file:

# ── Stage 0: co-op join blocked during Tutorial (2026-08-10) ────────────────────────────────
func test_can_p2_join_false_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
var m = preload("res://main.gd").new()
assert_false(m._can_p2_join(sim, "tutorial"), "join is blocked for the duration of Tutorial")
m.free()
func test_can_p2_join_true_in_survival_with_one_pilot():
var sim := Sim.new(1, SimContentFixture.db())
var m = preload("res://main.gd").new()
assert_true(m._can_p2_join(sim, "survival"), "join is allowed in Survival with only P1 present")
m.free()
func test_can_p2_join_false_once_two_pilots_present():
var sim := Sim.new(1, SimContentFixture.db())
sim.add_pilot()
var m = preload("res://main.gd").new()
assert_false(m._can_p2_join(sim, "survival"), "no 3rd pilot -- unaffected by this task, pre-existing behavior")
m.free()
  • Step 2: Run to verify the first fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Expected: test_can_p2_join_false_in_tutorial FAILS (_can_p2_join doesn’t exist yet); the other two either fail on the same missing-method error or, once the method exists in a later run, pass as regression guards for the pre-existing V01_LOCK_COOP/pilot-count behavior this task doesn’t change.

  • Step 3: Extract the pure helper and use it at the real call site

In main.gd, find (lines 1133-1137):

var playable := input_router.playable_joypads()
if not V01_LOCK_COOP and sim.pilots.size() < 2 and playable.size() >= 2:
if Input.is_joy_button_pressed(playable[1], JOY_BUTTON_A):
_auto = false
sim.add_pilot()

Replace with:

var playable := input_router.playable_joypads()
if _can_p2_join(sim, _mode) and playable.size() >= 2:
if Input.is_joy_button_pressed(playable[1], JOY_BUTTON_A):
_auto = false
sim.add_pilot()

Directly above func _is_story() -> bool: (or any other existing pure-decision-helper cluster in the file — match wherever _should_force_tutorial-style helpers already live, if this plan’s earlier tasks haven’t added one there first), add:

# Pure decision, no I/O -- unit-testable without a real second joypad device (which can't be
# simulated headlessly, see tests/test_local_coop_input.gd's header comment). Local co-op stays
# blocked for the whole Tutorial run (2026-08-10) -- a second pilot joining mid-Tutorial would
# get an empty active_weapon_ids with no path to any of the pickups Tutorial's staged gates hand
# out one at a time to a single pilot.
func _can_p2_join(sim: Sim, mode: String) -> bool:
return not V01_LOCK_COOP and mode != "tutorial" and sim.pilots.size() < 2
  • Step 4: Run to verify all tests pass

Same command as Step 2. Expected: PASS on all three.

  • Step 5: Commit
Terminal window
git add main.gd tests/test_main.gd
git commit -m "feat(story): block local co-op join for the duration of Tutorial mode"

Task 3: Stage 1 — movement intro, remove ControlHint for Tutorial, force tactical HUD visible

Section titled “Task 3: Stage 1 — movement intro, remove ControlHint for Tutorial, force tactical HUD visible”

Files:

  • Modify: data/story.json (new move_intro dialogue node, attach to ember_entry’s on_enter)
  • Modify: ui/hud.gd (add suppress_story_label, update the story-label gate)
  • Modify: main.gd:942-948 (remove ControlHint for Tutorial; add the shared dock-force-visible fix at the same site)
  • Modify: main.gd (the control_hint.advance() per-frame call, ~line 1300-1307 — must not crash now that control_hint is never created in Tutorial either)
  • Test: tests/test_main.gd, tests/test_hud.gd

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1).
  • Produces: Hud.suppress_story_label: bool (new field, default false).

Note on ControlHint: research confirmed its only real callers are main.gd:41 (var decl), main.gd:947 (instantiation), and main.gd:1300-1307 (_process()’s per-frame advance()/done() calls) — all in main.gd — plus its own class file and tests/test_control_hint.gd. This task removes it for Tutorial mode entirely (it’s now fully redundant — Stages 1/2/3/4/9 each teach individually what it used to summarize in one blanket toast) rather than just leaving it dead-but-present, since it visually collides with the new move_intro dialogue box (same bottom-of-screen position) and runs on its own unfrozen wall-clock timer that would desync from the dialogue-driven freeze. control_hint stays null for every mode now; is_instance_valid(control_hint) at the per-frame call site already returns false for null, so no crash — but delete ui/control_hint.gd and tests/test_control_hint.gd too, since nothing constructs it anymore anywhere in the tree (confirmed no other live caller).

  • Step 1: Write the failing tests

In tests/test_main.gd, find the existing pair (added by the 2026-07-21 tutorial-onboarding plan):

# ── Control hint stays off for Survival (2026-07-05, Chris) — Tutorial is the new onboarding
# entry point (2026-07-21) and gets its own hint below; this removal is unchanged for Survival.
func test_control_hint_is_not_shown_in_survival() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
assert_null(m.control_hint, "the controls-reminder overlay must not appear in Survival")
func test_control_hint_is_shown_in_tutorial() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
assert_not_null(m.control_hint, "the controls-reminder overlay appears at the start of a Tutorial run")

Replace with (both modes now expect no ControlHint — the staged dialogue sequence replaces it):

# ── Control hint removed entirely (2026-08-10) — Stages 1-4/9 teach movement/weapons/dash/decoy
# individually via staged dialogue now, making the old blanket toast fully redundant and visually
# colliding with the new move_intro dialogue box. Survival's 2026-07-05 removal (commit c7ef9d9)
# and Tutorial's 2026-07-21 addition (commit range around the tutorial-onboarding plan) are both
# superseded by this: no mode ever shows it.
func test_control_hint_is_never_shown_in_survival() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("survival")
assert_null(m.control_hint, "the controls-reminder overlay must not appear in Survival")
func test_control_hint_is_never_shown_in_tutorial() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
assert_null(m.control_hint, "staged dialogue replaces the old blanket ControlHint toast")

In tests/test_hud.gd, add at the end of the file:

func test_story_label_hidden_when_suppress_story_label_true():
var hud := Hud.new()
add_child_autofree(hud)
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(StoryData.from_arrays(
[{"id": "r", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}],
[], "r"))
hud.suppress_story_label = true
hud.update_hud(sim)
assert_false(hud._story_label.visible, "label stays hidden while suppressed, even in a story run")
func test_story_label_shown_when_not_suppressed():
var hud := Hud.new()
add_child_autofree(hud)
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(StoryData.from_arrays(
[{"id": "r", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}],
[], "r"))
hud.suppress_story_label = false
hud.update_hud(sim)
assert_true(hud._story_label.visible, "label shows normally once not suppressed")
  • Step 2: Run to verify all four fail (or note the pre-existing pair inverts)

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_hud -gexit Expected: test_control_hint_is_never_shown_in_tutorial FAILS (today’s code still creates one); test_story_label_* FAIL (suppress_story_label doesn’t exist on Hud yet — script error).

  • Step 3: Add suppress_story_label to Hud and gate the story label with it

In ui/hud.gd, find:

if sim.story != null:
_story_label.visible = true
_story_label.text = _story_objective(sim)
else:
_story_label.visible = false

Replace with:

if sim.story != null and not suppress_story_label:
_story_label.visible = true
_story_label.text = _story_objective(sim)
else:
_story_label.visible = false

Find the class-level var block near _story_label’s declaration and add a new field (place it near the top of the file’s var declarations, alongside other public config fields):

var suppress_story_label: bool = false # Tutorial: hidden until Stage 1's move_intro has fired
  • Step 4: Remove ControlHint for Tutorial mode; add the shared dock-force-visible fix

In main.gd, find (lines 942-948):

control_hint = null
if _mode == "tutorial":
control_hint = ControlHint.new()
add_child(control_hint)

Replace with:

# ControlHint removed entirely (2026-08-10) -- Stages 1-4/9 of the tutorial-stage-gating
# redesign teach movement/weapons/dash/decoy individually via staged dialogue, making the
# old blanket toast fully redundant (and it visually collided with the new move_intro
# dialogue box, same bottom-of-screen position). See docs/superpowers/specs/
# 2026-08-10-tutorial-stage-gating-design.md.
control_hint = null
# Weapon/drone docks are hidden by default in every mode (Y/V reveals them,
# _toggle_tactical_hud()) -- Tutorial forces them visible for its whole run so Stages 2/3/9's
# "the tile appears" teaching actually has something to point at, without the player needing
# to have separately discovered the Y/V toggle. Setting _tactical_hud_shown (not just
# .visible) keeps this consistent if the player presses Y/V mid-Tutorial -- _toggle_tactical_hud()
# reads/writes that bool, not .visible directly.
if _mode == "tutorial":
_tactical_hud_shown = true
weapon_panel.visible = true
drone_dock.visible = true
  • Step 5: Delete the now-dead ControlHint class and its test
Terminal window
git rm ui/control_hint.gd ui/control_hint.gd.uid tests/test_control_hint.gd tests/test_control_hint.gd.uid

(If .uid files don’t exist for either, git rm the .gd files only — Godot regenerates .uid sidecars automatically and their absence isn’t an error.)

  • Step 6: Add the move_intro dialogue node and attach it to ember_entry

In data/story.json, find the ember_entry room (it currently has no on_enter key at all):

{
"id": "ember_entry",
"kind": "corridor",
"region": "ember",
"rect": [
-450,
-480,
900,
480
],
"doors": [
"d_ember_a1"
]
},

Replace with (adds "on_enter": "move_intro"):

{
"id": "ember_entry",
"kind": "corridor",
"region": "ember",
"rect": [
-450,
-480,
900,
480
],
"doors": [
"d_ember_a1"
],
"on_enter": "move_intro"
},

In the "dialogue" array, find the ember_intro entry (this plan’s Task 4 will trim its lines; add move_intro immediately before it so it’s the first dialogue node in the file, matching it being the first beat the player ever sees):

{
"id": "ember_intro",
"speaker": "echo",

Insert immediately before it:

{
"id": "move_intro",
"speaker": "echo",
"lines": [
"You're in your ship. Move with the left stick."
]
},
  • Step 7: Wire suppress_story_label from main.gd

In main.gd, find (inside _process(), before the hud.update_hud(sim, delta) call at line 1554 — search for that exact call to locate the insertion point):

hud.update_hud(sim, delta)

Replace with:

hud.suppress_story_label = (_mode == "tutorial" and sim.story != null and sim.story.tutorial_locked("move_intro"))
hud.update_hud(sim, delta)
  • Step 8: Run to verify all tests pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_hud -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS on all three. The last one guards against a data/story.json syntax error from the manual edit (a malformed JSON file fails every test that loads it, not just new ones).

  • Step 9: Commit
Terminal window
git add data/story.json ui/hud.gd main.gd tests/test_main.gd tests/test_hud.gd
git rm ui/control_hint.gd tests/test_control_hint.gd
git commit -m "feat(story): Stage 1 movement intro; remove ControlHint; force tactical HUD visible in Tutorial"

Task 4: Stage 2 — Blaster (not Blade) as the taught auto-weapon, via a Tutorial-only remap

Section titled “Task 4: Stage 2 — Blaster (not Blade) as the taught auto-weapon, via a Tutorial-only remap”

Files:

  • Modify: data/story.json (new give_blaster reward entry; trim ember_intro’s lines)
  • Modify: sim/story_director.gd:82-84 (_check_npcs() — the reward-id remap)
  • Test: tests/test_story_director.gd, tests/test_story_loader.gd

Interfaces:

  • Consumes: StoryState.tutorial_cap.
  • Produces: nothing new — a data addition + a remap at an existing call site.

Why the remap, not editing give_blade directly: echo_intro’s reward is shared with ordinary (currently hidden) Story mode. Editing it in place would silently change Story’s own first-weapon content the moment it’s ever re-exposed. The remap is scoped to tutorial_cap, so Story keeps granting Blade unchanged.

  • Step 1: Write the failing tests

In tests/test_story_director.gd, add:

func test_echo_intro_grants_blaster_in_tutorial():
var d := _map()
d.npcs = [{"id": "echo_intro", "room": "entry", "pos": Vector2(50, 50), "speaker": "echo", "dialogue": "intro", "reward": "give_blade"}]
d.rewards["give_blade"] = {"id": "give_blade", "kind": "weapon", "weapon": "blade"}
d.rewards["give_blaster"] = {"id": "give_blaster", "kind": "weapon", "weapon": "blaster"}
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(d, false, false, true) # tutorial_cap=true
sim.player.pos = Vector2(50, 50)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.upgrade_system.is_weapon_active(sim, sim.player, "blaster"), "tutorial grants Blaster")
assert_false(sim.upgrade_system.is_weapon_active(sim, sim.player, "blade"), "not the authored Blade")
func test_echo_intro_still_grants_blade_outside_tutorial():
var d := _map()
d.npcs = [{"id": "echo_intro", "room": "entry", "pos": Vector2(50, 50), "speaker": "echo", "dialogue": "intro", "reward": "give_blade"}]
d.rewards["give_blade"] = {"id": "give_blade", "kind": "weapon", "weapon": "blade"}
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(d) # tutorial_cap defaults false -- ordinary Story mode
sim.player.pos = Vector2(50, 50)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.upgrade_system.is_weapon_active(sim, sim.player, "blade"), "Story mode is unaffected -- still grants Blade")
  • Step 2: Run to verify the first fails, the second already passes

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Expected: test_echo_intro_grants_blaster_in_tutorial FAILS — today Blade is granted regardless of tutorial_cap. test_echo_intro_still_grants_blade_outside_tutorial should already PASS (it’s a regression guard for behavior that must not change).

  • Step 3: Add the remap in _check_npcs()

In sim/story_director.gd, find:

if npc.get("reward") != null and String(npc["reward"]) != "":
sim.grant_story_reward(String(npc["reward"]), npos) # weapon drops where the NPC stands
sim.story.events.append({"kind": "reward", "reward": String(npc["reward"]), "npc": nid})

Replace with:

if npc.get("reward") != null and String(npc["reward"]) != "":
var reward_id := String(npc["reward"])
# Tutorial teaches Blaster (Survival's real starter), not Story mode's Blade -- remap
# at the call site, gated on tutorial_cap, so the shared give_blade reward data (and
# Story mode's own content) is untouched. See docs/superpowers/specs/
# 2026-08-10-tutorial-stage-gating-design.md's "Decisions confirmed by Chris".
if sim.story.tutorial_cap and reward_id == "give_blade":
reward_id = "give_blaster"
sim.grant_story_reward(reward_id, npos) # weapon drops where the NPC stands
sim.story.events.append({"kind": "reward", "reward": reward_id, "npc": nid})
  • Step 4: Add the give_blaster reward entry to data/story.json

Find the give_blade reward entry:

{
"id": "give_blade",
"kind": "weapon",
"weapon": "blade"
}

Add a new entry immediately before it (still inside the "rewards" array):

{
"id": "give_blaster",
"kind": "weapon",
"weapon": "blaster"
},
  • Step 5: Trim ember_intro’s lines down to auto-weapon content only

In data/story.json, find:

{
"id": "ember_intro",
"speaker": "echo",
"lines": [
"You're awake. Good.",
"The Spectrum is going dark, from the top down.",
"Up is the only way that was ever open. Take the blade and climb.",
"Your weapons fight on their own — just fly, and they find their marks. The right stick throws an aimed bolt.",
"Tap the warp when it's tight — for a heartbeat you can't be touched. Watch the glow at your engine: dark means it's spent, bright means it's charged and ready.",
"And if you're torn up — hold still a moment. Light mends what stops moving.",
"The blade sweeps an arc around you — no aiming. Charge in close and it cuts everything it touches."
],
"tutorial": true
},

Replace with (drops the “Take the blade” line since Stage 2 now teaches Blaster; drops the manual-weapon line, the dash/warp line, and the heal line — Stages 3 and 4 teach those separately; the heal line is intentionally not re-added anywhere else, see this plan’s Task 13 punch-list note; drops the last “blade sweeps an arc” line entirely since Blade is no longer the weapon being granted here):

{
"id": "ember_intro",
"speaker": "echo",
"lines": [
"You're awake. Good.",
"The Spectrum is going dark, from the top down.",
"This is your auto-weapon. It fires and aims on its own. It's weak right now — upgrades will make it much stronger."
],
"tutorial": true
},
  • Step 6: Run to verify tests pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS on both.

  • Step 7: Commit
Terminal window
git add sim/story_director.gd data/story.json tests/test_story_director.gd
git commit -m "feat(story): Stage 2 -- Tutorial teaches Blaster via a tutorial-only reward remap"

Task 5: Stage 3 — gate the manual weapon (WeaponAim / “Rift Bolt”) behind aim_intro

Section titled “Task 5: Stage 3 — gate the manual weapon (WeaponAim / “Rift Bolt”) behind aim_intro”

Files:

  • Modify: sim/sim.gd:2249 (_fire_aim())
  • Modify: ui/weapon_panel.gd:124-142 (update_panel() — _aim_root visibility)
  • Modify: data/story.json (repoint ember_e1’s encounter dialogue from the placeholder null_1 to a new aim_intro node)
  • Test: tests/test_sim.gd (or the nearest existing weapon-firing test file — see Step 1 note), tests/test_weapon_panel.gd

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1).
  • Produces: nothing new.

Trigger correction (already reflected in the design doc’s revision, restated here for the plan): ember_a1’s on_enter (echo_xp) is NOT the trigger — it’s untouched, general leveling flavor text. The trigger is ember_e1’s encounter-start dialogue field, which currently points at the throwaway placeholder null_1 ("One little light, climbing.", "I have swallowed brighter than you.") — confirmed genuinely unclaimed by anything else in this plan.

  • Step 1: Write the failing tests

Find an existing test file that constructs a Sim and directly drives _fire_aim or aim-weapon firing via sim.tick_single() with aim_dir set — search tests/ for aim_dir to find the right home (likely tests/test_weapon_aim.gd or similar; if no dedicated file exists, add to tests/test_story_director.gd since it needs a story-enabled Sim). Add:

func test_aim_weapon_inert_before_aim_intro_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true) # tutorial_cap=true
var proj_count_before := sim.projectiles.count
var input := InputState.new(Vector2.ZERO, Vector2(1, 0)) # full-deadzone right-stick aim
sim.tick_single(input)
assert_eq(sim.projectiles.count, proj_count_before, "aim weapon does not fire before aim_intro has been seen")
func test_aim_weapon_fires_once_aim_intro_seen():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.story.mark_dialogue_seen("aim_intro")
var proj_count_before := sim.projectiles.count
var input := InputState.new(Vector2.ZERO, Vector2(1, 0))
sim.tick_single(input)
assert_gt(sim.projectiles.count, proj_count_before, "aim weapon fires once aim_intro has fired")
func test_aim_weapon_unaffected_outside_story():
var sim := Sim.new(1, SimContentFixture.db()) # no enable_story() -- plain Survival
var proj_count_before := sim.projectiles.count
var input := InputState.new(Vector2.ZERO, Vector2(1, 0))
sim.tick_single(input)
assert_gt(sim.projectiles.count, proj_count_before, "Survival's aim weapon is completely unaffected")

(If placed in tests/test_story_director.gd, these can use that file’s own _map()/_sim() helpers directly, matching the file’s existing style.)

In tests/test_weapon_panel.gd, add:

func test_aim_tile_hidden_before_aim_intro_in_tutorial():
var p := WeaponPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(StoryData.from_arrays(
[{"id": "r", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}],
[], "r"), false, false, true) # tutorial_cap=true
p.update_panel(sim)
assert_false(p._aim_root.visible, "Aim tile hidden before aim_intro fires")
func test_aim_tile_visible_once_aim_intro_seen():
var p := WeaponPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(StoryData.from_arrays(
[{"id": "r", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}],
[], "r"), false, false, true)
sim.story.mark_dialogue_seen("aim_intro")
p.update_panel(sim)
assert_true(p._aim_root.visible, "Aim tile shown once aim_intro has fired")
func test_aim_tile_visible_in_survival():
var p := WeaponPanel.new()
add_child_autofree(p)
await get_tree().process_frame
var sim := Sim.new(1, SimContentFixture.db()) # no story -- Survival
p.update_panel(sim)
assert_true(p._aim_root.visible, "Survival's Aim tile is unaffected")
  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit (or whichever file Step 1’s sim-side tests landed in) Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_weapon_panel -gexit Expected: the _intro-gated FAIL, the Survival/outside-tutorial ones already PASS.

  • Step 3: Gate _fire_aim()

In sim/sim.gd, find:

func _fire_aim(pilot: PlayerState, pilot_input: InputState, dt: float) -> void:
var aim := pilot.arsenal.aim
aim.timer -= dt
if pilot_input.aim_dir.length() < aim.deadzone:
return

Replace with:

func _fire_aim(pilot: PlayerState, pilot_input: InputState, dt: float) -> void:
var aim := pilot.arsenal.aim
aim.timer -= dt
if story != null and story.tutorial_locked("aim_intro"):
return
if pilot_input.aim_dir.length() < aim.deadzone:
return

(The gate is placed before the deadzone check, not after, so a held right stick during the locked period never starts consuming/advancing aim.timer either — matches the intent that the weapon is fully inert, not merely silent.)

  • Step 4: Gate _aim_root visibility in ui/weapon_panel.gd

In ui/weapon_panel.gd, find:

# Fixed Manual/Aim tile: sits just past the (variable-length) hull weapon row, and its live
# stats read straight off the pilot's arsenal every frame -- never gated by ownership, unlike
# the slot tiles above.
_aim_root.position = Vector2(_tiles.size() * (TILE_C + GAP_C) + AIM_GAP, 0)
var aim := sim.player.arsenal.aim
_aim_stat.text = "AIM dmg %.1f prc %d aoe %.0f" % [aim.damage, aim.pierce, aim.burst_radius]

Replace with:

# Fixed Manual/Aim tile: sits just past the (variable-length) hull weapon row, and its live
# stats read straight off the pilot's arsenal every frame -- never gated by ownership, unlike
# the slot tiles above. Tutorial hides it entirely until aim_intro has fired (Stage 3, see
# docs/superpowers/specs/2026-08-10-tutorial-stage-gating-design.md).
if sim.story != null and sim.story.tutorial_locked("aim_intro"):
_aim_root.visible = false
else:
_aim_root.visible = true
_aim_root.position = Vector2(_tiles.size() * (TILE_C + GAP_C) + AIM_GAP, 0)
var aim := sim.player.arsenal.aim
_aim_stat.text = "AIM dmg %.1f prc %d aoe %.0f" % [aim.damage, aim.pierce, aim.burst_radius]
  • Step 5: Repoint ember_e1’s encounter dialogue from null_1 to a new aim_intro node

In data/story.json, find the ember_e1 encounter:

{
"id": "ember_e1",
"waves": [
{
"type": "swarmer",
"count": 2,
"delay": 0.6
},
{
"type": "swarmer",
"count": 2,
"delay": 2.5
}
],
"dialogue": [
"null_1"
],
"reward": "give_pulse",
"clear_dialogue": [
"pulse_learn"
]
},

Replace "dialogue": ["null_1"] with "dialogue": ["aim_intro"]:

{
"id": "ember_e1",
"waves": [
{
"type": "swarmer",
"count": 2,
"delay": 0.6
},
{
"type": "swarmer",
"count": 2,
"delay": 2.5
}
],
"dialogue": [
"aim_intro"
],
"reward": "give_pulse",
"clear_dialogue": [
"pulse_learn"
]
},

(null_1 itself stays in the dialogue array unreferenced by any encounter now — leave the node definition in place; it’s harmless dead content, not worth a separate cleanup task here.)

Add the new aim_intro node to the "dialogue" array, immediately after ember_intro:

{
"id": "aim_intro",
"speaker": "echo",
"lines": [
"The right stick fires your manual weapon. It hits harder than your auto-weapon, but you have to aim it yourself — it won't track for you, so it's easy to miss."
],
"tutorial": true
},
  • Step 6: Run to verify all tests pass

Run the same commands as Step 2. Expected: PASS on all six. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS (JSON validity guard).

  • Step 7: Commit
Terminal window
git add sim/sim.gd ui/weapon_panel.gd data/story.json tests/
git commit -m "feat(story): Stage 3 -- gate the manual (Rift Bolt) weapon behind aim_intro"

Task 6: Stage 4 — gate dash/warp behind pulse_learn, folded into the Pulse-weapon beat

Section titled “Task 6: Stage 4 — gate dash/warp behind pulse_learn, folded into the Pulse-weapon beat”

Files:

  • Modify: sim/sim.gd:2122 (_update_dash())
  • Modify: data/story.json (extend pulse_learn with the dash lines, splice not replace)
  • Test: tests/test_story_director.gd (or the file Task 5 used for aim-weapon sim tests)

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1).
  • Produces: nothing new.

Trigger and gate id are the same thing: pulse_learn. ember_e1’s clear_dialogue (pulse_learn) already fires when the encounter is cleared and already grants the Pulse weapon ("reward": "give_pulse") — the design doc folds the dash lesson into this existing beat rather than inventing a new trigger for an otherwise-unaccounted-for Pulse pickup. There is no separate dash_intro id anywhere in this task — the gate checks tutorial_locked("pulse_learn") directly, since that’s the dialogue id that actually fires at the trigger point.

  • Step 1: Write the failing tests

Add to whichever file Task 5’s sim-side aim tests landed in:

func test_dash_inert_before_pulse_learn_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
var pos_before := sim.player.pos
var input := InputState.new(Vector2.RIGHT, Vector2.ZERO, false, true) # dash pressed
sim.tick_single(input)
assert_eq(sim.player.pos, pos_before, "dash does not move the player before pulse_learn has been seen")
func test_dash_works_once_pulse_learn_seen():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.story.mark_dialogue_seen("pulse_learn")
var pos_before := sim.player.pos
var input := InputState.new(Vector2.RIGHT, Vector2.ZERO, false, true)
sim.tick_single(input)
assert_ne(sim.player.pos, pos_before, "dash moves the player once pulse_learn has fired")
func test_dash_unaffected_outside_story():
var sim := Sim.new(1, SimContentFixture.db())
var pos_before := sim.player.pos
var input := InputState.new(Vector2.RIGHT, Vector2.ZERO, false, true)
sim.tick_single(input)
assert_ne(sim.player.pos, pos_before, "Survival's dash is completely unaffected")
  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit (adjust -gselect if Task 5 used a different file) Expected: test_dash_inert_before_pulse_learn_in_tutorial and test_dash_works_once_pulse_learn_seen FAIL (dash currently works from tick 1 regardless of story state); the Survival test already PASSES.

  • Step 3: Gate _update_dash()

In sim/sim.gd, find:

func _update_dash(pilot: PlayerState, input: InputState, dt: float) -> void:
# Decrement cooldown + active timers unconditionally.
pilot.dash_cd = maxf(pilot.dash_cd - dt, 0.0)
pilot.iframe_timer = maxf(pilot.iframe_timer - dt, 0.0)
pilot.dash_timer = maxf(pilot.dash_timer - dt, 0.0)
pilot.inv = maxf(pilot.inv - dt, 0.0)
if pilot.inv <= 0.0:
pilot.rrd = 0.0
# Start a new dash when the button is pressed and the cooldown has expired.
if input.dash and pilot.dash_cd <= 0.0 and not game_over:

Replace with:

func _update_dash(pilot: PlayerState, input: InputState, dt: float) -> void:
# Decrement cooldown + active timers unconditionally -- even while locked, so the moment
# pulse_learn fires the player doesn't inherit a stale mid-dash timer from before it did.
pilot.dash_cd = maxf(pilot.dash_cd - dt, 0.0)
pilot.iframe_timer = maxf(pilot.iframe_timer - dt, 0.0)
pilot.dash_timer = maxf(pilot.dash_timer - dt, 0.0)
pilot.inv = maxf(pilot.inv - dt, 0.0)
if pilot.inv <= 0.0:
pilot.rrd = 0.0
if story != null and story.tutorial_locked("pulse_learn"):
return
# Start a new dash when the button is pressed and the cooldown has expired.
if input.dash and pilot.dash_cd <= 0.0 and not game_over:

(Gated inside _update_dash() itself, called generically for every pilot at sim/sim.gd:790 — covers any future caller of sim.tick()/tick_single(), not just the one path through main.gd. Confirmed via research: main.gd has no input.dash read of its own to gate upstream of this — the field passes through opaque until _update_dash() reads it.)

  • Step 4: Extend pulse_learn with the dash lines (splice, don’t replace)

In data/story.json, find:

{
"id": "pulse_learn",
"speaker": "echo",
"lines": [
"That spark you earned is the Pulse.",
"It strikes the nearest enemy on its own, then leaps to others close by — chain lightning.",
"Stay in the thick of them and it never stops working. Try it in the next room."
],
"tutorial": true
},

Replace with (existing three lines kept verbatim, dash lines appended):

{
"id": "pulse_learn",
"speaker": "echo",
"lines": [
"That spark you earned is the Pulse.",
"It strikes the nearest enemy on its own, then leaps to others close by — chain lightning.",
"Stay in the thick of them and it never stops working. Try it in the next room.",
"Tap the warp when it's tight — for a heartbeat you can't be touched. Watch the glow at your engine: dark means it's spent, bright means it's charged and ready."
],
"tutorial": true
},

(This node’s id stays pulse_learn, matching what Step 3’s tutorial_locked("pulse_learn") gate checks — no separate dash_intro id exists anywhere in this task.)

  • Step 5: Run to verify all tests pass

Run the same command as Step 2. Expected: PASS on all three. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS.

  • Step 6: Commit
Terminal window
git add sim/sim.gd data/story.json tests/
git commit -m "feat(story): Stage 4 -- gate dash/warp behind pulse_learn (folds in the Pulse beat)"

Task 7: ember_e1 — solo-spawn wave mode (only one enemy alive at once, Tutorial-only)

Section titled “Task 7: ember_e1 — solo-spawn wave mode (only one enemy alive at once, Tutorial-only)”

Files:

  • Modify: sim/story_director.gd:153-182 (_drive_encounter())
  • Modify: data/story.json (ember_e1’s encounter object — add "solo_spawn": true)
  • Test: tests/test_story_director.gd

Interfaces:

  • Consumes: StoryState.tutorial_cap.
  • Produces: nothing new — this removes Stage 7’s (Task 9) level-up multi-panel-stack risk at the root, by making it structurally impossible for more than one ember_e1 enemy to be alive at once during Tutorial.

Why this belongs before Task 9: Task 9’s defensive “cap the first level-up bank at 1” only needs to be a cheap backstop, not the primary fix, once this lands — do this first so Task 9’s test fixtures can rely on it.

  • Step 1: Write the failing tests

In tests/test_story_director.gd, add:

func test_solo_spawn_holds_next_wave_until_arena_clear():
var d := _map()
d.encounters["e1"] = {"id": "e1", "waves": [
{"type": "swarmer", "count": 1, "delay": 0.0},
{"type": "swarmer", "count": 1, "delay": 0.1},
], "solo_spawn": true}
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(d, false, false, true) # tutorial_cap=true -- solo_spawn only applies here
sim.player.pos = Vector2(300, 600) # inside "arena" per _map()'s rect
sim.tick_single(InputState.new(Vector2.ZERO)) # enters arena, wave 1 spawns (delay 0.0)
assert_eq(sim.enemies.count, 1, "wave 1 spawns immediately")
for i in range(30): # far more than enough ticks for wave 2's 0.1s delay to elapse if unguarded
sim.tick_single(InputState.new(Vector2.ZERO))
assert_eq(sim.enemies.count, 1, "wave 2 is held -- the first enemy is still alive")
sim.elemental_system.damage_enemy(sim, 0, 1.0e9) # kill the one enemy
sim.tick_single(InputState.new(Vector2.ZERO))
sim.tick_single(InputState.new(Vector2.ZERO))
assert_eq(sim.enemies.count, 1, "wave 2 spawns once the arena is clear")
func test_solo_spawn_inert_outside_tutorial_cap():
var d := _map()
d.encounters["e1"] = {"id": "e1", "waves": [
{"type": "swarmer", "count": 1, "delay": 0.0},
{"type": "swarmer", "count": 1, "delay": 0.1},
], "solo_spawn": true}
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(d) # tutorial_cap defaults false -- ordinary Story mode
sim.player.pos = Vector2(300, 600)
sim.tick_single(InputState.new(Vector2.ZERO))
for i in range(30):
sim.tick_single(InputState.new(Vector2.ZERO))
assert_eq(sim.enemies.count, 2, "outside tutorial_cap, solo_spawn is a no-op -- both waves fire on their normal timers")
  • Step 2: Run to verify the first test fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Expected: test_solo_spawn_holds_next_wave_until_arena_clear FAILS (both waves spawn on their timers today, giving enemies.count == 2 well before the kill). The second test should already PASS (nothing reads solo_spawn yet, so it’s inert by definition — confirms it becomes a real no-op, not just an untested one, once implemented).

  • Step 3: Add the solo_spawn wait-for-clear mode

In sim/story_director.gd, find:

var enc := _encounter_data(sim)
var waves: Array = enc.get("waves", [])
var is_survive := String(enc.get("clear", "kill")) == "survive"
# Spawn waves in order; a survive encounter LOOPS them so the pressure never lets up.
if _wave_index < waves.size():
_wave_timer -= dt
if _wave_timer <= 0.0:
_spawn_wave(sim, waves[_wave_index], _wave_index)
_wave_index += 1
if _wave_index < waves.size():
_wave_timer = float(waves[_wave_index].get("delay", 1.0))
elif is_survive and waves.size() > 0:
_wave_index = 0 # loop the wave list
_wave_timer = float(waves[0].get("delay", 1.0)) + 1.0 # brief breather between cycles

Replace with:

var enc := _encounter_data(sim)
var waves: Array = enc.get("waves", [])
var is_survive := String(enc.get("clear", "kill")) == "survive"
# Spawn waves in order; a survive encounter LOOPS them so the pressure never lets up.
if _wave_index < waves.size():
# Tutorial-only: hold the next wave until the arena is fully clear, so at most one
# enemy is ever alive at once (prevents a multi-kill-same-tick level-up stack on the
# player's very first fight). Pure sim-state read, no RNG -- inert outside tutorial_cap
# even if the data flag is present, so Story mode's pacing is untouched.
var solo_spawn := sim.story.tutorial_cap and bool(enc.get("solo_spawn", false))
if solo_spawn and sim.enemies.count > 0:
pass # hold -- the current wave isn't cleared yet
else:
_wave_timer -= dt
if _wave_timer <= 0.0:
_spawn_wave(sim, waves[_wave_index], _wave_index)
_wave_index += 1
if _wave_index < waves.size():
_wave_timer = float(waves[_wave_index].get("delay", 1.0))
elif is_survive and waves.size() > 0:
_wave_index = 0 # loop the wave list
_wave_timer = float(waves[0].get("delay", 1.0)) + 1.0 # brief breather between cycles
  • Step 4: Add "solo_spawn": true to ember_e1’s encounter data

In data/story.json, find ember_e1 (already edited by Task 5 — its "dialogue" field is now ["aim_intro"]):

{
"id": "ember_e1",
"waves": [
{
"type": "swarmer",
"count": 2,
"delay": 0.6
},
{
"type": "swarmer",
"count": 2,
"delay": 2.5
}
],
"dialogue": [
"aim_intro"
],
"reward": "give_pulse",
"clear_dialogue": [
"pulse_learn"
]
},

Replace with (adds "solo_spawn": true; also drops each wave’s "count" from 2 to 1, since solo_spawn only prevents overlap BETWEEN waves — a wave that itself spawns 2 enemies at once would still put 2 alive simultaneously the instant it fires):

{
"id": "ember_e1",
"waves": [
{
"type": "swarmer",
"count": 1,
"delay": 0.6
},
{
"type": "swarmer",
"count": 1,
"delay": 0.6
},
{
"type": "swarmer",
"count": 1,
"delay": 0.6
},
{
"type": "swarmer",
"count": 1,
"delay": 0.6
}
],
"dialogue": [
"aim_intro"
],
"reward": "give_pulse",
"clear_dialogue": [
"pulse_learn"
],
"solo_spawn": true
},

(Kept the total enemy count at 4 — same as today’s 2+2 — just spread one at a time, so overall encounter length/XP total is unchanged; only concurrency changes. delay between waves after the first is now only relevant as a minimum gap once the arena clears, not a hard schedule.)

  • Step 5: Run to verify all tests pass

Run the same command as Step 2. Expected: PASS on both new tests. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_spawn_story_enemy -gexit Expected: PASS (regression check — this file likely exercises _spawn_wave/wave mechanics generally; confirm no assumption about ember_e1’s exact wave shape is baked in elsewhere).

  • Step 6: Commit
Terminal window
git add sim/story_director.gd data/story.json tests/test_story_director.gd
git commit -m "feat(story): ember_e1 solo-spawn mode -- one enemy alive at a time in Tutorial"

Task 8: Stages 5+6 — reactive HP-bar and threat-radar narration

Section titled “Task 8: Stages 5+6 — reactive HP-bar and threat-radar narration”

Files:

  • Modify: sim/story_director.gd (generalize the existing boss-only _check_teaching pattern into a Tutorial-wide sibling; add it to update())
  • Modify: data/story.json (new hp_intro, radar_intro dialogue nodes)
  • Test: tests/test_story_director.gd

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1).
  • Produces: StoryDirector._check_tutorial_teaching(sim: Sim) -> void (new sibling method to the existing _check_teaching).

Both stages are narration-only — the HP bar (alpha-faded via _hp_flash_alpha) and threat radar (update_radar()’s top.is_empty() check) are already correctly content-gated in ui/; no visibility gate is needed there. This task only adds the reactive dialogue trigger.

  • Step 1: Write the failing tests

In tests/test_story_director.gd, add:

func test_hp_intro_fires_on_first_damage_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_false(sim.story.seen_dialogue.has("hp_intro"), "not fired while full HP")
sim.player.hp = sim.player.max_hp - 1.0
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.story.seen_dialogue.has("hp_intro"), "fires the first tick HP drops below max")
func test_hp_intro_only_fires_once():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.player.hp = sim.player.max_hp - 1.0
sim.tick_single(InputState.new(Vector2.ZERO))
sim.player.hp = sim.player.max_hp # heal back up
sim.tick_single(InputState.new(Vector2.ZERO))
sim.player.hp = sim.player.max_hp - 1.0 # drop again
sim.tick_single(InputState.new(Vector2.ZERO))
var say_count := 0
for e in sim.story.events:
if String(e.get("kind", "")) == "say" and String(e.get("id", "")) == "hp_intro":
say_count += 1
assert_lte(say_count, 1, "hp_intro is fire-once even across repeated HP drops")
func test_radar_intro_fires_when_enemies_present_in_tutorial():
var d := _map()
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(d, false, false, true)
sim.player.pos = Vector2(300, 600) # enters "arena", which has encounter "e1" -> 2 swarmers
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.story.seen_dialogue.has("radar_intro"), "fires once enemies exist")
func test_hp_and_radar_intro_never_fire_outside_tutorial_cap():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map()) # tutorial_cap defaults false
sim.player.hp = sim.player.max_hp - 1.0
sim.player.pos = Vector2(300, 600)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_false(sim.story.seen_dialogue.has("hp_intro"), "no new narration outside the capped tutorial")
assert_false(sim.story.seen_dialogue.has("radar_intro"), "no new narration outside the capped tutorial")
  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Expected: the hp_intro/radar_intro tests FAIL (no such dialogue ids exist, nothing fires them); the “never fire outside tutorial_cap” test passes trivially today (still should after the implementation, as a real regression guard).

  • Step 3: Add _check_tutorial_teaching() and call it from update()

In sim/story_director.gd, find:

# During a Warden fight, teach survival once each (fire-once via _say): the decoy when
# first hurt, then holding still to heal when health gets very low.
func _check_teaching(sim: Sim) -> void:
if sim.boss_rotation.boss_index(sim) == -1:
return # only while a Warden is alive
if sim.player.hp < sim.player.max_hp:
_say(sim, "teach_decoy")
if sim.player.hp <= sim.player.max_hp * TEACH_LOW_FRAC:
_say(sim, "teach_heal")

Add immediately after:

# Tutorial-wide reactive teaching (2026-08-10, distinct from the boss-only _check_teaching
# above): fires narration the moment its matching HUD element would first have something to
# show, rather than at a fixed room/NPC trigger. Both HP bar and threat radar are already
# content-gated in ui/ (alpha-faded / visible=false until real content exists) -- this only adds
# the narration, no visibility gate needed on either.
func _check_tutorial_teaching(sim: Sim) -> void:
if not sim.story.tutorial_cap:
return
if sim.player.hp < sim.player.max_hp:
_say(sim, "hp_intro")
if sim.enemies.count > 0:
_say(sim, "radar_intro")

Find update()’s call to _check_teaching:

_check_teaching(sim)

Add immediately after:

_check_tutorial_teaching(sim)

(If update()’s exact surrounding lines differ from this single-line citation, locate the existing _check_teaching(sim) call inside func update(sim: Sim, dt: float) -> void: and add the new call directly after it — same function, same tick, no reordering relative to the rest of update()’s body.)

  • Step 4: Add the hp_intro and radar_intro dialogue nodes

In data/story.json, add to the "dialogue" array (position doesn’t matter functionally; place near aim_intro for readability):

{
"id": "hp_intro",
"speaker": "echo",
"lines": [
"That's your health bar — it only shows when you're hurt. Watch it: the border warns you harder the lower you get."
],
"tutorial": true
},
{
"id": "radar_intro",
"speaker": "echo",
"lines": [
"That ring at the edge of your screen is your threat radar — it points at danger you can't see on screen yet."
],
"tutorial": true
},
  • Step 5: Run to verify all tests pass

Run the same command as Step 2. Expected: PASS on all five. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS.

  • Step 6: Commit
Terminal window
git add sim/story_director.gd data/story.json tests/test_story_director.gd
git commit -m "feat(story): Stages 5+6 -- reactive HP-bar and threat-radar narration in Tutorial"

Task 9: Stage 7 — gate the level-up panel behind levelup_intro; fix Story Board’s capture deadlock

Section titled “Task 9: Stage 7 — gate the level-up panel behind levelup_intro; fix Story Board’s capture deadlock”

Files:

  • Modify: sim/story_director.gd (add the levelup reactive check to _check_tutorial_teaching)
  • Modify: main.gd:1231 (the primary level-up-panel open check)
  • Modify: main.gd:1430, main.gd:1439 (Story Board capture deadlock fix — REQUIRED, ships with this task per the design doc, not as a follow-up)
  • Modify: data/story.json (new levelup_intro dialogue node)
  • Test: tests/test_story_director.gd, tests/test_main.gd

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1), main._next_leveling_pilot() (pre-existing, main.gd:1591), main._capturing (pre-existing, main.gd:173, set true by tools/story_board/capture.gd:39).
  • Produces: nothing new.

Why the capture-deadlock fix ships here, not separately: confirmed live mechanism — main._auto = not _is_story() (main.gd:1027) is false for the entire Tutorial run, so the ONLY existing auto-dismiss path for the level-up/dimension-choice panels (if _auto and _auto_pick_timer > 0.0: ...) never fires during an unattended Tutorial capture. tools/story_board/capture.gd’s only exit condition is main._replay_done and _pending.is_empty() — once a panel opens and freezes the sim, that condition can never become true, and the capture hangs forever with zero output. This is already a live risk today, independent of this redesign (StoryDirector._spawn_boss_adds() can push a Tutorial run over the first XP threshold from boss-fight adds alone) — but Stage 7’s new gate makes a level-up open on literally every Tutorial run’s first fight, so it must ship fixed, not discovered later by a hung capture run.

  • Step 1: Write the failing tests

In tests/test_story_director.gd, add:

func test_levelup_intro_fires_on_first_pending_levelup_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_false(sim.story.seen_dialogue.has("levelup_intro"), "not fired with no pending levelup")
sim.player.pending_levelups = 1
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.story.seen_dialogue.has("levelup_intro"), "fires the first tick a levelup is pending")

In tests/test_main.gd, add:

# ── Stage 7: level-up panel gated behind levelup_intro (2026-08-10) ─────────────────────────
func test_levelup_panel_stays_closed_before_levelup_intro_in_tutorial():
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null # prevent any incidental _bank_run() save
m.sim.player.pending_levelups = 1 # bank one without going through real XP/kills
await get_tree().physics_frame
assert_false(m._paused_for_levelup, "the panel does not open before levelup_intro has fired")
func test_levelup_panel_opens_once_levelup_intro_seen():
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null
m.sim.story.mark_dialogue_seen("levelup_intro")
m.sim.player.pending_levelups = 1
await get_tree().physics_frame
assert_true(m._paused_for_levelup, "the panel opens normally once levelup_intro has already fired")
# ── Story Board capture deadlock fix (2026-08-10) — pre-existing bug, independent of Stage 7 ──
func test_capturing_flag_auto_dismisses_levelup_panel():
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null
m._capturing = true # simulates tools/story_board/capture.gd's real setup
m.sim.story.mark_dialogue_seen("levelup_intro") # panel must be allowed to open first
m.sim.player.pending_levelups = 1
await get_tree().physics_frame
assert_true(m._paused_for_levelup, "panel opened")
m._auto_pick_timer = 0.01 # about to elapse
await get_tree().physics_frame
assert_false(m._paused_for_levelup, "auto-dismissed even though _auto is false, because _capturing is true")
  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Expected: all four new tests FAIL — no levelup_intro gate exists yet, and _capturing doesn’t currently affect the auto-dismiss timer at all.

  • Step 3: Add the levelup_intro reactive check

In sim/story_director.gd, find (as left by Task 8 — note this shape checks sim.story.tutorial_locked(id) per beat, not a single top-level tutorial_cap guard; tutorial_locked() already folds tutorial_cap in, so there’s nothing to gate at the top):

func _check_tutorial_teaching(sim: Sim) -> void:
if sim.player.hp < sim.player.max_hp and sim.story.tutorial_locked("hp_intro"):
_say(sim, "hp_intro")
if sim.enemies.count > 0 and sim.story.tutorial_locked("radar_intro"):
_say(sim, "radar_intro")

Replace with:

func _check_tutorial_teaching(sim: Sim) -> void:
if sim.player.hp < sim.player.max_hp and sim.story.tutorial_locked("hp_intro"):
_say(sim, "hp_intro")
if sim.enemies.count > 0 and sim.story.tutorial_locked("radar_intro"):
_say(sim, "radar_intro")
if sim.player.pending_levelups > 0 and sim.story.tutorial_locked("levelup_intro"):
_say(sim, "levelup_intro")

If Task 8’s actual committed code differs from the “find” snippet above (e.g. its own fix round changed the shape further), treat the snippet as illustrative of the PATTERN — add the new pending_levelups condition using the same sim.story.tutorial_locked(id) form the other two conditions in the real committed function use, not this literal text. Read the real function first.

  • Step 4: Gate the level-up panel’s primary open check in main.gd

In main.gd, find (~line 1231):

if _next_leveling_pilot() != null:
_levelup_opened_this_tick = true
_open_levelup()

Replace with:

if _next_leveling_pilot() != null \
and not (_mode == "tutorial" and sim.story != null and sim.story.tutorial_locked("levelup_intro")):
_levelup_opened_this_tick = true
_open_levelup()

(Only this primary open check needs the guard — the chained recheck inside _on_upgrade_chosen() at main.gd:2054 is only reachable once a panel has legitimately opened once already, which by construction can’t happen before levelup_intro has fired.)

  • Step 5: Fix the Story Board capture deadlock

In main.gd, find (~line 1430):

if _auto and _auto_pick_timer > 0.0:
_auto_pick_timer -= delta
if _auto_pick_timer <= 0.0:
_on_upgrade_chosen(auto_player.pick_upgrade(_current_choice_ids))
return

Replace with:

if (_auto or _capturing) and _auto_pick_timer > 0.0:
_auto_pick_timer -= delta
if _auto_pick_timer <= 0.0:
_on_upgrade_chosen(auto_player.pick_upgrade(_current_choice_ids))
return

Find the matching block for the dimension-choice popup (~line 1439):

if _auto and _auto_pick_timer > 0.0:
_auto_pick_timer -= delta
if _auto_pick_timer <= 0.0:
_on_dimension_chosen(auto_player.pick_dimension(sim.pending_dimension_choice))
return

Replace with:

if (_auto or _capturing) and _auto_pick_timer > 0.0:
_auto_pick_timer -= delta
if _auto_pick_timer <= 0.0:
_on_dimension_chosen(auto_player.pick_dimension(sim.pending_dimension_choice))
return
  • Step 6: Add a defensive cap on the first level-up bank

Per the design doc: with Task 7’s solo_spawn in place, multiple kills can no longer land in the same tick during ember_e1, so this is a backstop for the much rarer case of a single kill’s XP alone crossing two thresholds at once — cheap to add, not worth skipping.

In sim/sim.gd, find _process_levelups (the function incrementing pending_levelups — search for its definition; it’s near _bank_xp per this plan’s research). Locate the loop/line that can increment pending_levelups by more than 1 in a single call. Simplest and most robust: cap it where _say(sim, "levelup_intro") fires, not inside XP processing (keeps XP accrual completely untouched, matching the design doc’s “XP accrual is untouched” note). In sim/story_director.gd, find the line just added in Step 3:

if sim.player.pending_levelups > 0 and sim.story.tutorial_locked("levelup_intro"):
_say(sim, "levelup_intro")

Replace with:

if sim.player.pending_levelups > 0 and sim.story.tutorial_locked("levelup_intro"):
# Defensive backstop (Task 7's solo_spawn already prevents the realistic multi-kill-
# same-tick case at the root -- this only covers a single kill's XP alone crossing two
# thresholds at once). Cap the very first bank at 1; anything extra opens normally right
# after, same as any other level-up chain. Being inside this tutorial_locked() branch
# already guarantees this is the first time -- no separate seen_dialogue check needed.
sim.player.pending_levelups = mini(sim.player.pending_levelups, 1)
_say(sim, "levelup_intro")
  • Step 7: Add the levelup_intro dialogue node

In data/story.json, add to the "dialogue" array:

{
"id": "levelup_intro",
"speaker": "echo",
"lines": [
"Defeating enemies earns XP. Enough of it and you'll get to pick a new upgrade — take a moment to choose, there's no rush."
],
"tutorial": true
},
  • Step 8: Run to verify all tests pass

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_main -gexit Expected: PASS on all. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS.

  • Step 9: Commit
Terminal window
git add sim/story_director.gd sim/sim.gd main.gd data/story.json tests/
git commit -m "feat(story): Stage 7 -- gate level-up panel behind levelup_intro; fix Story Board capture deadlock"

Task 10: Stage 8 — reactive crystal narration

Section titled “Task 10: Stage 8 — reactive crystal narration”

Files:

  • Modify: sim/story_director.gd (_check_tutorial_teaching)
  • Modify: data/story.json (new crystal_intro dialogue node)
  • Test: tests/test_story_director.gd

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1), sim.crystals.counts (pre-existing — confirmed via research: there is no dedicated persistent crystal-counter HUD widget anywhere; the only place a crystal count renders at all is ui/level_up_panel.gd’s wallet section, which is already gated by Task 9’s levelup_intro).
  • Produces: nothing new.

Correction to the design doc: its Stage 8 was left as an open research gap (“crystal-counter HUD element’s owning file… unresearched entirely”). Research now confirms there IS no such HUD element — crystals are only ever shown inside the level-up panel, already gated. This task is therefore narration-only (matching Stages 5/6’s pattern), reusing the same _check_tutorial_teaching mechanism, with no UI-side gate needed at all.

  • Step 1: Write the failing test

In tests/test_story_director.gd, add:

func test_crystal_intro_fires_on_first_crystal_grant_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.tick_single(InputState.new(Vector2.ZERO))
assert_false(sim.story.seen_dialogue.has("crystal_intro"), "not fired with an empty wallet")
sim.crystals.counts["fire"] = 1
sim.tick_single(InputState.new(Vector2.ZERO))
assert_true(sim.story.seen_dialogue.has("crystal_intro"), "fires the first tick any crystal exists")
  • Step 2: Run to verify it fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_director -gexit Expected: FAIL — no crystal_intro id exists, nothing fires it.

  • Step 3: Add the reactive check

In sim/story_director.gd, find (as left by Task 9 — if the real committed function differs from this literal text, e.g. from Task 9’s own fix loop, read the real function and add the new condition using the same and sim.story.tutorial_locked(id) form every other condition in it uses):

if sim.player.pending_levelups > 0 and sim.story.tutorial_locked("levelup_intro"):
sim.player.pending_levelups = mini(sim.player.pending_levelups, 1)
_say(sim, "levelup_intro")

Add immediately after:

if not sim.crystals.counts.is_empty() and sim.story.tutorial_locked("crystal_intro"):
_say(sim, "crystal_intro")
  • Step 4: Add the crystal_intro dialogue node

In data/story.json, add to the "dialogue" array:

{
"id": "crystal_intro",
"speaker": "echo",
"lines": [
"Crystals build up as you fight — spend enough on a weapon and it upgrades for good."
],
"tutorial": true
},
  • Step 5: Run to verify it passes

Run the same command as Step 2. Expected: PASS. Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_story_loader -gexit Expected: PASS.

  • Step 6: Commit
Terminal window
git add sim/story_director.gd data/story.json tests/test_story_director.gd
git commit -m "feat(story): Stage 8 -- reactive crystal narration in Tutorial"

Task 11: Stage 9 — gate the decoy/drone behind echo_decoy

Section titled “Task 11: Stage 9 — gate the decoy/drone behind echo_decoy”

Files:

  • Modify: sim/drone_director.gd:100-107 (update_drones())
  • Test: tests/test_drone_coop.gd (or tests/test_drones.gd — see Step 1 note)

Interfaces:

  • Consumes: StoryState.tutorial_locked() (Task 1).

  • Produces: nothing new. Reuses the existing echo_decoy dialogue id directly as the gate key (it already fires at the right trigger — ember_a2’s on_enter — no new dialogue node needed).

  • Step 1: Write the failing tests

Find or add to a test file that constructs a Sim with drone_director reachable and drives update_drones() directly (search tests/ for update_drones( to find the closest existing pattern; tests/test_drones.gd is a reasonable home if no better match exists). Add:

func test_decoy_charge_frozen_before_echo_decoy_in_tutorial():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
for i in range(120): # far more than enough ticks to reach full charge if unguarded
sim.tick_single(InputState.new(Vector2.ZERO))
assert_eq(sim.player.drone_charge, 0.0, "decoy charge stays frozen at 0 before echo_decoy has fired")
func test_decoy_charges_once_echo_decoy_seen():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(_map(), false, false, true)
sim.story.mark_dialogue_seen("echo_decoy")
for i in range(5):
sim.tick_single(InputState.new(Vector2.ZERO))
assert_gt(sim.player.drone_charge, 0.0, "charge accrues normally once echo_decoy has fired")
func test_decoy_unaffected_outside_story():
var sim := Sim.new(1, SimContentFixture.db())
for i in range(5):
sim.tick_single(InputState.new(Vector2.ZERO))
assert_gt(sim.player.drone_charge, 0.0, "Survival's decoy is completely unaffected")

(These need _map()’s Sim to actually own no drone yet — pilot_has_drone() must return false, which it does by default on a fresh PlayerState/PilotArsenal, matching the design doc’s confirmation that a fresh save can’t own a drone.)

  • Step 2: Run to verify they fail

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_drones -gexit (adjust -gselect to match wherever Step 1 landed) Expected: test_decoy_charge_frozen_before_echo_decoy_in_tutorial FAILS (charge accrues from tick 1 today regardless of story state); the other two already PASS.

  • Step 3: Gate the charge accrual + deploy block

In sim/drone_director.gd, find:

func update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> void:
if not pilot_has_drone(sim, pilot):
pilot.drone_charge = minf(pilot.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0)
if input.decoy and pilot.drone_charge >= 1.0:
deploy_drones(sim, pilot, current_loadout(sim))
pilot.drone_charge = 0.0
return

Replace with:

func update_drones(sim: Sim, pilot: PlayerState, input: InputState, dt: float) -> void:
if not pilot_has_drone(sim, pilot):
# Tutorial: the decoy meter stays frozen at 0 until echo_decoy has fired (Stage 9) --
# reuses the existing dialogue id directly as the gate key, since it already fires at the
# right trigger (ember_a2's on_enter). Wraps the WHOLE block (accrual + deploy), not just
# the accrual line, for robustness -- deploy can never fire while frozen at 0 either way,
# but this doesn't rely on that as an unstated invariant.
if sim.story == null or not sim.story.tutorial_locked("echo_decoy"):
pilot.drone_charge = minf(pilot.drone_charge + DECOY_RECHARGE_RATE * sim.drone_recharge_mult * dt, 1.0)
if input.decoy and pilot.drone_charge >= 1.0:
deploy_drones(sim, pilot, current_loadout(sim))
pilot.drone_charge = 0.0
return
  • Step 4: Run to verify all tests pass

Run the same command as Step 2. Expected: PASS on all three.

  • Step 5: Commit
Terminal window
git add sim/drone_director.gd tests/
git commit -m "feat(story): Stage 9 -- gate the decoy/drone behind echo_decoy"

Task 12: Stage 12 — suppress the post-Warden Dimension Choice popup in Tutorial

Section titled “Task 12: Stage 12 — suppress the post-Warden Dimension Choice popup in Tutorial”

Files:

  • Modify: sim/sim.gd:2601 (_roll_dimension_choice())
  • Test: tests/test_sim.gd (or nearest existing test file exercising _roll_dimension_choice / pending_dimension_choice — search tests/ for _roll_dimension_choice or pending_dimension_choice to find the right home)

Interfaces:

  • Consumes: Sim.story (directly, NOT tutorial_locked() — this is the one deliberate exception in the whole plan).
  • Produces: nothing new.

Why story != null alone, not tutorial_cap-scoped: the intent is to suppress the popup in ordinary Story mode too (its own boss-outcome handling is different from Survival’s), not just Tutorial. Confirmed as a real, currently-unreliable bug independent of this redesign — today the popup only “usually” avoids colliding with the tutorial’s own closing dialogue due to execution timing, not a real gate. This is flagged as an explicit open product question (see the design doc’s Open Questions #2) — if Chris later decides Story mode should KEEP the popup, change this condition to _mode == "tutorial"-scoped at the main.gd call site instead, not here.

  • Step 1: Write the failing tests

Add to the file located above:

func test_dimension_choice_suppressed_in_story_mode():
var sim := Sim.new(1, SimContentFixture.db())
sim.enable_story(StoryData.from_arrays(
[{"id": "r", "kind": "corridor", "region": "ember", "rect": Rect2(0, 0, 400, 300), "doors": []}],
[], "r"))
sim.areas_enabled = true
sim._roll_dimension_choice()
assert_true(sim.pending_dimension_choice.is_empty(), "no dimension choice offered in a story run")
func test_dimension_choice_still_offered_in_survival():
var sim := Sim.new(1, SimContentFixture.db()) # no story -- Survival
sim.areas_enabled = true
sim._roll_dimension_choice()
assert_false(sim.pending_dimension_choice.is_empty(), "Survival's dimension choice is completely unaffected")

(If _roll_dimension_choice or pending_dimension_choice are not directly callable/visible from a test due to func visibility conventions elsewhere in this codebase, check how the nearest existing test in the same area calls it — GDScript has no true private, so a direct call should work identically to any other same-file test pattern already used in this repo.)

  • Step 2: Run to verify the first fails

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=<file> -gexit Expected: test_dimension_choice_suppressed_in_story_mode FAILS (today it’s offered regardless of story state, gated only on areas_enabled); the Survival test already PASSES.

  • Step 3: Gate _roll_dimension_choice()

In sim/sim.gd, find:

func _roll_dimension_choice() -> void:
if not areas_enabled:
return # v0.1: the explorable-areas system is gated off
if not pending_dimension_choice.is_empty():
return

Replace with:

func _roll_dimension_choice() -> void:
if not areas_enabled:
return # v0.1: the explorable-areas system is gated off
if story != null:
return # Tutorial AND ordinary Story mode both handle boss outcomes their own way --
# deliberately NOT tutorial_cap-scoped, unlike every other gate in this plan.
# See docs/superpowers/specs/2026-08-10-tutorial-stage-gating-design.md Open
# Question #2 if this needs narrowing to Tutorial-only later.
if not pending_dimension_choice.is_empty():
return
  • Step 4: Run to verify both tests pass

Same command as Step 2. Expected: PASS on both.

  • Step 5: Commit
Terminal window
git add sim/sim.gd tests/
git commit -m "feat(story): Stage 12 -- suppress the post-Warden Dimension Choice popup in story runs"

Files: none (verification only)

  • Step 1: Full GUT suite

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gexit Expected: exit 0, 0 failures.

  • Step 2: Test-count guard

Run: scripts/check-test-count.sh Expected: passes (no silent stale-class-cache script drop) — important here specifically because Task 3 deletes tests/test_control_hint.gd, which changes the expected count; confirm the script recomputes cleanly rather than flagging a false drop.

  • Step 3: Determinism baselines

Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_survival -gexit Run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -gselect=test_determinism_coop -gexit Expected: PASS, unchanged baselines (4217109746/3312602286 for survival, 370166760/2437492589 for co-op — see CLAUDE.md’s Architecture section, and re-read the literal pinned assertion in tests/test_determinism_survival.gd, not this prose, if either fails). Every gate in this plan lives behind story != null (Survival’s is always null) or, for Task 12, is a pure early-return with no state mutation on the Survival path — no re-pin is expected; a failure here is a real regression to investigate.

  • Step 4: Headless boot smoke

Run: godot --headless --path . --quit-after 120 Expected: no SCRIPT ERROR in stderr.

  • Step 4.5: Two pre-implementation-checklist items closed by research, not code — confirm they’re still true after all prior tasks land

Both were resolved during this plan’s own research pass (not left as open gaps), recorded here so a reviewer can verify them directly rather than trusting prose from an earlier session:

  1. ui/ship_config_panel.gd never renders the manual (Aim) weapon at all — its weapon ring is built from sim.upgrade_system.active_weapon_views(), which only iterates pilot.arsenal.active_weapon_ids (the Aim weapon is never in that array, by design — see sim/sim.gd:932’s own comment). Confirmed via a repo-wide grep for aim/Aim in that file returning zero matches. No gate was needed there; re-grep it after Task 5 lands to confirm nothing in this plan accidentally added an Aim-weapon reference to that file.
  2. sim/story_director.gd’s _check_teaching() (the pre-existing boss-only teach_decoy/ teach_heal function, untouched by this plan) should not visually duplicate Stage 9’s fuller echo_decoy intro. teach_decoy fires reactively during the Warden fight if the player is hurt and hasn’t seen it before; by the time a player reaches the Warden, echo_decoy (Task 11) has necessarily already fired (room-chain order: ember_a2 is passed through before ember_warden). Read teach_decoy’s dialogue text in data/story.json directly and confirm it reads as a reminder (“remember the decoy”) rather than a duplicate first-teach — if it reads as duplicate/contradictory now that echo_decoy’s beat is fuller (Task 11 reuses it verbatim, unchanged), that’s a content tweak worth making here, not a code change.
  • Step 5: Story Board capture smoke test against the redesigned Tutorial

Run a real (not simulated) capture via tools/story_board/capture.gd against the new Tutorial — follow docs/architecture/dev-tools.md’s existing invocation pattern for this tool. Expected: completes without hanging (this is the direct verification that Task 9’s (_auto or _capturing) fix actually works end-to-end, not just in the unit test), produces a storyboard.json with roughly 14 beats (the new move_intro first beat plus the split/added nodes from Tasks 4-11: at minimum move_intro, ember_intro, aim_intro, pulse_learn, hp_intro, radar_intro, levelup_intro, crystal_intro, echo_decoy, warden_intro, tutorial_complete — beat count and numbering will differ from any pre-redesign capture; do not try to reconcile against an old annotations.json by index).

  • Step 6: Manual playtest on Mac (not automatable — do this by hand)

Export or run in-editor (godot --path .), with a fresh user://meta.json (rename/back up the real one first if testing on Chris’s machine, then restore it — do NOT delete it):

  1. Fresh launch → Tutorial run starts. Confirm: no weapon fires, no dash works, no HUD element beyond the move_intro dialogue box is visible/active until each is individually taught.
  2. Move toward Echo — confirm nothing else is usable while moving.
  3. Meet Echo — confirm the auto-weapon (Blaster, not Blade) is granted and starts firing on its own; confirm the weapon dock tile appears.
  4. Enter the first arena — confirm aim_intro fires before the right stick does anything, then confirm it works and the Aim tile appears once it has.
  5. Fight through — confirm only ONE swarmer is ever alive at a time; confirm dash does nothing until pulse_learn’s beat (which also grants Pulse) fires, then works; confirm the HP bar and threat radar narration beats fire at the right moments and nothing appears before them; confirm the level-up panel doesn’t open until levelup_intro has fired, then opens normally.
  6. Reach ember_a2 — confirm the decoy meter was frozen at 0 the whole time before this, and starts charging/works after echo_decoy.
  7. Defeat the Ember Warden, walk into the portal — confirm the Dimension Choice popup does NOT appear, tutorial_complete plays, and the game auto-drops into a fresh Survival run with everything unlocked immediately (no leftover gating).
  8. Quit and relaunch — menu shows Tutorial + Survival as normal; a Tutorial replay re-applies every gate from scratch (current, intentionally-unchanged behavior — see the design doc’s Open Question #3 if this should ever change).

This is the step that validates the actual “nothing is active until introduced” feel end-to-end — the automated tests above cover the mechanics, not the experience.

  • Step 7: Update docs/RELEASE-0.1.md and CLAUDE.md

Mark the tutorial-stage-gating item [x] in docs/RELEASE-0.1.md with a ✅ DONE (commit range) note, following the file’s existing convention. Update CLAUDE.md’s “Current status” section in place (per its own stated convention — condense, don’t append a new dated bullet) to reflect the redesigned Tutorial, and note that BUILD N+1 (whatever the next real deploy is) should be the one Eve captures for Story Board, per Chris’s 2026-08-10 decision that her in-flight capture targets the updated tutorial, not the old one.

  • Step 8: Commit
Terminal window
git add docs/RELEASE-0.1.md CLAUDE.md
git commit -m "docs: mark tutorial-stage-gating done; update Current status"