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
@@ -2,11 +2,10 @@
2
2
  // (items, spells, quests, NPCs, talents, recipes, zones, dungeons, objects, factions); Tab narrows to one kind.
3
3
  // A page lists what links to it (where an item drops, who sells it, which quest rewards it, who teaches a spell)
4
4
  // as rows: Enter follows a link, Esc goes back. Search and sources are phonecraft's own pure indexes.
5
- import { bold, dim, yellow, green, red, cyan, qualityPaint, wrapLines } from './text.mjs';
5
+ import { bold, dim, yellow, green, red, cyan, qualityPaint, wrapLines, coinText } from './text.mjs';
6
6
  import { itemLines, spellLines, creatureLines, rewardLines } from './tuiViews.mjs';
7
7
  import { wikiIndex, wikiTokens, wikiSearch, WIKI_KINDS, WIKI_KIND_LABEL, WIKI_KIND_ONE } from './wikiIndex.ts';
8
8
  import { wikiSourceIndexSync } from './wikiSources.ts';
9
- import { moneyText } from '../domain/economy/money.ts';
10
9
  import { className } from '../domain/core/ids.ts';
11
10
 
12
11
  const H = (text) => ({ text, header: true });
@@ -135,7 +134,7 @@ function pageLinks(app, kind, id) {
135
134
  section('Reputation', (q.rewRep ?? []).filter((r) => r.value).map((r) => link(app, 'faction', r.faction, `${ds.faction(r.faction)?.name ?? r.faction} ${r.value > 0 ? '+' : ''}${r.value}`)));
136
135
  const chain = [q.prevQuest && ['Comes after', q.prevQuest], q.nextQuest && ['Leads to', q.nextQuest]].filter(Boolean);
137
136
  for (const [label, e] of chain) section(label, [link(app, 'quest', e, ds.quest(e)?.title ?? String(e))]);
138
- rows.push(H(''), H(dim(`From ${q.starterName || '?'}, to ${q.enderName || '?'}${q.rewMoney ? `, ${moneyText(q.rewMoney)}` : ''}`)));
137
+ rows.push(H(''), H(dim(`From ${q.starterName || '?'}, to ${q.enderName || '?'}${q.rewMoney ? `, ${coinText(q.rewMoney)}` : ''}`)));
139
138
  }
