shrike/modules.py¶

Part of SHRIKE.

   1"""Hull sockets, the thirty-module catalogue, and the modules that act.
   2
   3Building a ship in SHRIKE is arranging it. Sockets carry positions, so the same
   4auto-turret is a different purchase in the bow and in the stern: a docked
   5turret only covers the arc its socket faces, and every unit of mass bolted on is
   6felt in the handling model. Three things live here:
   7
   8* :class:`Socket` and :class:`SocketRack`, the positional fitting system. One
   9  rack per hull, one layout per hull, and every socket carries a size, so a
  10  reactor cannot be crammed into a bow mount meant for a sensor.
  11* :data:`MODULE_CATALOGUE`, all thirty launch modules with family, tier, price,
  12  socket size, Broker gating and pool sibling, beside :class:`ModulePool`, the
  13  world pool that permanently widens as the player buys rare modules.
  14* The modules with behaviour of their own: :class:`Refinery`, the conversion
  15  bet; :class:`AutoTurret`, the four deployable turret types; and
  16  :class:`Fabricator`, which prints ammunition from scrap. Everything else is a
  17  :class:`PassiveModule` publishing an effect bag the rest of the run reads.
  18  Power modules keep their behaviour in ``power.py`` and are fitted from here
  19  like anything else.
  20* :func:`effect_summary`, which turns any of the above into the one mechanical
  21  line a shop or a fit panel prints. It is derived from the effect bag and the
  22  behaviour tables rather than written out, so the shelf can never quote a
  23  number the run does not apply.
  24
  25The conversion bet
  26==================
  27
  28The Refinery is the only way to bank Cores away from a trading post, and it
  29prices its 30 percent premium in exactly the currencies the game is about: a
  30socket, a bite out of the capacitor, and noise. A batch is a
  31``balance.REFINERY_BATCH_CHANNEL_S`` channel drinking
  32``balance.ENERGY_REFINERY_PER_S`` and paying ``balance.CONVERT_RATE_REFINERY``,
  33started by the third rung of the interact hold. The signature burst is not
  34charged here: the signature meter and the notoriety tally both consume
  35``REFINERY_BATCH_COMPLETED``, so the batch is announced once and priced by the
  36systems that own those numbers.
  37
  38Turrets
  39=======
  40
  41A turret in a socket is a hull mount and covers only the arc its socket faces.
  42:meth:`AutoTurret.deploy` drops it in space instead, where it covers every
  43bearing and keeps firing after the ship has moved on. Both states draw
  44``balance.ENERGY_AUTO_TURRET_PER_S``, and both announce every shot as a visible
  45tracer, because a turret the player cannot read is a turret they cannot plan
  46around. A normal warp retrieves everything deployed; an emergency warp leaves
  47it behind, which is the price of the panic button.
  48"""
  49
  50import math
  51from collections.abc import Callable, Iterable
  52from dataclasses import dataclass
  53
  54from simvx.core import Input, Material, Mesh, MeshInstance3D, Node, Node3D, Quat, Signal, Vec2, Vec3
  55
  56from . import balance
  57from .power import RTG, Generator, PowerSource, SignalWiring, SolarWings
  58from .runtime import PLANE_Y, Groups, Services, SignalNames
  59from .weapons import WEAPON_RACK_GROUP
  60
  61# ============================================================================
  62# Numbers this module owns
  63#
  64# balance.py fixes the prices' tier bands, the energy draws, the batch length
  65# and the conversion rates. What it does not fix, and what therefore lives
  66# here, is the geometry of a hull's sockets, how heavy each module is, how hard
  67# each turret hits, and the strength of the passive effects the design names
  68# without numbering.
  69# ============================================================================
  70
  71#: Socket sizes, smallest first. A socket accepts any module of its own size or
  72#: smaller, so a large mount is never wasted, only expensive.
  73SOCKET_SIZES = ("small", "medium", "large")
  74
  75#: Seconds of held interact that start a Refinery batch; the first rung of the
  76#: same hold patches a breach and belongs to ``ship.py``.
  77REFINERY_INTERACT_HOLD_S = 3.0
  78
  79#: Scrap consumed by one Refinery batch. A batch is deliberately smaller than a
  80#: sector's haul so refining a full hold is several bursts of noise rather than
  81#: one, and the player can stop between them.
  82REFINERY_BATCH_SCRAP = 40.0
  83
  84#: How long a turret's tracer stays on screen. Long enough to read the intent,
  85#: short enough that four turrets do not become a light show.
  86TURRET_TRACER_S = 0.07
  87#: Player fire is warm gold and white, turrets included.
  88TRACER_COLOUR = (1.0, 0.86, 0.55, 1.0)
  89TRACER_RADIUS = 0.06
  90
  91#: Reference mass of a bare starter hull, the denominator of the handling
  92#: penalty, and the floor that penalty is clamped to.
  93HULL_REFERENCE_MASS = 12.0
  94MIN_HANDLING_MULTIPLIER = 0.6
  95
  96
  97def _wrap_angle(radians: float) -> float:
  98    """Fold an angle into ``[-pi, pi)``."""
  99    return (radians + math.pi) % (2.0 * math.pi) - math.pi
 100
 101
 102# ============================================================================
 103# Sockets
 104# ============================================================================
 105
 106
 107@dataclass(frozen=True, eq=False)
 108class Socket:
 109    """One external mounting point on the hull.
 110
 111    ``offset`` is the plane offset from the hull centre in the ship's own
 112    frame, where the nose runs along local -Z and starboard along local +X, the
 113    same convention the weapon hardpoints use. :attr:`bearing` turns that
 114    offset into the direction the socket faces relative to the nose, which is
 115    what makes a bow turret and a stern turret different purchases.
 116    """
 117
 118    index: int
 119    offset: Vec2
 120    size: str
 121    label: str = ""
 122
 123    @property
 124    def bearing(self) -> float:
 125        """Direction the socket faces, radians anticlockwise from the nose."""
 126        return _wrap_angle(math.atan2(-float(self.offset.y), float(self.offset.x)) - math.pi * 0.5)
 127
 128    def accepts(self, size: str) -> bool:
 129        """Whether a module of *size* fits this mount."""
 130        return SOCKET_SIZES.index(size) <= SOCKET_SIZES.index(self.size)
 131
 132    def local_position(self) -> Vec3:
 133        """The socket's position in the hull's local frame, on the flight plane."""
 134        return Vec3(float(self.offset.x), PLANE_Y, float(self.offset.y))
 135
 136
 137def _layout(*entries: tuple[float, float, str, str]) -> tuple[Socket, ...]:
 138    """Build a hull's socket tuple from ``(x, z, size, label)`` rows."""
 139    return tuple(Socket(i, Vec2(x, z), size, label) for i, (x, z, size, label) in enumerate(entries))
 140
 141
 142#: One layout per hull in ``balance.HULL_SOCKETS``. The counts are balance's;
 143#: the positions and sizes are this module's, and they are what gives each hull
 144#: its character: the Vagrant is symmetrical, the Barge carries two extra large
 145#: waist mounts, the Dart is bow-heavy and small, the Hive is built around two
 146#: large bays amidships.
 147HULL_SOCKET_LAYOUTS: dict[str, tuple[Socket, ...]] = {
 148    "vagrant": _layout(
 149        (0.0, -1.7, "large", "bow"),
 150        (-0.9, -0.9, "small", "port bow"),
 151        (0.9, -0.9, "small", "starboard bow"),
 152        (-1.1, 0.7, "medium", "port quarter"),
 153        (1.1, 0.7, "medium", "starboard quarter"),
 154        (0.0, 1.6, "large", "stern"),
 155    ),
 156    "barge": _layout(
 157        (0.0, -1.9, "large", "bow"),
 158        (-1.0, -1.0, "small", "port bow"),
 159        (1.0, -1.0, "small", "starboard bow"),
 160        (-1.5, -0.1, "large", "port waist"),
 161        (1.5, -0.1, "large", "starboard waist"),
 162        (-1.2, 0.9, "medium", "port quarter"),
 163        (1.2, 0.9, "medium", "starboard quarter"),
 164        (0.0, 1.8, "large", "stern"),
 165    ),
 166    "dart": _layout(
 167        (0.0, -1.5, "medium", "bow"),
 168        (-0.7, -0.6, "small", "port bow"),
 169        (0.7, -0.6, "small", "starboard bow"),
 170        (0.0, 1.3, "medium", "stern"),
 171    ),
 172    "hive": _layout(
 173        (0.0, -1.6, "medium", "bow"),
 174        (-1.3, -0.2, "large", "port bay"),
 175        (1.3, -0.2, "large", "starboard bay"),
 176        (-1.0, 0.8, "small", "port quarter"),
 177        (1.0, 0.8, "small", "starboard quarter"),
 178        (0.0, 1.7, "large", "stern"),
 179    ),
 180}
 181
 182
 183def sockets_for_hull(hull_id: str) -> tuple[Socket, ...]:
 184    """The socket layout of a hull, by the ids in ``balance.HULL_SOCKETS``."""
 185    try:
 186        return HULL_SOCKET_LAYOUTS[hull_id]
 187    except KeyError:
 188        raise ValueError(f"unknown hull {hull_id!r}; expected one of {sorted(HULL_SOCKET_LAYOUTS)}") from None
 189
 190
 191# ============================================================================
 192# The catalogue
 193# ============================================================================
 194
 195#: Price band per tier, the design's module price curve.
 196PRICE_BANDS: dict[int, tuple[int, int]] = {
 197    1: (balance.MODULE_PRICE_TIER1_MIN, balance.MODULE_PRICE_TIER1_MAX),
 198    2: (balance.MODULE_PRICE_TIER2_MIN, balance.MODULE_PRICE_TIER2_MAX),
 199    3: (balance.MODULE_PRICE_TIER3_MIN, balance.MODULE_PRICE_TIER3_MAX),
 200}
 201
 202
 203def price_band(tier: int) -> tuple[int, int]:
 204    """The scrap price band a tier's modules are priced inside."""
 205    return PRICE_BANDS[tier]
 206
 207
 208def _module(
 209    family: str,
 210    tier: int,
 211    price: int,
 212    socket_size: str,
 213    mass: float,
 214    *,
 215    broker: bool = False,
 216    pool_sibling: str = "",
 217    locked: bool = False,
 218    effects: dict[str, float] | None = None,
 219    note: str = "",
 220) -> dict:
 221    """One catalogue row.
 222
 223    ``pool_sibling`` names the module this one's first purchase adds to the
 224    world pool, empty when it adds nothing; ``locked`` marks a module that
 225    starts outside that pool and can only arrive through its sibling.
 226    ``effects`` is the bag passive modules publish to the rest of the run.
 227    """
 228    return {
 229        "family": family,
 230        "tier": tier,
 231        "price": price,
 232        "price_band": PRICE_BANDS[tier],
 233        "socket_size": socket_size,
 234        "mass": mass,
 235        "broker_exclusive": broker,
 236        "pool_sibling": pool_sibling,
 237        "locked": locked,
 238        "effects": dict(effects or {}),
 239        "note": note,
 240    }
 241
 242
 243#: All thirty launch modules. Eight are Broker exclusives, gated on notoriety
 244#: rather than on the pool; eight more start outside the pool entirely and are
 245#: added to it for good the first time their sibling is bought.
 246MODULE_CATALOGUE: dict[str, dict] = {
 247    # -- auto-turrets (4) ---------------------------------------------------
 248    "sentry_turret": _module(
 249        "turret", 1, 45, "small", 0.6, pool_sibling="flak_turret", note="steady kinetic tracer, the first turret"
 250    ),
 251    "flak_turret": _module("turret", 2, 95, "small", 0.8, locked=True, note="bursts, eats shoals"),
 252    "lance_turret": _module(
 253        "turret", 2, 130, "medium", 1.1, pool_sibling="arc_turret", note="long reach, narrow arc, one heavy shot"
 254    ),
 255    "arc_turret": _module("turret", 3, 190, "medium", 1.0, locked=True, note="close-range energy lash, widest arc"),
 256    # -- shield emitters (3) ------------------------------------------------
 257    "emitter_vane": _module(
 258        "shield", 1, 50, "small", 0.5, pool_sibling="emitter_bank", effects={"shield_arc_bonus_degrees": 30.0}
 259    ),
 260    "emitter_bank": _module(
 261        "shield",
 262        2,
 263        110,
 264        "medium",
 265        0.9,
 266        pool_sibling="bubble_emitter",
 267        locked=True,
 268        effects={"shield_absorb_bonus": 30.0},
 269    ),
 270    "bubble_emitter": _module(
 271        "shield",
 272        3,
 273        230,
 274        "large",
 275        1.6,
 276        locked=True,
 277        effects={"shield_arc_bonus_degrees": 240.0, "shield_energy_mult": balance.SHIELD_BUBBLE_ENERGY_MULT},
 278        note="the arc closes toward a bubble at triple energy cost",
 279    ),
 280    # -- power (3); behaviour lives in power.py -----------------------------
 281    "solar_wings": _module("power", 1, 55, "large", 1.2, note="silent, fragile, geography-dependent"),
 282    "generator": _module("power", 1, 60, "large", 1.8, note="strong anywhere, burns fuel, hums"),
 283    "rtg": _module("power", 1, balance.RTG_PRICE_SCRAP, "medium", 1.0, note="a trickle that survives silent running"),
 284    # -- manufacturing ------------------------------------------------------
 285    "refinery": _module(
 286        "refinery", 2, 120, "large", 2.0, pool_sibling="fabricator", note="the conversion bet, 1.3 Cores per 10 scrap"
 287    ),
 288    "fabricator": _module(
 289        "fabricator",
 290        2,
 291        100,
 292        "medium",
 293        1.2,
 294        pool_sibling="drone_bay",
 295        locked=True,
 296        note="scrap to ammunition",
 297    ),
 298    "drone_bay": _module("drone", 3, 210, "large", 1.7, locked=True, effects={"drones": 2.0}, note="two escorts"),
 299    # -- salvage and handling ----------------------------------------------
 300    "cargo_scoop": _module("scoop", 1, 40, "small", 0.4, effects={"scoop_range_mult": 1.5}),
 301    "ram_scoop": _module(
 302        "scoop",
 303        2,
 304        115,
 305        "medium",
 306        1.0,
 307        locked=True,
 308        effects={"scoop_range_mult": 1.5, "ram_collect": 1.0},
 309        note="afterburning through wreckage collects it",
 310    ),
 311    "grav_anchor": _module("handling", 2, 90, "medium", 1.4, effects={"knockback_taken_mult": 0.5}),
 312    "afterburner_injectors": _module(
 313        "handling",
 314        1,
 315        50,
 316        "medium",
 317        0.7,
 318        pool_sibling="ram_scoop",
 319        effects={"afterburner_speed_mult": 1.15, "afterburner_energy_mult": 0.85},
 320    ),
 321    # -- damage control -----------------------------------------------------
 322    "breach_foam": _module(
 323        "repair", 1, 35, "small", 0.3, effects={"patch_channel_mult": 0.5}, note="halves the rooted patch"
 324    ),
 325    # -- signature and sensors ----------------------------------------------
 326    "signature_dampener": _module(
 327        "signature", 1, 55, "small", 0.5, pool_sibling="wake_damper", effects={"signature_mult": 0.92}
 328    ),
 329    "wake_damper": _module(
 330        "signature",
 331        2,
 332        105,
 333        "medium",
 334        0.9,
 335        locked=True,
 336        effects={
 337            "signature_mult": balance.WAKE_DAMPER_SIGNATURE_MULT,
 338            "mining_speed_mult": balance.WAKE_DAMPER_MINING_SPEED_MULT,
 339        },
 340    ),
 341    "sensor_mast": _module(
 342        "sensor", 1, 45, "medium", 0.4, effects={"chart_tag_jumps": float(balance.SENSOR_MAST_CHART_TAG_JUMPS)}
 343    ),
 344    # -- quill-tech ---------------------------------------------------------
 345    "mirror_lantern": _module(
 346        "quill_tech",
 347        3,
 348        260,
 349        "large",
 350        1.5,
 351        effects={"lantern_blind_s": balance.MIRROR_LANTERN_BLIND_S},
 352        note="built from spare quills; blinds the Shrike for a punish window",
 353    ),
 354    # -- Broker exclusives (8, all tier 3) ----------------------------------
 355    "null_shroud": _module("signature", 3, 245, "medium", 1.1, broker=True, effects={"signature_mult": 0.70}),
 356    "singularity_hook": _module("scoop", 3, 215, "medium", 1.3, broker=True, effects={"scoop_range_mult": 3.0}),
 357    "overcharge_cell": _module(
 358        "power", 3, 235, "large", 1.9, broker=True, effects={"capacitor_max": balance.CAPACITOR_MAX_UPGRADED}
 359    ),
 360    "quill_rail_feed": _module(
 361        "quill_tech", 3, 250, "medium", 1.2, broker=True, effects={"ballistic_damage_mult": 1.25}
 362    ),
 363    "phase_plating": _module("armour", 3, 230, "large", 2.4, broker=True, effects={"damage_taken_mult": 0.85}),
 364    "swarm_lattice": _module("drone", 3, 260, "large", 2.0, broker=True, effects={"drones": 4.0}),
 365    "deep_lens": _module("sensor", 3, 185, "small", 0.5, broker=True, effects={"chart_tag_jumps": 4.0}),
 366    "lantern_baffle": _module("armour", 3, 205, "medium", 1.4, broker=True, effects={"lantern_damage_mult": 0.5}),
 367}
 368
 369
 370def module_spec(module_id: str) -> dict:
 371    """One catalogue row, or a ``ValueError`` naming the unknown module."""
 372    try:
 373        return MODULE_CATALOGUE[module_id]
 374    except KeyError:
 375        raise ValueError(f"unknown module id {module_id!r}") from None
 376
 377
 378def pool_sibling_pairs() -> list[tuple[str, str]]:
 379    """Every ``(purchase, unlocked sibling)`` pair, in catalogue order."""
 380    return [(mid, spec["pool_sibling"]) for mid, spec in MODULE_CATALOGUE.items() if spec["pool_sibling"]]
 381
 382
 383# ============================================================================
 384# Saying what a module does
 385#
 386# A shelf that prices a module owes the pilot the number it is charging for.
 387# "keeps the meter quieter for longer" is a mood; "signature fill -8 percent"
 388# is a purchase decision, and it is the same number the meter actually applies.
 389# Every phrase below is derived from the effect bag or from this module's own
 390# behaviour tables, so a rebalance moves the shelf text with it.
 391# ============================================================================
 392
 393
 394def _percent_less(value: float) -> str:
 395    """A multiplier below 1 as the reduction it is: 0.92 to "-8 percent"."""
 396    return f"-{(1.0 - float(value)) * 100.0:.0f} percent"
 397
 398
 399def _percent_more(value: float) -> str:
 400    """A multiplier above 1 as the gain it is: 1.15 to "+15 percent"."""
 401    return f"+{(float(value) - 1.0) * 100.0:.0f} percent"
 402
 403
 404def _times(value: float) -> str:
 405    """A multiplier written as a multiplier, for the ones that are not deltas."""
 406    text = f"{float(value):.2f}".rstrip("0").rstrip(".")
 407    return f"x{text}"
 408
 409
 410#: One phrase per effect key. The key is the same string the systems query off
 411#: :meth:`SocketRack.effect_multiplier`, so an effect that gains a phrase here
 412#: and no consumer, or a consumer and no phrase, is visible as a gap.
 413EFFECT_PHRASINGS: dict[str, Callable[[float], str]] = {
 414    "signature_mult": lambda v: f"signature fill {_percent_less(v)}",
 415    "shield_arc_bonus_degrees": lambda v: f"shield arc +{v:.0f} degrees",
 416    "shield_absorb_bonus": lambda v: f"shield soaks +{v:.0f} damage",
 417    "shield_energy_mult": lambda v: f"shield energy {_times(v)}",
 418    "scoop_range_mult": lambda v: f"scoop reach {_percent_more(v)}",
 419    "ram_collect": lambda v: "afterburning through wreckage collects it",
 420    "knockback_taken_mult": lambda v: f"knockback taken {_percent_less(v)}",
 421    "afterburner_speed_mult": lambda v: f"afterburner speed {_percent_more(v)}",
 422    "afterburner_energy_mult": lambda v: f"afterburner energy {_percent_less(v)}",
 423    "patch_channel_mult": lambda v: f"breach patch {_percent_less(v)} time",
 424    "mining_speed_mult": lambda v: f"mining speed {_percent_less(v)}",
 425    "chart_tag_jumps": lambda v: f"chart tags {v:.0f} jumps ahead",
 426    "lantern_blind_s": lambda v: f"blinds the lantern for {v:.0f} s",
 427    "capacitor_max": lambda v: f"capacitor max {v:.0f}",
 428    "ballistic_damage_mult": lambda v: f"ballistic damage {_percent_more(v)}",
 429    "damage_taken_mult": lambda v: f"damage taken {_percent_less(v)}",
 430    "lantern_damage_mult": lambda v: f"lantern damage {_percent_less(v)}",
 431    "drones": lambda v: f"{v:.0f} escort drones",
 432}
 433
 434
 435def _turret_effect(module_id: str) -> str:
 436    """A turret's numbers: what it hits for, how far, and how wide it covers."""
 437    profile = TURRET_PROFILES[module_id]
 438    return (
 439        f"{profile.dps:.0f} DPS auto, {profile.reach:.0f} reach, "
 440        f"{profile.arc_degrees:.0f} deg arc, {balance.ENERGY_AUTO_TURRET_PER_S:.1f} energy/s"
 441    )
 442
 443
 444#: Modules whose behaviour is code rather than an effect bag. Each says what it
 445#: pays out and what it costs, in the units the run's own meters use.
 446BEHAVIOUR_EFFECTS: dict[str, Callable[[], str]] = {
 447    "solar_wings": lambda: f"+{balance.SOLAR_OUTPUT_PER_S:.0f} energy/s in the open, none in nebula, silent",
 448    "generator": lambda: (
 449        f"+{balance.GENERATOR_OUTPUT_PER_S:.0f} energy/s, burns fuel, "
 450        f"+{balance.GENERATOR_SIGNATURE_PER_S:.1f} signature/s"
 451    ),
 452    "rtg": lambda: f"+{balance.RTG_OUTPUT_PER_S:.0f} energy/s, survives silent running",
 453    "refinery": lambda: (
 454        f"{REFINERY_BATCH_SCRAP:.0f} scrap to Cores at {balance.CONVERT_RATE_REFINERY:.1f} per 10, "
 455        f"{balance.REFINERY_BATCH_CHANNEL_S:.0f} s, +{balance.SIGNATURE_REFINERY_BATCH:.0f} signature"
 456    ),
 457    "fabricator": lambda: f"{balance.FABRICATOR_SCRAP_PER_BOX:.0f} scrap prints one ammo box",
 458}
 459
 460
 461def effect_summary(module_id: str) -> str:
 462    """What *module_id* does, in numbers, on one line.
 463
 464    The effect bag is read first because it is what the run actually applies;
 465    a module whose behaviour is code answers from :data:`BEHAVIOUR_EFFECTS`.
 466    The catalogue note is a fallback and nothing more: where numbers exist they
 467    say the same thing in fewer words, and a row that printed both would be
 468    half prose on a screen with a fixed width.
 469    """
 470    spec = module_spec(module_id)
 471    if module_id in TURRET_PROFILES:
 472        parts = [_turret_effect(module_id)]
 473    elif module_id in BEHAVIOUR_EFFECTS:
 474        parts = [BEHAVIOUR_EFFECTS[module_id]()]
 475    else:
 476        parts = [EFFECT_PHRASINGS[key](value) for key, value in spec["effects"].items() if key in EFFECT_PHRASINGS]
 477    parts = [part for part in parts if part]
 478    return ", ".join(parts) if parts else str(spec["note"])
 479
 480
 481def socket_label(module_id: str) -> str:
 482    """The mount a module needs, for a shelf row that has no live hull to ask."""
 483    return f"{module_spec(module_id)['socket_size']} socket"
 484
 485
 486class ModulePool:
 487    """The set of modules the world may offer, and how it permanently widens.
 488
 489    Buying a module that names a ``pool_sibling`` adds that sibling to the pool
 490    for good, so shops get more interesting exactly as the player gets better.
 491    Expansions persist in the meta profile; Broker exclusives are not part of
 492    this system, being gated on notoriety instead.
 493    """
 494
 495    #: Profile key the expansions are stored under.
 496    PROFILE_KEY = "module_pool_expansions"
 497
 498    def __init__(self, expansions: Iterable[str] = ()):
 499        self.expansions: set[str] = {mid for mid in expansions if mid in MODULE_CATALOGUE}
 500
 501    def unlocked(self, module_id: str) -> bool:
 502        """Whether *module_id* may appear in stock at all."""
 503        spec = module_spec(module_id)
 504        return not spec["locked"] or module_id in self.expansions
 505
 506    def available(self, *, notoriety: int = 0) -> list[str]:
 507        """Every module a depot may stock right now, in catalogue order.
 508
 509        Broker exclusives join the list once the ship is notorious enough for
 510        the barge to show itself; the depots themselves filter further.
 511        """
 512        broker_open = notoriety >= balance.BROKER_NOTORIETY_THRESHOLD
 513        return [
 514            mid
 515            for mid, spec in MODULE_CATALOGUE.items()
 516            if self.unlocked(mid) and (broker_open or not spec["broker_exclusive"])
 517        ]
 518
 519    def record_purchase(self, module_id: str) -> str | None:
 520        """Bank a purchase; returns the sibling it just added to the pool, if any."""
 521        sibling = module_spec(module_id)["pool_sibling"]
 522        if not sibling or sibling in self.expansions:
 523            return None
 524        self.expansions.add(sibling)
 525        return sibling
 526
 527    def to_profile(self) -> list[str]:
 528        """The expansions, sorted, ready to be written into the profile."""
 529        return sorted(self.expansions)
 530
 531    @classmethod
 532    def from_profile(cls, profile: dict) -> ModulePool:
 533        """Rebuild the pool from a saved profile, tolerating an absent key."""
 534        return cls(profile.get(cls.PROFILE_KEY, ()))
 535
 536
 537# ============================================================================
 538# Module nodes
 539# ============================================================================
 540
 541#: Family colours, so a glance at the hull says what is bolted to it.
 542FAMILY_COLOURS: dict[str, tuple[float, float, float, float]] = {
 543    "turret": (0.82, 0.74, 0.55, 1.0),
 544    "shield": (0.45, 0.70, 0.95, 1.0),
 545    "power": (0.95, 0.80, 0.35, 1.0),
 546    "refinery": (0.95, 0.55, 0.30, 1.0),
 547    "fabricator": (0.70, 0.72, 0.78, 1.0),
 548    "drone": (0.60, 0.85, 0.70, 1.0),
 549    "scoop": (0.65, 0.70, 0.75, 1.0),
 550    "handling": (0.72, 0.66, 0.60, 1.0),
 551    "repair": (0.85, 0.85, 0.80, 1.0),
 552    "signature": (0.35, 0.40, 0.50, 1.0),
 553    "sensor": (0.55, 0.80, 0.85, 1.0),
 554    "quill_tech": (0.85, 0.60, 0.90, 1.0),
 555    "armour": (0.60, 0.62, 0.66, 1.0),
 556}
 557
 558#: Mesh size a module of each socket size is drawn at.
 559MODULE_VISUAL_SIZE = {"small": 0.32, "medium": 0.45, "large": 0.62}
 560
 561#: Group the socket rack joins, so a depot can ask a live hull what it is
 562#: carrying without walking the tree by node name. The weapon rack publishes
 563#: itself the same way through ``weapons.WEAPON_RACK_GROUP``.
 564SOCKET_RACK_GROUP = "socket_rack"
 565
 566
 567class ShipModule(Node3D):
 568    """Base for anything bolted into a socket.
 569
 570    A module knows its catalogue row and the socket it sits in, and reaches the
 571    run's systems through the service singletons rather than through its
 572    parents, so it behaves the same on a hull, on a test harness, or dropped in
 573    space.
 574    """
 575
 576    def __init__(self, module_id: str, **kwargs):
 577        spec = module_spec(module_id)
 578        kwargs.setdefault("name", module_id)
 579        super().__init__(**kwargs)
 580        self.module_id = module_id
 581        self.spec = spec
 582        self.socket: Socket | None = None
 583        self.rack: SocketRack | None = None
 584
 585    # -- description ------------------------------------------------------
 586
 587    @property
 588    def family(self) -> str:
 589        return self.spec["family"]
 590
 591    @property
 592    def mass(self) -> float:
 593        return float(self.spec["mass"])
 594
 595    @property
 596    def effects(self) -> dict[str, float]:
 597        """The passive effect bag other systems read off the rack."""
 598        return self.spec["effects"]
 599
 600    # -- lifecycle --------------------------------------------------------
 601
 602    def on_ready(self):
 603        self.add_child(self._build_visual())
 604
 605    def _build_visual(self) -> Node3D:
 606        colour = FAMILY_COLOURS.get(self.family, (0.7, 0.7, 0.7, 1.0))
 607        size = MODULE_VISUAL_SIZE[self.spec["socket_size"]]
 608        return MeshInstance3D(
 609            name="Casing",
 610            mesh=Mesh.cube(size=size),
 611            material=Material(colour=colour, metallic=0.7, roughness=0.4),
 612        )
 613
 614    def on_removed(self) -> None:
 615        """Undo anything the module did to the run. Called before it is freed."""
 616
 617    # -- services ---------------------------------------------------------
 618
 619    def power(self):
 620        """The run's power system, or None before one is registered."""
 621        tree = self.tree
 622        return None if tree is None else tree.singletons.get(Services.POWER)
 623
 624    def economy(self):
 625        """The run's scrap and Cores ledger, or None outside a run."""
 626        tree = self.tree
 627        return None if tree is None else tree.singletons.get(Services.ECONOMY)
 628
 629    def ship(self) -> Node3D | None:
 630        """The hull this module belongs to, or None while it is not fitted."""
 631        return None if self.rack is None else self.rack.ship()
 632
 633
 634class PassiveModule(ShipModule):
 635    """A module whose whole behaviour is the effect bag it publishes.
 636
 637    Most of the catalogue is passive: plating, scoops, injectors, sensors. The
 638    rack aggregates their effects and the systems that care read them from
 639    there. The one effect a passive module applies itself is the signature
 640    multiplier, because the meter owns a register for exactly this and applying
 641    it anywhere else would double-count it.
 642    """
 643
 644    def __init__(self, module_id: str, **kwargs):
 645        super().__init__(module_id, **kwargs)
 646        self._signature_applied = False
 647
 648    @property
 649    def signature_source(self) -> str:
 650        """The meter register this module writes to.
 651
 652        Keyed by socket as well as by module, so a second dampener in a second
 653        socket stacks with the first instead of overwriting it.
 654        """
 655        socket = 0 if self.socket is None else self.socket.index
 656        return f"module:{self.module_id}:{socket}"
 657
 658    def on_update(self, dt: float):
 659        if not self._signature_applied and "signature_mult" in self.effects:
 660            meter = self._meter()
 661            if meter is not None:
 662                meter.set_gain_multiplier(self.signature_source, float(self.effects["signature_mult"]))
 663                self._signature_applied = True
 664
 665    def on_removed(self) -> None:
 666        if not self._signature_applied:
 667            return
 668        meter = self._meter()
 669        if meter is not None:
 670            meter.set_gain_multiplier(self.signature_source, 1.0)
 671        self._signature_applied = False
 672
 673    def _meter(self):
 674        tree = self.tree
 675        return None if tree is None else tree.singletons.get(Services.SIGNATURE)
 676
 677
 678# ============================================================================
 679# The Refinery
 680# ============================================================================
 681
 682
 683class Refinery(ShipModule):
 684    """The conversion bet, socketed: scrap into Cores anywhere, loudly.
 685
 686    A batch is a channel, not an instant: :meth:`begin_batch` reserves up to
 687    :data:`REFINERY_BATCH_SCRAP` and runs for ``balance.REFINERY_BATCH_CHANNEL_S``
 688    while paying ``balance.ENERGY_REFINERY_PER_S``. A capacitor that cannot keep
 689    up aborts the batch with the scrap untouched, which is the whole point: the
 690    flak cannon and the smelter draw from the same hundred points.
 691
 692    The player starts a batch by holding the interact action for
 693    :data:`REFINERY_INTERACT_HOLD_S`, the last rung of the interact ladder;
 694    ``ship.py`` owns the patch rung earlier in the same hold and neither blocks
 695    the other.
 696    """
 697
 698    #: Registered as ``SignalNames.REFINERY_BATCH_STARTED`` / ``_COMPLETED``.
 699    refinery_batch_started = Signal()
 700    refinery_batch_completed = Signal(float)
 701
 702    def __init__(self, module_id: str = "refinery", **kwargs):
 703        super().__init__(module_id, **kwargs)
 704        self._remaining = 0.0
 705        self._batch_scrap = 0.0
 706        self._hold = 0.0
 707        self._hold_consumed = False
 708
 709    # -- reading ----------------------------------------------------------
 710
 711    @property
 712    def running(self) -> bool:
 713        """Whether a batch is in the smelter right now."""
 714        return self._remaining > 0.0
 715
 716    @property
 717    def batch_scrap(self) -> float:
 718        """Scrap the running batch will convert, zero when idle."""
 719        return self._batch_scrap
 720
 721    def batch_fraction(self) -> float:
 722        """Progress of the running batch, 0 to 1, for the HUD's radial fill."""
 723        if not self.running:
 724            return 0.0
 725        return 1.0 - self._remaining / balance.REFINERY_BATCH_CHANNEL_S
 726
 727    def hold_fraction(self) -> float:
 728        """Progress of the interact hold toward starting a batch, 0 to 1."""
 729        return min(1.0, self._hold / REFINERY_INTERACT_HOLD_S)
 730
 731    # -- driving ----------------------------------------------------------
 732
 733    def begin_batch(self) -> bool:
 734        """Load the smelter and start the channel. False if it cannot start."""
 735        if self.running:
 736            return False
 737        economy = self.economy()
 738        if economy is None or self.power() is None:
 739            return False
 740        loaded = min(REFINERY_BATCH_SCRAP, float(getattr(economy, "scrap", 0.0)))
 741        if loaded <= 0.0:
 742            return False
 743        self._batch_scrap = loaded
 744        self._remaining = balance.REFINERY_BATCH_CHANNEL_S
 745        self.refinery_batch_started()
 746        return True
 747
 748    def cancel_batch(self) -> None:
 749        """Abandon the running batch. The scrap was never spent, so it stays."""
 750        self._remaining = 0.0
 751        self._batch_scrap = 0.0
 752
 753    def on_update(self, dt: float):
 754        self._tick_hold(dt)
 755        if self.running:
 756            self._tick_batch(dt)
 757
 758    # -- internals --------------------------------------------------------
 759
 760    def _tick_hold(self, dt: float) -> None:
 761        if not Input.is_action_pressed("interact"):
 762            self._hold = 0.0
 763            self._hold_consumed = False
 764            return
 765        self._hold += dt
 766        if self._hold_consumed or self._hold < REFINERY_INTERACT_HOLD_S:
 767            return
 768        if self.begin_batch():
 769            self._hold_consumed = True
 770
 771    def _tick_batch(self, dt: float) -> None:
 772        power = self.power()
 773        if power is None or not power.drain(balance.ENERGY_REFINERY_PER_S, dt, "refinery"):
 774            self.cancel_batch()
 775            return
 776        self._remaining -= dt
 777        if self._remaining > 0.0:
 778            return
 779        self._complete()
 780
 781    def _complete(self) -> None:
 782        economy = self.economy()
 783        converted = self._batch_scrap
 784        self._remaining = 0.0
 785        self._batch_scrap = 0.0
 786        if economy is None:
 787            return
 788        converted = min(converted, float(getattr(economy, "scrap", 0.0)))
 789        if converted <= 0.0:
 790            return
 791        cores = economy.convert(converted, balance.CONVERT_RATE_REFINERY)
 792        if cores is None:
 793            cores = balance.cores_from_scrap(converted, balance.CONVERT_RATE_REFINERY)
 794        # The signature burst and the notoriety are charged by the meter and the
 795        # bounty tally off this signal; announcing it twice would price it twice.
 796        self.refinery_batch_completed(float(cores))
 797
 798
 799# ============================================================================
 800# Auto-turrets
 801# ============================================================================
 802
 803
 804@dataclass(frozen=True)
 805class TurretProfile:
 806    """How one turret type shoots. Cadence, reach and arc are feel numbers."""
 807
 808    dps: float
 809    reach: float
 810    rate: float
 811    arc_degrees: float
 812    kind: str = "ballistic"
 813    note: str = ""
 814
 815
 816#: The four turret types. Every one draws ``balance.ENERGY_AUTO_TURRET_PER_S``;
 817#: what they buy with it is reach, cadence and coverage.
 818TURRET_PROFILES: dict[str, TurretProfile] = {
 819    "sentry_turret": TurretProfile(14.0, 22.0, 3.0, 180.0, note="the dependable one"),
 820    "flak_turret": TurretProfile(26.0, 16.0, 5.0, 200.0, note="short bursts into shoals"),
 821    "lance_turret": TurretProfile(22.0, 34.0, 1.0, 140.0, note="reaches across the arena, hates being flanked"),
 822    "arc_turret": TurretProfile(30.0, 14.0, 6.0, 260.0, kind="energy", note="close lash, nearly all-round"),
 823}
 824
 825
 826class AutoTurret(ShipModule):
 827    """A socketed turret that finds its own targets, docked or deployed.
 828
 829    Docked, it is a hull mount: it covers only the arc its socket faces, so
 830    where it is bought matters as much as which one. :meth:`deploy` drops it in
 831    space, where it covers every bearing and holds the ground the ship has left.
 832    Either way it pays ``balance.ENERGY_AUTO_TURRET_PER_S`` and draws a tracer
 833    for every shot, so the player can read what it is doing without watching it.
 834    """
 835
 836    #: ``(module_id)`` on every shot. Local to this module; the HUD and the
 837    #: audio director read it off the turret they are watching.
 838    turret_fired = Signal(str)
 839
 840    def __init__(self, module_id: str, **kwargs):
 841        super().__init__(module_id, **kwargs)
 842        self.profile = TURRET_PROFILES[module_id]
 843        self.deployed = False
 844        self._cooldown = 0.0
 845        self._tracer: Node3D | None = None
 846        self._tracer_mesh: MeshInstance3D | None = None
 847        self._tracer_left = 0.0
 848
 849    def on_enter_tree(self):
 850        super().on_enter_tree()
 851        self.add_to_group(Groups.TURRETS)
 852
 853    # -- deployment -------------------------------------------------------
 854
 855    def deploy(self) -> bool:
 856        """Drop the turret where the ship is standing. False if already out."""
 857        if self.deployed or self.rack is None:
 858            return False
 859        anchor = self.world_position
 860        host = self.rack.deployment_host()
 861        if host is None:
 862            return False
 863        self.reparent(host)
 864        self.world_position = Vec3(float(anchor[0]), PLANE_Y, float(anchor[2]))
 865        self.deployed = True
 866        return True
 867
 868    def retrieve(self) -> bool:
 869        """Take the turret back into its socket. False if it was never out."""
 870        if not self.deployed or self.rack is None or self.socket is None:
 871            return False
 872        self.reparent(self.rack)
 873        self.position = self.socket.local_position()
 874        self.deployed = False
 875        return True
 876
 877    def on_removed(self) -> None:
 878        self.deployed = False
 879
 880    # -- coverage ---------------------------------------------------------
 881
 882    @property
 883    def coverage_degrees(self) -> float:
 884        """Width of the arc the turret can shoot into."""
 885        return 360.0 if self.deployed else self.profile.arc_degrees
 886
 887    def coverage_centre(self) -> float:
 888        """World heading the arc is centred on, radians anticlockwise from +X."""
 889        if self.deployed or self.socket is None:
 890            return 0.0
 891        ship = self.ship()
 892        heading = float(getattr(ship, "heading", 0.0)) if ship is not None else 0.0
 893        return _wrap_angle(heading + self.socket.bearing)
 894
 895    def covers(self, world_point) -> bool:
 896        """Whether the turret can bring its guns to bear on a plane point."""
 897        if self.deployed:
 898            return True
 899        origin = self.world_position
 900        dx = float(world_point[0]) - float(origin[0])
 901        dz = float(world_point[2]) - float(origin[2])
 902        if math.hypot(dx, dz) < 1e-6:
 903            return True
 904        offset = abs(_wrap_angle(math.atan2(-dz, dx) - self.coverage_centre()))
 905        return offset <= math.radians(self.coverage_degrees) * 0.5
 906
 907    # -- firing -----------------------------------------------------------
 908
 909    @property
 910    def damage_per_shot(self) -> float:
 911        """DPS spread evenly across a second of cadence."""
 912        return self.profile.dps / self.profile.rate
 913
 914    def on_update(self, dt: float):
 915        self._tick_tracer(dt)
 916        self._cooldown = max(0.0, self._cooldown - dt)
 917        power = self.power()
 918        if power is None or not power.drain(balance.ENERGY_AUTO_TURRET_PER_S, dt, "auto_turret"):
 919            return
 920        if self._cooldown > 0.0:
 921            return
 922        target = self.acquire()
 923        if target is None:
 924            return
 925        self._cooldown = 1.0 / self.profile.rate
 926        self._fire(target)
 927
 928    def acquire(self) -> Node3D | None:
 929        """The nearest hostile in reach and inside the arc, or None."""
 930        tree = self.tree
 931        if tree is None:
 932            return None
 933        origin = self.world_position
 934        best: Node3D | None = None
 935        best_distance = self.profile.reach
 936        for group in (Groups.ENEMIES, Groups.HUNTER):
 937            for node in tree.group(group):
 938                if not isinstance(node, Node3D) or node.destroying:
 939                    continue
 940                position = node.world_position
 941                distance = math.hypot(float(position[0]) - float(origin[0]), float(position[2]) - float(origin[2]))
 942                if distance > best_distance or not self.covers(position):
 943                    continue
 944                best, best_distance = node, distance
 945        return best
 946
 947    def _fire(self, target: Node3D) -> None:
 948        origin, position = self.world_position, target.world_position
 949        dx = float(position[0]) - float(origin[0])
 950        dz = float(position[2]) - float(origin[2])
 951        length = math.hypot(dx, dz)
 952        direction = Vec3(dx / length, 0.0, dz / length) if length > 1e-6 else Vec3(1.0, 0.0, 0.0)
 953        self._show_tracer(direction, length)
 954        self.turret_fired(self.module_id)
 955        tree = self.tree
 956        router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
 957        if router is not None:
 958            router.deal(target, self.damage_per_shot, kind=self.profile.kind, direction=direction)
 959
 960    def _show_tracer(self, direction: Vec3, length: float) -> None:
 961        if self._tracer is None:
 962            self._tracer = self.add_child(Node3D(name="Tracer"))
 963            self._tracer_mesh = self._tracer.add_child(
 964                MeshInstance3D(
 965                    name="Line",
 966                    mesh=Mesh.cylinder(radius=TRACER_RADIUS, height=1.0, segments=6),
 967                    material=Material(colour=TRACER_COLOUR, emissive_colour=TRACER_COLOUR[:3], emissive_strength=4.0),
 968                    rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 969                )
 970            )
 971        self._tracer.visible = True
 972        self._tracer.face_along(direction)
 973        self._tracer_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
 974        self._tracer_mesh.scale = Vec3(1.0, max(length, 1e-3), 1.0)
 975        self._tracer_left = TURRET_TRACER_S
 976
 977    def _tick_tracer(self, dt: float) -> None:
 978        if self._tracer_left <= 0.0:
 979            return
 980        self._tracer_left -= dt
 981        if self._tracer_left <= 0.0 and self._tracer is not None:
 982            self._tracer.visible = False
 983
 984
 985# ============================================================================
 986# The Fabricator
 987# ============================================================================
 988
 989
 990class Fabricator(ShipModule):
 991    """Prints ammunition from scrap, joining the two economies at a price.
 992
 993    ``balance.FABRICATOR_SCRAP_PER_BOX`` of scrap becomes one box on the weapon
 994    rack, half what a depot charges, which is what lets a ballistic build stay
 995    fed between posts without ever docking.
 996    """
 997
 998    def weapon_rack(self):
 999        """The ship's weapon rack, or None if nothing is fitted yet."""
