@rian8337/osu-difficulty-calculator 1.4.16 → 2.0.0-alpha.2

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
@@ -1,27 +1,2619 @@
1
- "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
5
- }) : (function(o, m, k, k2) {
6
- if (k2 === undefined) k2 = k;
7
- o[k2] = m[k];
8
- }));
9
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
10
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
11
- };
12
- Object.defineProperty(exports, "__esModule", { value: true });
13
- __exportStar(require("./preprocessing/DifficultyHitObject"), exports);
14
- __exportStar(require("./preprocessing/DifficultyHitObjectCreator"), exports);
15
- __exportStar(require("./skills/DroidAim"), exports);
16
- __exportStar(require("./skills/DroidFlashlight"), exports);
17
- __exportStar(require("./DroidPerformanceCalculator"), exports);
18
- __exportStar(require("./skills/DroidRhythm"), exports);
19
- __exportStar(require("./DroidStarRating"), exports);
20
- __exportStar(require("./skills/DroidTap"), exports);
21
- __exportStar(require("./MapStars"), exports);
22
- __exportStar(require("./skills/OsuAim"), exports);
23
- __exportStar(require("./skills/OsuFlashlight"), exports);
24
- __exportStar(require("./OsuPerformanceCalculator"), exports);
25
- __exportStar(require("./skills/OsuSpeed"), exports);
26
- __exportStar(require("./OsuStarRating"), exports);
27
- //# sourceMappingURL=index.js.map
1
+ 'use strict';
2
+
3
+ Object.defineProperty(exports, '__esModule', { value: true });
4
+
5
+ var osuBase = require('@rian8337/osu-base');
6
+
7
+ /**
8
+ * Represents an osu!standard hit object with difficulty calculation values.
9
+ */
10
+ class DifficultyHitObject {
11
+ /**
12
+ * The underlying hitobject.
13
+ */
14
+ object;
15
+ /**
16
+ * The aim strain generated by the hitobject if sliders are considered.
17
+ */
18
+ aimStrainWithSliders = 0;
19
+ /**
20
+ * The aim strain generated by the hitobject if sliders are not considered.
21
+ */
22
+ aimStrainWithoutSliders = 0;
23
+ /**
24
+ * The tap strain generated by the hitobject.
25
+ *
26
+ * This is also used for osu!standard as opposed to "speed strain".
27
+ */
28
+ tapStrain = 0;
29
+ /**
30
+ * The tap strain generated by the hitobject if `strainTime` isn't modified by
31
+ * OD. This is used in three-finger detection.
32
+ */
33
+ originalTapStrain = 0;
34
+ /**
35
+ * The rhythm multiplier generated by the hitobject. This is used to alter tap strain.
36
+ */
37
+ rhythmMultiplier = 0;
38
+ /**
39
+ * The rhythm strain generated by the hitobject.
40
+ */
41
+ rhythmStrain = 0;
42
+ /**
43
+ * The flashlight strain generated by the hitobject.
44
+ */
45
+ flashlightStrain = 0;
46
+ /**
47
+ * The visual strain generated by the hitobject.
48
+ */
49
+ visualStrain = 0;
50
+ /**
51
+ * The normalized distance from the "lazy" end position of the previous hitobject to the start position of this hitobject.
52
+ *
53
+ * 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).
54
+ */
55
+ lazyJumpDistance = 0;
56
+ /**
57
+ * The normalized shortest distance to consider for a jump between the previous hitobject and this hitobject.
58
+ *
59
+ * 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.
60
+ *
61
+ * 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.
62
+ * 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,
63
+ * such that the jump is felt as only starting from the slider's true end position.
64
+ *
65
+ * Now consider a slider - circle pattern where the circle is stacked along the path inside the slider.
66
+ * In this case, the lazy end position correctly estimates the true end position of the slider and provides the more natural movement path.
67
+ */
68
+ minimumJumpDistance = 0;
69
+ /**
70
+ * The time taken to travel through `minimumJumpDistance`, with a minimum value of 25ms.
71
+ */
72
+ minimumJumpTime = 0;
73
+ /**
74
+ * The normalized distance between the start and end position of this hitobject.
75
+ */
76
+ travelDistance = 0;
77
+ /**
78
+ * The time taken to travel through `travelDistance`, with a minimum value of 25ms for a non-zero distance.
79
+ */
80
+ travelTime = 0;
81
+ /**
82
+ * Angle the player has to take to hit this hitobject.
83
+ *
84
+ * Calculated as the angle between the circles (current-2, current-1, current).
85
+ */
86
+ angle = null;
87
+ /**
88
+ * The amount of milliseconds elapsed between this hitobject and the last hitobject.
89
+ */
90
+ deltaTime = 0;
91
+ /**
92
+ * The amount of milliseconds elapsed since the start time of the previous hitobject, with a minimum of 25ms.
93
+ */
94
+ strainTime = 0;
95
+ /**
96
+ * Adjusted start time of the hitobject, taking speed multiplier into account.
97
+ */
98
+ startTime = 0;
99
+ /**
100
+ * Adjusted end time of the hitobject, taking speed multiplier into account.
101
+ */
102
+ endTime = 0;
103
+ /**
104
+ * The note density of the hitobject.
105
+ */
106
+ noteDensity = 0;
107
+ /**
108
+ * The overlapping factor of the hitobject.
109
+ *
110
+ * This is used to scale visual skill.
111
+ */
112
+ overlappingFactor = 0;
113
+ /**
114
+ * Adjusted velocity of the hitobject, taking speed multiplier into account.
115
+ */
116
+ velocity = 0;
117
+ /**
118
+ * @param object The underlying hitobject.
119
+ */
120
+ constructor(object) {
121
+ this.object = object;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * A converter used to convert normal hitobjects into difficulty hitobjects.
127
+ */
128
+ class DifficultyHitObjectCreator {
129
+ /**
130
+ * The threshold for small circle buff for osu!droid.
131
+ */
132
+ DROID_CIRCLESIZE_BUFF_THRESHOLD = 52.5;
133
+ /**
134
+ * The threshold for small circle buff for osu!standard.
135
+ */
136
+ PC_CIRCLESIZE_BUFF_THRESHOLD = 30;
137
+ /**
138
+ * The gamemode this creator is creating for.
139
+ */
140
+ mode = osuBase.modes.osu;
141
+ /**
142
+ * The base normalized radius of hitobjects.
143
+ */
144
+ normalizedRadius = 50;
145
+ maximumSliderRadius = this.normalizedRadius * 2.4;
146
+ assumedSliderRadius = this.normalizedRadius * 1.8;
147
+ minDeltaTime = 25;
148
+ /**
149
+ * Generates difficulty hitobjects for difficulty calculation.
150
+ */
151
+ generateDifficultyObjects(params) {
152
+ params.preempt ??= 600;
153
+ this.mode = params.mode;
154
+ const circleSize = params.circleSize;
155
+ const scale = (1 - (0.7 * (circleSize - 5)) / 5) / 2;
156
+ params.objects[0].scale = scale;
157
+ const scalingFactor = this.getScalingFactor(params.objects[0].radius);
158
+ const difficultyObjects = [];
159
+ for (let i = 0; i < params.objects.length; ++i) {
160
+ const object = new DifficultyHitObject(params.objects[i]);
161
+ object.object.scale = scale;
162
+ if (object.object instanceof osuBase.Slider) {
163
+ object.velocity =
164
+ object.object.velocity * params.speedMultiplier;
165
+ object.object.nestedHitObjects.forEach((h) => {
166
+ h.scale = scale;
167
+ });
168
+ this.calculateSliderCursorPosition(object.object);
169
+ object.travelDistance = object.object.lazyTravelDistance;
170
+ object.travelTime = Math.max(object.object.lazyTravelTime / params.speedMultiplier, this.minDeltaTime);
171
+ }
172
+ const lastObject = difficultyObjects[i - 1];
173
+ const lastLastObject = difficultyObjects[i - 2];
174
+ object.startTime = object.object.startTime / params.speedMultiplier;
175
+ object.endTime = object.object.endTime / params.speedMultiplier;
176
+ if (!lastObject) {
177
+ difficultyObjects.push(object);
178
+ continue;
179
+ }
180
+ object.deltaTime =
181
+ (object.object.startTime - lastObject.object.startTime) /
182
+ params.speedMultiplier;
183
+ // Cap to 25ms to prevent difficulty calculation breaking from simultaneous objects.
184
+ object.strainTime = Math.max(this.minDeltaTime, object.deltaTime);
185
+ const visibleObjects = params.objects.filter((o) => o.startTime / params.speedMultiplier > object.startTime &&
186
+ o.startTime / params.speedMultiplier <=
187
+ object.endTime + params.preempt);
188
+ object.noteDensity = 1;
189
+ for (const hitObject of visibleObjects) {
190
+ // Calculate delta time assuming the current object is a circle.
191
+ let deltaTime = Math.abs(hitObject.startTime / params.speedMultiplier -
192
+ object.startTime);
193
+ // But if the current object is a slider, then we alter the delta time to account for slider end time.
194
+ if (object.object instanceof osuBase.Slider) {
195
+ if (object.object.startTime >= hitObject.startTime &&
196
+ object.object.endTime <= hitObject.startTime) {
197
+ // If the object starts when the slider is active, then the slider is technically still visible.
198
+ // Set delta time to 0.
199
+ deltaTime = 0;
200
+ }
201
+ else {
202
+ // Otherwise, we take the delta time from the slider head or tail, whichever is smaller as it's the closest time.
203
+ deltaTime = Math.min(deltaTime, Math.abs(hitObject.startTime / params.speedMultiplier -
204
+ object.endTime));
205
+ }
206
+ }
207
+ object.noteDensity += 1 - deltaTime / params.preempt;
208
+ if (!(hitObject instanceof osuBase.Spinner)) {
209
+ object.overlappingFactor +=
210
+ // Penalize objects that are too close to the object in both distance
211
+ // and delta time to prevent stream maps from being overweighted.
212
+ Math.max(0, 1 -
213
+ object.object.stackedPosition.getDistance(hitObject.stackedEndPosition) /
214
+ (3 * object.object.radius)) *
215
+ (7.5 /
216
+ (1 +
217
+ Math.exp(0.15 *
218
+ (Math.max(deltaTime, this.minDeltaTime) -
219
+ 75))));
220
+ }
221
+ }
222
+ if (object.object instanceof osuBase.Spinner ||
223
+ lastObject.object instanceof osuBase.Spinner) {
224
+ difficultyObjects.push(object);
225
+ continue;
226
+ }
227
+ const lastCursorPosition = this.getEndCursorPosition(lastObject.object);
228
+ object.lazyJumpDistance = object.object.stackedPosition
229
+ .scale(scalingFactor)
230
+ .subtract(lastCursorPosition.scale(scalingFactor)).length;
231
+ object.minimumJumpTime = object.strainTime;
232
+ object.minimumJumpDistance = object.lazyJumpDistance;
233
+ if (lastObject.object instanceof osuBase.Slider) {
234
+ object.minimumJumpTime = Math.max(object.strainTime - lastObject.travelTime, this.minDeltaTime);
235
+ // 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.
236
+ //
237
+ // 1. The anti-flow pattern, where players cut the slider short in order to move to the next hitobject.
238
+ //
239
+ // <======o==> ← slider
240
+ // | ← most natural jump path
241
+ // o ← a follow-up hitcircle
242
+ //
243
+ // In this case the most natural jump path is approximated by LazyJumpDistance.
244
+ //
245
+ // 2. The flow pattern, where players follow through the slider to its visual extent into the next hitobject.
246
+ //
247
+ // <======o==>---o
248
+ // ↑
249
+ // most natural jump path
250
+ //
251
+ // 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.
252
+ //
253
+ // Thus, the player is assumed to jump the minimum of these two distances in all cases.
254
+ const tailJumpDistance = lastObject.object.tail.stackedPosition.subtract(object.object.stackedPosition).length * scalingFactor;
255
+ object.minimumJumpDistance = Math.max(0, Math.min(object.lazyJumpDistance -
256
+ (this.maximumSliderRadius -
257
+ this.assumedSliderRadius), tailJumpDistance - this.maximumSliderRadius));
258
+ }
259
+ if (lastLastObject && !(lastLastObject.object instanceof osuBase.Spinner)) {
260
+ const lastLastCursorPosition = this.getEndCursorPosition(lastLastObject.object);
261
+ const v1 = lastLastCursorPosition.subtract(lastObject.object.stackedPosition);
262
+ const v2 = object.object.stackedPosition.subtract(lastCursorPosition);
263
+ const dot = v1.dot(v2);
264
+ const det = v1.x * v2.y - v1.y * v2.x;
265
+ object.angle = Math.abs(Math.atan2(det, dot));
266
+ }
267
+ difficultyObjects.push(object);
268
+ }
269
+ return difficultyObjects;
270
+ }
271
+ /**
272
+ * Calculates a slider's cursor position.
273
+ */
274
+ calculateSliderCursorPosition(slider) {
275
+ if (slider.lazyEndPosition) {
276
+ return;
277
+ }
278
+ // Droid doesn't have a legacy slider tail. Since beatmap parser defaults slider tail
279
+ // to legacy slider tail, it needs to be changed to real slider tail first.
280
+ if (this.mode === osuBase.modes.droid) {
281
+ slider.tail.startTime += osuBase.Slider.legacyLastTickOffset;
282
+ slider.tail.endTime += osuBase.Slider.legacyLastTickOffset;
283
+ slider.nestedHitObjects.sort((a, b) => {
284
+ return a.startTime - b.startTime;
285
+ });
286
+ // Temporary lazy end position until a real result can be derived.
287
+ slider.lazyEndPosition = slider.stackedPosition;
288
+ // Stop here if the slider has too short duration due to float number limitation.
289
+ // Incredibly close start and end time fluctuates travel distance and lazy
290
+ // end position heavily, which we do not want to happen.
291
+ //
292
+ // In the real game, this shouldn't happen. Perhaps we need to reinvestigate this
293
+ // in the future.
294
+ if (osuBase.Precision.almostEqualsNumber(slider.startTime, slider.endTime)) {
295
+ return;
296
+ }
297
+ }
298
+ // Not using slider.endTime due to legacy last tick offset.
299
+ slider.lazyTravelTime =
300
+ slider.nestedHitObjects.at(-1).startTime - slider.startTime;
301
+ let endTimeMin = slider.lazyTravelTime / slider.spanDuration;
302
+ if (endTimeMin % 2 >= 1) {
303
+ endTimeMin = 1 - (endTimeMin % 1);
304
+ }
305
+ else {
306
+ endTimeMin %= 1;
307
+ }
308
+ // Temporary lazy end position until a real result can be derived.
309
+ slider.lazyEndPosition = slider.stackedPosition.add(slider.path.positionAt(endTimeMin));
310
+ let currentCursorPosition = slider.stackedPosition;
311
+ const scalingFactor = this.normalizedRadius / slider.radius;
312
+ for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
313
+ const currentMovementObject = slider.nestedHitObjects[i];
314
+ let currentMovement = currentMovementObject.stackedPosition.subtract(currentCursorPosition);
315
+ let currentMovementLength = scalingFactor * currentMovement.length;
316
+ // The amount of movement required so that the cursor position needs to be updated.
317
+ let requiredMovement = this.assumedSliderRadius;
318
+ if (i === slider.nestedHitObjects.length - 1) {
319
+ // The end of a slider has special aim rules due to the relaxed time constraint on position.
320
+ // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
321
+ // For sliders that are circular, the lazy end position may actually be farther away than the sliders' true end.
322
+ // This code is designed to prevent buffing situations where lazy end is actually a less efficient movement.
323
+ const lazyMovement = slider.lazyEndPosition.subtract(currentCursorPosition);
324
+ if (lazyMovement.length < currentMovement.length) {
325
+ currentMovement = lazyMovement;
326
+ }
327
+ currentMovementLength = scalingFactor * currentMovement.length;
328
+ }
329
+ else if (currentMovementObject instanceof osuBase.SliderRepeat) {
330
+ // For a slider repeat, assume a tighter movement threshold to better assess repeat sliders.
331
+ requiredMovement = this.normalizedRadius;
332
+ }
333
+ if (currentMovementLength > requiredMovement) {
334
+ // This finds the positional delta from the required radius and the current position,
335
+ // and updates the currentCursorPosition accordingly, as well as rewarding distance.
336
+ currentCursorPosition = currentCursorPosition.add(currentMovement.scale((currentMovementLength - requiredMovement) /
337
+ currentMovementLength));
338
+ currentMovementLength *=
339
+ (currentMovementLength - requiredMovement) /
340
+ currentMovementLength;
341
+ slider.lazyTravelDistance += currentMovementLength;
342
+ }
343
+ if (i === slider.nestedHitObjects.length - 1) {
344
+ slider.lazyEndPosition = currentCursorPosition;
345
+ }
346
+ }
347
+ // Bonus for repeat sliders until a better per nested object strain system can be achieved.
348
+ if (this.mode === osuBase.modes.droid) {
349
+ slider.lazyTravelDistance *= Math.pow(1 + slider.repeats / 4, 1 / 4);
350
+ }
351
+ else {
352
+ slider.lazyTravelDistance *= Math.pow(1 + slider.repeats / 2.5, 1 / 2.5);
353
+ }
354
+ }
355
+ /**
356
+ * Gets the scaling factor of a radius.
357
+ *
358
+ * @param radius The radius to get the scaling factor from.
359
+ */
360
+ getScalingFactor(radius) {
361
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
362
+ let scalingFactor = this.normalizedRadius / radius;
363
+ // High circle size (small CS) bonus
364
+ switch (this.mode) {
365
+ case osuBase.modes.droid:
366
+ if (radius < this.DROID_CIRCLESIZE_BUFF_THRESHOLD) {
367
+ scalingFactor *=
368
+ 1 +
369
+ Math.min(this.DROID_CIRCLESIZE_BUFF_THRESHOLD - radius, 20) /
370
+ 40;
371
+ }
372
+ break;
373
+ case osuBase.modes.osu:
374
+ if (radius < this.PC_CIRCLESIZE_BUFF_THRESHOLD) {
375
+ scalingFactor *=
376
+ 1 +
377
+ Math.min(this.PC_CIRCLESIZE_BUFF_THRESHOLD - radius, 5) /
378
+ 50;
379
+ }
380
+ }
381
+ return scalingFactor;
382
+ }
383
+ /**
384
+ * Returns the end cursor position of a hitobject.
385
+ */
386
+ getEndCursorPosition(object) {
387
+ let pos = object.stackedPosition;
388
+ if (object instanceof osuBase.Slider) {
389
+ this.calculateSliderCursorPosition(object);
390
+ pos = object.lazyEndPosition ?? pos;
391
+ }
392
+ return pos;
393
+ }
394
+ }
395
+
396
+ /**
397
+ * A bare minimal abstract skill for fully custom skill implementations.
398
+ */
399
+ class Skill {
400
+ /**
401
+ * The hitobjects that were processed previously. They can affect the strain values of the following objects.
402
+ *
403
+ * The latest hitobject is at index 0.
404
+ */
405
+ previous = [];
406
+ /**
407
+ * Number of previous hitobjects to keep inside the `previous` array.
408
+ */
409
+ historyLength = 2;
410
+ /**
411
+ * The mods that this skill processes.
412
+ */
413
+ mods;
414
+ constructor(mods) {
415
+ this.mods = mods;
416
+ }
417
+ processInternal(current) {
418
+ while (this.previous.length > this.historyLength) {
419
+ this.previous.pop();
420
+ }
421
+ this.process(current);
422
+ this.previous.unshift(current);
423
+ }
424
+ }
425
+
426
+ /**
427
+ * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
428
+ * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
429
+ */
430
+ class StrainSkill extends Skill {
431
+ /**
432
+ * The strain of currently calculated hitobject.
433
+ */
434
+ currentStrain = 0;
435
+ /**
436
+ * The current section's strain peak.
437
+ */
438
+ currentSectionPeak = 0;
439
+ /**
440
+ * Strain peaks are stored here.
441
+ */
442
+ strainPeaks = [];
443
+ sectionLength = 400;
444
+ currentSectionEnd = 0;
445
+ /**
446
+ * Calculates the strain value of a hitobject and stores the value in it. This value is affected by previously processed objects.
447
+ *
448
+ * @param current The hitobject to process.
449
+ */
450
+ process(current) {
451
+ // The first object doesn't generate a strain, so we begin with an incremented section end
452
+ if (this.previous.length === 0) {
453
+ this.currentSectionEnd =
454
+ Math.ceil(current.startTime / this.sectionLength) *
455
+ this.sectionLength;
456
+ }
457
+ while (current.startTime > this.currentSectionEnd) {
458
+ this.saveCurrentPeak();
459
+ this.startNewSectionFrom(this.currentSectionEnd);
460
+ this.currentSectionEnd += this.sectionLength;
461
+ }
462
+ this.currentStrain = this.strainValueAt(current);
463
+ this.saveToHitObject(current);
464
+ this.currentSectionPeak = Math.max(this.currentStrain, this.currentSectionPeak);
465
+ }
466
+ /**
467
+ * Saves the current peak strain level to the list of strain peaks, which will be used to calculate an overall difficulty.
468
+ */
469
+ saveCurrentPeak() {
470
+ this.strainPeaks.push(this.currentSectionPeak);
471
+ }
472
+ /**
473
+ * Sets the initial strain level for a new section.
474
+ *
475
+ * @param offset The beginning of the new section in milliseconds, adjusted by speed multiplier.
476
+ */
477
+ startNewSectionFrom(offset) {
478
+ // The maximum strain of the new section is not zero by default, strain decays as usual regardless of section boundaries.
479
+ // This means we need to capture the strain level at the beginning of the new section, and use that as the initial peak level.
480
+ this.currentSectionPeak =
481
+ this.currentStrain *
482
+ this.strainDecay(offset - this.previous[0].startTime);
483
+ }
484
+ /**
485
+ * Calculates strain decay for a specified time frame.
486
+ *
487
+ * @param ms The time frame to calculate.
488
+ */
489
+ strainDecay(ms) {
490
+ return Math.pow(this.strainDecayBase, ms / 1000);
491
+ }
492
+ }
493
+
494
+ /**
495
+ * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
496
+ * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
497
+ */
498
+ class DroidSkill extends StrainSkill {
499
+ difficultyValue() {
500
+ const sortedStrains = this.strainPeaks
501
+ .slice()
502
+ .sort((a, b) => {
503
+ return b - a;
504
+ });
505
+ // We are reducing the highest strains first to account for extreme difficulty spikes.
506
+ for (let i = 0; i < Math.min(sortedStrains.length, this.reducedSectionCount); ++i) {
507
+ const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / this.reducedSectionCount, 0, 1)));
508
+ sortedStrains[i] *= osuBase.Interpolation.lerp(this.reducedSectionBaseline, 1, scale);
509
+ }
510
+ // Math here preserves the property that two notes of equal difficulty x, we have their summed difficulty = x * starsPerDouble.
511
+ // This also applies to two sets of notes with equal difficulty.
512
+ return Math.pow(sortedStrains.reduce((a, v) => {
513
+ if (v <= 0) {
514
+ return a;
515
+ }
516
+ return a + Math.pow(v, 1 / Math.log2(this.starsPerDouble));
517
+ }, 0), Math.log2(this.starsPerDouble));
518
+ }
519
+ }
520
+
521
+ /**
522
+ * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
523
+ */
524
+ class DroidAim extends DroidSkill {
525
+ skillMultiplier = 23.25;
526
+ strainDecayBase = 0.15;
527
+ reducedSectionCount = 10;
528
+ reducedSectionBaseline = 0.75;
529
+ starsPerDouble = 1.05;
530
+ /**
531
+ * Spacing threshold for a single hitobject spacing.
532
+ */
533
+ SINGLE_SPACING_THRESHOLD = 175;
534
+ // ~200 1/2 BPM jumps
535
+ minSpeedBonus = 150;
536
+ wideAngleMultiplier = 1.5;
537
+ acuteAngleMultiplier = 2;
538
+ sliderMultiplier = 1.5;
539
+ velocityChangeMultiplier = 0.75;
540
+ withSliders;
541
+ constructor(mods, withSliders) {
542
+ super(mods);
543
+ this.withSliders = withSliders;
544
+ }
545
+ /**
546
+ * @param current The hitobject to calculate.
547
+ */
548
+ strainValueOf(current) {
549
+ if (current.object instanceof osuBase.Spinner) {
550
+ return 0;
551
+ }
552
+ return this.aimStrainOf(current) + this.movementStrainOf(current);
553
+ }
554
+ /**
555
+ * Calculates the aim strain of a hitobject.
556
+ */
557
+ aimStrainOf(current) {
558
+ if (this.previous.length <= 1 ||
559
+ this.previous[0].object instanceof osuBase.Spinner) {
560
+ return 0;
561
+ }
562
+ const last = this.previous[0];
563
+ const lastLast = this.previous[1];
564
+ // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
565
+ let currentVelocity = current.lazyJumpDistance / current.strainTime;
566
+ // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
567
+ if (last.object instanceof osuBase.Slider &&
568
+ last.object.ticks > 0 &&
569
+ this.withSliders) {
570
+ // Calculate the slider velocity from slider head to slider end.
571
+ const travelVelocity = last.travelDistance / last.travelTime;
572
+ // Calculate the movement velocity from slider end to current object.
573
+ const movementVelocity = current.minimumJumpDistance / current.minimumJumpTime;
574
+ // Take the larger total combined velocity.
575
+ currentVelocity = Math.max(currentVelocity, movementVelocity + travelVelocity);
576
+ }
577
+ // As above, do the same for the previous hitobject.
578
+ let prevVelocity = last.lazyJumpDistance / last.strainTime;
579
+ if (lastLast.object instanceof osuBase.Slider &&
580
+ lastLast.object.ticks > 0 &&
581
+ this.withSliders) {
582
+ const travelVelocity = lastLast.travelDistance / lastLast.travelTime;
583
+ const movementVelocity = last.minimumJumpDistance / last.minimumJumpTime;
584
+ prevVelocity = Math.max(prevVelocity, movementVelocity + travelVelocity);
585
+ }
586
+ let wideAngleBonus = 0;
587
+ let acuteAngleBonus = 0;
588
+ let sliderBonus = 0;
589
+ let velocityChangeBonus = 0;
590
+ // Start strain with regular velocity.
591
+ let strain = currentVelocity;
592
+ if (Math.max(current.strainTime, last.strainTime) <
593
+ 1.25 * Math.min(current.strainTime, last.strainTime)) {
594
+ // If rhythms are the same.
595
+ if (current.angle !== null &&
596
+ last.angle !== null &&
597
+ lastLast.angle !== null) {
598
+ // Rewarding angles, take the smaller velocity as base.
599
+ const angleBonus = Math.min(currentVelocity, prevVelocity);
600
+ wideAngleBonus = this.calculateWideAngleBonus(current.angle);
601
+ acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
602
+ // Only buff deltaTime exceeding 300 BPM 1/2.
603
+ if (current.strainTime > 100) {
604
+ acuteAngleBonus = 0;
605
+ }
606
+ else {
607
+ acuteAngleBonus *=
608
+ // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
609
+ this.calculateAcuteAngleBonus(last.angle) *
610
+ // The maximum velocity we buff is equal to 125 / strainTime.
611
+ Math.min(angleBonus, 125 / current.strainTime) *
612
+ // Scale buff from 300 BPM 1/2 to 400 BPM 1/2.
613
+ Math.pow(Math.sin((Math.PI / 2) *
614
+ Math.min(1, (100 - current.strainTime) / 25)), 2) *
615
+ // Buff distance exceeding 50 (radius) up to 100 (diameter).
616
+ Math.pow(Math.sin(((Math.PI / 2) *
617
+ (osuBase.MathUtils.clamp(current.lazyJumpDistance, 50, 100) -
618
+ 50)) /
619
+ 50), 2);
620
+ }
621
+ // Penalize wide angles if they're repeated, reducing the penalty as last.angle gets more acute.
622
+ wideAngleBonus *=
623
+ angleBonus *
624
+ (1 -
625
+ Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(last.angle), 3)));
626
+ // Penalize acute angles if they're repeated, reducing the penalty as lastLast.angle gets more obtuse.
627
+ acuteAngleBonus *=
628
+ 0.5 +
629
+ 0.5 *
630
+ (1 -
631
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastLast.angle), 3)));
632
+ }
633
+ }
634
+ if (Math.max(prevVelocity, currentVelocity)) {
635
+ // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
636
+ prevVelocity =
637
+ (last.lazyJumpDistance + lastLast.travelDistance) /
638
+ last.strainTime;
639
+ currentVelocity =
640
+ (current.lazyJumpDistance + last.travelDistance) /
641
+ current.strainTime;
642
+ // Scale with ratio of difference compared to half the max distance.
643
+ const distanceRatio = Math.pow(Math.sin(((Math.PI / 2) * Math.abs(prevVelocity - currentVelocity)) /
644
+ Math.max(prevVelocity, currentVelocity)), 2);
645
+ // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
646
+ const overlapVelocityBuff = Math.min(125 / Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
647
+ // Reward for % distance slowed down compared to previous, paying attention to not award overlap.
648
+ const nonOverlapVelocityBuff = Math.abs(prevVelocity - currentVelocity) *
649
+ // Do not award overlap.
650
+ Math.pow(Math.sin((Math.PI / 2) *
651
+ Math.min(1, Math.min(current.lazyJumpDistance, last.lazyJumpDistance) / 100)), 2);
652
+ // Choose the largest bonus, multiplied by ratio.
653
+ velocityChangeBonus =
654
+ Math.max(overlapVelocityBuff, nonOverlapVelocityBuff) *
655
+ distanceRatio;
656
+ // Penalize for rhythm changes.
657
+ velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
658
+ Math.max(current.strainTime, last.strainTime), 2);
659
+ }
660
+ if (last.travelTime) {
661
+ // Reward sliders based on velocity.
662
+ sliderBonus = last.travelDistance / last.travelTime;
663
+ }
664
+ // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
665
+ strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
666
+ velocityChangeBonus * this.velocityChangeMultiplier);
667
+ // Add in additional slider velocity bonus.
668
+ if (this.withSliders) {
669
+ strain += sliderBonus * this.sliderMultiplier;
670
+ }
671
+ return strain;
672
+ }
673
+ /**
674
+ * Calculates the movement strain of a hitobject.
675
+ */
676
+ movementStrainOf(current) {
677
+ let speedBonus = 1;
678
+ if (current.strainTime < this.minSpeedBonus) {
679
+ speedBonus +=
680
+ 0.75 *
681
+ Math.pow((this.minSpeedBonus - current.strainTime) / 45, 2);
682
+ }
683
+ const travelDistance = this.previous[0]?.travelDistance ?? 0;
684
+ const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
685
+ return ((50 *
686
+ speedBonus *
687
+ Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 5)) /
688
+ current.strainTime);
689
+ }
690
+ /**
691
+ * @param current The hitobject to calculate.
692
+ */
693
+ strainValueAt(current) {
694
+ this.currentStrain *= this.strainDecay(current.deltaTime);
695
+ this.currentStrain +=
696
+ this.strainValueOf(current) * this.skillMultiplier;
697
+ return this.currentStrain;
698
+ }
699
+ /**
700
+ * @param current The hitobject to save to.
701
+ */
702
+ saveToHitObject(current) {
703
+ if (this.withSliders) {
704
+ current.aimStrainWithSliders = this.currentStrain;
705
+ }
706
+ else {
707
+ current.aimStrainWithoutSliders = this.currentStrain;
708
+ }
709
+ }
710
+ /**
711
+ * Calculates the bonus of wide angles.
712
+ */
713
+ calculateWideAngleBonus(angle) {
714
+ return Math.pow(Math.sin((3 / 4) *
715
+ (Math.min((5 / 6) * Math.PI, Math.max(Math.PI / 6, angle)) -
716
+ Math.PI / 6)), 2);
717
+ }
718
+ /**
719
+ * Calculates the bonus of acute angles.
720
+ */
721
+ calculateAcuteAngleBonus(angle) {
722
+ return 1 - this.calculateWideAngleBonus(angle);
723
+ }
724
+ }
725
+
726
+ /**
727
+ * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
728
+ */
729
+ class DroidFlashlight extends DroidSkill {
730
+ historyLength = 10;
731
+ skillMultiplier = 0.15;
732
+ strainDecayBase = 0.15;
733
+ reducedSectionCount = 10;
734
+ reducedSectionBaseline = 0.75;
735
+ starsPerDouble = 1.05;
736
+ strainValueOf(current) {
737
+ if (current.object instanceof osuBase.Spinner) {
738
+ return 0;
739
+ }
740
+ const scalingFactor = 52 / current.object.radius;
741
+ let smallDistNerf = 1;
742
+ let cumulativeStrainTime = 0;
743
+ let result = 0;
744
+ let last = current;
745
+ for (let i = 0; i < this.previous.length; ++i) {
746
+ const currentObject = this.previous[i];
747
+ if (!(currentObject.object instanceof osuBase.Spinner)) {
748
+ const jumpDistance = current.object.stackedPosition.subtract(currentObject.object.endPosition).length;
749
+ cumulativeStrainTime += last.strainTime;
750
+ // We want to nerf objects that can be easily seen within the Flashlight circle radius.
751
+ if (i === 0) {
752
+ smallDistNerf = Math.min(1, jumpDistance / 75);
753
+ }
754
+ // We also want to nerf stacks so that only the first object of the stack is accounted for.
755
+ const stackNerf = Math.min(1, currentObject.lazyJumpDistance / scalingFactor / 25);
756
+ result +=
757
+ (stackNerf * scalingFactor * jumpDistance) /
758
+ cumulativeStrainTime;
759
+ }
760
+ last = currentObject;
761
+ }
762
+ return Math.pow(smallDistNerf * result, 2);
763
+ }
764
+ /**
765
+ * @param current The hitobject to calculate.
766
+ */
767
+ strainValueAt(current) {
768
+ this.currentStrain *= this.strainDecay(current.deltaTime);
769
+ this.currentStrain +=
770
+ this.strainValueOf(current) * this.skillMultiplier;
771
+ return this.currentStrain;
772
+ }
773
+ saveToHitObject(current) {
774
+ current.flashlightStrain = this.currentStrain;
775
+ }
776
+ }
777
+
778
+ /**
779
+ * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
780
+ */
781
+ class DroidTap extends DroidSkill {
782
+ skillMultiplier = 1375;
783
+ reducedSectionCount = 5;
784
+ reducedSectionBaseline = 0.75;
785
+ strainDecayBase = 0.3;
786
+ starsPerDouble = 1.1;
787
+ // ~200 1/4 BPM streams
788
+ minSpeedBonus = 75;
789
+ currentTapStrain = 0;
790
+ currentOriginalTapStrain = 0;
791
+ hitWindow;
792
+ constructor(mods, overallDifficulty) {
793
+ super(mods);
794
+ this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
795
+ }
796
+ /**
797
+ * @param current The hitobject to calculate.
798
+ */
799
+ strainValueOf(current) {
800
+ if (current.object instanceof osuBase.Spinner) {
801
+ return 0;
802
+ }
803
+ let strainTime = current.strainTime;
804
+ const greatWindowFull = this.hitWindow.hitWindowFor300() * 2;
805
+ // Aim to nerf cheesy rhythms (very fast consecutive doubles with large deltatimes between).
806
+ if (this.previous[0] &&
807
+ strainTime < greatWindowFull &&
808
+ this.previous[0].strainTime > strainTime) {
809
+ strainTime = osuBase.Interpolation.lerp(this.previous[0].strainTime, strainTime, strainTime / greatWindowFull);
810
+ }
811
+ // Cap deltatime to the OD 300 hitwindow.
812
+ // 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.
813
+ strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.58, 0.91, 1);
814
+ let speedBonus = 1;
815
+ if (strainTime < this.minSpeedBonus) {
816
+ speedBonus +=
817
+ 0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
818
+ }
819
+ let originalSpeedBonus = 1;
820
+ if (current.strainTime < this.minSpeedBonus) {
821
+ originalSpeedBonus +=
822
+ 0.75 *
823
+ Math.pow((this.minSpeedBonus - current.strainTime) / 40, 2);
824
+ }
825
+ const decay = this.strainDecay(current.deltaTime);
826
+ this.currentTapStrain *= decay;
827
+ this.currentTapStrain +=
828
+ this.tapStrainOf(speedBonus, strainTime) * this.skillMultiplier;
829
+ this.currentOriginalTapStrain *= decay;
830
+ this.currentOriginalTapStrain +=
831
+ this.tapStrainOf(originalSpeedBonus, current.strainTime) *
832
+ this.skillMultiplier;
833
+ this.currentOriginalTapStrain *= current.rhythmMultiplier;
834
+ return this.currentTapStrain * current.rhythmMultiplier;
835
+ }
836
+ /**
837
+ * Calculates the tap strain of a hitobject given a specific speed bonus and strain time.
838
+ */
839
+ tapStrainOf(speedBonus, strainTime) {
840
+ return speedBonus / strainTime;
841
+ }
842
+ /**
843
+ * @param current The hitobject to calculate.
844
+ */
845
+ strainValueAt(current) {
846
+ return this.strainValueOf(current);
847
+ }
848
+ /**
849
+ * @param current The hitobject to save to.
850
+ */
851
+ saveToHitObject(current) {
852
+ current.tapStrain = this.currentStrain;
853
+ current.originalTapStrain = this.currentOriginalTapStrain;
854
+ }
855
+ }
856
+
857
+ /**
858
+ * The base of difficulty calculation.
859
+ */
860
+ class StarRating {
861
+ /**
862
+ * The calculated beatmap.
863
+ */
864
+ map = new osuBase.Beatmap();
865
+ /**
866
+ * The difficulty objects of the beatmap.
867
+ */
868
+ objects = [];
869
+ /**
870
+ * The modifications applied.
871
+ */
872
+ mods = [];
873
+ /**
874
+ * The total star rating of the beatmap.
875
+ */
876
+ total = 0;
877
+ /**
878
+ * The map statistics of the beatmap after modifications are applied.
879
+ */
880
+ stats = new osuBase.MapStats();
881
+ /**
882
+ * The strain peaks of various calculated difficulties.
883
+ */
884
+ strainPeaks = {
885
+ aimWithSliders: [],
886
+ aimWithoutSliders: [],
887
+ speed: [],
888
+ flashlight: [],
889
+ };
890
+ /**
891
+ * Additional data that is used in performance calculation.
892
+ */
893
+ attributes = {
894
+ speedNoteCount: 0,
895
+ sliderFactor: 1,
896
+ };
897
+ sectionLength = 400;
898
+ /**
899
+ * Calculates the star rating of the specified beatmap.
900
+ *
901
+ * The beatmap is analyzed in chunks of `sectionLength` duration.
902
+ * For each chunk the highest hitobject strains are added to
903
+ * a list which is then collapsed into a weighted sum, much
904
+ * like scores are weighted on a user's profile.
905
+ *
906
+ * For subsequent chunks, the initial max strain is calculated
907
+ * by decaying the previous hitobject's strain until the
908
+ * beginning of the new chunk.
909
+ *
910
+ * The first object doesn't generate a strain
911
+ * so we begin calculating from the second object.
912
+ *
913
+ * Also don't forget to manually add the peak strain for the last
914
+ * section which would otherwise be ignored.
915
+ */
916
+ calculate(params, mode) {
917
+ const map = (this.map = osuBase.Utils.deepCopy(params.map));
918
+ const mod = (this.mods = params.mods ?? this.mods);
919
+ this.stats = new osuBase.MapStats({
920
+ cs: map.difficulty.cs,
921
+ ar: map.difficulty.ar,
922
+ od: map.difficulty.od,
923
+ hp: map.difficulty.hp,
924
+ mods: mod,
925
+ speedMultiplier: params.stats?.speedMultiplier ?? 1,
926
+ oldStatistics: params.stats?.oldStatistics ?? false,
927
+ }).calculate({ mode: mode });
928
+ this.generateDifficultyHitObjects(mode);
929
+ this.calculateAll();
930
+ return this;
931
+ }
932
+ /**
933
+ * Generates difficulty hitobjects for this calculator.
934
+ *
935
+ * @param mode The gamemode to generate difficulty hitobjects for.
936
+ */
937
+ generateDifficultyHitObjects(mode) {
938
+ this.objects.length = 0;
939
+ this.objects.push(...new DifficultyHitObjectCreator().generateDifficultyObjects({
940
+ objects: this.map.hitObjects.objects,
941
+ circleSize: this.stats.cs,
942
+ speedMultiplier: this.stats.speedMultiplier,
943
+ mode: mode,
944
+ preempt: osuBase.MapStats.arToMS(this.stats.ar),
945
+ }));
946
+ }
947
+ /**
948
+ * Calculates the skills provided.
949
+ *
950
+ * @param skills The skills to calculate.
951
+ */
952
+ calculateSkills(...skills) {
953
+ this.objects.slice(1).forEach((h, i) => {
954
+ skills.forEach((skill) => {
955
+ skill.processInternal(h);
956
+ if (i === this.objects.length - 2) {
957
+ // Don't forget to save the last strain peak, which would otherwise be ignored.
958
+ skill.saveCurrentPeak();
959
+ }
960
+ });
961
+ });
962
+ }
963
+ /**
964
+ * Calculates the star rating value of a difficulty.
965
+ *
966
+ * @param difficulty The difficulty to calculate.
967
+ */
968
+ starValue(difficulty) {
969
+ return Math.sqrt(difficulty) * this.difficultyMultiplier;
970
+ }
971
+ /**
972
+ * Calculates the base performance value of a difficulty rating.
973
+ *
974
+ * @param rating The difficulty rating.
975
+ */
976
+ basePerformanceValue(rating) {
977
+ return Math.pow(5 * Math.max(1, rating / 0.0675) - 4, 3) / 100000;
978
+ }
979
+ }
980
+
981
+ /**
982
+ * Represents the skill required to properly follow a beatmap's rhythm.
983
+ */
984
+ class DroidRhythm extends DroidSkill {
985
+ historyLength = 32;
986
+ skillMultiplier = 1;
987
+ reducedSectionCount = 5;
988
+ reducedSectionBaseline = 0.75;
989
+ strainDecayBase = 0.3;
990
+ starsPerDouble = 1.75;
991
+ rhythmMultiplier;
992
+ historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
993
+ currentRhythm = 1;
994
+ hitWindow;
995
+ constructor(mods, overallDifficulty) {
996
+ super(mods);
997
+ this.hitWindow = new osuBase.OsuHitWindow(overallDifficulty);
998
+ const odScaling = Math.pow(this.hitWindow.overallDifficulty, 2) / 400;
999
+ this.rhythmMultiplier =
1000
+ 0.75 +
1001
+ (this.hitWindow.overallDifficulty >= 0 ? odScaling : -odScaling);
1002
+ }
1003
+ /**
1004
+ * Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current object.
1005
+ */
1006
+ calculateRhythmBonus(current) {
1007
+ if (current.object instanceof osuBase.Spinner) {
1008
+ return 0;
1009
+ }
1010
+ let previousIslandSize = 0;
1011
+ let rhythmComplexitySum = 0;
1012
+ let islandSize = 1;
1013
+ // Store the ratio of the current start of an island to buff for tighter rhythms.
1014
+ let startRatio = 0;
1015
+ let firstDeltaSwitch = false;
1016
+ let rhythmStart = 0;
1017
+ while (rhythmStart < this.previous.length - 2 &&
1018
+ current.startTime - this.previous[rhythmStart].startTime <
1019
+ this.historyTimeMax) {
1020
+ ++rhythmStart;
1021
+ }
1022
+ for (let i = rhythmStart; i > 0; --i) {
1023
+ // Scale note 0 to 1 from history to now.
1024
+ let currentHistoricalDecay = (this.historyTimeMax -
1025
+ (current.startTime - this.previous[i - 1].startTime)) /
1026
+ this.historyTimeMax;
1027
+ // Either we're limited by time or limited by object count.
1028
+ currentHistoricalDecay = Math.min(currentHistoricalDecay, (this.previous.length - i) / this.previous.length);
1029
+ const currentDelta = this.previous[i - 1].strainTime;
1030
+ const prevDelta = this.previous[i].strainTime;
1031
+ const lastDelta = this.previous[i + 1].strainTime;
1032
+ const currentRatio = 1 +
1033
+ 6 *
1034
+ Math.min(0.5, Math.pow(Math.sin(Math.PI /
1035
+ (Math.min(prevDelta, currentDelta) /
1036
+ Math.max(prevDelta, currentDelta))), 2));
1037
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) -
1038
+ this.hitWindow.hitWindowFor300() * 0.4) /
1039
+ (this.hitWindow.hitWindowFor300() * 0.4));
1040
+ let effectiveRatio = windowPenalty * currentRatio;
1041
+ if (firstDeltaSwitch) {
1042
+ if (prevDelta <= 1.25 * currentDelta &&
1043
+ prevDelta * 1.25 >= currentDelta) {
1044
+ // Island is still progressing, count size.
1045
+ if (islandSize < 7) {
1046
+ ++islandSize;
1047
+ }
1048
+ }
1049
+ else {
1050
+ if (this.previous[i - 1].object instanceof osuBase.Slider) {
1051
+ // BPM change is into slider, this is easy acc window.
1052
+ effectiveRatio /= 8;
1053
+ }
1054
+ if (this.previous[i].object instanceof osuBase.Slider) {
1055
+ // BPM change was from a slider, this is typically easier than circle -> circle.
1056
+ effectiveRatio /= 4;
1057
+ }
1058
+ if (previousIslandSize === islandSize) {
1059
+ // Repeated island size (ex: triplet -> triplet).
1060
+ effectiveRatio /= 4;
1061
+ }
1062
+ if (previousIslandSize % 2 === islandSize % 2) {
1063
+ // Repeated island polarity (2 -> 4, 3 -> 5).
1064
+ effectiveRatio /= 2;
1065
+ }
1066
+ if (lastDelta > prevDelta + 10 &&
1067
+ prevDelta > currentDelta + 10) {
1068
+ // Previous increase happened a note ago.
1069
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
1070
+ effectiveRatio /= 8;
1071
+ }
1072
+ rhythmComplexitySum +=
1073
+ (((Math.sqrt(effectiveRatio * startRatio) *
1074
+ currentHistoricalDecay *
1075
+ Math.sqrt(4 + islandSize)) /
1076
+ 2) *
1077
+ Math.sqrt(4 + previousIslandSize)) /
1078
+ 2;
1079
+ startRatio = effectiveRatio;
1080
+ previousIslandSize = islandSize;
1081
+ if (prevDelta * 1.25 < currentDelta) {
1082
+ // We're slowing down, stop counting.
1083
+ // If we're speeding up, this stays as is and we keep counting island size.
1084
+ firstDeltaSwitch = false;
1085
+ }
1086
+ islandSize = 1;
1087
+ }
1088
+ }
1089
+ else if (prevDelta > 1.25 * currentDelta) {
1090
+ // We want to be speeding up.
1091
+ // Begin counting island until we change speed again.
1092
+ firstDeltaSwitch = true;
1093
+ startRatio = effectiveRatio;
1094
+ islandSize = 1;
1095
+ }
1096
+ }
1097
+ return Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier) / 2;
1098
+ }
1099
+ strainValueAt(current) {
1100
+ this.currentRhythm = this.calculateRhythmBonus(current);
1101
+ this.currentStrain *= this.strainDecay(current.deltaTime);
1102
+ this.currentStrain += this.currentRhythm - 1;
1103
+ return this.currentStrain;
1104
+ }
1105
+ saveToHitObject(current) {
1106
+ current.rhythmStrain = this.currentStrain;
1107
+ current.rhythmMultiplier = this.currentRhythm;
1108
+ }
1109
+ }
1110
+
1111
+ /**
1112
+ * Represents the skill required to read every object in the map.
1113
+ */
1114
+ class DroidVisual extends DroidSkill {
1115
+ historyLength = 4;
1116
+ starsPerDouble = 1.025;
1117
+ reducedSectionCount = 10;
1118
+ reducedSectionBaseline = 0.75;
1119
+ skillMultiplier = 20;
1120
+ strainDecayBase = 0.1;
1121
+ preempt;
1122
+ isHidden;
1123
+ constructor(mods, preempt) {
1124
+ super(mods);
1125
+ this.preempt = preempt;
1126
+ this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1127
+ }
1128
+ /**
1129
+ * @param current The hitobject to calculate.
1130
+ */
1131
+ strainValueOf(current) {
1132
+ if (current.object instanceof osuBase.Spinner) {
1133
+ return 0;
1134
+ }
1135
+ // Start with base density and give global bonus for Hidden.
1136
+ // Add density caps for sanity.
1137
+ let strain = Math.min(20, Math.pow(current.noteDensity, 2)) /
1138
+ 10 /
1139
+ (1 + current.overlappingFactor);
1140
+ if (this.isHidden) {
1141
+ strain +=
1142
+ Math.min(25, Math.pow(current.noteDensity, 1.25)) /
1143
+ 10 /
1144
+ (1 + current.overlappingFactor / 1.25);
1145
+ }
1146
+ // Give bonus for AR higher than 10.33.
1147
+ if (this.preempt < 400) {
1148
+ strain += Math.pow(400 - this.preempt, 1.3) / 135;
1149
+ }
1150
+ if (current.object instanceof osuBase.Slider) {
1151
+ const scalingFactor = 50 / current.object.radius;
1152
+ // Reward sliders based on velocity.
1153
+ strain +=
1154
+ // Avoid overbuffing extremely fast sliders.
1155
+ Math.min(5, current.velocity * 1.25) *
1156
+ // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1157
+ Math.min(1, current.travelDistance / scalingFactor / 125);
1158
+ let cumulativeStrainTime = 0;
1159
+ // Reward for velocity changes based on last few sliders.
1160
+ for (let i = 0; i < this.previous.length; ++i) {
1161
+ const last = this.previous[i];
1162
+ cumulativeStrainTime += last.strainTime;
1163
+ if (!(last.object instanceof osuBase.Slider)) {
1164
+ continue;
1165
+ }
1166
+ strain +=
1167
+ // Avoid overbuffing extremely fast velocity changes.
1168
+ Math.min(8, 2 * Math.abs(current.velocity - last.velocity)) *
1169
+ // Scale with distance travelled to avoid overbuffing fast sliders with short distance.
1170
+ Math.min(1, last.travelDistance / scalingFactor / 100) *
1171
+ // Scale with cumulative strain time to avoid overbuffing past sliders.
1172
+ Math.min(1, 300 / cumulativeStrainTime);
1173
+ }
1174
+ }
1175
+ return strain;
1176
+ }
1177
+ strainValueAt(current) {
1178
+ this.currentStrain *= this.strainDecay(current.deltaTime);
1179
+ this.currentStrain +=
1180
+ this.strainValueOf(current) * this.skillMultiplier;
1181
+ return this.currentStrain * (1 + (current.rhythmMultiplier - 1) / 5);
1182
+ }
1183
+ saveToHitObject(current) {
1184
+ current.visualStrain = this.currentStrain;
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Difficulty calculator for osu!droid gamemode.
1190
+ */
1191
+ class DroidStarRating extends StarRating {
1192
+ /**
1193
+ * The aim star rating of the beatmap.
1194
+ */
1195
+ aim = 0;
1196
+ /**
1197
+ * The tap star rating of the beatmap.
1198
+ */
1199
+ tap = 0;
1200
+ /**
1201
+ * The rhythm star rating of the beatmap.
1202
+ */
1203
+ rhythm = 0;
1204
+ /**
1205
+ * The flashlight star rating of the beatmap.
1206
+ */
1207
+ flashlight = 0;
1208
+ /**
1209
+ * The visual star rating of the beatmap.
1210
+ */
1211
+ visual = 0;
1212
+ difficultyMultiplier = 0.18;
1213
+ /**
1214
+ * Calculates the star rating of the specified beatmap.
1215
+ *
1216
+ * The beatmap is analyzed in chunks of `sectionLength` duration.
1217
+ * For each chunk the highest hitobject strains are added to
1218
+ * a list which is then collapsed into a weighted sum, much
1219
+ * like scores are weighted on a user's profile.
1220
+ *
1221
+ * For subsequent chunks, the initial max strain is calculated
1222
+ * by decaying the previous hitobject's strain until the
1223
+ * beginning of the new chunk.
1224
+ *
1225
+ * The first object doesn't generate a strain
1226
+ * so we begin calculating from the second object.
1227
+ *
1228
+ * Also don't forget to manually add the peak strain for the last
1229
+ * section which would otherwise be ignored.
1230
+ */
1231
+ calculate(params) {
1232
+ return super.calculate(params, osuBase.modes.droid);
1233
+ }
1234
+ /**
1235
+ * Generates difficulty hitobjects for this calculator.
1236
+ */
1237
+ generateDifficultyHitObjects() {
1238
+ super.generateDifficultyHitObjects(osuBase.modes.droid);
1239
+ }
1240
+ /**
1241
+ * Calculates the aim star rating of the beatmap and stores it in this instance.
1242
+ */
1243
+ calculateAim() {
1244
+ const aimSkill = new DroidAim(this.mods, true);
1245
+ const aimSkillWithoutSliders = new DroidAim(this.mods, false);
1246
+ this.calculateSkills(aimSkill, aimSkillWithoutSliders);
1247
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1248
+ }
1249
+ /**
1250
+ * Calculates the speed star rating of the beatmap and stores it in this instance.
1251
+ */
1252
+ calculateTap() {
1253
+ const tapSkill = new DroidTap(this.mods, this.stats.od);
1254
+ this.calculateSkills(tapSkill);
1255
+ this.postCalculateTap(tapSkill);
1256
+ }
1257
+ /**
1258
+ * Calculates the rhythm star rating of the beatmap and stores it in this instance.
1259
+ */
1260
+ calculateRhythm() {
1261
+ const rhythmSkill = new DroidRhythm(this.mods, this.stats.od);
1262
+ this.calculateSkills(rhythmSkill);
1263
+ this.postCalculateRhythm(rhythmSkill);
1264
+ }
1265
+ /**
1266
+ * Calculates the flashlight star rating of the beatmap and stores it in this instance.
1267
+ */
1268
+ calculateFlashlight() {
1269
+ const flashlightSkill = new DroidFlashlight(this.mods);
1270
+ this.calculateSkills(flashlightSkill);
1271
+ this.postCalculateFlashlight(flashlightSkill);
1272
+ }
1273
+ /**
1274
+ * Calculates the visual star rating of the beatmap and stores it in this instance.
1275
+ */
1276
+ calculateVisual() {
1277
+ const visualSkill = new DroidVisual(this.mods, osuBase.MapStats.arToMS(this.stats.ar));
1278
+ this.calculateSkills(visualSkill);
1279
+ this.postCalculateVisual(visualSkill);
1280
+ }
1281
+ calculateTotal() {
1282
+ const aimPerformanceValue = this.basePerformanceValue(this.aim);
1283
+ const tapPerformanceValue = this.basePerformanceValue(this.tap);
1284
+ const flashlightPerformanceValue = this.mods.some((m) => m instanceof osuBase.ModFlashlight)
1285
+ ? Math.pow(this.flashlight, 2) * 25
1286
+ : 0;
1287
+ const visualPerformanceValue = Math.pow(this.visual, 2) * 25;
1288
+ const basePerformanceValue = Math.pow(Math.pow(aimPerformanceValue, 1.1) +
1289
+ Math.pow(tapPerformanceValue, 1.1) +
1290
+ Math.pow(flashlightPerformanceValue, 1.1) +
1291
+ Math.pow(visualPerformanceValue, 1.1), 1 / 1.1);
1292
+ if (basePerformanceValue > 1e-5) {
1293
+ this.total =
1294
+ Math.cbrt(1.12) *
1295
+ 0.025 *
1296
+ (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
1297
+ 4);
1298
+ }
1299
+ }
1300
+ calculateAll() {
1301
+ const skills = this.createSkills();
1302
+ const isRelax = this.mods.some((m) => m instanceof osuBase.ModRelax);
1303
+ this.calculateSkills(...skills);
1304
+ const aimSkill = skills[0];
1305
+ const aimSkillWithoutSliders = skills[1];
1306
+ const rhythmSkill = skills[2];
1307
+ const tapSkill = skills[3];
1308
+ const flashlightSkill = skills[4];
1309
+ const visualSkill = skills[5];
1310
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1311
+ if (!isRelax) {
1312
+ this.postCalculateTap(tapSkill);
1313
+ }
1314
+ else {
1315
+ this.calculateSpeedAttributes();
1316
+ }
1317
+ if (!isRelax) {
1318
+ this.postCalculateRhythm(rhythmSkill);
1319
+ }
1320
+ this.postCalculateFlashlight(flashlightSkill);
1321
+ this.postCalculateVisual(visualSkill);
1322
+ this.calculateTotal();
1323
+ }
1324
+ /**
1325
+ * Returns a string representative of the class.
1326
+ */
1327
+ toString() {
1328
+ return (this.total.toFixed(2) +
1329
+ " stars (" +
1330
+ this.aim.toFixed(2) +
1331
+ " aim, " +
1332
+ this.tap.toFixed(2) +
1333
+ " tap, " +
1334
+ this.rhythm.toFixed(2) +
1335
+ " rhythm, " +
1336
+ this.flashlight.toFixed(2) +
1337
+ " flashlight, " +
1338
+ this.visual.toFixed(2) +
1339
+ " visual)");
1340
+ }
1341
+ /**
1342
+ * Creates skills to be calculated.
1343
+ */
1344
+ createSkills() {
1345
+ return [
1346
+ new DroidAim(this.mods, true),
1347
+ new DroidAim(this.mods, false),
1348
+ // Tap skill depends on rhythm skill, so we put it first
1349
+ new DroidRhythm(this.mods, this.stats.od),
1350
+ new DroidTap(this.mods, this.stats.od),
1351
+ new DroidFlashlight(this.mods),
1352
+ new DroidVisual(this.mods, osuBase.MapStats.arToMS(this.stats.ar)),
1353
+ ];
1354
+ }
1355
+ /**
1356
+ * Called after aim skill calculation.
1357
+ *
1358
+ * @param aimSkill The aim skill that considers sliders.
1359
+ * @param aimSkillWithoutSliders The aim skill that doesn't consider sliders.
1360
+ */
1361
+ postCalculateAim(aimSkill, aimSkillWithoutSliders) {
1362
+ this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
1363
+ this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
1364
+ this.aim = this.starValue(aimSkill.difficultyValue());
1365
+ if (this.aim) {
1366
+ this.attributes.sliderFactor =
1367
+ this.starValue(aimSkillWithoutSliders.difficultyValue()) /
1368
+ this.aim;
1369
+ }
1370
+ }
1371
+ /**
1372
+ * Called after tap skill calculation.
1373
+ *
1374
+ * @param tapSkill The tap skill.
1375
+ */
1376
+ postCalculateTap(tapSkill) {
1377
+ this.strainPeaks.speed = tapSkill.strainPeaks;
1378
+ this.tap = this.starValue(tapSkill.difficultyValue());
1379
+ this.calculateSpeedAttributes();
1380
+ }
1381
+ /**
1382
+ * Calculates speed-related attributes.
1383
+ */
1384
+ calculateSpeedAttributes() {
1385
+ const objectStrains = this.objects.map((v) => v.tapStrain);
1386
+ const maxStrain = Math.max(...objectStrains);
1387
+ if (maxStrain) {
1388
+ this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
1389
+ }
1390
+ }
1391
+ /**
1392
+ * Called after rhythm skill calculation.
1393
+ *
1394
+ * @param rhythmSkill The rhythm skill.
1395
+ */
1396
+ postCalculateRhythm(rhythmSkill) {
1397
+ this.rhythm = this.starValue(rhythmSkill.difficultyValue());
1398
+ }
1399
+ /**
1400
+ * Called after flashlight skill calculation.
1401
+ *
1402
+ * @param flashlightSkill The flashlight skill.
1403
+ */
1404
+ postCalculateFlashlight(flashlightSkill) {
1405
+ this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
1406
+ this.flashlight = this.starValue(flashlightSkill.difficultyValue());
1407
+ }
1408
+ /**
1409
+ * Called after visual skill calculation.
1410
+ *
1411
+ * @param visualSkill The visual skill.
1412
+ */
1413
+ postCalculateVisual(visualSkill) {
1414
+ this.visual = this.starValue(visualSkill.difficultyValue());
1415
+ }
1416
+ }
1417
+
1418
+ /**
1419
+ * The base class of performance calculators.
1420
+ */
1421
+ class PerformanceCalculator {
1422
+ /**
1423
+ * The overall performance value.
1424
+ */
1425
+ total = 0;
1426
+ /**
1427
+ * The calculated accuracy.
1428
+ */
1429
+ computedAccuracy = new osuBase.Accuracy({});
1430
+ /**
1431
+ * The map statistics after applying modifications.
1432
+ */
1433
+ mapStatistics = new osuBase.MapStats();
1434
+ /**
1435
+ * Penalty for combo breaks.
1436
+ */
1437
+ comboPenalty = 0;
1438
+ /**
1439
+ * The amount of misses that are filtered out from sliderbreaks.
1440
+ */
1441
+ effectiveMissCount = 0;
1442
+ /**
1443
+ * Nerf factor used for nerfing beatmaps with very likely dropped sliderends.
1444
+ */
1445
+ sliderNerfFactor = 1;
1446
+ /**
1447
+ * Internal calculation method, used to process calculation from implementations.
1448
+ */
1449
+ calculateInternal(params, mode) {
1450
+ this.handleParams(params, mode);
1451
+ this.calculateValues();
1452
+ this.total = this.calculateTotalValue();
1453
+ return this;
1454
+ }
1455
+ /**
1456
+ * Calculates the base performance value of a star rating.
1457
+ */
1458
+ baseValue(stars) {
1459
+ return Math.pow(5 * Math.max(1, stars / 0.0675) - 4, 3) / 100000;
1460
+ }
1461
+ /**
1462
+ * Processes given parameters for usage in performance calculation.
1463
+ */
1464
+ handleParams(params, mode) {
1465
+ this.stars = params.stars;
1466
+ const maxCombo = this.stars.map.maxCombo;
1467
+ const miss = this.computedAccuracy.nmiss;
1468
+ const combo = params.combo ?? maxCombo - miss;
1469
+ const mod = this.stars.mods;
1470
+ const baseAR = this.stars.map.difficulty.ar;
1471
+ const baseOD = this.stars.map.difficulty.od;
1472
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
1473
+ this.comboPenalty = Math.min(Math.pow(combo / maxCombo, 0.8), 1);
1474
+ if (params.accPercent instanceof osuBase.Accuracy) {
1475
+ // Copy into new instance to not modify the original
1476
+ this.computedAccuracy = new osuBase.Accuracy(params.accPercent);
1477
+ }
1478
+ else {
1479
+ this.computedAccuracy = new osuBase.Accuracy({
1480
+ percent: params.accPercent,
1481
+ nobjects: this.stars.objects.length,
1482
+ nmiss: params.miss || 0,
1483
+ });
1484
+ }
1485
+ this.effectiveMissCount = this.calculateEffectiveMissCount(combo, maxCombo, mode);
1486
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModNoFail)) {
1487
+ this.finalMultiplier *= Math.max(0.9, 1 - 0.02 * this.effectiveMissCount);
1488
+ }
1489
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModSpunOut)) {
1490
+ this.finalMultiplier *=
1491
+ 1 -
1492
+ Math.pow(this.stars.map.hitObjects.spinners /
1493
+ this.stars.objects.length, 0.85);
1494
+ }
1495
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModRelax)) {
1496
+ // As we're adding 100s and 50s to an approximated number of combo breaks, the result can be higher
1497
+ // than total hits in specific scenarios (which breaks some calculations), so we need to clamp it.
1498
+ this.effectiveMissCount = Math.min(this.effectiveMissCount +
1499
+ this.computedAccuracy.n100 +
1500
+ this.computedAccuracy.n50, this.stars.objects.length);
1501
+ this.finalMultiplier *= 0.6;
1502
+ }
1503
+ this.mapStatistics = new osuBase.MapStats({
1504
+ ar: baseAR,
1505
+ od: baseOD,
1506
+ mods: mod,
1507
+ });
1508
+ // We assume 15% of sliders in a beatmap are difficult since there's no way to tell from the performance calculator.
1509
+ const estimateDifficultSliders = this.stars.map.hitObjects.sliders * 0.15;
1510
+ const estimateSliderEndsDropped = osuBase.MathUtils.clamp(Math.min(this.computedAccuracy.n300 +
1511
+ this.computedAccuracy.n50 +
1512
+ this.computedAccuracy.nmiss, maxCombo - combo), 0, estimateDifficultSliders);
1513
+ if (this.stars.map.hitObjects.sliders > 0) {
1514
+ this.sliderNerfFactor =
1515
+ (1 - this.stars.attributes.sliderFactor) *
1516
+ Math.pow(1 -
1517
+ estimateSliderEndsDropped /
1518
+ estimateDifficultSliders, 3) +
1519
+ this.stars.attributes.sliderFactor;
1520
+ }
1521
+ if (params.stats) {
1522
+ this.mapStatistics.ar = params.stats.ar ?? this.mapStatistics.ar;
1523
+ this.mapStatistics.isForceAR =
1524
+ params.stats.isForceAR ?? this.mapStatistics.isForceAR;
1525
+ this.mapStatistics.speedMultiplier =
1526
+ params.stats.speedMultiplier ??
1527
+ this.mapStatistics.speedMultiplier;
1528
+ this.mapStatistics.oldStatistics =
1529
+ params.stats.oldStatistics ?? this.mapStatistics.oldStatistics;
1530
+ }
1531
+ this.mapStatistics.calculate({ mode: mode });
1532
+ }
1533
+ /**
1534
+ * Calculates the amount of misses + sliderbreaks from combo.
1535
+ */
1536
+ calculateEffectiveMissCount(combo, maxCombo, mode) {
1537
+ let comboBasedMissCount = 0;
1538
+ if (this.stars.map.hitObjects.sliders > 0) {
1539
+ const fullComboThreshold = maxCombo - 0.1 * this.stars.map.hitObjects.sliders;
1540
+ if (combo < fullComboThreshold) {
1541
+ // We're clamping miss count because since it's derived from combo, it can
1542
+ // be higher than the amount of objects and that breaks some calculations.
1543
+ comboBasedMissCount = Math.min(fullComboThreshold / Math.max(1, combo), this.stars.objects.length);
1544
+ }
1545
+ }
1546
+ return Math.max(this.computedAccuracy.nmiss, mode === osuBase.modes.droid
1547
+ ? comboBasedMissCount
1548
+ : Math.floor(comboBasedMissCount));
1549
+ }
1550
+ }
1551
+
1552
+ /**
1553
+ * A performance points calculator that calculates performance points for osu!droid gamemode.
1554
+ */
1555
+ class DroidPerformanceCalculator extends PerformanceCalculator {
1556
+ stars = new DroidStarRating();
1557
+ finalMultiplier = 1.24;
1558
+ /**
1559
+ * The aim performance value.
1560
+ */
1561
+ aim = 0;
1562
+ /**
1563
+ * The tap performance value.
1564
+ */
1565
+ tap = 0;
1566
+ /**
1567
+ * The accuracy performance value.
1568
+ */
1569
+ accuracy = 0;
1570
+ /**
1571
+ * The flashlight performance value.
1572
+ */
1573
+ flashlight = 0;
1574
+ /**
1575
+ * The visual performance value.
1576
+ */
1577
+ visual = 0;
1578
+ tapPenalty = 1;
1579
+ calculate(params) {
1580
+ this.tapPenalty = params.tapPenalty ?? 1;
1581
+ return this.calculateInternal(params, osuBase.modes.droid);
1582
+ }
1583
+ calculateValues() {
1584
+ this.calculateAimValue();
1585
+ this.calculateTapValue();
1586
+ this.calculateAccuracyValue();
1587
+ this.calculateFlashlightValue();
1588
+ this.calculateVisualValue();
1589
+ // Apply tap penalty for penalized plays.
1590
+ this.tap /= this.tapPenalty;
1591
+ }
1592
+ calculateTotalValue() {
1593
+ return (Math.pow(Math.pow(this.aim, 1.1) +
1594
+ Math.pow(this.tap, 1.1) +
1595
+ Math.pow(this.accuracy, 1.1) +
1596
+ Math.pow(this.flashlight, 1.1) +
1597
+ Math.pow(this.visual, 1.1), 1 / 1.1) * this.finalMultiplier);
1598
+ }
1599
+ /**
1600
+ * Calculates the aim performance value of the beatmap.
1601
+ */
1602
+ calculateAimValue() {
1603
+ // Global variables
1604
+ const objectCount = this.stars.objects.length;
1605
+ this.aim = this.baseValue(Math.pow(this.stars.aim, 0.8));
1606
+ if (this.effectiveMissCount > 0) {
1607
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
1608
+ this.aim *=
1609
+ 0.97 *
1610
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), this.effectiveMissCount);
1611
+ }
1612
+ // Combo scaling
1613
+ this.aim *= this.comboPenalty;
1614
+ // Scale the aim value with slider factor to nerf very likely dropped sliderends.
1615
+ this.aim *= this.sliderNerfFactor;
1616
+ // Scale the aim value with accuracy.
1617
+ this.aim *= this.computedAccuracy.value(objectCount);
1618
+ // It is also important to consider accuracy difficulty when doing that.
1619
+ const odScaling = Math.pow(this.mapStatistics.od, 2) / 2500;
1620
+ this.aim *=
1621
+ 0.98 + (this.mapStatistics.od >= 0 ? odScaling : -odScaling);
1622
+ }
1623
+ /**
1624
+ * Calculates the tap performance value of the beatmap.
1625
+ */
1626
+ calculateTapValue() {
1627
+ // Global variables
1628
+ const objectCount = this.stars.objects.length;
1629
+ this.tap = this.baseValue(this.stars.tap);
1630
+ if (this.effectiveMissCount > 0) {
1631
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
1632
+ this.tap *=
1633
+ 0.97 *
1634
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), Math.pow(this.effectiveMissCount, 0.875));
1635
+ }
1636
+ // Combo scaling
1637
+ this.tap *= this.comboPenalty;
1638
+ // Calculate accuracy assuming the worst case scenario.
1639
+ const countGreat = this.computedAccuracy.n300;
1640
+ const countOk = this.computedAccuracy.n100;
1641
+ const countMeh = this.computedAccuracy.n50;
1642
+ const relevantTotalDiff = objectCount - this.stars.attributes.speedNoteCount;
1643
+ const relevantAccuracy = new osuBase.Accuracy({
1644
+ n300: Math.max(0, countGreat - relevantTotalDiff),
1645
+ n100: Math.max(0, countOk - Math.max(0, relevantTotalDiff - countGreat)),
1646
+ n50: Math.max(0, countMeh - Math.max(0, relevantTotalDiff - countGreat - countOk)),
1647
+ nmiss: this.effectiveMissCount,
1648
+ });
1649
+ // Scale the speed value with accuracy and OD.
1650
+ const od = this.mapStatistics.od;
1651
+ const odScaling = Math.pow(od, 2) / 750;
1652
+ this.tap *=
1653
+ (0.95 + (od > 0 ? odScaling : -odScaling)) *
1654
+ Math.pow((this.computedAccuracy.value(objectCount) +
1655
+ relevantAccuracy.value(this.stars.attributes.speedNoteCount)) /
1656
+ 2, (14 - Math.max(od, 2.5)) / 2);
1657
+ // Scale the speed value with # of 50s to punish doubletapping.
1658
+ this.tap *= Math.pow(0.98, Math.max(0, this.computedAccuracy.n50 - objectCount / 500));
1659
+ }
1660
+ /**
1661
+ * Calculates the accuracy performance value of the beatmap.
1662
+ */
1663
+ calculateAccuracyValue() {
1664
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModRelax)) {
1665
+ return;
1666
+ }
1667
+ // Global variables
1668
+ const objectCount = this.stars.objects.length;
1669
+ const ncircles = this.stars.mods.some((m) => m instanceof osuBase.ModScoreV2)
1670
+ ? objectCount - this.stars.map.hitObjects.spinners
1671
+ : this.stars.map.hitObjects.circles;
1672
+ if (ncircles === 0) {
1673
+ return;
1674
+ }
1675
+ const realAccuracy = new osuBase.Accuracy({
1676
+ ...this.computedAccuracy,
1677
+ n300: this.computedAccuracy.n300 - (objectCount - ncircles),
1678
+ });
1679
+ // Lots of arbitrary values from testing.
1680
+ // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
1681
+ this.accuracy =
1682
+ Math.pow(1.4, this.mapStatistics.od) *
1683
+ Math.pow(realAccuracy.value(ncircles), 12) *
1684
+ 10;
1685
+ // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
1686
+ this.accuracy *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
1687
+ // Scale the accuracy value with rhythm complexity.
1688
+ this.accuracy *= 1.5 / (1 + Math.exp(-(this.stars.rhythm - 1) / 2));
1689
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
1690
+ this.accuracy *= 1.08;
1691
+ }
1692
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
1693
+ this.accuracy *= 1.02;
1694
+ }
1695
+ }
1696
+ /**
1697
+ * Calculates the flashlight performance value of the beatmap.
1698
+ */
1699
+ calculateFlashlightValue() {
1700
+ if (!this.stars.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
1701
+ return;
1702
+ }
1703
+ // Global variables
1704
+ const objectCount = this.stars.objects.length;
1705
+ this.flashlight =
1706
+ Math.pow(Math.pow(this.stars.flashlight, 0.8), 2) * 25;
1707
+ // Add an additional bonus for HDFL.
1708
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
1709
+ this.flashlight *= 1.3;
1710
+ }
1711
+ // Combo scaling
1712
+ this.flashlight *= this.comboPenalty;
1713
+ if (this.effectiveMissCount > 0) {
1714
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
1715
+ this.flashlight *=
1716
+ 0.97 *
1717
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), Math.pow(this.effectiveMissCount, 0.875));
1718
+ }
1719
+ // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
1720
+ this.flashlight *=
1721
+ 0.7 +
1722
+ 0.1 * Math.min(1, objectCount / 200) +
1723
+ (objectCount > 200
1724
+ ? 0.2 * Math.min(1, (objectCount - 200) / 200)
1725
+ : 0);
1726
+ // Scale the flashlight value with accuracy slightly.
1727
+ this.flashlight *= 0.5 + this.computedAccuracy.value(objectCount) / 2;
1728
+ // It is also important to consider accuracy difficulty when doing that.
1729
+ const odScaling = Math.pow(this.mapStatistics.od, 2) / 2500;
1730
+ this.flashlight *=
1731
+ 0.98 + (this.mapStatistics.od >= 0 ? odScaling : -odScaling);
1732
+ }
1733
+ /**
1734
+ * Calculates the visual performance value of the beatmap.
1735
+ */
1736
+ calculateVisualValue() {
1737
+ // Global variables
1738
+ const objectCount = this.stars.objects.length;
1739
+ this.visual = Math.pow(Math.pow(this.stars.visual, 0.8), 2) * 25;
1740
+ if (this.effectiveMissCount > 0) {
1741
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
1742
+ this.visual *=
1743
+ 0.97 *
1744
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), this.effectiveMissCount);
1745
+ }
1746
+ // Combo scaling
1747
+ this.visual *= this.comboPenalty;
1748
+ // Scale the visual value with object count to penalize short maps.
1749
+ this.visual *= Math.min(1, 1.650668 +
1750
+ (0.4845796 - 1.650668) /
1751
+ (1 + Math.pow(objectCount / 817.9306, 1.147469)));
1752
+ // Scale the visual value with accuracy harshly.
1753
+ this.visual *= Math.pow(this.computedAccuracy.value(), 8);
1754
+ // It is also important to consider accuracy difficulty when doing that.
1755
+ const odScaling = Math.pow(this.mapStatistics.od, 2) / 2500;
1756
+ this.visual *=
1757
+ 0.98 + (this.mapStatistics.od >= 0 ? odScaling : -odScaling);
1758
+ }
1759
+ toString() {
1760
+ return (this.total.toFixed(2) +
1761
+ " pp (" +
1762
+ this.aim.toFixed(2) +
1763
+ " aim, " +
1764
+ this.tap.toFixed(2) +
1765
+ " tap, " +
1766
+ this.accuracy.toFixed(2) +
1767
+ " acc, " +
1768
+ this.flashlight.toFixed(2) +
1769
+ " flashlight, " +
1770
+ this.visual.toFixed(2) +
1771
+ " visual)");
1772
+ }
1773
+ }
1774
+
1775
+ /**
1776
+ * Used to processes strain values of difficulty hitobjects, keep track of strain levels caused by the processed objects
1777
+ * and to calculate a final difficulty value representing the difficulty of hitting all the processed objects.
1778
+ */
1779
+ class OsuSkill extends StrainSkill {
1780
+ difficultyValue() {
1781
+ const sortedStrains = this.strainPeaks
1782
+ .slice()
1783
+ .sort((a, b) => {
1784
+ return b - a;
1785
+ });
1786
+ // We are reducing the highest strains first to account for extreme difficulty spikes.
1787
+ for (let i = 0; i < Math.min(sortedStrains.length, this.reducedSectionCount); ++i) {
1788
+ const scale = Math.log10(osuBase.Interpolation.lerp(1, 10, osuBase.MathUtils.clamp(i / this.reducedSectionCount, 0, 1)));
1789
+ sortedStrains[i] *= osuBase.Interpolation.lerp(this.reducedSectionBaseline, 1, scale);
1790
+ }
1791
+ // Difficulty is the weighted sum of the highest strains from every section.
1792
+ // We're sorting from highest to lowest strain.
1793
+ sortedStrains.sort((a, b) => {
1794
+ return b - a;
1795
+ });
1796
+ let difficulty = 0;
1797
+ let weight = 1;
1798
+ for (const strain of sortedStrains) {
1799
+ const addition = strain * weight;
1800
+ if (difficulty + addition === difficulty) {
1801
+ break;
1802
+ }
1803
+ difficulty += addition;
1804
+ weight *= this.decayWeight;
1805
+ }
1806
+ return difficulty * this.difficultyMultiplier;
1807
+ }
1808
+ }
1809
+
1810
+ /**
1811
+ * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
1812
+ */
1813
+ class OsuAim extends OsuSkill {
1814
+ skillMultiplier = 23.25;
1815
+ strainDecayBase = 0.15;
1816
+ reducedSectionCount = 10;
1817
+ reducedSectionBaseline = 0.75;
1818
+ difficultyMultiplier = 1.06;
1819
+ decayWeight = 0.9;
1820
+ wideAngleMultiplier = 1.5;
1821
+ acuteAngleMultiplier = 2;
1822
+ sliderMultiplier = 1.5;
1823
+ velocityChangeMultiplier = 0.75;
1824
+ withSliders;
1825
+ constructor(mods, withSliders) {
1826
+ super(mods);
1827
+ this.withSliders = withSliders;
1828
+ }
1829
+ /**
1830
+ * @param current The hitobject to calculate.
1831
+ */
1832
+ strainValueOf(current) {
1833
+ if (current.object instanceof osuBase.Spinner ||
1834
+ this.previous.length <= 1 ||
1835
+ this.previous[0].object instanceof osuBase.Spinner) {
1836
+ return 0;
1837
+ }
1838
+ const last = this.previous[0];
1839
+ const lastLast = this.previous[1];
1840
+ // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
1841
+ let currentVelocity = current.lazyJumpDistance / current.strainTime;
1842
+ // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
1843
+ if (last.object instanceof osuBase.Slider && this.withSliders) {
1844
+ // Calculate the slider velocity from slider head to slider end.
1845
+ const travelVelocity = last.travelDistance / last.travelTime;
1846
+ // Calculate the movement velocity from slider end to current object.
1847
+ const movementVelocity = current.minimumJumpDistance / current.minimumJumpTime;
1848
+ // Take the larger total combined velocity.
1849
+ currentVelocity = Math.max(currentVelocity, movementVelocity + travelVelocity);
1850
+ }
1851
+ // As above, do the same for the previous hitobject.
1852
+ let prevVelocity = last.lazyJumpDistance / last.strainTime;
1853
+ if (lastLast.object instanceof osuBase.Slider && this.withSliders) {
1854
+ const travelVelocity = lastLast.travelDistance / lastLast.travelTime;
1855
+ const movementVelocity = last.minimumJumpDistance / last.minimumJumpTime;
1856
+ prevVelocity = Math.max(prevVelocity, movementVelocity + travelVelocity);
1857
+ }
1858
+ let wideAngleBonus = 0;
1859
+ let acuteAngleBonus = 0;
1860
+ let sliderBonus = 0;
1861
+ let velocityChangeBonus = 0;
1862
+ // Start strain with regular velocity.
1863
+ let strain = currentVelocity;
1864
+ if (Math.max(current.strainTime, last.strainTime) <
1865
+ 1.25 * Math.min(current.strainTime, last.strainTime)) {
1866
+ // If rhythms are the same.
1867
+ if (current.angle !== null &&
1868
+ last.angle !== null &&
1869
+ lastLast.angle !== null) {
1870
+ // Rewarding angles, take the smaller velocity as base.
1871
+ const angleBonus = Math.min(currentVelocity, prevVelocity);
1872
+ wideAngleBonus = this.calculateWideAngleBonus(current.angle);
1873
+ acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
1874
+ // Only buff deltaTime exceeding 300 BPM 1/2.
1875
+ if (current.strainTime > 100) {
1876
+ acuteAngleBonus = 0;
1877
+ }
1878
+ else {
1879
+ acuteAngleBonus *=
1880
+ // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
1881
+ this.calculateAcuteAngleBonus(last.angle) *
1882
+ // The maximum velocity we buff is equal to 125 / strainTime.
1883
+ Math.min(angleBonus, 125 / current.strainTime) *
1884
+ // Scale buff from 300 BPM 1/2 to 400 BPM 1/2.
1885
+ Math.pow(Math.sin((Math.PI / 2) *
1886
+ Math.min(1, (100 - current.strainTime) / 25)), 2) *
1887
+ // Buff distance exceeding 50 (radius) up to 100 (diameter).
1888
+ Math.pow(Math.sin(((Math.PI / 2) *
1889
+ (osuBase.MathUtils.clamp(current.lazyJumpDistance, 50, 100) -
1890
+ 50)) /
1891
+ 50), 2);
1892
+ }
1893
+ // Penalize wide angles if they're repeated, reducing the penalty as last.angle gets more acute.
1894
+ wideAngleBonus *=
1895
+ angleBonus *
1896
+ (1 -
1897
+ Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(last.angle), 3)));
1898
+ // Penalize acute angles if they're repeated, reducing the penalty as lastLast.angle gets more obtuse.
1899
+ acuteAngleBonus *=
1900
+ 0.5 +
1901
+ 0.5 *
1902
+ (1 -
1903
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastLast.angle), 3)));
1904
+ }
1905
+ }
1906
+ if (Math.max(prevVelocity, currentVelocity)) {
1907
+ // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
1908
+ prevVelocity =
1909
+ (last.lazyJumpDistance + lastLast.travelDistance) /
1910
+ last.strainTime;
1911
+ currentVelocity =
1912
+ (current.lazyJumpDistance + last.travelDistance) /
1913
+ current.strainTime;
1914
+ // Scale with ratio of difference compared to half the max distance.
1915
+ const distanceRatio = Math.pow(Math.sin(((Math.PI / 2) * Math.abs(prevVelocity - currentVelocity)) /
1916
+ Math.max(prevVelocity, currentVelocity)), 2);
1917
+ // Reward for % distance up to 125 / strainTime for overlaps where velocity is still changing.
1918
+ const overlapVelocityBuff = Math.min(125 / Math.min(current.strainTime, last.strainTime), Math.abs(prevVelocity - currentVelocity));
1919
+ // Reward for % distance slowed down compared to previous, paying attention to not award overlap.
1920
+ const nonOverlapVelocityBuff = Math.abs(prevVelocity - currentVelocity) *
1921
+ // Do not award overlap.
1922
+ Math.pow(Math.sin((Math.PI / 2) *
1923
+ Math.min(1, Math.min(current.lazyJumpDistance, last.lazyJumpDistance) / 100)), 2);
1924
+ // Choose the largest bonus, multiplied by ratio.
1925
+ velocityChangeBonus =
1926
+ Math.max(overlapVelocityBuff, nonOverlapVelocityBuff) *
1927
+ distanceRatio;
1928
+ // Penalize for rhythm changes.
1929
+ velocityChangeBonus *= Math.pow(Math.min(current.strainTime, last.strainTime) /
1930
+ Math.max(current.strainTime, last.strainTime), 2);
1931
+ }
1932
+ if (last.travelTime) {
1933
+ // Reward sliders based on velocity.
1934
+ sliderBonus = last.travelDistance / last.travelTime;
1935
+ }
1936
+ // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
1937
+ strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
1938
+ velocityChangeBonus * this.velocityChangeMultiplier);
1939
+ // Add in additional slider velocity bonus.
1940
+ if (this.withSliders) {
1941
+ strain += sliderBonus * this.sliderMultiplier;
1942
+ }
1943
+ return strain;
1944
+ }
1945
+ /**
1946
+ * @param current The hitobject to calculate.
1947
+ */
1948
+ strainValueAt(current) {
1949
+ this.currentStrain *= this.strainDecay(current.deltaTime);
1950
+ this.currentStrain +=
1951
+ this.strainValueOf(current) * this.skillMultiplier;
1952
+ return this.currentStrain;
1953
+ }
1954
+ /**
1955
+ * @param current The hitobject to save to.
1956
+ */
1957
+ saveToHitObject(current) {
1958
+ current.aimStrainWithSliders = this.currentStrain;
1959
+ }
1960
+ /**
1961
+ * Calculates the bonus of wide angles.
1962
+ */
1963
+ calculateWideAngleBonus(angle) {
1964
+ return Math.pow(Math.sin((3 / 4) *
1965
+ (Math.min((5 / 6) * Math.PI, Math.max(Math.PI / 6, angle)) -
1966
+ Math.PI / 6)), 2);
1967
+ }
1968
+ /**
1969
+ * Calculates the bonus of acute angles.
1970
+ */
1971
+ calculateAcuteAngleBonus(angle) {
1972
+ return 1 - this.calculateWideAngleBonus(angle);
1973
+ }
1974
+ }
1975
+
1976
+ /**
1977
+ * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
1978
+ */
1979
+ class OsuSpeed extends OsuSkill {
1980
+ /**
1981
+ * Spacing threshold for a single hitobject spacing.
1982
+ */
1983
+ SINGLE_SPACING_THRESHOLD = 125;
1984
+ historyLength = 32;
1985
+ skillMultiplier = 1375;
1986
+ strainDecayBase = 0.3;
1987
+ reducedSectionCount = 5;
1988
+ reducedSectionBaseline = 0.75;
1989
+ difficultyMultiplier = 1.04;
1990
+ decayWeight = 0.9;
1991
+ rhythmMultiplier = 0.75;
1992
+ historyTimeMax = 5000; // 5 seconds of calculateRhythmBonus max.
1993
+ currentSpeedStrain = 0;
1994
+ currentRhythm = 0;
1995
+ // ~200 1/4 BPM streams
1996
+ minSpeedBonus = 75;
1997
+ greatWindow;
1998
+ constructor(mods, greatWindow) {
1999
+ super(mods);
2000
+ this.greatWindow = greatWindow;
2001
+ }
2002
+ /**
2003
+ * @param current The hitobject to calculate.
2004
+ */
2005
+ strainValueOf(current) {
2006
+ if (current.object instanceof osuBase.Spinner) {
2007
+ return 0;
2008
+ }
2009
+ let strainTime = current.strainTime;
2010
+ const greatWindowFull = this.greatWindow * 2;
2011
+ const speedWindowRatio = strainTime / greatWindowFull;
2012
+ // Aim to nerf cheesy rhythms (very fast consecutive doubles with large deltatimes between).
2013
+ if (this.previous[0] &&
2014
+ strainTime < greatWindowFull &&
2015
+ this.previous[0].strainTime > strainTime) {
2016
+ strainTime = osuBase.Interpolation.lerp(this.previous[0].strainTime, strainTime, speedWindowRatio);
2017
+ }
2018
+ // Cap deltatime to the OD 300 hitwindow.
2019
+ // 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.
2020
+ strainTime /= osuBase.MathUtils.clamp(strainTime / greatWindowFull / 0.93, 0.92, 1);
2021
+ let speedBonus = 1;
2022
+ if (strainTime < this.minSpeedBonus) {
2023
+ speedBonus +=
2024
+ 0.75 * Math.pow((this.minSpeedBonus - strainTime) / 40, 2);
2025
+ }
2026
+ const travelDistance = this.previous[0]?.travelDistance ?? 0;
2027
+ const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.lazyJumpDistance);
2028
+ return ((speedBonus +
2029
+ speedBonus *
2030
+ Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.5)) /
2031
+ strainTime);
2032
+ }
2033
+ /**
2034
+ * @param current The hitobject to calculate.
2035
+ */
2036
+ strainValueAt(current) {
2037
+ this.currentSpeedStrain *= this.strainDecay(current.deltaTime);
2038
+ this.currentSpeedStrain +=
2039
+ this.strainValueOf(current) * this.skillMultiplier;
2040
+ this.currentRhythm = this.calculateRhythmBonus(current);
2041
+ return this.currentSpeedStrain * this.currentRhythm;
2042
+ }
2043
+ /**
2044
+ * Calculates a rhythm multiplier for the difficulty of the tap associated with historic data of the current object.
2045
+ */
2046
+ calculateRhythmBonus(current) {
2047
+ if (current.object instanceof osuBase.Spinner) {
2048
+ return 0;
2049
+ }
2050
+ let previousIslandSize = 0;
2051
+ let rhythmComplexitySum = 0;
2052
+ let islandSize = 1;
2053
+ // Store the ratio of the current start of an island to buff for tighter rhythms.
2054
+ let startRatio = 0;
2055
+ let firstDeltaSwitch = false;
2056
+ let rhythmStart = 0;
2057
+ while (rhythmStart < this.previous.length - 2 &&
2058
+ current.startTime - this.previous[rhythmStart].startTime <
2059
+ this.historyTimeMax) {
2060
+ ++rhythmStart;
2061
+ }
2062
+ for (let i = rhythmStart; i > 0; --i) {
2063
+ // Scale note 0 to 1 from history to now.
2064
+ let currentHistoricalDecay = (this.historyTimeMax -
2065
+ (current.startTime - this.previous[i - 1].startTime)) /
2066
+ this.historyTimeMax;
2067
+ // Either we're limited by time or limited by object count.
2068
+ currentHistoricalDecay = Math.min(currentHistoricalDecay, (this.previous.length - i) / this.previous.length);
2069
+ const currentDelta = this.previous[i - 1].strainTime;
2070
+ const prevDelta = this.previous[i].strainTime;
2071
+ const lastDelta = this.previous[i + 1].strainTime;
2072
+ const currentRatio = 1 +
2073
+ 6 *
2074
+ Math.min(0.5, Math.pow(Math.sin(Math.PI /
2075
+ (Math.min(prevDelta, currentDelta) /
2076
+ Math.max(prevDelta, currentDelta))), 2));
2077
+ const windowPenalty = Math.min(1, Math.max(0, Math.abs(prevDelta - currentDelta) - this.greatWindow * 0.6) /
2078
+ (this.greatWindow * 0.6));
2079
+ let effectiveRatio = windowPenalty * currentRatio;
2080
+ if (firstDeltaSwitch) {
2081
+ if (prevDelta <= 1.25 * currentDelta &&
2082
+ prevDelta * 1.25 >= currentDelta) {
2083
+ // Island is still progressing, count size.
2084
+ if (islandSize < 7) {
2085
+ ++islandSize;
2086
+ }
2087
+ }
2088
+ else {
2089
+ if (this.previous[i - 1].object instanceof osuBase.Slider) {
2090
+ // BPM change is into slider, this is easy acc window.
2091
+ effectiveRatio /= 8;
2092
+ }
2093
+ if (this.previous[i].object instanceof osuBase.Slider) {
2094
+ // BPM change was from a slider, this is typically easier than circle -> circle.
2095
+ effectiveRatio /= 4;
2096
+ }
2097
+ if (previousIslandSize === islandSize) {
2098
+ // Repeated island size (ex: triplet -> triplet).
2099
+ effectiveRatio /= 4;
2100
+ }
2101
+ if (previousIslandSize % 2 === islandSize % 2) {
2102
+ // Repeated island polarity (2 -> 4, 3 -> 5).
2103
+ effectiveRatio /= 2;
2104
+ }
2105
+ if (lastDelta > prevDelta + 10 &&
2106
+ prevDelta > currentDelta + 10) {
2107
+ // Previous increase happened a note ago.
2108
+ // Albeit this is a 1/1 -> 1/2-1/4 type of transition, we don't want to buff this.
2109
+ effectiveRatio /= 8;
2110
+ }
2111
+ rhythmComplexitySum +=
2112
+ (((Math.sqrt(effectiveRatio * startRatio) *
2113
+ currentHistoricalDecay *
2114
+ Math.sqrt(4 + islandSize)) /
2115
+ 2) *
2116
+ Math.sqrt(4 + previousIslandSize)) /
2117
+ 2;
2118
+ startRatio = effectiveRatio;
2119
+ previousIslandSize = islandSize;
2120
+ if (prevDelta * 1.25 < currentDelta) {
2121
+ // We're slowing down, stop counting.
2122
+ // If we're speeding up, this stays as is and we keep counting island size.
2123
+ firstDeltaSwitch = false;
2124
+ }
2125
+ islandSize = 1;
2126
+ }
2127
+ }
2128
+ else if (prevDelta > 1.25 * currentDelta) {
2129
+ // We want to be speeding up.
2130
+ // Begin counting island until we change speed again.
2131
+ firstDeltaSwitch = true;
2132
+ startRatio = effectiveRatio;
2133
+ islandSize = 1;
2134
+ }
2135
+ }
2136
+ return Math.sqrt(4 + rhythmComplexitySum * this.rhythmMultiplier) / 2;
2137
+ }
2138
+ /**
2139
+ * @param current The hitobject to save to.
2140
+ */
2141
+ saveToHitObject(current) {
2142
+ current.tapStrain = this.currentStrain;
2143
+ current.rhythmMultiplier = this.currentRhythm;
2144
+ }
2145
+ }
2146
+
2147
+ /**
2148
+ * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
2149
+ */
2150
+ class OsuFlashlight extends OsuSkill {
2151
+ historyLength = 10;
2152
+ skillMultiplier = 0.15;
2153
+ strainDecayBase = 0.15;
2154
+ reducedSectionCount = 10;
2155
+ reducedSectionBaseline = 0.75;
2156
+ difficultyMultiplier = 1.06;
2157
+ decayWeight = 1;
2158
+ strainValueOf(current) {
2159
+ if (current.object instanceof osuBase.Spinner) {
2160
+ return 0;
2161
+ }
2162
+ const scalingFactor = 52 / current.object.radius;
2163
+ let smallDistNerf = 1;
2164
+ let cumulativeStrainTime = 0;
2165
+ let result = 0;
2166
+ for (let i = 0; i < this.previous.length; ++i) {
2167
+ const previous = this.previous[i];
2168
+ if (previous.object instanceof osuBase.Spinner) {
2169
+ continue;
2170
+ }
2171
+ const jumpDistance = current.object.stackedPosition.subtract(previous.object.endPosition).length;
2172
+ cumulativeStrainTime += previous.strainTime;
2173
+ // We want to nerf objects that can be easily seen within the Flashlight circle radius.
2174
+ if (i === 0) {
2175
+ smallDistNerf = Math.min(1, jumpDistance / 75);
2176
+ }
2177
+ // We also want to nerf stacks so that only the first object of the stack is accounted for.
2178
+ const stackNerf = Math.min(1, previous.lazyJumpDistance / scalingFactor / 25);
2179
+ result +=
2180
+ (Math.pow(0.8, i) * stackNerf * scalingFactor * jumpDistance) /
2181
+ cumulativeStrainTime;
2182
+ }
2183
+ return Math.pow(smallDistNerf * result, 2);
2184
+ }
2185
+ /**
2186
+ * @param current The hitobject to calculate.
2187
+ */
2188
+ strainValueAt(current) {
2189
+ this.currentStrain *= this.strainDecay(current.deltaTime);
2190
+ this.currentStrain +=
2191
+ this.strainValueOf(current) * this.skillMultiplier;
2192
+ return this.currentStrain;
2193
+ }
2194
+ saveToHitObject(current) {
2195
+ current.flashlightStrain = this.currentStrain;
2196
+ }
2197
+ }
2198
+
2199
+ /**
2200
+ * Difficulty calculator for osu!standard gamemode.
2201
+ */
2202
+ class OsuStarRating extends StarRating {
2203
+ /**
2204
+ * The aim star rating of the beatmap.
2205
+ */
2206
+ aim = 0;
2207
+ /**
2208
+ * The speed star rating of the beatmap.
2209
+ */
2210
+ speed = 0;
2211
+ /**
2212
+ * The flashlight star rating of the beatmap.
2213
+ */
2214
+ flashlight = 0;
2215
+ difficultyMultiplier = 0.0675;
2216
+ calculate(params) {
2217
+ return super.calculate(params, osuBase.modes.osu);
2218
+ }
2219
+ /**
2220
+ * Generates difficulty hitobjects for this calculator.
2221
+ */
2222
+ generateDifficultyHitObjects() {
2223
+ super.generateDifficultyHitObjects(osuBase.modes.osu);
2224
+ }
2225
+ /**
2226
+ * Calculates the aim star rating of the beatmap and stores it in this instance.
2227
+ */
2228
+ calculateAim() {
2229
+ const aimSkill = new OsuAim(this.mods, true);
2230
+ const aimSkillWithoutSliders = new OsuAim(this.mods, false);
2231
+ this.calculateSkills(aimSkill, aimSkillWithoutSliders);
2232
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
2233
+ }
2234
+ /**
2235
+ * Calculates the speed star rating of the beatmap and stores it in this instance.
2236
+ */
2237
+ calculateSpeed() {
2238
+ if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
2239
+ return;
2240
+ }
2241
+ const speedSkill = new OsuSpeed(this.mods, new osuBase.OsuHitWindow(this.stats.od).hitWindowFor300());
2242
+ this.calculateSkills(speedSkill);
2243
+ this.postCalculateSpeed(speedSkill);
2244
+ }
2245
+ /**
2246
+ * Calculates the flashlight star rating of the beatmap and stores it in this instance.
2247
+ */
2248
+ calculateFlashlight() {
2249
+ const flashlightSkill = new OsuFlashlight(this.mods);
2250
+ this.calculateSkills(flashlightSkill);
2251
+ this.postCalculateFlashlight(flashlightSkill);
2252
+ }
2253
+ calculateTotal() {
2254
+ const aimPerformanceValue = this.basePerformanceValue(this.aim);
2255
+ const speedPerformanceValue = this.basePerformanceValue(this.speed);
2256
+ let flashlightPerformanceValue = 0;
2257
+ if (this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2258
+ flashlightPerformanceValue = Math.pow(this.flashlight, 2) * 25;
2259
+ }
2260
+ const basePerformanceValue = Math.pow(Math.pow(aimPerformanceValue, 1.1) +
2261
+ Math.pow(speedPerformanceValue, 1.1) +
2262
+ Math.pow(flashlightPerformanceValue, 1.1), 1 / 1.1);
2263
+ if (basePerformanceValue > 1e-5) {
2264
+ this.total =
2265
+ Math.cbrt(1.12) *
2266
+ 0.027 *
2267
+ (Math.cbrt((100000 / Math.pow(2, 1 / 1.1)) * basePerformanceValue) +
2268
+ 4);
2269
+ }
2270
+ }
2271
+ calculateAll() {
2272
+ const skills = this.createSkills();
2273
+ const isRelax = this.mods.some((m) => m instanceof osuBase.ModRelax);
2274
+ this.calculateSkills(...skills);
2275
+ const aimSkill = skills[0];
2276
+ const aimSkillWithoutSliders = skills[1];
2277
+ let speedSkill;
2278
+ let flashlightSkill;
2279
+ if (isRelax) {
2280
+ flashlightSkill = skills[2];
2281
+ }
2282
+ else {
2283
+ speedSkill = skills[2];
2284
+ flashlightSkill = skills[3];
2285
+ }
2286
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
2287
+ if (speedSkill) {
2288
+ this.postCalculateSpeed(speedSkill);
2289
+ }
2290
+ this.postCalculateFlashlight(flashlightSkill);
2291
+ this.calculateTotal();
2292
+ }
2293
+ /**
2294
+ * Returns a string representative of the class.
2295
+ */
2296
+ toString() {
2297
+ return (this.total.toFixed(2) +
2298
+ " stars (" +
2299
+ this.aim.toFixed(2) +
2300
+ " aim, " +
2301
+ this.speed.toFixed(2) +
2302
+ " speed, " +
2303
+ this.flashlight.toFixed(2) +
2304
+ " flashlight)");
2305
+ }
2306
+ /**
2307
+ * Creates skills to be calculated.
2308
+ */
2309
+ createSkills() {
2310
+ return [
2311
+ new OsuAim(this.mods, true),
2312
+ new OsuAim(this.mods, false),
2313
+ new OsuSpeed(this.mods, new osuBase.OsuHitWindow(this.stats.od).hitWindowFor300()),
2314
+ new OsuFlashlight(this.mods),
2315
+ ];
2316
+ }
2317
+ /**
2318
+ * Called after aim skill calculation.
2319
+ *
2320
+ * @param aimSkill The aim skill that considers sliders.
2321
+ * @param aimSkillWithoutSliders The aim skill that doesn't consider sliders.
2322
+ */
2323
+ postCalculateAim(aimSkill, aimSkillWithoutSliders) {
2324
+ this.strainPeaks.aimWithSliders = aimSkill.strainPeaks;
2325
+ this.strainPeaks.aimWithoutSliders = aimSkillWithoutSliders.strainPeaks;
2326
+ this.aim = this.starValue(aimSkill.difficultyValue());
2327
+ if (this.aim) {
2328
+ this.attributes.sliderFactor =
2329
+ this.starValue(aimSkillWithoutSliders.difficultyValue()) /
2330
+ this.aim;
2331
+ }
2332
+ }
2333
+ /**
2334
+ * Called after speed skill calculation.
2335
+ *
2336
+ * @param speedSkill The speed skill.
2337
+ */
2338
+ postCalculateSpeed(speedSkill) {
2339
+ this.strainPeaks.speed = speedSkill.strainPeaks;
2340
+ this.speed = this.starValue(speedSkill.difficultyValue());
2341
+ }
2342
+ /**
2343
+ * Called after flashlight skill calculation.
2344
+ *
2345
+ * @param flashlightSkill The flashlight skill.
2346
+ */
2347
+ postCalculateFlashlight(flashlightSkill) {
2348
+ this.strainPeaks.flashlight = flashlightSkill.strainPeaks;
2349
+ this.flashlight = this.starValue(flashlightSkill.difficultyValue());
2350
+ }
2351
+ }
2352
+
2353
+ /**
2354
+ * A star rating calculator that configures which mode to calculate difficulty for and what mods are applied.
2355
+ */
2356
+ class MapStars {
2357
+ /**
2358
+ * The osu!droid star rating of the beatmap.
2359
+ */
2360
+ droidStars = new DroidStarRating();
2361
+ /**
2362
+ * The osu!standard star rating of the beatmap.
2363
+ */
2364
+ pcStars = new OsuStarRating();
2365
+ /**
2366
+ * Calculates the star rating of a beatmap.
2367
+ */
2368
+ calculate(params) {
2369
+ const mod = params.mods ?? [];
2370
+ const stats = new osuBase.MapStats({
2371
+ speedMultiplier: params.stats?.speedMultiplier ?? 1,
2372
+ isForceAR: params.stats?.isForceAR ?? false,
2373
+ oldStatistics: params.stats?.oldStatistics ?? false,
2374
+ });
2375
+ this.droidStars.calculate({
2376
+ map: params.map,
2377
+ mods: mod,
2378
+ stats,
2379
+ });
2380
+ this.pcStars.calculate({
2381
+ map: params.map,
2382
+ mods: mod,
2383
+ stats,
2384
+ });
2385
+ return this;
2386
+ }
2387
+ /**
2388
+ * Returns a string representative of the class.
2389
+ */
2390
+ toString() {
2391
+ return `${this.droidStars.toString()}\n${this.pcStars.toString()}`;
2392
+ }
2393
+ }
2394
+
2395
+ /**
2396
+ * A performance points calculator that calculates performance points for osu!standard gamemode.
2397
+ */
2398
+ class OsuPerformanceCalculator extends PerformanceCalculator {
2399
+ stars = new OsuStarRating();
2400
+ finalMultiplier = 1.12;
2401
+ /**
2402
+ * The aim performance value.
2403
+ */
2404
+ aim = 0;
2405
+ /**
2406
+ * The speed performance value.
2407
+ */
2408
+ speed = 0;
2409
+ /**
2410
+ * The accuracy performance value.
2411
+ */
2412
+ accuracy = 0;
2413
+ /**
2414
+ * The flashlight performance value.
2415
+ */
2416
+ flashlight = 0;
2417
+ calculate(params) {
2418
+ return this.calculateInternal(params, osuBase.modes.osu);
2419
+ }
2420
+ calculateValues() {
2421
+ this.calculateAimValue();
2422
+ this.calculateSpeedValue();
2423
+ this.calculateAccuracyValue();
2424
+ this.calculateFlashlightValue();
2425
+ }
2426
+ calculateTotalValue() {
2427
+ return (Math.pow(Math.pow(this.aim, 1.1) +
2428
+ Math.pow(this.speed, 1.1) +
2429
+ Math.pow(this.accuracy, 1.1) +
2430
+ Math.pow(this.flashlight, 1.1), 1 / 1.1) * this.finalMultiplier);
2431
+ }
2432
+ /**
2433
+ * Calculates the aim performance value of the beatmap.
2434
+ */
2435
+ calculateAimValue() {
2436
+ // Global variables
2437
+ const objectCount = this.stars.objects.length;
2438
+ const calculatedAR = this.mapStatistics.ar;
2439
+ this.aim = this.baseValue(Math.pow(this.stars.aim, this.stars.mods.some((m) => m instanceof osuBase.ModTouchDevice)
2440
+ ? 0.8
2441
+ : 1));
2442
+ // Longer maps are worth more
2443
+ let lengthBonus = 0.95 + 0.4 * Math.min(1, objectCount / 2000);
2444
+ if (objectCount > 2000) {
2445
+ lengthBonus += Math.log10(objectCount / 2000) * 0.5;
2446
+ }
2447
+ this.aim *= lengthBonus;
2448
+ if (this.effectiveMissCount > 0) {
2449
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
2450
+ this.aim *=
2451
+ 0.97 *
2452
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), this.effectiveMissCount);
2453
+ }
2454
+ // Combo scaling
2455
+ this.aim *= this.comboPenalty;
2456
+ // AR scaling
2457
+ let arFactor = 0;
2458
+ if (calculatedAR > 10.33) {
2459
+ arFactor += 0.3 * (calculatedAR - 10.33);
2460
+ }
2461
+ else if (calculatedAR < 8) {
2462
+ arFactor += 0.1 * (8 - calculatedAR);
2463
+ }
2464
+ // Buff for longer maps with high AR.
2465
+ this.aim *= 1 + arFactor * lengthBonus;
2466
+ // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
2467
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
2468
+ this.aim *= 1 + 0.04 * (12 - calculatedAR);
2469
+ }
2470
+ // Scale the aim value with slider factor to nerf very likely dropped sliderends.
2471
+ this.aim *= this.sliderNerfFactor;
2472
+ // Scale the aim value with accuracy.
2473
+ this.aim *= this.computedAccuracy.value(objectCount);
2474
+ // It is also important to consider accuracy difficulty when doing that.
2475
+ const odScaling = Math.pow(this.mapStatistics.od, 2) / 2500;
2476
+ this.aim *= 0.98 + odScaling;
2477
+ }
2478
+ /**
2479
+ * Calculates the speed performance value of the beatmap.
2480
+ */
2481
+ calculateSpeedValue() {
2482
+ // Global variables
2483
+ const objectCount = this.stars.objects.length;
2484
+ const calculatedAR = this.mapStatistics.ar;
2485
+ const n50 = this.computedAccuracy.n50;
2486
+ this.speed = this.baseValue(this.stars.speed);
2487
+ // Longer maps are worth more
2488
+ let lengthBonus = 0.95 + 0.4 * Math.min(1, objectCount / 2000);
2489
+ if (objectCount > 2000) {
2490
+ lengthBonus += Math.log10(objectCount / 2000) * 0.5;
2491
+ }
2492
+ this.speed *= lengthBonus;
2493
+ if (this.effectiveMissCount > 0) {
2494
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
2495
+ this.speed *=
2496
+ 0.97 *
2497
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), Math.pow(this.effectiveMissCount, 0.875));
2498
+ }
2499
+ // Combo scaling
2500
+ this.speed *= this.comboPenalty;
2501
+ // AR scaling
2502
+ if (calculatedAR > 10.33) {
2503
+ // Buff for longer maps with high AR.
2504
+ this.speed *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
2505
+ }
2506
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
2507
+ this.speed *= 1 + 0.04 * (12 - calculatedAR);
2508
+ }
2509
+ // Scale the speed value with accuracy and OD.
2510
+ this.speed *=
2511
+ (0.95 + Math.pow(this.mapStatistics.od, 2) / 750) *
2512
+ Math.pow(this.computedAccuracy.value(objectCount), (14.5 - Math.max(this.mapStatistics.od, 8)) / 2);
2513
+ // Scale the speed value with # of 50s to punish doubletapping.
2514
+ this.speed *= Math.pow(0.98, Math.max(0, n50 - objectCount / 500));
2515
+ }
2516
+ /**
2517
+ * Calculates the accuracy performance value of the beatmap.
2518
+ */
2519
+ calculateAccuracyValue() {
2520
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModRelax)) {
2521
+ return;
2522
+ }
2523
+ // Global variables
2524
+ const nobjects = this.stars.objects.length;
2525
+ const ncircles = this.stars.mods.some((m) => m instanceof osuBase.ModScoreV2)
2526
+ ? nobjects - this.stars.map.hitObjects.spinners
2527
+ : this.stars.map.hitObjects.circles;
2528
+ if (ncircles === 0) {
2529
+ return;
2530
+ }
2531
+ const realAccuracy = new osuBase.Accuracy({
2532
+ ...this.computedAccuracy,
2533
+ n300: this.computedAccuracy.n300 -
2534
+ (this.stars.objects.length - ncircles),
2535
+ });
2536
+ // Lots of arbitrary values from testing.
2537
+ // Considering to use derivation from perfect accuracy in a probabilistic manner - assume normal distribution
2538
+ this.accuracy =
2539
+ Math.pow(1.52163, this.mapStatistics.od) *
2540
+ Math.pow(realAccuracy.value(ncircles), 24) *
2541
+ 2.83;
2542
+ // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
2543
+ this.accuracy *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
2544
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
2545
+ this.accuracy *= 1.08;
2546
+ }
2547
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2548
+ this.accuracy *= 1.02;
2549
+ }
2550
+ }
2551
+ /**
2552
+ * Calculates the flashlight performance value of the beatmap.
2553
+ */
2554
+ calculateFlashlightValue() {
2555
+ if (!this.stars.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
2556
+ return;
2557
+ }
2558
+ // Global variables
2559
+ const objectCount = this.stars.objects.length;
2560
+ this.flashlight =
2561
+ Math.pow(Math.pow(this.stars.flashlight, this.stars.mods.some((m) => m instanceof osuBase.ModTouchDevice)
2562
+ ? 0.8
2563
+ : 1), 2) * 25;
2564
+ // Add an additional bonus for HDFL.
2565
+ if (this.stars.mods.some((m) => m instanceof osuBase.ModHidden)) {
2566
+ this.flashlight *= 1.3;
2567
+ }
2568
+ // Combo scaling
2569
+ this.flashlight *= this.comboPenalty;
2570
+ if (this.effectiveMissCount > 0) {
2571
+ // Penalize misses by assessing # of misses relative to the total # of objects. Default a 3% reduction for any # of misses.
2572
+ this.flashlight *=
2573
+ 0.97 *
2574
+ Math.pow(1 - Math.pow(this.effectiveMissCount / objectCount, 0.775), Math.pow(this.effectiveMissCount, 0.875));
2575
+ }
2576
+ // Account for shorter maps having a higher ratio of 0 combo/100 combo flashlight radius.
2577
+ this.flashlight *=
2578
+ 0.7 +
2579
+ 0.1 * Math.min(1, objectCount / 200) +
2580
+ (objectCount > 200
2581
+ ? 0.2 * Math.min(1, (objectCount - 200) / 200)
2582
+ : 0);
2583
+ // Scale the flashlight value with accuracy slightly.
2584
+ this.flashlight *= 0.5 + this.computedAccuracy.value(objectCount) / 2;
2585
+ // It is also important to consider accuracy difficulty when doing that.
2586
+ const odScaling = Math.pow(this.mapStatistics.od, 2) / 2500;
2587
+ this.flashlight *= 0.98 + odScaling;
2588
+ }
2589
+ toString() {
2590
+ return (this.total.toFixed(2) +
2591
+ " pp (" +
2592
+ this.aim.toFixed(2) +
2593
+ " aim, " +
2594
+ this.speed.toFixed(2) +
2595
+ " speed, " +
2596
+ this.accuracy.toFixed(2) +
2597
+ " acc, " +
2598
+ this.flashlight.toFixed(2) +
2599
+ " flashlight)");
2600
+ }
2601
+ }
2602
+
2603
+ exports.DifficultyHitObject = DifficultyHitObject;
2604
+ exports.DifficultyHitObjectCreator = DifficultyHitObjectCreator;
2605
+ exports.DroidAim = DroidAim;
2606
+ exports.DroidFlashlight = DroidFlashlight;
2607
+ exports.DroidPerformanceCalculator = DroidPerformanceCalculator;
2608
+ exports.DroidRhythm = DroidRhythm;
2609
+ exports.DroidStarRating = DroidStarRating;
2610
+ exports.DroidTap = DroidTap;
2611
+ exports.MapStars = MapStars;
2612
+ exports.OsuAim = OsuAim;
2613
+ exports.OsuFlashlight = OsuFlashlight;
2614
+ exports.OsuPerformanceCalculator = OsuPerformanceCalculator;
2615
+ exports.OsuSpeed = OsuSpeed;
2616
+ exports.OsuStarRating = OsuStarRating;
2617
+ exports.PerformanceCalculator = PerformanceCalculator;
2618
+ exports.StarRating = StarRating;
2619
+ //# sourceMappingURL=index.js.map