shrike/sector.py¶

Part of SHRIKE.

   1"""What a warp-in drops you into: one sector's harvestable content.
   2
   3A sector is built from one seed and one biome, and every feature it places is
   4priced in noise, because loudness is the price of wealth:
   5
   6* **Mineral veins are back-loaded.** The surface 70 percent of a vein cracks
   7  fast; the last 30 percent is a core that only yields under a sustained beam
   8  and pays double per unit of ore. Nearly half a vein's value therefore sits
   9  behind the point at which a sensible pilot would already have left.
  10* **Wrecks mirror that shape** at the sector's scale: an outer bay of quick
  11  scrap anybody can grab, and a sealed vault behind a 20-second hack channel
  12  worth +15 signature and a sector's worth of scrap.
  13* **Fuel comets, ice chunks and vents** are the strategic refills the biome
  14  table inverts. An Ice Field is poor in scrap and thick with breathable ice, a
  15  Vent Field pays fuel and oxygen, a Nebula pays scrap and blinds the warning.
  16* **Scrap motes** are what everything else releases. Nothing credits the ledger
  17  on the spot: ore becomes glowing confetti you have to fly back through.
  18  :meth:`Sector.credit` is where a scooped mote becomes income, and for oxygen
  19  and fuel that means the run's tanks directly, not only a signal.
  20
  21A sector is also a bounded place, and it says so three ways rather than letting
  22a straight line end in a silent void: the dust thins and desaturates past
  23:data:`SECTOR_CONTENT_RADIUS`, crossing :data:`SECTOR_BOUNDARY_RADIUS` raises
  24:data:`BOUNDARY_NOTICE` until the pilot turns back, and
  25:meth:`Sector.compass_targets` keeps naming the nearest vein, wreck and depot
  26for the HUD's screen-edge markers.
  27
  28When the Shrike tears in it cracks the sector open. :meth:`Sector.crack_open`
  29shears every sealed vault and splits every core so it pays at surface speed
  30with no channel, which is why the safest loot in the sector appears right next
  31to the thing that wants you dead.
  32
  33Every one of those verbs announces itself. :meth:`Sector.affordance` resolves
  34the nearest thing the ship can act on into one short line naming the key and
  35the verb, which the HUD draws above the reticle, and each feature carries its
  36own state on its skin: a vein dims as its surface goes, its core lights up when
  37it is the only thing left, a spent body goes dark, a looted wreck's bay light
  38goes out, and a sealed vault glows until it is open. A pilot should never have
  39to guess whether a rock still has anything in it.
  40
  41Generation is deterministic in the seed: the same seed, biome and sector index
  42always produce the same layout, so a node's danger and its payout are both
  43learnable. Charted lanes run both ways, so the run can walk back into a place
  44it has already stripped; ``revisited=True`` deals that place again from the
  45same seed with its harvest gone (:data:`REVISIT_HARVEST_MULT`). Backtracking
  46buys the air and the fuel you flew past, never a fresh field of ore.
  47"""
  48
  49import math
  50import random
  51from dataclasses import dataclass
  52
  53from simvx.core import Input, Material, Mesh, MeshInstance3D, Node3D, Signal, Vec2, Vec3
  54
  55from . import balance, events
  56from .power import SignalWiring
  57from .runtime import PLANE_Y, Groups, Services, to_plane
  58
  59try:  # Geometry and the dust field come from the kitbash module when installed.
  60    from . import artkit, vfx
  61except ImportError:  # pragma: no cover - the art layer is optional here
  62    artkit = None
  63    vfx = None
  64
  65# ============================================================================
  66# Module-local tuning
  67#
  68# These are the numbers the design fixes in prose but balance.py does not yet
  69# carry: the sector's physical size, the shape of the mining curve, and the
  70# per-biome feature counts behind the biome table's resource inversions.
  71# ============================================================================
  72
  73#: Radius of the playable disc, in world units on the flight plane. Sized so a
  74#: sector reads as a place rather than as a void: at cruise its far edge is
  75#: about four seconds away, and the whole disc is a handful of screens.
  76SECTOR_RADIUS = 60.0
  77#: Nothing is placed inside this radius of the warp-in point.
  78SECTOR_SPAWN_CLEARANCE = 18.0
  79#: The first :data:`SECTOR_NEAR_FEATURES` placements are forced inside this
  80#: radius of the warp-in point. Scattering the whole sector by area alone is
  81#: what left a player warping into an empty screen with the nearest thing to
  82#: do a long dead flight away; something must be in view on arrival.
  83SECTOR_NEAR_RADIUS = 35.0
  84SECTOR_NEAR_FEATURES = 3
  85#: Where the sector's content stops: no feature is ever placed outside it.
  86SECTOR_CONTENT_RADIUS = SECTOR_RADIUS
  87#: Crossing this raises the leaving-sector cue and keeps it up until the pilot
  88#: turns back. The gap between it and the content radius is the shoulder a
  89#: pilot chasing a mote to the rim is allowed without being nagged.
  90SECTOR_BOUNDARY_RADIUS = SECTOR_RADIUS + 15.0
  91#: Where the dust has thinned as far as it goes. Flying a straight line used to
  92#: end in a field of grain identical to the one over the veins, which reads as
  93#: more sector; past here the grain is nearly gone and the picture says empty.
  94SECTOR_VOID_RADIUS = SECTOR_RADIUS + 90.0
  95#: What the dust keeps of its density and of its colour out in the void.
  96VOID_DUST_ALPHA_FLOOR = 0.15
  97VOID_DUST_SATURATION_FLOOR = 0.10
  98#: The cue itself. It names what is out there and the key that shows where to
  99#: go instead, because "you are leaving" on its own is only half an answer.
 100#: Run one has no chart, so its variant offers the drive instead: naming M to
 101#: a pilot whose M answers "the chart is offline" is a door painted on a wall.
 102BOUNDARY_NOTICE = "LEAVING SECTOR: nothing out here (M: chart)"
 103BOUNDARY_NOTICE_OFFLINE = "LEAVING SECTOR: nothing out here (R: warp on)"
 104
 105#: Kinds the sector offers the HUD's screen-edge compass, nearest first. The
 106#: depot is not among them: it carries the HUD's own persistent beacon, and a
 107#: thing with two markers on it is a thing the pilot counts twice.
 108COMPASS_DEPOSIT = "deposit"
 109COMPASS_WRECK = "wreck"
 110
 111#: Refill floats merge for this long after the last credit, so a vent pays one
 112#: label when the pilot leaves it rather than one per frame while they sit.
 113CREDIT_FLOAT_MERGE_S = 0.35
 114#: What each credited resource is called on its float label. Scrap is absent on
 115#: purpose: the run scene already floats its own merged "+N SCRAP" off the same
 116#: signal, and two labels for one mote is worse than none.
 117CREDIT_FLOAT_LABELS: dict[str, str] = {"o2": "O2", "fuel": "FUEL"}
 118
 119#: Minimum separation between two placed features.
 120SECTOR_MIN_SEPARATION = 14.0
 121#: Rejection-sampling attempts per feature before the separation rule is relaxed.
 122SECTOR_PLACEMENT_ATTEMPTS = 40
 123
 124#: The sector every run opens in. Its depot is guaranteed, whatever the chart's
 125#: edges rolled: see :data:`FIRST_SECTOR_DEPOT_RADIUS`.
 126FIRST_SECTOR_INDEX = 1
 127#: Where the opening sector's guaranteed depot is planted, as a radius from the
 128#: warp-in point. Inside the near band, so the beacon it carries points at
 129#: something a pilot can reach in the first minute rather than at the far rim.
 130#:
 131#: The guarantee exists because six recorded boots reached neither a depot, a
 132#: shop nor a refit in twenty-five minutes of play. Nothing was wrong with the
 133#: chart: a drift depot rides an edge, and the node a run starts on is the one
 134#: node nobody arrives at along an edge, so the opening sector could never have
 135#: one. A pilot therefore mined, banked nothing, and learned that scrap is a
 136#: number that goes up. The first sector is where the economy has to be taught,
 137#: so the first sector always has somewhere to spend.
 138FIRST_SECTOR_DEPOT_RADIUS = 26.0
 139
 140#: Radius of the ring drawn on the ground around the dock, in world units.
 141#: Pinned by test to ``trading.DOCK_RADIUS``, which is the radius the bay
 142#: actually docks at: a ring that is not the bay is a lie drawn on the floor.
 143#: Stated here rather than imported so that the sector, which places the
 144#: anchor, does not have to depend on the module that sells things at it.
 145#:
 146#: A blind pilot crossed a depot's bay three times without docking. The bay was
 147#: a number in a sensor and nothing in the world; the ring is that number drawn,
 148#: and it lights when the hull is inside it (:class:`~shrike.vfx.DockRing`).
 149DOCK_RING_RADIUS = 14.0
 150
 151#: What a vein says when the last of it comes out. The thinning beam already
 152#: reads as a vein running down, and the rock goes dark when it is spent, but
 153#: neither says the word: a blind playtest kept the beam on a dead rock because
 154#: nothing had told it the rock was finished.
 155TAPPED_OUT_LABEL = "TAPPED OUT"
 156
 157#: How big a vein is drawn, and therefore how big it is: a base body plus this
 158#: much per point of ore, so a rich vein is visibly the fatter target.
 159DEPOSIT_BASE_RADIUS = 1.4
 160DEPOSIT_RADIUS_PER_SCRAP = 0.02
 161#: A hulk's own extent, and the seal on it. Both are what a beam has to reach.
 162WRECK_HIT_RADIUS = 2.6
 163VAULT_HIT_RADIUS = 1.6
 164
 165
 166def deposit_radius(value: float) -> float:
 167    """How wide a vein worth *value* is drawn, in world units."""
 168    return DEPOSIT_BASE_RADIUS + DEPOSIT_RADIUS_PER_SCRAP * max(0.0, float(value))
 169
 170
 171#: Beam damage a vein absorbs per point of scrap it is worth. With the mining
 172#: laser at ``balance.MINING_LASER_DEPOSIT_MULT`` times its DPS, a 50-scrap
 173#: vein is a little under eight seconds of surface and a similar core.
 174DEPOSIT_WORK_PER_SCRAP = 8.0
 175#: The core is denser than the surface: this much more work per unit of ore.
 176DEPOSIT_CORE_HARDNESS = 1.6
 177#: Seconds of unbroken beam on an exposed core before it yields at full rate.
 178DEPOSIT_CORE_SUSTAIN_S = 4.0
 179#: Yield multiplier on a core the beam has only just touched.
 180DEPOSIT_CORE_COLD_RATE = 0.25
 181#: Losing the beam for longer than this cools the core back down.
 182DEPOSIT_BEAM_GRACE_S = 0.35
 183
 184#: Value spread across a sector's veins, as a multiplier on the even share.
 185DEPOSIT_VALUE_SPREAD = (0.7, 1.3)
 186#: A wreck's outer bay is worth this much of a vein's share of the budget.
 187WRECK_BAY_BUDGET_WEIGHT = 0.6
 188#: The design table calls the Wreck Graveyard "huge scrap"; ``BiomeSpec`` has
 189#: no field for it yet, so the multiplier lives here until balance.py grows one.
 190WRECK_GRAVEYARD_SCRAP_MULT = 1.5
 191
 192#: What a sector still holds the second time the run walks into it. Charted
 193#: lanes run both ways, so a node can be revisited, and a node that rebuilt
 194#: itself whole would make a 15-fuel round trip the cheapest ore in the game.
 195#: The harvest is the thing already taken: the veins come back thin and few,
 196#: the sealed vault does not come back at all, and the strategic refills come
 197#: back halved, because ice and fuel are the honest reason to fly back and
 198#: an empty return trip is no reason to have the lanes at all.
 199REVISIT_HARVEST_MULT = 0.2
 200REVISIT_FEATURE_CAP = 1
 201REVISIT_REFILL_MULT = 0.5
 202
 203#: Losing the hack channel for longer than this stalls it and it starts to slip.
 204VAULT_HACK_GRACE_S = 0.5
 205#: Hack progress lost per second while the channel is not being sustained.
 206VAULT_HACK_DECAY_PER_S = 0.02
 207#: How close the ship must stay for the hack channel to keep running. The
 208#: channel is a place you have to remain, not a beam you have to hold: holding
 209#: the mining laser on a seal for twenty seconds while a wave lands is not a
 210#: decision, it is a punishment, and the beam's own caller could only ever
 211#: announce the channel once per trigger anyway.
 212VAULT_HACK_RADIUS = 16.0
 213
 214#: Motes are collected by flying through them.
 215PICKUP_SCOOP_RADIUS = 4.5
 216#: A tap of ``interact`` grabs the nearest loose prize inside this radius.
 217PICKUP_GRAB_RADIUS = 14.0
 218#: Motes drift out of a broken vein at up to this speed, and slow to a stop.
 219PICKUP_DRIFT_SPEED = 4.0
 220PICKUP_DRIFT_DAMPING = 1.6
 221#: A single burst never spawns more than this many motes; the rest lump together.
 222PICKUP_BURST_MAX = 12
 223
 224#: How many distinct silhouettes artkit is asked for per feature type. Small on
 225#: purpose: artkit caches meshes by seed, so variety is bought once per sector.
 226VISUAL_SEED_POOL = 4
 227
 228#: Emissive strength of a vein's ore accent at full surface, once the core is
 229#: the only thing left, and once the body is spent. The middle value is a flare,
 230#: not a fade: an exposed core is the one moment a rock is worth more than it
 231#: looks, so it is the one moment it looks like more.
 232DEPOSIT_ACCENT_FULL = 1.0
 233DEPOSIT_ACCENT_CORE = 2.6
 234DEPOSIT_ACCENT_SPENT = 0.0
 235#: How dark a spent body's rock goes, as a multiplier on its colour.
 236DEPOSIT_SPENT_DIM = 0.45
 237#: Floor on the surface accent, so a nearly-stripped vein is still visible as a
 238#: thing in space rather than fading into the background before it is empty.
 239DEPOSIT_ACCENT_FLOOR = 0.25
 240
 241#: The wreck's bay light: on while the outer bay still holds anything, out the
 242#: moment it is emptied. It is the whole of "the wreck visibly empties".
 243WRECK_BAY_LIGHT_RADIUS = 0.55
 244WRECK_BAY_LIGHT_OFFSET = 2.2
 245WRECK_BAY_LIGHT_COLOUR = (0.95, 0.78, 0.35, 1.0)
 246WRECK_BAY_LIGHT_STRENGTH = 2.2
 247
 248#: A sealed vault glows; an opened one is a hole. These are the two states.
 249VAULT_SEALED_STRENGTH = 2.0
 250VAULT_OPEN_STRENGTH = 0.0
 251VAULT_OPEN_DIM = 0.35
 252
 253#: How far away the core prompt starts offering the beam. Set to the mining
 254#: laser's own reach in ``weapons.FIRE_PROFILES``, so the line appears exactly
 255#: where the beam would land: a prompt that promises a verb out of range is the
 256#: same defect as no prompt at all, one step further on.
 257AFFORDANCE_BEAM_RADIUS = 26.0
 258
 259#: The affordance lines themselves. Every one names its key and its verb, and
 260#: the vault's names its price, because twenty loud seconds is a decision.
 261PROMPT_GRAB = "F: GRAB"
 262PROMPT_LOOT_BAY = "F: GRAB SALVAGE"
 263PROMPT_HACK_VAULT = "F: HACK VAULT, 20 s, loud"
 264PROMPT_HACKING_VAULT = "HACKING VAULT: stay close, {seconds:.0f} s"
 265PROMPT_CRACK_CORE = "HOLD RMB: CRACK THE CORE"
 266
 267#: Value bands for one mote of each resource kind.
 268MOTE_VALUE_BANDS: dict[str, tuple[float, float]] = {
 269    "scrap": (float(balance.SCRAP_ORE_CHUNK_MIN), float(balance.SCRAP_ORE_CHUNK_MAX)),
 270    "fuel": (2.5, 5.0),
 271    "o2": (balance.ICE_CHUNK_O2 * 0.5, balance.ICE_CHUNK_O2),
 272    "ammo": (float(balance.AMMO_BOX_ROUNDS), float(balance.AMMO_BOX_ROUNDS)),
 273}
 274
 275#: A vent's reach, and how fast it gives up its charge while you sit in it.
 276VENT_RADIUS = 9.0
 277VENT_FUEL_PER_S = 2.0
 278VENT_O2_PER_S = 4.0
 279VENT_FUEL_CHARGE = balance.FUEL_COMET_MIN
 280VENT_O2_CHARGE = balance.ICE_CHUNK_O2 * 2.0
 281
 282
 283@dataclass(frozen=True)
 284class BiomeContent:
 285    """How many of each feature a biome places, as inclusive count ranges.
 286
 287    This is the biome table's resource inversion expressed as geography: an Ice
 288    Field is not poor in scrap because a multiplier says so, it is poor because
 289    it grew fewer veins and more ice.
 290    """
 291
 292    deposits: tuple[int, int]
 293    wrecks: tuple[int, int]
 294    fuel_comets: tuple[int, int]
 295    ice_chunks: tuple[int, int]
 296    vents: tuple[int, int]
 297    hazards: tuple[int, int] = (0, 0)
 298    extra_vaults: int = 0
 299    ammo_box_chance: float = 0.2
 300
 301
 302BIOME_CONTENT: dict[str, BiomeContent] = {
 303    "debris_field": BiomeContent(
 304        deposits=(3, 4), wrecks=(2, 3), fuel_comets=(0, 1), ice_chunks=(0, 1), vents=(0, 0), ammo_box_chance=0.6
 305    ),
 306    "solar_shallows": BiomeContent(
 307        deposits=(3, 5), wrecks=(1, 2), fuel_comets=(1, 2), ice_chunks=(0, 0), vents=(0, 0), ammo_box_chance=0.2
 308    ),
 309    "ice_field": BiomeContent(
 310        deposits=(2, 3), wrecks=(1, 2), fuel_comets=(0, 1), ice_chunks=(5, 8), vents=(0, 1), ammo_box_chance=0.1
 311    ),
 312    "nebula": BiomeContent(
 313        deposits=(4, 6), wrecks=(2, 3), fuel_comets=(0, 1), ice_chunks=(0, 1), vents=(0, 0), ammo_box_chance=0.2
 314    ),
 315    "wreck_graveyard": BiomeContent(
 316        deposits=(2, 3),
 317        wrecks=(5, 7),
 318        fuel_comets=(1, 2),
 319        ice_chunks=(0, 1),
 320        vents=(0, 0),
 321        hazards=(1, 2),
 322        extra_vaults=1,
 323        ammo_box_chance=0.5,
 324    ),
 325    "vent_field": BiomeContent(
 326        deposits=(2, 3),
 327        wrecks=(1, 2),
 328        fuel_comets=(2, 3),
 329        ice_chunks=(2, 3),
 330        vents=(3, 5),
 331        hazards=(3, 5),
 332        ammo_box_chance=0.1,
 333    ),
 334    "broker_claim": BiomeContent(
 335        deposits=(2, 3),
 336        wrecks=(2, 3),
 337        fuel_comets=(1, 1),
 338        ice_chunks=(0, 1),
 339        vents=(0, 1),
 340        extra_vaults=1,
 341        ammo_box_chance=0.3,
 342    ),
 343    "roost": BiomeContent(deposits=(0, 0), wrecks=(0, 0), fuel_comets=(0, 0), ice_chunks=(0, 0), vents=(0, 0)),
 344}
 345
 346#: Readability palette for the placeholder geometry: harvest is warm, refills
 347#: are cold, sealed prizes glow.
 348_FEATURE_COLOURS: dict[str, tuple[float, float, float, float]] = {
 349    "vein": (0.42, 0.35, 0.28, 1.0),
 350    "rich_vein": (0.62, 0.48, 0.24, 1.0),
 351    "comet": (0.30, 0.45, 0.55, 1.0),
 352    "ice": (0.55, 0.75, 0.85, 1.0),
 353    "wreck": (0.28, 0.30, 0.34, 1.0),
 354    "vault": (0.75, 0.62, 0.25, 1.0),
 355    "vent": (0.35, 0.28, 0.30, 1.0),
 356    "mote": (0.95, 0.78, 0.35, 1.0),
 357}
 358
 359
 360def _plane_distance(a: Vec2, b: Vec2) -> float:
 361    """Distance between two flight-plane points."""
 362    return math.hypot(float(a.x) - float(b.x), float(a.y) - float(b.y))
 363
 364
 365def _placeholder(kind: str, radius: float) -> MeshInstance3D:
 366    """A primitive stand-in so a sector is never invisible without artkit."""
 367    colour = _FEATURE_COLOURS.get(kind, (0.5, 0.5, 0.5, 1.0))
 368    emissive = kind in ("mote", "vault", "vent", "ice")
 369    material = Material(
 370        colour=colour,
 371        metallic=0.1 if emissive else 0.6,
 372        roughness=0.8,
 373        emissive_colour=colour[:3] if emissive else None,
 374    )
 375    return MeshInstance3D(name="Visual", mesh=Mesh.sphere(radius=radius, rings=8, segments=10), material=material)
 376
 377
 378#: The name artkit gives an emissive accent mesh. Looked up rather than imported
 379#: so a sector still skins its features when the art layer is not installed.
 380ART_ACCENT_NAME = getattr(artkit, "ROLE_ACCENT", "Accent")
 381
 382
 383def _mesh_instances(node: Node3D) -> list[MeshInstance3D]:
 384    """Every mesh under *node*, whichever layer built the geometry."""
 385    found: list[MeshInstance3D] = []
 386    stack = [node]
 387    while stack:
 388        current = stack.pop()
 389        if isinstance(current, MeshInstance3D):
 390            found.append(current)
 391        stack.extend(current.children)
 392    return found
 393
 394
 395def _set_emissive(mesh: MeshInstance3D | None, colour, strength: float) -> None:
 396    """Drive one mesh's emissive to *strength*, keeping *colour*'s hue."""
 397    material = getattr(mesh, "material", None)
 398    if material is None:
 399        return
 400    material.emissive_colour = (float(colour[0]), float(colour[1]), float(colour[2]), float(strength))
 401
 402
 403def _dim_albedo(mesh: MeshInstance3D | None, factor: float) -> None:
 404    """Scale one mesh's albedo toward black, keeping its alpha."""
 405    material = getattr(mesh, "material", None)
 406    if material is None:
 407        return
 408    colour = tuple(float(channel) for channel in material.colour)
 409    rgb = tuple(channel * float(factor) for channel in colour[:3])
 410    material.colour = (*rgb, colour[3] if len(colour) > 3 else 1.0)
 411
 412
 413@dataclass(frozen=True)
 414class Affordance:
 415    """One thing in reach, and the line that names the key that takes it.
 416
 417    *kind* is the verb id (``"grab"``, ``"bay"``, ``"vault"``, ``"hacking"``,
 418    ``"core"``), *target* the feature it belongs to, and *distance* how far the
 419    ship is from it. The HUD only ever draws :attr:`prompt`; the rest is for the
 420    interact handler, which must act on exactly what the prompt promised.
 421    """
 422
 423    prompt: str
 424    kind: str
 425    target: object
 426    distance: float
 427
 428
 429# ============================================================================
 430# Placed features
 431# ============================================================================
 432
 433
 434class SectorFeature(Node3D):
 435    """Base for everything a sector places, with a cached owning sector."""
 436
 437    def __init__(self, *, visual_seed: int = 0, **kwargs):
 438        super().__init__(**kwargs)
 439        self.visual_seed = int(visual_seed)
 440        self._sector: Sector | None = None
 441
 442    @property
 443    def sector(self) -> Sector | None:
 444        """The :class:`Sector` this feature belongs to, or None once detached."""
 445        if self._sector is None:
 446            node = self.parent
 447            while node is not None:
 448                if isinstance(node, Sector):
 449                    self._sector = node
 450                    break
 451                node = node.parent
 452        return self._sector
 453
 454    @property
 455    def plane_position(self) -> Vec2:
 456        """This feature's world position on the flight plane.
 457
 458        World rather than local, because a vault is a child of the wreck it is
 459        welded into and its local position is the keel, not the sector.
 460        """
 461        return to_plane(self.world_position)
 462
 463
 464class Deposit(SectorFeature):
 465    """A back-loaded ore body: a fast surface and a core that pays double.
 466
 467    Mining is work against two pools. The surface holds
 468    ``1 - balance.DEPOSIT_CORE_FRACTION`` of the ore and gives it up at the
 469    beam's face value. The core holds the rest, costs
 470    :data:`DEPOSIT_CORE_HARDNESS` times as much work per unit of ore, pays
 471    ``balance.DEPOSIT_CORE_PAYOUT_MULT`` times as much for it, and only reaches
 472    full yield after :data:`DEPOSIT_CORE_SUSTAIN_S` seconds of unbroken beam on
 473    the exposed core. Let the beam drop and the core cools back down, so a
 474    pilot who taps between dodges never banks the back half of a vein.
 475
 476    ``remaining_surface`` and ``remaining_core`` are beam-damage remaining, not
 477    scrap; :attr:`value_remaining` is the payout still in the rock.
 478
 479    All three of those states are on the rock's skin. The ore accent dims as
 480    the surface goes, flares to :data:`DEPOSIT_ACCENT_CORE` the moment the core
 481    is the only thing left (which is also when the crack-the-core prompt
 482    appears), and goes out entirely when the body is spent, with the rock
 483    itself darkened behind it. A pilot should be able to tell a stripped vein
 484    from a fresh one at a glance, from across the sector.
 485    """
 486
 487    def __init__(
 488        self,
 489        *,
 490        value: float = 50.0,
 491        rich: bool = False,
 492        resource: str = "scrap",
 493        core_fraction: float = balance.DEPOSIT_CORE_FRACTION,
 494        **kwargs,
 495    ):
 496        super().__init__(**kwargs)
 497        self.resource = resource
 498        self.rich = bool(rich)
 499        self.value = max(0.0, float(value))
 500        self.tapped = False
 501        self.split = False
 502        #: Radius a beam has to reach to be cutting this vein. It is the body's
 503        #: own drawn radius, because anything smaller means a beam laid across
 504        #: the middle of a rock passes through it and pays nothing, which is
 505        #: what a blind pilot read as "mining does not work".
 506        self.hit_radius = deposit_radius(self.value)
 507
 508        core_fraction = min(max(float(core_fraction), 0.0), 1.0)
 509        surface_fraction = 1.0 - core_fraction
 510        work = DEPOSIT_WORK_PER_SCRAP * self.value
 511        self.remaining_surface = work * surface_fraction
 512        self.remaining_core = work * core_fraction * DEPOSIT_CORE_HARDNESS
 513
 514        # Payout per unit of ore, set so surface plus core equals the vein's value
 515        # with the core paying its multiple.
 516        ore_units = surface_fraction + core_fraction * balance.DEPOSIT_CORE_PAYOUT_MULT
 517        per_ore = self.value / ore_units if ore_units > 0 else 0.0
 518        self._surface_pay = (per_ore * surface_fraction) / self.remaining_surface if self.remaining_surface > 0 else 0.0
 519        core_pay_total = per_ore * core_fraction * balance.DEPOSIT_CORE_PAYOUT_MULT
 520        self._core_pay = core_pay_total / self.remaining_core if self.remaining_core > 0 else 0.0
 521
 522        self._core_beam = 0.0
 523        self._idle = DEPOSIT_BEAM_GRACE_S
 524        self._bank = 0.0
 525
 526        self._surface_full = self.remaining_surface
 527        self._body: MeshInstance3D | None = None
 528        self._accent: MeshInstance3D | None = None
 529        self._accent_colour = (1.0, 0.82, 0.36)
 530        self._spent_shown = False
 531
 532    def on_enter_tree(self):
 533        super().on_enter_tree()
 534        self.add_to_group(Groups.DEPOSITS)
 535
 536    def on_ready(self):
 537        radius = self.hit_radius
 538        if artkit is not None and self.resource == "scrap":
 539            art = self.add_child(artkit.build_deposit(self.rich, self.visual_seed))
 540        else:
 541            kind = "comet" if self.resource == "fuel" else ("rich_vein" if self.rich else "vein")
 542            art = self.add_child(_placeholder(kind, radius))
 543        self._bind_skin(art)
 544        self.refresh_visual()
 545
 546    def _bind_skin(self, art: Node3D) -> None:
 547        """Find the two meshes this deposit shows its state on.
 548
 549        The kitbash deposit carries a separate ore accent; the placeholder is
 550        one sphere, which then plays both parts. Either way the accent's
 551        emissive is the state channel and the body's albedo is the "spent" one.
 552        """
 553        meshes = _mesh_instances(art)
 554        if not meshes:
 555            return
 556        accents = [mesh for mesh in meshes if mesh.name == ART_ACCENT_NAME]
 557        bodies = [mesh for mesh in meshes if mesh.name != ART_ACCENT_NAME]
 558        self._accent = accents[0] if accents else meshes[0]
 559        self._body = bodies[0] if bodies else meshes[0]
 560        material = getattr(self._accent, "material", None)
 561        emissive = getattr(material, "emissive_colour", None)
 562        if emissive is not None:
 563            self._accent_colour = tuple(float(channel) for channel in emissive[:3])
 564        elif material is not None:
 565            self._accent_colour = tuple(float(channel) for channel in tuple(material.colour)[:3])
 566
 567    def accent_strength(self) -> float:
 568        """Emissive strength the ore accent should currently be showing."""
 569        if self.depleted:
 570            return DEPOSIT_ACCENT_SPENT
 571        if self.core_exposed:
 572            return DEPOSIT_ACCENT_CORE
 573        if self._surface_full <= 0.0:
 574            return DEPOSIT_ACCENT_FULL
 575        left = max(0.0, self.remaining_surface) / self._surface_full
 576        return DEPOSIT_ACCENT_FLOOR + (DEPOSIT_ACCENT_FULL - DEPOSIT_ACCENT_FLOOR) * left
 577
 578    def refresh_visual(self) -> None:
 579        """Put the current state on the rock. Cheap enough to call per tick.
 580
 581        The frame the last ore comes out is also where the rock says so in
 582        words: the beam thinning and the body going dark are both true and both
 583        gradual, and a pilot who has been holding the trigger reads neither as
 584        "this one is finished". One label, once, where the vein is.
 585        """
 586        _set_emissive(self._accent, self._accent_colour, self.accent_strength())
 587        if not self.depleted or self._spent_shown:
 588            return
 589        self._spent_shown = True
 590        self._announce_spent()
 591        _dim_albedo(self._body, DEPOSIT_SPENT_DIM)
 592        if self._accent is not self._body:
 593            _dim_albedo(self._accent, DEPOSIT_SPENT_DIM)
 594        # A spent body is no longer a deposit as far as the beam is concerned.
 595        # Leaving it in the group left it clamping the mining laser's reach for
 596        # nothing, so a stripped vein went on blocking the one behind it.
 597        self.remove_from_group(Groups.DEPOSITS)
 598
 599    def _announce_spent(self) -> None:
 600        """Float one :data:`TAPPED_OUT_LABEL` over the vein that just ran out.
 601
 602        Only for a vein that was actually worked: a body the Shrike sheared or
 603        a deposit that was born empty has nothing to tell the pilot about their
 604        own beam. Credits nothing, so the label stays where the rock is instead
 605        of flying at a counter.
 606
 607        Planted at the rock's position rather than pinned to the rock. A spent
 608        vein does not move, so following it buys nothing, and the HUD steps a
 609        planted label around the ship's own readings while a pinned one has to
 610        stay on the thing it names: pinned, this one rose through ``SIGNATURE``
 611        on a rendered frame.
 612        """
 613        if not self.tapped:
 614            return
 615        sector = self.sector
 616        hud = sector._service(Services.HUD) if sector is not None else None
 617        if hud is None:
 618            return
 619        hud.float_text(TAPPED_OUT_LABEL, position=Vec3(self.world_position), toward="")
 620
 621    @property
 622    def depleted(self) -> bool:
 623        """True once neither pool has ore left."""
 624        return self.remaining_surface <= 0.0 and self.remaining_core <= 0.0
 625
 626    @property
 627    def core_exposed(self) -> bool:
 628        """True once the surface is gone and the core is the only thing left."""
 629        return self.remaining_surface <= 0.0 and self.remaining_core > 0.0
 630
 631    @property
 632    def value_remaining(self) -> float:
 633        """Payout still locked in the rock, in this deposit's resource."""
 634        return self.remaining_surface * self._surface_pay + self.remaining_core * self._core_pay
 635
 636    def core_yield_rate(self) -> float:
 637        """Current fraction of the core's full yield rate, from 0 to 1."""
 638        if self.split:
 639            return 1.0
 640        ramp = min(self._core_beam / DEPOSIT_CORE_SUSTAIN_S, 1.0) if DEPOSIT_CORE_SUSTAIN_S > 0 else 1.0
 641        return DEPOSIT_CORE_COLD_RATE + (1.0 - DEPOSIT_CORE_COLD_RATE) * ramp
 642
 643    def mine(self, dps: float, dt: float) -> float:
 644        """Apply *dps* of beam for *dt* seconds; returns the resource released.
 645
 646        Call once per frame while the beam holds. Released resource leaves as
 647        motes rather than crediting anything directly, so the return value is
 648        for feedback (the HUD's scrap ticker, the vfx spray), not for the
 649        ledger.
 650        """
 651        if self.depleted or dps <= 0.0 or dt <= 0.0:
 652            return 0.0
 653        self._idle = 0.0
 654        released = 0.0
 655        work = dps * dt
 656
 657        if self.remaining_surface > 0.0:
 658            used = min(work, self.remaining_surface)
 659            self.remaining_surface -= used
 660            work -= used
 661            released += used * self._surface_pay
 662
 663        if self.remaining_core > 0.0:
 664            if self.remaining_surface <= 0.0:
 665                self._core_beam += dt
 666            if work > 0.0:
 667                used = min(work * self.core_yield_rate(), self.remaining_core)
 668                self.remaining_core -= used
 669                released += used * self._core_pay
 670
 671        if released > 0.0 and not self.tapped:
 672            self.tapped = True
 673            sector = self.sector
 674            if sector is not None and self.rich:
 675                sector.report_rich_tap()
 676        self._bank_release(released)
 677        self.refresh_visual()
 678        return released
 679
 680    def split_core(self) -> None:
 681        """Shear the core open so it yields at surface speed with no sustain."""
 682        self.split = True
 683        self._core_beam = DEPOSIT_CORE_SUSTAIN_S
 684        self.refresh_visual()
 685
 686    def on_update(self, dt: float):
 687        self._idle += dt
 688        if self._idle > DEPOSIT_BEAM_GRACE_S and not self.split:
 689            self._core_beam = 0.0
 690
 691    def _bank_release(self, amount: float) -> None:
 692        """Hold released ore back until it is worth a mote, then spawn one."""
 693        if amount <= 0.0:
 694            return
 695        self._bank += amount
 696        sector = self.sector
 697        if sector is None:
 698            return
 699        threshold = MOTE_VALUE_BANDS.get(self.resource, (1.0, 1.0))[0]
 700        if self._bank >= threshold or (self.depleted and self._bank > 0.0):
 701            sector.release(self.resource, self._bank, self.plane_position)
 702            self._bank = 0.0
 703
 704
 705class FuelComet(Deposit):
 706    """A frozen fuel body. All surface, no core, and mining one is loud."""
 707
 708    def __init__(self, *, value: float = balance.FUEL_COMET_MIN, **kwargs):
 709        super().__init__(value=value, rich=True, resource="fuel", core_fraction=0.0, **kwargs)
 710
 711
 712class Vault(SectorFeature):
 713    """A sealed prize behind a hack channel that announces you while it runs.
 714
 715    The channel is started by a tap of ``interact`` inside
 716    :data:`VAULT_HACK_RADIUS` (or by the mining beam touching the seal, which
 717    calls :meth:`begin_hack` the same way), and it then runs for
 718    ``balance.VAULT_HACK_CHANNEL_S`` for as long as the ship stays inside that
 719    radius. Fly away and it stalls and slips back; see it through and the vault
 720    pays ``balance.SCRAP_VAULT_MIN`` to ``MAX`` scrap and
 721    ``balance.SIGNATURE_VAULT_HACK`` signature.
 722
 723    Proximity rather than a held beam is what makes the twenty seconds a
 724    decision. It is also the only contract a caller can honour: a beam knows it
 725    has touched a seal, but it announces that once per trigger pull, so a
 726    channel that demanded a call every frame simply never completed, and the
 727    vault sat there eating beam ticks and paying nothing.
 728
 729    The Shrike does not respect seals. :meth:`shear` opens a vault outright
 730    when the sector cracks, with no channel and no signature bill.
 731    """
 732
 733    def __init__(self, *, scrap: float = balance.SCRAP_VAULT_MIN, **kwargs):
 734        super().__init__(**kwargs)
 735        self.scrap = float(scrap)
 736        self.sealed = True
 737        self.hacking = False
 738        #: What a beam has to reach to be standing on the seal.
 739        self.hit_radius = VAULT_HIT_RADIUS
 740        self.hack_progress = 0.0
 741        self._since_beam = VAULT_HACK_GRACE_S * 2.0
 742        self._visual: MeshInstance3D | None = None
 743
 744    def on_enter_tree(self):
 745        super().on_enter_tree()
 746        self.add_to_group(Groups.VAULTS)
 747
 748    def on_ready(self):
 749        self._visual = self.add_child(_placeholder("vault", VAULT_HIT_RADIUS))
 750        self._refresh_visual()
 751
 752    @property
 753    def hack_remaining_s(self) -> float:
 754        """Seconds of channel still owed, zero once the vault is open."""
 755        if not self.sealed:
 756            return 0.0
 757        return max(0.0, (1.0 - self.hack_progress) * balance.VAULT_HACK_CHANNEL_S)
 758
 759    def begin_hack(self) -> bool:
 760        """Start the hack channel. False when the vault is already open.
 761
 762        Idempotent: starting a running channel simply re-arms it, which is what
 763        the mining beam's one call per trigger pull amounts to.
 764        """
 765        if not self.sealed:
 766            return False
 767        self._since_beam = 0.0
 768        self.hacking = True
 769        return True
 770
 771    def in_hack_range(self, here: Vec2) -> bool:
 772        """Whether a ship at *here* is close enough to run the channel."""
 773        return _plane_distance(here, self.plane_position) <= VAULT_HACK_RADIUS
 774
 775    def _sustained(self) -> bool:
 776        """Whether the channel has a reason to keep running this frame."""
 777        if self._since_beam <= VAULT_HACK_GRACE_S:
 778            return True
 779        tree = self.tree
 780        ship = tree.get_first_in_group(Groups.SHIP) if tree is not None else None
 781        return ship is not None and self.in_hack_range(to_plane(ship.world_position))
 782
 783    def on_update(self, dt: float):
 784        if not self.sealed or not self.hacking:
 785            return
 786        self._since_beam += dt
 787        if self._sustained():
 788            self.hack_progress += dt / balance.VAULT_HACK_CHANNEL_S
 789            if self.hack_progress >= 1.0:
 790                self._complete()
 791        else:
 792            self.hack_progress = max(0.0, self.hack_progress - VAULT_HACK_DECAY_PER_S * dt)
 793            if self.hack_progress <= 0.0:
 794                self.hacking = False
 795
 796    def shear(self) -> None:
 797        """Open the vault the way the Shrike does: no channel, no signature."""
 798        if not self.sealed:
 799            return
 800        self.sealed = False
 801        self.hacking = False
 802        self.hack_progress = 1.0
 803        self._pay_out()
 804        self._refresh_visual()
 805
 806    def _complete(self) -> None:
 807        self.sealed = False
 808        self.hacking = False
 809        self.hack_progress = 1.0
 810        sector = self.sector
 811        if sector is not None:
 812            sector.report_vault_hacked(self.scrap)
 813        self._pay_out()
 814        self._refresh_visual()
 815
 816    def _refresh_visual(self) -> None:
 817        """A sealed vault glows; an opened one is a dark hole in the keel."""
 818        colour = _FEATURE_COLOURS["vault"]
 819        if self.sealed:
 820            _set_emissive(self._visual, colour, VAULT_SEALED_STRENGTH)
 821            return
 822        _set_emissive(self._visual, colour, VAULT_OPEN_STRENGTH)
 823        _dim_albedo(self._visual, VAULT_OPEN_DIM)
 824
 825    def _pay_out(self) -> None:
 826        sector = self.sector
 827        if sector is not None and self.scrap > 0.0:
 828            sector.release("scrap", self.scrap, self.plane_position)
 829        self.scrap = 0.0
 830        # An opened vault is no longer a vault to the beam that opened it: it
 831        # leaves the group so it stops clamping the mining laser's reach on
 832        # every later pass over a wreck the pilot has already worked.
 833        self.remove_from_group(Groups.VAULTS)
 834
 835
 836class Wreck(SectorFeature):
 837    """A dead hull: a quick outer bay, and often a vault welded into the keel.
 838
 839    The bay is a tap of ``interact``, the fastest scrap in the sector and the
 840    reason a wreck is worth approaching at all. What makes it worth staying for
 841    is :attr:`vault`, and the vault is the loud half.
 842
 843    A wreck that still holds something burns a bay light. Emptying it puts the
 844    light out, so a graveyard the pilot has already worked reads as worked from
 845    a distance instead of being seven identical silhouettes.
 846    """
 847
 848    def __init__(
 849        self,
 850        *,
 851        bay_scrap: float = balance.SCRAP_WRECK_MIN,
 852        fuel: float = balance.WRECK_TANK_FUEL,
 853        ammo_boxes: int = 0,
 854        module_tier: int = 1,
 855        **kwargs,
 856    ):
 857        super().__init__(**kwargs)
 858        self.bay_scrap = float(bay_scrap)
 859        self.fuel = float(fuel)
 860        self.ammo_boxes = int(ammo_boxes)
 861        self.module_tier = int(module_tier)
 862        self.looted = False
 863        self.vault: Vault | None = None
 864        #: What a beam has to reach to be standing on this hulk.
 865        self.hit_radius = WRECK_HIT_RADIUS
 866        self._bay_light: MeshInstance3D | None = None
 867
 868    def on_enter_tree(self):
 869        super().on_enter_tree()
 870        self.add_to_group(Groups.WRECKS)
 871
 872    def on_ready(self):
 873        if artkit is not None:
 874            self.add_child(artkit.build_wreck(self.visual_seed))
 875        else:
 876            self.add_child(_placeholder("wreck", WRECK_HIT_RADIUS))
 877        light = _placeholder("mote", WRECK_BAY_LIGHT_RADIUS)
 878        light.name = "BayLight"
 879        light.position = Vec3(0.0, PLANE_Y, WRECK_BAY_LIGHT_OFFSET)
 880        _set_emissive(light, WRECK_BAY_LIGHT_COLOUR, WRECK_BAY_LIGHT_STRENGTH)
 881        self._bay_light = self.add_child(light)
 882        self._refresh_visual()
 883
 884    @property
 885    def has_bay(self) -> bool:
 886        """Whether the outer bay still holds anything worth a grab."""
 887        return not self.looted and (self.bay_scrap > 0.0 or self.fuel > 0.0 or self.ammo_boxes > 0)
 888
 889    def _refresh_visual(self) -> None:
 890        if self._bay_light is not None:
 891            self._bay_light.visible = self.has_bay
 892
 893    def loot_bay(self) -> float:
 894        """Empty the outer bay. Returns the scrap released, 0 if already looted."""
 895        if self.looted:
 896            return 0.0
 897        self.looted = True
 898        sector = self.sector
 899        released = self.bay_scrap
 900        if sector is not None:
 901            at = self.plane_position
 902            if self.bay_scrap > 0.0:
 903                sector.release("scrap", self.bay_scrap, at)
 904            if self.fuel > 0.0:
 905                sector.release("fuel", self.fuel, at)
 906            for _ in range(self.ammo_boxes):
 907                sector.release("ammo", float(balance.AMMO_BOX_ROUNDS), at)
 908        self.bay_scrap = 0.0
 909        self.fuel = 0.0
 910        self.ammo_boxes = 0
 911        self._refresh_visual()
 912        return released
 913
 914
 915class Pickup(SectorFeature):
 916    """A loose prize: scrap confetti, an ice chunk, a spilled tank, an ammo box.
 917
 918    Collected by flying through it (:data:`PICKUP_SCOOP_RADIUS`) or by a tap of
 919    ``interact`` from a little further out. Collection is the only thing in the
 920    sector that credits a resource, which is what makes income a physical act.
 921    """
 922
 923    def __init__(self, *, kind: str = "scrap", amount: float = 1.0, drift: Vec2 | None = None, **kwargs):
 924        super().__init__(**kwargs)
 925        self.kind = kind
 926        self.amount = float(amount)
 927        self.collected = False
 928        self._drift = drift if drift is not None else Vec2(0.0, 0.0)
 929
 930    def on_enter_tree(self):
 931        super().on_enter_tree()
 932        self.add_to_group(Groups.SALVAGE)
 933
 934    def on_ready(self):
 935        self.add_child(_placeholder("ice" if self.kind == "o2" else "mote", 0.5))
 936
 937    def on_update(self, dt: float):
 938        speed = math.hypot(float(self._drift.x), float(self._drift.y))
 939        if speed <= 0.01:
 940            return
 941        self.position = Vec3(
 942            float(self.position.x) + float(self._drift.x) * dt,
 943            PLANE_Y,
 944            float(self.position.z) + float(self._drift.y) * dt,
 945        )
 946        decay = max(0.0, 1.0 - PICKUP_DRIFT_DAMPING * dt)
 947        self._drift = Vec2(float(self._drift.x) * decay, float(self._drift.y) * decay)
 948
 949    def collect(self) -> float:
 950        """Credit this pickup and remove it. Returns the amount credited."""
 951        if self.collected:
 952            return 0.0
 953        self.collected = True
 954        sector = self.sector
 955        if sector is not None:
 956            sector.credit(self.kind, self.amount)
 957        self.destroy()
 958        return self.amount
 959
 960
 961class Vent(SectorFeature):
 962    """A thermal vent: park in it and it refills fuel and oxygen until it dies.
 963
 964    The Vent Field's whole inversion, made physical. A vent is generous and
 965    stationary, which means taking it costs the one thing a sector charges for:
 966    time spent not leaving.
 967    """
 968
 969    def __init__(
 970        self,
 971        *,
 972        fuel: float = VENT_FUEL_CHARGE,
 973        o2: float = VENT_O2_CHARGE,
 974        radius: float = VENT_RADIUS,
 975        **kwargs,
 976    ):
 977        super().__init__(**kwargs)
 978        self.fuel = float(fuel)
 979        self.o2 = float(o2)
 980        self.radius = float(radius)
 981
 982    def on_enter_tree(self):
 983        super().on_enter_tree()
 984        self.add_to_group(Groups.HAZARDS)
 985
 986    def on_ready(self):
 987        self.add_child(_placeholder("vent", 2.0))
 988
 989    @property
 990    def spent(self) -> bool:
 991        """True once the vent has nothing left to give."""
 992        return self.fuel <= 0.0 and self.o2 <= 0.0
 993
 994    def draw_from(self, dt: float) -> None:
 995        """Bleed one frame's worth of refill into the ship's tanks."""
 996        sector = self.sector
 997        if sector is None or dt <= 0.0:
 998            return
 999        if self.fuel > 0.0:
