@rian8337/osu-difficulty-calculator 4.0.0-beta.11 → 4.0.0-beta.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -563,7 +563,7 @@ class DifficultyHitObjectCreator {
563
563
  }
564
564
 
565
565
  /**
566
- * The base of difficulty calculators.
566
+ * The base of a difficulty calculator.
567
567
  */
568
568
  class DifficultyCalculator {
569
569
  /**
@@ -626,8 +626,8 @@ class DifficultyCalculator {
626
626
  od: this.beatmap.difficulty.od,
627
627
  hp: this.beatmap.difficulty.hp,
628
628
  mods: options?.mods,
629
- speedMultiplier: options?.stats?.speedMultiplier,
630
- oldStatistics: options?.stats?.oldStatistics,
629
+ speedMultiplier: options?.stats?.speedMultiplier ?? 1,
630
+ oldStatistics: options?.stats?.oldStatistics ?? false,
631
631
  }).calculate({ mode: this.mode });
632
632
  this.preProcess();
633
633
  this.populateDifficultyAttributes();
@@ -661,15 +661,11 @@ class DifficultyCalculator {
661
661
  */
662
662
  calculateSkills(...skills) {
663
663
  // The first object doesn't generate a strain, so we begin calculating from the second object.
664
- this.objects.slice(1).forEach((h, i) => {
665
- skills.forEach((skill) => {
666
- skill.process(h);
667
- if (i === this.objects.length - 2) {
668
- // Don't forget to save the last strain peak, which would otherwise be ignored.
669
- skill.saveCurrentPeak();
670
- }
671
- });
672
- });
664
+ for (const object of this.objects.slice(1)) {
665
+ for (const skill of skills) {
666
+ skill.process(object);
667
+ }
668
+ }
673
669
  }
674
670
  /**
675
671
  * Populates the stored difficulty attributes with necessary data.
@@ -709,12 +705,9 @@ class DroidAimEvaluator extends AimEvaluator {
709
705
  static wideAngleMultiplier = 1.65;
710
706
  static sliderMultiplier = 1.5;
711
707
  static velocityChangeMultiplier = 0.85;
712
- /**
713
- * Spacing threshold for a single hitobject spacing.
714
- */
715
- static SINGLE_SPACING_THRESHOLD = 175;
716
- // ~200 1/2 BPM jumps
717
- static minSpeedBonus = 150;
708
+ static singleSpacingThreshold = 100;
709
+ // 200 1/4 BPM delta time
710
+ static minSpeedBonus = 75;
718
711
  /**
719
712
  * Evaluates the difficulty of aiming the current object, based on:
720
713
  *
@@ -732,13 +725,13 @@ class DroidAimEvaluator extends AimEvaluator {
732
725
  current.isOverlapping(true)) {
733
726
  return 0;
734
727
  }
735
- return (this.aimStrainOf(current, withSliders) +
736
- this.movementStrainOf(current));
728
+ return (this.snapAimStrainOf(current, withSliders) +
729
+ this.flowAimStrainOf(current));
737
730
  }
738
731
  /**
739
- * Calculates the aim strain of a hitobject.
732
+ * Calculates the snap aim strain of a hitobject.
740
733
  */
741
- static aimStrainOf(current, withSliders) {
734
+ static snapAimStrainOf(current, withSliders) {
742
735
  if (current.index <= 1 ||
743
736
  current.previous(0)?.object instanceof osuBase.Spinner) {
744
737
  return 0;
@@ -848,21 +841,18 @@ class DroidAimEvaluator extends AimEvaluator {
848
841
  return strain;
849
842
  }
850
843
  /**
851
- * Calculates the movement strain of a hitobject.
844
+ * Calculates the flow aim strain of a hitobject.
852
845
  */
853
- static movementStrainOf(current) {
846
+ static flowAimStrainOf(current) {
854
847
  let speedBonus = 1;
855
848
  if (current.strainTime < this.minSpeedBonus) {
856
849
  speedBonus +=
857
850
  0.75 *
858
- Math.pow((this.minSpeedBonus - current.strainTime) / 45, 2);
851
+ Math.pow((this.minSpeedBonus - current.strainTime) / 40, 2);
859
852
  }
860
853
  const travelDistance = current.previous(0)?.travelDistance ?? 0;
861
- const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
862
- return ((50 *
863
- speedBonus *
864
- Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 5)) /
865
- current.strainTime);
854
+ const shortDistancePenalty = Math.pow(Math.min(this.singleSpacingThreshold, travelDistance + current.minimumJumpDistance) / this.singleSpacingThreshold, 3.5);
855
+ return (200 * speedBonus * shortDistancePenalty) / current.strainTime;
866
856
  }
867
857
  }
868
858
 
@@ -886,25 +876,14 @@ class Skill {
886
876
  * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
887
877
  */
888
878
  class StrainSkill extends Skill {
889
- /**
890
- * The strain of currently calculated hitobject.
891
- */
892
- currentStrain = 0;
893
- /**
894
- * The current section's strain peak.
895
- */
896
- currentSectionPeak = 0;
897
879
  /**
898
880
  * Strain peaks are stored here.
899
881
  */
900
882
  strainPeaks = [];
901
883
  sectionLength = 400;
884
+ currentStrain = 0;
885
+ currentSectionPeak = 0;
902
886
  currentSectionEnd = 0;
903
- /**
904
- * Calculates the strain value of a hitobject and stores the value in it. This value is affected by previously processed objects.
905
- *
906
- * @param current The hitobject to process.
907
- */
908
887
  process(current) {
909
888
  // The first object doesn't generate a strain, so we begin with an incremented section end
910
889
  if (current.index === 0) {
@@ -921,6 +900,10 @@ class StrainSkill extends Skill {
921
900
  this.currentStrain = this.strainValueAt(current);
922
901
  this.saveToHitObject(current);
923
902
  this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
903
+ if (!current.next(0)) {
904
+ // Don't forget to save the last strain peak, which would otherwise be ignored.
905
+ this.saveCurrentPeak();
906
+ }
924
907
  }
925
908
  /**
926
909
  * Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty.
@@ -939,15 +922,13 @@ class StrainSkill extends Skill {
939
922
  /**
940
923
  * Sets the initial strain level for a new section.
941
924
  *
942
- * @param offset The beginning of the new section in milliseconds, adjusted by speed multiplier.
925
+ * @param time The beginning of the new section in milliseconds.
943
926
  * @param current The current hitobject.
944
927
  */
945
- startNewSectionFrom(offset, current) {
946
- // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
928
+ startNewSectionFrom(time, current) {
929
+ // The maximum strain of the new section is not zero by default.
947
930
  // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
948
- this.currentSectionPeak =
949
- this.currentStrain *
950
- this.strainDecay(offset - current.previous(0).startTime);
931
+ this.currentSectionPeak = this.calculateInitialStrain(time, current);
951
932
  }
952
933
  }
953
934
 
@@ -981,35 +962,37 @@ class DroidSkill extends StrainSkill {
981
962
  * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
982
963
  */
983
964
  class DroidAim extends DroidSkill {
984
- skillMultiplier = 24.55;
985
965
  strainDecayBase = 0.15;
986
966
  reducedSectionCount = 10;
987
967
  reducedSectionBaseline = 0.75;
988
968
  starsPerDouble = 1.05;
969
+ skillMultiplier = 24.55;
989
970
  withSliders;
971
+ currentAimStrain = 0;
990
972
  constructor(mods, withSliders) {
991
973
  super(mods);
992
974
  this.withSliders = withSliders;
993
975
  }
994
- /**
995
- * @param current The hitobject to calculate.
996
- */
997
976
  strainValueAt(current) {
998
- this.currentStrain *= this.strainDecay(current.deltaTime);
999
- this.currentStrain +=
977
+ this.currentAimStrain *= this.strainDecay(current.deltaTime);
978
+ this.currentAimStrain +=
1000
979
  DroidAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
1001
980
  this.skillMultiplier;
1002
- return this.currentStrain;
981
+ return this.currentAimStrain;
982
+ }
983
+ calculateInitialStrain(time, current) {
984
+ return (this.currentAimStrain *
985
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
1003
986
  }
1004
987
  /**
1005
988
  * @param current The hitobject to save to.
1006
989
  */
1007
990
  saveToHitObject(current) {
1008
991
  if (this.withSliders) {
1009
- current.aimStrainWithSliders = this.currentStrain;
992
+ current.aimStrainWithSliders = this.currentAimStrain;
1010
993
  }
1011
994
  else {
1012
- current.aimStrainWithoutSliders = this.currentStrain;
995
+ current.aimStrainWithoutSliders = this.currentAimStrain;
1013
996
  }
1014
997
  }
1015
998
  }
@@ -1047,10 +1030,10 @@ class DroidTapEvaluator extends SpeedEvaluator {
1047
1030
  }
1048
1031
  let doubletapness = 1;
1049
1032
  if (considerCheesability) {
1050
- const greatWindowFull = greatWindow * 2;
1051
1033
  // Nerf doubletappable doubles.
1052
1034
  const next = current.next(0);
1053
1035
  if (next) {
1036
+ const greatWindowFull = greatWindow * 2;
1054
1037
  const currentDeltaTime = Math.max(1, current.deltaTime);
1055
1038
  const nextDeltaTime = Math.max(1, next.deltaTime);
1056
1039
  const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
@@ -1074,38 +1057,44 @@ class DroidTapEvaluator extends SpeedEvaluator {
1074
1057
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
1075
1058
  */
1076
1059
  class DroidTap extends DroidSkill {
1077
- skillMultiplier = 1375;
1078
1060
  reducedSectionCount = 10;
1079
1061
  reducedSectionBaseline = 0.75;
1080
1062
  strainDecayBase = 0.3;
1081
1063
  starsPerDouble = 1.1;
1082
1064
  currentTapStrain = 0;
1083
- currentOriginalTapStrain = 0;
1065
+ currentRhythmMultiplier = 0;
1066
+ skillMultiplier = 1375;
1084
1067
  greatWindow;
1085
- constructor(mods, overallDifficulty) {
1068
+ considerCheesability;
1069
+ constructor(mods, overallDifficulty, considerCheesability) {
1086
1070
  super(mods);
1087
1071
  this.greatWindow = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
1072
+ this.considerCheesability = considerCheesability;
1088
1073
  }
1089
- /**
1090
- * @param current The hitobject to calculate.
1091
- */
1092
1074
  strainValueAt(current) {
1093
1075
  const decay = this.strainDecay(current.strainTime);
1094
1076
  this.currentTapStrain *= decay;
1095
1077
  this.currentTapStrain +=
1096
- DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, true) * this.skillMultiplier;
1097
- this.currentOriginalTapStrain *= decay;
1098
- this.currentOriginalTapStrain +=
1099
- DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, false) * this.skillMultiplier;
1100
- this.currentOriginalTapStrain *= current.rhythmMultiplier;
1078
+ DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, this.considerCheesability) * this.skillMultiplier;
1079
+ this.currentRhythmMultiplier = current.rhythmMultiplier;
1101
1080
  return this.currentTapStrain * current.rhythmMultiplier;
1102
1081
  }
1082
+ calculateInitialStrain(time, current) {
1083
+ return (this.currentTapStrain *
1084
+ this.currentRhythmMultiplier *
1085
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
1086
+ }
1103
1087
  /**
1104
1088
  * @param current The hitobject to save to.
1105
1089
  */
1106
1090
  saveToHitObject(current) {
1107
- current.tapStrain = this.currentStrain;
1108
- current.originalTapStrain = this.currentOriginalTapStrain;
1091
+ const strain = this.currentTapStrain * this.currentRhythmMultiplier;
1092
+ if (this.considerCheesability) {
1093
+ current.tapStrain = strain;
1094
+ }
1095
+ else {
1096
+ current.originalTapStrain = strain;
1097
+ }
1109
1098
  }
1110
1099
  }
1111
1100
 
@@ -1213,33 +1202,36 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1213
1202
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
1214
1203
  */
1215
1204
  class DroidFlashlight extends DroidSkill {
1216
- skillMultiplier = 0.052;
1217
1205
  strainDecayBase = 0.15;
1218
1206
  reducedSectionCount = 0;
1219
1207
  reducedSectionBaseline = 1;
1220
1208
  starsPerDouble = 1.06;
1209
+ skillMultiplier = 0.052;
1221
1210
  isHidden;
1222
1211
  withSliders;
1212
+ currentFlashlightStrain = 0;
1223
1213
  constructor(mods, withSliders) {
1224
1214
  super(mods);
1225
1215
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1226
1216
  this.withSliders = withSliders;
1227
1217
  }
1228
- /**
1229
- * @param current The hitobject to calculate.
1230
- */
1231
1218
  strainValueAt(current) {
1232
- this.currentStrain *= this.strainDecay(current.deltaTime);
1233
- this.currentStrain +=
1219
+ this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
1220
+ this.currentFlashlightStrain +=
1234
1221
  DroidFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1235
- return this.currentStrain;
1222
+ return this.currentFlashlightStrain;
1223
+ }
1224
+ calculateInitialStrain(time, current) {
1225
+ return (this.currentFlashlightStrain *
1226
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
1236
1227
  }
1237
1228
  saveToHitObject(current) {
1238
1229
  if (this.withSliders) {
1239
- current.flashlightStrainWithSliders = this.currentStrain;
1230
+ current.flashlightStrainWithSliders = this.currentFlashlightStrain;
1240
1231
  }
1241
1232
  else {
1242
- current.flashlightStrainWithoutSliders = this.currentStrain;
1233
+ current.flashlightStrainWithoutSliders =
1234
+ this.currentFlashlightStrain;
1243
1235
  }
1244
1236
  }
1245
1237
  difficultyValue() {
@@ -1313,8 +1305,8 @@ class DroidRhythmEvaluator extends RhythmEvaluator {
1313
1305
  Math.min(0.5, Math.pow(Math.sin(Math.PI /
1314
1306
  (Math.min(prevDelta, currentDelta) /
1315
1307
  Math.max(prevDelta, currentDelta))), 2));
1316
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.4) /
1317
- (greatWindow * 0.4));
1308
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.6) /
1309
+ (greatWindow * 0.6));
1318
1310
  let effectiveRatio = windowPenalty * currentRatio;
1319
1311
  if (firstDeltaSwitch) {
1320
1312
  if (prevDelta <= 1.25 * currentDelta &&
@@ -1391,26 +1383,31 @@ class DroidRhythmEvaluator extends RhythmEvaluator {
1391
1383
  * Represents the skill required to properly follow a beatmap's rhythm.
1392
1384
  */
1393
1385
  class DroidRhythm extends DroidSkill {
1394
- skillMultiplier = 1;
1395
1386
  reducedSectionCount = 5;
1396
1387
  reducedSectionBaseline = 0.75;
1397
1388
  strainDecayBase = 0.3;
1398
1389
  starsPerDouble = 1.75;
1399
- currentRhythm = 1;
1390
+ currentRhythmStrain = 0;
1391
+ currentRhythmMultiplier = 1;
1400
1392
  hitWindow;
1401
1393
  constructor(mods, overallDifficulty) {
1402
1394
  super(mods);
1403
1395
  this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
1404
1396
  }
1405
1397
  strainValueAt(current) {
1406
- this.currentRhythm = DroidRhythmEvaluator.evaluateDifficultyOf(current, this.hitWindow.hitWindowFor300());
1407
- this.currentStrain *= this.strainDecay(current.deltaTime);
1408
- this.currentStrain += this.currentRhythm - 1;
1409
- return this.currentStrain;
1398
+ this.currentRhythmMultiplier =
1399
+ DroidRhythmEvaluator.evaluateDifficultyOf(current, this.hitWindow.hitWindowFor300());
1400
+ this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
1401
+ this.currentRhythmStrain += this.currentRhythmMultiplier - 1;
1402
+ return this.currentRhythmStrain;
1403
+ }
1404
+ calculateInitialStrain(time, current) {
1405
+ return (this.currentRhythmStrain *
1406
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
1410
1407
  }
1411
1408
  saveToHitObject(current) {
1412
- current.rhythmStrain = this.currentStrain;
1413
- current.rhythmMultiplier = this.currentRhythm;
1409
+ current.rhythmStrain = this.currentRhythmStrain;
1410
+ current.rhythmMultiplier = this.currentRhythmMultiplier;
1414
1411
  }
1415
1412
  }
1416
1413
 
@@ -1475,12 +1472,14 @@ class DroidVisualEvaluator {
1475
1472
  }
1476
1473
  if (current.object instanceof osuBase.Slider && withSliders) {
1477
1474
  const scalingFactor = 50 / current.object.getRadius(osuBase.Modes.droid);
1478
- // Reward sliders based on velocity.
1475
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1476
+ const pixelTravelDistance = current.object.lazyTravelDistance / scalingFactor;
1477
+ const currentVelocity = pixelTravelDistance / current.travelTime;
1479
1478
  strain +=
1480
- // Avoid overbuffing extremely fast sliders.
1481
- Math.min(6, current.velocity * 1.5) *
1482
- // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1483
- Math.min(1, current.travelDistance / scalingFactor / 125);
1479
+ // Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
1480
+ Math.min(6, currentVelocity * 1.5) *
1481
+ // Longer sliders require more reading.
1482
+ (pixelTravelDistance / 100);
1484
1483
  let cumulativeStrainTime = 0;
1485
1484
  // Reward for velocity changes based on last few sliders.
1486
1485
  for (let i = 0; i < Math.min(current.index, 4); ++i) {
@@ -1491,12 +1490,16 @@ class DroidVisualEvaluator {
1491
1490
  last.isOverlapping(true)) {
1492
1491
  continue;
1493
1492
  }
1493
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1494
+ const pixelTravelDistance = last.object.lazyTravelDistance / scalingFactor;
1495
+ const lastVelocity = pixelTravelDistance / last.travelTime;
1494
1496
  strain +=
1495
- // Avoid overbuffing extremely fast velocity changes.
1496
- Math.min(10, 2.5 * Math.abs(current.velocity - last.velocity)) *
1497
- // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1498
- Math.min(1, last.travelDistance / scalingFactor / 100) *
1499
- // Scale with cumulative strain time to avoid overbuffing past sliders.
1497
+ // Reward past sliders based on velocity changes, while also
1498
+ // avoiding overbuffing extremely fast velocity changes.
1499
+ Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
1500
+ // Longer sliders require more reading.
1501
+ (pixelTravelDistance / 125) *
1502
+ // Avoid overbuffing past sliders.
1500
1503
  Math.min(1, 300 / cumulativeStrainTime);
1501
1504
  }
1502
1505
  }
@@ -1511,27 +1514,36 @@ class DroidVisual extends DroidSkill {
1511
1514
  starsPerDouble = 1.025;
1512
1515
  reducedSectionCount = 10;
1513
1516
  reducedSectionBaseline = 0.75;
1514
- skillMultiplier = 10;
1515
1517
  strainDecayBase = 0.1;
1516
1518
  isHidden;
1517
- withsliders;
1519
+ withSliders;
1520
+ currentVisualStrain = 0;
1521
+ currentRhythmMultiplier = 1;
1522
+ skillMultiplier = 10;
1518
1523
  constructor(mods, withSliders) {
1519
1524
  super(mods);
1520
1525
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1521
- this.withsliders = withSliders;
1526
+ this.withSliders = withSliders;
1522
1527
  }
1523
1528
  strainValueAt(current) {
1524
- this.currentStrain *= this.strainDecay(current.deltaTime);
1525
- this.currentStrain +=
1526
- DroidVisualEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withsliders) * this.skillMultiplier;
1527
- return this.currentStrain * (1 + (current.rhythmMultiplier - 1) / 5);
1529
+ this.currentVisualStrain *= this.strainDecay(current.deltaTime);
1530
+ this.currentVisualStrain +=
1531
+ DroidVisualEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1532
+ this.currentRhythmMultiplier = current.rhythmMultiplier;
1533
+ return this.currentVisualStrain * this.currentRhythmMultiplier;
1534
+ }
1535
+ calculateInitialStrain(time, current) {
1536
+ return (this.currentVisualStrain *
1537
+ this.currentRhythmMultiplier *
1538
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
1528
1539
  }
1529
1540
  saveToHitObject(current) {
1530
- if (this.withsliders) {
1531
- current.visualStrainWithSliders = this.currentStrain;
1541
+ const strain = this.currentVisualStrain * this.currentRhythmMultiplier;
1542
+ if (this.withSliders) {
1543
+ current.visualStrainWithSliders = strain;
1532
1544
  }
1533
1545
  else {
1534
- current.visualStrainWithoutSliders = this.currentStrain;
1546
+ current.visualStrainWithoutSliders = strain;
1535
1547
  }
1536
1548
  }
1537
1549
  }
@@ -1609,25 +1621,16 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1609
1621
  * Calculates the tap star rating of the beatmap and stores it in this instance.
1610
1622
  */
1611
1623
  calculateTap() {
1612
- const tapSkill = new DroidTap(this.mods, this.stats.od);
1613
- this.calculateSkills(tapSkill);
1614
- if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1615
- this.tap = this.attributes.tapDifficulty = 0;
1616
- this.attributes.possibleThreeFingeredSections = [];
1617
- }
1618
- else {
1619
- this.postCalculateTap(tapSkill);
1620
- }
1621
- this.calculateSpeedAttributes();
1624
+ const od = this.stats.od;
1625
+ const tapSkillCheese = new DroidTap(this.mods, od, true);
1626
+ const tapSkillNoCheese = new DroidTap(this.mods, od, false);
1627
+ this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
1628
+ this.postCalculateTap(tapSkillCheese);
1622
1629
  }
1623
1630
  /**
1624
1631
  * Calculates the rhythm star rating of the beatmap and stores it in this instance.
1625
1632
  */
1626
1633
  calculateRhythm() {
1627
- if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1628
- this.rhythm = this.attributes.rhythmDifficulty = 0;
1629
- return;
1630
- }
1631
1634
  const rhythmSkill = new DroidRhythm(this.mods, this.stats.od);
1632
1635
  this.calculateSkills(rhythmSkill);
1633
1636
  this.postCalculateRhythm(rhythmSkill);
@@ -1679,39 +1682,21 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1679
1682
  }
1680
1683
  calculateAll() {
1681
1684
  const skills = this.createSkills();
1682
- const isRelax = this.mods.some((m) => m instanceof osuBase.ModRelax);
1683
- if (isRelax) {
1684
- // Remove visual skills to reduce overhead.
1685
- skills.pop();
1686
- skills.pop();
1687
- }
1688
1685
  this.calculateSkills(...skills);
1689
1686
  const aimSkill = skills[0];
1690
1687
  const aimSkillWithoutSliders = skills[1];
1691
1688
  const rhythmSkill = skills[2];
1692
- const tapSkill = skills[3];
1693
- const flashlightSkill = skills[4];
1694
- const flashlightSkillWithoutSliders = skills[5];
1695
- const visualSkill = skills[6] ?? null;
1696
- const visualSkillWithoutSliders = skills[7] ?? null;
1689
+ const tapSkillCheese = skills[3];
1690
+ const flashlightSkill = skills[5];
1691
+ const flashlightSkillWithoutSliders = skills[6];
1692
+ const visualSkill = skills[7];
1693
+ const visualSkillWithoutSliders = skills[8];
1697
1694
  this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1698
- if (isRelax) {
1699
- this.tap = this.attributes.tapDifficulty = 0;
1700
- }
1701
- else {
1702
- this.postCalculateTap(tapSkill);
1703
- }
1704
- this.calculateSpeedAttributes();
1705
- if (!isRelax) {
1706
- this.postCalculateRhythm(rhythmSkill);
1707
- }
1695
+ this.postCalculateTap(tapSkillCheese);
1696
+ this.calculateTapAttributes();
1697
+ this.postCalculateRhythm(rhythmSkill);
1708
1698
  this.postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders);
1709
- if (visualSkill && visualSkillWithoutSliders) {
1710
- this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1711
- }
1712
- else {
1713
- this.visual = this.attributes.visualDifficulty = 0;
1714
- }
1699
+ this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1715
1700
  this.calculateTotal();
1716
1701
  }
1717
1702
  toString() {
@@ -1736,12 +1721,14 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1736
1721
  osuBase.HitObjectStackEvaluator.applyDroidStacking(this.beatmap.hitObjects.objects, this.beatmap.general.stackLeniency);
1737
1722
  }
1738
1723
  createSkills() {
1724
+ const od = this.stats.od;
1739
1725
  return [
1740
1726
  new DroidAim(this.mods, true),
1741
1727
  new DroidAim(this.mods, false),
1742
1728
  // Tap skill depends on rhythm skill, so we put it first
1743
- new DroidRhythm(this.mods, this.stats.od),
1744
- new DroidTap(this.mods, this.stats.od),
1729
+ new DroidRhythm(this.mods, od),
1730
+ new DroidTap(this.mods, od, true),
1731
+ new DroidTap(this.mods, od, false),
1745
1732
  new DroidFlashlight(this.mods, true),
1746
1733
  new DroidFlashlight(this.mods, false),
1747
1734
  new DroidVisual(this.mods, true),
@@ -1813,16 +1800,23 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1813
1800
  /**
1814
1801
  * Called after tap skill calculation.
1815
1802
  *
1816
- * @param tapSkill The tap skill.
1803
+ * @param tapSkillCheese The tap skill that considers cheesing.
1817
1804
  */
1818
- postCalculateTap(tapSkill) {
1819
- this.strainPeaks.speed = tapSkill.strainPeaks;
1820
- this.tap = this.attributes.tapDifficulty = this.starValue(tapSkill.difficultyValue());
1805
+ postCalculateTap(tapSkillCheese) {
1806
+ this.strainPeaks.speed = tapSkillCheese.strainPeaks;
1807
+ if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1808
+ this.tap = this.attributes.tapDifficulty = 0;
1809
+ this.attributes.possibleThreeFingeredSections = [];
1810
+ }
1811
+ else {
1812
+ this.tap = this.attributes.tapDifficulty = this.starValue(tapSkillCheese.difficultyValue());
1813
+ }
1814
+ this.calculateTapAttributes();
1821
1815
  }
1822
1816
  /**
1823
- * Calculates speed-related attributes.
1817
+ * Calculates tap-related attributes.
1824
1818
  */
1825
- calculateSpeedAttributes() {
1819
+ calculateTapAttributes() {
1826
1820
  this.attributes.possibleThreeFingeredSections = [];
1827
1821
  const tempSections = [];
1828
1822
  const objectStrains = [];
@@ -2019,7 +2013,7 @@ class PerformanceCalculator {
2019
2013
  calculate(options) {
2020
2014
  this.handleOptions(options);
2021
2015
  this.calculateValues();
2022
- this.calculateTotalValue();
2016
+ this.total = this.calculateTotalValue();
2023
2017
  return this;
2024
2018
  }
2025
2019
  /**
@@ -2233,9 +2227,9 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2233
2227
  if (value === this._tapPenalty) {
2234
2228
  return;
2235
2229
  }
2236
- this.tap *= this._tapPenalty / value;
2237
2230
  this._tapPenalty = value;
2238
- this.calculateTotalValue();
2231
+ this.tap = this.calculateTapValue();
2232
+ this.total = this.calculateTotalValue();
2239
2233
  }
2240
2234
  /**
2241
2235
  * Applies an aim slider cheese penalty value to this calculator.
@@ -2255,8 +2249,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2255
2249
  return;
2256
2250
  }
2257
2251
  this._aimSliderCheesePenalty = value;
2258
- this.calculateAimValue();
2259
- this.calculateTotalValue();
2252
+ this.aim = this.calculateAimValue();
2253
+ this.total = this.calculateTotalValue();
2260
2254
  }
2261
2255
  /**
2262
2256
  * Applies a flashlight slider cheese penalty value to this calculator.
@@ -2276,8 +2270,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2276
2270
  return;
2277
2271
  }
2278
2272
  this._flashlightSliderCheesePenalty = value;
2279
- this.calculateFlashlightValue();
2280
- this.calculateTotalValue();
2273
+ this.flashlight = this.calculateFlashlightValue();
2274
+ this.total = this.calculateTotalValue();
2281
2275
  }
2282
2276
  /**
2283
2277
  * Applies a visual slider cheese penalty value to this calculator.
@@ -2297,25 +2291,24 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2297
2291
  return;
2298
2292
  }
2299
2293
  this._visualSliderCheesePenalty = value;
2300
- this.calculateVisualValue();
2301
- this.calculateTotalValue();
2294
+ this.visual = this.calculateVisualValue();
2295
+ this.total = this.calculateTotalValue();
2302
2296
  }
2303
2297
  calculateValues() {
2304
2298
  this._deviation = this.calculateDeviation();
2305
2299
  this._tapDeviation = this.calculateTapDeviation();
2306
- this.calculateAimValue();
2307
- this.calculateTapValue();
2308
- this.calculateAccuracyValue();
2309
- this.calculateFlashlightValue();
2310
- this.calculateVisualValue();
2300
+ this.aim = this.calculateAimValue();
2301
+ this.tap = this.calculateTapValue();
2302
+ this.accuracy = this.calculateAccuracyValue();
2303
+ this.flashlight = this.calculateFlashlightValue();
2304
+ this.visual = this.calculateVisualValue();
2311
2305
  }
2312
2306
  calculateTotalValue() {
2313
- this.total =
2314
- Math.pow(Math.pow(this.aim, 1.1) +
2315
- Math.pow(this.tap, 1.1) +
2316
- Math.pow(this.accuracy, 1.1) +
2317
- Math.pow(this.flashlight, 1.1) +
2318
- Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier;
2307
+ return (Math.pow(Math.pow(this.aim, 1.1) +
2308
+ Math.pow(this.tap, 1.1) +
2309
+ Math.pow(this.accuracy, 1.1) +
2310
+ Math.pow(this.flashlight, 1.1) +
2311
+ Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier);
2319
2312
  }
2320
2313
  handleOptions(options) {
2321
2314
  this._tapPenalty = options?.tapPenalty ?? 1;
@@ -2330,23 +2323,31 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2330
2323
  * Calculates the aim performance value of the beatmap.
2331
2324
  */
2332
2325
  calculateAimValue() {
2333
- this.aim = this.baseValue(Math.pow(this.difficultyAttributes.aimDifficulty, 0.8));
2334
- this.aim *= this.calculateMissPenalty(this.difficultyAttributes.aimDifficultStrainCount);
2326
+ let aimValue = this.baseValue(Math.pow(this.difficultyAttributes.aimDifficulty, 0.8));
2327
+ aimValue *= this.proportionalMissPenalty;
2328
+ // Scale the aim value with estimated full combo deviation.
2329
+ aimValue *= this.calculateDeviationBasedLengthScaling();
2335
2330
  // Scale the aim value with slider factor to nerf very likely dropped sliderends.
2336
- this.aim *= this.sliderNerfFactor;
2331
+ aimValue *= this.sliderNerfFactor;
2337
2332
  // Scale the aim value with slider cheese penalty.
2338
- this.aim *= this._aimSliderCheesePenalty;
2333
+ aimValue *= this._aimSliderCheesePenalty;
2339
2334
  // Scale the aim value with deviation.
2340
- this.aim *=
2335
+ aimValue *=
2341
2336
  1.05 *
2342
- Math.pow(osuBase.ErrorFunction.erf(32.0625 / (Math.SQRT2 * this._deviation)), 1.5);
2337
+ Math.sqrt(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)));
2338
+ // OD 7 SS stays the same.
2339
+ aimValue *= 0.98 + Math.pow(7, 2) / 2500;
2340
+ return aimValue;
2343
2341
  }
2344
2342
  /**
2345
2343
  * Calculates the tap performance value of the beatmap.
2346
2344
  */
2347
2345
  calculateTapValue() {
2348
- this.tap = this.baseValue(this.difficultyAttributes.tapDifficulty);
2349
- this.tap *= this.calculateMissPenalty(this.difficultyAttributes.tapDifficultStrainCount);
2346
+ let tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
2347
+ tapValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.tapDifficultStrainCount);
2348
+ // Scale the tap value with estimated full combo deviation.
2349
+ // Require more objects to be present as object count can rack up easily in tap-oriented beatmaps.
2350
+ tapValue *= this.calculateDeviationBasedLengthScaling(this.totalHits / 1.45);
2350
2351
  // Normalize the deviation to 300 BPM.
2351
2352
  const normalizedDeviation = this.tapDeviation *
2352
2353
  Math.max(1, 50 / this.difficultyAttributes.averageSpeedDeltaTime);
@@ -2360,11 +2361,14 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2360
2361
  Math.exp(-(normalizedDeviation - 7500 / averageBPM) /
2361
2362
  ((2 * 300) / averageBPM))));
2362
2363
  // Scale the tap value with tap deviation.
2363
- this.tap *=
2364
+ tapValue *=
2364
2365
  1.1 *
2365
- Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * adjustedDeviation)), 1.25);
2366
+ Math.pow(osuBase.ErrorFunction.erf(20 / (Math.SQRT2 * adjustedDeviation)), 0.625);
2366
2367
  // Scale the tap value with three-fingered penalty.
2367
- this.tap /= this._tapPenalty;
2368
+ tapValue /= this._tapPenalty;
2369
+ // OD 8 SS stays the same.
2370
+ tapValue *= 0.95 + Math.pow(8, 2) / 750;
2371
+ return tapValue;
2368
2372
  }
2369
2373
  /**
2370
2374
  * Calculates the accuracy performance value of the beatmap.
@@ -2372,77 +2376,75 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2372
2376
  calculateAccuracyValue() {
2373
2377
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax) ||
2374
2378
  this.totalSuccessfulHits === 0) {
2375
- this.accuracy = 0;
2376
- return;
2379
+ return 0;
2377
2380
  }
2378
- this.accuracy =
2379
- 650 *
2380
- Math.exp(-0.1 * this._deviation) *
2381
- // The following function is to give higher reward for deviations lower than 25 (250 UR).
2382
- (15 / (this._deviation + 15) + 0.65);
2383
- // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
2381
+ let accuracyValue = 800 * Math.exp(-0.1 * this._deviation);
2384
2382
  const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
2385
2383
  ? this.totalHits - this.difficultyAttributes.spinnerCount
2386
2384
  : this.difficultyAttributes.hitCircleCount;
2387
- this.accuracy *= Math.min(1.15, Math.sqrt(Math.log(1 + ((Math.E - 1) * ncircles) / 1000)));
2385
+ // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
2386
+ accuracyValue *= Math.min(1.15, Math.sqrt(Math.log(1 + ((Math.E - 1) * ncircles) / 1000)));
2388
2387
  // Scale the accuracy value with rhythm complexity.
2389
- this.accuracy *=
2388
+ accuracyValue *=
2390
2389
  1.5 /
2391
2390
  (1 +
2392
2391
  Math.exp(-(this.difficultyAttributes.rhythmDifficulty - 1) / 2));
2392
+ // Penalize accuracy pp after the first miss.
2393
+ accuracyValue *= Math.pow(0.97, Math.max(0, this.effectiveMissCount - 1));
2393
2394
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2394
- this.accuracy *= 1.02;
2395
+ accuracyValue *= 1.02;
2395
2396
  }
2397
+ return accuracyValue;
2396
2398
  }
2397
2399
  /**
2398
2400
  * Calculates the flashlight performance value of the beatmap.
2399
2401
  */
2400
2402
  calculateFlashlightValue() {
2401
2403
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2402
- this.flashlight = 0;
2403
- return;
2404
+ return 0;
2404
2405
  }
2405
- this.flashlight =
2406
- Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
2407
- this.flashlight *= this.calculateMissPenalty(this.difficultyAttributes.flashlightDifficultStrainCount);
2406
+ let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
2407
+ flashlightValue *= this.proportionalMissPenalty;
2408
2408
  // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
2409
- this.flashlight *=
2409
+ flashlightValue *=
2410
2410
  0.7 +
2411
2411
  0.1 * Math.min(1, this.totalHits / 200) +
2412
2412
  (this.totalHits > 200
2413
2413
  ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
2414
2414
  : 0);
2415
2415
  // Scale the flashlight value with slider cheese penalty.
2416
- this.flashlight *= this._flashlightSliderCheesePenalty;
2416
+ flashlightValue *= this._flashlightSliderCheesePenalty;
2417
2417
  // Scale the flashlight value with deviation.
2418
- this.flashlight *= osuBase.ErrorFunction.erf(50 / (Math.SQRT2 * this._deviation));
2418
+ flashlightValue *= osuBase.ErrorFunction.erf(50 / (Math.SQRT2 * this._deviation));
2419
+ return flashlightValue;
2419
2420
  }
2420
2421
  /**
2421
2422
  * Calculates the visual performance value of the beatmap.
2422
2423
  */
2423
2424
  calculateVisualValue() {
2424
- this.visual =
2425
- Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
2426
- this.visual *= this.calculateMissPenalty(this.difficultyAttributes.visualDifficultStrainCount);
2427
- // Scale the visual value with object count to penalize short maps.
2428
- this.visual *= Math.min(1, 1.650668 +
2429
- (0.4845796 - 1.650668) /
2430
- (1 + Math.pow(this.totalHits / 817.9306, 1.147469)));
2425
+ let visualValue = Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
2426
+ visualValue *= this.proportionalMissPenalty;
2427
+ // Scale the visual value with estimated full combo deviation.
2428
+ // As visual is easily "bypassable" with memorization, punish for memorization.
2429
+ visualValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
2431
2430
  // Scale the visual value with slider cheese penalty.
2432
- this.visual *= this._visualSliderCheesePenalty;
2431
+ visualValue *= this._visualSliderCheesePenalty;
2433
2432
  // Scale the visual value with deviation.
2434
- this.visual *=
2433
+ visualValue *=
2435
2434
  1.065 *
2436
- Math.pow(osuBase.ErrorFunction.erf(30 / (Math.SQRT2 * this._deviation)), 1.75);
2435
+ Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.8);
2436
+ // OD 5 SS stays the same.
2437
+ visualValue *= 0.98 + Math.pow(5, 2) / 2500;
2438
+ return visualValue;
2437
2439
  }
2438
2440
  /**
2439
- * Calculates miss penalty.
2441
+ * Calculates a strain-based miss penalty.
2440
2442
  *
2441
- * Miss penalty assumes that a player will miss on the hardest parts of a map,
2443
+ * Strain-based miss penalty assumes that a player will miss on the hardest parts of a map,
2442
2444
  * so we use the amount of relatively difficult sections to adjust miss penalty
2443
2445
  * to make it more punishing on maps with lower amount of hard sections.
2444
2446
  */
2445
- calculateMissPenalty(difficultStrainCount) {
2447
+ calculateStrainBasedMissPenalty(difficultStrainCount) {
2446
2448
  if (this.effectiveMissCount === 0) {
2447
2449
  return 1;
2448
2450
  }
@@ -2450,6 +2452,52 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2450
2452
  (this.effectiveMissCount / (2 * Math.sqrt(difficultStrainCount)) +
2451
2453
  1));
2452
2454
  }
2455
+ /**
2456
+ * The object-based proportional miss penalty.
2457
+ */
2458
+ get proportionalMissPenalty() {
2459
+ if (this.effectiveMissCount === 0) {
2460
+ return 1;
2461
+ }
2462
+ const missProportion = (this.totalHits - this.effectiveMissCount) / (this.totalHits + 1);
2463
+ const noMissProportion = this.totalHits / (this.totalHits + 1);
2464
+ return (
2465
+ // Aim deviation-based scale.
2466
+ (osuBase.ErrorFunction.erfInv(missProportion) /
2467
+ osuBase.ErrorFunction.erfInv(noMissProportion)) *
2468
+ // Cheesing-based scale (i.e. 50% misses is deliberately only hitting each other
2469
+ // note, 90% misses is deliberately only hitting 1 note every 10 notes).
2470
+ Math.pow(missProportion, 8));
2471
+ }
2472
+ /**
2473
+ * Calculates the object-based length scaling based on the deviation of a player for a full
2474
+ * combo in this beatmap, taking retries into account.
2475
+ *
2476
+ * @param objectCount The amount of objects to be considered. Defaults to the amount of
2477
+ * objects in this beatmap.
2478
+ * @param punishForMemorization Whether to punish the deviation for memorization. Defaults to `false`.
2479
+ */
2480
+ calculateDeviationBasedLengthScaling(objectCount = this.totalHits, punishForMemorization = false) {
2481
+ // Assume a sample proportion of hits for a full combo to be `(n - 0.5) / n` due to
2482
+ // continuity correction, where `n` is the object count.
2483
+ const calculateProportion = (notes) => (notes - 0.5) / notes;
2484
+ // Keeping `x` notes as the benchmark, assume that a player will retry a beatmap
2485
+ // `max(1, x/n)` times relative to an `x`-note beatmap.
2486
+ const benchmarkNotes = 700;
2487
+ // Calculate the proportion equivalent to the bottom half of retry count percentile of
2488
+ // scores and take it as the player's "real" proportion.
2489
+ const retryProportion = (proportion, notes, tries) => proportion +
2490
+ Math.sqrt((2 * proportion * (1 - proportion)) / notes) *
2491
+ osuBase.ErrorFunction.erfInv(1 / tries - 1);
2492
+ // Using the proportion, we calculate the deviation based off that proportion and again
2493
+ // compared to the hit deviation for proportion `(n - 0.5) / n`.
2494
+ let multiplier = Math.max(0, osuBase.ErrorFunction.erfInv(retryProportion(calculateProportion(objectCount), objectCount, Math.max(1, benchmarkNotes / objectCount))) / osuBase.ErrorFunction.erfInv(calculateProportion(benchmarkNotes)) || 0);
2495
+ // Punish for memorization if needed.
2496
+ if (punishForMemorization) {
2497
+ multiplier *= Math.min(1, Math.sqrt(objectCount / benchmarkNotes));
2498
+ }
2499
+ return multiplier;
2500
+ }
2453
2501
  /**
2454
2502
  * Estimates the player's tap deviation based on the OD, number of circles and sliders,
2455
2503
  * and number of 300s, 100s, 50s, and misses, assuming the player's mean hit error is 0.
@@ -2470,60 +2518,60 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2470
2518
  }
2471
2519
  const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).hitWindowFor300();
2472
2520
  // Obtain the 50 and 100 hit window for droid.
2473
- const realHitWindow300 = hitWindow300 * this.difficultyAttributes.clockRate;
2474
- const droidHitWindow = new osuBase.DroidHitWindow(osuBase.OsuHitWindow.hitWindow300ToOD(realHitWindow300));
2475
2521
  const isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
2522
+ const droidHitWindow = new osuBase.DroidHitWindow(osuBase.DroidHitWindow.hitWindow300ToOD(hitWindow300 * this.difficultyAttributes.clockRate, isPrecise));
2476
2523
  const hitWindow50 = droidHitWindow.hitWindowFor50(isPrecise) /
2477
2524
  this.difficultyAttributes.clockRate;
2478
2525
  const hitWindow100 = droidHitWindow.hitWindowFor100(isPrecise) /
2479
2526
  this.difficultyAttributes.clockRate;
2480
- const { n300, n100, n50, nmiss } = this.computedAccuracy;
2481
- const greatCountOnCircles = this.difficultyAttributes.hitCircleCount - n100 - n50 - nmiss;
2482
- // The probability that a player hits a circle is unknown, but we can estimate it to be
2483
- // the number of greats on circles divided by the number of circles, and then add one
2484
- // to the number of circles as a bias correction / bayesian prior.
2485
- const greatProbabilityCircle = Math.max(0, greatCountOnCircles / (this.difficultyAttributes.hitCircleCount + 1));
2486
- let greatProbabilitySlider;
2487
- if (greatCountOnCircles < 0) {
2488
- const nonCircleMisses = -greatCountOnCircles;
2489
- greatProbabilitySlider = Math.max(0, (this.difficultyAttributes.sliderCount - nonCircleMisses) /
2490
- (this.difficultyAttributes.sliderCount + 1));
2491
- }
2492
- else {
2493
- greatProbabilitySlider =
2494
- this.difficultyAttributes.sliderCount /
2495
- (this.difficultyAttributes.sliderCount + 1);
2496
- }
2497
- if (greatProbabilityCircle === 0 && greatProbabilitySlider === 0) {
2498
- return Number.POSITIVE_INFINITY;
2499
- }
2500
- const calculateDeviation = (mainHitWindow, probability) => {
2501
- if (probability === 0) {
2502
- return Number.POSITIVE_INFINITY;
2503
- }
2504
- // Start with normal deviation.
2505
- const normalDeviation = mainHitWindow /
2506
- (Math.SQRT2 * osuBase.ErrorFunction.erfInv(probability));
2527
+ const { n100, n50, nmiss } = this.computedAccuracy;
2528
+ const circleCount = this.difficultyAttributes.hitCircleCount;
2529
+ const missCountCircles = Math.min(nmiss, circleCount);
2530
+ const mehCountCircles = Math.min(n50, circleCount - missCountCircles);
2531
+ const okCountCircles = Math.min(n100, circleCount - missCountCircles - mehCountCircles);
2532
+ const greatCountCircles = Math.max(0, circleCount - missCountCircles - mehCountCircles - okCountCircles);
2533
+ // Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
2534
+ // compute the deviation on circles.
2535
+ if (greatCountCircles > 0) {
2536
+ // The probability that a player hits a circle is unknown, but we can estimate it to be
2537
+ // the number of greats on circles divided by the number of circles, and then add one
2538
+ // to the number of circles as a bias correction / bayesian prior.
2539
+ const greatProbabilityCircle = Math.max(0, greatCountCircles / (greatCountCircles + okCountCircles + 1));
2540
+ // Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
2541
+ // Begin with the normal distribution first.
2542
+ const deviationOnCircles = hitWindow300 /
2543
+ (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
2507
2544
  // Get the variance of the truncated variable.
2508
- const truncatedVariance = Math.pow(normalDeviation, 2) -
2509
- (Math.SQRT2 *
2545
+ const truncatedVariance = Math.pow(deviationOnCircles, 2) -
2546
+ (Math.sqrt(2 / Math.PI) *
2510
2547
  hitWindow100 *
2511
- normalDeviation *
2512
- Math.exp(-0.5 * Math.pow(hitWindow100 / normalDeviation, 2))) /
2513
- (Math.sqrt(Math.PI) *
2514
- osuBase.ErrorFunction.erf(hitWindow100 / (Math.SQRT2 * normalDeviation)));
2515
- // Add 50s by assuming they are uniformly distributed.
2516
- return Math.sqrt((1 / (n300 + n100 + n50)) *
2517
- ((n300 + n100) * truncatedVariance +
2518
- (n50 *
2519
- (Math.pow(hitWindow50, 2) +
2520
- hitWindow100 * hitWindow50 +
2521
- Math.pow(hitWindow100, 2))) /
2522
- 3));
2523
- };
2524
- const deviationOnCircles = calculateDeviation(hitWindow300, greatProbabilityCircle);
2525
- const deviationOnSliders = calculateDeviation(hitWindow50, greatProbabilitySlider);
2526
- return Math.min(deviationOnCircles, deviationOnSliders);
2548
+ deviationOnCircles *
2549
+ Math.exp(-0.5 * Math.pow(hitWindow100 / deviationOnCircles, 2))) /
2550
+ osuBase.ErrorFunction.erf(hitWindow100 / (Math.SQRT2 * deviationOnCircles));
2551
+ // Then compute the variance for 50s.
2552
+ const mehVariance = (Math.pow(hitWindow50, 2) +
2553
+ hitWindow100 * hitWindow50 +
2554
+ Math.pow(hitWindow100, 2)) /
2555
+ 3;
2556
+ // Find the total deviation.
2557
+ return Math.sqrt(((greatCountCircles + okCountCircles) * truncatedVariance +
2558
+ mehCountCircles * mehVariance) /
2559
+ (greatCountCircles + okCountCircles + mehCountCircles));
2560
+ }
2561
+ // If there are more non-300s than there are circles, compute the deviation on sliders instead.
2562
+ // Here, all that matters is whether or not the slider was missed, since it is impossible
2563
+ // to get a 100 or 50 on a slider by mis-tapping it.
2564
+ const sliderCount = this.difficultyAttributes.sliderCount;
2565
+ const missCountSliders = Math.min(sliderCount, nmiss - missCountCircles);
2566
+ const greatCountSliders = sliderCount - missCountSliders;
2567
+ // We only get here if nothing was hit. In this case, there is no estimate for deviation.
2568
+ // Note that this is never negative, so checking if this is only equal to 0 makes sense.
2569
+ if (greatCountSliders === 0) {
2570
+ return Number.POSITIVE_INFINITY;
2571
+ }
2572
+ const greatProbabilitySlider = greatCountSliders / (sliderCount + 1);
2573
+ return (hitWindow50 /
2574
+ (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilitySlider)));
2527
2575
  }
2528
2576
  /**
2529
2577
  * Does the same as {@link calculateDeviation}, but only for notes and inaccuracies that are relevant to tap difficulty.
@@ -2536,15 +2584,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2536
2584
  return Number.POSITIVE_INFINITY;
2537
2585
  }
2538
2586
  const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).hitWindowFor300();
2539
- // Obtain the 50 and 100 hit window for droid.
2540
- const realHitWindow300 = hitWindow300 * this.difficultyAttributes.clockRate;
2541
- const droidHitWindow = new osuBase.DroidHitWindow(osuBase.OsuHitWindow.hitWindow300ToOD(realHitWindow300));
2542
- const isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
2543
- const hitWindow50 = droidHitWindow.hitWindowFor50(isPrecise) /
2544
- this.difficultyAttributes.clockRate;
2545
- const hitWindow100 = droidHitWindow.hitWindowFor100(isPrecise) /
2546
- this.difficultyAttributes.clockRate;
2547
- const { n300, n100, n50, nmiss } = this.computedAccuracy;
2587
+ const { n100, n50, nmiss } = this.computedAccuracy;
2548
2588
  // Assume a fixed ratio of non-300s hit in speed notes based on speed note count ratio and OD.
2549
2589
  // Graph: https://www.desmos.com/calculator/31argjcxqc
2550
2590
  const speedNoteRatio = this.difficultyAttributes.speedNoteCount / this.totalHits;
@@ -2559,25 +2599,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2559
2599
  return Number.POSITIVE_INFINITY;
2560
2600
  }
2561
2601
  const greatProbability = relevantCountGreat / (this.difficultyAttributes.speedNoteCount + 1);
2562
- // Start with normal deviation.
2563
- const normalDeviation = hitWindow300 /
2564
- (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbability));
2565
- // Get the variance of the truncated variable.
2566
- const truncatedVariance = Math.pow(normalDeviation, 2) -
2567
- (Math.SQRT2 *
2568
- hitWindow100 *
2569
- normalDeviation *
2570
- Math.exp(-0.5 * Math.pow(hitWindow100 / normalDeviation, 2))) /
2571
- (Math.sqrt(Math.PI) *
2572
- osuBase.ErrorFunction.erf(hitWindow100 / (Math.SQRT2 * normalDeviation)));
2573
- // Add 50s by assuming they are uniformly distributed.
2574
- return Math.sqrt((1 / (n300 + n100 + n50)) *
2575
- ((n300 + n100) * truncatedVariance +
2576
- (n50 *
2577
- (Math.pow(hitWindow50, 2) +
2578
- hitWindow100 * hitWindow50 +
2579
- Math.pow(hitWindow100, 2))) /
2580
- 3));
2602
+ return (hitWindow300 / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbability)));
2581
2603
  }
2582
2604
  toString() {
2583
2605
  return (this.total.toFixed(2) +
@@ -2764,36 +2786,37 @@ class OsuAimEvaluator extends AimEvaluator {
2764
2786
  * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
2765
2787
  */
2766
2788
  class OsuAim extends OsuSkill {
2767
- skillMultiplier = 23.55;
2768
2789
  strainDecayBase = 0.15;
2769
2790
  reducedSectionCount = 10;
2770
2791
  reducedSectionBaseline = 0.75;
2771
- difficultyMultiplier = 1.06;
2772
2792
  decayWeight = 0.9;
2793
+ currentAimStrain = 0;
2794
+ skillMultiplier = 23.55;
2773
2795
  withSliders;
2774
2796
  constructor(mods, withSliders) {
2775
2797
  super(mods);
2776
2798
  this.withSliders = withSliders;
2777
2799
  }
2778
- /**
2779
- * @param current The hitobject to calculate.
2780
- */
2781
2800
  strainValueAt(current) {
2782
- this.currentStrain *= this.strainDecay(current.deltaTime);
2783
- this.currentStrain +=
2801
+ this.currentAimStrain *= this.strainDecay(current.deltaTime);
2802
+ this.currentAimStrain +=
2784
2803
  OsuAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
2785
2804
  this.skillMultiplier;
2786
- return this.currentStrain;
2805
+ return this.currentAimStrain;
2806
+ }
2807
+ calculateInitialStrain(time, current) {
2808
+ return (this.currentAimStrain *
2809
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
2787
2810
  }
2788
2811
  /**
2789
2812
  * @param current The hitobject to save to.
2790
2813
  */
2791
2814
  saveToHitObject(current) {
2792
2815
  if (this.withSliders) {
2793
- current.aimStrainWithSliders = this.currentStrain;
2816
+ current.aimStrainWithSliders = this.currentAimStrain;
2794
2817
  }
2795
2818
  else {
2796
- current.aimStrainWithoutSliders = this.currentStrain;
2819
+ current.aimStrainWithoutSliders = this.currentAimStrain;
2797
2820
  }
2798
2821
  }
2799
2822
  }
@@ -2965,7 +2988,6 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
2965
2988
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
2966
2989
  */
2967
2990
  class OsuSpeed extends OsuSkill {
2968
- skillMultiplier = 1375;
2969
2991
  strainDecayBase = 0.3;
2970
2992
  reducedSectionCount = 5;
2971
2993
  reducedSectionBaseline = 0.75;
@@ -2973,6 +2995,7 @@ class OsuSpeed extends OsuSkill {
2973
2995
  decayWeight = 0.9;
2974
2996
  currentSpeedStrain = 0;
2975
2997
  currentRhythm = 0;
2998
+ skillMultiplier = 1375;
2976
2999
  greatWindow;
2977
3000
  constructor(mods, greatWindow) {
2978
3001
  super(mods);
@@ -2989,11 +3012,16 @@ class OsuSpeed extends OsuSkill {
2989
3012
  this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current, this.greatWindow);
2990
3013
  return this.currentSpeedStrain * this.currentRhythm;
2991
3014
  }
3015
+ calculateInitialStrain(time, current) {
3016
+ return (this.currentSpeedStrain *
3017
+ this.currentRhythm *
3018
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
3019
+ }
2992
3020
  /**
2993
3021
  * @param current The hitobject to save to.
2994
3022
  */
2995
3023
  saveToHitObject(current) {
2996
- current.tapStrain = this.currentStrain;
3024
+ current.tapStrain = this.currentSpeedStrain * this.currentRhythm;
2997
3025
  current.rhythmMultiplier = this.currentRhythm;
2998
3026
  }
2999
3027
  }
@@ -3084,27 +3112,29 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3084
3112
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
3085
3113
  */
3086
3114
  class OsuFlashlight extends OsuSkill {
3087
- skillMultiplier = 0.052;
3088
3115
  strainDecayBase = 0.15;
3089
3116
  reducedSectionCount = 0;
3090
3117
  reducedSectionBaseline = 1;
3091
3118
  decayWeight = 1;
3119
+ currentFlashlightStrain = 0;
3120
+ skillMultiplier = 0.052;
3092
3121
  isHidden;
3093
3122
  constructor(mods) {
3094
3123
  super(mods);
3095
3124
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
3096
3125
  }
3097
- /**
3098
- * @param current The hitobject to calculate.
3099
- */
3100
3126
  strainValueAt(current) {
3101
- this.currentStrain *= this.strainDecay(current.deltaTime);
3102
- this.currentStrain +=
3127
+ this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
3128
+ this.currentFlashlightStrain +=
3103
3129
  OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden) * this.skillMultiplier;
3104
- return this.currentStrain;
3130
+ return this.currentFlashlightStrain;
3131
+ }
3132
+ calculateInitialStrain(time, current) {
3133
+ return (this.currentFlashlightStrain *
3134
+ this.strainDecay(time - (current.previous(0)?.startTime ?? 0)));
3105
3135
  }
3106
3136
  saveToHitObject(current) {
3107
- current.flashlightStrainWithSliders = this.currentStrain;
3137
+ current.flashlightStrainWithSliders = this.currentFlashlightStrain;
3108
3138
  }
3109
3139
  }
3110
3140
 
@@ -3367,6 +3397,18 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3367
3397
  difficultyAttributes;
3368
3398
  finalMultiplier = 1.14;
3369
3399
  mode = osuBase.Modes.osu;
3400
+ calculateValues() {
3401
+ this.aim = this.calculateAimValue();
3402
+ this.speed = this.calculateSpeedValue();
3403
+ this.accuracy = this.calculateAccuracyValue();
3404
+ this.flashlight = this.calculateFlashlightValue();
3405
+ }
3406
+ calculateTotalValue() {
3407
+ return (Math.pow(Math.pow(this.aim, 1.1) +
3408
+ Math.pow(this.speed, 1.1) +
3409
+ Math.pow(this.accuracy, 1.1) +
3410
+ Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier);
3411
+ }
3370
3412
  /**
3371
3413
  * @param difficultyAttributes The difficulty attributes to calculate.
3372
3414
  */
@@ -3374,39 +3416,27 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3374
3416
  super();
3375
3417
  this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
3376
3418
  }
3377
- calculateValues() {
3378
- this.calculateAimValue();
3379
- this.calculateSpeedValue();
3380
- this.calculateAccuracyValue();
3381
- this.calculateFlashlightValue();
3382
- }
3383
- calculateTotalValue() {
3384
- this.total =
3385
- Math.pow(Math.pow(this.aim, 1.1) +
3386
- Math.pow(this.speed, 1.1) +
3387
- Math.pow(this.accuracy, 1.1) +
3388
- Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier;
3389
- }
3390
3419
  /**
3391
3420
  * Calculates the aim performance value of the beatmap.
3392
3421
  */
3393
3422
  calculateAimValue() {
3394
- this.aim = this.baseValue(this.difficultyAttributes.aimDifficulty);
3423
+ let aimValue = this.baseValue(this.difficultyAttributes.aimDifficulty);
3395
3424
  // Longer maps are worth more
3396
3425
  let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
3397
3426
  if (this.totalHits > 2000) {
3398
3427
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
3399
3428
  }
3400
- this.aim *= lengthBonus;
3429
+ aimValue *= lengthBonus;
3401
3430
  if (this.effectiveMissCount > 0) {
3402
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3403
- this.aim *=
3431
+ // Penalize misses by assessing # of misses relative to the total # of objects.
3432
+ // Default a 3% reduction for any # of misses.
3433
+ aimValue *=
3404
3434
  0.97 *
3405
3435
  Math.pow(1 -
3406
3436
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
3407
3437
  }
3408
3438
  // Combo scaling
3409
- this.aim *= this.comboPenalty;
3439
+ aimValue *= this.comboPenalty;
3410
3440
  const calculatedAR = this.difficultyAttributes.approachRate;
3411
3441
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3412
3442
  // AR scaling
@@ -3418,66 +3448,70 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3418
3448
  arFactor += 0.05 * (8 - calculatedAR);
3419
3449
  }
3420
3450
  // Buff for longer maps with high AR.
3421
- this.aim *= 1 + arFactor * lengthBonus;
3451
+ aimValue *= 1 + arFactor * lengthBonus;
3422
3452
  }
3423
3453
  // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
3424
3454
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3425
- this.aim *= 1 + 0.04 * (12 - calculatedAR);
3455
+ aimValue *= 1 + 0.04 * (12 - calculatedAR);
3426
3456
  }
3427
3457
  // Scale the aim value with slider factor to nerf very likely dropped sliderends.
3428
- this.aim *= this.sliderNerfFactor;
3458
+ aimValue *= this.sliderNerfFactor;
3429
3459
  // Scale the aim value with accuracy.
3430
- this.aim *= this.computedAccuracy.value();
3460
+ aimValue *= this.computedAccuracy.value();
3431
3461
  // It is also important to consider accuracy difficulty when doing that.
3432
3462
  const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
3433
- this.aim *= 0.98 + odScaling;
3463
+ aimValue *= 0.98 + odScaling;
3464
+ return aimValue;
3434
3465
  }
3435
3466
  /**
3436
3467
  * Calculates the speed performance value of the beatmap.
3437
3468
  */
3438
3469
  calculateSpeedValue() {
3439
3470
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3440
- this.speed = 0;
3441
- return;
3471
+ return 0;
3442
3472
  }
3443
- // Global variables
3444
- this.speed = this.baseValue(this.difficultyAttributes.speedDifficulty);
3473
+ let speedValue = this.baseValue(this.difficultyAttributes.speedDifficulty);
3445
3474
  // Longer maps are worth more
3446
3475
  let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
3447
3476
  if (this.totalHits > 2000) {
3448
3477
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
3449
3478
  }
3450
- this.speed *= lengthBonus;
3479
+ speedValue *= lengthBonus;
3451
3480
  if (this.effectiveMissCount > 0) {
3452
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3453
- this.speed *=
3481
+ // Penalize misses by assessing # of misses relative to the total # of objects.
3482
+ // Default a 3% reduction for any # of misses.
3483
+ speedValue *=
3454
3484
  0.97 *
3455
3485
  Math.pow(1 -
3456
3486
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
3457
3487
  }
3458
3488
  // Combo scaling
3459
- this.speed *= this.comboPenalty;
3489
+ speedValue *= this.comboPenalty;
3460
3490
  // AR scaling
3461
3491
  const calculatedAR = this.difficultyAttributes.approachRate;
3462
3492
  if (calculatedAR > 10.33) {
3463
3493
  // Buff for longer maps with high AR.
3464
- this.speed *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
3494
+ speedValue *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
3465
3495
  }
3466
3496
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3467
- this.speed *= 1 + 0.04 * (12 - calculatedAR);
3497
+ speedValue *= 1 + 0.04 * (12 - calculatedAR);
3468
3498
  }
3469
3499
  // Calculate accuracy assuming the worst case scenario.
3470
3500
  const countGreat = this.computedAccuracy.n300;
3471
3501
  const countOk = this.computedAccuracy.n100;
3472
3502
  const countMeh = this.computedAccuracy.n50;
3473
3503
  const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
3474
- const relevantAccuracy = new osuBase.Accuracy({
3475
- n300: Math.max(0, countGreat - relevantTotalDiff),
3476
- n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
3477
- n50: Math.max(0, countMeh - Math.max(0, relevantTotalDiff - countGreat - countOk)),
3478
- });
3504
+ const relevantAccuracy = new osuBase.Accuracy(this.difficultyAttributes.speedNoteCount > 0
3505
+ ? {
3506
+ n300: Math.max(0, countGreat - relevantTotalDiff),
3507
+ n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
3508
+ n50: Math.max(0, countMeh -
3509
+ Math.max(0, relevantTotalDiff - countGreat - countOk)),
3510
+ }
3511
+ : // Set accuracy to 0.
3512
+ { n300: 0, nobjects: 1 });
3479
3513
  // Scale the speed value with accuracy and OD.
3480
- this.speed *=
3514
+ speedValue *=
3481
3515
  (0.95 +
3482
3516
  Math.pow(this.difficultyAttributes.overallDifficulty, 2) /
3483
3517
  750) *
@@ -3487,22 +3521,21 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3487
3521
  Math.max(this.difficultyAttributes.overallDifficulty, 8)) /
3488
3522
  2);
3489
3523
  // Scale the speed value with # of 50s to punish doubletapping.
3490
- this.speed *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
3524
+ speedValue *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
3525
+ return speedValue;
3491
3526
  }
3492
3527
  /**
3493
3528
  * Calculates the accuracy performance value of the beatmap.
3494
3529
  */
3495
3530
  calculateAccuracyValue() {
3496
3531
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3497
- this.accuracy = 0;
3498
- return;
3532
+ return 0;
3499
3533
  }
3500
3534
  const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
3501
3535
  ? this.totalHits - this.difficultyAttributes.spinnerCount
3502
3536
  : this.difficultyAttributes.hitCircleCount;
3503
3537
  if (ncircles === 0) {
3504
- this.accuracy = 0;
3505
- return;
3538
+ return 0;
3506
3539
  }
3507
3540
  const realAccuracy = new osuBase.Accuracy({
3508
3541
  ...this.computedAccuracy,
@@ -3510,52 +3543,50 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3510
3543
  });
3511
3544
  // Lots of arbitrary values from testing.
3512
3545
  // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
3513
- this.accuracy =
3514
- Math.pow(1.52163, this.difficultyAttributes.overallDifficulty) *
3515
- // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
3516
- Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
3517
- 2.83;
3546
+ let accuracyValue = Math.pow(1.52163, this.difficultyAttributes.overallDifficulty) *
3547
+ // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
3548
+ Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
3549
+ 2.83;
3518
3550
  // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
3519
- this.accuracy *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
3551
+ accuracyValue *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
3520
3552
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3521
- this.accuracy *= 1.08;
3553
+ accuracyValue *= 1.08;
3522
3554
  }
3523
3555
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3524
- this.accuracy *= 1.02;
3556
+ accuracyValue *= 1.02;
3525
3557
  }
3558
+ return accuracyValue;
3526
3559
  }
3527
3560
  /**
3528
3561
  * Calculates the flashlight performance value of the beatmap.
3529
3562
  */
3530
3563
  calculateFlashlightValue() {
3531
3564
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3532
- this.flashlight = 0;
3533
- return;
3565
+ return 0;
3534
3566
  }
3535
- // Global variables
3536
- this.flashlight =
3537
- Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
3567
+ let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
3538
3568
  // Combo scaling
3539
- this.flashlight *= this.comboPenalty;
3569
+ flashlightValue *= this.comboPenalty;
3540
3570
  if (this.effectiveMissCount > 0) {
3541
3571
  // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3542
- this.flashlight *=
3572
+ flashlightValue *=
3543
3573
  0.97 *
3544
3574
  Math.pow(1 -
3545
3575
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
3546
3576
  }
3547
3577
  // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
3548
- this.flashlight *=
3578
+ flashlightValue *=
3549
3579
  0.7 +
3550
3580
  0.1 * Math.min(1, this.totalHits / 200) +
3551
3581
  (this.totalHits > 200
3552
3582
  ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
3553
3583
  : 0);
3554
3584
  // Scale the flashlight value with accuracy slightly.
3555
- this.flashlight *= 0.5 + this.computedAccuracy.value() / 2;
3585
+ flashlightValue *= 0.5 + this.computedAccuracy.value() / 2;
3556
3586
  // It is also important to consider accuracy difficulty when doing that.
3557
3587
  const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
3558
- this.flashlight *= 0.98 + odScaling;
3588
+ flashlightValue *= 0.98 + odScaling;
3589
+ return flashlightValue;
3559
3590
  }
3560
3591
  toString() {
3561
3592
  return (this.total.toFixed(2) +