shrike/trading.py¶

Part of SHRIKE.

   1"""The scrap ledger, the drift depots, and the Broker barge.
   2
   3Three things live here.
   4
   5:class:`Economy` is the run's purse. It is the only place scrap is added,
   6spent or turned into Cores, so every conversion in the game passes one function
   7and shares one unit, Cores per 10 scrap. It listens for the sector's
   8``RESOURCE_COLLECTED`` and credits the scrap motes that reach the ship, which
   9is what makes flying through the confetti the income.
  10
  11:class:`Depot` is the trading post: a hermit in a gutted freighter with a
  12docking bay, six items of stock plus a pity slot biased toward the build you
  13are actually flying, an oxygen canister and a fuel cell that are on the shelf
  14whatever the roll says, a reroll that doubles every time you use it, and a
  15standing offer to buy your scrap outright at
  16``balance.CONVERT_RATE_TRADING_POST``. That
  17offer is what anchors meta banking to places on the chart: away from a post the
  18only conversion is the Refinery, and the Refinery is loud.
  19
  20The bay owns the docking, the shelf and the prices; the screen that shows them
  21is ``shrike.flow.DepotScene``, which drives :meth:`Depot.tick_interact` by hand
  22while it holds the run still so the bay keeps answering its own key. A shelf
  23stays put for the whole visit: :meth:`Depot.stock_for` marks a bought entry
  24``sold`` rather than dropping it, because a shelf that reshuffles between the
  25press and the release is a shelf nobody can read. Consumables restock inside
  26:data:`CONSUMABLE_PURCHASE_LIMITS`; a module or a weapon is a thing, and the bay
  27has one of it.
  28
  29:class:`Broker` is the same barge for notorious ships only. Below
  30``balance.BROKER_NOTORIETY_THRESHOLD`` it will not open its bay, and above it
  31the stock is the eight tier-3 exclusives a quiet player never sees.
  32
  33Modules themselves are ``shrike.modules``'s business. The shelf reads
  34``MODULE_CATALOGUE`` for a module's family, tier and price, and ``ModulePool``
  35for whether the world offers it at all, so the shop and the socket rack can
  36never disagree about what a thing is or what it costs. Weapons have no such
  37catalogue, so their price band lives here beside the consumables.
  38
  39What a row promises
  40===================
  41
  42Every priced row also carries a ``fit``: where this purchase would land on the
  43hull that is docked right now, read off the live racks rather than guessed.
  44That is what turns "AUTOCANNON, 43 scrap" into "AUTOCANNON, 43 scrap, mounts
  45hardpoint 2" or, on a full rack, into an honest warning that it will stow. The
  46effect half of the line comes from the two modules that own the numbers,
  47``modules.effect_summary`` and ``weapons.weapon_summary``, so a rebalance moves
  48the shelf text with it and no sentence here can drift out of date.
  49
  50The bay is also where a fit is changed, because it is the only place the run
  51stands still. :meth:`Depot.swap_weapon` performs one mount and announces what
  52came off; the run scene owns the stowage and moves the two ids itself, which is
  53what keeps a single list authoritative. The screen that drives it is
  54``shrike.flow.RefitScene``, a schematic of the hull with its mounts drawn where
  55they really sit; :meth:`Depot.swap_options` is the same offer as a flat list,
  56which is what a headless caller wants and what the dock screen used to print.
  57The sentences both of them speak live here, beside the shelf's, so the wording
  58of a fit is written once: :func:`item_summary` for what a thing does and
  59:func:`refit_preview` for what mounting it here would cost.
  60"""
  61
  62from __future__ import annotations
  63
  64import math
  65import random
  66from collections.abc import Callable
  67from dataclasses import dataclass
  68
  69from simvx.core import Area3D, Input, Node, Node3D, Signal, SphereShape3D, Vec2
  70
  71from . import balance
  72from .modules import (
  73    MODULE_CATALOGUE,
  74    SOCKET_RACK_GROUP,
  75    ModulePool,
  76    effect_summary,
  77    module_spec,
  78    price_band,
  79    socket_label,
  80)
  81from .power import SignalWiring
  82from .runtime import Groups, Layers, Services, SignalNames, to_plane
  83from .weapons import WEAPON_RACK_GROUP, weapon_summary
  84
  85try:
  86    from . import artkit
  87except ImportError:  # pragma: no cover - artkit is optional for logic tests
  88    artkit = None
  89
  90# ============================================================================
  91# Local constants
  92#
  93# balance.py owns prices, rates and thresholds. What follows is the shape of
  94# the shop and the reach of the docking bay, which are this module's own.
  95# ============================================================================
  96
  97#: How close the ship must come before the bay will take it, in world units.
  98#:
  99#: The barge's own drum is five units across the beam, and the ship cruises at
 100#: about sixteen units a second, so the original eight left a three-unit shell
 101#: of hull clearance that a pilot at speed crossed in a fifth of a second. That
 102#: is not a docking bay, it is a coin toss, and it is why a marked depot read as
 103#: something the game would not let you touch. Fourteen gives the approach about
 104#: a second and a half of standing still in, which is what a bay is for.
 105DOCK_RADIUS = 14.0
 106
 107#: Hold the interact button this long while docked to sell the hold. The
 108#: design's interact ladder is tap to dock, 1 s to patch, 3 s to sell or refine.
 109INTERACT_SELL_HOLD_S = 3.0
 110
 111#: The bay's affordance lines. Every one names its key and its verb, and the
 112#: sell line names the price, because a hold is only worth selling if the pilot
 113#: can see what it is worth.
 114PROMPT_DOCK = "F: DOCK"
 115PROMPT_SELL = "HOLD F: SELL {scrap:.0f} SCRAP FOR {cores:.1f} CORES"
 116PROMPT_SELL_EMPTY = "DOCKED: nothing in the hold to sell"
 117PROMPT_DOCK_REFUSED = "BROKER BARGE: notoriety {threshold} to dock"
 118
 119#: How many of one shelf entry a single visit will sell.
 120#:
 121#: A module or a weapon is a thing, and the bay has exactly one of it. A
 122#: consumable is a commodity and the bay will keep selling it while the hold
 123#: can pay, except where the design caps it: air is rationed at
 124#: ``balance.DEPOT_O2_CANISTERS_PER_VISIT`` so a full purse cannot simply buy
 125#: its way out of the life-support clock.
 126UNIQUE_PURCHASE_LIMIT = 1
 127#: What an uncapped consumable is capped at anyway, so no shelf is infinite.
 128CONSUMABLE_RESTOCK_LIMIT = balance.DEPOT_CONSUMABLE_RESTOCK_LIMIT
 129CONSUMABLE_PURCHASE_LIMITS: dict[str, int] = {
 130    "o2_canister": balance.DEPOT_O2_CANISTERS_PER_VISIT,
 131    "fuel_cell": balance.DEPOT_FUEL_CELLS_PER_VISIT,
 132}
 133
 134#: The two supplies every bay carries whatever the dice say.
 135#:
 136#: Air and fuel are not stock, they are the two clocks a run is flown against,
 137#: and a shelf that rolled neither is a shop with no answer to either. The
 138#: blind gate found exactly that: a bay selling an autocannon, a dampener, an
 139#: RTG, a sentry, an ammo box and an emitter vane to a hull down to 61 oxygen
 140#: with one jump of fuel in the tank, and no screen anywhere in the game that
 141#: named where more of either comes from.
 142#:
 143#: So they are stocked *beside* the rolled shelf rather than inside it: a
 144#: reroll re-rolls the six and never touches these two, and the rolled
 145#: consumable slot draws from what is left, so the shelf can never spend a
 146#: slot offering air it was already offering. What a visit has already sold
 147#: still stands: the canister ration is spent once it is spent.
 148GUARANTEED_CONSUMABLES: tuple[str, ...] = ("o2_canister", "fuel_cell")
 149#: The slot mark a standing supply carries, beside ``stock`` and ``pity``.
 150SUPPLY_SLOT = "supply"
 151
 152#: How often the pity slot really honours the build it is reading. Below 1.0
 153#: on purpose: an archetype has to stay findable without being guaranteed.
 154PITY_BIAS_STRENGTH = 0.75
 155
 156#: Extra weight the Refinery carries in the pity slot. The conversion bet only
 157#: stays live if most runs are offered the module that makes it.
 158REFINERY_PITY_WEIGHT = 3.0
 159
 160#: Tiers a depot may stock per act, read off the wreck drop table so the shop
 161#: and the world agree about when tier 3 exists at all.
 162ACT_TIER_BANDS = {act: drops.module_tiers_in_wrecks for act, drops in balance.ACT_DROPS.items()}
 163
 164#: Spacing between one depot's seeds and the next reroll's, so two depots one
 165#: apart never share a shelf.
 166_REROLL_STRIDE = 1_000_003
 167
 168
 169# ============================================================================
 170# Stock catalogue
 171# ============================================================================
 172
 173
 174@dataclass(frozen=True)
 175class StockItem:
 176    """One thing on a depot's shelf."""
 177
 178    id: str
 179    kind: str  # "weapon" | "module" | "consumable"
 180    family: str
 181    tier: int
 182    price: int
 183    slot: str = "stock"  # "stock" | "pity"
 184    broker_exclusive: bool = False
 185    pool_sibling: str = ""
 186
 187    def as_dict(self) -> dict:
 188        """The plain-dict form the depot hands out."""
 189        return {
 190            "id": self.id,
 191            "kind": self.kind,
 192            "family": self.family,
 193            "tier": self.tier,
 194            "price": self.price,
 195            "slot": self.slot,
 196            "broker_exclusive": self.broker_exclusive,
 197            "pool_sibling": self.pool_sibling,
 198        }
 199
 200
 201#: Consumables, priced by the design rather than by a tier band.
 202CONSUMABLES: dict[str, tuple[str, int]] = {
 203    "ammo_box": ("ammo", balance.AMMO_BOX_PRICE_SCRAP),
 204    "o2_canister": ("life_support", balance.DEPOT_O2_CANISTER_PRICE_SCRAP),
 205    "fuel_cell": ("fuel", balance.DEPOT_FUEL_PRICE_SCRAP),
 206    "breach_patch": ("repair", balance.BREACH_PATCH_SCRAP),
 207}
 208
 209#: Which price band each weapon is sold in. ``balance.WEAPONS`` fixes what a
 210#: weapon does and which economy it feeds; what a trader charges for one is the
 211#: shop's business, and follows the module price curve by tier.
 212WEAPON_TIERS: dict[str, int] = {
 213    "pulse_blaster": 1,
 214    "mining_laser": 1,
 215    "autocannon": 1,
 216    "scatter_coil": 2,
 217    "arc_beam": 2,
 218    "flak_cannon": 2,
 219    "rail_lance": 2,
 220    "grav_hook": 2,
 221    "missile_rack": 3,
 222    "nova_mortar": 3,
 223}
 224
 225
 226#: What one of each consumable actually does, as the delta it applies. A
 227#: consumable is bought for a number, so the shelf quotes the number: "+40 O2
 228#: now" is an offer where "air in the tank" is a mood.
 229CONSUMABLE_EFFECTS: dict[str, str] = {
 230    "ammo_box": "one full box of rounds now",
 231    "o2_canister": (
 232        f"+{balance.DEPOT_O2_CANISTER_AMOUNT:.0f} O2 now, "
 233        f"{balance.DEPOT_O2_CANISTER_AMOUNT / balance.O2_DRAIN_PER_S:.0f} s of air on a sound hull"
 234    ),
 235    "fuel_cell": f"+{balance.DEPOT_FUEL_AMOUNT:.0f} fuel now",
 236    "breach_patch": "seals one open breach now",
 237}
 238
 239#: What each shelf category is called on the dock screen.
 240CATEGORY_LABELS: dict[str, str] = {"weapon": "weapon", "module": "module", "consumable": "supply"}
 241#: What a standing supply's category says instead. The phrase is the promise:
 242#: a pilot who rerolls a shelf hunting for air has to be able to read, before
 243#: they spend the scrap, that these two rows are not what a reroll changes.
 244CATEGORY_ALWAYS_STOCKED = "supply, always stocked"
 245
 246#: Where a purchase would land, said before the scrap is spent. The shelf is
 247#: the last place a fit can still be planned, so a weapon that will not mount
 248#: says so on the row rather than in the toast that follows the payment.
 249FIT_MOUNTS = "mounts hardpoint {hardpoint}"
 250FIT_STOWS = "STOWS: hardpoints full (swap at dock)"
 251FIT_INSTALLS = "{socket} socket"
 252FIT_NO_SOCKET = "NO FREE {size} SOCKET"
 253FIT_FEEDS = "feeds the {name}, {rounds} rounds"
 254FIT_NO_FEED = "NO BALLISTIC WEAPON TO FEED"
 255FIT_SEALS = "{breaches} open now"
 256FIT_NO_BREACH = "NO OPEN BREACH TO SEAL"
 257
 258
 259#: Headings of the fit panel's three blocks.
 260FIT_HEADING_WEAPONS = "WEAPONS"
 261FIT_HEADING_MODULES = "MODULES"
 262FIT_HEADING_STOWED = "STOWED"
 263
 264#: A refit offer as one line: what goes on, where, and what comes off.
 265SWAP_ROW = "MOUNT {name} ON HARDPOINT {hardpoint}, {displaced} COMES OFF"
 266SWAP_ROW_FREE = "MOUNT {name} ON HARDPOINT {hardpoint}, which is empty"
 267
 268#: What the refit panel says. Every line is the same sentence at a different
 269#: stage of one decision: what a mount is carrying, what putting this on it
 270#: would do, and why it will not go on at all. A refusal is written the same
 271#: way as an offer on purpose: the panel prints the answer *before* the commit,
 272#: so "will not fit" has to read as a description of the hull rather than as an
 273#: error the player has already earned.
 274REFIT_SLOT_EMPTY = "EMPTY"
 275REFIT_PREVIEW_MOUNT = "{name} ON {slot}: {summary}, REPLACES {displaced}"
 276REFIT_PREVIEW_MOUNT_BARE = "{name} ON {slot}: {summary}, THE MOUNT IS BARE"
 277REFIT_PREVIEW_PICK_MOUNT = "{name}: {summary}. PICK A MOUNT FOR IT"
 278REFIT_PREVIEW_SLOT_FULL = "{slot} CARRIES {occupant}: {summary}"
 279REFIT_PREVIEW_SLOT_BARE = "{slot} IS BARE. PICK SOMETHING OUT OF THE LOCKER"
 280REFIT_REFUSE_GUN_IN_SOCKET = "{name} IS A GUN: IT NEEDS A HARDPOINT, NOT THE {slot}"
 281REFIT_REFUSE_MODULE_ON_HARDPOINT = "{name} IS A MODULE: IT NEEDS A SOCKET, NOT {slot}"
 282REFIT_REFUSE_TOO_LARGE = "{name} NEEDS A {size} SOCKET; {slot} IS {socket_size}"
 283REFIT_REFUSE_TURRETS_FULL = "{name} WOULD BE TURRET {count}: A HULL CARRIES {maximum}"
 284
 285
 286def item_name(item_id: str) -> str:
 287    """A shelf entry's id as a display name: ``pulse_blaster`` to ``PULSE BLASTER``."""
 288    return str(item_id).replace("_", " ").upper()
 289
 290
 291def category_label(item: dict) -> str:
 292    """The one-word category the dock screen files *item* under."""
 293    kind = str(item.get("kind", ""))
 294    label = CATEGORY_LABELS.get(kind, kind or "item")
 295    if item.get("slot") == "pity":
 296        return f"{label}, for your build"
 297    if item.get("slot") == SUPPLY_SLOT:
 298        return CATEGORY_ALWAYS_STOCKED
 299    return label
 300
 301
 302def effect_line(item: dict) -> str:
 303    """One line saying what *item* does, for the shelf that is selling it.
 304
 305    Every line is mechanical. A weapon leads with where it would land, because
 306    a gun that is going to stow is a different purchase from one that is going
 307    to shoot, and then quotes its DPS, its feed and its trigger. A module leads
 308    with the numbers it actually applies and ends with the mount it takes. A
 309    consumable leads with its delta.
 310
 311    The mount half comes off ``item["fit"]``, which :meth:`Depot.priced` folds
 312    in from the live hull. Without a hull the line still describes the thing,
 313    it simply cannot promise it a socket. A consumable the hull has no use for
 314    prints the refusal alone: "+40 O2 now" beside "no tank to fill" is two
 315    halves of a sentence arguing with each other.
 316    """
 317    item_id = str(item.get("id", ""))
 318    kind = str(item.get("kind", ""))
 319    fit = str(item.get("fit", ""))
 320    blocked = bool(item.get("fit_blocked", False))
 321    if kind == "weapon":
 322        if item_id not in balance.WEAPONS:
 323            return ""
 324        return ", ".join(part for part in (fit, weapon_summary(item_id)) if part)
 325    if kind == "module":
 326        if item_id not in MODULE_CATALOGUE:
 327            return ""
 328        return ", ".join(part for part in (effect_summary(item_id), fit or socket_label(item_id)) if part)
 329    if blocked:
 330        return fit
 331    return ", ".join(part for part in (CONSUMABLE_EFFECTS.get(item_id, ""), fit) if part)
 332
 333
 334def item_summary(item_id: str) -> str:
 335    """What a thing in the locker does, gun or module, in one line.
 336
 337    The two catalogues answer the question in their own words and neither
 338    knows about the other, so the refit panel would otherwise have to branch on
 339    the kind at every place it prints a name. Empty for an id in neither, which
 340    is what a stale save or a hand-built test row hands in.
 341    """
 342    item_id = str(item_id)
 343    if item_id in balance.WEAPONS:
 344        return weapon_summary(item_id)
 345    if item_id in MODULE_CATALOGUE:
 346        return effect_summary(item_id)
 347    return ""
 348
 349
 350def refit_preview(item_id: str, slot_label: str, occupant: str = "") -> str:
 351    """What mounting *item_id* on *slot_label* would do, before it is committed.
 352
 353    The panel prints this while the mount is only being *looked* at, which is
 354    the whole point of it: a refit that names the gun coming off before the
 355    press is a decision, and the same sentence after the press is a receipt.
 356    *occupant* is the id already on the mount, empty for a bare one.
 357    """
 358    name = item_name(item_id)
 359    summary = item_summary(item_id)
 360    if occupant:
 361        return REFIT_PREVIEW_MOUNT.format(name=name, slot=slot_label, summary=summary, displaced=item_name(occupant))
 362    return REFIT_PREVIEW_MOUNT_BARE.format(name=name, slot=slot_label, summary=summary)
 363
 364
 365def fit_panel_lines(
 366    rack=None,
 367    sockets=None,
 368    stowed: tuple[str, ...] | list[str] = (),
 369    stowed_modules: tuple[str, ...] | list[str] = (),
 370) -> list[str]:
 371    """The current fit as a screen prints it: hardpoints, sockets, stowage.
 372
 373    The dock screen's FIT panel. Composed here rather than in either rack
 374    because a fit is the two of them together, and a pilot deciding what to buy
 375    reads them as one list. Either rack may be absent, which is what a test
 376    harness or a half-built run hands in; the block it owns is simply left out.
 377
 378    The stowage is one block, guns and modules together, because it is one
 379    locker: a module pulled out of a socket at the refit panel is waiting for a
 380    mount in exactly the way a gun with no free hardpoint is.
 381    """
 382    lines: list[str] = []
 383    if rack is not None:
 384        lines.append(FIT_HEADING_WEAPONS)
 385        lines.extend(rack.fit_lines())
 386    if sockets is not None:
 387        lines.append(FIT_HEADING_MODULES)
 388        lines.extend(sockets.fit_lines())
 389    locker = [*stowed, *stowed_modules]
 390    if locker:
 391        lines.append(FIT_HEADING_STOWED)
 392        lines.extend(f"{item_name(item_id)}, {item_summary(item_id)}" for item_id in locker)
 393    return lines
 394
 395
 396def _weapon_price(weapon_id: str, rng: random.Random) -> int:
 397    low, high = price_band(WEAPON_TIERS.get(weapon_id, 1))
 398    return int(rng.randint(low, high))
 399
 400
 401def _weapon_family(weapon_id: str) -> str:
 402    spec = balance.WEAPONS.get(weapon_id)
 403    return spec.family if spec is not None else "energy"
 404
 405
 406def _module_item(module_id: str, *, slot: str = "stock") -> StockItem:
 407    """One catalogue module as a shelf entry, priced by ``shrike.modules``."""
 408    spec = module_spec(module_id)
 409    return StockItem(
 410        id=module_id,
 411        kind="module",
 412        family=str(spec["family"]),
 413        tier=int(spec["tier"]),
 414        price=int(spec["price"]),
 415        slot=slot,
 416        broker_exclusive=bool(spec["broker_exclusive"]),
 417        pool_sibling=str(spec["pool_sibling"] or ""),
 418    )
 419
 420
 421# ============================================================================
 422# Economy
 423# ============================================================================
 424
 425
 426class Economy(Node):
 427    """The run's scrap and the Cores it becomes. Singleton ``Services.ECONOMY``.
 428
 429    Scrap arrives from the sector's ``RESOURCE_COLLECTED``, from bounties and
 430    from anything else that calls :meth:`add_scrap`. It leaves through
 431    :meth:`spend_scrap` (purchases, patches, the Lure fee) or through
 432    :meth:`convert`, which is the only door onto the meta currency and takes
 433    its rate from the caller: ``balance.CONVERT_RATE_TRADING_POST`` at a depot,
 434    ``CONVERT_RATE_REFINERY`` aboard, ``CONVERT_RATE_EXTRACTION`` at the Gate,
 435    ``CONVERT_RATE_DEATH`` when the run ends badly.
 436    """
 437
 438    scrap_changed = Signal(float)
 439    cores_banked = Signal(float, float)
 440
 441    def __init__(self, *, scrap: float = 0.0, **kwargs):
 442        super().__init__(**kwargs)
 443        self.scrap = max(0.0, float(scrap))
 444        self.cores_banked_this_run = 0.0
 445        #: Every scrap ever credited this run, for the death ledger's income line.
 446        self.scrap_earned_this_run = 0.0
 447        self._wiring = SignalWiring(self)
 448        self._wiring.want(SignalNames.RESOURCE_COLLECTED, self._on_resource_collected)
 449
 450    def on_ready(self):
 451        self._wiring.sweep()
 452
 453    def on_update(self, dt: float):
 454        self._wiring.poll(dt)
 455
 456    def add_scrap(self, amount: float, source: str = "") -> None:
 457        """Credit *amount* of scrap. *source* is for the ledger, not the maths."""
 458        del source
 459        amount = float(amount)
 460        if amount <= 0.0:
 461            return
 462        self.scrap += amount
 463        self.scrap_earned_this_run += amount
 464        self.scrap_changed(self.scrap)
 465
 466    def spend_scrap(self, amount: float) -> bool:
 467        """Atomic spend. False and no deduction when the hold is short."""
 468        amount = float(amount)
 469        if amount < 0.0 or amount > self.scrap + 1e-9:
 470            return False
 471        self.scrap = max(0.0, self.scrap - amount)
 472        self.scrap_changed(self.scrap)
 473        return True
 474
 475    def convert(self, scrap: float, rate: float) -> float:
 476        """Turn carried scrap into Cores at *rate* Cores per 10 scrap.
 477
 478        Converts at most what is carried, so a caller may pass more than the
 479        hold holds and still get an honest answer. Returns the Cores banked.
 480        """
 481        scrap = min(max(0.0, float(scrap)), self.scrap)
 482        if scrap <= 0.0:
 483            return 0.0
 484        cores = balance.cores_from_scrap(scrap, float(rate))
 485        self.scrap -= scrap
 486        self.cores_banked_this_run += cores
 487        self.scrap_changed(self.scrap)
 488        self.cores_banked(cores, float(rate))
 489        return cores
 490
 491    def _on_resource_collected(self, kind: str, amount: float) -> None:
 492        if kind == "scrap":
 493            self.add_scrap(amount, "sector")
 494
 495
 496# ============================================================================
 497# Depots
 498# ============================================================================
 499
 500
 501class Depot(Node3D):
 502    """A drift depot: a docking bay, a shelf of stock and a standing scrap offer.
 503
 504    Docking is proximity plus intent. The bay is an ``Area3D`` sensor on
 505    ``Layers.INTERACT`` watching for the ship, and a tap of ``interact`` inside
 506    it docks; holding the same button for :data:`INTERACT_SELL_HOLD_S` while
 507    docked sells the whole hold at the post's rate. Flying out undocks.
 508
 509    Stock is deterministic from ``seed`` and the reroll count, so a route
 510    replayed on the same seed offers the same shelf; :meth:`stock_for` layers
 511    the pity slot on top of it from the build profile the run hands in, and
 512    puts :data:`GUARANTEED_CONSUMABLES` in front of the whole roll, so air and
 513    fuel are always for sale somewhere the pilot can find them.
 514    """
 515
 516    docked = Signal(str)
 517    undocked = Signal()
 518    #: ``(item_id, price)`` once payment has cleared. Module-local by design:
 519    #: the run scene connects at mount and delivers the goods, so the depot
 520    #: never has to know what a socket or a hardpoint is.
 521    item_purchased = Signal(str, float)
 522    #: ``(mounted_id, displaced_id, hardpoint)`` after a swap at the bay. The
 523    #: run scene owns the stowage, so it listens for this and moves the two ids
 524    #: between its locker and the hull.
 525    weapon_swapped = Signal(str, str, int)
 526
 527    def __init__(
 528        self,
 529        *,
 530        depot_id: str = "depot",
 531        seed: int = 0,
 532        act: int = 1,
 533        dock_radius: float = DOCK_RADIUS,
 534        pool: ModulePool | None = None,
 535        **kwargs,
 536    ):
 537        super().__init__(**kwargs)
 538        self.depot_id = str(depot_id)
 539        self.seed = int(seed)
 540        self.act = max(1, min(balance.CHART_ACTS, int(act)))
 541        self.dock_radius = float(dock_radius)
 542
 543        self.is_docked = False
 544        self.in_range = False
 545        self.rerolls = 0
 546        #: Ids this visit will not sell again, either because the bay had one
 547        #: of them or because the ration is spent.
 548        self.sold_out: set[str] = set()
 549        #: How many of each id this visit has sold, which is what the shelf
 550        #: shows and what :data:`CONSUMABLE_PURCHASE_LIMITS` is measured against.
 551        self.purchases: dict[str, int] = {}
 552        #: The world pool this shop stocks from; a purchase here widens it for
 553        #: good, and the meta profile is what carries that between runs.
 554        self.pool = pool if pool is not None else ModulePool()
 555        #: Sibling modules a purchase here has just unlocked, for the run to bank.
 556        self.pool_unlocks: list[str] = []
 557        #: Asked before payment, with the item dict. Returning False refuses
 558        #: the sale and nothing is spent; the run scene installs one that
 559        #: checks the hull can actually take the item, so a full socket rack
 560        #: costs a toast rather than scrap.
 561        self.purchase_gate: Callable[[dict], bool] | None = None
 562
 563        self._interact_hold = 0.0
 564        self._sold_this_press = False
 565        #: True while a press that has already been answered elsewhere is still
 566        #: down. Undocking releases the bay on the same button the dock screen
 567        #: commits with, and without this the release that closed the bay would
 568        #: open it again on the very next frame.
 569        self._ignore_release = False
 570        self._stock_cache: tuple[object, list[dict]] | None = None
 571
 572    # -- scene ------------------------------------------------------------
 573
 574    def on_enter_tree(self):
 575        super().on_enter_tree()
 576        self.add_to_group(Groups.DEPOTS)
 577
 578    def on_ready(self):
 579        if artkit is not None:
 580            self.add_child(artkit.build_depot(self.seed))
 581        self.add_child(
 582            Area3D(
 583                name="DockSensor",
 584                shape=SphereShape3D(radius=self.dock_radius),
 585                collision_layer=Layers.INTERACT,
 586                collision_mask=Layers.MASK_INTERACT_SENSOR,
 587            )
 588        )
 589
 590    @property
 591    def plane_position(self) -> Vec2:
 592        return to_plane(self.world_position)
 593
 594    def _ship(self):
 595        tree = self.tree
 596        return tree.get_first_in_group(Groups.SHIP) if tree is not None else None
 597
 598    def on_update(self, dt: float):
 599        ship = self._ship()
 600        if ship is None:
 601            self.in_range = False
 602            if self.is_docked:
 603                self.undock()
 604            return
 605
 606        here = self.plane_position
 607        there = to_plane(ship.world_position)
 608        distance = math.hypot(float(here.x) - float(there.x), float(here.y) - float(there.y))
 609        self.in_range = distance <= self.dock_radius
 610
 611        if self.is_docked and not self.in_range:
 612            self.undock()
 613
 614        self.tick_interact(dt)
 615
 616    def tick_interact(self, dt: float, *, taps_toggle_bay: bool = True) -> None:
 617        """Read one frame of the bay's interact ladder.
 618
 619        Resolved on release so one button can both tap and hold: a tap toggles
 620        the bay, :data:`INTERACT_SELL_HOLD_S` of hold sells the hold at the
 621        post's rate.
 622
 623        Called from :meth:`on_update` in the ordinary case, and directly by the
 624        dock screen while that screen holds the run still: a paused depot would
 625        stop answering the button the world spent the whole run teaching. The
 626        screen passes ``taps_toggle_bay=False`` because while it is up a tap is
 627        its own confirm and the screen owns the way out, but the sell rung stays
 628        live, so holding the key still empties the hold exactly as it does in
 629        open space.
 630        """
 631        if Input.is_action_just_pressed("interact"):
 632            self._interact_hold = 0.0
 633            self._sold_this_press = False
 634            self._ignore_release = False
 635        if Input.is_action_pressed("interact"):
 636            self._interact_hold += dt
 637            if self.is_docked and not self._sold_this_press and self._interact_hold >= INTERACT_SELL_HOLD_S:
 638                self._sold_this_press = True
 639                self.sell_hold()
 640        elif Input.is_action_just_released("interact"):
 641            tapped = not self._sold_this_press and self._interact_hold < INTERACT_SELL_HOLD_S
 642            if tapped and taps_toggle_bay and not self._ignore_release:
 643                if self.is_docked:
 644                    self.undock()
 645                elif self.in_range:
 646                    self.dock()
 647            self._interact_hold = 0.0
 648            self._sold_this_press = False
 649            self._ignore_release = False
 650
 651    def interact_hold_fraction(self) -> float:
 652        """Progress of the sell hold, 0 to 1, for the HUD's radial fill."""
 653        if not self.is_docked or self._sold_this_press:
 654            return 0.0
 655        return min(1.0, self._interact_hold / INTERACT_SELL_HOLD_S)
 656
 657    def affordance(self) -> str:
 658        """The verb line for the bay's current state, empty when out of range.
 659
 660        Docked, the line quotes the hold and what the post would pay for it.
 661        In range but not docked, it names the key. A barge that will not open
 662        its bay says why, rather than answering a press with silence.
 663        """
 664        if self.is_docked:
 665            economy = self._economy()
 666            scrap = float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
 667            if scrap <= 0.0:
 668                return PROMPT_SELL_EMPTY
 669            cores = balance.cores_from_scrap(scrap, balance.CONVERT_RATE_TRADING_POST)
 670            return PROMPT_SELL.format(scrap=scrap, cores=cores)
 671        if not self.in_range:
 672            return ""
 673        if not self.can_dock():
 674            return PROMPT_DOCK_REFUSED.format(threshold=balance.BROKER_NOTORIETY_THRESHOLD)
 675        return PROMPT_DOCK
 676
 677    # -- docking -----------------------------------------------------------
 678
 679    def can_dock(self) -> bool:
 680        """Whether this bay will take the ship at all. Always true for a post."""
 681        return True
 682
 683    def dock(self) -> bool:
 684        """Open the bay. False when the ship is out of range or refused."""
 685        if self.is_docked or not self.in_range or not self.can_dock():
 686            return False
 687        self.is_docked = True
 688        self.docked(self.depot_id)
 689        return True
 690
 691    def undock(self) -> None:
 692        """Close the bay and let the run continue."""
 693        if not self.is_docked:
 694            return
 695        self.is_docked = False
 696        self._ignore_release = True
 697        self.undocked()
 698
 699    # -- stock -------------------------------------------------------------
 700
 701    def stock_for(self, build_profile: dict) -> list[dict]:
 702        """This visit's shelf: the standing supplies, six items, and the pity slot.
 703
 704        The supplies are :data:`GUARANTEED_CONSUMABLES`, air and fuel, which
 705        every bay carries and no reroll can take away. Six is
 706        ``balance.DEPOT_STOCK_WEAPONS`` weapons, ``DEPOT_STOCK_MODULES``
 707        modules and ``DEPOT_STOCK_CONSUMABLES`` consumable drawn from what the
 708        supplies leave; the pity slot is drawn from whatever families the
 709        profile is already flying, so an archetype stays findable without ever
 710        being promised. The result is stable for a given profile and reroll
 711        count.
 712
 713        A bought entry stays on the shelf carrying ``sold``, rather than
 714        vanishing and letting the rest of the shelf reshuffle under a pilot who
 715        is still reading it. Only a reroll changes what is on offer.
 716        """
 717        profile = dict(build_profile or {})
 718        signature = (self.rerolls, _profile_signature(profile))
 719        if self._stock_cache is not None and self._stock_cache[0] == signature:
 720            return [self.priced(item) for item in self._stock_cache[1]]
 721
 722        # Seeded from integers only: a shelf must roll the same way in every
 723        # process, and Python's string hashing is salted per run.
 724        rng = random.Random(self.seed * _REROLL_STRIDE + self.rerolls)
 725        items = self._roll_stock(rng, profile)
 726        self._stock_cache = (signature, [dict(item) for item in items])
 727        return [self.priced(item) for item in items]
 728
 729    def priced(self, item: dict) -> dict:
 730        """One shelf entry with this visit's live state folded in.
 731
 732        Beside what the visit has sold, the entry carries ``fit``: where this
 733        purchase would land on the hull that is docked right now. It is read
 734        off the live racks rather than guessed, so the row cannot promise a
 735        hardpoint the delivery will not give it.
 736        """
 737        entry = dict(item)
 738        item_id = str(entry.get("id", ""))
 739        entry["sold"] = item_id in self.sold_out
 740        entry["bought"] = int(self.purchases.get(item_id, 0))
 741        entry["fit"], entry["fit_blocked"] = self.fit_for(entry)
 742        return entry
 743
 744    # -- what a purchase would do to the hull ------------------------------
 745
 746    def weapon_rack(self):
 747        """The docked ship's weapon rack, or None when no hull is in the tree."""
 748        tree = self.tree
 749        racks = tree.group(WEAPON_RACK_GROUP) if tree is not None else []
 750        return racks[0] if racks else None
 751
 752    def socket_rack(self):
 753        """The docked ship's socket rack, or None when no hull is in the tree."""
 754        tree = self.tree
 755        racks = tree.group(SOCKET_RACK_GROUP) if tree is not None else []
 756        return racks[0] if racks else None
 757
 758    def fit_for(self, item: dict) -> tuple[str, bool]:
 759        """Where buying *item* would land it, and whether it would land at all.
 760
 761        The text is what the shelf prints; the flag is True when the hull has
 762        nowhere to put the thing, which is the same answer the run scene's
 763        purchase gate gives after the press. Saying it on the row is what turns
 764        a refused purchase from a surprise into a decision.
 765
 766        Empty and False when there is no hull to ask, which is the honest
 767        answer: a shelf rolled outside a run knows what a thing is and not
 768        where it would go.
 769        """
 770        item_id = str(item.get("id", ""))
 771        kind = str(item.get("kind", ""))
 772        if kind == "weapon":
 773            return self._weapon_fit()
 774        if kind == "module":
 775            return self._module_fit(item_id)
 776        return self._consumable_fit(item_id)
 777
 778    def _weapon_fit(self) -> tuple[str, bool]:
 779        rack = self.weapon_rack()
 780        if rack is None:
 781            return "", False
 782        hardpoint = rack.free_hardpoint()
 783        # A full rack is not a refusal: the weapon is bought and stowed, and
 784        # the bay is where it is swapped onto the hull.
 785        if hardpoint is None:
 786            return FIT_STOWS, False
 787        return FIT_MOUNTS.format(hardpoint=hardpoint + 1), False
 788
 789    def _module_fit(self, module_id: str) -> tuple[str, bool]:
 790        sockets = self.socket_rack()
 791        if sockets is None or module_id not in MODULE_CATALOGUE:
 792            return "", False
 793        socket = sockets.socket_for(module_id)
 794        if socket is None:
 795            return FIT_NO_SOCKET.format(size=str(module_spec(module_id)["socket_size"]).upper()), True
 796        return FIT_INSTALLS.format(socket=socket.label), False
 797
 798    def _consumable_fit(self, item_id: str) -> tuple[str, bool]:
 799        if item_id == "ammo_box":
 800            rack = self.weapon_rack()
 801            if rack is None:
 802                return "", False
 803            eater = rack.ammo_eater()
 804            if eater is None:
 805                return FIT_NO_FEED, True
 806            rounds = int(balance.WEAPONS[eater].rounds_per_box or 0)
 807            return FIT_FEEDS.format(name=item_name(eater), rounds=rounds), False
 808        if item_id == "breach_patch":
 809            ship = self._ship()
 810            if ship is None:
 811                return "", False
 812            breaches = int(getattr(ship, "open_breaches", 0) or 0)
 813            if breaches <= 0:
 814                return FIT_NO_BREACH, True
 815            return FIT_SEALS.format(breaches=breaches), False
 816        return "", False
 817
 818    def _roll_stock(self, rng: random.Random, profile: dict) -> list[dict]:
 819        weapons = self._weapon_pool(profile)
 820        modules = self._module_pool(profile)
 821        # The two clocks first, and outside the roll: see GUARANTEED_CONSUMABLES.
 822        # First in the list as well as always in it, because the shelf is read
 823        # from the top and a bay is docked at when something is running out.
 824        items: list[StockItem] = [self._supply(consumable_id) for consumable_id in GUARANTEED_CONSUMABLES]
 825
 826        for weapon_id in _sample(rng, weapons, balance.DEPOT_STOCK_WEAPONS):
 827            items.append(
 828                StockItem(
 829                    weapon_id,
 830                    "weapon",
 831                    _weapon_family(weapon_id),
 832                    WEAPON_TIERS.get(weapon_id, 1),
 833                    _weapon_price(weapon_id, rng),
 834                )
 835            )
 836        for module_id in _sample(rng, modules, balance.DEPOT_STOCK_MODULES):
 837            items.append(_module_item(module_id))
 838        rolled = [consumable_id for consumable_id in CONSUMABLES if consumable_id not in GUARANTEED_CONSUMABLES]
 839        for consumable_id in _sample(rng, rolled, balance.DEPOT_STOCK_CONSUMABLES):
 840            family, price = CONSUMABLES[consumable_id]
 841            items.append(StockItem(consumable_id, "consumable", family, 1, price))
 842
 843        for _ in range(balance.DEPOT_PITY_SLOTS):
 844            pity = self._roll_pity(rng, profile, modules, {item.id for item in items})
 845            if pity is not None:
 846                items.append(pity)
 847        return [item.as_dict() for item in items]
 848
 849    def _supply(self, consumable_id: str) -> StockItem:
 850        """One standing supply row, priced by the design rather than by a roll."""
 851        family, price = CONSUMABLES[consumable_id]
 852        return StockItem(consumable_id, "consumable", family, 1, price, slot=SUPPLY_SLOT)
 853
 854    def _roll_pity(
 855        self,
 856        rng: random.Random,
 857        profile: dict,
 858        modules: list[str],
 859        taken: set[str],
 860    ) -> StockItem | None:
 861        """One slot that reads the build. Biased, never guaranteed."""
 862        weights = _profile_weights(profile)
 863        candidates = [module_id for module_id in modules if module_id not in taken] or list(modules)
 864        if not candidates:
 865            return None
 866
 867        def weight_of(module_id: str) -> float:
 868            family = module_spec(module_id)["family"]
 869            weight = weights.get(family, 0.0)
 870            if family == "refinery":
 871                weight += REFINERY_PITY_WEIGHT
 872            return weight
 873
 874        matching = [module_id for module_id in candidates if weight_of(module_id) > 0.0]
 875        if matching and rng.random() < PITY_BIAS_STRENGTH:
 876            chosen = _weighted_choice(rng, matching, [weight_of(module_id) for module_id in matching])
 877        else:
 878            chosen = rng.choice(candidates)
 879        return _module_item(chosen, slot="pity")
 880
 881    def _tier_band(self, profile: dict) -> tuple[int, int]:
 882        act = int(profile.get("act", self.act))
 883        return ACT_TIER_BANDS[max(1, min(balance.CHART_ACTS, act))]
 884
 885    def _weapon_pool(self, profile: dict) -> list[str]:
 886        low, high = self._tier_band(profile)
 887        owned = set(profile.get("weapons", ()))
 888        pool = [wid for wid, tier in WEAPON_TIERS.items() if low <= tier <= high and wid not in owned]
 889        return pool or [wid for wid in WEAPON_TIERS if wid not in owned] or list(WEAPON_TIERS)
 890
 891    def pool_for(self, profile: dict) -> ModulePool:
 892        """The world pool this visit stocks from, widened by what is unlocked."""
 893        expansions = set(self.pool.expansions)
 894        expansions |= {str(module_id) for module_id in profile.get("pool_unlocks", ())}
 895        expansions |= {str(module_id) for module_id in profile.get(ModulePool.PROFILE_KEY, ())}
 896        return ModulePool(expansions)
 897
 898    def _module_pool(self, profile: dict) -> list[str]:
 899        low, high = self._tier_band(profile)
 900        owned = set(profile.get("modules", ()))
 901        available = self.pool_for(profile).available()
 902        pool = [
 903            module_id
 904            for module_id in available
 905            if low <= module_spec(module_id)["tier"] <= high
 906            and module_id not in owned
 907            and module_id not in self.sold_out
 908        ]
 909        return pool or available
 910
 911    def reroll_price(self) -> int:
 912        """What the next reroll costs: 10 scrap, doubling every time."""
 913        return int(balance.DEPOT_REROLL_BASE_SCRAP * balance.DEPOT_REROLL_PRICE_MULT**self.rerolls)
 914
 915    def reroll(self) -> bool:
 916        """Buy a fresh shelf. False when the hold cannot cover the price."""
 917        economy = self._economy()
 918        price = self.reroll_price()
 919        if economy is None or not economy.spend_scrap(price):
 920            return False
 921        self.rerolls += 1
 922        self._stock_cache = None
 923        return True
 924
 925    # -- trading -----------------------------------------------------------
 926
 927    def purchase_limit(self, item: dict) -> int:
 928        """How many of *item* this visit will sell in total."""
 929        if str(item.get("kind", "")) != "consumable":
 930            return UNIQUE_PURCHASE_LIMIT
 931        return CONSUMABLE_PURCHASE_LIMITS.get(str(item.get("id", "")), CONSUMABLE_RESTOCK_LIMIT)
 932
 933    def buy(self, item: dict) -> bool:
 934        """Buy one stocked *item*.
 935
 936        False, and no payment, when the bay is closed, the item is gone, the
 937        :attr:`purchase_gate` refuses it, or the hold cannot cover the price.
 938        """
 939        item_id = str(item.get("id", ""))
 940        economy = self._economy()
 941        if not self.is_docked or economy is None or item_id in self.sold_out:
 942            return False
 943        if self.purchase_gate is not None and not self.purchase_gate(dict(item)):
 944            return False
 945        price = float(item.get("price", 0.0))
 946        if not economy.spend_scrap(price):
 947            return False
 948        sold = self.purchases.get(item_id, 0) + 1
 949        self.purchases[item_id] = sold
 950        if sold >= self.purchase_limit(item):
 951            self.sold_out.add(item_id)
 952        if item.get("kind") == "module":
 953            sibling = self.pool.record_purchase(item_id)
 954            if sibling is not None:
 955                self.pool_unlocks.append(sibling)
 956        self.item_purchased(item_id, price)
 957        return True
 958
 959    # -- refitting ---------------------------------------------------------
 960
 961    def swap_options(self, stowed: tuple[str, ...] | list[str]) -> list[dict]:
 962        """Every stowed weapon against every hardpoint it could take.
 963
 964        The flat form of the offer the refit panel draws on the hull: each
 965        entry carries the id going on, the hardpoint it goes on, the id coming
 966        off and a line naming all three, so a caller with no screen (a test, a
 967        headless probe) can enumerate the refits a visit is offering.
 968
 969        Empty when nothing is stowed, which is the ordinary case.
 970        """
 971        rack = self.weapon_rack()
 972        if rack is None:
 973            return []
 974        options: list[dict] = []
 975        for weapon_id in stowed:
 976            if weapon_id not in balance.WEAPONS:
 977                continue
 978            for hardpoint, fitted in enumerate(rack.hardpoints):
 979                displaced = fitted.weapon_id if fitted is not None else ""
 980                template = SWAP_ROW if displaced else SWAP_ROW_FREE
 981                options.append(
 982                    {
 983                        "weapon_id": weapon_id,
 984                        "hardpoint": hardpoint,
 985                        "displaced": displaced,
 986                        "label": template.format(
 987                            name=item_name(weapon_id),
 988                            hardpoint=hardpoint + 1,
 989                            displaced=item_name(displaced),
 990                        ),
 991                    }
 992                )
 993        return options
 994
 995    def swap_weapon(self, weapon_id: str, hardpoint: int) -> str | None:
 996        """Mount a stowed weapon and hand back the id it displaced.
 997
 998        The empty string when the hardpoint was bare, and None when the swap
 999        could not happen at all: no bay open, no hull, or a hardpoint that does
1000        not exist. The caller owns the stowage and moves both ids itself, which
1001        is what keeps one list authoritative.
1002        """
1003        rack = self.weapon_rack()
1004        if not self.is_docked or rack is None or weapon_id not in balance.WEAPONS:
1005            return None
1006        if not 0 <= hardpoint < len(rack.hardpoints):
1007            return None
1008        displaced = rack.swap_in(weapon_id, hardpoint)
1009        # The shelf needs no invalidation: the rolled stock is cached, but
1010        # `priced` re-reads the hull on every shelf read, so the next refresh
1011        # already names the hardpoint this swap has just freed or filled.
1012        self.weapon_swapped(weapon_id, displaced, hardpoint)
1013        return displaced
1014
1015    def sell_quote(self) -> tuple[float, float, float]:
1016        """What the hold is, what the post would pay for it, and at what rate."""
1017        economy = self._economy()
1018        scrap = float(getattr(economy, "scrap", 0.0)) if economy is not None else 0.0
1019        rate = balance.CONVERT_RATE_TRADING_POST
1020        return scrap, balance.cores_from_scrap(scrap, rate), rate
1021
1022    def sell_hold(self) -> float:
1023        """Sell the whole hold at the post's rate. Returns Cores banked."""
1024        economy = self._economy()
1025        if economy is None or not self.is_docked:
1026            return 0.0
1027        return economy.convert(economy.scrap, balance.CONVERT_RATE_TRADING_POST)
1028
1029    def _economy(self) -> Economy | None:
1030        tree = self.tree
1031        return tree.singletons.get(Services.ECONOMY) if tree is not None else None
1032
1033    def _notoriety(self) -> int:
1034        tree = self.tree
1035        tally = tree.singletons.get(Services.NOTORIETY) if tree is not None else None
1036        return int(getattr(tally, "value", 0)) if tally is not None else 0
1037
1038
1039class Broker(Depot):
1040    """The black-market barge: notoriety only, and the eight best modules.
1041
1042    It will not open its bay below ``balance.BROKER_NOTORIETY_THRESHOLD``, and
1043    what it sells is the tier-3 stock no quiet run ever sees. That gate is the
1044    payoff gradient that keeps loud play worth its price.
1045    """
1046
1047    def __init__(self, *, depot_id: str = "broker", **kwargs):
1048        super().__init__(depot_id=depot_id, **kwargs)
1049
1050    def can_dock(self) -> bool:
1051        tree = self.tree
1052        tally = tree.singletons.get(Services.NOTORIETY) if tree is not None else None
1053        if tally is not None and hasattr(tally, "broker_unlocked"):
1054            return bool(tally.broker_unlocked())
1055        return self._notoriety() >= balance.BROKER_NOTORIETY_THRESHOLD
1056
1057    def exclusives(self) -> list[str]:
1058        """The tier-3 modules only this barge stocks."""
1059        return [module_id for module_id, spec in MODULE_CATALOGUE.items() if spec["broker_exclusive"]]
1060
1061    def stock_for(self, build_profile: dict) -> list[dict]:
1062        """All eight exclusives, priced. No pity slot: it is all top shelf."""
1063        del build_profile
1064        return [self.priced(_module_item(module_id).as_dict()) for module_id in self.exclusives()]
1065
1066
1067# ============================================================================
1068# Helpers
1069# ============================================================================
1070
1071
1072def _profile_signature(profile: dict) -> tuple:
1073    """A hashable read of the parts of a build profile the shelf depends on."""
1074    return (
1075        int(profile.get("act", 0)),
1076        tuple(sorted(str(x) for x in profile.get("weapons", ()))),
1077        tuple(sorted(str(x) for x in profile.get("modules", ()))),
1078        tuple(sorted(str(x) for x in profile.get("pool_unlocks", ()))),
1079        tuple(sorted(str(x) for x in profile.get(ModulePool.PROFILE_KEY, ()))),
1080        tuple(sorted((str(k), float(v)) for k, v in dict(profile.get("families", {})).items())),
1081    )
1082
1083
1084def _profile_weights(profile: dict) -> dict[str, float]:
1085    """Family weights for the pity slot, from whatever the profile carries.
1086
1087    ``families`` wins where it is given; otherwise the weights are read off
1088    what the ship is already flying, which is the same question asked the other
1089    way round.
1090    """
1091    weights = {str(k): float(v) for k, v in dict(profile.get("families", {})).items()}
1092    for module_id in profile.get("modules", ()):
1093        spec = MODULE_CATALOGUE.get(str(module_id))
1094        if spec is not None:
1095            family = str(spec["family"])
1096            weights[family] = weights.get(family, 0.0) + 1.0
1097    for weapon_id in profile.get("weapons", ()):
1098        family = _weapon_family(str(weapon_id))
1099        weights[family] = weights.get(family, 0.0) + 1.0
1100        if family == "ballistic":
1101            weights["turret"] = weights.get("turret", 0.0) + 0.5
1102    return weights
1103
1104
1105def _sample(rng: random.Random, pool: list, count: int) -> list:
1106    """*count* distinct picks from *pool*, or the whole pool when it is short."""
1107    if count >= len(pool):
1108        return list(pool)
1109    return rng.sample(pool, count)
1110
1111
1112def _weighted_choice(rng: random.Random, items: list, weights: list[float]):
1113    total = sum(weights)
1114    if total <= 0.0:
1115        return rng.choice(items)
1116    roll = rng.random() * total
1117    for item, weight in zip(items, weights, strict=True):
1118        roll -= weight
1119        if roll <= 0.0:
1120            return item
1121    return items[-1]