@rian8337/osu-difficulty-calculator 4.0.0-beta.93 → 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 +1292 -808
  2. package/package.json +3 -3
  3. package/typings/index.d.ts +327 -180
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
  }
@@ -1268,8 +1264,8 @@ class DroidFlashlight extends DroidSkill {
1268
1264
  this.totalObjects = totalObjects;
1269
1265
  this.reducedSectionCount = 0;
1270
1266
  this.reducedSectionBaseline = 1;
1271
- this.starsPerDouble = 1.06;
1272
- this.skillMultiplier = 0.024;
1267
+ this.starsPerDouble = 1;
1268
+ this.skillMultiplier = 0.058;
1273
1269
  this.currentFlashlightStrain = 0;
1274
1270
  }
1275
1271
  strainValueAt(current) {
@@ -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;
@@ -3640,13 +3632,9 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
3640
3632
  attributes.rhythmDifficulty = this.calculateDifficultyRating((_a = rhythm === null || rhythm === void 0 ? void 0 : rhythm.difficultyValue()) !== null && _a !== void 0 ? _a : 0);
3641
3633
  }
3642
3634
  populateFlashlightAttributes(attributes, skills) {
3635
+ var _a;
3643
3636
  const flashlight = skills.find((s) => s instanceof DroidFlashlight);
3644
- if (!flashlight) {
3645
- attributes.flashlightDifficulty = 0;
3646
- return;
3647
- }
3648
- attributes.flashlightDifficulty =
3649
- Math.sqrt(flashlight.difficultyValue()) * 0.18;
3637
+ attributes.flashlightDifficulty = this.calculateDifficultyRating((_a = flashlight === null || flashlight === void 0 ? void 0 : flashlight.difficultyValue()) !== null && _a !== void 0 ? _a : 0);
3650
3638
  }
3651
3639
  populateReadingAttributes(attributes, skills) {
3652
3640
  const reading = skills.find((s) => s instanceof DroidReading);
@@ -3678,154 +3666,33 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
3678
3666
  }
3679
3667
 
3680
3668
  /**
3681
- * An evaluator for calculating osu!standard Aim skill.
3669
+ * An evaluator for calculating osu!standard agility aim difficulty.
3682
3670
  */
