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