shrike/power.py¶

Part of SHRIKE.

   1"""Power generation, life support, and the silent-running posture.
   2
   3The capacitor is the tactical blood supply: weapons, the shield, the
   4afterburner and the scoop all spend from it through :meth:`PowerSystem.request`
   5and :meth:`PowerSystem.drain`, and nothing else is allowed to touch the number.
   6Generation comes from socketed modules, and each one prices its output in a
   7different currency:
   8
   9* :class:`SolarWings` are silent and free, but only pay in lit biomes, take two
  10  seconds to unfold or fold away, and widen the ship's hitbox while out.
  11* :class:`Generator` pays anywhere and pays best, and charges fuel and noise.
  12* :class:`RTG` pays a trickle, silently, forever, and is the only source that
  13  survives silent running.
  14
  15Silent running is the posture that inverts the whole economy: generation
  16collapses to the RTG, the generator shuts down, weapons go offline (enforced by
  17``weapons.py`` off ``SILENT_RUNNING_CHANGED``), and the signature meter starts
  18falling toward the act floor instead of climbing.
  19
  20Underneath all of it sits the hull's own emergency bus, a trickle no module
  21supplies and no posture stops. It is what makes being out of power a setback
  22rather than the end of the run: it cannot pay for a fight, but it always pays
  23for the next shot eventually.
  24
  25Life support and the fuel burn tick here too, so that the ship's breaches and
  26the generator's appetite are read from one place per frame.
  27"""
  28
  29import importlib.util
  30import math
  31from collections.abc import Callable, Iterator
  32from weakref import WeakKeyDictionary, WeakSet
  33
  34from simvx.core import Input, MeshInstance3D, Node, Node3D, Quat, Signal, Vec3
  35
  36from . import balance
  37from .runtime import Services, SignalNames
  38
  39#: Wings per solar array. Balance sizes one wing; the array is always a pair,
  40#: so a sheared wing costs exactly half the array's output.
  41SOLAR_WING_COUNT = 2
  42
  43#: How often deferred signal wiring and power-source discovery re-walk the
  44#: tree, in seconds. Sectors, enemies and socketed modules all come and go
  45#: mid-run, so both passes are periodic rather than one-shot.
  46REWIRE_INTERVAL_S = 0.5
  47
  48#: The hull's emergency bus, in energy per second. Always on, whatever is
  49#: fitted and whatever the posture, and far under any weapon's draw: it buys a
  50#: way out of a dead capacitor, never a sustained fight.
  51POWER_EMERGENCY_TRICKLE_PER_S = balance.POWER_EMERGENCY_TRICKLE_PER_S
  52
  53#: Capacitor level a weapon refuses to fire below. Above it a shot is always
  54#: allowed, even one the tank cannot fully cover, so a fight never ends because
  55#: the capacitor was a fraction short of the exact cost.
  56WEAPONS_ENERGY_FLOOR = balance.WEAPONS_ENERGY_FLOOR
  57
  58#: Seconds between ``ENERGY_DENIED`` emissions per consumer. A held beam over
  59#: an empty capacitor asks every frame; one click and one flash a second says
  60#: "no power" without turning the mix into a geiger counter. Denials are still
  61#: refused every frame, only the cue is limited.
  62ENERGY_DENIED_CUE_INTERVAL_S = 1.0
  63
  64#: What C says on a run that has not unlocked the posture yet. The onboarding
  65#: schedule holds silent running back until run three; before that the key must
  66#: not silently do nothing, and it must not act either, because a whole-mix
  67#: low-pass with no HUD light and no card is a haunted soundtrack, not a
  68#: mechanic.
  69SILENT_RUNNING_LOCKED_TOAST = "SILENT RUNNING COMES ONLINE ON A LATER FLIGHT"
  70
  71# ============================================================================
  72# The modules' own geometry
  73#
  74# The three generation modules move real state (a widened hitbox, thirty hit
  75# points a wing, a fuel burn, a hum that is the signature warning) and for a
  76# long time none of them put a single triangle on the hull. A pilot pressed
  77# X, the array's signal fired, the capacitor started filling, and the ship on
  78# screen was unchanged; the whole power economy was a HUD reading with no
  79# object behind it. The constants below are how each module reads at a glance.
  80# ============================================================================
  81
  82#: Quarter turn about +Y that brings an art-kit build (nose along +X) onto the
  83#: hull's own frame, where the nose runs down -Z. The ship applies exactly this
  84#: to its hull art; a socketed module inherits the hull's transform, so it
  85#: applies it too rather than inventing a second convention.
  86ART_QUARTER_TURN = Quat.from_axis_angle(Vec3(0.0, 1.0, 0.0), math.pi * 0.5)
  87
  88#: How far a folded wing stands out of the flight plane. Just short of upright,
  89#: so a stowed array still catches an edge of light and reads as folded rather
  90#: than as missing.
  91SOLAR_FOLD_RADIANS = math.radians(84.0)
  92#: Span a fully folded wing still shows, as a fraction of its built span: the
  93#: panels concertina into the root rather than shrinking to nothing.
  94SOLAR_STOWED_SPAN = 0.18
  95#: Cell glow as a multiple of the palette's accent strength, at zero output and
  96#: at the array's full rated output. The floor is what keeps a deployed array in
  97#: a nebula visible as unlit hardware rather than absent.
  98SOLAR_GLOW_DARK = 0.12
  99SOLAR_GLOW_LIT = 1.4
 100#: What a wing that has taken damage but still carries cells is dimmed to.
 101SOLAR_GLOW_DAMAGED = 0.35
 102#: Hit points below which a wing counts as visibly damaged, as a fraction.
 103SOLAR_DAMAGED_FRACTION = 0.6
 104
 105#: The generator's vent pulses at the hum's own rate, between these multiples of
 106#: the accent strength. Off is off: a stopped generator shows a dark vent, which
 107#: is the difference the silent-running posture is played around.
 108GENERATOR_HUM_HZ = 1.7
 109GENERATOR_GLOW_MIN = 0.55
 110GENERATOR_GLOW_MAX = 1.6
 111#: How often the vent is actually rewritten while the generator turns. The hum
 112#: is under two cycles a second, so thirty steps a second draws the same pulse
 113#: the eye sees; rewriting it once per frame instead ties the cost of an idle
 114#: animation to the panel's refresh rate, which is how a game gets hotter on
 115#: better hardware for no visible gain.
 116GENERATOR_VENT_REFRESH_S = 1.0 / 30.0
 117
 118#: The RTG's window never moves. It is the only source silent running leaves
 119#: running, so a dark hull with one warm band still lit is what the posture
 120#: looks like from outside.
 121RTG_GLOW = 0.75
 122
 123
 124def _artkit():
 125    """The art kit, or None in a tree built without it.
 126
 127    Modules are meant to survive a partial checkout the same way the ship does:
 128    without the kit a generator is still a generator, it simply has no vent to
 129    light up.
 130    """
 131    if importlib.util.find_spec("shrike.artkit") is None:
 132        return None
 133    from . import artkit
 134
 135    return artkit
 136
 137
 138def _accent_materials(node: Node3D) -> list:
 139    """Every emissive accent material under *node*, in tree order."""
 140    from .artkit import ROLE_ACCENT
 141
 142    return [i.material for i in node.find_all(MeshInstance3D) if i.name == ROLE_ACCENT and i.material is not None]
 143
 144
 145def accent_strength() -> float:
 146    """The player palette's accent strength, looked up once.
 147
 148    Every lit module scales its glow by this, and the palette is a constant of
 149    the art kit, so the lookup is memoised rather than repeated per module per
 150    frame.
 151    """
 152    global _ACCENT_STRENGTH
 153    if _ACCENT_STRENGTH is None:
 154        from .artkit import palette
 155
 156        _ACCENT_STRENGTH = float(palette("player").accent_strength)
 157    return _ACCENT_STRENGTH
 158
 159
 160#: Memo behind :func:`accent_strength`.
 161_ACCENT_STRENGTH: float | None = None
 162
 163
 164def is_weapon_consumer(consumer: str) -> bool:
 165    """True if *consumer* is a weapon id, and so spends under the reserve floor."""
 166    return consumer in balance.WEAPONS
 167
 168
 169def iter_tree_nodes(tree) -> Iterator[Node]:
 170    """Every node in *tree*: the singletons' subtrees first, then the scene."""
 171    seen: set[int] = set()
 172    roots = list(tree.singletons.values())
 173    if tree.root is not None:
 174        roots.append(tree.root)
 175    for root in roots:
 176        for node in root.walk():
 177            if id(node) not in seen:
 178                seen.add(id(node))
 179                yield node
 180
 181
 182# ============================================================================
 183# Deferred signal wiring
 184# ============================================================================
 185
 186
 187class SignalWiring:
 188    """Connect an owner's handlers to emitters that need not exist yet.
 189
 190    Modules mount in an order no single module controls, and the interesting
 191    emitters (the sector, a refinery, a screamer that spawned nine seconds ago)
 192    appear and vanish during a run. A one-shot ``connect`` in ``on_ready``
 193    therefore misses most of them. This walks the tree on an interval and
 194    connects every emitter carrying a wanted signal exactly once, tracking what
 195    it has already wired in a weak map so a destroyed node costs nothing.
 196
 197    The interval leaves one blind spot: a node that emits during its own
 198    ``on_ready`` has, by definition, never been swept. An emitter about to do
 199    that calls :meth:`sweep_all` first, which brings every live wiring up to
 200    date before the signal fires (the sector does this for its one-shot
 201    ``sector_entered``).
 202
 203    Usage::
 204
 205        self._wiring = SignalWiring(self)
 206        self._wiring.want(SignalNames.VAULT_HACKED, self._on_vault)
 207        self._wiring.sweep()          # in on_ready, for emitters already up
 208        self._wiring.poll(dt)         # in on_update, for the ones still coming
 209    """
 210
 211    #: Every live wiring (a ``WeakSet[SignalWiring]``), so an emitter can
 212    #: demand an immediate global sweep.
 213    _instances: WeakSet = WeakSet()
 214
 215    def __init__(self, owner: Node, *, interval_s: float = REWIRE_INTERVAL_S):
 216        self._owner = owner
 217        self._interval = float(interval_s)
 218        self._handlers: dict[str, Callable] = {}
 219        self._wired: WeakKeyDictionary[Node, set[str]] = WeakKeyDictionary()
 220        self._since_sweep = self._interval
 221        SignalWiring._instances.add(self)
 222
 223    @classmethod
 224    def sweep_all(cls) -> None:
 225        """Sweep every live wiring now.
 226
 227        For an emitter mounted moments ago and about to fire a signal no
 228        interval sweep can have seen it carrying yet. Sweeps are idempotent,
 229        so the only cost is the tree walks, and the wirings sharing a tree
 230        share one walk.
 231        """
 232        by_tree: dict[int, tuple[object, list[SignalWiring]]] = {}
 233        for wiring in list(cls._instances):
 234            tree = wiring._owner.tree
 235            if tree is None or not wiring._handlers:
 236                wiring._since_sweep = 0.0
 237                continue
 238            by_tree.setdefault(id(tree), (tree, []))[1].append(wiring)
 239        for tree, wirings in by_tree.values():
 240            cls._sweep_together(tree, wirings)
 241
 242    @classmethod
 243    def _sweep_together(cls, tree, wirings: list[SignalWiring]) -> None:
 244        """Walk *tree* once and offer every node to each of *wirings*."""
 245        for wiring in wirings:
 246            wiring._since_sweep = 0.0
 247        for node in iter_tree_nodes(tree):
 248            for wiring in wirings:
 249                wiring._wire_node(node)
 250
 251    def want(self, signal_name: str, handler: Callable) -> None:
 252        """Route every emitter of *signal_name* to *handler*."""
 253        self._handlers[signal_name] = handler
 254
 255    def poll(self, dt: float) -> None:
 256        """Sweep if the interval has elapsed. Cheap to call every frame.
 257
 258        The walk is shared. A dozen wirings are live in a run, all on the same
 259        interval, all asking the same two hundred and fifty nodes the same
 260        question, so the first one whose timer expires walks the tree and
 261        offers each node to every other wiring that is due this frame. A
 262        partner is counted as due one frame early, because the ones that have
 263        not been polled yet are exactly one *dt* behind this one; that is what
 264        pulls them into lockstep instead of leaving them walking one after
 265        another. Sweeps are idempotent, so the wiring that results is identical
 266        to a dozen separate walks; only the walking is done once.
 267        """
 268        self._since_sweep += dt
 269        if self._since_sweep < self._interval:
 270            return
 271        tree = self._owner.tree
 272        if tree is None or not self._handlers:
 273            self._since_sweep = 0.0
 274            return
 275        due = [self]
 276        due.extend(
 277            other
 278            for other in SignalWiring._instances
 279            if other is not self
 280            and other._handlers
 281            and other._since_sweep + dt >= other._interval
 282            and other._owner.tree is tree
 283        )
 284        SignalWiring._sweep_together(tree, due)
 285
 286    def sweep(self) -> None:
 287        """Connect to every wanted emitter currently in the tree."""
 288        self._since_sweep = 0.0
 289        tree = self._owner.tree
 290        if tree is None or not self._handlers:
 291            return
 292        for node in iter_tree_nodes(tree):
 293            self._wire_node(node)
 294
 295    def _wire_node(self, node: Node) -> None:
 296        """Connect this wiring to whatever *node* emits that it wants."""
 297        wired = self._wired.get(node)
 298        if wired is not None and len(wired) == len(self._handlers):
 299            return
 300        for name, handler in self._handlers.items():
 301            if wired is not None and name in wired:
 302                continue
 303            signal = getattr(node, name, None)
 304            if not isinstance(signal, Signal):
 305                continue
 306            signal.connect(handler)
 307            if wired is None:
 308                wired = set()
 309                self._wired[node] = wired
 310            wired.add(name)
 311
 312
 313# ============================================================================
 314# Generation modules
 315# ============================================================================
 316
 317
 318class PowerSource(Node3D):
 319    """Base for the socketed modules that feed the capacitor.
 320
 321    A source is pure declaration: it reports what it produces and what that
 322    production costs per second, and :class:`PowerSystem` does the ticking. The
 323    system finds sources by walking the tree, so a module starts paying as soon
 324    as it is socketed and stops the moment it is removed, with no registration
 325    handshake to leak.
 326    """
 327
 328    #: True if the source keeps producing under silent running. Only the RTG.
 329    silent_safe: bool = False
 330    #: Ledger key the signature meter files this source's noise under.
 331    signature_reason: str = "power"
 332
 333    def output_per_s(self) -> float:
 334        """Energy this source adds to the capacitor each second."""
 335        return 0.0
 336
 337    def potential_output_per_s(self) -> float:
 338        """What this source pays once fully brought up, whatever it pays now.
 339
 340        The ranking of the fit rather than of the frame: a folded wing and an
 341        idle generator both report what they would pay, which is what decides
 342        which module the hull treats as its primary source.
 343        """
 344        return self.output_per_s()
 345
 346    def fuel_per_s(self) -> float:
 347        """Fuel this source burns each second."""
 348        return 0.0
 349
 350    def signature_per_s(self) -> float:
 351        """Signature this source adds each second."""
 352        return 0.0
 353
 354    def set_biome(self, biome: balance.BiomeSpec) -> None:
 355        """Told the sector's biome; sources that care about light override."""
 356
 357    def shut_down(self) -> None:
 358        """Stop producing. Called when silent running engages or fuel runs dry."""
 359
 360    def power_system(self) -> PowerSystem | None:
 361        """The run's power system, or None before it is registered."""
 362        tree = self.tree
 363        if tree is None:
 364            return None
 365        return tree.singletons.get(Services.POWER)
 366
 367
 368class SolarWings(PowerSource):
 369    """A pair of unfolding solar wings: free power that makes you a bigger target.
 370
 371    Deployment is a two-second animation in both directions, and the wings pay
 372    in proportion to how far out they are, so a panicked retraction gives up its
 373    power gradually rather than at a cliff. Each wing carries its own hit points
 374    and half the array's output; flak that shears one halves the yield for the
 375    rest of the sector. Output scales with the biome's light, which is 2x in the
 376    Solar Shallows and nothing at all in a nebula.
 377
 378    Wings that are the hull's primary source come out by themselves the moment
 379    they are fitted, fully and without the animation. Folding them is a real
 380    decision (a smaller target, no power) and folding them back out is worth two
 381    seconds of exposure; starting the run folded was neither, only a ship that
 382    quietly ran flat before its first fight.
 383    """
 384
 385    solar_state_changed = Signal(str)
 386    wing_destroyed = Signal(int)
 387
 388    #: The four animation states, in the order a full cycle visits them.
 389    STATES = ("retracted", "deploying", "deployed", "retracting")
 390
 391    def __init__(self, **kwargs):
 392        super().__init__(**kwargs)
 393        self.state = "retracted"
 394        self.wing_hp = [balance.SOLAR_WING_HP] * SOLAR_WING_COUNT
 395        self.deployment = 0.0
 396        self._light_multiplier = 1.0
 397        self._auto_deploy_pending = True
 398        #: One pivot node per wing, in ``wing_hp`` order, or empty without art.
 399        self.wing_nodes: list[Node3D] = []
 400        self._cell_materials: list = []
 401        #: The state the model on the hull was last built from. See
 402        #: :meth:`_visual_key`.
 403        self._visual_shown: tuple | None = None
 404
 405    # --- Geometry ----------------------------------------------------------
 406
 407    def on_ready(self):
 408        """Hang the array's own geometry and put it in its current state."""
 409        kit = _artkit()
 410        if kit is None:
 411            return
 412        art = self.add_child(kit.build_solar_wings())
 413        art.rotation = ART_QUARTER_TURN
 414        for name in kit.SOLAR_WING_NAMES[:SOLAR_WING_COUNT]:
 415            wing = next((child for child in art.children if child.name == name), None)
 416            if wing is None:
 417                continue
 418            self.wing_nodes.append(wing)
 419            materials = _accent_materials(wing)
 420            self._cell_materials.append(materials[0] if materials else None)
 421        # The two states the pilot reads off the hull are also the two the
 422        # module announces, so the announcement is what drives the model: a
 423        # refresh that only ran per frame would be true by coincidence.
 424        self.solar_state_changed.connect(self._on_state_shown)
 425        self.wing_destroyed.connect(self._on_wing_lost)
 426        self.refresh_visual_state()
 427
 428    def _on_state_shown(self, state: str) -> None:
 429        del state
 430        self.refresh_visual_state()
 431
 432    def _on_wing_lost(self, index: int) -> None:
 433        del index
 434        self.refresh_visual_state()
 435
 436    def _visual_key(self) -> tuple:
 437        """Everything the model is a function of, as one comparable value.
 438
 439        The wings are geometry driven by three numbers, and for most of a run
 440        none of them move: an array parked deployed in one biome is the same
 441        pair of panels frame after frame. Rebuilding a quaternion, writing two
 442        transforms and re-lighting the cells to arrive at the state already on
 443        the hull is the whole of that cost, so the frame tick asks this first.
 444        """
 445        return (round(self.deployment, 4), round(self.glow_fraction(), 4), tuple(self.wing_hp))
 446
 447    def refresh_visual_state(self) -> None:
 448        """Put every wing where its deployment, its light and its damage say.
 449
 450        A destroyed wing leaves the hull outright: half the array's output is
 451        gone for the rest of the sector, and a panel still hanging there would
 452        say the opposite. A damaged one stays, dimmed.
 453        """
 454        self._visual_shown = self._visual_key()
 455        if not self.wing_nodes:
 456            return
 457        span = SOLAR_STOWED_SPAN + (1.0 - SOLAR_STOWED_SPAN) * self.deployment
 458        fold = SOLAR_FOLD_RADIANS * (1.0 - self.deployment)
 459        lit = self.glow_fraction()
 460        for index, wing in enumerate(self.wing_nodes):
 461            hp = self.wing_hp[index] if index < len(self.wing_hp) else 0.0
 462            if hp <= 0.0:
 463                wing.visible = False
 464                continue
 465            wing.visible = True
 466            # Port and starboard hinge in opposite senses about the shared
 467            # fore-aft axis, so both tips rise rather than one folding through
 468            # the hull.
 469            sense = 1.0 if index % 2 == 0 else -1.0
 470            wing.rotation = Quat.from_axis_angle(Vec3(1.0, 0.0, 0.0), sense * fold)
 471            wing.scale = Vec3(1.0, 1.0, span)
 472            material = self._cell_materials[index] if index < len(self._cell_materials) else None
 473            if material is None:
 474                continue
 475            glow = SOLAR_GLOW_DARK + (SOLAR_GLOW_LIT - SOLAR_GLOW_DARK) * lit
 476            if hp < balance.SOLAR_WING_HP * SOLAR_DAMAGED_FRACTION:
 477                glow = min(glow, SOLAR_GLOW_DAMAGED)
 478            material.emissive_strength = accent_strength() * glow
 479
 480    def glow_fraction(self) -> float:
 481        """How hard the cells are working, 0 to 1, against the array's best light.
 482
 483        Live output over what a whole undamaged array would make in the
 484        brightest biome, so the glint answers "is this paying?" rather than
 485        "are the wings out?", and an array unfolded in a nebula stays dark.
 486        """
 487        best = balance.SOLAR_OUTPUT_SOLAR_SHALLOWS_PER_S
 488        if best <= 0.0:
 489            return 0.0
 490        return min(1.0, max(0.0, self.output_per_s() / best))
 491
 492    # --- Deployment --------------------------------------------------------
 493
 494    def toggle(self) -> None:
 495        """Reverse the wings: unfold if folded or folding, fold otherwise."""
 496        self._set_state("retracting" if self.state in ("deployed", "deploying") else "deploying")
 497
 498    def deploy_now(self) -> None:
 499        """Snap the wings out with no animation: the state a run starts in."""
 500        self.deployment = 1.0
 501        self._set_state("deployed")
 502
 503    def is_primary_source(self) -> bool:
 504        """True if nothing else fitted to this hull can out-pay the wings.
 505
 506        Rated output decides it, so wings bolted on beside a generator stay the
 507        backup the player unfolds deliberately, and wings in a nebula (which pay
 508        nothing at all) stay folded rather than widening the hitbox for free.
 509        """
 510        mine = self.potential_output_per_s()
 511        if mine <= 0.0:
 512            return False
 513        tree = self.tree
 514        if tree is None:
 515            return True
 516        for node in iter_tree_nodes(tree):
 517            if node is not self and isinstance(node, PowerSource) and node.potential_output_per_s() > mine:
 518                return False
 519        return True
 520
 521    def hitbox_scale(self) -> float:
 522        """Multiplier the ship applies to its hull radius while the wings are out."""
 523        return 1.0 + balance.SOLAR_HITBOX_WIDEN_FRACTION * self.deployment
 524
 525    def _set_state(self, state: str) -> None:
 526        if state == self.state:
 527            return
 528        self.state = state
 529        self.solar_state_changed(state)
 530
 531    # --- Damage ------------------------------------------------------------
 532
 533    def damage_wing(self, index: int, amount: float) -> bool:
 534        """Apply *amount* to one wing. Returns True if this shot destroyed it."""
 535        before = self.wing_hp[index]
 536        if before <= 0.0:
 537            return False
 538        self.wing_hp[index] = max(0.0, before - float(amount))
 539        if self.wing_hp[index] > 0.0:
 540            return False
 541        self.wing_destroyed(index)
 542        return True
 543
 544    def live_wings(self) -> int:
 545        """How many wings still carry cells."""
 546        return sum(1 for hp in self.wing_hp if hp > 0.0)
 547
 548    # --- Production --------------------------------------------------------
 549
 550    def set_biome(self, biome: balance.BiomeSpec) -> None:
 551        self._light_multiplier = biome.solar_mult
 552
 553    def potential_output_per_s(self) -> float:
 554        per_wing = balance.SOLAR_OUTPUT_PER_S * self._light_multiplier / SOLAR_WING_COUNT
 555        return per_wing * self.live_wings()
 556
 557    def output_per_s(self) -> float:
 558        return self.potential_output_per_s() * self.deployment
 559
 560    # --- Lifecycle ---------------------------------------------------------
 561
 562    def on_update(self, dt: float):
 563        if self._auto_deploy_pending:
 564            # One shot, on the first frame the wings are in a mounted tree: by
 565            # then every other module has mounted and the sector's light is
 566            # known, so "am I the primary source" has a real answer.
 567            self._auto_deploy_pending = False
 568            if self.state == "retracted" and self.is_primary_source():
 569                self.deploy_now()
 570        if Input.is_action_just_pressed("solar_wings"):
 571            self.toggle()
 572        if self.state == "deploying":
 573            self.deployment += dt / balance.SOLAR_DEPLOY_S
 574            if self.deployment >= 1.0:
 575                self.deployment = 1.0
 576                self._set_state("deployed")
 577        elif self.state == "retracting":
 578            self.deployment -= dt / balance.SOLAR_RETRACT_S
 579            if self.deployment <= 0.0:
 580                self.deployment = 0.0
 581                self._set_state("retracted")
 582        # The two-second unfold and the glint that tracks the biome's light are
 583        # both continuous, so the model is refreshed off the frame as well as on
 584        # the state change that starts and ends it, but only while one of the
 585        # three numbers it is built from has actually moved. A settled array,
 586        # which is what an array is nearly all the time, costs one comparison.
 587        if self._visual_key() != self._visual_shown:
 588            self.refresh_visual_state()
 589
 590
 591class Generator(PowerSource):
 592    """The loud option: power anywhere, paid for in fuel and in attention.
 593
 594    Running the generator is the single clearest statement of the game's theme.
 595    It hums in the mix, and that hum is the signature warning, so flicking it on
 596    for a fight and killing it to go dark is the posture change every fight is
 597    built around. It refuses to start under silent running and stops itself the
 598    moment the tank runs dry.
 599    """
 600
 601    generator_changed = Signal(bool)
 602
 603    signature_reason = "generator"
 604
 605    def __init__(self, **kwargs):
 606        super().__init__(**kwargs)
 607        self.running = False
 608        self._vent = None
 609        self._hum_phase = 0.0
 610        #: Seconds until the vent is next rewritten. See
 611        #: :data:`GENERATOR_VENT_REFRESH_S`.
 612        self._vent_due = 0.0
 613
 614    # --- Geometry ----------------------------------------------------------
 615
 616    def on_ready(self):
 617        """Hang the block, and light its vent if it is already turning."""
 618        kit = _artkit()
 619        if kit is None:
 620            return
 621        art = self.add_child(kit.build_generator())
 622        art.rotation = ART_QUARTER_TURN
 623        materials = _accent_materials(art)
 624        self._vent = materials[0] if materials else None
 625        self.generator_changed.connect(self._on_running_shown)
 626        self.refresh_visual_state()
 627
 628    def _on_running_shown(self, running: bool) -> None:
 629        del running
 630        self.refresh_visual_state()
 631
 632    def refresh_visual_state(self) -> None:
 633        """Light the vent in step with the hum, or leave it dark.
 634
 635        The pulse is the hum made visible. The generator's loudness is the
 636        signature warning the whole posture game is played around, and a pilot
 637        who has muted the mix or cannot hear it had no way at all to tell a
 638        running generator from a stopped one.
 639        """
 640        if self._vent is None:
 641            return
 642        if not self.running:
 643            self._vent.emissive_strength = 0.0
 644            return
 645        pulse = 0.5 + 0.5 * math.sin(self._hum_phase * math.tau)
 646        strength = GENERATOR_GLOW_MIN + (GENERATOR_GLOW_MAX - GENERATOR_GLOW_MIN) * pulse
 647        self._vent.emissive_strength = accent_strength() * strength
 648
 649    def toggle(self) -> None:
 650        """Start or stop the generator. Starting is refused while dark."""
 651        if not self.running:
 652            system = self.power_system()
 653            if system is not None and (system.silent_running or system.fuel <= 0.0):
 654                return
 655        self.set_running(not self.running)
 656
 657    def set_running(self, active: bool) -> None:
 658        """Set the running state, emitting the hum cue only on a real change."""
 659        active = bool(active)
 660        if active == self.running:
 661            return
 662        self.running = active
 663        self.generator_changed(active)
 664
 665    def shut_down(self) -> None:
 666        self.set_running(False)
 667
 668    def output_per_s(self) -> float:
 669        return balance.GENERATOR_OUTPUT_PER_S if self.running else 0.0
 670
 671    def potential_output_per_s(self) -> float:
 672        return balance.GENERATOR_OUTPUT_PER_S
 673
 674    def fuel_per_s(self) -> float:
 675        return balance.GENERATOR_FUEL_PER_S if self.running else 0.0
 676
 677    def signature_per_s(self) -> float:
 678        return balance.GENERATOR_SIGNATURE_PER_S if self.running else 0.0
 679
 680    def on_update(self, dt: float):
 681        if Input.is_action_just_pressed("generator"):
 682            self.toggle()
 683        if not self.running:
 684            # A dark vent is a state, not an animation: the stop wrote it once
 685            # through ``generator_changed`` and nothing moves it afterwards.
 686            return
 687        self._hum_phase += dt * GENERATOR_HUM_HZ
 688        self._vent_due -= dt
 689        if self._vent_due <= 0.0:
 690            self._vent_due = GENERATOR_VENT_REFRESH_S
 691            self.refresh_visual_state()
 692
 693
 694class RTG(PowerSource):
 695    """A radioisotope trickle: small, silent, and the only power left when dark."""
 696
 697    silent_safe = True
 698
 699    def on_ready(self):
 700        """Hang the drum, with its window lit and left alone.
 701
 702        Nothing drives it afterwards: the RTG has one state, and a light that
 703        never changes is exactly the reading the module is worth.
 704        """
 705        kit = _artkit()
 706        if kit is None:
 707            return
 708        art = self.add_child(kit.build_rtg())
 709        art.rotation = ART_QUARTER_TURN
 710        for material in _accent_materials(art):
 711            material.emissive_strength = accent_strength() * RTG_GLOW
 712
 713    def output_per_s(self) -> float:
 714        return balance.RTG_OUTPUT_PER_S
 715
 716
 717# ============================================================================
 718# The system
 719# ============================================================================
 720
 721
 722class PowerSystem(Node):
 723    """The run's capacitor, fuel tank, life support and power posture.
 724
 725    Registered as the ``Services.POWER`` singleton. Every consumer spends
 726    through :meth:`request` (an atomic bite) or :meth:`drain` (a per-second
 727    draw), and a denial is an event in its own right: ``ENERGY_DENIED`` is what
 728    tells the HUD to flash the arc and the audio director to click.
 729
 730    Per frame it burns the generator's fuel, files the generator's noise with
 731    the signature meter, charges the capacitor from every live source and from
 732    the emergency bus, and bleeds oxygen at the base rate plus a per-breach
 733    penalty.
 734    """
 735
 736    capacitor_changed = Signal(float, float)
 737    energy_denied = Signal(str)
 738    o2_changed = Signal(float, float)
 739    fuel_changed = Signal(float, float)
 740    silent_running_changed = Signal(bool)
 741
 742    def __init__(self, *, capacitor_max: float = balance.CAPACITOR_MAX, **kwargs):
 743        super().__init__(**kwargs)
 744        self.capacitor_max = float(capacitor_max)
 745        self.capacitor = self.capacitor_max
 746        self.o2_max = balance.O2_MAX
 747        self.o2 = balance.O2_MAX
 748        self.fuel_max = balance.FUEL_MAX
 749        self.fuel = balance.FUEL_MAX
 750        self.open_breaches = 0
 751        self.silent_running = False
 752        #: Whether the posture answers its key at all. The onboarding schedule
 753        #: holds it back until run three; flow sets this at run start.
 754        self.silent_running_enabled = True
 755        self.biome = balance.BIOMES["debris_field"]
 756        self._sources: list[PowerSource] = []
 757        self._since_source_sweep = REWIRE_INTERVAL_S
 758        self._wiring = SignalWiring(self)
 759        self._elapsed = 0.0
 760        #: ``consumer -> elapsed`` of its last ``ENERGY_DENIED`` emission.
 761        self._denied_at: dict[str, float] = {}
 762        #: Seconds left of a forced blackout. See :meth:`begin_blackout`.
 763        self.blackout_remaining = 0.0
 764
 765    # --- Lifecycle ---------------------------------------------------------
 766
 767    def on_ready(self):
 768        self._wiring.want(SignalNames.BREACH_OPENED, self._on_breach_count)
 769        self._wiring.want(SignalNames.BREACH_PATCHED, self._on_breach_count)
 770        self._wiring.want(SignalNames.SECTOR_ENTERED, self._on_sector_entered)
 771        self._wiring.sweep()
 772        self.refresh_sources()
 773
 774    def on_update(self, dt: float):
 775        self._elapsed += dt
 776        self._wiring.poll(dt)
 777        self._since_source_sweep += dt
 778        if self._since_source_sweep >= REWIRE_INTERVAL_S:
 779            self._since_source_sweep = 0.0
 780            self.refresh_sources()
 781        if Input.is_action_just_pressed("silent_running"):
 782            if self.silent_running_enabled:
 783                self.set_silent_running(not self.silent_running)
 784            else:
 785                self._answer_locked_silent_running()
 786        self._tick_blackout(dt)
 787        self._tick_fuel(dt)
 788        self._tick_signature(dt)
 789        self._tick_generation(dt)
 790        self._tick_life_support(dt)
 791
 792    # --- Blackout ----------------------------------------------------------
 793
 794    def begin_blackout(self, seconds: float) -> None:
 795        """Empty the capacitor and hold it empty for *seconds*.
 796
 797        The Shrike's lantern burn is the one caller. It used to do this by
 798        spending the whole charge through :meth:`request` every frame, which
 799        emptied the tank but let every source refill it in between, so the arc
 800        strobed instead of going out and the blackout was invisible even though
 801        the number was right. One state, one reading: while this is running the
 802        bar is at zero, :attr:`blacked_out` is true, and the HUD can grey the
 803        arc rather than draw a live gauge that happens to be empty.
 804        """
 805        seconds = float(seconds)
 806        if seconds <= 0.0:
 807            return
 808        self.blackout_remaining = max(self.blackout_remaining, seconds)
 809        self._set_capacitor(0.0)
 810
 811    @property
 812    def blacked_out(self) -> bool:
 813        """True while a blackout is holding the capacitor at zero."""
 814        return self.blackout_remaining > 0.0
 815
 816    def _tick_blackout(self, dt: float) -> None:
 817        if self.blackout_remaining <= 0.0:
 818            return
 819        self.blackout_remaining = max(0.0, self.blackout_remaining - dt)
 820        self._set_capacitor(0.0)
 821
 822    # --- Sources -----------------------------------------------------------
 823
 824    def refresh_sources(self) -> None:
 825        """Rediscover the socketed power modules currently in the tree.
 826
 827        Called on an interval and directly by ``modules.py`` after a socket
 828        change, so a freshly installed RTG pays from the next frame.
 829        """
 830        tree = self.tree
 831        if tree is None:
 832            self._sources = []
 833            return
 834        found: list[PowerSource] = []
 835        for node in iter_tree_nodes(tree):
 836            if isinstance(node, PowerSource):
 837                node.set_biome(self.biome)
 838                found.append(node)
 839        self._sources = found
 840
 841    def sources(self) -> list[PowerSource]:
 842        """The power modules the system is currently drawing from."""
 843        return list(self._sources)
 844
 845    def _live_sources(self) -> list[PowerSource]:
 846        if not self.silent_running:
 847            return self._sources
 848        return [source for source in self._sources if source.silent_safe]
 849
 850    def generation_per_s(self) -> float:
 851        """Total energy per second from wings, generator and RTG right now."""
 852        return sum(source.output_per_s() for source in self._live_sources())
 853
 854    def emergency_trickle_per_s(self) -> float:
 855        """The hull's own charge rate, owed to no module and stopped by nothing.
 856
 857        Silent running does not touch it and neither does an empty fuel tank: a
 858        ship with folded wings, a dry generator and a flat capacitor still
 859        climbs back to firing on this alone. It sits far below every weapon's
 860        draw, so it buys the way out of a dead stop and never the fight.
 861        """
 862        return POWER_EMERGENCY_TRICKLE_PER_S
 863
 864    def charge_rate_per_s(self) -> float:
 865        """Everything reaching the capacitor per second, modules and bus alike."""
 866        return self.generation_per_s() + self.emergency_trickle_per_s()
 867
 868    # --- Per-frame ticks ---------------------------------------------------
 869
 870    def _tick_fuel(self, dt: float) -> None:
 871        burners = [source for source in self._live_sources() if source.fuel_per_s() > 0.0]
 872        if not burners:
 873            return
 874        needed = sum(source.fuel_per_s() for source in burners) * dt
 875        if needed <= self.fuel:
 876            self._set_fuel(self.fuel - needed)
 877            return
 878        self._set_fuel(0.0)
 879        for source in burners:
 880            source.shut_down()
 881
 882    def _tick_signature(self, dt: float) -> None:
 883        meter = self._meter()
 884        if meter is None:
 885            return
 886        for source in self._live_sources():
 887            gain = source.signature_per_s()
 888            if gain > 0.0:
 889                meter.add(gain * dt, source.signature_reason)
 890
 891    def _tick_generation(self, dt: float) -> None:
 892        if self.blacked_out:
 893            return  # the bus is down; the sources are still turning, into nothing
 894        self.add_energy(self.charge_rate_per_s() * dt)
 895
 896    def _tick_life_support(self, dt: float) -> None:
 897        drain = balance.O2_DRAIN_PER_S + self.open_breaches * balance.O2_DRAIN_PER_BREACH_PER_S
 898        self._set_o2(self.o2 - drain * dt)
 899
 900    # --- Energy ------------------------------------------------------------
 901
 902    def request(self, amount: float, consumer: str) -> bool:
 903        """Spend *amount*. False and ``ENERGY_DENIED`` if the tank is short.
 904
 905        Weapons spend against a floor instead: a shot is refused only below
 906        :data:`WEAPONS_ENERGY_FLOOR`, and above it goes off for whatever the
 907        capacitor still holds. Losing a fight because the tank was a fraction
 908        under the exact cost reads as a broken gun rather than as a resource
 909        decision, and the denial that matters is the one the player can see
 910        coming. Every other consumer spends atomically or not at all.
 911
 912        A denial is an event in its own right: ``ENERGY_DENIED`` names the
 913        consumer so the HUD and the audio director can say "no power" rather
 914        than the "no ammo" an empty magazine says. The cue is rate-limited per
 915        consumer to one per :data:`ENERGY_DENIED_CUE_INTERVAL_S`, because a
 916        held beam over an empty tank asks every frame; the request itself is
 917        still refused every time.
 918        """
 919        amount = float(amount)
 920        if amount <= 0.0:
 921            return True
 922        floor = WEAPONS_ENERGY_FLOOR if is_weapon_consumer(consumer) else amount
 923        if self.capacitor < floor:
 924            self._deny(consumer)
 925            return False
 926        self._set_capacitor(self.capacitor - amount)
 927        return True
 928
 929    def _deny(self, consumer: str) -> None:
 930        """Emit the denial cue, at most once per interval per consumer."""
 931        last = self._denied_at.get(consumer)
 932        if last is not None and self._elapsed - last < ENERGY_DENIED_CUE_INTERVAL_S:
 933            return
 934        self._denied_at[consumer] = self._elapsed
 935        self.energy_denied(consumer)
 936
 937    def drain(self, per_second: float, dt: float, consumer: str) -> bool:
 938        """Spend a per-second draw for one frame. Same contract as :meth:`request`.
 939
 940        A beam therefore gets the frame that takes the capacitor under the floor
 941        and stutters out on the next one, which is the same "one last shot" rule
 942        the pulse weapons get.
 943        """
 944        return self.request(per_second * dt, consumer)
 945
 946    def add_energy(self, amount: float) -> None:
 947        """Charge the capacitor, clamped to its maximum."""
 948        self._set_capacitor(self.capacitor + float(amount))
 949
 950    def set_capacitor_max(self, maximum: float) -> None:
 951        """Raise or lower the capacitor ceiling (hull choice, doctrine, modules)."""
 952        self.capacitor_max = float(maximum)
 953        self._set_capacitor(self.capacitor)
 954
 955    def _set_capacitor(self, value: float) -> None:
 956        value = min(self.capacitor_max, max(0.0, value))
 957        if value == self.capacitor:
 958            return
 959        self.capacitor = value
 960        self.capacitor_changed(value, self.capacitor_max)
 961
 962    # --- Fuel and oxygen ---------------------------------------------------
 963
 964    def add_fuel(self, amount: float) -> None:
 965        """Take on fuel from a comet, a wreck tank or a depot purchase."""
 966        self._set_fuel(self.fuel + float(amount))
 967
 968    def spend_fuel(self, amount: float) -> bool:
 969        """Spend *amount* of fuel atomically. False if the tank cannot cover it."""
 970        amount = float(amount)
 971        if amount <= 0.0:
 972            return True
 973        if self.fuel < amount:
 974            return False
 975        self._set_fuel(self.fuel - amount)
 976        return True
 977
 978    def add_o2(self, amount: float) -> None:
 979        """Refill life support from an ice chunk or a depot canister."""
 980        self._set_o2(self.o2 + float(amount))
 981
 982    def restore_levels(self, *, capacitor: float, fuel: float, o2: float) -> None:
 983        """Adopt saved meter levels wholesale, clamped to the current maxima.
 984
 985        The resume path after a suspended run: a rebuilt system starts full,
 986        and the levels the player actually had are applied in one step so every
 987        change signal fires exactly once per meter.
 988        """
 989        self._set_capacitor(float(capacitor))
 990        self._set_fuel(float(fuel))
 991        self._set_o2(float(o2))
 992
 993    def _set_fuel(self, value: float) -> None:
 994        value = min(self.fuel_max, max(0.0, value))
 995        if value == self.fuel:
 996            return
 997        self.fuel = value
 998        self.fuel_changed(value, self.fuel_max)
 999
