@rian8337/osu-droid-replay-analyzer 1.4.17 → 2.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,20 +1,1727 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
5
- }) : (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- o[k2] = m[k];
8
- }));
9
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
10
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
11
- };
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- __exportStar(require("./data/CursorData"), exports);
14
- __exportStar(require("./constants/hitResult"), exports);
15
- __exportStar(require("./constants/movementType"), exports);
16
- __exportStar(require("./ReplayAnalyzer"), exports);
17
- __exportStar(require("./data/ReplayData"), exports);
18
- __exportStar(require("./data/ReplayObjectData"), exports);
19
- __exportStar(require("./analysis/ThreeFingerChecker"), exports);
20
- //# sourceMappingURL=index.js.map
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var osuBase = require('@rian8337/osu-base');
6
+ var osuDifficultyCalculator = require('@rian8337/osu-difficulty-calculator');
7
+ var osuRebalanceDifficultyCalculator = require('@rian8337/osu-rebalance-difficulty-calculator');
8
+ var unzipper = require('unzipper');
9
+ var javaDeserialization = require('java-deserialization');
10
+ var stream = require('stream');
11
+
12
+ function _interopNamespace(e) {
13
+ if (e && e.__esModule) return e;
14
+ var n = Object.create(null);
15
+ if (e) {
16
+ Object.keys(e).forEach(function (k) {
17
+ if (k !== 'default') {
18
+ var d = Object.getOwnPropertyDescriptor(e, k);
19
+ Object.defineProperty(n, k, d.get ? d : {
20
+ enumerable: true,
21
+ get: function () { return e[k]; }
22
+ });
23
+ }
24
+ });
25
+ }
26
+ n["default"] = e;
27
+ return Object.freeze(n);
28
+ }
29
+
30
+ var javaDeserialization__namespace = /*#__PURE__*/_interopNamespace(javaDeserialization);
31
+
32
+ /**
33
+ * Represents a cursor instance in an osu!droid replay.
34
+ *
35
+ * Stores cursor movement data such as x and y coordinates, movement size, etc.
36
+ *
37
+ * This is used when analyzing replays using replay analyzer.
38
+ */
39
+ class CursorData {
40
+ size;
41
+ time;
42
+ x;
43
+ y;
44
+ id;
45
+ constructor(values) {
46
+ this.size = values.size;
47
+ this.time = values.time;
48
+ this.x = values.x;
49
+ this.y = values.y;
50
+ this.id = values.id;
51
+ }
52
+ }
53
+
54
+ /**
55
+ * The result of a hit in an osu!droid replay.
56
+ */
57
+ exports.hitResult = void 0;
58
+ (function (hitResult) {
59
+ /**
60
+ * Miss (0).
61
+ */
62
+ hitResult[hitResult["RESULT_0"] = 1] = "RESULT_0";
63
+ /**
64
+ * Meh (50).
65
+ */
66
+ hitResult[hitResult["RESULT_50"] = 2] = "RESULT_50";
67
+ /**
68
+ * Great (100).
69
+ */
70
+ hitResult[hitResult["RESULT_100"] = 3] = "RESULT_100";
71
+ /**
72
+ * Good (300).
73
+ */
74
+ hitResult[hitResult["RESULT_300"] = 4] = "RESULT_300";
75
+ })(exports.hitResult || (exports.hitResult = {}));
76
+
77
+ /**
78
+ * Movement type of a cursor in an osu!droid replay.
79
+ */
80
+ exports.movementType = void 0;
81
+ (function (movementType) {
82
+ movementType[movementType["DOWN"] = 0] = "DOWN";
83
+ movementType[movementType["MOVE"] = 1] = "MOVE";
84
+ movementType[movementType["UP"] = 2] = "UP";
85
+ })(exports.movementType || (exports.movementType = {}));
86
+
87
+ /**
88
+ * Represents a replay data in an osu!droid replay.
89
+ *
90
+ * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
91
+ *
92
+ * This is used when analyzing replays using replay analyzer.
93
+ */
94
+ class ReplayData {
95
+ replayVersion;
96
+ folderName;
97
+ fileName;
98
+ hash;
99
+ time;
100
+ hit300k;
101
+ hit100k;
102
+ score;
103
+ maxCombo;
104
+ accuracy;
105
+ isFullCombo;
106
+ playerName;
107
+ rawMods;
108
+ rank;
109
+ convertedMods;
110
+ cursorMovement;
111
+ hitObjectData;
112
+ speedModification;
113
+ forcedAR;
114
+ constructor(values) {
115
+ this.replayVersion = values.replayVersion;
116
+ this.folderName = values.folderName;
117
+ this.fileName = values.fileName;
118
+ this.hash = values.hash;
119
+ this.time = new Date(values.time || 0);
120
+ this.hit300k = values.hit300k || 0;
121
+ this.hit100k = values.hit100k || 0;
122
+ this.score = values.score || 0;
123
+ this.maxCombo = values.maxCombo || 0;
124
+ this.accuracy = values.accuracy || new osuBase.Accuracy({});
125
+ this.isFullCombo = values.isFullCombo || false;
126
+ this.playerName = values.playerName || "";
127
+ this.rawMods = values.rawMods || "";
128
+ this.rank = values.rank || "";
129
+ this.convertedMods = values.convertedMods || [];
130
+ this.cursorMovement = values.cursorMovement;
131
+ this.hitObjectData = values.hitObjectData;
132
+ this.speedModification = values.speedModification || 1;
133
+ this.forcedAR = values.forcedAR;
134
+ }
135
+ }
136
+
137
+ /**
138
+ * A beatmap section generator that generates beatmap section based on aim/speed strain.
139
+ */
140
+ class BeatmapSectionGenerator {
141
+ /**
142
+ * Generates `BeatmapSection`s for the specified beatmap.
143
+ *
144
+ * @param map The beatmap to generate.
145
+ * @param minSectionObjectCount The maximum delta time allowed between two beatmap sections.
146
+ * Increasing this number decreases the amount of beatmap sections in general. Note that this value does not account for the speed multiplier of
147
+ * the play, similar to the way replay object data is stored.
148
+ * @param maxSectionDeltaTime The minimum object count required to make a beatmap section. Increasing this number decreases the amount of beatmap sections.
149
+ */
150
+ static generateSections(map, minSectionObjectCount, maxSectionDeltaTime) {
151
+ const beatmapSections = [];
152
+ let firstObjectIndex = 0;
153
+ for (let i = 0; i < map.objects.length - 1; ++i) {
154
+ const current = map.objects[i];
155
+ const next = map.objects[i + 1];
156
+ const realDeltaTime = next.object.startTime - current.object.endTime;
157
+ if (realDeltaTime >= maxSectionDeltaTime) {
158
+ // Ignore sections that don't meet object count requirement.
159
+ if (i - firstObjectIndex < minSectionObjectCount) {
160
+ firstObjectIndex = i + 1;
161
+ continue;
162
+ }
163
+ beatmapSections.push({
164
+ firstObjectIndex,
165
+ lastObjectIndex: i,
166
+ });
167
+ firstObjectIndex = i + 1;
168
+ }
169
+ }
170
+ // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
171
+ if (map.objects.length - firstObjectIndex > minSectionObjectCount) {
172
+ beatmapSections.push({
173
+ firstObjectIndex,
174
+ lastObjectIndex: map.objects.length - 1,
175
+ });
176
+ }
177
+ return beatmapSections;
178
+ }
179
+ }
180
+
181
+ /**
182
+ * Represents a section of a beatmap.
183
+ */
184
+ class BeatmapSection {
185
+ /**
186
+ * The index of the first `DifficultyHitObject` of this beatmap section.
187
+ */
188
+ firstObjectIndex;
189
+ /**
190
+ * The index of the last `DifficultyHitObject` of this beatmap section.
191
+ */
192
+ lastObjectIndex;
193
+ /**
194
+ * @param firstObjectIndex The index of the first `DifficultyHitObject` of this beatmap section.
195
+ * @param lastObjectIndex The index of the last `DifficultyHitObject` of this beatmap section.
196
+ */
197
+ constructor(firstObjectIndex, lastObjectIndex) {
198
+ this.firstObjectIndex = firstObjectIndex;
199
+ this.lastObjectIndex = lastObjectIndex;
200
+ }
201
+ }
202
+
203
+ /**
204
+ * A section of a beatmap with extra information used for detecting three-finger usage.
205
+ */
206
+ class ThreeFingerBeatmapSection extends BeatmapSection {
207
+ /**
208
+ * Whether or not this beatmap section is dragged.
209
+ */
210
+ isDragged;
211
+ /**
212
+ * The index of the cursor that is dragging this section.
213
+ */
214
+ dragFingerIndex;
215
+ constructor(values) {
216
+ super(values.firstObjectIndex, values.lastObjectIndex);
217
+ this.isDragged = values.isDragged;
218
+ this.dragFingerIndex = values.dragFingerIndex;
219
+ }
220
+ }
221
+
222
+ /**
223
+ * Utility to check whether or not a beatmap is three-fingered.
224
+ */
225
+ class ThreeFingerChecker {
226
+ /**
227
+ * The beatmap to analyze.
228
+ */
229
+ map;
230
+ /**
231
+ * The data of the replay.
232
+ */
233
+ data;
234
+ /**
235
+ * The strain threshold to start detecting for 3-fingered section.
236
+ *
237
+ * Increasing this number will result in less sections being flagged.
238
+ */
239
+ static strainThreshold = 175;
240
+ /**
241
+ * The distance threshold between cursors to assume that two cursors are
242
+ * actually pressed with 1 finger in osu!pixels.
243
+ *
244
+ * This is used to prevent cases where a player would lift their finger
245
+ * too fast to the point where the 4th cursor instance or beyond is recorded
246
+ * as 1st, 2nd, or 3rd cursor instance.
247
+ */
248
+ cursorDistancingDistanceThreshold = 60;
249
+ /**
250
+ * The threshold for the amount of cursors that are assumed to be pressed
251
+ * by a single finger.
252
+ */
253
+ cursorDistancingCountThreshold = 10;
254
+ /**
255
+ * The threshold for the time difference of cursors that are assumed to be pressed
256
+ * by a single finger, in milliseconds.
257
+ */
258
+ cursorDistancingTimeThreshold = 1000;
259
+ /**
260
+ * The amount of notes that has a tap strain exceeding `strainThreshold`.
261
+ */
262
+ strainNoteCount;
263
+ /**
264
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
265
+ *
266
+ * Increasing this number will increase detection accuracy, however
267
+ * it also increases the chance of falsely flagged plays.
268
+ */
269
+ threeFingerRatioThreshold = 0.01;
270
+ /**
271
+ * The maximum delta time allowed between two beatmap sections.
272
+ *
273
+ * Increasing this number decreases the amount of beatmap sections in general.
274
+ *
275
+ * Note that this value does not account for the speed multiplier of
276
+ * the play, similar to the way replay object data is stored.
277
+ */
278
+ maxSectionDeltaTime = 2000;
279
+ /**
280
+ * The minimum object count required to make a beatmap section.
281
+ *
282
+ * Increasing this number decreases the amount of beatmap sections.
283
+ */
284
+ minSectionObjectCount = 5;
285
+ /**
286
+ * The sections of the beatmap that was cut based on `maxSectionDeltaTime` and `minSectionObjectCount`.
287
+ */
288
+ beatmapSections = [];
289
+ /**
290
+ * This threshold is used to filter out accidental taps.
291
+ *
292
+ * Increasing this number makes the filtration more sensitive, however it
293
+ * will also increase the chance of 3-fingered plays getting out from
294
+ * being flagged.
295
+ */
296
+ accidentalTapThreshold = 400;
297
+ /**
298
+ * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
299
+ */
300
+ hitWindow;
301
+ /**
302
+ * A reprocessed break points to match right on object time.
303
+ *
304
+ * This is used to increase detection accuracy since break points do not start right at the
305
+ * start of the hitobject before it and do not end right at the first hitobject after it.
306
+ */
307
+ breakPointAccurateTimes = [];
308
+ /**
309
+ * A cursor data array that only contains `movementType.DOWN` movement ID occurrences.
310
+ */
311
+ downCursorInstances = [];
312
+ /**
313
+ * Nerf factors from all sections that were three-fingered.
314
+ */
315
+ nerfFactors = [];
316
+ /**
317
+ * @param map The beatmap to analyze.
318
+ * @param data The data of the replay.
319
+ */
320
+ constructor(map, data) {
321
+ this.map = map;
322
+ this.data = data;
323
+ const stats = new osuBase.MapStats({
324
+ od: this.map.map.difficulty.od,
325
+ mods: this.map.mods.filter((m) => !osuBase.ModUtil.speedChangingMods
326
+ .map((v) => v.droidString)
327
+ .includes(m.droidString)),
328
+ }).calculate();
329
+ this.hitWindow = new osuBase.DroidHitWindow(stats.od);
330
+ const strainNotes = map.objects.filter((v) => v.originalTapStrain >= ThreeFingerChecker.strainThreshold);
331
+ this.strainNoteCount = strainNotes.length;
332
+ }
333
+ /**
334
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
335
+ */
336
+ static isEligibleToDetect(map) {
337
+ return map.objects.some((v) => v.originalTapStrain >= this.strainThreshold);
338
+ }
339
+ /**
340
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
341
+ *
342
+ * The beatmap will be separated into sections and each section will be determined
343
+ * whether or not it is dragged.
344
+ *
345
+ * After that, each section will be assigned a nerf factor based on whether or not
346
+ * the section is 3-fingered. These nerf factors will be summed up into a final
347
+ * nerf factor, taking beatmap difficulty into account.
348
+ */
349
+ check() {
350
+ if (this.strainNoteCount === 0) {
351
+ return { is3Finger: false, penalty: 1 };
352
+ }
353
+ this.getAccurateBreakPoints();
354
+ this.filterCursorInstances();
355
+ if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
356
+ return { is3Finger: false, penalty: 1 };
357
+ }
358
+ this.getBeatmapSections();
359
+ this.detectDragPlay();
360
+ this.getDetailedBeatmapSections();
361
+ this.preventAccidentalTaps();
362
+ if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
363
+ return { is3Finger: false, penalty: 1 };
364
+ }
365
+ this.calculateNerfFactors();
366
+ const finalPenalty = this.calculateFinalPenalty();
367
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
368
+ }
369
+ /**
370
+ * Generates a new set of "accurate break points".
371
+ *
372
+ * This is done to increase detection accuracy since break points do not start right at the
373
+ * start of the hitobject before it and do not end right at the first hitobject after it.
374
+ */
375
+ getAccurateBreakPoints() {
376
+ const objects = this.map.objects;
377
+ const objectData = this.data.hitObjectData;
378
+ const isPrecise = this.map.mods.some((m) => m instanceof osuBase.ModPrecise);
379
+ for (const breakPoint of this.map.map.events.breaks) {
380
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.object.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
381
+ let timeBefore = objects[beforeIndex].object.endTime;
382
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
383
+ let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
384
+ switch (objectData[beforeIndex].result) {
385
+ case exports.hitResult.RESULT_300:
386
+ beforeIndexHitWindowLength =
387
+ this.hitWindow.hitWindowFor300(isPrecise);
388
+ break;
389
+ case exports.hitResult.RESULT_100:
390
+ beforeIndexHitWindowLength =
391
+ this.hitWindow.hitWindowFor100(isPrecise);
392
+ break;
393
+ default:
394
+ beforeIndexHitWindowLength =
395
+ this.hitWindow.hitWindowFor50(isPrecise);
396
+ }
397
+ timeBefore += beforeIndexHitWindowLength;
398
+ const afterIndex = beforeIndex + 1;
399
+ let timeAfter = objects[afterIndex].object.startTime;
400
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
401
+ let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
402
+ switch (objectData[afterIndex].result) {
403
+ case exports.hitResult.RESULT_300:
404
+ afterIndexHitWindowLength =
405
+ this.hitWindow.hitWindowFor300(isPrecise);
406
+ break;
407
+ case exports.hitResult.RESULT_100:
408
+ afterIndexHitWindowLength =
409
+ this.hitWindow.hitWindowFor100(isPrecise);
410
+ break;
411
+ default:
412
+ afterIndexHitWindowLength =
413
+ this.hitWindow.hitWindowFor50(isPrecise);
414
+ }
415
+ timeAfter += afterIndexHitWindowLength;
416
+ this.breakPointAccurateTimes.push({
417
+ startTime: timeBefore,
418
+ endTime: timeAfter,
419
+ });
420
+ }
421
+ }
422
+ /**
423
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
424
+ *
425
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
426
+ */
427
+ filterCursorInstances() {
428
+ const objects = this.map.objects;
429
+ const objectData = this.data.hitObjectData;
430
+ const firstObjectResult = objectData[0].result;
431
+ const lastObjectResult = objectData.at(-1).result;
432
+ const isPrecise = this.map.mods.some((m) => m instanceof osuBase.ModPrecise);
433
+ // For sliders, automatically set hit window length to be as lenient as possible.
434
+ let firstObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
435
+ if (objects[0].object instanceof osuBase.Circle) {
436
+ switch (firstObjectResult) {
437
+ case exports.hitResult.RESULT_300:
438
+ firstObjectHitWindow =
439
+ this.hitWindow.hitWindowFor300(isPrecise);
440
+ break;
441
+ case exports.hitResult.RESULT_100:
442
+ firstObjectHitWindow =
443
+ this.hitWindow.hitWindowFor100(isPrecise);
444
+ break;
445
+ default:
446
+ firstObjectHitWindow =
447
+ this.hitWindow.hitWindowFor50(isPrecise);
448
+ }
449
+ }
450
+ // For sliders, automatically set hit window length to be as lenient as possible.
451
+ let lastObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
452
+ if (objects.at(-1).object instanceof osuBase.Circle) {
453
+ switch (lastObjectResult) {
454
+ case exports.hitResult.RESULT_300:
455
+ lastObjectHitWindow =
456
+ this.hitWindow.hitWindowFor300(isPrecise);
457
+ break;
458
+ case exports.hitResult.RESULT_100:
459
+ lastObjectHitWindow =
460
+ this.hitWindow.hitWindowFor100(isPrecise);
461
+ break;
462
+ default:
463
+ lastObjectHitWindow =
464
+ this.hitWindow.hitWindowFor50(isPrecise);
465
+ }
466
+ }
467
+ // These hit time uses hit window length as threshold.
468
+ // This is because cursors aren't recorded exactly at hit time,
469
+ // probably due to the game's behavior.
470
+ const firstObjectHitTime = objects[0].object.startTime - firstObjectHitWindow;
471
+ const lastObjectHitTime = objects.at(-1).object.startTime + lastObjectHitWindow;
472
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
473
+ const cursorInstance = this.data.cursorMovement[i];
474
+ const newCursorData = new CursorData({
475
+ size: 0,
476
+ time: [],
477
+ x: [],
478
+ y: [],
479
+ id: [],
480
+ });
481
+ for (let j = 0; j < cursorInstance.size; ++j) {
482
+ if (cursorInstance.id[j] !== exports.movementType.DOWN) {
483
+ continue;
484
+ }
485
+ const time = cursorInstance.time[j];
486
+ if (time < firstObjectHitTime || time > lastObjectHitTime) {
487
+ continue;
488
+ }
489
+ if (this.breakPointAccurateTimes.some((v) => time >= v.startTime && time <= v.endTime)) {
490
+ continue;
491
+ }
492
+ ++newCursorData.size;
493
+ newCursorData.time.push(time);
494
+ newCursorData.x.push(cursorInstance.x[j]);
495
+ newCursorData.y.push(cursorInstance.y[j]);
496
+ newCursorData.id.push(cursorInstance.id[j]);
497
+ }
498
+ this.downCursorInstances.push(newCursorData);
499
+ }
500
+ }
501
+ /**
502
+ * Divides the beatmap into sections, which will be used to
503
+ * detect dragged sections and improve detection speed.
504
+ */
505
+ getBeatmapSections() {
506
+ const beatmapSections = BeatmapSectionGenerator.generateSections(this.map, this.minSectionObjectCount, this.maxSectionDeltaTime);
507
+ for (const beatmapSection of beatmapSections) {
508
+ this.beatmapSections.push(new ThreeFingerBeatmapSection({
509
+ firstObjectIndex: beatmapSection.firstObjectIndex,
510
+ lastObjectIndex: beatmapSection.lastObjectIndex,
511
+ isDragged: false,
512
+ dragFingerIndex: -1,
513
+ }));
514
+ }
515
+ }
516
+ /**
517
+ * Checks whether or not each beatmap sections is dragged.
518
+ */
519
+ detectDragPlay() {
520
+ for (let i = 0; i < this.beatmapSections.length; ++i) {
521
+ const dragIndex = this.checkDrag(this.beatmapSections[i]);
522
+ this.beatmapSections[i].dragFingerIndex = dragIndex;
523
+ this.beatmapSections[i].isDragged = dragIndex !== -1;
524
+ }
525
+ }
526
+ /**
527
+ * Checks if a section is dragged and returns the index of the drag finger.
528
+ *
529
+ * If the section is not dragged, -1 will be returned.
530
+ *
531
+ * @param section The section to check.
532
+ */
533
+ checkDrag(section) {
534
+ const objects = this.map.objects;
535
+ const objectData = this.data.hitObjectData;
536
+ const isPrecise = this.map.mods.some((m) => m instanceof osuBase.ModPrecise);
537
+ const firstObject = objects[section.firstObjectIndex];
538
+ const lastObject = objects[section.lastObjectIndex];
539
+ let firstObjectMinHitTime = firstObject.object.startTime;
540
+ if (firstObject.object instanceof osuBase.Circle) {
541
+ switch (objectData[section.firstObjectIndex].result) {
542
+ case exports.hitResult.RESULT_300:
543
+ firstObjectMinHitTime -=
544
+ this.hitWindow.hitWindowFor300(isPrecise);
545
+ break;
546
+ case exports.hitResult.RESULT_100:
547
+ firstObjectMinHitTime -=
548
+ this.hitWindow.hitWindowFor100(isPrecise);
549
+ break;
550
+ default:
551
+ firstObjectMinHitTime -=
552
+ this.hitWindow.hitWindowFor50(isPrecise);
553
+ }
554
+ }
555
+ else {
556
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(isPrecise);
557
+ }
558
+ let lastObjectMaxHitTime = lastObject.object.startTime;
559
+ if (lastObject.object instanceof osuBase.Circle) {
560
+ switch (objectData[section.lastObjectIndex].result) {
561
+ case exports.hitResult.RESULT_300:
562
+ lastObjectMaxHitTime +=
563
+ this.hitWindow.hitWindowFor300(isPrecise);
564
+ break;
565
+ case exports.hitResult.RESULT_100:
566
+ lastObjectMaxHitTime +=
567
+ this.hitWindow.hitWindowFor100(isPrecise);
568
+ break;
569
+ default:
570
+ lastObjectMaxHitTime +=
571
+ this.hitWindow.hitWindowFor50(isPrecise);
572
+ }
573
+ }
574
+ else {
575
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(isPrecise);
576
+ }
577
+ // Since there may be more than 1 cursor instance index,
578
+ // we check which cursor instance follows hitobjects all over.
579
+ const cursorIndexes = [];
580
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
581
+ const c = this.data.cursorMovement[i];
582
+ if (c.size === 0) {
583
+ continue;
584
+ }
585
+ // Do not include cursors that don't have an occurence in this section
586
+ // this speeds up checking process.
587
+ if (c.time.filter((v) => v >= firstObjectMinHitTime && v <= lastObjectMaxHitTime).length === 0) {
588
+ continue;
589
+ }
590
+ // If this cursor instance doesn't move, it's not the cursor instance we want.
591
+ if (c.id.filter((v) => v === exports.movementType.MOVE).length === 0) {
592
+ continue;
593
+ }
594
+ cursorIndexes.push(i);
595
+ }
596
+ return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
597
+ }
598
+ /**
599
+ * Finds the drag index of the section.
600
+ *
601
+ * @param sectionObjects The objects in the section.
602
+ * @param sectionReplayObjectData The hitobject data of all objects in the section.
603
+ * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
604
+ */
605
+ findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
606
+ let objectIndex = sectionObjects.findIndex((v, i) => !(v.object instanceof osuBase.Spinner) &&
607
+ sectionReplayObjectData[i].result !== exports.hitResult.RESULT_0);
608
+ if (objectIndex === -1) {
609
+ return -1;
610
+ }
611
+ while (cursorIndexes.length > 0) {
612
+ if (objectIndex === sectionObjects.length) {
613
+ break;
614
+ }
615
+ const o = sectionObjects[objectIndex];
616
+ const s = sectionReplayObjectData[objectIndex];
617
+ ++objectIndex;
618
+ if (s.result === exports.hitResult.RESULT_0) {
619
+ continue;
620
+ }
621
+ // Get the cursor instance that is closest to the object's hit time.
622
+ for (let j = 0; j < cursorIndexes.length; ++j) {
623
+ const c = this.data.cursorMovement[cursorIndexes[j]];
624
+ // Cursor instances aren't always recorded at all times,
625
+ // therefore the game emulates the movement between
626
+ // movementType.MOVE cursors.
627
+ const hitTime = o.object.startTime + s.accuracy;
628
+ const nextHitIndex = c.time.findIndex((v) => v >= hitTime);
629
+ const hitIndex = nextHitIndex - 1;
630
+ if (hitIndex <= -1) {
631
+ cursorIndexes[j] = -1;
632
+ continue;
633
+ }
634
+ if (c.id[hitIndex] === exports.movementType.UP) {
635
+ cursorIndexes[j] = -1;
636
+ continue;
637
+ }
638
+ const cursorPosition = new osuBase.Vector2(c.x[hitIndex], c.y[hitIndex]);
639
+ let isInObject = false;
640
+ if (c.id[nextHitIndex] === exports.movementType.MOVE ||
641
+ c.id[hitIndex] === exports.movementType.MOVE) {
642
+ // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
643
+ // This minimizes rounding error.
644
+ for (let mSecPassed = c.time[hitIndex]; mSecPassed <= c.time[nextHitIndex]; ++mSecPassed) {
645
+ const t = (mSecPassed - c.time[nextHitIndex]) /
646
+ (c.time[hitIndex] - c.time[nextHitIndex]);
647
+ cursorPosition.x = osuBase.Interpolation.lerp(c.x[hitIndex], c.x[nextHitIndex], t);
648
+ cursorPosition.y = osuBase.Interpolation.lerp(c.y[hitIndex], c.y[nextHitIndex], t);
649
+ if (o.object.stackedPosition.getDistance(cursorPosition) <= o.object.radius) {
650
+ isInObject = true;
651
+ break;
652
+ }
653
+ }
654
+ }
655
+ else {
656
+ isInObject =
657
+ o.object.stackedPosition.getDistance(cursorPosition) <=
658
+ o.object.radius;
659
+ }
660
+ if (!isInObject) {
661
+ cursorIndexes[j] = -1;
662
+ }
663
+ }
664
+ cursorIndexes = cursorIndexes.filter((v) => v !== -1);
665
+ }
666
+ return cursorIndexes.shift() ?? -1;
667
+ }
668
+ /**
669
+ * Redivides the beatmap into sections.
670
+ *
671
+ * The result will be used to detect for three-fingered
672
+ * sections.
673
+ */
674
+ getDetailedBeatmapSections() {
675
+ const objects = this.map.objects;
676
+ const newBeatmapSections = [];
677
+ for (const beatmapSection of this.beatmapSections) {
678
+ let inSpeedSection = false;
679
+ let newFirstObjectIndex = beatmapSection.firstObjectIndex;
680
+ for (let i = beatmapSection.firstObjectIndex; i <= beatmapSection.lastObjectIndex; ++i) {
681
+ if (!inSpeedSection &&
682
+ objects[i].originalTapStrain >=
683
+ ThreeFingerChecker.strainThreshold) {
684
+ inSpeedSection = true;
685
+ newFirstObjectIndex = i;
686
+ continue;
687
+ }
688
+ if (inSpeedSection &&
689
+ objects[i].originalTapStrain <
690
+ ThreeFingerChecker.strainThreshold) {
691
+ inSpeedSection = false;
692
+ newBeatmapSections.push({
693
+ firstObjectIndex: newFirstObjectIndex,
694
+ lastObjectIndex: i,
695
+ isDragged: beatmapSection.isDragged,
696
+ dragFingerIndex: beatmapSection.dragFingerIndex,
697
+ });
698
+ }
699
+ }
700
+ // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
701
+ if (inSpeedSection) {
702
+ newBeatmapSections.push({
703
+ firstObjectIndex: newFirstObjectIndex,
704
+ lastObjectIndex: beatmapSection.lastObjectIndex,
705
+ isDragged: beatmapSection.isDragged,
706
+ dragFingerIndex: beatmapSection.dragFingerIndex,
707
+ });
708
+ }
709
+ }
710
+ this.beatmapSections.length = 0;
711
+ this.beatmapSections.push(...newBeatmapSections);
712
+ }
713
+ /**
714
+ * Attempts to prevent accidental taps from being flagged.
715
+ *
716
+ * This detection will filter cursors that don't hit
717
+ * any object in beatmap sections, thus eliminating any
718
+ * unnecessary taps.
719
+ */
720
+ preventAccidentalTaps() {
721
+ let filledCursorAmount = this.downCursorInstances.filter((v) => v.size > 0).length;
722
+ if (filledCursorAmount <= 3) {
723
+ return;
724
+ }
725
+ const objects = this.map.objects;
726
+ const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.size, 0);
727
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
728
+ if (filledCursorAmount <= 3) {
729
+ break;
730
+ }
731
+ const cursorInstance = this.downCursorInstances[i];
732
+ // Use an estimation for accidental tap threshold.
733
+ if (cursorInstance.size <=
734
+ Math.ceil(objects.length / this.accidentalTapThreshold) &&
735
+ cursorInstance.size / totalCursorAmount <
736
+ this.threeFingerRatioThreshold * 2) {
737
+ --filledCursorAmount;
738
+ for (const property in cursorInstance) {
739
+ const prop = property;
740
+ if (Array.isArray(cursorInstance[prop])) {
741
+ cursorInstance[prop].length = 0;
742
+ }
743
+ else {
744
+ cursorInstance[prop] = 0;
745
+ }
746
+ }
747
+ }
748
+ this.downCursorInstances[i] = cursorInstance;
749
+ }
750
+ }
751
+ /**
752
+ * Creates nerf factors by scanning through objects.
753
+ *
754
+ * This check will ignore all objects with speed strain below `strainThreshold`.
755
+ */
756
+ calculateNerfFactors() {
757
+ const objects = this.map.objects;
758
+ const objectData = this.data.hitObjectData;
759
+ const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
760
+ // We only filter cursor instances that are above the strain threshold.
761
+ // This minimalizes the amount of cursor instances to analyze.
762
+ for (const beatmapSection of this.beatmapSections) {
763
+ const dragIndex = beatmapSection.dragFingerIndex;
764
+ const startTime = objects[beatmapSection.firstObjectIndex].object.startTime +
765
+ (objectData[beatmapSection.firstObjectIndex].result !==
766
+ exports.hitResult.RESULT_0
767
+ ? objectData[beatmapSection.firstObjectIndex].accuracy
768
+ : -this.hitWindow.hitWindowFor50(isPrecise));
769
+ const endTime = objects[beatmapSection.lastObjectIndex].object.endTime +
770
+ (objectData[beatmapSection.lastObjectIndex].result !==
771
+ exports.hitResult.RESULT_0
772
+ ? objectData[beatmapSection.lastObjectIndex].accuracy
773
+ : this.hitWindow.hitWindowFor50(isPrecise));
774
+ // Filter cursor instances during section.
775
+ this.downCursorInstances.forEach((c) => {
776
+ const i = c.time.findIndex((t) => t >= startTime);
777
+ if (i !== -1) {
778
+ c.size -= i;
779
+ c.time.splice(0, i);
780
+ c.x.splice(0, i);
781
+ c.y.splice(0, i);
782
+ c.id.splice(0, i);
783
+ }
784
+ });
785
+ const cursorAmounts = [];
786
+ const cursorVectorTimes = [];
787
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
788
+ // Do not include drag cursor instance.
789
+ if (i === dragIndex) {
790
+ continue;
791
+ }
792
+ const cursorData = this.downCursorInstances[i];
793
+ let amount = 0;
794
+ for (let j = 0; j < cursorData.size; ++j) {
795
+ if (cursorData.time[j] >= startTime &&
796
+ cursorData.time[j] <= endTime) {
797
+ ++amount;
798
+ cursorVectorTimes.push({
799
+ vector: new osuBase.Vector2(cursorData.x[j], cursorData.y[j]),
800
+ time: cursorData.time[j],
801
+ });
802
+ }
803
+ }
804
+ cursorAmounts.push(amount);
805
+ }
806
+ // This index will be used to detect if a section is 3-fingered.
807
+ // If the section is dragged, the dragged instance will be ignored,
808
+ // hence why the index is 1 less than nondragged section.
809
+ const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
810
+ // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
811
+ // to check if the section is 3-fingered.
812
+ const threeFingerRatio = cursorAmounts
813
+ .slice(fingerSplitIndex)
814
+ .reduce((acc, value) => acc + value, 0) /
815
+ cursorAmounts
816
+ .slice(0, fingerSplitIndex)
817
+ .reduce((acc, value) => acc + value, 0);
818
+ const similarPresses = [];
819
+ for (const cursorVectorTime of cursorVectorTimes) {
820
+ const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
821
+ this.cursorDistancingDistanceThreshold);
822
+ if (pressIndex !== -1) {
823
+ if (cursorVectorTime.time -
824
+ similarPresses[pressIndex].lastTime >=
825
+ this.cursorDistancingTimeThreshold) {
826
+ similarPresses.splice(pressIndex, 1);
827
+ similarPresses.push({
828
+ vector: cursorVectorTime.vector,
829
+ count: 1,
830
+ lastTime: cursorVectorTime.time,
831
+ });
832
+ continue;
833
+ }
834
+ similarPresses[pressIndex].vector = cursorVectorTime.vector;
835
+ similarPresses[pressIndex].lastTime = cursorVectorTime.time;
836
+ ++similarPresses[pressIndex].count;
837
+ }
838
+ else {
839
+ similarPresses.push({
840
+ vector: cursorVectorTime.vector,
841
+ count: 1,
842
+ lastTime: cursorVectorTime.time,
843
+ });
844
+ }
845
+ }
846
+ // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
847
+ const validPresses = similarPresses
848
+ .filter((v) => v.count >= this.cursorDistancingCountThreshold)
849
+ .sort((a, b) => {
850
+ return b.count - a.count;
851
+ })
852
+ .slice(2);
853
+ // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
854
+ if ((threeFingerRatio > this.threeFingerRatioThreshold &&
855
+ cursorAmounts.filter((v) => v > 1).length > 3) ||
856
+ validPresses.length > 0) {
857
+ // Strain factor
858
+ const objectCount = beatmapSection.lastObjectIndex -
859
+ beatmapSection.firstObjectIndex +
860
+ 1;
861
+ const strainFactor = Math.pow(objects
862
+ .slice(beatmapSection.firstObjectIndex, beatmapSection.lastObjectIndex)
863
+ .reduce((acc, value) => acc +
864
+ value.originalTapStrain /
865
+ ThreeFingerChecker.strainThreshold, 0), 0.75);
866
+ // We can ignore the first 3 (2 for drag) filled cursor instances
867
+ // since they are guaranteed not 3 finger.
868
+ const threeFingerCursorAmounts = cursorAmounts
869
+ .slice(fingerSplitIndex)
870
+ .filter((amount) => amount > 0);
871
+ // Finger factor applies more penalty if more fingers were used.
872
+ const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
873
+ ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
874
+ Math.pow(((index + 1) * value * objectCount) /
875
+ this.strainNoteCount, 0.8), 1)
876
+ : Math.pow(validPresses.reduce((acc, value, index) => acc +
877
+ Math.pow(((index + 1) *
878
+ (value.count /
879
+ (this
880
+ .cursorDistancingCountThreshold *
881
+ 2)) *
882
+ objectCount) /
883
+ this.strainNoteCount, 0.2), 1), 0.2);
884
+ // Length factor applies more penalty if there are more 3-fingered object.
885
+ const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
886
+ this.nerfFactors.push({
887
+ strainFactor: Math.max(1, strainFactor),
888
+ fingerFactor,
889
+ lengthFactor,
890
+ });
891
+ }
892
+ }
893
+ }
894
+ /**
895
+ * Calculates the final penalty.
896
+ */
897
+ calculateFinalPenalty() {
898
+ return (1 +
899
+ this.nerfFactors.reduce((a, n) => a +
900
+ 0.015 *
901
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Contains information about which cursor index hits a hitobject.
907
+ */
908
+ class IndexedHitObject {
909
+ /**
910
+ * The index of the cursor that hits the hitobject.
911
+ */
912
+ cursorIndex;
913
+ /**
914
+ * The underlying difficulty hitobject.
915
+ */
916
+ object;
917
+ /**
918
+ * @param object The underlying difficulty hitobject.
919
+ * @param cursorIndex The index of the cursor that hits the hitobject.
920
+ */
921
+ constructor(object, cursorIndex) {
922
+ this.object = object;
923
+ this.cursorIndex = cursorIndex;
924
+ }
925
+ }
926
+
927
+ /**
928
+ * Utility to check whether or not a beatmap is two-handed.
929
+ */
930
+ class TwoHandChecker {
931
+ /**
932
+ * The beatmap that is being analyzed.
933
+ */
934
+ map;
935
+ /**
936
+ * The data of the replay.
937
+ */
938
+ data;
939
+ /**
940
+ * A cursor data array that only contains `movementType.DOWN` and `movementType.MOVE` movement ID occurrences.
941
+ */
942
+ downMoveCursorInstances = [];
943
+ /**
944
+ * The hitobjects of the beatmap that have been assigned with their respective cursor index.
945
+ */
946
+ indexedHitObjects = [];
947
+ /**
948
+ * The osu!droid hitwindow of the analyzed beatmap.
949
+ */
950
+ hitWindow;
951
+ /**
952
+ * The minimum count of a cursor index occurrence to be valid.
953
+ *
954
+ * This is used to prevent excessive penalty by splitting the beatmap into
955
+ * those that do not worth any strain.
956
+ */
957
+ minCursorIndexCount = 5;
958
+ /**
959
+ * @param map The beatmap to analyze.
960
+ * @param data The data of the replay.
961
+ */
962
+ constructor(map, data) {
963
+ this.map = map;
964
+ this.data = data;
965
+ const stats = new osuBase.MapStats({
966
+ od: this.map.map.difficulty.od,
967
+ mods: this.map.mods.filter((m) => !osuBase.ModUtil.speedChangingMods
968
+ .map((v) => v.droidString)
969
+ .includes(m.droidString)),
970
+ }).calculate();
971
+ this.hitWindow = new osuBase.DroidHitWindow(stats.od);
972
+ }
973
+ /**
974
+ * Checks if a beatmap is two-handed.
975
+ */
976
+ check() {
977
+ this.filterCursorInstances();
978
+ if (this.downMoveCursorInstances.filter((v) => v.size > 0).length <= 1) {
979
+ return false;
980
+ }
981
+ this.indexHitObjects();
982
+ this.applyPenalty();
983
+ return true;
984
+ }
985
+ /**
986
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` and `movementType.MOVE` movement ID.
987
+ */
988
+ filterCursorInstances() {
989
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
990
+ const cursorInstance = this.data.cursorMovement[i];
991
+ const newCursorData = {
992
+ size: 0,
993
+ time: [],
994
+ x: [],
995
+ y: [],
996
+ id: [],
997
+ };
998
+ for (let j = 0; j < cursorInstance.size; ++j) {
999
+ if (cursorInstance.id[j] === exports.movementType.UP) {
1000
+ continue;
1001
+ }
1002
+ ++newCursorData.size;
1003
+ newCursorData.time.push(cursorInstance.time[j]);
1004
+ newCursorData.x.push(cursorInstance.x[j]);
1005
+ newCursorData.y.push(cursorInstance.y[j]);
1006
+ newCursorData.id.push(cursorInstance.id[j]);
1007
+ }
1008
+ this.downMoveCursorInstances.push(newCursorData);
1009
+ }
1010
+ }
1011
+ /**
1012
+ * Converts hitobjects into indexed hit objects.
1013
+ */
1014
+ indexHitObjects() {
1015
+ const objects = this.map.objects;
1016
+ const objectData = this.data.hitObjectData;
1017
+ const indexes = [];
1018
+ for (let i = 0; i < this.map.objects.length; ++i) {
1019
+ const current = objects[i];
1020
+ const currentData = objectData[i];
1021
+ const index = this.getCursorIndex(current, currentData);
1022
+ indexes.push(index);
1023
+ this.indexedHitObjects.push(new IndexedHitObject(current, index));
1024
+ }
1025
+ console.log(indexes.filter((v) => v !== -1).length, "cursors found,", indexes.filter((v) => v === -1).length, "not found");
1026
+ const indexCounts = osuBase.Utils.initializeArray(this.downMoveCursorInstances.length, 0);
1027
+ for (const index of indexes) {
1028
+ if (index === -1) {
1029
+ continue;
1030
+ }
1031
+ ++indexCounts[index];
1032
+ }
1033
+ const mainCursorIndex = indexCounts.indexOf(Math.max(...indexCounts));
1034
+ const ignoredCursorIndexes = [];
1035
+ for (let i = 0; i < indexCounts.length; ++i) {
1036
+ if (indexCounts[i] < this.minCursorIndexCount) {
1037
+ ignoredCursorIndexes.push(i);
1038
+ }
1039
+ }
1040
+ this.indexedHitObjects.forEach((indexedHitObject) => {
1041
+ if (indexedHitObject.cursorIndex === -1 ||
1042
+ ignoredCursorIndexes.includes(indexedHitObject.cursorIndex)) {
1043
+ indexedHitObject.cursorIndex = mainCursorIndex;
1044
+ }
1045
+ });
1046
+ for (let i = 0; i < this.downMoveCursorInstances.length; ++i) {
1047
+ console.log("Index", i, "count:", indexes.filter((v) => v === i).length);
1048
+ }
1049
+ }
1050
+ /**
1051
+ * Gets the cursor index that hits the given object.
1052
+ *
1053
+ * @param object The object to check.
1054
+ * @param data The replay data of the object.
1055
+ * @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
1056
+ */
1057
+ getCursorIndex(object, data) {
1058
+ if (object.object instanceof osuBase.Spinner ||
1059
+ data.result === exports.hitResult.RESULT_0) {
1060
+ return -1;
1061
+ }
1062
+ const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1063
+ let hitWindowLength;
1064
+ switch (data.result) {
1065
+ case exports.hitResult.RESULT_300:
1066
+ hitWindowLength = this.hitWindow.hitWindowFor300(isPrecise);
1067
+ break;
1068
+ case exports.hitResult.RESULT_100:
1069
+ hitWindowLength = this.hitWindow.hitWindowFor100(isPrecise);
1070
+ break;
1071
+ default:
1072
+ hitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
1073
+ }
1074
+ const hitTime = object.object.startTime;
1075
+ const maximumHitTime = hitTime + hitWindowLength;
1076
+ const minimumHitTime = hitTime - hitWindowLength;
1077
+ const cursorInformations = [];
1078
+ for (let i = 0; i < this.downMoveCursorInstances.length; ++i) {
1079
+ const c = this.downMoveCursorInstances[i];
1080
+ let minDistance = Number.POSITIVE_INFINITY;
1081
+ let minHitTime = 0;
1082
+ for (let j = 0; j < c.size; ++j) {
1083
+ if (c.time[j] < minimumHitTime) {
1084
+ continue;
1085
+ }
1086
+ // For some reason, some cursor instances repeat itself,
1087
+ // so just skip it to save time.
1088
+ if (c.time[j + 1] === c.time[j]) {
1089
+ continue;
1090
+ }
1091
+ if (c.time[j - 1] > maximumHitTime) {
1092
+ break;
1093
+ }
1094
+ let hitPosition = new osuBase.Vector2(c.x[j], c.y[j]);
1095
+ let distanceToObject = object.object.stackedPosition.getDistance(hitPosition);
1096
+ if (minDistance > distanceToObject) {
1097
+ minDistance = distanceToObject;
1098
+ minHitTime = c.time[j];
1099
+ }
1100
+ minDistance = Math.min(minDistance, object.object.stackedPosition.getDistance(hitPosition));
1101
+ if (c.id[j + 1] === exports.movementType.MOVE ||
1102
+ c.id[j] === exports.movementType.MOVE) {
1103
+ // Interpolate cursor position between two occurrences
1104
+ const initialPosition = new osuBase.Vector2(c.x[j], c.y[j]);
1105
+ const nextPosition = new osuBase.Vector2(c.x[j + 1], c.y[j + 1]);
1106
+ const displacement = nextPosition.subtract(initialPosition);
1107
+ for (let mSecPassed = c.time[j]; mSecPassed <= Math.min(c.time[j + 1], maximumHitTime); ++mSecPassed) {
1108
+ const progress = (mSecPassed - c.time[j]) /
1109
+ (c.time[j + 1] - c.time[j]);
1110
+ hitPosition = initialPosition.add(displacement.scale(progress));
1111
+ distanceToObject =
1112
+ object.object.stackedPosition.getDistance(hitPosition);
1113
+ if (minDistance > distanceToObject) {
1114
+ minDistance = distanceToObject;
1115
+ minHitTime = mSecPassed;
1116
+ }
1117
+ }
1118
+ }
1119
+ }
1120
+ if (minDistance <= object.object.radius) {
1121
+ cursorInformations.push({
1122
+ cursorIndex: i,
1123
+ hitTimeDiff: Math.abs(minHitTime - hitTime),
1124
+ });
1125
+ }
1126
+ }
1127
+ if (cursorInformations.length === 0) {
1128
+ return -1;
1129
+ }
1130
+ // Now we look at which cursor is closest to hit time
1131
+ const minHitTimeDiff = Math.min(...cursorInformations.map((v) => {
1132
+ return v.hitTimeDiff;
1133
+ }));
1134
+ return (cursorInformations.find((c) => c.hitTimeDiff === minHitTimeDiff)
1135
+ ?.cursorIndex);
1136
+ }
1137
+ /**
1138
+ * Applies penalty to the original star rating instance.
1139
+ */
1140
+ applyPenalty() {
1141
+ const beatmaps = new Array(this.downMoveCursorInstances.length);
1142
+ this.indexedHitObjects.forEach((o) => {
1143
+ if (!beatmaps[o.cursorIndex]) {
1144
+ const map = osuBase.Utils.deepCopy(this.map.map);
1145
+ beatmaps[o.cursorIndex] = map;
1146
+ }
1147
+ beatmaps[o.cursorIndex].hitObjects.add(o.object.object);
1148
+ });
1149
+ this.map.objects.length = 0;
1150
+ beatmaps.forEach((beatmap) => {
1151
+ if (!beatmap) {
1152
+ return;
1153
+ }
1154
+ const starRating = osuBase.Utils.deepCopy(this.map);
1155
+ starRating.map = beatmap;
1156
+ starRating.generateDifficultyHitObjects();
1157
+ starRating.objects[0].deltaTime =
1158
+ starRating.objects[0].object.startTime -
1159
+ this.indexedHitObjects[0].object.object.startTime;
1160
+ starRating.objects[0].strainTime = Math.max(50, starRating.objects[0].deltaTime);
1161
+ this.map.objects.push(...starRating.objects);
1162
+ });
1163
+ this.map.objects.sort((a, b) => {
1164
+ return a.startTime - b.startTime;
1165
+ });
1166
+ this.map.calculateAll();
1167
+ }
1168
+ }
1169
+
1170
+ /**
1171
+ * A replay analyzer that analyzes a replay from osu!droid.
1172
+ *
1173
+ * Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
1174
+ *
1175
+ * Once analyzed, the result can be accessed via the `data` property.
1176
+ */
1177
+ class ReplayAnalyzer {
1178
+ /**
1179
+ * The score ID of the replay.
1180
+ */
1181
+ scoreID;
1182
+ /**
1183
+ * The original odr file of the replay.
1184
+ */
1185
+ originalODR = null;
1186
+ /**
1187
+ * The fixed odr file of the replay.
1188
+ */
1189
+ fixedODR = null;
1190
+ /**
1191
+ * Whether or not the play is considered using >=3 finger abuse.
1192
+ */
1193
+ is3Finger;
1194
+ /**
1195
+ * Whether or not the play is considered 2-handed.
1196
+ */
1197
+ is2Hand;
1198
+ /**
1199
+ * The beatmap that is being analyzed. `DroidStarRating` or `RebalanceDroidStarRating` is required for three finger or two hand analyzing.
1200
+ */
1201
+ map;
1202
+ /**
1203
+ * The results of the analyzer. `null` when initialized.
1204
+ */
1205
+ data = null;
1206
+ /**
1207
+ * Penalty value used to penalize dpp for 2-hand.
1208
+ */
1209
+ aimPenalty = 1;
1210
+ /**
1211
+ * Penalty value used to penalize dpp for 3 finger abuse.
1212
+ */
1213
+ tapPenalty = 1;
1214
+ /**
1215
+ * Whether this replay has been checked against 3 finger usage.
1216
+ */
1217
+ hasBeenCheckedFor3Finger = false;
1218
+ /**
1219
+ * Whether this replay has been checked against 2 hand usage.
1220
+ */
1221
+ hasBeenCheckedFor2Hand = false;
1222
+ // Sizes of primitive data types in Java (in bytes)
1223
+ BYTE_LENGTH = 1;
1224
+ SHORT_LENGTH = 2;
1225
+ INT_LENGTH = 4;
1226
+ FLOAT_LENGTH = 4;
1227
+ LONG_LENGTH = 8;
1228
+ constructor(values) {
1229
+ this.scoreID = values.scoreID;
1230
+ this.map = values.map;
1231
+ }
1232
+ /**
1233
+ * Analyzes a replay.
1234
+ */
1235
+ async analyze() {
1236
+ if (!this.originalODR && !this.fixedODR) {
1237
+ this.originalODR = await this.downloadReplay();
1238
+ }
1239
+ if (!this.originalODR) {
1240
+ return this;
1241
+ }
1242
+ if (!this.fixedODR) {
1243
+ this.fixedODR = await this.decompress().catch(() => {
1244
+ return null;
1245
+ });
1246
+ }
1247
+ if (!this.fixedODR) {
1248
+ return this;
1249
+ }
1250
+ this.parseReplay();
1251
+ return this;
1252
+ }
1253
+ /**
1254
+ * Downloads the given score ID's replay.
1255
+ */
1256
+ async downloadReplay() {
1257
+ const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1258
+ .setRequireAPIkey(false)
1259
+ .setEndpoint("upload")
1260
+ .addParameter("", `${this.scoreID}.odr`);
1261
+ const result = await apiRequestBuilder.sendRequest();
1262
+ if (result.statusCode !== 200) {
1263
+ return null;
1264
+ }
1265
+ return result.data;
1266
+ }
1267
+ /**
1268
+ * Decompresses a replay.
1269
+ *
1270
+ * The decompressed replay is in a form of Java object. This will be converted to a buffer and deserialized to read data from the replay.
1271
+ */
1272
+ decompress() {
1273
+ return new Promise((resolve, reject) => {
1274
+ const stream$1 = new stream.Readable();
1275
+ stream$1.push(this.originalODR);
1276
+ stream$1.push(null);
1277
+ stream$1
1278
+ .pipe(unzipper.Parse())
1279
+ .on("entry", async (entry) => {
1280
+ const fileName = entry.path;
1281
+ if (fileName === "data") {
1282
+ return resolve(await entry.buffer());
1283
+ }
1284
+ else {
1285
+ entry.autodrain();
1286
+ }
1287
+ })
1288
+ .on("error", (e) => {
1289
+ setTimeout(() => reject(e), 2000);
1290
+ });
1291
+ });
1292
+ }
1293
+ /**
1294
+ * Parses a replay after being downloaded and converted to a buffer.
1295
+ */
1296
+ parseReplay() {
1297
+ // javaDeserialization can only somewhat parse some string field
1298
+ // the rest will be a buffer that we need to manually parse
1299
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1300
+ let rawObject;
1301
+ try {
1302
+ rawObject = javaDeserialization__namespace.parse(this.fixedODR);
1303
+ }
1304
+ catch {
1305
+ return;
1306
+ }
1307
+ const resultObject = {
1308
+ replayVersion: rawObject[0].version,
1309
+ folderName: rawObject[1],
1310
+ fileName: rawObject[2],
1311
+ hash: rawObject[3],
1312
+ cursorMovement: [],
1313
+ hitObjectData: [],
1314
+ };
1315
+ if (resultObject.replayVersion >= 3) {
1316
+ resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
1317
+ resultObject.hit300k = rawObject[4].readInt32BE(8);
1318
+ resultObject.hit100k = rawObject[4].readInt32BE(16);
1319
+ resultObject.score = rawObject[4].readInt32BE(32);
1320
+ resultObject.maxCombo = rawObject[4].readInt32BE(36);
1321
+ resultObject.accuracy = new osuBase.Accuracy({
1322
+ n300: rawObject[4].readInt32BE(12),
1323
+ n100: rawObject[4].readInt32BE(20),
1324
+ n50: rawObject[4].readInt32BE(24),
1325
+ nmiss: rawObject[4].readInt32BE(28),
1326
+ });
1327
+ resultObject.isFullCombo = !!rawObject[4][44];
1328
+ resultObject.playerName = rawObject[5];
1329
+ resultObject.rawMods = rawObject[6].elements;
1330
+ resultObject.convertedMods = this.convertMods(rawObject[6].elements);
1331
+ // Determine rank
1332
+ const totalHits = resultObject.accuracy.n300 +
1333
+ resultObject.accuracy.n100 +
1334
+ resultObject.accuracy.n50 +
1335
+ resultObject.accuracy.nmiss;
1336
+ const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
1337
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
1338
+ switch (true) {
1339
+ case resultObject.accuracy.value() === 1:
1340
+ if (isHidden) {
1341
+ resultObject.rank = "XH";
1342
+ }
1343
+ else {
1344
+ resultObject.rank = "X";
1345
+ }
1346
+ break;
1347
+ case hit300Ratio > 0.9 &&
1348
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
1349
+ !resultObject.accuracy.nmiss:
1350
+ if (isHidden) {
1351
+ resultObject.rank = "SH";
1352
+ }
1353
+ else {
1354
+ resultObject.rank = "S";
1355
+ }
1356
+ break;
1357
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
1358
+ hit300Ratio > 0.9:
1359
+ resultObject.rank = "A";
1360
+ break;
1361
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
1362
+ hit300Ratio > 0.8:
1363
+ resultObject.rank = "B";
1364
+ break;
1365
+ case hit300Ratio > 0.6:
1366
+ resultObject.rank = "C";
1367
+ break;
1368
+ default:
1369
+ resultObject.rank = "D";
1370
+ }
1371
+ }
1372
+ if (resultObject.replayVersion >= 4) {
1373
+ const s = rawObject[7].split("|");
1374
+ resultObject.speedModification =
1375
+ parseFloat(s[0].replace("x", "")) || 1;
1376
+ if (s.length > 1) {
1377
+ resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
1378
+ }
1379
+ }
1380
+ let bufferIndex;
1381
+ switch (true) {
1382
+ // replay v4 and above
1383
+ case resultObject.replayVersion >= 4:
1384
+ bufferIndex = 8;
1385
+ break;
1386
+ // replay v3
1387
+ case resultObject.replayVersion === 3:
1388
+ bufferIndex = 7;
1389
+ break;
1390
+ // replay v1 and v2
1391
+ default:
1392
+ bufferIndex = 4;
1393
+ }
1394
+ const replayDataBufferArray = [];
1395
+ for (bufferIndex; bufferIndex < rawObject.length; ++bufferIndex) {
1396
+ replayDataBufferArray.push(rawObject[bufferIndex]);
1397
+ }
1398
+ // Merge all cursor movement and hit object data section into one for better control when parsing
1399
+ const replayDataBuffer = Buffer.concat(replayDataBufferArray);
1400
+ let bufferCounter = 0;
1401
+ const size = replayDataBuffer.readInt32BE(bufferCounter);
1402
+ bufferCounter += this.INT_LENGTH;
1403
+ // Parse movement data
1404
+ for (let x = 0; x < size; x++) {
1405
+ const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
1406
+ bufferCounter += this.INT_LENGTH;
1407
+ const moveArray = {
1408
+ size: moveSize,
1409
+ time: [],
1410
+ x: [],
1411
+ y: [],
1412
+ id: [],
1413
+ };
1414
+ for (let i = 0; i < moveSize; i++) {
1415
+ moveArray.time[i] = replayDataBuffer.readInt32BE(bufferCounter);
1416
+ bufferCounter += this.INT_LENGTH;
1417
+ moveArray.id[i] = moveArray.time[i] & 3;
1418
+ moveArray.time[i] >>= 2;
1419
+ if (moveArray.id[i] !== exports.movementType.UP) {
1420
+ if (resultObject.replayVersion >= 5) {
1421
+ moveArray.x[i] =
1422
+ replayDataBuffer.readFloatBE(bufferCounter);
1423
+ bufferCounter += this.FLOAT_LENGTH;
1424
+ moveArray.y[i] =
1425
+ replayDataBuffer.readFloatBE(bufferCounter);
1426
+ bufferCounter += this.FLOAT_LENGTH;
1427
+ }
1428
+ else {
1429
+ moveArray.x[i] =
1430
+ replayDataBuffer.readInt16BE(bufferCounter);
1431
+ bufferCounter += this.SHORT_LENGTH;
1432
+ moveArray.y[i] =
1433
+ replayDataBuffer.readInt16BE(bufferCounter);
1434
+ bufferCounter += this.SHORT_LENGTH;
1435
+ }
1436
+ }
1437
+ else {
1438
+ moveArray.x[i] = -1;
1439
+ moveArray.y[i] = -1;
1440
+ }
1441
+ }
1442
+ resultObject.cursorMovement.push(moveArray);
1443
+ }
1444
+ const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
1445
+ bufferCounter += this.INT_LENGTH;
1446
+ // Parse result data
1447
+ for (let i = 0; i < replayObjectLength; i++) {
1448
+ const replayObjectData = {
1449
+ accuracy: 0,
1450
+ tickset: [],
1451
+ result: 0,
1452
+ };
1453
+ replayObjectData.accuracy =
1454
+ replayDataBuffer.readInt16BE(bufferCounter);
1455
+ bufferCounter += this.SHORT_LENGTH;
1456
+ const len = replayDataBuffer.readInt8(bufferCounter);
1457
+ bufferCounter += this.BYTE_LENGTH;
1458
+ if (len > 0) {
1459
+ const bytes = [];
1460
+ for (let j = 0; j < len; j++) {
1461
+ bytes.push(replayDataBuffer.readInt8(bufferCounter));
1462
+ bufferCounter += this.BYTE_LENGTH;
1463
+ }
1464
+ // Int/int division in Java; numbers must be truncated to get actual number
1465
+ for (let j = 0; j < len * 8; j++) {
1466
+ replayObjectData.tickset[j] =
1467
+ (bytes[len - Math.trunc(j / 8) - 1] &
1468
+ (1 << Math.trunc(j % 8))) !==
1469
+ 0;
1470
+ }
1471
+ }
1472
+ if (resultObject.replayVersion >= 1) {
1473
+ replayObjectData.result =
1474
+ replayDataBuffer.readInt8(bufferCounter);
1475
+ bufferCounter += this.BYTE_LENGTH;
1476
+ }
1477
+ resultObject.hitObjectData.push(replayObjectData);
1478
+ }
1479
+ // Parse max combo, hit results, and accuracy in old replay version
1480
+ if (resultObject.replayVersion < 3 && this.map) {
1481
+ let hit300 = 0;
1482
+ let hit300k = 0;
1483
+ let hit100 = 0;
1484
+ let hit100k = 0;
1485
+ let hit50 = 0;
1486
+ let hit0 = 0;
1487
+ let grantsGekiOrKatu = true;
1488
+ const objects = (this.map instanceof osuDifficultyCalculator.DroidStarRating ||
1489
+ this.map instanceof osuRebalanceDifficultyCalculator.DroidStarRating
1490
+ ? this.map.map
1491
+ : this.map).hitObjects.objects;
1492
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
1493
+ // Hit result
1494
+ const hitObjectData = resultObject.hitObjectData[i];
1495
+ const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
1496
+ switch (hitObjectData.result) {
1497
+ case exports.hitResult.RESULT_0:
1498
+ ++hit0;
1499
+ grantsGekiOrKatu = false;
1500
+ break;
1501
+ case exports.hitResult.RESULT_50:
1502
+ ++hit50;
1503
+ grantsGekiOrKatu = false;
1504
+ break;
1505
+ case exports.hitResult.RESULT_100:
1506
+ ++hit100;
1507
+ if (grantsGekiOrKatu && isNextNewCombo) {
1508
+ ++hit100k;
1509
+ }
1510
+ break;
1511
+ case exports.hitResult.RESULT_300:
1512
+ ++hit300;
1513
+ if (grantsGekiOrKatu && isNextNewCombo) {
1514
+ ++hit300k;
1515
+ }
1516
+ break;
1517
+ }
1518
+ if (isNextNewCombo) {
1519
+ grantsGekiOrKatu = true;
1520
+ }
1521
+ }
1522
+ resultObject.hit300k = hit300k;
1523
+ resultObject.hit100k = hit100k;
1524
+ resultObject.accuracy = new osuBase.Accuracy({
1525
+ n300: hit300,
1526
+ n100: hit100,
1527
+ n50: hit50,
1528
+ nmiss: hit0,
1529
+ nobjects: hit300 + hit100 + hit50 + hit0,
1530
+ });
1531
+ // Determine rank
1532
+ const totalHits = resultObject.accuracy.n300 +
1533
+ resultObject.accuracy.n100 +
1534
+ resultObject.accuracy.n50 +
1535
+ resultObject.accuracy.nmiss;
1536
+ const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
1537
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
1538
+ switch (true) {
1539
+ case resultObject.accuracy.value() === 1:
1540
+ if (isHidden) {
1541
+ resultObject.rank = "XH";
1542
+ }
1543
+ else {
1544
+ resultObject.rank = "X";
1545
+ }
1546
+ break;
1547
+ case hit300Ratio > 0.9 &&
1548
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
1549
+ !resultObject.accuracy.nmiss:
1550
+ if (isHidden) {
1551
+ resultObject.rank = "SH";
1552
+ }
1553
+ else {
1554
+ resultObject.rank = "S";
1555
+ }
1556
+ break;
1557
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
1558
+ hit300Ratio > 0.9:
1559
+ resultObject.rank = "A";
1560
+ break;
1561
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
1562
+ hit300Ratio > 0.8:
1563
+ resultObject.rank = "B";
1564
+ break;
1565
+ case hit300Ratio > 0.6:
1566
+ resultObject.rank = "C";
1567
+ break;
1568
+ default:
1569
+ resultObject.rank = "D";
1570
+ }
1571
+ }
1572
+ this.data = new ReplayData(resultObject);
1573
+ }
1574
+ /**
1575
+ * Gets hit error information of the replay.
1576
+ *
1577
+ * `analyze()` must be called before calling this.
1578
+ */
1579
+ calculateHitError() {
1580
+ if (!this.data || !this.map) {
1581
+ return null;
1582
+ }
1583
+ const hitObjectData = this.data.hitObjectData;
1584
+ let positiveCount = 0;
1585
+ let negativeCount = 0;
1586
+ let positiveTotal = 0;
1587
+ let negativeTotal = 0;
1588
+ const objects = (this.map instanceof osuDifficultyCalculator.DroidStarRating ||
1589
+ this.map instanceof osuRebalanceDifficultyCalculator.DroidStarRating
1590
+ ? this.map.map
1591
+ : this.map).hitObjects.objects;
1592
+ for (let i = 0; i < hitObjectData.length; ++i) {
1593
+ const v = hitObjectData[i];
1594
+ const o = objects[i];
1595
+ if (o instanceof osuBase.Spinner || v.result === exports.hitResult.RESULT_0) {
1596
+ continue;
1597
+ }
1598
+ const accuracy = v.accuracy;
1599
+ if (accuracy >= 0) {
1600
+ positiveTotal += accuracy;
1601
+ ++positiveCount;
1602
+ }
1603
+ else {
1604
+ negativeTotal += accuracy;
1605
+ ++negativeCount;
1606
+ }
1607
+ }
1608
+ return {
1609
+ positiveAvg: positiveTotal / positiveCount || 0,
1610
+ negativeAvg: negativeTotal / negativeCount || 0,
1611
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(hitObjectData.map((v, i) => v.result !== exports.hitResult.RESULT_0 &&
1612
+ !(objects[i] instanceof osuBase.Spinner)
1613
+ ? v.accuracy
1614
+ : 0)) * 10,
1615
+ };
1616
+ }
1617
+ /**
1618
+ * Converts replay mods to droid mod string.
1619
+ */
1620
+ convertDroidMods(replayMods) {
1621
+ const replayModsConstants = {
1622
+ MOD_NOFAIL: "n",
1623
+ MOD_EASY: "e",
1624
+ MOD_HIDDEN: "h",
1625
+ MOD_HARDROCK: "r",
1626
+ MOD_DOUBLETIME: "d",
1627
+ MOD_HALFTIME: "t",
1628
+ MOD_NIGHTCORE: "c",
1629
+ MOD_PRECISE: "s",
1630
+ MOD_SMALLCIRCLE: "m",
1631
+ MOD_SPEEDUP: "b",
1632
+ MOD_REALLYEASY: "l",
1633
+ MOD_PERFECT: "f",
1634
+ MOD_SUDDENDEATH: "u",
1635
+ MOD_SCOREV2: "v",
1636
+ };
1637
+ let modString = "";
1638
+ for (const mod of replayMods) {
1639
+ for (const property in replayModsConstants) {
1640
+ if (!(property in replayModsConstants)) {
1641
+ continue;
1642
+ }
1643
+ if (!mod.includes(property)) {
1644
+ continue;
1645
+ }
1646
+ modString +=
1647
+ replayModsConstants[property];
1648
+ break;
1649
+ }
1650
+ }
1651
+ return modString;
1652
+ }
1653
+ /**
1654
+ * Converts replay mods to regular mod string.
1655
+ */
1656
+ convertMods(replayMods) {
1657
+ return osuBase.ModUtil.droidStringToMods(this.convertDroidMods(replayMods));
1658
+ }
1659
+ /**
1660
+ * Checks if a play is using 3 fingers.
1661
+ *
1662
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
1663
+ */
1664
+ checkFor3Finger() {
1665
+ if (!(this.map instanceof osuDifficultyCalculator.DroidStarRating ||
1666
+ this.map instanceof osuRebalanceDifficultyCalculator.DroidStarRating) ||
1667
+ !this.data) {
1668
+ return;
1669
+ }
1670
+ const threeFingerChecker = new ThreeFingerChecker(this.map, this.data);
1671
+ const result = threeFingerChecker.check();
1672
+ this.is3Finger = result.is3Finger;
1673
+ this.tapPenalty = result.penalty;
1674
+ this.hasBeenCheckedFor3Finger = true;
1675
+ }
1676
+ /**
1677
+ * Checks if a play is using 2 hands.
1678
+ *
1679
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
1680
+ */
1681
+ checkFor2Hand() {
1682
+ if (!(this.map instanceof osuDifficultyCalculator.DroidStarRating ||
1683
+ this.map instanceof osuRebalanceDifficultyCalculator.DroidStarRating) ||
1684
+ !this.data) {
1685
+ return;
1686
+ }
1687
+ const twoHandChecker = new TwoHandChecker(this.map, this.data);
1688
+ this.is2Hand = twoHandChecker.check();
1689
+ this.hasBeenCheckedFor2Hand = true;
1690
+ }
1691
+ }
1692
+
1693
+ /**
1694
+ * Represents a hitobject in an osu!droid replay.
1695
+ *
1696
+ * Stores information about hitobjects in an osu!droid replay such as hit offset, tickset, and hit result.
1697
+ *
1698
+ * This is used when analyzing replays using replay analyzer.
1699
+ */
1700
+ class ReplayObjectData {
1701
+ /**
1702
+ * The offset of which the hitobject was hit in milliseconds.
1703
+ */
1704
+ accuracy;
1705
+ /**
1706
+ * The tickset of the hitobject.
1707
+ *
1708
+ * This is used to determine whether or not a slider event (tick/repeat/end) is hit based on the order they appear.
1709
+ */
1710
+ tickset;
1711
+ /**
1712
+ * The bitwise hit result of the hitobject.
1713
+ */
1714
+ result;
1715
+ constructor(values) {
1716
+ this.accuracy = values.accuracy;
1717
+ this.tickset = values.tickset;
1718
+ this.result = values.result;
1719
+ }
1720
+ }
1721
+
1722
+ exports.CursorData = CursorData;
1723
+ exports.ReplayAnalyzer = ReplayAnalyzer;
1724
+ exports.ReplayData = ReplayData;
1725
+ exports.ReplayObjectData = ReplayObjectData;
1726
+ exports.ThreeFingerChecker = ThreeFingerChecker;
1727
+ //# sourceMappingURL=index.js.map