shrike/onboarding.py¶

Part of SHRIKE.

  1"""The staged first three runs: the HUD subtraction schedule and the safe arrival.
  2
  3Section 10 of the design fixes onboarding as subtraction rather than tuition.
  4The HUD is complete from the first frame of run 3 and is *narrowed* before
  5that, so nothing the player learns has to be unlearned and no tutorial box ever
  6covers the screen:
  7
  8* **Run 1** shows the gauges, the resource strip and the signature border, plus
  9  the diegetic and accessibility elements the HUD never subtracts (the spool
 10  ring, the interact fill, the damage edge, subtitles and stamps). No ammo, no
 11  chart, no notoriety.
 12* **Run 2** adds the ammunition counter, which is the one readout run one has
 13  nothing to put in. Ballistic weapons start appearing in depot stock the same
 14  run.
 15* **Run 3** adds silent running, the Refinery and trading-post conversion, and
 16  the notoriety trail. After run 3 nothing is added, and after run 2 nothing
 17  tactical is.
 18
 19Subtraction has one hard floor, bought at the cost of a whole blind playtest:
 20**a resource the run can charge or credit is never subtracted.** Run one quotes
 21five scrap for a hull patch and fifteen fuel for a jump, and drains air the
 22whole time; hiding those three numbers left every price in the game quoted in a
 23currency with no readout anywhere on the screen.
 24
 25Teaching happens on the HUD's control-card channel, never on the caption
 26queue. A card names the keys of the lesson in hand and stays up until the
 27player does that thing, at which point the next card replaces it. Combat
 28captions keep the bottom subtitle line to themselves, so a control the player
 29has not found yet cannot be scrolled away by a kill chime. A card whose verb
 30the sector never offers (nothing to mine, no breach to patch) gives up after
 31:data:`CARD_TIMEOUT_S` rather than stalling the script.
 32
 33Neither channel is the HUD's affordance line or its controls bar. Those are
 34permanent fixtures the run scene drives, on for every run and every profile,
 35and they are what a pilot past run three still has: the script stops teaching,
 36so the game has to keep naming its verbs by itself.
 37
 38Two kinds of teaching hang off that one channel:
 39
 40* **The script** is the fixed run-one to run-three sequence above, played in
 41  order and paced by the clock.
 42* **The lessons** in :data:`LESSON_CARDS` are answers to something that has
 43  just happened, whatever run it happens on: a hold worth spending, a
 44  Refinery worth feeding, a warp key pressed with nowhere to go. A lesson is
 45  queued behind whatever the script is holding rather than shoving it aside,
 46  and is taught once per profile.
 47
 48:data:`ARCHETYPE_TAGS` is the other half of the same promise. The first time a
 49profile sees an archetype the enemy wears its name and its job for
 50:data:`SIGHTING_TAG_HOLD_S`, floating beside it, so nothing that kills you was
 51ever anonymous. The seen set lives in the profile's ``codex["enemies"]``.
 52
 53Run 1 also gets one scripted, survivable Shrike arrival: the tank is topped up
 54first so fleeing is always affordable, then the meter is driven straight to
 55lock so the klaxon, the ladder and the lockout are learned in the body rather
 56than read in a tooltip.
 57
 58:class:`Onboarding` is the pure schedule and answers every "is this unlocked
 59yet" question the run scene asks. :class:`OnboardingDirector` is the node that
 60applies it to a live run.
 61"""
 62
 63from __future__ import annotations
 64
 65from dataclasses import dataclass
 66
 67from simvx.core import Input, Node, Signal, Vec2
 68
 69from . import balance
 70from .hud import ALL_HUD_ELEMENTS, COLOUR_ENEMY_TAG, MINIMAL_HUD_ELEMENTS, Hud, HudElements
 71from .power import iter_tree_nodes
 72from .runtime import Groups, Services, gamepad_aim_input
 73
 74# ============================================================================
 75# The schedule
 76# ============================================================================
 77
 78#: Runs the schedule covers. From this run on the game is shown whole.
 79STAGED_RUNS = 3
 80
 81#: What run 2 adds: the one readout run one has no use for.
 82#:
 83#: It used to add the air, the fuel and the hold as well, and that was the
 84#: schedule's one real mistake. Run one charges five scrap for a patch, fifteen
 85#: fuel for a jump and drains air the whole time; subtracting those three
 86#: readouts left a pilot being quoted prices in currencies with no number
 87#: anywhere on the screen, and a second blind playtest stalled on exactly that.
 88#: The rule the schedule now obeys: **a resource the run can charge or credit
 89#: is never subtracted.** Ammunition survives here because run one is flown on
 90#: energy weapons and never sees a round.
 91RUN_2_ADDITIONS: frozenset[str] = frozenset({HudElements.AMMO})
 92
 93#: What run 3 adds: the posture, the conversion bet and the reputation.
 94RUN_3_ADDITIONS: frozenset[str] = frozenset({HudElements.SILENT_RUNNING, HudElements.NOTORIETY})
 95
 96#: Seconds into run 1 before the scripted arrival is provoked. Long enough to
 97#: have flown, mined and worked through the control cards, short enough that
 98#: the tutorial's climax lands while the pilot is still alive to learn from it.
 99SCRIPTED_ARRIVAL_AT_S = 70.0
