azeroth-shell 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +362 -85
  2. package/bin/azsh.mjs +35 -19
  3. package/package.json +12 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/gzLoader.mjs +47 -6
  8. package/src/cli/hdArt.mjs +81 -0
  9. package/src/cli/iconArt.mjs +145 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +298 -0
  12. package/src/cli/oocPrep.mjs +32 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +48 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +319 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +21 -3
  20. package/src/cli/tui.mjs +262 -102
  21. package/src/cli/tuiAutopilot.mjs +356 -0
  22. package/src/cli/tuiBags.mjs +37 -4
  23. package/src/cli/tuiBg.mjs +370 -41
  24. package/src/cli/tuiCheck.mjs +633 -37
  25. package/src/cli/tuiCity.mjs +146 -41
  26. package/src/cli/tuiCompanionKit.mjs +6 -6
  27. package/src/cli/tuiCompanions.mjs +211 -41
  28. package/src/cli/tuiCreate.mjs +230 -0
  29. package/src/cli/tuiDungeon.mjs +57 -21
  30. package/src/cli/tuiFight.mjs +467 -88
  31. package/src/cli/tuiFightPanes.mjs +42 -14
  32. package/src/cli/tuiFloor.mjs +21 -9
  33. package/src/cli/tuiGear.mjs +17 -14
  34. package/src/cli/tuiLoot.mjs +6 -6
  35. package/src/cli/tuiMap.mjs +214 -0
  36. package/src/cli/tuiPaperdoll.mjs +108 -0
  37. package/src/cli/tuiPartyStage.mjs +206 -0
  38. package/src/cli/tuiPet.mjs +16 -8
  39. package/src/cli/tuiProfessions.mjs +27 -25
  40. package/src/cli/tuiRotationCond.mjs +390 -0
  41. package/src/cli/tuiSaves.mjs +24 -1
  42. package/src/cli/tuiSearch.mjs +79 -27
  43. package/src/cli/tuiSettings.mjs +3 -4
  44. package/src/cli/tuiStage.mjs +360 -0
  45. package/src/cli/tuiTalentTree.mjs +124 -0
  46. package/src/cli/tuiTalents.mjs +3 -4
  47. package/src/cli/tuiViews.mjs +633 -172
  48. package/src/cli/tuiWiki.mjs +2 -3
  49. package/src/cli/tuiWorld.mjs +90 -7
  50. package/src/data/gear-art.bin.br +0 -0
  51. package/src/data/hd-art.json +114 -0
  52. package/src/data/icon-art.bin.gz +0 -0
  53. package/src/data/model-art-worn.bin.br +0 -0
  54. package/src/data/model-art.bin.gz +0 -0
  55. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  56. package/src/data/tables/creatures.json.gz +0 -0
  57. package/src/data/world-map-sixel.bin.br +0 -0
  58. package/src/data/world-map.bin.gz +0 -0
  59. package/src/domain/bg/match.ts +85 -24
  60. package/src/domain/bg/strategies.ts +144 -29
  61. package/src/domain/bg/types.ts +2 -0
  62. package/src/domain/bg/wsg.ts +35 -20
  63. package/src/domain/character/character.ts +6 -0
  64. package/src/domain/pet/pet.ts +31 -0
  65. package/src/domain/quests/doQuest.ts +24 -6
  66. package/src/domain/rotation/build.ts +3031 -3031
  67. package/src/domain/rotation/classes/paladin.ts +16 -11
  68. package/src/domain/rotation/evaluate.ts +3 -0
  69. package/src/engine/combat/applySpell.ts +20 -9
  70. package/src/engine/combat/applySpellLate.ts +16 -12
  71. package/src/engine/combat/areaAura.ts +284 -283
  72. package/src/engine/combat/fighterFactory.ts +1 -1
  73. package/src/engine/combat/pickAction.ts +3494 -3449
  74. package/src/engine/combat/procsDamage.ts +31 -1
  75. package/src/engine/combat/sessionChase.ts +19 -2
  76. package/src/engine/combat/sessionMotionKit.ts +40 -2
  77. package/src/engine/game/loadReconcile.ts +21 -1
  78. package/src/engine/game/pets.ts +33 -1
  79. package/src/engine/game/types.ts +4 -0
  80. package/src/quests/quests.ts +60 -0
