shrike/bounty.py¶
Part of SHRIKE.
1"""Notoriety, the run-level reputation dial, and the two set pieces it buys.
2
3Signature is this sector's bill; notoriety is your reputation with the dark. It
4is a run-level tally from 0 to 100 that never appears as an in-sector meter: it
5lives on the star chart and, diegetically, in the brightness of your engine
6trail. Four loud feats raise it, a fully quiet sector decays it, and what it
7buys and costs is the whole point of winding it up:
8
9* **It pays.** The Broker barge only shows its bay at
10 ``balance.BROKER_NOTORIETY_THRESHOLD``, bounty payouts double at
11 ``balance.BOUNTY_DOUBLE_NOTORIETY_THRESHOLD``, and the Roost needs
12 ``balance.ROOST_PROVOCATION_NOTORIETY`` worth of provocation to answer.
13* **It charges.** The wave budget carries ``value / 10``, the Wake front eats
14 the chart faster above ``balance.WAKE_ACCEL_NOTORIETY``, and the Magistrates
15 start jumping in above ``balance.MAGISTRATE_NOTORIETY_THRESHOLD``.
16
17:class:`Notoriety` owns the tally and answers every one of those questions, so
18no other module re-derives a threshold from the raw number.
19
20The set pieces
21==============
22
23Two threats in the game are neither a wave nor the hunter, and both are here
24because both are consequences of the dial:
25
26* :class:`Warden`: a stationary pirate mini-boss with a rotating shield arc of
27 its own, parked over a vault, a hoard or the Broker's bay. It is a mirror
28 match that teaches directional shielding by example, and it is a fight the
29 player chooses to start: it sleeps until the ship comes to it.
30* :class:`Magistrate`: three named bounty hunters, one signature move each,
31 jumping in on notorious ships and paying out in scrap and a black-market
32 discount chit. :class:`MagistrateDirector` owns the jump-in schedule.
33
34Neither is in ``balance.ENEMIES``, which carries the nine wave archetypes
35alone: they have specs of their own (``balance.WARDEN``, ``balance.MAGISTRATE``)
36and are staged by the sector and by the director rather than bought out of a
37threat budget.
38"""
39
40from __future__ import annotations
41
42import math
43import random
44from dataclasses import dataclass
45
46from simvx.core import Node, Node3D, Quat, Signal, Vec2, Vec3
47
48from . import artkit, balance
49from .enemies.advanced import AdvancedEnemy, bearing, wrap_angle
50from .power import SignalWiring
51from .runtime import Groups, Services, SignalNames, from_plane, to_plane
52
53# ============================================================================
54# Module-local anchors
55#
56# Ranges, cadences and the chit's discount: numbers the design fixes in prose
57# and balance.py does not name. They belong in balance.py at the tuning pass.
58# ============================================================================
59
60#: What one Magistrate's discount chit takes off a Broker price, as a fraction,
61#: and the most any number of them may take off together.
62BROKER_CHIT_DISCOUNT = 0.15
63BROKER_CHIT_DISCOUNT_MAX = 0.45
64
65# Warden
66#: How fast the guard's arc sweeps, radians per second. Slow enough to read the
67#: gap coming, fast enough that camping one bearing never works.
68WARDEN_ARC_ROTATION_RATE = math.radians(50.0)
69#: It sleeps until the ship comes inside this range, and gives up beyond it.
70WARDEN_AGGRO_RANGE = 46.0
71WARDEN_DISENGAGE_RANGE = 78.0
72#: Reach of its gun, and how often it fires while the ship is inside that reach.
73WARDEN_GUN_RANGE = 42.0
74WARDEN_FIRE_INTERVAL_S = 2.0
75#: A mini-boss hull is a big target; the wave roster's 1.5 would be absurd here.
76WARDEN_HITBOX_RADIUS = 3.4
77
78# Magistrate
79MAGISTRATE_SPEED = 17.0
80MAGISTRATE_STANDOFF_DISTANCE = 30.0
81MAGISTRATE_STANDOFF_BAND = 5.0
82MAGISTRATE_GUN_RANGE = 38.0
83MAGISTRATE_FIRE_INTERVAL_S = 2.4
84#: A signature move is the punctuation of the duel, not its rhythm.
85MAGISTRATE_MOVE_INTERVAL_S = 9.0
86#: Signature moves telegraph for longer than a bolt: they are worth reading.
87MAGISTRATE_MOVE_TELEGRAPH_S = 0.9
88MAGISTRATE_HITBOX_RADIUS = 2.2
89#: Where one tears in from, relative to the ship, and how long after entering a
90#: sector it does so. The retry is how often the jump-in is reconsidered once
91#: the first attempt found the sector busy.
92MAGISTRATE_SPAWN_RADIUS = 60.0
93MAGISTRATE_JUMP_IN_DELAY_S = 25.0
94MAGISTRATE_JUMP_IN_RETRY_S = 8.0
95
96# Harrier's lance run
97LANCE_RUN_SPEED_MULT = 3.2
98LANCE_RUN_S = 1.1
99LANCE_RUN_HIT_RADIUS = 4.0
100LANCE_RUN_DAMAGE_MULT = 1.6
101
102# Caracara's snare
103SNARE_RANGE = 44.0
104SNARE_S = 2.5
105#: Pull applied to the ship's plane velocity each second while the line holds.
106SNARE_PULL_PER_S = 26.0
107
108# Nightjar's dark lantern
109DARK_LANTERN_RANGE = 34.0
110DARK_LANTERN_CAPACITOR_DRAIN = 40.0
111
112
113# ============================================================================
114# The tally
115# ============================================================================
116
117
118class Notoriety(Node):
119 """The run-level reputation tally, registered as ``Services.NOTORIETY``.
120
121 Gains are itemised: every one is filed under a reason, so the death ledger
122 can print what made the run loud and so a sector can be judged quiet on the
123 evidence rather than on a flag someone remembered to clear. The four gains
124 are the design's four loud feats, in :data:`Notoriety.GAIN_EVENTS`, and the
125 tally wires itself to their signals rather than being pushed by them.
126
127 Everything downstream asks this node rather than the number: the wave
128 composer takes :meth:`wave_budget_bonus`, the chart takes
129 :meth:`wake_accelerated`, the Broker takes :meth:`broker_unlocked`, a
130 bounty takes :meth:`bounty_payout`, and the vfx engine ribbon takes
131 :meth:`trail_brightness` off ``NOTORIETY_CHANGED``.
132 """
133
134 #: ``runtime.SignalNames.NOTORIETY_CHANGED`` (value, reason).
135 notoriety_changed = Signal(int, str)
136
137 #: Every itemised gain, keyed by the ledger name it is filed under.
138 GAIN_EVENTS: dict[str, int] = {
139 "vault": balance.NOTORIETY_VAULT_CRACKED,
140 "refinery": balance.NOTORIETY_REFINERY_BATCH,
141 "elite_kill": balance.NOTORIETY_ELITE_KILL,
142 "arrival_survived": balance.NOTORIETY_ARRIVAL_SURVIVED,
143 }
144
145 def __init__(self, *, value: int = 0, **kwargs):
146 super().__init__(**kwargs)
147 self.value = self._clamp(value)
148 #: Notoriety charged per reason across the whole run, for the ledger.
149 self.gains: dict[str, int] = {}
150 #: The same, for the sector in progress. A sector that ends with this
151 #: empty was fully quiet and decays.
152 self.sector_gains: dict[str, int] = {}
153 #: How many sectors have ended quiet, and how many have ended at all.
154 self.quiet_sectors = 0
155 self.sectors_completed = 0
156 #: Unspent Magistrate discount chits.
157 self.chits = 0
158 self._sector_open = False
159 self._arrival_pending = False
160 self._wiring = SignalWiring(self)
161
162 # --- Lifecycle ---------------------------------------------------------
163
164 def on_ready(self):
165 self._wiring.want(SignalNames.VAULT_HACKED, self._on_vault_hacked)
166 self._wiring.want(SignalNames.REFINERY_BATCH_COMPLETED, self._on_refinery_batch)
167 self._wiring.want(SignalNames.ENEMY_KILLED, self._on_enemy_killed)
168 self._wiring.want(SignalNames.HUNTER_ARRIVED, self._on_hunter_arrived)
169 self._wiring.want(SignalNames.HUNTER_DEPARTED, self._on_hunter_departed)
170 self._wiring.want(SignalNames.SECTOR_ENTERED, self._on_sector_entered)
171 self._wiring.sweep()
172
173 def on_update(self, dt: float):
174 self._wiring.poll(dt)
175
176 # --- Writing -----------------------------------------------------------
177
178 def add(self, amount: int, reason: str) -> None:
179 """Charge *amount* of notoriety, filed under *reason*.
180
181 The ledger records what the feat was worth even when the tally is
182 already at ``balance.NOTORIETY_MAX``: a sector spent cracking vaults at
183 a capped reading was not a quiet sector, and pretending otherwise would
184 hand the player a free decay.
185 """
186 amount = int(amount)
187 if amount <= 0:
188 return
189 self.gains[reason] = self.gains.get(reason, 0) + amount
190 self.sector_gains[reason] = self.sector_gains.get(reason, 0) + amount
191 self._set_value(self.value + amount, reason)
192
193 def report_event(self, event: str) -> None:
194 """Charge one of the itemised gains in :data:`GAIN_EVENTS` by name."""
195 self.add(self.GAIN_EVENTS[event], event)
196
197 def on_sector_completed(self, was_quiet: bool | None = None) -> None:
198 """Settle the sector in progress, decaying by 5 if it was fully quiet.
199
200 *was_quiet* is normally left out and read off this sector's ledger: a
201 sector is quiet when nothing in it charged notoriety. Callers that know
202 better (a sector abandoned mid-jump, a scripted onboarding run) may say
203 so outright.
204 """
205 quiet = (not self.sector_gains) if was_quiet is None else bool(was_quiet)
206 self.sectors_completed += 1
207 self.sector_gains = {}
208 self._sector_open = False
209 if not quiet:
210 return
211 self.quiet_sectors += 1
212 self._set_value(self.value - balance.NOTORIETY_QUIET_SECTOR_DECAY, "quiet_sector")
213
214 def begin_sector(self) -> None:
215 """Open a fresh sector's ledger, settling any sector still open."""
216 if self._sector_open:
217 self.on_sector_completed()
218 self.sector_gains = {}
219 self._sector_open = True
220
221 # --- Reading -----------------------------------------------------------
222
223 @property
224 def sector_was_quiet(self) -> bool:
225 """Whether the sector in progress has charged nothing so far."""
226 return not self.sector_gains
227
228 def broker_unlocked(self) -> bool:
229 """Whether the black-market barge will show a notorious ship its bay."""
230 return self.value >= balance.BROKER_NOTORIETY_THRESHOLD
231
232 def bounties_doubled(self) -> bool:
233 """Whether bounty payouts pay twice."""
234 return self.value >= balance.BOUNTY_DOUBLE_NOTORIETY_THRESHOLD
235
236 def magistrates_active(self) -> bool:
237 """Whether the named bounty hunters have this ship on their books."""
238 return self.value >= balance.MAGISTRATE_NOTORIETY_THRESHOLD
239
240 def wake_accelerated(self) -> bool:
241 """Whether the Wake front is eating the chart at the faster rate."""
242 return self.value >= balance.WAKE_ACCEL_NOTORIETY
243
244 def roost_provocation_met(self, *, lure_assembled: bool = False) -> bool:
245 """Whether the Roost would answer.
246
247 The assembled Lure is worth ``balance.LURE_NOTORIETY_EQUIVALENT`` of
248 noise by itself, which is the whole reason it exists: the quietest
249 campaign in the game is never locked out of the true ending.
250 """
251 provocation = self.value + (balance.LURE_NOTORIETY_EQUIVALENT if lure_assembled else 0)
252 return provocation >= balance.ROOST_PROVOCATION_NOTORIETY
253
254 def bounty_payout(self, base: float) -> float:
255 """What a *base* bounty actually pays at this reading."""
256 return float(base) * (2.0 if self.bounties_doubled() else 1.0)
257
258 def wave_budget_bonus(self) -> float:
259 """The threat budget this reputation adds to every wave."""
260 return self.value / balance.WAVE_BUDGET_NOTORIETY_DIVISOR
261
262 def trail_brightness(self) -> float:
263 """Engine-trail brightness, 0 (unknown) to 1 (notorious).
264
265 The diegetic readout: ``vfx.EngineRibbon`` maps this onto the ribbon's
266 emissive off ``NOTORIETY_CHANGED``, so a hunting Magistrate reads the
267 ship's reputation off its own exhaust before the player does.
268 """
269 return max(0.0, min(self.value / float(balance.NOTORIETY_MAX), 1.0))
270
271 # --- Discount chits ----------------------------------------------------
272
273 def add_chit(self, source: str = "magistrate") -> None:
274 """Bank a black-market discount chit, the Magistrates' second drop."""
275 del source
276 self.chits += 1
277
278 def broker_discount(self) -> float:
279 """The fraction the banked chits take off a Broker price."""
280 return min(self.chits * BROKER_CHIT_DISCOUNT, BROKER_CHIT_DISCOUNT_MAX)
281
282 def spend_chit(self) -> bool:
283 """Redeem one chit. False when there is none to redeem."""
284 if self.chits <= 0:
285 return False
286 self.chits -= 1
287 return True
288
289 # --- Suspend save ------------------------------------------------------
290
291 def capture(self) -> dict:
292 """This tally as the suspend save's ``notoriety`` section."""
293 return {"value": self.value, "chits": self.chits, "gains": dict(self.gains)}
294
295 def restore(self, state: dict) -> None:
296 """Adopt a captured tally. Missing keys keep their current values."""
297 self.value = self._clamp(state.get("value", self.value))
298 self.chits = max(0, int(state.get("chits", self.chits)))
299 self.gains = {str(k): int(v) for k, v in dict(state.get("gains", self.gains)).items()}
300 self.sector_gains = {}
301 self.notoriety_changed(self.value, "restored")
302
303 # --- Internals ---------------------------------------------------------
304
305 @staticmethod
306 def _clamp(value) -> int:
307 return max(0, min(int(value), balance.NOTORIETY_MAX))
308
309 def _set_value(self, value: int, reason: str) -> None:
310 value = self._clamp(value)
311 if value == self.value:
312 return
313 self.value = value
314 self.notoriety_changed(value, reason)
315
316 # --- Signal handlers ---------------------------------------------------
317
318 def _on_vault_hacked(self, scrap: float) -> None:
319 self.report_event("vault")
320
321 def _on_refinery_batch(self, cores: float) -> None:
322 self.report_event("refinery")
323
324 def _on_enemy_killed(self, archetype: str, position, elite: bool) -> None:
325 if elite:
326 self.report_event("elite_kill")
327
328 def _on_hunter_arrived(self, arrival_index: int) -> None:
329 self._arrival_pending = True
330
331 def _on_hunter_departed(self) -> None:
332 """An arrival the ship lived through. A departure alone is not a feat."""
333 if not self._arrival_pending:
334 return
335 self._arrival_pending = False
336 self.report_event("arrival_survived")
337
338 def _on_sector_entered(self, sector_index: int, biome_id: str) -> None:
339 self.begin_sector()
340
341
342# ============================================================================
343# Set-piece scaffolding
344# ============================================================================
345
346#: ``Enemy.__init__`` resolves its spec from ``balance.ENEMIES``, which carries
347#: the nine wave archetypes and neither set piece. A set piece therefore hands
348#: the base a roster key to look up and installs its own spec and archetype id
349#: immediately afterwards. The base wants a ``SPEC`` hook so this stand-in can
350#: go; until it has one, nothing outside :meth:`SetPieceEnemy.__init__` ever
351#: sees the borrowed entry.
352_ROSTER_STAND_IN = "husk_turret"
353
354
355def _named_child(node: Node3D, name: str) -> Node3D:
356 """The direct child of *node* called *name*. The art kit promises it."""
357 return next(child for child in node.children if child.name == name)
358
359
360class SetPieceEnemy(AdvancedEnemy):
361 """A mini-boss that carries its own spec instead of a wave-roster entry.
362
363 Everything else is an ordinary enemy: it sits in ``Groups.ENEMIES`` on
364 ``Layers.ENEMY``, it telegraphs white before it attacks, and it dies through
365 the damage router like any mite. What it does not do is cost threat budget:
366 a set piece is placed by the sector or jumped in by a director, never bought
367 by the wave composer.
368 """
369
370 #: This set piece's own entry from ``balance``. Subclasses override.
371 SPEC: balance.EnemySpec = balance.WARDEN
372 ARCHETYPE = _ROSTER_STAND_IN
373
374 def __init__(self, **kwargs):
375 super().__init__(**kwargs)
376 self.ARCHETYPE = self.SPEC.id
377 self.spec = self.SPEC
378 self.hp = float(self.SPEC.hp)
379 self.max_hp = float(self.SPEC.hp)
380
381 def ship_bearing(self) -> float | None:
382 """Heading from this hull toward the ship, or None when there is none."""
383 ship = self.player_ship()
384 if ship is None:
385 return None
386 return bearing(self.plane_position, to_plane(ship.position))
387
388
389# ============================================================================
390# The Warden
391# ============================================================================
392
393
394class Warden(SetPieceEnemy):
395 """A stationary guard with a rotating shield arc, parked over something good.
396
397 The mirror match: it carries the player's own ``balance.SHIELD_ARC_DEGREES``
398 arc, sweeping steadily, blocking everything inside the cone and nothing
399 outside it. Killing 1,600 hull points therefore means reading the sweep and
400 shooting the gap, which is the same skill the player's own arc asks of them,
401 demonstrated by something that does it better.
402
403 It is a fight you choose to start. A Warden sleeps until the ship comes
404 inside :data:`WARDEN_AGGRO_RANGE` or shoots it, and it leashes to whatever
405 it guards, so a vault can always be left alone.
406 """
407
408 SPEC = balance.WARDEN
409 SPEED = 0.0
410 HITBOX_RADIUS = WARDEN_HITBOX_RADIUS
411
412 #: (blocked amount) whenever the arc eats a hit. The vfx layer sparks on it.
413 shield_blocked = Signal(float)
414 #: (what it was guarding) once, as the hull comes apart.
415 warden_defeated = Signal(str)
416
417 def __init__(self, *, guarding: str = "hoard", **kwargs):
418 super().__init__(**kwargs)
419 #: What this Warden is parked over: "vault", "broker" or "hoard".
420 self.guarding = str(guarding)
421 #: Arc centre as a world heading, anticlockwise from +X seen from above.
422 self.arc_centre = 0.0
423 self.half_width = math.radians(balance.SHIELD_ARC_DEGREES) * 0.5
424 self.aggroed = False
425 #: Where it was placed; it never leaves this point.
426 self.guard_point = Vec2(0.0, 0.0)
427 self.damage_blocked = 0.0
428 #: The art kit's emissive cone, yawed onto the live arc every frame.
429 self.arc_art: Node3D | None = None
430 #: The emplacement, yawed at whatever the Warden is shooting.
431 self.gun_art: Node3D | None = None
432 self._until_shot = WARDEN_FIRE_INTERVAL_S
433
434 # -- Lifecycle ----------------------------------------------------------
435
436 def on_ready(self):
437 super().on_ready()
438 self.guard_point = self.plane_position
439
440 def build_body(self):
441 """The art kit's bastion. The hull itself never yaws: it is a fort,
442 and the two things on it that aim, the arc and the gun, aim alone."""
443 art = self.add_child(artkit.build_warden())
444 self.arc_art = _named_child(art, "ArcArt")
445 self.gun_art = _named_child(art, "GunArt")
446
447 # -- The arc ------------------------------------------------------------
448
449 def covers(self, incoming_direction: Vec3) -> bool:
450 """True when the arc faces *incoming_direction*.
451
452 *incoming_direction* is the world direction from this hull toward the
453 source of the damage, the same convention the player's own
454 ``ship.ShieldArc.covers`` speaks. A zero-length direction names no
455 bearing and is never covered.
456 """
457 x, z = float(incoming_direction.x), float(incoming_direction.z)
458 if math.hypot(x, z) < 1e-9:
459 return False
460 return abs(wrap_angle(math.atan2(-z, x) - self.arc_centre)) <= self.half_width + 1e-9
461
462 def covers_bearing(self, heading: float) -> bool:
463 """True when the arc faces a heading in radians."""
464 return abs(wrap_angle(float(heading) - self.arc_centre)) <= self.half_width + 1e-9
465
466 def take_damage(self, amount: float, kind: str) -> None:
467 """Eat the hit if the arc is facing it, otherwise take it on bare hull.
468
469 The router's kill-side interface carries no direction, so the bearing
470 under attack is read off the ship: on a single flight plane that is the
471 bearing the player is shooting from, which is the one the fight is
472 about.
473 """
474 if self.destroying or amount <= 0.0:
475 return
476 self.aggroed = True
477 heading = self.ship_bearing()
478 if heading is not None and self.covers_bearing(heading):
479 self.damage_blocked += float(amount)
480 self.shield_blocked(float(amount))
481 return
482 super().take_damage(amount, kind)
483
484 def kill(self) -> None:
485 if self.destroying:
486 return
487 self.warden_defeated(self.guarding)
488 super().kill()
489
490 # -- Behaviour ----------------------------------------------------------
491
492 def on_update(self, dt: float):
493 super().on_update(dt)
494 self.arc_centre = wrap_angle(self.arc_centre + WARDEN_ARC_ROTATION_RATE * dt)
495 if self.arc_art is not None:
496 self.arc_art.rotation = Quat.from_euler(0.0, self.arc_centre, 0.0)
497 self._update_aggro()
498 if not self.aggroed:
499 return
500 self._until_shot -= dt
501 if self._until_shot > 0.0:
502 return
503 if self.begin_attack():
504 self._until_shot = WARDEN_FIRE_INTERVAL_S
505
506 def perform_attack(self) -> None:
507 ship = self.player_ship()
508 if ship is None or self.distance_to(ship) > WARDEN_GUN_RANGE:
509 return
510 heading = self.ship_bearing()
511 if heading is not None and self.gun_art is not None:
512 self.gun_art.rotation = Quat.from_euler(0.0, heading, 0.0)
513 self.deal_damage_to(ship, self.spec.damage, "energy")
514
515 def _update_aggro(self) -> None:
516 ship = self.player_ship()
517 if ship is None:
518 self.aggroed = False
519 return
520 distance = self.distance_to(ship)
521 if not self.aggroed and distance <= WARDEN_AGGRO_RANGE:
522 self.aggroed = True
523 elif self.aggroed and distance > WARDEN_DISENGAGE_RANGE:
524 self.aggroed = False
525
526
527def spawn_warden(parent: Node3D, point: Vec2, *, guarding: str = "hoard", name: str = "Warden") -> Warden:
528 """Park a Warden on a plane point, guarding a vault, a hoard or the Broker."""
529 warden = Warden(name=name, guarding=guarding, position=from_plane(Vec2(float(point.x), float(point.y))))
530 parent.add_child(warden)
531 return warden
532
533
534# ============================================================================
535# The Magistrates
536# ============================================================================
537
538
539@dataclass(frozen=True)
540class MagistrateSpec:
541 """One named bounty hunter: who they are and what they do that nobody else does."""
542
543 id: str
544 title: str
545 move_id: str
546 #: One line for the codex and for the HUD stamp when they tear in.
547 silhouette: str
548 accent: tuple[float, float, float, float]
549
550
551#: The three, and only three. Twelve was a budget sink; these each have to earn
552#: their silhouette, so each gets exactly one move nothing else in the game
553#: does. The accents are three shades of the enemy palette's magenta: a
554#: Magistrate must read as hostile at the same glance that names it.
555MAGISTRATES: dict[str, MagistrateSpec] = {
556 "harrier": MagistrateSpec(
557 "harrier",
558 "Marshal Harrier",
559 "lance_run",
560 "twin booms around a needle nose",
561 (1.00, 0.32, 0.62, 1.0),
562 ),
563 "caracara": MagistrateSpec(
564 "caracara",
565 "Vice-Marshal Caracara",
566 "snare",
567 "a broad ringed hull with two claws",
568 (0.92, 0.14, 0.86, 1.0),
569 ),
570 "nightjar": MagistrateSpec(
571 "nightjar",
572 "Magistrate Nightjar",
573 "dark_lantern",
574 "a narrow shard carrying one shuttered lamp",
575 (0.66, 0.22, 1.00, 1.0),
576 ),
577}
578
579
580class Magistrate(SetPieceEnemy):
581 """A named bounty hunter who came for the ship's reputation.
582
583 A Magistrate duels: it holds :data:`MAGISTRATE_STANDOFF_DISTANCE`, trades
584 telegraphed bolts, and every :data:`MAGISTRATE_MOVE_INTERVAL_S` plays the
585 one move that is its own. Killing one pays
586 ``balance.MAGISTRATE_SCRAP_DROP_MIN`` to ``MAX`` scrap, doubled once the
587 tally has passed ``balance.BOUNTY_DOUBLE_NOTORIETY_THRESHOLD``, plus a
588 black-market discount chit: notoriety is the reason they came and the reason
589 they are worth killing.
590
591 Subclasses supply the silhouette and :meth:`perform_signature_move`.
592 """
593
594 SPEC = balance.MAGISTRATE
595 SPEED = MAGISTRATE_SPEED
596 HITBOX_RADIUS = MAGISTRATE_HITBOX_RADIUS
597 #: Key into :data:`MAGISTRATES`; subclasses set it.
598 MAGISTRATE_ID: str = ""
599
600 #: (magistrate id, scrap paid) once the hull comes apart.
601 bounty_claimed = Signal(str, float)
602 #: (magistrate id, move id) as the move's telegraph starts.
603 signature_move_started = Signal(str, str)
604
605 def __init__(self, **kwargs):
606 super().__init__(**kwargs)
607 self.magistrate = MAGISTRATES[self.MAGISTRATE_ID]
608 self.telegraph_duration = balance.ENEMY_FIRE_TELEGRAPH_S
609 self.moves_played = 0
610 self._until_shot = MAGISTRATE_FIRE_INTERVAL_S
611 self._until_move = MAGISTRATE_MOVE_INTERVAL_S
612 self._move_pending = False
613
614 # -- Identity -----------------------------------------------------------
615
616 @property
617 def magistrate_id(self) -> str:
618 """Which of the three this is."""
619 return self.MAGISTRATE_ID
620
621 @property
622 def move_id(self) -> str:
623 """The id of this Magistrate's signature move."""
624 return self.magistrate.move_id
625
626 def build_body(self):
627 """The art kit's hunter frame, wearing this Magistrate's variant.
628
629 One shared sleek silhouette carries "a hunter is here"; the variant
630 geometry carries the name, and the accent is this hunter's own shade
631 of the enemy magenta.
632 """
633 self.add_child(artkit.build_magistrate(self.MAGISTRATE_ID, accent=self.magistrate.accent))
634
635 # -- Behaviour ----------------------------------------------------------
636
637 def on_update(self, dt: float):
638 super().on_update(dt)
639 ship = self.player_ship()
640 if ship is None:
641 return
642 self._until_move -= dt
643 self._until_shot -= dt
644 if self._until_move <= 0.0 and self.can_play_move(ship):
645 self.begin_signature_move()
646 return
647 if self._until_shot <= 0.0 and self.distance_to(ship) <= MAGISTRATE_GUN_RANGE and self.begin_attack():
648 self._until_shot = MAGISTRATE_FIRE_INTERVAL_S
649
650 def on_fixed_update(self, dt: float):
651 ship = self.player_ship()
652 if ship is None:
653 return
654 self.hold_standoff(to_plane(ship.position), dt, MAGISTRATE_STANDOFF_DISTANCE, MAGISTRATE_STANDOFF_BAND)
655
656 def begin_signature_move(self) -> bool:
657 """Telegraph the move, longer than a bolt, and play it when the flash ends."""
658 self.telegraph_duration = MAGISTRATE_MOVE_TELEGRAPH_S
659 self._move_pending = True
660 if not self.begin_attack():
661 self._move_pending = False
662 self.telegraph_duration = balance.ENEMY_FIRE_TELEGRAPH_S
663 return False
664 self._until_move = MAGISTRATE_MOVE_INTERVAL_S
665 self.signature_move_started(self.magistrate_id, self.move_id)
666 return True
667
668 def perform_attack(self) -> None:
669 if self._move_pending:
670 self._move_pending = False
671 self.telegraph_duration = balance.ENEMY_FIRE_TELEGRAPH_S
672 self.moves_played += 1
673 self.perform_signature_move()
674 return
675 ship = self.player_ship()
676 if ship is None or self.distance_to(ship) > MAGISTRATE_GUN_RANGE:
677 return
678 self.deal_damage_to(ship, self.spec.damage, "energy")
679
680 def can_play_move(self, ship) -> bool:
681 """Whether the signature move is worth playing from here. Overridden."""
682 return self.distance_to(ship) <= MAGISTRATE_GUN_RANGE
683
684 def perform_signature_move(self) -> None:
685 """Play the one move that is this Magistrate's. Overridden."""
686
687 # -- The bounty ---------------------------------------------------------
688
689 def kill(self) -> None:
690 if self.destroying:
691 return
692 self.pay_bounty()
693 super().kill()
694
695 def pay_bounty(self) -> float:
696 """Bank the bounty and the chit. Returns the scrap paid."""
697 base = float(self.rng.randint(balance.MAGISTRATE_SCRAP_DROP_MIN, balance.MAGISTRATE_SCRAP_DROP_MAX))
698 tally = self.notoriety()
699 paid = tally.bounty_payout(base) if tally is not None else base
700 economy = self._service(Services.ECONOMY)
701 if economy is not None:
702 economy.add_scrap(paid, "bounty")
703 if tally is not None:
704 tally.add_chit(self.magistrate_id)
705 self.bounty_claimed(self.magistrate_id, paid)
706 return paid
707
708 def notoriety(self) -> Notoriety | None:
709 """The run's tally, or None in a scene assembled without one."""
710 return self._service(Services.NOTORIETY)
711
712 def _service(self, name: str):
713 tree = self.tree
714 return tree.singletons.get(name) if tree is not None else None
715
716
717class Harrier(Magistrate):
718 """Marshal Harrier: twin booms, and a lance run straight down your throat.
719
720 The move is a committed dash through the ship's position at
721 :data:`LANCE_RUN_SPEED_MULT` of its cruise, hitting once for
722 :data:`LANCE_RUN_DAMAGE_MULT` of a bolt. It is a line drawn before it is
723 flown, so the answer is the same sidestep a Lancer teaches, asked at a speed
724 that punishes reading it late.
725 """
726
727 MAGISTRATE_ID = "harrier"
728
729 def __init__(self, **kwargs):
730 super().__init__(**kwargs)
731 self._dash_remaining = 0.0
732 self._dash_direction = Vec2(1.0, 0.0)
733 self._dash_hit = False
734
735 @property
736 def dashing(self) -> bool:
737 """Whether a lance run is in flight."""
738 return self._dash_remaining > 0.0
739
740 def perform_signature_move(self) -> None:
741 ship = self.player_ship()
742 if ship is None:
743 return
744 target = to_plane(ship.position)
745 here = self.plane_position
746 dx, dz = float(target.x) - float(here.x), float(target.y) - float(here.y)
747 length = math.hypot(dx, dz)
748 if length < 1e-6:
749 return
750 self._dash_direction = Vec2(dx / length, dz / length)
751 self._dash_remaining = LANCE_RUN_S
752 self._dash_hit = False
753
754 def on_fixed_update(self, dt: float):
755 if not self.dashing:
756 super().on_fixed_update(dt)
757 return
758 self._dash_remaining = max(0.0, self._dash_remaining - dt)
759 self.move(dt, self._dash_direction, self.SPEED * LANCE_RUN_SPEED_MULT)
760 self.face(self._dash_direction)
761 ship = self.player_ship()
762 if ship is None or self._dash_hit or self.distance_to(ship) > LANCE_RUN_HIT_RADIUS:
763 return
764 self._dash_hit = True
765 self.deal_damage_to(ship, self.spec.damage * LANCE_RUN_DAMAGE_MULT, "impact")
766
767
768class Caracara(Magistrate):
769 """Vice-Marshal Caracara: a ringed hull, two claws, and a snare line.
770
771 The move costs no hull at all. It hooks the ship and drags it toward the
772 Caracara for :data:`SNARE_S` seconds, which is the one thing this game can
773 take away that hurts more than damage: your position. Break it by out-flying
774 the pull or by killing the hunter holding the line.
775 """
776
777 MAGISTRATE_ID = "caracara"
778
779 def __init__(self, **kwargs):
780 super().__init__(**kwargs)
781 self._snare_remaining = 0.0
782
783 @property
784 def snaring(self) -> bool:
785 """Whether the snare line is live."""
786 return self._snare_remaining > 0.0
787
788 def can_play_move(self, ship) -> bool:
789 return self.distance_to(ship) <= SNARE_RANGE
790
791 def perform_signature_move(self) -> None:
792 self._snare_remaining = SNARE_S
793
794 def on_fixed_update(self, dt: float):
795 super().on_fixed_update(dt)
796 if not self.snaring:
797 return
798 self._snare_remaining = max(0.0, self._snare_remaining - dt)
799 ship = self.player_ship()
800 if ship is None:
801 return
802 velocity = getattr(ship, "velocity", None)
803 if velocity is None:
804 return
805 pull = self._pull_toward(ship)
806 ship.velocity = Vec2(
807 float(velocity.x) + float(pull.x) * SNARE_PULL_PER_S * dt,
808 float(velocity.y) + float(pull.y) * SNARE_PULL_PER_S * dt,
809 )
810
811 def _pull_toward(self, ship) -> Vec2:
812 here = self.plane_position
813 there = to_plane(ship.position)
814 dx, dz = float(here.x) - float(there.x), float(here.y) - float(there.y)
815 length = math.hypot(dx, dz)
816 if length < 1e-6:
817 return Vec2(0.0, 0.0)
818 return Vec2(dx / length, dz / length)
819
820
821class Nightjar(Magistrate):
822 """Magistrate Nightjar: a shard with one shuttered lamp, and a dark lantern.
823
824 The move opens the lamp and takes the capacitor: up to
825 :data:`DARK_LANTERN_CAPACITOR_DRAIN` through :class:`power.PowerSystem`, the
826 same door every other spend uses, so a fat capacitor, a running generator
827 and silent running all change what it costs. Nightjar does not kill you; it
828 takes away the shield, the beam and the afterburner and lets the sector do
829 it.
830 """
831
832 MAGISTRATE_ID = "nightjar"
833
834 def can_play_move(self, ship) -> bool:
835 return self.distance_to(ship) <= DARK_LANTERN_RANGE
836
837 def perform_signature_move(self) -> None:
838 ship = self.player_ship()
839 power = self._service(Services.POWER)
840 if ship is None or power is None or self.distance_to(ship) > DARK_LANTERN_RANGE:
841 return
842 drained = min(DARK_LANTERN_CAPACITOR_DRAIN, float(power.capacitor))
843 if drained > 0.0:
844 power.request(drained, "magistrate_dark_lantern")
845
846
847#: Every Magistrate by id, in the order they are first seen.
848MAGISTRATE_TYPES: dict[str, type[Magistrate]] = {
849 Harrier.MAGISTRATE_ID: Harrier,
850 Caracara.MAGISTRATE_ID: Caracara,
851 Nightjar.MAGISTRATE_ID: Nightjar,
852}
853
854
855# ============================================================================
856# The jump-in schedule
857# ============================================================================
858
859
860class MagistrateDirector(Node):
861 """Decides when a named bounty hunter tears into the sector.
862
863 One at a time, never during a hunter arrival (the arrival is the wave, and
864 the Shrike does not share), and never at all below
865 ``balance.MAGISTRATE_NOTORIETY_THRESHOLD``: the Magistrates are the price of
866 a reputation, so a quiet run never meets one. The three are drawn in a
867 seeded order and none repeats until all three have been seen.
868 """
869
870 #: (magistrate id) as the hull tears in.
871 magistrate_jumped_in = Signal(str)
872
873 def __init__(self, *, seed: int = 0, **kwargs):
874 super().__init__(**kwargs)
875 self.armed = True
876 self.jumped_in = 0
877 #: Where a Magistrate is parented; defaults to the run scene root.
878 self.spawn_parent: Node3D | None = None
879 self.seconds_to_jump = MAGISTRATE_JUMP_IN_DELAY_S
880 #: True between ``HUNTER_ARRIVED`` and ``HUNTER_DEPARTED``. The hunter
881 #: node exists through the whole telegraph ladder, so its presence in
882 #: the tree is not the question; whether it is in the arena is.
883 self.hunter_present = False
884 self._rng = random.Random(seed)
885 self._order: list[str] = []
886 self._wiring = SignalWiring(self)
887
888 # -- Lifecycle ----------------------------------------------------------
889
890 def on_ready(self):
891 self._wiring.want(SignalNames.SECTOR_ENTERED, self._on_sector_entered)
892 self._wiring.want(SignalNames.HUNTER_ARRIVED, self._on_hunter_arrived)
893 self._wiring.want(SignalNames.HUNTER_DEPARTED, self._on_hunter_departed)
894 self._wiring.sweep()
895
896 def on_update(self, dt: float):
897 self._wiring.poll(dt)
898 if not self.armed:
899 return
900 self.seconds_to_jump -= dt
901 if self.seconds_to_jump > 0.0:
902 return
903 if self.jump_in() is None:
904 self.seconds_to_jump = MAGISTRATE_JUMP_IN_RETRY_S
905
906 # -- Reading ------------------------------------------------------------
907
908 def notoriety(self) -> Notoriety | None:
909 """The run's tally, or None in a scene assembled without one."""
910 tree = self.tree
911 return tree.singletons.get(Services.NOTORIETY) if tree is not None else None
912
913 def live_magistrate(self) -> Magistrate | None:
914 """The Magistrate currently in the sector, or None."""
915 tree = self.tree
916 if tree is None:
917 return None
918 for enemy in tree.group(Groups.ENEMIES):
919 if isinstance(enemy, Magistrate) and not enemy.destroying:
920 return enemy
921 return None
922
923 def may_jump_in(self) -> bool:
924 """Whether the sector would accept a Magistrate right now."""
925 tally = self.notoriety()
926 if tally is None or not tally.magistrates_active():
927 return False
928 if self.hunter_present or self.live_magistrate() is not None:
929 return False
930 return self.tree is not None
931
932 def next_id(self) -> str:
933 """The id the next jump-in will use, without consuming it."""
934 if not self._order:
935 self._order = self._fresh_order()
936 return self._order[0]
937
938 # -- Staging ------------------------------------------------------------
939
940 def jump_in(self, magistrate_id: str | None = None) -> Magistrate | None:
941 """Tear a Magistrate in on the spawn ring. None when the sector says no."""
942 if magistrate_id is None and not self.may_jump_in():
943 return None
944 parent = self._parent_for_spawn()
945 if parent is None:
946 return None
947 chosen = magistrate_id if magistrate_id is not None else self._take_id()
948 heading = self._rng.uniform(0.0, math.tau)
949 centre = self._ship_centre()
950 position = Vec2(
951 float(centre.x) + math.cos(heading) * MAGISTRATE_SPAWN_RADIUS,
952 float(centre.y) + math.sin(heading) * MAGISTRATE_SPAWN_RADIUS,
953 )
954 magistrate = MAGISTRATE_TYPES[chosen](
955 name=f"Magistrate{chosen.title()}",
956 seed=self._rng.randrange(1 << 30),
957 position=from_plane(position),
958 )
959 parent.add_child(magistrate)
960 self.jumped_in += 1
961 self.seconds_to_jump = MAGISTRATE_JUMP_IN_DELAY_S
962 self.magistrate_jumped_in(chosen)
963 return magistrate
964
965 # -- Internals ----------------------------------------------------------
966
967 def _fresh_order(self) -> list[str]:
968 order = list(MAGISTRATE_TYPES)
969 self._rng.shuffle(order)
970 return order
971
972 def _take_id(self) -> str:
973 if not self._order:
974 self._order = self._fresh_order()
975 return self._order.pop(0)
976
977 def _ship_centre(self) -> Vec2:
978 tree = self.tree
979 if tree is not None:
980 for ship in tree.group(Groups.SHIP):
981 if not ship.destroying:
982 return to_plane(ship.position)
983 return Vec2(0.0, 0.0)
984
985 def _parent_for_spawn(self) -> Node | None:
986 if self.spawn_parent is not None and self.spawn_parent.tree is not None:
987 return self.spawn_parent
988 tree = self.tree
989 if tree is None:
990 return None
991 return tree.root if tree.root is not None else None
992
993 def _on_sector_entered(self, sector_index: int, biome_id: str) -> None:
994 self.seconds_to_jump = MAGISTRATE_JUMP_IN_DELAY_S
995
996 def _on_hunter_arrived(self, arrival_index: int) -> None:
997 self.hunter_present = True
998
999 def _on_hunter_departed(self) -> None:
1000 self.hunter_present = False
1001
1002
1003__all__ = [
1004 "BROKER_CHIT_DISCOUNT",
1005 "BROKER_CHIT_DISCOUNT_MAX",
1006 "MAGISTRATES",
1007 "MAGISTRATE_TYPES",
1008 "Caracara",
1009 "Harrier",
1010 "Magistrate",
1011 "MagistrateDirector",
1012 "MagistrateSpec",
1013 "Nightjar",
1014 "Notoriety",
1015 "SetPieceEnemy",
1016 "Warden",
1017 "spawn_warden",
1018]