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.
Files changed (63) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +9 -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 +135 -0
  10. package/src/cli/iconGrid.mjs +100 -0
  11. package/src/cli/modelArt.mjs +295 -0
  12. package/src/cli/paperdoll.mjs +76 -0
  13. package/src/cli/perfCheck.mjs +56 -0
  14. package/src/cli/repl.mjs +32 -7
  15. package/src/cli/rotationText.mjs +6 -6
  16. package/src/cli/sixel.mjs +307 -0
  17. package/src/cli/term.mjs +106 -12
  18. package/src/cli/text.mjs +5 -3
  19. package/src/cli/tui.mjs +231 -95
  20. package/src/cli/tuiAutopilot.mjs +347 -0
  21. package/src/cli/tuiBg.mjs +370 -41
  22. package/src/cli/tuiCheck.mjs +362 -27
  23. package/src/cli/tuiCity.mjs +118 -13
  24. package/src/cli/tuiCompanions.mjs +25 -15
  25. package/src/cli/tuiCreate.mjs +223 -0
  26. package/src/cli/tuiDungeon.mjs +1 -1
  27. package/src/cli/tuiFight.mjs +423 -86
  28. package/src/cli/tuiFightPanes.mjs +27 -8
  29. package/src/cli/tuiFloor.mjs +1 -1
  30. package/src/cli/tuiGear.mjs +10 -6
  31. package/src/cli/tuiLoot.mjs +1 -0
  32. package/src/cli/tuiPaperdoll.mjs +107 -0
  33. package/src/cli/tuiPartyStage.mjs +177 -0
  34. package/src/cli/tuiProfessions.mjs +4 -2
  35. package/src/cli/tuiRotationCond.mjs +390 -0
  36. package/src/cli/tuiSaves.mjs +10 -1
  37. package/src/cli/tuiSearch.mjs +75 -22
  38. package/src/cli/tuiStage.mjs +304 -0
  39. package/src/cli/tuiTalentTree.mjs +124 -0
  40. package/src/cli/tuiViews.mjs +284 -110
  41. package/src/cli/tuiWorld.mjs +2 -2
  42. package/src/data/gear-art.bin.br +0 -0
  43. package/src/data/hd-art.json +114 -0
  44. package/src/data/icon-art.bin.gz +0 -0
  45. package/src/data/model-art.bin.gz +0 -0
  46. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  47. package/src/domain/bg/match.ts +85 -24
  48. package/src/domain/bg/strategies.ts +144 -29
  49. package/src/domain/bg/types.ts +2 -0
  50. package/src/domain/bg/wsg.ts +35 -20
  51. package/src/domain/quests/doQuest.ts +24 -6
  52. package/src/domain/rotation/build.ts +3031 -3031
  53. package/src/domain/rotation/classes/paladin.ts +16 -11
  54. package/src/domain/rotation/evaluate.ts +3 -0
  55. package/src/engine/combat/applySpell.ts +20 -9
  56. package/src/engine/combat/applySpellLate.ts +16 -12
  57. package/src/engine/combat/areaAura.ts +284 -283
  58. package/src/engine/combat/fighterFactory.ts +1 -1
  59. package/src/engine/combat/pickAction.ts +39 -1
  60. package/src/engine/combat/procsDamage.ts +31 -1
  61. package/src/engine/combat/sessionChase.ts +7 -1
  62. package/src/engine/combat/sessionMotionKit.ts +40 -2
  63. package/src/quests/quests.ts +60 -0
