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