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
@@ -1,283 +1,284 @@
1
- // Continuous AREA AURAS (class A) — membership is live, not a duration clock. The holder re-collects
2
- // the in-radius, same-subgroup party each slice; a unit out of radius or ungrouped has it REMOVED.
3
-
4
- import type { Side } from '../core/events.ts';
5
- import {
6
- applySpellMod,
7
- radiusSpellModsFromTalentEffects,
8
- SPELLMOD_RADIUS_LABEL,
9
- SPELLMOD_RADIUS_OP,
10
- type SpellMod,
11
- } from '../spells/spellMods.ts';
12
- import { isWithinRadiusYd, type CombatPosYd } from './combatGeometry.ts';
13
- import { fighterIsInCombat } from './combatFlag.ts';
14
- import {
15
- SLOW_ENGAGED_RATE,
16
- SLOW_IDLE_RATE,
17
- slowSliceDue,
18
- type SlowDiag,
19
- } from './slowUpdate.ts';
20
- import type { ActiveEffect, Fighter } from './types.ts';
21
-
22
- // The radius-mod reader is pure and lives in spellMods.ts so buildCombatant can call it without the
23
- // combat session graph. Re-exported here because this is the module that documents what it is FOR.
24
- export { radiusSpellModsFromTalentEffects, SPELLMOD_RADIUS_LABEL, SPELLMOD_RADIUS_OP };
25
-
26
- // The effective radius: the base scaled by the owner's SPELLMOD_RADIUS. An absent base is returned
27
- // unchanged — a missing radius on a real class-A aura is a bake gap, never an invented default.
28
- export function effectiveAreaRadiusYd(
29
- baseRadiusYd: number | undefined | null,
30
- mods: readonly SpellMod[] | undefined | null,
31
- ): number | undefined {
32
- if (baseRadiusYd == null || !(baseRadiusYd > 0)) return undefined;
33
- if (!mods || mods.length === 0) return baseRadiusYd;
34
- const { value } = applySpellMod(baseRadiusYd, mods, { op: SPELLMOD_RADIUS_LABEL });
35
- return value > 0 ? value : baseRadiusYd;
36
- }
37
-
38
- // One live class-A area-aura holder registered on the session. The closures are the apply-missing and
39
- // remove arms, so the pass owns only the geometry and membership decision.
40
- export interface AreaAuraSource {
41
- // Unit whose position is the aura origin. Totem side for totem kits (Classic: at the caster's feet, not
42
- // the caster), pet side for Blood Pact, caster side for Sanctity / Devotion.
43
- holderSide: Side;
44
- /** Aura spell id (kit / party spell) — identifies the rows this source owns. */
45
- spellId: number;
46
- name: string;
47
- // `GetSpellRadius(EffectRadiusIndex)` yards. `undefined` = the bake has no radius for a real area aura —
48
- // a gap, surfaced by `missingRadius`, and failed open.
49
- baseRadiusYd?: number;
50
- /** Unit whose SPELLMOD_RADIUS mods scale the radius (totem OWNER, or the caster). */
51
- modOwnerSide: Side;
52
- /** Re-apply to a member that is (still / again) in radius. Must be idempotent. */
53
- apply: (target: Fighter) => void;
54
- /** Remove this holder's rows from a member that left radius / party / died. */
55
- strip: (target: Fighter) => void;
56
- /** Current membership candidates (living party / pet rules). */
57
- members: () => Fighter[];
58
- // Sides that currently hold this holder's rows, member or not. members() alone cannot express the
59
- // remove arm: the C++ runs it from the TARGET, so a unit that LEFT still re-checks and removes.
60
- carriers: () => Side[];
61
- /** False once the holder is gone (totem unsummoned / caster dead / caster lost its own row). */
62
- alive: () => boolean;
63
- /** Cast-tick radius filter — not C2-gated. Set by registerAreaAuraSource. */
64
- radiusPending?: boolean;
65
- // spell_group of an exclusive family (paladin auras are group 8): one holder keeps ONE source per
66
- // group, so switching Devotion to Sanctity strips Devotion off every carrier.
67
- exclusionGroup?: number;
68
- }
69
-
70
- // A holder swapping auras inside one exclusive group loses the old row, so every carrier loses it too.
71
- // Cite: Unit.cpp:4907 RemoveNoStackAurasDueToAuraHolder; SpellStacking.cpp:358; SpellAuras.cpp:597.
72
- export function stripExclusiveSiblingSources(
73
- sources: AreaAuraSource[],
74
- incoming: Pick<AreaAuraSource, 'holderSide' | 'spellId' | 'exclusionGroup'>,
75
- bySide: (side: Side | string) => Fighter | undefined,
76
- ): number {
77
- const group = incoming.exclusionGroup;
78
- if (group == null || group === 0) return 0;
79
- let stripped = 0;
80
- for (let i = sources.length - 1; i >= 0; i--) {
81
- const s = sources[i]!;
82
- if (s.holderSide !== incoming.holderSide) continue;
83
- if (s.spellId === incoming.spellId) continue;
84
- if (s.exclusionGroup !== group) continue;
85
- for (const side of s.carriers()) {
86
- const unit = bySide(side);
87
- if (unit) s.strip(unit);
88
- }
89
- sources.splice(i, 1);
90
- stripped++;
91
- }
92
- return stripped;
93
- }
94
-
95
- // Rows of OTHER spells in the same exclusive group that this holder left on any unit, source-registered
96
- // or not (a Retribution Aura damage shield has no source). Reversed and removed, count returned.
97
- export function stripHolderRowsInGroup(
98
- effects: ActiveEffect[],
99
- holderSide: Side,
100
- keepSpellId: number,
101
- group: number,
102
- groupOf: (spellId: number) => number | undefined,
103
- bySide: (side: Side | string) => Fighter | undefined,
104
- reverse: (unit: Fighter, e: ActiveEffect) => void,
105
- fromHolder?: readonly ActiveEffect[],
106
- ): number {
107
- let removed = 0;
108
- const rows = fromHolder ?? effects;
109
- for (let i = rows.length - 1; i >= 0; i--) {
110
- const e = rows[i]!;
111
- if (e.source !== holderSide) continue;
112
- if (e.spellId === keepSpellId) continue;
113
- if ((e.exclusionGroup ?? groupOf(e.spellId)) !== group) continue;
114
- if (isSheetBakedDisplayRow(e)) continue;
115
- const unit = bySide(e.target);
116
- if (!unit) continue;
117
- if (!e.broken) reverse(unit, e);
118
- else unit.buffActive.delete(e.spellId);
119
- const idx = fromHolder ? effects.lastIndexOf(e) : i;
120
- if (idx >= 0) effects.splice(idx, 1);
121
- removed++;
122
- }
123
- return removed;
124
- }
125
-
126
- // A sheet-baked display row: the stats already live in the spec, so the effect carries no mod to
127
- // reverse. Stripping one would let a re-entry stack combat stats on top of the sheet.
128
- function isSheetBakedDisplayRow(e: ActiveEffect): boolean {
129
- return e.kind === 'buff' && e.mod == null;
130
- }
131
-
132
- // Remove one holder's rows for `spellId` from `target` and reverse their live side-effects. Scoped by
133
- // source, so twin holders never strip each other. Cite: AreaAura::Update remove arm ~592–624.
134
- export function stripAreaAuraRowsFrom(
135
- effects: ActiveEffect[],
136
- target: Fighter,
137
- spellId: number,
138
- holderSide: Side,
139
- reverse: (unit: Fighter, e: ActiveEffect) => void,
140
- onTarget?: readonly ActiveEffect[],
141
- ): number {
142
- let removed = 0;
143
- const rows = onTarget ?? effects;
144
- for (let i = rows.length - 1; i >= 0; i--) {
145
- const e = rows[i]!;
146
- if (e.target !== target.side) continue;
147
- if (e.spellId !== spellId) continue;
148
- if (e.source !== holderSide) continue;
149
- if (isSheetBakedDisplayRow(e)) continue;
150
- if (!e.broken) reverse(target, e);
151
- else target.buffActive.delete(e.spellId);
152
- const idx = onTarget ? effects.lastIndexOf(e) : i;
153
- if (idx >= 0) effects.splice(idx, 1);
154
- removed++;
155
- }
156
- return removed;
157
- }
158
-
159
- /** True when this holder still has at least one live row for `spellId` on `target`. */
160
- export function hasAreaAuraRow(
161
- effects: readonly ActiveEffect[],
162
- target: Fighter,
163
- spellId: number,
164
- holderSide: Side,
165
- ): boolean {
166
- return effects.some(
167
- (e) => e.target === target.side && e.spellId === spellId && e.source === holderSide,
168
- );
169
- }
170
-
171
- // Distinct sides carrying this holder's rows. Sheet-baked display rows are excluded, since stripping
172
- // deliberately leaves them alone and listing such a side would re-strip nothing every slice.
173
- export function areaAuraCarrierSides(
174
- effects: readonly ActiveEffect[],
175
- spellId: number,
176
- holderSide: Side,
177
- ): Side[] {
178
- const out: Side[] = [];
179
- for (const e of effects) {
180
- if (e.spellId !== spellId) continue;
181
- if (e.source !== holderSide) continue;
182
- if (isSheetBakedDisplayRow(e)) continue;
183
- if (out.includes(e.target)) continue;
184
- out.push(e.target);
185
- }
186
- return out;
187
- }
188
-
189
- export interface AreaAuraMaintainDeps {
190
- sources: AreaAuraSource[];
191
- posOf: (side: Side | string) => CombatPosYd | undefined;
192
- bySide: (side: Side | string) => Fighter | undefined;
193
- dead: (f: Fighter) => boolean;
194
- /** Geometry present? Absent → legacy whole-side behaviour, no strips. */
195
- hasGeometry: () => boolean;
196
- t: number;
197
- tickMs: number;
198
- diag?: SlowDiag;
199
- }
200
-
201
- /** Holder C2 slice — Recoil leftover; death/unsummon is not on this clock. */
202
- export function areaAuraRadiusDue(
203
- holderSide: string,
204
- t: number,
205
- tickMs: number,
206
- engaged: boolean,
207
- pending: boolean,
208
- ): boolean {
209
- if (pending) return true;
210
- return slowSliceDue(
211
- holderSide,
212
- t,
213
- tickMs,
214
- engaged ? SLOW_ENGAGED_RATE : SLOW_IDLE_RATE,
215
- );
216
- }
217
-
218
- // The remove arm for carriers that are no longer members. The C++ runs it from the TARGET, so a
219
- // holder-side members() loop never revisits a unit that left — Devotion Aura sat on a departed rogue.
220
- function stripDepartedCarriers(s: AreaAuraSource, deps: AreaAuraMaintainDeps): void {
221
- const carriers = s.carriers();
222
- if (carriers.length === 0) return;
223
- const memberSides = new Set<Side>();
224
- for (const m of s.members()) memberSides.add(m.side);
225
- for (const side of carriers) {
226
- if (memberSides.has(side)) continue;
227
- const unit = deps.bySide(side);
228
- if (unit) s.strip(unit);
229
- }
230
- }
231
-
232
- // AreaAura::Update for every registered holder, once per tick after motion. Radius and membership ride
233
- // the slow slice; holder death, unsummon and member death still strip immediately.
234
- export function runAreaAuraMaintain(deps: AreaAuraMaintainDeps): void {
235
- const { sources } = deps;
236
- if (sources.length === 0) return;
237
- const geometry = deps.hasGeometry();
238
-
239
- for (let i = sources.length - 1; i >= 0; i--) {
240
- const s = sources[i]!;
241
- if (!s.alive()) {
242
- // Holder gone strips EVERY carrier, not just the current members — a unit that left
243
- // the party before the totem was unsummoned still holds rows.
244
- stripDepartedCarriers(s, deps);
245
- for (const m of s.members()) s.strip(m);
246
- sources.splice(i, 1);
247
- continue;
248
- }
249
- const pending = s.radiusPending === true;
250
- if (pending) s.radiusPending = false;
251
- const probe = deps.bySide(s.modOwnerSide) ?? deps.bySide(s.holderSide);
252
- const engaged = probe ? fighterIsInCombat(probe, deps.t) : false;
253
- const due = areaAuraRadiusDue(s.holderSide, deps.t, deps.tickMs, engaged, pending);
254
- if (!due) {
255
- for (const m of s.members()) {
256
- if (deps.dead(m)) s.strip(m);
257
- }
258
- if (deps.diag) deps.diag.areaAuraSkip++;
259
- continue;
260
- }
261
- if (deps.diag) deps.diag.areaAuraRadius++;
262
- // Membership and radius share the C2 slice: subgroup/leave is membership, not geometry.
263
- stripDepartedCarriers(s, deps);
264
- const origin = geometry ? deps.posOf(s.holderSide) : undefined;
265
- const modOwner = deps.bySide(s.modOwnerSide);
266
- const radius = effectiveAreaRadiusYd(s.baseRadiusYd, modOwner?.radiusSpellMods);
267
- for (const m of s.members()) {
268
- if (deps.dead(m)) {
269
- s.strip(m);
270
- continue;
271
- }
272
- if (!geometry) {
273
- // No coords — keep the historic whole-side reach (never invent a radius).
274
- s.apply(m);
275
- continue;
276
- }
277
- // isWithinRadiusYd fails OPEN on a missing radius or a missing position:
278
- // a bake gap must not silently delete a real party aura (§4.7).
279
- if (isWithinRadiusYd(origin, deps.posOf(m.side), radius)) s.apply(m);
280
- else s.strip(m);
281
- }
282
- }
283
- }
1
+ // Continuous AREA AURAS (class A) — membership is live, not a duration clock. The holder re-collects
2
+ // the in-radius, same-subgroup party each slice; a unit out of radius or ungrouped has it REMOVED.
3
+
4
+ import type { Side } from '../core/events.ts';
5
+ import {
6
+ applySpellMod,
7
+ radiusSpellModsFromTalentEffects,
8
+ SPELLMOD_RADIUS_LABEL,
9
+ SPELLMOD_RADIUS_OP,
10
+ type SpellMod,
11
+ } from '../spells/spellMods.ts';
12
+ import { isWithinRadiusYd, type CombatPosYd } from './combatGeometry.ts';
13
+ import { fighterIsInCombat } from './combatFlag.ts';
14
+ import {
15
+ SLOW_ENGAGED_RATE,
16
+ SLOW_IDLE_RATE,
17
+ slowSliceDue,
18
+ type SlowDiag,
19
+ } from './slowUpdate.ts';
20
+ import type { ActiveEffect, Fighter } from './types.ts';
21
+
22
+ // The radius-mod reader is pure and lives in spellMods.ts so buildCombatant can call it without the
23
+ // combat session graph. Re-exported here because this is the module that documents what it is FOR.
24
+ export { radiusSpellModsFromTalentEffects, SPELLMOD_RADIUS_LABEL, SPELLMOD_RADIUS_OP };
25
+
26
+ // The effective radius: the base scaled by the owner's SPELLMOD_RADIUS. An absent base is returned
27
+ // unchanged — a missing radius on a real class-A aura is a bake gap, never an invented default.
28
+ export function effectiveAreaRadiusYd(
29
+ baseRadiusYd: number | undefined | null,
30
+ mods: readonly SpellMod[] | undefined | null,
31
+ ): number | undefined {
32
+ if (baseRadiusYd == null || !(baseRadiusYd > 0)) return undefined;
33
+ if (!mods || mods.length === 0) return baseRadiusYd;
34
+ const { value } = applySpellMod(baseRadiusYd, mods, { op: SPELLMOD_RADIUS_LABEL });
35
+ return value > 0 ? value : baseRadiusYd;
36
+ }
37
+
38
+ // One live class-A area-aura holder registered on the session. The closures are the apply-missing and
39
+ // remove arms, so the pass owns only the geometry and membership decision.
40
+ export interface AreaAuraSource {
41
+ // Unit whose position is the aura origin. Totem side for totem kits (Classic: at the caster's feet, not
42
+ // the caster), pet side for Blood Pact, caster side for Sanctity / Devotion.
43
+ holderSide: Side;
44
+ /** Aura spell id (kit / party spell) — identifies the rows this source owns. */
45
+ spellId: number;
46
+ name: string;
47
+ // `GetSpellRadius(EffectRadiusIndex)` yards. `undefined` = the bake has no radius for a real area aura —
48
+ // a gap, surfaced by `missingRadius`, and failed open.
49
+ baseRadiusYd?: number;
50
+ /** Unit whose SPELLMOD_RADIUS mods scale the radius (totem OWNER, or the caster). */
51
+ modOwnerSide: Side;
52
+ // Re-apply to a member that is (still / again) in radius. Must be idempotent. `t` is the pass's
53
+ // clock: a closure that kept its cast's `t` stamped a late joiner's row in the past and ticked it up.
54
+ apply: (target: Fighter, t: number) => void;
55
+ /** Remove this holder's rows from a member that left radius / party / died. */
56
+ strip: (target: Fighter) => void;
57
+ /** Current membership candidates (living party / pet rules). */
58
+ members: () => Fighter[];
59
+ // Sides that currently hold this holder's rows, member or not. members() alone cannot express the
60
+ // remove arm: the C++ runs it from the TARGET, so a unit that LEFT still re-checks and removes.
61
+ carriers: () => Side[];
62
+ /** False once the holder is gone (totem unsummoned / caster dead / caster lost its own row). */
63
+ alive: () => boolean;
64
+ /** Cast-tick radius filter — not C2-gated. Set by registerAreaAuraSource. */
65
+ radiusPending?: boolean;
66
+ // spell_group of an exclusive family (paladin auras are group 8): one holder keeps ONE source per
67
+ // group, so switching Devotion to Sanctity strips Devotion off every carrier.
68
+ exclusionGroup?: number;
69
+ }
70
+
71
+ // A holder swapping auras inside one exclusive group loses the old row, so every carrier loses it too.
72
+ // Cite: Unit.cpp:4907 RemoveNoStackAurasDueToAuraHolder; SpellStacking.cpp:358; SpellAuras.cpp:597.
73
+ export function stripExclusiveSiblingSources(
74
+ sources: AreaAuraSource[],
75
+ incoming: Pick<AreaAuraSource, 'holderSide' | 'spellId' | 'exclusionGroup'>,
76
+ bySide: (side: Side | string) => Fighter | undefined,
77
+ ): number {
78
+ const group = incoming.exclusionGroup;
79
+ if (group == null || group === 0) return 0;
80
+ let stripped = 0;
81
+ for (let i = sources.length - 1; i >= 0; i--) {
82
+ const s = sources[i]!;
83
+ if (s.holderSide !== incoming.holderSide) continue;
84
+ if (s.spellId === incoming.spellId) continue;
85
+ if (s.exclusionGroup !== group) continue;
86
+ for (const side of s.carriers()) {
87
+ const unit = bySide(side);
88
+ if (unit) s.strip(unit);
89
+ }
90
+ sources.splice(i, 1);
91
+ stripped++;
92
+ }
93
+ return stripped;
94
+ }
95
+
96
+ // Rows of OTHER spells in the same exclusive group that this holder left on any unit, source-registered
97
+ // or not (a Retribution Aura damage shield has no source). Reversed and removed, count returned.
98
+ export function stripHolderRowsInGroup(
99
+ effects: ActiveEffect[],
100
+ holderSide: Side,
101
+ keepSpellId: number,
102
+ group: number,
103
+ groupOf: (spellId: number) => number | undefined,
104
+ bySide: (side: Side | string) => Fighter | undefined,
105
+ reverse: (unit: Fighter, e: ActiveEffect) => void,
106
+ fromHolder?: readonly ActiveEffect[],
107
+ ): number {
108
+ let removed = 0;
109
+ const rows = fromHolder ?? effects;
110
+ for (let i = rows.length - 1; i >= 0; i--) {
111
+ const e = rows[i]!;
112
+ if (e.source !== holderSide) continue;
113
+ if (e.spellId === keepSpellId) continue;
114
+ if ((e.exclusionGroup ?? groupOf(e.spellId)) !== group) continue;
115
+ if (isSheetBakedDisplayRow(e)) continue;
116
+ const unit = bySide(e.target);
117
+ if (!unit) continue;
118
+ if (!e.broken) reverse(unit, e);
119
+ else unit.buffActive.delete(e.spellId);
120
+ const idx = fromHolder ? effects.lastIndexOf(e) : i;
121
+ if (idx >= 0) effects.splice(idx, 1);
122
+ removed++;
123
+ }
124
+ return removed;
125
+ }
126
+
127
+ // A sheet-baked display row: the stats already live in the spec, so the effect carries no mod to
128
+ // reverse. Stripping one would let a re-entry stack combat stats on top of the sheet.
129
+ function isSheetBakedDisplayRow(e: ActiveEffect): boolean {
130
+ return e.kind === 'buff' && e.mod == null;
131
+ }
132
+
133
+ // Remove one holder's rows for `spellId` from `target` and reverse their live side-effects. Scoped by
134
+ // source, so twin holders never strip each other. Cite: AreaAura::Update remove arm ~592–624.
135
+ export function stripAreaAuraRowsFrom(
136
+ effects: ActiveEffect[],
137
+ target: Fighter,
138
+ spellId: number,
139
+ holderSide: Side,
140
+ reverse: (unit: Fighter, e: ActiveEffect) => void,
141
+ onTarget?: readonly ActiveEffect[],
142
+ ): number {
143
+ let removed = 0;
144
+ const rows = onTarget ?? effects;
145
+ for (let i = rows.length - 1; i >= 0; i--) {
146
+ const e = rows[i]!;
147
+ if (e.target !== target.side) continue;
148
+ if (e.spellId !== spellId) continue;
149
+ if (e.source !== holderSide) continue;
150
+ if (isSheetBakedDisplayRow(e)) continue;
151
+ if (!e.broken) reverse(target, e);
152
+ else target.buffActive.delete(e.spellId);
153
+ const idx = onTarget ? effects.lastIndexOf(e) : i;
154
+ if (idx >= 0) effects.splice(idx, 1);
155
+ removed++;
156
+ }
157
+ return removed;
158
+ }
159
+
160
+ /** True when this holder still has at least one live row for `spellId` on `target`. */
161
+ export function hasAreaAuraRow(
162
+ effects: readonly ActiveEffect[],
163
+ target: Fighter,
164
+ spellId: number,
165
+ holderSide: Side,
166
+ ): boolean {
167
+ return effects.some(
168
+ (e) => e.target === target.side && e.spellId === spellId && e.source === holderSide,
169
+ );
170
+ }
171
+
172
+ // Distinct sides carrying this holder's rows. Sheet-baked display rows are excluded, since stripping
173
+ // deliberately leaves them alone and listing such a side would re-strip nothing every slice.
174
+ export function areaAuraCarrierSides(
175
+ effects: readonly ActiveEffect[],
176
+ spellId: number,
177
+ holderSide: Side,
178
+ ): Side[] {
179
+ const out: Side[] = [];
180
+ for (const e of effects) {
181
+ if (e.spellId !== spellId) continue;
182
+ if (e.source !== holderSide) continue;
183
+ if (isSheetBakedDisplayRow(e)) continue;
184
+ if (out.includes(e.target)) continue;
185
+ out.push(e.target);
186
+ }
187
+ return out;
188
+ }
189
+
190
+ export interface AreaAuraMaintainDeps {
191
+ sources: AreaAuraSource[];
192
+ posOf: (side: Side | string) => CombatPosYd | undefined;
193
+ bySide: (side: Side | string) => Fighter | undefined;
194
+ dead: (f: Fighter) => boolean;
195
+ /** Geometry present? Absent → legacy whole-side behaviour, no strips. */
196
+ hasGeometry: () => boolean;
197
+ t: number;
198
+ tickMs: number;
199
+ diag?: SlowDiag;
200
+ }
201
+
202
+ /** Holder C2 slice — Recoil leftover; death/unsummon is not on this clock. */
203
+ export function areaAuraRadiusDue(
204
+ holderSide: string,
205
+ t: number,
206
+ tickMs: number,
207
+ engaged: boolean,
208
+ pending: boolean,
209
+ ): boolean {
210
+ if (pending) return true;
211
+ return slowSliceDue(
212
+ holderSide,
213
+ t,
214
+ tickMs,
215
+ engaged ? SLOW_ENGAGED_RATE : SLOW_IDLE_RATE,
216
+ );
217
+ }
218
+
219
+ // The remove arm for carriers that are no longer members. The C++ runs it from the TARGET, so a
220
+ // holder-side members() loop never revisits a unit that left — Devotion Aura sat on a departed rogue.
221
+ function stripDepartedCarriers(s: AreaAuraSource, deps: AreaAuraMaintainDeps): void {
222
+ const carriers = s.carriers();
223
+ if (carriers.length === 0) return;
224
+ const memberSides = new Set<Side>();
225
+ for (const m of s.members()) memberSides.add(m.side);
226
+ for (const side of carriers) {
227
+ if (memberSides.has(side)) continue;
228
+ const unit = deps.bySide(side);
229
+ if (unit) s.strip(unit);
230
+ }
231
+ }
232
+
233
+ // AreaAura::Update for every registered holder, once per tick after motion. Radius and membership ride
234
+ // the slow slice; holder death, unsummon and member death still strip immediately.
235
+ export function runAreaAuraMaintain(deps: AreaAuraMaintainDeps): void {
236
+ const { sources } = deps;
237
+ if (sources.length === 0) return;
238
+ const geometry = deps.hasGeometry();
239
+
240
+ for (let i = sources.length - 1; i >= 0; i--) {
241
+ const s = sources[i]!;
242
+ if (!s.alive()) {
243
+ // Holder gone strips EVERY carrier, not just the current members — a unit that left
244
+ // the party before the totem was unsummoned still holds rows.
245
+ stripDepartedCarriers(s, deps);
246
+ for (const m of s.members()) s.strip(m);
247
+ sources.splice(i, 1);
248
+ continue;
249
+ }
250
+ const pending = s.radiusPending === true;
251
+ if (pending) s.radiusPending = false;
252
+ const probe = deps.bySide(s.modOwnerSide) ?? deps.bySide(s.holderSide);
253
+ const engaged = probe ? fighterIsInCombat(probe, deps.t) : false;
254
+ const due = areaAuraRadiusDue(s.holderSide, deps.t, deps.tickMs, engaged, pending);
255
+ if (!due) {
256
+ for (const m of s.members()) {
257
+ if (deps.dead(m)) s.strip(m);
258
+ }
259
+ if (deps.diag) deps.diag.areaAuraSkip++;
260
+ continue;
261
+ }
262
+ if (deps.diag) deps.diag.areaAuraRadius++;
263
+ // Membership and radius share the C2 slice: subgroup/leave is membership, not geometry.
264
+ stripDepartedCarriers(s, deps);
265
+ const origin = geometry ? deps.posOf(s.holderSide) : undefined;
266
+ const modOwner = deps.bySide(s.modOwnerSide);
267
+ const radius = effectiveAreaRadiusYd(s.baseRadiusYd, modOwner?.radiusSpellMods);
268
+ for (const m of s.members()) {
269
+ if (deps.dead(m)) {
270
+ s.strip(m);
271
+ continue;
272
+ }
273
+ if (!geometry) {
274
+ // No coords — keep the historic whole-side reach (never invent a radius).
275
+ s.apply(m, deps.t);
276
+ continue;
277
+ }
278
+ // isWithinRadiusYd fails OPEN on a missing radius or a missing position:
279
+ // a bake gap must not silently delete a real party aura (§4.7).
280
+ if (isWithinRadiusYd(origin, deps.posOf(m.side), radius)) s.apply(m, deps.t);
281
+ else s.strip(m);
282
+ }
283
+ }
284
+ }
@@ -51,7 +51,7 @@ import {
51
51
  export const ALL_SCHOOLS: School[] = ['physical', 'holy', 'fire', 'nature', 'frost', 'shadow', 'arcane'];
52
52
 
53
53
  /** One-school MOD_DAMAGE_DONE BuffMod → school. Cite: SharedDefines.h SpellSchoolMask. */
54
- const SPELL_DAMAGE_MOD_SCHOOL: Partial<Record<BuffMod, School>> = {
54
+ export const SPELL_DAMAGE_MOD_SCHOOL: Partial<Record<BuffMod, School>> = {
55
55
  spell_damage_holy: 'holy',
56
56
  spell_damage_fire: 'fire',
57
57
  spell_damage_nature: 'nature',