shrike/roost.py¶

Part of SHRIKE.

  1"""The endgame campaign: Lure fragments, Fletching pity, and the Roost duel.
  2
  3Three pieces, matching design section 7's Predation ending:
  4
  5:class:`LureCampaign`
  6    The across-runs campaign state, carried on the meta profile dictionary that
  7    ``save.py`` persists. Log-fragment caches from three biome types build the
  8    Lure blueprint; assembly costs ``balance.LURE_ASSEMBLY_SCRAP``; the
  9    assembled Lure puts the Roost node on the chart beside the Deep Gate and
 10    counts as a provocation by itself. The campaign also owns the quill gate:
 11    ``balance.QUILLS_TO_OPEN_ROOST`` quills are spent once to open the Roost,
 12    and while defeated phases persist each retry costs
 13    ``balance.ROOST_RETRY_QUILL_COST``.
 14
 15:class:`FletchingLedger`
 16    The catch-up mechanism. Heralds roll a Fletching drop whose chance rises
 17    ``balance.FLETCHING_RATE_RISE_PER_RUN`` per run since the last quill, and
 18    ``balance.FLETCHINGS_PER_QUILL`` Fletchings forge a quill at any depot, so
 19    even a flee-only player converges on the Roost.
 20
 21:class:`RoostScene`
 22    The three-phase duel. It opens with the exact arrival ladder every run has
 23    taught, and for the first time standing still is correct; there is no
 24    warp-out. Phase 1 is the known lantern kit at full aggression. Phase 2 eats
 25    the lights and hunts the player's emissions: firing, the generator and the
 26    afterburner all reveal the ship, silent running fades it. Phase 3 cracks
 27    the hull open into a telegraphed barrage while the exposed core is cut;
 28    the mirror-lantern blinds it for ``balance.MIRROR_LANTERN_BLIND_S`` punish
 29    windows in which the core takes full damage. Defeated phases persist on
 30    the profile across attempts. Victory rolls credits, mutates the main-menu
 31    nebula permanently, pays ``balance.ROOST_KILL_CORES`` and unlocks Hunt
 32    Rank 1.
 33"""
 34
 35from __future__ import annotations
 36
 37import math
 38import random
 39from collections.abc import Callable
 40
 41from simvx.core import Node, Signal, Vec3
 42
 43from . import balance
 44from .power import SignalWiring
 45from .runtime import Groups, SignalNames
 46
 47# ============================================================================
 48# Numbers the design fixes by feel rather than by table, so balance.py has no
 49# name for them yet. Flagged for the balance pass.
 50# ============================================================================
 51
 52#: The three biome types whose log-fragment caches build the Lure blueprint.
 53#: balance.LURE_FRAGMENT_BIOME_TYPES fixes the count at three; the design
 54#: leaves the biomes themselves unnamed, so these are the working picks.
 55LURE_FRAGMENT_BIOMES = ("nebula", "wreck_graveyard", "vent_field")
 56#: Fragment caches needed from each biome type before the blueprint is whole.
 57LURE_FRAGMENTS_PER_BIOME = 3
 58
 59#: Base chance a killed herald drops a Fletching, before the per-run rise.
 60FLETCHING_BASE_DROP_CHANCE = 0.25
 61
 62#: The quill-tech blueprint id the mirror-lantern punish windows require.
 63MIRROR_LANTERN_BLUEPRINT = "mirror_lantern"
 64
 65#: Per-phase HP pools for the duel, phases 1 to 3.
 66ROOST_PHASE_HP = (750.0, 600.0, 500.0)
 67#: An aimed attack misses when the ship has moved this far from the point it
 68#: was locked at when the telegraph began.
 69ROOST_ATTACK_DODGE_RADIUS = 6.0
 70#: Phase 1: seconds of tracking between lantern burns, full aggression.
 71ROOST_LANTERN_COOLDOWN_S = 2.5
 72#: Phase 2: strike cadence and cost once the emissions give the ship away.
 73ROOST_STRIKE_COOLDOWN_S = 2.0
 74ROOST_STRIKE_WINDUP_S = 1.0
 75ROOST_STRIKE_DAMAGE = 25.0
 76#: Phase 3: barrage cadence, telegraph and per-pulse damage.
 77ROOST_BARRAGE_INTERVAL_S = 4.0
 78ROOST_BARRAGE_TELEGRAPH_S = 0.8
 79ROOST_BARRAGE_DAMAGE = 12.0
 80#: Seconds between mirror-lantern activations.
 81ROOST_MIRROR_COOLDOWN_S = 12.0
 82
 83#: Phase 2 emission model: firing spikes the meter, running loud fills it
 84#: slowly, silence drains it. The Roost only hunts above the threshold.
 85EMISSION_MAX = 100.0
 86EMISSION_PER_SHOT = 8.0
 87EMISSION_GENERATOR_PER_S = 6.0
 88EMISSION_AFTERBURNER_PER_S = 10.0
 89EMISSION_DECAY_PER_S = 4.0
 90EMISSION_SILENT_DECAY_MULT = 3.0
 91EMISSION_HUNT_THRESHOLD = 40.0
 92
 93
 94# ============================================================================
 95# Profile helpers shared by the campaign, the scene and flow.py
 96# ============================================================================
 97
 98
 99def quills_to_open(profile: dict) -> int:
