@rian8337/osu-droid-replay-analyzer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,684 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ThreeFingerChecker = void 0;
4
+ const osu_base_1 = require("@rian8337/osu-base");
5
+ const __1 = require("..");
6
+ const CursorData_1 = require("../data/CursorData");
7
+ const BeatmapSectionGenerator_1 = require("./BeatmapSectionGenerator");
8
+ const ThreeFingerBeatmapSection_1 = require("./data/ThreeFingerBeatmapSection");
9
+ /**
10
+ * Utility to check whether or not a beatmap is three-fingered.
11
+ */
12
+ class ThreeFingerChecker {
13
+ /**
14
+ * @param map The beatmap to analyze.
15
+ * @param data The data of the replay.
16
+ */
17
+ constructor(map, data) {
18
+ /**
19
+ * The distance threshold between cursors to assume that two cursors are
20
+ * actually pressed with 1 finger in osu!pixels.
21
+ *
22
+ * This is used to prevent cases where a player would lift their finger
23
+ * too fast to the point where the 4th cursor instance or beyond is recorded
24
+ * as 1st, 2nd, or 3rd cursor instance.
25
+ */
26
+ this.cursorDistancingDistanceThreshold = 60;
27
+ /**
28
+ * The threshold for the amount of cursors that are assumed to be pressed
29
+ * by a single finger.
30
+ */
31
+ this.cursorDistancingCountThreshold = 10;
32
+ /**
33
+ * The threshold for the time difference of cursors that are assumed to be pressed
34
+ * by a single finger, in milliseconds.
35
+ */
36
+ this.cursorDistancingTimeThreshold = 1000;
37
+ /**
38
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
39
+ *
40
+ * Increasing this number will increase detection accuracy, however
41
+ * it also increases the chance of falsely flagged plays.
42
+ */
43
+ this.threeFingerRatioThreshold = 0.01;
44
+ /**
45
+ * The maximum delta time allowed between two beatmap sections.
46
+ *
47
+ * Increasing this number decreases the amount of beatmap sections in general.
48
+ *
49
+ * Note that this value does not account for the speed multiplier of
50
+ * the play, similar to the way replay object data is stored.
51
+ */
52
+ this.maxSectionDeltaTime = 2000;
53
+ /**
54
+ * The minimum object count required to make a beatmap section.
55
+ *
56
+ * Increasing this number decreases the amount of beatmap sections.
57
+ */
58
+ this.minSectionObjectCount = 5;
59
+ /**
60
+ * The sections of the beatmap that was cut based on `maxSectionDeltaTime` and `minSectionObjectCount`.
61
+ */
62
+ this.beatmapSections = [];
63
+ /**
64
+ * This threshold is used to filter out accidental taps.
65
+ *
66
+ * Increasing this number makes the filtration more sensitive, however it
67
+ * will also increase the chance of 3-fingered plays getting out from
68
+ * being flagged.
69
+ */
70
+ this.accidentalTapThreshold = 400;
71
+ /**
72
+ * A reprocessed break points to match right on object time.
73
+ *
74
+ * This is used to increase detection accuracy since break points do not start right at the
75
+ * start of the hitobject before it and do not end right at the first hitobject after it.
76
+ */
77
+ this.breakPointAccurateTimes = [];
78
+ /**
79
+ * A cursor data array that only contains `movementType.DOWN` movement ID occurrences.
80
+ */
81
+ this.downCursorInstances = [];
82
+ /**
83
+ * Nerf factors from all sections that were three-fingered.
84
+ */
85
+ this.nerfFactors = [];
86
+ this.map = map;
87
+ this.data = data;
88
+ const stats = new osu_base_1.MapStats({
89
+ od: this.map.map.od,
90
+ mods: this.map.mods.filter((m) => !osu_base_1.ModUtil.speedChangingMods
91
+ .map((v) => v.droidString)
92
+ .includes(m.droidString)),
93
+ }).calculate();
94
+ this.hitWindow = new osu_base_1.DroidHitWindow(stats.od);
95
+ const strainNotes = map.objects.filter((v) => v.originalTapStrain >= ThreeFingerChecker.strainThreshold);
96
+ this.strainNoteCount = strainNotes.length;
97
+ }
98
+ /**
99
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
100
+ */
101
+ static isEligibleToDetect(map) {
102
+ return map.objects.some((v) => v.originalTapStrain >= this.strainThreshold);
103
+ }
104
+ /**
105
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
106
+ *
107
+ * The beatmap will be separated into sections and each section will be determined
108
+ * whether or not it is dragged.
109
+ *
110
+ * After that, each section will be assigned a nerf factor based on whether or not
111
+ * the section is 3-fingered. These nerf factors will be summed up into a final
112
+ * nerf factor, taking beatmap difficulty into account.
113
+ */
114
+ check() {
115
+ if (this.strainNoteCount === 0) {
116
+ return { is3Finger: false, penalty: 1 };
117
+ }
118
+ this.getAccurateBreakPoints();
119
+ this.filterCursorInstances();
120
+ if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
121
+ return { is3Finger: false, penalty: 1 };
122
+ }
123
+ this.getBeatmapSections();
124
+ this.detectDragPlay();
125
+ this.getDetailedBeatmapSections();
126
+ this.preventAccidentalTaps();
127
+ if (this.downCursorInstances.filter((v) => v.size > 0).length <= 3) {
128
+ return { is3Finger: false, penalty: 1 };
129
+ }
130
+ this.calculateNerfFactors();
131
+ const finalPenalty = this.calculateFinalPenalty();
132
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
133
+ }
134
+ /**
135
+ * Generates a new set of "accurate break points".
136
+ *
137
+ * This is done to increase detection accuracy since break points do not start right at the
138
+ * start of the hitobject before it and do not end right at the first hitobject after it.
139
+ */
140
+ getAccurateBreakPoints() {
141
+ const objects = this.map.objects;
142
+ const objectData = this.data.hitObjectData;
143
+ const isPrecise = this.map.mods.some((m) => m instanceof osu_base_1.ModPrecise);
144
+ for (const breakPoint of this.map.map.breakPoints) {
145
+ const beforeIndex = osu_base_1.MathUtils.clamp(objects.findIndex((o) => o.object.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
146
+ let timeBefore = objects[beforeIndex].object.endTime;
147
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
148
+ let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
149
+ switch (objectData[beforeIndex].result) {
150
+ case __1.hitResult.RESULT_300:
151
+ beforeIndexHitWindowLength =
152
+ this.hitWindow.hitWindowFor300(isPrecise);
153
+ break;
154
+ case __1.hitResult.RESULT_100:
155
+ beforeIndexHitWindowLength =
156
+ this.hitWindow.hitWindowFor100(isPrecise);
157
+ break;
158
+ default:
159
+ beforeIndexHitWindowLength =
160
+ this.hitWindow.hitWindowFor50(isPrecise);
161
+ }
162
+ timeBefore += beforeIndexHitWindowLength;
163
+ const afterIndex = beforeIndex + 1;
164
+ let timeAfter = objects[afterIndex].object.startTime;
165
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
166
+ let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
167
+ switch (objectData[afterIndex].result) {
168
+ case __1.hitResult.RESULT_300:
169
+ afterIndexHitWindowLength =
170
+ this.hitWindow.hitWindowFor300(isPrecise);
171
+ break;
172
+ case __1.hitResult.RESULT_100:
173
+ afterIndexHitWindowLength =
174
+ this.hitWindow.hitWindowFor100(isPrecise);
175
+ break;
176
+ default:
177
+ afterIndexHitWindowLength =
178
+ this.hitWindow.hitWindowFor50(isPrecise);
179
+ }
180
+ timeAfter += afterIndexHitWindowLength;
181
+ this.breakPointAccurateTimes.push({
182
+ startTime: timeBefore,
183
+ endTime: timeAfter,
184
+ });
185
+ }
186
+ }
187
+ /**
188
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
189
+ *
190
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
191
+ */
192
+ filterCursorInstances() {
193
+ const objects = this.map.objects;
194
+ const objectData = this.data.hitObjectData;
195
+ const firstObjectResult = objectData[0].result;
196
+ const lastObjectResult = objectData.at(-1).result;
197
+ const isPrecise = this.map.mods.some((m) => m instanceof osu_base_1.ModPrecise);
198
+ // For sliders, automatically set hit window length to be as lenient as possible.
199
+ let firstObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
200
+ if (objects[0].object instanceof osu_base_1.Circle) {
201
+ switch (firstObjectResult) {
202
+ case __1.hitResult.RESULT_300:
203
+ firstObjectHitWindow =
204
+ this.hitWindow.hitWindowFor300(isPrecise);
205
+ break;
206
+ case __1.hitResult.RESULT_100:
207
+ firstObjectHitWindow =
208
+ this.hitWindow.hitWindowFor100(isPrecise);
209
+ break;
210
+ default:
211
+ firstObjectHitWindow =
212
+ this.hitWindow.hitWindowFor50(isPrecise);
213
+ }
214
+ }
215
+ // For sliders, automatically set hit window length to be as lenient as possible.
216
+ let lastObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
217
+ if (objects.at(-1).object instanceof osu_base_1.Circle) {
218
+ switch (lastObjectResult) {
219
+ case __1.hitResult.RESULT_300:
220
+ lastObjectHitWindow =
221
+ this.hitWindow.hitWindowFor300(isPrecise);
222
+ break;
223
+ case __1.hitResult.RESULT_100:
224
+ lastObjectHitWindow =
225
+ this.hitWindow.hitWindowFor100(isPrecise);
226
+ break;
227
+ default:
228
+ lastObjectHitWindow =
229
+ this.hitWindow.hitWindowFor50(isPrecise);
230
+ }
231
+ }
232
+ // These hit time uses hit window length as threshold.
233
+ // This is because cursors aren't recorded exactly at hit time,
234
+ // probably due to the game's behavior.
235
+ const firstObjectHitTime = objects[0].object.startTime - firstObjectHitWindow;
236
+ const lastObjectHitTime = objects.at(-1).object.startTime + lastObjectHitWindow;
237
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
238
+ const cursorInstance = this.data.cursorMovement[i];
239
+ const newCursorData = new CursorData_1.CursorData({
240
+ size: 0,
241
+ time: [],
242
+ x: [],
243
+ y: [],
244
+ id: [],
245
+ });
246
+ for (let j = 0; j < cursorInstance.size; ++j) {
247
+ if (cursorInstance.id[j] !== __1.movementType.DOWN) {
248
+ continue;
249
+ }
250
+ const time = cursorInstance.time[j];
251
+ if (time < firstObjectHitTime || time > lastObjectHitTime) {
252
+ continue;
253
+ }
254
+ if (this.breakPointAccurateTimes.some((v) => time >= v.startTime && time <= v.endTime)) {
255
+ continue;
256
+ }
257
+ ++newCursorData.size;
258
+ newCursorData.time.push(time);
259
+ newCursorData.x.push(cursorInstance.x[j]);
260
+ newCursorData.y.push(cursorInstance.y[j]);
261
+ newCursorData.id.push(cursorInstance.id[j]);
262
+ }
263
+ this.downCursorInstances.push(newCursorData);
264
+ }
265
+ }
266
+ /**
267
+ * Divides the beatmap into sections, which will be used to
268
+ * detect dragged sections and improve detection speed.
269
+ */
270
+ getBeatmapSections() {
271
+ const beatmapSections = BeatmapSectionGenerator_1.BeatmapSectionGenerator.generateSections(this.map, this.minSectionObjectCount, this.maxSectionDeltaTime);
272
+ for (const beatmapSection of beatmapSections) {
273
+ this.beatmapSections.push(new ThreeFingerBeatmapSection_1.ThreeFingerBeatmapSection({
274
+ firstObjectIndex: beatmapSection.firstObjectIndex,
275
+ lastObjectIndex: beatmapSection.lastObjectIndex,
276
+ isDragged: false,
277
+ dragFingerIndex: -1,
278
+ }));
279
+ }
280
+ }
281
+ /**
282
+ * Checks whether or not each beatmap sections is dragged.
283
+ */
284
+ detectDragPlay() {
285
+ for (let i = 0; i < this.beatmapSections.length; ++i) {
286
+ const dragIndex = this.checkDrag(this.beatmapSections[i]);
287
+ this.beatmapSections[i].dragFingerIndex = dragIndex;
288
+ this.beatmapSections[i].isDragged = dragIndex !== -1;
289
+ }
290
+ }
291
+ /**
292
+ * Checks if a section is dragged and returns the index of the drag finger.
293
+ *
294
+ * If the section is not dragged, -1 will be returned.
295
+ *
296
+ * @param section The section to check.
297
+ */
298
+ checkDrag(section) {
299
+ const objects = this.map.objects;
300
+ const objectData = this.data.hitObjectData;
301
+ const isPrecise = this.map.mods.some((m) => m instanceof osu_base_1.ModPrecise);
302
+ const firstObject = objects[section.firstObjectIndex];
303
+ const lastObject = objects[section.lastObjectIndex];
304
+ let firstObjectMinHitTime = firstObject.object.startTime;
305
+ if (firstObject.object instanceof osu_base_1.Circle) {
306
+ switch (objectData[section.firstObjectIndex].result) {
307
+ case __1.hitResult.RESULT_300:
308
+ firstObjectMinHitTime -=
309
+ this.hitWindow.hitWindowFor300(isPrecise);
310
+ break;
311
+ case __1.hitResult.RESULT_100:
312
+ firstObjectMinHitTime -=
313
+ this.hitWindow.hitWindowFor100(isPrecise);
314
+ break;
315
+ default:
316
+ firstObjectMinHitTime -=
317
+ this.hitWindow.hitWindowFor50(isPrecise);
318
+ }
319
+ }
320
+ else {
321
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(isPrecise);
322
+ }
323
+ let lastObjectMaxHitTime = lastObject.object.startTime;
324
+ if (lastObject.object instanceof osu_base_1.Circle) {
325
+ switch (objectData[section.lastObjectIndex].result) {
326
+ case __1.hitResult.RESULT_300:
327
+ lastObjectMaxHitTime +=
328
+ this.hitWindow.hitWindowFor300(isPrecise);
329
+ break;
330
+ case __1.hitResult.RESULT_100:
331
+ lastObjectMaxHitTime +=
332
+ this.hitWindow.hitWindowFor100(isPrecise);
333
+ break;
334
+ default:
335
+ lastObjectMaxHitTime +=
336
+ this.hitWindow.hitWindowFor50(isPrecise);
337
+ }
338
+ }
339
+ else {
340
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(isPrecise);
341
+ }
342
+ // Since there may be more than 1 cursor instance index,
343
+ // we check which cursor instance follows hitobjects all over.
344
+ const cursorIndexes = [];
345
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
346
+ const c = this.data.cursorMovement[i];
347
+ if (c.size === 0) {
348
+ continue;
349
+ }
350
+ // Do not include cursors that don't have an occurence in this section
351
+ // this speeds up checking process.
352
+ if (c.time.filter((v) => v >= firstObjectMinHitTime && v <= lastObjectMaxHitTime).length === 0) {
353
+ continue;
354
+ }
355
+ // If this cursor instance doesn't move, it's not the cursor instance we want.
356
+ if (c.id.filter((v) => v === __1.movementType.MOVE).length === 0) {
357
+ continue;
358
+ }
359
+ cursorIndexes.push(i);
360
+ }
361
+ return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
362
+ }
363
+ /**
364
+ * Finds the drag index of the section.
365
+ *
366
+ * @param sectionObjects The objects in the section.
367
+ * @param sectionReplayObjectData The hitobject data of all objects in the section.
368
+ * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
369
+ */
370
+ findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
371
+ let objectIndex = sectionObjects.findIndex((v, i) => !(v.object instanceof osu_base_1.Spinner) &&
372
+ sectionReplayObjectData[i].result !== __1.hitResult.RESULT_0);
373
+ if (objectIndex === -1) {
374
+ return -1;
375
+ }
376
+ while (cursorIndexes.length > 0) {
377
+ if (objectIndex === sectionObjects.length) {
378
+ break;
379
+ }
380
+ const o = sectionObjects[objectIndex];
381
+ const s = sectionReplayObjectData[objectIndex];
382
+ ++objectIndex;
383
+ if (s.result === __1.hitResult.RESULT_0) {
384
+ continue;
385
+ }
386
+ // Get the cursor instance that is closest to the object's hit time.
387
+ for (let j = 0; j < cursorIndexes.length; ++j) {
388
+ const c = this.data.cursorMovement[cursorIndexes[j]];
389
+ // Cursor instances aren't always recorded at all times,
390
+ // therefore the game emulates the movement between
391
+ // movementType.MOVE cursors.
392
+ const hitTime = o.object.startTime + s.accuracy;
393
+ const nextHitIndex = c.time.findIndex((v) => v >= hitTime);
394
+ const hitIndex = nextHitIndex - 1;
395
+ if (hitIndex <= -1) {
396
+ cursorIndexes[j] = -1;
397
+ continue;
398
+ }
399
+ if (c.id[hitIndex] === __1.movementType.UP) {
400
+ cursorIndexes[j] = -1;
401
+ continue;
402
+ }
403
+ const cursorPosition = new osu_base_1.Vector2(c.x[hitIndex], c.y[hitIndex]);
404
+ let isInObject = false;
405
+ if (c.id[nextHitIndex] === __1.movementType.MOVE ||
406
+ c.id[hitIndex] === __1.movementType.MOVE) {
407
+ // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
408
+ // This minimizes rounding error.
409
+ for (let mSecPassed = c.time[hitIndex]; mSecPassed <= c.time[nextHitIndex]; ++mSecPassed) {
410
+ const t = (mSecPassed - c.time[nextHitIndex]) /
411
+ (c.time[hitIndex] - c.time[nextHitIndex]);
412
+ cursorPosition.x = osu_base_1.Interpolation.lerp(c.x[hitIndex], c.x[nextHitIndex], t);
413
+ cursorPosition.y = osu_base_1.Interpolation.lerp(c.y[hitIndex], c.y[nextHitIndex], t);
414
+ if (o.object.stackedPosition.getDistance(cursorPosition) <= o.object.radius) {
415
+ isInObject = true;
416
+ break;
417
+ }
418
+ }
419
+ }
420
+ else {
421
+ isInObject =
422
+ o.object.stackedPosition.getDistance(cursorPosition) <=
423
+ o.object.radius;
424
+ }
425
+ if (!isInObject) {
426
+ cursorIndexes[j] = -1;
427
+ }
428
+ }
429
+ cursorIndexes = cursorIndexes.filter((v) => v !== -1);
430
+ }
431
+ return cursorIndexes.shift() ?? -1;
432
+ }
433
+ /**
434
+ * Redivides the beatmap into sections.
435
+ *
436
+ * The result will be used to detect for three-fingered
437
+ * sections.
438
+ */
439
+ getDetailedBeatmapSections() {
440
+ const objects = this.map.objects;
441
+ const newBeatmapSections = [];
442
+ for (const beatmapSection of this.beatmapSections) {
443
+ let inSpeedSection = false;
444
+ let newFirstObjectIndex = beatmapSection.firstObjectIndex;
445
+ for (let i = beatmapSection.firstObjectIndex; i <= beatmapSection.lastObjectIndex; ++i) {
446
+ if (!inSpeedSection &&
447
+ objects[i].originalTapStrain >=
448
+ ThreeFingerChecker.strainThreshold) {
449
+ inSpeedSection = true;
450
+ newFirstObjectIndex = i;
451
+ continue;
452
+ }
453
+ if (inSpeedSection &&
454
+ objects[i].originalTapStrain <
455
+ ThreeFingerChecker.strainThreshold) {
456
+ inSpeedSection = false;
457
+ newBeatmapSections.push({
458
+ firstObjectIndex: newFirstObjectIndex,
459
+ lastObjectIndex: i,
460
+ isDragged: beatmapSection.isDragged,
461
+ dragFingerIndex: beatmapSection.dragFingerIndex,
462
+ });
463
+ }
464
+ }
465
+ // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
466
+ if (inSpeedSection) {
467
+ newBeatmapSections.push({
468
+ firstObjectIndex: newFirstObjectIndex,
469
+ lastObjectIndex: beatmapSection.lastObjectIndex,
470
+ isDragged: beatmapSection.isDragged,
471
+ dragFingerIndex: beatmapSection.dragFingerIndex,
472
+ });
473
+ }
474
+ }
475
+ this.beatmapSections.length = 0;
476
+ this.beatmapSections.push(...newBeatmapSections);
477
+ }
478
+ /**
479
+ * Attempts to prevent accidental taps from being flagged.
480
+ *
481
+ * This detection will filter cursors that don't hit
482
+ * any object in beatmap sections, thus eliminating any
483
+ * unnecessary taps.
484
+ */
485
+ preventAccidentalTaps() {
486
+ let filledCursorAmount = this.downCursorInstances.filter((v) => v.size > 0).length;
487
+ if (filledCursorAmount <= 3) {
488
+ return;
489
+ }
490
+ const objects = this.map.objects;
491
+ const totalCursorAmount = this.downCursorInstances
492
+ .map((v) => {
493
+ return v.size;
494
+ })
495
+ .reduce((acc, value) => acc + value, 0);
496
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
497
+ if (filledCursorAmount <= 3) {
498
+ break;
499
+ }
500
+ const cursorInstance = this.downCursorInstances[i];
501
+ // Use an estimation for accidental tap threshold.
502
+ if (cursorInstance.size <=
503
+ Math.ceil(objects.length / this.accidentalTapThreshold) &&
504
+ cursorInstance.size / totalCursorAmount <
505
+ this.threeFingerRatioThreshold * 2) {
506
+ --filledCursorAmount;
507
+ for (const property in cursorInstance) {
508
+ const prop = property;
509
+ if (Array.isArray(cursorInstance[prop])) {
510
+ cursorInstance[prop].length = 0;
511
+ }
512
+ else {
513
+ cursorInstance[prop] = 0;
514
+ }
515
+ }
516
+ }
517
+ this.downCursorInstances[i] = cursorInstance;
518
+ }
519
+ }
520
+ /**
521
+ * Creates nerf factors by scanning through objects.
522
+ *
523
+ * This check will ignore all objects with speed strain below `strainThreshold`.
524
+ */
525
+ calculateNerfFactors() {
526
+ const objects = this.map.objects;
527
+ const objectData = this.data.hitObjectData;
528
+ const isPrecise = this.data.convertedMods.some((m) => m instanceof osu_base_1.ModPrecise);
529
+ // We only filter cursor instances that are above the strain threshold.
530
+ // This minimalizes the amount of cursor instances to analyze.
531
+ for (const beatmapSection of this.beatmapSections) {
532
+ const dragIndex = beatmapSection.dragFingerIndex;
533
+ const startTime = objects[beatmapSection.firstObjectIndex].object.startTime +
534
+ (objectData[beatmapSection.firstObjectIndex].result !==
535
+ __1.hitResult.RESULT_0
536
+ ? objectData[beatmapSection.firstObjectIndex].accuracy
537
+ : -this.hitWindow.hitWindowFor50(isPrecise));
538
+ const endTime = objects[beatmapSection.lastObjectIndex].object.endTime +
539
+ (objectData[beatmapSection.lastObjectIndex].result !==
540
+ __1.hitResult.RESULT_0
541
+ ? objectData[beatmapSection.lastObjectIndex].accuracy
542
+ : this.hitWindow.hitWindowFor50(isPrecise));
543
+ // Filter cursor instances during section.
544
+ this.downCursorInstances.forEach((c) => {
545
+ const i = c.time.findIndex((t) => t >= startTime);
546
+ if (i !== -1) {
547
+ c.size -= i;
548
+ c.time.splice(0, i);
549
+ c.x.splice(0, i);
550
+ c.y.splice(0, i);
551
+ c.id.splice(0, i);
552
+ }
553
+ });
554
+ const cursorAmounts = [];
555
+ const cursorVectorTimes = [];
556
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
557
+ // Do not include drag cursor instance.
558
+ if (i === dragIndex) {
559
+ continue;
560
+ }
561
+ const cursorData = this.downCursorInstances[i];
562
+ let amount = 0;
563
+ for (let j = 0; j < cursorData.size; ++j) {
564
+ if (cursorData.time[j] >= startTime &&
565
+ cursorData.time[j] <= endTime) {
566
+ ++amount;
567
+ cursorVectorTimes.push({
568
+ vector: new osu_base_1.Vector2(cursorData.x[j], cursorData.y[j]),
569
+ time: cursorData.time[j],
570
+ });
571
+ }
572
+ }
573
+ cursorAmounts.push(amount);
574
+ }
575
+ // This index will be used to detect if a section is 3-fingered.
576
+ // If the section is dragged, the dragged instance will be ignored,
577
+ // hence why the index is 1 less than nondragged section.
578
+ const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
579
+ // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
580
+ // to check if the section is 3-fingered.
581
+ const threeFingerRatio = cursorAmounts
582
+ .slice(fingerSplitIndex)
583
+ .reduce((acc, value) => acc + value, 0) /
584
+ cursorAmounts
585
+ .slice(0, fingerSplitIndex)
586
+ .reduce((acc, value) => acc + value, 0);
587
+ const similarPresses = [];
588
+ for (const cursorVectorTime of cursorVectorTimes) {
589
+ const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
590
+ this.cursorDistancingDistanceThreshold);
591
+ if (pressIndex !== -1) {
592
+ if (cursorVectorTime.time -
593
+ similarPresses[pressIndex].lastTime >=
594
+ this.cursorDistancingTimeThreshold) {
595
+ similarPresses.splice(pressIndex, 1);
596
+ similarPresses.push({
597
+ vector: cursorVectorTime.vector,
598
+ count: 1,
599
+ lastTime: cursorVectorTime.time,
600
+ });
601
+ continue;
602
+ }
603
+ similarPresses[pressIndex].vector = cursorVectorTime.vector;
604
+ similarPresses[pressIndex].lastTime = cursorVectorTime.time;
605
+ ++similarPresses[pressIndex].count;
606
+ }
607
+ else {
608
+ similarPresses.push({
609
+ vector: cursorVectorTime.vector,
610
+ count: 1,
611
+ lastTime: cursorVectorTime.time,
612
+ });
613
+ }
614
+ }
615
+ // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
616
+ const validPresses = similarPresses
617
+ .filter((v) => v.count >= this.cursorDistancingCountThreshold)
618
+ .sort((a, b) => {
619
+ return b.count - a.count;
620
+ })
621
+ .slice(2);
622
+ // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
623
+ if ((threeFingerRatio > this.threeFingerRatioThreshold &&
624
+ cursorAmounts.filter((v) => v > 1).length > 3) ||
625
+ validPresses.length > 0) {
626
+ // Strain factor
627
+ const objectCount = beatmapSection.lastObjectIndex -
628
+ beatmapSection.firstObjectIndex +
629
+ 1;
630
+ const strainFactor = Math.pow(objects
631
+ .slice(beatmapSection.firstObjectIndex, beatmapSection.lastObjectIndex)
632
+ .map((v) => {
633
+ return v.originalTapStrain;
634
+ })
635
+ .sort((a, b) => {
636
+ return b - a;
637
+ })
638
+ .reduce((acc, value) => acc +
639
+ value / ThreeFingerChecker.strainThreshold, 0), 0.75);
640
+ // We can ignore the first 3 (2 for drag) filled cursor instances
641
+ // since they are guaranteed not 3 finger.
642
+ const threeFingerCursorAmounts = cursorAmounts
643
+ .slice(fingerSplitIndex)
644
+ .filter((amount) => amount > 0);
645
+ // Finger factor applies more penalty if more fingers were used.
646
+ const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
647
+ ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
648
+ Math.pow(((index + 1) * value * objectCount) /
649
+ this.strainNoteCount, 0.8), 1)
650
+ : Math.pow(validPresses.reduce((acc, value, index) => acc +
651
+ Math.pow(((index + 1) *
652
+ (value.count /
653
+ (this
654
+ .cursorDistancingCountThreshold *
655
+ 2)) *
656
+ objectCount) /
657
+ this.strainNoteCount, 0.2), 1), 0.2);
658
+ // Length factor applies more penalty if there are more 3-fingered object.
659
+ const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
660
+ this.nerfFactors.push({
661
+ strainFactor: Math.max(1, strainFactor),
662
+ fingerFactor,
663
+ lengthFactor,
664
+ });
665
+ }
666
+ }
667
+ }
668
+ /**
669
+ * Calculates the final penalty.
670
+ */
671
+ calculateFinalPenalty() {
672
+ return (1 +
673
+ this.nerfFactors.reduce((a, n) => a +
674
+ 0.015 *
675
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
676
+ }
677
+ }
678
+ exports.ThreeFingerChecker = ThreeFingerChecker;
679
+ /**
680
+ * The strain threshold to start detecting for 3-fingered section.
681
+ *
682
+ * Increasing this number will result in less sections being flagged.
683
+ */
684
+ ThreeFingerChecker.strainThreshold = 175;