shrike/events.pyΒΆ
Part of SHRIKE.
1"""The signal-event deck: twelve cards a sector can deal you on arrival.
2
3Every sector warp-in resolves one card from a run-scoped deck. The deck is
4weighted toward cards this profile has never seen, so early runs spend most of
5their draws teaching new content and later runs still shuffle the familiar
6ones. Cards are pure data: the sector reads the resource fields and seeds the
7matching content, and everyone else keys off the emitted event id and looks the
8card up here.
9
10A card never spawns enemies itself. ``hostile`` is the declaration that
11something will shoot at you because of it; the wave composer and the hunter
12read it off the ``signal_event`` payload and decide what arrives.
13"""
14
15import random
16from dataclasses import dataclass
17
18from . import balance
19
20#: Relative draw weight of a card the profile has never resolved.
21UNSEEN_DRAW_WEIGHT = 4.0
22#: Relative draw weight of a card the profile has already resolved.
23SEEN_DRAW_WEIGHT = 1.0
24
25
26@dataclass(frozen=True)
27class SignalEventCard:
28 """One card in the deck.
29
30 ``kind`` groups the card for presentation: "boon" pays out, "bait" pays out
31 but wakes something, "hazard" only costs, "trade" opens a purchase. The
32 resource fields are budgets the sector spends on extra content, in the same
33 units as the rest of the economy: scrap, fuel, O2. ``vaults`` seeds extra
34 sealed prizes behind a hack channel. ``signature_mult`` is a sector-scoped
35 multiplier on signature gain that the meter applies while this card is live.
36 """
37
38 id: str
39 title: str
40 kind: str
41 hostile: bool = False
42 scrap: float = 0.0
43 fuel: float = 0.0
44 o2: float = 0.0
45 vaults: int = 0
46 signature_mult: float = 1.0
47 note: str = ""
48
49
50SIGNAL_EVENTS: dict[str, SignalEventCard] = {
51 "lifeboat_distress": SignalEventCard(
52 "lifeboat_distress",
53 "Lifeboat Distress",
54 "boon",
55 scrap=35.0,
56 note="A crewed lifeboat answers and pays for the escort in salvage.",
57 ),
58 "bait_beacon": SignalEventCard(
59 "bait_beacon",
60 "Repeating Beacon",
61 "bait",
62 hostile=True,
63 scrap=15.0,
64 note="The distress loop repeats too cleanly. Something set it.",
65 ),
66 "derelict_cache": SignalEventCard(
67 "derelict_cache",
68 "Derelict Cache",
69 "boon",
70 scrap=60.0,
71 note="A supply cache drifts unclaimed with its seals still green.",
72 ),
73 "herald_nest": SignalEventCard(
74 "herald_nest",
75 "Dormant Herald Nest",
76 "bait",
77 hostile=True,
78 vaults=1,
79 note="A nest sleeps in the hulk. Rob it and it wakes.",
80 ),
81 "black_box": SignalEventCard(
82 "black_box",
83 "Black Box",
84 "boon",
85 scrap=15.0,
86 note="A flight recorder still transmitting; its log fragment is worth reading.",
87 ),
88 "fuel_barge": SignalEventCard(
89 "fuel_barge",
90 "Ruptured Barge",
91 "boon",
92 fuel=balance.FUEL_COMET_MAX * 2.0,
93 note="A tanker broke up here and its cargo froze into comets.",
94 ),
95 "ice_calving": SignalEventCard(
96 "ice_calving",
97 "Ice Calving",
98 "boon",
99 o2=balance.ICE_CHUNK_O2 * 4.0,
100 note="A shelf sheds; breathable ice tumbles across the lane.",
101 ),
102 "mine_drift": SignalEventCard(
103 "mine_drift",
104 "Mine Drift",
105 "hazard",
106 hostile=True,
107 note="Somebody else's minefield has drifted across the approach.",
108 ),
109 "broker_courier": SignalEventCard(
110 "broker_courier",
111 "Broker Courier",
112 "trade",
113 note="A courier drone offers one module below list price and asks no questions.",
114 ),
115 "scavenger_pack": SignalEventCard(
116 "scavenger_pack",
117 "Scavenger Pack",
118 "hazard",
119 hostile=True,
120 note="Skimmers are already here, and they strip floating salvage.",
121 ),
122 "sensor_shadow": SignalEventCard(
123 "sensor_shadow",
124 "Sensor Shadow",
125 "boon",
126 signature_mult=0.75,
127 note="Charged dust swallows your emissions. Be loud while it lasts.",
128 ),
129 "wake_surge": SignalEventCard(
130 "wake_surge",
131 "Wake Surge",
132 "hazard",
133 signature_mult=1.25,
134 note="The Wake laps the sector's edge and everything you do reads louder.",
135 ),
136}
137
138
139class SignalEventDeck:
140 """A run-scoped deck of signal-event cards, weighted toward unseen ones.
141
142 ``seen`` is the profile's set of card ids and is mutated in place as cards
143 are drawn, so persisting it persists the weighting. Within one deck a card
144 is never dealt twice until every card has been dealt, at which point the
145 deck reshuffles.
146 """
147
148 def __init__(self, seed: int, seen: set[str] | None = None):
149 self._rng = random.Random(seed)
150 self.seen: set[str] = seen if seen is not None else set()
151 self._drawn: list[str] = []
152 #: Cards dealt over the deck's whole life, reshuffles included. A deck
153 #: built with the same seed and initial ``seen`` reproduces its state
154 #: exactly by drawing this many times, which is how a suspended run
155 #: gets the same card back on resume.
156 self.draws = 0
157
158 @property
159 def drawn(self) -> list[str]:
160 """Cards dealt since the deck last reshuffled, oldest first."""
161 return list(self._drawn)
162
163 def weights(self) -> dict[str, float]:
164 """Current draw weight per candidate card, for the chart and for tests."""
165 candidates = [card_id for card_id in SIGNAL_EVENTS if card_id not in self._drawn]
166 if not candidates:
167 candidates = list(SIGNAL_EVENTS)
168 return {card_id: (SEEN_DRAW_WEIGHT if card_id in self.seen else UNSEEN_DRAW_WEIGHT) for card_id in candidates}
169
170 def draw(self) -> str:
171 """Deal one card, mark it seen, and return its id."""
172 if len(self._drawn) >= len(SIGNAL_EVENTS):
173 self._drawn.clear()
174 weights = self.weights()
175 card_ids = list(weights)
176 card_id = self._rng.choices(card_ids, weights=[weights[c] for c in card_ids], k=1)[0]
177 self._drawn.append(card_id)
178 self.seen.add(card_id)
179 self.draws += 1
180 return card_id
181
182
183def card(event_id: str) -> SignalEventCard:
184 """The card behind an emitted ``signal_event`` id."""
185 return SIGNAL_EVENTS[event_id]