shrike/signature.pyΒΆ

Part of SHRIKE.

  1"""The signature meter: the bill this sector charges for everything you do.
  2
  3One meter per sector, 0 to 100, drawn as the screen border warming to red. It
  4climbs on its own at a rate set by the act, and every profitable verb in the
  5game pushes it faster: tapping a rich node, killing something, running the
  6generator, refining scrap, cracking a vault, letting a screamer live. At 100 it
  7locks, and the Shrike's telegraph ladder starts.
  8
  9The only way down is silent running, and only as far as the act's floor: to
 10nothing in the Shallows, to 20 in the middle act, to 40 in the Deep. Going dark
 11is therefore a real playstyle early and only damage control later, which is the
 12point: the dial has to move both ways or quiet play is just hiding.
 13"""
 14
 15from simvx.core import Node, Signal
 16
 17from . import balance
 18from .events import card
 19from .power import SignalWiring
 20from .runtime import SignalNames
 21
 22#: Every itemised signature gain, keyed by the ledger name it is filed under.
 23#: Per-second gains (the act baseline, the generator) are not events and are
 24#: charged by the tick and by ``power.PowerSystem`` respectively.
 25GAIN_EVENTS: dict[str, float] = {
 26    "rich_node": balance.SIGNATURE_RICH_NODE_TAP,
 27    "kill": balance.SIGNATURE_PER_KILL,
 28    "refinery_batch": balance.SIGNATURE_REFINERY_BATCH,
 29    "vault_hack": balance.SIGNATURE_VAULT_HACK,
 30    "screamer": balance.SIGNATURE_SCREAMER_SURVIVED,
 31}
 32
 33#: Gain-multiplier source a signal-event card occupies. Cards are sector-scoped,
 34#: so this entry is dropped whenever the meter resets.
 35SIGNAL_EVENT_MULTIPLIER = "signal_event"
 36
 37
 38class SignatureMeter(Node):
 39    """The per-sector noise meter, registered as the ``Services.SIGNATURE`` singleton.
 40
 41    Gains arrive three ways: the act baseline every frame, per-second draws
 42    pushed by the power system, and the itemised events in :data:`GAIN_EVENTS`.
 43    All three pass through :meth:`add`, so the silent-running quarter and any
 44    build multipliers (a wake damper, the Dart hull, the Steady Wake assist)
 45    apply uniformly and are configured in one place via
 46    :meth:`set_gain_multiplier`.
 47    """
 48
 49    signature_changed = Signal(float)
 50    signature_locked = Signal()
 51
 52    def __init__(self, *, act: int = 1, **kwargs):
 53        super().__init__(**kwargs)
 54        self.act = int(act)
 55        self.value = self.floor
 56        self.locked = False
 57        self.silent_running = False
 58        #: Signature charged per ledger reason this sector, for the death recap.
 59        self.gains: dict[str, float] = {}
 60        self._gain_multipliers: dict[str, float] = {}
 61        self._wiring = SignalWiring(self)
 62
 63    # --- Lifecycle ---------------------------------------------------------
 64
 65    def on_ready(self):
 66        self._wiring.want(SignalNames.RICH_NODE_TAPPED, self._on_rich_node)
 67        self._wiring.want(SignalNames.ENEMY_KILLED, self._on_enemy_killed)
 68        self._wiring.want(SignalNames.VAULT_HACKED, self._on_vault_hacked)
 69        self._wiring.want(SignalNames.REFINERY_BATCH_COMPLETED, self._on_refinery_batch)
 70        self._wiring.want(SignalNames.SCREAMER_SCREAMED, self._on_screamer)
 71        self._wiring.want(SignalNames.SECTOR_ENTERED, self._on_sector_entered)
 72        self._wiring.want(SignalNames.SIGNAL_EVENT, self._on_signal_event)
 73        self._wiring.want(SignalNames.LAST_STAND_TRIGGERED, self._on_last_stand)
 74        self._wiring.sweep()
 75
 76    def on_update(self, dt: float):
 77        self._wiring.poll(dt)
 78        baseline = balance.SIGNATURE_BASELINE_PER_S[self.act]
 79        if baseline > 0.0:
 80            self.add(baseline * dt, "baseline")
 81        if self.silent_running:
 82            self._decay(dt)
 83
 84    # --- Reading -----------------------------------------------------------
 85
 86    @property
 87    def floor(self) -> float:
 88        """The lowest silent running can take the meter in this act."""
 89        return balance.SIGNATURE_FLOOR[self.act]
 90
 91    def gain_multiplier(self) -> float:
 92        """The product of every active fill modifier, silent running included."""
 93        multiplier = balance.SILENT_SIGNATURE_GAIN_MULT if self.silent_running else 1.0
 94        for value in self._gain_multipliers.values():
 95            multiplier *= value
 96        return multiplier
 97
 98    # --- Writing -----------------------------------------------------------
 99
