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

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