shrike/waves.py¶

Part of SHRIKE.

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