1000            amount = min(self.fuel, VENT_FUEL_PER_S * dt)
1001            self.fuel -= amount
1002            sector.credit("fuel", amount)
1003        if self.o2 > 0.0:
1004            amount = min(self.o2, VENT_O2_PER_S * dt)
1005            self.o2 -= amount
1006            sector.credit("o2", amount)
1007
1008
1009# ============================================================================
1010# The sector
1011# ============================================================================
1012
1013
1014class Sector(Node3D):
1015    """One generated sector: its biome, its content, and its signal event.
1016
1017    Construct with a ``seed`` to generate on mount, or call :meth:`generate`
1018    later to build (or rebuild) the layout. Generation draws one card from the
1019    run's :class:`~shrike.events.SignalEventDeck`, sizes the harvest against
1020    the act's drop table and the biome's inversions, scatters the features, and
1021    emits ``sector_entered`` followed by ``signal_event``.
1022    """
1023
1024    #: Where a jump drops the hull, in the sector's own space. Every layout is
1025    #: built around it: nothing is placed inside :data:`SECTOR_SPAWN_CLEARANCE`
1026    #: of it and the first :data:`SECTOR_NEAR_FEATURES` placements are forced
1027    #: inside :data:`SECTOR_NEAR_RADIUS`, so a pilot who arrives on it has
1028    #: something worth flying at already in view. Arriving anywhere else throws
1029    #: that guarantee away, which is why ``flow.enter_sector`` places the hull
1030    #: here on every jump.
1031    warp_anchor = Vec3(0.0, PLANE_Y, 0.0)
1032
1033    #: (sector_index: int, biome_id: str)
1034    sector_entered = Signal(int, str)
1035    #: (kind: str, amount: float) for "scrap", "fuel", "o2" and "ammo"
1036    resource_collected = Signal(str, float)
1037    #: A rich node gave up its first ore; the signature meter charges for it.
1038    rich_node_tapped = Signal()
1039    #: (scrap: float) a vault channel completed under its own power
1040    vault_hacked = Signal(float)
1041    #: (event_id: str) the card this sector dealt
1042    signal_event = Signal(str)
1043    #: The Shrike's arrival tore the sector open.
1044    sector_cracked = Signal()
1045
1046    def __init__(
1047        self,
1048        *,
1049        sector_index: int = 1,
1050        biome_id: str = "debris_field",
1051        seed: int | None = None,
1052        deck: events.SignalEventDeck | None = None,
1053        has_depot: bool = False,
1054        chart_offline: bool = False,
1055        revisited: bool = False,
1056        **kwargs,
1057    ):
1058        super().__init__(**kwargs)
1059        self.sector_index = int(sector_index)
1060        self.biome = balance.BIOMES[biome_id]
1061        self.deck = deck
1062        #: Whether a drift depot rides this sector. The caller says so from the
1063        #: chart edge it came in along; the opening sector says so itself,
1064        #: because no edge leads into it and the run has to be able to spend
1065        #: what its first sector pays. See :data:`FIRST_SECTOR_DEPOT_RADIUS`.
1066        self.has_depot = bool(has_depot) or self.sector_index == FIRST_SECTOR_INDEX
1067        #: True on a run flown before the chart calibrates: the boundary cue
1068        #: then offers the drive key rather than a chart that will not open.
1069        self.chart_offline = bool(chart_offline)
1070        #: True when the run has stood here before. The place is the same
1071        #: place, dealt from the same seed; what it is not is a fresh harvest.
1072        #: See :data:`REVISIT_HARVEST_MULT`.
1073        self.revisited = bool(revisited)
1074
1075        self.event_id: str | None = None
1076        self.event: events.SignalEventCard | None = None
1077        self.harvest_budget = 0.0
1078        self.depot_anchor: Vec2 | None = None
1079        self.hazard_anchors: list[Vec2] = []
1080        self.cracked = False
1081        #: The sector's motion reference, mounted on entry and never cleared.
1082        self.dust: Node3D | None = None
1083        #: The ring drawn on the ground around the dock, when this sector has
1084        #: one. Mounted by the layout beside the anchor it marks and armed from
1085        #: the same tick that resolves the affordances.
1086        self.dock_ring: Node3D | None = None
1087        #: True while the ship is outside :data:`SECTOR_BOUNDARY_RADIUS`.
1088        self.beyond_boundary = False
1089
1090        self._dust_fade = 1.0
1091        self._credit_bank: dict[str, float] = {}
1092        self._credit_wait = 0.0
1093
1094        self._seed = seed
1095        self._rng = random.Random(seed if seed is not None else 0)
1096        self._generated = False
1097        self._deposits: list[Deposit] = []
1098        self._wrecks: list[Wreck] = []
1099        self._vaults: list[Vault] = []
1100        self._vents: list[Vent] = []
1101        self._pickups: list[Pickup] = []
1102        self._crack_countdown: float | None = None
1103        self._hunter_connected = False
1104
1105    # -- read-only views ---------------------------------------------------
1106
1107    @property
1108    def deposits(self) -> list[Deposit]:
1109        """Every ore body in the sector, veins and comets alike."""
1110        return list(self._deposits)
1111
1112    @property
1113    def wrecks(self) -> list[Wreck]:
1114        return list(self._wrecks)
1115
1116    @property
1117    def vaults(self) -> list[Vault]:
1118        return list(self._vaults)
1119
1120    @property
1121    def vents(self) -> list[Vent]:
1122        return list(self._vents)
1123
1124    @property
1125    def pickups(self) -> list[Pickup]:
1126        """Loose prizes currently floating, oldest first."""
1127        return [p for p in self._pickups if not p.collected]
1128
1129    @property
1130    def signature_mult(self) -> float:
1131        """The card's sector-scoped multiplier on signature gain."""
1132        return self.event.signature_mult if self.event is not None else 1.0
1133
1134    @property
1135    def hostile_event(self) -> bool:
1136        """Whether this sector's card promises that something will shoot."""
1137        return bool(self.event.hostile) if self.event is not None else False
1138
1139    def harvestable_scrap(self) -> float:
1140        """Scrap still in the ground, in bays, in sealed vaults and in motes."""
1141        total = sum(d.value_remaining for d in self._deposits if d.resource == "scrap")
1142        total += sum(w.bay_scrap for w in self._wrecks)
1143        total += sum(v.scrap for v in self._vaults)
1144        total += sum(p.amount for p in self.pickups if p.kind == "scrap")
1145        return total
1146
1147    # -- lifecycle ---------------------------------------------------------
1148
1149    def on_ready(self):
1150        self._mount_dust()
1151        if self._seed is not None and not self._generated:
1152            self.generate(self._seed)
1153
1154    def _mount_dust(self) -> None:
1155        """Give the sector its motion reference.
1156
1157        Every sector gets one, including an empty one: the dust is what tells
1158        the player the ship is moving at all, so it is not content and it is
1159        not optional. It is a child of the sector rather than of the run scene
1160        so that leaving takes it with the rest of the place.
1161        """
1162        if self.dust is not None or vfx is None:
1163            return
1164        self.dust = self.add_child(vfx.DustField(name="Dust", seed=self._seed if self._seed is not None else 0))
1165
1166    def on_update(self, dt: float):
1167        self._connect_hunter_if_present()
1168        if self._crack_countdown is not None:
1169            self._crack_countdown -= dt
1170            if self._crack_countdown <= 0.0:
1171                self._crack_countdown = None
1172                self.crack_open()
1173
1174        self._pickups = [p for p in self._pickups if not p.collected and not p.destroying]
1175        self._tick_credit_floats(dt)
1176        ship = self.tree.get_first_in_group(Groups.SHIP) if self.tree is not None else None
1177        if ship is None:
1178            return
1179        here = to_plane(ship.position)
1180
1181        for pickup in list(self._pickups):
1182            if _plane_distance(here, pickup.plane_position) <= PICKUP_SCOOP_RADIUS:
1183                pickup.collect()
1184
1185        for vent in self._vents:
1186            if not vent.spent and _plane_distance(here, vent.plane_position) <= vent.radius:
1187                vent.draw_from(dt)
1188
1189        if Input.is_action_just_pressed("interact"):
1190            self.perform_interact(here)
1191
1192        self._tick_dock_ring(here)
1193        self._tick_boundary(here)
1194
1195    def _tick_dock_ring(self, here: Vec2) -> None:
1196        """Light the bay's ring while the hull is standing in it."""
1197        ring = self.dock_ring
1198        anchor = self.depot_anchor
1199        if ring is None or anchor is None:
1200            return
1201        ring.set_armed(_plane_distance(here, anchor) <= DOCK_RING_RADIUS)
1202
1203    # -- the edge of the place ---------------------------------------------
1204
1205    def _tick_boundary(self, here: Vec2) -> None:
1206        """Say where the sector stops, in the dust and on the HUD.
1207
1208        Three channels, because one was never enough: the grain thins and
1209        drains of colour as the content is left behind, the HUD carries a
1210        standing line naming what is out there and the key back to the chart,
1211        and the screen-edge compass keeps pointing at the things worth flying
1212        to. A straight line out should look, read and steer like a mistake.
1213        """
1214        radius = math.hypot(float(here.x), float(here.y))
1215        self._apply_dust_fade(self.dust_fade_for(radius))
1216        self.beyond_boundary = radius > SECTOR_BOUNDARY_RADIUS
1217        hud = self._service(Services.HUD)
1218        if hud is None:
1219            return
1220        if self.beyond_boundary:
1221            hud.set_boundary_notice(BOUNDARY_NOTICE_OFFLINE if self.chart_offline else BOUNDARY_NOTICE)
1222        hud.set_compass_targets(self.compass_targets(here))
1223
1224    @staticmethod
1225    def dust_fade_for(radius: float) -> float:
1226        """Density the dust should carry *radius* out, 1 inside, 0 in the void."""
1227        if radius <= SECTOR_CONTENT_RADIUS:
1228            return 1.0
1229        span = max(SECTOR_VOID_RADIUS - SECTOR_CONTENT_RADIUS, 1e-6)
1230        return max(0.0, 1.0 - (radius - SECTOR_CONTENT_RADIUS) / span)
1231
1232    @property
1233    def dust_fade(self) -> float:
1234        """The density fraction the dust is currently drawn at."""
1235        return self._dust_fade
1236
1237    def compass_targets(self, here: Vec2 | None = None) -> list[tuple[Vec3, str]]:
1238        """The nearest thing of each kind worth flying to, as ``(where, kind)``.
1239
1240        One deposit and one wreck: the answers to "there is nothing here" that
1241        are in this sector. Kinds are the ``COMPASS_*`` ids, and the HUD decides
1242        whether they are needed, since only it knows what is on screen.
1243
1244        Every offered target is inside :data:`SECTOR_CONTENT_RADIUS`, checked
1245        rather than assumed. A marker is a promise that flying at it arrives
1246        somewhere, and a playtester who chased one to the sector boundary and
1247        out the other side had that promise broken by the HUD rather than by
1248        the pilot. The depot is deliberately absent: it has its own persistent
1249        beacon, and offering it here as well drew it twice.
1250        """
1251        if here is None:
1252            ship = self.tree.get_first_in_group(Groups.SHIP) if self.tree is not None else None
1253            if ship is None:
1254                return []
1255            here = to_plane(ship.world_position)
1256
1257        out: list[tuple[Vec3, str]] = []
1258        nearest = self._nearest([d for d in self._deposits if not d.depleted], here)
1259        if nearest is not None:
1260            out.append((nearest.world_position, COMPASS_DEPOSIT))
1261        worth_a_look = [w for w in self._wrecks if w.has_bay or (w.vault is not None and w.vault.sealed)]
1262        nearest = self._nearest(worth_a_look, here)
1263        if nearest is not None:
1264            out.append((nearest.world_position, COMPASS_WRECK))
1265        return [(where, kind) for where, kind in out if self.in_sector(to_plane(where))]
1266
1267    @staticmethod
1268    def in_sector(where: Vec2) -> bool:
1269        """Whether *where* is inside the sector's content disc."""
1270        return math.hypot(float(where.x), float(where.y)) <= SECTOR_CONTENT_RADIUS
1271
1272    @staticmethod
1273    def _nearest(features: list, here: Vec2):
1274        """The feature of *features* closest to *here*, or None when empty."""
1275        if not features:
1276            return None
1277        return min(features, key=lambda feature: _plane_distance(here, feature.plane_position))
1278
1279    def _apply_dust_fade(self, fade: float) -> None:
1280        """Thin and desaturate the dust to *fade*, 1 being the field as built.
1281
1282        Driven through :meth:`~shrike.vfx.DustField.set_fade`, which owns the
1283        materials and keeps the fade across the act re-tints the field does for
1284        itself; the floors here are this sector's say over what the void keeps.
1285        """
1286        if self.dust is None or abs(fade - self._dust_fade) < 1e-3:
1287            return
1288        self._dust_fade = fade
1289        self.dust.set_fade(
1290            density=VOID_DUST_ALPHA_FLOOR + (1.0 - VOID_DUST_ALPHA_FLOOR) * fade,
1291            saturation=VOID_DUST_SATURATION_FLOOR + (1.0 - VOID_DUST_SATURATION_FLOOR) * fade,
1292            glow=fade,
1293        )
1294
1295    # -- affordances -------------------------------------------------------
1296
1297    def affordance(self, here: Vec2 | None = None) -> Affordance | None:
1298        """The nearest verb the ship at *here* can perform, or None.
1299
1300        Each candidate is gated by the radius its own verb actually uses, so
1301        the line the HUD draws and the thing the key does turn on and off
1302        together. Ranking is by plain distance: an exposed core under the nose
1303        beats a mote across the sector, and vice versa.
1304        """
1305        if here is None:
1306            ship = self.tree.get_first_in_group(Groups.SHIP) if self.tree is not None else None
1307            if ship is None:
1308                return None
1309            here = to_plane(ship.world_position)
1310
1311        running = next((v for v in self._vaults if v.sealed and v.hacking and v.in_hack_range(here)), None)
1312        if running is not None:
1313            return Affordance(
1314                PROMPT_HACKING_VAULT.format(seconds=running.hack_remaining_s),
1315                "hacking",
1316                running,
1317                _plane_distance(here, running.plane_position),
1318            )
1319
1320        best: Affordance | None = None
1321
1322        def offer(prompt: str, kind: str, target, distance: float, reach: float) -> None:
1323            nonlocal best
1324            if distance > reach:
1325                return
1326            if best is None or distance < best.distance:
1327                best = Affordance(prompt, kind, target, distance)
1328
1329        for pickup in self.pickups:
1330            offer(PROMPT_GRAB, "grab", pickup, _plane_distance(here, pickup.plane_position), PICKUP_GRAB_RADIUS)
1331        for wreck in self._wrecks:
1332            if wreck.has_bay:
1333                offer(PROMPT_LOOT_BAY, "bay", wreck, _plane_distance(here, wreck.plane_position), PICKUP_GRAB_RADIUS)
1334        for vault in self._vaults:
1335            if vault.sealed:
1336                offer(PROMPT_HACK_VAULT, "vault", vault, _plane_distance(here, vault.plane_position), VAULT_HACK_RADIUS)
1337        for deposit in self._deposits:
1338            if deposit.core_exposed:
1339                offer(
1340                    PROMPT_CRACK_CORE,
1341                    "core",
1342                    deposit,
1343                    _plane_distance(here, deposit.plane_position),
1344                    AFFORDANCE_BEAM_RADIUS,
1345                )
1346        return best
1347
1348    def perform_interact(self, here: Vec2 | None = None) -> str:
1349        """Take whatever the affordance is currently offering ``interact``.
1350
1351        Returns the verb id taken, or an empty string when nothing was in
1352        reach. The core is not on this key: cracking it is a held beam, and the
1353        prompt says so.
1354        """
1355        found = self.affordance(here)
1356        if found is None:
1357            return ""
1358        if found.kind == "grab":
1359            found.target.collect()
1360        elif found.kind == "bay":
1361            found.target.loot_bay()
1362        elif found.kind == "vault":
1363            found.target.begin_hack()
1364        else:
1365            return ""
1366        return found.kind
1367
1368    # -- generation --------------------------------------------------------
1369
1370    def generate(self, seed: int) -> None:
1371        """Build the sector's content from *seed*, replacing anything already placed."""
1372        self._seed = seed
1373        rng = random.Random(seed)
1374        # A second stream, so runtime scatter (motes) never perturbs the layout.
1375        self._rng = random.Random(seed + 1)
1376        self._clear()
1377
1378        deck = self.deck if self.deck is not None else events.SignalEventDeck(seed)
1379        self.deck = deck
1380        self.event_id = deck.draw()
1381        self.event = events.card(self.event_id)
1382
1383        content = BIOME_CONTENT[self.biome.id]
1384        drops = balance.ACT_DROPS[balance.act_for_sector(self.sector_index)]
1385
1386        budget = rng.uniform(*drops.scrap_per_sector) * self.biome.scrap_mult
1387        if self.biome.id == "wreck_graveyard":
1388            budget *= WRECK_GRAVEYARD_SCRAP_MULT
1389        n_deposits = rng.randint(*content.deposits)
1390        n_wrecks = rng.randint(*content.wrecks)
1391        if self.revisited:
1392            budget *= REVISIT_HARVEST_MULT
1393            n_deposits = min(n_deposits, REVISIT_FEATURE_CAP)
1394            n_wrecks = min(n_wrecks, REVISIT_FEATURE_CAP)
1395        self.harvest_budget = budget
1396
1397        share = budget / max(n_deposits + n_wrecks * WRECK_BAY_BUDGET_WEIGHT, 1e-6)
1398
1399        taken: list[Vec2] = []
1400        self._place_deposits(rng, n_deposits, share, taken)
1401        self._place_wrecks(rng, n_wrecks, share * WRECK_BAY_BUDGET_WEIGHT, drops, content, taken)
1402        self._place_vaults(rng, drops, content, taken)
1403        self._place_refills(rng, content, taken)
1404        self._place_event_content(rng, taken)
1405        self._place_anchors(rng, content, taken)
1406
1407        self._generated = True
1408        # Both signals below fire once, from this node's own on_ready, which no
1409        # interval sweep can have seen yet: the wirings must be told first, or
1410        # every deferred consumer misses the only sector_entered it will get.
1411        SignalWiring.sweep_all()
1412        self.sector_entered(self.sector_index, self.biome.id)
1413        self.signal_event(self.event_id)
1414
1415    def _place_deposits(self, rng: random.Random, count: int, share: float, taken: list[Vec2]) -> None:
1416        values = [share * rng.uniform(*DEPOSIT_VALUE_SPREAD) for _ in range(count)]
1417        for index, value in enumerate(values):
1418            deposit = Deposit(
1419                name=f"Vein{index}",
1420                position=self._scatter(rng, taken),
1421                value=value,
1422                rich=value >= share,
1423                visual_seed=rng.randrange(VISUAL_SEED_POOL),
1424            )
1425            self._deposits.append(self.add_child(deposit))
1426
1427    def _place_wrecks(
1428        self,
1429        rng: random.Random,
1430        count: int,
1431        bay_value: float,
1432        drops: balance.ActDrops,
1433        content: BiomeContent,
1434        taken: list[Vec2],
1435    ) -> None:
1436        for index in range(count):
1437            wreck = Wreck(
1438                name=f"Wreck{index}",
1439                position=self._scatter(rng, taken),
1440                bay_scrap=bay_value * rng.uniform(*DEPOSIT_VALUE_SPREAD),
1441                fuel=balance.WRECK_TANK_FUEL,
1442                ammo_boxes=1 if rng.random() < content.ammo_box_chance else 0,
1443                module_tier=rng.randint(*drops.module_tiers_in_wrecks),
1444                visual_seed=rng.randrange(VISUAL_SEED_POOL),
1445            )
1446            self._wrecks.append(self.add_child(wreck))
1447
1448    def _place_vaults(
1449        self, rng: random.Random, drops: balance.ActDrops, content: BiomeContent, taken: list[Vec2]
1450    ) -> None:
1451        card = self.event
1452        extra = content.extra_vaults + (card.vaults if card is not None else 0)
1453        count = rng.randint(*drops.vaults_per_sector) + extra
1454        if self.revisited:
1455            # The sealed prize is the one thing a sector holds exactly once.
1456            count = 0
1457        free_wrecks = [w for w in self._wrecks if w.vault is None]
1458        rng.shuffle(free_wrecks)
1459        for index in range(count):
1460            scrap = rng.uniform(balance.SCRAP_VAULT_MIN, balance.SCRAP_VAULT_MAX) * self.biome.scrap_mult
1461            if free_wrecks:
1462                host = free_wrecks.pop()
1463                vault = Vault(name="Vault", position=Vec3(0.0, PLANE_Y, 0.0), scrap=scrap)
1464                host.vault = host.add_child(vault)
1465            else:
1466                vault = Vault(name=f"Vault{index}", position=self._scatter(rng, taken), scrap=scrap)
1467                self.add_child(vault)
1468            self._vaults.append(vault)
1469
1470    def _place_refills(self, rng: random.Random, content: BiomeContent, taken: list[Vec2]) -> None:
1471        for index in range(self._refill_count(rng, content.fuel_comets)):
1472            comet = FuelComet(
1473                name=f"Comet{index}",
1474                position=self._scatter(rng, taken),
1475                value=rng.uniform(balance.FUEL_COMET_MIN, balance.FUEL_COMET_MAX),
1476            )
1477            self._deposits.append(self.add_child(comet))
1478        for index in range(self._refill_count(rng, content.ice_chunks)):
1479            self._add_pickup(
1480                Pickup(name=f"Ice{index}", position=self._scatter(rng, taken), kind="o2", amount=balance.ICE_CHUNK_O2)
1481            )
1482        for index in range(self._refill_count(rng, content.vents)):
1483            vent = Vent(name=f"Vent{index}", position=self._scatter(rng, taken))
1484            self._vents.append(self.add_child(vent))
1485
1486    def _refill_count(self, rng: random.Random, band: tuple[int, int]) -> int:
1487        """How many of a strategic refill this visit places.
1488
1489        The roll happens whatever the visit, so a revisited layout is the same
1490        layout thinned rather than a different one, and only the count that
1491        comes out of it is halved.
1492        """
1493        count = rng.randint(*band)
1494        return int(count * REVISIT_REFILL_MULT) if self.revisited else count
1495
1496    def _place_event_content(self, rng: random.Random, taken: list[Vec2]) -> None:
1497        card = self.event
1498        if card is None:
1499            return
1500        if card.scrap > 0.0:
1501            self._add_pickup(Pickup(name="Cache", position=self._scatter(rng, taken), kind="scrap", amount=card.scrap))
1502        if card.fuel > 0.0:
1503            comet = FuelComet(name="EventComet", position=self._scatter(rng, taken), value=card.fuel)
1504            self._deposits.append(self.add_child(comet))
1505        if card.o2 > 0.0:
1506            chunks = max(1, int(round(card.o2 / balance.ICE_CHUNK_O2)))
1507            for index in range(chunks):
1508                self._add_pickup(
1509                    Pickup(
1510                        name=f"EventIce{index}",
1511                        position=self._scatter(rng, taken),
1512                        kind="o2",
1513                        amount=card.o2 / chunks,
1514                    )
1515                )
1516
1517    def _place_anchors(self, rng: random.Random, content: BiomeContent, taken: list[Vec2]) -> None:
1518        self.hazard_anchors = [to_plane(self._scatter(rng, taken)) for _ in range(rng.randint(*content.hazards))]
1519        self.depot_anchor = self._place_depot(rng, taken) if self.has_depot else None
1520        self._mount_dock_ring()
1521
1522    def _mount_dock_ring(self) -> None:
1523        """Draw the bay on the ground, if this sector has one.
1524
1525        The sector places the anchor, so the sector owns the mark on it: the
1526        dock itself is built by the run, and a ring that only appears once a
1527        shop node exists is a ring the layout cannot promise. Nothing here
1528        depends on the dock, only on where it will be.
1529        """
1530        if self.dock_ring is not None:
1531            parent = self.dock_ring.parent
1532            if parent is not None:
1533                parent.remove_child(self.dock_ring)
1534            self.dock_ring.destroy()
1535            self.dock_ring = None
1536        if self.depot_anchor is None or vfx is None:
1537            return
1538        anchor = self.depot_anchor
1539        self.dock_ring = self.add_child(
1540            vfx.DockRing(
1541                name="DockRing",
1542                radius=DOCK_RING_RADIUS,
1543                position=Vec3(float(anchor.x), PLANE_Y, float(anchor.y)),
1544            )
1545        )
1546
1547    def _place_depot(self, rng: random.Random, taken: list[Vec2]) -> Vec2:
1548        """Where the dock sits, near the warp-in point in the opening sector.
1549
1550        A depot anywhere on a sixty-unit disc is a long flight to a shop a pilot
1551        does not yet know exists. The opening sector is the one that has to teach
1552        the sink, so its dock is planted on a ring the ship can be at within the
1553        first minute, on a bearing the rest of the layout has left free. Later
1554        sectors keep the ordinary scatter: by then the beacon means something.
1555        """
1556        if self.sector_index != FIRST_SECTOR_INDEX:
1557            return to_plane(self._scatter(rng, taken))
1558        best = Vec2(FIRST_SECTOR_DEPOT_RADIUS, 0.0)
1559        best_clearance = -1.0
1560        for _ in range(SECTOR_PLACEMENT_ATTEMPTS):
1561            angle = rng.uniform(0.0, math.tau)
1562            spot = Vec2(
1563                FIRST_SECTOR_DEPOT_RADIUS * math.cos(angle),
1564                FIRST_SECTOR_DEPOT_RADIUS * math.sin(angle),
1565            )
1566            clearance = min((_plane_distance(spot, other) for other in taken), default=SECTOR_RADIUS)
1567            if clearance > best_clearance:
1568                best, best_clearance = spot, clearance
1569            if clearance >= SECTOR_MIN_SEPARATION:
1570                break
1571        taken.append(best)
1572        return best
1573
1574    def _scatter(self, rng: random.Random, taken: list[Vec2]) -> Vec3:
1575        """A free spot on the plane, clear of the warp-in point and its neighbours.
1576
1577        The first :data:`SECTOR_NEAR_FEATURES` spots of a layout are drawn from
1578        the near band only, so a pilot who has just warped in can see something
1579        worth flying at without turning the ship around first. Everything after
1580        them is scattered evenly by area across the whole disc.
1581        """
1582        outer = SECTOR_NEAR_RADIUS if len(taken) < SECTOR_NEAR_FEATURES else SECTOR_RADIUS
1583        span = outer - SECTOR_SPAWN_CLEARANCE
1584        best = Vec2(0.0, 0.0)
1585        for _ in range(SECTOR_PLACEMENT_ATTEMPTS):
1586            radius = SECTOR_SPAWN_CLEARANCE + span * math.sqrt(rng.random())
1587            angle = rng.uniform(0.0, math.tau)
1588            best = Vec2(radius * math.cos(angle), radius * math.sin(angle))
1589            if all(_plane_distance(best, other) >= SECTOR_MIN_SEPARATION for other in taken):
1590                break
1591        taken.append(best)
1592        return Vec3(float(best.x), PLANE_Y, float(best.y))
1593
1594    def _clear(self) -> None:
1595        for feature in (*self._deposits, *self._wrecks, *self._vaults, *self._vents, *self._pickups):
1596            parent = feature.parent
1597            if parent is not None:
1598                parent.remove_child(feature)
1599            feature.destroy()
1600        self._deposits.clear()
1601        self._wrecks.clear()
1602        self._vaults.clear()
1603        self._vents.clear()
1604        self._pickups.clear()
1605        self.hazard_anchors = []
1606        self.depot_anchor = None
1607        self.cracked = False
1608        self._mount_dock_ring()
1609
1610    # -- the harvest ledger ------------------------------------------------
1611
1612    def release(self, kind: str, amount: float, at: Vec2) -> list[Pickup]:
1613        """Scatter *amount* of *kind* as motes around *at*. Returns the motes."""
1614        if amount <= 0.0:
1615            return []
1616        low, high = MOTE_VALUE_BANDS.get(kind, (amount, amount))
1617        rng = self._rng
1618        motes: list[Pickup] = []
1619        left = amount
1620        while left > 0.0 and len(motes) < PICKUP_BURST_MAX:
1621            value = min(left, rng.uniform(low, high))
1622            if left - value < low * 0.5:
1623                value = left
1624            left -= value
1625            angle = rng.uniform(0.0, math.tau)
1626            speed = rng.uniform(0.3, 1.0) * PICKUP_DRIFT_SPEED
1627            offset = rng.uniform(0.5, 2.0)
1628            mote = Pickup(
1629                name="Mote",
1630                position=Vec3(float(at.x) + math.cos(angle) * offset, PLANE_Y, float(at.y) + math.sin(angle) * offset),
1631                kind=kind,
1632                amount=value,
1633                drift=Vec2(math.cos(angle) * speed, math.sin(angle) * speed),
1634            )
1635            motes.append(self._add_pickup(mote))
1636        if left > 0.0 and motes:
1637            motes[-1].amount += left
1638        return motes
1639
1640    def credit(self, kind: str, amount: float) -> None:
1641        """Put *amount* of *kind* aboard the ship, and announce it.
1642
1643        The announcement is not the income. ``resource_collected`` is a
1644        notification several modules listen to for their own reasons, and the
1645        run's tanks are not among them: life support and the fuel tank are
1646        filled here, through the power system's own ``add_o2`` / ``add_fuel``,
1647        the way a depot canister fills them. Without this an ice field was
1648        scenery, a fuel comet was a rock, and a vent was a light.
1649        """
1650        if amount <= 0.0:
1651            return
1652        power = self._service(Services.POWER)
1653        if power is not None:
1654            if kind == "o2":
1655                power.add_o2(amount)
1656            elif kind == "fuel":
1657                power.add_fuel(amount)
1658        if kind in CREDIT_FLOAT_LABELS:
1659            self._credit_bank[kind] = self._credit_bank.get(kind, 0.0) + float(amount)
1660            self._credit_wait = CREDIT_FLOAT_MERGE_S
1661        self.resource_collected(kind, amount)
1662
1663    def _tick_credit_floats(self, dt: float) -> None:
1664        """Float one label per refill once the credits stop arriving.
1665
1666        A vent pays every frame it is sat in, so a label per credit would be a
1667        column of "+0 O2". The bank flushes once the stream dries up, which
1668        makes one label per act of collection however long the act took, and the
1669        HUD carries whatever is left over into the next one: a refill worth two
1670        thirds of a unit is not a "+0", it is two thirds of the next label.
1671        """
1672        if not self._credit_bank:
1673            return
1674        self._credit_wait -= dt
1675        if self._credit_wait > 0.0:
1676            return
1677        banked, self._credit_bank = self._credit_bank, {}
1678        hud = self._service(Services.HUD)
1679        ship = self.tree.get_first_in_group(Groups.SHIP) if self.tree is not None else None
1680        if hud is None or ship is None:
1681            return
1682        for kind, amount in banked.items():
1683            # O2 and fuel are gauges on the ring around the hull, so their
1684            # labels stay where they landed rather than flying anywhere.
1685            hud.credit_float(kind, amount, CREDIT_FLOAT_LABELS[kind], node=ship, toward="")
1686
1687    def _service(self, name: str):
1688        """The run singleton registered under *name*, or None outside a run."""
1689        tree = self.tree
1690        return tree.singletons.get(name) if tree is not None else None
1691
1692    def report_rich_tap(self) -> None:
1693        """A rich node gave up its first ore. The meter charges for it."""
1694        self.rich_node_tapped()
1695
1696    def report_vault_hacked(self, scrap: float) -> None:
1697        """A vault channel completed. Worth signature, notoriety and scrap."""
1698        self.vault_hacked(scrap)
1699
1700    def _add_pickup(self, pickup: Pickup) -> Pickup:
1701        self.add_child(pickup)
1702        self._pickups.append(pickup)
1703        return pickup
1704
1705    # -- the Shrike cracks it open -----------------------------------------
1706
1707    def connect_hunter(self, hunter) -> None:
1708        """Listen to *hunter* for the arrival that cracks this sector open."""
1709        signal = getattr(hunter, "hunter_arrived", None)
1710        if signal is None or self._hunter_connected:
1711            return
1712        signal.connect(self.on_hunter_arrived)
1713        self._hunter_connected = True
1714
1715    def on_hunter_arrived(self, arrival_index: int = 0) -> None:
1716        """Schedule the crack for ``balance.SHRIKE_CRACK_OPEN_S`` from now."""
1717        if not self.cracked and self._crack_countdown is None:
1718            self._crack_countdown = balance.SHRIKE_CRACK_OPEN_S
1719
1720    def crack_open(self) -> None:
1721        """Shear every sealed vault and split every core. Idempotent."""
1722        if self.cracked:
1723            return
1724        self.cracked = True
1725        for vault in self._vaults:
1726            vault.shear()
1727        for deposit in self._deposits:
1728            deposit.split_core()
1729        self.sector_cracked()
1730
1731    def _connect_hunter_if_present(self) -> None:
1732        if self._hunter_connected or self.tree is None:
1733            return
1734        hunter = self.tree.get_first_in_group(Groups.HUNTER)
1735        if hunter is not None:
1736            self.connect_hunter(hunter)