Skip to content

Tutorial lesson track — implementation plan (2026-09-24)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (- [ ]) syntax.

Goal: Rebuild the Ember Reach tutorial as ten ordered lessons, each unlocking exactly one thing and ending on an objective the game checks, with a clear objective panel, speakers you can tell apart, rewritten copy, and every feature gated until its lesson has been read.

Architecture: A new pure sim/tutorial_lessons.gd (TutorialLessons, owned as sim.tutorial, created only for the capped tutorial) holds the lesson table, tracks lesson/step/progress from real sim signals, owns the scripted spawns and door gates, and answers every gate query (is_unlocked, levelup_allowed, pickup_allowed, npc_meetable). StoryDirector calls it every tick; the UI reads it (objective_view, panel_options) and reports the two UI-only facts (stats sheet opened) through note_ui. Beats count as read on the tick after they were said, which is exactly “dismissed” because the tutorial freezes the sim while dialogue shows.

Tech Stack: Godot 4.7.1, typed GDScript, GUT 9.7.1 (headless).

Spec (binding): docs/superpowers/specs/2026-09-24-tutorial-lesson-track-design.md

  • Work on a feature branch tutorial-lesson-track off main; one commit per task; never push from an implementer. Chris or the controller pushes after review.
  • Gate after every task: bash scripts/check-test-count.sh must end with a line starting test-count guard OK (the spec calls this “guard OK”).
  • After adding any new class_name file, run godot --headless --path . --import once before running GUT (otherwise GUT errors “Some GUT class_names have not been imported”). Fresh worktrees need the same import pass first.
  • Single-script test run: godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=<script_name_without_.gd> -gexit (-gtest does not narrow; -gselect does).
  • /sim stays pure: every sim file extends RefCounted; no Node, Input, Engine, Time or OS APIs; no RNG draws in any new code (all new placement is deterministic math).
  • One-way data flow holds: UI reads sim state. The only UI to sim writes are the existing tick/apply_upgrade plus the new TutorialLessons.note_ui(sim, event), which, like apply_upgrade, is a player choice made while the sim is frozen.
  • Determinism baselines must stay UNCHANGED (story mode is not in them). Verify in Tasks 4, 5 and 12: -gselect=test_determinism_survival and -gselect=test_determinism_coop both pass with no re-pin. Read the pinned values in those files, never prose.
  • Every change is gated on sim.tutorial != null (tutorial only). Survival, co-op and ordinary non-tutorial Story mode behave exactly as before; ordinary Story mode must keep working (its own tests stay green).
  • Player-facing text: plain English, short sentences, one idea each, second person, TRUE to the sim (read the code behind every claim), and NO em dashes (U+2014) anywhere in player-facing strings.
  • XP is paid on biomass: any spawn path uses sim.spawn_story_enemy(...) (which passes biomass), never a bare enemies.add.
  • StoryDirector reads pilot state BEFORE _sweep_dead() writes it: anything banked at the end of tick N is only visible to the lesson track on tick N+1. Harnesses must not drain pending_levelups inline while the level-up panel is locked.
  • story_trace.gd + StoryAutoPlayer are the only automated story coverage; they must complete the whole tutorial at the end of this plan.
  • UI sweep is zsh scripts/ui-sweep.sh (never bash), windowed, foreground. It already fails on unmodified main (bestiary.png stale, hud_in_run.png flaky, see NEXT_TASKS.md); A/B against a detached main worktree before blaming this branch.
  • Any WINDOWED godot --path . run can corrupt project.godot: run git diff --quiet project.godot || git checkout -- project.godot right after every windowed run. Never commit project.godot changes.
  • Between Task 3 and Task 5 the tutorial is not playable (old beat gates stop opening before the new feature gates land). Do not deploy mid-plan.
File Create/Modify Responsibility
sim/tutorial_lessons.gd Create Lesson table, unlock queries, lesson/step progression, objective detection, scripted spawns, tutorial door gates, objective/panel views, test/capture skip_to
sim/story_state.gd Modify Track which beats have been READ (read_dialogue, said_pending, promote_read); tutorial_locked becomes read-based
sim/story_director.gd Modify Public say(); mark beats said/read; call the lesson track each tick; skip tutorial-owned room beats, encounters and NPC dialogue; delete _check_tutorial_teaching
sim/sim.gd Modify tutorial field + tutorial_locks(); promote read beats each tick; feature gates on aim/warp/decoy recharge; pickup gate; threshold gate
sim/elemental_system.gd Modify Report every hit (context, hp before/after) to the lesson track
sim/upgrade_system.gd Modify Tutorial crystal-offer gating + the guaranteed crystal card; report picks to the lesson track
sim/drone_director.gd Modify Decoy meter/deploy/Bay gates use the decoy feature
data/story.json Modify Speaker role/emblem/side; every tutorial beat rewritten; new lesson beats; obsolete tutorial beats removed
ui/button_glyph.gd Modify Draw LB/RB bumpers, L/R sticks, and the Menu button
ui/button_prompt.gd Modify Last-used input device tracking; action-to-glyph table
input/input_router.gd Modify Feed every input event to ButtonPrompt.note_input
ui/speaker_emblem.gd Create Drawn speaker emblem (diamond, flame, ring, crown)
ui/dialogue_box.gd Modify Emblem instead of initial, role subtitle, allies left and enemies right, control names by last-used device
ui/objective_panel.gd Create Clear-glass top-centre lesson bar with glyph prompt, progress, tick, and a tip line
ui/level_up_base.gd Modify sheet_opened signal + no-op configure_tutorial
ui/level_up_panel.gd Modify Tutorial options: tag explainer line, stats sheet gating, sheet-before-pick
ui/pause_menu.gd Modify hidden_entries + stats_opened signal
ui/weapon_panel.gd Modify Aim tile gated on the aim feature
ui/threat_radar.gd Modify offscreen_count() for the radar tip
main.gd Modify Wire the objective panel, tips, level-up/pause options, UI notes, hotkey locks, codex-card lock, starter loadout, dev-only X overlay, feature-gated HUD/touch
tools/story_auto/story_auto_player.gd Modify Perform each objective (targets, warp, decoy, Nova, crystal pick)
tools/story_auto/story_trace.gd Modify Drive UI objectives, print lessons in order, fail on out-of-lesson unlocks
tools/tutorial_capture/lesson_shots.gd + .tscn Create Dev-only 1280x720 capture of dialogue + objective panel per lesson
tests/test_story_read_gating.gd Create Beats read on the tick after they are said
tests/test_tutorial_lessons.gd Create Table shape and unlock queries
tests/test_tutorial_lesson_flow.gd Create Progression through real sim paths
tests/test_tutorial_lesson_hooks.gd Create Hit/kill attribution, picks, UI notes, offers, pickups, thresholds
tests/test_tutorial_copy.gd Create Copy lint + speaker metadata
tests/test_speaker_emblem.gd Create Emblem shapes
tests/test_objective_panel.gd Create Objective panel rendering + layout
existing tests (listed per task) Modify Migrate from beat-keyed gates to lesson features
docs/architecture/meta-and-story.md, CLAUDE.md, docs/RELEASE-0.1.md Modify Architecture note, current-status bullet in place, RC entry + playtest script

Rooms follow the existing Ember Reach spine: ember_entry → ember_a1 → ember_a2 (secret Orbit room off it) → ember_safe1 → ember_a3 → ember_a4 → ember_warden → ember_portal.

# id Title Intro beat Room Opens gate Unlocks Steps (kind: detection)
1 wake WAKE UP move_intro entry move meet echo_intro
2 blaster BLASTER ember_intro entry weapon_dock pickup blaster; kills 5 with blaster (swarmer, 1 alive)
3 levelup LEVEL UP tut_levelup a1 g_ember_a1 levelup reach ember_a1; level (swarmer, 2 alive); pick (panel)
4 stats SHIP STATS tut_stats a1 stats_sheet level (swarmer, 2); sheet (panel); pick (panel)
5 riftbolt RIFT BOLT tut_riftbolt a1 aim hits 3 with aim (swarmer, 3)
6 warp WARP tut_warp a2 g_ember_a2 warp reach ember_a2; warp with an enemy close (elite = Charger, 1)
7 decoy DECOY tut_decoy a2 decoy decoy out (swarmer, 3); survive 10 s (swarmer, 4)
8 crystals CRYSTALS tut_crystals a2 crystals level (swarmer, 2); pick_crystal (panel); sheet (pause menu)
9 nova NOVA echo_gift safe1 g_ember_safe1 nova reach ember_safe1; meet echo_gift; pickup nova; kills 8 with nova (spider, 4)
10 warden THE WARDEN tut_warden a3 g_ember_a3 nothing reach ember_warden; boss cleared; reach ember_portal

Decisions baked into this table (each is repeated where it is implemented):

  • Decision: “Fly through the open door” reach_room steps are navigation glue at the start of lessons 3, 6, 9 and 10, so the player is never told to do a lesson’s objective in a room with a sealed door. They unlock nothing.
  • Decision: lesson 10 keeps ember_a3 (Ash) and ember_a4 (The Null) room encounters as “fight your way to the Warden”. This is how Ash and The Null get introduced, and it teaches nothing new, matching “nothing new” for lesson 10.
  • Decision: the objective says “Destroy 5 enemies”, not “drones”: the player’s own decoy is called a drone in-game (touch button DRONE, Drone Bay), so “drones” would name the wrong thing.
  • Decision: Nova’s objective counts killing blows dealt by Nova (damage_context == "nova"). Spiders (Weaver, 5 HP) are used because Nova’s 5.2 damage kills them in one pulse; swarmers (6 HP) would split kills with the Blaster.
  • Decision: lesson 8’s “see its bar in the stats sheet” is done from the pause menu’s Ship stats, because the level-up panel closes when you pick. Its prompt is the Menu glyph.
  • Decision: lesson 4 blocks confirming a card until the stats sheet has been opened once on that panel, so “open the stats sheet → pick a card” happens in order.
  • Decision: the spec’s “Collect XP” objective reads “Destroy enemies to earn XP” (lessons 4 and 8: “Earn XP for your next level”). Kill XP is banked straight to the pilot (AUTO_COLLECT_XP = true, sim.gd:219; _award_xp, sim.gd:2409-2418), so there is nothing to fly over and collect; “collect” would be untrue to the sim.

Task 1: Read tracking, the lesson table and unlock queries

Section titled “Task 1: Read tracking, the lesson table and unlock queries”

Files:

  • Create: sim/tutorial_lessons.gd
  • Modify: sim/story_state.gd (after mark_dialogue_seen, lines 64-69), sim/story_director.gd (_say, lines 346-363), sim/sim.gd (fields at 617-618, tick at 772-773, enable_story at 969-991)
  • Test: create tests/test_story_read_gating.gd, tests/test_tutorial_lessons.gd

Interfaces:

  • Consumes: StoryState.mark_dialogue_seen(id) -> bool, StoryData.npcs, StoryData.room_by_id(id) -> Dictionary, UpgradeSystem.grant_weapon(sim, pilot, wid).

  • Produces:

    • StoryState.read_dialogue: Dictionary, StoryState.said_pending: Array[String], StoryState.note_said(id: String) -> void, StoryState.mark_read(id: String) -> void, StoryState.promote_read() -> void. (tutorial_locked is NOT changed in this task; Task 5 flips it.)
    • StoryDirector.say(sim: Sim, dialogue_id: String) -> void (public wrapper of _say).
    • Sim.tutorial: TutorialLessons (null unless enable_story(..., tutorial_cap=true)), Sim.tutorial_locks(feature: String) -> bool.
    • TutorialLessons constants F_MOVE, F_WEAPON_DOCK, F_LEVELUP, F_STATS, F_AIM, F_WARP, F_DECOY, F_CRYSTALS, F_NOVA, LESSONS, TUTORIAL_GATES, OWNED_ROOMS, OWNED_NPCS, LESSON_WEAPONS, CRYSTAL_CARD, FREE_LEVELUPS_FROM, DONE_HOLD, SPAWN_GAP, SPAWN_MIN_DIST, WARP_CLOSE_RADIUS, tip/tag-note strings; vars index, step, progress; static lesson_index(id) -> int, static owner_of(feature) -> int, static all_features() -> Array[String], is_unlocked(feature) -> bool, is_active() -> bool, current_lesson() -> Dictionary, current_step() -> Dictionary, current_step_kind() -> String, levelup_allowed() -> bool, crystal_grants_allowed() -> bool, forced_offer() -> String, pickup_allowed(wid) -> bool, npc_meetable(nid) -> bool, owns_room(room_id) -> bool, owns_npc(nid) -> bool, skip_to(sim, lesson_id, read := true, step_i := 0, place := false) -> void.
  • Step 1: Write the failing tests

tests/test_story_read_gating.gd:

extends GutTest
# Beats count as READ on the tick after they were said (spec §3: gates open when the beat is read,
# not when it is queued). This is exactly "dismissed" in the tutorial because main.gd freezes the
# tutorial sim while the dialogue box shows, so no tick runs until the player has finished the beat.
func _tut() -> Sim:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true)
return sim
func test_a_beat_is_not_read_on_the_tick_it_is_said() -> void:
var sim := _tut()
sim.story_director.say(sim, "ash_taunt")
assert_true(sim.story.seen_dialogue.has("ash_taunt"), "said")
assert_false(sim.story.read_dialogue.has("ash_taunt"), "but not read while it is still on screen")
func test_a_said_beat_is_read_from_the_next_tick() -> void:
var sim := _tut()
sim.story_director.say(sim, "ash_taunt")
sim.tick_single(InputState.new())
assert_true(sim.story.read_dialogue.has("ash_taunt"), "read once a tick has run past it")
func test_a_missing_beat_counts_as_read_at_once() -> void:
var sim := _tut()
sim.story_director.say(sim, "no_such_beat")
assert_true(sim.story.read_dialogue.has("no_such_beat"), "nothing to read, so nothing to wait for")
func test_story_mode_marks_suppressed_tutorial_beats_read() -> void:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), true, false, false)
sim.story_director.say(sim, "move_intro") # tutorial-flagged, suppressed outside the tutorial
assert_true(sim.story.read_dialogue.has("move_intro"))

tests/test_tutorial_lessons.gd:

extends GutTest
# The lesson table + unlock queries (spec §1, §4). Progression itself is covered by
# test_tutorial_lesson_flow.gd; these pin the table shape and the gate primitive.
func _data() -> StoryData:
return StoryLoader.load_from_path("res://data/story.json")
func _tut() -> Sim:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(_data(), false, false, true)
return sim
func test_there_are_ten_lessons_in_the_spec_order() -> void:
var ids: Array = []
for l in TutorialLessons.LESSONS:
ids.append(String(l["id"]))
assert_eq(ids, ["wake", "blaster", "levelup", "stats", "riftbolt", "warp", "decoy", "crystals", "nova", "warden"])
func test_every_feature_belongs_to_exactly_one_lesson() -> void:
var seen := {}
for i in TutorialLessons.LESSONS.size():
for f in TutorialLessons.LESSONS[i]["unlocks"]:
assert_false(seen.has(f), "%s is unlocked by two lessons" % f)
seen[f] = i
for f in [TutorialLessons.F_WEAPON_DOCK, TutorialLessons.F_LEVELUP, TutorialLessons.F_STATS,
TutorialLessons.F_AIM, TutorialLessons.F_WARP, TutorialLessons.F_DECOY,
TutorialLessons.F_CRYSTALS, TutorialLessons.F_NOVA]:
assert_true(seen.has(f), "%s has an owning lesson" % f)
assert_eq(TutorialLessons.owner_of(TutorialLessons.F_AIM), 4, "the Rift Bolt is lesson 5")
func test_lesson_references_exist_in_story_json() -> void:
var d := _data()
var gates := {}
for door in d.doors:
gates[String(door.get("gate", ""))] = true
var npcs := {}
for n in d.npcs:
npcs[String(n["id"])] = true
for l in TutorialLessons.LESSONS:
assert_false(d.room_by_id(String(l["room"])).is_empty(), "room %s" % l["room"])
var g := String(l.get("open_gate", ""))
assert_true(g == "" or gates.has(g), "gate %s" % g)
for st in l["steps"]:
assert_true(String(st["kind"]) in TutorialLessons.STEP_KINDS, "kind %s" % st["kind"])
if st.has("npc"):
assert_true(npcs.has(String(st["npc"])), "npc %s" % st["npc"])
if st.has("room"):
assert_false(d.room_by_id(String(st["room"])).is_empty(), "room %s" % st["room"])
assert_false(String(st["text"]).contains("—"), "no em dash: %s" % st["text"])
for g in TutorialLessons.TUTORIAL_GATES:
assert_true(gates.has(g), "tutorial gate %s exists" % g)
func test_only_the_capped_tutorial_gets_a_lesson_track() -> void:
var content := ContentLoader.load_from_path("res://data/bible.json")
var surv := Sim.new(1, content)
surv.enable_survival()
assert_null(surv.tutorial, "Survival has no lesson track")
assert_false(surv.tutorial_locks(TutorialLessons.F_AIM), "and nothing is locked there")
var story := Sim.new(1, content)
story.enable_story(_data(), true, true, false)
assert_null(story.tutorial, "ordinary Story mode has no lesson track")
assert_not_null(_tut().tutorial, "the capped tutorial does")
func test_features_unlock_only_once_their_lesson_is_read() -> void:
var sim := _tut()
assert_true(sim.tutorial_locks(TutorialLessons.F_AIM), "a fresh tutorial has no Rift Bolt")
sim.tutorial.skip_to(sim, "riftbolt", false)
assert_true(sim.tutorial_locks(TutorialLessons.F_AIM), "its lesson has started but the intro is unread")
sim.tutorial.skip_to(sim, "riftbolt", true)
assert_false(sim.tutorial_locks(TutorialLessons.F_AIM), "read: unlocked")
assert_false(sim.tutorial_locks(TutorialLessons.F_WEAPON_DOCK), "earlier lessons stay unlocked")
assert_true(sim.tutorial_locks(TutorialLessons.F_WARP), "later lessons stay locked")
func test_skip_to_carries_earlier_lessons_forward() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "riftbolt", true)
assert_true(sim.player.arsenal.active_weapon_ids.has("blaster"), "the Blaster was picked up in lesson 2")
assert_true(sim.story.talked_npcs.has("echo_intro"), "Echo was met in lesson 1")
assert_false(sim.story.is_gate_sealed("g_ember_a1"), "lesson 3 opened the first door")
assert_true(sim.story.is_gate_sealed("g_ember_a2"), "lesson 6's door is still shut")
func test_levelup_panel_is_only_allowed_on_panel_steps() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 1) # "Destroy enemies to earn XP"
assert_false(sim.tutorial.levelup_allowed())
sim.tutorial.skip_to(sim, "levelup", true, 2) # "Pick an upgrade"
assert_true(sim.tutorial.levelup_allowed())
sim.tutorial.skip_to(sim, "riftbolt", true)
assert_false(sim.tutorial.levelup_allowed(), "surplus levels wait while the Rift Bolt is taught")
sim.tutorial.skip_to(sim, "nova", true)
assert_true(sim.tutorial.levelup_allowed(), "from the Nova lesson on, levels open freely")
  • Step 2: Run to confirm they fail (TutorialLessons / read_dialogue / say do not exist): godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_tutorial_lessons -gexit and the same with -gselect=test_story_read_gating.

  • Step 3: Implement read tracking. In sim/story_state.gd, add under seen_dialogue:

