@rian8337/osu-droid-replay-analyzer 4.0.0-beta.7 → 4.0.0-beta.71
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 +1481 -1355
- package/package.json +13 -12
- package/typings/index.d.ts +434 -266
- package/dist/index.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
var osuBase = require('@rian8337/osu-base');
|
|
4
|
-
var osuDifficultyCalculator = require('@rian8337/osu-difficulty-calculator');
|
|
5
|
-
var osuRebalanceDifficultyCalculator = require('@rian8337/osu-rebalance-difficulty-calculator');
|
|
6
|
-
var unzipper = require('unzipper');
|
|
7
4
|
var javaDeserialization = require('java-deserialization');
|
|
8
5
|
var node_stream = require('node:stream');
|
|
6
|
+
var unzipper = require('unzipper');
|
|
9
7
|
|
|
10
8
|
function _interopNamespaceDefault(e) {
|
|
11
9
|
var n = Object.create(null);
|
|
@@ -51,23 +49,32 @@ exports.MovementType = void 0;
|
|
|
51
49
|
* Represents a cursor's occurrence.
|
|
52
50
|
*/
|
|
53
51
|
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
52
|
constructor(time, x, y, id) {
|
|
67
53
|
this.time = time;
|
|
68
54
|
this.position = new osuBase.Vector2(x, y);
|
|
69
55
|
this.id = id;
|
|
70
56
|
}
|
|
57
|
+
/**
|
|
58
|
+
* Returns a string representation of this `CursorOccurrence`.
|
|
59
|
+
*/
|
|
60
|
+
toString() {
|
|
61
|
+
let str = `${this.time}ms `;
|
|
62
|
+
switch (this.id) {
|
|
63
|
+
case exports.MovementType.down:
|
|
64
|
+
str += "Down";
|
|
65
|
+
break;
|
|
66
|
+
case exports.MovementType.up:
|
|
67
|
+
str += "Up";
|
|
68
|
+
break;
|
|
69
|
+
case exports.MovementType.move:
|
|
70
|
+
str += "Move";
|
|
71
|
+
break;
|
|
72
|
+
}
|
|
73
|
+
if (this.id !== exports.MovementType.up) {
|
|
74
|
+
str += ` (${this.position.x.toFixed(2)}, ${this.position.y.toFixed(2)})`;
|
|
75
|
+
}
|
|
76
|
+
return str;
|
|
77
|
+
}
|
|
71
78
|
}
|
|
72
79
|
|
|
73
80
|
/**
|
|
@@ -125,7 +132,8 @@ class CursorOccurrenceGroup {
|
|
|
125
132
|
* The time at which this cursor occurrence group ends.
|
|
126
133
|
*/
|
|
127
134
|
get endTime() {
|
|
128
|
-
|
|
135
|
+
var _a, _b, _c, _d;
|
|
136
|
+
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
137
|
}
|
|
130
138
|
/**
|
|
131
139
|
* The duration this cursor occurrence group is active for.
|
|
@@ -145,20 +153,6 @@ class CursorOccurrenceGroup {
|
|
|
145
153
|
}
|
|
146
154
|
return cursors;
|
|
147
155
|
}
|
|
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
156
|
constructor(down, moves, up) {
|
|
163
157
|
this._down = down;
|
|
164
158
|
this._moves = moves;
|
|
@@ -182,13 +176,14 @@ class CursorOccurrenceGroup {
|
|
|
182
176
|
* @returns The cursor occurrence at the given time, `null` if not found.
|
|
183
177
|
*/
|
|
184
178
|
cursorAt(time) {
|
|
179
|
+
var _a;
|
|
185
180
|
if (!this.isActiveAt(time)) {
|
|
186
181
|
return null;
|
|
187
182
|
}
|
|
188
183
|
if (this._down.time === time) {
|
|
189
184
|
return this._down;
|
|
190
185
|
}
|
|
191
|
-
if (this._up
|
|
186
|
+
if (((_a = this._up) === null || _a === void 0 ? void 0 : _a.time) === time) {
|
|
192
187
|
return this._up;
|
|
193
188
|
}
|
|
194
189
|
let l = 0;
|
|
@@ -208,6 +203,12 @@ class CursorOccurrenceGroup {
|
|
|
208
203
|
// l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
|
|
209
204
|
return this._moves[l - 1];
|
|
210
205
|
}
|
|
206
|
+
/**
|
|
207
|
+
* Returns a string representation of this `CursorOccurrenceGroup`.
|
|
208
|
+
*/
|
|
209
|
+
toString() {
|
|
210
|
+
return `Down: ${this.down.time}ms (${this.down.position.x.toFixed(2)}, ${this.down.position.y.toFixed(2)}) | Moves: ${this.moves.length} | Up: ${this.up ? `${this.up.time}ms` : "N/A"}`;
|
|
211
|
+
}
|
|
211
212
|
}
|
|
212
213
|
|
|
213
214
|
/**
|
|
@@ -218,17 +219,14 @@ class CursorOccurrenceGroup {
|
|
|
218
219
|
* This is used when analyzing replays using replay analyzer.
|
|
219
220
|
*/
|
|
220
221
|
class CursorData {
|
|
221
|
-
/**
|
|
222
|
-
* The occurrence groups of this cursor instance.
|
|
223
|
-
*/
|
|
224
|
-
occurrenceGroups = [];
|
|
225
222
|
/**
|
|
226
223
|
* The time at which the first occurrence of this cursor instance occurs.
|
|
227
224
|
*
|
|
228
225
|
* Will return `null` if there are no occurrences.
|
|
229
226
|
*/
|
|
230
227
|
get earliestOccurrenceTime() {
|
|
231
|
-
|
|
228
|
+
var _a, _b;
|
|
229
|
+
return (_b = (_a = this.occurrenceGroups.at(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : null;
|
|
232
230
|
}
|
|
233
231
|
/**
|
|
234
232
|
* The time at which the latest occurrence of this cursor instance occurs.
|
|
@@ -236,7 +234,8 @@ class CursorData {
|
|
|
236
234
|
* Will return `null` if there are no occurrences.
|
|
237
235
|
*/
|
|
238
236
|
get latestOccurrenceTime() {
|
|
239
|
-
|
|
237
|
+
var _a, _b;
|
|
238
|
+
return (_b = (_a = this.occurrenceGroups.at(-1)) === null || _a === void 0 ? void 0 : _a.endTime) !== null && _b !== void 0 ? _b : null;
|
|
240
239
|
}
|
|
241
240
|
/**
|
|
242
241
|
* The amount of cursor occurrences of this cursor instance.
|
|
@@ -263,6 +262,10 @@ class CursorData {
|
|
|
263
262
|
return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
|
|
264
263
|
}
|
|
265
264
|
constructor(values) {
|
|
265
|
+
/**
|
|
266
|
+
* The occurrence groups of this cursor instance.
|
|
267
|
+
*/
|
|
268
|
+
this.occurrenceGroups = [];
|
|
266
269
|
let downOccurrence = null;
|
|
267
270
|
let moveOccurrences = [];
|
|
268
271
|
for (let i = 0; i < values.size; ++i) {
|
|
@@ -313,165 +316,43 @@ exports.HitResult = void 0;
|
|
|
313
316
|
})(exports.HitResult || (exports.HitResult = {}));
|
|
314
317
|
|
|
315
318
|
/**
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
* Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
|
|
319
|
-
*
|
|
320
|
-
* This is used when analyzing replays using replay analyzer.
|
|
321
|
-
*/
|
|
322
|
-
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
|
-
constructor(values) {
|
|
343
|
-
this.replayVersion = values.replayVersion;
|
|
344
|
-
this.folderName = values.folderName;
|
|
345
|
-
this.fileName = values.fileName;
|
|
346
|
-
this.hash = values.hash;
|
|
347
|
-
this.time = new Date(values.time || 0);
|
|
348
|
-
this.hit300k = values.hit300k || 0;
|
|
349
|
-
this.hit100k = values.hit100k || 0;
|
|
350
|
-
this.score = values.score || 0;
|
|
351
|
-
this.maxCombo = values.maxCombo || 0;
|
|
352
|
-
this.accuracy = values.accuracy || new osuBase.Accuracy({});
|
|
353
|
-
this.isFullCombo = values.isFullCombo || false;
|
|
354
|
-
this.playerName = values.playerName || "";
|
|
355
|
-
this.rawMods = values.rawMods || "";
|
|
356
|
-
this.rank = values.rank || "";
|
|
357
|
-
this.convertedMods = values.convertedMods || [];
|
|
358
|
-
this.cursorMovement = values.cursorMovement;
|
|
359
|
-
this.hitObjectData = values.hitObjectData;
|
|
360
|
-
this.speedModification = values.speedModification || 1;
|
|
361
|
-
this.forcedAR = values.forcedAR;
|
|
362
|
-
}
|
|
363
|
-
}
|
|
364
|
-
|
|
365
|
-
/**
|
|
366
|
-
* Utility to check whether or not a beatmap is three-fingered.
|
|
319
|
+
* Utility to check whether or not a beatmap is three-fingered for rebalance scores.
|
|
367
320
|
*/
|
|
368
|
-
class
|
|
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;
|
|
321
|
+
class RebalanceThreeFingerChecker {
|
|
452
322
|
/**
|
|
453
323
|
* @param beatmap The beatmap to analyze.
|
|
454
324
|
* @param data The data of the replay.
|
|
455
325
|
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
456
326
|
*/
|
|
457
327
|
constructor(beatmap, data, difficultyAttributes) {
|
|
328
|
+
/**
|
|
329
|
+
* Extended sections of the beatmap for drag detection.
|
|
330
|
+
*/
|
|
331
|
+
this.beatmapSections = [];
|
|
332
|
+
/**
|
|
333
|
+
* A reprocessed break points to match right on object time.
|
|
334
|
+
*
|
|
335
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
336
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
337
|
+
*/
|
|
338
|
+
this.breakPointAccurateTimes = [];
|
|
339
|
+
/**
|
|
340
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
341
|
+
*
|
|
342
|
+
* Each index represents the cursor index.
|
|
343
|
+
*/
|
|
344
|
+
this.downCursorInstances = [];
|
|
345
|
+
/**
|
|
346
|
+
* Nerf factors from all sections that were three-fingered.
|
|
347
|
+
*/
|
|
348
|
+
this.nerfFactors = [];
|
|
458
349
|
this.beatmap = beatmap;
|
|
459
350
|
this.data = data;
|
|
460
351
|
this.difficultyAttributes = difficultyAttributes;
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
}).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
|
|
466
|
-
this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
|
|
467
|
-
this.hitWindow = new osuBase.DroidHitWindow(stats.od);
|
|
468
|
-
this.strainNoteCount =
|
|
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;
|
|
352
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
353
|
+
this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
354
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
355
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od);
|
|
475
356
|
}
|
|
476
357
|
/**
|
|
477
358
|
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
@@ -492,20 +373,13 @@ class ThreeFingerChecker {
|
|
|
492
373
|
* nerf factor, taking beatmap difficulty into account.
|
|
493
374
|
*/
|
|
494
375
|
check() {
|
|
495
|
-
if (this.
|
|
376
|
+
if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
|
|
377
|
+
this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
|
|
496
378
|
return { is3Finger: false, penalty: 1 };
|
|
497
379
|
}
|
|
498
380
|
this.getAccurateBreakPoints();
|
|
499
381
|
this.filterCursorInstances();
|
|
500
|
-
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
501
|
-
return { is3Finger: false, penalty: 1 };
|
|
502
|
-
}
|
|
503
382
|
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
383
|
this.calculateNerfFactors();
|
|
510
384
|
const finalPenalty = this.calculateFinalPenalty();
|
|
511
385
|
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
@@ -514,42 +388,32 @@ class ThreeFingerChecker {
|
|
|
514
388
|
* Generates a new set of "accurate break points".
|
|
515
389
|
*
|
|
516
390
|
* This is done to increase detection accuracy since break points do not start right at the
|
|
517
|
-
*
|
|
391
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
518
392
|
*/
|
|
519
393
|
getAccurateBreakPoints() {
|
|
520
|
-
const
|
|
394
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
521
395
|
const objectData = this.data.hitObjectData;
|
|
522
396
|
for (const breakPoint of this.beatmap.events.breaks) {
|
|
523
397
|
const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
let
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
default:
|
|
535
|
-
beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
398
|
+
const objectBefore = objects[beforeIndex];
|
|
399
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
400
|
+
let timeBefore = objectBefore.endTime;
|
|
401
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
402
|
+
if (objectBeforeData.result !== exports.HitResult.miss) {
|
|
403
|
+
timeBefore += objectBeforeData.accuracy;
|
|
404
|
+
}
|
|
405
|
+
else {
|
|
406
|
+
timeBefore += this.hitWindow.mehWindow;
|
|
407
|
+
}
|
|
536
408
|
}
|
|
537
|
-
timeBefore += beforeIndexHitWindowLength;
|
|
538
409
|
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);
|
|
410
|
+
const objectAfter = objects[afterIndex];
|
|
411
|
+
const objectAfterData = objectData[afterIndex];
|
|
412
|
+
let timeAfter = objectAfter.startTime;
|
|
413
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
414
|
+
objectAfterData.result !== exports.HitResult.miss) {
|
|
415
|
+
timeAfter += objectAfterData.accuracy;
|
|
551
416
|
}
|
|
552
|
-
timeAfter += afterIndexHitWindowLength;
|
|
553
417
|
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
554
418
|
startTime: timeBefore,
|
|
555
419
|
endTime: timeAfter,
|
|
@@ -562,48 +426,51 @@ class ThreeFingerChecker {
|
|
|
562
426
|
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
563
427
|
*/
|
|
564
428
|
filterCursorInstances() {
|
|
565
|
-
const
|
|
429
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
566
430
|
const objectData = this.data.hitObjectData;
|
|
567
431
|
const firstObjectResult = objectData[0].result;
|
|
568
432
|
const lastObjectResult = objectData.at(-1).result;
|
|
433
|
+
const firstObject = objects[0];
|
|
434
|
+
const lastObject = objects.at(-1);
|
|
569
435
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
570
|
-
let firstObjectHitWindow = this.hitWindow.
|
|
571
|
-
if (
|
|
436
|
+
let firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
437
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
572
438
|
switch (firstObjectResult) {
|
|
573
439
|
case exports.HitResult.great:
|
|
574
|
-
firstObjectHitWindow = this.hitWindow.
|
|
440
|
+
firstObjectHitWindow = this.hitWindow.greatWindow;
|
|
575
441
|
break;
|
|
576
442
|
case exports.HitResult.good:
|
|
577
|
-
firstObjectHitWindow = this.hitWindow.
|
|
443
|
+
firstObjectHitWindow = this.hitWindow.okWindow;
|
|
578
444
|
break;
|
|
579
445
|
default:
|
|
580
|
-
firstObjectHitWindow = this.hitWindow.
|
|
446
|
+
firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
581
447
|
}
|
|
582
448
|
}
|
|
583
449
|
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
584
|
-
let lastObjectHitWindow = this.hitWindow.
|
|
585
|
-
if (
|
|
450
|
+
let lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
451
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
586
452
|
switch (lastObjectResult) {
|
|
587
453
|
case exports.HitResult.great:
|
|
588
|
-
lastObjectHitWindow = this.hitWindow.
|
|
454
|
+
lastObjectHitWindow = this.hitWindow.greatWindow;
|
|
589
455
|
break;
|
|
590
456
|
case exports.HitResult.good:
|
|
591
|
-
lastObjectHitWindow = this.hitWindow.
|
|
457
|
+
lastObjectHitWindow = this.hitWindow.okWindow;
|
|
592
458
|
break;
|
|
593
459
|
default:
|
|
594
|
-
lastObjectHitWindow = this.hitWindow.
|
|
460
|
+
lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
595
461
|
}
|
|
596
462
|
}
|
|
463
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
464
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
465
|
+
}
|
|
597
466
|
// These hit time uses hit window length as threshold.
|
|
598
467
|
// This is because cursors aren't recorded exactly at hit time,
|
|
599
468
|
// probably due to the game's behavior.
|
|
600
|
-
const firstObjectHitTime =
|
|
601
|
-
const lastObjectHitTime =
|
|
602
|
-
for (
|
|
603
|
-
const cursorInstance = this.data.cursorMovement[i];
|
|
469
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
470
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
471
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
604
472
|
const validOccurrences = [];
|
|
605
|
-
for (
|
|
606
|
-
const group = cursorInstance.occurrenceGroups[j];
|
|
473
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
607
474
|
if (group.startTime < firstObjectHitTime) {
|
|
608
475
|
continue;
|
|
609
476
|
}
|
|
@@ -624,395 +491,898 @@ class ThreeFingerChecker {
|
|
|
624
491
|
* detect dragged sections and improve detection speed.
|
|
625
492
|
*/
|
|
626
493
|
getBeatmapSections() {
|
|
494
|
+
const beatmapObjects = this.beatmap.hitObjects.objects;
|
|
495
|
+
const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
496
|
+
// This intentionally starts from 1 because we need to look at the previous cursor.
|
|
497
|
+
const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
|
|
498
|
+
const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
627
499
|
for (const section of this.difficultyAttributes
|
|
628
500
|
.possibleThreeFingeredSections) {
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
*/
|
|
639
|
-
detectDragPlay() {
|
|
640
|
-
for (let i = 0; i < this.beatmapSections.length; ++i) {
|
|
641
|
-
const dragIndex = this.checkDrag(this.beatmapSections[i]);
|
|
642
|
-
this.beatmapSections[i].dragFingerIndex = dragIndex;
|
|
643
|
-
this.beatmapSections[i].isDragged = dragIndex !== -1;
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
/**
|
|
647
|
-
* Checks if a section is dragged and returns the index of the drag finger.
|
|
648
|
-
*
|
|
649
|
-
* If the section is not dragged, -1 will be returned.
|
|
650
|
-
*
|
|
651
|
-
* @param section The section to check.
|
|
652
|
-
*/
|
|
653
|
-
checkDrag(section) {
|
|
654
|
-
const { objects } = this.beatmap.hitObjects;
|
|
655
|
-
const objectData = this.data.hitObjectData;
|
|
656
|
-
const firstObject = objects[section.firstObjectIndex];
|
|
657
|
-
const lastObject = objects[section.lastObjectIndex];
|
|
658
|
-
let firstObjectMinHitTime = firstObject.startTime;
|
|
659
|
-
if (firstObject instanceof osuBase.Circle) {
|
|
660
|
-
switch (objectData[section.firstObjectIndex].result) {
|
|
661
|
-
case exports.HitResult.great:
|
|
662
|
-
firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
663
|
-
break;
|
|
664
|
-
case exports.HitResult.good:
|
|
665
|
-
firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
666
|
-
break;
|
|
667
|
-
default:
|
|
668
|
-
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
else {
|
|
672
|
-
firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
673
|
-
}
|
|
674
|
-
let lastObjectMaxHitTime = lastObject.startTime;
|
|
675
|
-
if (lastObject instanceof osuBase.Circle) {
|
|
676
|
-
switch (objectData[section.lastObjectIndex].result) {
|
|
677
|
-
case exports.HitResult.great:
|
|
678
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
|
|
679
|
-
break;
|
|
680
|
-
case exports.HitResult.good:
|
|
681
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
|
|
682
|
-
break;
|
|
683
|
-
default:
|
|
684
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
685
|
-
}
|
|
686
|
-
}
|
|
687
|
-
else {
|
|
688
|
-
lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
|
|
689
|
-
}
|
|
690
|
-
// Since there may be more than 1 cursor instance index,
|
|
691
|
-
// we check which cursor instance follows hitobjects all over.
|
|
692
|
-
const cursorIndexes = [];
|
|
693
|
-
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
694
|
-
const c = this.data.cursorMovement[i];
|
|
695
|
-
if (c.occurrenceGroups.length === 0) {
|
|
696
|
-
continue;
|
|
697
|
-
}
|
|
698
|
-
// Do not include cursors that don't have an occurence in this section
|
|
699
|
-
// this speeds up checking process.
|
|
700
|
-
if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
|
|
701
|
-
v.endTime <= lastObjectMaxHitTime).length === 0) {
|
|
702
|
-
continue;
|
|
703
|
-
}
|
|
704
|
-
// If this cursor instance doesn't move, it's not the cursor instance we want.
|
|
705
|
-
if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
|
|
706
|
-
0) {
|
|
707
|
-
continue;
|
|
501
|
+
const objects = [];
|
|
502
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
503
|
+
const object = beatmapObjects[i];
|
|
504
|
+
const objectData = this.data.hitObjectData[i];
|
|
505
|
+
objects.push({
|
|
506
|
+
object: object,
|
|
507
|
+
aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
|
|
508
|
+
pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
|
|
509
|
+
});
|
|
708
510
|
}
|
|
709
|
-
|
|
511
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
|
|
710
512
|
}
|
|
711
|
-
return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
|
|
712
513
|
}
|
|
713
514
|
/**
|
|
714
|
-
*
|
|
515
|
+
* Obtains the index of the cursor that aimed the object at the nearest time.
|
|
715
516
|
*
|
|
716
|
-
* @param
|
|
717
|
-
* @param
|
|
718
|
-
* @param
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
517
|
+
* @param object The object to obtain the index for.
|
|
518
|
+
* @param objectData The hit data of the object.
|
|
519
|
+
* @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
|
|
520
|
+
* @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
|
|
521
|
+
* @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
522
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
523
|
+
*/
|
|
524
|
+
getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
|
|
525
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
526
|
+
return -1;
|
|
527
|
+
}
|
|
528
|
+
// Check for sliderbreaks and treat them as misses.
|
|
529
|
+
if (object instanceof osuBase.Slider &&
|
|
530
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
531
|
+
objectData.accuracy >
|
|
532
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
533
|
+
return -1;
|
|
534
|
+
}
|
|
535
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
536
|
+
const objectPosition = object.stackedPosition;
|
|
537
|
+
// We are maintaining the closest distance to the object.
|
|
538
|
+
// This is because the radius that is calculated is using an estimation.
|
|
539
|
+
// As such, it does not reflect the actual object radius in gameplay.
|
|
540
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
541
|
+
let nearestCursorIndex = -1;
|
|
542
|
+
// Observe the cursor position at the object's hit time.
|
|
543
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
544
|
+
const cursorData = this.data.cursorMovement[i];
|
|
545
|
+
for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
|
|
546
|
+
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
547
|
+
if (cursorGroup.endTime < hitTime) {
|
|
548
|
+
// Reset cursor index pointer.
|
|
549
|
+
cursorIndices[i] = 1;
|
|
744
550
|
continue;
|
|
745
551
|
}
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
if (!cursorGroup) {
|
|
749
|
-
continue;
|
|
552
|
+
if (cursorGroup.startTime > hitTime) {
|
|
553
|
+
break;
|
|
750
554
|
}
|
|
751
555
|
const cursors = cursorGroup.allOccurrences;
|
|
752
|
-
for (let k =
|
|
556
|
+
for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
|
|
753
557
|
const cursor = cursors[k];
|
|
754
558
|
const prevCursor = cursors[k - 1];
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
if (prevCursor.time > object.startTime + hitWindow50) {
|
|
559
|
+
// Cursor is past the object's hit time.
|
|
560
|
+
if (prevCursor.time > hitTime) {
|
|
759
561
|
break;
|
|
760
562
|
}
|
|
761
|
-
|
|
563
|
+
// Cursor is before the object's hit time.
|
|
564
|
+
if (hitTime > cursor.time) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
let distance;
|
|
568
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
569
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
762
570
|
switch (cursor.id) {
|
|
763
571
|
case exports.MovementType.up:
|
|
764
|
-
|
|
765
|
-
prevCursor.position.getDistance(objectPosition) <= object.getRadius(osuBase.Modes.droid);
|
|
572
|
+
distance = prevPosition.getDistance(objectPosition);
|
|
766
573
|
break;
|
|
767
|
-
case exports.MovementType.move:
|
|
574
|
+
case exports.MovementType.move: {
|
|
768
575
|
// Interpolate movement.
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
576
|
+
const t = (hitTime - prevCursor.time) /
|
|
577
|
+
(cursor.time - prevCursor.time);
|
|
578
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
579
|
+
distance =
|
|
580
|
+
objectPosition.getDistance(cursorPosition);
|
|
581
|
+
break;
|
|
582
|
+
}
|
|
583
|
+
case exports.MovementType.down:
|
|
584
|
+
continue;
|
|
778
585
|
}
|
|
779
|
-
if (
|
|
780
|
-
|
|
586
|
+
if (closestDistance > distance) {
|
|
587
|
+
closestDistance = distance;
|
|
588
|
+
nearestCursorIndex = i;
|
|
781
589
|
}
|
|
782
590
|
}
|
|
591
|
+
// Reset cursor index pointer on end of group.
|
|
592
|
+
if (cursorIndices[i] === cursors.length) {
|
|
593
|
+
cursorIndices[i] = 1;
|
|
594
|
+
}
|
|
595
|
+
break;
|
|
783
596
|
}
|
|
597
|
+
// The previous object may still be hit with the same cursor group or cursor index.
|
|
598
|
+
cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
|
|
599
|
+
cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
|
|
784
600
|
}
|
|
785
|
-
return
|
|
601
|
+
return nearestCursorIndex;
|
|
786
602
|
}
|
|
787
603
|
/**
|
|
788
|
-
*
|
|
604
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
789
605
|
*
|
|
790
|
-
*
|
|
791
|
-
*
|
|
792
|
-
*
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
606
|
+
* @param object The object to obtain the index for.
|
|
607
|
+
* @param objectData The hit data of the object.
|
|
608
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
609
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
610
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
611
|
+
*/
|
|
612
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices) {
|
|
613
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
614
|
+
return -1;
|
|
615
|
+
}
|
|
616
|
+
// Check for sliderbreaks and treat them as misses.
|
|
617
|
+
if (object instanceof osuBase.Slider &&
|
|
618
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
619
|
+
objectData.accuracy >
|
|
620
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
621
|
+
return -1;
|
|
622
|
+
}
|
|
623
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
624
|
+
let nearestCursorInstanceIndex = -1;
|
|
625
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
801
626
|
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
802
|
-
|
|
803
|
-
|
|
627
|
+
const cursors = this.downCursorInstances[i];
|
|
628
|
+
let cursorNearestTime = Number.POSITIVE_INFINITY;
|
|
629
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
630
|
+
const cursor = cursors[j];
|
|
631
|
+
if (cursor.time > hitTime) {
|
|
632
|
+
break;
|
|
633
|
+
}
|
|
634
|
+
cursorNearestTime = hitTime - cursor.time;
|
|
804
635
|
}
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
Math.ceil(objects.length / this.accidentalTapThreshold) &&
|
|
809
|
-
cursorInstances.length / totalCursorAmount <
|
|
810
|
-
this.threeFingerRatioThreshold * 2) {
|
|
811
|
-
--filledCursorAmount;
|
|
812
|
-
cursorInstances.length = 0;
|
|
636
|
+
if (cursorNearestTime < nearestTime) {
|
|
637
|
+
nearestCursorInstanceIndex = i;
|
|
638
|
+
nearestTime = cursorNearestTime;
|
|
813
639
|
}
|
|
814
|
-
this.downCursorInstances[i] = cursorInstances;
|
|
815
640
|
}
|
|
641
|
+
return nearestCursorInstanceIndex;
|
|
816
642
|
}
|
|
817
643
|
/**
|
|
818
644
|
* Creates nerf factors by scanning through objects.
|
|
819
|
-
*
|
|
820
|
-
* This check will ignore all objects with speed strain below `strainThreshold`.
|
|
821
645
|
*/
|
|
822
646
|
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
647
|
for (const beatmapSection of this.beatmapSections) {
|
|
828
|
-
const
|
|
829
|
-
const
|
|
830
|
-
(
|
|
831
|
-
exports.HitResult.miss
|
|
832
|
-
? objectData[beatmapSection.firstObjectIndex].accuracy
|
|
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) {
|
|
648
|
+
const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
|
|
649
|
+
for (const object of beatmapSection.objects) {
|
|
650
|
+
if (object.pressingCursorInstanceIndex === -1) {
|
|
844
651
|
continue;
|
|
845
652
|
}
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
}
|
|
857
|
-
}
|
|
858
|
-
cursorAmounts.push(amount);
|
|
859
|
-
}
|
|
860
|
-
// This index will be used to detect if a section is 3-fingered.
|
|
861
|
-
// If the section is dragged, the dragged instance will be ignored,
|
|
862
|
-
// hence why the index is 1 less than nondragged section.
|
|
863
|
-
const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
|
|
864
|
-
// Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
|
|
865
|
-
// to check if the section is 3-fingered.
|
|
866
|
-
const threeFingerRatio = cursorAmounts
|
|
867
|
-
.slice(fingerSplitIndex)
|
|
868
|
-
.reduce((acc, value) => acc + value, 0) /
|
|
869
|
-
cursorAmounts
|
|
870
|
-
.slice(0, fingerSplitIndex)
|
|
871
|
-
.reduce((acc, value) => acc + value, 0);
|
|
872
|
-
const similarPresses = [];
|
|
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;
|
|
653
|
+
if (object.aimingCursorInstanceIndex < 3) {
|
|
654
|
+
// The aim cursor is in the first three cursors. They are counted as non-3 finger.
|
|
655
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
656
|
+
case 0:
|
|
657
|
+
case 1:
|
|
658
|
+
case 2:
|
|
659
|
+
break;
|
|
660
|
+
default:
|
|
661
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
|
|
662
|
+
break;
|
|
887
663
|
}
|
|
888
|
-
similarPresses[pressIndex].vector = cursorVectorTime.vector;
|
|
889
|
-
similarPresses[pressIndex].lastTime = cursorVectorTime.time;
|
|
890
|
-
++similarPresses[pressIndex].count;
|
|
891
664
|
}
|
|
892
665
|
else {
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
666
|
+
// The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
|
|
667
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
668
|
+
case 0:
|
|
669
|
+
case 1:
|
|
670
|
+
break;
|
|
671
|
+
default:
|
|
672
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
|
|
673
|
+
break;
|
|
674
|
+
}
|
|
898
675
|
}
|
|
899
676
|
}
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
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 -
|
|
912
|
-
beatmapSection.firstObjectIndex +
|
|
913
|
-
1;
|
|
914
|
-
// We can ignore the first 3 (2 for drag) filled cursor instances
|
|
915
|
-
// since they are guaranteed not 3 finger.
|
|
916
|
-
const threeFingerCursorAmounts = cursorAmounts
|
|
917
|
-
.slice(fingerSplitIndex)
|
|
918
|
-
.filter((amount) => amount > 0);
|
|
919
|
-
// Finger factor applies more penalty if more fingers were used.
|
|
920
|
-
const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
|
|
921
|
-
? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
|
|
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);
|
|
932
|
-
// Length factor applies more penalty if there are more 3-fingered object.
|
|
933
|
-
const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
|
|
934
|
-
this.nerfFactors.push({
|
|
935
|
-
strainFactor: Math.max(1, beatmapSection.sumStrain),
|
|
936
|
-
fingerFactor,
|
|
937
|
-
lengthFactor,
|
|
938
|
-
});
|
|
677
|
+
const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
|
|
678
|
+
if (threeFingerCursorCount === 0) {
|
|
679
|
+
continue;
|
|
939
680
|
}
|
|
681
|
+
const sectionObjectCount = beatmapSection.objects.length;
|
|
682
|
+
const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
|
|
683
|
+
const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
|
|
684
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
685
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
686
|
+
Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
|
|
687
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
688
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
|
|
689
|
+
this.nerfFactors.push({
|
|
690
|
+
strainFactor: strainFactor,
|
|
691
|
+
fingerFactor: fingerFactor,
|
|
692
|
+
lengthFactor: lengthFactor,
|
|
693
|
+
});
|
|
940
694
|
}
|
|
941
695
|
}
|
|
942
696
|
/**
|
|
943
697
|
* Calculates the final penalty.
|
|
944
698
|
*/
|
|
945
699
|
calculateFinalPenalty() {
|
|
946
|
-
return (
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
700
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
701
|
+
0.015 *
|
|
702
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
703
|
+
}
|
|
704
|
+
getCursorPosition(cursor) {
|
|
705
|
+
if (this.isHardRock) {
|
|
706
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
707
|
+
}
|
|
708
|
+
return cursor.position;
|
|
950
709
|
}
|
|
951
710
|
}
|
|
952
711
|
|
|
712
|
+
/******************************************************************************
|
|
713
|
+
Copyright (c) Microsoft Corporation.
|
|
714
|
+
|
|
715
|
+
Permission to use, copy, modify, and/or distribute this software for any
|
|
716
|
+
purpose with or without fee is hereby granted.
|
|
717
|
+
|
|
718
|
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
|
719
|
+
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
|
720
|
+
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
|
721
|
+
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
|
722
|
+
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
|
723
|
+
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
|
724
|
+
PERFORMANCE OF THIS SOFTWARE.
|
|
725
|
+
***************************************************************************** */
|
|
726
|
+
/* global Reflect, Promise, SuppressedError, Symbol */
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
function __awaiter(thisArg, _arguments, P, generator) {
|
|
730
|
+
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
|
|
731
|
+
return new (P || (P = Promise))(function (resolve, reject) {
|
|
732
|
+
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
|
|
733
|
+
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
|
|
734
|
+
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
|
|
735
|
+
step((generator = generator.apply(thisArg, _arguments || [])).next());
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
|
|
740
|
+
var e = new Error(message);
|
|
741
|
+
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
|
|
742
|
+
};
|
|
743
|
+
|
|
953
744
|
/**
|
|
954
|
-
*
|
|
745
|
+
* Utility to check whether relevant sliders in a beatmap are cheesed.
|
|
955
746
|
*/
|
|
956
|
-
class
|
|
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;
|
|
747
|
+
class SliderCheeseChecker {
|
|
990
748
|
/**
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
749
|
+
* @param beatmap The beatmap to analyze.
|
|
750
|
+
* @param data The data of the replay.
|
|
751
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
994
752
|
*/
|
|
995
|
-
|
|
753
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
754
|
+
this.beatmap = beatmap;
|
|
755
|
+
this.data = data;
|
|
756
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
757
|
+
this.hitWindow50 = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
758
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od).mehWindow
|
|
759
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od).mehWindow;
|
|
760
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
761
|
+
}
|
|
996
762
|
/**
|
|
997
|
-
*
|
|
763
|
+
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
998
764
|
*/
|
|
999
|
-
|
|
765
|
+
check() {
|
|
766
|
+
if (this.difficultyAttributes.difficultSliders.length === 0 ||
|
|
767
|
+
(this.difficultyAttributes.sliderFactor === 1 &&
|
|
768
|
+
this.difficultyAttributes.flashlightSliderFactor === 1 &&
|
|
769
|
+
this.difficultyAttributes.visualSliderFactor === 1)) {
|
|
770
|
+
return {
|
|
771
|
+
aimPenalty: 1,
|
|
772
|
+
flashlightPenalty: 1,
|
|
773
|
+
visualPenalty: 1,
|
|
774
|
+
};
|
|
775
|
+
}
|
|
776
|
+
const cheesedDifficultyRatings = this.checkSliderCheesing();
|
|
777
|
+
return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
|
|
778
|
+
}
|
|
1000
779
|
/**
|
|
1001
|
-
*
|
|
1002
|
-
* @param cursorIndex The cursor index that moves towards the hitobject.
|
|
1003
|
-
* @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
|
|
1004
|
-
* @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
|
|
1005
|
-
* @param angle The angle of the movement of the cursor that moves towards the hitobject.
|
|
780
|
+
* Checks for sliders that were cheesed.
|
|
1006
781
|
*/
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
this.
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
782
|
+
checkSliderCheesing() {
|
|
783
|
+
const { objects } = this.beatmap.hitObjects;
|
|
784
|
+
const cheesedDifficultyRatings = [];
|
|
785
|
+
// Current loop indices are stored for efficiency.
|
|
786
|
+
const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
|
|
787
|
+
const acceptableRadius = objects[0].radius * 2;
|
|
788
|
+
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
789
|
+
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
790
|
+
.slice()
|
|
791
|
+
.sort((a, b) => a.index - b.index)) {
|
|
792
|
+
if (difficultSlider.index >= this.data.hitObjectData.length) {
|
|
793
|
+
continue;
|
|
794
|
+
}
|
|
795
|
+
const object = objects[difficultSlider.index];
|
|
796
|
+
const objectData = this.data.hitObjectData[difficultSlider.index];
|
|
797
|
+
// If a miss or slider break occurs, we disregard the check for that slider.
|
|
798
|
+
if (objectData.result === exports.HitResult.miss ||
|
|
799
|
+
-this.hitWindow50 > objectData.accuracy ||
|
|
800
|
+
objectData.accuracy >
|
|
801
|
+
Math.min(this.hitWindow50, object.duration)) {
|
|
802
|
+
continue;
|
|
803
|
+
}
|
|
804
|
+
const objectStartPosition = object.stackedPosition;
|
|
805
|
+
// These time boundaries should consider the delta time between the previous and next
|
|
806
|
+
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
807
|
+
// compute and the accuracy gain is small. As such, let's settle with 50 hit window.
|
|
808
|
+
const minTimeLimit = object.startTime - this.hitWindow50;
|
|
809
|
+
const maxTimeLimit = object.startTime + this.hitWindow50;
|
|
810
|
+
// Get the closest tap distance across all cursors.
|
|
811
|
+
const closestDistances = [];
|
|
812
|
+
const closestGroupIndices = [];
|
|
813
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
814
|
+
const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
|
|
815
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
816
|
+
let closestIndex = cursorGroups.length;
|
|
817
|
+
for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
|
|
818
|
+
const group = cursorGroups[j];
|
|
819
|
+
if (group.endTime < minTimeLimit) {
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
822
|
+
if (group.startTime > maxTimeLimit) {
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
if (group.startTime >= minTimeLimit) {
|
|
826
|
+
const position = this.getCursorPosition(group.down);
|
|
827
|
+
const distance = position.getDistance(objectStartPosition);
|
|
828
|
+
if (closestDistance > distance) {
|
|
829
|
+
closestDistance = distance;
|
|
830
|
+
closestIndex = j;
|
|
831
|
+
}
|
|
832
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
833
|
+
break;
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
// Normally, we check if there are cursor presses within the group's active time.
|
|
837
|
+
// However, some funky workarounds are used throughout the game for replays, so
|
|
838
|
+
// for the time being we only check for cursor distances across the group.
|
|
839
|
+
const { allOccurrences } = group;
|
|
840
|
+
for (let k = 1; k < allOccurrences.length; ++k) {
|
|
841
|
+
const cursor = allOccurrences[k];
|
|
842
|
+
const prevCursor = allOccurrences[k - 1];
|
|
843
|
+
let distance = Number.POSITIVE_INFINITY;
|
|
844
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
845
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
846
|
+
switch (cursor.id) {
|
|
847
|
+
case exports.MovementType.up:
|
|
848
|
+
distance =
|
|
849
|
+
prevPosition.getDistance(objectStartPosition);
|
|
850
|
+
break;
|
|
851
|
+
case exports.MovementType.move:
|
|
852
|
+
for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
|
|
853
|
+
Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
|
|
854
|
+
const t = (mSecPassed - prevCursor.time) /
|
|
855
|
+
(cursor.time - prevCursor.time);
|
|
856
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
857
|
+
distance =
|
|
858
|
+
cursorPosition.getDistance(objectStartPosition);
|
|
859
|
+
if (closestDistance > distance) {
|
|
860
|
+
closestDistance = distance;
|
|
861
|
+
closestIndex = j;
|
|
862
|
+
}
|
|
863
|
+
if (closestDistance <=
|
|
864
|
+
acceptableRadius / 2) {
|
|
865
|
+
break;
|
|
866
|
+
}
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
if (closestDistance > distance) {
|
|
870
|
+
closestDistance = distance;
|
|
871
|
+
closestIndex = j;
|
|
872
|
+
}
|
|
873
|
+
if (closestDistance <= acceptableRadius / 2) {
|
|
874
|
+
break;
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
closestDistances.push(closestDistance);
|
|
879
|
+
closestGroupIndices.push(closestIndex);
|
|
880
|
+
if (cursorLoopIndices[i] > 0) {
|
|
881
|
+
// Decrement the index. The previous group may also have a role on the next slider.
|
|
882
|
+
--cursorLoopIndices[i];
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
886
|
+
const closestDistance = closestDistances[cursorIndex];
|
|
887
|
+
if (closestDistance > acceptableRadius / 2) {
|
|
888
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
889
|
+
continue;
|
|
890
|
+
}
|
|
891
|
+
const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
|
|
892
|
+
let isCheesed = false;
|
|
893
|
+
// Track cursor movement to see if it lands on every tick.
|
|
894
|
+
let occurrenceLoopIndex = 1;
|
|
895
|
+
const { allOccurrences } = group;
|
|
896
|
+
for (let i = 1; i < object.nestedHitObjects.length; ++i) {
|
|
897
|
+
if (isCheesed) {
|
|
898
|
+
break;
|
|
899
|
+
}
|
|
900
|
+
const tickWasHit = objectData.tickset[i - 1];
|
|
901
|
+
if (!tickWasHit) {
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
const nestedObject = object.nestedHitObjects[i];
|
|
905
|
+
const nestedPosition = nestedObject.stackedPosition;
|
|
906
|
+
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
907
|
+
allOccurrences[occurrenceLoopIndex].time <
|
|
908
|
+
nestedObject.startTime) {
|
|
909
|
+
++occurrenceLoopIndex;
|
|
910
|
+
}
|
|
911
|
+
if (occurrenceLoopIndex === allOccurrences.length) {
|
|
912
|
+
continue;
|
|
913
|
+
}
|
|
914
|
+
const cursor = allOccurrences[occurrenceLoopIndex];
|
|
915
|
+
const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
|
|
916
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
917
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
918
|
+
switch (cursor.id) {
|
|
919
|
+
case exports.MovementType.move: {
|
|
920
|
+
// Interpolate cursor position during nested object time.
|
|
921
|
+
const t = (nestedObject.startTime - prevCursor.time) /
|
|
922
|
+
(cursor.time - prevCursor.time);
|
|
923
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
924
|
+
const distance = cursorPosition.getDistance(nestedPosition);
|
|
925
|
+
isCheesed = distance > acceptableRadius;
|
|
926
|
+
break;
|
|
927
|
+
}
|
|
928
|
+
case exports.MovementType.up:
|
|
929
|
+
isCheesed =
|
|
930
|
+
prevPosition.getDistance(nestedPosition) >
|
|
931
|
+
acceptableRadius;
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
if (isCheesed) {
|
|
935
|
+
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
return cheesedDifficultyRatings;
|
|
939
|
+
}
|
|
940
|
+
/**
|
|
941
|
+
* Calculates the slider cheese penalty.
|
|
942
|
+
*/
|
|
943
|
+
calculateSliderCheesePenalty(cheesedDifficultyRatings) {
|
|
944
|
+
const summedDifficultyRating = Math.min(1, cheesedDifficultyRatings.reduce((a, v) => a + v, 0));
|
|
945
|
+
return {
|
|
946
|
+
aimPenalty: Math.max(this.difficultyAttributes.sliderFactor, Math.pow(1 -
|
|
947
|
+
summedDifficultyRating *
|
|
948
|
+
this.difficultyAttributes.sliderFactor, 2)),
|
|
949
|
+
flashlightPenalty: Math.max(this.difficultyAttributes.flashlightSliderFactor, Math.pow(1 -
|
|
950
|
+
summedDifficultyRating *
|
|
951
|
+
this.difficultyAttributes.flashlightSliderFactor, 2)),
|
|
952
|
+
visualPenalty: Math.max(this.difficultyAttributes.visualSliderFactor, Math.pow(1 -
|
|
953
|
+
summedDifficultyRating *
|
|
954
|
+
this.difficultyAttributes.visualSliderFactor, 2)),
|
|
955
|
+
};
|
|
956
|
+
}
|
|
957
|
+
getCursorPosition(cursor) {
|
|
958
|
+
if (this.isHardRock) {
|
|
959
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
960
|
+
}
|
|
961
|
+
return cursor.position;
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Utility to check whether or not a beatmap is three-fingered for rebalance scores.
|
|
967
|
+
*/
|
|
968
|
+
class ThreeFingerChecker {
|
|
969
|
+
/**
|
|
970
|
+
* @param beatmap The beatmap to analyze.
|
|
971
|
+
* @param data The data of the replay.
|
|
972
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
973
|
+
*/
|
|
974
|
+
constructor(beatmap, data, difficultyAttributes) {
|
|
975
|
+
/**
|
|
976
|
+
* Extended sections of the beatmap for drag detection.
|
|
977
|
+
*/
|
|
978
|
+
this.beatmapSections = [];
|
|
979
|
+
/**
|
|
980
|
+
* A reprocessed break points to match right on object time.
|
|
981
|
+
*
|
|
982
|
+
* This is used to increase detection accuracy since break points do not start right at the
|
|
983
|
+
* start of the hitobject before it and do not end right at the first hitobject after it.
|
|
984
|
+
*/
|
|
985
|
+
this.breakPointAccurateTimes = [];
|
|
986
|
+
/**
|
|
987
|
+
* A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
|
|
988
|
+
*
|
|
989
|
+
* Each index represents the cursor index.
|
|
990
|
+
*/
|
|
991
|
+
this.downCursorInstances = [];
|
|
992
|
+
/**
|
|
993
|
+
* Nerf factors from all sections that were three-fingered.
|
|
994
|
+
*/
|
|
995
|
+
this.nerfFactors = [];
|
|
996
|
+
this.beatmap = beatmap;
|
|
997
|
+
this.data = data;
|
|
998
|
+
this.difficultyAttributes = difficultyAttributes;
|
|
999
|
+
this.isHardRock = difficultyAttributes.mods.has(osuBase.ModHardRock);
|
|
1000
|
+
this.hitWindow = difficultyAttributes.mods.has(osuBase.ModPrecise)
|
|
1001
|
+
? new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
|
|
1002
|
+
: new osuBase.DroidHitWindow(beatmap.difficulty.od);
|
|
1003
|
+
}
|
|
1004
|
+
/**
|
|
1005
|
+
* Checks whether a beatmap is eligible to be detected for 3-finger.
|
|
1006
|
+
*
|
|
1007
|
+
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
1008
|
+
*/
|
|
1009
|
+
static isEligibleToDetect(difficultyAttributes) {
|
|
1010
|
+
return difficultyAttributes.possibleThreeFingeredSections.length > 0;
|
|
1011
|
+
}
|
|
1012
|
+
/**
|
|
1013
|
+
* Checks if the given beatmap is 3-fingered and also returns the final penalty.
|
|
1014
|
+
*
|
|
1015
|
+
* The beatmap will be separated into sections and each section will be determined
|
|
1016
|
+
* whether or not it is dragged.
|
|
1017
|
+
*
|
|
1018
|
+
* After that, each section will be assigned a nerf factor based on whether or not
|
|
1019
|
+
* the section is 3-fingered. These nerf factors will be summed up into a final
|
|
1020
|
+
* nerf factor, taking beatmap difficulty into account.
|
|
1021
|
+
*/
|
|
1022
|
+
check() {
|
|
1023
|
+
if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
|
|
1024
|
+
this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
|
|
1025
|
+
return { is3Finger: false, penalty: 1 };
|
|
1026
|
+
}
|
|
1027
|
+
this.getAccurateBreakPoints();
|
|
1028
|
+
this.filterCursorInstances();
|
|
1029
|
+
if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
|
|
1030
|
+
return { is3Finger: false, penalty: 1 };
|
|
1031
|
+
}
|
|
1032
|
+
this.getBeatmapSections();
|
|
1033
|
+
this.calculateNerfFactors();
|
|
1034
|
+
const finalPenalty = this.calculateFinalPenalty();
|
|
1035
|
+
return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
|
|
1036
|
+
}
|
|
1037
|
+
/**
|
|
1038
|
+
* Generates a new set of "accurate break points".
|
|
1039
|
+
*
|
|
1040
|
+
* This is done to increase detection accuracy since break points do not start right at the
|
|
1041
|
+
* end of the hitobject before it and do not end right at the first hitobject after it.
|
|
1042
|
+
*/
|
|
1043
|
+
getAccurateBreakPoints() {
|
|
1044
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
1045
|
+
const objectData = this.data.hitObjectData;
|
|
1046
|
+
for (const breakPoint of this.beatmap.events.breaks) {
|
|
1047
|
+
const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
|
|
1048
|
+
const objectBefore = objects[beforeIndex];
|
|
1049
|
+
const objectBeforeData = objectData[beforeIndex];
|
|
1050
|
+
let timeBefore = objectBefore.endTime;
|
|
1051
|
+
if (objectBefore instanceof osuBase.Circle) {
|
|
1052
|
+
if (objectBeforeData.result !== exports.HitResult.miss) {
|
|
1053
|
+
timeBefore += objectBeforeData.accuracy;
|
|
1054
|
+
}
|
|
1055
|
+
else {
|
|
1056
|
+
timeBefore += this.hitWindow.mehWindow;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
const afterIndex = beforeIndex + 1;
|
|
1060
|
+
const objectAfter = objects[afterIndex];
|
|
1061
|
+
const objectAfterData = objectData[afterIndex];
|
|
1062
|
+
let timeAfter = objectAfter.startTime;
|
|
1063
|
+
if (objectAfter instanceof osuBase.Circle &&
|
|
1064
|
+
objectAfterData.result !== exports.HitResult.miss) {
|
|
1065
|
+
timeAfter += objectAfterData.accuracy;
|
|
1066
|
+
}
|
|
1067
|
+
this.breakPointAccurateTimes.push(new osuBase.BreakPoint({
|
|
1068
|
+
startTime: timeBefore,
|
|
1069
|
+
endTime: timeAfter,
|
|
1070
|
+
}));
|
|
1071
|
+
}
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
|
|
1075
|
+
*
|
|
1076
|
+
* This also filters cursors that are in break period or happen before start/after end of the beatmap.
|
|
1077
|
+
*/
|
|
1078
|
+
filterCursorInstances() {
|
|
1079
|
+
const objects = this.beatmap.hitObjects.objects;
|
|
1080
|
+
const objectData = this.data.hitObjectData;
|
|
1081
|
+
const firstObjectResult = objectData[0].result;
|
|
1082
|
+
const lastObjectResult = objectData.at(-1).result;
|
|
1083
|
+
const firstObject = objects[0];
|
|
1084
|
+
const lastObject = objects.at(-1);
|
|
1085
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
1086
|
+
let firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
1087
|
+
if (firstObject instanceof osuBase.Circle) {
|
|
1088
|
+
switch (firstObjectResult) {
|
|
1089
|
+
case exports.HitResult.great:
|
|
1090
|
+
firstObjectHitWindow = this.hitWindow.greatWindow;
|
|
1091
|
+
break;
|
|
1092
|
+
case exports.HitResult.good:
|
|
1093
|
+
firstObjectHitWindow = this.hitWindow.okWindow;
|
|
1094
|
+
break;
|
|
1095
|
+
default:
|
|
1096
|
+
firstObjectHitWindow = this.hitWindow.mehWindow;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
// For sliders, automatically set hit window length to be as lenient as possible.
|
|
1100
|
+
let lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
1101
|
+
if (lastObject instanceof osuBase.Circle) {
|
|
1102
|
+
switch (lastObjectResult) {
|
|
1103
|
+
case exports.HitResult.great:
|
|
1104
|
+
lastObjectHitWindow = this.hitWindow.greatWindow;
|
|
1105
|
+
break;
|
|
1106
|
+
case exports.HitResult.good:
|
|
1107
|
+
lastObjectHitWindow = this.hitWindow.okWindow;
|
|
1108
|
+
break;
|
|
1109
|
+
default:
|
|
1110
|
+
lastObjectHitWindow = this.hitWindow.mehWindow;
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
else if (lastObject instanceof osuBase.Slider) {
|
|
1114
|
+
lastObjectHitWindow = Math.min(lastObject.spanDuration, lastObjectHitWindow);
|
|
1115
|
+
}
|
|
1116
|
+
// These hit time uses hit window length as threshold.
|
|
1117
|
+
// This is because cursors aren't recorded exactly at hit time,
|
|
1118
|
+
// probably due to the game's behavior.
|
|
1119
|
+
const firstObjectHitTime = firstObject.startTime - firstObjectHitWindow;
|
|
1120
|
+
const lastObjectHitTime = lastObject.startTime + lastObjectHitWindow;
|
|
1121
|
+
for (const cursorInstance of this.data.cursorMovement) {
|
|
1122
|
+
const validOccurrences = [];
|
|
1123
|
+
for (const group of cursorInstance.occurrenceGroups) {
|
|
1124
|
+
if (group.startTime < firstObjectHitTime) {
|
|
1125
|
+
continue;
|
|
1126
|
+
}
|
|
1127
|
+
if (group.startTime > lastObjectHitTime) {
|
|
1128
|
+
break;
|
|
1129
|
+
}
|
|
1130
|
+
if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
|
|
1131
|
+
group.endTime <= v.endTime)) {
|
|
1132
|
+
continue;
|
|
1133
|
+
}
|
|
1134
|
+
validOccurrences.push(group.down);
|
|
1135
|
+
}
|
|
1136
|
+
this.downCursorInstances.push(validOccurrences);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
/**
|
|
1140
|
+
* Divides the beatmap into sections, which will be used to
|
|
1141
|
+
* detect dragged sections and improve detection speed.
|
|
1142
|
+
*/
|
|
1143
|
+
getBeatmapSections() {
|
|
1144
|
+
const beatmapObjects = this.beatmap.hitObjects.objects;
|
|
1145
|
+
const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
1146
|
+
// This intentionally starts from 1 because we need to look at the previous cursor.
|
|
1147
|
+
const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
|
|
1148
|
+
const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
|
|
1149
|
+
for (const section of this.difficultyAttributes
|
|
1150
|
+
.possibleThreeFingeredSections) {
|
|
1151
|
+
const objects = [];
|
|
1152
|
+
for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
|
|
1153
|
+
const object = beatmapObjects[i];
|
|
1154
|
+
const objectData = this.data.hitObjectData[i];
|
|
1155
|
+
objects.push({
|
|
1156
|
+
object: object,
|
|
1157
|
+
aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
|
|
1158
|
+
pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
/**
|
|
1165
|
+
* Obtains the index of the cursor that aimed the object at the nearest time.
|
|
1166
|
+
*
|
|
1167
|
+
* @param object The object to obtain the index for.
|
|
1168
|
+
* @param objectData The hit data of the object.
|
|
1169
|
+
* @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
|
|
1170
|
+
* @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
|
|
1171
|
+
* @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
1172
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
1173
|
+
*/
|
|
1174
|
+
getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
|
|
1175
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
1176
|
+
return -1;
|
|
1177
|
+
}
|
|
1178
|
+
// Check for sliderbreaks and treat them as misses.
|
|
1179
|
+
if (object instanceof osuBase.Slider &&
|
|
1180
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
1181
|
+
objectData.accuracy >
|
|
1182
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
1183
|
+
return -1;
|
|
1184
|
+
}
|
|
1185
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
1186
|
+
const objectPosition = object.stackedPosition;
|
|
1187
|
+
// We are maintaining the closest distance to the object.
|
|
1188
|
+
// This is because the radius that is calculated is using an estimation.
|
|
1189
|
+
// As such, it does not reflect the actual object radius in gameplay.
|
|
1190
|
+
let closestDistance = Number.POSITIVE_INFINITY;
|
|
1191
|
+
let nearestCursorIndex = -1;
|
|
1192
|
+
// Observe the cursor position at the object's hit time.
|
|
1193
|
+
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1194
|
+
const cursorData = this.data.cursorMovement[i];
|
|
1195
|
+
for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
|
|
1196
|
+
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
1197
|
+
if (cursorGroup.endTime < hitTime) {
|
|
1198
|
+
// Reset cursor index pointer.
|
|
1199
|
+
cursorIndices[i] = 1;
|
|
1200
|
+
continue;
|
|
1201
|
+
}
|
|
1202
|
+
if (cursorGroup.startTime > hitTime) {
|
|
1203
|
+
break;
|
|
1204
|
+
}
|
|
1205
|
+
const cursors = cursorGroup.allOccurrences;
|
|
1206
|
+
for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
|
|
1207
|
+
const cursor = cursors[k];
|
|
1208
|
+
const prevCursor = cursors[k - 1];
|
|
1209
|
+
// Cursor is past the object's hit time.
|
|
1210
|
+
if (prevCursor.time > hitTime) {
|
|
1211
|
+
break;
|
|
1212
|
+
}
|
|
1213
|
+
// Cursor is before the object's hit time.
|
|
1214
|
+
if (hitTime > cursor.time) {
|
|
1215
|
+
continue;
|
|
1216
|
+
}
|
|
1217
|
+
let distance;
|
|
1218
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
1219
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
1220
|
+
switch (cursor.id) {
|
|
1221
|
+
case exports.MovementType.up:
|
|
1222
|
+
distance = prevPosition.getDistance(objectPosition);
|
|
1223
|
+
break;
|
|
1224
|
+
case exports.MovementType.move: {
|
|
1225
|
+
// Interpolate movement.
|
|
1226
|
+
const t = (hitTime - prevCursor.time) /
|
|
1227
|
+
(cursor.time - prevCursor.time);
|
|
1228
|
+
const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
|
|
1229
|
+
distance =
|
|
1230
|
+
objectPosition.getDistance(cursorPosition);
|
|
1231
|
+
break;
|
|
1232
|
+
}
|
|
1233
|
+
case exports.MovementType.down:
|
|
1234
|
+
continue;
|
|
1235
|
+
}
|
|
1236
|
+
if (closestDistance > distance) {
|
|
1237
|
+
closestDistance = distance;
|
|
1238
|
+
nearestCursorIndex = i;
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
// Reset cursor index pointer on end of group.
|
|
1242
|
+
if (cursorIndices[i] === cursors.length) {
|
|
1243
|
+
cursorIndices[i] = 1;
|
|
1244
|
+
}
|
|
1245
|
+
break;
|
|
1246
|
+
}
|
|
1247
|
+
// The previous object may still be hit with the same cursor group or cursor index.
|
|
1248
|
+
cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
|
|
1249
|
+
cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
|
|
1250
|
+
}
|
|
1251
|
+
return nearestCursorIndex;
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Obtains the index of the nearest cursor of which an object was pressed in terms of time.
|
|
1255
|
+
*
|
|
1256
|
+
* @param object The object to obtain the index for.
|
|
1257
|
+
* @param objectData The hit data of the object.
|
|
1258
|
+
* @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
|
|
1259
|
+
* @param excludedIndices The cursor indices that should not be checked.
|
|
1260
|
+
* @returns The index of the cursor, -1 if the object was missed or it's a spinner.
|
|
1261
|
+
*/
|
|
1262
|
+
getObjectPressIndex(object, objectData, cursorLookupIndices) {
|
|
1263
|
+
if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
|
|
1264
|
+
return -1;
|
|
1265
|
+
}
|
|
1266
|
+
// Check for sliderbreaks and treat them as misses.
|
|
1267
|
+
if (object instanceof osuBase.Slider &&
|
|
1268
|
+
(-this.hitWindow.mehWindow > objectData.accuracy ||
|
|
1269
|
+
objectData.accuracy >
|
|
1270
|
+
Math.min(this.hitWindow.mehWindow, object.duration))) {
|
|
1271
|
+
return -1;
|
|
1272
|
+
}
|
|
1273
|
+
const hitTime = object.startTime + objectData.accuracy;
|
|
1274
|
+
let nearestCursorInstanceIndex = -1;
|
|
1275
|
+
let nearestTime = Number.POSITIVE_INFINITY;
|
|
1276
|
+
for (let i = 0; i < this.downCursorInstances.length; ++i) {
|
|
1277
|
+
const cursors = this.downCursorInstances[i];
|
|
1278
|
+
let cursorNearestTime = Number.POSITIVE_INFINITY;
|
|
1279
|
+
for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
|
|
1280
|
+
const cursor = cursors[j];
|
|
1281
|
+
if (cursor.time > hitTime) {
|
|
1282
|
+
break;
|
|
1283
|
+
}
|
|
1284
|
+
cursorNearestTime = hitTime - cursor.time;
|
|
1285
|
+
}
|
|
1286
|
+
if (cursorNearestTime < nearestTime) {
|
|
1287
|
+
nearestCursorInstanceIndex = i;
|
|
1288
|
+
nearestTime = cursorNearestTime;
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
return nearestCursorInstanceIndex;
|
|
1292
|
+
}
|
|
1293
|
+
/**
|
|
1294
|
+
* Creates nerf factors by scanning through objects.
|
|
1295
|
+
*/
|
|
1296
|
+
calculateNerfFactors() {
|
|
1297
|
+
for (const beatmapSection of this.beatmapSections) {
|
|
1298
|
+
const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
|
|
1299
|
+
for (const object of beatmapSection.objects) {
|
|
1300
|
+
if (object.pressingCursorInstanceIndex === -1) {
|
|
1301
|
+
continue;
|
|
1302
|
+
}
|
|
1303
|
+
if (object.aimingCursorInstanceIndex < 3) {
|
|
1304
|
+
// The aim cursor is in the first three cursors. They are counted as non-3 finger.
|
|
1305
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
1306
|
+
case 0:
|
|
1307
|
+
case 1:
|
|
1308
|
+
case 2:
|
|
1309
|
+
break;
|
|
1310
|
+
default:
|
|
1311
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
else {
|
|
1316
|
+
// The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
|
|
1317
|
+
switch (object.pressingCursorInstanceIndex) {
|
|
1318
|
+
case 0:
|
|
1319
|
+
case 1:
|
|
1320
|
+
break;
|
|
1321
|
+
default:
|
|
1322
|
+
++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
|
|
1323
|
+
break;
|
|
1324
|
+
}
|
|
1325
|
+
}
|
|
1326
|
+
}
|
|
1327
|
+
const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
|
|
1328
|
+
if (threeFingerCursorCount === 0) {
|
|
1329
|
+
continue;
|
|
1330
|
+
}
|
|
1331
|
+
const sectionObjectCount = beatmapSection.objects.length;
|
|
1332
|
+
const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
|
|
1333
|
+
const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
|
|
1334
|
+
// Finger factor applies more penalty if more fingers were used.
|
|
1335
|
+
const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
|
|
1336
|
+
Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
|
|
1337
|
+
// Length factor applies more penalty if there are more 3-fingered object.
|
|
1338
|
+
const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
|
|
1339
|
+
this.nerfFactors.push({
|
|
1340
|
+
strainFactor: strainFactor,
|
|
1341
|
+
fingerFactor: fingerFactor,
|
|
1342
|
+
lengthFactor: lengthFactor,
|
|
1343
|
+
});
|
|
1344
|
+
}
|
|
1345
|
+
}
|
|
1346
|
+
/**
|
|
1347
|
+
* Calculates the final penalty.
|
|
1348
|
+
*/
|
|
1349
|
+
calculateFinalPenalty() {
|
|
1350
|
+
return this.nerfFactors.reduce((a, n) => a +
|
|
1351
|
+
0.015 *
|
|
1352
|
+
Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
|
|
1353
|
+
}
|
|
1354
|
+
getCursorPosition(cursor) {
|
|
1355
|
+
if (this.isHardRock) {
|
|
1356
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
1357
|
+
}
|
|
1358
|
+
return cursor.position;
|
|
1359
|
+
}
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
/**
|
|
1363
|
+
* Contains information about which cursor index hits a hitobject.
|
|
1364
|
+
*/
|
|
1365
|
+
class IndexedHitObject {
|
|
1366
|
+
/**
|
|
1367
|
+
* @param object The underlying hitobject.
|
|
1368
|
+
* @param cursorIndex The cursor index that moves towards the hitobject.
|
|
1369
|
+
* @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
|
|
1370
|
+
* @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
|
|
1371
|
+
* @param angle The angle of the movement of the cursor that moves towards the hitobject.
|
|
1372
|
+
*/
|
|
1373
|
+
constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
|
|
1374
|
+
/**
|
|
1375
|
+
* If this is a slider, whether the slider was cheesed.
|
|
1376
|
+
*/
|
|
1377
|
+
this.sliderCheesed = false;
|
|
1378
|
+
this.object = object;
|
|
1379
|
+
this.cursorIndex = cursorIndex;
|
|
1380
|
+
this.groupIndex = groupIndex;
|
|
1381
|
+
this.occurrenceIndex = occurrenceIndex;
|
|
1382
|
+
this.angle = angle;
|
|
1383
|
+
this.is2Handed = is2Handed;
|
|
1384
|
+
this.endCursorPosition = this.object.getStackedEndPosition(osuBase.Modes.droid);
|
|
1385
|
+
}
|
|
1016
1386
|
}
|
|
1017
1387
|
|
|
1018
1388
|
// import { writeFileSync } from "fs";
|
|
@@ -1020,41 +1390,26 @@ class IndexedHitObject {
|
|
|
1020
1390
|
* Utility to check whether or not a beatmap is two-handed.
|
|
1021
1391
|
*/
|
|
1022
1392
|
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
1393
|
// private csvString: string;
|
|
1044
1394
|
/**
|
|
1045
|
-
* @param
|
|
1395
|
+
* @param beatmap The beatmap to analyze.
|
|
1396
|
+
* @param attributes The difficulty attributes to analyze.
|
|
1046
1397
|
* @param data The data of the replay.
|
|
1047
1398
|
*/
|
|
1048
|
-
constructor(
|
|
1049
|
-
|
|
1399
|
+
constructor(beatmap, attributes, data) {
|
|
1400
|
+
/**
|
|
1401
|
+
* The hitobjects of the beatmap that have been assigned with their respective cursor index.
|
|
1402
|
+
*/
|
|
1403
|
+
this.indexedHitObjects = [];
|
|
1404
|
+
this.beatmap = beatmap;
|
|
1405
|
+
this.attributes = attributes;
|
|
1050
1406
|
this.data = data;
|
|
1051
|
-
const
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
this.
|
|
1057
|
-
this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1407
|
+
const greatWindow = new osuBase.OsuHitWindow(attributes.overallDifficulty).greatWindow *
|
|
1408
|
+
attributes.clockRate;
|
|
1409
|
+
this.hitWindow = attributes.mods.has(osuBase.ModPrecise)
|
|
1410
|
+
? new osuBase.PreciseDroidHitWindow(osuBase.PreciseDroidHitWindow.greatWindowToOD(greatWindow))
|
|
1411
|
+
: new osuBase.DroidHitWindow(osuBase.DroidHitWindow.greatWindowToOD(greatWindow));
|
|
1412
|
+
this.isHardRock = attributes.mods.has(osuBase.ModHardRock);
|
|
1058
1413
|
// this.csvString = `Mods,${
|
|
1059
1414
|
// data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
|
|
1060
1415
|
// }\nCombo,${data.maxCombo}\nAccuracy,"${(
|
|
@@ -1114,26 +1469,36 @@ class TwoHandChecker {
|
|
|
1114
1469
|
// ),
|
|
1115
1470
|
// this.csvString
|
|
1116
1471
|
// );
|
|
1117
|
-
let twoHandedNoteCount = 0;
|
|
1118
|
-
const maxStrain = Math.max(
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1472
|
+
// let twoHandedNoteCount = 0;
|
|
1473
|
+
// const maxStrain = Math.max(
|
|
1474
|
+
// ...this.attributes.objects.map((v) => v.aimStrainWithSliders),
|
|
1475
|
+
// );
|
|
1476
|
+
// if (maxStrain) {
|
|
1477
|
+
// twoHandedNoteCount = this.indexedHitObjects.reduce(
|
|
1478
|
+
// (total, object) => {
|
|
1479
|
+
// if (!object.is2Handed) {
|
|
1480
|
+
// return total;
|
|
1481
|
+
// }
|
|
1482
|
+
// return (
|
|
1483
|
+
// total +
|
|
1484
|
+
// 1 /
|
|
1485
|
+
// (1 +
|
|
1486
|
+
// Math.exp(
|
|
1487
|
+
// -(
|
|
1488
|
+
// (object.object.aimStrainWithSliders /
|
|
1489
|
+
// maxStrain) *
|
|
1490
|
+
// 12 -
|
|
1491
|
+
// 6
|
|
1492
|
+
// ),
|
|
1493
|
+
// ))
|
|
1494
|
+
// );
|
|
1495
|
+
// },
|
|
1496
|
+
// 0,
|
|
1497
|
+
// );
|
|
1498
|
+
// }
|
|
1133
1499
|
return {
|
|
1134
|
-
is2Hand:
|
|
1135
|
-
|
|
1136
|
-
twoHandedNoteCount: twoHandedNoteCount,
|
|
1500
|
+
is2Hand: 0 > this.attributes.aimNoteCount * 0.15,
|
|
1501
|
+
twoHandedNoteCount: 0,
|
|
1137
1502
|
};
|
|
1138
1503
|
}
|
|
1139
1504
|
/**
|
|
@@ -1141,8 +1506,7 @@ class TwoHandChecker {
|
|
|
1141
1506
|
*/
|
|
1142
1507
|
indexHitObjects() {
|
|
1143
1508
|
const indexes = [];
|
|
1144
|
-
for (let i = 0; i <
|
|
1145
|
-
Math.min(this.data.hitObjectData.length, this.calculator.objects.length); ++i) {
|
|
1509
|
+
for (let i = 0; i < this.data.hitObjectData.length; ++i) {
|
|
1146
1510
|
const indexedHitObject = this.getIndexedHitObject(i);
|
|
1147
1511
|
indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i]);
|
|
1148
1512
|
indexes.push(indexedHitObject.cursorIndex);
|
|
@@ -1203,43 +1567,40 @@ class TwoHandChecker {
|
|
|
1203
1567
|
* @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
|
|
1204
1568
|
*/
|
|
1205
1569
|
getIndexedHitObject(objectIndex) {
|
|
1206
|
-
const
|
|
1207
|
-
const { object } = diffObject;
|
|
1570
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1208
1571
|
// We don't care about the first object and spinners.
|
|
1209
1572
|
if (objectIndex === 0 || object instanceof osuBase.Spinner) {
|
|
1210
|
-
return new IndexedHitObject(
|
|
1573
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1211
1574
|
}
|
|
1212
1575
|
// We don't care if the aim strain is too low.
|
|
1213
1576
|
// if (diffObject.aimStrainWithSliders < 200) {
|
|
1214
1577
|
// return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
|
|
1215
1578
|
// }
|
|
1216
|
-
const prevObject = this.
|
|
1579
|
+
const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1217
1580
|
const prevObjectData = this.data.hitObjectData[objectIndex - 1];
|
|
1218
1581
|
if (prevObject instanceof osuBase.Spinner ||
|
|
1219
1582
|
prevObjectData.result === exports.HitResult.miss) {
|
|
1220
|
-
return new IndexedHitObject(
|
|
1583
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1221
1584
|
}
|
|
1222
|
-
const objectStartPosition = object.
|
|
1223
|
-
let prevObjectEndPosition = prevObject.
|
|
1585
|
+
const objectStartPosition = object.stackedPosition;
|
|
1586
|
+
let prevObjectEndPosition = prevObject.stackedEndPosition;
|
|
1224
1587
|
if (prevObject instanceof osuBase.Slider) {
|
|
1225
|
-
if (prevObject.
|
|
1226
|
-
const
|
|
1588
|
+
if (prevObject.distance > 0) {
|
|
1589
|
+
const endPosition = prevObject.stackedEndPosition;
|
|
1590
|
+
const lazyEndMovement = objectStartPosition.subtract(endPosition);
|
|
1227
1591
|
const actualEndMovement = objectStartPosition.subtract(prevObjectEndPosition);
|
|
1228
1592
|
if (lazyEndMovement.length < actualEndMovement.length) {
|
|
1229
|
-
prevObjectEndPosition =
|
|
1593
|
+
prevObjectEndPosition = endPosition;
|
|
1230
1594
|
}
|
|
1231
1595
|
}
|
|
1232
1596
|
else {
|
|
1233
|
-
prevObjectEndPosition = prevObject.
|
|
1597
|
+
prevObjectEndPosition = prevObject.stackedPosition;
|
|
1234
1598
|
}
|
|
1235
1599
|
}
|
|
1236
|
-
const prevToCurrentMovement = object
|
|
1237
|
-
.getStackedPosition(osuBase.Modes.droid)
|
|
1238
|
-
.subtract(prevObjectEndPosition);
|
|
1239
|
-
const radius = object.getRadius(osuBase.Modes.droid);
|
|
1600
|
+
const prevToCurrentMovement = object.stackedPosition.subtract(prevObjectEndPosition);
|
|
1240
1601
|
// Don't consider objects that are too close to each other.
|
|
1241
|
-
if (prevToCurrentMovement.length <= radius) {
|
|
1242
|
-
return new IndexedHitObject(
|
|
1602
|
+
if (prevToCurrentMovement.length <= object.radius) {
|
|
1603
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1243
1604
|
}
|
|
1244
1605
|
// The case for a one-handed object is that there will be a slight movement in the cursor towards
|
|
1245
1606
|
// the next object in fast patterns. We should not be worried about slow patterns as they will only
|
|
@@ -1259,12 +1620,12 @@ class TwoHandChecker {
|
|
|
1259
1620
|
this.indexedHitObjects[objectIndex - 1].endCursorPosition =
|
|
1260
1621
|
prevObjectInformation.position;
|
|
1261
1622
|
if (prevObjectInformation.position.x === Number.POSITIVE_INFINITY) {
|
|
1262
|
-
return new IndexedHitObject(
|
|
1623
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1263
1624
|
}
|
|
1264
1625
|
if (prevObjectInformation.cursorIndex ===
|
|
1265
1626
|
objectInformation.cursorIndex &&
|
|
1266
1627
|
prevObjectInformation.groupIndex === objectInformation.groupIndex) {
|
|
1267
|
-
return new IndexedHitObject(
|
|
1628
|
+
return new IndexedHitObject(object, objectInformation.cursorIndex, objectInformation.groupIndex, prevObjectInformation.occurrenceIndex, 0, false);
|
|
1268
1629
|
}
|
|
1269
1630
|
const cursorData = this.data.cursorMovement[prevObjectInformation.cursorIndex];
|
|
1270
1631
|
// There can be multiple angles to which the cursor moves towards the next object.
|
|
@@ -1276,13 +1637,15 @@ class TwoHandChecker {
|
|
|
1276
1637
|
for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
|
|
1277
1638
|
const cursor = cursors[i];
|
|
1278
1639
|
const prevCursor = cursors[i - 1];
|
|
1279
|
-
|
|
1640
|
+
const currentPosition = this.getCursorPosition(cursor);
|
|
1641
|
+
const prevPosition = this.getCursorPosition(prevCursor);
|
|
1642
|
+
if (currentPosition.equals(prevPosition)) {
|
|
1280
1643
|
continue;
|
|
1281
1644
|
}
|
|
1282
1645
|
if (cursor.id === exports.MovementType.up) {
|
|
1283
1646
|
break;
|
|
1284
1647
|
}
|
|
1285
|
-
const currentMovement =
|
|
1648
|
+
const currentMovement = currentPosition.subtract(prevPosition);
|
|
1286
1649
|
const dot = prevToCurrentCursorMovement.dot(currentMovement);
|
|
1287
1650
|
const det = prevToCurrentCursorMovement.x * currentMovement.y -
|
|
1288
1651
|
prevToCurrentCursorMovement.y * currentMovement.x;
|
|
@@ -1344,9 +1707,9 @@ class TwoHandChecker {
|
|
|
1344
1707
|
// }
|
|
1345
1708
|
// }
|
|
1346
1709
|
if (!Number.isFinite(finalAngle)) {
|
|
1347
|
-
return new IndexedHitObject(
|
|
1710
|
+
return new IndexedHitObject(object, -1, -1, -1, null, false);
|
|
1348
1711
|
}
|
|
1349
|
-
return new IndexedHitObject(
|
|
1712
|
+
return new IndexedHitObject(object, prevObjectInformation.cursorIndex, prevObjectInformation.groupIndex, prevObjectInformation.occurrenceIndex, finalAngle, is2Handed);
|
|
1350
1713
|
}
|
|
1351
1714
|
/**
|
|
1352
1715
|
* Gets the position of the cursor that presses an object.
|
|
@@ -1355,9 +1718,9 @@ class TwoHandChecker {
|
|
|
1355
1718
|
* @returns The position of the cursor that presses the object.
|
|
1356
1719
|
*/
|
|
1357
1720
|
getCursorPositionForObjectStart(objectIndex) {
|
|
1358
|
-
const object = this.
|
|
1721
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1359
1722
|
const data = this.data.hitObjectData[objectIndex];
|
|
1360
|
-
const objectPosition = object.
|
|
1723
|
+
const objectPosition = object.stackedPosition;
|
|
1361
1724
|
if (object instanceof osuBase.Spinner) {
|
|
1362
1725
|
return {
|
|
1363
1726
|
position: objectPosition,
|
|
@@ -1367,22 +1730,20 @@ class TwoHandChecker {
|
|
|
1367
1730
|
cursorTime: object.startTime,
|
|
1368
1731
|
};
|
|
1369
1732
|
}
|
|
1370
|
-
|
|
1371
|
-
let hitWindow = this.hitWindow50;
|
|
1372
|
-
const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
|
|
1733
|
+
let hitWindow = this.hitWindow.mehWindow;
|
|
1373
1734
|
// For sliders, set the hit window to as lenient as possible.
|
|
1374
1735
|
if (object instanceof osuBase.Circle) {
|
|
1375
1736
|
switch (data.result) {
|
|
1376
1737
|
case exports.HitResult.great:
|
|
1377
|
-
hitWindow = this.hitWindow.
|
|
1738
|
+
hitWindow = this.hitWindow.greatWindow;
|
|
1378
1739
|
break;
|
|
1379
1740
|
case exports.HitResult.good:
|
|
1380
|
-
hitWindow = this.hitWindow.
|
|
1741
|
+
hitWindow = this.hitWindow.okWindow;
|
|
1381
1742
|
break;
|
|
1382
1743
|
}
|
|
1383
1744
|
}
|
|
1384
1745
|
// TODO: what to do for head sliderbreaks?
|
|
1385
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1746
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1386
1747
|
let nearestCursorIndex = 0;
|
|
1387
1748
|
let nearestGroupIndex = 0;
|
|
1388
1749
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1390,12 +1751,12 @@ class TwoHandChecker {
|
|
|
1390
1751
|
const minimumActiveTime = object.startTime - hitWindow;
|
|
1391
1752
|
const maximumActiveTime = object.startTime + hitWindow;
|
|
1392
1753
|
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1393
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1754
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1394
1755
|
break;
|
|
1395
1756
|
}
|
|
1396
1757
|
const cursorData = this.data.cursorMovement[i];
|
|
1397
1758
|
for (let j = 0; j < cursorData.occurrenceGroups.length; ++j) {
|
|
1398
|
-
if (nearestPosition.getDistance(objectPosition) <= radius) {
|
|
1759
|
+
if (nearestPosition.getDistance(objectPosition) <= object.radius) {
|
|
1399
1760
|
break;
|
|
1400
1761
|
}
|
|
1401
1762
|
const cursorGroup = cursorData.occurrenceGroups[j];
|
|
@@ -1407,13 +1768,14 @@ class TwoHandChecker {
|
|
|
1407
1768
|
}
|
|
1408
1769
|
// Validate the down press first.
|
|
1409
1770
|
const { down } = cursorGroup;
|
|
1410
|
-
|
|
1771
|
+
const downPosition = this.getCursorPosition(down);
|
|
1772
|
+
if (downPosition.getDistance(objectPosition) <= object.radius &&
|
|
1411
1773
|
Math.abs(down.time - object.startTime) <= hitWindow) {
|
|
1412
1774
|
if (objectIndex > 0) {
|
|
1413
|
-
const prevObject = this.
|
|
1775
|
+
const prevObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1414
1776
|
if (down.time > prevObject.endTime) {
|
|
1415
1777
|
return {
|
|
1416
|
-
position:
|
|
1778
|
+
position: downPosition,
|
|
1417
1779
|
cursorIndex: i,
|
|
1418
1780
|
groupIndex: j,
|
|
1419
1781
|
occurrenceIndex: 0,
|
|
@@ -1423,7 +1785,7 @@ class TwoHandChecker {
|
|
|
1423
1785
|
}
|
|
1424
1786
|
else {
|
|
1425
1787
|
return {
|
|
1426
|
-
position:
|
|
1788
|
+
position: downPosition,
|
|
1427
1789
|
cursorIndex: i,
|
|
1428
1790
|
groupIndex: j,
|
|
1429
1791
|
occurrenceIndex: 0,
|
|
@@ -1443,7 +1805,7 @@ class TwoHandChecker {
|
|
|
1443
1805
|
}
|
|
1444
1806
|
let cursorPosition;
|
|
1445
1807
|
if (cursor.id === exports.MovementType.up) {
|
|
1446
|
-
cursorPosition = prevCursor
|
|
1808
|
+
cursorPosition = this.getCursorPosition(prevCursor);
|
|
1447
1809
|
const distance = cursorPosition.getDistance(objectPosition);
|
|
1448
1810
|
if (distance <
|
|
1449
1811
|
nearestPosition.getDistance(objectPosition)) {
|
|
@@ -1468,7 +1830,7 @@ class TwoHandChecker {
|
|
|
1468
1830
|
}
|
|
1469
1831
|
const t = (cursorGroup.down.time - prevCursor.time) /
|
|
1470
1832
|
(cursor.time - prevCursor.time);
|
|
1471
|
-
cursorPosition =
|
|
1833
|
+
cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
|
|
1472
1834
|
const distance = cursorPosition.getDistance(objectPosition);
|
|
1473
1835
|
if (distance <
|
|
1474
1836
|
nearestPosition.getDistance(objectPosition)) {
|
|
@@ -1478,12 +1840,13 @@ class TwoHandChecker {
|
|
|
1478
1840
|
nearestCursorGroupIndex = k;
|
|
1479
1841
|
nearestCursorTime = cursorGroup.down.time;
|
|
1480
1842
|
}
|
|
1481
|
-
if (distance <= radius) {
|
|
1843
|
+
if (distance <= object.radius) {
|
|
1482
1844
|
break;
|
|
1483
1845
|
}
|
|
1484
1846
|
}
|
|
1485
1847
|
}
|
|
1486
|
-
if (nearestPosition.getDistance(objectPosition) <=
|
|
1848
|
+
if (nearestPosition.getDistance(objectPosition) <=
|
|
1849
|
+
object.radius) {
|
|
1487
1850
|
break;
|
|
1488
1851
|
}
|
|
1489
1852
|
}
|
|
@@ -1514,24 +1877,25 @@ class TwoHandChecker {
|
|
|
1514
1877
|
* @returns The position of the cursor at the object's end position.
|
|
1515
1878
|
*/
|
|
1516
1879
|
getCursorPositionForObjectEnd(objectIndex) {
|
|
1517
|
-
const object = this.
|
|
1880
|
+
const object = this.beatmap.hitObjects.objects[objectIndex];
|
|
1518
1881
|
if (!(object instanceof osuBase.Slider)) {
|
|
1519
1882
|
return this.getCursorPositionForObjectStart(objectIndex);
|
|
1520
1883
|
}
|
|
1521
|
-
const nextObject = this.
|
|
1522
|
-
let objectEndPosition = object.
|
|
1523
|
-
if (object.
|
|
1524
|
-
const
|
|
1525
|
-
const
|
|
1884
|
+
const nextObject = this.beatmap.hitObjects.objects[objectIndex - 1];
|
|
1885
|
+
let objectEndPosition = object.stackedEndPosition;
|
|
1886
|
+
if (object.distance > 0 && nextObject) {
|
|
1887
|
+
const endPosition = object.stackedEndPosition;
|
|
1888
|
+
const nextStartPosition = nextObject.stackedPosition;
|
|
1889
|
+
const lazyEndMovement = nextStartPosition.subtract(endPosition);
|
|
1526
1890
|
const actualEndMovement = nextStartPosition.subtract(objectEndPosition);
|
|
1527
1891
|
if (lazyEndMovement.length < actualEndMovement.length) {
|
|
1528
|
-
objectEndPosition =
|
|
1892
|
+
objectEndPosition = endPosition;
|
|
1529
1893
|
}
|
|
1530
1894
|
}
|
|
1531
1895
|
else {
|
|
1532
|
-
objectEndPosition = object.
|
|
1896
|
+
objectEndPosition = object.stackedPosition;
|
|
1533
1897
|
}
|
|
1534
|
-
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY
|
|
1898
|
+
let nearestPosition = new osuBase.Vector2(Number.POSITIVE_INFINITY);
|
|
1535
1899
|
let nearestCursorIndex = 0;
|
|
1536
1900
|
let nearestGroupIndex = 0;
|
|
1537
1901
|
let nearestCursorGroupIndex = 0;
|
|
@@ -1552,18 +1916,18 @@ class TwoHandChecker {
|
|
|
1552
1916
|
let cursorPosition;
|
|
1553
1917
|
switch (cursor.id) {
|
|
1554
1918
|
case exports.MovementType.down:
|
|
1555
|
-
cursorPosition = cursor
|
|
1919
|
+
cursorPosition = this.getCursorPosition(cursor);
|
|
1556
1920
|
break;
|
|
1557
1921
|
case exports.MovementType.up: {
|
|
1558
1922
|
const prevCursor = cursors[k - 1];
|
|
1559
|
-
cursorPosition = prevCursor
|
|
1923
|
+
cursorPosition = this.getCursorPosition(prevCursor);
|
|
1560
1924
|
break;
|
|
1561
1925
|
}
|
|
1562
1926
|
case exports.MovementType.move: {
|
|
1563
1927
|
const prevCursor = cursors[k - 1];
|
|
1564
1928
|
const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
|
|
1565
1929
|
(cursor.time - prevCursor.time), 0, 1);
|
|
1566
|
-
cursorPosition =
|
|
1930
|
+
cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
|
|
1567
1931
|
break;
|
|
1568
1932
|
}
|
|
1569
1933
|
}
|
|
@@ -1618,257 +1982,65 @@ class TwoHandChecker {
|
|
|
1618
1982
|
* @returns Whether the slider was cheesed.
|
|
1619
1983
|
*/
|
|
1620
1984
|
checkSliderCheesing(indexedHitObject, hitData) {
|
|
1621
|
-
if (!(indexedHitObject.object
|
|
1985
|
+
if (!(indexedHitObject.object instanceof osuBase.Slider) ||
|
|
1622
1986
|
hitData.result === exports.HitResult.miss ||
|
|
1623
1987
|
indexedHitObject.cursorIndex === -1) {
|
|
1624
1988
|
return false;
|
|
1625
1989
|
}
|
|
1626
1990
|
return false;
|
|
1627
1991
|
}
|
|
1992
|
+
getCursorPosition(cursor) {
|
|
1993
|
+
if (this.isHardRock) {
|
|
1994
|
+
return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
|
|
1995
|
+
}
|
|
1996
|
+
return cursor.position;
|
|
1997
|
+
}
|
|
1628
1998
|
}
|
|
1629
1999
|
|
|
1630
2000
|
/**
|
|
1631
|
-
*
|
|
2001
|
+
* Represents a replay data in an osu!droid replay version 1 and 2.
|
|
2002
|
+
*
|
|
2003
|
+
* Stores generic information about an osu!droid replay.
|
|
2004
|
+
*
|
|
2005
|
+
* This is used when analyzing replays using replay analyzer.
|
|
1632
2006
|
*/
|
|
1633
|
-
class
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
difficultyAttributes;
|
|
1646
|
-
/**
|
|
1647
|
-
* The 50 osu!droid hit window of the analyzed beatmap.
|
|
1648
|
-
*/
|
|
1649
|
-
hitWindow50;
|
|
1650
|
-
/**
|
|
1651
|
-
* @param beatmap The beatmap to analyze.
|
|
1652
|
-
* @param data The data of the replay.
|
|
1653
|
-
* @param difficultyAttributes The difficulty attributes of the beatmap.
|
|
1654
|
-
*/
|
|
1655
|
-
constructor(beatmap, data, difficultyAttributes) {
|
|
1656
|
-
this.beatmap = beatmap;
|
|
1657
|
-
this.data = data;
|
|
1658
|
-
this.difficultyAttributes = difficultyAttributes;
|
|
1659
|
-
const stats = new osuBase.MapStats({
|
|
1660
|
-
od: this.beatmap.difficulty.od,
|
|
1661
|
-
mods: this.difficultyAttributes.mods.filter((m) => m.isApplicableToDroid() &&
|
|
1662
|
-
!osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
1663
|
-
}).calculate({ mode: osuBase.Modes.droid, convertDroidOD: false });
|
|
1664
|
-
this.hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
|
|
1665
|
-
}
|
|
1666
|
-
/**
|
|
1667
|
-
* Checks if relevant sliders in the given beatmap was cheesed.
|
|
1668
|
-
*/
|
|
1669
|
-
check() {
|
|
1670
|
-
if (this.difficultyAttributes.difficultSliders.length === 0 ||
|
|
1671
|
-
(this.difficultyAttributes.sliderFactor === 1 &&
|
|
1672
|
-
this.difficultyAttributes.flashlightSliderFactor === 1 &&
|
|
1673
|
-
this.difficultyAttributes.visualSliderFactor === 1)) {
|
|
1674
|
-
return {
|
|
1675
|
-
aimPenalty: 1,
|
|
1676
|
-
flashlightPenalty: 1,
|
|
1677
|
-
visualPenalty: 1,
|
|
1678
|
-
};
|
|
1679
|
-
}
|
|
1680
|
-
const cheesedDifficultyRatings = this.checkSliderCheesing();
|
|
1681
|
-
return this.calculateSliderCheesePenalty(cheesedDifficultyRatings);
|
|
2007
|
+
class ReplayData {
|
|
2008
|
+
constructor(values) {
|
|
2009
|
+
this.replayVersion = values.replayVersion;
|
|
2010
|
+
this.folderName = values.folderName;
|
|
2011
|
+
this.fileName = values.fileName;
|
|
2012
|
+
this.hash = values.hash;
|
|
2013
|
+
this.accuracy = values.accuracy;
|
|
2014
|
+
this.rank = values.rank;
|
|
2015
|
+
this.hit300k = values.hit300k;
|
|
2016
|
+
this.hit100k = values.hit100k;
|
|
2017
|
+
this.cursorMovement = values.cursorMovement;
|
|
2018
|
+
this.hitObjectData = values.hitObjectData;
|
|
1682
2019
|
}
|
|
1683
2020
|
/**
|
|
1684
|
-
*
|
|
2021
|
+
* Whether the replay's version is 3 or later.
|
|
1685
2022
|
*/
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
const cheesedDifficultyRatings = [];
|
|
1689
|
-
// Current loop indices are stored for efficiency.
|
|
1690
|
-
const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
|
|
1691
|
-
const circleSize = new osuBase.MapStats({
|
|
1692
|
-
cs: this.beatmap.difficulty.cs,
|
|
1693
|
-
mods: this.difficultyAttributes.mods,
|
|
1694
|
-
}).calculate({ mode: osuBase.Modes.droid }).cs;
|
|
1695
|
-
const scale = osuBase.CircleSizeCalculator.standardCSToStandardScale(circleSize);
|
|
1696
|
-
const acceptableRadius = 64 * scale * 2.4;
|
|
1697
|
-
// Sort difficult sliders by index so that cursor loop indices work properly.
|
|
1698
|
-
for (const difficultSlider of this.difficultyAttributes.difficultSliders
|
|
1699
|
-
.slice()
|
|
1700
|
-
.sort((a, b) => a.index - b.index)) {
|
|
1701
|
-
if (difficultSlider.index >= this.data.hitObjectData.length) {
|
|
1702
|
-
continue;
|
|
1703
|
-
}
|
|
1704
|
-
const objectData = this.data.hitObjectData[difficultSlider.index];
|
|
1705
|
-
// If a miss or slider break occurs, we disregard the check for that slider.
|
|
1706
|
-
if (objectData.result === exports.HitResult.miss ||
|
|
1707
|
-
objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
|
|
1708
|
-
continue;
|
|
1709
|
-
}
|
|
1710
|
-
let object = objects[difficultSlider.index];
|
|
1711
|
-
if (object.droidScale !== scale) {
|
|
1712
|
-
// Deep clone the object so that we can assign scale properly.
|
|
1713
|
-
object = osuBase.Utils.deepCopy(object);
|
|
1714
|
-
object.droidScale = scale;
|
|
1715
|
-
}
|
|
1716
|
-
const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
|
|
1717
|
-
// These time boundaries should consider the delta time between the previous and next
|
|
1718
|
-
// object as well as their hit accuracy. However, they are somewhat complicated to
|
|
1719
|
-
// compute and the accuracy gain is small. As such, let's settle with 50 hit window.
|
|
1720
|
-
const minTimeLimit = object.startTime - this.hitWindow50;
|
|
1721
|
-
const maxTimeLimit = object.startTime + this.hitWindow50;
|
|
1722
|
-
// Get the closest tap distance across all cursors.
|
|
1723
|
-
const closestDistances = [];
|
|
1724
|
-
const closestGroupIndices = [];
|
|
1725
|
-
for (let i = 0; i < this.data.cursorMovement.length; ++i) {
|
|
1726
|
-
const cursorGroups = this.data.cursorMovement[i].occurrenceGroups;
|
|
1727
|
-
let closestDistance = Number.POSITIVE_INFINITY;
|
|
1728
|
-
let closestIndex = cursorGroups.length;
|
|
1729
|
-
for (let j = cursorLoopIndices[i]; j < cursorGroups.length; j = ++cursorLoopIndices[i]) {
|
|
1730
|
-
const group = cursorGroups[j];
|
|
1731
|
-
if (group.endTime < minTimeLimit) {
|
|
1732
|
-
continue;
|
|
1733
|
-
}
|
|
1734
|
-
if (group.startTime > maxTimeLimit) {
|
|
1735
|
-
break;
|
|
1736
|
-
}
|
|
1737
|
-
if (group.startTime >= minTimeLimit) {
|
|
1738
|
-
const distance = group.down.position.getDistance(objectStartPosition);
|
|
1739
|
-
if (closestDistance > distance) {
|
|
1740
|
-
closestDistance = distance;
|
|
1741
|
-
closestIndex = j;
|
|
1742
|
-
}
|
|
1743
|
-
if (closestDistance <= acceptableRadius / 2) {
|
|
1744
|
-
break;
|
|
1745
|
-
}
|
|
1746
|
-
}
|
|
1747
|
-
// Normally, we check if there are cursor presses within the group's active time.
|
|
1748
|
-
// However, some funky workarounds are used throughout the game for replays, so
|
|
1749
|
-
// for the time being we only check for cursor distances across the group.
|
|
1750
|
-
const { allOccurrences } = group;
|
|
1751
|
-
for (let k = 1; k < allOccurrences.length; ++k) {
|
|
1752
|
-
const occurrence = allOccurrences[k];
|
|
1753
|
-
const prevOccurrence = allOccurrences[k - 1];
|
|
1754
|
-
let distance = Number.POSITIVE_INFINITY;
|
|
1755
|
-
switch (occurrence.id) {
|
|
1756
|
-
case exports.MovementType.up:
|
|
1757
|
-
distance =
|
|
1758
|
-
prevOccurrence.position.getDistance(objectStartPosition);
|
|
1759
|
-
break;
|
|
1760
|
-
case exports.MovementType.move:
|
|
1761
|
-
for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
|
|
1762
|
-
Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
|
|
1763
|
-
const t = (mSecPassed - prevOccurrence.time) /
|
|
1764
|
-
(occurrence.time - prevOccurrence.time);
|
|
1765
|
-
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));
|
|
1766
|
-
distance =
|
|
1767
|
-
cursorPosition.getDistance(objectStartPosition);
|
|
1768
|
-
if (closestDistance > distance) {
|
|
1769
|
-
closestDistance = distance;
|
|
1770
|
-
closestIndex = j;
|
|
1771
|
-
}
|
|
1772
|
-
if (closestDistance <=
|
|
1773
|
-
acceptableRadius / 2) {
|
|
1774
|
-
break;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
if (closestDistance > distance) {
|
|
1779
|
-
closestDistance = distance;
|
|
1780
|
-
closestIndex = j;
|
|
1781
|
-
}
|
|
1782
|
-
if (closestDistance <= acceptableRadius / 2) {
|
|
1783
|
-
break;
|
|
1784
|
-
}
|
|
1785
|
-
}
|
|
1786
|
-
}
|
|
1787
|
-
closestDistances.push(closestDistance);
|
|
1788
|
-
closestGroupIndices.push(closestIndex);
|
|
1789
|
-
if (cursorLoopIndices[i] > 0) {
|
|
1790
|
-
// Decrement the index. The previous group may also have a role on the next slider.
|
|
1791
|
-
--cursorLoopIndices[i];
|
|
1792
|
-
}
|
|
1793
|
-
}
|
|
1794
|
-
const cursorIndex = closestDistances.indexOf(Math.min(...closestDistances));
|
|
1795
|
-
const closestDistance = closestDistances[cursorIndex];
|
|
1796
|
-
if (closestDistance > acceptableRadius / 2) {
|
|
1797
|
-
console.log("Index", difficultSlider.index, "was cheesed with rating", difficultSlider.difficultyRating, "time", object.startTime);
|
|
1798
|
-
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
1799
|
-
continue;
|
|
1800
|
-
}
|
|
1801
|
-
const group = this.data.cursorMovement[cursorIndex].occurrenceGroups[closestGroupIndices[cursorIndex]];
|
|
1802
|
-
let isCheesed = false;
|
|
1803
|
-
// Track cursor movement to see if it lands on every tick.
|
|
1804
|
-
let occurrenceLoopIndex = 1;
|
|
1805
|
-
const { allOccurrences } = group;
|
|
1806
|
-
for (let i = 1; i < object.nestedHitObjects.length; ++i) {
|
|
1807
|
-
if (isCheesed) {
|
|
1808
|
-
break;
|
|
1809
|
-
}
|
|
1810
|
-
const tickWasHit = objectData.tickset[i - 1];
|
|
1811
|
-
if (!tickWasHit) {
|
|
1812
|
-
continue;
|
|
1813
|
-
}
|
|
1814
|
-
const nestedObject = object.nestedHitObjects[i];
|
|
1815
|
-
nestedObject.droidScale = scale;
|
|
1816
|
-
// Special treatment for slider tail where its treated as a "legacy tail" in osu!standard.
|
|
1817
|
-
// In that case, its time is assumed to be 36ms behind the slider's end time. However, that
|
|
1818
|
-
// is not the case for osu!droid.
|
|
1819
|
-
if (nestedObject instanceof osuBase.SliderTail) {
|
|
1820
|
-
nestedObject.startTime = object.endTime;
|
|
1821
|
-
nestedObject.endTime = object.endTime;
|
|
1822
|
-
}
|
|
1823
|
-
const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
|
|
1824
|
-
while (occurrenceLoopIndex < allOccurrences.length &&
|
|
1825
|
-
allOccurrences[occurrenceLoopIndex].time <
|
|
1826
|
-
nestedObject.startTime) {
|
|
1827
|
-
++occurrenceLoopIndex;
|
|
1828
|
-
}
|
|
1829
|
-
if (occurrenceLoopIndex === allOccurrences.length) {
|
|
1830
|
-
continue;
|
|
1831
|
-
}
|
|
1832
|
-
const occurrence = allOccurrences[occurrenceLoopIndex];
|
|
1833
|
-
const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
|
|
1834
|
-
switch (occurrence.id) {
|
|
1835
|
-
case exports.MovementType.move: {
|
|
1836
|
-
// Interpolate cursor position during nested object time.
|
|
1837
|
-
const t = (nestedObject.startTime - prevOccurrence.time) /
|
|
1838
|
-
(occurrence.time - prevOccurrence.time);
|
|
1839
|
-
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));
|
|
1840
|
-
const distance = cursorPosition.getDistance(nestedPosition);
|
|
1841
|
-
isCheesed = distance > acceptableRadius;
|
|
1842
|
-
break;
|
|
1843
|
-
}
|
|
1844
|
-
case exports.MovementType.up:
|
|
1845
|
-
isCheesed =
|
|
1846
|
-
prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
|
|
1847
|
-
}
|
|
1848
|
-
}
|
|
1849
|
-
if (isCheesed) {
|
|
1850
|
-
console.log("Index", difficultSlider.index, "was cheesed with rating", difficultSlider.difficultyRating, "time", object.startTime);
|
|
1851
|
-
cheesedDifficultyRatings.push(difficultSlider.difficultyRating);
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
return cheesedDifficultyRatings;
|
|
2023
|
+
isReplayV3() {
|
|
2024
|
+
return this.replayVersion >= 3;
|
|
1855
2025
|
}
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
2026
|
+
}
|
|
2027
|
+
|
|
2028
|
+
/**
|
|
2029
|
+
* Represents a replay data for replay version 3.
|
|
2030
|
+
*
|
|
2031
|
+
* Stores generic information about an osu!droid replay.
|
|
2032
|
+
*
|
|
2033
|
+
* This is used when analyzing replays using replay analyzer.
|
|
2034
|
+
*/
|
|
2035
|
+
class ReplayV3Data extends ReplayData {
|
|
2036
|
+
constructor(values) {
|
|
2037
|
+
super(values);
|
|
2038
|
+
this.time = values.time;
|
|
2039
|
+
this.score = values.score;
|
|
2040
|
+
this.maxCombo = values.maxCombo;
|
|
2041
|
+
this.isFullCombo = values.isFullCombo;
|
|
2042
|
+
this.playerName = values.playerName;
|
|
2043
|
+
this.convertedMods = values.convertedMods;
|
|
1872
2044
|
}
|
|
1873
2045
|
}
|
|
1874
2046
|
|
|
@@ -1880,116 +2052,203 @@ class SliderCheeseChecker {
|
|
|
1880
2052
|
* Once analyzed, the result can be accessed via the `data` property.
|
|
1881
2053
|
*/
|
|
1882
2054
|
class ReplayAnalyzer {
|
|
2055
|
+
constructor(values) {
|
|
2056
|
+
/**
|
|
2057
|
+
* The original odr file of the replay.
|
|
2058
|
+
*/
|
|
2059
|
+
this.originalODR = null;
|
|
2060
|
+
/**
|
|
2061
|
+
* The fixed odr file of the replay.
|
|
2062
|
+
*/
|
|
2063
|
+
this.fixedODR = null;
|
|
2064
|
+
/**
|
|
2065
|
+
* The results of the analyzer. `null` when initialized.
|
|
2066
|
+
*/
|
|
2067
|
+
this.data = null;
|
|
2068
|
+
/**
|
|
2069
|
+
* Penalty value used to penalize dpp for 2-hand.
|
|
2070
|
+
*/
|
|
2071
|
+
this.aimPenalty = 1;
|
|
2072
|
+
/**
|
|
2073
|
+
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
2074
|
+
*/
|
|
2075
|
+
this.tapPenalty = 1;
|
|
2076
|
+
/**
|
|
2077
|
+
* Penalty values used to penalize dpp for slider cheesing.
|
|
2078
|
+
*/
|
|
2079
|
+
this.sliderCheesePenalty = {
|
|
2080
|
+
aimPenalty: 1,
|
|
2081
|
+
flashlightPenalty: 1,
|
|
2082
|
+
visualPenalty: 1,
|
|
2083
|
+
};
|
|
2084
|
+
/**
|
|
2085
|
+
* Whether this replay has been checked against 3 finger usage.
|
|
2086
|
+
*/
|
|
2087
|
+
this.hasBeenCheckedFor3Finger = false;
|
|
2088
|
+
/**
|
|
2089
|
+
* Whether this replay has been checked against 2 hand usage.
|
|
2090
|
+
*/
|
|
2091
|
+
this.hasBeenCheckedFor2Hand = false;
|
|
2092
|
+
/**
|
|
2093
|
+
* Whether this replay has been checked against slider cheesing.
|
|
2094
|
+
*/
|
|
2095
|
+
this.hasBeenCheckedForSliderCheesing = false;
|
|
2096
|
+
/**
|
|
2097
|
+
* The amount of two-handed objects.
|
|
2098
|
+
*/
|
|
2099
|
+
this.twoHandedNoteCount = 0;
|
|
2100
|
+
this.bufferOffset = 0;
|
|
2101
|
+
this.scoreID = values === null || values === void 0 ? void 0 : values.scoreID;
|
|
2102
|
+
this.beatmap = values === null || values === void 0 ? void 0 : values.map;
|
|
2103
|
+
this.difficultyAttributes = values === null || values === void 0 ? void 0 : values.difficultyAttributes;
|
|
2104
|
+
if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
|
|
2105
|
+
this.playableBeatmap = this.beatmap;
|
|
2106
|
+
}
|
|
2107
|
+
}
|
|
1883
2108
|
/**
|
|
1884
|
-
*
|
|
1885
|
-
*/
|
|
1886
|
-
scoreID;
|
|
1887
|
-
/**
|
|
1888
|
-
* The original odr file of the replay.
|
|
1889
|
-
*/
|
|
1890
|
-
originalODR = null;
|
|
1891
|
-
/**
|
|
1892
|
-
* The fixed odr file of the replay.
|
|
1893
|
-
*/
|
|
1894
|
-
fixedODR = null;
|
|
1895
|
-
/**
|
|
1896
|
-
* Whether or not the play is considered using >=3 finger abuse.
|
|
1897
|
-
*/
|
|
1898
|
-
is3Finger;
|
|
1899
|
-
/**
|
|
1900
|
-
* Whether or not the play is considered 2-handed.
|
|
1901
|
-
*/
|
|
1902
|
-
is2Hand;
|
|
1903
|
-
/**
|
|
1904
|
-
* The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
|
|
1905
|
-
*/
|
|
1906
|
-
beatmap;
|
|
1907
|
-
/**
|
|
1908
|
-
* The difficulty attributes of the beatmap.
|
|
1909
|
-
*/
|
|
1910
|
-
difficultyAttributes;
|
|
1911
|
-
/**
|
|
1912
|
-
* The results of the analyzer. `null` when initialized.
|
|
1913
|
-
*/
|
|
1914
|
-
data = null;
|
|
1915
|
-
/**
|
|
1916
|
-
* Penalty value used to penalize dpp for 2-hand.
|
|
1917
|
-
*/
|
|
1918
|
-
aimPenalty = 1;
|
|
1919
|
-
/**
|
|
1920
|
-
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
1921
|
-
*/
|
|
1922
|
-
tapPenalty = 1;
|
|
1923
|
-
/**
|
|
1924
|
-
* Penalty values used to penalize dpp for slider cheesing.
|
|
1925
|
-
*/
|
|
1926
|
-
sliderCheesePenalty = {
|
|
1927
|
-
aimPenalty: 1,
|
|
1928
|
-
flashlightPenalty: 1,
|
|
1929
|
-
visualPenalty: 1,
|
|
1930
|
-
};
|
|
1931
|
-
/**
|
|
1932
|
-
* Whether this replay has been checked against 3 finger usage.
|
|
2109
|
+
* Analyzes a replay.
|
|
1933
2110
|
*/
|
|
1934
|
-
|
|
2111
|
+
analyze() {
|
|
2112
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2113
|
+
if (!this.originalODR && !this.fixedODR) {
|
|
2114
|
+
this.originalODR = yield this.downloadReplay();
|
|
2115
|
+
}
|
|
2116
|
+
if (!this.originalODR) {
|
|
2117
|
+
return this;
|
|
2118
|
+
}
|
|
2119
|
+
if (!this.fixedODR) {
|
|
2120
|
+
this.fixedODR = yield this.decompress().catch(() => null);
|
|
2121
|
+
}
|
|
2122
|
+
if (!this.fixedODR) {
|
|
2123
|
+
return this;
|
|
2124
|
+
}
|
|
2125
|
+
this.parseReplay();
|
|
2126
|
+
return this;
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
1935
2129
|
/**
|
|
1936
|
-
*
|
|
2130
|
+
* Gets hit error information of the replay.
|
|
2131
|
+
*
|
|
2132
|
+
* `analyze()` must be called before calling this.
|
|
1937
2133
|
*/
|
|
1938
|
-
|
|
2134
|
+
calculateHitError() {
|
|
2135
|
+
var _a, _b;
|
|
2136
|
+
if (!this.data || !this.beatmap) {
|
|
2137
|
+
return null;
|
|
2138
|
+
}
|
|
2139
|
+
const hitObjectData = this.data.hitObjectData;
|
|
2140
|
+
let positiveCount = 0;
|
|
2141
|
+
let negativeCount = 0;
|
|
2142
|
+
let positiveTotal = 0;
|
|
2143
|
+
let negativeTotal = 0;
|
|
2144
|
+
const { objects } = this.beatmap.hitObjects;
|
|
2145
|
+
const mods = this.data.isReplayV3()
|
|
2146
|
+
? this.data.convertedMods
|
|
2147
|
+
: (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods) !== null && _b !== void 0 ? _b : new osuBase.ModMap();
|
|
2148
|
+
const adjustedDifficulty = new osuBase.BeatmapDifficulty(this.beatmap.difficulty);
|
|
2149
|
+
osuBase.ModUtil.applyModsToBeatmapDifficulty(adjustedDifficulty, osuBase.Modes.droid, mods);
|
|
2150
|
+
const mehWindow = mods.has(osuBase.ModPrecise)
|
|
2151
|
+
? new osuBase.PreciseDroidHitWindow(adjustedDifficulty.od).mehWindow
|
|
2152
|
+
: new osuBase.DroidHitWindow(adjustedDifficulty.od).mehWindow;
|
|
2153
|
+
const accuracies = [];
|
|
2154
|
+
for (let i = 0; i < hitObjectData.length; ++i) {
|
|
2155
|
+
const v = hitObjectData[i];
|
|
2156
|
+
const o = objects[i];
|
|
2157
|
+
if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
|
|
2158
|
+
continue;
|
|
2159
|
+
}
|
|
2160
|
+
const { accuracy } = v;
|
|
2161
|
+
if (o instanceof osuBase.Slider &&
|
|
2162
|
+
// Do not include slider breaks.
|
|
2163
|
+
(-mehWindow > accuracy ||
|
|
2164
|
+
accuracy > Math.min(mehWindow, o.duration))) {
|
|
2165
|
+
continue;
|
|
2166
|
+
}
|
|
2167
|
+
accuracies.push(accuracy);
|
|
2168
|
+
if (accuracy >= 0) {
|
|
2169
|
+
positiveTotal += accuracy;
|
|
2170
|
+
++positiveCount;
|
|
2171
|
+
}
|
|
2172
|
+
else {
|
|
2173
|
+
negativeTotal += accuracy;
|
|
2174
|
+
++negativeCount;
|
|
2175
|
+
}
|
|
2176
|
+
}
|
|
2177
|
+
return {
|
|
2178
|
+
positiveAvg: positiveTotal / positiveCount || 0,
|
|
2179
|
+
negativeAvg: negativeTotal / negativeCount || 0,
|
|
2180
|
+
unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
|
|
2181
|
+
};
|
|
2182
|
+
}
|
|
1939
2183
|
/**
|
|
1940
|
-
*
|
|
2184
|
+
* Checks if a play is using 3 fingers.
|
|
2185
|
+
*
|
|
2186
|
+
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
1941
2187
|
*/
|
|
1942
|
-
|
|
2188
|
+
checkFor3Finger() {
|
|
2189
|
+
var _a;
|
|
2190
|
+
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2191
|
+
return;
|
|
2192
|
+
}
|
|
2193
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2194
|
+
const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
|
|
2195
|
+
? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
|
|
2196
|
+
: new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
|
|
2197
|
+
const result = threeFingerChecker.check();
|
|
2198
|
+
this.is3Finger = result.is3Finger;
|
|
2199
|
+
this.tapPenalty = result.penalty;
|
|
2200
|
+
this.hasBeenCheckedFor3Finger = true;
|
|
2201
|
+
}
|
|
1943
2202
|
/**
|
|
1944
|
-
*
|
|
2203
|
+
* Checks if a play is using 2 hands.
|
|
2204
|
+
*
|
|
2205
|
+
* Requires `analyze()` to be called first as well as `beatmap` and `difficultyAttributes` to be defined.
|
|
1945
2206
|
*/
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
INT_LENGTH = 4;
|
|
1951
|
-
FLOAT_LENGTH = 4;
|
|
1952
|
-
LONG_LENGTH = 8;
|
|
1953
|
-
constructor(values) {
|
|
1954
|
-
this.scoreID = values.scoreID;
|
|
1955
|
-
this.beatmap = values.map;
|
|
1956
|
-
this.difficultyAttributes = values.difficultyAttributes;
|
|
1957
|
-
if (this.beatmap && !(this.beatmap instanceof osuBase.Beatmap)) {
|
|
1958
|
-
this.difficultyAttributes = this.beatmap.attributes;
|
|
2207
|
+
checkFor2Hand() {
|
|
2208
|
+
var _a;
|
|
2209
|
+
if (!this.beatmap || !this.difficultyAttributes || !this.data) {
|
|
2210
|
+
return;
|
|
1959
2211
|
}
|
|
2212
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2213
|
+
const twoHandChecker = new TwoHandChecker(this.playableBeatmap, this.difficultyAttributes, this.data);
|
|
2214
|
+
const result = twoHandChecker.check();
|
|
2215
|
+
this.is2Hand = result.is2Hand;
|
|
2216
|
+
this.twoHandedNoteCount = result.twoHandedNoteCount;
|
|
2217
|
+
this.hasBeenCheckedFor2Hand = true;
|
|
1960
2218
|
}
|
|
1961
2219
|
/**
|
|
1962
|
-
*
|
|
2220
|
+
* Checks if a play has cheesed sliders.
|
|
2221
|
+
*
|
|
2222
|
+
* Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
|
|
1963
2223
|
*/
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
if (!this.originalODR) {
|
|
1969
|
-
return this;
|
|
1970
|
-
}
|
|
1971
|
-
if (!this.fixedODR) {
|
|
1972
|
-
this.fixedODR = await this.decompress().catch(() => null);
|
|
1973
|
-
}
|
|
1974
|
-
if (!this.fixedODR) {
|
|
1975
|
-
return this;
|
|
2224
|
+
checkForSliderCheesing() {
|
|
2225
|
+
var _a;
|
|
2226
|
+
if (!this.beatmap || !this.data || !this.difficultyAttributes) {
|
|
2227
|
+
return;
|
|
1976
2228
|
}
|
|
1977
|
-
this.
|
|
1978
|
-
|
|
2229
|
+
(_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
|
|
2230
|
+
const sliderCheeseChecker = new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
|
|
2231
|
+
this.sliderCheesePenalty = sliderCheeseChecker.check();
|
|
2232
|
+
this.hasBeenCheckedForSliderCheesing = true;
|
|
1979
2233
|
}
|
|
1980
2234
|
/**
|
|
1981
2235
|
* Downloads the given score ID's replay.
|
|
1982
2236
|
*/
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
.
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
2237
|
+
downloadReplay() {
|
|
2238
|
+
return __awaiter(this, void 0, void 0, function* () {
|
|
2239
|
+
if (this.scoreID === undefined) {
|
|
2240
|
+
return null;
|
|
2241
|
+
}
|
|
2242
|
+
const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
|
|
2243
|
+
.setRequireAPIkey(false)
|
|
2244
|
+
.setEndpoint("upload")
|
|
2245
|
+
.addParameter("", `${this.scoreID}.odr`);
|
|
2246
|
+
const result = yield apiRequestBuilder.sendRequest();
|
|
2247
|
+
if (result.statusCode !== 200) {
|
|
2248
|
+
return null;
|
|
2249
|
+
}
|
|
2250
|
+
return result.data;
|
|
2251
|
+
});
|
|
1993
2252
|
}
|
|
1994
2253
|
/**
|
|
1995
2254
|
* Decompresses a replay.
|
|
@@ -2003,15 +2262,15 @@ class ReplayAnalyzer {
|
|
|
2003
2262
|
stream.push(null);
|
|
2004
2263
|
stream
|
|
2005
2264
|
.pipe(unzipper.Parse())
|
|
2006
|
-
.on("entry",
|
|
2265
|
+
.on("entry", (entry) => __awaiter(this, void 0, void 0, function* () {
|
|
2007
2266
|
const fileName = entry.path;
|
|
2008
2267
|
if (fileName === "data") {
|
|
2009
|
-
return resolve(
|
|
2268
|
+
return resolve(yield entry.buffer());
|
|
2010
2269
|
}
|
|
2011
2270
|
else {
|
|
2012
2271
|
entry.autodrain();
|
|
2013
2272
|
}
|
|
2014
|
-
})
|
|
2273
|
+
}))
|
|
2015
2274
|
.on("error", (e) => {
|
|
2016
2275
|
setTimeout(() => reject(e), 2000);
|
|
2017
2276
|
});
|
|
@@ -2021,6 +2280,9 @@ class ReplayAnalyzer {
|
|
|
2021
2280
|
* Parses a replay after being downloaded and converted to a buffer.
|
|
2022
2281
|
*/
|
|
2023
2282
|
parseReplay() {
|
|
2283
|
+
if (!this.fixedODR) {
|
|
2284
|
+
return;
|
|
2285
|
+
}
|
|
2024
2286
|
// javaDeserialization can only somewhat parse some string field
|
|
2025
2287
|
// the rest will be a buffer that we need to manually parse
|
|
2026
2288
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
@@ -2028,7 +2290,7 @@ class ReplayAnalyzer {
|
|
|
2028
2290
|
try {
|
|
2029
2291
|
rawObject = javaDeserialization__namespace.parse(this.fixedODR);
|
|
2030
2292
|
}
|
|
2031
|
-
catch {
|
|
2293
|
+
catch (_a) {
|
|
2032
2294
|
return;
|
|
2033
2295
|
}
|
|
2034
2296
|
const resultObject = {
|
|
@@ -2038,85 +2300,60 @@ class ReplayAnalyzer {
|
|
|
2038
2300
|
hash: rawObject[3],
|
|
2039
2301
|
cursorMovement: [],
|
|
2040
2302
|
hitObjectData: [],
|
|
2303
|
+
accuracy: new osuBase.Accuracy({ n300: 0 }),
|
|
2304
|
+
rank: "D",
|
|
2305
|
+
convertedMods: new osuBase.ModMap(),
|
|
2306
|
+
hit100k: 0,
|
|
2307
|
+
hit300k: 0,
|
|
2308
|
+
isFullCombo: false,
|
|
2309
|
+
maxCombo: 0,
|
|
2310
|
+
playerName: "",
|
|
2311
|
+
score: 0,
|
|
2312
|
+
time: new Date(0),
|
|
2041
2313
|
};
|
|
2042
2314
|
if (resultObject.replayVersion >= 3) {
|
|
2043
|
-
resultObject.time
|
|
2315
|
+
resultObject.time.setTime(Number(rawObject[4].readBigUInt64BE(0)));
|
|
2044
2316
|
resultObject.hit300k = rawObject[4].readInt32BE(8);
|
|
2317
|
+
resultObject.accuracy.n300 = rawObject[4].readInt32BE(12);
|
|
2045
2318
|
resultObject.hit100k = rawObject[4].readInt32BE(16);
|
|
2319
|
+
resultObject.accuracy.n100 = rawObject[4].readInt32BE(20);
|
|
2320
|
+
resultObject.accuracy.n50 = rawObject[4].readInt32BE(24);
|
|
2321
|
+
resultObject.accuracy.nmiss = rawObject[4].readInt32BE(28);
|
|
2046
2322
|
resultObject.score = rawObject[4].readInt32BE(32);
|
|
2047
2323
|
resultObject.maxCombo = rawObject[4].readInt32BE(36);
|
|
2048
|
-
resultObject.
|
|
2049
|
-
n300: rawObject[4].readInt32BE(12),
|
|
2050
|
-
n100: rawObject[4].readInt32BE(20),
|
|
2051
|
-
n50: rawObject[4].readInt32BE(24),
|
|
2052
|
-
nmiss: rawObject[4].readInt32BE(28),
|
|
2053
|
-
});
|
|
2054
|
-
resultObject.isFullCombo = !!rawObject[4][44];
|
|
2324
|
+
resultObject.isFullCombo = resultObject.accuracy.value() === 1;
|
|
2055
2325
|
resultObject.playerName = rawObject[5];
|
|
2056
|
-
resultObject.
|
|
2057
|
-
|
|
2058
|
-
// Determine rank
|
|
2059
|
-
const totalHits = resultObject.accuracy.n300 +
|
|
2060
|
-
resultObject.accuracy.n100 +
|
|
2061
|
-
resultObject.accuracy.n50 +
|
|
2062
|
-
resultObject.accuracy.nmiss;
|
|
2063
|
-
const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
|
|
2064
|
-
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2065
|
-
switch (true) {
|
|
2066
|
-
case resultObject.accuracy.value() === 1:
|
|
2067
|
-
if (isHidden) {
|
|
2068
|
-
resultObject.rank = "XH";
|
|
2069
|
-
}
|
|
2070
|
-
else {
|
|
2071
|
-
resultObject.rank = "X";
|
|
2072
|
-
}
|
|
2073
|
-
break;
|
|
2074
|
-
case hit300Ratio > 0.9 &&
|
|
2075
|
-
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2076
|
-
!resultObject.accuracy.nmiss:
|
|
2077
|
-
if (isHidden) {
|
|
2078
|
-
resultObject.rank = "SH";
|
|
2079
|
-
}
|
|
2080
|
-
else {
|
|
2081
|
-
resultObject.rank = "S";
|
|
2082
|
-
}
|
|
2083
|
-
break;
|
|
2084
|
-
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
2085
|
-
hit300Ratio > 0.9:
|
|
2086
|
-
resultObject.rank = "A";
|
|
2087
|
-
break;
|
|
2088
|
-
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
2089
|
-
hit300Ratio > 0.8:
|
|
2090
|
-
resultObject.rank = "B";
|
|
2091
|
-
break;
|
|
2092
|
-
case hit300Ratio > 0.6:
|
|
2093
|
-
resultObject.rank = "C";
|
|
2094
|
-
break;
|
|
2095
|
-
default:
|
|
2096
|
-
resultObject.rank = "D";
|
|
2326
|
+
if (resultObject.replayVersion >= 7) {
|
|
2327
|
+
resultObject.convertedMods = osuBase.ModUtil.deserializeMods(rawObject[6]);
|
|
2097
2328
|
}
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
2102
|
-
|
|
2103
|
-
if (s.length > 1) {
|
|
2104
|
-
resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
|
|
2329
|
+
else {
|
|
2330
|
+
resultObject.convertedMods = this.convertDroidMods(resultObject.replayVersion, Object.values(rawObject[6].elements));
|
|
2331
|
+
if (resultObject.replayVersion >= 4) {
|
|
2332
|
+
osuBase.DroidLegacyModConverter.parseExtraModString(resultObject.convertedMods, rawObject[7].split("|"));
|
|
2333
|
+
}
|
|
2105
2334
|
}
|
|
2335
|
+
resultObject.rank = this.calculateRank(resultObject);
|
|
2336
|
+
}
|
|
2337
|
+
if (resultObject.replayVersion <= 6) {
|
|
2338
|
+
resultObject.convertedMods.set(osuBase.ModReplayV6);
|
|
2106
2339
|
}
|
|
2107
2340
|
let bufferIndex;
|
|
2108
|
-
switch (
|
|
2109
|
-
|
|
2110
|
-
case
|
|
2111
|
-
bufferIndex =
|
|
2341
|
+
switch (resultObject.replayVersion) {
|
|
2342
|
+
case 1:
|
|
2343
|
+
case 2:
|
|
2344
|
+
bufferIndex = 4;
|
|
2112
2345
|
break;
|
|
2113
|
-
|
|
2114
|
-
case
|
|
2346
|
+
case 3:
|
|
2347
|
+
case 7:
|
|
2115
2348
|
bufferIndex = 7;
|
|
2116
2349
|
break;
|
|
2117
|
-
|
|
2350
|
+
case 4:
|
|
2351
|
+
case 5:
|
|
2352
|
+
case 6:
|
|
2353
|
+
bufferIndex = 8;
|
|
2354
|
+
break;
|
|
2118
2355
|
default:
|
|
2119
|
-
|
|
2356
|
+
throw new Error("Unsupported replay version: " + resultObject.replayVersion);
|
|
2120
2357
|
}
|
|
2121
2358
|
const replayDataBufferArray = [];
|
|
2122
2359
|
while (bufferIndex < rawObject.length) {
|
|
@@ -2124,39 +2361,87 @@ class ReplayAnalyzer {
|
|
|
2124
2361
|
}
|
|
2125
2362
|
// Merge all cursor movement and hit object data section into one for better control when parsing
|
|
2126
2363
|
const replayDataBuffer = Buffer.concat(replayDataBufferArray);
|
|
2127
|
-
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
2131
|
-
|
|
2132
|
-
|
|
2133
|
-
|
|
2364
|
+
this.bufferOffset = 0;
|
|
2365
|
+
this.parseMovementData(resultObject, replayDataBuffer);
|
|
2366
|
+
this.parseHitObjectData(resultObject, replayDataBuffer);
|
|
2367
|
+
this.parseOldReplayInformation(resultObject);
|
|
2368
|
+
switch (resultObject.replayVersion) {
|
|
2369
|
+
case 1:
|
|
2370
|
+
case 2:
|
|
2371
|
+
this.data = new ReplayData(resultObject);
|
|
2372
|
+
break;
|
|
2373
|
+
default:
|
|
2374
|
+
this.data = new ReplayV3Data(resultObject);
|
|
2375
|
+
}
|
|
2376
|
+
}
|
|
2377
|
+
/**
|
|
2378
|
+
* Converts replay mods to droid mod string.
|
|
2379
|
+
*/
|
|
2380
|
+
convertDroidMods(replayVersion, replayMods) {
|
|
2381
|
+
const replayModsConstants = {
|
|
2382
|
+
MOD_AUTO: osuBase.ModAuto,
|
|
2383
|
+
MOD_AUTOPILOT: osuBase.ModAutopilot,
|
|
2384
|
+
MOD_NOFAIL: osuBase.ModNoFail,
|
|
2385
|
+
MOD_EASY: osuBase.ModEasy,
|
|
2386
|
+
MOD_HIDDEN: osuBase.ModHidden,
|
|
2387
|
+
MOD_TRACEABLE: osuBase.ModTraceable,
|
|
2388
|
+
MOD_HARDROCK: osuBase.ModHardRock,
|
|
2389
|
+
MOD_DOUBLETIME: osuBase.ModDoubleTime,
|
|
2390
|
+
MOD_HALFTIME: osuBase.ModHalfTime,
|
|
2391
|
+
MOD_NIGHTCORE: osuBase.ModNightCore,
|
|
2392
|
+
MOD_PRECISE: osuBase.ModPrecise,
|
|
2393
|
+
MOD_SMALLCIRCLE: osuBase.ModSmallCircle,
|
|
2394
|
+
MOD_REALLYEASY: osuBase.ModReallyEasy,
|
|
2395
|
+
MOD_RELAX: osuBase.ModRelax,
|
|
2396
|
+
MOD_PERFECT: osuBase.ModPerfect,
|
|
2397
|
+
MOD_SUDDENDEATH: osuBase.ModSuddenDeath,
|
|
2398
|
+
MOD_SCOREV2: osuBase.ModScoreV2,
|
|
2399
|
+
MOD_FLASHLIGHT: osuBase.ModFlashlight,
|
|
2400
|
+
};
|
|
2401
|
+
const map = new osuBase.ModMap();
|
|
2402
|
+
for (const mod of replayMods) {
|
|
2403
|
+
for (const property in Object(replayModsConstants)) {
|
|
2404
|
+
if (!mod.includes(property)) {
|
|
2405
|
+
continue;
|
|
2406
|
+
}
|
|
2407
|
+
if (replayVersion <= 3 && mod === "MOD_NIGHTCORE") {
|
|
2408
|
+
// In replay v3, the NightCore mod is bugged. See ModOldNightCore's description.
|
|
2409
|
+
map.set(new osuBase.ModOldNightCore());
|
|
2410
|
+
}
|
|
2411
|
+
else {
|
|
2412
|
+
map.set(replayModsConstants[property]);
|
|
2413
|
+
}
|
|
2414
|
+
break;
|
|
2415
|
+
}
|
|
2416
|
+
}
|
|
2417
|
+
return map;
|
|
2418
|
+
}
|
|
2419
|
+
parseMovementData(resultObject, replayDataBuffer) {
|
|
2420
|
+
resultObject.cursorMovement.length = 0;
|
|
2421
|
+
const size = this.readInt(replayDataBuffer);
|
|
2422
|
+
for (let i = 0; i < size; i++) {
|
|
2423
|
+
const moveSize = this.readInt(replayDataBuffer);
|
|
2134
2424
|
const time = [];
|
|
2135
2425
|
const x = [];
|
|
2136
2426
|
const y = [];
|
|
2137
2427
|
const id = [];
|
|
2138
|
-
for (let
|
|
2139
|
-
time[
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
2143
|
-
if (id[i] !== exports.MovementType.up) {
|
|
2428
|
+
for (let j = 0; j < moveSize; j++) {
|
|
2429
|
+
time[j] = this.readInt(replayDataBuffer);
|
|
2430
|
+
id[j] = time[j] & 3;
|
|
2431
|
+
time[j] >>= 2;
|
|
2432
|
+
if (id[j] !== exports.MovementType.up) {
|
|
2144
2433
|
if (resultObject.replayVersion >= 5) {
|
|
2145
|
-
x[
|
|
2146
|
-
|
|
2147
|
-
y[i] = replayDataBuffer.readFloatBE(bufferCounter);
|
|
2148
|
-
bufferCounter += this.FLOAT_LENGTH;
|
|
2434
|
+
x[j] = this.readFloat(replayDataBuffer);
|
|
2435
|
+
y[j] = this.readFloat(replayDataBuffer);
|
|
2149
2436
|
}
|
|
2150
2437
|
else {
|
|
2151
|
-
x[
|
|
2152
|
-
|
|
2153
|
-
y[i] = replayDataBuffer.readInt16BE(bufferCounter);
|
|
2154
|
-
bufferCounter += this.SHORT_LENGTH;
|
|
2438
|
+
x[j] = this.readShort(replayDataBuffer);
|
|
2439
|
+
y[j] = this.readShort(replayDataBuffer);
|
|
2155
2440
|
}
|
|
2156
2441
|
}
|
|
2157
2442
|
else {
|
|
2158
|
-
x[
|
|
2159
|
-
y[
|
|
2443
|
+
x[j] = -1;
|
|
2444
|
+
y[j] = -1;
|
|
2160
2445
|
}
|
|
2161
2446
|
}
|
|
2162
2447
|
resultObject.cursorMovement.push(new CursorData({
|
|
@@ -2167,8 +2452,10 @@ class ReplayAnalyzer {
|
|
|
2167
2452
|
id: id,
|
|
2168
2453
|
}));
|
|
2169
2454
|
}
|
|
2170
|
-
|
|
2171
|
-
|
|
2455
|
+
}
|
|
2456
|
+
parseHitObjectData(resultObject, replayDataBuffer) {
|
|
2457
|
+
resultObject.hitObjectData.length = 0;
|
|
2458
|
+
const replayObjectLength = this.readInt(replayDataBuffer);
|
|
2172
2459
|
// Parse result data
|
|
2173
2460
|
for (let i = 0; i < replayObjectLength; i++) {
|
|
2174
2461
|
const replayObjectData = {
|
|
@@ -2176,16 +2463,12 @@ class ReplayAnalyzer {
|
|
|
2176
2463
|
tickset: [],
|
|
2177
2464
|
result: exports.HitResult.miss,
|
|
2178
2465
|
};
|
|
2179
|
-
replayObjectData.accuracy =
|
|
2180
|
-
|
|
2181
|
-
bufferCounter += this.SHORT_LENGTH;
|
|
2182
|
-
const len = replayDataBuffer.readInt8(bufferCounter);
|
|
2183
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2466
|
+
replayObjectData.accuracy = this.readShort(replayDataBuffer);
|
|
2467
|
+
const len = this.readByte(replayDataBuffer);
|
|
2184
2468
|
if (len > 0) {
|
|
2185
2469
|
const bytes = [];
|
|
2186
2470
|
for (let j = 0; j < len; j++) {
|
|
2187
|
-
bytes.push(
|
|
2188
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2471
|
+
bytes.push(this.readByte(replayDataBuffer));
|
|
2189
2472
|
}
|
|
2190
2473
|
// Int/int division in Java; numbers must be truncated to get actual number
|
|
2191
2474
|
for (let j = 0; j < len * 8; j++) {
|
|
@@ -2195,249 +2478,117 @@ class ReplayAnalyzer {
|
|
|
2195
2478
|
}
|
|
2196
2479
|
}
|
|
2197
2480
|
if (resultObject.replayVersion >= 1) {
|
|
2198
|
-
replayObjectData.result =
|
|
2199
|
-
replayDataBuffer.readInt8(bufferCounter);
|
|
2200
|
-
bufferCounter += this.BYTE_LENGTH;
|
|
2481
|
+
replayObjectData.result = this.readByte(replayDataBuffer);
|
|
2201
2482
|
}
|
|
2202
2483
|
resultObject.hitObjectData.push(replayObjectData);
|
|
2203
2484
|
}
|
|
2485
|
+
}
|
|
2486
|
+
parseOldReplayInformation(resultObject) {
|
|
2487
|
+
var _a, _b, _c;
|
|
2204
2488
|
// Parse max combo, hit results, and accuracy in old replay version
|
|
2205
|
-
if (resultObject.replayVersion
|
|
2206
|
-
|
|
2207
|
-
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
const
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
case exports.HitResult.miss:
|
|
2223
|
-
++hit0;
|
|
2224
|
-
grantsGekiOrKatu = false;
|
|
2225
|
-
break;
|
|
2226
|
-
case exports.HitResult.meh:
|
|
2227
|
-
++hit50;
|
|
2228
|
-
grantsGekiOrKatu = false;
|
|
2229
|
-
break;
|
|
2230
|
-
case exports.HitResult.good:
|
|
2231
|
-
++hit100;
|
|
2232
|
-
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2233
|
-
++hit100k;
|
|
2234
|
-
}
|
|
2235
|
-
break;
|
|
2236
|
-
case exports.HitResult.great:
|
|
2237
|
-
++hit300;
|
|
2238
|
-
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2239
|
-
++hit300k;
|
|
2240
|
-
}
|
|
2241
|
-
break;
|
|
2242
|
-
}
|
|
2243
|
-
if (isNextNewCombo) {
|
|
2244
|
-
grantsGekiOrKatu = true;
|
|
2245
|
-
}
|
|
2246
|
-
}
|
|
2247
|
-
resultObject.hit300k = hit300k;
|
|
2248
|
-
resultObject.hit100k = hit100k;
|
|
2249
|
-
resultObject.accuracy = new osuBase.Accuracy({
|
|
2250
|
-
n300: hit300,
|
|
2251
|
-
n100: hit100,
|
|
2252
|
-
n50: hit50,
|
|
2253
|
-
nmiss: hit0,
|
|
2254
|
-
nobjects: hit300 + hit100 + hit50 + hit0,
|
|
2255
|
-
});
|
|
2256
|
-
// Determine rank
|
|
2257
|
-
const totalHits = resultObject.accuracy.n300 +
|
|
2258
|
-
resultObject.accuracy.n100 +
|
|
2259
|
-
resultObject.accuracy.n50 +
|
|
2260
|
-
resultObject.accuracy.nmiss;
|
|
2261
|
-
const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
|
|
2262
|
-
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2263
|
-
switch (true) {
|
|
2264
|
-
case resultObject.accuracy.value() === 1:
|
|
2265
|
-
if (isHidden) {
|
|
2266
|
-
resultObject.rank = "XH";
|
|
2267
|
-
}
|
|
2268
|
-
else {
|
|
2269
|
-
resultObject.rank = "X";
|
|
2270
|
-
}
|
|
2271
|
-
break;
|
|
2272
|
-
case hit300Ratio > 0.9 &&
|
|
2273
|
-
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2274
|
-
!resultObject.accuracy.nmiss:
|
|
2275
|
-
if (isHidden) {
|
|
2276
|
-
resultObject.rank = "SH";
|
|
2277
|
-
}
|
|
2278
|
-
else {
|
|
2279
|
-
resultObject.rank = "S";
|
|
2280
|
-
}
|
|
2489
|
+
if (resultObject.replayVersion >= 3) {
|
|
2490
|
+
return;
|
|
2491
|
+
}
|
|
2492
|
+
const objects = (_a = this.beatmap) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
|
|
2493
|
+
let grantsGekiOrKatu = true;
|
|
2494
|
+
for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
|
|
2495
|
+
// Hit result
|
|
2496
|
+
const hitObjectData = resultObject.hitObjectData[i];
|
|
2497
|
+
const isNextNewCombo = objects
|
|
2498
|
+
? i + 1 !== objects.length
|
|
2499
|
+
? objects[i + 1].isNewCombo
|
|
2500
|
+
: true
|
|
2501
|
+
: false;
|
|
2502
|
+
switch (hitObjectData.result) {
|
|
2503
|
+
case exports.HitResult.miss:
|
|
2504
|
+
++resultObject.accuracy.nmiss;
|
|
2505
|
+
grantsGekiOrKatu = false;
|
|
2281
2506
|
break;
|
|
2282
|
-
case
|
|
2283
|
-
|
|
2284
|
-
|
|
2507
|
+
case exports.HitResult.meh:
|
|
2508
|
+
++resultObject.accuracy.n50;
|
|
2509
|
+
grantsGekiOrKatu = false;
|
|
2285
2510
|
break;
|
|
2286
|
-
case
|
|
2287
|
-
|
|
2288
|
-
|
|
2511
|
+
case exports.HitResult.good:
|
|
2512
|
+
++resultObject.accuracy.n100;
|
|
2513
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2514
|
+
(_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
|
|
2515
|
+
++resultObject.hit100k;
|
|
2516
|
+
}
|
|
2289
2517
|
break;
|
|
2290
|
-
case
|
|
2291
|
-
resultObject.
|
|
2518
|
+
case exports.HitResult.great:
|
|
2519
|
+
++resultObject.accuracy.n300;
|
|
2520
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
2521
|
+
(_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
|
|
2522
|
+
++resultObject.hit300k;
|
|
2523
|
+
}
|
|
2292
2524
|
break;
|
|
2293
|
-
default:
|
|
2294
|
-
resultObject.rank = "D";
|
|
2295
|
-
}
|
|
2296
|
-
}
|
|
2297
|
-
this.data = new ReplayData(resultObject);
|
|
2298
|
-
}
|
|
2299
|
-
/**
|
|
2300
|
-
* Gets hit error information of the replay.
|
|
2301
|
-
*
|
|
2302
|
-
* `analyze()` must be called before calling this.
|
|
2303
|
-
*/
|
|
2304
|
-
calculateHitError() {
|
|
2305
|
-
if (!this.data || !this.beatmap) {
|
|
2306
|
-
return null;
|
|
2307
|
-
}
|
|
2308
|
-
const hitObjectData = this.data.hitObjectData;
|
|
2309
|
-
let positiveCount = 0;
|
|
2310
|
-
let negativeCount = 0;
|
|
2311
|
-
let positiveTotal = 0;
|
|
2312
|
-
let negativeTotal = 0;
|
|
2313
|
-
const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2314
|
-
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
|
|
2315
|
-
? this.beatmap.beatmap
|
|
2316
|
-
: this.beatmap;
|
|
2317
|
-
const objects = beatmap.hitObjects.objects;
|
|
2318
|
-
const stats = new osuBase.MapStats({
|
|
2319
|
-
od: beatmap.difficulty.od,
|
|
2320
|
-
mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
|
|
2321
|
-
}).calculate();
|
|
2322
|
-
const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
|
|
2323
|
-
// The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
|
|
2324
|
-
// https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
|
|
2325
|
-
//
|
|
2326
|
-
// In such cases, the slider is skipped.
|
|
2327
|
-
const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
|
|
2328
|
-
const accuracies = [];
|
|
2329
|
-
for (let i = 0; i < hitObjectData.length; ++i) {
|
|
2330
|
-
const v = hitObjectData[i];
|
|
2331
|
-
const o = objects[i];
|
|
2332
|
-
if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
|
|
2333
|
-
accuracies.push(0);
|
|
2334
|
-
continue;
|
|
2335
|
-
}
|
|
2336
|
-
const accuracy = v.accuracy;
|
|
2337
|
-
if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
|
|
2338
|
-
accuracies.push(0);
|
|
2339
|
-
continue;
|
|
2340
2525
|
}
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
positiveTotal += accuracy;
|
|
2344
|
-
++positiveCount;
|
|
2345
|
-
}
|
|
2346
|
-
else {
|
|
2347
|
-
negativeTotal += accuracy;
|
|
2348
|
-
++negativeCount;
|
|
2526
|
+
if (isNextNewCombo) {
|
|
2527
|
+
grantsGekiOrKatu = true;
|
|
2349
2528
|
}
|
|
2350
2529
|
}
|
|
2351
|
-
|
|
2352
|
-
positiveAvg: positiveTotal / positiveCount || 0,
|
|
2353
|
-
negativeAvg: negativeTotal / negativeCount || 0,
|
|
2354
|
-
unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
|
|
2355
|
-
};
|
|
2530
|
+
resultObject.rank = this.calculateRank(resultObject);
|
|
2356
2531
|
}
|
|
2357
|
-
|
|
2358
|
-
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
if (!mod.includes(property)) {
|
|
2384
|
-
continue;
|
|
2385
|
-
}
|
|
2386
|
-
modString +=
|
|
2387
|
-
replayModsConstants[property];
|
|
2388
|
-
break;
|
|
2389
|
-
}
|
|
2532
|
+
calculateRank(resultObject) {
|
|
2533
|
+
var _a, _b, _c;
|
|
2534
|
+
const totalHits = resultObject.accuracy.n300 +
|
|
2535
|
+
resultObject.accuracy.n100 +
|
|
2536
|
+
resultObject.accuracy.n50 +
|
|
2537
|
+
resultObject.accuracy.nmiss;
|
|
2538
|
+
const isHidden = (_c = (((_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.has(osuBase.ModHidden)) ||
|
|
2539
|
+
((_b = resultObject.convertedMods) === null || _b === void 0 ? void 0 : _b.has(osuBase.ModFlashlight)))) !== null && _c !== void 0 ? _c : false;
|
|
2540
|
+
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
2541
|
+
switch (true) {
|
|
2542
|
+
case resultObject.accuracy.value() === 1:
|
|
2543
|
+
return isHidden ? "XH" : "X";
|
|
2544
|
+
case hit300Ratio > 0.9 &&
|
|
2545
|
+
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
2546
|
+
!resultObject.accuracy.nmiss:
|
|
2547
|
+
return isHidden ? "SH" : "S";
|
|
2548
|
+
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
2549
|
+
hit300Ratio > 0.9:
|
|
2550
|
+
return "A";
|
|
2551
|
+
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
2552
|
+
hit300Ratio > 0.8:
|
|
2553
|
+
return "B";
|
|
2554
|
+
case hit300Ratio > 0.6:
|
|
2555
|
+
return "C";
|
|
2556
|
+
default:
|
|
2557
|
+
return "D";
|
|
2390
2558
|
}
|
|
2391
|
-
return modString;
|
|
2392
2559
|
}
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2404
|
-
|
|
2405
|
-
const result = threeFingerChecker.check();
|
|
2406
|
-
this.is3Finger = result.is3Finger;
|
|
2407
|
-
this.tapPenalty = result.penalty;
|
|
2408
|
-
this.hasBeenCheckedFor3Finger = true;
|
|
2560
|
+
constructPlayableBeatmap() {
|
|
2561
|
+
var _a;
|
|
2562
|
+
if (this.beatmap instanceof osuBase.DroidPlayableBeatmap) {
|
|
2563
|
+
return this.beatmap;
|
|
2564
|
+
}
|
|
2565
|
+
if (!this.beatmap || !this.data) {
|
|
2566
|
+
throw new Error("Beatmap and replay data must be defined.");
|
|
2567
|
+
}
|
|
2568
|
+
const mods = this.data.isReplayV3()
|
|
2569
|
+
? this.data.convertedMods
|
|
2570
|
+
: (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods;
|
|
2571
|
+
return this.beatmap.createDroidPlayableBeatmap(mods);
|
|
2409
2572
|
}
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
*/
|
|
2415
|
-
checkFor2Hand() {
|
|
2416
|
-
if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
|
|
2417
|
-
this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
|
|
2418
|
-
!this.data) {
|
|
2419
|
-
return;
|
|
2420
|
-
}
|
|
2421
|
-
const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
|
|
2422
|
-
const result = twoHandChecker.check();
|
|
2423
|
-
this.is2Hand = result.is2Hand;
|
|
2424
|
-
this.twoHandedNoteCount = result.twoHandedNoteCount;
|
|
2425
|
-
this.hasBeenCheckedFor2Hand = true;
|
|
2573
|
+
readByte(buffer) {
|
|
2574
|
+
const num = buffer.readInt8(this.bufferOffset);
|
|
2575
|
+
this.bufferOffset += 1;
|
|
2576
|
+
return num;
|
|
2426
2577
|
}
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
this.
|
|
2440
|
-
|
|
2578
|
+
readShort(buffer) {
|
|
2579
|
+
const num = buffer.readInt16BE(this.bufferOffset);
|
|
2580
|
+
this.bufferOffset += 2;
|
|
2581
|
+
return num;
|
|
2582
|
+
}
|
|
2583
|
+
readInt(buffer) {
|
|
2584
|
+
const num = buffer.readInt32BE(this.bufferOffset);
|
|
2585
|
+
this.bufferOffset += 4;
|
|
2586
|
+
return num;
|
|
2587
|
+
}
|
|
2588
|
+
readFloat(buffer) {
|
|
2589
|
+
const num = buffer.readFloatBE(this.bufferOffset);
|
|
2590
|
+
this.bufferOffset += 4;
|
|
2591
|
+
return num;
|
|
2441
2592
|
}
|
|
2442
2593
|
}
|
|
2443
2594
|
|
|
@@ -2449,33 +2600,6 @@ class ReplayAnalyzer {
|
|
|
2449
2600
|
* This is used when analyzing replays using replay analyzer.
|
|
2450
2601
|
*/
|
|
2451
2602
|
class ReplayObjectData {
|
|
2452
|
-
/**
|
|
2453
|
-
* 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)).
|
|
2454
|
-
*
|
|
2455
|
-
* For sliders, this is the offset at which the slider head was hit. For
|
|
2456
|
-
* 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)).
|
|
2457
|
-
*
|
|
2458
|
-
* For spinners, this is the total amount at which the spinner was spinned:
|
|
2459
|
-
* ```js
|
|
2460
|
-
* const rotations = Math.floor(data.accuracy / 4);
|
|
2461
|
-
* ```
|
|
2462
|
-
* The remainder of the division denotes the hit result of the spinner:
|
|
2463
|
-
* - `HitResult.great`: 3
|
|
2464
|
-
* - `HitResult.good`: 2
|
|
2465
|
-
* - `HitResult.meh`: 1
|
|
2466
|
-
* - `HitResult.miss`: 0
|
|
2467
|
-
*/
|
|
2468
|
-
accuracy;
|
|
2469
|
-
/**
|
|
2470
|
-
* The tickset of the hitobject.
|
|
2471
|
-
*
|
|
2472
|
-
* This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
|
|
2473
|
-
*/
|
|
2474
|
-
tickset;
|
|
2475
|
-
/**
|
|
2476
|
-
* The bitwise hit result of the hitobject.
|
|
2477
|
-
*/
|
|
2478
|
-
result;
|
|
2479
2603
|
constructor(values) {
|
|
2480
2604
|
this.accuracy = values.accuracy;
|
|
2481
2605
|
this.tickset = values.tickset;
|
|
@@ -2486,9 +2610,11 @@ class ReplayObjectData {
|
|
|
2486
2610
|
exports.CursorData = CursorData;
|
|
2487
2611
|
exports.CursorOccurrence = CursorOccurrence;
|
|
2488
2612
|
exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
|
|
2613
|
+
exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
|
|
2489
2614
|
exports.ReplayAnalyzer = ReplayAnalyzer;
|
|
2490
2615
|
exports.ReplayData = ReplayData;
|
|
2491
2616
|
exports.ReplayObjectData = ReplayObjectData;
|
|
2617
|
+
exports.ReplayV3Data = ReplayV3Data;
|
|
2492
2618
|
exports.SliderCheeseChecker = SliderCheeseChecker;
|
|
2493
2619
|
exports.ThreeFingerChecker = ThreeFingerChecker;
|
|
2494
2620
|
exports.TwoHandChecker = TwoHandChecker;
|