@rian8337/osu-difficulty-calculator 4.0.0-beta.76 → 4.0.0-beta.78

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
@@ -249,12 +249,8 @@ class DifficultyHitObject {
249
249
  * Computes the properties of this hitobject.
250
250
  *
251
251
  * @param clockRate The clock rate of the beatmap.
252
- * @param hitObjects The hitobjects in the beatmap.
253
252
  */
254
- computeProperties(clockRate,
255
- // Required for `DroidDifficultyHitObject` override.
256
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
257
- hitObjects) {
253
+ computeProperties(clockRate) {
258
254
  this.calculateSliderCursorPosition();
259
255
  this.setDistances(clockRate);
260
256
  }
@@ -348,7 +344,11 @@ class DifficultyHitObject {
348
344
  return;
349
345
  }
350
346
  // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
351
- const { scalingFactor } = this;
347
+ let scalingFactor = DifficultyHitObject.normalizedRadius / this.object.radius;
348
+ // High circle size (small CS) bonus
349
+ if (this.mode === osuBase.Modes.osu && this.object.radius < 30) {
350
+ scalingFactor *= 1 + Math.min(30 - this.object.radius, 5) / 50;
351
+ }
352
352
  const lastCursorPosition = this.lastDifficultyObject !== null
353
353
  ? this.getEndCursorPosition(this.lastDifficultyObject)
354
354
  : this.lastObject.stackedPosition;
@@ -503,16 +503,8 @@ DifficultyHitObject.minDeltaTime = 25;
503
503
  * Represents an osu!droid hit object with difficulty calculation values.
504
504
  */
505
505
  class DroidDifficultyHitObject extends DifficultyHitObject {
506
- get scalingFactor() {
507
- const radius = this.object.radius;
508
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
509
- let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
510
- // High circle size (small CS) bonus
511
- if (radius < this.radiusBuffThreshold) {
512
- scalingFactor *=
513
- 1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
514
- }
515
- return scalingFactor;
506
+ get smallCircleBonus() {
507
+ return Math.max(1, 1 + Math.pow((70 - this.object.radius) / 50, 2));
516
508
  }
517
509
  /**
518
510
  * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
@@ -547,13 +539,9 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
547
539
  */
548
540
  this.flashlightStrainWithoutSliders = 0;
549
541
  /**
550
- * The visual strain generated by the hitobject if sliders are considered.
551
- */
552
- this.visualStrainWithSliders = 0;
553
- /**
554
- * The visual strain generated by the hitobject if sliders are not considered.
542
+ * The reading difficulty generated by the hitobject.
555
543
  */
556
- this.visualStrainWithoutSliders = 0;
544
+ this.readingDifficulty = 0;
557
545
  /**
558
546
  * The note density of the hitobject.
559
547
  */
@@ -569,10 +557,6 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
569
557
  this.maximumSliderRadius = DifficultyHitObject.normalizedRadius * 2;
570
558
  this.timePreempt = object.timePreempt / clockRate;
571
559
  }
572
- computeProperties(clockRate, hitObjects) {
573
- super.computeProperties(clockRate, hitObjects);
574
- this.setVisuals(clockRate, hitObjects);
575
- }
576
560
  opacityAt(time, mods) {
577
561
  // Traceable hides the primary piece of a hit circle (that is, its body), so consider it as fully invisible.
578
562
  if (this.object instanceof osuBase.Circle && mods.has(osuBase.ModTraceable)) {
@@ -649,60 +633,6 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
649
633
  }
650
634
  return true;
651
635
  }
652
- setVisuals(clockRate, hitObjects) {
653
- // We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
654
- // while the second array contains objects after the current object ends.
655
- // For overlapping factor, we also need to consider previous visible objects.
656
- const prevVisibleObjects = [];
657
- const nextVisibleObjects = [];
658
- for (let j = this.index + 2; j < hitObjects.length; ++j) {
659
- const o = hitObjects[j];
660
- if (o instanceof osuBase.Spinner) {
661
- continue;
662
- }
663
- if (o.startTime / clockRate > this.endTime + this.timePreempt) {
664
- break;
665
- }
666
- nextVisibleObjects.push(o);
667
- }
668
- for (let j = 0; j < this.index; ++j) {
669
- const prev = this.previous(j);
670
- if (prev.object instanceof osuBase.Spinner) {
671
- continue;
672
- }
673
- if (prev.startTime >= this.startTime) {
674
- continue;
675
- }
676
- if (prev.startTime < this.startTime - this.timePreempt) {
677
- break;
678
- }
679
- prevVisibleObjects.push(prev.object);
680
- }
681
- for (const hitObject of prevVisibleObjects) {
682
- const distance = this.object.stackedPosition.getDistance(hitObject.stackedEndPosition);
683
- const deltaTime = this.startTime - hitObject.endTime / clockRate;
684
- this.applyToOverlappingFactor(distance, deltaTime);
685
- }
686
- for (const hitObject of nextVisibleObjects) {
687
- const distance = hitObject.stackedPosition.getDistance(this.object.stackedEndPosition);
688
- const deltaTime = hitObject.startTime / clockRate - this.endTime;
689
- if (deltaTime >= 0) {
690
- this.noteDensity += 1 - deltaTime / this.timePreempt;
691
- }
692
- this.applyToOverlappingFactor(distance, deltaTime);
693
- }
694
- }
695
- applyToOverlappingFactor(distance, deltaTime) {
696
- // Penalize objects that are too close to the object in both distance
697
- // and delta time to prevent stream maps from being overweighted.
698
- this.overlappingFactor +=
699
- Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
700
- (7.5 /
701
- (1 +
702
- Math.exp(0.15 *
703
- (Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
704
- 75))));
705
- }
706
636
  }
707
637
 
708
638
  /**
@@ -740,6 +670,7 @@ class DroidAimEvaluator {
740
670
  }
741
671
  const last = current.previous(0);
742
672
  const lastLast = current.previous(1);
673
+ const last2 = current.previous(2);
743
674
  const radius = DroidDifficultyHitObject.normalizedRadius;
744
675
  const diameter = DroidDifficultyHitObject.normalizedDiameter;
745
676
  // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
@@ -771,36 +702,35 @@ class DroidAimEvaluator {
771
702
  let wiggleBonus = 0;
772
703
  // Start strain with regular velocity.
773
704
  let strain = currentVelocity;
774
- if (
775
- // If rhythms are the same.
776
- Math.max(current.strainTime, last.strainTime) <
777
- 1.25 * Math.min(current.strainTime, last.strainTime) &&
778
- current.angle !== null &&
779
- last.angle !== null) {
705
+ if (current.angle !== null && last.angle !== null) {
780
706
  const currentAngle = current.angle;
781
707
  const lastAngle = last.angle;
782
708
  // Rewarding angles, take the smaller velocity as base.
783
709
  const angleBonus = Math.min(currentVelocity, prevVelocity);
710
+ // If rhythms are the same.
711
+ if (Math.max(current.strainTime, last.strainTime) <
712
+ 1.25 * Math.min(current.strainTime, last.strainTime)) {
713
+ acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
714
+ acuteAngleBonus *=
715
+ 0.08 +
716
+ 0.92 *
717
+ (1 -
718
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
719
+ // Apply acute angle bonus for BPM above 300 1/2 and distance more than 1.25 diameter
720
+ acuteAngleBonus *=
721
+ angleBonus *
722
+ osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 450) *
723
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter * 1.25, diameter * 2.5);
724
+ }
784
725
  wideAngleBonus = this.calculateWideAngleBonus(current.angle);
785
- acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
786
726
  // Penalize angle repetition.
787
727
  wideAngleBonus *=
788
728
  1 -
789
729
  Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
790
- acuteAngleBonus *=
791
- 0.08 +
792
- 0.92 *
793
- (1 -
794
- Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
795
730
  // Apply full wide angle bonus for distance more than one diameter
796
731
  wideAngleBonus *=
797
732
  angleBonus *
798
733
  osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
799
- // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
800
- acuteAngleBonus *=
801
- angleBonus *
802
- osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
803
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
804
734
  // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
805
735
  // https://www.desmos.com/calculator/dp0v0nvowc
806
736
  wiggleBonus =
@@ -811,6 +741,15 @@ class DroidAimEvaluator {
811
741
  osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
812
742
  Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
813
743
  osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
744
+ if (last2 !== null) {
745
+ // If objects just go back and forth through a middle point - don't give as much wide bonus.
746
+ // Use previous(2) and previous(0) because angles calculation is done prevprev-prev-curr, so any
747
+ // object's angle's center point is always the previous object.
748
+ const distance = last2.object.stackedPosition.getDistance(last.object.stackedPosition);
749
+ if (distance < 1) {
750
+ wideAngleBonus *= 1 - 0.35 * (1 - distance);
751
+ }
752
+ }
814
753
  }
815
754
  if (Math.max(prevVelocity, currentVelocity)) {
816
755
  // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
@@ -821,8 +760,8 @@ class DroidAimEvaluator {
821
760
  (current.lazyJumpDistance + last.travelDistance) /
822
761
  current.strainTime;
823
762
  // Scale with ratio of difference compared to half the max distance.
824
- const distanceRatio = Math.pow(Math.sin(((Math.PI / 2) * Math.abs(prevVelocity - currentVelocity)) /
825
- Math.max(prevVelocity, currentVelocity)), 2);
763
+ const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
764
+ Math.max(prevVelocity, currentVelocity), 0, 1);
826
765
  // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
827
766
  const overlapVelocityBuff = Math.min(125 / Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
828
767
  velocityChangeBonus = overlapVelocityBuff * distanceRatio;
@@ -835,9 +774,11 @@ class DroidAimEvaluator {
835
774
  sliderBonus = last.travelDistance / last.travelTime;
836
775
  }
837
776
  strain += wiggleBonus * this.wiggleMultiplier;
838
- // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
839
- strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
840
- velocityChangeBonus * this.velocityChangeMultiplier);
777
+ strain += velocityChangeBonus * this.velocityChangeMultiplier;
778
+ // Add in acute angle bonus or wide angle bonus, whichever is larger.
779
+ strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier);
780
+ // Apply high circle size bonus
781
+ strain *= current.smallCircleBonus;
841
782
  // Add in additional slider velocity bonus.
842
783
  if (withSliders) {
843
784
  strain +=
@@ -861,7 +802,12 @@ class DroidAimEvaluator {
861
802
  const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
862
803
  const distance = travelDistance + current.minimumJumpDistance;
863
804
  const shortDistancePenalty = Math.min(1, Math.pow(distance / this.singleSpacingThreshold, 3.5));
864
- return (200 * speedBonus * shortDistancePenalty) / current.strainTime;
805
+ return ((200 *
806
+ speedBonus *
807
+ shortDistancePenalty *
808
+ // Apply reduced small circle bonus for flow aim difficulty since it does not scale as hard as snap aim.
809
+ Math.sqrt(current.smallCircleBonus)) /
810
+ current.strainTime);
865
811
  }
866
812
  static calculateWideAngleBonus(angle) {
867
813
  return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
@@ -870,8 +816,8 @@ class DroidAimEvaluator {
870
816
  return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
871
817
  }
872
818
  }
873
- DroidAimEvaluator.wideAngleMultiplier = 1.5;
874
- DroidAimEvaluator.acuteAngleMultiplier = 2.6;
819
+ DroidAimEvaluator.wideAngleMultiplier = 1.6;
820
+ DroidAimEvaluator.acuteAngleMultiplier = 2.4;
875
821
  DroidAimEvaluator.sliderMultiplier = 1.35;
876
822
  DroidAimEvaluator.velocityChangeMultiplier = 0.75;
877
823
  DroidAimEvaluator.wiggleMultiplier = 1.02;
@@ -944,7 +890,7 @@ class StrainSkill extends Skill {
944
890
  *
945
891
  * The result is scaled by clock rate as it affects the total number of strains.
946
892
  */
947
- countDifficultStrains() {
893
+ countTopWeightedStrains() {
948
894
  if (this.difficulty === 0) {
949
895
  return 0;
950
896
  }
@@ -1034,21 +980,18 @@ class DroidAim extends DroidSkill {
1034
980
  this.skillMultiplier = 26.5;
1035
981
  this.currentAimStrain = 0;
1036
982
  this.sliderStrains = [];
983
+ this.maxSliderStrain = 0;
1037
984
  this.withSliders = withSliders;
1038
985
  }
1039
986
  /**
1040
987
  * Obtains the amount of sliders that are considered difficult in terms of relative strain.
1041
988
  */
1042
989
  countDifficultSliders() {
1043
- if (this.sliderStrains.length === 0) {
1044
- return 0;
1045
- }
1046
- const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
1047
- if (maxSliderStrain === 0) {
990
+ if (this.sliderStrains.length === 0 || this.maxSliderStrain === 0) {
1048
991
  return 0;
1049
992
  }
1050
993
  return this.sliderStrains.reduce((total, strain) => total +
1051
- 1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
994
+ 1 / (1 + Math.exp(-((strain / this.maxSliderStrain) * 12 - 6))), 0);
1052
995
  }
1053
996
  strainValueAt(current) {
1054
997
  this.currentAimStrain *= this.strainDecay(current.deltaTime);
@@ -1057,6 +1000,7 @@ class DroidAim extends DroidSkill {
1057
1000
  this.skillMultiplier;
1058
1001
  if (current.object instanceof osuBase.Slider) {
1059
1002
  this.sliderStrains.push(this.currentAimStrain);
1003
+ this.maxSliderStrain = Math.max(this.maxSliderStrain, this.currentAimStrain);
1060
1004
  }
1061
1005
  return this.currentAimStrain;
1062
1006
  }
@@ -1089,10 +1033,10 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
1089
1033
  super(cacheableAttributes);
1090
1034
  this.tapDifficulty = 0;
1091
1035
  this.rhythmDifficulty = 0;
1092
- this.visualDifficulty = 0;
1036
+ this.readingDifficulty = 0;
1093
1037
  this.tapDifficultStrainCount = 0;
1094
1038
  this.flashlightDifficultStrainCount = 0;
1095
- this.visualDifficultStrainCount = 0;
1039
+ this.readingDifficultNoteCount = 0;
1096
1040
  this.averageSpeedDeltaTime = 0;
1097
1041
  this.vibroFactor = 1;
1098
1042
  if (!cacheableAttributes) {
@@ -1100,13 +1044,13 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
1100
1044
  }
1101
1045
  this.tapDifficulty = cacheableAttributes.tapDifficulty;
1102
1046
  this.rhythmDifficulty = cacheableAttributes.rhythmDifficulty;
1103
- this.visualDifficulty = cacheableAttributes.visualDifficulty;
1047
+ this.readingDifficulty = cacheableAttributes.readingDifficulty;
1104
1048
  this.tapDifficultStrainCount =
1105
1049
  cacheableAttributes.tapDifficultStrainCount;
1106
1050
  this.flashlightDifficultStrainCount =
1107
1051
  cacheableAttributes.flashlightDifficultStrainCount;
1108
- this.visualDifficultStrainCount =
1109
- cacheableAttributes.visualDifficultStrainCount;
1052
+ this.readingDifficultNoteCount =
1053
+ cacheableAttributes.readingDifficultNoteCount;
1110
1054
  this.averageSpeedDeltaTime = cacheableAttributes.averageSpeedDeltaTime;
1111
1055
  this.vibroFactor = cacheableAttributes.vibroFactor;
1112
1056
  }
@@ -1116,7 +1060,7 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
1116
1060
  `${this.tapDifficulty.toFixed(2)} tap, ` +
1117
1061
  `${this.rhythmDifficulty.toFixed(2)} rhythm, ` +
1118
1062
  `${this.flashlightDifficulty.toFixed(2)} flashlight, ` +
1119
- `${this.visualDifficulty.toFixed(2)} visual)`);
1063
+ `${this.readingDifficulty.toFixed(2)} reading)`);
1120
1064
  }
1121
1065
  }
1122
1066
 
@@ -1265,6 +1209,312 @@ class DroidFlashlight extends DroidSkill {
1265
1209
  }
1266
1210
  }