var read_dialogue: Dictionary = {} # dialogue_id -> true once the player has finished reading it
var said_pending: Array[String] = [] # said this tick; become READ at the top of the next tick

and after mark_dialogue_seen:

func note_said(id: String) -> void:
said_pending.append(id)
func mark_read(id: String) -> void:
read_dialogue[id] = true
# Called by Sim.tick at the top of every tick. A beat said on tick N is READ from tick N+1. In the
# tutorial that is exactly "dismissed": main.gd freezes the tutorial sim while the dialogue box is
# showing, so tick N+1 cannot run until the player has finished every queued line.
func promote_read() -> void:
for id in said_pending:
read_dialogue[id] = true
said_pending.clear()

In sim/story_director.gd replace _say with:

func say(sim: Sim, dialogue_id: String) -> void:
_say(sim, dialogue_id)
func _say(sim: Sim, dialogue_id: String) -> void:
if not sim.story.mark_dialogue_seen(dialogue_id):
return # fire-once
var dlg: Dictionary = sim.story.data.dialogue.get(dialogue_id, {})
if dlg.is_empty():
sim.story.mark_read(dialogue_id) # nothing to read, so nothing to wait for
return
if sim.story.suppress_tutorial and bool(dlg.get("tutorial", false)):
sim.story.mark_read(dialogue_id) # tutorials don't replay; never shown, so never waited on
return
sim.story.note_said(dialogue_id)
var spk_id := String(dlg.get("speaker", ""))
var spk: Dictionary = sim.story.data.speakers.get(spk_id, {})
sim.story.events.append({
"kind": "say",
"id": dialogue_id,
"speaker": spk_id,
"name": spk.get("name", spk_id),
"color": spk.get("color", "#ffffff"),
"lines": dlg.get("lines", []),
})

In sim/sim.gd: next to story_director (line 618) add

var tutorial: TutorialLessons = null # the capped tutorial's lesson track; null everywhere else (determinism-safe)
# True while a tutorial feature is still locked. Always false outside the capped tutorial.
func tutorial_locks(feature: String) -> bool:
return tutorial != null and not tutorial.is_unlocked(feature)

in tick() (line 772-773) make the story block

if story != null:
story.events.clear() # story signals are per-tick, like fx_events
story.promote_read() # beats said last tick have now been read (see StoryState.promote_read)

and in enable_story, right after story_director = StoryDirector.new():

tutorial = TutorialLessons.new() if tutorial_cap else null
  • Step 4: Create sim/tutorial_lessons.gd (table, queries and skip_to; the per-tick engine arrives in Task 3):
class_name TutorialLessons
extends RefCounted
# The tutorial LESSON TRACK (spec: docs/superpowers/specs/2026-09-24-tutorial-lesson-track-design.md).
# Ten lessons. Each unlocks one thing and ends on an objective the game checks. Owned by Sim as
# `sim.tutorial` and created by Sim.enable_story() ONLY for the capped tutorial, so Survival, both
# determinism baselines and ordinary Story mode never touch it (sim.tutorial == null there).
#
# Pure /sim: RefCounted, no Node/Input/Engine/Time, and no RNG draws anywhere (spawn placement is
# deterministic math). The UI reads objective_view()/panel_options(); the only UI write is note_ui().
#
# A lesson's features unlock when its intro beat has been READ (StoryState.read_dialogue), never
# when it is queued (the 2026-08-19 audit's "gates open on queue" hole).
const F_MOVE := "move" # informational: movement is never gated (the sim is frozen while you read)
const F_WEAPON_DOCK := "weapon_dock"
const F_LEVELUP := "levelup"
const F_STATS := "stats_sheet"
const F_AIM := "aim"
const F_WARP := "warp"
const F_DECOY := "decoy" # decoy meter, deploy, and the Drone Bay
const F_CRYSTALS := "crystals" # crystal cards + crystal threshold upgrades/evolutions
const F_NOVA := "nova"
const STEP_KINDS := ["meet", "pickup", "kills", "hits", "reach_room", "level", "pick", "pick_crystal",
"sheet", "warp", "decoy", "survive", "boss"]
const DONE_HOLD := 1.5 # seconds the finished lesson holds its tick before the next begins
const SPAWN_GAP := 0.8 # seconds between scripted lesson spawns
const SPAWN_MIN_DIST := 220.0 # a scripted spawn never lands closer than this to the player
const WARP_CLOSE_RADIUS := 260.0 # "an enemy is close" for the warp lesson
const GOLDEN_ANGLE := 2.39996323 # spawn ring step (deterministic, evenly spread)
# The guaranteed crystal card for lesson 8. Crystal counts are always empty before this card (crystal
# cards are locked until lesson 8), so +3 kinetic crosses no rung but builds toward the Blaster's 5
# kinetic and the Rift Bolt's 10 kinetic: the panel tags it LONG-TERM (LevelUpPanel.why_tag).
const CRYSTAL_CARD := "crystals:kinetic=3"
const FREE_LEVELUPS_FROM := 8 # 0-based: from the Nova lesson on, level-ups and crystal grants flow freely
const OWNED_ROOMS := ["ember_entry", "ember_a1", "ember_a2", "ember_safe1"] # no room beat/encounter here in the tutorial
const OWNED_NPCS := ["echo_intro", "echo_gift"] # met only on their lesson's step
const LESSON_WEAPONS := ["blaster", "nova"] # picked up only on their lesson's step
const TUTORIAL_GATES := ["g_ember_a1", "g_ember_a2", "g_ember_safe1", "g_ember_a3"] # sealed at start
# Tag explainer lines for the level-up panel (LevelUpPanel.why_tag decides the tags).
const TAG_NOTE_LEVELUP := "The tag says when a card pays off. INSTANT means right away."
const TAG_NOTE_CRYSTALS := "LONG-TERM cards build toward a weapon upgrade."
# Contextual tips shown in the objective panel (render-side, never a lesson).
const TIP_RADAR := "Arrows at the screen edge point to enemies you can't see."
const TIP_HP := "Your health bar shows at the top when you're hurt."
const TIP_DECOY := "Low on health? Send out your decoy drone."
const TIP_HEAL := "Hold still for a moment to heal."
const LESSONS: Array = [
{"id": "wake", "title": "WAKE UP", "intro": "move_intro", "room": "ember_entry", "unlocks": [F_MOVE],
"steps": [
{"kind": "meet", "npc": "echo_intro", "text": "Fly to Echo", "prompt": "move"},
]},
{"id": "blaster", "title": "BLASTER", "intro": "ember_intro", "room": "ember_entry", "unlocks": [F_WEAPON_DOCK],
"steps": [
{"kind": "pickup", "weapon": "blaster", "text": "Fly over the Blaster to pick it up", "prompt": "move"},
{"kind": "kills", "weapon": "blaster", "count": 5, "text": "Destroy 5 enemies", "prompt": "",
"spawn": {"type": "swarmer", "alive": 1}},
]},
{"id": "levelup", "title": "LEVEL UP", "intro": "tut_levelup", "room": "ember_a1", "open_gate": "g_ember_a1",
"unlocks": [F_LEVELUP],
"steps": [
{"kind": "reach_room", "room": "ember_a1", "text": "Fly through the open door", "prompt": "move"},
{"kind": "level", "text": "Destroy enemies to earn XP", "prompt": "", "spawn": {"type": "swarmer", "alive": 2}},
{"kind": "pick", "text": "Pick an upgrade", "prompt": "choose", "panel": true},
]},
{"id": "stats", "title": "SHIP STATS", "intro": "tut_stats", "room": "ember_a1", "unlocks": [F_STATS],
"steps": [
{"kind": "level", "text": "Earn XP for your next level", "prompt": "",
"spawn": {"type": "swarmer", "alive": 2}},
{"kind": "sheet", "text": "Open Ship stats", "prompt": "sheet", "panel": true},
{"kind": "pick", "text": "Pick an upgrade", "prompt": "choose", "panel": true},
]},
{"id": "riftbolt", "title": "RIFT BOLT", "intro": "tut_riftbolt", "room": "ember_a1", "unlocks": [F_AIM],
"steps": [
{"kind": "hits", "weapon": "aim", "count": 3, "text": "Hit 3 enemies with the Rift Bolt", "prompt": "aim",
"spawn": {"type": "swarmer", "alive": 3}},
]},
{"id": "warp", "title": "WARP", "intro": "tut_warp", "room": "ember_a2", "open_gate": "g_ember_a2",
"unlocks": [F_WARP],
"steps": [
{"kind": "reach_room", "room": "ember_a2", "text": "Fly through the open door", "prompt": "move"},
{"kind": "warp", "text": "Warp while a Charger is close", "prompt": "warp",
"spawn": {"type": "elite", "alive": 1}},
]},
{"id": "decoy", "title": "DECOY", "intro": "tut_decoy", "room": "ember_a2", "unlocks": [F_DECOY],
"steps": [
{"kind": "decoy", "text": "Send out your decoy drone", "prompt": "decoy",
"spawn": {"type": "swarmer", "alive": 3}},
{"kind": "survive", "seconds": 10, "text": "Survive 10 seconds", "prompt": "",
"spawn": {"type": "swarmer", "alive": 4}},
]},
{"id": "crystals", "title": "CRYSTALS", "intro": "tut_crystals", "room": "ember_a2", "unlocks": [F_CRYSTALS],
"steps": [
{"kind": "level", "text": "Earn XP for your next level", "prompt": "",
"spawn": {"type": "swarmer", "alive": 2}},
{"kind": "pick_crystal", "text": "Pick the crystal card", "prompt": "choose", "panel": true},
{"kind": "sheet", "text": "Pause and open Ship stats", "prompt": "menu"},
]},
{"id": "nova", "title": "NOVA", "intro": "echo_gift", "room": "ember_safe1", "open_gate": "g_ember_safe1",
"unlocks": [F_NOVA],
"steps": [
{"kind": "reach_room", "room": "ember_safe1", "text": "Fly through the open door", "prompt": "move"},
{"kind": "meet", "npc": "echo_gift", "text": "Fly to Echo", "prompt": "move"},
{"kind": "pickup", "weapon": "nova", "text": "Fly over Nova to pick it up", "prompt": "move"},
{"kind": "kills", "weapon": "nova", "count": 8, "text": "Destroy 8 enemies with Nova", "prompt": "",
"spawn": {"type": "spider", "alive": 4}},
]},
{"id": "warden", "title": "THE WARDEN", "intro": "tut_warden", "room": "ember_a3", "open_gate": "g_ember_a3",
"unlocks": [],
"steps": [
{"kind": "reach_room", "room": "ember_warden", "text": "Fight your way to the Warden", "prompt": "move"},
{"kind": "boss", "room": "ember_warden", "text": "Defeat the Ember Warden", "prompt": ""},
{"kind": "reach_room", "room": "ember_portal", "text": "Fly into the portal", "prompt": "move"},
]},
]
var index: int = 0 # current lesson (== LESSONS.size() once the track is finished)
var step: int = 0 # current step within the lesson
var progress: int = 0 # hits/kills counted on the current step
var _intro_read := false # the current lesson's intro beat has been read (refreshed each tick)
var _started := false
var _done_timer := 0.0 # > 0 while a finished lesson holds its tick
var _survive_t := 0.0
var _spawn_t := 0.0
var _spawn_seq := 0
var _enemy_was_close := false
static func lesson_index(id: String) -> int:
for i in LESSONS.size():
if String(LESSONS[i]["id"]) == id:
return i
return -1
static func owner_of(feature: String) -> int:
for i in LESSONS.size():
if (LESSONS[i]["unlocks"] as Array).has(feature):
return i
return -1
static func all_features() -> Array[String]:
var out: Array[String] = []
for l in LESSONS:
for f in l["unlocks"]:
out.append(String(f))
return out
func is_unlocked(feature: String) -> bool:
var owner := owner_of(feature)
if owner < 0:
return true
return index > owner or (index == owner and _intro_read)
# The current step is live: its lesson's intro has been read and the lesson is not in its done hold.
func is_active() -> bool:
return index < LESSONS.size() and _intro_read and _done_timer <= 0.0
func current_lesson() -> Dictionary:
return LESSONS[index] if index < LESSONS.size() else {}
func current_step() -> Dictionary:
var l := current_lesson()
if l.is_empty():
return {}
var steps: Array = l["steps"]
return steps[mini(step, steps.size() - 1)]
func current_step_kind() -> String:
return String(current_step().get("kind", "")) if is_active() else ""
# The level-up panel opens only on a panel step (lessons 3, 4, 8), then freely from lesson 9 on.
# Anything earned in between stays banked in pending_levelups (the spec's surplus hold-back).
func levelup_allowed() -> bool:
if index >= FREE_LEVELUPS_FROM:
return true
return is_active() and bool(current_step().get("panel", false))
func crystal_grants_allowed() -> bool:
return index >= FREE_LEVELUPS_FROM
func forced_offer() -> String:
return CRYSTAL_CARD if current_step_kind() == "pick_crystal" else ""
func pickup_allowed(wid: String) -> bool:
if not LESSON_WEAPONS.has(wid):
return true # e.g. the optional secret-room Orbit
var st := current_step()
return current_step_kind() == "pickup" and String(st.get("weapon", "")) == wid
func npc_meetable(nid: String) -> bool:
if not OWNED_NPCS.has(nid):
return true
return current_step_kind() == "meet" and String(current_step().get("npc", "")) == nid
func owns_room(room_id: String) -> bool:
return OWNED_ROOMS.has(room_id)
func owns_npc(nid: String) -> bool:
return OWNED_NPCS.has(nid)
# Jump straight to a lesson (tests, the capture tool, and dev use). Carries everything the earlier
# lessons would have done forward: doors, met NPCs, picked-up weapons. read=false models "the intro
# has just been said": it is marked seen + said, so it is read on the next tick.
func skip_to(sim: Sim, lesson_id: String, read := true, step_i := 0, place := false) -> void:
var li := lesson_index(lesson_id)
assert(li >= 0, "unknown lesson %s" % lesson_id)
_started = true
for g in TUTORIAL_GATES:
sim.story.seal_gate(g)
for k in range(li + 1):
var g := String(LESSONS[k].get("open_gate", ""))
if g != "":
sim.story.open_gate(g)
for k in range(li + 1):
var steps: Array = LESSONS[k]["steps"]
var upto := steps.size() if k < li else step_i
for s in range(upto):
var st: Dictionary = steps[s]
match String(st["kind"]):
"meet":
sim.story.talked_npcs[String(st["npc"])] = true
"pickup":
sim.upgrade_system.grant_weapon(sim, sim.player, String(st["weapon"]))
index = li
step = step_i
progress = 0
_done_timer = 0.0
_survive_t = 0.0
_spawn_t = 0.0
var intro := String(LESSONS[li]["intro"])
sim.story.mark_dialogue_seen(intro)
if read:
sim.story.mark_read(intro)
_intro_read = true
_on_unlocked(sim, LESSONS[li])
else:
sim.story.note_said(intro)
_intro_read = false
if place:
var r: Rect2 = sim.story.data.room_by_id(String(LESSONS[li]["room"])).get("rect", Rect2())
if r.size != Vector2.ZERO:
sim.player.pos = r.get_center()
# Effects that happen the moment a lesson's intro has been read.
func _on_unlocked(sim: Sim, lesson: Dictionary) -> void:
if (lesson["unlocks"] as Array).has(F_DECOY):
sim.player.drone_charge = 1.0 # spec §1 lesson 7: the meter starts full for the lesson
  • Step 5: Import and run. godot --headless --path . --import, then both -gselect runs above: PASS. Then bash scripts/check-test-count.sh: test-count guard OK.
  • Step 6: Commit feat(sim): tutorial lesson table, unlock queries and read-tracked beats.

Task 2: Tutorial copy and speaker identity in data/story.json

Section titled “Task 2: Tutorial copy and speaker identity in data/story.json”

Files:

  • Modify: data/story.json (speakers lines 26-66; dialogue beats listed below), sim/story_director.gd (_say event dict)
  • Test: create tests/test_tutorial_copy.gd; modify tests/test_story_say_id.gd

Interfaces:

  • Consumes: TutorialLessons.LESSONS (intro ids, step text).
  • Produces: speakers carry role: String, emblem: String (diamond|flame|ring|crown), side: String (left|right); every say event carries role, emblem, side. New beats tut_levelup, tut_stats, tut_riftbolt, tut_warp, tut_decoy, tut_crystals, tut_warden (all speaker: echo, tutorial: true).

Old tutorial-only beats that the lesson track no longer uses stay in the file until Task 5 deletes them (their code references go in Tasks 3 and 5).

  • Step 1: Write the failing tests. tests/test_tutorial_copy.gd:
