azeroth-shell 0.1.1 → 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.
Files changed (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -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/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. 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
- const best = seals.find((s) => s.effects.some((e) => e.kind === 'seal' && (e.seal === 'command' || e.seal === 'righteousness'))) ?? seals[0];
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
- 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;
@@ -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(