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 it
  16  sets one, 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# A mining laser that drew the same full-length bar of light whether it was
 117# cutting a vein or crossing empty space would leave a scrap counter that ticks
 118# twice a second as the only way to find out whether the beam was on anything,
 119# and a pilot could sweep it across a whole sector without ever learning what
 120# it was for. So the beam ends where it bites, marks the bite, and thins out
 121# 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        out.extend(tree.group(group, Node3D))
 353    return out
 354
 355
 356def _sweep(
 357    tree,
 358    origin: tuple[float, float],
 359    direction: tuple[float, float],
 360    length: float,
 361    padding: float,
 362    *groups: str,
 363) -> list[tuple[float, Node3D, tuple[float, float]]]:
 364    """Every group member the segment crosses, nearest first.
 365
 366    Each hit is ``(distance, node, centre)``. *centre* is the plane centre of
 367    the body that was actually struck, which for a swarm is the member's own
 368    position rather than the shoal node's: a shoal is tested against each of
 369    its live members, so it reports the distance at which the segment reaches
 370    its nearest mite, and the damage that follows has to land on that mite
 371    rather than on the shoal's centre of mass.
 372    """
 373    hits: list[tuple[float, Node3D, tuple[float, float]]] = []
 374    for node in _group_nodes(tree, *groups):
 375        radius = _target_radius(node) + padding
 376        if _is_swarm(node):
 377            reached = [
 378                (distance, member)
 379                for distance, member in (
 380                    (_segment_circle_distance(origin, direction, length, member, radius), member)
 381                    for member in _swarm_members(node)
 382                )
 383                if distance is not None
 384            ]
 385            if not reached:
 386                continue
 387            distance, centre = min(reached, key=lambda pair: pair[0])
 388        else:
 389            centre = _plane_of(node)
 390            distance = _segment_circle_distance(origin, direction, length, centre, radius)
 391            if distance is None:
 392                continue
 393        hits.append((distance, node, centre))
 394    hits.sort(key=lambda hit: hit[0])
 395    return hits
 396
 397
 398def _hostiles(tree) -> list[Node3D]:
 399    """Everything player fire is allowed to damage."""
 400    return _group_nodes(tree, Groups.ENEMIES, Groups.HUNTER)
 401
 402
 403def _deal(
 404    tree,
 405    target: Node3D,
 406    amount: float,
 407    kind: str,
 408    direction: tuple[float, float],
 409    at: tuple[float, float] | None = None,
 410    splash: float = 0.0,
 411) -> None:
 412    """Hand a damage number to the router, the only code that applies one.
 413
 414    A swarm is the one exception, and it is an interface gap rather than a
 415    choice: ``DamageRouter.deal`` takes a node, and a shoal's members are array
 416    rows at their own positions, so damage that landed at a point is applied
 417    through the shoal's own ``damage_at`` instead. *at* is therefore the struck
 418    member's own centre, not the point on its surface the round touched: the
 419    sweep pads a target's radius by the projectile's, and a burst centred on
 420    that padded surface would fall entirely outside the member it just hit.
 421    """
 422    if at is not None and _is_swarm(target):
 423        target.damage_at(Vec3(at[0], PLANE_Y, at[1]), max(splash, _target_radius(target)), amount, kind)
 424        return
 425    router = tree.singletons.get(Services.DAMAGE) if tree is not None else None
 426    if router is None:
 427        return
 428    router.deal(target, amount, kind=kind, direction=Vec3(direction[0], 0.0, direction[1]))
 429
 430
 431# ============================================================================
 432# Projectiles
 433# ============================================================================
 434
 435
 436class Projectile(Area3D):
 437    """One round in flight on the plane.
 438
 439    The sensor body carries the ``Layers.PLAYER_FIRE`` contract so terrain and
 440    anything else watching player fire sees the round; damage is resolved by
 441    sweeping the segment travelled this step, which is what stops a fast round
 442    from stepping over a small target between frames.
 443    """
 444
 445    def __init__(
 446        self,
 447        *,
 448        weapon_id: str,
 449        kind: str,
 450        damage: float,
 451        direction: tuple[float, float],
 452        speed: float,
 453        reach: float,
 454        pierce: int = 0,
 455        blast: float = 0.0,
 456        homing_rate: float = 0.0,
 457        **kwargs,
 458    ):
 459        kwargs.setdefault("shape", SphereShape3D(radius=PROJECTILE_RADIUS))
 460        super().__init__(**kwargs)
 461        self.collision_layer = Layers.PLAYER_FIRE
 462        self.collision_mask = Layers.MASK_PLAYER_FIRE
 463        self.weapon_id = weapon_id
 464        self.kind = kind
 465        self.damage = float(damage)
 466        self.speed = float(speed)
 467        self.reach = float(reach)
 468        self.pierce = int(pierce)
 469        self.blast = float(blast)
 470        self.homing_rate = float(homing_rate)
 471        self._direction = _normalise(direction[0], direction[1])
 472        self._travelled = 0.0
 473        self._struck: set[int] = set()
 474
 475    def on_enter_tree(self):
 476        super().on_enter_tree()
 477        self.add_to_group(Groups.PLAYER_PROJECTILES)
 478
 479    def on_ready(self):
 480        tracer = Material(colour=PROJECTILE_COLOUR, emissive_colour=PROJECTILE_COLOUR[:3], emissive_strength=3.0)
 481        self.add_child(
 482            MeshInstance3D(
 483                name="Tracer",
 484                mesh=Mesh.sphere(radius=PROJECTILE_RADIUS, rings=6, segments=8),
 485                material=tracer,
 486            )
 487        )
 488        self.body_entered.connect(self._on_body_entered)
 489
 490    def on_fixed_update(self, dt: float):
 491        if self.destroying:
 492            return
 493        if self.homing_rate > 0.0:
 494            self._steer(dt)
 495        step = min(self.speed * dt, self.reach - self._travelled)
 496        if step <= 0.0:
 497            self._expire(detonate=False)
 498            return
 499        origin = _plane_of(self)
 500        if self._resolve(origin, step):
 501            return
 502        self.position = Vec3(
 503            origin[0] + self._direction[0] * step,
 504            PLANE_Y,
 505            origin[1] + self._direction[1] * step,
 506        )
 507        self._travelled += step
 508
 509    def _steer(self, dt: float):
 510        """Turn toward the nearest hostile, limited by the homing rate."""
 511        here = _plane_of(self)
 512        nearest = None
 513        best = float("inf")
 514        for node in _hostiles(self.tree):
 515            tx, tz = _plane_of(node)
 516            distance = math.hypot(tx - here[0], tz - here[1])
 517            if distance < best:
 518                best, nearest = distance, node
 519        if nearest is None:
 520            return
 521        tx, tz = _plane_of(nearest)
 522        wanted = _normalise(tx - here[0], tz - here[1])
 523        cross = self._direction[0] * wanted[1] - self._direction[1] * wanted[0]
 524        dot = max(-1.0, min(1.0, self._direction[0] * wanted[0] + self._direction[1] * wanted[1]))
 525        turn = max(-self.homing_rate * dt, min(self.homing_rate * dt, math.acos(dot) * (1.0 if cross >= 0.0 else -1.0)))
 526        self._direction = _normalise(*_rotate(self._direction[0], self._direction[1], turn))
 527
 528    def _resolve(self, origin: tuple[float, float], step: float) -> bool:
 529        """Damage everything the step crosses. True when the round is spent."""
 530        for distance, target, centre in _sweep(
 531            self.tree, origin, self._direction, step, PROJECTILE_RADIUS, Groups.ENEMIES, Groups.HUNTER
 532        ):
 533            if id(target) in self._struck:
 534                continue
 535            self._struck.add(id(target))
 536            _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
 537            if self.pierce <= 0:
 538                self.position = Vec3(
 539                    origin[0] + self._direction[0] * distance,
 540                    PLANE_Y,
 541                    origin[1] + self._direction[1] * distance,
 542                )
 543                self._expire(detonate=True)
 544                return True
 545            self.pierce -= 1
 546        return False
 547
 548    def _on_body_entered(self, other):
 549        """Terrain stops a round where it hits, blast and all."""
 550        if int(getattr(other, "collision_layer", 0)) & Layers.TERRAIN:
 551            self._expire(detonate=True)
 552
 553    def _expire(self, *, detonate: bool):
 554        if self.destroying:
 555            return
 556        if detonate and self.blast > 0.0:
 557            self._detonate()
 558        self.remove_from_group(Groups.PLAYER_PROJECTILES)
 559        self.destroy()
 560
 561    def _detonate(self):
 562        """Splash damage on everything in the blast that the round did not hit.
 563
 564        A shoal caught by the blast takes it across the whole radius in one
 565        call, which is the burst clearing several rows at once.
 566        """
 567        centre = _plane_of(self)
 568        for target in _hostiles(self.tree):
 569            if id(target) in self._struck:
 570                continue
 571            if _is_swarm(target):
 572                _deal(self.tree, target, self.damage, self.kind, self._direction, at=centre, splash=self.blast)
 573                continue
 574            tx, tz = _plane_of(target)
 575            if math.hypot(tx - centre[0], tz - centre[1]) <= self.blast + _target_radius(target):
 576                _deal(self.tree, target, self.damage, self.kind, self._direction)
 577
 578
 579# ============================================================================
 580# Weapons
 581# ============================================================================
 582
 583
 584class Weapon(Node3D):
 585    """One mounted weapon: cadence, cost and the shape of what it fires.
 586
 587    A weapon is inert until something drives it. :meth:`set_trigger` holds the
 588    trigger down (the rack does this from the input actions every frame) and
 589    :meth:`try_fire` demands a shot right now, returning whether one left the
 590    muzzle. Beams report whether they are currently burning.
 591    """
 592
 593    def __init__(self, weapon_id: str, **kwargs):
 594        if weapon_id not in balance.WEAPONS:
 595            raise ValueError(f"unknown weapon id {weapon_id!r}")
 596        kwargs.setdefault("name", weapon_id)
 597        super().__init__(**kwargs)
 598        self.weapon_id = weapon_id
 599        self.spec = balance.WEAPONS[weapon_id]
 600        self.profile = FIRE_PROFILES[weapon_id]
 601        self.rack: WeaponRack | None = None
 602        self._trigger = False
 603        self._cooldown = 0.0
 604        self._aim_point = Vec3(0.0, PLANE_Y, 0.0)
 605        self._beam_active = False
 606        self._beam: Node3D | None = None
 607        self._beam_mesh: MeshInstance3D | None = None
 608        self._beam_spot: MeshInstance3D | None = None
 609        self._beam_hit = False
 610        self._beam_length = 0.0
 611        self._spark_wait = 0.0
 612        self._tracer_left = 0.0
 613        self._hacking: set[int] = set()
 614
 615    # -- the mount --------------------------------------------------------
 616
 617    def on_ready(self):
 618        self.add_child(self._build_mount())
 619
 620    def _build_mount(self) -> Node3D:
 621        """The gun as it sits on the hull: a breech block and a barrel.
 622
 623        Without this a hardpoint is an empty transform, so buying a weapon
 624        changes the ship's fire and nothing about the ship, and a pilot has no
 625        way to see which hardpoint carries what. The barrel runs along local
 626        -Z, which is the hull's nose, and its length and bore come off the
 627        family, so a rail lance and a pulse blaster are told apart in
 628        silhouette. Geometry from engine primitives, as the beam and the tracer
 629        already are; ``artkit`` would be the tidier home for it once it grows a
 630        player-weapon builder.
 631        """
 632        family = self.spec.family
 633        length = MOUNT_BARREL_LENGTH[family]
 634        radius = MOUNT_BARREL_RADIUS[family]
 635        mount = Node3D(name="Mount")
 636        mount.add_child(
 637            MeshInstance3D(
 638                name="Breech",
 639                mesh=Mesh.box(size=MOUNT_BREECH_SIZE),
 640                material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.8, roughness=0.45),
 641            )
 642        )
 643        mount.add_child(
 644            MeshInstance3D(
 645                name="Barrel",
 646                mesh=Mesh.cylinder(radius=radius, height=length, segments=10),
 647                material=Material(colour=MOUNT_METAL_COLOUR, metallic=0.85, roughness=0.35),
 648                rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 649                position=Vec3(0.0, 0.0, -length * 0.5),
 650            )
 651        )
 652        accent = MOUNT_ACCENT_COLOURS[family]
 653        mount.add_child(
 654            MeshInstance3D(
 655                name="Muzzle",
 656                mesh=Mesh.sphere(radius=radius * 1.25, rings=6, segments=8),
 657                material=Material(colour=accent, emissive_colour=accent[:3], emissive_strength=MOUNT_ACCENT_STRENGTH),
 658                position=Vec3(0.0, 0.0, -length),
 659            )
 660        )
 661        return mount
 662
 663    # -- description ------------------------------------------------------
 664
 665    @property
 666    def trigger_action(self) -> str:
 667        """The input action this weapon answers."""
 668        return MINING_ACTION if self.weapon_id in TRIGGER_MINING else PRIMARY_ACTION
 669
 670    @property
 671    def damage_per_projectile(self) -> float:
 672        """DPS divided evenly across a second's worth of pellets."""
 673        shots_per_second = self.profile.rate * self.profile.pellets
 674        if self.profile.beam or shots_per_second <= 0.0:
 675            return 0.0
 676        return self.spec.dps / shots_per_second
 677
 678    @property
 679    def beam_active(self) -> bool:
 680        """Whether a beam weapon is burning right now."""
 681        return self._beam_active
 682
 683    # -- driving ----------------------------------------------------------
 684
 685    def set_trigger(self, held: bool) -> None:
 686        """Hold or release the trigger; beams and automatics keep going."""
 687        self._trigger = bool(held)
 688
 689    def set_aim(self, world_point: Vec3) -> None:
 690        """The flight-plane point every shot converges on."""
 691        self._aim_point = Vec3(world_point)
 692
 693    def try_fire(self, aim_point: Vec3) -> bool:
 694        """Fire one shot at *aim_point* if the weapon is ready and can pay.
 695
 696        Beams engage instead of firing a shot and report whether they burn.
 697        """
 698        self.set_aim(aim_point)
 699        if self.profile.beam:
 700            self._trigger = True
 701            return self._beam_active
 702        if self._cooldown > 0.0 or not self._pay_for_shot():
 703            return False
 704        self._cooldown = 1.0 / self.profile.rate
 705        self._discharge()
 706        self._announce_fired()
 707        return True
 708
 709    def on_update(self, dt: float):
 710        self._cooldown = max(0.0, self._cooldown - dt)
 711        if self.profile.beam:
 712            self._tick_beam(dt)
 713            return
 714        if self._trigger:
 715            self.try_fire(self._aim_point)
 716        if self._tracer_left > 0.0:
 717            self._tracer_left -= dt
 718            if self._tracer_left <= 0.0 and self._beam is not None:
 719                self._beam.visible = False
 720                self._beam_hit = False
 721
 722    # -- payment (overridden by the two economies) ------------------------
 723
 724    def _pay_for_shot(self) -> bool:
 725        return True
 726
 727    def _pay_for_beam(self, dt: float) -> bool:
 728        return True
 729
 730    def _power(self):
 731        return self.tree.singletons.get(Services.POWER) if self.tree is not None else None
 732
 733    def _free_fire(self) -> bool:
 734        return bool(self.rack is not None and self.rack.free_fire)
 735
 736    # -- discharge --------------------------------------------------------
 737
 738    def _aim_direction(self) -> tuple[float, float]:
 739        muzzle = _plane_of(self)
 740        aim = self._aim_point
 741        return _normalise(float(aim[0]) - muzzle[0], float(aim[2]) - muzzle[1])
 742
 743    def _discharge(self) -> None:
 744        """Put one trigger pull's worth of shots into the world."""
 745        base = self._aim_direction()
 746        pellets = self.profile.pellets
 747        for index in range(pellets):
 748            offset = 0.0
 749            if pellets > 1 and self.profile.spread > 0.0:
 750                offset = self.profile.spread * (2.0 * index / (pellets - 1) - 1.0)
 751            direction = _rotate(base[0], base[1], offset)
 752            if self.profile.speed <= 0.0:
 753                self._hitscan(direction)
 754            else:
 755                self._spawn_projectile(direction)
 756
 757    def _hitscan(self, direction: tuple[float, float]) -> None:
 758        """Resolve an instant shot and leave a tracer along the line it took."""
 759        origin = _plane_of(self)
 760        allowed = self.profile.pierce + 1
 761        reach = self.profile.reach
 762        hit = False
 763        for distance, target, centre in _sweep(
 764            self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.HUNTER
 765        ):
 766            _deal(self.tree, target, self.damage_per_projectile, self.spec.family, direction, at=centre)
 767            reach, hit = distance, True
 768            allowed -= 1
 769            if allowed <= 0:
 770                break
 771        self._show_beam(direction, reach, hit=hit, full_bore=True)
 772        self._tracer_left = HITSCAN_TRACER_S
 773
 774    def _spawn_projectile(self, direction: tuple[float, float]) -> None:
 775        if self.tree is None or self.tree.root is None:
 776            return
 777        muzzle = _plane_of(self)
 778        self.tree.root.add_child(
 779            Projectile(
 780                name=f"{self.weapon_id}_round",
 781                weapon_id=self.weapon_id,
 782                kind=self.spec.family,
 783                damage=self.damage_per_projectile,
 784                direction=direction,
 785                speed=self.profile.speed,
 786                reach=self.profile.reach,
 787                pierce=self.profile.pierce,
 788                blast=self.profile.blast,
 789                homing_rate=self.profile.homing_rate,
 790                position=Vec3(muzzle[0], PLANE_Y, muzzle[1]),
 791            )
 792        )
 793
 794    # -- beams ------------------------------------------------------------
 795
 796    def _tick_beam(self, dt: float) -> None:
 797        if not self._trigger or not self._pay_for_beam(dt):
 798            self._end_beam()
 799            return
 800        if not self._beam_active:
 801            self._beam_active = True
 802            self._announce_fired()
 803        self._beam_damage(dt)
 804
 805    def _beam_damage(self, dt: float) -> None:
 806        origin = _plane_of(self)
 807        direction = self._aim_direction()
 808        reach = self.profile.reach
 809        hit = False
 810        struck = _sweep(self.tree, origin, direction, reach, 0.0, Groups.ENEMIES, Groups.HUNTER)
 811        if struck:
 812            distance, target, centre = struck[0]
 813            reach, hit = min(reach, distance), True
 814            _deal(self.tree, target, self.spec.dps * dt, self.spec.family, direction, at=centre)
 815        if self.profile.mines:
 816            mined = self._mine(origin, direction, dt)
 817            hit = hit or mined < self.profile.reach
 818            reach = min(reach, mined)
 819        if not hit:
 820            reach = self.profile.reach * BEAM_MISS_REACH_FRACTION
 821        self._show_beam(direction, reach, hit=hit)
 822        self._tick_sparks(dt, origin, direction, reach, hit)
 823
 824    def _mine(self, origin: tuple[float, float], direction: tuple[float, float], dt: float) -> float:
 825        """Work the beam against the surfaces it can reach; returns the reach used.
 826
 827        Deposits take the mining multiplier rather than the raw beam DPS, and a
 828        vault held in the beam starts its hack channel once. A wreck's hull is
 829        neither, but it is solid: the beam stops on it rather than passing
 830        through, because a beam that draws straight through a hulk is telling
 831        the pilot the hulk is not there.
 832        """
 833        reach = self.profile.reach
 834        padding = BEAM_CONTACT_PADDING
 835        deposits = _sweep(self.tree, origin, direction, reach, padding, Groups.DEPOSITS)
 836        if deposits:
 837            distance, deposit, _centre = deposits[0]
 838            reach = min(reach, distance)
 839            deposit.mine(self.spec.dps * balance.MINING_LASER_DEPOSIT_MULT, dt)
 840        vaults = _sweep(self.tree, origin, direction, reach, padding, Groups.VAULTS)
 841        if vaults:
 842            distance, vault, _centre = vaults[0]
 843            reach = min(reach, distance)
 844            if id(vault) not in self._hacking:
 845                self._hacking.add(id(vault))
 846                vault.begin_hack()
 847        wrecks = _sweep(self.tree, origin, direction, reach, padding, Groups.WRECKS)
 848        if wrecks:
 849            reach = min(reach, wrecks[0][0])
 850        return reach
 851
 852    @property
 853    def beam_on_target(self) -> bool:
 854        """Whether the beam currently drawn is standing on a surface."""
 855        return self._beam_hit
 856
 857    @property
 858    def beam_reach(self) -> float:
 859        """How far the beam currently drawn actually travels, world units."""
 860        return self._beam_length
 861
 862    def _tick_sparks(
 863        self,
 864        dt: float,
 865        origin: tuple[float, float],
 866        direction: tuple[float, float],
 867        reach: float,
 868        hit: bool,
 869    ) -> None:
 870        """Throw chips off the contact point while the beam is biting something.
 871
 872        A beam in vacuum throws none, so the sparks and their absence are the
 873        same message read twice: the pilot who cannot tell a thinner beam from a
 874        fatter one can still tell a shower from an empty line.
 875        """
 876        if not hit or vfx is None or self.tree is None:
 877            self._spark_wait = 0.0
 878            return
 879        self._spark_wait -= dt
 880        if self._spark_wait > 0.0:
 881            return
 882        self._spark_wait = BEAM_SPARK_INTERVAL_S
 883        contact = Vec3(origin[0] + direction[0] * reach, PLANE_Y, origin[1] + direction[1] * reach)
 884        vfx.Vfx.spawn(self.tree, "beam_spark", contact, direction=Vec3(-direction[0], 0.0, -direction[1]))
 885
 886    def _show_beam(
 887        self,
 888        direction: tuple[float, float],
 889        length: float,
 890        *,
 891        hit: bool = True,
 892        full_bore: bool | None = None,
 893    ) -> None:
 894        """Draw the beam out to *length*, saying whether it found anything.
 895
 896        A held beam that ends on a surface is at full bore, warm, and capped
 897        with a bead of light on the thing it is cutting; one that ends in vacuum
 898        is thin and grey. The two must not be told apart by the scrap counter
 899        alone.
 900
 901        *full_bore* separates the two questions for the tracer a hitscan round
 902        leaves behind, which is a slug's own trace rather than a beam standing
 903        on something: it is drawn hot whether or not the slug connected, and
 904        only the bead answers to *hit*.
 905        """
 906        if self._beam is None:
 907            self._build_beam()
 908        hot = hit if full_bore is None else bool(full_bore)
 909        self._beam.visible = True
 910        self._beam.face_along(Vec3(direction[0], 0.0, direction[1]))
 911        width = 1.0 if hot else BEAM_MISS_WIDTH
 912        self._beam_mesh.position = Vec3(0.0, 0.0, -length * 0.5)
 913        self._beam_mesh.scale = Vec3(width, max(length, 1e-3), width)
 914        material = self._beam_mesh.material
 915        colour = BEAM_COLOUR if hot else BEAM_MISS_COLOUR
 916        material.colour = colour
 917        material.emissive_colour = colour[:3]
 918        material.emissive_strength = BEAM_EMISSIVE_HIT if hot else BEAM_EMISSIVE_MISS
 919        self._beam_spot.position = Vec3(0.0, 0.0, -length)
 920        self._beam_spot.visible = hit
 921        self._beam_hit = hit
 922        self._beam_length = float(length)
 923
 924    def _build_beam(self) -> None:
 925        self._beam = self.add_child(Node3D(name="Beam"))
 926        self._beam_mesh = self._beam.add_child(
 927            MeshInstance3D(
 928                name="Core",
 929                mesh=Mesh.cylinder(radius=BEAM_RADIUS, height=1.0, segments=8),
 930                material=Material(
 931                    colour=BEAM_COLOUR,
 932                    emissive_colour=BEAM_COLOUR[:3],
 933                    emissive_strength=BEAM_EMISSIVE_HIT,
 934                ),
 935                rotation=Quat.from_euler(math.radians(90.0), 0.0, 0.0),
 936            )
 937        )
 938        self._beam_spot = self._beam.add_child(
 939            MeshInstance3D(
 940                name="Contact",
 941                mesh=Mesh.sphere(radius=BEAM_RADIUS * BEAM_SPOT_RADIUS_MULT, rings=6, segments=10),
 942                material=Material(
 943                    colour=BEAM_COLOUR,
 944                    emissive_colour=BEAM_COLOUR[:3],
 945                    emissive_strength=BEAM_EMISSIVE_HIT,
 946                ),
 947            )
 948        )
 949        self._beam_spot.visible = False
 950
 951    def _end_beam(self) -> None:
 952        self._beam_active = False
 953        self._beam_hit = False
 954        self._spark_wait = 0.0
 955        self._hacking.clear()
 956        if self._beam is not None:
 957            self._beam.visible = False
 958        if self._beam_spot is not None:
 959            self._beam_spot.visible = False
 960
 961    # -- signals ----------------------------------------------------------
 962
 963    def _announce_fired(self) -> None:
 964        if self.rack is not None:
 965            self.rack.weapon_fired.emit(self.weapon_id)
 966
 967
 968class EnergyWeapon(Weapon):
 969    """Pulse blaster, scatter coil, arc beam, mining laser and nova mortar.
 970
 971    Fed entirely by the capacitor through ``PowerSystem``; no magazine, no
 972    ammo box, no scrap. Last Stand waives the cost without waiving the shot.
 973    """
 974
 975    def _energy_per_shot(self) -> float:
 976        if self.spec.energy_per_shot is not None:
 977            return float(self.spec.energy_per_shot)
 978        return PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
 979
 980    def _energy_per_second(self) -> float:
 981        if self.spec.energy_per_s is not None:
 982            return float(self.spec.energy_per_s)
 983        return 0.0
 984
 985    def _pay_for_shot(self) -> bool:
 986        cost = self._energy_per_shot()
 987        power = self._power()
 988        if cost <= 0.0 or power is None or self._free_fire():
 989            return True
 990        return bool(power.request(cost, self.weapon_id))
 991
 992    def _pay_for_beam(self, dt: float) -> bool:
 993        cost = self._energy_per_second()
 994        power = self._power()
 995        if cost <= 0.0 or power is None or self._free_fire():
 996            return True
 997        return bool(power.drain(cost, dt, self.weapon_id))
 998
 999