1000        tree = self.tree
1001        if tree is None:
1002            return None
1003        racks = tree.group(WEAPON_RACK_GROUP)
1004        return racks[0] if racks else None
1005
1006    def craftable(self) -> list[str]:
1007        """Weapon ids this module can print boxes for."""
1008        return [wid for wid, spec in balance.WEAPONS.items() if spec.family == "ballistic"]
1009
1010    def craft(self, weapon_id: str) -> bool:
1011        """Buy one ammo box for *weapon_id*. False if the scrap or rack is missing."""
1012        if weapon_id not in self.craftable():
1013            raise ValueError(f"{weapon_id!r} is not a ballistic weapon; nothing to print")
1014        rack = self.weapon_rack()
1015        economy = self.economy()
1016        if rack is None or economy is None:
1017            return False
1018        if not economy.spend_scrap(balance.FABRICATOR_SCRAP_PER_BOX):
1019            return False
1020        rack.add_ammo_box(weapon_id)
1021        return True
1022
1023
1024# ============================================================================
1025# Building modules
1026# ============================================================================
1027
1028#: Power modules keep their behaviour in ``power.py``; the catalogue fits them.
1029POWER_CLASSES: dict[str, type[PowerSource]] = {
1030    "solar_wings": SolarWings,
1031    "generator": Generator,
1032    "rtg": RTG,
1033}
1034
1035#: Modules with behaviour of their own, by id. Everything else is passive.
1036SPECIAL_CLASSES: dict[str, type[ShipModule]] = {
1037    "refinery": Refinery,
1038    "fabricator": Fabricator,
1039}
1040
1041
1042def make_module(module_id: str, **kwargs) -> Node3D:
1043    """Build the node for a catalogue id, picking the class by family."""
1044    spec = module_spec(module_id)
1045    power_class = POWER_CLASSES.get(module_id)
1046    if power_class is not None:
1047        kwargs.setdefault("name", module_id)
1048        node = power_class(**kwargs)
1049        node.module_id = module_id
1050        return node
1051    if spec["family"] == "turret":
1052        return AutoTurret(module_id, **kwargs)
1053    special = SPECIAL_CLASSES.get(module_id)
1054    if special is not None:
1055        return special(module_id, **kwargs)
1056    return PassiveModule(module_id, **kwargs)
1057
1058
1059# ============================================================================
1060# The rack
1061# ============================================================================
1062
1063
1064class SocketRack(Node3D):
1065    """The hull's external sockets and everything bolted into them.
1066
1067    The rack is the one place a module is fitted or stripped, so it is also the
1068    one place the consequences are applied: the power system rediscovers its
1069    sources, the handling model learns the new mass, and the aggregate effect
1070    bag other systems read is recomputed from what is actually installed.
1071
1072    It also owns the turret contract across a warp. A normal warp retrieves
1073    every deployed turret; an emergency warp abandons them, which is what the
1074    1.5x fuel price on the panic button is buying you out of.
1075    """
1076
1077    #: ``(module_id, socket_index)`` on every fitting change.
1078    module_installed = Signal(str, int)
1079    module_removed = Signal(str, int)
1080
1081    def __init__(self, hull_id: str = "vagrant", **kwargs):
1082        kwargs.setdefault("name", "Sockets")
1083        super().__init__(**kwargs)
1084        self.hull_id = hull_id
1085        self.sockets: list[Socket] = list(sockets_for_hull(hull_id))
1086        self._fitted: list[Node3D | None] = [None] * len(self.sockets)
1087        self._ids: list[str | None] = [None] * len(self.sockets)
1088        self._wiring = SignalWiring(self)
1089
1090    # -- lifecycle --------------------------------------------------------
1091
1092    def on_enter_tree(self):
1093        super().on_enter_tree()
1094        self.add_to_group(SOCKET_RACK_GROUP)
1095
1096    def on_ready(self):
1097        self._wiring.want(SignalNames.WARP_COMPLETED, self._on_warp_completed)
1098        self._wiring.sweep()
1099
1100    def on_update(self, dt: float):
1101        self._wiring.poll(dt)
1102
1103    # -- fitting ----------------------------------------------------------
1104
1105    def install(self, module_id: str, socket_index: int) -> Node3D:
1106        """Bolt *module_id* into a socket and return the node it built.
1107
1108        Raises ``ValueError`` for an unknown module, a socket that does not
1109        exist, a socket already filled, a module too large for the mount, or a
1110        fifth turret past ``balance.AUTO_TURRETS_MAX``.
1111        """
1112        spec = module_spec(module_id)
1113        socket = self._socket(socket_index)
1114        if self._fitted[socket_index] is not None:
1115            raise ValueError(f"socket {socket_index} ({socket.label}) already carries {self._ids[socket_index]!r}")
1116        if not socket.accepts(spec["socket_size"]):
1117            raise ValueError(f"{module_id!r} needs a {spec['socket_size']} socket; {socket.label} is {socket.size}")
1118        if spec["family"] == "turret" and len(self.turrets()) >= balance.AUTO_TURRETS_MAX:
1119            raise ValueError(f"a hull carries at most {balance.AUTO_TURRETS_MAX} auto-turrets")
1120
1121        node = make_module(module_id, position=socket.local_position())
1122        node.socket = socket
1123        node.rack = self
1124        self._fitted[socket_index] = self.add_child(node)
1125        self._ids[socket_index] = module_id
1126        self._on_fitting_changed()
1127        self.module_installed(module_id, socket_index)
1128        return node
1129
1130    def remove(self, socket_index: int) -> None:
1131        """Strip a socket back to bare mounting. A no-op on an empty socket."""
1132        self._socket(socket_index)
1133        node = self._fitted[socket_index]
1134        if node is None:
1135            return
1136        module_id = self._ids[socket_index]
1137        self._fitted[socket_index] = None
1138        self._ids[socket_index] = None
1139        removed = getattr(node, "on_removed", None)
1140        if callable(removed):
1141            removed()
1142        node.rack = None
1143        # Immediate rather than deferred: the power system rediscovers its
1144        # sources on the next line, and a module still in the tree at that
1145        # moment would keep paying out for the rest of the frame. A deployed
1146        # turret hangs off the scene rather than off the rack, so the unlink
1147        # goes through whatever is actually carrying it.
1148        if node.parent is not None:
1149            node.parent.remove_child(node)
1150        node.destroy()
1151        self._on_fitting_changed()
1152        self.module_removed(module_id, socket_index)
1153
1154    def _socket(self, socket_index: int) -> Socket:
1155        if not 0 <= socket_index < len(self.sockets):
1156            raise ValueError(f"socket {socket_index} does not exist (hull has {len(self.sockets)})")
1157        return self.sockets[socket_index]
1158
1159    # -- reading ----------------------------------------------------------
1160
1161    def module_at(self, socket_index: int) -> Node3D | None:
1162        """Whatever is in a socket, or None."""
1163        self._socket(socket_index)
1164        return self._fitted[socket_index]
1165
1166    def module_id_at(self, socket_index: int) -> str | None:
1167        """The catalogue id in a socket, or None."""
1168        self._socket(socket_index)
1169        return self._ids[socket_index]
1170
1171    def installed(self) -> list[tuple[int, Node3D]]:
1172        """Every fitted module with the socket index it occupies."""
1173        return [(i, node) for i, node in enumerate(self._fitted) if node is not None]
1174
1175    def installed_ids(self) -> list[str]:
1176        """Catalogue ids of everything currently fitted, in socket order."""
1177        return [mid for mid in self._ids if mid is not None]
1178
1179    def free_sockets(self, size: str | None = None) -> list[Socket]:
1180        """Empty sockets, optionally only those a module of *size* fits."""
1181        return [
1182            socket
1183            for i, socket in enumerate(self.sockets)
1184            if self._fitted[i] is None and (size is None or socket.accepts(size))
1185        ]
1186
1187    def socket_for(self, module_id: str) -> Socket | None:
1188        """Where *module_id* would go if it were bought now, or None for nowhere.
1189
1190        The same choice :meth:`install` is given by the run scene, the sternmost
1191        mount that takes it, so a shelf can promise a socket by name before the
1192        scrap is spent rather than after.
1193        """
1194        free = self.free_sockets(str(module_spec(module_id)["socket_size"]))
1195        return free[-1] if free else None
1196
1197    def fit_lines(self) -> list[str]:
1198        """One line per socket, for a screen that shows the current fit.
1199
1200        Empty mounts are listed too. A rack that only printed what is installed
1201        would answer "what can I still buy?" with silence, which is the question
1202        a pilot standing in a shop is actually asking.
1203        """
1204        lines: list[str] = []
1205        for index, socket in enumerate(self.sockets):
1206            head = f"{socket.label} ({socket.size})"
1207            module_id = self._ids[index]
1208            if module_id is None:
1209                lines.append(f"{head}: empty")
1210                continue
1211            lines.append(f"{head}: {module_id.replace('_', ' ').upper()}, {effect_summary(module_id)}")
1212        return lines
1213
1214    def turrets(self) -> list[AutoTurret]:
1215        """Every auto-turret on the hull, docked or deployed."""
1216        return [node for node in self._fitted if isinstance(node, AutoTurret)]
1217
1218    def ship(self) -> Node3D | None:
1219        """The hull the rack is mounted on."""
1220        return self.parent if isinstance(self.parent, Node3D) else None
1221
1222    def deployment_host(self) -> Node | None:
1223        """Where a deployed turret is parented: the ship's own parent, or the root.
1224
1225        A turret that is out is no longer part of the hull, so it must hang off
1226        something that does not move with it. Anything above the ship will do,
1227        since a ``Node3D`` under a plain ``Node`` keeps its own world transform.
1228        """
1229        ship = self.ship()
1230        if ship is not None and ship.parent is not None:
1231            return ship.parent
1232        tree = self.tree
1233        return None if tree is None else tree.root
1234
1235    # -- aggregates -------------------------------------------------------
1236
1237    def total_mass(self) -> float:
1238        """Mass of everything fitted, in the handling model's units."""
1239        return sum(float(MODULE_CATALOGUE[mid]["mass"]) for mid in self.installed_ids())
1240
1241    def handling_multiplier(self) -> float:
1242        """How much the fitted mass slows the hull, 1.0 on a bare ship.
1243
1244        The ship module applies this to its acceleration and turn rate; until it
1245        does, the number is still the honest measure of what a build costs to
1246        fly, and the rack pushes it to any hull that accepts it.
1247        """
1248        loaded = 1.0 / (1.0 + self.total_mass() / HULL_REFERENCE_MASS)
1249        return max(MIN_HANDLING_MULTIPLIER, loaded)
1250
1251    def effect_multiplier(self, name: str) -> float:
1252        """The product of one named multiplicative effect across the build."""
1253        value = 1.0
1254        for mid in self.installed_ids():
1255            effect = MODULE_CATALOGUE[mid]["effects"].get(name)
1256            if effect is not None:
1257                value *= float(effect)
1258        return value
1259
1260    def effect_bonus(self, name: str) -> float:
1261        """The sum of one named additive effect across the build."""
1262        return sum(float(MODULE_CATALOGUE[mid]["effects"].get(name, 0.0)) for mid in self.installed_ids())
1263
1264    # -- turret contract across a warp ------------------------------------
1265
1266    def deploy_turrets(self) -> int:
1267        """Drop every docked turret where the ship stands; returns how many."""
1268        return sum(1 for turret in self.turrets() if turret.deploy())
1269
1270    def retrieve_turrets(self) -> int:
1271        """Take every deployed turret back aboard; returns how many."""
1272        return sum(1 for turret in self.turrets() if turret.retrieve())
1273
1274    def _on_warp_completed(self, emergency: bool) -> None:
1275        if not emergency:
1276            self.retrieve_turrets()
1277            return
1278        for index, node in list(self.installed()):
1279            if isinstance(node, AutoTurret) and node.deployed:
1280                self.remove(index)
1281
1282    # -- consequences of a fitting change ---------------------------------
1283
1284    def _on_fitting_changed(self) -> None:
1285        tree = self.tree
1286        power = None if tree is None else tree.singletons.get(Services.POWER)
1287        if power is not None:
1288            power.refresh_sources()
1289        ship = self.ship()
1290        set_mass = getattr(ship, "set_module_mass", None)
1291        if callable(set_mass):
1292            set_mass(self.total_mass())