@rian8337/osu-droid-replay-analyzer 4.0.0-beta.24 → 4.0.0-beta.26

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 +808 -886
  2. package/package.json +5 -5
  3. package/typings/index.d.ts +224 -112
package/dist/index.js CHANGED
@@ -294,19 +294,15 @@ exports.HitResult = void 0;
294
294
  * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
295
295
  */
296
296
  class RebalanceThreeFingerChecker {
297
+ get hitWindow50() {
298
+ return this.hitWindow.hitWindowFor50(this.isPrecise);
299
+ }
297
300
  /**
298
301
  * @param beatmap The beatmap to analyze.
299
302
  * @param data The data of the replay.
300
303
  * @param difficultyAttributes The difficulty attributes of the beatmap.
301
304
  */
302
305
  constructor(beatmap, data, difficultyAttributes) {
303
- /**
304
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
305
- *
306
- * Increasing this number will increase detection accuracy, however
307
- * it also increases the chance of falsely flagged plays.
308
- */
309
- this.threeFingerRatioThreshold = 0.01;
310
306
  /**
311
307
  * Extended sections of the beatmap for drag detection.
312
308
  */
@@ -331,17 +327,9 @@ class RebalanceThreeFingerChecker {
331
327
  this.beatmap = beatmap;
332
328
  this.data = data;
333
329
  this.difficultyAttributes = difficultyAttributes;
334
- const od = osuBase.calculateDroidDifficultyStatistics({
335
- overallDifficulty: beatmap.difficulty.od,
336
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
337
- convertOverallDifficulty: false,
338
- }).overallDifficulty;
339
- this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
340
- this.hitWindow = new osuBase.DroidHitWindow(od);
341
- this.hitObjects = beatmap.hitObjects.objects;
342
- this.objectRadiusThreshold = this.hitObjects[0].radius;
343
- // Only allow object radius threshold to deviate slightly from actual object radius.
344
- this.maximumObjectRadiusThreshold = this.objectRadiusThreshold * 1.05;
330
+ this.isPrecise = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
331
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
332
+ this.hitWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od);
345
333
  }
346
334
  /**
347
335
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -362,7 +350,8 @@ class RebalanceThreeFingerChecker {
362
350
  * nerf factor, taking beatmap difficulty into account.
363
351
  */
364
352
  check() {
365
- if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
353
+ if (!RebalanceThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
354
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
366
355
  return { is3Finger: false, penalty: 1 };
367
356
  }
368
357
  this.getAccurateBreakPoints();
@@ -382,10 +371,11 @@ class RebalanceThreeFingerChecker {
382
371
  * end of the hitobject before it and do not end right at the first hitobject after it.
383
372
  */
384
373
  getAccurateBreakPoints() {
374
+ const objects = this.beatmap.hitObjects.objects;
385
375
  const objectData = this.data.hitObjectData;
386
376
  for (const breakPoint of this.beatmap.events.breaks) {
387
- const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
388
- const objectBefore = this.hitObjects[beforeIndex];
377
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
378
+ const objectBefore = objects[beforeIndex];
389
379
  const objectBeforeData = objectData[beforeIndex];
390
380
  let timeBefore = objectBefore.endTime;
391
381
  if (objectBefore instanceof osuBase.Circle) {
@@ -397,9 +387,9 @@ class RebalanceThreeFingerChecker {
397
387
  }
398
388
  }
399
389
  const afterIndex = beforeIndex + 1;
400
- const objectAfter = this.hitObjects[afterIndex];
390
+ const objectAfter = objects[afterIndex];
401
391
  const objectAfterData = objectData[afterIndex];
402
- let timeAfter = this.hitObjects[afterIndex].startTime;
392
+ let timeAfter = objectAfter.startTime;
403
393
  if (objectAfter instanceof osuBase.Circle &&
404
394
  objectAfterData.result !== exports.HitResult.miss) {
405
395
  timeAfter += objectAfterData.accuracy;
@@ -416,11 +406,12 @@ class RebalanceThreeFingerChecker {
416
406
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
417
407
  */
418
408
  filterCursorInstances() {
409
+ const objects = this.beatmap.hitObjects.objects;
419
410
  const objectData = this.data.hitObjectData;
420
411
  const firstObjectResult = objectData[0].result;
421
412
  const lastObjectResult = objectData.at(-1).result;
422
- const firstObject = this.hitObjects[0];
423
- const lastObject = this.hitObjects.at(-1);
413
+ const firstObject = objects[0];
414
+ const lastObject = objects.at(-1);
424
415
  // For sliders, automatically set hit window length to be as lenient as possible.
425
416
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
426
417
  if (firstObject instanceof osuBase.Circle) {
@@ -480,289 +471,202 @@ class RebalanceThreeFingerChecker {
480
471
  * detect dragged sections and improve detection speed.
481
472
  */
482
473
  getBeatmapSections() {
483
- const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
474
+ const beatmapObjects = this.beatmap.hitObjects.objects;
475
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
476
+ // This intentionally starts from 1 because we need to look at the previous cursor.
477
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
478
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
484
479
  for (const section of this.difficultyAttributes
485
480
  .possibleThreeFingeredSections) {
486
- const dragFingerIndex = this.findDragIndex(section);
487
481
  const objects = [];
488
482
  for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
489
- objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
483
+ const object = beatmapObjects[i];
484
+ const objectData = this.data.hitObjectData[i];
485
+ objects.push({
486
+ object: object,
487
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
488
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
489
+ });
490
490
  }
491
- this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
491
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
492
492
  }
493
493
  }
494
494
  /**
495
- * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
495
+ * Obtains the index of the cursor that aimed the object at the nearest time.
496
496
  *
497
497
  * @param object The object to obtain the index for.
498
498
  * @param objectData The hit data of the object.
499
- * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
500
- * @param excludedIndices The cursor indices that should not be checked.
499
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
500
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
501
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
501
502
  * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
502
503
  */
503
- getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
504
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
504
505
  if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
505
- return {
506
- pressingCursorInstanceIndex: -1,
507
- pressingCursorIndex: -1,
508
- };
506
+ return -1;
509
507
  }
510
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
511
508
  // Check for sliderbreaks and treat them as misses.
512
509
  if (object instanceof osuBase.Slider &&
513
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
514
- return {
515
- pressingCursorInstanceIndex: -1,
516
- pressingCursorIndex: -1,
517
- };
518
- }
519
- // We are not directly using hit time to determine which cursor pressed the object
520
- // to account for time difference between hit registration and object judgement.
521
- let minHitTime = object.startTime;
522
- let maxHitTime = object.startTime;
523
- if (object instanceof osuBase.Circle) {
524
- let hitWindowGap = hitWindow50;
525
- switch (objectData.result) {
526
- case exports.HitResult.great:
527
- hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
528
- break;
529
- case exports.HitResult.good:
530
- hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
531
- break;
532
- }
533
- minHitTime -= hitWindowGap;
534
- maxHitTime += hitWindowGap;
535
- }
536
- else if (object instanceof osuBase.Slider) {
537
- minHitTime -= hitWindow50;
538
- maxHitTime += Math.min(hitWindow50, object.spanDuration);
510
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
511
+ return -1;
539
512
  }
540
513
  const hitTime = object.startTime + objectData.accuracy;
541
- let nearestCursorInstanceIndex = null;
542
- let nearestCursorIndex = null;
543
- let nearestTime = Number.POSITIVE_INFINITY;
544
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
545
- if (excludedCursorIndices.includes(i)) {
546
- continue;
547
- }
548
- const cursors = this.downCursorInstances[i];
549
- for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
550
- const cursor = cursors[j];
551
- if (cursor.time < minHitTime) {
514
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
515
+ // We are maintaining the closest distance to the object.
516
+ // This is because the radius that is calculated is using an estimation.
517
+ // As such, it does not reflect the actual object radius in gameplay.
518
+ let closestDistance = Number.POSITIVE_INFINITY;
519
+ let nearestCursorIndex = -1;
520
+ // Observe the cursor position at the object's hit time.
521
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
522
+ const cursorData = this.data.cursorMovement[i];
523
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
524
+ const cursorGroup = cursorData.occurrenceGroups[j];
525
+ if (cursorGroup.endTime < hitTime) {
526
+ // Reset cursor index pointer.
527
+ cursorIndices[i] = 1;
552
528
  continue;
553
529
  }
554
- if (cursor.time > maxHitTime) {
530
+ if (cursorGroup.startTime > hitTime) {
555
531
  break;
556
532
  }
557
- const deltaTime = Math.abs(hitTime - cursor.time);
558
- if (deltaTime > nearestTime) {
559
- break;
533
+ const cursors = cursorGroup.allOccurrences;
534
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
535
+ const cursor = cursors[k];
536
+ const prevCursor = cursors[k - 1];
537
+ // Cursor is past the object's hit time.
538
+ if (prevCursor.time > hitTime) {
539
+ break;
540
+ }
541
+ // Cursor is before the object's hit time.
542
+ if (hitTime > cursor.time) {
543
+ continue;
544
+ }
545
+ let distance;
546
+ const currentPosition = this.getCursorPosition(cursor);
547
+ const prevPosition = this.getCursorPosition(prevCursor);
548
+ switch (cursor.id) {
549
+ case exports.MovementType.up:
550
+ distance = prevPosition.getDistance(objectPosition);
551
+ break;
552
+ case exports.MovementType.move: {
553
+ // Interpolate movement.
554
+ const t = (hitTime - prevCursor.time) /
555
+ (cursor.time - prevCursor.time);
556
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
557
+ distance =
558
+ objectPosition.getDistance(cursorPosition);
559
+ break;
560
+ }
561
+ case exports.MovementType.down:
562
+ continue;
563
+ }
564
+ if (closestDistance > distance) {
565
+ closestDistance = distance;
566
+ nearestCursorIndex = i;
567
+ }
560
568
  }
561
- nearestCursorInstanceIndex = i;
562
- nearestCursorIndex = j;
563
- nearestTime = deltaTime;
569
+ // Reset cursor index pointer on end of group.
570
+ if (cursorIndices[i] === cursors.length) {
571
+ cursorIndices[i] = 1;
572
+ }
573
+ break;
564
574
  }
575
+ // The previous object may still be hit with the same cursor group or cursor index.
576
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
577
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
565
578
  }
566
- return {
567
- pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
568
- pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
569
- };
579
+ return nearestCursorIndex;
570
580
  }
571
581
  /**
572
- * Checks if a section is dragged and returns the index of the drag finger.
573
- *
574
- * If the section is not dragged, -1 will be returned.
582
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
575
583
  *
576
- * @param section The section to check.
584
+ * @param object The object to obtain the index for.
585
+ * @param objectData The hit data of the object.
586
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
587
+ * @param excludedIndices The cursor indices that should not be checked.
588
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
577
589
  */
578
- findDragIndex(section) {
579
- var _a;
580
- const objectData = this.data.hitObjectData;
581
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
582
- const firstObject = this.hitObjects[section.firstObjectIndex];
583
- const firstObjectData = objectData[section.firstObjectIndex];
584
- const lastObject = this.hitObjects[section.lastObjectIndex];
585
- const lastObjectData = objectData[section.lastObjectIndex];
586
- let firstObjectHitTime = firstObject.startTime;
587
- let lastObjectHitTime = lastObject.startTime;
588
- // Check for slider breaks.
589
- if (firstObject instanceof osuBase.Slider &&
590
- firstObjectData.accuracy === Math.floor(hitWindow50) + 13) {
591
- firstObjectHitTime -= hitWindow50;
592
- }
593
- else {
594
- firstObjectHitTime += firstObjectData.accuracy;
595
- }
596
- if (lastObject instanceof osuBase.Slider &&
597
- lastObjectData.accuracy === Math.floor(hitWindow50) + 13) {
598
- lastObjectHitTime += Math.min(hitWindow50, lastObject.nestedHitObjects[1].startTime - lastObject.startTime);
590
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
591
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
592
+ return -1;
599
593
  }
600
- else {
601
- lastObjectHitTime += lastObjectData.accuracy;
594
+ // Check for sliderbreaks and treat them as misses.
595
+ if (object instanceof osuBase.Slider &&
596
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
597
+ return -1;
602
598
  }
603
- // Since there may be more than 1 cursor instance index,
604
- // we check which cursor instance follows hitobjects all over.
605
- const cursorInstanceIndices = [];
606
- const cursorGroupIndices = [];
607
- const cursorIndices = [];
608
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
609
- const c = this.data.cursorMovement[i];
610
- if (c.occurrenceGroups.length === 0) {
611
- continue;
612
- }
613
- // Do not include cursors that don't have an occurence in this section
614
- // this speeds up checking process.
615
- if (c.occurrenceGroups.filter((v) => v.startTime <= firstObjectHitTime &&
616
- v.endTime >= lastObjectHitTime).length === 0) {
617
- continue;
618
- }
619
- // If this cursor instance doesn't move, it's not the cursor instance we want.
620
- if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
621
- 0) {
622
- continue;
623
- }
624
- cursorInstanceIndices.push(i);
625
- cursorGroupIndices.push(0);
626
- // Start checking from the second cursor.
627
- cursorIndices.push(1);
628
- }
629
- const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
630
- const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
631
- for (let i = 0; i < sectionObjects.length &&
632
- cursorInstanceIndices.some((v) => v !== -1); ++i) {
633
- const object = sectionObjects[i];
634
- const objectData = sectionReplayObjectData[i];
635
- if (object instanceof osuBase.Spinner ||
636
- objectData.result === exports.HitResult.miss) {
637
- continue;
638
- }
639
- // Exclude sliderbreaks.
640
- if (object instanceof osuBase.Slider &&
641
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
642
- continue;
643
- }
644
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
645
- const hitTime = object.startTime + objectData.accuracy;
646
- // Observe the cursor position at the object's hit time.
647
- for (let j = 0; j < cursorInstanceIndices.length; ++j) {
648
- if (cursorInstanceIndices[j] === -1) {
649
- continue;
650
- }
651
- const cursorData = this.data.cursorMovement[cursorInstanceIndices[j]];
652
- for (let k = cursorGroupIndices[j]; k < cursorData.occurrenceGroups.length; cursorGroupIndices[j] = ++k) {
653
- const cursorGroup = cursorData.occurrenceGroups[k];
654
- if (!cursorGroup.isActiveAt(hitTime)) {
655
- continue;
656
- }
657
- const cursors = cursorGroup.allOccurrences;
658
- // We are maintaining the closest distance of cursors to the object.
659
- // This is because the radius that is calculated is using an estimation. As such,
660
- // it does not perfectly reflect the actual object radius in gameplay.
661
- let closestDistance = this.maximumObjectRadiusThreshold;
662
- for (let l = cursorIndices[j]; l < cursors.length; cursorIndices[j] = ++l) {
663
- const cursor = cursors[l];
664
- const prevCursor = cursors[l - 1];
665
- // Cursor is past the object's hit time.
666
- if (prevCursor.time > hitTime) {
667
- break;
668
- }
669
- // Cursor is before the object's hit time.
670
- if (hitTime > cursor.time) {
671
- continue;
672
- }
673
- let distance;
674
- switch (cursor.id) {
675
- case exports.MovementType.up:
676
- distance =
677
- prevCursor.position.getDistance(objectPosition);
678
- break;
679
- case exports.MovementType.move: {
680
- // Interpolate movement.
681
- const t = (hitTime - prevCursor.time) /
682
- (cursor.time - prevCursor.time);
683
- const cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
684
- distance =
685
- objectPosition.getDistance(cursorPosition);
686
- break;
687
- }
688
- case exports.MovementType.down:
689
- continue;
690
- }
691
- closestDistance = Math.min(closestDistance, distance);
692
- }
693
- // At this point, the object is guaranteed to be hit, but the distance may not reach the threshold
694
- // due to radius estimation. In order to work around this, we store the farthest distance from all
695
- // processed cursors so far and use it as threshold rather than the actual object radius.
696
- if (closestDistance <= this.maximumObjectRadiusThreshold &&
697
- closestDistance > this.objectRadiusThreshold) {
698
- this.objectRadiusThreshold = closestDistance;
699
- }
700
- if (closestDistance > this.objectRadiusThreshold) {
701
- cursorInstanceIndices[j] = -1;
702
- }
599
+ const hitTime = object.startTime + objectData.accuracy;
600
+ let nearestCursorInstanceIndex = -1;
601
+ let nearestTime = Number.POSITIVE_INFINITY;
602
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
603
+ const cursors = this.downCursorInstances[i];
604
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
605
+ for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
606
+ const cursor = cursors[j];
607
+ if (cursor.time > hitTime) {
703
608
  break;
704
609
  }
705
- // The previous object may still be hit with the same cursor group or cursor index.
706
- cursorGroupIndices[j] = Math.max(0, cursorGroupIndices[j] - 1);
707
- cursorIndices[j] = Math.max(1, cursorIndices[j] - 1);
610
+ cursorNearestTime = hitTime - cursor.time;
611
+ }
612
+ if (cursorNearestTime < nearestTime) {
613
+ nearestCursorInstanceIndex = i;
614
+ nearestTime = cursorNearestTime;
708
615
  }
709
616
  }
710
- return (_a = cursorInstanceIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
617
+ return nearestCursorInstanceIndex;
711
618
  }
712
619
  /**
713
620
  * Creates nerf factors by scanning through objects.
714
621
  */
715
622
  calculateNerfFactors() {
716
623
  for (const beatmapSection of this.beatmapSections) {
717
- const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
624
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
718
625
  for (const object of beatmapSection.objects) {
719
- if (object.pressingCursorIndex === -1 ||
720
- object.pressingCursorInstanceIndex ===
721
- beatmapSection.dragFingerIndex) {
626
+ if (object.pressingCursorInstanceIndex === -1) {
722
627
  continue;
723
628
  }
724
- ++cursorCounts[object.pressingCursorInstanceIndex];
725
- }
726
- if (beatmapSection.dragFingerIndex !== -1) {
727
- // Remove the drag index to prevent it from being picked up into the detection.
728
- cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
629
+ if (object.aimingCursorInstanceIndex < 3) {
630
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
631
+ switch (object.pressingCursorInstanceIndex) {
632
+ case 0:
633
+ case 1:
634
+ case 2:
635
+ break;
636
+ default:
637
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
638
+ break;
639
+ }
640
+ }
641
+ else {
642
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
643
+ switch (object.pressingCursorInstanceIndex) {
644
+ case 0:
645
+ case 1:
646
+ break;
647
+ default:
648
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
649
+ break;
650
+ }
651
+ }
729
652
  }
730
- // This index will be used to detect if a section is 3-fingered.
731
- // If the section is dragged, the dragged instance will be ignored,
732
- // hence why the index is 1 less than nondragged section.
733
- const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
734
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
735
- // to check if the section is 3-fingered.
736
- const threeFingerRatio = cursorCounts
737
- .slice(fingerSplitIndex)
738
- .reduce((acc, value) => acc + value, 0) /
739
- Math.max(cursorCounts
740
- .slice(0, fingerSplitIndex)
741
- .reduce((acc, value) => acc + value, 0), 1);
742
- if (threeFingerRatio > this.threeFingerRatioThreshold) {
743
- const threeFingeredObjectCount = cursorCounts
744
- .slice(fingerSplitIndex)
745
- .reduce((acc, value) => acc + value, 0);
746
- const sectionObjectCount = beatmapSection.lastObjectIndex -
747
- beatmapSection.firstObjectIndex +
748
- 1;
749
- const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
750
- // We can ignore the first 3 (2 for drag) filled cursor instances
751
- // since they are guaranteed not 3 finger.
752
- const threeFingerCursorCounts = cursorCounts
753
- .slice(fingerSplitIndex)
754
- .filter((amount) => amount > 0);
755
- // Finger factor applies more penalty if more fingers were used.
756
- const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
757
- Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
758
- // Length factor applies more penalty if there are more 3-fingered object.
759
- const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
760
- this.nerfFactors.push({
761
- strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
762
- fingerFactor: fingerFactor,
763
- lengthFactor: lengthFactor,
764
- });
653
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
654
+ if (threeFingerCursorCount === 0) {
655
+ continue;
765
656
  }
657
+ const sectionObjectCount = beatmapSection.objects.length;
658
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
659
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
660
+ // Finger factor applies more penalty if more fingers were used.
661
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
662
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
663
+ // Length factor applies more penalty if there are more 3-fingered object.
664
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
665
+ this.nerfFactors.push({
666
+ strainFactor: strainFactor,
667
+ fingerFactor: fingerFactor,
668
+ lengthFactor: lengthFactor,
669
+ });
766
670
  }
767
671
  }
768
672
  /**
@@ -773,6 +677,12 @@ class RebalanceThreeFingerChecker {
773
677
  0.015 *
774
678
  Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
775
679
  }
680
+ getCursorPosition(cursor) {
681
+ if (this.isHardRock) {
682
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
683
+ }
684
+ return cursor.position;
685
+ }
776
686
  }
777
687
 
778
688
  /******************************************************************************
@@ -808,9 +718,9 @@ typeof SuppressedError === "function" ? SuppressedError : function (error, suppr
808
718
  };
809
719
 
810
720
  /**
811
- * Represents a replay data in an osu!droid replay.
721
+ * Represents a replay data in an osu!droid replay version 1 and 2.
812
722
  *
813
- * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
723
+ * Stores generic information about an osu!droid replay.
814
724
  *
815
725
  * This is used when analyzing replays using replay analyzer.
816
726
  */
@@ -820,45 +730,46 @@ class ReplayData {
820
730
  this.folderName = values.folderName;
821
731
  this.fileName = values.fileName;
822
732
  this.hash = values.hash;
823
- this.time = new Date(values.time || 0);
824
- this.hit300k = values.hit300k || 0;
825
- this.hit100k = values.hit100k || 0;
826
- this.score = values.score || 0;
827
- this.maxCombo = values.maxCombo || 0;
828
- this.accuracy = values.accuracy || new osuBase.Accuracy({});
829
- this.isFullCombo = values.isFullCombo || false;
830
- this.playerName = values.playerName || "";
831
- this.rawMods = values.rawMods || "";
832
- this.rank = values.rank || "";
833
- this.convertedMods = values.convertedMods || [];
733
+ this.accuracy = values.accuracy;
734
+ this.rank = values.rank;
735
+ this.hit300k = values.hit300k;
736
+ this.hit100k = values.hit100k;
834
737
  this.cursorMovement = values.cursorMovement;
835
738
  this.hitObjectData = values.hitObjectData;
836
- this.speedMultiplier = values.speedMultiplier || 1;
837
- this.forceCS = values.forceCS;
838
- this.forceAR = values.forceAR;
839
- this.forceOD = values.forceOD;
840
- this.forceHP = values.forceHP;
841
- this.flashlightFollowDelay = values.flashlightFollowDelay;
739
+ }
740
+ /**
741
+ * Whether the replay's version is 3 or later.
742
+ */
743
+ isReplayV3() {
744
+ return this.replayVersion >= 3;
745
+ }
746
+ /**
747
+ * Whether the replay's version is 4 or later.
748
+ */
749
+ isReplayV4() {
750
+ return this.replayVersion >= 4;
751
+ }
752
+ /**
753
+ * Whether the replay's version is 5 or later.
754
+ */
755
+ isReplayV5() {
756
+ return this.replayVersion >= 5;
842
757
  }
843
758
  }
844
759
 
845
760
  /**
846
- * Utility to check whether or not a beatmap is three-fingered.
761
+ * Utility to check whether or not a beatmap is three-fingered for rebalance scores.
847
762
  */
848
763
  class ThreeFingerChecker {
764
+ get hitWindow50() {
765
+ return this.hitWindow.hitWindowFor50(this.isPrecise);
766
+ }
849
767
  /**
850
768
  * @param beatmap The beatmap to analyze.
851
769
  * @param data The data of the replay.
852
770
  * @param difficultyAttributes The difficulty attributes of the beatmap.
853
771
  */
854
772
  constructor(beatmap, data, difficultyAttributes) {
855
- /**
856
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
857
- *
858
- * Increasing this number will increase detection accuracy, however
859
- * it also increases the chance of falsely flagged plays.
860
- */
861
- this.threeFingerRatioThreshold = 0.01;
862
773
  /**
863
774
  * Extended sections of the beatmap for drag detection.
864
775
  */
@@ -883,14 +794,9 @@ class ThreeFingerChecker {
883
794
  this.beatmap = beatmap;
884
795
  this.data = data;
885
796
  this.difficultyAttributes = difficultyAttributes;
886
- const od = osuBase.calculateDroidDifficultyStatistics({
887
- overallDifficulty: beatmap.difficulty.od,
888
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
889
- convertOverallDifficulty: false,
890
- }).overallDifficulty;
891
- this.isPrecise = this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
892
- this.hitWindow = new osuBase.DroidHitWindow(od);
893
- this.hitObjects = beatmap.hitObjects.objects;
797
+ this.isPrecise = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise);
798
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
799
+ this.hitWindow = new osuBase.DroidHitWindow(beatmap.difficulty.od);
894
800
  }
895
801
  /**
896
802
  * Checks whether a beatmap is eligible to be detected for 3-finger.
@@ -911,7 +817,8 @@ class ThreeFingerChecker {
911
817
  * nerf factor, taking beatmap difficulty into account.
912
818
  */
913
819
  check() {
914
- if (this.difficultyAttributes.possibleThreeFingeredSections.length === 0) {
820
+ if (!ThreeFingerChecker.isEligibleToDetect(this.difficultyAttributes) ||
821
+ this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 3) {
915
822
  return { is3Finger: false, penalty: 1 };
916
823
  }
917
824
  this.getAccurateBreakPoints();
@@ -931,10 +838,11 @@ class ThreeFingerChecker {
931
838
  * end of the hitobject before it and do not end right at the first hitobject after it.
932
839
  */
933
840
  getAccurateBreakPoints() {
841
+ const objects = this.beatmap.hitObjects.objects;
934
842
  const objectData = this.data.hitObjectData;
935
843
  for (const breakPoint of this.beatmap.events.breaks) {
936
- const beforeIndex = osuBase.MathUtils.clamp(this.hitObjects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, this.hitObjects.length - 2);
937
- const objectBefore = this.hitObjects[beforeIndex];
844
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
845
+ const objectBefore = objects[beforeIndex];
938
846
  const objectBeforeData = objectData[beforeIndex];
939
847
  let timeBefore = objectBefore.endTime;
940
848
  if (objectBefore instanceof osuBase.Circle) {
@@ -946,9 +854,9 @@ class ThreeFingerChecker {
946
854
  }
947
855
  }
948
856
  const afterIndex = beforeIndex + 1;
949
- const objectAfter = this.hitObjects[afterIndex];
857
+ const objectAfter = objects[afterIndex];
950
858
  const objectAfterData = objectData[afterIndex];
951
- let timeAfter = this.hitObjects[afterIndex].startTime;
859
+ let timeAfter = objectAfter.startTime;
952
860
  if (objectAfter instanceof osuBase.Circle &&
953
861
  objectAfterData.result !== exports.HitResult.miss) {
954
862
  timeAfter += objectAfterData.accuracy;
@@ -965,11 +873,12 @@ class ThreeFingerChecker {
965
873
  * This also filters cursors that are in break period or happen before start/after end of the beatmap.
966
874
  */
967
875
  filterCursorInstances() {
876
+ const objects = this.beatmap.hitObjects.objects;
968
877
  const objectData = this.data.hitObjectData;
969
878
  const firstObjectResult = objectData[0].result;
970
879
  const lastObjectResult = objectData.at(-1).result;
971
- const firstObject = this.hitObjects[0];
972
- const lastObject = this.hitObjects.at(-1);
880
+ const firstObject = objects[0];
881
+ const lastObject = objects.at(-1);
973
882
  // For sliders, automatically set hit window length to be as lenient as possible.
974
883
  let firstObjectHitWindow = this.hitWindow.hitWindowFor50(this.isPrecise);
975
884
  if (firstObject instanceof osuBase.Circle) {
@@ -1029,280 +938,217 @@ class ThreeFingerChecker {
1029
938
  * detect dragged sections and improve detection speed.
1030
939
  */
1031
940
  getBeatmapSections() {
1032
- const cursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
941
+ const beatmapObjects = this.beatmap.hitObjects.objects;
942
+ const aimCursorGroupLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
943
+ // This intentionally starts from 1 because we need to look at the previous cursor.
944
+ const aimCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 1);
945
+ const pressCursorLookupIndices = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1033
946
  for (const section of this.difficultyAttributes
1034
947
  .possibleThreeFingeredSections) {
1035
- const dragFingerIndex = this.findDragIndex(section);
1036
948
  const objects = [];
1037
949
  for (let i = section.firstObjectIndex; i <= section.lastObjectIndex; ++i) {
1038
- objects.push(Object.assign({ object: this.hitObjects[i] }, this.getObjectPressIndex(this.hitObjects[i], this.data.hitObjectData[i], cursorLookupIndices, dragFingerIndex)));
950
+ const object = beatmapObjects[i];
951
+ const objectData = this.data.hitObjectData[i];
952
+ objects.push({
953
+ object: object,
954
+ aimingCursorInstanceIndex: this.getObjectAimIndex(object, objectData, aimCursorGroupLookupIndices, aimCursorLookupIndices),
955
+ pressingCursorInstanceIndex: this.getObjectPressIndex(object, objectData, pressCursorLookupIndices),
956
+ });
1039
957
  }
1040
- this.beatmapSections.push(Object.assign(Object.assign({}, section), { dragFingerIndex: dragFingerIndex, objects: objects }));
958
+ this.beatmapSections.push(Object.assign(Object.assign({}, section), { objects: objects }));
1041
959
  }
1042
960
  }
1043
961
  /**
1044
- * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
962
+ * Obtains the index of the cursor that aimed the object at the nearest time.
1045
963
  *
1046
964
  * @param object The object to obtain the index for.
1047
965
  * @param objectData The hit data of the object.
1048
- * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1049
- * @param excludedIndices The cursor indices that should not be checked.
966
+ * @param cursorInstanceIndices The cursor indices to start looking for the cursor instance from, to save computation time.
967
+ * @param cursorGroupIndices The cursor indices to start looking for the cursor group from, to save computation time.
968
+ * @param cursorIndices The cursor indices to start looking for the cursor from, to save computation time.
1050
969
  * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1051
970
  */
1052
- getObjectPressIndex(object, objectData, cursorLookupIndices, ...excludedCursorIndices) {
971
+ getObjectAimIndex(object, objectData, cursorGroupIndices, cursorIndices) {
1053
972
  if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1054
- return {
1055
- pressingCursorInstanceIndex: -1,
1056
- pressingCursorIndex: -1,
1057
- };
973
+ return -1;
1058
974
  }
1059
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
1060
975
  // Check for sliderbreaks and treat them as misses.
1061
976
  if (object instanceof osuBase.Slider &&
1062
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
1063
- return {
1064
- pressingCursorInstanceIndex: -1,
1065
- pressingCursorIndex: -1,
1066
- };
977
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
978
+ return -1;
1067
979
  }
1068
- // We are not directly using hit time to determine which cursor pressed the object
1069
- // to account for time difference between hit registration and object judgement.
1070
- let minHitTime = object.startTime;
1071
- let maxHitTime = object.startTime;
1072
- if (object instanceof osuBase.Circle) {
1073
- let hitWindowGap = hitWindow50;
1074
- switch (objectData.result) {
1075
- case exports.HitResult.great:
1076
- hitWindowGap = this.hitWindow.hitWindowFor300(this.isPrecise);
1077
- break;
1078
- case exports.HitResult.good:
1079
- hitWindowGap = this.hitWindow.hitWindowFor100(this.isPrecise);
980
+ const hitTime = object.startTime + objectData.accuracy;
981
+ const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
982
+ // We are maintaining the closest distance to the object.
983
+ // This is because the radius that is calculated is using an estimation.
984
+ // As such, it does not reflect the actual object radius in gameplay.
985
+ let closestDistance = Number.POSITIVE_INFINITY;
986
+ let nearestCursorIndex = -1;
987
+ // Observe the cursor position at the object's hit time.
988
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
989
+ const cursorData = this.data.cursorMovement[i];
990
+ for (let j = cursorGroupIndices[i]; j < cursorData.occurrenceGroups.length; cursorGroupIndices[i] = ++j) {
991
+ const cursorGroup = cursorData.occurrenceGroups[j];
992
+ if (cursorGroup.endTime < hitTime) {
993
+ // Reset cursor index pointer.
994
+ cursorIndices[i] = 1;
995
+ continue;
996
+ }
997
+ if (cursorGroup.startTime > hitTime) {
1080
998
  break;
999
+ }
1000
+ const cursors = cursorGroup.allOccurrences;
1001
+ for (let k = cursorIndices[i]; k < cursors.length; cursorIndices[i] = ++k) {
1002
+ const cursor = cursors[k];
1003
+ const prevCursor = cursors[k - 1];
1004
+ // Cursor is past the object's hit time.
1005
+ if (prevCursor.time > hitTime) {
1006
+ break;
1007
+ }
1008
+ // Cursor is before the object's hit time.
1009
+ if (hitTime > cursor.time) {
1010
+ continue;
1011
+ }
1012
+ let distance;
1013
+ const currentPosition = this.getCursorPosition(cursor);
1014
+ const prevPosition = this.getCursorPosition(prevCursor);
1015
+ switch (cursor.id) {
1016
+ case exports.MovementType.up:
1017
+ distance = prevPosition.getDistance(objectPosition);
1018
+ break;
1019
+ case exports.MovementType.move: {
1020
+ // Interpolate movement.
1021
+ const t = (hitTime - prevCursor.time) /
1022
+ (cursor.time - prevCursor.time);
1023
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
1024
+ distance =
1025
+ objectPosition.getDistance(cursorPosition);
1026
+ break;
1027
+ }
1028
+ case exports.MovementType.down:
1029
+ continue;
1030
+ }
1031
+ if (closestDistance > distance) {
1032
+ closestDistance = distance;
1033
+ nearestCursorIndex = i;
1034
+ }
1035
+ }
1036
+ // Reset cursor index pointer on end of group.
1037
+ if (cursorIndices[i] === cursors.length) {
1038
+ cursorIndices[i] = 1;
1039
+ }
1040
+ break;
1081
1041
  }
1082
- minHitTime -= hitWindowGap;
1083
- maxHitTime += hitWindowGap;
1042
+ // The previous object may still be hit with the same cursor group or cursor index.
1043
+ cursorGroupIndices[i] = Math.max(0, cursorGroupIndices[i] - 1);
1044
+ cursorIndices[i] = Math.max(1, cursorIndices[i] - 1);
1045
+ }
1046
+ return nearestCursorIndex;
1047
+ }
1048
+ /**
1049
+ * Obtains the index of the nearest cursor of which an object was pressed in terms of time.
1050
+ *
1051
+ * @param object The object to obtain the index for.
1052
+ * @param objectData The hit data of the object.
1053
+ * @param cursorLookupIndices The cursor indices to start looking for the cursor from, to save computation time.
1054
+ * @param excludedIndices The cursor indices that should not be checked.
1055
+ * @returns The index of the cursor, -1 if the object was missed or it's a spinner.
1056
+ */
1057
+ getObjectPressIndex(object, objectData, cursorLookupIndices) {
1058
+ if (objectData.result === exports.HitResult.miss || object instanceof osuBase.Spinner) {
1059
+ return -1;
1084
1060
  }
1085
- else if (object instanceof osuBase.Slider) {
1086
- minHitTime -= hitWindow50;
1087
- maxHitTime += Math.min(hitWindow50, object.spanDuration);
1061
+ // Check for sliderbreaks and treat them as misses.
1062
+ if (object instanceof osuBase.Slider &&
1063
+ objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1064
+ return -1;
1088
1065
  }
1089
1066
  const hitTime = object.startTime + objectData.accuracy;
1090
- let nearestCursorInstanceIndex = null;
1091
- let nearestCursorIndex = null;
1067
+ let nearestCursorInstanceIndex = -1;
1092
1068
  let nearestTime = Number.POSITIVE_INFINITY;
1093
1069
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
1094
- if (excludedCursorIndices.includes(i)) {
1095
- continue;
1096
- }
1097
1070
  const cursors = this.downCursorInstances[i];
1071
+ let cursorNearestTime = Number.POSITIVE_INFINITY;
1098
1072
  for (let j = cursorLookupIndices[i]; j < cursors.length; cursorLookupIndices[i] = ++j) {
1099
1073
  const cursor = cursors[j];
1100
- if (cursor.time < minHitTime) {
1101
- continue;
1102
- }
1103
- if (cursor.time > maxHitTime) {
1104
- break;
1105
- }
1106
- const deltaTime = Math.abs(hitTime - cursor.time);
1107
- if (deltaTime > nearestTime) {
1074
+ if (cursor.time > hitTime) {
1108
1075
  break;
1109
1076
  }
1077
+ cursorNearestTime = hitTime - cursor.time;
1078
+ }
1079
+ if (cursorNearestTime < nearestTime) {
1110
1080
  nearestCursorInstanceIndex = i;
1111
- nearestCursorIndex = j;
1112
- nearestTime = deltaTime;
1081
+ nearestTime = cursorNearestTime;
1113
1082
  }
1114
1083
  }
1115
- return {
1116
- pressingCursorInstanceIndex: nearestCursorInstanceIndex !== null && nearestCursorInstanceIndex !== void 0 ? nearestCursorInstanceIndex : -1,
1117
- pressingCursorIndex: nearestCursorIndex !== null && nearestCursorIndex !== void 0 ? nearestCursorIndex : -1,
1118
- };
1084
+ return nearestCursorInstanceIndex;
1119
1085
  }
1120
1086
  /**
1121
- * Checks if a section is dragged and returns the index of the drag finger.
1122
- *
1123
- * If the section is not dragged, -1 will be returned.
1124
- *
1125
- * @param section The section to check.
1087
+ * Creates nerf factors by scanning through objects.
1126
1088
  */
1127
- findDragIndex(section) {
1128
- var _a;
1129
- const objectData = this.data.hitObjectData;
1130
- const firstObject = this.hitObjects[section.firstObjectIndex];
1131
- const lastObject = this.hitObjects[section.lastObjectIndex];
1132
- let firstObjectMinHitTime = firstObject.startTime;
1133
- if (firstObject instanceof osuBase.Circle) {
1134
- switch (objectData[section.firstObjectIndex].result) {
1135
- case exports.HitResult.great:
1136
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor300(this.isPrecise);
1137
- break;
1138
- case exports.HitResult.good:
1139
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor100(this.isPrecise);
1140
- break;
1141
- default:
1142
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
1089
+ calculateNerfFactors() {
1090
+ for (const beatmapSection of this.beatmapSections) {
1091
+ const threeFingerCursorCounts = osuBase.Utils.initializeArray(Math.max(0, this.downCursorInstances.length - 2), 0);
1092
+ for (const object of beatmapSection.objects) {
1093
+ if (object.pressingCursorInstanceIndex === -1) {
1094
+ continue;
1095
+ }
1096
+ if (object.aimingCursorInstanceIndex < 3) {
1097
+ // The aim cursor is in the first three cursors. They are counted as non-3 finger.
1098
+ switch (object.pressingCursorInstanceIndex) {
1099
+ case 0:
1100
+ case 1:
1101
+ case 2:
1102
+ break;
1103
+ default:
1104
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 3];
1105
+ break;
1106
+ }
1107
+ }
1108
+ else {
1109
+ // The aim cursor is somewhere else. only count the first 2 cursors as non-3 finger.
1110
+ switch (object.pressingCursorInstanceIndex) {
1111
+ case 0:
1112
+ case 1:
1113
+ break;
1114
+ default:
1115
+ ++threeFingerCursorCounts[object.pressingCursorInstanceIndex - 2];
1116
+ break;
1117
+ }
1118
+ }
1143
1119
  }
1144
- }
1145
- else if (firstObject instanceof osuBase.Slider) {
1146
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(this.isPrecise);
1147
- }
1148
- let lastObjectMaxHitTime = lastObject.startTime;
1149
- if (lastObject instanceof osuBase.Circle) {
1150
- switch (objectData[section.lastObjectIndex].result) {
1151
- case exports.HitResult.great:
1152
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor300(this.isPrecise);
1153
- break;
1154
- case exports.HitResult.good:
1155
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor100(this.isPrecise);
1156
- break;
1157
- default:
1158
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(this.isPrecise);
1120
+ const threeFingerCursorCount = threeFingerCursorCounts.reduce((a, v) => a + v, 0);
1121
+ if (threeFingerCursorCount === 0) {
1122
+ continue;
1159
1123
  }
1124
+ const sectionObjectCount = beatmapSection.objects.length;
1125
+ const threeFingeredObjectRatio = threeFingerCursorCount / sectionObjectCount;
1126
+ const strainFactor = Math.max(1, beatmapSection.sumStrain * threeFingeredObjectRatio);
1127
+ // Finger factor applies more penalty if more fingers were used.
1128
+ const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1129
+ Math.pow(((index + 1) * count) / sectionObjectCount, 0.9), 1);
1130
+ // Length factor applies more penalty if there are more 3-fingered object.
1131
+ const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 0.8);
1132
+ this.nerfFactors.push({
1133
+ strainFactor: strainFactor,
1134
+ fingerFactor: fingerFactor,
1135
+ lengthFactor: lengthFactor,
1136
+ });
1160
1137
  }
1161
- else if (lastObject instanceof osuBase.Slider) {
1162
- lastObjectMaxHitTime += Math.min(this.hitWindow.hitWindowFor50(this.isPrecise), lastObject.spanDuration);
1163
- }
1164
- // Since there may be more than 1 cursor instance index,
1165
- // we check which cursor instance follows hitobjects all over.
1166
- const cursorIndices = [];
1167
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1168
- const c = this.data.cursorMovement[i];
1169
- if (c.occurrenceGroups.length === 0) {
1170
- continue;
1171
- }
1172
- // Do not include cursors that don't have an occurence in this section
1173
- // this speeds up checking process.
1174
- if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
1175
- v.endTime <= lastObjectMaxHitTime).length === 0) {
1176
- continue;
1177
- }
1178
- // If this cursor instance doesn't move, it's not the cursor instance we want.
1179
- if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
1180
- 0) {
1181
- continue;
1182
- }
1183
- cursorIndices.push(i);
1184
- }
1185
- const sectionObjects = this.hitObjects.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
1186
- const sectionReplayObjectData = objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1);
1187
- const hitWindow50 = this.hitWindow.hitWindowFor50(this.isPrecise);
1188
- for (let i = 0; i < sectionObjects.length && cursorIndices.some((v) => v !== -1); ++i) {
1189
- const object = sectionObjects[i];
1190
- const objectData = sectionReplayObjectData[i];
1191
- if (object instanceof osuBase.Spinner ||
1192
- objectData.result === exports.HitResult.miss) {
1193
- continue;
1194
- }
1195
- // Exclude sliderbreaks.
1196
- if (object instanceof osuBase.Slider &&
1197
- objectData.accuracy === Math.floor(hitWindow50) + 13) {
1198
- continue;
1199
- }
1200
- const objectPosition = object.getStackedPosition(osuBase.Modes.droid);
1201
- const hitTime = object.startTime + objectData.accuracy;
1202
- // Observe the cursor position at the object's hit time.
1203
- for (let j = 0; j < cursorIndices.length; ++j) {
1204
- if (cursorIndices[j] === -1) {
1205
- continue;
1206
- }
1207
- const cursorData = this.data.cursorMovement[cursorIndices[j]];
1208
- const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
1209
- if (!cursorGroup) {
1210
- continue;
1211
- }
1212
- const cursors = cursorGroup.allOccurrences;
1213
- let isInObject = false;
1214
- for (let k = 1; k < cursors.length; ++k) {
1215
- const cursor = cursors[k];
1216
- const prevCursor = cursors[k - 1];
1217
- // Only consider cursor at interval prev.time <= hitTime <= current.time.
1218
- if (prevCursor.time <= hitTime && cursor.time >= hitTime) {
1219
- switch (cursor.id) {
1220
- case exports.MovementType.up:
1221
- isInObject =
1222
- prevCursor.position.getDistance(objectPosition) <= object.radius;
1223
- break;
1224
- case exports.MovementType.move: {
1225
- // Interpolate movement.
1226
- const t = (hitTime - prevCursor.time) /
1227
- (cursor.time - prevCursor.time);
1228
- const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevCursor.position.x, cursor.position.x, t), osuBase.Interpolation.lerp(prevCursor.position.y, cursor.position.y, t));
1229
- isInObject =
1230
- objectPosition.getDistance(cursorPosition) <= object.radius;
1231
- }
1232
- }
1233
- break;
1234
- }
1235
- }
1236
- if (!isInObject) {
1237
- cursorIndices[j] = -1;
1238
- }
1239
- }
1240
- }
1241
- return (_a = cursorIndices.find((v) => v !== -1)) !== null && _a !== void 0 ? _a : -1;
1242
- }
1243
- /**
1244
- * Creates nerf factors by scanning through objects.
1245
- */
1246
- calculateNerfFactors() {
1247
- for (const beatmapSection of this.beatmapSections) {
1248
- const cursorCounts = osuBase.Utils.initializeArray(this.downCursorInstances.length, 0);
1249
- for (const object of beatmapSection.objects) {
1250
- if (object.pressingCursorIndex === -1 ||
1251
- object.pressingCursorInstanceIndex ===
1252
- beatmapSection.dragFingerIndex) {
1253
- continue;
1254
- }
1255
- ++cursorCounts[object.pressingCursorInstanceIndex];
1256
- }
1257
- if (beatmapSection.dragFingerIndex !== -1) {
1258
- // Remove the drag index to prevent it from being picked up into the detection.
1259
- cursorCounts.splice(beatmapSection.dragFingerIndex, 1);
1260
- }
1261
- // This index will be used to detect if a section is 3-fingered.
1262
- // If the section is dragged, the dragged instance will be ignored,
1263
- // hence why the index is 1 less than nondragged section.
1264
- const fingerSplitIndex = beatmapSection.dragFingerIndex !== -1 ? 2 : 3;
1265
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
1266
- // to check if the section is 3-fingered.
1267
- const threeFingerRatio = cursorCounts
1268
- .slice(fingerSplitIndex)
1269
- .reduce((acc, value) => acc + value, 0) /
1270
- Math.max(cursorCounts
1271
- .slice(0, fingerSplitIndex)
1272
- .reduce((acc, value) => acc + value, 0), 1);
1273
- if (threeFingerRatio > this.threeFingerRatioThreshold) {
1274
- const threeFingeredObjectCount = cursorCounts
1275
- .slice(fingerSplitIndex)
1276
- .reduce((acc, value) => acc + value, 0);
1277
- const sectionObjectCount = beatmapSection.lastObjectIndex -
1278
- beatmapSection.firstObjectIndex +
1279
- 1;
1280
- const threeFingeredObjectRatio = threeFingeredObjectCount / sectionObjectCount;
1281
- // We can ignore the first 3 (2 for drag) filled cursor instances
1282
- // since they are guaranteed not 3 finger.
1283
- const threeFingerCursorCounts = cursorCounts
1284
- .slice(fingerSplitIndex)
1285
- .filter((amount) => amount > 0);
1286
- // Finger factor applies more penalty if more fingers were used.
1287
- const fingerFactor = threeFingerCursorCounts.reduce((acc, count, index) => acc +
1288
- Math.pow((index + 1) * count * threeFingeredObjectRatio, 0.9), 1);
1289
- // Length factor applies more penalty if there are more 3-fingered object.
1290
- const lengthFactor = 1 + Math.pow(threeFingeredObjectRatio, 1.2);
1291
- this.nerfFactors.push({
1292
- strainFactor: beatmapSection.sumStrain * threeFingeredObjectRatio,
1293
- fingerFactor: fingerFactor,
1294
- lengthFactor: lengthFactor,
1295
- });
1296
- }
1138
+ }
1139
+ /**
1140
+ * Calculates the final penalty.
1141
+ */
1142
+ calculateFinalPenalty() {
1143
+ return this.nerfFactors.reduce((a, n) => a +
1144
+ 0.015 *
1145
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1146
+ }
1147
+ getCursorPosition(cursor) {
1148
+ if (this.isHardRock) {
1149
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1297
1150
  }
1298
- }
1299
- /**
1300
- * Calculates the final penalty.
1301
- */
1302
- calculateFinalPenalty() {
1303
- return this.nerfFactors.reduce((a, n) => a +
1304
- 0.015 *
1305
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 1);
1151
+ return cursor.position;
1306
1152
  }
1307
1153
  }
1308
1154
 
@@ -1349,13 +1195,11 @@ class TwoHandChecker {
1349
1195
  this.indexedHitObjects = [];
1350
1196
  this.calculator = calculator;
1351
1197
  this.data = data;
1352
- const od = osuBase.calculateDroidDifficultyStatistics({
1353
- overallDifficulty: calculator.beatmap.difficulty.od,
1354
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1355
- convertOverallDifficulty: false,
1356
- }).overallDifficulty;
1357
- this.hitWindow = new osuBase.DroidHitWindow(od);
1198
+ const greatWindow = new osuBase.OsuHitWindow(calculator.attributes.overallDifficulty).hitWindowFor300() * calculator.attributes.clockRate;
1199
+ const isPrecise = calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
1200
+ this.hitWindow = new osuBase.DroidHitWindow(osuBase.DroidHitWindow.hitWindow300ToOD(greatWindow, isPrecise));
1358
1201
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1202
+ this.isHardRock = calculator.mods.some((m) => m instanceof osuBase.ModHardRock);
1359
1203
  // this.csvString = `Mods,${
1360
1204
  // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
1361
1205
  // }\nCombo,${data.maxCombo}\nAccuracy,"${(
@@ -1576,13 +1420,15 @@ class TwoHandChecker {
1576
1420
  for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
1577
1421
  const cursor = cursors[i];
1578
1422
  const prevCursor = cursors[i - 1];
1579
- if (cursor.position.equals(prevCursor.position)) {
1423
+ const currentPosition = this.getCursorPosition(cursor);
1424
+ const prevPosition = this.getCursorPosition(prevCursor);
1425
+ if (currentPosition.equals(prevPosition)) {
1580
1426
  continue;
1581
1427
  }
1582
1428
  if (cursor.id === exports.MovementType.up) {
1583
1429
  break;
1584
1430
  }
1585
- const currentMovement = cursor.position.subtract(prevCursor.position);
1431
+ const currentMovement = currentPosition.subtract(prevPosition);
1586
1432
  const dot = prevToCurrentCursorMovement.dot(currentMovement);
1587
1433
  const det = prevToCurrentCursorMovement.x * currentMovement.y -
1588
1434
  prevToCurrentCursorMovement.y * currentMovement.x;
@@ -1668,7 +1514,7 @@ class TwoHandChecker {
1668
1514
  };
1669
1515
  }
1670
1516
  let hitWindow = this.hitWindow50;
1671
- const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1517
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
1672
1518
  // For sliders, set the hit window to as lenient as possible.
1673
1519
  if (object instanceof osuBase.Circle) {
1674
1520
  switch (data.result) {
@@ -1706,14 +1552,14 @@ class TwoHandChecker {
1706
1552
  }
1707
1553
  // Validate the down press first.
1708
1554
  const { down } = cursorGroup;
1709
- if (down.position.getDistance(objectPosition) <=
1710
- object.radius &&
1555
+ const downPosition = this.getCursorPosition(down);
1556
+ if (downPosition.getDistance(objectPosition) <= object.radius &&
1711
1557
  Math.abs(down.time - object.startTime) <= hitWindow) {
1712
1558
  if (objectIndex > 0) {
1713
1559
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1714
1560
  if (down.time > prevObject.endTime) {
1715
1561
  return {
1716
- position: down.position,
1562
+ position: downPosition,
1717
1563
  cursorIndex: i,
1718
1564
  groupIndex: j,
1719
1565
  occurrenceIndex: 0,
@@ -1723,7 +1569,7 @@ class TwoHandChecker {
1723
1569
  }
1724
1570
  else {
1725
1571
  return {
1726
- position: down.position,
1572
+ position: downPosition,
1727
1573
  cursorIndex: i,
1728
1574
  groupIndex: j,
1729
1575
  occurrenceIndex: 0,
@@ -1743,7 +1589,7 @@ class TwoHandChecker {
1743
1589
  }
1744
1590
  let cursorPosition;
1745
1591
  if (cursor.id === exports.MovementType.up) {
1746
- cursorPosition = prevCursor.position;
1592
+ cursorPosition = this.getCursorPosition(prevCursor);
1747
1593
  const distance = cursorPosition.getDistance(objectPosition);
1748
1594
  if (distance <
1749
1595
  nearestPosition.getDistance(objectPosition)) {
@@ -1768,7 +1614,7 @@ class TwoHandChecker {
1768
1614
  }
1769
1615
  const t = (cursorGroup.down.time - prevCursor.time) /
1770
1616
  (cursor.time - prevCursor.time);
1771
- cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
1617
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1772
1618
  const distance = cursorPosition.getDistance(objectPosition);
1773
1619
  if (distance <
1774
1620
  nearestPosition.getDistance(objectPosition)) {
@@ -1853,18 +1699,18 @@ class TwoHandChecker {
1853
1699
  let cursorPosition;
1854
1700
  switch (cursor.id) {
1855
1701
  case exports.MovementType.down:
1856
- cursorPosition = cursor.position;
1702
+ cursorPosition = this.getCursorPosition(cursor);
1857
1703
  break;
1858
1704
  case exports.MovementType.up: {
1859
1705
  const prevCursor = cursors[k - 1];
1860
- cursorPosition = prevCursor.position;
1706
+ cursorPosition = this.getCursorPosition(prevCursor);
1861
1707
  break;
1862
1708
  }
1863
1709
  case exports.MovementType.move: {
1864
1710
  const prevCursor = cursors[k - 1];
1865
1711
  const t = osuBase.MathUtils.clamp((object.endTime - prevCursor.time) /
1866
1712
  (cursor.time - prevCursor.time), 0, 1);
1867
- cursorPosition = osuBase.Interpolation.lerp(prevCursor.position, cursor.position, t);
1713
+ cursorPosition = osuBase.Interpolation.lerp(this.getCursorPosition(prevCursor), this.getCursorPosition(cursor), t);
1868
1714
  break;
1869
1715
  }
1870
1716
  }
@@ -1926,6 +1772,12 @@ class TwoHandChecker {
1926
1772
  }
1927
1773
  return false;
1928
1774
  }
1775
+ getCursorPosition(cursor) {
1776
+ if (this.isHardRock) {
1777
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1778
+ }
1779
+ return cursor.position;
1780
+ }
1929
1781
  }
1930
1782
 
1931
1783
  /**
@@ -1941,12 +1793,8 @@ class SliderCheeseChecker {
1941
1793
  this.beatmap = beatmap;
1942
1794
  this.data = data;
1943
1795
  this.difficultyAttributes = difficultyAttributes;
1944
- const od = osuBase.calculateDroidDifficultyStatistics({
1945
- overallDifficulty: beatmap.difficulty.od,
1946
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
1947
- convertOverallDifficulty: false,
1948
- }).overallDifficulty;
1949
- this.hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1796
+ this.hitWindow50 = new osuBase.DroidHitWindow(beatmap.difficulty.od).hitWindowFor50(difficultyAttributes.mods.some((m) => m instanceof osuBase.ModPrecise));
1797
+ this.isHardRock = difficultyAttributes.mods.some((m) => m instanceof osuBase.ModHardRock);
1950
1798
  }
1951
1799
  /**
1952
1800
  * Checks if relevant sliders in the given beatmap was cheesed.
@@ -1972,7 +1820,7 @@ class SliderCheeseChecker {
1972
1820
  const { objects } = this.beatmap.hitObjects;
1973
1821
  const cheesedDifficultyRatings = [];
1974
1822
  // Current loop indices are stored for efficiency.
1975
- const cursorLoopIndices = osuBase.Utils.initializeArray(10, 0);
1823
+ const cursorLoopIndices = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1976
1824
  const acceptableRadius = objects[0].radius * 2;
1977
1825
  // Sort difficult sliders by index so that cursor loop indices work properly.
1978
1826
  for (const difficultSlider of this.difficultyAttributes.difficultSliders
@@ -2010,7 +1858,8 @@ class SliderCheeseChecker {
2010
1858
  break;
2011
1859
  }
2012
1860
  if (group.startTime >= minTimeLimit) {
2013
- const distance = group.down.position.getDistance(objectStartPosition);
1861
+ const position = this.getCursorPosition(group.down);
1862
+ const distance = position.getDistance(objectStartPosition);
2014
1863
  if (closestDistance > distance) {
2015
1864
  closestDistance = distance;
2016
1865
  closestIndex = j;
@@ -2024,20 +1873,22 @@ class SliderCheeseChecker {
2024
1873
  // for the time being we only check for cursor distances across the group.
2025
1874
  const { allOccurrences } = group;
2026
1875
  for (let k = 1; k < allOccurrences.length; ++k) {
2027
- const occurrence = allOccurrences[k];
2028
- const prevOccurrence = allOccurrences[k - 1];
1876
+ const cursor = allOccurrences[k];
1877
+ const prevCursor = allOccurrences[k - 1];
2029
1878
  let distance = Number.POSITIVE_INFINITY;
2030
- switch (occurrence.id) {
1879
+ const currentPosition = this.getCursorPosition(cursor);
1880
+ const prevPosition = this.getCursorPosition(prevCursor);
1881
+ switch (cursor.id) {
2031
1882
  case exports.MovementType.up:
2032
1883
  distance =
2033
- prevOccurrence.position.getDistance(objectStartPosition);
1884
+ prevPosition.getDistance(objectStartPosition);
2034
1885
  break;
2035
1886
  case exports.MovementType.move:
2036
- for (let mSecPassed = Math.max(prevOccurrence.time, minTimeLimit); mSecPassed <=
2037
- Math.min(occurrence.time, maxTimeLimit); ++mSecPassed) {
2038
- const t = (mSecPassed - prevOccurrence.time) /
2039
- (occurrence.time - prevOccurrence.time);
2040
- const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
1887
+ for (let mSecPassed = Math.max(prevCursor.time, minTimeLimit); mSecPassed <=
1888
+ Math.min(cursor.time, maxTimeLimit); ++mSecPassed) {
1889
+ const t = (mSecPassed - prevCursor.time) /
1890
+ (cursor.time - prevCursor.time);
1891
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
2041
1892
  distance =
2042
1893
  cursorPosition.getDistance(objectStartPosition);
2043
1894
  if (closestDistance > distance) {
@@ -2095,21 +1946,24 @@ class SliderCheeseChecker {
2095
1946
  if (occurrenceLoopIndex === allOccurrences.length) {
2096
1947
  continue;
2097
1948
  }
2098
- const occurrence = allOccurrences[occurrenceLoopIndex];
2099
- const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
2100
- switch (occurrence.id) {
1949
+ const cursor = allOccurrences[occurrenceLoopIndex];
1950
+ const prevCursor = allOccurrences[occurrenceLoopIndex - 1];
1951
+ const currentPosition = this.getCursorPosition(cursor);
1952
+ const prevPosition = this.getCursorPosition(prevCursor);
1953
+ switch (cursor.id) {
2101
1954
  case exports.MovementType.move: {
2102
1955
  // Interpolate cursor position during nested object time.
2103
- const t = (nestedObject.startTime - prevOccurrence.time) /
2104
- (occurrence.time - prevOccurrence.time);
2105
- const cursorPosition = new osuBase.Vector2(osuBase.Interpolation.lerp(prevOccurrence.position.x, occurrence.position.x, t), osuBase.Interpolation.lerp(prevOccurrence.position.y, occurrence.position.y, t));
1956
+ const t = (nestedObject.startTime - prevCursor.time) /
1957
+ (cursor.time - prevCursor.time);
1958
+ const cursorPosition = osuBase.Interpolation.lerp(prevPosition, currentPosition, t);
2106
1959
  const distance = cursorPosition.getDistance(nestedPosition);
2107
1960
  isCheesed = distance > acceptableRadius;
2108
1961
  break;
2109
1962
  }
2110
1963
  case exports.MovementType.up:
2111
1964
  isCheesed =
2112
- prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius;
1965
+ prevPosition.getDistance(nestedPosition) >
1966
+ acceptableRadius;
2113
1967
  }
2114
1968
  }
2115
1969
  if (isCheesed) {
@@ -2135,6 +1989,65 @@ class SliderCheeseChecker {
2135
1989
  this.difficultyAttributes.visualSliderFactor, 2)),
2136
1990
  };
2137
1991
  }
1992
+ getCursorPosition(cursor) {
1993
+ if (this.isHardRock) {
1994
+ return new osuBase.Vector2(cursor.position.x, osuBase.Playfield.baseSize.y - cursor.position.y);
1995
+ }
1996
+ return cursor.position;
1997
+ }
1998
+ }
1999
+
2000
+ /**
2001
+ * Represents a replay data for replay version 3.
2002
+ *
2003
+ * Stores generic information about an osu!droid replay.
2004
+ *
2005
+ * This is used when analyzing replays using replay analyzer.
2006
+ */
2007
+ class ReplayV3Data extends ReplayData {
2008
+ constructor(values) {
2009
+ super(values);
2010
+ this.time = values.time;
2011
+ this.score = values.score;
2012
+ this.maxCombo = values.maxCombo;
2013
+ this.isFullCombo = values.isFullCombo;
2014
+ this.playerName = values.playerName;
2015
+ this.rawMods = values.rawMods;
2016
+ this.convertedMods = values.convertedMods;
2017
+ }
2018
+ }
2019
+
2020
+ /**
2021
+ * Represents a replay data for replay version 4.
2022
+ *
2023
+ * Stores generic information about an osu!droid replay.
2024
+ *
2025
+ * This is used when analyzing replays using replay analyzer.
2026
+ */
2027
+ class ReplayV4Data extends ReplayV3Data {
2028
+ constructor(values) {
2029
+ var _a, _b;
2030
+ super(values);
2031
+ this.forceAR = values.forceAR;
2032
+ this.speedMultiplier = (_a = values.speedMultiplier) !== null && _a !== void 0 ? _a : 1;
2033
+ this.flashlightFollowDelay = (_b = values.flashlightFollowDelay) !== null && _b !== void 0 ? _b : 0.12;
2034
+ }
2035
+ }
2036
+
2037
+ /**
2038
+ * Represents a replay data for replay version 5.
2039
+ *
2040
+ * Stores generic information about an osu!droid replay.
2041
+ *
2042
+ * This is used when analyzing replays using replay analyzer.
2043
+ */
2044
+ class ReplayV5Data extends ReplayV4Data {
2045
+ constructor(values) {
2046
+ super(values);
2047
+ this.forceCS = values.forceCS;
2048
+ this.forceOD = values.forceOD;
2049
+ this.forceHP = values.forceHP;
2050
+ }
2138
2051
  }
2139
2052
 
2140
2053
  /**
@@ -2190,6 +2103,7 @@ class ReplayAnalyzer {
2190
2103
  * The amount of two-handed objects.
2191
2104
  */
2192
2105
  this.twoHandedNoteCount = 0;
2106
+ this.bufferOffset = 0;
2193
2107
  this.scoreID = values.scoreID;
2194
2108
  this.beatmap = values.map;
2195
2109
  this.difficultyAttributes = values.difficultyAttributes;
@@ -2218,6 +2132,117 @@ class ReplayAnalyzer {
2218
2132
  return this;
2219
2133
  });
2220
2134
  }
2135
+ /**
2136
+ * Gets hit error information of the replay.
2137
+ *
2138
+ * `analyze()` must be called before calling this.
2139
+ */
2140
+ calculateHitError() {
2141
+ var _a, _b;
2142
+ if (!this.data || !this.beatmap) {
2143
+ return null;
2144
+ }
2145
+ const hitObjectData = this.data.hitObjectData;
2146
+ let positiveCount = 0;
2147
+ let negativeCount = 0;
2148
+ let positiveTotal = 0;
2149
+ let negativeTotal = 0;
2150
+ const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2151
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2152
+ ? this.beatmap.beatmap
2153
+ : this.beatmap;
2154
+ const { objects } = beatmap.hitObjects;
2155
+ const mods = this.data.isReplayV3()
2156
+ ? this.data.convertedMods
2157
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.filter((m) => m.isApplicableToDroid())) !== null && _b !== void 0 ? _b : [];
2158
+ const adjustedDifficulty = new osuBase.BeatmapDifficulty(beatmap.difficulty);
2159
+ osuBase.ModUtil.applyModsToBeatmapDifficulty(adjustedDifficulty, osuBase.Modes.droid, mods, this.data.isReplayV4() ? this.data.speedMultiplier : 1);
2160
+ const hitWindow50 = new osuBase.DroidHitWindow(adjustedDifficulty.od).hitWindowFor50(mods.some((m) => m instanceof osuBase.ModPrecise));
2161
+ // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2162
+ // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2163
+ //
2164
+ // In such cases, the slider is skipped.
2165
+ const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2166
+ const accuracies = [];
2167
+ for (let i = 0; i < hitObjectData.length; ++i) {
2168
+ const v = hitObjectData[i];
2169
+ const o = objects[i];
2170
+ if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2171
+ accuracies.push(0);
2172
+ continue;
2173
+ }
2174
+ const { accuracy } = v;
2175
+ if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2176
+ accuracies.push(0);
2177
+ continue;
2178
+ }
2179
+ accuracies.push(accuracy);
2180
+ if (accuracy >= 0) {
2181
+ positiveTotal += accuracy;
2182
+ ++positiveCount;
2183
+ }
2184
+ else {
2185
+ negativeTotal += accuracy;
2186
+ ++negativeCount;
2187
+ }
2188
+ }
2189
+ return {
2190
+ positiveAvg: positiveTotal / positiveCount || 0,
2191
+ negativeAvg: negativeTotal / negativeCount || 0,
2192
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2193
+ };
2194
+ }
2195
+ /**
2196
+ * Checks if a play is using 3 fingers.
2197
+ *
2198
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2199
+ */
2200
+ checkFor3Finger() {
2201
+ var _a;
2202
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2203
+ return;
2204
+ }
2205
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2206
+ const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2207
+ ? new RebalanceThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes)
2208
+ : new ThreeFingerChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2209
+ const result = threeFingerChecker.check();
2210
+ this.is3Finger = result.is3Finger;
2211
+ this.tapPenalty = result.penalty;
2212
+ this.hasBeenCheckedFor3Finger = true;
2213
+ }
2214
+ /**
2215
+ * Checks if a play is using 2 hands.
2216
+ *
2217
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2218
+ */
2219
+ checkFor2Hand() {
2220
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2221
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2222
+ !this.data) {
2223
+ return;
2224
+ }
2225
+ const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2226
+ const result = twoHandChecker.check();
2227
+ this.is2Hand = result.is2Hand;
2228
+ this.twoHandedNoteCount = result.twoHandedNoteCount;
2229
+ this.hasBeenCheckedFor2Hand = true;
2230
+ }
2231
+ /**
2232
+ * Checks if a play has cheesed sliders.
2233
+ *
2234
+ * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2235
+ */
2236
+ checkForSliderCheesing() {
2237
+ var _a;
2238
+ if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2239
+ return;
2240
+ }
2241
+ (_a = this.playableBeatmap) !== null && _a !== void 0 ? _a : (this.playableBeatmap = this.constructPlayableBeatmap());
2242
+ const sliderCheeseChecker = new SliderCheeseChecker(this.playableBeatmap, this.data, this.difficultyAttributes);
2243
+ this.sliderCheesePenalty = sliderCheeseChecker.check();
2244
+ this.hasBeenCheckedForSliderCheesing = true;
2245
+ }
2221
2246
  /**
2222
2247
  * Downloads the given score ID's replay.
2223
2248
  */
@@ -2264,7 +2289,6 @@ class ReplayAnalyzer {
2264
2289
  * Parses a replay after being downloaded and converted to a buffer.
2265
2290
  */
2266
2291
  parseReplay() {
2267
- var _a, _b, _c;
2268
2292
  if (!this.fixedODR) {
2269
2293
  return;
2270
2294
  }
@@ -2275,7 +2299,7 @@ class ReplayAnalyzer {
2275
2299
  try {
2276
2300
  rawObject = javaDeserialization__namespace.parse(this.fixedODR);
2277
2301
  }
2278
- catch (_d) {
2302
+ catch (_a) {
2279
2303
  return;
2280
2304
  }
2281
2305
  const resultObject = {
@@ -2286,67 +2310,32 @@ class ReplayAnalyzer {
2286
2310
  cursorMovement: [],
2287
2311
  hitObjectData: [],
2288
2312
  accuracy: new osuBase.Accuracy({ n300: 0 }),
2289
- rank: "",
2290
- };
2291
- const determineRank = () => {
2292
- var _a, _b;
2293
- const totalHits = resultObject.accuracy.n300 +
2294
- resultObject.accuracy.n100 +
2295
- resultObject.accuracy.n50 +
2296
- resultObject.accuracy.nmiss;
2297
- const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2298
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2299
- switch (true) {
2300
- case resultObject.accuracy.value() === 1:
2301
- if (isHidden) {
2302
- resultObject.rank = "XH";
2303
- }
2304
- else {
2305
- resultObject.rank = "X";
2306
- }
2307
- break;
2308
- case hit300Ratio > 0.9 &&
2309
- resultObject.accuracy.n50 / totalHits < 0.01 &&
2310
- !resultObject.accuracy.nmiss:
2311
- if (isHidden) {
2312
- resultObject.rank = "SH";
2313
- }
2314
- else {
2315
- resultObject.rank = "S";
2316
- }
2317
- break;
2318
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2319
- hit300Ratio > 0.9:
2320
- resultObject.rank = "A";
2321
- break;
2322
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2323
- hit300Ratio > 0.8:
2324
- resultObject.rank = "B";
2325
- break;
2326
- case hit300Ratio > 0.6:
2327
- resultObject.rank = "C";
2328
- break;
2329
- default:
2330
- resultObject.rank = "D";
2331
- }
2313
+ rank: "D",
2314
+ convertedMods: [],
2315
+ hit100k: 0,
2316
+ hit300k: 0,
2317
+ isFullCombo: false,
2318
+ maxCombo: 0,
2319
+ playerName: "",
2320
+ rawMods: [],
2321
+ score: 0,
2322
+ time: new Date(0),
2332
2323
  };
2333
2324
  if (resultObject.replayVersion >= 3) {
2334
- resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
2325
+ resultObject.time.setTime(Number(rawObject[4].readBigUInt64BE(0)));
2335
2326
  resultObject.hit300k = rawObject[4].readInt32BE(8);
2327
+ resultObject.accuracy.n300 = rawObject[4].readInt32BE(12);
2336
2328
  resultObject.hit100k = rawObject[4].readInt32BE(16);
2329
+ resultObject.accuracy.n100 = rawObject[4].readInt32BE(20);
2330
+ resultObject.accuracy.n50 = rawObject[4].readInt32BE(24);
2331
+ resultObject.accuracy.nmiss = rawObject[4].readInt32BE(28);
2337
2332
  resultObject.score = rawObject[4].readInt32BE(32);
2338
2333
  resultObject.maxCombo = rawObject[4].readInt32BE(36);
2339
- resultObject.accuracy = new osuBase.Accuracy({
2340
- n300: rawObject[4].readInt32BE(12),
2341
- n100: rawObject[4].readInt32BE(20),
2342
- n50: rawObject[4].readInt32BE(24),
2343
- nmiss: rawObject[4].readInt32BE(28),
2344
- });
2345
2334
  resultObject.isFullCombo = !!rawObject[4][44];
2346
2335
  resultObject.playerName = rawObject[5];
2347
- resultObject.rawMods = rawObject[6].elements;
2348
- resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
2349
- determineRank();
2336
+ resultObject.rawMods = Object.values(rawObject[6].elements);
2337
+ resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(resultObject.rawMods));
2338
+ resultObject.rank = this.calculateRank(resultObject);
2350
2339
  }
2351
2340
  if (resultObject.replayVersion >= 4) {
2352
2341
  const str = rawObject[7].split("|");
@@ -2398,32 +2387,87 @@ class ReplayAnalyzer {
2398
2387
  }
2399
2388
  // Merge all cursor movement and hit object data section into one for better control when parsing
2400
2389
  const replayDataBuffer = Buffer.concat(replayDataBufferArray);
2401
- const bufferCounter = { counter: 0 };
2402
- const size = this.readInt(replayDataBuffer, bufferCounter);
2403
- // Parse movement data
2404
- for (let x = 0; x < size; x++) {
2405
- const moveSize = this.readInt(replayDataBuffer, bufferCounter);
2390
+ this.bufferOffset = 0;
2391
+ this.parseMovementData(resultObject, replayDataBuffer);
2392
+ this.parseHitObjectData(resultObject, replayDataBuffer);
2393
+ this.parseOldReplayInformation(resultObject);
2394
+ switch (resultObject.replayVersion) {
2395
+ case 3:
2396
+ this.data = new ReplayV3Data(resultObject);
2397
+ break;
2398
+ case 4:
2399
+ this.data = new ReplayV4Data(resultObject);
2400
+ break;
2401
+ case 5:
2402
+ this.data = new ReplayV5Data(resultObject);
2403
+ break;
2404
+ default:
2405
+ this.data = new ReplayData(resultObject);
2406
+ }
2407
+ }
2408
+ /**
2409
+ * Converts replay mods to droid mod string.
2410
+ */
2411
+ convertDroidMods(replayMods) {
2412
+ const replayModsConstants = {
2413
+ MOD_AUTO: new osuBase.ModAuto().droidString,
2414
+ MOD_AUTOPILOT: new osuBase.ModAutopilot().droidString,
2415
+ MOD_NOFAIL: new osuBase.ModNoFail().droidString,
2416
+ MOD_EASY: new osuBase.ModEasy().droidString,
2417
+ MOD_HIDDEN: new osuBase.ModHidden().droidString,
2418
+ MOD_HARDROCK: new osuBase.ModHardRock().droidString,
2419
+ MOD_DOUBLETIME: new osuBase.ModDoubleTime().droidString,
2420
+ MOD_HALFTIME: new osuBase.ModHalfTime().droidString,
2421
+ MOD_NIGHTCORE: new osuBase.ModNightCore().droidString,
2422
+ MOD_PRECISE: new osuBase.ModPrecise().droidString,
2423
+ MOD_SMALLCIRCLE: new osuBase.ModSmallCircle().droidString,
2424
+ MOD_SPEEDUP: new osuBase.ModSpeedUp().droidString,
2425
+ MOD_REALLYEASY: new osuBase.ModReallyEasy().droidString,
2426
+ MOD_RELAX: new osuBase.ModRelax().droidString,
2427
+ MOD_PERFECT: new osuBase.ModPerfect().droidString,
2428
+ MOD_SUDDENDEATH: new osuBase.ModSuddenDeath().droidString,
2429
+ MOD_SCOREV2: new osuBase.ModScoreV2().droidString,
2430
+ MOD_FLASHLIGHT: new osuBase.ModFlashlight().droidString,
2431
+ };
2432
+ let modString = "";
2433
+ for (const mod of replayMods) {
2434
+ for (const property in replayModsConstants) {
2435
+ if (!mod.includes(property)) {
2436
+ continue;
2437
+ }
2438
+ modString +=
2439
+ replayModsConstants[property];
2440
+ break;
2441
+ }
2442
+ }
2443
+ return modString;
2444
+ }
2445
+ parseMovementData(resultObject, replayDataBuffer) {
2446
+ resultObject.cursorMovement.length = 0;
2447
+ const size = this.readInt(replayDataBuffer);
2448
+ for (let i = 0; i < size; i++) {
2449
+ const moveSize = this.readInt(replayDataBuffer);
2406
2450
  const time = [];
2407
2451
  const x = [];
2408
2452
  const y = [];
2409
2453
  const id = [];
2410
- for (let i = 0; i < moveSize; i++) {
2411
- time[i] = this.readInt(replayDataBuffer, bufferCounter);
2412
- id[i] = time[i] & 3;
2413
- time[i] >>= 2;
2414
- if (id[i] !== exports.MovementType.up) {
2454
+ for (let j = 0; j < moveSize; j++) {
2455
+ time[j] = this.readInt(replayDataBuffer);
2456
+ id[j] = time[j] & 3;
2457
+ time[j] >>= 2;
2458
+ if (id[j] !== exports.MovementType.up) {
2415
2459
  if (resultObject.replayVersion >= 5) {
2416
- x[i] = this.readFloat(replayDataBuffer, bufferCounter);
2417
- y[i] = this.readFloat(replayDataBuffer, bufferCounter);
2460
+ x[j] = this.readFloat(replayDataBuffer);
2461
+ y[j] = this.readFloat(replayDataBuffer);
2418
2462
  }
2419
2463
  else {
2420
- x[i] = this.readShort(replayDataBuffer, bufferCounter);
2421
- y[i] = this.readShort(replayDataBuffer, bufferCounter);
2464
+ x[j] = this.readShort(replayDataBuffer);
2465
+ y[j] = this.readShort(replayDataBuffer);
2422
2466
  }
2423
2467
  }
2424
2468
  else {
2425
- x[i] = -1;
2426
- y[i] = -1;
2469
+ x[j] = -1;
2470
+ y[j] = -1;
2427
2471
  }
2428
2472
  }
2429
2473
  resultObject.cursorMovement.push(new CursorData({
@@ -2434,7 +2478,10 @@ class ReplayAnalyzer {
2434
2478
  id: id,
2435
2479
  }));
2436
2480
  }
2437
- const replayObjectLength = this.readInt(replayDataBuffer, bufferCounter);
2481
+ }
2482
+ parseHitObjectData(resultObject, replayDataBuffer) {
2483
+ resultObject.hitObjectData.length = 0;
2484
+ const replayObjectLength = this.readInt(replayDataBuffer);
2438
2485
  // Parse result data
2439
2486
  for (let i = 0; i < replayObjectLength; i++) {
2440
2487
  const replayObjectData = {
@@ -2442,12 +2489,12 @@ class ReplayAnalyzer {
2442
2489
  tickset: [],
2443
2490
  result: exports.HitResult.miss,
2444
2491
  };
2445
- replayObjectData.accuracy = this.readShort(replayDataBuffer, bufferCounter);
2446
- const len = this.readByte(replayDataBuffer, bufferCounter);
2492
+ replayObjectData.accuracy = this.readShort(replayDataBuffer);
2493
+ const len = this.readByte(replayDataBuffer);
2447
2494
  if (len > 0) {
2448
2495
  const bytes = [];
2449
2496
  for (let j = 0; j < len; j++) {
2450
- bytes.push(this.readByte(replayDataBuffer, bufferCounter));
2497
+ bytes.push(this.readByte(replayDataBuffer));
2451
2498
  }
2452
2499
  // Int/int division in Java; numbers must be truncated to get actual number
2453
2500
  for (let j = 0; j < len * 8; j++) {
@@ -2457,265 +2504,137 @@ class ReplayAnalyzer {
2457
2504
  }
2458
2505
  }
2459
2506
  if (resultObject.replayVersion >= 1) {
2460
- replayObjectData.result = this.readByte(replayDataBuffer, bufferCounter);
2507
+ replayObjectData.result = this.readByte(replayDataBuffer);
2461
2508
  }
2462
2509
  resultObject.hitObjectData.push(replayObjectData);
2463
2510
  }
2464
- // Parse max combo, hit results, and accuracy in old replay version
2465
- if (resultObject.replayVersion < 3) {
2466
- const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2467
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2468
- ? this.beatmap.beatmap
2469
- : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2470
- let grantsGekiOrKatu = true;
2471
- for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2472
- // Hit result
2473
- const hitObjectData = resultObject.hitObjectData[i];
2474
- const isNextNewCombo = objects
2475
- ? i + 1 !== objects.length
2476
- ? objects[i + 1].isNewCombo
2477
- : true
2478
- : false;
2479
- switch (hitObjectData.result) {
2480
- case exports.HitResult.miss:
2481
- ++resultObject.accuracy.nmiss;
2482
- grantsGekiOrKatu = false;
2483
- break;
2484
- case exports.HitResult.meh:
2485
- ++resultObject.accuracy.n50;
2486
- grantsGekiOrKatu = false;
2487
- break;
2488
- case exports.HitResult.good:
2489
- ++resultObject.accuracy.n100;
2490
- if (grantsGekiOrKatu && isNextNewCombo) {
2491
- (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2492
- ++resultObject.hit100k;
2493
- }
2494
- break;
2495
- case exports.HitResult.great:
2496
- ++resultObject.accuracy.n300;
2497
- if (grantsGekiOrKatu && isNextNewCombo) {
2498
- (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2499
- ++resultObject.hit300k;
2500
- }
2501
- break;
2502
- }
2503
- if (isNextNewCombo) {
2504
- grantsGekiOrKatu = true;
2505
- }
2506
- }
2507
- determineRank();
2508
- }
2509
- this.data = new ReplayData(resultObject);
2510
2511
  }
2511
- /**
2512
- * Gets hit error information of the replay.
2513
- *
2514
- * `analyze()` must be called before calling this.
2515
- */
2516
- calculateHitError() {
2517
- if (!this.data || !this.beatmap) {
2518
- return null;
2512
+ parseOldReplayInformation(resultObject) {
2513
+ var _a, _b, _c;
2514
+ // Parse max combo, hit results, and accuracy in old replay version
2515
+ if (resultObject.replayVersion >= 3) {
2516
+ return;
2519
2517
  }
2520
- const hitObjectData = this.data.hitObjectData;
2521
- let positiveCount = 0;
2522
- let negativeCount = 0;
2523
- let positiveTotal = 0;
2524
- let negativeTotal = 0;
2525
- const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2518
+ const objects = (_a = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2526
2519
  this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2527
2520
  ? this.beatmap.beatmap
2528
- : this.beatmap;
2529
- const { objects } = beatmap.hitObjects;
2530
- const od = osuBase.calculateDroidDifficultyStatistics({
2531
- overallDifficulty: beatmap.difficulty.od,
2532
- mods: osuBase.ModUtil.removeSpeedChangingMods(this.data.convertedMods),
2533
- }).overallDifficulty;
2534
- const hitWindow50 = new osuBase.DroidHitWindow(od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2535
- // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2536
- // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2537
- //
2538
- // In such cases, the slider is skipped.
2539
- const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2540
- const accuracies = [];
2541
- for (let i = 0; i < hitObjectData.length; ++i) {
2542
- const v = hitObjectData[i];
2543
- const o = objects[i];
2544
- if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2545
- accuracies.push(0);
2546
- continue;
2547
- }
2548
- const { accuracy } = v;
2549
- if (o instanceof osuBase.Slider && accuracy === sliderbreakHitOffset) {
2550
- accuracies.push(0);
2551
- continue;
2552
- }
2553
- accuracies.push(accuracy);
2554
- if (accuracy >= 0) {
2555
- positiveTotal += accuracy;
2556
- ++positiveCount;
2557
- }
2558
- else {
2559
- negativeTotal += accuracy;
2560
- ++negativeCount;
2561
- }
2562
- }
2563
- return {
2564
- positiveAvg: positiveTotal / positiveCount || 0,
2565
- negativeAvg: negativeTotal / negativeCount || 0,
2566
- unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2567
- };
2568
- }
2569
- /**
2570
- * Converts replay mods to droid mod string.
2571
- */
2572
- convertDroidMods(replayMods) {
2573
- const replayModsConstants = {
2574
- MOD_AUTO: new osuBase.ModAuto().droidString,
2575
- MOD_AUTOPILOT: new osuBase.ModAutopilot().droidString,
2576
- MOD_NOFAIL: new osuBase.ModNoFail().droidString,
2577
- MOD_EASY: new osuBase.ModEasy().droidString,
2578
- MOD_HIDDEN: new osuBase.ModHidden().droidString,
2579
- MOD_HARDROCK: new osuBase.ModHardRock().droidString,
2580
- MOD_DOUBLETIME: new osuBase.ModDoubleTime().droidString,
2581
- MOD_HALFTIME: new osuBase.ModHalfTime().droidString,
2582
- MOD_NIGHTCORE: new osuBase.ModNightCore().droidString,
2583
- MOD_PRECISE: new osuBase.ModPrecise().droidString,
2584
- MOD_SMALLCIRCLE: new osuBase.ModSmallCircle().droidString,
2585
- MOD_SPEEDUP: new osuBase.ModSpeedUp().droidString,
2586
- MOD_REALLYEASY: new osuBase.ModReallyEasy().droidString,
2587
- MOD_RELAX: new osuBase.ModRelax().droidString,
2588
- MOD_PERFECT: new osuBase.ModPerfect().droidString,
2589
- MOD_SUDDENDEATH: new osuBase.ModSuddenDeath().droidString,
2590
- MOD_SCOREV2: new osuBase.ModScoreV2().droidString,
2591
- MOD_FLASHLIGHT: new osuBase.ModFlashlight().droidString,
2592
- };
2593
- let modString = "";
2594
- for (const mod of replayMods) {
2595
- for (const property in replayModsConstants) {
2596
- if (!mod.includes(property)) {
2597
- continue;
2598
- }
2599
- modString +=
2600
- replayModsConstants[property];
2601
- break;
2521
+ : this.beatmap)) === null || _a === void 0 ? void 0 : _a.hitObjects.objects;
2522
+ let grantsGekiOrKatu = true;
2523
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
2524
+ // Hit result
2525
+ const hitObjectData = resultObject.hitObjectData[i];
2526
+ const isNextNewCombo = objects
2527
+ ? i + 1 !== objects.length
2528
+ ? objects[i + 1].isNewCombo
2529
+ : true
2530
+ : false;
2531
+ switch (hitObjectData.result) {
2532
+ case exports.HitResult.miss:
2533
+ ++resultObject.accuracy.nmiss;
2534
+ grantsGekiOrKatu = false;
2535
+ break;
2536
+ case exports.HitResult.meh:
2537
+ ++resultObject.accuracy.n50;
2538
+ grantsGekiOrKatu = false;
2539
+ break;
2540
+ case exports.HitResult.good:
2541
+ ++resultObject.accuracy.n100;
2542
+ if (grantsGekiOrKatu && isNextNewCombo) {
2543
+ (_b = resultObject.hit100k) !== null && _b !== void 0 ? _b : (resultObject.hit100k = 0);
2544
+ ++resultObject.hit100k;
2545
+ }
2546
+ break;
2547
+ case exports.HitResult.great:
2548
+ ++resultObject.accuracy.n300;
2549
+ if (grantsGekiOrKatu && isNextNewCombo) {
2550
+ (_c = resultObject.hit300k) !== null && _c !== void 0 ? _c : (resultObject.hit300k = 0);
2551
+ ++resultObject.hit300k;
2552
+ }
2553
+ break;
2602
2554
  }
2603
- }
2604
- return modString;
2605
- }
2606
- /**
2607
- * Checks if a play is using 3 fingers.
2608
- *
2609
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2610
- */
2611
- checkFor3Finger() {
2612
- var _a;
2613
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2614
- return;
2615
- }
2616
- if (!this.convertedBeatmap) {
2617
- const mods = this.data.convertedMods.slice();
2618
- if ([
2619
- this.data.forceCS,
2620
- this.data.forceAR,
2621
- this.data.forceOD,
2622
- this.data.forceHP,
2623
- ].some((v) => v !== undefined)) {
2624
- mods.push(new osuBase.ModDifficultyAdjust({
2625
- cs: this.data.forceCS,
2626
- ar: this.data.forceAR,
2627
- od: this.data.forceOD,
2628
- hp: this.data.forceHP,
2629
- }));
2555
+ if (isNextNewCombo) {
2556
+ grantsGekiOrKatu = true;
2630
2557
  }
2631
- (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2632
- ? this.beatmap
2633
- : this.beatmap.beatmap).convert({
2634
- mode: osuBase.Modes.droid,
2635
- mods: mods,
2636
- customSpeedMultiplier: this.data.speedMultiplier,
2637
- }));
2638
2558
  }
2639
- const threeFingerChecker = this.difficultyAttributes.mode === "rebalance"
2640
- ? new RebalanceThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes)
2641
- : new ThreeFingerChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2642
- const result = threeFingerChecker.check();
2643
- this.is3Finger = result.is3Finger;
2644
- this.tapPenalty = result.penalty;
2645
- this.hasBeenCheckedFor3Finger = true;
2559
+ resultObject.rank = this.calculateRank(resultObject);
2646
2560
  }
2647
- /**
2648
- * Checks if a play is using 2 hands.
2649
- *
2650
- * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2651
- */
2652
- checkFor2Hand() {
2653
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2654
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2655
- !this.data) {
2656
- return;
2561
+ calculateRank(resultObject) {
2562
+ var _a, _b;
2563
+ const totalHits = resultObject.accuracy.n300 +
2564
+ resultObject.accuracy.n100 +
2565
+ resultObject.accuracy.n50 +
2566
+ resultObject.accuracy.nmiss;
2567
+ const isHidden = (_b = (_a = resultObject.convertedMods) === null || _a === void 0 ? void 0 : _a.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight)) !== null && _b !== void 0 ? _b : false;
2568
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2569
+ switch (true) {
2570
+ case resultObject.accuracy.value() === 1:
2571
+ return isHidden ? "XH" : "X";
2572
+ case hit300Ratio > 0.9 &&
2573
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
2574
+ !resultObject.accuracy.nmiss:
2575
+ return isHidden ? "SH" : "S";
2576
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2577
+ hit300Ratio > 0.9:
2578
+ return "A";
2579
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2580
+ hit300Ratio > 0.8:
2581
+ return "B";
2582
+ case hit300Ratio > 0.6:
2583
+ return "C";
2584
+ default:
2585
+ return "D";
2657
2586
  }
2658
- const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2659
- const result = twoHandChecker.check();
2660
- this.is2Hand = result.is2Hand;
2661
- this.twoHandedNoteCount = result.twoHandedNoteCount;
2662
- this.hasBeenCheckedFor2Hand = true;
2663
2587
  }
2664
- /**
2665
- * Checks if a play has cheesed sliders.
2666
- *
2667
- * Requires `analyze()` to be called first and `map` and `difficultyAttributes` to be defined.
2668
- */
2669
- checkForSliderCheesing() {
2670
- var _a;
2671
- if (!this.beatmap || !this.data || !this.difficultyAttributes) {
2672
- return;
2673
- }
2674
- if (!this.convertedBeatmap) {
2675
- const mods = this.data.convertedMods.slice();
2676
- if ([
2588
+ constructPlayableBeatmap() {
2589
+ var _a, _b;
2590
+ if (!this.beatmap || !this.data) {
2591
+ throw new Error("Beatmap and replay data must be defined.");
2592
+ }
2593
+ const mods = this.data.isReplayV3()
2594
+ ? this.data.convertedMods.slice()
2595
+ : (_b = (_a = this.difficultyAttributes) === null || _a === void 0 ? void 0 : _a.mods.slice()) !== null && _b !== void 0 ? _b : [];
2596
+ if (this.data.isReplayV5() &&
2597
+ [
2677
2598
  this.data.forceCS,
2678
2599
  this.data.forceAR,
2679
2600
  this.data.forceOD,
2680
2601
  this.data.forceHP,
2681
2602
  ].some((v) => v !== undefined)) {
2682
- mods.push(new osuBase.ModDifficultyAdjust({
2683
- cs: this.data.forceCS,
2684
- ar: this.data.forceAR,
2685
- od: this.data.forceOD,
2686
- hp: this.data.forceHP,
2687
- }));
2688
- }
2689
- (_a = this.convertedBeatmap) !== null && _a !== void 0 ? _a : (this.convertedBeatmap = new osuBase.BeatmapConverter(this.beatmap instanceof osuBase.Beatmap
2690
- ? this.beatmap
2691
- : this.beatmap.beatmap).convert({
2692
- mode: osuBase.Modes.droid,
2693
- mods: mods,
2694
- customSpeedMultiplier: this.data.speedMultiplier,
2603
+ mods.push(new osuBase.ModDifficultyAdjust({
2604
+ cs: this.data.forceCS,
2605
+ ar: this.data.forceAR,
2606
+ od: this.data.forceOD,
2607
+ hp: this.data.forceHP,
2695
2608
  }));
2696
2609
  }
2697
- const sliderCheeseChecker = new SliderCheeseChecker(this.convertedBeatmap, this.data, this.difficultyAttributes);
2698
- this.sliderCheesePenalty = sliderCheeseChecker.check();
2699
- this.hasBeenCheckedForSliderCheesing = true;
2610
+ return (this.beatmap instanceof osuBase.Beatmap
2611
+ ? this.beatmap
2612
+ : this.beatmap.beatmap).createPlayableBeatmap({
2613
+ mode: osuBase.Modes.droid,
2614
+ mods: mods,
2615
+ customSpeedMultiplier: this.data.isReplayV4()
2616
+ ? this.data.speedMultiplier
2617
+ : 1,
2618
+ });
2700
2619
  }
2701
- readByte(buffer, counter) {
2702
- const num = buffer.readInt8(counter.counter);
2703
- counter.counter += 1;
2620
+ readByte(buffer) {
2621
+ const num = buffer.readInt8(this.bufferOffset);
2622
+ this.bufferOffset += 1;
2704
2623
  return num;
2705
2624
  }
2706
- readShort(buffer, counter) {
2707
- const num = buffer.readInt16BE(counter.counter);
2708
- counter.counter += 2;
2625
+ readShort(buffer) {
2626
+ const num = buffer.readInt16BE(this.bufferOffset);
2627
+ this.bufferOffset += 2;
2709
2628
  return num;
2710
2629
  }
2711
- readInt(buffer, counter) {
2712
- const num = buffer.readInt32BE(counter.counter);
2713
- counter.counter += 4;
2630
+ readInt(buffer) {
2631
+ const num = buffer.readInt32BE(this.bufferOffset);
2632
+ this.bufferOffset += 4;
2714
2633
  return num;
2715
2634
  }
2716
- readFloat(buffer, counter) {
2717
- const num = buffer.readFloatBE(counter.counter);
2718
- counter.counter += 4;
2635
+ readFloat(buffer) {
2636
+ const num = buffer.readFloatBE(this.bufferOffset);
2637
+ this.bufferOffset += 4;
2719
2638
  return num;
2720
2639
  }
2721
2640
  }
@@ -2742,6 +2661,9 @@ exports.RebalanceThreeFingerChecker = RebalanceThreeFingerChecker;
2742
2661
  exports.ReplayAnalyzer = ReplayAnalyzer;
2743
2662
  exports.ReplayData = ReplayData;
2744
2663
  exports.ReplayObjectData = ReplayObjectData;
2664
+ exports.ReplayV3Data = ReplayV3Data;
2665
+ exports.ReplayV4Data = ReplayV4Data;
2666
+ exports.ReplayV5Data = ReplayV5Data;
2745
2667
  exports.SliderCheeseChecker = SliderCheeseChecker;
2746
2668
  exports.ThreeFingerChecker = ThreeFingerChecker;
2747
2669
  exports.TwoHandChecker = TwoHandChecker;