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)