@rian8337/osu-difficulty-calculator 4.0.0-beta.24 → 4.0.0-beta.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -32,6 +32,12 @@ AimEvaluator.velocityChangeMultiplier = 0.75;
32
32
  * The base of a difficulty calculator.
33
33
  */
34
34
  class DifficultyCalculator {
35
+ /**
36
+ * The difficulty objects of the beatmap.
37
+ */
38
+ get objects() {
39
+ return this._objects;
40
+ }
35
41
  /**
36
42
  * The total star rating of the beatmap.
37
43
  */
@@ -41,14 +47,13 @@ class DifficultyCalculator {
41
47
  /**
42
48
  * Constructs a new instance of the calculator.
43
49
  *
44
- * @param beatmap The beatmap to calculate. This beatmap will be deep-cloned to prevent reference changes.
50
+ * @param beatmap The beatmap to calculate.
45
51
  */
46
52
  constructor(beatmap) {
47
- var _a;
48
53
  /**
49
54
  * The difficulty objects of the beatmap.
50
55
  */
51
- this.objects = [];
56
+ this._objects = [];
52
57
  /**
53
58
  * The modifications applied.
54
59
  */
@@ -63,13 +68,6 @@ class DifficultyCalculator {
63
68
  flashlight: [],
64
69
  };
65
70
  this.beatmap = beatmap;
66
- this.difficultyStatistics = {
67
- circleSize: beatmap.difficulty.cs,
68
- approachRate: (_a = beatmap.difficulty.ar) !== null && _a !== void 0 ? _a : beatmap.difficulty.od,
69
- overallDifficulty: beatmap.difficulty.od,
70
- healthDrain: beatmap.difficulty.hp,
71
- overallSpeedMultiplier: 1,
72
- };
73
71
  }
74
72
  /**
75
73
  * Calculates the star rating of the specified beatmap.
@@ -87,16 +85,17 @@ class DifficultyCalculator {
87
85
  * @returns The current instance.
88
86
  */
89
87
  calculate(options) {
90
- var _a;
88
+ var _a, _b;
91
89
  this.mods = (_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : [];
92
- const converted = new osuBase.BeatmapConverter(this.beatmap).convert({
90
+ const playableBeatmap = this.beatmap.createPlayableBeatmap({
93
91
  mode: this.mode,
94
92
  mods: this.mods,
95
93
  customSpeedMultiplier: options === null || options === void 0 ? void 0 : options.customSpeedMultiplier,
96
94
  });
97
- this.difficultyStatistics = Object.seal(this.computeDifficultyStatistics(options));
98
- this.populateDifficultyAttributes();
99
- this.objects.push(...this.generateDifficultyHitObjects(converted));
95
+ const clockRate = osuBase.ModUtil.calculateRateWithMods(this.mods) *
96
+ ((_b = options === null || options === void 0 ? void 0 : options.customSpeedMultiplier) !== null && _b !== void 0 ? _b : 1);
97
+ this.populateDifficultyAttributes(playableBeatmap, clockRate);
98
+ this._objects = this.generateDifficultyHitObjects(playableBeatmap, clockRate);
100
99
  this.calculateAll();
101
100
  return this;
102
101
  }
@@ -115,18 +114,27 @@ class DifficultyCalculator {
115
114
  }
116
115
  /**
117
116
  * Populates the stored difficulty attributes with necessary data.
117
+ *
118
+ * @param beatmap The beatmap to populate the attributes with.
119
+ * @param clockRate The clock rate of the beatmap.
118
120
  */
119
- populateDifficultyAttributes() {
120
- this.attributes.approachRate = this.difficultyStatistics.approachRate;
121
+ populateDifficultyAttributes(beatmap, clockRate) {
121
122
  this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
122
123
  this.attributes.maxCombo = this.beatmap.maxCombo;
123
124
  this.attributes.mods = this.mods.slice();
124
- this.attributes.overallDifficulty =
125
- this.difficultyStatistics.overallDifficulty;
126
125
  this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
127
126
  this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
128
- this.attributes.clockRate =
129
- this.difficultyStatistics.overallSpeedMultiplier;
127
+ this.attributes.clockRate = clockRate;
128
+ let greatWindow;
129
+ switch (this.mode) {
130
+ case osuBase.Modes.droid:
131
+ greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).hitWindowFor300(this.mods.some((m) => m instanceof osuBase.ModPrecise));
132
+ break;
133
+ case osuBase.Modes.osu:
134
+ greatWindow = new osuBase.OsuHitWindow(beatmap.difficulty.od).hitWindowFor300();
135
+ break;
136
+ }
137
+ this.attributes.overallDifficulty = osuBase.OsuHitWindow.hitWindow300ToOD(greatWindow / clockRate);
130
138
  }
131
139
  /**
132
140
  * Calculates the star rating value of a difficulty.
@@ -159,8 +167,9 @@ class DifficultyHitObject {
159
167
  * @param lastLastObject The hitobject before the last hitobject.
160
168
  * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
161
169
  * @param clockRate The clock rate of the beatmap.
170
+ * @param greatWindow The great window of the hitobject.
162
171
  */
163
- constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
172
+ constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow) {
164
173
  /**
165
174
  * The aim strain generated by the hitobject if sliders are considered.
166
175
  */
@@ -213,18 +222,18 @@ class DifficultyHitObject {
213
222
  this.normalizedRadius = 50;
214
223
  this.maximumSliderRadius = this.normalizedRadius * 2.4;
215
224
  this.assumedSliderRadius = this.normalizedRadius * 1.8;
216
- this.minDeltaTime = 25;
217
225
  this.object = object;
218
226
  this.lastObject = lastObject;
219
227
  this.lastLastObject = lastLastObject;
220
228
  this.hitObjects = difficultyHitObjects;
229
+ this.fullGreatWindow = greatWindow * 2;
221
230
  this.index = difficultyHitObjects.length - 1;
222
231
  // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
223
232
  this.startTime = object.startTime / clockRate;
224
233
  this.endTime = object.endTime / clockRate;
225
234
  if (lastObject) {
226
235
  this.deltaTime = this.startTime - lastObject.startTime / clockRate;
227
- this.strainTime = Math.max(this.deltaTime, this.minDeltaTime);
236
+ this.strainTime = Math.max(this.deltaTime, DifficultyHitObject.minDeltaTime);
228
237
  }
229
238
  else {
230
239
  this.deltaTime = 0;
@@ -295,6 +304,24 @@ class DifficultyHitObject {
295
304
  }
296
305
  return osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1);
297
306
  }
307
+ /**
308
+ * How possible is it to doubletap this object together with the next one and get perfect
309
+ * judgement in range from 0 to 1.
310
+ *
311
+ * A value closer to 1 indicates a higher possibility.
312
+ */
313
+ get doubletapness() {
314
+ const next = this.next(0);
315
+ if (!next) {
316
+ return 0;
317
+ }
318
+ const currentDeltaTime = Math.max(1, this.deltaTime);
319
+ const nextDeltaTime = Math.max(1, next.deltaTime);
320
+ const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
321
+ const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
322
+ const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.fullGreatWindow), 2);
323
+ return 1 - Math.pow(speedRatio, 1 - windowRatio);
324
+ }
298
325
  setDistances(clockRate) {
299
326
  if (this.object instanceof osuBase.Slider) {
300
327
  this.calculateSliderCursorPosition(this.object);
@@ -306,7 +333,7 @@ class DifficultyHitObject {
306
333
  else {
307
334
  this.travelDistance *= Math.pow(1 + this.object.repeatCount / 2.5, 1 / 2.5);
308
335
  }
309
- this.travelTime = Math.max(this.object.lazyTravelTime / clockRate, this.minDeltaTime);
336
+ this.travelTime = Math.max(this.object.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
310
337
  }
311
338
  // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner.
312
339
  if (!this.lastObject ||
@@ -324,8 +351,8 @@ class DifficultyHitObject {
324
351
  this.minimumJumpTime = this.strainTime;
325
352
  this.minimumJumpDistance = this.lazyJumpDistance;
326
353
  if (this.lastObject instanceof osuBase.Slider) {
327
- const lastTravelTime = Math.max(this.lastObject.lazyTravelTime / clockRate, this.minDeltaTime);
328
- this.minimumJumpTime = Math.max(this.strainTime - lastTravelTime, this.minDeltaTime);
354
+ const lastTravelTime = Math.max(this.lastObject.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
355
+ this.minimumJumpTime = Math.max(this.strainTime - lastTravelTime, DifficultyHitObject.minDeltaTime);
329
356
  // There are two types of slider-to-object patterns to consider in order to better approximate the real movement a player will take to jump between the hitobjects.
330
357
  //
331
358
  // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
@@ -440,6 +467,10 @@ class DifficultyHitObject {
440
467
  return pos;
441
468
  }
442
469
  }
470
+ /**
471
+ * The lowest possible delta time value.
472
+ */
473
+ DifficultyHitObject.minDeltaTime = 25;
443
474
 
444
475
  /**
445
476
  * An evaluator for calculating osu!droid Aim skill.
@@ -692,6 +723,7 @@ class DroidSkill extends StrainSkill {
692
723
  constructor() {
693
724
  super(...arguments);
694
725
  this._objectStrains = [];
726
+ this.difficulty = 0;
695
727
  }
696
728
  /**
697
729
  * The strains of hitobjects.
@@ -705,14 +737,14 @@ class DroidSkill extends StrainSkill {
705
737
  * The result is scaled by clock rate as it affects the total number of strains.
706
738
  */
707
739
  countDifficultStrains() {
708
- if (this._objectStrains.length === 0) {
740
+ if (this.difficulty === 0) {
709
741
  return 0;
710
742
  }
711
- const maxStrain = Math.max(...this._objectStrains);
712
- if (maxStrain === 0) {
713
- return 0;
714
- }
715
- return this._objectStrains.reduce((total, next) => total + Math.pow(next / maxStrain, 4), 0);
743
+ // This is what the top strain is if all strain values were identical.
744
+ const consistentTopStrain = this.difficulty / 10;
745
+ // Use a weighted sum of all strains.
746
+ return this._objectStrains.reduce((total, next) => total +
747
+ 1.1 / (1 + Math.exp(-10 * (next / consistentTopStrain - 0.88))), 0);
716
748
  }
717
749
  process(current) {
718
750
  super.process(current);
@@ -730,12 +762,12 @@ class DroidSkill extends StrainSkill {
730
762
  }
731
763
  // Math here preserves the property that two notes of equal difficulty x, we have their summed difficulty = x * starsPerDouble.
732
764
  // This also applies to two sets of notes with equal difficulty.
733
- return Math.pow(strains.reduce((a, v) => {
734
- if (v <= 0) {
735
- return a;
736
- }
737
- return a + Math.pow(v, 1 / Math.log2(this.starsPerDouble));
738
- }, 0), Math.log2(this.starsPerDouble));
765
+ this.difficulty = 0;
766
+ for (const strain of strains) {
767
+ this.difficulty += Math.pow(strain, 1 / Math.log2(this.starsPerDouble));
768
+ }
769
+ this.difficulty = Math.pow(this.difficulty, Math.log2(this.starsPerDouble));
770
+ return this.difficulty;
739
771
  }
740
772
  calculateCurrentSectionStart(current) {
741
773
  return current.startTime;
@@ -811,27 +843,16 @@ class DroidTapEvaluator extends SpeedEvaluator {
811
843
  * @param considerCheesability Whether to consider cheesability.
812
844
  * @param strainTimeCap The strain time to cap the object's strain time to.
813
845
  */
814
- static evaluateDifficultyOf(current, greatWindow, considerCheesability, strainTimeCap) {
846
+ static evaluateDifficultyOf(current, considerCheesability, strainTimeCap) {
815
847
  if (current.object instanceof osuBase.Spinner ||
816
848
  // Exclude overlapping objects that can be tapped at once.
817
849
  current.isOverlapping(false)) {
818
850
  return 0;
819
851
  }
820
- let doubletapness = 1;
821
- if (considerCheesability) {
822
- // Nerf doubletappable doubles.
823
- const next = current.next(0);
824
- if (next) {
825
- const greatWindowFull = greatWindow * 2;
826
- const currentDeltaTime = Math.max(1, current.deltaTime);
827
- const nextDeltaTime = Math.max(1, next.deltaTime);
828
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
829
- const speedRatio = currentDeltaTime /
830
- Math.max(currentDeltaTime, deltaDifference);
831
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
832
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
833
- }
834
- }
852
+ // Nerf doubletappable doubles.
853
+ const doubletapness = considerCheesability
854
+ ? 1 - current.doubletapness
855
+ : 1;
835
856
  const strainTime = strainTimeCap !== undefined
836
857
  ? // We cap the strain time to 50 here as the chance of vibro is higher in any BPM higher than 300.
837
858
  Math.max(50, strainTimeCap, current.strainTime)
@@ -856,7 +877,7 @@ class DroidTap extends DroidSkill {
856
877
  get objectDeltaTimes() {
857
878
  return this._objectDeltaTimes;
858
879
  }
859
- constructor(mods, overallDifficulty, considerCheesability, strainTimeCap) {
880
+ constructor(mods, considerCheesability, strainTimeCap) {
860
881
  super(mods);
861
882
  this.reducedSectionCount = 10;
862
883
  this.reducedSectionBaseline = 0.75;
@@ -866,7 +887,6 @@ class DroidTap extends DroidSkill {
866
887
  this.currentRhythmMultiplier = 0;
867
888
  this.skillMultiplier = 1375;
868
889
  this._objectDeltaTimes = [];
869
- this.greatWindow = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
870
890
  this.considerCheesability = considerCheesability;
871
891
  this.strainTimeCap = strainTimeCap;
872
892
  }
@@ -905,7 +925,7 @@ class DroidTap extends DroidSkill {
905
925
  strainValueAt(current) {
906
926
  this.currentTapStrain *= this.strainDecay(current.strainTime);
907
927
  this.currentTapStrain +=
908
- DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
928
+ DroidTapEvaluator.evaluateDifficultyOf(current, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
909
929
  this.currentRhythmMultiplier = current.rhythmMultiplier;
910
930
  this._objectDeltaTimes.push(current.deltaTime);
911
931
  return this.currentTapStrain * current.rhythmMultiplier;
@@ -1046,7 +1066,7 @@ class DroidFlashlight extends DroidSkill {
1046
1066
  this.reducedSectionCount = 0;
1047
1067
  this.reducedSectionBaseline = 1;
1048
1068
  this.starsPerDouble = 1.06;
1049
- this.skillMultiplier = 0.052;
1069
+ this.skillMultiplier = 0.02;
1050
1070
  this.currentFlashlightStrain = 0;
1051
1071
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1052
1072
  this.withSliders = withSliders;
@@ -1075,45 +1095,62 @@ class DroidFlashlight extends DroidSkill {
1075
1095
  }
1076
1096
  }
1077
1097
  difficultyValue() {
1078
- return Math.pow(this.strainPeaks.reduce((a, v) => a + v, 0) * this.starsPerDouble, 0.8);
1098
+ return (this.strainPeaks.reduce((a, v) => a + v, 0) * this.starsPerDouble);
1079
1099
  }
1080
1100
  }
1081
1101
 
1082
- /**
1083
- * An evaluator for calculating rhythm skill.
1084
- *
1085
- * This class should be considered an "evaluating" class and not persisted.
1086
- */
1087
- class RhythmEvaluator {
1102
+ class Island {
1103
+ constructor(delta, deltaDifferenceEpsilon) {
1104
+ this.delta = Number.MAX_SAFE_INTEGER;
1105
+ this.deltaCount = 0;
1106
+ if (deltaDifferenceEpsilon === undefined) {
1107
+ this.deltaDifferenceEpsilon = delta;
1108
+ }
1109
+ else {
1110
+ this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
1111
+ this.addDelta(delta);
1112
+ }
1113
+ }
1114
+ addDelta(delta) {
1115
+ if (this.delta === Number.MAX_SAFE_INTEGER) {
1116
+ this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
1117
+ }
1118
+ ++this.deltaCount;
1119
+ }
1120
+ isSimilarPolarity(other) {
1121
+ // 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)
1122
+ // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
1123
+ return this.deltaCount % 2 == other.deltaCount % 2;
1124
+ }
1125
+ equals(other) {
1126
+ return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
1127
+ this.deltaCount === other.deltaCount);
1128
+ }
1088
1129
  }
1089
- RhythmEvaluator.rhythmMultiplier = 0.75;
1090
- RhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1091
-
1092
1130
  /**
1093
1131
  * An evaluator for calculating osu!droid Rhythm skill.
1094
1132
  */
1095
- class DroidRhythmEvaluator extends RhythmEvaluator {
1133
+ class DroidRhythmEvaluator {
1096
1134
  /**
1097
1135
  * Calculates a rhythm multiplier for the difficulty of the tap associated
1098
1136
  * with historic data of the current object.
1099
1137
  *
1100
1138
  * @param current The current object.
1101
- * @param greatWindow The great hit window of the current object.
1102
1139
  */
1103
- static evaluateDifficultyOf(current, greatWindow) {
1104
- if (current.object instanceof osuBase.Spinner ||
1105
- // Exclude overlapping objects that can be tapped at once.
1106
- current.isOverlapping(false)) {
1140
+ static evaluateDifficultyOf(current) {
1141
+ if (current.object instanceof osuBase.Spinner) {
1107
1142
  return 1;
1108
1143
  }
1109
- let previousIslandSize = 0;
1144
+ const deltaDifferenceEpsilon = current.fullGreatWindow * 0.3;
1110
1145
  let rhythmComplexitySum = 0;
1111
- let islandSize = 1;
1146
+ let island = new Island(deltaDifferenceEpsilon);
1147
+ let previousIsland = new Island(deltaDifferenceEpsilon);
1148
+ const islandCounts = new Map();
1112
1149
  // Store the ratio of the current start of an island to buff for tighter rhythms.
1113
1150
  let startRatio = 0;
1114
1151
  let firstDeltaSwitch = false;
1115
1152
  let rhythmStart = 0;
1116
- const historicalNoteCount = Math.min(current.index, 32);
1153
+ const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
1117
1154
  // Exclude overlapping objects that can be tapped at once.
1118
1155
  const validPrevious = [];
1119
1156
  for (let i = 0; i < historicalNoteCount; ++i) {
@@ -1131,111 +1168,140 @@ class DroidRhythmEvaluator extends RhythmEvaluator {
1131
1168
  ++rhythmStart;
1132
1169
  }
1133
1170
  for (let i = rhythmStart; i > 0; --i) {
1171
+ const currentObject = validPrevious[i - 1];
1172
+ const prevObject = validPrevious[i];
1173
+ const lastObject = validPrevious[i + 1];
1134
1174
  // Scale note 0 to 1 from history to now.
1135
- let currentHistoricalDecay = (this.historyTimeMax -
1136
- (current.startTime - validPrevious[i - 1].startTime)) /
1175
+ const timeDecay = (this.historyTimeMax -
1176
+ (current.startTime - currentObject.startTime)) /
1137
1177
  this.historyTimeMax;
1178
+ const noteDecay = (validPrevious.length - i) / validPrevious.length;
1138
1179
  // Either we're limited by time or limited by object count.
1139
- currentHistoricalDecay = Math.min(currentHistoricalDecay, (validPrevious.length - i) / validPrevious.length);
1140
- const currentDelta = validPrevious[i - 1].strainTime;
1141
- const prevDelta = validPrevious[i].strainTime;
1142
- const lastDelta = validPrevious[i + 1].strainTime;
1180
+ const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
1181
+ const currentDelta = currentObject.strainTime;
1182
+ const prevDelta = prevObject.strainTime;
1183
+ const lastDelta = lastObject.strainTime;
1184
+ // Calculate how much current delta difference deserves a rhythm bonus
1185
+ // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
1186
+ const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
1187
+ Math.max(prevDelta, currentDelta);
1143
1188
  const currentRatio = 1 +
1144
- 6 *
1145
- Math.min(0.5, Math.pow(Math.sin(Math.PI /
1146
- (Math.min(prevDelta, currentDelta) /
1147
- Math.max(prevDelta, currentDelta))), 2));
1148
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.6) /
1149
- (greatWindow * 0.6));
1150
- let effectiveRatio = windowPenalty * currentRatio;
1189
+ this.rhythmRatioMultiplier *
1190
+ Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
1191
+ // Reduce ratio bonus if delta difference is too big
1192
+ const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
1193
+ const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
1194
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
1195
+ let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
1151
1196
  if (firstDeltaSwitch) {
1152
- if (prevDelta <= 1.25 * currentDelta &&
1153
- prevDelta * 1.25 >= currentDelta) {
1197
+ if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
1154
1198
  // Island is still progressing, count size.
1155
- if (islandSize < 7) {
1156
- ++islandSize;
1157
- }
1199
+ island.addDelta(currentDelta);
1158
1200
  }
1159
1201
  else {
1160
- if (validPrevious[i - 1].object instanceof osuBase.Slider) {
1161
- // BPM change is into slider, this is easy acc window.
1202
+ // BPM change is into slider, this is easy acc window.
1203
+ if (currentObject.object instanceof osuBase.Slider) {
1162
1204
  effectiveRatio /= 8;
1163
1205
  }
1164
- if (validPrevious[i].object instanceof osuBase.Slider) {
1165
- // BPM change was from a slider, this is typically easier than circle -> circle.
1166
- effectiveRatio /= 4;
1167
- }
1168
- if (previousIslandSize === islandSize) {
1169
- // Repeated island size (ex: triplet -> triplet).
1170
- effectiveRatio /= 4;
1206
+ // BPM change was from a slider, this is easier typically than circle -> circle.
1207
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
1208
+ // than bursts without sliders.
1209
+ if (prevObject.object instanceof osuBase.Slider) {
1210
+ effectiveRatio *= 0.3;
1171
1211
  }
1172
- if (previousIslandSize % 2 === islandSize % 2) {
1173
- // Repeated island polarity (2 -> 4, 3 -> 5).
1212
+ // Repeated island polarity (2 -> 4, 3 -> 5).
1213
+ if (island.isSimilarPolarity(previousIsland)) {
1174
1214
  effectiveRatio /= 2;
1175
1215
  }
1176
- if (lastDelta > prevDelta + 10 &&
1177
- prevDelta > currentDelta + 10) {
1178
- // Previous increase happened a note ago.
1179
- // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
1216
+ // Previous increase happened a note ago.
1217
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
1218
+ if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
1219
+ prevDelta > currentDelta + deltaDifferenceEpsilon) {
1180
1220
  effectiveRatio /= 8;
1181
1221
  }
1222
+ // Repeated island size (ex: triplet -> triplet).
1223
+ // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
1224
+ if (previousIsland.deltaCount == island.deltaCount) {
1225
+ effectiveRatio /= 2;
1226
+ }
1227
+ let islandFound = false;
1228
+ for (const [currentIsland, count] of islandCounts) {
1229
+ if (!island.equals(currentIsland)) {
1230
+ continue;
1231
+ }
1232
+ islandFound = true;
1233
+ let islandCount = count;
1234
+ if (previousIsland.equals(island)) {
1235
+ // Only add island to island counts if they're going one after another.
1236
+ ++islandCount;
1237
+ islandCounts.set(currentIsland, islandCount);
1238
+ }
1239
+ // Repeated island (ex: triplet -> triplet).
1240
+ // Graph: https://www.desmos.com/calculator/pj7an56zwf
1241
+ effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
1242
+ break;
1243
+ }
1244
+ if (!islandFound) {
1245
+ islandCounts.set(island, 1);
1246
+ }
1247
+ // Scale down the difficulty if the object is doubletappable.
1248
+ effectiveRatio *= 1 - prevObject.doubletapness * 0.75;
1182
1249
  rhythmComplexitySum +=
1183
- (((Math.sqrt(effectiveRatio * startRatio) *
1184
- currentHistoricalDecay *
1185
- Math.sqrt(4 + islandSize)) /
1186
- 2) *
1187
- Math.sqrt(4 + previousIslandSize)) /
1188
- 2;
1250
+ Math.sqrt(effectiveRatio * startRatio) *
1251
+ currentHistoricalDecay;
1189
1252
  startRatio = effectiveRatio;
1190
- previousIslandSize = islandSize;
1191
- if (prevDelta * 1.25 < currentDelta) {
1253
+ previousIsland = island;
1254
+ if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
1192
1255
  // We're slowing down, stop counting.
1193
1256
  // If we're speeding up, this stays as is and we keep counting island size.
1194
1257
  firstDeltaSwitch = false;
1195
1258
  }
1196
- islandSize = 1;
1259
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
1197
1260
  }
1198
1261
  }
1199
- else if (prevDelta > 1.25 * currentDelta) {
1200
- // We want to be speeding up.
1262
+ else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
1263
+ // We are speeding up.
1201
1264
  // Begin counting island until we change speed again.
1202
1265
  firstDeltaSwitch = true;
1266
+ // BPM change is into slider, this is easy acc window.
1267
+ if (currentObject.object instanceof osuBase.Slider) {
1268
+ effectiveRatio *= 0.6;
1269
+ }
1270
+ // BPM change was from a slider, this is easier typically than circle -> circle
1271
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
1272
+ // than bursts without sliders
1273
+ if (prevObject.object instanceof osuBase.Slider) {
1274
+ effectiveRatio *= 0.6;
1275
+ }
1203
1276
  startRatio = effectiveRatio;
1204
- islandSize = 1;
1277
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
1205
1278
  }
1206
1279
  }
1207
- // Nerf doubles that can be tapped at the same time to get Great hit results.
1208
- const next = current.next(0);
1209
- let doubletapness = 1;
1210
- if (next) {
1211
- const currentDeltaTime = Math.max(1, current.deltaTime);
1212
- const nextDeltaTime = Math.max(1, next.deltaTime);
1213
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
1214
- const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
1215
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / (greatWindow * 2)), 2);
1216
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
1217
- }
1218
- return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier * doubletapness) / 2);
1280
+ return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
1281
+ 2);
1219
1282
  }
1220
1283
  }
1284
+ DroidRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1285
+ DroidRhythmEvaluator.historyObjectsMax = 32;
1286
+ DroidRhythmEvaluator.rhythmOverallMultiplier = 0.95;
1287
+ DroidRhythmEvaluator.rhythmRatioMultiplier = 12;
1221
1288
 
1222
1289
  /**
1223
1290
  * Represents the skill required to properly follow a beatmap's rhythm.
1224
1291
  */
1225
1292
  class DroidRhythm extends DroidSkill {
1226
- constructor(mods, overallDifficulty) {
1227
- super(mods);
1293
+ constructor() {
1294
+ super(...arguments);
1228
1295
  this.reducedSectionCount = 5;
1229
1296
  this.reducedSectionBaseline = 0.75;
1230
1297
  this.strainDecayBase = 0.3;
1231
1298
  this.starsPerDouble = 1.75;
1232
1299
  this.currentRhythmStrain = 0;
1233
1300
  this.currentRhythmMultiplier = 1;
1234
- this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
1235
1301
  }
1236
1302
  strainValueAt(current) {
1237
1303
  this.currentRhythmMultiplier =
1238
- DroidRhythmEvaluator.evaluateDifficultyOf(current, this.hitWindow.hitWindowFor300());
1304
+ DroidRhythmEvaluator.evaluateDifficultyOf(current);
1239
1305
  this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
1240
1306
  this.currentRhythmStrain += this.currentRhythmMultiplier - 1;
1241
1307
  return this.currentRhythmStrain;
@@ -1318,11 +1384,12 @@ class DroidVisualEvaluator {
1318
1384
  // Invert the scaling factor to determine the true travel distance independent of circle size.
1319
1385
  const pixelTravelDistance = current.object.lazyTravelDistance / scalingFactor;
1320
1386
  const currentVelocity = pixelTravelDistance / current.travelTime;
1387
+ const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
1321
1388
  strain +=
1322
1389
  // Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
1323
1390
  Math.min(6, currentVelocity * 1.5) *
1324
1391
  // Longer sliders require more reading.
1325
- (pixelTravelDistance / 100);
1392
+ (spanTravelDistance / 100);
1326
1393
  let cumulativeStrainTime = 0;
1327
1394
  // Reward for velocity changes based on last few sliders.
1328
1395
  for (let i = 0; i < Math.min(current.index, 4); ++i) {
@@ -1334,14 +1401,15 @@ class DroidVisualEvaluator {
1334
1401
  continue;
1335
1402
  }
1336
1403
  // Invert the scaling factor to determine the true travel distance independent of circle size.
1337
- const pixelTravelDistance = last.object.lazyTravelDistance / scalingFactor;
1338
- const lastVelocity = pixelTravelDistance / last.travelTime;
1404
+ const lastPixelTravelDistance = last.object.lazyTravelDistance / scalingFactor;
1405
+ const lastVelocity = lastPixelTravelDistance / last.travelTime;
1406
+ const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
1339
1407
  strain +=
1340
1408
  // Reward past sliders based on velocity changes, while also
1341
1409
  // avoiding overbuffing extremely fast velocity changes.
1342
1410
  Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
1343
1411
  // Longer sliders require more reading.
1344
- (pixelTravelDistance / 125) *
1412
+ (lastSpanTravelDistance / 125) *
1345
1413
  // Avoid overbuffing past sliders.
1346
1414
  Math.min(1, 300 / cumulativeStrainTime);
1347
1415
  }
@@ -1417,10 +1485,10 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
1417
1485
  * @param lastLastObject The hitobject before the last hitobject.
1418
1486
  * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
1419
1487
  * @param clockRate The clock rate of the beatmap.
1420
- * @param isForceAR Whether force AR is enabled.
1488
+ * @param greatWindow The great window of the hitobject.
1421
1489
  */
1422
- constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, isForceAR) {
1423
- super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
1490
+ constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow) {
1491
+ super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow);
1424
1492
  /**
1425
1493
  * The tap strain generated by the hitobject.
1426
1494
  */
@@ -1463,10 +1531,7 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
1463
1531
  this.radiusBuffThreshold = 70;
1464
1532
  this.mode = osuBase.Modes.droid;
1465
1533
  this.maximumSliderRadius = this.normalizedRadius * 2;
1466
- this.timePreempt = object.timePreempt;
1467
- if (!isForceAR) {
1468
- this.timePreempt /= clockRate;
1469
- }
1534
+ this.timePreempt = object.timePreempt / clockRate;
1470
1535
  }
1471
1536
  computeProperties(clockRate, hitObjects) {
1472
1537
  super.computeProperties(clockRate, hitObjects);
@@ -1478,6 +1543,9 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
1478
1543
  * Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
1479
1544
  * can be hit with just a single tap in osu!droid.
1480
1545
  *
1546
+ * In the case of sliders, it is considered overlapping if all nested hitobjects can be hit with
1547
+ * one aim motion.
1548
+ *
1481
1549
  * @param considerDistance Whether to consider the distance between both hitobjects.
1482
1550
  * @returns Whether the hitobject is considered overlapping.
1483
1551
  */
@@ -1485,23 +1553,48 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
1485
1553
  if (this.object instanceof osuBase.Spinner) {
1486
1554
  return false;
1487
1555
  }
1488
- const previous = this.previous(0);
1489
- if (!previous || previous.object instanceof osuBase.Spinner) {
1556
+ const prev = this.previous(0);
1557
+ if (!prev || prev.object instanceof osuBase.Spinner) {
1490
1558
  return false;
1491
1559
  }
1492
- if (this.deltaTime >= 5) {
1560
+ if (this.object.startTime !== prev.object.startTime) {
1493
1561
  return false;
1494
1562
  }
1495
- if (considerDistance) {
1496
- const endPosition = this.object.getStackedPosition(osuBase.Modes.droid);
1497
- let distance = previous.object
1498
- .getStackedEndPosition(osuBase.Modes.droid)
1499
- .getDistance(endPosition);
1500
- if (previous.object instanceof osuBase.Slider &&
1501
- previous.object.lazyEndPosition) {
1502
- distance = Math.min(distance, previous.object.lazyEndPosition.getDistance(endPosition));
1563
+ if (!considerDistance) {
1564
+ return true;
1565
+ }
1566
+ const distanceThreshold = 2 * this.object.radius;
1567
+ const startPosition = this.object.getStackedPosition(osuBase.Modes.droid);
1568
+ const prevStartPosition = prev.object.getStackedPosition(osuBase.Modes.droid);
1569
+ // We need to consider two cases:
1570
+ //
1571
+ // Case 1: Current object is a circle, or previous object is a circle.
1572
+ // In this case, we only need to check if their positions are close enough to be tapped together.
1573
+ //
1574
+ // Case 2: Both objects are sliders.
1575
+ // In this case, we need to check if all nested hitobjects can be hit together.
1576
+ // To start with, check if the starting positions can be tapped together.
1577
+ if (startPosition.getDistance(prevStartPosition) > distanceThreshold) {
1578
+ return false;
1579
+ }
1580
+ if (this.object instanceof osuBase.Circle || prev.object instanceof osuBase.Circle) {
1581
+ return true;
1582
+ }
1583
+ // Check if all nested hitobjects can be hit together.
1584
+ for (let i = 1; i < this.object.nestedHitObjects.length; ++i) {
1585
+ const position = this.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1586
+ const prevPosition = prevStartPosition.add(prev.object.curvePositionAt(i / (this.object.nestedHitObjects.length - 1)));
1587
+ if (position.getDistance(prevPosition) > distanceThreshold) {
1588
+ return false;
1589
+ }
1590
+ }
1591
+ // Do the same for the previous slider as well.
1592
+ for (let i = 1; i < prev.object.nestedHitObjects.length; ++i) {
1593
+ const prevPosition = prev.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1594
+ const position = startPosition.add(this.object.curvePositionAt(i / (prev.object.nestedHitObjects.length - 1)));
1595
+ if (prevPosition.getDistance(position) > distanceThreshold) {
1596
+ return false;
1503
1597
  }
1504
- return distance <= 2 * this.object.radius;
1505
1598
  }
1506
1599
  return true;
1507
1600
  }
@@ -1559,7 +1652,9 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
1559
1652
  Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
1560
1653
  (7.5 /
1561
1654
  (1 +
1562
- Math.exp(0.15 * (Math.max(deltaTime, this.minDeltaTime) - 75))));
1655
+ Math.exp(0.15 *
1656
+ (Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
1657
+ 75))));
1563
1658
  }
1564
1659
  }
1565
1660
 
@@ -1583,7 +1678,6 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1583
1678
  speedNoteCount: 0,
1584
1679
  sliderFactor: 0,
1585
1680
  clockRate: 1,
1586
- approachRate: 0,
1587
1681
  overallDifficulty: 0,
1588
1682
  hitCircleCount: 0,
1589
1683
  sliderCount: 0,
@@ -1633,30 +1727,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1633
1727
  return this.attributes.visualDifficulty;
1634
1728
  }
1635
1729
  get cacheableAttributes() {
1636
- return {
1637
- tapDifficulty: this.tap,
1638
- rhythmDifficulty: this.rhythm,
1639
- visualDifficulty: this.visual,
1640
- mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods),
1641
- starRating: this.total,
1642
- maxCombo: this.attributes.maxCombo,
1643
- aimDifficulty: this.aim,
1644
- flashlightDifficulty: this.flashlight,
1645
- speedNoteCount: this.attributes.speedNoteCount,
1646
- sliderFactor: this.attributes.sliderFactor,
1647
- clockRate: this.attributes.clockRate,
1648
- approachRate: this.attributes.approachRate,
1649
- overallDifficulty: this.attributes.overallDifficulty,
1650
- hitCircleCount: this.attributes.hitCircleCount,
1651
- sliderCount: this.attributes.sliderCount,
1652
- spinnerCount: this.attributes.spinnerCount,
1653
- aimDifficultStrainCount: this.attributes.aimDifficultStrainCount,
1654
- tapDifficultStrainCount: this.attributes.tapDifficultStrainCount,
1655
- flashlightDifficultStrainCount: this.attributes.flashlightDifficultStrainCount,
1656
- visualDifficultStrainCount: this.attributes.visualDifficultStrainCount,
1657
- averageSpeedDeltaTime: this.attributes.averageSpeedDeltaTime,
1658
- vibroFactor: this.attributes.vibroFactor,
1659
- };
1730
+ return Object.assign(Object.assign({}, this.attributes), { mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods) });
1660
1731
  }
1661
1732
  /**
1662
1733
  * Calculates the aim star rating of the beatmap and stores it in this instance.
@@ -1671,11 +1742,10 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1671
1742
  * Calculates the tap star rating of the beatmap and stores it in this instance.
1672
1743
  */
1673
1744
  calculateTap() {
1674
- const od = this.difficultyStatistics.overallDifficulty;
1675
- const tapSkillCheese = new DroidTap(this.mods, od, true);
1676
- const tapSkillNoCheese = new DroidTap(this.mods, od, false);
1745
+ const tapSkillCheese = new DroidTap(this.mods, true);
1746
+ const tapSkillNoCheese = new DroidTap(this.mods, false);
1677
1747
  this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
1678
- const tapSkillVibro = new DroidTap(this.mods, od, true, tapSkillCheese.relevantDeltaTime());
1748
+ const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1679
1749
  this.calculateSkills(tapSkillVibro);
1680
1750
  this.postCalculateTap(tapSkillCheese, tapSkillVibro);
1681
1751
  }
@@ -1683,7 +1753,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1683
1753
  * Calculates the rhythm star rating of the beatmap and stores it in this instance.
1684
1754
  */
1685
1755
  calculateRhythm() {
1686
- const rhythmSkill = new DroidRhythm(this.mods, this.difficultyStatistics.overallDifficulty);
1756
+ const rhythmSkill = new DroidRhythm(this.mods);
1687
1757
  this.calculateSkills(rhythmSkill);
1688
1758
  this.postCalculateRhythm(rhythmSkill);
1689
1759
  }
@@ -1743,7 +1813,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1743
1813
  const flashlightSkillWithoutSliders = skills[6];
1744
1814
  const visualSkill = skills[7];
1745
1815
  const visualSkillWithoutSliders = skills[8];
1746
- const tapSkillVibro = new DroidTap(this.mods, this.difficultyStatistics.overallDifficulty, true, tapSkillCheese.relevantDeltaTime());
1816
+ const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1747
1817
  this.calculateSkills(tapSkillVibro);
1748
1818
  this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1749
1819
  this.postCalculateTap(tapSkillCheese, tapSkillVibro);
@@ -1766,42 +1836,29 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1766
1836
  this.visual.toFixed(2) +
1767
1837
  " visual)");
1768
1838
  }
1769
- generateDifficultyHitObjects(convertedBeatmap) {
1839
+ generateDifficultyHitObjects(beatmap, clockRate) {
1770
1840
  var _a, _b;
1771
1841
  const difficultyObjects = [];
1772
- const { objects } = convertedBeatmap.hitObjects;
1773
- const difficultyAdjustMod = this.mods.find((m) => m instanceof osuBase.ModDifficultyAdjust);
1842
+ const { objects } = beatmap.hitObjects;
1843
+ const isPrecise = this.mods.some((m) => m instanceof osuBase.ModPrecise);
1844
+ const greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).hitWindowFor300(isPrecise) / clockRate;
1774
1845
  for (let i = 0; i < objects.length; ++i) {
1775
- const difficultyObject = new DroidDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, this.difficultyStatistics.overallSpeedMultiplier, (difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.ar) !== undefined);
1776
- difficultyObject.computeProperties(this.difficultyStatistics.overallSpeedMultiplier, objects);
1846
+ const difficultyObject = new DroidDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, clockRate, greatWindow);
1847
+ difficultyObject.computeProperties(clockRate, objects);
1777
1848
  difficultyObjects.push(difficultyObject);
1778
1849
  }
1779
1850
  return difficultyObjects;
1780
1851
  }
1781
- computeDifficultyStatistics(options) {
1782
- var _a;
1783
- const { difficulty } = this.beatmap;
1784
- return osuBase.calculateDroidDifficultyStatistics({
1785
- circleSize: difficulty.cs,
1786
- approachRate: (_a = difficulty.ar) !== null && _a !== void 0 ? _a : difficulty.od,
1787
- overallDifficulty: difficulty.od,
1788
- healthDrain: difficulty.hp,
1789
- mods: this.mods,
1790
- customSpeedMultiplier: options === null || options === void 0 ? void 0 : options.customSpeedMultiplier,
1791
- oldStatistics: options === null || options === void 0 ? void 0 : options.oldStatistics,
1792
- });
1793
- }
1794
1852
  createSkills() {
1795
- const od = this.difficultyStatistics.overallDifficulty;
1796
1853
  return [
1797
1854
  new DroidAim(this.mods, true),
1798
1855
  new DroidAim(this.mods, false),
1799
1856
  // Tap skill depends on rhythm skill, so we put it first
1800
- new DroidRhythm(this.mods, od),
1857
+ new DroidRhythm(this.mods),
1801
1858
  // Cheesability tap
1802
- new DroidTap(this.mods, od, true),
1859
+ new DroidTap(this.mods, true),
1803
1860
  // Non-cheesability tap
1804
- new DroidTap(this.mods, od, false),
1861
+ new DroidTap(this.mods, false),
1805
1862
  new DroidFlashlight(this.mods, true),
1806
1863
  new DroidFlashlight(this.mods, false),
1807
1864
  new DroidVisual(this.mods, true),
@@ -1817,7 +1874,9 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1817
1874
  postCalculateAim(aimSkill, aimSkillWithoutSliders) {
1818
1875
  this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
1819
1876
  this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
1820
- this.attributes.aimDifficulty = this.starValue(aimSkill.difficultyValue());
1877
+ this.attributes.aimDifficulty = this.mods.some((m) => m instanceof osuBase.ModAutopilot)
1878
+ ? 0
1879
+ : this.starValue(aimSkill.difficultyValue());
1821
1880
  if (this.aim) {
1822
1881
  this.attributes.sliderFactor =
1823
1882
  this.starValue(aimSkillWithoutSliders.difficultyValue()) /
@@ -1834,6 +1893,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1834
1893
  * Calculates aim-related attributes.
1835
1894
  */
1836
1895
  calculateAimAttributes() {
1896
+ this.attributes.difficultSliders = [];
1837
1897
  const topDifficultSliders = [];
1838
1898
  for (let i = 0; i < this.objects.length; ++i) {
1839
1899
  const object = this.objects[i];
@@ -1954,10 +2014,12 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1954
2014
  this.attributes.flashlightSliderFactor =
1955
2015
  this.starValue(flashlightSkillWithoutSliders.difficultyValue()) / this.flashlight;
1956
2016
  }
2017
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
2018
+ this.attributes.flashlightDifficulty *= 0.3;
2019
+ }
1957
2020
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1958
2021
  this.attributes.flashlightDifficulty *= 0.7;
1959
2022
  }
1960
- this.attributes.flashlightDifficulty = this.flashlight;
1961
2023
  this.attributes.flashlightDifficultStrainCount =
1962
2024
  flashlightSkill.countDifficultStrains();
1963
2025
  }
@@ -1976,6 +2038,9 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1976
2038
  this.starValue(visualSkillWithoutSliders.difficultyValue()) /
1977
2039
  this.visual;
1978
2040
  }
2041
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
2042
+ this.attributes.visualDifficulty *= 0.8;
2043
+ }
1979
2044
  this.attributes.visualDifficultStrainCount =
1980
2045
  visualSkillWithSliders.countDifficultStrains();
1981
2046
  }
@@ -2357,8 +2422,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2357
2422
  aimValue *= this._aimSliderCheesePenalty;
2358
2423
  // Scale the aim value with deviation.
2359
2424
  aimValue *=
2360
- 1.05 *
2361
- Math.sqrt(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)));
2425
+ 1.025 *
2426
+ Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.475);
2362
2427
  // OD 7 SS stays the same.
2363
2428
  aimValue *= 0.98 + Math.pow(7, 2) / 2500;
2364
2429
  return aimValue;
@@ -2370,8 +2435,9 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2370
2435
  let tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
2371
2436
  tapValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.tapDifficultStrainCount);
2372
2437
  // Scale the tap value with estimated full combo deviation.
2373
- // Require more objects to be present as object count can rack up easily in tap-oriented beatmaps.
2374
- tapValue *= this.calculateDeviationBasedLengthScaling(this.totalHits / 1.45);
2438
+ // Consider notes that are difficult to tap with respect to other notes, but
2439
+ // also cap the note count to prevent buffing filler patterns.
2440
+ tapValue *= this.calculateDeviationBasedLengthScaling(Math.min(this.difficultyAttributes.speedNoteCount, this.totalHits / 1.45));
2375
2441
  // Normalize the deviation to 300 BPM.
2376
2442
  const normalizedDeviation = this.tapDeviation *
2377
2443
  Math.max(1, 50 / this.difficultyAttributes.averageSpeedDeltaTime);
@@ -2386,8 +2452,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2386
2452
  ((2 * 300) / averageBPM))));
2387
2453
  // Scale the tap value with tap deviation.
2388
2454
  tapValue *=
2389
- 1.1 *
2390
- Math.pow(osuBase.ErrorFunction.erf(20 / (Math.SQRT2 * adjustedDeviation)), 0.625);
2455
+ 1.05 *
2456
+ Math.pow(osuBase.ErrorFunction.erf(20 / (Math.SQRT2 * adjustedDeviation)), 0.6);
2391
2457
  // Additional scaling for tap value based on average BPM and how "vibroable" the beatmap is.
2392
2458
  // Higher BPMs require more precise tapping. When the deviation is too high,
2393
2459
  // it can be assumed that the player taps invariant to rhythm.
@@ -2412,7 +2478,7 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2412
2478
  this.totalSuccessfulHits === 0) {
2413
2479
  return 0;
2414
2480
  }
2415
- let accuracyValue = 800 * Math.exp(-0.1 * this._deviation);
2481
+ let accuracyValue = 650 * Math.exp(-0.1 * this._deviation);
2416
2482
  const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
2417
2483
  ? this.totalHits - this.difficultyAttributes.spinnerCount
2418
2484
  : this.difficultyAttributes.hitCircleCount;
@@ -2465,8 +2531,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2465
2531
  visualValue *= this._visualSliderCheesePenalty;
2466
2532
  // Scale the visual value with deviation.
2467
2533
  visualValue *=
2468
- 1.065 *
2469
- Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.8);
2534
+ 1.05 *
2535
+ Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.775);
2470
2536
  // OD 5 SS stays the same.
2471
2537
  visualValue *= 0.98 + Math.pow(5, 2) / 2500;
2472
2538
  return visualValue;
@@ -2482,8 +2548,9 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2482
2548
  if (this.effectiveMissCount === 0) {
2483
2549
  return 1;
2484
2550
  }
2485
- return (0.94 /
2486
- (this.effectiveMissCount / (2 * Math.sqrt(difficultStrainCount)) +
2551
+ return (0.96 /
2552
+ (this.effectiveMissCount /
2553
+ (4 * Math.pow(Math.log(difficultStrainCount), 0.94)) +
2487
2554
  1));
2488
2555
  }
2489
2556
  /**
@@ -2910,30 +2977,19 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
2910
2977
  * - and how easily they can be cheesed.
2911
2978
  *
2912
2979
  * @param current The current object.
2913
- * @param greatWindow The great hit window of the current object.
2914
2980
  */
2915
- static evaluateDifficultyOf(current, greatWindow) {
2981
+ static evaluateDifficultyOf(current) {
2916
2982
  var _a;
2917
2983
  if (current.object instanceof osuBase.Spinner) {
2918
2984
  return 0;
2919
2985
  }
2920
2986
  const prev = current.previous(0);
2921
2987
  let strainTime = current.strainTime;
2922
- const greatWindowFull = greatWindow * 2;
2923
2988
  // Nerf doubletappable doubles.
2924
- const next = current.next(0);
2925
- let doubletapness = 1;
2926
- if (next) {
2927
- const currentDeltaTime = Math.max(1, current.deltaTime);
2928
- const nextDeltaTime = Math.max(1, next.deltaTime);
2929
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
2930
- const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
2931
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
2932
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
2933
- }
2989
+ const doubletapness = 1 - current.doubletapness;
2934
2990
  // Cap deltatime to the OD 300 hitwindow.
2935
2991
  // 0.93 is derived from making sure 260 BPM 1/4 OD8 streams aren't nerfed harshly, whilst 0.92 limits the effect of the cap.
2936
- strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.93, 0.92, 1);
2992
+ strainTime /= osuBase.MathUtils.clamp(strainTime / current.fullGreatWindow / 0.93, 0.92, 1);
2937
2993
  let speedBonus = 1;
2938
2994
  if (strainTime < this.minSpeedBonus) {
2939
2995
  speedBonus +=
@@ -2956,7 +3012,7 @@ OsuSpeedEvaluator.SINGLE_SPACING_THRESHOLD = 125;
2956
3012
  /**
2957
3013
  * An evaluator for calculating osu!standard Rhythm skill.
2958
3014
  */
2959
- class OsuRhythmEvaluator extends RhythmEvaluator {
3015
+ class OsuRhythmEvaluator {
2960
3016
  /**
2961
3017
  * Calculates a rhythm multiplier for the difficulty of the tap associated
2962
3018
  * with historic data of the current object.
@@ -2964,7 +3020,7 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
2964
3020
  * @param current The current object.
2965
3021
  * @param greatWindow The great hit window of the current object.
2966
3022
  */
2967
- static evaluateDifficultyOf(current, greatWindow) {
3023
+ static evaluateDifficultyOf(current) {
2968
3024
  if (current.object instanceof osuBase.Spinner) {
2969
3025
  return 0;
2970
3026
  }
@@ -2999,8 +3055,9 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
2999
3055
  Math.min(0.5, Math.pow(Math.sin(Math.PI /
3000
3056
  (Math.min(prevDelta, currentDelta) /
3001
3057
  Math.max(prevDelta, currentDelta))), 2));
3002
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.6) /
3003
- (greatWindow * 0.6));
3058
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) -
3059
+ current.fullGreatWindow * 0.3) /
3060
+ (current.fullGreatWindow * 0.3));
3004
3061
  let effectiveRatio = windowPenalty * currentRatio;
3005
3062
  if (firstDeltaSwitch) {
3006
3063
  if (prevDelta <= 1.25 * currentDelta &&
@@ -3061,13 +3118,15 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
3061
3118
  return Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier) / 2;
3062
3119
  }
3063
3120
  }
3121
+ OsuRhythmEvaluator.rhythmMultiplier = 0.75;
3122
+ OsuRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
3064
3123
 
3065
3124
  /**
3066
3125
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
3067
3126
  */
3068
3127
  class OsuSpeed extends OsuSkill {
3069
- constructor(mods, overallDifficulty) {
3070
- super(mods);
3128
+ constructor() {
3129
+ super(...arguments);
3071
3130
  this.strainDecayBase = 0.3;
3072
3131
  this.reducedSectionCount = 5;
3073
3132
  this.reducedSectionBaseline = 0.75;
@@ -3076,7 +3135,6 @@ class OsuSpeed extends OsuSkill {
3076
3135
  this.currentSpeedStrain = 0;
3077
3136
  this.currentRhythm = 0;
3078
3137
  this.skillMultiplier = 1375;
3079
- this.greatWindow = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
3080
3138
  }
3081
3139
  /**
3082
3140
  * @param current The hitobject to calculate.
@@ -3084,9 +3142,9 @@ class OsuSpeed extends OsuSkill {
3084
3142
  strainValueAt(current) {
3085
3143
  this.currentSpeedStrain *= this.strainDecay(current.strainTime);
3086
3144
  this.currentSpeedStrain +=
3087
- OsuSpeedEvaluator.evaluateDifficultyOf(current, this.greatWindow) *
3145
+ OsuSpeedEvaluator.evaluateDifficultyOf(current) *
3088
3146
  this.skillMultiplier;
3089
- this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current, this.greatWindow);
3147
+ this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
3090
3148
  return this.currentSpeedStrain * this.currentRhythm;
3091
3149
  }
3092
3150
  calculateInitialStrain(time, current) {
@@ -3220,29 +3278,8 @@ class OsuFlashlight extends OsuSkill {
3220
3278
  * Represents an osu!standard hit object with difficulty calculation values.
3221
3279
  */
3222
3280
  class OsuDifficultyHitObject extends DifficultyHitObject {
3223
- get scalingFactor() {
3224
- const radius = this.object.radius;
3225
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
3226
- let scalingFactor = this.normalizedRadius / radius;
3227
- // High circle size (small CS) bonus
3228
- if (radius < this.radiusBuffThreshold) {
3229
- scalingFactor *=
3230
- 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
3231
- }
3232
- return scalingFactor;
3233
- }
3234
- /**
3235
- * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
3236
- * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
3237
- *
3238
- * @param object The underlying hitobject.
3239
- * @param lastObject The hitobject before this hitobject.
3240
- * @param lastLastObject The hitobject before the last hitobject.
3241
- * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
3242
- * @param clockRate The clock rate of the beatmap.
3243
- */
3244
- constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate) {
3245
- super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate);
3281
+ constructor() {
3282
+ super(...arguments);
3246
3283
  /**
3247
3284
  * The speed strain generated by the hitobject.
3248
3285
  */
@@ -3254,6 +3291,17 @@ class OsuDifficultyHitObject extends DifficultyHitObject {
3254
3291
  this.radiusBuffThreshold = 30;
3255
3292
  this.mode = osuBase.Modes.osu;
3256
3293
  }
3294
+ get scalingFactor() {
3295
+ const radius = this.object.radius;
3296
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
3297
+ let scalingFactor = this.normalizedRadius / radius;
3298
+ // High circle size (small CS) bonus
3299
+ if (radius < this.radiusBuffThreshold) {
3300
+ scalingFactor *=
3301
+ 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
3302
+ }
3303
+ return scalingFactor;
3304
+ }
3257
3305
  }
3258
3306
 
3259
3307
  /**
@@ -3319,7 +3367,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3319
3367
  this.attributes.speedDifficulty = 0;
3320
3368
  return;
3321
3369
  }
3322
- const speedSkill = new OsuSpeed(this.mods, this.difficultyStatistics.overallDifficulty);
3370
+ const speedSkill = new OsuSpeed(this.mods);
3323
3371
  this.calculateSkills(speedSkill);
3324
3372
  this.postCalculateSpeed(speedSkill);
3325
3373
  }
@@ -3383,37 +3431,32 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3383
3431
  this.flashlight.toFixed(2) +
3384
3432
  " flashlight)");
3385
3433
  }
3386
- generateDifficultyHitObjects(convertedBeatmap) {
3434
+ generateDifficultyHitObjects(beatmap, clockRate) {
3387
3435
  var _a, _b;
3388
3436
  const difficultyObjects = [];
3389
- const { objects } = convertedBeatmap.hitObjects;
3437
+ const { objects } = beatmap.hitObjects;
3438
+ const greatWindow = new osuBase.OsuHitWindow(beatmap.difficulty.od).hitWindowFor300() /
3439
+ clockRate;
3390
3440
  for (let i = 0; i < objects.length; ++i) {
3391
- const difficultyObject = new OsuDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, this.difficultyStatistics.overallSpeedMultiplier);
3392
- difficultyObject.computeProperties(this.difficultyStatistics.overallSpeedMultiplier, objects);
3441
+ const difficultyObject = new OsuDifficultyHitObject(objects[i], (_a = objects[i - 1]) !== null && _a !== void 0 ? _a : null, (_b = objects[i - 2]) !== null && _b !== void 0 ? _b : null, difficultyObjects, clockRate, greatWindow);
3442
+ difficultyObject.computeProperties(clockRate, objects);
3393
3443
  difficultyObjects.push(difficultyObject);
3394
3444
  }
3395
3445
  return difficultyObjects;
3396
3446
  }
3397
- computeDifficultyStatistics(options) {
3398
- var _a;
3399
- const { difficulty } = this.beatmap;
3400
- return osuBase.calculateOsuDifficultyStatistics({
3401
- circleSize: difficulty.cs,
3402
- approachRate: (_a = difficulty.ar) !== null && _a !== void 0 ? _a : difficulty.od,
3403
- overallDifficulty: difficulty.od,
3404
- healthDrain: difficulty.hp,
3405
- mods: options === null || options === void 0 ? void 0 : options.mods,
3406
- customSpeedMultiplier: options === null || options === void 0 ? void 0 : options.customSpeedMultiplier,
3407
- });
3408
- }
3409
3447
  createSkills() {
3410
3448
  return [
3411
3449
  new OsuAim(this.mods, true),
3412
3450
  new OsuAim(this.mods, false),
3413
- new OsuSpeed(this.mods, this.difficultyStatistics.overallDifficulty),
3451
+ new OsuSpeed(this.mods),
3414
3452
  new OsuFlashlight(this.mods),
3415
3453
  ];
3416
3454
  }
3455
+ populateDifficultyAttributes(beatmap, clockRate) {
3456
+ super.populateDifficultyAttributes(beatmap, clockRate);
3457
+ const preempt = osuBase.BeatmapDifficulty.difficultyRange(beatmap.difficulty.ar, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
3458
+ this.attributes.approachRate = osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
3459
+ }
3417
3460
  /**
3418
3461
  * Called after aim skill calculation.
3419
3462
  *
@@ -3720,6 +3763,5 @@ exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
3720
3763
  exports.OsuSpeed = OsuSpeed;
3721
3764
  exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
3722
3765
  exports.PerformanceCalculator = PerformanceCalculator;
3723
- exports.RhythmEvaluator = RhythmEvaluator;
3724
3766
  exports.SpeedEvaluator = SpeedEvaluator;
3725
3767
  //# sourceMappingURL=index.js.map