1267
1211
 
1212
+ /**
1213
+ * An evaluator for calculating osu!droid reading skill.
1214
+ */
1215
+ class DroidReadingEvaluator {
1216
+ static evaluateDifficultyOf(current, clockRate, mods) {
1217
+ if (current.object instanceof osuBase.Spinner ||
1218
+ // Exclude overlapping objects that can be tapped at once.
1219
+ current.isOverlapping(true) ||
1220
+ current.index <= 0) {
1221
+ return 0;
1222
+ }
1223
+ const constantAngleNerfFactor = this.getConstantAngleNerfFactor(current);
1224
+ // Only allow velocity to buff.
1225
+ const velocityFactor = Math.max(1, current.minimumJumpDistance / current.strainTime);
1226
+ let pastObjectDifficultyInfluence = 0;
1227
+ for (const prev of this.retrievePastVisibleObjects(current)) {
1228
+ let prevDifficulty = current.opacityAt(prev.object.startTime, this.emptyModMap);
1229
+ // Small distances mean objects may be cheesed, so it does not matter whether they are arranged confusingly.
1230
+ prevDifficulty *= osuBase.MathUtils.smootherstep(prev.lazyJumpDistance, 15, this.distanceInfluenceThreshold);
1231
+ // Account less for objects close to the maximum reading window.
1232
+ prevDifficulty *= this.getTimeNerfFactor(current.startTime - prev.startTime);
1233
+ pastObjectDifficultyInfluence += prevDifficulty;
1234
+ }
1235
+ // Value higher note densities exponentially.
1236
+ let noteDensityDifficulty = Math.pow(pastObjectDifficultyInfluence, 1.45) *
1237
+ 0.9 *
1238
+ constantAngleNerfFactor *
1239
+ velocityFactor;
1240
+ // Award only denser than average beatmaps.
1241
+ noteDensityDifficulty = Math.max(0, noteDensityDifficulty - this.densityDifficultyBase);
1242
+ // Apply a soft cap to general density reading to account for partial memorization.
1243
+ noteDensityDifficulty =
1244
+ Math.pow(noteDensityDifficulty, 0.8) * this.densityMultiplier;
1245
+ let hiddenDifficulty = 0;
1246
+ if (mods.has(osuBase.ModHidden)) {
1247
+ const timeSpentInvisible = this.getDurationSpentInvisible(current) / clockRate;
1248
+ // Value time spent invisible exponentially.
1249
+ const timeSpentInvisibleFactor = Math.pow(timeSpentInvisible, 2.1) * 0.0001;
1250
+ // Buff current object if upcoming objects are dense. This is on the basis that part of
1251
+ // Hidden difficulty is the uncertainty of the current cursor position in relation to
1252
+ // future notes.
1253
+ const futureObjectDifficultyInfluence = this.calculateCurrentVisibleObjectsDensity(current);
1254
+ // Account for both past and current densities.
1255
+ const densityFactor = Math.pow(Math.max(1, futureObjectDifficultyInfluence +
1256
+ pastObjectDifficultyInfluence -
1257
+ 2), 2.2) * 3.1;
1258
+ hiddenDifficulty +=
1259
+ (timeSpentInvisibleFactor + densityFactor) *
1260
+ constantAngleNerfFactor *
1261
+ velocityFactor *
1262
+ 0.007;
1263
+ // Apply a soft cap to general Hidden reading to account for partial memorization.
1264
+ hiddenDifficulty =
1265
+ Math.pow(hiddenDifficulty, 0.65) * this.hiddenMultiplier;
1266
+ const prev = current.previous(0);
1267
+ // Buff perfect stacks only if the current object is completely invisible at the
1268
+ // time the previous object was clicked.
1269
+ if (current.lazyJumpDistance === 0 &&
1270
+ current.opacityAt(prev.object.startTime + prev.object.timePreempt, mods) === 0 &&
1271
+ prev.startTime + prev.timePreempt > current.startTime) {
1272
+ hiddenDifficulty +=
1273
+ (this.hiddenMultiplier * 1303) /
1274
+ // Perfect stacks are harder the less time between notes.
1275
+ Math.pow(current.strainTime, 1.5);
1276
+ }
1277
+ }
1278
+ // Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
1279
+ // https://www.desmos.com/calculator/9v2wrms1ie
1280
+ const preemptDifficulty = (Math.pow((this.preemptStartingPoint -
1281
+ current.timePreempt +
1282
+ Math.abs(current.timePreempt - this.preemptStartingPoint)) /
1283
+ 2, 2.35) /
1284
+ this.preemptBalancingFactor) *
1285
+ constantAngleNerfFactor *
1286
+ velocityFactor;
1287
+ let sliderDifficulty = 0;
1288
+ if (current.object instanceof osuBase.Slider) {
1289
+ const scalingFactor = 50 / current.object.radius;
1290
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1291
+ const pixelTravelDistance = current.lazyTravelDistance / scalingFactor;
1292
+ const currentVelocity = pixelTravelDistance / current.travelTime;
1293
+ const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
1294
+ sliderDifficulty +=
1295
+ // Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
1296
+ Math.min(4, currentVelocity * 0.8) *
1297
+ // Longer sliders require more reading.
1298
+ (spanTravelDistance / 125);
1299
+ let cumulativeStrainTime = 0;
1300
+ // Reward for velocity changes based on last few sliders.
1301
+ for (let i = 0; i < Math.min(current.index, 4); ++i) {
1302
+ const last = current.previous(i);
1303
+ cumulativeStrainTime += last.strainTime;
1304
+ if (!(last.object instanceof osuBase.Slider) ||
1305
+ // Exclude overlapping objects that can be tapped at once.
1306
+ last.isOverlapping(true)) {
1307
+ continue;
1308
+ }
1309
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1310
+ const lastPixelTravelDistance = last.lazyTravelDistance / scalingFactor;
1311
+ const lastVelocity = lastPixelTravelDistance / last.travelTime;
1312
+ const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
1313
+ sliderDifficulty +=
1314
+ // Reward past sliders based on velocity changes, while also
1315
+ // avoiding overbuffing extremely fast velocity changes.
1316
+ Math.min(4, 0.8 * Math.abs(currentVelocity - lastVelocity)) *
1317
+ // Longer sliders require more reading.
1318
+ (lastSpanTravelDistance / 150) *
1319
+ // Avoid overbuffing past sliders.
1320
+ Math.min(1, 250 / cumulativeStrainTime);
1321
+ }
1322
+ }
1323
+ return (preemptDifficulty +
1324
+ hiddenDifficulty +
1325
+ noteDensityDifficulty +
1326
+ sliderDifficulty);
1327
+ }
1328
+ /**
1329
+ * Retrieves a list of objects that are visible at the point in time the current object needs to be hit.
1330
+ *
1331
+ * @param current The current object.
1332
+ */
1333
+ static *retrievePastVisibleObjects(current) {
1334
+ for (let i = 0; i < current.index; ++i) {
1335
+ const prev = current.previous(i);
1336
+ if (!prev ||
1337
+ current.startTime - prev.startTime > this.readingWindowSize ||
1338
+ // The previous object is not visible at the time the current object needs to be hit.
1339
+ prev.startTime + prev.timePreempt < current.startTime) {
1340
+ break;
1341
+ }
1342
+ if (prev.isOverlapping(true)) {
1343
+ continue;
1344
+ }
1345
+ yield prev;
1346
+ }
1347
+ }
1348
+ /**
1349
+ * Calculates the density of objects visible at the point in time the current object needs to be hit.
1350
+ *
1351
+ * @param current The current object.
1352
+ */
1353
+ static calculateCurrentVisibleObjectsDensity(current) {
1354
+ let visibleObjectCount = 0;
1355
+ let index = 0;
1356
+ let next = current.next(0);
1357
+ while (next) {
1358
+ if (next.startTime - current.startTime > this.readingWindowSize ||
1359
+ // The next object is not visible at the time the current object needs to be hit.
1360
+ current.startTime + current.timePreempt < next.startTime) {
1361
+ break;
1362
+ }
1363
+ if (next.isOverlapping(true)) {
1364
+ continue;
1365
+ }
1366
+ const timeNerfFactor = this.getTimeNerfFactor(next.startTime - current.startTime);
1367
+ visibleObjectCount +=
1368
+ next.opacityAt(current.object.startTime, this.emptyModMap) *
1369
+ timeNerfFactor;
1370
+ next = current.next(++index);
1371
+ }
1372
+ return visibleObjectCount;
1373
+ }
1374
+ /**
1375
+ * Returns the time an object spends invisible with the Hidden mod at the current approach rate.
1376
+ *
1377
+ * @param current The current object.
1378
+ */
1379
+ static getDurationSpentInvisible(current) {
1380
+ const { object } = current;
1381
+ const fadeOutStartTime = object.startTime - object.timePreempt + object.timeFadeIn;
1382
+ const fadeOutDuration = object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
1383
+ return (fadeOutStartTime +
1384
+ fadeOutDuration -
1385
+ (object.startTime - object.timePreempt));
1386
+ }
1387
+ /**
1388
+ * Calculates a factor of how often the current object's angle has been repeated in a certain time frame.
1389
+ * It does this by checking the difference in angle between current and past objects and sums them up
1390
+ * based on a range of similarity.
1391
+ *
1392
+ * @param current The current object.
1393
+ */
1394
+ static getConstantAngleNerfFactor(current) {
1395
+ const maxTimeLimit = 2000; // 2 seconds
1396
+ const minTimeLimit = 200;
1397
+ let constantAngleCount = 0;
1398
+ let index = 0;
1399
+ let currentTimeGap = 0;
1400
+ while (currentTimeGap < maxTimeLimit) {
1401
+ const loopObj = current.previous(index);
1402
+ if (!loopObj) {
1403
+ break;
1404
+ }
1405
+ if (loopObj.angle !== null && current.angle !== null) {
1406
+ const angleDifference = Math.abs(current.angle - loopObj.angle);
1407
+ // Account less for objects that are close to the time limit.
1408
+ const longIntervalFactor = osuBase.MathUtils.clamp(1 -
1409
+ (loopObj.strainTime - minTimeLimit) /
1410
+ (maxTimeLimit - minTimeLimit), 0, 1);
1411
+ constantAngleCount +=
1412
+ Math.cos(3 * Math.min(Math.PI / 6, angleDifference)) *
1413
+ longIntervalFactor;
1414
+ }
1415
+ currentTimeGap = current.startTime - loopObj.startTime;
1416
+ index++;
1417
+ }
1418
+ return osuBase.MathUtils.clamp(2 / constantAngleCount, 0.2, 1);
1419
+ }
1420
+ static getTimeNerfFactor(deltaTime) {
1421
+ return osuBase.MathUtils.clamp(2 - deltaTime / (this.readingWindowSize / 2), 0, 1);
1422
+ }
1423
+ }
1424
+ DroidReadingEvaluator.emptyModMap = new osuBase.ModMap();
1425
+ DroidReadingEvaluator.readingWindowSize = 3000; // 3 seconds
1426
+ DroidReadingEvaluator.distanceInfluenceThreshold = DroidDifficultyHitObject.normalizedDiameter * 1.25; // 1.25 circles distance between centers
1427
+ DroidReadingEvaluator.hiddenMultiplier = 0.85;
1428
+ DroidReadingEvaluator.densityMultiplier = 0.8;
1429
+ DroidReadingEvaluator.densityDifficultyBase = 1.5;
1430
+ DroidReadingEvaluator.preemptBalancingFactor = 220000;
1431
+ DroidReadingEvaluator.preemptStartingPoint = 475; // AR 9.83 in milliseconds
1432
+
1433
+ /**
1434
+ * Represents the skill required to read every object in the map.
1435
+ */
1436
+ class DroidReading extends Skill {
1437
+ constructor(mods, clockRate, hitObjects) {
1438
+ super(mods);
1439
+ this.clockRate = clockRate;
1440
+ this.hitObjects = hitObjects;
1441
+ this.noteDifficulties = [];
1442
+ this.strainDecayBase = 0.8;
1443
+ this.skillMultiplier = 2;
1444
+ this.currentNoteDifficulty = 0;
1445
+ this.difficulty = 0;
1446
+ this.noteWeightSum = 0;
1447
+ }
1448
+ process(current) {
1449
+ this.currentNoteDifficulty *= this.strainDecay(current.deltaTime);
1450
+ this.currentNoteDifficulty +=
1451
+ DroidReadingEvaluator.evaluateDifficultyOf(current, this.clockRate, this.mods) * this.skillMultiplier;
1452
+ const difficulty = this.currentNoteDifficulty * current.rhythmMultiplier;
1453
+ this.noteDifficulties.push(difficulty);
1454
+ current.readingDifficulty = difficulty;
1455
+ }
1456
+ difficultyValue() {
1457
+ if (this.hitObjects.length === 0) {
1458
+ return 0;
1459
+ }
1460
+ // Notes with 0 difficulty are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
1461
+ // These notes will not contribute to the difficulty.
1462
+ const peaks = this.noteDifficulties.filter((d) => d > 0);
1463
+ // Start time at first object.
1464
+ const reducedDuration = this.hitObjects[0].startTime / this.clockRate + 60 * 1000;
1465
+ // Assume the first few seconds are completely memorized.
1466
+ let reducedCount = 0;
1467
+ for (const object of this.hitObjects) {
1468
+ if (object.startTime / this.clockRate > reducedDuration) {
1469
+ break;
1470
+ }
1471
+ ++reducedCount;
1472
+ }
1473
+ for (let i = 0; i < Math.min(peaks.length, reducedCount); ++i) {
1474
+ peaks[i] *= Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / reducedCount, 0, 1)));
1475
+ }
1476
+ peaks.sort((a, b) => b - a);
1477
+ // Difficulty is the weighted sum of the highest notes.
1478
+ // We're sorting from highest to lowest note.
1479
+ this.difficulty = 0;
1480
+ this.noteWeightSum = 0;
1481
+ for (let i = 0; i < peaks.length; ++i) {
1482
+ // Use a harmonic sum for note which effectively buffs maps with more notes, especially if
1483
+ // note difficulties are consistent. Constants are arbitrary and give good values.
1484
+ // https://www.desmos.com/calculator/5eb60faf4c
1485
+ const weight = (1 + 1 / (1 + i)) / (Math.pow(i, 0.8) + 1 + 1 / (1 + i));
1486
+ if (weight === 0) {
1487
+ // Shortcut to avoid unnecessary iterations.
1488
+ break;
1489
+ }
1490
+ this.noteWeightSum += weight;
1491
+ this.difficulty += peaks[i] * weight;
1492
+ }
1493
+ return this.difficulty;
1494
+ }
1495
+ /**
1496
+ * Returns the number of relevant objects weighted against the top note.
1497
+ */
1498
+ countTopWeightedNotes() {
1499
+ if (this.noteDifficulties.length === 0 ||
1500
+ this.difficulty === 0 ||
1501
+ this.noteWeightSum === 0) {
1502
+ return 0;
1503
+ }
1504
+ // What would the top note be if all note values were identical
1505
+ const consistentTopNote = this.difficulty / this.noteWeightSum;
1506
+ if (consistentTopNote === 0) {
1507
+ return 0;
1508
+ }
1509
+ // Use a weighted sum of all notes. Constants are arbitrary and give nice values
1510
+ return this.noteDifficulties.reduce((total, next) => total +
1511
+ 1.1 / (1 + Math.exp(-5 * (next / consistentTopNote - 1.15))), 0);
1512
+ }
1513
+ strainDecay(ms) {
1514
+ return Math.pow(this.strainDecayBase, ms / 1000);
1515
+ }
1516
+ }
1517
+
1268
1518
  class Island {
1269
1519
  constructor(delta, deltaDifferenceEpsilon) {
1270
1520
  this.delta = Number.MAX_SAFE_INTEGER;
@@ -1346,21 +1596,23 @@ class DroidRhythmEvaluator {
1346
1596
  const noteDecay = (validPrevious.length - i) / validPrevious.length;
1347
1597
  // Either we're limited by time or limited by object count.
1348
1598
  const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
1349
- const currentDelta = currentObject.strainTime;
1350
- const prevDelta = prevObject.strainTime;
1351
- const lastDelta = lastObject.strainTime;
1352
- // Calculate how much current delta difference deserves a rhythm bonus
1353
- // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
1354
- const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
1355
- Math.max(prevDelta, currentDelta);
1599
+ // Use custom cap value to ensure that that at this point delta time is actually zero.
1600
+ const currentDelta = Math.max(currentObject.deltaTime, 1e-7);
1601
+ const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
1602
+ const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
1603
+ // Calculate how much current delta difference deserves a rhythm bonus.
1604
+ // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200).
1605
+ const deltaDifference = Math.max(prevDelta, currentDelta) /
1606
+ Math.min(prevDelta, currentDelta);
1607
+ // Take only the fractional part of the value since we are only interested in punishing multiples.
1608
+ const deltaDifferenceFraction = deltaDifference - Math.trunc(deltaDifference);
1356
1609
  const currentRatio = 1 +
1357
1610
  this.rhythmRatioMultiplier *
1358
- Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
1611
+ Math.min(0.5, osuBase.MathUtils.smoothstepBellCurve(deltaDifferenceFraction));
1359
1612
  // Reduce ratio bonus if delta difference is too big
1360
- const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
1361
- const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
1613
+ const differenceMultiplier = osuBase.MathUtils.clamp(2 - deltaDifference / 8, 0, 1);
1362
1614
  const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
1363
- let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
1615
+ let effectiveRatio = windowPenalty * currentRatio * differenceMultiplier;
1364
1616
  if (firstDeltaSwitch) {
1365
1617
  if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
1366
1618
  // Island is still progressing, count size.
@@ -1454,7 +1706,7 @@ class DroidRhythmEvaluator {
1454
1706
  DroidRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1455
1707
  DroidRhythmEvaluator.historyObjectsMax = 32;
1456
1708
  DroidRhythmEvaluator.rhythmOverallMultiplier = 0.95;
1457
- DroidRhythmEvaluator.rhythmRatioMultiplier = 12;
1709
+ DroidRhythmEvaluator.rhythmRatioMultiplier = 15;
1458
1710
 
1459
1711
  /**
1460
1712
  * Represents the skill required to properly follow a beatmap's rhythm.
@@ -1471,10 +1723,11 @@ class DroidRhythm extends DroidSkill {
1471
1723
  this.useSliderAccuracy = mods.has(osuBase.ModScoreV2);
1472
1724
  }
1473
1725
  strainValueAt(current) {
1474
- this.currentRhythmMultiplier =
1475
- DroidRhythmEvaluator.evaluateDifficultyOf(current, this.useSliderAccuracy);
1726
+ const rhythmMultiplier = DroidRhythmEvaluator.evaluateDifficultyOf(current, this.useSliderAccuracy);
1727
+ const doubletapness = 1 - current.doubletapness;
1476
1728
  this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
1477
- this.currentRhythmStrain += this.currentRhythmMultiplier - 1;
1729
+ this.currentRhythmStrain += (rhythmMultiplier - 1) * doubletapness;
1730
+ this.currentRhythmMultiplier = rhythmMultiplier * doubletapness;
1478
1731
  return this.currentRhythmStrain;
1479
1732
  }
1480
1733
  calculateInitialStrain(time, current) {
@@ -1552,6 +1805,7 @@ class DroidTap extends DroidSkill {
1552
1805
  this.currentRhythmMultiplier = 0;
1553
1806
  this.skillMultiplier = 1.375;
1554
1807
  this._objectDeltaTimes = [];
1808
+ this.maxStrain = 0;
1555
1809
  this.considerCheesability = considerCheesability;
1556
1810
  this.strainTimeCap = strainTimeCap;
1557
1811
  }
@@ -1559,33 +1813,27 @@ class DroidTap extends DroidSkill {
1559
1813
  * The amount of notes that are relevant to the difficulty.
1560
1814
  */
1561
1815
  relevantNoteCount() {
1562
- if (this._objectStrains.length === 0) {
1563
- return 0;
1564
- }
1565
- const maxStrain = osuBase.MathUtils.max(this._objectStrains);
1566
- if (maxStrain === 0) {
1816
+ if (this._objectStrains.length === 0 || this.maxStrain === 0) {
1567
1817
  return 0;
1568
1818
  }
1569
- return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
1819
+ return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / this.maxStrain) * 12 - 6))), 0);
1570
1820
  }
1571
1821
  /**
1572
1822
  * The delta time relevant to the difficulty.
1573
1823
  */
1574
1824
  relevantDeltaTime() {
1575
- if (this._objectStrains.length === 0) {
1576
- return 0;
1577
- }
1578
- const maxStrain = osuBase.MathUtils.max(this._objectStrains);
1579
- if (maxStrain === 0) {
1825
+ if (this._objectStrains.length === 0 || this.maxStrain === 0) {
1580
1826
  return 0;
1581
1827
  }
1582
1828
  return (this._objectDeltaTimes.reduce((total, next, index) => total +
1583
- (next * 1) /
1829
+ next /
1584
1830
  (1 +
1585
- Math.exp(-((this._objectStrains[index] / maxStrain) *
1831
+ Math.exp(-((this._objectStrains[index] /
1832
+ this.maxStrain) *
1586
1833
  25 -
1587
1834
  20))), 0) /
1588
- this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 25 - 20))), 0));
1835
+ this._objectStrains.reduce((total, next) => total +
1836
+ 1 / (1 + Math.exp(-((next / this.maxStrain) * 25 - 20))), 0));
1589
1837
  }