100
101#: The sector run 1 usually meets the scripted arrival in. Advisory only: the
102#: clock is the gate, and the arrival lands in whatever sector the run occupies
103#: when it expires. A pilot who warped out before the deadline used to lose the
104#: arrival entirely, and with it run one's whole wave schedule.
105SCRIPTED_ARRIVAL_SECTOR = 1
106
107
108@dataclass(frozen=True)
109class ScriptedArrival:
110    """Run one's staged Shrike arrival.
111
112    *guaranteed_fuel* is topped into the tank the moment the ladder starts, so
113    the lesson is "the klaxon means leave" and never "the klaxon means die".
114    """
115
116    sector_index: int = SCRIPTED_ARRIVAL_SECTOR
117    at_elapsed_s: float = SCRIPTED_ARRIVAL_AT_S
118    guaranteed_fuel: float = balance.FUEL_MAX
119
120
121# ============================================================================
122# The control cards
123# ============================================================================
124
125
126class Verbs:
127    """The things a card can wait for the player to do."""
128
129    THRUST = "thrust"
130    AIM = "aim"
131    FIRE = "fire"
132    MINE = "mine"
133    GRAB = "grab"
134    SOLAR_WINGS = "solar_wings"
135    GENERATOR = "generator"
136    PATCH = "patch"
137    WARP = "warp"
138    CHART = "chart"
139
140
141#: Which actions satisfy each verb. Actions only, per the input contract, so a
142#: remapped key teaches itself without the script knowing about it.
143VERB_ACTIONS: dict[str, tuple[str, ...]] = {
144    Verbs.THRUST: ("thrust_up", "thrust_down", "thrust_left", "thrust_right"),
145    Verbs.AIM: ("aim_up", "aim_down", "aim_left", "aim_right"),
146    Verbs.FIRE: ("fire_primary",),
147    Verbs.MINE: ("mining_beam",),
148    # The grab and the patch are the same key at two hold lengths, so the grab
149    # card is satisfied by a tap and the patch card only by the one-second hold
150    # in VERB_HOLD_S. Teaching the tap first is deliberate: it is the verb the
151    # sector offers constantly, and it is the one that introduces the prompt.
152    Verbs.GRAB: ("interact",),
153    Verbs.SOLAR_WINGS: ("solar_wings",),
154    Verbs.GENERATOR: ("generator",),
155    Verbs.PATCH: ("interact",),
156    Verbs.WARP: ("warp_spool",),
157    Verbs.CHART: ("star_chart",),
158}
159
160#: Seconds a verb must be held before it counts as done. A tap proves you found
161#: the key; a hold proves you found what the key is for, which is the lesson for
162#: the beam and the patch channel.
163VERB_HOLD_S: dict[str, float] = {
164    Verbs.THRUST: 0.4,
165    Verbs.MINE: 1.0,
166    Verbs.PATCH: 1.0,
167}
168
169#: The verbs a card counts by what the run gained rather than by the key.
170#: Both of these are ways of getting paid, so both are satisfied by the pay:
171#: a beam held at empty space and an interact tapped at nothing have taught
172#: nobody anything, and the cards used to advance on both.
173OUTCOME_VERBS: frozenset[str] = frozenset({Verbs.MINE, Verbs.GRAB})
174
175#: Mouse travel, in pixels, that counts as having aimed. Keyboard pilots have no
176#: ``aim_*`` binding at all: the pointer is the aim, exactly as the ship reads it.
177AIM_MOUSE_TRAVEL_PX = 90.0
178#: Right-stick deflection that counts as aiming on a pad.
179AIM_STICK_DEFLECTION = 0.4
180
181#: How long a card with no verb stays up before the script moves on.
182CARD_HOLD_S = 7.0
183#: How long a card waits for a verb the sector may never offer (no vein in
184#: range, no breach to patch) before giving up. A lesson is not a gate.
185CARD_TIMEOUT_S = 45.0
186
187
188class Gates:
189    """Conditions a card waits for before it is worth showing at all.
190
191    A card that names a verb the hull cannot perform is worse than no card: the
192    blind playtest read "PATCH A BREACH: hold F" on an undamaged hull, held F
193    at nothing, and came away believing the key was broken.
194    """
195
196    #: The hull has at least one open breach.
197    BREACH = "breach"
198
199
200#: Seconds a gated card waits before it steps aside for the rest of the script.
201#: It keeps its place in the queue; it simply stops holding the door.
202GATE_RETRY_S = 6.0
203
204#: How finely the card's progress bar is quantised before it is redrawn. The
205#: HUD draws the bar itself; this only bounds how often a moving hold re-shows
206#: the card. A tap of the beam must show as a tap rather than as nothing at all.
207PROGRESS_TICKS = 8
208
209#: What the script says when a key is pressed that a later card teaches. The
210#: run honours the key regardless: this answers it rather than refusing it, so
211#: no bound key on the tutorial is ever silent.
212OUT_OF_ORDER_ANSWER = "FIRST: {text}"
213#: Seconds between two of those answers, so a held key is one sentence.
214OUT_OF_ORDER_COOLDOWN_S = 2.5
215
216
217@dataclass(frozen=True)
218class ControlCard:
219    """One lesson on the HUD's control channel.
220
221    *text* names the keys, *hint* is the smaller line under it, and *verb* is
222    what the card waits for: one of :class:`Verbs`, or empty for a card that
223    simply holds for *hold_s*. *at_s* is the earliest point in the run the card
224    may be shown, which is how a line lands on the beat it belongs to. *gate*
225    is a :class:`Gates` condition the run must satisfy before the card is worth
226    putting up at all.
227    """
228
229    text: str
230    verb: str = ""
231    hint: str = ""
232    at_s: float = 0.0
233    hold_s: float = CARD_HOLD_S
234    gate: str = ""
235
236
237#: Run one's script: fly, shoot, harvest, keep the lights on, then obey the
238#: klaxon. Every line names its key, because a key nobody names is a key nobody
239#: presses.
240RUN_1_CARDS: tuple[ControlCard, ...] = (
241    ControlCard("FLY: W A S D", verb=Verbs.THRUST, hint="you drift; the engines only ever push"),
242    ControlCard("AIM: the mouse", verb=Verbs.AIM, hint="the nose follows the pointer wherever you thrust"),
243    ControlCard("FIRE: left mouse button", verb=Verbs.FIRE, hint="the guns spend the capacitor"),
244    ControlCard(
245        "MINE: hold right mouse button on an orange-flecked rock",
246        verb=Verbs.MINE,
247        hint="the beam pays in scrap: the surface pays fast, the core pays double",
248    ),
249    ControlCard(
250        "GRAB: F beside the drifting motes or a wreck's open bay",
251        verb=Verbs.GRAB,
252        hint="the line above your ship always names the key and the verb in reach",
253    ),
254    ControlCard(
255        "SOLAR WINGS: X",
256        verb=Verbs.SOLAR_WINGS,
257        hint="deployed wings refill the capacitor from starlight, and they can be shot off",
258    ),
259    ControlCard(
260        "GENERATOR: Z",
261        verb=Verbs.GENERATOR,
262        hint="it refills the capacitor from fuel, and it is the loudest thing you own",
263    ),
264    ControlCard(
265        "CAPACITOR: the blue arc under the ship",
266        hint="the guns and the burner (SHIFT) spend it; X and Z put it back",
267    ),
268    ControlCard(
269        "PATCH A BREACH: hold F",
270        verb=Verbs.PATCH,
271        hint="a breach bleeds your air until it is patched",
272        gate=Gates.BREACH,
273    ),
274    ControlCard(
275        "THE KLAXON MEANS LEAVE: R spools the drive",
276        at_s=SCRIPTED_ARRIVAL_AT_S + 1.0,
277        hint="the tank is full; nothing out here is worth staying for",
278        hold_s=12.0,
279    ),
280)
281
282#: Run two's script: the chart is open now, and fuel is the leash on it.
283RUN_2_CARDS: tuple[ControlCard, ...] = (
284    ControlCard(
285        "STAR CHART: M",
286        verb=Verbs.CHART,
287        at_s=3.0,
288        hint="every route is priced in fuel, and the prices are honest",
289    ),
290    ControlCard(
291        # R first, M second: every arrival arms a lane for itself now, so the
292        # chip already names somewhere to go and the chart is the override
293        # rather than the prerequisite. Taught the other way round, the lesson
294        # sent a pilot to a screen to confirm a choice the drive had made.
295        "WARP OUT: R flies the route named under the ship; M picks another",
296        verb=Verbs.WARP,
297        at_s=12.0,
298        hint="the drive spools for five seconds and the jump costs that route's fuel price; tap R again to abort",
299    ),
300    ControlCard("OXYGEN: the green arc", at_s=32.0, hint="breaches bleed it; hold F to patch one"),
301)
302
303#: Run three's script: the conversion bet, and the noise it costs.
304RUN_3_CARDS: tuple[ControlCard, ...] = (
305    ControlCard("CONVERT AT A DEPOT: hold F", at_s=3.0, hint="scrap only becomes Cores where you convert it"),
306    ControlCard("GO DARK: C", at_s=20.0, hint="it cools the meter, and the guns go dark with it"),
307)
308
309
310# ============================================================================
311# Lessons: the cards a moment asks for rather than the clock
312# ============================================================================
313
314
315class Lessons:
316    """Ids for the cards that answer something that just happened."""
317
318    #: The hold is worth something, and nothing in the sector has said so.
319    SCRAP = "scrap_sinks"
320    #: The smelter is aboard and the hold is big enough to feed it.
321    REFINE = "refine"
322    #: The drive was spooled with nowhere to spool to.
323    WARP = "warp"
324
325
326#: Scrap in the hold that earns the sinks lesson: enough to be worth a plan.
327SCRAP_LESSON_SCRAP = 50.0
328#: Scrap in the hold that earns the refine lesson, once a Refinery is fitted.
329REFINE_LESSON_SCRAP = 100.0
330#: The catalogue id of the smelter, as ``modules.MODULE_CATALOGUE`` spells it.
331REFINERY_MODULE_ID = "refinery"
332#: Seconds between the director's sweeps for a lesson or a first sighting.
333LESSON_POLL_S = 0.5
334
335#: The lesson cards themselves. Every one names its key, like the script does.
336LESSON_CARDS: dict[str, ControlCard] = {
337    Lessons.SCRAP: ControlCard(
338        "SCRAP BUYS FUEL, AMMO AND MODULES",
339        hint="spend it at a trading post, or sell it there for Cores; carrying it home pays far less",
340    ),
341    Lessons.REFINE: ControlCard(
342        "REFINE THE HOLD: hold F",
343        hint="the smelter turns scrap into Cores wherever you float, and it is heard for sectors",
344    ),
345    Lessons.WARP: ControlCard(
346        "WARP NEEDS A DESTINATION: M picks it, R spools the drive",
347        hint="the chart quotes every route in fuel; tap R again to abort a spool",
348    ),
349}
350
351
352# ============================================================================
353# First sightings
354# ============================================================================
355
356#: ``archetype -> the three words that say what it does to you``. balance.py
357#: carries each archetype's design note, which is written for the balance pass
358#: rather than for a pilot reading it mid-dodge, so the pilot's version lives
359#: here. A new archetype without a line is tagged with its name alone.
360ARCHETYPE_TAGS: dict[str, str] = {
361    "mite": "swarms and chews",
362    "skimmer": "steals your salvage",
363    "lancer": "dashes and rams",
364    "mag_mine": "lunges when close",
365    "welder": "repairs its friends",
366    "bombardier": "shells from afar",
367    "screamer": "screams for attention",
368    "husk_turret": "burns fixed lanes",
369    "herald": "heralds the Shrike",
370    "warden": "guards its vault",
371    "magistrate": "hunts your bounty",
372}
373
374#: How long a first-sighting tag floats beside the thing it names.
375SIGHTING_TAG_HOLD_S = 4.0
376#: The profile's codex bucket the seen archetypes are kept in.
377CODEX_ENEMIES = "enemies"
378
379
380def sighting_tag(archetype: str) -> str:
381    """The label a first sighting of *archetype* wears."""
382    name = str(archetype).replace("_", " ").upper()
383    role = ARCHETYPE_TAGS.get(str(archetype), "")
384    return f"{name}: {role}" if role else name
385
386
387class Onboarding:
388    """The staged-run schedule: what is on screen and what is unlocked.
389
390    Pure data and predicates. Every method takes the run number so one instance
391    can answer for any run, and defaults to the run it was built for.
392    """
393
394    def __init__(self, run_number: int = 1):
395        self.run_number = max(1, int(run_number))
396
397    def __repr__(self) -> str:
398        return f"Onboarding(run_number={self.run_number})"
399
400    # -- state -------------------------------------------------------------
401
402    @property
403    def active(self) -> bool:
404        """Whether this run is still inside the staged schedule."""
405        return self.run_number < STAGED_RUNS
406
407    def _run(self, run_number: int | None) -> int:
408        return self.run_number if run_number is None else max(1, int(run_number))
409
410    # -- the HUD -----------------------------------------------------------
411
412    def visible_hud_elements(self, run_number: int | None = None) -> set[str]:
413        """The HUD element ids drawn on *run_number*.
414
415        Run 1 is ``hud.MINIMAL_HUD_ELEMENTS``, run 2 adds :data:`RUN_2_ADDITIONS`,
416        and run 3 onward is the whole display.
417        """
418        run = self._run(run_number)
419        if run >= STAGED_RUNS:
420            return set(ALL_HUD_ELEMENTS)
421        elements = set(MINIMAL_HUD_ELEMENTS)
422        if run >= 2:
423            elements |= RUN_2_ADDITIONS
424        return elements
425
426    def hidden_hud_elements(self, run_number: int | None = None) -> set[str]:
427        """The complement of :meth:`visible_hud_elements`, for the tutorial copy."""
428        return set(ALL_HUD_ELEMENTS) - self.visible_hud_elements(run_number)
429
430    # -- feature gates -----------------------------------------------------
431
432    def chart_open(self, run_number: int | None = None) -> bool:
433        """Whether the player picks the route. Run 1 flies a fixed gentle one."""
434        return self._run(run_number) >= 2
435
436    def ballistics_offered(self, run_number: int | None = None) -> bool:
437        """Whether depots stock ammo-fed weapons yet."""
438        return self._run(run_number) >= 2
439
440    def silent_running_available(self, run_number: int | None = None) -> bool:
441        return self._run(run_number) >= STAGED_RUNS
442
443    def conversion_available(self, run_number: int | None = None) -> bool:
444        """The Refinery and the trading posts' scrap desk."""
445        return self._run(run_number) >= STAGED_RUNS
446
447    def notoriety_tracked(self, run_number: int | None = None) -> bool:
448        return self._run(run_number) >= STAGED_RUNS
449
450    # -- the staged arrival ------------------------------------------------
451
452    def scripted_arrival(self, run_number: int | None = None) -> ScriptedArrival | None:
453        """Run one's staged arrival, or None once the Shrike comes on its own."""
454        return ScriptedArrival() if self._run(run_number) == 1 else None
455
456    def control_cards(self, run_number: int | None = None) -> tuple[ControlCard, ...]:
457        """The control-card script for *run_number*, in the order it is taught."""
458        return {1: RUN_1_CARDS, 2: RUN_2_CARDS, STAGED_RUNS: RUN_3_CARDS}.get(self._run(run_number), ())
459
460
461# ============================================================================
462# The director
463# ============================================================================
464
465
466class OnboardingDirector(Node):
467    """Applies the schedule to a live run.
468
469    A child of the run scene. On mount it narrows the HUD; every frame it runs
470    the control-card script, watching the action map for the verb the card in
471    hand is waiting for, and on run 1 it tops the tank up and provokes the one
472    arrival the player is meant to survive.
473
474    It also sweeps the live run every :data:`LESSON_POLL_S` for the two things
475    a schedule cannot know in advance: a hold worth teaching a sink for, and an
476    archetype this profile has never seen. Both need the profile to remember
477    what has been taught, so a run built without one teaches everything once
478    per run and persists nothing.
479    """
480
481    #: Emitted once, when the staged arrival has been provoked.
482    arrival_provoked = Signal()
483    #: ``(verb, card_index)`` every time a card is satisfied and put away.
484    card_completed = Signal(str, int)
485    #: ``(archetype)`` the first time this profile lays eyes on one.
486    archetype_sighted = Signal(str)
487
488    def __init__(
489        self,
490        *,
491        run_number: int = 1,
492        onboarding: Onboarding | None = None,
493        profile: dict | None = None,
494        **kwargs,
495    ):
496        super().__init__(**kwargs)
497        self.onboarding = onboarding if onboarding is not None else Onboarding(run_number)
498        #: The live meta profile, or None in a harness without one.
499        self.profile = profile
500        self.elapsed = 0.0
501        self.sector_index = SCRIPTED_ARRIVAL_SECTOR
502        self.provoked = False
503        self._cards = list(self.onboarding.control_cards())
504        self._card_index = 0
505        self._card_shown = False
506        self._card_elapsed = 0.0
507        self._verb_held = 0.0
508        self._mouse_travel = 0.0
509        self._last_mouse: Vec2 | None = None
510        self._taught: set[str] = set()
511        self._poll_in = 0.0
512        #: Scrap earned when the card in hand went up, which is what the two
513        #: outcome verbs are measured against.
514        self._scrap_mark = 0.0
515        self._verb_seen = False
516        self._gate_waited: dict[str, float] = {}
517        self._progress_shown: int | None = None
518        self._answer_cooldown = 0.0
519        self._answered: set[str] = set()
520
521    # -- lifecycle ---------------------------------------------------------
522
523    def on_ready(self):
524        self.apply_to_hud()
525
526    def on_update(self, dt: float):
527        self.elapsed += dt
528        self._run_card_script(dt)
529        self._poll_in -= dt
530        if self._poll_in <= 0.0:
531            self._poll_in = LESSON_POLL_S
532            self.poll_lessons()
533            self.poll_sightings()
534        arrival = self.onboarding.scripted_arrival()
535        if arrival is None or self.provoked:
536            return
537        if self.elapsed < arrival.at_elapsed_s:
538            return
539        self.provoke_arrival()
540
541    # -- the control-card script -------------------------------------------
542
543    @property
544    def card(self) -> ControlCard | None:
545        """The card the script is on, or None once the script is finished."""
546        return self._cards[self._card_index] if self._card_index < len(self._cards) else None
547
548    @property
549    def cards_completed(self) -> int:
550        """How many cards the script has put away."""
551        return self._card_index
552
553    @property
554    def pending_cards(self) -> tuple[ControlCard, ...]:
555        """The card in hand and everything queued behind it, in order."""
556        return tuple(self._cards[self._card_index :])
557
558    def report_verb(self, verb: str) -> bool:
559        """Tell the director the player just did *verb*.
560
561        The director watches the action map itself, so this is for the cases
562        the action map cannot see: a system confirming the thing really
563        happened. Returns whether it satisfied the card in hand.
564        """
565        card = self.card
566        if card is None or not self._card_shown or card.verb != verb:
567            return False
568        self._advance_card()
569        return True
570
571    def _run_card_script(self, dt: float) -> None:
572        card = self.card
573        self._answer_cooldown = max(0.0, self._answer_cooldown - dt)
574        if card is None:
575            return
576        if not self._card_shown:
577            if self.elapsed < card.at_s:
578                return
579            if not self._gate_met(card):
580                self._wait_for_gate(card, dt)
581                return
582            hud = self.hud()
583            if hud is None:
584                return
585            self._card_shown = True
586            self._card_elapsed = 0.0
587            self._verb_held = 0.0
588            self._verb_seen = False
589            self._mouse_travel = 0.0
590            self._last_mouse = None
591            self._scrap_mark = self._scrap_earned()
592            self._progress_shown = None
593            self._answered.clear()
594            self._show_card(card)
595            return
596        self._card_elapsed += dt
597        if not card.verb:
598            if self._card_elapsed >= card.hold_s:
599                self._advance_card()
600            return
601        if self._verb_performed(card.verb, dt):
602            self._advance_card()
603            return
604        if self._card_elapsed >= CARD_TIMEOUT_S:
605            # The sector never offered the verb. Teaching is not a gate, so the
606            # script moves on rather than holding the panel up all run.
607            self._advance_card()
608            return
609        self._show_progress(card)
610        self._answer_out_of_order(card)
611
612    def restore_card(self) -> bool:
613        """Put the card in hand back on the glass, and say whether there was one.
614
615        The HUD's transient layer is swept whole on a jump, because everything
616        in it belongs to the sector that raised it. The card in hand does not:
617        it teaches a key, and a pilot who jumped mid-lesson has not learned it
618        yet. The progress mark is dropped with it so the bar is redrawn from
619        whatever the hold has reached rather than from the tick it last showed.
620        """
621        card = self.card
622        if card is None or not self._card_shown:
623            return False
624        self._progress_shown = None
625        self._show_card(card)
626        return True
627
628    def _advance_card(self) -> None:
629        card = self.card
630        self._card_index += 1
631        self._card_shown = False
632        self._progress_shown = None
633        hud = self.hud()
634        if hud is not None:
635            hud.clear_control_card()
636        if card is not None:
637            self.card_completed(card.verb, self._card_index - 1)
638
639    # -- gates -------------------------------------------------------------
640
641    def _gate_met(self, card: ControlCard) -> bool:
642        """Whether the run currently satisfies *card*'s gate."""
643        if card.gate != Gates.BREACH:
644            return True
645        tree = self.tree
646        if tree is None:
647            return False
648        return any(int(getattr(ship, "open_breaches", 0)) > 0 for ship in tree.group(Groups.SHIP))
649
650    def _wait_for_gate(self, card: ControlCard, dt: float) -> None:
651        """Hold a gated card back, and stop it holding the script back.
652
653        After :data:`GATE_RETRY_S` the card goes to the end of the queue so the
654        lines behind it are taught, and after :data:`CARD_TIMEOUT_S` of waiting
655        it is dropped: nothing in this script is allowed to stall a run.
656        """
657        waited = self._gate_waited.get(card.text, 0.0) + dt
658        self._gate_waited[card.text] = waited
659        if waited >= CARD_TIMEOUT_S:
660            self._cards.pop(self._card_index)
661            return
662        if waited >= GATE_RETRY_S and len(self._cards) - self._card_index > 1:
663            self._cards.append(self._cards.pop(self._card_index))
664
665    # -- the card on screen -------------------------------------------------
666
667    def _show_card(self, card: ControlCard, progress: float | None = None) -> None:
668        hud = self.hud()
669        if hud is None:
670            return
671        hud.show_control_card(card.text, verb=card.verb, hint=card.hint, progress=progress)
672
673    def _show_progress(self, card: ControlCard) -> None:
674        """Report how far along the card is, when it has anything to show.
675
676        A card that waits for a one-second hold and shows nothing for the first
677        nine tenths of it reads as a card that is not listening, which is what
678        a tap of the mining beam looked like. The HUD draws the bar; this only
679        re-shows the card when the reading has moved a visible amount.
680        """
681        fraction = self.card_progress
682        ticks = int(round(min(1.0, fraction) * PROGRESS_TICKS)) if fraction > 0.0 else None
683        if ticks == self._progress_shown:
684            return
685        self._progress_shown = ticks
686        self._show_card(card, fraction if ticks is not None else None)
687
688    @property
689    def card_progress(self) -> float:
690        """How far the card in hand has got, from 0 to 1."""
691        card = self.card
692        if card is None or not self._card_shown or not card.verb:
693            return 0.0
694        hold = VERB_HOLD_S.get(card.verb, 0.0)
695        if card.verb in OUTCOME_VERBS:
696            done = self._scrap_earned() > self._scrap_mark
697            held = min(1.0, self._verb_held / hold) if hold > 0.0 else float(self._verb_seen)
698            return 1.0 if done else 0.5 * held
699        if hold <= 0.0:
700            return 0.0
701        return min(1.0, self._verb_held / hold)
702
703    def _answer_out_of_order(self, card: ControlCard) -> None:
704        """Answer a key a later card teaches with what the script wants first.
705
706        The run honours every one of these keys; this is not a refusal. It is
707        the answer the blind playtest never got: a press that changes nothing
708        visible on a tutorial screen reads as a broken key.
709
710        Once per key per card, and never twice in the same breath. A pilot who
711        keeps firing while learning to fly is not asking the same question over
712        and over, and the toast line is needed for answers they have not had.
713
714        It goes on the answer line as a *note* rather than as the line itself,
715        because the run answers the same press in the same frame and has more to
716        say about it: pressing the generator key with no generator fitted is
717        both "the script wants you to fly first" and "there is no generator".
718        Whichever spoke last used to be the whole answer, so a blind pilot read
719        ``FIRST: FLY: W A S D`` and filed Z as a dead key. See
720        :meth:`shrike.hud.Hud.set_toast_note`.
721        """
722        if self._answer_cooldown > 0.0:
723            return
724        later = self._cards[self._card_index + 1 :]
725        actions = {action for entry in later for action in VERB_ACTIONS.get(entry.verb, ())}
726        actions -= set(VERB_ACTIONS.get(card.verb, ())) | self._answered
727        pressed = next((action for action in sorted(actions) if Input.is_action_just_pressed(action)), "")
728        if not pressed:
729            return
730        self._answered.add(pressed)
731        hud = self.hud()
732        if hud is not None:
733            hud.set_toast_note(OUT_OF_ORDER_ANSWER.format(text=card.text.upper()))
734        self._answer_cooldown = OUT_OF_ORDER_COOLDOWN_S
735
736    def _scrap_earned(self) -> float:
737        """Scrap this run has banked, which only ever goes up."""
738        economy = self._service(Services.ECONOMY)
739        return float(getattr(economy, "scrap_earned_this_run", 0.0)) if economy is not None else 0.0
740
741    def _verb_performed(self, verb: str, dt: float) -> bool:
742        """Whether the player has now done *verb*, holds and outcomes included.
743
744        A hold has to be continuous: letting go puts the card back to zero, so
745        the beam lesson is learned as a beam rather than as a click. The two
746        harvest verbs want the outcome rather than the key: a beam held at
747        empty space and an interact tapped at nothing have taught nobody
748        anything, and the cards used to tick over on both.
749        """
750        if verb == Verbs.AIM and self._aimed(dt):
751            return True
752        pressed = any(Input.is_action_pressed(action) for action in VERB_ACTIONS.get(verb, ()))
753        self._verb_held = self._verb_held + dt if pressed else 0.0
754        self._verb_seen = self._verb_seen or pressed
755        held_enough = pressed and self._verb_held >= VERB_HOLD_S.get(verb, 0.0)
756        if verb in OUTCOME_VERBS:
757            return self._verb_seen and self._scrap_earned() > self._scrap_mark
758        return held_enough
759
760    def _aimed(self, dt: float) -> bool:
761        """Whether the pointer has been moved, or the pad's aim stick pushed."""
762        del dt
763        stick = gamepad_aim_input()
764        if abs(float(stick.x)) >= AIM_STICK_DEFLECTION or abs(float(stick.y)) >= AIM_STICK_DEFLECTION:
765            return True
766        mouse = Vec2(Input.mouse_position)
767        if self._last_mouse is not None:
768            self._mouse_travel += abs(float(mouse.x) - float(self._last_mouse.x))
769            self._mouse_travel += abs(float(mouse.y) - float(self._last_mouse.y))
770        self._last_mouse = mouse
771        return self._mouse_travel >= AIM_MOUSE_TRAVEL_PX
772
773    def set_sector(self, sector_index: int) -> None:
774        """Follow the run's sector. A record, not a gate: the arrival is timed."""
775        self.sector_index = int(sector_index)
776
777    # -- lessons -----------------------------------------------------------
778
779    def taught(self, lesson: str) -> bool:
780        """Whether *lesson* has already been given to this profile."""
781        if lesson in self._taught:
782            return True
783        return bool(lesson in self._profile_lessons())
784
785    def teach(self, lesson: str) -> bool:
786        """Queue *lesson* behind the script. False when it is already taught.
787
788        The card joins the end of the script rather than replacing whatever is
789        on screen: a lesson that shoves the card the player is still working
790        through aside teaches neither.
791        """
792        card = LESSON_CARDS.get(lesson)
793        if card is None or self.taught(lesson):
794            return False
795        self._taught.add(lesson)
796        self._profile_lessons()[lesson] = True
797        self._cards.append(card)
798        return True
799
800    def poll_lessons(self) -> None:
801        """Teach whatever the run's own state now asks for."""
802        economy = self._service(Services.ECONOMY)
803        scrap = float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
804        if scrap >= SCRAP_LESSON_SCRAP:
805            self.teach(Lessons.SCRAP)
806        if scrap >= REFINE_LESSON_SCRAP and self.refinery_fitted():
807            self.teach(Lessons.REFINE)
808
809    def refinery_fitted(self) -> bool:
810        """Whether the hull is carrying a Refinery right now."""
811        tree = self.tree
812        if tree is None:
813            return False
814        for ship in tree.group(Groups.SHIP):
815            for node in ship.walk():
816                installed = getattr(node, "installed_ids", None)
817                if callable(installed) and REFINERY_MODULE_ID in installed():
818                    return True
819        return False
820
821    # -- first sightings ---------------------------------------------------
822
823    def poll_sightings(self) -> None:
824        """Tag every archetype in the sector this profile has not met before."""
825        tree = self.tree
826        if tree is None:
827            return
828        for enemy in tree.group(Groups.ENEMIES):
829            if enemy.destroying:
830                continue
831            self.note_sighting(self.archetype_of(enemy), enemy)
832
833    def note_sighting(self, archetype: str, enemy=None, *, position=None) -> bool:
834        """Record a first sighting and float its tag. False when it is known.
835
836        The tag rides *enemy* so it keeps up with something that is already
837        closing; pass a *position* instead for a sighting whose node has gone.
838        """
839        archetype = str(archetype)
840        if not archetype or archetype in self.seen_archetypes():
841            return False
842        self.seen_archetypes().append(archetype)
843        hud = self.hud()
844        if hud is not None and (enemy is not None or position is not None):
845            hud.float_text(
846                sighting_tag(archetype),
847                node=enemy,
848                position=position,
849                hold_s=SIGHTING_TAG_HOLD_S,
850                colour=COLOUR_ENEMY_TAG,
851            )
852        self.archetype_sighted(archetype)
853        return True
854
855    @staticmethod
856    def archetype_of(enemy) -> str:
857        """The archetype id of *enemy*, the way ``combat.py`` reports its kills."""
858        spec = getattr(enemy, "spec", None)
859        archetype = getattr(spec, "id", None)
860        return str(archetype) if archetype else type(enemy).__name__.lower()
861
862    def seen_archetypes(self) -> list:
863        """The profile's codex of met archetypes, or a run-local stand-in."""
864        codex = self._profile_section("codex")
865        seen = codex.get(CODEX_ENEMIES)
866        if not isinstance(seen, list):
867            seen = []
868            codex[CODEX_ENEMIES] = seen
869        return seen
870
871    def _profile_lessons(self) -> dict:
872        return self._profile_section("lessons")
873
874    def _profile_section(self, key: str) -> dict:
875        """One mutable section of the profile, created on first use.
876
877        A director mounted without a profile keeps its sections on itself, so
878        a harness (and the smoke gate) teaches each lesson once per run and
879        writes nothing to disk.
880        """
881        if self.profile is None:
882            self.profile = {}
883        section = self.profile.get(key)
884        if not isinstance(section, dict):
885            section = {}
886            self.profile[key] = section
887        return section
888
889    # -- effects -----------------------------------------------------------
890
891    def apply_to_hud(self) -> bool:
892        """Narrow the HUD to this run's element set. False when there is no HUD."""
893        hud = self.hud()
894        if hud is None:
895            return False
896        hud.set_visible_elements(self.onboarding.visible_hud_elements())
897        return True
898
899    def provoke_arrival(self) -> bool:
900        """Top the tank up and drive the meter to lock. False when it cannot.
901
902        Fuel first, always: the scripted arrival exists to teach the escape, so
903        the escape has to be affordable before the klaxon sounds.
904        """
905        meter = self._service(Services.SIGNATURE)
906        if meter is None:
907            return False
908        power = self._service(Services.POWER)
909        arrival = self.onboarding.scripted_arrival() or ScriptedArrival()
910        if power is not None and float(power.fuel) < arrival.guaranteed_fuel:
911            power.add_fuel(arrival.guaranteed_fuel - float(power.fuel))
912        self.provoked = True
913        meter.add(balance.SIGNATURE_MAX, "onboarding", raw=True)
914        self.arrival_provoked()
915        return True
916
917    # -- lookups -----------------------------------------------------------
918
919    def hud(self) -> Hud | None:
920        """The run's HUD: the service entry if there is one, else the scene's."""
921        tree = self.tree
922        if tree is None:
923            return None
924        service = tree.singletons.get(Services.HUD)
925        if isinstance(service, Hud):
926            return service
927        for node in iter_tree_nodes(tree):
928            if isinstance(node, Hud):
929                return node
930        return None
931
932    def _service(self, name: str):
933        tree = self.tree
934        return tree.singletons.get(name) if tree is not None else None