shrike/weapons.py¶

Part of SHRIKE.

   1"""Hardpoints, the ten-weapon roster, and everything they put in the air.
   2
   3The roster splits down the game's two economies and the split is structural
   4rather than cosmetic. :class:`EnergyWeapon` spends the capacitor through the
   5``PowerSystem`` service and never looks at a magazine; :class:`BallisticWeapon`
   6spends rounds from a per-weapon magazine fed by ammo boxes and never touches
   7the capacitor; :class:`GravHook` spends neither, because it deals no damage and
   8exists to move things.
   9
  10Firing model
  11============
  12
  13Every weapon reads three data sources and nothing else:
  14
  15* ``balance.WEAPONS[weapon_id]`` for family, sustained DPS and, where the
  16  design has fixed it, the energy cost.
  17* :data:`FIRE_PROFILES` for the cadence, reach and shot shape, which are feel
  18  numbers rather than balance numbers.
  19* the live services, ``Services.POWER`` for energy and ``Services.DAMAGE`` for
  20  every damage number that leaves this module.
  21
  22Per-projectile damage is always derived, never typed: a weapon that fires
  23``pellets`` shots at ``rate`` per second deals ``spec.dps / (rate * pellets)``
  24per pellet, so sustained fire into a single target delivers exactly the
  25roster's DPS. Beams deal ``spec.dps * dt`` per tick for the same reason.
  26
  27Hit resolution happens in flight-plane geometry: a projectile sweeps the
  28segment it covers this step against the hostile groups, so a fast round cannot
  29step over a target between frames. Projectiles are still real
  30:class:`~simvx.core.Area3D` sensors on ``Layers.PLAYER_FIRE`` carrying the
  31collision contract, which is how terrain stops them and how anything watching
  32for player fire sees them. Damage itself is never applied here: it is handed to
  33``DamageRouter.deal``, which owns shield coverage, the ballistic premium and
  34elite modifiers.
  35
  36The fit
  37=======
  38
  39A mounted weapon is geometry on the hull, not only a transform: every one
  40builds a breech and a barrel sized by its family, so swapping a pulse blaster
  41for a rail lance changes the ship the player is looking at. :func:`weapon_summary`
  42is the one mechanical line a shop or a fit panel prints for a weapon, derived
  43from ``balance.WEAPONS`` and :data:`FIRE_PROFILES`; :meth:`WeaponRack.swap_in`
  44is how a fit is changed, returning the id it displaced so the caller that owns
  45the stowage can bank it.
  46"""
  47
  48import math
  49from dataclasses import dataclass
  50
  51from simvx.core import (
  52    Area3D,
  53    Input,
  54    Material,
  55    Mesh,
  56    MeshInstance3D,
  57    Node,
  58    Node3D,
  59    Quat,
  60    Signal,
  61    SphereShape3D,
  62    Vec3,
  63)
  64
  65from . import balance
  66from .runtime import PLANE_Y, Groups, Layers, Services
  67
  68try:  # The contact sparks come from the effect pool when the art layer is in.
  69    from . import vfx
  70except ImportError:  # pragma: no cover - the art layer is optional here
  71    vfx = None
  72
  73# ============================================================================
  74# Numbers this module owns
  75#
  76# Everything here is a cadence, a reach or a shot shape: feel numbers that
  77# balance.py does not fix. The DPS, the energy costs and the magazine sizes all
  78# come from balance.py and are never restated.
  79# ============================================================================
  80
  81#: Group the weapon rack joins so other systems reach it without walking the
  82#: tree by node name.
  83WEAPON_RACK_GROUP = "weapon_rack"
  84
  85#: Plane offsets of the four hardpoints from the hull centre, ``(x, z)``.
  86#: The outboard pair is fitted first, the forward pair as the hull grows.
  87HARDPOINT_OFFSETS = ((-0.85, 0.15), (0.85, 0.15), (-1.45, -0.55), (1.45, -0.55))
  88
  89#: Radius assumed for a target that publishes no hitbox radius.
  90DEFAULT_TARGET_RADIUS = 1.0
  91#: Radius one member of a swarm occupies, for shoals that publish array rows
  92#: rather than a body per member.
  93SWARM_MEMBER_RADIUS = 0.6
  94#: Radius of a projectile's own body, added to the target radius when sweeping.
  95PROJECTILE_RADIUS = 0.35
  96#: Seconds a magazine swap locks a ballistic weapon out for.
  97RELOAD_S = 1.2
  98#: Per-shot energy for the two energy weapons balance.py leaves unset, plus the
  99#: grav hook. A weapon whose ``WeaponSpec`` carries a cost uses that instead.
 100PROVISIONAL_ENERGY_PER_SHOT = {"scatter_coil": 4.0, "nova_mortar": 12.0, "grav_hook": 6.0}
 101
 102#: Speed and duration of a grav hook yank.
 103GRAV_HOOK_PULL_SPEED = 26.0
 104GRAV_HOOK_PULL_S = 0.8
 105
 106#: Player fire is warm gold and white; enemy fire is cold magenta. The palette
 107#: is the readability contract, so projectile materials live at one site.
 108PROJECTILE_COLOUR = (1.0, 0.82, 0.42, 1.0)
 109BEAM_COLOUR = (1.0, 0.93, 0.72, 1.0)
 110BEAM_RADIUS = 0.09
 111#: How long a hitscan shot leaves its tracer line on screen.
 112HITSCAN_TRACER_S = 0.06
 113
 114# -- A beam that says whether it is working ----------------------------------
 115#
 116# The mining laser used to draw the same full-length bar of light whether it
 117# was cutting a vein or crossing empty space, so the only way to find out
 118# whether the beam was on anything was to watch a scrap counter that ticks
 119# twice a second. A blind pilot swept the beam across a whole sector without
 120# ever learning what it was for. The beam now ends where it bites, marks the
 121# bite, and thins out when it bites nothing.
 122
 123#: Beam colour while it is cutting nothing: the same hue drained of saturation
 124#: and heat, so a miss reads as a spent aiming line rather than as work.
 125BEAM_MISS_COLOUR = (0.62, 0.62, 0.62, 1.0)
 126#: Emissive strength of the beam core, on a surface and in vacuum.
 127BEAM_EMISSIVE_HIT = 4.0
 128BEAM_EMISSIVE_MISS = 0.9
 129#: Cross-section of a beam that is cutting nothing, as a fraction of full bore.
 130BEAM_MISS_WIDTH = 0.4
 131#: Reach of a beam with nothing in front of it, as a fraction of the profile's
 132#: own reach. A miss stops short of the range ring as well as thinning: the
 133#: pilot sees the beam fail to arrive at what they were pointing at.
 134BEAM_MISS_REACH_FRACTION = 0.7
 135#: The bead of light burning on whatever the beam has found, as a multiple of
 136#: the beam's own radius: the highlight that says which of two veins in line
 137#: with the muzzle is the one being cut.
 138BEAM_SPOT_RADIUS_MULT = 3.4
 139#: Seconds between the chip bursts a working beam throws off its contact point.
 140BEAM_SPARK_INTERVAL_S = 0.12
 141#: How far past a mineable surface's own radius the beam still counts as being
 142#: on it. Aiming is done with a mouse at a rock the camera draws small, and a
 143#: beam that has to pass through the exact centre of a vein is a beam that
 144#: usually pays nothing. Combat reach is deliberately not padded this way.
 145BEAM_CONTACT_PADDING = 1.0
 146
 147#: Weapons that answer the utility trigger (``mining_beam``) rather than
 148#: ``fire_primary``.
 149TRIGGER_MINING = frozenset({"mining_laser", "grav_hook"})
 150
 151PRIMARY_ACTION = "fire_primary"
 152MINING_ACTION = "mining_beam"
 153
 154#: What the utility trigger is called on a shelf row. A pilot who buys a mining
 155#: laser and holds the fire button gets nothing, so the row has to name the key.
 156#: The primary trigger is unremarked, being what every other weapon answers.
 157TRIGGER_LABELS = {MINING_ACTION: "utility trigger"}
 158
 159#: The mount a weapon puts on the hull, so a hardpoint reads as fitted.
 160#: Barrel dimensions are metres along the hull's local -Z, which is the nose.
 161MOUNT_BARREL_LENGTH = {"energy": 0.62, "ballistic": 0.95, "utility": 0.42}
 162MOUNT_BARREL_RADIUS = {"energy": 0.075, "ballistic": 0.11, "utility": 0.14}
 163#: The receiver block behind the barrel, shared by every family.
 164MOUNT_BREECH_SIZE = 0.22
 165#: Gunmetal for the barrel; the muzzle carries the family's own warm accent so
 166#: two different guns on two hardpoints are told apart at a glance.
 167MOUNT_METAL_COLOUR = (0.30, 0.31, 0.34, 1.0)
 168MOUNT_ACCENT_COLOURS = {
 169    "energy": (0.55, 0.85, 1.0, 1.0),
 170    "ballistic": (1.0, 0.72, 0.34, 1.0),
 171    "utility": (0.72, 0.55, 0.95, 1.0),
 172}
 173#: Below the bloom threshold: a muzzle marks the gun, it does not light the sky.
 174MOUNT_ACCENT_STRENGTH = 0.9
 175
 176
 177@dataclass(frozen=True)
 178class FireProfile:
 179    """How one weapon puts its damage into the world.
 180
 181    ``rate`` is trigger pulls per second and is ignored by beams, which are
 182    continuous. ``speed`` of zero means hitscan. ``pellets`` shots leave the
 183    muzzle per pull, fanned evenly across ``spread`` radians either side of the
 184    aim line, and each carries an equal share of the weapon's DPS.
 185    """
 186
 187    rate: float
 188    reach: float
 189    speed: float = 0.0
 190    pellets: int = 1
 191    spread: float = 0.0
 192    pierce: int = 0
 193    blast: float = 0.0
 194    homing_rate: float = 0.0
 195    beam: bool = False
 196    mines: bool = False
 197    rounds_per_shot: int = 1
 198
 199
 200#: One profile per roster entry; the keys match ``balance.WEAPONS`` exactly.
 201FIRE_PROFILES: dict[str, FireProfile] = {
 202    "pulse_blaster": FireProfile(rate=5.0, reach=60.0, speed=90.0),
 203    "scatter_coil": FireProfile(rate=2.0, reach=22.0, speed=70.0, pellets=8, spread=0.30),
 204    "arc_beam": FireProfile(rate=0.0, reach=40.0, beam=True),
 205    "mining_laser": FireProfile(rate=0.0, reach=26.0, beam=True, mines=True),
 206    "nova_mortar": FireProfile(rate=0.8, reach=55.0, speed=40.0, blast=9.0),
 207    "rail_lance": FireProfile(rate=1.5, reach=90.0, pierce=3),
 208    "flak_cannon": FireProfile(rate=2.5, reach=34.0, speed=65.0, pellets=6, spread=0.26, blast=2.6),
 209    "autocannon": FireProfile(rate=8.0, reach=55.0, speed=110.0),
 210    "missile_rack": FireProfile(
 211        rate=1.0, reach=70.0, speed=55.0, pellets=2, spread=0.05, blast=5.0, homing_rate=3.0, rounds_per_shot=2
 212    ),
 213    "grav_hook": FireProfile(rate=0.5, reach=45.0),
 214}
 215
 216
 217# ============================================================================
 218# Saying what a weapon does
 219#
 220# A shelf row that says "40 DPS ballistic" has priced a word. What a pilot has
 221# to decide is whether the gun feeds off the capacitor they are already
 222# spending on the shield or off boxes they have to keep buying, and which
 223# trigger it answers. Those are the three facts below, and they come off
 224# balance.py and this module's own profiles rather than off prose.
 225# ============================================================================
 226
 227
 228def trigger_action_for(weapon_id: str) -> str:
 229    """The input action *weapon_id* answers, without mounting one to ask."""
 230    return MINING_ACTION if weapon_id in TRIGGER_MINING else PRIMARY_ACTION
 231
 232
 233def feed_line(weapon_id: str) -> str:
 234    """What the weapon costs to fire, in the units the pilot's meters use.
 235
 236    Ballistic weapons quote the box because a box is the thing that runs out;
 237    energy weapons quote the capacitor draw, per shot or per second as the
 238    weapon spends it.
 239    """
 240    spec = balance.WEAPONS[weapon_id]
 241    profile = FIRE_PROFILES[weapon_id]
 242    if spec.family == "ballistic":
 243        rounds = int(spec.rounds_per_box or 0)
 244        per_shot = f", {profile.rounds_per_shot} a shot" if profile.rounds_per_shot > 1 else ""
 245        return f"uses ammo, {rounds} a box{per_shot}"
 246    per_second = spec.energy_per_s if spec.energy_per_s is not None else None
 247    if per_second is not None:
 248        return f"drains {per_second:.0f} energy/s"
 249    per_shot_cost = spec.energy_per_shot
 250    if per_shot_cost is None:
 251        per_shot_cost = PROVISIONAL_ENERGY_PER_SHOT.get(weapon_id, 0.0)
 252    if per_shot_cost <= 0.0:
 253        return "costs nothing to fire"
 254    return f"{per_shot_cost:.0f} energy a shot"
 255
 256
 257def weapon_summary(weapon_id: str) -> str:
 258    """DPS, feed and trigger on one line, for a shelf row or a fit panel."""
 259    spec = balance.WEAPONS[weapon_id]
 260    head = f"{spec.dps:.0f} DPS" if spec.dps > 0.0 else "no damage"
 261    parts = [head, feed_line(weapon_id), TRIGGER_LABELS.get(trigger_action_for(weapon_id), "")]
 262    if spec.note:
 263        parts.append(spec.note)
 264    return ", ".join(part for part in parts if part)
 265
 266
 267# ============================================================================
 268# Flight-plane geometry
 269# ============================================================================
 270
 271
 272def _plane_of(node: Node3D) -> tuple[float, float]:
 273    """A node's world position as flight-plane ``(x, z)`` floats."""
 274    p = node.world_position
 275    return float(p[0]), float(p[2])
 276
 277
 278def _normalise(x: float, z: float) -> tuple[float, float]:
 279    """Unit vector on the plane; a zero vector becomes +X."""
 280    length = math.hypot(x, z)
 281    if length < 1e-9:
 282        return 1.0, 0.0
 283    return x / length, z / length
 284
 285
 286def _rotate(x: float, z: float, angle: float) -> tuple[float, float]:
 287    """Rotate a plane vector by *angle* radians."""
 288    c, s = math.cos(angle), math.sin(angle)
 289    return x * c - z * s, x * s + z * c
 290
 291
 292def _is_swarm(node: Node3D) -> bool:
 293    """Whether *node* is a swarm whose members are array rows, not bodies.
 294
 295    A shoal publishes ``live_positions`` and takes damage through
 296    ``damage_at`` at a world point, because no individual member has a hitbox
 297    of its own to sweep against.
 298    """
 299    return getattr(node, "HITBOX_RADIUS", DEFAULT_TARGET_RADIUS) is None and hasattr(node, "damage_at")
 300
 301
 302def _target_radius(node: Node3D) -> float:
 303    """Collision radius a shot must reach to count as a hit on *node*."""
 304    hitbox = getattr(node, "HITBOX_RADIUS", None)
 305    if hitbox is not None:
 306        return float(hitbox)
 307    if _is_swarm(node):
 308        return SWARM_MEMBER_RADIUS
 309    return float(getattr(node, "hit_radius", DEFAULT_TARGET_RADIUS))
 310
 311
 312def _swarm_members(node: Node3D) -> list[tuple[float, float]]:
 313    """Plane positions of a swarm's live members."""
 314    rows = getattr(node, "live_positions", None)
 315    if rows is None:
 316        return []
 317    return [(float(row[0]), float(row[2])) for row in rows]
 318
 319
 320def _segment_circle_distance(
 321    origin: tuple[float, float],
 322    direction: tuple[float, float],
 323    length: float,
 324    centre: tuple[float, float],
 325    radius: float,
 326) -> float | None:
 327    """Distance along a unit-direction segment at which it first enters a circle.
 328
 329    Returns ``None`` when the segment misses. A segment starting inside the
 330    circle reports 0.0, so a shot spawned on top of a target still connects.
 331    """
 332    fx, fz = origin[0] - centre[0], origin[1] - centre[1]
 333    b = fx * direction[0] + fz * direction[1]
 334    c = fx * fx + fz * fz - radius * radius
 335    if c <= 0.0:
 336        return 0.0
 337    discriminant = b * b - c
 338    if discriminant < 0.0:
 339        return None
 340    entry = -b - math.sqrt(discriminant)
 341    if 0.0 <= entry <= length:
 342        return entry
 343    return None
 344
 345
 346def _group_nodes(tree, *groups: str) -> list[Node3D]:
 347    """Live ``Node3D`` members of the given groups, skipping anything expiring."""
 348    out: list[Node3D] = []
 349    if tree is None:
 350        return out
 351    for group in groups:
 352        for node in tree.group(group):
 353            if isinstance(node, Node3D) and not node.destroying:
 354                out.append(node)
 355    return out
 356
 357
 358def _sweep(
 359    tree,
 360    origin: tuple[float, float],
 361    direction: tuple[float, float],
 362    length: float,
 363    padding: float,
 364    *groups: str,
 365) -> list[tuple[float, Node3D, tuple[float, float]]]:
 366    """Every group member the segment crosses, nearest first.
 367
 368    Each hit is ``(distance, node, centre)``. *centre* is the plane centre of
 369    the body that was actually struck, which for a swarm is the member's own
 370    position rather than the shoal node's: a shoal is tested against each of
 371    its live members, so it reports the distance at which the segment reaches
 372    its nearest mite, and the damage that follows has to land on that mite
 373    rather than on the shoal's centre of mass.
 374    """
 375    hits: list[tuple[float, Node3D, tuple[float, float]]] = []
 376    for node in _group_nodes(tree, *groups):
 377        radius = _target_radius(node) + padding
 378        if _is_swarm(node):
 379            reached = [
 380                (distance, member)
 381                for distance, member in (
 382                    (_segment_circle_distance(origin, direction, length, member, radius), member)
 383                    for member in _swarm_members(node)
 384                )
 385                if distance is not None
 386            ]
 387            if not reached:
 388                continue
 389            distance, centre = min(reached, key=lambda pair: pair[0])
 390        else:
 391            centre = _plane_of(node)
 392            distance = _segment_circle_distance(origin, direction, length, centre, radius)
 393            if distance is None:
 394                continue
 395        hits.append((distance, node, centre))
 396    hits.sort(key=lambda hit: hit[0])
 397    return hits
 398
 399
 400def _hostiles(tree) -> list[Node3D]:
 401    """Everything player fire is allowed to damage."""
 402    return _group_nodes(tree, Groups.ENEMIES, Groups.HUNTER)
 403
 404
 405def _deal(
 406    tree,
 407    target: Node3D,
 408    amount: float,
 409    kind: str,
 410    direction: tuple[float, float],
 411    at: tuple[float, float] | None = None,
 412    splash: float = 0.0,
 413) -> None:
 414    """Hand a damage number to the router, the only code that applies one.
 415
 416    A swarm is the one exception, and it is an interface gap rather than a
 417    choice: ``DamageRouter.deal`` takes a node, and a shoal's members are array
 418    rows at their own positions, so damage that landed at a point is applied
 419    through the shoal's own ``damage_at`` instead. *at* is therefore the struck
 420    member's own centre, not the point on its surface the round touched: the
 421    sweep pads a target's radius by the projectile's, and a burst centred on
 422    that padded surface would fall entirely outside the member it just hit.
 423    """
 424    if at is not None and _is_swarm(target):
 425        target.damage_at(Vec3(at[0], PLANE_Y, at[1]), max(splash, _target_radius(target)), amount, kind)
 426        return
 427    router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
 428    if router is None:
 429        return
 430    router.deal(target, amount, kind=kind, direction=Vec3(direction[0], 0.0, direction[1]))
 431
 432
 433# ============================================================================
 434# Projectiles
 435# ============================================================================
 436
 437
 438class Projectile(Area3D):
 439    """One round in flight on the plane.
 440
 441    The sensor body carries the ``Layers.PLAYER_FIRE`` contract so terrain and
 442    anything else watching player fire sees the round; damage is resolved by
 443    sweeping the segment travelled this step, which is what stops a fast round
 444    from stepping over a small target between frames.
 445    """
 446
 447    def __init__(
 448        self,
 449        *,
 450        weapon_id: str,
 451        kind: str,
 452        damage: float,
 453        direction: tuple[float, float],
 454        speed: float,
 455        reach: float,
 456        pierce: int = 0,
 457        blast: float = 0.0,
 458        homing_rate: float = 0.0,
 459        **kwargs,
 460    ):
 461        kwargs.setdefault("shape", SphereShape3D(radius=PROJECTILE_RADIUS))
 462        super().__init__(**kwargs)
 463        self.collision_layer = Layers.PLAYER_FIRE
 464        self.collision_mask = Layers.MASK_PLAYER_FIRE
 465        self.weapon_id = weapon_id
 466        self.kind = kind
 467        self.damage = float(damage)
 468        self.speed = float(speed)
 469        self.reach = float(reach)
 470        self.pierce = int(pierce)
 471        self.blast = float(blast)
 472        self.homing_rate = float(homing_rate)
 473        self._direction = _normalise(direction[0], direction[1])
 474        self._travelled = 0.0
 475        self._struck: set[int] = set()
 476
 477    def on_enter_tree(self):
 478        super().on_enter_tree()
 479        self.add_to_group(Groups.PLAYER_PROJECTILES)
 480
 481    def on_ready(self):
 482        tracer = Material(colour=PROJECTILE_COLOUR, emissive_colour=PROJECTILE_COLOUR[:3], emissive_strength=3.0)
 483        self.add_child(
 484            MeshInstance3D(
 485                name="Tracer",
 486                mesh=Mesh.sphere(radius=PROJECTILE_RADIUS, rings=6, segments=8),
 487                material=tracer,
 488            )
 489        )
 490        self.body_entered.connect(self._on_body_entered)
 491
 492    def on_fixed_update(self, dt: float):
 493        if self.destroying:
 494            return
 495        if self.homing_rate > 0.0:
 496            self._steer(dt)
 497        step = min(self.speed * dt, self.reach - self._travelled)
 498        if step <= 0.0:
 499            self._expire(detonate=False)
 500            return
 501        origin = _plane_of(self)
 502        if self._resolve(origin, step):
 503            return
 504        self.position = Vec3(
 505            origin[0] + self._direction[0] * step,
 506            PLANE_Y,
 507            origin[1] + self._direction[1] * step,
 508        )
 509        self._travelled += step
 510
 511    def _steer(self, dt: float):
 512        """Turn toward the nearest hostile, limited by the homing rate."""
 513        here = _plane_of(self)
 514        nearest = None
 515        best = float("inf")
 516        for node in _hostiles(self.tree):
 517            tx, tz = _plane_of(node)
 518            distance = math.hypot(tx - here[0], tz - here[1])
 519            if distance < best:
 520                best, nearest = distance, node
 521        if nearest is None:
 522            return
 523        tx, tz = _plane_of(nearest)
 524        wanted = _normalise(tx - here[0], tz - here[1])
 525        cross = self._direction[0] * wanted[1] - self._direction[1] * wanted[0]
 526        dot = max(-1.0, min(1.0, self._direction[0] * wanted[0] + self._direction[1] * wanted[1]))
 527        turn = max(-self.homing_rate * dt, min(self.homing_rate * dt, math.acos(dot) * (1.0 if cross >= 0.0 else -1.0)))
 528        self._direction = _normalise(*_rotate(self._direction[0], self._direction[1], turn))
 529
 530    def _resolve(self, origin: tuple[float, float], step: float) -> bool:
 531        """Damage everything the step crosses. True when the round is spent."""
 532        for distance, target, centre in _sweep(
 533            self.tree, origin, self._direction, step, PROJECTILE_RADIUS, Groups.ENEMIES, Groups.HUNTER
 534        ):
 535            if id(target) in self._struck:
 536                continue
 537            self._struck.add(id(target))
 538            _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
 539            if self.pierce <= 0:
 540                self.position = Vec3(
 541                    origin[0] + self._direction[0] * distance,
 542                    PLANE_Y,
 543                    origin[1] + self._direction[1] * distance,
 544                )
 545                self._expire(detonate=True)
 546                return True
 547            self.pierce -= 1
 548        return False
 549
 550    def _on_body_entered(self, other):
 551        """Terrain stops a round where it hits, blast and all."""
 552        if int(getattr(other, "collision_layer", 0)) & Layers.TERRAIN:
 553            self._expire(detonate=True)
 554
 555    def _expire(self, *, detonate: bool):
 556        if self.destroying:
 557            return
 558        if detonate and self.blast > 0.0:
 559            self._detonate()
 560        self.remove_from_group(Groups.PLAYER_PROJECTILES)
 561        self.destroy()
 562
 563    def _detonate(self):
 564        """Splash damage on everything in the blast that the round did not hit.
 565
 566        A shoal caught by the blast takes it across the whole radius in one
 567        call, which is the burst clearing several rows at once.
 568        """
 569        centre = _plane_of(self)
 570        for target in _hostiles(self.tree):
 571            if id(target) in self._struck:
 572                continue
 573            if _is_swarm(target):
 574                _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
 575                continue
 576            tx, tz = _plane_of(target)
 577            if math.hypot(tx - centre[0], tz - centre[1]) <= self.blast + _target_radius(target):
 578                _deal(self.tree, target, self.damage, self.kind, self._direction)
 579
 580
 581# ============================================================================
 582# Weapons
 583# ============================================================================
 584
 585
 586class Weapon(Node3D):
 587    """One mounted weapon: cadence, cost and the shape of what it fires.
 588
 589    A weapon is inert until something drives it. :meth:`set_trigger` holds the
 590    trigger down (the rack does this from the input actions every frame) and
 591    :meth:`try_fire` demands a shot right now, returning whether one left the
 592    muzzle. Beams report whether they are currently burning.
 593    """
 594
 595    def __init__(self, weapon_id: str, **kwargs):
 596        if weapon_id not in balance.WEAPONS:
 597            raise ValueError(f"unknown weapon id {weapon_id!r}")
 598        kwargs.setdefault("name", weapon_id)
 599        super().__init__(**kwargs)
 600        self.weapon_id = weapon_id
 601        self.spec = balance.WEAPONS[weapon_id]
 602        self.profile = FIRE_PROFILES[weapon_id]
 603        self.rack: WeaponRack | None = None
 604        self._trigger = False
 605        self._cooldown = 0.0
 606        self._aim_point = Vec3(0.0, PLANE_Y, 0.0)
 607        self._beam_active = False
 608        self._beam: Node3D | None = None
 609        self._beam_mesh: MeshInstance3D | None = None
 610        self._beam_spot: MeshInstance3D | None = None
 611        self._beam_hit = False
 612        self._beam_length = 0.0
 613        self._spark_wait = 0.0
 614        self._tracer_left = 0.0
 615        self._hacking: set[int] = set()
 616
 617    # -- the mount --------------------------------------------------------
 618
 619    def on_ready(self):
 620        self.add_child(self._build_mount())
 621
 622    def _build_mount(self) -> Node3D:
 623        """The gun as it sits on the hull: a breech block and a barrel.
 624
 625        Without this a hardpoint is an empty transform, so buying a weapon
 626        changes the ship's fire and nothing about the ship, and a pilot has no
 627        way to see which hardpoint carries what. The barrel runs along local
 628        -Z, which is the hull's nose, and its length and bore come off the
 629        family, so a rail lance and a pulse blaster are told apart in
 630        silhouette. Geometry from engine primitives, as the beam and the tracer
 631        already are; ``artkit`` would be the tidier home for it once it grows a
 632        player-weapon builder.
 633        """
 634        family = self.spec.family
 635        length = MOUNT_BARREL_LENGTH[family]
 636        radius = MOUNT_BARREL_RADIUS[family]
 637        mount = Node3D(name="Mount")
 638        mount.add_child(
 639            MeshInstance3D(
 640                name="Breech",
 641                mesh=Mesh.cube(size=MOUNT_BREECH_SIZE),
 642                material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.8, roughness=0.45),
 643            )
 644        )
 645        mount.add_child(
 646            MeshInstance3D(
 647                name="Barrel",
 648                mesh=Mesh.cylinder(radius=radius, height=length, segments=10),
 649                material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.85, roughness=0.35),
 650                rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 651                position=Vec3(0.0, 0.0, -length * 0.5),
 652            )
 653        )
 654        accent = MOUNT_ACCENT_COLOURS[family]
 655        mount.add_child(
 656            MeshInstance3D(
 657                name="Muzzle",
 658                mesh=Mesh.sphere(radius=radius * 1.25, rings=6, segments=8),
 659                material=Material(colour=accent, emissive_colour=accent[:3], emissive_strength=MOUNT_ACCENT_STRENGTH),
 660                position=Vec3(0.0, 0.0, -length),
 661            )
 662        )
 663        return mount
 664
 665    # -- description ------------------------------------------------------
 666
 667    @property
 668    def trigger_action(self) -> str:
 669        """The input action this weapon answers."""
 670        return MINING_ACTION if self.weapon_id in TRIGGER_MINING else PRIMARY_ACTION
 671
 672    @property
 673    def damage_per_projectile(self) -> float:
 674        """DPS divided evenly across a second's worth of pellets."""
 675        shots_per_second = self.profile.rate * self.profile.pellets
 676        if self.profile.beam or shots_per_second <= 0.0:
 677            return 0.0
 678        return self.spec.dps / shots_per_second
 679
 680    @property
 681    def beam_active(self) -> bool:
 682        """Whether a beam weapon is burning right now."""
 683        return self._beam_active
 684
 685    # -- driving ----------------------------------------------------------
 686
 687    def set_trigger(self, held: bool) -> None:
 688        """Hold or release the trigger; beams and automatics keep going."""
 689        self._trigger = bool(held)
 690
 691    def set_aim(self, world_point: Vec3) -> None:
 692        """The flight-plane point every shot converges on."""
 693        self._aim_point = Vec3(world_point)
 694
 695    def try_fire(self, aim_point: Vec3) -> bool:
 696        """Fire one shot at *aim_point* if the weapon is ready and can pay.
 697
 698        Beams engage instead of firing a shot and report whether they burn.
 699        """
 700        self.set_aim(aim_point)
 701        if self.profile.beam:
 702            self._trigger = True
 703            return self._beam_active
 704        if self._cooldown > 0.0 or not self._pay_for_shot():
 705            return False
 706        self._cooldown = 1.0 / self.profile.rate
 707        self._discharge()
 708        self._announce_fired()
 709        return True
 710
 711    def on_update(self, dt: float):
 712        self._cooldown = max(0.0, self._cooldown - dt)
 713        if self.profile.beam:
 714            self._tick_beam(dt)
 715            return
 716        if self._trigger:
 717            self.try_fire(self._aim_point)
 718        if self._tracer_left > 0.0:
 719            self._tracer_left -= dt
 720            if self._tracer_left <= 0.0 and self._beam is not None:
 721                self._beam.visible = False
 722                self._beam_hit = False
 723
 724    # -- payment (overridden by the two economies) ------------------------
 725
 726    def _pay_for_shot(self) -> bool:
 727        return True
 728
 729    def _pay_for_beam(self, dt: float) -> bool:
 730        return True
 731
 732    def _power(self):
 733        return self.tree.singletons.get(Services.POWER) if self.tree is not None else None
 734
 735    def _free_fire(self) -> bool:
 736        return bool(self.rack is not None and self.rack.free_fire)
 737
 738    # -- discharge --------------------------------------------------------
 739
 740    def _aim_direction(self) -> tuple[float, float]:
 741        muzzle = _plane_of(self)
 742        aim = self._aim_point
 743        return _normalise(float(aim[0]) - muzzle[0], float(aim[2]) - muzzle[1])
 744
 745    def _discharge(self) -> None:
 746        """Put one trigger pull's worth of shots into the world."""
 747        base = self._aim_direction()
 748        pellets = self.profile.pellets
 749        for index in range(pellets):
 750            offset = 0.0
 751            if pellets > 1 and self.profile.spread > 0.0:
 752                offset = self.profile.spread * (2.0 * index / (pellets - 1) - 1.0)
 753            direction = _rotate(base[0], base[1], offset)
 754            if self.profile.speed <= 0.0:
 755                self._hitscan(direction)
 756            else:
 757                self._spawn_projectile(direction)
 758
 759    def _hitscan(self, direction: tuple[float, float]) -> None:
 760        """Resolve an instant shot and leave a tracer along the line it took."""
 761        origin = _plane_of(self)
 762        allowed = self.profile.pierce + 1
 763        reach = self.profile.reach
 764        hit = False
 765        for distance, target, centre in _sweep(
 766            self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.HUNTER
 767        ):
 768            _deal(self.tree, target, self.damage_per_projectile, self.spec.family, direction, at=centre)
 769            reach, hit = distance, True
 770            allowed -= 1
 771            if allowed <= 0:
 772                break
 773        self._show_beam(direction, reach, hit=hit, full_bore=True)
 774        self._tracer_left = HITSCAN_TRACER_S
 775
 776    def _spawn_projectile(self, direction: tuple[float, float]) -> None:
 777        if self.tree is None or self.tree.root is None:
 778            return
 779        muzzle = _plane_of(self)
 780        self.tree.root.add_child(
 781            Projectile(
 782                name=f"{self.weapon_id}_round",
 783                weapon_id=self.weapon_id,
 784                kind=self.spec.family,
 785                damage=self.damage_per_projectile,
 786                direction=direction,
 787                speed=self.profile.speed,
 788                reach=self.profile.reach,
 789                pierce=self.profile.pierce,
 790                blast=self.profile.blast,
 791                homing_rate=self.profile.homing_rate,
 792                position=Vec3(muzzle[0], PLANE_Y, muzzle[1]),
 793            )
 794        )
 795
 796    # -- beams ------------------------------------------------------------
 797
 798    def _tick_beam(self, dt: float) -> None:
 799        if not self._trigger or not self._pay_for_beam(dt):
 800            self._end_beam()
 801            return
 802        if not self._beam_active:
 803            self._beam_active = True
 804            self._announce_fired()
 805        self._beam_damage(dt)
 806
 807    def _beam_damage(self, dt: float) -> None:
 808        origin = _plane_of(self)
 809        direction = self._aim_direction()
 810        reach = self.profile.reach
 811        hit = False
 812        struck = _sweep(self.tree, origin, direction, reach, 0.0, Groups.ENEMIES, Groups.HUNTER)
 813        if struck:
 814            distance, target, centre = struck[0]
 815            reach, hit = min(reach, distance), True
 816            _deal(self.tree, target, self.spec.dps * dt, self.spec.family, direction, at=centre)
 817        if self.profile.mines:
 818            mined = self._mine(origin, direction, dt)
 819            hit = hit or mined < self.profile.reach
 820            reach = min(reach, mined)
 821        if not hit:
 822            reach = self.profile.reach * BEAM_MISS_REACH_FRACTION
 823        self._show_beam(direction, reach, hit=hit)
 824        self._tick_sparks(dt, origin, direction, reach, hit)
 825
 826    def _mine(self, origin: tuple[float, float], direction: tuple[float, float], dt: float) -> float:
 827        """Work the beam against the surfaces it can reach; returns the reach used.
 828
 829        Deposits take the mining multiplier rather than the raw beam DPS, and a
 830        vault held in the beam starts its hack channel once. A wreck's hull is
 831        neither, but it is solid: the beam stops on it rather than passing
 832        through, because a beam that draws straight through a hulk is telling
 833        the pilot the hulk is not there.
 834        """
 835        reach = self.profile.reach
 836        padding = BEAM_CONTACT_PADDING
 837        deposits = _sweep(self.tree, origin, direction, reach, padding, Groups.DEPOSITS)
 838        if deposits:
 839            distance, deposit, _centre = deposits[0]
 840            reach = min(reach, distance)
 841            deposit.mine(self.spec.dps * balance.MINING_LASER_DEPOSIT_MULT, dt)
 842        vaults = _sweep(self.tree, origin, direction, reach, padding, Groups.VAULTS)
 843        if vaults:
 844            distance, vault, _centre = vaults[0]
 845            reach = min(reach, distance)
 846            if id(vault) not in self._hacking:
 847                self._hacking.add(id(vault))
 848                vault.begin_hack()
 849        wrecks = _sweep(self.tree, origin, direction, reach, padding, Groups.WRECKS)
 850        if wrecks:
 851            reach = min(reach, wrecks[0][0])
 852        return reach
 853
 854    @property
 855    def beam_on_target(self) -> bool:
 856        """Whether the beam currently drawn is standing on a surface."""
 857        return self._beam_hit
 858
 859    @property
 860    def beam_reach(self) -> float:
 861        """How far the beam currently drawn actually travels, world units."""
 862        return self._beam_length
 863
 864    def _tick_sparks(
 865        self,
 866        dt: float,
 867        origin: tuple[float, float],
 868        direction: tuple[float, float],
 869        reach: float,
 870        hit: bool,
 871    ) -> None:
 872        """Throw chips off the contact point while the beam is biting something.
 873
 874        A beam in vacuum throws none, so the sparks and their absence are the
 875        same message read twice: the pilot who cannot tell a thinner beam from a
 876        fatter one can still tell a shower from an empty line.
 877        """
 878        if not hit or vfx is None or self.tree is None:
 879            self._spark_wait = 0.0
 880            return
 881        self._spark_wait -= dt
 882        if self._spark_wait > 0.0:
 883            return
 884        self._spark_wait = BEAM_SPARK_INTERVAL_S
 885        contact = Vec3(origin[0] + direction[0] * reach, PLANE_Y, origin[1] + direction[1] * reach)
 886        vfx.Vfx.spawn(self.tree, "beam_spark", contact, direction=Vec3(-direction[0], 0.0, -direction[1]))
 887
 888    def _show_beam(
 889        self,
 890        direction: tuple[float, float],
 891        length: float,
 892        *,
 893        hit: bool = True,
 894        full_bore: bool | None = None,
 895    ) -> None:
 896        """Draw the beam out to *length*, saying whether it found anything.
 897
 898        A held beam that ends on a surface is at full bore, warm, and capped
 899        with a bead of light on the thing it is cutting; one that ends in vacuum
 900        is thin and grey. The two must not be told apart by the scrap counter
 901        alone.
 902
 903        *full_bore* separates the two questions for the tracer a hitscan round
 904        leaves behind, which is a slug's own trace rather than a beam standing
 905        on something: it is drawn hot whether or not the slug connected, and
 906        only the bead answers to *hit*.
 907        """
 908        if self._beam is None:
 909            self._build_beam()
 910        hot = hit if full_bore is None else bool(full_bore)
 911        self._beam.visible = True
 912        self._beam.face_along(Vec3(direction[0], 0.0, direction[1]))
 913        width = 1.0 if hot else BEAM_MISS_WIDTH
 914        self._beam_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
 915        self._beam_mesh.scale = Vec3(width, max(length, 1e-3), width)
 916        material = self._beam_mesh.material
 917        colour = BEAM_COLOUR if hot else BEAM_MISS_COLOUR
 918        material.colour = colour
 919        material.emissive_colour = colour[:3]
 920        material.emissive_strength = BEAM_EMISSIVE_HIT if hot else BEAM_EMISSIVE_MISS
 921        self._beam_spot.position = Vec3(0.0, 0.0, -length)
 922        self._beam_spot.visible = hit
 923        self._beam_hit = hit
 924        self._beam_length = float(length)
 925
 926    def _build_beam(self) -> None:
 927        self._beam = self.add_child(Node3D(name="Beam"))
 928        self._beam_mesh = self._beam.add_child(
 929            MeshInstance3D(
 930                name="Core",
 931                mesh=Mesh.cylinder(radius=BEAM_RADIUS, height=1.0, segments=8),
 932                material=Material(
 933                    colour=BEAM_COLOUR,
 934                    emissive_colour=BEAM_COLOUR[:3],
 935                    emissive_strength=BEAM_EMISSIVE_HIT,
 936                ),
 937                rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 938            )
 939        )
 940        self._beam_spot = self._beam.add_child(
 941            MeshInstance3D(
 942                name="Contact",
 943                mesh=Mesh.sphere(radius=BEAM_RADIUS * BEAM_SPOT_RADIUS_MULT, rings=6, segments=10),
 944                material=Material(
 945                    colour=BEAM_COLOUR,
 946                    emissive_colour=BEAM_COLOUR[:3],
 947                    emissive_strength=BEAM_EMISSIVE_HIT,
 948                ),
 949            )
 950        )
 951        self._beam_spot.visible = False
 952
 953    def _end_beam(self) -> None:
 954        self._beam_active = False
 955        self._beam_hit = False
 956        self._spark_wait = 0.0
 957        self._hacking.clear()
 958        if self._beam is not None:
 959            self._beam.visible = False
 960        if self._beam_spot is not None:
 961            self._beam_spot.visible = False
 962
 963    # -- signals ----------------------------------------------------------
 964
 965    def _announce_fired(self) -> None:
 966        if self.rack is not None:
 967            self.rack.weapon_fired(self.weapon_id)
 968
 969
 970class EnergyWeapon(Weapon):
 971    """Pulse blaster, scatter coil, arc beam, mining laser and nova mortar.
 972
 973    Fed entirely by the capacitor through ``PowerSystem``; no magazine, no
 974    ammo box, no scrap. Last Stand waives the cost without waiving the shot.
 975    """
 976
 977    def _energy_per_shot(self) -> float:
 978        if self.spec.energy_per_shot is not None:
 979            return float(self.spec.energy_per_shot)
 980        return PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
 981
 982    def _energy_per_second(self) -> float:
 983        if self.spec.energy_per_s is not None:
 984            return float(self.spec.energy_per_s)
 985        return 0.0
 986
 987    def _pay_for_shot(self) -> bool:
 988        cost = self._energy_per_shot()
 989        power = self._power()
 990        if cost <= 0.0 or power is None or self._free_fire():
 991            return True
 992        return bool(power.request(cost, self.weapon_id))
 993
 994    def _pay_for_beam(self, dt: float) -> bool:
 995        cost = self._energy_per_second()
 996        power = self._power()
 997        if cost <= 0.0 or power is None or self._free_fire():
 998            return True
 999        return bool(power.drain(cost, dt, self.weapon_id))