extends GutTest
# Tutorial copy rules (spec §3, §5): dialogue is story only (1-2 short lines), instructions live in the
# objective panel, every speaker has a name + role + drawn emblem, allies left and enemies right,
# every villain's first line says who they are, and no em dashes anywhere.
const EM_DASH := "—"
func _d() -> StoryData:
return StoryLoader.load_from_path("res://data/story.json")
func test_speakers_have_role_emblem_and_side() -> void:
var s := _d().speakers
var want := {
"echo": ["your guide", "diamond", "left"],
"ash": ["Ember raider", "flame", "right"],
"null_voice": ["the dark", "ring", "right"],
"warden_voice": ["boss", "crown", "right"],
}
for id in want:
assert_eq(String(s[id].get("role", "")), want[id][0], "%s role" % id)
assert_eq(String(s[id].get("emblem", "")), want[id][1], "%s emblem" % id)
assert_eq(String(s[id].get("side", "")), want[id][2], "%s side" % id)
for id in s:
assert_true(String(s[id].get("emblem", "")) in SpeakerEmblem.KINDS, "%s has a drawable emblem" % id)
assert_true(String(s[id].get("side", "")) in ["left", "right"], "%s has a side" % id)
func test_lesson_intros_exist_and_are_short_story_lines() -> void:
var d := _d()
for l in TutorialLessons.LESSONS:
var id := String(l["intro"])
assert_true(d.dialogue.has(id), "intro %s exists" % id)
var dl: Dictionary = d.dialogue[id]
assert_eq(String(dl["speaker"]), "echo", "%s is Echo's" % id)
assert_true(bool(dl.get("tutorial", false)), "%s is tutorial-only" % id)
var lines: Array = dl["lines"]
assert_between(lines.size(), 1, 2, "%s has 1-2 lines" % id)
for ln in lines:
assert_false(String(ln).contains("{"), "%s carries no control token: instructions live in the objective panel" % id)
assert_lt(String(ln).length(), 90, "%s line is short: %s" % [id, ln])
func test_no_em_dashes_in_any_line_the_tutorial_can_show() -> void:
var d := _d()
var ids: Array = ["tutorial_complete", "ember_secret_found", "ash_taunt", "skirm_taunt", "null_2", "warden_intro"]
for l in TutorialLessons.LESSONS:
ids.append(String(l["intro"]))
for id in ids:
for ln in d.dialogue[id]["lines"]:
assert_false(String(ln).contains(EM_DASH), "%s: %s" % [id, ln])
func test_every_villain_introduces_themselves_first() -> void:
var d := _d()
assert_true(String(d.dialogue["ash_taunt"]["lines"][0]).contains("Ash"))
assert_true(String(d.dialogue["null_2"]["lines"][0]).contains("the Null"))
assert_true(String(d.dialogue["warden_intro"]["lines"][0]).contains("Ember Warden"))
func test_the_four_tutorial_speakers_look_different() -> void:
var s := _d().speakers
var seen := {}
for id in ["echo", "ash", "null_voice", "warden_voice"]:
var e := String(s[id]["emblem"])
assert_false(seen.has(e), "emblem %s is unique" % e)
seen[e] = true

Append to tests/test_story_say_id.gd:

func test_say_event_carries_speaker_role_emblem_and_side() -> void:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"))
sim.story_director.say(sim, "warden_intro")
var ev: Dictionary = sim.story.events.back()
assert_eq(String(ev["role"]), "boss")
assert_eq(String(ev["emblem"]), "crown")
assert_eq(String(ev["side"]), "right")

Note: SpeakerEmblem.KINDS is created in Task 7. To keep this task self-contained, create the file now with just the constant (Task 7 fills in the drawing):

class_name SpeakerEmblem
extends Control
# Drawn speaker emblem for the dialogue box (spec §3). Task 7 adds the drawing.
const KINDS := ["diamond", "flame", "ring", "crown"]
  • Step 2: Run -gselect=test_tutorial_copy and -gselect=test_story_say_id (after --import): FAIL (fields and beats missing).

  • Step 3: Edit data/story.json speakers to exactly:

{"id": "echo", "name": "Echo", "color": "#ffd27f", "role": "your guide", "emblem": "diamond", "side": "left"},
{"id": "null_voice", "name": "The Null", "color": "#b06cff", "role": "the dark", "emblem": "ring", "side": "right"},
{"id": "ash", "name": "Ash", "color": "#ffab40", "role": "Ember raider", "emblem": "flame", "side": "right"},
{"id": "warden_voice", "name": "Ember Warden", "color": "#ff3b4e", "role": "boss", "emblem": "crown", "side": "right"},
{"id": "rime", "name": "Rime", "color": "#7fe0ff", "role": "Hush survivor", "emblem": "diamond", "side": "left"},
{"id": "hush_warden_voice", "name": "Hush Warden", "color": "#9fcfff", "role": "boss", "emblem": "crown", "side": "right"},
{"id": "vex", "name": "Vex", "color": "#ffe24d", "role": "rival pilot", "emblem": "flame", "side": "left"},
{"id": "lw_warden_voice", "name": "Live Wire Warden", "color": "#c8a0ff", "role": "boss", "emblem": "crown", "side": "right"}

Decision: Ash moves from #ff7a3c to #ffab40 (amber) and the Warden from #ff4d4d to #ff3b4e (crimson) so their tints separate as well as their emblems. Story-mode-only speakers get metadata too so ordinary Story mode never renders a missing emblem.

  • Step 4: Rewrite the beats. Replace lines (keep every other field) and add the new beats. Full copy (speaker, role in brackets):
id speaker lines
move_intro (L1 intro) Echo (your guide) “You’re awake. Good. I’m Echo.” / “Come to me. I have something for you.”
ember_intro (L2 intro) Echo “The dark is eating the Spectrum, from the top down.” / “I dropped you a Blaster. It aims and fires at the nearest enemy by itself.”
tut_levelup (L3, new) Echo “Every enemy you destroy gives you XP.” / “Earn enough and you choose an upgrade.”
tut_stats (L4, new) Echo “Upgrades add up fast.” / “Ship stats show exactly what each card will change.”
tut_riftbolt (L5, new) Echo “Your ship has a second gun: the Rift Bolt.” / “It fires where you point it, and faster than the Blaster.”
tut_warp (L6, new) Echo “Chargers rush straight at you and hit hard.” / “Warp jumps you clear. Nothing can hurt you mid-jump.”
tut_decoy (L7, new) Echo “I’ve charged your decoy drone.” / “Most enemies chase it instead of you, for eight seconds.”
tut_crystals (L8, new) Echo “Some upgrades give crystals.” / “Crystals are never spent. Collect enough and a weapon upgrades itself.”
echo_gift (L9 intro) Echo “Come to the next room. I have one more gift.” / “It’s called Nova. It hits everything close to you every two seconds.”
tut_warden (L10, new) Echo “The Ember Warden guards the way out of the Reach.” / “Get past its raiders, then take it down.”
ash_taunt Ash (Ember raider) “I’m Ash. This Reach is mine to raid.” / “You don’t belong this high, spark.”
skirm_taunt Ash “Faster ones now. Keep up.”
null_2 The Null (the dark) “I am the Null. I swallow light.” / “The Reach will wear you down for me.”
warden_intro Ember Warden (boss) “I am the Ember Warden.” / “Your climb ends at my gate.”
tutorial_complete Echo “The Warden is down. You’ve learned everything I can teach.” / “Out there the swarm never stops. Survive as long as you can.”
ember_secret_found Echo “A hidden room. Well found.” / “These shards circle you and cut through anything they touch.”

New beats are {"id": ..., "speaker": "echo", "tutorial": true, "lines": [...]}. Every claim was checked against the sim: Blaster fires at the nearest enemy (weapon_blaster.gd header); kill XP is banked straight to you (AUTO_COLLECT_XP, sim.gd:219, _award_xp 2409-2418), so no line tells you to collect anything; the level-up StatsSheet previews the focused card (level_up_panel.gd:4-5); Rift Bolt cooldown 0.24 s vs Blaster 0.45 s (weapon_aim.gd:9, bible); dash i-frames 0.18 s outlast the 0.16 s burst (sim.gd:120-121); walkers retarget the nearest drone (enemy_behaviors.gd:489) and a basic decoy lives DECOY_LIFE 8 s × life 1.0 (drone_director.gd:27,39); thresholds never decrement crystals (sim.gd:929-931); Nova cooldown 2 s, area 180 (bible); Orbit shards orbit and damage (weapon_orbit.gd).

  • Step 5: Add fields to the say event in StoryDirector._say (the dict built in Task 1):
"role": spk.get("role", ""),
"emblem": spk.get("emblem", ""),
"side": spk.get("side", "left"),
  • Step 6: Run both selects: PASS; -gselect=test_story_loader: PASS (it still lists the old beats, which still exist). Gate: test-count guard OK.
  • Step 7: Commit content(story): tutorial lesson copy and speaker roles, emblems, sides.

Task 3: The lesson engine and StoryDirector integration

Section titled “Task 3: The lesson engine and StoryDirector integration”

Files:

  • Modify: sim/tutorial_lessons.gd (add engine), sim/story_director.gd (update 35-45, delete _check_tutorial_teaching 86-137 and _deferred_levelups/_idle_*/MOVE_NUDGE_AFTER/IDLE_EPSILON_SQ, _check_npcs 149-170, _enter_room 180-210)
  • Test: create tests/test_tutorial_lesson_flow.gd; delete superseded tests (listed in Step 5)

Interfaces:

  • Consumes: StoryState.read_dialogue, talked_npcs, current_room, is_cleared, seal_gate/open_gate; StoryDirector.say; Sim.spawn_story_enemy(type, pos, power); Sim._ENEMY_NAME_TO_TID; DroneDirector.pilot_has_drone(sim, pilot); Sim.DASH_TIME.

  • Produces: TutorialLessons.refresh(sim), update(sim, dt), complete_step(sim), spawn_point(sim) -> Vector2, static enemy_within(sim, r) -> bool, objective_view(sim) -> Dictionary, panel_options() -> Dictionary, target_pos(sim) -> Variant; lesson events {"kind": "lesson", "event": "start"|"step"|"done", "index": int, "id": String} in sim.story.events.

  • Step 1: Write the failing tests tests/test_tutorial_lesson_flow.gd:

extends GutTest
# The lesson track driven through REAL sim paths on the real Ember Reach data (spec §1).
func _tut() -> Sim:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true)
sim.player.dev_invuln = true
return sim
func _ticks(sim: Sim, n: int, input: InputState = null) -> void:
for _i in n:
sim.tick_single(input if input != null else InputState.new())
func _lesson_events(sim: Sim, ev: String) -> int:
var n := 0
for e in sim.story.events:
if String(e.get("kind", "")) == "lesson" and String(e.get("event", "")) == ev:
n += 1
return n
func _count_type(sim: Sim, tid: int) -> int:
var n := 0
for i in sim.enemies.count:
if sim.enemies.type_id[i] == tid:
n += 1
return n
func test_run_opens_on_lesson_one_with_its_intro_and_sealed_doors() -> void:
var sim := _tut()
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.index, 0)
assert_true(sim.story.seen_dialogue.has("move_intro"), "lesson 1's intro is said")
assert_eq(_lesson_events(sim, "start"), 1)
for g in TutorialLessons.TUTORIAL_GATES:
assert_true(sim.story.is_gate_sealed(g), "%s sealed until its lesson" % g)
func test_echo_is_not_met_until_her_lesson_is_read() -> void:
var sim := _tut()
sim.player.pos = Vector2(0, -310) # inside Echo's 130px talk radius, outside the 50px pickup reach
sim.tick_single(InputState.new())
assert_false(sim.story.talked_npcs.has("echo_intro"), "intro said this tick, not read")
sim.tick_single(InputState.new())
assert_true(sim.story.talked_npcs.has("echo_intro"), "read: she can be met")
func test_meeting_echo_ends_lesson_one_then_holds_before_lesson_two() -> void:
var sim := _tut()
_ticks(sim, 2)
sim.player.pos = Vector2(0, -310) # talk radius, but not standing on the Blaster drop at (0, -410)
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.index, 0, "still lesson 1 during its done beat")
assert_true(sim.tutorial.objective_view(sim)["done"], "the objective shows its tick")
assert_false(sim.story.seen_dialogue.has("ember_intro"), "Echo's own NPC line is owned by the lesson")
_ticks(sim, int(TutorialLessons.DONE_HOLD * 60.0) + 1)
assert_eq(sim.tutorial.index, 1)
assert_true(sim.story.seen_dialogue.has("ember_intro"), "lesson 2's intro is said when it begins")
assert_eq(sim.weapon_pickups.size(), 1, "the Blaster lies where Echo stands")
func test_owned_rooms_run_no_room_beat_or_encounter() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 1, true) # standing in ember_a1
sim.tick_single(InputState.new())
assert_eq(sim.story_director._active_encounter, "", "ember_e1 does not run in the tutorial")
assert_false(sim.story.seen_dialogue.has("echo_xp"), "ember_a1's room beat is owned by the lesson")
func test_story_mode_still_runs_the_first_arena() -> void:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), true, false, false)
sim.tick_single(InputState.new())
sim.player.pos = (sim.story.data.room_by_id("ember_a1")["rect"] as Rect2).get_center()
sim.tick_single(InputState.new())
assert_eq(sim.story_director._active_encounter, "ember_e1", "ordinary Story mode is untouched")
func test_echo_gift_waits_for_the_nova_lesson() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 1)
sim.player.pos = Vector2(0, -2010) # Echo's safe-room spot
_ticks(sim, 2)
assert_false(sim.story.talked_npcs.has("echo_gift"), "Nova is not handed out early")
func test_kill_step_keeps_exactly_its_scripted_enemies_topped_up() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "blaster", true, 1) # "Destroy 5 enemies", swarmers one at a time
var max_seen := 0
for _i in 300:
sim.tick_single(InputState.new())
max_seen = maxi(max_seen, _count_type(sim, EnemyPool.TYPE_SWARMER))
assert_eq(max_seen, 1, "never more than one at a time")
assert_gt(sim.kills, 0, "and they keep coming as the Blaster clears them")
func test_spawns_never_land_on_the_player() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "decoy", true, 1, true)
for _i in 8:
var p := sim.tutorial.spawn_point(sim)
assert_gte(p.distance_to(sim.player.pos), TutorialLessons.SPAWN_MIN_DIST - 0.01)
sim.tutorial._spawn_seq += 1
func test_reach_room_step_completes_on_entering_the_room() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "warp", true, 0)
sim.player.pos = (sim.story.data.room_by_id("ember_a2")["rect"] as Rect2).get_center()
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.step, 1, "on to the warp itself")
func test_level_step_completes_the_tick_after_a_level_is_banked() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 1)
sim.player.xp = sim.player.xp_to_next
sim.tick_single(InputState.new()) # banked at the END of this tick
assert_eq(sim.tutorial.step, 1, "the director read pilot state before the bank")
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.step, 2, "next tick it sees the level and moves to the pick")
func test_warp_counts_only_with_an_enemy_close() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "warp", true, 1)
sim.tutorial._spawn_t = 999.0 # hold the scripted Charger back so the room stays empty
sim.tick_single(InputState.new())
sim.tick_single(InputState.new(Vector2.ZERO, Vector2.ZERO, false, true))
assert_eq(sim.tutorial.step, 1, "a warp in an empty room teaches nothing")
_ticks(sim, 120) # cooldown
sim.spawn_story_enemy("elite", sim.player.pos + Vector2(120, 0))
sim.tick_single(InputState.new()) # the track notes the Charger is close
sim.tick_single(InputState.new(Vector2.ZERO, Vector2.ZERO, false, true))
assert_gt(sim.tutorial._done_timer, 0.0, "warping away from a close Charger completes the lesson")
func test_decoy_lesson_starts_full_then_needs_a_deploy_and_ten_seconds() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "decoy", true)
assert_eq(sim.player.drone_charge, 1.0, "the meter starts full")
sim.tick_single(InputState.new(Vector2.ZERO, Vector2.ZERO, true, false))
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.step, 1, "decoy out, now survive")
_ticks(sim, 10 * 60 + 2)
assert_gt(sim.tutorial._done_timer, 0.0, "ten seconds survived")
func test_boss_step_completes_when_the_warden_room_clears() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "warden", true, 1)
sim.story.mark_cleared("ember_warden")
sim.tick_single(InputState.new())
assert_eq(sim.tutorial.step, 2, "then fly into the portal")
func test_objective_view_counts_and_hides_before_the_intro_is_read() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "riftbolt", false)
assert_false(sim.tutorial.objective_view(sim)["visible"], "hidden while the intro is on screen")
sim.tutorial.skip_to(sim, "riftbolt", true)
sim.tutorial.progress = 2
var v: Dictionary = sim.tutorial.objective_view(sim)
assert_eq([v["lesson"], v["total"], v["title"], v["text"], v["prompt"], v["progress"], v["count"]],
[5, 10, "RIFT BOLT", "Hit 3 enemies with the Rift Bolt", "aim", 2, 3])
  • Step 2: Run -gselect=test_tutorial_lesson_flow: FAIL.

  • Step 3: Add the engine to sim/tutorial_lessons.gd:

