@kar-mi/spirit-vale-tools-combat 3.3.6 → 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,10 +1,9 @@
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
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";
@@ -69,6 +68,53 @@ function readBoolean(buffer, offset, description) {
69
68
  return { value: value === 1, nextOffset: offset + 1 };
70
69
  }
71
70
 
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";
89
+ }
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
+
72
118
  // src/combat-tracker.ts
73
119
  var HIT_RESULTS = {
74
120
  0: "normal",
@@ -79,9 +125,6 @@ var HIT_RESULTS = {
79
125
  };
80
126
  var SKILL_RPC_NAMES = new Set(["CastBegin_C", "AutoCast_C", "ToggleBegin_C", "CastComplete_C", "CastInterrupt_C", "CastCancel_C"]);
81
127
  var CURRENT_STATUS_COMPONENT_INDICES = new Set([5, 6]);
82
- var CURRENT_HEALTH_COMPONENT_INDICES = new Set([2, 3]);
83
- var HEALING_SKILL_IDS = new Set(["Heal", "HighHeal", "FieldHealing"]);
84
- var REGEN_SKILL_IDS = new Set(["HealAll", "Sanctuary", "GuardianBond", "SanctuaryField"]);
85
128
  var REGEN_STATUS_ID = "Regeneration";
86
129
 
87
130
  class FishNetCombatTracker {
@@ -93,10 +136,17 @@ class FishNetCombatTracker {
93
136
  monsterCatalog;
94
137
  bossCatalog;
95
138
  monsters;
96
- semanticMap;
139
+ healthComponentIndices;
140
+ directHealingSkillIds;
141
+ regenerationSkillIds;
142
+ barrierSkillIds;
143
+ barrierStatusIds;
97
144
  activations = new Map;
98
145
  bossIdentities = new Map;
99
146
  activeRegenSources = new Map;
147
+ bondRegenSources = new Map;
148
+ barriers = new Map;
149
+ recentDamageHits = new Map;
100
150
  summonStacks = new Map;
101
151
  summonSkillSyncState = new Map;
102
152
  authoritativeSummonActors = new Set;
@@ -112,15 +162,17 @@ class FishNetCombatTracker {
112
162
  if (!Number.isInteger(maxAge) || maxAge < 1)
113
163
  throw new Error("activationMaxAgeTicks must be a positive integer");
114
164
  this.activationMaxAgeTicks = maxAge;
115
- 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;
116
166
  assertMatchingBuild("skill catalog", options.skillCatalog?.buildFingerprint, buildFingerprint);
117
- assertMatchingBuild("semantic map", options.semanticMap?.buildFingerprint, buildFingerprint);
118
167
  const skillCatalog = options.skillCatalog ?? tryLoadBundledSkillCatalog(buildFingerprint);
119
- const semanticMap = options.semanticMap ?? (skillCatalog ? tryLoadBundledSemanticMap(buildFingerprint) : loadBundledFishNetSemanticMap(buildFingerprint));
120
- 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;
121
175
  this.skillLabels = new Map(skillCatalog?.skills.map(({ id, displayName }) => [id, displayName]) ?? []);
122
- for (const { value, label } of semanticMap?.verifiedSkillLabels ?? [])
123
- this.skillLabels.set(value, label);
124
176
  this.actorIdentityResolver = options.actorIdentityResolver;
125
177
  this.healingTraitsResolver = options.healingTraitsResolver;
126
178
  this.monsterCatalog = options.monsterCatalog;
@@ -143,12 +195,15 @@ class FishNetCombatTracker {
143
195
  if (this.recentDamageTick !== packet.tick) {
144
196
  this.recentDamageTick = packet.tick;
145
197
  this.recentDamageSignatures.clear();
198
+ this.recentDamageHits.clear();
146
199
  }
147
200
  const events = [
148
201
  ...monsterIdentity ? [monsterIdentity] : [],
149
202
  ...bossLifecycle ? [bossLifecycle] : [],
150
203
  ...summonLifecycle ? [summonLifecycle] : []
151
204
  ];
205
+ this.consumeBondSync(packet);
206
+ events.push(...this.consumeBarrierSync(packet));
152
207
  if (packet.packetName === "syncType" && matchesBehaviour(packet, "SummoningComponent")) {
153
208
  events.push(...this.consumeSummonSkillSync(packet));
154
209
  return events;
@@ -228,10 +283,13 @@ class FishNetCombatTracker {
228
283
  this.activations.clear();
229
284
  this.bossIdentities.clear();
230
285
  this.activeRegenSources.clear();
286
+ this.bondRegenSources.clear();
287
+ this.barriers.clear();
231
288
  this.summonStacks.clear();
232
289
  this.summonSkillSyncState.clear();
233
290
  this.authoritativeSummonActors.clear();
234
291
  this.recentDamageSignatures.clear();
292
+ this.recentDamageHits.clear();
235
293
  this.recentDamageTick = undefined;
236
294
  this.monsters?.reset();
237
295
  }
@@ -299,6 +357,14 @@ class FishNetCombatTracker {
299
357
  actorId,
300
358
  actorIdentity: this.actorIdentityResolver?.(actorId)
301
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
+ }
302
368
  return [
303
369
  ...batch.applies.map((display) => ({
304
370
  ...base,
@@ -320,7 +386,7 @@ class FishNetCombatTracker {
320
386
  rpcName: "ApplyEffectDisplays_O"
321
387
  });
322
388
  }
323
- if (packet.rpcHash !== 1 || !CURRENT_HEALTH_COMPONENT_INDICES.has(packet.networkBehaviourIndex))
389
+ if (packet.rpcHash !== 1 || !this.healthComponentIndices.has(packet.networkBehaviourIndex))
324
390
  return [];
325
391
  let amount;
326
392
  try {
@@ -330,17 +396,18 @@ class FishNetCombatTracker {
330
396
  }
331
397
  if (amount.value < 0)
332
398
  return [];
333
- const settings = packet.payload.subarray(amount.nextOffset);
334
- const settingsHex = settings.toString("hex");
335
- const knownHealthRecovery = this.semanticMap?.recoveryStyles?.some((definition) => definition.networkBehaviourType === "HealthComponent" && definition.rpcName === "Recover_C" && definition.undecodedPayloadHex === settingsHex) ?? false;
336
- if (!knownHealthRecovery)
399
+ const settings = decodeFloaterSettings(packet.payload, amount.nextOffset);
400
+ if (!settings)
337
401
  return [];
338
402
  return [this.consumeRecover({
339
403
  ...packet,
340
404
  networkBehaviourType: "HealthComponent",
341
405
  rpcName: "Recover_C",
342
- decodedFields: [{ name: "amount", codec: "packedInt32", value: amount.value }],
343
- undecodedPayload: settings
406
+ decodedFields: [
407
+ { name: "amount", codec: "packedInt32", value: amount.value },
408
+ ...settings
409
+ ],
410
+ undecodedPayload: undefined
344
411
  })];
345
412
  }
346
413
  consumeSkillDisplay(packet) {
@@ -485,7 +552,7 @@ class FishNetCombatTracker {
485
552
  if (!sourceId)
486
553
  return;
487
554
  const activation2 = this.createActivation(actorId, "skill", packet.tick, sourceId, false);
488
- activation2.targetId = numberField(packet, "targetId");
555
+ activation2.targetId = rpcName === "AutoCast_C" ? numberField(packet, "obj") : numberField(packet, "targetId");
489
556
  return {
490
557
  kind: "activation",
491
558
  rpc: rpcName,
@@ -570,6 +637,8 @@ class FishNetCombatTracker {
570
637
  actorIdentity: this.actorIdentityResolver?.(actorId)
571
638
  }];
572
639
  }
640
+ if (value >= 0)
641
+ this.recentDamageHits.set(packet.objectId, { actorId, sourceId, sourceLabel });
573
642
  const hitCode = requiredNumberField(packet, "dmg.Hit");
574
643
  const hitResult = HIT_RESULTS[hitCode] ?? hitCode;
575
644
  const targetId = packet.objectId;
@@ -669,7 +738,7 @@ class FishNetCombatTracker {
669
738
  consumeRecover(packet) {
670
739
  const targetId = packet.objectId;
671
740
  const value = requiredNumberField(packet, "amount");
672
- const recoveryStyle = classifyFishNetRecoveryStyle(packet, this.semanticMap);
741
+ const recoveryStyle = classifyFishNetRecoveryStyle(packet);
673
742
  if (recoveryStyle === "passive-regeneration") {
674
743
  return this.createSelfRecovery(packet, value, recoveryStyle, "passive-regeneration", "Passive regeneration");
675
744
  }
@@ -677,7 +746,7 @@ class FishNetCombatTracker {
677
746
  const source = drainRecoverySource(this.healingTraitsResolver?.(targetId));
678
747
  return this.createSelfRecovery(packet, value, recoveryStyle, source.sourceId, source.sourceLabel);
679
748
  }
680
- const candidates = this.eligibleHealActivations(packet.tick, targetId, HEALING_SKILL_IDS);
749
+ const candidates = this.eligibleHealActivations(packet.tick, targetId, this.directHealingSkillIds);
681
750
  let attribution;
682
751
  let actorId;
683
752
  let sourceId;
@@ -688,7 +757,7 @@ class FishNetCombatTracker {
688
757
  const [candidate] = candidates;
689
758
  if (!candidate)
690
759
  throw new Error("missing combat activation candidate");
691
- attribution = candidate.inferred ? "inferred" : "exact";
760
+ attribution = "inferred";
692
761
  actorId = candidate.actorId;
693
762
  sourceId = candidate.sourceId;
694
763
  sourceLabel = candidate.sourceLabel;
@@ -697,8 +766,8 @@ class FishNetCombatTracker {
697
766
  attribution = "ambiguous";
698
767
  candidateActivationIds = candidates.map(({ id }) => id);
699
768
  } else {
700
- const regenSource = this.activeRegenSources.get(targetId);
701
- if (regenSource?.candidateActivationIds) {
769
+ const regenSource = this.activeRegenSources.get(targetId) ?? this.bondRegenSources.get(targetId);
770
+ if (regenSource?.candidateActivationIds || regenSource?.ambiguous) {
702
771
  attribution = "ambiguous";
703
772
  candidateActivationIds = regenSource.candidateActivationIds;
704
773
  } else if (regenSource?.actorId !== undefined) {
@@ -729,6 +798,101 @@ class FishNetCombatTracker {
729
798
  actorIdentity: actorId === undefined ? undefined : this.actorIdentityResolver?.(actorId)
730
799
  };
731
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
+ }
732
896
  createSelfRecovery(packet, value, recoveryStyle, sourceId, sourceLabel) {
733
897
  const actorId = packet.objectId;
734
898
  return {
@@ -758,7 +922,7 @@ class FishNetCombatTracker {
758
922
  const actorId = packet.objectId;
759
923
  if (statusId === REGEN_STATUS_ID) {
760
924
  if (packet.rpcName === "ApplyEffect_T") {
761
- const candidates = this.eligibleHealActivations(packet.tick, actorId, REGEN_SKILL_IDS);
925
+ const candidates = this.eligibleHealActivations(packet.tick, actorId, this.regenerationSkillIds);
762
926
  if (candidates.length === 1) {
763
927
  const [candidate] = candidates;
764
928
  if (!candidate)
@@ -778,6 +942,9 @@ class FishNetCombatTracker {
778
942
  this.activeRegenSources.delete(actorId);
779
943
  }
780
944
  }
945
+ if (this.barrierStatusIds.has(statusId)) {
946
+ this.observeBarrierStatus(actorId, packet.rpcName === "ApplyEffect_T", packet.tick);
947
+ }
781
948
  return {
782
949
  kind: "status",
783
950
  rpc: packet.rpcName,
@@ -791,6 +958,15 @@ class FishNetCombatTracker {
791
958
  actorIdentity: this.actorIdentityResolver?.(actorId)
792
959
  };
793
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
+ }
794
970
  createActivation(actorId, actionKind, tick, sourceId, inferred) {
795
971
  const activation = {
796
972
  id: `activation-${this.nextActivation++}`,
@@ -827,6 +1003,102 @@ function drainRecoverySource(traits) {
827
1003
  }
828
1004
  return { sourceId: "siphon-health-leech", sourceLabel: "Siphon / Health Leech" };
829
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
+ }
830
1102
  function field(packet, name) {
831
1103
  return packet.decodedFields?.find((candidate) => candidate.name === name)?.value;
832
1104
  }
@@ -916,7 +1188,7 @@ function isCompleteDamagePacket(packet, requireVectors) {
916
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")));
917
1189
  }
918
1190
  function isCompleteRecoverPacket(packet) {
919
- 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);
920
1192
  }
921
1193
  function nullableStringField(packet, name) {
922
1194
  const value = field(packet, name);
@@ -950,9 +1222,9 @@ function tryLoadBundledSkillCatalog(buildFingerprint) {
950
1222
  return;
951
1223
  }
952
1224
  }
953
- function tryLoadBundledSemanticMap(buildFingerprint) {
1225
+ function tryLoadBundledRpcMap(buildFingerprint) {
954
1226
  try {
955
- return loadBundledFishNetSemanticMap(buildFingerprint);
1227
+ return loadBundledFishNetRpcMap(buildFingerprint);
956
1228
  } catch {
957
1229
  return;
958
1230
  }
@@ -969,7 +1241,7 @@ function createBossCatalog(entries) {
969
1241
  }
970
1242
  var CURRENT_BOSS_SKILL_NAMES = createBossCatalog({});
971
1243
  // src/actor-directory.ts
972
- import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap } from "@kar-mi/spirit-vale-tools-capture";
1244
+ import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2 } from "@kar-mi/spirit-vale-tools-capture";
973
1245
  import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
974
1246
 
975
1247
  class FishNetActorDirectory {
@@ -1335,7 +1607,7 @@ function hasMonsterIdentityEvidence(packet) {
1335
1607
  return typeof mobId === "string" && mobId.length > 0;
1336
1608
  }
1337
1609
  var VISUAL_DATA_SYNC_INDEX = 5;
1338
- 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}`));
1339
1611
  function decodeSpawnIdentity(packet) {
1340
1612
  if (!hasPlayerControllerEvidence(packet))
1341
1613
  return;
@@ -1778,21 +2050,24 @@ function recordHit(actor, hit, maxTimelineBuckets) {
1778
2050
  if (hit.critical)
1779
2051
  skill.criticalHits += 1;
1780
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
+ }
1781
2064
  function positiveTau(value) {
1782
2065
  if (!Number.isFinite(value) || value <= 0)
1783
2066
  throw new Error("currentTauSeconds must be a positive finite number");
1784
2067
  return value;
1785
2068
  }
1786
2069
  function recordEnemyHit(encounter, actor, event, observedAtMs, mobIdentities) {
1787
- const bySkill = actor.enemySkills.get(event.targetId) ?? new Map;
1788
- actor.enemySkills.set(event.targetId, bySkill);
1789
- const stats = bySkill.get(event.sourceId) ?? { sourceLabel: event.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
1790
- stats.sourceLabel = event.sourceLabel;
1791
- stats.damage += event.value;
1792
- stats.hits += 1;
1793
- if (event.hitResult === "critical")
1794
- stats.criticalHits += 1;
1795
- bySkill.set(event.sourceId, stats);
2070
+ foldEnemySkill(actor.enemySkills, event.targetId, event.sourceId, event.sourceLabel, event.value, event.hitResult === "critical");
1796
2071
  if (!encounter.enemyFirstSeenAtMs.has(event.targetId)) {
1797
2072
  encounter.enemyFirstSeenAtMs.set(event.targetId, observedAtMs);
1798
2073
  }
@@ -1813,6 +2088,9 @@ function createActor(actorId, encounterStartedAtMs, currentRate = DEFAULT_CURREN
1813
2088
  criticalHits: 0,
1814
2089
  kills: 0,
1815
2090
  skills: new Map,
2091
+ absorbed: 0,
2092
+ absorbedSkills: new Map,
2093
+ absorbedByEnemy: new Map,
1816
2094
  encounterSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
1817
2095
  actorSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
1818
2096
  currentRate: typeof currentRate === "number" ? new EwmaRate({ tauSeconds: currentRate }) : currentRate
@@ -1846,6 +2124,7 @@ function renderEncounter(encounter, options = {}) {
1846
2124
  const durationMs = Math.max(minimumDurationMs, encounter.lastDamageAtMs - encounter.startedAtMs);
1847
2125
  const mergedActors = mergeActors(encounter.actors);
1848
2126
  const totalDamage = mergedActors.reduce((sum, actor) => sum + actor.damage, 0);
2127
+ const totalAbsorbed = mergedActors.reduce((sum, actor) => sum + actor.absorbed, 0);
1849
2128
  const rowForActor = (actor, rowId) => actorRow(actor, rowId, encounter.startedAtMs, durationMs, totalDamage, snapshotNowMs, minimumDurationMs, actor.displayName === undefined, "encounter");
1850
2129
  const displayed = visibleDisplayGroups(mergedActors, encounter, {
1851
2130
  snapshotNowMs,
@@ -1870,6 +2149,7 @@ function renderEncounter(encounter, options = {}) {
1870
2149
  ...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs },
1871
2150
  durationMs,
1872
2151
  totalDamage,
2152
+ ...totalAbsorbed <= 0 ? {} : { totalAbsorbed },
1873
2153
  partyDps: perSecond(totalDamage, durationMs),
1874
2154
  partyCurrentDps,
1875
2155
  actors,
@@ -1887,6 +2167,12 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
1887
2167
  ...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
1888
2168
  })).sort(compareRows);
1889
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);
1890
2176
  return {
1891
2177
  rowId,
1892
2178
  actorIds: [...actor.actorIds],
@@ -1895,6 +2181,8 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
1895
2181
  durationMs,
1896
2182
  ...actor.lastDamageAtMs === undefined ? {} : { lastDamageAtMs: actor.lastDamageAtMs },
1897
2183
  damage: actor.damage,
2184
+ ...actor.absorbed <= 0 ? {} : { absorbed: actor.absorbed },
2185
+ ...absorbedSkills === undefined || absorbedSkills.length === 0 ? {} : { absorbedSkills },
1898
2186
  dps: perSecond(actor.damage, durationMs),
1899
2187
  currentDps: actor.currentRate.rateAt(nowMs, { fromMs: startedAtMs, minimumMs: minimumDurationMs }),
1900
2188
  contribution: partyDamage === 0 ? 0 : actor.damage / partyDamage,
@@ -1932,7 +2220,7 @@ function actorRowId(actor) {
1932
2220
  function mergeActors(actors) {
1933
2221
  const merged = new Map;
1934
2222
  for (const actor of actors) {
1935
- if (actor.damage <= 0)
2223
+ if (actor.damage <= 0 && actor.absorbed <= 0)
1936
2224
  continue;
1937
2225
  const displayName = actor.displayName?.trim() || undefined;
1938
2226
  const key = actorRowId(actor);
@@ -2004,6 +2292,21 @@ function accumulate(target, actor) {
2004
2292
  target.skills.set(skill.sourceId, { ...skill });
2005
2293
  }
2006
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
+ }
2007
2310
  }
2008
2311
  function rebase(series, originMs) {
2009
2312
  const shift = Math.round((series.originMs - originMs) / series.widthMs);
@@ -2402,10 +2705,17 @@ function parseDpsLogEvent(value) {
2402
2705
  if (value["kind"] === "heal") {
2403
2706
  if (!isFiniteNumber(value["targetId"]) || !isFiniteNumber(value["value"]))
2404
2707
  return;
2708
+ if (value["attribution"] !== undefined && value["attribution"] !== "exact" && value["attribution"] !== "inferred" && value["attribution"] !== "ambiguous" && value["attribution"] !== "unattributed")
2709
+ return;
2405
2710
  if (value["recoveryStyle"] !== undefined && value["recoveryStyle"] !== "standard" && value["recoveryStyle"] !== "passive-regeneration" && value["recoveryStyle"] !== "drain" && value["recoveryStyle"] !== "unknown")
2406
2711
  return;
2407
2712
  return value;
2408
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
+ }
2409
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")
2410
2720
  return;
2411
2721
  if (value["kind"] === "death" && typeof value["duplicatesDamageEvent"] !== "boolean")
@@ -2428,7 +2738,7 @@ function isFiniteNumber(value) {
2428
2738
  return typeof value === "number" && Number.isFinite(value);
2429
2739
  }
2430
2740
  // src/replay-capture.ts
2431
- 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";
2432
2742
  import { isLogStreamHeader as isLogStreamHeader2, parseLogRecord as parseLogRecord2 } from "@kar-mi/spirit-vale-tools-logging";
2433
2743
  function decodeCombatCaptureJsonLines(text, options) {
2434
2744
  const replay = new CombatCaptureReplay(options);
@@ -2450,7 +2760,7 @@ async function replayCombatCaptures(paths, options) {
2450
2760
 
2451
2761
  class CombatCaptureReplay {
2452
2762
  options;
2453
- session = new FishNetSessionDecoder(loadBundledFishNetRpcMap2());
2763
+ session = new FishNetSessionDecoder(loadBundledFishNetRpcMap3());
2454
2764
  datagrams = 0;
2455
2765
  combatEvents = 0;
2456
2766
  identityEvents = 0;
@@ -2589,7 +2899,7 @@ function duration(milliseconds) {
2589
2899
  return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
2590
2900
  }
2591
2901
  // src/history/domain.ts
2592
- var COMBAT_DOMAIN_VERSION = 7;
2902
+ var COMBAT_DOMAIN_VERSION = 8;
2593
2903
  var COMBAT_DOMAIN_NAME = "combat";
2594
2904
  var SCHEMA = `
2595
2905
  create table if not exists combat_encounters (
@@ -2642,6 +2952,8 @@ create table if not exists combat_actors (
2642
2952
  hits integer not null default 0,
2643
2953
  critical_hits integer not null default 0,
2644
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,
2645
2957
  -- The current-DPS rate estimator: its accumulated rate and the time that rate was last advanced
2646
2958
  -- to. Needed so a pass that resumes mid-encounter reports the same currentDps as one that never
2647
2959
  -- stopped. Two numbers, where a trailing window of recent hits used to be serialised here.
@@ -2691,11 +3003,13 @@ create table if not exists combat_timeline_buckets (
2691
3003
  primary key (session_id, encounter_id, meter, actor_index, origin, bucket_index)
2692
3004
  );
2693
3005
 
2694
- -- Per rendered DPS actor lifetime, per enemy, per skill. The actor index prevents a reused network
2695
- -- 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.
2696
3009
  create table if not exists combat_enemy_skills (
2697
3010
  session_id text not null,
2698
3011
  encounter_id text not null,
3012
+ meter text not null default 'dps',
2699
3013
  actor_index integer not null,
2700
3014
  target_id integer not null,
2701
3015
  source_id text not null,
@@ -2703,7 +3017,7 @@ create table if not exists combat_enemy_skills (
2703
3017
  damage integer not null default 0,
2704
3018
  hits integer not null default 0,
2705
3019
  critical_hits integer not null default 0,
2706
- 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)
2707
3021
  );
2708
3022
 
2709
3023
  create table if not exists combat_enemies (
@@ -2816,15 +3130,15 @@ class MeterReducer {
2816
3130
  actor.archetype = event.archetype;
2817
3131
  actor.activeIdentity = true;
2818
3132
  }
2819
- consumeCombat(event, observedAtMs, identities) {
3133
+ consumeCombat(event, observedAtMs, identities, mobIdentities) {
2820
3134
  if (!this.current)
2821
3135
  return;
2822
3136
  const hit = this.kind === "tanked" ? tankedHit(event, identities) : healingHit(event, identities);
2823
3137
  if (!hit)
2824
3138
  return;
2825
- this.apply(hit, observedAtMs);
3139
+ this.apply(hit, observedAtMs, mobIdentities);
2826
3140
  }
2827
- apply(hit, observedAtMs) {
3141
+ apply(hit, observedAtMs, mobIdentities) {
2828
3142
  const encounter = this.current;
2829
3143
  encounter.lastDamageAtMs = Math.max(encounter.lastDamageAtMs, observedAtMs);
2830
3144
  let actor = encounter.activeActors.get(hit.actorId);
@@ -2839,12 +3153,39 @@ class MeterReducer {
2839
3153
  actor.archetype = hit.identity.archetype;
2840
3154
  actor.activeIdentity = true;
2841
3155
  }
3156
+ if (hit.channel === "absorbed") {
3157
+ recordAbsorbed(actor, hit);
3158
+ return;
3159
+ }
2842
3160
  recordHit(actor, { ...hit, atMs: observedAtMs }, this.maxTimelineBuckets);
2843
3161
  if (hit.counterpartId !== undefined)
2844
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);
2845
3184
  }
2846
3185
  }
2847
3186
  function tankedHit(event, identities) {
3187
+ if (event.kind === "shield")
3188
+ return shieldAbsorbedHit(event, identities);
2848
3189
  if (event.kind !== "damage" && event.kind !== "death")
2849
3190
  return;
2850
3191
  const identity = identities.get(event.targetId);
@@ -2865,11 +3206,15 @@ function tankedHit(event, identities) {
2865
3206
  };
2866
3207
  }
2867
3208
  function healingHit(event, identities) {
3209
+ if (event.kind === "shield")
3210
+ return shieldAppliedHit(event, identities);
2868
3211
  if (event.kind !== "heal")
2869
3212
  return;
2870
3213
  if (!Number.isFinite(event.value) || event.value <= 0)
2871
3214
  return;
2872
- const healerId = event.actorId ?? event.targetId;
3215
+ const healerId = event.actorId;
3216
+ if (healerId === undefined)
3217
+ return;
2873
3218
  const identity = identities.get(healerId);
2874
3219
  if (!identity)
2875
3220
  return;
@@ -2883,8 +3228,49 @@ function healingHit(event, identities) {
2883
3228
  critical: false
2884
3229
  };
2885
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
+ }
2886
3271
 
2887
3272
  // src/history/importer.ts
3273
+ var ABSORBED_METER = "absorbed";
2888
3274
  var METER_KINDS = [
2889
3275
  { meter: "tanked", kind: "tanked" },
2890
3276
  { meter: "healing", kind: "healing" }
@@ -3019,12 +3405,12 @@ function consume(reducer, meters, record, note) {
3019
3405
  for (const entry of meters) {
3020
3406
  if (entry.reducer.current?.id !== encounter.id)
3021
3407
  entry.reducer.begin(encounter.id, encounter.startedAtMs);
3022
- entry.reducer.consumeCombat(event, observedAtMs, reducer.identities);
3408
+ entry.reducer.consumeCombat(event, observedAtMs, reducer.identities, reducer.mobIdentities);
3023
3409
  }
3024
3410
  }
3025
3411
  function writeEncounter(model, sessionId, encounter, reducer, meterAggregates) {
3026
3412
  const database = statements(model);
3027
- writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities);
3413
+ writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities, meterAggregates?.get("tanked"));
3028
3414
  const totalDamage = encounter.actors.reduce((sum, actor) => sum + actor.damage, 0);
3029
3415
  database.query(`insert or replace into combat_encounters
3030
3416
  (session_id, encounter_id, started_at_ms, last_damage_at_ms, ended_at_ms, total_damage)
@@ -3044,9 +3430,9 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3044
3430
  const database = statements(model);
3045
3431
  const insertActor = database.query(`insert or replace into combat_actors
3046
3432
  (session_id, encounter_id, meter, actor_index, actor_id, active_slot, display_name, archetype, owner_connection_id, uid,
3047
- 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)
3048
3434
  values ($sessionId, $encounterId, $meter, $actorIndex, $actorId, $activeSlot, $displayName, $archetype, $ownerConnectionId, $uid,
3049
- $activeIdentity, $damage, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
3435
+ $activeIdentity, $damage, $absorbed, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
3050
3436
  const insertSkill = database.query(`insert or replace into combat_skills
3051
3437
  (session_id, encounter_id, meter, actor_index, source_id, source_label, damage, hits, critical_hits)
3052
3438
  values ($sessionId, $encounterId, $meter, $actorIndex, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
@@ -3057,8 +3443,8 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3057
3443
  (session_id, encounter_id, meter, actor_index, origin, origin_ms, width_ms, bucket_index, damage)
3058
3444
  values ($sessionId, $encounterId, $meter, $actorIndex, $origin, $originMs, $widthMs, $bucketIndex, $damage)`);
3059
3445
  const insertEnemySkill = database.query(`insert or replace into combat_enemy_skills
3060
- (session_id, encounter_id, actor_index, target_id, source_id, source_label, damage, hits, critical_hits)
3061
- 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)`);
3062
3448
  const deleteBucketTail = database.query(`delete from combat_timeline_buckets
3063
3449
  where session_id = $sessionId and encounter_id = $encounterId and meter = $meter
3064
3450
  and actor_index = $actorIndex and origin = $origin and bucket_index >= $bucketCount`);
@@ -3076,6 +3462,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3076
3462
  uid: actor.uid ?? null,
3077
3463
  activeIdentity: actor.activeIdentity ? 1 : 0,
3078
3464
  damage: actor.damage,
3465
+ absorbed: actor.absorbed,
3079
3466
  firstDamageAtMs: actor.firstDamageAtMs ?? null,
3080
3467
  lastDamageAtMs: actor.lastDamageAtMs ?? null,
3081
3468
  hits: actor.hits,
@@ -3108,12 +3495,13 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3108
3495
  damage: actor.targetDamage.get(targetId) ?? 0
3109
3496
  });
3110
3497
  }
3111
- if (meter === "dps") {
3498
+ if (meter === "dps" || meter === "tanked") {
3112
3499
  for (const [targetId, bySkill] of actor.enemySkills) {
3113
3500
  for (const [sourceId, stats] of bySkill) {
3114
3501
  insertEnemySkill.run({
3115
3502
  sessionId,
3116
3503
  encounterId,
3504
+ meter,
3117
3505
  actorIndex,
3118
3506
  targetId,
3119
3507
  sourceId,
@@ -3125,6 +3513,24 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3125
3513
  }
3126
3514
  }
3127
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
+ }
3128
3534
  for (const [origin, series] of [["encounter", actor.encounterSeries], ["actor", actor.actorSeries]]) {
3129
3535
  const dirtyFrom = takeDirtyFrom(series);
3130
3536
  if (dirtyFrom === Number.POSITIVE_INFINITY)
@@ -3148,7 +3554,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
3148
3554
  }
3149
3555
  }
3150
3556
  }
3151
- function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
3557
+ function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities, tanked) {
3152
3558
  const database = statements(model);
3153
3559
  const scope = { sessionId, encounterId: encounter.id };
3154
3560
  const insertEnemy = database.query(`insert or replace into combat_enemies
@@ -3162,11 +3568,15 @@ function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
3162
3568
  attacker_label, attacker_is_monster, source_label, damage, critical)
3163
3569
  values ($sessionId, $encounterId, $deathIndex, $hitIndex, $beforeDeathMs, $attackerActorId,
3164
3570
  $attackerLabel, $attackerIsMonster, $sourceLabel, $damage, $critical)`);
3165
- 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) {
3166
3576
  insertEnemy.run({
3167
3577
  ...scope,
3168
3578
  targetId,
3169
- displayName: encounter.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
3579
+ displayName: encounter.enemyNames.get(targetId) ?? tanked?.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
3170
3580
  firstSeenAtMs
3171
3581
  });
3172
3582
  }
@@ -3319,7 +3729,7 @@ function loadOpenEncounter(model, sessionId) {
3319
3729
  actor.targetIds.add(target.target_id);
3320
3730
  actor.targetDamage.set(target.target_id, target.damage);
3321
3731
  }
3322
- 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)) {
3323
3733
  const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
3324
3734
  actor.enemySkills.set(enemySkill.target_id, bySkill);
3325
3735
  bySkill.set(enemySkill.source_id, {
@@ -3363,6 +3773,7 @@ function loadMeterAggregate(model, sessionId, open, meter) {
3363
3773
  actor.archetype = actorRow2.archetype;
3364
3774
  actor.activeIdentity = actorRow2.active_identity === 1;
3365
3775
  actor.damage = actorRow2.damage;
3776
+ actor.absorbed = actorRow2.absorbed;
3366
3777
  if (actorRow2.first_damage_at_ms !== null)
3367
3778
  actor.firstDamageAtMs = actorRow2.first_damage_at_ms;
3368
3779
  if (actorRow2.last_damage_at_ms !== null)
@@ -3387,6 +3798,30 @@ function loadMeterAggregate(model, sessionId, open, meter) {
3387
3798
  actor.targetIds.add(target.target_id);
3388
3799
  actor.targetDamage.set(target.target_id, target.damage);
3389
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
+ }
3390
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)) {
3391
3826
  const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
3392
3827
  series.originMs = bucket.origin_ms;
@@ -3426,23 +3861,17 @@ class CombatHistoryStore {
3426
3861
  ...rows.length > limit && last ? { nextCursor: encodeCursor({ startedAtMs: last.started_at_ms, encounterId: last.encounter_id }) } : {}
3427
3862
  };
3428
3863
  }
3429
- getEnemyBreakdown(sessionId, encounterId) {
3430
- 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);
3431
- const counts = new Map;
3432
- for (const enemy of enemies) {
3433
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3434
- 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
+ }
3435
3873
  }
3436
- const nextIndex = new Map;
3437
- const options = enemies.map((enemy) => {
3438
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3439
- if ((counts.get(name) ?? 0) <= 1)
3440
- return { targetId: enemy.target_id, label: name };
3441
- const index = (nextIndex.get(name) ?? 0) + 1;
3442
- nextIndex.set(name, index);
3443
- return { targetId: enemy.target_id, label: `${name} (${index})` };
3444
- });
3445
- const encounter = this.loadEncounter(sessionId, encounterId, "dps", { withTimeline: false });
3874
+ const encounter = this.loadEncounter(sessionId, encounterId, meter, { withTimeline: false });
3446
3875
  const skills = encounter ? displayActorAggregates(encounter, { includeAllAnonymous: true }).flatMap(({ rowId, actor }) => [...actor.enemySkills].flatMap(([targetId, bySkill]) => [...bySkill].map(([sourceId, stats]) => ({
3447
3876
  attackerRowId: rowId,
3448
3877
  targetId,
@@ -3452,6 +3881,21 @@ class CombatHistoryStore {
3452
3881
  hits: stats.hits,
3453
3882
  criticalHits: stats.criticalHits
3454
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
+ });
3455
3899
  return { encounterId, enemies: options, skills };
3456
3900
  }
3457
3901
  getDeathLog(query) {
@@ -3524,6 +3968,7 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3524
3968
  actor.uid = row.uid;
3525
3969
  actor.activeIdentity = row.active_identity === 1;
3526
3970
  actor.damage = row.damage;
3971
+ actor.absorbed = row.absorbed;
3527
3972
  if (row.first_damage_at_ms !== null)
3528
3973
  actor.firstDamageAtMs = row.first_damage_at_ms;
3529
3974
  if (row.last_damage_at_ms !== null)
@@ -3545,8 +3990,8 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3545
3990
  actor.targetIds.add(target.target_id);
3546
3991
  actor.targetDamage.set(target.target_id, target.damage);
3547
3992
  }
3548
- if (meter === "dps") {
3549
- 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)) {
3550
3995
  const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
3551
3996
  actor.enemySkills.set(enemySkill.target_id, bySkill);
3552
3997
  bySkill.set(enemySkill.source_id, {
@@ -3557,6 +4002,20 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3557
4002
  });
3558
4003
  }
3559
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
+ }
3560
4019
  if (withTimeline) {
3561
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)) {
3562
4021
  const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
@@ -3569,6 +4028,13 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
3569
4028
  }
3570
4029
  return actor;
3571
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
+ }
3572
4038
  function summary(sessionId, row) {
3573
4039
  return {
3574
4040
  sessionId,
@@ -3697,7 +4163,7 @@ class LiveCombatService {
3697
4163
  if (this.meterId === undefined)
3698
4164
  return;
3699
4165
  const identities = this.reducer.identities;
3700
- this.tanked.consumeCombat(event, observedAtMs, identities);
4166
+ this.tanked.consumeCombat(event, observedAtMs, identities, this.reducer.mobIdentities);
3701
4167
  this.healing.consumeCombat(event, observedAtMs, identities);
3702
4168
  this.lastEventAtMs = Math.max(this.lastEventAtMs ?? observedAtMs, observedAtMs);
3703
4169
  }