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