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