shrike/onboarding.py¶

Part of SHRIKE.

  1"""The staged first three runs: the HUD subtraction schedule and the safe arrival.
  2
  3Onboarding works by 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: **a resource the run can charge or credit is
 20never subtracted.** Run one quotes five scrap for a hull patch and fifteen fuel
 21for a jump, and drains air the whole time; hiding those three numbers would
 22leave every price in the game quoted in a currency with no readout anywhere on
 23the 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, controller_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#: The air, the fuel and the hold are shown from run one. Run one charges five
 84#: scrap for a patch, fifteen fuel for a jump and drains air the whole time;
 85#: subtracting those three readouts would leave a pilot quoted prices in
 86#: currencies with no number anywhere on the screen. The rule the schedule
 87#: obeys: **a resource the run can charge or credit is never subtracted.**
 88#: Ammunition survives here because run one is flown on
 89#: energy weapons and never sees a round.
 90RUN_2_ADDITIONS: frozenset[str] = frozenset({HudElements.AMMO})
 91
 92#: What run 3 adds: the posture, the conversion bet and the reputation.
 93RUN_3_ADDITIONS: frozenset[str] = frozenset({HudElements.SILENT_RUNNING, HudElements.NOTORIETY})
 94
 95#: Seconds into run 1 before the scripted arrival is provoked. Long enough to
 96#: have flown, mined and worked through the control cards, short enough that
 97#: the tutorial's climax lands while the pilot is still alive to learn from it.
 98SCRIPTED_ARRIVAL_AT_S = 70.0
 99
100#: The sector run 1 usually meets the scripted arrival in. Advisory only: the
101#: clock is the gate, and the arrival lands in whatever sector the run occupies
102#: when it expires. Tying it to this sector would let a pilot who warped out
103#: before the deadline lose the arrival entirely, and with it run one's whole
104#: 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, never raw keys, 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 a card that counted the key would 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: a
192    pilot who reads "PATCH A BREACH: hold F" on an undamaged hull holds F at
193    nothing and comes away believing the key is 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, 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        # would send a pilot to a screen to confirm a choice the drive has 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 note, which is written for tuning rather than
358#: 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.emit(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 a pilot needs: a press that changes nothing visible on a
708        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        If whichever spoke last were the whole answer, a pilot would read
719        ``FIRST: FLY: W A S D`` and file 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.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 a card that counted the key would tick over on both.
749        """
750        if verb == Verbs.AIM and self._aimed(dt):
751            return True
752        pressed = any(Input.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 = controller_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            self.note_sighting(self.archetype_of(enemy), enemy)
830
831    def note_sighting(self, archetype: str, enemy=None, *, position=None) -> bool:
832        """Record a first sighting and float its tag. False when it is known.
833
834        The tag rides *enemy* so it keeps up with something that is already
835        closing; pass a *position* instead for a sighting whose node has gone.
836        """
837        archetype = str(archetype)
838        if not archetype or archetype in self.seen_archetypes():
839            return False
840        self.seen_archetypes().append(archetype)
841        hud = self.hud()
842        if hud is not None and (enemy is not None or position is not None):
843            hud.float_text(
844                sighting_tag(archetype),
845                node=enemy,
846                position=position,
847                hold_s=SIGHTING_TAG_HOLD_S,
848                colour=COLOUR_ENEMY_TAG,
849            )
850        self.archetype_sighted.emit(archetype)
851        return True
852
853    @staticmethod
854    def archetype_of(enemy) -> str:
855        """The archetype id of *enemy*, the way ``combat.py`` reports its kills."""
856        spec = getattr(enemy, "spec", None)
857        archetype = getattr(spec, "id", None)
858        return str(archetype) if archetype else type(enemy).__name__.lower()
859
860    def seen_archetypes(self) -> list:
861        """The profile's codex of met archetypes, or a run-local stand-in."""
862        codex = self._profile_section("codex")
863        seen = codex.get(CODEX_ENEMIES)
864        if not isinstance(seen, list):
865            seen = []
866            codex[CODEX_ENEMIES] = seen
867        return seen
868
869    def _profile_lessons(self) -> dict:
870        return self._profile_section("lessons")
871
872    def _profile_section(self, key: str) -> dict:
873        """One mutable section of the profile, created on first use.
874
875        A director mounted without a profile keeps its sections on itself, so
876        a harness (and the smoke gate) teaches each lesson once per run and
877        writes nothing to disk.
878        """
879        if self.profile is None:
880            self.profile = {}
881        section = self.profile.get(key)
882        if not isinstance(section, dict):
883            section = {}
884            self.profile[key] = section
885        return section
886
887    # -- effects -----------------------------------------------------------
888
889    def apply_to_hud(self) -> bool:
890        """Narrow the HUD to this run's element set. False when there is no HUD."""
891        hud = self.hud()
892        if hud is None:
893            return False
894        hud.set_visible_elements(self.onboarding.visible_hud_elements())
895        return True
896
897    def provoke_arrival(self) -> bool:
898        """Top the tank up and drive the meter to lock. False when it cannot.
899
900        Fuel first, always: the scripted arrival exists to teach the escape, so
901        the escape has to be affordable before the klaxon sounds.
902        """
903        meter = self._service(Services.SIGNATURE)
904        if meter is None:
905            return False
906        power = self._service(Services.POWER)
907        arrival = self.onboarding.scripted_arrival() or ScriptedArrival()
908        if power is not None and float(power.fuel) < arrival.guaranteed_fuel:
909            power.add_fuel(arrival.guaranteed_fuel - float(power.fuel))
910        self.provoked = True
911        meter.add(balance.SIGNATURE_MAX, "onboarding", raw=True)
912        self.arrival_provoked.emit()
913        return True
914
915    # -- lookups -----------------------------------------------------------
916
917    def hud(self) -> Hud | None:
918        """The run's HUD: the service entry if there is one, else the scene's."""
919        tree = self.tree
920        if tree is None:
921            return None
922        service = tree.singletons.get(Services.HUD)
923        if isinstance(service, Hud):
924            return service
925        for node in iter_tree_nodes(tree):
926            if isinstance(node, Hud):
927                return node
928        return None
929
930    def _service(self, name: str):
931        tree = self.tree
932        return tree.singletons.get(name) if tree is not None else None