3683
- class OsuAimEvaluator {
3671
+ class OsuAgilityEvaluator {
3684
3672
  /**
3685
- * Evaluates the difficulty of aiming the current object, based on:
3686
- *
3687
- * - cursor velocity to the current object,
3688
- * - angle difficulty,
3689
- * - sharp velocity increases,
3690
- * - and slider difficulty.
3673
+ * Evaluates the difficulty of fast aiming the current object.
3691
3674
  *
3692
3675
  * @param current The current object.
3693
- * @param withSliders Whether to take slider difficulty into account.
3694
3676
  */
3695
- static evaluateDifficultyOf(current, withSliders) {
3696
- const last = current.previous(0);
3697
- if (current.object instanceof osuBase.Spinner ||
3698
- current.index <= 1 ||
3699
- last.object instanceof osuBase.Spinner) {
3677
+ static evaluateDifficultyOf(current) {
3678
+ var _a;
3679
+ if (current.object instanceof osuBase.Spinner) {
3700
3680
  return 0;
3701
3681
  }
3702
- const lastLast = current.previous(1);
3703
- const last2 = current.previous(2);
3704
- const radius = current.normalizedRadius;
3705
- const diameter = current.normalizedDiameter;
3706
- // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
3707
- let currentVelocity = current.lazyJumpDistance / current.strainTime;
3708
- // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
3709
- if (last.object instanceof osuBase.Slider && withSliders) {
3710
- // Calculate the slider velocity from slider head to slider end.
3711
- const travelVelocity = last.travelDistance / last.travelTime;
3712
- // Calculate the movement velocity from slider end to current object.
3713
- const movementVelocity = current.minimumJumpDistance / current.minimumJumpTime;
3714
- // Take the larger total combined velocity.
3715
- currentVelocity = Math.max(currentVelocity, movementVelocity + travelVelocity);
3716
- }
3717
- // As above, do the same for the previous hitobject.
3718
- let prevVelocity = last.lazyJumpDistance / last.strainTime;
3719
- if (lastLast.object instanceof osuBase.Slider && withSliders) {
3720
- const travelVelocity = lastLast.travelDistance / lastLast.travelTime;
3721
- const movementVelocity = last.minimumJumpDistance / last.minimumJumpTime;
3722
- prevVelocity = Math.max(prevVelocity, movementVelocity + travelVelocity);
3723
- }
3724
- let wideAngleBonus = 0;
3725
- let acuteAngleBonus = 0;
3726
- let sliderBonus = 0;
3727
- let velocityChangeBonus = 0;
3728
- let wiggleBonus = 0;
3729
- // Start strain with regular velocity.
3730
- let strain = currentVelocity;
3731
- if (current.angle !== null && last.angle !== null) {
3732
- const currentAngle = current.angle;
3733
- const lastAngle = last.angle;
3734
- // Rewarding angles, take the smaller velocity as base.
3735
- const angleBonus = Math.min(currentVelocity, prevVelocity);
3736
- if (
3737
- // If rhythms are the same.
3738
- Math.max(current.strainTime, last.strainTime) <
3739
- 1.25 * Math.min(current.strainTime, last.strainTime)) {
3740
- acuteAngleBonus = this.calculateAcuteAngleBonus(currentAngle);
3741
- // Penalize angle repetition.
3742
- acuteAngleBonus *=
3743
- 0.08 +
3744
- 0.92 *
3745
- (1 -
3746
- Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
3747
- // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
3748
- acuteAngleBonus *=
3749
- angleBonus *
3750
- osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
3751
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
3752
- }
3753
- wideAngleBonus = this.calculateWideAngleBonus(currentAngle);
3754
- // Penalize angle repetition.
3755
- wideAngleBonus *=
3756
- 1 -
3757
- Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
3758
- // Apply full wide angle bonus for distance more than one diameter
3759
- wideAngleBonus *=
3760
- angleBonus *
3761
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
3762
- // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
3763
- // https://www.desmos.com/calculator/dp0v0nvowc
3764
- wiggleBonus =
3765
- angleBonus *
3766
- osuBase.MathUtils.smootherstep(current.lazyJumpDistance, radius, diameter) *
3767
- Math.pow(osuBase.MathUtils.reverseLerp(current.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3768
- osuBase.MathUtils.smootherstep(currentAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
3769
- osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
3770
- Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3771
- osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
3772
- if (last2 !== null) {
3773
- // If objects just go back and forth through a middle point - don't give as much wide bonus.
3774
- // Use previous(2) and previous(0) because angles calculation is done prevprev-prev-curr, so any
3775
- // object's angle's center point is always the previous object.
3776
- const distance = last2.object.stackedPosition.getDistance(last.object.stackedPosition);
3777
- if (distance < 1) {
3778
- wideAngleBonus *= 1 - 0.35 * (1 - distance);
3779
- }
3780
- }
3781
- }
3782
- if (Math.max(prevVelocity, currentVelocity)) {
3783
- // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
3784
- prevVelocity =
3785
- (last.lazyJumpDistance + lastLast.travelDistance) /
3786
- last.strainTime;
3787
- currentVelocity =
3788
- (current.lazyJumpDistance + last.travelDistance) /
3789
- current.strainTime;
3790
- // Scale with ratio of difference compared to half the max distance.
3791
- const distanceRatio = osuBase.MathUtils.smoothstep(Math.abs(prevVelocity - currentVelocity) /
3792
- Math.max(prevVelocity, currentVelocity), 0, 1);
3793
- // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
3794
- const overlapVelocityBuff = Math.min((diameter * 1.25) /
3795
- Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
3796
- velocityChangeBonus = overlapVelocityBuff * distanceRatio;
3797
- // Penalize for rhythm changes.
3798
- velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
3799
- Math.max(current.strainTime, last.strainTime), 2);
3800
- }
3801
- if (last.travelTime) {
3802
- // Reward sliders based on velocity.
3803
- sliderBonus = last.travelDistance / last.travelTime;
3804
- }
3805
- strain += wiggleBonus * this.wiggleMultiplier;
3806
- strain += velocityChangeBonus * this.velocityChangeMultiplier;
3807
- // Add in acute angle bonus or wide angle bonus, whichever is larger.
3808
- strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier);
3809
- // Apply high circle size bonus
3810
- strain *= current.smallCircleBonus;
3811
- // Add in additional slider velocity bonus.
3812
- if (withSliders) {
3813
- strain += sliderBonus * this.sliderMultiplier;
3814
- }
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);
3815
3690
  return strain;
3816
3691
  }
3817
- static calculateWideAngleBonus(angle) {
3818
- return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
3819
- }
3820
- static calculateAcuteAngleBonus(angle) {
3821
- 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));
3822
3694
  }
3823
3695
  }
3824
- OsuAimEvaluator.wideAngleMultiplier = 1.5;
3825
- OsuAimEvaluator.acuteAngleMultiplier = 2.55;
3826
- OsuAimEvaluator.sliderMultiplier = 1.35;
3827
- OsuAimEvaluator.velocityChangeMultiplier = 0.75;
3828
- OsuAimEvaluator.wiggleMultiplier = 1.02;
3829
3696
 
3830
3697
  /**
3831
3698
  * An evaluator for calculating osu!standard Flashlight skill.
@@ -3844,6 +3711,7 @@ class OsuFlashlightEvaluator {
3844
3711
  * @param mods The mods used.
3845
3712
  */
3846
3713
  static evaluateDifficultyOf(current, mods) {
3714
+ var _a;
3847
3715
  if (current.object instanceof osuBase.Spinner) {
3848
3716
  return 0;
3849
3717
  }
@@ -3853,6 +3721,10 @@ class OsuFlashlightEvaluator {
3853
3721
  let result = 0;
3854
3722
  let last = current;
3855
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;
3856
3728
  for (let i = 0; i < Math.min(current.index, 10); ++i) {
3857
3729
  const currentObject = current.previous(i);
3858
3730
  cumulativeStrainTime += last.strainTime;
@@ -3868,7 +3740,7 @@ class OsuFlashlightEvaluator {
3868
3740
  const opacityBonus = 1 +
3869
3741
  this.maxOpacityBonus *
3870
3742
  (1 -
3871
- current.opacityAt(currentObject.object.startTime, mods));
3743
+ current.opacityAt(currentObject.object.startTime, opacityMods));
3872
3744
  result +=
3873
3745
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
3874
3746
  cumulativeStrainTime;
@@ -3912,149 +3784,660 @@ OsuFlashlightEvaluator.minVelocity = 0.5;
3912
3784
  OsuFlashlightEvaluator.sliderMultiplier = 1.3;
3913
3785
  OsuFlashlightEvaluator.minAngleMultiplier = 0.2;
3914
3786
 
3915
- class OsuIsland {
3916
- constructor(delta, deltaDifferenceEpsilon) {
3917
- this.delta = Number.MAX_SAFE_INTEGER;
3918
- this.deltaCount = 0;
3919
- if (deltaDifferenceEpsilon === undefined) {
3920
- this.deltaDifferenceEpsilon = delta;
3921
- }
3922
- else {
3923
- this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
3924
- this.addDelta(delta);
3925
- }
3926
- }
3927
- addDelta(delta) {
3928
- if (this.delta === Number.MAX_SAFE_INTEGER) {
3929
- this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
3930
- }
3931
- ++this.deltaCount;
3932
- }
3933
- isSimilarPolarity(other) {
3934
- // 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)
3935
- // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
3936
- return this.deltaCount % 2 == other.deltaCount % 2;
3937
- }
3938
- equals(other) {
3939
- return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
3940
- this.deltaCount === other.deltaCount);
3941
- }
3942
- }
3943
-
3944
3787
  /**
3945
- * An evaluator for calculating osu!standard Rhythm skill.
3788
+ * An evaluator for calculating osu!standard snap aim difficulty.
3946
3789
  */
3947
- class OsuRhythmEvaluator {
3790
+ class OsuSnapAimEvaluator {
3948
3791
  /**
3949
- * Calculates a rhythm multiplier for the difficulty of the tap associated
3950
- * 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.
3951
3798
  *
3952
3799
  * @param current The current object.
3800
+ * @param withSliders Whether to take slider difficulty into account.
3953
3801
  */
3954
- static evaluateDifficultyOf(current) {
3955
- 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) {
3956
3807
  return 0;
3957
3808
  }
3958
- const deltaDifferenceEpsilon = current.hitWindowFor(osuBase.HitResult.Great) * 0.3;
3959
- let rhythmComplexitySum = 0;
3960
- let island = new OsuIsland(deltaDifferenceEpsilon);
3961
- let previousIsland = new OsuIsland(deltaDifferenceEpsilon);
3962
- const islandCounts = new Map();
3963
- // Store the ratio of the current start of an island to buff for tighter rhythms.
3964
- let startRatio = 0;
3965
- let firstDeltaSwitch = false;
3966
- let rhythmStart = 0;
3967
- const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
3968
- while (rhythmStart < historicalNoteCount - 2 &&
3969
- current.startTime - current.previous(rhythmStart).startTime <
3970
- this.historyTimeMax) {
3971
- ++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);
3972
3821
  }
3973
- let prevObject = current.previous(rhythmStart);
3974
- let lastObject = current.previous(rhythmStart + 1);
3975
- for (let i = rhythmStart; i > 0; --i) {
3976
- const currentObject = current.previous(i - 1);
3977
- // Scale note 0 to 1 from history to now.
3978
- const timeDecay = (this.historyTimeMax -
3979
- (current.startTime - currentObject.startTime)) /
3980
- this.historyTimeMax;
3981
- const noteDecay = (historicalNoteCount - i) / historicalNoteCount;
3982
- // Either we're limited by time or limited by object count.
3983
- const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
3984
- // Use custom cap value to ensure that that at this point delta time is actually zero.
3985
- const currentDelta = Math.max(currentObject.deltaTime, 1e-7);
3986
- const prevDelta = Math.max(prevObject.deltaTime, 1e-7);
3987
- const lastDelta = Math.max(lastObject.deltaTime, 1e-7);
3988
- // Calculate how much current delta difference deserves a rhythm bonus
3989
- // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
3990
- const deltaDifference = Math.max(prevDelta, currentDelta) /
3991
- Math.min(prevDelta, currentDelta);
3992
- // Take only the fractional part of the value since we are only interested in punishing multiples.
3993
- const deltaDifferenceFraction = deltaDifference - Math.trunc(deltaDifference);
3994
- const currentRatio = 1 +
3995
- this.rhythmRatioMultiplier *
3996
- Math.min(0.5, osuBase.MathUtils.smoothstepBellCurve(deltaDifferenceFraction));
3997
- // Reduce ratio bonus if delta difference is too big
3998
- const differenceMultiplier = osuBase.MathUtils.clamp(2 - deltaDifference / 8, 0, 1);
3999
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
4000
- let effectiveRatio = windowPenalty * currentRatio * differenceMultiplier;
4001
- if (firstDeltaSwitch) {
4002
- if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
4003
- // Island is still progressing, count size.
4004
- island.addDelta(currentDelta);
4005
- }
4006
- else {
4007
- // BPM change is into slider, this is easy acc window.
4008
- if (currentObject.object instanceof osuBase.Slider) {
4009
- effectiveRatio /= 8;
4010
- }
4011
- // BPM change was from a slider, this is easier typically than circle -> circle.
4012
- // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
4013
- // than bursts without sliders.
4014
- if (prevObject.object instanceof osuBase.Slider) {
4015
- effectiveRatio *= 0.3;
4016
- }
4017
- // Repeated island polarity (2 -> 4, 3 -> 5).
4018
- if (island.isSimilarPolarity(previousIsland)) {
4019
- effectiveRatio /= 2;
4020
- }
4021
- // Previous increase happened a note ago.
4022
- // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
4023
- if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
4024
- prevDelta > currentDelta + deltaDifferenceEpsilon) {
4025
- effectiveRatio /= 8;
4026
- }
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
+ }
4027
4402
  // Repeated island size (ex: triplet -> triplet).
4028
4403
  // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
4029
4404
  if (previousIsland.deltaCount == island.deltaCount) {
4030
4405
  effectiveRatio /= 2;
4031
4406
  }
4032
- let islandFound = false;
4033
- for (const [currentIsland, count] of islandCounts) {
4034
- 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)) {
4035
4413
  continue;
4036
4414
  }
4037
- islandFound = true;
4038
- let islandCount = count;
4039
- if (previousIsland.equals(island)) {
4040
- // Only add island to island counts if they're going one after another.
4041
- ++islandCount;
4042
- 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;
4043
4418
  }
4419
+ const power = osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75);
4044
4420
  // Repeated island (ex: triplet -> triplet).
4045
4421
  // Graph: https://www.desmos.com/calculator/pj7an56zwf
4046
- 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;
4047
4424
  break;
4048
4425
  }
4049
- if (!islandFound) {
4050
- islandCounts.set(island, 1);
4426
+ if (!found && island.deltaCount > 0) {
4427
+ islands.push(island);
4051
4428
  }
4052
4429
  // Scale down the difficulty if the object is doubletappable.
4053
4430
  effectiveRatio *=
4054
4431
  1 - prevObject.getDoubletapness(currentObject) * 0.75;
4055
- rhythmComplexitySum +=
4056
- Math.sqrt(effectiveRatio * startRatio) *
4057
- 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
+ }
4058
4441
  startRatio = effectiveRatio;
4059
4442
  previousIsland = island;
4060
4443
  if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
@@ -4062,7 +4445,7 @@ class OsuRhythmEvaluator {
4062
4445
  // If we're speeding up, this stays as is and we keep counting island size.
4063
4446
  firstDeltaSwitch = false;
4064
4447
  }
4065
- island = new OsuIsland(currentDelta, deltaDifferenceEpsilon);
4448
+ island = new Island(currentDelta);
4066
4449
  }
4067
4450
  }
4068
4451
  else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
@@ -4080,20 +4463,28 @@ class OsuRhythmEvaluator {
4080
4463
  effectiveRatio *= 0.6;
4081
4464
  }
4082
4465
  startRatio = effectiveRatio;
4083
- island = new OsuIsland(currentDelta, deltaDifferenceEpsilon);
4466
+ island = new Island(currentDelta);
4084
4467
  }
