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]