# Called at the TOP of StoryDirector.update, before NPCs/rooms are checked, so a beat read this tick
# gates everything else this tick consistently.
func refresh(sim: Sim) -> void:
if not _started or index >= LESSONS.size() or _intro_read:
return
if sim.story.read_dialogue.has(String(LESSONS[index]["intro"])):
_intro_read = true
_on_unlocked(sim, LESSONS[index])
_emit(sim, "unlocked")
# Called at the END of StoryDirector.update (after rooms, NPCs and encounters have run this tick).
func update(sim: Sim, dt: float) -> void:
if not _started:
_started = true
for g in TUTORIAL_GATES:
sim.story.seal_gate(g)
_begin_lesson(sim)
return
if index >= LESSONS.size():
return
if _done_timer > 0.0:
_done_timer -= dt
if _done_timer <= 0.0:
index += 1
if index < LESSONS.size():
_begin_lesson(sim)
return
if not _intro_read:
return
var st := current_step()
_spawn_for(sim, st, dt)
if _step_complete(sim, st, dt):
complete_step(sim)
_enemy_was_close = enemy_within(sim, WARP_CLOSE_RADIUS)
func _begin_lesson(sim: Sim) -> void:
var lesson: Dictionary = LESSONS[index]
step = 0
progress = 0
_survive_t = 0.0
_spawn_t = 0.0
_intro_read = false
var g := String(lesson.get("open_gate", ""))
if g != "":
sim.story.open_gate(g)
_emit(sim, "start")
sim.story_director.say(sim, String(lesson["intro"]))
# Advance past the current step. Hooks (picks, UI notes) call this synchronously so the level-up
# chain in main.gd sees the new state before it decides whether to open another panel.
func complete_step(sim: Sim) -> void:
if not is_active():
return
_emit(sim, "step")
var steps: Array = LESSONS[index]["steps"]
if step + 1 >= steps.size():
_done_timer = DONE_HOLD # keep showing the last step, ticked
_emit(sim, "done")
return
step += 1
progress = 0
_survive_t = 0.0
_spawn_t = 0.0
func _step_complete(sim: Sim, st: Dictionary, dt: float) -> bool:
match String(st.get("kind", "")):
"meet":
return sim.story.talked_npcs.has(String(st["npc"]))
"pickup":
return sim.player.arsenal.active_weapon_ids.has(String(st["weapon"]))
"reach_room":
return sim.story.current_room == String(st["room"])
"level":
return sim.player.pending_levelups > 0
"warp":
# _update_dash runs before the story director in Sim.tick, so on the tick a warp starts
# dash_timer is exactly DASH_TIME here. "Close" is judged from LAST tick's position, i.e.
# before the warp carried the player away.
return sim.player.dash_timer >= Sim.DASH_TIME - 0.0001 and _enemy_was_close
"decoy":
return sim.drone_director.pilot_has_drone(sim, sim.player)
"survive":
_survive_t += dt
return _survive_t >= float(st.get("seconds", 10))
"boss":
return sim.story.is_cleared(String(st["room"]))
"kills", "hits":
return progress >= int(st.get("count", 1))
return false # pick / pick_crystal / sheet complete through their hooks
# Keep `alive` of the step's enemy type in the player's current room, one every SPAWN_GAP.
func _spawn_for(sim: Sim, st: Dictionary, dt: float) -> void:
var sp: Dictionary = st.get("spawn", {})
if sp.is_empty():
return
_spawn_t -= dt
if _spawn_t > 0.0:
return
var type_name := String(sp["type"])
var tid: int = Sim._ENEMY_NAME_TO_TID.get(type_name, EnemyPool.TYPE_SWARMER)
var alive := 0
for i in sim.enemies.count:
if sim.enemies.type_id[i] == tid:
alive += 1
if alive >= int(sp.get("alive", 1)):
return
_spawn_t = SPAWN_GAP
sim.spawn_story_enemy(type_name, spawn_point(sim), 1.0)
_spawn_seq += 1
# Deterministic ring point in the player's current room (no RNG), flipped across the centre if it
# would land within SPAWN_MIN_DIST of the player.
func spawn_point(sim: Sim) -> Vector2:
var rect: Rect2 = sim.story.data.room_by_id(sim.story.current_room).get("rect", Rect2())
if rect.size == Vector2.ZERO:
return sim.player.pos + Vector2(0.0, -SPAWN_MIN_DIST)
var c := rect.get_center()
var r := maxf(minf(rect.size.x, rect.size.y) * 0.5 - 90.0, 40.0)
var dir := Vector2.from_angle(float(_spawn_seq) * GOLDEN_ANGLE)
var p := c + dir * r
if p.distance_to(sim.player.pos) < SPAWN_MIN_DIST:
p = c - dir * r
if p.distance_to(sim.player.pos) < SPAWN_MIN_DIST:
p = sim.player.pos + (p - sim.player.pos).normalized() * SPAWN_MIN_DIST if p != sim.player.pos \
else sim.player.pos + dir * SPAWN_MIN_DIST
return p
static func enemy_within(sim: Sim, r: float) -> bool:
var r2 := r * r
for i in sim.enemies.count:
if sim.enemies.pos[i].distance_squared_to(sim.player.pos) <= r2:
return true
return false
func _emit(sim: Sim, ev: String) -> void:
sim.story.events.append({"kind": "lesson", "event": ev, "index": index,
"id": String(LESSONS[index]["id"]) if index < LESSONS.size() else "", "step": step})
# What the objective panel shows. Hidden until the intro has been read.
func objective_view(sim: Sim) -> Dictionary:
if index >= LESSONS.size() or not (_intro_read or _done_timer > 0.0):
return {"visible": false}
var lesson: Dictionary = LESSONS[index]
var st := current_step()
var count := int(st.get("count", 0))
var prog := progress
match String(st.get("kind", "")):
"level":
prog = int(floor(sim.player.xp))
count = int(ceil(sim.player.xp_to_next))
"survive":
count = int(st.get("seconds", 10))
prog = mini(int(floor(_survive_t)), count)
return {"visible": true, "lesson": index + 1, "total": LESSONS.size(), "title": String(lesson["title"]),
"text": String(st.get("text", "")), "prompt": String(st.get("prompt", "")),
"progress": prog, "count": count, "done": _done_timer > 0.0}
# Options for the level-up panel while it is open in the tutorial (Task 9 consumes these).
func panel_options() -> Dictionary:
var lid := String(current_lesson().get("id", ""))
var st := current_step()
var note := TAG_NOTE_LEVELUP if lid == "levelup" else (TAG_NOTE_CRYSTALS if lid == "crystals" else "")
return {"tag_note": note, "stats_enabled": is_unlocked(F_STATS),
"sheet_first": current_step_kind() == "sheet" and bool(st.get("panel", false))}
# Where the auto-player should fly for a step that has a place (meet, pickup). null = no place.
func target_pos(sim: Sim) -> Variant:
var st := current_step()
match current_step_kind():
"meet":
for n in sim.story.data.npcs:
if String(n["id"]) == String(st["npc"]):
return n["pos"]
"pickup":
for pk in sim.weapon_pickups:
if String(pk["weapon"]) == String(st["weapon"]):
return pk["pos"]
return null
  • Step 4: Integrate in sim/story_director.gd.
    • update() becomes:
