@rian8337/osu-difficulty-calculator 4.0.0-beta.2 → 4.0.0-beta.20

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