Tutorial Stage Gating Implementation Plan
Tutorial Stage Gating Implementation Plan
Section titled “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.
Global Constraints
Section titled “Global Constraints”/simfiles (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 readmain._modefrom/sim.- Every gate site uses
sim.story.tutorial_locked(id)— never a hand-rolledseen_dialogue.has(id)check.tutorial_locked()folds intutorial_cap; a hand-rolled check would leak new gating into ordinary (non-capped) Story mode, whosestoryis also non-null. The one deliberate exception is Task 12 (_roll_dimension_choice), which gates onstory != nullalone — 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, wheresim.storyis alwaysnull. No shorthand. - Never call
MetaStore.save_state()in a test. Writes the realuser://meta.jsonfile — no test-sandboxed path. Setmeta = nullfirst if a test path would otherwise trigger_bank_run()’s save call (seetests/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, aftermark_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 trueAdd 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
git add sim/story_state.gd tests/test_story_director.gdgit 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 — seemain._should_force_tutorial()in the 2026-07-21 tutorial-onboarding plan, andtests/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
git add main.gd tests/test_main.gdgit 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(newmove_introdialogue node, attach toember_entry’son_enter) - Modify:
ui/hud.gd(addsuppress_story_label, update the story-label gate) - Modify:
main.gd:942-948(removeControlHintfor Tutorial; add the shared dock-force-visible fix at the same site) - Modify:
main.gd(thecontrol_hint.advance()per-frame call, ~line 1300-1307 — must not crash now thatcontrol_hintis 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, defaultfalse).
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_labeltoHudand 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 = falseReplace with:
if sim.story != null and not suppress_story_label: _story_label.visible = true _story_label.text = _story_objective(sim) else: _story_label.visible = falseFind 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
ControlHintfor 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
ControlHintclass and its test
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_introdialogue node and attach it toember_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_labelfrommain.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
git add data/story.json ui/hud.gd main.gd tests/test_main.gd tests/test_hud.gdgit rm ui/control_hint.gd tests/test_control_hint.gdgit 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(newgive_blasterreward entry; trimember_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_blasterreward entry todata/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
git add sim/story_director.gd data/story.json tests/test_story_director.gdgit 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_rootvisibility) - Modify:
data/story.json(repointember_e1’s encounterdialoguefrom the placeholdernull_1to a newaim_intronode) - 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: returnReplace 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_rootvisibility inui/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 fromnull_1to a newaim_intronode
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
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(extendpulse_learnwith 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_learnwith 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
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_e1enemy 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_spawnwait-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 cyclesReplace 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": truetoember_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
git add sim/story_director.gd data/story.json tests/test_story_director.gdgit 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_teachingpattern into a Tutorial-wide sibling; add it toupdate()) - Modify:
data/story.json(newhp_intro,radar_introdialogue 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 fromupdate()
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_introandradar_introdialogue 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
git add sim/story_director.gd data/story.json tests/test_story_director.gdgit 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(newlevelup_introdialogue 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 bytools/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_introreactive 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)) returnReplace 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)) returnFind 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)) returnReplace 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_introdialogue 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
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(newcrystal_introdialogue 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 isui/level_up_panel.gd’s wallet section, which is already gated by Task 9’slevelup_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_introdialogue 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
git add sim/story_director.gd data/story.json tests/test_story_director.gdgit 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(ortests/test_drones.gd— see Step 1 note)
Interfaces:
-
Consumes:
StoryState.tutorial_locked()(Task 1). -
Produces: nothing new. Reuses the existing
echo_decoydialogue id directly as the gate key (it already fires at the right trigger —ember_a2’son_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 returnReplace 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
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— searchtests/for_roll_dimension_choiceorpending_dimension_choiceto find the right home)
Interfaces:
- Consumes:
Sim.story(directly, NOTtutorial_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(): returnReplace 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
git add sim/sim.gd tests/git commit -m "feat(story): Stage 12 -- suppress the post-Warden Dimension Choice popup in story runs"Task 13: Full verification sweep
Section titled “Task 13: Full verification sweep”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:
ui/ship_config_panel.gdnever renders the manual (Aim) weapon at all — its weapon ring is built fromsim.upgrade_system.active_weapon_views(), which only iteratespilot.arsenal.active_weapon_ids(the Aim weapon is never in that array, by design — seesim/sim.gd:932’s own comment). Confirmed via a repo-wide grep foraim/Aimin 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.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 fullerecho_decoyintro.teach_decoyfires 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_a2is passed through beforeember_warden). Readteach_decoy’s dialogue text indata/story.jsondirectly and confirm it reads as a reminder (“remember the decoy”) rather than a duplicate first-teach — if it reads as duplicate/contradictory now thatecho_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):
- Fresh launch → Tutorial run starts. Confirm: no weapon fires, no dash works, no HUD element
beyond the
move_introdialogue box is visible/active until each is individually taught. - Move toward Echo — confirm nothing else is usable while moving.
- Meet Echo — confirm the auto-weapon (Blaster, not Blade) is granted and starts firing on its own; confirm the weapon dock tile appears.
- Enter the first arena — confirm
aim_introfires before the right stick does anything, then confirm it works and the Aim tile appears once it has. - 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 untillevelup_introhas fired, then opens normally. - Reach
ember_a2— confirm the decoy meter was frozen at 0 the whole time before this, and starts charging/works afterecho_decoy. - Defeat the Ember Warden, walk into the portal — confirm the Dimension Choice popup does NOT
appear,
tutorial_completeplays, and the game auto-drops into a fresh Survival run with everything unlocked immediately (no leftover gating). - 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.mdandCLAUDE.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
git add docs/RELEASE-0.1.md CLAUDE.mdgit commit -m "docs: mark tutorial-stage-gating done; update Current status"