1000
1001
1002class BallisticWeapon(Weapon):
1003    """Rail lance, flak cannon, autocannon and missile rack.
1004
1005    Fed by a magazine that ammo boxes refill and by nothing else: a ballistic
1006    weapon costs zero capacitor, which is what lets a scrap-fed build leave the
1007    power economy altogether. An empty magazine swaps a box in automatically
1008    when the rack holds one, at the price of a reload lockout.
1009    """
1010
1011    def __init__(self, weapon_id: str, **kwargs):
1012        super().__init__(weapon_id, **kwargs)
1013        self.magazine_size = int(self.spec.rounds_per_box or 0)
1014        self.rounds = self.magazine_size
1015
1016    def reload_from_box(self) -> bool:
1017        """Consume one of the rack's boxes to refill the magazine."""
1018        if self.rounds >= self.magazine_size or self.rack is None:
1019            return False
1020        if not self.rack.take_ammo_box(self.weapon_id):
1021            return False
1022        self.rounds = self.magazine_size
1023        self._announce_ammo()
1024        return True
1025
1026    def _pay_for_shot(self) -> bool:
1027        needed = self.profile.rounds_per_shot
1028        if self.rounds < needed:
1029            if self.reload_from_box():
1030                self._cooldown = RELOAD_S
1031            return False
1032        self.rounds -= needed
1033        self._announce_ammo()
1034        return True
1035
1036    def _announce_ammo(self) -> None:
1037        if self.rack is not None:
1038            self.rack.ammo_changed(self.weapon_id, self.rounds)
1039
1040
1041class GravHook(Weapon):
1042    """The utility hardpoint: yanks one enemy or salvage mote toward the ship.
1043
1044    Deals no damage at all. What it moves, it moves on the flight plane at a
1045    fixed speed for a fixed time, so a mote pulled out of a Skimmer's path and
1046    a Lancer pulled out of its dash both read the same way.
1047    """
1048
1049    def __init__(self, weapon_id: str = "grav_hook", **kwargs):
1050        super().__init__(weapon_id, **kwargs)
1051        self._hooked: list[tuple[Node3D, float]] = []
1052
1053    def _pay_for_shot(self) -> bool:
1054        cost = PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
1055        power = self._power()
1056        if cost <= 0.0 or power is None or self._free_fire():
1057            return True
1058        return bool(power.request(cost, self.weapon_id))
1059
1060    def _discharge(self) -> None:
1061        origin = _plane_of(self)
1062        direction = self._aim_direction()
1063        caught = _sweep(
1064            self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.SALVAGE, Groups.HUNTER
1065        )
1066        # A swarm has no single body to grab: its members are array rows that
1067        # do not follow the shoal node.
1068        for _distance, target, _centre in caught:
1069            if not _is_swarm(target):
1070                self._hooked.append((target, GRAV_HOOK_PULL_S))
1071                return
1072
1073    def on_update(self, dt: float):
1074        super().on_update(dt)
1075        self._pull(dt)
1076
1077    def _pull(self, dt: float) -> None:
1078        if not self._hooked:
1079            return
1080        anchor = self._anchor()
1081        still: list[tuple[Node3D, float]] = []
1082        for target, remaining in self._hooked:
1083            if target.destroying or remaining <= 0.0:
1084                continue
1085            tx, tz = _plane_of(target)
1086            towards = _normalise(anchor[0] - tx, anchor[1] - tz)
1087            step = GRAV_HOOK_PULL_SPEED * dt
1088            target.position = Vec3(
1089                float(target.position[0]) + towards[0] * step,
1090                PLANE_Y,
1091                float(target.position[2]) + towards[1] * step,
1092            )
1093            still.append((target, remaining - dt))
1094        self._hooked = still
1095
1096    def _anchor(self) -> tuple[float, float]:
1097        """Where hooked things are dragged to: the ship, or the hook itself."""
1098        ships = _group_nodes(self.tree, Groups.SHIP)
1099        return _plane_of(ships[0]) if ships else _plane_of(self)
1100
1101
1102#: Which class mounts each roster entry.
1103_WEAPON_CLASSES = {"energy": EnergyWeapon, "ballistic": BallisticWeapon, "utility": GravHook}
1104
1105
1106def make_weapon(weapon_id: str, **kwargs) -> Weapon:
1107    """Build the weapon node for a roster id, picking the class by family."""
1108    if weapon_id not in balance.WEAPONS:
1109        raise ValueError(f"unknown weapon id {weapon_id!r}")
1110    return _WEAPON_CLASSES[balance.WEAPONS[weapon_id].family](weapon_id, **kwargs)
1111
1112
1113# ============================================================================
1114# The rack
1115# ============================================================================
1116
1117
1118class WeaponRack(Node3D):
1119    """The ship's hardpoints, what is bolted to them, and the ammo locker.
1120
1121    The rack is the single place other systems connect to for fire and ammo
1122    events, and the single place the two fire actions are read. It drives its
1123    weapons every frame: aim first, then trigger state, so a held button keeps
1124    an automatic firing and a released one drops a beam within the frame.
1125
1126    It is a ``Node3D`` rather than a bare ``Node`` because the weapons hanging
1127    off it are positioned hardpoints and must inherit the hull's transform.
1128    """
1129
1130    #: ``(weapon_id)`` whenever a shot leaves a muzzle or a beam engages.
1131    weapon_fired = Signal(str)
1132    #: ``(weapon_id, rounds)`` after every round spent and every reload.
1133    ammo_changed = Signal(str, int)
1134    #: ``(weapon_id, hardpoint)`` when a hardpoint's fitting changes.
1135    weapon_equipped = Signal(str, int)
1136
1137    def __init__(self, hardpoints: int = balance.HARDPOINTS_STARTER, **kwargs):
1138        kwargs.setdefault("name", "Hardpoints")
1139        super().__init__(**kwargs)
1140        self.hardpoints: list[Weapon | None] = [None] * self._validated_count(hardpoints)
1141        self._boxes: dict[str, int] = {}
1142        self._aim_point: Vec3 | None = None
1143        self._triggers = {PRIMARY_ACTION: False, MINING_ACTION: False}
1144        #: When True the rack reads the fire actions itself. Turrets and other
1145        #: non-player mounts set it False and drive the triggers directly.
1146        self.player_controlled = True
1147        #: Last Stand waives energy costs; ammo is still spent.
1148        self.free_fire = False
1149
1150    @staticmethod
1151    def _validated_count(hardpoints: int) -> int:
1152        if not 1 <= int(hardpoints) <= balance.HARDPOINTS_MAX:
1153            raise ValueError(f"hardpoint count must be 1 to {balance.HARDPOINTS_MAX}, got {hardpoints}")
1154        return int(hardpoints)
1155
1156    def on_enter_tree(self):
1157        super().on_enter_tree()
1158        self.add_to_group(WEAPON_RACK_GROUP)
1159
1160    # -- fitting ----------------------------------------------------------
1161
1162    def equip(self, weapon_id: str, hardpoint: int) -> None:
1163        """Bolt *weapon_id* to *hardpoint*, replacing whatever was there."""
1164        self._check_hardpoint(hardpoint)
1165        self.unequip(hardpoint)
1166        offset = HARDPOINT_OFFSETS[hardpoint % len(HARDPOINT_OFFSETS)]
1167        weapon = make_weapon(weapon_id, position=Vec3(offset[0], PLANE_Y, offset[1]))
1168        weapon.rack = self
1169        self.hardpoints[hardpoint] = self.add_child(weapon)
1170        self.weapon_equipped(weapon_id, hardpoint)
1171
1172    def unequip(self, hardpoint: int) -> None:
1173        """Strip a hardpoint back to bare mounting."""
1174        self._check_hardpoint(hardpoint)
1175        fitted = self.hardpoints[hardpoint]
1176        if fitted is not None:
1177            self.hardpoints[hardpoint] = None
1178            fitted.rack = None
1179            fitted.destroy()
1180
1181    def swap_in(self, weapon_id: str, hardpoint: int) -> str:
1182        """Fit *weapon_id* to *hardpoint* and return whatever it displaced.
1183
1184        The empty string when the hardpoint was bare. The caller owns the
1185        locker, so a swap is one call here and one list edit there rather than
1186        two half-authoritative copies of what the ship is carrying; the run
1187        scene puts the returned id back into its stowage.
1188        """
1189        self._check_hardpoint(hardpoint)
1190        fitted = self.hardpoints[hardpoint]
1191        displaced = fitted.weapon_id if fitted is not None else ""
1192        self.equip(weapon_id, hardpoint)
1193        return displaced
1194
1195    def free_hardpoint(self) -> int | None:
1196        """The lowest bare hardpoint, or None when every mount is taken.
1197
1198        The same choice :meth:`equip` is given when a purchase lands, so a
1199        shelf can name the hardpoint a weapon would take before it is bought.
1200        """
1201        for index, fitted in enumerate(self.hardpoints):
1202            if fitted is None:
1203                return index
1204        return None
1205
1206    def fit_lines(self) -> list[str]:
1207        """One line per hardpoint, for a screen that shows the current fit.
1208
1209        Bare mounts are listed as bare, because the question a pilot asks in a
1210        shop is as often "where would this go?" as "what have I got?". A
1211        ballistic mount also carries its magazine and its spare boxes, which is
1212        the number that decides whether the next purchase is a gun or a box.
1213        """
1214        lines: list[str] = []
1215        for index, fitted in enumerate(self.hardpoints):
1216            head = f"hardpoint {index + 1}"
1217            if fitted is None:
1218                lines.append(f"{head}: empty")
1219                continue
1220            name = fitted.weapon_id.replace("_", " ").upper()
1221            line = f"{head}: {name}, {weapon_summary(fitted.weapon_id)}"
1222            if isinstance(fitted, BallisticWeapon):
1223                line += f", {fitted.rounds}/{fitted.magazine_size} loaded, {self.ammo_boxes(fitted.weapon_id)} boxes"
1224            lines.append(line)
1225        return lines
1226
1227    def add_hardpoint(self) -> int:
1228        """Open one more hardpoint, up to the hull maximum, and return its index."""
1229        if len(self.hardpoints) >= balance.HARDPOINTS_MAX:
1230            raise ValueError(f"a hull carries at most {balance.HARDPOINTS_MAX} hardpoints")
1231        self.hardpoints.append(None)
1232        return len(self.hardpoints) - 1
1233
1234    def weapons(self) -> list[Weapon]:
1235        """Every fitted weapon, in hardpoint order."""
1236        return [w for w in self.hardpoints if w is not None]
1237
1238    def _check_hardpoint(self, hardpoint: int) -> None:
1239        if not 0 <= hardpoint < len(self.hardpoints):
1240            raise ValueError(f"hardpoint {hardpoint} does not exist (rack has {len(self.hardpoints)})")
1241
1242    # -- ammunition -------------------------------------------------------
1243
1244    def ammo_boxes(self, weapon_id: str) -> int:
1245        """Spare boxes held for *weapon_id*."""
1246        return self._boxes.get(weapon_id, 0)
1247
1248    def add_ammo_box(self, weapon_id: str) -> None:
1249        """Stow one box; boxes are per weapon, as the magazines are."""
1250        self._require_ballistic(weapon_id)
1251        self._boxes[weapon_id] = self._boxes.get(weapon_id, 0) + 1
1252
1253    def take_ammo_box(self, weapon_id: str) -> bool:
1254        """Consume one stowed box. False when the locker is empty."""
1255        held = self._boxes.get(weapon_id, 0)
1256        if held <= 0:
1257            return False
1258        self._boxes[weapon_id] = held - 1
1259        return True
1260
1261    def ammo_eater(self) -> str | None:
1262        """The mounted ballistic weapon a bought box feeds: the hungriest one.
1263
1264        None when nothing on the hull takes boxes, which is what makes a box a
1265        refusable purchase rather than scrap spent on a locker with no gun.
1266        """
1267        ballistic = [weapon for weapon in self.weapons() if weapon.spec.family == "ballistic"]
1268        if not ballistic:
1269            return None
1270        return min(ballistic, key=lambda weapon: self.ammo_boxes(weapon.weapon_id)).weapon_id
1271
1272    def locker(self) -> dict[str, int]:
1273        """Every non-empty box count by weapon id, for the suspend save."""
1274        return {weapon_id: held for weapon_id, held in self._boxes.items() if held > 0}
1275
1276    @staticmethod
1277    def _require_ballistic(weapon_id: str) -> None:
1278        spec = balance.WEAPONS.get(weapon_id)
1279        if spec is None or spec.family != "ballistic":
1280            raise ValueError(f"{weapon_id!r} does not take ammo boxes")
1281
1282    # -- driving ----------------------------------------------------------
1283
1284    def set_aim(self, world_point: Vec3) -> None:
1285        """The flight-plane point every fitted weapon converges on."""
1286        self._aim_point = Vec3(world_point)
1287
1288    def set_primary_trigger(self, held: bool) -> None:
1289        self._triggers[PRIMARY_ACTION] = bool(held)
1290
1291    def set_mining_trigger(self, held: bool) -> None:
1292        self._triggers[MINING_ACTION] = bool(held)
1293
1294    def on_update(self, dt: float):
1295        if self.player_controlled:
1296            self.set_primary_trigger(Input.is_action_pressed(PRIMARY_ACTION))
1297            self.set_mining_trigger(Input.is_action_pressed(MINING_ACTION))
1298        aim = self._aim_point if self._aim_point is not None else self._default_aim()
1299        for weapon in self.weapons():
1300            weapon.set_aim(aim)
1301            weapon.set_trigger(self._triggers[weapon.trigger_action])
1302
1303    def _default_aim(self) -> Vec3:
1304        """Straight down the hull's nose when nothing has set an aim point."""
1305        heading = float(getattr(self.parent, "heading", 0.0))
1306        here = _plane_of(self)
1307        return Vec3(here[0] + math.cos(heading) * 10.0, PLANE_Y, here[1] - math.sin(heading) * 10.0)
1308
1309
1310def attach_rack(ship: Node, hardpoints: int = balance.HARDPOINTS_STARTER, loadout: tuple[str, ...] = ()) -> WeaponRack:
1311    """Mount a rack on *ship* and fit *loadout* to its hardpoints in order."""
1312    rack = ship.add_child(WeaponRack(hardpoints=hardpoints))
1313    for index, weapon_id in enumerate(loadout[:hardpoints]):
1314        rack.equip(weapon_id, index)
1315    return rack