@rian8337/osu-difficulty-calculator 4.0.0-beta.50 → 4.0.0-beta.52

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
@@ -2,33 +2,6 @@
2
2
 
3
3
  var osuBase = require('@rian8337/osu-base');
4
4
 
5
- /**
6
- * An evaluator for calculating aim skill.
7
- *
8
- * This class should be considered an "evaluating" class and not persisted.
9
- */
10
- class AimEvaluator {
11
- /**
12
- * Calculates the bonus of wide angles.
13
- */
14
- static calculateWideAngleBonus(angle) {
15
- return Math.pow(Math.sin((3 / 4) *
16
- (Math.min((5 / 6) * Math.PI, Math.max(Math.PI / 6, angle)) -
17
- Math.PI / 6)), 2);
18
- }
19
- /**
20
- * Calculates the bonus of acute angles.
21
- */
22
- static calculateAcuteAngleBonus(angle) {
23
- return 1 - this.calculateWideAngleBonus(angle);
24
- }
25
- }
26
- AimEvaluator.wideAngleMultiplier = 1.5;
27
- AimEvaluator.acuteAngleMultiplier = 1.95;
28
- AimEvaluator.sliderMultiplier = 1.35;
29
- AimEvaluator.velocityChangeMultiplier = 0.75;
30
- AimEvaluator.wiggleMultiplier = 1.02;
31
-
32
5
  /**
33
6
  * The base of a difficulty calculator.
34
7
  */