100    def add(self, amount: float, reason: str, *, raw: bool = False) -> None:
101        """Charge *amount* of signature, filed under *reason*.
102
103        The amount is scaled by :meth:`gain_multiplier` unless *raw* is set,
104        which is reserved for the few jumps that are the mechanic rather than a
105        gain, such as the Last Stand's leap straight to a locked meter.
106        """
107        amount = float(amount)
108        if amount <= 0.0:
109            return
110        charged = amount if raw else amount * self.gain_multiplier()
111        if charged <= 0.0:
112            return
113        self.gains[reason] = self.gains.get(reason, 0.0) + charged
114        self._set_value(self.value + charged)
115
116    def report_event(self, event: str) -> None:
117        """Charge one of the itemised gains in :data:`GAIN_EVENTS` by name."""
118        self.add(GAIN_EVENTS[event], event)
119
120    def set_act(self, act: int) -> None:
121        """Adopt an act's baseline rate and floor.
122
123        The current reading is left alone: deepening raises the floor the meter
124        can be decayed to, it does not itself make the ship louder. The next
125        :meth:`reset_for_sector` starts from the new floor.
126        """
127        if act not in balance.SIGNATURE_BASELINE_PER_S:
128            raise ValueError(f"act must be one of {sorted(balance.SIGNATURE_BASELINE_PER_S)}, got {act!r}")
129        self.act = int(act)
130
131    def reset_for_sector(self) -> None:
132        """Start a fresh sector: back to the act floor, unlocked, ledger cleared.
133
134        The floor rather than zero, because the floor is the reading silent
135        running settles at in this act, and a sector that began below it could
136        never be returned to by going dark.
137        """
138        self.locked = False
139        self.gains = {}
140        self._gain_multipliers.pop(SIGNAL_EVENT_MULTIPLIER, None)
141        self._set_value(self.floor)
142
143    def set_silent_running(self, active: bool) -> None:
144        """Adopt the ship's power posture. Driven by ``power.PowerSystem``."""
145        self.silent_running = bool(active)
146
147    def set_gain_multiplier(self, source: str, multiplier: float) -> None:
148        """Register a named fill modifier, or clear it by passing 1.0.
149
150        Sources are the things that change how loudly the whole ship reads: a
151        wake damper module, the Dart hull, a signal-event card, the Steady Wake
152        assist. One entry per source so they compose without fighting.
153        """
154        multiplier = float(multiplier)
155        if multiplier == 1.0:
156            self._gain_multipliers.pop(source, None)
157        else:
158            self._gain_multipliers[source] = multiplier
159
160    # --- Internals ---------------------------------------------------------
161
162    def _decay(self, dt: float) -> None:
163        floor = self.floor
164        if self.value <= floor:
165            return
166        self._set_value(max(floor, self.value - balance.SILENT_SIGNATURE_DECAY_PER_S * dt))
167
168    def _set_value(self, value: float) -> None:
169        value = min(balance.SIGNATURE_MAX, max(0.0, value))
170        if value == self.value:
171            return
172        self.value = value
173        self.signature_changed(value)
174        if value >= balance.SIGNATURE_MAX and not self.locked:
175            self.locked = True
176            self.signature_locked()
177
178    # --- Signal handlers ---------------------------------------------------
179
180    def _on_rich_node(self) -> None:
181        self.report_event("rich_node")
182
183    def _on_enemy_killed(self, archetype: str, position, elite: bool) -> None:
184        self.report_event("kill")
185
186    def _on_vault_hacked(self, scrap: float) -> None:
187        self.report_event("vault_hack")
188
189    def _on_refinery_batch(self, cores: float) -> None:
190        self.report_event("refinery_batch")
191
192    def _on_screamer(self) -> None:
193        self.report_event("screamer")
194
195    def _on_sector_entered(self, sector_index: int, biome_id: str) -> None:
196        self.set_act(balance.act_for_sector(sector_index))
197        self.reset_for_sector()
198
199    def _on_signal_event(self, event_id: str) -> None:
200        self.set_gain_multiplier(SIGNAL_EVENT_MULTIPLIER, card(event_id).signature_mult)
201
202    def _on_last_stand(self) -> None:
203        self.add(balance.SIGNATURE_MAX, "last_stand", raw=True)