shrike/waves.py¶
Part of SHRIKE.
1"""The wave composer: what arrives every 35 to 55 seconds, and from where.
2
3Between the hunter's arrivals a sector is not quiet. A composer coroutine
4spends a threat budget
5
6 T = 10 + 3 x sector index + notoriety / 10
7
8(``balance.wave_threat_budget``) on one hand-authored recipe, and the recipe is
9always the same three-part sentence: one **threat** that kills you, one **tax**
10that costs you a resource, and one **modifier** that changes where you are
11allowed to stand. Lancers plus Skimmers plus a Welder. Bombardiers plus Mites
12plus a Screamer. Loudness therefore buys a meaner sentence rather than only a
13longer one: recipes carry a notoriety floor, so a quiet ship never meets the
14mixes a loud one lives in.
15
16Three properties the rest of the game leans on:
17
18* **Learnable danger.** Everything a sector throws comes off one
19 :class:`random.Random` seeded from the chart node, so a node that opened with
20 a bombardier battery opens with it again for the whole run. Backing out of a
21 jump and coming back is a real decision rather than a reroll.
22* **Readable arrivals.** Every group tears in from one off-screen bearing, the
23 bearings of a wave are spread apart, and each is announced by an edge chevron
24 ``balance.SPAWN_EDGE_CHEVRON_S`` before anything exists to shoot at. In a
25 nebula, whose fog hides spawn directions, the chevron is withheld and
26 the wave arrives unannounced.
27* **The arrival is the wave.** The Shrike's visit is not a backdrop to another
28 Lancer pack: ``HUNTER_ARRIVED`` stops the schedule and ``HUNTER_DEPARTED``
29 resumes it, on the same seeded stream, so the pause costs the player nothing
30 and the hunter has the screen to itself.
31* **The staged opening comes first.** A first-run pilot is still being taught
32 which key thrusts, so run one throws nothing at all until the scripted
33 arrival has come and gone, and then throws it at half budget and without
34 Lancers. Run two keeps its first forty seconds quiet and throws the first wave
35 the moment they are up. From run three the schedule is the plain one. Neither
36 hold is open-ended: a held run that never meets the condition to lift the hold
37 starts composing anyway rather than staying empty. The composer reads the run
38 number off the run scene's config; :meth:`WaveComposer.set_run_number` says it
39 outright.
40
41The composer owns no enemy behaviour. It picks the sentence, prices it, chooses
42the bearings and mounts the bodies; everything after that belongs to
43``shrike.enemies``.
44"""
45
46from __future__ import annotations
47
48import math
49import random
50from collections.abc import Iterable, Mapping, Sequence
51from dataclasses import dataclass, field
52
53from simvx.core import Node, Node3D, Signal, Vec2
54
55from . import balance
56from .enemies.advanced import Bombardier, HuskTurret, Screamer, Welder, apply_elite
57from .enemies.basic import Lancer, MagMine, MiteShoal, Skimmer
58from .enemies.steering import Enemy
59from .power import SignalWiring
60from .runtime import Groups, Services, SignalNames, from_plane, to_plane
61
62# ============================================================================
63# Composition constants
64#
65# Staging geometry and pacing that balance.py carries no equivalent for: these
66# describe how a wave is presented rather than how hard it hits. The threat
67# numbers themselves all come from balance.
68# ============================================================================
69
70#: Distance from the ship a group tears in at, in world units. Comfortably
71#: outside the camera's view of the flight plane, close enough that the chevron
72#: and the contact belong to the same event.
73SPAWN_RING_RADIUS = 46.0
74#: Per-body radial jitter on that ring, so a group arrives as a loose formation.
75SPAWN_RING_JITTER = 5.0
76#: Half-width of the arc a group's bodies scatter across, radians.
77SPAWN_GROUP_SPREAD = 0.30
78#: Half-width of the random offset applied to each group's bearing, radians.
79#: Small enough that three groups stay visibly separated around the ship.
80SPAWN_BEARING_JITTER = 0.35
81
82#: Biomes whose fog hides spawn directions: no chevron, no warning.
83FOGGED_BIOMES = ("nebula",)
84
85#: Extra threat budget past ``balance.ELITE_THREAT_THRESHOLD`` that buys one
86#: more elite tag in the same wave.
87ELITE_BUDGET_PER_EXTRA = 12.0
88#: Hard ceiling on elite tags in one wave, whatever the budget.
89ELITE_MAX_PER_WAVE = 3
90
91#: Weight multiplier applied to the recipe that ran last, so a node's danger is
92#: consistent without becoming one repeated sentence.
93RECIPE_REPEAT_WEIGHT = 0.15
94
95# -- The staged opening ------------------------------------------------------
96#
97# ``onboarding.py`` owns what the first runs teach; these say what the composer
98# does while that teaching is going on. They are pacing rather than tuning,
99# which is why they live here and not in balance.py.
100
101#: The last run the composer softens. From the run after this one the schedule
102#: is the plain one, at full budget, with every archetype in the pools.
103SOFTENED_RUNS = 2
104#: Share of the threat budget a run-one wave may spend. Half of the opening
105#: sector's budget cannot pay for one of everything in even the cheapest
106#: sentence, so run one's first sector composes nothing at all and the softened
107#: mixes first appear a jump later. That is the intent, not a shortfall: the
108#: sector the tutorial happens in belongs to the tutorial.
109RUN_1_BUDGET_FRACTION = 0.5
110#: Archetypes run one never buys. The Lancer's dash is what kills a pilot who
111#: has not learned to dodge yet, so run one's sentences are written without it.
112RUN_1_BARRED_ARCHETYPES: frozenset[str] = frozenset({"lancer"})
113#: Seconds of quiet at the head of run two before its first wave lands. Long
114#: enough to deploy the wings and find a vein, short enough that the opening
115#: threatens: a due wave fires the moment the grace expires rather than rolling
116#: a fresh interval, so this is when the first wave arrives and not merely when
117#: it becomes legal. If the grace and a fresh interval added up, run two could
118#: fly ninety seconds without meeting anything.
119RUN_2_FIRST_WAVE_GRACE_S = 40.0
120#: How long run one's hold may last before the schedule runs anyway. Run one
121#: normally hands its opening to the scripted arrival and resumes once that has
122#: been survived, but a pilot who jumps out of the opening sector before the
123#: arrival is provoked never survives one, and without a ceiling that run stays
124#: empty for as long as it lasts. Set past the arrival ladder's own length, so
125#: it only ever fires for a run the ladder never reached.
126RUN_1_HOLD_CEILING_S = 150.0
127#: Run number that means "not known": nothing is softened, which is what an
128#: arena test or a scene with no run config wants.
129RUN_NUMBER_UNKNOWN = 0
130
131#: Units bought per purchase of an archetype: (minimum, maximum). The minimum
132#: is what one appearance of the archetype costs the recipe; the maximum is what
133#: stops a rich budget collapsing into a single stacked archetype. Budget a
134#: sentence cannot spend under these ceilings is not wasted: past
135#: ``balance.ELITE_THREAT_THRESHOLD`` it buys elite tags instead: meaner mixes,
136#: not just more units.
137PURCHASE_LIMITS: dict[str, tuple[int, int]] = {
138 "mite": (balance.MITE_SHOAL_MIN, balance.MITE_SHOAL_MAX),
139 "skimmer": (1, 6),
140 "lancer": (1, 7),
141 "mag_mine": (1, 9),
142 "welder": (1, 4),
143 "bombardier": (1, 5),
144 "screamer": (1, 4),
145 "husk_turret": (1, 5),
146}
147
148# ============================================================================
149# The grammar
150# ============================================================================
151
152#: Archetypes that kill you if ignored.
153THREAT_ARCHETYPES = ("lancer", "bombardier", "husk_turret")
154#: Archetypes that cost you a resource rather than hull: scrap, ammo, signature.
155TAX_ARCHETYPES = ("mite", "skimmer", "screamer")
156#: Archetypes that change where you are allowed to stand, or in what order you
157#: are allowed to shoot.
158MODIFIER_ARCHETYPES = ("welder", "mag_mine", "screamer", "husk_turret")
159
160#: The grammar's three slots. An archetype may serve two roles (a Screamer taxes
161#: signature and rewrites your kill order); no recipe may use one twice.
162ROLE_POOLS: dict[str, tuple[str, ...]] = {
163 "threat": THREAT_ARCHETYPES,
164 "tax": TAX_ARCHETYPES,
165 "modifier": MODIFIER_ARCHETYPES,
166}
167
168#: Heralds are the hunter's escort, not a wave: they arrive on the telegraph
169#: ladder and spend none of this budget.
170EXCLUDED_ARCHETYPES = ("herald",)
171
172#: Per-biome bias on each archetype, multiplying a recipe's draw weight through
173#: the geometric mean of its three slots. Anything unlisted weighs 1.0; a zero
174#: bars the archetype from that biome entirely.
175BIOME_WEIGHTS: dict[str, dict[str, float]] = {
176 "debris_field": {},
177 "solar_shallows": {"mite": 1.6, "bombardier": 1.4, "skimmer": 0.8},
178 "ice_field": {"lancer": 0.7, "mite": 0.7, "husk_turret": 1.4, "mag_mine": 1.3},
179 "nebula": {"lancer": 1.5, "mag_mine": 1.4, "bombardier": 0.7, "skimmer": 0.8},
180 "wreck_graveyard": {"husk_turret": 2.0, "skimmer": 1.6, "welder": 1.2, "mite": 0.8},
181 "vent_field": {"mag_mine": 2.0, "bombardier": 1.2, "husk_turret": 0.7},
182 "broker_claim": {"welder": 1.4, "bombardier": 1.3, "screamer": 0.6},
183 "roost": {},
184}
185
186#: The authored recipe table. Each entry is one threat + tax + modifier
187#: sentence; ``biomes`` empty means every biome, ``min_notoriety`` is the dial
188#: reading a mix needs before it can be drawn, and the budget floor is derived
189#: from ``PURCHASE_LIMITS`` rather than authored, so a recipe can never be drawn
190#: at a budget that cannot pay for one of everything it names.
191#:
192#: The combinations left in ROLE_POOLS are room for more recipes.
193WAVE_RECIPES: list[dict] = [
194 {
195 "id": "lance_and_larceny",
196 "threat": "lancer",
197 "tax": "skimmer",
198 "modifier": "welder",
199 "note": "the design's worked example: dash pressure, a thief, and a medic keeping it alive",
200 },
201 {
202 "id": "mortar_and_shoal",
203 "threat": "bombardier",
204 "tax": "mite",
205 "modifier": "screamer",
206 "note": "the second worked example: reticles on your mining spot while a shoal chews the hull",
207 },
208 {
209 "id": "picket_line",
210 "threat": "lancer",
211 "tax": "skimmer",
212 "modifier": "mag_mine",
213 "weight": 1.2,
214 "note": "the act 1 staple: mines close the easy escape lane the dash pushes you down",
215 },
216 {
217 "id": "siren_run",
218 "threat": "lancer",
219 "tax": "screamer",
220 "modifier": "mag_mine",
221 "note": "the kill-order exam: the siren is cheap and the dash punishes the detour",
222 },
223 {
224 "id": "dead_ship_guns",
225 "threat": "husk_turret",
226 "tax": "skimmer",
227 "modifier": "welder",
228 "biomes": ("wreck_graveyard", "debris_field"),
229 "note": "emplacements you must break line with while the salvage walks away",
230 },
231 {
232 "id": "graveyard_shift",
233 "threat": "husk_turret",
234 "tax": "mite",
235 "modifier": "mag_mine",
236 "biomes": ("wreck_graveyard",),
237 "note": "the hulks shoot, the mines seal the gaps between them",
238 },
239 {
240 "id": "panel_hunt",
241 "threat": "bombardier",
242 "tax": "skimmer",
243 "modifier": "mag_mine",
244 "biomes": ("solar_shallows",),
245 "note": "stand-off fire aimed at deployed wings: retract and lose the sun, stay and lose the panel",
246 },
247 {
248 "id": "glare_ambush",
249 "threat": "lancer",
250 "tax": "mite",
251 "modifier": "welder",
252 "biomes": ("solar_shallows",),
253 "note": "a shoal on the wings with a medic behind it",
254 },
255 {
256 "id": "slow_ice",
257 "threat": "lancer",
258 "tax": "skimmer",
259 "modifier": "husk_turret",
260 "biomes": ("ice_field",),
261 "note": "slow bodies, fixed guns: the biome that rewards reading the field before moving",
262 },
263 {
264 "id": "frost_choir",
265 "threat": "husk_turret",
266 "tax": "screamer",
267 "modifier": "mag_mine",
268 "biomes": ("ice_field",),
269 "note": "sirens hiding behind a mined approach to a gun line",
270 },
271 {
272 "id": "fog_lance",
273 "threat": "lancer",
274 "tax": "mite",
275 "modifier": "mag_mine",
276 "biomes": ("nebula",),
277 "note": "dash attacks out of a fog that swallows the chevron",
278 },
279 {
280 "id": "fog_battery",
281 "threat": "bombardier",
282 "tax": "screamer",
283 "modifier": "welder",
284 "biomes": ("nebula",),
285 "min_notoriety": 20,
286 "note": "unseen mortars with a repair line you have to find before you can end it",
287 },
288 {
289 "id": "vent_maze",
290 "threat": "bombardier",
291 "tax": "mite",
292 "modifier": "mag_mine",
293 "biomes": ("vent_field",),
294 "note": "the mine maze the biome is named for, with reticles falling into it",
295 },
296 {
297 "id": "pressure_cooker",
298 "threat": "bombardier",
299 "tax": "screamer",
300 "modifier": "mag_mine",
301 "biomes": ("vent_field",),
302 "note": "refuelling under mortars while a siren prices the delay",
303 },
304 {
305 "id": "broker_muscle",
306 "threat": "bombardier",
307 "tax": "skimmer",
308 "modifier": "welder",
309 "biomes": ("broker_claim",),
310 "min_notoriety": 50,
311 "note": "the barge's own security: expensive, patient, and hard to finish",
312 },
313 {
314 "id": "claim_jumpers",
315 "threat": "husk_turret",
316 "tax": "screamer",
317 "modifier": "welder",
318 "biomes": ("broker_claim",),
319 "min_notoriety": 50,
320 "note": "fixed guns the welders keep standing while the sirens raise your price",
321 },
322 {
323 "id": "hard_shoulder",
324 "threat": "husk_turret",
325 "tax": "mite",
326 "modifier": "welder",
327 "min_notoriety": 25,
328 "note": "a healed gun line behind a shoal: the first wave that punishes ignoring support",
329 },
330 {
331 "id": "tithe_collectors",
332 "threat": "bombardier",
333 "tax": "skimmer",
334 "modifier": "screamer",
335 "min_notoriety": 25,
336 "note": "they came for the haul, and the siren makes leaving it expensive too",
337 },
338 {
339 "id": "wolfpack",
340 "threat": "lancer",
341 "tax": "screamer",
342 "modifier": "welder",
343 "min_notoriety": 15,
344 "note": "dash attackers that get patched between passes",
345 },
346 {
347 "id": "minefield_watch",
348 "threat": "husk_turret",
349 "tax": "skimmer",
350 "modifier": "mag_mine",
351 "note": "static everything: the wave you out-position rather than out-shoot",
352 },
353 {
354 "id": "chorus_and_chaff",
355 "threat": "bombardier",
356 "tax": "mite",
357 "modifier": "welder",
358 "min_notoriety": 40,
359 "weight": 0.9,
360 "note": "the loud-ship mix: a shoal, a battery, and a medic behind both",
361 },
362 {
363 "id": "long_arm",
364 "threat": "bombardier",
365 "tax": "screamer",
366 "modifier": "husk_turret",
367 "min_notoriety": 30,
368 "note": "mortars from one side and a gun line on the other; the middle is not a place to mine",
369 },
370 {
371 "id": "swarm_and_screen",
372 "threat": "lancer",
373 "tax": "mite",
374 "modifier": "husk_turret",
375 "note": "the shoal screens the dash line, the emplacement owns the lane you dodge into",
376 },
377 {
378 "id": "salvage_war",
379 "threat": "husk_turret",
380 "tax": "skimmer",
381 "modifier": "screamer",
382 "biomes": ("wreck_graveyard", "debris_field"),
383 "note": "everything in it wants the haul rather than the hull",
384 },
385]
386
387
388@dataclass(frozen=True)
389class WaveRecipe:
390 """One threat + tax + modifier sentence, validated against the grammar."""
391
392 id: str
393 threat: str
394 tax: str
395 modifier: str
396 biomes: tuple[str, ...] = ()
397 min_notoriety: float = 0.0
398 weight: float = 1.0
399 note: str = ""
400 #: Cheapest budget that pays for one purchase of every slot. Derived.
401 floor_cost: float = field(default=0.0, compare=False)
402
403 @property
404 def slots(self) -> tuple[str, str, str]:
405 """The recipe's archetypes in spending order: threat, tax, modifier."""
406 return (self.threat, self.tax, self.modifier)
407
408 def allows(self, biome_id: str) -> bool:
409 """Whether this recipe may be drawn in *biome_id*."""
410 return not self.biomes or biome_id in self.biomes
411
412 def affordable(self, budget: float) -> bool:
413 """Whether *budget* covers one purchase of each of the three slots."""
414 return budget >= self.floor_cost
415
416
417def _slot_floor(archetype: str) -> float:
418 """Threat cost of the smallest legal purchase of *archetype*."""
419 return float(balance.ENEMIES[archetype].threat * PURCHASE_LIMITS[archetype][0])
420
421
422def _build_recipe(entry: Mapping) -> WaveRecipe:
423 """Validate one authored table entry and freeze it into a :class:`WaveRecipe`.
424
425 Every failure here is a typo that would otherwise show up as a wave that
426 quietly never spawns, so each one raises rather than falling back.
427 """
428 for role, pool in ROLE_POOLS.items():
429 archetype = entry[role]
430 if archetype in EXCLUDED_ARCHETYPES:
431 raise ValueError(f"recipe {entry['id']!r} uses {archetype!r}, which waves never buy")
432 if archetype not in pool:
433 raise ValueError(f"recipe {entry['id']!r} puts {archetype!r} in the {role} slot; expected one of {pool}")
434 slots = (entry["threat"], entry["tax"], entry["modifier"])
435 if len(set(slots)) != len(slots):
436 raise ValueError(f"recipe {entry['id']!r} uses the same archetype in two slots: {slots}")
437 for biome_id in entry.get("biomes", ()):
438 if biome_id not in balance.BIOMES:
439 raise ValueError(f"recipe {entry['id']!r} names unknown biome {biome_id!r}")
440 return WaveRecipe(
441 id=entry["id"],
442 threat=entry["threat"],
443 tax=entry["tax"],
444 modifier=entry["modifier"],
445 biomes=tuple(entry.get("biomes", ())),
446 min_notoriety=float(entry.get("min_notoriety", 0.0)),
447 weight=float(entry.get("weight", 1.0)),
448 note=entry.get("note", ""),
449 floor_cost=sum(_slot_floor(archetype) for archetype in slots),
450 )
451
452
453def _build_recipes(table: Sequence[Mapping]) -> tuple[WaveRecipe, ...]:
454 recipes = tuple(_build_recipe(entry) for entry in table)
455 ids = [recipe.id for recipe in recipes]
456 duplicates = {name for name in ids if ids.count(name) > 1}
457 if duplicates:
458 raise ValueError(f"duplicate wave recipe ids: {sorted(duplicates)}")
459 return recipes
460
461
462#: The validated table, in authoring order.
463RECIPES: tuple[WaveRecipe, ...] = _build_recipes(WAVE_RECIPES)
464RECIPES_BY_ID: dict[str, WaveRecipe] = {recipe.id: recipe for recipe in RECIPES}
465
466
467# ============================================================================
468# Choosing and pricing a wave
469# ============================================================================
470
471
472def archetype_weight(biome_id: str, archetype: str) -> float:
473 """This biome's bias on one archetype; 1.0 where the biome is neutral."""
474 return float(BIOME_WEIGHTS.get(biome_id, {}).get(archetype, 1.0))
475
476
477def recipe_weight(recipe: WaveRecipe, biome_id: str) -> float:
478 """The recipe's draw weight in *biome_id*.
479
480 The three slots' biome biases combine as a geometric mean, so a recipe is
481 promoted when the biome likes all of it and barred outright when the biome
482 bars any of it.
483 """
484 product = 1.0
485 for archetype in recipe.slots:
486 weight = archetype_weight(biome_id, archetype)
487 if weight <= 0.0:
488 return 0.0
489 product *= weight
490 return recipe.weight * product ** (1.0 / 3.0)
491
492
493def eligible_recipes(biome_id: str, budget: float, notoriety: float, *, barred: Iterable[str] = ()) -> list[WaveRecipe]:
494 """Every recipe this biome, budget and notoriety reading allow, in table order.
495
496 *barred* names archetypes the run is not allowed to buy at all, which is how
497 the staged opening keeps Lancers out of run one.
498 """
499 excluded = frozenset(barred)
500 return [
501 recipe
502 for recipe in RECIPES
503 if recipe.allows(biome_id)
504 and recipe.affordable(budget)
505 and notoriety >= recipe.min_notoriety
506 and recipe_weight(recipe, biome_id) > 0.0
507 and not excluded.intersection(recipe.slots)
508 ]
509
510
511def choose_recipe(
512 rng: random.Random,
513 biome_id: str,
514 budget: float,
515 notoriety: float,
516 *,
517 avoid: str = "",
518 barred: Iterable[str] = (),
519) -> WaveRecipe | None:
520 """Draw one recipe by biome-weighted chance, or ``None`` if none is affordable.
521
522 *avoid* names the recipe that ran last: it stays in the draw at
523 :data:`RECIPE_REPEAT_WEIGHT` of its weight, so a node keeps its character
524 without repeating one sentence all sector. *barred* is passed through to
525 :func:`eligible_recipes`.
526 """
527 candidates = eligible_recipes(biome_id, budget, notoriety, barred=barred)
528 if not candidates:
529 return None
530 weights = [
531 recipe_weight(recipe, biome_id) * (RECIPE_REPEAT_WEIGHT if recipe.id == avoid else 1.0) for recipe in candidates
532 ]
533 total = sum(weights)
534 if total <= 0.0:
535 return candidates[0]
536 roll = rng.uniform(0.0, total)
537 for recipe, weight in zip(candidates, weights, strict=True):
538 roll -= weight
539 if roll <= 0.0:
540 return recipe
541 return candidates[-1]
542
543
544def purchase(recipe: WaveRecipe, budget: float) -> tuple[list[tuple[str, int]], float]:
545 """Spend *budget* on *recipe*, returning ``(purchases, threat spent)``.
546
547 One of everything first, in threat-tax-modifier order, so a wave that can
548 only just be paid for is still a whole sentence; then a round-robin top-up
549 that thickens each slot a unit at a time up to its
550 :data:`PURCHASE_LIMITS` ceiling. Leftover budget that buys nothing legal is
551 simply not spent, which is what keeps a rich wave a mix rather than a
552 stack.
553 """
554 remaining = float(budget)
555 counts: dict[str, int] = {}
556 for archetype in recipe.slots:
557 floor = _slot_floor(archetype)
558 if floor <= remaining:
559 counts[archetype] = PURCHASE_LIMITS[archetype][0]
560 remaining -= floor
561 bought = True
562 while bought:
563 bought = False
564 for archetype in recipe.slots:
565 if archetype not in counts or counts[archetype] >= PURCHASE_LIMITS[archetype][1]:
566 continue
567 unit = float(balance.ENEMIES[archetype].threat)
568 if unit > remaining:
569 continue
570 counts[archetype] += 1
571 remaining -= unit
572 bought = True
573 purchases = [(archetype, counts[archetype]) for archetype in recipe.slots if archetype in counts]
574 return purchases, float(budget) - remaining
575
576
577# ============================================================================
578# Staging
579# ============================================================================
580
581#: Every archetype a wave can buy, by its ``balance.ENEMIES`` id.
582ARCHETYPE_TYPES: dict[str, type[Enemy]] = {
583 "mite": MiteShoal,
584 "skimmer": Skimmer,
585 "lancer": Lancer,
586 "mag_mine": MagMine,
587 "welder": Welder,
588 "bombardier": Bombardier,
589 "screamer": Screamer,
590 "husk_turret": HuskTurret,
591}
592
593
594@dataclass
595class SpawnChevron:
596 """One edge marker: the direction a group is about to tear in from.
597
598 ``bearing`` is radians anticlockwise from +X seen from above, matching the
599 rest of the flight-plane convention; ``origin`` is the plane point the group
600 forms up on, which is what the HUD projects to a screen edge and what the
601 audio director pans the chirp to.
602 """
603
604 archetype: str
605 bearing: float
606 origin: Vec2
607 remaining: float
608
609
610@dataclass
611class SpawnGroup:
612 """One purchase, placed: an archetype, how many, and where they come from.
613
614 ``centre`` is the ship position the bearing was measured from, kept so the
615 bodies form up where the chevron promised even though the ship has had a
616 second to move since.
617 """
618
619 archetype: str
620 count: int
621 bearing: float
622 origin: Vec2
623 centre: Vec2
624
625
626class WaveComposer(Node):
627 """Spends the sector's threat budget on a wave every 35 to 55 seconds.
628
629 Register as ``Services.WAVES`` and call :meth:`start` on sector entry with
630 the chart node's seed. The schedule then runs itself: roll an interval,
631 price a recipe against ``balance.wave_threat_budget``, raise the edge
632 chevrons, and tear the bodies in a second later. :meth:`stop` and
633 :meth:`resume` bracket a hunter arrival, which is the one thing that is
634 allowed to interrupt the schedule, because the arrival is itself the wave.
635
636 Everything the composer decides is reproducible from ``(seed, sector index,
637 biome)``: the same node always throws the same waves in the same order, so
638 a player who backs out of a jump and returns meets the danger they left.
639
640 The staged opening is the one exception to "the schedule runs itself":
641 while :attr:`held_for_onboarding` is true a wave that comes due waits for
642 the hold to lift rather than being thrown away, and run one spends
643 :meth:`wave_budget` rather than the whole formula. See
644 :meth:`set_run_number`.
645 """
646
647 #: ``runtime.SignalNames.WAVE_COMPOSED`` (recipe_id, threat_spent), at chevron time.
648 wave_composed = Signal(str, float)
649 #: ``runtime.SignalNames.WAVE_SPAWNED`` (unit count), when the bodies exist.
650 wave_spawned = Signal(int)
651
652 def __init__(self, **kwargs):
653 super().__init__(**kwargs)
654 self.sector_index = 0
655 self.biome_id: str | None = None
656 self.seed = 0
657 #: Where spawned bodies are parented; defaults to the run scene root.
658 self.spawn_parent: Node3D | None = None
659 self.waves_spawned = 0
660 self.last_recipe_id = ""
661 self.last_threat_spent = 0.0
662 self.last_purchases: list[tuple[str, int]] = []
663 self.last_elites: list[tuple[str, str]] = []
664 self.seconds_to_wave = 0.0
665 self._rng = random.Random(0)
666 self._running = False
667 self._handle = None
668 self._chevrons: list[SpawnChevron] = []
669 self._wiring = SignalWiring(self)
670 self._run_number = RUN_NUMBER_UNKNOWN
671 self._run_elapsed = 0.0
672 self._arrival_survived = False
673
674 # -- Lifecycle ----------------------------------------------------------
675
676 def on_ready(self):
677 self._wiring.want(SignalNames.HUNTER_ARRIVED, self._on_hunter_arrived)
678 self._wiring.want(SignalNames.HUNTER_DEPARTED, self._on_hunter_departed)
679 self._wiring.sweep()
680
681 def on_update(self, dt: float):
682 self._wiring.poll(dt)
683 if self.biome_id is not None:
684 # The run clock, which the staged opening reads. It keeps running
685 # through an arrival: a pause the hunter imposed is not quiet.
686 self._run_elapsed += dt
687
688 def on_exit_tree(self):
689 self.stop()
690
691 # -- Schedule -----------------------------------------------------------
692
693 @property
694 def active(self) -> bool:
695 """Whether waves are currently being scheduled."""
696 return self._running
697
698 @property
699 def paused(self) -> bool:
700 """Whether a started sector's schedule is held, as during an arrival."""
701 return self.biome_id is not None and not self._running
702
703 @property
704 def chevrons(self) -> list[SpawnChevron]:
705 """The edge markers currently up, for the HUD and the audio director."""
706 return list(self._chevrons)
707
708 def start(self, sector_index: int, biome_id: str, seed: int, *, run_number: int | None = None) -> None:
709 """Begin composing for one sector, seeded so its danger is learnable.
710
711 *run_number* is the staged opening's input. Left out, the composer reads
712 it off the run scene's config the first time, and falls back to
713 :data:`RUN_NUMBER_UNKNOWN` (nothing softened) in a scene that has none.
714 A run number already in hand is never overwritten by a sector change:
715 jumping is a sector boundary, not a run boundary.
716
717 Starting a sector also clears the field. This is the game's one sector
718 boundary (``flow.enter_sector`` calls it on every jump): an explicit
719 call, rather than a ``SECTOR_ENTERED`` handler, because the field must
720 be empty before the new sector begins, not whenever the wiring gets
721 around to it.
722 """
723 if biome_id not in balance.BIOMES:
724 raise KeyError(f"unknown biome {biome_id!r}; expected one of {sorted(balance.BIOMES)}")
725 self.stop()
726 self.despawn_all()
727 if run_number is not None:
728 self.set_run_number(run_number)
729 elif self._run_number == RUN_NUMBER_UNKNOWN:
730 self.set_run_number(self._discover_run_number())
731 self.sector_index = int(sector_index)
732 self.biome_id = biome_id
733 self.seed = int(seed)
734 self._rng = random.Random(f"shrike-waves:{seed}:{sector_index}:{biome_id}")
735 self.waves_spawned = 0
736 self.last_recipe_id = ""
737 self.last_threat_spent = 0.0
738 self.last_purchases = []
739 self.last_elites = []
740 self.resume()
741
742 def stop(self) -> None:
743 """Hold the schedule and drop any chevron already raised.
744
745 A hunter arrival calls this: the arrival is the wave, and nothing else
746 gets to share the screen with it. The seeded stream is kept, so
747 :meth:`resume` carries on with the sector's own sequence.
748 """
749 self._running = False
750 if self._handle is not None:
751 self.stop_coroutine(self._handle)
752 self._handle = None
753 self._chevrons.clear()
754 self.seconds_to_wave = 0.0
755
756 def despawn_all(self) -> int:
757 """Clear the field of hostiles and return how many bodies were taken.
758
759 A wave belongs to the sector it was composed for. Bodies are parented to
760 the run scene rather than to the sector node, so nothing removes them
761 when the sector is torn down and swapped: without this a jump carries the
762 pack that chased you into it through the warp and drops it, mid-fight,
763 into the next node's opening seconds. The hunter is untouched, because it
764 follows the ship by design and leaves on its own signal.
765 """
766 tree = self.tree
767 if tree is None:
768 return 0
769 doomed = tree.group(Groups.ENEMIES)
770 for enemy in doomed:
771 enemy.destroy()
772 return len(doomed)
773
774 def resume(self) -> None:
775 """Restart a held schedule, rolling a fresh interval before the next wave."""
776 if self._running or self.biome_id is None:
777 return
778 self._running = True
779 self._handle = self.start_coroutine(self._compose_loop())
780
781 # -- Budget -------------------------------------------------------------
782
783 def notoriety(self) -> float:
784 """The run's notoriety reading, or zero before that system is mounted."""
785 tree = self.tree
786 if tree is None:
787 return 0.0
788 service = tree.singletons.get(Services.NOTORIETY)
789 return float(getattr(service, "value", 0.0)) if service is not None else 0.0
790
791 def threat_budget(self) -> float:
792 """T = 10 + 3 x sector index + notoriety / 10, for the current sector."""
793 return balance.wave_threat_budget(self.sector_index, self.notoriety())
794
795 def wave_budget(self) -> float:
796 """What a wave may actually spend: the formula, softened by the run.
797
798 Only the staged runs differ from :meth:`threat_budget`; every later run
799 spends the formula whole.
800 """
801 return self.threat_budget() * self.budget_fraction()
802
803 # -- The staged opening -------------------------------------------------
804
805 @property
806 def run_number(self) -> int:
807 """Which run this is, or :data:`RUN_NUMBER_UNKNOWN` when nothing said."""
808 return self._run_number
809
810 def set_run_number(self, run_number: int) -> None:
811 """Tell the composer which run it is composing for.
812
813 Changing it restarts the staged opening: the run clock and the memory of
814 the scripted arrival both belong to one run, not to one sector.
815 """
816 value = max(RUN_NUMBER_UNKNOWN, int(run_number))
817 if value == self._run_number:
818 return
819 self._run_number = value
820 self._run_elapsed = 0.0
821 self._arrival_survived = False
822
823 @property
824 def softened(self) -> bool:
825 """Whether this run is still inside the staged opening."""
826 return RUN_NUMBER_UNKNOWN < self._run_number <= SOFTENED_RUNS
827
828 @property
829 def arrival_survived(self) -> bool:
830 """Whether a hunter arrival has come and gone this run."""
831 return self._arrival_survived
832
833 @property
834 def run_elapsed(self) -> float:
835 """Seconds of composing this run, across every sector of it."""
836 return self._run_elapsed
837
838 @property
839 def held_for_onboarding(self) -> bool:
840 """Whether the staged opening is still holding the next wave back.
841
842 Run one holds everything until the scripted arrival has been survived:
843 the first thing a new pilot fights is the Shrike, and the game says so
844 rather than opening with a Lancer pack behind the tutorial cards. Run
845 two only holds :data:`RUN_2_FIRST_WAVE_GRACE_S`.
846
847 Run one's hold has a ceiling because the condition that lifts it is not
848 one the composer can bring about. A pilot who leaves the opening sector
849 before the arrival is provoked is never handed one, and without a
850 ceiling the run would have no way back to a schedule at all, meeting
851 nothing whatsoever for as long as it lasted. Past :data:`RUN_1_HOLD_CEILING_S` the
852 schedule runs regardless, still at run one's softened budget and without
853 the barred archetypes.
854 """
855 if not self.softened:
856 return False
857 if self._run_number == 1:
858 return not self._arrival_survived and self._run_elapsed < RUN_1_HOLD_CEILING_S
859 return self._run_elapsed < RUN_2_FIRST_WAVE_GRACE_S
860
861 def budget_fraction(self) -> float:
862 """Share of the threat budget this run's waves may spend."""
863 return RUN_1_BUDGET_FRACTION if self._run_number == 1 else 1.0
864
865 def barred_archetypes(self) -> frozenset[str]:
866 """Archetypes this run never buys."""
867 return RUN_1_BARRED_ARCHETYPES if self._run_number == 1 else frozenset()
868
869 def _discover_run_number(self) -> int:
870 """The run number off the run scene's config, or unknown without one.
871
872 The composer is a singleton rather than a child of the run scene, so it
873 reads the run it belongs to from the scene root's own state. A caller
874 that would rather say it outright passes ``run_number`` to
875 :meth:`start`.
876 """
877 tree = self.tree
878 root = tree.root if tree is not None else None
879 if root is None:
880 return RUN_NUMBER_UNKNOWN
881 value = getattr(getattr(root, "state", None), "run_number", None)
882 if value is None:
883 config = getattr(root, "config_dict", None)
884 value = config.get("run_number") if isinstance(config, Mapping) else None
885 return RUN_NUMBER_UNKNOWN if value is None else max(RUN_NUMBER_UNKNOWN, int(value))
886
887 def live_enemy_units(self) -> int:
888 """Bodies currently on the field, counting a mite shoal as its live mites."""
889 tree = self.tree
890 if tree is None:
891 return 0
892 total = 0
893 for enemy in tree.group(Groups.ENEMIES):
894 if getattr(enemy, "destroying", False):
895 continue
896 total += int(getattr(enemy, "alive_count", 1))
897 return total
898
899 # -- Composition --------------------------------------------------------
900
901 def compose(self, budget: float) -> list[tuple[str, int]]:
902 """Draw a recipe for *budget* and price it into ``(archetype, count)`` buys.
903
904 Records the drawn recipe on :attr:`last_recipe_id` and what it cost on
905 :attr:`last_threat_spent`. An empty list means no recipe in this biome
906 was affordable, which is a quiet wave rather than an error.
907 """
908 biome_id = self.biome_id or "debris_field"
909 recipe = choose_recipe(
910 self._rng,
911 biome_id,
912 budget,
913 self.notoriety(),
914 avoid=self.last_recipe_id,
915 barred=self.barred_archetypes(),
916 )
917 if recipe is None:
918 self.last_recipe_id = ""
919 self.last_threat_spent = 0.0
920 self.last_purchases = []
921 return []
922 purchases, spent = purchase(recipe, budget)
923 self.last_recipe_id = recipe.id
924 self.last_threat_spent = spent
925 self.last_purchases = list(purchases)
926 return purchases
927
928 def _compose_loop(self):
929 while self._running:
930 self.seconds_to_wave = self._rng.uniform(balance.WAVE_INTERVAL_MIN_S, balance.WAVE_INTERVAL_MAX_S)
931 while self.seconds_to_wave > 0.0:
932 dt = yield
933 self.seconds_to_wave = max(0.0, self.seconds_to_wave - (dt or 0.0))
934 # A wave that comes due inside the staged opening is deferred, not
935 # cancelled: it lands the instant the hold lifts. Rolling a fresh
936 # interval instead would put run two's first wave a whole interval
937 # past its grace and leave the opening ninety seconds of empty
938 # screen.
939 while self.held_for_onboarding:
940 yield
941 yield from self._run_wave()
942
943 def _run_wave(self):
944 budget = self.wave_budget()
945 purchases = self.compose(budget)
946 if not purchases:
947 return
948 self.wave_composed.emit(self.last_recipe_id, self.last_threat_spent)
949 groups = self._plan_groups(purchases)
950 lead = 0.0 if self.biome_id in FOGGED_BIOMES else balance.SPAWN_EDGE_CHEVRON_S
951 self._raise_chevrons(groups, lead)
952 elapsed = 0.0
953 while elapsed < lead:
954 dt = yield
955 elapsed += dt or 0.0
956 for chevron in self._chevrons:
957 chevron.remaining = max(0.0, lead - elapsed)
958 self._chevrons.clear()
959 spawned = self._spawn(groups, budget)
960 self.waves_spawned += 1
961 self.wave_spawned.emit(spawned)
962
963 # -- Staging ------------------------------------------------------------
964
965 def _ship_centre(self) -> Vec2:
966 tree = self.tree
967 if tree is not None:
968 for ship in tree.group(Groups.SHIP):
969 return to_plane(ship.position)
970 return Vec2(0.0, 0.0)
971
972 def _plan_groups(self, purchases: Sequence[tuple[str, int]]) -> list[SpawnGroup]:
973 """Give each purchase its own readable bearing around the ship."""
974 centre = self._ship_centre()
975 base = self._rng.uniform(0.0, math.tau)
976 step = math.tau / max(1, len(purchases))
977 groups: list[SpawnGroup] = []
978 for index, (archetype, count) in enumerate(purchases):
979 bearing = base + index * step + self._rng.uniform(-SPAWN_BEARING_JITTER, SPAWN_BEARING_JITTER)
980 origin = Vec2(
981 float(centre.x) + math.cos(bearing) * SPAWN_RING_RADIUS,
982 float(centre.y) + math.sin(bearing) * SPAWN_RING_RADIUS,
983 )
984 groups.append(
985 SpawnGroup(
986 archetype=archetype,
987 count=int(count),
988 bearing=bearing % math.tau,
989 origin=origin,
990 centre=Vec2(float(centre.x), float(centre.y)),
991 )
992 )
993 return groups
994
995 def _raise_chevrons(self, groups: Iterable[SpawnGroup], lead: float) -> None:
996 """Put an edge marker up per group and pan its chirp, unless the biome fogs it."""
997 self._chevrons = []
998 if lead <= 0.0:
999 return
1000 for group in groups:
1001 self._chevrons.append(
1002 SpawnChevron(
1003 archetype=group.archetype,
1004 bearing=group.bearing,
1005 origin=Vec2(float(group.origin.x), float(group.origin.y)),
1006 remaining=lead,
1007 )
1008 )
1009 tree = self.tree
1010 audio = tree.singletons.get(Services.AUDIO) if tree is not None else None
1011 if audio is None or not hasattr(audio, "play"):
1012 return
1013 for chevron in self._chevrons:
1014 audio.play("spawn_chevron", position=from_plane(chevron.origin))
1015
1016 def _spawn_parent(self) -> Node | None:
1017 if self.spawn_parent is not None and self.spawn_parent.tree is not None:
1018 return self.spawn_parent
1019 tree = self.tree
1020 if tree is None:
1021 return None
1022 return tree.root if tree.root is not None else self
1023
1024 def _spawn(self, groups: Sequence[SpawnGroup], budget: float) -> int:
1025 """Mount the wave's bodies and return how many units arrived."""
1026 parent = self._spawn_parent()
1027 if parent is None:
1028 return 0
1029 capacity = max(0, balance.ENEMY_HARD_CAP - self.live_enemy_units())
1030 bodies: list[Enemy] = []
1031 units = 0
1032 for group in groups:
1033 count = min(group.count, capacity - units)
1034 if group.archetype == "mite":
1035 if count < balance.MITE_SHOAL_MIN:
1036 continue
1037 bodies.append(self._mount_mites(parent, group, count))
1038 units += count
1039 continue
1040 for member in range(count):
1041 bodies.append(self._mount(parent, group, member))
1042 units += 1
1043 self._apply_elites(bodies, budget)
1044 return units
1045
1046 def _member_position(self, group: SpawnGroup, member: int) -> Vec2:
1047 centre = group.centre
1048 bearing = group.bearing + self._rng.uniform(-SPAWN_GROUP_SPREAD, SPAWN_GROUP_SPREAD)
1049 radius = SPAWN_RING_RADIUS + self._rng.uniform(-SPAWN_RING_JITTER, SPAWN_RING_JITTER)
1050 return Vec2(float(centre.x) + math.cos(bearing) * radius, float(centre.y) + math.sin(bearing) * radius)
1051
1052 def _body_name(self, archetype: str, member: int) -> str:
1053 stem = "".join(part.title() for part in archetype.split("_"))
1054 return f"{stem}W{self.waves_spawned + 1}N{member + 1}"
1055
1056 def _mount_mites(self, parent: Node, group: SpawnGroup, count: int) -> Enemy:
1057 shoal = MiteShoal(name=self._body_name("mite", 0), count=count, seed=self._rng.getrandbits(32))
1058 shoal.position = from_plane(group.origin)
1059 parent.add_child(shoal)
1060 return shoal
1061
1062 def _mount(self, parent: Node, group: SpawnGroup, member: int) -> Enemy:
1063 archetype = group.archetype
1064 node_type = ARCHETYPE_TYPES[archetype]
1065 if archetype == "mag_mine":
1066 drift = self._rng.uniform(0.0, math.tau)
1067 enemy = node_type(name=self._body_name(archetype, member), drift=Vec2(math.cos(drift), math.sin(drift)))
1068 else:
1069 enemy = node_type(name=self._body_name(archetype, member))
1070 enemy.position = from_plane(self._member_position(group, member))
1071 parent.add_child(enemy)
1072 setter = getattr(enemy, "set_seed", None)
1073 if callable(setter):
1074 setter(self._rng.getrandbits(32))
1075 return enemy
1076
1077 def _apply_elites(self, bodies: Sequence[Enemy], budget: float) -> None:
1078 """Tag part of a rich wave with elite modifiers.
1079
1080 Past ``balance.ELITE_THREAT_THRESHOLD`` one body is elite, and every
1081 further :data:`ELITE_BUDGET_PER_EXTRA` of budget buys one more, up to
1082 :data:`ELITE_MAX_PER_WAVE`. Tags land after the bodies are mounted, so
1083 a modifier that scales a hull sees the hull it is scaling.
1084 """
1085 self.last_elites = []
1086 if not bodies or budget <= balance.ELITE_THREAT_THRESHOLD:
1087 return
1088 extra = int((budget - balance.ELITE_THREAT_THRESHOLD) / ELITE_BUDGET_PER_EXTRA)
1089 wanted = min(len(bodies), ELITE_MAX_PER_WAVE, 1 + extra)
1090 for enemy in self._rng.sample(list(bodies), wanted):
1091 modifier = self._rng.choice(balance.ELITE_MODIFIERS)
1092 apply_elite(enemy, modifier)
1093 self.last_elites.append((enemy.ARCHETYPE, modifier))
1094
1095 # -- Hunter handshake ---------------------------------------------------
1096
1097 def _on_hunter_arrived(self, arrival_index: int) -> None:
1098 self.stop()
1099
1100 def _on_hunter_departed(self) -> None:
1101 # Run one's hold lifts here: the scripted arrival has been survived, so
1102 # the pilot has met the thing the waves are a warm-up for.
1103 self._arrival_survived = True
1104 self.resume()