@@ -19,7 +19,7 @@ import {
19
19
  spellCritChance,
20
20
  ccResistChance,
21
21
  } from './formulas.ts';
22
- import { fighterSpellCrit, fighterEffectiveArmor, applyBuffMod } from './fighterFactory.ts';
22
+ import { fighterSpellCrit, fighterEffectiveArmor, applyBuffMod, SPELL_DAMAGE_MOD_SCHOOL } from './fighterFactory.ts';
23
23
  import {
24
24
  powerOfType, maxPowerOfType, setPowerOfType, casterPowerTypeId, powerTypeName,
25
25
  } from './powerPools.ts';
@@ -267,6 +267,35 @@ export function installProcsDamage(ctx: CombatContext, deps: installProcsDamageD
267
267
  }
268
268
  };
269
269
 
270
+ // A charged stat buff with DEAL spell flags (Chaos Fire 24389: "your next Fire spell") spends a
271
+ // charge when the bearer lands a damaging spell. Aura 13 has no proc handler, so the generic path
272
+ // drops the charge. A one-school damage mod only takes a spell of that school (24389 SchoolMask 0x04).
273
+ // Cite: UnitAuraProcHandler.cpp:46,:500-509; sql/archive/0.10/5019_mangos_spell_proc_event.sql.
274
+ const consumeChargedBuffsOnDealHit = (src: Fighter, fromSpell: SpellDef): void => {
275
+ const t = ctx.t;
276
+ const dealFlags = harmfulSpellDealFlags();
277
+ const mine = ctx.effectsOn(src.side);
278
+ for (let i = mine.length - 1; i >= 0; i--) {
279
+ const e = mine[i]!;
280
+ if (e.kind !== 'buff') continue;
281
+ if (e.charges == null || e.charges <= 0) continue;
282
+ if (e.expiresAt <= t) continue;
283
+ // An aura cannot spend its own charge. Cite: UnitAuraProcHandler.cpp:543.
284
+ if (e.spellId === fromSpell.id) continue;
285
+ const pf = (e.procFlags ?? 0) | 0;
286
+ if (pf === 0 || (pf & dealFlags) === 0) continue;
287
+ const modSchool = e.mod ? SPELL_DAMAGE_MOD_SCHOOL[e.mod as keyof typeof SPELL_DAMAGE_MOD_SCHOOL] : undefined;
288
+ if (modSchool && fromSpell.school !== modSchool) continue;
289
+ const { remaining, remove } = consumeProcCharge(e.charges);
290
+ if (remove) {
291
+ ctx.removeAurasDueToSpell?.(src, e.spellId);
292
+ } else {
293
+ e.charges = remaining;
294
+ ctx.touchEffect(e);
295
+ }
296
+ }
297
+ };
298
+
270
299
  // Proc support: match the flags and the per-minute rate, then fire the trigger spell's effects as a
271
300
  // free, instant, no-cost side effect. The injected rng keeps it deterministic, and procs do not nest.
272
301
  const triggerProcs = (
@@ -290,6 +319,7 @@ export function installProcsDamage(ctx: CombatContext, deps: installProcsDamageD
290
319
  else syncCombustionChip(src);
291
320
  }
292
321
  }
322
+ if (flagsHint === 'spell' && fromSpell) consumeChargedBuffsOnDealHit(src, fromSpell);
293
323
  const procs = src.procs || [];
294
324
  if (!procs.length) return;
295
325
  const procCtx = makeProcCtx(src, wasCrit, flagsHint, fromSpell);
@@ -32,7 +32,7 @@ import {
32
32
  isChaseInsideModel,
33
33
  type ChaseClosenessState,
34
34
  } from './chaseCloseness.ts';
