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)