1590
1838
  strainValueAt(current) {
1591
1839
  this.currentTapStrain *= this.strainDecay(current.strainTime);
@@ -1593,7 +1841,9 @@ class DroidTap extends DroidSkill {
1593
1841
  DroidTapEvaluator.evaluateDifficultyOf(current, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
1594
1842
  this.currentRhythmMultiplier = current.rhythmMultiplier;
1595
1843
  this._objectDeltaTimes.push(current.deltaTime);
1596
- return this.currentTapStrain * current.rhythmMultiplier;
1844
+ const strain = this.currentTapStrain * this.currentRhythmMultiplier;
1845
+ this.maxStrain = Math.max(this.maxStrain, strain);
1846
+ return strain;
1597
1847
  }
1598
1848
  calculateInitialStrain(time, current) {
1599
1849
  var _a, _b;
@@ -1621,153 +1871,6 @@ class DroidTap extends DroidSkill {
1621
1871
  }
1622
1872
  }
1623
1873
 
1624
- /**
1625
- * An evaluator for calculating osu!droid visual skill.
1626
- */
1627
- class DroidVisualEvaluator {
1628
- /**
1629
- * Evaluates the difficulty of reading the current object, based on:
1630
- *
1631
- * - note density of the current object,
1632
- * - overlapping factor of the current object,
1633
- * - the preempt time of the current object,
1634
- * - the visual opacity of the current object,
1635
- * - the velocity of the current object if it's a slider,
1636
- * - past objects' velocity if they are sliders,
1637
- * - and whether the Hidden mod is enabled.
1638
- *
1639
- * @param current The current object.
1640
- * @param mods The mods used.
1641
- * @param withSliders Whether to take slider difficulty into account.
1642
- */
1643
- static evaluateDifficultyOf(current, mods, withSliders) {
1644
- if (current.object instanceof osuBase.Spinner ||
1645
- // Exclude overlapping objects that can be tapped at once.
1646
- current.isOverlapping(true) ||
1647
- current.index === 0) {
1648
- return 0;
1649
- }
1650
- // Start with base density and give global bonus for Hidden.
1651
- // Add density caps for sanity.
1652
- let strain;
1653
- if (mods.has(osuBase.ModHidden)) {
1654
- strain = Math.min(30, Math.pow(current.noteDensity, 3));
1655
- }
1656
- else if (mods.has(osuBase.ModTraceable)) {
1657
- // Give more bonus for hit circles due to there being no circle piece.
1658
- if (current.object instanceof osuBase.Circle) {
1659
- strain = Math.min(25, Math.pow(current.noteDensity, 2.5));
1660
- }
1661
- else {
1662
- strain = Math.min(22.5, Math.pow(current.noteDensity, 2.25));
1663
- }
1664
- }
1665
- else {
1666
- strain = Math.min(20, Math.pow(current.noteDensity, 2));
1667
- }
1668
- // Bonus based on how visible the object is.
1669
- for (let i = 0; i < Math.min(current.index, 10); ++i) {
1670
- const previous = current.previous(i);
1671
- if (previous.object instanceof osuBase.Spinner ||
1672
- // Exclude overlapping objects that can be tapped at once.
1673
- previous.isOverlapping(true)) {
1674
- continue;
1675
- }
1676
- // Do not consider objects that don't fall under time preempt.
1677
- if (current.object.startTime - previous.object.endTime >
1678
- current.object.timePreempt) {
1679
- break;
1680
- }
1681
- strain +=
1682
- (1 - current.opacityAt(previous.object.startTime, mods)) / 4;
1683
- }
1684
- if (current.timePreempt < 400) {
1685
- // Give bonus for AR higher than 10.33.
1686
- strain += Math.pow(400 - current.timePreempt, 1.35) / 100;
1687
- }
1688
- // Scale the value with overlapping factor.
1689
- strain /= 10 * (1 + current.overlappingFactor);
1690
- if (current.object instanceof osuBase.Slider && withSliders) {
1691
- const scalingFactor = 50 / current.object.radius;
1692
- // Invert the scaling factor to determine the true travel distance independent of circle size.
1693
- const pixelTravelDistance = current.lazyTravelDistance / scalingFactor;
1694
- const currentVelocity = pixelTravelDistance / current.travelTime;
1695
- const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
1696
- strain +=
1697
- // Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
1698
- Math.min(6, currentVelocity * 1.5) *
1699
- // Longer sliders require more reading.
1700
- (spanTravelDistance / 100);
1701
- let cumulativeStrainTime = 0;
1702
- // Reward for velocity changes based on last few sliders.
1703
- for (let i = 0; i < Math.min(current.index, 4); ++i) {
1704
- const last = current.previous(i);
1705
- cumulativeStrainTime += last.strainTime;
1706
- if (!(last.object instanceof osuBase.Slider) ||
1707
- // Exclude overlapping objects that can be tapped at once.
1708
- last.isOverlapping(true)) {
1709
- continue;
1710
- }
1711
- // Invert the scaling factor to determine the true travel distance independent of circle size.
1712
- const lastPixelTravelDistance = last.lazyTravelDistance / scalingFactor;
1713
- const lastVelocity = lastPixelTravelDistance / last.travelTime;
1714
- const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
1715
- strain +=
1716
- // Reward past sliders based on velocity changes, while also
1717
- // avoiding overbuffing extremely fast velocity changes.
1718
- Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
1719
- // Longer sliders require more reading.
1720
- (lastSpanTravelDistance / 125) *
1721
- // Avoid overbuffing past sliders.
1722
- Math.min(1, 300 / cumulativeStrainTime);
1723
- }
1724
- }
1725
- return strain;
1726
- }
1727
- }
1728
-
1729
- /**
1730
- * Represents the skill required to read every object in the map.
1731
- */
1732
- class DroidVisual extends DroidSkill {
1733
- constructor(mods, withSliders) {
1734
- super(mods);
1735
- this.starsPerDouble = 1.025;
1736
- this.reducedSectionCount = 10;
1737
- this.reducedSectionBaseline = 0.75;
1738
- this.strainDecayBase = 0.1;
1739
- this.currentVisualStrain = 0;
1740
- this.currentRhythmMultiplier = 1;
1741
- this.skillMultiplier = 11.2;
1742
- this.withSliders = withSliders;
1743
- }
1744
- strainValueAt(current) {
1745
- this.currentVisualStrain *= this.strainDecay(current.deltaTime);
1746
- this.currentVisualStrain +=
1747
- DroidVisualEvaluator.evaluateDifficultyOf(current, this.mods, this.withSliders) * this.skillMultiplier;
1748
- this.currentRhythmMultiplier = current.rhythmMultiplier;
1749
- return this.currentVisualStrain * this.currentRhythmMultiplier;
1750
- }
1751
- calculateInitialStrain(time, current) {
1752
- var _a, _b;
1753
- return (this.currentVisualStrain *
1754
- this.currentRhythmMultiplier *
1755
- this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
1756
- }
1757
- getObjectStrain() {
1758
- return this.currentVisualStrain * this.currentRhythmMultiplier;
1759
- }
1760
- saveToHitObject(current) {
1761
- const strain = this.currentVisualStrain * this.currentRhythmMultiplier;
1762
- if (this.withSliders) {
1763
- current.visualStrainWithSliders = strain;
1764
- }
1765
- else {
1766
- current.visualStrainWithoutSliders = strain;
1767
- }
1768
- }
1769
- }
1770
-
1771
1874
  /**
1772
1875
  * Holds data that can be used to calculate osu!droid performance points as well
1773
1876
  * as doing some analysis using the replay of a score.
@@ -1778,19 +1881,15 @@ class ExtendedDroidDifficultyAttributes extends DroidDifficultyAttributes {
1778
1881
  this.mode = "live";
1779
1882
  this.possibleThreeFingeredSections = [];
1780
1883
  this.difficultSliders = [];
1781
- this.aimNoteCount = 0;
1782
1884
  this.flashlightSliderFactor = 1;
1783
- this.visualSliderFactor = 1;
1784
1885
  if (!cacheableAttributes) {
1785
1886
  return;
1786
1887
  }
1787
1888
  this.possibleThreeFingeredSections =
1788
1889
  cacheableAttributes.possibleThreeFingeredSections;
1789
1890
  this.difficultSliders = cacheableAttributes.difficultSliders;
1790
- this.aimNoteCount = cacheableAttributes.aimNoteCount;
1791
1891
  this.flashlightSliderFactor =
1792
1892
  cacheableAttributes.flashlightSliderFactor;
1793
- this.visualSliderFactor = cacheableAttributes.visualSliderFactor;
1794
1893
  }
1795
1894
  }
1796
1895
 
@@ -1816,31 +1915,28 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1816
1915
  attributes.hitCircleCount = beatmap.hitObjects.circles;
1817
1916
  attributes.sliderCount = beatmap.hitObjects.sliders;
1818
1917
  attributes.spinnerCount = beatmap.hitObjects.spinners;
1918
+ let greatWindow;
1919
+ if (attributes.mods.has(osuBase.ModPrecise)) {
1920
+ greatWindow = new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
1921
+ .greatWindow;
1922
+ }
1923
+ else {
1924
+ greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).greatWindow;
1925
+ }
1926
+ attributes.overallDifficulty = osuBase.OsuHitWindow.greatWindowToOD(greatWindow / attributes.clockRate);
1819
1927
  this.populateAimAttributes(attributes, skills, objects);
1820
1928
  this.populateTapAttributes(attributes, skills, objects);
1821
1929
  this.populateRhythmAttributes(attributes, skills);
1822
1930
  this.populateFlashlightAttributes(attributes, skills);
1823
- this.populateVisualAttributes(attributes, skills);
1824
- if (beatmap.mods.has(osuBase.ModRelax)) {
1825
- attributes.aimDifficulty *= 0.9;
1826
- attributes.tapDifficulty = 0;
1827
- attributes.rhythmDifficulty = 0;
1828
- attributes.flashlightDifficulty *= 0.7;
1829
- attributes.visualDifficulty = 0;
1830
- }
1831
- else if (beatmap.mods.has(osuBase.ModAutopilot)) {
1832
- attributes.aimDifficulty = 0;
1833
- attributes.flashlightDifficulty *= 0.3;
1834
- attributes.visualDifficulty *= 0.8;
1835
- }
1931
+ this.populateReadingAttributes(attributes, skills);
1836
1932
  const aimPerformanceValue = this.basePerformanceValue(Math.pow(attributes.aimDifficulty, 0.8));
1837
1933
  const tapPerformanceValue = this.basePerformanceValue(attributes.tapDifficulty);
1838
1934
  const flashlightPerformanceValue = Math.pow(attributes.flashlightDifficulty, 1.6) * 25;
1839
- const visualPerformanceValue = Math.pow(attributes.visualDifficulty, 1.6) * 22.5;
1935
+ const readingPerformanceValue = Math.pow(Math.pow(attributes.readingDifficulty, 2) * 25, 0.8);
1840
1936
  const basePerformanceValue = Math.pow(Math.pow(aimPerformanceValue, 1.1) +
1841
1937
  Math.pow(tapPerformanceValue, 1.1) +
1842
1938
  Math.pow(flashlightPerformanceValue, 1.1) +
1843
- Math.pow(visualPerformanceValue, 1.1), 1 / 1.1);
1939
+ Math.pow(readingPerformanceValue, 1.1), 1 / 1.1);
1844
1940
  if (basePerformanceValue > 1e-5) {
1845
1941
  // Document for formula derivation:
1846
1942
  // https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
@@ -1852,15 +1948,6 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1852
1948
  else {
1853
1949
  attributes.starRating = 0;
1854
1950
  }
1855
- let greatWindow;
1856
- if (attributes.mods.has(osuBase.ModPrecise)) {
1857
- greatWindow = new osuBase.PreciseDroidHitWindow(beatmap.difficulty.od)
1858
- .greatWindow;
1859
- }
1860
- else {
1861
- greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).greatWindow;
1862
- }
1863
- attributes.overallDifficulty = osuBase.OsuHitWindow.greatWindowToOD(greatWindow / attributes.clockRate);
1864
1951
  return attributes;
1865
1952
  }
1866
1953
  createPlayableBeatmap(beatmap, mods) {
@@ -1873,7 +1960,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1873
1960
  const { objects } = beatmap.hitObjects;
1874
1961
  for (let i = 0; i < objects.length; ++i) {
1875
1962
  const difficultyObject = new DroidDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, difficultyObjects, clockRate, i - 1);
1876
- difficultyObject.computeProperties(clockRate, objects);
1963
+ difficultyObject.computeProperties(clockRate);
1877
1964
  difficultyObjects.push(difficultyObject);
1878
1965
  }
1879
1966
  return difficultyObjects;
@@ -1886,13 +1973,13 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1886
1973
  skills.push(new DroidAim(mods, false));
1887
1974
  }
1888
1975
  if (!mods.has(osuBase.ModRelax)) {
1889
- // Tap and visual skills depend on rhythm skill, so we put it first
1976
+ // Tap skills depend on rhythm skill, so we put it first
1890
1977
  skills.push(new DroidRhythm(mods));
1891
1978
  skills.push(new DroidTap(mods, true));
1892
1979
  skills.push(new DroidTap(mods, false));
1893
- skills.push(new DroidVisual(mods, true));
1894
- skills.push(new DroidVisual(mods, false));
1980
+ skills.push(new DroidTap(mods, true, 50));
1895
1981
  }
1982
+ skills.push(new DroidReading(mods, beatmap.speedMultiplier, beatmap.hitObjects.objects));
1896
1983
  if (mods.has(osuBase.ModFlashlight)) {
1897
1984
  skills.push(new DroidFlashlight(mods, true));
1898
1985
  skills.push(new DroidFlashlight(mods, false));
@@ -1911,12 +1998,18 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1911
1998
  populateAimAttributes(attributes, skills, objects) {
1912
1999
  const aim = skills.find((s) => s instanceof DroidAim && s.withSliders);
1913
2000
  const aimNoSlider = skills.find((s) => s instanceof DroidAim && !s.withSliders);
1914
- if (!aim || !aimNoSlider) {
2001
+ if (!aim || !aimNoSlider || attributes.mods.has(osuBase.ModAutopilot)) {
2002
+ attributes.aimDifficulty = 0;
2003
+ attributes.aimDifficultSliderCount = 0;
2004
+ attributes.aimDifficultStrainCount = 0;
1915
2005
  return;
1916
2006
  }
1917
2007
  attributes.aimDifficulty = this.calculateRating(aim);
1918
2008
  attributes.aimDifficultSliderCount = aim.countDifficultSliders();
1919
- attributes.aimDifficultStrainCount = aim.countDifficultStrains();
2009
+ attributes.aimDifficultStrainCount = aim.countTopWeightedStrains();
2010
+ if (attributes.mods.has(osuBase.ModRelax)) {
2011
+ attributes.aimDifficulty *= 0.9;
2012
+ }
1920
2013
  const topDifficultSliders = [];
1921
2014
  for (let i = 0; i < objects.length; ++i) {
1922
2015
  const object = objects[i];
@@ -1955,18 +2048,22 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1955
2048
  }
1956
2049
  populateTapAttributes(attributes, skills, objects) {
1957
2050
  const tap = skills.find((s) => s instanceof DroidTap && s.considerCheesability);
1958
- if (!tap) {
2051
+ const tapVibro = skills.find((s) => s instanceof DroidTap &&
2052
+ s.considerCheesability &&
2053
+ s.strainTimeCap !== undefined);
2054
+ if (!tap || !tapVibro || attributes.mods.has(osuBase.ModRelax)) {
2055
+ attributes.tapDifficulty = 0;
2056
+ attributes.tapDifficultStrainCount = 0;
2057
+ attributes.speedNoteCount = 0;
2058
+ attributes.averageSpeedDeltaTime = 0;
2059
+ attributes.vibroFactor = 1;
1959
2060
  return;
1960
2061
  }
1961
2062
  attributes.tapDifficulty = this.calculateRating(tap);
1962
- attributes.tapDifficultStrainCount = tap.countDifficultStrains();
2063
+ attributes.tapDifficultStrainCount = tap.countTopWeightedStrains();
1963
2064
  attributes.speedNoteCount = tap.relevantNoteCount();
1964
2065
  attributes.averageSpeedDeltaTime = tap.relevantDeltaTime();
1965
2066
  if (attributes.tapDifficulty > 0) {
1966
- const tapVibro = new DroidTap(attributes.mods, true, attributes.averageSpeedDeltaTime);
1967
- for (const object of objects) {
1968
- tapVibro.process(object);
1969
- }
1970
2067
  attributes.vibroFactor =
1971
2068
  this.calculateRating(tapVibro) / attributes.tapDifficulty;
1972
2069
  }
@@ -2013,20 +2110,29 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
2013
2110
  }
2014
2111
  populateRhythmAttributes(attributes, skills) {
2015
2112
  const rhythm = skills.find((s) => s instanceof DroidRhythm);
2016
- if (!rhythm) {
2017
- return;
2018
- }
2019
- attributes.rhythmDifficulty = this.calculateRating(rhythm);
2113
+ attributes.rhythmDifficulty =
2114
+ rhythm && !attributes.mods.has(osuBase.ModRelax)
2115
+ ? this.calculateRating(rhythm)
2116
+ : 0;
2020
2117
  }
2021
2118
  populateFlashlightAttributes(attributes, skills) {
2022
2119
  const flashlight = skills.find((s) => s instanceof DroidFlashlight && s.withSliders);
2023
2120
  const flashlightNoSliders = skills.find((s) => s instanceof DroidFlashlight && !s.withSliders);
2024
2121
  if (!flashlight || !flashlightNoSliders) {
2122
+ attributes.flashlightDifficulty = 0;
2123
+ attributes.flashlightDifficultStrainCount = 0;
2124
+ attributes.flashlightSliderFactor = 1;
2025
2125
  return;
2026
2126
  }
2027
2127
  attributes.flashlightDifficulty = this.calculateRating(flashlight);
2028
2128
  attributes.flashlightDifficultStrainCount =
2029
- flashlight.countDifficultStrains();
2129
+ flashlight.countTopWeightedStrains();
2130
+ if (attributes.mods.has(osuBase.ModRelax)) {
2131
+ attributes.flashlightDifficulty *= 0.7;
2132
+ }
2133
+ else if (attributes.mods.has(osuBase.ModAutopilot)) {
2134
+ attributes.flashlightDifficulty *= 0.4;
2135
+ }
2030
2136
  if (attributes.flashlightDifficulty > 0) {
2031
2137
  attributes.flashlightSliderFactor =
2032
2138
  this.calculateRating(flashlightNoSliders) /
@@ -2036,22 +2142,25 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
2036
2142
  attributes.flashlightSliderFactor = 1;
2037
2143
  }
2038
2144
  }
2039
- populateVisualAttributes(attributes, skills) {
2040
- const visual = skills.find((s) => s instanceof DroidVisual && s.withSliders);
2041
- const visualNoSliders = skills.find((s) => s instanceof DroidVisual && !s.withSliders);
2042
- if (!visual || !visualNoSliders) {
2145
+ populateReadingAttributes(attributes, skills) {
2146
+ const reading = skills.find((s) => s instanceof DroidReading);
2147
+ if (!reading) {
2148
+ attributes.readingDifficulty = 0;
2149
+ attributes.readingDifficultNoteCount = 0;
2043
2150
  return;
2044
2151
  }
2045
- attributes.visualDifficulty = this.calculateRating(visual);
2046
- attributes.visualDifficultStrainCount = visual.countDifficultStrains();
2047
- if (attributes.visualDifficulty > 0) {
2048
- attributes.visualSliderFactor =
2049
- this.calculateRating(visualNoSliders) /
2050
- attributes.visualDifficulty;
2152
+ attributes.readingDifficulty = this.calculateRating(reading);
2153
+ attributes.readingDifficultNoteCount = reading.countTopWeightedNotes();
2154
+ if (attributes.mods.has(osuBase.ModRelax)) {
2155
+ attributes.readingDifficulty *= 0.7;
2051
2156
  }
2052
- else {
2053
- attributes.visualSliderFactor = 1;
2157
+ else if (attributes.mods.has(osuBase.ModAutopilot)) {
2158
+ attributes.readingDifficulty *= 0.4;
2054
2159
  }
2160
+ // Consider accuracy difficulty.
2161
+ const ratingMultiplier = 0.75 +
2162
+ Math.pow(Math.max(0, attributes.overallDifficulty), 2.2) / 800;
2163
+ attributes.readingDifficulty *= Math.sqrt(ratingMultiplier);
2055
2164
  }
2056
2165
  }
2057
2166
  /**
@@ -2322,14 +2431,13 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2322
2431
  */
2323
2432
  this.flashlight = 0;
2324
2433
  /**
2325
- * The visual performance value.
2434
+ * The reading performance value.
2326
2435
  */
2327
- this.visual = 0;
2436
+ this.reading = 0;
2328
2437
  this.finalMultiplier = 1.24;
2329
2438
  this.mode = osuBase.Modes.droid;
2330
2439
  this._aimSliderCheesePenalty = 1;
2331
2440
  this._flashlightSliderCheesePenalty = 1;
2332
- this._visualSliderCheesePenalty = 1;
2333
2441
  this._tapPenalty = 1;
2334
2442
  this._deviation = 0;
2335
2443
  this._tapDeviation = 0;
@@ -2370,14 +2478,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2370
2478
  get flashlightSliderCheesePenalty() {
2371
2479
  return this._flashlightSliderCheesePenalty;
2372
2480
  }
2373
- /**
2374
- * The penalty used to penalize the visual performance value.
2375
- *
2376
- * Can be properly obtained by analyzing the replay associated with the score.
2377
- */
2378
- get visualSliderCheesePenalty() {
2379
- return this._visualSliderCheesePenalty;
2380
- }
2381
2481
  /**
2382
2482
  * Applies a tap penalty value to this calculator.
2383
2483
  *
@@ -2438,27 +2538,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2438
2538
  this.flashlight = this.calculateFlashlightValue();
2439
2539
  this.total = this.calculateTotalValue();
2440
2540
  }
2441
- /**
2442
- * Applies a visual slider cheese penalty value to this calculator.
2443
- *
2444
- * The visual and total performance value will be recalculated afterwards.
2445
- *
2446
- * @param value The slider cheese penalty value. Must be between 0 and 1.
2447
- */
2448
- applyVisualSliderCheesePenalty(value) {
2449
- if (value < 0) {
2450
- throw new RangeError("New visual slider cheese penalty must be greater than or equal to zero.");
2451
- }
2452
- if (value > 1) {
2453
- throw new RangeError("New visual slider cheese penalty must be less than or equal to one.");
2454
- }
2455
- if (value === this._visualSliderCheesePenalty) {
2456
- return;
2457
- }
2458
- this._visualSliderCheesePenalty = value;
2459
- this.visual = this.calculateVisualValue();
2460
- this.total = this.calculateTotalValue();
2461
- }
2462
2541
  calculateValues() {
2463
2542
  this._deviation = this.calculateDeviation();
2464
2543
  this._tapDeviation = this.calculateTapDeviation();
@@ -2466,23 +2545,21 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2466
2545
  this.tap = this.calculateTapValue();
2467
2546
  this.accuracy = this.calculateAccuracyValue();
2468
2547
  this.flashlight = this.calculateFlashlightValue();
2469
- this.visual = this.calculateVisualValue();
2548
+ this.reading = this.calculateReadingValue();
2470
2549
  }
2471
2550
  calculateTotalValue() {
2472
2551
  return (Math.pow(Math.pow(this.aim, 1.1) +
2473
2552
  Math.pow(this.tap, 1.1) +
2474
2553
  Math.pow(this.accuracy, 1.1) +
2475
2554
  Math.pow(this.flashlight, 1.1) +
2476
- Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier);
2555
+ Math.pow(this.reading, 1.1), 1 / 1.1) * this.finalMultiplier);
2477
2556
  }
2478
2557
  handleOptions(options) {
2479
- var _a, _b, _c, _d;
2558
+ var _a, _b, _c;
2480
2559
  this._tapPenalty = (_a = options === null || options === void 0 ? void 0 : options.tapPenalty) !== null && _a !== void 0 ? _a : 1;
2481
2560
  this._aimSliderCheesePenalty = (_b = options === null || options === void 0 ? void 0 : options.aimSliderCheesePenalty) !== null && _b !== void 0 ? _b : 1;
2482
2561
  this._flashlightSliderCheesePenalty =
2483
2562
  (_c = options === null || options === void 0 ? void 0 : options.flashlightSliderCheesePenalty) !== null && _c !== void 0 ? _c : 1;
2484
- this._visualSliderCheesePenalty =
2485
- (_d = options === null || options === void 0 ? void 0 : options.visualSliderCheesePenalty) !== null && _d !== void 0 ? _d : 1;
2486
2563
  super.handleOptions(options);
2487
2564
  }
2488
2565
  /**
@@ -2541,6 +2618,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2541
2618
  Math.exp((this._tapDeviation - 7500 / averageBPM) /
2542
2619
  ((2 * 300) / averageBPM))) +
2543
2620
  Math.pow(this.difficultyAttributes.vibroFactor, 6);
2621
+ tapValue *= this.calculateTapHighDeviationNerf();
2544
2622
  // Scale the tap value with three-fingered penalty.
2545
2623
  tapValue /= this._tapPenalty;
2546
2624
  // OD 8 SS stays the same.
@@ -2595,23 +2673,20 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2595
2673
  return flashlightValue;
2596
2674
  }
2597
2675
  /**
2598
- * Calculates the visual performance value of the beatmap.
2676
+ * Calculates the reading performance value of the beatmap.
2599
2677
  */
2600
- calculateVisualValue() {
2601
- let visualValue = Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
2602
- visualValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.visualDifficultStrainCount), this.proportionalMissPenalty);
2603
- // Scale the visual value with estimated full combo deviation.
2604
- // As visual is easily "bypassable" with memorization, punish for memorization.
2605
- visualValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
2606
- // Scale the visual value with slider cheese penalty.
2607
- visualValue *= this._visualSliderCheesePenalty;
2608
- // Scale the visual value with deviation.
2609
- visualValue *=
2610
- 1.05 *
2611
- Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.775);
2678
+ calculateReadingValue() {
2679
+ let readingValue = Math.pow(Math.pow(this.difficultyAttributes.readingDifficulty, 2) * 25, 0.8);
2680
+ readingValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.readingDifficultNoteCount), this.proportionalMissPenalty);
2681
+ // Scale the reading value with estimated full combo deviation.
2682
+ // As reading is easily "bypassable" with memorization, punish for memorization.
2683
+ readingValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
2684
+ // Scale the reading value with deviation.
2685
+ readingValue *=
2686
+ 1.05 * osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation));
2612
2687
  // OD 5 SS stays the same.
