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