1000    def _set_o2(self, value: float) -> None:
1001        value = min(self.o2_max, max(0.0, value))
1002        if value == self.o2:
1003            return
1004        self.o2 = value
1005        self.o2_changed(value, self.o2_max)
1006
1007    # --- Posture -----------------------------------------------------------
1008
1009    def set_silent_running(self, active: bool) -> None:
1010        """Go dark, or come back up.
1011
1012        Going dark shuts every source that is not silent-safe, so the generator
1013        stops humming and stops burning fuel; the signature meter picks the
1014        posture up from the same frame and begins decaying toward the act floor.
1015        """
1016        active = bool(active)
1017        if active == self.silent_running:
1018            return
1019        self.silent_running = active
1020        if active:
1021            for source in self._sources:
1022                source.shut_down()
1023        self.silent_running_changed(active)
1024        self._meter()
1025
1026    def _answer_locked_silent_running(self) -> None:
1027        """Answer C on a run flown before the posture unlocks.
1028
1029        The key does nothing, so the answer goes on the toast line; going dark
1030        anyway with the HUD light hidden would be a mechanic the player cannot
1031        see acting on a run that has not taught it.
1032        """
1033        tree = self.tree
1034        hud = tree.singletons.get(Services.HUD) if tree is not None else None
1035        if hud is not None:
1036            hud.show_toast(SILENT_RUNNING_LOCKED_TOAST)
1037
1038    def set_biome(self, biome_id: str) -> None:
1039        """Adopt the sector's biome, which sets how much light the wings get."""
1040        self.biome = balance.BIOMES[biome_id]
1041        for source in self._sources:
1042            source.set_biome(self.biome)
1043
1044    # --- Signal handlers ---------------------------------------------------
1045
1046    def _on_breach_count(self, open_breaches: int) -> None:
1047        self.open_breaches = int(open_breaches)
1048
1049    def _on_sector_entered(self, sector_index: int, biome_id: str) -> None:
1050        self.set_biome(biome_id)
1051
1052    def _meter(self):
1053        """The signature meter, told the current power posture as it is fetched.
1054
1055        Syncing here rather than only on the toggle means a meter registered
1056        after the player went dark still learns the posture on the next frame.
1057        """
1058        tree = self.tree
1059        if tree is None:
1060            return None
1061        meter = tree.singletons.get(Services.SIGNATURE)
1062        if meter is not None:
1063            meter.set_silent_running(self.silent_running)
1064        return meter