2613
- visualValue *= 0.98 + Math.pow(5, 2) / 2500;
2614
- return visualValue;
2688
+ readingValue *= 0.98 + Math.pow(5, 2) / 2500;
2689
+ return readingValue;
2615
2690
  }
2616
2691
  /**
2617
2692
  * The object-based proportional miss penalty.
@@ -2660,7 +2735,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2660
2735
  return multiplier;
2661
2736
  }
2662
2737
  /**
2663
- * Estimates the player's tap deviation based on the OD, number of circles and sliders,
2738
+ * Estimates the player's deviation based on the OD, number of circles and sliders,
2664
2739
  * and number of 300s, 100s, 50s, and misses, assuming the player's mean hit error is 0.
2665
2740
  *
2666
2741
  * The estimation is consistent in that two SS scores on the same map
@@ -2683,19 +2758,21 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2683
2758
  const hitWindow100 = hitWindow.okWindow / clockRate;
2684
2759
  const hitWindow50 = hitWindow.mehWindow / clockRate;
2685
2760
  const { n100, n50, nmiss } = this.computedAccuracy;
2686
- const circleCount = this.difficultyAttributes.hitCircleCount;
2687
- const missCountCircles = Math.min(nmiss, circleCount);
2688
- const mehCountCircles = Math.min(n50, circleCount - missCountCircles);
2689
- const okCountCircles = Math.min(n100, circleCount - missCountCircles - mehCountCircles);
2690
- const greatCountCircles = Math.max(0, circleCount - missCountCircles - mehCountCircles - okCountCircles);
2761
+ let objectCount = this.difficultyAttributes.hitCircleCount;
2762
+ if (this.mods.has(osuBase.ModScoreV2)) {
2763
+ objectCount += this.difficultyAttributes.sliderCount;
2764
+ }
2765
+ const missCount = Math.min(nmiss, objectCount);
2766
+ const mehCount = Math.min(n50, objectCount - missCount);
2767
+ const okCount = Math.min(n100, objectCount - missCount - mehCount);
2768
+ const greatCount = Math.max(0, objectCount - missCount - mehCount - okCount);
2691
2769
  // Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
2692
2770
  // compute the deviation on circles.
2693
- if (greatCountCircles > 0) {
2771
+ if (greatCount > 0) {
2694
2772
  // The probability that a player hits a circle is unknown, but we can estimate it to be
2695
2773
  // the number of greats on circles divided by the number of circles, and then add one
2696
2774
  // to the number of circles as a bias correction.
2697
- const greatProbabilityCircle = greatCountCircles /
2698
- (circleCount - missCountCircles - mehCountCircles + 1);
2775
+ const greatProbabilityCircle = greatCount / (objectCount - missCount - mehCount + 1);
2699
2776
  // Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
2700
2777
  // Begin with the normal distribution first.
2701
2778
  let deviationOnCircles = hitWindow300 /
@@ -2714,17 +2791,16 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2714
2791
  hitWindow100 * hitWindow100) /
2715
2792
  3;
2716
2793
  // Find the total deviation.
2717
- deviationOnCircles = Math.sqrt(((greatCountCircles + okCountCircles) *
2718
- Math.pow(deviationOnCircles, 2) +
2719
- mehCountCircles * mehVariance) /
2720
- (greatCountCircles + okCountCircles + mehCountCircles));
2794
+ deviationOnCircles = Math.sqrt(((greatCount + okCount) * Math.pow(deviationOnCircles, 2) +
2795
+ mehCount * mehVariance) /
2796
+ (greatCount + okCount + mehCount));
2721
2797
  return deviationOnCircles;
2722
2798
  }
2723
2799
  // If there are more non-300s than there are circles, compute the deviation on sliders instead.
2724
2800
  // Here, all that matters is whether or not the slider was missed, since it is impossible
2725
2801
  // to get a 100 or 50 on a slider by mis-tapping it.
2726
2802
  const sliderCount = this.difficultyAttributes.sliderCount;
2727
- const missCountSliders = Math.min(sliderCount, nmiss - missCountCircles);
2803
+ const missCountSliders = Math.min(sliderCount, nmiss - missCount);
2728
2804
  const greatCountSliders = sliderCount - missCountSliders;
2729
2805
  // We only get here if nothing was hit. In this case, there is no estimate for deviation.
2730
2806
  // Note that this is never negative, so checking if this is only equal to 0 makes sense.
@@ -2745,7 +2821,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2745
2821
  if (this.totalSuccessfulHits === 0) {
2746
2822
  return Number.POSITIVE_INFINITY;
2747
2823
  }
2748
- const { speedNoteCount, clockRate } = this.difficultyAttributes;
2824
+ const { clockRate, speedNoteCount } = this.difficultyAttributes;
2749
2825
  const hitWindow = this.getConvertedHitWindow();
2750
2826
  const hitWindow300 = hitWindow.greatWindow / clockRate;
2751
2827
  const hitWindow100 = hitWindow.okWindow / clockRate;
@@ -2754,48 +2830,83 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2754
2830
  // Assume a fixed ratio of non-300s hit in speed notes based on speed note count ratio and OD.
2755
2831
  // Graph: https://www.desmos.com/calculator/iskvgjkxr4
2756
2832
  const speedNoteRatio = speedNoteCount / this.totalHits;
2757
- const nonGreatCount = n100 + n50 + nmiss;
2758
2833
  const nonGreatRatio = 1 -
2759
2834
  (Math.pow(Math.exp(Math.sqrt(hitWindow300)) + 1, 1 - speedNoteRatio) -
2760
2835
  1) /
2761
2836
  Math.exp(Math.sqrt(hitWindow300));
2762
- const relevantCountGreat = Math.max(0, speedNoteCount - nonGreatCount * nonGreatRatio);
2763
- const relevantCountOk = n100 * nonGreatRatio;
2764
- const relevantCountMeh = n50 * nonGreatRatio;
2765
- const relevantCountMiss = nmiss * nonGreatRatio;
2766
- // Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
2767
- // compute the deviation on circles.
2768
- if (relevantCountGreat > 0) {
2769
- // The probability that a player hits a circle is unknown, but we can estimate it to be
2770
- // the number of greats on circles divided by the number of circles, and then add one
2771
- // to the number of circles as a bias correction.
2772
- const greatProbabilityCircle = relevantCountGreat /
2773
- (speedNoteCount - relevantCountMiss - relevantCountMeh + 1);
2774
- // Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
2775
- // Begin with the normal distribution first.
2776
- let deviationOnCircles = hitWindow300 /
2777
- (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
2778
- deviationOnCircles *= Math.sqrt(1 -
2779
- (Math.sqrt(2 / Math.PI) *
2780
- hitWindow100 *
2781
- Math.exp(-0.5 *
2782
- Math.pow(hitWindow100 / deviationOnCircles, 2))) /
2783
- (deviationOnCircles *
2784
- osuBase.ErrorFunction.erf(hitWindow100 /
2785
- (Math.SQRT2 * deviationOnCircles))));
2786
- // Then compute the variance for 50s.
2787
- const mehVariance = (hitWindow50 * hitWindow50 +
2788
- hitWindow100 * hitWindow50 +
2789
- hitWindow100 * hitWindow100) /
2790
- 3;
2791
- // Find the total deviation.
2792
- deviationOnCircles = Math.sqrt(((relevantCountGreat + relevantCountOk) *
2793
- Math.pow(deviationOnCircles, 2) +
2794
- relevantCountMeh * mehVariance) /
2795
- (relevantCountGreat + relevantCountOk + relevantCountMeh));
2796
- return deviationOnCircles;
2837
+ // Assume worst case - all non-300s happened in speed notes.
2838
+ const relevantCountMiss = Math.min(nmiss * nonGreatRatio, speedNoteCount);
2839
+ const relevantCountMeh = Math.min(n50 * nonGreatRatio, speedNoteCount - relevantCountMiss);
2840
+ const relevantCountOk = Math.min(n100 * nonGreatRatio, speedNoteCount - relevantCountMiss - relevantCountMeh);
2841
+ const relevantCountGreat = Math.max(0, speedNoteCount -
2842
+ relevantCountMiss -
2843
+ relevantCountMeh -
2844
+ relevantCountOk);
2845
+ if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) {
2846
+ return Number.POSITIVE_INFINITY;
2797
2847
  }
2798
- return Number.POSITIVE_INFINITY;
2848
+ // The sample proportion of successful hits.
2849
+ const n = Math.max(1, relevantCountGreat + relevantCountOk);
2850
+ const p = relevantCountGreat / n;
2851
+ // 99% critical value for the normal distribution (one-tailed).
2852
+ const z = 2.32634787404;
2853
+ // We can be 99% confident that the population proportion is at least this value.
2854
+ const pLowerBound = Math.min(p, (n * p + Math.pow(z, 2) / 2) / (n + Math.pow(z, 2)) -
2855
+ (z / (n + Math.pow(z, 2))) *
2856
+ Math.sqrt(n * p * (1 - p) + Math.pow(z, 2) / 4));
2857
+ let deviation;
2858
+ // Tested maximum precision for the deviation calculation.
2859
+ if (pLowerBound > 0.01) {
2860
+ // Compute deviation assuming 300s and 109s are normally distributed.
2861
+ deviation =
2862
+ hitWindow300 / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(pLowerBound));
2863
+ // Subtract the deviation provided by tails that land outside the 100 hit window from the deviation computed above.
2864
+ // This is equivalent to calculating the deviation of a normal distribution truncated at +-okHitWindow.
2865
+ const hitWindow100TailAmount = (Math.sqrt(2 / Math.PI) *
2866
+ hitWindow100 *
2867
+ Math.exp(-0.5 * Math.pow(hitWindow100 / deviation, 2))) /
2868
+ (deviation *
2869
+ osuBase.ErrorFunction.erf(hitWindow100 / (Math.SQRT2 * deviation)));
2870
+ deviation *= Math.sqrt(1 - hitWindow100TailAmount);
2871
+ }
2872
+ else {
2873
+ // A tested limit value for the case of a score only containing 100s.
2874
+ deviation = hitWindow100 / Math.sqrt(3);
2875
+ }
2876
+ // Compute and add the variance for 50s, assuming that they are uniformly distriubted.
2877
+ const mehVariance = (hitWindow50 * hitWindow50 +
2878
+ hitWindow100 * hitWindow50 +
2879
+ hitWindow100 * hitWindow100) /
2880
+ 3;
2881
+ deviation = Math.sqrt(((relevantCountGreat + relevantCountOk) * Math.pow(deviation, 2) +
2882
+ relevantCountMeh * mehVariance) /
2883
+ (relevantCountGreat + relevantCountOk + relevantCountMeh));
2884
+ return deviation;
2885
+ }
2886
+ /**
2887
+ * Calculates a multiplier for tap to account for improper tapping based on the deviation and tap difficulty.
2888
+ *
2889
+ * [Graph](https://www.desmos.com/calculator/z5l9ebrwpi)
2890
+ */
2891
+ calculateTapHighDeviationNerf() {
2892
+ if (this.tapDeviation == Number.POSITIVE_INFINITY) {
2893
+ return 0;
2894
+ }
2895
+ const tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
2896
+ // Decide a point where the PP value achieved compared to the tap deviation is assumed to be tapped
2897
+ // improperly. Any PP above this point is considered "excess" tap difficulty. This is used to cause
2898
+ // PP above the cutoff to scale logarithmically towards the original tap value thus nerfing the value.
2899
+ const excessTapDifficultyCutoff = 100 + 250 * Math.pow(25 / this.tapDeviation, 6.5);
2900
+ if (tapValue <= excessTapDifficultyCutoff) {
2901
+ return 1;
2902
+ }
2903
+ const scale = 50;
2904
+ const adjustedTapValue = scale *
2905
+ (Math.log((tapValue - excessTapDifficultyCutoff) / scale + 1) +
2906
+ excessTapDifficultyCutoff / scale);
2907
+ // 250 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible.
2908
+ const t = 1 - osuBase.Interpolation.reverseLerp(this.tapDeviation, 25, 30);
2909
+ return osuBase.Interpolation.lerp(adjustedTapValue, tapValue, t) / tapValue;
2799
2910
  }