35
- import { rangedAutoMinYards, spellTargetTypeAllowed } from './predicates.ts';
35
+ import { focusImmunityBlocksSpell, rangedAutoMinYards, spellTargetTypeAllowed } from './predicates.ts';
36
36
  import { CREATURE_FAMILY_ASSISTANCE_RADIUS } from '../../domain/creature/creatureAiConstants.ts';
37
37
  import type { MotionOwner } from './motionClaim.ts';
38
38
  import type { MotionGenerator } from './motionGenerator.ts';
@@ -47,6 +47,8 @@ import type { UnitNavPath, PathToOpts } from './sessionPathTo.ts';
47
47
  const RANGED_MIN_CLEARANCE_YD = 2;
48
48
  /** Stay this far inside an unpulled mob's detection so the step does not graze aggro. */
49
49
  const UNPULLED_AGGRO_MARGIN_YD = 1.5;
50
+ /** Park this far inside a cast's rangeMax, past the walk order's 0.25 yd arrival slack. */
51
+ const CAST_REACH_SLACK_YD = 0.5;
50
52
  // How far inside the bad arc a melee DPS must be before it starts a flank slide (~20 deg). A dead band, not
51
53
  // a tolerance: the arc's own edge is what moves under it every tick.
52
54
  const MELEE_SLOT_ENTRY_MARGIN_RAD = 0.35;