4085
4468
  lastObject = prevObject;
4086
4469
  prevObject = currentObject;
4087
4470
  }
4088
- return ((Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) *
4089
- (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) /
4090
4474
  2);
4091
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
+ }
4092
4483
  }
4093
4484
  OsuRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
4094
4485
  OsuRhythmEvaluator.historyObjectsMax = 32;
4095
- OsuRhythmEvaluator.rhythmOverallMultiplier = 1;
4096
- OsuRhythmEvaluator.rhythmRatioMultiplier = 15;
4486
+ OsuRhythmEvaluator.rhythmOverallMultiplier = 0.95;
4487
+ OsuRhythmEvaluator.rhythmRatioMultiplier = 26;
4097
4488
 
4098
4489
  /**
4099
4490
  * An evaluator for calculating osu!standard speed skill.
@@ -4103,18 +4494,14 @@ class OsuSpeedEvaluator {
4103
4494
  * Evaluates the difficulty of tapping the current object, based on:
4104
4495
  *
4105
4496
  * - time between pressing the previous and current object,
4106
- * - distance between those objects,
4107
4497
  * - and how easily they can be cheesed.
4108
4498
  *
4109
4499
  * @param current The current object.
4110
- * @param mods The mods applied.
4111
4500
  */
4112
- static evaluateDifficultyOf(current, mods) {
4113
- var _a;
4501
+ static evaluateDifficultyOf(current) {
4114
4502
  if (current.object instanceof osuBase.Spinner) {
4115
4503
  return 0;
4116
4504
  }
4117
- const prev = current.previous(0);
4118
4505
  let strainTime = current.strainTime;
4119
4506
  // Nerf doubletappable doubles.
4120
4507
  const doubletapness = 1 - current.getDoubletapness(current.next(0));
@@ -4124,389 +4511,389 @@ class OsuSpeedEvaluator {
4124
4511
  // speedBonus will be 0.0 for BPM < 200
4125
4512
  let speedBonus = 0;
4126
4513
  // Add additional scaling bonus for streams/bursts higher than 200bpm
4127
- if (strainTime < this.minSpeedBonus) {
4514
+ if (osuBase.MathUtils.millisecondsToBPM(strainTime) > this.minSpeedBonus) {
4128
4515
  speedBonus =
4129
- 0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
4130
- }
4131
- const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
4132
- const singleSpacingThreshold = current.normalizedDiameter * 1.25;
4133
- // Cap distance at spacing threshold
4134
- const distance = Math.min(singleSpacingThreshold, travelDistance + current.minimumJumpDistance);
4135
- // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
4136
- let distanceBonus = Math.pow(distance / singleSpacingThreshold, 3.95) *
4137
- this.DISTANCE_MULTIPLIER;
4138
- // Apply reduced small circle bonus because flow aim difficulty on small circles does not scale as hard as jumps.
4139
- distanceBonus *= Math.sqrt(current.smallCircleBonus);
4140
- if (mods.has(osuBase.ModAutopilot)) {
4141
- distanceBonus = 0;
4516
+ 0.75 *
4517
+ Math.pow((osuBase.MathUtils.bpmToMilliseconds(this.minSpeedBonus) -
4518
+ strainTime) /
4519
+ 40, 2);
4142
4520
  }
4143
4521
  // Base difficulty with all bonuses
4144
- const difficulty = ((1 + speedBonus + distanceBonus) * 1000) / strainTime;
4522
+ let difficulty = ((1 + speedBonus) * 1000) / strainTime;
4523
+ difficulty *= this.highBpmBonus(current.strainTime);
4145
4524
  // Apply penalty if there's doubletappable doubles
4146
4525
  return difficulty * doubletapness;
4147
4526
  }
4527
+ static highBpmBonus(ms) {
4528
+ return 1 / (1 - Math.pow(0.3, ms / 1000));
4529
+ }
4148
4530
  }
4149
4531
  // ~200 1/4 BPM streams
4150
- OsuSpeedEvaluator.minSpeedBonus = 75;
4151
- OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.8;
4532
+ OsuSpeedEvaluator.minSpeedBonus = 200;
4152
4533
 
4153
- class OsuRatingCalculator {
4154
- constructor(mods, totalHits, approachRate, overallDifficulty, mechanicalDifficultyRating, sliderFactor) {
4155
- this.mods = mods;
4156
- this.totalHits = totalHits;
4157
- this.approachRate = approachRate;
4158
- this.overallDifficulty = overallDifficulty;
4159
- this.mechanicalDifficultyRating = mechanicalDifficultyRating;
4160
- 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;
4161
4558
  }
4162
- computeAimRating(aimDifficultyValue) {
4163
- 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) {
4164
4564
  return 0;
4165
4565
  }
4166
- let aimRating = OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue);
4167
- if (this.mods.has(osuBase.ModTouchDevice)) {
4168
- aimRating = Math.pow(aimRating, 0.8);
4169
- }
4170
- if (this.mods.has(osuBase.ModRelax)) {
4171
- aimRating *= 0.9;
4172
- }
4173
- if (this.mods.has(osuBase.ModMagnetised)) {
4174
- const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4175
- aimRating *= 1 - magnetisedStrength;
4176
- }
4177
- let ratingMultiplier = 1;
4178
- const approachRateLengthBonus = 0.95 +
4179
- 0.4 * Math.min(1, this.totalHits / 2000) +
4180
- (this.totalHits > 2000
4181
- ? Math.log10(this.totalHits / 2000) * 0.5
4182
- : 0);
4183
- let approachRateFactor = 0;
4184
- if (this.approachRate > 10.33) {
4185
- approachRateFactor = 0.3 * (this.approachRate - 10.33);
4186
- }
4187
- else if (this.approachRate < 8) {
4188
- approachRateFactor = 0.05 * (8 - this.approachRate);
4189
- }
4190
- if (this.mods.has(osuBase.ModRelax)) {
4191
- approachRateFactor = 0;
4192
- }
4193
- // Buff for longer beatmaps with high AR.
4194
- ratingMultiplier += approachRateFactor * approachRateLengthBonus;
4195
- if (this.mods.has(osuBase.ModHidden)) {
4196
- const visibilityFactor = this.calculateAimVisibilityFactor();
4197
- ratingMultiplier += OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, visibilityFactor, this.sliderFactor);
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;
4198
4577
  }
4199
- // It is important to consider accuracy difficulty when scaling with accuracy.
4200
- ratingMultiplier *=
4201
- 0.98 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 2500;
4202
- return aimRating * Math.cbrt(ratingMultiplier);
4203
- }
4204
- computeSpeedRating(speedDifficultyValue) {
4205
- if (this.mods.has(osuBase.ModRelax)) {
4578
+ const consistentTopStrain = difficultyValue * (1 - this.decayWeight);
4579
+ if (consistentTopStrain === 0) {
4206
4580
  return 0;
4207
4581
  }
4208
- let speedRating = OsuRatingCalculator.calculateDifficultyRating(speedDifficultyValue);
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) {
4209
4587
  if (this.mods.has(osuBase.ModAutopilot)) {
4210
- speedRating *= 0.5;
4588
+ return 0;
4211
4589
  }
4212
- if (this.mods.has(osuBase.ModMagnetised)) {
4213
- // Reduce speed rating because of the distance scaling, with maximum reduction being 0.7.
4214
- const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4215
- speedRating *= 1 - magnetisedStrength * 0.3;
4216
- }
4217
- let ratingMultiplier = 1;
4218
- const approachRateLengthBonus = 0.95 +
4219
- 0.4 * Math.min(1, this.totalHits / 2000) +
4220
- (this.totalHits > 2000
4221
- ? Math.log10(this.totalHits / 2000) * 0.5
4222
- : 0);
4223
- let approachRateFactor = 0;
4224
- if (this.approachRate > 10.33) {
4225
- approachRateFactor = 0.3 * (this.approachRate - 10.33);
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);
4226
4597
  }
4227
- if (this.mods.has(osuBase.ModAutopilot)) {
4228
- approachRateFactor = 0;
4598
+ return this.currentStrain;
4599
+ }
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;
4229
4608
  }
