azeroth-shell 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +326 -82
  2. package/bin/azsh.mjs +21 -19
  3. package/package.json +19 -1
  4. package/src/autopilot/plan.ts +8 -11
  5. package/src/autopilot/view.ts +12 -11
  6. package/src/cli/combatLogFormat.ts +22 -0
  7. package/src/cli/fileRepository.mjs +46 -1
  8. package/src/cli/gzLoader.mjs +47 -6
  9. package/src/cli/hdArt.mjs +81 -0
  10. package/src/cli/iconArt.mjs +135 -0
  11. package/src/cli/iconGrid.mjs +100 -0
  12. package/src/cli/modelArt.mjs +295 -0
  13. package/src/cli/paperdoll.mjs +76 -0
  14. package/src/cli/perfCheck.mjs +56 -0
  15. package/src/cli/repl.mjs +36 -7
  16. package/src/cli/rotationText.mjs +6 -6
  17. package/src/cli/sixel.mjs +307 -0
  18. package/src/cli/term.mjs +106 -12
  19. package/src/cli/text.mjs +5 -3
  20. package/src/cli/tui.mjs +252 -98
  21. package/src/cli/tuiAutopilot.mjs +347 -0
  22. package/src/cli/tuiBg.mjs +371 -42
  23. package/src/cli/tuiCheck.mjs +415 -28
  24. package/src/cli/tuiCity.mjs +118 -13
  25. package/src/cli/tuiCompanions.mjs +25 -15
  26. package/src/cli/tuiCreate.mjs +223 -0
  27. package/src/cli/tuiDungeon.mjs +1 -1
  28. package/src/cli/tuiFight.mjs +425 -88
  29. package/src/cli/tuiFightPanes.mjs +27 -8
  30. package/src/cli/tuiFloor.mjs +2 -2
  31. package/src/cli/tuiGear.mjs +10 -6
  32. package/src/cli/tuiLoot.mjs +1 -0
  33. package/src/cli/tuiPaperdoll.mjs +107 -0
  34. package/src/cli/tuiPartyStage.mjs +177 -0
  35. package/src/cli/tuiProfessions.mjs +4 -2
  36. package/src/cli/tuiRotationCond.mjs +390 -0
  37. package/src/cli/tuiSaves.mjs +22 -3
  38. package/src/cli/tuiSearch.mjs +75 -22
  39. package/src/cli/tuiSettings.mjs +15 -0
  40. package/src/cli/tuiSimLab.mjs +1 -1
  41. package/src/cli/tuiStage.mjs +304 -0
  42. package/src/cli/tuiTalentTree.mjs +124 -0
  43. package/src/cli/tuiViews.mjs +296 -115
  44. package/src/cli/tuiWorld.mjs +4 -5
  45. package/src/data/gear-art.bin.br +0 -0
  46. package/src/data/hd-art.json +114 -0
  47. package/src/data/icon-art.bin.gz +0 -0
  48. package/src/data/model-art.bin.gz +0 -0
  49. package/src/data/rotations/pve/paladin/retribution.json +2 -2
  50. package/src/domain/bg/match.ts +85 -24
  51. package/src/domain/bg/strategies.ts +144 -29
  52. package/src/domain/bg/types.ts +2 -0
  53. package/src/domain/bg/wsg.ts +35 -20
  54. package/src/domain/quests/doQuest.ts +24 -6
  55. package/src/domain/rotation/build.ts +3031 -3031
  56. package/src/domain/rotation/classes/paladin.ts +16 -11
  57. package/src/domain/rotation/evaluate.ts +3 -0
  58. package/src/engine/combat/applySpell.ts +20 -9
  59. package/src/engine/combat/applySpellLate.ts +16 -12
  60. package/src/engine/combat/areaAura.ts +284 -283
  61. package/src/engine/combat/fighterFactory.ts +1 -1
  62. package/src/engine/combat/pickAction.ts +39 -1
  63. package/src/engine/combat/procsDamage.ts +31 -1
  64. package/src/engine/combat/sessionChase.ts +7 -1
  65. package/src/engine/combat/sessionMotionKit.ts +40 -2
  66. package/src/quests/quests.ts +60 -0
@@ -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 tab opens on this card. Instant pace never gets here.
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 world = app.tabs[0]?.view;
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
  {
@@ -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
  }
@@ -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: (_unit, view) => ({ kind: 'move', to: homeBase(view) }),
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
- // Closer chasers care more, so the whole team does not peel onto one runner — but even
81
- // the furthest chaser outranks holding a base.
82
- return 95 - Math.min(20, dist(unit.pos, c.pos) / 40);
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: () => 80,
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
- /** Go take their flag. */
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) => (unit.role === 'flag-carrier' ? 85 : unit.role === 'offense' ? 60 : 20),
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: (_unit, view) => {
224
+ order: (unit, view) => {
119
225
  const f = theirFlag(view);
120
- return f ? { kind: 'attack-move', to: f.pos } : { kind: 'attack-move', to: enemyBase(view) };
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
- /** Contest the middle — where the two flag runs cross. */
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) => (unit.role === 'mid' ? 55 : 25),
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
- /** Fight what is in front of you. The floor action — always available, always lowest. */
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
- return dist(unit.pos, foe.pos) < 25 ? 50 : 10;
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: (_unit, view) => ({ kind: 'retreat', to: homeBase(view) }),
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.
@@ -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). */