140
139
  } else if (kind === 'recipe') {
141
140
  const r = ds.recipe(id);
@@ -1,14 +1,20 @@
1
1
  // World-tab extras that are not a creature fight, a quest or a vendor: pick pocket for rogues (P on a creature)
2
2
  // real-time journeys, riding (instructors, mounts), and the zone's objects (chests, books, quest objects).
3
- import { bold, dim, yellow, red, green, cyan, bar, wrapLines } from './text.mjs';
3
+ import { bold, dim, yellow, red, green, cyan, bar, wrapLines, coinText } from './text.mjs';
4
4
  import { startFight } from './tuiFight.mjs';
5
5
  import { knownUsableSpells } from '../domain/character/characterSpells.ts';
6
6
  import { activeStealthAura } from '../domain/character/buildCombatant.ts';
7
+ import { inferRole } from '../engine/combat.ts';
8
+ import { countItem } from '../domain/items/inventory.ts';
9
+ import { requiresSourceUseCredit } from '../domain/quests/questlog.ts';
10
+ import { tamingRodByQuestId } from '../domain/quests/questTameRods.ts';
11
+ import { pendingCorpseUseItem } from '../domain/quests/questCorpseUse.ts';
12
+ import { pendingScriptItemUse } from '../domain/quests/questScriptItemUse.ts';
7
13
  import { pickpocketLootKey, canRestockPickpocket } from '../domain/professions/pickpocket.ts';
8
- import { moneyText } from '../domain/economy/money.ts';
9
14
  import { vendorPriceMultiplierFor, trainerSpellCost } from '../engine/game/vendorOffers.ts';
10
- import { questDialog } from './tuiViews.mjs';
15
+ import { questDialog, npcLines } from './tuiViews.mjs';
11
16
  import { mailDialog } from './tuiCity.mjs';
17
+ import { withWorldMap } from './tuiMap.mjs';
12
18
  import { autoLootOn, openLoot, takeAll } from './tuiLoot.mjs';
13
19
  import { zoneGameObjects, zoneGoListedForPlayer, makeGoPlayerVisibility, questReferencedGoEntries, questTargets } from '../world/world.ts';
14
20
  import { goQuestGiverOffer, goDialogMarkerFromOffer, questStatusFromLog } from '../domain/quests/questlog.ts';
@@ -54,6 +60,82 @@ export function pickPocketTag(app, creature) {
54
60
  return st === 'ready' ? dim(' P: pick pocket') : st === 'empty' ? dim(' pockets empty') : '';
55
61
  }
56
62
 
63
+ // ---- taming ----------------------------------------------------------------------------------------------
64
+ // The facade's tamePet is ungated, so the row gates it as phonecraft's worldView does: a hunter who has learned
65
+ // Tame Beast (the pet quest's reward) and has no pet out. While a Taming the Beast quest is open, its rod takes
66
+ // the place of the tame on the beast it names (useQuestItemOnTarget credits it). Ref worldView.ts:155.
67
+
68
+ const HUNTER = 3;
69
+
70
+ /** The Tame Beast rank you know, or undefined. */
71
+ export function tameSpell(app) {
72
+ if (app.ch.cls !== HUNTER) return undefined;
73
+ return knownUsableSpells(app.ch, app.g.state.spellbook, app.ds).find((s) => (s.role ?? inferRole(s)) === 'pet_tame');
74
+ }
75
+
76
+ /** A quest item to use on a creature: the open Taming the Beast quest's rod (only on its beast), or a use-to-credit source item (on a tameable beast). */
77
+ export function questUseItem(app) {
78
+ const inv = app.g.state.inventory;
79
+ for (const qs of app.g.state.questlog.quests) {
80
+ if (qs.status === 'turnedIn' || qs.status === 'complete') continue;
81
+ const rod = tamingRodByQuestId(qs.entry);
82
+ if (rod && countItem(inv, rod.item) >= 1) { const it = app.ds.item(rod.item); if (it) return { entry: it.entry, name: it.name, onlyCreature: rod.targetCreature }; }
83
+ const q = app.ds.quest(qs.entry);
84
+ if (!q || !requiresSourceUseCredit(q)) continue;
85
+ for (const t of q.sourceItemTargets ?? []) { if (countItem(inv, t.item) < 1) continue; const it = app.ds.item(t.item); if (it) return { entry: it.entry, name: it.name }; }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ const useItemFits = (use, creature) => !!use && (use.onlyCreature != null ? creature.entry === use.onlyCreature : !!creature.tameable);
91
+
92
+ /** What t on this creature does: 'use' (a quest item), 'tame', or why not ('' when it is not for t at all). */
93
+ export function tameState(app, creature) {
94
+ if (useItemFits(questUseItem(app), creature)) return 'use';
95
+ if (app.ch.cls !== HUNTER || !creature.tameable) return '';
96
+ if (!tameSpell(app)) return 'unlearned';
97
+ return app.ch.pet ? 'pet' : 'tame';
98
+ }
99
+
100
+ export function tameTag(app, creature) {
101
+ const st = tameState(app, creature);
102
+ return st === 'use' ? dim(` t: use ${questUseItem(app).name}`) : st === 'tame' ? dim(' t: tame') : '';
103
+ }
104
+
105
+ /** t on a creature: use the quest item on it, or tame it. */
106
+ export function tame(app, creature) {
107
+ const st = tameState(app, creature);
108
+ if (st === 'use') {
109
+ const use = questUseItem(app);
110
+ app.result(app.g.useQuestItemOnTarget({ itemEntry: use.entry, targetCreatureEntry: creature.entry }), `You use ${use.name} on ${creature.name}.`);
111
+ return;
112
+ }
113
+ if (app.ch.cls !== HUNTER) { app.flashMsg('Only hunters tame beasts.'); return; }
114
+ if (!creature.tameable) { app.flashMsg(`${creature.name} cannot be tamed.`); return; }
115
+ if (st === 'unlearned') { app.flashMsg('Learn Tame Beast first: the hunter trainer sends you on the taming quests at level 10.'); return; }
116
+ if (st === 'pet') { app.flashMsg(`Dismiss or stable ${app.ch.pet.name} before taming another.`); return; }
117
+ app.result(app.g.tamePet(creature.entry), `You tame ${creature.name}.`);
118
+ }
119
+
120
+ // ---- right here: loot left behind, and quest items used on a corpse or a creature the list never offers -------
121
+ // Ref worldView.ts:551-582. A corpse item (Bramble, a molt) goes on your last kill; a script item (Lazy Peons'
122
+ // blackjack) goes on a civilian, who is never on the creature list.
123
+
124
+ export function hereRows(app) {
125
+ const g = app.g, ds = app.ds, inv = g.state.inventory, rows = [];
126
+ const left = g.pendingLoot();
127
+ if (left && app.top()?.id !== 'loot') rows.push({ text: `${yellow('Loot')} ${left.title ?? 'what you left'} ${dim('still lootable')}`, detail: () => [bold(left.title ?? 'Loot'), 'You closed it with things still in it.', '', dim('Enter: open it again')], act: () => openLoot(app) });
128
+ const active = g.state.questlog.quests.filter((qs) => qs.status === 'active').map((qs) => ({ qs, q: ds.quest(qs.entry) })).filter((x) => x.q);
129
+ const useRow = (item, target, q) => {
130
+ const it = ds.item(item), on = target ? ds.creature(target)?.name ?? `creature ${target}` : ds.creature(g.state.lastCorpse?.creatureEntry ?? 0)?.name ?? 'the corpse';
131
+ return { text: `${yellow('Use')} ${it?.name ?? `item ${item}`} on ${on} ${dim(q.title)}`, icon: it?.icon, detail: () => [bold(`Use ${it?.name ?? 'item'} on ${on}`), `For ${q.title}.`, '', dim('Enter: use it')], act: () => app.result(g.useQuestItemOnTarget({ itemEntry: item, ...(target ? { targetCreatureEntry: target } : {}) }), `You use ${it?.name ?? 'it'} on ${on}.`) };
132
+ };
133
+ const corpse = active.map(({ q }) => ({ q, item: pendingCorpseUseItem(ds, q, inv, g.state.lastCorpse) })).find((x) => x.item);
134
+ if (corpse) rows.push(useRow(corpse.item, 0, corpse.q));
135
+ for (const { q, qs } of active) { const u = pendingScriptItemUse(ds, q, qs, inv, app.ch.zoneId); if (u) { rows.push(useRow(u.item, u.targetCreatureEntry, q)); break; } }
136
+ return rows.length ? [{ text: '', header: true }, { text: 'Here', header: true }, ...rows] : [];
137
+ }
138
+
57
139
  // ---- journeys ------------------------------------------------------------------------------------------
58
140
  // With a real-time travel pace (Settings) a trip takes wall-clock time: the game loop calls journeyTick, the
59
141
  // header shows where you are headed, and the World and Travel tabs carry this card. Instant pace never gets here.
@@ -101,7 +183,8 @@ export function journeyRows(app) {
101
183
  { text: `${bar(st.progress.totalElapsedSeconds, total, 30, cyan)} ${mmss(st.progress.remainingSeconds)} left${app.travelPaused ? yellow(' paused') : ''}`, header: true },
102
184
  {
103
185
  text: `${leg ? `${leg.kind} ${app.ds.zone(leg.from)?.name ?? leg.from} → ${app.ds.zone(leg.to)?.name ?? leg.to}` : ''} ${dim(`${st.timeScale}x Enter: journey options`)}`,
104
- detail: () => [bold(`To ${dest}`), '', ...st.legs.map((l, i) => `${i < st.progress.legIndex ? green('✓') : i === st.progress.legIndex ? cyan('▶') : dim('·')} ${l.kind} ${app.ds.zone(l.from)?.name ?? l.from} → ${app.ds.zone(l.to)?.name ?? l.to} ${dim(mmss(l.seconds))}`), '', dim('Enter: speed, pause, arrive now, turn back')],
186
+ // The legs, then the world map with the traveller walking the current one (tuiMap.mjs).
187
+ detail: () => withWorldMap(app, [bold(`To ${dest}`), '', ...st.legs.map((l, i) => `${i < st.progress.legIndex ? green('✓') : i === st.progress.legIndex ? cyan('▶') : dim('·')} ${l.kind} ${app.ds.zone(l.from)?.name ?? l.from} → ${app.ds.zone(l.to)?.name ?? l.to} ${dim(mmss(l.seconds))}`), '', dim('Enter: speed, pause, arrive now, turn back')]),
105
188
  act: (a) => journeyMenu(a),
106
189
  },
107
190
  { text: '', header: true },
@@ -130,7 +213,7 @@ export function ridingRows(app) {
130
213
  const name = app.ds.creature(entry)?.name ?? `Riding Instructor (${entry})`;
131
214
  return {
132
215
  text: `${name} ${dim('<Riding Instructor>')}${app.ch.ridingSkill ? dim(` you ride at ${app.ch.ridingSkill}`) : ''}`,
133
- detail: () => [bold(name), 'Riding instructor', `Your riding: ${app.ch.ridingSkill ?? 0}`, '', dim('Enter: learn to ride')],
216
+ detail: () => npcLines(app, entry, [bold(name), 'Riding instructor', `Your riding: ${app.ch.ridingSkill ?? 0}`, '', dim('Enter: learn to ride')]),
134
217
  act: (a) => ridingDialog(a, entry),
135
218
  };
136
219
  });
@@ -146,11 +229,11 @@ function ridingDialog(app, entry) {
146
229
  seen.add(sp.rank);
147
230
  const cost = trainerSpellCost(sp.cost, mod);
148
231
  const why = have >= sp.rank ? 'known' : sp.step > 1 && have < 75 ? 'needs Apprentice Riding' : app.ch.level < sp.reqLevel ? `level ${sp.reqLevel}` : app.ch.gold < cost ? 'not enough money' : '';
149
- opts.push({ label: `${why ? dim(`Riding ${sp.rank}`) : green(`Riding ${sp.rank}`)} ${dim(`level ${sp.reqLevel}, ${moneyText(cost)}`)}${why ? dim(` ${why}`) : ''}`, value: sp.rank });
232
+ opts.push({ label: `${why ? dim(`Riding ${sp.rank}`) : green(`Riding ${sp.rank}`)} ${dim(`level ${sp.reqLevel}, ${coinText(cost)}`)}${why ? dim(` ${why}`) : ''}`, value: sp.rank });
150
233
  }
151
234
  app.pick(`${app.ds.creature(entry)?.name ?? 'Riding instructor'}`, opts, (rank) => {
152
235
  const r = app.g.trainRiding(entry, rank);
153
- app.result(r, r.ok ? `You learn Riding (${r.rank}) for ${moneyText(r.spent)}.` : '');
236
+ app.result(r, r.ok ? `You learn Riding (${r.rank}) for ${coinText(r.spent)}.` : '');
154
237
  });
155
238
  }
156
239
 
Binary file
@@ -0,0 +1,114 @@
1
+ {
2
+ "tag": "art-2",
3
+ "base": "https://github.com/LienSimen/azsh/releases/download/art-2/",
4
+ "files": [
5
+ {
6
+ "name": "model-art-hd.bin.br",
7
+ "path": "model-art-hd.bin.br",
8
+ "size": 19637101,
9
+ "sha256": "347fffd9272586dff2eeec7b6763ae0814701906fdcb3bb6217046130240acc8"
10
+ },
11
+ {
12
+ "name": "icon-art-hd.bin.br",
13
+ "path": "icon-art-hd.bin.br",
14
+ "size": 5183918,
15
+ "sha256": "fc66716b4403d443f7a22da6bf956464220923c5faa80245070bd4ca1e556501"
16
+ },
17
+ {
18
+ "name": "gear-art-hd-dwarf-female.bin.br",
19
+ "path": "gear-art-hd/dwarf-female.bin.br",
20
+ "size": 10001585,
21
+ "sha256": "a96907c4ecf9dfb1d9cf9d5614fb06a1e09998e70bbfd17e55c6e79a6ad8b4ad"
22
+ },
23
+ {
24
+ "name": "gear-art-hd-dwarf-male.bin.br",
25
+ "path": "gear-art-hd/dwarf-male.bin.br",
26
+ "size": 14404864,
27
+ "sha256": "e096f75a13261756312d2472130d43e09f90cfeaa2327a4b4f8d9c96d6b52c1c"
28
+ },
29
+ {
30
+ "name": "gear-art-hd-gnome-female.bin.br",
31
+ "path": "gear-art-hd/gnome-female.bin.br",
32
+ "size": 11048883,
33
+ "sha256": "cdbb81d645ecd01c48c25d06982e372e6f697ad29862c72dac9fcfb59fd4d16c"
34
+ },
35
+ {
36
+ "name": "gear-art-hd-gnome-male.bin.br",
37
+ "path": "gear-art-hd/gnome-male.bin.br",
38
+ "size": 11875475,
39
+ "sha256": "c4440b1475ecb73d290140619eb99196d32f3417e27d2e04384b5378cf49730d"
40
+ },
41
+ {
42
+ "name": "gear-art-hd-human-female.bin.br",
43
+ "path": "gear-art-hd/human-female.bin.br",
44
+ "size": 7089810,
45
+ "sha256": "5c4d7d3d39a445bdbf7ff43d7e412a0a8679a841f2376eb1dc7aa54f16995bd2"
46
+ },
47
+ {
48
+ "name": "gear-art-hd-human-male.bin.br",
49
+ "path": "gear-art-hd/human-male.bin.br",
50
+ "size": 9311191,
51
+ "sha256": "207abf704dd8efea247efc2a51a466715b89c55381ce5efb4915377a53dd45ee"
52
+ },
53
+ {
54
+ "name": "gear-art-hd-nightelf-female.bin.br",
55
+ "path": "gear-art-hd/nightelf-female.bin.br",
56
+ "size": 6381920,
57
+ "sha256": "0b7bf823059d380bfb7f72a18863a17e44023aba87b219d2416a7e4b6583e055"
58
+ },
59
+ {
60
+ "name": "gear-art-hd-nightelf-male.bin.br",
61
+ "path": "gear-art-hd/nightelf-male.bin.br",
62
+ "size": 9122533,
63
+ "sha256": "3711a86d14fa788ab5ecdf49dcd9eae30409a7a30bd59ed1849672ed52f01794"
64
+ },
65
+ {
66
+ "name": "gear-art-hd-orc-female.bin.br",
67
+ "path": "gear-art-hd/orc-female.bin.br",
68
+ "size": 8417526,
69
+ "sha256": "eb3ba4150f9146725ed530798e56d47128d67be79ae597db267c087fa9786688"
70
+ },
71
+ {
72
+ "name": "gear-art-hd-orc-male.bin.br",
73
+ "path": "gear-art-hd/orc-male.bin.br",
74
+ "size": 13171006,
75
+ "sha256": "2c41e7fea6ab2db7e5b1c64990dc9d963240610d7bfe52e5ec496da9cd392f5b"
76
+ },
77
+ {
78
+ "name": "gear-art-hd-tauren-female.bin.br",
79
+ "path": "gear-art-hd/tauren-female.bin.br",
80
+ "size": 8849291,
81
+ "sha256": "fe7f37cd80abfcbab36e54cfd50311833457f6b98a1cea34e87ae031965bc35a"
82
+ },
83
+ {
84
+ "name": "gear-art-hd-tauren-male.bin.br",
85
+ "path": "gear-art-hd/tauren-male.bin.br",
86
+ "size": 12346906,
87
+ "sha256": "b718b1abdf972a602636a5b3ccf15ce9eb6f9714b8d29e8c78682fa4e0962c05"
88
+ },
89
+ {
90
+ "name": "gear-art-hd-troll-female.bin.br",
91
+ "path": "gear-art-hd/troll-female.bin.br",
92
+ "size": 6684064,
93
+ "sha256": "f1bef8c28ad0d267a14118b830309f952cc40acd95bb79954e3a720f5e2916ef"
94
+ },
95
+ {
96
+ "name": "gear-art-hd-troll-male.bin.br",
97
+ "path": "gear-art-hd/troll-male.bin.br",
98
+ "size": 8909278,
99
+ "sha256": "f58f08e62a6f23295b887a3b97657eb4f3e097e47e17aa0f3e94111500b69f26"
100
+ },
101
+ {
102
+ "name": "gear-art-hd-undead-female.bin.br",
103
+ "path": "gear-art-hd/undead-female.bin.br",
104
+ "size": 7500090,
105
+ "sha256": "d99136b4fa6edef4fdc676dbb5e988e4bf3b65a125ce1de8b84b889ae17d06a9"
106
+ },
107
+ {
108
+ "name": "gear-art-hd-undead-male.bin.br",
109
+ "path": "gear-art-hd/undead-male.bin.br",
110
+ "size": 9072949,
111
+ "sha256": "d421f1719d60abcbe466775bc724710d5adc0049e89dbbc29fd0c64151351f2b"
112
+ }
113
+ ]
114
+ }
Binary file
Binary file
Binary file
@@ -394,7 +394,7 @@
394
394
  ],
395
395
  "pull": [
396
396
  {
397
- "spell": "Seal of Righteousness",
397
+ "spell": "Seal of Command / Seal of Righteousness",
398
398
  "when": "buff absent",
399
399
  "conditions": [
400
400
  {
@@ -520,7 +520,7 @@
520
520
  ]
521
521
  },
522
522
  {
523
- "spell": "Seal of Righteousness",
523
+ "spell": "Seal of Command / Seal of Righteousness",
524
524
  "when": "buff absent",
525
525
  "conditions": [
526
526
  {
Binary file
Binary file
Binary file
@@ -128,7 +128,7 @@ export class BgMatch {
128
128
  // Guard on "already registered dead", NOT alive(): a unit the resolver just took to 0 hp
129
129
  // is already !alive, so that guard skipped kill() and the roster piled up as corpses.
130
130
  if (!u || u.deadUntilMs != null) continue;
131
- this.world.kill(u, this.respawnMs);
131
+ this.world.kill(u, this.respawnWaitMs());
132
132
  this.deaths++;
133
133
  }
134
134
  }
@@ -136,6 +136,19 @@ export class BgMatch {
136
136
 
137
137
  }
138
138
 
139
+ // The spirit healer raises everyone waiting at once, every respawnMs on the match clock; a body that
140
+ // fell just before the wave has not released yet and waits for the next. Cite: BattleGround.cpp
141
+ // RESURRECTION_INTERVAL, m_LastResurrectTime. Per-death timers made the dead trickle back one at a
142
+ // time, each walking alone into the side that had just killed them: 95 deaths, 0-0 after 20 minutes.
143
+ respawnWaitMs(): number {
144
+ const cycle = this.respawnMs;
145
+ if (!(cycle > 0)) return 0;
146
+ const t = this.world.t;
147
+ let wait = cycle - (((t % cycle) + cycle) % cycle);
148
+ if (wait < RELEASE_MS) wait += cycle;
149
+ return wait;
150
+ }
151
+
139
152
  stats(): BgMatchStats {
140
153
  return {
141
154
  ticks: this.ticks,
@@ -153,18 +166,57 @@ export class BgMatch {
153
166
  }
154
167
  }
155
168
 
169
+ /** A body that fell this close to the wave misses it: the ghost has not released yet. */
170
+ const RELEASE_MS = 3000;
171
+
156
172
  /** Classes that can hold someone up: paladin, priest, druid, shaman. Cite: domain/ids CLASS. */
157
173
  const HEALER_CLASSES = new Set([2, 5, 7, 11]);
158
174
 
159
- // A deliberately simple shard resolver for probes and auto-resolve: units in contact chip the
160
- // nearest enemy and healers top up allies. Without the healing half, 112 pickups gave 2 captures.
175
+ // A seat heals if its roster role says so; a seat with no role falls back to the class. The class
176
+ // alone made six of ten bodies a side "healers" on a real roster (every paladin, priest, shaman and
177
+ // druid), so four attackers chipped at six healers and a 10v10 ran minutes without a death.
178
+ const isHealer = (u: BgUnit): boolean => u.heals ?? HEALER_CLASSES.has(u.cls);
179
+
180
+ // The body the flat rates were tuned on: a level-60 seat. Damage and healing scale with the FIELD's
181
+ // average pool, so a level-20 field of 560-hp bodies trades as a level-60 field of 3,000 does, while
182
+ // a 970-hp warrior on it still outlasts a 380-hp warlock. Flat 60 dps killed a level-22 flag carrier
183
+ // in nine seconds, so every pickup was a drop and a 12-minute match ended 0-0. Scaling by the
184
+ // target's own pool was the first fix and made every body die in the same time, hp or no hp.
185
+ export const ATTRITION_REF_HP = 3000;
186
+
187
+ /** The field's mean max hp over the reference, recomputed once per match tick. */
188
+ function poolScale(world: BgWorld, cache: { t: number; scale: number }): number {
189
+ if (cache.t === world.t) return cache.scale;
190
+ let sum = 0;
191
+ let n = 0;
192
+ for (const u of world.units.values()) { sum += u.maxHp; n++; }
193
+ cache.t = world.t;
194
+ cache.scale = n ? (sum / n) / ATTRITION_REF_HP : 1;
195
+ return cache.scale;
196
+ }
197
+
198
+ // A deliberately simple shard resolver for probes and auto-resolve: each side focuses one enemy (the
199
+ // flag carrier if one is in the shard, else the most hurt) and healers top up allies. Without the
200
+ // healing half, 112 pickups gave 2 captures; with "nearest" targeting instead of a focus, two healers
201
+ // a side out-healed a 10v10 indefinitely.
161
202
  export function attritionResolver(dpsPerSecond = 60, hpsPerSecond = dpsPerSecond * 0.55): ShardResolver {
203
+ // Who each attacker hit last tick, so a side stays on its target until it drops.
204
+ const lastTarget = new WeakMap<BgUnit, BgUnit>();
205
+ const pool = { t: NaN, scale: 1 };
162
206
  return (world, shard, dtMs, rng) => {
207
+ const scale = poolScale(world, pool);
163
208
  const dead: BgUnitId[] = [];
164
209
  const units = shard.units.map((id) => world.units.get(id)!).filter((u) => world.alive(u));
165
210
  const dt = dtMs / 1000;
211
+ const focus: Record<BgTeam, BgUnit | null> = {
212
+ alliance: pickFocus(world, 'alliance', units, rng, lastTarget),
213
+ horde: pickFocus(world, 'horde', units, rng, lastTarget),
214
+ };
166
215
  for (const u of units) {
167
- if (HEALER_CLASSES.has(u.cls)) {
216
+ // Dropped earlier this tick: no dead man's swing. The side iterated first used to take one from
217
+ // every body it killed, since the corpse's own turn still came round.
218
+ if (u.hp <= 0) continue;
219
+ if (isHealer(u)) {
168
220
  // Heal the most hurt friendly in the shard, self included.
169
221
  let target: BgUnit | null = null;
170
222
  let worst = Infinity;
@@ -174,13 +226,16 @@ export function attritionResolver(dpsPerSecond = 60, hpsPerSecond = dpsPerSecond
174
226
  if (pct < worst) { worst = pct; target = o; }
175
227
  }
176
228
  if (target && worst < 1) {
177
- target.hp = Math.min(target.maxHp, target.hp + hpsPerSecond * dt);
229
+ target.hp = Math.min(target.maxHp, target.hp + hpsPerSecond * dt * scale);
178
230
  continue; // healing IS this unit's action for the tick
179
231
  }
180
232
  }
181
- const foe = pickFoe(world, u, units, rng);
233
+ // The focus fell this tick: the rest of the side turns to the next one.
234
+ let foe = focus[u.team];
235
+ if (foe && !world.alive(foe)) foe = focus[u.team] = pickFocus(world, u.team, units, rng, lastTarget);
182
236
  if (!foe) continue;
183
- foe.hp -= dpsPerSecond * dt;
237
+ lastTarget.set(u, foe);
238
+ foe.hp -= dpsPerSecond * dt * scale;
184
239
  if (foe.hp <= 0 && !dead.includes(foe.id)) {
185
240
  dead.push(foe.id);
186
241
  // `u` landed the killing chip — the only place the killer is known, and what the
@@ -192,24 +247,30 @@ export function attritionResolver(dpsPerSecond = 60, hpsPerSecond = dpsPerSecond
192
247
  };
193
248
  }
194
249
 
195
- /** Yards within which two foes count as equally close, so the pick is a real choice. */
196
- const FOE_TIE_YD = 8;
197
-
198
- // Nearest enemy, with ties broken from the seeded stream. The tie-break is what makes the seed mean
199
- // anything: strict nearest-first never touched the rng, so five seeds produced one identical match.
200
- function pickFoe(world: BgWorld, u: BgUnit, pool: BgUnit[], rng: Rng): BgUnit | null {
201
- let bd = Infinity;
202
- for (const o of pool) {
203
- if (o.team === u.team || !world.alive(o)) continue;
204
- const d = world.distance(u, o);
205
- if (d < bd) bd = d;
206
- }
207
- if (!Number.isFinite(bd)) return null;
208
- const close: BgUnit[] = [];
209
- for (const o of pool) {
210
- if (o.team === u.team || !world.alive(o)) continue;
211
- if (world.distance(u, o) <= bd + FOE_TIE_YD) close.push(o);
250
+ /** Health fraction within which two foes count as equally hurt, so the pick is a real choice. */
251
+ const FOCUS_TIE_PCT = 0.1;
252
+
253
+ // One side's target in a shard: the enemy carrying a flag; else the target the side was already on,
254
+ // while it stands; else the most hurt enemy, ties broken from the seeded stream. The tie-break is
255
+ // what makes the seed mean anything: a strict pick never touched the rng, so five seeds produced one
256
+ // identical match. Sticking matters as much as focusing: re-picking "most hurt" every tick with a
257
+ // tie band spread the damage over every body in reach, so a 10v10 ran 70 s with no death and then
258
+ // seven bodies fell in the same five seconds.
259
+ function pickFocus(world: BgWorld, team: BgTeam, pool: BgUnit[], rng: Rng, lastTarget: WeakMap<BgUnit, BgUnit>): BgUnit | null {
260
+ const foes = pool.filter((o) => o.team !== team && world.alive(o));
261
+ if (!foes.length) return null;
262
+ const carriers = foes.filter((o) => o.carryingFlag);
263
+ if (!carriers.length) {
264
+ for (const u of pool) {
265
+ if (u.team !== team || !world.alive(u)) continue;
266
+ const held = lastTarget.get(u);
267
+ if (held && foes.includes(held)) return held;
268
+ }
212
269
  }
270
+ const among = carriers.length ? carriers : foes;
271
+ let worst = Infinity;
272
+ for (const o of among) worst = Math.min(worst, o.hp / Math.max(1, o.maxHp));
273
+ const close = among.filter((o) => o.hp / Math.max(1, o.maxHp) <= worst + FOCUS_TIE_PCT);
213
274
  if (close.length <= 1) return close[0] ?? null;
214
275
  return close[Math.min(close.length - 1, Math.floor(rng.next() * close.length))]!;
215
276
  }