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.
- package/README.md +326 -82
- package/bin/azsh.mjs +21 -19
- package/package.json +19 -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/fileRepository.mjs +46 -1
- package/src/cli/gzLoader.mjs +47 -6
- package/src/cli/hdArt.mjs +81 -0
- package/src/cli/iconArt.mjs +135 -0
- package/src/cli/iconGrid.mjs +100 -0
- package/src/cli/modelArt.mjs +295 -0
- package/src/cli/paperdoll.mjs +76 -0
- package/src/cli/perfCheck.mjs +56 -0
- package/src/cli/repl.mjs +36 -7
- package/src/cli/rotationText.mjs +6 -6
- package/src/cli/sixel.mjs +307 -0
- package/src/cli/term.mjs +106 -12
- package/src/cli/text.mjs +5 -3
- package/src/cli/tui.mjs +252 -98
- package/src/cli/tuiAutopilot.mjs +347 -0
- package/src/cli/tuiBg.mjs +371 -42
- package/src/cli/tuiCheck.mjs +415 -28
- package/src/cli/tuiCity.mjs +118 -13
- package/src/cli/tuiCompanions.mjs +25 -15
- package/src/cli/tuiCreate.mjs +223 -0
- package/src/cli/tuiDungeon.mjs +1 -1
- package/src/cli/tuiFight.mjs +425 -88
- package/src/cli/tuiFightPanes.mjs +27 -8
- package/src/cli/tuiFloor.mjs +2 -2
- package/src/cli/tuiGear.mjs +10 -6
- package/src/cli/tuiLoot.mjs +1 -0
- package/src/cli/tuiPaperdoll.mjs +107 -0
- package/src/cli/tuiPartyStage.mjs +177 -0
- package/src/cli/tuiProfessions.mjs +4 -2
- package/src/cli/tuiRotationCond.mjs +390 -0
- package/src/cli/tuiSaves.mjs +22 -3
- package/src/cli/tuiSearch.mjs +75 -22
- package/src/cli/tuiSettings.mjs +15 -0
- package/src/cli/tuiSimLab.mjs +1 -1
- package/src/cli/tuiStage.mjs +304 -0
- package/src/cli/tuiTalentTree.mjs +124 -0
- package/src/cli/tuiViews.mjs +296 -115
- package/src/cli/tuiWorld.mjs +4 -5
- 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.bin.gz +0 -0
- package/src/data/rotations/pve/paladin/retribution.json +2 -2
- 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/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 +39 -1
- package/src/engine/combat/procsDamage.ts +31 -1
- package/src/engine/combat/sessionChase.ts +7 -1
- package/src/engine/combat/sessionMotionKit.ts +40 -2
- package/src/quests/quests.ts +60 -0
package/src/cli/tuiWorld.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { activeStealthAura } from '../domain/character/buildCombatant.ts';
|
|
|
7
7
|
import { pickpocketLootKey, canRestockPickpocket } from '../domain/professions/pickpocket.ts';
|
|
8
8
|
import { moneyText } from '../domain/economy/money.ts';
|
|
9
9
|
import { vendorPriceMultiplierFor, trainerSpellCost } from '../engine/game/vendorOffers.ts';
|
|
10
|
-
import { questDialog } from './tuiViews.mjs';
|
|
10
|
+
import { questDialog, npcLines } from './tuiViews.mjs';
|
|
11
11
|
import { mailDialog } from './tuiCity.mjs';
|
|
12
12
|
import { autoLootOn, openLoot, takeAll } from './tuiLoot.mjs';
|
|
13
13
|
import { zoneGameObjects, zoneGoListedForPlayer, makeGoPlayerVisibility, questReferencedGoEntries, questTargets } from '../world/world.ts';
|
|
@@ -56,7 +56,7 @@ export function pickPocketTag(app, creature) {
|
|
|
56
56
|
|
|
57
57
|
// ---- journeys ------------------------------------------------------------------------------------------
|
|
58
58
|
// With a real-time travel pace (Settings) a trip takes wall-clock time: the game loop calls journeyTick, the
|
|
59
|
-
// header shows where you are headed, and the World
|
|
59
|
+
// header shows where you are headed, and the World and Travel tabs carry this card. Instant pace never gets here.
|
|
60
60
|
|
|
61
61
|
const PACES = ['instant', 1, 2, 4];
|
|
62
62
|
export const paceLabel = (p) => (p === 'instant' ? 'instant' : `${p}x real time`);
|
|
@@ -86,8 +86,7 @@ export function journeyTick(app) {
|
|
|
86
86
|
if (!app.g.state.char.journey) {
|
|
87
87
|
app.say(green(`You arrive in ${app.zone()?.name ?? app.ch.zoneId}.`));
|
|
88
88
|
app.journeyWas = null;
|
|
89
|
-
const
|
|
90
|
-
if (world) { world.cursor = 0; world.scroll = 0; }
|
|
89
|
+
for (const v of [app.tabs[0]?.view, app.top()]) if (v) { v.cursor = 0; v.scroll = 0; }
|
|
91
90
|
}
|
|
92
91
|
}
|
|
93
92
|
|
|
@@ -131,7 +130,7 @@ export function ridingRows(app) {
|
|
|
131
130
|
const name = app.ds.creature(entry)?.name ?? `Riding Instructor (${entry})`;
|
|
132
131
|
return {
|
|
133
132
|
text: `${name} ${dim('<Riding Instructor>')}${app.ch.ridingSkill ? dim(` you ride at ${app.ch.ridingSkill}`) : ''}`,
|
|
134
|
-
detail: () => [bold(name), 'Riding instructor', `Your riding: ${app.ch.ridingSkill ?? 0}`, '', dim('Enter: learn to ride')],
|
|
133
|
+
detail: () => npcLines(app, entry, [bold(name), 'Riding instructor', `Your riding: ${app.ch.ridingSkill ?? 0}`, '', dim('Enter: learn to ride')]),
|
|
135
134
|
act: (a) => ridingDialog(a, entry),
|
|
136
135
|
};
|
|
137
136
|
});
|
|
Binary file
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
{
|
|
2
|
+
"tag": "art-1",
|
|
3
|
+
"base": "https://github.com/LienSimen/azsh/releases/download/art-1/",
|
|
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": 5183379,
|
|
15
|
+
"sha256": "e92bb1e7fe58c1d5a13628cdd12beccf7254c9929dee216b773103452abb6bab"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"name": "gear-art-hd-dwarf-female.bin.br",
|
|
19
|
+
"path": "gear-art-hd/dwarf-female.bin.br",
|
|
20
|
+
"size": 3065094,
|
|
21
|
+
"sha256": "1c04ccce7f4c1ad24e2e437ca55a2081ad6eb2c589c2da8f05df36127d9729ca"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"name": "gear-art-hd-dwarf-male.bin.br",
|
|
25
|
+
"path": "gear-art-hd/dwarf-male.bin.br",
|
|
26
|
+
"size": 4447262,
|
|
27
|
+
"sha256": "7e1ff7f3a2f968034f14d53013d4e46c399314c9d3b9a2a242714a4d56b5a179"
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
"name": "gear-art-hd-gnome-female.bin.br",
|
|
31
|
+
"path": "gear-art-hd/gnome-female.bin.br",
|
|
32
|
+
"size": 3285698,
|
|
33
|
+
"sha256": "ddac7efdc36cc003ebb34b4811bfe49c86a6bad6c35fb7bb312fc2ad31054f11"
|
|
34
|
+
},
|
|
35
|
+
{
|
|
36
|
+
"name": "gear-art-hd-gnome-male.bin.br",
|
|
37
|
+
"path": "gear-art-hd/gnome-male.bin.br",
|
|
38
|
+
"size": 3748025,
|
|
39
|
+
"sha256": "3ba3e446a654ab2e2a394f60ea7e7d38d66991d1252e318d4e5060bbdf5a4357"
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
"name": "gear-art-hd-human-female.bin.br",
|
|
43
|
+
"path": "gear-art-hd/human-female.bin.br",
|
|
44
|
+
"size": 2183524,
|
|
45
|
+
"sha256": "036193451db30ed95cca8120158d5dc494c2210420f53835ba9bad3bcdd2887f"
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"name": "gear-art-hd-human-male.bin.br",
|
|
49
|
+
"path": "gear-art-hd/human-male.bin.br",
|
|
50
|
+
"size": 2869717,
|
|
51
|
+
"sha256": "97fbd6604dec28c8bb68e6e301b1acc2f628a52d831a7c6f3cac3f7eab6d6883"
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
"name": "gear-art-hd-nightelf-female.bin.br",
|
|
55
|
+
"path": "gear-art-hd/nightelf-female.bin.br",
|
|
56
|
+
"size": 2000472,
|
|
57
|
+
"sha256": "321f9303ac0e48e71d09e1169a7f4f95e4c747012399da070b42d77e8a33920a"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"name": "gear-art-hd-nightelf-male.bin.br",
|
|
61
|
+
"path": "gear-art-hd/nightelf-male.bin.br",
|
|
62
|
+
"size": 2799028,
|
|
63
|
+
"sha256": "e9bbcedefb814729f746ae24bc80031627babf6c9bf89fe67ae6fc0783e3f420"
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "gear-art-hd-orc-female.bin.br",
|
|
67
|
+
"path": "gear-art-hd/orc-female.bin.br",
|
|
68
|
+
"size": 2639880,
|
|
69
|
+
"sha256": "65675608a4ef58ab0d19b79f712ce94c994ddadfa72371ec0503e83f1a6dc89f"
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
"name": "gear-art-hd-orc-male.bin.br",
|
|
73
|
+
"path": "gear-art-hd/orc-male.bin.br",
|
|
74
|
+
"size": 4227013,
|
|
75
|
+
"sha256": "09bf8130c09719ed0117b507f7baac3fa252bcfe189d2518cb4c4ad24042eff1"
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
"name": "gear-art-hd-tauren-female.bin.br",
|
|
79
|
+
"path": "gear-art-hd/tauren-female.bin.br",
|
|
80
|
+
"size": 2699924,
|
|
81
|
+
"sha256": "94cd59aad17351f3d44c338943f61143355f092e5a7a7961055fd46980a8c334"
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
"name": "gear-art-hd-tauren-male.bin.br",
|
|
85
|
+
"path": "gear-art-hd/tauren-male.bin.br",
|
|
86
|
+
"size": 3810060,
|
|
87
|
+
"sha256": "b0bd1bd0bdbc6d99846d9969d0136c7ca71fc70150527759cc3b424f1e5cbb33"
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
"name": "gear-art-hd-troll-female.bin.br",
|
|
91
|
+
"path": "gear-art-hd/troll-female.bin.br",
|
|
92
|
+
"size": 2053657,
|
|
93
|
+
"sha256": "af3a015cd97f3d37ee6401959a919254c768ea7305243251d1eaf9ffb2bcb8df"
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
"name": "gear-art-hd-troll-male.bin.br",
|
|
97
|
+
"path": "gear-art-hd/troll-male.bin.br",
|
|
98
|
+
"size": 2820001,
|
|
99
|
+
"sha256": "bef8de643988c386df9f2c125721692cfd8cda98670d6305f726c0e7f2413c3b"
|
|
100
|
+
},
|
|
101
|
+
{
|
|
102
|
+
"name": "gear-art-hd-undead-female.bin.br",
|
|
103
|
+
"path": "gear-art-hd/undead-female.bin.br",
|
|
104
|
+
"size": 2278891,
|
|
105
|
+
"sha256": "dc2dab19f7eda1af4e69a242509edc58c62a3ba0188ec28302dfa32a37d441e5"
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
"name": "gear-art-hd-undead-male.bin.br",
|
|
109
|
+
"path": "gear-art-hd/undead-male.bin.br",
|
|
110
|
+
"size": 2812557,
|
|
111
|
+
"sha256": "3a7d57acd0c824893cb346d7de782983a757b963791957311a6679b383c89545"
|
|
112
|
+
}
|
|
113
|
+
]
|
|
114
|
+
}
|
|
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
|
{
|
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
|
}
|
|
@@ -27,6 +27,19 @@ const enemyBase = (view: BgView): BgPos => {
|
|
|
27
27
|
return { x: g.x, y: g.y };
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
// Each side's flag runs keep to one edge of the field, past the brawl at the mid point: Alliance
|
|
31
|
+
// along the north side, Horde along the south, so the two runs never cross each other either.
|
|
32
|
+
// A run straight through mid put the carrier inside the mid fight's 30 yd shard every time.
|
|
33
|
+
const LANE_DY: Record<BgTeam, number> = { alliance: 90, horde: -90 };
|
|
34
|
+
const laneWaypoint = (team: BgTeam): BgPos => ({ x: WSG_GEOMETRY.mid.x, y: WSG_GEOMETRY.mid.y + LANE_DY[team] });
|
|
35
|
+
/** Toward `to` by the lane: the lane point while it is still ahead on the way, then `to` itself. */
|
|
36
|
+
const viaLane = (unit: BgUnitView, team: BgTeam, to: BgPos): BgPos => {
|
|
37
|
+
const wp = laneWaypoint(team);
|
|
38
|
+
const dir = Math.sign(to.x - unit.pos.x);
|
|
39
|
+
if (dir !== 0 && Math.sign(wp.x - unit.pos.x) === dir && Math.abs(wp.x - unit.pos.x) > 15) return wp;
|
|
40
|
+
return to;
|
|
41
|
+
};
|
|
42
|
+
|
|
30
43
|
const ourFlag = (view: BgView) => view.objectives.find((o) => o.id === flagObjectiveId(view.team));
|
|
31
44
|
const theirFlag = (view: BgView) =>
|
|
32
45
|
view.objectives.find((o) => o.id === flagObjectiveId(OTHER_TEAM[view.team]));
|
|
@@ -37,6 +50,21 @@ const ourCarrier = (view: BgView): BgUnitView | undefined =>
|
|
|
37
50
|
const theirCarrier = (view: BgView): BgUnitView | undefined =>
|
|
38
51
|
view.enemies.find((u) => u.carryingFlag && u.alive);
|
|
39
52
|
|
|
53
|
+
// Both flags carried: neither side can score until the other carrier dies and the flag returns. Left to
|
|
54
|
+
// escorting and holding, both teams sat on their own carrier at home and a 25-minute match had two
|
|
55
|
+
// pickups. The field goes to kill their carrier; the defenders stay with ours.
|
|
56
|
+
const doubleCarry = (view: BgView): boolean => !!ourCarrier(view) && !!theirCarrier(view);
|
|
57
|
+
|
|
58
|
+
const nearestOf = <T extends { pos: BgPos }>(from: BgPos, list: readonly T[]): T | undefined => {
|
|
59
|
+
let best: T | undefined;
|
|
60
|
+
let bd = Infinity;
|
|
61
|
+
for (const o of list) {
|
|
62
|
+
const d = dist(from, o.pos);
|
|
63
|
+
if (d < bd) { bd = d; best = o; }
|
|
64
|
+
}
|
|
65
|
+
return best;
|
|
66
|
+
};
|
|
67
|
+
|
|
40
68
|
const nearestVisibleEnemy = (unit: BgUnitView, view: BgView): BgUnitView | undefined => {
|
|
41
69
|
let best: BgUnitView | undefined;
|
|
42
70
|
let bd = Infinity;
|
|
@@ -55,7 +83,7 @@ const runFlagHome: BgAction = {
|
|
|
55
83
|
name: 'runFlagHome',
|
|
56
84
|
relevance: () => 100,
|
|
57
85
|
available: (unit) => !!unit.carryingFlag,
|
|
58
|
-
order: (
|
|
86
|
+
order: (unit, view) => ({ kind: 'move', to: viaLane(unit, view.team, homeBase(view)) }),
|
|
59
87
|
};
|
|
60
88
|
|
|
61
89
|
// Our own flag is on the ground: touching it returns it. Urgent — a dropped flag returns itself in 10 s,
|
|
@@ -70,18 +98,38 @@ const returnOurFlag: BgAction = {
|
|
|
70
98
|
},
|
|
71
99
|
};
|
|
72
100
|
|
|
101
|
+
/** How far the flag runner will step aside to help kill the enemy carrier; beyond it they keep running. */
|
|
102
|
+
const RUNNER_PEEL_YD = 60;
|
|
103
|
+
/** An enemy carrier this close pulls a body out of the fight it is in. */
|
|
104
|
+
const CARRIER_PEEL_YD = 60;
|
|
105
|
+
/** A carrier of ours this close does too. */
|
|
106
|
+
const CARRIER_HELP_YD = 100;
|
|
107
|
+
|
|
73
108
|
// Chase whoever has our flag. Its FLOOR must stay above holdOurBase (70): at 50, defenders
|
|
74
109
|
// preferred an already-safe base over retrieving the flag, and both teams sat at 0-0 forever.
|
|
110
|
+
// The flag runner is the exception: a cross-carry is how a stalemate breaks, so they only peel onto
|
|
111
|
+
// a carrier who runs past them.
|
|
75
112
|
const chaseEnemyCarrier: BgAction = {
|
|
76
113
|
name: 'chaseEnemyCarrier',
|
|
77
114
|
relevance: (unit, view) => {
|
|
78
115
|
const c = theirCarrier(view);
|
|
79
116
|
if (!c) return 0;
|
|
80
|
-
|
|
81
|
-
//
|
|
82
|
-
|
|
117
|
+
if (unit.role === 'flag-carrier') return 90;
|
|
118
|
+
// A carrier passing close outranks everything but the flag actions; a far one still outranks
|
|
119
|
+
// holding a base (70), but not the fight this body is in (82): with every mid fighter peeling
|
|
120
|
+
// onto a carrier 200 yd away, the whole brawl migrated and the runs always died in it.
|
|
121
|
+
return dist(unit.pos, c.pos) < CARRIER_PEEL_YD ? 95 : 75;
|
|
122
|
+
},
|
|
123
|
+
available: (unit, view) => {
|
|
124
|
+
if (unit.carryingFlag) return false;
|
|
125
|
+
const c = theirCarrier(view);
|
|
126
|
+
if (!c) return false;
|
|
127
|
+
const d = dist(unit.pos, c.pos);
|
|
128
|
+
if (unit.role === 'flag-carrier') return d < RUNNER_PEEL_YD;
|
|
129
|
+
// In a double carry a defender guards our carrier instead of crossing the map.
|
|
130
|
+
if (unit.role === 'defense' && doubleCarry(view)) return d < CARRIER_PEEL_YD;
|
|
131
|
+
return true;
|
|
83
132
|
},
|
|
84
|
-
available: (unit, view) => !unit.carryingFlag && !!theirCarrier(view),
|
|
85
133
|
// `follow`, NOT attack-move to a position: the strategy layer runs at 1 Hz, so a position order
|
|
86
134
|
// sends an equal-speed chaser to where the carrier was a second ago and never closes.
|
|
87
135
|
order: (_unit, view) => {
|
|
@@ -92,13 +140,18 @@ const chaseEnemyCarrier: BgAction = {
|
|
|
92
140
|
|
|
93
141
|
// Stay on our carrier and kill what touches them. Open to every non-defense role: with escorts
|
|
94
142
|
// alone a carrier ran 620 yd through the enemy team by itself and died within ~50 yd every time.
|
|
143
|
+
// A carrier within reach outranks the fight a body is in (82), as a passing enemy carrier does;
|
|
144
|
+
// one far away does not, so the mid fight is not abandoned for a run on the other side of the map.
|
|
95
145
|
const escortCarrier: BgAction = {
|
|
96
146
|
name: 'escortCarrier',
|
|
97
|
-
relevance: () =>
|
|
147
|
+
relevance: (unit, view) => {
|
|
148
|
+
const c = ourCarrier(view);
|
|
149
|
+
return c && dist(unit.pos, c.pos) < CARRIER_HELP_YD ? 84 : 80;
|
|
150
|
+
},
|
|
98
151
|
available: (unit, view) => {
|
|
99
152
|
if (unit.carryingFlag) return false;
|
|
100
153
|
if (unit.role === 'defense') return false;
|
|
101
|
-
return !!ourCarrier(view);
|
|
154
|
+
return !!ourCarrier(view) && !theirCarrier(view);
|
|
102
155
|
},
|
|
103
156
|
order: (_unit, view) => {
|
|
104
157
|
const c = ourCarrier(view);
|
|
@@ -106,18 +159,73 @@ const escortCarrier: BgAction = {
|
|
|
106
159
|
},
|
|
107
160
|
};
|
|
108
161
|
|
|
109
|
-
|
|
162
|
+
// Our flag runner, alive and not yet carrying: the body the escorts walk in with.
|
|
163
|
+
const ourRunner = (view: BgView): BgUnitView | undefined =>
|
|
164
|
+
view.own.find((u) => u.role === 'flag-carrier' && u.alive && !u.carryingFlag);
|
|
165
|
+
|
|
166
|
+
// Walk in with the runner. Escorts used to wait at mid for a carrier to exist, so the runner went
|
|
167
|
+
// into the enemy flag room alone, picked up, and died to the two defenders before anyone arrived.
|
|
168
|
+
const escortRunner: BgAction = {
|
|
169
|
+
name: 'escortRunner',
|
|
170
|
+
relevance: () => 58,
|
|
171
|
+
available: (unit, view) => !unit.carryingFlag && !ourCarrier(view) && !!ourRunner(view) && ourRunner(view)!.id !== unit.id,
|
|
172
|
+
order: (_unit, view) => {
|
|
173
|
+
const r = ourRunner(view);
|
|
174
|
+
return r ? { kind: 'follow', target: r.id } : null;
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
/** An escort this close is with the runner; farther, the runner goes back for them. */
|
|
179
|
+
const ESCORT_WITH_YD = 25;
|
|
180
|
+
/** An escort farther than this from the runner is somebody the runner should wait for. */
|
|
181
|
+
const ESCORT_FAR_YD = 40;
|
|
182
|
+
|
|
183
|
+
/** Our living escorts who are not carrying anything. */
|
|
184
|
+
const ourEscorts = (view: BgView): BgUnitView[] =>
|
|
185
|
+
view.own.filter((u) => u.role === 'fc-escort' && u.alive && !u.carryingFlag);
|
|
186
|
+
|
|
187
|
+
// The runner does not walk into the enemy flag room alone. With escorts alive but elsewhere (held in
|
|
188
|
+
// a fight at mid, or just back from the graveyard) the runner goes to the nearest one; once an escort
|
|
189
|
+
// is beside them, grabEnemyFlag takes over and the group walks in together. Alone, a level-22 runner
|
|
190
|
+
// picked up and died to the two defenders in nine seconds, sixteen times in a row: 0-0 after 20 min.
|
|
191
|
+
const waitForEscort: BgAction = {
|
|
192
|
+
name: 'waitForEscort',
|
|
193
|
+
relevance: () => 86,
|
|
194
|
+
available: (unit, view) => {
|
|
195
|
+
if (unit.role !== 'flag-carrier' || unit.carryingFlag) return false;
|
|
196
|
+
const escorts = ourEscorts(view).filter((e) => e.id !== unit.id);
|
|
197
|
+
if (!escorts.length) return false;
|
|
198
|
+
if (escorts.some((e) => dist(e.pos, unit.pos) < ESCORT_WITH_YD)) return false;
|
|
199
|
+
return escorts.some((e) => dist(e.pos, unit.pos) > ESCORT_FAR_YD);
|
|
200
|
+
},
|
|
201
|
+
order: (unit, view) => {
|
|
202
|
+
const e = nearestOf(unit.pos, ourEscorts(view).filter((o) => o.id !== unit.id));
|
|
203
|
+
return e ? { kind: 'follow', target: e.id } : null;
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/** A dropped flag this close is anyone's to pick up before it returns itself in 10 s. */
|
|
208
|
+
const GROUND_FLAG_REACH_YD = 60;
|
|
209
|
+
|
|
210
|
+
// Go take their flag. The runner walks across the map for it; a fighter in reach of a dropped one
|
|
211
|
+
// picks it up (a pass, in WSG terms); everyone else only takes it from the ground when it is near.
|
|
110
212
|
const grabEnemyFlag: BgAction = {
|
|
111
213
|
name: 'grabEnemyFlag',
|
|
112
|
-
relevance: (unit) =>
|
|
214
|
+
relevance: (unit, view) => {
|
|
215
|
+
const f = theirFlag(view);
|
|
216
|
+
if (f?.state === 'ground' && unit.role !== 'defense' && dist(unit.pos, f.pos) < GROUND_FLAG_REACH_YD) return 90;
|
|
217
|
+
return unit.role === 'flag-carrier' ? 85 : unit.role === 'offense' ? 42 : 20;
|
|
218
|
+
},
|
|
113
219
|
available: (unit, view) => {
|
|
114
220
|
if (unit.carryingFlag) return false;
|
|
115
221
|
const f = theirFlag(view);
|
|
116
222
|
return f?.state === 'base' || f?.state === 'ground';
|
|
117
223
|
},
|
|
118
|
-
order: (
|
|
224
|
+
order: (unit, view) => {
|
|
119
225
|
const f = theirFlag(view);
|
|
120
|
-
|
|
226
|
+
const to = f ? f.pos : enemyBase(view);
|
|
227
|
+
// The runner walks the lane in; a dropped flag is wherever it fell, so it is walked to straight.
|
|
228
|
+
return { kind: 'attack-move', to: unit.role === 'flag-carrier' && f?.state !== 'ground' ? viaLane(unit, view.team, to) : to };
|
|
121
229
|
},
|
|
122
230
|
};
|
|
123
231
|
|
|
@@ -138,21 +246,38 @@ const holdOurBase: BgAction = {
|
|
|
138
246
|
},
|
|
139
247
|
};
|
|
140
248
|
|
|
141
|
-
/**
|
|
249
|
+
/** Standing this close to the mid point is holding it. */
|
|
250
|
+
const MID_HELD_YD = 40;
|
|
251
|
+
|
|
252
|
+
// Contest the middle — where the two flag runs cross. Walking there outranks an enemy in reach;
|
|
253
|
+
// once there, an enemy in reach outranks standing on the point, or two groups stood 50 yd apart
|
|
254
|
+
// at mid for a whole match and watched each other's runners walk past.
|
|
142
255
|
const contestMid: BgAction = {
|
|
143
256
|
name: 'contestMid',
|
|
144
|
-
relevance: (unit) =>
|
|
257
|
+
relevance: (unit) => {
|
|
258
|
+
if (unit.role !== 'mid') return 25;
|
|
259
|
+
return dist(unit.pos, WSG_GEOMETRY.mid) > MID_HELD_YD ? 55 : 30;
|
|
260
|
+
},
|
|
145
261
|
available: (unit) => !unit.carryingFlag,
|
|
146
262
|
order: () => ({ kind: 'attack-move', to: { ...WSG_GEOMETRY.mid } }),
|
|
147
263
|
};
|
|
148
264
|
|
|
149
|
-
/**
|
|
265
|
+
/** Touching distance: a fight you are in. */
|
|
266
|
+
const ENGAGE_CONTACT_YD = 25;
|
|
267
|
+
/** An enemy this close is worth walking over to. */
|
|
268
|
+
const ENGAGE_REACH_YD = 60;
|
|
269
|
+
|
|
270
|
+
// Fight what is in front of you. In contact it outranks escorting (80) and the offense grab (42), so
|
|
271
|
+
// a body finishes the fight it is in instead of walking off mid-swing with the enemy on its back; it
|
|
272
|
+
// stays under the runner's grab (85), the carrier chase and the flag actions. In reach it outranks
|
|
273
|
+
// holding mid; far away it is the floor.
|
|
150
274
|
const engageNearest: BgAction = {
|
|
151
275
|
name: 'engageNearest',
|
|
152
276
|
relevance: (unit, view) => {
|
|
153
277
|
const foe = nearestVisibleEnemy(unit, view);
|
|
154
278
|
if (!foe) return 0;
|
|
155
|
-
|
|
279
|
+
const d = dist(unit.pos, foe.pos);
|
|
280
|
+
return d < ENGAGE_CONTACT_YD ? 82 : d < ENGAGE_REACH_YD ? 45 : 10;
|
|
156
281
|
},
|
|
157
282
|
available: (unit, view) => !unit.carryingFlag && !!nearestVisibleEnemy(unit, view),
|
|
158
283
|
// Same reason as chaseEnemyCarrier: track the unit, not a one-second-old point.
|
|
@@ -174,7 +299,7 @@ const carrierDisengage: BgAction = {
|
|
|
174
299
|
);
|
|
175
300
|
return !helpNear;
|
|
176
301
|
},
|
|
177
|
-
order: (
|
|
302
|
+
order: (unit, view) => ({ kind: 'retreat', to: viaLane(unit, view.team, homeBase(view)) }),
|
|
178
303
|
};
|
|
179
304
|
|
|
180
305
|
// ---- Arathi Basin: nodes, not flags ----------------------------------------
|
|
@@ -192,16 +317,6 @@ const takeableNodes = (view: BgView) =>
|
|
|
192
317
|
const nodesUnderAssault = (view: BgView) =>
|
|
193
318
|
nodes(view).filter((o) => o.state === 'assaulted' && o.prevOwner === view.team);
|
|
194
319
|
|
|
195
|
-
const nearestOf = <T extends { pos: BgPos }>(from: BgPos, list: readonly T[]): T | undefined => {
|
|
196
|
-
let best: T | undefined;
|
|
197
|
-
let bd = Infinity;
|
|
198
|
-
for (const o of list) {
|
|
199
|
-
const d = dist(from, o.pos);
|
|
200
|
-
if (d < bd) { bd = d; best = o; }
|
|
201
|
-
}
|
|
202
|
-
return best;
|
|
203
|
-
};
|
|
204
|
-
|
|
205
320
|
const abHome = (view: BgView): BgPos => {
|
|
206
321
|
const s = AB_GEOMETRY.start[view.team];
|
|
207
322
|
return { x: s.x, y: s.y };
|
|
@@ -475,7 +590,7 @@ export const AV_MID: BgPos = { ...AV_GEOMETRY.mid };
|
|
|
475
590
|
export const BG_STRATEGIES = {
|
|
476
591
|
'wsg.offense': {
|
|
477
592
|
name: '~wsg.offense',
|
|
478
|
-
actions: [runFlagHome, carrierDisengage, escortCarrier, chaseEnemyCarrier, grabEnemyFlag, engageNearest, contestMid],
|
|
593
|
+
actions: [runFlagHome, carrierDisengage, escortCarrier, chaseEnemyCarrier, waitForEscort, grabEnemyFlag, engageNearest, contestMid],
|
|
479
594
|
},
|
|
480
595
|
'wsg.defense': {
|
|
481
596
|
name: '~wsg.defense',
|
|
@@ -483,11 +598,11 @@ export const BG_STRATEGIES = {
|
|
|
483
598
|
},
|
|
484
599
|
'wsg.fc-escort': {
|
|
485
600
|
name: '~wsg.fc-escort',
|
|
486
|
-
actions: [runFlagHome, carrierDisengage, escortCarrier, chaseEnemyCarrier, engageNearest, contestMid],
|
|
601
|
+
actions: [runFlagHome, carrierDisengage, escortCarrier, chaseEnemyCarrier, grabEnemyFlag, escortRunner, engageNearest, contestMid],
|
|
487
602
|
},
|
|
488
603
|
'wsg.mid': {
|
|
489
604
|
name: '~wsg.mid',
|
|
490
|
-
actions: [returnOurFlag, chaseEnemyCarrier, escortCarrier, contestMid, engageNearest],
|
|
605
|
+
actions: [runFlagHome, carrierDisengage, returnOurFlag, chaseEnemyCarrier, escortCarrier, grabEnemyFlag, contestMid, engageNearest],
|
|
491
606
|
},
|
|
492
607
|
// Arathi Basin. No flag actions at all: the WSG bundles walk to WSG_GEOMETRY, which is map 489,
|
|
493
608
|
// so an AB unit on one of them marched off the plate and never touched a banner.
|
package/src/domain/bg/types.ts
CHANGED
|
@@ -75,6 +75,8 @@ export interface BgUnit {
|
|
|
75
75
|
order: BgOrder;
|
|
76
76
|
/** Combat spec — only needed when a shard is folded into a real createSession. */
|
|
77
77
|
spec?: CombatantSpec;
|
|
78
|
+
/** Does this seat heal? The attrition resolver reads it; absent, it falls back to the class. */
|
|
79
|
+
heals?: boolean;
|
|
78
80
|
/** Movement speed, yards/second. */
|
|
79
81
|
speedYd: number;
|
|
80
82
|
/** Set while this unit carries an enemy flag (the aura id, 23333 / 23335). */
|