@rian8337/osu-difficulty-calculator 4.0.0-beta.4 → 4.0.0-beta.40

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