@@ -314,10 +287,10 @@ class DifficultyHitObject {
314
287
  * Calculates the opacity of the hitobject at a given time.
315
288
  *
316
289
  * @param time The time to calculate the hitobject's opacity at.
317
- * @param isHidden Whether Hidden mod is used.
290
+ * @param mods The mods used.
318
291
  * @returns The opacity of the hitobject at the given time.
319
292
  */
320
- opacityAt(time, isHidden) {
293
+ opacityAt(time, mods) {
321
294
  if (time > this.object.startTime) {
322
295
  // Consider a hitobject as being invisible when its start time is passed.
323
296
  // In reality the hitobject will be visible beyond its start time up until its hittable window has passed,
@@ -326,7 +299,7 @@ class DifficultyHitObject {
326
299
  }
327
300
  const fadeInStartTime = this.object.startTime - this.object.timePreempt;
328
301
  const fadeInDuration = this.object.timeFadeIn;
329
- if (isHidden) {
302
+ if (mods.some((m) => m instanceof osuBase.ModHidden)) {
330
303
  const fadeOutStartTime = fadeInStartTime + fadeInDuration;
331
304
  const fadeOutDuration = this.object.timePreempt * osuBase.ModHidden.fadeOutDurationMultiplier;
332
305
  return Math.min(osuBase.MathUtils.clamp((time - fadeInStartTime) / fadeInDuration, 0, 1), 1 -
@@ -424,6 +397,36 @@ class DifficultyHitObject {
424
397
  if (slider.lazyEndPosition) {
425
398
  return;
426
399
  }
400
+ let trackingEndTime = slider.endTime;
401
+ let { nestedHitObjects: nestedObjects } = slider;
402
+ if (this.mode === osuBase.Modes.osu) {
403
+ trackingEndTime = Math.max(slider.endTime - osuBase.Slider.legacyLastTickOffset, slider.startTime + slider.duration / 2);
404
+ let lastRealTick = null;
405
+ for (let i = nestedObjects.length - 2; i > 0; --i) {
406
+ const current = nestedObjects[i];
407
+ if (current instanceof osuBase.SliderTick) {
408
+ lastRealTick = current;
409
+ break;
410
+ }
411
+ if (current instanceof osuBase.SliderRepeat) {
412
+ // A repeat means the slider does not have a slider tick.
413
+ break;
414
+ }
415
+ }
416
+ if (lastRealTick && lastRealTick.startTime > trackingEndTime) {
417
+ trackingEndTime = lastRealTick.startTime;
418
+ // When the last tick falls after the tracking end time, we need to re-sort the nested objects
419
+ // based on time. This creates a somewhat weird ordering which is counter to how a user would
420
+ // understand the slider, but allows a zero-diff with known diffcalc output.
421
+ //
422
+ // To reiterate, this is definitely not correct from a difficulty calculation perspective
423
+ // and should be revisited at a later date.
424
+ const reordered = nestedObjects.slice();
425
+ reordered.splice(reordered.indexOf(lastRealTick), 1);
426
+ reordered.push(lastRealTick);
427
+ nestedObjects = reordered;
428
+ }
429
+ }
427
430
  if (this.mode === osuBase.Modes.droid) {
428
431
  // Temporary lazy end position until a real result can be derived.
429
432
  slider.lazyEndPosition = slider.getStackedPosition(this.mode);
@@ -433,9 +436,7 @@ class DifficultyHitObject {
433
436
  return;
434
437
  }
435
438
  }
436
- // Not using slider.endTime due to legacy last tick offset.
437
- slider.lazyTravelTime =
438
- slider.nestedHitObjects.at(-1).startTime - slider.startTime;
439
+ slider.lazyTravelTime = trackingEndTime - slider.startTime;
439
440
  let endTimeMin = slider.lazyTravelTime / slider.spanDuration;
440
441
  if (endTimeMin % 2 >= 1) {
441
442
  endTimeMin = 1 - (endTimeMin % 1);
@@ -449,15 +450,15 @@ class DifficultyHitObject {
449
450
  .add(slider.path.positionAt(endTimeMin));
450
451
  let currentCursorPosition = slider.getStackedPosition(this.mode);
451
452
  const scalingFactor = DifficultyHitObject.normalizedRadius / slider.radius;
452
- for (let i = 1; i < slider.nestedHitObjects.length; ++i) {
453
- const currentMovementObject = slider.nestedHitObjects[i];
453
+ for (let i = 1; i < nestedObjects.length; ++i) {
454
+ const currentMovementObject = nestedObjects[i];
454
455
  let currentMovement = currentMovementObject
455
456
  .getStackedPosition(this.mode)
456
457
  .subtract(currentCursorPosition);
457
458
  let currentMovementLength = scalingFactor * currentMovement.length;
458
459
  // The amount of movement required so that the cursor position needs to be updated.
459
460
  let requiredMovement = this.assumedSliderRadius;
460
- if (i === slider.nestedHitObjects.length - 1) {
461
+ if (i === nestedObjects.length - 1) {
461
462
  // The end of a slider has special aim rules due to the relaxed time constraint on position.
462
463
  // There is both a lazy end position as well as the actual end slider position. We assume the player takes the simpler movement.
463
464
  // For sliders that are circular, the lazy end position may actually be farther away than the sliders' true end.
@@ -482,7 +483,7 @@ class DifficultyHitObject {
482
483
  currentMovementLength;
483
484
  slider.lazyTravelDistance += currentMovementLength;
484
485
  }
485
- if (i === slider.nestedHitObjects.length - 1) {
486
+ if (i === nestedObjects.length - 1) {
486
487
  slider.lazyEndPosition = currentCursorPosition;
487
488
  }
488
489
  }
@@ -581,6 +582,14 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
581
582
  super.computeProperties(clockRate, hitObjects);
582
583
  this.setVisuals(clockRate, hitObjects);
583
584
  }
585
+ opacityAt(time, mods) {
586
+ // Traceable hides the primary piece of a hit circle (that is, its body), so consider it as fully invisible.
587
+ if (this.object instanceof osuBase.Circle &&
588
+ mods.some((m) => m instanceof osuBase.ModTraceable)) {
589
+ return 0;
590
+ }
591
+ return super.opacityAt(time, mods);
592
+ }
584
593
  /**
585
594
  * Determines whether this hitobject is considered overlapping with the hitobject before it.
586
595
  *
@@ -705,7 +714,7 @@ class DroidDifficultyHitObject extends DifficultyHitObject {
705
714
  /**
706
715
  * An evaluator for calculating osu!droid Aim skill.
707
716
  */
708
- class DroidAimEvaluator extends AimEvaluator {
717
+ class DroidAimEvaluator {
709
718
  /**
710
719
  * Evaluates the difficulty of aiming the current object, based on:
711
720
  *
@@ -773,8 +782,7 @@ class DroidAimEvaluator extends AimEvaluator {
773
782
  Math.max(current.strainTime, last.strainTime) <
774
783
  1.25 * Math.min(current.strainTime, last.strainTime) &&
775
784
  current.angle !== null &&
776
- last.angle !== null &&
777
- lastLast.angle !== null) {
785
+ last.angle !== null) {
778
786
  const currentAngle = current.angle;
779
787
  const lastAngle = last.angle;
780
788
  // Rewarding angles, take the smaller velocity as base.
@@ -869,6 +877,7 @@ DroidAimEvaluator.wideAngleMultiplier = 1.5;
869
877
  DroidAimEvaluator.acuteAngleMultiplier = 2.6;
870
878
  DroidAimEvaluator.sliderMultiplier = 1.35;
871
879
  DroidAimEvaluator.velocityChangeMultiplier = 0.75;
880
+ DroidAimEvaluator.wiggleMultiplier = 1.02;
872
881
  DroidAimEvaluator.singleSpacingThreshold = 100;
873
882
  // 200 1/4 BPM delta time
874
883
  DroidAimEvaluator.minSpeedBonus = 75;
@@ -1034,7 +1043,7 @@ class DroidAim extends DroidSkill {
1034
1043
  if (this.sliderStrains.length === 0) {
1035
1044
  return 0;
1036
1045
  }
1037
- const maxSliderStrain = Math.max(...this.sliderStrains);
1046
+ const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
1038
1047
  if (maxSliderStrain === 0) {
1039
1048
  return 0;
1040
1049
  }
@@ -1072,20 +1081,10 @@ class DroidAim extends DroidSkill {
1072
1081
  }
1073
1082
  }
1074
1083
 
1075
- /**
1076
- * An evaluator for calculating speed or tap skill.
1077
- *
1078
- * This class should be considered an "evaluating" class and not persisted.
1079
- */
1080
- class SpeedEvaluator {
1081
- }
1082
- // ~200 1/4 BPM streams
1083
- SpeedEvaluator.minSpeedBonus = 75;
1084
-
1085
1084
  /**
1086
1085
  * An evaluator for calculating osu!droid tap skill.
1087
1086
  */
1088
- class DroidTapEvaluator extends SpeedEvaluator {
1087
+ class DroidTapEvaluator {
1089
1088
  /**
1090
1089
  * Evaluates the difficulty of tapping the current object, based on:
1091
1090
  *
@@ -1122,6 +1121,8 @@ class DroidTapEvaluator extends SpeedEvaluator {
1122
1121
  return (speedBonus * Math.pow(doubletapness, 1.5) * 1000) / strainTime;
1123
1122
  }
1124
1123
  }
1124
+ // ~200 1/4 BPM streams
1125
+ DroidTapEvaluator.minSpeedBonus = 75;
1125
1126
 
1126
1127
  /**
1127
1128
  * Represents the skill required to press keys or tap with regards to keeping up with the speed at which objects need to be hit.
@@ -1153,7 +1154,7 @@ class DroidTap extends DroidSkill {
1153
1154
  if (this._objectStrains.length === 0) {
1154
1155
  return 0;
1155
1156
  }
1156
- const maxStrain = Math.max(...this._objectStrains);
1157
+ const maxStrain = osuBase.MathUtils.max(this._objectStrains);
1157
1158
  if (maxStrain === 0) {
1158
1159
  return 0;
1159
1160
  }
@@ -1166,7 +1167,7 @@ class DroidTap extends DroidSkill {
1166
1167
  if (this._objectStrains.length === 0) {
1167
1168
  return 0;
1168
1169
  }
1169
- const maxStrain = Math.max(...this._objectStrains);
1170
+ const maxStrain = osuBase.MathUtils.max(this._objectStrains);
1170
1171
  if (maxStrain === 0) {
1171
1172
  return 0;
1172
1173
  }
@@ -1212,23 +1213,10 @@ class DroidTap extends DroidSkill {
1212
1213
  }
1213
1214
  }
1214
1215
 
1215
- /**
1216
- * An evaluator for calculating flashlight skill.
1217
- *
1218
- * This class should be considered an "evaluating" class and not persisted.
1219
- */
1220
- class FlashlightEvaluator {
1221
- }
1222
- FlashlightEvaluator.maxOpacityBonus = 0.4;
1223
- FlashlightEvaluator.hiddenBonus = 0.2;
1224
- FlashlightEvaluator.minVelocity = 0.5;
1225
- FlashlightEvaluator.sliderMultiplier = 1.3;
1226
- FlashlightEvaluator.minAngleMultiplier = 0.2;
1227
-
1228
1216
  /**
1229
1217
  * An evaluator for calculating osu!droid Flashlight skill.
1230
1218
  */
1231
- class DroidFlashlightEvaluator extends FlashlightEvaluator {
1219
+ class DroidFlashlightEvaluator {
1232
1220
  /**
1233
1221
  * Evaluates the difficulty of memorizing and hitting the current object, based on:
1234
1222
  *
@@ -1239,10 +1227,10 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1239
1227
  * - and whether Hidden mod is enabled.
1240
1228
  *
1241
1229
  * @param current The current object.
1242
- * @param isHiddenMod Whether the Hidden mod is enabled.
1230
+ * @param mods The mods used.
1243
1231
  * @param withSliders Whether to take slider difficulty into account.
1244
1232
  */
1245
- static evaluateDifficultyOf(current, isHiddenMod, withSliders) {
1233
+ static evaluateDifficultyOf(current, mods, withSliders) {
1246
1234
  if (current.object instanceof osuBase.Spinner ||
1247
1235
  // Exclude overlapping objects that can be tapped at once.
1248
1236
  current.isOverlapping(true)) {
@@ -1273,7 +1261,7 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1273
1261
  const opacityBonus = 1 +
1274
1262
  this.maxOpacityBonus *
1275
1263
  (1 -
1276
- current.opacityAt(currentObject.object.startTime, isHiddenMod));
1264
+ current.opacityAt(currentObject.object.startTime, mods));
1277
1265
  result +=
1278
1266
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
1279
1267
  cumulativeStrainTime;
@@ -1288,9 +1276,20 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1288
1276
  }
1289
1277
  result = Math.pow(smallDistNerf * result, 2);
1290
1278
  // Additional bonus for Hidden due to there being no approach circles.
1291
- if (isHiddenMod) {
1279
+ if (mods.some((m) => m instanceof osuBase.ModHidden)) {
1292
1280
  result *= 1 + this.hiddenBonus;
1293
1281
  }
1282
+ else if (mods.some((m) => m instanceof osuBase.ModTraceable)) {
1283
+ // Additional bonus for Traceable due to there being no primary or secondary object pieces.
1284
+ if (current.object instanceof osuBase.Circle) {
1285
+ // Additional bonus for hit circles due to there being no circle piece, which is the primary piece.
1286
+ result *= 1 + this.traceableCircleBonus;
1287
+ }
1288
+ else {
1289
+ // The rest of the objects only hide secondary pieces.
1290
+ result *= 1 + this.traceableObjectBonus;
1291
+ }
1292
+ }
1294
1293
  // Nerf patterns with repeated angles.
1295
1294
  result *=
1296
1295
  this.minAngleMultiplier +
@@ -1311,6 +1310,13 @@ class DroidFlashlightEvaluator extends FlashlightEvaluator {
1311
1310
  return result;
1312
1311
  }
1313
1312
  }
1313
+ DroidFlashlightEvaluator.maxOpacityBonus = 0.4;
1314
+ DroidFlashlightEvaluator.hiddenBonus = 0.2;
1315
+ DroidFlashlightEvaluator.traceableCircleBonus = 0.15;
1316
+ DroidFlashlightEvaluator.traceableObjectBonus = 0.1;
1317
+ DroidFlashlightEvaluator.minVelocity = 0.5;
1318
+ DroidFlashlightEvaluator.sliderMultiplier = 1.3;
1319
+ DroidFlashlightEvaluator.minAngleMultiplier = 0.2;
1314
1320
 
1315
1321
  /**
1316
1322
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
@@ -1324,13 +1330,12 @@ class DroidFlashlight extends DroidSkill {
1324
1330
  this.starsPerDouble = 1.06;
1325
1331
  this.skillMultiplier = 0.02;
1326
1332
  this.currentFlashlightStrain = 0;
1327
- this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1328
1333
  this.withSliders = withSliders;
1329
1334
  }
1330
1335
  strainValueAt(current) {
1331
1336
  this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
1332
1337
  this.currentFlashlightStrain +=
1333
- DroidFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1338
+ DroidFlashlightEvaluator.evaluateDifficultyOf(current, this.mods, this.withSliders) * this.skillMultiplier;
1334
1339
  return this.currentFlashlightStrain;
1335
1340
  }
1336
1341
  calculateInitialStrain(time, current) {
@@ -1597,22 +1602,31 @@ class DroidVisualEvaluator {
1597
1602
  * - and whether the Hidden mod is enabled.
1598
1603
  *
1599
1604
  * @param current The current object.
1600
- * @param isHiddenMod Whether the Hidden mod is enabled.
1605
+ * @param mods The mods used.
1601
1606
  * @param withSliders Whether to take slider difficulty into account.
1602
1607
  */
1603
- static evaluateDifficultyOf(current, isHiddenMod, withSliders) {
1608
+ static evaluateDifficultyOf(current, mods, withSliders) {
1604
1609
  if (current.object instanceof osuBase.Spinner ||
1605
1610
  // Exclude overlapping objects that can be tapped at once.
1606
1611
  current.isOverlapping(true) ||
1607
1612
  current.index === 0) {
1608
1613
  return 0;
1609
1614
  }
1610
- // Start with base density and give global bonus for Hidden.
1615
+ // Start with base density and give global bonus for Hidden and Traceable.
1611
1616
  // Add density caps for sanity.
1612
1617
  let strain;
1613
- if (isHiddenMod) {
1618
+ if (mods.some((m) => m instanceof osuBase.ModHidden)) {
1614
1619
  strain = Math.min(30, Math.pow(current.noteDensity, 3));
1615
1620
  }
1621
+ else if (mods.some((m) => m instanceof osuBase.ModTraceable)) {
1622
+ // Give more bonus for hit circles due to there being no circle piece.
1623
+ if (current.object instanceof osuBase.Circle) {
1624
+ strain = Math.min(25, Math.pow(current.noteDensity, 2.5));
1625
+ }
1626
+ else {
1627
+ strain = Math.min(22.5, Math.pow(current.noteDensity, 2.25));
1628
+ }
1629
+ }
1616
1630
  else {
1617
1631
  strain = Math.min(20, Math.pow(current.noteDensity, 2));
1618
1632
  }
@@ -1630,9 +1644,7 @@ class DroidVisualEvaluator {
1630
1644
  break;
1631
1645
  }
1632
1646
  strain +=
1633
- (1 -
1634
- current.opacityAt(previous.object.startTime, isHiddenMod)) /
1635
- 4;
1647
+ (1 - current.opacityAt(previous.object.startTime, mods)) / 4;
1636
1648
  }
1637
1649
  if (current.timePreempt < 400) {
1638
1650
  // Give bonus for AR higher than 10.33.
@@ -1692,13 +1704,12 @@ class DroidVisual extends DroidSkill {
1692
1704
  this.currentVisualStrain = 0;
1693
1705
  this.currentRhythmMultiplier = 1;
1694
1706
  this.skillMultiplier = 10;
1695
- this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
1696
1707
  this.withSliders = withSliders;
1697
1708
  }
1698
1709
  strainValueAt(current) {
1699
1710
  this.currentVisualStrain *= this.strainDecay(current.deltaTime);
1700
1711
  this.currentVisualStrain +=
1701
- DroidVisualEvaluator.evaluateDifficultyOf(current, this.isHidden, this.withSliders) * this.skillMultiplier;
1712
+ DroidVisualEvaluator.evaluateDifficultyOf(current, this.mods, this.withSliders) * this.skillMultiplier;
1702
1713
  this.currentRhythmMultiplier = current.rhythmMultiplier;
1703
1714
  return this.currentVisualStrain * this.currentRhythmMultiplier;
1704
1715
  }
@@ -1802,6 +1813,10 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1802
1813
  * Calculates the aim star rating of the beatmap and stores it in this instance.
1803
1814
  */
1804
1815
  calculateAim() {
1816
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
1817
+ this.attributes.aimDifficulty = 0;
1818
+ return;
1819
+ }
1805
1820
  const aimSkill = new DroidAim(this.mods, true);
1806
1821
  const aimSkillWithoutSliders = new DroidAim(this.mods, false);
1807
1822
  this.calculateSkills(aimSkill, aimSkillWithoutSliders);
@@ -1811,6 +1826,10 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1811
1826
  * Calculates the tap star rating of the beatmap and stores it in this instance.
1812
1827
  */
1813
1828
  calculateTap() {
1829
+ if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1830
+ this.attributes.tapDifficulty = 0;
1831
+ return;
1832
+ }
1814
1833
  const tapSkillCheese = new DroidTap(this.mods, true);
1815
1834
  const tapSkillNoCheese = new DroidTap(this.mods, false);
1816
1835
  this.calculateSkills(tapSkillCheese, tapSkillNoCheese);
@@ -1830,6 +1849,10 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1830
1849
  * Calculates the flashlight star rating of the beatmap and stores it in this instance.
1831
1850
  */
1832
1851
  calculateFlashlight() {
1852
+ if (!this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
1853
+ this.attributes.flashlightDifficulty = 0;
1854
+ return;
1855
+ }
1833
1856
  const flashlightSkill = new DroidFlashlight(this.mods, true);
1834
1857
  const flashlightSkillWithoutSliders = new DroidFlashlight(this.mods, false);
1835
1858
  this.calculateSkills(flashlightSkill, flashlightSkillWithoutSliders);
@@ -1874,21 +1897,29 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1874
1897
  calculateAll() {
1875
1898
  const skills = this.createSkills();
1876
1899
  this.calculateSkills(...skills);
1877
- const aimSkill = skills[0];
1878
- const aimSkillWithoutSliders = skills[1];
1879
- const rhythmSkill = skills[2];
1880
- const tapSkillCheese = skills[3];
1881
- const flashlightSkill = skills[5];
1882
- const flashlightSkillWithoutSliders = skills[6];
1883
- const visualSkill = skills[7];
1884
- const visualSkillWithoutSliders = skills[8];
1885
- const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1886
- this.calculateSkills(tapSkillVibro);
1887
- this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1888
- this.postCalculateTap(tapSkillCheese, tapSkillVibro);
1900
+ const aimSkill = skills.find((s) => s instanceof DroidAim && s.withSliders);
1901
+ const aimSkillWithoutSliders = skills.find((s) => s instanceof DroidAim && !s.withSliders);
1902
+ const rhythmSkill = skills.find((s) => s instanceof DroidRhythm);
1903
+ const tapSkillCheese = skills.find((s) => s instanceof DroidTap && s.considerCheesability);
1904
+ const flashlightSkill = skills.find((s) => s instanceof DroidFlashlight && s.withSliders);
1905
+ const flashlightSkillWithoutSliders = skills.find((s) => s instanceof DroidFlashlight && !s.withSliders);
1906
+ const visualSkill = skills.find((s) => s instanceof DroidVisual && s.withSliders);
1907
+ const visualSkillWithoutSliders = skills.find((s) => s instanceof DroidVisual && !s.withSliders);
1908
+ if (aimSkill && aimSkillWithoutSliders) {
1909
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
1910
+ }
1911
+ if (tapSkillCheese) {
1912
+ const tapSkillVibro = new DroidTap(this.mods, true, tapSkillCheese.relevantDeltaTime());
1913
+ this.calculateSkills(tapSkillVibro);
1914
+ this.postCalculateTap(tapSkillCheese, tapSkillVibro);
1915
+ }
1889
1916
  this.postCalculateRhythm(rhythmSkill);
1890
- this.postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders);
1891
- this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1917
+ if (flashlightSkill && flashlightSkillWithoutSliders) {
1918
+ this.postCalculateFlashlight(flashlightSkill, flashlightSkillWithoutSliders);
1919
+ }
1920
+ if (visualSkill && visualSkillWithoutSliders) {
1921
+ this.postCalculateVisual(visualSkill, visualSkillWithoutSliders);
1922
+ }
1892
1923
  this.calculateTotal();
1893
1924
  }
1894
1925
  toString() {
@@ -1917,20 +1948,24 @@ class DroidDifficultyCalculator extends DifficultyCalculator {
1917
1948
  return difficultyObjects;
1918
1949
  }
1919
1950
  createSkills() {
1920
- return [
1921
- new DroidAim(this.mods, true),
1922
- new DroidAim(this.mods, false),
1923
- // Tap skill depends on rhythm skill, so we put it first
1924
- new DroidRhythm(this.mods),
1925
- // Cheesability tap
1926
- new DroidTap(this.mods, true),
1927
- // Non-cheesability tap
1928
- new DroidTap(this.mods, false),
1929
- new DroidFlashlight(this.mods, true),
1930
- new DroidFlashlight(this.mods, false),
1931
- new DroidVisual(this.mods, true),
1932
- new DroidVisual(this.mods, false),
1933
- ];
1951
+ const skills = [];
1952
+ if (!this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
1953
+ skills.push(new DroidAim(this.mods, true));
1954
+ skills.push(new DroidAim(this.mods, false));
1955
+ }
1956
+ if (!this.mods.some((m) => m instanceof osuBase.ModRelax)) {
1957
+ // Tap and visual skills depend on rhythm skill, so we put it first
1958
+ skills.push(new DroidRhythm(this.mods));
1959
+ skills.push(new DroidTap(this.mods, true));
1960
+ skills.push(new DroidTap(this.mods, false));
1961
+ skills.push(new DroidVisual(this.mods, true));
1962
+ skills.push(new DroidVisual(this.mods, false));
1963
+ }
1964
+ if (this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
1965
+ skills.push(new DroidFlashlight(this.mods, true));
1966
+ skills.push(new DroidFlashlight(this.mods, false));
1967
+ }
1968
+ return skills;
1934
1969
  }
1935
1970
  calculateClockRate(options) {
1936
1971
  var _a, _b;
@@ -2882,10 +2917,40 @@ class OsuSkill extends StrainSkill {
2882
2917
  }
2883
2918
  }
2884
2919
 
2920
+ /**
2921
+ * Represents an osu!standard hit object with difficulty calculation values.
2922
+ */
2923
+ class OsuDifficultyHitObject extends DifficultyHitObject {
2924
+ constructor() {
2925
+ super(...arguments);
2926
+ /**
2927
+ * The speed strain generated by the hitobject.
2928
+ */
2929
+ this.speedStrain = 0;
2930
+ /**
2931
+ * The flashlight strain generated by this hitobject.
2932
+ */
2933
+ this.flashlightStrain = 0;
2934
+ this.radiusBuffThreshold = 30;
2935
+ this.mode = osuBase.Modes.osu;
2936
+ }
2937
+ get scalingFactor() {
2938
+ const radius = this.object.radius;
2939
+ // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
2940
+ let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
2941
+ // High circle size (small CS) bonus
2942
+ if (radius < this.radiusBuffThreshold) {
2943
+ scalingFactor *=
2944
+ 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
2945
+ }
2946
+ return scalingFactor;
2947
+ }
2948
+ }
2949
+
2885
2950
  /**
2886
2951
  * An evaluator for calculating osu!standard Aim skill.
2887
2952
  */
2888
- class OsuAimEvaluator extends AimEvaluator {
2953
+ class OsuAimEvaluator {
2889
2954
  /**
2890
2955
  * Evaluates the difficulty of aiming the current object, based on:
2891
2956
  *
@@ -2905,6 +2970,8 @@ class OsuAimEvaluator extends AimEvaluator {
2905
2970
  return 0;
2906
2971
  }
2907
2972
  const lastLast = current.previous(1);
2973
+ const radius = OsuDifficultyHitObject.normalizedRadius;
2974
+ const diameter = OsuDifficultyHitObject.normalizedDiameter;
2908
2975
  // Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
2909
2976
  let currentVelocity = current.lazyJumpDistance / current.strainTime;
2910
2977
  // But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
@@ -2927,6 +2994,7 @@ class OsuAimEvaluator extends AimEvaluator {
2927
2994
  let acuteAngleBonus = 0;
2928
2995
  let sliderBonus = 0;
2929
2996
  let velocityChangeBonus = 0;
2997
+ let wiggleBonus = 0;
2930
2998
  // Start strain with regular velocity.
2931
2999
  let strain = currentVelocity;
2932
3000
  if (
@@ -2934,42 +3002,41 @@ class OsuAimEvaluator extends AimEvaluator {
2934
3002
  Math.max(current.strainTime, last.strainTime) <
2935
3003
  1.25 * Math.min(current.strainTime, last.strainTime) &&
2936
3004
  current.angle !== null &&
2937
- last.angle !== null &&
2938
- lastLast.angle !== null) {
3005
+ last.angle !== null) {
3006
+ const currentAngle = current.angle;
3007
+ const lastAngle = last.angle;
2939
3008
  // Rewarding angles, take the smaller velocity as base.
2940
3009
  const angleBonus = Math.min(currentVelocity, prevVelocity);
2941
3010
  wideAngleBonus = this.calculateWideAngleBonus(current.angle);
2942
3011
  acuteAngleBonus = this.calculateAcuteAngleBonus(current.angle);
2943
- // Only buff deltaTime exceeding 300 BPM 1/2.
2944
- if (current.strainTime > 100) {
2945
- acuteAngleBonus = 0;
2946
- }
2947
- else {
2948
- acuteAngleBonus *=
2949
- // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
2950
- this.calculateAcuteAngleBonus(last.angle) *
2951
- // The maximum velocity we buff is equal to 125 / strainTime.
2952
- Math.min(angleBonus, 125 / current.strainTime) *
2953
- // Scale buff from 300 BPM 1/2 to 400 BPM 1/2.
2954
- Math.pow(Math.sin((Math.PI / 2) *
2955
- Math.min(1, (100 - current.strainTime) / 25)), 2) *
2956
- // Buff distance exceeding 50 (radius) up to 100 (diameter).
2957
- Math.pow(Math.sin(((Math.PI / 2) *
2958
- (osuBase.MathUtils.clamp(current.lazyJumpDistance, 50, 100) -
2959
- 50)) /
2960
- 50), 2);
2961
- }
2962
- // Penalize wide angles if they're repeated, reducing the penalty as last.angle gets more acute.
3012
+ // Penalize angle repetition.
2963
3013
  wideAngleBonus *=
2964
- angleBonus *
2965
- (1 -
2966
- Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(last.angle), 3)));
2967
- // Penalize acute angles if they're repeated, reducing the penalty as lastLast.angle gets more obtuse.
3014
+ 1 -
3015
+ Math.min(wideAngleBonus, Math.pow(this.calculateWideAngleBonus(lastAngle), 3));
2968
3016
  acuteAngleBonus *=
2969
- 0.5 +
2970
- 0.5 *
3017
+ 0.08 +
3018
+ 0.92 *
2971
3019
  (1 -
2972
- Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastLast.angle), 3)));
3020
+ Math.min(acuteAngleBonus, Math.pow(this.calculateAcuteAngleBonus(lastAngle), 3)));
3021
+ // Apply full wide angle bonus for distance more than one diameter
3022
+ wideAngleBonus *=
3023
+ angleBonus *
3024
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, 0, diameter);
3025
+ // Apply acute angle bonus for BPM above 300 1/2 and distance more than one diameter
3026
+ acuteAngleBonus *=
3027
+ angleBonus *
3028
+ osuBase.MathUtils.smootherstep(osuBase.MathUtils.millisecondsToBPM(current.strainTime, 2), 300, 400) *
3029
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, diameter, diameter * 2);
3030
+ // Apply wiggle bonus for jumps that are [radius, 3*diameter] in distance, with < 110 angle
3031
+ // https://www.desmos.com/calculator/dp0v0nvowc
3032
+ wiggleBonus =
3033
+ angleBonus *
3034
+ osuBase.MathUtils.smootherstep(current.lazyJumpDistance, radius, diameter) *
3035
+ Math.pow(osuBase.MathUtils.reverseLerp(current.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3036
+ osuBase.MathUtils.smootherstep(currentAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60)) *
3037
+ osuBase.MathUtils.smootherstep(last.lazyJumpDistance, radius, diameter) *
3038
+ Math.pow(osuBase.MathUtils.reverseLerp(last.lazyJumpDistance, diameter * 3, diameter), 1.8) *
3039
+ osuBase.MathUtils.smootherstep(lastAngle, osuBase.MathUtils.degreesToRadians(110), osuBase.MathUtils.degreesToRadians(60));
2973
3040
  }
2974
3041
  if (Math.max(prevVelocity, currentVelocity)) {
2975
3042
  // We want to use the average velocity over the whole object when awarding differences, not the individual jump and slider path velocities.
@@ -2993,6 +3060,7 @@ class OsuAimEvaluator extends AimEvaluator {
2993
3060
  // Reward sliders based on velocity.
2994
3061
  sliderBonus = last.travelDistance / last.travelTime;
2995
3062
  }
3063
+ strain += wiggleBonus * this.wiggleMultiplier;
2996
3064
  // Add in acute angle bonus or wide angle bonus + velocity change bonus, whichever is larger.
2997
3065
  strain += Math.max(acuteAngleBonus * this.acuteAngleMultiplier, wideAngleBonus * this.wideAngleMultiplier +
2998
3066
  velocityChangeBonus * this.velocityChangeMultiplier);
@@ -3002,7 +3070,18 @@ class OsuAimEvaluator extends AimEvaluator {
3002
3070
  }
3003
3071
  return strain;
3004
3072
  }
3073
+ static calculateWideAngleBonus(angle) {
3074
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(40), osuBase.MathUtils.degreesToRadians(140));
3075
+ }
3076
+ static calculateAcuteAngleBonus(angle) {
3077
+ return osuBase.MathUtils.smoothstep(angle, osuBase.MathUtils.degreesToRadians(140), osuBase.MathUtils.degreesToRadians(40));
3078
+ }
3005
3079
  }
3080
+ OsuAimEvaluator.wideAngleMultiplier = 1.5;
3081
+ OsuAimEvaluator.acuteAngleMultiplier = 2.6;
3082
+ OsuAimEvaluator.sliderMultiplier = 1.35;
3083
+ OsuAimEvaluator.velocityChangeMultiplier = 0.75;
3084
+ OsuAimEvaluator.wiggleMultiplier = 1.02;
3006
3085
 
3007
3086
  /**
3008
3087
  * Represents the skill required to correctly aim at every object in the map with a uniform CircleSize and normalized distances.
@@ -3015,15 +3094,33 @@ class OsuAim extends OsuSkill {
3015
3094
  this.reducedSectionBaseline = 0.75;
3016
3095
  this.decayWeight = 0.9;
3017
3096
  this.currentAimStrain = 0;
3018
- this.skillMultiplier = 25.18;
3097
+ this.skillMultiplier = 25.6;
3098
+ this.sliderStrains = [];
3019
3099
  this.withSliders = withSliders;
3020
3100
  }
3101
+ /**
3102
+ * Obtains the amount of sliders that are considered difficult in terms of relative strain.
3103
+ */
3104
+ countDifficultSliders() {
3105
+ if (this.sliderStrains.length === 0) {
3106
+ return 0;
3107
+ }
3108
+ const maxSliderStrain = osuBase.MathUtils.max(this.sliderStrains);
3109
+ if (maxSliderStrain === 0) {
3110
+ return 0;
3111
+ }
3112
+ return this.sliderStrains.reduce((total, strain) => total +
3113
+ 1 / (1 + Math.exp(-((strain / maxSliderStrain) * 12 - 6))), 0);
3114
+ }
3021
3115
  strainValueAt(current) {
3022
3116
  this.currentAimStrain *= this.strainDecay(current.deltaTime);
3023
3117
  this.currentAimStrain +=
3024
3118
  OsuAimEvaluator.evaluateDifficultyOf(current, this.withSliders) *
3025
3119
  this.skillMultiplier;
3026
3120
  this._objectStrains.push(this.currentAimStrain);
3121
+ if (current.object instanceof osuBase.Slider) {
3122
+ this.sliderStrains.push(this.currentAimStrain);
3123
+ }
3027
3124
  return this.currentAimStrain;
3028
3125
  }
3029
3126
  calculateInitialStrain(time, current) {
@@ -3047,7 +3144,7 @@ class OsuAim extends OsuSkill {
3047
3144
  /**
3048
3145
  * An evaluator for calculating osu!standard speed skill.
3049
3146
  */
3050
- class OsuSpeedEvaluator extends SpeedEvaluator {
3147
+ class OsuSpeedEvaluator {
3051
3148
  /**
3052
3149
  * Evaluates the difficulty of tapping the current object, based on:
3053
3150
  *
@@ -3056,8 +3153,9 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
3056
3153
  * - and how easily they can be cheesed.
3057
3154
  *
3058
3155
  * @param current The current object.
3156
+ * @param mods The mods applied.
3059
3157
  */
3060
- static evaluateDifficultyOf(current) {
3158
+ static evaluateDifficultyOf(current, mods) {
3061
3159
  var _a;
3062
3160
  if (current.object instanceof osuBase.Spinner) {
3063
3161
  return 0;
@@ -3080,8 +3178,11 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
3080
3178
  // Cap distance at spacing threshold
3081
3179
  const distance = Math.min(this.SINGLE_SPACING_THRESHOLD, travelDistance + current.minimumJumpDistance);
3082
3180
  // Max distance bonus is 1 * `distance_multiplier` at single_spacing_threshold
3083
- const distanceBonus = Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.95) *
3181
+ let distanceBonus = Math.pow(distance / this.SINGLE_SPACING_THRESHOLD, 3.95) *
3084
3182
  this.DISTANCE_MULTIPLIER;
3183
+ if (mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3184
+ distanceBonus = 0;
3185
+ }
3085
3186
  // Base difficulty with all bonuses
3086
3187
  const difficulty = ((1 + speedBonus + distanceBonus) * 1000) / strainTime;
3087
3188
  // Apply penalty if there's doubletappable doubles
@@ -3094,7 +3195,9 @@ class OsuSpeedEvaluator extends SpeedEvaluator {
3094
3195
  * About 1.25 circles distance between hitobject centers.
3095
3196
  */
3096
3197
  OsuSpeedEvaluator.SINGLE_SPACING_THRESHOLD = 125;
3097
- OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.94;
3198
+ // ~200 1/4 BPM streams
3199
+ OsuSpeedEvaluator.minSpeedBonus = 75;
3200
+ OsuSpeedEvaluator.DISTANCE_MULTIPLIER = 0.9;
3098
3201
 
3099
3202
  /**
3100
3203
  * An evaluator for calculating osu!standard Rhythm skill.
@@ -3196,7 +3299,7 @@ class OsuRhythmEvaluator {
3196
3299
  }
3197
3300
  // Repeated island (ex: triplet -> triplet).
3198
3301
  // Graph: https://www.desmos.com/calculator/pj7an56zwf
3199
- effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, 2.75 / (1 + Math.exp(14 - 0.24 * island.delta))));
3302
+ effectiveRatio *= Math.min(3 / islandCount, Math.pow(1 / islandCount, osuBase.MathUtils.offsetLogistic(island.delta, 58.33, 0.24, 2.75)));
3200
3303
  break;
3201
3304
  }
3202
3305
  if (!islandFound) {
@@ -3256,7 +3359,7 @@ class OsuSpeed extends OsuSkill {
3256
3359
  this.decayWeight = 0.9;
3257
3360
  this.currentSpeedStrain = 0;
3258
3361
  this.currentRhythm = 0;
3259
- this.skillMultiplier = 1.43;
3362
+ this.skillMultiplier = 1.46;
3260
3363
  }
3261
3364
  /**
3262
3365
  * @param current The hitobject to calculate.
@@ -3264,7 +3367,7 @@ class OsuSpeed extends OsuSkill {
3264
3367
  strainValueAt(current) {
3265
3368
  this.currentSpeedStrain *= this.strainDecay(current.strainTime);
3266
3369
  this.currentSpeedStrain +=
3267
- OsuSpeedEvaluator.evaluateDifficultyOf(current) *
3370
+ OsuSpeedEvaluator.evaluateDifficultyOf(current, this.mods) *
3268
3371
  this.skillMultiplier;
3269
3372
  this.currentRhythm = OsuRhythmEvaluator.evaluateDifficultyOf(current);
3270
3373
  const strain = this.currentSpeedStrain * this.currentRhythm;
@@ -3289,7 +3392,7 @@ class OsuSpeed extends OsuSkill {
3289
3392
  /**
3290
3393
  * An evaluator for calculating osu!standard Flashlight skill.
3291
3394
  */
3292
- class OsuFlashlightEvaluator extends FlashlightEvaluator {
3395
+ class OsuFlashlightEvaluator {
3293
3396
  /**
3294
3397
  * Evaluates the difficulty of memorizing and hitting the current object, based on:
3295
3398
  *
@@ -3300,9 +3403,9 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3300
3403
  * - and whether Hidden mod is enabled.
3301
3404
  *
3302
3405
  * @param current The current object.
3303
- * @param isHiddenMod Whether the Hidden mod is enabled.
3406
+ * @param mods The mods used.
3304
3407
  */
3305
- static evaluateDifficultyOf(current, isHiddenMod) {
3408
+ static evaluateDifficultyOf(current, mods) {
3306
3409
  if (current.object instanceof osuBase.Spinner) {
3307
3410
  return 0;
3308
3411
  }
@@ -3314,11 +3417,11 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3314
3417
  let angleRepeatCount = 0;
3315
3418
  for (let i = 0; i < Math.min(current.index, 10); ++i) {
3316
3419
  const currentObject = current.previous(i);
3420
+ cumulativeStrainTime += last.strainTime;
3317
3421
  if (!(currentObject.object instanceof osuBase.Spinner)) {
3318
3422
  const jumpDistance = current.object
3319
3423
  .getStackedPosition(osuBase.Modes.osu)
3320
3424
  .subtract(currentObject.object.getStackedEndPosition(osuBase.Modes.osu)).length;
3321
- cumulativeStrainTime += last.strainTime;
3322
3425
  // We want to nerf objects that can be easily seen within the Flashlight circle radius.
3323
3426
  if (i === 0) {
3324
3427
  smallDistNerf = Math.min(1, jumpDistance / 75);
@@ -3329,7 +3432,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3329
3432
  const opacityBonus = 1 +
3330
3433
  this.maxOpacityBonus *
3331
3434
  (1 -
3332
- current.opacityAt(currentObject.object.startTime, isHiddenMod));
3435
+ current.opacityAt(currentObject.object.startTime, mods));
3333
3436
  result +=
3334
3437
  (stackNerf * opacityBonus * scalingFactor * jumpDistance) /
3335
3438
  cumulativeStrainTime;
@@ -3344,7 +3447,7 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3344
3447
  }
3345
3448
  result = Math.pow(smallDistNerf * result, 2);
3346
3449
  // Additional bonus for Hidden due to there being no approach circles.
3347
- if (isHiddenMod) {
3450
+ if (mods.some((m) => m instanceof osuBase.ModHidden)) {
3348
3451
  result *= 1 + this.hiddenBonus;
3349
3452
  }
3350
3453
  // Nerf patterns with repeated angles.
@@ -3367,20 +3470,24 @@ class OsuFlashlightEvaluator extends FlashlightEvaluator {
3367
3470
  return result;
3368
3471
  }
3369
3472
  }
3473
+ OsuFlashlightEvaluator.maxOpacityBonus = 0.4;
3474
+ OsuFlashlightEvaluator.hiddenBonus = 0.2;
3475
+ OsuFlashlightEvaluator.minVelocity = 0.5;
3476
+ OsuFlashlightEvaluator.sliderMultiplier = 1.3;
3477
+ OsuFlashlightEvaluator.minAngleMultiplier = 0.2;
3370
3478
 
3371
3479
  /**
3372
3480
  * Represents the skill required to memorize and hit every object in a beatmap with the Flashlight mod enabled.
3373
3481
  */
3374
3482
  class OsuFlashlight extends OsuSkill {
3375
- constructor(mods) {
3376
- super(mods);
3483
+ constructor() {
3484
+ super(...arguments);
3377
3485
  this.strainDecayBase = 0.15;
3378
3486
  this.reducedSectionCount = 0;
3379
3487
  this.reducedSectionBaseline = 1;
3380
3488
  this.decayWeight = 1;
3381
3489
  this.currentFlashlightStrain = 0;
3382
3490
  this.skillMultiplier = 0.05512;
3383
- this.isHidden = mods.some((m) => m instanceof osuBase.ModHidden);
3384
3491
  }
3385
3492
  difficultyValue() {
3386
3493
  return this.strainPeaks.reduce((a, b) => a + b, 0);
@@ -3388,7 +3495,8 @@ class OsuFlashlight extends OsuSkill {
3388
3495
  strainValueAt(current) {
3389
3496
  this.currentFlashlightStrain *= this.strainDecay(current.deltaTime);
3390
3497
  this.currentFlashlightStrain +=
3391
- OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.isHidden) * this.skillMultiplier;
3498
+ OsuFlashlightEvaluator.evaluateDifficultyOf(current, this.mods) *
3499
+ this.skillMultiplier;
3392
3500
  return this.currentFlashlightStrain;
3393
3501
  }
3394
3502
  calculateInitialStrain(time, current) {
@@ -3401,36 +3509,6 @@ class OsuFlashlight extends OsuSkill {
3401
3509
  }
3402
3510
  }
3403
3511
 
3404
- /**
3405
- * Represents an osu!standard hit object with difficulty calculation values.
3406
- */
3407
- class OsuDifficultyHitObject extends DifficultyHitObject {
3408
- constructor() {
3409
- super(...arguments);
3410
- /**
3411
- * The speed strain generated by the hitobject.
3412
- */
3413
- this.speedStrain = 0;
3414
- /**
3415
- * The flashlight strain generated by this hitobject.
3416
- */
3417
- this.flashlightStrain = 0;
3418
- this.radiusBuffThreshold = 30;
3419
- this.mode = osuBase.Modes.osu;
3420
- }
3421
- get scalingFactor() {
3422
- const radius = this.object.radius;
3423
- // We will scale distances by this factor, so we can assume a uniform CircleSize among beatmaps.
3424
- let scalingFactor = DifficultyHitObject.normalizedRadius / radius;
3425
- // High circle size (small CS) bonus
3426
- if (radius < this.radiusBuffThreshold) {
3427
- scalingFactor *=
3428
- 1 + Math.min(this.radiusBuffThreshold - radius, 5) / 50;
3429
- }
3430
- return scalingFactor;
3431
- }
3432
- }
3433
-
3434
3512
  /**
3435
3513
  * A difficulty calculator for osu!standard gamemode.
3436
3514
  */
@@ -3452,6 +3530,7 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3452
3530
  hitCircleCount: 0,
3453
3531
  sliderCount: 0,
3454
3532
  spinnerCount: 0,
3533
+ aimDifficultSliderCount: 0,
3455
3534
  aimDifficultStrainCount: 0,
3456
3535
  speedDifficultStrainCount: 0,
3457
3536
  };
@@ -3483,6 +3562,10 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3483
3562
  * Calculates the aim star rating of the beatmap and stores it in this instance.
3484
3563
  */
3485
3564
  calculateAim() {
3565
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3566
+ this.attributes.aimDifficulty = 0;
3567
+ return;
3568
+ }
3486
3569
  const aimSkill = new OsuAim(this.mods, true);
3487
3570
  const aimSkillWithoutSliders = new OsuAim(this.mods, false);
3488
3571
  this.calculateSkills(aimSkill, aimSkillWithoutSliders);
@@ -3504,6 +3587,10 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3504
3587
  * Calculates the flashlight star rating of the beatmap and stores it in this instance.
3505
3588
  */
3506
3589
  calculateFlashlight() {
3590
+ if (!this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3591
+ this.attributes.flashlightDifficulty = 0;
3592
+ return;
3593
+ }
3507
3594
  const flashlightSkill = new OsuFlashlight(this.mods);
3508
3595
  this.calculateSkills(flashlightSkill);
3509
3596
  this.postCalculateFlashlight(flashlightSkill);
@@ -3533,21 +3620,20 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3533
3620
  }
3534
3621
  calculateAll() {
3535
3622
  const skills = this.createSkills();
3536
- const isRelax = this.mods.some((m) => m instanceof osuBase.ModRelax);
3537
3623
  this.calculateSkills(...skills);
3538
- const aimSkill = skills[0];
3539
- const aimSkillWithoutSliders = skills[1];
3540
- const speedSkill = skills[2];
3541
- const flashlightSkill = skills[3];
3542
- this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
3543
- if (isRelax) {
3544
- this.attributes.speedDifficulty = 0;
3545
- }
3546
- else {
3624
+ const aimSkill = skills.find((s) => s instanceof OsuAim && s.withSliders);
3625
+ const aimSkillWithoutSliders = skills.find((s) => s instanceof OsuAim && !s.withSliders);
3626
+ const speedSkill = skills.find((s) => s instanceof OsuSpeed);
3627
+ const flashlightSkill = skills.find((s) => s instanceof OsuFlashlight);
3628
+ if (aimSkill && aimSkillWithoutSliders) {
3629
+ this.postCalculateAim(aimSkill, aimSkillWithoutSliders);
3630
+ }
3631
+ if (speedSkill) {
3547
3632
  this.postCalculateSpeed(speedSkill);
3548
3633
  }
3549
- this.calculateSpeedAttributes();
3550
- this.postCalculateFlashlight(flashlightSkill);
3634
+ if (flashlightSkill) {
3635
+ this.postCalculateFlashlight(flashlightSkill);
3636
+ }
3551
3637
  this.calculateTotal();
3552
3638
  }
3553
3639
  toString() {
@@ -3572,12 +3658,18 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3572
3658
  return difficultyObjects;
3573
3659
  }
3574
3660
  createSkills() {
3575
- return [
3576
- new OsuAim(this.mods, true),
3577
- new OsuAim(this.mods, false),
3578
- new OsuSpeed(this.mods),
3579
- new OsuFlashlight(this.mods),
3580
- ];
3661
+ const skills = [];
3662
+ if (!this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3663
+ skills.push(new OsuAim(this.mods, true));
3664
+ skills.push(new OsuAim(this.mods, false));
3665
+ }
3666
+ if (!this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3667
+ skills.push(new OsuSpeed(this.mods));
3668
+ }
3669
+ if (this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
3670
+ skills.push(new OsuFlashlight(this.mods));
3671
+ }
3672
+ return skills;
3581
3673
  }
3582
3674
  populateDifficultyAttributes(beatmap, clockRate) {
3583
3675
  super.populateDifficultyAttributes(beatmap, clockRate);
@@ -3605,8 +3697,13 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3605
3697
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3606
3698
  this.attributes.aimDifficulty *= 0.9;
3607
3699
  }
3700
+ else if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3701
+ this.attributes.aimDifficulty = 0;
3702
+ }
3608
3703
  this.attributes.aimDifficultStrainCount =
3609
3704
  aimSkill.countDifficultStrains();
3705
+ this.attributes.aimDifficultSliderCount =
3706
+ aimSkill.countDifficultSliders();
3610
3707
  }
3611
3708
  /**
3612
3709
  * Called after speed skill calculation.
@@ -3615,16 +3712,16 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3615
3712
  */
3616
3713
  postCalculateSpeed(speedSkill) {
3617
3714
  this.strainPeaks.speed = speedSkill.strainPeaks;
3618
- this.attributes.speedDifficulty = this.starValue(speedSkill.difficultyValue());
3715
+ this.attributes.speedDifficulty = this.mods.some((m) => m instanceof osuBase.ModRelax)
3716
+ ? 0
3717
+ : this.starValue(speedSkill.difficultyValue());
3718
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3719
+ this.attributes.speedDifficulty *= 0.5;
3720
+ }
3619
3721
  this.attributes.speedDifficultStrainCount =
3620
3722
  speedSkill.countDifficultStrains();
3621
- }
3622
- /**
3623
- * Calculates speed-related attributes.
3624
- */
3625
- calculateSpeedAttributes() {
3626
3723
  const objectStrains = this.objects.map((v) => v.speedStrain);
3627
- const maxStrain = Math.max(...objectStrains);
3724
+ const maxStrain = osuBase.MathUtils.max(objectStrains);
3628
3725
  if (maxStrain) {
3629
3726
  this.attributes.speedNoteCount = objectStrains.reduce((total, next) => total + 1 / (1 + Math.exp(-((next / maxStrain) * 12 - 6))), 0);
3630
3727
  }
@@ -3643,6 +3740,9 @@ class OsuDifficultyCalculator extends DifficultyCalculator {
3643
3740
  if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3644
3741
  this.attributes.flashlightDifficulty *= 0.7;
3645
3742
  }
3743
+ else if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3744
+ this.attributes.flashlightDifficulty *= 0.4;
3745
+ }
3646
3746
  }
3647
3747
  }
3648
3748
 
@@ -3671,8 +3771,10 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3671
3771
  this.finalMultiplier = 1.15;
3672
3772
  this.mode = osuBase.Modes.osu;
3673
3773
  this.comboPenalty = 1;
3774
+ this.speedDeviation = 0;
3674
3775
  }
3675
3776
  calculateValues() {
3777
+ this.speedDeviation = this.calculateSpeedDeviation();
3676
3778
  this.aim = this.calculateAimValue();
3677
3779
  this.speed = this.calculateSpeedValue();
3678
3780
  this.accuracy = this.calculateAccuracyValue();
@@ -3696,6 +3798,9 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3696
3798
  * Calculates the aim performance value of the beatmap.
3697
3799
  */
3698
3800
  calculateAimValue() {
3801
+ if (this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3802
+ return 0;
3803
+ }
3699
3804
  let aimValue = this.baseValue(this.difficultyAttributes.aimDifficulty);
3700
3805
  // Longer maps are worth more
3701
3806
  let lengthBonus = 0.95 + 0.4 * Math.min(1, this.totalHits / 2000);
@@ -3703,6 +3808,14 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3703
3808
  lengthBonus += Math.log10(this.totalHits / 2000) * 0.5;
3704
3809
  }
3705
3810
  aimValue *= lengthBonus;
3811
+ if (this.effectiveMissCount > 0) {
3812
+ // Penalize misses by assessing # of misses relative to the total # of objects.
3813
+ // Default a 3% reduction for any # of misses.
3814
+ aimValue *=
3815
+ 0.97 *
3816
+ Math.pow(1 -
3817
+ Math.pow(this.effectiveMissCount / this.totalHits, 0.775), this.effectiveMissCount);
3818
+ }
3706
3819
  aimValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.aimDifficultStrainCount);
3707
3820
  const calculatedAR = this.difficultyAttributes.approachRate;
3708
3821
  if (!this.mods.some((m) => m instanceof osuBase.ModRelax)) {
@@ -3718,7 +3831,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3718
3831
  aimValue *= 1 + arFactor * lengthBonus;
3719
3832
  }
3720
3833
  // We want to give more reward for lower AR when it comes to aim and HD. This nerfs high AR and buffs lower AR.
3721
- if (this.mods.some((m) => m instanceof osuBase.ModHidden)) {
3834
+ if (this.mods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModTraceable)) {
3722
3835
  aimValue *= 1 + 0.04 * (12 - calculatedAR);
3723
3836
  }
3724
3837
  // Scale the aim value with slider factor to nerf very likely dropped sliderends.
@@ -3734,7 +3847,8 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3734
3847
  * Calculates the speed performance value of the beatmap.
3735
3848
  */
3736
3849
  calculateSpeedValue() {
3737
- if (this.mods.some((m) => m instanceof osuBase.ModRelax)) {
3850
+ if (this.mods.some((m) => m instanceof osuBase.ModRelax) ||
3851
+ this.speedDeviation === Number.POSITIVE_INFINITY) {
3738
3852
  return 0;
3739
3853
  }
3740
3854
  let speedValue = this.baseValue(this.difficultyAttributes.speedDifficulty);
@@ -3747,11 +3861,12 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3747
3861
  speedValue *= this.calculateStrainBasedMissPenalty(this.difficultyAttributes.speedDifficultStrainCount);
3748
3862
  // AR scaling
3749
3863
  const calculatedAR = this.difficultyAttributes.approachRate;
3750
- if (calculatedAR > 10.33) {
3864
+ if (calculatedAR > 10.33 &&
3865
+ !this.mods.some((m) => m instanceof osuBase.ModAutopilot)) {
3751
3866
  // Buff for longer maps with high AR.
3752
3867
  speedValue *= 1 + 0.3 * (calculatedAR - 10.33) * lengthBonus;
3753
3868
  }
3754
- if (this.mods.some((m) => m instanceof osuBase.ModHidden)) {
3869
+ if (this.mods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModTraceable)) {
3755
3870
  speedValue *= 1 + 0.04 * (12 - calculatedAR);
3756
3871
  }
3757
3872
  // Calculate accuracy assuming the worst case scenario.
@@ -3768,16 +3883,15 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3768
3883
  }
3769
3884
  : // Set accuracy to 0.
3770
3885
  { n300: 0, nobjects: 1 });
3886
+ speedValue *= this.calculateSpeedHighDeviationNerf();
3771
3887
  // Scale the speed value with accuracy and OD.
3772
3888
  speedValue *=
3773
3889
  (0.95 +
3774
- Math.pow(this.difficultyAttributes.overallDifficulty, 2) /
3890
+ Math.pow(Math.max(0, this.difficultyAttributes.overallDifficulty), 2) /
3775
3891
  750) *
3776
3892
  Math.pow((this.computedAccuracy.value() +
3777
3893
  relevantAccuracy.value(this.difficultyAttributes.speedNoteCount)) /
3778
3894
  2, (14.5 - this.difficultyAttributes.overallDifficulty) / 2);
3779
- // Scale the speed value with # of 50s to punish doubletapping.
3780
- speedValue *= Math.pow(0.99, Math.max(0, this.computedAccuracy.n50 - this.totalHits / 500));
3781
3895
  return speedValue;
3782
3896
  }
3783
3897
  /**
@@ -3802,7 +3916,7 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3802
3916
  2.83;
3803
3917
  // Bonus for many hitcircles - it's harder to keep good accuracy up for longer
3804
3918
  accuracyValue *= Math.min(1.15, Math.pow(ncircles / 1000, 0.3));
3805
- if (this.mods.some((m) => m instanceof osuBase.ModHidden)) {
3919
+ if (this.mods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModTraceable)) {
3806
3920
  accuracyValue *= 1.08;
3807
3921
  }
3808
3922
  if (this.mods.some((m) => m instanceof osuBase.ModFlashlight)) {
@@ -3841,6 +3955,113 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3841
3955
  flashlightValue *= 0.98 + odScaling;
3842
3956
  return flashlightValue;
3843
3957
  }
3958
+ /**
3959
+ * Estimates a player's deviation on speed notes using {@link calculateDeviation}, assuming worst-case.
3960
+ *
3961
+ * Treats all speed notes as hit circles.
3962
+ */
3963
+ calculateSpeedDeviation() {
3964
+ if (this.totalSuccessfulHits === 0) {
3965
+ return Number.POSITIVE_INFINITY;
3966
+ }
3967
+ // Calculate accuracy assuming the worst case scenario
3968
+ const speedNoteCount = this.difficultyAttributes.speedNoteCount +
3969
+ (this.totalHits - this.difficultyAttributes.speedNoteCount) * 0.1;
3970
+ // Assume worst case: all mistakes were on speed notes
3971
+ const relevantCountMiss = Math.min(this.computedAccuracy.nmiss, speedNoteCount);
3972
+ const relevantCountMeh = Math.min(this.computedAccuracy.n50, speedNoteCount - relevantCountMiss);
3973
+ const relevantCountOk = Math.min(this.computedAccuracy.n100, speedNoteCount - relevantCountMiss - relevantCountMeh);
3974
+ const relevantCountGreat = Math.max(0, speedNoteCount -
3975
+ relevantCountMiss -
3976
+ relevantCountMeh -
3977
+ relevantCountOk);
3978
+ return this.calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss);
3979
+ }
3980
+ /**
3981
+ * Estimates the player's tap deviation based on the OD, given number of greats, oks, mehs and misses,
3982
+ * assuming the player's mean hit error is 0. The estimation is consistent in that two SS scores on the
3983
+ * same map with the same settings will always return the same deviation.
3984
+ *
3985
+ * Misses are ignored because they are usually due to misaiming.
3986
+ *
3987
+ * Greats and oks are assumed to follow a normal distribution, whereas mehs are assumed to follow a uniform distribution.
3988
+ */
3989
+ calculateDeviation(relevantCountGreat, relevantCountOk, relevantCountMeh, relevantCountMiss) {
3990
+ if (relevantCountGreat + relevantCountOk + relevantCountMeh <= 0) {
3991
+ return Number.POSITIVE_INFINITY;
3992
+ }
3993
+ const objectCount = relevantCountGreat +
3994
+ relevantCountOk +
3995
+ relevantCountMeh +
3996
+ relevantCountMiss;
3997
+ // Obtain the great, ok, and meh windows.
3998
+ const hitWindow = new osuBase.OsuHitWindow(osuBase.OsuHitWindow.greatWindowToOD(
3999
+ // Convert current OD to non clock rate-adjusted OD.
4000
+ new osuBase.OsuHitWindow(this.difficultyAttributes.overallDifficulty)
4001
+ .greatWindow * this.difficultyAttributes.clockRate));
4002
+ const { greatWindow, okWindow, mehWindow } = hitWindow;
4003
+ // The probability that a player hits a circle is unknown, but we can estimate it to be
4004
+ // the number of greats on circles divided by the number of circles, and then add one
4005
+ // to the number of circles as a bias correction.
4006
+ const n = Math.max(1, objectCount - relevantCountMiss - relevantCountMeh);
4007
+ // 99% critical value for the normal distribution (one-tailed).
4008
+ const z = 2.32634787404;
4009
+ // Proportion of greats hit on circles, ignoring misses and 50s.
4010
+ const p = relevantCountGreat / n;
4011
+ // We can be 99% confident that p is at least this value.
4012
+ const pLowerBound = (n * p + (z * z) / 2) / (n + z * z) -
4013
+ (z / (n + z * z)) * Math.sqrt(n * p * (1 - p) + (z * z) / 4);
4014
+ // Compute the deviation assuming greats and oks are normally distributed, and mehs are uniformly distributed.
4015
+ // Begin with greats and oks first. Ignoring mehs, we can be 99% confident that the deviation is not higher than:
4016
+ let deviation = greatWindow / (Math.SQRT2 * osuBase.ErrorFunction.erfInv(pLowerBound));
4017
+ const randomValue = (Math.sqrt(2 / Math.PI) *
4018
+ okWindow *
4019
+ Math.pow(Math.exp(-0.5 * (okWindow / deviation)), 2)) /
4020
+ (deviation *
4021
+ osuBase.ErrorFunction.erf(okWindow / (Math.SQRT2 * deviation)));
4022
+ deviation *= Math.sqrt(1 - randomValue);
4023
+ // Value deviation approach as greatCount approaches 0
4024
+ const limitValue = okWindow / Math.sqrt(3);
4025
+ // If precision is not enough to compute true deviation - use limit value
4026
+ if (pLowerBound == 0.0 || randomValue >= 1 || deviation > limitValue) {
4027
+ deviation = limitValue;
4028
+ }
4029
+ // Then compute the variance for mehs.
4030
+ const mehVariance = (Math.pow(mehWindow, 2) +
4031
+ okWindow * mehWindow +
4032
+ Math.pow(okWindow, 2)) /
4033
+ 3;
4034
+ // Find the total deviation.
4035
+ deviation = Math.sqrt(((relevantCountGreat + relevantCountOk) * Math.pow(deviation, 2) +
4036
+ relevantCountMeh * mehVariance) /
4037
+ (relevantCountGreat + relevantCountOk + relevantCountMeh));
4038
+ return deviation;
4039
+ }
4040
+ /**
4041
+ * Calculates multiplier for speed to account for improper tapping based on the deviation and speed difficulty.
4042
+ *
4043
+ * [Graph](https://www.desmos.com/calculator/dmogdhzofn)
4044
+ */
4045
+ calculateSpeedHighDeviationNerf() {
4046
+ if (this.speedDeviation == Number.POSITIVE_INFINITY) {
4047
+ return 0;
4048
+ }
4049
+ const speedValue = this.baseValue(this.difficultyAttributes.speedDifficulty);
4050
+ // Decide a point where the PP value achieved compared to the speed deviation is assumed to be tapped
4051
+ // improperly. Any PP above this point is considered "excess" speed difficulty. This is used to cause
4052
+ // PP above the cutoff to scale logarithmically towards the original speed value thus nerfing the value.
4053
+ const excessSpeedDifficultyCutoff = 100 + 220 * Math.pow(22 / this.speedDeviation, 6.5);
4054
+ if (speedValue <= excessSpeedDifficultyCutoff) {
4055
+ return 1;
4056
+ }
4057
+ const scale = 50;
4058
+ const adjustedSpeedValue = scale *
4059
+ (Math.log((speedValue - excessSpeedDifficultyCutoff) / scale + 1) +
4060
+ excessSpeedDifficultyCutoff / scale);
4061
+ // 220 UR and less are considered tapped correctly to ensure that normal scores will be punished as little as possible
4062
+ const t = 1 - osuBase.Interpolation.reverseLerp(this.speedDeviation, 22, 27);
4063
+ return (osuBase.Interpolation.lerp(adjustedSpeedValue, speedValue, t) / speedValue);
4064
+ }
3844
4065
  toString() {
3845
4066
  return (this.total.toFixed(2) +
3846
4067
  " pp (" +
@@ -3855,7 +4076,6 @@ class OsuPerformanceCalculator extends PerformanceCalculator {
3855
4076
  }
3856
4077
  }
3857
4078
 
3858
- exports.AimEvaluator = AimEvaluator;
3859
4079
  exports.DifficultyCalculator = DifficultyCalculator;
3860
4080
  exports.DifficultyHitObject = DifficultyHitObject;
3861
4081
  exports.DroidAim = DroidAim;
@@ -3871,7 +4091,6 @@ exports.DroidTap = DroidTap;
3871
4091
  exports.DroidTapEvaluator = DroidTapEvaluator;
3872
4092
  exports.DroidVisual = DroidVisual;
3873
4093
  exports.DroidVisualEvaluator = DroidVisualEvaluator;
3874
- exports.FlashlightEvaluator = FlashlightEvaluator;
3875
4094
  exports.OsuAim = OsuAim;
3876
4095
  exports.OsuAimEvaluator = OsuAimEvaluator;
3877
4096
  exports.OsuDifficultyCalculator = OsuDifficultyCalculator;
@@ -3883,5 +4102,4 @@ exports.OsuRhythmEvaluator = OsuRhythmEvaluator;
3883
4102
  exports.OsuSpeed = OsuSpeed;
3884
4103
  exports.OsuSpeedEvaluator = OsuSpeedEvaluator;
3885
4104
  exports.PerformanceCalculator = PerformanceCalculator;
3886
- exports.SpeedEvaluator = SpeedEvaluator;
3887
4105
  //# sourceMappingURL=index.js.map