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())