@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.
Files changed (72) hide show
  1. package/dist/events/combat-decoding.d.ts +41 -0
  2. package/dist/events/combat-decoding.d.ts.map +1 -0
  3. package/dist/{combat-tracker.d.ts → events/combat-events.d.ts} +29 -112
  4. package/dist/events/combat-events.d.ts.map +1 -0
  5. package/dist/events/damage-observer.d.ts +12 -0
  6. package/dist/events/damage-observer.d.ts.map +1 -0
  7. package/dist/events/decoded-fields.d.ts +5 -0
  8. package/dist/events/decoded-fields.d.ts.map +1 -0
  9. package/dist/events/effect-display.d.ts.map +1 -0
  10. package/dist/events/generated/combat-semantics.d.ts +7 -0
  11. package/dist/events/generated/combat-semantics.d.ts.map +1 -0
  12. package/dist/events/inference/recovery-style.d.ts +5 -0
  13. package/dist/events/inference/recovery-style.d.ts.map +1 -0
  14. package/dist/history/domain.d.ts +1 -1
  15. package/dist/history/domain.d.ts.map +1 -1
  16. package/dist/history/importer.d.ts.map +1 -1
  17. package/dist/history/store.d.ts +8 -4
  18. package/dist/history/store.d.ts.map +1 -1
  19. package/dist/index.d.ts +26 -20
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +1057 -613
  22. package/dist/reducers/damage.d.ts +10 -2
  23. package/dist/reducers/damage.d.ts.map +1 -1
  24. package/dist/reducers/meter-group.d.ts +21 -0
  25. package/dist/reducers/meter-group.d.ts.map +1 -0
  26. package/dist/reducers/meter.d.ts +3 -3
  27. package/dist/reducers/meter.d.ts.map +1 -1
  28. package/dist/reducers/rows.d.ts +2 -2
  29. package/dist/reducers/rows.d.ts.map +1 -1
  30. package/dist/{snapshot.d.ts → reducers/snapshot.d.ts} +16 -10
  31. package/dist/reducers/snapshot.d.ts.map +1 -0
  32. package/dist/{live-log.d.ts → replay/live-log.d.ts} +4 -3
  33. package/dist/replay/live-log.d.ts.map +1 -0
  34. package/dist/{replay-capture.d.ts → replay/replay-capture.d.ts} +4 -7
  35. package/dist/replay/replay-capture.d.ts.map +1 -0
  36. package/dist/replay/replay-summary.d.ts.map +1 -0
  37. package/dist/{replay.d.ts → replay/replay.d.ts} +4 -4
  38. package/dist/replay/replay.d.ts.map +1 -0
  39. package/dist/{live-combat.d.ts → runtime/live-combat.d.ts} +6 -7
  40. package/dist/runtime/live-combat.d.ts.map +1 -0
  41. package/dist/testing/combat-packets.d.ts +28 -0
  42. package/dist/testing/combat-packets.d.ts.map +1 -0
  43. package/dist/{actor-directory.d.ts → tracking/actor-directory.d.ts} +18 -0
  44. package/dist/tracking/actor-directory.d.ts.map +1 -0
  45. package/dist/tracking/boss-catalog.d.ts +4 -0
  46. package/dist/tracking/boss-catalog.d.ts.map +1 -0
  47. package/dist/tracking/combat-tracker.d.ts +76 -0
  48. package/dist/tracking/combat-tracker.d.ts.map +1 -0
  49. package/dist/tracking/monster-identity.d.ts +32 -0
  50. package/dist/tracking/monster-identity.d.ts.map +1 -0
  51. package/dist/tracking/position-tracker.d.ts.map +1 -0
  52. package/dist/{status-tracker.d.ts → tracking/status-tracker.d.ts} +1 -1
  53. package/dist/tracking/status-tracker.d.ts.map +1 -0
  54. package/dist/tracking/summon-tracker.d.ts +47 -0
  55. package/dist/tracking/summon-tracker.d.ts.map +1 -0
  56. package/package.json +6 -6
  57. package/dist/actor-directory.d.ts.map +0 -1
  58. package/dist/boss-catalog.d.ts +0 -6
  59. package/dist/boss-catalog.d.ts.map +0 -1
  60. package/dist/combat-tracker.d.ts.map +0 -1
  61. package/dist/effect-display.d.ts.map +0 -1
  62. package/dist/live-combat.d.ts.map +0 -1
  63. package/dist/live-log.d.ts.map +0 -1
  64. package/dist/position-tracker.d.ts.map +0 -1
  65. package/dist/replay-capture.d.ts.map +0 -1
  66. package/dist/replay-summary.d.ts.map +0 -1
  67. package/dist/replay.d.ts.map +0 -1
  68. package/dist/snapshot.d.ts.map +0 -1
  69. package/dist/status-tracker.d.ts.map +0 -1
  70. /package/dist/{effect-display.d.ts → events/effect-display.d.ts} +0 -0
  71. /package/dist/{replay-summary.d.ts → replay/replay-summary.d.ts} +0 -0
  72. /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
