@rian8337/osu-droid-replay-analyzer 3.0.0-beta.31 → 3.0.0-beta.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -130,6 +130,12 @@ class CursorOccurrenceGroup {
130
130
  get endTime() {
131
131
  return this._up?.time ?? this._moves.at(-1)?.time ?? this._down.time;
132
132
  }
133
+ /**
134
+ * The duration this cursor occurrence group is active for.
135
+ */
136
+ get duration() {
137
+ return this.endTime - this.startTime;
138
+ }
133
139
  /**
134
140
  * All cursor occurrences in this group.
135
141
  *
@@ -828,73 +834,69 @@ class ThreeFingerChecker {
828
834
  * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
829
835
  */
830
836
  findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
831
- let objectIndex = sectionObjects.findIndex((v, i) => !(v.object instanceof osuBase.Spinner) &&
832
- sectionReplayObjectData[i].result !== exports.HitResult.miss);
833
- if (objectIndex === -1) {
834
- return -1;
835
- }
836
- while (cursorIndexes.length > 0) {
837
- if (objectIndex === sectionObjects.length) {
838
- break;
837
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
838
+ const hitWindow50 = this.hitWindow.hitWindowFor50(isPrecise);
839
+ for (let i = 0; i < sectionObjects.length && cursorIndexes.every((v) => v !== -1); ++i) {
840
+ const object = sectionObjects[i];
841
+ const objectData = sectionReplayObjectData[i];
842
+ if (object.object instanceof osuBase.Spinner ||
843
+ objectData.result === exports.HitResult.miss) {
844
+ continue;
839
845
  }
840
- const o = sectionObjects[objectIndex];
841
- const s = sectionReplayObjectData[objectIndex];
842
- ++objectIndex;
843
- if (s.result === exports.HitResult.miss) {
846
+ // Exclude sliderbreaks.
847
+ if (object.object instanceof osuBase.Slider &&
848
+ objectData.accuracy === Math.floor(hitWindow50) + 13) {
844
849
  continue;
845
850
  }
846
- // Get the cursor instance that is closest to the object's hit time.
851
+ const objectPosition = object.object.getStackedPosition(osuBase.Modes.droid);
852
+ const hitTime = object.object.startTime + objectData.accuracy;
853
+ // Observe the cursor position at the object's hit time.
847
854
  for (let j = 0; j < cursorIndexes.length; ++j) {
848
- const c = this.data.cursorMovement[cursorIndexes[j]];
849
- // Cursor instances aren't always recorded at all times,
850
- // therefore the game emulates the movement between
851
- // movementType.MOVE cursors.
852
- const hitTime = o.object.startTime + s.accuracy;
853
- const cursorGroup = c.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
854
- if (!cursorGroup) {
855
+ if (cursorIndexes[j] === -1) {
855
856
  continue;
856
857
  }
857
- const cursors = cursorGroup.allOccurrences;
858
- const nextHitIndex = cursors.findIndex((v) => v.time >= hitTime);
859
- const hitIndex = nextHitIndex - 1;
860
- if (hitIndex <= -1) {
861
- cursorIndexes[j] = -1;
858
+ const cursorData = this.data.cursorMovement[cursorIndexes[j]];
859
+ const cursorGroup = cursorData.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
860
+ if (!cursorGroup) {
862
861
  continue;
863
862
  }
864
- const cursorPosition = new osuBase.Vector2(cursors[hitIndex].position.x, cursors[hitIndex].position.y);
865
- let isInObject = false;
866
- if (cursors[nextHitIndex].id === exports.MovementType.move) {
867
- // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
868
- // This minimizes rounding error.
869
- for (let mSecPassed = cursors[hitIndex].time; mSecPassed <= cursors[nextHitIndex].time; ++mSecPassed) {
870
- const t = (mSecPassed - cursors[nextHitIndex].time) /
871
- (cursors[hitIndex].time -
872
- cursors[nextHitIndex].time);
873
- cursorPosition.x = osuBase.Interpolation.lerp(cursors[hitIndex].position.x, cursors[nextHitIndex].position.x, t);
874
- cursorPosition.y = osuBase.Interpolation.lerp(cursors[hitIndex].position.y, cursors[nextHitIndex].position.y, t);
875
- if (o.object
876
- .getStackedPosition(osuBase.Modes.droid)
877
- .getDistance(cursorPosition) <=
878
- o.object.getRadius(osuBase.Modes.droid)) {
879
- isInObject = true;
863
+ const cursors = cursorGroup.allOccurrences;
864
+ for (let k = 1; k < cursors.length; ++k) {
865
+ const cursor = cursors[k];
866
+ const prevCursor = cursors[k - 1];
867
+ if (prevCursor.time <
868
+ object.object.startTime - hitWindow50) {
869
+ continue;
870
+ }
871
+ if (prevCursor.time >
872
+ object.object.startTime + hitWindow50) {
873
+ break;
874
+ }
875
+ let isInObject = false;
876
+ switch (cursor.id) {
877
+ case exports.MovementType.up:
878
+ isInObject =
879
+ prevCursor.position.getDistance(objectPosition) <= object.object.getRadius(osuBase.Modes.droid);
880
880
  break;
881
- }
881
+ case exports.MovementType.move:
882
+ // Interpolate movement.
883
+ for (let mSecPassed = prevCursor.time; !isInObject &&
884
+ mSecPassed <=
885
+ Math.min(cursor.time, object.object.startTime + hitWindow50); ++mSecPassed) {
886
+ const t = (mSecPassed - prevCursor.time) /
887
+ (cursor.time - prevCursor.time);
888
+ 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));
889
+ isInObject =
890
+ objectPosition.getDistance(cursorPosition) <= object.object.getRadius(osuBase.Modes.droid);
891
+ }
892
+ }
893
+ if (!isInObject) {
894
+ cursorIndexes[j] = -1;
882
895
  }
883
- }
884
- else {
885
- isInObject =
886
- o.object
887
- .getStackedPosition(osuBase.Modes.droid)
888
- .getDistance(cursorPosition) <=
889
- o.object.getRadius(osuBase.Modes.droid);
890
- }
891
- if (!isInObject) {
892
- cursorIndexes[j] = -1;
893
896
  }
894
897
  }
895
- cursorIndexes = cursorIndexes.filter((v) => v !== -1);
896
898
  }
897
- return cursorIndexes.shift() ?? -1;
899
+ return cursorIndexes.find((v) => v !== -1) ?? -1;
898
900
  }
899
901
  /**
900
902
  * Redivides the beatmap into sections.
@@ -994,13 +996,6 @@ class ThreeFingerChecker {
994
996
  exports.HitResult.miss
995
997
  ? objectData[beatmapSection.lastObjectIndex].accuracy
996
998
  : this.hitWindow.hitWindowFor50(isPrecise));
997
- // Filter cursor instances during section.
998
- this.downCursorInstances.forEach((c) => {
999
- const i = c.findIndex((t) => t.time >= startTime);
1000
- if (i !== -1) {
1001
- c = c.slice(i);
1002
- }
1003
- });
1004
999
  const cursorAmounts = [];
1005
1000
  const cursorVectorTimes = [];
1006
1001
  for (let i = 0; i < this.downCursorInstances.length; ++i) {
@@ -1069,7 +1064,8 @@ class ThreeFingerChecker {
1069
1064
  .slice(2);
1070
1065
  // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
1071
1066
  if ((threeFingerRatio > this.threeFingerRatioThreshold &&
1072
- cursorAmounts.filter((v) => v > 1).length > 3) ||
1067
+ cursorAmounts.filter((v) => v > 1).length >
1068
+ fingerSplitIndex) ||
1073
1069
  validPresses.length > 0) {
1074
1070
  // Strain factor
1075
1071
  const objectCount = beatmapSection.lastObjectIndex -
@@ -1130,6 +1126,20 @@ class IndexedHitObject {
1130
1126
  * the hitobject or it did not meet the criteria for detection.
1131
1127
  */
1132
1128
  cursorIndex;
1129
+ /**
1130
+ * The group index of the cursor within the cursor index that hits the hitobject.
1131
+ *
1132
+ * If -1, the detection was unable to find any cursor that attempted to hit
1133
+ * the hitobject or it did not meet the criteria for detection.
1134
+ */
1135
+ groupIndex;
1136
+ /**
1137
+ * The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
1138
+ *
1139
+ * If -1, the detection was unable to find any cursor that attempted to hit
1140
+ * the hitobject or it did not meet the criteria for detection.
1141
+ */
1142
+ occurrenceIndex;
1133
1143
  /**
1134
1144
  * The angle of the movement of the cursor towards the next hitobject.
1135
1145
  */
@@ -1143,23 +1153,34 @@ class IndexedHitObject {
1143
1153
  */
1144
1154
  object;
1145
1155
  /**
1146
- * Whether the hitobject was most likely two-handed.
1156
+ * The position of the cursor at the end of this hitobject.
1157
+ *
1158
+ * Will be altered during detection.
1147
1159
  */
1148
- get is2Handed() {
1149
- return this.angle !== null && this.angle >= Math.PI / 6;
1150
- }
1160
+ endCursorPosition;
1161
+ /**
1162
+ * Whether the hitobject is likely two-handed.
1163
+ */
1164
+ is2Handed;
1151
1165
  /**
1152
1166
  * @param object The underlying difficulty hitobject.
1153
1167
  * @param cursorIndex The cursor index that moves towards the hitobject.
1168
+ * @param groupIndex The group index of the cursor within the cursor index that hits the hitobject.
1169
+ * @param occurrenceIndex The occurrence index within the group of the cursor within the cursor index that hits the hitobject.
1154
1170
  * @param angle The angle of the movement of the cursor that moves towards the hitobject.
1155
1171
  */
1156
- constructor(object, cursorIndex, angle) {
1172
+ constructor(object, cursorIndex, groupIndex, occurrenceIndex, angle, is2Handed) {
1157
1173
  this.object = object;
1158
1174
  this.cursorIndex = cursorIndex;
1175
+ this.groupIndex = groupIndex;
1176
+ this.occurrenceIndex = occurrenceIndex;
1159
1177
  this.angle = angle;
1178
+ this.is2Handed = is2Handed;
1179
+ this.endCursorPosition = this.object.object.getStackedEndPosition(osuBase.Modes.droid);
1160
1180
  }
1161
1181
  }
1162
1182
 
1183
+ // import { writeFileSync } from "fs";
1163
1184
  /**
1164
1185
  * Utility to check whether or not a beatmap is two-handed.
1165
1186
  */
@@ -1184,6 +1205,7 @@ class TwoHandChecker {
1184
1205
  * The 50 osu!droid hit window of the analyzed beatmap.
1185
1206
  */
1186
1207
  hitWindow50;
1208
+ // private csvString: string;
1187
1209
  /**
1188
1210
  * @param calculator The difficulty calculator to analyze.
1189
1211
  * @param data The data of the replay.
@@ -1198,17 +1220,86 @@ class TwoHandChecker {
1198
1220
  }).calculate();
1199
1221
  this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1200
1222
  this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1223
+ // this.csvString = `Mods,${
1224
+ // data.convertedMods.reduce((a, m) => a + m.acronym, "") || "NM"
1225
+ // }\nCombo,${data.maxCombo}\nAccuracy,"${(
1226
+ // data.accuracy.value() * 100
1227
+ // ).toFixed(2)}% [${data.accuracy.n300}/${data.accuracy.n100}/${
1228
+ // data.accuracy.n50
1229
+ // }/${
1230
+ // data.accuracy.nmiss
1231
+ // }]"\n\nIndex,Type,StartTime,EndTime,DeltaTime,CursorIndex,GroupIndex,CursorDuration\n`;
1201
1232
  }
1202
1233
  /**
1203
1234
  * Checks if a beatmap is two-handed.
1204
1235
  */
1205
1236
  check() {
1206
1237
  if (this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 1) {
1207
- return { is2Hand: false, cursorIndexes: [] };
1238
+ return { is2Hand: false, twoHandedNoteCount: 0 };
1208
1239
  }
1209
1240
  this.indexHitObjects();
1210
- this.applyPenalty();
1211
- return { is2Hand: false, cursorIndexes: [] };
1241
+ // this.applyPenalty();
1242
+ // for (let i = 0; i < this.calculator.objects.length; ++i) {
1243
+ // const object: PlaceableHitObject =
1244
+ // this.calculator.objects[i].object;
1245
+ // if (object instanceof Spinner) {
1246
+ // continue;
1247
+ // }
1248
+ // let deltaTime: number = 0;
1249
+ // if (i > 0) {
1250
+ // const prevObject: PlaceableHitObject =
1251
+ // this.calculator.objects[i - 1].object;
1252
+ // deltaTime = Math.max(
1253
+ // object.startTime - prevObject.startTime,
1254
+ // 25
1255
+ // );
1256
+ // if (prevObject instanceof Slider) {
1257
+ // deltaTime = Math.max(
1258
+ // deltaTime - prevObject.lazyTravelTime,
1259
+ // 25
1260
+ // );
1261
+ // }
1262
+ // }
1263
+ // const objectInfo = this.getCursorPositionForObjectStart(i);
1264
+ // this.csvString += `${i},${object.typeStr()},${object.startTime},${
1265
+ // object instanceof Slider
1266
+ // ? object.startTime + object.lazyTravelTime
1267
+ // : object.endTime
1268
+ // },${deltaTime},${objectInfo.cursorIndex},${objectInfo.groupIndex},${
1269
+ // this.data.cursorMovement[objectInfo.cursorIndex]
1270
+ // ?.occurrenceGroups[objectInfo.groupIndex]?.duration ??
1271
+ // Number.POSITIVE_INFINITY
1272
+ // }\n`;
1273
+ // }
1274
+ // writeFileSync(
1275
+ // join(
1276
+ // process.cwd(),
1277
+ // "files",
1278
+ // `${this.data.playerName} - ${this.calculator.beatmap.metadata.fullTitle}.csv`
1279
+ // ),
1280
+ // this.csvString
1281
+ // );
1282
+ let twoHandedNoteCount = 0;
1283
+ const maxStrain = Math.max(...this.calculator.objects.map((v) => v.aimStrainWithSliders));
1284
+ if (maxStrain) {
1285
+ twoHandedNoteCount = this.indexedHitObjects.reduce((total, object) => {
1286
+ if (!object.is2Handed) {
1287
+ return total;
1288
+ }
1289
+ return (total +
1290
+ 1 /
1291
+ (1 +
1292
+ Math.exp(-((object.object.aimStrainWithSliders /
1293
+ maxStrain) *
1294
+ 12 -
1295
+ 6))));
1296
+ }, 0);
1297
+ }
1298
+ return {
1299
+ is2Hand: twoHandedNoteCount >
1300
+ this.calculator.attributes.aimNoteCount * 0.15,
1301
+ twoHandedNoteCount: twoHandedNoteCount,
1302
+ };
1212
1303
  }
1213
1304
  /**
1214
1305
  * Converts hitobjects into indexed hit objects.
@@ -1221,6 +1312,7 @@ class TwoHandChecker {
1221
1312
  indexes.push(indexedHitObject.cursorIndex);
1222
1313
  this.indexedHitObjects.push(indexedHitObject);
1223
1314
  }
1315
+ // console.log("Aim note count:", this.calculator.attributes.aimNoteCount);
1224
1316
  // console.log(
1225
1317
  // this.indexedHitObjects.filter((v) => v.cursorIndex !== -1).length +
1226
1318
  // 1,
@@ -1248,8 +1340,7 @@ class TwoHandChecker {
1248
1340
  // (v) =>
1249
1341
  // // v.cursorIndex === -1 &&
1250
1342
  // // v.object.aimStrainWithSliders > 200 &&
1251
- // v.object.deltaTime > 75 &&
1252
- // v.is2Handed
1343
+ // v.object.deltaTime > 75 && v.is2Handed
1253
1344
  // )
1254
1345
  // .map((v) => {
1255
1346
  // return {
@@ -1280,17 +1371,17 @@ class TwoHandChecker {
1280
1371
  const { object } = diffObject;
1281
1372
  // We don't care about the first object and spinners.
1282
1373
  if (objectIndex === 0 || object instanceof osuBase.Spinner) {
1283
- return new IndexedHitObject(diffObject, -1, null);
1374
+ return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1284
1375
  }
1285
1376
  // We don't care if the aim strain is too low.
1286
- if (diffObject.aimStrainWithSliders < 200) {
1287
- return new IndexedHitObject(diffObject, -1, null);
1288
- }
1377
+ // if (diffObject.aimStrainWithSliders < 200) {
1378
+ // return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1379
+ // }
1289
1380
  const prevObject = this.calculator.beatmap.hitObjects.objects[objectIndex - 1];
1290
1381
  const prevObjectData = this.data.hitObjectData[objectIndex - 1];
1291
1382
  if (prevObject instanceof osuBase.Spinner ||
1292
1383
  prevObjectData.result === exports.HitResult.miss) {
1293
- return new IndexedHitObject(diffObject, -1, null);
1384
+ return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1294
1385
  }
1295
1386
  const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1296
1387
  let prevObjectEndPosition = prevObject.getStackedEndPosition(osuBase.Modes.droid);
@@ -1309,9 +1400,10 @@ class TwoHandChecker {
1309
1400
  const prevToCurrentMovement = object
1310
1401
  .getStackedPosition(osuBase.Modes.droid)
1311
1402
  .subtract(prevObjectEndPosition);
1403
+ const radius = object.getRadius(osuBase.Modes.droid);
1312
1404
  // Don't consider objects that are too close to each other.
1313
- if (prevToCurrentMovement.length <= object.getRadius(osuBase.Modes.droid)) {
1314
- return new IndexedHitObject(diffObject, -1, null);
1405
+ if (prevToCurrentMovement.length <= radius) {
1406
+ return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1315
1407
  }
1316
1408
  // The case for a one-handed object is that there will be a slight movement in the cursor towards
1317
1409
  // the next object in fast patterns. We should not be worried about slow patterns as they will only
@@ -1322,10 +1414,21 @@ class TwoHandChecker {
1322
1414
  // 2. The cursor position that presses the current object
1323
1415
  // and then get the movement vector between both positions, then see if the cursor movement after the previous object
1324
1416
  // was pressed produces an angle that is acute enough with respect to the aforementioned movement vector.
1417
+ // If the angle isn't fulfilled, check for the "initial velocity", which is the movement velocity from the
1418
+ // previous object's end time to the current object's start time and the "air velocity", which is the
1419
+ // velocity from the previous object's cursor release time to the next object's cursor press time. If the velocity
1420
+ // during the held tap is significantly less than the estimated velocity, it's considered two hand.
1325
1421
  const objectInformation = this.getCursorPositionForObjectStart(objectIndex);
1326
1422
  const prevObjectInformation = this.getCursorPositionForObjectEnd(objectIndex - 1);
1423
+ this.indexedHitObjects[objectIndex - 1].endCursorPosition =
1424
+ prevObjectInformation.position;
1327
1425
  if (prevObjectInformation.position.x === Number.POSITIVE_INFINITY) {
1328
- return new IndexedHitObject(diffObject, -1, null);
1426
+ return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1427
+ }
1428
+ if (prevObjectInformation.cursorIndex ===
1429
+ objectInformation.cursorIndex &&
1430
+ prevObjectInformation.groupIndex === objectInformation.groupIndex) {
1431
+ return new IndexedHitObject(diffObject, objectInformation.cursorIndex, objectInformation.groupIndex, prevObjectInformation.occurrenceIndex, 0, false);
1329
1432
  }
1330
1433
  const cursorData = this.data.cursorMovement[prevObjectInformation.cursorIndex];
1331
1434
  // There can be multiple angles to which the cursor moves towards the next object.
@@ -1334,7 +1437,7 @@ class TwoHandChecker {
1334
1437
  const cursorGroup = cursorData.occurrenceGroups[prevObjectInformation.groupIndex];
1335
1438
  const cursors = cursorGroup.allOccurrences;
1336
1439
  const prevToCurrentCursorMovement = objectInformation.position.subtract(prevObjectInformation.position);
1337
- for (let i = prevObjectInformation.cursorGroupIndex + 1; i < cursors.length; ++i) {
1440
+ for (let i = prevObjectInformation.occurrenceIndex + 1; i < cursors.length; ++i) {
1338
1441
  const cursor = cursors[i];
1339
1442
  const prevCursor = cursors[i - 1];
1340
1443
  if (cursor.position.equals(prevCursor.position)) {
@@ -1350,167 +1453,64 @@ class TwoHandChecker {
1350
1453
  const movementAngle = Math.abs(Math.atan2(det, dot));
1351
1454
  finalAngle = Math.min(finalAngle, movementAngle);
1352
1455
  }
1353
- return new IndexedHitObject(diffObject, Number.isFinite(finalAngle)
1354
- ? prevObjectInformation.cursorIndex
1355
- : -1, Number.isFinite(finalAngle) ? finalAngle : null);
1356
- // let checkingTime: number = prevObject.endTime;
1357
- // if (prevObject instanceof Circle) {
1358
- // checkingTime += prevObjectData.accuracy;
1359
- // } else if (prevDiffObject.travelDistance === 0) {
1360
- // // If the slider doesn't have any travel distance, use the start time as the checking point.
1361
- // checkingTime = prevObject.startTime;
1362
- // if (prevObjectData.accuracy !== Math.floor(this.hitWindow50) + 13) {
1363
- // checkingTime += prevObjectData.accuracy;
1364
- // }
1365
- // }
1366
- // let cursorTimeLimit: number = object.startTime;
1367
- // if (object instanceof Circle) {
1368
- // cursorTimeLimit += objectData.accuracy;
1369
- // } else if (object instanceof Slider) {
1370
- // if (objectData.accuracy !== Math.floor(this.hitWindow50) + 13) {
1371
- // cursorTimeLimit += objectData.accuracy;
1372
- // } else {
1373
- // cursorTimeLimit += this.hitWindow50;
1374
- // }
1375
- // }
1376
- // let hitWindow: number = this.hitWindow50;
1377
- // const isPrecise: boolean = this.data.convertedMods.some(
1378
- // (m) => m instanceof ModPrecise
1379
- // );
1380
- // // For sliders, set the hit window to as lenient as possible.
1381
- // if (object instanceof Circle) {
1382
- // switch (objectData.result) {
1383
- // case HitResult.great:
1384
- // hitWindow = this.hitWindow.hitWindowFor300(isPrecise);
1385
- // break;
1386
- // case HitResult.good:
1387
- // hitWindow = this.hitWindow.hitWindowFor100(isPrecise);
1388
- // break;
1389
- // }
1390
- // }
1391
- // // There can be multiple angles to which every cursor moves towards the next object.
1392
- // // For this, we take the smallest angle from a cursor.
1393
- // const finalAngles: number[] = Utils.initializeArray(
1394
- // this.data.cursorMovement.length,
1395
- // Number.POSITIVE_INFINITY
1396
- // );
1397
- // for (
1398
- // let i = prevObjectInformation.cursorIndex;
1399
- // i < this.data.cursorMovement.length;
1400
- // ++i
1401
- // ) {
1402
- // }
1403
- // for (const cursor of this.data.cursorMovement) {
1404
- // // TODO: use same method for getCursorPositionForObject
1405
- // const cursorGroup: CursorOccurrenceGroup | undefined =
1406
- // cursor.occurrenceGroups.find((v) => v.isActiveAt(checkingTime));
1407
- // if (!cursorGroup) {
1408
- // continue;
1409
- // }
1410
- // const cursors: CursorOccurrence[] = cursorGroup.allOccurrences;
1411
- // // This is the best approximation we can come up with, which is the cursor position
1412
- // // at the previous object's end time.
1413
- // let cursorIndex: number = cursors.findIndex(
1414
- // (v) => v.time >= checkingTime
1456
+ const is2Handed = finalAngle >= Math.PI / 6;
1457
+ // if (is2Handed) {
1458
+ // // If angle isn't fulfilled, check for cursor velocity.
1459
+ // let deltaTime: number = Math.max(
1460
+ // object.startTime - prevObject.startTime,
1461
+ // 25
1415
1462
  // );
1416
- // const endTimeCursor: CursorOccurrence = cursors[cursorIndex];
1417
- // if (endTimeCursor.id === MovementType.up) {
1418
- // continue;
1463
+ // if (prevObject instanceof Slider) {
1464
+ // deltaTime = Math.max(deltaTime - prevObject.lazyTravelTime, 25);
1419
1465
  // }
1420
- // let actualEndCursorPosition: Vector2 = endTimeCursor.position;
1421
- // if (endTimeCursor.id === MovementType.move) {
1422
- // // Interpolate end cursor up to the point of checking time.
1423
- // const prevCursor: CursorOccurrence = cursors[cursorIndex - 1];
1424
- // const t: number =
1425
- // (checkingTime - prevCursor.time) /
1426
- // (endTimeCursor.time - prevCursor.time);
1427
- // actualEndCursorPosition = new Vector2(
1428
- // Interpolation.lerp(
1429
- // prevCursor.position.x,
1430
- // endTimeCursor.position.x,
1431
- // t
1432
- // ),
1433
- // Interpolation.lerp(
1434
- // prevCursor.position.y,
1435
- // endTimeCursor.position.y,
1436
- // t
1437
- // )
1438
- // );
1439
- // }
1440
- // // There can be multiple angles to which the cursor moves towards the next object.
1441
- // // For this, we take the smallest angle.
1442
- // let finalAngle: number = Number.POSITIVE_INFINITY;
1443
- // // Start the loop at index + 1 so that the loop considers the next cursor as opposed to
1444
- // // the assigned cursor above. Don't do it if the next cursor is an up occurrence, though.
1445
- // if (
1446
- // cursorIndex === 0 ||
1447
- // (cursorIndex < cursors.length - 2 &&
1448
- // !cursors[cursorIndex + 1].position.equals(
1449
- // endTimeCursor.position
1450
- // ))
1466
+ // // "Initial velocity" is the movement velocity from the previous object's end time to the current object's start time.
1467
+ // const initialVelocity: number =
1468
+ // objectStartPosition.getDistance(prevObjectEndPosition) /
1469
+ // deltaTime;
1470
+ // // const finalCursor: CursorOccurrence =
1471
+ // // cursorGroup.moves.at(-1) ?? cursorGroup.down;
1472
+ // const currentCursorGroup: CursorOccurrenceGroup =
1473
+ // this.data.cursorMovement[objectInformation.cursorIndex]
1474
+ // .occurrenceGroups[objectInformation.groupIndex];
1475
+ // // const cursorDistance: number = currentCursorGroup.down.position.getDistance(
1476
+ // // finalCursor.position
1477
+ // // );
1478
+ // const cursorDuration: number = Math.max(
1479
+ // 0,
1480
+ // currentCursorGroup.startTime - cursorGroup.endTime
1481
+ // );
1482
+ // // "Air velocity" is the cursor velocity from the moment the previous cursor release action to the next cursor press action.
1483
+ // // const airVelocity: number = cursorDistance / cursorDuration;
1484
+ // is2Handed =
1485
+ // // airVelocity / initialVelocity < 0.8 &&
1486
+ // cursorDuration / deltaTime > 0.2;
1487
+ // for (
1488
+ // let i = prevObjectInformation.occurrenceIndex + 1;
1489
+ // i < cursors.length && is2Handed;
1490
+ // ++i
1451
1491
  // ) {
1452
- // ++cursorIndex;
1453
- // }
1454
- // const endCursorToCurrentMovement: Vector2 =
1455
- // objectStartPosition.subtract(actualEndCursorPosition);
1456
- // for (; cursorIndex < cursors.length; ++cursorIndex) {
1457
- // const cursor: CursorOccurrence = cursors[cursorIndex];
1458
- // const prevCursor: CursorOccurrence = cursors[cursorIndex - 1];
1492
+ // const cursor: CursorOccurrence = cursors[i];
1493
+ // const prevCursor: CursorOccurrence = cursors[i - 1];
1459
1494
  // if (
1460
- // cursor.id === MovementType.up ||
1461
- // prevCursor.time >= cursorTimeLimit
1495
+ // cursor.position.equals(prevCursor.position) ||
1496
+ // cursor.time === prevCursor.time
1462
1497
  // ) {
1463
- // break;
1464
- // }
1465
- // // Only consider occurrences that actually move from one place to another.
1466
- // if (cursor.position.equals(prevCursor.position)) {
1467
1498
  // continue;
1468
1499
  // }
1469
- // const distance: number = cursor.position.getDistance(
1470
- // actualEndCursorPosition
1471
- // );
1472
- // if (distance > 1.5 * object.getRadius(Modes.droid)) {
1473
- // continue;
1474
- // }
1475
- // const currentMovement: Vector2 = cursor.position.subtract(
1476
- // prevCursor.position
1477
- // );
1478
- // const dot: number = currentMovement.dot(
1479
- // endCursorToCurrentMovement
1480
- // );
1481
- // const det: number =
1482
- // currentMovement.x * endCursorToCurrentMovement.y -
1483
- // currentMovement.y * endCursorToCurrentMovement.x;
1484
- // const movementAngle: number = Math.abs(Math.atan2(det, dot));
1485
- // finalAngle = Math.min(finalAngle, movementAngle);
1486
- // }
1487
- // if (
1488
- // finalAngle === Number.POSITIVE_INFINITY &&
1489
- // (finalAngles.length === 0 ||
1490
- // finalAngles.every((v) => v === Number.POSITIVE_INFINITY))
1491
- // ) {
1492
- // // Special case where drag occurrences are in the same spot as the press.
1493
- // const { down, moves } = cursorGroup;
1494
- // if (
1495
- // // Don't consider this if the press occurrence does not hit the object.
1496
- // down.position.getDistance(prevObjectEndPosition) <=
1497
- // object.getRadius(Modes.droid) &&
1498
- // moves.every((v) => v.position.equals(down.position))
1499
- // ) {
1500
- // finalAngle = 0;
1500
+ // if (cursor.id === MovementType.up) {
1501
+ // break;
1501
1502
  // }
1503
+ // // "Cursor velocity" is the velocity from the previous object's cursor release time to the current object's press time.
1504
+ // const cursorVelocity: number =
1505
+ // cursor.position.getDistance(prevCursor.position) /
1506
+ // (cursor.time - prevCursor.time);
1507
+ // is2Handed = cursorVelocity / initialVelocity < 0.8;
1502
1508
  // }
1503
- // finalAngles.push(finalAngle);
1504
1509
  // }
1505
- // const smallestAngle: number = Math.min(...finalAngles);
1506
- // const finalIndex: number = finalAngles.findIndex(
1507
- // (v) => v === smallestAngle
1508
- // );
1509
- // return new IndexedHitObject(
1510
- // diffObject,
1511
- // Number.isFinite(smallestAngle) ? finalIndex : -1,
1512
- // Number.isFinite(smallestAngle) ? smallestAngle : null
1513
- // );
1510
+ if (!Number.isFinite(finalAngle)) {
1511
+ return new IndexedHitObject(diffObject, -1, -1, -1, null, false);
1512
+ }
1513
+ return new IndexedHitObject(diffObject, prevObjectInformation.cursorIndex, prevObjectInformation.groupIndex, prevObjectInformation.occurrenceIndex, finalAngle, is2Handed);
1514
1514
  }
1515
1515
  /**
1516
1516
  * Gets the position of the cursor that presses an object.
@@ -1527,7 +1527,7 @@ class TwoHandChecker {
1527
1527
  position: objectPosition,
1528
1528
  cursorIndex: 0,
1529
1529
  groupIndex: Number.POSITIVE_INFINITY,
1530
- cursorGroupIndex: Number.POSITIVE_INFINITY,
1530
+ occurrenceIndex: Number.POSITIVE_INFINITY,
1531
1531
  cursorTime: object.startTime,
1532
1532
  };
1533
1533
  }
@@ -1580,7 +1580,7 @@ class TwoHandChecker {
1580
1580
  position: down.position,
1581
1581
  cursorIndex: i,
1582
1582
  groupIndex: j,
1583
- cursorGroupIndex: 0,
1583
+ occurrenceIndex: 0,
1584
1584
  cursorTime: down.time,
1585
1585
  };
1586
1586
  }
@@ -1590,7 +1590,7 @@ class TwoHandChecker {
1590
1590
  position: down.position,
1591
1591
  cursorIndex: i,
1592
1592
  groupIndex: j,
1593
- cursorGroupIndex: 0,
1593
+ occurrenceIndex: 0,
1594
1594
  cursorTime: down.time,
1595
1595
  };
1596
1596
  }
@@ -1659,7 +1659,7 @@ class TwoHandChecker {
1659
1659
  position: nearestPosition,
1660
1660
  cursorIndex: Number.POSITIVE_INFINITY,
1661
1661
  groupIndex: Number.POSITIVE_INFINITY,
1662
- cursorGroupIndex: Number.POSITIVE_INFINITY,
1662
+ occurrenceIndex: Number.POSITIVE_INFINITY,
1663
1663
  cursorTime: object.startTime,
1664
1664
  };
1665
1665
  }
@@ -1667,7 +1667,7 @@ class TwoHandChecker {
1667
1667
  position: nearestPosition,
1668
1668
  cursorIndex: nearestCursorIndex,
1669
1669
  groupIndex: nearestGroupIndex,
1670
- cursorGroupIndex: nearestCursorGroupIndex,
1670
+ occurrenceIndex: nearestCursorGroupIndex,
1671
1671
  cursorTime: nearestCursorTime,
1672
1672
  };
1673
1673
  }
@@ -1760,7 +1760,7 @@ class TwoHandChecker {
1760
1760
  position: nearestPosition,
1761
1761
  cursorIndex: Number.POSITIVE_INFINITY,
1762
1762
  groupIndex: Number.POSITIVE_INFINITY,
1763
- cursorGroupIndex: Number.POSITIVE_INFINITY,
1763
+ occurrenceIndex: Number.POSITIVE_INFINITY,
1764
1764
  cursorTime: object.endTime,
1765
1765
  };
1766
1766
  }
@@ -1768,7 +1768,7 @@ class TwoHandChecker {
1768
1768
  position: nearestPosition,
1769
1769
  cursorIndex: nearestCursorIndex,
1770
1770
  groupIndex: nearestGroupIndex,
1771
- cursorGroupIndex: nearestCursorGroupIndex,
1771
+ occurrenceIndex: nearestCursorGroupIndex,
1772
1772
  cursorTime: nearestCursorTime,
1773
1773
  };
1774
1774
  }
@@ -1804,6 +1804,7 @@ class TwoHandChecker {
1804
1804
  this.indexedHitObjects.forEach((o) => {
1805
1805
  if (!o.is2Handed) {
1806
1806
  beatmaps[0].hitObjects.add(o.object.object);
1807
+ addToSecondBeatmap = true;
1807
1808
  return;
1808
1809
  }
1809
1810
  const beatmap = addToSecondBeatmap
@@ -1861,6 +1862,173 @@ class TwoHandChecker {
1861
1862
  }
1862
1863
  }
1863
1864
 
1865
+ /**
1866
+ * Utility to check whether relevant sliders in a beatmap are cheesed.
1867
+ */
1868
+ class SliderCheeseChecker {
1869
+ /**
1870
+ * The difficulty calculator that is being analyzed.
1871
+ */
1872
+ calculator;
1873
+ /**
1874
+ * The data of the replay.
1875
+ */
1876
+ data;
1877
+ /**
1878
+ * The osu!droid hitwindow of the analyzed beatmap.
1879
+ */
1880
+ hitWindow;
1881
+ /**
1882
+ * The 50 osu!droid hit window of the analyzed beatmap.
1883
+ */
1884
+ hitWindow50;
1885
+ /**
1886
+ * The indexes of objects that were cheesed.
1887
+ */
1888
+ cheesedObjectIndexes = [];
1889
+ /**
1890
+ * @param calculator The difficulty calculator to analyze.
1891
+ * @param data The data of the replay.
1892
+ */
1893
+ constructor(calculator, data) {
1894
+ this.calculator = calculator;
1895
+ this.data = data;
1896
+ const stats = new osuBase.MapStats({
1897
+ od: this.calculator.beatmap.difficulty.od,
1898
+ mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
1899
+ !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
1900
+ }).calculate();
1901
+ this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1902
+ this.hitWindow50 = this.hitWindow.hitWindowFor50(calculator.mods.some((m) => m instanceof osuBase.ModPrecise));
1903
+ }
1904
+ /**
1905
+ * Checks if relevant sliders in the given beatmap was cheesed.
1906
+ *
1907
+ * Returns a number that can be passed to a `DroidPerformanceCalculator`
1908
+ * to alter the aim performance value.
1909
+ */
1910
+ check() {
1911
+ if (this.calculator.attributes.sliderCount === 0) {
1912
+ return 1;
1913
+ }
1914
+ this.checkSliderCheesing();
1915
+ return this.calculateSliderCheesePenalty();
1916
+ }
1917
+ /**
1918
+ * Checks for sliders that were cheesed.
1919
+ */
1920
+ checkSliderCheesing() {
1921
+ // Current loop indexes are stored for efficiency.
1922
+ const cursorLoopIndexes = osuBase.Utils.initializeArray(10, 0);
1923
+ const acceptableRadius = this.calculator.objects[0].object.getRadius(osuBase.Modes.droid) * 2;
1924
+ for (let i = 0; i < this.calculator.objects.length; ++i) {
1925
+ const diffObject = this.calculator.objects[i];
1926
+ const { object } = diffObject;
1927
+ if (diffObject.travelDistance === 0 ||
1928
+ !(object instanceof osuBase.Slider)) {
1929
+ continue;
1930
+ }
1931
+ const objectStartPosition = object.getStackedPosition(osuBase.Modes.droid);
1932
+ const objectData = this.data.hitObjectData[i];
1933
+ // If a slider break occurs, we disregard the check for that slider.
1934
+ if (objectData.accuracy === Math.floor(this.hitWindow50) + 13) {
1935
+ continue;
1936
+ }
1937
+ let isCheesed = false;
1938
+ for (let j = 0; j < this.data.cursorMovement.length; ++j) {
1939
+ if (isCheesed) {
1940
+ break;
1941
+ }
1942
+ const cursorGroups = this.data.cursorMovement[j].occurrenceGroups;
1943
+ for (let k = cursorLoopIndexes[j]; k < cursorGroups.length; k = ++cursorLoopIndexes[j]) {
1944
+ const group = cursorGroups[k];
1945
+ if (group.startTime < object.startTime - this.hitWindow50) {
1946
+ continue;
1947
+ }
1948
+ if (group.startTime > object.startTime + this.hitWindow50) {
1949
+ break;
1950
+ }
1951
+ if (group.down.position.getDistance(objectStartPosition) >
1952
+ acceptableRadius) {
1953
+ continue;
1954
+ }
1955
+ // Track cursor movement to see if it lands on every tick.
1956
+ let occurrenceLoopIndex = 1;
1957
+ let isSliderFulfilled = true;
1958
+ const { allOccurrences } = group;
1959
+ for (let l = 1; l < object.nestedHitObjects.length; ++l) {
1960
+ if (!isSliderFulfilled) {
1961
+ break;
1962
+ }
1963
+ const tickWasHit = objectData.tickset[l - 1];
1964
+ if (!tickWasHit) {
1965
+ continue;
1966
+ }
1967
+ const nestedObject = object.nestedHitObjects[l];
1968
+ const nestedPosition = nestedObject.getStackedPosition(osuBase.Modes.droid);
1969
+ while (occurrenceLoopIndex < allOccurrences.length &&
1970
+ allOccurrences[occurrenceLoopIndex].time <
1971
+ nestedObject.startTime) {
1972
+ ++occurrenceLoopIndex;
1973
+ }
1974
+ if (occurrenceLoopIndex === allOccurrences.length) {
1975
+ continue;
1976
+ }
1977
+ const occurrence = allOccurrences[occurrenceLoopIndex];
1978
+ const prevOccurrence = allOccurrences[occurrenceLoopIndex - 1];
1979
+ switch (occurrence.id) {
1980
+ case exports.MovementType.move: {
1981
+ // Interpolate cursor position during nested object time.
1982
+ const t = (nestedObject.startTime -
1983
+ prevOccurrence.time) /
1984
+ (occurrence.time - prevOccurrence.time);
1985
+ 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));
1986
+ const distance = cursorPosition.getDistance(nestedPosition);
1987
+ if (distance > acceptableRadius) {
1988
+ isSliderFulfilled = false;
1989
+ }
1990
+ break;
1991
+ }
1992
+ case exports.MovementType.up:
1993
+ if (prevOccurrence.position.getDistance(nestedPosition) > acceptableRadius) {
1994
+ isSliderFulfilled = false;
1995
+ break;
1996
+ }
1997
+ }
1998
+ }
1999
+ if (!isSliderFulfilled) {
2000
+ isCheesed = true;
2001
+ break;
2002
+ }
2003
+ }
2004
+ }
2005
+ if (isCheesed) {
2006
+ this.cheesedObjectIndexes.push(i);
2007
+ }
2008
+ }
2009
+ }
2010
+ /**
2011
+ * Calculates the slider cheese penalty.
2012
+ */
2013
+ calculateSliderCheesePenalty() {
2014
+ let calculator;
2015
+ if (this.calculator instanceof osuDifficultyCalculator.DroidDifficultyCalculator) {
2016
+ calculator = new osuDifficultyCalculator.DroidDifficultyCalculator(this.calculator.beatmap);
2017
+ }
2018
+ else {
2019
+ calculator = new osuRebalanceDifficultyCalculator.DroidDifficultyCalculator(this.calculator.beatmap);
2020
+ }
2021
+ Object.assign(calculator.attributes, this.calculator.attributes);
2022
+ calculator.stats = this.calculator.stats;
2023
+ calculator.generateDifficultyHitObjects();
2024
+ for (const index of this.cheesedObjectIndexes) {
2025
+ calculator.objects[index].travelDistance = 0;
2026
+ }
2027
+ calculator.calculateAim();
2028
+ return this.calculator.aim / calculator.aim;
2029
+ }
2030
+ }
2031
+
1864
2032
  /**
1865
2033
  * A replay analyzer that analyzes a replay from osu!droid.
1866
2034
  *
@@ -1890,7 +2058,7 @@ class ReplayAnalyzer {
1890
2058
  */
1891
2059
  is2Hand;
1892
2060
  /**
1893
- * The beatmap that is being analyzed. `DroidStarRating` or `RebalanceDroidStarRating` is required for three finger or two hand analyzing.
2061
+ * The beatmap that is being analyzed. `DroidDifficultyCalculator` or `RebalanceDroidDifficultyCalculator` is required for three finger or two hand analyzing.
1894
2062
  */
1895
2063
  beatmap;
1896
2064
  /**
@@ -1905,6 +2073,10 @@ class ReplayAnalyzer {
1905
2073
  * Penalty value used to penalize dpp for 3 finger abuse.
1906
2074
  */
1907
2075
  tapPenalty = 1;
2076
+ /**
2077
+ * Penalty value used to penalize dpp for slider cheesing.
2078
+ */
2079
+ sliderCheesePenalty = 1;
1908
2080
  /**
1909
2081
  * Whether this replay has been checked against 3 finger usage.
1910
2082
  */
@@ -1914,11 +2086,13 @@ class ReplayAnalyzer {
1914
2086
  */
1915
2087
  hasBeenCheckedFor2Hand = false;
1916
2088
  /**
1917
- * The cursor indexes at which each object was hit.
1918
- *
1919
- * This is filled after 2 hand usage has been checked.
2089
+ * Whether this repla has been checked against slider cheesing.
1920
2090
  */
1921
- twoHandCursorIndexes = [];
2091
+ hasBeenCheckedForSliderCheesing = false;
2092
+ /**
2093
+ * The amount of two-handed objects.
2094
+ */
2095
+ twoHandedNoteCount = 0;
1922
2096
  // Sizes of primitive data types in Java (in bytes)
1923
2097
  BYTE_LENGTH = 1;
1924
2098
  SHORT_LENGTH = 2;
@@ -2364,7 +2538,7 @@ class ReplayAnalyzer {
2364
2538
  /**
2365
2539
  * Checks if a play is using 3 fingers.
2366
2540
  *
2367
- * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
2541
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2368
2542
  */
2369
2543
  checkFor3Finger() {
2370
2544
  if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
@@ -2381,7 +2555,7 @@ class ReplayAnalyzer {
2381
2555
  /**
2382
2556
  * Checks if a play is using 2 hands.
2383
2557
  *
2384
- * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
2558
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2385
2559
  */
2386
2560
  checkFor2Hand() {
2387
2561
  if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
@@ -2392,9 +2566,24 @@ class ReplayAnalyzer {
2392
2566
  const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2393
2567
  const result = twoHandChecker.check();
2394
2568
  this.is2Hand = result.is2Hand;
2395
- this.twoHandCursorIndexes = result.cursorIndexes;
2569
+ this.twoHandedNoteCount = result.twoHandedNoteCount;
2396
2570
  this.hasBeenCheckedFor2Hand = true;
2397
2571
  }
2572
+ /**
2573
+ * Checks if a play has cheesed sliders.
2574
+ *
2575
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidDifficultyCalculator`.
2576
+ */
2577
+ checkForSliderCheesing() {
2578
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2579
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2580
+ !this.data) {
2581
+ return;
2582
+ }
2583
+ const sliderCheeseChecker = new SliderCheeseChecker(this.beatmap, this.data);
2584
+ this.sliderCheesePenalty = sliderCheeseChecker.check();
2585
+ this.hasBeenCheckedForSliderCheesing = true;
2586
+ }
2398
2587
  }
2399
2588
 
2400
2589
  /**