@kar-mi/spirit-vale-tools-combat 3.3.5 → 4.0.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/dist/index.js CHANGED
@@ -1,12 +1,11 @@
1
1
  // @bun
2
2
  // src/combat-tracker.ts
3
3
  import {
4
- classifyFishNetRecoveryStyle,
5
4
  CURRENT_GAME_BUILD_FINGERPRINT,
6
5
  FishNetMonsterDirectory,
7
- loadBundledFishNetSemanticMap
6
+ loadBundledFishNetRpcMap
8
7
  } from "@kar-mi/spirit-vale-tools-capture";
9
- import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
8
+ import { readSignedPackedWhole as readSignedPackedWhole2 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
10
9
  import { loadBundledSkillCatalog } from "@kar-mi/spirit-vale-tools-skills";
11
10
 
12
11
  // src/effect-display.ts
@@ -69,51 +68,53 @@ function readBoolean(buffer, offset, description) {
69
68
  return { value: value === 1, nextOffset: offset + 1 };
70
69
  }
71
70
 
72
- // src/summon-calibration.ts
73
- import { FishNetProtocolError as FishNetProtocolError2 } from "@kar-mi/spirit-vale-tools-capture";
74
- import { checkedEnd as checkedEnd2, readSignedPackedWhole as readSignedPackedWhole2, requireBytes as requireBytes2 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
75
- function decodeSummonCalibration(payload) {
76
- const count = readSignedPackedWhole2(payload, 0);
77
- if (count.value === -1) {
78
- requireComplete(payload, count.nextOffset);
79
- return [];
80
- }
81
- if (count.value < 0)
82
- throw new FishNetProtocolError2("invalid summon calibration array length");
83
- const entries = [];
84
- let offset = count.nextOffset;
85
- for (let index = 0;index < count.value; index += 1) {
86
- const skill = readString2(payload, offset);
87
- offset = skill.nextOffset;
88
- const primary = readBoolean2(payload, offset, "summon primary flag");
89
- offset = primary.nextOffset;
90
- const mounted = readBoolean2(payload, offset, "summon mounted flag");
91
- offset = mounted.nextOffset;
92
- entries.push({ skillId: skill.value, isPrimary: primary.value, isMountedSummon: mounted.value });
93
- }
94
- requireComplete(payload, offset);
95
- return entries;
96
- }
97
- function readString2(buffer, offset) {
98
- const length = readSignedPackedWhole2(buffer, offset);
99
- if (length.value < 0)
100
- throw new FishNetProtocolError2("summon skill id must not be null");
101
- const nextOffset = checkedEnd2(buffer, length.nextOffset, length.value);
102
- return { value: buffer.toString("utf8", length.nextOffset, nextOffset), nextOffset };
103
- }
104
- function readBoolean2(buffer, offset, description) {
105
- requireBytes2(buffer, offset, 1, description);
106
- const value = buffer[offset];
107
- if (value !== 0 && value !== 1)
108
- throw new FishNetProtocolError2(`invalid ${description}`);
109
- return { value: value === 1, nextOffset: offset + 1 };
110
- }
111
- function requireComplete(buffer, offset) {
112
- if (offset !== buffer.length) {
113
- throw new FishNetProtocolError2(`summon calibration left ${buffer.length - offset} undecoded bytes`);
114
- }
71
+ // src/inference/recovery-style.ts
72
+ function classifyFishNetRecoveryStyle(packet) {
73
+ if (packet.rpcName !== "Recover_C" || packet.networkBehaviourType !== "HealthComponent")
74
+ return "unknown";
75
+ const value = (name) => packet.decodedFields?.find((field) => field.name === name)?.value;
76
+ const disableFloater = value("settings.DisableFloater");
77
+ const disableSfx = value("settings.DisableSfx");
78
+ const offset = value("settings.Offset");
79
+ const scale = value("settings.Scale");
80
+ if (typeof disableFloater !== "boolean" || typeof disableSfx !== "boolean" || typeof offset !== "number" || typeof scale !== "number")
81
+ return "unknown";
82
+ if (!disableFloater && disableSfx && offset < 0 && scale > 0)
83
+ return "drain";
84
+ if (!disableFloater && disableSfx && offset === 0 && scale === 0)
85
+ return "passive-regeneration";
86
+ if (!disableFloater && !disableSfx && offset > 0 && scale === 0)
87
+ return "standard";
88
+ return "unknown";
115
89
  }
116
90
 
91
+ // src/generated/combat-semantics.ts
92
+ var COMBAT_SEMANTICS_BUILD_FINGERPRINT = "53305f369c4176d4b7644d06ad7f0cdc5c9bda6563144d0ce8cff3d12c520158";
93
+ var DIRECT_HEALING_SKILL_IDS = new Set([
94
+ "Consecration",
95
+ "DeathCoilSummon",
96
+ "FieldHealing",
97
+ "Heal",
98
+ "HighHeal"
99
+ ]);
100
+ var REGENERATION_SKILL_IDS = new Set([
101
+ "GuardianBond",
102
+ "HealAll",
103
+ "Sanctuary",
104
+ "SanctuaryField"
105
+ ]);
106
+ var BARRIER_SKILL_IDS = new Set([
107
+ "Barrier",
108
+ "CorpseBarrier",
109
+ "EclipsingAegis"
110
+ ]);
111
+ var BARRIER_STATUS_IDS = new Set([
112
+ "Barrier",
113
+ "CorpseBarrier",
114
+ "EclipsingAegis",
115
+ "RadiantBulwark"
116
+ ]);
117
+
117
118
  // src/combat-tracker.ts
118
119
  var HIT_RESULTS = {
119
120
  0: "normal",
@@ -124,10 +125,6 @@ var HIT_RESULTS = {
124
125
  };
125
126
  var SKILL_RPC_NAMES = new Set(["CastBegin_C", "AutoCast_C", "ToggleBegin_C", "CastComplete_C", "CastInterrupt_C", "CastCancel_C"]);
126
127
  var CURRENT_STATUS_COMPONENT_INDICES = new Set([5, 6]);
127
- var CURRENT_HEALTH_COMPONENT_INDICES = new Set([2, 3]);
128
- var MAX_RECOVERED_SUMMON_BYTES = 256;
129
- var HEALING_SKILL_IDS = new Set(["Heal", "HighHeal", "FieldHealing"]);
130
- var REGEN_SKILL_IDS = new Set(["HealAll", "Sanctuary", "GuardianBond", "SanctuaryField"]);
131
128
  var REGEN_STATUS_ID = "Regeneration";
132
129
 
133
130
  class FishNetCombatTracker {
@@ -136,15 +133,23 @@ class FishNetCombatTracker {
136
133
  skillLabels;
137
134
  actorIdentityResolver;
138
135
  healingTraitsResolver;
139
- localActorIdResolver;
140
136
  monsterCatalog;
141
137
  bossCatalog;
142
138
  monsters;
143
- semanticMap;
139
+ healthComponentIndices;
140
+ directHealingSkillIds;
141
+ regenerationSkillIds;
142
+ barrierSkillIds;
143
+ barrierStatusIds;
144
144
  activations = new Map;
145
145
  bossIdentities = new Map;
146
146
  activeRegenSources = new Map;
147
+ bondRegenSources = new Map;
148
+ barriers = new Map;
149
+ recentDamageHits = new Map;
147
150
  summonStacks = new Map;
151
+ summonSkillSyncState = new Map;
152
+ authoritativeSummonActors = new Set;
148
153
  recentDamageSignatures = new Set;
149
154
  recentDamageTick;
150
155
  nextActivation = 1;
@@ -157,18 +162,19 @@ class FishNetCombatTracker {
157
162
  if (!Number.isInteger(maxAge) || maxAge < 1)
158
163
  throw new Error("activationMaxAgeTicks must be a positive integer");
159
164
  this.activationMaxAgeTicks = maxAge;
160
- const buildFingerprint = options.buildFingerprint ?? options.skillCatalog?.buildFingerprint ?? options.semanticMap?.buildFingerprint ?? CURRENT_GAME_BUILD_FINGERPRINT;
165
+ const buildFingerprint = options.buildFingerprint ?? options.skillCatalog?.buildFingerprint ?? CURRENT_GAME_BUILD_FINGERPRINT;
161
166
  assertMatchingBuild("skill catalog", options.skillCatalog?.buildFingerprint, buildFingerprint);
162
- assertMatchingBuild("semantic map", options.semanticMap?.buildFingerprint, buildFingerprint);
163
167
  const skillCatalog = options.skillCatalog ?? tryLoadBundledSkillCatalog(buildFingerprint);
164
- const semanticMap = options.semanticMap ?? (skillCatalog ? tryLoadBundledSemanticMap(buildFingerprint) : loadBundledFishNetSemanticMap(buildFingerprint));
165
- this.semanticMap = semanticMap;
168
+ const rpcMap = tryLoadBundledRpcMap(buildFingerprint);
169
+ this.healthComponentIndices = healthComponentIndices(rpcMap);
170
+ const currentSemantics = buildFingerprint === COMBAT_SEMANTICS_BUILD_FINGERPRINT;
171
+ this.directHealingSkillIds = currentSemantics ? DIRECT_HEALING_SKILL_IDS : new Set;
172
+ this.regenerationSkillIds = currentSemantics ? REGENERATION_SKILL_IDS : new Set;
173
+ this.barrierSkillIds = currentSemantics ? BARRIER_SKILL_IDS : new Set;
174
+ this.barrierStatusIds = currentSemantics ? BARRIER_STATUS_IDS : new Set;
166
175
  this.skillLabels = new Map(skillCatalog?.skills.map(({ id, displayName }) => [id, displayName]) ?? []);
167
- for (const { value, label } of semanticMap?.verifiedSkillLabels ?? [])
168
- this.skillLabels.set(value, label);
169
176
  this.actorIdentityResolver = options.actorIdentityResolver;
170
177
  this.healingTraitsResolver = options.healingTraitsResolver;
171
- this.localActorIdResolver = options.localActorIdResolver;
172
178
  this.monsterCatalog = options.monsterCatalog;
173
179
  if (options.monsterCatalog)
174
180
  this.monsters = new FishNetMonsterDirectory(options.monsterCatalog);
@@ -177,6 +183,7 @@ class FishNetCombatTracker {
177
183
  consume(packet) {
178
184
  const monsterIdentity = this.monsterIdentity(packet.tick, this.monsters?.consume(packet));
179
185
  const bossLifecycle = this.bossIdentityLifecycle(packet);
186
+ const summonLifecycle = this.consumeSummonObjectLifecycle(packet);
180
187
  if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
181
188
  this.reset();
182
189
  return uniqueMonsterIdentityEvents([
@@ -188,14 +195,21 @@ class FishNetCombatTracker {
188
195
  if (this.recentDamageTick !== packet.tick) {
189
196
  this.recentDamageTick = packet.tick;
190
197
  this.recentDamageSignatures.clear();
198
+ this.recentDamageHits.clear();
191
199
  }
192
200
  const events = [
193
201
  ...monsterIdentity ? [monsterIdentity] : [],
194
- ...bossLifecycle ? [bossLifecycle] : []
202
+ ...bossLifecycle ? [bossLifecycle] : [],
203
+ ...summonLifecycle ? [summonLifecycle] : []
195
204
  ];
205
+ this.consumeBondSync(packet);
206
+ events.push(...this.consumeBarrierSync(packet));
207
+ if (packet.packetName === "syncType" && matchesBehaviour(packet, "SummoningComponent")) {
208
+ events.push(...this.consumeSummonSkillSync(packet));
209
+ return events;
210
+ }
196
211
  if (packet.objectId === undefined || !packet.rpcName) {
197
212
  events.push(...this.recoverAmbiguousCombat(packet));
198
- events.push(...this.recoverSummons(packet));
199
213
  return events;
200
214
  }
201
215
  if (SKILL_RPC_NAMES.has(packet.rpcName) && matchesBehaviour(packet, "SkillsComponent")) {
@@ -255,18 +269,27 @@ class FishNetCombatTracker {
255
269
  }
256
270
  return events;
257
271
  }
258
- if (packet.rpcName === "CalibrateSummons_T" && matchesBehaviour(packet, "SummoningComponent")) {
272
+ if (packet.rpcName === "CalibrateSummons_T" && packet.rpcResolution === "verified" && packet.networkBehaviourType === "SummoningComponent") {
259
273
  events.push(...this.consumeSummonCalibration(packet));
260
274
  return events;
261
275
  }
276
+ if (packet.rpcName === "LoadCharacter_T" && packet.rpcResolution === "verified" && packet.networkBehaviourType === "PlayerSave") {
277
+ events.push(...this.consumeLoginEffects(packet));
278
+ return events;
279
+ }
262
280
  return events;
263
281
  }
264
282
  reset() {
265
283
  this.activations.clear();
266
284
  this.bossIdentities.clear();
267
285
  this.activeRegenSources.clear();
286
+ this.bondRegenSources.clear();
287
+ this.barriers.clear();
268
288
  this.summonStacks.clear();
289
+ this.summonSkillSyncState.clear();
290
+ this.authoritativeSummonActors.clear();
269
291
  this.recentDamageSignatures.clear();
292
+ this.recentDamageHits.clear();
270
293
  this.recentDamageTick = undefined;
271
294
  this.monsters?.reset();
272
295
  }
@@ -334,6 +357,14 @@ class FishNetCombatTracker {
334
357
  actorId,
335
358
  actorIdentity: this.actorIdentityResolver?.(actorId)
336
359
  };
360
+ for (const display of batch.applies) {
361
+ if (this.barrierStatusIds.has(display.statusId))
362
+ this.observeBarrierStatus(actorId, true, packet.tick);
363
+ }
364
+ for (const statusId of batch.removes) {
365
+ if (this.barrierStatusIds.has(statusId))
366
+ this.observeBarrierStatus(actorId, false, packet.tick);
367
+ }
337
368
  return [
338
369
  ...batch.applies.map((display) => ({
339
370
  ...base,
@@ -355,27 +386,28 @@ class FishNetCombatTracker {
355
386
  rpcName: "ApplyEffectDisplays_O"
356
387
  });
357
388
  }
358
- if (packet.rpcHash !== 1 || !CURRENT_HEALTH_COMPONENT_INDICES.has(packet.networkBehaviourIndex))
389
+ if (packet.rpcHash !== 1 || !this.healthComponentIndices.has(packet.networkBehaviourIndex))
359
390
  return [];
360
391
  let amount;
361
392
  try {
362
- amount = readSignedPackedWhole3(packet.payload, 0);
393
+ amount = readSignedPackedWhole2(packet.payload, 0);
363
394
  } catch {
364
395
  return [];
365
396
  }
366
397
  if (amount.value < 0)
367
398
  return [];
368
- const settings = packet.payload.subarray(amount.nextOffset);
369
- const settingsHex = settings.toString("hex");
370
- const knownHealthRecovery = this.semanticMap?.recoveryStyles?.some((definition) => definition.networkBehaviourType === "HealthComponent" && definition.rpcName === "Recover_C" && definition.undecodedPayloadHex === settingsHex) ?? false;
371
- if (!knownHealthRecovery)
399
+ const settings = decodeFloaterSettings(packet.payload, amount.nextOffset);
400
+ if (!settings)
372
401
  return [];
373
402
  return [this.consumeRecover({
374
403
  ...packet,
375
404
  networkBehaviourType: "HealthComponent",
376
405
  rpcName: "Recover_C",
377
- decodedFields: [{ name: "amount", codec: "packedInt32", value: amount.value }],
378
- undecodedPayload: settings
406
+ decodedFields: [
407
+ { name: "amount", codec: "packedInt32", value: amount.value },
408
+ ...settings
409
+ ],
410
+ undecodedPayload: undefined
379
411
  })];
380
412
  }
381
413
  consumeSkillDisplay(packet) {
@@ -397,35 +429,92 @@ class FishNetCombatTracker {
397
429
  actorIdentity: this.actorIdentityResolver?.(actorId)
398
430
  };
399
431
  }
400
- recoverSummons(packet) {
401
- if (packet.rpcName || packet.rpcResolution === "verified" || packet.rpcResolution === "recovered")
432
+ consumeLoginEffects(packet) {
433
+ const entries = decodedLoginEffects(packet);
434
+ if (!entries)
402
435
  return [];
403
- if (packet.payload.length < 2 || packet.payload.length > MAX_RECOVERED_SUMMON_BYTES)
404
- return [];
405
- if (this.skillLabels.size === 0)
436
+ const actorId = packet.objectId;
437
+ return entries.map((entry) => ({
438
+ kind: "status",
439
+ rpc: "LoadCharacter_T",
440
+ tick: packet.tick,
441
+ payloadBytes: packet.payload.length,
442
+ fields: {
443
+ Id: entry.statusId,
444
+ Level: entry.level,
445
+ Duration: entry.remainingSeconds ?? null,
446
+ Stacks: entry.stacks
447
+ },
448
+ actorId,
449
+ statusId: entry.statusId,
450
+ level: entry.level,
451
+ action: "applied",
452
+ ...entry.remainingSeconds === undefined ? {} : { remainingSeconds: entry.remainingSeconds },
453
+ stacks: entry.stacks,
454
+ actorIdentity: this.actorIdentityResolver?.(actorId)
455
+ }));
456
+ }
457
+ consumeSummonSkillSync(packet) {
458
+ const summonObjectId = packet.objectId;
459
+ const summonerSync = packet.syncEntries?.find((candidate) => candidate.name === "SummonerSync")?.fields.find((entryField) => entryField.name === "SummonerSync")?.value;
460
+ const skillId = packet.syncEntries?.find((candidate) => candidate.name === "SummonSkillSync")?.fields.find((entryField) => entryField.name === "SkillId")?.value;
461
+ if (typeof summonerSync !== "number" && typeof skillId !== "string")
406
462
  return [];
407
- const actorId = packet.objectId ?? this.localActorIdResolver?.();
408
- if (actorId === undefined)
463
+ const state = this.summonSkillSyncState.get(summonObjectId) ?? { counted: false };
464
+ if (typeof summonerSync === "number")
465
+ state.ownerActorId = summonerSync;
466
+ if (typeof skillId === "string")
467
+ state.skillId = skillId;
468
+ this.summonSkillSyncState.set(summonObjectId, state);
469
+ if (state.counted || state.ownerActorId === undefined || state.skillId === undefined)
409
470
  return [];
410
- let entries;
411
- try {
412
- entries = decodeSummonCalibration(packet.payload);
413
- } catch {
471
+ if (this.authoritativeSummonActors.has(state.ownerActorId))
414
472
  return [];
473
+ state.counted = true;
474
+ return [this.applySummonDelta(state.ownerActorId, state.skillId, 1, packet, "SummonSkillSync")];
475
+ }
476
+ consumeSummonObjectLifecycle(packet) {
477
+ if (packet.packetName !== "objectDespawn" && packet.packetName !== "objectSpawn" || packet.objectId === undefined) {
478
+ return;
415
479
  }
416
- if (entries.length === 0)
417
- return [];
418
- if (!entries.every(({ skillId }) => this.skillLabels.has(skillId)))
419
- return [];
420
- return this.applySummonSnapshot(actorId, packet, entries).map((event) => ({ ...event, recovered: true }));
480
+ const state = this.summonSkillSyncState.get(packet.objectId);
481
+ if (!state)
482
+ return;
483
+ this.summonSkillSyncState.delete(packet.objectId);
484
+ if (!state.counted || state.ownerActorId === undefined || state.skillId === undefined)
485
+ return;
486
+ if (this.authoritativeSummonActors.has(state.ownerActorId))
487
+ return;
488
+ return this.applySummonDelta(state.ownerActorId, state.skillId, -1, packet, "SummonSkillSync");
489
+ }
490
+ applySummonDelta(actorId, skillId, delta, packet, rpc) {
491
+ const stacks = new Map(this.summonStacks.get(actorId));
492
+ const nextCount = Math.max(0, (stacks.get(skillId) ?? 0) + delta);
493
+ if (nextCount === 0)
494
+ stacks.delete(skillId);
495
+ else
496
+ stacks.set(skillId, nextCount);
497
+ if (stacks.size === 0)
498
+ this.summonStacks.delete(actorId);
499
+ else
500
+ this.summonStacks.set(actorId, stacks);
501
+ return {
502
+ kind: "summon",
503
+ rpc,
504
+ tick: packet.tick,
505
+ payloadBytes: packet.payload.length,
506
+ fields: decodedFieldRecord(packet),
507
+ actorId,
508
+ skillId,
509
+ stacks: nextCount,
510
+ actorIdentity: this.actorIdentityResolver?.(actorId)
511
+ };
421
512
  }
422
513
  consumeSummonCalibration(packet) {
423
- let entries;
424
- try {
425
- entries = decodeSummonCalibration(packet.payload);
426
- } catch {
514
+ const entries = decodedSummonCalibration(packet);
515
+ if (!entries)
427
516
  return [];
428
- }
517
+ this.authoritativeSummonActors.add(packet.objectId);
429
518
  return this.applySummonSnapshot(packet.objectId, packet, entries);
430
519
  }
431
520
  applySummonSnapshot(actorId, packet, entries) {
@@ -446,7 +535,7 @@ class FishNetCombatTracker {
446
535
  rpc: "CalibrateSummons_T",
447
536
  tick: packet.tick,
448
537
  payloadBytes: packet.payload.length,
449
- fields: {},
538
+ fields: decodedFieldRecord(packet),
450
539
  actorId,
451
540
  skillId,
452
541
  stacks: current.get(skillId) ?? 0,
@@ -463,7 +552,7 @@ class FishNetCombatTracker {
463
552
  if (!sourceId)
464
553
  return;
465
554
  const activation2 = this.createActivation(actorId, "skill", packet.tick, sourceId, false);
466
- activation2.targetId = numberField(packet, "targetId");
555
+ activation2.targetId = rpcName === "AutoCast_C" ? numberField(packet, "obj") : numberField(packet, "targetId");
467
556
  return {
468
557
  kind: "activation",
469
558
  rpc: rpcName,
@@ -548,6 +637,8 @@ class FishNetCombatTracker {
548
637
  actorIdentity: this.actorIdentityResolver?.(actorId)
549
638
  }];
550
639
  }
640
+ if (value >= 0)
641
+ this.recentDamageHits.set(packet.objectId, { actorId, sourceId, sourceLabel });
551
642
  const hitCode = requiredNumberField(packet, "dmg.Hit");
552
643
  const hitResult = HIT_RESULTS[hitCode] ?? hitCode;
553
644
  const targetId = packet.objectId;
@@ -647,7 +738,7 @@ class FishNetCombatTracker {
647
738
  consumeRecover(packet) {
648
739
  const targetId = packet.objectId;
649
740
  const value = requiredNumberField(packet, "amount");
650
- const recoveryStyle = classifyFishNetRecoveryStyle(packet, this.semanticMap);
741
+ const recoveryStyle = classifyFishNetRecoveryStyle(packet);
651
742
  if (recoveryStyle === "passive-regeneration") {
652
743
  return this.createSelfRecovery(packet, value, recoveryStyle, "passive-regeneration", "Passive regeneration");
653
744
  }
@@ -655,7 +746,7 @@ class FishNetCombatTracker {
655
746
  const source = drainRecoverySource(this.healingTraitsResolver?.(targetId));
656
747
  return this.createSelfRecovery(packet, value, recoveryStyle, source.sourceId, source.sourceLabel);
657
748
  }
658
- const candidates = this.eligibleHealActivations(packet.tick, targetId, HEALING_SKILL_IDS);
749
+ const candidates = this.eligibleHealActivations(packet.tick, targetId, this.directHealingSkillIds);
659
750
  let attribution;
660
751
  let actorId;
661
752
  let sourceId;
@@ -666,7 +757,7 @@ class FishNetCombatTracker {
666
757
  const [candidate] = candidates;
667
758
  if (!candidate)
668
759
  throw new Error("missing combat activation candidate");
669
- attribution = candidate.inferred ? "inferred" : "exact";
760
+ attribution = "inferred";
670
761
  actorId = candidate.actorId;
671
762
  sourceId = candidate.sourceId;
672
763
  sourceLabel = candidate.sourceLabel;
@@ -675,8 +766,8 @@ class FishNetCombatTracker {
675
766
  attribution = "ambiguous";
676
767
  candidateActivationIds = candidates.map(({ id }) => id);
677
768
  } else {
678
- const regenSource = this.activeRegenSources.get(targetId);
679
- if (regenSource?.candidateActivationIds) {
769
+ const regenSource = this.activeRegenSources.get(targetId) ?? this.bondRegenSources.get(targetId);
770
+ if (regenSource?.candidateActivationIds || regenSource?.ambiguous) {
680
771
  attribution = "ambiguous";
681
772
  candidateActivationIds = regenSource.candidateActivationIds;
682
773
  } else if (regenSource?.actorId !== undefined) {
@@ -707,6 +798,101 @@ class FishNetCombatTracker {
707
798
  actorIdentity: actorId === undefined ? undefined : this.actorIdentityResolver?.(actorId)
708
799
  };
709
800
  }
801
+ consumeBarrierSync(packet) {
802
+ if (packet.objectId === undefined)
803
+ return [];
804
+ if (packet.packetName === "objectDespawn" || packet.packetName === "objectSpawn") {
805
+ this.barriers.delete(packet.objectId);
806
+ if (packet.packetName === "objectDespawn")
807
+ return [];
808
+ }
809
+ const values = barrierSyncValues(packet);
810
+ if (values.length === 0)
811
+ return [];
812
+ const targetId = packet.objectId;
813
+ const events = [];
814
+ for (const barrierAfter of values) {
815
+ if (!Number.isInteger(barrierAfter) || barrierAfter < 0)
816
+ continue;
817
+ const previous = this.barriers.get(targetId);
818
+ if (!previous || packet.packetName === "objectSpawn") {
819
+ this.barriers.set(targetId, { value: barrierAfter, statusActive: previous?.statusActive ?? false });
820
+ continue;
821
+ }
822
+ const barrierBefore = previous.value;
823
+ if (barrierAfter === barrierBefore)
824
+ continue;
825
+ let source = previous;
826
+ let action;
827
+ if (barrierAfter > barrierBefore) {
828
+ source = sourceFromCandidates(this.eligibleHealActivations(packet.tick, targetId, this.barrierSkillIds));
829
+ action = "gained";
830
+ } else if (this.recentDamageHits.has(targetId)) {
831
+ action = "absorbed";
832
+ } else if (barrierAfter === 0 && !previous.statusActive) {
833
+ action = "cleared";
834
+ } else {
835
+ action = "reduced";
836
+ }
837
+ const incoming = action === "absorbed" ? this.recentDamageHits.get(targetId) : undefined;
838
+ const attribution = source.candidateActivationIds ? "ambiguous" : source.actorId === undefined ? "unattributed" : "inferred";
839
+ events.push({
840
+ kind: "shield",
841
+ rpc: "barrierSync",
842
+ tick: packet.tick,
843
+ payloadBytes: packet.payload.length,
844
+ fields: { barrierSync: barrierAfter },
845
+ targetId,
846
+ actorId: source.actorId,
847
+ sourceId: source.sourceId,
848
+ sourceLabel: source.sourceLabel,
849
+ value: Math.abs(barrierAfter - barrierBefore),
850
+ barrierBefore,
851
+ barrierAfter,
852
+ action,
853
+ ...incoming === undefined ? {} : {
854
+ incomingActorId: incoming.actorId,
855
+ incomingSourceId: incoming.sourceId,
856
+ incomingSourceLabel: incoming.sourceLabel
857
+ },
858
+ attribution,
859
+ activationId: source.activationId,
860
+ candidateActivationIds: source.candidateActivationIds,
861
+ actorIdentity: source.actorId === undefined ? undefined : this.actorIdentityResolver?.(source.actorId)
862
+ });
863
+ this.barriers.set(targetId, {
864
+ ...source,
865
+ value: barrierAfter,
866
+ statusActive: barrierAfter === 0 ? false : previous.statusActive
867
+ });
868
+ }
869
+ return events;
870
+ }
871
+ consumeBondSync(packet) {
872
+ if (packet.objectId === undefined)
873
+ return;
874
+ if (packet.packetName === "objectDespawn" || packet.packetName === "objectSpawn") {
875
+ this.bondRegenSources.delete(packet.objectId);
876
+ if (packet.packetName === "objectDespawn")
877
+ return;
878
+ }
879
+ const entries = bondSyncEntries(packet);
880
+ if (!entries)
881
+ return;
882
+ const sources = entries.filter((entry) => !entry.caster && this.regenerationSkillIds.has(entry.skillId));
883
+ if (sources.length === 0) {
884
+ this.bondRegenSources.delete(packet.objectId);
885
+ } else if (sources.length === 1) {
886
+ const source = sources[0];
887
+ this.bondRegenSources.set(packet.objectId, {
888
+ actorId: source.otherId,
889
+ sourceId: source.skillId,
890
+ sourceLabel: this.skillLabels.get(source.skillId) ?? source.skillId
891
+ });
892
+ } else {
893
+ this.bondRegenSources.set(packet.objectId, { ambiguous: true });
894
+ }
895
+ }
710
896
  createSelfRecovery(packet, value, recoveryStyle, sourceId, sourceLabel) {
711
897
  const actorId = packet.objectId;
712
898
  return {
@@ -736,7 +922,7 @@ class FishNetCombatTracker {
736
922
  const actorId = packet.objectId;
737
923
  if (statusId === REGEN_STATUS_ID) {
738
924
  if (packet.rpcName === "ApplyEffect_T") {
739
- const candidates = this.eligibleHealActivations(packet.tick, actorId, REGEN_SKILL_IDS);
925
+ const candidates = this.eligibleHealActivations(packet.tick, actorId, this.regenerationSkillIds);
740
926
  if (candidates.length === 1) {
741
927
  const [candidate] = candidates;
742
928
  if (!candidate)
@@ -756,6 +942,9 @@ class FishNetCombatTracker {
756
942
  this.activeRegenSources.delete(actorId);
757
943
  }
758
944
  }
945
+ if (this.barrierStatusIds.has(statusId)) {
946
+ this.observeBarrierStatus(actorId, packet.rpcName === "ApplyEffect_T", packet.tick);
947
+ }
759
948
  return {
760
949
  kind: "status",
761
950
  rpc: packet.rpcName,
@@ -769,6 +958,15 @@ class FishNetCombatTracker {
769
958
  actorIdentity: this.actorIdentityResolver?.(actorId)
770
959
  };
771
960
  }
961
+ observeBarrierStatus(actorId, applied, tick) {
962
+ const current = this.barriers.get(actorId) ?? { value: 0, statusActive: false };
963
+ if (!applied) {
964
+ this.barriers.set(actorId, { ...current, statusActive: false });
965
+ return;
966
+ }
967
+ const source = sourceFromCandidates(this.eligibleHealActivations(tick, actorId, this.barrierSkillIds));
968
+ this.barriers.set(actorId, { ...current, ...source, statusActive: true });
969
+ }
772
970
  createActivation(actorId, actionKind, tick, sourceId, inferred) {
773
971
  const activation = {
774
972
  id: `activation-${this.nextActivation++}`,
@@ -805,12 +1003,147 @@ function drainRecoverySource(traits) {
805
1003
  }
806
1004
  return { sourceId: "siphon-health-leech", sourceLabel: "Siphon / Health Leech" };
807
1005
  }
1006
+ function sourceFromCandidates(candidates) {
1007
+ if (candidates.length === 1) {
1008
+ const candidate = candidates[0];
1009
+ return {
1010
+ actorId: candidate.actorId,
1011
+ sourceId: candidate.sourceId,
1012
+ sourceLabel: candidate.sourceLabel,
1013
+ activationId: candidate.id
1014
+ };
1015
+ }
1016
+ return candidates.length > 1 ? { candidateActivationIds: candidates.map(({ id }) => id) } : {};
1017
+ }
1018
+ function barrierSyncValues(packet) {
1019
+ const values = [];
1020
+ const read = (entry, behaviourType) => {
1021
+ if (behaviourType !== undefined && behaviourType !== "HealthComponent")
1022
+ return;
1023
+ if (entry.index !== 2 && entry.name !== "barrierSync")
1024
+ return;
1025
+ const value = entry.fields.find(({ name }) => name === "barrierSync")?.value;
1026
+ if (typeof value === "number")
1027
+ values.push(value);
1028
+ };
1029
+ for (const entry of packet.spawnSyncEntries ?? [])
1030
+ read(entry, entry.networkBehaviourType);
1031
+ if (packet.packetName === "syncType" && matchesBehaviour(packet, "HealthComponent")) {
1032
+ for (const entry of packet.syncEntries ?? [])
1033
+ read(entry, packet.networkBehaviourType);
1034
+ if (!packet.syncEntries) {
1035
+ const value = numberField(packet, "barrierSync");
1036
+ if ((packet.syncIndex === 2 || packet.syncName === "barrierSync") && value !== undefined)
1037
+ values.push(value);
1038
+ }
1039
+ }
1040
+ return values;
1041
+ }
1042
+ function bondSyncEntries(packet) {
1043
+ const decode = (entry, behaviourType) => {
1044
+ if (behaviourType !== undefined && behaviourType !== "SkillsComponent")
1045
+ return;
1046
+ if (entry.index !== 2 && entry.name !== "BondSync")
1047
+ return;
1048
+ const length = entry.fields.find(({ name }) => name === "Entries.length")?.value;
1049
+ if (typeof length !== "number" || !Number.isInteger(length) || length < 0)
1050
+ return;
1051
+ const result = [];
1052
+ for (let index = 0;index < length; index += 1) {
1053
+ const prefix = `Entries[${index}]`;
1054
+ const otherId = entry.fields.find(({ name }) => name === `${prefix}.Other`)?.value;
1055
+ const skillId = entry.fields.find(({ name }) => name === `${prefix}.SkillId`)?.value;
1056
+ const caster = entry.fields.find(({ name }) => name === `${prefix}.Caster`)?.value;
1057
+ if (typeof otherId !== "number" || typeof skillId !== "string" || typeof caster !== "boolean")
1058
+ return;
1059
+ result.push({ otherId, skillId, caster });
1060
+ }
1061
+ return result;
1062
+ };
1063
+ for (const entry of packet.spawnSyncEntries ?? []) {
1064
+ const result = decode(entry, entry.networkBehaviourType);
1065
+ if (result)
1066
+ return result;
1067
+ }
1068
+ if (packet.packetName === "syncType" && matchesBehaviour(packet, "SkillsComponent")) {
1069
+ for (const entry of packet.syncEntries ?? []) {
1070
+ const result = decode(entry, packet.networkBehaviourType);
1071
+ if (result)
1072
+ return result;
1073
+ }
1074
+ }
1075
+ return;
1076
+ }
1077
+ function decodeFloaterSettings(payload, start) {
1078
+ if (payload.length - start < 2)
1079
+ return;
1080
+ const disableFloater = payload[start];
1081
+ const disableSfx = payload[start + 1];
1082
+ if (disableFloater !== 0 && disableFloater !== 1 || disableSfx !== 0 && disableSfx !== 1)
1083
+ return;
1084
+ let offset;
1085
+ try {
1086
+ offset = readSignedPackedWhole2(payload, start + 2);
1087
+ } catch {
1088
+ return;
1089
+ }
1090
+ if (payload.length - offset.nextOffset !== 4)
1091
+ return;
1092
+ return [
1093
+ { name: "settings.DisableFloater", codec: "boolean", value: disableFloater === 1 },
1094
+ { name: "settings.DisableSfx", codec: "boolean", value: disableSfx === 1 },
1095
+ { name: "settings.Offset", codec: "packedInt32", value: offset.value },
1096
+ { name: "settings.Scale", codec: "float32", value: payload.readFloatLE(offset.nextOffset) }
1097
+ ];
1098
+ }
1099
+ function healthComponentIndices(map) {
1100
+ return new Set(map?.prefabs?.flatMap(({ components }) => components.filter(({ typeName }) => typeName === "HealthComponent").map(({ index }) => index)) ?? []);
1101
+ }
808
1102
  function field(packet, name) {
809
1103
  return packet.decodedFields?.find((candidate) => candidate.name === name)?.value;
810
1104
  }
811
1105
  function decodedFieldRecord(packet) {
812
1106
  return Object.fromEntries(packet.decodedFields?.map(({ name, value }) => [name, value]) ?? []);
813
1107
  }
1108
+ function decodedSummonCalibration(packet) {
1109
+ if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
1110
+ return;
1111
+ const length = numberField(packet, "data.length");
1112
+ if (length === undefined || !Number.isInteger(length) || length < 0)
1113
+ return;
1114
+ const entries = [];
1115
+ for (let index = 0;index < length; index += 1) {
1116
+ const skillId = stringField(packet, `data[${index}].SkillId`);
1117
+ const level = numberField(packet, `data[${index}].Level`);
1118
+ if (skillId === undefined || level === undefined || !Number.isInteger(level) || level < 0)
1119
+ return;
1120
+ entries.push({ skillId, level });
1121
+ }
1122
+ return entries;
1123
+ }
1124
+ function decodedLoginEffects(packet) {
1125
+ if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
1126
+ return;
1127
+ const length = numberField(packet, "data.State.Effects.length");
1128
+ if (length === undefined || !Number.isInteger(length) || length < 0)
1129
+ return;
1130
+ const entries = [];
1131
+ for (let index = 0;index < length; index += 1) {
1132
+ const statusId = stringField(packet, `data.State.Effects[${index}].Id`);
1133
+ const level = numberField(packet, `data.State.Effects[${index}].Level`);
1134
+ const duration = numberField(packet, `data.State.Effects[${index}].Duration`);
1135
+ const stacks = numberField(packet, `data.State.Effects[${index}].Stacks`);
1136
+ if (statusId === undefined || level === undefined || !Number.isInteger(level) || level < 0 || duration === undefined || stacks === undefined || !Number.isInteger(stacks) || stacks < 0)
1137
+ return;
1138
+ entries.push({
1139
+ statusId,
1140
+ level,
1141
+ ...duration < 0 || !Number.isFinite(duration) ? {} : { remainingSeconds: duration },
1142
+ stacks
1143
+ });
1144
+ }
1145
+ return entries;
1146
+ }
814
1147
  function matchesBehaviour(packet, expected) {
815
1148
  return packet.networkBehaviourType === undefined || packet.networkBehaviourType === expected;
816
1149
  }
@@ -855,7 +1188,7 @@ function isCompleteDamagePacket(packet, requireVectors) {
855
1188
  return numeric.every((name) => numberField(packet, name) !== undefined) && nullableStringField(packet, "dmg.DamageSourceId") !== undefined && typeof field(packet, "dmg.IsClone") === "boolean" && typeof field(packet, "dmg.IsSummon") === "boolean" && (!requireVectors || Array.isArray(field(packet, "position")) && Array.isArray(field(packet, "origin")));
856
1189
  }
857
1190
  function isCompleteRecoverPacket(packet) {
858
- return numberField(packet, "amount") !== undefined;
1191
+ return numberField(packet, "amount") !== undefined && typeof field(packet, "settings.DisableFloater") === "boolean" && typeof field(packet, "settings.DisableSfx") === "boolean" && numberField(packet, "settings.Offset") !== undefined && numberField(packet, "settings.Scale") !== undefined && (!packet.undecodedPayload || packet.undecodedPayload.length === 0);
859
1192
  }
860
1193
  function nullableStringField(packet, name) {
861
1194
  const value = field(packet, name);
@@ -889,9 +1222,9 @@ function tryLoadBundledSkillCatalog(buildFingerprint) {
889
1222
  return;
890
1223
  }
891
1224
  }
892
- function tryLoadBundledSemanticMap(buildFingerprint) {
1225
+ function tryLoadBundledRpcMap(buildFingerprint) {
893
1226
  try {
894
- return loadBundledFishNetSemanticMap(buildFingerprint);
1227
+ return loadBundledFishNetRpcMap(buildFingerprint);
895
1228
  } catch {
896
1229
  return;
897
1230
  }
@@ -908,8 +1241,8 @@ function createBossCatalog(entries) {
908
1241
  }
909
1242
  var CURRENT_BOSS_SKILL_NAMES = createBossCatalog({});
910
1243
  // src/actor-directory.ts
911
- import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap } from "@kar-mi/spirit-vale-tools-capture";
912
- import { readSignedPackedWhole as readSignedPackedWhole4 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
1244
+ import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2 } from "@kar-mi/spirit-vale-tools-capture";
1245
+ import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
913
1246
 
914
1247
  class FishNetActorDirectory {
915
1248
  options;
@@ -1274,7 +1607,7 @@ function hasMonsterIdentityEvidence(packet) {
1274
1607
  return typeof mobId === "string" && mobId.length > 0;
1275
1608
  }
1276
1609
  var VISUAL_DATA_SYNC_INDEX = 5;
1277
- var PLAYER_PREFAB_KEYS = new Set((loadBundledFishNetRpcMap().prefabs ?? []).filter(({ components }) => components.some(({ typeName }) => typeName === "PlayerController")).map(({ collectionId, prefabId }) => `${collectionId}:${prefabId}`));
1610
+ var PLAYER_PREFAB_KEYS = new Set((loadBundledFishNetRpcMap2().prefabs ?? []).filter(({ components }) => components.some(({ typeName }) => typeName === "PlayerController")).map(({ collectionId, prefabId }) => `${collectionId}:${prefabId}`));
1278
1611
  function decodeSpawnIdentity(packet) {
1279
1612
  if (!hasPlayerControllerEvidence(packet))
1280
1613
  return;
@@ -1313,7 +1646,7 @@ function decodeCharacterDataName(payload) {
1313
1646
  try {
1314
1647
  let offset = 0;
1315
1648
  if (skipEnum)
1316
- offset = readSignedPackedWhole4(payload, offset).nextOffset;
1649
+ offset = readSignedPackedWhole3(payload, offset).nextOffset;
1317
1650
  const run = decodeFieldRun(payload, [characterNameParameter()], offset);
1318
1651
  const values = new Map(run.fields.map((field2) => [field2.name, field2.value]));
1319
1652
  const uid = values.get("data.UID");
@@ -1717,21 +2050,24 @@ function recordHit(actor, hit, maxTimelineBuckets) {
1717
2050
  if (hit.critical)
1718
2051
  skill.criticalHits += 1;
1719
2052
  }
2053
+ function foldEnemySkill(byEnemy, enemyId, sourceId, sourceLabel, value, critical) {
2054
+ const bySkill = byEnemy.get(enemyId) ?? new Map;
2055
+ byEnemy.set(enemyId, bySkill);
2056
+ const stats = bySkill.get(sourceId) ?? { sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
2057
+ stats.sourceLabel = sourceLabel;
2058
+ stats.damage += value;
2059
+ stats.hits += 1;
2060
+ if (critical)
2061
+ stats.criticalHits += 1;
2062
+ bySkill.set(sourceId, stats);
2063
+ }
1720
2064
  function positiveTau(value) {
1721
2065
  if (!Number.isFinite(value) || value <= 0)
1722
2066
  throw new Error("currentTauSeconds must be a positive finite number");
1723
2067
  return value;
1724
2068
  }
1725
2069
  function recordEnemyHit(encounter, actor, event, observedAtMs, mobIdentities) {
1726
- const bySkill = actor.enemySkills.get(event.targetId) ?? new Map;
1727
- actor.enemySkills.set(event.targetId, bySkill);
1728
- const stats = bySkill.get(event.sourceId) ?? { sourceLabel: event.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
1729
- stats.sourceLabel = event.sourceLabel;
1730
- stats.damage += event.value;
1731
- stats.hits += 1;
1732
- if (event.hitResult === "critical")
1733
- stats.criticalHits += 1;
1734
- bySkill.set(event.sourceId, stats);
2070
+ foldEnemySkill(actor.enemySkills, event.targetId, event.sourceId, event.sourceLabel, event.value, event.hitResult === "critical");
1735
2071
  if (!encounter.enemyFirstSeenAtMs.has(event.targetId)) {
1736
2072
  encounter.enemyFirstSeenAtMs.set(event.targetId, observedAtMs);
1737
2073
  }
@@ -1752,6 +2088,9 @@ function createActor(actorId, encounterStartedAtMs, currentRate = DEFAULT_CURREN
1752
2088
  criticalHits: 0,
1753
2089
  kills: 0,
1754
2090
  skills: new Map,
2091
+ absorbed: 0,
2092
+ absorbedSkills: new Map,
2093
+ absorbedByEnemy: new Map,
1755
2094
  encounterSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
1756
2095
  actorSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
1757
2096
  currentRate: typeof currentRate === "number" ? new EwmaRate({ tauSeconds: currentRate }) : currentRate
@@ -1785,6 +2124,7 @@ function renderEncounter(encounter, options = {}) {
1785
2124
  const durationMs = Math.max(minimumDurationMs, encounter.lastDamageAtMs - encounter.startedAtMs);
1786
2125
  const mergedActors = mergeActors(encounter.actors);
1787
2126
  const totalDamage = mergedActors.reduce((sum, actor) => sum + actor.damage, 0);
2127
+ const totalAbsorbed = mergedActors.reduce((sum, actor) => sum + actor.absorbed, 0);
1788
2128
  const rowForActor = (actor, rowId) => actorRow(actor, rowId, encounter.startedAtMs, durationMs, totalDamage, snapshotNowMs, minimumDurationMs, actor.displayName === undefined, "encounter");
1789
2129
  const displayed = visibleDisplayGroups(mergedActors, encounter, {
1790
2130
  snapshotNowMs,
@@ -1809,6 +2149,7 @@ function renderEncounter(encounter, options = {}) {
1809
2149
  ...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs },
1810
2150
  durationMs,
1811
2151
  totalDamage,
2152
+ ...totalAbsorbed <= 0 ? {} : { totalAbsorbed },
1812
2153
  partyDps: perSecond(totalDamage, durationMs),
1813
2154
  partyCurrentDps,
1814
2155
  actors,
@@ -1826,6 +2167,12 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
1826
2167
  ...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
1827
2168
  })).sort(compareRows);
1828
2169
  const series = alignment === "actor" ? actor.actorSeries : actor.encounterSeries;
2170
+ const absorbedSkills = actor.absorbed <= 0 ? undefined : [...actor.absorbedSkills.values()].map((skill) => ({
2171
+ ...skill,
2172
+ dps: perSecond(skill.damage, durationMs),
2173
+ contribution: skill.damage / actor.absorbed,
2174
+ ...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
2175
+ })).sort(compareRows);
1829
2176
  return {
1830
2177
  rowId,
1831
2178
  actorIds: [...actor.actorIds],
@@ -1834,6 +2181,8 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
1834
2181
  durationMs,
1835
2182
  ...actor.lastDamageAtMs === undefined ? {} : { lastDamageAtMs: actor.lastDamageAtMs },
1836
2183
  damage: actor.damage,
2184
+ ...actor.absorbed <= 0 ? {} : { absorbed: actor.absorbed },
2185
+ ...absorbedSkills === undefined || absorbedSkills.length === 0 ? {} : { absorbedSkills },
1837
2186
  dps: perSecond(actor.damage, durationMs),
1838
2187
  currentDps: actor.currentRate.rateAt(nowMs, { fromMs: startedAtMs, minimumMs: minimumDurationMs }),
1839
2188
  contribution: partyDamage === 0 ? 0 : actor.damage / partyDamage,
@@ -1871,7 +2220,7 @@ function actorRowId(actor) {
1871
2220
  function mergeActors(actors) {
1872
2221
  const merged = new Map;
1873
2222
  for (const actor of actors) {
1874
- if (actor.damage <= 0)
2223
+ if (actor.damage <= 0 && actor.absorbed <= 0)
1875
2224
  continue;
1876
2225
  const displayName = actor.displayName?.trim() || undefined;
1877
2226
  const key = actorRowId(actor);
@@ -1943,6 +2292,21 @@ function accumulate(target, actor) {
1943
2292
  target.skills.set(skill.sourceId, { ...skill });
1944
2293
  }
1945
2294
  }
2295
+ target.absorbed += actor.absorbed;
2296
+ for (const skill of actor.absorbedSkills.values()) {
2297
+ const current = target.absorbedSkills.get(skill.sourceId);
2298
+ if (current) {
2299
+ current.sourceLabel = skill.sourceLabel;
2300
+ current.damage += skill.damage;
2301
+ current.hits += skill.hits;
2302
+ current.criticalHits += skill.criticalHits;
2303
+ } else {
2304
+ target.absorbedSkills.set(skill.sourceId, { ...skill });
2305
+ }
2306
+ }
2307
+ for (const [enemyId, value] of actor.absorbedByEnemy) {
2308
+ target.absorbedByEnemy.set(enemyId, (target.absorbedByEnemy.get(enemyId) ?? 0) + value);
2309
+ }
1946
2310
  }
1947
2311
  function rebase(series, originMs) {
1948
2312
  const shift = Math.round((series.originMs - originMs) / series.widthMs);
@@ -2341,10 +2705,17 @@ function parseDpsLogEvent(value) {
2341
2705
  if (value["kind"] === "heal") {
2342
2706
  if (!isFiniteNumber(value["targetId"]) || !isFiniteNumber(value["value"]))
2343
2707
  return;
2708
+ if (value["attribution"] !== undefined && value["attribution"] !== "exact" && value["attribution"] !== "inferred" && value["attribution"] !== "ambiguous" && value["attribution"] !== "unattributed")
2709
+ return;
2344
2710
  if (value["recoveryStyle"] !== undefined && value["recoveryStyle"] !== "standard" && value["recoveryStyle"] !== "passive-regeneration" && value["recoveryStyle"] !== "drain" && value["recoveryStyle"] !== "unknown")
2345
2711
  return;
2346
2712
  return value;
2347
2713
  }
2714
+ if (value["kind"] === "shield") {
2715
+ if (!isFiniteNumber(value["targetId"]) || !isFiniteNumber(value["value"]) || !isFiniteNumber(value["barrierBefore"]) || !isFiniteNumber(value["barrierAfter"]) || value["action"] !== "gained" && value["action"] !== "absorbed" && value["action"] !== "cleared" && value["action"] !== "reduced" || value["attribution"] !== "exact" && value["attribution"] !== "inferred" && value["attribution"] !== "ambiguous" && value["attribution"] !== "unattributed")
2716
+ return;
2717
+ return value;
2718
+ }
2348
2719
  if (value["kind"] !== "damage" && value["kind"] !== "death" || !isFiniteNumber(value["actorId"]) || !isFiniteNumber(value["value"]) || !isFiniteNumber(value["team"]) || typeof value["sourceId"] !== "string" || typeof value["sourceLabel"] !== "string")
2349
2720
  return;
2350
2721
  if (value["kind"] === "death" && typeof value["duplicatesDamageEvent"] !== "boolean")
@@ -2367,7 +2738,7 @@ function isFiniteNumber(value) {
2367
2738
  return typeof value === "number" && Number.isFinite(value);
2368
2739
  }
2369
2740
  // src/replay-capture.ts
2370
- import { FishNetSessionDecoder, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2, decodeLiteNetLibDatagram } from "@kar-mi/spirit-vale-tools-capture";
2741
+ import { FishNetSessionDecoder, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap3, decodeLiteNetLibDatagram } from "@kar-mi/spirit-vale-tools-capture";
2371
2742
  import { isLogStreamHeader as isLogStreamHeader2, parseLogRecord as parseLogRecord2 } from "@kar-mi/spirit-vale-tools-logging";
2372
2743
  function decodeCombatCaptureJsonLines(text, options) {
2373
2744
  const replay = new CombatCaptureReplay(options);
@@ -2389,7 +2760,7 @@ async function replayCombatCaptures(paths, options) {
2389
2760
 
2390
2761
  class CombatCaptureReplay {
2391
2762
  options;
2392
- session = new FishNetSessionDecoder(loadBundledFishNetRpcMap2());
2763
+ session = new FishNetSessionDecoder(loadBundledFishNetRpcMap3());
2393
2764
  datagrams = 0;
2394
2765
  combatEvents = 0;
2395
2766
  identityEvents = 0;
@@ -2528,7 +2899,7 @@ function duration(milliseconds) {
2528
2899
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
2529
2900
  }
2530
2901
  // src/history/domain.ts
2531
- var COMBAT_DOMAIN_VERSION = 7;
2902
+ var COMBAT_DOMAIN_VERSION = 8;
2532
2903
  var COMBAT_DOMAIN_NAME = "combat";
2533
2904
  var SCHEMA = `
2534
2905
  create table if not exists combat_encounters (
@@ -2581,6 +2952,8 @@ create table if not exists combat_actors (
2581
2952
  hits integer not null default 0,
2582
2953
  critical_hits integer not null default 0,
2583
2954
  kills integer not null default 0,
2955
+ -- Tanked meter only: damage a shield on this actor soaked, kept apart from the damage column (raw damage taken).
2956
+ absorbed integer not null default 0,
2584
2957
  -- The current-DPS rate estimator: its accumulated rate and the time that rate was last advanced
2585
2958
  -- to. Needed so a pass that resumes mid-encounter reports the same currentDps as one that never
2586
2959
  -- stopped. Two numbers, where a trailing window of recent hits used to be serialised here.
@@ -2630,11 +3003,13 @@ create table if not exists combat_timeline_buckets (
2630
3003
  primary key (session_id, encounter_id, meter, actor_index, origin, bucket_index)
2631
3004
  );
2632
3005
 
2633
- -- Per rendered DPS actor lifetime, per enemy, per skill. The actor index prevents a reused network
2634
- -- actor id from assigning the same hit to two rows that are actually different players.
3006
+ -- Per rendered actor lifetime, per enemy, per skill. The meter column is 'dps' (outgoing party
3007
+ -- damage by enemy skill) or 'tanked' (incoming damage by enemy skill). The actor index prevents a
3008
+ -- reused network actor id from assigning the same hit to two rows that are actually different players.
2635
3009
  create table if not exists combat_enemy_skills (
2636
3010
  session_id text not null,
2637
3011
  encounter_id text not null,
3012
+ meter text not null default 'dps',
2638
3013
  actor_index integer not null,
2639
3014
  target_id integer not null,
2640
3015
  source_id text not null,
@@ -2642,7 +3017,7 @@ create table if not exists combat_enemy_skills (
2642
3017
  damage integer not null default 0,
2643
3018
  hits integer not null default 0,
2644
3019
  critical_hits integer not null default 0,
2645
- primary key (session_id, encounter_id, actor_index, target_id, source_id)
3020
+ primary key (session_id, encounter_id, meter, actor_index, target_id, source_id)
2646
3021
  );
2647
3022
 
2648
3023
  create table if not exists combat_enemies (
@@ -2755,15 +3130,15 @@ class MeterReducer {
2755
3130
  actor.archetype = event.archetype;
2756
3131
  actor.activeIdentity = true;
2757
3132
  }
2758
- consumeCombat(event, observedAtMs, identities) {
3133
+ consumeCombat(event, observedAtMs, identities, mobIdentities) {
2759
3134
  if (!this.current)
2760
3135
  return;
2761
3136
  const hit = this.kind === "tanked" ? tankedHit(event, identities) : healingHit(event, identities);
2762
3137
  if (!hit)
2763
3138
  return;
2764
- this.apply(hit, observedAtMs);
3139
+ this.apply(hit, observedAtMs, mobIdentities);
2765
3140
  }
2766
- apply(hit, observedAtMs) {
3141
+ apply(hit, observedAtMs, mobIdentities) {
2767
3142
  const encounter = this.current;
2768
3143
  encounter.lastDamageAtMs = Math.max(encounter.lastDamageAtMs, observedAtMs);
2769
3144
  let actor = encounter.activeActors.get(hit.actorId);
@@ -2778,12 +3153,39 @@ class MeterReducer {
2778
3153
  actor.archetype = hit.identity.archetype;
2779
3154
  actor.activeIdentity = true;
2780
3155
  }
3156
+ if (hit.channel === "absorbed") {
3157
+ recordAbsorbed(actor, hit);
3158
+ return;
3159
+ }
2781
3160
  recordHit(actor, { ...hit, atMs: observedAtMs }, this.maxTimelineBuckets);
2782
3161
  if (hit.counterpartId !== undefined)
2783
3162
  actor.targetIds.add(hit.counterpartId);
3163
+ if (this.kind === "tanked" && hit.counterpartId !== undefined) {
3164
+ const enemyId = hit.counterpartId;
3165
+ actor.targetDamage.set(enemyId, (actor.targetDamage.get(enemyId) ?? 0) + hit.value);
3166
+ foldEnemySkill(actor.enemySkills, enemyId, hit.sourceId, hit.sourceLabel, hit.value, hit.critical);
3167
+ if (!encounter.enemyFirstSeenAtMs.has(enemyId))
3168
+ encounter.enemyFirstSeenAtMs.set(enemyId, observedAtMs);
3169
+ const name = mobIdentities?.get(enemyId);
3170
+ if (name !== undefined)
3171
+ encounter.enemyNames.set(enemyId, name);
3172
+ }
3173
+ }
3174
+ }
3175
+ function recordAbsorbed(actor, hit) {
3176
+ actor.absorbed += hit.value;
3177
+ const skill = actor.absorbedSkills.get(hit.sourceId) ?? { sourceId: hit.sourceId, sourceLabel: hit.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
3178
+ skill.sourceLabel = hit.sourceLabel;
3179
+ skill.damage += hit.value;
3180
+ skill.hits += 1;
3181
+ actor.absorbedSkills.set(hit.sourceId, skill);
3182
+ if (hit.counterpartId !== undefined) {
3183
+ actor.absorbedByEnemy.set(hit.counterpartId, (actor.absorbedByEnemy.get(hit.counterpartId) ?? 0) + hit.value);
2784
3184
  }
2785
3185
  }
2786
3186
  function tankedHit(event, identities) {
3187
+ if (event.kind === "shield")
3188
+ return shieldAbsorbedHit(event, identities);
2787
3189
  if (event.kind !== "damage" && event.kind !== "death")
2788
3190
  return;
2789
3191
  const identity = identities.get(event.targetId);
@@ -2804,11 +3206,15 @@ function tankedHit(event, identities) {
2804
3206
  };
2805
3207
  }
2806
3208
  function healingHit(event, identities) {
3209
+ if (event.kind === "shield")
3210
+ return shieldAppliedHit(event, identities);
2807
3211
  if (event.kind !== "heal")
2808
3212
  return;
2809
3213
  if (!Number.isFinite(event.value) || event.value <= 0)
2810
3214
  return;
2811
- const healerId = event.actorId ?? event.targetId;
3215
+ const healerId = event.actorId;
3216
+ if (healerId === undefined)
3217
+ return;
2812
3218
  const identity = identities.get(healerId);
2813
3219
  if (!identity)
2814
3220
  return;
@@ -2822,8 +3228,49 @@ function healingHit(event, identities) {
2822
3228
  critical: false
2823
3229
  };
2824
3230
  }
3231
+ function shieldAppliedHit(event, identities) {
3232
+ if (event.kind !== "shield" || event.action !== "gained")
3233
+ return;
3234
+ if (!Number.isFinite(event.value) || event.value <= 0)
3235
+ return;
3236
+ const casterId = event.actorId;
3237
+ if (casterId === undefined)
3238
+ return;
3239
+ const identity = identities.get(casterId);
3240
+ if (!identity)
3241
+ return;
3242
+ return {
3243
+ actorId: casterId,
3244
+ identity,
3245
+ counterpartId: event.targetId,
3246
+ value: event.value,
3247
+ sourceId: event.sourceId ?? "shield",
3248
+ sourceLabel: event.sourceLabel ?? "Shield",
3249
+ critical: false
3250
+ };
3251
+ }
3252
+ function shieldAbsorbedHit(event, identities) {
3253
+ if (event.kind !== "shield" || event.action !== "absorbed")
3254
+ return;
3255
+ if (!Number.isFinite(event.value) || event.value <= 0)
3256
+ return;
3257
+ const identity = identities.get(event.targetId);
3258
+ if (!identity)
3259
+ return;
3260
+ return {
3261
+ actorId: event.targetId,
3262
+ identity,
3263
+ ...event.incomingActorId === undefined ? {} : { counterpartId: event.incomingActorId },
3264
+ value: event.value,
3265
+ sourceId: event.incomingSourceId ?? "absorbed:unknown",
3266
+ sourceLabel: event.incomingSourceLabel ?? "Absorbed (unattributed)",
3267
+ critical: false,
3268
+ channel: "absorbed"
3269
+ };
3270
+ }
2825
3271
 
2826
3272
  // src/history/importer.ts
3273
+ var ABSORBED_METER = "absorbed";
2827
3274
  var METER_KINDS = [
2828
3275
  { meter: "tanked", kind: "tanked" },
2829
3276
  { meter: "healing", kind: "healing" }
@@ -2958,12 +3405,12 @@ function consume(reducer, meters, record, note) {
2958
3405
  for (const entry of meters) {
2959
3406
  if (entry.reducer.current?.id !== encounter.id)
2960
3407
  entry.reducer.begin(encounter.id, encounter.startedAtMs);
2961
- entry.reducer.consumeCombat(event, observedAtMs, reducer.identities);
3408
+ entry.reducer.consumeCombat(event, observedAtMs, reducer.identities, reducer.mobIdentities);
2962
3409
  }
2963
3410
  }
2964
3411
  function writeEncounter(model, sessionId, encounter, reducer, meterAggregates) {
2965
3412
  const database = statements(model);
2966
- writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities);
3413
+ writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities, meterAggregates?.get("tanked"));
2967
3414
  const totalDamage = encounter.actors.reduce((sum, actor) => sum + actor.damage, 0);
2968
3415
  database.query(`insert or replace into combat_encounters
2969
3416
  (session_id, encounter_id, started_at_ms, last_damage_at_ms, ended_at_ms, total_damage)
@@ -2983,9 +3430,9 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
2983
3430
  const database = statements(model);
2984
3431
  const insertActor = database.query(`insert or replace into combat_actors
2985
3432
  (session_id, encounter_id, meter, actor_index, actor_id, active_slot, display_name, archetype, owner_connection_id, uid,
2986
- active_identity, damage, first_damage_at_ms, last_damage_at_ms, hits, critical_hits, kills, ewma_rate, ewma_at_ms, ewma_tau_seconds)
3433
+ active_identity, damage, absorbed, first_damage_at_ms, last_damage_at_ms, hits, critical_hits, kills, ewma_rate, ewma_at_ms, ewma_tau_seconds)
2987
3434
  values ($sessionId, $encounterId, $meter, $actorIndex, $actorId, $activeSlot, $displayName, $archetype, $ownerConnectionId, $uid,
2988
- $activeIdentity, $damage, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
3435
+ $activeIdentity, $damage, $absorbed, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
2989
3436
  const insertSkill = database.query(`insert or replace into combat_skills
2990
3437
  (session_id, encounter_id, meter, actor_index, source_id, source_label, damage, hits, critical_hits)
2991
3438
  values ($sessionId, $encounterId, $meter, $actorIndex, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
@@ -2996,8 +3443,8 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
2996
3443
  (session_id, encounter_id, meter, actor_index, origin, origin_ms, width_ms, bucket_index, damage)
2997
3444
  values ($sessionId, $encounterId, $meter, $actorIndex, $origin, $originMs, $widthMs, $bucketIndex, $damage)`);
2998
3445
  const insertEnemySkill = database.query(`insert or replace into combat_enemy_skills
2999
- (session_id, encounter_id, actor_index, target_id, source_id, source_label, damage, hits, critical_hits)
3000
- values ($sessionId, $encounterId, $actorIndex, $targetId, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
3446
+ (session_id, encounter_id, meter, actor_index, target_id, source_id, source_label, damage, hits, critical_hits)
3447
+ values ($sessionId, $encounterId, $meter, $actorIndex, $targetId, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
3001
3448
  const deleteBucketTail = database.query(`delete from combat_timeline_buckets
3002
3449
  where session_id = $sessionId and encounter_id = $encounterId and meter = $meter
3003
3450
  and actor_index = $actorIndex and origin = $origin and bucket_index >= $bucketCount`);
@@ -3015,6 +3462,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3015
3462
  uid: actor.uid ?? null,
3016
3463
  activeIdentity: actor.activeIdentity ? 1 : 0,
3017
3464
  damage: actor.damage,
3465
+ absorbed: actor.absorbed,
3018
3466
  firstDamageAtMs: actor.firstDamageAtMs ?? null,
3019
3467
  lastDamageAtMs: actor.lastDamageAtMs ?? null,
3020
3468
  hits: actor.hits,
@@ -3047,12 +3495,13 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3047
3495
  damage: actor.targetDamage.get(targetId) ?? 0
3048
3496
  });
3049
3497
  }
3050
- if (meter === "dps") {
3498
+ if (meter === "dps" || meter === "tanked") {
3051
3499
  for (const [targetId, bySkill] of actor.enemySkills) {
3052
3500
  for (const [sourceId, stats] of bySkill) {
3053
3501
  insertEnemySkill.run({
3054
3502
  sessionId,
3055
3503
  encounterId,
3504
+ meter,
3056
3505
  actorIndex,
3057
3506
  targetId,
3058
3507
  sourceId,
@@ -3064,6 +3513,24 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3064
3513
  }
3065
3514
  }
3066
3515
  }
3516
+ if (meter === "tanked") {
3517
+ for (const skill of actor.absorbedSkills.values()) {
3518
+ insertSkill.run({
3519
+ sessionId,
3520
+ encounterId,
3521
+ meter: ABSORBED_METER,
3522
+ actorIndex,
3523
+ sourceId: skill.sourceId,
3524
+ sourceLabel: skill.sourceLabel,
3525
+ damage: skill.damage,
3526
+ hits: skill.hits,
3527
+ criticalHits: skill.criticalHits
3528
+ });
3529
+ }
3530
+ for (const [targetId, value] of actor.absorbedByEnemy) {
3531
+ insertTarget.run({ sessionId, encounterId, meter: ABSORBED_METER, actorIndex, targetId, damage: value });
3532
+ }
3533
+ }
3067
3534
  for (const [origin, series] of [["encounter", actor.encounterSeries], ["actor", actor.actorSeries]]) {
3068
3535
  const dirtyFrom = takeDirtyFrom(series);
3069
3536
  if (dirtyFrom === Number.POSITIVE_INFINITY)
@@ -3087,7 +3554,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3087
3554
  }
3088
3555
  }
3089
3556
  }
3090
- function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
3557
+ function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities, tanked) {
3091
3558
  const database = statements(model);
3092
3559
  const scope = { sessionId, encounterId: encounter.id };
3093
3560
  const insertEnemy = database.query(`insert or replace into combat_enemies
@@ -3101,11 +3568,15 @@ function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
3101
3568
  attacker_label, attacker_is_monster, source_label, damage, critical)
3102
3569
  values ($sessionId, $encounterId, $deathIndex, $hitIndex, $beforeDeathMs, $attackerActorId,
3103
3570
  $attackerLabel, $attackerIsMonster, $sourceLabel, $damage, $critical)`);
3104
- for (const [targetId, firstSeenAtMs] of encounter.enemyFirstSeenAtMs) {
3571
+ const enemyFirstSeen = new Map(encounter.enemyFirstSeenAtMs);
3572
+ for (const [targetId, firstSeenAtMs] of tanked?.enemyFirstSeenAtMs ?? []) {
3573
+ enemyFirstSeen.set(targetId, Math.min(firstSeenAtMs, enemyFirstSeen.get(targetId) ?? firstSeenAtMs));
3574
+ }
3575
+ for (const [targetId, firstSeenAtMs] of enemyFirstSeen) {
3105
3576
  insertEnemy.run({
3106
3577
  ...scope,
3107
3578
  targetId,
3108
- displayName: encounter.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
3579
+ displayName: encounter.enemyNames.get(targetId) ?? tanked?.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
3109
3580
  firstSeenAtMs
3110
3581
  });
3111
3582
  }
@@ -3258,7 +3729,7 @@ function loadOpenEncounter(model, sessionId) {
3258
3729
  actor.targetIds.add(target.target_id);
3259
3730
  actor.targetDamage.set(target.target_id, target.damage);
3260
3731
  }
3261
- for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and actor_index = ?").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
3732
+ for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and meter = 'dps' and actor_index = ?").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
3262
3733
  const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
3263
3734
  actor.enemySkills.set(enemySkill.target_id, bySkill);
3264
3735
  bySkill.set(enemySkill.source_id, {
@@ -3302,6 +3773,7 @@ function loadMeterAggregate(model, sessionId, open, meter) {
3302
3773
  actor.archetype = actorRow2.archetype;
3303
3774
  actor.activeIdentity = actorRow2.active_identity === 1;
3304
3775
  actor.damage = actorRow2.damage;
3776
+ actor.absorbed = actorRow2.absorbed;
3305
3777
  if (actorRow2.first_damage_at_ms !== null)
3306
3778
  actor.firstDamageAtMs = actorRow2.first_damage_at_ms;
3307
3779
  if (actorRow2.last_damage_at_ms !== null)
@@ -3326,6 +3798,30 @@ function loadMeterAggregate(model, sessionId, open, meter) {
3326
3798
  actor.targetIds.add(target.target_id);
3327
3799
  actor.targetDamage.set(target.target_id, target.damage);
3328
3800
  }
3801
+ if (meter === "tanked") {
3802
+ for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and meter = 'tanked' and actor_index = ?").all(sessionId, open.id, actorRow2.actor_index)) {
3803
+ const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
3804
+ actor.enemySkills.set(enemySkill.target_id, bySkill);
3805
+ bySkill.set(enemySkill.source_id, {
3806
+ sourceLabel: enemySkill.source_label,
3807
+ damage: enemySkill.damage,
3808
+ hits: enemySkill.hits,
3809
+ criticalHits: enemySkill.critical_hits
3810
+ });
3811
+ }
3812
+ for (const skill of database.query("select source_id, source_label, damage, hits, critical_hits from combat_skills where session_id = ? and encounter_id = ? and meter = 'absorbed' and actor_index = ?").all(sessionId, open.id, actorRow2.actor_index)) {
3813
+ actor.absorbedSkills.set(skill.source_id, {
3814
+ sourceId: skill.source_id,
3815
+ sourceLabel: skill.source_label,
3816
+ damage: skill.damage,
3817
+ hits: skill.hits,
3818
+ criticalHits: skill.critical_hits
3819
+ });
3820
+ }
3821
+ for (const target of database.query("select target_id, damage from combat_targets where session_id = ? and encounter_id = ? and meter = 'absorbed' and actor_index = ?").all(sessionId, open.id, actorRow2.actor_index)) {
3822
+ actor.absorbedByEnemy.set(target.target_id, target.damage);
3823
+ }
3824
+ }
3329
3825
  for (const bucket of database.query("select origin, origin_ms, width_ms, bucket_index, damage from combat_timeline_buckets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ? order by bucket_index").all(sessionId, open.id, meter, actorRow2.actor_index)) {
3330
3826
  const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
3331
3827
  series.originMs = bucket.origin_ms;
@@ -3365,23 +3861,17 @@ class CombatHistoryStore {
3365
3861
  ...rows.length > limit && last ? { nextCursor: encodeCursor({ startedAtMs: last.started_at_ms, encounterId: last.encounter_id }) } : {}
3366
3862
  };
3367
3863
  }
3368
- getEnemyBreakdown(sessionId, encounterId) {
3369
- const enemies = this.model.database.query("select target_id, display_name, first_seen_at_ms from combat_enemies where session_id = ? and encounter_id = ? order by first_seen_at_ms, target_id").all(sessionId, encounterId);
3370
- const counts = new Map;
3371
- for (const enemy of enemies) {
3372
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3373
- counts.set(name, (counts.get(name) ?? 0) + 1);
3864
+ getEnemyBreakdown(sessionId, encounterId, meter = "dps") {
3865
+ const enemyRows = this.model.database.query("select target_id, display_name, first_seen_at_ms from combat_enemies where session_id = ? and encounter_id = ? order by first_seen_at_ms, target_id").all(sessionId, encounterId);
3866
+ const names = new Map(enemyRows.map((enemy) => [enemy.target_id, enemy.display_name]));
3867
+ if (meter === "tanked") {
3868
+ const state = this.model.database.query("select mob_identities_json from combat_stream_state where session_id = ?").get(sessionId);
3869
+ for (const [id, name] of JSON.parse(state?.mob_identities_json ?? "[]")) {
3870
+ if (!names.get(id))
3871
+ names.set(id, name);
3872
+ }
3374
3873
  }
3375
- const nextIndex = new Map;
3376
- const options = enemies.map((enemy) => {
3377
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3378
- if ((counts.get(name) ?? 0) <= 1)
3379
- return { targetId: enemy.target_id, label: name };
3380
- const index = (nextIndex.get(name) ?? 0) + 1;
3381
- nextIndex.set(name, index);
3382
- return { targetId: enemy.target_id, label: `${name} (${index})` };
3383
- });
3384
- const encounter = this.loadEncounter(sessionId, encounterId, "dps", { withTimeline: false });
3874
+ const encounter = this.loadEncounter(sessionId, encounterId, meter, { withTimeline: false });
3385
3875
  const skills = encounter ? displayActorAggregates(encounter, { includeAllAnonymous: true }).flatMap(({ rowId, actor }) => [...actor.enemySkills].flatMap(([targetId, bySkill]) => [...bySkill].map(([sourceId, stats]) => ({
3386
3876
  attackerRowId: rowId,
3387
3877
  targetId,
@@ -3391,6 +3881,21 @@ class CombatHistoryStore {
3391
3881
  hits: stats.hits,
3392
3882
  criticalHits: stats.criticalHits
3393
3883
  })))).sort((left, right) => right.damage - left.damage) : [];
3884
+ const pickerIds = orderedEnemyIds(skills, enemyRows);
3885
+ const counts = new Map;
3886
+ for (const targetId of pickerIds) {
3887
+ const name = names.get(targetId) ?? `Enemy ${targetId}`;
3888
+ counts.set(name, (counts.get(name) ?? 0) + 1);
3889
+ }
3890
+ const nextIndex = new Map;
3891
+ const options = pickerIds.map((targetId) => {
3892
+ const name = names.get(targetId) ?? `Enemy ${targetId}`;
3893
+ if ((counts.get(name) ?? 0) <= 1)
3894
+ return { targetId, label: name };
3895
+ const index = (nextIndex.get(name) ?? 0) + 1;
3896
+ nextIndex.set(name, index);
3897
+ return { targetId, label: `${name} (${index})` };
3898
+ });
3394
3899
  return { encounterId, enemies: options, skills };
3395
3900
  }
3396
3901
  getDeathLog(query) {
@@ -3463,6 +3968,7 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3463
3968
  actor.uid = row.uid;
3464
3969
  actor.activeIdentity = row.active_identity === 1;
3465
3970
  actor.damage = row.damage;
3971
+ actor.absorbed = row.absorbed;
3466
3972
  if (row.first_damage_at_ms !== null)
3467
3973
  actor.firstDamageAtMs = row.first_damage_at_ms;
3468
3974
  if (row.last_damage_at_ms !== null)
@@ -3484,8 +3990,8 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3484
3990
  actor.targetIds.add(target.target_id);
3485
3991
  actor.targetDamage.set(target.target_id, target.damage);
3486
3992
  }
3487
- if (meter === "dps") {
3488
- for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and actor_index = ?").all(sessionId, encounterId, row.actor_index)) {
3993
+ if (meter === "dps" || meter === "tanked") {
3994
+ for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and meter = ? and actor_index = ?").all(sessionId, encounterId, meter, row.actor_index)) {
3489
3995
  const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
3490
3996
  actor.enemySkills.set(enemySkill.target_id, bySkill);
3491
3997
  bySkill.set(enemySkill.source_id, {
@@ -3496,6 +4002,20 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3496
4002
  });
3497
4003
  }
3498
4004
  }
4005
+ if (meter === "tanked") {
4006
+ for (const skill of database.query("select source_id, source_label, damage, hits, critical_hits from combat_skills where session_id = ? and encounter_id = ? and meter = 'absorbed' and actor_index = ?").all(sessionId, encounterId, row.actor_index)) {
4007
+ actor.absorbedSkills.set(skill.source_id, {
4008
+ sourceId: skill.source_id,
4009
+ sourceLabel: skill.source_label,
4010
+ damage: skill.damage,
4011
+ hits: skill.hits,
4012
+ criticalHits: skill.critical_hits
4013
+ });
4014
+ }
4015
+ for (const target of database.query("select target_id, damage from combat_targets where session_id = ? and encounter_id = ? and meter = 'absorbed' and actor_index = ?").all(sessionId, encounterId, row.actor_index)) {
4016
+ actor.absorbedByEnemy.set(target.target_id, target.damage);
4017
+ }
4018
+ }
3499
4019
  if (withTimeline) {
3500
4020
  for (const bucket of database.query("select origin, origin_ms, width_ms, bucket_index, damage from combat_timeline_buckets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ? order by bucket_index").all(sessionId, encounterId, meter, row.actor_index)) {
3501
4021
  const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
@@ -3508,6 +4028,13 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3508
4028
  }
3509
4029
  return actor;
3510
4030
  }
4031
+ function orderedEnemyIds(skills, enemyRows) {
4032
+ const withSkills = new Set(skills.map((skill) => skill.targetId));
4033
+ const ordered = enemyRows.map((enemy) => enemy.target_id).filter((id) => withSkills.has(id));
4034
+ const seen = new Set(ordered);
4035
+ const rest = [...withSkills].filter((id) => !seen.has(id)).sort((left, right) => left - right);
4036
+ return [...ordered, ...rest];
4037
+ }
3511
4038
  function summary(sessionId, row) {
3512
4039
  return {
3513
4040
  sessionId,
@@ -3636,7 +4163,7 @@ class LiveCombatService {
3636
4163
  if (this.meterId === undefined)
3637
4164
  return;
3638
4165
  const identities = this.reducer.identities;
3639
- this.tanked.consumeCombat(event, observedAtMs, identities);
4166
+ this.tanked.consumeCombat(event, observedAtMs, identities, this.reducer.mobIdentities);
3640
4167
  this.healing.consumeCombat(event, observedAtMs, identities);
3641
4168
  this.lastEventAtMs = Math.max(this.lastEventAtMs ?? observedAtMs, observedAtMs);
3642
4169
  }