@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/combat-tracker.d.ts +42 -5
- package/dist/combat-tracker.d.ts.map +1 -1
- package/dist/generated/combat-semantics.d.ts +7 -0
- package/dist/generated/combat-semantics.d.ts.map +1 -0
- package/dist/history/domain.d.ts +1 -1
- package/dist/history/domain.d.ts.map +1 -1
- package/dist/history/importer.d.ts.map +1 -1
- package/dist/history/store.d.ts +6 -2
- package/dist/history/store.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +547 -81
- package/dist/inference/recovery-style.d.ts +5 -0
- package/dist/inference/recovery-style.d.ts.map +1 -0
- package/dist/reducers/damage.d.ts +8 -0
- package/dist/reducers/damage.d.ts.map +1 -1
- package/dist/reducers/meter.d.ts +1 -1
- package/dist/reducers/meter.d.ts.map +1 -1
- package/dist/reducers/rows.d.ts.map +1 -1
- package/dist/replay.d.ts.map +1 -1
- package/dist/snapshot.d.ts +6 -0
- package/dist/snapshot.d.ts.map +1 -1
- package/package.json +6 -6
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
|
-
|
|
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
|
-
|
|
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 ??
|
|
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
|
|
120
|
-
this.
|
|
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 || !
|
|
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
|
|
334
|
-
|
|
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: [
|
|
343
|
-
|
|
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
|
|
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,
|
|
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 =
|
|
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,
|
|
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
|
|
1225
|
+
function tryLoadBundledRpcMap(buildFingerprint) {
|
|
954
1226
|
try {
|
|
955
|
-
return
|
|
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((
|
|
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
|
-
|
|
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
|
|
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(
|
|
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 =
|
|
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
|
|
2695
|
-
--
|
|
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
|
|
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
|
-
|
|
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
|
|
3431
|
-
const
|
|
3432
|
-
|
|
3433
|
-
const
|
|
3434
|
-
|
|
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
|
|
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
|
}
|