4230
- // Buff for longer beatmaps with high AR.
4231
- ratingMultiplier += approachRateFactor * approachRateLengthBonus;
4232
- if (this.mods.has(osuBase.ModHidden)) {
4233
- const visibilityFactor = this.calculateSpeedVisibilityFactor();
4234
- ratingMultiplier += OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, visibilityFactor, this.sliderFactor);
4609
+ else {
4610
+ current.aimStrainWithoutSliders = this.currentStrain;
4235
4611
  }
4236
- ratingMultiplier *=
4237
- 0.95 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 750;
4238
- return speedRating * Math.cbrt(ratingMultiplier);
4239
4612
  }
4240
- computeFlashlightRating(flashlightDifficultyValue) {
4241
- if (!this.mods.has(osuBase.ModFlashlight)) {
4242
- return 0;
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);
4619
+ if (this.mods.has(osuBase.ModMagnetised)) {
4620
+ const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4621
+ totalDifficulty *= 1 - magnetisedStrength;
4243
4622
  }
4244
- let flashlightRating = OsuRatingCalculator.calculateDifficultyRating(flashlightDifficultyValue);
4623
+ totalDifficulty *=
4624
+ 0.985 + Math.pow(Math.max(0, current.overallDifficulty), 2) / 4000;
4625
+ return totalDifficulty;
4626
+ }
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;
4245
4635
  if (this.mods.has(osuBase.ModTouchDevice)) {
4246
- 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);
4247
4639
  }
4248
4640
  if (this.mods.has(osuBase.ModRelax)) {
4249
- flashlightRating *= 0.7;
4250
- }
4251
- else if (this.mods.has(osuBase.ModAutopilot)) {
4252
- flashlightRating *= 0.4;
4253
- }
4254
- if (this.mods.has(osuBase.ModMagnetised)) {
4255
- const magnetisedStrength = this.mods.get(osuBase.ModMagnetised).attractionStrength.value;
4256
- flashlightRating *= 1 - magnetisedStrength;
4257
- }
4258
- if (this.mods.has(osuBase.ModDeflate)) {
4259
- const deflateInitialScale = this.mods.get(osuBase.ModDeflate).startScale.value;
4260
- flashlightRating *= osuBase.MathUtils.clamp(osuBase.Interpolation.reverseLerp(deflateInitialScale, 11, 1), 0.1, 1);
4641
+ combinedSnapDifficulty *= 0.75;
4642
+ flowDifficulty *= 0.6;
4261
4643
  }
4262
- let ratingMultiplier = 1;
4263
- // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
4264
- ratingMultiplier *=
4265
- 0.7 +
4266
- 0.1 * Math.min(1, this.totalHits / 200) +
4267
- (this.totalHits > 200
4268
- ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
4269
- : 0);
4270
- // It is important to consider accuracy difficulty when scaling with accuracy.
4271
- ratingMultiplier *=
4272
- 0.98 + Math.pow(Math.max(0, this.overallDifficulty), 2) / 2500;
4273
- return flashlightRating * Math.sqrt(ratingMultiplier);
4274
- }
4275
- calculateAimVisibilityFactor() {
4276
- const approachRateFactorEndpoint = 11.5;
4277
- const mechanicalDifficultyFactor = osuBase.Interpolation.reverseLerp(this.mechanicalDifficultyRating, 5, 10);
4278
- const approachRateFactorStartingPoint = osuBase.Interpolation.lerp(9, 10.33, mechanicalDifficultyFactor);
4279
- return osuBase.Interpolation.reverseLerp(this.approachRate, approachRateFactorEndpoint, approachRateFactorStartingPoint);
4280
- }
4281
- calculateSpeedVisibilityFactor() {
4282
- const approachRateFactorEndpoint = 11.5;
4283
- const mechanicalDifficultyFactor = osuBase.Interpolation.reverseLerp(this.mechanicalDifficultyRating, 5, 10);
4284
- const approachRateFactorStartingPoint = osuBase.Interpolation.lerp(10, 10.33, mechanicalDifficultyFactor);
4285
- return osuBase.Interpolation.reverseLerp(this.approachRate, approachRateFactorEndpoint, approachRateFactorStartingPoint);
4644
+ const totalDifficulty = combinedSnapDifficulty * pSnap + flowDifficulty * pFlow;
4645
+ return totalDifficulty * this.skillMultiplierTotal;
4286
4646
  }
4287
4647
  /**
4288
- * 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.
4289
4658
  *
4290
- * @param mods The mods applied to the calculation.
4291
- * @param approachRate The approach rate of the beatmap.
4292
- * @param visibilityFactor The visibility factor to apply.
4293
- * @param sliderFactor The slider factor to apply.
4294
- * @returns The visibility bonus multiplier.
4659
+ * @param ratio The ratio.
4660
+ * @returns The probability.
4295
4661
  */
4296
- static calculateVisibilityBonus(mods, approachRate, visibilityFactor = 1, sliderFactor = 1) {
4297
- var _a, _b;
4298
- 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);
4299
- // Start from normal curve, rewarding lower AR up to AR 7.
4300
- // Traceable forcefully requires a lower reading bonus for now as it is post-applied in pp, which make
4301
- // it multiplicative with the regular AR bonuses.
4302
- // This means it has an advantage over Hidden, so we decrease the multiplier to compensate.
4303
- // This should be removed once we are able to apply Traceable bonuses in star rating (requires real-time
4304
- // difficulty calculations being possible).
4305
- let readingBonus = (isAlwaysPartiallyVisible ? 0.025 : 0.04) *
4306
- (12 - Math.max(approachRate, 7));
4307
- readingBonus *= visibilityFactor;
4308
- // We want to reward slideraim on low AR less.
4309
- const sliderVisibilityFactor = Math.pow(sliderFactor, 3);
4310
- // For AR up to 0, reduce reward for very low ARs when object is visible.
4311
- if (approachRate < 7) {
4312
- readingBonus +=
4313
- (isAlwaysPartiallyVisible ? 0.02 : 0.045) *
4314
- (7 - Math.max(approachRate, 0)) *
4315
- sliderVisibilityFactor;
4662
+ calculateSnapFlowProbability(ratio) {
4663
+ if (ratio === 0) {
4664
+ return 0;
4316
4665
  }
4317
- // Starting from AR 0, cap values so they won't grow to infinity.
4318
- if (approachRate < 0) {
4319
- readingBonus +=
4320
- (isAlwaysPartiallyVisible ? 0.01 : 0.1) *
4321
- (1 - Math.pow(1.5, approachRate)) *
4322
- sliderVisibilityFactor;
4666
+ if (Number.isNaN(ratio)) {
4667
+ return 1;
4323
4668
  }
4324
- return readingBonus;
4325
- }
4326
- static calculateDifficultyRating(difficultyValue) {
4327
- return Math.sqrt(difficultyValue) * this.difficultyMultiplier;
4328
- }
4329
- }
4330
- OsuRatingCalculator.difficultyMultiplier = 0.0675;
4331
-
4332
- /**
4333
- * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
4334
- * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
4335
- */
4336
- class OsuSkill extends StrainSkill {
4337
- constructor() {
4338
- super(...arguments);
4339
- this.difficulty = 0;
4669
+ return osuBase.MathUtils.logistic(-7.27 * Math.log(ratio));
4340
4670
  }
4341
4671
  difficultyValue() {
4342
- const strains = this.currentStrainPeaks.slice().sort((a, b) => b - a);
4343
- if (this.reducedSectionCount > 0) {
4344
- // We are reducing the highest strains first to account for extreme difficulty spikes.
4345
- for (let i = 0; i < Math.min(strains.length, this.reducedSectionCount); ++i) {
4346
- const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / this.reducedSectionCount, 0, 1)));
4347
- strains[i] *= osuBase.Interpolation.lerp(this.reducedSectionBaseline, 1, scale);
4348
- }
4349
- 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;
4350
4699
  }
