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.
- package/README.md +362 -85
- package/bin/azsh.mjs +35 -19
- package/package.json +12 -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 +145 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +298 -0
- package/src/cli/oocPrep.mjs +32 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +48 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +319 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +21 -3
- package/src/cli/tui.mjs +262 -102
- package/src/cli/tuiAutopilot.mjs +356 -0
- package/src/cli/tuiBags.mjs +37 -4
- package/src/cli/tuiBg.mjs +370 -41
- package/src/cli/tuiCheck.mjs +633 -37
- package/src/cli/tuiCity.mjs +146 -41
- package/src/cli/tuiCompanionKit.mjs +6 -6
- package/src/cli/tuiCompanions.mjs +211 -41
- package/src/cli/tuiCreate.mjs +230 -0
- package/src/cli/tuiDungeon.mjs +57 -21
- package/src/cli/tuiFight.mjs +467 -88
- package/src/cli/tuiFightPanes.mjs +42 -14
- package/src/cli/tuiFloor.mjs +21 -9
- package/src/cli/tuiGear.mjs +17 -14
- package/src/cli/tuiLoot.mjs +6 -6
- package/src/cli/tuiMap.mjs +214 -0
- package/src/cli/tuiPaperdoll.mjs +108 -0
- package/src/cli/tuiPartyStage.mjs +206 -0
- package/src/cli/tuiPet.mjs +16 -8
- package/src/cli/tuiProfessions.mjs +27 -25
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +24 -1
- package/src/cli/tuiSearch.mjs +79 -27
- package/src/cli/tuiSettings.mjs +3 -4
- package/src/cli/tuiStage.mjs +360 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiTalents.mjs +3 -4
- package/src/cli/tuiViews.mjs +633 -172
- package/src/cli/tuiWiki.mjs +2 -3
- package/src/cli/tuiWorld.mjs +90 -7
- 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-worn.bin.br +0 -0
- package/src/data/model-art.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- package/src/data/tables/creatures.json.gz +0 -0
- package/src/data/world-map-sixel.bin.br +0 -0
- package/src/data/world-map.bin.gz +0 -0
- 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/character/character.ts +6 -0
- package/src/domain/pet/pet.ts +31 -0
- 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 +3494 -3449
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +19 -2
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/engine/game/loadReconcile.ts +21 -1
- package/src/engine/game/pets.ts +33 -1
- package/src/engine/game/types.ts +4 -0
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiWiki.mjs
CHANGED
|
@@ -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 ? `, ${
|
|
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);
|
package/src/cli/tuiWorld.mjs
CHANGED
|
@@ -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
|
-
|
|
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}, ${
|
|
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 ${
|
|
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
|
package/src/domain/bg/match.ts
CHANGED
|
@@ -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.
|
|
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
|
|
160
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
196
|
-
const
|
|
197
|
-
|
|
198
|
-
//
|
|
199
|
-
//
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
}
|