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 anAffixInstance[]for direct assignment toenemy.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 thedata/affixesre-export.RARE_AFFIX_DROP_RATE—0.7, the probability that ararehost rolls any affix at all.BIAS_WEIGHT—3.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).Rngtype —() => numberreturning uniform[0,1).- Internal
targetCountForRarity,sampleUnique,sampleWeightedhelpers.
READS FROM
AffixInstancefrom../core/types— return-row shape.EnemyRarityfrom../../data/enemies/_types— drives the count distribution.- The injected
Rngfunction (defaults toMath.random). - The injected
poolandarchetypearguments 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
poolarray —sampleUniqueandsampleWeightedboth callpool.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
commonoruncommonrarities — they short-circuit to[]. - Does not return more affixes than the pool size;
targetis capped viaMath.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 fromengine/enemies/spawner._spawnElitePackon leader spawn.- Re-exported through
engine/affixesbarrel (consumed by spawner + tests). - Test suites import
rollEliteAffixes,WORLD_ELITE_AFFIX_POOL,RARE_AFFIX_DROP_RATE,BIAS_WEIGHT, andARCHETYPE_AFFIX_BIASdirectly for deterministic assertions with a seeded RNG.
Pattern notes
Rarity-to-count table (target count before pool cap):
| Rarity | Target count | Notes |
|---|---|---|
common | 0 | Basic enemies — no affixes. |
uncommon | 0 | Basic enemies — no affixes. |
rare | 1 with RARE_AFFIX_DROP_RATE (0.7) probability, else 0 | Single coin-flip before the sample. |
epic | 1 or 2 (50/50) | Always at least 1. |
legendary | 2 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 dispatch — rollEliteAffixes 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 parity — WORLD_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.