100    """Quills the first attempt of a Roost campaign costs.
101
102    Hunt Rank 7 raises the toll per ``balance.HUNT_RANK7_QUILLS_TO_OPEN_ROOST``.
103    """
104    if int(profile.get("hunt_rank", 0)) >= 7:
105        return int(balance.HUNT_RANK7_QUILLS_TO_OPEN_ROOST)
106    return int(balance.QUILLS_TO_OPEN_ROOST)
107
108
109def quills_after_death(quills: int, *, roost_attempt: bool) -> int:
110    """What the one-kept-on-death rule leaves of *quills*.
111
112    The rule applies only to ordinary runs; quills carried into a Roost
113    attempt are exempt, so a summit expedition never pays the ordinary tax on
114    top of its entry fee. flow.py calls this from the death ledger.
115    """
116    if roost_attempt:
117        return int(quills)
118    return min(int(quills), int(balance.QUILLS_KEPT_ON_DEATH))
119
120
121def can_wake_roost(profile: dict, fuel: float, notoriety: int = 0) -> bool:
122    """Whether the Roost can be woken: spare fuel plus a provocation.
123
124    Notoriety ``balance.ROOST_PROVOCATION_NOTORIETY`` qualifies, and so does
125    the assembled Lure by itself: it is sixty notoriety worth of noise in a
126    single object, which keeps the quietest playstyle inside the true ending.
127    """
128    if float(fuel) < balance.ROOST_FUEL_REQUIRED:
129        return False
130    assembled = bool(profile.get("lure", {}).get("assembled", False))
131    return assembled or int(notoriety) >= balance.ROOST_PROVOCATION_NOTORIETY
132
133
134def reveal_roost_node(graph, profile: dict) -> str | None:
135    """Put the Roost on *graph* beside the Deep Gate when the Lure is aboard.
136
137    *graph* is a ``chart.ChartGraph``; its ``reveal_roost`` places the node
138    adjacent to the gate so extraction and predation are one fork taken with
139    the same fuel. Returns the node id, or ``None`` while the Lure is not yet
140    assembled. Calling this every jump is harmless.
141    """
142    if not profile.get("lure", {}).get("assembled", False):
143        return None
144    return graph.reveal_roost()
145
146
147def menu_nebula_mutated(profile: dict) -> bool:
148    """Whether the main-menu nebula shows the permanent post-kill mutation."""
149    return int(profile.get("hunt_rank", 0)) >= 1
150
151
152def apply_victory(profile: dict) -> dict:
153    """Bank a Roost kill on *profile* and return the rewards summary.
154
155    Pays ``balance.ROOST_KILL_CORES``, raises the Hunt Rank one tier (the
156    first kill unlocks Rank 1), and resets the campaign so the next rank's
157    Roost is a fresh summit. The caller rolls credits and saves the profile.
158    """
159    profile["cores"] = int(profile.get("cores", 0)) + int(balance.ROOST_KILL_CORES)
160    profile["hunt_rank"] = min(int(profile.get("hunt_rank", 0)) + 1, int(balance.HUNT_RANKS_TOTAL))
161    profile["roost"] = {"phases_defeated": 0, "attempts": 0}
162    return {
163        "cores": int(balance.ROOST_KILL_CORES),
164        "hunt_rank": int(profile["hunt_rank"]),
165        "credits_rolled": True,
166        "menu_nebula_mutated": True,
167    }
168
169
170def _roost_state(profile: dict) -> dict:
171    state = profile.setdefault("roost", {})
172    state.setdefault("phases_defeated", 0)
173    state.setdefault("attempts", 0)
174    return state
175
176
177# ============================================================================
178# The Lure campaign
179# ============================================================================
180
181
182class LureCampaign:
183    """The across-runs half of Predation, carried on the meta profile.
184
185    Plain state logic over the profile dictionary: fragments, assembly and the
186    quill gate all mutate the same dictionary ``save.SaveSystem`` writes, so
187    persistence is wherever the profile already goes. Scrap payment goes
188    through the *spend_scrap* callable (``Economy.spend_scrap`` in a run);
189    without one, assembly has nothing to pay with and refuses.
190    """
191
192    def __init__(self, profile: dict, *, spend_scrap: Callable[[float], bool] | None = None):
193        self.profile = profile
194        self._spend_scrap = spend_scrap
195
196    # -- fragments and assembly -------------------------------------------
197
198    def fragments(self) -> dict[str, int]:
199        """Caches held per fragment biome type, zero-filled for the missing."""
200        stored = self.profile.get("lure", {}).get("fragments", {})
201        return {biome: int(stored.get(biome, 0)) for biome in LURE_FRAGMENT_BIOMES}
202
203    def collect_fragment(self, biome_id: str) -> bool:
204        """Bank one log-fragment cache found in *biome_id*.
205
206        Only the three fragment biome types count, and a biome that has
207        already yielded its share yields no more: the campaign has a shape,
208        not a lottery. Returns whether the cache counted.
209        """
210        if biome_id not in LURE_FRAGMENT_BIOMES:
211            return False
212        lure = self.profile.setdefault("lure", {"fragments": {}, "assembled": False})
213        held = int(lure.setdefault("fragments", {}).get(biome_id, 0))
214        if held >= LURE_FRAGMENTS_PER_BIOME:
215            return False
216        lure["fragments"][biome_id] = held + 1
217        return True
218
219    def blueprint_complete(self) -> bool:
220        """Whether every fragment biome has yielded its full share."""
221        return all(count >= LURE_FRAGMENTS_PER_BIOME for count in self.fragments().values())
222
223    def assembled(self) -> bool:
224        """Whether the Lure is aboard, which is what reveals the Roost node."""
225        return bool(self.profile.get("lure", {}).get("assembled", False))
226
227    def can_assemble(self) -> bool:
228        """Blueprint complete and the Lure not yet built; scrap is checked at assembly."""
229        return self.blueprint_complete() and not self.assembled()
230
231    def assemble(self) -> bool:
232        """Build the Lure for ``balance.LURE_ASSEMBLY_SCRAP``. Returns success."""
233        if not self.can_assemble():
234            return False
235        if self._spend_scrap is None or not self._spend_scrap(float(balance.LURE_ASSEMBLY_SCRAP)):
236            return False
237        self.profile.setdefault("lure", {})["assembled"] = True
238        return True
239
240    # -- the quill gate ---------------------------------------------------
241
242    def quills(self) -> int:
243        """Quills banked on the profile."""
244        return int(self.profile.get("quills", 0))
245
246    def add_quill(self, count: int = 1) -> None:
247        """Bank sheared or forged quills and reset the pity counter."""
248        self.profile["quills"] = self.quills() + int(count)
249        self.profile["runs_since_last_quill"] = 0
250
251    def campaign_open(self) -> bool:
252        """Whether the five-quill toll has already been paid this campaign."""
253        return int(_roost_state(self.profile).get("attempts", 0)) > 0
254
255    def attempt_cost(self) -> int:
256        """Quills the next attempt costs: the opening toll once, then retries."""
257        if self.campaign_open():
258            return int(balance.ROOST_RETRY_QUILL_COST)
259        return quills_to_open(self.profile)
260
261    def can_begin_attempt(self, fuel: float, notoriety: int = 0) -> bool:
262        """Whether an attempt can start: Lure aboard, Roost woken, toll payable."""
263        return (
264            self.assembled() and can_wake_roost(self.profile, fuel, notoriety) and self.quills() >= self.attempt_cost()
265        )
266
267    def begin_attempt(self) -> bool:
268        """Spend the quill toll and count the attempt. Returns success.
269
270        Defeated phases stay defeated on the profile, so a paid retry resumes
271        at the first phase still standing.
272        """
273        cost = self.attempt_cost()
274        if self.quills() < cost:
275            return False
276        self.profile["quills"] = self.quills() - cost
277        _roost_state(self.profile)["attempts"] += 1
278        return True
279
280
281# ============================================================================
282# The Fletching ledger
283# ============================================================================
284
285
286class FletchingLedger:
287    """Herald Fletching drops and the pity forge, over the meta profile.
288
289    The drop chance starts at :data:`FLETCHING_BASE_DROP_CHANCE` and rises
290    ``balance.FLETCHING_RATE_RISE_PER_RUN`` for every run finished since the
291    last quill, so the funnel converges instead of stalling.
292    """
293
294    def __init__(self, profile: dict, *, rng: random.Random | None = None):
295        self.profile = profile
296        self._rng = rng if rng is not None else random.Random()
297
298    def fletchings(self) -> int:
299        """Fletchings banked on the profile."""
300        return int(self.profile.get("fletchings", 0))
301
302    def drop_chance(self) -> float:
303        """The current herald drop chance, clamped to certainty."""
304        runs = int(self.profile.get("runs_since_last_quill", 0))
305        return min(1.0, FLETCHING_BASE_DROP_CHANCE + balance.FLETCHING_RATE_RISE_PER_RUN * runs)
306
307    def on_herald_killed(self) -> bool:
308        """Roll the drop for one dead herald; banks a Fletching on success."""
309        if self._rng.random() >= self.drop_chance():
310            return False
311        self.profile["fletchings"] = self.fletchings() + 1
312        return True
313
314    def can_forge(self) -> bool:
315        """Whether a quill's worth of Fletchings is banked."""
316        return self.fletchings() >= int(balance.FLETCHINGS_PER_QUILL)
317
318    def forge_quill(self) -> bool:
319        """Trade ``balance.FLETCHINGS_PER_QUILL`` Fletchings for a quill.
320
321        Depot-side code offers this while docked; the ledger itself only
322        enforces the price. Forging counts as gaining a quill, so the pity
323        counter resets.
324        """
325        if not self.can_forge():
326            return False
327        self.profile["fletchings"] = self.fletchings() - int(balance.FLETCHINGS_PER_QUILL)
328        self.profile["quills"] = int(self.profile.get("quills", 0)) + 1
329        self.profile["runs_since_last_quill"] = 0
330        return True
331
332    def record_run(self, *, quill_gained: bool) -> None:
333        """Advance the pity counter at run end; a quill run resets it."""
334        if quill_gained:
335            self.profile["runs_since_last_quill"] = 0
336        else:
337            self.profile["runs_since_last_quill"] = int(self.profile.get("runs_since_last_quill", 0)) + 1
338
339
340# ============================================================================
341# The Roost duel
342# ============================================================================
343
344#: Per-phase aimed-attack cadence: seconds between attacks, telegraph length,
345#: damage on a clean hit, and the damage kind the ship's ledger sees.
346_PHASE_COOLDOWN_S = {1: ROOST_LANTERN_COOLDOWN_S, 2: ROOST_STRIKE_COOLDOWN_S, 3: ROOST_BARRAGE_INTERVAL_S}
347_PHASE_WINDUP_S = {1: balance.SHRIKE_LANTERN_PREBURN_WARNING_S, 2: ROOST_STRIKE_WINDUP_S, 3: ROOST_BARRAGE_TELEGRAPH_S}
348_PHASE_DAMAGE = {1: balance.SHRIKE_LANTERN_HULL_DAMAGE, 2: ROOST_STRIKE_DAMAGE, 3: ROOST_BARRAGE_DAMAGE}
349_PHASE_KIND = {1: "lantern", 2: "strike", 3: "barrage"}
350
351PHASE_COUNT = len(ROOST_PHASE_HP)
352
353
354class RoostScene(Node):
355    """The three-phase duel, resumed at the first phase still standing.
356
357    The scene is the fight's director: it runs the opening arrival ladder,
358    scrambles the warp for the whole duel (there is no warp-out), drives the
359    per-phase attack cycles against the player ship, and takes the player's
360    damage through the same :class:`~shrike.combat.DamageRouter` path as every
361    other hostile. Defeated phases are written to the profile the moment they
362    fall, so death after a phase kill loses nothing but the retry quill.
363
364    Every attack is telegraphed: the Roost locks the ship's position when the
365    windup begins and the burn lands only if the ship is still within
366    :data:`ROOST_ATTACK_DODGE_RADIUS` of that point, so dodging is a piloting
367    problem at full aggression, exactly as the run taught it.
368    """
369
370    hunter_telegraph = Signal(str)  # SignalNames.HUNTER_TELEGRAPH: "t60" | "t30" | "t0"
371    roost_attack_telegraphed = Signal(str)  # attack kind, at windup start
372    roost_phase_defeated = Signal(int)  # 1-based phase that just fell
373    roost_blinded = Signal(float)  # mirror-lantern window length, seconds
374    roost_victory = Signal(dict)  # rewards summary from apply_victory
375    roost_failed = Signal(int)  # 1-based phase the attempt died in
376
377    def __init__(
378        self,
379        profile: dict,
380        *,
381        telegraph_s: float = balance.SHRIKE_TELEGRAPH_FIRST_S,
382        mirror_lantern: bool | None = None,
383        **kwargs,
384    ):
385        super().__init__(**kwargs)
386        self.profile = profile
387        if mirror_lantern is None:
388            mirror_lantern = MIRROR_LANTERN_BLUEPRINT in profile.get("quill_tech", [])
389        self.mirror_lantern_available = bool(mirror_lantern)
390
391        #: "ladder" | "fight" | "won" | "lost"
392        self.state = "ladder"
393        #: 1-based current phase; opens past whatever earlier attempts felled.
394        self.phase = min(int(_roost_state(profile).get("phases_defeated", 0)) + 1, PHASE_COUNT)
395        self.phase_hp = float(ROOST_PHASE_HP[self.phase - 1])
396        #: Phase 2's giveaway meter, 0 to EMISSION_MAX.
397        self.emission = 0.0
398
399        self._telegraph_remaining = float(telegraph_s)
400        self._t30_sent = False
401        self._fight_s = 0.0
402        self._attack_state = "cooldown"  # "cooldown" | "windup"
403        self._attack_timer = _PHASE_COOLDOWN_S[self.phase]
404        self._locked_point: tuple[float, float] | None = None
405        self._blind_remaining = 0.0
406        self._mirror_cooldown = 0.0
407        self._blackout_remaining = 0.0
408        self._generator_on = False
409        self._afterburner_on = False
410        self._silent_running = False
411        self._warp_held = False
412        self._wiring = SignalWiring(self)
413        self._wiring.want(SignalNames.WEAPON_FIRED, self._on_weapon_fired)
414        self._wiring.want(SignalNames.GENERATOR_CHANGED, self._on_generator_changed)
415        self._wiring.want(SignalNames.AFTERBURNER_CHANGED, self._on_afterburner_changed)
416        self._wiring.want(SignalNames.SILENT_RUNNING_CHANGED, self._on_silent_running_changed)
417        self._wiring.want(SignalNames.SHIP_DESTROYED, self._on_ship_destroyed)
418
419    # ------------------------------------------------------------- properties
420
421    @property
422    def damage_taken_mult(self) -> float:
423        """What the DamageRouter scales incoming numbers by.
424
425        Phases 1 and 2 take honest damage. Phase 3's exposed core is plated
426        like a fin until the mirror-lantern blinds it, which is what makes the
427        punish windows worth building the module for.
428        """
429        if self.phase == 3 and self._blind_remaining <= 0.0:
430            return float(balance.SHRIKE_FIN_DAMAGE_TAKEN_MULT)
431        return 1.0
432
433    def blinded(self) -> bool:
434        """Whether a mirror-lantern window is open."""
435        return self._blind_remaining > 0.0
436
437    def damage_multiplier(self) -> float:
438        """Full aggression: the ramp starts high and climbs while present."""
439        steps = math.floor(self._fight_s / balance.SHRIKE_RAMP_INTERVAL_S)
440        return 1.0 + balance.SHRIKE_RAMP_START_LATER_ARRIVALS + balance.SHRIKE_RAMP_FRACTION * steps
441
442    # ------------------------------------------------------------------ setup
443
444    def on_enter_tree(self):
445        super().on_enter_tree()
446        self.add_to_group(Groups.HUNTER)
447
448    def on_ready(self):
449        self._wiring.sweep()
450        self._emit_stage("t60")
451        if self._telegraph_remaining <= balance.SHRIKE_TELEGRAPH_KLAXON_S:
452            self._emit_stage("t30")
453            self._t30_sent = True
454
455    # ------------------------------------------------------------- public API
456
457    def take_damage(self, amount: float, kind: str = "impact") -> None:
458        """Damage the current phase; the router has already applied plating."""
459        if self.state != "fight" or amount <= 0.0:
460            return
461        self.phase_hp -= float(amount)
462        if self.phase_hp <= 0.0:
463            self._defeat_phase()
464
465    def activate_mirror_lantern(self) -> bool:
466        """Open a ``balance.MIRROR_LANTERN_BLIND_S`` punish window.
467
468        Only in phase 3, only with the quill-tech built, and only off
469        cooldown. Blinding cancels any barrage mid-telegraph.
470        """
471        if self.state != "fight" or self.phase != 3 or not self.mirror_lantern_available:
472            return False
473        if self._mirror_cooldown > 0.0:
474            return False
475        self._blind_remaining = float(balance.MIRROR_LANTERN_BLIND_S)
476        self._mirror_cooldown = ROOST_MIRROR_COOLDOWN_S
477        self._reset_attack()
478        self.roost_blinded(float(balance.MIRROR_LANTERN_BLIND_S))
479        return True
480
481    def fail(self) -> None:
482        """End the attempt in defeat; felled phases stay felled on the profile."""
483        if self.state in ("won", "lost"):
484            return
485        self.state = "lost"
486        self._release_warp()
487        self.roost_failed(self.phase)
488
489    # ------------------------------------------------------------------- tick
490
491    def on_update(self, dt: float):
492        self._wiring.poll(dt)
493        if self.state == "ladder":
494            self._hold_warp()
495            self._tick_ladder(dt)
496        elif self.state == "fight":
497            self._hold_warp()
498            self._tick_fight(dt)
499
500    def _tick_ladder(self, dt: float) -> None:
501        self._telegraph_remaining -= dt
502        if not self._t30_sent and self._telegraph_remaining <= balance.SHRIKE_TELEGRAPH_KLAXON_S:
503            self._t30_sent = True
504            self._emit_stage("t30")
505        if self._telegraph_remaining <= 0.0:
506            self.state = "fight"
507            self._emit_stage("t0")
508
509    def _tick_fight(self, dt: float) -> None:
510        self._fight_s += dt
511        self._tick_blackout(dt)
512        if self._mirror_cooldown > 0.0:
513            self._mirror_cooldown = max(0.0, self._mirror_cooldown - dt)
514        if self.phase == 2:
515            self._tick_emission(dt)
516        if self.phase == 3 and self._blind_remaining > 0.0:
517            self._blind_remaining = max(0.0, self._blind_remaining - dt)
518            self._reset_attack()
519            return
520        self._tick_attack(dt)
521
522    def _tick_emission(self, dt: float) -> None:
523        gain = 0.0
524        if self._generator_on:
525            gain += EMISSION_GENERATOR_PER_S
526        if self._afterburner_on:
527            gain += EMISSION_AFTERBURNER_PER_S
528        decay = EMISSION_DECAY_PER_S * (EMISSION_SILENT_DECAY_MULT if self._silent_running else 1.0)
529        self.emission = min(EMISSION_MAX, max(0.0, self.emission + (gain - decay) * dt))
530
531    def _tick_attack(self, dt: float) -> None:
532        if self._attack_state == "cooldown":
533            self._attack_timer = max(0.0, self._attack_timer - dt)
534            if self._attack_timer <= 0.0 and self._may_windup():
535                self._begin_windup()
536        else:
537            self._attack_timer -= dt
538            if self._attack_timer <= 0.0:
539                self._resolve_attack()
540
541    def _may_windup(self) -> bool:
542        if self._ship() is None:
543            return False
544        if self.phase == 2:
545            return self.emission >= EMISSION_HUNT_THRESHOLD
546        return True
547
548    def _begin_windup(self) -> None:
549        self._attack_state = "windup"
550        self._attack_timer = _PHASE_WINDUP_S[self.phase]
551        self._locked_point = self._ship_plane()
552        self.roost_attack_telegraphed(_PHASE_KIND[self.phase])
553
554    def _resolve_attack(self) -> None:
555        locked = self._locked_point
556        self._reset_attack()
557        ship = self._ship()
558        here = self._ship_plane()
559        if ship is None or locked is None or here is None:
560            return
561        if math.hypot(here[0] - locked[0], here[1] - locked[1]) > ROOST_ATTACK_DODGE_RADIUS:
562            return
563        # An enveloping wash, not a bearing-aligned shot: the shield arc covers
564        # no bearing for it and the only defence is not being where it looked.
565        amount = _PHASE_DAMAGE[self.phase] * self.damage_multiplier()
566        ship.apply_damage(amount, Vec3(0.0, 0.0, 0.0), kind=_PHASE_KIND[self.phase])
567        if self.phase == 1:
568            self._blackout_remaining = balance.SHRIKE_LANTERN_CAPACITOR_BLACKOUT_S
569
570    def _tick_blackout(self, dt: float) -> None:
571        """Hold the capacitor at zero for the blackout second after a burn."""
572        if self._blackout_remaining <= 0.0:
573            return
574        self._blackout_remaining = max(0.0, self._blackout_remaining - dt)
575        ship = self._ship()
576        if ship is None:
577            return
578        power = ship.power
579        charge = float(getattr(power, "capacitor", 0.0))
580        if charge > 0.0:
581            power.request(charge, "lantern_blackout")
582
583    def _reset_attack(self) -> None:
584        self._attack_state = "cooldown"
585        self._attack_timer = _PHASE_COOLDOWN_S[self.phase]
586        self._locked_point = None
587
588    # ----------------------------------------------------------------- phases
589
590    def _defeat_phase(self) -> None:
591        felled = self.phase
592        state = _roost_state(self.profile)
593        state["phases_defeated"] = max(int(state.get("phases_defeated", 0)), felled)
594        self.roost_phase_defeated(felled)
595        if felled >= PHASE_COUNT:
596            self.state = "won"
597            self._release_warp()
598            self.roost_victory(apply_victory(self.profile))
599            return
600        self.phase = felled + 1
601        self.phase_hp = float(ROOST_PHASE_HP[self.phase - 1])
602        self.emission = 0.0
603        self._blind_remaining = 0.0
604        self._reset_attack()
605
606    # ------------------------------------------------------------ warp denial
607
608    def _hold_warp(self) -> None:
609        ship = self._ship()
610        if ship is not None:
611            ship.warp_scrambled = True
612            self._warp_held = True
613
614    def _release_warp(self) -> None:
615        if not self._warp_held:
616            return
617        self._warp_held = False
618        ship = self._ship()
619        if ship is not None:
620            ship.warp_scrambled = False
621
622    # -------------------------------------------------------------- listeners
623
624    def _on_weapon_fired(self, weapon_id: str) -> None:
625        if self.state == "fight" and self.phase == 2:
626            self.emission = min(EMISSION_MAX, self.emission + EMISSION_PER_SHOT)
627
628    def _on_generator_changed(self, running: bool) -> None:
629        self._generator_on = bool(running)
630
631    def _on_afterburner_changed(self, active: bool) -> None:
632        self._afterburner_on = bool(active)
633
634    def _on_silent_running_changed(self, active: bool) -> None:
635        self._silent_running = bool(active)
636
637    def _on_ship_destroyed(self) -> None:
638        if self.state in ("ladder", "fight"):
639            self.fail()
640
641    # ----------------------------------------------------------------- lookup
642
643    def _emit_stage(self, stage: str) -> None:
644        self.hunter_telegraph(stage)
645
646    def _ship(self):
647        return self.tree.get_first_in_group(Groups.SHIP) if self.tree is not None else None
648
649    def _ship_plane(self) -> tuple[float, float] | None:
650        ship = self._ship()
651        if ship is None:
652            return None
653        return float(ship.position.x), float(ship.position.z)