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