@kar-mi/spirit-vale-tools-combat 3.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +56 -0
- package/dist/actor-directory.d.ts +88 -0
- package/dist/actor-directory.d.ts.map +1 -0
- package/dist/boss-catalog.d.ts +6 -0
- package/dist/boss-catalog.d.ts.map +1 -0
- package/dist/combat-tracker.d.ts +295 -0
- package/dist/combat-tracker.d.ts.map +1 -0
- package/dist/dps-meter.d.ts +115 -0
- package/dist/dps-meter.d.ts.map +1 -0
- package/dist/effect-display.d.ts +16 -0
- package/dist/effect-display.d.ts.map +1 -0
- package/dist/history/domain.d.ts +6 -0
- package/dist/history/domain.d.ts.map +1 -0
- package/dist/history/importer.d.ts +12 -0
- package/dist/history/importer.d.ts.map +1 -0
- package/dist/history/store.d.ts +90 -0
- package/dist/history/store.d.ts.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3998 -0
- package/dist/live-combat.d.ts +80 -0
- package/dist/live-combat.d.ts.map +1 -0
- package/dist/live-log.d.ts +50 -0
- package/dist/live-log.d.ts.map +1 -0
- package/dist/position-tracker.d.ts +43 -0
- package/dist/position-tracker.d.ts.map +1 -0
- package/dist/reducers/damage.d.ts +168 -0
- package/dist/reducers/damage.d.ts.map +1 -0
- package/dist/reducers/meter.d.ts +29 -0
- package/dist/reducers/meter.d.ts.map +1 -0
- package/dist/reducers/rows.d.ts +37 -0
- package/dist/reducers/rows.d.ts.map +1 -0
- package/dist/reducers/timeline.d.ts +29 -0
- package/dist/reducers/timeline.d.ts.map +1 -0
- package/dist/replay-capture.d.ts +24 -0
- package/dist/replay-capture.d.ts.map +1 -0
- package/dist/replay-summary.d.ts +14 -0
- package/dist/replay-summary.d.ts.map +1 -0
- package/dist/replay.d.ts +11 -0
- package/dist/replay.d.ts.map +1 -0
- package/dist/snapshot.d.ts +67 -0
- package/dist/snapshot.d.ts.map +1 -0
- package/dist/status-tracker.d.ts +61 -0
- package/dist/status-tracker.d.ts.map +1 -0
- package/dist/summon-calibration.d.ts +8 -0
- package/dist/summon-calibration.d.ts.map +1 -0
- package/package.json +33 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,3998 @@
|
|
|
1
|
+
// @bun
|
|
2
|
+
// src/combat-tracker.ts
|
|
3
|
+
import {
|
|
4
|
+
classifyFishNetRecoveryStyle,
|
|
5
|
+
CURRENT_GAME_BUILD_FINGERPRINT,
|
|
6
|
+
FishNetMonsterDirectory,
|
|
7
|
+
loadBundledFishNetSemanticMap
|
|
8
|
+
} from "@kar-mi/spirit-vale-tools-capture";
|
|
9
|
+
import { readSignedPackedWhole as readSignedPackedWhole3 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
10
|
+
import { loadBundledSkillCatalog } from "@kar-mi/spirit-vale-tools-skills";
|
|
11
|
+
|
|
12
|
+
// src/effect-display.ts
|
|
13
|
+
import { FishNetProtocolError } from "@kar-mi/spirit-vale-tools-capture";
|
|
14
|
+
import { checkedEnd, readSignedPackedWhole, requireBytes } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
15
|
+
function decodeEffectDisplays(payload) {
|
|
16
|
+
const applies = [];
|
|
17
|
+
let offset = 0;
|
|
18
|
+
const count = readSignedPackedWhole(payload, offset);
|
|
19
|
+
offset = count.nextOffset;
|
|
20
|
+
if (count.value < -1)
|
|
21
|
+
throw new FishNetProtocolError("invalid effect display array length");
|
|
22
|
+
for (let index = 0;index < count.value; index += 1) {
|
|
23
|
+
const status = readString(payload, offset);
|
|
24
|
+
offset = status.nextOffset;
|
|
25
|
+
requireBytes(payload, offset, 4, "effect display remaining seconds");
|
|
26
|
+
const remainingSeconds = payload.readFloatLE(offset);
|
|
27
|
+
offset += 4;
|
|
28
|
+
const stacks = readSignedPackedWhole(payload, offset);
|
|
29
|
+
offset = stacks.nextOffset;
|
|
30
|
+
const maxStacks = readSignedPackedWhole(payload, offset);
|
|
31
|
+
offset = maxStacks.nextOffset;
|
|
32
|
+
offset = readBoolean(payload, offset, "effect display flag").nextOffset;
|
|
33
|
+
if (stacks.value < 0)
|
|
34
|
+
throw new FishNetProtocolError("negative effect display stack count");
|
|
35
|
+
applies.push({
|
|
36
|
+
statusId: status.value,
|
|
37
|
+
...remainingSeconds < 0 || !Number.isFinite(remainingSeconds) ? {} : { remainingSeconds },
|
|
38
|
+
stacks: stacks.value,
|
|
39
|
+
maxStacks: Math.max(0, maxStacks.value)
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
const removes = [];
|
|
43
|
+
const removeCount = readSignedPackedWhole(payload, offset);
|
|
44
|
+
offset = removeCount.nextOffset;
|
|
45
|
+
if (removeCount.value < -1)
|
|
46
|
+
throw new FishNetProtocolError("invalid effect removal array length");
|
|
47
|
+
for (let index = 0;index < removeCount.value; index += 1) {
|
|
48
|
+
const status = readString(payload, offset);
|
|
49
|
+
offset = status.nextOffset;
|
|
50
|
+
removes.push(status.value);
|
|
51
|
+
}
|
|
52
|
+
if (offset !== payload.length) {
|
|
53
|
+
throw new FishNetProtocolError(`effect displays left ${payload.length - offset} undecoded bytes`);
|
|
54
|
+
}
|
|
55
|
+
return { applies, removes };
|
|
56
|
+
}
|
|
57
|
+
function readString(buffer, offset) {
|
|
58
|
+
const length = readSignedPackedWhole(buffer, offset);
|
|
59
|
+
if (length.value < 0)
|
|
60
|
+
throw new FishNetProtocolError("effect display status id must not be null");
|
|
61
|
+
const nextOffset = checkedEnd(buffer, length.nextOffset, length.value);
|
|
62
|
+
return { value: buffer.toString("utf8", length.nextOffset, nextOffset), nextOffset };
|
|
63
|
+
}
|
|
64
|
+
function readBoolean(buffer, offset, description) {
|
|
65
|
+
requireBytes(buffer, offset, 1, description);
|
|
66
|
+
const value = buffer[offset];
|
|
67
|
+
if (value !== 0 && value !== 1)
|
|
68
|
+
throw new FishNetProtocolError(`invalid ${description}`);
|
|
69
|
+
return { value: value === 1, nextOffset: offset + 1 };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// src/summon-calibration.ts
|
|
73
|
+
import { FishNetProtocolError as FishNetProtocolError2 } from "@kar-mi/spirit-vale-tools-capture";
|
|
74
|
+
import { checkedEnd as checkedEnd2, readSignedPackedWhole as readSignedPackedWhole2, requireBytes as requireBytes2 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
75
|
+
function decodeSummonCalibration(payload) {
|
|
76
|
+
const count = readSignedPackedWhole2(payload, 0);
|
|
77
|
+
if (count.value === -1) {
|
|
78
|
+
requireComplete(payload, count.nextOffset);
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
if (count.value < 0)
|
|
82
|
+
throw new FishNetProtocolError2("invalid summon calibration array length");
|
|
83
|
+
const entries = [];
|
|
84
|
+
let offset = count.nextOffset;
|
|
85
|
+
for (let index = 0;index < count.value; index += 1) {
|
|
86
|
+
const skill = readString2(payload, offset);
|
|
87
|
+
offset = skill.nextOffset;
|
|
88
|
+
const primary = readBoolean2(payload, offset, "summon primary flag");
|
|
89
|
+
offset = primary.nextOffset;
|
|
90
|
+
const mounted = readBoolean2(payload, offset, "summon mounted flag");
|
|
91
|
+
offset = mounted.nextOffset;
|
|
92
|
+
entries.push({ skillId: skill.value, isPrimary: primary.value, isMountedSummon: mounted.value });
|
|
93
|
+
}
|
|
94
|
+
requireComplete(payload, offset);
|
|
95
|
+
return entries;
|
|
96
|
+
}
|
|
97
|
+
function readString2(buffer, offset) {
|
|
98
|
+
const length = readSignedPackedWhole2(buffer, offset);
|
|
99
|
+
if (length.value < 0)
|
|
100
|
+
throw new FishNetProtocolError2("summon skill id must not be null");
|
|
101
|
+
const nextOffset = checkedEnd2(buffer, length.nextOffset, length.value);
|
|
102
|
+
return { value: buffer.toString("utf8", length.nextOffset, nextOffset), nextOffset };
|
|
103
|
+
}
|
|
104
|
+
function readBoolean2(buffer, offset, description) {
|
|
105
|
+
requireBytes2(buffer, offset, 1, description);
|
|
106
|
+
const value = buffer[offset];
|
|
107
|
+
if (value !== 0 && value !== 1)
|
|
108
|
+
throw new FishNetProtocolError2(`invalid ${description}`);
|
|
109
|
+
return { value: value === 1, nextOffset: offset + 1 };
|
|
110
|
+
}
|
|
111
|
+
function requireComplete(buffer, offset) {
|
|
112
|
+
if (offset !== buffer.length) {
|
|
113
|
+
throw new FishNetProtocolError2(`summon calibration left ${buffer.length - offset} undecoded bytes`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// src/combat-tracker.ts
|
|
118
|
+
var HIT_RESULTS = {
|
|
119
|
+
0: "normal",
|
|
120
|
+
1: "critical",
|
|
121
|
+
2: "miss",
|
|
122
|
+
3: "blocked",
|
|
123
|
+
4: "dodged"
|
|
124
|
+
};
|
|
125
|
+
var SKILL_RPC_NAMES = new Set(["CastBegin_C", "AutoCast_C", "ToggleBegin_C", "CastComplete_C", "CastInterrupt_C", "CastCancel_C"]);
|
|
126
|
+
var CURRENT_STATUS_COMPONENT_INDICES = new Set([5, 6]);
|
|
127
|
+
var CURRENT_HEALTH_COMPONENT_INDICES = new Set([2, 3]);
|
|
128
|
+
var MAX_RECOVERED_SUMMON_BYTES = 256;
|
|
129
|
+
var HEALING_SKILL_IDS = new Set(["Heal", "HighHeal", "FieldHealing"]);
|
|
130
|
+
var REGEN_SKILL_IDS = new Set(["HealAll", "Sanctuary", "GuardianBond", "SanctuaryField"]);
|
|
131
|
+
var REGEN_STATUS_ID = "Regeneration";
|
|
132
|
+
|
|
133
|
+
class FishNetCombatTracker {
|
|
134
|
+
hitGraceTicks;
|
|
135
|
+
activationMaxAgeTicks;
|
|
136
|
+
skillLabels;
|
|
137
|
+
actorIdentityResolver;
|
|
138
|
+
healingTraitsResolver;
|
|
139
|
+
localActorIdResolver;
|
|
140
|
+
monsterCatalog;
|
|
141
|
+
bossCatalog;
|
|
142
|
+
monsters;
|
|
143
|
+
semanticMap;
|
|
144
|
+
activations = new Map;
|
|
145
|
+
bossIdentities = new Map;
|
|
146
|
+
activeRegenSources = new Map;
|
|
147
|
+
summonStacks = new Map;
|
|
148
|
+
recentDamageSignatures = new Set;
|
|
149
|
+
recentDamageTick;
|
|
150
|
+
nextActivation = 1;
|
|
151
|
+
constructor(options = {}) {
|
|
152
|
+
const grace = options.hitGraceTicks ?? 30;
|
|
153
|
+
if (!Number.isInteger(grace) || grace < 0)
|
|
154
|
+
throw new Error("hitGraceTicks must be a non-negative integer");
|
|
155
|
+
this.hitGraceTicks = grace;
|
|
156
|
+
const maxAge = options.activationMaxAgeTicks ?? 900;
|
|
157
|
+
if (!Number.isInteger(maxAge) || maxAge < 1)
|
|
158
|
+
throw new Error("activationMaxAgeTicks must be a positive integer");
|
|
159
|
+
this.activationMaxAgeTicks = maxAge;
|
|
160
|
+
const buildFingerprint = options.buildFingerprint ?? options.skillCatalog?.buildFingerprint ?? options.semanticMap?.buildFingerprint ?? CURRENT_GAME_BUILD_FINGERPRINT;
|
|
161
|
+
assertMatchingBuild("skill catalog", options.skillCatalog?.buildFingerprint, buildFingerprint);
|
|
162
|
+
assertMatchingBuild("semantic map", options.semanticMap?.buildFingerprint, buildFingerprint);
|
|
163
|
+
const skillCatalog = options.skillCatalog ?? tryLoadBundledSkillCatalog(buildFingerprint);
|
|
164
|
+
const semanticMap = options.semanticMap ?? (skillCatalog ? tryLoadBundledSemanticMap(buildFingerprint) : loadBundledFishNetSemanticMap(buildFingerprint));
|
|
165
|
+
this.semanticMap = semanticMap;
|
|
166
|
+
this.skillLabels = new Map(skillCatalog?.skills.map(({ id, displayName }) => [id, displayName]) ?? []);
|
|
167
|
+
for (const { value, label } of semanticMap?.verifiedSkillLabels ?? [])
|
|
168
|
+
this.skillLabels.set(value, label);
|
|
169
|
+
this.actorIdentityResolver = options.actorIdentityResolver;
|
|
170
|
+
this.healingTraitsResolver = options.healingTraitsResolver;
|
|
171
|
+
this.localActorIdResolver = options.localActorIdResolver;
|
|
172
|
+
this.monsterCatalog = options.monsterCatalog;
|
|
173
|
+
if (options.monsterCatalog)
|
|
174
|
+
this.monsters = new FishNetMonsterDirectory(options.monsterCatalog);
|
|
175
|
+
this.bossCatalog = options.bossCatalog;
|
|
176
|
+
}
|
|
177
|
+
consume(packet) {
|
|
178
|
+
const monsterIdentity = this.monsterIdentity(packet.tick, this.monsters?.consume(packet));
|
|
179
|
+
const bossLifecycle = this.bossIdentityLifecycle(packet);
|
|
180
|
+
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
181
|
+
this.reset();
|
|
182
|
+
return uniqueMonsterIdentityEvents([
|
|
183
|
+
...monsterIdentity ? [monsterIdentity] : [],
|
|
184
|
+
...bossLifecycle ? [bossLifecycle] : []
|
|
185
|
+
]);
|
|
186
|
+
}
|
|
187
|
+
this.pruneExpired(packet.tick);
|
|
188
|
+
if (this.recentDamageTick !== packet.tick) {
|
|
189
|
+
this.recentDamageTick = packet.tick;
|
|
190
|
+
this.recentDamageSignatures.clear();
|
|
191
|
+
}
|
|
192
|
+
const events = [
|
|
193
|
+
...monsterIdentity ? [monsterIdentity] : [],
|
|
194
|
+
...bossLifecycle ? [bossLifecycle] : []
|
|
195
|
+
];
|
|
196
|
+
if (packet.objectId === undefined || !packet.rpcName) {
|
|
197
|
+
events.push(...this.recoverAmbiguousCombat(packet));
|
|
198
|
+
events.push(...this.recoverSummons(packet));
|
|
199
|
+
return events;
|
|
200
|
+
}
|
|
201
|
+
if (SKILL_RPC_NAMES.has(packet.rpcName) && matchesBehaviour(packet, "SkillsComponent")) {
|
|
202
|
+
const skillEvent = this.consumeSkill(packet);
|
|
203
|
+
if (skillEvent) {
|
|
204
|
+
events.push(skillEvent);
|
|
205
|
+
const bossIdentity = this.bossIdentity(skillEvent);
|
|
206
|
+
if (bossIdentity)
|
|
207
|
+
events.push(bossIdentity);
|
|
208
|
+
}
|
|
209
|
+
return events;
|
|
210
|
+
}
|
|
211
|
+
if (packet.rpcName === "Attack_C" && matchesBehaviour(packet, "CombatComponent")) {
|
|
212
|
+
events.push(this.beginAttack(packet));
|
|
213
|
+
return events;
|
|
214
|
+
}
|
|
215
|
+
if ((packet.rpcName === "ApplyDamage_C" || packet.rpcName === "Death_C") && matchesBehaviour(packet, "HealthComponent")) {
|
|
216
|
+
const death = packet.rpcName === "Death_C";
|
|
217
|
+
if (!isCompleteDamagePacket(packet, !death))
|
|
218
|
+
return events;
|
|
219
|
+
events.push(...this.consumeDamage(packet, death));
|
|
220
|
+
return events;
|
|
221
|
+
}
|
|
222
|
+
if (packet.rpcName === "Recover_C" && matchesBehaviour(packet, "HealthComponent")) {
|
|
223
|
+
if (!isCompleteRecoverPacket(packet))
|
|
224
|
+
return events;
|
|
225
|
+
events.push(this.consumeRecover(packet));
|
|
226
|
+
return events;
|
|
227
|
+
}
|
|
228
|
+
if ((packet.rpcName === "ApplyEffect_T" || packet.rpcName === "RemoveEffect_T") && matchesBehaviour(packet, "StatusComponent")) {
|
|
229
|
+
const statusEvent = this.consumeStatus(packet);
|
|
230
|
+
if (statusEvent)
|
|
231
|
+
events.push(statusEvent);
|
|
232
|
+
return events;
|
|
233
|
+
}
|
|
234
|
+
if (packet.rpcName === "ApplyEffectDisplays_O" && matchesBehaviour(packet, "StatusComponent")) {
|
|
235
|
+
events.push(...this.consumeEffectDisplays(packet));
|
|
236
|
+
return events;
|
|
237
|
+
}
|
|
238
|
+
if ((packet.rpcName === "ApplySkillDisplay_O" || packet.rpcName === "RemoveSkillDisplay_O") && matchesBehaviour(packet, "StatusComponent")) {
|
|
239
|
+
const skillDisplay = this.consumeSkillDisplay(packet);
|
|
240
|
+
if (skillDisplay)
|
|
241
|
+
events.push(skillDisplay);
|
|
242
|
+
return events;
|
|
243
|
+
}
|
|
244
|
+
if (packet.rpcName === "FullHeal_C" && matchesBehaviour(packet, "PlayerController")) {
|
|
245
|
+
if (packet.payload.length === 0) {
|
|
246
|
+
events.push({
|
|
247
|
+
kind: "fullHeal",
|
|
248
|
+
rpc: "FullHeal_C",
|
|
249
|
+
tick: packet.tick,
|
|
250
|
+
payloadBytes: 0,
|
|
251
|
+
fields: {},
|
|
252
|
+
targetId: packet.objectId,
|
|
253
|
+
actorIdentity: this.actorIdentityResolver?.(packet.objectId)
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return events;
|
|
257
|
+
}
|
|
258
|
+
if (packet.rpcName === "CalibrateSummons_T" && matchesBehaviour(packet, "SummoningComponent")) {
|
|
259
|
+
events.push(...this.consumeSummonCalibration(packet));
|
|
260
|
+
return events;
|
|
261
|
+
}
|
|
262
|
+
return events;
|
|
263
|
+
}
|
|
264
|
+
reset() {
|
|
265
|
+
this.activations.clear();
|
|
266
|
+
this.bossIdentities.clear();
|
|
267
|
+
this.activeRegenSources.clear();
|
|
268
|
+
this.summonStacks.clear();
|
|
269
|
+
this.recentDamageSignatures.clear();
|
|
270
|
+
this.recentDamageTick = undefined;
|
|
271
|
+
this.monsters?.reset();
|
|
272
|
+
}
|
|
273
|
+
monsterIdentity(tick, change) {
|
|
274
|
+
if (!change)
|
|
275
|
+
return;
|
|
276
|
+
if (change.operation === "reset")
|
|
277
|
+
return { kind: "monsterIdentity", operation: "reset", tick };
|
|
278
|
+
if (change.operation === "remove") {
|
|
279
|
+
return { kind: "monsterIdentity", operation: "remove", tick, actorId: change.objectId };
|
|
280
|
+
}
|
|
281
|
+
const definition = this.monsterCatalog?.get(change.spawn.mobId);
|
|
282
|
+
return definition ? {
|
|
283
|
+
kind: "monsterIdentity",
|
|
284
|
+
operation: "upsert",
|
|
285
|
+
tick,
|
|
286
|
+
actorId: change.objectId,
|
|
287
|
+
mobId: change.spawn.mobId,
|
|
288
|
+
displayName: definition.displayName
|
|
289
|
+
} : undefined;
|
|
290
|
+
}
|
|
291
|
+
bossIdentity(event) {
|
|
292
|
+
if (!event.sourceId || event.actorIdentity || this.monsters?.get(event.actorId))
|
|
293
|
+
return;
|
|
294
|
+
const definition = this.bossCatalog?.get(event.sourceId);
|
|
295
|
+
if (!definition)
|
|
296
|
+
return;
|
|
297
|
+
if (this.bossIdentities.has(event.actorId))
|
|
298
|
+
return;
|
|
299
|
+
this.bossIdentities.set(event.actorId, definition.displayName);
|
|
300
|
+
return {
|
|
301
|
+
kind: "monsterIdentity",
|
|
302
|
+
operation: "upsert",
|
|
303
|
+
tick: event.tick,
|
|
304
|
+
actorId: event.actorId,
|
|
305
|
+
mobId: `boss:${event.sourceId}`,
|
|
306
|
+
displayName: definition.displayName
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
bossIdentityLifecycle(packet) {
|
|
310
|
+
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
311
|
+
if (this.bossIdentities.size === 0)
|
|
312
|
+
return;
|
|
313
|
+
this.bossIdentities.clear();
|
|
314
|
+
return { kind: "monsterIdentity", operation: "reset", tick: packet.tick };
|
|
315
|
+
}
|
|
316
|
+
if (packet.packetName !== "objectSpawn" && packet.packetName !== "objectDespawn" || packet.objectId === undefined || !this.bossIdentities.delete(packet.objectId))
|
|
317
|
+
return;
|
|
318
|
+
return { kind: "monsterIdentity", operation: "remove", tick: packet.tick, actorId: packet.objectId };
|
|
319
|
+
}
|
|
320
|
+
consumeEffectDisplays(packet) {
|
|
321
|
+
let batch;
|
|
322
|
+
try {
|
|
323
|
+
batch = decodeEffectDisplays(packet.payload);
|
|
324
|
+
} catch {
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
const actorId = packet.objectId;
|
|
328
|
+
const base = {
|
|
329
|
+
kind: "status",
|
|
330
|
+
rpc: "ApplyEffectDisplays_O",
|
|
331
|
+
tick: packet.tick,
|
|
332
|
+
payloadBytes: packet.payload.length,
|
|
333
|
+
fields: {},
|
|
334
|
+
actorId,
|
|
335
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
336
|
+
};
|
|
337
|
+
return [
|
|
338
|
+
...batch.applies.map((display) => ({
|
|
339
|
+
...base,
|
|
340
|
+
statusId: display.statusId,
|
|
341
|
+
action: "applied",
|
|
342
|
+
...display.remainingSeconds === undefined ? {} : { remainingSeconds: display.remainingSeconds },
|
|
343
|
+
stacks: display.stacks
|
|
344
|
+
})),
|
|
345
|
+
...batch.removes.map((statusId) => ({ ...base, statusId, action: "removed" }))
|
|
346
|
+
];
|
|
347
|
+
}
|
|
348
|
+
recoverAmbiguousCombat(packet) {
|
|
349
|
+
if (packet.rpcResolution !== "ambiguous" || packet.packetName !== "observersRpc" || packet.objectId === undefined || packet.networkBehaviourIndex === undefined)
|
|
350
|
+
return [];
|
|
351
|
+
if (packet.rpcHash === 5 && CURRENT_STATUS_COMPONENT_INDICES.has(packet.networkBehaviourIndex)) {
|
|
352
|
+
return this.consumeEffectDisplays({
|
|
353
|
+
...packet,
|
|
354
|
+
networkBehaviourType: "StatusComponent",
|
|
355
|
+
rpcName: "ApplyEffectDisplays_O"
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
if (packet.rpcHash !== 1 || !CURRENT_HEALTH_COMPONENT_INDICES.has(packet.networkBehaviourIndex))
|
|
359
|
+
return [];
|
|
360
|
+
let amount;
|
|
361
|
+
try {
|
|
362
|
+
amount = readSignedPackedWhole3(packet.payload, 0);
|
|
363
|
+
} catch {
|
|
364
|
+
return [];
|
|
365
|
+
}
|
|
366
|
+
if (amount.value < 0)
|
|
367
|
+
return [];
|
|
368
|
+
const settings = packet.payload.subarray(amount.nextOffset);
|
|
369
|
+
const settingsHex = settings.toString("hex");
|
|
370
|
+
const knownHealthRecovery = this.semanticMap?.recoveryStyles?.some((definition) => definition.networkBehaviourType === "HealthComponent" && definition.rpcName === "Recover_C" && definition.undecodedPayloadHex === settingsHex) ?? false;
|
|
371
|
+
if (!knownHealthRecovery)
|
|
372
|
+
return [];
|
|
373
|
+
return [this.consumeRecover({
|
|
374
|
+
...packet,
|
|
375
|
+
networkBehaviourType: "HealthComponent",
|
|
376
|
+
rpcName: "Recover_C",
|
|
377
|
+
decodedFields: [{ name: "amount", codec: "packedInt32", value: amount.value }],
|
|
378
|
+
undecodedPayload: settings
|
|
379
|
+
})];
|
|
380
|
+
}
|
|
381
|
+
consumeSkillDisplay(packet) {
|
|
382
|
+
const statusId = stringField(packet, "id");
|
|
383
|
+
if (!statusId)
|
|
384
|
+
return;
|
|
385
|
+
const actorId = packet.objectId;
|
|
386
|
+
const level = numberField(packet, "lv");
|
|
387
|
+
return {
|
|
388
|
+
kind: "status",
|
|
389
|
+
rpc: packet.rpcName,
|
|
390
|
+
tick: packet.tick,
|
|
391
|
+
payloadBytes: packet.payload.length,
|
|
392
|
+
fields: decodedFieldRecord(packet),
|
|
393
|
+
actorId,
|
|
394
|
+
statusId,
|
|
395
|
+
action: packet.rpcName === "ApplySkillDisplay_O" ? "applied" : "removed",
|
|
396
|
+
...level === undefined ? {} : { level },
|
|
397
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
398
|
+
};
|
|
399
|
+
}
|
|
400
|
+
recoverSummons(packet) {
|
|
401
|
+
if (packet.rpcName || packet.rpcResolution === "verified" || packet.rpcResolution === "recovered")
|
|
402
|
+
return [];
|
|
403
|
+
if (packet.payload.length < 2 || packet.payload.length > MAX_RECOVERED_SUMMON_BYTES)
|
|
404
|
+
return [];
|
|
405
|
+
if (this.skillLabels.size === 0)
|
|
406
|
+
return [];
|
|
407
|
+
const actorId = packet.objectId ?? this.localActorIdResolver?.();
|
|
408
|
+
if (actorId === undefined)
|
|
409
|
+
return [];
|
|
410
|
+
let entries;
|
|
411
|
+
try {
|
|
412
|
+
entries = decodeSummonCalibration(packet.payload);
|
|
413
|
+
} catch {
|
|
414
|
+
return [];
|
|
415
|
+
}
|
|
416
|
+
if (entries.length === 0)
|
|
417
|
+
return [];
|
|
418
|
+
if (!entries.every(({ skillId }) => this.skillLabels.has(skillId)))
|
|
419
|
+
return [];
|
|
420
|
+
return this.applySummonSnapshot(actorId, packet, entries).map((event) => ({ ...event, recovered: true }));
|
|
421
|
+
}
|
|
422
|
+
consumeSummonCalibration(packet) {
|
|
423
|
+
let entries;
|
|
424
|
+
try {
|
|
425
|
+
entries = decodeSummonCalibration(packet.payload);
|
|
426
|
+
} catch {
|
|
427
|
+
return [];
|
|
428
|
+
}
|
|
429
|
+
return this.applySummonSnapshot(packet.objectId, packet, entries);
|
|
430
|
+
}
|
|
431
|
+
applySummonSnapshot(actorId, packet, entries) {
|
|
432
|
+
const previous = this.summonStacks.get(actorId) ?? new Map;
|
|
433
|
+
const current = new Map;
|
|
434
|
+
for (const { skillId } of entries)
|
|
435
|
+
current.set(skillId, (current.get(skillId) ?? 0) + 1);
|
|
436
|
+
const changedSkillIds = [
|
|
437
|
+
...current.keys(),
|
|
438
|
+
...[...previous.keys()].filter((skillId) => !current.has(skillId))
|
|
439
|
+
].filter((skillId) => current.get(skillId) !== previous.get(skillId));
|
|
440
|
+
if (current.size === 0)
|
|
441
|
+
this.summonStacks.delete(actorId);
|
|
442
|
+
else
|
|
443
|
+
this.summonStacks.set(actorId, current);
|
|
444
|
+
return changedSkillIds.map((skillId) => ({
|
|
445
|
+
kind: "summon",
|
|
446
|
+
rpc: "CalibrateSummons_T",
|
|
447
|
+
tick: packet.tick,
|
|
448
|
+
payloadBytes: packet.payload.length,
|
|
449
|
+
fields: {},
|
|
450
|
+
actorId,
|
|
451
|
+
skillId,
|
|
452
|
+
stacks: current.get(skillId) ?? 0,
|
|
453
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
454
|
+
}));
|
|
455
|
+
}
|
|
456
|
+
consumeSkill(packet) {
|
|
457
|
+
const actorId = packet.objectId;
|
|
458
|
+
const rpcName = packet.rpcName;
|
|
459
|
+
if (!rpcName)
|
|
460
|
+
return;
|
|
461
|
+
if (rpcName === "CastBegin_C" || rpcName === "AutoCast_C" || rpcName === "ToggleBegin_C") {
|
|
462
|
+
const sourceId = rpcName === "ToggleBegin_C" ? stringField(packet, "id") : stringField(packet, "dto.Id");
|
|
463
|
+
if (!sourceId)
|
|
464
|
+
return;
|
|
465
|
+
const activation2 = this.createActivation(actorId, "skill", packet.tick, sourceId, false);
|
|
466
|
+
activation2.targetId = numberField(packet, "targetId");
|
|
467
|
+
return {
|
|
468
|
+
kind: "activation",
|
|
469
|
+
rpc: rpcName,
|
|
470
|
+
tick: packet.tick,
|
|
471
|
+
payloadBytes: packet.payload.length,
|
|
472
|
+
fields: decodedFieldRecord(packet),
|
|
473
|
+
actorId,
|
|
474
|
+
activationId: activation2.id,
|
|
475
|
+
actionKind: "skill",
|
|
476
|
+
phase: "begin",
|
|
477
|
+
sourceId,
|
|
478
|
+
sourceLabel: activation2.sourceLabel,
|
|
479
|
+
targetId: activation2.targetId,
|
|
480
|
+
level: numberField(packet, "dto.Level")
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
const phases = {
|
|
484
|
+
CastComplete_C: "complete",
|
|
485
|
+
CastInterrupt_C: "interrupt",
|
|
486
|
+
CastCancel_C: "cancel"
|
|
487
|
+
};
|
|
488
|
+
const phase = phases[rpcName];
|
|
489
|
+
if (!phase)
|
|
490
|
+
return;
|
|
491
|
+
const activation = this.findLifecycleActivation(actorId);
|
|
492
|
+
if (activation) {
|
|
493
|
+
activation.endTick = packet.tick;
|
|
494
|
+
activation.deadlineTick = packet.tick + this.hitGraceTicks;
|
|
495
|
+
}
|
|
496
|
+
return {
|
|
497
|
+
kind: "activation",
|
|
498
|
+
rpc: rpcName,
|
|
499
|
+
tick: packet.tick,
|
|
500
|
+
payloadBytes: packet.payload.length,
|
|
501
|
+
fields: decodedFieldRecord(packet),
|
|
502
|
+
actorId,
|
|
503
|
+
activationId: activation?.id,
|
|
504
|
+
actionKind: activation?.actionKind ?? "skill",
|
|
505
|
+
phase,
|
|
506
|
+
sourceId: activation?.sourceId,
|
|
507
|
+
sourceLabel: activation?.sourceLabel
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
beginAttack(packet) {
|
|
511
|
+
const actorId = packet.objectId;
|
|
512
|
+
const attackIndex = numberField(packet, "attackIndex");
|
|
513
|
+
const activation = this.createActivation(actorId, "basicAttack", packet.tick, undefined, false);
|
|
514
|
+
activation.endTick = packet.tick;
|
|
515
|
+
activation.deadlineTick = packet.tick + this.hitGraceTicks;
|
|
516
|
+
return {
|
|
517
|
+
kind: "activation",
|
|
518
|
+
rpc: "Attack_C",
|
|
519
|
+
tick: packet.tick,
|
|
520
|
+
payloadBytes: packet.payload.length,
|
|
521
|
+
fields: decodedFieldRecord(packet),
|
|
522
|
+
actorId,
|
|
523
|
+
activationId: activation.id,
|
|
524
|
+
actionKind: "basicAttack",
|
|
525
|
+
phase: "begin",
|
|
526
|
+
attackIndex
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
consumeDamage(packet, death) {
|
|
530
|
+
const actorId = requiredNumberField(packet, "dmg.AttackerId");
|
|
531
|
+
const damageType = requiredNumberField(packet, "dmg.Type");
|
|
532
|
+
const rawSourceId = nullableStringField(packet, "dmg.DamageSourceId");
|
|
533
|
+
const { sourceId, sourceLabel } = resolveDamageSource(rawSourceId, damageType, this.skillLabels);
|
|
534
|
+
const value = requiredNumberField(packet, "dmg.Value");
|
|
535
|
+
if (!death && value < 0) {
|
|
536
|
+
return [{
|
|
537
|
+
kind: "heal",
|
|
538
|
+
rpc: "ApplyDamage_C",
|
|
539
|
+
tick: packet.tick,
|
|
540
|
+
payloadBytes: packet.payload.length,
|
|
541
|
+
fields: decodedFieldRecord(packet),
|
|
542
|
+
targetId: packet.objectId,
|
|
543
|
+
actorId,
|
|
544
|
+
sourceId,
|
|
545
|
+
sourceLabel,
|
|
546
|
+
value: -value,
|
|
547
|
+
attribution: "exact",
|
|
548
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
549
|
+
}];
|
|
550
|
+
}
|
|
551
|
+
const hitCode = requiredNumberField(packet, "dmg.Hit");
|
|
552
|
+
const hitResult = HIT_RESULTS[hitCode] ?? hitCode;
|
|
553
|
+
const targetId = packet.objectId;
|
|
554
|
+
const exactCandidates = this.eligibleActivations(packet.tick, actorId).filter((activation) => activation.sourceId === sourceId);
|
|
555
|
+
let candidates = exactCandidates;
|
|
556
|
+
if (candidates.length === 0) {
|
|
557
|
+
const unboundAttacks = this.eligibleActivations(packet.tick, actorId).filter((activation) => activation.actionKind === "basicAttack" && activation.sourceId === undefined);
|
|
558
|
+
if (unboundAttacks.length === 1) {
|
|
559
|
+
const [attack] = unboundAttacks;
|
|
560
|
+
if (attack) {
|
|
561
|
+
attack.sourceId = sourceId;
|
|
562
|
+
attack.sourceLabel = sourceLabel;
|
|
563
|
+
candidates = [attack];
|
|
564
|
+
}
|
|
565
|
+
} else if (unboundAttacks.length > 1) {
|
|
566
|
+
candidates = unboundAttacks;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
const events = [];
|
|
570
|
+
let attribution;
|
|
571
|
+
let activationId;
|
|
572
|
+
let candidateActivationIds;
|
|
573
|
+
if (candidates.length === 0) {
|
|
574
|
+
const inferred = this.createActivation(actorId, "inferred", packet.tick, sourceId, true);
|
|
575
|
+
inferred.endTick = packet.tick;
|
|
576
|
+
inferred.deadlineTick = packet.tick + this.hitGraceTicks;
|
|
577
|
+
candidates = [inferred];
|
|
578
|
+
attribution = "inferred";
|
|
579
|
+
activationId = inferred.id;
|
|
580
|
+
events.push({
|
|
581
|
+
kind: "activation",
|
|
582
|
+
rpc: "inferred",
|
|
583
|
+
tick: packet.tick,
|
|
584
|
+
payloadBytes: 0,
|
|
585
|
+
fields: {},
|
|
586
|
+
actorId,
|
|
587
|
+
activationId: inferred.id,
|
|
588
|
+
actionKind: "inferred",
|
|
589
|
+
phase: "inferred",
|
|
590
|
+
sourceId,
|
|
591
|
+
sourceLabel,
|
|
592
|
+
inferred: true
|
|
593
|
+
});
|
|
594
|
+
} else if (candidates.length === 1) {
|
|
595
|
+
const [candidate] = candidates;
|
|
596
|
+
if (!candidate)
|
|
597
|
+
throw new Error("missing combat activation candidate");
|
|
598
|
+
attribution = candidate.inferred ? "inferred" : "exact";
|
|
599
|
+
activationId = candidate.id;
|
|
600
|
+
} else {
|
|
601
|
+
attribution = "ambiguous";
|
|
602
|
+
candidateActivationIds = candidates.map(({ id }) => id);
|
|
603
|
+
}
|
|
604
|
+
const common = {
|
|
605
|
+
tick: packet.tick,
|
|
606
|
+
payloadBytes: packet.payload.length,
|
|
607
|
+
fields: decodedFieldRecord(packet),
|
|
608
|
+
actorId,
|
|
609
|
+
targetId,
|
|
610
|
+
sourceId,
|
|
611
|
+
sourceLabel,
|
|
612
|
+
value,
|
|
613
|
+
hitResult,
|
|
614
|
+
wireHits: requiredNumberField(packet, "dmg.Hits"),
|
|
615
|
+
damageType,
|
|
616
|
+
team: requiredNumberField(packet, "dmg.Team"),
|
|
617
|
+
element: requiredNumberField(packet, "dmg.Element"),
|
|
618
|
+
weaponType: requiredNumberField(packet, "dmg.WeaponType"),
|
|
619
|
+
range: requiredNumberField(packet, "dmg.Range"),
|
|
620
|
+
isClone: requiredBooleanField(packet, "dmg.IsClone"),
|
|
621
|
+
isSummon: requiredBooleanField(packet, "dmg.IsSummon"),
|
|
622
|
+
attribution,
|
|
623
|
+
activationId,
|
|
624
|
+
candidateActivationIds,
|
|
625
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
626
|
+
};
|
|
627
|
+
const signature = damageSignature(packet.tick, targetId, actorId, sourceId, value, hitCode);
|
|
628
|
+
if (death) {
|
|
629
|
+
events.push({
|
|
630
|
+
kind: "death",
|
|
631
|
+
rpc: "Death_C",
|
|
632
|
+
...common,
|
|
633
|
+
duplicatesDamageEvent: this.recentDamageSignatures.has(signature)
|
|
634
|
+
});
|
|
635
|
+
} else {
|
|
636
|
+
this.recentDamageSignatures.add(signature);
|
|
637
|
+
events.push({
|
|
638
|
+
kind: "damage",
|
|
639
|
+
rpc: "ApplyDamage_C",
|
|
640
|
+
...common,
|
|
641
|
+
position: requiredVectorField(packet, "position"),
|
|
642
|
+
origin: requiredVectorField(packet, "origin")
|
|
643
|
+
});
|
|
644
|
+
}
|
|
645
|
+
return events;
|
|
646
|
+
}
|
|
647
|
+
consumeRecover(packet) {
|
|
648
|
+
const targetId = packet.objectId;
|
|
649
|
+
const value = requiredNumberField(packet, "amount");
|
|
650
|
+
const recoveryStyle = classifyFishNetRecoveryStyle(packet, this.semanticMap);
|
|
651
|
+
if (recoveryStyle === "passive-regeneration") {
|
|
652
|
+
return this.createSelfRecovery(packet, value, recoveryStyle, "passive-regeneration", "Passive regeneration");
|
|
653
|
+
}
|
|
654
|
+
if (recoveryStyle === "drain") {
|
|
655
|
+
const source = drainRecoverySource(this.healingTraitsResolver?.(targetId));
|
|
656
|
+
return this.createSelfRecovery(packet, value, recoveryStyle, source.sourceId, source.sourceLabel);
|
|
657
|
+
}
|
|
658
|
+
const candidates = this.eligibleHealActivations(packet.tick, targetId, HEALING_SKILL_IDS);
|
|
659
|
+
let attribution;
|
|
660
|
+
let actorId;
|
|
661
|
+
let sourceId;
|
|
662
|
+
let sourceLabel;
|
|
663
|
+
let activationId;
|
|
664
|
+
let candidateActivationIds;
|
|
665
|
+
if (candidates.length === 1) {
|
|
666
|
+
const [candidate] = candidates;
|
|
667
|
+
if (!candidate)
|
|
668
|
+
throw new Error("missing combat activation candidate");
|
|
669
|
+
attribution = candidate.inferred ? "inferred" : "exact";
|
|
670
|
+
actorId = candidate.actorId;
|
|
671
|
+
sourceId = candidate.sourceId;
|
|
672
|
+
sourceLabel = candidate.sourceLabel;
|
|
673
|
+
activationId = candidate.id;
|
|
674
|
+
} else if (candidates.length > 1) {
|
|
675
|
+
attribution = "ambiguous";
|
|
676
|
+
candidateActivationIds = candidates.map(({ id }) => id);
|
|
677
|
+
} else {
|
|
678
|
+
const regenSource = this.activeRegenSources.get(targetId);
|
|
679
|
+
if (regenSource?.candidateActivationIds) {
|
|
680
|
+
attribution = "ambiguous";
|
|
681
|
+
candidateActivationIds = regenSource.candidateActivationIds;
|
|
682
|
+
} else if (regenSource?.actorId !== undefined) {
|
|
683
|
+
attribution = "inferred";
|
|
684
|
+
actorId = regenSource.actorId;
|
|
685
|
+
sourceId = regenSource.sourceId;
|
|
686
|
+
sourceLabel = regenSource.sourceLabel;
|
|
687
|
+
activationId = regenSource.activationId;
|
|
688
|
+
} else {
|
|
689
|
+
attribution = "unattributed";
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
return {
|
|
693
|
+
kind: "heal",
|
|
694
|
+
rpc: "Recover_C",
|
|
695
|
+
tick: packet.tick,
|
|
696
|
+
payloadBytes: packet.payload.length,
|
|
697
|
+
fields: decodedFieldRecord(packet),
|
|
698
|
+
targetId,
|
|
699
|
+
actorId,
|
|
700
|
+
sourceId,
|
|
701
|
+
sourceLabel,
|
|
702
|
+
value,
|
|
703
|
+
recoveryStyle,
|
|
704
|
+
attribution,
|
|
705
|
+
activationId,
|
|
706
|
+
candidateActivationIds,
|
|
707
|
+
actorIdentity: actorId === undefined ? undefined : this.actorIdentityResolver?.(actorId)
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
createSelfRecovery(packet, value, recoveryStyle, sourceId, sourceLabel) {
|
|
711
|
+
const actorId = packet.objectId;
|
|
712
|
+
return {
|
|
713
|
+
kind: "heal",
|
|
714
|
+
rpc: "Recover_C",
|
|
715
|
+
tick: packet.tick,
|
|
716
|
+
payloadBytes: packet.payload.length,
|
|
717
|
+
fields: decodedFieldRecord(packet),
|
|
718
|
+
targetId: actorId,
|
|
719
|
+
actorId,
|
|
720
|
+
sourceId,
|
|
721
|
+
sourceLabel,
|
|
722
|
+
value,
|
|
723
|
+
recoveryStyle,
|
|
724
|
+
attribution: "inferred",
|
|
725
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
eligibleHealActivations(tick, recipientId, skillIds) {
|
|
729
|
+
return [...this.activations.values()].filter((activation) => activation.actionKind === "skill" && activation.sourceId !== undefined && skillIds.has(activation.sourceId) && activation.startTick <= tick && (activation.deadlineTick === undefined || tick <= activation.deadlineTick) && activation.targetId === recipientId);
|
|
730
|
+
}
|
|
731
|
+
consumeStatus(packet) {
|
|
732
|
+
const statusId = stringField(packet, "statusId");
|
|
733
|
+
const level = numberField(packet, "level");
|
|
734
|
+
if (statusId === undefined || level === undefined)
|
|
735
|
+
return;
|
|
736
|
+
const actorId = packet.objectId;
|
|
737
|
+
if (statusId === REGEN_STATUS_ID) {
|
|
738
|
+
if (packet.rpcName === "ApplyEffect_T") {
|
|
739
|
+
const candidates = this.eligibleHealActivations(packet.tick, actorId, REGEN_SKILL_IDS);
|
|
740
|
+
if (candidates.length === 1) {
|
|
741
|
+
const [candidate] = candidates;
|
|
742
|
+
if (!candidate)
|
|
743
|
+
throw new Error("missing combat activation candidate");
|
|
744
|
+
this.activeRegenSources.set(actorId, {
|
|
745
|
+
actorId: candidate.actorId,
|
|
746
|
+
sourceId: candidate.sourceId,
|
|
747
|
+
sourceLabel: candidate.sourceLabel,
|
|
748
|
+
activationId: candidate.id
|
|
749
|
+
});
|
|
750
|
+
} else if (candidates.length > 1) {
|
|
751
|
+
this.activeRegenSources.set(actorId, { candidateActivationIds: candidates.map(({ id }) => id) });
|
|
752
|
+
} else {
|
|
753
|
+
this.activeRegenSources.delete(actorId);
|
|
754
|
+
}
|
|
755
|
+
} else {
|
|
756
|
+
this.activeRegenSources.delete(actorId);
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
return {
|
|
760
|
+
kind: "status",
|
|
761
|
+
rpc: packet.rpcName,
|
|
762
|
+
tick: packet.tick,
|
|
763
|
+
payloadBytes: packet.payload.length,
|
|
764
|
+
fields: decodedFieldRecord(packet),
|
|
765
|
+
actorId,
|
|
766
|
+
statusId,
|
|
767
|
+
level,
|
|
768
|
+
action: packet.rpcName === "ApplyEffect_T" ? "applied" : "removed",
|
|
769
|
+
actorIdentity: this.actorIdentityResolver?.(actorId)
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
createActivation(actorId, actionKind, tick, sourceId, inferred) {
|
|
773
|
+
const activation = {
|
|
774
|
+
id: `activation-${this.nextActivation++}`,
|
|
775
|
+
actorId,
|
|
776
|
+
actionKind,
|
|
777
|
+
sourceId,
|
|
778
|
+
sourceLabel: sourceId === undefined ? undefined : this.skillLabels.get(sourceId) ?? sourceId,
|
|
779
|
+
startTick: tick,
|
|
780
|
+
inferred
|
|
781
|
+
};
|
|
782
|
+
this.activations.set(activation.id, activation);
|
|
783
|
+
return activation;
|
|
784
|
+
}
|
|
785
|
+
findLifecycleActivation(actorId) {
|
|
786
|
+
return [...this.activations.values()].filter((activation) => activation.actorId === actorId && activation.actionKind === "skill" && activation.endTick === undefined).sort((left, right) => left.startTick - right.startTick)[0];
|
|
787
|
+
}
|
|
788
|
+
eligibleActivations(tick, actorId) {
|
|
789
|
+
return [...this.activations.values()].filter((activation) => {
|
|
790
|
+
return activation.actorId === actorId && activation.startTick <= tick && (activation.deadlineTick === undefined || tick <= activation.deadlineTick);
|
|
791
|
+
});
|
|
792
|
+
}
|
|
793
|
+
pruneExpired(tick) {
|
|
794
|
+
const expired = [...this.activations.values()].filter((activation) => activation.deadlineTick !== undefined ? activation.deadlineTick < tick : tick - activation.startTick > this.activationMaxAgeTicks).map(({ id }) => id);
|
|
795
|
+
for (const id of expired)
|
|
796
|
+
this.activations.delete(id);
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
function drainRecoverySource(traits) {
|
|
800
|
+
if (traits?.hasSiphonHealth && !traits.hasHealthLeech) {
|
|
801
|
+
return { sourceId: "siphon-health", sourceLabel: "Siphon Health" };
|
|
802
|
+
}
|
|
803
|
+
if (traits?.hasHealthLeech && !traits.hasSiphonHealth) {
|
|
804
|
+
return { sourceId: "health-leech", sourceLabel: "Health Leech" };
|
|
805
|
+
}
|
|
806
|
+
return { sourceId: "siphon-health-leech", sourceLabel: "Siphon / Health Leech" };
|
|
807
|
+
}
|
|
808
|
+
function field(packet, name) {
|
|
809
|
+
return packet.decodedFields?.find((candidate) => candidate.name === name)?.value;
|
|
810
|
+
}
|
|
811
|
+
function decodedFieldRecord(packet) {
|
|
812
|
+
return Object.fromEntries(packet.decodedFields?.map(({ name, value }) => [name, value]) ?? []);
|
|
813
|
+
}
|
|
814
|
+
function matchesBehaviour(packet, expected) {
|
|
815
|
+
return packet.networkBehaviourType === undefined || packet.networkBehaviourType === expected;
|
|
816
|
+
}
|
|
817
|
+
function numberField(packet, name) {
|
|
818
|
+
const value = field(packet, name);
|
|
819
|
+
return typeof value === "number" ? value : undefined;
|
|
820
|
+
}
|
|
821
|
+
function stringField(packet, name) {
|
|
822
|
+
const value = field(packet, name);
|
|
823
|
+
return typeof value === "string" ? value : undefined;
|
|
824
|
+
}
|
|
825
|
+
function requiredNumberField(packet, name) {
|
|
826
|
+
const value = numberField(packet, name);
|
|
827
|
+
if (value === undefined)
|
|
828
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing numeric field ${name}`);
|
|
829
|
+
return value;
|
|
830
|
+
}
|
|
831
|
+
function requiredBooleanField(packet, name) {
|
|
832
|
+
const value = field(packet, name);
|
|
833
|
+
if (typeof value !== "boolean")
|
|
834
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing boolean field ${name}`);
|
|
835
|
+
return value;
|
|
836
|
+
}
|
|
837
|
+
function requiredVectorField(packet, name) {
|
|
838
|
+
const value = field(packet, name);
|
|
839
|
+
if (!Array.isArray(value))
|
|
840
|
+
throw new Error(`${packet.networkBehaviourType}.${packet.rpcName} is missing vector field ${name}`);
|
|
841
|
+
return value;
|
|
842
|
+
}
|
|
843
|
+
function isCompleteDamagePacket(packet, requireVectors) {
|
|
844
|
+
const numeric = [
|
|
845
|
+
"dmg.Team",
|
|
846
|
+
"dmg.Value",
|
|
847
|
+
"dmg.Type",
|
|
848
|
+
"dmg.Hit",
|
|
849
|
+
"dmg.Hits",
|
|
850
|
+
"dmg.AttackerId",
|
|
851
|
+
"dmg.Element",
|
|
852
|
+
"dmg.WeaponType",
|
|
853
|
+
"dmg.Range"
|
|
854
|
+
];
|
|
855
|
+
return numeric.every((name) => numberField(packet, name) !== undefined) && nullableStringField(packet, "dmg.DamageSourceId") !== undefined && typeof field(packet, "dmg.IsClone") === "boolean" && typeof field(packet, "dmg.IsSummon") === "boolean" && (!requireVectors || Array.isArray(field(packet, "position")) && Array.isArray(field(packet, "origin")));
|
|
856
|
+
}
|
|
857
|
+
function isCompleteRecoverPacket(packet) {
|
|
858
|
+
return numberField(packet, "amount") !== undefined;
|
|
859
|
+
}
|
|
860
|
+
function nullableStringField(packet, name) {
|
|
861
|
+
const value = field(packet, name);
|
|
862
|
+
return typeof value === "string" || value === null ? value : undefined;
|
|
863
|
+
}
|
|
864
|
+
function damageSignature(tick, targetId, actorId, sourceId, value, hitCode) {
|
|
865
|
+
return `${tick}\x00${targetId}\x00${actorId}\x00${sourceId}\x00${value}\x00${hitCode}`;
|
|
866
|
+
}
|
|
867
|
+
var DAMAGE_TYPE_SOURCES = new Map([
|
|
868
|
+
[4, { sourceId: "reflect", sourceLabel: "Reflect Damage" }]
|
|
869
|
+
]);
|
|
870
|
+
function resolveDamageSource(rawSourceId, damageType, skillLabels) {
|
|
871
|
+
if (typeof rawSourceId === "string") {
|
|
872
|
+
return {
|
|
873
|
+
sourceId: rawSourceId,
|
|
874
|
+
sourceLabel: skillLabels.get(rawSourceId) ?? rawSourceId
|
|
875
|
+
};
|
|
876
|
+
}
|
|
877
|
+
return (rawSourceId === null ? DAMAGE_TYPE_SOURCES.get(damageType) : undefined) ?? {
|
|
878
|
+
sourceId: "unknown",
|
|
879
|
+
sourceLabel: "unknown"
|
|
880
|
+
};
|
|
881
|
+
}
|
|
882
|
+
function uniqueMonsterIdentityEvents(events) {
|
|
883
|
+
return events.filter((event, index) => event.operation !== "reset" || events.findIndex((candidate) => candidate.operation === "reset") === index);
|
|
884
|
+
}
|
|
885
|
+
function tryLoadBundledSkillCatalog(buildFingerprint) {
|
|
886
|
+
try {
|
|
887
|
+
return loadBundledSkillCatalog(buildFingerprint);
|
|
888
|
+
} catch {
|
|
889
|
+
return;
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
function tryLoadBundledSemanticMap(buildFingerprint) {
|
|
893
|
+
try {
|
|
894
|
+
return loadBundledFishNetSemanticMap(buildFingerprint);
|
|
895
|
+
} catch {
|
|
896
|
+
return;
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
function assertMatchingBuild(label, candidate, expected) {
|
|
900
|
+
if (candidate !== undefined && candidate !== expected) {
|
|
901
|
+
throw new Error(`${label} build ${JSON.stringify(candidate)} does not match ${JSON.stringify(expected)}`);
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
// src/boss-catalog.ts
|
|
905
|
+
function createBossCatalog(entries) {
|
|
906
|
+
const catalog = new Map(Object.entries(entries).map(([skillId, displayName]) => [skillId, { displayName }]));
|
|
907
|
+
return catalog;
|
|
908
|
+
}
|
|
909
|
+
var CURRENT_BOSS_SKILL_NAMES = createBossCatalog({});
|
|
910
|
+
// src/actor-directory.ts
|
|
911
|
+
import { characterDataParameter, decodeFieldRun, loadBundledFishNetRpcMap } from "@kar-mi/spirit-vale-tools-capture";
|
|
912
|
+
import { readSignedPackedWhole as readSignedPackedWhole4 } from "@kar-mi/spirit-vale-tools-capture/wire-reader";
|
|
913
|
+
|
|
914
|
+
class FishNetActorDirectory {
|
|
915
|
+
options;
|
|
916
|
+
identities = new Map;
|
|
917
|
+
objects = new Map;
|
|
918
|
+
ownerObjects = new Map;
|
|
919
|
+
identitySources = new Map;
|
|
920
|
+
sourceRevisions = new Map;
|
|
921
|
+
observedPlayerActors = new Set;
|
|
922
|
+
uidIdentities = new Map;
|
|
923
|
+
nextSourceRevision = 1;
|
|
924
|
+
localIdentity;
|
|
925
|
+
constructor(options = {}) {
|
|
926
|
+
this.options = options;
|
|
927
|
+
this.localIdentity = options.localIdentity;
|
|
928
|
+
for (const known of options.knownIdentities ?? []) {
|
|
929
|
+
this.uidIdentities.set(known.uid, {
|
|
930
|
+
displayName: known.displayName,
|
|
931
|
+
...known.archetype === undefined ? {} : { archetype: known.archetype }
|
|
932
|
+
});
|
|
933
|
+
}
|
|
934
|
+
this.seedLocalIdentity();
|
|
935
|
+
}
|
|
936
|
+
consume(packet) {
|
|
937
|
+
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
938
|
+
this.clear();
|
|
939
|
+
return [{ kind: "actorIdentity", operation: "reset", tick: packet.tick }];
|
|
940
|
+
}
|
|
941
|
+
if (packet.packetName === "objectSpawn" && packet.objectId !== undefined) {
|
|
942
|
+
const observedPlayerActor = this.observedPlayerActors.has(packet.objectId);
|
|
943
|
+
const events = this.removeObject(packet.objectId, packet.tick, true);
|
|
944
|
+
if (observedPlayerActor)
|
|
945
|
+
this.observedPlayerActors.add(packet.objectId);
|
|
946
|
+
const ownerConnectionId = validOwner(packet.ownerConnectionId);
|
|
947
|
+
const identityEligible = hasIdentityBehaviourEvidence(packet) || observedPlayerActor;
|
|
948
|
+
this.objects.set(packet.objectId, {
|
|
949
|
+
...ownerConnectionId === undefined ? {} : { ownerConnectionId },
|
|
950
|
+
identityEligible
|
|
951
|
+
});
|
|
952
|
+
if (ownerConnectionId !== undefined)
|
|
953
|
+
this.addOwnerObject(ownerConnectionId, packet.objectId);
|
|
954
|
+
if (hasMonsterIdentityEvidence(packet)) {
|
|
955
|
+
events.push(...this.clearPlayerIdentity(packet.objectId, packet.tick));
|
|
956
|
+
events.push(...this.refreshOwner(ownerConnectionId, packet.tick, true));
|
|
957
|
+
return events;
|
|
958
|
+
}
|
|
959
|
+
const embeddedIdentity = this.resolveSpawnIdentity(packet);
|
|
960
|
+
if (embeddedIdentity) {
|
|
961
|
+
const next = {
|
|
962
|
+
actorId: packet.objectId,
|
|
963
|
+
displayName: embeddedIdentity.displayName,
|
|
964
|
+
...embeddedIdentity.archetype === undefined ? {} : { archetype: embeddedIdentity.archetype }
|
|
965
|
+
};
|
|
966
|
+
this.identitySources.set(packet.objectId, next);
|
|
967
|
+
this.sourceRevisions.set(packet.objectId, this.nextSourceRevision++);
|
|
968
|
+
const object = this.objects.get(packet.objectId);
|
|
969
|
+
if (object)
|
|
970
|
+
object.identityEligible = true;
|
|
971
|
+
if (ownerConnectionId === undefined)
|
|
972
|
+
events.push(...this.reconcile(packet.objectId, next, packet.tick));
|
|
973
|
+
}
|
|
974
|
+
events.push(...this.refreshOwner(ownerConnectionId, packet.tick, true));
|
|
975
|
+
return events;
|
|
976
|
+
}
|
|
977
|
+
if (packet.packetName === "objectDespawn" && packet.objectId !== undefined) {
|
|
978
|
+
return this.removeObject(packet.objectId, packet.tick, true);
|
|
979
|
+
}
|
|
980
|
+
if (packet.packetName === "ownershipChange" && packet.objectId !== undefined) {
|
|
981
|
+
return this.changeOwner(packet.objectId, validOwner(packet.ownerConnectionId), packet.tick);
|
|
982
|
+
}
|
|
983
|
+
if (packet.packetName === "serverRpc" && packet.objectId !== undefined && this.localIdentity !== undefined && !this.identitySources.has(packet.objectId)) {
|
|
984
|
+
return this.applyIdentitySource(packet.objectId, {
|
|
985
|
+
actorId: packet.objectId,
|
|
986
|
+
displayName: this.localIdentity.displayName,
|
|
987
|
+
...this.localIdentity.uid === undefined ? {} : { uid: this.localIdentity.uid },
|
|
988
|
+
...this.localIdentity.archetype === undefined ? {} : { archetype: this.localIdentity.archetype }
|
|
989
|
+
}, packet.tick);
|
|
990
|
+
}
|
|
991
|
+
if (packet.objectId !== undefined && hasMonsterIdentityEvidence(packet)) {
|
|
992
|
+
return this.clearPlayerIdentity(packet.objectId, packet.tick);
|
|
993
|
+
}
|
|
994
|
+
if (packet.objectId !== undefined && packet.rpcName !== undefined && CHARACTER_RPC_NAMES.has(packet.rpcName)) {
|
|
995
|
+
const character = decodeCharacterDataName(packet.payload);
|
|
996
|
+
if (character === undefined)
|
|
997
|
+
return [];
|
|
998
|
+
const archetype2 = this.localIdentity?.displayName === character.displayName ? this.localIdentity.archetype : undefined;
|
|
999
|
+
const identity = {
|
|
1000
|
+
...character,
|
|
1001
|
+
...archetype2 === undefined ? {} : { archetype: archetype2 }
|
|
1002
|
+
};
|
|
1003
|
+
this.learnUidIdentity(character.uid, identity);
|
|
1004
|
+
this.updateLocalIdentity(identity);
|
|
1005
|
+
return this.applyIdentitySource(packet.objectId, { actorId: packet.objectId, ...identity }, packet.tick);
|
|
1006
|
+
}
|
|
1007
|
+
if (packet.packetName !== "syncType" || packet.objectId === undefined || packet.networkBehaviourType !== "PlayerController" || packet.syncName !== "VisualData" && packet.syncIndex !== 5) {
|
|
1008
|
+
return [];
|
|
1009
|
+
}
|
|
1010
|
+
const displayName = decodedField(packet, "Appearance.DisplayName");
|
|
1011
|
+
if (typeof displayName !== "string" || displayName.length === 0)
|
|
1012
|
+
return [];
|
|
1013
|
+
const decodedArchetype = decodedField(packet, "Appearance.Archetype");
|
|
1014
|
+
const archetype = typeof decodedArchetype === "number" && Number.isInteger(decodedArchetype) ? decodedArchetype : undefined;
|
|
1015
|
+
return this.applyIdentitySource(packet.objectId, {
|
|
1016
|
+
actorId: packet.objectId,
|
|
1017
|
+
displayName,
|
|
1018
|
+
...archetype === undefined ? {} : { archetype }
|
|
1019
|
+
}, packet.tick);
|
|
1020
|
+
}
|
|
1021
|
+
applyIdentitySource(actorId, next, tick) {
|
|
1022
|
+
const current = this.identitySources.get(actorId);
|
|
1023
|
+
const identity = next.archetype === undefined && current?.displayName === next.displayName && current.archetype !== undefined ? { ...next, archetype: current.archetype } : next;
|
|
1024
|
+
this.identitySources.set(actorId, identity);
|
|
1025
|
+
this.sourceRevisions.set(actorId, this.nextSourceRevision++);
|
|
1026
|
+
const object = this.objects.get(actorId);
|
|
1027
|
+
if (object)
|
|
1028
|
+
object.identityEligible = true;
|
|
1029
|
+
if (object?.ownerConnectionId !== undefined)
|
|
1030
|
+
return this.refreshOwner(object.ownerConnectionId, tick);
|
|
1031
|
+
return this.reconcile(actorId, identity, tick);
|
|
1032
|
+
}
|
|
1033
|
+
get(actorId) {
|
|
1034
|
+
const identity = this.identities.get(actorId);
|
|
1035
|
+
return identity ? { ...identity } : undefined;
|
|
1036
|
+
}
|
|
1037
|
+
getAttribution(actorId) {
|
|
1038
|
+
const direct = this.identities.get(actorId);
|
|
1039
|
+
if (direct)
|
|
1040
|
+
return { ...direct };
|
|
1041
|
+
const ownerConnectionId = this.objects.get(actorId)?.ownerConnectionId;
|
|
1042
|
+
if (ownerConnectionId === undefined)
|
|
1043
|
+
return;
|
|
1044
|
+
const objectIds = this.ownerObjects.get(ownerConnectionId);
|
|
1045
|
+
if (!objectIds)
|
|
1046
|
+
return;
|
|
1047
|
+
let best;
|
|
1048
|
+
let bestRevision = -1;
|
|
1049
|
+
for (const objectId of objectIds) {
|
|
1050
|
+
const identity = this.identities.get(objectId) ?? this.identitySources.get(objectId);
|
|
1051
|
+
const revision = this.sourceRevisions.get(objectId) ?? -1;
|
|
1052
|
+
if (identity && revision >= bestRevision) {
|
|
1053
|
+
best = identity;
|
|
1054
|
+
bestRevision = revision;
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
1057
|
+
return best ? { ...best, ownerConnectionId } : undefined;
|
|
1058
|
+
}
|
|
1059
|
+
snapshot() {
|
|
1060
|
+
return [...this.identities.values()].map((identity) => ({ ...identity }));
|
|
1061
|
+
}
|
|
1062
|
+
observePlayerActor(actorId, tick) {
|
|
1063
|
+
if (!Number.isInteger(actorId) || actorId < 0)
|
|
1064
|
+
return [];
|
|
1065
|
+
this.observedPlayerActors.add(actorId);
|
|
1066
|
+
const object = this.objects.get(actorId);
|
|
1067
|
+
if (!object)
|
|
1068
|
+
return [];
|
|
1069
|
+
object.identityEligible = true;
|
|
1070
|
+
if (object.ownerConnectionId !== undefined)
|
|
1071
|
+
return this.refreshOwner(object.ownerConnectionId, tick);
|
|
1072
|
+
return this.reconcile(actorId, this.identitySources.get(actorId), tick);
|
|
1073
|
+
}
|
|
1074
|
+
reset() {
|
|
1075
|
+
this.clear();
|
|
1076
|
+
this.seedLocalIdentity();
|
|
1077
|
+
}
|
|
1078
|
+
setLocalIdentity(identity) {
|
|
1079
|
+
this.localIdentity = mergeLocalIdentity(this.localIdentity, identity);
|
|
1080
|
+
this.seedLocalIdentity();
|
|
1081
|
+
}
|
|
1082
|
+
seedLocalIdentity() {
|
|
1083
|
+
if (!this.localIdentity)
|
|
1084
|
+
return;
|
|
1085
|
+
if (this.localIdentity.uid) {
|
|
1086
|
+
this.learnUidIdentity(this.localIdentity.uid, {
|
|
1087
|
+
displayName: this.localIdentity.displayName,
|
|
1088
|
+
...this.localIdentity.archetype === undefined ? {} : { archetype: this.localIdentity.archetype }
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
updateLocalIdentity(identity) {
|
|
1093
|
+
const next = mergeLocalIdentity(this.localIdentity, identity);
|
|
1094
|
+
if (this.localIdentity?.displayName === next.displayName && this.localIdentity.uid === next.uid && this.localIdentity.archetype === next.archetype)
|
|
1095
|
+
return;
|
|
1096
|
+
this.localIdentity = next;
|
|
1097
|
+
this.options.onLocalIdentity?.(next);
|
|
1098
|
+
}
|
|
1099
|
+
resolveSpawnIdentity(packet) {
|
|
1100
|
+
return decodeSpawnIdentity(packet);
|
|
1101
|
+
}
|
|
1102
|
+
learnUidIdentity(uid, identity) {
|
|
1103
|
+
const current = this.uidIdentities.get(uid);
|
|
1104
|
+
const archetype = identity.archetype ?? (current?.displayName === identity.displayName ? current.archetype : undefined);
|
|
1105
|
+
const next = {
|
|
1106
|
+
displayName: identity.displayName,
|
|
1107
|
+
...archetype === undefined ? {} : { archetype }
|
|
1108
|
+
};
|
|
1109
|
+
if (current?.displayName === next.displayName && current.archetype === next.archetype)
|
|
1110
|
+
return;
|
|
1111
|
+
this.uidIdentities.set(uid, next);
|
|
1112
|
+
this.options.onIdentityLearned?.({ uid, ...next });
|
|
1113
|
+
}
|
|
1114
|
+
changeOwner(actorId, ownerConnectionId, tick) {
|
|
1115
|
+
const current = this.objects.get(actorId) ?? { identityEligible: false };
|
|
1116
|
+
const previousOwner = current.ownerConnectionId;
|
|
1117
|
+
if (previousOwner === ownerConnectionId)
|
|
1118
|
+
return [];
|
|
1119
|
+
if (previousOwner !== undefined)
|
|
1120
|
+
this.removeOwnerObject(previousOwner, actorId);
|
|
1121
|
+
current.ownerConnectionId = ownerConnectionId;
|
|
1122
|
+
this.objects.set(actorId, current);
|
|
1123
|
+
if (ownerConnectionId !== undefined)
|
|
1124
|
+
this.addOwnerObject(ownerConnectionId, actorId);
|
|
1125
|
+
const events = this.refreshOwner(previousOwner, tick, true);
|
|
1126
|
+
if (ownerConnectionId === undefined) {
|
|
1127
|
+
events.push(...this.reconcile(actorId, this.identitySources.get(actorId), tick));
|
|
1128
|
+
} else {
|
|
1129
|
+
events.push(...this.refreshOwner(ownerConnectionId, tick, true));
|
|
1130
|
+
}
|
|
1131
|
+
return events;
|
|
1132
|
+
}
|
|
1133
|
+
removeObject(actorId, tick, retainIdentity = false) {
|
|
1134
|
+
const object = this.objects.get(actorId);
|
|
1135
|
+
const ownerConnectionId = object?.ownerConnectionId;
|
|
1136
|
+
if (ownerConnectionId !== undefined)
|
|
1137
|
+
this.removeOwnerObject(ownerConnectionId, actorId);
|
|
1138
|
+
this.objects.delete(actorId);
|
|
1139
|
+
this.observedPlayerActors.delete(actorId);
|
|
1140
|
+
this.identitySources.delete(actorId);
|
|
1141
|
+
this.sourceRevisions.delete(actorId);
|
|
1142
|
+
const events = retainIdentity ? [] : this.reconcile(actorId, undefined, tick);
|
|
1143
|
+
events.push(...this.refreshOwner(ownerConnectionId, tick, retainIdentity));
|
|
1144
|
+
return events;
|
|
1145
|
+
}
|
|
1146
|
+
clearPlayerIdentity(actorId, tick) {
|
|
1147
|
+
this.observedPlayerActors.delete(actorId);
|
|
1148
|
+
this.identitySources.delete(actorId);
|
|
1149
|
+
this.sourceRevisions.delete(actorId);
|
|
1150
|
+
const object = this.objects.get(actorId);
|
|
1151
|
+
if (object)
|
|
1152
|
+
object.identityEligible = false;
|
|
1153
|
+
const events = this.reconcile(actorId, undefined, tick);
|
|
1154
|
+
events.push(...this.refreshOwner(object?.ownerConnectionId, tick, true));
|
|
1155
|
+
return events;
|
|
1156
|
+
}
|
|
1157
|
+
refreshOwner(ownerConnectionId, tick, retainExistingWhenSourceMissing = false) {
|
|
1158
|
+
if (ownerConnectionId === undefined)
|
|
1159
|
+
return [];
|
|
1160
|
+
const objectIds = this.ownerObjects.get(ownerConnectionId);
|
|
1161
|
+
if (!objectIds)
|
|
1162
|
+
return [];
|
|
1163
|
+
let source;
|
|
1164
|
+
let revision = -1;
|
|
1165
|
+
for (const objectId of objectIds) {
|
|
1166
|
+
const candidate = this.identitySources.get(objectId);
|
|
1167
|
+
const candidateRevision = this.sourceRevisions.get(objectId) ?? -1;
|
|
1168
|
+
if (candidate && candidateRevision > revision) {
|
|
1169
|
+
source = candidate;
|
|
1170
|
+
revision = candidateRevision;
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
if (!source && retainExistingWhenSourceMissing)
|
|
1174
|
+
return [];
|
|
1175
|
+
if (source && source.archetype === undefined) {
|
|
1176
|
+
const sourceDisplayName = source.displayName;
|
|
1177
|
+
let classSource;
|
|
1178
|
+
let classRevision = -1;
|
|
1179
|
+
for (const objectId of objectIds) {
|
|
1180
|
+
const candidate = this.identitySources.get(objectId);
|
|
1181
|
+
const candidateRevision = this.sourceRevisions.get(objectId) ?? -1;
|
|
1182
|
+
if (candidate?.displayName === sourceDisplayName && candidate.archetype !== undefined && candidateRevision > classRevision) {
|
|
1183
|
+
classSource = candidate;
|
|
1184
|
+
classRevision = candidateRevision;
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
if (classSource?.archetype !== undefined)
|
|
1188
|
+
source = { ...source, archetype: classSource.archetype };
|
|
1189
|
+
}
|
|
1190
|
+
if (source?.archetype !== undefined) {
|
|
1191
|
+
for (const objectId of objectIds) {
|
|
1192
|
+
const candidate = this.identitySources.get(objectId);
|
|
1193
|
+
if (candidate?.displayName === source.displayName && candidate.archetype !== source.archetype) {
|
|
1194
|
+
this.identitySources.set(objectId, { ...candidate, archetype: source.archetype });
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
const events = [];
|
|
1199
|
+
for (const objectId of objectIds) {
|
|
1200
|
+
if (!this.objects.get(objectId)?.identityEligible)
|
|
1201
|
+
continue;
|
|
1202
|
+
const identity = source && {
|
|
1203
|
+
actorId: objectId,
|
|
1204
|
+
displayName: source.displayName,
|
|
1205
|
+
...source.archetype === undefined ? {} : { archetype: source.archetype },
|
|
1206
|
+
...source.uid === undefined ? {} : { uid: source.uid },
|
|
1207
|
+
ownerConnectionId
|
|
1208
|
+
};
|
|
1209
|
+
events.push(...this.reconcile(objectId, identity, tick));
|
|
1210
|
+
}
|
|
1211
|
+
return events;
|
|
1212
|
+
}
|
|
1213
|
+
reconcile(actorId, next, tick) {
|
|
1214
|
+
const current = this.identities.get(actorId);
|
|
1215
|
+
if (!next) {
|
|
1216
|
+
if (!this.identities.delete(actorId))
|
|
1217
|
+
return [];
|
|
1218
|
+
return [{ kind: "actorIdentity", operation: "remove", tick, actorId }];
|
|
1219
|
+
}
|
|
1220
|
+
if (current?.displayName === next.displayName && current.archetype === next.archetype && current.ownerConnectionId === next.ownerConnectionId && current.uid === next.uid)
|
|
1221
|
+
return [];
|
|
1222
|
+
this.identities.set(actorId, next);
|
|
1223
|
+
return [{ kind: "actorIdentity", operation: "upsert", tick, ...next }];
|
|
1224
|
+
}
|
|
1225
|
+
addOwnerObject(ownerConnectionId, actorId) {
|
|
1226
|
+
const objects = this.ownerObjects.get(ownerConnectionId) ?? new Set;
|
|
1227
|
+
objects.add(actorId);
|
|
1228
|
+
this.ownerObjects.set(ownerConnectionId, objects);
|
|
1229
|
+
}
|
|
1230
|
+
removeOwnerObject(ownerConnectionId, actorId) {
|
|
1231
|
+
const objects = this.ownerObjects.get(ownerConnectionId);
|
|
1232
|
+
if (!objects)
|
|
1233
|
+
return;
|
|
1234
|
+
objects.delete(actorId);
|
|
1235
|
+
if (objects.size === 0)
|
|
1236
|
+
this.ownerObjects.delete(ownerConnectionId);
|
|
1237
|
+
}
|
|
1238
|
+
clear() {
|
|
1239
|
+
this.identities.clear();
|
|
1240
|
+
this.objects.clear();
|
|
1241
|
+
this.ownerObjects.clear();
|
|
1242
|
+
this.identitySources.clear();
|
|
1243
|
+
this.sourceRevisions.clear();
|
|
1244
|
+
this.observedPlayerActors.clear();
|
|
1245
|
+
this.nextSourceRevision = 1;
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
var IDENTITY_BEHAVIOURS = new Set(["PlayerController", "SkillsComponent", "CombatComponent", "HealthComponent"]);
|
|
1249
|
+
function validOwner(ownerConnectionId) {
|
|
1250
|
+
return ownerConnectionId !== undefined && Number.isInteger(ownerConnectionId) && ownerConnectionId >= 0 ? ownerConnectionId : undefined;
|
|
1251
|
+
}
|
|
1252
|
+
function mergeLocalIdentity(current, next) {
|
|
1253
|
+
const sameCharacter = current?.displayName === next.displayName;
|
|
1254
|
+
const uid = next.uid ?? (sameCharacter ? current?.uid : undefined);
|
|
1255
|
+
const archetype = next.archetype ?? (sameCharacter ? current?.archetype : undefined);
|
|
1256
|
+
return {
|
|
1257
|
+
displayName: next.displayName,
|
|
1258
|
+
...uid === undefined ? {} : { uid },
|
|
1259
|
+
...archetype === undefined ? {} : { archetype }
|
|
1260
|
+
};
|
|
1261
|
+
}
|
|
1262
|
+
function decodedField(packet, name) {
|
|
1263
|
+
return packet.decodedFields?.find((field2) => field2.name === name)?.value;
|
|
1264
|
+
}
|
|
1265
|
+
function hasMonsterIdentityEvidence(packet) {
|
|
1266
|
+
if (packet.packetName === "objectSpawn") {
|
|
1267
|
+
const entry = packet.spawnSyncEntries?.find((candidate) => candidate.networkBehaviourType === "MonsterController" && candidate.name === "Data");
|
|
1268
|
+
const mobId2 = entry?.fields.find((field2) => field2.name === "Id")?.value;
|
|
1269
|
+
return typeof mobId2 === "string" && mobId2.length > 0;
|
|
1270
|
+
}
|
|
1271
|
+
if (packet.packetName !== "syncType" || packet.networkBehaviourType !== "MonsterController" || packet.syncName !== "Data" && packet.syncIndex !== 0)
|
|
1272
|
+
return false;
|
|
1273
|
+
const mobId = decodedField(packet, "Data.Id") ?? decodedField(packet, "Monster.Id") ?? decodedField(packet, "Id");
|
|
1274
|
+
return typeof mobId === "string" && mobId.length > 0;
|
|
1275
|
+
}
|
|
1276
|
+
var VISUAL_DATA_SYNC_INDEX = 5;
|
|
1277
|
+
var PLAYER_PREFAB_KEYS = new Set((loadBundledFishNetRpcMap().prefabs ?? []).filter(({ components }) => components.some(({ typeName }) => typeName === "PlayerController")).map(({ collectionId, prefabId }) => `${collectionId}:${prefabId}`));
|
|
1278
|
+
function decodeSpawnIdentity(packet) {
|
|
1279
|
+
if (!hasPlayerControllerEvidence(packet))
|
|
1280
|
+
return;
|
|
1281
|
+
const entry = packet.spawnSyncEntries?.find((candidate) => candidate.networkBehaviourType === "PlayerController" && candidate.index === VISUAL_DATA_SYNC_INDEX);
|
|
1282
|
+
if (!entry)
|
|
1283
|
+
return;
|
|
1284
|
+
const displayName = entry.fields.find((field2) => field2.name === "Appearance.DisplayName")?.value;
|
|
1285
|
+
const archetype = entry.fields.find((field2) => field2.name === "Appearance.Archetype")?.value;
|
|
1286
|
+
if (typeof displayName !== "string" || displayName.length === 0 || !Number.isInteger(archetype))
|
|
1287
|
+
return;
|
|
1288
|
+
return { displayName, archetype };
|
|
1289
|
+
}
|
|
1290
|
+
function hasIdentityBehaviourEvidence(packet) {
|
|
1291
|
+
return isCurrentPlayerPrefab(packet) || (packet.rpcLinkRegistrations?.some(({ networkBehaviourType }) => networkBehaviourType !== undefined && IDENTITY_BEHAVIOURS.has(networkBehaviourType)) ?? false);
|
|
1292
|
+
}
|
|
1293
|
+
function hasPlayerControllerEvidence(packet) {
|
|
1294
|
+
return isCurrentPlayerPrefab(packet) || (packet.rpcLinkRegistrations?.some(({ networkBehaviourType }) => networkBehaviourType === "PlayerController") ?? false);
|
|
1295
|
+
}
|
|
1296
|
+
function isCurrentPlayerPrefab(packet) {
|
|
1297
|
+
if (packet.spawnCollectionId === undefined || packet.spawnPrefabId === undefined)
|
|
1298
|
+
return false;
|
|
1299
|
+
return PLAYER_PREFAB_KEYS.has(`${packet.spawnCollectionId}:${packet.spawnPrefabId}`);
|
|
1300
|
+
}
|
|
1301
|
+
function hasControlCharacters(value) {
|
|
1302
|
+
for (const character of value) {
|
|
1303
|
+
const code = character.codePointAt(0);
|
|
1304
|
+
if (code < 32 || code === 127)
|
|
1305
|
+
return true;
|
|
1306
|
+
}
|
|
1307
|
+
return false;
|
|
1308
|
+
}
|
|
1309
|
+
var CHARACTER_RPC_NAMES = new Set(["LoadCharacter_T", "CharacterCallback_T"]);
|
|
1310
|
+
var GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1311
|
+
function decodeCharacterDataName(payload) {
|
|
1312
|
+
for (const skipEnum of [true, false]) {
|
|
1313
|
+
try {
|
|
1314
|
+
let offset = 0;
|
|
1315
|
+
if (skipEnum)
|
|
1316
|
+
offset = readSignedPackedWhole4(payload, offset).nextOffset;
|
|
1317
|
+
const run = decodeFieldRun(payload, [characterNameParameter()], offset);
|
|
1318
|
+
const values = new Map(run.fields.map((field2) => [field2.name, field2.value]));
|
|
1319
|
+
const uid = values.get("data.UID");
|
|
1320
|
+
if (typeof uid !== "string" || !GUID_PATTERN.test(uid))
|
|
1321
|
+
continue;
|
|
1322
|
+
const name = values.get("data.Name");
|
|
1323
|
+
if (typeof name !== "string" || !name.trim() || hasControlCharacters(name))
|
|
1324
|
+
continue;
|
|
1325
|
+
return { displayName: name, uid };
|
|
1326
|
+
} catch {}
|
|
1327
|
+
}
|
|
1328
|
+
return;
|
|
1329
|
+
}
|
|
1330
|
+
var cachedCharacterNameParameter;
|
|
1331
|
+
function characterNameParameter() {
|
|
1332
|
+
if (cachedCharacterNameParameter)
|
|
1333
|
+
return cachedCharacterNameParameter;
|
|
1334
|
+
const full = characterDataParameter();
|
|
1335
|
+
const fields = full.fields ?? [];
|
|
1336
|
+
const nameIndex = fields.findIndex((field2) => field2.name === "Name");
|
|
1337
|
+
cachedCharacterNameParameter = { ...full, fields: fields.slice(0, nameIndex + 1) };
|
|
1338
|
+
return cachedCharacterNameParameter;
|
|
1339
|
+
}
|
|
1340
|
+
// src/status-tracker.ts
|
|
1341
|
+
import {
|
|
1342
|
+
FishNetStatusDirectory,
|
|
1343
|
+
loadBundledStatusCatalog,
|
|
1344
|
+
statusDurationSeconds
|
|
1345
|
+
} from "@kar-mi/spirit-vale-tools-statuses";
|
|
1346
|
+
import { FishNetSkillDirectory, loadBundledSkillCatalog as loadBundledSkillCatalog2 } from "@kar-mi/spirit-vale-tools-skills";
|
|
1347
|
+
|
|
1348
|
+
// src/reducers/damage.ts
|
|
1349
|
+
import { EwmaRate } from "@kar-mi/spirit-vale-tools-metrics";
|
|
1350
|
+
|
|
1351
|
+
// src/reducers/timeline.ts
|
|
1352
|
+
var ANALYSIS_BUCKET_MS = 5000;
|
|
1353
|
+
function createSeries(originMs, widthMs = ANALYSIS_BUCKET_MS) {
|
|
1354
|
+
return { originMs, widthMs, buckets: [], dirtyFrom: Number.POSITIVE_INFINITY };
|
|
1355
|
+
}
|
|
1356
|
+
function takeDirtyFrom(series) {
|
|
1357
|
+
const from = series.dirtyFrom;
|
|
1358
|
+
series.dirtyFrom = Number.POSITIVE_INFINITY;
|
|
1359
|
+
return from;
|
|
1360
|
+
}
|
|
1361
|
+
function addToSeries(series, atMs, damage, maxBuckets = Number.POSITIVE_INFINITY) {
|
|
1362
|
+
let index = Math.max(0, Math.floor((atMs - series.originMs) / series.widthMs));
|
|
1363
|
+
while (index >= maxBuckets) {
|
|
1364
|
+
collapse(series);
|
|
1365
|
+
index = Math.max(0, Math.floor((atMs - series.originMs) / series.widthMs));
|
|
1366
|
+
}
|
|
1367
|
+
series.dirtyFrom = Math.min(series.dirtyFrom, series.buckets.length, index);
|
|
1368
|
+
while (series.buckets.length <= index)
|
|
1369
|
+
series.buckets.push(0);
|
|
1370
|
+
series.buckets[index] += damage;
|
|
1371
|
+
}
|
|
1372
|
+
function collapse(series) {
|
|
1373
|
+
const merged = [];
|
|
1374
|
+
for (let index = 0;index < series.buckets.length; index += 2) {
|
|
1375
|
+
merged.push((series.buckets[index] ?? 0) + (series.buckets[index + 1] ?? 0));
|
|
1376
|
+
}
|
|
1377
|
+
series.buckets = merged;
|
|
1378
|
+
series.widthMs *= 2;
|
|
1379
|
+
series.dirtyFrom = 0;
|
|
1380
|
+
}
|
|
1381
|
+
function addSeries(target, source) {
|
|
1382
|
+
if (source.originMs === target.originMs && source.widthMs === target.widthMs) {
|
|
1383
|
+
for (const [index, damage] of source.buckets.entries()) {
|
|
1384
|
+
while (target.buckets.length <= index)
|
|
1385
|
+
target.buckets.push(0);
|
|
1386
|
+
target.buckets[index] += damage;
|
|
1387
|
+
}
|
|
1388
|
+
return;
|
|
1389
|
+
}
|
|
1390
|
+
for (const [index, damage] of source.buckets.entries()) {
|
|
1391
|
+
if (damage === 0)
|
|
1392
|
+
continue;
|
|
1393
|
+
addToSeries(target, source.originMs + index * source.widthMs, damage);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
function seriesPoints(series, durationMs) {
|
|
1397
|
+
const bucketCount = Math.max(1, Math.ceil(durationMs / series.widthMs));
|
|
1398
|
+
const points = [{ elapsedMs: 0, damage: 0, cumulativeDamage: 0, dps: 0 }];
|
|
1399
|
+
let cumulativeDamage = 0;
|
|
1400
|
+
for (let index = 0;index < bucketCount; index += 1) {
|
|
1401
|
+
const elapsedMs = Math.min(durationMs, (index + 1) * series.widthMs);
|
|
1402
|
+
const bucketDurationMs = Math.max(1, elapsedMs - index * series.widthMs);
|
|
1403
|
+
let damage = series.buckets[index] ?? 0;
|
|
1404
|
+
if (index === bucketCount - 1) {
|
|
1405
|
+
for (let overflow = bucketCount;overflow < series.buckets.length; overflow += 1) {
|
|
1406
|
+
damage += series.buckets[overflow] ?? 0;
|
|
1407
|
+
}
|
|
1408
|
+
}
|
|
1409
|
+
cumulativeDamage += damage;
|
|
1410
|
+
points.push({ elapsedMs, damage, cumulativeDamage, dps: damage / (bucketDurationMs / 1000) });
|
|
1411
|
+
}
|
|
1412
|
+
return points;
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/reducers/damage.ts
|
|
1416
|
+
var DEFAULT_IDLE_GAP_MS = 30000;
|
|
1417
|
+
var DEFAULT_MINIMUM_DURATION_MS = 1000;
|
|
1418
|
+
var DEFAULT_CURRENT_TAU_SECONDS = 2.5;
|
|
1419
|
+
var MOB_IDENTITY_PREFIX = "__spiritvaleMobIdentity:";
|
|
1420
|
+
var DEATH_LOOKBACK_MS = 1e4;
|
|
1421
|
+
var MAX_MOB_IDENTITIES = 4096;
|
|
1422
|
+
var MAX_IDENTITIES = 4096;
|
|
1423
|
+
|
|
1424
|
+
class DamageReducer {
|
|
1425
|
+
identities = new Map;
|
|
1426
|
+
mobIdentities = new Map;
|
|
1427
|
+
current;
|
|
1428
|
+
recentHits = new Map;
|
|
1429
|
+
lastSweepAtMs;
|
|
1430
|
+
idleGapMs;
|
|
1431
|
+
currentTauSeconds;
|
|
1432
|
+
maxTimelineBuckets;
|
|
1433
|
+
createEncounterId;
|
|
1434
|
+
onEncounterFinished;
|
|
1435
|
+
nextEncounter = 1;
|
|
1436
|
+
constructor(options = {}) {
|
|
1437
|
+
this.idleGapMs = options.idleGapMs ?? DEFAULT_IDLE_GAP_MS;
|
|
1438
|
+
this.currentTauSeconds = positiveTau(options.currentTauSeconds ?? DEFAULT_CURRENT_TAU_SECONDS);
|
|
1439
|
+
this.maxTimelineBuckets = options.maxTimelineBuckets ?? Number.POSITIVE_INFINITY;
|
|
1440
|
+
this.createEncounterId = options.createEncounterId ?? (() => `encounter-${this.nextEncounter++}`);
|
|
1441
|
+
if (options.onEncounterFinished)
|
|
1442
|
+
this.onEncounterFinished = options.onEncounterFinished;
|
|
1443
|
+
}
|
|
1444
|
+
resume(encounter) {
|
|
1445
|
+
this.current = encounter;
|
|
1446
|
+
}
|
|
1447
|
+
consumeIdentity(event, _observedAtMs) {
|
|
1448
|
+
if (event.operation === "reset") {
|
|
1449
|
+
this.identities.clear();
|
|
1450
|
+
this.mobIdentities.clear();
|
|
1451
|
+
if (this.current) {
|
|
1452
|
+
for (const actor2 of this.current.actors)
|
|
1453
|
+
actor2.activeIdentity = false;
|
|
1454
|
+
this.current.activeActors.clear();
|
|
1455
|
+
}
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
if (event.operation === "remove") {
|
|
1459
|
+
this.identities.delete(event.actorId);
|
|
1460
|
+
const actor2 = this.current?.activeActors.get(event.actorId);
|
|
1461
|
+
if (actor2)
|
|
1462
|
+
actor2.activeIdentity = false;
|
|
1463
|
+
this.current?.activeActors.delete(event.actorId);
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
const previousIdentity = this.identities.get(event.actorId);
|
|
1467
|
+
const archetype = event.archetype ?? previousIdentity?.archetype;
|
|
1468
|
+
const ownerConnectionId = event.ownerConnectionId ?? previousIdentity?.ownerConnectionId;
|
|
1469
|
+
const uid = event.uid ?? previousIdentity?.uid;
|
|
1470
|
+
this.rememberIdentity(event.actorId, {
|
|
1471
|
+
displayName: event.displayName,
|
|
1472
|
+
...archetype === undefined ? {} : { archetype },
|
|
1473
|
+
...ownerConnectionId === undefined ? {} : { ownerConnectionId },
|
|
1474
|
+
...uid === undefined ? {} : { uid }
|
|
1475
|
+
});
|
|
1476
|
+
if (!this.current)
|
|
1477
|
+
return;
|
|
1478
|
+
const actor = this.actorFor(event.actorId);
|
|
1479
|
+
actor.displayName = event.displayName;
|
|
1480
|
+
if (event.archetype !== undefined)
|
|
1481
|
+
actor.archetype = event.archetype;
|
|
1482
|
+
if (event.ownerConnectionId !== undefined)
|
|
1483
|
+
actor.ownerConnectionId = event.ownerConnectionId;
|
|
1484
|
+
if (event.uid !== undefined)
|
|
1485
|
+
actor.uid = event.uid;
|
|
1486
|
+
actor.activeIdentity = true;
|
|
1487
|
+
}
|
|
1488
|
+
consumeCombat(event, observedAtMs) {
|
|
1489
|
+
if (event.kind === "monsterIdentity") {
|
|
1490
|
+
if (event.operation === "reset")
|
|
1491
|
+
this.mobIdentities.clear();
|
|
1492
|
+
else if (event.operation === "remove")
|
|
1493
|
+
this.mobIdentities.delete(event.actorId);
|
|
1494
|
+
else {
|
|
1495
|
+
this.rememberMobIdentity(event.actorId, event.displayName);
|
|
1496
|
+
if (this.current?.enemyFirstSeenAtMs.has(event.actorId)) {
|
|
1497
|
+
this.current.enemyNames.set(event.actorId, event.displayName);
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const actorId = event.actorId;
|
|
1503
|
+
if (event.actorIdentity && actorId !== undefined) {
|
|
1504
|
+
const previousIdentity = this.identities.get(actorId);
|
|
1505
|
+
const archetype = event.actorIdentity.archetype ?? previousIdentity?.archetype;
|
|
1506
|
+
const ownerConnectionId = event.actorIdentity.ownerConnectionId ?? previousIdentity?.ownerConnectionId;
|
|
1507
|
+
const uid = event.actorIdentity.uid ?? previousIdentity?.uid;
|
|
1508
|
+
this.rememberIdentity(actorId, {
|
|
1509
|
+
displayName: event.actorIdentity.displayName,
|
|
1510
|
+
...archetype === undefined ? {} : { archetype },
|
|
1511
|
+
...ownerConnectionId === undefined ? {} : { ownerConnectionId },
|
|
1512
|
+
...uid === undefined ? {} : { uid }
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
if (event.kind === "activation" && event.sourceId?.startsWith(MOB_IDENTITY_PREFIX) && event.sourceLabel) {
|
|
1516
|
+
this.rememberMobIdentity(event.actorId, event.sourceLabel);
|
|
1517
|
+
return;
|
|
1518
|
+
}
|
|
1519
|
+
this.trackRecentHit(event, observedAtMs);
|
|
1520
|
+
const countedDamage = isCountedDamage(event);
|
|
1521
|
+
const countedKill = isCountedKill(event);
|
|
1522
|
+
if (!countedDamage && !countedKill) {
|
|
1523
|
+
if (this.current && observedAtMs - this.current.lastDamageAtMs >= this.idleGapMs) {
|
|
1524
|
+
this.finish(this.current.lastDamageAtMs + this.idleGapMs);
|
|
1525
|
+
}
|
|
1526
|
+
if (this.current)
|
|
1527
|
+
this.recordEncounterHit(event, observedAtMs);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (this.current && observedAtMs - this.current.lastDamageAtMs >= this.idleGapMs) {
|
|
1531
|
+
this.finish(this.current.lastDamageAtMs + this.idleGapMs);
|
|
1532
|
+
}
|
|
1533
|
+
if (!this.current && !countedDamage)
|
|
1534
|
+
return;
|
|
1535
|
+
if (!this.current) {
|
|
1536
|
+
const actors = [...this.identities].map(([identityActorId, identity]) => ({
|
|
1537
|
+
...createActor(identityActorId, observedAtMs),
|
|
1538
|
+
...identity,
|
|
1539
|
+
activeIdentity: true
|
|
1540
|
+
}));
|
|
1541
|
+
this.current = {
|
|
1542
|
+
id: this.createEncounterId(observedAtMs),
|
|
1543
|
+
startedAtMs: observedAtMs,
|
|
1544
|
+
lastDamageAtMs: observedAtMs,
|
|
1545
|
+
actors,
|
|
1546
|
+
activeActors: new Map(actors.map((actor2) => [actor2.actorId, actor2])),
|
|
1547
|
+
enemyFirstSeenAtMs: new Map,
|
|
1548
|
+
enemyNames: new Map,
|
|
1549
|
+
deaths: []
|
|
1550
|
+
};
|
|
1551
|
+
}
|
|
1552
|
+
const encounter = this.current;
|
|
1553
|
+
if (countedDamage)
|
|
1554
|
+
encounter.lastDamageAtMs = observedAtMs;
|
|
1555
|
+
this.recordEncounterHit(event, observedAtMs);
|
|
1556
|
+
const actor = this.actorFor(event.actorId);
|
|
1557
|
+
const eventIdentity = event.actorIdentity ?? this.identities.get(event.actorId);
|
|
1558
|
+
if (eventIdentity) {
|
|
1559
|
+
actor.displayName = eventIdentity.displayName;
|
|
1560
|
+
if (eventIdentity.archetype !== undefined)
|
|
1561
|
+
actor.archetype = eventIdentity.archetype;
|
|
1562
|
+
if (eventIdentity.ownerConnectionId !== undefined)
|
|
1563
|
+
actor.ownerConnectionId = eventIdentity.ownerConnectionId;
|
|
1564
|
+
if (eventIdentity.uid !== undefined)
|
|
1565
|
+
actor.uid = eventIdentity.uid;
|
|
1566
|
+
actor.activeIdentity = true;
|
|
1567
|
+
}
|
|
1568
|
+
if (countedDamage) {
|
|
1569
|
+
recordHit(actor, {
|
|
1570
|
+
value: event.value,
|
|
1571
|
+
atMs: observedAtMs,
|
|
1572
|
+
critical: event.hitResult === "critical",
|
|
1573
|
+
sourceId: event.sourceId,
|
|
1574
|
+
sourceLabel: event.sourceLabel
|
|
1575
|
+
}, this.maxTimelineBuckets);
|
|
1576
|
+
if (isMobTarget(this.identities, event.actorId, event.targetId)) {
|
|
1577
|
+
recordEnemyHit(encounter, actor, event, observedAtMs, this.mobIdentities);
|
|
1578
|
+
actor.targetIds.add(event.targetId);
|
|
1579
|
+
actor.targetDamage.set(event.targetId, (actor.targetDamage.get(event.targetId) ?? 0) + event.value);
|
|
1580
|
+
}
|
|
1581
|
+
}
|
|
1582
|
+
if (countedKill)
|
|
1583
|
+
actor.kills += 1;
|
|
1584
|
+
}
|
|
1585
|
+
advance(observedAtMs) {
|
|
1586
|
+
if (this.current && observedAtMs - this.current.lastDamageAtMs >= this.idleGapMs) {
|
|
1587
|
+
this.finish(this.current.lastDamageAtMs + this.idleGapMs);
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
reset(observedAtMs) {
|
|
1591
|
+
this.finish(observedAtMs);
|
|
1592
|
+
}
|
|
1593
|
+
finish(endedAtMs) {
|
|
1594
|
+
if (!this.current)
|
|
1595
|
+
return;
|
|
1596
|
+
const encounter = this.current;
|
|
1597
|
+
encounter.endedAtMs = Math.max(endedAtMs, encounter.lastDamageAtMs);
|
|
1598
|
+
this.current = undefined;
|
|
1599
|
+
this.onEncounterFinished?.(encounter);
|
|
1600
|
+
}
|
|
1601
|
+
trackRecentHit(event, observedAtMs) {
|
|
1602
|
+
if (event.kind !== "damage" && event.kind !== "death")
|
|
1603
|
+
return;
|
|
1604
|
+
if (!isPositiveHit(event))
|
|
1605
|
+
return;
|
|
1606
|
+
const attackerLabel = this.identities.get(event.actorId)?.displayName ?? this.mobIdentities.get(event.actorId) ?? `Actor ${event.actorId}`;
|
|
1607
|
+
const hits = this.recentHits.get(event.targetId) ?? [];
|
|
1608
|
+
hits.push({
|
|
1609
|
+
atMs: observedAtMs,
|
|
1610
|
+
hit: {
|
|
1611
|
+
beforeDeathMs: 0,
|
|
1612
|
+
attackerActorId: event.actorId,
|
|
1613
|
+
attackerLabel,
|
|
1614
|
+
attackerIsMonster: this.mobIdentities.has(event.actorId),
|
|
1615
|
+
sourceLabel: event.sourceLabel,
|
|
1616
|
+
damage: event.value,
|
|
1617
|
+
critical: event.hitResult === "critical"
|
|
1618
|
+
}
|
|
1619
|
+
});
|
|
1620
|
+
const cutoffMs = observedAtMs - DEATH_LOOKBACK_MS;
|
|
1621
|
+
while (hits.length > 0 && hits[0].atMs < cutoffMs)
|
|
1622
|
+
hits.shift();
|
|
1623
|
+
if (hits.length === 0)
|
|
1624
|
+
this.recentHits.delete(event.targetId);
|
|
1625
|
+
else
|
|
1626
|
+
this.recentHits.set(event.targetId, hits);
|
|
1627
|
+
this.sweepRecentHits(observedAtMs);
|
|
1628
|
+
}
|
|
1629
|
+
pruneRecentHits(observedAtMs) {
|
|
1630
|
+
this.sweepRecentHits(observedAtMs, true);
|
|
1631
|
+
}
|
|
1632
|
+
sweepRecentHits(observedAtMs, force = false) {
|
|
1633
|
+
if (!force && this.lastSweepAtMs !== undefined && observedAtMs - this.lastSweepAtMs < DEATH_LOOKBACK_MS)
|
|
1634
|
+
return;
|
|
1635
|
+
this.lastSweepAtMs = observedAtMs;
|
|
1636
|
+
const cutoffMs = observedAtMs - DEATH_LOOKBACK_MS;
|
|
1637
|
+
for (const [targetId, hits] of this.recentHits) {
|
|
1638
|
+
while (hits.length > 0 && hits[0].atMs < cutoffMs)
|
|
1639
|
+
hits.shift();
|
|
1640
|
+
if (hits.length === 0)
|
|
1641
|
+
this.recentHits.delete(targetId);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
rememberIdentity(actorId, identity) {
|
|
1645
|
+
this.identities.delete(actorId);
|
|
1646
|
+
this.identities.set(actorId, identity);
|
|
1647
|
+
while (this.identities.size > MAX_IDENTITIES) {
|
|
1648
|
+
const oldest = this.identities.keys().next();
|
|
1649
|
+
if (oldest.done)
|
|
1650
|
+
break;
|
|
1651
|
+
this.identities.delete(oldest.value);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
rememberMobIdentity(actorId, displayName) {
|
|
1655
|
+
this.mobIdentities.delete(actorId);
|
|
1656
|
+
this.mobIdentities.set(actorId, displayName);
|
|
1657
|
+
while (this.mobIdentities.size > MAX_MOB_IDENTITIES) {
|
|
1658
|
+
const oldest = this.mobIdentities.keys().next();
|
|
1659
|
+
if (oldest.done)
|
|
1660
|
+
break;
|
|
1661
|
+
this.mobIdentities.delete(oldest.value);
|
|
1662
|
+
}
|
|
1663
|
+
}
|
|
1664
|
+
recordEncounterHit(event, observedAtMs) {
|
|
1665
|
+
if (event.kind !== "damage" && event.kind !== "death")
|
|
1666
|
+
return;
|
|
1667
|
+
if (!this.current)
|
|
1668
|
+
return;
|
|
1669
|
+
if (event.kind !== "death")
|
|
1670
|
+
return;
|
|
1671
|
+
if (this.mobIdentities.has(event.targetId))
|
|
1672
|
+
return;
|
|
1673
|
+
if (event.team === 0 && !this.identities.has(event.targetId) && event.actorId !== event.targetId)
|
|
1674
|
+
return;
|
|
1675
|
+
const windowStartMs = observedAtMs - DEATH_LOOKBACK_MS;
|
|
1676
|
+
const victimHits = (this.recentHits.get(event.targetId) ?? []).filter((entry) => entry.atMs >= windowStartMs && entry.atMs <= observedAtMs).map((entry) => ({ ...entry.hit, beforeDeathMs: Math.max(0, observedAtMs - entry.atMs) })).sort((left, right) => right.beforeDeathMs - left.beforeDeathMs);
|
|
1677
|
+
this.current.deaths.push({
|
|
1678
|
+
victimName: this.identities.get(event.targetId)?.displayName ?? "Unidentified player",
|
|
1679
|
+
targetId: event.targetId,
|
|
1680
|
+
diedAtMs: observedAtMs,
|
|
1681
|
+
totalDamage: victimHits.reduce((total, hit) => total + hit.damage, 0),
|
|
1682
|
+
hits: victimHits
|
|
1683
|
+
});
|
|
1684
|
+
}
|
|
1685
|
+
actorFor(actorId) {
|
|
1686
|
+
const encounter = this.current;
|
|
1687
|
+
let actor = encounter.activeActors.get(actorId);
|
|
1688
|
+
if (!actor) {
|
|
1689
|
+
actor = createActor(actorId, encounter.startedAtMs, this.currentTauSeconds);
|
|
1690
|
+
encounter.actors.push(actor);
|
|
1691
|
+
encounter.activeActors.set(actorId, actor);
|
|
1692
|
+
}
|
|
1693
|
+
return actor;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
function recordHit(actor, hit, maxTimelineBuckets) {
|
|
1697
|
+
actor.damage += hit.value;
|
|
1698
|
+
if (actor.firstDamageAtMs === undefined) {
|
|
1699
|
+
actor.firstDamageAtMs = hit.atMs;
|
|
1700
|
+
actor.actorSeries.originMs = hit.atMs;
|
|
1701
|
+
}
|
|
1702
|
+
actor.lastDamageAtMs = hit.atMs;
|
|
1703
|
+
actor.hits += 1;
|
|
1704
|
+
if (hit.critical)
|
|
1705
|
+
actor.criticalHits += 1;
|
|
1706
|
+
addToSeries(actor.encounterSeries, hit.atMs, hit.value, maxTimelineBuckets);
|
|
1707
|
+
addToSeries(actor.actorSeries, hit.atMs, hit.value, maxTimelineBuckets);
|
|
1708
|
+
actor.currentRate.record(hit.value, hit.atMs);
|
|
1709
|
+
let skill = actor.skills.get(hit.sourceId);
|
|
1710
|
+
if (!skill) {
|
|
1711
|
+
skill = { sourceId: hit.sourceId, sourceLabel: hit.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
|
|
1712
|
+
actor.skills.set(hit.sourceId, skill);
|
|
1713
|
+
}
|
|
1714
|
+
skill.sourceLabel = hit.sourceLabel;
|
|
1715
|
+
skill.damage += hit.value;
|
|
1716
|
+
skill.hits += 1;
|
|
1717
|
+
if (hit.critical)
|
|
1718
|
+
skill.criticalHits += 1;
|
|
1719
|
+
}
|
|
1720
|
+
function positiveTau(value) {
|
|
1721
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
1722
|
+
throw new Error("currentTauSeconds must be a positive finite number");
|
|
1723
|
+
return value;
|
|
1724
|
+
}
|
|
1725
|
+
function recordEnemyHit(encounter, actor, event, observedAtMs, mobIdentities) {
|
|
1726
|
+
const bySkill = actor.enemySkills.get(event.targetId) ?? new Map;
|
|
1727
|
+
actor.enemySkills.set(event.targetId, bySkill);
|
|
1728
|
+
const stats = bySkill.get(event.sourceId) ?? { sourceLabel: event.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
|
|
1729
|
+
stats.sourceLabel = event.sourceLabel;
|
|
1730
|
+
stats.damage += event.value;
|
|
1731
|
+
stats.hits += 1;
|
|
1732
|
+
if (event.hitResult === "critical")
|
|
1733
|
+
stats.criticalHits += 1;
|
|
1734
|
+
bySkill.set(event.sourceId, stats);
|
|
1735
|
+
if (!encounter.enemyFirstSeenAtMs.has(event.targetId)) {
|
|
1736
|
+
encounter.enemyFirstSeenAtMs.set(event.targetId, observedAtMs);
|
|
1737
|
+
}
|
|
1738
|
+
const targetName = mobIdentities.get(event.targetId);
|
|
1739
|
+
if (targetName !== undefined)
|
|
1740
|
+
encounter.enemyNames.set(event.targetId, targetName);
|
|
1741
|
+
}
|
|
1742
|
+
function createActor(actorId, encounterStartedAtMs, currentRate = DEFAULT_CURRENT_TAU_SECONDS) {
|
|
1743
|
+
return {
|
|
1744
|
+
actorId,
|
|
1745
|
+
actorIds: [actorId],
|
|
1746
|
+
activeIdentity: false,
|
|
1747
|
+
damage: 0,
|
|
1748
|
+
targetIds: new Set,
|
|
1749
|
+
targetDamage: new Map,
|
|
1750
|
+
enemySkills: new Map,
|
|
1751
|
+
hits: 0,
|
|
1752
|
+
criticalHits: 0,
|
|
1753
|
+
kills: 0,
|
|
1754
|
+
skills: new Map,
|
|
1755
|
+
encounterSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
|
|
1756
|
+
actorSeries: createSeries(encounterStartedAtMs, ANALYSIS_BUCKET_MS),
|
|
1757
|
+
currentRate: typeof currentRate === "number" ? new EwmaRate({ tauSeconds: currentRate }) : currentRate
|
|
1758
|
+
};
|
|
1759
|
+
}
|
|
1760
|
+
function isCountedDamage(event) {
|
|
1761
|
+
if (event.kind !== "damage" && event.kind !== "death")
|
|
1762
|
+
return false;
|
|
1763
|
+
if (event.team !== 0 || event.actorId === event.targetId || !Number.isFinite(event.value) || event.value <= 0)
|
|
1764
|
+
return false;
|
|
1765
|
+
return event.kind === "damage" || !event.duplicatesDamageEvent;
|
|
1766
|
+
}
|
|
1767
|
+
function isCountedKill(event) {
|
|
1768
|
+
return event.kind === "death" && event.team === 0 && event.actorId !== event.targetId && Number.isFinite(event.value) && event.value > 0;
|
|
1769
|
+
}
|
|
1770
|
+
function isPositiveHit(event) {
|
|
1771
|
+
return Number.isFinite(event.value) && event.value > 0 && (event.kind === "damage" || !event.duplicatesDamageEvent);
|
|
1772
|
+
}
|
|
1773
|
+
function isMobTarget(identities, actorId, targetId) {
|
|
1774
|
+
return targetId >= 0 && targetId !== actorId && !identities.has(targetId);
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1777
|
+
// src/reducers/rows.ts
|
|
1778
|
+
var DEFAULT_ANONYMOUS_IDENTITY_GRACE_MS = 1e4;
|
|
1779
|
+
function renderEncounter(encounter, options = {}) {
|
|
1780
|
+
const minimumDurationMs = options.minimumDurationMs ?? DEFAULT_MINIMUM_DURATION_MS;
|
|
1781
|
+
const anonymousIdentityGraceMs = options.anonymousIdentityGraceMs ?? DEFAULT_ANONYMOUS_IDENTITY_GRACE_MS;
|
|
1782
|
+
const personalName = options.personalName ?? "";
|
|
1783
|
+
const personalActorId = options.personalActorId;
|
|
1784
|
+
const snapshotNowMs = Math.max(options.nowMs ?? encounter.lastDamageAtMs, encounter.lastDamageAtMs);
|
|
1785
|
+
const durationMs = Math.max(minimumDurationMs, encounter.lastDamageAtMs - encounter.startedAtMs);
|
|
1786
|
+
const mergedActors = mergeActors(encounter.actors);
|
|
1787
|
+
const totalDamage = mergedActors.reduce((sum, actor) => sum + actor.damage, 0);
|
|
1788
|
+
const rowForActor = (actor, rowId) => actorRow(actor, rowId, encounter.startedAtMs, durationMs, totalDamage, snapshotNowMs, minimumDurationMs, actor.displayName === undefined, "encounter");
|
|
1789
|
+
const displayed = visibleDisplayGroups(mergedActors, encounter, {
|
|
1790
|
+
snapshotNowMs,
|
|
1791
|
+
anonymousIdentityGraceMs,
|
|
1792
|
+
personalActorId
|
|
1793
|
+
});
|
|
1794
|
+
const actors = displayed.map(({ actor, rowId }) => rowForActor(actor, rowId)).sort(compareRows);
|
|
1795
|
+
const partyCurrentDps = mergedActors.reduce((sum, actor) => sum + rowForActor(actor, actorRowId(actor)).currentDps, 0);
|
|
1796
|
+
const namedActors = mergedActors.filter((actor) => actor.displayName !== undefined);
|
|
1797
|
+
const unidentifiedActorIds = displayed.filter(({ actor }) => actor.displayName === undefined).flatMap(({ actor }) => actor.actorIds);
|
|
1798
|
+
const normalizedPersonalName = normalizeName(personalName);
|
|
1799
|
+
const selectedPersonalActor = personalActorId === undefined ? undefined : mergedActors.find((actor) => actor.actorIds.includes(personalActorId));
|
|
1800
|
+
const personalActor = selectedPersonalActor ?? (normalizedPersonalName ? namedActors.find((actor) => normalizeName(actor.displayName ?? "") === normalizedPersonalName) : undefined);
|
|
1801
|
+
const personalMatch = selectedPersonalActor ? "matched" : personalActorId !== undefined ? "missing" : !normalizedPersonalName ? "unconfigured" : personalActor ? "matched" : "missing";
|
|
1802
|
+
const personalStartMs = personalActor?.firstDamageAtMs ?? encounter.startedAtMs;
|
|
1803
|
+
const personalDurationMs = Math.max(minimumDurationMs, (personalActor?.lastDamageAtMs ?? personalStartMs) - personalStartMs);
|
|
1804
|
+
const personal = personalActor === undefined ? undefined : actorRow(personalActor, actorRowId(personalActor), personalStartMs, personalDurationMs, totalDamage, snapshotNowMs, minimumDurationMs, personalActor.displayName === undefined, "actor");
|
|
1805
|
+
return {
|
|
1806
|
+
id: encounter.id,
|
|
1807
|
+
startedAtMs: encounter.startedAtMs,
|
|
1808
|
+
lastDamageAtMs: encounter.lastDamageAtMs,
|
|
1809
|
+
...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs },
|
|
1810
|
+
durationMs,
|
|
1811
|
+
totalDamage,
|
|
1812
|
+
partyDps: perSecond(totalDamage, durationMs),
|
|
1813
|
+
partyCurrentDps,
|
|
1814
|
+
actors,
|
|
1815
|
+
unidentifiedActorIds,
|
|
1816
|
+
personalName,
|
|
1817
|
+
personalMatch,
|
|
1818
|
+
...personalMatch === "matched" && personal ? { personal } : {}
|
|
1819
|
+
};
|
|
1820
|
+
}
|
|
1821
|
+
function actorRow(actor, rowId, startedAtMs, durationMs, partyDamage, nowMs, minimumDurationMs, isUnidentified, alignment) {
|
|
1822
|
+
const skills = [...actor.skills.values()].map((skill) => ({
|
|
1823
|
+
...skill,
|
|
1824
|
+
dps: perSecond(skill.damage, durationMs),
|
|
1825
|
+
contribution: actor.damage === 0 ? 0 : skill.damage / actor.damage,
|
|
1826
|
+
...skill.hits === 0 ? {} : { critRate: skill.criticalHits / skill.hits }
|
|
1827
|
+
})).sort(compareRows);
|
|
1828
|
+
const series = alignment === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
1829
|
+
return {
|
|
1830
|
+
rowId,
|
|
1831
|
+
actorIds: [...actor.actorIds],
|
|
1832
|
+
displayName: actor.displayName ?? (isUnidentified ? unidentifiedLabel(actor) : "Unknown"),
|
|
1833
|
+
...actor.archetype === undefined ? {} : { archetype: actor.archetype },
|
|
1834
|
+
durationMs,
|
|
1835
|
+
...actor.lastDamageAtMs === undefined ? {} : { lastDamageAtMs: actor.lastDamageAtMs },
|
|
1836
|
+
damage: actor.damage,
|
|
1837
|
+
dps: perSecond(actor.damage, durationMs),
|
|
1838
|
+
currentDps: actor.currentRate.rateAt(nowMs, { fromMs: startedAtMs, minimumMs: minimumDurationMs }),
|
|
1839
|
+
contribution: partyDamage === 0 ? 0 : actor.damage / partyDamage,
|
|
1840
|
+
hits: actor.hits,
|
|
1841
|
+
criticalHits: actor.criticalHits,
|
|
1842
|
+
...actor.hits === 0 ? {} : { critRate: actor.criticalHits / actor.hits },
|
|
1843
|
+
kills: actor.kills,
|
|
1844
|
+
mobsHit: actor.targetIds.size,
|
|
1845
|
+
skills,
|
|
1846
|
+
timeline: seriesPoints(series, durationMs),
|
|
1847
|
+
...isUnidentified ? { isUnidentified: true } : {}
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
function unidentifiedLabel(actor) {
|
|
1851
|
+
return `Unidentified (${Math.min(...actor.actorIds)})`;
|
|
1852
|
+
}
|
|
1853
|
+
function displayActorAggregates(encounter, options = {}) {
|
|
1854
|
+
return visibleDisplayGroups(mergeActors(encounter.actors), encounter, options);
|
|
1855
|
+
}
|
|
1856
|
+
function visibleDisplayGroups(mergedActors, encounter, options) {
|
|
1857
|
+
const snapshotNowMs = Math.max(options.snapshotNowMs ?? encounter.lastDamageAtMs, encounter.lastDamageAtMs);
|
|
1858
|
+
const anonymousIdentityGraceMs = options.anonymousIdentityGraceMs ?? DEFAULT_ANONYMOUS_IDENTITY_GRACE_MS;
|
|
1859
|
+
return mergedActors.filter((actor) => actor.displayName !== undefined || options.includeAllAnonymous === true || encounter.endedAtMs !== undefined || options.personalActorId !== undefined && actor.actorIds.includes(options.personalActorId) || actor.firstDamageAtMs === undefined || snapshotNowMs - actor.firstDamageAtMs >= anonymousIdentityGraceMs).map((actor) => ({ rowId: actorRowId(actor), actor }));
|
|
1860
|
+
}
|
|
1861
|
+
function actorRowId(actor) {
|
|
1862
|
+
const displayName = actor.displayName?.trim();
|
|
1863
|
+
if (displayName)
|
|
1864
|
+
return `name:${normalizeName(displayName)}`;
|
|
1865
|
+
if (actor.uid !== undefined)
|
|
1866
|
+
return `uid:${actor.uid}`;
|
|
1867
|
+
if (actor.ownerConnectionId !== undefined)
|
|
1868
|
+
return `owner:${actor.ownerConnectionId}`;
|
|
1869
|
+
return `actor:${actor.actorId}`;
|
|
1870
|
+
}
|
|
1871
|
+
function mergeActors(actors) {
|
|
1872
|
+
const merged = new Map;
|
|
1873
|
+
for (const actor of actors) {
|
|
1874
|
+
if (actor.damage <= 0)
|
|
1875
|
+
continue;
|
|
1876
|
+
const displayName = actor.displayName?.trim() || undefined;
|
|
1877
|
+
const key = actorRowId(actor);
|
|
1878
|
+
let target = merged.get(key);
|
|
1879
|
+
if (!target) {
|
|
1880
|
+
target = {
|
|
1881
|
+
...createActor(actor.actorId, actor.encounterSeries.originMs, actor.currentRate.emptyLike()),
|
|
1882
|
+
...displayName === undefined ? {} : { displayName },
|
|
1883
|
+
activeIdentity: actor.activeIdentity,
|
|
1884
|
+
...actor.archetype === undefined ? {} : { archetype: actor.archetype },
|
|
1885
|
+
...actor.ownerConnectionId === undefined ? {} : { ownerConnectionId: actor.ownerConnectionId },
|
|
1886
|
+
...actor.uid === undefined ? {} : { uid: actor.uid }
|
|
1887
|
+
};
|
|
1888
|
+
merged.set(key, target);
|
|
1889
|
+
}
|
|
1890
|
+
if (actor.activeIdentity && actor.archetype !== undefined) {
|
|
1891
|
+
target.archetype = actor.archetype;
|
|
1892
|
+
} else if (target.archetype === undefined) {
|
|
1893
|
+
target.archetype = actor.archetype;
|
|
1894
|
+
}
|
|
1895
|
+
target.activeIdentity ||= actor.activeIdentity;
|
|
1896
|
+
accumulate(target, actor);
|
|
1897
|
+
}
|
|
1898
|
+
return [...merged.values()];
|
|
1899
|
+
}
|
|
1900
|
+
function accumulate(target, actor) {
|
|
1901
|
+
target.damage += actor.damage;
|
|
1902
|
+
target.firstDamageAtMs = minDefined(target.firstDamageAtMs, actor.firstDamageAtMs);
|
|
1903
|
+
target.lastDamageAtMs = maxDefined(target.lastDamageAtMs, actor.lastDamageAtMs);
|
|
1904
|
+
target.hits += actor.hits;
|
|
1905
|
+
target.criticalHits += actor.criticalHits;
|
|
1906
|
+
target.kills += actor.kills;
|
|
1907
|
+
target.actorIds = [...new Set([...target.actorIds, ...actor.actorIds])];
|
|
1908
|
+
for (const targetId of actor.targetIds)
|
|
1909
|
+
target.targetIds.add(targetId);
|
|
1910
|
+
for (const [targetId, damage] of actor.targetDamage) {
|
|
1911
|
+
target.targetDamage.set(targetId, (target.targetDamage.get(targetId) ?? 0) + damage);
|
|
1912
|
+
}
|
|
1913
|
+
for (const [targetId, skills] of actor.enemySkills) {
|
|
1914
|
+
const targetSkills = target.enemySkills.get(targetId) ?? new Map;
|
|
1915
|
+
target.enemySkills.set(targetId, targetSkills);
|
|
1916
|
+
for (const [sourceId, stats] of skills) {
|
|
1917
|
+
const current = targetSkills.get(sourceId) ?? { sourceLabel: stats.sourceLabel, damage: 0, hits: 0, criticalHits: 0 };
|
|
1918
|
+
current.sourceLabel = stats.sourceLabel;
|
|
1919
|
+
current.damage += stats.damage;
|
|
1920
|
+
current.hits += stats.hits;
|
|
1921
|
+
current.criticalHits += stats.criticalHits;
|
|
1922
|
+
targetSkills.set(sourceId, current);
|
|
1923
|
+
}
|
|
1924
|
+
}
|
|
1925
|
+
target.currentRate.add(actor.currentRate);
|
|
1926
|
+
addSeries(target.encounterSeries, actor.encounterSeries);
|
|
1927
|
+
if (target.actorSeries.buckets.length === 0) {
|
|
1928
|
+
target.actorSeries = { ...actor.actorSeries, buckets: [...actor.actorSeries.buckets] };
|
|
1929
|
+
} else {
|
|
1930
|
+
if (actor.actorSeries.originMs < target.actorSeries.originMs) {
|
|
1931
|
+
rebase(target.actorSeries, actor.actorSeries.originMs);
|
|
1932
|
+
}
|
|
1933
|
+
addSeries(target.actorSeries, actor.actorSeries);
|
|
1934
|
+
}
|
|
1935
|
+
for (const skill of actor.skills.values()) {
|
|
1936
|
+
const current = target.skills.get(skill.sourceId);
|
|
1937
|
+
if (current) {
|
|
1938
|
+
current.sourceLabel = skill.sourceLabel;
|
|
1939
|
+
current.damage += skill.damage;
|
|
1940
|
+
current.hits += skill.hits;
|
|
1941
|
+
current.criticalHits += skill.criticalHits;
|
|
1942
|
+
} else {
|
|
1943
|
+
target.skills.set(skill.sourceId, { ...skill });
|
|
1944
|
+
}
|
|
1945
|
+
}
|
|
1946
|
+
}
|
|
1947
|
+
function rebase(series, originMs) {
|
|
1948
|
+
const shift = Math.round((series.originMs - originMs) / series.widthMs);
|
|
1949
|
+
if (shift <= 0) {
|
|
1950
|
+
series.originMs = originMs;
|
|
1951
|
+
return;
|
|
1952
|
+
}
|
|
1953
|
+
series.buckets = [...new Array(shift).fill(0), ...series.buckets];
|
|
1954
|
+
series.originMs = originMs;
|
|
1955
|
+
}
|
|
1956
|
+
function compareRows(left, right) {
|
|
1957
|
+
return right.damage - left.damage || (left.sourceLabel ?? left.displayName ?? "").localeCompare(right.sourceLabel ?? right.displayName ?? "");
|
|
1958
|
+
}
|
|
1959
|
+
function perSecond(damage, durationMs) {
|
|
1960
|
+
return damage / (durationMs / 1000);
|
|
1961
|
+
}
|
|
1962
|
+
function normalizeName(name) {
|
|
1963
|
+
return name.normalize("NFC").trim().toLowerCase();
|
|
1964
|
+
}
|
|
1965
|
+
function minDefined(left, right) {
|
|
1966
|
+
if (left === undefined)
|
|
1967
|
+
return right;
|
|
1968
|
+
if (right === undefined)
|
|
1969
|
+
return left;
|
|
1970
|
+
return Math.min(left, right);
|
|
1971
|
+
}
|
|
1972
|
+
function maxDefined(left, right) {
|
|
1973
|
+
if (left === undefined)
|
|
1974
|
+
return right;
|
|
1975
|
+
if (right === undefined)
|
|
1976
|
+
return left;
|
|
1977
|
+
return Math.max(left, right);
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// src/status-tracker.ts
|
|
1981
|
+
var REFRESHING_FEEDS = new Set(["ApplyEffectDisplays_O", "ApplySkillDisplay_O"]);
|
|
1982
|
+
var EXPIRY_REFRESH_TOLERANCE_MS = 2000;
|
|
1983
|
+
var KEEP_ALIVE_GRACE_MS = 1000;
|
|
1984
|
+
|
|
1985
|
+
class FishNetStatusTracker {
|
|
1986
|
+
directory;
|
|
1987
|
+
skillDirectory;
|
|
1988
|
+
active = new Map;
|
|
1989
|
+
identities = new Map;
|
|
1990
|
+
actorIdByUid = new Map;
|
|
1991
|
+
revisionValue = 0;
|
|
1992
|
+
nextExpiryRevision = -1;
|
|
1993
|
+
nextExpiry;
|
|
1994
|
+
constructor(options = {}) {
|
|
1995
|
+
this.directory = new FishNetStatusDirectory(options.statusCatalog ?? loadBundledStatusCatalog());
|
|
1996
|
+
this.skillDirectory = new FishNetSkillDirectory(options.skillCatalog ?? loadBundledSkillCatalog2());
|
|
1997
|
+
}
|
|
1998
|
+
get revision() {
|
|
1999
|
+
return this.revisionValue;
|
|
2000
|
+
}
|
|
2001
|
+
nextExpiryAtMs() {
|
|
2002
|
+
if (this.nextExpiryRevision === this.revisionValue)
|
|
2003
|
+
return this.nextExpiry;
|
|
2004
|
+
let earliest;
|
|
2005
|
+
for (const statuses of this.active.values()) {
|
|
2006
|
+
for (const tracked of statuses.values()) {
|
|
2007
|
+
if (tracked.expiresAtMs === undefined)
|
|
2008
|
+
continue;
|
|
2009
|
+
if (earliest === undefined || tracked.expiresAtMs < earliest)
|
|
2010
|
+
earliest = tracked.expiresAtMs;
|
|
2011
|
+
}
|
|
2012
|
+
}
|
|
2013
|
+
this.nextExpiry = earliest;
|
|
2014
|
+
this.nextExpiryRevision = this.revisionValue;
|
|
2015
|
+
return earliest;
|
|
2016
|
+
}
|
|
2017
|
+
touch() {
|
|
2018
|
+
this.revisionValue += 1;
|
|
2019
|
+
}
|
|
2020
|
+
consume(event, observedAtMs) {
|
|
2021
|
+
if (event.kind === "status")
|
|
2022
|
+
this.consumeStatus(event, observedAtMs);
|
|
2023
|
+
else if (event.kind === "summon")
|
|
2024
|
+
this.consumeSummon(event, observedAtMs);
|
|
2025
|
+
else if (event.kind === "activation")
|
|
2026
|
+
this.consumeActivation(event, observedAtMs);
|
|
2027
|
+
else if (event.kind === "death" && this.active.delete(event.targetId))
|
|
2028
|
+
this.touch();
|
|
2029
|
+
}
|
|
2030
|
+
consumeIdentity(event) {
|
|
2031
|
+
if (event.operation === "reset") {
|
|
2032
|
+
this.identities.clear();
|
|
2033
|
+
if (this.active.size > 0)
|
|
2034
|
+
this.touch();
|
|
2035
|
+
this.active.clear();
|
|
2036
|
+
return;
|
|
2037
|
+
}
|
|
2038
|
+
if (event.operation === "remove") {
|
|
2039
|
+
this.identities.delete(event.actorId);
|
|
2040
|
+
if (this.active.delete(event.actorId))
|
|
2041
|
+
this.touch();
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (event.uid) {
|
|
2045
|
+
const previousActorId = this.actorIdByUid.get(event.uid);
|
|
2046
|
+
if (previousActorId !== undefined && previousActorId !== event.actorId) {
|
|
2047
|
+
this.migrateActive(previousActorId, event.actorId);
|
|
2048
|
+
}
|
|
2049
|
+
this.actorIdByUid.set(event.uid, event.actorId);
|
|
2050
|
+
}
|
|
2051
|
+
this.identities.set(event.actorId, event.displayName);
|
|
2052
|
+
}
|
|
2053
|
+
migrateActive(fromActorId, toActorId) {
|
|
2054
|
+
const fromStatuses = this.active.get(fromActorId);
|
|
2055
|
+
if (!fromStatuses)
|
|
2056
|
+
return;
|
|
2057
|
+
this.active.delete(fromActorId);
|
|
2058
|
+
const toStatuses = this.active.get(toActorId) ?? new Map;
|
|
2059
|
+
for (const [statusId, tracked] of fromStatuses) {
|
|
2060
|
+
if (!toStatuses.has(statusId))
|
|
2061
|
+
toStatuses.set(statusId, tracked);
|
|
2062
|
+
}
|
|
2063
|
+
this.active.set(toActorId, toStatuses);
|
|
2064
|
+
this.touch();
|
|
2065
|
+
}
|
|
2066
|
+
consumeStatus(event, observedAtMs) {
|
|
2067
|
+
if (event.actorIdentity)
|
|
2068
|
+
this.identities.set(event.actorId, event.actorIdentity.displayName);
|
|
2069
|
+
const statuses = this.active.get(event.actorId) ?? new Map;
|
|
2070
|
+
if (event.action === "removed") {
|
|
2071
|
+
if (statuses.delete(event.statusId))
|
|
2072
|
+
this.touch();
|
|
2073
|
+
if (statuses.size === 0)
|
|
2074
|
+
this.active.delete(event.actorId);
|
|
2075
|
+
else
|
|
2076
|
+
this.active.set(event.actorId, statuses);
|
|
2077
|
+
return;
|
|
2078
|
+
}
|
|
2079
|
+
const previous = statuses.get(event.statusId);
|
|
2080
|
+
const level = event.level ?? previous?.level ?? 1;
|
|
2081
|
+
const expiresAtMs = this.resolveExpiry(event, level, observedAtMs, previous);
|
|
2082
|
+
const tracked = {
|
|
2083
|
+
level,
|
|
2084
|
+
appliedAtMs: REFRESHING_FEEDS.has(event.rpc) ? previous?.appliedAtMs ?? observedAtMs : observedAtMs,
|
|
2085
|
+
...expiresAtMs === undefined ? {} : { expiresAtMs },
|
|
2086
|
+
...event.stacks === undefined ? {} : { stacks: event.stacks }
|
|
2087
|
+
};
|
|
2088
|
+
statuses.set(event.statusId, tracked);
|
|
2089
|
+
this.active.set(event.actorId, statuses);
|
|
2090
|
+
if (!sameTracked(previous, tracked))
|
|
2091
|
+
this.touch();
|
|
2092
|
+
}
|
|
2093
|
+
isTimed(statusId, level) {
|
|
2094
|
+
return statusDurationSeconds(this.directory.resolve(statusId), level) !== undefined;
|
|
2095
|
+
}
|
|
2096
|
+
resolveExpiry(event, level, observedAtMs, previous) {
|
|
2097
|
+
if (event.remainingSeconds !== undefined) {
|
|
2098
|
+
const reported = observedAtMs + event.remainingSeconds * 1000;
|
|
2099
|
+
if (!this.isTimed(event.statusId, level))
|
|
2100
|
+
return reported + KEEP_ALIVE_GRACE_MS;
|
|
2101
|
+
const established = previous?.expiresAtMs;
|
|
2102
|
+
if (established !== undefined && reported > established && reported - established < EXPIRY_REFRESH_TOLERANCE_MS) {
|
|
2103
|
+
return established;
|
|
2104
|
+
}
|
|
2105
|
+
return reported;
|
|
2106
|
+
}
|
|
2107
|
+
if (event.rpc === "ApplyEffectDisplays_O")
|
|
2108
|
+
return;
|
|
2109
|
+
if (event.rpc === "ApplySkillDisplay_O")
|
|
2110
|
+
return previous?.expiresAtMs;
|
|
2111
|
+
const durationSeconds = statusDurationSeconds(this.directory.resolve(event.statusId), level);
|
|
2112
|
+
return durationSeconds === undefined ? undefined : observedAtMs + durationSeconds * 1000;
|
|
2113
|
+
}
|
|
2114
|
+
consumeSummon(event, observedAtMs) {
|
|
2115
|
+
if (event.actorIdentity)
|
|
2116
|
+
this.identities.set(event.actorId, event.actorIdentity.displayName);
|
|
2117
|
+
const statuses = this.active.get(event.actorId) ?? new Map;
|
|
2118
|
+
if (event.stacks <= 0) {
|
|
2119
|
+
if (statuses.delete(event.skillId))
|
|
2120
|
+
this.touch();
|
|
2121
|
+
if (statuses.size === 0)
|
|
2122
|
+
this.active.delete(event.actorId);
|
|
2123
|
+
else
|
|
2124
|
+
this.active.set(event.actorId, statuses);
|
|
2125
|
+
return;
|
|
2126
|
+
}
|
|
2127
|
+
const previous = statuses.get(event.skillId);
|
|
2128
|
+
const tracked = { level: 1, stacks: event.stacks, appliedAtMs: observedAtMs, summon: true };
|
|
2129
|
+
statuses.set(event.skillId, tracked);
|
|
2130
|
+
this.active.set(event.actorId, statuses);
|
|
2131
|
+
if (!sameTracked(previous, tracked))
|
|
2132
|
+
this.touch();
|
|
2133
|
+
}
|
|
2134
|
+
consumeActivation(event, observedAtMs) {
|
|
2135
|
+
if (event.phase === "interrupt" || event.phase === "cancel")
|
|
2136
|
+
return;
|
|
2137
|
+
if (!event.sourceId)
|
|
2138
|
+
return;
|
|
2139
|
+
const statuses = this.active.get(event.actorId);
|
|
2140
|
+
if (!statuses)
|
|
2141
|
+
return;
|
|
2142
|
+
for (const [statusId, tracked] of statuses) {
|
|
2143
|
+
if (tracked.summon)
|
|
2144
|
+
continue;
|
|
2145
|
+
const definition = this.directory.resolve(statusId);
|
|
2146
|
+
const isGranter = statusId === event.sourceId || definition?.effects.some((effect) => effect.id === event.sourceId);
|
|
2147
|
+
if (!isGranter)
|
|
2148
|
+
continue;
|
|
2149
|
+
const sourceEffect = definition?.effects.find((effect) => effect.id === event.sourceId);
|
|
2150
|
+
const durationDefinition = definition && sourceEffect ? { ...definition, effects: [sourceEffect] } : definition;
|
|
2151
|
+
const durationSeconds = statusDurationSeconds(durationDefinition, event.level ?? tracked.level);
|
|
2152
|
+
const expiresAtMs = durationSeconds === undefined ? undefined : observedAtMs + durationSeconds * 1000;
|
|
2153
|
+
if (tracked.appliedAtMs !== observedAtMs || tracked.expiresAtMs !== expiresAtMs)
|
|
2154
|
+
this.touch();
|
|
2155
|
+
tracked.appliedAtMs = observedAtMs;
|
|
2156
|
+
tracked.expiresAtMs = expiresAtMs;
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
2159
|
+
advance(nowMs) {
|
|
2160
|
+
let expired = false;
|
|
2161
|
+
for (const [actorId, statuses] of this.active) {
|
|
2162
|
+
for (const [statusId, tracked] of statuses) {
|
|
2163
|
+
if (tracked.expiresAtMs !== undefined && tracked.expiresAtMs <= nowMs) {
|
|
2164
|
+
statuses.delete(statusId);
|
|
2165
|
+
expired = true;
|
|
2166
|
+
}
|
|
2167
|
+
}
|
|
2168
|
+
if (statuses.size === 0)
|
|
2169
|
+
this.active.delete(actorId);
|
|
2170
|
+
}
|
|
2171
|
+
if (expired)
|
|
2172
|
+
this.touch();
|
|
2173
|
+
}
|
|
2174
|
+
getActiveStatuses(actorId, nowMs) {
|
|
2175
|
+
const statuses = this.active.get(actorId);
|
|
2176
|
+
if (!statuses)
|
|
2177
|
+
return [];
|
|
2178
|
+
const result = [];
|
|
2179
|
+
for (const [statusId, tracked] of statuses) {
|
|
2180
|
+
if (tracked.expiresAtMs !== undefined && tracked.expiresAtMs <= nowMs)
|
|
2181
|
+
continue;
|
|
2182
|
+
const definition = this.directory.resolve(statusId);
|
|
2183
|
+
const skillDefinition = this.skillDirectory.resolve(statusId);
|
|
2184
|
+
const spriteId = definition?.spriteId ?? skillDefinition?.spriteId;
|
|
2185
|
+
const timed = statusDurationSeconds(definition, tracked.level) !== undefined;
|
|
2186
|
+
result.push({
|
|
2187
|
+
statusId,
|
|
2188
|
+
displayName: definition?.displayName ?? skillDefinition?.displayName ?? statusId,
|
|
2189
|
+
...spriteId === undefined ? {} : { spriteId },
|
|
2190
|
+
isDebuff: definition?.isDebuff ?? false,
|
|
2191
|
+
level: tracked.level,
|
|
2192
|
+
appliedAtMs: tracked.appliedAtMs,
|
|
2193
|
+
...tracked.stacks === undefined ? {} : { stacks: tracked.stacks },
|
|
2194
|
+
...tracked.expiresAtMs === undefined || !timed ? {} : { expiresAtMs: tracked.expiresAtMs, remainingMs: Math.max(0, tracked.expiresAtMs - nowMs) }
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
return result.sort((left, right) => left.appliedAtMs - right.appliedAtMs);
|
|
2198
|
+
}
|
|
2199
|
+
getActiveStatusesForActors(actorIds, nowMs) {
|
|
2200
|
+
const byStatusId = new Map;
|
|
2201
|
+
for (const actorId of actorIds) {
|
|
2202
|
+
for (const status of this.getActiveStatuses(actorId, nowMs)) {
|
|
2203
|
+
const current = byStatusId.get(status.statusId);
|
|
2204
|
+
if (!current || status.appliedAtMs > current.appliedAtMs)
|
|
2205
|
+
byStatusId.set(status.statusId, status);
|
|
2206
|
+
}
|
|
2207
|
+
}
|
|
2208
|
+
return [...byStatusId.values()].sort((left, right) => left.appliedAtMs - right.appliedAtMs);
|
|
2209
|
+
}
|
|
2210
|
+
getActiveStatusesForName(personalName, nowMs) {
|
|
2211
|
+
const normalized = normalizeName(personalName);
|
|
2212
|
+
if (!normalized)
|
|
2213
|
+
return [];
|
|
2214
|
+
const actorIds = [...this.identities].filter(([, displayName]) => normalizeName(displayName) === normalized).map(([actorId]) => actorId);
|
|
2215
|
+
return this.getActiveStatusesForActors(actorIds, nowMs);
|
|
2216
|
+
}
|
|
2217
|
+
reset() {
|
|
2218
|
+
if (this.active.size > 0)
|
|
2219
|
+
this.touch();
|
|
2220
|
+
this.active.clear();
|
|
2221
|
+
this.identities.clear();
|
|
2222
|
+
this.actorIdByUid.clear();
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function sameTracked(previous, next) {
|
|
2226
|
+
return previous !== undefined && previous.level === next.level && previous.appliedAtMs === next.appliedAtMs && previous.expiresAtMs === next.expiresAtMs && previous.stacks === next.stacks && previous.summon === next.summon;
|
|
2227
|
+
}
|
|
2228
|
+
// src/replay.ts
|
|
2229
|
+
import { isRecord, isLogStreamHeader, parseLogRecord } from "@kar-mi/spirit-vale-tools-logging";
|
|
2230
|
+
async function loadDpsReplay(path, personalName = "") {
|
|
2231
|
+
const finished = [];
|
|
2232
|
+
const reducer = new DamageReducer({ onEncounterFinished: (encounter) => finished.push(encounter) });
|
|
2233
|
+
let invalidLines = 0;
|
|
2234
|
+
let recordedAtOriginMs;
|
|
2235
|
+
let lastTime = 0;
|
|
2236
|
+
for await (const line of readLines(Bun.file(path).stream())) {
|
|
2237
|
+
if (!line.trim())
|
|
2238
|
+
continue;
|
|
2239
|
+
let candidate;
|
|
2240
|
+
try {
|
|
2241
|
+
candidate = JSON.parse(line);
|
|
2242
|
+
} catch {
|
|
2243
|
+
invalidLines += 1;
|
|
2244
|
+
continue;
|
|
2245
|
+
}
|
|
2246
|
+
if (isLogStreamHeader(candidate))
|
|
2247
|
+
continue;
|
|
2248
|
+
const record = parseLogRecord(candidate);
|
|
2249
|
+
if (!record) {
|
|
2250
|
+
invalidLines += 1;
|
|
2251
|
+
continue;
|
|
2252
|
+
}
|
|
2253
|
+
const event = parseDpsLogRecord(record.type, record.data);
|
|
2254
|
+
if (event === null)
|
|
2255
|
+
continue;
|
|
2256
|
+
if (!event) {
|
|
2257
|
+
invalidLines += 1;
|
|
2258
|
+
continue;
|
|
2259
|
+
}
|
|
2260
|
+
const recordedAtMs = Date.parse(record.recordedAt);
|
|
2261
|
+
recordedAtOriginMs ??= recordedAtMs;
|
|
2262
|
+
lastTime = Math.max(lastTime, recordedAtMs - recordedAtOriginMs);
|
|
2263
|
+
if (event.kind === "actorIdentity")
|
|
2264
|
+
reducer.consumeIdentity(event, lastTime);
|
|
2265
|
+
else
|
|
2266
|
+
reducer.consumeCombat(event, lastTime);
|
|
2267
|
+
}
|
|
2268
|
+
reducer.reset(lastTime);
|
|
2269
|
+
const snapshots = finished.map((encounter) => renderEncounter(encounter, { personalName }));
|
|
2270
|
+
return { snapshots, invalidLines };
|
|
2271
|
+
}
|
|
2272
|
+
async function* readLines(stream) {
|
|
2273
|
+
const reader = stream.getReader();
|
|
2274
|
+
let decoder;
|
|
2275
|
+
let pending = "";
|
|
2276
|
+
try {
|
|
2277
|
+
while (true) {
|
|
2278
|
+
const { done, value } = await reader.read();
|
|
2279
|
+
if (done)
|
|
2280
|
+
break;
|
|
2281
|
+
if (!decoder)
|
|
2282
|
+
decoder = decoderFor(value);
|
|
2283
|
+
pending += decoder.decode(value, { stream: true });
|
|
2284
|
+
const lines = pending.split(/\r?\n/);
|
|
2285
|
+
pending = lines.pop() ?? "";
|
|
2286
|
+
yield* lines;
|
|
2287
|
+
}
|
|
2288
|
+
pending += decoder?.decode() ?? "";
|
|
2289
|
+
if (pending)
|
|
2290
|
+
yield pending;
|
|
2291
|
+
} finally {
|
|
2292
|
+
reader.releaseLock();
|
|
2293
|
+
}
|
|
2294
|
+
}
|
|
2295
|
+
function decoderFor(firstChunk) {
|
|
2296
|
+
if (firstChunk[0] === 255 && firstChunk[1] === 254)
|
|
2297
|
+
return new TextDecoder("utf-16le");
|
|
2298
|
+
if (firstChunk[0] === 254 && firstChunk[1] === 255)
|
|
2299
|
+
return new TextDecoder("utf-16be");
|
|
2300
|
+
return new TextDecoder("utf-8");
|
|
2301
|
+
}
|
|
2302
|
+
function parseDpsLogEvent(value) {
|
|
2303
|
+
if (!isRecord(value) || !isFiniteNumber(value["tick"]) || typeof value["kind"] !== "string")
|
|
2304
|
+
return;
|
|
2305
|
+
if (value["kind"] === "actorIdentity") {
|
|
2306
|
+
if (value["operation"] === "reset")
|
|
2307
|
+
return value;
|
|
2308
|
+
if (!isFiniteNumber(value["actorId"]))
|
|
2309
|
+
return;
|
|
2310
|
+
if (value["operation"] === "remove")
|
|
2311
|
+
return value;
|
|
2312
|
+
if (value["operation"] === "upsert" && typeof value["displayName"] === "string" && value["displayName"].length > 0) {
|
|
2313
|
+
return value;
|
|
2314
|
+
}
|
|
2315
|
+
return;
|
|
2316
|
+
}
|
|
2317
|
+
if (value["kind"] === "monsterIdentity") {
|
|
2318
|
+
if (value["operation"] === "reset")
|
|
2319
|
+
return value;
|
|
2320
|
+
if (!isFiniteNumber(value["actorId"]))
|
|
2321
|
+
return;
|
|
2322
|
+
if (value["operation"] === "remove")
|
|
2323
|
+
return value;
|
|
2324
|
+
if (value["operation"] === "upsert" && typeof value["mobId"] === "string" && value["mobId"].length > 0 && typeof value["displayName"] === "string" && value["displayName"].length > 0) {
|
|
2325
|
+
return value;
|
|
2326
|
+
}
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
if (value["kind"] === "activation")
|
|
2330
|
+
return value;
|
|
2331
|
+
if (value["kind"] === "status") {
|
|
2332
|
+
if (!isFiniteNumber(value["actorId"]) || typeof value["statusId"] !== "string" || value["level"] !== undefined && !isFiniteNumber(value["level"]) || value["remainingSeconds"] !== undefined && !isFiniteNumber(value["remainingSeconds"]) || value["action"] !== "applied" && value["action"] !== "removed")
|
|
2333
|
+
return;
|
|
2334
|
+
return value;
|
|
2335
|
+
}
|
|
2336
|
+
if (value["kind"] === "summon") {
|
|
2337
|
+
if (!isFiniteNumber(value["actorId"]) || typeof value["skillId"] !== "string" || value["skillId"].length === 0 || !isFiniteNumber(value["stacks"]) || !Number.isInteger(value["stacks"]) || value["stacks"] < 0)
|
|
2338
|
+
return;
|
|
2339
|
+
return value;
|
|
2340
|
+
}
|
|
2341
|
+
if (value["kind"] === "heal") {
|
|
2342
|
+
if (!isFiniteNumber(value["targetId"]) || !isFiniteNumber(value["value"]))
|
|
2343
|
+
return;
|
|
2344
|
+
if (value["recoveryStyle"] !== undefined && value["recoveryStyle"] !== "standard" && value["recoveryStyle"] !== "passive-regeneration" && value["recoveryStyle"] !== "drain" && value["recoveryStyle"] !== "unknown")
|
|
2345
|
+
return;
|
|
2346
|
+
return value;
|
|
2347
|
+
}
|
|
2348
|
+
if (value["kind"] !== "damage" && value["kind"] !== "death" || !isFiniteNumber(value["actorId"]) || !isFiniteNumber(value["value"]) || !isFiniteNumber(value["team"]) || typeof value["sourceId"] !== "string" || typeof value["sourceLabel"] !== "string")
|
|
2349
|
+
return;
|
|
2350
|
+
if (value["kind"] === "death" && typeof value["duplicatesDamageEvent"] !== "boolean")
|
|
2351
|
+
return;
|
|
2352
|
+
return value;
|
|
2353
|
+
}
|
|
2354
|
+
function parseDpsLogRecord(type, data) {
|
|
2355
|
+
if (type !== "combat.actorIdentity" && type !== "combat.event")
|
|
2356
|
+
return null;
|
|
2357
|
+
const event = parseDpsLogEvent(data);
|
|
2358
|
+
if (!event)
|
|
2359
|
+
return;
|
|
2360
|
+
if (type === "combat.actorIdentity" && event.kind !== "actorIdentity")
|
|
2361
|
+
return;
|
|
2362
|
+
if (type === "combat.event" && event.kind === "actorIdentity")
|
|
2363
|
+
return;
|
|
2364
|
+
return event;
|
|
2365
|
+
}
|
|
2366
|
+
function isFiniteNumber(value) {
|
|
2367
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
2368
|
+
}
|
|
2369
|
+
// src/replay-capture.ts
|
|
2370
|
+
import { FishNetSessionDecoder, loadBundledFishNetRpcMap as loadBundledFishNetRpcMap2, decodeLiteNetLibDatagram } from "@kar-mi/spirit-vale-tools-capture";
|
|
2371
|
+
import { isLogStreamHeader as isLogStreamHeader2, parseLogRecord as parseLogRecord2 } from "@kar-mi/spirit-vale-tools-logging";
|
|
2372
|
+
function decodeCombatCaptureJsonLines(text, options) {
|
|
2373
|
+
const replay = new CombatCaptureReplay(options);
|
|
2374
|
+
for (const line of text.split(/\r?\n/))
|
|
2375
|
+
replay.consumeLine(line);
|
|
2376
|
+
return replay.result();
|
|
2377
|
+
}
|
|
2378
|
+
async function replayCombatCapture(path, options) {
|
|
2379
|
+
return replayCombatCaptures([path], options);
|
|
2380
|
+
}
|
|
2381
|
+
async function replayCombatCaptures(paths, options) {
|
|
2382
|
+
const replay = new CombatCaptureReplay(options);
|
|
2383
|
+
for (const path of paths) {
|
|
2384
|
+
for await (const line of readLines2(Bun.file(path).stream()))
|
|
2385
|
+
replay.consumeLine(line);
|
|
2386
|
+
}
|
|
2387
|
+
return replay.result();
|
|
2388
|
+
}
|
|
2389
|
+
|
|
2390
|
+
class CombatCaptureReplay {
|
|
2391
|
+
options;
|
|
2392
|
+
session = new FishNetSessionDecoder(loadBundledFishNetRpcMap2());
|
|
2393
|
+
datagrams = 0;
|
|
2394
|
+
combatEvents = 0;
|
|
2395
|
+
identityEvents = 0;
|
|
2396
|
+
invalidLines = 0;
|
|
2397
|
+
decodeWarnings = 0;
|
|
2398
|
+
constructor(options) {
|
|
2399
|
+
this.options = options;
|
|
2400
|
+
}
|
|
2401
|
+
consumeLine(line) {
|
|
2402
|
+
if (!line.trim())
|
|
2403
|
+
return;
|
|
2404
|
+
let record;
|
|
2405
|
+
try {
|
|
2406
|
+
const value = JSON.parse(line);
|
|
2407
|
+
if (isLogStreamHeader2(value))
|
|
2408
|
+
return;
|
|
2409
|
+
record = parseLogRecord2(value);
|
|
2410
|
+
} catch {
|
|
2411
|
+
record = undefined;
|
|
2412
|
+
}
|
|
2413
|
+
if (!record) {
|
|
2414
|
+
this.invalidLines += 1;
|
|
2415
|
+
return;
|
|
2416
|
+
}
|
|
2417
|
+
if (record.type !== "transport.packet")
|
|
2418
|
+
return;
|
|
2419
|
+
const packet = parseUdpRecord(record.data);
|
|
2420
|
+
if (!packet) {
|
|
2421
|
+
this.invalidLines += 1;
|
|
2422
|
+
return;
|
|
2423
|
+
}
|
|
2424
|
+
this.datagrams += 1;
|
|
2425
|
+
const observedAtMs = Date.parse(record.recordedAt);
|
|
2426
|
+
const source = `${packet.sourceIP}:${packet.sourcePort}`;
|
|
2427
|
+
const destination = `${packet.destinationIP}:${packet.destinationPort}`;
|
|
2428
|
+
const endpoints = [source, destination].sort();
|
|
2429
|
+
const connectionBase = `${endpoints[0]}<->${endpoints[1]}`;
|
|
2430
|
+
const direction = source === endpoints[0] ? "a-to-b" : "b-to-a";
|
|
2431
|
+
try {
|
|
2432
|
+
for (const decoded of decodeLiteNetLibDatagram(Buffer.from(packet.payloadHex, "hex"))) {
|
|
2433
|
+
const packet2 = decoded.packet;
|
|
2434
|
+
const connectionId = `${connectionBase}#${packet2.connectionNumber}`;
|
|
2435
|
+
if (packet2.property === "connectRequest" || packet2.property === "connectAccept" || packet2.property === "disconnect") {
|
|
2436
|
+
this.session.reset(connectionId);
|
|
2437
|
+
continue;
|
|
2438
|
+
}
|
|
2439
|
+
if (packet2.property !== "unreliable" && packet2.property !== "channeled" || packet2.payload.length < 6)
|
|
2440
|
+
continue;
|
|
2441
|
+
const fishNetPackets = this.session.decode(packet2.payload, {
|
|
2442
|
+
reliable: packet2.property === "channeled",
|
|
2443
|
+
connectionId,
|
|
2444
|
+
direction,
|
|
2445
|
+
channel: packet2.property === "channeled" ? packet2.channel : 1,
|
|
2446
|
+
sequence: packet2.property === "channeled" ? packet2.sequence : undefined
|
|
2447
|
+
});
|
|
2448
|
+
for (const fishNetPacket of fishNetPackets) {
|
|
2449
|
+
for (const event of this.options.directory.consume(fishNetPacket)) {
|
|
2450
|
+
this.identityEvents += 1;
|
|
2451
|
+
if (Number.isFinite(observedAtMs))
|
|
2452
|
+
this.options.onEvent?.(event, observedAtMs);
|
|
2453
|
+
}
|
|
2454
|
+
for (const event of this.options.tracker.consume(fishNetPacket)) {
|
|
2455
|
+
this.combatEvents += 1;
|
|
2456
|
+
if (Number.isFinite(observedAtMs))
|
|
2457
|
+
this.options.onEvent?.(event, observedAtMs);
|
|
2458
|
+
}
|
|
2459
|
+
}
|
|
2460
|
+
}
|
|
2461
|
+
} catch {
|
|
2462
|
+
this.decodeWarnings += 1;
|
|
2463
|
+
}
|
|
2464
|
+
}
|
|
2465
|
+
result() {
|
|
2466
|
+
return {
|
|
2467
|
+
datagrams: this.datagrams,
|
|
2468
|
+
combatEvents: this.combatEvents,
|
|
2469
|
+
identityEvents: this.identityEvents,
|
|
2470
|
+
invalidLines: this.invalidLines,
|
|
2471
|
+
decodeWarnings: this.decodeWarnings
|
|
2472
|
+
};
|
|
2473
|
+
}
|
|
2474
|
+
}
|
|
2475
|
+
function parseUdpRecord(data) {
|
|
2476
|
+
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"]))
|
|
2477
|
+
return;
|
|
2478
|
+
return data;
|
|
2479
|
+
}
|
|
2480
|
+
function isPort(value) {
|
|
2481
|
+
return Number.isInteger(value) && value >= 0 && value <= 65535;
|
|
2482
|
+
}
|
|
2483
|
+
async function* readLines2(stream) {
|
|
2484
|
+
const reader = stream.getReader();
|
|
2485
|
+
const decoder = new TextDecoder;
|
|
2486
|
+
let pending = "";
|
|
2487
|
+
try {
|
|
2488
|
+
while (true) {
|
|
2489
|
+
const { done, value } = await reader.read();
|
|
2490
|
+
if (done)
|
|
2491
|
+
break;
|
|
2492
|
+
pending += decoder.decode(value, { stream: true });
|
|
2493
|
+
const lines = pending.split(/\r?\n/);
|
|
2494
|
+
pending = lines.pop() ?? "";
|
|
2495
|
+
yield* lines;
|
|
2496
|
+
}
|
|
2497
|
+
pending += decoder.decode();
|
|
2498
|
+
if (pending)
|
|
2499
|
+
yield pending;
|
|
2500
|
+
} finally {
|
|
2501
|
+
reader.releaseLock();
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2504
|
+
// src/replay-summary.ts
|
|
2505
|
+
import { compact, count, warnings } from "@kar-mi/spirit-vale-tools-capture";
|
|
2506
|
+
async function readCombatReplaySummary(path) {
|
|
2507
|
+
const replay = await loadDpsReplay(path);
|
|
2508
|
+
const encounters = replay.snapshots;
|
|
2509
|
+
return {
|
|
2510
|
+
encounters: encounters.length,
|
|
2511
|
+
totalDamage: encounters.reduce((total, encounter) => total + encounter.totalDamage, 0),
|
|
2512
|
+
durationMs: encounters.reduce((total, encounter) => total + encounter.durationMs, 0),
|
|
2513
|
+
invalidLines: replay.invalidLines
|
|
2514
|
+
};
|
|
2515
|
+
}
|
|
2516
|
+
async function formatCombatReplaySummary(path) {
|
|
2517
|
+
return formatSummary(await readCombatReplaySummary(path));
|
|
2518
|
+
}
|
|
2519
|
+
async function inspectCombatReplaySummary(path) {
|
|
2520
|
+
const summary = await readCombatReplaySummary(path);
|
|
2521
|
+
return { recordCount: summary.encounters, summary: formatSummary(summary) };
|
|
2522
|
+
}
|
|
2523
|
+
function formatSummary(summary) {
|
|
2524
|
+
return `${count(summary.encounters, "encounter")} \xB7 ${compact(summary.totalDamage)} damage \xB7 ${duration(summary.durationMs)}${warnings(summary.invalidLines)}`;
|
|
2525
|
+
}
|
|
2526
|
+
function duration(milliseconds) {
|
|
2527
|
+
const seconds = Math.round(milliseconds / 1000);
|
|
2528
|
+
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
|
|
2529
|
+
}
|
|
2530
|
+
// src/history/domain.ts
|
|
2531
|
+
var COMBAT_DOMAIN_VERSION = 7;
|
|
2532
|
+
var COMBAT_DOMAIN_NAME = "combat";
|
|
2533
|
+
var SCHEMA = `
|
|
2534
|
+
create table if not exists combat_encounters (
|
|
2535
|
+
session_id text not null,
|
|
2536
|
+
encounter_id text not null,
|
|
2537
|
+
started_at_ms integer not null,
|
|
2538
|
+
last_damage_at_ms integer not null,
|
|
2539
|
+
ended_at_ms integer,
|
|
2540
|
+
total_damage integer not null default 0,
|
|
2541
|
+
primary key (session_id, encounter_id)
|
|
2542
|
+
);
|
|
2543
|
+
create index if not exists combat_encounters_by_start on combat_encounters (session_id, started_at_ms, encounter_id);
|
|
2544
|
+
|
|
2545
|
+
-- Reducer state scoped to the whole stream, not to one encounter: the actor identity map, the
|
|
2546
|
+
-- monster names seen so far, and the death-log lookback buffer. All bounded. This is kept per
|
|
2547
|
+
-- session because an incremental pass creates a fresh reducer each time and there is frequently no
|
|
2548
|
+
-- encounter open at the boundary \u2014 between fights, and always before the first one.
|
|
2549
|
+
create table if not exists combat_stream_state (
|
|
2550
|
+
session_id text not null primary key,
|
|
2551
|
+
identities_json text not null default '[]',
|
|
2552
|
+
mob_identities_json text not null default '[]',
|
|
2553
|
+
recent_hits_json text not null default '[]',
|
|
2554
|
+
-- Lines that were not a valid log record, accumulated across passes so the figure covers the whole
|
|
2555
|
+
-- stream rather than whichever slice the last incremental pass happened to read.
|
|
2556
|
+
invalid_lines integer not null default 0
|
|
2557
|
+
);
|
|
2558
|
+
create index if not exists combat_encounters_open on combat_encounters (session_id) where ended_at_ms is null;
|
|
2559
|
+
|
|
2560
|
+
-- One row set per meter: "dps" is outgoing party damage, "tanked" is incoming damage grouped by the
|
|
2561
|
+
-- party member taking it, and "healing" is restored health grouped by the healer. All three share
|
|
2562
|
+
-- these tables because they accumulate identically and render through the same code.
|
|
2563
|
+
create table if not exists combat_actors (
|
|
2564
|
+
session_id text not null,
|
|
2565
|
+
encounter_id text not null,
|
|
2566
|
+
meter text not null default 'dps',
|
|
2567
|
+
-- Position within the encounter's actor list. An actor id can appear more than once when an
|
|
2568
|
+
-- identity is removed and the id is reused, and those aggregates must stay distinct.
|
|
2569
|
+
actor_index integer not null,
|
|
2570
|
+
actor_id integer not null,
|
|
2571
|
+
-- Whether this aggregate is the one further events for actor_id accumulate into.
|
|
2572
|
+
active_slot integer not null default 0,
|
|
2573
|
+
display_name text,
|
|
2574
|
+
archetype integer,
|
|
2575
|
+
owner_connection_id integer,
|
|
2576
|
+
uid text,
|
|
2577
|
+
active_identity integer not null default 0,
|
|
2578
|
+
damage integer not null default 0,
|
|
2579
|
+
first_damage_at_ms integer,
|
|
2580
|
+
last_damage_at_ms integer,
|
|
2581
|
+
hits integer not null default 0,
|
|
2582
|
+
critical_hits integer not null default 0,
|
|
2583
|
+
kills integer not null default 0,
|
|
2584
|
+
-- The current-DPS rate estimator: its accumulated rate and the time that rate was last advanced
|
|
2585
|
+
-- to. Needed so a pass that resumes mid-encounter reports the same currentDps as one that never
|
|
2586
|
+
-- stopped. Two numbers, where a trailing window of recent hits used to be serialised here.
|
|
2587
|
+
ewma_rate real not null default 0,
|
|
2588
|
+
ewma_at_ms integer not null default 0,
|
|
2589
|
+
-- The decay constant the rate was accumulated with. Stored rather than assumed because the rate
|
|
2590
|
+
-- only means anything paired with it, so a reader rebuilds the estimator that produced the value.
|
|
2591
|
+
ewma_tau_seconds real not null default 2.5,
|
|
2592
|
+
primary key (session_id, encounter_id, meter, actor_index)
|
|
2593
|
+
);
|
|
2594
|
+
|
|
2595
|
+
create table if not exists combat_skills (
|
|
2596
|
+
session_id text not null,
|
|
2597
|
+
encounter_id text not null,
|
|
2598
|
+
meter text not null default 'dps',
|
|
2599
|
+
actor_index integer not null,
|
|
2600
|
+
source_id text not null,
|
|
2601
|
+
source_label text not null,
|
|
2602
|
+
damage integer not null default 0,
|
|
2603
|
+
hits integer not null default 0,
|
|
2604
|
+
critical_hits integer not null default 0,
|
|
2605
|
+
primary key (session_id, encounter_id, meter, actor_index, source_id)
|
|
2606
|
+
);
|
|
2607
|
+
|
|
2608
|
+
create table if not exists combat_targets (
|
|
2609
|
+
session_id text not null,
|
|
2610
|
+
encounter_id text not null,
|
|
2611
|
+
meter text not null default 'dps',
|
|
2612
|
+
actor_index integer not null,
|
|
2613
|
+
target_id integer not null,
|
|
2614
|
+
damage integer not null default 0,
|
|
2615
|
+
primary key (session_id, encounter_id, meter, actor_index, target_id)
|
|
2616
|
+
);
|
|
2617
|
+
|
|
2618
|
+
create table if not exists combat_timeline_buckets (
|
|
2619
|
+
session_id text not null,
|
|
2620
|
+
encounter_id text not null,
|
|
2621
|
+
meter text not null default 'dps',
|
|
2622
|
+
actor_index integer not null,
|
|
2623
|
+
-- "encounter" buckets start at the encounter; "actor" buckets start at that actor's first damage,
|
|
2624
|
+
-- which is the alignment the personal row uses.
|
|
2625
|
+
origin text not null,
|
|
2626
|
+
origin_ms integer not null,
|
|
2627
|
+
width_ms integer not null,
|
|
2628
|
+
bucket_index integer not null,
|
|
2629
|
+
damage integer not null default 0,
|
|
2630
|
+
primary key (session_id, encounter_id, meter, actor_index, origin, bucket_index)
|
|
2631
|
+
);
|
|
2632
|
+
|
|
2633
|
+
-- Per rendered DPS actor lifetime, per enemy, per skill. The actor index prevents a reused network
|
|
2634
|
+
-- actor id from assigning the same hit to two rows that are actually different players.
|
|
2635
|
+
create table if not exists combat_enemy_skills (
|
|
2636
|
+
session_id text not null,
|
|
2637
|
+
encounter_id text not null,
|
|
2638
|
+
actor_index integer not null,
|
|
2639
|
+
target_id integer not null,
|
|
2640
|
+
source_id text not null,
|
|
2641
|
+
source_label text not null,
|
|
2642
|
+
damage integer not null default 0,
|
|
2643
|
+
hits integer not null default 0,
|
|
2644
|
+
critical_hits integer not null default 0,
|
|
2645
|
+
primary key (session_id, encounter_id, actor_index, target_id, source_id)
|
|
2646
|
+
);
|
|
2647
|
+
|
|
2648
|
+
create table if not exists combat_enemies (
|
|
2649
|
+
session_id text not null,
|
|
2650
|
+
encounter_id text not null,
|
|
2651
|
+
target_id integer not null,
|
|
2652
|
+
display_name text,
|
|
2653
|
+
first_seen_at_ms integer not null,
|
|
2654
|
+
primary key (session_id, encounter_id, target_id)
|
|
2655
|
+
);
|
|
2656
|
+
|
|
2657
|
+
create table if not exists combat_deaths (
|
|
2658
|
+
session_id text not null,
|
|
2659
|
+
encounter_id text not null,
|
|
2660
|
+
death_index integer not null,
|
|
2661
|
+
victim_name text not null,
|
|
2662
|
+
target_id integer not null,
|
|
2663
|
+
died_at_ms integer not null,
|
|
2664
|
+
total_damage integer not null default 0,
|
|
2665
|
+
primary key (session_id, encounter_id, death_index)
|
|
2666
|
+
);
|
|
2667
|
+
create index if not exists combat_deaths_by_time on combat_deaths (session_id, died_at_ms, death_index);
|
|
2668
|
+
|
|
2669
|
+
create table if not exists combat_death_hits (
|
|
2670
|
+
session_id text not null,
|
|
2671
|
+
encounter_id text not null,
|
|
2672
|
+
death_index integer not null,
|
|
2673
|
+
hit_index integer not null,
|
|
2674
|
+
before_death_ms integer not null,
|
|
2675
|
+
attacker_actor_id integer not null,
|
|
2676
|
+
attacker_label text not null,
|
|
2677
|
+
attacker_is_monster integer not null default 0,
|
|
2678
|
+
source_label text not null,
|
|
2679
|
+
damage integer not null default 0,
|
|
2680
|
+
critical integer not null default 0,
|
|
2681
|
+
primary key (session_id, encounter_id, death_index, hit_index)
|
|
2682
|
+
);
|
|
2683
|
+
`;
|
|
2684
|
+
function createCombatDomain() {
|
|
2685
|
+
return {
|
|
2686
|
+
name: COMBAT_DOMAIN_NAME,
|
|
2687
|
+
version: COMBAT_DOMAIN_VERSION,
|
|
2688
|
+
createSchema(database) {
|
|
2689
|
+
database.exec(SCHEMA);
|
|
2690
|
+
},
|
|
2691
|
+
dropSchema(database) {
|
|
2692
|
+
for (const table of [
|
|
2693
|
+
"combat_stream_state",
|
|
2694
|
+
"combat_death_hits",
|
|
2695
|
+
"combat_deaths",
|
|
2696
|
+
"combat_enemies",
|
|
2697
|
+
"combat_enemy_skills",
|
|
2698
|
+
"combat_timeline_buckets",
|
|
2699
|
+
"combat_targets",
|
|
2700
|
+
"combat_skills",
|
|
2701
|
+
"combat_actors",
|
|
2702
|
+
"combat_encounters"
|
|
2703
|
+
]) {
|
|
2704
|
+
database.exec(`drop table if exists ${table}`);
|
|
2705
|
+
}
|
|
2706
|
+
}
|
|
2707
|
+
};
|
|
2708
|
+
}
|
|
2709
|
+
// src/reducers/meter.ts
|
|
2710
|
+
class MeterReducer {
|
|
2711
|
+
kind;
|
|
2712
|
+
current;
|
|
2713
|
+
currentTauSeconds;
|
|
2714
|
+
maxTimelineBuckets;
|
|
2715
|
+
constructor(options) {
|
|
2716
|
+
this.kind = options.kind;
|
|
2717
|
+
this.currentTauSeconds = positiveTau(options.currentTauSeconds ?? DEFAULT_CURRENT_TAU_SECONDS);
|
|
2718
|
+
this.maxTimelineBuckets = options.maxTimelineBuckets ?? Number.POSITIVE_INFINITY;
|
|
2719
|
+
}
|
|
2720
|
+
begin(id, startedAtMs) {
|
|
2721
|
+
this.current = {
|
|
2722
|
+
id,
|
|
2723
|
+
startedAtMs,
|
|
2724
|
+
lastDamageAtMs: startedAtMs,
|
|
2725
|
+
actors: [],
|
|
2726
|
+
activeActors: new Map,
|
|
2727
|
+
enemyFirstSeenAtMs: new Map,
|
|
2728
|
+
enemyNames: new Map,
|
|
2729
|
+
deaths: []
|
|
2730
|
+
};
|
|
2731
|
+
return this.current;
|
|
2732
|
+
}
|
|
2733
|
+
resume(encounter) {
|
|
2734
|
+
this.current = encounter;
|
|
2735
|
+
}
|
|
2736
|
+
finish(endedAtMs) {
|
|
2737
|
+
const encounter = this.current;
|
|
2738
|
+
if (!encounter)
|
|
2739
|
+
return;
|
|
2740
|
+
encounter.endedAtMs = Math.max(endedAtMs, encounter.lastDamageAtMs);
|
|
2741
|
+
this.current = undefined;
|
|
2742
|
+
return encounter;
|
|
2743
|
+
}
|
|
2744
|
+
reset() {
|
|
2745
|
+
this.current = undefined;
|
|
2746
|
+
}
|
|
2747
|
+
consumeIdentity(event) {
|
|
2748
|
+
if (!this.current || event.operation !== "upsert")
|
|
2749
|
+
return;
|
|
2750
|
+
const actor = this.current.activeActors.get(event.actorId);
|
|
2751
|
+
if (!actor)
|
|
2752
|
+
return;
|
|
2753
|
+
actor.displayName = event.displayName;
|
|
2754
|
+
if (event.archetype !== undefined)
|
|
2755
|
+
actor.archetype = event.archetype;
|
|
2756
|
+
actor.activeIdentity = true;
|
|
2757
|
+
}
|
|
2758
|
+
consumeCombat(event, observedAtMs, identities) {
|
|
2759
|
+
if (!this.current)
|
|
2760
|
+
return;
|
|
2761
|
+
const hit = this.kind === "tanked" ? tankedHit(event, identities) : healingHit(event, identities);
|
|
2762
|
+
if (!hit)
|
|
2763
|
+
return;
|
|
2764
|
+
this.apply(hit, observedAtMs);
|
|
2765
|
+
}
|
|
2766
|
+
apply(hit, observedAtMs) {
|
|
2767
|
+
const encounter = this.current;
|
|
2768
|
+
encounter.lastDamageAtMs = Math.max(encounter.lastDamageAtMs, observedAtMs);
|
|
2769
|
+
let actor = encounter.activeActors.get(hit.actorId);
|
|
2770
|
+
if (!actor) {
|
|
2771
|
+
actor = createActor(hit.actorId, encounter.startedAtMs, this.currentTauSeconds);
|
|
2772
|
+
encounter.actors.push(actor);
|
|
2773
|
+
encounter.activeActors.set(hit.actorId, actor);
|
|
2774
|
+
}
|
|
2775
|
+
if (hit.identity) {
|
|
2776
|
+
actor.displayName = hit.identity.displayName;
|
|
2777
|
+
if (hit.identity.archetype !== undefined)
|
|
2778
|
+
actor.archetype = hit.identity.archetype;
|
|
2779
|
+
actor.activeIdentity = true;
|
|
2780
|
+
}
|
|
2781
|
+
recordHit(actor, { ...hit, atMs: observedAtMs }, this.maxTimelineBuckets);
|
|
2782
|
+
if (hit.counterpartId !== undefined)
|
|
2783
|
+
actor.targetIds.add(hit.counterpartId);
|
|
2784
|
+
}
|
|
2785
|
+
}
|
|
2786
|
+
function tankedHit(event, identities) {
|
|
2787
|
+
if (event.kind !== "damage" && event.kind !== "death")
|
|
2788
|
+
return;
|
|
2789
|
+
const identity = identities.get(event.targetId);
|
|
2790
|
+
const reflected = event.team === 0 && event.actorId === event.targetId;
|
|
2791
|
+
const incoming = event.team !== 0 && event.actorId !== event.targetId;
|
|
2792
|
+
if (!identity && !reflected && !incoming)
|
|
2793
|
+
return;
|
|
2794
|
+
if (!isPositiveHit(event))
|
|
2795
|
+
return;
|
|
2796
|
+
return {
|
|
2797
|
+
actorId: event.targetId,
|
|
2798
|
+
...identity === undefined ? {} : { identity },
|
|
2799
|
+
counterpartId: event.actorId,
|
|
2800
|
+
value: event.value,
|
|
2801
|
+
sourceId: event.sourceId,
|
|
2802
|
+
sourceLabel: event.sourceLabel,
|
|
2803
|
+
critical: event.hitResult === "critical"
|
|
2804
|
+
};
|
|
2805
|
+
}
|
|
2806
|
+
function healingHit(event, identities) {
|
|
2807
|
+
if (event.kind !== "heal")
|
|
2808
|
+
return;
|
|
2809
|
+
if (!Number.isFinite(event.value) || event.value <= 0)
|
|
2810
|
+
return;
|
|
2811
|
+
const healerId = event.actorId ?? event.targetId;
|
|
2812
|
+
const identity = identities.get(healerId);
|
|
2813
|
+
if (!identity)
|
|
2814
|
+
return;
|
|
2815
|
+
return {
|
|
2816
|
+
actorId: healerId,
|
|
2817
|
+
identity,
|
|
2818
|
+
counterpartId: event.targetId,
|
|
2819
|
+
value: event.value,
|
|
2820
|
+
sourceId: event.sourceId ?? "heal",
|
|
2821
|
+
sourceLabel: event.sourceLabel ?? "Healing",
|
|
2822
|
+
critical: false
|
|
2823
|
+
};
|
|
2824
|
+
}
|
|
2825
|
+
|
|
2826
|
+
// src/history/importer.ts
|
|
2827
|
+
var METER_KINDS = [
|
|
2828
|
+
{ meter: "tanked", kind: "tanked" },
|
|
2829
|
+
{ meter: "healing", kind: "healing" }
|
|
2830
|
+
];
|
|
2831
|
+
async function indexCombatStream(model, options) {
|
|
2832
|
+
const { sessionId, sourcePath } = options;
|
|
2833
|
+
const finished = [];
|
|
2834
|
+
let currentSequence = 0;
|
|
2835
|
+
let lastObservedAtMs = 0;
|
|
2836
|
+
const meters = METER_KINDS.map(({ meter, kind }) => ({
|
|
2837
|
+
meter,
|
|
2838
|
+
reducer: new MeterReducer({
|
|
2839
|
+
kind,
|
|
2840
|
+
...options.currentTauSeconds === undefined ? {} : { currentTauSeconds: options.currentTauSeconds }
|
|
2841
|
+
})
|
|
2842
|
+
}));
|
|
2843
|
+
const meterAggregates = new Map;
|
|
2844
|
+
const captureMeters = (encounterId) => {
|
|
2845
|
+
const byMeter = meterAggregates.get(encounterId) ?? new Map;
|
|
2846
|
+
for (const entry of meters) {
|
|
2847
|
+
if (entry.reducer.current)
|
|
2848
|
+
byMeter.set(entry.meter, entry.reducer.current);
|
|
2849
|
+
}
|
|
2850
|
+
if (byMeter.size > 0)
|
|
2851
|
+
meterAggregates.set(encounterId, byMeter);
|
|
2852
|
+
};
|
|
2853
|
+
const reducer = new DamageReducer({
|
|
2854
|
+
...options.idleGapMs === undefined ? {} : { idleGapMs: options.idleGapMs },
|
|
2855
|
+
...options.currentTauSeconds === undefined ? {} : { currentTauSeconds: options.currentTauSeconds },
|
|
2856
|
+
createEncounterId: () => `enc-${currentSequence}`,
|
|
2857
|
+
onEncounterFinished: (encounter) => {
|
|
2858
|
+
captureMeters(encounter.id);
|
|
2859
|
+
for (const entry of meters)
|
|
2860
|
+
entry.reducer.finish(encounter.endedAtMs ?? encounter.lastDamageAtMs);
|
|
2861
|
+
finished.push(encounter);
|
|
2862
|
+
}
|
|
2863
|
+
});
|
|
2864
|
+
loadStreamState(model, sessionId, reducer);
|
|
2865
|
+
const open = loadOpenEncounter(model, sessionId);
|
|
2866
|
+
if (open) {
|
|
2867
|
+
reducer.resume(open);
|
|
2868
|
+
for (const entry of meters) {
|
|
2869
|
+
entry.reducer.resume(loadMeterAggregate(model, sessionId, open, entry.meter));
|
|
2870
|
+
}
|
|
2871
|
+
}
|
|
2872
|
+
const request = {
|
|
2873
|
+
sessionId,
|
|
2874
|
+
stream: "combat",
|
|
2875
|
+
domain: COMBAT_DOMAIN_NAME,
|
|
2876
|
+
sourcePath,
|
|
2877
|
+
...options.batchBytes === undefined ? {} : { batchBytes: options.batchBytes },
|
|
2878
|
+
apply(records) {
|
|
2879
|
+
const previouslyOpen = reducer.current?.id;
|
|
2880
|
+
for (const record of records) {
|
|
2881
|
+
consume(reducer, meters, record, (sequence, observedAtMs) => {
|
|
2882
|
+
currentSequence = sequence;
|
|
2883
|
+
lastObservedAtMs = observedAtMs;
|
|
2884
|
+
});
|
|
2885
|
+
}
|
|
2886
|
+
for (const encounter of finished.splice(0)) {
|
|
2887
|
+
writeEncounter(model, sessionId, encounter, reducer, meterAggregates.get(encounter.id));
|
|
2888
|
+
meterAggregates.delete(encounter.id);
|
|
2889
|
+
}
|
|
2890
|
+
if (reducer.current) {
|
|
2891
|
+
captureMeters(reducer.current.id);
|
|
2892
|
+
writeEncounter(model, sessionId, reducer.current, reducer, meterAggregates.get(reducer.current.id));
|
|
2893
|
+
} else if (previouslyOpen)
|
|
2894
|
+
clearEncounter(model, sessionId, previouslyOpen, { keepFinished: true });
|
|
2895
|
+
reducer.pruneRecentHits(lastObservedAtMs);
|
|
2896
|
+
writeStreamState(model, sessionId, reducer);
|
|
2897
|
+
},
|
|
2898
|
+
clear(scope, database) {
|
|
2899
|
+
for (const table of TABLES) {
|
|
2900
|
+
database.query(`delete from ${table} where session_id = $sessionId`).run({ sessionId: scope.sessionId });
|
|
2901
|
+
}
|
|
2902
|
+
reducer.current = undefined;
|
|
2903
|
+
reducer.identities.clear();
|
|
2904
|
+
reducer.mobIdentities.clear();
|
|
2905
|
+
reducer.recentHits.clear();
|
|
2906
|
+
for (const entry of meters)
|
|
2907
|
+
entry.reducer.reset();
|
|
2908
|
+
meterAggregates.clear();
|
|
2909
|
+
finished.length = 0;
|
|
2910
|
+
}
|
|
2911
|
+
};
|
|
2912
|
+
const result = await model.indexStream(request);
|
|
2913
|
+
recordInvalidLines(model, sessionId, result.invalidLines, result.rebuilt);
|
|
2914
|
+
if (options.finalize && reducer.current) {
|
|
2915
|
+
model.transaction(() => {
|
|
2916
|
+
reducer.reset(lastObservedAtMs);
|
|
2917
|
+
for (const encounter of finished.splice(0)) {
|
|
2918
|
+
writeEncounter(model, sessionId, encounter, reducer, meterAggregates.get(encounter.id));
|
|
2919
|
+
meterAggregates.delete(encounter.id);
|
|
2920
|
+
}
|
|
2921
|
+
});
|
|
2922
|
+
}
|
|
2923
|
+
return result;
|
|
2924
|
+
}
|
|
2925
|
+
var TABLES = [
|
|
2926
|
+
"combat_stream_state",
|
|
2927
|
+
"combat_death_hits",
|
|
2928
|
+
"combat_deaths",
|
|
2929
|
+
"combat_enemies",
|
|
2930
|
+
"combat_enemy_skills",
|
|
2931
|
+
"combat_timeline_buckets",
|
|
2932
|
+
"combat_targets",
|
|
2933
|
+
"combat_skills",
|
|
2934
|
+
"combat_actors",
|
|
2935
|
+
"combat_encounters"
|
|
2936
|
+
];
|
|
2937
|
+
function statements(model) {
|
|
2938
|
+
return { query: (sql) => model.statement(sql) };
|
|
2939
|
+
}
|
|
2940
|
+
function consume(reducer, meters, record, note) {
|
|
2941
|
+
const event = parseDpsLogRecord(record.type, record.data);
|
|
2942
|
+
if (!event)
|
|
2943
|
+
return;
|
|
2944
|
+
const observedAtMs = Date.parse(record.recordedAt);
|
|
2945
|
+
if (!Number.isFinite(observedAtMs))
|
|
2946
|
+
return;
|
|
2947
|
+
note(record.sequence, observedAtMs);
|
|
2948
|
+
if (event.kind === "actorIdentity") {
|
|
2949
|
+
reducer.consumeIdentity(event, observedAtMs);
|
|
2950
|
+
for (const entry of meters)
|
|
2951
|
+
entry.reducer.consumeIdentity(event);
|
|
2952
|
+
return;
|
|
2953
|
+
}
|
|
2954
|
+
reducer.consumeCombat(event, observedAtMs);
|
|
2955
|
+
const encounter = reducer.current;
|
|
2956
|
+
if (!encounter)
|
|
2957
|
+
return;
|
|
2958
|
+
for (const entry of meters) {
|
|
2959
|
+
if (entry.reducer.current?.id !== encounter.id)
|
|
2960
|
+
entry.reducer.begin(encounter.id, encounter.startedAtMs);
|
|
2961
|
+
entry.reducer.consumeCombat(event, observedAtMs, reducer.identities);
|
|
2962
|
+
}
|
|
2963
|
+
}
|
|
2964
|
+
function writeEncounter(model, sessionId, encounter, reducer, meterAggregates) {
|
|
2965
|
+
const database = statements(model);
|
|
2966
|
+
writeEnemiesAndDeaths(model, sessionId, encounter, reducer.mobIdentities);
|
|
2967
|
+
const totalDamage = encounter.actors.reduce((sum, actor) => sum + actor.damage, 0);
|
|
2968
|
+
database.query(`insert or replace into combat_encounters
|
|
2969
|
+
(session_id, encounter_id, started_at_ms, last_damage_at_ms, ended_at_ms, total_damage)
|
|
2970
|
+
values ($sessionId, $encounterId, $startedAtMs, $lastDamageAtMs, $endedAtMs, $totalDamage)`).run({
|
|
2971
|
+
sessionId,
|
|
2972
|
+
encounterId: encounter.id,
|
|
2973
|
+
startedAtMs: encounter.startedAtMs,
|
|
2974
|
+
lastDamageAtMs: encounter.lastDamageAtMs,
|
|
2975
|
+
endedAtMs: encounter.endedAtMs ?? null,
|
|
2976
|
+
totalDamage
|
|
2977
|
+
});
|
|
2978
|
+
writeActors(model, sessionId, encounter, "dps");
|
|
2979
|
+
for (const [meter, aggregate] of meterAggregates ?? [])
|
|
2980
|
+
writeActors(model, sessionId, aggregate, meter, encounter.id);
|
|
2981
|
+
}
|
|
2982
|
+
function writeActors(model, sessionId, aggregate, meter, encounterId = aggregate.id) {
|
|
2983
|
+
const database = statements(model);
|
|
2984
|
+
const insertActor = database.query(`insert or replace into combat_actors
|
|
2985
|
+
(session_id, encounter_id, meter, actor_index, actor_id, active_slot, display_name, archetype, owner_connection_id, uid,
|
|
2986
|
+
active_identity, damage, first_damage_at_ms, last_damage_at_ms, hits, critical_hits, kills, ewma_rate, ewma_at_ms, ewma_tau_seconds)
|
|
2987
|
+
values ($sessionId, $encounterId, $meter, $actorIndex, $actorId, $activeSlot, $displayName, $archetype, $ownerConnectionId, $uid,
|
|
2988
|
+
$activeIdentity, $damage, $firstDamageAtMs, $lastDamageAtMs, $hits, $criticalHits, $kills, $ewmaRate, $ewmaAtMs, $ewmaTauSeconds)`);
|
|
2989
|
+
const insertSkill = database.query(`insert or replace into combat_skills
|
|
2990
|
+
(session_id, encounter_id, meter, actor_index, source_id, source_label, damage, hits, critical_hits)
|
|
2991
|
+
values ($sessionId, $encounterId, $meter, $actorIndex, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
|
|
2992
|
+
const insertTarget = database.query(`insert or replace into combat_targets
|
|
2993
|
+
(session_id, encounter_id, meter, actor_index, target_id, damage)
|
|
2994
|
+
values ($sessionId, $encounterId, $meter, $actorIndex, $targetId, $damage)`);
|
|
2995
|
+
const insertBucket = database.query(`insert or replace into combat_timeline_buckets
|
|
2996
|
+
(session_id, encounter_id, meter, actor_index, origin, origin_ms, width_ms, bucket_index, damage)
|
|
2997
|
+
values ($sessionId, $encounterId, $meter, $actorIndex, $origin, $originMs, $widthMs, $bucketIndex, $damage)`);
|
|
2998
|
+
const insertEnemySkill = database.query(`insert or replace into combat_enemy_skills
|
|
2999
|
+
(session_id, encounter_id, actor_index, target_id, source_id, source_label, damage, hits, critical_hits)
|
|
3000
|
+
values ($sessionId, $encounterId, $actorIndex, $targetId, $sourceId, $sourceLabel, $damage, $hits, $criticalHits)`);
|
|
3001
|
+
const deleteBucketTail = database.query(`delete from combat_timeline_buckets
|
|
3002
|
+
where session_id = $sessionId and encounter_id = $encounterId and meter = $meter
|
|
3003
|
+
and actor_index = $actorIndex and origin = $origin and bucket_index >= $bucketCount`);
|
|
3004
|
+
for (const [actorIndex, actor] of aggregate.actors.entries()) {
|
|
3005
|
+
insertActor.run({
|
|
3006
|
+
sessionId,
|
|
3007
|
+
encounterId,
|
|
3008
|
+
meter,
|
|
3009
|
+
actorIndex,
|
|
3010
|
+
actorId: actor.actorId,
|
|
3011
|
+
activeSlot: aggregate.activeActors.get(actor.actorId) === actor ? 1 : 0,
|
|
3012
|
+
displayName: actor.displayName ?? null,
|
|
3013
|
+
archetype: actor.archetype ?? null,
|
|
3014
|
+
ownerConnectionId: actor.ownerConnectionId ?? null,
|
|
3015
|
+
uid: actor.uid ?? null,
|
|
3016
|
+
activeIdentity: actor.activeIdentity ? 1 : 0,
|
|
3017
|
+
damage: actor.damage,
|
|
3018
|
+
firstDamageAtMs: actor.firstDamageAtMs ?? null,
|
|
3019
|
+
lastDamageAtMs: actor.lastDamageAtMs ?? null,
|
|
3020
|
+
hits: actor.hits,
|
|
3021
|
+
criticalHits: actor.criticalHits,
|
|
3022
|
+
kills: actor.kills,
|
|
3023
|
+
ewmaRate: actor.currentRate.state().rate,
|
|
3024
|
+
ewmaAtMs: actor.currentRate.state().updatedAtMs,
|
|
3025
|
+
ewmaTauSeconds: actor.currentRate.tauSeconds
|
|
3026
|
+
});
|
|
3027
|
+
for (const skill of actor.skills.values()) {
|
|
3028
|
+
insertSkill.run({
|
|
3029
|
+
sessionId,
|
|
3030
|
+
encounterId,
|
|
3031
|
+
meter,
|
|
3032
|
+
actorIndex,
|
|
3033
|
+
sourceId: skill.sourceId,
|
|
3034
|
+
sourceLabel: skill.sourceLabel,
|
|
3035
|
+
damage: skill.damage,
|
|
3036
|
+
hits: skill.hits,
|
|
3037
|
+
criticalHits: skill.criticalHits
|
|
3038
|
+
});
|
|
3039
|
+
}
|
|
3040
|
+
for (const targetId of actor.targetIds) {
|
|
3041
|
+
insertTarget.run({
|
|
3042
|
+
sessionId,
|
|
3043
|
+
encounterId,
|
|
3044
|
+
meter,
|
|
3045
|
+
actorIndex,
|
|
3046
|
+
targetId,
|
|
3047
|
+
damage: actor.targetDamage.get(targetId) ?? 0
|
|
3048
|
+
});
|
|
3049
|
+
}
|
|
3050
|
+
if (meter === "dps") {
|
|
3051
|
+
for (const [targetId, bySkill] of actor.enemySkills) {
|
|
3052
|
+
for (const [sourceId, stats] of bySkill) {
|
|
3053
|
+
insertEnemySkill.run({
|
|
3054
|
+
sessionId,
|
|
3055
|
+
encounterId,
|
|
3056
|
+
actorIndex,
|
|
3057
|
+
targetId,
|
|
3058
|
+
sourceId,
|
|
3059
|
+
sourceLabel: stats.sourceLabel,
|
|
3060
|
+
damage: stats.damage,
|
|
3061
|
+
hits: stats.hits,
|
|
3062
|
+
criticalHits: stats.criticalHits
|
|
3063
|
+
});
|
|
3064
|
+
}
|
|
3065
|
+
}
|
|
3066
|
+
}
|
|
3067
|
+
for (const [origin, series] of [["encounter", actor.encounterSeries], ["actor", actor.actorSeries]]) {
|
|
3068
|
+
const dirtyFrom = takeDirtyFrom(series);
|
|
3069
|
+
if (dirtyFrom === Number.POSITIVE_INFINITY)
|
|
3070
|
+
continue;
|
|
3071
|
+
if (dirtyFrom === 0) {
|
|
3072
|
+
deleteBucketTail.run({ sessionId, encounterId, meter, actorIndex, origin, bucketCount: series.buckets.length });
|
|
3073
|
+
}
|
|
3074
|
+
for (let bucketIndex = dirtyFrom;bucketIndex < series.buckets.length; bucketIndex += 1) {
|
|
3075
|
+
insertBucket.run({
|
|
3076
|
+
sessionId,
|
|
3077
|
+
encounterId,
|
|
3078
|
+
meter,
|
|
3079
|
+
actorIndex,
|
|
3080
|
+
origin,
|
|
3081
|
+
originMs: series.originMs,
|
|
3082
|
+
widthMs: series.widthMs,
|
|
3083
|
+
bucketIndex,
|
|
3084
|
+
damage: series.buckets[bucketIndex] ?? 0
|
|
3085
|
+
});
|
|
3086
|
+
}
|
|
3087
|
+
}
|
|
3088
|
+
}
|
|
3089
|
+
}
|
|
3090
|
+
function writeEnemiesAndDeaths(model, sessionId, encounter, mobIdentities) {
|
|
3091
|
+
const database = statements(model);
|
|
3092
|
+
const scope = { sessionId, encounterId: encounter.id };
|
|
3093
|
+
const insertEnemy = database.query(`insert or replace into combat_enemies
|
|
3094
|
+
(session_id, encounter_id, target_id, display_name, first_seen_at_ms)
|
|
3095
|
+
values ($sessionId, $encounterId, $targetId, $displayName, $firstSeenAtMs)`);
|
|
3096
|
+
const insertDeath = database.query(`insert or replace into combat_deaths
|
|
3097
|
+
(session_id, encounter_id, death_index, victim_name, target_id, died_at_ms, total_damage)
|
|
3098
|
+
values ($sessionId, $encounterId, $deathIndex, $victimName, $targetId, $diedAtMs, $totalDamage)`);
|
|
3099
|
+
const insertDeathHit = database.query(`insert or replace into combat_death_hits
|
|
3100
|
+
(session_id, encounter_id, death_index, hit_index, before_death_ms, attacker_actor_id,
|
|
3101
|
+
attacker_label, attacker_is_monster, source_label, damage, critical)
|
|
3102
|
+
values ($sessionId, $encounterId, $deathIndex, $hitIndex, $beforeDeathMs, $attackerActorId,
|
|
3103
|
+
$attackerLabel, $attackerIsMonster, $sourceLabel, $damage, $critical)`);
|
|
3104
|
+
for (const [targetId, firstSeenAtMs] of encounter.enemyFirstSeenAtMs) {
|
|
3105
|
+
insertEnemy.run({
|
|
3106
|
+
...scope,
|
|
3107
|
+
targetId,
|
|
3108
|
+
displayName: encounter.enemyNames.get(targetId) ?? mobIdentities.get(targetId) ?? null,
|
|
3109
|
+
firstSeenAtMs
|
|
3110
|
+
});
|
|
3111
|
+
}
|
|
3112
|
+
for (const [deathIndex, death] of encounter.deaths.entries()) {
|
|
3113
|
+
insertDeath.run({
|
|
3114
|
+
...scope,
|
|
3115
|
+
deathIndex,
|
|
3116
|
+
victimName: death.victimName,
|
|
3117
|
+
targetId: death.targetId,
|
|
3118
|
+
diedAtMs: death.diedAtMs,
|
|
3119
|
+
totalDamage: death.totalDamage
|
|
3120
|
+
});
|
|
3121
|
+
for (const [hitIndex, hit] of death.hits.entries()) {
|
|
3122
|
+
insertDeathHit.run({
|
|
3123
|
+
...scope,
|
|
3124
|
+
deathIndex,
|
|
3125
|
+
hitIndex,
|
|
3126
|
+
beforeDeathMs: hit.beforeDeathMs,
|
|
3127
|
+
attackerActorId: hit.attackerActorId,
|
|
3128
|
+
attackerLabel: hit.attackerLabel,
|
|
3129
|
+
attackerIsMonster: hit.attackerIsMonster ? 1 : 0,
|
|
3130
|
+
sourceLabel: hit.sourceLabel,
|
|
3131
|
+
damage: hit.damage,
|
|
3132
|
+
critical: hit.critical ? 1 : 0
|
|
3133
|
+
});
|
|
3134
|
+
}
|
|
3135
|
+
}
|
|
3136
|
+
}
|
|
3137
|
+
function clearEncounter(model, sessionId, encounterId, options = {}) {
|
|
3138
|
+
const database = statements(model);
|
|
3139
|
+
if (options.keepFinished) {
|
|
3140
|
+
const stored = database.query("select ended_at_ms from combat_encounters where session_id = $sessionId and encounter_id = $encounterId").get({ sessionId, encounterId });
|
|
3141
|
+
if (stored && stored.ended_at_ms !== null)
|
|
3142
|
+
return;
|
|
3143
|
+
}
|
|
3144
|
+
for (const table of TABLES) {
|
|
3145
|
+
database.query(`delete from ${table} where session_id = $sessionId and encounter_id = $encounterId`).run({ sessionId, encounterId });
|
|
3146
|
+
}
|
|
3147
|
+
}
|
|
3148
|
+
function recordInvalidLines(model, sessionId, invalidLines, rebuilt) {
|
|
3149
|
+
if (invalidLines === 0 && !rebuilt)
|
|
3150
|
+
return;
|
|
3151
|
+
model.transaction(() => {
|
|
3152
|
+
statements(model).query(`insert into combat_stream_state (session_id, invalid_lines) values ($sessionId, $invalidLines)
|
|
3153
|
+
on conflict(session_id) do update set
|
|
3154
|
+
invalid_lines = case when $rebuilt = 1 then $invalidLines else combat_stream_state.invalid_lines + $invalidLines end`).run({ sessionId, invalidLines, rebuilt: rebuilt ? 1 : 0 });
|
|
3155
|
+
});
|
|
3156
|
+
}
|
|
3157
|
+
function loadStreamState(model, sessionId, reducer) {
|
|
3158
|
+
const row = statements(model).query("select identities_json, mob_identities_json, recent_hits_json from combat_stream_state where session_id = $sessionId").get({ sessionId });
|
|
3159
|
+
if (!row)
|
|
3160
|
+
return;
|
|
3161
|
+
for (const [actorId, identity] of JSON.parse(row.identities_json)) {
|
|
3162
|
+
reducer.identities.set(actorId, identity);
|
|
3163
|
+
}
|
|
3164
|
+
for (const [actorId, name] of JSON.parse(row.mob_identities_json)) {
|
|
3165
|
+
reducer.mobIdentities.set(actorId, name);
|
|
3166
|
+
}
|
|
3167
|
+
for (const [targetId, hits] of JSON.parse(row.recent_hits_json)) {
|
|
3168
|
+
reducer.recentHits.set(targetId, hits);
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
function writeStreamState(model, sessionId, reducer) {
|
|
3172
|
+
statements(model).query(`insert into combat_stream_state
|
|
3173
|
+
(session_id, identities_json, mob_identities_json, recent_hits_json)
|
|
3174
|
+
values ($sessionId, $identitiesJson, $mobIdentitiesJson, $recentHitsJson)
|
|
3175
|
+
on conflict(session_id) do update set
|
|
3176
|
+
identities_json = excluded.identities_json,
|
|
3177
|
+
mob_identities_json = excluded.mob_identities_json,
|
|
3178
|
+
recent_hits_json = excluded.recent_hits_json`).run({
|
|
3179
|
+
sessionId,
|
|
3180
|
+
identitiesJson: JSON.stringify([...reducer.identities]),
|
|
3181
|
+
mobIdentitiesJson: JSON.stringify([...reducer.mobIdentities]),
|
|
3182
|
+
recentHitsJson: JSON.stringify([...reducer.recentHits])
|
|
3183
|
+
});
|
|
3184
|
+
}
|
|
3185
|
+
function loadEnemyIdentity(database, sessionId, encounterId) {
|
|
3186
|
+
const rows = database.query("select target_id, display_name, first_seen_at_ms from combat_enemies where session_id = ? and encounter_id = ?").all(sessionId, encounterId);
|
|
3187
|
+
return {
|
|
3188
|
+
enemyFirstSeenAtMs: new Map(rows.map((enemy) => [enemy.target_id, enemy.first_seen_at_ms])),
|
|
3189
|
+
enemyNames: new Map(rows.filter((enemy) => enemy.display_name !== null).map((enemy) => [enemy.target_id, enemy.display_name]))
|
|
3190
|
+
};
|
|
3191
|
+
}
|
|
3192
|
+
function loadDeaths(database, sessionId, encounterId) {
|
|
3193
|
+
return database.query("select * from combat_deaths where session_id = ? and encounter_id = ? order by death_index").all(sessionId, encounterId).map((row) => ({
|
|
3194
|
+
victimName: row.victim_name,
|
|
3195
|
+
targetId: row.target_id,
|
|
3196
|
+
diedAtMs: row.died_at_ms,
|
|
3197
|
+
totalDamage: row.total_damage,
|
|
3198
|
+
hits: database.query("select * from combat_death_hits where session_id = ? and encounter_id = ? and death_index = ? order by hit_index").all(sessionId, encounterId, row.death_index).map((hit) => ({
|
|
3199
|
+
beforeDeathMs: hit.before_death_ms,
|
|
3200
|
+
attackerActorId: hit.attacker_actor_id,
|
|
3201
|
+
attackerLabel: hit.attacker_label,
|
|
3202
|
+
attackerIsMonster: hit.attacker_is_monster === 1,
|
|
3203
|
+
sourceLabel: hit.source_label,
|
|
3204
|
+
damage: hit.damage,
|
|
3205
|
+
critical: hit.critical === 1
|
|
3206
|
+
}))
|
|
3207
|
+
}));
|
|
3208
|
+
}
|
|
3209
|
+
function loadOpenEncounter(model, sessionId) {
|
|
3210
|
+
const database = statements(model);
|
|
3211
|
+
const row = database.query("select encounter_id, started_at_ms, last_damage_at_ms, ended_at_ms from combat_encounters where session_id = $sessionId and ended_at_ms is null").get({ sessionId });
|
|
3212
|
+
if (!row)
|
|
3213
|
+
return;
|
|
3214
|
+
const encounter = {
|
|
3215
|
+
id: row.encounter_id,
|
|
3216
|
+
startedAtMs: row.started_at_ms,
|
|
3217
|
+
lastDamageAtMs: row.last_damage_at_ms,
|
|
3218
|
+
actors: [],
|
|
3219
|
+
activeActors: new Map,
|
|
3220
|
+
...loadEnemyIdentity(database, sessionId, row.encounter_id),
|
|
3221
|
+
deaths: loadDeaths(database, sessionId, row.encounter_id)
|
|
3222
|
+
};
|
|
3223
|
+
const actorRows = database.query("select * from combat_actors where session_id = $sessionId and encounter_id = $encounterId and meter = 'dps' order by actor_index").all({ sessionId, encounterId: row.encounter_id });
|
|
3224
|
+
for (const actorRow2 of actorRows) {
|
|
3225
|
+
const actor = createActor(actorRow2.actor_id, encounter.startedAtMs, actorRow2.ewma_tau_seconds);
|
|
3226
|
+
if (actorRow2.display_name !== null)
|
|
3227
|
+
actor.displayName = actorRow2.display_name;
|
|
3228
|
+
if (actorRow2.archetype !== null)
|
|
3229
|
+
actor.archetype = actorRow2.archetype;
|
|
3230
|
+
if (actorRow2.owner_connection_id !== null)
|
|
3231
|
+
actor.ownerConnectionId = actorRow2.owner_connection_id;
|
|
3232
|
+
if (actorRow2.uid !== null)
|
|
3233
|
+
actor.uid = actorRow2.uid;
|
|
3234
|
+
actor.activeIdentity = actorRow2.active_identity === 1;
|
|
3235
|
+
actor.damage = actorRow2.damage;
|
|
3236
|
+
if (actorRow2.first_damage_at_ms !== null)
|
|
3237
|
+
actor.firstDamageAtMs = actorRow2.first_damage_at_ms;
|
|
3238
|
+
if (actorRow2.last_damage_at_ms !== null)
|
|
3239
|
+
actor.lastDamageAtMs = actorRow2.last_damage_at_ms;
|
|
3240
|
+
actor.hits = actorRow2.hits;
|
|
3241
|
+
actor.criticalHits = actorRow2.critical_hits;
|
|
3242
|
+
actor.kills = actorRow2.kills;
|
|
3243
|
+
actor.currentRate.restore({
|
|
3244
|
+
rate: actorRow2.ewma_rate,
|
|
3245
|
+
updatedAtMs: actorRow2.ewma_at_ms,
|
|
3246
|
+
tauSeconds: actorRow2.ewma_tau_seconds
|
|
3247
|
+
});
|
|
3248
|
+
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 = 'dps' and actor_index = ?").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
|
|
3249
|
+
actor.skills.set(skill.source_id, {
|
|
3250
|
+
sourceId: skill.source_id,
|
|
3251
|
+
sourceLabel: skill.source_label,
|
|
3252
|
+
damage: skill.damage,
|
|
3253
|
+
hits: skill.hits,
|
|
3254
|
+
criticalHits: skill.critical_hits
|
|
3255
|
+
});
|
|
3256
|
+
}
|
|
3257
|
+
for (const target of database.query("select target_id, damage from combat_targets where session_id = ? and encounter_id = ? and meter = 'dps' and actor_index = ?").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
|
|
3258
|
+
actor.targetIds.add(target.target_id);
|
|
3259
|
+
actor.targetDamage.set(target.target_id, target.damage);
|
|
3260
|
+
}
|
|
3261
|
+
for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and actor_index = ?").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
|
|
3262
|
+
const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
|
|
3263
|
+
actor.enemySkills.set(enemySkill.target_id, bySkill);
|
|
3264
|
+
bySkill.set(enemySkill.source_id, {
|
|
3265
|
+
sourceLabel: enemySkill.source_label,
|
|
3266
|
+
damage: enemySkill.damage,
|
|
3267
|
+
hits: enemySkill.hits,
|
|
3268
|
+
criticalHits: enemySkill.critical_hits
|
|
3269
|
+
});
|
|
3270
|
+
}
|
|
3271
|
+
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 = 'dps' and actor_index = ? order by bucket_index").all(sessionId, row.encounter_id, actorRow2.actor_index)) {
|
|
3272
|
+
const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
3273
|
+
series.originMs = bucket.origin_ms;
|
|
3274
|
+
series.widthMs = bucket.width_ms;
|
|
3275
|
+
while (series.buckets.length <= bucket.bucket_index)
|
|
3276
|
+
series.buckets.push(0);
|
|
3277
|
+
series.buckets[bucket.bucket_index] = bucket.damage;
|
|
3278
|
+
}
|
|
3279
|
+
encounter.actors.push(actor);
|
|
3280
|
+
if (actorRow2.active_slot === 1)
|
|
3281
|
+
encounter.activeActors.set(actor.actorId, actor);
|
|
3282
|
+
}
|
|
3283
|
+
return encounter;
|
|
3284
|
+
}
|
|
3285
|
+
function loadMeterAggregate(model, sessionId, open, meter) {
|
|
3286
|
+
const database = statements(model);
|
|
3287
|
+
const aggregate = {
|
|
3288
|
+
id: open.id,
|
|
3289
|
+
startedAtMs: open.startedAtMs,
|
|
3290
|
+
lastDamageAtMs: open.lastDamageAtMs,
|
|
3291
|
+
actors: [],
|
|
3292
|
+
activeActors: new Map,
|
|
3293
|
+
enemyFirstSeenAtMs: new Map,
|
|
3294
|
+
enemyNames: new Map,
|
|
3295
|
+
deaths: []
|
|
3296
|
+
};
|
|
3297
|
+
for (const actorRow2 of database.query("select * from combat_actors where session_id = ? and encounter_id = ? and meter = ? order by actor_index").all(sessionId, open.id, meter)) {
|
|
3298
|
+
const actor = createActor(actorRow2.actor_id, aggregate.startedAtMs, actorRow2.ewma_tau_seconds);
|
|
3299
|
+
if (actorRow2.display_name !== null)
|
|
3300
|
+
actor.displayName = actorRow2.display_name;
|
|
3301
|
+
if (actorRow2.archetype !== null)
|
|
3302
|
+
actor.archetype = actorRow2.archetype;
|
|
3303
|
+
actor.activeIdentity = actorRow2.active_identity === 1;
|
|
3304
|
+
actor.damage = actorRow2.damage;
|
|
3305
|
+
if (actorRow2.first_damage_at_ms !== null)
|
|
3306
|
+
actor.firstDamageAtMs = actorRow2.first_damage_at_ms;
|
|
3307
|
+
if (actorRow2.last_damage_at_ms !== null)
|
|
3308
|
+
actor.lastDamageAtMs = actorRow2.last_damage_at_ms;
|
|
3309
|
+
actor.hits = actorRow2.hits;
|
|
3310
|
+
actor.criticalHits = actorRow2.critical_hits;
|
|
3311
|
+
actor.currentRate.restore({
|
|
3312
|
+
rate: actorRow2.ewma_rate,
|
|
3313
|
+
updatedAtMs: actorRow2.ewma_at_ms,
|
|
3314
|
+
tauSeconds: actorRow2.ewma_tau_seconds
|
|
3315
|
+
});
|
|
3316
|
+
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 = ? and actor_index = ?").all(sessionId, open.id, meter, actorRow2.actor_index)) {
|
|
3317
|
+
actor.skills.set(skill.source_id, {
|
|
3318
|
+
sourceId: skill.source_id,
|
|
3319
|
+
sourceLabel: skill.source_label,
|
|
3320
|
+
damage: skill.damage,
|
|
3321
|
+
hits: skill.hits,
|
|
3322
|
+
criticalHits: skill.critical_hits
|
|
3323
|
+
});
|
|
3324
|
+
}
|
|
3325
|
+
for (const target of database.query("select target_id, damage from combat_targets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ?").all(sessionId, open.id, meter, actorRow2.actor_index)) {
|
|
3326
|
+
actor.targetIds.add(target.target_id);
|
|
3327
|
+
actor.targetDamage.set(target.target_id, target.damage);
|
|
3328
|
+
}
|
|
3329
|
+
for (const bucket of database.query("select origin, origin_ms, width_ms, bucket_index, damage from combat_timeline_buckets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ? order by bucket_index").all(sessionId, open.id, meter, actorRow2.actor_index)) {
|
|
3330
|
+
const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
3331
|
+
series.originMs = bucket.origin_ms;
|
|
3332
|
+
series.widthMs = bucket.width_ms;
|
|
3333
|
+
while (series.buckets.length <= bucket.bucket_index)
|
|
3334
|
+
series.buckets.push(0);
|
|
3335
|
+
series.buckets[bucket.bucket_index] = bucket.damage;
|
|
3336
|
+
}
|
|
3337
|
+
aggregate.actors.push(actor);
|
|
3338
|
+
if (actorRow2.active_slot === 1)
|
|
3339
|
+
aggregate.activeActors.set(actor.actorId, actor);
|
|
3340
|
+
}
|
|
3341
|
+
return aggregate;
|
|
3342
|
+
}
|
|
3343
|
+
// src/history/store.ts
|
|
3344
|
+
var DEFAULT_LIMIT = 50;
|
|
3345
|
+
|
|
3346
|
+
class CombatHistoryStore {
|
|
3347
|
+
model;
|
|
3348
|
+
constructor(model) {
|
|
3349
|
+
this.model = model;
|
|
3350
|
+
}
|
|
3351
|
+
invalidLines(sessionId) {
|
|
3352
|
+
const row = this.model.statement("select invalid_lines from combat_stream_state where session_id = $sessionId").get({ sessionId });
|
|
3353
|
+
return row?.invalid_lines ?? 0;
|
|
3354
|
+
}
|
|
3355
|
+
listEncounters(query) {
|
|
3356
|
+
const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT);
|
|
3357
|
+
const cursor = decodeCursor(query.cursor);
|
|
3358
|
+
const rows = cursor ? this.model.database.query(`select * from combat_encounters
|
|
3359
|
+
where session_id = ? and (started_at_ms > ? or (started_at_ms = ? and encounter_id > ?))
|
|
3360
|
+
order by started_at_ms, encounter_id limit ?`).all(query.sessionId, cursor.startedAtMs, cursor.startedAtMs, cursor.encounterId, limit + 1) : this.model.database.query("select * from combat_encounters where session_id = ? order by started_at_ms, encounter_id limit ?").all(query.sessionId, limit + 1);
|
|
3361
|
+
const page = rows.slice(0, limit);
|
|
3362
|
+
const last = page.at(-1);
|
|
3363
|
+
return {
|
|
3364
|
+
items: page.map((row) => summary(query.sessionId, row)),
|
|
3365
|
+
...rows.length > limit && last ? { nextCursor: encodeCursor({ startedAtMs: last.started_at_ms, encounterId: last.encounter_id }) } : {}
|
|
3366
|
+
};
|
|
3367
|
+
}
|
|
3368
|
+
getEnemyBreakdown(sessionId, encounterId) {
|
|
3369
|
+
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);
|
|
3370
|
+
const counts = new Map;
|
|
3371
|
+
for (const enemy of enemies) {
|
|
3372
|
+
const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
|
|
3373
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
3374
|
+
}
|
|
3375
|
+
const nextIndex = new Map;
|
|
3376
|
+
const options = enemies.map((enemy) => {
|
|
3377
|
+
const name = enemy.display_name ?? `Enemy ${enemy.target_id}`;
|
|
3378
|
+
if ((counts.get(name) ?? 0) <= 1)
|
|
3379
|
+
return { targetId: enemy.target_id, label: name };
|
|
3380
|
+
const index = (nextIndex.get(name) ?? 0) + 1;
|
|
3381
|
+
nextIndex.set(name, index);
|
|
3382
|
+
return { targetId: enemy.target_id, label: `${name} (${index})` };
|
|
3383
|
+
});
|
|
3384
|
+
const encounter = this.loadEncounter(sessionId, encounterId, "dps", { withTimeline: false });
|
|
3385
|
+
const skills = encounter ? displayActorAggregates(encounter, { includeAllAnonymous: true }).flatMap(({ rowId, actor }) => [...actor.enemySkills].flatMap(([targetId, bySkill]) => [...bySkill].map(([sourceId, stats]) => ({
|
|
3386
|
+
attackerRowId: rowId,
|
|
3387
|
+
targetId,
|
|
3388
|
+
sourceId,
|
|
3389
|
+
sourceLabel: stats.sourceLabel,
|
|
3390
|
+
damage: stats.damage,
|
|
3391
|
+
hits: stats.hits,
|
|
3392
|
+
criticalHits: stats.criticalHits
|
|
3393
|
+
})))).sort((left, right) => right.damage - left.damage) : [];
|
|
3394
|
+
return { encounterId, enemies: options, skills };
|
|
3395
|
+
}
|
|
3396
|
+
getDeathLog(query) {
|
|
3397
|
+
const limit = Math.max(1, query.limit ?? DEFAULT_LIMIT);
|
|
3398
|
+
const cursor = decodeCursor(query.cursor);
|
|
3399
|
+
const rows = cursor ? this.model.database.query(`select * from combat_deaths
|
|
3400
|
+
where session_id = ? and (died_at_ms < ? or (died_at_ms = ? and encounter_id || ':' || death_index < ?))
|
|
3401
|
+
order by died_at_ms desc, encounter_id desc, death_index desc limit ?`).all(query.sessionId, cursor.startedAtMs, cursor.startedAtMs, cursor.encounterId, limit + 1) : this.model.database.query("select * from combat_deaths where session_id = ? order by died_at_ms desc, encounter_id desc, death_index desc limit ?").all(query.sessionId, limit + 1);
|
|
3402
|
+
const page = rows.slice(0, limit);
|
|
3403
|
+
const last = page.at(-1);
|
|
3404
|
+
return {
|
|
3405
|
+
items: page.map((row) => ({
|
|
3406
|
+
encounterId: row.encounter_id,
|
|
3407
|
+
deathIndex: row.death_index,
|
|
3408
|
+
victimName: row.victim_name,
|
|
3409
|
+
targetId: row.target_id,
|
|
3410
|
+
diedAtMs: row.died_at_ms,
|
|
3411
|
+
totalDamage: row.total_damage,
|
|
3412
|
+
hits: this.model.database.query("select * from combat_death_hits where session_id = ? and encounter_id = ? and death_index = ? order by hit_index").all(query.sessionId, row.encounter_id, row.death_index).map((hit) => ({
|
|
3413
|
+
beforeDeathMs: hit.before_death_ms,
|
|
3414
|
+
attackerActorId: hit.attacker_actor_id,
|
|
3415
|
+
attackerLabel: hit.attacker_label,
|
|
3416
|
+
attackerIsMonster: hit.attacker_is_monster === 1,
|
|
3417
|
+
sourceLabel: hit.source_label,
|
|
3418
|
+
damage: hit.damage,
|
|
3419
|
+
critical: hit.critical === 1
|
|
3420
|
+
}))
|
|
3421
|
+
})),
|
|
3422
|
+
...rows.length > limit && last ? { nextCursor: encodeCursor({ startedAtMs: last.died_at_ms, encounterId: `${last.encounter_id}:${last.death_index}` }) } : {}
|
|
3423
|
+
};
|
|
3424
|
+
}
|
|
3425
|
+
getEncounter(sessionId, encounterId, options = {}) {
|
|
3426
|
+
const { meter = "dps", ...renderOptions } = options;
|
|
3427
|
+
const encounter = this.loadEncounter(sessionId, encounterId, meter);
|
|
3428
|
+
if (!encounter)
|
|
3429
|
+
return;
|
|
3430
|
+
return renderEncounter(encounter, { nowMs: encounter.lastDamageAtMs, ...renderOptions });
|
|
3431
|
+
}
|
|
3432
|
+
loadEncounter(sessionId, encounterId, meter, options = {}) {
|
|
3433
|
+
const database = this.model.database;
|
|
3434
|
+
const row = database.query("select * from combat_encounters where session_id = ? and encounter_id = ?").get(sessionId, encounterId);
|
|
3435
|
+
if (!row)
|
|
3436
|
+
return;
|
|
3437
|
+
const encounter = {
|
|
3438
|
+
id: row.encounter_id,
|
|
3439
|
+
startedAtMs: row.started_at_ms,
|
|
3440
|
+
lastDamageAtMs: row.last_damage_at_ms,
|
|
3441
|
+
...row.ended_at_ms === null ? {} : { endedAtMs: row.ended_at_ms },
|
|
3442
|
+
actors: [],
|
|
3443
|
+
activeActors: new Map,
|
|
3444
|
+
enemyFirstSeenAtMs: new Map,
|
|
3445
|
+
enemyNames: new Map,
|
|
3446
|
+
deaths: []
|
|
3447
|
+
};
|
|
3448
|
+
for (const actorRow2 of database.query("select * from combat_actors where session_id = ? and encounter_id = ? and meter = ? order by actor_index").all(sessionId, encounterId, meter)) {
|
|
3449
|
+
encounter.actors.push(hydrateActor(database, sessionId, encounterId, meter, actorRow2, encounter.startedAtMs, options.withTimeline ?? true));
|
|
3450
|
+
}
|
|
3451
|
+
return encounter;
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
function hydrateActor(database, sessionId, encounterId, meter, row, encounterStartedAtMs, withTimeline) {
|
|
3455
|
+
const actor = createActor(row.actor_id, encounterStartedAtMs, row.ewma_tau_seconds);
|
|
3456
|
+
if (row.display_name !== null)
|
|
3457
|
+
actor.displayName = row.display_name;
|
|
3458
|
+
if (row.archetype !== null)
|
|
3459
|
+
actor.archetype = row.archetype;
|
|
3460
|
+
if (row.owner_connection_id !== null)
|
|
3461
|
+
actor.ownerConnectionId = row.owner_connection_id;
|
|
3462
|
+
if (row.uid !== null)
|
|
3463
|
+
actor.uid = row.uid;
|
|
3464
|
+
actor.activeIdentity = row.active_identity === 1;
|
|
3465
|
+
actor.damage = row.damage;
|
|
3466
|
+
if (row.first_damage_at_ms !== null)
|
|
3467
|
+
actor.firstDamageAtMs = row.first_damage_at_ms;
|
|
3468
|
+
if (row.last_damage_at_ms !== null)
|
|
3469
|
+
actor.lastDamageAtMs = row.last_damage_at_ms;
|
|
3470
|
+
actor.hits = row.hits;
|
|
3471
|
+
actor.criticalHits = row.critical_hits;
|
|
3472
|
+
actor.kills = row.kills;
|
|
3473
|
+
actor.currentRate.restore({ rate: row.ewma_rate, updatedAtMs: row.ewma_at_ms, tauSeconds: row.ewma_tau_seconds });
|
|
3474
|
+
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 = ? and actor_index = ?").all(sessionId, encounterId, meter, row.actor_index)) {
|
|
3475
|
+
actor.skills.set(skill.source_id, {
|
|
3476
|
+
sourceId: skill.source_id,
|
|
3477
|
+
sourceLabel: skill.source_label,
|
|
3478
|
+
damage: skill.damage,
|
|
3479
|
+
hits: skill.hits,
|
|
3480
|
+
criticalHits: skill.critical_hits
|
|
3481
|
+
});
|
|
3482
|
+
}
|
|
3483
|
+
for (const target of database.query("select target_id, damage from combat_targets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ?").all(sessionId, encounterId, meter, row.actor_index)) {
|
|
3484
|
+
actor.targetIds.add(target.target_id);
|
|
3485
|
+
actor.targetDamage.set(target.target_id, target.damage);
|
|
3486
|
+
}
|
|
3487
|
+
if (meter === "dps") {
|
|
3488
|
+
for (const enemySkill of database.query("select target_id, source_id, source_label, damage, hits, critical_hits from combat_enemy_skills where session_id = ? and encounter_id = ? and actor_index = ?").all(sessionId, encounterId, row.actor_index)) {
|
|
3489
|
+
const bySkill = actor.enemySkills.get(enemySkill.target_id) ?? new Map;
|
|
3490
|
+
actor.enemySkills.set(enemySkill.target_id, bySkill);
|
|
3491
|
+
bySkill.set(enemySkill.source_id, {
|
|
3492
|
+
sourceLabel: enemySkill.source_label,
|
|
3493
|
+
damage: enemySkill.damage,
|
|
3494
|
+
hits: enemySkill.hits,
|
|
3495
|
+
criticalHits: enemySkill.critical_hits
|
|
3496
|
+
});
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
if (withTimeline) {
|
|
3500
|
+
for (const bucket of database.query("select origin, origin_ms, width_ms, bucket_index, damage from combat_timeline_buckets where session_id = ? and encounter_id = ? and meter = ? and actor_index = ? order by bucket_index").all(sessionId, encounterId, meter, row.actor_index)) {
|
|
3501
|
+
const series = bucket.origin === "actor" ? actor.actorSeries : actor.encounterSeries;
|
|
3502
|
+
series.originMs = bucket.origin_ms;
|
|
3503
|
+
series.widthMs = bucket.width_ms;
|
|
3504
|
+
while (series.buckets.length <= bucket.bucket_index)
|
|
3505
|
+
series.buckets.push(0);
|
|
3506
|
+
series.buckets[bucket.bucket_index] = bucket.damage;
|
|
3507
|
+
}
|
|
3508
|
+
}
|
|
3509
|
+
return actor;
|
|
3510
|
+
}
|
|
3511
|
+
function summary(sessionId, row) {
|
|
3512
|
+
return {
|
|
3513
|
+
sessionId,
|
|
3514
|
+
encounterId: row.encounter_id,
|
|
3515
|
+
startedAtMs: row.started_at_ms,
|
|
3516
|
+
lastDamageAtMs: row.last_damage_at_ms,
|
|
3517
|
+
...row.ended_at_ms === null ? {} : { endedAtMs: row.ended_at_ms },
|
|
3518
|
+
durationMs: Math.max(1000, row.last_damage_at_ms - row.started_at_ms),
|
|
3519
|
+
totalDamage: row.total_damage
|
|
3520
|
+
};
|
|
3521
|
+
}
|
|
3522
|
+
function encodeCursor(cursor) {
|
|
3523
|
+
return Buffer.from(`${cursor.startedAtMs}:${cursor.encounterId}`, "utf8").toString("base64url");
|
|
3524
|
+
}
|
|
3525
|
+
function decodeCursor(cursor) {
|
|
3526
|
+
if (!cursor)
|
|
3527
|
+
return;
|
|
3528
|
+
const text = Buffer.from(cursor, "base64url").toString("utf8");
|
|
3529
|
+
const separator = text.indexOf(":");
|
|
3530
|
+
if (separator < 0)
|
|
3531
|
+
return;
|
|
3532
|
+
const startedAtMs = Number(text.slice(0, separator));
|
|
3533
|
+
if (!Number.isFinite(startedAtMs))
|
|
3534
|
+
return;
|
|
3535
|
+
return { startedAtMs, encounterId: text.slice(separator + 1) };
|
|
3536
|
+
}
|
|
3537
|
+
// src/live-combat.ts
|
|
3538
|
+
class LiveCombatService {
|
|
3539
|
+
reducer;
|
|
3540
|
+
currentTauSeconds;
|
|
3541
|
+
minimumDurationMs;
|
|
3542
|
+
retainedFinishedEncounters;
|
|
3543
|
+
onEncounterFinished;
|
|
3544
|
+
tanked;
|
|
3545
|
+
healing;
|
|
3546
|
+
meterId;
|
|
3547
|
+
lastEventAtMs;
|
|
3548
|
+
latestFinished;
|
|
3549
|
+
personalName;
|
|
3550
|
+
personalActorId;
|
|
3551
|
+
revision = 0;
|
|
3552
|
+
constructor(options = {}) {
|
|
3553
|
+
this.currentTauSeconds = positive(options.currentTauSeconds ?? DEFAULT_CURRENT_TAU_SECONDS, "currentTauSeconds");
|
|
3554
|
+
this.minimumDurationMs = positive(options.minimumDurationMs ?? 1000, "minimumDurationMs");
|
|
3555
|
+
this.retainedFinishedEncounters = integerAtLeast(options.retainedFinishedEncounters ?? 1, "retainedFinishedEncounters");
|
|
3556
|
+
const timelinePoints = positiveInteger(options.timelinePoints ?? 720, "timelinePoints");
|
|
3557
|
+
this.personalName = options.personalName ?? "";
|
|
3558
|
+
this.personalActorId = options.personalActorId;
|
|
3559
|
+
this.onEncounterFinished = options.onEncounterFinished;
|
|
3560
|
+
this.reducer = new DamageReducer({
|
|
3561
|
+
idleGapMs: options.idleGapMs,
|
|
3562
|
+
currentTauSeconds: this.currentTauSeconds,
|
|
3563
|
+
maxTimelineBuckets: timelinePoints,
|
|
3564
|
+
onEncounterFinished: (encounter) => this.finishMeter(encounter)
|
|
3565
|
+
});
|
|
3566
|
+
this.tanked = new MeterReducer({ kind: "tanked", currentTauSeconds: this.currentTauSeconds, maxTimelineBuckets: timelinePoints });
|
|
3567
|
+
this.healing = new MeterReducer({ kind: "healing", currentTauSeconds: this.currentTauSeconds, maxTimelineBuckets: timelinePoints });
|
|
3568
|
+
}
|
|
3569
|
+
consumeIdentity(event, observedAtMs) {
|
|
3570
|
+
const previous = event.operation === "reset" ? undefined : this.reducer.identities.get(event.actorId);
|
|
3571
|
+
const hadIdentities = this.reducer.identities.size > 0;
|
|
3572
|
+
this.reducer.consumeIdentity(event, observedAtMs);
|
|
3573
|
+
this.tanked.consumeIdentity(event);
|
|
3574
|
+
this.healing.consumeIdentity(event);
|
|
3575
|
+
if (this.identityChanged(event, previous, hadIdentities))
|
|
3576
|
+
this.revision += 1;
|
|
3577
|
+
}
|
|
3578
|
+
identityChanged(event, previous, hadIdentities) {
|
|
3579
|
+
if (event.operation === "reset")
|
|
3580
|
+
return hadIdentities;
|
|
3581
|
+
if (event.operation === "remove")
|
|
3582
|
+
return previous !== undefined;
|
|
3583
|
+
const current = this.reducer.identities.get(event.actorId);
|
|
3584
|
+
if (!previous || !current)
|
|
3585
|
+
return true;
|
|
3586
|
+
return previous.displayName !== current.displayName || previous.archetype !== current.archetype || previous.ownerConnectionId !== current.ownerConnectionId || previous.uid !== current.uid;
|
|
3587
|
+
}
|
|
3588
|
+
consumeCombat(event, observedAtMs) {
|
|
3589
|
+
this.reducer.consumeCombat(event, observedAtMs);
|
|
3590
|
+
const encounter = this.reducer.current;
|
|
3591
|
+
if (encounter) {
|
|
3592
|
+
this.ensureMeter(encounter);
|
|
3593
|
+
this.recordMeterEvent(event, observedAtMs);
|
|
3594
|
+
}
|
|
3595
|
+
this.revision += 1;
|
|
3596
|
+
}
|
|
3597
|
+
setPersonalName(personalName) {
|
|
3598
|
+
if (this.personalName === personalName)
|
|
3599
|
+
return;
|
|
3600
|
+
this.personalName = personalName;
|
|
3601
|
+
this.revision += 1;
|
|
3602
|
+
}
|
|
3603
|
+
setPersonalActorId(personalActorId) {
|
|
3604
|
+
if (this.personalActorId === personalActorId)
|
|
3605
|
+
return;
|
|
3606
|
+
this.personalActorId = personalActorId;
|
|
3607
|
+
this.revision += 1;
|
|
3608
|
+
}
|
|
3609
|
+
getPersonalActorId() {
|
|
3610
|
+
return this.personalActorId;
|
|
3611
|
+
}
|
|
3612
|
+
advance(observedAtMs) {
|
|
3613
|
+
this.reducer.advance(observedAtMs);
|
|
3614
|
+
}
|
|
3615
|
+
reset(observedAtMs) {
|
|
3616
|
+
this.reducer.reset(observedAtMs);
|
|
3617
|
+
}
|
|
3618
|
+
getState(nowMs) {
|
|
3619
|
+
const current = this.reducer.current && this.meterId === this.reducer.current.id ? this.renderRecord(this.reducer.current, nowMs) : undefined;
|
|
3620
|
+
const latestFinished = this.latestFinished ? this.renderRecord(this.latestFinished.encounter, this.latestFinished.endedAtMs, this.latestFinished) : undefined;
|
|
3621
|
+
return {
|
|
3622
|
+
revision: this.revision,
|
|
3623
|
+
...current ? { current } : {},
|
|
3624
|
+
...latestFinished ? { latestFinished } : {}
|
|
3625
|
+
};
|
|
3626
|
+
}
|
|
3627
|
+
ensureMeter(encounter) {
|
|
3628
|
+
if (this.meterId === encounter.id)
|
|
3629
|
+
return;
|
|
3630
|
+
this.meterId = encounter.id;
|
|
3631
|
+
this.lastEventAtMs = encounter.startedAtMs;
|
|
3632
|
+
this.tanked.begin(encounter.id, encounter.startedAtMs);
|
|
3633
|
+
this.healing.begin(encounter.id, encounter.startedAtMs);
|
|
3634
|
+
}
|
|
3635
|
+
recordMeterEvent(event, observedAtMs) {
|
|
3636
|
+
if (this.meterId === undefined)
|
|
3637
|
+
return;
|
|
3638
|
+
const identities = this.reducer.identities;
|
|
3639
|
+
this.tanked.consumeCombat(event, observedAtMs, identities);
|
|
3640
|
+
this.healing.consumeCombat(event, observedAtMs, identities);
|
|
3641
|
+
this.lastEventAtMs = Math.max(this.lastEventAtMs ?? observedAtMs, observedAtMs);
|
|
3642
|
+
}
|
|
3643
|
+
finishMeter(encounter) {
|
|
3644
|
+
this.ensureMeter(encounter);
|
|
3645
|
+
const endedAtMs = encounter.endedAtMs ?? encounter.lastDamageAtMs;
|
|
3646
|
+
const finished = {
|
|
3647
|
+
encounter,
|
|
3648
|
+
endedAtMs,
|
|
3649
|
+
lastEventAtMs: this.lastEventAtMs ?? encounter.lastDamageAtMs,
|
|
3650
|
+
...this.tanked.current === undefined ? {} : { tanked: this.tanked.current },
|
|
3651
|
+
...this.healing.current === undefined ? {} : { healing: this.healing.current }
|
|
3652
|
+
};
|
|
3653
|
+
const record = this.renderRecord(encounter, endedAtMs, finished);
|
|
3654
|
+
this.tanked.finish(endedAtMs);
|
|
3655
|
+
this.healing.finish(endedAtMs);
|
|
3656
|
+
this.meterId = undefined;
|
|
3657
|
+
this.lastEventAtMs = undefined;
|
|
3658
|
+
this.latestFinished = this.retainedFinishedEncounters > 0 ? finished : undefined;
|
|
3659
|
+
this.revision += 1;
|
|
3660
|
+
const callback = this.onEncounterFinished;
|
|
3661
|
+
if (callback)
|
|
3662
|
+
Promise.resolve(callback(record));
|
|
3663
|
+
}
|
|
3664
|
+
renderRecord(encounter, nowMs, finished) {
|
|
3665
|
+
const atMs = Math.max(nowMs ?? finished?.lastEventAtMs ?? this.lastEventAtMs ?? encounter.lastDamageAtMs, encounter.lastDamageAtMs);
|
|
3666
|
+
const personal = {
|
|
3667
|
+
personalName: this.personalName,
|
|
3668
|
+
...this.personalActorId === undefined ? {} : { personalActorId: this.personalActorId }
|
|
3669
|
+
};
|
|
3670
|
+
const render = (aggregate) => renderMeter(aggregate ?? emptyAggregate(encounter), {
|
|
3671
|
+
nowMs: atMs,
|
|
3672
|
+
windowEndMs: encounter.endedAtMs ?? atMs,
|
|
3673
|
+
lastEventAtMs: finished?.lastEventAtMs ?? this.lastEventAtMs ?? encounter.lastDamageAtMs,
|
|
3674
|
+
minimumDurationMs: this.minimumDurationMs,
|
|
3675
|
+
...personal,
|
|
3676
|
+
...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs }
|
|
3677
|
+
});
|
|
3678
|
+
return {
|
|
3679
|
+
dps: renderEncounter(encounter, {
|
|
3680
|
+
nowMs: atMs,
|
|
3681
|
+
minimumDurationMs: this.minimumDurationMs,
|
|
3682
|
+
...personal
|
|
3683
|
+
}),
|
|
3684
|
+
tps: render(finished ? finished.tanked : this.tanked.current),
|
|
3685
|
+
hps: render(finished ? finished.healing : this.healing.current)
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
function emptyAggregate(encounter) {
|
|
3690
|
+
return {
|
|
3691
|
+
id: encounter.id,
|
|
3692
|
+
startedAtMs: encounter.startedAtMs,
|
|
3693
|
+
lastDamageAtMs: encounter.startedAtMs,
|
|
3694
|
+
actors: [],
|
|
3695
|
+
activeActors: new Map,
|
|
3696
|
+
enemyFirstSeenAtMs: new Map,
|
|
3697
|
+
enemyNames: new Map,
|
|
3698
|
+
deaths: [],
|
|
3699
|
+
...encounter.endedAtMs === undefined ? {} : { endedAtMs: encounter.endedAtMs }
|
|
3700
|
+
};
|
|
3701
|
+
}
|
|
3702
|
+
function renderMeter(aggregate, options) {
|
|
3703
|
+
const windowed = {
|
|
3704
|
+
...aggregate,
|
|
3705
|
+
lastDamageAtMs: Math.max(aggregate.lastDamageAtMs, options.windowEndMs)
|
|
3706
|
+
};
|
|
3707
|
+
const detail = renderEncounter(windowed, {
|
|
3708
|
+
nowMs: options.nowMs,
|
|
3709
|
+
minimumDurationMs: options.minimumDurationMs,
|
|
3710
|
+
...options.personalName === undefined ? {} : { personalName: options.personalName },
|
|
3711
|
+
...options.personalActorId === undefined ? {} : { personalActorId: options.personalActorId }
|
|
3712
|
+
});
|
|
3713
|
+
const firstByActorId = new Map;
|
|
3714
|
+
for (const actor of aggregate.actors) {
|
|
3715
|
+
if (actor.firstDamageAtMs !== undefined)
|
|
3716
|
+
firstByActorId.set(actor.actorId, actor.firstDamageAtMs);
|
|
3717
|
+
}
|
|
3718
|
+
const rows = detail.actors.map((actor) => {
|
|
3719
|
+
const firstAtMs = actor.actorIds.reduce((earliest, actorId) => {
|
|
3720
|
+
const candidate = firstByActorId.get(actorId);
|
|
3721
|
+
if (candidate === undefined)
|
|
3722
|
+
return earliest;
|
|
3723
|
+
return earliest === undefined ? candidate : Math.min(earliest, candidate);
|
|
3724
|
+
}, undefined);
|
|
3725
|
+
return {
|
|
3726
|
+
displayName: actor.displayName,
|
|
3727
|
+
actorIds: [...actor.actorIds],
|
|
3728
|
+
amount: actor.damage,
|
|
3729
|
+
rate: actor.dps,
|
|
3730
|
+
hits: actor.hits,
|
|
3731
|
+
...firstAtMs === undefined ? {} : { firstAtMs },
|
|
3732
|
+
...actor.lastDamageAtMs === undefined ? {} : { lastAtMs: actor.lastDamageAtMs }
|
|
3733
|
+
};
|
|
3734
|
+
});
|
|
3735
|
+
return {
|
|
3736
|
+
id: aggregate.id,
|
|
3737
|
+
startedAtMs: aggregate.startedAtMs,
|
|
3738
|
+
lastEventAtMs: options.lastEventAtMs,
|
|
3739
|
+
...options.endedAtMs === undefined ? {} : { endedAtMs: options.endedAtMs },
|
|
3740
|
+
durationMs: detail.durationMs,
|
|
3741
|
+
total: detail.totalDamage,
|
|
3742
|
+
rate: detail.partyDps,
|
|
3743
|
+
rows,
|
|
3744
|
+
detail
|
|
3745
|
+
};
|
|
3746
|
+
}
|
|
3747
|
+
function positive(value, name) {
|
|
3748
|
+
if (!Number.isFinite(value) || value <= 0)
|
|
3749
|
+
throw new Error(`${name} must be a positive finite number`);
|
|
3750
|
+
return value;
|
|
3751
|
+
}
|
|
3752
|
+
function integerAtLeast(value, name) {
|
|
3753
|
+
if (!Number.isInteger(value) || value < 0)
|
|
3754
|
+
throw new Error(`${name} must be a non-negative integer`);
|
|
3755
|
+
return value;
|
|
3756
|
+
}
|
|
3757
|
+
function positiveInteger(value, name) {
|
|
3758
|
+
if (!Number.isInteger(value) || value <= 0)
|
|
3759
|
+
throw new Error(`${name} must be a positive integer`);
|
|
3760
|
+
return value;
|
|
3761
|
+
}
|
|
3762
|
+
// src/live-log.ts
|
|
3763
|
+
import {
|
|
3764
|
+
DEFAULT_STREAM_BATCH_BYTES,
|
|
3765
|
+
JsonlTailReader,
|
|
3766
|
+
LiveLogSessionFollower,
|
|
3767
|
+
isLogStreamHeader as isLogStreamHeader3,
|
|
3768
|
+
parseLogRecord as parseLogRecord3
|
|
3769
|
+
} from "@kar-mi/spirit-vale-tools-logging";
|
|
3770
|
+
class DpsLogFollower {
|
|
3771
|
+
ticksPerSecond;
|
|
3772
|
+
reader;
|
|
3773
|
+
recordedAtOriginMs;
|
|
3774
|
+
lastObservedAtMs = 0;
|
|
3775
|
+
originTick;
|
|
3776
|
+
lastTick;
|
|
3777
|
+
revision = 0;
|
|
3778
|
+
constructor(path, ticksPerSecond = 30) {
|
|
3779
|
+
this.ticksPerSecond = ticksPerSecond;
|
|
3780
|
+
this.reader = new JsonlTailReader(path, { createDecoder: decoderFor2, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES });
|
|
3781
|
+
}
|
|
3782
|
+
async poll() {
|
|
3783
|
+
return this.consumeRead(await this.reader.read());
|
|
3784
|
+
}
|
|
3785
|
+
consumeRead({ missing, reset, lines }) {
|
|
3786
|
+
if (missing) {
|
|
3787
|
+
return { events: [], invalidLines: 0, missing: true, reset: false, changed: false, revision: this.revision };
|
|
3788
|
+
}
|
|
3789
|
+
if (reset)
|
|
3790
|
+
this.resetState();
|
|
3791
|
+
const consumed = this.consume(lines);
|
|
3792
|
+
const changed = reset || consumed.events.length > 0;
|
|
3793
|
+
if (changed)
|
|
3794
|
+
this.revision += 1;
|
|
3795
|
+
return { ...consumed, missing: false, reset, changed, revision: this.revision };
|
|
3796
|
+
}
|
|
3797
|
+
consume(lines) {
|
|
3798
|
+
const events = [];
|
|
3799
|
+
let invalidLines = 0;
|
|
3800
|
+
for (const line of lines) {
|
|
3801
|
+
if (!line.trim())
|
|
3802
|
+
continue;
|
|
3803
|
+
let candidate;
|
|
3804
|
+
try {
|
|
3805
|
+
candidate = JSON.parse(line);
|
|
3806
|
+
} catch {
|
|
3807
|
+
invalidLines += 1;
|
|
3808
|
+
continue;
|
|
3809
|
+
}
|
|
3810
|
+
if (isLogStreamHeader3(candidate))
|
|
3811
|
+
continue;
|
|
3812
|
+
const record = parseLogRecord3(candidate);
|
|
3813
|
+
if (!record) {
|
|
3814
|
+
invalidLines += 1;
|
|
3815
|
+
continue;
|
|
3816
|
+
}
|
|
3817
|
+
const event = parseDpsLogRecord(record.type, record.data);
|
|
3818
|
+
if (event === null)
|
|
3819
|
+
continue;
|
|
3820
|
+
if (!event) {
|
|
3821
|
+
invalidLines += 1;
|
|
3822
|
+
continue;
|
|
3823
|
+
}
|
|
3824
|
+
const observedAtMs = this.observedAt(record.recordedAt, event.tick);
|
|
3825
|
+
events.push({ event, observedAtMs });
|
|
3826
|
+
}
|
|
3827
|
+
return { events, invalidLines };
|
|
3828
|
+
}
|
|
3829
|
+
observedAt(recordedAt, tick) {
|
|
3830
|
+
const recordedAtMs = Date.parse(recordedAt);
|
|
3831
|
+
if (Number.isFinite(recordedAtMs)) {
|
|
3832
|
+
this.recordedAtOriginMs ??= recordedAtMs;
|
|
3833
|
+
this.lastObservedAtMs = Math.max(this.lastObservedAtMs, recordedAtMs - this.recordedAtOriginMs);
|
|
3834
|
+
return this.lastObservedAtMs;
|
|
3835
|
+
}
|
|
3836
|
+
if (this.lastTick !== undefined && tick < this.lastTick)
|
|
3837
|
+
this.originTick = undefined;
|
|
3838
|
+
this.originTick ??= tick;
|
|
3839
|
+
this.lastTick = tick;
|
|
3840
|
+
this.lastObservedAtMs = Math.max(this.lastObservedAtMs, (tick - this.originTick) * 1000 / this.ticksPerSecond);
|
|
3841
|
+
return this.lastObservedAtMs;
|
|
3842
|
+
}
|
|
3843
|
+
resetState() {
|
|
3844
|
+
this.recordedAtOriginMs = undefined;
|
|
3845
|
+
this.lastObservedAtMs = 0;
|
|
3846
|
+
this.originTick = undefined;
|
|
3847
|
+
this.lastTick = undefined;
|
|
3848
|
+
}
|
|
3849
|
+
}
|
|
3850
|
+
|
|
3851
|
+
class DpsSessionLogFollower {
|
|
3852
|
+
inner;
|
|
3853
|
+
constructor(logDirectory, ticksPerSecond = 30, options = {}) {
|
|
3854
|
+
const { ticksPerSecond: ticksOverride, ...tuning } = options;
|
|
3855
|
+
const ticks = ticksOverride ?? ticksPerSecond;
|
|
3856
|
+
this.inner = new LiveLogSessionFollower({
|
|
3857
|
+
stream: "combat",
|
|
3858
|
+
...logDirectory === undefined ? {} : { logDirectory },
|
|
3859
|
+
...tuning,
|
|
3860
|
+
readerOptions: { createDecoder: decoderFor2, maxReadBytes: DEFAULT_STREAM_BATCH_BYTES },
|
|
3861
|
+
createFollower: (path) => new DpsLogFollower(path, ticks),
|
|
3862
|
+
mergeSessionChange: (batch, changedSession) => ({
|
|
3863
|
+
...batch,
|
|
3864
|
+
reset: batch.reset || changedSession,
|
|
3865
|
+
changed: batch.changed || changedSession
|
|
3866
|
+
}),
|
|
3867
|
+
noStreamBatch: (reset) => ({ events: [], invalidLines: 0, missing: true, reset, changed: reset, revision: 0 })
|
|
3868
|
+
});
|
|
3869
|
+
}
|
|
3870
|
+
static watch(logDirectory, options = {}) {
|
|
3871
|
+
return new DpsSessionLogFollower(logDirectory, options.ticksPerSecond ?? 30, options);
|
|
3872
|
+
}
|
|
3873
|
+
poll() {
|
|
3874
|
+
return this.inner.poll();
|
|
3875
|
+
}
|
|
3876
|
+
next() {
|
|
3877
|
+
return this.inner.next();
|
|
3878
|
+
}
|
|
3879
|
+
[Symbol.asyncIterator]() {
|
|
3880
|
+
return this.inner[Symbol.asyncIterator]();
|
|
3881
|
+
}
|
|
3882
|
+
close() {
|
|
3883
|
+
this.inner.close();
|
|
3884
|
+
}
|
|
3885
|
+
}
|
|
3886
|
+
function decoderFor2(firstChunk) {
|
|
3887
|
+
if (firstChunk[0] === 255 && firstChunk[1] === 254)
|
|
3888
|
+
return new TextDecoder("utf-16le");
|
|
3889
|
+
if (firstChunk[0] === 254 && firstChunk[1] === 255)
|
|
3890
|
+
return new TextDecoder("utf-16be");
|
|
3891
|
+
return new TextDecoder("utf-8");
|
|
3892
|
+
}
|
|
3893
|
+
// src/position-tracker.ts
|
|
3894
|
+
class FishNetPositionTracker {
|
|
3895
|
+
options;
|
|
3896
|
+
positions = new Map;
|
|
3897
|
+
localObjectId;
|
|
3898
|
+
constructor(options = {}) {
|
|
3899
|
+
this.options = options;
|
|
3900
|
+
}
|
|
3901
|
+
setLocalObjectId(objectId) {
|
|
3902
|
+
this.localObjectId = objectId;
|
|
3903
|
+
}
|
|
3904
|
+
self() {
|
|
3905
|
+
return this.localObjectId === undefined ? undefined : this.positions.get(this.localObjectId);
|
|
3906
|
+
}
|
|
3907
|
+
get(objectId) {
|
|
3908
|
+
return this.positions.get(objectId);
|
|
3909
|
+
}
|
|
3910
|
+
snapshot() {
|
|
3911
|
+
return [...this.positions].map(([objectId, position]) => ({ objectId, position: { ...position } }));
|
|
3912
|
+
}
|
|
3913
|
+
reset() {
|
|
3914
|
+
this.positions.clear();
|
|
3915
|
+
this.localObjectId = undefined;
|
|
3916
|
+
}
|
|
3917
|
+
consume(packet) {
|
|
3918
|
+
if (packet.packetName === "authenticated" || packet.packetName === "disconnect") {
|
|
3919
|
+
this.reset();
|
|
3920
|
+
return [];
|
|
3921
|
+
}
|
|
3922
|
+
if (packet.objectId === undefined)
|
|
3923
|
+
return [];
|
|
3924
|
+
if (packet.packetName === "objectDespawn") {
|
|
3925
|
+
this.positions.delete(packet.objectId);
|
|
3926
|
+
return [];
|
|
3927
|
+
}
|
|
3928
|
+
if (packet.packetName === "objectSpawn") {
|
|
3929
|
+
const spawn = packet.spawnLocalPosition;
|
|
3930
|
+
if (!spawn)
|
|
3931
|
+
return [];
|
|
3932
|
+
const position2 = { x: spawn[0], y: spawn[1], z: spawn[2] };
|
|
3933
|
+
if (packet.spawnHeading !== undefined)
|
|
3934
|
+
position2.heading = packet.spawnHeading;
|
|
3935
|
+
this.positions.set(packet.objectId, position2);
|
|
3936
|
+
return [this.event(packet, packet.objectId, position2)];
|
|
3937
|
+
}
|
|
3938
|
+
const update = packet.networkTransform;
|
|
3939
|
+
if (!update)
|
|
3940
|
+
return [];
|
|
3941
|
+
const previous = this.positions.get(packet.objectId);
|
|
3942
|
+
const next = {
|
|
3943
|
+
x: update.position.x ?? previous?.x,
|
|
3944
|
+
y: update.position.y ?? previous?.y,
|
|
3945
|
+
z: update.position.z ?? previous?.z
|
|
3946
|
+
};
|
|
3947
|
+
if (next.x === undefined || next.y === undefined || next.z === undefined)
|
|
3948
|
+
return [];
|
|
3949
|
+
const position = { x: next.x, y: next.y, z: next.z };
|
|
3950
|
+
const heading = update.heading ?? previous?.heading;
|
|
3951
|
+
if (heading !== undefined)
|
|
3952
|
+
position.heading = heading;
|
|
3953
|
+
this.positions.set(packet.objectId, position);
|
|
3954
|
+
return [this.event(packet, packet.objectId, position)];
|
|
3955
|
+
}
|
|
3956
|
+
event(packet, objectId, position) {
|
|
3957
|
+
const displayName = this.options.directory?.getAttribution(objectId)?.displayName;
|
|
3958
|
+
return {
|
|
3959
|
+
kind: "position",
|
|
3960
|
+
tick: packet.tick,
|
|
3961
|
+
objectId,
|
|
3962
|
+
position: { ...position },
|
|
3963
|
+
...displayName === undefined ? {} : { displayName },
|
|
3964
|
+
self: objectId === this.localObjectId
|
|
3965
|
+
};
|
|
3966
|
+
}
|
|
3967
|
+
}
|
|
3968
|
+
export {
|
|
3969
|
+
ANALYSIS_BUCKET_MS,
|
|
3970
|
+
COMBAT_DOMAIN_NAME,
|
|
3971
|
+
COMBAT_DOMAIN_VERSION,
|
|
3972
|
+
CURRENT_BOSS_SKILL_NAMES,
|
|
3973
|
+
CombatHistoryStore,
|
|
3974
|
+
DamageReducer,
|
|
3975
|
+
DpsLogFollower,
|
|
3976
|
+
DpsSessionLogFollower,
|
|
3977
|
+
FishNetActorDirectory,
|
|
3978
|
+
FishNetCombatTracker,
|
|
3979
|
+
FishNetPositionTracker,
|
|
3980
|
+
FishNetStatusTracker,
|
|
3981
|
+
LiveCombatService,
|
|
3982
|
+
MeterReducer,
|
|
3983
|
+
actorRowId,
|
|
3984
|
+
createBossCatalog,
|
|
3985
|
+
createCombatDomain,
|
|
3986
|
+
decodeCombatCaptureJsonLines,
|
|
3987
|
+
displayActorAggregates,
|
|
3988
|
+
formatCombatReplaySummary,
|
|
3989
|
+
indexCombatStream,
|
|
3990
|
+
inspectCombatReplaySummary,
|
|
3991
|
+
loadDpsReplay,
|
|
3992
|
+
normalizeName,
|
|
3993
|
+
parseDpsLogRecord,
|
|
3994
|
+
readCombatReplaySummary,
|
|
3995
|
+
renderEncounter,
|
|
3996
|
+
replayCombatCapture,
|
|
3997
|
+
replayCombatCaptures
|
|
3998
|
+
};
|