engine/affixes/roll

PURPOSE

Pure data pass that decides which affixes (if any) a freshly-spawned elite-pack leader carries. Called from engine/enemies/spawner._spawnElitePack at leader spawn time. The pool is the world-roaming subset only — no BossArena-dependent affixes — and rolls are deterministic when an RNG is injected.

OWNS

  • rollEliteAffixes(rarity, rng?, pool?, archetype?) — the public entry point that returns an AffixInstance[] for direct assignment to enemy.affixes.
  • WORLD_ELITE_AFFIX_POOL — the eight-id default candidate pool (burning_aura, volatile, regenerating, reflective_burst, phasing, summoner, hardened, gravity_well). Kept local to this file to break a circular module-init chain (runtime → spawner → roll → data/affixes → runtime); a test asserts equality with the data/affixes re-export.
  • RARE_AFFIX_DROP_RATE0.7, the probability that a rare host rolls any affix at all.
  • BIAS_WEIGHT3.0, the multiplier applied to a thematic affix in the weighted sampler. A biased affix is ~3× as likely as an unbiased one when both are still in the pool; not a guaranteed match.
  • ARCHETYPE_AFFIX_BIAS — the archetype-to-preferred-affix table (charger/brute → hardened, racer → phasing, mortar/bombardier → volatile, sniper/lurker → reflective_burst, gunner → burning_aura, wisp → summoner, field → regenerating, orb → gravity_well).
  • Rng type — () => number returning uniform [0,1).
  • Internal targetCountForRarity, sampleUnique, sampleWeighted helpers.

READS FROM

  • AffixInstance from ../core/types — return-row shape.
  • EnemyRarity from ../../data/enemies/_types — drives the count distribution.
  • The injected Rng function (defaults to Math.random).
  • The injected pool and archetype arguments when supplied by the caller.

PUSHES TO

Nothing. The function returns a fresh AffixInstance[]; the caller (the spawner) assigns it to enemy.affixes. No globals, no event bus, no telemetry, no logging.

DOES NOT

  • Does not import from data/affixes (would create the circular init chain documented above).
  • Does not mutate the input pool array — sampleUnique and sampleWeighted both call pool.slice() first.
  • Does not apply or activate the affixes — only chooses ids and wraps them in { defId, state: {} }.
  • Does not de-duplicate across multiple calls — uniqueness is within a single roll only (Fisher-Yates without replacement).
  • Does not guarantee the biased affix appears; bias only shifts weights.
  • Does not roll for common or uncommon rarities — they short-circuit to [].
  • Does not return more affixes than the pool size; target is capped via Math.min(n, work.length).
  • Does not handle BossArena-specific affixes; the world pool is the only default.

Signals

None emitted. Pure function.

Entry points

  • rollEliteAffixes(rarity, rng?, pool?, archetype?) — called from engine/enemies/spawner._spawnElitePack on leader spawn.
  • Re-exported through engine/affixes barrel (consumed by spawner + tests).
  • Test suites import rollEliteAffixes, WORLD_ELITE_AFFIX_POOL, RARE_AFFIX_DROP_RATE, BIAS_WEIGHT, and ARCHETYPE_AFFIX_BIAS directly for deterministic assertions with a seeded RNG.

Pattern notes

Rarity-to-count table (target count before pool cap):

RarityTarget countNotes
common0Basic enemies — no affixes.
uncommon0Basic enemies — no affixes.
rare1 with RARE_AFFIX_DROP_RATE (0.7) probability, else 0Single coin-flip before the sample.
epic1 or 2 (50/50)Always at least 1.
legendary2 or 3 (50/50)Always at least 2.

Fisher-Yates without replacement (sampleUnique) — copies the pool with slice(), then for each of take = min(n, work.length) picks an index j in [i, work.length), swaps work[i] with work[j], and pushes work[i]. Standard partial shuffle; each id is sampled exactly once and the input pool is never mutated.

Weighted sampling (sampleWeighted) — when an archetype is supplied AND its bias entry is present in the pool, this path runs instead of the uniform sampler. For each pick it rebuilds a cumulative-weight array over the remaining work entries (weight BIAS_WEIGHT = 3.0 for the biased id, 1.0 otherwise), draws r = rng() * total, linear-scans cum[] for the first index where r < cum[i], then removes the chosen entry by swap-and-pop. O(n²) on pool size, acceptable for the eight-entry pool.

Bias dispatchrollEliteAffixes only enters the weighted path when archetype !== undefined, ARCHETYPE_AFFIX_BIAS[archetype] resolves, AND pool.includes(biasedAffix). Callers that omit archetype (and the existing test suite) keep the original uniform-sampling behavior.

Early outs — if target === 0 or pool.length === 0, the function returns [] immediately and never enters either sampler.

RNG injection — the rng parameter defaults to Math.random but every internal call routes through it, so a seeded RNG produces fully deterministic affix rolls for tests.

Local pool, enforced parityWORLD_ELITE_AFFIX_POOL is intentionally duplicated here (not imported) to keep the module-init graph acyclic; data/affixes.ts re-exports its own WORLD_ELITE_AFFIX_IDS and a test asserts the two lists are equal, so the duplication is build-time-checked rather than comment-trusted.