@rian8337/osu-droid-replay-analyzer 4.0.0-beta.6 → 4.0.0-beta.60

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