4351
- // Difficulty is the weighted sum of the highest strains from every section.
4352
- // We're sorting from highest to lowest strain.
4353
- this.difficulty = 0;
4354
- let weight = 1;
4355
- for (const strain of strains) {
4356
- const addition = strain * weight;
4357
- if (this.difficulty + addition === this.difficulty) {
4358
- 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)));
4359
4721
  }
4360
- this.difficulty += addition;
4361
- weight *= this.decayWeight;
4722
+ time += strain.sectionLength;
4723
+ ++strainsToRemove;
4362
4724
  }
4363
- return this.difficulty;
4725
+ strains.splice(0, strainsToRemove);
4726
+ return strains.sort((a, b) => b.value - a.value);
4364
4727
  }
4365
- }
4366
-
4367
- class StrainUtils {
4368
- static countTopWeightedSliders(sliderStrains, difficultyValue) {
4369
- if (sliderStrains.length === 0) {
4370
- return 0;
4371
- }
4372
- const consistentTopStrain = difficultyValue / 10;
4373
- if (consistentTopStrain === 0) {
4374
- return 0;
4375
- }
4376
- // Use a weighted sum of all strains. Constants are arbitrary and give nice values
4377
- return sliderStrains.reduce((total, next) => total +
4378
- osuBase.MathUtils.offsetLogistic(next / consistentTopStrain, 0.88, 10, 1.1));
4728
+ strainDecay(ms) {
4729
+ return Math.pow(0.2, ms / 1000);
4379
4730
  }
4380
4731
  }
4381
4732
 
4382
4733
  /**
4383
- * 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.
4384
4735
  */
4385
- class OsuAim extends OsuSkill {
4386
- constructor(mods, withSliders) {
4736
+ class OsuReading extends HarmonicSkill {
4737
+ constructor(mods, clockRate, hitObjects) {
4387
4738
  super(mods);
4388
- this.strainDecayBase = 0.15;
4389
- this.reducedSectionCount = 10;
4390
- this.reducedSectionBaseline = 0.75;
4391
- this.decayWeight = 0.9;
4392
- this.currentAimStrain = 0;
4393
- this.skillMultiplier = 26;
4394
- this.sliderStrains = [];
4395
- 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;
4396
4744
  }
4397
- /**
4398
- * Obtains the amount of sliders that are considered difficult in terms of relative strain.
4399
- */
4400
- countDifficultSliders() {
4401
- if (this.sliderStrains.length === 0) {
4745
+ countTopWeightedObjectDifficulties(difficultyValue) {
4746
+ if (difficultyValue === 0) {
4402
4747
  return 0;
4403
4748
  }
4404
- const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
4405
- if (maxSliderStrain === 0) {
4749
+ if (this.noteWeightSum === 0) {
4406
4750
  return 0;
4407
4751
  }
4408
- return this.sliderStrains.reduce((total, strain) => total +
4409
- 1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
4410
- }
4411
- /**
4412
- * Obtains the amount of sliders that are considered difficult in terms of relative strain, weighted by consistency.
4413
- */
4414
- countTopWeightedSliders() {
4415
- 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);
4416
4759
  }
4417
- strainValueAt(current) {
4418
- this.currentAimStrain *= this.strainDecay(current.deltaTime);
4419
- this.currentAimStrain +=
4420
- 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) *
4421
4766
  this.skillMultiplier;
4422
- this._objectStrains.push(this.currentAimStrain);
4423
- if (current.object instanceof osuBase.Slider) {
4424
- this.sliderStrains.push(this.currentAimStrain);
4425
- }
4426
- return this.currentAimStrain;
4767
+ return this.currentDifficulty;
4427
4768
  }
4428
- calculateInitialStrain(time, current) {
4429
- var _a, _b;
4430
- return (this.currentAimStrain *
4431
- 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
+ }
4432
4775
  }
4433
- /**
4434
- * @param current The hitobject to save to.
4435
- */
4436
4776
  saveToHitObject(current, difficulty) {
4437
- if (this.withSliders) {
4438
- 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);
4439
4783
  }
4440
- else {
4441
- 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;
4442
4815
  }
4816
+ return reducedNoteCount;
4443
4817
  }
4444
- strainDecay(ms) {
4445
- return Math.pow(this.strainDecayBase, ms / 1000);
4818
+ difficultyDecay(ms) {
4819
+ return Math.pow(this.difficultyDecayBase, ms / 1000);
4446
4820
  }
4447
4821
  }
4448
4822
 
4449
4823
  /**
4450
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.
4451
4825
  */
4452
- class OsuSpeed extends OsuSkill {
4826
+ class OsuSpeed extends HarmonicSkill {
4453
4827
  constructor() {
4454
4828
  super(...arguments);
4455
- this.strainDecayBase = 0.3;
4456
- this.reducedSectionCount = 5;
4457
- this.reducedSectionBaseline = 0.75;
4458
- this.decayWeight = 0.9;
4459
- this.currentSpeedStrain = 0;
4829
+ this.harmonicScale = 20;
4830
+ this.currentDifficulty = 0;
4460
4831
  this.currentRhythm = 0;
4461
- this.skillMultiplier = 1.47;
4462
- this.sliderStrains = [];
4463
- this.maxStrain = 0;
4832
+ this.skillMultiplier = 1.16;
4833
+ this.strainDecayBase = 0.3;
4834
+ this.sliderDifficulties = [];
4835
+ this.maxDifficulty = 0;
4464
4836
  }
4465
4837
  /**
4466
4838
  * The amount of notes that are relevant to the difficulty.
4467
4839
  */
4468
4840
  relevantNoteCount() {
4469
- if (this._objectStrains.length === 0 || this.maxStrain === 0) {
4841
+ if (this.objectDifficulties.length === 0 || this.maxDifficulty === 0) {
4470
4842
  return 0;
4471
4843
  }
4472
- 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);
4473
4846
  }
4474
4847
  /**
4475
- * 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.
4476
4851
  */
4477
- countTopWeightedSliders() {
4478
- 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);
4479
4867
  }
4480
- /**
4481
- * @param current The hitobject to calculate.
4482
- */
4483
- strainValueAt(current) {
4484
- this.currentSpeedStrain *= this.strainDecay(current.strainTime);
4485
- this.currentSpeedStrain +=
4486
- 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) *
4487
4877
  this.skillMultiplier;
4488
4878
  this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
4489
- const strain = this.currentSpeedStrain * this.currentRhythm;
4490
- this._objectStrains.push(strain);
4491
- this.maxStrain = Math.max(this.maxStrain, strain);
4879
+ const difficulty = this.currentDifficulty * this.currentRhythm;
4880
+ this.maxDifficulty = Math.max(this.maxDifficulty, difficulty);
4492
4881
  if (current.object instanceof osuBase.Slider) {
4493
- this.sliderStrains.push(strain);
4882
+ this.sliderDifficulties.push(difficulty);
4494
4883
  }
4495
- return strain;
4496
- }
4497
- calculateInitialStrain(time, current) {
4498
- var _a, _b;
4499
- return (this.currentSpeedStrain *
4500
- this.currentRhythm *
4501
- 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;
4502
4885
  }
4503
- /**
4504
- * @param current The hitobject to save to.
4505
- */
4506
- saveToHitObject(current, difficulty) {
4507
- current.speedStrain = difficulty;
4886
+ saveToHitObject(current) {
4887
+ current.speedStrain = this.currentDifficulty * this.currentRhythm;
4508
4888
  current.rhythmMultiplier = this.currentRhythm;
4509
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
+ }
4510
4897
  strainDecay(ms) {
4511
4898
  return Math.pow(this.strainDecayBase, ms / 1000);
4512
4899
  }
@@ -4534,6 +4921,10 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4534
4921
  * The flashlight performance value.
4535
4922
  */
4536
4923
  this.flashlight = 0;
4924
+ /**
4925
+ * The reading performance value.
4926
+ */
4927
+ this.reading = 0;
4537
4928
  this.greatWindow = 0;
4538
4929
  this.okWindow = 0;
4539
4930
  this.mehWindow = 0;
@@ -4574,20 +4965,17 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4574
4965
  this.greatWindow = hitWindow.greatWindow / clockRate;
4575
4966
  this.okWindow = hitWindow.okWindow / clockRate;
4576
4967
  this.mehWindow = hitWindow.mehWindow / clockRate;
4577
- this.approachRate =
4578
- OsuDifficultyCalculator.calculateRateAdjustedApproachRate(ar, clockRate);
4579
- this.overallDifficulty =
4580
- OsuDifficultyCalculator.calculateRateAdjustedOverallDifficulty(od, clockRate);
4968
+ this.approachRate = this.calculateRateAdjustedApproachRate(ar, clockRate);
4969
+ this.overallDifficulty = (79.5 - this.greatWindow) / 6;
4581
4970
  this.speedDeviation = this.calculateSpeedDeviation();
4582
4971
  this.aim = this.calculateAimValue();
4583
4972
  this.speed = this.calculateSpeedValue();
4584
4973
  this.accuracy = this.calculateAccuracyValue();
4585
4974
  this.flashlight = this.calculateFlashlightValue();
4975
+ this.reading = this.calculateReadingValue();
4976
+ const cognitionValue = OsuDifficultyCalculator.sumCognitionDifficulty(this.reading, this.flashlight);
4586
4977
  this.total =
4587
- Math.pow(Math.pow(this.aim, 1.1) +
4588
- Math.pow(this.speed, 1.1) +
4589
- Math.pow(this.accuracy, 1.1) +
4590
- Math.pow(this.flashlight, 1.1), 1 / 1.1) * finalMultiplier;
4978
+ osuBase.MathUtils.norm(OsuPerformanceCalculator.normExponent, this.aim, this.speed, this.accuracy, cognitionValue) * finalMultiplier;
4591
4979
  }
4592
4980
  /**
4593
4981
  * Calculates the aim performance value of the beatmap.
@@ -4620,7 +5008,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4620
5008
  }
4621
5009
  let aimValue = OsuAim.difficultyToPerformance(aimDifficulty);
4622
5010
  // Longer maps are worth more
4623
- 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);
4624
5012
  if (this.totalHits > 2000) {
4625
5013
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
4626
5014
  }
@@ -4645,7 +5033,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4645
5033
  else if (this.mods.has(osuBase.ModTraceable)) {
4646
5034
  aimValue *=
4647
5035
  1 +
4648
- OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, undefined, this.difficultyAttributes.sliderFactor);
5036
+ this.calculateTraceableBonus(this.difficultyAttributes.sliderFactor);
4649
5037
  }
4650
5038
  // Scale the aim value with accuracy.
4651
5039
  aimValue *= this.computedAccuracy.value();
@@ -4660,45 +5048,23 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4660
5048
  return 0;
4661
5049
  }
4662
5050
  let speedValue = OsuSpeed.difficultyToPerformance(this.difficultyAttributes.speedDifficulty);
4663
- // Longer maps are worth more
4664
- let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
4665
- if (this.totalHits > 2000) {
4666
- lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
4667
- }
4668
- speedValue *= lengthBonus;
4669
5051
  if (this._effectiveMissCount > 0) {
4670
5052
  const speedEstimatedSliderBreaks = this.calculateEstimatedSliderBreaks(this.difficultyAttributes.speedTopWeightedSliderFactor);
4671
5053
  const relevantMissCount = Math.min(this._effectiveMissCount + speedEstimatedSliderBreaks, this.totalImperfectHits + this.sliderTicksMissed);
4672
5054
  speedValue *= this.calculateMissPenalty(relevantMissCount, this.difficultyAttributes.speedDifficultStrainCount);
4673
5055
  }
4674
- // Traceable bonuses are excluded when Blinds is present, as the increased visual difficulty is
4675
- // redundant when notes cannot be seen.
4676
5056
  if (this.mods.has(osuBase.ModBlinds)) {
4677
5057
  // Increasing the speed value by object count for Blinds is not ideal, so the minimum buff is given.
4678
5058
  speedValue *= 1.12;
4679
5059
  }
4680
- else if (this.mods.has(osuBase.ModTraceable)) {
4681
- speedValue *=
4682
- 1 +
4683
- OsuRatingCalculator.calculateVisibilityBonus(this.mods, this.approachRate, undefined, this.difficultyAttributes.sliderFactor);
4684
- }
4685
- // Calculate accuracy assuming the worst case scenario.
4686
- const countGreat = this.computedAccuracy.n300;
4687
- const countOk = this.computedAccuracy.n100;
4688
- const countMeh = this.computedAccuracy.n50;
4689
- const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
4690
- const relevantAccuracy = new osuBase.Accuracy(this.difficultyAttributes.speedNoteCount > 0
4691
- ? {
4692
- n300: Math.max(0, countGreat - relevantTotalDiff),
4693
- n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
4694
- n50: Math.max(0, countMeh -
4695
- Math.max(0, relevantTotalDiff - countGreat - countOk)),
4696
- }
4697
- : // Set accuracy to 0.
4698
- { n300: 0, nobjects: 1 });
4699
5060
  speedValue *= this.calculateSpeedHighDeviationNerf();
4700
- // Scale the speed value with accuracy and OD.
4701
- 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);
4702
5068
  return speedValue;
4703
5069
  }
4704
5070
  /**
@@ -4708,7 +5074,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4708
5074
  if (this.mods.has(osuBase.ModRelax)) {
4709
5075
  return 0;
4710
5076
  }
4711
- const ncircles = this.mods.has(osuBase.ModScoreV2)
5077
+ const ncircles = !this.usingClassicSliderAccuracy || this.mods.has(osuBase.ModScoreV2)
4712
5078
  ? this.totalHits - this.difficultyAttributes.spinnerCount
4713
5079
  : this.difficultyAttributes.hitCircleCount;
4714
5080
  if (ncircles === 0) {
@@ -4721,21 +5087,18 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4721
5087
  // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
4722
5088
  Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
4723
5089
  2.83;
4724
- // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
4725
- 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);
4726
5092
  // Increasing the accuracy value by object count for Blinds isn't ideal, so the minimum buff is given.
4727
5093
  if (this.mods.has(osuBase.ModBlinds)) {
4728
5094
  accuracyValue *= 1.14;
4729
5095
  }
4730
- else if (this.mods.has(osuBase.ModHidden) || this.mods.has(osuBase.ModTraceable)) {
5096
+ else if (this.mods.has(osuBase.ModTraceable)) {
4731
5097
  // Decrease bonus for AR > 10.
4732
5098
  accuracyValue *=
4733
5099
  1 +
4734
5100
  0.08 * osuBase.Interpolation.reverseLerp(this.approachRate, 11.5, 10);
4735
5101
  }
4736
- if (this.mods.has(osuBase.ModFlashlight)) {
4737
- accuracyValue *= 1.02;
4738
- }
4739
5102
  return accuracyValue;
4740
5103
  }
4741
5104
  /**
@@ -4755,17 +5118,23 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4755
5118
  Math.pow(1 -
4756
5119
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
4757
5120
  }
4758
- // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
4759
- flashlightValue *=
4760
- 0.7 +
4761
- 0.1 * Math.min(1, this.totalHits / 200) +
4762
- (this.totalHits > 200
4763
- ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
4764
- : 0);
4765
5121
  // Scale the flashlight value with accuracy slightly.
4766
5122
  flashlightValue *= 0.5 + this.computedAccuracy.value() / 2;
4767
5123
  return flashlightValue;
4768
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
+ }
4769
5138
  /**
4770
5139
  * Calculates a strain-based miss penalty.
4771
5140
  *
@@ -4777,9 +5146,8 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4777
5146
  if (missCount === 0) {
4778
5147
  return 1;
4779
5148
  }
4780
- return (0.96 /
4781
- (missCount / (4 * Math.pow(Math.log(difficultStrainCount), 0.94)) +
4782
- 1));
5149
+ // https://www.desmos.com/calculator/naggvbcz0a
5150
+ return 0.93 / (missCount / (4 * Math.log(difficultStrainCount)) + 1);
4783
5151
  }
4784
5152
  /**
4785
5153
  * Estimates a player's deviation on speed notes using {@link calculateDeviation}, assuming worst-case.
@@ -4881,19 +5249,21 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4881
5249
  return (osuBase.Interpolation.lerp(adjustedSpeedValue, speedValue, t) / speedValue);
4882
5250
  }
4883
5251
  calculateEstimatedSliderBreaks(topWeightedSliderFactor) {
4884
- const { n100 } = this.computedAccuracy;
4885
- if (!this.usingClassicSliderAccuracy || n100 === 0) {
5252
+ const nonMissMistakes = this.computedAccuracy.n100 + this.computedAccuracy.n50;
5253
+ if (!this.usingClassicSliderAccuracy || nonMissMistakes === 0) {
4886
5254
  return 0;
4887
5255
  }
4888
5256
  const missedComboPercent = 1 - this.combo / this.difficultyAttributes.maxCombo;
4889
- let estimatedSliderBreaks = Math.min(n100, this._effectiveMissCount * topWeightedSliderFactor);
4890
- // Scores with more Oks are more likely to have slider breaks.
4891
- 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);
4892
5262
  // There is a low probability of extra slider breaks on effective miss counts close to 1, as
4893
5263
  // score based calculations are good at indicating if only a single break occurred.
4894
5264
  estimatedSliderBreaks *= osuBase.MathUtils.smoothstep(this._effectiveMissCount, 1, 2);
4895
5265
  return (estimatedSliderBreaks *
4896
- okAdjustment *
5266
+ nonMissMistakeAdjustment *
4897
5267
  osuBase.MathUtils.offsetLogistic(missedComboPercent, 0.33, 15));
4898
5268
  }
4899
5269
  /**
@@ -4902,16 +5272,27 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4902
5272
  calculateComboBasedEstimatedMissCount() {
4903
5273
  let missCount = this.computedAccuracy.nmiss;
4904
5274
  const { combo } = this;
4905
- const { sliderCount, maxCombo } = this.difficultyAttributes;
5275
+ const { aimTopWeightedSliderFactor, sliderCount, maxCombo } = this.difficultyAttributes;
4906
5276
  if (sliderCount <= 0) {
4907
5277
  return missCount;
4908
5278
  }
4909
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);
4910
5284
  // Consider that full combo is maximum combo minus dropped slider tails since
4911
5285
  // they don't contribute to combo but also don't break it.
4912
5286
  // In classic scores, we can't know the amount of dropped sliders so we estimate
4913
5287
  // to 10% of all sliders in the beatmap.
4914
- 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);
4915
5296
  if (combo < fullComboThreshold) {
4916
5297
  missCount = fullComboThreshold / Math.max(1, combo);
4917
5298
  }
@@ -4938,6 +5319,36 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4938
5319
  }
4939
5320
  return missCount;
4940
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
+ }
4941
5352
  toString() {
4942
5353
  return (this.total.toFixed(2) +
4943
5354
  " pp (" +
@@ -4948,10 +5359,13 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
4948
5359
  this.accuracy.toFixed(2) +
4949
5360
  " accuracy, " +
4950
5361
  this.flashlight.toFixed(2) +
4951
- " flashlight)");
5362
+ " flashlight, " +
5363
+ this.reading.toFixed(2) +
5364
+ " reading)");
4952
5365
  }
4953
5366
  }
4954
- OsuPerformanceCalculator.finalMultiplier = 1.14;
5367
+ OsuPerformanceCalculator.finalMultiplier = 1.12;
5368
+ OsuPerformanceCalculator.normExponent = 1.1;
4955
5369
 
4956
5370
  /**
4957
5371
  * Represents an osu!standard hit object with difficulty calculation values.
@@ -4967,34 +5381,83 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
4967
5381
  this.mode = osuBase.Modes.Osu;
4968
5382
  }
4969
5383
  get smallCircleBonus() {
4970
- 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;
4971
5389
  }
4972
5390
  }
4973
5391
 
4974
5392
  /**
4975
- * 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.
4976
5395
  */
4977
- class OsuFlashlight extends OsuSkill {
5396
+ class OsuSkill extends StrainSkill {
4978
5397
  constructor() {
4979
5398
  super(...arguments);
4980
- 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;
4981
5437
  this.reducedSectionCount = 0;
4982
5438
  this.reducedSectionBaseline = 1;
4983
5439
  this.decayWeight = 1;
4984
5440
  this.currentFlashlightStrain = 0;
4985
- this.skillMultiplier = 0.05512;
4986
- }
4987
- static difficultyToPerformance(difficulty) {
4988
- return Math.pow(difficulty, 2) * 25;
5441
+ this.skillMultiplier = 0.058;
4989
5442
  }
4990
5443
  difficultyValue() {
4991
- 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;
4992
5453
  }
4993
5454
  strainValueAt(current) {
5455
+ if (!this.mods.has(osuBase.ModFlashlight)) {
5456
+ return 0;
5457
+ }
4994
5458
  this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
4995
5459
  this.currentFlashlightStrain +=
4996
- OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.mods) *
4997
- this.skillMultiplier;
5460
+ this.calculateAdjustedDifficulty(current) * this.skillMultiplier;
4998
5461
  return this.currentFlashlightStrain;
4999
5462
  }
5000
5463
  calculateInitialStrain(time, current) {
@@ -5002,11 +5465,34 @@ class OsuFlashlight extends OsuSkill {
5002
5465
  return (this.currentFlashlightStrain *
5003
5466
  this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
5004
5467
  }
5005
- saveToHitObject(current, difficulty) {
5006
- 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;
5007
5493
  }
5008
5494
  strainDecay(ms) {
5009
- return Math.pow(this.strainDecayBase, ms / 1000);
5495
+ return Math.pow(0.15, ms / 1000);
5010
5496
  }
5011
5497
  }
5012
5498
 
@@ -5020,7 +5506,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5020
5506
  this.drainRate = 0;
5021
5507
  this.speedDifficulty = 0;
5022
5508
  this.speedDifficultStrainCount = 0;
5023
- this.aimTopWeightedSliderFactor = 0;
5024
5509
  this.speedTopWeightedSliderFactor = 0;
5025
5510
  if (!cacheableAttributes) {
5026
5511
  return;
@@ -5030,8 +5515,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5030
5515
  this.speedDifficulty = cacheableAttributes.speedDifficulty;
5031
5516
  this.speedDifficultStrainCount =
5032
5517
  cacheableAttributes.speedDifficultStrainCount;
5033
- this.aimTopWeightedSliderFactor =
5034
- cacheableAttributes.aimTopWeightedSliderFactor;
5035
5518
  this.speedTopWeightedSliderFactor =
5036
5519
  cacheableAttributes.speedTopWeightedSliderFactor;
5037
5520
  }
@@ -5039,7 +5522,8 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5039
5522
  return (super.toString() +
5040
5523
  ` (${this.aimDifficulty.toFixed(2)} aim, ` +
5041
5524
  `${this.speedDifficulty.toFixed(2)} speed, ` +
5042
- `${this.flashlightDifficulty.toFixed(2)} flashlight)`);
5525
+ `${this.flashlightDifficulty.toFixed(2)} flashlight, ` +
5526
+ `${this.readingDifficulty.toFixed(2)} reading)`);
5043
5527
  }
5044
5528
  }
5045
5529
 
@@ -5049,7 +5533,6 @@ class OsuDifficultyAttributes extends DifficultyAttributes {
5049
5533
  class OsuDifficultyCalculator extends DifficultyCalculator {
5050
5534
  constructor() {
5051
5535
  super();
5052
- this.starRatingMultiplier = 0.0265;
5053
5536
  this.difficultyAdjustmentMods.push(osuBase.ModTouchDevice, osuBase.ModBlinds);
5054
5537
  }
5055
5538
  retainDifficultyAdjustmentMods(mods) {
@@ -5057,8 +5540,8 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5057
5540
  mod.isOsuRelevant &&
5058
5541
  this.difficultyAdjustmentMods.some((m) => mod instanceof m));
5059
5542
  }
5060
- createDifficultyAttributes(_beatmap, playableBeatmap, skills) {
5061
- 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;
5062
5545
  const attributes = new OsuDifficultyAttributes();
5063
5546
  if (playableBeatmap.hitObjects.objects.length === 0) {
5064
5547
  return attributes;
@@ -5069,27 +5552,26 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5069
5552
  attributes.hitCircleCount = playableBeatmap.hitObjects.circles;
5070
5553
  attributes.sliderCount = playableBeatmap.hitObjects.sliders;
5071
5554
  attributes.spinnerCount = playableBeatmap.hitObjects.spinners;
5555
+ attributes.approachRate = playableBeatmap.difficulty.ar;
5556
+ attributes.overallDifficulty = playableBeatmap.difficulty.od;
5072
5557
  attributes.drainRate = playableBeatmap.difficulty.hp;
5073
- attributes.approachRate =
5074
- OsuDifficultyCalculator.calculateRateAdjustedApproachRate(playableBeatmap.difficulty.ar, attributes.clockRate);
5075
- attributes.overallDifficulty =
5076
- OsuDifficultyCalculator.calculateRateAdjustedOverallDifficulty(playableBeatmap.difficulty.od, attributes.clockRate);
5077
5558
  const aim = skills.find((s) => s instanceof OsuAim && s.withSliders);
5078
5559
  const aimNoSlider = skills.find((s) => s instanceof OsuAim && !s.withSliders);
5079
5560
  const speed = skills.find((s) => s instanceof OsuSpeed);
5080
5561
  const flashlight = skills.find((s) => s instanceof OsuFlashlight);
5562
+ const reading = skills.find((s) => s instanceof OsuReading);
5081
5563
  // Aim attributes
5082
5564
  const aimDifficultyValue = (_a = aim === null || aim === void 0 ? void 0 : aim.difficultyValue()) !== null && _a !== void 0 ? _a : 0;
5083
- 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;
5084
5567
  attributes.aimDifficultStrainCount =
5085
- (_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;
5086
5569
  attributes.sliderFactor =
5087
5570
  aimDifficultyValue > 0
5088
- ? OsuRatingCalculator.calculateDifficultyRating((_d = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.difficultyValue()) !== null && _d !== void 0 ? _d : 0) /
5089
- OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue)
5571
+ ? this.calculateAimDifficultyRating(aimNoSliderDifficultyValue) / this.calculateAimDifficultyRating(aimDifficultyValue)
5090
5572
  : 1;
5091
- const aimNoSliderTopWeightedSliderCount = (_e = aimNoSlider === null || aimNoSlider === void 0 ? void 0 : aimNoSlider.countTopWeightedSliders()) !== null && _e !== void 0 ? _e : 0;
5092
- 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;
5093
5575
  attributes.aimTopWeightedSliderFactor =
5094
5576
  aimNoSliderTopWeightedSliderCount /
5095
5577
  Math.max(1, aimNoSliderDifficultStrainCount -
@@ -5098,27 +5580,29 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5098
5580
  const speedDifficultyValue = (_g = speed === null || speed === void 0 ? void 0 : speed.difficultyValue()) !== null && _g !== void 0 ? _g : 0;
5099
5581
  attributes.speedNoteCount = (_h = speed === null || speed === void 0 ? void 0 : speed.relevantNoteCount()) !== null && _h !== void 0 ? _h : 0;
5100
5582
  attributes.speedDifficultStrainCount =
5101
- (_j = speed === null || speed === void 0 ? void 0 : speed.countTopWeightedStrains(speedDifficultyValue)) !== null && _j !== void 0 ? _j : 0;
5102
- 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;
5103
5585
  attributes.speedTopWeightedSliderFactor =
5104
5586
  speedTopWeightedSliderCount /
5105
5587
  Math.max(1, attributes.speedDifficultStrainCount -
5106
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;
5107
5593
  // Final rating
5108
- const mechanicalDifficultyRating = this.calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue);
5109
- const ratingCalculator = new OsuRatingCalculator(attributes.mods, playableBeatmap.hitObjects.objects.length, attributes.approachRate, attributes.overallDifficulty, mechanicalDifficultyRating, attributes.sliderFactor);
5110
5594
  attributes.aimDifficulty =
5111
- ratingCalculator.computeAimRating(aimDifficultyValue);
5595
+ this.calculateAimDifficultyRating(aimDifficultyValue);
5112
5596
  attributes.speedDifficulty =
5113
- ratingCalculator.computeSpeedRating(speedDifficultyValue);
5114
- attributes.flashlightDifficulty =
5115
- 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);
5116
5600
  const baseAimPerformance = OsuAim.difficultyToPerformance(attributes.aimDifficulty);
5117
5601
  const baseSpeedPerformance = OsuSpeed.difficultyToPerformance(attributes.speedDifficulty);
5118
5602
  const baseFlashlightPerformance = OsuFlashlight.difficultyToPerformance(attributes.flashlightDifficulty);
5119
- const basePerformance = Math.pow(Math.pow(baseAimPerformance, 1.1) +
5120
- Math.pow(baseSpeedPerformance, 1.1) +
5121
- 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);
5122
5606
  attributes.starRating = this.calculateStarRating(basePerformance);
5123
5607
  return attributes;
5124
5608
  }
@@ -5126,12 +5610,11 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5126
5610
  return beatmap.createOsuPlayableBeatmap(mods);
5127
5611
  }
5128
5612
  createDifficultyHitObjects(beatmap) {
5129
- var _a;
5130
5613
  const clockRate = beatmap.speedMultiplier;
5131
5614
  const difficultyObjects = [];
5132
5615
  const { objects } = beatmap.hitObjects;
5133
5616
  for (let i = 1; i < objects.length; ++i) {
5134
- 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);
5135
5618
  difficultyObject.computeProperties(clockRate);
5136
5619
  difficultyObjects.push(difficultyObject);
5137
5620
  }
@@ -5147,8 +5630,9 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5147
5630
  if (!mods.has(osuBase.ModRelax)) {
5148
5631
  skills.push(new OsuSpeed(mods));
5149
5632
  }
5633
+ skills.push(new OsuReading(mods, beatmap.speedMultiplier, beatmap.hitObjects.objects));
5150
5634
  if (mods.has(osuBase.ModFlashlight)) {
5151
- skills.push(new OsuFlashlight(mods));
5635
+ skills.push(new OsuFlashlight(mods, beatmap.hitObjects.objects.length));
5152
5636
  }
5153
5637
  return skills;
5154
5638
  }
@@ -5158,30 +5642,26 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
5158
5642
  new OsuAim(mods, true),
5159
5643
  new OsuAim(mods, false),
5160
5644
  new OsuSpeed(mods),
5161
- new OsuFlashlight(mods),
5645
+ new OsuFlashlight(mods, beatmap.hitObjects.objects.length),
5162
5646
  ];
5163
5647
  }
5164
- calculateMechanicalDifficultyRating(aimDifficultyValue, speedDifficultyValue) {
5165
- const aimValue = OsuSkill.difficultyToPerformance(OsuRatingCalculator.calculateDifficultyRating(aimDifficultyValue));
5166
- const speedValue = OsuSkill.difficultyToPerformance(OsuRatingCalculator.calculateDifficultyRating(speedDifficultyValue));
5167
- const totalValue = Math.pow(Math.pow(aimValue, 1.1) + Math.pow(speedValue, 1.1), 1 / 1.1);
5168
- 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;
5169
5653
  }
5170
5654
  calculateStarRating(basePerformance) {
5171
- if (basePerformance <= 1e-5) {
5172
- return 0;
5173
- }
5174
- return (Math.cbrt(OsuPerformanceCalculator.finalMultiplier) *
5175
- this.starRatingMultiplier *
5176
- (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformance) + 4));
5177
- }
5178
- static calculateRateAdjustedApproachRate(approachRate, clockRate) {
5179
- const preempt = osuBase.BeatmapDifficulty.difficultyRange(approachRate, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
5180
- return osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
5655
+ return Math.cbrt(basePerformance * OsuPerformanceCalculator.finalMultiplier);
5181
5656
  }
5182
- static calculateRateAdjustedOverallDifficulty(overallDifficulty, clockRate) {
5183
- const greatWindow = new osuBase.OsuHitWindow(overallDifficulty).greatWindow / clockRate;
5184
- 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));
5185
5665
  }
5186
5666
  }
