@rian8337/osu-base 4.0.0-beta.56 → 4.0.0-beta.59

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.
Files changed (3) hide show
  1. package/dist/index.js +1657 -1611
  2. package/package.json +2 -2
  3. package/typings/index.d.ts +73 -63
package/dist/index.js CHANGED
@@ -389,139 +389,144 @@ exports.Modes = void 0;
389
389
  })(exports.Modes || (exports.Modes = {}));
390
390
 
391
391
  /**
392
- * Represents a mod.
392
+ * Represents a hit window.
393
393
  */
394
- class Mod {
395
- constructor() {
396
- /**
397
- * `Mod`s that are incompatible with this `Mod`.
398
- */
399
- this.incompatibleMods = new Set();
400
- }
394
+ class HitWindow {
401
395
  /**
402
- * Serializes this `Mod` to a `SerializedMod`.
396
+ * @param overallDifficulty The overall difficulty of this `HitWindow`. Defaults to 5.
403
397
  */
404
- serialize() {
405
- var _a;
406
- const serialized = {
407
- acronym: this.acronym,
408
- settings: (_a = this.serializeSettings()) !== null && _a !== void 0 ? _a : undefined,
409
- };
410
- if (!serialized.settings) {
411
- delete serialized.settings;
412
- }
413
- return serialized;
398
+ constructor(overallDifficulty = 5) {
399
+ this.overallDifficulty = overallDifficulty;
414
400
  }
401
+ }
402
+ /**
403
+ * A fixed miss hit window regardless of difficulty settings.
404
+ */
405
+ HitWindow.missWindow = 400;
406
+
407
+ /**
408
+ * Represents the hit window of osu!droid _without_ the Precise mod.
409
+ */
410
+ class DroidHitWindow extends HitWindow {
415
411
  /**
416
- * Copies the settings of a `SerializedMod` to this `Mod`.
412
+ * Calculates the overall difficulty value of a great (300) hit window.
417
413
  *
418
- * @param mod The `SerializedMod` to copy the settings from. Must be the same `Mod` type.
419
- * @throws {TypeError} If the `SerializedMod` is not the same type as this `Mod`.
414
+ * @param value The value of the hit window, in milliseconds.
415
+ * @returns The overall difficulty value.
420
416
  */
421
- copySettings(mod) {
422
- if (mod.acronym !== this.acronym) {
423
- throw new TypeError(`Cannot copy settings from ${mod.acronym} to ${this.acronym}`);
424
- }
417
+ static greatWindowToOD(value) {
418
+ return 5 - (value - 75) / 5;
425
419
  }
426
420
  /**
427
- * Whether this `Mod` can be applied to osu!droid.
421
+ * Calculates the overall difficulty value of a good (100) hit window.
422
+ *
423
+ * @param value The value of the hit window, in milliseconds.
424
+ * @returns The overall difficulty value.
428
425
  */
429
- isApplicableToDroid() {
430
- return "droidRanked" in this;
426
+ static okWindowToOD(value) {
427
+ return 5 - (value - 150) / 10;
431
428
  }
432
429
  /**
433
- * Whether this `Mod` can be applied to osu!standard.
430
+ * Calculates the overall difficulty value of a meh (50) hit window.
431
+ *
432
+ * @param value The value of the hit window, in milliseconds.
433
+ * @returns The overall difficulty value.
434
434
  */
435
- isApplicableToOsu() {
436
- return "osuRanked" in this;
435
+ static mehWindowToOD(value) {
436
+ return 5 - (value - 250) / 10;
437
437
  }
438
- /**
439
- * Whether this `Mod` can be applied to osu!standard, specifically the osu!stable client.
440
- */
441
- isApplicableToOsuStable() {
442
- return "bitwise" in this;
438
+ get greatWindow() {
439
+ return 75 + 5 * (5 - this.overallDifficulty);
443
440
  }
444
- /**
445
- * Whether this `Mod` can be applied to a `Beatmap`.
446
- */
447
- isApplicableToBeatmap() {
448
- return "applyToBeatmap" in this;
441
+ get okWindow() {
442
+ return 150 + 10 * (5 - this.overallDifficulty);
449
443
  }
450
- /**
451
- * Whether this `Mod` can be applied to a `BeatmapDifficulty`.
452
- */
453
- isApplicableToDifficulty() {
454
- return "applyToDifficulty" in this;
444
+ get mehWindow() {
445
+ return 250 + 10 * (5 - this.overallDifficulty);
455
446
  }
447
+ }
448
+
449
+ /**
450
+ * Represents the hit window of osu!standard.
451
+ */
452
+ class OsuHitWindow extends HitWindow {
456
453
  /**
457
- * Whether this `Mod` can be applied to a `BeatmapDifficulty` relative to other `Mod`s and settings.
454
+ * Calculates the overall difficulty value of a great (300) hit window.
455
+ *
456
+ * @param value The value of the hit window, in milliseconds.
457
+ * @returns The overall difficulty value.
458
458
  */
459
- isApplicableToDifficultyWithSettings() {
460
- return "applyToDifficultyWithSettings" in this;
459
+ static greatWindowToOD(value) {
460
+ return (80 - value) / 6;
461
461
  }
462
462
  /**
463
- * Whether this `Mod` can be applied to a `HitObject`.
463
+ * Calculates the overall difficulty value of a good (100) hit window.
464
+ *
465
+ * @param value The value of the hit window, in milliseconds.
466
+ * @returns The overall difficulty value.
464
467
  */
465
- isApplicableToHitObject() {
466
- return "applyToHitObject" in this;
468
+ static okWindowToOD(value) {
469
+ return (140 - value) / 8;
467
470
  }
468
471
  /**
469
- * Whether this `Mod` can be applied to a `HitObject` relative to other `Mod`s and settings.
472
+ * Calculates the overall difficulty value of a meh hit window.
473
+ *
474
+ * @param value The value of the hit window, in milliseconds.
475
+ * @returns The overall difficulty value.
470
476
  */
471
- isApplicableToHitObjectWithSettings() {
472
- return "applyToHitObjectWithSettings" in this;
477
+ static mehWindowToOD(value) {
478
+ return (200 - value) / 10;
473
479
  }
474
- /**
475
- * Whether this `Mod` can be applied to a track's playback rate.
476
- */
477
- isApplicableToTrackRate() {
478
- return "applyToRate" in this;
480
+ get greatWindow() {
481
+ return 80 - 6 * this.overallDifficulty;
482
+ }
483
+ get okWindow() {
484
+ return 140 - 8 * this.overallDifficulty;
479
485
  }
486
+ get mehWindow() {
487
+ return 200 - 10 * this.overallDifficulty;
488
+ }
489
+ }
490
+
491
+ /**
492
+ * Represents the hit window of osu!droid _with_ the Precise mod.
493
+ */
494
+ class PreciseDroidHitWindow extends HitWindow {
480
495
  /**
481
- * Whether this `Mod` is migratable to a new `Mod` in osu!droid.
496
+ * Calculates the overall difficulty value of a great (300) hit window.
497
+ *
498
+ * @param value The value of the hit window, in milliseconds.
499
+ * @returns The overall difficulty value.
482
500
  */
483
- isMigratableDroidMod() {
484
- return "migrateDroidMod" in this;
501
+ static greatWindowToOD(value) {
502
+ return 5 - (value - 55) / 6;
485
503
  }
486
504
  /**
487
- * Serializes the settings of this `Mod` to an object that can be converted to a JSON.
505
+ * Calculates the overall difficulty value of a good (100) hit window.
488
506
  *
489
- * @returns The serialized settings of this `Mod`, or `null` if there are no settings.
507
+ * @param value The value of the hit window, in milliseconds.
508
+ * @returns The overall difficulty value.
490
509
  */
491
- serializeSettings() {
492
- return null;
510
+ static okWindowToOD(value) {
511
+ return 5 - (value - 120) / 8;
493
512
  }
494
513
  /**
495
- * Returns the string representation of this `Mod`.
514
+ * Calculates the overall difficulty value of a meh (50) hit window.
515
+ *
516
+ * @param value The value of the hit window, in milliseconds.
517
+ * @returns The overall difficulty value.
496
518
  */
497
- toString() {
498
- return this.acronym;
499
- }
500
- }
501
-
502
- /**
503
- * Represents the ScoreV2 mod.
504
- */
505
- class ModScoreV2 extends Mod {
506
- constructor() {
507
- super(...arguments);
508
- this.acronym = "V2";
509
- this.name = "ScoreV2";
510
- this.droidRanked = false;
511
- this.osuRanked = false;
512
- this.bitwise = 1 << 29;
513
- }
514
- get isDroidRelevant() {
515
- return true;
519
+ static mehWindowToOD(value) {
520
+ return 5 - (value - 180) / 10;
516
521
  }
517
- calculateDroidScoreMultiplier() {
518
- return 1;
522
+ get greatWindow() {
523
+ return 55 + 6 * (5 - this.overallDifficulty);
519
524
  }
520
- get isOsuRelevant() {
521
- return true;
525
+ get okWindow() {
526
+ return 120 + 8 * (5 - this.overallDifficulty);
522
527
  }
523
- get osuScoreMultiplier() {
524
- return 1;
528
+ get mehWindow() {
529
+ return 180 + 10 * (5 - this.overallDifficulty);
525
530
  }
526
531
  }
527
532
 
@@ -766,18 +771,20 @@ class CircleSizeCalculator {
766
771
  * @param mods The mods to apply.
767
772
  * @returns The calculated osu!droid scale.
768
773
  */
769
- static droidCSToDroidScale(cs, mods = []) {
774
+ static droidCSToDroidScale(cs, mods) {
770
775
  // Create a dummy beatmap difficulty for circle size calculation.
771
776
  const difficulty = new BeatmapDifficulty();
772
777
  difficulty.cs = cs;
773
- for (const mod of mods) {
774
- if (mod.isApplicableToDifficulty()) {
775
- mod.applyToDifficulty(exports.Modes.droid, difficulty);
778
+ if (mods !== undefined) {
779
+ for (const mod of mods.values()) {
780
+ if (mod.isApplicableToDifficulty()) {
781
+ mod.applyToDifficulty(exports.Modes.droid, difficulty);
782
+ }
776
783
  }
777
- }
778
- for (const mod of mods) {
779
- if (mod.isApplicableToDifficultyWithSettings()) {
780
- mod.applyToDifficultyWithSettings(exports.Modes.droid, difficulty, mods);
784
+ for (const mod of mods.values()) {
785
+ if (mod.isApplicableToDifficultyWithSettings()) {
786
+ mod.applyToDifficultyWithSettings(exports.Modes.droid, difficulty, mods);
787
+ }
781
788
  }
782
789
  }
783
790
  return Math.max(((this.assumedDroidHeight / 480) *
@@ -901,135 +908,35 @@ CircleSizeCalculator.brokenGamefieldRoundingAllowance = 1.00041;
901
908
  CircleSizeCalculator.assumedDroidHeight = 681;
902
909
 
903
910
  /**
904
- * Represents a hit window.
911
+ * Represents a hitobject in a beatmap.
905
912
  */
906
- class HitWindow {
913
+ class HitObject {
907
914
  /**
908
- * @param overallDifficulty The overall difficulty of this `HitWindow`. Defaults to 5.
915
+ * The position of the hitobject in osu!pixels.
909
916
  */
910
- constructor(overallDifficulty = 5) {
911
- this.overallDifficulty = overallDifficulty;
917
+ get position() {
918
+ return this._position;
919
+ }
920
+ set position(value) {
921
+ this._position = value;
912
922
  }
913
- }
914
- /**
915
- * A fixed miss hit window regardless of difficulty settings.
916
- */
917
- HitWindow.missWindow = 400;
918
-
919
- /**
920
- * Represents the hit window of osu!droid _without_ the Precise mod.
921
- */
922
- class DroidHitWindow extends HitWindow {
923
923
  /**
924
- * Calculates the overall difficulty value of a great (300) hit window.
925
- *
926
- * @param value The value of the hit window, in milliseconds.
927
- * @returns The overall difficulty value.
924
+ * The end position of the hitobject in osu!pixels.
928
925
  */
929
- static greatWindowToOD(value) {
930
- return 5 - (value - 75) / 5;
926
+ get endPosition() {
927
+ return this.position;
931
928
  }
932
929
  /**
933
- * Calculates the overall difficulty value of a good (100) hit window.
934
- *
935
- * @param value The value of the hit window, in milliseconds.
936
- * @returns The overall difficulty value.
930
+ * The end time of the hitobject.
937
931
  */
938
- static okWindowToOD(value) {
939
- return 5 - (value - 150) / 10;
932
+ get endTime() {
933
+ return this.startTime;
940
934
  }
941
935
  /**
942
- * Calculates the overall difficulty value of a meh (50) hit window.
943
- *
944
- * @param value The value of the hit window, in milliseconds.
945
- * @returns The overall difficulty value.
936
+ * The duration of the hitobject.
946
937
  */
947
- static mehWindowToOD(value) {
948
- return 5 - (value - 250) / 10;
949
- }
950
- get greatWindow() {
951
- return 75 + 5 * (5 - this.overallDifficulty);
952
- }
953
- get okWindow() {
954
- return 150 + 10 * (5 - this.overallDifficulty);
955
- }
956
- get mehWindow() {
957
- return 250 + 10 * (5 - this.overallDifficulty);
958
- }
959
- }
960
-
961
- /**
962
- * Represents the hit window of osu!standard.
963
- */
964
- class OsuHitWindow extends HitWindow {
965
- /**
966
- * Calculates the overall difficulty value of a great (300) hit window.
967
- *
968
- * @param value The value of the hit window, in milliseconds.
969
- * @returns The overall difficulty value.
970
- */
971
- static greatWindowToOD(value) {
972
- return (80 - value) / 6;
973
- }
974
- /**
975
- * Calculates the overall difficulty value of a good (100) hit window.
976
- *
977
- * @param value The value of the hit window, in milliseconds.
978
- * @returns The overall difficulty value.
979
- */
980
- static okWindowToOD(value) {
981
- return (140 - value) / 8;
982
- }
983
- /**
984
- * Calculates the overall difficulty value of a meh hit window.
985
- *
986
- * @param value The value of the hit window, in milliseconds.
987
- * @returns The overall difficulty value.
988
- */
989
- static mehWindowToOD(value) {
990
- return (200 - value) / 10;
991
- }
992
- get greatWindow() {
993
- return 80 - 6 * this.overallDifficulty;
994
- }
995
- get okWindow() {
996
- return 140 - 8 * this.overallDifficulty;
997
- }
998
- get mehWindow() {
999
- return 200 - 10 * this.overallDifficulty;
1000
- }
1001
- }
1002
-
1003
- /**
1004
- * Represents a hitobject in a beatmap.
1005
- */
1006
- class HitObject {
1007
- /**
1008
- * The position of the hitobject in osu!pixels.
1009
- */
1010
- get position() {
1011
- return this._position;
1012
- }
1013
- set position(value) {
1014
- this._position = value;
1015
- }
1016
- /**
1017
- * The end position of the hitobject in osu!pixels.
1018
- */
1019
- get endPosition() {
1020
- return this.position;
1021
- }
1022
- /**
1023
- * The end time of the hitobject.
1024
- */
1025
- get endTime() {
1026
- return this.startTime;
1027
- }
1028
- /**
1029
- * The duration of the hitobject.
1030
- */
1031
- get duration() {
1032
- return this.endTime - this.startTime;
938
+ get duration() {
939
+ return this.endTime - this.startTime;
1033
940
  }
1034
941
  /**
1035
942
  * The index of this hitobject in the current combo.
@@ -1310,66 +1217,309 @@ HitObject.preemptMin = 450;
1310
1217
  HitObject.controlPointLeniency = 1;
1311
1218
 
1312
1219
  /**
1313
- * Represents a circle in a beatmap.
1314
- *
1315
- * All we need from circles is their position. All positions
1316
- * stored in the objects are in playfield coordinates (512*384
1317
- * rectangle).
1220
+ * Represents a mod.
1318
1221
  */
1319
- class Circle extends HitObject {
1320
- constructor(values) {
1321
- super(values);
1222
+ class Mod {
1223
+ constructor() {
1224
+ /**
1225
+ * `Mod`s that are incompatible with this `Mod`.
1226
+ */
1227
+ this.incompatibleMods = new Set();
1322
1228
  }
1323
- toString() {
1324
- return `Position: [${this._position.x}, ${this._position.y}]`;
1229
+ /**
1230
+ * Serializes this `Mod` to a `SerializedMod`.
1231
+ */
1232
+ serialize() {
1233
+ var _a;
1234
+ const serialized = {
1235
+ acronym: this.acronym,
1236
+ settings: (_a = this.serializeSettings()) !== null && _a !== void 0 ? _a : undefined,
1237
+ };
1238
+ if (!serialized.settings) {
1239
+ delete serialized.settings;
1240
+ }
1241
+ return serialized;
1325
1242
  }
1326
- }
1327
-
1328
- /**
1329
- * Represents a hitobject that can be nested within a slider.
1330
- */
1331
- class SliderNestedHitObject extends HitObject {
1332
- constructor(values) {
1333
- super(values);
1334
- this.spanIndex = values.spanIndex;
1335
- this.spanStartTime = values.spanStartTime;
1243
+ /**
1244
+ * Copies the settings of a `SerializedMod` to this `Mod`.
1245
+ *
1246
+ * @param mod The `SerializedMod` to copy the settings from. Must be the same `Mod` type.
1247
+ * @throws {TypeError} If the `SerializedMod` is not the same type as this `Mod`.
1248
+ */
1249
+ copySettings(mod) {
1250
+ if (mod.acronym !== this.acronym) {
1251
+ throw new TypeError(`Cannot copy settings from ${mod.acronym} to ${this.acronym}`);
1252
+ }
1336
1253
  }
1254
+ /**
1255
+ * Whether this `Mod` can be applied to osu!droid.
1256
+ */
1257
+ isApplicableToDroid() {
1258
+ return "droidRanked" in this;
1259
+ }
1260
+ /**
1261
+ * Whether this `Mod` can be applied to osu!standard.
1262
+ */
1263
+ isApplicableToOsu() {
1264
+ return "osuRanked" in this;
1265
+ }
1266
+ /**
1267
+ * Whether this `Mod` can be applied to osu!standard, specifically the osu!stable client.
1268
+ */
1269
+ isApplicableToOsuStable() {
1270
+ return "bitwise" in this;
1271
+ }
1272
+ /**
1273
+ * Whether this `Mod` can be applied to a `Beatmap`.
1274
+ */
1275
+ isApplicableToBeatmap() {
1276
+ return "applyToBeatmap" in this;
1277
+ }
1278
+ /**
1279
+ * Whether this `Mod` can be applied to a `BeatmapDifficulty`.
1280
+ */
1281
+ isApplicableToDifficulty() {
1282
+ return "applyToDifficulty" in this;
1283
+ }
1284
+ /**
1285
+ * Whether this `Mod` can be applied to a `BeatmapDifficulty` relative to other `Mod`s and settings.
1286
+ */
1287
+ isApplicableToDifficultyWithSettings() {
1288
+ return "applyToDifficultyWithSettings" in this;
1289
+ }
1290
+ /**
1291
+ * Whether this `Mod` can be applied to a `HitObject`.
1292
+ */
1293
+ isApplicableToHitObject() {
1294
+ return "applyToHitObject" in this;
1295
+ }
1296
+ /**
1297
+ * Whether this `Mod` can be applied to a `HitObject` relative to other `Mod`s and settings.
1298
+ */
1299
+ isApplicableToHitObjectWithSettings() {
1300
+ return "applyToHitObjectWithSettings" in this;
1301
+ }
1302
+ /**
1303
+ * Whether this `Mod` can be applied to a track's playback rate.
1304
+ */
1305
+ isApplicableToTrackRate() {
1306
+ return "applyToRate" in this;
1307
+ }
1308
+ /**
1309
+ * Whether this `Mod` is migratable to a new `Mod` in osu!droid.
1310
+ */
1311
+ isMigratableDroidMod() {
1312
+ return "migrateDroidMod" in this;
1313
+ }
1314
+ /**
1315
+ * Serializes the settings of this `Mod` to an object that can be converted to a JSON.
1316
+ *
1317
+ * @returns The serialized settings of this `Mod`, or `null` if there are no settings.
1318
+ */
1319
+ serializeSettings() {
1320
+ return null;
1321
+ }
1322
+ /**
1323
+ * Returns the string representation of this `Mod`.
1324
+ */
1337
1325
  toString() {
1338
- return `Position: [${this._position.x}, ${this._position.y}], span index: ${this.spanIndex}, span start time: ${this.spanStartTime}`;
1326
+ return this.acronym;
1339
1327
  }
1340
1328
  }
1341
1329
 
1342
1330
  /**
1343
- * Represents the head of a slider.
1331
+ * Represents the Relax mod.
1344
1332
  */
1345
- class SliderHead extends SliderNestedHitObject {
1346
- constructor(values) {
1347
- super(Object.assign(Object.assign({}, values), { spanIndex: 0, spanStartTime: values.startTime }));
1333
+ class ModRelax extends Mod {
1334
+ constructor() {
1335
+ super();
1336
+ this.acronym = "RX";
1337
+ this.name = "Relax";
1338
+ this.droidRanked = false;
1339
+ this.osuRanked = false;
1340
+ this.bitwise = 1 << 7;
1341
+ this.incompatibleMods.add(ModAuto).add(ModAutopilot);
1342
+ }
1343
+ get isDroidRelevant() {
1344
+ return true;
1345
+ }
1346
+ calculateDroidScoreMultiplier() {
1347
+ return 0.001;
1348
+ }
1349
+ get isOsuRelevant() {
1350
+ return true;
1351
+ }
1352
+ get osuScoreMultiplier() {
1353
+ return 0;
1348
1354
  }
1349
1355
  }
1350
1356
 
1351
1357
  /**
1352
- * An empty `HitWindow` that does not have any hit windows.
1353
- *
1354
- * No time values are provided (meaning instantaneous hit or miss).
1358
+ * Represents the Autopilot mod.
1355
1359
  */
1356
- class EmptyHitWindow extends HitWindow {
1360
+ class ModAutopilot extends Mod {
1357
1361
  constructor() {
1358
- super(0);
1362
+ super();
1363
+ this.acronym = "AP";
1364
+ this.name = "Autopilot";
1365
+ this.droidRanked = false;
1366
+ this.osuRanked = false;
1367
+ this.bitwise = 1 << 13;
1368
+ this.incompatibleMods.add(ModRelax).add(ModAuto);
1359
1369
  }
1360
- get greatWindow() {
1361
- return 0;
1370
+ get isDroidRelevant() {
1371
+ return true;
1362
1372
  }
1363
- get okWindow() {
1364
- return 0;
1373
+ calculateDroidScoreMultiplier() {
1374
+ return 0.001;
1365
1375
  }
1366
- get mehWindow() {
1376
+ get isOsuRelevant() {
1377
+ return true;
1378
+ }
1379
+ get osuScoreMultiplier() {
1367
1380
  return 0;
1368
1381
  }
1369
1382
  }
1370
1383
 
1371
1384
  /**
1372
- * Represents a nested hit object that is at the end of a slider path (either repeat or tail).
1385
+ * Represents the Auto mod.
1386
+ */
1387
+ class ModAuto extends Mod {
1388
+ constructor() {
1389
+ super();
1390
+ this.acronym = "AT";
1391
+ this.name = "Autoplay";
1392
+ this.droidRanked = false;
1393
+ this.osuRanked = false;
1394
+ this.bitwise = 1 << 11;
1395
+ this.incompatibleMods.add(ModAutopilot).add(ModRelax);
1396
+ }
1397
+ get isDroidRelevant() {
1398
+ return true;
1399
+ }
1400
+ calculateDroidScoreMultiplier() {
1401
+ return 1;
1402
+ }
1403
+ get isOsuRelevant() {
1404
+ return true;
1405
+ }
1406
+ get osuScoreMultiplier() {
1407
+ return 1;
1408
+ }
1409
+ }
1410
+
1411
+ /**
1412
+ * Represents a `Mod` that adjusts the playback rate of a track.
1413
+ */
1414
+ class ModRateAdjust extends Mod {
1415
+ /**
1416
+ * The generic osu!droid score multiplier of this `Mod`.
1417
+ */
1418
+ get droidScoreMultiplier() {
1419
+ return this.trackRateMultiplier >= 1
1420
+ ? 1 + (this.trackRateMultiplier - 1) * 0.24
1421
+ : Math.pow(0.3, (1 - this.trackRateMultiplier) * 4);
1422
+ }
1423
+ /**
1424
+ * Generic getter to determine if this `ModRateAdjust` is relevant.
1425
+ */
1426
+ get isRelevant() {
1427
+ return this.trackRateMultiplier !== 1;
1428
+ }
1429
+ applyToRate(time, rate) {
1430
+ return rate * this.trackRateMultiplier;
1431
+ }
1432
+ }
1433
+
1434
+ /**
1435
+ * Represents the Custom Speed mod.
1436
+ *
1437
+ * This is a replacement `Mod` for speed modify in osu!droid and custom rates in osu!lazer.
1438
+ */
1439
+ class ModCustomSpeed extends ModRateAdjust {
1440
+ constructor(trackRateMultiplier = 1) {
1441
+ super();
1442
+ this.acronym = "CS";
1443
+ this.name = "Custom Speed";
1444
+ this.droidRanked = true;
1445
+ this.osuRanked = false;
1446
+ this.trackRateMultiplier = trackRateMultiplier;
1447
+ }
1448
+ copySettings(mod) {
1449
+ var _a, _b;
1450
+ super.copySettings(mod);
1451
+ this.trackRateMultiplier =
1452
+ (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.rateMultiplier) !== null && _b !== void 0 ? _b : this.trackRateMultiplier;
1453
+ }
1454
+ get isDroidRelevant() {
1455
+ return this.isRelevant;
1456
+ }
1457
+ calculateDroidScoreMultiplier() {
1458
+ return this.droidScoreMultiplier;
1459
+ }
1460
+ get isOsuRelevant() {
1461
+ return this.isRelevant;
1462
+ }
1463
+ get osuScoreMultiplier() {
1464
+ // Round to the nearest multiple of 0.1.
1465
+ let value = Math.trunc(this.trackRateMultiplier * 10) / 10;
1466
+ // Offset back to 0.
1467
+ --value;
1468
+ return this.trackRateMultiplier >= 1 ? 1 + value / 5 : 0.6 + value;
1469
+ }
1470
+ serializeSettings() {
1471
+ return { rateMultiplier: this.trackRateMultiplier };
1472
+ }
1473
+ toString() {
1474
+ return `${super.toString()} (${this.trackRateMultiplier.toFixed(2)}x)`;
1475
+ }
1476
+ }
1477
+
1478
+ /**
1479
+ * Represents a hitobject that can be nested within a slider.
1480
+ */
1481
+ class SliderNestedHitObject extends HitObject {
1482
+ constructor(values) {
1483
+ super(values);
1484
+ this.spanIndex = values.spanIndex;
1485
+ this.spanStartTime = values.spanStartTime;
1486
+ }
1487
+ toString() {
1488
+ return `Position: [${this._position.x}, ${this._position.y}], span index: ${this.spanIndex}, span start time: ${this.spanStartTime}`;
1489
+ }
1490
+ }
1491
+
1492
+ /**
1493
+ * Represents the head of a slider.
1494
+ */
1495
+ class SliderHead extends SliderNestedHitObject {
1496
+ constructor(values) {
1497
+ super(Object.assign(Object.assign({}, values), { spanIndex: 0, spanStartTime: values.startTime }));
1498
+ }
1499
+ }
1500
+
1501
+ /**
1502
+ * An empty `HitWindow` that does not have any hit windows.
1503
+ *
1504
+ * No time values are provided (meaning instantaneous hit or miss).
1505
+ */
1506
+ class EmptyHitWindow extends HitWindow {
1507
+ constructor() {
1508
+ super(0);
1509
+ }
1510
+ get greatWindow() {
1511
+ return 0;
1512
+ }
1513
+ get okWindow() {
1514
+ return 0;
1515
+ }
1516
+ get mehWindow() {
1517
+ return 0;
1518
+ }
1519
+ }
1520
+
1521
+ /**
1522
+ * Represents a nested hit object that is at the end of a slider path (either repeat or tail).
1373
1523
  */
1374
1524
  class SliderEndCircle extends SliderNestedHitObject {
1375
1525
  constructor(values) {
@@ -1941,799 +2091,75 @@ Slider.baseTickSample = new BankHitSampleInfo("slidertick");
1941
2091
  Slider.legacyLastTickOffset = 36;
1942
2092
 
1943
2093
  /**
1944
- * Represents the osu! playfield.
1945
- */
1946
- class Playfield {
1947
- }
1948
- /**
1949
- * The size of the playfield, which is 512x384.
1950
- */
1951
- Playfield.baseSize = new Vector2(512, 384);
1952
-
1953
- /**
1954
- * Represents a spinner in a beatmap.
1955
- *
1956
- * All we need from spinners is their duration. The
1957
- * position of a spinner is always at 256x192.
2094
+ * Represents the Difficulty Adjust mod.
1958
2095
  */
1959
- class Spinner extends HitObject {
1960
- get endTime() {
1961
- return this._endTime;
2096
+ class ModDifficultyAdjust extends Mod {
2097
+ get isRelevant() {
2098
+ return (this.cs !== undefined ||
2099
+ this.ar !== undefined ||
2100
+ this.od !== undefined ||
2101
+ this.hp !== undefined);
1962
2102
  }
1963
2103
  constructor(values) {
1964
- super(Object.assign(Object.assign({}, values), { position: Playfield.baseSize.divide(2) }));
1965
- this._endTime = values.endTime;
1966
- }
1967
- applySamples(controlPoints) {
1968
- super.applySamples(controlPoints);
1969
- const samplePoints = controlPoints.sample.between(this.startTime + HitObject.controlPointLeniency, this.endTime + HitObject.controlPointLeniency);
1970
- this.auxiliarySamples.length = 0;
1971
- this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerSpinSample)))));
1972
- this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerBonusSample)))));
1973
- }
1974
- getStackedPosition() {
1975
- return this.position;
2104
+ super();
2105
+ this.acronym = "DA";
2106
+ this.name = "Difficulty Adjust";
2107
+ this.droidRanked = false;
2108
+ this.osuRanked = false;
2109
+ this.cs = values === null || values === void 0 ? void 0 : values.cs;
2110
+ this.ar = values === null || values === void 0 ? void 0 : values.ar;
2111
+ this.od = values === null || values === void 0 ? void 0 : values.od;
2112
+ this.hp = values === null || values === void 0 ? void 0 : values.hp;
1976
2113
  }
1977
- getStackedEndPosition() {
1978
- return this.position;
2114
+ copySettings(mod) {
2115
+ var _a, _b, _c, _d;
2116
+ super.copySettings(mod);
2117
+ this.cs = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.cs;
2118
+ this.ar = (_b = mod.settings) === null || _b === void 0 ? void 0 : _b.ar;
2119
+ this.od = (_c = mod.settings) === null || _c === void 0 ? void 0 : _c.od;
2120
+ this.hp = (_d = mod.settings) === null || _d === void 0 ? void 0 : _d.hp;
1979
2121
  }
1980
- createHitWindow() {
1981
- return new EmptyHitWindow();
2122
+ get isDroidRelevant() {
2123
+ return this.isRelevant;
1982
2124
  }
1983
- toString() {
1984
- return `Position: [${this._position.x}, ${this._position.y}], duration: ${this.duration}`;
2125
+ calculateDroidScoreMultiplier(difficulty) {
2126
+ // Graph: https://www.desmos.com/calculator/yrggkhrkzz
2127
+ let multiplier = 1;
2128
+ if (this.cs !== undefined) {
2129
+ const diff = this.cs - difficulty.cs;
2130
+ multiplier *=
2131
+ diff >= 0
2132
+ ? 1 + 0.0075 * Math.pow(diff, 1.5)
2133
+ : 2 / (1 + Math.exp(-0.5 * diff));
2134
+ }
2135
+ if (this.od !== undefined) {
2136
+ const diff = this.od - difficulty.od;
2137
+ multiplier *=
2138
+ diff >= 0
2139
+ ? 1 + 0.005 * Math.pow(diff, 1.3)
2140
+ : 2 / (1 + Math.exp(-0.25 * diff));
2141
+ }
2142
+ return multiplier;
1985
2143
  }
1986
- }
1987
- Spinner.baseSpinnerSpinSample = new BankHitSampleInfo("spinnerspin");
1988
- Spinner.baseSpinnerBonusSample = new BankHitSampleInfo("spinnerbonus");
1989
-
1990
- /**
1991
- * Contains information about hit objects of a beatmap.
1992
- */
1993
- class BeatmapHitObjects {
1994
- constructor() {
1995
- this._objects = [];
1996
- this._circles = 0;
1997
- this._sliders = 0;
1998
- this._spinners = 0;
2144
+ get isOsuRelevant() {
2145
+ return this.isRelevant;
1999
2146
  }
2000
- /**
2001
- * The objects of the beatmap.
2002
- */
2003
- get objects() {
2004
- return this._objects;
2147
+ get osuScoreMultiplier() {
2148
+ return 0.5;
2005
2149
  }
2006
- /**
2007
- * The amount of circles in the beatmap.
2008
- */
2009
- get circles() {
2010
- return this._circles;
2011
- }
2012
- /**
2013
- * The amount of sliders in the beatmap.
2014
- */
2015
- get sliders() {
2016
- return this._sliders;
2017
- }
2018
- /**
2019
- * The amount of spinners in the beatmap.
2020
- */
2021
- get spinners() {
2022
- return this._spinners;
2023
- }
2024
- /**
2025
- * The amount of slider ticks in the beatmap.
2026
- *
2027
- * This iterates through all objects and should be stored locally or used sparingly.
2028
- */
2029
- get sliderTicks() {
2030
- return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.ticks : acc), 0);
2031
- }
2032
- /**
2033
- * The amount of sliderends in the beatmap.
2034
- */
2035
- get sliderEnds() {
2036
- return this.sliders;
2037
- }
2038
- /**
2039
- * The amount of slider repeat points in the beatmap.
2040
- *
2041
- * This iterates through all objects and should be stored locally or used sparingly.
2042
- */
2043
- get sliderRepeatPoints() {
2044
- return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.repeatCount : acc), 0);
2045
- }
2046
- /**
2047
- * Adds hitobjects.
2048
- *
2049
- * The sorting order of hitobjects will be maintained.
2050
- *
2051
- * @param objects The hitobjects to add.
2052
- */
2053
- add(...objects) {
2054
- for (const object of objects) {
2055
- // Objects may be out of order *only* if a user has manually edited an .osu file.
2056
- // Unfortunately there are "ranked" maps in this state (example: https://osu.ppy.sh/s/594828).
2057
- // Finding index is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted).
2058
- this._objects.splice(this.findInsertionIndex(object.startTime), 0, object);
2059
- if (object instanceof Circle) {
2060
- ++this._circles;
2061
- }
2062
- else if (object instanceof Slider) {
2063
- ++this._sliders;
2064
- }
2065
- else {
2066
- ++this._spinners;
2067
- }
2068
- }
2069
- }
2070
- /**
2071
- * Removes a hitobject at an index.
2072
- *
2073
- * @param index The index of the hitobject to remove.
2074
- * @returns The hitobject that was removed, `null` if no hitobject was removed.
2075
- */
2076
- removeAt(index) {
2077
- var _a;
2078
- const object = (_a = this._objects.splice(index, 1)[0]) !== null && _a !== void 0 ? _a : null;
2079
- if (object instanceof Circle) {
2080
- --this._circles;
2081
- }
2082
- else if (object instanceof Slider) {
2083
- --this._sliders;
2084
- }
2085
- else if (object instanceof Spinner) {
2086
- --this._spinners;
2087
- }
2088
- return object;
2089
- }
2090
- /**
2091
- * Clears all hitobjects.
2092
- */
2093
- clear() {
2094
- this._objects.length = 0;
2095
- this._circles = 0;
2096
- this._sliders = 0;
2097
- this._spinners = 0;
2098
- }
2099
- /**
2100
- * Finds the insertion index of a hitobject in a given time.
2101
- *
2102
- * @param startTime The start time of the hitobject.
2103
- */
2104
- findInsertionIndex(startTime) {
2105
- if (this._objects.length === 0 ||
2106
- startTime < this._objects[0].startTime) {
2107
- return 0;
2108
- }
2109
- if (startTime >= this._objects.at(-1).startTime) {
2110
- return this._objects.length;
2111
- }
2112
- let l = 0;
2113
- let r = this._objects.length - 2;
2114
- while (l <= r) {
2115
- const pivot = l + ((r - l) >> 1);
2116
- if (this._objects[pivot].startTime < startTime) {
2117
- l = pivot + 1;
2118
- }
2119
- else if (this._objects[pivot].startTime > startTime) {
2120
- r = pivot - 1;
2121
- }
2122
- else {
2123
- return pivot;
2124
- }
2125
- }
2126
- return l;
2127
- }
2128
- }
2129
-
2130
- /**
2131
- * Converts a beatmap for another mode.
2132
- */
2133
- class BeatmapConverter {
2134
- constructor(beatmap) {
2135
- this.beatmap = beatmap;
2136
- }
2137
- /**
2138
- * Converts the beatmap.
2139
- *
2140
- * @returns The converted beatmap.
2141
- */
2142
- convert() {
2143
- const converted = new Beatmap(this.beatmap);
2144
- // Shallow clone isn't enough to ensure we don't mutate some beatmap properties unexpectedly.
2145
- converted.difficulty = new BeatmapDifficulty(this.beatmap.difficulty);
2146
- converted.hitObjects = this.convertHitObjects();
2147
- return converted;
2148
- }
2149
- convertHitObjects() {
2150
- const hitObjects = new BeatmapHitObjects();
2151
- this.beatmap.hitObjects.objects.forEach((hitObject) => {
2152
- hitObjects.add(this.convertHitObject(hitObject));
2153
- });
2154
- return hitObjects;
2155
- }
2156
- convertHitObject(hitObject) {
2157
- let object;
2158
- if (hitObject instanceof Circle) {
2159
- object = new Circle({
2160
- startTime: hitObject.startTime,
2161
- position: hitObject.position,
2162
- newCombo: hitObject.isNewCombo,
2163
- type: hitObject.type,
2164
- comboOffset: hitObject.comboOffset,
2165
- });
2166
- }
2167
- else if (hitObject instanceof Slider) {
2168
- object = new Slider({
2169
- startTime: hitObject.startTime,
2170
- position: hitObject.position,
2171
- newCombo: hitObject.isNewCombo,
2172
- type: hitObject.type,
2173
- path: hitObject.path,
2174
- repeatCount: hitObject.repeatCount,
2175
- nodeSamples: hitObject.nodeSamples,
2176
- comboOffset: hitObject.comboOffset,
2177
- tickDistanceMultiplier:
2178
- // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
2179
- // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
2180
- this.beatmap.formatVersion < 8
2181
- ? 1 /
2182
- this.beatmap.controlPoints.difficulty.controlPointAt(hitObject.startTime).speedMultiplier
2183
- : 1,
2184
- });
2185
- }
2186
- else {
2187
- object = new Spinner({
2188
- startTime: hitObject.startTime,
2189
- endTime: hitObject.endTime,
2190
- type: hitObject.type,
2191
- });
2192
- }
2193
- object.samples = hitObject.samples;
2194
- object.auxiliarySamples = hitObject.auxiliarySamples;
2195
- return object;
2196
- }
2197
- }
2198
-
2199
- /**
2200
- * Provides functionality to alter a beatmap after it has been converted.
2201
- */
2202
- class BeatmapProcessor {
2203
- constructor(beatmap) {
2204
- this.beatmap = beatmap;
2205
- }
2206
- /**
2207
- * Processes the converted beatmap prior to `HitObject.applyDefaults` being invoked.
2208
- *
2209
- * Nested hitobjects generated during `HitObject.applyDefaults` will not be present by this point,
2210
- * and no mods will have been applied to the hitobjects.
2211
- *
2212
- * This can only be used to add alterations to hitobjects generated directly through the conversion process.
2213
- */
2214
- preProcess() {
2215
- let last = null;
2216
- for (const object of this.beatmap.hitObjects.objects) {
2217
- object.updateComboInformation(last);
2218
- last = object;
2219
- }
2220
- // Mark the last object in the beatmap as last in combo.
2221
- if (last) {
2222
- last.isLastInCombo = true;
2223
- }
2224
- }
2225
- /**
2226
- * Processes the converted beatmap after `HitObject.applyDefaults` has been invoked.
2227
- *
2228
- * Nested hitobjects generated during `HitObject.applyDefaults` wil be present by this point,
2229
- * and mods will have been applied to all hitobjects.
2230
- *
2231
- * This should be used to add alterations to hitobjects while they are in their most playable state.
2232
- *
2233
- * @param mode The mode to add alterations for.
2234
- */
2235
- postProcess(mode) {
2236
- const objects = this.beatmap.hitObjects.objects;
2237
- if (objects.length === 0) {
2238
- return;
2239
- }
2240
- // Reset stacking
2241
- objects.forEach((h) => {
2242
- h.stackHeight = 0;
2243
- });
2244
- switch (mode) {
2245
- case exports.Modes.droid:
2246
- this.applyDroidStacking();
2247
- break;
2248
- case exports.Modes.osu:
2249
- if (this.beatmap.formatVersion >= 6) {
2250
- this.applyStandardStacking();
2251
- }
2252
- else {
2253
- this.applyStandardOldStacking();
2254
- }
2255
- break;
2256
- }
2257
- }
2258
- applyDroidStacking() {
2259
- const objects = this.beatmap.hitObjects.objects;
2260
- if (objects.length === 0) {
2261
- return;
2262
- }
2263
- const convertedScale = CircleSizeCalculator.standardScaleToDroidScale(objects[0].scale);
2264
- for (let i = 0; i < objects.length - 1; ++i) {
2265
- const current = objects[i];
2266
- const next = objects[i + 1];
2267
- if (current instanceof Circle &&
2268
- next.startTime - current.startTime <
2269
- 2000 * this.beatmap.general.stackLeniency &&
2270
- next.position.getDistance(current.position) <
2271
- Math.sqrt(convertedScale)) {
2272
- next.stackHeight = current.stackHeight + 1;
2273
- }
2274
- }
2275
- }
2276
- applyStandardStacking() {
2277
- const objects = this.beatmap.hitObjects.objects;
2278
- const startIndex = 0;
2279
- const endIndex = objects.length - 1;
2280
- let extendedEndIndex = endIndex;
2281
- if (endIndex < objects.length - 1) {
2282
- for (let i = endIndex; i >= startIndex; --i) {
2283
- let stackBaseIndex = i;
2284
- for (let n = stackBaseIndex + 1; n < objects.length; ++n) {
2285
- const stackBaseObject = objects[stackBaseIndex];
2286
- if (stackBaseObject instanceof Spinner) {
2287
- break;
2288
- }
2289
- const objectN = objects[n];
2290
- if (objectN instanceof Spinner) {
2291
- break;
2292
- }
2293
- const stackThreshold = objectN.timePreempt *
2294
- this.beatmap.general.stackLeniency;
2295
- if (objectN.startTime - stackBaseObject.endTime >
2296
- stackThreshold) {
2297
- // We are no longer within stacking range of the next object.
2298
- break;
2299
- }
2300
- const endPositionDistanceCheck = stackBaseObject instanceof Slider
2301
- ? stackBaseObject.endPosition.getDistance(objectN.position) < BeatmapProcessor.stackDistance
2302
- : false;
2303
- if (stackBaseObject.position.getDistance(objectN.position) <
2304
- BeatmapProcessor.stackDistance ||
2305
- endPositionDistanceCheck) {
2306
- stackBaseIndex = n;
2307
- // Hit objects after the specified update range haven't been reset yet
2308
- objectN.stackHeight = 0;
2309
- }
2310
- }
2311
- if (stackBaseIndex > extendedEndIndex) {
2312
- extendedEndIndex = stackBaseIndex;
2313
- if (extendedEndIndex === objects.length - 1) {
2314
- break;
2315
- }
2316
- }
2317
- }
2318
- }
2319
- // Reverse pass for stack calculation.
2320
- let extendedStartIndex = startIndex;
2321
- for (let i = extendedEndIndex; i > startIndex; --i) {
2322
- let n = i;
2323
- // We should check every note which has not yet got a stack.
2324
- // Consider the case we have two inter-wound stacks and this will make sense.
2325
- //
2326
- // o <-1 o <-2
2327
- // o <-3 o <-4
2328
- //
2329
- // We first process starting from 4 and handle 2,
2330
- // then we come backwards on the i loop iteration until we reach 3 and handle 1.
2331
- // 2 and 1 will be ignored in the i loop because they already have a stack value.
2332
- let objectI = objects[i];
2333
- if (objectI.stackHeight !== 0 || objectI instanceof Spinner) {
2334
- continue;
2335
- }
2336
- const stackThreshold = objectI.timePreempt * this.beatmap.general.stackLeniency;
2337
- // If this object is a hit circle, then we enter this "special" case.
2338
- // It either ends with a stack of hit circles only, or a stack of hit circles that are underneath a slider.
2339
- // Any other case is handled by the "instanceof Slider" code below this.
2340
- if (objectI instanceof Circle) {
2341
- while (--n >= 0) {
2342
- const objectN = objects[n];
2343
- if (objectN instanceof Spinner) {
2344
- continue;
2345
- }
2346
- if (objectI.startTime - objectN.endTime > stackThreshold) {
2347
- // We are no longer within stacking range of the previous object.
2348
- break;
2349
- }
2350
- // Hit objects before the specified update range haven't been reset yet
2351
- if (n < extendedStartIndex) {
2352
- objectN.stackHeight = 0;
2353
- extendedStartIndex = n;
2354
- }
2355
- // This is a special case where hit circles are moved DOWN and RIGHT (negative stacking) if they are under the *last* slider in a stacked pattern.
2356
- // o==o <- slider is at original location
2357
- // o <- hitCircle has stack of -1
2358
- // o <- hitCircle has stack of -2
2359
- if (objectN instanceof Slider &&
2360
- objectN.endPosition.getDistance(objectI.position) <
2361
- BeatmapProcessor.stackDistance) {
2362
- const offset = objectI.stackHeight - objectN.stackHeight + 1;
2363
- for (let j = n + 1; j <= i; ++j) {
2364
- // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
2365
- const objectJ = objects[j];
2366
- if (objectN.endPosition.getDistance(objectJ.position) < BeatmapProcessor.stackDistance) {
2367
- objectJ.stackHeight -= offset;
2368
- }
2369
- }
2370
- // We have hit a slider. We should restart calculation using this as the new base.
2371
- // Breaking here will mean that the slider still has a stack count of 0, so will be handled in the i-outer-loop.
2372
- break;
2373
- }
2374
- if (objectN.position.getDistance(objectI.position) <
2375
- BeatmapProcessor.stackDistance) {
2376
- // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out.
2377
- // NOTE: Sliders with start positions stacking are a special case that is also handled here.
2378
- objectN.stackHeight = objectI.stackHeight + 1;
2379
- objectI = objectN;
2380
- }
2381
- }
2382
- }
2383
- else if (objectI instanceof Slider) {
2384
- // We have hit the first slider in a possible stack.
2385
- // From this point on, we ALWAYS stack positive regardless.
2386
- while (--n >= startIndex) {
2387
- const objectN = objects[n];
2388
- if (objectN instanceof Spinner) {
2389
- continue;
2390
- }
2391
- if (objectI.startTime - objectN.startTime >
2392
- stackThreshold) {
2393
- // We are no longer within stacking range of the previous object.
2394
- break;
2395
- }
2396
- if (objectN.endPosition.getDistance(objectI.position) <
2397
- BeatmapProcessor.stackDistance) {
2398
- objectN.stackHeight = objectI.stackHeight + 1;
2399
- objectI = objectN;
2400
- }
2401
- }
2402
- }
2403
- }
2404
- }
2405
- applyStandardOldStacking() {
2406
- const objects = this.beatmap.hitObjects.objects;
2407
- for (let i = 0; i < objects.length; ++i) {
2408
- const currentObject = objects[i];
2409
- if (currentObject.stackHeight !== 0 &&
2410
- !(currentObject instanceof Slider)) {
2411
- continue;
2412
- }
2413
- let startTime = currentObject.endTime;
2414
- let sliderStack = 0;
2415
- const stackThreshold = currentObject.timePreempt * this.beatmap.general.stackLeniency;
2416
- for (let j = i + 1; j < objects.length; ++j) {
2417
- if (objects[j].startTime - stackThreshold > startTime) {
2418
- break;
2419
- }
2420
- // Note the use of `startTime` in the code below doesn't match osu!stable's use of `endTime`.
2421
- // This is because in osu!stable's implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
2422
- // and therefore it does not have a correct `endTime`, but instead the default of `endTime = startTime`.
2423
- //
2424
- // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
2425
- // if we use `endTime` here it would result in unexpected stacking.
2426
- //
2427
- // Reference: https://github.com/ppy/osu/pull/24188
2428
- if (objects[j].position.getDistance(currentObject.position) <
2429
- BeatmapProcessor.stackDistance) {
2430
- ++currentObject.stackHeight;
2431
- startTime = objects[j].startTime;
2432
- }
2433
- else if (objects[j].position.getDistance(currentObject.endPosition) <
2434
- BeatmapProcessor.stackDistance) {
2435
- // Case for sliders - bump notes down and right, rather than up and left.
2436
- ++sliderStack;
2437
- objects[j].stackHeight -= sliderStack;
2438
- startTime = objects[j].startTime;
2439
- }
2440
- }
2441
- }
2442
- }
2443
- }
2444
- BeatmapProcessor.stackDistance = 3;
2445
-
2446
- /**
2447
- * Represents the hit window of osu!droid _with_ the Precise mod.
2448
- */
2449
- class PreciseDroidHitWindow extends HitWindow {
2450
- /**
2451
- * Calculates the overall difficulty value of a great (300) hit window.
2452
- *
2453
- * @param value The value of the hit window, in milliseconds.
2454
- * @returns The overall difficulty value.
2455
- */
2456
- static greatWindowToOD(value) {
2457
- return 5 - (value - 55) / 6;
2458
- }
2459
- /**
2460
- * Calculates the overall difficulty value of a good (100) hit window.
2461
- *
2462
- * @param value The value of the hit window, in milliseconds.
2463
- * @returns The overall difficulty value.
2464
- */
2465
- static okWindowToOD(value) {
2466
- return 5 - (value - 120) / 8;
2467
- }
2468
- /**
2469
- * Calculates the overall difficulty value of a meh (50) hit window.
2470
- *
2471
- * @param value The value of the hit window, in milliseconds.
2472
- * @returns The overall difficulty value.
2473
- */
2474
- static mehWindowToOD(value) {
2475
- return 5 - (value - 180) / 10;
2476
- }
2477
- get greatWindow() {
2478
- return 55 + 6 * (5 - this.overallDifficulty);
2479
- }
2480
- get okWindow() {
2481
- return 120 + 8 * (5 - this.overallDifficulty);
2482
- }
2483
- get mehWindow() {
2484
- return 180 + 10 * (5 - this.overallDifficulty);
2485
- }
2486
- }
2487
-
2488
- /**
2489
- * Represents the Precise mod.
2490
- */
2491
- class ModPrecise extends Mod {
2492
- constructor() {
2493
- super(...arguments);
2494
- this.acronym = "PR";
2495
- this.name = "Precise";
2496
- this.droidRanked = true;
2497
- }
2498
- get isDroidRelevant() {
2499
- return true;
2500
- }
2501
- calculateDroidScoreMultiplier() {
2502
- return 1.06;
2503
- }
2504
- applyToHitObject(mode, hitObject) {
2505
- var _a, _b;
2506
- if (mode !== exports.Modes.droid || hitObject instanceof Spinner) {
2507
- return;
2508
- }
2509
- if (hitObject instanceof Slider) {
2510
- // For sliders, the hit window is enforced in the head - everything else is an instant hit or miss.
2511
- hitObject.head.hitWindow = new PreciseDroidHitWindow((_a = hitObject.head.hitWindow) === null || _a === void 0 ? void 0 : _a.overallDifficulty);
2512
- }
2513
- else {
2514
- hitObject.hitWindow = new PreciseDroidHitWindow((_b = hitObject.hitWindow) === null || _b === void 0 ? void 0 : _b.overallDifficulty);
2515
- }
2516
- }
2517
- }
2518
-
2519
- /**
2520
- * Represents the Relax mod.
2521
- */
2522
- class ModRelax extends Mod {
2523
- constructor() {
2524
- super();
2525
- this.acronym = "RX";
2526
- this.name = "Relax";
2527
- this.droidRanked = false;
2528
- this.osuRanked = false;
2529
- this.bitwise = 1 << 7;
2530
- this.incompatibleMods.add(ModAuto).add(ModAutopilot);
2531
- }
2532
- get isDroidRelevant() {
2533
- return true;
2534
- }
2535
- calculateDroidScoreMultiplier() {
2536
- return 0.001;
2537
- }
2538
- get isOsuRelevant() {
2539
- return true;
2540
- }
2541
- get osuScoreMultiplier() {
2542
- return 0;
2543
- }
2544
- }
2545
-
2546
- /**
2547
- * Represents the Autopilot mod.
2548
- */
2549
- class ModAutopilot extends Mod {
2550
- constructor() {
2551
- super();
2552
- this.acronym = "AP";
2553
- this.name = "Autopilot";
2554
- this.droidRanked = false;
2555
- this.osuRanked = false;
2556
- this.bitwise = 1 << 13;
2557
- this.incompatibleMods.add(ModRelax).add(ModAuto);
2558
- }
2559
- get isDroidRelevant() {
2560
- return true;
2561
- }
2562
- calculateDroidScoreMultiplier() {
2563
- return 0.001;
2564
- }
2565
- get isOsuRelevant() {
2566
- return true;
2567
- }
2568
- get osuScoreMultiplier() {
2569
- return 0;
2570
- }
2571
- }
2572
-
2573
- /**
2574
- * Represents the Auto mod.
2575
- */
2576
- class ModAuto extends Mod {
2577
- constructor() {
2578
- super();
2579
- this.acronym = "AT";
2580
- this.name = "Autoplay";
2581
- this.droidRanked = false;
2582
- this.osuRanked = false;
2583
- this.bitwise = 1 << 11;
2584
- this.incompatibleMods.add(ModAutopilot).add(ModRelax);
2585
- }
2586
- get isDroidRelevant() {
2587
- return true;
2588
- }
2589
- calculateDroidScoreMultiplier() {
2590
- return 1;
2591
- }
2592
- get isOsuRelevant() {
2593
- return true;
2594
- }
2595
- get osuScoreMultiplier() {
2596
- return 1;
2597
- }
2598
- }
2599
-
2600
- /**
2601
- * Represents a `Mod` that adjusts the playback rate of a track.
2602
- */
2603
- class ModRateAdjust extends Mod {
2604
- /**
2605
- * The generic osu!droid score multiplier of this `Mod`.
2606
- */
2607
- get droidScoreMultiplier() {
2608
- return this.trackRateMultiplier >= 1
2609
- ? 1 + (this.trackRateMultiplier - 1) * 0.24
2610
- : Math.pow(0.3, (1 - this.trackRateMultiplier) * 4);
2611
- }
2612
- /**
2613
- * Generic getter to determine if this `ModRateAdjust` is relevant.
2614
- */
2615
- get isRelevant() {
2616
- return this.trackRateMultiplier !== 1;
2617
- }
2618
- applyToRate(time, rate) {
2619
- return rate * this.trackRateMultiplier;
2620
- }
2621
- }
2622
-
2623
- /**
2624
- * Represents the Custom Speed mod.
2625
- *
2626
- * This is a replacement `Mod` for speed modify in osu!droid and custom rates in osu!lazer.
2627
- */
2628
- class ModCustomSpeed extends ModRateAdjust {
2629
- constructor(trackRateMultiplier = 1) {
2630
- super();
2631
- this.acronym = "CS";
2632
- this.name = "Custom Speed";
2633
- this.droidRanked = true;
2634
- this.osuRanked = false;
2635
- this.trackRateMultiplier = trackRateMultiplier;
2636
- }
2637
- copySettings(mod) {
2638
- var _a, _b;
2639
- super.copySettings(mod);
2640
- this.trackRateMultiplier =
2641
- (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.rateMultiplier) !== null && _b !== void 0 ? _b : this.trackRateMultiplier;
2642
- }
2643
- get isDroidRelevant() {
2644
- return this.isRelevant;
2645
- }
2646
- calculateDroidScoreMultiplier() {
2647
- return this.droidScoreMultiplier;
2648
- }
2649
- get isOsuRelevant() {
2650
- return this.isRelevant;
2651
- }
2652
- get osuScoreMultiplier() {
2653
- // Round to the nearest multiple of 0.1.
2654
- let value = Math.trunc(this.trackRateMultiplier * 10) / 10;
2655
- // Offset back to 0.
2656
- --value;
2657
- return this.trackRateMultiplier >= 1 ? 1 + value / 5 : 0.6 + value;
2658
- }
2659
- serializeSettings() {
2660
- return { rateMultiplier: this.trackRateMultiplier };
2661
- }
2662
- toString() {
2663
- return `${super.toString()} (${this.trackRateMultiplier.toFixed(2)}x)`;
2664
- }
2665
- }
2666
-
2667
- /**
2668
- * Represents the Difficulty Adjust mod.
2669
- */
2670
- class ModDifficultyAdjust extends Mod {
2671
- get isRelevant() {
2672
- return (this.cs !== undefined ||
2673
- this.ar !== undefined ||
2674
- this.od !== undefined ||
2675
- this.hp !== undefined);
2676
- }
2677
- constructor(values) {
2678
- super();
2679
- this.acronym = "DA";
2680
- this.name = "Difficulty Adjust";
2681
- this.droidRanked = false;
2682
- this.osuRanked = false;
2683
- this.cs = values === null || values === void 0 ? void 0 : values.cs;
2684
- this.ar = values === null || values === void 0 ? void 0 : values.ar;
2685
- this.od = values === null || values === void 0 ? void 0 : values.od;
2686
- this.hp = values === null || values === void 0 ? void 0 : values.hp;
2687
- }
2688
- copySettings(mod) {
2689
- var _a, _b, _c, _d;
2690
- super.copySettings(mod);
2691
- this.cs = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.cs;
2692
- this.ar = (_b = mod.settings) === null || _b === void 0 ? void 0 : _b.ar;
2693
- this.od = (_c = mod.settings) === null || _c === void 0 ? void 0 : _c.od;
2694
- this.hp = (_d = mod.settings) === null || _d === void 0 ? void 0 : _d.hp;
2695
- }
2696
- get isDroidRelevant() {
2697
- return this.isRelevant;
2698
- }
2699
- calculateDroidScoreMultiplier(difficulty) {
2700
- // Graph: https://www.desmos.com/calculator/yrggkhrkzz
2701
- let multiplier = 1;
2702
- if (this.cs !== undefined) {
2703
- const diff = this.cs - difficulty.cs;
2704
- multiplier *=
2705
- diff >= 0
2706
- ? 1 + 0.0075 * Math.pow(diff, 1.5)
2707
- : 2 / (1 + Math.exp(-0.5 * diff));
2708
- }
2709
- if (this.od !== undefined) {
2710
- const diff = this.od - difficulty.od;
2711
- multiplier *=
2712
- diff >= 0
2713
- ? 1 + 0.005 * Math.pow(diff, 1.3)
2714
- : 2 / (1 + Math.exp(-0.25 * diff));
2715
- }
2716
- return multiplier;
2717
- }
2718
- get isOsuRelevant() {
2719
- return this.isRelevant;
2720
- }
2721
- get osuScoreMultiplier() {
2722
- return 0.5;
2723
- }
2724
- applyToDifficultyWithSettings(_, difficulty, mods) {
2725
- var _a, _b, _c, _d;
2726
- difficulty.cs = (_a = this.cs) !== null && _a !== void 0 ? _a : difficulty.cs;
2727
- difficulty.ar = (_b = this.ar) !== null && _b !== void 0 ? _b : difficulty.ar;
2728
- difficulty.od = (_c = this.od) !== null && _c !== void 0 ? _c : difficulty.od;
2729
- difficulty.hp = (_d = this.hp) !== null && _d !== void 0 ? _d : difficulty.hp;
2730
- // Special case for force AR, where the AR value is kept constant with respect to game time.
2731
- // This makes the player perceive the AR as is under all speed multipliers.
2732
- if (this.ar !== undefined) {
2733
- const preempt = BeatmapDifficulty.difficultyRange(this.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2734
- const trackRate = this.calculateTrackRate(mods);
2735
- difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt * trackRate, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2736
- }
2150
+ applyToDifficultyWithSettings(_, difficulty, mods) {
2151
+ var _a, _b, _c, _d;
2152
+ difficulty.cs = (_a = this.cs) !== null && _a !== void 0 ? _a : difficulty.cs;
2153
+ difficulty.ar = (_b = this.ar) !== null && _b !== void 0 ? _b : difficulty.ar;
2154
+ difficulty.od = (_c = this.od) !== null && _c !== void 0 ? _c : difficulty.od;
2155
+ difficulty.hp = (_d = this.hp) !== null && _d !== void 0 ? _d : difficulty.hp;
2156
+ // Special case for force AR, where the AR value is kept constant with respect to game time.
2157
+ // This makes the player perceive the AR as is under all speed multipliers.
2158
+ if (this.ar !== undefined) {
2159
+ const preempt = BeatmapDifficulty.difficultyRange(this.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2160
+ const trackRate = this.calculateTrackRate(mods.values());
2161
+ difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt * trackRate, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
2162
+ }
2737
2163
  }
2738
2164
  applyToHitObjectWithSettings(_, hitObject, mods) {
2739
2165
  // Special case for force AR, where the AR value is kept constant with respect to game time.
@@ -2772,8 +2198,8 @@ class ModDifficultyAdjust extends Mod {
2772
2198
  }
2773
2199
  applyFadeAdjustment(hitObject, mods) {
2774
2200
  // IMPORTANT: These do not use `ModUtil.calculateRateWithMods` to avoid circular dependency.
2775
- const initialTrackRate = this.calculateTrackRate(mods);
2776
- const currentTrackRate = this.calculateTrackRate(mods, hitObject.startTime);
2201
+ const initialTrackRate = this.calculateTrackRate(mods.values());
2202
+ const currentTrackRate = this.calculateTrackRate(mods.values(), hitObject.startTime);
2777
2203
  // Cancel the rate that was initially applied to timePreempt (via applyToDifficulty above and
2778
2204
  // HitObject.applyDefaults) and apply the current one.
2779
2205
  hitObject.timePreempt *= currentTrackRate / initialTrackRate;
@@ -2781,9 +2207,13 @@ class ModDifficultyAdjust extends Mod {
2781
2207
  }
2782
2208
  calculateTrackRate(mods, time = 0) {
2783
2209
  // IMPORTANT: This does not use `ModUtil.calculateRateWithMods` to avoid circular dependency.
2784
- return mods.reduce((rate, mod) => mod.isApplicableToTrackRate()
2785
- ? mod.applyToRate(time, rate)
2786
- : rate, 1);
2210
+ let rate = 1;
2211
+ for (const mod of mods) {
2212
+ if (mod.isApplicableToTrackRate()) {
2213
+ rate = mod.applyToRate(time, rate);
2214
+ }
2215
+ }
2216
+ return rate;
2787
2217
  }
2788
2218
  toString() {
2789
2219
  const settings = [];
@@ -2887,6 +2317,16 @@ class ModDoubleTime extends ModRateAdjust {
2887
2317
  }
2888
2318
  }
2889
2319
 
2320
+ /**
2321
+ * Represents the osu! playfield.
2322
+ */
2323
+ class Playfield {
2324
+ }
2325
+ /**
2326
+ * The size of the playfield, which is 512x384.
2327
+ */
2328
+ Playfield.baseSize = new Vector2(512, 384);
2329
+
2890
2330
  /**
2891
2331
  * Types of slider paths.
2892
2332
  */
@@ -3430,86 +2870,295 @@ class SliderPath {
3430
2870
  }
3431
2871
 
3432
2872
  /**
3433
- * Represents the HardRock mod.
2873
+ * Represents the HardRock mod.
2874
+ */
2875
+ class ModHardRock extends Mod {
2876
+ constructor() {
2877
+ super();
2878
+ this.acronym = "HR";
2879
+ this.name = "HardRock";
2880
+ this.droidRanked = true;
2881
+ this.osuRanked = true;
2882
+ this.bitwise = 1 << 4;
2883
+ this.incompatibleMods.add(ModEasy);
2884
+ }
2885
+ get isDroidRelevant() {
2886
+ return true;
2887
+ }
2888
+ calculateDroidScoreMultiplier() {
2889
+ return 1.06;
2890
+ }
2891
+ get isOsuRelevant() {
2892
+ return true;
2893
+ }
2894
+ get osuScoreMultiplier() {
2895
+ return 1.06;
2896
+ }
2897
+ applyToDifficulty(mode, difficulty) {
2898
+ switch (mode) {
2899
+ case exports.Modes.droid: {
2900
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
2901
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale - 0.125);
2902
+ break;
2903
+ }
2904
+ case exports.Modes.osu:
2905
+ // CS uses a custom 1.3 ratio.
2906
+ difficulty.cs = this.applySetting(difficulty.cs, 1.3);
2907
+ break;
2908
+ }
2909
+ difficulty.ar = this.applySetting(difficulty.ar);
2910
+ difficulty.od = this.applySetting(difficulty.od);
2911
+ difficulty.hp = this.applySetting(difficulty.hp);
2912
+ }
2913
+ applyToHitObject(_, hitObject) {
2914
+ // Reflect the position of the hit object.
2915
+ hitObject.position = this.reflectVector(hitObject.position);
2916
+ if (!(hitObject instanceof Slider)) {
2917
+ return;
2918
+ }
2919
+ // Reflect the control points of the slider. This will reflect the positions of head and tail circles.
2920
+ hitObject.path = new SliderPath({
2921
+ pathType: hitObject.path.pathType,
2922
+ controlPoints: hitObject.path.controlPoints.map((v) => this.reflectControlPoint(v)),
2923
+ expectedDistance: hitObject.path.expectedDistance,
2924
+ });
2925
+ // Reflect the position of slider ticks and repeats.
2926
+ hitObject.nestedHitObjects.slice(1, -1).forEach((obj) => {
2927
+ obj.position = this.reflectVector(obj.position);
2928
+ });
2929
+ }
2930
+ reflectVector(vector) {
2931
+ return new Vector2(vector.x, Playfield.baseSize.y - vector.y);
2932
+ }
2933
+ reflectControlPoint(vector) {
2934
+ return new Vector2(vector.x, -vector.y);
2935
+ }
2936
+ applySetting(value, ratio = 1.4) {
2937
+ return Math.min(value * ratio, 10);
2938
+ }
2939
+ }
2940
+
2941
+ /**
2942
+ * Represents the Easy mod.
2943
+ */
2944
+ class ModEasy extends Mod {
2945
+ constructor() {
2946
+ super();
2947
+ this.acronym = "EZ";
2948
+ this.name = "Easy";
2949
+ this.droidRanked = true;
2950
+ this.osuRanked = true;
2951
+ this.bitwise = 1 << 1;
2952
+ this.incompatibleMods.add(ModHardRock);
2953
+ }
2954
+ get isDroidRelevant() {
2955
+ return true;
2956
+ }
2957
+ calculateDroidScoreMultiplier() {
2958
+ return 0.5;
2959
+ }
2960
+ get isOsuRelevant() {
2961
+ return true;
2962
+ }
2963
+ get osuScoreMultiplier() {
2964
+ return 0.5;
2965
+ }
2966
+ applyToDifficulty(mode, difficulty) {
2967
+ switch (mode) {
2968
+ case exports.Modes.droid: {
2969
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
2970
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
2971
+ break;
2972
+ }
2973
+ case exports.Modes.osu:
2974
+ difficulty.cs /= 2;
2975
+ }
2976
+ difficulty.ar /= 2;
2977
+ difficulty.od /= 2;
2978
+ difficulty.hp /= 2;
2979
+ }
2980
+ }
2981
+
2982
+ /**
2983
+ * Represents the Flashlight mod.
2984
+ */
2985
+ class ModFlashlight extends Mod {
2986
+ constructor() {
2987
+ super(...arguments);
2988
+ this.acronym = "FL";
2989
+ this.name = "Flashlight";
2990
+ this.droidRanked = true;
2991
+ this.osuRanked = true;
2992
+ this.bitwise = 1 << 10;
2993
+ /**
2994
+ * The amount of seconds until the Flashlight follow area reaches the cursor.
2995
+ */
2996
+ this.followDelay = ModFlashlight.defaultFollowDelay;
2997
+ }
2998
+ copySettings(mod) {
2999
+ var _a, _b;
3000
+ super.copySettings(mod);
3001
+ this.followDelay =
3002
+ (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.areaFollowDelay) !== null && _b !== void 0 ? _b : this.followDelay;
3003
+ }
3004
+ get isDroidRelevant() {
3005
+ return true;
3006
+ }
3007
+ calculateDroidScoreMultiplier() {
3008
+ return 1.12;
3009
+ }
3010
+ get isOsuRelevant() {
3011
+ return true;
3012
+ }
3013
+ get osuScoreMultiplier() {
3014
+ return 1.12;
3015
+ }
3016
+ serializeSettings() {
3017
+ return { areaFollowDelay: this.followDelay };
3018
+ }
3019
+ toString() {
3020
+ if (this.followDelay === ModFlashlight.defaultFollowDelay) {
3021
+ return super.toString();
3022
+ }
3023
+ return `${super.toString()} (${this.followDelay.toFixed(2)}s follow delay)`;
3024
+ }
3025
+ }
3026
+ /**
3027
+ * The default amount of seconds until the Flashlight follow area reaches the cursor.
3028
+ */
3029
+ ModFlashlight.defaultFollowDelay = 0.12;
3030
+
3031
+ /**
3032
+ * Represents the Traceable mod.
3033
+ */
3034
+ class ModTraceable extends Mod {
3035
+ constructor() {
3036
+ super();
3037
+ this.acronym = "TC";
3038
+ this.name = "Traceable";
3039
+ this.droidRanked = false;
3040
+ this.osuRanked = false;
3041
+ this.incompatibleMods.add(ModHidden);
3042
+ }
3043
+ get isDroidRelevant() {
3044
+ return true;
3045
+ }
3046
+ calculateDroidScoreMultiplier() {
3047
+ return 1.06;
3048
+ }
3049
+ get isOsuRelevant() {
3050
+ return true;
3051
+ }
3052
+ get osuScoreMultiplier() {
3053
+ return 1;
3054
+ }
3055
+ }
3056
+
3057
+ /**
3058
+ * Represents the Hidden mod.
3059
+ */
3060
+ class ModHidden extends Mod {
3061
+ constructor() {
3062
+ super();
3063
+ this.acronym = "HD";
3064
+ this.name = "Hidden";
3065
+ this.droidRanked = true;
3066
+ this.osuRanked = true;
3067
+ this.bitwise = 1 << 3;
3068
+ this.incompatibleMods.add(ModTraceable);
3069
+ }
3070
+ get isDroidRelevant() {
3071
+ return true;
3072
+ }
3073
+ calculateDroidScoreMultiplier() {
3074
+ return 1.06;
3075
+ }
3076
+ get isOsuRelevant() {
3077
+ return true;
3078
+ }
3079
+ get osuScoreMultiplier() {
3080
+ return 1.06;
3081
+ }
3082
+ applyToBeatmap(beatmap) {
3083
+ const applyFadeInAdjustment = (hitObject) => {
3084
+ hitObject.timeFadeIn =
3085
+ hitObject.timePreempt * ModHidden.fadeInDurationMultiplier;
3086
+ if (hitObject instanceof Slider) {
3087
+ hitObject.nestedHitObjects.forEach(applyFadeInAdjustment);
3088
+ }
3089
+ };
3090
+ beatmap.hitObjects.objects.forEach(applyFadeInAdjustment);
3091
+ }
3092
+ }
3093
+ ModHidden.fadeInDurationMultiplier = 0.4;
3094
+ ModHidden.fadeOutDurationMultiplier = 0.3;
3095
+
3096
+ /**
3097
+ * Represents the SuddenDeath mod.
3098
+ */
3099
+ class ModSuddenDeath extends Mod {
3100
+ constructor() {
3101
+ super();
3102
+ this.acronym = "SD";
3103
+ this.name = "Sudden Death";
3104
+ this.droidRanked = true;
3105
+ this.osuRanked = true;
3106
+ this.bitwise = 1 << 5;
3107
+ this.incompatibleMods.add(ModNoFail).add(ModPerfect);
3108
+ }
3109
+ get isDroidRelevant() {
3110
+ return true;
3111
+ }
3112
+ calculateDroidScoreMultiplier() {
3113
+ return 1;
3114
+ }
3115
+ get isOsuRelevant() {
3116
+ return true;
3117
+ }
3118
+ get osuScoreMultiplier() {
3119
+ return 1;
3120
+ }
3121
+ }
3122
+
3123
+ /**
3124
+ * Represents the Perfect mod.
3434
3125
  */
3435
- class ModHardRock extends Mod {
3126
+ class ModPerfect extends Mod {
3436
3127
  constructor() {
3437
3128
  super();
3438
- this.acronym = "HR";
3439
- this.name = "HardRock";
3129
+ this.acronym = "PF";
3130
+ this.name = "Perfect";
3440
3131
  this.droidRanked = true;
3441
3132
  this.osuRanked = true;
3442
- this.bitwise = 1 << 4;
3443
- this.incompatibleMods.add(ModEasy);
3133
+ this.bitwise = 1 << 14;
3134
+ this.incompatibleMods.add(ModNoFail).add(ModSuddenDeath);
3444
3135
  }
3445
3136
  get isDroidRelevant() {
3446
3137
  return true;
3447
3138
  }
3448
3139
  calculateDroidScoreMultiplier() {
3449
- return 1.06;
3140
+ return 1;
3450
3141
  }
3451
3142
  get isOsuRelevant() {
3452
3143
  return true;
3453
3144
  }
3454
3145
  get osuScoreMultiplier() {
3455
- return 1.06;
3456
- }
3457
- applyToDifficulty(mode, difficulty) {
3458
- switch (mode) {
3459
- case exports.Modes.droid: {
3460
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3461
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale - 0.125);
3462
- break;
3463
- }
3464
- case exports.Modes.osu:
3465
- // CS uses a custom 1.3 ratio.
3466
- difficulty.cs = this.applySetting(difficulty.cs, 1.3);
3467
- break;
3468
- }
3469
- difficulty.ar = this.applySetting(difficulty.ar);
3470
- difficulty.od = this.applySetting(difficulty.od);
3471
- difficulty.hp = this.applySetting(difficulty.hp);
3472
- }
3473
- applyToHitObject(_, hitObject) {
3474
- // Reflect the position of the hit object.
3475
- hitObject.position = this.reflectVector(hitObject.position);
3476
- if (!(hitObject instanceof Slider)) {
3477
- return;
3478
- }
3479
- // Reflect the control points of the slider. This will reflect the positions of head and tail circles.
3480
- hitObject.path = new SliderPath({
3481
- pathType: hitObject.path.pathType,
3482
- controlPoints: hitObject.path.controlPoints.map((v) => this.reflectControlPoint(v)),
3483
- expectedDistance: hitObject.path.expectedDistance,
3484
- });
3485
- // Reflect the position of slider ticks and repeats.
3486
- hitObject.nestedHitObjects.slice(1, -1).forEach((obj) => {
3487
- obj.position = this.reflectVector(obj.position);
3488
- });
3489
- }
3490
- reflectVector(vector) {
3491
- return new Vector2(vector.x, Playfield.baseSize.y - vector.y);
3492
- }
3493
- reflectControlPoint(vector) {
3494
- return new Vector2(vector.x, -vector.y);
3495
- }
3496
- applySetting(value, ratio = 1.4) {
3497
- return Math.min(value * ratio, 10);
3146
+ return 1;
3498
3147
  }
3499
3148
  }
3500
3149
 
3501
3150
  /**
3502
- * Represents the Easy mod.
3151
+ * Represents the NoFail mod.
3503
3152
  */
3504
- class ModEasy extends Mod {
3153
+ class ModNoFail extends Mod {
3505
3154
  constructor() {
3506
3155
  super();
3507
- this.acronym = "EZ";
3508
- this.name = "Easy";
3156
+ this.acronym = "NF";
3157
+ this.name = "NoFail";
3509
3158
  this.droidRanked = true;
3510
3159
  this.osuRanked = true;
3511
- this.bitwise = 1 << 1;
3512
- this.incompatibleMods.add(ModHardRock);
3160
+ this.bitwise = 1 << 0;
3161
+ this.incompatibleMods.add(ModPerfect).add(ModSuddenDeath);
3513
3162
  }
3514
3163
  get isDroidRelevant() {
3515
3164
  return true;
@@ -3523,88 +3172,213 @@ class ModEasy extends Mod {
3523
3172
  get osuScoreMultiplier() {
3524
3173
  return 0.5;
3525
3174
  }
3526
- applyToDifficulty(mode, difficulty) {
3527
- switch (mode) {
3528
- case exports.Modes.droid: {
3529
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3530
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3531
- break;
3532
- }
3533
- case exports.Modes.osu:
3534
- difficulty.cs /= 2;
3535
- }
3536
- difficulty.ar /= 2;
3537
- difficulty.od /= 2;
3538
- difficulty.hp /= 2;
3175
+ }
3176
+
3177
+ /**
3178
+ * Represents a spinner in a beatmap.
3179
+ *
3180
+ * All we need from spinners is their duration. The
3181
+ * position of a spinner is always at 256x192.
3182
+ */
3183
+ class Spinner extends HitObject {
3184
+ get endTime() {
3185
+ return this._endTime;
3186
+ }
3187
+ constructor(values) {
3188
+ super(Object.assign(Object.assign({}, values), { position: Playfield.baseSize.divide(2) }));
3189
+ this._endTime = values.endTime;
3190
+ }
3191
+ applySamples(controlPoints) {
3192
+ super.applySamples(controlPoints);
3193
+ const samplePoints = controlPoints.sample.between(this.startTime + HitObject.controlPointLeniency, this.endTime + HitObject.controlPointLeniency);
3194
+ this.auxiliarySamples.length = 0;
3195
+ this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerSpinSample)))));
3196
+ this.auxiliarySamples.push(new SequenceHitSampleInfo(samplePoints.map((s) => new TimedHitSampleInfo(s.time, s.applyTo(Spinner.baseSpinnerBonusSample)))));
3197
+ }
3198
+ getStackedPosition() {
3199
+ return this.position;
3200
+ }
3201
+ getStackedEndPosition() {
3202
+ return this.position;
3203
+ }
3204
+ createHitWindow() {
3205
+ return new EmptyHitWindow();
3206
+ }
3207
+ toString() {
3208
+ return `Position: [${this._position.x}, ${this._position.y}], duration: ${this.duration}`;
3539
3209
  }
3540
3210
  }
3211
+ Spinner.baseSpinnerSpinSample = new BankHitSampleInfo("spinnerspin");
3212
+ Spinner.baseSpinnerBonusSample = new BankHitSampleInfo("spinnerbonus");
3541
3213
 
3542
3214
  /**
3543
- * Represents the Flashlight mod.
3215
+ * Represents the Precise mod.
3544
3216
  */
3545
- class ModFlashlight extends Mod {
3217
+ class ModPrecise extends Mod {
3546
3218
  constructor() {
3547
3219
  super(...arguments);
3548
- this.acronym = "FL";
3549
- this.name = "Flashlight";
3220
+ this.acronym = "PR";
3221
+ this.name = "Precise";
3550
3222
  this.droidRanked = true;
3551
- this.osuRanked = true;
3552
- this.bitwise = 1 << 10;
3553
- /**
3554
- * The amount of seconds until the Flashlight follow area reaches the cursor.
3555
- */
3556
- this.followDelay = ModFlashlight.defaultFollowDelay;
3557
3223
  }
3558
- copySettings(mod) {
3224
+ get isDroidRelevant() {
3225
+ return true;
3226
+ }
3227
+ calculateDroidScoreMultiplier() {
3228
+ return 1.06;
3229
+ }
3230
+ applyToHitObject(mode, hitObject) {
3559
3231
  var _a, _b;
3560
- super.copySettings(mod);
3561
- this.followDelay =
3562
- (_b = (_a = mod.settings) === null || _a === void 0 ? void 0 : _a.areaFollowDelay) !== null && _b !== void 0 ? _b : this.followDelay;
3232
+ if (mode !== exports.Modes.droid || hitObject instanceof Spinner) {
3233
+ return;
3234
+ }
3235
+ if (hitObject instanceof Slider) {
3236
+ // For sliders, the hit window is enforced in the head - everything else is an instant hit or miss.
3237
+ hitObject.head.hitWindow = new PreciseDroidHitWindow((_a = hitObject.head.hitWindow) === null || _a === void 0 ? void 0 : _a.overallDifficulty);
3238
+ }
3239
+ else {
3240
+ hitObject.hitWindow = new PreciseDroidHitWindow((_b = hitObject.hitWindow) === null || _b === void 0 ? void 0 : _b.overallDifficulty);
3241
+ }
3242
+ }
3243
+ }
3244
+
3245
+ /**
3246
+ * Represents the ReallyEasy mod.
3247
+ */
3248
+ class ModReallyEasy extends Mod {
3249
+ constructor() {
3250
+ super(...arguments);
3251
+ this.acronym = "RE";
3252
+ this.name = "ReallyEasy";
3253
+ this.droidRanked = false;
3563
3254
  }
3564
3255
  get isDroidRelevant() {
3565
3256
  return true;
3566
3257
  }
3567
3258
  calculateDroidScoreMultiplier() {
3568
- return 1.12;
3259
+ return 0.4;
3260
+ }
3261
+ applyToDifficultyWithSettings(mode, difficulty, mods) {
3262
+ var _a;
3263
+ if (mode !== exports.Modes.droid) {
3264
+ return;
3265
+ }
3266
+ const difficultyAdjustMod = mods.get(ModDifficultyAdjust);
3267
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.ar) === undefined) {
3268
+ if (mods.has(ModEasy)) {
3269
+ difficulty.ar *= 2;
3270
+ difficulty.ar -= 0.5;
3271
+ }
3272
+ const customSpeed = mods.get(ModCustomSpeed);
3273
+ difficulty.ar -= 0.5;
3274
+ difficulty.ar -= ((_a = customSpeed === null || customSpeed === void 0 ? void 0 : customSpeed.trackRateMultiplier) !== null && _a !== void 0 ? _a : 1) - 1;
3275
+ }
3276
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.cs) === undefined) {
3277
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3278
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3279
+ }
3280
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.od) === undefined) {
3281
+ difficulty.od /= 2;
3282
+ }
3283
+ if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.hp) === undefined) {
3284
+ difficulty.hp /= 2;
3285
+ }
3286
+ }
3287
+ }
3288
+
3289
+ /**
3290
+ * Represents the ScoreV2 mod.
3291
+ */
3292
+ class ModScoreV2 extends Mod {
3293
+ constructor() {
3294
+ super(...arguments);
3295
+ this.acronym = "V2";
3296
+ this.name = "ScoreV2";
3297
+ this.droidRanked = false;
3298
+ this.osuRanked = false;
3299
+ this.bitwise = 1 << 29;
3300
+ }
3301
+ get isDroidRelevant() {
3302
+ return true;
3303
+ }
3304
+ calculateDroidScoreMultiplier() {
3305
+ return 1;
3306
+ }
3307
+ get isOsuRelevant() {
3308
+ return true;
3309
+ }
3310
+ get osuScoreMultiplier() {
3311
+ return 1;
3312
+ }
3313
+ }
3314
+
3315
+ /**
3316
+ * Represents the SmallCircle mod.
3317
+ *
3318
+ * This is a legacy osu!droid mod that may still be exist when parsing replays.
3319
+ */
3320
+ class ModSmallCircle extends Mod {
3321
+ constructor() {
3322
+ super(...arguments);
3323
+ this.acronym = "SC";
3324
+ this.name = "SmallCircle";
3325
+ this.droidRanked = false;
3326
+ }
3327
+ get isDroidRelevant() {
3328
+ return true;
3329
+ }
3330
+ calculateDroidScoreMultiplier() {
3331
+ return 1.06;
3332
+ }
3333
+ migrateDroidMod(difficulty) {
3334
+ return new ModDifficultyAdjust({ cs: difficulty.cs + 4 });
3335
+ }
3336
+ applyToDifficulty(mode, difficulty) {
3337
+ switch (mode) {
3338
+ case exports.Modes.droid: {
3339
+ const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3340
+ difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale -
3341
+ ((CircleSizeCalculator.assumedDroidHeight / 480) *
3342
+ (4 * 4.48) *
3343
+ 2) /
3344
+ 128);
3345
+ break;
3346
+ }
3347
+ case exports.Modes.osu:
3348
+ difficulty.cs += 4;
3349
+ }
3350
+ }
3351
+ }
3352
+
3353
+ /**
3354
+ * Represents the SpunOut mod.
3355
+ */
3356
+ class ModSpunOut extends Mod {
3357
+ constructor() {
3358
+ super(...arguments);
3359
+ this.acronym = "SO";
3360
+ this.name = "SpunOut";
3361
+ this.osuRanked = true;
3362
+ this.bitwise = 1 << 12;
3569
3363
  }
3570
3364
  get isOsuRelevant() {
3571
3365
  return true;
3572
3366
  }
3573
3367
  get osuScoreMultiplier() {
3574
- return 1.12;
3575
- }
3576
- serializeSettings() {
3577
- return { areaFollowDelay: this.followDelay };
3578
- }
3579
- toString() {
3580
- if (this.followDelay === ModFlashlight.defaultFollowDelay) {
3581
- return super.toString();
3582
- }
3583
- return `${super.toString()} (${this.followDelay.toFixed(2)}s follow delay)`;
3368
+ return 0.9;
3584
3369
  }
3585
3370
  }
3586
- /**
3587
- * The default amount of seconds until the Flashlight follow area reaches the cursor.
3588
- */
3589
- ModFlashlight.defaultFollowDelay = 0.12;
3590
3371
 
3591
3372
  /**
3592
- * Represents the Traceable mod.
3373
+ * Represents the TouchDevice mod.
3593
3374
  */
3594
- class ModTraceable extends Mod {
3375
+ class ModTouchDevice extends Mod {
3595
3376
  constructor() {
3596
- super();
3597
- this.acronym = "TC";
3598
- this.name = "Traceable";
3599
- this.droidRanked = false;
3600
- this.osuRanked = false;
3601
- this.incompatibleMods.add(ModHidden);
3602
- }
3603
- get isDroidRelevant() {
3604
- return true;
3605
- }
3606
- calculateDroidScoreMultiplier() {
3607
- return 1.06;
3377
+ super(...arguments);
3378
+ this.acronym = "TD";
3379
+ this.name = "TouchDevice";
3380
+ this.osuRanked = true;
3381
+ this.bitwise = 1 << 2;
3608
3382
  }
3609
3383
  get isOsuRelevant() {
3610
3384
  return true;
@@ -3615,510 +3389,777 @@ class ModTraceable extends Mod {
3615
3389
  }
3616
3390
 
3617
3391
  /**
3618
- * Represents the Hidden mod.
3392
+ * Utilities for mods.
3619
3393
  */
3620
- class ModHidden extends Mod {
3621
- constructor() {
3622
- super();
3623
- this.acronym = "HD";
3624
- this.name = "Hidden";
3625
- this.droidRanked = true;
3626
- this.osuRanked = true;
3627
- this.bitwise = 1 << 3;
3628
- this.incompatibleMods.add(ModTraceable);
3394
+ class ModUtil {
3395
+ /**
3396
+ * Gets a list of mods from a PC modbits.
3397
+ *
3398
+ * @param modbits The modbits.
3399
+ * @returns The list of mods.
3400
+ */
3401
+ static pcModbitsToMods(modbits) {
3402
+ const map = new ModMap();
3403
+ if (modbits === 0) {
3404
+ return map;
3405
+ }
3406
+ for (const modType of this.allMods.values()) {
3407
+ const mod = new modType();
3408
+ if (mod.isApplicableToOsuStable() && (mod.bitwise & modbits) > 0) {
3409
+ map.set(mod);
3410
+ }
3411
+ }
3412
+ return map;
3629
3413
  }
3630
- get isDroidRelevant() {
3631
- return true;
3414
+ /**
3415
+ * Serializes a list of `Mod`s.
3416
+ *
3417
+ * @param mods The list of `Mod`s to serialize.
3418
+ * @returns The serialized list of `Mod`s.
3419
+ */
3420
+ static serializeMods(mods) {
3421
+ const serializedMods = [];
3422
+ for (const mod of mods) {
3423
+ serializedMods.push(mod.serialize());
3424
+ }
3425
+ return serializedMods;
3632
3426
  }
3633
- calculateDroidScoreMultiplier() {
3634
- return 1.06;
3427
+ /**
3428
+ * Deserializes a list of `SerializedMod`s.
3429
+ *
3430
+ * @param mods The list of `SerializedMod`s to deserialize.
3431
+ * @returns The deserialized list of `Mod`s.
3432
+ */
3433
+ static deserializeMods(mods) {
3434
+ const map = new ModMap();
3435
+ for (const serializedMod of mods) {
3436
+ const modType = this.allMods.get(serializedMod.acronym);
3437
+ if (!modType) {
3438
+ continue;
3439
+ }
3440
+ const mod = new modType();
3441
+ if (serializedMod.settings) {
3442
+ mod.copySettings(serializedMod);
3443
+ }
3444
+ map.set(mod);
3445
+ }
3446
+ return map;
3635
3447
  }
3636
- get isOsuRelevant() {
3637
- return true;
3448
+ /**
3449
+ * Gets a list of mods from a PC mod string, such as "HDHR".
3450
+ *
3451
+ * @param str The string.
3452
+ * @returns The list of mods.
3453
+ */
3454
+ static pcStringToMods(str) {
3455
+ const map = new ModMap();
3456
+ str = str.toLowerCase();
3457
+ while (str) {
3458
+ let nchars = 1;
3459
+ for (const acronym of this.allMods.keys()) {
3460
+ if (str.startsWith(acronym.toLowerCase())) {
3461
+ const modType = this.allMods.get(acronym);
3462
+ map.set(modType);
3463
+ nchars = acronym.length;
3464
+ break;
3465
+ }
3466
+ }
3467
+ str = str.slice(nchars);
3468
+ }
3469
+ return map;
3638
3470
  }
3639
- get osuScoreMultiplier() {
3640
- return 1.06;
3471
+ /**
3472
+ * Converts a list of mods into its osu!standard string counterpart.
3473
+ *
3474
+ * @param mods The array of mods to convert.
3475
+ * @returns The string representing the mods in osu!standard.
3476
+ */
3477
+ static modsToOsuString(mods) {
3478
+ let str = "";
3479
+ for (const mod of mods) {
3480
+ if (mod instanceof ModDifficultyAdjust) {
3481
+ continue;
3482
+ }
3483
+ str += mod.acronym;
3484
+ }
3485
+ return str;
3641
3486
  }
3642
- applyToBeatmap(beatmap) {
3643
- const applyFadeInAdjustment = (hitObject) => {
3644
- hitObject.timeFadeIn =
3645
- hitObject.timePreempt * ModHidden.fadeInDurationMultiplier;
3646
- if (hitObject instanceof Slider) {
3647
- hitObject.nestedHitObjects.forEach(applyFadeInAdjustment);
3487
+ /**
3488
+ * Converts an array of `Mod`s into an ordered string based on {@link allMods}.
3489
+ *
3490
+ * @param mods The array of `Mod`s to convert.
3491
+ * @returns The string representing the `Mod`s in ordered form.
3492
+ */
3493
+ static modsToOrderedString(mods) {
3494
+ const strs = [];
3495
+ for (const modType of this.allMods.values()) {
3496
+ for (const mod of mods) {
3497
+ if (mod instanceof modType) {
3498
+ strs.push(mod.toString());
3499
+ break;
3500
+ }
3648
3501
  }
3649
- };
3650
- beatmap.hitObjects.objects.forEach(applyFadeInAdjustment);
3502
+ }
3503
+ return strs.join();
3504
+ }
3505
+ /**
3506
+ * Removes speed-changing mods from an array of mods.
3507
+ *
3508
+ * @param mods The array of mods.
3509
+ * @returns A new array with speed changing mods filtered out.
3510
+ */
3511
+ static removeSpeedChangingMods(mods) {
3512
+ return mods.filter((m) => !(m instanceof ModRateAdjust));
3513
+ }
3514
+ /**
3515
+ * Applies the selected `Mod`s to a `BeatmapDifficulty`.
3516
+ *
3517
+ * @param difficulty The `BeatmapDifficulty` to apply the `Mod`s to.
3518
+ * @param mode The game mode to apply the `Mod`s for.
3519
+ * @param mods The selected `Mod`s. Defaults to No Mod.
3520
+ * @param withRateChange Whether to apply rate changes. Defaults to `false`.
3521
+ */
3522
+ static applyModsToBeatmapDifficulty(difficulty, mode, mods, withRateChange = false) {
3523
+ if (mods !== undefined) {
3524
+ for (const mod of mods.values()) {
3525
+ if (mod.isApplicableToDifficulty()) {
3526
+ mod.applyToDifficulty(mode, difficulty);
3527
+ }
3528
+ }
3529
+ }
3530
+ let rate = 1;
3531
+ if (mods !== undefined) {
3532
+ for (const mod of mods.values()) {
3533
+ if (mod.isApplicableToDifficultyWithSettings()) {
3534
+ mod.applyToDifficultyWithSettings(mode, difficulty, mods);
3535
+ }
3536
+ if (mod.isApplicableToTrackRate()) {
3537
+ rate = mod.applyToRate(0, rate);
3538
+ }
3539
+ }
3540
+ }
3541
+ if (!withRateChange) {
3542
+ return;
3543
+ }
3544
+ // Apply rate adjustments
3545
+ const preempt = BeatmapDifficulty.difficultyRange(difficulty.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin) / rate;
3546
+ difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
3547
+ switch (mode) {
3548
+ case exports.Modes.droid:
3549
+ if (mods === null || mods === void 0 ? void 0 : mods.has(ModPrecise)) {
3550
+ const hitWindow = new PreciseDroidHitWindow(difficulty.od);
3551
+ difficulty.od = PreciseDroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3552
+ }
3553
+ else {
3554
+ const hitWindow = new DroidHitWindow(difficulty.od);
3555
+ difficulty.od = DroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3556
+ }
3557
+ break;
3558
+ case exports.Modes.osu: {
3559
+ const hitWindow = new OsuHitWindow(difficulty.od);
3560
+ difficulty.od = OsuHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
3561
+ break;
3562
+ }
3563
+ }
3564
+ }
3565
+ /**
3566
+ * Calculates the playback rate for the track with the selected `Mod`s at the given time.
3567
+ *
3568
+ * @param mods The list of selected `Mod`s.
3569
+ * @param time The time at which the playback rate is queried, in milliseconds. Defaults to 0.
3570
+ * @returns The rate with `Mod`s.
3571
+ */
3572
+ static calculateRateWithMods(mods, time = 0) {
3573
+ let rate = 1;
3574
+ for (const mod of mods) {
3575
+ if (mod.isApplicableToTrackRate()) {
3576
+ rate = mod.applyToRate(time, rate);
3577
+ }
3578
+ }
3579
+ return rate;
3580
+ }
3581
+ }
3582
+ /**
3583
+ * All `Mod`s that exists, mapped by their acronym.
3584
+ */
3585
+ ModUtil.allMods = (() => {
3586
+ const mods = [
3587
+ // Janky order to keep the order on what players are used to
3588
+ ModAuto,
3589
+ ModRelax,
3590
+ ModAutopilot,
3591
+ ModEasy,
3592
+ ModNoFail,
3593
+ ModHidden,
3594
+ ModTraceable,
3595
+ ModDoubleTime,
3596
+ ModNightCore,
3597
+ ModHalfTime,
3598
+ ModCustomSpeed,
3599
+ ModHardRock,
3600
+ ModDifficultyAdjust,
3601
+ ModFlashlight,
3602
+ ModSuddenDeath,
3603
+ ModPerfect,
3604
+ ModPrecise,
3605
+ ModReallyEasy,
3606
+ ModScoreV2,
3607
+ ModSmallCircle,
3608
+ ModSpunOut,
3609
+ ModTouchDevice,
3610
+ ];
3611
+ const map = new Map();
3612
+ for (const mod of mods) {
3613
+ map.set(new mod().acronym, mod);
3651
3614
  }
3652
- }
3653
- ModHidden.fadeInDurationMultiplier = 0.4;
3654
- ModHidden.fadeOutDurationMultiplier = 0.3;
3615
+ return map;
3616
+ })();
3655
3617
 
3656
3618
  /**
3657
- * Represents the SuddenDeath mod.
3619
+ * A map that stores `Mod`s depending on their type.
3620
+ *
3621
+ * This also has additional utilities to eliminate unnecessary `Mod`s.
3658
3622
  */
3659
- class ModSuddenDeath extends Mod {
3660
- constructor() {
3661
- super();
3662
- this.acronym = "SD";
3663
- this.name = "Sudden Death";
3664
- this.droidRanked = true;
3665
- this.osuRanked = true;
3666
- this.bitwise = 1 << 5;
3667
- this.incompatibleMods.add(ModNoFail).add(ModPerfect);
3668
- }
3669
- get isDroidRelevant() {
3670
- return true;
3671
- }
3672
- calculateDroidScoreMultiplier() {
3673
- return 1;
3674
- }
3675
- get isOsuRelevant() {
3676
- return true;
3623
+ class ModMap extends Map {
3624
+ /**
3625
+ * Whether this map is empty.
3626
+ */
3627
+ get isEmpty() {
3628
+ return this.size === 0;
3629
+ }
3630
+ constructor(iterable) {
3631
+ if (Array.isArray(iterable)) {
3632
+ for (const [key, value] of iterable) {
3633
+ // Ensure the mod type corresponds to the mod instance.
3634
+ if (key !== value.constructor) {
3635
+ throw new TypeError(`Key ${key.name} does not match value ${value.constructor.name}`);
3636
+ }
3637
+ }
3638
+ }
3639
+ super(iterable);
3640
+ }
3641
+ has(keyOrValue) {
3642
+ const key = keyOrValue instanceof Mod
3643
+ ? keyOrValue.constructor
3644
+ : keyOrValue;
3645
+ return super.has(key);
3646
+ }
3647
+ get(key) {
3648
+ return super.get(key);
3649
+ }
3650
+ set(keyOrValue) {
3651
+ const key = (keyOrValue instanceof Mod ? keyOrValue.constructor : keyOrValue);
3652
+ const value = keyOrValue instanceof Mod ? keyOrValue : new key();
3653
+ // Ensure the mod type corresponds to the mod instance.
3654
+ if (key !== value.constructor) {
3655
+ throw new TypeError(`Key ${key.name} does not match value ${value.constructor.name}`);
3656
+ }
3657
+ const existing = this.get(key);
3658
+ // If all difficulty statistics are set, all other difficulty adjusting mods are irrelevant, so we remove them.
3659
+ // This prevents potential abuse cases where score multipliers from non-affecting mods stack (i.e., forcing
3660
+ // all difficulty statistics while using the Hard Rock mod).
3661
+ const removeDifficultyAdjustmentMods = value instanceof ModDifficultyAdjust &&
3662
+ value.cs !== undefined &&
3663
+ value.ar !== undefined &&
3664
+ value.od !== undefined &&
3665
+ value.hp !== undefined;
3666
+ if (removeDifficultyAdjustmentMods) {
3667
+ this.delete(ModEasy);
3668
+ this.delete(ModHardRock);
3669
+ this.delete(ModReallyEasy);
3670
+ this.delete(ModSmallCircle);
3671
+ }
3672
+ // Check if there are any mods that are incompatible with the new mod.
3673
+ // If so, remove them.
3674
+ for (const incompatibleMod of value.incompatibleMods) {
3675
+ for (const mod of this.values()) {
3676
+ if (mod instanceof incompatibleMod) {
3677
+ this.delete(mod.constructor);
3678
+ }
3679
+ }
3680
+ }
3681
+ super.set(key, value);
3682
+ return existing !== null && existing !== void 0 ? existing : null;
3677
3683
  }
3678
- get osuScoreMultiplier() {
3679
- return 1;
3684
+ /**
3685
+ * Serializes all `Mod`s that are in this map.
3686
+ */
3687
+ serializeMods() {
3688
+ return ModUtil.serializeMods(this.values());
3680
3689
  }
3681
3690
  }
3682
3691
 
3683
3692
  /**
3684
- * Represents the Perfect mod.
3693
+ * Represents a circle in a beatmap.
3694
+ *
3695
+ * All we need from circles is their position. All positions
3696
+ * stored in the objects are in playfield coordinates (512*384
3697
+ * rectangle).
3685
3698
  */
3686
- class ModPerfect extends Mod {
3687
- constructor() {
3688
- super();
3689
- this.acronym = "PF";
3690
- this.name = "Perfect";
3691
- this.droidRanked = true;
3692
- this.osuRanked = true;
3693
- this.bitwise = 1 << 14;
3694
- this.incompatibleMods.add(ModNoFail).add(ModSuddenDeath);
3695
- }
3696
- get isDroidRelevant() {
3697
- return true;
3698
- }
3699
- calculateDroidScoreMultiplier() {
3700
- return 1;
3701
- }
3702
- get isOsuRelevant() {
3703
- return true;
3699
+ class Circle extends HitObject {
3700
+ constructor(values) {
3701
+ super(values);
3704
3702
  }
3705
- get osuScoreMultiplier() {
3706
- return 1;
3703
+ toString() {
3704
+ return `Position: [${this._position.x}, ${this._position.y}]`;
3707
3705
  }
3708
3706
  }
3709
3707
 
3710
3708
  /**
3711
- * Represents the NoFail mod.
3709
+ * Contains information about hit objects of a beatmap.
3712
3710
  */
3713
- class ModNoFail extends Mod {
3711
+ class BeatmapHitObjects {
3714
3712
  constructor() {
3715
- super();
3716
- this.acronym = "NF";
3717
- this.name = "NoFail";
3718
- this.droidRanked = true;
3719
- this.osuRanked = true;
3720
- this.bitwise = 1 << 0;
3721
- this.incompatibleMods.add(ModPerfect).add(ModSuddenDeath);
3713
+ this._objects = [];
3714
+ this._circles = 0;
3715
+ this._sliders = 0;
3716
+ this._spinners = 0;
3722
3717
  }
3723
- get isDroidRelevant() {
3724
- return true;
3718
+ /**
3719
+ * The objects of the beatmap.
3720
+ */
3721
+ get objects() {
3722
+ return this._objects;
3725
3723
  }
3726
- calculateDroidScoreMultiplier() {
3727
- return 0.5;
3724
+ /**
3725
+ * The amount of circles in the beatmap.
3726
+ */
3727
+ get circles() {
3728
+ return this._circles;
3728
3729
  }
3729
- get isOsuRelevant() {
3730
- return true;
3730
+ /**
3731
+ * The amount of sliders in the beatmap.
3732
+ */
3733
+ get sliders() {
3734
+ return this._sliders;
3731
3735
  }
3732
- get osuScoreMultiplier() {
3733
- return 0.5;
3736
+ /**
3737
+ * The amount of spinners in the beatmap.
3738
+ */
3739
+ get spinners() {
3740
+ return this._spinners;
3734
3741
  }
3735
- }
3736
-
3737
- /**
3738
- * Represents the ReallyEasy mod.
3739
- */
3740
- class ModReallyEasy extends Mod {
3741
- constructor() {
3742
- super(...arguments);
3743
- this.acronym = "RE";
3744
- this.name = "ReallyEasy";
3745
- this.droidRanked = false;
3742
+ /**
3743
+ * The amount of slider ticks in the beatmap.
3744
+ *
3745
+ * This iterates through all objects and should be stored locally or used sparingly.
3746
+ */
3747
+ get sliderTicks() {
3748
+ return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.ticks : acc), 0);
3746
3749
  }
3747
- get isDroidRelevant() {
3748
- return true;
3750
+ /**
3751
+ * The amount of sliderends in the beatmap.
3752
+ */
3753
+ get sliderEnds() {
3754
+ return this.sliders;
3749
3755
  }
3750
- calculateDroidScoreMultiplier() {
3751
- return 0.4;
3756
+ /**
3757
+ * The amount of slider repeat points in the beatmap.
3758
+ *
3759
+ * This iterates through all objects and should be stored locally or used sparingly.
3760
+ */
3761
+ get sliderRepeatPoints() {
3762
+ return this.objects.reduce((acc, cur) => (cur instanceof Slider ? acc + cur.repeatCount : acc), 0);
3752
3763
  }
3753
- applyToDifficultyWithSettings(mode, difficulty, mods) {
3754
- var _a;
3755
- if (mode !== exports.Modes.droid) {
3756
- return;
3757
- }
3758
- const difficultyAdjustMod = mods.find((m) => m instanceof ModDifficultyAdjust);
3759
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.ar) === undefined) {
3760
- if (mods.some((m) => m instanceof ModEasy)) {
3761
- difficulty.ar *= 2;
3762
- difficulty.ar -= 0.5;
3764
+ /**
3765
+ * Adds hitobjects.
3766
+ *
3767
+ * The sorting order of hitobjects will be maintained.
3768
+ *
3769
+ * @param objects The hitobjects to add.
3770
+ */
3771
+ add(...objects) {
3772
+ for (const object of objects) {
3773
+ // Objects may be out of order *only* if a user has manually edited an .osu file.
3774
+ // Unfortunately there are "ranked" maps in this state (example: https://osu.ppy.sh/s/594828).
3775
+ // Finding index is used to guarantee that the parsing order of hitobjects with equal start times is maintained (stably-sorted).
3776
+ this._objects.splice(this.findInsertionIndex(object.startTime), 0, object);
3777
+ if (object instanceof Circle) {
3778
+ ++this._circles;
3779
+ }
3780
+ else if (object instanceof Slider) {
3781
+ ++this._sliders;
3782
+ }
3783
+ else {
3784
+ ++this._spinners;
3763
3785
  }
3764
- const customSpeed = mods.find((m) => m instanceof ModCustomSpeed);
3765
- difficulty.ar -= 0.5;
3766
- difficulty.ar -= ((_a = customSpeed === null || customSpeed === void 0 ? void 0 : customSpeed.trackRateMultiplier) !== null && _a !== void 0 ? _a : 1) - 1;
3767
3786
  }
3768
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.cs) === undefined) {
3769
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3770
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale + 0.125);
3787
+ }
3788
+ /**
3789
+ * Removes a hitobject at an index.
3790
+ *
3791
+ * @param index The index of the hitobject to remove.
3792
+ * @returns The hitobject that was removed, `null` if no hitobject was removed.
3793
+ */
3794
+ removeAt(index) {
3795
+ var _a;
3796
+ const object = (_a = this._objects.splice(index, 1)[0]) !== null && _a !== void 0 ? _a : null;
3797
+ if (object instanceof Circle) {
3798
+ --this._circles;
3771
3799
  }
3772
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.od) === undefined) {
3773
- difficulty.od /= 2;
3800
+ else if (object instanceof Slider) {
3801
+ --this._sliders;
3774
3802
  }
3775
- if ((difficultyAdjustMod === null || difficultyAdjustMod === void 0 ? void 0 : difficultyAdjustMod.hp) === undefined) {
3776
- difficulty.hp /= 2;
3803
+ else if (object instanceof Spinner) {
3804
+ --this._spinners;
3777
3805
  }
3806
+ return object;
3778
3807
  }
3779
- }
3780
-
3781
- /**
3782
- * Represents the SmallCircle mod.
3783
- *
3784
- * This is a legacy osu!droid mod that may still be exist when parsing replays.
3785
- */
3786
- class ModSmallCircle extends Mod {
3787
- constructor() {
3788
- super(...arguments);
3789
- this.acronym = "SC";
3790
- this.name = "SmallCircle";
3791
- this.droidRanked = false;
3792
- }
3793
- get isDroidRelevant() {
3794
- return true;
3795
- }
3796
- calculateDroidScoreMultiplier() {
3797
- return 1.06;
3798
- }
3799
- migrateDroidMod(difficulty) {
3800
- return new ModDifficultyAdjust({ cs: difficulty.cs + 4 });
3808
+ /**
3809
+ * Clears all hitobjects.
3810
+ */
3811
+ clear() {
3812
+ this._objects.length = 0;
3813
+ this._circles = 0;
3814
+ this._sliders = 0;
3815
+ this._spinners = 0;
3801
3816
  }
3802
- applyToDifficulty(mode, difficulty) {
3803
- switch (mode) {
3804
- case exports.Modes.droid: {
3805
- const scale = CircleSizeCalculator.droidCSToDroidScale(difficulty.cs);
3806
- difficulty.cs = CircleSizeCalculator.droidScaleToDroidCS(scale -
3807
- ((CircleSizeCalculator.assumedDroidHeight / 480) *
3808
- (4 * 4.48) *
3809
- 2) /
3810
- 128);
3811
- break;
3817
+ /**
3818
+ * Finds the insertion index of a hitobject in a given time.
3819
+ *
3820
+ * @param startTime The start time of the hitobject.
3821
+ */
3822
+ findInsertionIndex(startTime) {
3823
+ if (this._objects.length === 0 ||
3824
+ startTime < this._objects[0].startTime) {
3825
+ return 0;
3826
+ }
3827
+ if (startTime >= this._objects.at(-1).startTime) {
3828
+ return this._objects.length;
3829
+ }
3830
+ let l = 0;
3831
+ let r = this._objects.length - 2;
3832
+ while (l <= r) {
3833
+ const pivot = l + ((r - l) >> 1);
3834
+ if (this._objects[pivot].startTime < startTime) {
3835
+ l = pivot + 1;
3836
+ }
3837
+ else if (this._objects[pivot].startTime > startTime) {
3838
+ r = pivot - 1;
3839
+ }
3840
+ else {
3841
+ return pivot;
3812
3842
  }
3813
- case exports.Modes.osu:
3814
- difficulty.cs += 4;
3815
3843
  }
3844
+ return l;
3816
3845
  }
3817
3846
  }
3818
3847
 
3819
3848
  /**
3820
- * Represents the SpunOut mod.
3849
+ * Converts a beatmap for another mode.
3821
3850
  */
3822
- class ModSpunOut extends Mod {
3823
- constructor() {
3824
- super(...arguments);
3825
- this.acronym = "SO";
3826
- this.name = "SpunOut";
3827
- this.osuRanked = true;
3828
- this.bitwise = 1 << 12;
3829
- }
3830
- get isOsuRelevant() {
3831
- return true;
3832
- }
3833
- get osuScoreMultiplier() {
3834
- return 0.9;
3851
+ class BeatmapConverter {
3852
+ constructor(beatmap) {
3853
+ this.beatmap = beatmap;
3835
3854
  }
3836
- }
3837
-
3838
- /**
3839
- * Represents the TouchDevice mod.
3840
- */
3841
- class ModTouchDevice extends Mod {
3842
- constructor() {
3843
- super(...arguments);
3844
- this.acronym = "TD";
3845
- this.name = "TouchDevice";
3846
- this.osuRanked = true;
3847
- this.bitwise = 1 << 2;
3855
+ /**
3856
+ * Converts the beatmap.
3857
+ *
3858
+ * @returns The converted beatmap.
3859
+ */
3860
+ convert() {
3861
+ const converted = new Beatmap(this.beatmap);
3862
+ // Shallow clone isn't enough to ensure we don't mutate some beatmap properties unexpectedly.
3863
+ converted.difficulty = new BeatmapDifficulty(this.beatmap.difficulty);
3864
+ converted.hitObjects = this.convertHitObjects();
3865
+ return converted;
3848
3866
  }
3849
- get isOsuRelevant() {
3850
- return true;
3867
+ convertHitObjects() {
3868
+ const hitObjects = new BeatmapHitObjects();
3869
+ this.beatmap.hitObjects.objects.forEach((hitObject) => {
3870
+ hitObjects.add(this.convertHitObject(hitObject));
3871
+ });
3872
+ return hitObjects;
3851
3873
  }
3852
- get osuScoreMultiplier() {
3853
- return 1;
3874
+ convertHitObject(hitObject) {
3875
+ let object;
3876
+ if (hitObject instanceof Circle) {
3877
+ object = new Circle({
3878
+ startTime: hitObject.startTime,
3879
+ position: hitObject.position,
3880
+ newCombo: hitObject.isNewCombo,
3881
+ type: hitObject.type,
3882
+ comboOffset: hitObject.comboOffset,
3883
+ });
3884
+ }
3885
+ else if (hitObject instanceof Slider) {
3886
+ object = new Slider({
3887
+ startTime: hitObject.startTime,
3888
+ position: hitObject.position,
3889
+ newCombo: hitObject.isNewCombo,
3890
+ type: hitObject.type,
3891
+ path: hitObject.path,
3892
+ repeatCount: hitObject.repeatCount,
3893
+ nodeSamples: hitObject.nodeSamples,
3894
+ comboOffset: hitObject.comboOffset,
3895
+ tickDistanceMultiplier:
3896
+ // Prior to v8, speed multipliers don't adjust for how many ticks are generated over the same distance.
3897
+ // This results in more (or less) ticks being generated in <v8 maps for the same time duration.
3898
+ this.beatmap.formatVersion < 8
3899
+ ? 1 /
3900
+ this.beatmap.controlPoints.difficulty.controlPointAt(hitObject.startTime).speedMultiplier
3901
+ : 1,
3902
+ });
3903
+ }
3904
+ else {
3905
+ object = new Spinner({
3906
+ startTime: hitObject.startTime,
3907
+ endTime: hitObject.endTime,
3908
+ type: hitObject.type,
3909
+ });
3910
+ }
3911
+ object.samples = hitObject.samples;
3912
+ object.auxiliarySamples = hitObject.auxiliarySamples;
3913
+ return object;
3854
3914
  }
3855
3915
  }
3856
3916
 
3857
3917
  /**
3858
- * Utilities for mods.
3918
+ * Provides functionality to alter a beatmap after it has been converted.
3859
3919
  */
3860
- class ModUtil {
3920
+ class BeatmapProcessor {
3921
+ constructor(beatmap) {
3922
+ this.beatmap = beatmap;
3923
+ }
3861
3924
  /**
3862
- * Gets a list of mods from a PC modbits.
3925
+ * Processes the converted beatmap prior to `HitObject.applyDefaults` being invoked.
3863
3926
  *
3864
- * @param modbits The modbits.
3865
- * @param options Options for parsing behavior.
3927
+ * Nested hitobjects generated during `HitObject.applyDefaults` will not be present by this point,
3928
+ * and no mods will have been applied to the hitobjects.
3929
+ *
3930
+ * This can only be used to add alterations to hitobjects generated directly through the conversion process.
3866
3931
  */
3867
- static pcModbitsToMods(modbits, options) {
3868
- if (modbits === 0) {
3869
- return [];
3932
+ preProcess() {
3933
+ let last = null;
3934
+ for (const object of this.beatmap.hitObjects.objects) {
3935
+ object.updateComboInformation(last);
3936
+ last = object;
3870
3937
  }
3871
- const mods = [];
3872
- for (const modType of this.allMods.values()) {
3873
- const mod = new modType();
3874
- if (mod.isApplicableToOsuStable() && (mod.bitwise & modbits) > 0) {
3875
- mods.push(mod);
3876
- }
3938
+ // Mark the last object in the beatmap as last in combo.
3939
+ if (last) {
3940
+ last.isLastInCombo = true;
3877
3941
  }
3878
- return this.processParsingOptions(mods, options);
3879
3942
  }
3880
3943
  /**
3881
- * Serializes a list of `Mod`s.
3944
+ * Processes the converted beatmap after `HitObject.applyDefaults` has been invoked.
3882
3945
  *
3883
- * @param mods The list of `Mod`s to serialize.
3884
- * @returns The serialized list of `Mod`s.
3946
+ * Nested hitobjects generated during `HitObject.applyDefaults` wil be present by this point,
3947
+ * and mods will have been applied to all hitobjects.
3948
+ *
3949
+ * This should be used to add alterations to hitobjects while they are in their most playable state.
3950
+ *
3951
+ * @param mode The mode to add alterations for.
3885
3952
  */
3886
- static serializeMods(mods) {
3887
- const serializedMods = [];
3888
- for (const mod of mods) {
3889
- serializedMods.push(mod.serialize());
3953
+ postProcess(mode) {
3954
+ const objects = this.beatmap.hitObjects.objects;
3955
+ if (objects.length === 0) {
3956
+ return;
3957
+ }
3958
+ // Reset stacking
3959
+ objects.forEach((h) => {
3960
+ h.stackHeight = 0;
3961
+ });
3962
+ switch (mode) {
3963
+ case exports.Modes.droid:
3964
+ this.applyDroidStacking();
3965
+ break;
3966
+ case exports.Modes.osu:
3967
+ if (this.beatmap.formatVersion >= 6) {
3968
+ this.applyStandardStacking();
3969
+ }
3970
+ else {
3971
+ this.applyStandardOldStacking();
3972
+ }
3973
+ break;
3890
3974
  }
3891
- return serializedMods;
3892
3975
  }
3893
- /**
3894
- * Deserializes a list of `SerializedMod`s.
3895
- *
3896
- * @param mods The list of `SerializedMod`s to deserialize.
3897
- * @returns The deserialized list of `Mod`s.
3898
- */
3899
- static deserializeMods(mods) {
3900
- const deserializedMods = [];
3901
- for (const serializedMod of mods) {
3902
- const modType = this.allMods.get(serializedMod.acronym);
3903
- if (!modType) {
3904
- continue;
3905
- }
3906
- const mod = new modType();
3907
- if (serializedMod.settings) {
3908
- mod.copySettings(serializedMod);
3976
+ applyDroidStacking() {
3977
+ const objects = this.beatmap.hitObjects.objects;
3978
+ if (objects.length === 0) {
3979
+ return;
3980
+ }
3981
+ const convertedScale = CircleSizeCalculator.standardScaleToDroidScale(objects[0].scale);
3982
+ for (let i = 0; i < objects.length - 1; ++i) {
3983
+ const current = objects[i];
3984
+ const next = objects[i + 1];
3985
+ if (current instanceof Circle &&
3986
+ next.startTime - current.startTime <
3987
+ 2000 * this.beatmap.general.stackLeniency &&
3988
+ next.position.getDistance(current.position) <
3989
+ Math.sqrt(convertedScale)) {
3990
+ next.stackHeight = current.stackHeight + 1;
3909
3991
  }
3910
- deserializedMods.push(mod);
3911
3992
  }
3912
- return deserializedMods;
3913
3993
  }
3914
- /**
3915
- * Gets a list of mods from a PC mod string, such as "HDHR".
3916
- *
3917
- * @param str The string.
3918
- * @param options Options for parsing behavior.
3919
- */
3920
- static pcStringToMods(str, options) {
3921
- const finalMods = [];
3922
- str = str.toLowerCase();
3923
- while (str) {
3924
- let nchars = 1;
3925
- for (const acronym of this.allMods.keys()) {
3926
- if (str.startsWith(acronym.toLowerCase())) {
3927
- const modType = this.allMods.get(acronym);
3928
- finalMods.push(new modType());
3929
- nchars = acronym.length;
3930
- break;
3994
+ applyStandardStacking() {
3995
+ const objects = this.beatmap.hitObjects.objects;
3996
+ const startIndex = 0;
3997
+ const endIndex = objects.length - 1;
3998
+ let extendedEndIndex = endIndex;
3999
+ if (endIndex < objects.length - 1) {
4000
+ for (let i = endIndex; i >= startIndex; --i) {
4001
+ let stackBaseIndex = i;
4002
+ for (let n = stackBaseIndex + 1; n < objects.length; ++n) {
4003
+ const stackBaseObject = objects[stackBaseIndex];
4004
+ if (stackBaseObject instanceof Spinner) {
4005
+ break;
4006
+ }
4007
+ const objectN = objects[n];
4008
+ if (objectN instanceof Spinner) {
4009
+ break;
4010
+ }
4011
+ const stackThreshold = objectN.timePreempt *
4012
+ this.beatmap.general.stackLeniency;
4013
+ if (objectN.startTime - stackBaseObject.endTime >
4014
+ stackThreshold) {
4015
+ // We are no longer within stacking range of the next object.
4016
+ break;
4017
+ }
4018
+ const endPositionDistanceCheck = stackBaseObject instanceof Slider
4019
+ ? stackBaseObject.endPosition.getDistance(objectN.position) < BeatmapProcessor.stackDistance
4020
+ : false;
4021
+ if (stackBaseObject.position.getDistance(objectN.position) <
4022
+ BeatmapProcessor.stackDistance ||
4023
+ endPositionDistanceCheck) {
4024
+ stackBaseIndex = n;
4025
+ // Hit objects after the specified update range haven't been reset yet
4026
+ objectN.stackHeight = 0;
4027
+ }
4028
+ }
4029
+ if (stackBaseIndex > extendedEndIndex) {
4030
+ extendedEndIndex = stackBaseIndex;
4031
+ if (extendedEndIndex === objects.length - 1) {
4032
+ break;
4033
+ }
3931
4034
  }
3932
4035
  }
3933
- str = str.slice(nchars);
3934
4036
  }
3935
- return this.processParsingOptions(finalMods, options);
3936
- }
3937
- /**
3938
- * Converts an array of mods into its osu!standard string counterpart.
3939
- *
3940
- * @param mods The array of mods to convert.
3941
- * @returns The string representing the mods in osu!standard.
3942
- */
3943
- static modsToOsuString(mods) {
3944
- return mods.reduce((a, v) => {
3945
- if (v instanceof ModDifficultyAdjust) {
3946
- return a;
4037
+ // Reverse pass for stack calculation.
4038
+ let extendedStartIndex = startIndex;
4039
+ for (let i = extendedEndIndex; i > startIndex; --i) {
4040
+ let n = i;
4041
+ // We should check every note which has not yet got a stack.
4042
+ // Consider the case we have two inter-wound stacks and this will make sense.
4043
+ //
4044
+ // o <-1 o <-2
4045
+ // o <-3 o <-4
4046
+ //
4047
+ // We first process starting from 4 and handle 2,
4048
+ // then we come backwards on the i loop iteration until we reach 3 and handle 1.
4049
+ // 2 and 1 will be ignored in the i loop because they already have a stack value.
4050
+ let objectI = objects[i];
4051
+ if (objectI.stackHeight !== 0 || objectI instanceof Spinner) {
4052
+ continue;
3947
4053
  }
3948
- return a + v.acronym;
3949
- }, "");
3950
- }
3951
- /**
3952
- * Converts an array of `Mod`s into an ordered string based on {@link allMods}.
3953
- *
3954
- * @param mods The array of `Mod`s to convert.
3955
- * @returns The string representing the `Mod`s in ordered form.
3956
- */
3957
- static modsToOrderedString(mods) {
3958
- const strs = [];
3959
- for (const modType of this.allMods.values()) {
3960
- for (const mod of mods) {
3961
- if (mod instanceof modType) {
3962
- strs.push(mod.toString());
3963
- break;
4054
+ const stackThreshold = objectI.timePreempt * this.beatmap.general.stackLeniency;
4055
+ // If this object is a hit circle, then we enter this "special" case.
4056
+ // It either ends with a stack of hit circles only, or a stack of hit circles that are underneath a slider.
4057
+ // Any other case is handled by the "instanceof Slider" code below this.
4058
+ if (objectI instanceof Circle) {
4059
+ while (--n >= 0) {
4060
+ const objectN = objects[n];
4061
+ if (objectN instanceof Spinner) {
4062
+ continue;
4063
+ }
4064
+ if (objectI.startTime - objectN.endTime > stackThreshold) {
4065
+ // We are no longer within stacking range of the previous object.
4066
+ break;
4067
+ }
4068
+ // Hit objects before the specified update range haven't been reset yet
4069
+ if (n < extendedStartIndex) {
4070
+ objectN.stackHeight = 0;
4071
+ extendedStartIndex = n;
4072
+ }
4073
+ // This is a special case where hit circles are moved DOWN and RIGHT (negative stacking) if they are under the *last* slider in a stacked pattern.
4074
+ // o==o <- slider is at original location
4075
+ // o <- hitCircle has stack of -1
4076
+ // o <- hitCircle has stack of -2
4077
+ if (objectN instanceof Slider &&
4078
+ objectN.endPosition.getDistance(objectI.position) <
4079
+ BeatmapProcessor.stackDistance) {
4080
+ const offset = objectI.stackHeight - objectN.stackHeight + 1;
4081
+ for (let j = n + 1; j <= i; ++j) {
4082
+ // For each object which was declared under this slider, we will offset it to appear *below* the slider end (rather than above).
4083
+ const objectJ = objects[j];
4084
+ if (objectN.endPosition.getDistance(objectJ.position) < BeatmapProcessor.stackDistance) {
4085
+ objectJ.stackHeight -= offset;
4086
+ }
4087
+ }
4088
+ // We have hit a slider. We should restart calculation using this as the new base.
4089
+ // Breaking here will mean that the slider still has a stack count of 0, so will be handled in the i-outer-loop.
4090
+ break;
4091
+ }
4092
+ if (objectN.position.getDistance(objectI.position) <
4093
+ BeatmapProcessor.stackDistance) {
4094
+ // Keep processing as if there are no sliders. If we come across a slider, this gets cancelled out.
4095
+ // NOTE: Sliders with start positions stacking are a special case that is also handled here.
4096
+ objectN.stackHeight = objectI.stackHeight + 1;
4097
+ objectI = objectN;
4098
+ }
3964
4099
  }
3965
4100
  }
3966
- }
3967
- return strs.join();
3968
- }
3969
- /**
3970
- * Checks for mods that are duplicated.
3971
- *
3972
- * @param mods The mods to check for.
3973
- * @returns Mods that have been filtered.
3974
- */
3975
- static checkDuplicateMods(mods) {
3976
- return Array.from(new Set(mods));
3977
- }
3978
- /**
3979
- * Checks for mods that are incompatible with each other.
3980
- *
3981
- * @param mods The mods to check for.
3982
- * @returns Mods that have been filtered.
3983
- */
3984
- static checkIncompatibleMods(mods) {
3985
- for (let i = 0; i < mods.length; ++i) {
3986
- const mod = mods[i];
3987
- for (const incompatibleMod of mod.incompatibleMods) {
3988
- if (mods.some((m) => m !== mod && m instanceof incompatibleMod)) {
3989
- mods = mods.filter((m) =>
3990
- // Keep the mod itself.
3991
- m === mod || !(m instanceof incompatibleMod));
4101
+ else if (objectI instanceof Slider) {
4102
+ // We have hit the first slider in a possible stack.
4103
+ // From this point on, we ALWAYS stack positive regardless.
4104
+ while (--n >= startIndex) {
4105
+ const objectN = objects[n];
4106
+ if (objectN instanceof Spinner) {
4107
+ continue;
4108
+ }
4109
+ if (objectI.startTime - objectN.startTime >
4110
+ stackThreshold) {
4111
+ // We are no longer within stacking range of the previous object.
4112
+ break;
4113
+ }
4114
+ if (objectN.endPosition.getDistance(objectI.position) <
4115
+ BeatmapProcessor.stackDistance) {
4116
+ objectN.stackHeight = objectI.stackHeight + 1;
4117
+ objectI = objectN;
4118
+ }
3992
4119
  }
3993
4120
  }
3994
4121
  }
3995
- return mods;
3996
- }
3997
- /**
3998
- * Removes speed-changing mods from an array of mods.
3999
- *
4000
- * @param mods The array of mods.
4001
- * @returns A new array with speed changing mods filtered out.
4002
- */
4003
- static removeSpeedChangingMods(mods) {
4004
- return mods.filter((m) => !(m instanceof ModRateAdjust));
4005
4122
  }
4006
- /**
4007
- * Applies the selected `Mod`s to a `BeatmapDifficulty`.
4008
- *
4009
- * @param difficulty The `BeatmapDifficulty` to apply the `Mod`s to.
4010
- * @param mode The game mode to apply the `Mod`s for.
4011
- * @param mods The selected `Mod`s.
4012
- * @param withRateChange Whether to apply rate changes.
4013
- * @param oldStatistics Whether to enforce old statistics. Some `Mod`s behave differently with this flag.
4014
- */
4015
- static applyModsToBeatmapDifficulty(difficulty, mode, mods, withRateChange = false) {
4016
- for (const mod of mods) {
4017
- if (mod.isApplicableToDifficulty()) {
4018
- mod.applyToDifficulty(mode, difficulty);
4019
- }
4020
- }
4021
- let rate = 1;
4022
- for (const mod of mods) {
4023
- if (mod.isApplicableToDifficultyWithSettings()) {
4024
- mod.applyToDifficultyWithSettings(mode, difficulty, mods);
4025
- }
4026
- if (mod.isApplicableToTrackRate()) {
4027
- rate = mod.applyToRate(0, rate);
4123
+ applyStandardOldStacking() {
4124
+ const objects = this.beatmap.hitObjects.objects;
4125
+ for (let i = 0; i < objects.length; ++i) {
4126
+ const currentObject = objects[i];
4127
+ if (currentObject.stackHeight !== 0 &&
4128
+ !(currentObject instanceof Slider)) {
4129
+ continue;
4028
4130
  }
4029
- }
4030
- if (!withRateChange) {
4031
- return;
4032
- }
4033
- // Apply rate adjustments
4034
- const preempt = BeatmapDifficulty.difficultyRange(difficulty.ar, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin) / rate;
4035
- difficulty.ar = BeatmapDifficulty.inverseDifficultyRange(preempt, HitObject.preemptMax, HitObject.preemptMid, HitObject.preemptMin);
4036
- switch (mode) {
4037
- case exports.Modes.droid:
4038
- if (mods.some((m) => m instanceof ModPrecise)) {
4039
- const hitWindow = new PreciseDroidHitWindow(difficulty.od);
4040
- difficulty.od = PreciseDroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
4131
+ let startTime = currentObject.endTime;
4132
+ let sliderStack = 0;
4133
+ const stackThreshold = currentObject.timePreempt * this.beatmap.general.stackLeniency;
4134
+ for (let j = i + 1; j < objects.length; ++j) {
4135
+ if (objects[j].startTime - stackThreshold > startTime) {
4136
+ break;
4041
4137
  }
4042
- else {
4043
- const hitWindow = new DroidHitWindow(difficulty.od);
4044
- difficulty.od = DroidHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
4138
+ // Note the use of `startTime` in the code below doesn't match osu!stable's use of `endTime`.
4139
+ // This is because in osu!stable's implementation, `UpdateCalculations` is not called on the inner-loop hitobject (j)
4140
+ // and therefore it does not have a correct `endTime`, but instead the default of `endTime = startTime`.
4141
+ //
4142
+ // Effects of this can be seen on https://osu.ppy.sh/beatmapsets/243#osu/1146 at sliders around 86647 ms, where
4143
+ // if we use `endTime` here it would result in unexpected stacking.
4144
+ //
4145
+ // Reference: https://github.com/ppy/osu/pull/24188
4146
+ if (objects[j].position.getDistance(currentObject.position) <
4147
+ BeatmapProcessor.stackDistance) {
4148
+ ++currentObject.stackHeight;
4149
+ startTime = objects[j].startTime;
4150
+ }
4151
+ else if (objects[j].position.getDistance(currentObject.endPosition) <
4152
+ BeatmapProcessor.stackDistance) {
4153
+ // Case for sliders - bump notes down and right, rather than up and left.
4154
+ ++sliderStack;
4155
+ objects[j].stackHeight -= sliderStack;
4156
+ startTime = objects[j].startTime;
4045
4157
  }
4046
- break;
4047
- case exports.Modes.osu: {
4048
- const hitWindow = new OsuHitWindow(difficulty.od);
4049
- difficulty.od = OsuHitWindow.greatWindowToOD(hitWindow.greatWindow / rate);
4050
- break;
4051
- }
4052
- }
4053
- }
4054
- /**
4055
- * Calculates the playback rate for the track with the selected `Mod`s at the given time.
4056
- *
4057
- * @param mods The list of selected `Mod`s.
4058
- * @param time The time at which the playback rate is queried, in milliseconds. Defaults to 0.
4059
- * @returns The rate with `Mod`s.
4060
- */
4061
- static calculateRateWithMods(mods, time = 0) {
4062
- let rate = 1;
4063
- for (const mod of mods) {
4064
- if (mod.isApplicableToTrackRate()) {
4065
- rate = mod.applyToRate(time, rate);
4066
4158
  }
4067
4159
  }
4068
- return rate;
4069
- }
4070
- /**
4071
- * Processes parsing options.
4072
- *
4073
- * @param mods The mods to process.
4074
- * @param options The options to process.
4075
- * @returns The processed mods.
4076
- */
4077
- static processParsingOptions(mods, options) {
4078
- if ((options === null || options === void 0 ? void 0 : options.checkDuplicate) !== false) {
4079
- mods = this.checkDuplicateMods(mods);
4080
- }
4081
- if ((options === null || options === void 0 ? void 0 : options.checkIncompatible) !== false) {
4082
- mods = this.checkIncompatibleMods(mods);
4083
- }
4084
- return mods;
4085
4160
  }
4086
4161
  }
4087
- /**
4088
- * All `Mod`s that exists, mapped by their acronym.
4089
- */
4090
- ModUtil.allMods = (() => {
4091
- const mods = [
4092
- // Janky order to keep the order on what players are used to
4093
- ModAuto,
4094
- ModRelax,
4095
- ModAutopilot,
4096
- ModEasy,
4097
- ModNoFail,
4098
- ModHidden,
4099
- ModTraceable,
4100
- ModDoubleTime,
4101
- ModNightCore,
4102
- ModHalfTime,
4103
- ModCustomSpeed,
4104
- ModHardRock,
4105
- ModDifficultyAdjust,
4106
- ModFlashlight,
4107
- ModSuddenDeath,
4108
- ModPerfect,
4109
- ModPrecise,
4110
- ModReallyEasy,
4111
- ModScoreV2,
4112
- ModSmallCircle,
4113
- ModSpunOut,
4114
- ModTouchDevice,
4115
- ];
4116
- const map = new Map();
4117
- for (const mod of mods) {
4118
- map.set(new mod().acronym, mod);
4119
- }
4120
- return map;
4121
- })();
4162
+ BeatmapProcessor.stackDistance = 3;
4122
4163
 
4123
4164
  /**
4124
4165
  * Represents a beatmap that is in a playable state for a specific game mode.
@@ -4149,8 +4190,8 @@ class PlayableBeatmap {
4149
4190
  this.colors = baseBeatmap.colors;
4150
4191
  this.hitObjects = baseBeatmap.hitObjects;
4151
4192
  this.maxCombo = baseBeatmap.maxCombo;
4152
- this.mods = mods.slice();
4153
- this.speedMultiplier = ModUtil.calculateRateWithMods(this.mods);
4193
+ this.mods = mods;
4194
+ this.speedMultiplier = ModUtil.calculateRateWithMods(this.mods.values());
4154
4195
  }
4155
4196
  }
4156
4197
 
@@ -4159,7 +4200,7 @@ class PlayableBeatmap {
4159
4200
  */
4160
4201
  class DroidPlayableBeatmap extends PlayableBeatmap {
4161
4202
  createHitWindow() {
4162
- if (this.mods.some((m) => m instanceof ModPrecise)) {
4203
+ if (this.mods.has(ModPrecise)) {
4163
4204
  return new PreciseDroidHitWindow(this.difficulty.od);
4164
4205
  }
4165
4206
  else {
@@ -4912,15 +4953,17 @@ class Beatmap {
4912
4953
  *
4913
4954
  * @param mods The modifications to calculate for. Defaults to No Mod.
4914
4955
  */
4915
- maxDroidScore(mods = []) {
4956
+ maxDroidScore(mods) {
4916
4957
  let scoreMultiplier = 1;
4917
- for (const mod of mods) {
4918
- if (mod.isApplicableToDroid()) {
4919
- scoreMultiplier *= mod.calculateDroidScoreMultiplier(this.difficulty);
4958
+ if (mods) {
4959
+ for (const mod of mods.values()) {
4960
+ if (mod.isApplicableToDroid()) {
4961
+ scoreMultiplier *= mod.calculateDroidScoreMultiplier(this.difficulty);
4962
+ }
4963
+ }
4964
+ if (mods.has(ModScoreV2)) {
4965
+ return 1e6 * scoreMultiplier;
4920
4966
  }
4921
- }
4922
- if (mods.some((m) => m instanceof ModScoreV2)) {
4923
- return 1e6 * scoreMultiplier;
4924
4967
  }
4925
4968
  const difficultyMultiplier = 1 +
4926
4969
  this.difficulty.od / 10 +
@@ -4955,17 +4998,19 @@ class Beatmap {
4955
4998
  *
4956
4999
  * @param mods The modifications to calculate for. Defaults to No Mod.
4957
5000
  */
4958
- maxOsuScore(mods = []) {
5001
+ maxOsuScore(mods) {
4959
5002
  const accumulatedDiffPoints = this.difficulty.cs + this.difficulty.hp + this.difficulty.od;
4960
5003
  let difficultyMultiplier = 2;
4961
5004
  let scoreMultiplier = 1;
4962
- for (const mod of mods) {
4963
- if (mod.isApplicableToOsu()) {
4964
- scoreMultiplier *= mod.osuScoreMultiplier;
5005
+ if (mods) {
5006
+ for (const mod of mods.values()) {
5007
+ if (mod.isApplicableToOsu()) {
5008
+ scoreMultiplier *= mod.osuScoreMultiplier;
5009
+ }
5010
+ }
5011
+ if (mods.has(ModScoreV2)) {
5012
+ return 1e6 * scoreMultiplier;
4965
5013
  }
4966
- }
4967
- if (mods.some((m) => m instanceof ModScoreV2)) {
4968
- return 1e6 * scoreMultiplier;
4969
5014
  }
4970
5015
  switch (true) {
4971
5016
  case accumulatedDiffPoints <= 5:
@@ -5020,7 +5065,7 @@ class Beatmap {
5020
5065
  * @param mods The `Mod`s to apply to the `Beatmap`. Defaults to No Mod.
5021
5066
  * @return The constructed `DroidPlayableBeatmap`.
5022
5067
  */
5023
- createDroidPlayableBeatmap(mods = []) {
5068
+ createDroidPlayableBeatmap(mods = new ModMap()) {
5024
5069
  return new DroidPlayableBeatmap(this.createPlayableBeatmap(mods, exports.Modes.droid), mods);
5025
5070
  }
5026
5071
  /**
@@ -5032,11 +5077,11 @@ class Beatmap {
5032
5077
  * @param mods The `Mod`s to apply to the `Beatmap`. Defaults to No Mod.
5033
5078
  * @return The constructed `OsuPlayableBeatmap`.
5034
5079
  */
5035
- createOsuPlayableBeatmap(mods = []) {
5080
+ createOsuPlayableBeatmap(mods = new ModMap()) {
5036
5081
  return new OsuPlayableBeatmap(this.createPlayableBeatmap(mods, exports.Modes.osu), mods);
5037
5082
  }
5038
5083
  createPlayableBeatmap(mods, mode) {
5039
- if (this.mode === mode && mods.length === 0) {
5084
+ if (this.mode === mode && mods.size === 0) {
5040
5085
  // Beatmap is already in a playable state.
5041
5086
  return this;
5042
5087
  }
@@ -8198,39 +8243,39 @@ class DroidLegacyModConverter {
8198
8243
  * @returns An array of `Mod`s.
8199
8244
  */
8200
8245
  static convert(str, difficulty) {
8246
+ const map = new ModMap();
8201
8247
  if (!str) {
8202
- return [];
8248
+ return map;
8203
8249
  }
8204
8250
  const data = str.split("|");
8205
8251
  if (!data[0]) {
8206
- return [];
8252
+ return map;
8207
8253
  }
8208
- const mods = [];
8209
8254
  for (const c of data[0]) {
8210
- const modType = this.droidLegacyStorableMods.get(c);
8255
+ const modType = this.legacyStorableMods.get(c);
8211
8256
  if (!modType) {
8212
8257
  continue;
8213
8258
  }
8214
8259
  const mod = new modType();
8215
8260
  if (mod.isMigratableDroidMod() && difficulty) {
8216
- mods.push(mod.migrateDroidMod(difficulty));
8261
+ map.set(mod.migrateDroidMod(difficulty));
8217
8262
  }
8218
8263
  else {
8219
- mods.push(mod);
8264
+ map.set(mod);
8220
8265
  }
8221
8266
  }
8222
8267
  if (data.length > 1) {
8223
- this.parseExtraModString(mods, data.slice(1));
8268
+ this.parseExtraModString(map, data.slice(1));
8224
8269
  }
8225
- return mods;
8270
+ return map;
8226
8271
  }
8227
8272
  /**
8228
8273
  * Parses the extra strings of a mod string.
8229
8274
  *
8230
- * @param mods The current `Mod`s.
8275
+ * @param map The current `Mod`s.
8231
8276
  * @param extraStrings The extra strings to parse.
8232
8277
  */
8233
- static parseExtraModString(mods, extraStrings) {
8278
+ static parseExtraModString(map, extraStrings) {
8234
8279
  let customCS;
8235
8280
  let customAR;
8236
8281
  let customOD;
@@ -8252,20 +8297,20 @@ class DroidLegacyModConverter {
8252
8297
  break;
8253
8298
  // FL follow delay
8254
8299
  case s.startsWith("FLD"): {
8255
- let flashlight = mods.find((m) => m instanceof ModFlashlight);
8300
+ let flashlight = map.get(ModFlashlight);
8256
8301
  if (!flashlight) {
8257
8302
  flashlight = new ModFlashlight();
8258
- mods.push(flashlight);
8303
+ map.set(flashlight);
8259
8304
  }
8260
8305
  flashlight.followDelay = parseFloat(s.slice(3));
8261
8306
  break;
8262
8307
  }
8263
8308
  // Speed multiplier
8264
8309
  case s.startsWith("x"): {
8265
- let customSpeed = mods.find((m) => m instanceof ModCustomSpeed);
8310
+ let customSpeed = map.get(ModCustomSpeed);
8266
8311
  if (!customSpeed) {
8267
8312
  customSpeed = new ModCustomSpeed();
8268
- mods.push(customSpeed);
8313
+ map.set(customSpeed);
8269
8314
  }
8270
8315
  customSpeed.trackRateMultiplier = parseFloat(s.slice(1));
8271
8316
  break;
@@ -8276,7 +8321,7 @@ class DroidLegacyModConverter {
8276
8321
  customAR !== undefined ||
8277
8322
  customOD !== undefined ||
8278
8323
  customHP !== undefined) {
8279
- mods.push(new ModDifficultyAdjust({
8324
+ map.set(new ModDifficultyAdjust({
8280
8325
  cs: customCS,
8281
8326
  ar: customAR,
8282
8327
  od: customOD,
@@ -8288,7 +8333,7 @@ class DroidLegacyModConverter {
8288
8333
  /**
8289
8334
  * All `Mod`s that can be stored in the legacy mods format by their respective encode character.
8290
8335
  */
8291
- DroidLegacyModConverter.droidLegacyStorableMods = new Map([
8336
+ DroidLegacyModConverter.legacyStorableMods = new Map([
8292
8337
  ["a", ModAuto],
8293
8338
  ["b", ModTraceable],
8294
8339
  ["c", ModNightCore],
@@ -9829,6 +9874,7 @@ exports.ModFlashlight = ModFlashlight;
9829
9874
  exports.ModHalfTime = ModHalfTime;
9830
9875
  exports.ModHardRock = ModHardRock;
9831
9876
  exports.ModHidden = ModHidden;
9877
+ exports.ModMap = ModMap;
9832
9878
  exports.ModNightCore = ModNightCore;
9833
9879
  exports.ModNoFail = ModNoFail;
9834
9880
  exports.ModOldNightCore = ModOldNightCore;