2800
2911
  getConvertedHitWindow() {
2801
2912
  const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).greatWindow;
@@ -2817,8 +2928,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2817
2928
  " acc, " +
2818
2929
  this.flashlight.toFixed(2) +
2819
2930
  " flashlight, " +
2820
- this.visual.toFixed(2) +
2821
- " visual)");
2931
+ this.reading.toFixed(2) +
2932
+ " reading)");
2822
2933
  }
2823
2934
  }
2824
2935
 
@@ -2836,19 +2947,10 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
2836
2947
  * The flashlight strain generated by this hitobject.
2837
2948
  */
2838
2949
  this.flashlightStrain = 0;
2839
- this.radiusBuffThreshold = 30;
2840
2950
  this.mode = osuBase.Modes.osu;
2841
2951
  }
2842
- get scalingFactor() {
2843
- const radius = this.object.radius;
2844
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
2845
- let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
2846
- // High circle size (small CS) bonus
2847
- if (radius < this.radiusBuffThreshold) {
2848
- scalingFactor *=
2849
- 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
2850
- }
2851
- return scalingFactor;
2952
+ get smallCircleBonus() {
2953
+ return Math.max(1, 1 + (30 - this.object.radius) / 40);
2852
2954
  }
2853
2955
  }
2854
2956
 
