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]