@kar-mi/spirit-vale-tools-combat 3.3.6 → 5.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/events/combat-decoding.d.ts +41 -0
- package/dist/events/combat-decoding.d.ts.map +1 -0
- package/dist/{combat-tracker.d.ts → events/combat-events.d.ts} +29 -112
- package/dist/events/combat-events.d.ts.map +1 -0
- package/dist/events/damage-observer.d.ts +12 -0
- package/dist/events/damage-observer.d.ts.map +1 -0
- package/dist/events/decoded-fields.d.ts +5 -0
- package/dist/events/decoded-fields.d.ts.map +1 -0
- package/dist/events/effect-display.d.ts.map +1 -0
- package/dist/events/generated/combat-semantics.d.ts +7 -0
- package/dist/events/generated/combat-semantics.d.ts.map +1 -0
- package/dist/events/inference/recovery-style.d.ts +5 -0
- package/dist/events/inference/recovery-style.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 +8 -4
- package/dist/history/store.d.ts.map +1 -1
- package/dist/index.d.ts +26 -20
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1057 -613
- package/dist/reducers/damage.d.ts +10 -2
- package/dist/reducers/damage.d.ts.map +1 -1
- package/dist/reducers/meter-group.d.ts +21 -0
- package/dist/reducers/meter-group.d.ts.map +1 -0
- package/dist/reducers/meter.d.ts +3 -3
- package/dist/reducers/meter.d.ts.map +1 -1
- package/dist/reducers/rows.d.ts +2 -2
- package/dist/reducers/rows.d.ts.map +1 -1
- package/dist/{snapshot.d.ts → reducers/snapshot.d.ts} +16 -10
- package/dist/reducers/snapshot.d.ts.map +1 -0
- package/dist/{live-log.d.ts → replay/live-log.d.ts} +4 -3
- package/dist/replay/live-log.d.ts.map +1 -0
- package/dist/{replay-capture.d.ts → replay/replay-capture.d.ts} +4 -7
- package/dist/replay/replay-capture.d.ts.map +1 -0
- package/dist/replay/replay-summary.d.ts.map +1 -0
- package/dist/{replay.d.ts → replay/replay.d.ts} +4 -4
- package/dist/replay/replay.d.ts.map +1 -0
- package/dist/{live-combat.d.ts → runtime/live-combat.d.ts} +6 -7
- package/dist/runtime/live-combat.d.ts.map +1 -0
- package/dist/testing/combat-packets.d.ts +28 -0
- package/dist/testing/combat-packets.d.ts.map +1 -0
- package/dist/{actor-directory.d.ts → tracking/actor-directory.d.ts} +18 -0
- package/dist/tracking/actor-directory.d.ts.map +1 -0
- package/dist/tracking/boss-catalog.d.ts +4 -0
- package/dist/tracking/boss-catalog.d.ts.map +1 -0
- package/dist/tracking/combat-tracker.d.ts +76 -0
- package/dist/tracking/combat-tracker.d.ts.map +1 -0
- package/dist/tracking/monster-identity.d.ts +32 -0
- package/dist/tracking/monster-identity.d.ts.map +1 -0
- package/dist/tracking/position-tracker.d.ts.map +1 -0
- package/dist/{status-tracker.d.ts → tracking/status-tracker.d.ts} +1 -1
- package/dist/tracking/status-tracker.d.ts.map +1 -0
- package/dist/tracking/summon-tracker.d.ts +47 -0
- package/dist/tracking/summon-tracker.d.ts.map +1 -0
- package/package.json +6 -6
- package/dist/actor-directory.d.ts.map +0 -1
- package/dist/boss-catalog.d.ts +0 -6
- package/dist/boss-catalog.d.ts.map +0 -1
- package/dist/combat-tracker.d.ts.map +0 -1
- package/dist/effect-display.d.ts.map +0 -1
- package/dist/live-combat.d.ts.map +0 -1
- package/dist/live-log.d.ts.map +0 -1
- package/dist/position-tracker.d.ts.map +0 -1
- package/dist/replay-capture.d.ts.map +0 -1
- package/dist/replay-summary.d.ts.map +0 -1
- package/dist/replay.d.ts.map +0 -1
- package/dist/snapshot.d.ts.map +0 -1
- package/dist/status-tracker.d.ts.map +0 -1
- /package/dist/{effect-display.d.ts → events/effect-display.d.ts} +0 -0
- /package/dist/{replay-summary.d.ts → replay/replay-summary.d.ts} +0 -0
- /package/dist/{position-tracker.d.ts → tracking/position-tracker.d.ts} +0 -0
package/dist/index.js
CHANGED
|
@@ -1,15 +1,26 @@
|
|
|
1
1
|
// @bun
|
|
2
|
-
// src/combat-tracker.ts
|
|
2
|
+
// src/tracking/combat-tracker.ts
|
|
3
3
|
import {
|
|
4
|
-
classifyFishNetRecoveryStyle,
|
|
5
4
|
CURRENT_GAME_BUILD_FINGERPRINT,
|
|
6
|
-
|
|
7
|
-
loadBundledFishNetSemanticMap
|
|
5
|
+
loadBundledFishNetRpcMap
|
|
8
6
|
} from "@kar-mi/spirit-vale-tools-capture";
|
|
9
|
-
import { readSignedPackedWhole as
|
|
7
|
+
import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
10
8
|
import { loadBundledSkillCatalog } from "@kar-mi/spirit-vale-tools-skills";
|
|
11
9
|
|
|
12
|
-
// src/
|
|
10
|
+
// src/events/decoded-fields.ts
|
|
11
|
+
function field(packet, name) {
|
|
12
|
+
return packet.decodedFields?.find((candidate) => candidate.name === name)?.value;
|
|
13
|
+
}
|
|
14
|
+
function numberField(packet, name) {
|
|
15
|
+
const value = field(packet, name);
|
|
16
|
+
return typeof value === "number" ? value : undefined;
|
|
17
|
+
}
|
|
18
|
+
function nullableStringField(packet, name) {
|
|
19
|
+
const value = field(packet, name);
|
|
20
|
+
return value === null || typeof value === "string" ? value : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// src/events/effect-display.ts
|
|
13
24
|
import { FishNetProtocolError } from "@kar-mi/spirit-vale-tools-capture";
|
|
14
25
|
import { checkedEnd, readSignedPackedWhole, requireBytes } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
15
26
|
function decodeEffectDisplays(payload) {
|
|
@@ -69,7 +80,445 @@ function readBoolean(buffer, offset, description) {
|
|
|
69
80
|
return { value: value === 1, nextOffset: offset + 1 };
|
|
70
81
|
}
|
|
71
82
|
|
|
72
|
-
// src/
|
|
83
|
+
// src/events/inference/recovery-style.ts
|
|
84
|
+
function classifyFishNetRecoveryStyle(packet) {
|
|
85
|
+
if (packet.rpcName !== "Recover_C" || packet.networkBehaviourType !== "HealthComponent")
|
|
86
|
+
return "unknown";
|
|
87
|
+
const value = (name) => packet.decodedFields?.find((field2) => field2.name === name)?.value;
|
|
88
|
+
const disableFloater = value("settings.DisableFloater");
|
|
89
|
+
const disableSfx = value("settings.DisableSfx");
|
|
90
|
+
const offset = value("settings.Offset");
|
|
91
|
+
const scale = value("settings.Scale");
|
|
92
|
+
if (typeof disableFloater !== "boolean" || typeof disableSfx !== "boolean" || typeof offset !== "number" || typeof scale !== "number")
|
|
93
|
+
return "unknown";
|
|
94
|
+
if (!disableFloater && disableSfx && offset < 0 && scale > 0)
|
|
95
|
+
return "drain";
|
|
96
|
+
if (!disableFloater && disableSfx && offset === 0 && scale === 0)
|
|
97
|
+
return "passive-regeneration";
|
|
98
|
+
if (!disableFloater && !disableSfx && offset > 0 && scale === 0)
|
|
99
|
+
return "standard";
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// src/events/damage-observer.ts
|
|
104
|
+
function observeFishNetDamagePacket(packet) {
|
|
105
|
+
if (packet.objectId === undefined || packet.rpcName !== "ApplyDamage_C" && packet.rpcName !== "Death_C")
|
|
106
|
+
return;
|
|
107
|
+
if (packet.networkBehaviourType !== undefined && packet.networkBehaviourType !== "HealthComponent")
|
|
108
|
+
return;
|
|
109
|
+
const requireVectors = packet.rpcName === "ApplyDamage_C";
|
|
110
|
+
const numeric = [
|
|
111
|
+
"dmg.Team",
|
|
112
|
+
"dmg.Value",
|
|
113
|
+
"dmg.Type",
|
|
114
|
+
"dmg.Hit",
|
|
115
|
+
"dmg.Hits",
|
|
116
|
+
"dmg.AttackerId",
|
|
117
|
+
"dmg.Element",
|
|
118
|
+
"dmg.WeaponType",
|
|
119
|
+
"dmg.Range"
|
|
120
|
+
];
|
|
121
|
+
if (!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")))) {
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
kind: packet.rpcName === "Death_C" ? "death" : "damage",
|
|
126
|
+
tick: packet.tick,
|
|
127
|
+
actorId: numberField(packet, "dmg.AttackerId"),
|
|
128
|
+
targetId: packet.objectId,
|
|
129
|
+
value: numberField(packet, "dmg.Value"),
|
|
130
|
+
team: numberField(packet, "dmg.Team")
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/events/generated/combat-semantics.ts
|
|
135
|
+
var COMBAT_SEMANTICS_BUILD_FINGERPRINT = "53305f369c4176d4b7644d06ad7f0cdc5c9bda6563144d0ce8cff3d12c520158";
|
|
136
|
+
var DIRECT_HEALING_SKILL_IDS = new Set([
|
|
137
|
+
"Consecration",
|
|
138
|
+
"DeathCoilSummon",
|
|
139
|
+
"FieldHealing",
|
|
140
|
+
"Heal",
|
|
141
|
+
"HighHeal"
|
|
142
|
+
]);
|
|
143
|
+
var REGENERATION_SKILL_IDS = new Set([
|
|
144
|
+
"GuardianBond",
|
|
145
|
+
"HealAll",
|
|
146
|
+
"Sanctuary",
|
|
147
|
+
"SanctuaryField"
|
|
148
|
+
]);
|
|
149
|
+
var BARRIER_SKILL_IDS = new Set([
|
|
150
|
+
"Barrier",
|
|
151
|
+
"CorpseBarrier",
|
|
152
|
+
"EclipsingAegis"
|
|
153
|
+
]);
|
|
154
|
+
var BARRIER_STATUS_IDS = new Set([
|
|
155
|
+
"Barrier",
|
|
156
|
+
"CorpseBarrier",
|
|
157
|
+
"EclipsingAegis",
|
|
158
|
+
"RadiantBulwark"
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
// src/events/combat-decoding.ts
|
|
162
|
+
import { readSignedPackedWhole as readSignedPackedWhole2 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
163
|
+
var DAMAGE_TYPE_SOURCES = new Map([
|
|
164
|
+
[4, { sourceId: "reflect", sourceLabel: "Reflect Damage" }]
|
|
165
|
+
]);
|
|
166
|
+
function matchesBehaviour(packet, expected) {
|
|
167
|
+
return packet.networkBehaviourType === undefined || packet.networkBehaviourType === expected;
|
|
168
|
+
}
|
|
169
|
+
function stringField(packet, name) {
|
|
170
|
+
const value = field(packet, name);
|
|
171
|
+
return typeof value === "string" ? value : undefined;
|
|
172
|
+
}
|
|
173
|
+
function requiredNumberField(packet, name) {
|
|
174
|
+
const value = numberField(packet, name);
|
|
175
|
+
if (value === undefined)
|
|
176
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing numeric field ${name}`);
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
function requiredBooleanField(packet, name) {
|
|
180
|
+
const value = field(packet, name);
|
|
181
|
+
if (typeof value !== "boolean")
|
|
182
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing boolean field ${name}`);
|
|
183
|
+
return value;
|
|
184
|
+
}
|
|
185
|
+
function requiredVectorField(packet, name) {
|
|
186
|
+
const value = field(packet, name);
|
|
187
|
+
if (!Array.isArray(value))
|
|
188
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing vector field ${name}`);
|
|
189
|
+
return value;
|
|
190
|
+
}
|
|
191
|
+
function decodedFieldRecord(packet) {
|
|
192
|
+
return Object.fromEntries(packet.decodedFields?.map(({ name, value }) => [name, value]) ?? []);
|
|
193
|
+
}
|
|
194
|
+
function healthComponentIndices(map) {
|
|
195
|
+
return new Set(map?.prefabs?.flatMap(({ components }) => components.filter(({ typeName }) => typeName === "HealthComponent").map(({ index }) => index)) ?? []);
|
|
196
|
+
}
|
|
197
|
+
function isCompleteRecoverPacket(packet) {
|
|
198
|
+
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);
|
|
199
|
+
}
|
|
200
|
+
function damageSignature(tick, targetId, actorId, sourceId, value, hitCode) {
|
|
201
|
+
return `${tick}\x00${targetId}\x00${actorId}\x00${sourceId}\x00${value}\x00${hitCode}`;
|
|
202
|
+
}
|
|
203
|
+
function drainRecoverySource(traits) {
|
|
204
|
+
if (traits?.hasSiphonHealth && !traits.hasHealthLeech) {
|
|
205
|
+
return { sourceId: "siphon-health", sourceLabel: "Siphon Health" };
|
|
206
|
+
}
|
|
207
|
+
if (traits?.hasHealthLeech && !traits.hasSiphonHealth) {
|
|
208
|
+
return { sourceId: "health-leech", sourceLabel: "Health Leech" };
|
|
209
|
+
}
|
|
210
|
+
return { sourceId: "siphon-health-leech", sourceLabel: "Siphon / Health Leech" };
|
|
211
|
+
}
|
|
212
|
+
function resolveDamageSource(rawSourceId, damageType, skillLabels) {
|
|
213
|
+
if (typeof rawSourceId === "string") {
|
|
214
|
+
return {
|
|
215
|
+
sourceId: rawSourceId,
|
|
216
|
+
sourceLabel: skillLabels.get(rawSourceId) ?? rawSourceId
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return (rawSourceId === null ? DAMAGE_TYPE_SOURCES.get(damageType) : undefined) ?? {
|
|
220
|
+
sourceId: "unknown",
|
|
221
|
+
sourceLabel: "unknown"
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function uniqueMonsterIdentityEvents(events) {
|
|
225
|
+
return events.filter((event, index) => event.operation !== "reset" || events.findIndex((candidate) => candidate.operation === "reset") === index);
|
|
226
|
+
}
|
|
227
|
+
function barrierSyncValues(packet) {
|
|
228
|
+
const values = [];
|
|
229
|
+
const read = (entry, behaviourType) => {
|
|
230
|
+
if (behaviourType !== undefined && behaviourType !== "HealthComponent")
|
|
231
|
+
return;
|
|
232
|
+
if (entry.index !== 2 && entry.name !== "barrierSync")
|
|
233
|
+
return;
|
|
234
|
+
const value = entry.fields.find(({ name }) => name === "barrierSync")?.value;
|
|
235
|
+
if (typeof value === "number")
|
|
236
|
+
values.push(value);
|
|
237
|
+
};
|
|
238
|
+
for (const entry of packet.spawnSyncEntries ?? [])
|
|
239
|
+
read(entry, entry.networkBehaviourType);
|
|
240
|
+
if (packet.packetName === "syncType" && matchesBehaviour(packet, "HealthComponent")) {
|
|
241
|
+
for (const entry of packet.syncEntries ?? [])
|
|
242
|
+
read(entry, packet.networkBehaviourType);
|
|
243
|
+
if (!packet.syncEntries) {
|
|
244
|
+
const value = numberField(packet, "barrierSync");
|
|
245
|
+
if ((packet.syncIndex === 2 || packet.syncName === "barrierSync") && value !== undefined)
|
|
246
|
+
values.push(value);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return values;
|
|
250
|
+
}
|
|
251
|
+
function bondSyncEntries(packet) {
|
|
252
|
+
const decode = (entry, behaviourType) => {
|
|
253
|
+
if (behaviourType !== undefined && behaviourType !== "SkillsComponent")
|
|
254
|
+
return;
|
|
255
|
+
if (entry.index !== 2 && entry.name !== "BondSync")
|
|
256
|
+
return;
|
|
257
|
+
const length = entry.fields.find(({ name }) => name === "Entries.length")?.value;
|
|
258
|
+
if (typeof length !== "number" || !Number.isInteger(length) || length < 0)
|
|
259
|
+
return;
|
|
260
|
+
const result = [];
|
|
261
|
+
for (let index = 0;index < length; index += 1) {
|
|
262
|
+
const prefix = `Entries[${index}]`;
|
|
263
|
+
const otherId = entry.fields.find(({ name }) => name === `${prefix}.Other`)?.value;
|
|
264
|
+
const skillId = entry.fields.find(({ name }) => name === `${prefix}.SkillId`)?.value;
|
|
265
|
+
const caster = entry.fields.find(({ name }) => name === `${prefix}.Caster`)?.value;
|
|
266
|
+
if (typeof otherId !== "number" || typeof skillId !== "string" || typeof caster !== "boolean")
|
|
267
|
+
return;
|
|
268
|
+
result.push({ otherId, skillId, caster });
|
|
269
|
+
}
|
|
270
|
+
return result;
|
|
271
|
+
};
|
|
272
|
+
for (const entry of packet.spawnSyncEntries ?? []) {
|
|
273
|
+
const result = decode(entry, entry.networkBehaviourType);
|
|
274
|
+
if (result)
|
|
275
|
+
return result;
|
|
276
|
+
}
|
|
277
|
+
if (packet.packetName === "syncType" && matchesBehaviour(packet, "SkillsComponent")) {
|
|
278
|
+
for (const entry of packet.syncEntries ?? []) {
|
|
279
|
+
const result = decode(entry, packet.networkBehaviourType);
|
|
280
|
+
if (result)
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
286
|
+
function decodeFloaterSettings(payload, start) {
|
|
287
|
+
if (payload.length - start < 2)
|
|
288
|
+
return;
|
|
289
|
+
const disableFloater = payload[start];
|
|
290
|
+
const disableSfx = payload[start + 1];
|
|
291
|
+
if (disableFloater !== 0 && disableFloater !== 1 || disableSfx !== 0 && disableSfx !== 1)
|
|
292
|
+
return;
|
|
293
|
+
let offset;
|
|
294
|
+
try {
|
|
295
|
+
offset = readSignedPackedWhole2(payload, start + 2);
|
|
296
|
+
} catch {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (payload.length - offset.nextOffset !== 4)
|
|
300
|
+
return;
|
|
301
|
+
return [
|
|
302
|
+
{ name: "settings.DisableFloater", codec: "boolean", value: disableFloater === 1 },
|
|
303
|
+
{ name: "settings.DisableSfx", codec: "boolean", value: disableSfx === 1 },
|
|
304
|
+
{ name: "settings.Offset", codec: "packedInt32", value: offset.value },
|
|
305
|
+
{ name: "settings.Scale", codec: "float32", value: payload.readFloatLE(offset.nextOffset) }
|
|
306
|
+
];
|
|
307
|
+
}
|
|
308
|
+
function decodedSummonCalibration(packet) {
|
|
309
|
+
if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
|
|
310
|
+
return;
|
|
311
|
+
const length = numberField(packet, "data.length");
|
|
312
|
+
if (length === undefined || !Number.isInteger(length) || length < 0)
|
|
313
|
+
return;
|
|
314
|
+
const entries = [];
|
|
315
|
+
for (let index = 0;index < length; index += 1) {
|
|
316
|
+
const skillId = stringField(packet, `data[${index}].SkillId`);
|
|
317
|
+
const level = numberField(packet, `data[${index}].Level`);
|
|
318
|
+
if (skillId === undefined || level === undefined || !Number.isInteger(level) || level < 0)
|
|
319
|
+
return;
|
|
320
|
+
entries.push({ skillId, level });
|
|
321
|
+
}
|
|
322
|
+
return entries;
|
|
323
|
+
}
|
|
324
|
+
function decodedLoginEffects(packet) {
|
|
325
|
+
if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
|
|
326
|
+
return;
|
|
327
|
+
const length = numberField(packet, "data.State.Effects.length");
|
|
328
|
+
if (length === undefined || !Number.isInteger(length) || length < 0)
|
|
329
|
+
return;
|
|
330
|
+
const entries = [];
|
|
331
|
+
for (let index = 0;index < length; index += 1) {
|
|
332
|
+
const statusId = stringField(packet, `data.State.Effects[${index}].Id`);
|
|
333
|
+
const level = numberField(packet, `data.State.Effects[${index}].Level`);
|
|
334
|
+
const duration = numberField(packet, `data.State.Effects[${index}].Duration`);
|
|
335
|
+
const stacks = numberField(packet, `data.State.Effects[${index}].Stacks`);
|
|
336
|
+
if (statusId === undefined || level === undefined || !Number.isInteger(level) || level < 0 || duration === undefined || stacks === undefined || !Number.isInteger(stacks) || stacks < 0)
|
|
337
|
+
return;
|
|
338
|
+
entries.push({
|
|
339
|
+
statusId,
|
|
340
|
+
level,
|
|
341
|
+
...duration < 0 || !Number.isFinite(duration) ? {} : { remainingSeconds: duration },
|
|
342
|
+
stacks
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return entries;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/tracking/monster-identity.ts
|
|
349
|
+
import { FishNetMonsterDirectory } from "@kar-mi/spirit-vale-tools-capture";
|
|
350
|
+
|
|
351
|
+
class FishNetMonsterIdentityTracker {
|
|
352
|
+
monsterCatalog;
|
|
353
|
+
bossCatalog;
|
|
354
|
+
monsters;
|
|
355
|
+
bossIdentities = new Map;
|
|
356
|
+
constructor(options = {}) {
|
|
357
|
+
this.monsterCatalog = options.monsterCatalog;
|
|
358
|
+
if (options.monsterCatalog)
|
|
359
|
+
this.monsters = new FishNetMonsterDirectory(options.monsterCatalog);
|
|
360
|
+
this.bossCatalog = options.bossCatalog;
|
|
361
|
+
}
|
|
362
|
+
reset() {
|
|
363
|
+
this.bossIdentities.clear();
|
|
364
|
+
this.monsters?.reset();
|
|
365
|
+
}
|
|
366
|
+
consumeDirectory(packet) {
|
|
367
|
+
const change = this.monsters?.consume(packet);
|
|
368
|
+
if (!change)
|
|
369
|
+
return;
|
|
370
|
+
const tick = packet.tick;
|
|
371
|
+
if (change.operation === "reset")
|
|
372
|
+
return { kind: "monsterIdentity", operation: "reset", tick };
|
|
373
|
+
if (change.operation === "remove") {
|
|
374
|
+
return { kind: "monsterIdentity", operation: "remove", tick, actorId: change.objectId };
|
|
375
|
+
}
|
|
376
|
+
const definition = this.monsterCatalog?.get(change.spawn.mobId);
|
|
377
|
+
return definition ? {
|
|
378
|
+
kind: "monsterIdentity",
|
|
379
|
+
operation: "upsert",
|
|
380
|
+
tick,
|
|
381
|
+
actorId: change.objectId,
|
|
382
|
+
mobId: change.spawn.mobId,
|
|
383
|
+
displayName: definition.displayName
|
|
384
|
+
} : undefined;
|
|
385
|
+
}
|
|
386
|
+
observeActivation(event) {
|
|
387
|
+
if (!event.sourceId || event.actorIdentity || this.monsters?.get(event.actorId))
|
|
388
|
+
return;
|
|
389
|
+
const definition = this.bossCatalog?.get(event.sourceId);
|
|
390
|
+
if (!definition)
|
|
391
|
+
return;
|
|
392
|
+
if (this.bossIdentities.has(event.actorId))
|
|
393
|
+
return;
|
|
394
|
+
this.bossIdentities.set(event.actorId, definition.displayName);
|
|
395
|
+
return {
|
|
396
|
+
kind: "monsterIdentity",
|
|
397
|
+
operation: "upsert",
|
|
398
|
+
tick: event.tick,
|
|
399
|
+
actorId: event.actorId,
|
|
400
|
+
mobId: `boss:${event.sourceId}`,
|
|
401
|
+
displayName: definition.displayName
|
|
402
|
+
};
|
|
403
|
+
}
|
|
404
|
+
consumeBossLifecycle(packet) {
|
|
405
|
+
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
406
|
+
if (this.bossIdentities.size === 0)
|
|
407
|
+
return;
|
|
408
|
+
this.bossIdentities.clear();
|
|
409
|
+
return { kind: "monsterIdentity", operation: "reset", tick: packet.tick };
|
|
410
|
+
}
|
|
411
|
+
if (packet.packetName !== "objectSpawn" && packet.packetName !== "objectDespawn" || packet.objectId === undefined || !this.bossIdentities.delete(packet.objectId))
|
|
412
|
+
return;
|
|
413
|
+
return { kind: "monsterIdentity", operation: "remove", tick: packet.tick, actorId: packet.objectId };
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/tracking/summon-tracker.ts
|
|
418
|
+
class FishNetSummonTracker {
|
|
419
|
+
actorIdentityResolver;
|
|
420
|
+
summonStacks = new Map;
|
|
421
|
+
summonSkillSyncState = new Map;
|
|
422
|
+
authoritativeSummonActors = new Set;
|
|
423
|
+
constructor(actorIdentityResolver) {
|
|
424
|
+
this.actorIdentityResolver = actorIdentityResolver;
|
|
425
|
+
}
|
|
426
|
+
reset() {
|
|
427
|
+
this.summonStacks.clear();
|
|
428
|
+
this.summonSkillSyncState.clear();
|
|
429
|
+
this.authoritativeSummonActors.clear();
|
|
430
|
+
}
|
|
431
|
+
consumeSkillSync(packet) {
|
|
432
|
+
const summonObjectId = packet.objectId;
|
|
433
|
+
const summonerSync = packet.syncEntries?.find((candidate) => candidate.name === "SummonerSync")?.fields.find((entryField) => entryField.name === "SummonerSync")?.value;
|
|
434
|
+
const skillId = packet.syncEntries?.find((candidate) => candidate.name === "SummonSkillSync")?.fields.find((entryField) => entryField.name === "SkillId")?.value;
|
|
435
|
+
if (typeof summonerSync !== "number" && typeof skillId !== "string")
|
|
436
|
+
return [];
|
|
437
|
+
const state = this.summonSkillSyncState.get(summonObjectId) ?? { counted: false };
|
|
438
|
+
if (typeof summonerSync === "number")
|
|
439
|
+
state.ownerActorId = summonerSync;
|
|
440
|
+
if (typeof skillId === "string")
|
|
441
|
+
state.skillId = skillId;
|
|
442
|
+
this.summonSkillSyncState.set(summonObjectId, state);
|
|
443
|
+
if (state.counted || state.ownerActorId === undefined || state.skillId === undefined)
|
|
444
|
+
return [];
|
|
445
|
+
if (this.authoritativeSummonActors.has(state.ownerActorId))
|
|
446
|
+
return [];
|
|
447
|
+
state.counted = true;
|
|
448
|
+
return [this.applyDelta(state.ownerActorId, state.skillId, 1, packet, "SummonSkillSync")];
|
|
449
|
+
}
|
|
450
|
+
consumeObjectLifecycle(packet) {
|
|
451
|
+
if (packet.packetName !== "objectDespawn" && packet.packetName !== "objectSpawn" || packet.objectId === undefined) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const state = this.summonSkillSyncState.get(packet.objectId);
|
|
455
|
+
if (!state)
|
|
456
|
+
return;
|
|
457
|
+
this.summonSkillSyncState.delete(packet.objectId);
|
|
458
|
+
if (!state.counted || state.ownerActorId === undefined || state.skillId === undefined)
|
|
459
|
+
return;
|
|
460
|
+
if (this.authoritativeSummonActors.has(state.ownerActorId))
|
|
461
|
+
return;
|
|
462
|
+
return this.applyDelta(state.ownerActorId, state.skillId, -1, packet, "SummonSkillSync");
|
|
463
|
+
}
|
|
464
|
+
consumeCalibration(packet) {
|
|
465
|
+
const entries = decodedSummonCalibration(packet);
|
|
466
|
+
if (!entries)
|
|
467
|
+
return [];
|
|
468
|
+
this.authoritativeSummonActors.add(packet.objectId);
|
|
469
|
+
return this.applySnapshot(packet.objectId, packet, entries);
|
|
470
|
+
}
|
|
471
|
+
applyDelta(actorId, skillId, delta, packet, rpc) {
|
|
472
|
+
const stacks = new Map(this.summonStacks.get(actorId));
|
|
473
|
+
const nextCount = Math.max(0, (stacks.get(skillId) ?? 0) + delta);
|
|
474
|
+
if (nextCount === 0)
|
|
475
|
+
stacks.delete(skillId);
|
|
476
|
+
else
|
|
477
|
+
stacks.set(skillId, nextCount);
|
|
478
|
+
if (stacks.size === 0)
|
|
479
|
+
this.summonStacks.delete(actorId);
|
|
480
|
+
else
|
|
481
|
+
this.summonStacks.set(actorId, stacks);
|
|
482
|
+
return {
|
|
483
|
+
kind: "summon",
|
|
484
|
+
rpc,
|
|
485
|
+
tick: packet.tick,
|
|
486
|
+
payloadBytes: packet.payload.length,
|
|
487
|
+
fields: decodedFieldRecord(packet),
|
|
488
|
+
actorId,
|
|
489
|
+
skillId,
|
|
490
|
+
stacks: nextCount,
|
|
491
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
applySnapshot(actorId, packet, entries) {
|
|
495
|
+
const previous = this.summonStacks.get(actorId) ?? new Map;
|
|
496
|
+
const current = new Map;
|
|
497
|
+
for (const { skillId } of entries)
|
|
498
|
+
current.set(skillId, (current.get(skillId) ?? 0) + 1);
|
|
499
|
+
const changedSkillIds = [
|
|
500
|
+
...current.keys(),
|
|
501
|
+
...[...previous.keys()].filter((skillId) => !current.has(skillId))
|
|
502
|
+
].filter((skillId) => current.get(skillId) !== previous.get(skillId));
|
|
503
|
+
if (current.size === 0)
|
|
504
|
+
this.summonStacks.delete(actorId);
|
|
505
|
+
else
|
|
506
|
+
this.summonStacks.set(actorId, current);
|
|
507
|
+
return changedSkillIds.map((skillId) => ({
|
|
508
|
+
kind: "summon",
|
|
509
|
+
rpc: "CalibrateSummons_T",
|
|
510
|
+
tick: packet.tick,
|
|
511
|
+
payloadBytes: packet.payload.length,
|
|
512
|
+
fields: decodedFieldRecord(packet),
|
|
513
|
+
actorId,
|
|
514
|
+
skillId,
|
|
515
|
+
stacks: current.get(skillId) ?? 0,
|
|
516
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// src/tracking/combat-tracker.ts
|
|
73
522
|
var HIT_RESULTS = {
|
|
74
523
|
0: "normal",
|
|
75
524
|
1: "critical",
|
|
@@ -79,9 +528,6 @@ var HIT_RESULTS = {
|
|
|
79
528
|
};
|
|
80
529
|
var SKILL_RPC_NAMES = new Set(["CastBegin_C", "AutoCast_C", "ToggleBegin_C", "CastComplete_C", "CastInterrupt_C", "CastCancel_C"]);
|
|
81
530
|
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
531
|
var REGEN_STATUS_ID = "Regeneration";
|
|
86
532
|
|
|
87
533
|
class FishNetCombatTracker {
|
|
@@ -90,16 +536,18 @@ class FishNetCombatTracker {
|
|
|
90
536
|
skillLabels;
|
|
91
537
|
actorIdentityResolver;
|
|
92
538
|
healingTraitsResolver;
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
539
|
+
healthComponentIndices;
|
|
540
|
+
directHealingSkillIds;
|
|
541
|
+
regenerationSkillIds;
|
|
542
|
+
barrierSkillIds;
|
|
543
|
+
barrierStatusIds;
|
|
544
|
+
monsterIdentities;
|
|
545
|
+
summons;
|
|
97
546
|
activations = new Map;
|
|
98
|
-
bossIdentities = new Map;
|
|
99
547
|
activeRegenSources = new Map;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
548
|
+
bondRegenSources = new Map;
|
|
549
|
+
barriers = new Map;
|
|
550
|
+
recentDamageHits = new Map;
|
|
103
551
|
recentDamageSignatures = new Set;
|
|
104
552
|
recentDamageTick;
|
|
105
553
|
nextActivation = 1;
|
|
@@ -112,26 +560,29 @@ class FishNetCombatTracker {
|
|
|
112
560
|
if (!Number.isInteger(maxAge) || maxAge < 1)
|
|
113
561
|
throw new Error("activationMaxAgeTicks must be a positive integer");
|
|
114
562
|
this.activationMaxAgeTicks = maxAge;
|
|
115
|
-
const buildFingerprint = options.buildFingerprint ?? options.skillCatalog?.buildFingerprint ??
|
|
563
|
+
const buildFingerprint = options.buildFingerprint ?? options.skillCatalog?.buildFingerprint ?? CURRENT_GAME_BUILD_FINGERPRINT;
|
|
116
564
|
assertMatchingBuild("skill catalog", options.skillCatalog?.buildFingerprint, buildFingerprint);
|
|
117
|
-
assertMatchingBuild("semantic map", options.semanticMap?.buildFingerprint, buildFingerprint);
|
|
118
565
|
const skillCatalog = options.skillCatalog ?? tryLoadBundledSkillCatalog(buildFingerprint);
|
|
119
|
-
const
|
|
120
|
-
this.
|
|
566
|
+
const rpcMap = tryLoadBundledRpcMap(buildFingerprint);
|
|
567
|
+
this.healthComponentIndices = healthComponentIndices(rpcMap);
|
|
568
|
+
const currentSemantics = buildFingerprint === COMBAT_SEMANTICS_BUILD_FINGERPRINT;
|
|
569
|
+
this.directHealingSkillIds = currentSemantics ? DIRECT_HEALING_SKILL_IDS : new Set;
|
|
570
|
+
this.regenerationSkillIds = currentSemantics ? REGENERATION_SKILL_IDS : new Set;
|
|
571
|
+
this.barrierSkillIds = currentSemantics ? BARRIER_SKILL_IDS : new Set;
|
|
572
|
+
this.barrierStatusIds = currentSemantics ? BARRIER_STATUS_IDS : new Set;
|
|
121
573
|
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
574
|
this.actorIdentityResolver = options.actorIdentityResolver;
|
|
125
575
|
this.healingTraitsResolver = options.healingTraitsResolver;
|
|
126
|
-
this.
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
576
|
+
this.monsterIdentities = new FishNetMonsterIdentityTracker({
|
|
577
|
+
monsterCatalog: options.monsterCatalog,
|
|
578
|
+
bossCatalog: options.bossCatalog
|
|
579
|
+
});
|
|
580
|
+
this.summons = new FishNetSummonTracker(options.actorIdentityResolver);
|
|
130
581
|
}
|
|
131
582
|
consume(packet) {
|
|
132
|
-
const monsterIdentity = this.
|
|
133
|
-
const bossLifecycle = this.
|
|
134
|
-
const summonLifecycle = this.
|
|
583
|
+
const monsterIdentity = this.monsterIdentities.consumeDirectory(packet);
|
|
584
|
+
const bossLifecycle = this.monsterIdentities.consumeBossLifecycle(packet);
|
|
585
|
+
const summonLifecycle = this.summons.consumeObjectLifecycle(packet);
|
|
135
586
|
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
136
587
|
this.reset();
|
|
137
588
|
return uniqueMonsterIdentityEvents([
|
|
@@ -143,14 +594,17 @@ class FishNetCombatTracker {
|
|
|
143
594
|
if (this.recentDamageTick !== packet.tick) {
|
|
144
595
|
this.recentDamageTick = packet.tick;
|
|
145
596
|
this.recentDamageSignatures.clear();
|
|
597
|
+
this.recentDamageHits.clear();
|
|
146
598
|
}
|
|
147
599
|
const events = [
|
|
148
600
|
...monsterIdentity ? [monsterIdentity] : [],
|
|
149
601
|
...bossLifecycle ? [bossLifecycle] : [],
|
|
150
602
|
...summonLifecycle ? [summonLifecycle] : []
|
|
151
603
|
];
|
|
604
|
+
this.consumeBondSync(packet);
|
|
605
|
+
events.push(...this.consumeBarrierSync(packet));
|
|
152
606
|
if (packet.packetName === "syncType" && matchesBehaviour(packet, "SummoningComponent")) {
|
|
153
|
-
events.push(...this.
|
|
607
|
+
events.push(...this.summons.consumeSkillSync(packet));
|
|
154
608
|
return events;
|
|
155
609
|
}
|
|
156
610
|
if (packet.objectId === undefined || !packet.rpcName) {
|
|
@@ -161,7 +615,7 @@ class FishNetCombatTracker {
|
|
|
161
615
|
const skillEvent = this.consumeSkill(packet);
|
|
162
616
|
if (skillEvent) {
|
|
163
617
|
events.push(skillEvent);
|
|
164
|
-
const bossIdentity = this.
|
|
618
|
+
const bossIdentity = this.monsterIdentities.observeActivation(skillEvent);
|
|
165
619
|
if (bossIdentity)
|
|
166
620
|
events.push(bossIdentity);
|
|
167
621
|
}
|
|
@@ -173,7 +627,7 @@ class FishNetCombatTracker {
|
|
|
173
627
|
}
|
|
174
628
|
if ((packet.rpcName === "ApplyDamage_C" || packet.rpcName === "Death_C") && matchesBehaviour(packet, "HealthComponent")) {
|
|
175
629
|
const death = packet.rpcName === "Death_C";
|
|
176
|
-
if (!
|
|
630
|
+
if (!observeFishNetDamagePacket(packet))
|
|
177
631
|
return events;
|
|
178
632
|
events.push(...this.consumeDamage(packet, death));
|
|
179
633
|
return events;
|
|
@@ -215,7 +669,7 @@ class FishNetCombatTracker {
|
|
|
215
669
|
return events;
|
|
216
670
|
}
|
|
217
671
|
if (packet.rpcName === "CalibrateSummons_T" && packet.rpcResolution === "verified" && packet.networkBehaviourType === "SummoningComponent") {
|
|
218
|
-
events.push(...this.
|
|
672
|
+
events.push(...this.summons.consumeCalibration(packet));
|
|
219
673
|
return events;
|
|
220
674
|
}
|
|
221
675
|
if (packet.rpcName === "LoadCharacter_T" && packet.rpcResolution === "verified" && packet.networkBehaviourType === "PlayerSave") {
|
|
@@ -226,61 +680,14 @@ class FishNetCombatTracker {
|
|
|
226
680
|
}
|
|
227
681
|
reset() {
|
|
228
682
|
this.activations.clear();
|
|
229
|
-
this.bossIdentities.clear();
|
|
230
683
|
this.activeRegenSources.clear();
|
|
231
|
-
this.
|
|
232
|
-
this.
|
|
233
|
-
this.authoritativeSummonActors.clear();
|
|
684
|
+
this.bondRegenSources.clear();
|
|
685
|
+
this.barriers.clear();
|
|
234
686
|
this.recentDamageSignatures.clear();
|
|
687
|
+
this.recentDamageHits.clear();
|
|
235
688
|
this.recentDamageTick = undefined;
|
|
236
|
-
this.
|
|
237
|
-
|
|
238
|
-
monsterIdentity(tick, change) {
|
|
239
|
-
if (!change)
|
|
240
|
-
return;
|
|
241
|
-
if (change.operation === "reset")
|
|
242
|
-
return { kind: "monsterIdentity", operation: "reset", tick };
|
|
243
|
-
if (change.operation === "remove") {
|
|
244
|
-
return { kind: "monsterIdentity", operation: "remove", tick, actorId: change.objectId };
|
|
245
|
-
}
|
|
246
|
-
const definition = this.monsterCatalog?.get(change.spawn.mobId);
|
|
247
|
-
return definition ? {
|
|
248
|
-
kind: "monsterIdentity",
|
|
249
|
-
operation: "upsert",
|
|
250
|
-
tick,
|
|
251
|
-
actorId: change.objectId,
|
|
252
|
-
mobId: change.spawn.mobId,
|
|
253
|
-
displayName: definition.displayName
|
|
254
|
-
} : undefined;
|
|
255
|
-
}
|
|
256
|
-
bossIdentity(event) {
|
|
257
|
-
if (!event.sourceId || event.actorIdentity || this.monsters?.get(event.actorId))
|
|
258
|
-
return;
|
|
259
|
-
const definition = this.bossCatalog?.get(event.sourceId);
|
|
260
|
-
if (!definition)
|
|
261
|
-
return;
|
|
262
|
-
if (this.bossIdentities.has(event.actorId))
|
|
263
|
-
return;
|
|
264
|
-
this.bossIdentities.set(event.actorId, definition.displayName);
|
|
265
|
-
return {
|
|
266
|
-
kind: "monsterIdentity",
|
|
267
|
-
operation: "upsert",
|
|
268
|
-
tick: event.tick,
|
|
269
|
-
actorId: event.actorId,
|
|
270
|
-
mobId: `boss:${event.sourceId}`,
|
|
271
|
-
displayName: definition.displayName
|
|
272
|
-
};
|
|
273
|
-
}
|
|
274
|
-
bossIdentityLifecycle(packet) {
|
|
275
|
-
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
276
|
-
if (this.bossIdentities.size === 0)
|
|
277
|
-
return;
|
|
278
|
-
this.bossIdentities.clear();
|
|
279
|
-
return { kind: "monsterIdentity", operation: "reset", tick: packet.tick };
|
|
280
|
-
}
|
|
281
|
-
if (packet.packetName !== "objectSpawn" && packet.packetName !== "objectDespawn" || packet.objectId === undefined || !this.bossIdentities.delete(packet.objectId))
|
|
282
|
-
return;
|
|
283
|
-
return { kind: "monsterIdentity", operation: "remove", tick: packet.tick, actorId: packet.objectId };
|
|
689
|
+
this.monsterIdentities.reset();
|
|
690
|
+
this.summons.reset();
|
|
284
691
|
}
|
|
285
692
|
consumeEffectDisplays(packet) {
|
|
286
693
|
let batch;
|
|
@@ -299,6 +706,14 @@ class FishNetCombatTracker {
|
|
|
299
706
|
actorId,
|
|
300
707
|
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
301
708
|
};
|
|
709
|
+
for (const display of batch.applies) {
|
|
710
|
+
if (this.barrierStatusIds.has(display.statusId))
|
|
711
|
+
this.observeBarrierStatus(actorId, true, packet.tick);
|
|
712
|
+
}
|
|
713
|
+
for (const statusId of batch.removes) {
|
|
714
|
+
if (this.barrierStatusIds.has(statusId))
|
|
715
|
+
this.observeBarrierStatus(actorId, false, packet.tick);
|
|
716
|
+
}
|
|
302
717
|
return [
|
|
303
718
|
...batch.applies.map((display) => ({
|
|
304
719
|
...base,
|
|
@@ -320,27 +735,28 @@ class FishNetCombatTracker {
|
|
|
320
735
|
rpcName: "ApplyEffectDisplays_O"
|
|
321
736
|
});
|
|
322
737
|
}
|
|
323
|
-
if (packet.rpcHash !== 1 || !
|
|
738
|
+
if (packet.rpcHash !== 1 || !this.healthComponentIndices.has(packet.networkBehaviourIndex))
|
|
324
739
|
return [];
|
|
325
740
|
let amount;
|
|
326
741
|
try {
|
|
327
|
-
amount =
|
|
742
|
+
amount = readSignedPackedWhole3(packet.payload, 0);
|
|
328
743
|
} catch {
|
|
329
744
|
return [];
|
|
330
745
|
}
|
|
331
746
|
if (amount.value < 0)
|
|
332
747
|
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)
|
|
748
|
+
const settings = decodeFloaterSettings(packet.payload, amount.nextOffset);
|
|
749
|
+
if (!settings)
|
|
337
750
|
return [];
|
|
338
751
|
return [this.consumeRecover({
|
|
339
752
|
...packet,
|
|
340
753
|
networkBehaviourType: "HealthComponent",
|
|
341
754
|
rpcName: "Recover_C",
|
|
342
|
-
decodedFields: [
|
|
343
|
-
|
|
755
|
+
decodedFields: [
|
|
756
|
+
{ name: "amount", codec: "packedInt32", value: amount.value },
|
|
757
|
+
...settings
|
|
758
|
+
],
|
|
759
|
+
undecodedPayload: undefined
|
|
344
760
|
})];
|
|
345
761
|
}
|
|
346
762
|
consumeSkillDisplay(packet) {
|
|
@@ -387,94 +803,6 @@ class FishNetCombatTracker {
|
|
|
387
803
|
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
388
804
|
}));
|
|
389
805
|
}
|
|
390
|
-
consumeSummonSkillSync(packet) {
|
|
391
|
-
const summonObjectId = packet.objectId;
|
|
392
|
-
const summonerSync = packet.syncEntries?.find((candidate) => candidate.name === "SummonerSync")?.fields.find((entryField) => entryField.name === "SummonerSync")?.value;
|
|
393
|
-
const skillId = packet.syncEntries?.find((candidate) => candidate.name === "SummonSkillSync")?.fields.find((entryField) => entryField.name === "SkillId")?.value;
|
|
394
|
-
if (typeof summonerSync !== "number" && typeof skillId !== "string")
|
|
395
|
-
return [];
|
|
396
|
-
const state = this.summonSkillSyncState.get(summonObjectId) ?? { counted: false };
|
|
397
|
-
if (typeof summonerSync === "number")
|
|
398
|
-
state.ownerActorId = summonerSync;
|
|
399
|
-
if (typeof skillId === "string")
|
|
400
|
-
state.skillId = skillId;
|
|
401
|
-
this.summonSkillSyncState.set(summonObjectId, state);
|
|
402
|
-
if (state.counted || state.ownerActorId === undefined || state.skillId === undefined)
|
|
403
|
-
return [];
|
|
404
|
-
if (this.authoritativeSummonActors.has(state.ownerActorId))
|
|
405
|
-
return [];
|
|
406
|
-
state.counted = true;
|
|
407
|
-
return [this.applySummonDelta(state.ownerActorId, state.skillId, 1, packet, "SummonSkillSync")];
|
|
408
|
-
}
|
|
409
|
-
consumeSummonObjectLifecycle(packet) {
|
|
410
|
-
if (packet.packetName !== "objectDespawn" && packet.packetName !== "objectSpawn" || packet.objectId === undefined) {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
const state = this.summonSkillSyncState.get(packet.objectId);
|
|
414
|
-
if (!state)
|
|
415
|
-
return;
|
|
416
|
-
this.summonSkillSyncState.delete(packet.objectId);
|
|
417
|
-
if (!state.counted || state.ownerActorId === undefined || state.skillId === undefined)
|
|
418
|
-
return;
|
|
419
|
-
if (this.authoritativeSummonActors.has(state.ownerActorId))
|
|
420
|
-
return;
|
|
421
|
-
return this.applySummonDelta(state.ownerActorId, state.skillId, -1, packet, "SummonSkillSync");
|
|
422
|
-
}
|
|
423
|
-
applySummonDelta(actorId, skillId, delta, packet, rpc) {
|
|
424
|
-
const stacks = new Map(this.summonStacks.get(actorId));
|
|
425
|
-
const nextCount = Math.max(0, (stacks.get(skillId) ?? 0) + delta);
|
|
426
|
-
if (nextCount === 0)
|
|
427
|
-
stacks.delete(skillId);
|
|
428
|
-
else
|
|
429
|
-
stacks.set(skillId, nextCount);
|
|
430
|
-
if (stacks.size === 0)
|
|
431
|
-
this.summonStacks.delete(actorId);
|
|
432
|
-
else
|
|
433
|
-
this.summonStacks.set(actorId, stacks);
|
|
434
|
-
return {
|
|
435
|
-
kind: "summon",
|
|
436
|
-
rpc,
|
|
437
|
-
tick: packet.tick,
|
|
438
|
-
payloadBytes: packet.payload.length,
|
|
439
|
-
fields: decodedFieldRecord(packet),
|
|
440
|
-
actorId,
|
|
441
|
-
skillId,
|
|
442
|
-
stacks: nextCount,
|
|
443
|
-
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
444
|
-
};
|
|
445
|
-
}
|
|
446
|
-
consumeSummonCalibration(packet) {
|
|
447
|
-
const entries = decodedSummonCalibration(packet);
|
|
448
|
-
if (!entries)
|
|
449
|
-
return [];
|
|
450
|
-
this.authoritativeSummonActors.add(packet.objectId);
|
|
451
|
-
return this.applySummonSnapshot(packet.objectId, packet, entries);
|
|
452
|
-
}
|
|
453
|
-
applySummonSnapshot(actorId, packet, entries) {
|
|
454
|
-
const previous = this.summonStacks.get(actorId) ?? new Map;
|
|
455
|
-
const current = new Map;
|
|
456
|
-
for (const { skillId } of entries)
|
|
457
|
-
current.set(skillId, (current.get(skillId) ?? 0) + 1);
|
|
458
|
-
const changedSkillIds = [
|
|
459
|
-
...current.keys(),
|
|
460
|
-
...[...previous.keys()].filter((skillId) => !current.has(skillId))
|
|
461
|
-
].filter((skillId) => current.get(skillId) !== previous.get(skillId));
|
|
462
|
-
if (current.size === 0)
|
|
463
|
-
this.summonStacks.delete(actorId);
|
|
464
|
-
else
|
|
465
|
-
this.summonStacks.set(actorId, current);
|
|
466
|
-
return changedSkillIds.map((skillId) => ({
|
|
467
|
-
kind: "summon",
|
|
468
|
-
rpc: "CalibrateSummons_T",
|
|
469
|
-
tick: packet.tick,
|
|
470
|
-
payloadBytes: packet.payload.length,
|
|
471
|
-
fields: decodedFieldRecord(packet),
|
|
472
|
-
actorId,
|
|
473
|
-
skillId,
|
|
474
|
-
stacks: current.get(skillId) ?? 0,
|
|
475
|
-
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
476
|
-
}));
|
|
477
|
-
}
|
|
478
806
|
consumeSkill(packet) {
|
|
479
807
|
const actorId = packet.objectId;
|
|
480
808
|
const rpcName = packet.rpcName;
|
|
@@ -485,7 +813,7 @@ class FishNetCombatTracker {
|
|
|
485
813
|
if (!sourceId)
|
|
486
814
|
return;
|
|
487
815
|
const activation2 = this.createActivation(actorId, "skill", packet.tick, sourceId, false);
|
|
488
|
-
activation2.targetId = numberField(packet, "targetId");
|
|
816
|
+
activation2.targetId = rpcName === "AutoCast_C" ? numberField(packet, "obj") : numberField(packet, "targetId");
|
|
489
817
|
return {
|
|
490
818
|
kind: "activation",
|
|
491
819
|
rpc: rpcName,
|
|
@@ -570,6 +898,8 @@ class FishNetCombatTracker {
|
|
|
570
898
|
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
571
899
|
}];
|
|
572
900
|
}
|
|
901
|
+
if (value >= 0)
|
|
902
|
+
this.recentDamageHits.set(packet.objectId, { actorId, sourceId, sourceLabel });
|
|
573
903
|
const hitCode = requiredNumberField(packet, "dmg.Hit");
|
|
574
904
|
const hitResult = HIT_RESULTS[hitCode] ?? hitCode;
|
|
575
905
|
const targetId = packet.objectId;
|
|
@@ -669,7 +999,7 @@ class FishNetCombatTracker {
|
|
|
669
999
|
consumeRecover(packet) {
|
|
670
1000
|
const targetId = packet.objectId;
|
|
671
1001
|
const value = requiredNumberField(packet, "amount");
|
|
672
|
-
const recoveryStyle = classifyFishNetRecoveryStyle(packet
|
|
1002
|
+
const recoveryStyle = classifyFishNetRecoveryStyle(packet);
|
|
673
1003
|
if (recoveryStyle === "passive-regeneration") {
|
|
674
1004
|
return this.createSelfRecovery(packet, value, recoveryStyle, "passive-regeneration", "Passive regeneration");
|
|
675
1005
|
}
|
|
@@ -677,7 +1007,7 @@ class FishNetCombatTracker {
|
|
|
677
1007
|
const source = drainRecoverySource(this.healingTraitsResolver?.(targetId));
|
|
678
1008
|
return this.createSelfRecovery(packet, value, recoveryStyle, source.sourceId, source.sourceLabel);
|
|
679
1009
|
}
|
|
680
|
-
const candidates = this.eligibleHealActivations(packet.tick, targetId,
|
|
1010
|
+
const candidates = this.eligibleHealActivations(packet.tick, targetId, this.directHealingSkillIds);
|
|
681
1011
|
let attribution;
|
|
682
1012
|
let actorId;
|
|
683
1013
|
let sourceId;
|
|
@@ -688,7 +1018,7 @@ class FishNetCombatTracker {
|
|
|
688
1018
|
const [candidate] = candidates;
|
|
689
1019
|
if (!candidate)
|
|
690
1020
|
throw new Error("missing combat activation candidate");
|
|
691
|
-
attribution =
|
|
1021
|
+
attribution = "inferred";
|
|
692
1022
|
actorId = candidate.actorId;
|
|
693
1023
|
sourceId = candidate.sourceId;
|
|
694
1024
|
sourceLabel = candidate.sourceLabel;
|
|
@@ -697,8 +1027,8 @@ class FishNetCombatTracker {
|
|
|
697
1027
|
attribution = "ambiguous";
|
|
698
1028
|
candidateActivationIds = candidates.map(({ id }) => id);
|
|
699
1029
|
} else {
|
|
700
|
-
const regenSource = this.activeRegenSources.get(targetId);
|
|
701
|
-
if (regenSource?.candidateActivationIds) {
|
|
1030
|
+
const regenSource = this.activeRegenSources.get(targetId) ?? this.bondRegenSources.get(targetId);
|
|
1031
|
+
if (regenSource?.candidateActivationIds || regenSource?.ambiguous) {
|
|
702
1032
|
attribution = "ambiguous";
|
|
703
1033
|
candidateActivationIds = regenSource.candidateActivationIds;
|
|
704
1034
|
} else if (regenSource?.actorId !== undefined) {
|
|
@@ -729,6 +1059,101 @@ class FishNetCombatTracker {
|
|
|
729
1059
|
actorIdentity: actorId === undefined ? undefined : this.actorIdentityResolver?.(actorId)
|
|
730
1060
|
};
|
|
731
1061
|
}
|
|
1062
|
+
consumeBarrierSync(packet) {
|
|
1063
|
+
if (packet.objectId === undefined)
|
|
1064
|
+
return [];
|
|
1065
|
+
if (packet.packetName === "objectDespawn" || packet.packetName === "objectSpawn") {
|
|
1066
|
+
this.barriers.delete(packet.objectId);
|
|
1067
|
+
if (packet.packetName === "objectDespawn")
|
|
1068
|
+
return [];
|
|
1069
|
+
}
|
|
1070
|
+
const values = barrierSyncValues(packet);
|
|
1071
|
+
if (values.length === 0)
|
|
1072
|
+
return [];
|
|
1073
|
+
const targetId = packet.objectId;
|
|
1074
|
+
const events = [];
|
|
1075
|
+
for (const barrierAfter of values) {
|
|
1076
|
+
if (!Number.isInteger(barrierAfter) || barrierAfter < 0)
|
|
1077
|
+
continue;
|
|
1078
|
+
const previous = this.barriers.get(targetId);
|
|
1079
|
+
if (!previous || packet.packetName === "objectSpawn") {
|
|
1080
|
+
this.barriers.set(targetId, { value: barrierAfter, statusActive: previous?.statusActive ?? false });
|
|
1081
|
+
continue;
|
|
1082
|
+
}
|
|
1083
|
+
const barrierBefore = previous.value;
|
|
1084
|
+
if (barrierAfter === barrierBefore)
|
|
1085
|
+
continue;
|
|
1086
|
+
let source = previous;
|
|
1087
|
+
let action;
|
|
1088
|
+
if (barrierAfter > barrierBefore) {
|
|
1089
|
+
source = sourceFromCandidates(this.eligibleHealActivations(packet.tick, targetId, this.barrierSkillIds));
|
|
1090
|
+
action = "gained";
|
|
1091
|
+
} else if (this.recentDamageHits.has(targetId)) {
|
|
1092
|
+
action = "absorbed";
|
|
1093
|
+
} else if (barrierAfter === 0 && !previous.statusActive) {
|
|
1094
|
+
action = "cleared";
|
|
1095
|
+
} else {
|
|
1096
|
+
action = "reduced";
|
|
1097
|
+
}
|
|
1098
|
+
const incoming = action === "absorbed" ? this.recentDamageHits.get(targetId) : undefined;
|
|
1099
|
+
const attribution = source.candidateActivationIds ? "ambiguous" : source.actorId === undefined ? "unattributed" : "inferred";
|
|
1100
|
+
events.push({
|
|
1101
|
+
kind: "shield",
|
|
1102
|
+
rpc: "barrierSync",
|
|
1103
|
+
tick: packet.tick,
|
|
1104
|
+
payloadBytes: packet.payload.length,
|
|
1105
|
+
fields: { barrierSync: barrierAfter },
|
|
1106
|
+
targetId,
|
|
1107
|
+
actorId: source.actorId,
|
|
1108
|
+
sourceId: source.sourceId,
|
|
1109
|
+
sourceLabel: source.sourceLabel,
|
|
1110
|
+
value: Math.abs(barrierAfter - barrierBefore),
|
|
1111
|
+
barrierBefore,
|
|
1112
|
+
barrierAfter,
|
|
1113
|
+
action,
|
|
1114
|
+
...incoming === undefined ? {} : {
|
|
1115
|
+
incomingActorId: incoming.actorId,
|
|
1116
|
+
incomingSourceId: incoming.sourceId,
|
|
1117
|
+
incomingSourceLabel: incoming.sourceLabel
|
|
1118
|
+
},
|
|
1119
|
+
attribution,
|
|
1120
|
+
activationId: source.activationId,
|
|
1121
|
+
candidateActivationIds: source.candidateActivationIds,
|
|
1122
|
+
actorIdentity: source.actorId === undefined ? undefined : this.actorIdentityResolver?.(source.actorId)
|
|
1123
|
+
});
|
|
1124
|
+
this.barriers.set(targetId, {
|
|
1125
|
+
...source,
|
|
1126
|
+
value: barrierAfter,
|
|
1127
|
+
statusActive: barrierAfter === 0 ? false : previous.statusActive
|
|
1128
|
+
});
|
|
1129
|
+
}
|
|
1130
|
+
return events;
|
|
1131
|
+
}
|
|
1132
|
+
consumeBondSync(packet) {
|
|
1133
|
+
if (packet.objectId === undefined)
|
|
1134
|
+
return;
|
|
1135
|
+
if (packet.packetName === "objectDespawn" || packet.packetName === "objectSpawn") {
|
|
1136
|
+
this.bondRegenSources.delete(packet.objectId);
|
|
1137
|
+
if (packet.packetName === "objectDespawn")
|
|
1138
|
+
return;
|
|
1139
|
+
}
|
|
1140
|
+
const entries = bondSyncEntries(packet);
|
|
1141
|
+
if (!entries)
|
|
1142
|
+
return;
|
|
1143
|
+
const sources = entries.filter((entry) => !entry.caster && this.regenerationSkillIds.has(entry.skillId));
|
|
1144
|
+
if (sources.length === 0) {
|
|
1145
|
+
this.bondRegenSources.delete(packet.objectId);
|
|
1146
|
+
} else if (sources.length === 1) {
|
|
1147
|
+
const source = sources[0];
|
|
1148
|
+
this.bondRegenSources.set(packet.objectId, {
|
|
1149
|
+
actorId: source.otherId,
|
|
1150
|
+
sourceId: source.skillId,
|
|
1151
|
+
sourceLabel: this.skillLabels.get(source.skillId) ?? source.skillId
|
|
1152
|
+
});
|
|
1153
|
+
} else {
|
|
1154
|
+
this.bondRegenSources.set(packet.objectId, { ambiguous: true });
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
732
1157
|
createSelfRecovery(packet, value, recoveryStyle, sourceId, sourceLabel) {
|
|
733
1158
|
const actorId = packet.objectId;
|
|
734
1159
|
return {
|
|
@@ -758,7 +1183,7 @@ class FishNetCombatTracker {
|
|
|
758
1183
|
const actorId = packet.objectId;
|
|
759
1184
|
if (statusId === REGEN_STATUS_ID) {
|
|
760
1185
|
if (packet.rpcName === "ApplyEffect_T") {
|
|
761
|
-
const candidates = this.eligibleHealActivations(packet.tick, actorId,
|
|
1186
|
+
const candidates = this.eligibleHealActivations(packet.tick, actorId, this.regenerationSkillIds);
|
|
762
1187
|
if (candidates.length === 1) {
|
|
763
1188
|
const [candidate] = candidates;
|
|
764
1189
|
if (!candidate)
|
|
@@ -778,6 +1203,9 @@ class FishNetCombatTracker {
|
|
|
778
1203
|
this.activeRegenSources.delete(actorId);
|
|
779
1204
|
}
|
|
780
1205
|
}
|
|
1206
|
+
if (this.barrierStatusIds.has(statusId)) {
|
|
1207
|
+
this.observeBarrierStatus(actorId, packet.rpcName === "ApplyEffect_T", packet.tick);
|
|
1208
|
+
}
|
|
781
1209
|
return {
|
|
782
1210
|
kind: "status",
|
|
783
1211
|
rpc: packet.rpcName,
|
|
@@ -791,6 +1219,15 @@ class FishNetCombatTracker {
|
|
|
791
1219
|
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
792
1220
|
};
|
|
793
1221
|
}
|
|
1222
|
+
observeBarrierStatus(actorId, applied, tick) {
|
|
1223
|
+
const current = this.barriers.get(actorId) ?? { value: 0, statusActive: false };
|
|
1224
|
+
if (!applied) {
|
|
1225
|
+
this.barriers.set(actorId, { ...current, statusActive: false });
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
const source = sourceFromCandidates(this.eligibleHealActivations(tick, actorId, this.barrierSkillIds));
|
|
1229
|
+
this.barriers.set(actorId, { ...current, ...source, statusActive: true });
|
|
1230
|
+
}
|
|
794
1231
|
createActivation(actorId, actionKind, tick, sourceId, inferred) {
|
|
795
1232
|
const activation = {
|
|
796
1233
|
id: `activation-${this.nextActivation++}`,
|
|
@@ -800,148 +1237,35 @@ class FishNetCombatTracker {
|
|
|
800
1237
|
sourceLabel: sourceId === undefined ? undefined : this.skillLabels.get(sourceId) ?? sourceId,
|
|
801
1238
|
startTick: tick,
|
|
802
1239
|
inferred
|
|
803
|
-
};
|
|
804
|
-
this.activations.set(activation.id, activation);
|
|
805
|
-
return activation;
|
|
806
|
-
}
|
|
807
|
-
findLifecycleActivation(actorId) {
|
|
808
|
-
return [...this.activations.values()].filter((activation) => activation.actorId === actorId && activation.actionKind === "skill" && activation.endTick === undefined).sort((left, right) => left.startTick - right.startTick)[0];
|
|
809
|
-
}
|
|
810
|
-
eligibleActivations(tick, actorId) {
|
|
811
|
-
return [...this.activations.values()].filter((activation) => {
|
|
812
|
-
return activation.actorId === actorId && activation.startTick <= tick && (activation.deadlineTick === undefined || tick <= activation.deadlineTick);
|
|
813
|
-
});
|
|
814
|
-
}
|
|
815
|
-
pruneExpired(tick) {
|
|
816
|
-
const expired = [...this.activations.values()].filter((activation) => activation.deadlineTick !== undefined ? activation.deadlineTick < tick : tick - activation.startTick > this.activationMaxAgeTicks).map(({ id }) => id);
|
|
817
|
-
for (const id of expired)
|
|
818
|
-
this.activations.delete(id);
|
|
819
|
-
}
|
|
820
|
-
}
|
|
821
|
-
function drainRecoverySource(traits) {
|
|
822
|
-
if (traits?.hasSiphonHealth && !traits.hasHealthLeech) {
|
|
823
|
-
return { sourceId: "siphon-health", sourceLabel: "Siphon Health" };
|
|
824
|
-
}
|
|
825
|
-
if (traits?.hasHealthLeech && !traits.hasSiphonHealth) {
|
|
826
|
-
return { sourceId: "health-leech", sourceLabel: "Health Leech" };
|
|
827
|
-
}
|
|
828
|
-
return { sourceId: "siphon-health-leech", sourceLabel: "Siphon / Health Leech" };
|
|
829
|
-
}
|
|
830
|
-
function field(packet, name) {
|
|
831
|
-
return packet.decodedFields?.find((candidate) => candidate.name === name)?.value;
|
|
832
|
-
}
|
|
833
|
-
function decodedFieldRecord(packet) {
|
|
834
|
-
return Object.fromEntries(packet.decodedFields?.map(({ name, value }) => [name, value]) ?? []);
|
|
835
|
-
}
|
|
836
|
-
function decodedSummonCalibration(packet) {
|
|
837
|
-
if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
|
|
838
|
-
return;
|
|
839
|
-
const length = numberField(packet, "data.length");
|
|
840
|
-
if (length === undefined || !Number.isInteger(length) || length < 0)
|
|
841
|
-
return;
|
|
842
|
-
const entries = [];
|
|
843
|
-
for (let index = 0;index < length; index += 1) {
|
|
844
|
-
const skillId = stringField(packet, `data[${index}].SkillId`);
|
|
845
|
-
const level = numberField(packet, `data[${index}].Level`);
|
|
846
|
-
if (skillId === undefined || level === undefined || !Number.isInteger(level) || level < 0)
|
|
847
|
-
return;
|
|
848
|
-
entries.push({ skillId, level });
|
|
849
|
-
}
|
|
850
|
-
return entries;
|
|
851
|
-
}
|
|
852
|
-
function decodedLoginEffects(packet) {
|
|
853
|
-
if (packet.undecodedPayload && packet.undecodedPayload.length > 0)
|
|
854
|
-
return;
|
|
855
|
-
const length = numberField(packet, "data.State.Effects.length");
|
|
856
|
-
if (length === undefined || !Number.isInteger(length) || length < 0)
|
|
857
|
-
return;
|
|
858
|
-
const entries = [];
|
|
859
|
-
for (let index = 0;index < length; index += 1) {
|
|
860
|
-
const statusId = stringField(packet, `data.State.Effects[${index}].Id`);
|
|
861
|
-
const level = numberField(packet, `data.State.Effects[${index}].Level`);
|
|
862
|
-
const duration = numberField(packet, `data.State.Effects[${index}].Duration`);
|
|
863
|
-
const stacks = numberField(packet, `data.State.Effects[${index}].Stacks`);
|
|
864
|
-
if (statusId === undefined || level === undefined || !Number.isInteger(level) || level < 0 || duration === undefined || stacks === undefined || !Number.isInteger(stacks) || stacks < 0)
|
|
865
|
-
return;
|
|
866
|
-
entries.push({
|
|
867
|
-
statusId,
|
|
868
|
-
level,
|
|
869
|
-
...duration < 0 || !Number.isFinite(duration) ? {} : { remainingSeconds: duration },
|
|
870
|
-
stacks
|
|
1240
|
+
};
|
|
1241
|
+
this.activations.set(activation.id, activation);
|
|
1242
|
+
return activation;
|
|
1243
|
+
}
|
|
1244
|
+
findLifecycleActivation(actorId) {
|
|
1245
|
+
return [...this.activations.values()].filter((activation) => activation.actorId === actorId && activation.actionKind === "skill" && activation.endTick === undefined).sort((left, right) => left.startTick - right.startTick)[0];
|
|
1246
|
+
}
|
|
1247
|
+
eligibleActivations(tick, actorId) {
|
|
1248
|
+
return [...this.activations.values()].filter((activation) => {
|
|
1249
|
+
return activation.actorId === actorId && activation.startTick <= tick && (activation.deadlineTick === undefined || tick <= activation.deadlineTick);
|
|
871
1250
|
});
|
|
872
1251
|
}
|
|
873
|
-
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
}
|
|
878
|
-
function numberField(packet, name) {
|
|
879
|
-
const value = field(packet, name);
|
|
880
|
-
return typeof value === "number" ? value : undefined;
|
|
881
|
-
}
|
|
882
|
-
function stringField(packet, name) {
|
|
883
|
-
const value = field(packet, name);
|
|
884
|
-
return typeof value === "string" ? value : undefined;
|
|
885
|
-
}
|
|
886
|
-
function requiredNumberField(packet, name) {
|
|
887
|
-
const value = numberField(packet, name);
|
|
888
|
-
if (value === undefined)
|
|
889
|
-
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing numeric field ${name}`);
|
|
890
|
-
return value;
|
|
891
|
-
}
|
|
892
|
-
function requiredBooleanField(packet, name) {
|
|
893
|
-
const value = field(packet, name);
|
|
894
|
-
if (typeof value !== "boolean")
|
|
895
|
-
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing boolean field ${name}`);
|
|
896
|
-
return value;
|
|
897
|
-
}
|
|
898
|
-
function requiredVectorField(packet, name) {
|
|
899
|
-
const value = field(packet, name);
|
|
900
|
-
if (!Array.isArray(value))
|
|
901
|
-
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing vector field ${name}`);
|
|
902
|
-
return value;
|
|
903
|
-
}
|
|
904
|
-
function isCompleteDamagePacket(packet, requireVectors) {
|
|
905
|
-
const numeric = [
|
|
906
|
-
"dmg.Team",
|
|
907
|
-
"dmg.Value",
|
|
908
|
-
"dmg.Type",
|
|
909
|
-
"dmg.Hit",
|
|
910
|
-
"dmg.Hits",
|
|
911
|
-
"dmg.AttackerId",
|
|
912
|
-
"dmg.Element",
|
|
913
|
-
"dmg.WeaponType",
|
|
914
|
-
"dmg.Range"
|
|
915
|
-
];
|
|
916
|
-
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
|
-
}
|
|
918
|
-
function isCompleteRecoverPacket(packet) {
|
|
919
|
-
return numberField(packet, "amount") !== undefined;
|
|
920
|
-
}
|
|
921
|
-
function nullableStringField(packet, name) {
|
|
922
|
-
const value = field(packet, name);
|
|
923
|
-
return typeof value === "string" || value === null ? value : undefined;
|
|
924
|
-
}
|
|
925
|
-
function damageSignature(tick, targetId, actorId, sourceId, value, hitCode) {
|
|
926
|
-
return `${tick}\x00${targetId}\x00${actorId}\x00${sourceId}\x00${value}\x00${hitCode}`;
|
|
1252
|
+
pruneExpired(tick) {
|
|
1253
|
+
const expired = [...this.activations.values()].filter((activation) => activation.deadlineTick !== undefined ? activation.deadlineTick < tick : tick - activation.startTick > this.activationMaxAgeTicks).map(({ id }) => id);
|
|
1254
|
+
for (const id of expired)
|
|
1255
|
+
this.activations.delete(id);
|
|
1256
|
+
}
|
|
927
1257
|
}
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
]
|
|
931
|
-
function resolveDamageSource(rawSourceId, damageType, skillLabels) {
|
|
932
|
-
if (typeof rawSourceId === "string") {
|
|
1258
|
+
function sourceFromCandidates(candidates) {
|
|
1259
|
+
if (candidates.length === 1) {
|
|
1260
|
+
const candidate = candidates[0];
|
|
933
1261
|
return {
|
|
934
|
-
|
|
935
|
-
|
|
1262
|
+
actorId: candidate.actorId,
|
|
1263
|
+
sourceId: candidate.sourceId,
|
|
1264
|
+
sourceLabel: candidate.sourceLabel,
|
|
1265
|
+
activationId: candidate.id
|
|
936
1266
|
};
|
|
937
1267
|
}
|
|
938
|
-
return
|
|
939
|
-
sourceId: "unknown",
|
|
940
|
-
sourceLabel: "unknown"
|
|
941
|
-
};
|
|
942
|
-
}
|
|
943
|
-
function uniqueMonsterIdentityEvents(events) {
|
|
944
|
-
return events.filter((event, index) => event.operation !== "reset" || events.findIndex((candidate) => candidate.operation === "reset") === index);
|
|
1268
|
+
return candidates.length > 1 ? { candidateActivationIds: candidates.map(({ id }) => id) } : {};
|
|
945
1269
|
}
|
|
946
1270
|
function tryLoadBundledSkillCatalog(buildFingerprint) {
|
|
947
1271
|
try {
|
|
@@ -950,9 +1274,9 @@ function tryLoadBundledSkillCatalog(buildFingerprint) {
|
|
|
950
1274
|
return;
|
|
951
1275
|
}
|
|
952
1276
|
}
|
|
953
|
-
function
|
|
1277
|
+
function tryLoadBundledRpcMap(buildFingerprint) {
|
|
954
1278
|
try {
|
|
955
|
-
return
|
|
1279
|
+
return loadBundledFishNetRpcMap(buildFingerprint);
|
|
956
1280
|
} catch {
|
|
957
1281
|
return;
|
|
958
1282
|
}
|
|
@@ -962,15 +1286,14 @@ function assertMatchingBuild(label, candidate, expected) {
|
|
|
962
1286
|
throw new Error(`${label} build ${JSON.stringify(candidate)} does not match ${JSON.stringify(expected)}`);
|
|
963
1287
|
}
|
|
964
1288
|
}
|
|
965
|
-
// src/boss-catalog.ts
|
|
1289
|
+
// src/tracking/boss-catalog.ts
|
|
966
1290
|
function createBossCatalog(entries) {
|
|
967
1291
|
const catalog = new Map(Object.entries(entries).map(([skillId, displayName]) => [skillId, { displayName }]));
|
|
968
1292
|
return catalog;
|
|
969
1293
|
}
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
import {
|
|
973
|
-
import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
1294
|
+
// src/tracking/actor-directory.ts
|
|
1295
|
+
import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2 } from "@kar-mi/spirit-vale-tools-capture";
|
|
1296
|
+
import { readSignedPackedWhole as readSignedPackedWhole4 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
974
1297
|
|
|
975
1298
|
class FishNetActorDirectory {
|
|
976
1299
|
options;
|
|
@@ -981,6 +1304,8 @@ class FishNetActorDirectory {
|
|
|
981
1304
|
sourceRevisions = new Map;
|
|
982
1305
|
observedPlayerActors = new Set;
|
|
983
1306
|
uidIdentities = new Map;
|
|
1307
|
+
nonPlayerActorIds = new Set;
|
|
1308
|
+
clearedOnDespawn = new Set;
|
|
984
1309
|
nextSourceRevision = 1;
|
|
985
1310
|
localIdentity;
|
|
986
1311
|
constructor(options = {}) {
|
|
@@ -999,6 +1324,9 @@ class FishNetActorDirectory {
|
|
|
999
1324
|
this.clear();
|
|
1000
1325
|
return [{ kind: "actorIdentity", operation: "reset", tick: packet.tick }];
|
|
1001
1326
|
}
|
|
1327
|
+
if (this.options.stickyPlayerIdentities && packet.objectId !== undefined && (hasMonsterIdentityEvidence(packet) || hasSummonIdentityEvidence(packet))) {
|
|
1328
|
+
this.nonPlayerActorIds.add(packet.objectId);
|
|
1329
|
+
}
|
|
1002
1330
|
if (packet.packetName === "objectSpawn" && packet.objectId !== undefined) {
|
|
1003
1331
|
const observedPlayerActor = this.observedPlayerActors.has(packet.objectId);
|
|
1004
1332
|
const events = this.removeObject(packet.objectId, packet.tick, true);
|
|
@@ -1012,7 +1340,7 @@ class FishNetActorDirectory {
|
|
|
1012
1340
|
});
|
|
1013
1341
|
if (ownerConnectionId !== undefined)
|
|
1014
1342
|
this.addOwnerObject(ownerConnectionId, packet.objectId);
|
|
1015
|
-
if (hasMonsterIdentityEvidence(packet)) {
|
|
1343
|
+
if (hasMonsterIdentityEvidence(packet) || this.isStickyNonPlayer(packet)) {
|
|
1016
1344
|
events.push(...this.clearPlayerIdentity(packet.objectId, packet.tick));
|
|
1017
1345
|
events.push(...this.refreshOwner(ownerConnectionId, packet.tick, true));
|
|
1018
1346
|
return events;
|
|
@@ -1036,7 +1364,12 @@ class FishNetActorDirectory {
|
|
|
1036
1364
|
return events;
|
|
1037
1365
|
}
|
|
1038
1366
|
if (packet.packetName === "objectDespawn" && packet.objectId !== undefined) {
|
|
1039
|
-
|
|
1367
|
+
const events = this.removeObject(packet.objectId, packet.tick, true);
|
|
1368
|
+
if (this.nonPlayerActorIds.delete(packet.objectId)) {
|
|
1369
|
+
this.clearedOnDespawn.add(packet.objectId);
|
|
1370
|
+
events.push(...this.reconcile(packet.objectId, undefined, packet.tick));
|
|
1371
|
+
}
|
|
1372
|
+
return events;
|
|
1040
1373
|
}
|
|
1041
1374
|
if (packet.packetName === "ownershipChange" && packet.objectId !== undefined) {
|
|
1042
1375
|
return this.changeOwner(packet.objectId, validOwner(packet.ownerConnectionId), packet.tick);
|
|
@@ -1049,7 +1382,7 @@ class FishNetActorDirectory {
|
|
|
1049
1382
|
...this.localIdentity.archetype === undefined ? {} : { archetype: this.localIdentity.archetype }
|
|
1050
1383
|
}, packet.tick);
|
|
1051
1384
|
}
|
|
1052
|
-
if (packet.objectId !== undefined && hasMonsterIdentityEvidence(packet)) {
|
|
1385
|
+
if (packet.objectId !== undefined && (hasMonsterIdentityEvidence(packet) || this.isStickyNonPlayer(packet))) {
|
|
1053
1386
|
return this.clearPlayerIdentity(packet.objectId, packet.tick);
|
|
1054
1387
|
}
|
|
1055
1388
|
if (packet.objectId !== undefined && packet.rpcName !== undefined && CHARACTER_RPC_NAMES.has(packet.rpcName)) {
|
|
@@ -1096,6 +1429,8 @@ class FishNetActorDirectory {
|
|
|
1096
1429
|
return identity ? { ...identity } : undefined;
|
|
1097
1430
|
}
|
|
1098
1431
|
getAttribution(actorId) {
|
|
1432
|
+
if (this.clearedOnDespawn.has(actorId))
|
|
1433
|
+
return;
|
|
1099
1434
|
const direct = this.identities.get(actorId);
|
|
1100
1435
|
if (direct)
|
|
1101
1436
|
return { ...direct };
|
|
@@ -1118,7 +1453,7 @@ class FishNetActorDirectory {
|
|
|
1118
1453
|
return best ? { ...best, ownerConnectionId } : undefined;
|
|
1119
1454
|
}
|
|
1120
1455
|
snapshot() {
|
|
1121
|
-
return [...this.identities.values()].map((identity) => ({ ...identity }));
|
|
1456
|
+
return [...this.identities.values()].filter((identity) => !this.clearedOnDespawn.has(identity.actorId)).map((identity) => ({ ...identity }));
|
|
1122
1457
|
}
|
|
1123
1458
|
observePlayerActor(actorId, tick) {
|
|
1124
1459
|
if (!Number.isInteger(actorId) || actorId < 0)
|
|
@@ -1204,6 +1539,9 @@ class FishNetActorDirectory {
|
|
|
1204
1539
|
events.push(...this.refreshOwner(ownerConnectionId, tick, retainIdentity));
|
|
1205
1540
|
return events;
|
|
1206
1541
|
}
|
|
1542
|
+
isStickyNonPlayer(packet) {
|
|
1543
|
+
return this.options.stickyPlayerIdentities === true && hasSummonIdentityEvidence(packet);
|
|
1544
|
+
}
|
|
1207
1545
|
clearPlayerIdentity(actorId, tick) {
|
|
1208
1546
|
this.observedPlayerActors.delete(actorId);
|
|
1209
1547
|
this.identitySources.delete(actorId);
|
|
@@ -1280,6 +1618,7 @@ class FishNetActorDirectory {
|
|
|
1280
1618
|
}
|
|
1281
1619
|
if (current?.displayName === next.displayName && current.archetype === next.archetype && current.ownerConnectionId === next.ownerConnectionId && current.uid === next.uid)
|
|
1282
1620
|
return [];
|
|
1621
|
+
this.clearedOnDespawn.delete(actorId);
|
|
1283
1622
|
this.identities.set(actorId, next);
|
|
1284
1623
|
return [{ kind: "actorIdentity", operation: "upsert", tick, ...next }];
|
|
1285
1624
|
}
|
|
@@ -1303,6 +1642,8 @@ class FishNetActorDirectory {
|
|
|
1303
1642
|
this.identitySources.clear();
|
|
1304
1643
|
this.sourceRevisions.clear();
|
|
1305
1644
|
this.observedPlayerActors.clear();
|
|
1645
|
+
this.nonPlayerActorIds.clear();
|
|
1646
|
+
this.clearedOnDespawn.clear();
|
|
1306
1647
|
this.nextSourceRevision = 1;
|
|
1307
1648
|
}
|
|
1308
1649
|
}
|
|
@@ -1334,8 +1675,16 @@ function hasMonsterIdentityEvidence(packet) {
|
|
|
1334
1675
|
const mobId = decodedField(packet, "Data.Id") ?? decodedField(packet, "Monster.Id") ?? decodedField(packet, "Id");
|
|
1335
1676
|
return typeof mobId === "string" && mobId.length > 0;
|
|
1336
1677
|
}
|
|
1678
|
+
function hasSummonIdentityEvidence(packet) {
|
|
1679
|
+
if (packet.packetName === "objectSpawn") {
|
|
1680
|
+
return packet.spawnSyncEntries?.some((candidate) => candidate.networkBehaviourType === "SummoningComponent" && candidate.name === "SummonerSync") ?? false;
|
|
1681
|
+
}
|
|
1682
|
+
if (packet.packetName !== "syncType" || packet.networkBehaviourType !== "SummoningComponent")
|
|
1683
|
+
return false;
|
|
1684
|
+
return packet.decodedFields?.some((field2) => field2.name === "SummonerSync") ?? false;
|
|
1685
|
+
}
|
|
1337
1686
|
var VISUAL_DATA_SYNC_INDEX = 5;
|
|
1338
|
-
var PLAYER_PREFAB_KEYS = new Set((
|
|
1687
|
+
var PLAYER_PREFAB_KEYS = new Set((loadBundledFishNetRpcMap2().prefabs ?? []).filter(({ components }) => components.some(({ typeName }) => typeName === "PlayerController")).map(({ collectionId, prefabId }) => `${collectionId}:${prefabId}`));
|
|
1339
1688
|
function decodeSpawnIdentity(packet) {
|
|
1340
1689
|
if (!hasPlayerControllerEvidence(packet))
|
|
1341
1690
|
return;
|
|
@@ -1374,7 +1723,7 @@ function decodeCharacterDataName(payload) {
|
|
|
1374
1723
|
try {
|
|
1375
1724
|
let offset = 0;
|
|
1376
1725
|
if (skipEnum)
|
|
1377
|
-
offset =
|
|
1726
|
+
offset = readSignedPackedWhole4(payload, offset).nextOffset;
|
|
1378
1727
|
const run = decodeFieldRun(payload, [characterNameParameter()], offset);
|
|
1379
1728
|
const values = new Map(run.fields.map((field2) => [field2.name, field2.value]));
|
|
1380
1729
|
const uid = values.get("data.UID");
|
|
@@ -1398,7 +1747,7 @@ function characterNameParameter() {
|
|
|
1398
1747
|
cachedCharacterNameParameter = { ...full, fields: fields.slice(0, nameIndex + 1) };
|
|
1399
1748
|
return cachedCharacterNameParameter;
|
|
1400
1749
|
}
|
|
1401
|
-
// src/status-tracker.ts
|
|
1750
|
+
// src/tracking/status-tracker.ts
|
|
1402
1751
|
import {
|
|
1403
1752
|
FishNetStatusDirectory,
|
|
1404
1753
|
loadBundledStatusCatalog,
|
|
@@ -1778,21 +2127,24 @@ function recordHit(actor, hit, maxTimelineBuckets) {
|
|
|
1778
2127
|
if (hit.critical)
|
|
1779
2128
|
skill.criticalHits += 1;
|
|
1780
2129
|
}
|
|
2130
|
+
function foldEnemySkill(byEnemy, enemyId, sourceId, sourceLabel, value, critical) {
|
|
2131
|
+
const bySkill = byEnemy.get(enemyId) ?? new Map;
|
|
2132
|
+
byEnemy.set(enemyId, bySkill);
|
|
2133
|
+
const stats = bySkill.get(sourceId) ?? { sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
|
|
2134
|
+
stats.sourceLabel = sourceLabel;
|
|
2135
|
+
stats.damage += value;
|
|
2136
|
+
stats.hits += 1;
|
|
2137
|
+
if (critical)
|
|
2138
|
+
stats.criticalHits += 1;
|
|
2139
|
+
bySkill.set(sourceId, stats);
|
|
2140
|
+
}
|
|
1781
2141
|
function positiveTau(value) {
|
|
1782
2142
|
if (!Number.isFinite(value) || value <= 0)
|
|
1783
2143
|
throw new Error("currentTauSeconds must be a positive finite number");
|
|
1784
2144
|
return value;
|
|
1785
2145
|
}
|
|
1786
2146
|
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);
|
|
2147
|
+
foldEnemySkill(actor.enemySkills, event.targetId, event.sourceId, event.sourceLabel, event.value, event.hitResult === "critical");
|
|
1796
2148
|
if (!encounter.enemyFirstSeenAtMs.has(event.targetId)) {
|
|
1797
2149
|
encounter.enemyFirstSeenAtMs.set(event.targetId, observedAtMs);
|
|
1798
2150
|
}
|
|
@@ -1813,6 +2165,9 @@ function createActor(actorId, encounterStartedAtMs, currentRate = DEFAULT_CURREN
|
|
|
1813
2165
|
criticalHits: 0,
|
|
1814
2166
|
kills: 0,
|
|
1815
2167
|
skills: new Map,
|
|
2168
|
+
absorbed: 0,
|
|
2169
|
+
absorbedSkills: new Map,
|
|
2170
|
+
absorbedByEnemy: new Map,
|
|
1816
2171
|
encounterSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
|
|
1817
2172
|
actorSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
|
|
1818
2173
|
currentRate: typeof currentRate === "number" ? new EwmaRate({ tauSeconds: currentRate }) : currentRate
|
|
@@ -1846,6 +2201,7 @@ function renderEncounter(encounter, options = {}) {
|
|
|
1846
2201
|
const durationMs = Math.max(minimumDurationMs, encounter.lastDamageAtMs - encounter.startedAtMs);
|
|
1847
2202
|
const mergedActors = mergeActors(encounter.actors);
|
|
1848
2203
|
const totalDamage = mergedActors.reduce((sum, actor) => sum + actor.damage, 0);
|
|
2204
|
+
const totalAbsorbed = mergedActors.reduce((sum, actor) => sum + actor.absorbed, 0);
|
|
1849
2205
|
const rowForActor = (actor, rowId) => actorRow(actor, rowId, encounter.startedAtMs, durationMs, totalDamage, snapshotNowMs, minimumDurationMs, actor.displayName === undefined, "encounter");
|
|
1850
2206
|
const displayed = visibleDisplayGroups(mergedActors, encounter, {
|
|
1851
2207
|
snapshotNowMs,
|
|
@@ -1870,6 +2226,7 @@ function renderEncounter(encounter, options = {}) {
|
|
|
1870
2226
|
...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs },
|
|
1871
2227
|
durationMs,
|
|
1872
2228
|
totalDamage,
|
|
2229
|
+
...totalAbsorbed <= 0 ? {} : { totalAbsorbed },
|
|
1873
2230
|
partyDps: perSecond(totalDamage, durationMs),
|
|
1874
2231
|
partyCurrentDps,
|
|
1875
2232
|
actors,
|
|
@@ -1887,6 +2244,12 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
|
|
|
1887
2244
|
...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
|
|
1888
2245
|
})).sort(compareRows);
|
|
1889
2246
|
const series = alignment === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
2247
|
+
const absorbedSkills = actor.absorbed <= 0 ? undefined : [...actor.absorbedSkills.values()].map((skill) => ({
|
|
2248
|
+
...skill,
|
|
2249
|
+
dps: perSecond(skill.damage, durationMs),
|
|
2250
|
+
contribution: skill.damage / actor.absorbed,
|
|
2251
|
+
...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
|
|
2252
|
+
})).sort(compareRows);
|
|
1890
2253
|
return {
|
|
1891
2254
|
rowId,
|
|
1892
2255
|
actorIds: [...actor.actorIds],
|
|
@@ -1895,6 +2258,8 @@ function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, min
|
|
|
1895
2258
|
durationMs,
|
|
1896
2259
|
...actor.lastDamageAtMs === undefined ? {} : { lastDamageAtMs: actor.lastDamageAtMs },
|
|
1897
2260
|
damage: actor.damage,
|
|
2261
|
+
...actor.absorbed <= 0 ? {} : { absorbed: actor.absorbed },
|
|
2262
|
+
...absorbedSkills === undefined || absorbedSkills.length === 0 ? {} : { absorbedSkills },
|
|
1898
2263
|
dps: perSecond(actor.damage, durationMs),
|
|
1899
2264
|
currentDps: actor.currentRate.rateAt(nowMs, { fromMs: startedAtMs, minimumMs: minimumDurationMs }),
|
|
1900
2265
|
contribution: partyDamage === 0 ? 0 : actor.damage / partyDamage,
|
|
@@ -1932,7 +2297,7 @@ function actorRowId(actor) {
|
|
|
1932
2297
|
function mergeActors(actors) {
|
|
1933
2298
|
const merged = new Map;
|
|
1934
2299
|
for (const actor of actors) {
|
|
1935
|
-
if (actor.damage <= 0)
|
|
2300
|
+
if (actor.damage <= 0 && actor.absorbed <= 0)
|
|
1936
2301
|
continue;
|
|
1937
2302
|
const displayName = actor.displayName?.trim() || undefined;
|
|
1938
2303
|
const key = actorRowId(actor);
|
|
@@ -2004,6 +2369,21 @@ function accumulate(target, actor) {
|
|
|
2004
2369
|
target.skills.set(skill.sourceId, { ...skill });
|
|
2005
2370
|
}
|
|
2006
2371
|
}
|
|
2372
|
+
target.absorbed += actor.absorbed;
|
|
2373
|
+
for (const skill of actor.absorbedSkills.values()) {
|
|
2374
|
+
const current = target.absorbedSkills.get(skill.sourceId);
|
|
2375
|
+
if (current) {
|
|
2376
|
+
current.sourceLabel = skill.sourceLabel;
|
|
2377
|
+
current.damage += skill.damage;
|
|
2378
|
+
current.hits += skill.hits;
|
|
2379
|
+
current.criticalHits += skill.criticalHits;
|
|
2380
|
+
} else {
|
|
2381
|
+
target.absorbedSkills.set(skill.sourceId, { ...skill });
|
|
2382
|
+
}
|
|
2383
|
+
}
|
|
2384
|
+
for (const [enemyId, value] of actor.absorbedByEnemy) {
|
|
2385
|
+
target.absorbedByEnemy.set(enemyId, (target.absorbedByEnemy.get(enemyId) ?? 0) + value);
|
|
2386
|
+
}
|
|
2007
2387
|
}
|
|
2008
2388
|
function rebase(series, originMs) {
|
|
2009
2389
|
const shift = Math.round((series.originMs - originMs) / series.widthMs);
|
|
@@ -2038,7 +2418,7 @@ function maxDefined(left, right) {
|
|
|
2038
2418
|
return Math.max(left, right);
|
|
2039
2419
|
}
|
|
2040
2420
|
|
|
2041
|
-
// src/status-tracker.ts
|
|
2421
|
+
// src/tracking/status-tracker.ts
|
|
2042
2422
|
var REFRESHING_FEEDS = new Set(["ApplyEffectDisplays_O", "ApplySkillDisplay_O"]);
|
|
2043
2423
|
var EXPIRY_REFRESH_TOLERANCE_MS = 2000;
|
|
2044
2424
|
var KEEP_ALIVE_GRACE_MS = 1000;
|
|
@@ -2286,31 +2666,24 @@ class FishNetStatusTracker {
|
|
|
2286
2666
|
function sameTracked(previous, next) {
|
|
2287
2667
|
return previous !== undefined && previous.level === next.level && previous.appliedAtMs === next.appliedAtMs && previous.expiresAtMs === next.expiresAtMs && previous.stacks === next.stacks && previous.summon === next.summon;
|
|
2288
2668
|
}
|
|
2289
|
-
// src/replay.ts
|
|
2290
|
-
import { isRecord,
|
|
2669
|
+
// src/replay/replay.ts
|
|
2670
|
+
import { isRecord, LogRecordLineDecoder, readTextLines } from "@kar-mi/spirit-vale-tools-logging";
|
|
2291
2671
|
async function loadDpsReplay(path, personalName = "") {
|
|
2292
2672
|
const finished = [];
|
|
2293
2673
|
const reducer = new DamageReducer({ onEncounterFinished: (encounter) => finished.push(encounter) });
|
|
2294
2674
|
let invalidLines = 0;
|
|
2295
2675
|
let recordedAtOriginMs;
|
|
2296
2676
|
let lastTime = 0;
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
try {
|
|
2302
|
-
candidate = JSON.parse(line);
|
|
2303
|
-
} catch {
|
|
2304
|
-
invalidLines += 1;
|
|
2677
|
+
const records = new LogRecordLineDecoder;
|
|
2678
|
+
for await (const line of readTextLines(Bun.file(path).stream())) {
|
|
2679
|
+
const decoded = records.decode(line);
|
|
2680
|
+
if (decoded.kind === "empty" || decoded.kind === "header")
|
|
2305
2681
|
continue;
|
|
2306
|
-
|
|
2307
|
-
if (isLogStreamHeader(candidate))
|
|
2308
|
-
continue;
|
|
2309
|
-
const record = parseLogRecord(candidate);
|
|
2310
|
-
if (!record) {
|
|
2682
|
+
if (decoded.kind === "invalid") {
|
|
2311
2683
|
invalidLines += 1;
|
|
2312
2684
|
continue;
|
|
2313
2685
|
}
|
|
2686
|
+
const record = decoded.record;
|
|
2314
2687
|
const event = parseDpsLogRecord(record.type, record.data);
|
|
2315
2688
|
if (event === null)
|
|
2316
2689
|
continue;
|
|
@@ -2330,36 +2703,6 @@ async function loadDpsReplay(path, personalName = "") {
|
|
|
2330
2703
|
const snapshots = finished.map((encounter) => renderEncounter(encounter, { personalName }));
|
|
2331
2704
|
return { snapshots, invalidLines };
|
|
2332
2705
|
}
|
|
2333
|
-
async function* readLines(stream) {
|
|
2334
|
-
const reader = stream.getReader();
|
|
2335
|
-
let decoder;
|
|
2336
|
-
let pending = "";
|
|
2337
|
-
try {
|
|
2338
|
-
while (true) {
|
|
2339
|
-
const { done, value } = await reader.read();
|
|
2340
|
-
if (done)
|
|
2341
|
-
break;
|
|
2342
|
-
if (!decoder)
|
|
2343
|
-
decoder = decoderFor(value);
|
|
2344
|
-
pending += decoder.decode(value, { stream: true });
|
|
2345
|
-
const lines = pending.split(/\r?\n/);
|
|
2346
|
-
pending = lines.pop() ?? "";
|
|
2347
|
-
yield* lines;
|
|
2348
|
-
}
|
|
2349
|
-
pending += decoder?.decode() ?? "";
|
|
2350
|
-
if (pending)
|
|
2351
|
-
yield pending;
|
|
2352
|
-
} finally {
|
|
2353
|
-
reader.releaseLock();
|
|
2354
|
-
}
|
|
2355
|
-
}
|
|
2356
|
-
function decoderFor(firstChunk) {
|
|
2357
|
-
if (firstChunk[0] === 255 && firstChunk[1] === 254)
|
|
2358
|
-
return new TextDecoder("utf-16le");
|
|
2359
|
-
if (firstChunk[0] === 254 && firstChunk[1] === 255)
|
|
2360
|
-
return new TextDecoder("utf-16be");
|
|
2361
|
-
return new TextDecoder("utf-8");
|
|
2362
|
-
}
|
|
2363
2706
|
function parseDpsLogEvent(value) {
|
|
2364
2707
|
if (!isRecord(value) || !isFiniteNumber(value["tick"]) || typeof value["kind"] !== "string")
|
|
2365
2708
|
return;
|
|
@@ -2402,10 +2745,17 @@ function parseDpsLogEvent(value) {
|
|
|
2402
2745
|
if (value["kind"] === "heal") {
|
|
2403
2746
|
if (!isFiniteNumber(value["targetId"]) || !isFiniteNumber(value["value"]))
|
|
2404
2747
|
return;
|
|
2748
|
+
if (value["attribution"] !== undefined && value["attribution"] !== "exact" && value["attribution"] !== "inferred" && value["attribution"] !== "ambiguous" && value["attribution"] !== "unattributed")
|
|
2749
|
+
return;
|
|
2405
2750
|
if (value["recoveryStyle"] !== undefined && value["recoveryStyle"] !== "standard" && value["recoveryStyle"] !== "passive-regeneration" && value["recoveryStyle"] !== "drain" && value["recoveryStyle"] !== "unknown")
|
|
2406
2751
|
return;
|
|
2407
2752
|
return value;
|
|
2408
2753
|
}
|
|
2754
|
+
if (value["kind"] === "shield") {
|
|
2755
|
+
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")
|
|
2756
|
+
return;
|
|
2757
|
+
return value;
|
|
2758
|
+
}
|
|
2409
2759
|
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
2760
|
return;
|
|
2411
2761
|
if (value["kind"] === "death" && typeof value["duplicatesDamageEvent"] !== "boolean")
|
|
@@ -2427,9 +2777,9 @@ function parseDpsLogRecord(type, data) {
|
|
|
2427
2777
|
function isFiniteNumber(value) {
|
|
2428
2778
|
return typeof value === "number" && Number.isFinite(value);
|
|
2429
2779
|
}
|
|
2430
|
-
// src/replay-capture.ts
|
|
2431
|
-
import {
|
|
2432
|
-
import {
|
|
2780
|
+
// src/replay/replay-capture.ts
|
|
2781
|
+
import { FishNetTransportReplay } from "@kar-mi/spirit-vale-tools-capture";
|
|
2782
|
+
import { LogRecordLineDecoder as LogRecordLineDecoder2, readTextLines as readTextLines2 } from "@kar-mi/spirit-vale-tools-logging";
|
|
2433
2783
|
function decodeCombatCaptureJsonLines(text, options) {
|
|
2434
2784
|
const replay = new CombatCaptureReplay(options);
|
|
2435
2785
|
for (const line of text.split(/\r?\n/))
|
|
@@ -2442,7 +2792,7 @@ async function replayCombatCapture(path, options) {
|
|
|
2442
2792
|
async function replayCombatCaptures(paths, options) {
|
|
2443
2793
|
const replay = new CombatCaptureReplay(options);
|
|
2444
2794
|
for (const path of paths) {
|
|
2445
|
-
for await (const line of
|
|
2795
|
+
for await (const line of readTextLines2(Bun.file(path).stream()))
|
|
2446
2796
|
replay.consumeLine(line);
|
|
2447
2797
|
}
|
|
2448
2798
|
return replay.result();
|
|
@@ -2450,120 +2800,48 @@ async function replayCombatCaptures(paths, options) {
|
|
|
2450
2800
|
|
|
2451
2801
|
class CombatCaptureReplay {
|
|
2452
2802
|
options;
|
|
2453
|
-
|
|
2454
|
-
|
|
2803
|
+
records = new LogRecordLineDecoder2;
|
|
2804
|
+
transport = new FishNetTransportReplay;
|
|
2455
2805
|
combatEvents = 0;
|
|
2456
2806
|
identityEvents = 0;
|
|
2457
2807
|
invalidLines = 0;
|
|
2458
|
-
decodeWarnings = 0;
|
|
2459
2808
|
constructor(options) {
|
|
2460
2809
|
this.options = options;
|
|
2461
2810
|
}
|
|
2462
2811
|
consumeLine(line) {
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
let record;
|
|
2466
|
-
try {
|
|
2467
|
-
const value = JSON.parse(line);
|
|
2468
|
-
if (isLogStreamHeader2(value))
|
|
2469
|
-
return;
|
|
2470
|
-
record = parseLogRecord2(value);
|
|
2471
|
-
} catch {
|
|
2472
|
-
record = undefined;
|
|
2473
|
-
}
|
|
2474
|
-
if (!record) {
|
|
2475
|
-
this.invalidLines += 1;
|
|
2476
|
-
return;
|
|
2477
|
-
}
|
|
2478
|
-
if (record.type !== "transport.packet")
|
|
2812
|
+
const decoded = this.records.decode(line);
|
|
2813
|
+
if (decoded.kind === "empty" || decoded.kind === "header")
|
|
2479
2814
|
return;
|
|
2480
|
-
|
|
2481
|
-
if (!packet) {
|
|
2815
|
+
if (decoded.kind === "invalid") {
|
|
2482
2816
|
this.invalidLines += 1;
|
|
2483
2817
|
return;
|
|
2484
2818
|
}
|
|
2485
|
-
this.
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
const connectionBase = `${endpoints[0]}<->${endpoints[1]}`;
|
|
2491
|
-
const direction = source === endpoints[0] ? "a-to-b" : "b-to-a";
|
|
2492
|
-
try {
|
|
2493
|
-
for (const decoded of decodeLiteNetLibDatagram(Buffer.from(packet.payloadHex, "hex"))) {
|
|
2494
|
-
const packet2 = decoded.packet;
|
|
2495
|
-
const connectionId = `${connectionBase}#${packet2.connectionNumber}`;
|
|
2496
|
-
if (packet2.property === "connectRequest" || packet2.property === "connectAccept" || packet2.property === "disconnect") {
|
|
2497
|
-
this.session.reset(connectionId);
|
|
2498
|
-
continue;
|
|
2499
|
-
}
|
|
2500
|
-
if (packet2.property !== "unreliable" && packet2.property !== "channeled" || packet2.payload.length < 6)
|
|
2501
|
-
continue;
|
|
2502
|
-
const fishNetPackets = this.session.decode(packet2.payload, {
|
|
2503
|
-
reliable: packet2.property === "channeled",
|
|
2504
|
-
connectionId,
|
|
2505
|
-
direction,
|
|
2506
|
-
channel: packet2.property === "channeled" ? packet2.channel : 1,
|
|
2507
|
-
sequence: packet2.property === "channeled" ? packet2.sequence : undefined
|
|
2508
|
-
});
|
|
2509
|
-
for (const fishNetPacket of fishNetPackets) {
|
|
2510
|
-
for (const event of this.options.directory.consume(fishNetPacket)) {
|
|
2511
|
-
this.identityEvents += 1;
|
|
2512
|
-
if (Number.isFinite(observedAtMs))
|
|
2513
|
-
this.options.onEvent?.(event, observedAtMs);
|
|
2514
|
-
}
|
|
2515
|
-
for (const event of this.options.tracker.consume(fishNetPacket)) {
|
|
2516
|
-
this.combatEvents += 1;
|
|
2517
|
-
if (Number.isFinite(observedAtMs))
|
|
2518
|
-
this.options.onEvent?.(event, observedAtMs);
|
|
2519
|
-
}
|
|
2520
|
-
}
|
|
2819
|
+
const result = this.transport.consumeRecord(decoded.record, (packet, observedAtMs) => {
|
|
2820
|
+
for (const event of this.options.directory.consume(packet)) {
|
|
2821
|
+
this.identityEvents += 1;
|
|
2822
|
+
if (observedAtMs !== undefined)
|
|
2823
|
+
this.options.onEvent?.(event, observedAtMs);
|
|
2521
2824
|
}
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2825
|
+
for (const event of this.options.tracker.consume(packet)) {
|
|
2826
|
+
this.combatEvents += 1;
|
|
2827
|
+
if (observedAtMs !== undefined)
|
|
2828
|
+
this.options.onEvent?.(event, observedAtMs);
|
|
2829
|
+
}
|
|
2830
|
+
});
|
|
2831
|
+
if (result === "invalid")
|
|
2832
|
+
this.invalidLines += 1;
|
|
2525
2833
|
}
|
|
2526
2834
|
result() {
|
|
2527
2835
|
return {
|
|
2528
|
-
|
|
2836
|
+
...this.transport.stats(),
|
|
2529
2837
|
combatEvents: this.combatEvents,
|
|
2530
2838
|
identityEvents: this.identityEvents,
|
|
2531
|
-
invalidLines: this.invalidLines
|
|
2532
|
-
decodeWarnings: this.decodeWarnings
|
|
2839
|
+
invalidLines: this.invalidLines
|
|
2533
2840
|
};
|
|
2534
2841
|
}
|
|
2535
2842
|
}
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
return;
|
|
2539
|
-
return data;
|
|
2540
|
-
}
|
|
2541
|
-
function isPort(value) {
|
|
2542
|
-
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
2543
|
-
}
|
|
2544
|
-
async function* readLines2(stream) {
|
|
2545
|
-
const reader = stream.getReader();
|
|
2546
|
-
const decoder = new TextDecoder;
|
|
2547
|
-
let pending = "";
|
|
2548
|
-
try {
|
|
2549
|
-
while (true) {
|
|
2550
|
-
const { done, value } = await reader.read();
|
|
2551
|
-
if (done)
|
|
2552
|
-
break;
|
|
2553
|
-
pending += decoder.decode(value, { stream: true });
|
|
2554
|
-
const lines = pending.split(/\r?\n/);
|
|
2555
|
-
pending = lines.pop() ?? "";
|
|
2556
|
-
yield* lines;
|
|
2557
|
-
}
|
|
2558
|
-
pending += decoder.decode();
|
|
2559
|
-
if (pending)
|
|
2560
|
-
yield pending;
|
|
2561
|
-
} finally {
|
|
2562
|
-
reader.releaseLock();
|
|
2563
|
-
}
|
|
2564
|
-
}
|
|
2565
|
-
// src/replay-summary.ts
|
|
2566
|
-
import { compact, count, warnings } from "@kar-mi/spirit-vale-tools-capture";
|
|
2843
|
+
// src/replay/replay-summary.ts
|
|
2844
|
+
import { compact, count, warnings } from "@kar-mi/spirit-vale-tools-logging";
|
|
2567
2845
|
async function readCombatReplaySummary(path) {
|
|
2568
2846
|
const replay = await loadDpsReplay(path);
|
|
2569
2847
|
const encounters = replay.snapshots;
|
|
@@ -2589,7 +2867,7 @@ function duration(milliseconds) {
|
|
|
2589
2867
|
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
2590
2868
|
}
|
|
2591
2869
|
// src/history/domain.ts
|
|
2592
|
-
var COMBAT_DOMAIN_VERSION =
|
|
2870
|
+
var COMBAT_DOMAIN_VERSION = 8;
|
|
2593
2871
|
var COMBAT_DOMAIN_NAME = "combat";
|
|
2594
2872
|
var SCHEMA = `
|
|
2595
2873
|
create table if not exists combat_encounters (
|
|
@@ -2642,6 +2920,8 @@ create table if not exists combat_actors (
|
|
|
2642
2920
|
hits integer not null default 0,
|
|
2643
2921
|
critical_hits integer not null default 0,
|
|
2644
2922
|
kills integer not null default 0,
|
|
2923
|
+
-- Tanked meter only: damage a shield on this actor soaked, kept apart from the damage column (raw damage taken).
|
|
2924
|
+
absorbed integer not null default 0,
|
|
2645
2925
|
-- The current-DPS rate estimator: its accumulated rate and the time that rate was last advanced
|
|
2646
2926
|
-- to. Needed so a pass that resumes mid-encounter reports the same currentDps as one that never
|
|
2647
2927
|
-- stopped. Two numbers, where a trailing window of recent hits used to be serialised here.
|
|
@@ -2691,11 +2971,13 @@ create table if not exists combat_timeline_buckets (
|
|
|
2691
2971
|
primary key (session_id, encounter_id, meter, actor_index, origin, bucket_index)
|
|
2692
2972
|
);
|
|
2693
2973
|
|
|
2694
|
-
-- Per rendered
|
|
2695
|
-
--
|
|
2974
|
+
-- Per rendered actor lifetime, per enemy, per skill. The meter column is 'dps' (outgoing party
|
|
2975
|
+
-- damage by enemy skill) or 'tanked' (incoming damage by enemy skill). The actor index prevents a
|
|
2976
|
+
-- reused network actor id from assigning the same hit to two rows that are actually different players.
|
|
2696
2977
|
create table if not exists combat_enemy_skills (
|
|
2697
2978
|
session_id text not null,
|
|
2698
2979
|
encounter_id text not null,
|
|
2980
|
+
meter text not null default 'dps',
|
|
2699
2981
|
actor_index integer not null,
|
|
2700
2982
|
target_id integer not null,
|
|
2701
2983
|
source_id text not null,
|
|
@@ -2703,7 +2985,7 @@ create table if not exists combat_enemy_skills (
|
|
|
2703
2985
|
damage integer not null default 0,
|
|
2704
2986
|
hits integer not null default 0,
|
|
2705
2987
|
critical_hits integer not null default 0,
|
|
2706
|
-
primary key (session_id, encounter_id, actor_index, target_id, source_id)
|
|
2988
|
+
primary key (session_id, encounter_id, meter, actor_index, target_id, source_id)
|
|
2707
2989
|
);
|
|
2708
2990
|
|
|
2709
2991
|
create table if not exists combat_enemies (
|
|
@@ -2816,15 +3098,15 @@ class MeterReducer {
|
|
|
2816
3098
|
actor.archetype = event.archetype;
|
|
2817
3099
|
actor.activeIdentity = true;
|
|
2818
3100
|
}
|
|
2819
|
-
consumeCombat(event, observedAtMs, identities) {
|
|
3101
|
+
consumeCombat(event, observedAtMs, identities, mobIdentities) {
|
|
2820
3102
|
if (!this.current)
|
|
2821
3103
|
return;
|
|
2822
3104
|
const hit = this.kind === "tanked" ? tankedHit(event, identities) : healingHit(event, identities);
|
|
2823
3105
|
if (!hit)
|
|
2824
3106
|
return;
|
|
2825
|
-
this.apply(hit, observedAtMs);
|
|
3107
|
+
this.apply(hit, observedAtMs, mobIdentities);
|
|
2826
3108
|
}
|
|
2827
|
-
apply(hit, observedAtMs) {
|
|
3109
|
+
apply(hit, observedAtMs, mobIdentities) {
|
|
2828
3110
|
const encounter = this.current;
|
|
2829
3111
|
encounter.lastDamageAtMs = Math.max(encounter.lastDamageAtMs, observedAtMs);
|
|
2830
3112
|
let actor = encounter.activeActors.get(hit.actorId);
|
|
@@ -2839,12 +3121,39 @@ class MeterReducer {
|
|
|
2839
3121
|
actor.archetype = hit.identity.archetype;
|
|
2840
3122
|
actor.activeIdentity = true;
|
|
2841
3123
|
}
|
|
3124
|
+
if (hit.channel === "absorbed") {
|
|
3125
|
+
recordAbsorbed(actor, hit);
|
|
3126
|
+
return;
|
|
3127
|
+
}
|
|
2842
3128
|
recordHit(actor, { ...hit, atMs: observedAtMs }, this.maxTimelineBuckets);
|
|
2843
3129
|
if (hit.counterpartId !== undefined)
|
|
2844
3130
|
actor.targetIds.add(hit.counterpartId);
|
|
3131
|
+
if (this.kind === "tanked" && hit.counterpartId !== undefined) {
|
|
3132
|
+
const enemyId = hit.counterpartId;
|
|
3133
|
+
actor.targetDamage.set(enemyId, (actor.targetDamage.get(enemyId) ?? 0) + hit.value);
|
|
3134
|
+
foldEnemySkill(actor.enemySkills, enemyId, hit.sourceId, hit.sourceLabel, hit.value, hit.critical);
|
|
3135
|
+
if (!encounter.enemyFirstSeenAtMs.has(enemyId))
|
|
3136
|
+
encounter.enemyFirstSeenAtMs.set(enemyId, observedAtMs);
|
|
3137
|
+
const name = mobIdentities?.get(enemyId);
|
|
3138
|
+
if (name !== undefined)
|
|
3139
|
+
encounter.enemyNames.set(enemyId, name);
|
|
3140
|
+
}
|
|
3141
|
+
}
|
|
3142
|
+
}
|
|
3143
|
+
function recordAbsorbed(actor, hit) {
|
|
3144
|
+
actor.absorbed += hit.value;
|
|
3145
|
+
const skill = actor.absorbedSkills.get(hit.sourceId) ?? { sourceId: hit.sourceId, sourceLabel: hit.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
|
|
3146
|
+
skill.sourceLabel = hit.sourceLabel;
|
|
3147
|
+
skill.damage += hit.value;
|
|
3148
|
+
skill.hits += 1;
|
|
3149
|
+
actor.absorbedSkills.set(hit.sourceId, skill);
|
|
3150
|
+
if (hit.counterpartId !== undefined) {
|
|
3151
|
+
actor.absorbedByEnemy.set(hit.counterpartId, (actor.absorbedByEnemy.get(hit.counterpartId) ?? 0) + hit.value);
|
|
2845
3152
|
}
|
|
2846
3153
|
}
|
|
2847
3154
|
function tankedHit(event, identities) {
|
|
3155
|
+
if (event.kind === "shield")
|
|
3156
|
+
return shieldAbsorbedHit(event, identities);
|
|
2848
3157
|
if (event.kind !== "damage" && event.kind !== "death")
|
|
2849
3158
|
return;
|
|
2850
3159
|
const identity = identities.get(event.targetId);
|
|
@@ -2865,11 +3174,15 @@ function tankedHit(event, identities) {
|
|
|
2865
3174
|
};
|
|
2866
3175
|
}
|
|
2867
3176
|
function healingHit(event, identities) {
|
|
3177
|
+
if (event.kind === "shield")
|
|
3178
|
+
return shieldAppliedHit(event, identities);
|
|
2868
3179
|
if (event.kind !== "heal")
|
|
2869
3180
|
return;
|
|
2870
3181
|
if (!Number.isFinite(event.value) || event.value <= 0)
|
|
2871
3182
|
return;
|
|
2872
|
-
const healerId = event.actorId
|
|
3183
|
+
const healerId = event.actorId;
|
|
3184
|
+
if (healerId === undefined)
|
|
3185
|
+
return;
|
|
2873
3186
|
const identity = identities.get(healerId);
|
|
2874
3187
|
if (!identity)
|
|
2875
3188
|
return;
|
|
@@ -2883,31 +3196,113 @@ function healingHit(event, identities) {
|
|
|
2883
3196
|
critical: false
|
|
2884
3197
|
};
|
|
2885
3198
|
}
|
|
3199
|
+
function shieldAppliedHit(event, identities) {
|
|
3200
|
+
if (event.kind !== "shield" || event.action !== "gained")
|
|
3201
|
+
return;
|
|
3202
|
+
if (!Number.isFinite(event.value) || event.value <= 0)
|
|
3203
|
+
return;
|
|
3204
|
+
const casterId = event.actorId;
|
|
3205
|
+
if (casterId === undefined)
|
|
3206
|
+
return;
|
|
3207
|
+
const identity = identities.get(casterId);
|
|
3208
|
+
if (!identity)
|
|
3209
|
+
return;
|
|
3210
|
+
return {
|
|
3211
|
+
actorId: casterId,
|
|
3212
|
+
identity,
|
|
3213
|
+
counterpartId: event.targetId,
|
|
3214
|
+
value: event.value,
|
|
3215
|
+
sourceId: event.sourceId ?? "shield",
|
|
3216
|
+
sourceLabel: event.sourceLabel ?? "Shield",
|
|
3217
|
+
critical: false
|
|
3218
|
+
};
|
|
3219
|
+
}
|
|
3220
|
+
function shieldAbsorbedHit(event, identities) {
|
|
3221
|
+
if (event.kind !== "shield" || event.action !== "absorbed")
|
|
3222
|
+
return;
|
|
3223
|
+
if (!Number.isFinite(event.value) || event.value <= 0)
|
|
3224
|
+
return;
|
|
3225
|
+
const identity = identities.get(event.targetId);
|
|
3226
|
+
if (!identity)
|
|
3227
|
+
return;
|
|
3228
|
+
return {
|
|
3229
|
+
actorId: event.targetId,
|
|
3230
|
+
identity,
|
|
3231
|
+
...event.incomingActorId === undefined ? {} : { counterpartId: event.incomingActorId },
|
|
3232
|
+
value: event.value,
|
|
3233
|
+
sourceId: event.incomingSourceId ?? "absorbed:unknown",
|
|
3234
|
+
sourceLabel: event.incomingSourceLabel ?? "Absorbed (unattributed)",
|
|
3235
|
+
critical: false,
|
|
3236
|
+
channel: "absorbed"
|
|
3237
|
+
};
|
|
3238
|
+
}
|
|
3239
|
+
|
|
3240
|
+
// src/reducers/meter-group.ts
|
|
3241
|
+
class MeterReducerGroup {
|
|
3242
|
+
reducers;
|
|
3243
|
+
constructor(options = {}) {
|
|
3244
|
+
const shared = {
|
|
3245
|
+
...options.currentTauSeconds === undefined ? {} : { currentTauSeconds: options.currentTauSeconds },
|
|
3246
|
+
...options.maxTimelineBuckets === undefined ? {} : { maxTimelineBuckets: options.maxTimelineBuckets }
|
|
3247
|
+
};
|
|
3248
|
+
this.reducers = {
|
|
3249
|
+
tanked: new MeterReducer({ kind: "tanked", ...shared }),
|
|
3250
|
+
healing: new MeterReducer({ kind: "healing", ...shared })
|
|
3251
|
+
};
|
|
3252
|
+
}
|
|
3253
|
+
current(kind) {
|
|
3254
|
+
return this.reducers[kind].current;
|
|
3255
|
+
}
|
|
3256
|
+
consumeIdentity(event) {
|
|
3257
|
+
for (const reducer of Object.values(this.reducers))
|
|
3258
|
+
reducer.consumeIdentity(event);
|
|
3259
|
+
}
|
|
3260
|
+
consumeCombat(encounter, event, observedAtMs, identities, mobIdentities) {
|
|
3261
|
+
if (!encounter)
|
|
3262
|
+
return;
|
|
3263
|
+
for (const reducer of Object.values(this.reducers)) {
|
|
3264
|
+
if (reducer.current?.id !== encounter.id)
|
|
3265
|
+
reducer.begin(encounter.id, encounter.startedAtMs);
|
|
3266
|
+
reducer.consumeCombat(event, observedAtMs, identities, mobIdentities);
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
resume(kind, encounter) {
|
|
3270
|
+
this.reducers[kind].resume(encounter);
|
|
3271
|
+
}
|
|
3272
|
+
snapshot() {
|
|
3273
|
+
const result = new Map;
|
|
3274
|
+
for (const kind of ["tanked", "healing"]) {
|
|
3275
|
+
const current = this.reducers[kind].current;
|
|
3276
|
+
if (current)
|
|
3277
|
+
result.set(kind, current);
|
|
3278
|
+
}
|
|
3279
|
+
return result;
|
|
3280
|
+
}
|
|
3281
|
+
finish(endedAtMs) {
|
|
3282
|
+
const result = this.snapshot();
|
|
3283
|
+
for (const reducer of Object.values(this.reducers))
|
|
3284
|
+
reducer.finish(endedAtMs);
|
|
3285
|
+
return result;
|
|
3286
|
+
}
|
|
3287
|
+
reset() {
|
|
3288
|
+
for (const reducer of Object.values(this.reducers))
|
|
3289
|
+
reducer.reset();
|
|
3290
|
+
}
|
|
3291
|
+
}
|
|
2886
3292
|
|
|
2887
3293
|
// src/history/importer.ts
|
|
2888
|
-
var
|
|
2889
|
-
{ meter: "tanked", kind: "tanked" },
|
|
2890
|
-
{ meter: "healing", kind: "healing" }
|
|
2891
|
-
];
|
|
3294
|
+
var ABSORBED_METER = "absorbed";
|
|
2892
3295
|
async function indexCombatStream(model, options) {
|
|
2893
3296
|
const { sessionId, sourcePath } = options;
|
|
2894
3297
|
const finished = [];
|
|
2895
3298
|
let currentSequence = 0;
|
|
2896
3299
|
let lastObservedAtMs = 0;
|
|
2897
|
-
const meters =
|
|
2898
|
-
|
|
2899
|
-
|
|
2900
|
-
kind,
|
|
2901
|
-
...options.currentTauSeconds === undefined ? {} : { currentTauSeconds: options.currentTauSeconds }
|
|
2902
|
-
})
|
|
2903
|
-
}));
|
|
3300
|
+
const meters = new MeterReducerGroup({
|
|
3301
|
+
...options.currentTauSeconds === undefined ? {} : { currentTauSeconds: options.currentTauSeconds }
|
|
3302
|
+
});
|
|
2904
3303
|
const meterAggregates = new Map;
|
|
2905
3304
|
const captureMeters = (encounterId) => {
|
|
2906
|
-
const byMeter =
|
|
2907
|
-
for (const entry of meters) {
|
|
2908
|
-
if (entry.reducer.current)
|
|
2909
|
-
byMeter.set(entry.meter, entry.reducer.current);
|
|
2910
|
-
}
|
|
3305
|
+
const byMeter = new Map(meters.snapshot());
|
|
2911
3306
|
if (byMeter.size > 0)
|
|
2912
3307
|
meterAggregates.set(encounterId, byMeter);
|
|
2913
3308
|
};
|
|
@@ -2917,8 +3312,7 @@ async function indexCombatStream(model, options) {
|
|
|
2917
3312
|
createEncounterId: () => `enc-${currentSequence}`,
|
|
2918
3313
|
onEncounterFinished: (encounter) => {
|
|
2919
3314
|
captureMeters(encounter.id);
|
|
2920
|
-
|
|
2921
|
-
entry.reducer.finish(encounter.endedAtMs ?? encounter.lastDamageAtMs);
|
|
3315
|
+
meters.finish(encounter.endedAtMs ?? encounter.lastDamageAtMs);
|
|
2922
3316
|
finished.push(encounter);
|
|
2923
3317
|
}
|
|
2924
3318
|
});
|
|
@@ -2926,9 +3320,8 @@ async function indexCombatStream(model, options) {
|
|
|
2926
3320
|
const open = loadOpenEncounter(model, sessionId);
|
|
2927
3321
|
if (open) {
|
|
2928
3322
|
reducer.resume(open);
|
|
2929
|
-
|
|
2930
|
-
|
|
2931
|
-
}
|
|
3323
|
+
meters.resume("tanked", loadMeterAggregate(model, sessionId, open, "tanked"));
|
|
3324
|
+
meters.resume("healing", loadMeterAggregate(model, sessionId, open, "healing"));
|
|
2932
3325
|
}
|
|
2933
3326
|
const request = {
|
|
2934
3327
|
sessionId,
|
|
@@ -2964,8 +3357,7 @@ async function indexCombatStream(model, options) {
|
|
|
2964
3357
|
reducer.identities.clear();
|
|
2965
3358
|
reducer.mobIdentities.clear();
|
|
2966
3359
|
reducer.recentHits.clear();
|
|
2967
|
-
|
|
2968
|
-
entry.reducer.reset();
|
|
3360
|
+
meters.reset();
|
|
2969
3361
|
meterAggregates.clear();
|
|
2970
3362
|
finished.length = 0;
|
|
2971
3363
|
}
|
|
@@ -3008,23 +3400,15 @@ function consume(reducer, meters, record, note) {
|
|
|
3008
3400
|
note(record.sequence, observedAtMs);
|
|
3009
3401
|
if (event.kind === "actorIdentity") {
|
|
3010
3402
|
reducer.consumeIdentity(event, observedAtMs);
|
|
3011
|
-
|
|
3012
|
-
entry.reducer.consumeIdentity(event);
|
|
3403
|
+
meters.consumeIdentity(event);
|
|
3013
3404
|
return;
|
|
3014
3405
|
}
|
|
3015
3406
|
reducer.consumeCombat(event, observedAtMs);
|
|
3016
|
-
|
|
3017
|
-
if (!encounter)
|
|
3018
|
-
return;
|
|
3019
|
-
for (const entry of meters) {
|
|
3020
|
-
if (entry.reducer.current?.id !== encounter.id)
|
|
3021
|
-
entry.reducer.begin(encounter.id, encounter.startedAtMs);
|
|
3022
|
-
entry.reducer.consumeCombat(event, observedAtMs, reducer.identities);
|
|
3023
|
-
}
|
|
3407
|
+
meters.consumeCombat(reducer.current, event, observedAtMs, reducer.identities, reducer.mobIdentities);
|
|
3024
3408
|
}
|
|
3025
3409
|
function writeEncounter(model, sessionId, encounter, reducer, meterAggregates) {
|
|
3026
3410
|
const database = statements(model);
|
|
3027
|
-
writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities);
|
|
3411
|
+
writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities, meterAggregates?.get("tanked"));
|
|
3028
3412
|
const totalDamage = encounter.actors.reduce((sum, actor) => sum + actor.damage, 0);
|
|
3029
3413
|
database.query(`insert or replace into combat_encounters
|
|
3030
3414
|
(session_id, encounter_id, started_at_ms, last_damage_at_ms, ended_at_ms, total_damage)
|
|
@@ -3044,9 +3428,9 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3044
3428
|
const database = statements(model);
|
|
3045
3429
|
const insertActor = database.query(`insert or replace into combat_actors
|
|
3046
3430
|
(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)
|
|
3431
|
+
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
3432
|
values ($sessionId, $encounterId, $meter, $actorIndex, $actorId, $activeSlot, $displayName, $archetype, $ownerConnectionId, $uid,
|
|
3049
|
-
$activeIdentity, $damage, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
|
|
3433
|
+
$activeIdentity, $damage, $absorbed, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
|
|
3050
3434
|
const insertSkill = database.query(`insert or replace into combat_skills
|
|
3051
3435
|
(session_id, encounter_id, meter, actor_index, source_id, source_label, damage, hits, critical_hits)
|
|
3052
3436
|
values ($sessionId, $encounterId, $meter, $actorIndex, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
|
|
@@ -3057,8 +3441,8 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3057
3441
|
(session_id, encounter_id, meter, actor_index, origin, origin_ms, width_ms, bucket_index, damage)
|
|
3058
3442
|
values ($sessionId, $encounterId, $meter, $actorIndex, $origin, $originMs, $widthMs, $bucketIndex, $damage)`);
|
|
3059
3443
|
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)`);
|
|
3444
|
+
(session_id, encounter_id, meter, actor_index, target_id, source_id, source_label, damage, hits, critical_hits)
|
|
3445
|
+
values ($sessionId, $encounterId, $meter, $actorIndex, $targetId, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
|
|
3062
3446
|
const deleteBucketTail = database.query(`delete from combat_timeline_buckets
|
|
3063
3447
|
where session_id = $sessionId and encounter_id = $encounterId and meter = $meter
|
|
3064
3448
|
and actor_index = $actorIndex and origin = $origin and bucket_index >= $bucketCount`);
|
|
@@ -3076,6 +3460,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3076
3460
|
uid: actor.uid ?? null,
|
|
3077
3461
|
activeIdentity: actor.activeIdentity ? 1 : 0,
|
|
3078
3462
|
damage: actor.damage,
|
|
3463
|
+
absorbed: actor.absorbed,
|
|
3079
3464
|
firstDamageAtMs: actor.firstDamageAtMs ?? null,
|
|
3080
3465
|
lastDamageAtMs: actor.lastDamageAtMs ?? null,
|
|
3081
3466
|
hits: actor.hits,
|
|
@@ -3108,12 +3493,13 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3108
3493
|
damage: actor.targetDamage.get(targetId) ?? 0
|
|
3109
3494
|
});
|
|
3110
3495
|
}
|
|
3111
|
-
if (meter === "dps") {
|
|
3496
|
+
if (meter === "dps" || meter === "tanked") {
|
|
3112
3497
|
for (const [targetId, bySkill] of actor.enemySkills) {
|
|
3113
3498
|
for (const [sourceId, stats] of bySkill) {
|
|
3114
3499
|
insertEnemySkill.run({
|
|
3115
3500
|
sessionId,
|
|
3116
3501
|
encounterId,
|
|
3502
|
+
meter,
|
|
3117
3503
|
actorIndex,
|
|
3118
3504
|
targetId,
|
|
3119
3505
|
sourceId,
|
|
@@ -3125,6 +3511,24 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3125
3511
|
}
|
|
3126
3512
|
}
|
|
3127
3513
|
}
|
|
3514
|
+
if (meter === "tanked") {
|
|
3515
|
+
for (const skill of actor.absorbedSkills.values()) {
|
|
3516
|
+
insertSkill.run({
|
|
3517
|
+
sessionId,
|
|
3518
|
+
encounterId,
|
|
3519
|
+
meter: ABSORBED_METER,
|
|
3520
|
+
actorIndex,
|
|
3521
|
+
sourceId: skill.sourceId,
|
|
3522
|
+
sourceLabel: skill.sourceLabel,
|
|
3523
|
+
damage: skill.damage,
|
|
3524
|
+
hits: skill.hits,
|
|
3525
|
+
criticalHits: skill.criticalHits
|
|
3526
|
+
});
|
|
3527
|
+
}
|
|
3528
|
+
for (const [targetId, value] of actor.absorbedByEnemy) {
|
|
3529
|
+
insertTarget.run({ sessionId, encounterId, meter: ABSORBED_METER, actorIndex, targetId, damage: value });
|
|
3530
|
+
}
|
|
3531
|
+
}
|
|
3128
3532
|
for (const [origin, series] of [["encounter", actor.encounterSeries], ["actor", actor.actorSeries]]) {
|
|
3129
3533
|
const dirtyFrom = takeDirtyFrom(series);
|
|
3130
3534
|
if (dirtyFrom === Number.POSITIVE_INFINITY)
|
|
@@ -3148,7 +3552,7 @@ function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate
|
|
|
3148
3552
|
}
|
|
3149
3553
|
}
|
|
3150
3554
|
}
|
|
3151
|
-
function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
|
|
3555
|
+
function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities, tanked) {
|
|
3152
3556
|
const database = statements(model);
|
|
3153
3557
|
const scope = { sessionId, encounterId: encounter.id };
|
|
3154
3558
|
const insertEnemy = database.query(`insert or replace into combat_enemies
|
|
@@ -3162,11 +3566,15 @@ function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
|
|
|
3162
3566
|
attacker_label, attacker_is_monster, source_label, damage, critical)
|
|
3163
3567
|
values ($sessionId, $encounterId, $deathIndex, $hitIndex, $beforeDeathMs, $attackerActorId,
|
|
3164
3568
|
$attackerLabel, $attackerIsMonster, $sourceLabel, $damage, $critical)`);
|
|
3165
|
-
|
|
3569
|
+
const enemyFirstSeen = new Map(encounter.enemyFirstSeenAtMs);
|
|
3570
|
+
for (const [targetId, firstSeenAtMs] of tanked?.enemyFirstSeenAtMs ?? []) {
|
|
3571
|
+
enemyFirstSeen.set(targetId, Math.min(firstSeenAtMs, enemyFirstSeen.get(targetId) ?? firstSeenAtMs));
|
|
3572
|
+
}
|
|
3573
|
+
for (const [targetId, firstSeenAtMs] of enemyFirstSeen) {
|
|
3166
3574
|
insertEnemy.run({
|
|
3167
3575
|
...scope,
|
|
3168
3576
|
targetId,
|
|
3169
|
-
displayName: encounter.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
|
|
3577
|
+
displayName: encounter.enemyNames.get(targetId) ?? tanked?.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
|
|
3170
3578
|
firstSeenAtMs
|
|
3171
3579
|
});
|
|
3172
3580
|
}
|
|
@@ -3319,7 +3727,7 @@ function loadOpenEncounter(model, sessionId) {
|
|
|
3319
3727
|
actor.targetIds.add(target.target_id);
|
|
3320
3728
|
actor.targetDamage.set(target.target_id, target.damage);
|
|
3321
3729
|
}
|
|
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)) {
|
|
3730
|
+
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
3731
|
const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
|
|
3324
3732
|
actor.enemySkills.set(enemySkill.target_id, bySkill);
|
|
3325
3733
|
bySkill.set(enemySkill.source_id, {
|
|
@@ -3363,6 +3771,7 @@ function loadMeterAggregate(model, sessionId, open, meter) {
|
|
|
3363
3771
|
actor.archetype = actorRow2.archetype;
|
|
3364
3772
|
actor.activeIdentity = actorRow2.active_identity === 1;
|
|
3365
3773
|
actor.damage = actorRow2.damage;
|
|
3774
|
+
actor.absorbed = actorRow2.absorbed;
|
|
3366
3775
|
if (actorRow2.first_damage_at_ms !== null)
|
|
3367
3776
|
actor.firstDamageAtMs = actorRow2.first_damage_at_ms;
|
|
3368
3777
|
if (actorRow2.last_damage_at_ms !== null)
|
|
@@ -3387,6 +3796,30 @@ function loadMeterAggregate(model, sessionId, open, meter) {
|
|
|
3387
3796
|
actor.targetIds.add(target.target_id);
|
|
3388
3797
|
actor.targetDamage.set(target.target_id, target.damage);
|
|
3389
3798
|
}
|
|
3799
|
+
if (meter === "tanked") {
|
|
3800
|
+
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)) {
|
|
3801
|
+
const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
|
|
3802
|
+
actor.enemySkills.set(enemySkill.target_id, bySkill);
|
|
3803
|
+
bySkill.set(enemySkill.source_id, {
|
|
3804
|
+
sourceLabel: enemySkill.source_label,
|
|
3805
|
+
damage: enemySkill.damage,
|
|
3806
|
+
hits: enemySkill.hits,
|
|
3807
|
+
criticalHits: enemySkill.critical_hits
|
|
3808
|
+
});
|
|
3809
|
+
}
|
|
3810
|
+
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)) {
|
|
3811
|
+
actor.absorbedSkills.set(skill.source_id, {
|
|
3812
|
+
sourceId: skill.source_id,
|
|
3813
|
+
sourceLabel: skill.source_label,
|
|
3814
|
+
damage: skill.damage,
|
|
3815
|
+
hits: skill.hits,
|
|
3816
|
+
criticalHits: skill.critical_hits
|
|
3817
|
+
});
|
|
3818
|
+
}
|
|
3819
|
+
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)) {
|
|
3820
|
+
actor.absorbedByEnemy.set(target.target_id, target.damage);
|
|
3821
|
+
}
|
|
3822
|
+
}
|
|
3390
3823
|
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
3824
|
const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
3392
3825
|
series.originMs = bucket.origin_ms;
|
|
@@ -3426,23 +3859,17 @@ class CombatHistoryStore {
|
|
|
3426
3859
|
...rows.length > limit && last ? { nextCursor: encodeCursor({ startedAtMs: last.started_at_ms, encounterId: last.encounter_id }) } : {}
|
|
3427
3860
|
};
|
|
3428
3861
|
}
|
|
3429
|
-
getEnemyBreakdown(sessionId, encounterId) {
|
|
3430
|
-
const
|
|
3431
|
-
const
|
|
3432
|
-
|
|
3433
|
-
const
|
|
3434
|
-
|
|
3862
|
+
getEnemyBreakdown(sessionId, encounterId, meter = "dps") {
|
|
3863
|
+
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);
|
|
3864
|
+
const names = new Map(enemyRows.map((enemy) => [enemy.target_id, enemy.display_name]));
|
|
3865
|
+
if (meter === "tanked") {
|
|
3866
|
+
const state = this.model.database.query("select mob_identities_json from combat_stream_state where session_id = ?").get(sessionId);
|
|
3867
|
+
for (const [id, name] of JSON.parse(state?.mob_identities_json ?? "[]")) {
|
|
3868
|
+
if (!names.get(id))
|
|
3869
|
+
names.set(id, name);
|
|
3870
|
+
}
|
|
3435
3871
|
}
|
|
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 });
|
|
3872
|
+
const encounter = this.loadEncounter(sessionId, encounterId, meter, { withTimeline: false });
|
|
3446
3873
|
const skills = encounter ? displayActorAggregates(encounter, { includeAllAnonymous: true }).flatMap(({ rowId, actor }) => [...actor.enemySkills].flatMap(([targetId, bySkill]) => [...bySkill].map(([sourceId, stats]) => ({
|
|
3447
3874
|
attackerRowId: rowId,
|
|
3448
3875
|
targetId,
|
|
@@ -3452,6 +3879,21 @@ class CombatHistoryStore {
|
|
|
3452
3879
|
hits: stats.hits,
|
|
3453
3880
|
criticalHits: stats.criticalHits
|
|
3454
3881
|
})))).sort((left, right) => right.damage - left.damage) : [];
|
|
3882
|
+
const pickerIds = orderedEnemyIds(skills, enemyRows);
|
|
3883
|
+
const counts = new Map;
|
|
3884
|
+
for (const targetId of pickerIds) {
|
|
3885
|
+
const name = names.get(targetId) ?? `Enemy ${targetId}`;
|
|
3886
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
3887
|
+
}
|
|
3888
|
+
const nextIndex = new Map;
|
|
3889
|
+
const options = pickerIds.map((targetId) => {
|
|
3890
|
+
const name = names.get(targetId) ?? `Enemy ${targetId}`;
|
|
3891
|
+
if ((counts.get(name) ?? 0) <= 1)
|
|
3892
|
+
return { targetId, label: name };
|
|
3893
|
+
const index = (nextIndex.get(name) ?? 0) + 1;
|
|
3894
|
+
nextIndex.set(name, index);
|
|
3895
|
+
return { targetId, label: `${name} (${index})` };
|
|
3896
|
+
});
|
|
3455
3897
|
return { encounterId, enemies: options, skills };
|
|
3456
3898
|
}
|
|
3457
3899
|
getDeathLog(query) {
|
|
@@ -3524,6 +3966,7 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
|
|
|
3524
3966
|
actor.uid = row.uid;
|
|
3525
3967
|
actor.activeIdentity = row.active_identity === 1;
|
|
3526
3968
|
actor.damage = row.damage;
|
|
3969
|
+
actor.absorbed = row.absorbed;
|
|
3527
3970
|
if (row.first_damage_at_ms !== null)
|
|
3528
3971
|
actor.firstDamageAtMs = row.first_damage_at_ms;
|
|
3529
3972
|
if (row.last_damage_at_ms !== null)
|
|
@@ -3545,8 +3988,8 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
|
|
|
3545
3988
|
actor.targetIds.add(target.target_id);
|
|
3546
3989
|
actor.targetDamage.set(target.target_id, target.damage);
|
|
3547
3990
|
}
|
|
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)) {
|
|
3991
|
+
if (meter === "dps" || meter === "tanked") {
|
|
3992
|
+
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
3993
|
const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
|
|
3551
3994
|
actor.enemySkills.set(enemySkill.target_id, bySkill);
|
|
3552
3995
|
bySkill.set(enemySkill.source_id, {
|
|
@@ -3557,6 +4000,20 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
|
|
|
3557
4000
|
});
|
|
3558
4001
|
}
|
|
3559
4002
|
}
|
|
4003
|
+
if (meter === "tanked") {
|
|
4004
|
+
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)) {
|
|
4005
|
+
actor.absorbedSkills.set(skill.source_id, {
|
|
4006
|
+
sourceId: skill.source_id,
|
|
4007
|
+
sourceLabel: skill.source_label,
|
|
4008
|
+
damage: skill.damage,
|
|
4009
|
+
hits: skill.hits,
|
|
4010
|
+
criticalHits: skill.critical_hits
|
|
4011
|
+
});
|
|
4012
|
+
}
|
|
4013
|
+
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)) {
|
|
4014
|
+
actor.absorbedByEnemy.set(target.target_id, target.damage);
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
3560
4017
|
if (withTimeline) {
|
|
3561
4018
|
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
4019
|
const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
@@ -3569,6 +4026,13 @@ function hydrateActor(database, sessionId, encounterId, meter, row, encounterSta
|
|
|
3569
4026
|
}
|
|
3570
4027
|
return actor;
|
|
3571
4028
|
}
|
|
4029
|
+
function orderedEnemyIds(skills, enemyRows) {
|
|
4030
|
+
const withSkills = new Set(skills.map((skill) => skill.targetId));
|
|
4031
|
+
const ordered = enemyRows.map((enemy) => enemy.target_id).filter((id) => withSkills.has(id));
|
|
4032
|
+
const seen = new Set(ordered);
|
|
4033
|
+
const rest = [...withSkills].filter((id) => !seen.has(id)).sort((left, right) => left - right);
|
|
4034
|
+
return [...ordered, ...rest];
|
|
4035
|
+
}
|
|
3572
4036
|
function summary(sessionId, row) {
|
|
3573
4037
|
return {
|
|
3574
4038
|
sessionId,
|
|
@@ -3595,15 +4059,14 @@ function decodeCursor(cursor) {
|
|
|
3595
4059
|
return;
|
|
3596
4060
|
return { startedAtMs, encounterId: text.slice(separator + 1) };
|
|
3597
4061
|
}
|
|
3598
|
-
// src/live-combat.ts
|
|
4062
|
+
// src/runtime/live-combat.ts
|
|
3599
4063
|
class LiveCombatService {
|
|
3600
4064
|
reducer;
|
|
3601
4065
|
currentTauSeconds;
|
|
3602
4066
|
minimumDurationMs;
|
|
3603
4067
|
retainedFinishedEncounters;
|
|
3604
4068
|
onEncounterFinished;
|
|
3605
|
-
|
|
3606
|
-
healing;
|
|
4069
|
+
meters;
|
|
3607
4070
|
meterId;
|
|
3608
4071
|
lastEventAtMs;
|
|
3609
4072
|
latestFinished;
|
|
@@ -3624,15 +4087,13 @@ class LiveCombatService {
|
|
|
3624
4087
|
maxTimelineBuckets: timelinePoints,
|
|
3625
4088
|
onEncounterFinished: (encounter) => this.finishMeter(encounter)
|
|
3626
4089
|
});
|
|
3627
|
-
this.
|
|
3628
|
-
this.healing = new MeterReducer({ kind: "healing", currentTauSeconds: this.currentTauSeconds, maxTimelineBuckets: timelinePoints });
|
|
4090
|
+
this.meters = new MeterReducerGroup({ currentTauSeconds: this.currentTauSeconds, maxTimelineBuckets: timelinePoints });
|
|
3629
4091
|
}
|
|
3630
4092
|
consumeIdentity(event, observedAtMs) {
|
|
3631
4093
|
const previous = event.operation === "reset" ? undefined : this.reducer.identities.get(event.actorId);
|
|
3632
4094
|
const hadIdentities = this.reducer.identities.size > 0;
|
|
3633
4095
|
this.reducer.consumeIdentity(event, observedAtMs);
|
|
3634
|
-
this.
|
|
3635
|
-
this.healing.consumeIdentity(event);
|
|
4096
|
+
this.meters.consumeIdentity(event);
|
|
3636
4097
|
if (this.identityChanged(event, previous, hadIdentities))
|
|
3637
4098
|
this.revision += 1;
|
|
3638
4099
|
}
|
|
@@ -3690,30 +4151,25 @@ class LiveCombatService {
|
|
|
3690
4151
|
return;
|
|
3691
4152
|
this.meterId = encounter.id;
|
|
3692
4153
|
this.lastEventAtMs = encounter.startedAtMs;
|
|
3693
|
-
this.tanked.begin(encounter.id, encounter.startedAtMs);
|
|
3694
|
-
this.healing.begin(encounter.id, encounter.startedAtMs);
|
|
3695
4154
|
}
|
|
3696
4155
|
recordMeterEvent(event, observedAtMs) {
|
|
3697
4156
|
if (this.meterId === undefined)
|
|
3698
4157
|
return;
|
|
3699
|
-
|
|
3700
|
-
this.tanked.consumeCombat(event, observedAtMs, identities);
|
|
3701
|
-
this.healing.consumeCombat(event, observedAtMs, identities);
|
|
4158
|
+
this.meters.consumeCombat(this.reducer.current, event, observedAtMs, this.reducer.identities, this.reducer.mobIdentities);
|
|
3702
4159
|
this.lastEventAtMs = Math.max(this.lastEventAtMs ?? observedAtMs, observedAtMs);
|
|
3703
4160
|
}
|
|
3704
4161
|
finishMeter(encounter) {
|
|
3705
4162
|
this.ensureMeter(encounter);
|
|
3706
4163
|
const endedAtMs = encounter.endedAtMs ?? encounter.lastDamageAtMs;
|
|
4164
|
+
const meterAggregates = this.meters.finish(endedAtMs);
|
|
3707
4165
|
const finished = {
|
|
3708
4166
|
encounter,
|
|
3709
4167
|
endedAtMs,
|
|
3710
4168
|
lastEventAtMs: this.lastEventAtMs ?? encounter.lastDamageAtMs,
|
|
3711
|
-
...
|
|
3712
|
-
...
|
|
4169
|
+
...meterAggregates.get("tanked") === undefined ? {} : { tanked: meterAggregates.get("tanked") },
|
|
4170
|
+
...meterAggregates.get("healing") === undefined ? {} : { healing: meterAggregates.get("healing") }
|
|
3713
4171
|
};
|
|
3714
4172
|
const record = this.renderRecord(encounter, endedAtMs, finished);
|
|
3715
|
-
this.tanked.finish(endedAtMs);
|
|
3716
|
-
this.healing.finish(endedAtMs);
|
|
3717
4173
|
this.meterId = undefined;
|
|
3718
4174
|
this.lastEventAtMs = undefined;
|
|
3719
4175
|
this.latestFinished = this.retainedFinishedEncounters > 0 ? finished : undefined;
|
|
@@ -3742,8 +4198,8 @@ class LiveCombatService {
|
|
|
3742
4198
|
minimumDurationMs: this.minimumDurationMs,
|
|
3743
4199
|
...personal
|
|
3744
4200
|
}),
|
|
3745
|
-
tps: render(finished ? finished.tanked : this.
|
|
3746
|
-
hps: render(finished ? finished.healing : this.
|
|
4201
|
+
tps: render(finished ? finished.tanked : this.meters.current("tanked")),
|
|
4202
|
+
hps: render(finished ? finished.healing : this.meters.current("healing"))
|
|
3747
4203
|
};
|
|
3748
4204
|
}
|
|
3749
4205
|
}
|
|
@@ -3820,17 +4276,18 @@ function positiveInteger(value, name) {
|
|
|
3820
4276
|
throw new Error(`${name} must be a positive integer`);
|
|
3821
4277
|
return value;
|
|
3822
4278
|
}
|
|
3823
|
-
// src/live-log.ts
|
|
4279
|
+
// src/replay/live-log.ts
|
|
3824
4280
|
import {
|
|
3825
4281
|
DEFAULT_STREAM_BATCH_BYTES,
|
|
4282
|
+
decoderForText,
|
|
3826
4283
|
JsonlTailReader,
|
|
3827
|
-
|
|
3828
|
-
|
|
3829
|
-
parseLogRecord as parseLogRecord3
|
|
4284
|
+
LogRecordLineDecoder as LogRecordLineDecoder3,
|
|
4285
|
+
LiveLogSessionFollower
|
|
3830
4286
|
} from "@kar-mi/spirit-vale-tools-logging";
|
|
3831
4287
|
class DpsLogFollower {
|
|
3832
4288
|
ticksPerSecond;
|
|
3833
4289
|
reader;
|
|
4290
|
+
records = new LogRecordLineDecoder3;
|
|
3834
4291
|
recordedAtOriginMs;
|
|
3835
4292
|
lastObservedAtMs = 0;
|
|
3836
4293
|
originTick;
|
|
@@ -3838,7 +4295,7 @@ class DpsLogFollower {
|
|
|
3838
4295
|
revision = 0;
|
|
3839
4296
|
constructor(path, ticksPerSecond = 30) {
|
|
3840
4297
|
this.ticksPerSecond = ticksPerSecond;
|
|
3841
|
-
this.reader = new JsonlTailReader(path, { createDecoder:
|
|
4298
|
+
this.reader = new JsonlTailReader(path, { createDecoder: decoderForText, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES });
|
|
3842
4299
|
}
|
|
3843
4300
|
async poll() {
|
|
3844
4301
|
return this.consumeRead(await this.reader.read());
|
|
@@ -3859,22 +4316,14 @@ class DpsLogFollower {
|
|
|
3859
4316
|
const events = [];
|
|
3860
4317
|
let invalidLines = 0;
|
|
3861
4318
|
for (const line of lines) {
|
|
3862
|
-
|
|
3863
|
-
|
|
3864
|
-
let candidate;
|
|
3865
|
-
try {
|
|
3866
|
-
candidate = JSON.parse(line);
|
|
3867
|
-
} catch {
|
|
3868
|
-
invalidLines += 1;
|
|
3869
|
-
continue;
|
|
3870
|
-
}
|
|
3871
|
-
if (isLogStreamHeader3(candidate))
|
|
4319
|
+
const decoded = this.records.decode(line);
|
|
4320
|
+
if (decoded.kind === "empty" || decoded.kind === "header")
|
|
3872
4321
|
continue;
|
|
3873
|
-
|
|
3874
|
-
if (!record) {
|
|
4322
|
+
if (decoded.kind === "invalid") {
|
|
3875
4323
|
invalidLines += 1;
|
|
3876
4324
|
continue;
|
|
3877
4325
|
}
|
|
4326
|
+
const record = decoded.record;
|
|
3878
4327
|
const event = parseDpsLogRecord(record.type, record.data);
|
|
3879
4328
|
if (event === null)
|
|
3880
4329
|
continue;
|
|
@@ -3902,6 +4351,7 @@ class DpsLogFollower {
|
|
|
3902
4351
|
return this.lastObservedAtMs;
|
|
3903
4352
|
}
|
|
3904
4353
|
resetState() {
|
|
4354
|
+
this.records.reset();
|
|
3905
4355
|
this.recordedAtOriginMs = undefined;
|
|
3906
4356
|
this.lastObservedAtMs = 0;
|
|
3907
4357
|
this.originTick = undefined;
|
|
@@ -3911,15 +4361,14 @@ class DpsLogFollower {
|
|
|
3911
4361
|
|
|
3912
4362
|
class DpsSessionLogFollower {
|
|
3913
4363
|
inner;
|
|
3914
|
-
constructor(logDirectory,
|
|
3915
|
-
const { ticksPerSecond
|
|
3916
|
-
const ticks = ticksOverride ?? ticksPerSecond;
|
|
4364
|
+
constructor(logDirectory, options = {}) {
|
|
4365
|
+
const { ticksPerSecond = 30, ...tuning } = options;
|
|
3917
4366
|
this.inner = new LiveLogSessionFollower({
|
|
3918
4367
|
stream: "combat",
|
|
3919
4368
|
...logDirectory === undefined ? {} : { logDirectory },
|
|
3920
4369
|
...tuning,
|
|
3921
|
-
readerOptions: { createDecoder:
|
|
3922
|
-
createFollower: (path) => new DpsLogFollower(path,
|
|
4370
|
+
readerOptions: { createDecoder: decoderForText, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES },
|
|
4371
|
+
createFollower: (path) => new DpsLogFollower(path, ticksPerSecond),
|
|
3923
4372
|
mergeSessionChange: (batch, changedSession) => ({
|
|
3924
4373
|
...batch,
|
|
3925
4374
|
reset: batch.reset || changedSession,
|
|
@@ -3929,7 +4378,7 @@ class DpsSessionLogFollower {
|
|
|
3929
4378
|
});
|
|
3930
4379
|
}
|
|
3931
4380
|
static watch(logDirectory, options = {}) {
|
|
3932
|
-
return new DpsSessionLogFollower(logDirectory, options
|
|
4381
|
+
return new DpsSessionLogFollower(logDirectory, options);
|
|
3933
4382
|
}
|
|
3934
4383
|
poll() {
|
|
3935
4384
|
return this.inner.poll();
|
|
@@ -3944,14 +4393,7 @@ class DpsSessionLogFollower {
|
|
|
3944
4393
|
this.inner.close();
|
|
3945
4394
|
}
|
|
3946
4395
|
}
|
|
3947
|
-
|
|
3948
|
-
if (firstChunk[0] === 255 && firstChunk[1] === 254)
|
|
3949
|
-
return new TextDecoder("utf-16le");
|
|
3950
|
-
if (firstChunk[0] === 254 && firstChunk[1] === 255)
|
|
3951
|
-
return new TextDecoder("utf-16be");
|
|
3952
|
-
return new TextDecoder("utf-8");
|
|
3953
|
-
}
|
|
3954
|
-
// src/position-tracker.ts
|
|
4396
|
+
// src/tracking/position-tracker.ts
|
|
3955
4397
|
class FishNetPositionTracker {
|
|
3956
4398
|
options;
|
|
3957
4399
|
positions = new Map;
|
|
@@ -4030,15 +4472,16 @@ export {
|
|
|
4030
4472
|
ANALYSIS_BUCKET_MS,
|
|
4031
4473
|
COMBAT_DOMAIN_NAME,
|
|
4032
4474
|
COMBAT_DOMAIN_VERSION,
|
|
4033
|
-
CURRENT_BOSS_SKILL_NAMES,
|
|
4034
4475
|
CombatHistoryStore,
|
|
4035
4476
|
DamageReducer,
|
|
4036
4477
|
DpsLogFollower,
|
|
4037
4478
|
DpsSessionLogFollower,
|
|
4038
4479
|
FishNetActorDirectory,
|
|
4039
4480
|
FishNetCombatTracker,
|
|
4481
|
+
FishNetMonsterIdentityTracker,
|
|
4040
4482
|
FishNetPositionTracker,
|
|
4041
4483
|
FishNetStatusTracker,
|
|
4484
|
+
FishNetSummonTracker,
|
|
4042
4485
|
LiveCombatService,
|
|
4043
4486
|
MeterReducer,
|
|
4044
4487
|
actorRowId,
|
|
@@ -4051,6 +4494,7 @@ export {
|
|
|
4051
4494
|
inspectCombatReplaySummary,
|
|
4052
4495
|
loadDpsReplay,
|
|
4053
4496
|
normalizeName,
|
|
4497
|
+
observeFishNetDamagePacket,
|
|
4054
4498
|
parseDpsLogRecord,
|
|
4055
4499
|
readCombatReplaySummary,
|
|
4056
4500
|
renderEncounter,
|