- FishNetMonsterDirectory,
7
- loadBundledFishNetSemanticMap
5
+ loadBundledFishNetRpcMap
8
6
  } from "@kar-mi/spirit-vale-tools-capture";
9
- import { readSignedPackedWhole as readSignedPackedWhole2 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
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/effect-display.ts
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/combat-tracker.ts
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
- monsterCatalog;
94
- bossCatalog;
95
- monsters;
96
- semanticMap;
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
- summonStacks = new Map;
101
- summonSkillSyncState = new Map;
102
- authoritativeSummonActors = new Set;
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 ?? options.semanticMap?.buildFingerprint ?? CURRENT_GAME_BUILD_FINGERPRINT;
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 semanticMap = options.semanticMap ?? (skillCatalog ? tryLoadBundledSemanticMap(buildFingerprint) : loadBundledFishNetSemanticMap(buildFingerprint));
120
- this.semanticMap = semanticMap;
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.monsterCatalog = options.monsterCatalog;
127
- if (options.monsterCatalog)
128
- this.monsters = new FishNetMonsterDirectory(options.monsterCatalog);
129
- this.bossCatalog = options.bossCatalog;
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.monsterIdentity(packet.tick, this.monsters?.consume(packet));
133
- const bossLifecycle = this.bossIdentityLifecycle(packet);
134
- const summonLifecycle = this.consumeSummonObjectLifecycle(packet);
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.consumeSummonSkillSync(packet));
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.bossIdentity(skillEvent);
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 (!isCompleteDamagePacket(packet, !death))
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.consumeSummonCalibration(packet));
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.summonStacks.clear();
232
- this.summonSkillSyncState.clear();
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.monsters?.reset();
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 || !CURRENT_HEALTH_COMPONENT_INDICES.has(packet.networkBehaviourIndex))
738
+ if (packet.rpcHash !== 1 || !this.healthComponentIndices.has(packet.networkBehaviourIndex))
324
739
  return [];
325
740
  let amount;
