@rian8337/osu-difficulty-calculator 4.0.0-beta.94 → 4.0.0-beta.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/dist/index.js +1290 -802
  2. package/package.json +3 -3
  3. package/typings/index.d.ts +326 -179
package/dist/index.js CHANGED
@@ -223,17 +223,8 @@ class Skill {
223
223
  get objectDifficulties() {
224
224
  return this._objectDifficulties;
225
225
  }
226
- /**
227
- * The start times of {@link DifficultyHitObject}s, populated by {@link Skill.process}.
228
- *
229
- * Indices correspond to {@link objectDifficulties}.
230
- */
231
- get objectTimes() {
232
- return this._objectTimes;
233
- }
234
226
  constructor(mods) {
235
227
  this._objectDifficulties = [];
236
- this._objectTimes = [];
237
228
  this.mods = mods;
238
229
  }
239
230
  /**
@@ -249,7 +240,6 @@ class Skill {
249
240
  const difficultyValue = this.processInternal(current);
250
241
  this.saveToHitObject(current, difficultyValue);
251
242
  this._objectDifficulties.push(difficultyValue);
252
- this._objectTimes.push(current.startTime);
253
243
  }
254
244
  /**
255
245
  * Saves the calculated difficulty to a {@link DifficultyHitObject}.
@@ -1015,6 +1005,7 @@ class DroidFlashlightEvaluator {
1015
1005
  * @param mods The mods used.
1016
1006
  */
1017
1007
  static evaluateDifficultyOf(current, mods) {
1008
+ var _a;
1018
1009
  if (current.object instanceof osuBase.Spinner ||
1019
1010
  // Exclude overlapping objects that can be tapped at once.
1020
1011
  current.isOverlapping(true)) {
@@ -1026,6 +1017,10 @@ class DroidFlashlightEvaluator {
1026
1017
  let result = 0;
1027
1018
  let last = current;
1028
1019
  let angleRepeatCount = 0;
1020
+ // Hidden's fade-out only affects opacity when the "only fade approach circles" setting is off.
1021
+ const opacityMods = ((_a = mods.get(osuBase.ModHidden)) === null || _a === void 0 ? void 0 : _a.onlyFadeApproachCircles.value)
1022
+ ? undefined
1023
+ : mods;
1029
1024
  for (let i = 0; i < Math.min(current.index, 10); ++i) {
1030
1025
  const currentObject = current.previous(i);
1031
1026
  cumulativeStrainTime += last.strainTime;
@@ -1043,7 +1038,7 @@ class DroidFlashlightEvaluator {
1043
1038
  const opacityBonus = 1 +
1044
1039
  this.maxOpacityBonus *
1045
1040
  (1 -
1046
- current.opacityAt(currentObject.object.startTime, mods));
1041
+ current.opacityAt(currentObject.object.startTime, opacityMods));
1047
1042
  result +=
1048
1043
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
1049
1044
  cumulativeStrainTime;
@@ -1133,7 +1128,10 @@ class StrainSkill extends Skill {
1133
1128
  get peaks() {
1134
1129
  return this.strainPeaks
1135
1130
  .map((value, i) => ({ time: this.strainPeakTimes[i], value }))
1136
- .concat({ time: this.currentSectionEnd, value: this.currentSectionPeak });
1131
+ .concat({
1132
+ time: this.currentSectionEnd,
1133
+ value: this.currentSectionPeak,
1134
+ });
1137
1135
  }
1138
1136
  /**
1139
1137
  * Converts a difficulty value to a performance value.
@@ -1180,9 +1178,7 @@ class StrainSkill extends Skill {
1180
1178
  this.startNewSectionFrom(this.currentSectionEnd, current);
1181
1179
  this.currentSectionEnd += this.sectionLength;
1182
1180
  }
1183
- // Ignore the first hitobject.
1184
1181
  this.currentStrain = this.strainValueAt(current);
1185
- this.saveToHitObject(current, this.currentStrain);
1186
1182
  this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
1187
1183
  return this.currentStrain;
1188
1184
  }
@@ -1326,6 +1322,7 @@ class HarmonicSkill extends Skill {
1326
1322
  constructor() {
1327
1323
  super(...arguments);
1328
1324
  this._noteWeightSum = 0;
1325
+ this._objectTimes = [];
1329
1326
  /**
1330
1327
  * Scaling factor applied as `x / (i + 1)`, where `x` is the skill's {@link harmonicScale} and `i`
1331
1328
  * is the index of the {@link DifficultyHitObject} being processed.
@@ -1348,6 +1345,14 @@ class HarmonicSkill extends Skill {
1348
1345
  get noteWeightSum() {
1349
1346
  return this._noteWeightSum;
1350
1347
  }
1348
+ /**
1349
+ * The start times of {@link DifficultyHitObject}s, populated by {@link HarmonicSkill.process}.
1350
+ *
1351
+ * Indices correspond to {@link objectDifficulties}.
1352
+ */
1353
+ get objectTimes() {
1354
+ return this._objectTimes;
1355
+ }
1351
1356
  get peaks() {
1352
1357
  return this.objectDifficulties.map((value, i) => ({
1353
1358
  time: this.objectTimes[i],
@@ -1413,6 +1418,13 @@ class HarmonicSkill extends Skill {
1413
1418
  applyDifficultyTransformation(difficulties) {
1414
1419
  // Do nothing by default.
1415
1420
  }
1421
+ process(current) {
1422
+ const previousLength = this.objectDifficulties.length;
1423
+ super.process(current);
1424
+ if (this.objectDifficulties.length > previousLength) {
1425
+ this._objectTimes.push(current.startTime);
1426
+ }
1427
+ }
1416
1428
  processInternal(current) {
1417
1429
  return this.objectDifficultyOf(current);
1418
1430
  }
@@ -2527,6 +2539,10 @@ class DifficultyHitObject {
2527
2539
  * The flashlight strain generated by the hitobject.
2528
2540
  */
2529
2541
  this.flashlightStrain = 0;
2542
+ /**
2543
+ * The reading difficulty generated by the hitobject.
2544
+ */
2545
+ this.readingDifficulty = 0;
2530
2546
  /**
2531
2547
  * The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
2532
2548
  */
@@ -2685,16 +2701,9 @@ class DifficultyHitObject {
2685
2701
  const nextDeltaTime = Math.max(1, nextObj.deltaTime);
2686
2702
  const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
2687
2703
  const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
2688
- if (this.mode === osuBase.Modes.Droid) {
2689
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.hitWindowFor(osuBase.HitResult.Great)), 5);
2690
- const distanceFactor = Math.pow(osuBase.Interpolation.reverseLerp(this.lazyJumpDistance, this.normalizedDiameter, this.normalizedRadius), 2);
2691
- return 1 - Math.pow(speedRatio, distanceFactor * (1 - windowRatio));
2692
- }
2693
- else {
2694
- // TODO: unported osu!standard change
2695
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.hitWindowFor(osuBase.HitResult.Great)), 2);
2696
- return 1 - Math.pow(speedRatio, 1 - windowRatio);
2697
- }
2704
+ const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.hitWindowFor(osuBase.HitResult.Great)), 5);
2705
+ const distanceFactor = Math.pow(osuBase.Interpolation.reverseLerp(this.lazyJumpDistance, this.normalizedDiameter, this.normalizedRadius), 2);
2706
+ return 1 - Math.pow(speedRatio, distanceFactor * (1 - windowRatio));
2698
2707
  }
2699
2708
  /**
2700
2709
  * Retrieves the full rate-adjusted hit window for a {@link HitResult}.
@@ -2715,17 +2724,9 @@ class DifficultyHitObject {
2715
2724
  var _a;
2716
2725
  if (this.object instanceof osuBase.Slider) {
2717
2726
  // Bonus for repeat sliders until a better per nested object strain system can be achieved.
2718
- if (this.mode === osuBase.Modes.Droid) {
2719
- this.travelDistance =
2720
- this.lazyTravelDistance *
2721
- Math.max(1, Math.pow(this.object.repeatCount, 0.3));
2722
- }
2723
- else {
2724
- // TODO: unported osu!standard change
2725
- this.travelDistance =
2726
- this.lazyTravelDistance *
2727
- Math.pow(1 + this.object.repeatCount / 2.5, 1 / 2.5);
2728
- }
2727
+ this.travelDistance =
2728
+ this.lazyTravelDistance *
2729
+ Math.max(1, Math.pow(this.object.repeatCount, 0.3));
2729
2730
  this.travelTime = Math.max(this.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
2730
2731
  }
2731
2732
  this.minimumJumpTime = this.strainTime;
@@ -2776,29 +2777,17 @@ class DifficultyHitObject {
2776
2777
  if (this.lastLastDifficultyObject &&
2777
2778
  !(this.lastLastDifficultyObject.object instanceof osuBase.Spinner)) {
2778
2779
  const lastLastCursorPosition = this.getEndCursorPosition(this.lastLastDifficultyObject);
2779
- if (this.mode === osuBase.Modes.Droid) {
2780
- if (((_a = this.lastDifficultyObject) === null || _a === void 0 ? void 0 : _a.object) instanceof osuBase.Slider &&
2781
- this.lastDifficultyObject.travelDistance > 0) {
2782
- lastCursorPosition =
2783
- this.lastDifficultyObject.object.stackedPosition;
2784
- }
2785
- const angle = this.calculateAngle(this.object.stackedPosition, lastCursorPosition, lastLastCursorPosition);
2786
- const sliderAngle = this.calculateSliderAngle(this.lastDifficultyObject, lastLastCursorPosition);
2787
- const v = this.object.stackedPosition.subtract(lastCursorPosition);
2788
- this.normalizedVectorAngle = Math.atan2(Math.abs(v.y), Math.abs(v.x));
2789
- this.angleSigned =
2790
- Math.abs(angle) <= Math.abs(sliderAngle)
2791
- ? angle
2792
- : sliderAngle;
2793
- }
2794
- else {
2795
- // TODO: unported osu!standard change
2796
- const v1 = lastLastCursorPosition.subtract(this.lastObject.stackedPosition);
2797
- const v2 = this.object.stackedPosition.subtract(lastCursorPosition);
2798
- const dot = v1.dot(v2);
2799
- const det = v1.x * v2.y - v1.y * v2.x;
2800
- this.angleSigned = Math.atan2(det, dot);
2780
+ if (((_a = this.lastDifficultyObject) === null || _a === void 0 ? void 0 : _a.object) instanceof osuBase.Slider &&
2781
+ this.lastDifficultyObject.travelDistance > 0) {
2782
+ lastCursorPosition =
2783
+ this.lastDifficultyObject.object.stackedPosition;
2801
2784
  }
2785
+ const angle = this.calculateAngle(this.object.stackedPosition, lastCursorPosition, lastLastCursorPosition);
2786
+ const sliderAngle = this.calculateSliderAngle(this.lastDifficultyObject, lastLastCursorPosition);
2787
+ const v = this.object.stackedPosition.subtract(lastCursorPosition);
2788
+ this.normalizedVectorAngle = Math.atan2(Math.abs(v.y), Math.abs(v.x));
2789
+ this.angleSigned =
2790
+ Math.abs(angle) <= Math.abs(sliderAngle) ? angle : sliderAngle;
2802
2791
  }
2803
2792
  }
2804
2793
  calculateAngle(currentPosition, lastPosition, lastLastPosition) {
@@ -2931,10 +2920,6 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
2931
2920
  * The rhythm difficulty generated by the hitobject.
2932
2921
  */
2933
2922
  this.rhythmDifficulty = 0;
2934
- /**
2935
- * The reading difficulty generated by the hitobject.
2936
- */
2937
- this.readingDifficulty = 0;
2938
2923
  this.normalizedRadius = 50;
2939
2924
  this.mode = osuBase.Modes.Droid;
2940
2925
  }
@@ -3022,17 +3007,24 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
3022
3007
  }
3023
3008
  }
3024
3009
 
3025
- class DroidIsland {
3026
- constructor(delta, deltaDifferenceEpsilon) {
3010
+ /**
3011
+ * Represents a group of consecutive objects with the same delta time.
3012
+ */
3013
+ class Island {
3014
+ constructor(delta) {
3015
+ /**
3016
+ * Delta time of every object in this island.
3017
+ */
3027
3018
  this.delta = Number.MAX_SAFE_INTEGER;
3028
- this.deltaCount = 0;
3029
- if (deltaDifferenceEpsilon === undefined) {
3030
- this.deltaDifferenceEpsilon = delta;
3031
- }
3032
- else {
3033
- this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
3034
- this.addDelta(delta);
3035
- }
3019
+ /**
3020
+ * How long the island is.
3021
+ */
3022
+ this.deltaCount = 1;
3023
+ /**
3024
+ * How many times the island already occured.
3025
+ */
3026
+ this.occurrences = 1;
3027
+ this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
3036
3028
  }
3037
3029
  addDelta(delta) {
3038
3030
  if (this.delta === Number.MAX_SAFE_INTEGER) {
@@ -3040,27 +3032,20 @@ class DroidIsland {
3040
3032
  }
3041
3033
  ++this.deltaCount;
3042
3034
  }
3043
- isSimilarPolarity(other) {
3035
+ isSimilarPolarity(other, epsilon) {
3044
3036
  // Single delta islands should not be compared.
3045
3037
  if (this.deltaCount <= 1 || other.deltaCount <= 1) {
3046
3038
  return false;
3047
3039
  }
3048
- return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
3040
+ return (Math.abs(this.delta - other.delta) < epsilon &&
3049
3041
  this.deltaCount % 2 === other.deltaCount % 2);
3050
3042
  }
3051
- equals(other) {
3052
- return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
3043
+ almostEquals(other, epsilon) {
3044
+ return (Math.abs(this.delta - other.delta) < epsilon &&
3053
3045
  this.deltaCount === other.deltaCount);
3054
3046
  }
3055
3047
  }
3056
3048
 
3057
- class DroidIslandCounter {
3058
- constructor(island, count) {
3059
- this.island = island;
3060
- this.count = count;
3061
- }
3062
- }
3063
-
3064
3049
  /**
3065
3050
  * An evaluator for calculating osu!droid Rhythm skill.
3066
3051
  */
@@ -3078,9 +3063,9 @@ class DroidRhythmEvaluator {
3078
3063
  }
3079
3064
  const deltaDifferenceEpsilon = current.hitWindowFor(osuBase.HitResult.Great) * 0.3;
3080
3065
  let rhythmComplexitySum = 0;
3081
- let island = new DroidIsland(deltaDifferenceEpsilon);
3082
- let previousIsland = new DroidIsland(deltaDifferenceEpsilon);
3083
- const islandCounts = [];
3066
+ let island = new Island(Number.MAX_SAFE_INTEGER);
3067
+ let previousIsland = new Island(Number.MAX_SAFE_INTEGER);
3068
+ const islands = [];
3084
3069
  // Store the ratio of the current start of an island to buff for tighter rhythms.
3085
3070
  let startRatio = 0;
3086
3071
  let firstDeltaSwitch = false;
@@ -3121,7 +3106,7 @@ class DroidRhythmEvaluator {
3121
3106
  const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
3122
3107
  const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
3123
3108
  if (island.delta == Number.MAX_SAFE_INTEGER) {
3124
- island = new DroidIsland(currentDelta, deltaDifferenceEpsilon);
3109
+ island = new Island(currentDelta);
3125
3110
  }
3126
3111
  // Calculate how much current delta difference deserves a rhythm bonus.
3127
3112
  // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200).
@@ -3158,7 +3143,7 @@ class DroidRhythmEvaluator {
3158
3143
  effectiveRatio /= 2;
3159
3144
  }
3160
3145
  // Repeated island polarity (2 -> 4, 3 -> 5).
3161
- if (island.isSimilarPolarity(previousIsland)) {
3146
+ if (island.isSimilarPolarity(previousIsland, deltaDifferenceEpsilon)) {
3162
3147
  effectiveRatio /= 2;
3163
3148
  }
3164
3149
  // Previous increase happened a note ago.
@@ -3175,18 +3160,24 @@ class DroidRhythmEvaluator {
3175
3160
  if (isSpeedingUp) {
3176
3161
  effectiveRatio *= 0.65;
3177
3162
  }
3178
- const islandCount = islandCounts.find((x) => x.island.equals(island));
3179
- if (islandCount) {
3163
+ let found = false;
3164
+ for (const existingIsland of islands) {
3165
+ if (!existingIsland.almostEquals(island, deltaDifferenceEpsilon)) {
3166
+ continue;
3167
+ }
3180
3168
  // Only add island to island counts if they're going one after another.
3181
- if (previousIsland.equals(island)) {
3182
- ++islandCount.count;
3169
+ if (previousIsland.almostEquals(island, deltaDifferenceEpsilon)) {
3170
+ ++existingIsland.occurrences;
3183
3171
  }
3172
+ const power = osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75);
3184
3173
  // Repeated island (ex: triplet -> triplet).
3185
3174
  // Graph: https://www.desmos.com/calculator/pj7an56zwf
3186
- effectiveRatio *= Math.min(3 / islandCount.count, Math.pow(1 / islandCount.count, osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75)));
3175
+ effectiveRatio *= Math.min(3 / existingIsland.occurrences, Math.pow(1 / existingIsland.occurrences, power));
3176
+ found = true;
3177
+ break;
3187
3178
  }
3188
- else if (island.deltaCount > 0) {
3189
- islandCounts.push(new DroidIslandCounter(island, 1));
3179
+ if (!found && island.deltaCount > 0) {
3180
+ islands.push(island);
3190
3181
  }
3191
3182
  // Scale down the difficulty if the object is doubletappable.
3192
3183
  effectiveRatio *=
@@ -3207,7 +3198,7 @@ class DroidRhythmEvaluator {
3207
3198
  // If we're speeding up, this stays as is and we keep counting island size.
3208
3199
  firstDeltaSwitch = false;
3209
3200
  }
3210
- island = new DroidIsland(currentDelta, deltaDifferenceEpsilon);
3201
+ island = new Island(currentDelta);
3211
3202
  }
3212
3203
  }
3213
3204
  else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
@@ -3225,7 +3216,7 @@ class DroidRhythmEvaluator {
3225
3216
  effectiveRatio *= 0.6;
3226
3217
  }
3227
3218
  startRatio = effectiveRatio;
3228
- island = new DroidIsland(currentDelta, deltaDifferenceEpsilon);
3219
+ island = new Island(currentDelta);
3229
3220
  }
3230
3221
  lastObject = prevObject;
3231
3222
  prevObject = currentObject;
@@ -3289,6 +3280,7 @@ class DifficultyAttributes {
3289
3280
  this.maxCombo = 0;
3290
3281
  this.aimDifficulty = 0;
3291
3282
  this.flashlightDifficulty = 0;
3283
+ this.readingDifficulty = 0;
3292
3284
  this.speedNoteCount = 0;
3293
3285
  this.sliderFactor = 1;
3294
3286
  this.clockRate = 1;
@@ -3298,6 +3290,8 @@ class DifficultyAttributes {
3298
3290
  this.spinnerCount = 0;
3299
3291
  this.aimDifficultSliderCount = 0;
3300
3292
  this.aimDifficultStrainCount = 0;
3293
+ this.aimTopWeightedSliderFactor = 0;
3294
+ this.readingDifficultNoteCount = 0;
3301
3295
  if (!cacheableAttributes) {
3302
3296
  return;
3303
3297
  }
@@ -3306,6 +3300,7 @@ class DifficultyAttributes {
3306
3300
  this.maxCombo = cacheableAttributes.maxCombo;
3307
3301
  this.aimDifficulty = cacheableAttributes.aimDifficulty;
3308
3302
  this.flashlightDifficulty = cacheableAttributes.flashlightDifficulty;
3303
+ this.readingDifficulty = cacheableAttributes.readingDifficulty;
3309
3304
  this.speedNoteCount = cacheableAttributes.speedNoteCount;
3310
3305
  this.sliderFactor = cacheableAttributes.sliderFactor;
3311
3306
  this.clockRate = cacheableAttributes.clockRate;
@@ -3317,6 +3312,10 @@ class DifficultyAttributes {
3317
3312
  cacheableAttributes.aimDifficultSliderCount;
3318
3313
  this.aimDifficultStrainCount =
3319
3314
  cacheableAttributes.aimDifficultStrainCount;
3315
+ this.aimTopWeightedSliderFactor =
3316
+ cacheableAttributes.aimTopWeightedSliderFactor;
3317
+ this.readingDifficultNoteCount =
3318
+ cacheableAttributes.readingDifficultNoteCount;
3320
3319
  }
3321
3320
  /**
3322
3321
  * Converts this `DifficultyAttributes` instance to an attribute structure that can be cached.
@@ -3341,10 +3340,7 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
3341
3340
  constructor(cacheableAttributes) {
3342
3341
  super(cacheableAttributes);
3343
3342
  this.tapDifficulty = 0;
3344
- this.readingDifficulty = 0;
3345
- this.aimTopWeightedSliderFactor = 0;
3346
3343
  this.tapTopWeightedSliderFactor = 0;
3347
- this.readingDifficultNoteCount = 0;
3348
3344
  this.rhythmDifficulty = 0;
3349
3345
  this.tapDifficultStrainCount = 0;
3350
3346
  this.maximumScore = 0;
@@ -3356,10 +3352,6 @@ class DroidDifficultyAttributes extends DifficultyAttributes {
3356
3352
  this.readingDifficulty = cacheableAttributes.readingDifficulty;
3357
3353
  this.tapDifficultStrainCount =
3358
3354
  cacheableAttributes.tapDifficultStrainCount;
3359
- this.readingDifficultNoteCount =
3360
- cacheableAttributes.readingDifficultNoteCount;
3361
- this.aimTopWeightedSliderFactor =
3362
- cacheableAttributes.aimTopWeightedSliderFactor;
3363
3355
  this.tapTopWeightedSliderFactor =
3364
3356
  cacheableAttributes.tapTopWeightedSliderFactor;
3365
3357
  this.maximumScore = cacheableAttributes.maximumScore;
@@ -3674,154 +3666,33 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
3674
3666
  }
3675
3667
 
3676
3668
  /**
3677
- * An evaluator for calculating osu!standard Aim skill.
3669
+ * An evaluator for calculating osu!standard agility aim difficulty.
3678
3670
  */
3679
- class OsuAimEvaluator {
3671
+ class OsuAgilityEvaluator {
3680
3672
  /**
3681
- * Evaluates the difficulty of aiming the current object, based on:
3682
- *
3683
- * - cursor velocity to the current object,
3684
- * - angle difficulty,
3685
- * - sharp velocity increases,
3686
- * - and slider difficulty.
3673
+ * Evaluates the difficulty of fast aiming the current object.
3687
3674
  *
3688
3675
  * @param current The current object.
3689
- * @param withSliders Whether to take slider difficulty into account.
3690
3676
  */
3691
- static evaluateDifficultyOf(current, withSliders) {
3692
- const last = current.previous(0);
3693
- if (current.object instanceof osuBase.Spinner ||
3694
- current.index <= 1 ||
3695
- last.object instanceof osuBase.Spinner) {
3677
+ static evaluateDifficultyOf(current) {
3678
+ var _a;
3679
+ if (current.object instanceof osuBase.Spinner) {
3696
3680
  return 0;
3697
3681
  }
3698
- const lastLast = current.previous(1);
3699
- const last2 = current.previous(2);
3700
- const radius = current.normalizedRadius;
3701
- const diameter = current.normalizedDiameter;
3702
- // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
3703
- let currentVelocity = current.lazyJumpDistance / current.strainTime;
3704
- // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
3705
- if (last.object instanceof osuBase.Slider && withSliders) {
3706
- // Calculate the slider velocity from slider head to slider end.
3707
- const travelVelocity = last.travelDistance / last.travelTime;
3708
- // Calculate the movement velocity from slider end to current object.
3709
- const movementVelocity = current.minimumJumpDistance / current.minimumJumpTime;
3710
- // Take the larger total combined velocity.
3711
- currentVelocity = Math.max(currentVelocity, movementVelocity + travelVelocity);
3712
- }
3713
- // As above, do the same for the previous hitobject.
3714
- let prevVelocity = last.lazyJumpDistance / last.strainTime;
3715
- if (lastLast.object instanceof osuBase.Slider && withSliders) {
3716
- const travelVelocity = lastLast.travelDistance / lastLast.travelTime;
3717
- const movementVelocity = last.minimumJumpDistance / last.minimumJumpTime;
3718
- prevVelocity = Math.max(prevVelocity, movementVelocity + travelVelocity);
3719
- }
3720
- let wideAngleBonus = 0;
3721
- let acuteAngleBonus = 0;
3722
- let sliderBonus = 0;
3723
- let velocityChangeBonus = 0;
3724
- let wiggleBonus = 0;
3725
- // Start strain with regular velocity.
3726
- let strain = currentVelocity;
3727
- if (current.angle !== null && last.angle !== null) {
3728
- const currentAngle = current.angle;
3729
- const lastAngle = last.angle;
3730
- // Rewarding angles, take the smaller velocity as base.
3731
- const angleBonus = Math.min(currentVelocity, prevVelocity);
3732
- if (
3733
- // If rhythms are the same.
3734
- Math.max(current.strainTime, last.strainTime) <
3735
- 1.25 * Math.min(current.strainTime, last.strainTime)) {
3736
- acuteAngleBonus = this.calculateAcuteAngleBonus(currentAngle);
3737
- // Penalize angle repetition.
3738
- acuteAngleBonus *=
3739
- 0.08 +
3740
- 0.92 *
3741
- (1 -
3742
- Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
3743
- // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
3744
- acuteAngleBonus *=
3745
- angleBonus *
3746
- osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
3747
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
3748
- }
3749
- wideAngleBonus = this.calculateWideAngleBonus(currentAngle);
3750
- // Penalize angle repetition.
3751
- wideAngleBonus *=
3752
- 1 -
3753
- Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
3754
- // Apply full wide angle bonus for distance more than one diameter
3755
- wideAngleBonus *=
3756
- angleBonus *
3757
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
3758
- // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
3759
- // https://www.desmos.com/calculator/dp0v0nvowc
3760
- wiggleBonus =
3761
- angleBonus *
3762
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, radius, diameter) *
3763
- Math.pow(osuBase.MathUtils.reverseLerp(current.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3764
- osuBase.MathUtils.smootherstep(currentAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
3765
- osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
3766
- Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3767
- osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
3768
- if (last2 !== null) {
3769
- // If objects just go back and forth through a middle point - don't give as much wide bonus.
3770
- // Use previous(2) and previous(0) because angles calculation is done prevprev-prev-curr, so any
3771
- // object's angle's center point is always the previous object.
3772
- const distance = last2.object.stackedPosition.getDistance(last.object.stackedPosition);
3773
- if (distance < 1) {
3774
- wideAngleBonus *= 1 - 0.35 * (1 - distance);
3775
- }
3776
- }
3777
- }
3778
- if (Math.max(prevVelocity, currentVelocity)) {
3779
- // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
3780
- prevVelocity =
3781
- (last.lazyJumpDistance + lastLast.travelDistance) /
3782
- last.strainTime;
3783
- currentVelocity =
3784
- (current.lazyJumpDistance + last.travelDistance) /
3785
- current.strainTime;
3786
- // Scale with ratio of difference compared to half the max distance.
3787
- const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
3788
- Math.max(prevVelocity, currentVelocity), 0, 1);
3789
- // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
3790
- const overlapVelocityBuff = Math.min((diameter * 1.25) /
3791
- Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
3792
- velocityChangeBonus = overlapVelocityBuff * distanceRatio;
3793
- // Penalize for rhythm changes.
3794
- velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
3795
- Math.max(current.strainTime, last.strainTime), 2);
3796
- }
3797
- if (last.travelTime) {
3798
- // Reward sliders based on velocity.
3799
- sliderBonus = last.travelDistance / last.travelTime;
3800
- }
3801
- strain += wiggleBonus * this.wiggleMultiplier;
3802
- strain += velocityChangeBonus * this.velocityChangeMultiplier;
3803
- // Add in acute angle bonus or wide angle bonus, whichever is larger.
3804
- strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier);
3805
- // Apply high circle size bonus
3806
- strain *= current.smallCircleBonus;
3807
- // Add in additional slider velocity bonus.
3808
- if (withSliders) {
3809
- strain += sliderBonus * this.sliderMultiplier;
3810
- }
3682
+ const prev = current.previous(0);
3683
+ const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.lazyTravelDistance) !== null && _a !== void 0 ? _a : 0;
3684
+ const distance = travelDistance + current.lazyJumpDistance;
3685
+ const distanceCap = current.normalizedDiameter * 1.2;
3686
+ const distanceScaled = Math.min(distance, distanceCap) / distanceCap;
3687
+ let strain = (distanceScaled * 1000) / current.strainTime;
3688
+ strain *= Math.pow(current.smallCircleBonus, 1.5);
3689
+ strain *= this.highBpmBonus(current.strainTime);
3811
3690
  return strain;
3812
3691
  }
3813
- static calculateWideAngleBonus(angle) {
3814
- return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
3815
- }
3816
- static calculateAcuteAngleBonus(angle) {
3817
- return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
3692
+ static highBpmBonus(ms) {
3693
+ return 1 / (1 - Math.pow(0.2, ms / 1000));
3818
3694
  }
3819
3695
  }
3820
- OsuAimEvaluator.wideAngleMultiplier = 1.5;
3821
- OsuAimEvaluator.acuteAngleMultiplier = 2.55;
3822
- OsuAimEvaluator.sliderMultiplier = 1.35;
3823
- OsuAimEvaluator.velocityChangeMultiplier = 0.75;
3824
- OsuAimEvaluator.wiggleMultiplier = 1.02;
3825
3696
 
3826
3697
  /**
3827
3698
  * An evaluator for calculating osu!standard Flashlight skill.
@@ -3840,6 +3711,7 @@ class OsuFlashlightEvaluator {
3840
3711
  * @param mods The mods used.
3841
3712
  */
3842
3713
  static evaluateDifficultyOf(current, mods) {
3714
+ var _a;
3843
3715
  if (current.object instanceof osuBase.Spinner) {
3844
3716
  return 0;
3845
3717
  }
@@ -3849,6 +3721,10 @@ class OsuFlashlightEvaluator {
3849
3721
  let result = 0;
3850
3722
  let last = current;
3851
3723
  let angleRepeatCount = 0;
3724
+ // Hidden's fade-out only affects opacity when the "only fade approach circles" setting is off.
3725
+ const opacityMods = ((_a = mods.get(osuBase.ModHidden)) === null || _a === void 0 ? void 0 : _a.onlyFadeApproachCircles.value)
3726
+ ? undefined
3727
+ : mods;
3852
3728
  for (let i = 0; i < Math.min(current.index, 10); ++i) {
3853
3729
  const currentObject = current.previous(i);
3854
3730
  cumulativeStrainTime += last.strainTime;
@@ -3864,7 +3740,7 @@ class OsuFlashlightEvaluator {
3864
3740
  const opacityBonus = 1 +
3865
3741
  this.maxOpacityBonus *
3866
3742
  (1 -
3867
- current.opacityAt(currentObject.object.startTime, mods));
3743
+ current.opacityAt(currentObject.object.startTime, opacityMods));
3868
3744
  result +=
3869
3745
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
3870
3746
  cumulativeStrainTime;
@@ -3908,149 +3784,660 @@ OsuFlashlightEvaluator.minVelocity = 0.5;
3908
3784
  OsuFlashlightEvaluator.sliderMultiplier = 1.3;
3909
3785
  OsuFlashlightEvaluator.minAngleMultiplier = 0.2;
3910
3786
 
3911
- class OsuIsland {
3912
- constructor(delta, deltaDifferenceEpsilon) {
3913
- this.delta = Number.MAX_SAFE_INTEGER;
3914
- this.deltaCount = 0;
3915
- if (deltaDifferenceEpsilon === undefined) {
3916
- this.deltaDifferenceEpsilon = delta;
3917
- }
3918
- else {
3919
- this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
3920
- this.addDelta(delta);
3921
- }
3922
- }
3923
- addDelta(delta) {
3924
- if (this.delta === Number.MAX_SAFE_INTEGER) {
3925
- this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
3926
- }
3927
- ++this.deltaCount;
3928
- }
3929
- isSimilarPolarity(other) {
3930
- // TODO: consider islands to be of similar polarity only if they're having the same average delta (we don't want to consider 3 singletaps similar to a triple)
3931
- // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
3932
- return this.deltaCount % 2 == other.deltaCount % 2;
3933
- }
3934
- equals(other) {
3935
- return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
3936
- this.deltaCount === other.deltaCount);
3937
- }
3938
- }
3939
-
3940
3787
  /**
3941
- * An evaluator for calculating osu!standard Rhythm skill.
3788
+ * An evaluator for calculating osu!standard snap aim difficulty.
3942
3789
  */
3943
- class OsuRhythmEvaluator {
3790
+ class OsuSnapAimEvaluator {
3944
3791
  /**
3945
- * Calculates a rhythm multiplier for the difficulty of the tap associated
3946
- * with historic data of the current object.
3792
+ * Evaluates the difficulty of aiming the current object, based on:
3793
+ *
3794
+ * - cursor velocity to the current object,
3795
+ * - angle difficulty,
3796
+ * - sharp velocity increases,
3797
+ * - and slider difficulty.
3947
3798
  *
3948
3799
  * @param current The current object.
3800
+ * @param withSliders Whether to take slider difficulty into account.
3949
3801
  */
3950
- static evaluateDifficultyOf(current) {
3951
- if (current.object instanceof osuBase.Spinner) {
3802
+ static evaluateDifficultyOf(current, withSliders) {
3803
+ const last = current.previous(0);
3804
+ if (current.object instanceof osuBase.Spinner ||
3805
+ current.index <= 1 ||
3806
+ last.object instanceof osuBase.Spinner) {
3952
3807
  return 0;
3953
3808
  }
3954
- const deltaDifferenceEpsilon = current.hitWindowFor(osuBase.HitResult.Great) * 0.3;
3955
- let rhythmComplexitySum = 0;
3956
- let island = new OsuIsland(deltaDifferenceEpsilon);
3957
- let previousIsland = new OsuIsland(deltaDifferenceEpsilon);
3958
- const islandCounts = new Map();
3959
- // Store the ratio of the current start of an island to buff for tighter rhythms.
3960
- let startRatio = 0;
3961
- let firstDeltaSwitch = false;
3962
- let rhythmStart = 0;
3963
- const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
3964
- while (rhythmStart < historicalNoteCount - 2 &&
3965
- current.startTime - current.previous(rhythmStart).startTime <
3966
- this.historyTimeMax) {
3967
- ++rhythmStart;
3809
+ const last2 = current.previous(2);
3810
+ const radius = current.normalizedRadius;
3811
+ const diameter = current.normalizedDiameter;
3812
+ // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
3813
+ const currentDistance = withSliders
3814
+ ? current.lazyJumpDistance
3815
+ : current.jumpDistance;
3816
+ let currentVelocity = currentDistance / current.strainTime;
3817
+ // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
3818
+ if (last.object instanceof osuBase.Slider && withSliders) {
3819
+ const sliderDistance = last.lazyTravelDistance + current.lazyJumpDistance;
3820
+ currentVelocity = Math.max(currentVelocity, sliderDistance / current.strainTime);
3968
3821
  }
3969
- let prevObject = current.previous(rhythmStart);
3970
- let lastObject = current.previous(rhythmStart + 1);
3971
- for (let i = rhythmStart; i > 0; --i) {
3972
- const currentObject = current.previous(i - 1);
3973
- // Scale note 0 to 1 from history to now.
3974
- const timeDecay = (this.historyTimeMax -
3975
- (current.startTime - currentObject.startTime)) /
3976
- this.historyTimeMax;
3977
- const noteDecay = (historicalNoteCount - i) / historicalNoteCount;
3978
- // Either we're limited by time or limited by object count.
3979
- const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
3980
- // Use custom cap value to ensure that that at this point delta time is actually zero.
3981
- const currentDelta = Math.max(currentObject.deltaTime, 1e-7);
3982
- const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
3983
- const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
3984
- // Calculate how much current delta difference deserves a rhythm bonus
3985
- // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
3986
- const deltaDifference = Math.max(prevDelta, currentDelta) /
3987
- Math.min(prevDelta, currentDelta);
3988
- // Take only the fractional part of the value since we are only interested in punishing multiples.
3989
- const deltaDifferenceFraction = deltaDifference - Math.trunc(deltaDifference);
3990
- const currentRatio = 1 +
3991
- this.rhythmRatioMultiplier *
3992
- Math.min(0.5, osuBase.MathUtils.smoothstepBellCurve(deltaDifferenceFraction));
3993
- // Reduce ratio bonus if delta difference is too big
3994
- const differenceMultiplier = osuBase.MathUtils.clamp(2 - deltaDifference / 8, 0, 1);
3995
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
3996
- let effectiveRatio = windowPenalty * currentRatio * differenceMultiplier;
3997
- if (firstDeltaSwitch) {
3998
- if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
3999
- // Island is still progressing, count size.
4000
- island.addDelta(currentDelta);
4001
- }
4002
- else {
4003
- // BPM change is into slider, this is easy acc window.
4004
- if (currentObject.object instanceof osuBase.Slider) {
4005
- effectiveRatio /= 8;
4006
- }
4007
- // BPM change was from a slider, this is easier typically than circle -> circle.
4008
- // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
4009
- // than bursts without sliders.
4010
- if (prevObject.object instanceof osuBase.Slider) {
4011
- effectiveRatio *= 0.3;
4012
- }
4013
- // Repeated island polarity (2 -> 4, 3 -> 5).
4014
- if (island.isSimilarPolarity(previousIsland)) {
4015
- effectiveRatio /= 2;
4016
- }
4017
- // Previous increase happened a note ago.
4018
- // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
4019
- if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
4020
- prevDelta > currentDelta + deltaDifferenceEpsilon) {
4021
- effectiveRatio /= 8;
4022
- }
4023
- // Repeated island size (ex: triplet -> triplet).
4024
- // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
4025
- if (previousIsland.deltaCount == island.deltaCount) {
3822
+ const prevDistance = withSliders
3823
+ ? last.lazyJumpDistance
3824
+ : last.jumpDistance;
3825
+ const prevVelocity = prevDistance / last.strainTime;
3826
+ // Start strain with regular velocity.
3827
+ let strain = currentVelocity;
3828
+ strain *= this.calculateVectorAngleRepetition(current, last);
3829
+ if (current.angle !== null && last.angle !== null) {
3830
+ const currentAngle = current.angle;
3831
+ const lastAngle = last.angle;
3832
+ // Rewarding angles, take the smaller velocity as base.
3833
+ const velocityInfluence = Math.min(currentVelocity, prevVelocity);
3834
+ let acuteAngleBonus = 0;
3835
+ if (
3836
+ // If rhythms are the same.
3837
+ Math.max(current.strainTime, last.strainTime) <
3838
+ 1.25 * Math.min(current.strainTime, last.strainTime)) {
3839
+ acuteAngleBonus =
3840
+ this.calculateAcuteAngleAcuteness(currentAngle);
3841
+ // Penalize angle repetition. It is important to do it _before_ multiplying by anything because we compare raw acuteness here.
3842
+ acuteAngleBonus *=
3843
+ 0.08 +
3844
+ 0.92 *
3845
+ (1 -
3846
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleAcuteness(lastAngle), 3)));
3847
+ // Apply acute angle bonus for BPM above 300 1/2.
3848
+ acuteAngleBonus *=
3849
+ velocityInfluence *
3850
+ osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
3851
+ osuBase.MathUtils.smootherstep(currentDistance, 0, diameter * 2);
3852
+ }
3853
+ let wideAngleBonus = this.calculateWideAngleAcuteness(currentAngle);
3854
+ // Penalize angle repetition. It is important to do it _before_ multiplying by velocity because we compare raw wideness here.
3855
+ wideAngleBonus *=
3856
+ 0.25 +
3857
+ 0.75 *
3858
+ (1 -
3859
+ Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleAcuteness(lastAngle), 3)));
3860
+ // Rescale velocity for wide angle bonus.
3861
+ const wideAngleTimeScale = 1.45;
3862
+ let wideAngleCurrentVelocity = currentDistance /
3863
+ Math.pow(current.strainTime, wideAngleTimeScale);
3864
+ const wideAnglePrevVelocity = prevDistance / Math.pow(last.strainTime, wideAngleTimeScale);
3865
+ if (last.object instanceof osuBase.Slider && withSliders) {
3866
+ const sliderDistance = last.lazyTravelDistance + current.lazyJumpDistance;
3867
+ wideAngleCurrentVelocity = Math.max(wideAngleCurrentVelocity, sliderDistance /
3868
+ Math.pow(current.strainTime, wideAngleTimeScale));
3869
+ }
3870
+ wideAngleBonus *= Math.min(wideAngleCurrentVelocity, wideAnglePrevVelocity);
3871
+ if (last2 !== null) {
3872
+ // If objects just go back and forth through a middle point - don't give as much wide bonus.
3873
+ // Use previous(2) and previous(0) because angles calculation is done prevprev-prev-curr, so any
3874
+ // object's angle's center point is always the previous object.
3875
+ const distance = last2.object.stackedPosition.getDistance(last.object.stackedPosition);
3876
+ if (distance < 1) {
3877
+ wideAngleBonus *= 1 - 0.55 * (1 - distance);
3878
+ }
3879
+ }
3880
+ // Add in acute angle bonus or wide angle bonus, whichever is larger.
3881
+ strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier);
3882
+ // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
3883
+ // https://www.desmos.com/calculator/dp0v0nvowc
3884
+ strain +=
3885
+ velocityInfluence *
3886
+ osuBase.MathUtils.smootherstep(currentDistance, radius, diameter) *
3887
+ Math.pow(osuBase.MathUtils.reverseLerp(currentDistance, diameter * 3, diameter), 1.8) *
3888
+ osuBase.MathUtils.smootherstep(currentAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
3889
+ osuBase.MathUtils.smootherstep(prevDistance, radius, diameter) *
3890
+ Math.pow(osuBase.MathUtils.reverseLerp(prevDistance, diameter * 3, diameter), 1.8) *
3891
+ osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
3892
+ this.wiggleMultiplier;
3893
+ }
3894
+ if (Math.max(prevVelocity, currentVelocity)) {
3895
+ if (withSliders) {
3896
+ // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
3897
+ currentVelocity = currentDistance / current.strainTime;
3898
+ }
3899
+ // Scale with ratio of difference compared to half the max distance.
3900
+ const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
3901
+ Math.max(prevVelocity, currentVelocity), 0, 1);
3902
+ // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
3903
+ const overlapVelocityBuff = Math.min((diameter * 1.25) /
3904
+ Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
3905
+ let velocityChangeBonus = overlapVelocityBuff * distanceRatio;
3906
+ // Penalize for rhythm changes.
3907
+ velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
3908
+ Math.max(current.strainTime, last.strainTime), 2);
3909
+ strain += velocityChangeBonus * this.velocityChangeMultiplier;
3910
+ }
3911
+ if (current.object instanceof osuBase.Slider && withSliders) {
3912
+ // Reward sliders based on velocity.
3913
+ const sliderBonus = current.travelDistance / current.travelTime;
3914
+ strain +=
3915
+ (sliderBonus < 1 ? sliderBonus : Math.pow(sliderBonus, 0.75)) *
3916
+ this.sliderMultiplier;
3917
+ }
3918
+ // Apply high circle size bonus
3919
+ strain *= current.smallCircleBonus;
3920
+ strain *= this.highBpmBonus(current.strainTime);
3921
+ return strain;
3922
+ }
3923
+ static calculateWideAngleAcuteness(angle) {
3924
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
3925
+ }
3926
+ static calculateAcuteAngleAcuteness(angle) {
3927
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
3928
+ }
3929
+ static highBpmBonus(ms) {
3930
+ return 1 / (1 - Math.pow(0.03, Math.pow(ms / 1000, 0.65)));
3931
+ }
3932
+ static calculateVectorAngleRepetition(current, prev) {
3933
+ if (current.angle === null || prev.angle === null) {
3934
+ return 1;
3935
+ }
3936
+ let constantAngleCount = 0;
3937
+ for (let i = 0; i < this.angleRepetitionNoteLimit; ++i) {
3938
+ const loopObj = current.previous(i);
3939
+ if (!loopObj) {
3940
+ break;
3941
+ }
3942
+ // Only consider vectors in the same jump section, as stopping to change rhythm ruins momentum.
3943
+ if (Math.max(current.strainTime, loopObj.strainTime) >
3944
+ 1.1 * Math.min(current.strainTime, loopObj.strainTime)) {
3945
+ break;
3946
+ }
3947
+ if (loopObj.normalizedVectorAngle !== null &&
3948
+ current.normalizedVectorAngle !== null) {
3949
+ const angleDifference = Math.abs(current.normalizedVectorAngle -
3950
+ loopObj.normalizedVectorAngle);
3951
+ // Refer to this Desmos for tuning.
3952
+ // Constants need to be precise so that values stay within the range of 0 and 1.
3953
+ // https://www.desmos.com/calculator/a8jesv5sv2
3954
+ constantAngleCount += Math.cos(8 *
3955
+ Math.min(osuBase.MathUtils.degreesToRadians(11.25), angleDifference));
3956
+ }
3957
+ }
3958
+ const vectorRepetition = Math.pow(Math.min(0.5 / constantAngleCount, 1), 2);
3959
+ const stackFactor = osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, current.normalizedDiameter);
3960
+ const angleDifferenceAdjusted = Math.cos(2 *
3961
+ Math.min(osuBase.MathUtils.degreesToRadians(45), Math.abs(current.angle - prev.angle) * stackFactor));
3962
+ const baseNerf = 1 -
3963
+ this.maximumRepetitionNerf *
3964
+ this.calculateAcuteAngleAcuteness(prev.angle) *
3965
+ angleDifferenceAdjusted;
3966
+ return Math.pow(baseNerf +
3967
+ (1 - baseNerf) *
3968
+ vectorRepetition *
3969
+ this.maximumVectorInfluence *
3970
+ stackFactor, 2);
3971
+ }
3972
+ }
3973
+ OsuSnapAimEvaluator.wideAngleMultiplier = 9.67;
3974
+ OsuSnapAimEvaluator.acuteAngleMultiplier = 2.41;
3975
+ OsuSnapAimEvaluator.sliderMultiplier = 1.5;
3976
+ OsuSnapAimEvaluator.velocityChangeMultiplier = 0.9;
3977
+ // Increasing this multiplier beyond 1.02 reduces difficulty as distance increases.
3978
+ // Refer to the desmos link above the wiggle bonus calculation.
3979
+ OsuSnapAimEvaluator.wiggleMultiplier = 1.02;
3980
+ OsuSnapAimEvaluator.angleRepetitionNoteLimit = 6;
3981
+ OsuSnapAimEvaluator.maximumRepetitionNerf = 0.15;
3982
+ OsuSnapAimEvaluator.maximumVectorInfluence = 0.5;
3983
+
3984
+ /**
3985
+ * An evaluator for calculating osu!standard flow aim difficulty.
3986
+ */
3987
+ class OsuFlowAimEvaluator {
3988
+ static evaluateDifficultyOf(current, withSliders) {
3989
+ var _a;
3990
+ if (current.object instanceof osuBase.Spinner ||
3991
+ current.index <= 1 ||
3992
+ ((_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.object) instanceof osuBase.Spinner) {
3993
+ return 0;
3994
+ }
3995
+ const last = current.previous(0);
3996
+ const lastLast = current.previous(1);
3997
+ const currentDistance = withSliders
3998
+ ? current.lazyJumpDistance
3999
+ : current.jumpDistance;
4000
+ const prevDistance = withSliders
4001
+ ? last.lazyJumpDistance
4002
+ : last.jumpDistance;
4003
+ let currentVelocity = currentDistance / current.strainTime;
4004
+ if (last.object instanceof osuBase.Slider && withSliders) {
4005
+ // If the last object is a slider, then we extend the travel velocity through the slider into the current object.
4006
+ const sliderDistance = last.lazyTravelDistance + current.lazyJumpDistance;
4007
+ currentVelocity = Math.max(currentVelocity, sliderDistance / current.strainTime);
4008
+ }
4009
+ const prevVelocity = prevDistance / last.strainTime;
4010
+ let flowDifficulty = currentVelocity;
4011
+ // Apply high circle size bonus to the base velocity.
4012
+ // We use reduced CS bonus here because the bonus was made for an evaluator with a different d/t scaling.
4013
+ flowDifficulty *= Math.sqrt(current.smallCircleBonus);
4014
+ // Rhythm changes are harder to flow.
4015
+ flowDifficulty *=
4016
+ 1 +
4017
+ Math.min(0.25, Math.pow((Math.max(current.strainTime, last.strainTime) -
4018
+ Math.min(current.strainTime, last.strainTime)) /
4019
+ 50, 4));
4020
+ if (current.angle !== null && last.angle !== null) {
4021
+ // Low angular velocity (consistent angles) is easier to follow than erratic flow.
4022
+ const angleDifference = Math.abs(current.angle - last.angle);
4023
+ const angleDifferenceAdjusted = Math.sin(angleDifference / 2) * 180;
4024
+ const angularVelocity = angleDifferenceAdjusted / (current.strainTime * 0.1);
4025
+ flowDifficulty *= 0.8 + Math.sqrt(angularVelocity / 270);
4026
+ }
4027
+ // If all three notes overlap, do not reward bonuses as there is no required additional movement.
4028
+ let overlappedNotesWeight = 1;
4029
+ if (current.index > 2) {
4030
+ const o1 = this.calculateOverlapFactor(current, last);
4031
+ const o2 = this.calculateOverlapFactor(current, lastLast);
4032
+ const o3 = this.calculateOverlapFactor(last, lastLast);
4033
+ overlappedNotesWeight = 1 - o1 * o2 * o3;
4034
+ }
4035
+ if (current.angle !== null) {
4036
+ // Acute angles are hard to flow.
4037
+ flowDifficulty +=
4038
+ currentVelocity *
4039
+ OsuSnapAimEvaluator.calculateAcuteAngleAcuteness(current.angle) *
4040
+ overlappedNotesWeight;
4041
+ }
4042
+ if (Math.max(prevVelocity, currentVelocity)) {
4043
+ if (withSliders) {
4044
+ currentVelocity = currentDistance / current.strainTime;
4045
+ }
4046
+ // Scale with ratio of difference compared to 0.5 * max distance.
4047
+ const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
4048
+ Math.max(prevVelocity, currentVelocity), 0, 1);
4049
+ // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
4050
+ const overlapVelocityBuff = Math.min((current.normalizedDiameter * 1.25) /
4051
+ Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
4052
+ flowDifficulty +=
4053
+ overlapVelocityBuff *
4054
+ distanceRatio *
4055
+ overlappedNotesWeight *
4056
+ this.velocityChangeMultiplier;
4057
+ }
4058
+ if (current.object instanceof osuBase.Slider && withSliders) {
4059
+ // Include slider velocity to make velocity more consistent with snap.
4060
+ flowDifficulty += current.travelDistance / current.travelTime;
4061
+ }
4062
+ // The final velocity is being raised to a power because flow difficulty scales harder with both high
4063
+ // distance and time, and we want to account for that.
4064
+ flowDifficulty = Math.pow(flowDifficulty, 1.45);
4065
+ // Reduce difficulty for low spacing since spacing below radius is always to be flowed.
4066
+ return (flowDifficulty *
4067
+ osuBase.MathUtils.smootherstep(currentDistance, 0, current.normalizedRadius));
4068
+ }
4069
+ static calculateOverlapFactor(o1, o2) {
4070
+ const distance = o1.object.stackedPosition.getDistance(o2.object.stackedPosition);
4071
+ const { radius } = o1.object;
4072
+ return osuBase.MathUtils.clamp(1 - Math.pow(Math.max(distance - radius, 0) / radius, 2), 0, 1);
4073
+ }
4074
+ }
4075
+ OsuFlowAimEvaluator.velocityChangeMultiplier = 0.52;
4076
+
4077
+ /**
4078
+ * Evaluator for reading difficulty in osu!standard.
4079
+ */
4080
+ class OsuReadingEvaluator {
4081
+ /**
4082
+ * Evaluates the difficulty of reading the object.
4083
+ */
4084
+ static evaluateDifficultyOf(current, mods) {
4085
+ if (current.object instanceof osuBase.Spinner || current.index === 0) {
4086
+ return 0;
4087
+ }
4088
+ // 1.5 circles distance between centers
4089
+ const distanceInfluenceThreshold = current.normalizedDiameter * 1.5;
4090
+ const next = current.next(0);
4091
+ // Only allow velocity to buff
4092
+ const velocity = Math.max(1, current.lazyJumpDistance / current.strainTime);
4093
+ const currentVisibleObjectDensity = this.retrieveCurrentVisibleObjectDensity(current);
4094
+ const pastObjectDifficultyInfluence = this.getPastObjectDifficultyInfluence(current, distanceInfluenceThreshold);
4095
+ const constantAngleNerfFactor = this.getConstantAngleNerfFactor(current);
4096
+ const noteDensityDifficulty = this.calculateDensityDifficulty(next, distanceInfluenceThreshold, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity);
4097
+ const hiddenDifficulty = this.calculateHiddenDifficulty(current, mods, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor);
4098
+ const preemptDifficulty = this.calculatePreemptDifficulty(velocity, constantAngleNerfFactor, current.timePreempt);
4099
+ let difficulty = osuBase.MathUtils.norm(1.5, preemptDifficulty, hiddenDifficulty, noteDensityDifficulty);
4100
+ // Having less time to process information is harder.
4101
+ difficulty *= this.highBpmBonus(current.strainTime);
4102
+ return difficulty;
4103
+ }
4104
+ /**
4105
+ * Calculates the density difficulty of the current object and how hard it is to aim it because of it based on:
4106
+ *
4107
+ * - cursor velocity to the current object,
4108
+ * - how many times the current object's angle was repeated,
4109
+ * - density of objects visible when the current object appears, and
4110
+ * - density of objects visible when the current object needs to be clicked.
4111
+ */
4112
+ static calculateDensityDifficulty(next, distanceInfluenceThreshold, velocity, constantAngleNerfFactor, pastObjectDifficultyInfluence, currentVisibleObjectDensity) {
4113
+ // Consider future densities too because it can make the path the cursor takes less clear.
4114
+ let futureObjectDifficultyInfluence = Math.sqrt(currentVisibleObjectDensity);
4115
+ if (next !== null) {
4116
+ // Reduce difficulty if movement to next object is small.
4117
+ futureObjectDifficultyInfluence *= osuBase.MathUtils.smootherstep(next.lazyJumpDistance, 15, distanceInfluenceThreshold);
4118
+ }
4119
+ // Value higher note densities exponentially.
4120
+ let noteDensityDifficulty = Math.pow(pastObjectDifficultyInfluence + futureObjectDifficultyInfluence, 1.7) *
4121
+ 0.4 *
4122
+ constantAngleNerfFactor *
4123
+ velocity;
4124
+ // Award only denser than average maps.
4125
+ noteDensityDifficulty = Math.max(0, noteDensityDifficulty - this.densityDifficultyBase);
4126
+ // Apply a soft cap to general density reading to account for partial memorization
4127
+ noteDensityDifficulty =
4128
+ Math.pow(noteDensityDifficulty, 0.45) * this.densityMultiplier;
4129
+ return noteDensityDifficulty;
4130
+ }
4131
+ /**
4132
+ * Calculates the difficulty of aiming the current object when the approach rate is very high based on:
4133
+ *
4134
+ * - cursor velocity to the current object,
4135
+ * - how many times the current object's angle was repeated, and
4136
+ * - how many milliseconds elapse between the approach circle appearing and touching the inner circle.
4137
+ */
4138
+ static calculatePreemptDifficulty(velocity, constantAngleNerfFactor, preempt) {
4139
+ // Arbitrary curve for the base value preempt difficulty should have as approach rate increases.
4140
+ // https://www.desmos.com/calculator/c175335a71
4141
+ let preemptDifficulty = Math.pow((this.preemptStartingPoint -
4142
+ preempt +
4143
+ Math.abs(preempt - this.preemptStartingPoint)) /
4144
+ 2, 2.5) / this.preemptBalancingFactor;
4145
+ preemptDifficulty *= constantAngleNerfFactor * velocity;
4146
+ return preemptDifficulty;
4147
+ }
4148
+ /**
4149
+ * Calculates the difficulty of aiming the current object when the Hidden mod is active based on:
4150
+ *
4151
+ * - cursor velocity to the current object,
4152
+ * - time the current object spends invisible,
4153
+ * - density of objects visible when the current object appears,
4154
+ * - density of objects visible when the current object needs to be clicked,
4155
+ * - how many times the current object's angle was repeated, and
4156
+ * - if the current object is perfectly stacked to the previous one.
4157
+ */
4158
+ static calculateHiddenDifficulty(current, mods, pastObjectDifficultyInfluence, currentVisibleObjectDensity, velocity, constantAngleNerfFactor) {
4159
+ var _a;
4160
+ if (!mods.has(osuBase.ModHidden) ||
4161
+ ((_a = mods.get(osuBase.ModHidden)) === null || _a === void 0 ? void 0 : _a.onlyFadeApproachCircles.value)) {
4162
+ return 0;
4163
+ }
4164
+ // Higher preempt means that time spent invisible is higher too, we want to reward that.
4165
+ const preemptFactor = Math.pow(current.timePreempt, 2.2) * 0.01;
4166
+ // Account for both past and current densities.
4167
+ const densityFactor = Math.pow(currentVisibleObjectDensity + pastObjectDifficultyInfluence, 3.3) * 3;
4168
+ let hiddenDifficulty = (preemptFactor + densityFactor) *
4169
+ constantAngleNerfFactor *
4170
+ velocity *
4171
+ 0.01;
4172
+ // Apply a soft cap to general HD reading to account for partial memorization.
4173
+ hiddenDifficulty =
4174
+ Math.pow(hiddenDifficulty, 0.4) * this.hiddenMultiplier;
4175
+ const prev = current.previous(0);
4176
+ // Buff perfect stacks only if current note is completely invisible at the time you click the previous note.
4177
+ if (current.lazyJumpDistance === 0 &&
4178
+ current.opacityAt(prev.object.startTime, mods) === 0 &&
4179
+ // At the same time, we only want to buff them if the current note is already
4180
+ // animating at the time the previous note was clicked.
4181
+ prev.startTime > current.startTime - current.timePreempt) {
4182
+ // Perfect stacks are harder the less time between notes.
4183
+ hiddenDifficulty +=
4184
+ (this.hiddenMultiplier * 2500) /
4185
+ Math.pow(current.strainTime, 1.5);
4186
+ }
4187
+ return hiddenDifficulty;
4188
+ }
4189
+ static getPastObjectDifficultyInfluence(current, distanceInfluenceThreshold) {
4190
+ let pastObjectDifficultyInfluence = 0;
4191
+ for (const loopObj of this.retrievePastVisibleObjects(current)) {
4192
+ let loopDifficulty = current.opacityAt(loopObj.object.startTime);
4193
+ // When aiming an object small distances mean previous objects may be cheesed,
4194
+ // so it doesn't matter whether they were arranged confusingly.
4195
+ loopDifficulty *= osuBase.MathUtils.smootherstep(loopObj.lazyJumpDistance, 15, distanceInfluenceThreshold);
4196
+ // Account less for objects close to the max reading window.
4197
+ const timeBetweenCurrAndLoopObj = current.startTime - loopObj.startTime;
4198
+ const timeNerfFactor = this.getTimeNerfFactor(timeBetweenCurrAndLoopObj);
4199
+ loopDifficulty *= timeNerfFactor;
4200
+ pastObjectDifficultyInfluence += loopDifficulty;
4201
+ }
4202
+ return pastObjectDifficultyInfluence;
4203
+ }
4204
+ /**
4205
+ * Returns a list of objects that are visible on screen at the point in time the current object becomes visible.
4206
+ */
4207
+ static *retrievePastVisibleObjects(current) {
4208
+ for (let i = 0; i < current.index; ++i) {
4209
+ const hitObject = current.previous(i);
4210
+ if (!hitObject ||
4211
+ current.startTime - hitObject.startTime >
4212
+ this.readingWindowSize ||
4213
+ // Current object not visible at the time object needs to be clicked
4214
+ hitObject.startTime < current.startTime - current.timePreempt) {
4215
+ break;
4216
+ }
4217
+ yield hitObject;
4218
+ }
4219
+ }
4220
+ /**
4221
+ * Returns the density of objects visible at the point in time the current object needs to be clicked capped by the reading window.
4222
+ */
4223
+ static retrieveCurrentVisibleObjectDensity(current) {
4224
+ let visibleObjectCount = 0;
4225
+ let hitObject = current.next(0);
4226
+ while (hitObject !== null) {
4227
+ if (hitObject.startTime - current.startTime >
4228
+ this.readingWindowSize ||
4229
+ // Object not visible at the time current object needs to be clicked.
4230
+ current.startTime + hitObject.timePreempt < hitObject.startTime) {
4231
+ break;
4232
+ }
4233
+ const timeBetweenCurrAndLoopObj = hitObject.startTime - current.startTime;
4234
+ const timeNerfFactor = this.getTimeNerfFactor(timeBetweenCurrAndLoopObj);
4235
+ visibleObjectCount +=
4236
+ hitObject.opacityAt(current.object.startTime) * timeNerfFactor;
4237
+ hitObject = hitObject.next(0);
4238
+ }
4239
+ return visibleObjectCount;
4240
+ }
4241
+ /**
4242
+ * Returns a factor of how often the current object's angle has been repeated in a certain time frame.
4243
+ * It does this by checking the difference in angle between current and past objects and sums them based on a range of similarity.
4244
+ * https://www.desmos.com/calculator/eb057a4822
4245
+ */
4246
+ static getConstantAngleNerfFactor(current) {
4247
+ let constantAngleCount = 0;
4248
+ let index = 0;
4249
+ let currentTimeGap = 0;
4250
+ let loopObjPrev0 = current;
4251
+ let loopObjPrev1 = null;
4252
+ let loopObjPrev2 = null;
4253
+ while (currentTimeGap < this.minimumAngleRelevancyTime) {
4254
+ const loopObj = current.previous(index);
4255
+ if (!loopObj) {
4256
+ break;
4257
+ }
4258
+ // Account less for objects that are close to the time limit.
4259
+ const longIntervalFactor = 1 -
4260
+ osuBase.Interpolation.reverseLerp(loopObj.strainTime, this.maximumAngleRelevancyTime, this.minimumAngleRelevancyTime);
4261
+ if (loopObj.angle !== null && current.angle !== null) {
4262
+ const angleDifference = Math.abs(current.angle - loopObj.angle);
4263
+ let angleDifferenceAlternating = Math.PI;
4264
+ if (loopObjPrev0.angle !== null &&
4265
+ typeof (loopObjPrev1 === null || loopObjPrev1 === void 0 ? void 0 : loopObjPrev1.angle) === "number" &&
4266
+ typeof (loopObjPrev2 === null || loopObjPrev2 === void 0 ? void 0 : loopObjPrev2.angle) === "number") {
4267
+ angleDifferenceAlternating = Math.abs(loopObjPrev1.angle - loopObj.angle);
4268
+ angleDifferenceAlternating += Math.abs(loopObjPrev2.angle - loopObjPrev0.angle);
4269
+ let weight = 1;
4270
+ // Be sure that one of the angles is very sharp, when other is wide.
4271
+ weight *= osuBase.Interpolation.reverseLerp(osuBase.MathUtils.radiansToDegrees(Math.min(loopObj.angle, loopObjPrev0.angle)), 20, 5);
4272
+ weight *= osuBase.Interpolation.reverseLerp(osuBase.MathUtils.radiansToDegrees(Math.max(loopObj.angle, loopObjPrev0.angle)), 60, 120);
4273
+ // Interpolate between max angle difference and rescaled alternating difference, with
4274
+ // harsher scaling compared to normal difference.
4275
+ angleDifferenceAlternating = osuBase.Interpolation.lerp(Math.PI, 0.1 * angleDifferenceAlternating, weight);
4276
+ }
4277
+ const stackFactor = osuBase.MathUtils.smootherstep(loopObj.lazyJumpDistance, 0, current.normalizedRadius);
4278
+ constantAngleCount +=
4279
+ Math.cos(3 *
4280
+ Math.min(osuBase.MathUtils.degreesToRadians(30), Math.min(angleDifference, angleDifferenceAlternating) * stackFactor)) * longIntervalFactor;
4281
+ }
4282
+ currentTimeGap = current.startTime - loopObj.startTime;
4283
+ index++;
4284
+ loopObjPrev2 = loopObjPrev1;
4285
+ loopObjPrev1 = loopObjPrev0;
4286
+ loopObjPrev0 = loopObj;
4287
+ }
4288
+ return osuBase.MathUtils.clamp(2 / constantAngleCount, 0.2, 1);
4289
+ }
4290
+ /**
4291
+ * Returns a nerfing factor for when objects are very distant in time, affecting reading less.
4292
+ */
4293
+ static getTimeNerfFactor(deltaTime) {
4294
+ return osuBase.MathUtils.clamp(2 - deltaTime / (this.readingWindowSize / 2), 0, 1);
4295
+ }
4296
+ static highBpmBonus(ms) {
4297
+ return 1 / (1 - Math.pow(0.8, ms / 1000));
4298
+ }
4299
+ }
4300
+ OsuReadingEvaluator.readingWindowSize = 3000; // 3 seconds
4301
+ OsuReadingEvaluator.hiddenMultiplier = 0.28;
4302
+ OsuReadingEvaluator.densityMultiplier = 2.4;
4303
+ OsuReadingEvaluator.densityDifficultyBase = 2.5;
4304
+ OsuReadingEvaluator.preemptBalancingFactor = 140000;
4305
+ OsuReadingEvaluator.preemptStartingPoint = 500; // AR 9.66 in milliseconds
4306
+ OsuReadingEvaluator.minimumAngleRelevancyTime = 2000; // 2 seconds
4307
+ OsuReadingEvaluator.maximumAngleRelevancyTime = 200;
4308
+
4309
+ /**
4310
+ * An evaluator for calculating osu!standard Rhythm skill.
4311
+ */
4312
+ class OsuRhythmEvaluator {
4313
+ /**
4314
+ * Calculates a rhythm multiplier for the difficulty of the tap associated
4315
+ * with historic data of the current object.
4316
+ *
4317
+ * @param current The current object.
4318
+ */
4319
+ static evaluateDifficultyOf(current) {
4320
+ if (current.object instanceof osuBase.Spinner) {
4321
+ return 0;
4322
+ }
4323
+ const deltaDifferenceEpsilon = current.hitWindowFor(osuBase.HitResult.Great) * 0.3;
4324
+ let rhythmComplexitySum = 0;
4325
+ let island = new Island(Number.MAX_SAFE_INTEGER);
4326
+ let previousIsland = new Island(Number.MAX_SAFE_INTEGER);
4327
+ const islands = [];
4328
+ // Store the ratio of the current start of an island to buff for tighter rhythms.
4329
+ let startRatio = 0;
4330
+ let firstDeltaSwitch = false;
4331
+ let rhythmStart = 0;
4332
+ const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
4333
+ while (rhythmStart < historicalNoteCount - 2 &&
4334
+ current.startTime - current.previous(rhythmStart).startTime <
4335
+ this.historyTimeMax) {
4336
+ ++rhythmStart;
4337
+ }
4338
+ let prevObject = current.previous(rhythmStart);
4339
+ let lastObject = current.previous(rhythmStart + 1);
4340
+ for (let i = rhythmStart; i > 0; --i) {
4341
+ const currentObject = current.previous(i - 1);
4342
+ if (currentObject.object instanceof osuBase.Spinner) {
4343
+ continue;
4344
+ }
4345
+ // Scale note 0 to 1 from history to now.
4346
+ const timeDecay = (this.historyTimeMax -
4347
+ (current.startTime - currentObject.startTime)) /
4348
+ this.historyTimeMax;
4349
+ const noteDecay = (historicalNoteCount - i) / historicalNoteCount;
4350
+ // Either we're limited by time or limited by object count.
4351
+ const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
4352
+ // Use custom cap value to ensure that at this point delta time is actually zero.
4353
+ const currentDelta = Math.max(currentObject.deltaTime, 1e-7);
4354
+ const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
4355
+ const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
4356
+ if (island.delta == Number.MAX_SAFE_INTEGER) {
4357
+ island = new Island(currentDelta);
4358
+ }
4359
+ // Calculate how much current delta difference deserves a rhythm bonus
4360
+ // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
4361
+ const deltaDifference = Math.max(prevDelta, currentDelta) /
4362
+ Math.min(prevDelta, currentDelta);
4363
+ // Reduce ratio bonus if delta difference is too big
4364
+ const differenceMultiplier = osuBase.MathUtils.clamp(2 - deltaDifference / 8, 0, 1);
4365
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
4366
+ let effectiveRatio = windowPenalty *
4367
+ this.getEffectiveRatio(deltaDifference) *
4368
+ differenceMultiplier;
4369
+ // If the previous object is a slider, it might be easier to tap since you do not have to do a whole tapping motion.
4370
+ // While a full deltatime might end up some weird ratio, the "unpress->tap" motion might be simple.
4371
+ // For example, a slider-circle-circle pattern should be evaluated as a regular triple and not as a single->double.
4372
+ if (prevObject.object instanceof osuBase.Slider) {
4373
+ const sliderLazyEndDelta = currentObject.minimumJumpTime;
4374
+ const sliderLazyEndDeltaDifference = Math.max(sliderLazyEndDelta, currentDelta) /
4375
+ Math.min(sliderLazyEndDelta, currentDelta);
4376
+ const sliderRealEndDelta = currentObject.lastObjectEndDeltaTime;
4377
+ const sliderRealEndDeltaDifference = Math.max(sliderRealEndDelta, currentDelta) /
4378
+ Math.min(sliderRealEndDelta, currentDelta);
4379
+ const sliderEffectiveRatio = Math.min(this.getEffectiveRatio(sliderLazyEndDeltaDifference), this.getEffectiveRatio(sliderRealEndDeltaDifference));
4380
+ effectiveRatio = Math.min(sliderEffectiveRatio, effectiveRatio);
4381
+ }
4382
+ const isSpeedingUp = prevDelta > currentDelta + deltaDifferenceEpsilon;
4383
+ if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
4384
+ island.addDelta(currentDelta);
4385
+ }
4386
+ if (firstDeltaSwitch) {
4387
+ if (Math.abs(prevDelta - currentDelta) > deltaDifferenceEpsilon) {
4388
+ // BPM change is into slider, this is easy acc window.
4389
+ if (currentObject.object instanceof osuBase.Slider) {
4390
+ effectiveRatio /= 2;
4391
+ }
4392
+ // Repeated island polarity (2 -> 4, 3 -> 5).
4393
+ if (island.isSimilarPolarity(previousIsland, deltaDifferenceEpsilon)) {
4394
+ effectiveRatio /= 2;
4395
+ }
4396
+ // Previous increase happened a note ago.
4397
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
4398
+ if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
4399
+ prevDelta > currentDelta + deltaDifferenceEpsilon) {
4400
+ effectiveRatio /= 8;
4401
+ }
4402
+ // Repeated island size (ex: triplet -> triplet).
4403
+ // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
4404
+ if (previousIsland.deltaCount == island.deltaCount) {
4026
4405
  effectiveRatio /= 2;
4027
4406
  }
4028
- let islandFound = false;
4029
- for (const [currentIsland, count] of islandCounts) {
4030
- if (!island.equals(currentIsland)) {
4407
+ if (isSpeedingUp) {
4408
+ effectiveRatio *= 0.65;
4409
+ }
4410
+ let found = false;
4411
+ for (const existingIsland of islands) {
4412
+ if (!existingIsland.almostEquals(island, deltaDifferenceEpsilon)) {
4031
4413
  continue;
4032
4414
  }
4033
- islandFound = true;
4034
- let islandCount = count;
4035
- if (previousIsland.equals(island)) {
4036
- // Only add island to island counts if they're going one after another.
4037
- ++islandCount;
4038
- islandCounts.set(currentIsland, islandCount);
4415
+ // Only add island to island counts if they're going one after another.
4416
+ if (previousIsland.almostEquals(island, deltaDifferenceEpsilon)) {
4417
+ ++existingIsland.occurrences;
4039
4418
  }
4419
+ const power = osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75);
4040
4420
  // Repeated island (ex: triplet -> triplet).
4041
4421
  // Graph: https://www.desmos.com/calculator/pj7an56zwf
4042
- effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75)));
4422
+ effectiveRatio *= Math.min(3 / existingIsland.occurrences, Math.pow(1 / existingIsland.occurrences, power));
4423
+ found = true;
4043
4424
  break;
4044
4425
  }
4045
- if (!islandFound) {
4046
- islandCounts.set(island, 1);
4426
+ if (!found && island.deltaCount > 0) {
4427
+ islands.push(island);
4047
4428
  }
4048
4429
  // Scale down the difficulty if the object is doubletappable.
4049
4430
  effectiveRatio *=
4050
4431
  1 - prevObject.getDoubletapness(currentObject) * 0.75;
4051
- rhythmComplexitySum +=
4052
- Math.sqrt(effectiveRatio * startRatio) *
4053
- currentHistoricalDecay;
4432
+ if (island.deltaCount > 1) {
4433
+ rhythmComplexitySum +=
4434
+ Math.sqrt(effectiveRatio * startRatio) *
4435
+ currentHistoricalDecay;
4436
+ }
4437
+ else {
4438
+ // Constant difficulty for single-note islands.
4439
+ rhythmComplexitySum += 0.7 * currentHistoricalDecay;
4440
+ }
4054
4441
  startRatio = effectiveRatio;
4055
4442
  previousIsland = island;
4056
4443
  if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
@@ -4058,7 +4445,7 @@ class OsuRhythmEvaluator {
4058
4445
  // If we're speeding up, this stays as is and we keep counting island size.
4059
4446
  firstDeltaSwitch = false;
4060
4447
  }
4061
- island = new OsuIsland(currentDelta, deltaDifferenceEpsilon);
4448
+ island = new Island(currentDelta);
4062
4449
  }
4063
4450
  }
4064
4451
  else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
@@ -4076,20 +4463,28 @@ class OsuRhythmEvaluator {
4076
4463
  effectiveRatio *= 0.6;
4077
4464
  }
4078
4465
  startRatio = effectiveRatio;
4079
- island = new OsuIsland(currentDelta, deltaDifferenceEpsilon);
4466
+ island = new Island(currentDelta);
4080
4467
  }
4081
4468
  lastObject = prevObject;
4082
4469
  prevObject = currentObject;
4083
4470
  }
4084
- return ((Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) *
4085
- (1 - current.getDoubletapness(current.next(0)))) /
4471
+ // If the current island is long we don't want the sum to have as big of an effect.
4472
+ rhythmComplexitySum *= osuBase.Interpolation.reverseLerp(island.deltaCount, 22, 3);
4473
+ return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
4086
4474
  2);
4087
4475
  }
4476
+ static getEffectiveRatio(deltaDifference) {
4477
+ // Take only the fractional part of the value since we are only interested in punishing multiples.
4478
+ const deltaDifferenceFraction = deltaDifference - Math.trunc(deltaDifference);
4479
+ return (1 +
4480
+ this.rhythmRatioMultiplier *
4481
+ Math.min(0.5, osuBase.MathUtils.smoothstepBellCurve(deltaDifferenceFraction)));
4482
+ }
4088
4483
  }
4089
4484
  OsuRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
4090
4485
  OsuRhythmEvaluator.historyObjectsMax = 32;
4091
- OsuRhythmEvaluator.rhythmOverallMultiplier = 1;
4092
- OsuRhythmEvaluator.rhythmRatioMultiplier = 15;
4486
+ OsuRhythmEvaluator.rhythmOverallMultiplier = 0.95;
4487
+ OsuRhythmEvaluator.rhythmRatioMultiplier = 26;
4093
4488
 
4094
4489
  /**
4095
4490
  * An evaluator for calculating osu!standard speed skill.
@@ -4099,18 +4494,14 @@ class OsuSpeedEvaluator {
4099
4494
  * Evaluates the difficulty of tapping the current object, based on:
4100
4495
  *
4101
4496
  * - time between pressing the previous and current object,
4102
- * - distance between those objects,
4103
4497
  * - and how easily they can be cheesed.
4104
4498
  *
4105
4499
  * @param current The current object.
4106
- * @param mods The mods applied.
4107
4500
  */
4108
- static evaluateDifficultyOf(current, mods) {
4109
- var _a;
4501
+ static evaluateDifficultyOf(current) {
4110
4502
  if (current.object instanceof osuBase.Spinner) {
4111
4503
  return 0;
4112
4504
  }
4113
- const prev = current.previous(0);
4114
4505
  let strainTime = current.strainTime;
4115
4506
  // Nerf doubletappable doubles.
4116
4507
  const doubletapness = 1 - current.getDoubletapness(current.next(0));
@@ -4120,389 +4511,389 @@ class OsuSpeedEvaluator {
4120
4511
  // speedBonus will be 0.0 for BPM < 200
4121
4512
  let speedBonus = 0;
4122
4513
  // Add additional scaling bonus for streams/bursts higher than 200bpm
4123
- if (strainTime < this.minSpeedBonus) {
4514
+ if (osuBase.MathUtils.millisecondsToBPM(strainTime) > this.minSpeedBonus) {
4124
4515
  speedBonus =
4125
- 0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
4126
- }
4127
- const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
4128
- const singleSpacingThreshold = current.normalizedDiameter * 1.25;
4129
- // Cap distance at spacing threshold
4130
- const distance = Math.min(singleSpacingThreshold, travelDistance + current.minimumJumpDistance);
4131
- // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
4132
- let distanceBonus = Math.pow(distance / singleSpacingThreshold, 3.95) *
4133
- this.DISTANCE_MULTIPLIER;
4134
- // Apply reduced small circle bonus because flow aim difficulty on small circles does not scale as hard as jumps.
4135
- distanceBonus *= Math.sqrt(current.smallCircleBonus);
4136
- if (mods.has(osuBase.ModAutopilot)) {
4137
- distanceBonus = 0;
4516
+ 0.75 *
4517
+ Math.pow((osuBase.MathUtils.bpmToMilliseconds(this.minSpeedBonus) -
4518
+ strainTime) /
4519
+ 40, 2);
4138
4520
  }
4139
4521
  // Base difficulty with all bonuses
4140
- const difficulty = ((1 + speedBonus + distanceBonus) * 1000) / strainTime;
4522
+ let difficulty = ((1 + speedBonus) * 1000) / strainTime;
4523
+ difficulty *= this.highBpmBonus(current.strainTime);
4141
4524
  // Apply penalty if there's doubletappable doubles
4142
4525
  return difficulty * doubletapness;
4143
4526
  }
4527
+ static highBpmBonus(ms) {
4528
+ return 1 / (1 - Math.pow(0.3, ms / 1000));
4529
+ }
4144
4530
  }
4145
4531
  // ~200 1/4 BPM streams
4146
- OsuSpeedEvaluator.minSpeedBonus = 75;
4147
- OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.8;
4532
+ OsuSpeedEvaluator.minSpeedBonus = 200;
4148
4533
 
4149
- class OsuRatingCalculator {
4150
- constructor(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor) {
4151
- this.mods = mods;
4152
- this.totalHits = totalHits;
4153
- this.approachRate = approachRate;
4154
- this.overallDifficulty = overallDifficulty;
4155
- this.mechanicalDifficultyRating = mechanicalDifficultyRating;
4156
- this.sliderFactor = sliderFactor;
4534
+ /**
4535
+ * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
4536
+ */
4537
+ class OsuAim extends VariableLengthStrainSkill {
4538
+ constructor(mods, withSliders) {
4539
+ super(mods);
4540
+ this.currentStrain = 0;
4541
+ this.skillMultiplierSnap = 70.9;
4542
+ this.skillMultiplierAgility = 2.35;
4543
+ this.skillMultiplierFlow = 242;
4544
+ this.skillMultiplierTotal = 1.12;
4545
+ this.combinedSnapNormExponent = 1.2;
4546
+ /**
4547
+ * The number of sections with the highest strains, which the peak strain reductions will apply to.
4548
+ * This is done in order to decrease their impact on the overall difficulty of the beatmap.
4549
+ */
4550
+ this.reducedSectionTime = 4000;
4551
+ /**
4552
+ * The baseline multiplier applied to the section with the biggest strain.
4553
+ */
4554
+ this.reducedStrainBaseline = 0.727;
4555
+ this.sliderStrains = [];
4556
+ this.maxSliderStrain = 0;
4557
+ this.withSliders = withSliders;
4157
4558
  }
4158
- computeAimRating(aimDifficultyValue) {
4159
- if (this.mods.has(osuBase.ModAutopilot)) {
4559
+ /**
4560
+ * Obtains the amount of sliders that are considered difficult in terms of relative strain.
4561
+ */
4562
+ countDifficultSliders() {
4563
+ if (this.sliderStrains.length === 0 || this.maxSliderStrain === 0) {
4160
4564
  return 0;
4161
4565
  }
4162
- let aimRating = OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue);
4163
- if (this.mods.has(osuBase.ModTouchDevice)) {
4164
- aimRating = Math.pow(aimRating, 0.8);
4165
- }
4166
- if (this.mods.has(osuBase.ModRelax)) {
4167
- aimRating *= 0.9;
4168
- }
4169
- if (this.mods.has(osuBase.ModMagnetised)) {
4170
- const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4171
- aimRating *= 1 - magnetisedStrength;
4172
- }
4173
- let ratingMultiplier = 1;
4174
- const approachRateLengthBonus = 0.95 +
4175
- 0.4 * Math.min(1, this.totalHits / 2000) +
4176
- (this.totalHits > 2000
4177
- ? Math.log10(this.totalHits / 2000) * 0.5
4178
- : 0);
4179
- let approachRateFactor = 0;
4180
- if (this.approachRate > 10.33) {
4181
- approachRateFactor = 0.3 * (this.approachRate - 10.33);
4566
+ return this.sliderStrains.reduce((total, strain) => total +
4567
+ 1 / (1 + Math.exp(-((strain / this.maxSliderStrain) * 12 - 6))), 0);
4568
+ }
4569
+ /**
4570
+ * Obtains the amount of sliders that are considered difficult in terms of relative strain, weighted by consistency.
4571
+ *
4572
+ * @param difficultyValue The final difficulty value.
4573
+ */
4574
+ countTopWeightedSliders(difficultyValue) {
4575
+ if (this.sliderStrains.length === 0) {
4576
+ return 0;
4182
4577
  }
4183
- else if (this.approachRate < 8) {
4184
- approachRateFactor = 0.05 * (8 - this.approachRate);
4578
+ const consistentTopStrain = difficultyValue * (1 - this.decayWeight);
4579
+ if (consistentTopStrain === 0) {
4580
+ return 0;
4185
4581
  }
4186
- if (this.mods.has(osuBase.ModRelax)) {
4187
- approachRateFactor = 0;
4582
+ // Use a weighted sum of all strains. Constants are arbitrary and give nice values
4583
+ return this.sliderStrains.reduce((total, next) => total +
4584
+ osuBase.MathUtils.offsetLogistic(next / consistentTopStrain, 0.88, 10, 1.1), 0);
4585
+ }
4586
+ strainValueAt(current) {
4587
+ if (this.mods.has(osuBase.ModAutopilot)) {
4588
+ return 0;
4188
4589
  }
4189
- // Buff for longer beatmaps with high AR.
4190
- ratingMultiplier += approachRateFactor * approachRateLengthBonus;
4191
- if (this.mods.has(osuBase.ModHidden)) {
4192
- const visibilityFactor = this.calculateAimVisibilityFactor();
4193
- ratingMultiplier += OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, visibilityFactor, this.sliderFactor);
4590
+ const decay = this.strainDecay(current.strainTime);
4591
+ this.currentStrain *= decay;
4592
+ this.currentStrain +=
4593
+ this.calculateAdjustedDifficulty(current) * (1 - decay);
4594
+ if (current.object instanceof osuBase.Slider) {
4595
+ this.sliderStrains.push(this.currentStrain);
4596
+ this.maxSliderStrain = Math.max(this.maxSliderStrain, this.currentStrain);
4194
4597
  }
4195
- // It is important to consider accuracy difficulty when scaling with accuracy.
4196
- ratingMultiplier *=
4197
- 0.98 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 2500;
4198
- return aimRating * Math.cbrt(ratingMultiplier);
4598
+ return this.currentStrain;
4199
4599
  }
4200
- computeSpeedRating(speedDifficultyValue) {
4201
- if (this.mods.has(osuBase.ModRelax)) {
4202
- return 0;
4600
+ calculateInitialStrain(time, current) {
4601
+ var _a, _b;
4602
+ return (this.currentStrain *
4603
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
4604
+ }
4605
+ saveToHitObject(current) {
4606
+ if (this.withSliders) {
4607
+ current.aimStrainWithSliders = this.currentStrain;
4203
4608
  }
4204
- let speedRating = OsuRatingCalculator.calculateDifficultyRating(speedDifficultyValue);
4205
- if (this.mods.has(osuBase.ModAutopilot)) {
4206
- speedRating *= 0.5;
4609
+ else {
4610
+ current.aimStrainWithoutSliders = this.currentStrain;
4207
4611
  }
4612
+ }
4613
+ calculateAdjustedDifficulty(current) {
4614
+ const snapDifficulty = OsuSnapAimEvaluator.evaluateDifficultyOf(current, this.withSliders) * this.skillMultiplierSnap;
4615
+ const agilityDifficulty = OsuAgilityEvaluator.evaluateDifficultyOf(current) *
4616
+ this.skillMultiplierAgility;
4617
+ const flowDifficulty = OsuFlowAimEvaluator.evaluateDifficultyOf(current, this.withSliders) * this.skillMultiplierFlow;
4618
+ let totalDifficulty = this.calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty);
4208
4619
  if (this.mods.has(osuBase.ModMagnetised)) {
4209
- // Reduce speed rating because of the distance scaling, with maximum reduction being 0.7.
4210
4620
  const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4211
- speedRating *= 1 - magnetisedStrength * 0.3;
4212
- }
4213
- let ratingMultiplier = 1;
4214
- const approachRateLengthBonus = 0.95 +
4215
- 0.4 * Math.min(1, this.totalHits / 2000) +
4216
- (this.totalHits > 2000
4217
- ? Math.log10(this.totalHits / 2000) * 0.5
4218
- : 0);
4219
- let approachRateFactor = 0;
4220
- if (this.approachRate > 10.33) {
4221
- approachRateFactor = 0.3 * (this.approachRate - 10.33);
4222
- }
4223
- if (this.mods.has(osuBase.ModAutopilot)) {
4224
- approachRateFactor = 0;
4621
+ totalDifficulty *= 1 - magnetisedStrength;
4225
4622
  }
4226
- // Buff for longer beatmaps with high AR.
4227
- ratingMultiplier += approachRateFactor * approachRateLengthBonus;
4228
- if (this.mods.has(osuBase.ModHidden)) {
4229
- const visibilityFactor = this.calculateSpeedVisibilityFactor();
4230
- ratingMultiplier += OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, visibilityFactor, this.sliderFactor);
4231
- }
4232
- ratingMultiplier *=
4233
- 0.95 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 750;
4234
- return speedRating * Math.cbrt(ratingMultiplier);
4623
+ totalDifficulty *=
4624
+ 0.985 + Math.pow(Math.max(0, current.overallDifficulty), 2) / 4000;
4625
+ return totalDifficulty;
4235
4626
  }
4236
- computeFlashlightRating(flashlightDifficultyValue) {
4237
- if (!this.mods.has(osuBase.ModFlashlight)) {
4238
- return 0;
4239
- }
4240
- let flashlightRating = OsuRatingCalculator.calculateDifficultyRating(flashlightDifficultyValue);
4627
+ calculateTotalValue(snapDifficulty, agilityDifficulty, flowDifficulty) {
4628
+ // We compare flow to combined snap and agility because snap by itself does not have enough difficulty
4629
+ // to be above flow on streams. Agility, on the other hand, is supposed to measure the rate of cursor
4630
+ // velocity changes while snapping. This means snapping every circle on a stream requires an enormous
4631
+ // amount of agility at which point it is easier to flow.
4632
+ let combinedSnapDifficulty = osuBase.MathUtils.norm(this.combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
4633
+ const pSnap = this.calculateSnapFlowProbability(flowDifficulty / combinedSnapDifficulty);
4634
+ const pFlow = 1 - pSnap;
4241
4635
  if (this.mods.has(osuBase.ModTouchDevice)) {
4242
- flashlightRating = Math.pow(flashlightRating, 0.8);
4636
+ // We do not adjust agility here since agility represents TD difficulty in a decent enough way.
4637
+ snapDifficulty = Math.pow(snapDifficulty, 0.89);
4638
+ combinedSnapDifficulty = osuBase.MathUtils.norm(this.combinedSnapNormExponent, snapDifficulty, agilityDifficulty);
4243
4639
  }
4244
4640
  if (this.mods.has(osuBase.ModRelax)) {
4245
- flashlightRating *= 0.7;
4246
- }
4247
- else if (this.mods.has(osuBase.ModAutopilot)) {
4248
- flashlightRating *= 0.4;
4249
- }
4250
- if (this.mods.has(osuBase.ModMagnetised)) {
4251
- const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4252
- flashlightRating *= 1 - magnetisedStrength;
4253
- }
4254
- if (this.mods.has(osuBase.ModDeflate)) {
4255
- const deflateInitialScale = this.mods.get(osuBase.ModDeflate).startScale.value;
4256
- flashlightRating *= osuBase.MathUtils.clamp(osuBase.Interpolation.reverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
4641
+ combinedSnapDifficulty *= 0.75;
4642
+ flowDifficulty *= 0.6;
4257
4643
  }
4258
- let ratingMultiplier = 1;
4259
- // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
4260
- ratingMultiplier *=
4261
- 0.7 +
4262
- 0.1 * Math.min(1, this.totalHits / 200) +
4263
- (this.totalHits > 200
4264
- ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
4265
- : 0);
4266
- // It is important to consider accuracy difficulty when scaling with accuracy.
4267
- ratingMultiplier *=
4268
- 0.98 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 2500;
4269
- return flashlightRating * Math.sqrt(ratingMultiplier);
4270
- }
4271
- calculateAimVisibilityFactor() {
4272
- const approachRateFactorEndpoint = 11.5;
4273
- const mechanicalDifficultyFactor = osuBase.Interpolation.reverseLerp(this.mechanicalDifficultyRating, 5, 10);
4274
- const approachRateFactorStartingPoint = osuBase.Interpolation.lerp(9, 10.33, mechanicalDifficultyFactor);
4275
- return osuBase.Interpolation.reverseLerp(this.approachRate, approachRateFactorEndpoint, approachRateFactorStartingPoint);
4276
- }
4277
- calculateSpeedVisibilityFactor() {
4278
- const approachRateFactorEndpoint = 11.5;
4279
- const mechanicalDifficultyFactor = osuBase.Interpolation.reverseLerp(this.mechanicalDifficultyRating, 5, 10);
4280
- const approachRateFactorStartingPoint = osuBase.Interpolation.lerp(10, 10.33, mechanicalDifficultyFactor);
4281
- return osuBase.Interpolation.reverseLerp(this.approachRate, approachRateFactorEndpoint, approachRateFactorStartingPoint);
4644
+ const totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow;
4645
+ return totalDifficulty * this.skillMultiplierTotal;
4282
4646
  }
4283
4647
  /**
4284
- * Calculates a visibility bonus that is applicable to Hidden and Traceable.
4648
+ * Converts the ratio of snap to flow into the probability of snapping or flowing.
4649
+ *
4650
+ * Constraints:
4651
+ * - `P(snap) + P(flow) = 1` (the object is always either snapped or flowed)
4652
+ * - `P(snap) = f(snap / flow)` and `P(flow) = f(flow/snap)` (i.e., snap and flow are symmetric and
4653
+ * reversible). This means `f(x) + f(1/x) = 1`
4654
+ * - `0 <= f(x) <= 1` (cannot have negative or greater than 100% probability of snapping or flowing)
4655
+ *
4656
+ * This logistic function is a solution, which fits nicely with the general idea of interpolation and
4657
+ * provides a tuneable constant.
4285
4658
  *
4286
- * @param mods The mods applied to the calculation.
4287
- * @param approachRate The approach rate of the beatmap.
4288
- * @param visibilityFactor The visibility factor to apply.
4289
- * @param sliderFactor The slider factor to apply.
4290
- * @returns The visibility bonus multiplier.
4659
+ * @param ratio The ratio.
4660
+ * @returns The probability.
4291
4661
  */
4292
- static calculateVisibilityBonus(mods, approachRate, visibilityFactor = 1, sliderFactor = 1) {
4293
- var _a, _b;
4294
- const isAlwaysPartiallyVisible = (_b = (_a = mods.get(osuBase.ModHidden)) === null || _a === void 0 ? void 0 : _a.onlyFadeApproachCircles.value) !== null && _b !== void 0 ? _b : mods.has(osuBase.ModTraceable);
4295
- // Start from normal curve, rewarding lower AR up to AR 7.
4296
- // Traceable forcefully requires a lower reading bonus for now as it is post-applied in pp, which make
4297
- // it multiplicative with the regular AR bonuses.
4298
- // This means it has an advantage over Hidden, so we decrease the multiplier to compensate.
4299
- // This should be removed once we are able to apply Traceable bonuses in star rating (requires real-time
4300
- // difficulty calculations being possible).
4301
- let readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) *
4302
- (12 - Math.max(approachRate, 7));
4303
- readingBonus *= visibilityFactor;
4304
- // We want to reward slideraim on low AR less.
4305
- const sliderVisibilityFactor = Math.pow(sliderFactor, 3);
4306
- // For AR up to 0, reduce reward for very low ARs when object is visible.
4307
- if (approachRate < 7) {
4308
- readingBonus +=
4309
- (isAlwaysPartiallyVisible ? 0.02 : 0.045) *
4310
- (7 - Math.max(approachRate, 0)) *
4311
- sliderVisibilityFactor;
4662
+ calculateSnapFlowProbability(ratio) {
4663
+ if (ratio === 0) {
4664
+ return 0;
4312
4665
  }
4313
- // Starting from AR 0, cap values so they won't grow to infinity.
4314
- if (approachRate < 0) {
4315
- readingBonus +=
4316
- (isAlwaysPartiallyVisible ? 0.01 : 0.1) *
4317
- (1 - Math.pow(1.5, approachRate)) *
4318
- sliderVisibilityFactor;
4666
+ if (Number.isNaN(ratio)) {
4667
+ return 1;
4319
4668
  }
4320
- return readingBonus;
4321
- }
4322
- static calculateDifficultyRating(difficultyValue) {
4323
- return Math.sqrt(difficultyValue) * this.difficultyMultiplier;
4324
- }
4325
- }
4326
- OsuRatingCalculator.difficultyMultiplier = 0.0675;
4327
-
4328
- /**
4329
- * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
4330
- * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
4331
- */
4332
- class OsuSkill extends StrainSkill {
4333
- constructor() {
4334
- super(...arguments);
4335
- this.difficulty = 0;
4669
+ return osuBase.MathUtils.logistic(-7.27 * Math.log(ratio));
4336
4670
  }
4337
4671
  difficultyValue() {
4338
- const strains = this.currentStrainPeaks.slice().sort((a, b) => b - a);
4339
- if (this.reducedSectionCount > 0) {
4340
- // We are reducing the highest strains first to account for extreme difficulty spikes.
4341
- for (let i = 0; i < Math.min(strains.length, this.reducedSectionCount); ++i) {
4342
- const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / this.reducedSectionCount, 0, 1)));
4343
- strains[i] *= osuBase.Interpolation.lerp(this.reducedSectionBaseline, 1, scale);
4344
- }
4345
- strains.sort((a, b) => b - a);
4672
+ let time = 0;
4673
+ let difficulty = 0;
4674
+ for (const strain of this.getReducedStrainPeaks()) {
4675
+ /* Weighting function can be thought of as:
4676
+ b
4677
+ decayWeight^x dx
4678
+ a
4679
+ where a = startTime and b = endTime
4680
+
4681
+ Technically, the function below has been slightly modified from the equation above.
4682
+ The real function would be
4683
+ double weight = Math.pow(this.decayWeight, startTime) - Math.pow(this.decayWeight, endTime))
4684
+ ...
4685
+ return difficulty / Math.log(1 / this.decayWeight)
4686
+ E.g. for a decayWeight of 0.9, we're multiplying by 10 instead of 9.49122...
4687
+
4688
+ This change makes it so that a beatmap composed solely of maxSectionLength chunks will have the exact same value
4689
+ when summed in this class and StrainSkill.
4690
+ Doing this ensures the relationship between strain values and difficulty values remains the same between the two
4691
+ classes.
4692
+ */
4693
+ const startTime = time;
4694
+ const endTime = time + strain.sectionLength / this.maxSectionLength;
4695
+ const weight = Math.pow(this.decayWeight, startTime) -
4696
+ Math.pow(this.decayWeight, endTime);
4697
+ difficulty += strain.value * weight;
4698
+ time = endTime;
4346
4699
  }
4347
- // Difficulty is the weighted sum of the highest strains from every section.
4348
- // We're sorting from highest to lowest strain.
4349
- this.difficulty = 0;
4350
- let weight = 1;
4351
- for (const strain of strains) {
4352
- const addition = strain * weight;
4353
- if (this.difficulty + addition === this.difficulty) {
4354
- break;
4700
+ return difficulty / (1 - this.decayWeight);
4701
+ }
4702
+ getReducedStrainPeaks() {
4703
+ // Sections with 0 strain are excluded to avoid worst-case time complexity of the following sort (e.g. /b/2351871).
4704
+ // These sections will not contribute to the difficulty.
4705
+ const strains = this.currentStrainPeaks
4706
+ .filter((s) => s.value > 0)
4707
+ .sort((a, b) => b.value - a.value);
4708
+ let time = 0;
4709
+ // All strains are removed at the end for optimization.
4710
+ let strainsToRemove = 0;
4711
+ // We are reducing the highest strains first to account for extreme difficulty spikes.
4712
+ // Strains are split into 20ms chunks to try to mitigate inconsistencies caused by reducing strains.
4713
+ const chunkSize = 20;
4714
+ while (strains.length > strainsToRemove &&
4715
+ time < this.reducedSectionTime) {
4716
+ const strain = strains[strainsToRemove];
4717
+ for (let addedTime = 0; addedTime < strain.sectionLength; addedTime += chunkSize) {
4718
+ const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp((time + addedTime) / this.reducedSectionTime, 0, 1)));
4719
+ strains.push(new StrainPeak(strain.value *
4720
+ osuBase.Interpolation.lerp(this.reducedStrainBaseline, 1, scale), Math.min(chunkSize, strain.sectionLength - addedTime)));
4355
4721
  }
4356
- this.difficulty += addition;
4357
- weight *= this.decayWeight;
4722
+ time += strain.sectionLength;
4723
+ ++strainsToRemove;
4358
4724
  }
4359
- return this.difficulty;
4725
+ strains.splice(0, strainsToRemove);
4726
+ return strains.sort((a, b) => b.value - a.value);
4360
4727
  }
4361
- }
4362
-
4363
- class StrainUtils {
4364
- static countTopWeightedSliders(sliderStrains, difficultyValue) {
4365
- if (sliderStrains.length === 0) {
4366
- return 0;
4367
- }
4368
- const consistentTopStrain = difficultyValue / 10;
4369
- if (consistentTopStrain === 0) {
4370
- return 0;
4371
- }
4372
- // Use a weighted sum of all strains. Constants are arbitrary and give nice values
4373
- return sliderStrains.reduce((total, next) => total +
4374
- osuBase.MathUtils.offsetLogistic(next / consistentTopStrain, 0.88, 10, 1.1));
4728
+ strainDecay(ms) {
4729
+ return Math.pow(0.2, ms / 1000);
4375
4730
  }
4376
4731
  }
4377
4732
 
4378
4733
  /**
4379
- * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
4734
+ * Represents the skill required to read every object in the beatmap.
4380
4735
  */
4381
- class OsuAim extends OsuSkill {
4382
- constructor(mods, withSliders) {
4736
+ class OsuReading extends HarmonicSkill {
4737
+ constructor(mods, clockRate, hitObjects) {
4383
4738
  super(mods);
4384
- this.strainDecayBase = 0.15;
4385
- this.reducedSectionCount = 10;
4386
- this.reducedSectionBaseline = 0.75;
4387
- this.decayWeight = 0.9;
4388
- this.currentAimStrain = 0;
4389
- this.skillMultiplier = 26;
4390
- this.sliderStrains = [];
4391
- this.withSliders = withSliders;
4739
+ this.clockRate = clockRate;
4740
+ this.hitObjects = hitObjects;
4741
+ this.currentDifficulty = 0;
4742
+ this.skillMultiplier = 2.5;
4743
+ this.difficultyDecayBase = 0.8;
4392
4744
  }
4393
- /**
4394
- * Obtains the amount of sliders that are considered difficult in terms of relative strain.
4395
- */
4396
- countDifficultSliders() {
4397
- if (this.sliderStrains.length === 0) {
4745
+ countTopWeightedObjectDifficulties(difficultyValue) {
4746
+ if (difficultyValue === 0) {
4398
4747
  return 0;
4399
4748
  }
4400
- const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
4401
- if (maxSliderStrain === 0) {
4749
+ if (this.noteWeightSum === 0) {
4402
4750
  return 0;
4403
4751
  }
4404
- return this.sliderStrains.reduce((total, strain) => total +
4405
- 1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
4406
- }
4407
- /**
4408
- * Obtains the amount of sliders that are considered difficult in terms of relative strain, weighted by consistency.
4409
- */
4410
- countTopWeightedSliders() {
4411
- return StrainUtils.countTopWeightedSliders(this.sliderStrains, this.difficulty);
4752
+ // This is what the top object difficulty is if all object difficulties were identical.
4753
+ const consistentTopNote = difficultyValue / this.noteWeightSum;
4754
+ if (consistentTopNote === 0) {
4755
+ return 0;
4756
+ }
4757
+ return this.objectDifficulties.reduce((total, next) => total +
4758
+ osuBase.MathUtils.offsetLogistic(next / consistentTopNote, 1.15, 5, 1.1), 0);
4412
4759
  }
4413
- strainValueAt(current) {
4414
- this.currentAimStrain *= this.strainDecay(current.deltaTime);
4415
- this.currentAimStrain +=
4416
- OsuAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
4760
+ objectDifficultyOf(current) {
4761
+ const decay = this.difficultyDecay(current.deltaTime);
4762
+ this.currentDifficulty *= decay;
4763
+ this.currentDifficulty +=
4764
+ this.calculateAdjustedDifficulty(current) *
4765
+ (1 - decay) *
4417
4766
  this.skillMultiplier;
4418
- this._objectStrains.push(this.currentAimStrain);
4419
- if (current.object instanceof osuBase.Slider) {
4420
- this.sliderStrains.push(this.currentAimStrain);
4421
- }
4422
- return this.currentAimStrain;
4767
+ return this.currentDifficulty;
4423
4768
  }
4424
- calculateInitialStrain(time, current) {
4425
- var _a, _b;
4426
- return (this.currentAimStrain *
4427
- this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
4769
+ applyDifficultyTransformation(difficulties) {
4770
+ // Assume the first few seconds are completely memorized.
4771
+ const reducedNoteCount = this.calculateReducedNoteCount();
4772
+ for (let i = 0; i < Math.min(difficulties.length, reducedNoteCount); ++i) {
4773
+ difficulties[i] *= Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / reducedNoteCount, 0, 1)));
4774
+ }
4428
4775
  }
4429
- /**
4430
- * @param current The hitobject to save to.
4431
- */
4432
4776
  saveToHitObject(current, difficulty) {
4433
- if (this.withSliders) {
4434
- current.aimStrainWithSliders = difficulty;
4777
+ current.readingDifficulty = difficulty;
4778
+ }
4779
+ calculateAdjustedDifficulty(current) {
4780
+ let difficulty = OsuReadingEvaluator.evaluateDifficultyOf(current, this.mods);
4781
+ if (this.mods.has(osuBase.ModTouchDevice)) {
4782
+ difficulty = Math.pow(difficulty, 0.89);
4435
4783
  }
4436
- else {
4437
- current.aimStrainWithoutSliders = difficulty;
4784
+ if (this.mods.has(osuBase.ModMagnetised)) {
4785
+ const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4786
+ difficulty *= 1 - magnetisedStrength;
4787
+ }
4788
+ if (this.mods.has(osuBase.ModRelax)) {
4789
+ difficulty *= 0.4;
4790
+ }
4791
+ else if (this.mods.has(osuBase.ModAutopilot)) {
4792
+ difficulty *= 0.1;
4793
+ }
4794
+ difficulty *=
4795
+ 0.825 +
4796
+ Math.pow(Math.max(0, current.overallDifficulty), 2.2) / 1125;
4797
+ return difficulty;
4798
+ }
4799
+ calculateReducedNoteCount() {
4800
+ if (this.hitObjects.length < 2) {
4801
+ return 0;
4802
+ }
4803
+ const reducedDifficultyDuration = 60 * 1000;
4804
+ // We take the 2nd note to match `createDifficultyHitObjects`
4805
+ const firstDifficultyObject = this.hitObjects[1];
4806
+ const reducedDuration = firstDifficultyObject.startTime / this.clockRate +
4807
+ reducedDifficultyDuration;
4808
+ let reducedNoteCount = 0;
4809
+ for (let i = 1; i < this.hitObjects.length; ++i) {
4810
+ const object = this.hitObjects[i];
4811
+ if (object.startTime / this.clockRate > reducedDuration) {
4812
+ break;
4813
+ }
4814
+ ++reducedNoteCount;
4438
4815
  }
4816
+ return reducedNoteCount;
4439
4817
  }
4440
- strainDecay(ms) {
4441
- return Math.pow(this.strainDecayBase, ms / 1000);
4818
+ difficultyDecay(ms) {
4819
+ return Math.pow(this.difficultyDecayBase, ms / 1000);
4442
4820
  }
4443
4821
  }
4444
4822
 
4445
4823
  /**
4446
4824
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
4447
4825
  */
4448
- class OsuSpeed extends OsuSkill {
4826
+ class OsuSpeed extends HarmonicSkill {
4449
4827
  constructor() {
4450
4828
  super(...arguments);
4451
- this.strainDecayBase = 0.3;
4452
- this.reducedSectionCount = 5;
4453
- this.reducedSectionBaseline = 0.75;
4454
- this.decayWeight = 0.9;
4455
- this.currentSpeedStrain = 0;
4829
+ this.harmonicScale = 20;
4830
+ this.currentDifficulty = 0;
4456
4831
  this.currentRhythm = 0;
4457
- this.skillMultiplier = 1.47;
4458
- this.sliderStrains = [];
4459
- this.maxStrain = 0;
4832
+ this.skillMultiplier = 1.16;
4833
+ this.strainDecayBase = 0.3;
4834
+ this.sliderDifficulties = [];
4835
+ this.maxDifficulty = 0;
4460
4836
  }
4461
4837
  /**
4462
4838
  * The amount of notes that are relevant to the difficulty.
4463
4839
  */
4464
4840
  relevantNoteCount() {
4465
- if (this._objectStrains.length === 0 || this.maxStrain === 0) {
4841
+ if (this.objectDifficulties.length === 0 || this.maxDifficulty === 0) {
4466
4842
  return 0;
4467
4843
  }
4468
- return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / this.maxStrain) * 12 - 6))), 0);
4844
+ return this.objectDifficulties.reduce((total, next) => total +
4845
+ 1 / (1 + Math.exp(-((next / this.maxDifficulty) * 12 - 6))), 0);
4469
4846
  }
4470
4847
  /**
4471
- * Obtains the amount of sliders that are considered difficult in terms of relative strain, weighted by consistency.
4848
+ * Obtains the amount of sliders that are considered difficult in terms of relative difficulty, weighted by consistency.
4849
+ *
4850
+ * @param difficultyValue The final difficulty value.
4472
4851
  */
4473
- countTopWeightedSliders() {
4474
- return StrainUtils.countTopWeightedSliders(this.sliderStrains, this.difficulty);
4852
+ countTopWeightedSliders(difficultyValue) {
4853
+ if (this.sliderDifficulties.length === 0) {
4854
+ return 0;
4855
+ }
4856
+ if (this.noteWeightSum === 0) {
4857
+ return 0;
4858
+ }
4859
+ // What would the top note be if all note values were identical
4860
+ const consistentTopNote = difficultyValue / this.noteWeightSum;
4861
+ if (consistentTopNote === 0) {
4862
+ return 0;
4863
+ }
4864
+ // Use a weighted sum of all notes. Constants are arbitrary and give nice values
4865
+ return this.sliderDifficulties.reduce((total, next) => total +
4866
+ osuBase.MathUtils.offsetLogistic(next / consistentTopNote, 0.88, 10, 1.1), 0);
4475
4867
  }
4476
- /**
4477
- * @param current The hitobject to calculate.
4478
- */
4479
- strainValueAt(current) {
4480
- this.currentSpeedStrain *= this.strainDecay(current.strainTime);
4481
- this.currentSpeedStrain +=
4482
- OsuSpeedEvaluator.evaluateDifficultyOf(current, this.mods) *
4868
+ objectDifficultyOf(current) {
4869
+ if (this.mods.has(osuBase.ModRelax)) {
4870
+ return 0;
4871
+ }
4872
+ const decay = this.strainDecay(current.strainTime);
4873
+ this.currentDifficulty *= decay;
4874
+ this.currentDifficulty +=
4875
+ this.calculateAdjustedDifficulty(current) *
4876
+ (1 - decay) *
4483
4877
  this.skillMultiplier;
4484
4878
  this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
4485
- const strain = this.currentSpeedStrain * this.currentRhythm;
4486
- this._objectStrains.push(strain);
4487
- this.maxStrain = Math.max(this.maxStrain, strain);
4879
+ const difficulty = this.currentDifficulty * this.currentRhythm;
4880
+ this.maxDifficulty = Math.max(this.maxDifficulty, difficulty);
4488
4881
  if (current.object instanceof osuBase.Slider) {
4489
- this.sliderStrains.push(strain);
4882
+ this.sliderDifficulties.push(difficulty);
4490
4883
  }
4491
- return strain;
4492
- }
4493
- calculateInitialStrain(time, current) {
4494
- var _a, _b;
4495
- return (this.currentSpeedStrain *
4496
- this.currentRhythm *
4497
- this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
4884
+ return difficulty;
4498
4885
  }
4499
- /**
4500
- * @param current The hitobject to save to.
4501
- */
4502
- saveToHitObject(current, difficulty) {
4503
- current.speedStrain = difficulty;
4886
+ saveToHitObject(current) {
4887
+ current.speedStrain = this.currentDifficulty * this.currentRhythm;
4504
4888
  current.rhythmMultiplier = this.currentRhythm;
4505
4889
  }
4890
+ calculateAdjustedDifficulty(current) {
4891
+ let difficulty = OsuSpeedEvaluator.evaluateDifficultyOf(current);
4892
+ if (this.mods.has(osuBase.ModAutopilot)) {
4893
+ difficulty *= 0.5;
4894
+ }
4895
+ return difficulty;
4896
+ }
4506
4897
  strainDecay(ms) {
4507
4898
  return Math.pow(this.strainDecayBase, ms / 1000);
4508
4899
  }
@@ -4530,6 +4921,10 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4530
4921
  * The flashlight performance value.
4531
4922
  */
4532
4923
  this.flashlight = 0;
4924
+ /**
4925
+ * The reading performance value.
4926
+ */
4927
+ this.reading = 0;
4533
4928
  this.greatWindow = 0;
4534
4929
  this.okWindow = 0;
4535
4930
  this.mehWindow = 0;
@@ -4570,20 +4965,17 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4570
4965
  this.greatWindow = hitWindow.greatWindow / clockRate;
4571
4966
  this.okWindow = hitWindow.okWindow / clockRate;
4572
4967
  this.mehWindow = hitWindow.mehWindow / clockRate;
4573
- this.approachRate =
4574
- OsuDifficultyCalculator.calculateRateAdjustedApproachRate(ar, clockRate);
4575
- this.overallDifficulty =
4576
- OsuDifficultyCalculator.calculateRateAdjustedOverallDifficulty(od, clockRate);
4968
+ this.approachRate = this.calculateRateAdjustedApproachRate(ar, clockRate);
4969
+ this.overallDifficulty = (79.5 - this.greatWindow) / 6;
4577
4970
  this.speedDeviation = this.calculateSpeedDeviation();
4578
4971
  this.aim = this.calculateAimValue();
4579
4972
  this.speed = this.calculateSpeedValue();
4580
4973
  this.accuracy = this.calculateAccuracyValue();
4581
4974
  this.flashlight = this.calculateFlashlightValue();
4975
+ this.reading = this.calculateReadingValue();
4976
+ const cognitionValue = OsuDifficultyCalculator.sumCognitionDifficulty(this.reading, this.flashlight);
4582
4977
  this.total =
4583
- Math.pow(Math.pow(this.aim, 1.1) +
4584
- Math.pow(this.speed, 1.1) +
4585
- Math.pow(this.accuracy, 1.1) +
4586
- Math.pow(this.flashlight, 1.1), 1 / 1.1) * finalMultiplier;
4978
+ osuBase.MathUtils.norm(OsuPerformanceCalculator.normExponent, this.aim, this.speed, this.accuracy, cognitionValue) * finalMultiplier;
4587
4979
  }
4588
4980
  /**
4589
4981
  * Calculates the aim performance value of the beatmap.
@@ -4616,7 +5008,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4616
5008
  }
4617
5009
  let aimValue = OsuAim.difficultyToPerformance(aimDifficulty);
4618
5010
  // Longer maps are worth more
4619
- let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
5011
+ let lengthBonus = 0.95 + 0.35 * Math.min(1, this.totalHits / 2000);
4620
5012
  if (this.totalHits > 2000) {
4621
5013
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
4622
5014
  }
@@ -4641,7 +5033,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4641
5033
  else if (this.mods.has(osuBase.ModTraceable)) {
4642
5034
  aimValue *=
4643
5035
  1 +
4644
- OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, undefined, this.difficultyAttributes.sliderFactor);
5036
+ this.calculateTraceableBonus(this.difficultyAttributes.sliderFactor);
4645
5037
  }
4646
5038
  // Scale the aim value with accuracy.
4647
5039
  aimValue *= this.computedAccuracy.value();
@@ -4656,45 +5048,23 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4656
5048
  return 0;
4657
5049
  }
4658
5050
  let speedValue = OsuSpeed.difficultyToPerformance(this.difficultyAttributes.speedDifficulty);
4659
- // Longer maps are worth more
4660
- let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
4661
- if (this.totalHits > 2000) {
4662
- lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
4663
- }
4664
- speedValue *= lengthBonus;
4665
5051
  if (this._effectiveMissCount > 0) {
4666
5052
  const speedEstimatedSliderBreaks = this.calculateEstimatedSliderBreaks(this.difficultyAttributes.speedTopWeightedSliderFactor);
4667
5053
  const relevantMissCount = Math.min(this._effectiveMissCount + speedEstimatedSliderBreaks, this.totalImperfectHits + this.sliderTicksMissed);
4668
5054
  speedValue *= this.calculateMissPenalty(relevantMissCount, this.difficultyAttributes.speedDifficultStrainCount);
4669
5055
  }
4670
- // Traceable bonuses are excluded when Blinds is present, as the increased visual difficulty is
4671
- // redundant when notes cannot be seen.
4672
5056
  if (this.mods.has(osuBase.ModBlinds)) {
4673
5057
  // Increasing the speed value by object count for Blinds is not ideal, so the minimum buff is given.
4674
5058
  speedValue *= 1.12;
4675
5059
  }
4676
- else if (this.mods.has(osuBase.ModTraceable)) {
4677
- speedValue *=
4678
- 1 +
4679
- OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, undefined, this.difficultyAttributes.sliderFactor);
4680
- }
4681
- // Calculate accuracy assuming the worst case scenario.
4682
- const countGreat = this.computedAccuracy.n300;
4683
- const countOk = this.computedAccuracy.n100;
4684
- const countMeh = this.computedAccuracy.n50;
4685
- const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
4686
- const relevantAccuracy = new osuBase.Accuracy(this.difficultyAttributes.speedNoteCount > 0
4687
- ? {
4688
- n300: Math.max(0, countGreat - relevantTotalDiff),
4689
- n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
4690
- n50: Math.max(0, countMeh -
4691
- Math.max(0, relevantTotalDiff - countGreat - countOk)),
4692
- }
4693
- : // Set accuracy to 0.
4694
- { n300: 0, nobjects: 1 });
4695
5060
  speedValue *= this.calculateSpeedHighDeviationNerf();
4696
- // Scale the speed value with accuracy and OD.
4697
- speedValue *= Math.pow((this.computedAccuracy.value() + relevantAccuracy.value()) / 2, (14.5 - this.overallDifficulty) / 2);
5061
+ // An effective hit window is created based on the speed SR. The higher the speed difficulty, the shorter the hit window.
5062
+ // For example, a speed SR of 4 leads to an effective hit window of 20ms, which is OD 10.
5063
+ const effectiveHitWindow = 20 * Math.pow(4 / this.difficultyAttributes.speedDifficulty, 0.35);
5064
+ // Find the proportion of 300s on speed notes assuming the hit window was the effective hit window.
5065
+ const effectiveAccuracy = osuBase.ErrorFunction.erf(effectiveHitWindow / this.speedDeviation);
5066
+ // Scale speed value by normalized accuracy.
5067
+ speedValue *= Math.pow(effectiveAccuracy, 2);
4698
5068
  return speedValue;
4699
5069
  }
4700
5070
  /**
@@ -4704,7 +5074,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4704
5074
  if (this.mods.has(osuBase.ModRelax)) {
4705
5075
  return 0;
4706
5076
  }
4707
- const ncircles = this.mods.has(osuBase.ModScoreV2)
5077
+ const ncircles = !this.usingClassicSliderAccuracy || this.mods.has(osuBase.ModScoreV2)
4708
5078
  ? this.totalHits - this.difficultyAttributes.spinnerCount
4709
5079
  : this.difficultyAttributes.hitCircleCount;
4710
5080
  if (ncircles === 0) {
@@ -4717,21 +5087,18 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4717
5087
  // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
4718
5088
  Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
4719
5089
  2.83;
4720
- // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
4721
- accuracyValue *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
5090
+ // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
5091
+ accuracyValue *= Math.pow(ncircles / 1000, ncircles < 1000 ? 0.3 : 0.1);
4722
5092
  // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
4723
5093
  if (this.mods.has(osuBase.ModBlinds)) {
4724
5094
  accuracyValue *= 1.14;
4725
5095
  }
4726
- else if (this.mods.has(osuBase.ModHidden) || this.mods.has(osuBase.ModTraceable)) {
5096
+ else if (this.mods.has(osuBase.ModTraceable)) {
4727
5097
  // Decrease bonus for AR > 10.
4728
5098
  accuracyValue *=
4729
5099
  1 +
4730
5100
  0.08 * osuBase.Interpolation.reverseLerp(this.approachRate, 11.5, 10);
4731
5101
  }
4732
- if (this.mods.has(osuBase.ModFlashlight)) {
4733
- accuracyValue *= 1.02;
4734
- }
4735
5102
  return accuracyValue;
4736
5103
  }
4737
5104
  /**
@@ -4751,17 +5118,23 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4751
5118
  Math.pow(1 -
4752
5119
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
4753
5120
  }
4754
- // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
4755
- flashlightValue *=
4756
- 0.7 +
4757
- 0.1 * Math.min(1, this.totalHits / 200) +
4758
- (this.totalHits > 200
4759
- ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
4760
- : 0);
4761
5121
  // Scale the flashlight value with accuracy slightly.
4762
5122
  flashlightValue *= 0.5 + this.computedAccuracy.value() / 2;
4763
5123
  return flashlightValue;
4764
5124
  }
5125
+ /**
5126
+ * Calculates the reading performance value of the beatmap.
5127
+ */
5128
+ calculateReadingValue() {
5129
+ let readingValue = OsuReading.difficultyToPerformance(this.difficultyAttributes.readingDifficulty);
5130
+ if (this.effectiveMissCount > 0) {
5131
+ const aimEstimatedSliderBreaks = this.calculateEstimatedSliderBreaks(this.difficultyAttributes.aimTopWeightedSliderFactor);
5132
+ readingValue *= this.calculateMissPenalty(this.effectiveMissCount + aimEstimatedSliderBreaks, this.difficultyAttributes.readingDifficultNoteCount);
5133
+ }
5134
+ // Scale the reading value with accuracy _harshly_.
5135
+ readingValue *= Math.pow(this.computedAccuracy.value(), 3);
5136
+ return readingValue;
5137
+ }
4765
5138
  /**
4766
5139
  * Calculates a strain-based miss penalty.
4767
5140
  *
@@ -4773,9 +5146,8 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4773
5146
  if (missCount === 0) {
4774
5147
  return 1;
4775
5148
  }
4776
- return (0.96 /
4777
- (missCount / (4 * Math.pow(Math.log(difficultStrainCount), 0.94)) +
4778
- 1));
5149
+ // https://www.desmos.com/calculator/naggvbcz0a
5150
+ return 0.93 / (missCount / (4 * Math.log(difficultStrainCount)) + 1);
4779
5151
  }
4780
5152
  /**
4781
5153
  * Estimates a player's deviation on speed notes using {@link calculateDeviation}, assuming worst-case.
@@ -4877,19 +5249,21 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4877
5249
  return (osuBase.Interpolation.lerp(adjustedSpeedValue, speedValue, t) / speedValue);
4878
5250
  }
4879
5251
  calculateEstimatedSliderBreaks(topWeightedSliderFactor) {
4880
- const { n100 } = this.computedAccuracy;
4881
- if (!this.usingClassicSliderAccuracy || n100 === 0) {
5252
+ const nonMissMistakes = this.computedAccuracy.n100 + this.computedAccuracy.n50;
5253
+ if (!this.usingClassicSliderAccuracy || nonMissMistakes === 0) {
4882
5254
  return 0;
4883
5255
  }
4884
5256
  const missedComboPercent = 1 - this.combo / this.difficultyAttributes.maxCombo;
4885
- let estimatedSliderBreaks = Math.min(n100, this._effectiveMissCount * topWeightedSliderFactor);
4886
- // Scores with more Oks are more likely to have slider breaks.
4887
- const okAdjustment = (n100 - estimatedSliderBreaks + 0.5) / n100;
5257
+ let estimatedSliderBreaks = Math.min(nonMissMistakes, this._effectiveMissCount * topWeightedSliderFactor);
5258
+ // Scores with more Oks and Mehs are more likely to have slider breaks.
5259
+ // We add an arbitrary value to both sides of the division to make it more stable on extreme ends.
5260
+ const nonMissMistakeAdjustment = (nonMissMistakes - estimatedSliderBreaks + 4.5) /
5261
+ (nonMissMistakes + 4);
4888
5262
  // There is a low probability of extra slider breaks on effective miss counts close to 1, as
4889
5263
  // score based calculations are good at indicating if only a single break occurred.
4890
5264
  estimatedSliderBreaks *= osuBase.MathUtils.smoothstep(this._effectiveMissCount, 1, 2);
4891
5265
  return (estimatedSliderBreaks *
4892
- okAdjustment *
5266
+ nonMissMistakeAdjustment *
4893
5267
  osuBase.MathUtils.offsetLogistic(missedComboPercent, 0.33, 15));
4894
5268
  }
4895
5269
  /**
@@ -4898,16 +5272,27 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4898
5272
  calculateComboBasedEstimatedMissCount() {
4899
5273
  let missCount = this.computedAccuracy.nmiss;
4900
5274
  const { combo } = this;
4901
- const { sliderCount, maxCombo } = this.difficultyAttributes;
5275
+ const { aimTopWeightedSliderFactor, sliderCount, maxCombo } = this.difficultyAttributes;
4902
5276
  if (sliderCount <= 0) {
4903
5277
  return missCount;
4904
5278
  }
4905
5279
  if (this.usingClassicSliderAccuracy) {
5280
+ // If sliders in the beatmap are hard, it's likely for player to drop sliderends.
5281
+ // However, if the beatmap has easy sliders, it's more likely for player to sliderbreak.
5282
+ const likelyMissedSliderendPortion = 0.04 +
5283
+ 0.06 * Math.pow(Math.min(aimTopWeightedSliderFactor, 1), 2);
4906
5284
  // Consider that full combo is maximum combo minus dropped slider tails since
4907
5285
  // they don't contribute to combo but also don't break it.
4908
5286
  // In classic scores, we can't know the amount of dropped sliders so we estimate
4909
5287
  // to 10% of all sliders in the beatmap.
4910
- const fullComboThreshold = maxCombo - 0.1 * sliderCount;
5288
+ const fullComboThreshold = maxCombo -
5289
+ Math.min(
5290
+ // 4 is the minimum leniency baseline to ensure that dropping one (for few) sliderends will
5291
+ // not instantly be treated as a sliderbreak even in cases where the slider count is low.
5292
+ // 4 was picked because in a lot of short stream beatmaps with small amount of sliders, there
5293
+ // are 2-3 sliders on which sliderends are often dropped. This is a kind of optimization to
5294
+ // achieve the most accurate result on average.
5295
+ 4 + likelyMissedSliderendPortion * sliderCount, sliderCount);
4911
5296
  if (combo < fullComboThreshold) {
4912
5297
  missCount = fullComboThreshold / Math.max(1, combo);
4913
5298
  }
@@ -4934,6 +5319,36 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4934
5319
  }
4935
5320
  return missCount;
4936
5321
  }
5322
+ calculateTraceableBonus(sliderFactor = 1) {
5323
+ // We want to reward slider aim less, more so at lower AR.
5324
+ const highApproachRateSliderVisibilityFactor = 0.5 + Math.pow(sliderFactor, 6) / 2;
5325
+ const lowApproachRateSliderVisibilityFactor = Math.pow(sliderFactor, 6);
5326
+ let traceableBonus = 0.0275;
5327
+ // Start from normal curve, rewarding lower AR up to AR7.
5328
+ traceableBonus +=
5329
+ 0.025 *
5330
+ (12.0 - Math.max(this.approachRate, 7)) *
5331
+ highApproachRateSliderVisibilityFactor;
5332
+ // For AR up to 0 - reduce reward for very low ARs when object is visible.
5333
+ if (this.approachRate < 7) {
5334
+ traceableBonus +=
5335
+ 0.025 *
5336
+ (7 - Math.max(this.approachRate, 0)) *
5337
+ lowApproachRateSliderVisibilityFactor;
5338
+ }
5339
+ // Starting from AR0 - cap values so they won't grow to infinity.
5340
+ if (this.approachRate < 0) {
5341
+ traceableBonus +=
5342
+ 0.025 *
5343
+ (1 - Math.pow(1.5, this.approachRate)) *
5344
+ lowApproachRateSliderVisibilityFactor;
5345
+ }
5346
+ return traceableBonus;
5347
+ }
5348
+ calculateRateAdjustedApproachRate(approachRate, clockRate) {
5349
+ const preempt = osuBase.BeatmapDifficulty.difficultyRange(approachRate, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
5350
+ return osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
5351
+ }
4937
5352
  toString() {
4938
5353
  return (this.total.toFixed(2) +
4939
5354
  " pp (" +
@@ -4944,10 +5359,13 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4944
5359
  this.accuracy.toFixed(2) +
4945
5360
  " accuracy, " +
4946
5361
  this.flashlight.toFixed(2) +
4947
- " flashlight)");
5362
+ " flashlight, " +
5363
+ this.reading.toFixed(2) +
5364
+ " reading)");
4948
5365
  }
4949
5366
  }
4950
- OsuPerformanceCalculator.finalMultiplier = 1.14;
5367
+ OsuPerformanceCalculator.finalMultiplier = 1.12;
5368
+ OsuPerformanceCalculator.normExponent = 1.1;
4951
5369
 
4952
5370
  /**
4953
5371
  * Represents an osu!standard hit object with difficulty calculation values.
@@ -4963,34 +5381,83 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
4963
5381
  this.mode = osuBase.Modes.Osu;
4964
5382
  }
4965
5383
  get smallCircleBonus() {
4966
- return Math.max(1, 1 + (30 - this.object.radius) / 40);
5384
+ return Math.max(1, 1 + (30 - this.object.radius) / 70);
5385
+ }
5386
+ get overallDifficulty() {
5387
+ const hitWindowGreat = this.hitWindowFor(osuBase.HitResult.Great) / 2;
5388
+ return (79.5 - hitWindowGreat) / 6;
4967
5389
  }
4968
5390
  }
4969
5391
 
4970
5392
  /**
4971
- * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
5393
+ * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
5394
+ * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
4972
5395
  */
4973
- class OsuFlashlight extends OsuSkill {
5396
+ class OsuSkill extends StrainSkill {
4974
5397
  constructor() {
4975
5398
  super(...arguments);
4976
- this.strainDecayBase = 0.15;
5399
+ this.difficulty = 0;
5400
+ }
5401
+ difficultyValue() {
5402
+ const strains = this.currentStrainPeaks.slice().sort((a, b) => b - a);
5403
+ if (this.reducedSectionCount > 0) {
5404
+ // We are reducing the highest strains first to account for extreme difficulty spikes.
5405
+ for (let i = 0; i < Math.min(strains.length, this.reducedSectionCount); ++i) {
5406
+ const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / this.reducedSectionCount, 0, 1)));
5407
+ strains[i] *= osuBase.Interpolation.lerp(this.reducedSectionBaseline, 1, scale);
5408
+ }
5409
+ strains.sort((a, b) => b - a);
5410
+ }
5411
+ // Difficulty is the weighted sum of the highest strains from every section.
5412
+ // We're sorting from highest to lowest strain.
5413
+ this.difficulty = 0;
5414
+ let weight = 1;
5415
+ for (const strain of strains) {
5416
+ const addition = strain * weight;
5417
+ if (this.difficulty + addition === this.difficulty) {
5418
+ break;
5419
+ }
5420
+ this.difficulty += addition;
5421
+ weight *= this.decayWeight;
5422
+ }
5423
+ return this.difficulty;
5424
+ }
5425
+ }
5426
+
5427
+ /**
5428
+ * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
5429
+ */
5430
+ class OsuFlashlight extends OsuSkill {
5431
+ static difficultyToPerformance(difficulty) {
5432
+ return Math.pow(difficulty, 2) * 25;
5433
+ }
5434
+ constructor(mods, totalObjects) {
5435
+ super(mods);
5436
+ this.totalObjects = totalObjects;
4977
5437
  this.reducedSectionCount = 0;
4978
5438
  this.reducedSectionBaseline = 1;
4979
5439
  this.decayWeight = 1;
4980
5440
  this.currentFlashlightStrain = 0;
4981
- this.skillMultiplier = 0.05512;
4982
- }
4983
- static difficultyToPerformance(difficulty) {
4984
- return Math.pow(difficulty, 2) * 25;
5441
+ this.skillMultiplier = 0.058;
4985
5442
  }
4986
5443
  difficultyValue() {
4987
- return this.currentStrainPeaks.reduce((a, b) => a + b, 0);
5444
+ let sum = this.currentStrainPeaks.reduce((a, b) => a + b, 0);
5445
+ // Account for shorter beatmaps having a higher ratio of 0 combo/100 combo flashlight radius.
5446
+ sum *=
5447
+ 0.7 +
5448
+ 0.1 * Math.min(1, this.totalObjects / 200) +
5449
+ (this.totalObjects > 200
5450
+ ? 0.2 * Math.min(1, (this.totalObjects - 200) / 200)
5451
+ : 0);
5452
+ return sum;
4988
5453
  }
4989
5454
  strainValueAt(current) {
5455
+ if (!this.mods.has(osuBase.ModFlashlight)) {
5456
+ return 0;
5457
+ }
4990
5458
  this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
4991
5459
  this.currentFlashlightStrain +=
4992
- OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.mods) *
4993
- this.skillMultiplier;
5460
+ this.calculateAdjustedDifficulty(current) * this.skillMultiplier;
4994
5461
  return this.currentFlashlightStrain;
4995
5462
  }
4996
5463
  calculateInitialStrain(time, current) {
@@ -4998,11 +5465,34 @@ class OsuFlashlight extends OsuSkill {
4998
5465
  return (this.currentFlashlightStrain *
4999
5466
  this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
5000
5467
  }
5001
- saveToHitObject(current, difficulty) {
5002
- current.flashlightStrain = difficulty;
5468
+ saveToHitObject(current) {
5469
+ current.flashlightStrain = this.currentFlashlightStrain;
5470
+ }
5471
+ calculateAdjustedDifficulty(current) {
5472
+ let difficulty = OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.mods);
5473
+ if (this.mods.has(osuBase.ModTouchDevice)) {
5474
+ difficulty = Math.pow(difficulty, 0.9);
5475
+ }
5476
+ if (this.mods.has(osuBase.ModMagnetised)) {
5477
+ const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
5478
+ difficulty *= 1 - magnetisedStrength;
5479
+ }
5480
+ if (this.mods.has(osuBase.ModDeflate)) {
5481
+ const deflateInitialScale = this.mods.get(osuBase.ModDeflate).startScale.value;
5482
+ difficulty *= osuBase.MathUtils.clamp(osuBase.Interpolation.reverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
5483
+ }
5484
+ if (this.mods.has(osuBase.ModRelax)) {
5485
+ difficulty *= 0.7;
5486
+ }
5487
+ else if (this.mods.has(osuBase.ModAutopilot)) {
5488
+ difficulty *= 0.4;
5489
+ }
5490
+ difficulty *=
5491
+ 0.985 + Math.pow(Math.max(0, current.overallDifficulty), 2) / 4000;
5492
+ return difficulty;
5003
5493
  }
5004
5494
  strainDecay(ms) {
5005
- return Math.pow(this.strainDecayBase, ms / 1000);
5495
+ return Math.pow(0.15, ms / 1000);
5006
5496
  }
5007
5497
  }
5008
5498
 
@@ -5016,7 +5506,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5016
5506
  this.drainRate = 0;
5017
5507
  this.speedDifficulty = 0;
5018
5508
  this.speedDifficultStrainCount = 0;
5019
- this.aimTopWeightedSliderFactor = 0;
5020
5509
  this.speedTopWeightedSliderFactor = 0;
5021
5510
  if (!cacheableAttributes) {
5022
5511
  return;
@@ -5026,8 +5515,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5026
5515
  this.speedDifficulty = cacheableAttributes.speedDifficulty;
5027
5516
  this.speedDifficultStrainCount =
5028
5517
  cacheableAttributes.speedDifficultStrainCount;
5029
- this.aimTopWeightedSliderFactor =
5030
- cacheableAttributes.aimTopWeightedSliderFactor;
5031
5518
  this.speedTopWeightedSliderFactor =
5032
5519
  cacheableAttributes.speedTopWeightedSliderFactor;
5033
5520
  }
@@ -5035,7 +5522,8 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5035
5522
  return (super.toString() +
5036
5523
  ` (${this.aimDifficulty.toFixed(2)} aim, ` +
5037
5524
  `${this.speedDifficulty.toFixed(2)} speed, ` +
5038
- `${this.flashlightDifficulty.toFixed(2)} flashlight)`);
5525
+ `${this.flashlightDifficulty.toFixed(2)} flashlight, ` +
5526
+ `${this.readingDifficulty.toFixed(2)} reading)`);
5039
5527
  }
5040
5528
  }
5041
5529
 
@@ -5045,7 +5533,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5045
5533
  class OsuDifficultyCalculator extends DifficultyCalculator {
5046
5534
  constructor() {
5047
5535
  super();
5048
- this.starRatingMultiplier = 0.0265;
5049
5536
  this.difficultyAdjustmentMods.push(osuBase.ModTouchDevice, osuBase.ModBlinds);
5050
5537
  }
5051
5538
  retainDifficultyAdjustmentMods(mods) {
@@ -5053,8 +5540,8 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5053
5540
  mod.isOsuRelevant &&
5054
5541
  this.difficultyAdjustmentMods.some((m) => mod instanceof m));
5055
5542
  }
5056
- createDifficultyAttributes(_beatmap, playableBeatmap, skills) {
5057
- var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l;
5543
+ createDifficultyAttributes(beatmap, playableBeatmap, skills) {
5544
+ var _a, _b, _c, _d, _e, _f, _g, _h, _j, _k, _l, _m, _o;
5058
5545
  const attributes = new OsuDifficultyAttributes();
5059
5546
  if (playableBeatmap.hitObjects.objects.length === 0) {
5060
5547
  return attributes;
@@ -5065,27 +5552,26 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5065
5552
  attributes.hitCircleCount = playableBeatmap.hitObjects.circles;
5066
5553
  attributes.sliderCount = playableBeatmap.hitObjects.sliders;
5067
5554
  attributes.spinnerCount = playableBeatmap.hitObjects.spinners;
5555
+ attributes.approachRate = playableBeatmap.difficulty.ar;
5556
+ attributes.overallDifficulty = playableBeatmap.difficulty.od;
5068
5557
  attributes.drainRate = playableBeatmap.difficulty.hp;
5069
- attributes.approachRate =
5070
- OsuDifficultyCalculator.calculateRateAdjustedApproachRate(playableBeatmap.difficulty.ar, attributes.clockRate);
5071
- attributes.overallDifficulty =
5072
- OsuDifficultyCalculator.calculateRateAdjustedOverallDifficulty(playableBeatmap.difficulty.od, attributes.clockRate);
5073
5558
  const aim = skills.find((s) => s instanceof OsuAim && s.withSliders);
5074
5559
  const aimNoSlider = skills.find((s) => s instanceof OsuAim && !s.withSliders);
5075
5560
  const speed = skills.find((s) => s instanceof OsuSpeed);
5076
5561
  const flashlight = skills.find((s) => s instanceof OsuFlashlight);
5562
+ const reading = skills.find((s) => s instanceof OsuReading);
5077
5563
  // Aim attributes
5078
5564
  const aimDifficultyValue = (_a = aim === null || aim === void 0 ? void 0 : aim.difficultyValue()) !== null && _a !== void 0 ? _a : 0;
5079
- attributes.aimDifficultSliderCount = (_b = aim === null || aim === void 0 ? void 0 : aim.countDifficultSliders()) !== null && _b !== void 0 ? _b : 0;
5565
+ const aimNoSliderDifficultyValue = (_b = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.difficultyValue()) !== null && _b !== void 0 ? _b : 0;
5566
+ attributes.aimDifficultSliderCount = (_c = aim === null || aim === void 0 ? void 0 : aim.countDifficultSliders()) !== null && _c !== void 0 ? _c : 0;
5080
5567
  attributes.aimDifficultStrainCount =
5081
- (_c = aim === null || aim === void 0 ? void 0 : aim.countTopWeightedStrains(aimDifficultyValue)) !== null && _c !== void 0 ? _c : 0;
5568
+ (_d = aim === null || aim === void 0 ? void 0 : aim.countTopWeightedStrains(aimDifficultyValue)) !== null && _d !== void 0 ? _d : 0;
5082
5569
  attributes.sliderFactor =
5083
5570
  aimDifficultyValue > 0
5084
- ? OsuRatingCalculator.calculateDifficultyRating((_d = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.difficultyValue()) !== null && _d !== void 0 ? _d : 0) /
5085
- OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue)
5571
+ ? this.calculateAimDifficultyRating(aimNoSliderDifficultyValue) / this.calculateAimDifficultyRating(aimDifficultyValue)
5086
5572
  : 1;
5087
- const aimNoSliderTopWeightedSliderCount = (_e = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.countTopWeightedSliders()) !== null && _e !== void 0 ? _e : 0;
5088
- const aimNoSliderDifficultStrainCount = (_f = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.countTopWeightedStrains(aimNoSlider.difficultyValue())) !== null && _f !== void 0 ? _f : 0;
5573
+ const aimNoSliderTopWeightedSliderCount = (_e = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.countTopWeightedSliders(aimNoSliderDifficultyValue)) !== null && _e !== void 0 ? _e : 0;
5574
+ const aimNoSliderDifficultStrainCount = (_f = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.countTopWeightedStrains(aimNoSliderDifficultyValue)) !== null && _f !== void 0 ? _f : 0;
5089
5575
  attributes.aimTopWeightedSliderFactor =
5090
5576
  aimNoSliderTopWeightedSliderCount /
5091
5577
  Math.max(1, aimNoSliderDifficultStrainCount -
@@ -5094,27 +5580,29 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5094
5580
  const speedDifficultyValue = (_g = speed === null || speed === void 0 ? void 0 : speed.difficultyValue()) !== null && _g !== void 0 ? _g : 0;
5095
5581
  attributes.speedNoteCount = (_h = speed === null || speed === void 0 ? void 0 : speed.relevantNoteCount()) !== null && _h !== void 0 ? _h : 0;
5096
5582
  attributes.speedDifficultStrainCount =
5097
- (_j = speed === null || speed === void 0 ? void 0 : speed.countTopWeightedStrains(speedDifficultyValue)) !== null && _j !== void 0 ? _j : 0;
5098
- const speedTopWeightedSliderCount = (_k = speed === null || speed === void 0 ? void 0 : speed.countTopWeightedSliders()) !== null && _k !== void 0 ? _k : 0;
5583
+ (_j = speed === null || speed === void 0 ? void 0 : speed.countTopWeightedObjectDifficulties(speedDifficultyValue)) !== null && _j !== void 0 ? _j : 0;
5584
+ const speedTopWeightedSliderCount = (_k = speed === null || speed === void 0 ? void 0 : speed.countTopWeightedSliders(speedDifficultyValue)) !== null && _k !== void 0 ? _k : 0;
5099
5585
  attributes.speedTopWeightedSliderFactor =
5100
5586
  speedTopWeightedSliderCount /
5101
5587
  Math.max(1, attributes.speedDifficultStrainCount -
5102
5588
  speedTopWeightedSliderCount);
5589
+ // Reading attributes
5590
+ const readingDifficultyValue = (_l = reading === null || reading === void 0 ? void 0 : reading.difficultyValue()) !== null && _l !== void 0 ? _l : 0;
5591
+ attributes.readingDifficultNoteCount =
5592
+ (_m = reading === null || reading === void 0 ? void 0 : reading.countTopWeightedObjectDifficulties(readingDifficultyValue)) !== null && _m !== void 0 ? _m : 0;
5103
5593
  // Final rating
5104
- const mechanicalDifficultyRating = this.calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
5105
- const ratingCalculator = new OsuRatingCalculator(attributes.mods, playableBeatmap.hitObjects.objects.length, attributes.approachRate, attributes.overallDifficulty, mechanicalDifficultyRating, attributes.sliderFactor);
5106
5594
  attributes.aimDifficulty =
5107
- ratingCalculator.computeAimRating(aimDifficultyValue);
5595
+ this.calculateAimDifficultyRating(aimDifficultyValue);
5108
5596
  attributes.speedDifficulty =
5109
- ratingCalculator.computeSpeedRating(speedDifficultyValue);
5110
- attributes.flashlightDifficulty =
5111
- ratingCalculator.computeFlashlightRating((_l = flashlight === null || flashlight === void 0 ? void 0 : flashlight.difficultyValue()) !== null && _l !== void 0 ? _l : 0);
5597
+ this.calculateDifficultyRating(speedDifficultyValue);
5598
+ attributes.flashlightDifficulty = this.calculateDifficultyRating((_o = flashlight === null || flashlight === void 0 ? void 0 : flashlight.difficultyValue()) !== null && _o !== void 0 ? _o : 0);
5599
+ attributes.readingDifficulty = this.calculateDifficultyRating(readingDifficultyValue);
5112
5600
  const baseAimPerformance = OsuAim.difficultyToPerformance(attributes.aimDifficulty);
5113
5601
  const baseSpeedPerformance = OsuSpeed.difficultyToPerformance(attributes.speedDifficulty);
5114
5602
  const baseFlashlightPerformance = OsuFlashlight.difficultyToPerformance(attributes.flashlightDifficulty);
5115
- const basePerformance = Math.pow(Math.pow(baseAimPerformance, 1.1) +
5116
- Math.pow(baseSpeedPerformance, 1.1) +
5117
- Math.pow(baseFlashlightPerformance, 1.1), 1 / 1.1);
5603
+ const baseReadingPerformance = OsuReading.difficultyToPerformance(attributes.readingDifficulty);
5604
+ const baseCognitionPerformance = OsuDifficultyCalculator.sumCognitionDifficulty(baseReadingPerformance, baseFlashlightPerformance);
5605
+ const basePerformance = osuBase.MathUtils.norm(OsuPerformanceCalculator.normExponent, baseAimPerformance, baseSpeedPerformance, baseCognitionPerformance);
5118
5606
  attributes.starRating = this.calculateStarRating(basePerformance);
5119
5607
  return attributes;
5120
5608
  }
@@ -5122,12 +5610,11 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5122
5610
  return beatmap.createOsuPlayableBeatmap(mods);
5123
5611
  }
5124
5612
  createDifficultyHitObjects(beatmap) {
5125
- var _a;
5126
5613
  const clockRate = beatmap.speedMultiplier;
5127
5614
  const difficultyObjects = [];
5128
5615
  const { objects } = beatmap.hitObjects;
5129
5616
  for (let i = 1; i < objects.length; ++i) {
5130
- const difficultyObject = new OsuDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, difficultyObjects, clockRate, i - 1);
5617
+ const difficultyObject = new OsuDifficultyHitObject(objects[i], objects[i - 1], difficultyObjects, clockRate, i - 1);
5131
5618
  difficultyObject.computeProperties(clockRate);
5132
5619
  difficultyObjects.push(difficultyObject);
5133
5620
  }
@@ -5143,8 +5630,9 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5143
5630
  if (!mods.has(osuBase.ModRelax)) {
5144
5631
  skills.push(new OsuSpeed(mods));
5145
5632
  }
5633
+ skills.push(new OsuReading(mods, beatmap.speedMultiplier, beatmap.hitObjects.objects));
5146
5634
  if (mods.has(osuBase.ModFlashlight)) {
5147
- skills.push(new OsuFlashlight(mods));
5635
+ skills.push(new OsuFlashlight(mods, beatmap.hitObjects.objects.length));
5148
5636
  }
5149
5637
  return skills;
5150
5638
  }
@@ -5154,30 +5642,26 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5154
5642
  new OsuAim(mods, true),
5155
5643
  new OsuAim(mods, false),
5156
5644
  new OsuSpeed(mods),
5157
- new OsuFlashlight(mods),
5645
+ new OsuFlashlight(mods, beatmap.hitObjects.objects.length),
5158
5646
  ];
5159
5647
  }
5160
- calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue) {
5161
- const aimValue = OsuSkill.difficultyToPerformance(OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue));
5162
- const speedValue = OsuSkill.difficultyToPerformance(OsuRatingCalculator.calculateDifficultyRating(speedDifficultyValue));
5163
- const totalValue = Math.pow(Math.pow(aimValue, 1.1) + Math.pow(speedValue, 1.1), 1 / 1.1);
5164
- return this.calculateStarRating(totalValue);
5648
+ calculateAimDifficultyRating(difficultyValue) {
5649
+ return Math.pow(difficultyValue, 0.63) * 0.02275;
5650
+ }
5651
+ calculateDifficultyRating(difficultyValue) {
5652
+ return Math.sqrt(difficultyValue) * 0.0675;
5165
5653
  }
5166
5654
  calculateStarRating(basePerformance) {
5167
- if (basePerformance <= 1e-5) {
5168
- return 0;
5169
- }
5170
- return (Math.cbrt(OsuPerformanceCalculator.finalMultiplier) *
5171
- this.starRatingMultiplier *
5172
- (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformance) + 4));
5173
- }
5174
- static calculateRateAdjustedApproachRate(approachRate, clockRate) {
5175
- const preempt = osuBase.BeatmapDifficulty.difficultyRange(approachRate, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
5176
- return osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
5655
+ return Math.cbrt(basePerformance * OsuPerformanceCalculator.finalMultiplier);
5177
5656
  }
5178
- static calculateRateAdjustedOverallDifficulty(overallDifficulty, clockRate) {
5179
- const greatWindow = new osuBase.OsuHitWindow(overallDifficulty).greatWindow / clockRate;
5180
- return (79.5 - greatWindow) / 6;
5657
+ static sumCognitionDifficulty(reading, flashlight) {
5658
+ if (reading <= 0) {
5659
+ return flashlight;
5660
+ }
5661
+ if (flashlight <= 0) {
5662
+ return reading;
5663
+ }
5664
+ return osuBase.MathUtils.norm(OsuPerformanceCalculator.normExponent, reading, flashlight * osuBase.MathUtils.clamp(flashlight / reading, 0.25, 1));
5181
5665
  }
5182
5666
  }
5183
5667
 
@@ -5201,15 +5685,19 @@ exports.DroidSnapAimEvaluator = DroidSnapAimEvaluator;
5201
5685
  exports.DroidTap = DroidTap;
5202
5686
  exports.DroidTapEvaluator = DroidTapEvaluator;
5203
5687
  exports.ExtendedDroidDifficultyAttributes = ExtendedDroidDifficultyAttributes;
5688
+ exports.OsuAgilityEvaluator = OsuAgilityEvaluator;
5204
5689
  exports.OsuAim = OsuAim;
5205
- exports.OsuAimEvaluator = OsuAimEvaluator;
5206
5690
  exports.OsuDifficultyAttributes = OsuDifficultyAttributes;
5207
5691
  exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
5208
5692
  exports.OsuDifficultyHitObject = OsuDifficultyHitObject;
5209
5693
  exports.OsuFlashlight = OsuFlashlight;
5210
5694
  exports.OsuFlashlightEvaluator = OsuFlashlightEvaluator;
5695
+ exports.OsuFlowAimEvaluator = OsuFlowAimEvaluator;
5211
5696
  exports.OsuPerformanceCalculator = OsuPerformanceCalculator;
5697
+ exports.OsuReading = OsuReading;
5698
+ exports.OsuReadingEvaluator = OsuReadingEvaluator;
5212
5699
  exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
5700
+ exports.OsuSnapAimEvaluator = OsuSnapAimEvaluator;
5213
5701
  exports.OsuSpeed = OsuSpeed;
5214
5702
  exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
5215
5703
  exports.PerformanceCalculator = PerformanceCalculator;