@@ -3439,19 +3541,16 @@ class OsuSpeed extends OsuSkill {
3439
3541
  this.currentSpeedStrain = 0;
3440
3542
  this.currentRhythm = 0;
3441
3543
  this.skillMultiplier = 1.46;
3544
+ this.maxStrain = 0;
3442
3545
  }
3443
3546
  /**
3444
3547
  * The amount of notes that are relevant to the difficulty.
3445
3548
  */
3446
3549
  relevantNoteCount() {
3447
- if (this._objectStrains.length === 0) {
3448
- return 0;
3449
- }
3450
- const maxStrain = osuBase.MathUtils.max(this._objectStrains);
3451
- if (maxStrain === 0) {
3550
+ if (this._objectStrains.length === 0 || this.maxStrain === 0) {
3452
3551
  return 0;
3453
3552
  }
3454
- return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
3553
+ return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / this.maxStrain) * 12 - 6))), 0);
3455
3554
  }
3456
3555
  /**
3457
3556
  * @param current The hitobject to calculate.
@@ -3464,6 +3563,7 @@ class OsuSpeed extends OsuSkill {
3464
3563
  this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
3465
3564
  const strain = this.currentSpeedStrain * this.currentRhythm;
3466
3565
  this._objectStrains.push(strain);
3566
+ this.maxStrain = Math.max(this.maxStrain, strain);
3467
3567
  return strain;
3468
3568
  }
3469
3569
  calculateInitialStrain(time, current) {
@@ -3549,7 +3649,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3549
3649
  const { objects } = beatmap.hitObjects;
3550
3650
  for (let i = 1; i < objects.length; ++i) {
3551
3651
  const difficultyObject = new OsuDifficultyHitObject(objects[i], objects[i - 1], difficultyObjects, clockRate, i - 1);
3552
- difficultyObject.computeProperties(clockRate, objects);
3652
+ difficultyObject.computeProperties(clockRate);
3553
3653
  difficultyObjects.push(difficultyObject);
3554
3654
  }
3555
3655
  return difficultyObjects;
@@ -3586,7 +3686,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3586
3686
  }
3587
3687
  attributes.aimDifficulty = this.calculateRating(aim);
3588
3688
  attributes.aimDifficultSliderCount = aim.countDifficultSliders();
3589
- attributes.aimDifficultStrainCount = aim.countDifficultStrains();
3689
+ attributes.aimDifficultStrainCount = aim.countTopWeightedStrains();
3590
3690
  if (attributes.aimDifficulty > 0) {
3591
3691
  attributes.sliderFactor =
3592
3692
  this.calculateRating(aimNoSlider) / attributes.aimDifficulty;
@@ -3602,7 +3702,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3602
3702
  }
3603
3703
  attributes.speedDifficulty = this.calculateRating(speed);
3604
3704
  attributes.speedNoteCount = speed.relevantNoteCount();
3605
- attributes.speedDifficultStrainCount = speed.countDifficultStrains();
3705
+ attributes.speedDifficultStrainCount = speed.countTopWeightedStrains();
3606
3706
  }
3607
3707
  populateFlashlightAttributes(attributes, skills) {
3608
3708
  const flashlight = skills.find((s) => s instanceof OsuFlashlight);
@@ -3953,12 +4053,12 @@ exports.DroidDifficultyHitObject = DroidDifficultyHitObject;
3953
4053
  exports.DroidFlashlight = DroidFlashlight;
3954
4054
  exports.DroidFlashlightEvaluator = DroidFlashlightEvaluator;
3955
4055
  exports.DroidPerformanceCalculator = DroidPerformanceCalculator;
4056
+ exports.DroidReading = DroidReading;
4057
+ exports.DroidReadingEvaluator = DroidReadingEvaluator;
3956
4058
  exports.DroidRhythm = DroidRhythm;
3957
4059
  exports.DroidRhythmEvaluator = DroidRhythmEvaluator;
3958
4060
  exports.DroidTap = DroidTap;
3959
4061
  exports.DroidTapEvaluator = DroidTapEvaluator;
3960
- exports.DroidVisual = DroidVisual;
3961
- exports.DroidVisualEvaluator = DroidVisualEvaluator;
3962
4062
  exports.ExtendedDroidDifficultyAttributes = ExtendedDroidDifficultyAttributes;
3963
4063
  exports.OsuAim = OsuAim;
3964
4064
  exports.OsuAimEvaluator = OsuAimEvaluator;