@rian8337/osu-droid-replay-analyzer 4.0.0-beta.23 → 4.0.0-beta.25

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.
Files changed (3) hide show
  1. package/dist/index.js +802 -858
  2. package/package.json +5 -5
  3. package/typings/index.d.ts +223 -111
package/dist/index.js CHANGED
@@ -294,19 +294,15 @@ exports.HitResult = void 0;
294
294
  * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
295
295
  */
296
296
  class RebalanceThreeFingerChecker {
297
+ get hitWindow50() {
298
+ return this.hitWindow.hitWindowFor50(this.isPrecise);
299
+ }
297
300
  /**
298
301
  * @param beatmap The beatmap to analyze.
299
302
  * @param data The data of the replay.
300
303
  * @param difficultyAttributes The difficulty attributes of the beatmap.
301
304
  */
302
305
  constructor(beatmap, data, difficultyAttributes) {
303
- /**
304
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
305
- *
306
- * Increasing this number will increase detection accuracy, however
307
- * it also increases the chance of falsely flagged plays.
308
- */
309
- this.threeFingerRatioThreshold = 0.01;
310
306
  /**
311
307
  * Extended sections of the beatmap for drag detection.
312
308
  */
@@ -333,15 +329,12 @@ class RebalanceThreeFingerChecker {
333
329
  this.difficultyAttributes = difficultyAttributes;
334
330
  const od = osuBase.calculateDroidDifficultyStatistics({
335
331
  overallDifficulty: beatmap.difficulty.od,
336
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
332
+ mods: osuBase.ModUtil.removeSpeedChangingMods(difficultyAttributes.mods),
337
333
  convertOverallDifficulty: false,
338
334
  }).overallDifficulty;
339
- this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
335
+ this.isPrecise = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
336
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
340
337
  this.hitWindow = new osuBase.DroidHitWindow(od);
341
- this.hitObjects = beatmap.hitObjects.objects;
342
- this.objectRadiusThreshold = this.hitObjects[0].radius;
343
- // Only allow object radius threshold to deviate slightly from actual object radius.
344
- this.maximumObjectRadiusThreshold = this.objectRadiusThreshold * 1.05;
345
338
  }
346
339
  /**
347
340
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -362,7 +355,8 @@ class RebalanceThreeFingerChecker {
362
355
  * nerf factor, taking beatmap difficulty into account.
363
356
  */
364
357
  check() {
365
- if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
358
+ if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
359
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
366
360
  return { is3Finger: false, penalty: 1 };
367
361
  }
368
362
  this.getAccurateBreakPoints();
@@ -382,10 +376,11 @@ class RebalanceThreeFingerChecker {
382
376
  * end of the hitobject before it and do not end right at the first hitobject after it.
383
377
  */
384
378
  getAccurateBreakPoints() {
379
+ const objects = this.beatmap.hitObjects.objects;
385
380
  const objectData = this.data.hitObjectData;
386
381
  for (const breakPoint of this.beatmap.events.breaks) {
387
- const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
388
- const objectBefore = this.hitObjects[beforeIndex];
382
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
383
+ const objectBefore = objects[beforeIndex];
389
384
  const objectBeforeData = objectData[beforeIndex];
390
385
  let timeBefore = objectBefore.endTime;
391
386
  if (objectBefore instanceof osuBase.Circle) {
@@ -397,9 +392,9 @@ class RebalanceThreeFingerChecker {
397
392
  }
398
393
  }
399
394
  const afterIndex = beforeIndex + 1;
400
- const objectAfter = this.hitObjects[afterIndex];
395
+ const objectAfter = objects[afterIndex];
401
396
  const objectAfterData = objectData[afterIndex];
402
- let timeAfter = this.hitObjects[afterIndex].startTime;
397
+ let timeAfter = objectAfter.startTime;
403
398
  if (objectAfter instanceof osuBase.Circle &&
404
399
  objectAfterData.result !== exports.HitResult.miss) {
405
400
  timeAfter += objectAfterData.accuracy;
@@ -416,11 +411,12 @@ class RebalanceThreeFingerChecker {
416
411
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
417
412
  */
418
413
  filterCursorInstances() {
414
+ const objects = this.beatmap.hitObjects.objects;
419
415
  const objectData = this.data.hitObjectData;
420
416
  const firstObjectResult = objectData[0].result;
421
417
  const lastObjectResult = objectData.at(-1).result;
422
- const firstObject = this.hitObjects[0];
423
- const lastObject = this.hitObjects.at(-1);
418
+ const firstObject = objects[0];
419
+ const lastObject = objects.at(-1);
424
420
  // For sliders, automatically set hit window length to be as lenient as possible.
425
421
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
426
422
  if (firstObject instanceof osuBase.Circle) {
@@ -480,289 +476,202 @@ class RebalanceThreeFingerChecker {
480
476
  * detect dragged sections and improve detection speed.
481
477
  */
482
478
  getBeatmapSections() {
483
- const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
479
+ const beatmapObjects = this.beatmap.hitObjects.objects;
480
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
481
+ // This intentionally starts from 1 because we need to look at the previous cursor.
482
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
483
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
484
484
  for (const section of this.difficultyAttributes
485
485
  .possibleThreeFingeredSections) {
486
- const dragFingerIndex = this.findDragIndex(section);
487
486
  const objects = [];
488
487
  for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
489
- objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
488
+ const object = beatmapObjects[i];
489
+ const objectData = this.data.hitObjectData[i];
490
+ objects.push({
491
+ object: object,
492
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
493
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
494
+ });
490
495
  }
491
- this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
496
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
492
497
  }
493
498
  }
494
499
  /**
495
- * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
500
+ * Obtains the index of the cursor that aimed the object at the nearest time.
496
501
  *
497
502
  * @param object The object to obtain the index for.
498
503
  * @param objectData The hit data of the object.
499
- * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
500
- * @param excludedIndices The cursor indices that should not be checked.
504
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
505
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
506
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
501
507
  * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
502
508
  */
503
- getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
509
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
504
510
  if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
505
- return {
506
- pressingCursorInstanceIndex: -1,
507
- pressingCursorIndex: -1,
508
- };
511
+ return -1;
509
512
  }
510
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
511
513
  // Check for sliderbreaks and treat them as misses.
512
514
  if (object instanceof osuBase.Slider &&
513
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
514
- return {
515
- pressingCursorInstanceIndex: -1,
516
- pressingCursorIndex: -1,
517
- };
518
- }
519
- // We are not directly using hit time to determine which cursor pressed the object
520
- // to account for time difference between hit registration and object judgement.
521
- let minHitTime = object.startTime;
522
- let maxHitTime = object.startTime;
523
- if (object instanceof osuBase.Circle) {
524
- let hitWindowGap = hitWindow50;
525
- switch (objectData.result) {
526
- case exports.HitResult.great:
527
- hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
528
- break;
529
- case exports.HitResult.good:
530
- hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
531
- break;
532
- }
533
- minHitTime -= hitWindowGap;
534
- maxHitTime += hitWindowGap;
535
- }
536
- else if (object instanceof osuBase.Slider) {
537
- minHitTime -= hitWindow50;
538
- maxHitTime += Math.min(hitWindow50, object.spanDuration);
515
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
516
+ return -1;
539
517
  }
540
518
  const hitTime = object.startTime + objectData.accuracy;
541
- let nearestCursorInstanceIndex = null;
542
- let nearestCursorIndex = null;
543
- let nearestTime = Number.POSITIVE_INFINITY;
544
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
545
- if (excludedCursorIndices.includes(i)) {
546
- continue;
547
- }
548
- const cursors = this.downCursorInstances[i];
549
- for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
550
- const cursor = cursors[j];
551
- if (cursor.time < minHitTime) {
519
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
520
+ // We are maintaining the closest distance to the object.
521
+ // This is because the radius that is calculated is using an estimation.
522
+ // As such, it does not reflect the actual object radius in gameplay.
523
+ let closestDistance = Number.POSITIVE_INFINITY;
524
+ let nearestCursorIndex = -1;
525
+ // Observe the cursor position at the object's hit time.
526
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
527
+ const cursorData = this.data.cursorMovement[i];
528
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
529
+ const cursorGroup = cursorData.occurrenceGroups[j];
530
+ if (cursorGroup.endTime < hitTime) {
531
+ // Reset cursor index pointer.
532
+ cursorIndices[i] = 1;
552
533
  continue;
553
534
  }
554
- if (cursor.time > maxHitTime) {
535
+ if (cursorGroup.startTime > hitTime) {
555
536
  break;
556
537
  }
557
- const deltaTime = Math.abs(hitTime - cursor.time);
558
- if (deltaTime > nearestTime) {
559
- break;
538
+ const cursors = cursorGroup.allOccurrences;
539
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
540
+ const cursor = cursors[k];
541
+ const prevCursor = cursors[k - 1];
542
+ // Cursor is past the object's hit time.
543
+ if (prevCursor.time > hitTime) {
544
+ break;
545
+ }
546
+ // Cursor is before the object's hit time.
547
+ if (hitTime > cursor.time) {
548
+ continue;
549
+ }
550
+ let distance;
551
+ const currentPosition = this.getCursorPosition(cursor);
552
+ const prevPosition = this.getCursorPosition(prevCursor);
553
+ switch (cursor.id) {
554
+ case exports.MovementType.up:
555
+ distance = prevPosition.getDistance(objectPosition);
556
+ break;
557
+ case exports.MovementType.move: {
558
+ // Interpolate movement.
559
+ const t = (hitTime - prevCursor.time) /
560
+ (cursor.time - prevCursor.time);
561
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
562
+ distance =
563
+ objectPosition.getDistance(cursorPosition);
564
+ break;
565
+ }
566
+ case exports.MovementType.down:
567
+ continue;
568
+ }
569
+ if (closestDistance > distance) {
570
+ closestDistance = distance;
571
+ nearestCursorIndex = i;
572
+ }
560
573
  }
561
- nearestCursorInstanceIndex = i;
562
- nearestCursorIndex = j;
563
- nearestTime = deltaTime;
574
+ // Reset cursor index pointer on end of group.
575
+ if (cursorIndices[i] === cursors.length) {
576
+ cursorIndices[i] = 1;
577
+ }
578
+ break;
564
579
  }
580
+ // The previous object may still be hit with the same cursor group or cursor index.
581
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
582
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
565
583
  }
566
- return {
567
- pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
568
- pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
569
- };
584
+ return nearestCursorIndex;
570
585
  }
571
586
  /**
572
- * Checks if a section is dragged and returns the index of the drag finger.
573
- *
574
- * If the section is not dragged, -1 will be returned.
587
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
575
588
  *
576
- * @param section The section to check.
589
+ * @param object The object to obtain the index for.
590
+ * @param objectData The hit data of the object.
591
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
592
+ * @param excludedIndices The cursor indices that should not be checked.
593
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
577
594
  */
578
- findDragIndex(section) {
579
- var _a;
580
- const objectData = this.data.hitObjectData;
581
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
582
- const firstObject = this.hitObjects[section.firstObjectIndex];
583
- const firstObjectData = objectData[section.firstObjectIndex];
584
- const lastObject = this.hitObjects[section.lastObjectIndex];
585
- const lastObjectData = objectData[section.lastObjectIndex];
586
- let firstObjectHitTime = firstObject.startTime;
587
- let lastObjectHitTime = lastObject.startTime;
588
- // Check for slider breaks.
589
- if (firstObject instanceof osuBase.Slider &&
590
- firstObjectData.accuracy === Math.floor(hitWindow50) + 13) {
591
- firstObjectHitTime -= hitWindow50;
592
- }
593
- else {
594
- firstObjectHitTime += firstObjectData.accuracy;
595
- }
596
- if (lastObject instanceof osuBase.Slider &&
597
- lastObjectData.accuracy === Math.floor(hitWindow50) + 13) {
598
- lastObjectHitTime += Math.min(hitWindow50, lastObject.nestedHitObjects[1].startTime - lastObject.startTime);
595
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
596
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
597
+ return -1;
599
598
  }
600
- else {
601
- lastObjectHitTime += lastObjectData.accuracy;
599
+ // Check for sliderbreaks and treat them as misses.
600
+ if (object instanceof osuBase.Slider &&
601
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
602
+ return -1;
602
603
  }
603
- // Since there may be more than 1 cursor instance index,
604
- // we check which cursor instance follows hitobjects all over.
605
- const cursorInstanceIndices = [];
606
- const cursorGroupIndices = [];
607
- const cursorIndices = [];
608
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
609
- const c = this.data.cursorMovement[i];
610
- if (c.occurrenceGroups.length === 0) {
611
- continue;
612
- }
613
- // Do not include cursors that don't have an occurence in this section
614
- // this speeds up checking process.
615
- if (c.occurrenceGroups.filter((v) => v.startTime <= firstObjectHitTime &&
616
- v.endTime >= lastObjectHitTime).length === 0) {
617
- continue;
618
- }
619
- // If this cursor instance doesn't move, it's not the cursor instance we want.
620
- if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
621
- 0) {
622
- continue;
623
- }
624
- cursorInstanceIndices.push(i);
625
- cursorGroupIndices.push(0);
626
- // Start checking from the second cursor.
627
- cursorIndices.push(1);
628
- }
629
- const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
630
- const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
631
- for (let i = 0; i < sectionObjects.length &&
632
- cursorInstanceIndices.some((v) => v !== -1); ++i) {
633
- const object = sectionObjects[i];
634
- const objectData = sectionReplayObjectData[i];
635
- if (object instanceof osuBase.Spinner ||
636
- objectData.result === exports.HitResult.miss) {
637
- continue;
638
- }
639
- // Exclude sliderbreaks.
640
- if (object instanceof osuBase.Slider &&
641
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
642
- continue;
643
- }
644
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
645
- const hitTime = object.startTime + objectData.accuracy;
646
- // Observe the cursor position at the object's hit time.
647
- for (let j = 0; j < cursorInstanceIndices.length; ++j) {
648
- if (cursorInstanceIndices[j] === -1) {
649
- continue;
650
- }
651
- const cursorData = this.data.cursorMovement[cursorInstanceIndices[j]];
652
- for (let k = cursorGroupIndices[j]; k < cursorData.occurrenceGroups.length; cursorGroupIndices[j] = ++k) {
653
- const cursorGroup = cursorData.occurrenceGroups[k];
654
- if (!cursorGroup.isActiveAt(hitTime)) {
655
- continue;
656
- }
657
- const cursors = cursorGroup.allOccurrences;
658
- // We are maintaining the closest distance of cursors to the object.
659
- // This is because the radius that is calculated is using an estimation. As such,
660
- // it does not perfectly reflect the actual object radius in gameplay.
661
- let closestDistance = this.maximumObjectRadiusThreshold;
662
- for (let l = cursorIndices[j]; l < cursors.length; cursorIndices[j] = ++l) {
663
- const cursor = cursors[l];
664
- const prevCursor = cursors[l - 1];
665
- // Cursor is past the object's hit time.
666
- if (prevCursor.time > hitTime) {
667
- break;
668
- }
669
- // Cursor is before the object's hit time.
670
- if (hitTime > cursor.time) {
671
- continue;
672
- }
673
- let distance;
674
- switch (cursor.id) {
675
- case exports.MovementType.up:
676
- distance =
677
- prevCursor.position.getDistance(objectPosition);
678
- break;
679
- case exports.MovementType.move: {
680
- // Interpolate movement.
681
- const t = (hitTime - prevCursor.time) /
682
- (cursor.time - prevCursor.time);
683
- const cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
684
- distance =
685
- objectPosition.getDistance(cursorPosition);
686
- break;
687
- }
688
- case exports.MovementType.down:
689
- continue;
690
- }
691
- closestDistance = Math.min(closestDistance, distance);
692
- }
693
- // At this point, the object is guaranteed to be hit, but the distance may not reach the threshold
694
- // due to radius estimation. In order to work around this, we store the farthest distance from all
695
- // processed cursors so far and use it as threshold rather than the actual object radius.
696
- if (closestDistance <= this.maximumObjectRadiusThreshold &&
697
- closestDistance > this.objectRadiusThreshold) {
698
- this.objectRadiusThreshold = closestDistance;
699
- }
700
- if (closestDistance > this.objectRadiusThreshold) {
701
- cursorInstanceIndices[j] = -1;
702
- }
604
+ const hitTime = object.startTime + objectData.accuracy;
605
+ let nearestCursorInstanceIndex = -1;
606
+ let nearestTime = Number.POSITIVE_INFINITY;
607
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
608
+ const cursors = this.downCursorInstances[i];
609
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
610
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
611
+ const cursor = cursors[j];
612
+ if (cursor.time > hitTime) {
703
613
  break;
704
614
  }
705
- // The previous object may still be hit with the same cursor group or cursor index.
706
- cursorGroupIndices[j] = Math.max(0, cursorGroupIndices[j] - 1);
707
- cursorIndices[j] = Math.max(1, cursorIndices[j] - 1);
615
+ cursorNearestTime = hitTime - cursor.time;
616
+ }
617
+ if (cursorNearestTime < nearestTime) {
618
+ nearestCursorInstanceIndex = i;
619
+ nearestTime = cursorNearestTime;
708
620
  }
709
621
  }
710
- return (_a = cursorInstanceIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
622
+ return nearestCursorInstanceIndex;
711
623
  }
712
624
  /**
713
625
  * Creates nerf factors by scanning through objects.
714
626
  */
715
627
  calculateNerfFactors() {
716
628
  for (const beatmapSection of this.beatmapSections) {
717
- const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
629
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
718
630
  for (const object of beatmapSection.objects) {
719
- if (object.pressingCursorIndex === -1 ||
720
- object.pressingCursorInstanceIndex ===
721
- beatmapSection.dragFingerIndex) {
631
+ if (object.pressingCursorInstanceIndex === -1) {
722
632
  continue;
723
633
  }
724
- ++cursorCounts[object.pressingCursorInstanceIndex];
725
- }
726
- if (beatmapSection.dragFingerIndex !== -1) {
727
- // Remove the drag index to prevent it from being picked up into the detection.
728
- cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
634
+ if (object.aimingCursorInstanceIndex < 3) {
635
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
636
+ switch (object.pressingCursorInstanceIndex) {
637
+ case 0:
638
+ case 1:
639
+ case 2:
640
+ break;
641
+ default:
642
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
643
+ break;
644
+ }
645
+ }
646
+ else {
647
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
648
+ switch (object.pressingCursorInstanceIndex) {
649
+ case 0:
650
+ case 1:
651
+ break;
652
+ default:
653
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
654
+ break;
655
+ }
656
+ }
729
657
  }
730
- // This index will be used to detect if a section is 3-fingered.
731
- // If the section is dragged, the dragged instance will be ignored,
732
- // hence why the index is 1 less than nondragged section.
733
- const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
734
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
735
- // to check if the section is 3-fingered.
736
- const threeFingerRatio = cursorCounts
737
- .slice(fingerSplitIndex)
738
- .reduce((acc, value) => acc + value, 0) /
739
- Math.max(cursorCounts
740
- .slice(0, fingerSplitIndex)
741
- .reduce((acc, value) => acc + value, 0), 1);
742
- if (threeFingerRatio > this.threeFingerRatioThreshold) {
743
- const threeFingeredObjectCount = cursorCounts
744
- .slice(fingerSplitIndex)
745
- .reduce((acc, value) => acc + value, 0);
746
- const sectionObjectCount = beatmapSection.lastObjectIndex -
747
- beatmapSection.firstObjectIndex +
748
- 1;
749
- const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
750
- // We can ignore the first 3 (2 for drag) filled cursor instances
751
- // since they are guaranteed not 3 finger.
752
- const threeFingerCursorCounts = cursorCounts
753
- .slice(fingerSplitIndex)
754
- .filter((amount) => amount > 0);
755
- // Finger factor applies more penalty if more fingers were used.
756
- const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
757
- Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
758
- // Length factor applies more penalty if there are more 3-fingered object.
759
- const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
760
- this.nerfFactors.push({
761
- strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
762
- fingerFactor: fingerFactor,
763
- lengthFactor: lengthFactor,
764
- });
658
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
659
+ if (threeFingerCursorCount === 0) {
660
+ continue;
765
661
  }
662
+ const sectionObjectCount = beatmapSection.objects.length;
663
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
664
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
665
+ // Finger factor applies more penalty if more fingers were used.
666
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
667
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
668
+ // Length factor applies more penalty if there are more 3-fingered object.
669
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
670
+ this.nerfFactors.push({
671
+ strainFactor: strainFactor,
672
+ fingerFactor: fingerFactor,
673
+ lengthFactor: lengthFactor,
674
+ });
766
675
  }
767
676
  }
768
677
  /**
@@ -773,6 +682,12 @@ class RebalanceThreeFingerChecker {
773
682
  0.015 *
774
683
  Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
775
684
  }
685
+ getCursorPosition(cursor) {
686
+ if (this.isHardRock) {
687
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
688
+ }
689
+ return cursor.position;
690
+ }
776
691
  }
777
692
 
778
693
  /******************************************************************************
@@ -808,9 +723,9 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
808
723
  };
809
724
 
810
725
  /**
811
- * Represents a replay data in an osu!droid replay.
726
+ * Represents a replay data in an osu!droid replay version 1 and 2.
812
727
  *
813
- * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
728
+ * Stores generic information about an osu!droid replay.
814
729
  *
815
730
  * This is used when analyzing replays using replay analyzer.
816
731
  */
@@ -820,45 +735,46 @@ class ReplayData {
820
735
  this.folderName = values.folderName;
821
736
  this.fileName = values.fileName;
822
737
  this.hash = values.hash;
823
- this.time = new Date(values.time || 0);
824
- this.hit300k = values.hit300k || 0;
825
- this.hit100k = values.hit100k || 0;
826
- this.score = values.score || 0;
827
- this.maxCombo = values.maxCombo || 0;
828
- this.accuracy = values.accuracy || new osuBase.Accuracy({});
829
- this.isFullCombo = values.isFullCombo || false;
830
- this.playerName = values.playerName || "";
831
- this.rawMods = values.rawMods || "";
832
- this.rank = values.rank || "";
833
- this.convertedMods = values.convertedMods || [];
738
+ this.accuracy = values.accuracy;
739
+ this.rank = values.rank;
740
+ this.hit300k = values.hit300k;
741
+ this.hit100k = values.hit100k;
834
742
  this.cursorMovement = values.cursorMovement;
835
743
  this.hitObjectData = values.hitObjectData;
836
- this.speedMultiplier = values.speedMultiplier || 1;
837
- this.forceCS = values.forceCS;
838
- this.forceAR = values.forceAR;
839
- this.forceOD = values.forceOD;
840
- this.forceHP = values.forceHP;
841
- this.flashlightFollowDelay = values.flashlightFollowDelay;
744
+ }
745
+ /**
746
+ * Whether the replay's version is 3 or later.
747
+ */
748
+ isReplayV3() {
749
+ return this.replayVersion >= 3;
750
+ }
751
+ /**
752
+ * Whether the replay's version is 4 or later.
753
+ */
754
+ isReplayV4() {
755
+ return this.replayVersion >= 4;
756
+ }
757
+ /**
758
+ * Whether the replay's version is 5 or later.
759
+ */
760
+ isReplayV5() {
761
+ return this.replayVersion >= 5;
842
762
  }
843
763
  }
844
764
 
845
765
  /**
846
- * Utility to check whether or not a beatmap is three-fingered.
766
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
847
767
  */
848
768
  class ThreeFingerChecker {
769
+ get hitWindow50() {
770
+ return this.hitWindow.hitWindowFor50(this.isPrecise);
771
+ }
849
772
  /**
850
773
  * @param beatmap The beatmap to analyze.
851
774
  * @param data The data of the replay.
852
775
  * @param difficultyAttributes The difficulty attributes of the beatmap.
853
776
  */
854
777
  constructor(beatmap, data, difficultyAttributes) {
855
- /**
856
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
857
- *
858
- * Increasing this number will increase detection accuracy, however
859
- * it also increases the chance of falsely flagged plays.
860
- */
861
- this.threeFingerRatioThreshold = 0.01;
862
778
  /**
863
779
  * Extended sections of the beatmap for drag detection.
864
780
  */
@@ -885,12 +801,12 @@ class ThreeFingerChecker {
885
801
  this.difficultyAttributes = difficultyAttributes;
886
802
  const od = osuBase.calculateDroidDifficultyStatistics({
887
803
  overallDifficulty: beatmap.difficulty.od,
888
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
804
+ mods: osuBase.ModUtil.removeSpeedChangingMods(difficultyAttributes.mods),
889
805
  convertOverallDifficulty: false,
890
806
  }).overallDifficulty;
891
- this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
807
+ this.isPrecise = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
808
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
892
809
  this.hitWindow = new osuBase.DroidHitWindow(od);
893
- this.hitObjects = beatmap.hitObjects.objects;
894
810
  }
895
811
  /**
896
812
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -911,7 +827,8 @@ class ThreeFingerChecker {
911
827
  * nerf factor, taking beatmap difficulty into account.
912
828
  */
913
829
  check() {
914
- if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
830
+ if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
831
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
915
832
  return { is3Finger: false, penalty: 1 };
916
833
  }
917
834
  this.getAccurateBreakPoints();
@@ -931,10 +848,11 @@ class ThreeFingerChecker {
931
848
  * end of the hitobject before it and do not end right at the first hitobject after it.
932
849
  */
933
850
  getAccurateBreakPoints() {
851
+ const objects = this.beatmap.hitObjects.objects;
934
852
  const objectData = this.data.hitObjectData;
935
853
  for (const breakPoint of this.beatmap.events.breaks) {
936
- const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
937
- const objectBefore = this.hitObjects[beforeIndex];
854
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
855
+ const objectBefore = objects[beforeIndex];
938
856
  const objectBeforeData = objectData[beforeIndex];
939
857
  let timeBefore = objectBefore.endTime;
940
858
  if (objectBefore instanceof osuBase.Circle) {
@@ -946,9 +864,9 @@ class ThreeFingerChecker {
946
864
  }
947
865
  }
948
866
  const afterIndex = beforeIndex + 1;
949
- const objectAfter = this.hitObjects[afterIndex];
867
+ const objectAfter = objects[afterIndex];
950
868
  const objectAfterData = objectData[afterIndex];
951
- let timeAfter = this.hitObjects[afterIndex].startTime;
869
+ let timeAfter = objectAfter.startTime;
952
870
  if (objectAfter instanceof osuBase.Circle &&
953
871
  objectAfterData.result !== exports.HitResult.miss) {
954
872
  timeAfter += objectAfterData.accuracy;
@@ -965,11 +883,12 @@ class ThreeFingerChecker {
965
883
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
966
884
  */
967
885
  filterCursorInstances() {
886
+ const objects = this.beatmap.hitObjects.objects;
968
887
  const objectData = this.data.hitObjectData;
969
888
  const firstObjectResult = objectData[0].result;
970
889
  const lastObjectResult = objectData.at(-1).result;
971
- const firstObject = this.hitObjects[0];
972
- const lastObject = this.hitObjects.at(-1);
890
+ const firstObject = objects[0];
891
+ const lastObject = objects.at(-1);
973
892
  // For sliders, automatically set hit window length to be as lenient as possible.
974
893
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
975
894
  if (firstObject instanceof osuBase.Circle) {
@@ -1029,280 +948,217 @@ class ThreeFingerChecker {
1029
948
  * detect dragged sections and improve detection speed.
1030
949
  */
1031
950
  getBeatmapSections() {
1032
- const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
951
+ const beatmapObjects = this.beatmap.hitObjects.objects;
952
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
953
+ // This intentionally starts from 1 because we need to look at the previous cursor.
954
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
955
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1033
956
  for (const section of this.difficultyAttributes
1034
957
  .possibleThreeFingeredSections) {
1035
- const dragFingerIndex = this.findDragIndex(section);
1036
958
  const objects = [];
1037
959
  for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
1038
- objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
960
+ const object = beatmapObjects[i];
961
+ const objectData = this.data.hitObjectData[i];
962
+ objects.push({
963
+ object: object,
964
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
965
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
966
+ });
1039
967
  }
1040
- this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
968
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
1041
969
  }
1042
970
  }
1043
971
  /**
1044
- * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
972
+ * Obtains the index of the cursor that aimed the object at the nearest time.
1045
973
  *
1046
974
  * @param object The object to obtain the index for.
1047
975
  * @param objectData The hit data of the object.
1048
- * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1049
- * @param excludedIndices The cursor indices that should not be checked.
976
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
977
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
978
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
1050
979
  * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1051
980
  */
1052
- getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
981
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
1053
982
  if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1054
- return {
1055
- pressingCursorInstanceIndex: -1,
1056
- pressingCursorIndex: -1,
1057
- };
983
+ return -1;
1058
984
  }
1059
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
1060
985
  // Check for sliderbreaks and treat them as misses.
1061
986
  if (object instanceof osuBase.Slider &&
1062
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
1063
- return {
1064
- pressingCursorInstanceIndex: -1,
1065
- pressingCursorIndex: -1,
1066
- };
987
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
988
+ return -1;
1067
989
  }
1068
- // We are not directly using hit time to determine which cursor pressed the object
1069
- // to account for time difference between hit registration and object judgement.
1070
- let minHitTime = object.startTime;
1071
- let maxHitTime = object.startTime;
1072
- if (object instanceof osuBase.Circle) {
1073
- let hitWindowGap = hitWindow50;
1074
- switch (objectData.result) {
1075
- case exports.HitResult.great:
1076
- hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
1077
- break;
1078
- case exports.HitResult.good:
1079
- hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
990
+ const hitTime = object.startTime + objectData.accuracy;
991
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
992
+ // We are maintaining the closest distance to the object.
993
+ // This is because the radius that is calculated is using an estimation.
994
+ // As such, it does not reflect the actual object radius in gameplay.
995
+ let closestDistance = Number.POSITIVE_INFINITY;
996
+ let nearestCursorIndex = -1;
997
+ // Observe the cursor position at the object's hit time.
998
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
999
+ const cursorData = this.data.cursorMovement[i];
1000
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
1001
+ const cursorGroup = cursorData.occurrenceGroups[j];
1002
+ if (cursorGroup.endTime < hitTime) {
1003
+ // Reset cursor index pointer.
1004
+ cursorIndices[i] = 1;
1005
+ continue;
1006
+ }
1007
+ if (cursorGroup.startTime > hitTime) {
1080
1008
  break;
1009
+ }
1010
+ const cursors = cursorGroup.allOccurrences;
1011
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
1012
+ const cursor = cursors[k];
1013
+ const prevCursor = cursors[k - 1];
1014
+ // Cursor is past the object's hit time.
1015
+ if (prevCursor.time > hitTime) {
1016
+ break;
1017
+ }
1018
+ // Cursor is before the object's hit time.
1019
+ if (hitTime > cursor.time) {
1020
+ continue;
1021
+ }
1022
+ let distance;
1023
+ const currentPosition = this.getCursorPosition(cursor);
1024
+ const prevPosition = this.getCursorPosition(prevCursor);
1025
+ switch (cursor.id) {
1026
+ case exports.MovementType.up:
1027
+ distance = prevPosition.getDistance(objectPosition);
1028
+ break;
1029
+ case exports.MovementType.move: {
1030
+ // Interpolate movement.
1031
+ const t = (hitTime - prevCursor.time) /
1032
+ (cursor.time - prevCursor.time);
1033
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1034
+ distance =
1035
+ objectPosition.getDistance(cursorPosition);
1036
+ break;
1037
+ }
1038
+ case exports.MovementType.down:
1039
+ continue;
1040
+ }
1041
+ if (closestDistance > distance) {
1042
+ closestDistance = distance;
1043
+ nearestCursorIndex = i;
1044
+ }
1045
+ }
1046
+ // Reset cursor index pointer on end of group.
1047
+ if (cursorIndices[i] === cursors.length) {
1048
+ cursorIndices[i] = 1;
1049
+ }
1050
+ break;
1081
1051
  }
1082
- minHitTime -= hitWindowGap;
1083
- maxHitTime += hitWindowGap;
1052
+ // The previous object may still be hit with the same cursor group or cursor index.
1053
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
1054
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
1055
+ }
1056
+ return nearestCursorIndex;
1057
+ }
1058
+ /**
1059
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
1060
+ *
1061
+ * @param object The object to obtain the index for.
1062
+ * @param objectData The hit data of the object.
1063
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1064
+ * @param excludedIndices The cursor indices that should not be checked.
1065
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1066
+ */
1067
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
1068
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1069
+ return -1;
1084
1070
  }
1085
- else if (object instanceof osuBase.Slider) {
1086
- minHitTime -= hitWindow50;
1087
- maxHitTime += Math.min(hitWindow50, object.spanDuration);
1071
+ // Check for sliderbreaks and treat them as misses.
1072
+ if (object instanceof osuBase.Slider &&
1073
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1074
+ return -1;
1088
1075
  }
1089
1076
  const hitTime = object.startTime + objectData.accuracy;
1090
- let nearestCursorInstanceIndex = null;
1091
- let nearestCursorIndex = null;
1077
+ let nearestCursorInstanceIndex = -1;
1092
1078
  let nearestTime = Number.POSITIVE_INFINITY;
1093
1079
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
1094
- if (excludedCursorIndices.includes(i)) {
1095
- continue;
1096
- }
1097
1080
  const cursors = this.downCursorInstances[i];
1081
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
1098
1082
  for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
1099
1083
  const cursor = cursors[j];
1100
- if (cursor.time < minHitTime) {
1101
- continue;
1102
- }
1103
- if (cursor.time > maxHitTime) {
1104
- break;
1105
- }
1106
- const deltaTime = Math.abs(hitTime - cursor.time);
1107
- if (deltaTime > nearestTime) {
1084
+ if (cursor.time > hitTime) {
1108
1085
  break;
1109
1086
  }
1087
+ cursorNearestTime = hitTime - cursor.time;
1088
+ }
1089
+ if (cursorNearestTime < nearestTime) {
1110
1090
  nearestCursorInstanceIndex = i;
1111
- nearestCursorIndex = j;
1112
- nearestTime = deltaTime;
1091
+ nearestTime = cursorNearestTime;
1113
1092
  }
1114
1093
  }
1115
- return {
1116
- pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
1117
- pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
1118
- };
1094
+ return nearestCursorInstanceIndex;
1119
1095
  }
1120
1096
  /**
1121
- * Checks if a section is dragged and returns the index of the drag finger.
1122
- *
1123
- * If the section is not dragged, -1 will be returned.
1124
- *
1125
- * @param section The section to check.
1097
+ * Creates nerf factors by scanning through objects.
1126
1098
  */
1127
- findDragIndex(section) {
1128
- var _a;
1129
- const objectData = this.data.hitObjectData;
1130
- const firstObject = this.hitObjects[section.firstObjectIndex];
1131
- const lastObject = this.hitObjects[section.lastObjectIndex];
1132
- let firstObjectMinHitTime = firstObject.startTime;
1133
- if (firstObject instanceof osuBase.Circle) {
1134
- switch (objectData[section.firstObjectIndex].result) {
1135
- case exports.HitResult.great:
1136
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
1137
- break;
1138
- case exports.HitResult.good:
1139
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
1140
- break;
1141
- default:
1142
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
1099
+ calculateNerfFactors() {
1100
+ for (const beatmapSection of this.beatmapSections) {
1101
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
1102
+ for (const object of beatmapSection.objects) {
1103
+ if (object.pressingCursorInstanceIndex === -1) {
1104
+ continue;
1105
+ }
1106
+ if (object.aimingCursorInstanceIndex < 3) {
1107
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
1108
+ switch (object.pressingCursorInstanceIndex) {
1109
+ case 0:
1110
+ case 1:
1111
+ case 2:
1112
+ break;
1113
+ default:
1114
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
1115
+ break;
1116
+ }
1117
+ }
1118
+ else {
1119
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
1120
+ switch (object.pressingCursorInstanceIndex) {
1121
+ case 0:
1122
+ case 1:
1123
+ break;
1124
+ default:
1125
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
1126
+ break;
1127
+ }
1128
+ }
1143
1129
  }
1144
- }
1145
- else if (firstObject instanceof osuBase.Slider) {
1146
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
1147
- }
1148
- let lastObjectMaxHitTime = lastObject.startTime;
1149
- if (lastObject instanceof osuBase.Circle) {
1150
- switch (objectData[section.lastObjectIndex].result) {
1151
- case exports.HitResult.great:
1152
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
1153
- break;
1154
- case exports.HitResult.good:
1155
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
1156
- break;
1157
- default:
1158
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
1130
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
1131
+ if (threeFingerCursorCount === 0) {
1132
+ continue;
1159
1133
  }
1134
+ const sectionObjectCount = beatmapSection.objects.length;
1135
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
1136
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
1137
+ // Finger factor applies more penalty if more fingers were used.
1138
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1139
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
1140
+ // Length factor applies more penalty if there are more 3-fingered object.
1141
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
1142
+ this.nerfFactors.push({
1143
+ strainFactor: strainFactor,
1144
+ fingerFactor: fingerFactor,
1145
+ lengthFactor: lengthFactor,
1146
+ });
1160
1147
  }
1161
- else if (lastObject instanceof osuBase.Slider) {
1162
- lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
1163
- }
1164
- // Since there may be more than 1 cursor instance index,
1165
- // we check which cursor instance follows hitobjects all over.
1166
- const cursorIndices = [];
1167
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1168
- const c = this.data.cursorMovement[i];
1169
- if (c.occurrenceGroups.length === 0) {
1170
- continue;
1171
- }
1172
- // Do not include cursors that don't have an occurence in this section
1173
- // this speeds up checking process.
1174
- if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
1175
- v.endTime <= lastObjectMaxHitTime).length === 0) {
1176
- continue;
1177
- }
1178
- // If this cursor instance doesn't move, it's not the cursor instance we want.
1179
- if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
1180
- 0) {
1181
- continue;
1182
- }
1183
- cursorIndices.push(i);
1184
- }
1185
- const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
1186
- const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
1187
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
1188
- for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
1189
- const object = sectionObjects[i];
1190
- const objectData = sectionReplayObjectData[i];
1191
- if (object instanceof osuBase.Spinner ||
1192
- objectData.result === exports.HitResult.miss) {
1193
- continue;
1194
- }
1195
- // Exclude sliderbreaks.
1196
- if (object instanceof osuBase.Slider &&
1197
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
1198
- continue;
1199
- }
1200
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
1201
- const hitTime = object.startTime + objectData.accuracy;
1202
- // Observe the cursor position at the object's hit time.
1203
- for (let j = 0; j < cursorIndices.length; ++j) {
1204
- if (cursorIndices[j] === -1) {
1205
- continue;
1206
- }
1207
- const cursorData = this.data.cursorMovement[cursorIndices[j]];
1208
- const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
1209
- if (!cursorGroup) {
1210
- continue;
1211
- }
1212
- const cursors = cursorGroup.allOccurrences;
1213
- let isInObject = false;
1214
- for (let k = 1; k < cursors.length; ++k) {
1215
- const cursor = cursors[k];
1216
- const prevCursor = cursors[k - 1];
1217
- // Only consider cursor at interval prev.time <= hitTime <= current.time.
1218
- if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
1219
- switch (cursor.id) {
1220
- case exports.MovementType.up:
1221
- isInObject =
1222
- prevCursor.position.getDistance(objectPosition) <= object.radius;
1223
- break;
1224
- case exports.MovementType.move: {
1225
- // Interpolate movement.
1226
- const t = (hitTime - prevCursor.time) /
1227
- (cursor.time - prevCursor.time);
1228
- 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));
1229
- isInObject =
1230
- objectPosition.getDistance(cursorPosition) <= object.radius;
1231
- }
1232
- }
1233
- break;
1234
- }
1235
- }
1236
- if (!isInObject) {
1237
- cursorIndices[j] = -1;
1238
- }
1239
- }
1240
- }
1241
- return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
1242
- }
1243
- /**
1244
- * Creates nerf factors by scanning through objects.
1245
- */
1246
- calculateNerfFactors() {
1247
- for (const beatmapSection of this.beatmapSections) {
1248
- const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1249
- for (const object of beatmapSection.objects) {
1250
- if (object.pressingCursorIndex === -1 ||
1251
- object.pressingCursorInstanceIndex ===
1252
- beatmapSection.dragFingerIndex) {
1253
- continue;
1254
- }
1255
- ++cursorCounts[object.pressingCursorInstanceIndex];
1256
- }
1257
- if (beatmapSection.dragFingerIndex !== -1) {
1258
- // Remove the drag index to prevent it from being picked up into the detection.
1259
- cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
1260
- }
1261
- // This index will be used to detect if a section is 3-fingered.
1262
- // If the section is dragged, the dragged instance will be ignored,
1263
- // hence why the index is 1 less than nondragged section.
1264
- const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
1265
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
1266
- // to check if the section is 3-fingered.
1267
- const threeFingerRatio = cursorCounts
1268
- .slice(fingerSplitIndex)
1269
- .reduce((acc, value) => acc + value, 0) /
1270
- Math.max(cursorCounts
1271
- .slice(0, fingerSplitIndex)
1272
- .reduce((acc, value) => acc + value, 0), 1);
1273
- if (threeFingerRatio > this.threeFingerRatioThreshold) {
1274
- const threeFingeredObjectCount = cursorCounts
1275
- .slice(fingerSplitIndex)
1276
- .reduce((acc, value) => acc + value, 0);
1277
- const sectionObjectCount = beatmapSection.lastObjectIndex -
1278
- beatmapSection.firstObjectIndex +
1279
- 1;
1280
- const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
1281
- // We can ignore the first 3 (2 for drag) filled cursor instances
1282
- // since they are guaranteed not 3 finger.
1283
- const threeFingerCursorCounts = cursorCounts
1284
- .slice(fingerSplitIndex)
1285
- .filter((amount) => amount > 0);
1286
- // Finger factor applies more penalty if more fingers were used.
1287
- const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1288
- Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
1289
- // Length factor applies more penalty if there are more 3-fingered object.
1290
- const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
1291
- this.nerfFactors.push({
1292
- strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
1293
- fingerFactor: fingerFactor,
1294
- lengthFactor: lengthFactor,
1295
- });
1296
- }
1148
+ }
1149
+ /**
1150
+ * Calculates the final penalty.
1151
+ */
1152
+ calculateFinalPenalty() {
1153
+ return this.nerfFactors.reduce((a, n) => a +
1154
+ 0.015 *
1155
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1156
+ }
1157
+ getCursorPosition(cursor) {
1158
+ if (this.isHardRock) {
1159
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1297
1160
  }
1298
- }
1299
- /**
1300
- * Calculates the final penalty.
1301
- */
1302
- calculateFinalPenalty() {
1303
- return this.nerfFactors.reduce((a, n) => a +
1304
- 0.015 *
1305
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1161
+ return cursor.position;
1306
1162
  }
1307
1163
  }
1308
1164
 
@@ -1351,11 +1207,12 @@ class TwoHandChecker {
1351
1207
  this.data = data;
1352
1208
  const od = osuBase.calculateDroidDifficultyStatistics({
1353
1209
  overallDifficulty: calculator.beatmap.difficulty.od,
1354
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1210
+ mods: osuBase.ModUtil.removeSpeedChangingMods(calculator.mods),
1355
1211
  convertOverallDifficulty: false,
1356
1212
  }).overallDifficulty;
1357
1213
  this.hitWindow = new osuBase.DroidHitWindow(od);
1358
1214
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1215
+ this.isHardRock = calculator.mods.some((m) => m instanceof osuBase.ModHardRock);
1359
1216
  // this.csvString = `Mods,${
1360
1217
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
1361
1218
  // }\nCombo,${data.maxCombo}\nAccuracy,"${(
@@ -1576,13 +1433,15 @@ class TwoHandChecker {
1576
1433
  for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
1577
1434
  const cursor = cursors[i];
1578
1435
  const prevCursor = cursors[i - 1];
1579
- if (cursor.position.equals(prevCursor.position)) {
1436
+ const currentPosition = this.getCursorPosition(cursor);
1437
+ const prevPosition = this.getCursorPosition(prevCursor);
1438
+ if (currentPosition.equals(prevPosition)) {
1580
1439
  continue;
1581
1440
  }
1582
1441
  if (cursor.id === exports.MovementType.up) {
1583
1442
  break;
1584
1443
  }
1585
- const currentMovement = cursor.position.subtract(prevCursor.position);
1444
+ const currentMovement = currentPosition.subtract(prevPosition);
1586
1445
  const dot = prevToCurrentCursorMovement.dot(currentMovement);
1587
1446
  const det = prevToCurrentCursorMovement.x * currentMovement.y -
1588
1447
  prevToCurrentCursorMovement.y * currentMovement.x;
@@ -1668,7 +1527,7 @@ class TwoHandChecker {
1668
1527
  };
1669
1528
  }
1670
1529
  let hitWindow = this.hitWindow50;
1671
- const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1530
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
1672
1531
  // For sliders, set the hit window to as lenient as possible.
1673
1532
  if (object instanceof osuBase.Circle) {
1674
1533
  switch (data.result) {
@@ -1706,14 +1565,14 @@ class TwoHandChecker {
1706
1565
  }
1707
1566
  // Validate the down press first.
1708
1567
  const { down } = cursorGroup;
1709
- if (down.position.getDistance(objectPosition) <=
1710
- object.radius &&
1568
+ const downPosition = this.getCursorPosition(down);
1569
+ if (downPosition.getDistance(objectPosition) <= object.radius &&
1711
1570
  Math.abs(down.time - object.startTime) <= hitWindow) {
1712
1571
  if (objectIndex > 0) {
1713
1572
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1714
1573
  if (down.time > prevObject.endTime) {
1715
1574
  return {
1716
- position: down.position,
1575
+ position: downPosition,
1717
1576
  cursorIndex: i,
1718
1577
  groupIndex: j,
1719
1578
  occurrenceIndex: 0,
@@ -1723,7 +1582,7 @@ class TwoHandChecker {
1723
1582
  }
1724
1583
  else {
1725
1584
  return {
1726
- position: down.position,
1585
+ position: downPosition,
1727
1586
  cursorIndex: i,
1728
1587
  groupIndex: j,
1729
1588
  occurrenceIndex: 0,
@@ -1743,7 +1602,7 @@ class TwoHandChecker {
1743
1602
  }
1744
1603
  let cursorPosition;
1745
1604
  if (cursor.id === exports.MovementType.up) {
1746
- cursorPosition = prevCursor.position;
1605
+ cursorPosition = this.getCursorPosition(prevCursor);
1747
1606
  const distance = cursorPosition.getDistance(objectPosition);
1748
1607
  if (distance <
1749
1608
  nearestPosition.getDistance(objectPosition)) {
@@ -1768,7 +1627,7 @@ class TwoHandChecker {
1768
1627
  }
1769
1628
  const t = (cursorGroup.down.time - prevCursor.time) /
1770
1629
  (cursor.time - prevCursor.time);
1771
- cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
1630
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1772
1631
  const distance = cursorPosition.getDistance(objectPosition);
1773
1632
  if (distance <
1774
1633
  nearestPosition.getDistance(objectPosition)) {
@@ -1853,18 +1712,18 @@ class TwoHandChecker {
1853
1712
  let cursorPosition;
1854
1713
  switch (cursor.id) {
1855
1714
  case exports.MovementType.down:
1856
- cursorPosition = cursor.position;
1715
+ cursorPosition = this.getCursorPosition(cursor);
1857
1716
  break;
1858
1717
  case exports.MovementType.up: {
1859
1718
  const prevCursor = cursors[k - 1];
1860
- cursorPosition = prevCursor.position;
1719
+ cursorPosition = this.getCursorPosition(prevCursor);
1861
1720
  break;
1862
1721
  }
1863
1722
  case exports.MovementType.move: {
1864
1723
  const prevCursor = cursors[k - 1];
1865
1724
  const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
1866
1725
  (cursor.time - prevCursor.time), 0, 1);
1867
- cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
1726
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1868
1727
  break;
1869
1728
  }
1870
1729
  }
@@ -1926,6 +1785,12 @@ class TwoHandChecker {
1926
1785
  }
1927
1786
  return false;
1928
1787
  }
1788
+ getCursorPosition(cursor) {
1789
+ if (this.isHardRock) {
1790
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1791
+ }
1792
+ return cursor.position;
1793
+ }
1929
1794
  }
1930
1795
 
1931
1796
  /**
@@ -1943,10 +1808,11 @@ class SliderCheeseChecker {
1943
1808
  this.difficultyAttributes = difficultyAttributes;
1944
1809
  const od = osuBase.calculateDroidDifficultyStatistics({
1945
1810
  overallDifficulty: beatmap.difficulty.od,
1946
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1811
+ mods: osuBase.ModUtil.removeSpeedChangingMods(difficultyAttributes.mods),
1947
1812
  convertOverallDifficulty: false,
1948
1813
  }).overallDifficulty;
1949
- this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1814
+ this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1815
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
1950
1816
  }
1951
1817
  /**
1952
1818
  * Checks if relevant sliders in the given beatmap was cheesed.
@@ -1972,7 +1838,7 @@ class SliderCheeseChecker {
1972
1838
  const { objects } = this.beatmap.hitObjects;
1973
1839
  const cheesedDifficultyRatings = [];
1974
1840
  // Current loop indices are stored for efficiency.
1975
- const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1841
+ const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1976
1842
  const acceptableRadius = objects[0].radius * 2;
1977
1843
  // Sort difficult sliders by index so that cursor loop indices work properly.
1978
1844
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
@@ -2010,7 +1876,8 @@ class SliderCheeseChecker {
2010
1876
  break;
2011
1877
  }
2012
1878
  if (group.startTime >= minTimeLimit) {
2013
- const distance = group.down.position.getDistance(objectStartPosition);
1879
+ const position = this.getCursorPosition(group.down);
1880
+ const distance = position.getDistance(objectStartPosition);
2014
1881
  if (closestDistance > distance) {
2015
1882
  closestDistance = distance;
2016
1883
  closestIndex = j;
@@ -2024,20 +1891,22 @@ class SliderCheeseChecker {
2024
1891
  // for the time being we only check for cursor distances across the group.
2025
1892
  const { allOccurrences } = group;
2026
1893
  for (let k = 1; k < allOccurrences.length; ++k) {
2027
- const occurrence = allOccurrences[k];
2028
- const prevOccurrence = allOccurrences[k - 1];
1894
+ const cursor = allOccurrences[k];
1895
+ const prevCursor = allOccurrences[k - 1];
2029
1896
  let distance = Number.POSITIVE_INFINITY;
2030
- switch (occurrence.id) {
1897
+ const currentPosition = this.getCursorPosition(cursor);
1898
+ const prevPosition = this.getCursorPosition(prevCursor);
1899
+ switch (cursor.id) {
2031
1900
  case exports.MovementType.up:
2032
1901
  distance =
2033
- prevOccurrence.position.getDistance(objectStartPosition);
1902
+ prevPosition.getDistance(objectStartPosition);
2034
1903
  break;
2035
1904
  case exports.MovementType.move:
2036
- for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
2037
- Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
2038
- const t = (mSecPassed - prevOccurrence.time) /
2039
- (occurrence.time - prevOccurrence.time);
2040
- 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));
1905
+ for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
1906
+ Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
1907
+ const t = (mSecPassed - prevCursor.time) /
1908
+ (cursor.time - prevCursor.time);
1909
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
2041
1910
  distance =
2042
1911
  cursorPosition.getDistance(objectStartPosition);
2043
1912
  if (closestDistance > distance) {
@@ -2095,21 +1964,24 @@ class SliderCheeseChecker {
2095
1964
  if (occurrenceLoopIndex === allOccurrences.length) {
2096
1965
  continue;
2097
1966
  }
2098
- const occurrence = allOccurrences[occurrenceLoopIndex];
2099
- const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
2100
- switch (occurrence.id) {
1967
+ const cursor = allOccurrences[occurrenceLoopIndex];
1968
+ const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
1969
+ const currentPosition = this.getCursorPosition(cursor);
1970
+ const prevPosition = this.getCursorPosition(prevCursor);
1971
+ switch (cursor.id) {
2101
1972
  case exports.MovementType.move: {
2102
1973
  // Interpolate cursor position during nested object time.
2103
- const t = (nestedObject.startTime - prevOccurrence.time) /
2104
- (occurrence.time - prevOccurrence.time);
2105
- 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));
1974
+ const t = (nestedObject.startTime - prevCursor.time) /
1975
+ (cursor.time - prevCursor.time);
1976
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
2106
1977
  const distance = cursorPosition.getDistance(nestedPosition);
2107
1978
  isCheesed = distance > acceptableRadius;
2108
1979
  break;
2109
1980
  }
2110
1981
  case exports.MovementType.up:
2111
1982
  isCheesed =
2112
- prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
1983
+ prevPosition.getDistance(nestedPosition) >
1984
+ acceptableRadius;
2113
1985
  }
2114
1986
  }
2115
1987
  if (isCheesed) {
@@ -2135,6 +2007,65 @@ class SliderCheeseChecker {
2135
2007
  this.difficultyAttributes.visualSliderFactor, 2)),
2136
2008
  };
2137
2009
  }
2010
+ getCursorPosition(cursor) {
2011
+ if (this.isHardRock) {
2012
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
2013
+ }
2014
+ return cursor.position;
2015
+ }
2016
+ }
2017
+
2018
+ /**
2019
+ * Represents a replay data for replay version 3.
2020
+ *
2021
+ * Stores generic information about an osu!droid replay.
2022
+ *
2023
+ * This is used when analyzing replays using replay analyzer.
2024
+ */
2025
+ class ReplayV3Data extends ReplayData {
2026
+ constructor(values) {
2027
+ super(values);
2028
+ this.time = values.time;
2029
+ this.score = values.score;
2030
+ this.maxCombo = values.maxCombo;
2031
+ this.isFullCombo = values.isFullCombo;
2032
+ this.playerName = values.playerName;
2033
+ this.rawMods = values.rawMods;
2034
+ this.convertedMods = values.convertedMods;
2035
+ }
2036
+ }
2037
+
2038
+ /**
2039
+ * Represents a replay data for replay version 4.
2040
+ *
2041
+ * Stores generic information about an osu!droid replay.
2042
+ *
2043
+ * This is used when analyzing replays using replay analyzer.
2044
+ */
2045
+ class ReplayV4Data extends ReplayV3Data {
2046
+ constructor(values) {
2047
+ var _a, _b;
2048
+ super(values);
2049
+ this.forceAR = values.forceAR;
2050
+ this.speedMultiplier = (_a = values.speedMultiplier) !== null && _a !== void 0 ? _a : 1;
2051
+ this.flashlightFollowDelay = (_b = values.flashlightFollowDelay) !== null && _b !== void 0 ? _b : 0.12;
2052
+ }
2053
+ }
2054
+
2055
+ /**
2056
+ * Represents a replay data for replay version 5.
2057
+ *
2058
+ * Stores generic information about an osu!droid replay.
2059
+ *
2060
+ * This is used when analyzing replays using replay analyzer.
2061
+ */
2062
+ class ReplayV5Data extends ReplayV4Data {
2063
+ constructor(values) {
2064
+ super(values);
2065
+ this.forceCS = values.forceCS;
2066
+ this.forceOD = values.forceOD;
2067
+ this.forceHP = values.forceHP;
2068
+ }
2138
2069
  }
2139
2070
 
2140
2071
  /**
@@ -2190,6 +2121,7 @@ class ReplayAnalyzer {
2190
2121
  * The amount of two-handed objects.
2191
2122
  */
2192
2123
  this.twoHandedNoteCount = 0;
2124
+ this.bufferOffset = 0;
2193
2125
  this.scoreID = values.scoreID;
2194
2126
  this.beatmap = values.map;
2195
2127
  this.difficultyAttributes = values.difficultyAttributes;
@@ -2218,6 +2150,119 @@ class ReplayAnalyzer {
2218
2150
  return this;
2219
2151
  });
2220
2152
  }
2153
+ /**
2154
+ * Gets hit error information of the replay.
2155
+ *
2156
+ * `analyze()` must be called before calling this.
2157
+ */
2158
+ calculateHitError() {
2159
+ var _a, _b;
2160
+ if (!this.data || !this.beatmap) {
2161
+ return null;
2162
+ }
2163
+ const hitObjectData = this.data.hitObjectData;
2164
+ let positiveCount = 0;
2165
+ let negativeCount = 0;
2166
+ let positiveTotal = 0;
2167
+ let negativeTotal = 0;
2168
+ const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2169
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2170
+ ? this.beatmap.beatmap
2171
+ : this.beatmap;
2172
+ const { objects } = beatmap.hitObjects;
2173
+ const mods = this.data.isReplayV3()
2174
+ ? this.data.convertedMods
2175
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.filter((m) => m.isApplicableToDroid())) !== null && _b !== void 0 ? _b : [];
2176
+ const od = osuBase.calculateDroidDifficultyStatistics({
2177
+ overallDifficulty: beatmap.difficulty.od,
2178
+ mods: osuBase.ModUtil.removeSpeedChangingMods(mods),
2179
+ }).overallDifficulty;
2180
+ const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(mods.some((m) => m instanceof osuBase.ModPrecise));
2181
+ // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2182
+ // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2183
+ //
2184
+ // In such cases, the slider is skipped.
2185
+ const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2186
+ const accuracies = [];
2187
+ for (let i = 0; i < hitObjectData.length; ++i) {
2188
+ const v = hitObjectData[i];
2189
+ const o = objects[i];
2190
+ if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2191
+ accuracies.push(0);
2192
+ continue;
2193
+ }
2194
+ const { accuracy } = v;
2195
+ if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2196
+ accuracies.push(0);
2197
+ continue;
2198
+ }
2199
+ accuracies.push(accuracy);
2200
+ if (accuracy >= 0) {
2201
+ positiveTotal += accuracy;
2202
+ ++positiveCount;
2203
+ }
2204
+ else {
2205
+ negativeTotal += accuracy;
2206
+ ++negativeCount;
2207
+ }
2208
+ }
2209
+ return {
2210
+ positiveAvg: positiveTotal / positiveCount || 0,
2211
+ negativeAvg: negativeTotal / negativeCount || 0,
2212
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2213
+ };
2214
+ }
2215
+ /**
2216
+ * Checks if a play is using 3 fingers.
2217
+ *
2218
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2219
+ */
2220
+ checkFor3Finger() {
2221
+ var _a;
2222
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2223
+ return;
2224
+ }
2225
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2226
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2227
+ ? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
2228
+ : new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2229
+ const result = threeFingerChecker.check();
2230
+ this.is3Finger = result.is3Finger;
2231
+ this.tapPenalty = result.penalty;
2232
+ this.hasBeenCheckedFor3Finger = true;
2233
+ }
2234
+ /**
2235
+ * Checks if a play is using 2 hands.
2236
+ *
2237
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2238
+ */
2239
+ checkFor2Hand() {
2240
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2241
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2242
+ !this.data) {
2243
+ return;
2244
+ }
2245
+ const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2246
+ const result = twoHandChecker.check();
2247
+ this.is2Hand = result.is2Hand;
2248
+ this.twoHandedNoteCount = result.twoHandedNoteCount;
2249
+ this.hasBeenCheckedFor2Hand = true;
2250
+ }
2251
+ /**
2252
+ * Checks if a play has cheesed sliders.
2253
+ *
2254
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2255
+ */
2256
+ checkForSliderCheesing() {
2257
+ var _a;
2258
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2259
+ return;
2260
+ }
2261
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2262
+ const sliderCheeseChecker = new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2263
+ this.sliderCheesePenalty = sliderCheeseChecker.check();
2264
+ this.hasBeenCheckedForSliderCheesing = true;
2265
+ }
2221
2266
  /**
2222
2267
  * Downloads the given score ID's replay.
2223
2268
  */
@@ -2264,7 +2309,6 @@ class ReplayAnalyzer {
2264
2309
  * Parses a replay after being downloaded and converted to a buffer.
2265
2310
  */
2266
2311
  parseReplay() {
2267
- var _a, _b, _c;
2268
2312
  if (!this.fixedODR) {
2269
2313
  return;
2270
2314
  }
@@ -2275,7 +2319,7 @@ class ReplayAnalyzer {
2275
2319
  try {
2276
2320
  rawObject = javaDeserialization__namespace.parse(this.fixedODR);
2277
2321
  }
2278
- catch (_d) {
2322
+ catch (_a) {
2279
2323
  return;
2280
2324
  }
2281
2325
  const resultObject = {
@@ -2286,49 +2330,16 @@ class ReplayAnalyzer {
2286
2330
  cursorMovement: [],
2287
2331
  hitObjectData: [],
2288
2332
  accuracy: new osuBase.Accuracy({ n300: 0 }),
2289
- rank: "",
2290
- };
2291
- const determineRank = () => {
2292
- var _a, _b;
2293
- const totalHits = resultObject.accuracy.n300 +
2294
- resultObject.accuracy.n100 +
2295
- resultObject.accuracy.n50 +
2296
- resultObject.accuracy.nmiss;
2297
- const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2298
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2299
- switch (true) {
2300
- case resultObject.accuracy.value() === 1:
2301
- if (isHidden) {
2302
- resultObject.rank = "XH";
2303
- }
2304
- else {
2305
- resultObject.rank = "X";
2306
- }
2307
- break;
2308
- case hit300Ratio > 0.9 &&
2309
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2310
- !resultObject.accuracy.nmiss:
2311
- if (isHidden) {
2312
- resultObject.rank = "SH";
2313
- }
2314
- else {
2315
- resultObject.rank = "S";
2316
- }
2317
- break;
2318
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2319
- hit300Ratio > 0.9:
2320
- resultObject.rank = "A";
2321
- break;
2322
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2323
- hit300Ratio > 0.8:
2324
- resultObject.rank = "B";
2325
- break;
2326
- case hit300Ratio > 0.6:
2327
- resultObject.rank = "C";
2328
- break;
2329
- default:
2330
- resultObject.rank = "D";
2331
- }
2333
+ rank: "D",
2334
+ convertedMods: [],
2335
+ hit100k: 0,
2336
+ hit300k: 0,
2337
+ isFullCombo: false,
2338
+ maxCombo: 0,
2339
+ playerName: "",
2340
+ rawMods: "",
2341
+ score: 0,
2342
+ time: new Date(0),
2332
2343
  };
2333
2344
  if (resultObject.replayVersion >= 3) {
2334
2345
  resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
@@ -2346,7 +2357,7 @@ class ReplayAnalyzer {
2346
2357
  resultObject.playerName = rawObject[5];
2347
2358
  resultObject.rawMods = rawObject[6].elements;
2348
2359
  resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
2349
- determineRank();
2360
+ resultObject.rank = this.calculateRank(resultObject);
2350
2361
  }
2351
2362
  if (resultObject.replayVersion >= 4) {
2352
2363
  const str = rawObject[7].split("|");
@@ -2398,32 +2409,87 @@ class ReplayAnalyzer {
2398
2409
  }
2399
2410
  // Merge all cursor movement and hit object data section into one for better control when parsing
2400
2411
  const replayDataBuffer = Buffer.concat(replayDataBufferArray);
2401
- const bufferCounter = { counter: 0 };
2402
- const size = this.readInt(replayDataBuffer, bufferCounter);
2403
- // Parse movement data
2404
- for (let x = 0; x < size; x++) {
2405
- const moveSize = this.readInt(replayDataBuffer, bufferCounter);
2412
+ this.bufferOffset = 0;
2413
+ this.parseMovementData(resultObject, replayDataBuffer);
2414
+ this.parseHitObjectData(resultObject, replayDataBuffer);
2415
+ this.parseOldReplayInformation(resultObject);
2416
+ switch (resultObject.replayVersion) {
2417
+ case 3:
2418
+ this.data = new ReplayV3Data(resultObject);
2419
+ break;
2420
+ case 4:
2421
+ this.data = new ReplayV4Data(resultObject);
2422
+ break;
2423
+ case 5:
2424
+ this.data = new ReplayV5Data(resultObject);
2425
+ break;
2426
+ default:
2427
+ this.data = new ReplayData(resultObject);
2428
+ }
2429
+ }
2430
+ /**
2431
+ * Converts replay mods to droid mod string.
2432
+ */
2433
+ convertDroidMods(replayMods) {
2434
+ const replayModsConstants = {
2435
+ MOD_AUTO: new osuBase.ModAuto().droidString,
2436
+ MOD_AUTOPILOT: new osuBase.ModAutopilot().droidString,
2437
+ MOD_NOFAIL: new osuBase.ModNoFail().droidString,
2438
+ MOD_EASY: new osuBase.ModEasy().droidString,
2439
+ MOD_HIDDEN: new osuBase.ModHidden().droidString,
2440
+ MOD_HARDROCK: new osuBase.ModHardRock().droidString,
2441
+ MOD_DOUBLETIME: new osuBase.ModDoubleTime().droidString,
2442
+ MOD_HALFTIME: new osuBase.ModHalfTime().droidString,
2443
+ MOD_NIGHTCORE: new osuBase.ModNightCore().droidString,
2444
+ MOD_PRECISE: new osuBase.ModPrecise().droidString,
2445
+ MOD_SMALLCIRCLE: new osuBase.ModSmallCircle().droidString,
2446
+ MOD_SPEEDUP: new osuBase.ModSpeedUp().droidString,
2447
+ MOD_REALLYEASY: new osuBase.ModReallyEasy().droidString,
2448
+ MOD_RELAX: new osuBase.ModRelax().droidString,
2449
+ MOD_PERFECT: new osuBase.ModPerfect().droidString,
2450
+ MOD_SUDDENDEATH: new osuBase.ModSuddenDeath().droidString,
2451
+ MOD_SCOREV2: new osuBase.ModScoreV2().droidString,
2452
+ MOD_FLASHLIGHT: new osuBase.ModFlashlight().droidString,
2453
+ };
2454
+ let modString = "";
2455
+ for (const mod of replayMods) {
2456
+ for (const property in replayModsConstants) {
2457
+ if (!mod.includes(property)) {
2458
+ continue;
2459
+ }
2460
+ modString +=
2461
+ replayModsConstants[property];
2462
+ break;
2463
+ }
2464
+ }
2465
+ return modString;
2466
+ }
2467
+ parseMovementData(resultObject, replayDataBuffer) {
2468
+ resultObject.cursorMovement.length = 0;
2469
+ const size = this.readInt(replayDataBuffer);
2470
+ for (let i = 0; i < size; i++) {
2471
+ const moveSize = this.readInt(replayDataBuffer);
2406
2472
  const time = [];
2407
2473
  const x = [];
2408
2474
  const y = [];
2409
2475
  const id = [];
2410
- for (let i = 0; i < moveSize; i++) {
2411
- time[i] = this.readInt(replayDataBuffer, bufferCounter);
2412
- id[i] = time[i] & 3;
2413
- time[i] >>= 2;
2414
- if (id[i] !== exports.MovementType.up) {
2476
+ for (let j = 0; j < moveSize; j++) {
2477
+ time[j] = this.readInt(replayDataBuffer);
2478
+ id[j] = time[j] & 3;
2479
+ time[j] >>= 2;
2480
+ if (id[j] !== exports.MovementType.up) {
2415
2481
  if (resultObject.replayVersion >= 5) {
2416
- x[i] = this.readFloat(replayDataBuffer, bufferCounter);
2417
- y[i] = this.readFloat(replayDataBuffer, bufferCounter);
2482
+ x[j] = this.readFloat(replayDataBuffer);
2483
+ y[j] = this.readFloat(replayDataBuffer);
2418
2484
  }
2419
2485
  else {
2420
- x[i] = this.readShort(replayDataBuffer, bufferCounter);
2421
- y[i] = this.readShort(replayDataBuffer, bufferCounter);
2486
+ x[j] = this.readShort(replayDataBuffer);
2487
+ y[j] = this.readShort(replayDataBuffer);
2422
2488
  }
2423
2489
  }
2424
2490
  else {
2425
- x[i] = -1;
2426
- y[i] = -1;
2491
+ x[j] = -1;
2492
+ y[j] = -1;
2427
2493
  }
2428
2494
  }
2429
2495
  resultObject.cursorMovement.push(new CursorData({
@@ -2434,7 +2500,10 @@ class ReplayAnalyzer {
2434
2500
  id: id,
2435
2501
  }));
2436
2502
  }
2437
- const replayObjectLength = this.readInt(replayDataBuffer, bufferCounter);
2503
+ }
2504
+ parseHitObjectData(resultObject, replayDataBuffer) {
2505
+ resultObject.hitObjectData.length = 0;
2506
+ const replayObjectLength = this.readInt(replayDataBuffer);
2438
2507
  // Parse result data
2439
2508
  for (let i = 0; i < replayObjectLength; i++) {
2440
2509
  const replayObjectData = {
@@ -2442,12 +2511,12 @@ class ReplayAnalyzer {
2442
2511
  tickset: [],
2443
2512
  result: exports.HitResult.miss,
2444
2513
  };
2445
- replayObjectData.accuracy = this.readShort(replayDataBuffer, bufferCounter);
2446
- const len = this.readByte(replayDataBuffer, bufferCounter);
2514
+ replayObjectData.accuracy = this.readShort(replayDataBuffer);
2515
+ const len = this.readByte(replayDataBuffer);
2447
2516
  if (len > 0) {
2448
2517
  const bytes = [];
2449
2518
  for (let j = 0; j < len; j++) {
2450
- bytes.push(this.readByte(replayDataBuffer, bufferCounter));
2519
+ bytes.push(this.readByte(replayDataBuffer));
2451
2520
  }
2452
2521
  // Int/int division in Java; numbers must be truncated to get actual number
2453
2522
  for (let j = 0; j < len * 8; j++) {
@@ -2457,265 +2526,137 @@ class ReplayAnalyzer {
2457
2526
  }
2458
2527
  }
2459
2528
  if (resultObject.replayVersion >= 1) {
2460
- replayObjectData.result = this.readByte(replayDataBuffer, bufferCounter);
2529
+ replayObjectData.result = this.readByte(replayDataBuffer);
2461
2530
  }
2462
2531
  resultObject.hitObjectData.push(replayObjectData);
2463
2532
  }
2464
- // Parse max combo, hit results, and accuracy in old replay version
2465
- if (resultObject.replayVersion < 3) {
2466
- const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2467
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2468
- ? this.beatmap.beatmap
2469
- : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2470
- let grantsGekiOrKatu = true;
2471
- for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2472
- // Hit result
2473
- const hitObjectData = resultObject.hitObjectData[i];
2474
- const isNextNewCombo = objects
2475
- ? i + 1 !== objects.length
2476
- ? objects[i + 1].isNewCombo
2477
- : true
2478
- : false;
2479
- switch (hitObjectData.result) {
2480
- case exports.HitResult.miss:
2481
- ++resultObject.accuracy.nmiss;
2482
- grantsGekiOrKatu = false;
2483
- break;
2484
- case exports.HitResult.meh:
2485
- ++resultObject.accuracy.n50;
2486
- grantsGekiOrKatu = false;
2487
- break;
2488
- case exports.HitResult.good:
2489
- ++resultObject.accuracy.n100;
2490
- if (grantsGekiOrKatu && isNextNewCombo) {
2491
- (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2492
- ++resultObject.hit100k;
2493
- }
2494
- break;
2495
- case exports.HitResult.great:
2496
- ++resultObject.accuracy.n300;
2497
- if (grantsGekiOrKatu && isNextNewCombo) {
2498
- (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2499
- ++resultObject.hit300k;
2500
- }
2501
- break;
2502
- }
2503
- if (isNextNewCombo) {
2504
- grantsGekiOrKatu = true;
2505
- }
2506
- }
2507
- determineRank();
2508
- }
2509
- this.data = new ReplayData(resultObject);
2510
2533
  }
2511
- /**
2512
- * Gets hit error information of the replay.
2513
- *
2514
- * `analyze()` must be called before calling this.
2515
- */
2516
- calculateHitError() {
2517
- if (!this.data || !this.beatmap) {
2518
- return null;
2534
+ parseOldReplayInformation(resultObject) {
2535
+ var _a, _b, _c;
2536
+ // Parse max combo, hit results, and accuracy in old replay version
2537
+ if (resultObject.replayVersion >= 3) {
2538
+ return;
2519
2539
  }
2520
- const hitObjectData = this.data.hitObjectData;
2521
- let positiveCount = 0;
2522
- let negativeCount = 0;
2523
- let positiveTotal = 0;
2524
- let negativeTotal = 0;
2525
- const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2540
+ const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2526
2541
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2527
2542
  ? this.beatmap.beatmap
2528
- : this.beatmap;
2529
- const { objects } = beatmap.hitObjects;
2530
- const od = osuBase.calculateDroidDifficultyStatistics({
2531
- overallDifficulty: beatmap.difficulty.od,
2532
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
2533
- }).overallDifficulty;
2534
- const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2535
- // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2536
- // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2537
- //
2538
- // In such cases, the slider is skipped.
2539
- const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2540
- const accuracies = [];
2541
- for (let i = 0; i < hitObjectData.length; ++i) {
2542
- const v = hitObjectData[i];
2543
- const o = objects[i];
2544
- if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2545
- accuracies.push(0);
2546
- continue;
2547
- }
2548
- const { accuracy } = v;
2549
- if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2550
- accuracies.push(0);
2551
- continue;
2552
- }
2553
- accuracies.push(accuracy);
2554
- if (accuracy >= 0) {
2555
- positiveTotal += accuracy;
2556
- ++positiveCount;
2557
- }
2558
- else {
2559
- negativeTotal += accuracy;
2560
- ++negativeCount;
2561
- }
2562
- }
2563
- return {
2564
- positiveAvg: positiveTotal / positiveCount || 0,
2565
- negativeAvg: negativeTotal / negativeCount || 0,
2566
- unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2567
- };
2568
- }
2569
- /**
2570
- * Converts replay mods to droid mod string.
2571
- */
2572
- convertDroidMods(replayMods) {
2573
- const replayModsConstants = {
2574
- MOD_AUTO: new osuBase.ModAuto().droidString,
2575
- MOD_AUTOPILOT: new osuBase.ModAutopilot().droidString,
2576
- MOD_NOFAIL: new osuBase.ModNoFail().droidString,
2577
- MOD_EASY: new osuBase.ModEasy().droidString,
2578
- MOD_HIDDEN: new osuBase.ModHidden().droidString,
2579
- MOD_HARDROCK: new osuBase.ModHardRock().droidString,
2580
- MOD_DOUBLETIME: new osuBase.ModDoubleTime().droidString,
2581
- MOD_HALFTIME: new osuBase.ModHalfTime().droidString,
2582
- MOD_NIGHTCORE: new osuBase.ModNightCore().droidString,
2583
- MOD_PRECISE: new osuBase.ModPrecise().droidString,
2584
- MOD_SMALLCIRCLE: new osuBase.ModSmallCircle().droidString,
2585
- MOD_SPEEDUP: new osuBase.ModSpeedUp().droidString,
2586
- MOD_REALLYEASY: new osuBase.ModReallyEasy().droidString,
2587
- MOD_RELAX: new osuBase.ModRelax().droidString,
2588
- MOD_PERFECT: new osuBase.ModPerfect().droidString,
2589
- MOD_SUDDENDEATH: new osuBase.ModSuddenDeath().droidString,
2590
- MOD_SCOREV2: new osuBase.ModScoreV2().droidString,
2591
- MOD_FLASHLIGHT: new osuBase.ModFlashlight().droidString,
2592
- };
2593
- let modString = "";
2594
- for (const mod of replayMods) {
2595
- for (const property in replayModsConstants) {
2596
- if (!mod.includes(property)) {
2597
- continue;
2598
- }
2599
- modString +=
2600
- replayModsConstants[property];
2601
- break;
2543
+ : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2544
+ let grantsGekiOrKatu = true;
2545
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2546
+ // Hit result
2547
+ const hitObjectData = resultObject.hitObjectData[i];
2548
+ const isNextNewCombo = objects
2549
+ ? i + 1 !== objects.length
2550
+ ? objects[i + 1].isNewCombo
2551
+ : true
2552
+ : false;
2553
+ switch (hitObjectData.result) {
2554
+ case exports.HitResult.miss:
2555
+ ++resultObject.accuracy.nmiss;
2556
+ grantsGekiOrKatu = false;
2557
+ break;
2558
+ case exports.HitResult.meh:
2559
+ ++resultObject.accuracy.n50;
2560
+ grantsGekiOrKatu = false;
2561
+ break;
2562
+ case exports.HitResult.good:
2563
+ ++resultObject.accuracy.n100;
2564
+ if (grantsGekiOrKatu && isNextNewCombo) {
2565
+ (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2566
+ ++resultObject.hit100k;
2567
+ }
2568
+ break;
2569
+ case exports.HitResult.great:
2570
+ ++resultObject.accuracy.n300;
2571
+ if (grantsGekiOrKatu && isNextNewCombo) {
2572
+ (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2573
+ ++resultObject.hit300k;
2574
+ }
2575
+ break;
2602
2576
  }
2603
- }
2604
- return modString;
2605
- }
2606
- /**
2607
- * Checks if a play is using 3 fingers.
2608
- *
2609
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2610
- */
2611
- checkFor3Finger() {
2612
- var _a;
2613
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2614
- return;
2615
- }
2616
- if (!this.convertedBeatmap) {
2617
- const mods = this.data.convertedMods.slice();
2618
- if ([
2619
- this.data.forceCS,
2620
- this.data.forceAR,
2621
- this.data.forceOD,
2622
- this.data.forceHP,
2623
- ].some((v) => v !== undefined)) {
2624
- mods.push(new osuBase.ModDifficultyAdjust({
2625
- cs: this.data.forceCS,
2626
- ar: this.data.forceAR,
2627
- od: this.data.forceOD,
2628
- hp: this.data.forceHP,
2629
- }));
2577
+ if (isNextNewCombo) {
2578
+ grantsGekiOrKatu = true;
2630
2579
  }
2631
- (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2632
- ? this.beatmap
2633
- : this.beatmap.beatmap).convert({
2634
- mode: osuBase.Modes.droid,
2635
- mods: mods,
2636
- customSpeedMultiplier: this.data.speedMultiplier,
2637
- }));
2638
2580
  }
2639
- const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2640
- ? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
2641
- : new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2642
- const result = threeFingerChecker.check();
2643
- this.is3Finger = result.is3Finger;
2644
- this.tapPenalty = result.penalty;
2645
- this.hasBeenCheckedFor3Finger = true;
2581
+ resultObject.rank = this.calculateRank(resultObject);
2646
2582
  }
2647
- /**
2648
- * Checks if a play is using 2 hands.
2649
- *
2650
- * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2651
- */
2652
- checkFor2Hand() {
2653
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2654
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2655
- !this.data) {
2656
- return;
2583
+ calculateRank(resultObject) {
2584
+ var _a, _b;
2585
+ const totalHits = resultObject.accuracy.n300 +
2586
+ resultObject.accuracy.n100 +
2587
+ resultObject.accuracy.n50 +
2588
+ resultObject.accuracy.nmiss;
2589
+ const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2590
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2591
+ switch (true) {
2592
+ case resultObject.accuracy.value() === 1:
2593
+ return isHidden ? "XH" : "X";
2594
+ case hit300Ratio > 0.9 &&
2595
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
2596
+ !resultObject.accuracy.nmiss:
2597
+ return isHidden ? "SH" : "S";
2598
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2599
+ hit300Ratio > 0.9:
2600
+ return "A";
2601
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2602
+ hit300Ratio > 0.8:
2603
+ return "B";
2604
+ case hit300Ratio > 0.6:
2605
+ return "C";
2606
+ default:
2607
+ return "D";
2657
2608
  }
2658
- const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2659
- const result = twoHandChecker.check();
2660
- this.is2Hand = result.is2Hand;
2661
- this.twoHandedNoteCount = result.twoHandedNoteCount;
2662
- this.hasBeenCheckedFor2Hand = true;
2663
2609
  }
2664
- /**
2665
- * Checks if a play has cheesed sliders.
2666
- *
2667
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2668
- */
2669
- checkForSliderCheesing() {
2670
- var _a;
2671
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2672
- return;
2673
- }
2674
- if (!this.convertedBeatmap) {
2675
- const mods = this.data.convertedMods.slice();
2676
- if ([
2610
+ constructPlayableBeatmap() {
2611
+ var _a, _b;
2612
+ if (!this.beatmap || !this.data) {
2613
+ throw new Error("Beatmap and replay data must be defined.");
2614
+ }
2615
+ const mods = this.data.isReplayV3()
2616
+ ? this.data.convertedMods.slice()
2617
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.slice()) !== null && _b !== void 0 ? _b : [];
2618
+ if (this.data.isReplayV5() &&
2619
+ [
2677
2620
  this.data.forceCS,
2678
2621
  this.data.forceAR,
2679
2622
  this.data.forceOD,
2680
2623
  this.data.forceHP,
2681
2624
  ].some((v) => v !== undefined)) {
2682
- mods.push(new osuBase.ModDifficultyAdjust({
2683
- cs: this.data.forceCS,
2684
- ar: this.data.forceAR,
2685
- od: this.data.forceOD,
2686
- hp: this.data.forceHP,
2687
- }));
2688
- }
2689
- (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2690
- ? this.beatmap
2691
- : this.beatmap.beatmap).convert({
2692
- mode: osuBase.Modes.droid,
2693
- mods: mods,
2694
- customSpeedMultiplier: this.data.speedMultiplier,
2625
+ mods.push(new osuBase.ModDifficultyAdjust({
2626
+ cs: this.data.forceCS,
2627
+ ar: this.data.forceAR,
2628
+ od: this.data.forceOD,
2629
+ hp: this.data.forceHP,
2695
2630
  }));
2696
2631
  }
2697
- const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2698
- this.sliderCheesePenalty = sliderCheeseChecker.check();
2699
- this.hasBeenCheckedForSliderCheesing = true;
2632
+ return (this.beatmap instanceof osuBase.Beatmap
2633
+ ? this.beatmap
2634
+ : this.beatmap.beatmap).createPlayableBeatmap({
2635
+ mode: osuBase.Modes.droid,
2636
+ mods: mods,
2637
+ customSpeedMultiplier: this.data.isReplayV4()
2638
+ ? this.data.speedMultiplier
2639
+ : 1,
2640
+ });
2700
2641
  }
2701
- readByte(buffer, counter) {
2702
- const num = buffer.readInt8(counter.counter);
2703
- counter.counter += 1;
2642
+ readByte(buffer) {
2643
+ const num = buffer.readInt8(this.bufferOffset);
2644
+ this.bufferOffset += 1;
2704
2645
  return num;
2705
2646
  }
2706
- readShort(buffer, counter) {
2707
- const num = buffer.readInt16BE(counter.counter);
2708
- counter.counter += 2;
2647
+ readShort(buffer) {
2648
+ const num = buffer.readInt16BE(this.bufferOffset);
2649
+ this.bufferOffset += 2;
2709
2650
  return num;
2710
2651
  }
2711
- readInt(buffer, counter) {
2712
- const num = buffer.readInt32BE(counter.counter);
2713
- counter.counter += 4;
2652
+ readInt(buffer) {
2653
+ const num = buffer.readInt32BE(this.bufferOffset);
2654
+ this.bufferOffset += 4;
2714
2655
  return num;
2715
2656
  }
2716
- readFloat(buffer, counter) {
2717
- const num = buffer.readFloatBE(counter.counter);
2718
- counter.counter += 4;
2657
+ readFloat(buffer) {
2658
+ const num = buffer.readFloatBE(this.bufferOffset);
2659
+ this.bufferOffset += 4;
2719
2660
  return num;
2720
2661
  }
2721
2662
  }
@@ -2742,6 +2683,9 @@ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2742
2683
  exports.ReplayAnalyzer = ReplayAnalyzer;
2743
2684
  exports.ReplayData = ReplayData;
2744
2685
  exports.ReplayObjectData = ReplayObjectData;
2686
+ exports.ReplayV3Data = ReplayV3Data;
2687
+ exports.ReplayV4Data = ReplayV4Data;
2688
+ exports.ReplayV5Data = ReplayV5Data;
2745
2689
  exports.SliderCheeseChecker = SliderCheeseChecker;
2746
2690
  exports.ThreeFingerChecker = ThreeFingerChecker;
2747
2691
  exports.TwoHandChecker = TwoHandChecker;