5187
5667
 
@@ -5205,15 +5685,19 @@ exports.DroidSnapAimEvaluator = DroidSnapAimEvaluator;
5205
5685
  exports.DroidTap = DroidTap;
5206
5686
  exports.DroidTapEvaluator = DroidTapEvaluator;
5207
5687
  exports.ExtendedDroidDifficultyAttributes = ExtendedDroidDifficultyAttributes;
5688
+ exports.OsuAgilityEvaluator = OsuAgilityEvaluator;
5208
5689
  exports.OsuAim = OsuAim;
5209
- exports.OsuAimEvaluator = OsuAimEvaluator;
5210
5690
  exports.OsuDifficultyAttributes = OsuDifficultyAttributes;
5211
5691
  exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
5212
5692
  exports.OsuDifficultyHitObject = OsuDifficultyHitObject;
5213
5693
  exports.OsuFlashlight = OsuFlashlight;
5214
5694
  exports.OsuFlashlightEvaluator = OsuFlashlightEvaluator;
5695
+ exports.OsuFlowAimEvaluator = OsuFlowAimEvaluator;
5215
5696
  exports.OsuPerformanceCalculator = OsuPerformanceCalculator;
5697
+ exports.OsuReading = OsuReading;
5698
+ exports.OsuReadingEvaluator = OsuReadingEvaluator;
5216
5699
  exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
5700
+ exports.OsuSnapAimEvaluator = OsuSnapAimEvaluator;
5217
5701
  exports.OsuSpeed = OsuSpeed;
5218
5702
  exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
5219
5703
  exports.PerformanceCalculator = PerformanceCalculator;