1000class BallisticWeapon(Weapon):
1001    """Rail lance, flak cannon, autocannon and missile rack.
1002
1003    Fed by a magazine that ammo boxes refill and by nothing else: a ballistic
1004    weapon costs zero capacitor, which is what lets a scrap-fed build leave the
1005    power economy altogether. An empty magazine swaps a box in automatically
1006    when the rack holds one, at the price of a reload lockout.
1007    """
1008
1009    def __init__(self, weapon_id: str, **kwargs):
1010        super().__init__(weapon_id, **kwargs)
1011        self.magazine_size = int(self.spec.rounds_per_box or 0)
1012        self.rounds = self.magazine_size
1013
1014    def reload_from_box(self) -> bool:
1015        """Consume one of the rack's boxes to refill the magazine."""
1016        if self.rounds >= self.magazine_size or self.rack is None:
1017            return False
1018        if not self.rack.take_ammo_box(self.weapon_id):
1019            return False
1020        self.rounds = self.magazine_size
1021        self._announce_ammo()
1022        return True
1023
1024    def _pay_for_shot(self) -> bool:
1025        needed = self.profile.rounds_per_shot
1026        if self.rounds < needed:
1027            if self.reload_from_box():
1028                self._cooldown = RELOAD_S
1029            return False
1030        self.rounds -= needed
1031        self._announce_ammo()
1032        return True
1033
1034    def _announce_ammo(self) -> None:
1035        if self.rack is not None:
1036            self.rack.ammo_changed.emit(self.weapon_id, self.rounds)
1037
1038
1039class GravHook(Weapon):
1040    """The utility hardpoint: yanks one enemy or salvage mote toward the ship.
1041
1042    Deals no damage at all. What it moves, it moves on the flight plane at a
1043    fixed speed for a fixed time, so a mote pulled out of a Skimmer's path and
1044    a Lancer pulled out of its dash both read the same way.
1045    """
1046
1047    def __init__(self, weapon_id: str = "grav_hook", **kwargs):
1048        super().__init__(weapon_id, **kwargs)
1049        self._hooked: list[tuple[Node3D, float]] = []
1050
1051    def _pay_for_shot(self) -> bool:
1052        cost = PROVISIONAL_ENERGY_PER_SHOT.get(self.weapon_id, 0.0)
1053        power = self._power()
1054        if cost <= 0.0 or power is None or self._free_fire():
1055            return True
1056        return bool(power.request(cost, self.weapon_id))
1057
1058    def _discharge(self) -> None:
1059        origin = _plane_of(self)
1060        direction = self._aim_direction()
1061        caught = _sweep(
1062            self.tree, origin, direction, self.profile.reach, 0.0, Groups.ENEMIES, Groups.SALVAGE, Groups.HUNTER
1063        )
1064        # A swarm has no single body to grab: its members are array rows that
1065        # do not follow the shoal node.
1066        for _distance, target, _centre in caught:
1067            if not _is_swarm(target):
1068                self._hooked.append((target, GRAV_HOOK_PULL_S))
1069                return
1070
1071    def on_update(self, dt: float):
1072        super().on_update(dt)
1073        self._pull(dt)
1074
1075    def _pull(self, dt: float) -> None:
1076        if not self._hooked:
1077            return
1078        anchor = self._anchor()
1079        still: list[tuple[Node3D, float]] = []
1080        for target, remaining in self._hooked:
1081            if target.destroying or remaining <= 0.0:
1082                continue
1083            tx, tz = _plane_of(target)
1084            towards = _normalise(anchor[0] - tx, anchor[1] - tz)
1085            step = GRAV_HOOK_PULL_SPEED * dt
1086            target.position = Vec3(
1087                float(target.position[0]) + towards[0] * step,
1088                PLANE_Y,
1089                float(target.position[2]) + towards[1] * step,
1090            )
1091            still.append((target, remaining - dt))
1092        self._hooked = still
1093
1094    def _anchor(self) -> tuple[float, float]:
1095        """Where hooked things are dragged to: the ship, or the hook itself."""
1096        ships = _group_nodes(self.tree, Groups.SHIP)
1097        return _plane_of(ships[0]) if ships else _plane_of(self)
1098
1099
1100#: Which class mounts each roster entry.
1101_WEAPON_CLASSES = {"energy": EnergyWeapon, "ballistic": BallisticWeapon, "utility": GravHook}
1102
1103
1104def make_weapon(weapon_id: str, **kwargs) -> Weapon:
1105    """Build the weapon node for a roster id, picking the class by family."""
1106    if weapon_id not in balance.WEAPONS:
1107        raise ValueError(f"unknown weapon id {weapon_id!r}")
1108    return _WEAPON_CLASSES[balance.WEAPONS[weapon_id].family](weapon_id, **kwargs)
1109
1110
1111# ============================================================================
1112# The rack
1113# ============================================================================
1114
1115
1116class WeaponRack(Node3D):
1117    """The ship's hardpoints, what is bolted to them, and the ammo locker.
1118
1119    The rack is the single place other systems connect to for fire and ammo
1120    events, and the single place the two fire actions are read. It drives its
1121    weapons every frame: aim first, then trigger state, so a held button keeps
1122    an automatic firing and a released one drops a beam within the frame.
1123
1124    It is a ``Node3D`` rather than a bare ``Node`` because the weapons hanging
1125    off it are positioned hardpoints and must inherit the hull's transform.
1126    """
1127
1128    #: ``(weapon_id)`` whenever a shot leaves a muzzle or a beam engages.
1129    weapon_fired = Signal(str)
1130    #: ``(weapon_id, rounds)`` after every round spent and every reload.
1131    ammo_changed = Signal(str, int)
1132    #: ``(weapon_id, hardpoint)`` when a hardpoint's fitting changes.
1133    weapon_equipped = Signal(str, int)
1134
1135    def __init__(self, hardpoints: int = balance.HARDPOINTS_STARTER, **kwargs):
1136        kwargs.setdefault("name", "Hardpoints")
1137        super().__init__(**kwargs)
1138        self.hardpoints: list[Weapon | None] = [None] * self._validated_count(hardpoints)
1139        self._boxes: dict[str, int] = {}
1140        self._aim_point: Vec3 | None = None
1141        self._triggers = {PRIMARY_ACTION: False, MINING_ACTION: False}
1142        #: When True the rack reads the fire actions itself. Turrets and other
1143        #: non-player mounts set it False and drive the triggers directly.
1144        self.player_controlled = True
1145        #: Last Stand waives energy costs; ammo is still spent.
1146        self.free_fire = False
1147
1148    @staticmethod
1149    def _validated_count(hardpoints: int) -> int:
1150        if not 1 <= int(hardpoints) <= balance.HARDPOINTS_MAX:
1151            raise ValueError(f"hardpoint count must be 1 to {balance.HARDPOINTS_MAX}, got {hardpoints}")
1152        return int(hardpoints)
1153
1154    def on_enter_tree(self):
1155        super().on_enter_tree()
1156        self.add_to_group(WEAPON_RACK_GROUP)
1157
1158    # -- fitting ----------------------------------------------------------
1159
1160    def equip(self, weapon_id: str, hardpoint: int) -> None:
1161        """Bolt *weapon_id* to *hardpoint*, replacing whatever was there."""
1162        self._check_hardpoint(hardpoint)
1163        self.unequip(hardpoint)
1164        offset = HARDPOINT_OFFSETS[hardpoint % len(HARDPOINT_OFFSETS)]
1165        weapon = make_weapon(weapon_id, position=Vec3(offset[0], PLANE_Y, offset[1]))
1166        weapon.rack = self
1167        self.hardpoints[hardpoint] = self.add_child(weapon)
1168        self.weapon_equipped.emit(weapon_id, hardpoint)
1169
1170    def unequip(self, hardpoint: int) -> None:
1171        """Strip a hardpoint back to bare mounting."""
1172        self._check_hardpoint(hardpoint)
1173        fitted = self.hardpoints[hardpoint]
1174        if fitted is not None:
1175            self.hardpoints[hardpoint] = None
1176            fitted.rack = None
1177            fitted.destroy()
1178
1179    def swap_in(self, weapon_id: str, hardpoint: int) -> str:
1180        """Fit *weapon_id* to *hardpoint* and return whatever it displaced.
1181
1182        The empty string when the hardpoint was bare. The caller owns the
1183        locker, so a swap is one call here and one list edit there rather than
1184        two half-authoritative copies of what the ship is carrying; the run
1185        scene puts the returned id back into its stowage.
1186        """
1187        self._check_hardpoint(hardpoint)
1188        fitted = self.hardpoints[hardpoint]
1189        displaced = fitted.weapon_id if fitted is not None else ""
1190        self.equip(weapon_id, hardpoint)
1191        return displaced
1192
1193    def free_hardpoint(self) -> int | None:
1194        """The lowest bare hardpoint, or None when every mount is taken.
1195
1196        The same choice :meth:`equip` is given when a purchase lands, so a
1197        shelf can name the hardpoint a weapon would take before it is bought.
1198        """
1199        for index, fitted in enumerate(self.hardpoints):
1200            if fitted is None:
1201                return index
1202        return None
1203
1204    def fit_lines(self) -> list[str]:
1205        """One line per hardpoint, for a screen that shows the current fit.
1206
1207        Bare mounts are listed as bare, because the question a pilot asks in a
1208        shop is as often "where would this go?" as "what have I got?". A
1209        ballistic mount also carries its magazine and its spare boxes, which is
1210        the number that decides whether the next purchase is a gun or a box.
1211        """
1212        lines: list[str] = []
1213        for index, fitted in enumerate(self.hardpoints):
1214            head = f"hardpoint {index + 1}"
1215            if fitted is None:
1216                lines.append(f"{head}: empty")
1217                continue
1218            name = fitted.weapon_id.replace("_", " ").upper()
1219            line = f"{head}: {name}, {weapon_summary(fitted.weapon_id)}"
1220            if isinstance(fitted, BallisticWeapon):
1221                line += f", {fitted.rounds}/{fitted.magazine_size} loaded, {self.ammo_boxes(fitted.weapon_id)} boxes"
1222            lines.append(line)
1223        return lines
1224
1225    def add_hardpoint(self) -> int:
1226        """Open one more hardpoint, up to the hull maximum, and return its index."""
1227        if len(self.hardpoints) >= balance.HARDPOINTS_MAX:
1228            raise ValueError(f"a hull carries at most {balance.HARDPOINTS_MAX} hardpoints")
1229        self.hardpoints.append(None)
1230        return len(self.hardpoints) - 1
1231
1232    def weapons(self) -> list[Weapon]:
1233        """Every fitted weapon, in hardpoint order."""
1234        return [w for w in self.hardpoints if w is not None]
1235
1236    def _check_hardpoint(self, hardpoint: int) -> None:
1237        if not 0 <= hardpoint < len(self.hardpoints):
1238            raise ValueError(f"hardpoint {hardpoint} does not exist (rack has {len(self.hardpoints)})")
1239
1240    # -- ammunition -------------------------------------------------------
1241
1242    def ammo_boxes(self, weapon_id: str) -> int:
1243        """Spare boxes held for *weapon_id*."""
1244        return self._boxes.get(weapon_id, 0)
1245
1246    def add_ammo_box(self, weapon_id: str) -> None:
1247        """Stow one box; boxes are per weapon, as the magazines are."""
1248        self._require_ballistic(weapon_id)
1249        self._boxes[weapon_id] = self._boxes.get(weapon_id, 0) + 1
1250
1251    def take_ammo_box(self, weapon_id: str) -> bool:
1252        """Consume one stowed box. False when the locker is empty."""
1253        held = self._boxes.get(weapon_id, 0)
1254        if held <= 0:
1255            return False
1256        self._boxes[weapon_id] = held - 1
1257        return True
1258
1259    def ammo_eater(self) -> str | None:
1260        """The mounted ballistic weapon a bought box feeds: the hungriest one.
1261
1262        None when nothing on the hull takes boxes, which is what makes a box a
1263        refusable purchase rather than scrap spent on a locker with no gun.
1264        """
1265        ballistic = [weapon for weapon in self.weapons() if weapon.spec.family == "ballistic"]
1266        if not ballistic:
1267            return None
1268        return min(ballistic, key=lambda weapon: self.ammo_boxes(weapon.weapon_id)).weapon_id
1269
1270    def locker(self) -> dict[str, int]:
1271        """Every non-empty box count by weapon id, for the suspend save."""
1272        return {weapon_id: held for weapon_id, held in self._boxes.items() if held > 0}
1273
1274    @staticmethod
1275    def _require_ballistic(weapon_id: str) -> None:
1276        spec = balance.WEAPONS.get(weapon_id)
1277        if spec is None or spec.family != "ballistic":
1278            raise ValueError(f"{weapon_id!r} does not take ammo boxes")
1279
1280    # -- driving ----------------------------------------------------------
1281
1282    def set_aim(self, world_point: Vec3) -> None:
1283        """The flight-plane point every fitted weapon converges on."""
1284        self._aim_point = Vec3(world_point)
1285
1286    def set_primary_trigger(self, held: bool) -> None:
1287        self._triggers[PRIMARY_ACTION] = bool(held)
1288
1289    def set_mining_trigger(self, held: bool) -> None:
1290        self._triggers[MINING_ACTION] = bool(held)
1291
1292    def on_update(self, dt: float):
1293        if self.player_controlled:
1294            self.set_primary_trigger(Input.pressed(PRIMARY_ACTION))
1295            self.set_mining_trigger(Input.pressed(MINING_ACTION))
1296        aim = self._aim_point if self._aim_point is not None else self._default_aim()
1297        for weapon in self.weapons():
1298            weapon.set_aim(aim)
1299            weapon.set_trigger(self._triggers[weapon.trigger_action])
1300
1301    def _default_aim(self) -> Vec3:
1302        """Straight down the hull's nose when nothing has set an aim point."""
1303        heading = float(getattr(self.parent, "heading", 0.0))
1304        here = _plane_of(self)
1305        return Vec3(here[0] + math.cos(heading) * 10.0, PLANE_Y, here[1] - math.sin(heading) * 10.0)
1306
1307
1308def attach_rack(ship: Node, hardpoints: int = balance.HARDPOINTS_STARTER, loadout: tuple[str, ...] = ()) -> WeaponRack:
1309    """Mount a rack on *ship* and fit *loadout* to its hardpoints in order."""
1310    rack = ship.add_child(WeaponRack(hardpoints=hardpoints))
1311    for index, weapon_id in enumerate(loadout[:hardpoints]):
1312        rack.equip(weapon_id, index)
1313    return rack