326
741
  try {
327
- amount = readSignedPackedWhole2(packet.payload, 0);
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.subarray(amount.nextOffset);
334
- const settingsHex = settings.toString("hex");
335
- const knownHealthRecovery = this.semanticMap?.recoveryStyles?.some((definition) => definition.networkBehaviourType === "HealthComponent" && definition.rpcName === "Recover_C" && definition.undecodedPayloadHex === settingsHex) ?? false;
336
- if (!knownHealthRecovery)
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: [{ name: "amount", codec: "packedInt32", value: amount.value }],
343
- undecodedPayload: settings
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, this.semanticMap);
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, HEALING_SKILL_IDS);
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 = candidate.inferred ? "inferred" : "exact";
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, REGEN_SKILL_IDS);
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
- return entries;
874
- }
875
- function matchesBehaviour(packet, expected) {
876
- return packet.networkBehaviourType === undefined || packet.networkBehaviourType === expected;
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
- var DAMAGE_TYPE_SOURCES = new Map([
929
- [4, { sourceId: "reflect", sourceLabel: "Reflect Damage" }]
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
- sourceId: rawSourceId,
935
- sourceLabel: skillLabels.get(rawSourceId) ?? rawSourceId
1262
+ actorId: candidate.actorId,
1263
+ sourceId: candidate.sourceId,
1264
+ sourceLabel: candidate.sourceLabel,
1265
+ activationId: candidate.id
936
1266
  };
937
1267
  }
938
- return (rawSourceId === null ? DAMAGE_TYPE_SOURCES.get(damageType) : undefined) ?? {
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 tryLoadBundledSemanticMap(buildFingerprint) {
1277
+ function tryLoadBundledRpcMap(buildFingerprint) {
954
1278
  try {
955
- return loadBundledFishNetSemanticMap(buildFingerprint);
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
- var CURRENT_BOSS_SKILL_NAMES = createBossCatalog({});
971
- // src/actor-directory.ts
972
- import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap } from "@kar-mi/spirit-vale-tools-capture";
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
- return this.removeObject(packet.objectId, packet.tick, true);
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((loadBundledFishNetRpcMap().prefabs ?? []).filter(({ components }) => components.some(({ typeName }) => typeName === "PlayerController")).map(({ collectionId, prefabId }) => `${collectionId}:${prefabId}`));
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 = readSignedPackedWhole3(payload, offset).nextOffset;
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
- const bySkill = actor.enemySkills.get(event.targetId) ?? new Map;
1788
- actor.enemySkills.set(event.targetId, bySkill);
1789
- const stats = bySkill.get(event.sourceId) ?? { sourceLabel: event.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
1790
- stats.sourceLabel = event.sourceLabel;
1791
- stats.damage += event.value;
1792
- stats.hits += 1;
1793
- if (event.hitResult === "critical")
1794
- stats.criticalHits += 1;
1795
- bySkill.set(event.sourceId, stats);
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, isLogStreamHeader, parseLogRecord } from "@kar-mi/spirit-vale-tools-logging";
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
- for await (const line of readLines(Bun.file(path).stream())) {
2298
- if (!line.trim())
2299
- continue;
2300
- let candidate;
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 { FishNetSessionDecoder, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2, decodeLiteNetLibDatagram } from "@kar-mi/spirit-vale-tools-capture";
2432
- import { isLogStreamHeader as isLogStreamHeader2, parseLogRecord as parseLogRecord2 } from "@kar-mi/spirit-vale-tools-logging";
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 readLines2(Bun.file(path).stream()))
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
- session = new FishNetSessionDecoder(loadBundledFishNetRpcMap2());
2454
- datagrams = 0;
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
- if (!line.trim())
2464
- return;
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
- const packet = parseUdpRecord(record.data);
2481
- if (!packet) {
2815
+ if (decoded.kind === "invalid") {
2482
2816
  this.invalidLines += 1;
2483
2817
  return;
2484
2818
  }
2485
- this.datagrams += 1;
2486
- const observedAtMs = Date.parse(record.recordedAt);
2487
- const source = `${packet.sourceIP}:${packet.sourcePort}`;
2488
- const destination = `${packet.destinationIP}:${packet.destinationPort}`;
2489
- const endpoints = [source, destination].sort();
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
- } catch {
2523
- this.decodeWarnings += 1;
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
- datagrams: this.datagrams,
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
- function parseUdpRecord(data) {
2537
- if (data["protocol"] !== "udp" || typeof data["sourceIP"] !== "string" || typeof data["destinationIP"] !== "string" || !isPort(data["sourcePort"]) || !isPort(data["destinationPort"]) || typeof data["payloadHex"] !== "string" || !/^(?:[0-9a-fA-F]{2})*$/.test(data["payloadHex"]))
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 = 7;
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 DPS actor lifetime, per enemy, per skill. The actor index prevents a reused network
2695
- -- actor id from assigning the same hit to two rows that are actually different players.
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 ?? event.targetId;
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 METER_KINDS = [
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 = METER_KINDS.map(({ meter, kind }) => ({
2898
- meter,
2899
- reducer: new MeterReducer({
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 = meterAggregates.get(encounterId) ?? new Map;
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
- for (const entry of meters)
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
- for (const entry of meters) {
2930
- entry.reducer.resume(loadMeterAggregate(model, sessionId, open, entry.meter));
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
- for (const entry of meters)
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
- for (const entry of meters)
3012
- entry.reducer.consumeIdentity(event);
3403
+ meters.consumeIdentity(event);
3013
3404
  return;
3014
3405
  }
3015
3406
  reducer.consumeCombat(event, observedAtMs);
3016
- const encounter = reducer.current;
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
- for (const [targetId, firstSeenAtMs] of encounter.enemyFirstSeenAtMs) {
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 enemies = this.model.database.query("select target_id, display_name, first_seen_at_ms from combat_enemies where session_id = ? and encounter_id = ? order by first_seen_at_ms, target_id").all(sessionId, encounterId);
3431
- const counts = new Map;
3432
- for (const enemy of enemies) {
3433
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3434
- counts.set(name, (counts.get(name) ?? 0) + 1);
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 nextIndex = new Map;
3437
- const options = enemies.map((enemy) => {
3438
- const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
3439
- if ((counts.get(name) ?? 0) <= 1)
3440
- return { targetId: enemy.target_id, label: name };
3441
- const index = (nextIndex.get(name) ?? 0) + 1;
3442
- nextIndex.set(name, index);
3443
- return { targetId: enemy.target_id, label: `${name} (${index})` };
3444
- });
3445
- const encounter = this.loadEncounter(sessionId, encounterId, "dps", { withTimeline: false });
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
- tanked;
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.tanked = new MeterReducer({ kind: "tanked", currentTauSeconds: this.currentTauSeconds, maxTimelineBuckets: timelinePoints });
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.tanked.consumeIdentity(event);
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
- const identities = this.reducer.identities;
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
- ...this.tanked.current === undefined ? {} : { tanked: this.tanked.current },
3712
- ...this.healing.current === undefined ? {} : { healing: this.healing.current }
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.tanked.current),
3746
- hps: render(finished ? finished.healing : this.healing.current)
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
- LiveLogSessionFollower,
3828
- isLogStreamHeader as isLogStreamHeader3,
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: decoderFor2, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES });
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
- if (!line.trim())
3863
- continue;
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
- const record = parseLogRecord3(candidate);
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, ticksPerSecond = 30, options = {}) {
3915
- const { ticksPerSecond: ticksOverride, ...tuning } = options;
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: decoderFor2, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES },
3922
- createFollower: (path) => new DpsLogFollower(path, ticks),
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.ticksPerSecond ?? 30, 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
- function decoderFor2(firstChunk) {
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,