azeroth-shell 0.1.2 → 0.2.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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +9 -1
- package/src/autopilot/plan.ts +8 -11
- package/src/autopilot/view.ts +12 -11
- package/src/cli/combatLogFormat.ts +22 -0
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +32 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +231 -95
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +362 -27
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +423 -86
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +1 -1
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +10 -1
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +284 -110
- package/src/cli/tuiWorld.mjs +2 -2
- package/src/data/gear-art.bin.br +0 -0
- package/src/data/hd-art.json +114 -0
- package/src/data/icon-art.bin.gz +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/domain/bg/match.ts +85 -24
- package/src/domain/bg/strategies.ts +144 -29
- package/src/domain/bg/types.ts +2 -0
- package/src/domain/bg/wsg.ts +35 -20
- package/src/domain/quests/doQuest.ts +24 -6
- package/src/domain/rotation/build.ts +3031 -3031
- package/src/domain/rotation/classes/paladin.ts +16 -11
- package/src/domain/rotation/evaluate.ts +3 -0
- package/src/engine/combat/applySpell.ts +20 -9
- package/src/engine/combat/applySpellLate.ts +16 -12
- package/src/engine/combat/areaAura.ts +284 -283
- package/src/engine/combat/fighterFactory.ts +1 -1
- package/src/engine/combat/pickAction.ts +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
|
@@ -2320,6 +2320,38 @@ export function installPickAction(ctx: CombatContext, deps: installPickActionDep
|
|
|
2320
2320
|
return snap;
|
|
2321
2321
|
};
|
|
2322
2322
|
|
|
2323
|
+
// A rotation whose damage rows the focus is immune to returns nothing, and a null pick is white
|
|
2324
|
+
// swings a caster holding at range never throws: the fire mage stood off a Fire Elemental forever.
|
|
2325
|
+
// Only then does the kit speak, in the class heuristics' order: a DoT not yet ticking, an instant,
|
|
2326
|
+
// a cast nuke, the wand. Single-target before ground AoE (Blizzard is a dot), and never a self AoE:
|
|
2327
|
+
// Hellfire burned a warlock to death. Healers keep their idle GCDs. Cite: Unit.cpp IsImmuneToSpell ~7329.
|
|
2328
|
+
// Only a SCHOOL wall, only a mana caster, only out of form: a bleed-immune mob is most mobs, and a
|
|
2329
|
+
// cat pooling energy against one shifted out to Wrath (feral vs Mana Surge 19 s -> 49 s).
|
|
2330
|
+
const IMMUNE_FILLER_ROLES = ['dot', 'instant_nuke', 'nuke'] as const;
|
|
2331
|
+
const immuneFallbackFiller = (f: Fighter, foe: Fighter): SpellDef | null => {
|
|
2332
|
+
if (f.spec.groupRole === 'healer' || foe === f || dead(foe)) return null;
|
|
2333
|
+
if (f.spec.powerType !== 'mana' || f.form) return null;
|
|
2334
|
+
if (((foe.immunity?.schoolImmuneMask ?? 0) | (foe.immunity?.damageImmuneMask ?? 0)
|
|
2335
|
+
| (foe.spec.schoolImmuneMask ?? 0)) === 0) return null;
|
|
2336
|
+
if (!targetImmunityIdsFor(foe, f).spells?.size) return null;
|
|
2337
|
+
// A reach, too: Hellfire is a self channel that passes the foe test and burns its caster.
|
|
2338
|
+
const usable = (s: SpellDef): boolean =>
|
|
2339
|
+
(s.rangeMax ?? 0) > 0 && isFoeDirectedOffense(s) && !isTriggeredCompanion(f, s)
|
|
2340
|
+
&& !isNonCastableTalentProc(s) && canCastSpell(f, foe, s);
|
|
2341
|
+
for (const aoe of [false, true]) {
|
|
2342
|
+
for (const role of IMMUNE_FILLER_ROLES) {
|
|
2343
|
+
// Any of our DoTs already ticking is skipped, hybrids too: Immolate went out 30 times a fight.
|
|
2344
|
+
const hit = f.spec.spells.find((s) => roleOf(s) === role && !!s.aoe === aoe && usable(s)
|
|
2345
|
+
&& !(s.effects.some((e) => e.kind === 'dot') && ctx.effectsOn(foe.side).some(
|
|
2346
|
+
(e) => e.kind === 'dot' && e.spellId === s.id && e.source === f.side,
|
|
2347
|
+
)));
|
|
2348
|
+
if (hit) return hit;
|
|
2349
|
+
}
|
|
2350
|
+
}
|
|
2351
|
+
const shoot = f.spec.spells.find((s) => isRangedAutoRepeatToggle(s) && usable(s));
|
|
2352
|
+
return shoot && f.spec.ranged && f.mode !== 'ranged' ? shoot : null;
|
|
2353
|
+
};
|
|
2354
|
+
|
|
2323
2355
|
const pickAction = (f: Fighter, foe: Fighter): SpellDef | null => {
|
|
2324
2356
|
const t = ctx.t;
|
|
2325
2357
|
// E6: MT/OT focus from the raid plan (boss-a / boss-b by stable side order — no entry id).
|
|
@@ -2912,6 +2944,7 @@ export function installPickAction(ctx: CombatContext, deps: installPickActionDep
|
|
|
2912
2944
|
return sp;
|
|
2913
2945
|
}
|
|
2914
2946
|
}
|
|
2947
|
+
if (pick == null && !f.haltDpsSchool) return immuneFallbackFiller(f, foe);
|
|
2915
2948
|
return null;
|
|
2916
2949
|
};
|
|
2917
2950
|
|
|
@@ -3174,7 +3207,12 @@ export function installPickAction(ctx: CombatContext, deps: installPickActionDep
|
|
|
3174
3207
|
const seals = f.spec.spells.filter((s) => roleOf(s) === 'seal');
|
|
3175
3208
|
if (seals.length) {
|
|
3176
3209
|
if (!f.activeSeal) {
|
|
3177
|
-
|
|
3210
|
+
// Command before Righteousness: the talent seal is the stronger swing proc, and the order the
|
|
3211
|
+
// spell list happens to come in must not decide which one a Ret paladin keeps up.
|
|
3212
|
+
const sealKind = (s: SpellDef): string => { const e = s.effects.find((x) => x.kind === 'seal'); return e && e.kind === 'seal' ? e.seal : ''; };
|
|
3213
|
+
const best = seals.find((s) => sealKind(s) === 'command')
|
|
3214
|
+
?? seals.find((s) => sealKind(s) === 'righteousness')
|
|
3215
|
+
?? seals[0];
|
|
3178
3216
|
if (best && canAfford(f, best) && !onCooldown(f, best)) return best;
|
|
3179
3217
|
} else {
|
|
3180
3218
|
const judge = find(f, 'judgement');
|
|
@@ -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;
|
|
@@ -773,10 +775,14 @@ export function createChaseArm(deps: ChaseDeps): {
|
|
|
773
775
|
for (const s of shortOffenseList(f)) {
|
|
774
776
|
if (onCooldown(f, s) || !canAfford(f, s)) continue;
|
|
775
777
|
if (!spellTargetTypeAllowed(s, tgt.creatureType ?? 0)) continue;
|
|
778
|
+
if (focusImmunityBlocksSpell(s, tgt, 1)) continue;
|
|
776
779
|
const r = s.rangeMax ?? 0;
|
|
777
780
|
if (r > fireR) fireR = r;
|
|
778
781
|
}
|
|
779
782
|
if (fireR > 0) {
|
|
783
|
+
// Inside the reach, not on it: a walk order arrives within ARRIVE_YD (0.25) of its dest, and
|
|
784
|
+
// a shaman parked at 20.2 yd never got the 20 yd Flame Shock off.
|
|
785
|
+
fireR -= CAST_REACH_SLACK_YD;
|
|
780
786
|
stopAt = stopAt > 0 ? Math.min(stopAt, fireR) : fireR;
|
|
781
787
|
} else {
|
|
782
788
|
// 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
|
-
|
|
136
|
-
|
|
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;
|
package/src/quests/quests.ts
CHANGED
|
@@ -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(
|