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)