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.box(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.emit()
 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.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.emit(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, Node3D):
 938                position = node.world_position
 939                distance = math.hypot(float(position[0]) - float(origin[0]), float(position[2]) - float(origin[2]))
 940                if distance > best_distance or not self.covers(position):
 941                    continue
 942                best, best_distance = node, distance
 943        return best
 944
 945    def _fire(self, target: Node3D) -> None:
 946        origin, position = self.world_position, target.world_position
 947        dx = float(position[0]) - float(origin[0])
 948        dz = float(position[2]) - float(origin[2])
 949        length = math.hypot(dx, dz)
 950        direction = Vec3(dx / length, 0.0, dz / length) if length > 1e-6 else Vec3(1.0, 0.0, 0.0)
 951        self._show_tracer(direction, length)
 952        self.turret_fired.emit(self.module_id)
 953        tree = self.tree
 954        router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
 955        if router is not None:
 956            router.deal(target, self.damage_per_shot, kind=self.profile.kind, direction=direction)
 957
 958    def _show_tracer(self, direction: Vec3, length: float) -> None:
 959        if self._tracer is None:
 960            self._tracer = self.add_child(Node3D(name="Tracer"))
 961            self._tracer_mesh = self._tracer.add_child(
 962                MeshInstance3D(
 963                    name="Line",
 964                    mesh=Mesh.cylinder(radius=TRACER_RADIUS, height=1.0, segments=6),
 965                    material=Material(colour=TRACER_COLOUR, emissive_colour=TRACER_COLOUR[:3], emissive_strength=4.0),
 966                    rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 967                )
 968            )
 969        self._tracer.visible = True
 970        self._tracer.face_along(direction)
 971        self._tracer_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
 972        self._tracer_mesh.scale = Vec3(1.0, max(length, 1e-3), 1.0)
 973        self._tracer_left = TURRET_TRACER_S
 974
 975    def _tick_tracer(self, dt: float) -> None:
 976        if self._tracer_left <= 0.0:
 977            return
 978        self._tracer_left -= dt
 979        if self._tracer_left <= 0.0 and self._tracer is not None:
 980            self._tracer.visible = False
 981
 982
 983# ============================================================================
 984# The Fabricator
 985# ============================================================================
 986
 987
 988class Fabricator(ShipModule):
 989    """Prints ammunition from scrap, joining the two economies at a price.
 990
 991    ``balance.FABRICATOR_SCRAP_PER_BOX`` of scrap becomes one box on the weapon
 992    rack, half what a depot charges, which is what lets a ballistic build stay
 993    fed between posts without ever docking.
 994    """
 995
 996    def weapon_rack(self):
 997        """The ship's weapon rack, or None if nothing is fitted yet."""
 998        tree = self.tree
 999        if tree is None:
1000            return None
1001        racks = tree.group(WEAPON_RACK_GROUP)
1002        return racks[0] if racks else None
1003
1004    def craftable(self) -> list[str]:
1005        """Weapon ids this module can print boxes for."""
1006        return [wid for wid, spec in balance.WEAPONS.items() if spec.family == "ballistic"]
1007
1008    def craft(self, weapon_id: str) -> bool:
1009        """Buy one ammo box for *weapon_id*. False if the scrap or rack is missing."""
1010        if weapon_id not in self.craftable():
1011            raise ValueError(f"{weapon_id!r} is not a ballistic weapon; nothing to print")
1012        rack = self.weapon_rack()
1013        economy = self.economy()
1014        if rack is None or economy is None:
1015            return False
1016        if not economy.spend_scrap(balance.FABRICATOR_SCRAP_PER_BOX):
1017            return False
1018        rack.add_ammo_box(weapon_id)
1019        return True
1020
1021
1022# ============================================================================
1023# Building modules
1024# ============================================================================
1025
1026#: Power modules keep their behaviour in ``power.py``; the catalogue fits them.
1027POWER_CLASSES: dict[str, type[PowerSource]] = {
1028    "solar_wings": SolarWings,
1029    "generator": Generator,
1030    "rtg": RTG,
1031}
1032
1033#: Modules with behaviour of their own, by id. Everything else is passive.
1034SPECIAL_CLASSES: dict[str, type[ShipModule]] = {
1035    "refinery": Refinery,
1036    "fabricator": Fabricator,
1037}
1038
1039
1040def make_module(module_id: str, **kwargs) -> Node3D:
1041    """Build the node for a catalogue id, picking the class by family."""
1042    spec = module_spec(module_id)
1043    power_class = POWER_CLASSES.get(module_id)
1044    if power_class is not None:
1045        kwargs.setdefault("name", module_id)
1046        node = power_class(**kwargs)
1047        node.module_id = module_id
1048        return node
1049    if spec["family"] == "turret":
1050        return AutoTurret(module_id, **kwargs)
1051    special = SPECIAL_CLASSES.get(module_id)
1052    if special is not None:
1053        return special(module_id, **kwargs)
1054    return PassiveModule(module_id, **kwargs)
1055
1056
1057# ============================================================================
1058# The rack
1059# ============================================================================
1060
1061
1062class SocketRack(Node3D):
1063    """The hull's external sockets and everything bolted into them.
1064
1065    The rack is the one place a module is fitted or stripped, so it is also the
1066    one place the consequences are applied: the power system rediscovers its
1067    sources, the handling model learns the new mass, and the aggregate effect
1068    bag other systems read is recomputed from what is actually installed.
1069
1070    It also owns the turret contract across a warp. A normal warp retrieves
1071    every deployed turret; an emergency warp abandons them, which is what the
1072    1.5x fuel price on the panic button is buying you out of.
1073    """
1074
1075    #: ``(module_id, socket_index)`` on every fitting change.
1076    module_installed = Signal(str, int)
1077    module_removed = Signal(str, int)
1078
1079    def __init__(self, hull_id: str = "vagrant", **kwargs):
1080        kwargs.setdefault("name", "Sockets")
1081        super().__init__(**kwargs)
1082        self.hull_id = hull_id
1083        self.sockets: list[Socket] = list(sockets_for_hull(hull_id))
1084        self._fitted: list[Node3D | None] = [None] * len(self.sockets)
1085        self._ids: list[str | None] = [None] * len(self.sockets)
1086        self._wiring = SignalWiring(self)
1087
1088    # -- lifecycle --------------------------------------------------------
1089
1090    def on_enter_tree(self):
1091        super().on_enter_tree()
1092        self.add_to_group(SOCKET_RACK_GROUP)
1093
1094    def on_ready(self):
1095        self._wiring.want(SignalNames.WARP_COMPLETED, self._on_warp_completed)
1096        self._wiring.sweep()
1097
1098    def on_update(self, dt: float):
1099        self._wiring.poll(dt)
1100
1101    # -- fitting ----------------------------------------------------------
1102
1103    def install(self, module_id: str, socket_index: int) -> Node3D:
1104        """Bolt *module_id* into a socket and return the node it built.
1105
1106        Raises ``ValueError`` for an unknown module, a socket that does not
1107        exist, a socket already filled, a module too large for the mount, or a
1108        fifth turret past ``balance.AUTO_TURRETS_MAX``.
1109        """
1110        spec = module_spec(module_id)
1111        socket = self._socket(socket_index)
1112        if self._fitted[socket_index] is not None:
1113            raise ValueError(f"socket {socket_index} ({socket.label}) already carries {self._ids[socket_index]!r}")
1114        if not socket.accepts(spec["socket_size"]):
1115            raise ValueError(f"{module_id!r} needs a {spec['socket_size']} socket; {socket.label} is {socket.size}")
1116        if spec["family"] == "turret" and len(self.turrets()) >= balance.AUTO_TURRETS_MAX:
1117            raise ValueError(f"a hull carries at most {balance.AUTO_TURRETS_MAX} auto-turrets")
1118
1119        node = make_module(module_id, position=socket.local_position())
1120        node.socket = socket
1121        node.rack = self
1122        self._fitted[socket_index] = self.add_child(node)
1123        self._ids[socket_index] = module_id
1124        self._on_fitting_changed()
1125        self.module_installed.emit(module_id, socket_index)
1126        return node
1127
1128    def remove(self, socket_index: int) -> None:
1129        """Strip a socket back to bare mounting. A no-op on an empty socket."""
1130        self._socket(socket_index)
1131        node = self._fitted[socket_index]
1132        if node is None:
1133            return
1134        module_id = self._ids[socket_index]
1135        self._fitted[socket_index] = None
1136        self._ids[socket_index] = None
1137        removed = getattr(node, "on_removed", None)
1138        if callable(removed):
1139            removed()
1140        node.rack = None
1141        # Immediate rather than deferred: the power system rediscovers its
1142        # sources on the next line, and a module still in the tree at that
1143        # moment would keep paying out for the rest of the frame. A deployed
1144        # turret hangs off the scene rather than off the rack, so the unlink
1145        # goes through whatever is actually carrying it.
1146        if node.parent is not None:
1147            node.parent.remove_child(node)
1148        node.destroy()
1149        self._on_fitting_changed()
1150        self.module_removed.emit(module_id, socket_index)
1151
1152    def _socket(self, socket_index: int) -> Socket:
1153        if not 0 <= socket_index < len(self.sockets):
1154            raise ValueError(f"socket {socket_index} does not exist (hull has {len(self.sockets)})")
1155        return self.sockets[socket_index]
1156
1157    # -- reading ----------------------------------------------------------
1158
1159    def module_at(self, socket_index: int) -> Node3D | None:
1160        """Whatever is in a socket, or None."""
1161        self._socket(socket_index)
1162        return self._fitted[socket_index]
1163
1164    def module_id_at(self, socket_index: int) -> str | None:
1165        """The catalogue id in a socket, or None."""
1166        self._socket(socket_index)
1167        return self._ids[socket_index]
1168
1169    def installed(self) -> list[tuple[int, Node3D]]:
1170        """Every fitted module with the socket index it occupies."""
1171        return [(i, node) for i, node in enumerate(self._fitted) if node is not None]
1172
1173    def installed_ids(self) -> list[str]:
1174        """Catalogue ids of everything currently fitted, in socket order."""
1175        return [mid for mid in self._ids if mid is not None]
1176
1177    def free_sockets(self, size: str | None = None) -> list[Socket]:
1178        """Empty sockets, optionally only those a module of *size* fits."""
1179        return [
1180            socket
1181            for i, socket in enumerate(self.sockets)
1182            if self._fitted[i] is None and (size is None or socket.accepts(size))
1183        ]
1184
1185    def socket_for(self, module_id: str) -> Socket | None:
1186        """Where *module_id* would go if it were bought now, or None for nowhere.
1187
1188        The same choice :meth:`install` is given by the run scene, the sternmost
1189        mount that takes it, so a shelf can promise a socket by name before the
1190        scrap is spent rather than after.
1191        """
1192        free = self.free_sockets(str(module_spec(module_id)["socket_size"]))
1193        return free[-1] if free else None
1194
1195    def fit_lines(self) -> list[str]:
1196        """One line per socket, for a screen that shows the current fit.
1197
1198        Empty mounts are listed too. A rack that only printed what is installed
1199        would answer "what can I still buy?" with silence, which is the question
1200        a pilot standing in a shop is actually asking.
1201        """
1202        lines: list[str] = []
1203        for index, socket in enumerate(self.sockets):
1204            head = f"{socket.label} ({socket.size})"
1205            module_id = self._ids[index]
1206            if module_id is None:
1207                lines.append(f"{head}: empty")
1208                continue
1209            lines.append(f"{head}: {module_id.replace('_', ' ').upper()}, {effect_summary(module_id)}")
1210        return lines
1211
1212    def turrets(self) -> list[AutoTurret]:
1213        """Every auto-turret on the hull, docked or deployed."""
1214        return [node for node in self._fitted if isinstance(node, AutoTurret)]
1215
1216    def ship(self) -> Node3D | None:
1217        """The hull the rack is mounted on."""
1218        return self.parent if isinstance(self.parent, Node3D) else None
1219
1220    def deployment_host(self) -> Node | None:
1221        """Where a deployed turret is parented: the ship's own parent, or the root.
1222
1223        A turret that is out is no longer part of the hull, so it must hang off
1224        something that does not move with it. Anything above the ship will do,
1225        since a ``Node3D`` under a plain ``Node`` keeps its own world transform.
1226        """
1227        ship = self.ship()
1228        if ship is not None and ship.parent is not None:
1229            return ship.parent
1230        tree = self.tree
1231        return None if tree is None else tree.root
1232
1233    # -- aggregates -------------------------------------------------------
1234
1235    def total_mass(self) -> float:
1236        """Mass of everything fitted, in the handling model's units."""
1237        return sum(float(MODULE_CATALOGUE[mid]["mass"]) for mid in self.installed_ids())
1238
1239    def handling_multiplier(self) -> float:
1240        """How much the fitted mass slows the hull, 1.0 on a bare ship.
1241
1242        The ship module applies this to its acceleration and turn rate; until it
1243        does, the number is still the honest measure of what a build costs to
1244        fly, and the rack pushes it to any hull that accepts it.
1245        """
1246        loaded = 1.0 / (1.0 + self.total_mass() / HULL_REFERENCE_MASS)
1247        return max(MIN_HANDLING_MULTIPLIER, loaded)
1248
1249    def effect_multiplier(self, name: str) -> float:
1250        """The product of one named multiplicative effect across the build."""
1251        value = 1.0
1252        for mid in self.installed_ids():
1253            effect = MODULE_CATALOGUE[mid]["effects"].get(name)
1254            if effect is not None:
1255                value *= float(effect)
1256        return value
1257
1258    def effect_bonus(self, name: str) -> float:
1259        """The sum of one named additive effect across the build."""
1260        return sum(float(MODULE_CATALOGUE[mid]["effects"].get(name, 0.0)) for mid in self.installed_ids())
1261
1262    # -- turret contract across a warp ------------------------------------
1263
1264    def deploy_turrets(self) -> int:
1265        """Drop every docked turret where the ship stands; returns how many."""
1266        return sum(1 for turret in self.turrets() if turret.deploy())
1267
1268    def retrieve_turrets(self) -> int:
1269        """Take every deployed turret back aboard; returns how many."""
1270        return sum(1 for turret in self.turrets() if turret.retrieve())
1271
1272    def _on_warp_completed(self, emergency: bool) -> None:
1273        if not emergency:
1274            self.retrieve_turrets()
1275            return
1276        for index, node in list(self.installed()):
1277            if isinstance(node, AutoTurret) and node.deployed:
1278                self.remove(index)
1279
1280    # -- consequences of a fitting change ---------------------------------
1281
1282    def _on_fitting_changed(self) -> None:
1283        tree = self.tree
1284        power = None if tree is None else tree.singletons.get(Services.POWER)
1285        if power is not None:
1286            power.refresh_sources()
1287        ship = self.ship()
1288        set_mass = getattr(ship, "set_module_mass", None)
1289        if callable(set_mass):
1290            set_mass(self.total_mass())