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()