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
@@ -47,26 +47,31 @@ export function finalizePaladinSealJudge(
47
47
 
48
48
  const pri = sealKindPriority(spec);
49
49
  // Kind first (resolved effects); rank only ties same kind (highest rank wins).
50
- const best = [...sealRules].sort((a, b) => {
51
- const kindA = sealKindOf(resolvedSealDef(a.def));
52
- const kindB = sealKindOf(resolvedSealDef(b.def));
50
+ const bySpec = (a: SpellDef, b: SpellDef): number => {
51
+ const kindA = sealKindOf(resolvedSealDef(a));
52
+ const kindB = sealKindOf(resolvedSealDef(b));
53
53
  return (pri[kindB] ?? -1) - (pri[kindA] ?? -1)
54
- || (b.def.rank ?? 0) - (a.def.rank ?? 0);
55
- })[0]!;
56
- const bestId = best.def.id;
57
- const dropSealIds = new Set(
58
- sealRules.filter((s) => s.def.id !== bestId).map((s) => s.def.id),
59
- );
54
+ || (b.rank ?? 0) - (a.rank ?? 0);
55
+ };
56
+ // The pick is among every seal the paladin KNOWS, not only the ones the pack wrote down. The
57
+ // 20-39 Retribution band authored Seal of Righteousness alone, so a Ret who took Seal of Command
58
+ // at 20 kept casting the trainer seal until the 40 band named Command. The rule the pack placed is
59
+ // kept where it sits; only the seal it casts changes.
60
+ const knownSeals = [...knownById.values()].filter((d) => isSealSpell(d));
61
+ const authored = sealRules.map((s) => s.def);
62
+ const best = [...(knownSeals.length ? knownSeals : authored)].sort(bySpec)[0]!;
63
+ const bestId = best.id;
64
+ const authoredIds = new Set(authored.map((d) => d.id));
60
65
 
61
66
  const out: RotationRule[] = [];
62
67
  let keptBestSeal = false;
63
68
  for (const r of combat) {
64
- if (r.action.kind === 'spell' && dropSealIds.has(r.action.spellId)) continue;
65
- if (r.action.kind === 'spell' && r.action.spellId === bestId) {
69
+ if (r.action.kind === 'spell' && authoredIds.has(r.action.spellId)) {
66
70
  if (keptBestSeal) continue; // one seal upkeep rule only
67
71
  keptBestSeal = true;
68
72
  out.push({
69
73
  ...r,
74
+ action: { ...r.action, spellId: bestId },
70
75
  conditions: [cond('self', 'buffPresent', '==', 0, bestId)],
71
76
  });
72
77
  continue;
@@ -1000,6 +1000,9 @@ function pullSequenceAction(
1000
1000
  const condSnap = snapForAction(snap, action);
1001
1001
  if (!conditionsPass(condSnap, conditionsForEval(rule))) continue; // not needed this pull
1002
1002
  if (!actionUsable(snap, action)) {
1003
+ // An immune target never makes the opener usable: waiting on it held a fire mage at range
1004
+ // forever against a Fire Elemental. Step past it like a spent row.
1005
+ if (action.kind === 'spell' && snap.target?.immuneSpellIds?.has(action.spellId)) continue;
1003
1006
  const inMelee = snap.self.inMelee === true
1004
1007
  || (!!snap.enemyDists && snap.enemyDists.some((d) => d <= IN_MELEE_YD));
1005
1008
  if (inMelee || snap.enemyCount === 0) continue;
@@ -281,7 +281,17 @@ export type installApplySpellDeps = {
281
281
  };
282
282
 
283
283
  export function installApplySpell(ctx: CombatContext, deps: installApplySpellDeps): void {
284
- const push = (e: any) => ctx.push(e);
284
+ // One aura row per holder, spell and instant. The push sits in the per-effect loop, so a two-effect
285
+ // buff (Dampen Magic: spell and healing taken) read "You gain" twice for one cast.
286
+ let lastAuraKey = '';
287
+ const push = (e: any) => {
288
+ if (e.kind === 'aura') {
289
+ const key = e.applied && e.spellId != null ? `${e.t}|${e.actor}|${e.spellId}` : '';
290
+ if (key && key === lastAuraKey) return;
291
+ lastAuraKey = key;
292
+ }
293
+ ctx.push(e);
294
+ };
285
295
  const player = ctx.player;
286
296
  const pet = ctx.pet;
287
297
  const effects = ctx.effects;
@@ -2220,7 +2230,8 @@ export function installApplySpell(ctx: CombatContext, deps: installApplySpellDep
2220
2230
  };
2221
2231
  // One-target apply, hoisted so the area-aura maintain pass can RE-apply it when an ally walks
2222
2232
  // back into radius. Idempotent: an existing row refreshes duration and never re-adds flat mods.
2223
- const applyBuffToTarget = (buffTgt: Fighter): void => {
2233
+ // `now` is that pass's clock for the log; the expiry stays on the cast's (the holder's remaining life).
2234
+ const applyBuffToTarget = (buffTgt: Fighter, now = t): void => {
2224
2235
  // Totem.cpp:282-287: mixed-hit school damage still lands; the APPLY_AURA index does not.
2225
2236
  const hostileBuff = isHostileBuffEffect(eff, baseAmount);
2226
2237
  if (
@@ -2353,10 +2364,10 @@ export function installApplySpell(ctx: CombatContext, deps: installApplySpellDep
2353
2364
  });
2354
2365
  }
2355
2366
  buffTgt.buffActive.add(spell.id);
2356
- push({ t, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
2367
+ push({ t: now, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
2357
2368
  if (buffTgt.side !== src.side) {
2358
2369
  push({
2359
- t, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
2370
+ t: now, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
2360
2371
  spellId: spell.id, name: spell.name, result: 'hit', amount: 0, school: spell.school,
2361
2372
  });
2362
2373
  }
@@ -2424,7 +2435,7 @@ export function installApplySpell(ctx: CombatContext, deps: installApplySpellDep
2424
2435
  }
2425
2436
  buffTgt.buffActive.delete(row.spellId);
2426
2437
  effects.splice(at, 1);
2427
- push({ t, kind: 'aura', actor: buffTgt.side, name: row.name, applied: false });
2438
+ push({ t: now, kind: 'aura', actor: buffTgt.side, name: row.name, applied: false });
2428
2439
  }
2429
2440
  }
2430
2441
  }
@@ -2457,10 +2468,10 @@ export function installApplySpell(ctx: CombatContext, deps: installApplySpellDep
2457
2468
  });
2458
2469
  // The aura row lives on the target's buff bar. Also emit a spell_hit for the caster so a
2459
2470
  // damageless hostile debuff shows in the fight log as a cast.
2460
- push({ t, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
2471
+ push({ t: now, kind: 'aura', actor: buffTgt.side, name: spell.name, applied: true, spellId: spell.id });
2461
2472
  if (buffTgt.side !== src.side) {
2462
2473
  push({
2463
- t, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
2474
+ t: now, kind: 'spell_hit', actor: src.side, target: buffTgt.side,
2464
2475
  spellId: spell.id, name: spell.name, result: 'hit', amount: 0, school: spell.school,
2465
2476
  });
2466
2477
  }
@@ -2530,9 +2541,9 @@ export function installApplySpell(ctx: CombatContext, deps: installApplySpellDep
2530
2541
  exclusionGroup: spell.exclusionGroup,
2531
2542
  // AreaAura::Update applies the MISSING rank only. An unguarded re-apply emitted 50 events
2532
2543
  // for one Blood Pact in a 2.5 s fight — the totem kit path guards the same way.
2533
- apply: (m: Fighter) => {
2544
+ apply: (m: Fighter, now: number) => {
2534
2545
  if (hasAreaAuraRow(ctx.effectsOn(m.side), m, spell.id, src.side)) return;
2535
- applyBuffToTarget(m);
2546
+ applyBuffToTarget(m, now);
2536
2547
  },
2537
2548
  strip: (m: Fighter) => {
2538
2549
  stripAreaAuraRowsFrom(effects, m, spell.id, src.side, reverseActiveEffect, ctx.effectsOn(m.side));
@@ -216,7 +216,11 @@ if (eff.kind === 'summon_totem') {
216
216
  };
217
217
  // Register the kit aura as a live class-A holder so the maintain pass keeps re-checking the
218
218
  // totem-to-ally distance. The origin is the TOTEM; the mod owner is the shaman.
219
- const registerTotemAreaAura = (abil: SpellDef, apply: (fr: Fighter) => void): void => {
219
+ // Each kit closure takes `now` (default: the summon's `t`): the maintain pass calls it later for an
220
+ // ally walking into radius, and the summon-time clock put that row's first tick in the past, so
221
+ // every missed Healing Stream tick landed at once.
222
+ // Expiry stays on the totem's clock, the holder's remaining life.
223
+ const registerTotemAreaAura = (abil: SpellDef, apply: (fr: Fighter, now: number) => void): void => {
220
224
  registerAreaAuraSource?.({
221
225
  holderSide: built.side,
222
226
  spellId: abil.id,
@@ -256,7 +260,7 @@ if (eff.kind === 'summon_totem') {
256
260
  isWithinRadiusYd(posOf?.(built.side), posOf?.(en.side), kitRadiusYd(abil) ?? 10),
257
261
  )
258
262
  : (ae.applyTo === 'self' ? [player] : inRadiusParty(abil));
259
- const applyKitBuff = (fr: Fighter): void => {
263
+ const applyKitBuff = (fr: Fighter, now = t): void => {
260
264
  if (dead(fr)) return;
261
265
  if (hasAreaAuraRow(rowsOn(fr), fr, abil.id, built.side)) return;
262
266
  applyBuffMod(fr, ae.mod, amount);
@@ -265,7 +269,7 @@ if (eff.kind === 'summon_totem') {
265
269
  kind: 'buff', source: built.side, target: fr.side, spellId: abil.id, name: abil.name,
266
270
  mod: ae.mod, amount, expiresAt: t + dur, fromTotem: built.side,
267
271
  });
268
- push({ t, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
272
+ push({ t: now, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
269
273
  };
270
274
  // continue (not return): one dead friendly must not abort party aura reach.
271
275
  // Cite: Totem.cpp UnSummon party strip :145–157; APPLY_AREA_AURA_PARTY effect 35.
@@ -278,7 +282,7 @@ if (eff.kind === 'summon_totem') {
278
282
  // Cite: APPLY_AREA_AURA_PARTY; Totem.cpp UnSummon removes aura on all subgroup members.
279
283
  const tickMs = Math.max(1, ae.tickMs);
280
284
  const kitPower = ae.kind === 'restore_power' ? (ae.powerType ?? POWER_MANA) | 0 : undefined;
281
- const applyKitTick = (fr: Fighter): void => {
285
+ const applyKitTick = (fr: Fighter, now = t): void => {
282
286
  if (dead(fr)) return;
283
287
  if (hasAreaAuraRow(rowsOn(fr), fr, abil.id, built.side)) return;
284
288
  // The aura lands on every living party member for strip symmetry; the pool it pays
@@ -286,11 +290,11 @@ if (eff.kind === 'summon_totem') {
286
290
  fr.buffActive.add(abil.id);
287
291
  effects.push({
288
292
  kind: ae.kind, source: built.side, target: fr.side, spellId: abil.id, name: abil.name,
289
- amountPerTick: ae.amountPerTick, pctOfMax: ae.pctOfMax, tickMs, nextTickAt: t + tickMs,
293
+ amountPerTick: ae.amountPerTick, pctOfMax: ae.pctOfMax, tickMs, nextTickAt: now + tickMs,
290
294
  ...(kitPower != null ? { powerType: kitPower } : {}),
291
295
  expiresAt: t + built.durationMs, fromTotem: built.side,
292
296
  });
293
- push({ t, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
297
+ push({ t: now, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
294
298
  };
295
299
  // Healing Stream / Mana Spring only tick on allies standing in the totem's
296
300
  // radius — walk out and the stream stops for you (§4.9.1).
@@ -315,7 +319,7 @@ if (eff.kind === 'summon_totem') {
315
319
  } else if (ae.kind === 'resist') {
316
320
  // MOD_RESISTANCE party aura — the Frost, Fire and Nature Resistance Totems. Same party
317
321
  // reach and strip symmetry as the stat buffs; without it they were summoned decoration.
318
- const applyKitResist = (fr: Fighter): void => {
322
+ const applyKitResist = (fr: Fighter, now = t): void => {
319
323
  if (dead(fr)) return;
320
324
  if (hasAreaAuraRow(rowsOn(fr), fr, abil.id, built.side)) return;
321
325
  applyResistMod(fr, ae.school, ae.amount);
@@ -325,7 +329,7 @@ if (eff.kind === 'summon_totem') {
325
329
  name: abil.name, school: ae.school, amount: ae.amount,
326
330
  expiresAt: t + built.durationMs, fromTotem: built.side,
327
331
  });
328
- push({ t, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
332
+ push({ t: now, kind: 'aura', actor: fr.side, name: abil.name, applied: true });
329
333
  };
330
334
  for (const fr of inRadiusParty(abil)) applyKitResist(fr);
331
335
  registerTotemAreaAura(abil, applyKitResist);
@@ -335,10 +339,10 @@ if (eff.kind === 'summon_totem') {
335
339
  applyFlatThreat(tot, ae.amount, abil as unknown as SpellDef);
336
340
  } else if (ae.kind === 'spell_magnet') {
337
341
  const magnetDur = ae.durationMs > 0 ? ae.durationMs : built.durationMs;
338
- const applyKitMagnet = (fr: Fighter): void => {
342
+ const applyKitMagnet = (fr: Fighter, now = t): void => {
339
343
  applySpellMagnetToParty({
340
344
  effects, effectsOn, party: [fr], source: built.side, spell: abil,
341
- expiresAt: t + magnetDur, t, push, touchEffect,
345
+ expiresAt: t + magnetDur, t: now, push, touchEffect,
342
346
  });
343
347
  };
344
348
  for (const fr of inRadiusParty(abil)) applyKitMagnet(fr);
@@ -374,11 +378,11 @@ if (eff.kind === 'summon_totem') {
374
378
  const magnetFx = trig.effects.find((e) => e.kind === 'spell_magnet');
375
379
  if (!magnetFx || magnetFx.kind !== 'spell_magnet') continue;
376
380
  const magnetDur = magnetFx.durationMs > 0 ? magnetFx.durationMs : built.durationMs;
377
- const applyTrigMagnet = (fr: Fighter): void => {
381
+ const applyTrigMagnet = (fr: Fighter, now = t): void => {
378
382
  applySpellMagnetToParty({
379
383
  effects, effectsOn, party: [fr], source: built.side,
380
384
  spell: { id: trig.id, name: trig.name, procCharges: magnetFx.charges },
381
- expiresAt: t + magnetDur, t, push, touchEffect,
385
+ expiresAt: t + magnetDur, t: now, push, touchEffect,
382
386
  });
383
387
  };
384
388
  for (const fr of inRadiusParty(trig)) applyTrigMagnet(fr);