Tutorial lesson track — implementation plan (2026-09-24)
Tutorial Lesson Track Implementation Plan
Section titled “Tutorial Lesson Track Implementation Plan”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
Global Constraints
Section titled “Global Constraints”- Work on a feature branch
tutorial-lesson-trackoffmain; 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.shmust end with a line startingtest-count guard OK(the spec calls this “guard OK”). - After adding any new
class_namefile, rungodot --headless --path . --importonce 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(-gtestdoes not narrow;-gselectdoes). /simstays pure: every sim fileextends 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_upgradeplus the newTutorialLessons.note_ui(sim, event), which, likeapply_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_survivaland-gselect=test_determinism_coopboth 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 bareenemies.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 drainpending_levelupsinline while the level-up panel is locked. story_trace.gd+StoryAutoPlayerare 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(neverbash), windowed, foreground. It already fails on unmodifiedmain(bestiary.pngstale,hud_in_run.pngflaky, seeNEXT_TASKS.md); A/B against a detachedmainworktree before blaming this branch. - Any WINDOWED
godot --path .run can corruptproject.godot: rungit diff --quiet project.godot || git checkout -- project.godotright after every windowed run. Never commitproject.godotchanges. - 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 Structure
Section titled “File Structure”| 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 |
Lesson plan (reference for every task)
Section titled “Lesson plan (reference for every task)”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_roomsteps 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) andember_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(aftermark_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_storyat 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_lockedis 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 unlessenable_story(..., tutorial_cap=true)),Sim.tutorial_locks(feature: String) -> bool.TutorialLessonsconstantsF_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; varsindex,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/saydo not exist):godot --headless --path . -s res://addons/gut/gut_cmdln.gd -gdir=res://tests -ginclude_subdirs -gselect=test_tutorial_lessons -gexitand the same with-gselect=test_story_read_gating. -
Step 3: Implement read tracking. In
sim/story_state.gd, add underseen_dialogue:
var read_dialogue: Dictionary = {} # dialogue_id -> true once the player has finished reading itvar said_pending: Array[String] = [] # said this tick; become READ at the top of the next tickand 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 andskip_to; the per-tick engine arrives in Task 3):
class_name TutorialLessonsextends 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 Bayconst F_CRYSTALS := "crystals" # crystal cards + crystal threshold upgrades/evolutionsconst 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 beginsconst SPAWN_GAP := 0.8 # seconds between scripted lesson spawnsconst SPAWN_MIN_DIST := 220.0 # a scripted spawn never lands closer than this to the playerconst WARP_CLOSE_RADIUS := 260.0 # "an enemy is close" for the warp lessonconst 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 freelyconst OWNED_ROOMS := ["ember_entry", "ember_a1", "ember_a2", "ember_safe1"] # no room beat/encounter here in the tutorialconst OWNED_NPCS := ["echo_intro", "echo_gift"] # met only on their lesson's stepconst LESSON_WEAPONS := ["blaster", "nova"] # picked up only on their lesson's stepconst 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 lessonvar progress: int = 0 # hits/kills counted on the current stepvar _intro_read := false # the current lesson's intro beat has been read (refreshed each tick)var _started := falsevar _done_timer := 0.0 # > 0 while a finished lesson holds its tickvar _survive_t := 0.0var _spawn_t := 0.0var _spawn_seq := 0var _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-gselectruns above: PASS. Thenbash 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(_sayevent dict) - Test: create
tests/test_tutorial_copy.gd; modifytests/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); everysayevent carriesrole,emblem,side. New beatstut_levelup,tut_stats,tut_riftbolt,tut_warp,tut_decoy,tut_crystals,tut_warden(allspeaker: 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] = trueAppend 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 SpeakerEmblemextends 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_copyand-gselect=test_story_say_id(after--import): FAIL (fields and beats missing). -
Step 3: Edit
data/story.jsonspeakers 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
sayevent inStoryDirector._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(update35-45, delete_check_tutorial_teaching86-137 and_deferred_levelups/_idle_*/MOVE_NUDGE_AFTER/IDLE_EPSILON_SQ,_check_npcs149-170,_enter_room180-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}insim.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, nowTutorialLessons.enemy_within), and the now-unused_deferred_levelups,_idle_t,_idle_pos,WARP_TEACH_RADIUS,MOVE_NUDGE_AFTER,IDLE_EPSILON_SQ. KeepTEACH_LOW_FRAC(Task 10’s heal tip reads it). -
_check_npcs: after thetalked_npcscheck addif 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: computevar owned := sim.tutorial != null and sim.tutorial.owns_room(room_id)at the top; change the on_enter branch toelif not owned and room.has("on_enter") and room["on_enter"] != null:; addand not ownedto 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/*.gdand 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_upgrade263-283),sim/sim.gd(_eval_thresholds936,_collect_weapon_pickups1210) - Test: create
tests/test_tutorial_lesson_hooks.gd
Interfaces:
-
Consumes:
Sim.damage_context(weapon id /"aim"/"drone", stamped per shot byProjPool.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.gddamage_enemy: immediately after the wyrm/non-wyrmif/elsethat writessim.enemies.data(ends line 202), add:
if sim.tutorial != null: sim.tutorial.on_enemy_hit(sim.damage_context, before, before - dealt)sim/upgrade_system.gdapply_upgrade: after the finalpilot.pending_levelups = maxi(...)addif sim.tutorial != null: sim.tutorial.on_upgrade_applied(sim, id)(two lines).sim/upgrade_system.gdsurvival branch: replace the start of theif 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 8sim/sim.gd_collect_weapon_pickups: first line inside the loop aftervar 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_survivaland-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→ renametest_tutorial_locked_false_once_read, replacemark_dialogue_seen("some_beat")withmark_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: replacesim.story.mark_dialogue_seen("aim_intro")withsim.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 withsim.tutorial.skip_to(sim, "warp", true, 1). -
tests/test_tutorial_progression.gd:test_drone_bay_deploy_respects_the_decoy_gate: preconditionassert_true(sim.tutorial_locks(TutorialLessons.F_DECOY)); unlock withsim.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 becomessim.tutorial.skip_to(sim, "riftbolt", true)(message “the Rift Bolt lesson alone does not unlock the warp”), second becomessim.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)thensim.player.drone_charge = 0.0(the lesson fills it; this test is about accrual), then the 5 ticks and the sameassert_gt. -
tests/test_weapon_panel.gd(79-100): rename to..._before_the_rift_bolt_lesson/..._once_the_rift_bolt_lesson_is_read; the second replacesmark_dialogue_seen("aim_intro")withsim.tutorial.skip_to(sim, "riftbolt", true). -
tests/test_main.gd:test_tutorial_docks_are_staged_by_their_own_teaching_beat→..._by_their_own_lesson: replacemark_dialogue_seen("ember_intro")withm.sim.tutorial.skip_to(m.sim, "blaster", true)andmark_dialogue_seen("echo_decoy")withm.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, onem._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(), thenm.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.gdtest_tutorial_beats_and_dependent_content_are_present_in_real_story_json: replace the id list with everyTutorialLessons.LESSONSintro id (loop over the table) and keep theecho_introreward 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 beatsaim_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_dialogueandember_e2.clear_dialogue(remove the keys). Decision: these are alltutorial: 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_healstay (Story mode’s_check_teachingstill references them).- Update the stale comments in
ui/touch_controls.gd:125,ui/hud.gd:33andui/dialogue_box.gd:53that 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 toolsreturns 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_style57-67),input/input_router.gd(_ready43),ui/dialogue_box.gd(CONTROL_NAMES/control_scheme192-211) - Test: modify
tests/test_button_prompt.gd,tests/test_dialogue_box.gd
Interfaces:
-
Produces:
ButtonGlyph.buttonalso 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
ButtonPromptadditions:
# 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 gand 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. Inpreferred_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, dispatchlb/rb/menuto_draw_pill(s)andls/rsto_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.gdset the controller entries to"warp": "the warp button","decoy": "the decoy button", and replacecontrol_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_panelandtest_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(_readyportrait 105-124,consume172-184,_layout_panel164-178,_show_current320-346) - Test: create
tests/test_speaker_emblem.gd; modifytests/test_dialogue_box.gd
Interfaces:
-
Consumes:
sayevents withemblem,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 SpeakerEmblemextends 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
_initialLabel (vars +_readyblock) with_emblem := SpeakerEmblem.new()sizedVector2(PORTRAIT, PORTRAIT)inside_portrait_bgatVector2.ZERO; add_role_label(mono font 16, right-aligned,mouse_filterignore) as a child of_panel; addvar _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:
- Replace the
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_initiallines 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.
Task 8: The objective panel
Section titled “Task 8: The objective panel”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; constsTOP_Y := 48.0,TOP_Y_BOSS := 118.0,WIDTH := 560.0,TIP_SECONDS := 6.0; seamsheader_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 ObjectivePanelextends 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.0const TOP_Y_BOSS := 118.0const WIDTH := 560.0const TIP_SECONDS := 6.0const HEADER_FONT := 14const TEXT_FONT := 20const 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: GlassPanelvar _header: Labelvar _text: Labelvar _prompt_box: HBoxContainervar _progress: Labelvar _tick: TickMarkvar _tip: Labelvar _tip_t := 0.0var _view: Dictionary = {}var _blocked := falsevar _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.textfunc objective_text() -> String: return _text.textfunc progress_text() -> String: return _progress.text if _progress.visible else ""func is_ticked() -> bool: return _tick.visiblefunc tip_text() -> String: return _tip.text if _tip.visible else ""func is_shown() -> bool: return _glass.visiblefunc 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 nullfunc 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(_ready76-137,show_for139-194,toggle_sheet225-232,_rebuild_hint247-258,_confirm465-469,_input506-520),ui/pause_menu.gd(signals 11-18, button adds 128-133,_open_stats200-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_openedsignal andconfigure_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 BEFOREadd_child; values"stats","ship","shop","bestiary"),PauseMenu.stats_openedsignal.
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;_openis 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: addsignal sheet_openedand
# 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: passui/level_up_panel.gd: varsvar _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_TEXTcolour, centre-bottom anchored atoffset_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 = falsenext to_accepting = false.toggle_sheet: first lineif not _stats_enabled and not _sheet.is_open(): return; after_sheet.open()add_sheet_seen = trueandsheet_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: buildvar parts := [["a", "CHOOSE", "ENTER"]]thenif _stats_enabled: parts.append(["y", "SHIP STATS", "TAB"]), passpartstoButtonPrompt.row._inputY/TAB branch: unchanged (toggle_sheet now self-guards).ui/pause_menu.gd: addsignal stats_openedandvar hidden_entries: Array[String] = [] # set before add_child (tutorial hides Ship/Shop/Bestiary, and Ship stats until its lesson); wrap each of the fourbox.add_child(_make_btn(...))lines for Ship stats / Ship / Shop / Bestiary inif not hidden_entries.has("<key>"):with keysstats,ship,shop,bestiary; in_open_statsafter_sheet.open()addstats_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.
Task 10: Wire it all in main.gd
Section titled “Task 10: Wire it all in main.gd”Files:
- Modify:
main.gd(meta block 717-733;_new_runneardialogue_box1052-1060;_physics_processstart ~1203;_process~1707;_open_levelup1776-1811;_toggle_pause467-482;_input2107-2137;_check_codex_encounters2169),ui/threat_radar.gd(afterupdate_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_healfor 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 underStoryDirector.TEACH_LOW_FRACfor the heal tip (only withstill_heal_mult > 0). -
Decision: UI notes are not in the replay stream (neither are picks). When
_capturingor_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 itspreload("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 byapply_baseandapply_to(ship, ...); wrap the decoy assignment andset_loadoutinif _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, afterdialogue_box/level_upcreation: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_frozenearly return, add_drive_capture_ui()._open_levelup: beforelevel_up.show_for(...)addlevel_up.configure_tutorial(sim.tutorial.panel_options() if (_mode == "tutorial" and sim.tutorial != null) else {})and before it_drive_capture_ui()._toggle_pause: beforeadd_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)._inputStage 3: first lineif tutorial_hotkey_blocked(event, _mode): return; replace the X block withif 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 lineif _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"prints0. 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
StoryAutoPlayeradditions:
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 theautovar is typedStoryAutoPlayer. In the main loop, before_consume_levelups, call_drive_ui(sim); collect lesson events; record first-unlock lessons each tick. Replace_consume_levelupsand 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 takenIn 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.indexAfter 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=tutorialmust exit 0, printfinished: true, list all ten lessons start/done in order, and printlessons: ok. Also-- --mode=storystill finishes (Story mode untouched). If the tutorial trace fails, diagnose withsuperpowers:systematic-debuggingbefore touching tuning: the likely knobs are each step’sspawn.alive/typeinTutorialLessons.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 Committest(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.pngandNN-<id>-objective.png(reports/ is gitignored). -
Step 1: Write the capture tool. Model it on
tools/ui_sweep/sweep.gd’s_frames/_captureSubViewport helpers (copy them), atSIZE := Vector2i(1280, 720), with a syntheticMetaState(never saved;tutorial_done = false; every enemy marked seen). Flow per lessoni(0..9), aftermain._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.godotConfirm[lesson_shots] DONEprinted 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 withButtonPrompt.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_survivaland-gselect=test_determinism_coop→ PASS, no re-pin (read the pinned assertions to confirm they are the values onmain).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, seeNEXT_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 eyeballingtools/ui_sweep/out/). A/B any surprise againstgit worktree add --detach <tmp> main(copyexport_presets.cfgin).- 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.tutorialownership; the ten lessons (link to the table insim/tutorial_lessons.gd); read-based gating viaStoryState.promote_readand 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.mdCurrent 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 Commitdocs(tutorial): lesson track captures, architecture note, RC entry.
Self-review
Section titled “Self-review”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).