@@ -178,27 +178,19 @@ export function installWsg(world: BgWorld): WsgEvent[] {
178
178
 
179
179
  if (obj.state !== 'base' && obj.state !== 'ground') continue;
180
180
 
181
- // Someone standing on it either takes it (enemy) or returns it (owner, dropped only).
182
- for (const u of w.units.values()) {
183
- if (!w.alive(u)) continue;
184
- if (detHypot(u.pos.x - obj.pos.x, u.pos.y - obj.pos.y) > WS_FLAG_INTERACT_YD) continue;
185
-
186
- if (u.team === team) {
187
- // Your own flag: on the ground → return it. At base → nothing.
188
- if (obj.state === 'ground') {
189
- returnFlag(team, u);
190
- break;
191
- }
192
- continue;
193
- }
194
- // Enemy flag: pick it up. One flag per unit.
195
- if (u.carryingFlag) continue;
196
- obj.state = 'carried';
197
- obj.carrier = u.id;
198
- u.carryingFlag = flagSpellCarriedBy(u.team);
199
- log({ t: w.t, kind: 'pickup', team: u.team, unit: u.id });
200
- break;
181
+ // Someone standing on it either takes it (enemy) or returns it (owner, dropped only). With
182
+ // both sides on a dropped flag the nearest hand wins; the first body in roster order used to,
183
+ // so the team added to the world first won every contested flag and every match.
184
+ const u = flagToucher(w, obj, team);
185
+ if (!u) continue;
186
+ if (u.team === team) {
187
+ returnFlag(team, u);
188
+ continue;
201
189
  }
190
+ obj.state = 'carried';
191
+ obj.carrier = u.id;
192
+ u.carryingFlag = flagSpellCarriedBy(u.team);
193
+ log({ t: w.t, kind: 'pickup', team: u.team, unit: u.id });
202
194
  }
203
195
 
204
196
  // A carrier standing on its OWN flag, with that flag at base, scores.
@@ -257,6 +249,29 @@ export function installWsg(world: BgWorld): WsgEvent[] {
257
249
  return events;
258
250
  }
259
251
 
252
+ // The body that gets a flag everyone within reach wants. The owner's side can only return a dropped
253
+ // flag; the other side's runner is preferred over its escorts at the base, since a pickup by the
254
+ // escort sent the runner home empty-handed. Exact ties (bodies stacked on one point) alternate by
255
+ // tick, so neither side is favoured by roster order.
256
+ function flagToucher(w: BgWorld, obj: { pos: BgPos; state: string }, owner: BgTeam): BgUnit | null {
257
+ let best: BgUnit | null = null;
258
+ let bd = Infinity;
259
+ let bestRunner = false;
260
+ const odd = Math.floor(w.t / Math.max(1, w.tickMs)) % 2 === 1;
261
+ for (const u of w.units.values()) {
262
+ if (!w.alive(u)) continue;
263
+ const own = u.team === owner;
264
+ if (own ? obj.state !== 'ground' : !!u.carryingFlag) continue;
265
+ const d = detHypot(u.pos.x - obj.pos.x, u.pos.y - obj.pos.y);
266
+ if (d > WS_FLAG_INTERACT_YD) continue;
267
+ const runner = !own && u.role === 'flag-carrier';
268
+ if (!best || (runner && !bestRunner) || (runner === bestRunner && (d < bd || (d === bd && odd)))) {
269
+ best = u; bd = d; bestRunner = runner;
270
+ }
271
+ }
272
+ return best;
273
+ }
274
+
260
275
  /** Where a dead unit of `team` comes back. Cite: world_safe_locs 771 / 772. */
261
276
  export function wsgGraveyard(team: BgTeam): BgPos {
262
277
  const gy = WSG_GEOMETRY.graveyard[team];
@@ -44,6 +44,8 @@ import { auraIdsForSnapshot, itemGrantsAdCommissionAura, snapshotHasAdCommission
44
44
  import {
45
45
  lootDropAllowed,
46
46
  compareQuestDroppers,
47
+ pickQuestDropper,
48
+ dropperPickSalt,
47
49
  creatureFarmLevel,
48
50
  creatureFarmRank,
49
51
  canTakeQuestForCondition,
@@ -935,6 +937,8 @@ function fightStep(
935
937
  const skip = opts?.skipCreatures;
936
938
  const hunt = huntableCreatures(ds, state.char.zoneId, state.questlog, state.char.race);
937
939
  const summoned = new Set(corpseUseSummonedEntries(ds, def));
940
+ const salt = dropperPickSalt(state);
941
+ const killable: { entry: number; level: number; chance: number; rank: number }[] = [];
938
942
  for (const kt of creatureCreditTargets(def)) {
939
943
  if ((qs.kills[String(kt.creatureEntry)] ?? 0) >= kt.count) continue;
940
944
  if (skip?.has(kt.creatureEntry)) continue;
@@ -949,7 +953,17 @@ function fightStep(
949
953
  // Friendly + noXp: beginFight refuses it as script credit, so proposing it strands the quest.
950
954
  if (isScriptCreditOnlyTarget(ds, kt.creatureEntry, state.questlog)) continue;
951
955
  // Named kill target is the quest — fight it even when it is not on zone.huntable.
952
- if (ds.creature(kt.creatureEntry)) return { kind: 'fight', creatureEntry: kt.creatureEntry };
956
+ const cr = ds.creature(kt.creatureEntry);
957
+ if (cr) killable.push({ entry: kt.creatureEntry, level: creatureFarmLevel(cr), chance: 0, rank: creatureFarmRank(cr) });
958
+ }
959
+ if (killable.length > 0) {
960
+ // Several unmet kill objectives: rotate among the near-equal ones that are actually here, so a
961
+ // multi-template objective does not open on its first-listed entry every pull.
962
+ const here = new Set(hunt.map((c) => c.entry));
963
+ const local = killable.filter((k) => here.has(k.entry));
964
+ if (local.length === 0) return { kind: 'fight', creatureEntry: killable[0].entry };
965
+ local.sort((a, b) => compareQuestDroppers(a, b, state.char.level) || a.entry - b.entry);
966
+ return { kind: 'fight', creatureEntry: pickQuestDropper(local, state.char.level, salt)!.entry };
953
967
  }
954
968
  const farm = corpseUseFightEntries(ds, def, qs, state.inventory);
955
969
  if (farm.length) {
@@ -986,7 +1000,8 @@ function fightStep(
986
1000
  }
987
1001
  const usable = [...byEntry.values()];
988
1002
  usable.sort((a, b) => compareQuestDroppers(a, b, state.char.level) || a.entry - b.entry);
989
- if (usable[0]) return { kind: 'fight', creatureEntry: usable[0].entry };
1003
+ const dropper = pickQuestDropper(usable, state.char.level, salt);
1004
+ if (dropper) return { kind: 'fight', creatureEntry: dropper.entry };
990
1005
  if (unmetItems.length === 0) return null;
991
1006
  // farm[] is one union over every itemTarget, and many named templates are not on zone.huntable.
992
1007
  // Prefer an entry that drops what we still need; 40 of 1366 farm quests gate it behind a condition.
@@ -1008,7 +1023,8 @@ function fightStep(
1008
1023
  ? farmDrop
1009
1024
  : farmScored.filter((s) => !farmCandidateIsHopeless(ds, def, s.ce, unmetItems, owned, snap));
1010
1025
  farmPool.sort((a, b) => compareQuestDroppers(a, b, state.char.level) || a.ce - b.ce);
1011
- if (farmPool[0]) return { kind: 'fight', creatureEntry: farmPool[0].ce };
1026
+ const farmPick = pickQuestDropper(farmPool, state.char.level, salt);
1027
+ if (farmPick) return { kind: 'fight', creatureEntry: farmPick.ce };
1012
1028
  for (const it of unmetItems) {
1013
1029
  const fight = pickFightable(ds, state, creatureDroppersForItem(ds, it.item, snap, state.char.level), skip);
1014
1030
  if (fight != null) return { kind: 'fight', creatureEntry: fight };
@@ -1055,13 +1071,14 @@ function dungeonOnlyWork(
1055
1071
  }
1056
1072
 
1057
1073
  // Farm entries for one item, ordered the way fightStep picks: noise floor first, then compareQuestDroppers,
1058
- // so the travel hop names the mob the fight will actually open on.
1074
+ // then the salted pick moved to the front, so the travel hop names the mob the fight will actually open on.
1059
1075
  function rankFarmForItem(
1060
1076
  ds: DataStore,
1061
1077
  farm: readonly number[],
1062
1078
  itemId: number,
1063
1079
  snap: ConditionSnapshot,
1064
1080
  playerLevel: number,
1081
+ salt: number,
1065
1082
  ): number[] {
1066
1083
  const scored: { ce: number; chance: number; level: number; rank: number }[] = [];
1067
1084
  const floor = dropperNoiseFloor(ds, itemId, snap);
@@ -1079,7 +1096,8 @@ function rankFarmForItem(
1079
1096
  const drops = scored.filter((s) => s.chance > 0);
1080
1097
  const pool = drops.length > 0 ? drops : scored;
1081
1098
  pool.sort((a, b) => compareQuestDroppers(a, b, playerLevel) || a.ce - b.ce);
1082
- return pool.map((s) => s.ce);
1099
+ const first = pickQuestDropper(pool, playerLevel, salt);
1100
+ return first ? [first.ce, ...pool.filter((s) => s !== first).map((s) => s.ce)] : [];
1083
1101
  }
1084
1102
 
1085
1103
  function farmTravelStep(
@@ -1108,7 +1126,7 @@ function farmTravelStep(
1108
1126
  if (countItem(ownedOf(state), it.item) >= it.count) continue;
1109
1127
  if (isProvidedItem(def, it.item)) continue;
1110
1128
  const ces = (def.farm ?? []).length
1111
- ? rankFarmForItem(ds, def.farm ?? [], it.item, snap, state.char.level)
1129
+ ? rankFarmForItem(ds, def.farm ?? [], it.item, snap, state.char.level, dropperPickSalt(state))
1112
1130
  : creatureDroppersForItem(ds, it.item, snap, state.char.level);
1113
1131
  for (const ce of ces) {
1114
1132
  if (opts?.skipCreatures?.has(ce)) continue;