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