@rian8337/osu-difficulty-calculator 4.0.0-beta.3 → 4.0.0-beta.31

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
@@ -8,10 +8,6 @@ var osuBase = require('@rian8337/osu-base');
8
8
  * This class should be considered an "evaluating" class and not persisted.
9
9
  */
10
10
  class AimEvaluator {
11
- static wideAngleMultiplier = 1.5;
12
- static acuteAngleMultiplier = 1.95;
13
- static sliderMultiplier = 1.35;
14
- static velocityChangeMultiplier = 0.75;
15
11
  /**
16
12
  * Calculates the bonus of wide angles.
17
13
  */
@@ -27,154 +23,244 @@ class AimEvaluator {
27
23
  return 1 - this.calculateWideAngleBonus(angle);
28
24
  }
29
25
  }
26
+ AimEvaluator.wideAngleMultiplier = 1.5;
27
+ AimEvaluator.acuteAngleMultiplier = 1.95;
28
+ AimEvaluator.sliderMultiplier = 1.35;
29
+ AimEvaluator.velocityChangeMultiplier = 0.75;
30
30
 
31
31
  /**
32
- * Represents an osu!standard hit object with difficulty calculation values.
32
+ * The base of a difficulty calculator.
33
33
  */
34
- class DifficultyHitObject {
35
- /**
36
- * The underlying hitobject.
37
- */
38
- object;
39
- /**
40
- * The index of this hitobject in the list of all hitobjects.
41
- *
42
- * This is one less than the actual index of the hitobject in the beatmap.
43
- */
44
- index = 0;
45
- /**
46
- * The preempt time of the hitobject.
47
- */
48
- baseTimePreempt = 600;
49
- /**
50
- * Adjusted preempt time of the hitobject, taking speed multiplier into account.
51
- */
52
- timePreempt = 600;
53
- /**
54
- * The fade in time of the hitobject.
55
- */
56
- timeFadeIn = 400;
57
- /**
58
- * The aim strain generated by the hitobject if sliders are considered.
59
- */
60
- aimStrainWithSliders = 0;
61
- /**
62
- * The aim strain generated by the hitobject if sliders are not considered.
63
- */
64
- aimStrainWithoutSliders = 0;
65
- /**
66
- * The tap strain generated by the hitobject.
67
- *
68
- * This is also used for osu!standard as opposed to "speed strain".
69
- */
70
- tapStrain = 0;
71
- /**
72
- * The tap strain generated by the hitobject if `strainTime` isn't modified by
73
- * OD. This is used in three-finger detection.
74
- */
75
- originalTapStrain = 0;
76
- /**
77
- * The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
78
- */
79
- rhythmMultiplier = 0;
80
- /**
81
- * The rhythm strain generated by the hitobject.
82
- */
83
- rhythmStrain = 0;
84
- /**
85
- * The flashlight strain generated by the hitobject if sliders are considered.
86
- */
87
- flashlightStrainWithSliders = 0;
88
- /**
89
- * The flashlight strain generated by the hitobject if sliders are not considered.
90
- */
91
- flashlightStrainWithoutSliders = 0;
34
+ class DifficultyCalculator {
92
35
  /**
93
- * The visual strain generated by the hitobject if sliders are considered.
36
+ * The difficulty objects of the beatmap.
94
37
  */
95
- visualStrainWithSliders = 0;
38
+ get objects() {
39
+ return this._objects;
40
+ }
96
41
  /**
97
- * The visual strain generated by the hitobject if sliders are not considered.
42
+ * The total star rating of the beatmap.
98
43
  */
99
- visualStrainWithoutSliders = 0;
44
+ get total() {
45
+ return this.attributes.starRating;
46
+ }
100
47
  /**
101
- * The normalized distance from the "lazy" end position of the previous hitobject to the start position of this hitobject.
48
+ * Constructs a new instance of the calculator.
102
49
  *
103
- * The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles).
50
+ * @param beatmap The beatmap to calculate.
104
51
  */
105
- lazyJumpDistance = 0;
52
+ constructor(beatmap) {
53
+ /**
54
+ * The difficulty objects of the beatmap.
55
+ */
56
+ this._objects = [];
57
+ /**
58
+ * The modifications applied.
59
+ */
60
+ this.mods = [];
61
+ /**
62
+ * The strain peaks of various calculated difficulties.
63
+ */
64
+ this.strainPeaks = {
65
+ aimWithSliders: [],
66
+ aimWithoutSliders: [],
67
+ speed: [],
68
+ flashlight: [],
69
+ };
70
+ this.beatmap = beatmap;
71
+ }
106
72
  /**
107
- * The normalized shortest distance to consider for a jump between the previous hitobject and this hitobject.
73
+ * Calculates the star rating of the specified beatmap.
108
74
  *
109
- * This is bounded from above by `lazyJumpDistance`, and is smaller than the former if a more natural path is able to be taken through the previous hitobject.
75
+ * The beatmap is analyzed in chunks of `sectionLength` duration.
76
+ * For each chunk the highest hitobject strains are added to
77
+ * a list which is then collapsed into a weighted sum, much
78
+ * like scores are weighted on a user's profile.
110
79
  *
111
- * Suppose a linear slider - circle pattern. Following the slider lazily (see: `lazyJumpDistance`) will result in underestimating the true end position of the slider as being closer towards the start position.
112
- * As a result, `lazyJumpDistance` overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end,
113
- * such that the jump is felt as only starting from the slider's true end position.
80
+ * For subsequent chunks, the initial max strain is calculated
81
+ * by decaying the previous hitobject's strain until the
82
+ * beginning of the new chunk.
114
83
  *
115
- * Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
116
- * In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
117
- */
118
- minimumJumpDistance = 0;
119
- /**
120
- * The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
121
- */
122
- minimumJumpTime = 0;
123
- /**
124
- * The normalized distance between the start and end position of this hitobject.
125
- */
126
- travelDistance = 0;
127
- /**
128
- * The time taken to travel through `travelDistance`, with a minimum value of 25ms for sliders.
84
+ * @param options Options for the difficulty calculation.
85
+ * @returns The current instance.
129
86
  */
130
- travelTime = 0;
87
+ calculate(options) {
88
+ var _a;
89
+ this.mods = (_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : [];
90
+ const playableBeatmap = this.beatmap.createPlayableBeatmap({
91
+ mode: this.mode,
92
+ mods: this.mods,
93
+ customSpeedMultiplier: options === null || options === void 0 ? void 0 : options.customSpeedMultiplier,
94
+ });
95
+ const clockRate = this.calculateClockRate(options);
96
+ this.populateDifficultyAttributes(playableBeatmap, clockRate);
97
+ this._objects = this.generateDifficultyHitObjects(playableBeatmap, clockRate);
98
+ this.calculateAll();
99
+ return this;
100
+ }
131
101
  /**
132
- * Angle the player has to take to hit this hitobject.
102
+ * Calculates the skills provided.
133
103
  *
134
- * Calculated as the angle between the circles (current-2, current-1, current).
135
- */
136
- angle = null;
137
- /**
138
- * The amount of milliseconds elapsed between this hitobject and the last hitobject.
139
- */
140
- deltaTime = 0;
141
- /**
142
- * The amount of milliseconds elapsed since the start time of the previous hitobject, with a minimum of 25ms.
143
- */
144
- strainTime = 0;
145
- /**
146
- * Adjusted start time of the hitobject, taking speed multiplier into account.
147
- */
148
- startTime = 0;
149
- /**
150
- * Adjusted end time of the hitobject, taking speed multiplier into account.
104
+ * @param skills The skills to calculate.
151
105
  */
152
- endTime = 0;
106
+ calculateSkills(...skills) {
107
+ // The first object doesn't generate a strain, so we begin calculating from the second object.
108
+ for (const object of this.objects.slice(1)) {
109
+ for (const skill of skills) {
110
+ skill.process(object);
111
+ }
112
+ }
113
+ }
153
114
  /**
154
- * The note density of the hitobject.
115
+ * Obtains the clock rate of the beatmap.
116
+ *
117
+ * @param options The options to obtain the clock rate with.
118
+ * @returns The clock rate of the beatmap.
155
119
  */
156
- noteDensity = 1;
120
+ calculateClockRate(options) {
121
+ var _a, _b;
122
+ return (osuBase.ModUtil.calculateRateWithMods((_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : []) *
123
+ ((_b = options === null || options === void 0 ? void 0 : options.customSpeedMultiplier) !== null && _b !== void 0 ? _b : 1));
124
+ }
157
125
  /**
158
- * The overlapping factor of the hitobject.
126
+ * Populates the stored difficulty attributes with necessary data.
159
127
  *
160
- * This is used to scale visual skill.
128
+ * @param beatmap The beatmap to populate the attributes with.
129
+ * @param clockRate The clock rate of the beatmap.
161
130
  */
162
- overlappingFactor = 0;
131
+ populateDifficultyAttributes(beatmap, clockRate) {
132
+ this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
133
+ this.attributes.maxCombo = this.beatmap.maxCombo;
134
+ this.attributes.mods = this.mods.slice();
135
+ this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
136
+ this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
137
+ this.attributes.clockRate = clockRate;
138
+ let greatWindow;
139
+ switch (this.mode) {
140
+ case osuBase.Modes.droid:
141
+ greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).hitWindowFor300(this.mods.some((m) => m instanceof osuBase.ModPrecise));
142
+ break;
143
+ case osuBase.Modes.osu:
144
+ greatWindow = new osuBase.OsuHitWindow(beatmap.difficulty.od).hitWindowFor300();
145
+ break;
146
+ }
147
+ this.attributes.overallDifficulty = osuBase.OsuHitWindow.hitWindow300ToOD(greatWindow / clockRate);
148
+ }
163
149
  /**
164
- * Adjusted velocity of the hitobject, taking speed multiplier into account.
150
+ * Calculates the star rating value of a difficulty.
151
+ *
152
+ * @param difficulty The difficulty to calculate.
165
153
  */
166
- velocity = 0;
154
+ starValue(difficulty) {
155
+ return Math.sqrt(difficulty) * this.difficultyMultiplier;
156
+ }
167
157
  /**
168
- * Other hitobjects in the beatmap, including this hitobject.
158
+ * Calculates the base performance value of a difficulty rating.
159
+ *
160
+ * @param rating The difficulty rating.
169
161
  */
170
- hitObjects;
162
+ basePerformanceValue(rating) {
163
+ return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Represents a hit object with difficulty calculation values.
169
+ */
170
+ class DifficultyHitObject {
171
171
  /**
172
+ * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
173
+ * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
174
+ *
172
175
  * @param object The underlying hitobject.
173
- * @param hitObjects All difficulty hitobjects in the processed beatmap.
174
- */
175
- constructor(object, hitObjects) {
176
+ * @param lastObject The hitobject before this hitobject.
177
+ * @param lastLastObject The hitobject before the last hitobject.
178
+ * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
179
+ * @param clockRate The clock rate of the beatmap.
180
+ * @param greatWindow The great window of the hitobject.
181
+ */
182
+ constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow) {
183
+ /**
184
+ * The aim strain generated by the hitobject if sliders are considered.
185
+ */
186
+ this.aimStrainWithSliders = 0;
187
+ /**
188
+ * The aim strain generated by the hitobject if sliders are not considered.
189
+ */
190
+ this.aimStrainWithoutSliders = 0;
191
+ /**
192
+ * The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
193
+ */
194
+ this.rhythmMultiplier = 0;
195
+ /**
196
+ * The normalized distance from the "lazy" end position of the previous hitobject to the start position of this hitobject.
197
+ *
198
+ * The "lazy" end position is the position at which the cursor ends up if the previous hitobject is followed with as minimal movement as possible (i.e. on the edge of slider follow circles).
199
+ */
200
+ this.lazyJumpDistance = 0;
201
+ /**
202
+ * The normalized shortest distance to consider for a jump between the previous hitobject and this hitobject.
203
+ *
204
+ * This is bounded from above by `lazyJumpDistance`, and is smaller than the former if a more natural path is able to be taken through the previous hitobject.
205
+ *
206
+ * Suppose a linear slider - circle pattern. Following the slider lazily (see: `lazyJumpDistance`) will result in underestimating the true end position of the slider as being closer towards the start position.
207
+ * As a result, `lazyJumpDistance` overestimates the jump distance because the player is able to take a more natural path by following through the slider to its end,
208
+ * such that the jump is felt as only starting from the slider's true end position.
209
+ *
210
+ * Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
211
+ * In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
212
+ */
213
+ this.minimumJumpDistance = 0;
214
+ /**
215
+ * The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
216
+ */
217
+ this.minimumJumpTime = 0;
218
+ /**
219
+ * The normalized distance between the start and end position of this hitobject.
220
+ */
221
+ this.travelDistance = 0;
222
+ /**
223
+ * The time taken to travel through `travelDistance`, with a minimum value of 25ms for sliders.
224
+ */
225
+ this.travelTime = 0;
226
+ /**
227
+ * Angle the player has to take to hit this hitobject.
228
+ *
229
+ * Calculated as the angle between the circles (current-2, current-1, current).
230
+ */
231
+ this.angle = null;
232
+ this.normalizedRadius = 50;
233
+ this.maximumSliderRadius = this.normalizedRadius * 2.4;
234
+ this.assumedSliderRadius = this.normalizedRadius * 1.8;
176
235
  this.object = object;
177
- this.hitObjects = hitObjects;
236
+ this.lastObject = lastObject;
237
+ this.lastLastObject = lastLastObject;
238
+ this.hitObjects = difficultyHitObjects;
239
+ this.fullGreatWindow = greatWindow * 2;
240
+ this.index = difficultyHitObjects.length - 1;
241
+ // Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
242
+ this.startTime = object.startTime / clockRate;
243
+ this.endTime = object.endTime / clockRate;
244
+ if (lastObject) {
245
+ this.deltaTime = this.startTime - lastObject.startTime / clockRate;
246
+ this.strainTime = Math.max(this.deltaTime, DifficultyHitObject.minDeltaTime);
247
+ }
248
+ else {
249
+ this.deltaTime = 0;
250
+ this.strainTime = 0;
251
+ }
252
+ }
253
+ /**
254
+ * Computes the properties of this hitobject.
255
+ *
256
+ * @param clockRate The clock rate of the beatmap.
257
+ * @param hitObjects The hitobjects in the beatmap.
258
+ */
259
+ computeProperties(clockRate,
260
+ // Required for `DroidDifficultyHitObject` override.
261
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
262
+ hitObjects) {
263
+ this.setDistances(clockRate);
178
264
  }
179
265
  /**
180
266
  * Gets the difficulty hitobject at a specific index with respect to the current
@@ -187,7 +273,8 @@ class DifficultyHitObject {
187
273
  * difficulty hitobject's index, `null` if the index is out of range.
188
274
  */
189
275
  previous(backwardsIndex) {
190
- return this.hitObjects[this.index - backwardsIndex] ?? null;
276
+ var _a;
277
+ return (_a = this.hitObjects[this.index - backwardsIndex]) !== null && _a !== void 0 ? _a : null;
191
278
  }
192
279
  /**
193
280
  * Gets the difficulty hitobject at a specific index with respect to the current
@@ -200,285 +287,128 @@ class DifficultyHitObject {
200
287
  * difficulty hitobject's index, `null` if the index is out of range.
201
288
  */
202
289
  next(forwardsIndex) {
203
- return this.hitObjects[this.index + forwardsIndex + 2] ?? null;
290
+ var _a;
291
+ return ((_a = this.hitObjects[this.index + forwardsIndex + 2]) !== null && _a !== void 0 ? _a : null);
204
292
  }
205
293
  /**
206
294
  * Calculates the opacity of the hitobject at a given time.
207
295
  *
208
296
  * @param time The time to calculate the hitobject's opacity at.
209
297
  * @param isHidden Whether Hidden mod is used.
210
- * @param mode The gamemode to calculate the opacity for.
211
298
  * @returns The opacity of the hitobject at the given time.
212
299
  */
213
- opacityAt(time, isHidden, mode) {
300
+ opacityAt(time, isHidden) {
214
301
  if (time > this.object.startTime) {
215
302
  // Consider a hitobject as being invisible when its start time is passed.
216
303
  // In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
217
304
  // but this is an approximation and such a case is unlikely to be hit where this function is used.
218
305
  return 0;
219
306
  }
220
- const fadeInStartTime = this.object.startTime - this.baseTimePreempt;
221
- const fadeInDuration = this.timeFadeIn;
307
+ const fadeInStartTime = this.object.startTime - this.object.timePreempt;
308
+ const fadeInDuration = this.object.timeFadeIn;
222
309
  if (isHidden) {
223
310
  const fadeOutStartTime = fadeInStartTime + fadeInDuration;
224
- const fadeOutDuration = this.baseTimePreempt *
225
- (mode === osuBase.Modes.droid
226
- ? 0.35
227
- : osuBase.ModHidden.fadeOutDurationMultiplier);
311
+ const fadeOutDuration = this.object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
228
312
  return Math.min(osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1), 1 -
229
313
  osuBase.MathUtils.clamp((time - fadeOutStartTime) / fadeOutDuration, 0, 1));
230
314
  }
231
315
  return osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1);
232
316
  }
233
317
  /**
234
- * Determines whether this hitobject is considered overlapping with the hitobject before it.
235
- *
236
- * Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
237
- * can be hit with just a single tap in osu!droid.
318
+ * How possible is it to doubletap this object together with the next one and get perfect
319
+ * judgement in range from 0 to 1.
238
320
  *
239
- * @param considerDistance Whether to consider the distance between both hitobjects.
240
- * @returns Whether the hitobject is considered overlapping.
321
+ * A value closer to 1 indicates a higher possibility.
241
322
  */
242
- isOverlapping(considerDistance) {
243
- if (this.object instanceof osuBase.Spinner) {
244
- return false;
245
- }
246
- const previous = this.previous(0);
247
- if (!previous || previous.object instanceof osuBase.Spinner) {
248
- return false;
249
- }
250
- if (this.deltaTime >= 5) {
251
- return false;
252
- }
253
- if (considerDistance) {
254
- const endPosition = this.object.getStackedPosition(osuBase.Modes.droid);
255
- let distance = previous.object
256
- .getStackedEndPosition(osuBase.Modes.droid)
257
- .getDistance(endPosition);
258
- if (previous.object instanceof osuBase.Slider &&
259
- previous.object.lazyEndPosition) {
260
- distance = Math.min(distance, previous.object.lazyEndPosition.getDistance(endPosition));
261
- }
262
- return distance <= 2 * this.object.getRadius(osuBase.Modes.droid);
323
+ get doubletapness() {
324
+ const next = this.next(0);
325
+ if (!next) {
326
+ return 0;
263
327
  }
264
- return true;
265
- }
266
- }
267
-
268
- /**
269
- * A converter used to convert normal hitobjects into difficulty hitobjects.
270
- */
271
- class DifficultyHitObjectCreator {
272
- /**
273
- * The threshold for small circle buff for osu!droid.
274
- */
275
- DROID_CIRCLESIZE_BUFF_THRESHOLD = 70;
276
- /**
277
- * The threshold for small circle buff for osu!standard.
278
- */
279
- PC_CIRCLESIZE_BUFF_THRESHOLD = 30;
280
- /**
281
- * The gamemode this creator is creating for.
282
- */
283
- mode = osuBase.Modes.osu;
284
- /**
285
- * The base normalized radius of hitobjects.
286
- */
287
- normalizedRadius = 50;
288
- maximumSliderRadius = this.normalizedRadius * 2.4;
289
- assumedSliderRadius = this.normalizedRadius * 1.8;
290
- minDeltaTime = 25;
291
- /**
292
- * Generates difficulty hitobjects for difficulty calculation.
293
- */
294
- generateDifficultyObjects(params) {
295
- params.preempt ??= 600;
296
- this.mode = params.mode;
297
- if (this.mode === osuBase.Modes.droid) {
298
- this.maximumSliderRadius = this.normalizedRadius * 2;
299
- }
300
- const droidCircleSize = new osuBase.MapStats({
301
- cs: params.circleSize,
302
- mods: params.mods,
303
- }).calculate({ mode: osuBase.Modes.droid }).cs;
304
- const droidScale = (1 - (0.7 * (droidCircleSize - 5)) / 5) / 2;
305
- const osuCircleSize = new osuBase.MapStats({
306
- cs: params.circleSize,
307
- mods: params.mods,
308
- }).calculate({ mode: osuBase.Modes.osu }).cs;
309
- const osuScale = (1 - (0.7 * (osuCircleSize - 5)) / 5) / 2;
310
- params.objects[0].droidScale = droidScale;
311
- params.objects[0].osuScale = osuScale;
312
- const scalingFactor = this.getScalingFactor(params.objects[0].getRadius(this.mode));
313
- const difficultyObjects = [];
314
- for (let i = 0; i < params.objects.length; ++i) {
315
- const object = new DifficultyHitObject(params.objects[i], difficultyObjects);
316
- object.index = difficultyObjects.length - 1;
317
- object.object.droidScale = droidScale;
318
- object.object.osuScale = osuScale;
319
- object.timePreempt = params.preempt;
320
- object.baseTimePreempt = params.preempt * params.speedMultiplier;
321
- if (object.object instanceof osuBase.Slider) {
322
- object.velocity =
323
- object.object.velocity * params.speedMultiplier;
324
- object.object.nestedHitObjects.forEach((o) => {
325
- o.droidScale = droidScale;
326
- o.osuScale = osuScale;
327
- });
328
- this.calculateSliderCursorPosition(object.object);
329
- object.travelDistance = object.object.lazyTravelDistance;
330
- // Bonus for repeat sliders until a better per nested object strain system can be achieved.
331
- if (this.mode === osuBase.Modes.droid) {
332
- object.travelDistance *= Math.pow(1 + object.object.repeats / 4, 1 / 4);
333
- }
334
- else {
335
- object.travelDistance *= Math.pow(1 + object.object.repeats / 2.5, 1 / 2.5);
336
- }
337
- object.travelTime = Math.max(object.object.lazyTravelTime / params.speedMultiplier, this.minDeltaTime);
328
+ const currentDeltaTime = Math.max(1, this.deltaTime);
329
+ const nextDeltaTime = Math.max(1, next.deltaTime);
330
+ const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
331
+ const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
332
+ const windowRatio = Math.pow(Math.min(1, currentDeltaTime / this.fullGreatWindow), 2);
333
+ return 1 - Math.pow(speedRatio, 1 - windowRatio);
334
+ }
335
+ setDistances(clockRate) {
336
+ if (this.object instanceof osuBase.Slider) {
337
+ this.calculateSliderCursorPosition(this.object);
338
+ this.travelDistance = this.object.lazyTravelDistance;
339
+ // Bonus for repeat sliders until a better per nested object strain system can be achieved.
340
+ if (this.mode === osuBase.Modes.droid) {
341
+ this.travelDistance *= Math.pow(1 + this.object.repeatCount / 4, 1 / 4);
338
342
  }
339
- const lastObject = difficultyObjects[i - 1];
340
- const lastLastObject = difficultyObjects[i - 2];
341
- object.startTime = object.object.startTime / params.speedMultiplier;
342
- object.endTime = object.object.endTime / params.speedMultiplier;
343
- if (!lastObject) {
344
- difficultyObjects.push(object);
345
- continue;
346
- }
347
- object.deltaTime =
348
- (object.object.startTime - lastObject.object.startTime) /
349
- params.speedMultiplier;
350
- // Cap to 25ms to prevent difficulty calculation breaking from simultaneous objects.
351
- object.strainTime = Math.max(this.minDeltaTime, object.deltaTime);
352
- if (object.object instanceof osuBase.Spinner) {
353
- difficultyObjects.push(object);
354
- continue;
355
- }
356
- // We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
357
- // while the second array contains objects after the current object ends.
358
- // For overlapping factor, we also need to consider previous visible objects.
359
- const prevVisibleObjects = [];
360
- const nextVisibleObjects = [];
361
- for (let j = i + 1; j < params.objects.length; ++j) {
362
- const o = params.objects[j];
363
- if (o instanceof osuBase.Spinner) {
364
- continue;
365
- }
366
- if (o.startTime / params.speedMultiplier >
367
- object.endTime + object.timePreempt) {
368
- break;
369
- }
370
- // Future objects do not have their scales set, so we set them here.
371
- o.droidScale = droidScale;
372
- o.osuScale = osuScale;
373
- nextVisibleObjects.push(o);
374
- }
375
- for (let j = 0; j < object.index; ++j) {
376
- const prev = object.previous(j);
377
- if (prev.object instanceof osuBase.Spinner) {
378
- continue;
379
- }
380
- if (prev.startTime >= object.startTime) {
381
- continue;
382
- }
383
- if (prev.startTime < object.startTime - object.timePreempt) {
384
- break;
385
- }
386
- prevVisibleObjects.push(prev.object);
387
- }
388
- for (const hitObject of prevVisibleObjects) {
389
- const distance = object.object
390
- .getStackedPosition(this.mode)
391
- .getDistance(hitObject.getStackedEndPosition(this.mode));
392
- const deltaTime = object.startTime -
393
- hitObject.endTime / params.speedMultiplier;
394
- this.applyToOverlappingFactor(object, distance, deltaTime);
395
- }
396
- for (const hitObject of nextVisibleObjects) {
397
- const distance = hitObject
398
- .getStackedPosition(this.mode)
399
- .getDistance(object.object.getStackedEndPosition(this.mode));
400
- const deltaTime = hitObject.startTime / params.speedMultiplier -
401
- object.endTime;
402
- if (deltaTime >= 0) {
403
- object.noteDensity += 1 - deltaTime / object.timePreempt;
404
- }
405
- this.applyToOverlappingFactor(object, distance, deltaTime);
406
- }
407
- if (lastObject.object instanceof osuBase.Spinner) {
408
- difficultyObjects.push(object);
409
- continue;
343
+ else {
344
+ this.travelDistance *= Math.pow(1 + this.object.repeatCount / 2.5, 1 / 2.5);
410
345
  }
411
- const lastCursorPosition = this.getEndCursorPosition(lastObject.object);
412
- object.lazyJumpDistance = object.object
346
+ this.travelTime = Math.max(this.object.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
347
+ }
348
+ // We don't need to calculate either angle or distance when one of the last->curr objects is a spinner.
349
+ if (!this.lastObject ||
350
+ this.object instanceof osuBase.Spinner ||
351
+ this.lastObject instanceof osuBase.Spinner) {
352
+ return;
353
+ }
354
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
355
+ const { scalingFactor } = this;
356
+ const lastCursorPosition = this.getEndCursorPosition(this.lastObject);
357
+ this.lazyJumpDistance = this.object
358
+ .getStackedPosition(this.mode)
359
+ .scale(scalingFactor)
360
+ .subtract(lastCursorPosition.scale(scalingFactor)).length;
361
+ this.minimumJumpTime = this.strainTime;
362
+ this.minimumJumpDistance = this.lazyJumpDistance;
363
+ if (this.lastObject instanceof osuBase.Slider) {
364
+ const lastTravelTime = Math.max(this.lastObject.lazyTravelTime / clockRate, DifficultyHitObject.minDeltaTime);
365
+ this.minimumJumpTime = Math.max(this.strainTime - lastTravelTime, DifficultyHitObject.minDeltaTime);
366
+ // 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.
367
+ //
368
+ // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
369
+ //
370
+ // <======o==> ← slider
371
+ // | ← most natural jump path
372
+ // o ← a follow-up hitcircle
373
+ //
374
+ // In this case the most natural jump path is approximated by LazyJumpDistance.
375
+ //
376
+ // 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
377
+ //
378
+ // <======o==>---o
379
+ // ↑
380
+ // most natural jump path
381
+ //
382
+ // In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
383
+ //
384
+ // Thus, the player is assumed to jump the minimum of these two distances in all cases.
385
+ const tailJumpDistance = this.lastObject.tail
413
386
  .getStackedPosition(this.mode)
414
- .scale(scalingFactor)
415
- .subtract(lastCursorPosition.scale(scalingFactor)).length;
416
- object.minimumJumpTime = object.strainTime;
417
- object.minimumJumpDistance = object.lazyJumpDistance;
418
- if (lastObject.object instanceof osuBase.Slider) {
419
- object.minimumJumpTime = Math.max(object.strainTime - lastObject.travelTime, this.minDeltaTime);
420
- // 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.
421
- //
422
- // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
423
- //
424
- // <======o==> ← slider
425
- // | ← most natural jump path
426
- // o ← a follow-up hitcircle
427
- //
428
- // In this case the most natural jump path is approximated by LazyJumpDistance.
429
- //
430
- // 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
431
- //
432
- // <======o==>---o
433
- // ↑
434
- // most natural jump path
435
- //
436
- // In this case the most natural jump path is better approximated by a new distance called "tailJumpDistance" - the distance between the slider's tail and the next hitobject.
437
- //
438
- // Thus, the player is assumed to jump the minimum of these two distances in all cases.
439
- const tailJumpDistance = lastObject.object.tail
440
- .getStackedPosition(this.mode)
441
- .subtract(object.object.getStackedPosition(this.mode))
442
- .length * scalingFactor;
443
- object.minimumJumpDistance = Math.max(0, Math.min(object.lazyJumpDistance -
444
- (this.maximumSliderRadius -
445
- this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
446
- }
447
- if (lastLastObject && !(lastLastObject.object instanceof osuBase.Spinner)) {
448
- const lastLastCursorPosition = this.getEndCursorPosition(lastLastObject.object);
449
- const v1 = lastLastCursorPosition.subtract(lastObject.object.getStackedPosition(this.mode));
450
- const v2 = object.object
451
- .getStackedPosition(this.mode)
452
- .subtract(lastCursorPosition);
453
- const dot = v1.dot(v2);
454
- const det = v1.x * v2.y - v1.y * v2.x;
455
- object.angle = Math.abs(Math.atan2(det, dot));
456
- }
457
- difficultyObjects.push(object);
387
+ .subtract(this.object.getStackedPosition(this.mode))
388
+ .length * scalingFactor;
389
+ this.minimumJumpDistance = Math.max(0, Math.min(this.lazyJumpDistance -
390
+ (this.maximumSliderRadius - this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
391
+ }
392
+ if (this.lastLastObject && !(this.lastLastObject instanceof osuBase.Spinner)) {
393
+ const lastLastCursorPosition = this.getEndCursorPosition(this.lastLastObject);
394
+ const v1 = lastLastCursorPosition.subtract(this.lastObject.getStackedPosition(this.mode));
395
+ const v2 = this.object
396
+ .getStackedPosition(this.mode)
397
+ .subtract(lastCursorPosition);
398
+ const dot = v1.dot(v2);
399
+ const det = v1.x * v2.y - v1.y * v2.x;
400
+ this.angle = Math.abs(Math.atan2(det, dot));
458
401
  }
459
- return difficultyObjects;
460
402
  }
461
- /**
462
- * Calculates a slider's cursor position.
463
- */
464
403
  calculateSliderCursorPosition(slider) {
465
404
  if (slider.lazyEndPosition) {
466
405
  return;
467
406
  }
468
- // Droid doesn't have a legacy slider tail. Since beatmap parser defaults slider tail
469
- // to legacy slider tail, it needs to be changed to real slider tail first.
470
407
  if (this.mode === osuBase.Modes.droid) {
471
- slider.tail.startTime += osuBase.Slider.legacyLastTickOffset;
472
- slider.tail.endTime += osuBase.Slider.legacyLastTickOffset;
473
- slider.nestedHitObjects.sort((a, b) => a.startTime - b.startTime);
474
408
  // Temporary lazy end position until a real result can be derived.
475
409
  slider.lazyEndPosition = slider.getStackedPosition(this.mode);
476
- // Stop here if the slider has too short duration due to float number limitation.
477
- // Incredibly close start and end time fluctuates travel distance and lazy
478
- // end position heavily, which we do not want to happen.
479
- //
480
- // In the real game, this shouldn't happen. Perhaps we need to reinvestigate this
481
- // in the future.
410
+ // Stop here if the slider has too short duration, allowing the player to essentially
411
+ // complete the slider without movement, making travel distance and time irrelevant.
482
412
  if (osuBase.Precision.almostEqualsNumber(slider.startTime, slider.endTime)) {
483
413
  return;
484
414
  }
@@ -498,7 +428,7 @@ class DifficultyHitObjectCreator {
498
428
  .getStackedPosition(this.mode)
499
429
  .add(slider.path.positionAt(endTimeMin));
500
430
  let currentCursorPosition = slider.getStackedPosition(this.mode);
501
- const scalingFactor = this.normalizedRadius / slider.getRadius(this.mode);
431
+ const scalingFactor = this.normalizedRadius / slider.radius;
502
432
  for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
503
433
  const currentMovementObject = slider.nestedHitObjects[i];
504
434
  let currentMovement = currentMovementObject
@@ -524,216 +454,38 @@ class DifficultyHitObjectCreator {
524
454
  }
525
455
  if (currentMovementLength > requiredMovement) {
526
456
  // This finds the positional delta from the required radius and the current position,
527
- // and updates the currentCursorPosition accordingly, as well as rewarding distance.
528
- currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
529
- currentMovementLength));
530
- currentMovementLength *=
531
- (currentMovementLength - requiredMovement) /
532
- currentMovementLength;
533
- slider.lazyTravelDistance += currentMovementLength;
534
- }
535
- if (i === slider.nestedHitObjects.length - 1) {
536
- slider.lazyEndPosition = currentCursorPosition;
537
- }
538
- }
539
- }
540
- /**
541
- * Gets the scaling factor of a radius.
542
- *
543
- * @param radius The radius to get the scaling factor from.
544
- */
545
- getScalingFactor(radius) {
546
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
547
- let scalingFactor = this.normalizedRadius / radius;
548
- // High circle size (small CS) bonus
549
- switch (this.mode) {
550
- case osuBase.Modes.droid:
551
- if (radius < this.DROID_CIRCLESIZE_BUFF_THRESHOLD) {
552
- scalingFactor *=
553
- 1 +
554
- Math.pow((this.DROID_CIRCLESIZE_BUFF_THRESHOLD - radius) /
555
- 50, 2);
556
- }
557
- break;
558
- case osuBase.Modes.osu:
559
- if (radius < this.PC_CIRCLESIZE_BUFF_THRESHOLD) {
560
- scalingFactor *=
561
- 1 +
562
- Math.min(this.PC_CIRCLESIZE_BUFF_THRESHOLD - radius, 5) /
563
- 50;
564
- }
565
- }
566
- return scalingFactor;
567
- }
568
- /**
569
- * Returns the end cursor position of a hitobject.
570
- */
571
- getEndCursorPosition(object) {
572
- let pos = object.getStackedPosition(this.mode);
573
- if (object instanceof osuBase.Slider) {
574
- this.calculateSliderCursorPosition(object);
575
- pos = object.lazyEndPosition ?? pos;
576
- }
577
- return pos;
578
- }
579
- applyToOverlappingFactor(object, distance, deltaTime) {
580
- // Penalize objects that are too close to the object in both distance
581
- // and delta time to prevent stream maps from being overweighted.
582
- object.overlappingFactor +=
583
- Math.max(0, 1 - distance / (3 * object.object.getRadius(this.mode))) *
584
- (7.5 /
585
- (1 +
586
- Math.exp(0.15 * (Math.max(deltaTime, this.minDeltaTime) - 75))));
587
- }
588
- }
589
-
590
- /**
591
- * The base of difficulty calculators.
592
- */
593
- class DifficultyCalculator {
594
- /**
595
- * The calculated beatmap.
596
- */
597
- beatmap;
598
- /**
599
- * The difficulty objects of the beatmap.
600
- */
601
- objects = [];
602
- /**
603
- * The modifications applied.
604
- */
605
- mods = [];
606
- /**
607
- * The total star rating of the beatmap.
608
- */
609
- total = 0;
610
- /**
611
- * The map statistics of the beatmap after modifications are applied.
612
- */
613
- stats = new osuBase.MapStats();
614
- /**
615
- * The strain peaks of various calculated difficulties.
616
- */
617
- strainPeaks = {
618
- aimWithSliders: [],
619
- aimWithoutSliders: [],
620
- speed: [],
621
- flashlight: [],
622
- };
623
- sectionLength = 400;
624
- /**
625
- * Constructs a new instance of the calculator.
626
- *
627
- * @param beatmap The beatmap to calculate. This beatmap will be deep-cloned to prevent reference changes.
628
- */
629
- constructor(beatmap) {
630
- this.beatmap = osuBase.Utils.deepCopy(beatmap);
631
- }
632
- /**
633
- * Calculates the star rating of the specified beatmap.
634
- *
635
- * The beatmap is analyzed in chunks of `sectionLength` duration.
636
- * For each chunk the highest hitobject strains are added to
637
- * a list which is then collapsed into a weighted sum, much
638
- * like scores are weighted on a user's profile.
639
- *
640
- * For subsequent chunks, the initial max strain is calculated
641
- * by decaying the previous hitobject's strain until the
642
- * beginning of the new chunk.
643
- *
644
- * @param options Options for the difficulty calculation.
645
- * @returns The current instance.
646
- */
647
- calculate(options) {
648
- this.mods = options?.mods ?? [];
649
- this.stats = new osuBase.MapStats({
650
- cs: this.beatmap.difficulty.cs,
651
- ar: this.beatmap.difficulty.ar,
652
- od: this.beatmap.difficulty.od,
653
- hp: this.beatmap.difficulty.hp,
654
- mods: options?.mods,
655
- speedMultiplier: options?.stats?.speedMultiplier,
656
- oldStatistics: options?.stats?.oldStatistics,
657
- }).calculate({ mode: this.mode });
658
- this.populateDifficultyAttributes();
659
- this.generateDifficultyHitObjects();
660
- this.calculateAll();
661
- return this;
662
- }
663
- /**
664
- * Generates difficulty hitobjects for this calculator.
665
- */
666
- generateDifficultyHitObjects() {
667
- this.objects.length = 0;
668
- this.objects.push(...new DifficultyHitObjectCreator().generateDifficultyObjects({
669
- objects: this.beatmap.hitObjects.objects,
670
- circleSize: this.beatmap.difficulty.cs,
671
- mods: this.mods,
672
- speedMultiplier: this.stats.speedMultiplier,
673
- mode: this.mode,
674
- preempt: osuBase.MapStats.arToMS(this.stats.ar),
675
- }));
676
- }
677
- /**
678
- * Calculates the skills provided.
679
- *
680
- * @param skills The skills to calculate.
681
- */
682
- calculateSkills(...skills) {
683
- // The first object doesn't generate a strain, so we begin calculating from the second object.
684
- this.objects.slice(1).forEach((h, i) => {
685
- skills.forEach((skill) => {
686
- skill.process(h);
687
- if (i === this.objects.length - 2) {
688
- // Don't forget to save the last strain peak, which would otherwise be ignored.
689
- skill.saveCurrentPeak();
690
- }
691
- });
692
- });
693
- }
694
- /**
695
- * Populates the stored difficulty attributes with necessary data.
696
- */
697
- populateDifficultyAttributes() {
698
- this.attributes.approachRate = this.stats.ar;
699
- this.attributes.hitCircleCount = this.beatmap.hitObjects.circles;
700
- this.attributes.maxCombo = this.beatmap.maxCombo;
701
- this.attributes.mods = this.mods.slice();
702
- this.attributes.overallDifficulty = this.stats.od;
703
- this.attributes.sliderCount = this.beatmap.hitObjects.sliders;
704
- this.attributes.spinnerCount = this.beatmap.hitObjects.spinners;
705
- }
706
- /**
707
- * Calculates the star rating value of a difficulty.
708
- *
709
- * @param difficulty The difficulty to calculate.
710
- */
711
- starValue(difficulty) {
712
- return Math.sqrt(difficulty) * this.difficultyMultiplier;
457
+ // and updates the currentCursorPosition accordingly, as well as rewarding distance.
458
+ currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
459
+ currentMovementLength));
460
+ currentMovementLength *=
461
+ (currentMovementLength - requiredMovement) /
462
+ currentMovementLength;
463
+ slider.lazyTravelDistance += currentMovementLength;
464
+ }
465
+ if (i === slider.nestedHitObjects.length - 1) {
466
+ slider.lazyEndPosition = currentCursorPosition;
467
+ }
468
+ }
713
469
  }
714
- /**
715
- * Calculates the base performance value of a difficulty rating.
716
- *
717
- * @param rating The difficulty rating.
718
- */
719
- basePerformanceValue(rating) {
720
- return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
470
+ getEndCursorPosition(object) {
471
+ var _a;
472
+ let pos = object.getStackedPosition(this.mode);
473
+ if (object instanceof osuBase.Slider) {
474
+ this.calculateSliderCursorPosition(object);
475
+ pos = (_a = object.lazyEndPosition) !== null && _a !== void 0 ? _a : pos;
476
+ }
477
+ return pos;
721
478
  }
722
479
  }
480
+ /**
481
+ * The lowest possible delta time value.
482
+ */
483
+ DifficultyHitObject.minDeltaTime = 25;
723
484
 
724
485
  /**
725
486
  * An evaluator for calculating osu!droid Aim skill.
726
487
  */
727
488
  class DroidAimEvaluator extends AimEvaluator {
728
- static wideAngleMultiplier = 1.65;
729
- static sliderMultiplier = 1.5;
730
- static velocityChangeMultiplier = 0.85;
731
- /**
732
- * Spacing threshold for a single hitobject spacing.
733
- */
734
- static SINGLE_SPACING_THRESHOLD = 175;
735
- // ~200 1/2 BPM jumps
736
- static minSpeedBonus = 150;
737
489
  /**
738
490
  * Evaluates the difficulty of aiming the current object, based on:
739
491
  *
@@ -751,15 +503,16 @@ class DroidAimEvaluator extends AimEvaluator {
751
503
  current.isOverlapping(true)) {
752
504
  return 0;
753
505
  }
754
- return (this.aimStrainOf(current, withSliders) +
755
- this.movementStrainOf(current));
506
+ return (this.snapAimStrainOf(current, withSliders) +
507
+ this.flowAimStrainOf(current));
756
508
  }
757
509
  /**
758
- * Calculates the aim strain of a hitobject.
510
+ * Calculates the snap aim strain of a hitobject.
759
511
  */
760
- static aimStrainOf(current, withSliders) {
512
+ static snapAimStrainOf(current, withSliders) {
513
+ var _a;
761
514
  if (current.index <= 1 ||
762
- current.previous(0)?.object instanceof osuBase.Spinner) {
515
+ ((_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.object) instanceof osuBase.Spinner) {
763
516
  return 0;
764
517
  }
765
518
  const last = current.previous(0);
@@ -861,28 +614,33 @@ class DroidAimEvaluator extends AimEvaluator {
861
614
  velocityChangeBonus * this.velocityChangeMultiplier);
862
615
  // Add in additional slider velocity bonus.
863
616
  if (withSliders) {
864
- strain += sliderBonus * this.sliderMultiplier;
617
+ strain +=
618
+ Math.pow(1 + sliderBonus * this.sliderMultiplier, 1.25) - 1;
865
619
  }
866
620
  return strain;
867
621
  }
868
622
  /**
869
- * Calculates the movement strain of a hitobject.
623
+ * Calculates the flow aim strain of a hitobject.
870
624
  */
871
- static movementStrainOf(current) {
625
+ static flowAimStrainOf(current) {
626
+ var _a, _b;
872
627
  let speedBonus = 1;
873
628
  if (current.strainTime < this.minSpeedBonus) {
874
629
  speedBonus +=
875
630
  0.75 *
876
- Math.pow((this.minSpeedBonus - current.strainTime) / 45, 2);
631
+ Math.pow((this.minSpeedBonus - current.strainTime) / 40, 2);
877
632
  }
878
- const travelDistance = current.previous(0)?.travelDistance ?? 0;
879
- const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
880
- return ((50 *
881
- speedBonus *
882
- Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 5)) /
883
- current.strainTime);
633
+ const travelDistance = (_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.travelDistance) !== null && _b !== void 0 ? _b : 0;
634
+ const shortDistancePenalty = Math.pow(Math.min(this.singleSpacingThreshold, travelDistance + current.minimumJumpDistance) / this.singleSpacingThreshold, 3.5);
635
+ return (200 * speedBonus * shortDistancePenalty) / current.strainTime;
884
636
  }
885
637
  }
638
+ DroidAimEvaluator.wideAngleMultiplier = 1.65;
639
+ DroidAimEvaluator.sliderMultiplier = 1.5;
640
+ DroidAimEvaluator.velocityChangeMultiplier = 0.85;
641
+ DroidAimEvaluator.singleSpacingThreshold = 100;
642
+ // 200 1/4 BPM delta time
643
+ DroidAimEvaluator.minSpeedBonus = 75;
886
644
 
887
645
  /**
888
646
  * A bare minimal abstract skill for fully custom skill implementations.
@@ -890,10 +648,6 @@ class DroidAimEvaluator extends AimEvaluator {
890
648
  * This class should be considered a "processing" class and not persisted.
891
649
  */
892
650
  class Skill {
893
- /**
894
- * The mods that this skill processes.
895
- */
896
- mods;
897
651
  constructor(mods) {
898
652
  this.mods = mods;
899
653
  }
@@ -904,33 +658,29 @@ class Skill {
904
658
  * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
905
659
  */
906
660
  class StrainSkill extends Skill {
661
+ constructor() {
662
+ super(...arguments);
663
+ /**
664
+ * Strain peaks are stored here.
665
+ */
666
+ this.strainPeaks = [];
667
+ this._objectStrains = [];
668
+ this.difficulty = 0;
669
+ this.sectionLength = 400;
670
+ this.currentStrain = 0;
671
+ this.currentSectionPeak = 0;
672
+ this.currentSectionEnd = 0;
673
+ }
907
674
  /**
908
- * The strain of currently calculated hitobject.
909
- */
910
- currentStrain = 0;
911
- /**
912
- * The current section's strain peak.
913
- */
914
- currentSectionPeak = 0;
915
- /**
916
- * Strain peaks are stored here.
917
- */
918
- strainPeaks = [];
919
- sectionLength = 400;
920
- currentSectionEnd = 0;
921
- isFirstObject = true;
922
- /**
923
- * Calculates the strain value of a hitobject and stores the value in it. This value is affected by previously processed objects.
924
- *
925
- * @param current The hitobject to process.
675
+ * The strains of hitobjects.
926
676
  */
677
+ get objectStrains() {
678
+ return this._objectStrains;
679
+ }
927
680
  process(current) {
928
681
  // The first object doesn't generate a strain, so we begin with an incremented section end
929
- if (this.isFirstObject) {
930
- this.currentSectionEnd =
931
- Math.ceil(current.startTime / this.sectionLength) *
932
- this.sectionLength;
933
- this.isFirstObject = false;
682
+ if (current.index === 0) {
683
+ this.currentSectionEnd = this.calculateCurrentSectionStart(current);
934
684
  }
935
685
  while (current.startTime > this.currentSectionEnd) {
936
686
  this.saveCurrentPeak();
@@ -941,6 +691,10 @@ class StrainSkill extends Skill {
941
691
  this.currentStrain = this.strainValueAt(current);
942
692
  this.saveToHitObject(current);
943
693
  this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
694
+ if (!current.next(0)) {
695
+ // Don't forget to save the last strain peak, which would otherwise be ignored.
696
+ this.saveCurrentPeak();
697
+ }
944
698
  }
945
699
  /**
946
700
  * Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty.
@@ -948,6 +702,21 @@ class StrainSkill extends Skill {
948
702
  saveCurrentPeak() {
949
703
  this.strainPeaks.push(this.currentSectionPeak);
950
704
  }
705
+ /**
706
+ * Returns the number of strains weighed against the top strain.
707
+ *
708
+ * The result is scaled by clock rate as it affects the total number of strains.
709
+ */
710
+ countDifficultStrains() {
711
+ if (this.difficulty === 0) {
712
+ return 0;
713
+ }
714
+ // This is what the top strain is if all strain values were identical.
715
+ const consistentTopStrain = this.difficulty / 10;
716
+ // Use a weighted sum of all strains.
717
+ return this._objectStrains.reduce((total, next) => total +
718
+ 1.1 / (1 + Math.exp(-10 * (next / consistentTopStrain - 0.88))), 0);
719
+ }
951
720
  /**
952
721
  * Calculates strain decay for a specified time frame.
953
722
  *
@@ -956,18 +725,26 @@ class StrainSkill extends Skill {
956
725
  strainDecay(ms) {
957
726
  return Math.pow(this.strainDecayBase, ms / 1000);
958
727
  }
728
+ /**
729
+ * Calculates the starting time of a strain section at an object.
730
+ *
731
+ * @param current The object at which the strain section starts.
732
+ * @returns The start time of the strain section.
733
+ */
734
+ calculateCurrentSectionStart(current) {
735
+ return (Math.ceil(current.startTime / this.sectionLength) *
736
+ this.sectionLength);
737
+ }
959
738
  /**
960
739
  * Sets the initial strain level for a new section.
961
740
  *
962
- * @param offset The beginning of the new section in milliseconds, adjusted by speed multiplier.
741
+ * @param time The beginning of the new section in milliseconds.
963
742
  * @param current The current hitobject.
964
743
  */
965
- startNewSectionFrom(offset, current) {
966
- // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
744
+ startNewSectionFrom(time, current) {
745
+ // The maximum strain of the new section is not zero by default.
967
746
  // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
968
- this.currentSectionPeak =
969
- this.currentStrain *
970
- this.strainDecay(offset - current.previous(0).startTime);
747
+ this.currentSectionPeak = this.calculateInitialStrain(time, current);
971
748
  }
972
749
  }
973
750
 
@@ -976,6 +753,10 @@ class StrainSkill extends Skill {
976
753
  * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
977
754
  */
978
755
  class DroidSkill extends StrainSkill {
756
+ process(current) {
757
+ super.process(current);
758
+ this._objectStrains.push(this.getObjectStrain(current));
759
+ }
979
760
  difficultyValue() {
980
761
  const strains = this.strainPeaks.slice();
981
762
  if (this.reducedSectionCount > 0) {
@@ -988,12 +769,15 @@ class DroidSkill extends StrainSkill {
988
769
  }
989
770
  // Math here preserves the property that two notes of equal difficulty x, we have their summed difficulty = x * starsPerDouble.
990
771
  // This also applies to two sets of notes with equal difficulty.
991
- return Math.pow(strains.reduce((a, v) => {
992
- if (v <= 0) {
993
- return a;
994
- }
995
- return a + Math.pow(v, 1 / Math.log2(this.starsPerDouble));
996
- }, 0), Math.log2(this.starsPerDouble));
772
+ this.difficulty = 0;
773
+ for (const strain of strains) {
774
+ this.difficulty += Math.pow(strain, 1 / Math.log2(this.starsPerDouble));
775
+ }
776
+ this.difficulty = Math.pow(this.difficulty, Math.log2(this.starsPerDouble));
777
+ return this.difficulty;
778
+ }
779
+ calculateCurrentSectionStart(current) {
780
+ return current.startTime;
997
781
  }
998
782
  }
999
783
 
@@ -1001,35 +785,40 @@ class DroidSkill extends StrainSkill {
1001
785
  * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
1002
786
  */
1003
787
  class DroidAim extends DroidSkill {
1004
- skillMultiplier = 24.55;
1005
- strainDecayBase = 0.15;
1006
- reducedSectionCount = 10;
1007
- reducedSectionBaseline = 0.75;
1008
- starsPerDouble = 1.05;
1009
- withSliders;
1010
788
  constructor(mods, withSliders) {
1011
789
  super(mods);
790
+ this.strainDecayBase = 0.15;
791
+ this.reducedSectionCount = 10;
792
+ this.reducedSectionBaseline = 0.75;
793
+ this.starsPerDouble = 1.05;
794
+ this.skillMultiplier = 24.55;
795
+ this.currentAimStrain = 0;
1012
796
  this.withSliders = withSliders;
1013
797
  }
1014
- /**
1015
- * @param current The hitobject to calculate.
1016
- */
1017
798
  strainValueAt(current) {
1018
- this.currentStrain *= this.strainDecay(current.deltaTime);
1019
- this.currentStrain +=
799
+ this.currentAimStrain *= this.strainDecay(current.deltaTime);
800
+ this.currentAimStrain +=
1020
801
  DroidAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
1021
802
  this.skillMultiplier;
1022
- return this.currentStrain;
803
+ return this.currentAimStrain;
804
+ }
805
+ calculateInitialStrain(time, current) {
806
+ var _a, _b;
807
+ return (this.currentAimStrain *
808
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
809
+ }
810
+ getObjectStrain() {
811
+ return this.currentAimStrain;
1023
812
  }
1024
813
  /**
1025
814
  * @param current The hitobject to save to.
1026
815
  */
1027
816
  saveToHitObject(current) {
1028
817
  if (this.withSliders) {
1029
- current.aimStrainWithSliders = this.currentStrain;
818
+ current.aimStrainWithSliders = this.currentAimStrain;
1030
819
  }
1031
820
  else {
1032
- current.aimStrainWithoutSliders = this.currentStrain;
821
+ current.aimStrainWithoutSliders = this.currentAimStrain;
1033
822
  }
1034
823
  }
1035
824
  }
@@ -1040,9 +829,9 @@ class DroidAim extends DroidSkill {
1040
829
  * This class should be considered an "evaluating" class and not persisted.
1041
830
  */
1042
831
  class SpeedEvaluator {
1043
- // ~200 1/4 BPM streams
1044
- static minSpeedBonus = 75;
1045
832
  }
833
+ // ~200 1/4 BPM streams
834
+ SpeedEvaluator.minSpeedBonus = 75;
1046
835
 
1047
836
  /**
1048
837
  * An evaluator for calculating osu!droid tap skill.
@@ -1053,43 +842,35 @@ class DroidTapEvaluator extends SpeedEvaluator {
1053
842
  *
1054
843
  * - time between pressing the previous and current object,
1055
844
  * - distance between those objects,
1056
- * - and how easily they can be cheesed.
845
+ * - how easily they can be cheesed,
846
+ * - and the strain time cap.
1057
847
  *
1058
848
  * @param current The current object.
1059
849
  * @param greatWindow The great hit window of the current object.
1060
850
  * @param considerCheesability Whether to consider cheesability.
851
+ * @param strainTimeCap The strain time to cap the object's strain time to.
1061
852
  */
1062
- static evaluateDifficultyOf(current, greatWindow, considerCheesability) {
853
+ static evaluateDifficultyOf(current, considerCheesability, strainTimeCap) {
1063
854
  if (current.object instanceof osuBase.Spinner ||
1064
855
  // Exclude overlapping objects that can be tapped at once.
1065
856
  current.isOverlapping(false)) {
1066
857
  return 0;
1067
858
  }
1068
- let strainTime = current.strainTime;
1069
- let doubletapness = 1;
1070
- if (considerCheesability) {
1071
- const greatWindowFull = greatWindow * 2;
1072
- // Nerf doubletappable doubles.
1073
- const next = current.next(0);
1074
- if (next) {
1075
- const currentDeltaTime = Math.max(1, current.deltaTime);
1076
- const nextDeltaTime = Math.max(1, next.deltaTime);
1077
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
1078
- const speedRatio = currentDeltaTime /
1079
- Math.max(currentDeltaTime, deltaDifference);
1080
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
1081
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
1082
- }
1083
- // Cap deltatime to the OD 300 hitwindow.
1084
- // 0.58 is derived from making sure 260 BPM 1/4 OD5 streams aren't nerfed harshly, whilst 0.91 limits the effect of the cap.
1085
- strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.58, 0.91, 1);
1086
- }
859
+ // Nerf doubletappable doubles.
860
+ const doubletapness = considerCheesability
861
+ ? 1 - current.doubletapness
862
+ : 1;
863
+ const strainTime = strainTimeCap !== undefined
864
+ ? // We cap the strain time to 50 here as the chance of vibro is higher in any BPM higher than 300.
865
+ Math.max(50, strainTimeCap, current.strainTime)
866
+ : current.strainTime;
1087
867
  let speedBonus = 1;
1088
868
  if (strainTime < this.minSpeedBonus) {
1089
869
  speedBonus +=
1090
- 0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
870
+ 0.75 *
871
+ Math.pow(osuBase.ErrorFunction.erf((this.minSpeedBonus - strainTime) / 40), 2);
1091
872
  }
1092
- return (speedBonus * doubletapness) / strainTime;
873
+ return (speedBonus * Math.pow(doubletapness, 1.5)) / strainTime;
1093
874
  }
1094
875
  }
1095
876
 
@@ -1097,38 +878,88 @@ class DroidTapEvaluator extends SpeedEvaluator {
1097
878
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
1098
879
  */
1099
880
  class DroidTap extends DroidSkill {
1100
- skillMultiplier = 1375;
1101
- reducedSectionCount = 10;
1102
- reducedSectionBaseline = 0.75;
1103
- strainDecayBase = 0.3;
1104
- starsPerDouble = 1.1;
1105
- currentTapStrain = 0;
1106
- currentOriginalTapStrain = 0;
1107
- greatWindow;
1108
- constructor(mods, overallDifficulty) {
881
+ /**
882
+ * The delta time of hitobjects.
883
+ */
884
+ get objectDeltaTimes() {
885
+ return this._objectDeltaTimes;
886
+ }
887
+ constructor(mods, considerCheesability, strainTimeCap) {
1109
888
  super(mods);
1110
- this.greatWindow = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
889
+ this.reducedSectionCount = 10;
890
+ this.reducedSectionBaseline = 0.75;
891
+ this.strainDecayBase = 0.3;
892
+ this.starsPerDouble = 1.1;
893
+ this.currentTapStrain = 0;
894
+ this.currentRhythmMultiplier = 0;
895
+ this.skillMultiplier = 1375;
896
+ this._objectDeltaTimes = [];
897
+ this.considerCheesability = considerCheesability;
898
+ this.strainTimeCap = strainTimeCap;
899
+ }
900
+ /**
901
+ * The amount of notes that are relevant to the difficulty.
902
+ */
903
+ relevantNoteCount() {
904
+ if (this._objectStrains.length === 0) {
905
+ return 0;
906
+ }
907
+ const maxStrain = Math.max(...this._objectStrains);
908
+ if (maxStrain === 0) {
909
+ return 0;
910
+ }
911
+ return this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
1111
912
  }
1112
913
  /**
1113
- * @param current The hitobject to calculate.
914
+ * The delta time relevant to the difficulty.
1114
915
  */
916
+ relevantDeltaTime() {
917
+ if (this._objectStrains.length === 0) {
918
+ return 0;
919
+ }
920
+ const maxStrain = Math.max(...this._objectStrains);
921
+ if (maxStrain === 0) {
922
+ return 0;
923
+ }
924
+ return (this._objectDeltaTimes.reduce((total, next, index) => total +
925
+ (next * 1) /
926
+ (1 +
927
+ Math.exp(-((this._objectStrains[index] / maxStrain) *
928
+ 25 -
929
+ 20))), 0) /
930
+ this._objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 25 - 20))), 0));
931
+ }
1115
932
  strainValueAt(current) {
1116
- const decay = this.strainDecay(current.strainTime);
1117
- this.currentTapStrain *= decay;
933
+ this.currentTapStrain *= this.strainDecay(current.strainTime);
1118
934
  this.currentTapStrain +=
1119
- DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, true) * this.skillMultiplier;
1120
- this.currentOriginalTapStrain *= decay;
1121
- this.currentOriginalTapStrain +=
1122
- DroidTapEvaluator.evaluateDifficultyOf(current, this.greatWindow, false) * this.skillMultiplier;
1123
- this.currentOriginalTapStrain *= current.rhythmMultiplier;
935
+ DroidTapEvaluator.evaluateDifficultyOf(current, this.considerCheesability, this.strainTimeCap) * this.skillMultiplier;
936
+ this.currentRhythmMultiplier = current.rhythmMultiplier;
937
+ this._objectDeltaTimes.push(current.deltaTime);
1124
938
  return this.currentTapStrain * current.rhythmMultiplier;
1125
939
  }
940
+ calculateInitialStrain(time, current) {
941
+ var _a, _b;
942
+ return (this.currentTapStrain *
943
+ this.currentRhythmMultiplier *
944
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
945
+ }
946
+ getObjectStrain() {
947
+ return this.currentTapStrain * this.currentRhythmMultiplier;
948
+ }
1126
949
  /**
1127
950
  * @param current The hitobject to save to.
1128
951
  */
1129
952
  saveToHitObject(current) {
1130
- current.tapStrain = this.currentStrain;
1131
- current.originalTapStrain = this.currentOriginalTapStrain;
953
+ if (this.strainTimeCap !== undefined) {
954
+ return;
955
+ }
956
+ const strain = this.currentTapStrain * this.currentRhythmMultiplier;
957
+ if (this.considerCheesability) {
958
+ current.tapStrain = strain;
959
+ }
960
+ else {
961
+ current.originalTapStrain = strain;
962
+ }
1132
963
  }
1133
964
  }
1134
965
 
@@ -1138,12 +969,12 @@ class DroidTap extends DroidSkill {
1138
969
  * This class should be considered an "evaluating" class and not persisted.
1139
970
  */
1140
971
  class FlashlightEvaluator {
1141
- static maxOpacityBonus = 0.4;
1142
- static hiddenBonus = 0.2;
1143
- static minVelocity = 0.5;
1144
- static sliderMultiplier = 1.3;
1145
- static minAngleMultiplier = 0.2;
1146
972
  }
973
+ FlashlightEvaluator.maxOpacityBonus = 0.4;
974
+ FlashlightEvaluator.hiddenBonus = 0.2;
975
+ FlashlightEvaluator.minVelocity = 0.5;
976
+ FlashlightEvaluator.sliderMultiplier = 1.3;
977
+ FlashlightEvaluator.minAngleMultiplier = 0.2;
1147
978
 
1148
979
  /**
1149
980
  * An evaluator for calculating osu!droid Flashlight skill.
@@ -1168,7 +999,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1168
999
  current.isOverlapping(true)) {
1169
1000
  return 0;
1170
1001
  }
1171
- const scalingFactor = 52 / current.object.getRadius(osuBase.Modes.droid);
1002
+ const scalingFactor = 52 / current.object.radius;
1172
1003
  let smallDistNerf = 1;
1173
1004
  let cumulativeStrainTime = 0;
1174
1005
  let result = 0;
@@ -1193,7 +1024,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1193
1024
  const opacityBonus = 1 +
1194
1025
  this.maxOpacityBonus *
1195
1026
  (1 -
1196
- current.opacityAt(currentObject.object.startTime, isHiddenMod, osuBase.Modes.droid));
1027
+ current.opacityAt(currentObject.object.startTime, isHiddenMod));
1197
1028
  result +=
1198
1029
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
1199
1030
  cumulativeStrainTime;
@@ -1224,8 +1055,8 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1224
1055
  // Longer sliders require more memorization.
1225
1056
  sliderBonus *= pixelTravelDistance;
1226
1057
  // Nerf sliders with repeats, as less memorization is required.
1227
- if (current.object.repeats > 0)
1228
- sliderBonus /= current.object.repeats + 1;
1058
+ if (current.object.repeatCount > 0)
1059
+ sliderBonus /= current.object.repeatCount + 1;
1229
1060
  }
1230
1061
  result += sliderBonus * this.sliderMultiplier;
1231
1062
  return result;
@@ -1236,72 +1067,98 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1236
1067
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
1237
1068
  */
1238
1069
  class DroidFlashlight extends DroidSkill {
1239
- skillMultiplier = 0.125;
1240
- strainDecayBase = 0.15;
1241
- reducedSectionCount = 0;
1242
- reducedSectionBaseline = 1;
1243
- starsPerDouble = 1.05;
1244
- isHidden;
1245
- withSliders;
1246
1070
  constructor(mods, withSliders) {
1247
1071
  super(mods);
1072
+ this.strainDecayBase = 0.15;
1073
+ this.reducedSectionCount = 0;
1074
+ this.reducedSectionBaseline = 1;
1075
+ this.starsPerDouble = 1.06;
1076
+ this.skillMultiplier = 0.02;
1077
+ this.currentFlashlightStrain = 0;
1248
1078
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1249
1079
  this.withSliders = withSliders;
1250
1080
  }
1251
- /**
1252
- * @param current The hitobject to calculate.
1253
- */
1254
1081
  strainValueAt(current) {
1255
- this.currentStrain *= this.strainDecay(current.deltaTime);
1256
- this.currentStrain +=
1082
+ this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
1083
+ this.currentFlashlightStrain +=
1257
1084
  DroidFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1258
- return this.currentStrain;
1085
+ return this.currentFlashlightStrain;
1086
+ }
1087
+ calculateInitialStrain(time, current) {
1088
+ var _a, _b;
1089
+ return (this.currentFlashlightStrain *
1090
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
1091
+ }
1092
+ getObjectStrain() {
1093
+ return this.currentFlashlightStrain;
1259
1094
  }
1260
1095
  saveToHitObject(current) {
1261
1096
  if (this.withSliders) {
1262
- current.flashlightStrainWithSliders = this.currentStrain;
1097
+ current.flashlightStrainWithSliders = this.currentFlashlightStrain;
1263
1098
  }
1264
1099
  else {
1265
- current.flashlightStrainWithoutSliders = this.currentStrain;
1100
+ current.flashlightStrainWithoutSliders =
1101
+ this.currentFlashlightStrain;
1266
1102
  }
1267
1103
  }
1104
+ difficultyValue() {
1105
+ return (this.strainPeaks.reduce((a, v) => a + v, 0) * this.starsPerDouble);
1106
+ }
1268
1107
  }
1269
1108
 
1270
- /**
1271
- * An evaluator for calculating rhythm skill.
1272
- *
1273
- * This class should be considered an "evaluating" class and not persisted.
1274
- */
1275
- class RhythmEvaluator {
1276
- static rhythmMultiplier = 0.75;
1277
- static historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1109
+ class Island {
1110
+ constructor(delta, deltaDifferenceEpsilon) {
1111
+ this.delta = Number.MAX_SAFE_INTEGER;
1112
+ this.deltaCount = 0;
1113
+ if (deltaDifferenceEpsilon === undefined) {
1114
+ this.deltaDifferenceEpsilon = delta;
1115
+ }
1116
+ else {
1117
+ this.deltaDifferenceEpsilon = deltaDifferenceEpsilon;
1118
+ this.addDelta(delta);
1119
+ }
1120
+ }
1121
+ addDelta(delta) {
1122
+ if (this.delta === Number.MAX_SAFE_INTEGER) {
1123
+ this.delta = Math.max(Math.trunc(delta), DifficultyHitObject.minDeltaTime);
1124
+ }
1125
+ ++this.deltaCount;
1126
+ }
1127
+ isSimilarPolarity(other) {
1128
+ // 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)
1129
+ // naively adding delta check here breaks _a lot_ of maps because of the flawed ratio calculation
1130
+ return this.deltaCount % 2 == other.deltaCount % 2;
1131
+ }
1132
+ equals(other) {
1133
+ return (Math.abs(this.delta - other.delta) < this.deltaDifferenceEpsilon &&
1134
+ this.deltaCount === other.deltaCount);
1135
+ }
1278
1136
  }
1279
1137
 
1280
1138
  /**
1281
1139
  * An evaluator for calculating osu!droid Rhythm skill.
1282
1140
  */
1283
- class DroidRhythmEvaluator extends RhythmEvaluator {
1141
+ class DroidRhythmEvaluator {
1284
1142
  /**
1285
1143
  * Calculates a rhythm multiplier for the difficulty of the tap associated
1286
1144
  * with historic data of the current object.
1287
1145
  *
1288
1146
  * @param current The current object.
1289
- * @param greatWindow The great hit window of the current object.
1290
1147
  */
1291
- static evaluateDifficultyOf(current, greatWindow) {
1292
- if (current.object instanceof osuBase.Spinner ||
1293
- // Exclude overlapping objects that can be tapped at once.
1294
- current.isOverlapping(false)) {
1148
+ static evaluateDifficultyOf(current) {
1149
+ if (current.object instanceof osuBase.Spinner) {
1295
1150
  return 1;
1296
1151
  }
1297
- let previousIslandSize = 0;
1152
+ const deltaDifferenceEpsilon = current.fullGreatWindow * 0.3;
1298
1153
  let rhythmComplexitySum = 0;
1299
- let islandSize = 1;
1154
+ let island = new Island(deltaDifferenceEpsilon);
1155
+ let previousIsland = new Island(deltaDifferenceEpsilon);
1156
+ const islandCounts = new Map();
1300
1157
  // Store the ratio of the current start of an island to buff for tighter rhythms.
1301
1158
  let startRatio = 0;
1302
1159
  let firstDeltaSwitch = false;
1303
1160
  let rhythmStart = 0;
1304
- const historicalNoteCount = Math.min(current.index, 32);
1161
+ const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
1305
1162
  // Exclude overlapping objects that can be tapped at once.
1306
1163
  const validPrevious = [];
1307
1164
  for (let i = 0; i < historicalNoteCount; ++i) {
@@ -1319,123 +1176,160 @@ class DroidRhythmEvaluator extends RhythmEvaluator {
1319
1176
  ++rhythmStart;
1320
1177
  }
1321
1178
  for (let i = rhythmStart; i > 0; --i) {
1179
+ const currentObject = validPrevious[i - 1];
1180
+ const prevObject = validPrevious[i];
1181
+ const lastObject = validPrevious[i + 1];
1322
1182
  // Scale note 0 to 1 from history to now.
1323
- let currentHistoricalDecay = (this.historyTimeMax -
1324
- (current.startTime - validPrevious[i - 1].startTime)) /
1183
+ const timeDecay = (this.historyTimeMax -
1184
+ (current.startTime - currentObject.startTime)) /
1325
1185
  this.historyTimeMax;
1186
+ const noteDecay = (validPrevious.length - i) / validPrevious.length;
1326
1187
  // Either we're limited by time or limited by object count.
1327
- currentHistoricalDecay = Math.min(currentHistoricalDecay, (validPrevious.length - i) / validPrevious.length);
1328
- const currentDelta = validPrevious[i - 1].strainTime;
1329
- const prevDelta = validPrevious[i].strainTime;
1330
- const lastDelta = validPrevious[i + 1].strainTime;
1188
+ const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
1189
+ const currentDelta = currentObject.strainTime;
1190
+ const prevDelta = prevObject.strainTime;
1191
+ const lastDelta = lastObject.strainTime;
1192
+ // Calculate how much current delta difference deserves a rhythm bonus
1193
+ // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
1194
+ const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
1195
+ Math.max(prevDelta, currentDelta);
1331
1196
  const currentRatio = 1 +
1332
- 6 *
1333
- Math.min(0.5, Math.pow(Math.sin(Math.PI /
1334
- (Math.min(prevDelta, currentDelta) /
1335
- Math.max(prevDelta, currentDelta))), 2));
1336
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.4) /
1337
- (greatWindow * 0.4));
1338
- let effectiveRatio = windowPenalty * currentRatio;
1197
+ this.rhythmRatioMultiplier *
1198
+ Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
1199
+ // Reduce ratio bonus if delta difference is too big
1200
+ const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
1201
+ const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
1202
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
1203
+ let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
1339
1204
  if (firstDeltaSwitch) {
1340
- if (prevDelta <= 1.25 * currentDelta &&
1341
- prevDelta * 1.25 >= currentDelta) {
1205
+ if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
1342
1206
  // Island is still progressing, count size.
1343
- if (islandSize < 7) {
1344
- ++islandSize;
1345
- }
1207
+ island.addDelta(currentDelta);
1346
1208
  }
1347
1209
  else {
1348
- if (validPrevious[i - 1].object instanceof osuBase.Slider) {
1349
- // BPM change is into slider, this is easy acc window.
1210
+ // BPM change is into slider, this is easy acc window.
1211
+ if (currentObject.object instanceof osuBase.Slider) {
1350
1212
  effectiveRatio /= 8;
1351
1213
  }
1352
- if (validPrevious[i].object instanceof osuBase.Slider) {
1353
- // BPM change was from a slider, this is typically easier than circle -> circle.
1354
- effectiveRatio /= 4;
1355
- }
1356
- if (previousIslandSize === islandSize) {
1357
- // Repeated island size (ex: triplet -> triplet).
1358
- effectiveRatio /= 4;
1214
+ // BPM change was from a slider, this is easier typically than circle -> circle.
1215
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
1216
+ // than bursts without sliders.
1217
+ if (prevObject.object instanceof osuBase.Slider) {
1218
+ effectiveRatio *= 0.3;
1359
1219
  }
1360
- if (previousIslandSize % 2 === islandSize % 2) {
1361
- // Repeated island polarity (2 -> 4, 3 -> 5).
1220
+ // Repeated island polarity (2 -> 4, 3 -> 5).
1221
+ if (island.isSimilarPolarity(previousIsland)) {
1362
1222
  effectiveRatio /= 2;
1363
1223
  }
1364
- if (lastDelta > prevDelta + 10 &&
1365
- prevDelta > currentDelta + 10) {
1366
- // Previous increase happened a note ago.
1367
- // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
1224
+ // Previous increase happened a note ago.
1225
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
1226
+ if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
1227
+ prevDelta > currentDelta + deltaDifferenceEpsilon) {
1368
1228
  effectiveRatio /= 8;
1369
1229
  }
1230
+ // Repeated island size (ex: triplet -> triplet).
1231
+ // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
1232
+ if (previousIsland.deltaCount == island.deltaCount) {
1233
+ effectiveRatio /= 2;
1234
+ }
1235
+ let islandFound = false;
1236
+ for (const [currentIsland, count] of islandCounts) {
1237
+ if (!island.equals(currentIsland)) {
1238
+ continue;
1239
+ }
1240
+ islandFound = true;
1241
+ let islandCount = count;
1242
+ if (previousIsland.equals(island)) {
1243
+ // Only add island to island counts if they're going one after another.
1244
+ ++islandCount;
1245
+ islandCounts.set(currentIsland, islandCount);
1246
+ }
1247
+ // Repeated island (ex: triplet -> triplet).
1248
+ // Graph: https://www.desmos.com/calculator/pj7an56zwf
1249
+ effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
1250
+ break;
1251
+ }
1252
+ if (!islandFound) {
1253
+ islandCounts.set(island, 1);
1254
+ }
1255
+ // Scale down the difficulty if the object is doubletappable.
1256
+ effectiveRatio *= 1 - prevObject.doubletapness * 0.75;
1370
1257
  rhythmComplexitySum +=
1371
- (((Math.sqrt(effectiveRatio * startRatio) *
1372
- currentHistoricalDecay *
1373
- Math.sqrt(4 + islandSize)) /
1374
- 2) *
1375
- Math.sqrt(4 + previousIslandSize)) /
1376
- 2;
1258
+ Math.sqrt(effectiveRatio * startRatio) *
1259
+ currentHistoricalDecay;
1377
1260
  startRatio = effectiveRatio;
1378
- previousIslandSize = islandSize;
1379
- if (prevDelta * 1.25 < currentDelta) {
1261
+ previousIsland = island;
1262
+ if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
1380
1263
  // We're slowing down, stop counting.
1381
1264
  // If we're speeding up, this stays as is and we keep counting island size.
1382
1265
  firstDeltaSwitch = false;
1383
1266
  }
1384
- islandSize = 1;
1267
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
1385
1268
  }
1386
1269
  }
1387
- else if (prevDelta > 1.25 * currentDelta) {
1388
- // We want to be speeding up.
1270
+ else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
1271
+ // We are speeding up.
1389
1272
  // Begin counting island until we change speed again.
1390
1273
  firstDeltaSwitch = true;
1274
+ // BPM change is into slider, this is easy acc window.
1275
+ if (currentObject.object instanceof osuBase.Slider) {
1276
+ effectiveRatio *= 0.6;
1277
+ }
1278
+ // BPM change was from a slider, this is easier typically than circle -> circle
1279
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
1280
+ // than bursts without sliders
1281
+ if (prevObject.object instanceof osuBase.Slider) {
1282
+ effectiveRatio *= 0.6;
1283
+ }
1391
1284
  startRatio = effectiveRatio;
1392
- islandSize = 1;
1285
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
1393
1286
  }
1394
1287
  }
1395
- // Nerf doubles that can be tapped at the same time to get Great hit results.
1396
- const next = current.next(0);
1397
- let doubletapness = 1;
1398
- if (next) {
1399
- const currentDeltaTime = Math.max(1, current.deltaTime);
1400
- const nextDeltaTime = Math.max(1, next.deltaTime);
1401
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
1402
- const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
1403
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / (greatWindow * 2)), 2);
1404
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
1405
- }
1406
- return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier * doubletapness) / 2);
1288
+ return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
1289
+ 2);
1407
1290
  }
1408
1291
  }
1292
+ DroidRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1293
+ DroidRhythmEvaluator.historyObjectsMax = 32;
1294
+ DroidRhythmEvaluator.rhythmOverallMultiplier = 0.95;
1295
+ DroidRhythmEvaluator.rhythmRatioMultiplier = 12;
1409
1296
 
1410
1297
  /**
1411
1298
  * Represents the skill required to properly follow a beatmap's rhythm.
1412
1299
  */
1413
1300
  class DroidRhythm extends DroidSkill {
1414
- skillMultiplier = 1;
1415
- reducedSectionCount = 5;
1416
- reducedSectionBaseline = 0.75;
1417
- strainDecayBase = 0.3;
1418
- starsPerDouble = 1.75;
1419
- currentRhythm = 1;
1420
- hitWindow;
1421
- constructor(mods, overallDifficulty) {
1422
- super(mods);
1423
- this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
1301
+ constructor() {
1302
+ super(...arguments);
1303
+ this.reducedSectionCount = 5;
1304
+ this.reducedSectionBaseline = 0.75;
1305
+ this.strainDecayBase = 0.3;
1306
+ this.starsPerDouble = 1.75;
1307
+ this.currentRhythmStrain = 0;
1308
+ this.currentRhythmMultiplier = 1;
1424
1309
  }
1425
1310
  strainValueAt(current) {
1426
- this.currentRhythm = DroidRhythmEvaluator.evaluateDifficultyOf(current, this.hitWindow.hitWindowFor300());
1427
- this.currentStrain *= this.strainDecay(current.deltaTime);
1428
- this.currentStrain += this.currentRhythm - 1;
1429
- return this.currentStrain;
1311
+ this.currentRhythmMultiplier =
1312
+ DroidRhythmEvaluator.evaluateDifficultyOf(current);
1313
+ this.currentRhythmStrain *= this.strainDecay(current.deltaTime);
1314
+ this.currentRhythmStrain += this.currentRhythmMultiplier - 1;
1315
+ return this.currentRhythmStrain;
1316
+ }
1317
+ calculateInitialStrain(time, current) {
1318
+ var _a, _b;
1319
+ return (this.currentRhythmStrain *
1320
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
1321
+ }
1322
+ getObjectStrain() {
1323
+ return this.currentRhythmStrain;
1430
1324
  }
1431
1325
  saveToHitObject(current) {
1432
- current.rhythmStrain = this.currentStrain;
1433
- current.rhythmMultiplier = this.currentRhythm;
1326
+ current.rhythmStrain = this.currentRhythmStrain;
1327
+ current.rhythmMultiplier = this.currentRhythmMultiplier;
1434
1328
  }
1435
1329
  }
1436
1330
 
1437
1331
  /**
1438
- * An evaluator for calculating osu!droid Visual skill.
1332
+ * An evaluator for calculating osu!droid visual skill.
1439
1333
  */
1440
1334
  class DroidVisualEvaluator {
1441
1335
  /**
@@ -1456,7 +1350,8 @@ class DroidVisualEvaluator {
1456
1350
  static evaluateDifficultyOf(current, isHiddenMod, withSliders) {
1457
1351
  if (current.object instanceof osuBase.Spinner ||
1458
1352
  // Exclude overlapping objects that can be tapped at once.
1459
- current.isOverlapping(true)) {
1353
+ current.isOverlapping(true) ||
1354
+ current.index === 0) {
1460
1355
  return 0;
1461
1356
  }
1462
1357
  // Start with base density and give global bonus for Hidden.
@@ -1478,28 +1373,31 @@ class DroidVisualEvaluator {
1478
1373
  }
1479
1374
  // Do not consider objects that don't fall under time preempt.
1480
1375
  if (current.object.startTime - previous.object.endTime >
1481
- current.baseTimePreempt) {
1376
+ current.object.timePreempt) {
1482
1377
  break;
1483
1378
  }
1484
1379
  strain +=
1485
1380
  (1 -
1486
- current.opacityAt(previous.object.startTime, isHiddenMod, osuBase.Modes.droid)) /
1381
+ current.opacityAt(previous.object.startTime, isHiddenMod)) /
1487
1382
  4;
1488
1383
  }
1489
- // Scale the value with overlapping factor.
1490
- strain /= 10 * (1 + current.overlappingFactor);
1491
1384
  if (current.timePreempt < 400) {
1492
1385
  // Give bonus for AR higher than 10.33.
1493
- strain += Math.pow(400 - current.timePreempt, 1.3) / 100;
1386
+ strain += Math.pow(400 - current.timePreempt, 1.35) / 100;
1494
1387
  }
1388
+ // Scale the value with overlapping factor.
1389
+ strain /= 10 * (1 + current.overlappingFactor);
1495
1390
  if (current.object instanceof osuBase.Slider && withSliders) {
1496
- const scalingFactor = 50 / current.object.getRadius(osuBase.Modes.droid);
1497
- // Reward sliders based on velocity.
1391
+ const scalingFactor = 50 / current.object.radius;
1392
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1393
+ const pixelTravelDistance = current.object.lazyTravelDistance / scalingFactor;
1394
+ const currentVelocity = pixelTravelDistance / current.travelTime;
1395
+ const spanTravelDistance = pixelTravelDistance / current.object.spanCount;
1498
1396
  strain +=
1499
- // Avoid overbuffing extremely fast sliders.
1500
- Math.min(6, current.velocity * 1.5) *
1501
- // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1502
- Math.min(1, current.travelDistance / scalingFactor / 125);
1397
+ // Reward sliders based on velocity, while also avoiding overbuffing extremely fast sliders.
1398
+ Math.min(6, currentVelocity * 1.5) *
1399
+ // Longer sliders require more reading.
1400
+ (spanTravelDistance / 100);
1503
1401
  let cumulativeStrainTime = 0;
1504
1402
  // Reward for velocity changes based on last few sliders.
1505
1403
  for (let i = 0; i < Math.min(current.index, 4); ++i) {
@@ -1510,52 +1408,19 @@ class DroidVisualEvaluator {
1510
1408
  last.isOverlapping(true)) {
1511
1409
  continue;
1512
1410
  }
1411
+ // Invert the scaling factor to determine the true travel distance independent of circle size.
1412
+ const lastPixelTravelDistance = last.object.lazyTravelDistance / scalingFactor;
1413
+ const lastVelocity = lastPixelTravelDistance / last.travelTime;
1414
+ const lastSpanTravelDistance = lastPixelTravelDistance / last.object.spanCount;
1513
1415
  strain +=
1514
- // Avoid overbuffing extremely fast velocity changes.
1515
- Math.min(10, 2.5 * Math.abs(current.velocity - last.velocity)) *
1516
- // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1517
- Math.min(1, last.travelDistance / scalingFactor / 100) *
1518
- // Scale with cumulative strain time to avoid overbuffing past sliders.
1519
- Math.min(1, 300 / cumulativeStrainTime);
1520
- }
1521
- }
1522
- // Reward for rhythm changes.
1523
- if (current.rhythmMultiplier > 1) {
1524
- let rhythmBonus = (current.rhythmMultiplier - 1) / 20;
1525
- // Rhythm changes are harder to read in Hidden.
1526
- // Add additional bonus for Hidden.
1527
- if (isHiddenMod) {
1528
- rhythmBonus += (current.rhythmMultiplier - 1) / 25;
1529
- }
1530
- // Rhythm changes are harder to read when objects are stacked together.
1531
- // Scale rhythm bonus based on the stack of past objects.
1532
- const diameter = 2 * current.object.getRadius(osuBase.Modes.droid);
1533
- let cumulativeStrainTime = 0;
1534
- for (let i = 0; i < Math.min(current.index, 5); ++i) {
1535
- const previous = current.previous(i);
1536
- if (previous.object instanceof osuBase.Spinner ||
1537
- // Exclude overlapping objects that can be tapped at once.
1538
- previous.isOverlapping(true)) {
1539
- continue;
1540
- }
1541
- const jumpDistance = current.object
1542
- .getStackedPosition(osuBase.Modes.droid)
1543
- .getDistance(previous.object.getStackedEndPosition(osuBase.Modes.droid));
1544
- cumulativeStrainTime += previous.strainTime;
1545
- rhythmBonus +=
1546
- // Scale the bonus with diameter.
1547
- osuBase.MathUtils.clamp((0.5 - jumpDistance / diameter) / 10, 0, 0.05) *
1548
- // Scale with cumulative strain time to avoid overbuffing past objects.
1416
+ // Reward past sliders based on velocity changes, while also
1417
+ // avoiding overbuffing extremely fast velocity changes.
1418
+ Math.min(10, 2.5 * Math.abs(currentVelocity - lastVelocity)) *
1419
+ // Longer sliders require more reading.
1420
+ (lastSpanTravelDistance / 125) *
1421
+ // Avoid overbuffing past sliders.
1549
1422
  Math.min(1, 300 / cumulativeStrainTime);
1550
- // Give a larger bonus for Hidden.
1551
- if (isHiddenMod) {
1552
- rhythmBonus +=
1553
- (1 -
1554
- current.opacityAt(previous.object.startTime, isHiddenMod, osuBase.Modes.droid)) /
1555
- 20;
1556
- }
1557
1423
  }
1558
- strain += rhythmBonus;
1559
1424
  }
1560
1425
  return strain;
1561
1426
  }
@@ -1565,31 +1430,239 @@ class DroidVisualEvaluator {
1565
1430
  * Represents the skill required to read every object in the map.
1566
1431
  */
1567
1432
  class DroidVisual extends DroidSkill {
1568
- starsPerDouble = 1.025;
1569
- reducedSectionCount = 10;
1570
- reducedSectionBaseline = 0.75;
1571
- skillMultiplier = 10;
1572
- strainDecayBase = 0.1;
1573
- isHidden;
1574
- withSliders;
1575
1433
  constructor(mods, withSliders) {
1576
1434
  super(mods);
1435
+ this.starsPerDouble = 1.025;
1436
+ this.reducedSectionCount = 10;
1437
+ this.reducedSectionBaseline = 0.75;
1438
+ this.strainDecayBase = 0.1;
1439
+ this.currentVisualStrain = 0;
1440
+ this.currentRhythmMultiplier = 1;
1441
+ this.skillMultiplier = 10;
1577
1442
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1578
1443
  this.withSliders = withSliders;
1579
1444
  }
1580
1445
  strainValueAt(current) {
1581
- this.currentStrain *= this.strainDecay(current.deltaTime);
1582
- this.currentStrain +=
1446
+ this.currentVisualStrain *= this.strainDecay(current.deltaTime);
1447
+ this.currentVisualStrain +=
1583
1448
  DroidVisualEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1584
- return this.currentStrain;
1449
+ this.currentRhythmMultiplier = current.rhythmMultiplier;
1450
+ return this.currentVisualStrain * this.currentRhythmMultiplier;
1451
+ }
1452
+ calculateInitialStrain(time, current) {
1453
+ var _a, _b;
1454
+ return (this.currentVisualStrain *
1455
+ this.currentRhythmMultiplier *
1456
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
1457
+ }
1458
+ getObjectStrain() {
1459
+ return this.currentVisualStrain * this.currentRhythmMultiplier;
1585
1460
  }
1586
1461
  saveToHitObject(current) {
1462
+ const strain = this.currentVisualStrain * this.currentRhythmMultiplier;
1587
1463
  if (this.withSliders) {
1588
- current.visualStrainWithSliders = this.currentStrain;
1464
+ current.visualStrainWithSliders = strain;
1589
1465
  }
1590
1466
  else {
1591
- current.visualStrainWithoutSliders = this.currentStrain;
1467
+ current.visualStrainWithoutSliders = strain;
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ /**
1473
+ * Represents an osu!droid hit object with difficulty calculation values.
1474
+ */
1475
+ class DroidDifficultyHitObject extends DifficultyHitObject {
1476
+ get scalingFactor() {
1477
+ const radius = this.object.radius;
1478
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
1479
+ let scalingFactor = this.normalizedRadius / radius;
1480
+ // High circle size (small CS) bonus
1481
+ if (radius < this.radiusBuffThreshold) {
1482
+ scalingFactor *=
1483
+ 1 + Math.pow((this.radiusBuffThreshold - radius) / 50, 2);
1484
+ }
1485
+ return scalingFactor;
1486
+ }
1487
+ /**
1488
+ * Note: You **must** call `computeProperties` at some point due to how TypeScript handles
1489
+ * overridden properties (see [this](https://github.com/microsoft/TypeScript/issues/1617) GitHub issue).
1490
+ *
1491
+ * @param object The underlying hitobject.
1492
+ * @param lastObject The hitobject before this hitobject.
1493
+ * @param lastLastObject The hitobject before the last hitobject.
1494
+ * @param difficultyHitObjects All difficulty hitobjects in the processed beatmap.
1495
+ * @param clockRate The clock rate of the beatmap.
1496
+ * @param greatWindow The great window of the hitobject.
1497
+ */
1498
+ constructor(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow) {
1499
+ super(object, lastObject, lastLastObject, difficultyHitObjects, clockRate, greatWindow);
1500
+ /**
1501
+ * The tap strain generated by the hitobject.
1502
+ */
1503
+ this.tapStrain = 0;
1504
+ /**
1505
+ * The tap strain generated by the hitobject if `strainTime` isn't modified by
1506
+ * OD. This is used in three-finger detection.
1507
+ */
1508
+ this.originalTapStrain = 0;
1509
+ /**
1510
+ * The rhythm strain generated by the hitobject.
1511
+ */
1512
+ this.rhythmStrain = 0;
1513
+ /**
1514
+ * The flashlight strain generated by the hitobject if sliders are considered.
1515
+ */
1516
+ this.flashlightStrainWithSliders = 0;
1517
+ /**
1518
+ * The flashlight strain generated by the hitobject if sliders are not considered.
1519
+ */
1520
+ this.flashlightStrainWithoutSliders = 0;
1521
+ /**
1522
+ * The visual strain generated by the hitobject if sliders are considered.
1523
+ */
1524
+ this.visualStrainWithSliders = 0;
1525
+ /**
1526
+ * The visual strain generated by the hitobject if sliders are not considered.
1527
+ */
1528
+ this.visualStrainWithoutSliders = 0;
1529
+ /**
1530
+ * The note density of the hitobject.
1531
+ */
1532
+ this.noteDensity = 1;
1533
+ /**
1534
+ * The overlapping factor of the hitobject.
1535
+ *
1536
+ * This is used to scale visual skill.
1537
+ */
1538
+ this.overlappingFactor = 0;
1539
+ this.radiusBuffThreshold = 70;
1540
+ this.mode = osuBase.Modes.droid;
1541
+ this.maximumSliderRadius = this.normalizedRadius * 2;
1542
+ this.timePreempt = object.timePreempt / clockRate;
1543
+ }
1544
+ computeProperties(clockRate, hitObjects) {
1545
+ super.computeProperties(clockRate, hitObjects);
1546
+ this.setVisuals(clockRate, hitObjects);
1547
+ }
1548
+ /**
1549
+ * Determines whether this hitobject is considered overlapping with the hitobject before it.
1550
+ *
1551
+ * Keep in mind that "overlapping" in this case is overlapping to the point where both hitobjects
1552
+ * can be hit with just a single tap in osu!droid.
1553
+ *
1554
+ * In the case of sliders, it is considered overlapping if all nested hitobjects can be hit with
1555
+ * one aim motion.
1556
+ *
1557
+ * @param considerDistance Whether to consider the distance between both hitobjects.
1558
+ * @returns Whether the hitobject is considered overlapping.
1559
+ */
1560
+ isOverlapping(considerDistance) {
1561
+ if (this.object instanceof osuBase.Spinner) {
1562
+ return false;
1563
+ }
1564
+ const prev = this.previous(0);
1565
+ if (!prev || prev.object instanceof osuBase.Spinner) {
1566
+ return false;
1567
+ }
1568
+ if (this.object.startTime !== prev.object.startTime) {
1569
+ return false;
1592
1570
  }
1571
+ if (!considerDistance) {
1572
+ return true;
1573
+ }
1574
+ const distanceThreshold = 2 * this.object.radius;
1575
+ const startPosition = this.object.getStackedPosition(osuBase.Modes.droid);
1576
+ const prevStartPosition = prev.object.getStackedPosition(osuBase.Modes.droid);
1577
+ // We need to consider two cases:
1578
+ //
1579
+ // Case 1: Current object is a circle, or previous object is a circle.
1580
+ // In this case, we only need to check if their positions are close enough to be tapped together.
1581
+ //
1582
+ // Case 2: Both objects are sliders.
1583
+ // In this case, we need to check if all nested hitobjects can be hit together.
1584
+ // To start with, check if the starting positions can be tapped together.
1585
+ if (startPosition.getDistance(prevStartPosition) > distanceThreshold) {
1586
+ return false;
1587
+ }
1588
+ if (this.object instanceof osuBase.Circle || prev.object instanceof osuBase.Circle) {
1589
+ return true;
1590
+ }
1591
+ // Check if all nested hitobjects can be hit together.
1592
+ for (let i = 1; i < this.object.nestedHitObjects.length; ++i) {
1593
+ const position = this.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1594
+ const prevPosition = prevStartPosition.add(prev.object.curvePositionAt(i / (this.object.nestedHitObjects.length - 1)));
1595
+ if (position.getDistance(prevPosition) > distanceThreshold) {
1596
+ return false;
1597
+ }
1598
+ }
1599
+ // Do the same for the previous slider as well.
1600
+ for (let i = 1; i < prev.object.nestedHitObjects.length; ++i) {
1601
+ const prevPosition = prev.object.nestedHitObjects[i].getStackedPosition(osuBase.Modes.droid);
1602
+ const position = startPosition.add(this.object.curvePositionAt(i / (prev.object.nestedHitObjects.length - 1)));
1603
+ if (prevPosition.getDistance(position) > distanceThreshold) {
1604
+ return false;
1605
+ }
1606
+ }
1607
+ return true;
1608
+ }
1609
+ setVisuals(clockRate, hitObjects) {
1610
+ // We'll have two visible object arrays. The first array contains objects before the current object starts in a reversed order,
1611
+ // while the second array contains objects after the current object ends.
1612
+ // For overlapping factor, we also need to consider previous visible objects.
1613
+ const prevVisibleObjects = [];
1614
+ const nextVisibleObjects = [];
1615
+ for (let j = this.index + 2; j < hitObjects.length; ++j) {
1616
+ const o = hitObjects[j];
1617
+ if (o instanceof osuBase.Spinner) {
1618
+ continue;
1619
+ }
1620
+ if (o.startTime / clockRate > this.endTime + this.timePreempt) {
1621
+ break;
1622
+ }
1623
+ nextVisibleObjects.push(o);
1624
+ }
1625
+ for (let j = 0; j < this.index; ++j) {
1626
+ const prev = this.previous(j);
1627
+ if (prev.object instanceof osuBase.Spinner) {
1628
+ continue;
1629
+ }
1630
+ if (prev.startTime >= this.startTime) {
1631
+ continue;
1632
+ }
1633
+ if (prev.startTime < this.startTime - this.timePreempt) {
1634
+ break;
1635
+ }
1636
+ prevVisibleObjects.push(prev.object);
1637
+ }
1638
+ for (const hitObject of prevVisibleObjects) {
1639
+ const distance = this.object
1640
+ .getStackedPosition(this.mode)
1641
+ .getDistance(hitObject.getStackedEndPosition(this.mode));
1642
+ const deltaTime = this.startTime - hitObject.endTime / clockRate;
1643
+ this.applyToOverlappingFactor(distance, deltaTime);
1644
+ }
1645
+ for (const hitObject of nextVisibleObjects) {
1646
+ const distance = hitObject
1647
+ .getStackedPosition(this.mode)
1648
+ .getDistance(this.object.getStackedEndPosition(this.mode));
1649
+ const deltaTime = hitObject.startTime / clockRate - this.endTime;
1650
+ if (deltaTime >= 0) {
1651
+ this.noteDensity += 1 - deltaTime / this.timePreempt;
1652
+ }
1653
+ this.applyToOverlappingFactor(distance, deltaTime);
1654
+ }
1655
+ }
1656
+ applyToOverlappingFactor(distance, deltaTime) {
1657
+ // Penalize objects that are too close to the object in both distance
1658
+ // and delta time to prevent stream maps from being overweighted.
1659
+ this.overlappingFactor +=
1660
+ Math.max(0, 1 - distance / (2.5 * this.object.radius)) *
1661
+ (7.5 /
1662
+ (1 +
1663
+ Math.exp(0.15 *
1664
+ (Math.max(deltaTime, DifficultyHitObject.minDeltaTime) -
1665
+ 75))));
1593
1666
  }
1594
1667
  }
1595
1668
 
@@ -1597,56 +1670,77 @@ class DroidVisual extends DroidSkill {
1597
1670
  * A difficulty calculator for osu!droid gamemode.
1598
1671
  */
1599
1672
  class DroidDifficultyCalculator extends DifficultyCalculator {
1673
+ constructor() {
1674
+ super(...arguments);
1675
+ this.attributes = {
1676
+ mode: "live",
1677
+ tapDifficulty: 0,
1678
+ rhythmDifficulty: 0,
1679
+ visualDifficulty: 0,
1680
+ aimNoteCount: 0,
1681
+ mods: [],
1682
+ starRating: 0,
1683
+ maxCombo: 0,
1684
+ aimDifficulty: 0,
1685
+ flashlightDifficulty: 0,
1686
+ speedNoteCount: 0,
1687
+ sliderFactor: 0,
1688
+ clockRate: 1,
1689
+ overallDifficulty: 0,
1690
+ hitCircleCount: 0,
1691
+ sliderCount: 0,
1692
+ spinnerCount: 0,
1693
+ aimDifficultStrainCount: 0,
1694
+ tapDifficultStrainCount: 0,
1695
+ flashlightDifficultStrainCount: 0,
1696
+ visualDifficultStrainCount: 0,
1697
+ flashlightSliderFactor: 0,
1698
+ visualSliderFactor: 0,
1699
+ possibleThreeFingeredSections: [],
1700
+ difficultSliders: [],
1701
+ averageSpeedDeltaTime: 0,
1702
+ vibroFactor: 1,
1703
+ };
1704
+ this.difficultyMultiplier = 0.18;
1705
+ this.mode = osuBase.Modes.droid;
1706
+ }
1600
1707
  /**
1601
1708
  * The aim star rating of the beatmap.
1602
1709
  */
1603
- aim = 0;
1710
+ get aim() {
1711
+ return this.attributes.aimDifficulty;
1712
+ }
1604
1713
  /**
1605
1714
  * The tap star rating of the beatmap.
1606
1715
  */
1607
- tap = 0;
1716
+ get tap() {
1717
+ return this.attributes.tapDifficulty;
1718
+ }
1608
1719
  /**
1609
1720
  * The rhythm star rating of the beatmap.
1610
1721
  */
1611
- rhythm = 0;
1722
+ get rhythm() {
1723
+ return this.attributes.rhythmDifficulty;
1724
+ }
1612
1725
  /**
1613
1726
  * The flashlight star rating of the beatmap.
1614
1727
  */
1615
- flashlight = 0;
1728
+ get flashlight() {
1729
+ return this.attributes.flashlightDifficulty;
1730
+ }
1616
1731
  /**
1617
1732
  * The visual star rating of the beatmap.
1618
1733
  */
1619
- visual = 0;
1620
- /**
1621
- * The strain threshold to start detecting for possible three-fingered section.
1622
- *
1623
- * Increasing this number will result in less sections being flagged.
1624
- */
1625
- static threeFingerStrainThreshold = 175;
1626
- attributes = {
1627
- tapDifficulty: 0,
1628
- rhythmDifficulty: 0,
1629
- visualDifficulty: 0,
1630
- aimNoteCount: 0,
1631
- mods: [],
1632
- starRating: 0,
1633
- maxCombo: 0,
1634
- aimDifficulty: 0,
1635
- flashlightDifficulty: 0,
1636
- speedNoteCount: 0,
1637
- sliderFactor: 0,
1638
- approachRate: 0,
1639
- overallDifficulty: 0,
1640
- hitCircleCount: 0,
1641
- sliderCount: 0,
1642
- spinnerCount: 0,
1643
- flashlightSliderFactor: 0,
1644
- visualSliderFactor: 0,
1645
- possibleThreeFingeredSections: [],
1646
- difficultSliders: [],
1647
- };
1648
- difficultyMultiplier = 0.18;
1649
- mode = osuBase.Modes.droid;
1734
+ get visual() {
1735
+ return this.attributes.visualDifficulty;
1736
+ }
1737
+ get cacheableAttributes() {
1738
+ return Object.assign(Object.assign({}, this.attributes), { mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods) });
1739
+ }
1740
+ // Override to use DroidDifficultyCalculationOptions
1741
+ calculate(options) {
1742
+ return super.calculate(options);
1743
+ }
1650
1744
  /**
1651
1745
  * Calculates the aim star rating of the beatmap and stores it in this instance.
1652
1746
  */
@@ -1660,26 +1754,18 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1660
1754
  * Calculates the tap star rating of the beatmap and stores it in this instance.
1661
1755
  */
1662
1756
  calculateTap() {
1663
- const tapSkill = new DroidTap(this.mods, this.stats.od);
1664
- this.calculateSkills(tapSkill);
1665
- if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1666
- this.tap = this.attributes.tapDifficulty = 0;
1667
- this.attributes.possibleThreeFingeredSections = [];
1668
- }
1669
- else {
1670
- this.postCalculateTap(tapSkill);
1671
- }
1672
- this.calculateSpeedAttributes();
1757
+ const tapSkillCheese = new DroidTap(this.mods, true);
1758
+ const tapSkillNoCheese = new DroidTap(this.mods, false);
1759
+ this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
1760
+ const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1761
+ this.calculateSkills(tapSkillVibro);
1762
+ this.postCalculateTap(tapSkillCheese, tapSkillVibro);
1673
1763
  }
1674
1764
  /**
1675
1765
  * Calculates the rhythm star rating of the beatmap and stores it in this instance.
1676
1766
  */
1677
1767
  calculateRhythm() {
1678
- if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1679
- this.rhythm = this.attributes.rhythmDifficulty = 0;
1680
- return;
1681
- }
1682
- const rhythmSkill = new DroidRhythm(this.mods, this.stats.od);
1768
+ const rhythmSkill = new DroidRhythm(this.mods);
1683
1769
  this.calculateSkills(rhythmSkill);
1684
1770
  this.postCalculateRhythm(rhythmSkill);
1685
1771
  }
@@ -1697,7 +1783,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1697
1783
  */
1698
1784
  calculateVisual() {
1699
1785
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1700
- this.visual = this.attributes.visualDifficulty = 0;
1786
+ this.attributes.visualDifficulty = 0;
1701
1787
  return;
1702
1788
  }
1703
1789
  const visualSkill = new DroidVisual(this.mods, true);
@@ -1719,55 +1805,35 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1719
1805
  if (basePerformanceValue > 1e-5) {
1720
1806
  // Document for formula derivation:
1721
1807
  // https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
1722
- this.total = this.attributes.starRating =
1808
+ this.attributes.starRating =
1723
1809
  0.027 *
1724
1810
  (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
1725
1811
  4);
1726
1812
  }
1727
1813
  else {
1728
- this.total = this.attributes.starRating = 0;
1814
+ this.attributes.starRating = 0;
1729
1815
  }
1730
1816
  }
1731
1817
  calculateAll() {
1732
1818
  const skills = this.createSkills();
1733
- const isRelax = this.mods.some((m) => m instanceof osuBase.ModRelax);
1734
- if (isRelax) {
1735
- // Remove visual skills to reduce overhead.
1736
- skills.pop();
1737
- skills.pop();
1738
- }
1739
1819
  this.calculateSkills(...skills);
1740
1820
  const aimSkill = skills[0];
1741
1821
  const aimSkillWithoutSliders = skills[1];
1742
1822
  const rhythmSkill = skills[2];
1743
- const tapSkill = skills[3];
1744
- const flashlightSkill = skills[4];
1745
- const flashlightSkillWithoutSliders = skills[5];
1746
- const visualSkill = skills[6] ?? null;
1747
- const visualSkillWithoutSliders = skills[7] ?? null;
1823
+ const tapSkillCheese = skills[3];
1824
+ const flashlightSkill = skills[5];
1825
+ const flashlightSkillWithoutSliders = skills[6];
1826
+ const visualSkill = skills[7];
1827
+ const visualSkillWithoutSliders = skills[8];
1828
+ const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1829
+ this.calculateSkills(tapSkillVibro);
1748
1830
  this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1749
- if (isRelax) {
1750
- this.tap = this.attributes.tapDifficulty = 0;
1751
- }
1752
- else {
1753
- this.postCalculateTap(tapSkill);
1754
- }
1755
- this.calculateSpeedAttributes();
1756
- if (!isRelax) {
1757
- this.postCalculateRhythm(rhythmSkill);
1758
- }
1831
+ this.postCalculateTap(tapSkillCheese, tapSkillVibro);
1832
+ this.postCalculateRhythm(rhythmSkill);
1759
1833
  this.postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders);
1760
- if (visualSkill && visualSkillWithoutSliders) {
1761
- this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1762
- }
1763
- else {
1764
- this.visual = this.attributes.visualDifficulty = 0;
1765
- }
1834
+ this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1766
1835
  this.calculateTotal();
1767
1836
  }
1768
- /**
1769
- * Returns a string representative of the class.
1770
- */
1771
1837
  toString() {
1772
1838
  return (this.total.toFixed(2) +
1773
1839
  " stars (" +
@@ -1782,22 +1848,39 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1782
1848
  this.visual.toFixed(2) +
1783
1849
  " visual)");
1784
1850
  }
1785
- /**
1786
- * Creates skills to be calculated.
1787
- */
1851
+ generateDifficultyHitObjects(beatmap, clockRate) {
1852
+ var _a, _b;
1853
+ const difficultyObjects = [];
1854
+ const { objects } = beatmap.hitObjects;
1855
+ const isPrecise = this.mods.some((m) => m instanceof osuBase.ModPrecise);
1856
+ const greatWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od).hitWindowFor300(isPrecise) / clockRate;
1857
+ for (let i = 0; i < objects.length; ++i) {
1858
+ 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);
1859
+ difficultyObject.computeProperties(clockRate, objects);
1860
+ difficultyObjects.push(difficultyObject);
1861
+ }
1862
+ return difficultyObjects;
1863
+ }
1788
1864
  createSkills() {
1789
1865
  return [
1790
1866
  new DroidAim(this.mods, true),
1791
1867
  new DroidAim(this.mods, false),
1792
1868
  // Tap skill depends on rhythm skill, so we put it first
1793
- new DroidRhythm(this.mods, this.stats.od),
1794
- new DroidTap(this.mods, this.stats.od),
1869
+ new DroidRhythm(this.mods),
1870
+ // Cheesability tap
1871
+ new DroidTap(this.mods, true),
1872
+ // Non-cheesability tap
1873
+ new DroidTap(this.mods, false),
1795
1874
  new DroidFlashlight(this.mods, true),
1796
1875
  new DroidFlashlight(this.mods, false),
1797
1876
  new DroidVisual(this.mods, true),
1798
1877
  new DroidVisual(this.mods, false),
1799
1878
  ];
1800
1879
  }
1880
+ calculateClockRate(options) {
1881
+ var _a, _b;
1882
+ return (osuBase.ModUtil.calculateRateWithMods((_a = options === null || options === void 0 ? void 0 : options.mods) !== null && _a !== void 0 ? _a : [], options === null || options === void 0 ? void 0 : options.oldStatistics) * ((_b = options === null || options === void 0 ? void 0 : options.customSpeedMultiplier) !== null && _b !== void 0 ? _b : 1));
1883
+ }
1801
1884
  /**
1802
1885
  * Called after aim skill calculation.
1803
1886
  *
@@ -1807,152 +1890,122 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1807
1890
  postCalculateAim(aimSkill, aimSkillWithoutSliders) {
1808
1891
  this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
1809
1892
  this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
1810
- this.aim = this.starValue(aimSkill.difficultyValue());
1893
+ this.attributes.aimDifficulty = this.mods.some((m) => m instanceof osuBase.ModAutopilot)
1894
+ ? 0
1895
+ : this.starValue(aimSkill.difficultyValue());
1811
1896
  if (this.aim) {
1812
1897
  this.attributes.sliderFactor =
1813
1898
  this.starValue(aimSkillWithoutSliders.difficultyValue()) /
1814
1899
  this.aim;
1815
1900
  }
1816
1901
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1817
- this.aim *= 0.9;
1902
+ this.attributes.aimDifficulty *= 0.9;
1818
1903
  }
1819
- this.attributes.aimDifficulty = this.aim;
1904
+ this.attributes.aimDifficultStrainCount =
1905
+ aimSkill.countDifficultStrains();
1820
1906
  this.calculateAimAttributes();
1821
1907
  }
1822
1908
  /**
1823
1909
  * Calculates aim-related attributes.
1824
1910
  */
1825
1911
  calculateAimAttributes() {
1826
- const objectStrains = [];
1827
- let maxStrain = 0;
1828
- // Take the top 15% most difficult sliders based on velocity.
1912
+ this.attributes.difficultSliders = [];
1829
1913
  const topDifficultSliders = [];
1830
1914
  for (let i = 0; i < this.objects.length; ++i) {
1831
1915
  const object = this.objects[i];
1832
- objectStrains.push(object.aimStrainWithSliders);
1833
- maxStrain = Math.max(maxStrain, object.aimStrainWithSliders);
1834
1916
  const velocity = object.travelDistance / object.travelTime;
1835
1917
  if (velocity > 0) {
1836
1918
  topDifficultSliders.push({
1837
1919
  index: i,
1838
1920
  velocity: velocity,
1839
1921
  });
1840
- topDifficultSliders.sort((a, b) => b.velocity - a.velocity);
1841
- while (topDifficultSliders.length >
1842
- Math.ceil(0.15 * this.beatmap.hitObjects.sliders)) {
1843
- topDifficultSliders.pop();
1844
- }
1845
1922
  }
1846
1923
  }
1847
- if (maxStrain) {
1848
- this.attributes.aimNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
1849
- }
1850
1924
  const velocitySum = topDifficultSliders.reduce((a, v) => a + v.velocity, 0);
1851
1925
  for (const slider of topDifficultSliders) {
1852
- this.attributes.difficultSliders.push({
1853
- index: slider.index,
1854
- difficultyRating: slider.velocity / velocitySum,
1855
- });
1926
+ const difficultyRating = slider.velocity / velocitySum;
1927
+ // Only consider sliders that are fast enough.
1928
+ if (difficultyRating > 0.02) {
1929
+ this.attributes.difficultSliders.push({
1930
+ index: slider.index,
1931
+ difficultyRating: slider.velocity / velocitySum,
1932
+ });
1933
+ }
1934
+ }
1935
+ this.attributes.difficultSliders.sort((a, b) => b.difficultyRating - a.difficultyRating);
1936
+ // Take the top 15% most difficult sliders.
1937
+ while (this.attributes.difficultSliders.length >
1938
+ Math.ceil(0.15 * this.beatmap.hitObjects.sliders)) {
1939
+ this.attributes.difficultSliders.pop();
1856
1940
  }
1857
1941
  }
1858
1942
  /**
1859
1943
  * Called after tap skill calculation.
1860
1944
  *
1861
- * @param tapSkill The tap skill.
1945
+ * @param tapSkillCheese The tap skill that considers cheesing.
1946
+ * @param tapSkillVibro The tap skill that considers vibro.
1862
1947
  */
1863
- postCalculateTap(tapSkill) {
1864
- this.strainPeaks.speed = tapSkill.strainPeaks;
1865
- this.tap = this.attributes.tapDifficulty = this.starValue(tapSkill.difficultyValue());
1948
+ postCalculateTap(tapSkillCheese, tapSkillVibro) {
1949
+ this.strainPeaks.speed = tapSkillCheese.strainPeaks;
1950
+ this.attributes.tapDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
1951
+ ? 0
1952
+ : this.starValue(tapSkillCheese.difficultyValue());
1953
+ if (this.tap) {
1954
+ this.attributes.vibroFactor =
1955
+ this.starValue(tapSkillVibro.difficultyValue()) / this.tap;
1956
+ }
1957
+ this.attributes.speedNoteCount = tapSkillCheese.relevantNoteCount();
1958
+ this.attributes.averageSpeedDeltaTime =
1959
+ tapSkillCheese.relevantDeltaTime();
1960
+ this.attributes.tapDifficultStrainCount =
1961
+ tapSkillCheese.countDifficultStrains();
1962
+ this.calculateTapAttributes();
1866
1963
  }
1867
1964
  /**
1868
- * Calculates speed-related attributes.
1965
+ * Calculates tap-related attributes.
1869
1966
  */
1870
- calculateSpeedAttributes() {
1967
+ calculateTapAttributes() {
1871
1968
  this.attributes.possibleThreeFingeredSections = [];
1872
- const tempSections = [];
1873
- const objectStrains = [];
1874
- let maxStrain = 0;
1875
- const maxSectionDeltaTime = 2000;
1969
+ const { threeFingerStrainThreshold } = DroidDifficultyCalculator;
1876
1970
  const minSectionObjectCount = 5;
1877
- let firstObjectIndex = 0;
1878
- for (let i = 0; i < this.objects.length - 1; ++i) {
1971
+ let inSpeedSection = false;
1972
+ let firstSpeedObjectIndex = 0;
1973
+ for (let i = 2; i < this.objects.length; ++i) {
1879
1974
  const current = this.objects[i];
1880
- const next = this.objects[i + 1];
1881
- if (i === 0) {
1882
- objectStrains.push(current.tapStrain);
1975
+ const prev = this.objects[i - 1];
1976
+ if (!inSpeedSection &&
1977
+ current.originalTapStrain >= threeFingerStrainThreshold) {
1978
+ inSpeedSection = true;
1979
+ firstSpeedObjectIndex = i;
1980
+ continue;
1883
1981
  }
1884
- objectStrains.push(next.tapStrain);
1885
- maxStrain = Math.max(current.tapStrain, maxStrain);
1886
- const realDeltaTime = next.object.startTime - current.object.endTime;
1887
- if (realDeltaTime >= maxSectionDeltaTime) {
1982
+ const currentDelta = current.deltaTime;
1983
+ const prevDelta = prev.deltaTime;
1984
+ const deltaRatio = Math.min(prevDelta, currentDelta) /
1985
+ Math.max(prevDelta, currentDelta);
1986
+ if (inSpeedSection &&
1987
+ (current.originalTapStrain < threeFingerStrainThreshold ||
1988
+ // Stop speed section on slowing down 1/2 rhythm change or anything slower.
1989
+ (prevDelta < currentDelta && deltaRatio <= 0.5) ||
1990
+ // Don't forget to manually add the last section, which would otherwise be ignored.
1991
+ i === this.objects.length - 1)) {
1992
+ const lastSpeedObjectIndex = i - (i === this.objects.length - 1 ? 0 : 1);
1993
+ inSpeedSection = false;
1888
1994
  // Ignore sections that don't meet object count requirement.
1889
- if (i - firstObjectIndex < minSectionObjectCount) {
1890
- firstObjectIndex = i + 1;
1891
- continue;
1892
- }
1893
- tempSections.push({
1894
- firstObjectIndex,
1895
- lastObjectIndex: i,
1896
- });
1897
- firstObjectIndex = i + 1;
1898
- }
1899
- }
1900
- // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
1901
- if (this.objects.length - firstObjectIndex > minSectionObjectCount) {
1902
- tempSections.push({
1903
- firstObjectIndex,
1904
- lastObjectIndex: this.objects.length - 1,
1905
- });
1906
- }
1907
- // Refilter with tap strain in mind.
1908
- const { threeFingerStrainThreshold } = DroidDifficultyCalculator;
1909
- for (const section of tempSections) {
1910
- let inSpeedSection = false;
1911
- let newFirstObjectIndex = section.firstObjectIndex;
1912
- for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
1913
- const current = this.objects[i];
1914
- if (!inSpeedSection &&
1915
- current.originalTapStrain >= threeFingerStrainThreshold) {
1916
- inSpeedSection = true;
1917
- newFirstObjectIndex = i;
1995
+ if (i - firstSpeedObjectIndex < minSectionObjectCount) {
1918
1996
  continue;
1919
1997
  }
1920
- if (inSpeedSection &&
1921
- current.originalTapStrain < threeFingerStrainThreshold) {
1922
- inSpeedSection = false;
1923
- this.attributes.possibleThreeFingeredSections.push({
1924
- firstObjectIndex: newFirstObjectIndex,
1925
- lastObjectIndex: i,
1926
- sumStrain: this.calculateThreeFingerSummedStrain(newFirstObjectIndex, i),
1927
- });
1928
- }
1929
- }
1930
- // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
1931
- if (inSpeedSection) {
1932
1998
  this.attributes.possibleThreeFingeredSections.push({
1933
- firstObjectIndex: newFirstObjectIndex,
1934
- lastObjectIndex: section.lastObjectIndex,
1935
- sumStrain: this.calculateThreeFingerSummedStrain(newFirstObjectIndex, section.lastObjectIndex),
1999
+ firstObjectIndex: firstSpeedObjectIndex,
2000
+ lastObjectIndex: lastSpeedObjectIndex,
2001
+ sumStrain: Math.pow(this.objects
2002
+ .slice(firstSpeedObjectIndex, lastSpeedObjectIndex + 1)
2003
+ .reduce((a, v) => a +
2004
+ v.originalTapStrain /
2005
+ threeFingerStrainThreshold, 0), 0.75),
1936
2006
  });
1937
2007
  }
1938
2008
  }
1939
- if (maxStrain) {
1940
- this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
1941
- }
1942
- }
1943
- /**
1944
- * Calculates the sum of strains for possible three-fingered sections.
1945
- *
1946
- * @param firstObjectIndex The index of the first object in the section.
1947
- * @param lastObjectIndex The index of the last object in the section.
1948
- * @returns The summed strain of the section.
1949
- */
1950
- calculateThreeFingerSummedStrain(firstObjectIndex, lastObjectIndex) {
1951
- return Math.pow(this.objects
1952
- .slice(firstObjectIndex, lastObjectIndex)
1953
- .reduce((a, v) => a +
1954
- v.originalTapStrain /
1955
- DroidDifficultyCalculator.threeFingerStrainThreshold, 0), 0.75);
1956
2009
  }
1957
2010
  /**
1958
2011
  * Called after rhythm skill calculation.
@@ -1960,7 +2013,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1960
2013
  * @param rhythmSkill The rhythm skill.
1961
2014
  */
1962
2015
  postCalculateRhythm(rhythmSkill) {
1963
- this.rhythm = this.attributes.rhythmDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
2016
+ this.attributes.rhythmDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
1964
2017
  ? 0
1965
2018
  : this.starValue(rhythmSkill.difficultyValue());
1966
2019
  }
@@ -1972,15 +2025,19 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1972
2025
  */
1973
2026
  postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders) {
1974
2027
  this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
1975
- this.flashlight = this.starValue(flashlightSkill.difficultyValue());
2028
+ this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
1976
2029
  if (this.flashlight) {
1977
2030
  this.attributes.flashlightSliderFactor =
1978
2031
  this.starValue(flashlightSkillWithoutSliders.difficultyValue()) / this.flashlight;
1979
2032
  }
2033
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
2034
+ this.attributes.flashlightDifficulty *= 0.3;
2035
+ }
1980
2036
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1981
- this.flashlight *= 0.7;
2037
+ this.attributes.flashlightDifficulty *= 0.7;
1982
2038
  }
1983
- this.attributes.flashlightDifficulty = this.flashlight;
2039
+ this.attributes.flashlightDifficultStrainCount =
2040
+ flashlightSkill.countDifficultStrains();
1984
2041
  }
1985
2042
  /**
1986
2043
  * Called after visual skill calculation.
@@ -1989,7 +2046,7 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1989
2046
  * @param visualSkillWithoutSliders The visual skill that doesn't consider sliders.
1990
2047
  */
1991
2048
  postCalculateVisual(visualSkillWithSliders, visualSkillWithoutSliders) {
1992
- this.visual = this.attributes.visualDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
2049
+ this.attributes.visualDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
1993
2050
  ? 0
1994
2051
  : this.starValue(visualSkillWithSliders.difficultyValue());
1995
2052
  if (this.visual) {
@@ -1997,33 +2054,51 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1997
2054
  this.starValue(visualSkillWithoutSliders.difficultyValue()) /
1998
2055
  this.visual;
1999
2056
  }
2057
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
2058
+ this.attributes.visualDifficulty *= 0.8;
2059
+ }
2060
+ this.attributes.visualDifficultStrainCount =
2061
+ visualSkillWithSliders.countDifficultStrains();
2000
2062
  }
2001
2063
  }
2064
+ /**
2065
+ * The strain threshold to start detecting for possible three-fingered section.
2066
+ *
2067
+ * Increasing this number will result in less sections being flagged.
2068
+ */
2069
+ DroidDifficultyCalculator.threeFingerStrainThreshold = 175;
2002
2070
 
2003
2071
  /**
2004
2072
  * The base class of performance calculators.
2005
2073
  */
2006
2074
  class PerformanceCalculator {
2007
2075
  /**
2008
- * The overall performance value.
2009
- */
2010
- total = 0;
2011
- /**
2012
- * The calculated accuracy.
2013
- */
2014
- computedAccuracy = new osuBase.Accuracy({});
2015
- /**
2016
- * Penalty for combo breaks.
2017
- */
2018
- comboPenalty = 0;
2019
- /**
2020
- * The amount of misses that are filtered out from sliderbreaks.
2021
- */
2022
- effectiveMissCount = 0;
2023
- /**
2024
- * Nerf factor used for nerfing beatmaps with very likely dropped sliderends.
2076
+ * @param difficultyAttributes The difficulty attributes to calculate.
2025
2077
  */
2026
- sliderNerfFactor = 1;
2078
+ constructor(difficultyAttributes) {
2079
+ /**
2080
+ * The overall performance value.
2081
+ */
2082
+ this.total = 0;
2083
+ /**
2084
+ * The calculated accuracy.
2085
+ */
2086
+ this.computedAccuracy = new osuBase.Accuracy({});
2087
+ /**
2088
+ * The amount of misses that are filtered out from sliderbreaks.
2089
+ */
2090
+ this.effectiveMissCount = 0;
2091
+ /**
2092
+ * Nerf factor used for nerfing beatmaps with very likely dropped sliderends.
2093
+ */
2094
+ this.sliderNerfFactor = 1;
2095
+ if (this.isCacheableAttribute(difficultyAttributes)) {
2096
+ this.difficultyAttributes = Object.assign(Object.assign({}, difficultyAttributes), { mods: osuBase.ModUtil.pcStringToMods(difficultyAttributes.mods) });
2097
+ }
2098
+ else {
2099
+ this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
2100
+ }
2101
+ }
2027
2102
  /**
2028
2103
  * Calculates the performance points of the beatmap.
2029
2104
  *
@@ -2033,7 +2108,7 @@ class PerformanceCalculator {
2033
2108
  calculate(options) {
2034
2109
  this.handleOptions(options);
2035
2110
  this.calculateValues();
2036
- this.calculateTotalValue();
2111
+ this.total = this.calculateTotalValue();
2037
2112
  return this;
2038
2113
  }
2039
2114
  /**
@@ -2064,11 +2139,11 @@ class PerformanceCalculator {
2064
2139
  * @param options Options for performance calculation.
2065
2140
  */
2066
2141
  handleOptions(options) {
2142
+ var _a;
2067
2143
  const maxCombo = this.difficultyAttributes.maxCombo;
2068
2144
  const miss = this.computedAccuracy.nmiss;
2069
- const combo = options?.combo ?? maxCombo - miss;
2070
- this.comboPenalty = Math.min(Math.pow(combo / maxCombo, 0.8), 1);
2071
- if (options?.accPercent instanceof osuBase.Accuracy) {
2145
+ const combo = (_a = options === null || options === void 0 ? void 0 : options.combo) !== null && _a !== void 0 ? _a : maxCombo - miss;
2146
+ if ((options === null || options === void 0 ? void 0 : options.accPercent) instanceof osuBase.Accuracy) {
2072
2147
  // Copy into new instance to not modify the original
2073
2148
  this.computedAccuracy = new osuBase.Accuracy(options.accPercent);
2074
2149
  if (this.computedAccuracy.n300 <= 0) {
@@ -2083,9 +2158,9 @@ class PerformanceCalculator {
2083
2158
  }
2084
2159
  else {
2085
2160
  this.computedAccuracy = new osuBase.Accuracy({
2086
- percent: options?.accPercent,
2161
+ percent: options === null || options === void 0 ? void 0 : options.accPercent,
2087
2162
  nobjects: this.totalHits,
2088
- nmiss: options?.miss || 0,
2163
+ nmiss: (options === null || options === void 0 ? void 0 : options.miss) || 0,
2089
2164
  });
2090
2165
  }
2091
2166
  this.effectiveMissCount = this.calculateEffectiveMissCount(combo, maxCombo);
@@ -2105,7 +2180,7 @@ class PerformanceCalculator {
2105
2180
  Math.pow(this.difficultyAttributes.overallDifficulty /
2106
2181
  13.33, 1.8)
2107
2182
  : 1);
2108
- const n50Multiplier = Math.max(0, this.difficultyAttributes.overallDifficulty > 0.0
2183
+ const n50Multiplier = Math.max(0, this.difficultyAttributes.overallDifficulty > 0
2109
2184
  ? 1 -
2110
2185
  Math.pow(this.difficultyAttributes.overallDifficulty /
2111
2186
  13.33, 5)
@@ -2119,7 +2194,7 @@ class PerformanceCalculator {
2119
2194
  if (this.difficultyAttributes.sliderCount > 0) {
2120
2195
  // We assume 15% of sliders in a beatmap are difficult since there's no way to tell from the performance calculator.
2121
2196
  const estimateDifficultSliders = this.difficultyAttributes.sliderCount * 0.15;
2122
- const estimateSliderEndsDropped = osuBase.MathUtils.clamp(Math.min(this.computedAccuracy.n300 +
2197
+ const estimateSliderEndsDropped = osuBase.MathUtils.clamp(Math.min(this.computedAccuracy.n100 +
2123
2198
  this.computedAccuracy.n50 +
2124
2199
  this.computedAccuracy.nmiss, maxCombo - combo), 0, estimateDifficultSliders);
2125
2200
  this.sliderNerfFactor =
@@ -2130,6 +2205,22 @@ class PerformanceCalculator {
2130
2205
  this.difficultyAttributes.sliderFactor;
2131
2206
  }
2132
2207
  }
2208
+ /**
2209
+ * Calculates a strain-based miss penalty.
2210
+ *
2211
+ * Strain-based miss penalty assumes that a player will miss on the hardest parts of a map,
2212
+ * so we use the amount of relatively difficult sections to adjust miss penalty
2213
+ * to make it more punishing on maps with lower amount of hard sections.
2214
+ */
2215
+ calculateStrainBasedMissPenalty(difficultStrainCount) {
2216
+ if (this.effectiveMissCount === 0) {
2217
+ return 1;
2218
+ }
2219
+ return (0.96 /
2220
+ (this.effectiveMissCount /
2221
+ (4 * Math.pow(Math.log(difficultStrainCount), 0.94)) +
2222
+ 1));
2223
+ }
2133
2224
  /**
2134
2225
  * Calculates the amount of misses + sliderbreaks from combo.
2135
2226
  */
@@ -2147,32 +2238,52 @@ class PerformanceCalculator {
2147
2238
  }
2148
2239
  return Math.max(this.computedAccuracy.nmiss, comboBasedMissCount);
2149
2240
  }
2241
+ /**
2242
+ * Determines whether an attribute is a cacheable attribute.
2243
+ *
2244
+ * @param attributes The attributes to check.
2245
+ * @returns Whether the attributes are cacheable.
2246
+ */
2247
+ isCacheableAttribute(attributes) {
2248
+ return typeof attributes.mods === "string";
2249
+ }
2150
2250
  }
2151
2251
 
2152
2252
  /**
2153
2253
  * A performance points calculator that calculates performance points for osu!droid gamemode.
2154
2254
  */
2155
2255
  class DroidPerformanceCalculator extends PerformanceCalculator {
2156
- /**
2157
- * The aim performance value.
2158
- */
2159
- aim = 0;
2160
- /**
2161
- * The tap performance value.
2162
- */
2163
- tap = 0;
2164
- /**
2165
- * The accuracy performance value.
2166
- */
2167
- accuracy = 0;
2168
- /**
2169
- * The flashlight performance value.
2170
- */
2171
- flashlight = 0;
2172
- /**
2173
- * The visual performance value.
2174
- */
2175
- visual = 0;
2256
+ constructor() {
2257
+ super(...arguments);
2258
+ /**
2259
+ * The aim performance value.
2260
+ */
2261
+ this.aim = 0;
2262
+ /**
2263
+ * The tap performance value.
2264
+ */
2265
+ this.tap = 0;
2266
+ /**
2267
+ * The accuracy performance value.
2268
+ */
2269
+ this.accuracy = 0;
2270
+ /**
2271
+ * The flashlight performance value.
2272
+ */
2273
+ this.flashlight = 0;
2274
+ /**
2275
+ * The visual performance value.
2276
+ */
2277
+ this.visual = 0;
2278
+ this.finalMultiplier = 1.24;
2279
+ this.mode = osuBase.Modes.droid;
2280
+ this._aimSliderCheesePenalty = 1;
2281
+ this._flashlightSliderCheesePenalty = 1;
2282
+ this._visualSliderCheesePenalty = 1;
2283
+ this._tapPenalty = 1;
2284
+ this._deviation = 0;
2285
+ this._tapDeviation = 0;
2286
+ }
2176
2287
  /**
2177
2288
  * The penalty used to penalize the tap performance value.
2178
2289
  *
@@ -2217,22 +2328,6 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2217
2328
  get visualSliderCheesePenalty() {
2218
2329
  return this._visualSliderCheesePenalty;
2219
2330
  }
2220
- difficultyAttributes;
2221
- finalMultiplier = 1.24;
2222
- mode = osuBase.Modes.droid;
2223
- _aimSliderCheesePenalty = 1;
2224
- _flashlightSliderCheesePenalty = 1;
2225
- _visualSliderCheesePenalty = 1;
2226
- _tapPenalty = 1;
2227
- _deviation = 0;
2228
- _tapDeviation = 0;
2229
- /**
2230
- * @param difficultyAttributes The difficulty attributes to calculate.
2231
- */
2232
- constructor(difficultyAttributes) {
2233
- super();
2234
- this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
2235
- }
2236
2331
  /**
2237
2332
  * Applies a tap penalty value to this calculator.
2238
2333
  *
@@ -2247,9 +2342,9 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2247
2342
  if (value === this._tapPenalty) {
2248
2343
  return;
2249
2344
  }
2250
- this.tap *= this._tapPenalty / value;
2251
2345
  this._tapPenalty = value;
2252
- this.calculateTotalValue();
2346
+ this.tap = this.calculateTapValue();
2347
+ this.total = this.calculateTotalValue();
2253
2348
  }
2254
2349
  /**
2255
2350
  * Applies an aim slider cheese penalty value to this calculator.
@@ -2269,8 +2364,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2269
2364
  return;
2270
2365
  }
2271
2366
  this._aimSliderCheesePenalty = value;
2272
- this.calculateAimValue();
2273
- this.calculateTotalValue();
2367
+ this.aim = this.calculateAimValue();
2368
+ this.total = this.calculateTotalValue();
2274
2369
  }
2275
2370
  /**
2276
2371
  * Applies a flashlight slider cheese penalty value to this calculator.
@@ -2290,8 +2385,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2290
2385
  return;
2291
2386
  }
2292
2387
  this._flashlightSliderCheesePenalty = value;
2293
- this.calculateFlashlightValue();
2294
- this.calculateTotalValue();
2388
+ this.flashlight = this.calculateFlashlightValue();
2389
+ this.total = this.calculateTotalValue();
2295
2390
  }
2296
2391
  /**
2297
2392
  * Applies a visual slider cheese penalty value to this calculator.
@@ -2311,80 +2406,96 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2311
2406
  return;
2312
2407
  }
2313
2408
  this._visualSliderCheesePenalty = value;
2314
- this.calculateVisualValue();
2315
- this.calculateTotalValue();
2409
+ this.visual = this.calculateVisualValue();
2410
+ this.total = this.calculateTotalValue();
2316
2411
  }
2317
2412
  calculateValues() {
2318
2413
  this._deviation = this.calculateDeviation();
2319
2414
  this._tapDeviation = this.calculateTapDeviation();
2320
- this.calculateAimValue();
2321
- this.calculateTapValue();
2322
- this.calculateAccuracyValue();
2323
- this.calculateFlashlightValue();
2324
- this.calculateVisualValue();
2415
+ this.aim = this.calculateAimValue();
2416
+ this.tap = this.calculateTapValue();
2417
+ this.accuracy = this.calculateAccuracyValue();
2418
+ this.flashlight = this.calculateFlashlightValue();
2419
+ this.visual = this.calculateVisualValue();
2325
2420
  }
2326
2421
  calculateTotalValue() {
2327
- this.total =
2328
- Math.pow(Math.pow(this.aim, 1.1) +
2329
- Math.pow(this.tap, 1.1) +
2330
- Math.pow(this.accuracy, 1.1) +
2331
- Math.pow(this.flashlight, 1.1) +
2332
- Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier;
2422
+ return (Math.pow(Math.pow(this.aim, 1.1) +
2423
+ Math.pow(this.tap, 1.1) +
2424
+ Math.pow(this.accuracy, 1.1) +
2425
+ Math.pow(this.flashlight, 1.1) +
2426
+ Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier);
2333
2427
  }
2334
2428
  handleOptions(options) {
2335
- this._tapPenalty = options?.tapPenalty ?? 1;
2336
- this._aimSliderCheesePenalty = options?.aimSliderCheesePenalty ?? 1;
2429
+ var _a, _b, _c, _d;
2430
+ this._tapPenalty = (_a = options === null || options === void 0 ? void 0 : options.tapPenalty) !== null && _a !== void 0 ? _a : 1;
2431
+ this._aimSliderCheesePenalty = (_b = options === null || options === void 0 ? void 0 : options.aimSliderCheesePenalty) !== null && _b !== void 0 ? _b : 1;
2337
2432
  this._flashlightSliderCheesePenalty =
2338
- options?.flashlightSliderCheesePenalty ?? 1;
2433
+ (_c = options === null || options === void 0 ? void 0 : options.flashlightSliderCheesePenalty) !== null && _c !== void 0 ? _c : 1;
2339
2434
  this._visualSliderCheesePenalty =
2340
- options?.visualSliderCheesePenalty ?? 1;
2435
+ (_d = options === null || options === void 0 ? void 0 : options.visualSliderCheesePenalty) !== null && _d !== void 0 ? _d : 1;
2341
2436
  super.handleOptions(options);
2342
2437
  }
2343
2438
  /**
2344
2439
  * Calculates the aim performance value of the beatmap.
2345
2440
  */
2346
2441
  calculateAimValue() {
2347
- this.aim = this.baseValue(Math.pow(this.difficultyAttributes.aimDifficulty, 0.8));
2348
- if (this.effectiveMissCount > 0) {
2349
- // Penalize misses by assessing # of misses relative to the total # of objects.
2350
- // Default a 3% reduction for any # of misses.
2351
- this.aim *=
2352
- 0.97 *
2353
- Math.pow(1 -
2354
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
2355
- }
2356
- // Combo scaling
2357
- this.aim *= this.comboPenalty;
2442
+ let aimValue = this.baseValue(Math.pow(this.difficultyAttributes.aimDifficulty, 0.8));
2443
+ aimValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.aimDifficultStrainCount), this.proportionalMissPenalty);
2444
+ // Scale the aim value with estimated full combo deviation.
2445
+ aimValue *= this.calculateDeviationBasedLengthScaling();
2358
2446
  // Scale the aim value with slider factor to nerf very likely dropped sliderends.
2359
- this.aim *= this.sliderNerfFactor;
2447
+ aimValue *= this.sliderNerfFactor;
2360
2448
  // Scale the aim value with slider cheese penalty.
2361
- this.aim *= this._aimSliderCheesePenalty;
2449
+ aimValue *= this._aimSliderCheesePenalty;
2362
2450
  // Scale the aim value with deviation.
2363
- this.aim *=
2364
- 1.05 *
2365
- Math.pow(osuBase.ErrorFunction.erf(32.0625 / (Math.SQRT2 * this._deviation)), 1.5);
2451
+ aimValue *=
2452
+ 1.025 *
2453
+ Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.475);
2454
+ // OD 7 SS stays the same.
2455
+ aimValue *= 0.98 + Math.pow(7, 2) / 2500;
2456
+ return aimValue;
2366
2457
  }
2367
2458
  /**
2368
2459
  * Calculates the tap performance value of the beatmap.
2369
2460
  */
2370
2461
  calculateTapValue() {
2371
- this.tap = this.baseValue(this.difficultyAttributes.tapDifficulty);
2372
- if (this.effectiveMissCount > 0) {
2373
- // Penalize misses by assessing # of misses relative to the total # of objects.
2374
- // Default a 3% reduction for any # of misses.
2375
- this.tap *=
2376
- 0.97 *
2377
- Math.pow(1 -
2378
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
2379
- }
2380
- // Combo scaling
2381
- this.tap *= this.comboPenalty;
2462
+ let tapValue = this.baseValue(this.difficultyAttributes.tapDifficulty);
2463
+ tapValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.tapDifficultStrainCount);
2464
+ // Scale the tap value with estimated full combo deviation.
2465
+ // Consider notes that are difficult to tap with respect to other notes, but
2466
+ // also cap the note count to prevent buffing filler patterns.
2467
+ tapValue *= this.calculateDeviationBasedLengthScaling(Math.min(this.difficultyAttributes.speedNoteCount, this.totalHits / 1.45));
2468
+ // Normalize the deviation to 300 BPM.
2469
+ const normalizedDeviation = this.tapDeviation *
2470
+ Math.max(1, 50 / this.difficultyAttributes.averageSpeedDeltaTime);
2471
+ // We expect the player to get 7500/x deviation when doubletapping x BPM.
2472
+ // Using this expectation, we penalize scores with deviation above 25.
2473
+ const averageBPM = 60000 / 4 / this.difficultyAttributes.averageSpeedDeltaTime;
2474
+ const adjustedDeviation = normalizedDeviation *
2475
+ (1 +
2476
+ 1 /
2477
+ (1 +
2478
+ Math.exp(-(normalizedDeviation - 7500 / averageBPM) /
2479
+ ((2 * 300) / averageBPM))));
2382
2480
  // Scale the tap value with tap deviation.
2383
- this.tap *=
2384
- 1.1 *
2385
- Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._tapDeviation)), 1.25);
2481
+ tapValue *=
2482
+ 1.05 *
2483
+ Math.pow(osuBase.ErrorFunction.erf(20 / (Math.SQRT2 * adjustedDeviation)), 0.6);
2484
+ // Additional scaling for tap value based on average BPM and how "vibroable" the beatmap is.
2485
+ // Higher BPMs require more precise tapping. When the deviation is too high,
2486
+ // it can be assumed that the player taps invariant to rhythm.
2487
+ // We harshen the punishment for such scenario.
2488
+ tapValue *=
2489
+ (1 - Math.pow(this.difficultyAttributes.vibroFactor, 6)) /
2490
+ (1 +
2491
+ Math.exp((this._tapDeviation - 7500 / averageBPM) /
2492
+ ((2 * 300) / averageBPM))) +
2493
+ Math.pow(this.difficultyAttributes.vibroFactor, 6);
2386
2494
  // Scale the tap value with three-fingered penalty.
2387
- this.tap /= this._tapPenalty;
2495
+ tapValue /= this._tapPenalty;
2496
+ // OD 8 SS stays the same.
2497
+ tapValue *= 0.95 + Math.pow(8, 2) / 750;
2498
+ return tapValue;
2388
2499
  }
2389
2500
  /**
2390
2501
  * Calculates the accuracy performance value of the beatmap.
@@ -2392,84 +2503,112 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2392
2503
  calculateAccuracyValue() {
2393
2504
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax) ||
2394
2505
  this.totalSuccessfulHits === 0) {
2395
- this.accuracy = 0;
2396
- return;
2506
+ return 0;
2397
2507
  }
2398
- this.accuracy =
2399
- 650 *
2400
- Math.exp(-0.125 * this._deviation) *
2401
- // The following function is to give higher reward for deviations lower than 25 (250 UR).
2402
- (15 / (this._deviation + 15) + 0.65);
2403
- // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
2508
+ let accuracyValue = 650 * Math.exp(-0.1 * this._deviation);
2404
2509
  const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
2405
2510
  ? this.totalHits - this.difficultyAttributes.spinnerCount
2406
2511
  : this.difficultyAttributes.hitCircleCount;
2407
- this.accuracy *= Math.min(1.15, Math.sqrt(Math.log(1 + ((Math.E - 1) * ncircles) / 1000)));
2512
+ // Bonus for many hitcircles - it's harder to keep good accuracy up for longer.
2513
+ accuracyValue *= Math.min(1.15, Math.sqrt(Math.log(1 + ((Math.E - 1) * ncircles) / 1000)));
2408
2514
  // Scale the accuracy value with rhythm complexity.
2409
- this.accuracy *=
2515
+ accuracyValue *=
2410
2516
  1.5 /
2411
2517
  (1 +
2412
2518
  Math.exp(-(this.difficultyAttributes.rhythmDifficulty - 1) / 2));
2519
+ // Penalize accuracy pp after the first miss.
2520
+ accuracyValue *= Math.pow(0.97, Math.max(0, this.effectiveMissCount - 1));
2413
2521
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2414
- this.accuracy *= 1.02;
2522
+ accuracyValue *= 1.02;
2415
2523
  }
2524
+ return accuracyValue;
2416
2525
  }
2417
2526
  /**
2418
2527
  * Calculates the flashlight performance value of the beatmap.
2419
2528
  */
2420
2529
  calculateFlashlightValue() {
2421
2530
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2422
- this.flashlight = 0;
2423
- return;
2424
- }
2425
- this.flashlight =
2426
- Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
2427
- // Combo scaling
2428
- this.flashlight *= this.comboPenalty;
2429
- if (this.effectiveMissCount > 0) {
2430
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
2431
- this.flashlight *=
2432
- 0.97 *
2433
- Math.pow(1 -
2434
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
2531
+ return 0;
2435
2532
  }
2533
+ let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 1.6) * 25;
2534
+ flashlightValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.flashlightDifficultStrainCount), this.proportionalMissPenalty);
2436
2535
  // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
2437
- this.flashlight *=
2536
+ flashlightValue *=
2438
2537
  0.7 +
2439
2538
  0.1 * Math.min(1, this.totalHits / 200) +
2440
2539
  (this.totalHits > 200
2441
2540
  ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
2442
2541
  : 0);
2443
2542
  // Scale the flashlight value with slider cheese penalty.
2444
- this.flashlight *= this._flashlightSliderCheesePenalty;
2543
+ flashlightValue *= this._flashlightSliderCheesePenalty;
2445
2544
  // Scale the flashlight value with deviation.
2446
- this.flashlight *= osuBase.ErrorFunction.erf(50 / (Math.SQRT2 * this._deviation));
2545
+ flashlightValue *= osuBase.ErrorFunction.erf(50 / (Math.SQRT2 * this._deviation));
2546
+ return flashlightValue;
2447
2547
  }
2448
2548
  /**
2449
2549
  * Calculates the visual performance value of the beatmap.
2450
2550
  */
2451
2551
  calculateVisualValue() {
2452
- this.visual =
2453
- Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
2454
- if (this.effectiveMissCount > 0) {
2455
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
2456
- this.visual *=
2457
- 0.97 *
2458
- Math.pow(1 -
2459
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
2460
- }
2461
- // Combo scaling
2462
- this.visual *= this.comboPenalty;
2463
- // Scale the visual value with object count to penalize short maps.
2464
- this.visual *= Math.min(1, 1.650668 +
2465
- (0.4845796 - 1.650668) /
2466
- (1 + Math.pow(this.totalHits / 817.9306, 1.147469)));
2552
+ let visualValue = Math.pow(this.difficultyAttributes.visualDifficulty, 1.6) * 22.5;
2553
+ visualValue *= Math.min(this.calculateStrainBasedMissPenalty(this.difficultyAttributes.visualDifficultStrainCount), this.proportionalMissPenalty);
2554
+ // Scale the visual value with estimated full combo deviation.
2555
+ // As visual is easily "bypassable" with memorization, punish for memorization.
2556
+ visualValue *= this.calculateDeviationBasedLengthScaling(undefined, true);
2467
2557
  // Scale the visual value with slider cheese penalty.
2468
- this.visual *= this._visualSliderCheesePenalty;
2558
+ visualValue *= this._visualSliderCheesePenalty;
2469
2559
  // Scale the visual value with deviation.
2470
- this.visual *=
2471
- 1.065 *
2472
- Math.pow(osuBase.ErrorFunction.erf(30 / (Math.SQRT2 * this._deviation)), 1.75);
2560
+ visualValue *=
2561
+ 1.05 *
2562
+ Math.pow(osuBase.ErrorFunction.erf(25 / (Math.SQRT2 * this._deviation)), 0.775);
2563
+ // OD 5 SS stays the same.
2564
+ visualValue *= 0.98 + Math.pow(5, 2) / 2500;
2565
+ return visualValue;
2566
+ }
2567
+ /**
2568
+ * The object-based proportional miss penalty.
2569
+ */
2570
+ get proportionalMissPenalty() {
2571
+ if (this.effectiveMissCount === 0) {
2572
+ return 1;
2573
+ }
2574
+ const missProportion = (this.totalHits - this.effectiveMissCount) / (this.totalHits + 1);
2575
+ const noMissProportion = this.totalHits / (this.totalHits + 1);
2576
+ return (
2577
+ // Aim deviation-based scale.
2578
+ (osuBase.ErrorFunction.erfInv(missProportion) /
2579
+ osuBase.ErrorFunction.erfInv(noMissProportion)) *
2580
+ // Cheesing-based scale (i.e. 50% misses is deliberately only hitting each other
2581
+ // note, 90% misses is deliberately only hitting 1 note every 10 notes).
2582
+ Math.pow(missProportion, 8));
2583
+ }
2584
+ /**
2585
+ * Calculates the object-based length scaling based on the deviation of a player for a full
2586
+ * combo in this beatmap, taking retries into account.
2587
+ *
2588
+ * @param objectCount The amount of objects to be considered. Defaults to the amount of
2589
+ * objects in this beatmap.
2590
+ * @param punishForMemorization Whether to punish the deviation for memorization. Defaults to `false`.
2591
+ */
2592
+ calculateDeviationBasedLengthScaling(objectCount = this.totalHits, punishForMemorization = false) {
2593
+ // Assume a sample proportion of hits for a full combo to be `(n - 0.5) / n` due to
2594
+ // continuity correction, where `n` is the object count.
2595
+ const calculateProportion = (notes) => (notes - 0.5) / notes;
2596
+ // Keeping `x` notes as the benchmark, assume that a player will retry a beatmap
2597
+ // `max(1, x/n)` times relative to an `x`-note beatmap.
2598
+ const benchmarkNotes = 700;
2599
+ // Calculate the proportion equivalent to the bottom half of retry count percentile of
2600
+ // scores and take it as the player's "real" proportion.
2601
+ const retryProportion = (proportion, notes, tries) => proportion +
2602
+ Math.sqrt((2 * proportion * (1 - proportion)) / notes) *
2603
+ osuBase.ErrorFunction.erfInv(1 / tries - 1);
2604
+ // Using the proportion, we calculate the deviation based off that proportion and again
2605
+ // compared to the hit deviation for proportion `(n - 0.5) / n`.
2606
+ let multiplier = Math.max(0, osuBase.ErrorFunction.erfInv(retryProportion(calculateProportion(objectCount), objectCount, Math.max(1, benchmarkNotes / objectCount))) / osuBase.ErrorFunction.erfInv(calculateProportion(benchmarkNotes)) || 0);
2607
+ // Punish for memorization if needed.
2608
+ if (punishForMemorization) {
2609
+ multiplier *= Math.min(1, Math.sqrt(objectCount / benchmarkNotes));
2610
+ }
2611
+ return multiplier;
2473
2612
  }
2474
2613
  /**
2475
2614
  * Estimates the player's tap deviation based on the OD, number of circles and sliders,
@@ -2490,40 +2629,65 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2490
2629
  return Number.POSITIVE_INFINITY;
2491
2630
  }
2492
2631
  const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).hitWindowFor300();
2493
- // Obtain the 50 hit window for droid.
2494
- const clockRate = new osuBase.MapStats({
2495
- mods: this.difficultyAttributes.mods,
2496
- }).calculate().speedMultiplier;
2497
- const realHitWindow300 = hitWindow300 * clockRate;
2498
- const droidHitWindow = new osuBase.DroidHitWindow(osuBase.OsuHitWindow.hitWindow300ToOD(realHitWindow300));
2499
- const hitWindow50 = droidHitWindow.hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise)) / clockRate;
2500
- const greatCountOnCircles = this.difficultyAttributes.hitCircleCount -
2501
- this.computedAccuracy.n100 -
2502
- this.computedAccuracy.n50 -
2503
- this.computedAccuracy.nmiss;
2504
- // The probability that a player hits a circle is unknown, but we can estimate it to be
2505
- // the number of greats on circles divided by the number of circles, and then add one
2506
- // to the number of circles as a bias correction / bayesian prior.
2507
- const greatProbabilityCircle = Math.max(0, greatCountOnCircles / (this.difficultyAttributes.hitCircleCount + 1));
2508
- let greatProbabilitySlider;
2509
- if (greatCountOnCircles < 0) {
2510
- const nonCircleMisses = -greatCountOnCircles;
2511
- greatProbabilitySlider = Math.max(0, (this.difficultyAttributes.sliderCount - nonCircleMisses) /
2512
- (this.difficultyAttributes.sliderCount + 1));
2513
- }
2514
- else {
2515
- greatProbabilitySlider =
2516
- this.difficultyAttributes.sliderCount /
2517
- (this.difficultyAttributes.sliderCount + 1);
2518
- }
2519
- if (greatProbabilityCircle === 0 && greatProbabilitySlider === 0) {
2632
+ // Obtain the 50 and 100 hit window for droid.
2633
+ const isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
2634
+ const droidHitWindow = new osuBase.DroidHitWindow(osuBase.DroidHitWindow.hitWindow300ToOD(hitWindow300 * this.difficultyAttributes.clockRate, isPrecise));
2635
+ const hitWindow50 = droidHitWindow.hitWindowFor50(isPrecise) /
2636
+ this.difficultyAttributes.clockRate;
2637
+ const hitWindow100 = droidHitWindow.hitWindowFor100(isPrecise) /
2638
+ this.difficultyAttributes.clockRate;
2639
+ const { n100, n50, nmiss } = this.computedAccuracy;
2640
+ const circleCount = this.difficultyAttributes.hitCircleCount;
2641
+ const missCountCircles = Math.min(nmiss, circleCount);
2642
+ const mehCountCircles = Math.min(n50, circleCount - missCountCircles);
2643
+ const okCountCircles = Math.min(n100, circleCount - missCountCircles - mehCountCircles);
2644
+ const greatCountCircles = Math.max(0, circleCount - missCountCircles - mehCountCircles - okCountCircles);
2645
+ // Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
2646
+ // compute the deviation on circles.
2647
+ if (greatCountCircles > 0) {
2648
+ // The probability that a player hits a circle is unknown, but we can estimate it to be
2649
+ // the number of greats on circles divided by the number of circles, and then add one
2650
+ // to the number of circles as a bias correction.
2651
+ const greatProbabilityCircle = greatCountCircles /
2652
+ (circleCount - missCountCircles - mehCountCircles + 1);
2653
+ // Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
2654
+ // Begin with the normal distribution first.
2655
+ let deviationOnCircles = hitWindow300 /
2656
+ (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
2657
+ deviationOnCircles *= Math.sqrt(1 -
2658
+ (Math.sqrt(2 / Math.PI) *
2659
+ hitWindow100 *
2660
+ Math.exp(-0.5 *
2661
+ Math.pow(hitWindow100 / deviationOnCircles, 2))) /
2662
+ (deviationOnCircles *
2663
+ osuBase.ErrorFunction.erf(hitWindow100 /
2664
+ (Math.SQRT2 * deviationOnCircles))));
2665
+ // Then compute the variance for 50s.
2666
+ const mehVariance = (hitWindow50 * hitWindow50 +
2667
+ hitWindow100 * hitWindow50 +
2668
+ hitWindow100 * hitWindow100) /
2669
+ 3;
2670
+ // Find the total deviation.
2671
+ deviationOnCircles = Math.sqrt(((greatCountCircles + okCountCircles) *
2672
+ Math.pow(deviationOnCircles, 2) +
2673
+ mehCountCircles * mehVariance) /
2674
+ (greatCountCircles + okCountCircles + mehCountCircles));
2675
+ return deviationOnCircles;
2676
+ }
2677
+ // If there are more non-300s than there are circles, compute the deviation on sliders instead.
2678
+ // Here, all that matters is whether or not the slider was missed, since it is impossible
2679
+ // to get a 100 or 50 on a slider by mis-tapping it.
2680
+ const sliderCount = this.difficultyAttributes.sliderCount;
2681
+ const missCountSliders = Math.min(sliderCount, nmiss - missCountCircles);
2682
+ const greatCountSliders = sliderCount - missCountSliders;
2683
+ // We only get here if nothing was hit. In this case, there is no estimate for deviation.
2684
+ // Note that this is never negative, so checking if this is only equal to 0 makes sense.
2685
+ if (greatCountSliders === 0) {
2520
2686
  return Number.POSITIVE_INFINITY;
2521
2687
  }
2522
- const deviationOnCircles = hitWindow300 /
2523
- (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
2524
- const deviationOnSliders = hitWindow50 /
2525
- (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilitySlider));
2526
- return Math.min(deviationOnCircles, deviationOnSliders);
2688
+ const greatProbabilitySlider = greatCountSliders / (sliderCount + 1);
2689
+ return (hitWindow50 /
2690
+ (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilitySlider)));
2527
2691
  }
2528
2692
  /**
2529
2693
  * Does the same as {@link calculateDeviation}, but only for notes and inaccuracies that are relevant to tap difficulty.
@@ -2535,14 +2699,59 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2535
2699
  if (this.totalSuccessfulHits === 0) {
2536
2700
  return Number.POSITIVE_INFINITY;
2537
2701
  }
2538
- const hitWindow300 = new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty).hitWindowFor300();
2539
- const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
2540
- const relevantCountGreat = Math.max(0, this.computedAccuracy.n300 - relevantTotalDiff);
2541
- if (relevantCountGreat === 0) {
2542
- return Number.POSITIVE_INFINITY;
2543
- }
2544
- const greatProbability = relevantCountGreat / (this.difficultyAttributes.speedNoteCount + 1);
2545
- return (hitWindow300 / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbability)));
2702
+ const { speedNoteCount, clockRate, overallDifficulty } = this.difficultyAttributes;
2703
+ const hitWindow300 = new osuBase.OsuHitWindow(overallDifficulty).hitWindowFor300();
2704
+ // Obtain the 50 and 100 hit window for droid.
2705
+ const isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
2706
+ const droidHitWindow = new osuBase.DroidHitWindow(osuBase.DroidHitWindow.hitWindow300ToOD(hitWindow300 * clockRate, isPrecise));
2707
+ const hitWindow50 = droidHitWindow.hitWindowFor50(isPrecise) / clockRate;
2708
+ const hitWindow100 = droidHitWindow.hitWindowFor100(isPrecise) / clockRate;
2709
+ const { n100, n50, nmiss } = this.computedAccuracy;
2710
+ // Assume a fixed ratio of non-300s hit in speed notes based on speed note count ratio and OD.
2711
+ // Graph: https://www.desmos.com/calculator/iskvgjkxr4
2712
+ const speedNoteRatio = speedNoteCount / this.totalHits;
2713
+ const nonGreatCount = n100 + n50 + nmiss;
2714
+ const nonGreatRatio = 1 -
2715
+ (Math.pow(Math.exp(Math.sqrt(hitWindow300)) + 1, 1 - speedNoteRatio) -
2716
+ 1) /
2717
+ Math.exp(Math.sqrt(hitWindow300));
2718
+ const relevantCountGreat = Math.max(0, speedNoteCount - nonGreatCount * nonGreatRatio);
2719
+ const relevantCountOk = n100 * nonGreatRatio;
2720
+ const relevantCountMeh = n50 * nonGreatRatio;
2721
+ const relevantCountMiss = nmiss * nonGreatRatio;
2722
+ // Assume 100s, 50s, and misses happen on circles. If there are less non-300s on circles than 300s,
2723
+ // compute the deviation on circles.
2724
+ if (relevantCountGreat > 0) {
2725
+ // The probability that a player hits a circle is unknown, but we can estimate it to be
2726
+ // the number of greats on circles divided by the number of circles, and then add one
2727
+ // to the number of circles as a bias correction.
2728
+ const greatProbabilityCircle = relevantCountGreat /
2729
+ (speedNoteCount - relevantCountMiss - relevantCountMeh + 1);
2730
+ // Compute the deviation assuming 300s and 100s are normally distributed, and 50s are uniformly distributed.
2731
+ // Begin with the normal distribution first.
2732
+ let deviationOnCircles = hitWindow300 /
2733
+ (Math.SQRT2 * osuBase.ErrorFunction.erfInv(greatProbabilityCircle));
2734
+ deviationOnCircles *= Math.sqrt(1 -
2735
+ (Math.sqrt(2 / Math.PI) *
2736
+ hitWindow100 *
2737
+ Math.exp(-0.5 *
2738
+ Math.pow(hitWindow100 / deviationOnCircles, 2))) /
2739
+ (deviationOnCircles *
2740
+ osuBase.ErrorFunction.erf(hitWindow100 /
2741
+ (Math.SQRT2 * deviationOnCircles))));
2742
+ // Then compute the variance for 50s.
2743
+ const mehVariance = (hitWindow50 * hitWindow50 +
2744
+ hitWindow100 * hitWindow50 +
2745
+ hitWindow100 * hitWindow100) /
2746
+ 3;
2747
+ // Find the total deviation.
2748
+ deviationOnCircles = Math.sqrt(((relevantCountGreat + relevantCountOk) *
2749
+ Math.pow(deviationOnCircles, 2) +
2750
+ relevantCountMeh * mehVariance) /
2751
+ (relevantCountGreat + relevantCountOk + relevantCountMeh));
2752
+ return deviationOnCircles;
2753
+ }
2754
+ return Number.POSITIVE_INFINITY;
2546
2755
  }
2547
2756
  toString() {
2548
2757
  return (this.total.toFixed(2) +
@@ -2565,20 +2774,8 @@ class DroidPerformanceCalculator extends PerformanceCalculator {
2565
2774
  * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
2566
2775
  */
2567
2776
  class OsuSkill extends StrainSkill {
2568
- /**
2569
- * The default multiplier applied to the final difficulty value after all other calculations.
2570
- *
2571
- * May be overridden via {@link difficultyMultiplier}.
2572
- */
2573
- static defaultDifficultyMultiplier = 1.06;
2574
- /**
2575
- * The final multiplier to be applied to the final difficulty value after all other calculations.
2576
- */
2577
- difficultyMultiplier = OsuSkill.defaultDifficultyMultiplier;
2578
2777
  difficultyValue() {
2579
- const strains = this.strainPeaks
2580
- .slice()
2581
- .sort((a, b) => b - a);
2778
+ const strains = this.strainPeaks.slice().sort((a, b) => b - a);
2582
2779
  if (this.reducedSectionCount > 0) {
2583
2780
  // We are reducing the highest strains first to account for extreme difficulty spikes.
2584
2781
  for (let i = 0; i < Math.min(strains.length, this.reducedSectionCount); ++i) {
@@ -2589,17 +2786,17 @@ class OsuSkill extends StrainSkill {
2589
2786
  }
2590
2787
  // Difficulty is the weighted sum of the highest strains from every section.
2591
2788
  // We're sorting from highest to lowest strain.
2592
- let difficulty = 0;
2789
+ this.difficulty = 0;
2593
2790
  let weight = 1;
2594
2791
  for (const strain of strains) {
2595
2792
  const addition = strain * weight;
2596
- if (difficulty + addition === difficulty) {
2793
+ if (this.difficulty + addition === this.difficulty) {
2597
2794
  break;
2598
2795
  }
2599
- difficulty += addition;
2796
+ this.difficulty += addition;
2600
2797
  weight *= this.decayWeight;
2601
2798
  }
2602
- return difficulty * this.difficultyMultiplier;
2799
+ return this.difficulty;
2603
2800
  }
2604
2801
  }
2605
2802
 
@@ -2622,7 +2819,7 @@ class OsuAimEvaluator extends AimEvaluator {
2622
2819
  const last = current.previous(0);
2623
2820
  if (current.object instanceof osuBase.Spinner ||
2624
2821
  current.index <= 1 ||
2625
- last?.object instanceof osuBase.Spinner) {
2822
+ (last === null || last === void 0 ? void 0 : last.object) instanceof osuBase.Spinner) {
2626
2823
  return 0;
2627
2824
  }
2628
2825
  const lastLast = current.previous(1);
@@ -2729,36 +2926,38 @@ class OsuAimEvaluator extends AimEvaluator {
2729
2926
  * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
2730
2927
  */
2731
2928
  class OsuAim extends OsuSkill {
2732
- skillMultiplier = 23.55;
2733
- strainDecayBase = 0.15;
2734
- reducedSectionCount = 10;
2735
- reducedSectionBaseline = 0.75;
2736
- difficultyMultiplier = 1.06;
2737
- decayWeight = 0.9;
2738
- withSliders;
2739
2929
  constructor(mods, withSliders) {
2740
2930
  super(mods);
2931
+ this.strainDecayBase = 0.15;
2932
+ this.reducedSectionCount = 10;
2933
+ this.reducedSectionBaseline = 0.75;
2934
+ this.decayWeight = 0.9;
2935
+ this.currentAimStrain = 0;
2936
+ this.skillMultiplier = 25.18;
2741
2937
  this.withSliders = withSliders;
2742
2938
  }
2743
- /**
2744
- * @param current The hitobject to calculate.
2745
- */
2746
2939
  strainValueAt(current) {
2747
- this.currentStrain *= this.strainDecay(current.deltaTime);
2748
- this.currentStrain +=
2940
+ this.currentAimStrain *= this.strainDecay(current.deltaTime);
2941
+ this.currentAimStrain +=
2749
2942
  OsuAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
2750
2943
  this.skillMultiplier;
2751
- return this.currentStrain;
2944
+ this._objectStrains.push(this.currentAimStrain);
2945
+ return this.currentAimStrain;
2946
+ }
2947
+ calculateInitialStrain(time, current) {
2948
+ var _a, _b;
2949
+ return (this.currentAimStrain *
2950
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
2752
2951
  }
2753
2952
  /**
2754
2953
  * @param current The hitobject to save to.
2755
2954
  */
2756
2955
  saveToHitObject(current) {
2757
2956
  if (this.withSliders) {
2758
- current.aimStrainWithSliders = this.currentStrain;
2957
+ current.aimStrainWithSliders = this.currentAimStrain;
2759
2958
  }
2760
2959
  else {
2761
- current.aimStrainWithoutSliders = this.currentStrain;
2960
+ current.aimStrainWithoutSliders = this.currentAimStrain;
2762
2961
  }
2763
2962
  }
2764
2963
  }
@@ -2767,10 +2966,6 @@ class OsuAim extends OsuSkill {
2767
2966
  * An evaluator for calculating osu!standard speed skill.
2768
2967
  */
2769
2968
  class OsuSpeedEvaluator extends SpeedEvaluator {
2770
- /**
2771
- * Spacing threshold for a single hitobject spacing.
2772
- */
2773
- static SINGLE_SPACING_THRESHOLD = 125;
2774
2969
  /**
2775
2970
  * Evaluates the difficulty of tapping the current object, based on:
2776
2971
  *
@@ -2779,67 +2974,70 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
2779
2974
  * - and how easily they can be cheesed.
2780
2975
  *
2781
2976
  * @param current The current object.
2782
- * @param greatWindow The great hit window of the current object.
2783
2977
  */
2784
- static evaluateDifficultyOf(current, greatWindow) {
2978
+ static evaluateDifficultyOf(current) {
2979
+ var _a;
2785
2980
  if (current.object instanceof osuBase.Spinner) {
2786
2981
  return 0;
2787
2982
  }
2788
2983
  const prev = current.previous(0);
2789
2984
  let strainTime = current.strainTime;
2790
- const greatWindowFull = greatWindow * 2;
2791
2985
  // Nerf doubletappable doubles.
2792
- const next = current.next(0);
2793
- let doubletapness = 1;
2794
- if (next) {
2795
- const currentDeltaTime = Math.max(1, current.deltaTime);
2796
- const nextDeltaTime = Math.max(1, next.deltaTime);
2797
- const deltaDifference = Math.abs(nextDeltaTime - currentDeltaTime);
2798
- const speedRatio = currentDeltaTime / Math.max(currentDeltaTime, deltaDifference);
2799
- const windowRatio = Math.pow(Math.min(1, currentDeltaTime / greatWindowFull), 2);
2800
- doubletapness = Math.pow(speedRatio, 1 - windowRatio);
2801
- }
2986
+ const doubletapness = 1 - current.doubletapness;
2802
2987
  // Cap deltatime to the OD 300 hitwindow.
2803
2988
  // 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.
2804
- strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.93, 0.92, 1);
2805
- let speedBonus = 1;
2989
+ strainTime /= osuBase.MathUtils.clamp(strainTime / current.fullGreatWindow / 0.93, 0.92, 1);
2990
+ // speedBonus will be 0.0 for BPM < 200
2991
+ let speedBonus = 0;
2992
+ // Add additional scaling bonus for streams/bursts higher than 200bpm
2806
2993
  if (strainTime < this.minSpeedBonus) {
2807
- speedBonus +=
2994
+ speedBonus =
2808
2995
  0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
2809
2996
  }
2810
- const travelDistance = prev?.travelDistance ?? 0;
2997
+ const travelDistance = (_a = prev === null || prev === void 0 ? void 0 : prev.travelDistance) !== null && _a !== void 0 ? _a : 0;
2998
+ // Cap distance at spacing threshold
2811
2999
  const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
2812
- return (((speedBonus +
2813
- speedBonus *
2814
- Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.5)) *
2815
- doubletapness) /
2816
- strainTime);
3000
+ // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
3001
+ const distanceBonus = Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.95) *
3002
+ this.DISTANCE_MULTIPLIER;
3003
+ // Base difficulty with all bonuses
3004
+ const difficulty = ((1 + speedBonus + distanceBonus) * 1000) / strainTime;
3005
+ // Apply penalty if there's doubletappable doubles
3006
+ return difficulty * doubletapness;
2817
3007
  }
2818
3008
  }
3009
+ /**
3010
+ * Spacing threshold for a single hitobject spacing.
3011
+ *
3012
+ * About 1.25 circles distance between hitobject centers.
3013
+ */
3014
+ OsuSpeedEvaluator.SINGLE_SPACING_THRESHOLD = 125;
3015
+ OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.94;
2819
3016
 
2820
3017
  /**
2821
3018
  * An evaluator for calculating osu!standard Rhythm skill.
2822
3019
  */
2823
- class OsuRhythmEvaluator extends RhythmEvaluator {
3020
+ class OsuRhythmEvaluator {
2824
3021
  /**
2825
3022
  * Calculates a rhythm multiplier for the difficulty of the tap associated
2826
3023
  * with historic data of the current object.
2827
3024
  *
2828
3025
  * @param current The current object.
2829
- * @param greatWindow The great hit window of the current object.
2830
3026
  */
2831
- static evaluateDifficultyOf(current, greatWindow) {
3027
+ static evaluateDifficultyOf(current) {
2832
3028
  if (current.object instanceof osuBase.Spinner) {
2833
3029
  return 0;
2834
3030
  }
2835
- let previousIslandSize = 0;
3031
+ const deltaDifferenceEpsilon = current.fullGreatWindow * 0.3;
2836
3032
  let rhythmComplexitySum = 0;
2837
- let islandSize = 1;
3033
+ let island = new Island(deltaDifferenceEpsilon);
3034
+ let previousIsland = new Island(deltaDifferenceEpsilon);
3035
+ const islandCounts = new Map();
2838
3036
  // Store the ratio of the current start of an island to buff for tighter rhythms.
2839
3037
  let startRatio = 0;
2840
3038
  let firstDeltaSwitch = false;
2841
- const historicalNoteCount = Math.min(current.index, 32);
2842
3039
  let rhythmStart = 0;
3040
+ const historicalNoteCount = Math.min(current.index, this.historyObjectsMax);
2843
3041
  while (rhythmStart < historicalNoteCount - 2 &&
2844
3042
  current.startTime - current.previous(rhythmStart).startTime <
2845
3043
  this.historyTimeMax) {
@@ -2850,98 +3048,133 @@ class OsuRhythmEvaluator extends RhythmEvaluator {
2850
3048
  const prevObject = current.previous(i);
2851
3049
  const lastObject = current.previous(i + 1);
2852
3050
  // Scale note 0 to 1 from history to now.
2853
- let currentHistoricalDecay = (this.historyTimeMax -
3051
+ const timeDecay = (this.historyTimeMax -
2854
3052
  (current.startTime - currentObject.startTime)) /
2855
3053
  this.historyTimeMax;
3054
+ const noteDecay = (historicalNoteCount - i) / historicalNoteCount;
2856
3055
  // Either we're limited by time or limited by object count.
2857
- currentHistoricalDecay = Math.min(currentHistoricalDecay, (historicalNoteCount - i) / historicalNoteCount);
3056
+ const currentHistoricalDecay = Math.min(timeDecay, noteDecay);
2858
3057
  const currentDelta = currentObject.strainTime;
2859
3058
  const prevDelta = prevObject.strainTime;
2860
3059
  const lastDelta = lastObject.strainTime;
3060
+ // Calculate how much current delta difference deserves a rhythm bonus
3061
+ // This function is meant to reduce rhythm bonus for deltas that are multiples of each other (i.e. 100 and 200)
3062
+ const deltaDifferenceRatio = Math.min(prevDelta, currentDelta) /
3063
+ Math.max(prevDelta, currentDelta);
2861
3064
  const currentRatio = 1 +
2862
- 6 *
2863
- Math.min(0.5, Math.pow(Math.sin(Math.PI /
2864
- (Math.min(prevDelta, currentDelta) /
2865
- Math.max(prevDelta, currentDelta))), 2));
2866
- const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - greatWindow * 0.6) /
2867
- (greatWindow * 0.6));
2868
- let effectiveRatio = windowPenalty * currentRatio;
3065
+ this.rhythmRatioMultiplier *
3066
+ Math.min(0.5, Math.pow(Math.sin(Math.PI / deltaDifferenceRatio), 2));
3067
+ // Reduce ratio bonus if delta difference is too big
3068
+ const fraction = Math.max(prevDelta / currentDelta, currentDelta / prevDelta);
3069
+ const fractionMultiplier = osuBase.MathUtils.clamp(2 - fraction / 8, 0, 1);
3070
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - deltaDifferenceEpsilon) / deltaDifferenceEpsilon);
3071
+ let effectiveRatio = windowPenalty * currentRatio * fractionMultiplier;
2869
3072
  if (firstDeltaSwitch) {
2870
- if (prevDelta <= 1.25 * currentDelta &&
2871
- prevDelta * 1.25 >= currentDelta) {
3073
+ if (Math.abs(prevDelta - currentDelta) < deltaDifferenceEpsilon) {
2872
3074
  // Island is still progressing, count size.
2873
- if (islandSize < 7) {
2874
- ++islandSize;
2875
- }
3075
+ island.addDelta(currentDelta);
2876
3076
  }
2877
3077
  else {
3078
+ // BPM change is into slider, this is easy acc window.
2878
3079
  if (currentObject.object instanceof osuBase.Slider) {
2879
- // BPM change is into slider, this is easy acc window.
2880
3080
  effectiveRatio /= 8;
2881
3081
  }
3082
+ // BPM change was from a slider, this is easier typically than circle -> circle.
3083
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
3084
+ // than bursts without sliders.
2882
3085
  if (prevObject.object instanceof osuBase.Slider) {
2883
- // BPM change was from a slider, this is typically easier than circle -> circle.
2884
- effectiveRatio /= 4;
2885
- }
2886
- if (previousIslandSize === islandSize) {
2887
- // Repeated island size (ex: triplet -> triplet).
2888
- effectiveRatio /= 4;
3086
+ effectiveRatio *= 0.3;
2889
3087
  }
2890
- if (previousIslandSize % 2 === islandSize % 2) {
2891
- // Repeated island polarity (2 -> 4, 3 -> 5).
3088
+ // Repeated island polarity (2 -> 4, 3 -> 5).
3089
+ if (island.isSimilarPolarity(previousIsland)) {
2892
3090
  effectiveRatio /= 2;
2893
3091
  }
2894
- if (lastDelta > prevDelta + 10 &&
2895
- prevDelta > currentDelta + 10) {
2896
- // Previous increase happened a note ago.
2897
- // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
3092
+ // Previous increase happened a note ago.
3093
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
3094
+ if (lastDelta > prevDelta + deltaDifferenceEpsilon &&
3095
+ prevDelta > currentDelta + deltaDifferenceEpsilon) {
2898
3096
  effectiveRatio /= 8;
2899
3097
  }
3098
+ // Repeated island size (ex: triplet -> triplet).
3099
+ // TODO: remove this nerf since its staying here only for balancing purposes because of the flawed ratio calculation
3100
+ if (previousIsland.deltaCount == island.deltaCount) {
3101
+ effectiveRatio /= 2;
3102
+ }
3103
+ let islandFound = false;
3104
+ for (const [currentIsland, count] of islandCounts) {
3105
+ if (!island.equals(currentIsland)) {
3106
+ continue;
3107
+ }
3108
+ islandFound = true;
3109
+ let islandCount = count;
3110
+ if (previousIsland.equals(island)) {
3111
+ // Only add island to island counts if they're going one after another.
3112
+ ++islandCount;
3113
+ islandCounts.set(currentIsland, islandCount);
3114
+ }
3115
+ // Repeated island (ex: triplet -> triplet).
3116
+ // Graph: https://www.desmos.com/calculator/pj7an56zwf
3117
+ effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
3118
+ break;
3119
+ }
3120
+ if (!islandFound) {
3121
+ islandCounts.set(island, 1);
3122
+ }
3123
+ // Scale down the difficulty if the object is doubletappable.
3124
+ effectiveRatio *= 1 - prevObject.doubletapness * 0.75;
2900
3125
  rhythmComplexitySum +=
2901
- (((Math.sqrt(effectiveRatio * startRatio) *
2902
- currentHistoricalDecay *
2903
- Math.sqrt(4 + islandSize)) /
2904
- 2) *
2905
- Math.sqrt(4 + previousIslandSize)) /
2906
- 2;
3126
+ Math.sqrt(effectiveRatio * startRatio) *
3127
+ currentHistoricalDecay;
2907
3128
  startRatio = effectiveRatio;
2908
- previousIslandSize = islandSize;
2909
- if (prevDelta * 1.25 < currentDelta) {
3129
+ previousIsland = island;
3130
+ if (prevDelta + deltaDifferenceEpsilon < currentDelta) {
2910
3131
  // We're slowing down, stop counting.
2911
3132
  // If we're speeding up, this stays as is and we keep counting island size.
2912
3133
  firstDeltaSwitch = false;
2913
3134
  }
2914
- islandSize = 1;
3135
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
2915
3136
  }
2916
3137
  }
2917
- else if (prevDelta > 1.25 * currentDelta) {
2918
- // We want to be speeding up.
3138
+ else if (prevDelta > currentDelta + deltaDifferenceEpsilon) {
3139
+ // We are speeding up.
2919
3140
  // Begin counting island until we change speed again.
2920
3141
  firstDeltaSwitch = true;
3142
+ // BPM change is into slider, this is easy acc window.
3143
+ if (currentObject.object instanceof osuBase.Slider) {
3144
+ effectiveRatio *= 0.6;
3145
+ }
3146
+ // BPM change was from a slider, this is easier typically than circle -> circle
3147
+ // Unintentional side effect is that bursts with kicksliders at the ends might have lower difficulty
3148
+ // than bursts without sliders
3149
+ if (prevObject.object instanceof osuBase.Slider) {
3150
+ effectiveRatio *= 0.6;
3151
+ }
2921
3152
  startRatio = effectiveRatio;
2922
- islandSize = 1;
3153
+ island = new Island(currentDelta, deltaDifferenceEpsilon);
2923
3154
  }
2924
3155
  }
2925
- return Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier) / 2;
3156
+ return (Math.sqrt(4 + rhythmComplexitySum * this.rhythmOverallMultiplier) /
3157
+ 2);
2926
3158
  }
2927
3159
  }
3160
+ OsuRhythmEvaluator.historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
3161
+ OsuRhythmEvaluator.historyObjectsMax = 32;
3162
+ OsuRhythmEvaluator.rhythmOverallMultiplier = 0.95;
3163
+ OsuRhythmEvaluator.rhythmRatioMultiplier = 12;
2928
3164
 
2929
3165
  /**
2930
3166
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
2931
3167
  */
2932
3168
  class OsuSpeed extends OsuSkill {
2933
- skillMultiplier = 1375;
2934
- strainDecayBase = 0.3;
2935
- reducedSectionCount = 5;
2936
- reducedSectionBaseline = 0.75;
2937
- difficultyMultiplier = 1.04;
2938
- decayWeight = 0.9;
2939
- currentSpeedStrain = 0;
2940
- currentRhythm = 0;
2941
- greatWindow;
2942
- constructor(mods, greatWindow) {
2943
- super(mods);
2944
- this.greatWindow = greatWindow;
3169
+ constructor() {
3170
+ super(...arguments);
3171
+ this.strainDecayBase = 0.3;
3172
+ this.reducedSectionCount = 5;
3173
+ this.reducedSectionBaseline = 0.75;
3174
+ this.decayWeight = 0.9;
3175
+ this.currentSpeedStrain = 0;
3176
+ this.currentRhythm = 0;
3177
+ this.skillMultiplier = 1.43;
2945
3178
  }
2946
3179
  /**
2947
3180
  * @param current The hitobject to calculate.
@@ -2949,16 +3182,24 @@ class OsuSpeed extends OsuSkill {
2949
3182
  strainValueAt(current) {
2950
3183
  this.currentSpeedStrain *= this.strainDecay(current.strainTime);
2951
3184
  this.currentSpeedStrain +=
2952
- OsuSpeedEvaluator.evaluateDifficultyOf(current, this.greatWindow) *
3185
+ OsuSpeedEvaluator.evaluateDifficultyOf(current) *
2953
3186
  this.skillMultiplier;
2954
- this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current, this.greatWindow);
2955
- return this.currentSpeedStrain * this.currentRhythm;
3187
+ this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
3188
+ const strain = this.currentSpeedStrain * this.currentRhythm;
3189
+ this._objectStrains.push(strain);
3190
+ return strain;
3191
+ }
3192
+ calculateInitialStrain(time, current) {
3193
+ var _a, _b;
3194
+ return (this.currentSpeedStrain *
3195
+ this.currentRhythm *
3196
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
2956
3197
  }
2957
3198
  /**
2958
3199
  * @param current The hitobject to save to.
2959
3200
  */
2960
3201
  saveToHitObject(current) {
2961
- current.tapStrain = this.currentStrain;
3202
+ current.speedStrain = this.currentSpeedStrain * this.currentRhythm;
2962
3203
  current.rhythmMultiplier = this.currentRhythm;
2963
3204
  }
2964
3205
  }
@@ -2983,7 +3224,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
2983
3224
  if (current.object instanceof osuBase.Spinner) {
2984
3225
  return 0;
2985
3226
  }
2986
- const scalingFactor = 52 / current.object.getRadius(osuBase.Modes.osu);
3227
+ const scalingFactor = 52 / current.object.radius;
2987
3228
  let smallDistNerf = 1;
2988
3229
  let cumulativeStrainTime = 0;
2989
3230
  let result = 0;
@@ -2994,7 +3235,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
2994
3235
  if (!(currentObject.object instanceof osuBase.Spinner)) {
2995
3236
  const jumpDistance = current.object
2996
3237
  .getStackedPosition(osuBase.Modes.osu)
2997
- .subtract(currentObject.object.endPosition).length;
3238
+ .subtract(currentObject.object.getStackedEndPosition(osuBase.Modes.osu)).length;
2998
3239
  cumulativeStrainTime += last.strainTime;
2999
3240
  // We want to nerf objects that can be easily seen within the Flashlight circle radius.
3000
3241
  if (i === 0) {
@@ -3006,7 +3247,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3006
3247
  const opacityBonus = 1 +
3007
3248
  this.maxOpacityBonus *
3008
3249
  (1 -
3009
- current.opacityAt(currentObject.object.startTime, isHiddenMod, osuBase.Modes.osu));
3250
+ current.opacityAt(currentObject.object.startTime, isHiddenMod));
3010
3251
  result +=
3011
3252
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
3012
3253
  cumulativeStrainTime;
@@ -3037,8 +3278,8 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3037
3278
  // Longer sliders require more memorization.
3038
3279
  sliderBonus *= pixelTravelDistance;
3039
3280
  // Nerf sliders with repeats, as less memorization is required.
3040
- if (current.object.repeats > 0)
3041
- sliderBonus /= current.object.repeats + 1;
3281
+ if (current.object.repeatCount > 0)
3282
+ sliderBonus /= current.object.repeatCount + 1;
3042
3283
  }
3043
3284
  result += sliderBonus * this.sliderMultiplier;
3044
3285
  return result;
@@ -3049,27 +3290,62 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3049
3290
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
3050
3291
  */
3051
3292
  class OsuFlashlight extends OsuSkill {
3052
- skillMultiplier = 0.052;
3053
- strainDecayBase = 0.15;
3054
- reducedSectionCount = 0;
3055
- reducedSectionBaseline = 1;
3056
- decayWeight = 1;
3057
- isHidden;
3058
3293
  constructor(mods) {
3059
3294
  super(mods);
3295
+ this.strainDecayBase = 0.15;
3296
+ this.reducedSectionCount = 0;
3297
+ this.reducedSectionBaseline = 1;
3298
+ this.decayWeight = 1;
3299
+ this.currentFlashlightStrain = 0;
3300
+ this.skillMultiplier = 0.05512;
3060
3301
  this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
3061
3302
  }
3062
- /**
3063
- * @param current The hitobject to calculate.
3064
- */
3303
+ difficultyValue() {
3304
+ return this.strainPeaks.reduce((a, b) => a + b, 0);
3305
+ }
3065
3306
  strainValueAt(current) {
3066
- this.currentStrain *= this.strainDecay(current.deltaTime);
3067
- this.currentStrain +=
3307
+ this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
3308
+ this.currentFlashlightStrain +=
3068
3309
  OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden) * this.skillMultiplier;
3069
- return this.currentStrain;
3310
+ return this.currentFlashlightStrain;
3311
+ }
3312
+ calculateInitialStrain(time, current) {
3313
+ var _a, _b;
3314
+ return (this.currentFlashlightStrain *
3315
+ this.strainDecay(time - ((_b = (_a = current.previous(0)) === null || _a === void 0 ? void 0 : _a.startTime) !== null && _b !== void 0 ? _b : 0)));
3070
3316
  }
3071
3317
  saveToHitObject(current) {
3072
- current.flashlightStrainWithSliders = this.currentStrain;
3318
+ current.flashlightStrain = this.currentFlashlightStrain;
3319
+ }
3320
+ }
3321
+
3322
+ /**
3323
+ * Represents an osu!standard hit object with difficulty calculation values.
3324
+ */
3325
+ class OsuDifficultyHitObject extends DifficultyHitObject {
3326
+ constructor() {
3327
+ super(...arguments);
3328
+ /**
3329
+ * The speed strain generated by the hitobject.
3330
+ */
3331
+ this.speedStrain = 0;
3332
+ /**
3333
+ * The flashlight strain generated by this hitobject.
3334
+ */
3335
+ this.flashlightStrain = 0;
3336
+ this.radiusBuffThreshold = 30;
3337
+ this.mode = osuBase.Modes.osu;
3338
+ }
3339
+ get scalingFactor() {
3340
+ const radius = this.object.radius;
3341
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
3342
+ let scalingFactor = this.normalizedRadius / radius;
3343
+ // High circle size (small CS) bonus
3344
+ if (radius < this.radiusBuffThreshold) {
3345
+ scalingFactor *=
3346
+ 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
3347
+ }
3348
+ return scalingFactor;
3073
3349
  }
3074
3350
  }
3075
3351
 
@@ -3077,35 +3353,50 @@ class OsuFlashlight extends OsuSkill {
3077
3353
  * A difficulty calculator for osu!standard gamemode.
3078
3354
  */
3079
3355
  class OsuDifficultyCalculator extends DifficultyCalculator {
3356
+ constructor() {
3357
+ super(...arguments);
3358
+ this.attributes = {
3359
+ speedDifficulty: 0,
3360
+ mods: [],
3361
+ starRating: 0,
3362
+ maxCombo: 0,
3363
+ aimDifficulty: 0,
3364
+ flashlightDifficulty: 0,
3365
+ speedNoteCount: 0,
3366
+ sliderFactor: 0,
3367
+ clockRate: 1,
3368
+ approachRate: 0,
3369
+ overallDifficulty: 0,
3370
+ hitCircleCount: 0,
3371
+ sliderCount: 0,
3372
+ spinnerCount: 0,
3373
+ aimDifficultStrainCount: 0,
3374
+ speedDifficultStrainCount: 0,
3375
+ };
3376
+ this.difficultyMultiplier = 0.0675;
3377
+ this.mode = osuBase.Modes.osu;
3378
+ }
3080
3379
  /**
3081
3380
  * The aim star rating of the beatmap.
3082
3381
  */
3083
- aim = 0;
3382
+ get aim() {
3383
+ return this.attributes.aimDifficulty;
3384
+ }
3084
3385
  /**
3085
3386
  * The speed star rating of the beatmap.
3086
3387
  */
3087
- speed = 0;
3388
+ get speed() {
3389
+ return this.attributes.speedDifficulty;
3390
+ }
3088
3391
  /**
3089
3392
  * The flashlight star rating of the beatmap.
3090
3393
  */
3091
- flashlight = 0;
3092
- attributes = {
3093
- speedDifficulty: 0,
3094
- mods: [],
3095
- starRating: 0,
3096
- maxCombo: 0,
3097
- aimDifficulty: 0,
3098
- flashlightDifficulty: 0,
3099
- speedNoteCount: 0,
3100
- sliderFactor: 0,
3101
- approachRate: 0,
3102
- overallDifficulty: 0,
3103
- hitCircleCount: 0,
3104
- sliderCount: 0,
3105
- spinnerCount: 0,
3106
- };
3107
- difficultyMultiplier = 0.0675;
3108
- mode = osuBase.Modes.osu;
3394
+ get flashlight() {
3395
+ return this.attributes.flashlightDifficulty;
3396
+ }
3397
+ get cacheableAttributes() {
3398
+ return Object.assign(Object.assign({}, this.attributes), { mods: osuBase.ModUtil.modsToOsuString(this.attributes.mods) });
3399
+ }
3109
3400
  /**
3110
3401
  * Calculates the aim star rating of the beatmap and stores it in this instance.
3111
3402
  */
@@ -3120,10 +3411,10 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3120
3411
  */
3121
3412
  calculateSpeed() {
3122
3413
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3123
- this.speed = this.attributes.speedDifficulty = 0;
3414
+ this.attributes.speedDifficulty = 0;
3124
3415
  return;
3125
3416
  }
3126
- const speedSkill = new OsuSpeed(this.mods, new osuBase.OsuHitWindow(this.stats.od).hitWindowFor300());
3417
+ const speedSkill = new OsuSpeed(this.mods);
3127
3418
  this.calculateSkills(speedSkill);
3128
3419
  this.postCalculateSpeed(speedSkill);
3129
3420
  }
@@ -3148,14 +3439,14 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3148
3439
  if (basePerformanceValue > 1e-5) {
3149
3440
  // Document for formula derivation:
3150
3441
  // https://docs.google.com/document/d/10DZGYYSsT_yjz2Mtp6yIJld0Rqx4E-vVHupCqiM4TNI/edit
3151
- this.total = this.attributes.starRating =
3152
- Math.cbrt(1.14) *
3442
+ this.attributes.starRating =
3443
+ Math.cbrt(1.15) *
3153
3444
  0.027 *
3154
3445
  (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
3155
3446
  4);
3156
3447
  }
3157
3448
  else {
3158
- this.total = this.attributes.starRating = 0;
3449
+ this.attributes.starRating = 0;
3159
3450
  }
3160
3451
  }
3161
3452
  calculateAll() {
@@ -3168,7 +3459,6 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3168
3459
  const flashlightSkill = skills[3];
3169
3460
  this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
3170
3461
  if (isRelax) {
3171
- this.speed = 0;
3172
3462
  this.attributes.speedDifficulty = 0;
3173
3463
  }
3174
3464
  else {
@@ -3178,9 +3468,6 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3178
3468
  this.postCalculateFlashlight(flashlightSkill);
3179
3469
  this.calculateTotal();
3180
3470
  }
3181
- /**
3182
- * Returns a string representative of the class.
3183
- */
3184
3471
  toString() {
3185
3472
  return (this.total.toFixed(2) +
3186
3473
  " stars (" +
@@ -3191,17 +3478,32 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3191
3478
  this.flashlight.toFixed(2) +
3192
3479
  " flashlight)");
3193
3480
  }
3194
- /**
3195
- * Creates skills to be calculated.
3196
- */
3481
+ generateDifficultyHitObjects(beatmap, clockRate) {
3482
+ var _a, _b;
3483
+ const difficultyObjects = [];
3484
+ const { objects } = beatmap.hitObjects;
3485
+ const greatWindow = new osuBase.OsuHitWindow(beatmap.difficulty.od).hitWindowFor300() /
3486
+ clockRate;
3487
+ for (let i = 0; i < objects.length; ++i) {
3488
+ 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);
3489
+ difficultyObject.computeProperties(clockRate, objects);
3490
+ difficultyObjects.push(difficultyObject);
3491
+ }
3492
+ return difficultyObjects;
3493
+ }
3197
3494
  createSkills() {
3198
3495
  return [
3199
3496
  new OsuAim(this.mods, true),
3200
3497
  new OsuAim(this.mods, false),
3201
- new OsuSpeed(this.mods, new osuBase.OsuHitWindow(this.stats.od).hitWindowFor300()),
3498
+ new OsuSpeed(this.mods),
3202
3499
  new OsuFlashlight(this.mods),
3203
3500
  ];
3204
3501
  }
3502
+ populateDifficultyAttributes(beatmap, clockRate) {
3503
+ super.populateDifficultyAttributes(beatmap, clockRate);
3504
+ const preempt = osuBase.BeatmapDifficulty.difficultyRange(beatmap.difficulty.ar, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin) / clockRate;
3505
+ this.attributes.approachRate = osuBase.BeatmapDifficulty.inverseDifficultyRange(preempt, osuBase.HitObject.preemptMax, osuBase.HitObject.preemptMid, osuBase.HitObject.preemptMin);
3506
+ }
3205
3507
  /**
3206
3508
  * Called after aim skill calculation.
3207
3509
  *
@@ -3211,19 +3513,20 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3211
3513
  postCalculateAim(aimSkill, aimSkillWithoutSliders) {
3212
3514
  this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
3213
3515
  this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
3214
- this.aim = this.starValue(aimSkill.difficultyValue());
3516
+ this.attributes.aimDifficulty = this.starValue(aimSkill.difficultyValue());
3215
3517
  if (this.aim) {
3216
3518
  this.attributes.sliderFactor =
3217
3519
  this.starValue(aimSkillWithoutSliders.difficultyValue()) /
3218
3520
  this.aim;
3219
3521
  }
3220
3522
  if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
3221
- this.aim = Math.pow(this.aim, 0.8);
3523
+ this.attributes.aimDifficulty = Math.pow(this.aim, 0.8);
3222
3524
  }
3223
3525
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3224
- this.aim *= 0.9;
3526
+ this.attributes.aimDifficulty *= 0.9;
3225
3527
  }
3226
- this.attributes.aimDifficulty = this.aim;
3528
+ this.attributes.aimDifficultStrainCount =
3529
+ aimSkill.countDifficultStrains();
3227
3530
  }
3228
3531
  /**
3229
3532
  * Called after speed skill calculation.
@@ -3232,13 +3535,15 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3232
3535
  */
3233
3536
  postCalculateSpeed(speedSkill) {
3234
3537
  this.strainPeaks.speed = speedSkill.strainPeaks;
3235
- this.speed = this.attributes.speedDifficulty = this.starValue(speedSkill.difficultyValue());
3538
+ this.attributes.speedDifficulty = this.starValue(speedSkill.difficultyValue());
3539
+ this.attributes.speedDifficultStrainCount =
3540
+ speedSkill.countDifficultStrains();
3236
3541
  }
3237
3542
  /**
3238
3543
  * Calculates speed-related attributes.
3239
3544
  */
3240
3545
  calculateSpeedAttributes() {
3241
- const objectStrains = this.objects.map((v) => v.tapStrain);
3546
+ const objectStrains = this.objects.map((v) => v.speedStrain);
3242
3547
  const maxStrain = Math.max(...objectStrains);
3243
3548
  if (maxStrain) {
3244
3549
  this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
@@ -3251,55 +3556,13 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3251
3556
  */
3252
3557
  postCalculateFlashlight(flashlightSkill) {
3253
3558
  this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
3254
- this.flashlight = this.starValue(flashlightSkill.difficultyValue());
3559
+ this.attributes.flashlightDifficulty = this.starValue(flashlightSkill.difficultyValue());
3255
3560
  if (this.mods.some((m) => m instanceof osuBase.ModTouchDevice)) {
3256
- this.flashlight = Math.pow(this.flashlight, 0.8);
3561
+ this.attributes.flashlightDifficulty = Math.pow(this.flashlight, 0.8);
3257
3562
  }
3258
3563
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3259
- this.flashlight *= 0.7;
3564
+ this.attributes.flashlightDifficulty *= 0.7;
3260
3565
  }
3261
- this.attributes.flashlightDifficulty = this.flashlight;
3262
- }
3263
- }
3264
-
3265
- /**
3266
- * A difficulty calculator that calculates for both osu!droid and osu!standard gamemode.
3267
- */
3268
- class MapStars {
3269
- /**
3270
- * The osu!droid difficulty calculator of the beatmap.
3271
- */
3272
- droid;
3273
- /**
3274
- * The osu!standard difficulty calculator of the beatmap.
3275
- */
3276
- osu;
3277
- /**
3278
- * Constructs this instance and calculates the given beatmap's osu!droid and osu!standard difficulty.
3279
- *
3280
- * @param beatmap The beatmap to calculate.
3281
- * @param options Options for the difficulty calculation.
3282
- */
3283
- constructor(beatmap, options) {
3284
- const stats = new osuBase.MapStats({
3285
- speedMultiplier: options?.stats?.speedMultiplier ?? 1,
3286
- isForceAR: options?.stats?.isForceAR ?? false,
3287
- oldStatistics: options?.stats?.oldStatistics ?? false,
3288
- });
3289
- this.droid = new DroidDifficultyCalculator(beatmap).calculate({
3290
- ...options,
3291
- stats,
3292
- });
3293
- this.osu = new OsuDifficultyCalculator(beatmap).calculate({
3294
- ...options,
3295
- stats,
3296
- });
3297
- }
3298
- /**
3299
- * Returns a string representative of the class.
3300
- */
3301
- toString() {
3302
- return `${this.droid.toString()}\n${this.osu.toString()}`;
3303
3566
  }
3304
3567
  }
3305
3568
 
@@ -3307,65 +3570,60 @@ class MapStars {
3307
3570
  * A performance points calculator that calculates performance points for osu!standard gamemode.
3308
3571
  */
3309
3572
  class OsuPerformanceCalculator extends PerformanceCalculator {
3310
- /**
3311
- * The aim performance value.
3312
- */
3313
- aim = 0;
3314
- /**
3315
- * The speed performance value.
3316
- */
3317
- speed = 0;
3318
- /**
3319
- * The accuracy performance value.
3320
- */
3321
- accuracy = 0;
3322
- /**
3323
- * The flashlight performance value.
3324
- */
3325
- flashlight = 0;
3326
- difficultyAttributes;
3327
- finalMultiplier = 1.14;
3328
- mode = osuBase.Modes.osu;
3329
- /**
3330
- * @param difficultyAttributes The difficulty attributes to calculate.
3331
- */
3332
- constructor(difficultyAttributes) {
3333
- super();
3334
- this.difficultyAttributes = osuBase.Utils.deepCopy(difficultyAttributes);
3573
+ constructor() {
3574
+ super(...arguments);
3575
+ /**
3576
+ * The aim performance value.
3577
+ */
3578
+ this.aim = 0;
3579
+ /**
3580
+ * The speed performance value.
3581
+ */
3582
+ this.speed = 0;
3583
+ /**
3584
+ * The accuracy performance value.
3585
+ */
3586
+ this.accuracy = 0;
3587
+ /**
3588
+ * The flashlight performance value.
3589
+ */
3590
+ this.flashlight = 0;
3591
+ this.finalMultiplier = 1.15;
3592
+ this.mode = osuBase.Modes.osu;
3593
+ this.comboPenalty = 1;
3335
3594
  }
3336
3595
  calculateValues() {
3337
- this.calculateAimValue();
3338
- this.calculateSpeedValue();
3339
- this.calculateAccuracyValue();
3340
- this.calculateFlashlightValue();
3596
+ this.aim = this.calculateAimValue();
3597
+ this.speed = this.calculateSpeedValue();
3598
+ this.accuracy = this.calculateAccuracyValue();
3599
+ this.flashlight = this.calculateFlashlightValue();
3341
3600
  }
3342
3601
  calculateTotalValue() {
3343
- this.total =
3344
- Math.pow(Math.pow(this.aim, 1.1) +
3345
- Math.pow(this.speed, 1.1) +
3346
- Math.pow(this.accuracy, 1.1) +
3347
- Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier;
3602
+ return (Math.pow(Math.pow(this.aim, 1.1) +
3603
+ Math.pow(this.speed, 1.1) +
3604
+ Math.pow(this.accuracy, 1.1) +
3605
+ Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier);
3606
+ }
3607
+ handleOptions(options) {
3608
+ var _a;
3609
+ super.handleOptions(options);
3610
+ const maxCombo = this.difficultyAttributes.maxCombo;
3611
+ const miss = this.computedAccuracy.nmiss;
3612
+ const combo = (_a = options === null || options === void 0 ? void 0 : options.combo) !== null && _a !== void 0 ? _a : maxCombo - miss;
3613
+ this.comboPenalty = Math.min(Math.pow(combo / maxCombo, 0.8), 1);
3348
3614
  }
3349
3615
  /**
3350
3616
  * Calculates the aim performance value of the beatmap.
3351
3617
  */
3352
3618
  calculateAimValue() {
3353
- this.aim = this.baseValue(this.difficultyAttributes.aimDifficulty);
3619
+ let aimValue = this.baseValue(this.difficultyAttributes.aimDifficulty);
3354
3620
  // Longer maps are worth more
3355
3621
  let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
3356
3622
  if (this.totalHits > 2000) {
3357
3623
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
3358
3624
  }
3359
- this.aim *= lengthBonus;
3360
- if (this.effectiveMissCount > 0) {
3361
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3362
- this.aim *=
3363
- 0.97 *
3364
- Math.pow(1 -
3365
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
3366
- }
3367
- // Combo scaling
3368
- this.aim *= this.comboPenalty;
3625
+ aimValue *= lengthBonus;
3626
+ aimValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.aimDifficultStrainCount);
3369
3627
  const calculatedAR = this.difficultyAttributes.approachRate;
3370
3628
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3371
3629
  // AR scaling
@@ -3377,145 +3635,131 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3377
3635
  arFactor += 0.05 * (8 - calculatedAR);
3378
3636
  }
3379
3637
  // Buff for longer maps with high AR.
3380
- this.aim *= 1 + arFactor * lengthBonus;
3638
+ aimValue *= 1 + arFactor * lengthBonus;
3381
3639
  }
3382
3640
  // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
3383
3641
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3384
- this.aim *= 1 + 0.04 * (12 - calculatedAR);
3642
+ aimValue *= 1 + 0.04 * (12 - calculatedAR);
3385
3643
  }
3386
3644
  // Scale the aim value with slider factor to nerf very likely dropped sliderends.
3387
- this.aim *= this.sliderNerfFactor;
3645
+ aimValue *= this.sliderNerfFactor;
3388
3646
  // Scale the aim value with accuracy.
3389
- this.aim *= this.computedAccuracy.value(this.totalHits);
3647
+ aimValue *= this.computedAccuracy.value();
3390
3648
  // It is also important to consider accuracy difficulty when doing that.
3391
3649
  const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
3392
- this.aim *= 0.98 + odScaling;
3650
+ aimValue *= 0.98 + odScaling;
3651
+ return aimValue;
3393
3652
  }
3394
3653
  /**
3395
3654
  * Calculates the speed performance value of the beatmap.
3396
3655
  */
3397
3656
  calculateSpeedValue() {
3398
3657
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3399
- this.speed = 0;
3400
- return;
3658
+ return 0;
3401
3659
  }
3402
- // Global variables
3403
- this.speed = this.baseValue(this.difficultyAttributes.speedDifficulty);
3660
+ let speedValue = this.baseValue(this.difficultyAttributes.speedDifficulty);
3404
3661
  // Longer maps are worth more
3405
3662
  let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
3406
3663
  if (this.totalHits > 2000) {
3407
3664
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
3408
3665
  }
3409
- this.speed *= lengthBonus;
3410
- if (this.effectiveMissCount > 0) {
3411
- // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3412
- this.speed *=
3413
- 0.97 *
3414
- Math.pow(1 -
3415
- Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
3416
- }
3417
- // Combo scaling
3418
- this.speed *= this.comboPenalty;
3666
+ speedValue *= lengthBonus;
3667
+ speedValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.speedDifficultStrainCount);
3419
3668
  // AR scaling
3420
3669
  const calculatedAR = this.difficultyAttributes.approachRate;
3421
3670
  if (calculatedAR > 10.33) {
3422
3671
  // Buff for longer maps with high AR.
3423
- this.speed *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
3672
+ speedValue *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
3424
3673
  }
3425
3674
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3426
- this.speed *= 1 + 0.04 * (12 - calculatedAR);
3675
+ speedValue *= 1 + 0.04 * (12 - calculatedAR);
3427
3676
  }
3428
3677
  // Calculate accuracy assuming the worst case scenario.
3429
3678
  const countGreat = this.computedAccuracy.n300;
3430
3679
  const countOk = this.computedAccuracy.n100;
3431
3680
  const countMeh = this.computedAccuracy.n50;
3432
3681
  const relevantTotalDiff = this.totalHits - this.difficultyAttributes.speedNoteCount;
3433
- const relevantAccuracy = new osuBase.Accuracy({
3434
- n300: Math.max(0, countGreat - relevantTotalDiff),
3435
- n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
3436
- n50: Math.max(0, countMeh - Math.max(0, relevantTotalDiff - countGreat - countOk)),
3437
- nmiss: this.effectiveMissCount,
3438
- });
3682
+ const relevantAccuracy = new osuBase.Accuracy(this.difficultyAttributes.speedNoteCount > 0
3683
+ ? {
3684
+ n300: Math.max(0, countGreat - relevantTotalDiff),
3685
+ n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
3686
+ n50: Math.max(0, countMeh -
3687
+ Math.max(0, relevantTotalDiff - countGreat - countOk)),
3688
+ }
3689
+ : // Set accuracy to 0.
3690
+ { n300: 0, nobjects: 1 });
3439
3691
  // Scale the speed value with accuracy and OD.
3440
- this.speed *=
3692
+ speedValue *=
3441
3693
  (0.95 +
3442
3694
  Math.pow(this.difficultyAttributes.overallDifficulty, 2) /
3443
3695
  750) *
3444
- Math.pow((this.computedAccuracy.value(this.totalHits) +
3445
- relevantAccuracy.value()) /
3446
- 2, (14.5 -
3447
- Math.max(this.difficultyAttributes.overallDifficulty, 8)) /
3448
- 2);
3696
+ Math.pow((this.computedAccuracy.value() +
3697
+ relevantAccuracy.value(this.difficultyAttributes.speedNoteCount)) /
3698
+ 2, (14.5 - this.difficultyAttributes.overallDifficulty) / 2);
3449
3699
  // Scale the speed value with # of 50s to punish doubletapping.
3450
- this.speed *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
3700
+ speedValue *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
3701
+ return speedValue;
3451
3702
  }
3452
3703
  /**
3453
3704
  * Calculates the accuracy performance value of the beatmap.
3454
3705
  */
3455
3706
  calculateAccuracyValue() {
3456
3707
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModRelax)) {
3457
- this.accuracy = 0;
3458
- return;
3708
+ return 0;
3459
3709
  }
3460
3710
  const ncircles = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModScoreV2)
3461
3711
  ? this.totalHits - this.difficultyAttributes.spinnerCount
3462
3712
  : this.difficultyAttributes.hitCircleCount;
3463
3713
  if (ncircles === 0) {
3464
- this.accuracy = 0;
3465
- return;
3714
+ return 0;
3466
3715
  }
3467
- const realAccuracy = new osuBase.Accuracy({
3468
- ...this.computedAccuracy,
3469
- n300: this.computedAccuracy.n300 - (this.totalHits - ncircles),
3470
- });
3716
+ const realAccuracy = new osuBase.Accuracy(Object.assign(Object.assign({}, this.computedAccuracy), { n300: this.computedAccuracy.n300 - (this.totalHits - ncircles) }));
3471
3717
  // Lots of arbitrary values from testing.
3472
3718
  // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
3473
- this.accuracy =
3474
- Math.pow(1.52163, this.difficultyAttributes.overallDifficulty) *
3475
- Math.pow(realAccuracy.value(ncircles), 24) *
3476
- 2.83;
3719
+ let accuracyValue = Math.pow(1.52163, this.difficultyAttributes.overallDifficulty) *
3720
+ // It is possible to reach a negative accuracy with this formula. Cap it at zero - zero points.
3721
+ Math.pow(realAccuracy.n300 < 0 ? 0 : realAccuracy.value(), 24) *
3722
+ 2.83;
3477
3723
  // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
3478
- this.accuracy *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
3724
+ accuracyValue *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
3479
3725
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHidden)) {
3480
- this.accuracy *= 1.08;
3726
+ accuracyValue *= 1.08;
3481
3727
  }
3482
3728
  if (this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3483
- this.accuracy *= 1.02;
3729
+ accuracyValue *= 1.02;
3484
3730
  }
3731
+ return accuracyValue;
3485
3732
  }
3486
3733
  /**
3487
3734
  * Calculates the flashlight performance value of the beatmap.
3488
3735
  */
3489
3736
  calculateFlashlightValue() {
3490
3737
  if (!this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3491
- this.flashlight = 0;
3492
- return;
3738
+ return 0;
3493
3739
  }
3494
- // Global variables
3495
- this.flashlight =
3496
- Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
3740
+ let flashlightValue = Math.pow(this.difficultyAttributes.flashlightDifficulty, 2) * 25;
3497
3741
  // Combo scaling
3498
- this.flashlight *= this.comboPenalty;
3742
+ flashlightValue *= this.comboPenalty;
3499
3743
  if (this.effectiveMissCount > 0) {
3500
3744
  // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
3501
- this.flashlight *=
3745
+ flashlightValue *=
3502
3746
  0.97 *
3503
3747
  Math.pow(1 -
3504
3748
  Math.pow(this.effectiveMissCount / this.totalHits, 0.775), Math.pow(this.effectiveMissCount, 0.875));
3505
3749
  }
3506
3750
  // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
3507
- this.flashlight *=
3751
+ flashlightValue *=
3508
3752
  0.7 +
3509
3753
  0.1 * Math.min(1, this.totalHits / 200) +
3510
3754
  (this.totalHits > 200
3511
3755
  ? 0.2 * Math.min(1, (this.totalHits - 200) / 200)
3512
3756
  : 0);
3513
3757
  // Scale the flashlight value with accuracy slightly.
3514
- this.flashlight *=
3515
- 0.5 + this.computedAccuracy.value(this.totalHits) / 2;
3758
+ flashlightValue *= 0.5 + this.computedAccuracy.value() / 2;
3516
3759
  // It is also important to consider accuracy difficulty when doing that.
3517
3760
  const odScaling = Math.pow(this.difficultyAttributes.overallDifficulty, 2) / 2500;
3518
- this.flashlight *= 0.98 + odScaling;
3761
+ flashlightValue *= 0.98 + odScaling;
3762
+ return flashlightValue;
3519
3763
  }
3520
3764
  toString() {
3521
3765
  return (this.total.toFixed(2) +
@@ -3534,10 +3778,10 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3534
3778
  exports.AimEvaluator = AimEvaluator;
3535
3779
  exports.DifficultyCalculator = DifficultyCalculator;
3536
3780
  exports.DifficultyHitObject = DifficultyHitObject;
3537
- exports.DifficultyHitObjectCreator = DifficultyHitObjectCreator;
3538
3781
  exports.DroidAim = DroidAim;
3539
3782
  exports.DroidAimEvaluator = DroidAimEvaluator;
3540
3783
  exports.DroidDifficultyCalculator = DroidDifficultyCalculator;
3784
+ exports.DroidDifficultyHitObject = DroidDifficultyHitObject;
3541
3785
  exports.DroidFlashlight = DroidFlashlight;
3542
3786
  exports.DroidFlashlightEvaluator = DroidFlashlightEvaluator;
3543
3787
  exports.DroidPerformanceCalculator = DroidPerformanceCalculator;
@@ -3548,10 +3792,10 @@ exports.DroidTapEvaluator = DroidTapEvaluator;
3548
3792
  exports.DroidVisual = DroidVisual;
3549
3793
  exports.DroidVisualEvaluator = DroidVisualEvaluator;
3550
3794
  exports.FlashlightEvaluator = FlashlightEvaluator;
3551
- exports.MapStars = MapStars;
3552
3795
  exports.OsuAim = OsuAim;
3553
3796
  exports.OsuAimEvaluator = OsuAimEvaluator;
3554
3797
  exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
3798
+ exports.OsuDifficultyHitObject = OsuDifficultyHitObject;
3555
3799
  exports.OsuFlashlight = OsuFlashlight;
3556
3800
  exports.OsuFlashlightEvaluator = OsuFlashlightEvaluator;
3557
3801
  exports.OsuPerformanceCalculator = OsuPerformanceCalculator;
@@ -3559,6 +3803,5 @@ exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
3559
3803
  exports.OsuSpeed = OsuSpeed;
3560
3804
  exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
3561
3805
  exports.PerformanceCalculator = PerformanceCalculator;
3562
- exports.RhythmEvaluator = RhythmEvaluator;
3563
3806
  exports.SpeedEvaluator = SpeedEvaluator;
3564
3807
  //# sourceMappingURL=index.js.map