@rian8337/osu-droid-replay-analyzer 4.0.0-beta.2 → 4.0.0-beta.20
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 +1049 -757
- package/package.json +13 -12
- package/typings/index.d.ts +201 -94
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -51,18 +51,6 @@ exports.MovementType = void 0;
|
|
|
51
51
|
* Represents a cursor's occurrence.
|
|
52
52
|
*/
|
|
53
53
|
class CursorOccurrence {
|
|
54
|
-
/**
|
|
55
|
-
* The time of this occurrence.
|
|
56
|
-
*/
|
|
57
|
-
time;
|
|
58
|
-
/**
|
|
59
|
-
* The position of the occurrence.
|
|
60
|
-
*/
|
|
61
|
-
position;
|
|
62
|
-
/**
|
|
63
|
-
* The movement ID of the occurrence.
|
|
64
|
-
*/
|
|
65
|
-
id;
|
|
66
54
|
constructor(time, x, y, id) {
|
|
67
55
|
this.time = time;
|
|
68
56
|
this.position = new osuBase.Vector2(x, y);
|
|
@@ -125,7 +113,8 @@ class CursorOccurrenceGroup {
|
|
|
125
113
|
* The time at which this cursor occurrence group ends.
|
|
126
114
|
*/
|
|
127
115
|
get endTime() {
|
|
128
|
-
|
|
116
|
+
var _a, _b, _c, _d;
|
|
117
|
+
return (_d = (_b = (_a = this._up) === null || _a === void 0 ? void 0 : _a.time) !== null && _b !== void 0 ? _b : (_c = this._moves.at(-1)) === null || _c === void 0 ? void 0 : _c.time) !== null && _d !== void 0 ? _d : this._down.time;
|
|
129
118
|
}
|
|
130
119
|
/**
|
|
131
120
|
* The duration this cursor occurrence group is active for.
|
|
@@ -145,20 +134,6 @@ class CursorOccurrenceGroup {
|
|
|
145
134
|
}
|
|
146
135
|
return cursors;
|
|
147
136
|
}
|
|
148
|
-
/**
|
|
149
|
-
* The cursor occurrence of movement type `movementType.DOWN`.
|
|
150
|
-
*/
|
|
151
|
-
_down;
|
|
152
|
-
/**
|
|
153
|
-
* The cursor occurrences of movement type `movementType.MOVE`.
|
|
154
|
-
*/
|
|
155
|
-
_moves;
|
|
156
|
-
/**
|
|
157
|
-
* The cursor occurrence of movement type `movementType.UP`.
|
|
158
|
-
*
|
|
159
|
-
* May not exist, such as when the player holds their cursor until the end of a beatmap.
|
|
160
|
-
*/
|
|
161
|
-
_up;
|
|
162
137
|
constructor(down, moves, up) {
|
|
163
138
|
this._down = down;
|
|
164
139
|
this._moves = moves;
|
|
@@ -182,13 +157,14 @@ class CursorOccurrenceGroup {
|
|
|
182
157
|
* @returns The cursor occurrence at the given time, `null` if not found.
|
|
183
158
|
*/
|
|
184
159
|
cursorAt(time) {
|
|
160
|
+
var _a;
|
|
185
161
|
if (!this.isActiveAt(time)) {
|
|
186
162
|
return null;
|
|
187
163
|
}
|
|
188
164
|
if (this._down.time === time) {
|
|
189
165
|
return this._down;
|
|
190
166
|
}
|
|
191
|
-
if (this._up
|
|
167
|
+
if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
|
|
192
168
|
return this._up;
|
|
193
169
|
}
|
|
194
170
|
let l = 0;
|
|
@@ -218,17 +194,14 @@ class CursorOccurrenceGroup {
|
|
|
218
194
|
* This is used when analyzing replays using replay analyzer.
|
|
219
195
|
*/
|
|
220
196
|
class CursorData {
|
|
221
|
-
/**
|
|
222
|
-
* The occurrence groups of this cursor instance.
|
|
223
|
-
*/
|
|
224
|
-
occurrenceGroups = [];
|
|
225
197
|
/**
|
|
226
198
|
* The time at which the first occurrence of this cursor instance occurs.
|
|
227
199
|
*
|
|
228
200
|
* Will return `null` if there are no occurrences.
|
|
229
201
|
*/
|
|
230
202
|
get earliestOccurrenceTime() {
|
|
231
|
-
|
|
203
|
+
var _a, _b;
|
|
204
|
+
return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
|
|
232
205
|
}
|
|
233
206
|
/**
|
|
234
207
|
* The time at which the latest occurrence of this cursor instance occurs.
|
|
@@ -236,7 +209,8 @@ class CursorData {
|
|
|
236
209
|
* Will return `null` if there are no occurrences.
|
|
237
210
|
*/
|
|
238
211
|
get latestOccurrenceTime() {
|
|
239
|
-
|
|
212
|
+
var _a, _b;
|
|
213
|
+
return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
|
|
240
214
|
}
|
|
241
215
|
/**
|
|
242
216
|
* The amount of cursor occurrences of this cursor instance.
|
|
@@ -263,6 +237,10 @@ class CursorData {
|
|
|
263
237
|
return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
|
|
264
238
|
}
|
|
265
239
|
constructor(values) {
|
|
240
|
+
/**
|
|
241
|
+
* The occurrence groups of this cursor instance.
|
|
242
|
+
*/
|
|
243
|
+
this.occurrenceGroups = [];
|
|
266
244
|
let downOccurrence = null;
|
|
267
245
|
let moveOccurrences = [];
|
|
268
246
|
for (let i = 0; i < values.size; ++i) {
|
|
@@ -312,6 +290,502 @@ exports.HitResult = void 0;
|
|
|
312
290
|
HitResult[HitResult["great"] = 4] = "great";
|
|
313
291
|
})(exports.HitResult || (exports.HitResult = {}));
|
|
314
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Utility to check whether or not a beatmap is three-fingered for rebalance scores.
|
|
295
|
+
*/
|
|
296
|
+
class RebalanceThreeFingerChecker {
|
|
297
|
+
/**
|
|
298
|
+
* @param beatmap The beatmap to analyze.
|
|
299
|
+
* @param data The data of the replay.
|
|
300
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
301
|
+
*/
|
|
302
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
303
|
+
/**
|
|
304
|
+
* The ratio threshold between non-3 finger cursors and 3-finger cursors.
|
|
305
|
+
*
|
|
306
|
+
* Increasing this number will increase detection accuracy, however
|
|
307
|
+
* it also increases the chance of falsely flagged plays.
|
|
308
|
+
*/
|
|
309
|
+
this.threeFingerRatioThreshold = 0.01;
|
|
310
|
+
/**
|
|
311
|
+
* Extended sections of the beatmap for drag detection.
|
|
312
|
+
*/
|
|
313
|
+
this.beatmapSections = [];
|
|
314
|
+
/**
|
|
315
|
+
* A reprocessed break points to match right on object time.
|
|
316
|
+
*
|
|
317
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
318
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
319
|
+
*/
|
|
320
|
+
this.breakPointAccurateTimes = [];
|
|
321
|
+
/**
|
|
322
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
323
|
+
*
|
|
324
|
+
* Each index represents the cursor index.
|
|
325
|
+
*/
|
|
326
|
+
this.downCursorInstances = [];
|
|
327
|
+
/**
|
|
328
|
+
* Nerf factors from all sections that were three-fingered.
|
|
329
|
+
*/
|
|
330
|
+
this.nerfFactors = [];
|
|
331
|
+
this.beatmap = beatmap;
|
|
332
|
+
this.data = data;
|
|
333
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
334
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
335
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
336
|
+
mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
|
|
337
|
+
convertOverallDifficulty: false,
|
|
338
|
+
}).overallDifficulty;
|
|
339
|
+
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
340
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
341
|
+
this.hitObjects = beatmap.hitObjects.objects;
|
|
342
|
+
}
|
|
343
|
+
/**
|
|
344
|
+
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
345
|
+
*
|
|
346
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
347
|
+
*/
|
|
348
|
+
static isEligibleToDetect(difficultyAttributes) {
|
|
349
|
+
return difficultyAttributes.possibleThreeFingeredSections.length > 0;
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Checks if the given beatmap is 3-fingered and also returns the final penalty.
|
|
353
|
+
*
|
|
354
|
+
* The beatmap will be separated into sections and each section will be determined
|
|
355
|
+
* whether or not it is dragged.
|
|
356
|
+
*
|
|
357
|
+
* After that, each section will be assigned a nerf factor based on whether or not
|
|
358
|
+
* the section is 3-fingered. These nerf factors will be summed up into a final
|
|
359
|
+
* nerf factor, taking beatmap difficulty into account.
|
|
360
|
+
*/
|
|
361
|
+
check() {
|
|
362
|
+
if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
|
|
363
|
+
return { is3Finger: false, penalty: 1 };
|
|
364
|
+
}
|
|
365
|
+
this.getAccurateBreakPoints();
|
|
366
|
+
this.filterCursorInstances();
|
|
367
|
+
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
368
|
+
return { is3Finger: false, penalty: 1 };
|
|
369
|
+
}
|
|
370
|
+
this.getBeatmapSections();
|
|
371
|
+
this.calculateNerfFactors();
|
|
372
|
+
const finalPenalty = this.calculateFinalPenalty();
|
|
373
|
+
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Generates a new set of "accurate break points".
|
|
377
|
+
*
|
|
378
|
+
* This is done to increase detection accuracy since break points do not start right at the
|
|
379
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
380
|
+
*/
|
|
381
|
+
getAccurateBreakPoints() {
|
|
382
|
+
const objectData = this.data.hitObjectData;
|
|
383
|
+
for (const breakPoint of this.beatmap.events.breaks) {
|
|
384
|
+
const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
|
|
385
|
+
const objectBefore = this.hitObjects[beforeIndex];
|
|
386
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
387
|
+
let timeBefore = objectBefore.endTime;
|
|
388
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
389
|
+
if (objectBeforeData.result !== exports.HitResult.miss) {
|
|
390
|
+
timeBefore += objectBeforeData.accuracy;
|
|
391
|
+
}
|
|
392
|
+
else {
|
|
393
|
+
timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
const afterIndex = beforeIndex + 1;
|
|
397
|
+
const objectAfter = this.hitObjects[afterIndex];
|
|
398
|
+
const objectAfterData = objectData[afterIndex];
|
|
399
|
+
let timeAfter = this.hitObjects[afterIndex].startTime;
|
|
400
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
401
|
+
objectAfterData.result !== exports.HitResult.miss) {
|
|
402
|
+
timeAfter += objectAfterData.accuracy;
|
|
403
|
+
}
|
|
404
|
+
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
405
|
+
startTime: timeBefore,
|
|
406
|
+
endTime: timeAfter,
|
|
407
|
+
}));
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
|
|
412
|
+
*
|
|
413
|
+
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
414
|
+
*/
|
|
415
|
+
filterCursorInstances() {
|
|
416
|
+
const objectData = this.data.hitObjectData;
|
|
417
|
+
const firstObjectResult = objectData[0].result;
|
|
418
|
+
const lastObjectResult = objectData.at(-1).result;
|
|
419
|
+
const firstObject = this.hitObjects[0];
|
|
420
|
+
const lastObject = this.hitObjects.at(-1);
|
|
421
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
422
|
+
let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
423
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
424
|
+
switch (firstObjectResult) {
|
|
425
|
+
case exports.HitResult.great:
|
|
426
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
427
|
+
break;
|
|
428
|
+
case exports.HitResult.good:
|
|
429
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
430
|
+
break;
|
|
431
|
+
default:
|
|
432
|
+
firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
436
|
+
let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
437
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
438
|
+
switch (lastObjectResult) {
|
|
439
|
+
case exports.HitResult.great:
|
|
440
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
441
|
+
break;
|
|
442
|
+
case exports.HitResult.good:
|
|
443
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
444
|
+
break;
|
|
445
|
+
default:
|
|
446
|
+
lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
450
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
451
|
+
}
|
|
452
|
+
// These hit time uses hit window length as threshold.
|
|
453
|
+
// This is because cursors aren't recorded exactly at hit time,
|
|
454
|
+
// probably due to the game's behavior.
|
|
455
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
456
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
457
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
458
|
+
const validOccurrences = [];
|
|
459
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
460
|
+
if (group.startTime < firstObjectHitTime) {
|
|
461
|
+
continue;
|
|
462
|
+
}
|
|
463
|
+
if (group.startTime > lastObjectHitTime) {
|
|
464
|
+
break;
|
|
465
|
+
}
|
|
466
|
+
if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
|
|
467
|
+
group.endTime <= v.endTime)) {
|
|
468
|
+
continue;
|
|
469
|
+
}
|
|
470
|
+
validOccurrences.push(group.down);
|
|
471
|
+
}
|
|
472
|
+
this.downCursorInstances.push(validOccurrences);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Divides the beatmap into sections, which will be used to
|
|
477
|
+
* detect dragged sections and improve detection speed.
|
|
478
|
+
*/
|
|
479
|
+
getBeatmapSections() {
|
|
480
|
+
const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
481
|
+
for (const section of this.difficultyAttributes
|
|
482
|
+
.possibleThreeFingeredSections) {
|
|
483
|
+
const dragFingerIndex = this.findDragIndex(section);
|
|
484
|
+
const objects = [];
|
|
485
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
486
|
+
objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
|
|
487
|
+
}
|
|
488
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
493
|
+
*
|
|
494
|
+
* @param object The object to obtain the index for.
|
|
495
|
+
* @param objectData The hit data of the object.
|
|
496
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
497
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
498
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
499
|
+
*/
|
|
500
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
|
|
501
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
502
|
+
return {
|
|
503
|
+
pressingCursorInstanceIndex: -1,
|
|
504
|
+
pressingCursorIndex: -1,
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
508
|
+
// Check for sliderbreaks and treat them as misses.
|
|
509
|
+
if (object instanceof osuBase.Slider &&
|
|
510
|
+
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
511
|
+
return {
|
|
512
|
+
pressingCursorInstanceIndex: -1,
|
|
513
|
+
pressingCursorIndex: -1,
|
|
514
|
+
};
|
|
515
|
+
}
|
|
516
|
+
// We are not directly using hit time to determine which cursor pressed the object
|
|
517
|
+
// to account for time difference between hit registration and object judgement.
|
|
518
|
+
let minHitTime = object.startTime;
|
|
519
|
+
let maxHitTime = object.startTime;
|
|
520
|
+
if (object instanceof osuBase.Circle) {
|
|
521
|
+
let hitWindowGap = hitWindow50;
|
|
522
|
+
switch (objectData.result) {
|
|
523
|
+
case exports.HitResult.great:
|
|
524
|
+
hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
525
|
+
break;
|
|
526
|
+
case exports.HitResult.good:
|
|
527
|
+
hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
528
|
+
break;
|
|
529
|
+
}
|
|
530
|
+
minHitTime -= hitWindowGap;
|
|
531
|
+
maxHitTime += hitWindowGap;
|
|
532
|
+
}
|
|
533
|
+
else if (object instanceof osuBase.Slider) {
|
|
534
|
+
minHitTime -= hitWindow50;
|
|
535
|
+
maxHitTime += Math.min(hitWindow50, object.spanDuration);
|
|
536
|
+
}
|
|
537
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
538
|
+
let nearestCursorInstanceIndex = null;
|
|
539
|
+
let nearestCursorIndex = null;
|
|
540
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
541
|
+
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
542
|
+
if (excludedCursorIndices.includes(i)) {
|
|
543
|
+
continue;
|
|
544
|
+
}
|
|
545
|
+
const cursors = this.downCursorInstances[i];
|
|
546
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
547
|
+
const cursor = cursors[j];
|
|
548
|
+
if (cursor.time < minHitTime) {
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
if (cursor.time > maxHitTime) {
|
|
552
|
+
break;
|
|
553
|
+
}
|
|
554
|
+
const deltaTime = Math.abs(hitTime - cursor.time);
|
|
555
|
+
if (deltaTime > nearestTime) {
|
|
556
|
+
break;
|
|
557
|
+
}
|
|
558
|
+
nearestCursorInstanceIndex = i;
|
|
559
|
+
nearestCursorIndex = j;
|
|
560
|
+
nearestTime = deltaTime;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
return {
|
|
564
|
+
pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
|
|
565
|
+
pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
|
|
566
|
+
};
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Checks if a section is dragged and returns the index of the drag finger.
|
|
570
|
+
*
|
|
571
|
+
* If the section is not dragged, -1 will be returned.
|
|
572
|
+
*
|
|
573
|
+
* @param section The section to check.
|
|
574
|
+
*/
|
|
575
|
+
findDragIndex(section) {
|
|
576
|
+
var _a;
|
|
577
|
+
const objectData = this.data.hitObjectData;
|
|
578
|
+
const firstObject = this.hitObjects[section.firstObjectIndex];
|
|
579
|
+
const lastObject = this.hitObjects[section.lastObjectIndex];
|
|
580
|
+
let firstObjectMinHitTime = firstObject.startTime;
|
|
581
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
582
|
+
switch (objectData[section.firstObjectIndex].result) {
|
|
583
|
+
case exports.HitResult.great:
|
|
584
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
585
|
+
break;
|
|
586
|
+
case exports.HitResult.good:
|
|
587
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
588
|
+
break;
|
|
589
|
+
default:
|
|
590
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
else if (firstObject instanceof osuBase.Slider) {
|
|
594
|
+
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
595
|
+
}
|
|
596
|
+
let lastObjectMaxHitTime = lastObject.startTime;
|
|
597
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
598
|
+
switch (objectData[section.lastObjectIndex].result) {
|
|
599
|
+
case exports.HitResult.great:
|
|
600
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
601
|
+
break;
|
|
602
|
+
case exports.HitResult.good:
|
|
603
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
604
|
+
break;
|
|
605
|
+
default:
|
|
606
|
+
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
610
|
+
lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
|
|
611
|
+
}
|
|
612
|
+
// Since there may be more than 1 cursor instance index,
|
|
613
|
+
// we check which cursor instance follows hitobjects all over.
|
|
614
|
+
const cursorIndices = [];
|
|
615
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
616
|
+
const c = this.data.cursorMovement[i];
|
|
617
|
+
if (c.occurrenceGroups.length === 0) {
|
|
618
|
+
continue;
|
|
619
|
+
}
|
|
620
|
+
// Do not include cursors that don't have an occurence in this section
|
|
621
|
+
// this speeds up checking process.
|
|
622
|
+
if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
|
|
623
|
+
v.endTime <= lastObjectMaxHitTime).length === 0) {
|
|
624
|
+
continue;
|
|
625
|
+
}
|
|
626
|
+
// If this cursor instance doesn't move, it's not the cursor instance we want.
|
|
627
|
+
if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
|
|
628
|
+
0) {
|
|
629
|
+
continue;
|
|
630
|
+
}
|
|
631
|
+
cursorIndices.push(i);
|
|
632
|
+
}
|
|
633
|
+
const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
634
|
+
const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
635
|
+
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
636
|
+
for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
|
|
637
|
+
const object = sectionObjects[i];
|
|
638
|
+
const objectData = sectionReplayObjectData[i];
|
|
639
|
+
if (object instanceof osuBase.Spinner ||
|
|
640
|
+
objectData.result === exports.HitResult.miss) {
|
|
641
|
+
continue;
|
|
642
|
+
}
|
|
643
|
+
// Exclude sliderbreaks.
|
|
644
|
+
if (object instanceof osuBase.Slider &&
|
|
645
|
+
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
646
|
+
continue;
|
|
647
|
+
}
|
|
648
|
+
const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
649
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
650
|
+
// Observe the cursor position at the object's hit time.
|
|
651
|
+
for (let j = 0; j < cursorIndices.length; ++j) {
|
|
652
|
+
if (cursorIndices[j] === -1) {
|
|
653
|
+
continue;
|
|
654
|
+
}
|
|
655
|
+
const cursorData = this.data.cursorMovement[cursorIndices[j]];
|
|
656
|
+
const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
|
|
657
|
+
if (!cursorGroup) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
const cursors = cursorGroup.allOccurrences;
|
|
661
|
+
let isInObject = false;
|
|
662
|
+
for (let k = 1; k < cursors.length; ++k) {
|
|
663
|
+
const cursor = cursors[k];
|
|
664
|
+
const prevCursor = cursors[k - 1];
|
|
665
|
+
// Only consider cursor at interval prev.time <= hitTime <= current.time.
|
|
666
|
+
if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
|
|
667
|
+
switch (cursor.id) {
|
|
668
|
+
case exports.MovementType.up:
|
|
669
|
+
isInObject =
|
|
670
|
+
prevCursor.position.getDistance(objectPosition) <= object.radius;
|
|
671
|
+
break;
|
|
672
|
+
case exports.MovementType.move: {
|
|
673
|
+
// Interpolate movement.
|
|
674
|
+
const t = (hitTime - prevCursor.time) /
|
|
675
|
+
(cursor.time - prevCursor.time);
|
|
676
|
+
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
|
|
677
|
+
isInObject =
|
|
678
|
+
objectPosition.getDistance(cursorPosition) <= object.radius;
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
break;
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
if (!isInObject) {
|
|
685
|
+
cursorIndices[j] = -1;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
|
|
690
|
+
}
|
|
691
|
+
/**
|
|
692
|
+
* Creates nerf factors by scanning through objects.
|
|
693
|
+
*/
|
|
694
|
+
calculateNerfFactors() {
|
|
695
|
+
for (const beatmapSection of this.beatmapSections) {
|
|
696
|
+
const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
697
|
+
for (const object of beatmapSection.objects) {
|
|
698
|
+
if (object.pressingCursorIndex === -1 ||
|
|
699
|
+
object.pressingCursorInstanceIndex ===
|
|
700
|
+
beatmapSection.dragFingerIndex) {
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
++cursorCounts[object.pressingCursorInstanceIndex];
|
|
704
|
+
}
|
|
705
|
+
if (beatmapSection.dragFingerIndex !== -1) {
|
|
706
|
+
// Remove the drag index to prevent it from being picked up into the detection.
|
|
707
|
+
cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
|
|
708
|
+
}
|
|
709
|
+
// This index will be used to detect if a section is 3-fingered.
|
|
710
|
+
// If the section is dragged, the dragged instance will be ignored,
|
|
711
|
+
// hence why the index is 1 less than nondragged section.
|
|
712
|
+
const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
|
|
713
|
+
// Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
|
|
714
|
+
// to check if the section is 3-fingered.
|
|
715
|
+
const threeFingerRatio = cursorCounts
|
|
716
|
+
.slice(fingerSplitIndex)
|
|
717
|
+
.reduce((acc, value) => acc + value, 0) /
|
|
718
|
+
Math.max(cursorCounts
|
|
719
|
+
.slice(0, fingerSplitIndex)
|
|
720
|
+
.reduce((acc, value) => acc + value, 0), 1);
|
|
721
|
+
if (threeFingerRatio > this.threeFingerRatioThreshold) {
|
|
722
|
+
const threeFingeredObjectCount = cursorCounts
|
|
723
|
+
.slice(fingerSplitIndex)
|
|
724
|
+
.reduce((acc, value) => acc + value, 0);
|
|
725
|
+
const sectionObjectCount = beatmapSection.lastObjectIndex -
|
|
726
|
+
beatmapSection.firstObjectIndex +
|
|
727
|
+
1;
|
|
728
|
+
const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
|
|
729
|
+
// We can ignore the first 3 (2 for drag) filled cursor instances
|
|
730
|
+
// since they are guaranteed not 3 finger.
|
|
731
|
+
const threeFingerCursorCounts = cursorCounts
|
|
732
|
+
.slice(fingerSplitIndex)
|
|
733
|
+
.filter((amount) => amount > 0);
|
|
734
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
735
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
736
|
+
Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
|
|
737
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
738
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
|
|
739
|
+
this.nerfFactors.push({
|
|
740
|
+
strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
|
|
741
|
+
fingerFactor: fingerFactor,
|
|
742
|
+
lengthFactor: lengthFactor,
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
}
|
|
747
|
+
/**
|
|
748
|
+
* Calculates the final penalty.
|
|
749
|
+
*/
|
|
750
|
+
calculateFinalPenalty() {
|
|
751
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
752
|
+
0.015 *
|
|
753
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
/******************************************************************************
|
|
758
|
+
Copyright (c) Microsoft Corporation.
|
|
759
|
+
|
|
760
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
761
|
+
purpose with or without fee is hereby granted.
|
|
762
|
+
|
|
763
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
764
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
765
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
766
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
767
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
768
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
769
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
770
|
+
***************************************************************************** */
|
|
771
|
+
/* global Reflect, Promise, SuppressedError, Symbol */
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
function __awaiter(thisArg, _arguments, P, generator) {
|
|
775
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
776
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
777
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
778
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
779
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
780
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
785
|
+
var e = new Error(message);
|
|
786
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
787
|
+
};
|
|
788
|
+
|
|
315
789
|
/**
|
|
316
790
|
* Represents a replay data in an osu!droid replay.
|
|
317
791
|
*
|
|
@@ -320,25 +794,6 @@ exports.HitResult = void 0;
|
|
|
320
794
|
* This is used when analyzing replays using replay analyzer.
|
|
321
795
|
*/
|
|
322
796
|
class ReplayData {
|
|
323
|
-
replayVersion;
|
|
324
|
-
folderName;
|
|
325
|
-
fileName;
|
|
326
|
-
hash;
|
|
327
|
-
time;
|
|
328
|
-
hit300k;
|
|
329
|
-
hit100k;
|
|
330
|
-
score;
|
|
331
|
-
maxCombo;
|
|
332
|
-
accuracy;
|
|
333
|
-
isFullCombo;
|
|
334
|
-
playerName;
|
|
335
|
-
rawMods;
|
|
336
|
-
rank;
|
|
337
|
-
convertedMods;
|
|
338
|
-
cursorMovement;
|
|
339
|
-
hitObjectData;
|
|
340
|
-
speedModification;
|
|
341
|
-
forcedAR;
|
|
342
797
|
constructor(values) {
|
|
343
798
|
this.replayVersion = values.replayVersion;
|
|
344
799
|
this.folderName = values.folderName;
|
|
@@ -357,8 +812,12 @@ class ReplayData {
|
|
|
357
812
|
this.convertedMods = values.convertedMods || [];
|
|
358
813
|
this.cursorMovement = values.cursorMovement;
|
|
359
814
|
this.hitObjectData = values.hitObjectData;
|
|
360
|
-
this.
|
|
361
|
-
this.
|
|
815
|
+
this.speedMultiplier = values.speedMultiplier || 1;
|
|
816
|
+
this.forceCS = values.forceCS;
|
|
817
|
+
this.forceAR = values.forceAR;
|
|
818
|
+
this.forceOD = values.forceOD;
|
|
819
|
+
this.forceHP = values.forceHP;
|
|
820
|
+
this.flashlightFollowDelay = values.flashlightFollowDelay;
|
|
362
821
|
}
|
|
363
822
|
}
|
|
364
823
|
|
|
@@ -366,112 +825,51 @@ class ReplayData {
|
|
|
366
825
|
* Utility to check whether or not a beatmap is three-fingered.
|
|
367
826
|
*/
|
|
368
827
|
class ThreeFingerChecker {
|
|
369
|
-
/**
|
|
370
|
-
* The beatmap that is being analyzed.
|
|
371
|
-
*/
|
|
372
|
-
beatmap;
|
|
373
|
-
/**
|
|
374
|
-
* The data of the replay.
|
|
375
|
-
*/
|
|
376
|
-
data;
|
|
377
|
-
/**
|
|
378
|
-
* The difficulty attributes of the beatmap.
|
|
379
|
-
*/
|
|
380
|
-
difficultyAttributes;
|
|
381
|
-
/**
|
|
382
|
-
* The true scale of objects.
|
|
383
|
-
*/
|
|
384
|
-
trueScale;
|
|
385
|
-
/**
|
|
386
|
-
* The distance threshold between cursors to assume that two cursors are
|
|
387
|
-
* actually pressed with 1 finger in osu!pixels.
|
|
388
|
-
*
|
|
389
|
-
* This is used to prevent cases where a player would lift their finger
|
|
390
|
-
* too fast to the point where the 4th cursor instance or beyond is recorded
|
|
391
|
-
* as 1st, 2nd, or 3rd cursor instance.
|
|
392
|
-
*/
|
|
393
|
-
cursorDistancingDistanceThreshold = 60;
|
|
394
|
-
/**
|
|
395
|
-
* The threshold for the amount of cursors that are assumed to be pressed
|
|
396
|
-
* by a single finger.
|
|
397
|
-
*/
|
|
398
|
-
cursorDistancingCountThreshold = 10;
|
|
399
|
-
/**
|
|
400
|
-
* The threshold for the time difference of cursors that are assumed to be pressed
|
|
401
|
-
* by a single finger, in milliseconds.
|
|
402
|
-
*/
|
|
403
|
-
cursorDistancingTimeThreshold = 1000;
|
|
404
|
-
/**
|
|
405
|
-
* The amount of notes that has a tap strain exceeding `strainThreshold`.
|
|
406
|
-
*/
|
|
407
|
-
strainNoteCount;
|
|
408
|
-
/**
|
|
409
|
-
* The ratio threshold between non-3 finger cursors and 3-finger cursors.
|
|
410
|
-
*
|
|
411
|
-
* Increasing this number will increase detection accuracy, however
|
|
412
|
-
* it also increases the chance of falsely flagged plays.
|
|
413
|
-
*/
|
|
414
|
-
threeFingerRatioThreshold = 0.01;
|
|
415
|
-
/**
|
|
416
|
-
* Extended sections of the beatmap for drag detection.
|
|
417
|
-
*/
|
|
418
|
-
beatmapSections = [];
|
|
419
|
-
/**
|
|
420
|
-
* This threshold is used to filter out accidental taps.
|
|
421
|
-
*
|
|
422
|
-
* Increasing this number makes the filtration more sensitive, however it
|
|
423
|
-
* will also increase the chance of 3-fingered plays getting out from
|
|
424
|
-
* being flagged.
|
|
425
|
-
*/
|
|
426
|
-
accidentalTapThreshold = 400;
|
|
427
|
-
/**
|
|
428
|
-
* The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
|
|
429
|
-
*/
|
|
430
|
-
hitWindow;
|
|
431
|
-
/**
|
|
432
|
-
* A reprocessed break points to match right on object time.
|
|
433
|
-
*
|
|
434
|
-
* This is used to increase detection accuracy since break points do not start right at the
|
|
435
|
-
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
436
|
-
*/
|
|
437
|
-
breakPointAccurateTimes = [];
|
|
438
|
-
/**
|
|
439
|
-
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
440
|
-
*
|
|
441
|
-
* Each index represents the cursor index.
|
|
442
|
-
*/
|
|
443
|
-
downCursorInstances = [];
|
|
444
|
-
/**
|
|
445
|
-
* Nerf factors from all sections that were three-fingered.
|
|
446
|
-
*/
|
|
447
|
-
nerfFactors = [];
|
|
448
|
-
/**
|
|
449
|
-
* Whether this score uses the Precise mod.
|
|
450
|
-
*/
|
|
451
|
-
isPrecise;
|
|
452
828
|
/**
|
|
453
829
|
* @param beatmap The beatmap to analyze.
|
|
454
830
|
* @param data The data of the replay.
|
|
455
831
|
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
456
832
|
*/
|
|
457
833
|
constructor(beatmap, data, difficultyAttributes) {
|
|
834
|
+
/**
|
|
835
|
+
* The ratio threshold between non-3 finger cursors and 3-finger cursors.
|
|
836
|
+
*
|
|
837
|
+
* Increasing this number will increase detection accuracy, however
|
|
838
|
+
* it also increases the chance of falsely flagged plays.
|
|
839
|
+
*/
|
|
840
|
+
this.threeFingerRatioThreshold = 0.01;
|
|
841
|
+
/**
|
|
842
|
+
* Extended sections of the beatmap for drag detection.
|
|
843
|
+
*/
|
|
844
|
+
this.beatmapSections = [];
|
|
845
|
+
/**
|
|
846
|
+
* A reprocessed break points to match right on object time.
|
|
847
|
+
*
|
|
848
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
849
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
850
|
+
*/
|
|
851
|
+
this.breakPointAccurateTimes = [];
|
|
852
|
+
/**
|
|
853
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
854
|
+
*
|
|
855
|
+
* Each index represents the cursor index.
|
|
856
|
+
*/
|
|
857
|
+
this.downCursorInstances = [];
|
|
858
|
+
/**
|
|
859
|
+
* Nerf factors from all sections that were three-fingered.
|
|
860
|
+
*/
|
|
861
|
+
this.nerfFactors = [];
|
|
458
862
|
this.beatmap = beatmap;
|
|
459
863
|
this.data = data;
|
|
460
864
|
this.difficultyAttributes = difficultyAttributes;
|
|
461
|
-
const
|
|
462
|
-
|
|
463
|
-
mods:
|
|
464
|
-
|
|
465
|
-
}).
|
|
865
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
866
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
867
|
+
mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
|
|
868
|
+
convertOverallDifficulty: false,
|
|
869
|
+
}).overallDifficulty;
|
|
466
870
|
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
467
|
-
this.hitWindow = new osuBase.DroidHitWindow(
|
|
468
|
-
this.
|
|
469
|
-
this.difficultyAttributes.possibleThreeFingeredSections.reduce((a, v) => a + v.lastObjectIndex - v.firstObjectIndex + 1, 0);
|
|
470
|
-
const circleSize = new osuBase.MapStats({
|
|
471
|
-
cs: this.beatmap.difficulty.cs,
|
|
472
|
-
mods: this.difficultyAttributes.mods,
|
|
473
|
-
}).calculate({ mode: osuBase.Modes.droid }).cs;
|
|
474
|
-
this.trueScale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
|
|
871
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
872
|
+
this.hitObjects = beatmap.hitObjects.objects;
|
|
475
873
|
}
|
|
476
874
|
/**
|
|
477
875
|
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
@@ -492,7 +890,7 @@ class ThreeFingerChecker {
|
|
|
492
890
|
* nerf factor, taking beatmap difficulty into account.
|
|
493
891
|
*/
|
|
494
892
|
check() {
|
|
495
|
-
if (this.
|
|
893
|
+
if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
|
|
496
894
|
return { is3Finger: false, penalty: 1 };
|
|
497
895
|
}
|
|
498
896
|
this.getAccurateBreakPoints();
|
|
@@ -501,11 +899,6 @@ class ThreeFingerChecker {
|
|
|
501
899
|
return { is3Finger: false, penalty: 1 };
|
|
502
900
|
}
|
|
503
901
|
this.getBeatmapSections();
|
|
504
|
-
this.detectDragPlay();
|
|
505
|
-
this.preventAccidentalTaps();
|
|
506
|
-
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
507
|
-
return { is3Finger: false, penalty: 1 };
|
|
508
|
-
}
|
|
509
902
|
this.calculateNerfFactors();
|
|
510
903
|
const finalPenalty = this.calculateFinalPenalty();
|
|
511
904
|
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
@@ -514,42 +907,31 @@ class ThreeFingerChecker {
|
|
|
514
907
|
* Generates a new set of "accurate break points".
|
|
515
908
|
*
|
|
516
909
|
* This is done to increase detection accuracy since break points do not start right at the
|
|
517
|
-
*
|
|
910
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
518
911
|
*/
|
|
519
912
|
getAccurateBreakPoints() {
|
|
520
|
-
const { objects } = this.beatmap.hitObjects;
|
|
521
913
|
const objectData = this.data.hitObjectData;
|
|
522
914
|
for (const breakPoint of this.beatmap.events.breaks) {
|
|
523
|
-
const beforeIndex = osuBase.MathUtils.clamp(
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
let
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
default:
|
|
535
|
-
beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
915
|
+
const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
|
|
916
|
+
const objectBefore = this.hitObjects[beforeIndex];
|
|
917
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
918
|
+
let timeBefore = objectBefore.endTime;
|
|
919
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
920
|
+
if (objectBeforeData.result !== exports.HitResult.miss) {
|
|
921
|
+
timeBefore += objectBeforeData.accuracy;
|
|
922
|
+
}
|
|
923
|
+
else {
|
|
924
|
+
timeBefore += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
925
|
+
}
|
|
536
926
|
}
|
|
537
|
-
timeBefore += beforeIndexHitWindowLength;
|
|
538
927
|
const afterIndex = beforeIndex + 1;
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
let
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
break;
|
|
546
|
-
case exports.HitResult.good:
|
|
547
|
-
afterIndexHitWindowLength = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
548
|
-
break;
|
|
549
|
-
default:
|
|
550
|
-
afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
928
|
+
const objectAfter = this.hitObjects[afterIndex];
|
|
929
|
+
const objectAfterData = objectData[afterIndex];
|
|
930
|
+
let timeAfter = this.hitObjects[afterIndex].startTime;
|
|
931
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
932
|
+
objectAfterData.result !== exports.HitResult.miss) {
|
|
933
|
+
timeAfter += objectAfterData.accuracy;
|
|
551
934
|
}
|
|
552
|
-
timeAfter += afterIndexHitWindowLength;
|
|
553
935
|
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
554
936
|
startTime: timeBefore,
|
|
555
937
|
endTime: timeAfter,
|
|
@@ -562,13 +944,14 @@ class ThreeFingerChecker {
|
|
|
562
944
|
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
563
945
|
*/
|
|
564
946
|
filterCursorInstances() {
|
|
565
|
-
const { objects } = this.beatmap.hitObjects;
|
|
566
947
|
const objectData = this.data.hitObjectData;
|
|
567
948
|
const firstObjectResult = objectData[0].result;
|
|
568
949
|
const lastObjectResult = objectData.at(-1).result;
|
|
950
|
+
const firstObject = this.hitObjects[0];
|
|
951
|
+
const lastObject = this.hitObjects.at(-1);
|
|
569
952
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
570
953
|
let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
571
|
-
if (
|
|
954
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
572
955
|
switch (firstObjectResult) {
|
|
573
956
|
case exports.HitResult.great:
|
|
574
957
|
firstObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
@@ -582,7 +965,7 @@ class ThreeFingerChecker {
|
|
|
582
965
|
}
|
|
583
966
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
584
967
|
let lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
585
|
-
if (
|
|
968
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
586
969
|
switch (lastObjectResult) {
|
|
587
970
|
case exports.HitResult.great:
|
|
588
971
|
lastObjectHitWindow = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
@@ -594,16 +977,17 @@ class ThreeFingerChecker {
|
|
|
594
977
|
lastObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
595
978
|
}
|
|
596
979
|
}
|
|
980
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
981
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
982
|
+
}
|
|
597
983
|
// These hit time uses hit window length as threshold.
|
|
598
984
|
// This is because cursors aren't recorded exactly at hit time,
|
|
599
985
|
// probably due to the game's behavior.
|
|
600
|
-
const firstObjectHitTime =
|
|
601
|
-
const lastObjectHitTime =
|
|
602
|
-
for (
|
|
603
|
-
const cursorInstance = this.data.cursorMovement[i];
|
|
986
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
987
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
988
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
604
989
|
const validOccurrences = [];
|
|
605
|
-
for (
|
|
606
|
-
const group = cursorInstance.occurrenceGroups[j];
|
|
990
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
607
991
|
if (group.startTime < firstObjectHitTime) {
|
|
608
992
|
continue;
|
|
609
993
|
}
|
|
@@ -624,24 +1008,93 @@ class ThreeFingerChecker {
|
|
|
624
1008
|
* detect dragged sections and improve detection speed.
|
|
625
1009
|
*/
|
|
626
1010
|
getBeatmapSections() {
|
|
1011
|
+
const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
627
1012
|
for (const section of this.difficultyAttributes
|
|
628
1013
|
.possibleThreeFingeredSections) {
|
|
629
|
-
this.
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
}
|
|
1014
|
+
const dragFingerIndex = this.findDragIndex(section);
|
|
1015
|
+
const objects = [];
|
|
1016
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
1017
|
+
objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
|
|
1018
|
+
}
|
|
1019
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
|
|
634
1020
|
}
|
|
635
1021
|
}
|
|
636
1022
|
/**
|
|
637
|
-
*
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
1023
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
1024
|
+
*
|
|
1025
|
+
* @param object The object to obtain the index for.
|
|
1026
|
+
* @param objectData The hit data of the object.
|
|
1027
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
1028
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
1029
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
1030
|
+
*/
|
|
1031
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
|
|
1032
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
1033
|
+
return {
|
|
1034
|
+
pressingCursorInstanceIndex: -1,
|
|
1035
|
+
pressingCursorIndex: -1,
|
|
1036
|
+
};
|
|
1037
|
+
}
|
|
1038
|
+
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
1039
|
+
// Check for sliderbreaks and treat them as misses.
|
|
1040
|
+
if (object instanceof osuBase.Slider &&
|
|
1041
|
+
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
1042
|
+
return {
|
|
1043
|
+
pressingCursorInstanceIndex: -1,
|
|
1044
|
+
pressingCursorIndex: -1,
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
// We are not directly using hit time to determine which cursor pressed the object
|
|
1048
|
+
// to account for time difference between hit registration and object judgement.
|
|
1049
|
+
let minHitTime = object.startTime;
|
|
1050
|
+
let maxHitTime = object.startTime;
|
|
1051
|
+
if (object instanceof osuBase.Circle) {
|
|
1052
|
+
let hitWindowGap = hitWindow50;
|
|
1053
|
+
switch (objectData.result) {
|
|
1054
|
+
case exports.HitResult.great:
|
|
1055
|
+
hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
1056
|
+
break;
|
|
1057
|
+
case exports.HitResult.good:
|
|
1058
|
+
hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
1059
|
+
break;
|
|
1060
|
+
}
|
|
1061
|
+
minHitTime -= hitWindowGap;
|
|
1062
|
+
maxHitTime += hitWindowGap;
|
|
1063
|
+
}
|
|
1064
|
+
else if (object instanceof osuBase.Slider) {
|
|
1065
|
+
minHitTime -= hitWindow50;
|
|
1066
|
+
maxHitTime += Math.min(hitWindow50, object.spanDuration);
|
|
1067
|
+
}
|
|
1068
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
1069
|
+
let nearestCursorInstanceIndex = null;
|
|
1070
|
+
let nearestCursorIndex = null;
|
|
1071
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
1072
|
+
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
1073
|
+
if (excludedCursorIndices.includes(i)) {
|
|
1074
|
+
continue;
|
|
1075
|
+
}
|
|
1076
|
+
const cursors = this.downCursorInstances[i];
|
|
1077
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
1078
|
+
const cursor = cursors[j];
|
|
1079
|
+
if (cursor.time < minHitTime) {
|
|
1080
|
+
continue;
|
|
1081
|
+
}
|
|
1082
|
+
if (cursor.time > maxHitTime) {
|
|
1083
|
+
break;
|
|
1084
|
+
}
|
|
1085
|
+
const deltaTime = Math.abs(hitTime - cursor.time);
|
|
1086
|
+
if (deltaTime > nearestTime) {
|
|
1087
|
+
break;
|
|
1088
|
+
}
|
|
1089
|
+
nearestCursorInstanceIndex = i;
|
|
1090
|
+
nearestCursorIndex = j;
|
|
1091
|
+
nearestTime = deltaTime;
|
|
1092
|
+
}
|
|
644
1093
|
}
|
|
1094
|
+
return {
|
|
1095
|
+
pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
|
|
1096
|
+
pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
|
|
1097
|
+
};
|
|
645
1098
|
}
|
|
646
1099
|
/**
|
|
647
1100
|
* Checks if a section is dragged and returns the index of the drag finger.
|
|
@@ -650,11 +1103,11 @@ class ThreeFingerChecker {
|
|
|
650
1103
|
*
|
|
651
1104
|
* @param section The section to check.
|
|
652
1105
|
*/
|
|
653
|
-
|
|
654
|
-
|
|
1106
|
+
findDragIndex(section) {
|
|
1107
|
+
var _a;
|
|
655
1108
|
const objectData = this.data.hitObjectData;
|
|
656
|
-
const firstObject =
|
|
657
|
-
const lastObject =
|
|
1109
|
+
const firstObject = this.hitObjects[section.firstObjectIndex];
|
|
1110
|
+
const lastObject = this.hitObjects[section.lastObjectIndex];
|
|
658
1111
|
let firstObjectMinHitTime = firstObject.startTime;
|
|
659
1112
|
if (firstObject instanceof osuBase.Circle) {
|
|
660
1113
|
switch (objectData[section.firstObjectIndex].result) {
|
|
@@ -668,7 +1121,7 @@ class ThreeFingerChecker {
|
|
|
668
1121
|
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
669
1122
|
}
|
|
670
1123
|
}
|
|
671
|
-
else {
|
|
1124
|
+
else if (firstObject instanceof osuBase.Slider) {
|
|
672
1125
|
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
673
1126
|
}
|
|
674
1127
|
let lastObjectMaxHitTime = lastObject.startTime;
|
|
@@ -684,12 +1137,12 @@ class ThreeFingerChecker {
|
|
|
684
1137
|
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
685
1138
|
}
|
|
686
1139
|
}
|
|
687
|
-
else {
|
|
688
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
1140
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
1141
|
+
lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
|
|
689
1142
|
}
|
|
690
1143
|
// Since there may be more than 1 cursor instance index,
|
|
691
1144
|
// we check which cursor instance follows hitobjects all over.
|
|
692
|
-
const
|
|
1145
|
+
const cursorIndices = [];
|
|
693
1146
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
694
1147
|
const c = this.data.cursorMovement[i];
|
|
695
1148
|
if (c.occurrenceGroups.length === 0) {
|
|
@@ -706,21 +1159,13 @@ class ThreeFingerChecker {
|
|
|
706
1159
|
0) {
|
|
707
1160
|
continue;
|
|
708
1161
|
}
|
|
709
|
-
|
|
1162
|
+
cursorIndices.push(i);
|
|
710
1163
|
}
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
/**
|
|
714
|
-
* Finds the drag index of the section.
|
|
715
|
-
*
|
|
716
|
-
* @param sectionObjects The objects in the section.
|
|
717
|
-
* @param sectionReplayObjectData The hitobject data of all objects in the section.
|
|
718
|
-
* @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
|
|
719
|
-
*/
|
|
720
|
-
findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
|
|
1164
|
+
const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
1165
|
+
const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
|
|
721
1166
|
const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
722
|
-
for (let i = 0; i < sectionObjects.length &&
|
|
723
|
-
|
|
1167
|
+
for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
|
|
1168
|
+
const object = sectionObjects[i];
|
|
724
1169
|
const objectData = sectionReplayObjectData[i];
|
|
725
1170
|
if (object instanceof osuBase.Spinner ||
|
|
726
1171
|
objectData.result === exports.HitResult.miss) {
|
|
@@ -731,210 +1176,101 @@ class ThreeFingerChecker {
|
|
|
731
1176
|
objectData.accuracy === Math.floor(hitWindow50) + 13) {
|
|
732
1177
|
continue;
|
|
733
1178
|
}
|
|
734
|
-
if (object.droidScale !== this.trueScale) {
|
|
735
|
-
// Deep copy the instance so that we can assign scale.
|
|
736
|
-
object = osuBase.Utils.deepCopy(object);
|
|
737
|
-
object.droidScale = this.trueScale;
|
|
738
|
-
}
|
|
739
1179
|
const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
740
1180
|
const hitTime = object.startTime + objectData.accuracy;
|
|
741
1181
|
// Observe the cursor position at the object's hit time.
|
|
742
|
-
for (let j = 0; j <
|
|
743
|
-
if (
|
|
1182
|
+
for (let j = 0; j < cursorIndices.length; ++j) {
|
|
1183
|
+
if (cursorIndices[j] === -1) {
|
|
744
1184
|
continue;
|
|
745
1185
|
}
|
|
746
|
-
const cursorData = this.data.cursorMovement[
|
|
1186
|
+
const cursorData = this.data.cursorMovement[cursorIndices[j]];
|
|
747
1187
|
const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
|
|
748
1188
|
if (!cursorGroup) {
|
|
749
1189
|
continue;
|
|
750
1190
|
}
|
|
751
1191
|
const cursors = cursorGroup.allOccurrences;
|
|
1192
|
+
let isInObject = false;
|
|
752
1193
|
for (let k = 1; k < cursors.length; ++k) {
|
|
753
1194
|
const cursor = cursors[k];
|
|
754
1195
|
const prevCursor = cursors[k - 1];
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
|
|
766
|
-
break;
|
|
767
|
-
case exports.MovementType.move:
|
|
768
|
-
// Interpolate movement.
|
|
769
|
-
for (let mSecPassed = prevCursor.time; !isInObject &&
|
|
770
|
-
mSecPassed <=
|
|
771
|
-
Math.min(cursor.time, object.startTime + hitWindow50); ++mSecPassed) {
|
|
772
|
-
const t = (mSecPassed - prevCursor.time) /
|
|
1196
|
+
// Only consider cursor at interval prev.time <= hitTime <= current.time.
|
|
1197
|
+
if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
|
|
1198
|
+
switch (cursor.id) {
|
|
1199
|
+
case exports.MovementType.up:
|
|
1200
|
+
isInObject =
|
|
1201
|
+
prevCursor.position.getDistance(objectPosition) <= object.radius;
|
|
1202
|
+
break;
|
|
1203
|
+
case exports.MovementType.move: {
|
|
1204
|
+
// Interpolate movement.
|
|
1205
|
+
const t = (hitTime - prevCursor.time) /
|
|
773
1206
|
(cursor.time - prevCursor.time);
|
|
774
1207
|
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
|
|
775
1208
|
isInObject =
|
|
776
|
-
objectPosition.getDistance(cursorPosition) <= object.
|
|
1209
|
+
objectPosition.getDistance(cursorPosition) <= object.radius;
|
|
777
1210
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
cursorIndexes[j] = -1;
|
|
1211
|
+
}
|
|
1212
|
+
break;
|
|
781
1213
|
}
|
|
782
1214
|
}
|
|
1215
|
+
if (!isInObject) {
|
|
1216
|
+
cursorIndices[j] = -1;
|
|
1217
|
+
}
|
|
783
1218
|
}
|
|
784
1219
|
}
|
|
785
|
-
return
|
|
786
|
-
}
|
|
787
|
-
/**
|
|
788
|
-
* Attempts to prevent accidental taps from being flagged.
|
|
789
|
-
*
|
|
790
|
-
* This detection will filter cursors that don't hit
|
|
791
|
-
* any object in beatmap sections, thus eliminating any
|
|
792
|
-
* unnecessary taps.
|
|
793
|
-
*/
|
|
794
|
-
preventAccidentalTaps() {
|
|
795
|
-
let filledCursorAmount = this.downCursorInstances.filter((v) => v.length > 0).length;
|
|
796
|
-
if (filledCursorAmount <= 3) {
|
|
797
|
-
return;
|
|
798
|
-
}
|
|
799
|
-
const { objects } = this.beatmap.hitObjects;
|
|
800
|
-
const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.length, 0);
|
|
801
|
-
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
802
|
-
if (filledCursorAmount <= 3) {
|
|
803
|
-
break;
|
|
804
|
-
}
|
|
805
|
-
const cursorInstances = this.downCursorInstances[i];
|
|
806
|
-
// Use an estimation for accidental tap threshold.
|
|
807
|
-
if (cursorInstances.length <=
|
|
808
|
-
Math.ceil(objects.length / this.accidentalTapThreshold) &&
|
|
809
|
-
cursorInstances.length / totalCursorAmount <
|
|
810
|
-
this.threeFingerRatioThreshold * 2) {
|
|
811
|
-
--filledCursorAmount;
|
|
812
|
-
cursorInstances.length = 0;
|
|
813
|
-
}
|
|
814
|
-
this.downCursorInstances[i] = cursorInstances;
|
|
815
|
-
}
|
|
1220
|
+
return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
|
|
816
1221
|
}
|
|
817
1222
|
/**
|
|
818
1223
|
* Creates nerf factors by scanning through objects.
|
|
819
|
-
*
|
|
820
|
-
* This check will ignore all objects with speed strain below `strainThreshold`.
|
|
821
1224
|
*/
|
|
822
1225
|
calculateNerfFactors() {
|
|
823
|
-
const { objects } = this.beatmap.hitObjects;
|
|
824
|
-
const objectData = this.data.hitObjectData;
|
|
825
|
-
// We only filter cursor instances that are above the strain threshold.
|
|
826
|
-
// This minimalizes the amount of cursor instances to analyze.
|
|
827
1226
|
for (const beatmapSection of this.beatmapSections) {
|
|
828
|
-
const
|
|
829
|
-
const
|
|
830
|
-
(
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
: -this.hitWindow.hitWindowFor50(this.isPrecise));
|
|
834
|
-
const endTime = objects[beatmapSection.lastObjectIndex].endTime +
|
|
835
|
-
(objectData[beatmapSection.lastObjectIndex].result !==
|
|
836
|
-
exports.HitResult.miss
|
|
837
|
-
? objectData[beatmapSection.lastObjectIndex].accuracy
|
|
838
|
-
: this.hitWindow.hitWindowFor50(this.isPrecise));
|
|
839
|
-
const cursorAmounts = [];
|
|
840
|
-
const cursorVectorTimes = [];
|
|
841
|
-
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
842
|
-
// Do not include drag cursor instance.
|
|
843
|
-
if (i === dragIndex) {
|
|
1227
|
+
const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
1228
|
+
for (const object of beatmapSection.objects) {
|
|
1229
|
+
if (object.pressingCursorIndex === -1 ||
|
|
1230
|
+
object.pressingCursorInstanceIndex ===
|
|
1231
|
+
beatmapSection.dragFingerIndex) {
|
|
844
1232
|
continue;
|
|
845
1233
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
++amount;
|
|
852
|
-
cursorVectorTimes.push({
|
|
853
|
-
vector: new osuBase.Vector2(cursors[j].position.x, cursors[j].position.y),
|
|
854
|
-
time: cursors[j].time,
|
|
855
|
-
});
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
cursorAmounts.push(amount);
|
|
1234
|
+
++cursorCounts[object.pressingCursorInstanceIndex];
|
|
1235
|
+
}
|
|
1236
|
+
if (beatmapSection.dragFingerIndex !== -1) {
|
|
1237
|
+
// Remove the drag index to prevent it from being picked up into the detection.
|
|
1238
|
+
cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
|
|
859
1239
|
}
|
|
860
1240
|
// This index will be used to detect if a section is 3-fingered.
|
|
861
1241
|
// If the section is dragged, the dragged instance will be ignored,
|
|
862
1242
|
// hence why the index is 1 less than nondragged section.
|
|
863
|
-
const fingerSplitIndex =
|
|
1243
|
+
const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
|
|
864
1244
|
// Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
|
|
865
1245
|
// to check if the section is 3-fingered.
|
|
866
|
-
const threeFingerRatio =
|
|
1246
|
+
const threeFingerRatio = cursorCounts
|
|
867
1247
|
.slice(fingerSplitIndex)
|
|
868
1248
|
.reduce((acc, value) => acc + value, 0) /
|
|
869
|
-
|
|
1249
|
+
Math.max(cursorCounts
|
|
870
1250
|
.slice(0, fingerSplitIndex)
|
|
1251
|
+
.reduce((acc, value) => acc + value, 0), 1);
|
|
1252
|
+
if (threeFingerRatio > this.threeFingerRatioThreshold) {
|
|
1253
|
+
const threeFingeredObjectCount = cursorCounts
|
|
1254
|
+
.slice(fingerSplitIndex)
|
|
871
1255
|
.reduce((acc, value) => acc + value, 0);
|
|
872
|
-
|
|
873
|
-
for (const cursorVectorTime of cursorVectorTimes) {
|
|
874
|
-
const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
|
|
875
|
-
this.cursorDistancingDistanceThreshold);
|
|
876
|
-
if (pressIndex !== -1) {
|
|
877
|
-
if (cursorVectorTime.time -
|
|
878
|
-
similarPresses[pressIndex].lastTime >=
|
|
879
|
-
this.cursorDistancingTimeThreshold) {
|
|
880
|
-
similarPresses.splice(pressIndex, 1);
|
|
881
|
-
similarPresses.push({
|
|
882
|
-
vector: cursorVectorTime.vector,
|
|
883
|
-
count: 1,
|
|
884
|
-
lastTime: cursorVectorTime.time,
|
|
885
|
-
});
|
|
886
|
-
continue;
|
|
887
|
-
}
|
|
888
|
-
similarPresses[pressIndex].vector = cursorVectorTime.vector;
|
|
889
|
-
similarPresses[pressIndex].lastTime = cursorVectorTime.time;
|
|
890
|
-
++similarPresses[pressIndex].count;
|
|
891
|
-
}
|
|
892
|
-
else {
|
|
893
|
-
similarPresses.push({
|
|
894
|
-
vector: cursorVectorTime.vector,
|
|
895
|
-
count: 1,
|
|
896
|
-
lastTime: cursorVectorTime.time,
|
|
897
|
-
});
|
|
898
|
-
}
|
|
899
|
-
}
|
|
900
|
-
// Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
|
|
901
|
-
const validPresses = similarPresses
|
|
902
|
-
.filter((v) => v.count >= this.cursorDistancingCountThreshold)
|
|
903
|
-
.sort((a, b) => b.count - a.count)
|
|
904
|
-
.slice(2);
|
|
905
|
-
// Ignore cursor presses that are only 1 for now since they are very likely to be accidental
|
|
906
|
-
if ((threeFingerRatio > this.threeFingerRatioThreshold &&
|
|
907
|
-
cursorAmounts.filter((v) => v > 1).length >
|
|
908
|
-
fingerSplitIndex) ||
|
|
909
|
-
validPresses.length > 0) {
|
|
910
|
-
// Strain factor
|
|
911
|
-
const objectCount = beatmapSection.lastObjectIndex -
|
|
1256
|
+
const sectionObjectCount = beatmapSection.lastObjectIndex -
|
|
912
1257
|
beatmapSection.firstObjectIndex +
|
|
913
1258
|
1;
|
|
1259
|
+
const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
|
|
914
1260
|
// We can ignore the first 3 (2 for drag) filled cursor instances
|
|
915
1261
|
// since they are guaranteed not 3 finger.
|
|
916
|
-
const
|
|
1262
|
+
const threeFingerCursorCounts = cursorCounts
|
|
917
1263
|
.slice(fingerSplitIndex)
|
|
918
1264
|
.filter((amount) => amount > 0);
|
|
919
1265
|
// Finger factor applies more penalty if more fingers were used.
|
|
920
|
-
const fingerFactor =
|
|
921
|
-
|
|
922
|
-
Math.pow(((index + 1) * value * objectCount) /
|
|
923
|
-
this.strainNoteCount, 0.9), 1)
|
|
924
|
-
: Math.pow(validPresses.reduce((acc, value, index) => acc +
|
|
925
|
-
Math.pow(((index + 1) *
|
|
926
|
-
(value.count /
|
|
927
|
-
(this
|
|
928
|
-
.cursorDistancingCountThreshold *
|
|
929
|
-
2)) *
|
|
930
|
-
objectCount) /
|
|
931
|
-
this.strainNoteCount, 0.2), 1), 0.2);
|
|
1266
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
1267
|
+
Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
|
|
932
1268
|
// Length factor applies more penalty if there are more 3-fingered object.
|
|
933
|
-
const lengthFactor = 1 + Math.pow(
|
|
1269
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
|
|
934
1270
|
this.nerfFactors.push({
|
|
935
|
-
strainFactor:
|
|
936
|
-
fingerFactor,
|
|
937
|
-
lengthFactor,
|
|
1271
|
+
strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
|
|
1272
|
+
fingerFactor: fingerFactor,
|
|
1273
|
+
lengthFactor: lengthFactor,
|
|
938
1274
|
});
|
|
939
1275
|
}
|
|
940
1276
|
}
|
|
@@ -943,10 +1279,9 @@ class ThreeFingerChecker {
|
|
|
943
1279
|
* Calculates the final penalty.
|
|
944
1280
|
*/
|
|
945
1281
|
calculateFinalPenalty() {
|
|
946
|
-
return (
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
|
|
1282
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
1283
|
+
0.015 *
|
|
1284
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
950
1285
|
}
|
|
951
1286
|
}
|
|
952
1287
|
|
|
@@ -954,49 +1289,6 @@ class ThreeFingerChecker {
|
|
|
954
1289
|
* Contains information about which cursor index hits a hitobject.
|
|
955
1290
|
*/
|
|
956
1291
|
class IndexedHitObject {
|
|
957
|
-
/**
|
|
958
|
-
* The cursor index that hits the hitobject.
|
|
959
|
-
*
|
|
960
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
961
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
962
|
-
*/
|
|
963
|
-
cursorIndex;
|
|
964
|
-
/**
|
|
965
|
-
* The group index of the cursor within the cursor index that hits the hitobject.
|
|
966
|
-
*
|
|
967
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
968
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
969
|
-
*/
|
|
970
|
-
groupIndex;
|
|
971
|
-
/**
|
|
972
|
-
* The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
|
|
973
|
-
*
|
|
974
|
-
* If -1, the detection was unable to find any cursor that attempted to hit
|
|
975
|
-
* the hitobject or it did not meet the criteria for detection.
|
|
976
|
-
*/
|
|
977
|
-
occurrenceIndex;
|
|
978
|
-
/**
|
|
979
|
-
* The angle of the movement of the cursor towards the next hitobject.
|
|
980
|
-
*/
|
|
981
|
-
angle;
|
|
982
|
-
/**
|
|
983
|
-
* If this is a slider, whether the slider was cheesed.
|
|
984
|
-
*/
|
|
985
|
-
sliderCheesed = false;
|
|
986
|
-
/**
|
|
987
|
-
* The underlying difficulty hitobject.
|
|
988
|
-
*/
|
|
989
|
-
object;
|
|
990
|
-
/**
|
|
991
|
-
* The position of the cursor at the end of this hitobject.
|
|
992
|
-
*
|
|
993
|
-
* Will be altered during detection.
|
|
994
|
-
*/
|
|
995
|
-
endCursorPosition;
|
|
996
|
-
/**
|
|
997
|
-
* Whether the hitobject is likely two-handed.
|
|
998
|
-
*/
|
|
999
|
-
is2Handed;
|
|
1000
1292
|
/**
|
|
1001
1293
|
* @param object The underlying difficulty hitobject.
|
|
1002
1294
|
* @param cursorIndex The cursor index that moves towards the hitobject.
|
|
@@ -1005,6 +1297,10 @@ class IndexedHitObject {
|
|
|
1005
1297
|
* @param angle The angle of the movement of the cursor that moves towards the hitobject.
|
|
1006
1298
|
*/
|
|
1007
1299
|
constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
|
|
1300
|
+
/**
|
|
1301
|
+
* If this is a slider, whether the slider was cheesed.
|
|
1302
|
+
*/
|
|
1303
|
+
this.sliderCheesed = false;
|
|
1008
1304
|
this.object = object;
|
|
1009
1305
|
this.cursorIndex = cursorIndex;
|
|
1010
1306
|
this.groupIndex = groupIndex;
|
|
@@ -1020,40 +1316,24 @@ class IndexedHitObject {
|
|
|
1020
1316
|
* Utility to check whether or not a beatmap is two-handed.
|
|
1021
1317
|
*/
|
|
1022
1318
|
class TwoHandChecker {
|
|
1023
|
-
/**
|
|
1024
|
-
* The difficulty calculator that is being analyzed.
|
|
1025
|
-
*/
|
|
1026
|
-
calculator;
|
|
1027
|
-
/**
|
|
1028
|
-
* The data of the replay.
|
|
1029
|
-
*/
|
|
1030
|
-
data;
|
|
1031
|
-
/**
|
|
1032
|
-
* The hitobjects of the beatmap that have been assigned with their respective cursor index.
|
|
1033
|
-
*/
|
|
1034
|
-
indexedHitObjects = [];
|
|
1035
|
-
/**
|
|
1036
|
-
* The osu!droid hitwindow of the analyzed beatmap.
|
|
1037
|
-
*/
|
|
1038
|
-
hitWindow;
|
|
1039
|
-
/**
|
|
1040
|
-
* The 50 osu!droid hit window of the analyzed beatmap.
|
|
1041
|
-
*/
|
|
1042
|
-
hitWindow50;
|
|
1043
1319
|
// private csvString: string;
|
|
1044
1320
|
/**
|
|
1045
1321
|
* @param calculator The difficulty calculator to analyze.
|
|
1046
1322
|
* @param data The data of the replay.
|
|
1047
1323
|
*/
|
|
1048
1324
|
constructor(calculator, data) {
|
|
1325
|
+
/**
|
|
1326
|
+
* The hitobjects of the beatmap that have been assigned with their respective cursor index.
|
|
1327
|
+
*/
|
|
1328
|
+
this.indexedHitObjects = [];
|
|
1049
1329
|
this.calculator = calculator;
|
|
1050
1330
|
this.data = data;
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
mods:
|
|
1054
|
-
|
|
1055
|
-
}).
|
|
1056
|
-
this.hitWindow = new osuBase.DroidHitWindow(
|
|
1331
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
1332
|
+
overallDifficulty: calculator.beatmap.difficulty.od,
|
|
1333
|
+
mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
|
|
1334
|
+
convertOverallDifficulty: false,
|
|
1335
|
+
}).overallDifficulty;
|
|
1336
|
+
this.hitWindow = new osuBase.DroidHitWindow(od);
|
|
1057
1337
|
this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1058
1338
|
// this.csvString = `Mods,${
|
|
1059
1339
|
// data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
|
|
@@ -1236,9 +1516,8 @@ class TwoHandChecker {
|
|
|
1236
1516
|
const prevToCurrentMovement = object
|
|
1237
1517
|
.getStackedPosition(osuBase.Modes.droid)
|
|
1238
1518
|
.subtract(prevObjectEndPosition);
|
|
1239
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1240
1519
|
// Don't consider objects that are too close to each other.
|
|
1241
|
-
if (prevToCurrentMovement.length <= radius) {
|
|
1520
|
+
if (prevToCurrentMovement.length <= object.radius) {
|
|
1242
1521
|
return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
|
|
1243
1522
|
}
|
|
1244
1523
|
// The case for a one-handed object is that there will be a slight movement in the cursor towards
|
|
@@ -1367,7 +1646,6 @@ class TwoHandChecker {
|
|
|
1367
1646
|
cursorTime: object.startTime,
|
|
1368
1647
|
};
|
|
1369
1648
|
}
|
|
1370
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1371
1649
|
let hitWindow = this.hitWindow50;
|
|
1372
1650
|
const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
|
|
1373
1651
|
// For sliders, set the hit window to as lenient as possible.
|
|
@@ -1382,7 +1660,7 @@ class TwoHandChecker {
|
|
|
1382
1660
|
}
|
|
1383
1661
|
}
|
|
1384
1662
|
// TODO: what to do for head sliderbreaks?
|
|
1385
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1663
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1386
1664
|
let nearestCursorIndex = 0;
|
|
1387
1665
|
let nearestGroupIndex = 0;
|
|
1388
1666
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1390,12 +1668,12 @@ class TwoHandChecker {
|
|
|
1390
1668
|
const minimumActiveTime = object.startTime - hitWindow;
|
|
1391
1669
|
const maximumActiveTime = object.startTime + hitWindow;
|
|
1392
1670
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1393
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1671
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1394
1672
|
break;
|
|
1395
1673
|
}
|
|
1396
1674
|
const cursorData = this.data.cursorMovement[i];
|
|
1397
1675
|
for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
|
|
1398
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1676
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1399
1677
|
break;
|
|
1400
1678
|
}
|
|
1401
1679
|
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
@@ -1407,7 +1685,8 @@ class TwoHandChecker {
|
|
|
1407
1685
|
}
|
|
1408
1686
|
// Validate the down press first.
|
|
1409
1687
|
const { down } = cursorGroup;
|
|
1410
|
-
if (down.position.getDistance(objectPosition) <=
|
|
1688
|
+
if (down.position.getDistance(objectPosition) <=
|
|
1689
|
+
object.radius &&
|
|
1411
1690
|
Math.abs(down.time - object.startTime) <= hitWindow) {
|
|
1412
1691
|
if (objectIndex > 0) {
|
|
1413
1692
|
const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
|
|
@@ -1478,12 +1757,13 @@ class TwoHandChecker {
|
|
|
1478
1757
|
nearestCursorGroupIndex = k;
|
|
1479
1758
|
nearestCursorTime = cursorGroup.down.time;
|
|
1480
1759
|
}
|
|
1481
|
-
if (distance <= radius) {
|
|
1760
|
+
if (distance <= object.radius) {
|
|
1482
1761
|
break;
|
|
1483
1762
|
}
|
|
1484
1763
|
}
|
|
1485
1764
|
}
|
|
1486
|
-
if (nearestPosition.getDistance(objectPosition) <=
|
|
1765
|
+
if (nearestPosition.getDistance(objectPosition) <=
|
|
1766
|
+
object.radius) {
|
|
1487
1767
|
break;
|
|
1488
1768
|
}
|
|
1489
1769
|
}
|
|
@@ -1531,7 +1811,7 @@ class TwoHandChecker {
|
|
|
1531
1811
|
else {
|
|
1532
1812
|
objectEndPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
1533
1813
|
}
|
|
1534
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1814
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1535
1815
|
let nearestCursorIndex = 0;
|
|
1536
1816
|
let nearestGroupIndex = 0;
|
|
1537
1817
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1631,22 +1911,6 @@ class TwoHandChecker {
|
|
|
1631
1911
|
* Utility to check whether relevant sliders in a beatmap are cheesed.
|
|
1632
1912
|
*/
|
|
1633
1913
|
class SliderCheeseChecker {
|
|
1634
|
-
/**
|
|
1635
|
-
* The beatmap that is being analyzed.
|
|
1636
|
-
*/
|
|
1637
|
-
beatmap;
|
|
1638
|
-
/**
|
|
1639
|
-
* The data of the replay.
|
|
1640
|
-
*/
|
|
1641
|
-
data;
|
|
1642
|
-
/**
|
|
1643
|
-
* The difficulty attributes of the beatmap.
|
|
1644
|
-
*/
|
|
1645
|
-
difficultyAttributes;
|
|
1646
|
-
/**
|
|
1647
|
-
* The 50 osu!droid hit window of the analyzed beatmap.
|
|
1648
|
-
*/
|
|
1649
|
-
hitWindow50;
|
|
1650
1914
|
/**
|
|
1651
1915
|
* @param beatmap The beatmap to analyze.
|
|
1652
1916
|
* @param data The data of the replay.
|
|
@@ -1656,18 +1920,21 @@ class SliderCheeseChecker {
|
|
|
1656
1920
|
this.beatmap = beatmap;
|
|
1657
1921
|
this.data = data;
|
|
1658
1922
|
this.difficultyAttributes = difficultyAttributes;
|
|
1659
|
-
const
|
|
1660
|
-
|
|
1661
|
-
mods:
|
|
1662
|
-
|
|
1663
|
-
}).
|
|
1664
|
-
this.hitWindow50 = new osuBase.DroidHitWindow(
|
|
1923
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
1924
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
1925
|
+
mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
|
|
1926
|
+
convertOverallDifficulty: false,
|
|
1927
|
+
}).overallDifficulty;
|
|
1928
|
+
this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1665
1929
|
}
|
|
1666
1930
|
/**
|
|
1667
1931
|
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
1668
1932
|
*/
|
|
1669
1933
|
check() {
|
|
1670
|
-
if (this.difficultyAttributes.difficultSliders.length === 0
|
|
1934
|
+
if (this.difficultyAttributes.difficultSliders.length === 0 ||
|
|
1935
|
+
(this.difficultyAttributes.sliderFactor === 1 &&
|
|
1936
|
+
this.difficultyAttributes.flashlightSliderFactor === 1 &&
|
|
1937
|
+
this.difficultyAttributes.visualSliderFactor === 1)) {
|
|
1671
1938
|
return {
|
|
1672
1939
|
aimPenalty: 1,
|
|
1673
1940
|
flashlightPenalty: 1,
|
|
@@ -1681,15 +1948,11 @@ class SliderCheeseChecker {
|
|
|
1681
1948
|
* Checks for sliders that were cheesed.
|
|
1682
1949
|
*/
|
|
1683
1950
|
checkSliderCheesing() {
|
|
1951
|
+
const { objects } = this.beatmap.hitObjects;
|
|
1684
1952
|
const cheesedDifficultyRatings = [];
|
|
1685
1953
|
// Current loop indices are stored for efficiency.
|
|
1686
1954
|
const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
|
|
1687
|
-
const
|
|
1688
|
-
cs: this.beatmap.difficulty.cs,
|
|
1689
|
-
mods: this.difficultyAttributes.mods,
|
|
1690
|
-
}).calculate({ mode: osuBase.Modes.droid }).cs;
|
|
1691
|
-
const scale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
|
|
1692
|
-
const acceptableRadius = 128 * scale;
|
|
1955
|
+
const acceptableRadius = objects[0].radius * 2;
|
|
1693
1956
|
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
1694
1957
|
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
1695
1958
|
.slice()
|
|
@@ -1698,18 +1961,19 @@ class SliderCheeseChecker {
|
|
|
1698
1961
|
continue;
|
|
1699
1962
|
}
|
|
1700
1963
|
const objectData = this.data.hitObjectData[difficultSlider.index];
|
|
1701
|
-
// If a slider break occurs, we disregard the check for that slider.
|
|
1702
|
-
if (objectData.
|
|
1964
|
+
// If a miss or slider break occurs, we disregard the check for that slider.
|
|
1965
|
+
if (objectData.result === exports.HitResult.miss ||
|
|
1966
|
+
objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
|
|
1703
1967
|
continue;
|
|
1704
1968
|
}
|
|
1705
|
-
|
|
1706
|
-
if (object.droidScale !== scale) {
|
|
1707
|
-
// Deep clone the object so that we can assign scale properly.
|
|
1708
|
-
object = osuBase.Utils.deepCopy(object);
|
|
1709
|
-
object.droidScale = scale;
|
|
1710
|
-
}
|
|
1969
|
+
const object = objects[difficultSlider.index];
|
|
1711
1970
|
const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
1712
|
-
//
|
|
1971
|
+
// These time boundaries should consider the delta time between the previous and next
|
|
1972
|
+
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
1973
|
+
// compute and the accuracy gain is small. As such, let's settle with 50 hit window.
|
|
1974
|
+
const minTimeLimit = object.startTime - this.hitWindow50;
|
|
1975
|
+
const maxTimeLimit = object.startTime + this.hitWindow50;
|
|
1976
|
+
// Get the closest tap distance across all cursors.
|
|
1713
1977
|
const closestDistances = [];
|
|
1714
1978
|
const closestGroupIndices = [];
|
|
1715
1979
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
@@ -1718,24 +1982,72 @@ class SliderCheeseChecker {
|
|
|
1718
1982
|
let closestIndex = cursorGroups.length;
|
|
1719
1983
|
for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
|
|
1720
1984
|
const group = cursorGroups[j];
|
|
1721
|
-
if (group.
|
|
1985
|
+
if (group.endTime < minTimeLimit) {
|
|
1722
1986
|
continue;
|
|
1723
1987
|
}
|
|
1724
|
-
if (group.startTime >
|
|
1988
|
+
if (group.startTime > maxTimeLimit) {
|
|
1725
1989
|
break;
|
|
1726
1990
|
}
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
closestDistance
|
|
1730
|
-
|
|
1991
|
+
if (group.startTime >= minTimeLimit) {
|
|
1992
|
+
const distance = group.down.position.getDistance(objectStartPosition);
|
|
1993
|
+
if (closestDistance > distance) {
|
|
1994
|
+
closestDistance = distance;
|
|
1995
|
+
closestIndex = j;
|
|
1996
|
+
}
|
|
1997
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
1998
|
+
break;
|
|
1999
|
+
}
|
|
2000
|
+
}
|
|
2001
|
+
// Normally, we check if there are cursor presses within the group's active time.
|
|
2002
|
+
// However, some funky workarounds are used throughout the game for replays, so
|
|
2003
|
+
// for the time being we only check for cursor distances across the group.
|
|
2004
|
+
const { allOccurrences } = group;
|
|
2005
|
+
for (let k = 1; k < allOccurrences.length; ++k) {
|
|
2006
|
+
const occurrence = allOccurrences[k];
|
|
2007
|
+
const prevOccurrence = allOccurrences[k - 1];
|
|
2008
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
2009
|
+
switch (occurrence.id) {
|
|
2010
|
+
case exports.MovementType.up:
|
|
2011
|
+
distance =
|
|
2012
|
+
prevOccurrence.position.getDistance(objectStartPosition);
|
|
2013
|
+
break;
|
|
2014
|
+
case exports.MovementType.move:
|
|
2015
|
+
for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
|
|
2016
|
+
Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
|
|
2017
|
+
const t = (mSecPassed - prevOccurrence.time) /
|
|
2018
|
+
(occurrence.time - prevOccurrence.time);
|
|
2019
|
+
const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
|
|
2020
|
+
distance =
|
|
2021
|
+
cursorPosition.getDistance(objectStartPosition);
|
|
2022
|
+
if (closestDistance > distance) {
|
|
2023
|
+
closestDistance = distance;
|
|
2024
|
+
closestIndex = j;
|
|
2025
|
+
}
|
|
2026
|
+
if (closestDistance <=
|
|
2027
|
+
acceptableRadius / 2) {
|
|
2028
|
+
break;
|
|
2029
|
+
}
|
|
2030
|
+
}
|
|
2031
|
+
}
|
|
2032
|
+
if (closestDistance > distance) {
|
|
2033
|
+
closestDistance = distance;
|
|
2034
|
+
closestIndex = j;
|
|
2035
|
+
}
|
|
2036
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
2037
|
+
break;
|
|
2038
|
+
}
|
|
1731
2039
|
}
|
|
1732
2040
|
}
|
|
1733
2041
|
closestDistances.push(closestDistance);
|
|
1734
2042
|
closestGroupIndices.push(closestIndex);
|
|
2043
|
+
if (cursorLoopIndices[i] > 0) {
|
|
2044
|
+
// Decrement the index. The previous group may also have a role on the next slider.
|
|
2045
|
+
--cursorLoopIndices[i];
|
|
2046
|
+
}
|
|
1735
2047
|
}
|
|
1736
2048
|
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
1737
2049
|
const closestDistance = closestDistances[cursorIndex];
|
|
1738
|
-
if (closestDistance > acceptableRadius) {
|
|
2050
|
+
if (closestDistance > acceptableRadius / 2) {
|
|
1739
2051
|
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
1740
2052
|
continue;
|
|
1741
2053
|
}
|
|
@@ -1752,12 +2064,7 @@ class SliderCheeseChecker {
|
|
|
1752
2064
|
if (!tickWasHit) {
|
|
1753
2065
|
continue;
|
|
1754
2066
|
}
|
|
1755
|
-
|
|
1756
|
-
if (nestedObject.droidScale !== scale) {
|
|
1757
|
-
// Deep clone the object so that we can assign scale properly
|
|
1758
|
-
nestedObject = osuBase.Utils.deepCopy(nestedObject);
|
|
1759
|
-
nestedObject.droidScale = scale;
|
|
1760
|
-
}
|
|
2067
|
+
const nestedObject = object.nestedHitObjects[i];
|
|
1761
2068
|
const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
|
|
1762
2069
|
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
1763
2070
|
allOccurrences[occurrenceLoopIndex].time <
|
|
@@ -1794,17 +2101,17 @@ class SliderCheeseChecker {
|
|
|
1794
2101
|
* Calculates the slider cheese penalty.
|
|
1795
2102
|
*/
|
|
1796
2103
|
calculateSliderCheesePenalty(cheesedDifficultyRatings) {
|
|
1797
|
-
const summedDifficultyRating = cheesedDifficultyRatings.reduce((a, v) => a + v, 0);
|
|
2104
|
+
const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
|
|
1798
2105
|
return {
|
|
1799
|
-
aimPenalty: Math.pow(1 -
|
|
2106
|
+
aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
|
|
1800
2107
|
summedDifficultyRating *
|
|
1801
|
-
this.difficultyAttributes.sliderFactor,
|
|
1802
|
-
flashlightPenalty: Math.pow(1 -
|
|
2108
|
+
this.difficultyAttributes.sliderFactor, 2)),
|
|
2109
|
+
flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
|
|
1803
2110
|
summedDifficultyRating *
|
|
1804
|
-
this.difficultyAttributes.flashlightSliderFactor,
|
|
1805
|
-
visualPenalty: Math.pow(1 -
|
|
2111
|
+
this.difficultyAttributes.flashlightSliderFactor, 2)),
|
|
2112
|
+
visualPenalty: Math.max(this.difficultyAttributes.visualSliderFactor, Math.pow(1 -
|
|
1806
2113
|
summedDifficultyRating *
|
|
1807
|
-
this.difficultyAttributes.visualSliderFactor,
|
|
2114
|
+
this.difficultyAttributes.visualSliderFactor, 2)),
|
|
1808
2115
|
};
|
|
1809
2116
|
}
|
|
1810
2117
|
}
|
|
@@ -1817,77 +2124,57 @@ class SliderCheeseChecker {
|
|
|
1817
2124
|
* Once analyzed, the result can be accessed via the `data` property.
|
|
1818
2125
|
*/
|
|
1819
2126
|
class ReplayAnalyzer {
|
|
1820
|
-
/**
|
|
1821
|
-
* The score ID of the replay.
|
|
1822
|
-
*/
|
|
1823
|
-
scoreID;
|
|
1824
|
-
/**
|
|
1825
|
-
* The original odr file of the replay.
|
|
1826
|
-
*/
|
|
1827
|
-
originalODR = null;
|
|
1828
|
-
/**
|
|
1829
|
-
* The fixed odr file of the replay.
|
|
1830
|
-
*/
|
|
1831
|
-
fixedODR = null;
|
|
1832
|
-
/**
|
|
1833
|
-
* Whether or not the play is considered using >=3 finger abuse.
|
|
1834
|
-
*/
|
|
1835
|
-
is3Finger;
|
|
1836
|
-
/**
|
|
1837
|
-
* Whether or not the play is considered 2-handed.
|
|
1838
|
-
*/
|
|
1839
|
-
is2Hand;
|
|
1840
|
-
/**
|
|
1841
|
-
* The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
|
|
1842
|
-
*/
|
|
1843
|
-
beatmap;
|
|
1844
|
-
/**
|
|
1845
|
-
* The difficulty attributes of the beatmap.
|
|
1846
|
-
*/
|
|
1847
|
-
difficultyAttributes;
|
|
1848
|
-
/**
|
|
1849
|
-
* The results of the analyzer. `null` when initialized.
|
|
1850
|
-
*/
|
|
1851
|
-
data = null;
|
|
1852
|
-
/**
|
|
1853
|
-
* Penalty value used to penalize dpp for 2-hand.
|
|
1854
|
-
*/
|
|
1855
|
-
aimPenalty = 1;
|
|
1856
|
-
/**
|
|
1857
|
-
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
1858
|
-
*/
|
|
1859
|
-
tapPenalty = 1;
|
|
1860
|
-
/**
|
|
1861
|
-
* Penalty values used to penalize dpp for slider cheesing.
|
|
1862
|
-
*/
|
|
1863
|
-
sliderCheesePenalty = {
|
|
1864
|
-
aimPenalty: 1,
|
|
1865
|
-
flashlightPenalty: 1,
|
|
1866
|
-
visualPenalty: 1,
|
|
1867
|
-
};
|
|
1868
|
-
/**
|
|
1869
|
-
* Whether this replay has been checked against 3 finger usage.
|
|
1870
|
-
*/
|
|
1871
|
-
hasBeenCheckedFor3Finger = false;
|
|
1872
|
-
/**
|
|
1873
|
-
* Whether this replay has been checked against 2 hand usage.
|
|
1874
|
-
*/
|
|
1875
|
-
hasBeenCheckedFor2Hand = false;
|
|
1876
|
-
/**
|
|
1877
|
-
* Whether this repla has been checked against slider cheesing.
|
|
1878
|
-
*/
|
|
1879
|
-
hasBeenCheckedForSliderCheesing = false;
|
|
1880
|
-
/**
|
|
1881
|
-
* The amount of two-handed objects.
|
|
1882
|
-
*/
|
|
1883
|
-
twoHandedNoteCount = 0;
|
|
1884
|
-
// Sizes of primitive data types in Java (in bytes)
|
|
1885
|
-
BYTE_LENGTH = 1;
|
|
1886
|
-
SHORT_LENGTH = 2;
|
|
1887
|
-
INT_LENGTH = 4;
|
|
1888
|
-
FLOAT_LENGTH = 4;
|
|
1889
|
-
LONG_LENGTH = 8;
|
|
1890
2127
|
constructor(values) {
|
|
2128
|
+
/**
|
|
2129
|
+
* The original odr file of the replay.
|
|
2130
|
+
*/
|
|
2131
|
+
this.originalODR = null;
|
|
2132
|
+
/**
|
|
2133
|
+
* The fixed odr file of the replay.
|
|
2134
|
+
*/
|
|
2135
|
+
this.fixedODR = null;
|
|
2136
|
+
/**
|
|
2137
|
+
* The results of the analyzer. `null` when initialized.
|
|
2138
|
+
*/
|
|
2139
|
+
this.data = null;
|
|
2140
|
+
/**
|
|
2141
|
+
* Penalty value used to penalize dpp for 2-hand.
|
|
2142
|
+
*/
|
|
2143
|
+
this.aimPenalty = 1;
|
|
2144
|
+
/**
|
|
2145
|
+
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
2146
|
+
*/
|
|
2147
|
+
this.tapPenalty = 1;
|
|
2148
|
+
/**
|
|
2149
|
+
* Penalty values used to penalize dpp for slider cheesing.
|
|
2150
|
+
*/
|
|
2151
|
+
this.sliderCheesePenalty = {
|
|
2152
|
+
aimPenalty: 1,
|
|
2153
|
+
flashlightPenalty: 1,
|
|
2154
|
+
visualPenalty: 1,
|
|
2155
|
+
};
|
|
2156
|
+
/**
|
|
2157
|
+
* Whether this replay has been checked against 3 finger usage.
|
|
2158
|
+
*/
|
|
2159
|
+
this.hasBeenCheckedFor3Finger = false;
|
|
2160
|
+
/**
|
|
2161
|
+
* Whether this replay has been checked against 2 hand usage.
|
|
2162
|
+
*/
|
|
2163
|
+
this.hasBeenCheckedFor2Hand = false;
|
|
2164
|
+
/**
|
|
2165
|
+
* Whether this replay has been checked against slider cheesing.
|
|
2166
|
+
*/
|
|
2167
|
+
this.hasBeenCheckedForSliderCheesing = false;
|
|
2168
|
+
/**
|
|
2169
|
+
* The amount of two-handed objects.
|
|
2170
|
+
*/
|
|
2171
|
+
this.twoHandedNoteCount = 0;
|
|
2172
|
+
// Sizes of primitive data types in Java (in bytes)
|
|
2173
|
+
this.BYTE_LENGTH = 1;
|
|
2174
|
+
this.SHORT_LENGTH = 2;
|
|
2175
|
+
this.INT_LENGTH = 4;
|
|
2176
|
+
this.FLOAT_LENGTH = 4;
|
|
2177
|
+
this.LONG_LENGTH = 8;
|
|
1891
2178
|
this.scoreID = values.scoreID;
|
|
1892
2179
|
this.beatmap = values.map;
|
|
1893
2180
|
this.difficultyAttributes = values.difficultyAttributes;
|
|
@@ -1898,35 +2185,39 @@ class ReplayAnalyzer {
|
|
|
1898
2185
|
/**
|
|
1899
2186
|
* Analyzes a replay.
|
|
1900
2187
|
*/
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
this.originalODR
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
this.fixedODR
|
|
1910
|
-
|
|
1911
|
-
|
|
2188
|
+
analyze() {
|
|
2189
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2190
|
+
if (!this.originalODR && !this.fixedODR) {
|
|
2191
|
+
this.originalODR = yield this.downloadReplay();
|
|
2192
|
+
}
|
|
2193
|
+
if (!this.originalODR) {
|
|
2194
|
+
return this;
|
|
2195
|
+
}
|
|
2196
|
+
if (!this.fixedODR) {
|
|
2197
|
+
this.fixedODR = yield this.decompress().catch(() => null);
|
|
2198
|
+
}
|
|
2199
|
+
if (!this.fixedODR) {
|
|
2200
|
+
return this;
|
|
2201
|
+
}
|
|
2202
|
+
this.parseReplay();
|
|
1912
2203
|
return this;
|
|
1913
|
-
}
|
|
1914
|
-
this.parseReplay();
|
|
1915
|
-
return this;
|
|
2204
|
+
});
|
|
1916
2205
|
}
|
|
1917
2206
|
/**
|
|
1918
2207
|
* Downloads the given score ID's replay.
|
|
1919
2208
|
*/
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
.
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
|
|
1927
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
2209
|
+
downloadReplay() {
|
|
2210
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2211
|
+
const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
|
|
2212
|
+
.setRequireAPIkey(false)
|
|
2213
|
+
.setEndpoint("upload")
|
|
2214
|
+
.addParameter("", `${this.scoreID}.odr`);
|
|
2215
|
+
const result = yield apiRequestBuilder.sendRequest();
|
|
2216
|
+
if (result.statusCode !== 200) {
|
|
2217
|
+
return null;
|
|
2218
|
+
}
|
|
2219
|
+
return result.data;
|
|
2220
|
+
});
|
|
1930
2221
|
}
|
|
1931
2222
|
/**
|
|
1932
2223
|
* Decompresses a replay.
|
|
@@ -1940,15 +2231,15 @@ class ReplayAnalyzer {
|
|
|
1940
2231
|
stream.push(null);
|
|
1941
2232
|
stream
|
|
1942
2233
|
.pipe(unzipper.Parse())
|
|
1943
|
-
.on("entry",
|
|
2234
|
+
.on("entry", (entry) => __awaiter(this, void 0, void 0, function* () {
|
|
1944
2235
|
const fileName = entry.path;
|
|
1945
2236
|
if (fileName === "data") {
|
|
1946
|
-
return resolve(
|
|
2237
|
+
return resolve(yield entry.buffer());
|
|
1947
2238
|
}
|
|
1948
2239
|
else {
|
|
1949
2240
|
entry.autodrain();
|
|
1950
2241
|
}
|
|
1951
|
-
})
|
|
2242
|
+
}))
|
|
1952
2243
|
.on("error", (e) => {
|
|
1953
2244
|
setTimeout(() => reject(e), 2000);
|
|
1954
2245
|
});
|
|
@@ -1958,6 +2249,10 @@ class ReplayAnalyzer {
|
|
|
1958
2249
|
* Parses a replay after being downloaded and converted to a buffer.
|
|
1959
2250
|
*/
|
|
1960
2251
|
parseReplay() {
|
|
2252
|
+
var _a, _b, _c;
|
|
2253
|
+
if (!this.fixedODR) {
|
|
2254
|
+
return;
|
|
2255
|
+
}
|
|
1961
2256
|
// javaDeserialization can only somewhat parse some string field
|
|
1962
2257
|
// the rest will be a buffer that we need to manually parse
|
|
1963
2258
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -1965,7 +2260,7 @@ class ReplayAnalyzer {
|
|
|
1965
2260
|
try {
|
|
1966
2261
|
rawObject = javaDeserialization__namespace.parse(this.fixedODR);
|
|
1967
2262
|
}
|
|
1968
|
-
catch {
|
|
2263
|
+
catch (_d) {
|
|
1969
2264
|
return;
|
|
1970
2265
|
}
|
|
1971
2266
|
const resultObject = {
|
|
@@ -1975,29 +2270,16 @@ class ReplayAnalyzer {
|
|
|
1975
2270
|
hash: rawObject[3],
|
|
1976
2271
|
cursorMovement: [],
|
|
1977
2272
|
hitObjectData: [],
|
|
2273
|
+
accuracy: new osuBase.Accuracy({ n300: 0 }),
|
|
2274
|
+
rank: "",
|
|
1978
2275
|
};
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
resultObject.hit300k = rawObject[4].readInt32BE(8);
|
|
1982
|
-
resultObject.hit100k = rawObject[4].readInt32BE(16);
|
|
1983
|
-
resultObject.score = rawObject[4].readInt32BE(32);
|
|
1984
|
-
resultObject.maxCombo = rawObject[4].readInt32BE(36);
|
|
1985
|
-
resultObject.accuracy = new osuBase.Accuracy({
|
|
1986
|
-
n300: rawObject[4].readInt32BE(12),
|
|
1987
|
-
n100: rawObject[4].readInt32BE(20),
|
|
1988
|
-
n50: rawObject[4].readInt32BE(24),
|
|
1989
|
-
nmiss: rawObject[4].readInt32BE(28),
|
|
1990
|
-
});
|
|
1991
|
-
resultObject.isFullCombo = !!rawObject[4][44];
|
|
1992
|
-
resultObject.playerName = rawObject[5];
|
|
1993
|
-
resultObject.rawMods = rawObject[6].elements;
|
|
1994
|
-
resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
|
|
1995
|
-
// Determine rank
|
|
2276
|
+
const determineRank = () => {
|
|
2277
|
+
var _a, _b;
|
|
1996
2278
|
const totalHits = resultObject.accuracy.n300 +
|
|
1997
2279
|
resultObject.accuracy.n100 +
|
|
1998
2280
|
resultObject.accuracy.n50 +
|
|
1999
2281
|
resultObject.accuracy.nmiss;
|
|
2000
|
-
const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
|
|
2282
|
+
const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
|
|
2001
2283
|
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2002
2284
|
switch (true) {
|
|
2003
2285
|
case resultObject.accuracy.value() === 1:
|
|
@@ -2032,13 +2314,53 @@ class ReplayAnalyzer {
|
|
|
2032
2314
|
default:
|
|
2033
2315
|
resultObject.rank = "D";
|
|
2034
2316
|
}
|
|
2317
|
+
};
|
|
2318
|
+
if (resultObject.replayVersion >= 3) {
|
|
2319
|
+
resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
|
|
2320
|
+
resultObject.hit300k = rawObject[4].readInt32BE(8);
|
|
2321
|
+
resultObject.hit100k = rawObject[4].readInt32BE(16);
|
|
2322
|
+
resultObject.score = rawObject[4].readInt32BE(32);
|
|
2323
|
+
resultObject.maxCombo = rawObject[4].readInt32BE(36);
|
|
2324
|
+
resultObject.accuracy = new osuBase.Accuracy({
|
|
2325
|
+
n300: rawObject[4].readInt32BE(12),
|
|
2326
|
+
n100: rawObject[4].readInt32BE(20),
|
|
2327
|
+
n50: rawObject[4].readInt32BE(24),
|
|
2328
|
+
nmiss: rawObject[4].readInt32BE(28),
|
|
2329
|
+
});
|
|
2330
|
+
resultObject.isFullCombo = !!rawObject[4][44];
|
|
2331
|
+
resultObject.playerName = rawObject[5];
|
|
2332
|
+
resultObject.rawMods = rawObject[6].elements;
|
|
2333
|
+
resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
|
|
2334
|
+
determineRank();
|
|
2035
2335
|
}
|
|
2036
2336
|
if (resultObject.replayVersion >= 4) {
|
|
2037
|
-
const
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
|
|
2337
|
+
const str = rawObject[7].split("|");
|
|
2338
|
+
for (const s of str) {
|
|
2339
|
+
switch (true) {
|
|
2340
|
+
// Forced stats
|
|
2341
|
+
case s.startsWith("CS"):
|
|
2342
|
+
resultObject.forceCS = parseFloat(s.replace("CS", ""));
|
|
2343
|
+
break;
|
|
2344
|
+
case s.startsWith("AR"):
|
|
2345
|
+
resultObject.forceAR = parseFloat(s.replace("AR", ""));
|
|
2346
|
+
break;
|
|
2347
|
+
case s.startsWith("OD"):
|
|
2348
|
+
resultObject.forceOD = parseFloat(s.replace("OD", ""));
|
|
2349
|
+
break;
|
|
2350
|
+
case s.startsWith("HP"):
|
|
2351
|
+
resultObject.forceHP = parseFloat(s.replace("HP", ""));
|
|
2352
|
+
break;
|
|
2353
|
+
// FL follow delay
|
|
2354
|
+
case s.startsWith("FLD"):
|
|
2355
|
+
resultObject.flashlightFollowDelay =
|
|
2356
|
+
parseFloat(s.replace("FLD", "")) || 0.12;
|
|
2357
|
+
break;
|
|
2358
|
+
// Speed multiplier
|
|
2359
|
+
case s.startsWith("x"):
|
|
2360
|
+
resultObject.speedMultiplier =
|
|
2361
|
+
parseFloat(s.replace("x", "")) || 1;
|
|
2362
|
+
break;
|
|
2363
|
+
}
|
|
2042
2364
|
}
|
|
2043
2365
|
}
|
|
2044
2366
|
let bufferIndex;
|
|
@@ -2111,7 +2433,7 @@ class ReplayAnalyzer {
|
|
|
2111
2433
|
const replayObjectData = {
|
|
2112
2434
|
accuracy: 0,
|
|
2113
2435
|
tickset: [],
|
|
2114
|
-
result:
|
|
2436
|
+
result: exports.HitResult.miss,
|
|
2115
2437
|
};
|
|
2116
2438
|
replayObjectData.accuracy =
|
|
2117
2439
|
replayDataBuffer.readInt16BE(bufferCounter);
|
|
@@ -2139,41 +2461,41 @@ class ReplayAnalyzer {
|
|
|
2139
2461
|
resultObject.hitObjectData.push(replayObjectData);
|
|
2140
2462
|
}
|
|
2141
2463
|
// Parse max combo, hit results, and accuracy in old replay version
|
|
2142
|
-
if (resultObject.replayVersion < 3
|
|
2143
|
-
|
|
2144
|
-
let hit300k = 0;
|
|
2145
|
-
let hit100 = 0;
|
|
2146
|
-
let hit100k = 0;
|
|
2147
|
-
let hit50 = 0;
|
|
2148
|
-
let hit0 = 0;
|
|
2149
|
-
let grantsGekiOrKatu = true;
|
|
2150
|
-
const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2464
|
+
if (resultObject.replayVersion < 3) {
|
|
2465
|
+
const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2151
2466
|
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
|
|
2152
2467
|
? this.beatmap.beatmap
|
|
2153
|
-
: this.beatmap).hitObjects.objects;
|
|
2468
|
+
: this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
|
|
2469
|
+
let grantsGekiOrKatu = true;
|
|
2154
2470
|
for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
|
|
2155
2471
|
// Hit result
|
|
2156
2472
|
const hitObjectData = resultObject.hitObjectData[i];
|
|
2157
|
-
const isNextNewCombo =
|
|
2473
|
+
const isNextNewCombo = objects
|
|
2474
|
+
? i + 1 !== objects.length
|
|
2475
|
+
? objects[i + 1].isNewCombo
|
|
2476
|
+
: true
|
|
2477
|
+
: false;
|
|
2158
2478
|
switch (hitObjectData.result) {
|
|
2159
2479
|
case exports.HitResult.miss:
|
|
2160
|
-
++
|
|
2480
|
+
++resultObject.accuracy.nmiss;
|
|
2161
2481
|
grantsGekiOrKatu = false;
|
|
2162
2482
|
break;
|
|
2163
2483
|
case exports.HitResult.meh:
|
|
2164
|
-
++
|
|
2484
|
+
++resultObject.accuracy.n50;
|
|
2165
2485
|
grantsGekiOrKatu = false;
|
|
2166
2486
|
break;
|
|
2167
2487
|
case exports.HitResult.good:
|
|
2168
|
-
++
|
|
2488
|
+
++resultObject.accuracy.n100;
|
|
2169
2489
|
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2170
|
-
|
|
2490
|
+
(_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
|
|
2491
|
+
++resultObject.hit100k;
|
|
2171
2492
|
}
|
|
2172
2493
|
break;
|
|
2173
2494
|
case exports.HitResult.great:
|
|
2174
|
-
++
|
|
2495
|
+
++resultObject.accuracy.n300;
|
|
2175
2496
|
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2176
|
-
|
|
2497
|
+
(_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
|
|
2498
|
+
++resultObject.hit300k;
|
|
2177
2499
|
}
|
|
2178
2500
|
break;
|
|
2179
2501
|
}
|
|
@@ -2181,55 +2503,7 @@ class ReplayAnalyzer {
|
|
|
2181
2503
|
grantsGekiOrKatu = true;
|
|
2182
2504
|
}
|
|
2183
2505
|
}
|
|
2184
|
-
|
|
2185
|
-
resultObject.hit100k = hit100k;
|
|
2186
|
-
resultObject.accuracy = new osuBase.Accuracy({
|
|
2187
|
-
n300: hit300,
|
|
2188
|
-
n100: hit100,
|
|
2189
|
-
n50: hit50,
|
|
2190
|
-
nmiss: hit0,
|
|
2191
|
-
nobjects: hit300 + hit100 + hit50 + hit0,
|
|
2192
|
-
});
|
|
2193
|
-
// Determine rank
|
|
2194
|
-
const totalHits = resultObject.accuracy.n300 +
|
|
2195
|
-
resultObject.accuracy.n100 +
|
|
2196
|
-
resultObject.accuracy.n50 +
|
|
2197
|
-
resultObject.accuracy.nmiss;
|
|
2198
|
-
const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
|
|
2199
|
-
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2200
|
-
switch (true) {
|
|
2201
|
-
case resultObject.accuracy.value() === 1:
|
|
2202
|
-
if (isHidden) {
|
|
2203
|
-
resultObject.rank = "XH";
|
|
2204
|
-
}
|
|
2205
|
-
else {
|
|
2206
|
-
resultObject.rank = "X";
|
|
2207
|
-
}
|
|
2208
|
-
break;
|
|
2209
|
-
case hit300Ratio > 0.9 &&
|
|
2210
|
-
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2211
|
-
!resultObject.accuracy.nmiss:
|
|
2212
|
-
if (isHidden) {
|
|
2213
|
-
resultObject.rank = "SH";
|
|
2214
|
-
}
|
|
2215
|
-
else {
|
|
2216
|
-
resultObject.rank = "S";
|
|
2217
|
-
}
|
|
2218
|
-
break;
|
|
2219
|
-
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
2220
|
-
hit300Ratio > 0.9:
|
|
2221
|
-
resultObject.rank = "A";
|
|
2222
|
-
break;
|
|
2223
|
-
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
2224
|
-
hit300Ratio > 0.8:
|
|
2225
|
-
resultObject.rank = "B";
|
|
2226
|
-
break;
|
|
2227
|
-
case hit300Ratio > 0.6:
|
|
2228
|
-
resultObject.rank = "C";
|
|
2229
|
-
break;
|
|
2230
|
-
default:
|
|
2231
|
-
resultObject.rank = "D";
|
|
2232
|
-
}
|
|
2506
|
+
determineRank();
|
|
2233
2507
|
}
|
|
2234
2508
|
this.data = new ReplayData(resultObject);
|
|
2235
2509
|
}
|
|
@@ -2251,12 +2525,12 @@ class ReplayAnalyzer {
|
|
|
2251
2525
|
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
|
|
2252
2526
|
? this.beatmap.beatmap
|
|
2253
2527
|
: this.beatmap;
|
|
2254
|
-
const objects = beatmap.hitObjects
|
|
2255
|
-
const
|
|
2256
|
-
|
|
2257
|
-
mods:
|
|
2258
|
-
}).
|
|
2259
|
-
const hitWindow50 = new osuBase.DroidHitWindow(
|
|
2528
|
+
const { objects } = beatmap.hitObjects;
|
|
2529
|
+
const od = osuBase.calculateDroidDifficultyStatistics({
|
|
2530
|
+
overallDifficulty: beatmap.difficulty.od,
|
|
2531
|
+
mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
|
|
2532
|
+
}).overallDifficulty;
|
|
2533
|
+
const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
|
|
2260
2534
|
// The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
|
|
2261
2535
|
// https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
|
|
2262
2536
|
//
|
|
@@ -2270,8 +2544,8 @@ class ReplayAnalyzer {
|
|
|
2270
2544
|
accuracies.push(0);
|
|
2271
2545
|
continue;
|
|
2272
2546
|
}
|
|
2273
|
-
const accuracy = v
|
|
2274
|
-
if (o instanceof osuBase.Slider &&
|
|
2547
|
+
const { accuracy } = v;
|
|
2548
|
+
if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
|
|
2275
2549
|
accuracies.push(0);
|
|
2276
2550
|
continue;
|
|
2277
2551
|
}
|
|
@@ -2310,13 +2584,11 @@ class ReplayAnalyzer {
|
|
|
2310
2584
|
MOD_PERFECT: "f",
|
|
2311
2585
|
MOD_SUDDENDEATH: "u",
|
|
2312
2586
|
MOD_SCOREV2: "v",
|
|
2587
|
+
MOD_FLASHLIGHT: "i",
|
|
2313
2588
|
};
|
|
2314
2589
|
let modString = "";
|
|
2315
2590
|
for (const mod of replayMods) {
|
|
2316
2591
|
for (const property in replayModsConstants) {
|
|
2317
|
-
if (!(property in replayModsConstants)) {
|
|
2318
|
-
continue;
|
|
2319
|
-
}
|
|
2320
2592
|
if (!mod.includes(property)) {
|
|
2321
2593
|
continue;
|
|
2322
2594
|
}
|
|
@@ -2333,12 +2605,36 @@ class ReplayAnalyzer {
|
|
|
2333
2605
|
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
2334
2606
|
*/
|
|
2335
2607
|
checkFor3Finger() {
|
|
2608
|
+
var _a;
|
|
2336
2609
|
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2337
2610
|
return;
|
|
2338
2611
|
}
|
|
2339
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2612
|
+
if (!this.convertedBeatmap) {
|
|
2613
|
+
const mods = this.data.convertedMods.slice();
|
|
2614
|
+
if ([
|
|
2615
|
+
this.data.forceCS,
|
|
2616
|
+
this.data.forceAR,
|
|
2617
|
+
this.data.forceOD,
|
|
2618
|
+
this.data.forceHP,
|
|
2619
|
+
].some((v) => v !== undefined)) {
|
|
2620
|
+
mods.push(new osuBase.ModDifficultyAdjust({
|
|
2621
|
+
cs: this.data.forceCS,
|
|
2622
|
+
ar: this.data.forceAR,
|
|
2623
|
+
od: this.data.forceOD,
|
|
2624
|
+
hp: this.data.forceHP,
|
|
2625
|
+
}));
|
|
2626
|
+
}
|
|
2627
|
+
(_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
|
|
2628
|
+
? this.beatmap
|
|
2629
|
+
: this.beatmap.beatmap).convert({
|
|
2630
|
+
mode: osuBase.Modes.droid,
|
|
2631
|
+
mods: mods,
|
|
2632
|
+
customSpeedMultiplier: this.data.speedMultiplier,
|
|
2633
|
+
}));
|
|
2634
|
+
}
|
|
2635
|
+
const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
|
|
2636
|
+
? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
|
|
2637
|
+
: new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
|
|
2342
2638
|
const result = threeFingerChecker.check();
|
|
2343
2639
|
this.is3Finger = result.is3Finger;
|
|
2344
2640
|
this.tapPenalty = result.penalty;
|
|
@@ -2367,12 +2663,34 @@ class ReplayAnalyzer {
|
|
|
2367
2663
|
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
2368
2664
|
*/
|
|
2369
2665
|
checkForSliderCheesing() {
|
|
2666
|
+
var _a;
|
|
2370
2667
|
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2371
2668
|
return;
|
|
2372
2669
|
}
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2670
|
+
if (!this.convertedBeatmap) {
|
|
2671
|
+
const mods = this.data.convertedMods.slice();
|
|
2672
|
+
if ([
|
|
2673
|
+
this.data.forceCS,
|
|
2674
|
+
this.data.forceAR,
|
|
2675
|
+
this.data.forceOD,
|
|
2676
|
+
this.data.forceHP,
|
|
2677
|
+
].some((v) => v !== undefined)) {
|
|
2678
|
+
mods.push(new osuBase.ModDifficultyAdjust({
|
|
2679
|
+
cs: this.data.forceCS,
|
|
2680
|
+
ar: this.data.forceAR,
|
|
2681
|
+
od: this.data.forceOD,
|
|
2682
|
+
hp: this.data.forceHP,
|
|
2683
|
+
}));
|
|
2684
|
+
}
|
|
2685
|
+
(_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
|
|
2686
|
+
? this.beatmap
|
|
2687
|
+
: this.beatmap.beatmap).convert({
|
|
2688
|
+
mode: osuBase.Modes.droid,
|
|
2689
|
+
mods: mods,
|
|
2690
|
+
customSpeedMultiplier: this.data.speedMultiplier,
|
|
2691
|
+
}));
|
|
2692
|
+
}
|
|
2693
|
+
const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
|
|
2376
2694
|
this.sliderCheesePenalty = sliderCheeseChecker.check();
|
|
2377
2695
|
this.hasBeenCheckedForSliderCheesing = true;
|
|
2378
2696
|
}
|
|
@@ -2386,33 +2704,6 @@ class ReplayAnalyzer {
|
|
|
2386
2704
|
* This is used when analyzing replays using replay analyzer.
|
|
2387
2705
|
*/
|
|
2388
2706
|
class ReplayObjectData {
|
|
2389
|
-
/**
|
|
2390
|
-
* For circles, this is the offset at which the circle was hit. If the hit accuracy is 10000, it means the circle was tapped too late ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/HitCircle.java#L298-L306)).
|
|
2391
|
-
*
|
|
2392
|
-
* For sliders, this is the offset at which the slider head was hit. For
|
|
2393
|
-
* sliderbreaks, the accuracy would be `Math.floor(<hit window 50>ms) + 13ms` ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821)).
|
|
2394
|
-
*
|
|
2395
|
-
* For spinners, this is the total amount at which the spinner was spinned:
|
|
2396
|
-
* ```js
|
|
2397
|
-
* const rotations = Math.floor(data.accuracy / 4);
|
|
2398
|
-
* ```
|
|
2399
|
-
* The remainder of the division denotes the hit result of the spinner:
|
|
2400
|
-
* - `HitResult.great`: 3
|
|
2401
|
-
* - `HitResult.good`: 2
|
|
2402
|
-
* - `HitResult.meh`: 1
|
|
2403
|
-
* - `HitResult.miss`: 0
|
|
2404
|
-
*/
|
|
2405
|
-
accuracy;
|
|
2406
|
-
/**
|
|
2407
|
-
* The tickset of the hitobject.
|
|
2408
|
-
*
|
|
2409
|
-
* This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
|
|
2410
|
-
*/
|
|
2411
|
-
tickset;
|
|
2412
|
-
/**
|
|
2413
|
-
* The bitwise hit result of the hitobject.
|
|
2414
|
-
*/
|
|
2415
|
-
result;
|
|
2416
2707
|
constructor(values) {
|
|
2417
2708
|
this.accuracy = values.accuracy;
|
|
2418
2709
|
this.tickset = values.tickset;
|
|
@@ -2423,6 +2714,7 @@ class ReplayObjectData {
|
|
|
2423
2714
|
exports.CursorData = CursorData;
|
|
2424
2715
|
exports.CursorOccurrence = CursorOccurrence;
|
|
2425
2716
|
exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
|
|
2717
|
+
exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
|
|
2426
2718
|
exports.ReplayAnalyzer = ReplayAnalyzer;
|
|
2427
2719
|
exports.ReplayData = ReplayData;
|
|
2428
2720
|
exports.ReplayObjectData = ReplayObjectData;
|