@@ -246,7 +248,18 @@ export function createChaseArm(deps: ChaseDeps): {
246
248
  if (isEnemySide(f.side)) {
247
249
  // The GRADE is the full contact ring while the PARK is half of it, which is the split the C++
248
250
  // makes — and it retires the knife edge where one ULP of step error triggered a full-HP evade.
249
- const reach = Math.max(pairContact, longestOffenseRangeYd(f) + EVADE_REACH_EPS_YD);
251
+ let reach = Math.max(pairContact, longestOffenseRangeYd(f) + EVADE_REACH_EPS_YD);
252
+ // With no ranged kit the park below stops at the short offense, so grade against it too. A
253
+ // Razormane Scout's Shoot 6660 infers instant_nuke: it parked at 24 yd, shot, and reset every 5 s.
254
+ // Scheduled spells only, so a kit row the mob never casts still lets it evade.
255
+ if (dist > reach) {
256
+ const castable = castableKitIds(f, ctx.spellOf);
257
+ for (const s of shortOffenseList(f)) {
258
+ if (castable && !castable.has(s.id | 0)) continue;
259
+ if (!spellTargetTypeAllowed(s, tgt.creatureType ?? 0) || focusImmunityBlocksSpell(s, tgt, 1)) continue;
260
+ reach = Math.max(reach, (s.rangeMax ?? 0) + EVADE_REACH_EPS_YD);
261
+ }
262
+ }
250
263
  const prev = unreachableFrom.get(f.side);
251
264
  const victimMoved = !!prev && (
252
265
  to.z != null && prev.victimZ != null
@@ -773,10 +786,14 @@ export function createChaseArm(deps: ChaseDeps): {
773
786
  for (const s of shortOffenseList(f)) {
774
787
  if (onCooldown(f, s) || !canAfford(f, s)) continue;
775
788
  if (!spellTargetTypeAllowed(s, tgt.creatureType ?? 0)) continue;
789
+ if (focusImmunityBlocksSpell(s, tgt, 1)) continue;
776
790
  const r = s.rangeMax ?? 0;
777
791
  if (r > fireR) fireR = r;
778
792
  }
779
793
  if (fireR > 0) {
794
+ // Inside the reach, not on it: a walk order arrives within ARRIVE_YD (0.25) of its dest, and
795
+ // a shaman parked at 20.2 yd never got the 20 yd Flame Shock off.
796
+ fireR -= CAST_REACH_SLACK_YD;
780
797
  stopAt = stopAt > 0 ? Math.min(stopAt, fireR) : fireR;
781
798
  } else {
782
799
  // Healer off-aggro may keep a stamped stand for heals; solo MH must not freeze.
@@ -10,6 +10,7 @@ import { effectiveSpeeds, type SpeedEffect } from '../../domain/nav/speed.ts';
10
10
  import { MIN_AGGRO_DISTANCE } from '../../domain/creature/creatureAi.ts';
11
11
  import { woundedSlowdownFactor } from '../../domain/creature/creatureAiConstants.ts';
12
12
  import { roleOf } from './roles.ts';
13
+ import { focusImmunityBlocksSpell } from './predicates.ts';
13
14
  import { triggeredIdsOf } from './triggeredCompanion.ts';
14
15
  import type { CombatContext } from './context.ts';
15
16
  import type { Fighter } from './types.ts';
@@ -132,8 +133,42 @@ export function createMotionKit(deps: MotionKitDeps): MotionKit {
132
133
  return r;
133
134
  };
134
135
 
135
- const longestOffenseRangeYd = (f: Fighter): number =>
136
- rangedOffenseKit(f, (f.spec.weapon?.dmgMax ?? 0) > 0, rangedPlan(f)).maxRangeYd;
136
+ // The focus when it can refuse a school, else null. Almost nothing can, so the kit walks below
137
+ // only run against an elemental or a bubble, and every other tick pays one mask read.
138
+ const schoolImmuneFoeOf = (f: Fighter): Fighter | null => {
139
+ const foe = ctx.foeOf?.(f);
140
+ if (!foe || foe === f) return null;
141
+ const masks = (foe.immunity?.schoolImmuneMask ?? 0) | (foe.immunity?.damageImmuneMask ?? 0)
142
+ | (foe.spec.schoolImmuneMask ?? 0);
143
+ return masks !== 0 ? foe : null;
144
+ };
145
+
146
+ // A focus that refuses the WHOLE offense kit (Vek'nilash to a frost mage) changes nothing: the
147
+ // caster holds where it stood, which is what a raid wants. Only a partial wall re-aims motion at
148
+ // what still lands. Cite: twinUppercut capability (casters at 30 yd take no Uppercut).
149
+ const immuneFoeWithAnOpening = (f: Fighter): Fighter | null => {
150
+ const foe = schoolImmuneFoeOf(f);
151
+ if (!foe) return null;
152
+ for (const s of rangedOffenseKit(f, (f.spec.weapon?.dmgMax ?? 0) > 0, rangedPlan(f)).list) {
153
+ if (!focusImmunityBlocksSpell(s, foe, 1)) return foe;
154
+ }
155
+ for (const s of shortOffenseList(f)) if (!focusImmunityBlocksSpell(s, foe, 1)) return foe;
156
+ return null;
157
+ };
158
+
159
+ // Reach of what can actually land: a shaman parked at Lightning Bolt's 40 yd from a Rock Elemental
160
+ // never came inside Flame Shock's 20.
161
+ const longestOffenseRangeYd = (f: Fighter): number => {
162
+ const kit = rangedOffenseKit(f, (f.spec.weapon?.dmgMax ?? 0) > 0, rangedPlan(f));
163
+ const foe = immuneFoeWithAnOpening(f);
164
+ if (!foe) return kit.maxRangeYd;
165
+ let r = 0;
166
+ for (const s of kit.list) {
167
+ if (focusImmunityBlocksSpell(s, foe, 1)) continue;
168
+ if ((s.rangeMax ?? 0) > r) r = s.rangeMax ?? 0;
169
+ }
170
+ return r;
171
+ };
137
172
 
138
173
  /** Judgement / Holy Shock — foe offense not in rangedOffenseKit (nuke/dot/shoot only). */
139
174
  type ShortOffenseKit = { len: number; list: SpellDef[] };
@@ -159,9 +194,12 @@ export function createMotionKit(deps: MotionKitDeps): MotionKit {
159
194
  const rangedAuto = rangedPlan(f);
160
195
  // Only cooldown/cost are live here; which spells even COUNT as ranged offense is a bake
161
196
  // constant, and re-deriving it walked the whole kit through roleOf per unit per tick.
197
+ // Immunity is live too: a Fireball the focus shrugs off held a fire mage at range forever.
198
+ const foe = immuneFoeWithAnOpening(f);
162
199
  for (const s of rangedOffenseKit(f, hasRealMelee, rangedAuto).list) {
163
200
  if (onCooldown(f, s)) continue;
164
201
  if (!canAfford(f, s)) continue;
202
+ if (foe && focusImmunityBlocksSpell(s, foe, 1)) continue;
165
203
  return true;
166
204
  }
167
205
  return false;
@@ -48,7 +48,7 @@ import { emptyInventory } from '../../domain/items/inventory.ts';
48
48
  import { trimSoulShards } from '../../domain/bots/soulShardCap.ts';
49
49
  import { companionLevel } from '../../domain/bots/squad.ts';
50
50
  import { syncCompanionProfessionTools } from './companionTraining.ts';
51
- import { petHasHappiness, reconcilePetPartyAuras } from '../../domain/pet/pet.ts';
51
+ import { petHasHappiness, reconcilePetPartyAuras, tpSpentOnKnown } from '../../domain/pet/pet.ts';
52
52
  import { playerbotJunkItem } from '../../domain/bots/playerbotItemEligibility.ts';
53
53
  import type { EquippedMap } from '../../domain/character/character.ts';
54
54
  import { createAliveDeathFlow, isPlayerAlive } from '../../domain/run/death.ts';
@@ -324,6 +324,26 @@ export function applyLoadReconcile(ds: DataStore, state: GameState, opts?: impor
324
324
  }
325
325
  }
326
326
 
327
+ // A pet tamed before the bake dropped its rank-1 filler was charged training points for rank 1 of
328
+ // every family chain on top of its real kit, and may still queue those ranks to teach. Refund the
329
+ // difference once and keep the queue to the real kit. Cite: Pet.cpp:1955-1998 InitPetCreateSpells.
330
+ {
331
+ for (const p of [state.char.pet, state.char.storedPet, ...(state.char.stablePets ?? [])]) {
332
+ if (!p || p.innateKit) continue;
333
+ const cdef = ds.creature(p.creature);
334
+ if (!cdef || !petHasHappiness(cdef)) continue;
335
+ const kit = cdef.petDefaultSpells ?? [];
336
+ const filler = (cdef.petSpells ?? []).filter((id) => {
337
+ const s = ds.spell(id);
338
+ return !!s && !s.passive && (s.rank | 0) <= 1;
339
+ });
340
+ const refund = tpSpentOnKnown([...kit, ...filler], ds) - tpSpentOnKnown(kit, ds);
341
+ if (refund > 0 && p.trainingPoints != null) p.trainingPoints += refund;
342
+ if (p.teachSpells) p.teachSpells = p.teachSpells.filter((id) => kit.includes(id));
343
+ p.innateKit = [...kit];
344
+ }
345
+ }
346
+
327
347
  // Action bars: normalize any loaded/legacy shape, and for a save that predates them
328
348
  // (empty) seed bar 1 with the player's usable abilities so combat isn't blank.
329
349
  state.actionBars = normalizeBars(state.actionBars);
@@ -3,7 +3,7 @@
3
3
 
4
4
  import {
5
5
  newPet, petHasHappiness, petTaughtIds, petRepertoireHas,
6
- feedPetHappinessGain, petDiet, isPetFood, tpForSpell, hasTpForSpell,
6
+ feedPetHappinessGain, petDiet, isPetFood, tpForSpell, hasTpForSpell, petUntrainCost, untrainPetState,
7
7
  LOYALTY_REBELLIOUS, FEED_PET_DURATION_S,
8
8
  autoFeedThreshold, AUTO_FEED_TARGET, HAPPINESS_MAX,
9
9
  applyPetPartyAuras, stripPetPartyAuras, petPartyAuraSpellIds, reconcilePetPartyAuras,
@@ -156,6 +156,8 @@ export function createPetsModule(ctx: GameCtx, extras: PetsExtras): Pick<
156
156
  | 'setPetAutoFeed'
157
157
  | 'buyPetSpell'
158
158
  | 'teachPetSpell'
159
+ | 'untrainPet'
160
+ | 'untrainPetCost'
159
161
  | 'togglePetAutocast'
160
162
  | 'setPetCommand'
161
163
  | 'setPetReact'
@@ -319,6 +321,36 @@ export function createPetsModule(ctx: GameCtx, extras: PetsExtras): Pick<
319
321
  return { ok: true };
320
322
  },
321
323
 
324
+ untrainPetCost(nowMs) {
325
+ const pet = state.char.pet;
326
+ const cdef = pet ? ds.creature(pet.creature) : undefined;
327
+ if (!pet || !cdef || !petHasHappiness(cdef)) return null;
328
+ return petUntrainCost(pet, nowMs ?? Date.now());
329
+ },
330
+
331
+ untrainPet(trainerEntry, nowMs) {
332
+ const trainer = ds.trainer(trainerEntry);
333
+ if (!trainer || !(trainer.subname ?? '').toLowerCase().includes('pet trainer')) {
334
+ return { ok: false, reason: 'That is not a pet trainer' };
335
+ }
336
+ if (!canInteractWithCreature(ds, state.char.race, trainerEntry)) {
337
+ return { ok: false, reason: 'They refuse to deal with you.' };
338
+ }
339
+ const pet = state.char.pet;
340
+ if (!pet) return { ok: false, reason: 'You have no pet' };
341
+ if (pet.health <= 0) return { ok: false, reason: 'Your pet is dead' };
342
+ const cdef = ds.creature(pet.creature);
343
+ if (!cdef || !petHasHappiness(cdef)) return { ok: false, reason: 'Only a hunter pet can be untrained' };
344
+ if (!petTaughtIds(state.char, cdef).length) return { ok: false, reason: `${pet.name} has nothing to forget` };
345
+ const now = nowMs ?? Date.now();
346
+ const cost = petUntrainCost(pet, now);
347
+ if (state.char.gold < cost) return { ok: false, reason: 'Not enough money' };
348
+ state.char.gold -= cost;
349
+ state.char.pet = { ...untrainPetState(pet, cdef, state.char.level, ds), resetTalentsCost: cost, resetTalentsTime: now };
350
+ log(`${pet.name} forgets its training for ${moneyMarked(cost)} - ${state.char.pet.trainingPoints} training points to spend again.`);
351
+ return { ok: true, spent: cost };
352
+ },
353
+
322
354
  togglePetAutocast(spellId) {
323
355
  const pet = state.char.pet;
324
356
  if (!pet) return { ok: false, reason: 'You have no pet' };
@@ -768,6 +768,10 @@ export interface Game {
768
768
  // Beast Training (spell 5149): teach the CURRENT pet a rank from the repertoire, spending ITS training
769
769
  // points (chain-aware). Cite: Pet.cpp GetTPForSpell/learnSpell.
770
770
  teachPetSpell(spellId: number): ActionResult;
771
+ // Untrain the current hunter pet at a pet trainer: forget every taught ability, training points back
772
+ // to level × (loyalty − 1), for the escalating fee petUntrainCost reads. Cite: HandlePetUnlearnOpcode.
773
+ untrainPet(trainerEntry: number, nowMs?: number): ActionResult<{ spent: number }>;
774
+ untrainPetCost(nowMs?: number): number | null;
771
775
  // Toggle autocast for a pet ability (right-click on the pet bar). Rank-agnostic by name chain; disabled
772
776
  // abilities stay learned but leave the combat kit (buildPet).
773
777
  togglePetAutocast(spellId: number): ActionResult<{ enabled: boolean }>;
@@ -534,6 +534,66 @@ export function compareQuestDroppers(
534
534
  return a.level - b.level;
535
535
  }
536
536
 
537
+ /** A dropper counts as "as good" as the best one when its chance is at least this share of it. */
538
+ export const SIMILAR_DROPPER_RATIO = 0.75;
539
+ /** ...and its level is at most this far below the best one's (and never gray to the player). */
540
+ export const SIMILAR_DROPPER_LEVELS_BELOW = 3;
541
+ /** ...and at most this far above the player (a harder mob than the best is never the pool's gain). */
542
+ export const SIMILAR_DROPPER_LEVELS_OVER = 1;
543
+
544
+ /**
545
+ * The entries worth pulling interchangeably with the top-ranked one: same elite class, a drop
546
+ * chance at least SIMILAR_DROPPER_RATIO of its chance, a level no more than SIMILAR_DROPPER_LEVELS_OVER
547
+ * above the player (or the best, when the whole set is above the player), no more than
548
+ * SIMILAR_DROPPER_LEVELS_BELOW under the best, and not gray unless the best is. Kill objectives
549
+ * carry chance 0, so every credited entry in the level band qualifies. `ranked` is ordered by
550
+ * compareQuestDroppers already, so ranked[0] is the best.
551
+ */
552
+ export function similarQuestDroppers<T extends { level: number; chance: number; rank?: number }>(
553
+ ranked: readonly T[],
554
+ playerLevel: number,
555
+ ): T[] {
556
+ const best = ranked[0];
557
+ if (!best) return [];
558
+ const bestElite = (best.rank ?? 0) > 0;
559
+ const haveLevel = playerLevel > 0;
560
+ const ceiling = Math.max(best.level, haveLevel ? playerLevel + SIMILAR_DROPPER_LEVELS_OVER : best.level);
561
+ const floor = best.level - SIMILAR_DROPPER_LEVELS_BELOW;
562
+ const bestGray = haveLevel && difficultyTier(best.level, playerLevel) === 'gray';
563
+ return ranked.filter((c) => {
564
+ if (((c.rank ?? 0) > 0) !== bestElite) return false;
565
+ if (c.chance < best.chance * SIMILAR_DROPPER_RATIO) return false;
566
+ if (c.level > ceiling || c.level < floor) return false;
567
+ return bestGray || !haveLevel || difficultyTier(c.level, playerLevel) !== 'gray';
568
+ });
569
+ }
570
+
571
+ /**
572
+ * One pull among the similar droppers, chosen by `salt` so the same state names the same mob and
573
+ * the next fight (state.fightCounter moved) can name another. Always picking the comparator's
574
+ * winner sent every Murloc quest at the Oracle and every collect quest at one template; a pool
575
+ * of near-equal mobs spreads the kills without touching the ranking the smokes pin.
576
+ */
577
+ export function pickQuestDropper<T extends { level: number; chance: number; rank?: number }>(
578
+ ranked: readonly T[],
579
+ playerLevel: number,
580
+ salt: number,
581
+ ): T | undefined {
582
+ const pool = similarQuestDroppers(ranked, playerLevel);
583
+ if (pool.length <= 1) return pool[0] ?? ranked[0];
584
+ // A short integer mix so consecutive salts do not walk the pool in order.
585
+ let h = (salt ^ 0x9e3779b9) >>> 0;
586
+ h = Math.imul(h ^ (h >>> 16), 0x85ebca6b) >>> 0;
587
+ h = Math.imul(h ^ (h >>> 13), 0xc2b2ae35) >>> 0;
588
+ h = (h ^ (h >>> 16)) >>> 0;
589
+ return pool[h % pool.length];
590
+ }
591
+
592
+ /** The salt pickQuestDropper takes for a save: the fight nonce under the save's seed. */
593
+ export function dropperPickSalt(state: { rngSeed: number; fightCounter: number }): number {
594
+ return ((state.rngSeed | 0) * 31 + (state.fightCounter | 0)) | 0;
595
+ }
596
+
537
597
  // The best eligible loot chance for an item on this creature, 0 for none. An equal-chance group row
538
598
  // scores as one so it beats a near-zero free row. Cite: domain/items/loot.ts.
539
599
  export function lootChanceForItem(