func update(sim: Sim, dt: float) -> void:
if not _started:
_started = true
_last_room = sim.story.current_room
_enter_room(sim, _last_room) # fire the start-room entry (dialogue / encounter)
if sim.tutorial != null:
sim.tutorial.refresh(sim) # a beat read this tick gates everything below consistently
_track_room(sim)
_check_npcs(sim)
_drive_encounter(sim, dt)
if sim.tutorial == null:
_check_teaching(sim) # the tutorial's Warden tips are render-side now (objective panel)
_spawn_boss_adds(sim, dt)
if sim.tutorial != null:
sim.tutorial.update(sim, dt)
  • Delete _check_tutorial_teaching (lines 86-137), _enemy_within (139-145, now TutorialLessons.enemy_within), and the now-unused _deferred_levelups, _idle_t, _idle_pos, WARP_TEACH_RADIUS, MOVE_NUDGE_AFTER, IDLE_EPSILON_SQ. Keep TEACH_LOW_FRAC (Task 10’s heal tip reads it).

  • _check_npcs: after the talked_npcs check add if sim.tutorial != null and not sim.tutorial.npc_meetable(nid): continue; wrap the dialogue call so an owned NPC stays silent (its lesson intro is the line): if npc.get("dialogue") != null and String(npc["dialogue"]) != "" and not (sim.tutorial != null and sim.tutorial.owns_npc(nid)):.

  • _enter_room: compute var owned := sim.tutorial != null and sim.tutorial.owns_room(room_id) at the top; change the on_enter branch to elif not owned and room.has("on_enter") and room["on_enter"] != null:; add and not owned to the encounter-start condition.

  • Step 5: Remove the tests of deleted behaviour (each is superseded by a test above or in Task 4):

    • tests/test_story_director.gd: test_hp_intro_fires_on_first_damage_in_tutorial, test_hp_intro_only_fires_once, test_radar_intro_fires_when_enemies_present_in_tutorial, test_hp_and_radar_intro_never_fire_outside_tutorial_cap, test_levelup_intro_fires_on_first_pending_levelup_in_tutorial, test_crystal_intro_fires_on_first_crystal_grant_in_tutorial (and any helper used only by them).
    • tests/test_tutorial_progression.gd: test_first_levelup_shows_one_panel_but_keeps_the_surplus, test_aim_intro_waits_for_the_first_kill, test_move_nudge_backstops_a_player_who_never_moves.
    • tests/test_main.gd: test_levelup_panel_stays_closed_while_levelup_intro_dialogue_is_still_showing (its replacement is in Task 5).
    • Grep for any other test that relied on those beats firing: /usr/bin/grep -n "hp_intro\|radar_intro\|levelup_intro\|crystal_intro\|move_nudge" tests/*.gd and fix each by reading it.
  • Step 6: Run -gselect=test_tutorial_lesson_flow, test_story_director, test_tutorial_progression, test_story_npc: PASS. Gate: test-count guard OK.

  • Step 7: Commit feat(sim): tutorial lesson engine drives Ember Reach.


Task 4: Sim hooks: attribution, picks, UI notes, offers, pickups, thresholds

Section titled “Task 4: Sim hooks: attribution, picks, UI notes, offers, pickups, thresholds”

Files:

  • Modify: sim/tutorial_lessons.gd, sim/elemental_system.gd (damage_enemy, 170-215), sim/upgrade_system.gd (survival branch 118-146, apply_upgrade 263-283), sim/sim.gd (_eval_thresholds 936, _collect_weapon_pickups 1210)
  • Test: create tests/test_tutorial_lesson_hooks.gd

Interfaces:

  • Consumes: Sim.damage_context (weapon id / "aim" / "drone", stamped per shot by ProjPool.context), UpgradeSystem.upgrade_effects, LevelUpPanel.why_tag (test only).

  • Produces: TutorialLessons.on_enemy_hit(context: String, hp_before: float, hp_after: float) -> void, on_upgrade_applied(sim: Sim, id: String) -> void, note_ui(sim: Sim, event: String) -> void (event "stats_sheet").

  • Step 1: Write the failing tests tests/test_tutorial_lesson_hooks.gd:

extends GutTest
func _tut() -> Sim:
var sim := Sim.new(1234, ContentLoader.load_from_path("res://data/bible.json"))
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true)
sim.player.dev_invuln = true
return sim
func _hit(sim: Sim, context: String, amount: float) -> void:
sim.damage_context = context
sim.elemental_system.damage_enemy(sim, 0, amount)
sim.damage_context = "unattributed"
# ── Hit / kill attribution (Sim.damage_context through the real damage choke point) ──────────
func test_blaster_killing_blows_count_for_the_blaster_lesson() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "blaster", true, 1)
sim.spawn_story_enemy("swarmer", sim.player.pos + Vector2(300, 0))
_hit(sim, "blaster", 1.0)
assert_eq(sim.tutorial.progress, 0, "a hit that does not kill is not a kill")
_hit(sim, "blaster", 999.0)
assert_eq(sim.tutorial.progress, 1)
_hit(sim, "blaster", 999.0)
assert_eq(sim.tutorial.progress, 1, "overkill on a dead enemy does not count twice")
func test_other_weapons_do_not_count() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "nova", true, 3)
sim.spawn_story_enemy("spider", sim.player.pos + Vector2(300, 0))
_hit(sim, "blaster", 999.0)
assert_eq(sim.tutorial.progress, 0, "a Blaster kill is not a Nova kill")
func test_rift_bolt_hits_count_through_real_fire() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "riftbolt", true)
sim.spawn_story_enemy("tank", sim.player.pos + Vector2(140, 0)) # 13 HP: survives a bolt
for _i in 20:
sim.tick_single(InputState.new(Vector2.ZERO, Vector2(1, 0)))
assert_gt(sim.tutorial.progress, 0, "aiming at it with the Rift Bolt registers hits")
# ── Picks and UI notes ───────────────────────────────────────────────────────────────────────
func test_any_pick_completes_a_pick_step_at_once() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 2)
sim.player.pending_levelups = 2
sim.upgrade_system.apply_upgrade(sim, sim.player, "armor")
assert_gt(sim.tutorial._done_timer, 0.0, "picked: lesson done")
assert_false(sim.tutorial.levelup_allowed(), "so main will not chain the banked surplus level")
assert_eq(sim.player.pending_levelups, 1, "which stays banked, not lost")
func test_only_a_crystal_pick_completes_the_crystal_step() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "crystals", true, 1)
sim.player.pending_levelups = 2
sim.upgrade_system.apply_upgrade(sim, sim.player, "armor")
assert_eq(sim.tutorial.step, 1, "a stat card does not teach crystals")
sim.upgrade_system.apply_upgrade(sim, sim.player, TutorialLessons.CRYSTAL_CARD)
assert_eq(sim.tutorial.step, 2, "the crystal card does")
func test_stats_sheet_note_completes_only_a_sheet_step() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "stats", true, 0)
sim.tutorial.note_ui(sim, "stats_sheet")
assert_eq(sim.tutorial.step, 0, "opening the sheet early does nothing")
sim.tutorial.skip_to(sim, "stats", true, 1)
sim.tutorial.note_ui(sim, "stats_sheet")
assert_eq(sim.tutorial.step, 2, "on the sheet step it completes it")
# ── Offers, crystals and thresholds ──────────────────────────────────────────────────────────
func test_no_crystal_cards_before_the_crystal_lesson() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "levelup", true, 2)
for _i in 20:
for id in sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3):
assert_false(String(id).begins_with("crystals:"), "no crystal card yet: %s" % id)
func test_crystal_lesson_guarantees_a_long_term_card_first() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "crystals", true, 1)
var ids := sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3)
assert_eq(ids[0], TutorialLessons.CRYSTAL_CARD)
var tag := LevelUpPanel.why_tag(sim.upgrade_system.upgrade_effects(sim, sim.player, ids[0]))
assert_eq(String(tag["text"]), "LONG-TERM")
func test_crystal_thresholds_wait_for_the_crystal_lesson() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "riftbolt", true)
sim.crystals.add("kinetic", 5)
sim._eval_thresholds()
assert_true(sim.player.arsenal.thresholds_done.is_empty(), "no auto-upgrade before lesson 8")
sim.tutorial.skip_to(sim, "crystals", true)
sim._eval_thresholds()
assert_false(sim.player.arsenal.thresholds_done.is_empty(), "then it works")
# ── Pickups ──────────────────────────────────────────────────────────────────────────────────
func test_lesson_weapons_are_picked_up_only_on_their_step() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "wake", true)
sim.drop_weapon_pickup("blaster", sim.player.pos)
sim.tick_single(InputState.new())
assert_false(sim.player.arsenal.active_weapon_ids.has("blaster"), "not during lesson 1")
sim.tutorial.skip_to(sim, "blaster", true, 0)
sim.tick_single(InputState.new())
assert_true(sim.player.arsenal.active_weapon_ids.has("blaster"), "picked up on its step")
func test_the_secret_orbit_is_always_collectable() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "warp", true, 1)
sim.drop_weapon_pickup("orbit", sim.player.pos)
sim.tick_single(InputState.new())
assert_true(sim.player.arsenal.active_weapon_ids.has("orbit"), "the optional secret stays optional, not gated")
  • Step 2: Run -gselect=test_tutorial_lesson_hooks: FAIL.
  • Step 3: Add hooks to sim/tutorial_lessons.gd:
# Every enemy hit, from ElementalSystem.damage_enemy (the one damage choke point). `context` is
# Sim.damage_context: the firing weapon's id, "aim" for the Rift Bolt, "drone" for drones.
func on_enemy_hit(context: String, hp_before: float, hp_after: float) -> void:
if not is_active() or hp_before <= 0.0:
return
var st := current_step()
if String(st.get("weapon", "")) != context:
return
match String(st.get("kind", "")):
"hits":
progress += 1
"kills":
if hp_after <= 0.0:
progress += 1
# Every applied level-up pick, from UpgradeSystem.apply_upgrade.
func on_upgrade_applied(sim: Sim, id: String) -> void:
var k := current_step_kind()
if k == "pick" or (k == "pick_crystal" and id.begins_with("crystals:")):
complete_step(sim)
# UI-only facts the sim cannot see. "stats_sheet": the player opened Ship stats (level-up Y or the
# pause menu). Like apply_upgrade this is a choice made while the sim is frozen; it is not in the
# replay stream (neither are picks), so capture/replay runs drive it themselves (main.gd, Task 10).
func note_ui(sim: Sim, event: String) -> void:
if event == "stats_sheet" and current_step_kind() == "sheet":
complete_step(sim)
  • Step 4: Wire the hooks.
    • sim/elemental_system.gd damage_enemy: immediately after the wyrm/non-wyrm if/else that writes sim.enemies.data (ends line 202), add:
if sim.tutorial != null:
sim.tutorial.on_enemy_hit(sim.damage_context, before, before - dealt)
  • sim/upgrade_system.gd apply_upgrade: after the final pilot.pending_levelups = maxi(...) add if sim.tutorial != null: sim.tutorial.on_upgrade_applied(sim, id) (two lines).
  • sim/upgrade_system.gd survival branch: replace the start of the if sim.ruleset == sim.RULESET_SURVIVAL: block through the crystal draws with:
if sim.ruleset == sim.RULESET_SURVIVAL:
var out: Array[String] = []
# Tutorial: crystal cards are locked until lesson 8, which guarantees one LONG-TERM card first.
# sim.tutorial is null everywhere else, so forced == "" and grants_ok == true: identical draws.
var forced := sim.tutorial.forced_offer() if sim.tutorial != null else ""
if forced != "":
out.append(forced)
var grants_ok := sim.tutorial == null or sim.tutorial.crystal_grants_allowed()
if grants_ok and sim.upgrade_rng.randf() < CRYSTAL_GRANT_CHANCE:
(the body of that `if` is unchanged) and change the trailing fill loop to `while grants_ok and out.size() < n:`.
  • sim/sim.gd _eval_thresholds: after the ruleset guard add
if tutorial_locks(TutorialLessons.F_CRYSTALS):
return # tutorial: crystal upgrades/evolutions are taught in lesson 8
  • sim/sim.gd _collect_weapon_pickups: first line inside the loop after var pk := ...:
if tutorial != null and not tutorial.pickup_allowed(String(pk["weapon"])):
i -= 1
continue # a lesson weapon waits on the ground for its own lesson step
  • Step 5: Run -gselect=test_tutorial_lesson_hooks, test_tutorial_lesson_flow, test_level_up_panel, test_story_npc: PASS. Determinism: -gselect=test_determinism_survival and -gselect=test_determinism_coop: PASS with no re-pin. Gate: test-count guard OK.
  • Step 6: Commit feat(sim): lesson objectives hear hits, picks and UI notes; tutorial offers gated.

Task 5: Every gate uses lesson features; beats gate on read

Section titled “Task 5: Every gate uses lesson features; beats gate on read”

Files:

  • Modify: sim/story_state.gd (tutorial_locked, 71-76), sim/sim.gd (915, 2169, 2291), sim/drone_director.gd (107, 438), ui/weapon_panel.gd (147), main.gd (547 _open_bay_direct; 1360 level-up gate; 1707 story label; 1721-1728 touch locks; 2159-2167 _stage_tutorial_hud; 2291-2298 _on_upgrade_chosen), tools/story_auto/story_trace.gd (84), data/story.json (delete obsolete beats + their references)
  • Test: modify tests/test_story_director.gd, tests/test_tutorial_progression.gd, tests/test_drones.gd, tests/test_weapon_panel.gd, tests/test_main.gd, tests/test_story_loader.gd

Interfaces:

  • Consumes: Sim.tutorial_locks, TutorialLessons.is_unlocked, levelup_allowed.

  • Produces: StoryState.tutorial_locked(id) = tutorial_cap and not read_dialogue.has(id); main._levelup_gate_open() -> bool.

  • Step 1: Write the failing test (append to tests/test_story_read_gating.gd):

func test_a_lesson_gate_opens_on_read_not_on_queue() -> void:
var sim := _tut()
sim.player.dev_invuln = true
sim.tutorial.skip_to(sim, "riftbolt", false) # intro just said, still on screen
assert_true(sim.tutorial_locks(TutorialLessons.F_AIM), "locked while the intro is unread")
sim.tick_single(InputState.new(Vector2.ZERO, Vector2(1, 0)))
assert_false(sim.tutorial_locks(TutorialLessons.F_AIM), "the next tick reads it")
var aim_shots := 0
for i in sim.projectiles.count:
if sim.projectiles.context[i] == "aim":
aim_shots += 1
assert_gt(aim_shots, 0, "and the Rift Bolt fires on that tick (counted by ProjPool.context, not the Blaster's shots)")
func test_tutorial_locked_is_read_based() -> void:
var sim := _tut()
sim.story.mark_dialogue_seen("ash_taunt")
assert_true(sim.story.tutorial_locked("ash_taunt"), "seen is not read")
sim.story.mark_read("ash_taunt")
assert_false(sim.story.tutorial_locked("ash_taunt"))

Migrate existing tests (rewrite each body exactly as below; keep names where the meaning survives):

  • tests/test_story_director.gd: test_tutorial_locked_false_once_seen → rename test_tutorial_locked_false_once_read, replace mark_dialogue_seen("some_beat") with mark_read("some_beat"). test_aim_weapon_inert_before_aim_intro_in_tutorial → test_aim_weapon_inert_before_the_rift_bolt_lesson (body unchanged except the message). test_aim_weapon_fires_once_aim_intro_seen → test_aim_weapon_fires_once_the_rift_bolt_lesson_is_read: replace sim.story.mark_dialogue_seen("aim_intro") with sim.tutorial.skip_to(sim, "riftbolt", true). test_dash_inert_before_warp_learn_in_tutorial → test_dash_inert_before_the_warp_lesson (body unchanged). test_dash_works_once_warp_learn_seen → test_dash_works_once_the_warp_lesson_is_read: replace the mark with sim.tutorial.skip_to(sim, "warp", true, 1).

  • tests/test_tutorial_progression.gd: test_drone_bay_deploy_respects_the_decoy_gate: precondition assert_true(sim.tutorial_locks(TutorialLessons.F_DECOY)); unlock with sim.tutorial.skip_to(sim, "decoy", true). test_dash_is_gated_on_the_beat_that_teaches_the_warp → test_dash_is_gated_on_the_warp_lesson: first mark becomes sim.tutorial.skip_to(sim, "riftbolt", true) (message “the Rift Bolt lesson alone does not unlock the warp”), second becomes sim.tutorial.skip_to(sim, "warp", true, 1).

  • tests/test_drones.gd (114-130): test_decoy_charge_frozen_before_echo_decoy_in_tutorial → ..._before_the_decoy_lesson (body unchanged). test_decoy_charges_once_echo_decoy_seen → test_decoy_charges_once_the_decoy_lesson_is_read: sim.tutorial.skip_to(sim, "decoy", true) then sim.player.drone_charge = 0.0 (the lesson fills it; this test is about accrual), then the 5 ticks and the same assert_gt.

  • tests/test_weapon_panel.gd (79-100): rename to ..._before_the_rift_bolt_lesson / ..._once_the_rift_bolt_lesson_is_read; the second replaces mark_dialogue_seen("aim_intro") with sim.tutorial.skip_to(sim, "riftbolt", true).

  • tests/test_main.gd: test_tutorial_docks_are_staged_by_their_own_teaching_beat → ..._by_their_own_lesson: replace mark_dialogue_seen("ember_intro") with m.sim.tutorial.skip_to(m.sim, "blaster", true) and mark_dialogue_seen("echo_decoy") with m.sim.tutorial.skip_to(m.sim, "decoy", true). test_levelup_panel_stays_closed_before_levelup_intro_in_tutorial → test_levelup_panel_stays_closed_outside_a_pick_step: after _on_mode_chosen, m.meta = null, m.sim.tutorial.skip_to(m.sim, "riftbolt", true), m.sim.player.pending_levelups = 1, one m._physics_process(1.0 / 60.0), assert_false(m._paused_for_levelup). test_levelup_panel_opens_once_levelup_intro_seen → test_levelup_panel_opens_on_the_pick_step: keep the first _physics_process + dialogue_box.clear(), then m.sim.tutorial.skip_to(m.sim, "levelup", true, 2) instead of the mark. test_capturing_flag_auto_dismisses_levelup_panel: same replacement for its mark.

  • tests/test_story_loader.gd test_tutorial_beats_and_dependent_content_are_present_in_real_story_json: replace the id list with every TutorialLessons.LESSONS intro id (loop over the table) and keep the echo_intro reward assertion.

  • Step 2: Run -gselect=test_story_read_gating: FAIL (locked checks still beat-based).

  • Step 3: Implement.

    • sim/story_state.gd: return tutorial_cap and not read_dialogue.has(id) and update the comment (“until the matching beat has been READ, i.e. the tick after it was said”).
    • sim/sim.gd:915: var decoy_locked: bool = tutorial_locks(TutorialLessons.F_DECOY). sim/sim.gd:2169: if tutorial_locks(TutorialLessons.F_WARP):. sim/sim.gd:2291: if tutorial_locks(TutorialLessons.F_AIM):. Update each comment to name the lesson, not the beat.
    • sim/drone_director.gd:107: if not sim.tutorial_locks(TutorialLessons.F_DECOY):; :438: if sim.tutorial_locks(TutorialLessons.F_DECOY):.
    • ui/weapon_panel.gd:147: if sim.tutorial_locks(TutorialLessons.F_AIM):.
    • main.gd:
      • _open_bay_direct (547): if sim.tutorial_locks(TutorialLessons.F_DECOY):.
      • add near _can_p2_join:
# The tutorial's level-up panel opens only when its lesson allows it (TutorialLessons.levelup_allowed);
# anything earned meanwhile stays banked in pending_levelups. Always open outside the tutorial.
func _levelup_gate_open() -> bool:
return not (_mode == "tutorial" and sim != null and sim.tutorial != null and not sim.tutorial.levelup_allowed())
- level-up open check (1358-1360): `if _next_leveling_pilot() != null and _levelup_gate_open() and not (_mode == "tutorial" and dialogue_box.is_showing()):` (update the comment above it).
- `_on_upgrade_chosen` (2294): `if _next_leveling_pilot() != null and _levelup_gate_open():`.
- story label (1707): `hud.suppress_story_label = _mode == "tutorial" # the objective panel replaces it (Task 10)`.
- touch locks (1721-1728) become one call: `touch_controls.set_ability_locks(sim.tutorial_locks(TutorialLessons.F_WARP), sim.tutorial_locks(TutorialLessons.F_DECOY), sim.tutorial_locks(TutorialLessons.F_AIM))`.
- `_stage_tutorial_hud` body: guard `sim.tutorial == null` in the early return, then `weapon_panel.visible = sim.tutorial.is_unlocked(TutorialLessons.F_WEAPON_DOCK)` and `drone_dock.visible = sim.tutorial.is_unlocked(TutorialLessons.F_DECOY)`.
  • tools/story_auto/story_trace.gd:84: if sim.tutorial != null and not sim.tutorial.levelup_allowed(): (and fix the comment).
  • data/story.json: delete the beats aim_intro, hp_intro, radar_intro, levelup_intro, crystal_intro, warp_learn, move_nudge, echo_xp, echo_decoy, pulse_learn, secret_hint, and their references: ember_a1.on_enter, ember_a2.on_enter (remove the keys), ember_e1.clear_dialogue and ember_e2.clear_dialogue (remove the keys). Decision: these are all tutorial: true, so ordinary Story mode suppresses them and never shows them; after Task 3 the tutorial no longer says them. Deleting them removes dead copy. teach_decoy/teach_heal stay (Story mode’s _check_teaching still references them).
  • Update the stale comments in ui/touch_controls.gd:125, ui/hud.gd:33 and ui/dialogue_box.gd:53 that name the deleted beats.
  • Step 4: Verify no beat-keyed gate is left: /usr/bin/grep -rn 'tutorial_locked("' --include='*.gd' sim ui main.gd input tools returns nothing (tests may still call it on purpose).
  • Step 5: Run every migrated script via -gselect (test_story_read_gating, test_story_director, test_tutorial_progression, test_drones, test_weapon_panel, test_main, test_story_loader) and the two determinism scripts (no re-pin). Gate: test-count guard OK.
  • Step 6: Commit refactor(tutorial): every gate keys off lesson features, opened on read.

Task 6: Glyphs for every control, chosen by the last-used device

Section titled “Task 6: Glyphs for every control, chosen by the last-used device”

Files:

  • Modify: ui/button_glyph.gd (preferred_size, _draw), ui/button_prompt.gd (current_style 57-67), input/input_router.gd (_ready 43), ui/dialogue_box.gd (CONTROL_NAMES/control_scheme 192-211)
  • Test: modify tests/test_button_prompt.gd, tests/test_dialogue_box.gd

Interfaces:

  • Produces: ButtonGlyph.button also accepts "lb", "rb", "ls", "rs", "menu"; ButtonPrompt.last_style (static), ButtonPrompt.style_for_event(event: InputEvent, joy_name: String, touch: bool) -> String (pure; "" = no change), ButtonPrompt.note_input(event: InputEvent) -> void, ButtonPrompt.reset_last_used() -> void, ButtonPrompt.ACTIONS (action -> [button, key_text]), ButtonPrompt.TOUCH_WORDS, ButtonPrompt.action_glyph(action: String, style: String, size: float) -> ButtonGlyph (null for touch/unknown).

  • Step 1: Write the failing tests (append to tests/test_button_prompt.gd):

func after_each() -> void:
ButtonPrompt.reset_last_used()
ButtonPrompt.force_style = ""
func test_style_for_event_follows_the_device_just_used() -> void:
var key := InputEventKey.new(); key.pressed = true; key.physical_keycode = KEY_W
assert_eq(ButtonPrompt.style_for_event(key, "", false), "key")
var jb := InputEventJoypadButton.new(); jb.pressed = true; jb.button_index = JOY_BUTTON_A
assert_eq(ButtonPrompt.style_for_event(jb, "DualSense Wireless Controller", false), "playstation")
assert_eq(ButtonPrompt.style_for_event(jb, "Xbox Wireless Controller", false), "xbox")
var drift := InputEventJoypadMotion.new(); drift.axis = JOY_AXIS_LEFT_X; drift.axis_value = 0.1
assert_eq(ButtonPrompt.style_for_event(drift, "Xbox Wireless Controller", false), "", "stick drift changes nothing")
assert_eq(ButtonPrompt.style_for_event(jb, "Siri Remote", false), "", "the Siri Remote never decides prompts")
assert_eq(ButtonPrompt.style_for_event(key, "", true), "none", "touch devices show no glyphs")
func test_last_used_device_wins_over_the_pad_scan() -> void:
ButtonPrompt.last_style = "key"
assert_eq(ButtonPrompt.current_style(), "key", "a Mac player on the keyboard sees keycaps even with a pad connected")
ButtonPrompt.last_style = "playstation"
assert_eq(ButtonPrompt.current_style(), "playstation")
func test_every_action_has_a_glyph_for_every_style() -> void:
for action in ButtonPrompt.ACTIONS:
for style in ["xbox", "playstation", "key"]:
var g := ButtonPrompt.action_glyph(action, style, 22.0)
assert_not_null(g, "%s/%s" % [action, style])
add_child_autofree(g)
assert_null(ButtonPrompt.action_glyph(action, "none", 22.0), "touch uses TOUCH_WORDS instead")
assert_true(ButtonPrompt.TOUCH_WORDS.has(action))
await get_tree().process_frame # every new button draws without error
func test_bumpers_and_menu_are_wider_than_face_buttons() -> void:
var g := ButtonGlyph.new(); g.style = "xbox"; g.button = "rb"
assert_gt(g.preferred_size().x, g.preferred_size().y)
g.button = "menu"
assert_gt(g.preferred_size().x, g.preferred_size().y)
g.button = "a"
assert_eq(g.preferred_size().x, g.preferred_size().y)
g.free()

Append to tests/test_dialogue_box.gd:

func test_controller_names_hard_code_no_buttons() -> void:
var s := DialogueBox.resolve_controls("{warp} {decoy}", "controller")
assert_false(s.contains("RB") or s.contains("LB"), s)
func test_control_scheme_follows_the_last_used_device() -> void:
ButtonPrompt.last_style = "key"
assert_eq(DialogueBox.control_scheme(), "keyboard")
ButtonPrompt.last_style = "xbox"
assert_eq(DialogueBox.control_scheme(), "controller")
ButtonPrompt.reset_last_used()
  • Step 2: Run -gselect=test_button_prompt, -gselect=test_dialogue_box: FAIL.
  • Step 3: Implement ButtonPrompt additions:
# The style of the device the player used LAST (spec §3: prompts follow the device in use, so a Mac
# player on a controller sees pad glyphs and one on the keyboard sees keycaps). "" = nothing seen yet.
static var last_style := ""
const ACTIONS := {
"move": ["ls", "WASD"], "aim": ["rs", "ARROWS"], "warp": ["rb", "SPACE"], "decoy": ["lb", "Q"],
"sheet": ["y", "TAB"], "menu": ["menu", "ESC"], "choose": ["a", "ENTER"],
}
const TOUCH_WORDS := {
"move": "STICK", "aim": "AIM PAD", "warp": "WARP", "decoy": "DRONE", "sheet": "STATS",
"menu": "PAUSE", "choose": "TAP A CARD",
}
const STICK_WAKE := 0.5 # a stick must move this far to count as "using the pad"
static func reset_last_used() -> void:
last_style = ""
# Pure classifier. joy_name is the event's pad name ("" for non-pad events).
static func style_for_event(event: InputEvent, joy_name: String, touch: bool) -> String:
if touch:
return "none"
if event is InputEventKey or event is InputEventMouseButton:
return "key" if event.is_pressed() else ""
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
if InputRouter.is_ignored_joypad(joy_name):
return ""
if event is InputEventJoypadMotion and absf((event as InputEventJoypadMotion).axis_value) < STICK_WAKE:
return ""
if event is InputEventJoypadButton and not event.is_pressed():
return ""
return style_for_name(joy_name)
return ""
static func note_input(event: InputEvent) -> void:
var joy := ""
if event is InputEventJoypadButton or event is InputEventJoypadMotion:
joy = Input.get_joy_name(event.device)
var s := style_for_event(event, joy, Platform.is_touch())
if s != "":
last_style = s
static func action_glyph(action: String, style: String, size: float) -> ButtonGlyph:
if style == "none" or not ACTIONS.has(action):
return null
var g := ButtonGlyph.new()
g.style = style
g.button = String(ACTIONS[action][0])
g.key_text = String(ACTIONS[action][1])
g.base_size = size
return g

and in current_style, after the force_style check and before the pad scan, insert: if device < 0 and last_style != "": return last_style.

  • Step 4: Draw the new buttons in ui/button_glyph.gd. In preferred_size() before the final return: if style != "key" and button in ["lb", "rb", "menu"]: return Vector2(roundf(base_size * 1.6), base_size). In _draw(), for non-key styles, dispatch lb/rb/menu to _draw_pill(s) and ls/rs to _draw_stick(s) before the existing face-button paths:
const BUMPER_TEXT := {"xbox": {"lb": "LB", "rb": "RB"}, "playstation": {"lb": "L1", "rb": "R1"}}
func _draw_pill(s: Vector2) -> void:
var sb := StyleBoxFlat.new()
sb.bg_color = DISC_COLOR
sb.border_color = RIM_COLOR
sb.set_border_width_all(maxi(1, int(round(s.y * 0.08))))
sb.set_corner_radius_all(int(round(s.y * 0.5)))
sb.anti_aliasing = true
draw_style_box(sb, Rect2(Vector2.ZERO, s))
if button == "menu":
var w := s.x * 0.32
var t := maxf(1.4, s.y * 0.08)
for k in [-1, 0, 1]:
var y := s.y * 0.5 + float(k) * s.y * 0.16
draw_line(Vector2(s.x * 0.5 - w * 0.5, y), Vector2(s.x * 0.5 + w * 0.5, y), RIM_COLOR, t, true)
return
var txt := String((BUMPER_TEXT.get(style, BUMPER_TEXT["xbox"]) as Dictionary).get(button, "RB"))
_draw_centered(txt, letter_font(), maxi(8, int(round(s.y * 0.42))), s * 0.5, RIM_COLOR)
func _draw_stick(s: Vector2) -> void:
var c := s * 0.5
var r := minf(s.x, s.y) * 0.5 - 0.5
draw_circle(c, r, DISC_COLOR, true, -1.0, true)
draw_arc(c, r - 0.6, 0.0, TAU, 48, RIM_COLOR, maxf(1.0, r * 0.1), true)
draw_circle(c, r * 0.55, Color(RIM_COLOR, 0.25), true, -1.0, true)
_draw_centered("L" if button == "ls" else "R", letter_font(), maxi(8, int(round(r * 1.0))), c, RIM_COLOR)
  • Step 5: Feed events. input/input_router.gd, add:
# Every input event tells the prompt system which device the player is using right now (spec §3).
# Read-only: never consumes the event.
func _input(event: InputEvent) -> void:
ButtonPrompt.note_input(event)
  • Step 6: Dialogue control names. In ui/dialogue_box.gd set the controller entries to "warp": "the warp button", "decoy": "the decoy button", and replace control_scheme() with:
static func control_scheme() -> String:
var s := ButtonPrompt.current_style() # follows the last-used device, then the pad scan
if s == "none":
return "touch"
return "keyboard" if s == "key" else "controller"
  • Step 7: Run both selects plus test_level_up_panel and test_pause_menu: PASS. Gate: test-count guard OK.
  • Step 8: Commit feat(ui): drawn prompts for every control, following the last-used device.

Task 7: Speaker emblems, roles and sides in the dialogue box

Section titled “Task 7: Speaker emblems, roles and sides in the dialogue box”

Files:

  • Modify: ui/speaker_emblem.gd (created in Task 2), ui/dialogue_box.gd (_ready portrait 105-124, consume 172-184, _layout_panel 164-178, _show_current 320-346)
  • Test: create tests/test_speaker_emblem.gd; modify tests/test_dialogue_box.gd

Interfaces:

  • Consumes: say events with emblem, role, side (Task 2).

  • Produces: SpeakerEmblem.kind: String, SpeakerEmblem.color: Color, static SpeakerEmblem.outline(kind: String, s: Vector2) -> PackedVector2Array (closed; empty for "ring"), static SpeakerEmblem.fallback(side: String) -> String; DialogueBox test seams _emblem: SpeakerEmblem, _role_label: Label, _side: String.

  • Step 1: Write the failing tests. tests/test_speaker_emblem.gd:

extends GutTest
func test_polygon_emblems_are_closed_outlines() -> void:
for k in ["diamond", "flame", "crown"]:
var pts := SpeakerEmblem.outline(k, Vector2(92, 92))
assert_gte(pts.size(), 5, k)
assert_eq(pts[0], pts[pts.size() - 1], "%s is closed" % k)
func test_ring_is_hollow_not_a_polygon() -> void:
assert_eq(SpeakerEmblem.outline("ring", Vector2(92, 92)).size(), 0)
func test_fallback_emblem_by_side() -> void:
assert_eq(SpeakerEmblem.fallback("left"), "diamond")
assert_eq(SpeakerEmblem.fallback("right"), "crown")
func test_every_kind_draws() -> void:
for k in SpeakerEmblem.KINDS:
var e := SpeakerEmblem.new()
e.kind = k
e.custom_minimum_size = Vector2(92, 92)
add_child_autofree(e)
await get_tree().process_frame
assert_true(true, "no draw errors")

In tests/test_dialogue_box.gd, change _say to func _say(name: String, lines: Array, id: String = "beat", emblem := "diamond", role := "your guide", side := "left") -> Dictionary: returning the dict with those three keys added; replace test_portrait_shows_speaker_initial with:

func test_portrait_draws_the_speakers_emblem_and_role() -> void:
var b := _box()
b.consume([_say("Ember Warden", ["hello"], "w", "crown", "boss", "right")])
assert_eq(b._emblem.kind, "crown", "an emblem, not an initial")
assert_eq(b._role_label.text, "boss")
assert_eq(b._name_label.text, "Ember Warden")
func test_enemies_speak_from_the_right_and_allies_from_the_left() -> void:
var b := _box()
b.consume([_say("Echo", ["a"], "e", "diamond", "your guide", "left")])
var left_x := b._portrait_bg.position.x
b.request_skip(); b.request_skip()
b.consume([_say("Ash", ["b"], "a", "flame", "Ember raider", "right")])
assert_gt(b._portrait_bg.position.x, left_x, "the portrait moves to the right for an enemy")
assert_lt(b._line_label.position.x, b._portrait_bg.position.x, "and the text sits left of it")
func test_missing_emblem_falls_back_by_side() -> void:
var b := _box()
b.consume([{"kind": "say", "id": "x", "name": "Someone", "color": "#fff", "lines": ["hi"], "side": "right"}])
assert_eq(b._emblem.kind, "crown")
  • Step 2: Run -gselect=test_speaker_emblem, -gselect=test_dialogue_box: FAIL.
  • Step 3: Implement ui/speaker_emblem.gd:
class_name SpeakerEmblem
extends Control
# Drawn speaker emblem for the dialogue box (spec §3): Echo's gold diamond, Ash's flame, The Null's
# hollow ring, the Ember Warden's crown. Replaces the old initial-in-a-square, which drew "E" for both
# Echo and the Ember Warden. Canvas primitives only (no textures, no font glyphs), sharp at 4K.
const KINDS := ["diamond", "flame", "ring", "crown"]
var kind := "diamond":
set(v):
kind = v
queue_redraw()
var color := Color.WHITE:
set(v):
color = v
queue_redraw()
func _init() -> void:
mouse_filter = Control.MOUSE_FILTER_IGNORE
static func fallback(side: String) -> String:
return "crown" if side == "right" else "diamond"
# Closed outline for the polygon kinds, centred in a box of size `s`. Empty for "ring" (drawn as an arc).
static func outline(k: String, s: Vector2) -> PackedVector2Array:
var c := s * 0.5
var r := minf(s.x, s.y) * 0.42
var pts: Array = []
match k:
"diamond":
pts = [Vector2(0, -1), Vector2(0.7, 0), Vector2(0, 1), Vector2(-0.7, 0)]
"flame":
pts = [Vector2(0, -1), Vector2(0.45, -0.2), Vector2(0.55, 0.35), Vector2(0.25, 0.8),
Vector2(-0.25, 0.8), Vector2(-0.55, 0.35), Vector2(-0.2, -0.05), Vector2(-0.1, -0.45)]
"crown":
pts = [Vector2(-0.8, 0.55), Vector2(-0.8, -0.35), Vector2(-0.4, 0.05), Vector2(0, -0.6),
Vector2(0.4, 0.05), Vector2(0.8, -0.35), Vector2(0.8, 0.55)]
_:
return PackedVector2Array()
var out := PackedVector2Array()
for p: Vector2 in pts:
out.append(c + p * r)
out.append(out[0])
return out
func _draw() -> void:
var s := size if size.x > 0.0 else custom_minimum_size
var c := s * 0.5
var r := minf(s.x, s.y) * 0.42
if kind == "ring":
draw_arc(c, r * 0.72, 0.0, TAU, 48, color, maxf(2.0, r * 0.22), true)
return
var pts := outline(kind, s)
if pts.is_empty():
return
var fill := pts.duplicate()
fill.remove_at(fill.size() - 1)
draw_colored_polygon(fill, Color(color, 0.35))
draw_polyline(pts, color, maxf(2.0, r * 0.06), true)
  • Step 4: Update ui/dialogue_box.gd.
    • Replace the _initial Label (vars + _ready block) with _emblem := SpeakerEmblem.new() sized Vector2(PORTRAIT, PORTRAIT) inside _portrait_bg at Vector2.ZERO; add _role_label (mono font 16, right-aligned, mouse_filter ignore) as a child of _panel; add var _side := "left".
    • consume: append "emblem": String(e.get("emblem", "")), "role": String(e.get("role", "")), "side": String(e.get("side", "left")) to each queued entry.
    • _layout_panel: position by side:
var text_w := w - PORTRAIT - 18.0 - 22.0 - 24.0
var right := _side == "right"
_portrait_bg.position = Vector2(w - 18.0 - PORTRAIT if right else 18.0, 28)
var tx := 24.0 if right else 18.0 + PORTRAIT + 22.0
_name_label.position = Vector2(tx, 14)
_name_label.size = Vector2(text_w, 30)
_role_label.position = Vector2(tx, 18)
_role_label.size = Vector2(text_w, 24)
_line_label.position = Vector2(tx, 50)
_line_label.size = Vector2(text_w, 88)
  • _show_current: replace the _initial lines with
_side = String(cur.get("side", "left"))
var kind := String(cur.get("emblem", ""))
_emblem.kind = kind if kind in SpeakerEmblem.KINDS else SpeakerEmblem.fallback(_side)
_emblem.color = col
_role_label.text = String(cur.get("role", ""))
_role_label.add_theme_color_override("font_color", Color(col.lerp(Color.WHITE, 0.3), 0.8))
_layout_panel()
  • Update the class header comment (“an emblem, not an initial”).
  • Step 5: Run both selects and test_story_ui, test_main: PASS. Gate: test-count guard OK.
  • Step 6: Commit feat(ui): speakers get drawn emblems, roles and sides.

Files:

  • Create: ui/objective_panel.gd
  • Test: create tests/test_objective_panel.gd

Interfaces:

  • Consumes: TutorialLessons.objective_view(sim) dict (visible, lesson, total, title, text, prompt, progress, count, done), ButtonPrompt.action_glyph, ButtonPrompt.current_style, ButtonPrompt.TOUCH_WORDS, GlassPanel.
  • Produces: ObjectivePanel (CanvasLayer, layer 12): update_view(view: Dictionary, boss_alive: bool, blocked: bool) -> void, show_tip(text: String, action := "") -> void, advance(delta: float) -> void; consts TOP_Y := 48.0, TOP_Y_BOSS := 118.0, WIDTH := 560.0, TIP_SECONDS := 6.0; seams header_text(), objective_text(), progress_text(), prompt_glyph() -> ButtonGlyph, prompt_word() -> String, is_ticked() -> bool, tip_text() -> String, panel_rect() -> Rect2, is_shown() -> bool.

Decision: the header uses the mono font, which carries U+00B7 (the title font Orbitron does not, level_up_panel.gd:171). A test pins it; if it ever fails, use “ / “ instead. Decision: the panel hides while the dialogue box or the pause menu is up. That is what guarantees it never overlaps the dialogue box (on touch the dialogue sits at the TOP, dialogue_box.gd:164-178). It sits at y 48 (under the HP bar, y 22-38) and drops to y 118 while a boss is alive (the boss bar is y 88-110, hud.gd:13).

  • Step 1: Write the failing tests tests/test_objective_panel.gd:
extends GutTest
func after_each() -> void:
ButtonPrompt.force_style = ""
func _view(extra := {}) -> Dictionary:
var v := {"visible": true, "lesson": 5, "total": 10, "title": "RIFT BOLT",
"text": "Hit 3 enemies with the Rift Bolt", "prompt": "aim", "progress": 2, "count": 3, "done": false}
v.merge(extra, true)
return v
func _panel() -> ObjectivePanel:
var p := ObjectivePanel.new()
add_child_autofree(p)
return p
func test_shows_lesson_header_objective_prompt_and_progress() -> void:
ButtonPrompt.force_style = "xbox"
var p := _panel()
await get_tree().process_frame
p.update_view(_view(), false, false)
assert_eq(p.header_text(), "LESSON 5 OF 10 · RIFT BOLT")
assert_eq(p.objective_text(), "Hit 3 enemies with the Rift Bolt")
assert_eq(p.progress_text(), "2/3")
assert_eq(p.prompt_glyph().button, "rs", "the right stick for aiming")
assert_false(p.is_ticked())
func test_keyboard_players_see_keycaps() -> void:
ButtonPrompt.force_style = "key"
var p := _panel()
await get_tree().process_frame
p.update_view(_view(), false, false)
assert_eq(p.prompt_glyph().key_text, "ARROWS")
func test_touch_shows_the_button_word_instead() -> void:
ButtonPrompt.force_style = "none"
var p := _panel()
await get_tree().process_frame
p.update_view(_view({"prompt": "decoy"}), false, false)
assert_null(p.prompt_glyph())
assert_eq(p.prompt_word(), "DRONE")
func test_done_shows_a_tick_instead_of_progress() -> void:
var p := _panel()
await get_tree().process_frame
p.update_view(_view({"done": true, "progress": 3}), false, false)
assert_true(p.is_ticked())
assert_eq(p.progress_text(), "")
func test_no_count_means_no_progress_label() -> void:
var p := _panel()
await get_tree().process_frame
p.update_view(_view({"count": 0, "prompt": ""}), false, false)
assert_eq(p.progress_text(), "")
func test_hidden_while_blocked_or_not_visible() -> void:
var p := _panel()
await get_tree().process_frame
p.update_view(_view(), false, true)
assert_false(p.is_shown(), "hidden under dialogue / pause")
p.update_view({"visible": false}, false, false)
assert_false(p.is_shown())
func test_layout_clears_the_hp_bar_boss_bar_and_level_up_strip() -> void:
var p := _panel()
await get_tree().process_frame
p.update_view(_view(), false, false)
await get_tree().process_frame
var r := p.panel_rect()
assert_gte(r.position.y, Hud.HP_BACKING_H + 22.0, "below the HP bar")
p.update_view(_view(), true, false)
await get_tree().process_frame
r = p.panel_rect()
assert_gte(r.position.y, Hud.BOSS_BAR_Y + Hud.BOSS_BAR_H, "below the boss bar while a boss lives")
assert_lt(r.end.y, 720.0 - (LevelUpPanel.STRIP_BOTTOM + LevelUpPanel.STRIP_H + 64.0), "above the level-up strip and header at 720p")
func test_tip_shows_then_fades() -> void:
var p := _panel()
await get_tree().process_frame
p.update_view({"visible": false}, false, false)
p.show_tip(TutorialLessons.TIP_RADAR)
assert_eq(p.tip_text(), TutorialLessons.TIP_RADAR)
assert_true(p.is_shown(), "a tip shows even between objectives")
p.advance(ObjectivePanel.TIP_SECONDS + 0.1)
p.update_view({"visible": false}, false, false)
assert_false(p.is_shown())
func test_mono_font_has_the_middle_dot() -> void:
assert_true(NeonTheme.mono_font().has_char(0x00B7))
  • Step 2: Run -gselect=test_objective_panel (after --import): FAIL.
  • Step 3: Implement ui/objective_panel.gd:
class_name ObjectivePanel
extends CanvasLayer
# Tutorial objective bar (spec §2): a small clear-glass panel, top centre, tutorial runs only.
# LESSON 5 OF 10 · RIFT BOLT
# Hit 3 enemies with the Rift Bolt [glyph] 2/3
# The glyph follows the last-used device (ButtonPrompt); touch shows the on-screen button's word.
# A tick replaces the count when the objective is done. A tip line under it carries contextual tips.
# Render-only: reads TutorialLessons.objective_view(); never writes the sim.
const TOP_Y := 48.0
const TOP_Y_BOSS := 118.0
const WIDTH := 560.0
const TIP_SECONDS := 6.0
const HEADER_FONT := 14
const TEXT_FONT := 20
const DONE_COLOR := Color(0.4, 1.0, 0.6)
class TickMark:
extends Control
func _init() -> void:
custom_minimum_size = Vector2(26, 26)
mouse_filter = Control.MOUSE_FILTER_IGNORE
func _draw() -> void:
var s := size if size.x > 0.0 else custom_minimum_size
draw_polyline(PackedVector2Array([Vector2(s.x * 0.18, s.y * 0.55), Vector2(s.x * 0.42, s.y * 0.78),
Vector2(s.x * 0.84, s.y * 0.24)]), ObjectivePanel.DONE_COLOR, maxf(2.0, s.y * 0.12), true)
var _glass: GlassPanel
var _header: Label
var _text: Label
var _prompt_box: HBoxContainer
var _progress: Label
var _tick: TickMark
var _tip: Label
var _tip_t := 0.0
var _view: Dictionary = {}
var _blocked := false
var _prompt_key := ""
func _ready() -> void:
layer = 12 # above the level-up panel (10) so "Pick an upgrade" shows while it is open
_glass = GlassPanel.new() # clear glass (frosted = false)
_glass.accent = Tokens.ACCENT
_glass.margin = 12.0
_glass.set_anchors_preset(Control.PRESET_CENTER_TOP)
_glass.custom_minimum_size = Vector2(WIDTH, 0)
_glass.position = Vector2(-WIDTH * 0.5, TOP_Y)
_glass.mouse_filter = Control.MOUSE_FILTER_IGNORE
add_child(_glass)
var col := VBoxContainer.new()
col.mouse_filter = Control.MOUSE_FILTER_IGNORE
col.add_theme_constant_override("separation", 4)
_glass.add_child(col)
_header = _label(HEADER_FONT, Tokens.TEXT_DIM)
col.add_child(_header)
var row := HBoxContainer.new()
row.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_theme_constant_override("separation", 10)
col.add_child(row)
_text = _label(TEXT_FONT, Tokens.TEXT)
_text.size_flags_horizontal = Control.SIZE_EXPAND_FILL
row.add_child(_text)
_prompt_box = HBoxContainer.new()
_prompt_box.mouse_filter = Control.MOUSE_FILTER_IGNORE
row.add_child(_prompt_box)
_progress = _label(TEXT_FONT, Tokens.ACCENT)
row.add_child(_progress)
_tick = TickMark.new()
_tick.visible = false
row.add_child(_tick)
_tip = _label(HEADER_FONT, Tokens.GOLD)
_tip.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART
_tip.visible = false
col.add_child(_tip)
_refresh_visible()
func _label(fs: int, c: Color) -> Label:
var l := Label.new()
l.add_theme_font_override("font", NeonTheme.mono_font())
l.add_theme_font_size_override("font_size", fs)
l.add_theme_color_override("font_color", c)
l.mouse_filter = Control.MOUSE_FILTER_IGNORE
l.vertical_alignment = VERTICAL_ALIGNMENT_CENTER
return l
func update_view(view: Dictionary, boss_alive: bool, blocked: bool) -> void:
_view = view
_blocked = blocked
_glass.position.y = TOP_Y_BOSS if boss_alive else TOP_Y
if bool(view.get("visible", false)):
_header.text = "LESSON %d OF %d · %s" % [int(view["lesson"]), int(view["total"]), String(view["title"])]
_text.text = String(view["text"])
var done := bool(view.get("done", false))
var count := int(view.get("count", 0))
_progress.text = "" if (done or count <= 0) else "%d/%d" % [int(view["progress"]), count]
_progress.visible = _progress.text != ""
_tick.visible = done
_set_prompt(String(view.get("prompt", "")))
_header.visible = bool(view.get("visible", false))
_text.get_parent().visible = bool(view.get("visible", false))
_refresh_visible()
func _set_prompt(action: String) -> void:
var style := ButtonPrompt.current_style()
var key := action + "|" + style
if key == _prompt_key:
return
_prompt_key = key
for c in _prompt_box.get_children():
_prompt_box.remove_child(c)
c.queue_free()
if action == "":
return
var g := ButtonPrompt.action_glyph(action, style, 26.0)
if g != null:
_prompt_box.add_child(g)
elif ButtonPrompt.TOUCH_WORDS.has(action):
var w := _label(HEADER_FONT, Tokens.ACCENT)
w.text = String(ButtonPrompt.TOUCH_WORDS[action])
_prompt_box.add_child(w)
# `action` is reserved for a prompt glyph on the tip line; the tip texts already name the control
# in words ("decoy drone"), so v1 shows text only.
func show_tip(text: String, _action := "") -> void:
_tip.text = text
_tip.visible = true
_tip_t = TIP_SECONDS
_refresh_visible()
func advance(delta: float) -> void:
if _tip_t <= 0.0:
return
_tip_t -= delta
if _tip_t <= 0.0:
_tip.visible = false
_refresh_visible()
func _refresh_visible() -> void:
_glass.visible = not _blocked and (bool(_view.get("visible", false)) or _tip.visible)
# ── Test seams ────────────────────────────────────────────────────────────────
func header_text() -> String: return _header.text
func objective_text() -> String: return _text.text
func progress_text() -> String: return _progress.text if _progress.visible else ""
func is_ticked() -> bool: return _tick.visible
func tip_text() -> String: return _tip.text if _tip.visible else ""
func is_shown() -> bool: return _glass.visible
func panel_rect() -> Rect2: return _glass.get_global_rect()
func prompt_glyph() -> ButtonGlyph:
for c in _prompt_box.get_children():
if c is ButtonGlyph:
return c
return null
func prompt_word() -> String:
for c in _prompt_box.get_children():
if c is Label:
return (c as Label).text
return ""

Note: panel_rect() measures the real laid-out rect; the 720-height assertion holds because the panel is at most two rows plus a tip under y 118 (about 118 + 110 = 228 px), far above the strip header at 720 - 328 = 392.

  • Step 4: Run -gselect=test_objective_panel: PASS. Gate: test-count guard OK.
  • Step 5: Commit feat(ui): tutorial objective panel.

Task 9: Level-up panel and pause menu tutorial options

Section titled “Task 9: Level-up panel and pause menu tutorial options”

Files:

  • Modify: ui/level_up_base.gd, ui/level_up_panel.gd (_ready 76-137, show_for 139-194, toggle_sheet 225-232, _rebuild_hint 247-258, _confirm 465-469, _input 506-520), ui/pause_menu.gd (signals 11-18, button adds 128-133, _open_stats 200-210)
  • Test: modify tests/test_level_up_panel.gd, tests/test_pause_menu.gd

Interfaces:

  • Consumes: TutorialLessons.panel_options() (tag_note: String, stats_enabled: bool, sheet_first: bool).
  • Produces: LevelUpBase.sheet_opened signal and configure_tutorial(opts: Dictionary) -> void (no-op on the base, so the legacy panel ignores it); LevelUpPanel _tag_note: Label, _stats_enabled, _sheet_first, _sheet_seen; PauseMenu.hidden_entries: Array[String] (set BEFORE add_child; values "stats", "ship", "shop", "bestiary"), PauseMenu.stats_opened signal.

Decision: tutorial staging applies to the liquid-glass LevelUpPanel only; LegacyLevelUpPanel (behind VisualLook.glass_ui = false) inherits the no-op, and its players can still do the sheet steps from the pause menu.

  • Step 1: Write the failing tests (append to tests/test_level_up_panel.gd; _open is the file’s existing helper):
func test_tutorial_tag_note_shows_only_when_given() -> void:
var r: Array = await _open(["armor"])
var panel: LevelUpPanel = r[1]
assert_false(panel._tag_note.visible, "no explainer outside the tutorial")
panel.configure_tutorial({"tag_note": TutorialLessons.TAG_NOTE_LEVELUP})
assert_true(panel._tag_note.visible)
assert_eq(panel._tag_note.text, TutorialLessons.TAG_NOTE_LEVELUP)
func test_stats_sheet_locked_when_disabled() -> void:
var r: Array = await _open(["armor"])
var panel: LevelUpPanel = r[1]
panel.configure_tutorial({"stats_enabled": false})
panel.show_for(r[0], (r[0] as Sim).player, ["armor"])
await get_tree().process_frame
var y := InputEventJoypadButton.new(); y.button_index = JOY_BUTTON_Y; y.pressed = true
panel._input(y)
assert_false(panel._sheet.is_open(), "Y does nothing before the Ship stats lesson")
for c in panel._hint.get_children():
if c is ButtonGlyph:
assert_ne((c as ButtonGlyph).button, "y", "and the hint row does not offer it")
func test_sheet_first_blocks_the_pick_until_the_sheet_is_opened() -> void:
var r: Array = await _open(["armor", "max_hp"])
var panel: LevelUpPanel = r[1]
panel.configure_tutorial({"sheet_first": true})
watch_signals(panel)
panel._confirm()
assert_signal_not_emitted(panel, "chosen", "no pick before Ship stats has been opened")
panel.toggle_sheet()
assert_signal_emitted(panel, "sheet_opened")
panel.toggle_sheet()
panel._confirm()
assert_signal_emitted(panel, "chosen")
func test_configure_resets_between_opens() -> void:
var r: Array = await _open(["armor"])
var panel: LevelUpPanel = r[1]
panel.configure_tutorial({"stats_enabled": false, "sheet_first": true, "tag_note": "x"})
panel.configure_tutorial({})
assert_true(panel._stats_enabled)
assert_false(panel._sheet_first)
assert_false(panel._tag_note.visible)

Append to tests/test_pause_menu.gd (read its existing construction helper first and reuse it; the menu must get hidden_entries before add_child):

func test_hidden_entries_are_left_out_of_the_menu() -> void:
var pm := PauseMenu.new()
pm.hidden_entries = ["ship", "shop", "bestiary", "stats"]
add_child_autofree(pm)
await get_tree().process_frame
var texts: Array = []
for b in pm._buttons:
texts.append(b.text)
for t in ["Ship", "Shop", "Bestiary", "Ship stats"]:
assert_false(texts.has(t), "%s hidden" % t)
assert_true(texts.has("Resume"))
func test_opening_ship_stats_is_announced() -> void:
var pm := PauseMenu.new()
add_child_autofree(pm)
await get_tree().process_frame
pm.bind_stats(Sim.new(1, SimContentFixture.db()), null)
watch_signals(pm)
pm._open_stats()
assert_signal_emitted(pm, "stats_opened")

(If bind_stats needs a real pilot, pass sim.player.)

  • Step 2: Run -gselect=test_level_up_panel, -gselect=test_pause_menu: FAIL.
  • Step 3: Implement.
    • ui/level_up_base.gd: add signal sheet_opened and
# Tutorial staging (TutorialLessons.panel_options): {"tag_note", "stats_enabled", "sheet_first"}.
# main calls this before every show_for ({} outside the tutorial). The legacy panel ignores it.
func configure_tutorial(_opts: Dictionary) -> void:
pass
  • ui/level_up_panel.gd: vars var _stats_enabled := true, var _sheet_first := false, var _sheet_seen := false, var _tag_note: Label. In _ready, after _header, build _tag_note (mono font 15, PROGRESS_TEXT colour, centre-bottom anchored at offset_top = -(STRIP_BOTTOM + STRIP_H + 96), offset_bottom = -(STRIP_BOTTOM + STRIP_H + 66), hidden). Then:
func configure_tutorial(opts: Dictionary) -> void:
_stats_enabled = bool(opts.get("stats_enabled", true))
_sheet_first = bool(opts.get("sheet_first", false))
_tag_note.text = String(opts.get("tag_note", ""))
_tag_note.visible = _tag_note.text != ""
if _stats_btn != null:
_stats_btn.visible = _stats_enabled
_rebuild_hint()
  • show_for: set _sheet_seen = false next to _accepting = false.
  • toggle_sheet: first line if not _stats_enabled and not _sheet.is_open(): return; after _sheet.open() add _sheet_seen = true and sheet_opened.emit().
  • _confirm: after the empty check, if _sheet_first and not _sheet_seen: _ui_nav(); return # lesson 4: look at Ship stats before choosing.
  • _rebuild_hint: build var parts := [["a", "CHOOSE", "ENTER"]] then if _stats_enabled: parts.append(["y", "SHIP STATS", "TAB"]), pass parts to ButtonPrompt.row.
  • _input Y/TAB branch: unchanged (toggle_sheet now self-guards).
  • ui/pause_menu.gd: add signal stats_opened and var hidden_entries: Array[String] = [] # set before add_child (tutorial hides Ship/Shop/Bestiary, and Ship stats until its lesson); wrap each of the four box.add_child(_make_btn(...)) lines for Ship stats / Ship / Shop / Bestiary in if not hidden_entries.has("<key>"): with keys stats, ship, shop, bestiary; in _open_stats after _sheet.open() add stats_opened.emit().
  • Step 4: Run both selects: PASS. Gate: test-count guard OK.
  • Step 5: Commit feat(ui): level-up and pause menu follow the tutorial's lessons.

Files:

  • Modify: main.gd (meta block 717-733; _new_run near dialogue_box 1052-1060; _physics_process start ~1203; _process ~1707; _open_levelup 1776-1811; _toggle_pause 467-482; _input 2107-2137; _check_codex_encounters 2169), ui/threat_radar.gd (after update_radar)
  • Test: modify tests/test_main.gd

Interfaces:

  • Consumes: everything above.
  • Produces (main.gd): var objective_panel: ObjectivePanel, var _tips_shown: Dictionary, static run_ship_for(mode: String, selected: String) -> String, static tutorial_pause_hidden(mode: String, s: Sim) -> Array[String], static tutorial_hotkey_blocked(event: InputEvent, mode: String) -> bool, static debug_overlay_hotkey(event: InputEvent, dev_tools: bool) -> bool, static tutorial_tip_for(s: Sim, offscreen: int, shown: Dictionary) -> Dictionary, _on_stats_sheet_opened(), _update_objective_panel(delta), _drive_capture_ui(); ThreatRadar.offscreen_count() -> int.

Decisions:

  • Decision: the tutorial uses the starter hull (ShipBonuses.DEFAULT_SHIP) for base stats and perk, the basic decoy and the default loadout, so the run matches what is taught. Purchased meta STAT upgrades (meta.apply_to) and the global drone-recharge upgrade still apply: the spec names only the ship perk, decoy type and drone loadout.

  • Decision: the radar, health and Warden tips are render-side lines in the objective panel, each shown once per run. The Warden tips replace the old sim beats teach_decoy/teach_heal for the tutorial (they froze the Warden fight mid-swing); “struggling” = HP at or under half for the decoy tip (only when the decoy is charged) and at or under StoryDirector.TEACH_LOW_FRAC for the heal tip (only with still_heal_mult > 0).

  • Decision: UI notes are not in the replay stream (neither are picks). When _capturing or _auto, main opens the “sheet” steps itself so a Story Board capture never stalls on lesson 4 or 8.

  • Step 1: Write the failing tests (append to tests/test_main.gd, reusing its preload("res://main.tscn").instantiate() pattern):

# ── Tutorial lesson track wiring (2026-09-24) ───────────────────────────────────────────────
func test_tutorial_run_uses_the_starter_ship() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
assert_eq(m.run_ship_for("tutorial", "obsidian"), ShipBonuses.DEFAULT_SHIP)
assert_eq(m.run_ship_for("survival", "obsidian"), "obsidian")
func test_tutorial_hides_ship_shop_bestiary_and_stats_until_its_lesson() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null
var hidden: Array = m.tutorial_pause_hidden("tutorial", m.sim)
for k in ["ship", "shop", "bestiary", "stats"]:
assert_true(hidden.has(k), k)
m.sim.tutorial.skip_to(m.sim, "stats", true)
assert_false(m.tutorial_pause_hidden("tutorial", m.sim).has("stats"), "Ship stats opens with its lesson")
assert_true(m.tutorial_pause_hidden("survival", m.sim).is_empty(), "nothing hidden in Survival")
func test_codex_and_hud_hotkeys_are_locked_in_the_tutorial() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
var y := InputEventJoypadButton.new(); y.button_index = JOY_BUTTON_Y; y.pressed = true
var c := InputEventKey.new(); c.physical_keycode = KEY_C; c.pressed = true
var v := InputEventKey.new(); v.physical_keycode = KEY_V; v.pressed = true
for ev in [y, c, v]:
assert_true(m.tutorial_hotkey_blocked(ev, "tutorial"))
assert_false(m.tutorial_hotkey_blocked(ev, "survival"))
func test_controller_x_debug_overlay_is_dev_only() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
var x := InputEventJoypadButton.new(); x.button_index = JOY_BUTTON_X; x.pressed = true
assert_false(m.debug_overlay_hotkey(x, false), "release builds ignore X")
assert_true(m.debug_overlay_hotkey(x, true))
func test_no_new_enemy_cards_in_the_tutorial() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = MetaState.new() # in-memory: nothing seen yet
m.sim.spawn_story_enemy("swarmer", m.sim.player.pos + Vector2(300, 0))
m._check_codex_encounters()
assert_false(m.codex.is_open(), "no first-sighting card over the tutorial")
assert_false(m.meta.seen_enemies.has("swarmer"), "so Survival still shows it later")
func test_tutorial_has_an_objective_panel_and_survival_does_not() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null
assert_not_null(m.objective_panel)
m.sim.tutorial.skip_to(m.sim, "riftbolt", true)
m._update_objective_panel(1.0 / 60.0)
assert_eq(m.objective_panel.objective_text(), "Hit 3 enemies with the Rift Bolt")
func test_opening_ship_stats_on_the_sheet_step_completes_it() -> void:
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.tutorial.skip_to(m.sim, "stats", true, 1)
m._on_stats_sheet_opened()
assert_eq(m.sim.tutorial.step, 2)
func test_tips_fire_once_in_order_of_need() -> void:
var m = preload("res://main.tscn").instantiate()
add_child_autofree(m)
await get_tree().process_frame
m._on_mode_chosen("tutorial")
m.meta = null
var shown := {}
assert_true(m.tutorial_tip_for(m.sim, 0, shown).is_empty(), "nothing to say yet")
var t: Dictionary = m.tutorial_tip_for(m.sim, 2, shown)
assert_eq(String(t["id"]), "radar", "an enemy off screen earns the radar tip")
shown["radar"] = true
assert_true(m.tutorial_tip_for(m.sim, 2, shown).is_empty(), "once only")
  • Step 2: Run -gselect=test_main: FAIL.
  • Step 3: Implement.
    • ui/threat_radar.gd: func offscreen_count() -> int: return _v2_list.size() # V2 off-screen threats this frame (tutorial radar tip).
    • Meta block (717-733): var ship := run_ship_for(_mode, meta.selected_ship) used by apply_base and apply_to(ship, ...); wrap the decoy assignment and set_loadout in if _mode != "tutorial": with a comment pointing to the spec.
static func run_ship_for(mode: String, selected: String) -> String:
return ShipBonuses.DEFAULT_SHIP if mode == "tutorial" else selected
  • _new_run, after dialogue_box/level_up creation: level_up.sheet_opened.connect(_on_stats_sheet_opened); then
objective_panel = null
_tips_shown = {}
if _mode == "tutorial":
objective_panel = ObjectivePanel.new()
add_child(objective_panel)
  • Handlers and helpers:
func _on_stats_sheet_opened() -> void:
if sim != null and sim.tutorial != null:
sim.tutorial.note_ui(sim, "stats_sheet")
static func tutorial_pause_hidden(mode: String, s: Sim) -> Array[String]:
var out: Array[String] = []
if mode != "tutorial" or s == null or s.tutorial == null:
return out
out.append("ship")
out.append("shop")
out.append("bestiary")
if not s.tutorial.is_unlocked(TutorialLessons.F_STATS):
out.append("stats")
return out
static func tutorial_hotkey_blocked(event: InputEvent, mode: String) -> bool:
if mode != "tutorial":
return false
if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_Y:
return true
return event is InputEventKey and event.pressed and not event.echo \
and event.physical_keycode in [KEY_V, KEY_C]
static func debug_overlay_hotkey(event: InputEvent, dev_tools: bool) -> bool:
return dev_tools and event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_X
static func tutorial_tip_for(s: Sim, offscreen: int, shown: Dictionary) -> Dictionary:
if s == null or s.tutorial == null:
return {}
if not shown.has("hp") and s.player.hp < s.player.max_hp:
return {"id": "hp", "text": TutorialLessons.TIP_HP, "action": ""}
if not shown.has("radar") and offscreen > 0:
return {"id": "radar", "text": TutorialLessons.TIP_RADAR, "action": ""}
if String(s.tutorial.current_lesson().get("id", "")) == "warden" and s.boss_rotation.any_boss_alive(s):
if not shown.has("decoy") and s.player.hp <= s.player.max_hp * 0.5 and s.player.drone_charge >= 1.0:
return {"id": "decoy", "text": TutorialLessons.TIP_DECOY, "action": "decoy"}
if not shown.has("heal") and s.player.hp <= s.player.max_hp * StoryDirector.TEACH_LOW_FRAC \
and s.player.still_heal_mult > 0.0:
return {"id": "heal", "text": TutorialLessons.TIP_HEAL, "action": ""}
return {}
func _update_objective_panel(delta: float) -> void:
if objective_panel == null or sim == null or sim.tutorial == null:
return
var blocked := (dialogue_box != null and dialogue_box.is_showing()) or _paused_for_menu
objective_panel.update_view(sim.tutorial.objective_view(sim), sim.boss_rotation.any_boss_alive(sim), blocked)
objective_panel.advance(delta)
if blocked:
return
var tip := tutorial_tip_for(sim, threat_radar.offscreen_count() if threat_radar != null else 0, _tips_shown)
if not tip.is_empty():
_tips_shown[String(tip["id"])] = true
objective_panel.show_tip(String(tip["text"]), String(tip["action"]))
# Replays and Story Board captures carry no UI notes (neither do they carry picks): open the stats
# sheet steps on the player's behalf so an unattended run never stalls on lessons 4 and 8.
func _drive_capture_ui() -> void:
if not (_capturing or _auto) or sim == null or sim.tutorial == null:
return
if sim.tutorial.current_step_kind() == "sheet":
sim.tutorial.note_ui(sim, "stats_sheet")
  • _process: after _stage_tutorial_hud() add _update_objective_panel(delta).
  • _physics_process: right after the _frozen early return, add _drive_capture_ui().
  • _open_levelup: before level_up.show_for(...) add level_up.configure_tutorial(sim.tutorial.panel_options() if (_mode == "tutorial" and sim.tutorial != null) else {}) and before it _drive_capture_ui().
  • _toggle_pause: before add_child(pause_menu): pause_menu.hidden_entries = tutorial_pause_hidden(_mode, sim); after the other connects: pause_menu.stats_opened.connect(_on_stats_sheet_opened).
  • _input Stage 3: first line if tutorial_hotkey_blocked(event, _mode): return; replace the X block with if event is InputEventJoypadButton and event.pressed and event.button_index == JOY_BUTTON_X: / if debug_overlay_hotkey(event, BuildConfig.dev_tools()): debug_overlay.toggle() / return (update the comment: dev builds only, spec §4).
  • _check_codex_encounters: first line if _mode == "tutorial": return # no NEW ENEMY cards in the tutorial (spec §4); Survival shows them later.
  • Step 4: Run -gselect=test_main, test_pause_menu, test_level_up_panel, test_objective_panel: PASS. Headless boot smoke: godot --headless --path . --quit-after 600 2>&1 | grep -c "SCRIPT ERROR" prints 0. Gate: test-count guard OK.
  • Step 5: Commit feat(tutorial): objective panel, tips, locked menus and hotkeys, starter loadout.

Task 11: StoryAutoPlayer and story_trace complete every lesson

Section titled “Task 11: StoryAutoPlayer and story_trace complete every lesson”

Files:

  • Modify: tools/story_auto/story_auto_player.gd, tools/story_auto/story_trace.gd
  • Test: modify tests/test_story_auto_player.gd

Interfaces:

  • Consumes: TutorialLessons.target_pos, current_step_kind, current_step, levelup_allowed, note_ui, enemy_within, all_features, owner_of, lesson events.

  • Produces: StoryAutoPlayer.pick_for(sim: Sim, ids: Array) -> String; trace output section === LESSONS === and exit code 1 when lessons are out of order, incomplete, or a feature unlocks outside its own lesson.

  • Step 1: Write the failing tests (append to tests/test_story_auto_player.gd):

func _tut() -> Sim:
var sim := Sim.new(1234, _content())
sim.enable_story(StoryLoader.load_from_path("res://data/story.json"), false, false, true)
return sim
func test_flies_to_the_lesson_target() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "wake", true)
var p := StoryAutoPlayer.new()
var dir := p.compute_move_for(sim, sim.player)
assert_gt(dir.dot((Vector2(0, -410) - sim.player.pos).normalized()), 0.99, "straight at Echo")
func test_warps_on_the_warp_step_only_with_an_enemy_close() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "warp", true, 1)
var p := StoryAutoPlayer.new()
assert_false(p.wants_dash(sim, sim.player), "nothing close yet")
sim.spawn_story_enemy("elite", sim.player.pos + Vector2(120, 0))
assert_true(p.wants_dash(sim, sim.player))
func test_sends_the_decoy_on_the_decoy_step() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "decoy", true)
assert_true(StoryAutoPlayer.new().wants_decoy(sim, sim.player))
func test_takes_the_crystal_card_on_the_crystal_step() -> void:
var sim := _tut()
sim.tutorial.skip_to(sim, "crystals", true, 1)
var p := StoryAutoPlayer.new()
assert_eq(p.pick_for(sim, ["armor", TutorialLessons.CRYSTAL_CARD, "max_hp"]), TutorialLessons.CRYSTAL_CARD)
  • Step 2: Run -gselect=test_story_auto_player: FAIL.
  • Step 3: Implement StoryAutoPlayer additions:
func compute_move_for(sim: Sim, pilot: PlayerState) -> Vector2:
if sim.story == null:
return super.compute_move_for(sim, pilot) # not a story run: behave exactly as before
if sim.tutorial != null:
var tgt: Variant = sim.tutorial.target_pos(sim)
if tgt is Vector2:
return _steer(pilot, tgt) # Echo / a weapon on the ground
# Nova only works up close: close on the nearest enemy instead of kiting away from it.
if String(sim.tutorial.current_step().get("weapon", "")) == "nova" \
and sim.tutorial.current_step_kind() == "kills" and sim.enemies.count > 0:
return _steer(pilot, sim.enemies.pos[sim._nearest_enemy_to(pilot.pos)])
# ... existing body (boss / fight / navigate) unchanged ...
func wants_dash(sim: Sim, pilot: PlayerState) -> bool:
if sim.tutorial != null and sim.tutorial.current_step_kind() == "warp":
return TutorialLessons.enemy_within(sim, TutorialLessons.WARP_CLOSE_RADIUS)
return super.wants_dash(sim, pilot)
func wants_decoy(sim: Sim, pilot: PlayerState) -> bool:
if sim.tutorial != null and sim.tutorial.current_step_kind() == "decoy":
return true
return super.wants_decoy(sim, pilot)
func pick_for(sim: Sim, ids: Array) -> String:
if sim.tutorial != null and sim.tutorial.current_step_kind() == "pick_crystal":
for id in ids:
if String(id).begins_with("crystals:"):
return String(id)
return pick_upgrade(ids)
  • Step 4: Update story_trace.gd. The class is extended so the auto var is typed StoryAutoPlayer. In the main loop, before _consume_levelups, call _drive_ui(sim); collect lesson events; record first-unlock lessons each tick. Replace _consume_levelups and add the helpers:
# The player opens Ship stats: on lesson 4 inside the level-up panel (Y), on lesson 8 from the pause
# menu. The trace has no UI, so it does what the player does.
func _drive_ui(sim: Sim) -> void:
if sim.tutorial != null and sim.tutorial.current_step_kind() == "sheet":
sim.tutorial.note_ui(sim, "stats_sheet")
func _consume_levelups(sim: Sim, auto: StoryAutoPlayer) -> int:
if sim.tutorial != null and not sim.tutorial.levelup_allowed():
return 0 # banked, exactly like main.gd's _levelup_gate_open()
var taken := 0
while sim.player.pending_levelups > 0 and taken < 10:
if sim.tutorial != null and not sim.tutorial.levelup_allowed():
break # a pick just finished the step: the surplus waits, like main's chain check
_drive_ui(sim)
var ids: Array = sim.upgrade_system.roll_upgrade_choices(sim, sim.player, 3)
if ids.is_empty():
sim.player.pending_levelups = 0
break
sim.upgrade_system.apply_upgrade(sim, sim.player, auto.pick_for(sim, ids))
taken += 1
return taken

In the loop, alongside the say rows:

for ev in sim.story.events:
if String(ev.get("kind", "")) == "lesson" and String(ev.get("event", "")) in ["start", "done"]:
lesson_rows.append({"tick": t, "event": String(ev["event"]), "index": int(ev["index"]), "id": String(ev["id"])})
if sim.tutorial != null:
for f in TutorialLessons.all_features():
if not unlock_at.has(f) and sim.tutorial.is_unlocked(f):
unlock_at[f] = sim.tutorial.index

After the beat table, print and check:

var lesson_ok := true
if sim.tutorial != null:
print("=== LESSONS ===")
var done_order: Array = []
for r in lesson_rows:
print("%-6s %-10s #%-2d %6d %7.1f" % [r["event"], r["id"], int(r["index"]) + 1, r["tick"], r["tick"] / 60.0])
if r["event"] == "done":
done_order.append(int(r["index"]))
var want: Array = range(TutorialLessons.LESSONS.size())
if done_order != want:
lesson_ok = false
print("LESSONS OUT OF ORDER OR INCOMPLETE: %s" % [done_order])
for f in unlock_at:
if int(unlock_at[f]) != TutorialLessons.owner_of(f):
lesson_ok = false
print("UNLOCK OUTSIDE ITS LESSON: %s unlocked in lesson %d, owned by lesson %d"
% [f, int(unlock_at[f]) + 1, TutorialLessons.owner_of(f) + 1])
print("lessons: %s" % ("ok" if lesson_ok else "FAILED"))
quit(0 if finished and lesson_ok else 1)

Remember the finish check: lesson 10’s last step is reach_room ember_portal, which completes on the same tick tutorial_complete is said, so its done event is recorded before the loop breaks.

  • Step 5: Run the tests -gselect=test_story_auto_player: PASS. Run the trace: godot --headless --path . -s res://tools/story_auto/story_trace.gd -- --mode=tutorial must exit 0, print finished: true, list all ten lessons start/done in order, and print lessons: ok. Also -- --mode=story still finishes (Story mode untouched). If the tutorial trace fails, diagnose with superpowers:systematic-debugging before touching tuning: the likely knobs are each step’s spawn.alive/type in TutorialLessons.LESSONS (for example Nova kills if the Blaster steals them) and nothing else. Record the final per-lesson sim times in the commit message.
  • Step 6: Gate test-count guard OK, then Commit test(tools): story trace drives and checks every tutorial lesson.

Task 12: Device-scale captures, final verification and docs

Section titled “Task 12: Device-scale captures, final verification and docs”

Files:

  • Create: tools/tutorial_capture/lesson_shots.gd, tools/tutorial_capture/lesson_shots.tscn (DEV ONLY, never referenced by game code or tests)
  • Modify: CLAUDE.md (Current status: replace the 2026-08-19 “Tutorial friendliness pass” bullet IN PLACE; update the “Open items flagged for Chris” tutorial item), docs/RELEASE-0.1.md (entry under §2 RC-blockers + a numbered script in §3 owed playtests), docs/architecture/meta-and-story.md (new “Tutorial lesson track” section)

Interfaces:

  • Consumes: main.sim.tutorial.skip_to, main.sim.story_director.say, main.dialogue_box.consume, main._update_objective_panel.

  • Produces: reports/tutorial-lessons/NN-<id>-dialogue.png and NN-<id>-objective.png (reports/ is gitignored).

  • Step 1: Write the capture tool. Model it on tools/ui_sweep/sweep.gd’s _frames/_capture SubViewport helpers (copy them), at SIZE := Vector2i(1280, 720), with a synthetic MetaState (never saved; tutorial_done = false; every enemy marked seen). Flow per lesson i (0..9), after main._on_mode_chosen("tutorial"):

for i in TutorialLessons.LESSONS.size():
var id := String(TutorialLessons.LESSONS[i]["id"])
var intro := String(TutorialLessons.LESSONS[i]["intro"])
main.dialogue_box.clear()
main.sim.tutorial.skip_to(main.sim, id, true, 0, true)
main.sim.story.seen_dialogue.erase(intro) # let it be said again, for the shot
main.sim.story.events.clear()
main.sim.story_director.say(main.sim, intro)
main.dialogue_box.consume(main.sim.story.events)
await _frames(SETTLE_FRAMES)
_capture("res://reports/tutorial-lessons/%02d-%s-dialogue.png" % [i + 1, id])
main.dialogue_box.clear()
main.sim.story.mark_read(intro)
main._update_objective_panel(1.0 / 60.0)
await _frames(SETTLE_FRAMES)
_capture("res://reports/tutorial-lessons/%02d-%s-objective.png" % [i + 1, id])
print("[lesson_shots] DONE")

Also capture lesson 4 with the level-up panel open (main.sim.player.pending_levelups = 1, skip to stats step 1, main._physics_process(1.0/60.0), wait) as 04-stats-levelup.png, and lesson 8’s crystal offer the same way as 08-crystals-levelup.png, so the tag explainer and the sheet-first hint are visible.

  • Step 2: Run it windowed, foreground, and immediately guard project.godot: godot --path . --rendering-method mobile --always-on-top res://tools/tutorial_capture/lesson_shots.tscn; git diff --quiet project.godot || git checkout -- project.godot Confirm [lesson_shots] DONE printed and 22 PNGs exist.
  • Step 3: Read every PNG (Read tool) and write down, per lesson: who is speaking is obvious (emblem + name + role, correct side); the objective panel reads LESSON n OF 10 · TITLE; the prompt glyph is right for the style; nothing overlaps (objective panel vs dialogue, HP bar, boss bar, level-up strip); no text is clipped at 1280x720. Fix anything wrong in its owning file (re-run that task’s tests) before continuing. Repeat once with ButtonPrompt.force_style = "key" set at the top of the tool, and once with "playstation", to see keycaps and PS glyphs.
  • Step 4: Full verification.
    • bash scripts/check-test-count.sh → test-count guard OK.
    • -gselect=test_determinism_survival and -gselect=test_determinism_coop → PASS, no re-pin (read the pinned assertions to confirm they are the values on main).
    • godot --headless --path . -s res://tools/story_auto/story_trace.gd -- --mode=tutorial → exit 0, lessons: ok; -- --mode=story → exit 0.
    • scripts/soak.sh --runs=4 --ticks=7200 --ships=cobalt --coop=both → clean (Survival untouched).
    • zsh scripts/ui-sweep.sh (foreground). Expect the two known pre-existing failures (bestiary.png, hud_in_run.png, see NEXT_TASKS.md); anything else that differs must be explained (a dialogue shot now shows an emblem instead of an initial is expected; re-bless only after eyeballing tools/ui_sweep/out/). A/B any surprise against git worktree add --detach <tmp> main (copy export_presets.cfg in).
    • Headless boot smoke: godot --headless --path . --quit-after 600 2>&1 | grep -c "SCRIPT ERROR" → 0.
  • Step 5: Docs.
    • docs/architecture/meta-and-story.md: add “Tutorial lesson track (2026-09-24)”: sim.tutorial ownership; the ten lessons (link to the table in sim/tutorial_lessons.gd); read-based gating via StoryState.promote_read and why it equals “dismissed”; the hooks (damage_enemy, apply_upgrade, note_ui); owned rooms/NPCs; how to add a lesson (table row + intro beat + a step kind if new + story_trace check); the two harness rules (UI notes not in replays; drive “sheet” steps).
    • CLAUDE.md Current status: replace the 2026-08-19 tutorial bullet in place with one bullet: tutorial rebuilt as a 10-lesson track (merged status, no BUILD bump / not on device yet), headline mechanics, trace result with per-lesson sim times, determinism unchanged, owed human playtest. Keep it short; do not append a new dated bullet.
    • docs/RELEASE-0.1.md: §2 entry “Tutorial lesson track” with commit range; §3 a playtest script: play the tutorial on Apple TV with a controller, on the Mac with keyboard AND with a controller (prompts must switch), on iPhone (touch words); for each lesson note whether you knew what to do and who was talking; time each lesson; confirm Shop/Ship/Bestiary are absent from pause, C/Y/V do nothing, no NEW ENEMY cards, the Warden tips appear only when you are low.
  • Step 6: Final gate test-count guard OK, then Commit docs(tutorial): lesson track captures, architecture note, RC entry.

Spec coverage (spec section → task):

Spec requirement Task
§1 ten lessons, one unlock + one checked objective each 1 (table), 3 (engine), 4 (hit/kill/pick/sheet detection)
§1 L1 fly to Echo 3 (meet, NPC gating)
§1 L2 fly over Blaster → destroy 5 3 (pickup), 4 (pickup gate, blaster kill attribution)
§1 L3 collect XP → pick (tag explainer) 3 (level), 4 (pick), 9 (tag note), 10 (config)
§1 L4 open stats sheet → pick 4 (note_ui), 9 (sheet_first, sheet_opened), 10 (routing)
§1 L5 hit 3 with Rift Bolt 4 (damage_context == "aim")
§1 L6 warp with an enemy close (chargers spawned) 3 (warp, elite spawn)
§1 L7 decoy (meter full) → survive 10 s 1 (_on_unlocked fills meter), 3 (decoy, survive)
§1 L8 guaranteed LONG-TERM crystal card → see its bar 4 (forced_offer, pick_crystal), 9/10 (pause stats_opened)
§1 L9 fly over Nova → destroy 8 with Nova 3 (meet echo_gift, pickup), 4 (nova kills)
§1 L10 defeat the Warden, tips only when struggling, hand-off 3 (boss, reach_room ember_portal), 10 (tips); hand-off unchanged (main.gd:1323)
§1 Pulse gift removed; Orbit room stays optional 3 (ember_a1 owned: ember_e1 never runs); 4 (Orbit pickup never gated)
§1 advance on completion + ~1.5 s done beat 3 (DONE_HOLD, complete_step)
§1 spawns scripted per lesson 3 (_spawn_for, owned rooms)
§1 surplus level-ups held; panel locked until L3 1 (levelup_allowed), 4 (synchronous pick completion), 5 (_levelup_gate_open)
§2 objective panel: header, glyph for device in use, progress, tick, no overlap 8, 10
§3 dialogue story only, 1-2 lines 2 (copy + lint)
§3 name + role + drawn emblem; allies left, enemies right 2 (data), 7 (render)
§3 villains say who they are 2
§3 prompts by last-used device as drawn glyphs; no RB/LB 6, 8
§3 gates open on read, not queue 1 (tracking), 5 (flip + test)
§4 locked until lesson: dock, aim, warp, decoy + Bay, level-up, stats sheet, crystal cards, thresholds, Nova 5 (aim/warp/decoy/Bay/dock/level-up), 4 (crystals/thresholds/Nova pickup), 9/10 (stats sheet)
§4 locked all tutorial: pause Shop/Ship/Bestiary, C, Y/V, NEW ENEMY cards, co-op, dimension, shop weapons 10 (menus, hotkeys, codex); co-op main.gd:668, dimension sim.gd:2654, shop weapons main.gd:752 already hold
§4 controller X overlay dev-only everywhere 10
§4 ignore saved perk, decoy type, loadout 10
§4 radar explained as a TIP when an enemy is off-screen 10 (offscreen_count, tip)
§5 every tutorial line rewritten, no em dashes 2, 5 (dead beats removed)
Testing: trace finishes, lessons in order, no out-of-lesson unlocks 11
Testing: GUT objective detection, gates before/after, read gating, emblem mapping, device prompts 3, 4, 5, 6, 7
Testing: determinism unaffected 4, 5, 12
Testing: 1280x720 captures per lesson; Chris playtests 12

Placeholder scan: every step names exact files and code; the only open-ended step is Task 11 Step 5’s tuning fallback, which names the only permitted knobs. Task 9’s pause-menu test notes one conditional (bind_stats pilot arg), resolved by reading the helper.

Name consistency check: skip_to(sim, lesson_id, read, step_i, place), is_unlocked, tutorial_locks, levelup_allowed, note_ui(sim, "stats_sheet"), on_enemy_hit(context, hp_before, hp_after), on_upgrade_applied(sim, id), objective_view(sim), panel_options(), target_pos(sim), enemy_within(sim, r), configure_tutorial(opts), sheet_opened, stats_opened, hidden_entries, ObjectivePanel.update_view(view, boss_alive, blocked) are used identically in every task that produces or consumes them. Lesson ids (wake, blaster, levelup, stats, riftbolt, warp, decoy, crystals, nova, warden) and feature keys match the table in Task 1.

Known limits (not gaps in the spec): UI notes and picks are not in the replay stream (existing limitation, handled by _drive_capture_ui). Feel and wall-clock pacing still need Chris’s playtest (Task 12 script).