@rian8337/osu-droid-replay-analyzer 3.0.0-beta.2 → 3.0.0-beta.20

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
@@ -29,1979 +29,2217 @@ function _interopNamespace(e) {
29
29
 
30
30
  var javaDeserialization__namespace = /*#__PURE__*/_interopNamespace(javaDeserialization);
31
31
 
32
- /**
33
- * Represents a cursor's occurrence.
34
- */
35
- class CursorOccurrence {
36
- /**
37
- * The time of this occurrence.
38
- */
39
- time;
40
- /**
41
- * The position of the occurrence.
42
- */
43
- position;
44
- /**
45
- * The movement ID of the occurrence.
46
- */
47
- id;
48
- constructor(time, x, y, id) {
49
- this.time = time;
50
- this.position = new osuBase.Vector2(x, y);
51
- this.id = id;
52
- }
32
+ /**
33
+ * Movement types of a cursor in an osu!droid replay.
34
+ *
35
+ * The cursor movement is represented as a player's action on the screen.
36
+ */
37
+ exports.MovementType = void 0;
38
+ (function (MovementType) {
39
+ /**
40
+ * The player places their finger on the screen.
41
+ */
42
+ MovementType[MovementType["down"] = 0] = "down";
43
+ /**
44
+ * The player drags their finger on the screen.
45
+ */
46
+ MovementType[MovementType["move"] = 1] = "move";
47
+ /**
48
+ * The player releases their finger from the screen.
49
+ */
50
+ MovementType[MovementType["up"] = 2] = "up";
51
+ })(exports.MovementType || (exports.MovementType = {}));
52
+
53
+ /**
54
+ * Represents a cursor's occurrence.
55
+ */
56
+ class CursorOccurrence {
57
+ /**
58
+ * The time of this occurrence.
59
+ */
60
+ time;
61
+ /**
62
+ * The position of the occurrence.
63
+ */
64
+ position;
65
+ /**
66
+ * The movement ID of the occurrence.
67
+ */
68
+ id;
69
+ constructor(time, x, y, id) {
70
+ this.time = time;
71
+ this.position = new osuBase.Vector2(x, y);
72
+ this.id = id;
73
+ }
53
74
  }
54
75
 
55
- /**
56
- * Represents a cursor instance in an osu!droid replay.
57
- *
58
- * Stores cursor movement data in the form of `CursorOccurrence`s.
59
- *
60
- * This is used when analyzing replays using replay analyzer.
61
- */
62
- class CursorData {
63
- /**
64
- * The occurrences of this cursor instance.
65
- */
66
- occurrences = [];
67
- constructor(values) {
68
- for (let i = 0; i < values.size; ++i) {
69
- this.occurrences.push(new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]));
70
- }
71
- }
76
+ /**
77
+ * Represents a group of cursor occurrences representing a cursor instance's
78
+ * movement when a player places their finger on the screen.
79
+ */
80
+ class CursorOccurrenceGroup {
81
+ /**
82
+ * The cursor occurrence of movement type `movementType.DOWN`.
83
+ */
84
+ get down() {
85
+ return this._down;
86
+ }
87
+ /**
88
+ * The cursor occurrence of movement type `movementType.DOWN`.
89
+ */
90
+ set down(value) {
91
+ if (value.id !== exports.MovementType.down) {
92
+ throw new TypeError("Attempting to set the down cursor occurrence to one with different movement type.");
93
+ }
94
+ this._down = value;
95
+ }
96
+ /**
97
+ * The cursor occurrences of movement type `movementType.MOVE`.
98
+ */
99
+ get moves() {
100
+ return this._moves;
101
+ }
102
+ /**
103
+ * The cursor occurrence of movement type `movementType.UP`.
104
+ *
105
+ * May not exist, such as when the player holds their cursor until the end of a beatmap.
106
+ */
107
+ get up() {
108
+ return this._up;
109
+ }
110
+ /**
111
+ * The cursor occurrence of movement type `movementType.UP`.
112
+ *
113
+ * May not exist, such as when the player holds their cursor until the end of a beatmap.
114
+ */
115
+ set up(value) {
116
+ if (value && value.id !== exports.MovementType.up) {
117
+ throw new TypeError("Attempting to set the up cursor occurrence to one with different movement type.");
118
+ }
119
+ this._up = value;
120
+ }
121
+ /**
122
+ * The time at which this cursor occurrence group starts.
123
+ */
124
+ get startTime() {
125
+ return this._down.time;
126
+ }
127
+ /**
128
+ * The time at which this cursor occurrence group ends.
129
+ */
130
+ get endTime() {
131
+ if (this._up) {
132
+ return this._up.time;
133
+ }
134
+ return this._moves.at(-1)?.time ?? this._down.time;
135
+ }
136
+ /**
137
+ * All cursor occurrences in this group.
138
+ *
139
+ * This iterates all occurrences and as such should be used sparingly or stored locally.
140
+ */
141
+ get allOccurrences() {
142
+ const cursors = [this._down, ...this._moves];
143
+ if (this._up) {
144
+ cursors.push(this._up);
145
+ }
146
+ return cursors;
147
+ }
148
+ /**
149
+ * The cursor occurrence of movement type `movementType.DOWN`.
150
+ */
151
+ _down;
152
+ /**
153
+ * The cursor occurrences of movement type `movementType.MOVE`.
154
+ */
155
+ _moves;
156
+ /**
157
+ * The cursor occurrence of movement type `movementType.UP`.
158
+ *
159
+ * May not exist, such as when the player holds their cursor until the end of a beatmap.
160
+ */
161
+ _up;
162
+ constructor(down, moves, up) {
163
+ this._down = down;
164
+ this._moves = moves;
165
+ // Re-set down cursor occurrence for checking.
166
+ this.down = down;
167
+ this.up = up;
168
+ }
169
+ /**
170
+ * Determines whether this cursor occurrence group is active at the specified time.
171
+ *
172
+ * @param time The time.
173
+ * @returns Whether this cursor occurrence group is active at the specified time.
174
+ */
175
+ isActiveAt(time) {
176
+ return time >= this.startTime && time <= this.endTime;
177
+ }
178
+ /**
179
+ * Finds the cursor occurrence that is active at a given time.
180
+ *
181
+ * @param time The time.
182
+ * @returns The cursor occurrence at the given time, `null` if not found.
183
+ */
184
+ cursorAt(time) {
185
+ if (!this.isActiveAt(time)) {
186
+ return null;
187
+ }
188
+ if (this._down.time === time) {
189
+ return this._down;
190
+ }
191
+ if (this._up?.time === time) {
192
+ return this._up;
193
+ }
194
+ let l = 0;
195
+ let r = this._moves.length - 2;
196
+ while (l <= r) {
197
+ const pivot = l + ((r - l) >> 1);
198
+ if (this._moves[pivot].time < time) {
199
+ l = pivot + 1;
200
+ }
201
+ else if (this._moves[pivot].time > time) {
202
+ r = pivot - 1;
203
+ }
204
+ else {
205
+ return this._moves[pivot];
206
+ }
207
+ }
208
+ // l will be the first cursor occurrence with time > this._moves[l].time, but we want the one before it
209
+ return this._moves[l - 1];
210
+ }
72
211
  }
73
212
 
74
- /**
75
- * The result of a hit in an osu!droid replay.
76
- */
77
- exports.hitResult = void 0;
78
- (function (hitResult) {
79
- /**
80
- * Miss (0).
81
- */
82
- hitResult[hitResult["RESULT_0"] = 1] = "RESULT_0";
83
- /**
84
- * Meh (50).
85
- */
86
- hitResult[hitResult["RESULT_50"] = 2] = "RESULT_50";
87
- /**
88
- * Great (100).
89
- */
90
- hitResult[hitResult["RESULT_100"] = 3] = "RESULT_100";
91
- /**
92
- * Good (300).
93
- */
94
- hitResult[hitResult["RESULT_300"] = 4] = "RESULT_300";
95
- })(exports.hitResult || (exports.hitResult = {}));
213
+ /**
214
+ * Represents a cursor instance in an osu!droid replay.
215
+ *
216
+ * Stores cursor movement data in the form of `CursorOccurrenceGroup`s.
217
+ *
218
+ * This is used when analyzing replays using replay analyzer.
219
+ */
220
+ class CursorData {
221
+ /**
222
+ * The occurrence groups of this cursor instance.
223
+ */
224
+ occurrenceGroups = [];
225
+ /**
226
+ * The time at which the first occurrence of this cursor instance occurs.
227
+ *
228
+ * Will return `null` if there are no occurrences.
229
+ */
230
+ get earliestOccurrenceTime() {
231
+ return this.occurrenceGroups.at(0)?.startTime ?? null;
232
+ }
233
+ /**
234
+ * The time at which the latest occurrence of this cursor instance occurs.
235
+ *
236
+ * Will return `null` if there are no occurrences.
237
+ */
238
+ get latestOccurrenceTime() {
239
+ return this.occurrenceGroups.at(-1)?.endTime ?? null;
240
+ }
241
+ /**
242
+ * The amount of cursor occurrences of this cursor instance.
243
+ */
244
+ get totalOccurrences() {
245
+ return this.occurrenceGroups.reduce((a, v) => {
246
+ // Down cursor.
247
+ ++a;
248
+ // Move cursors.
249
+ a += v.moves.length;
250
+ if (v.up) {
251
+ // Up cursor.
252
+ ++a;
253
+ }
254
+ return a;
255
+ }, 0);
256
+ }
257
+ /**
258
+ * All cursor occurrences of this cursor instnace.
259
+ *
260
+ * This iterates all occurrence groups and as such should be used sparingly or stored locally.
261
+ */
262
+ get allOccurrences() {
263
+ return this.occurrenceGroups.flatMap((v) => v.allOccurrences);
264
+ }
265
+ constructor(values) {
266
+ let downOccurrence = null;
267
+ let moveOccurrences = [];
268
+ for (let i = 0; i < values.size; ++i) {
269
+ const occurrence = new CursorOccurrence(values.time[i], values.x[i], values.y[i], values.id[i]);
270
+ switch (occurrence.id) {
271
+ case exports.MovementType.down:
272
+ downOccurrence = occurrence;
273
+ break;
274
+ case exports.MovementType.move:
275
+ moveOccurrences.push(occurrence);
276
+ break;
277
+ case exports.MovementType.up:
278
+ if (downOccurrence) {
279
+ this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences, occurrence));
280
+ downOccurrence = null;
281
+ }
282
+ moveOccurrences = [];
283
+ }
284
+ }
285
+ // Add the final cursor occurrence group as the loop may not catch it for special cases.
286
+ if (downOccurrence && moveOccurrences.length > 0) {
287
+ this.occurrenceGroups.push(new CursorOccurrenceGroup(downOccurrence, moveOccurrences));
288
+ }
289
+ }
290
+ }
96
291
 
97
- /**
98
- * Movement type of a cursor in an osu!droid replay.
99
- */
100
- exports.movementType = void 0;
101
- (function (movementType) {
102
- movementType[movementType["DOWN"] = 0] = "DOWN";
103
- movementType[movementType["MOVE"] = 1] = "MOVE";
104
- movementType[movementType["UP"] = 2] = "UP";
105
- })(exports.movementType || (exports.movementType = {}));
292
+ /**
293
+ * The result of a hit in an osu!droid replay.
294
+ */
295
+ exports.HitResult = void 0;
296
+ (function (HitResult) {
297
+ /**
298
+ * Miss (0).
299
+ */
300
+ HitResult[HitResult["miss"] = 1] = "miss";
301
+ /**
302
+ * Meh (50).
303
+ */
304
+ HitResult[HitResult["meh"] = 2] = "meh";
305
+ /**
306
+ * Good (100).
307
+ */
308
+ HitResult[HitResult["good"] = 3] = "good";
309
+ /**
310
+ * Great (300).
311
+ */
312
+ HitResult[HitResult["great"] = 4] = "great";
313
+ })(exports.HitResult || (exports.HitResult = {}));
106
314
 
107
- /**
108
- * Represents a replay data in an osu!droid replay.
109
- *
110
- * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
111
- *
112
- * This is used when analyzing replays using replay analyzer.
113
- */
114
- class ReplayData {
115
- replayVersion;
116
- folderName;
117
- fileName;
118
- hash;
119
- time;
120
- hit300k;
121
- hit100k;
122
- score;
123
- maxCombo;
124
- accuracy;
125
- isFullCombo;
126
- playerName;
127
- rawMods;
128
- rank;
129
- convertedMods;
130
- cursorMovement;
131
- hitObjectData;
132
- speedModification;
133
- forcedAR;
134
- constructor(values) {
135
- this.replayVersion = values.replayVersion;
136
- this.folderName = values.folderName;
137
- this.fileName = values.fileName;
138
- this.hash = values.hash;
139
- this.time = new Date(values.time || 0);
140
- this.hit300k = values.hit300k || 0;
141
- this.hit100k = values.hit100k || 0;
142
- this.score = values.score || 0;
143
- this.maxCombo = values.maxCombo || 0;
144
- this.accuracy = values.accuracy || new osuBase.Accuracy({});
145
- this.isFullCombo = values.isFullCombo || false;
146
- this.playerName = values.playerName || "";
147
- this.rawMods = values.rawMods || "";
148
- this.rank = values.rank || "";
149
- this.convertedMods = values.convertedMods || [];
150
- this.cursorMovement = values.cursorMovement;
151
- this.hitObjectData = values.hitObjectData;
152
- this.speedModification = values.speedModification || 1;
153
- this.forcedAR = values.forcedAR;
154
- }
315
+ /**
316
+ * Represents a replay data in an osu!droid replay.
317
+ *
318
+ * Stores generic information about an osu!droid replay such as player name, MD5 hash, time set, etc.
319
+ *
320
+ * This is used when analyzing replays using replay analyzer.
321
+ */
322
+ class ReplayData {
323
+ replayVersion;
324
+ folderName;
325
+ fileName;
326
+ hash;
327
+ time;
328
+ hit300k;
329
+ hit100k;
330
+ score;
331
+ maxCombo;
332
+ accuracy;
333
+ isFullCombo;
334
+ playerName;
335
+ rawMods;
336
+ rank;
337
+ convertedMods;
338
+ cursorMovement;
339
+ hitObjectData;
340
+ speedModification;
341
+ forcedAR;
342
+ constructor(values) {
343
+ this.replayVersion = values.replayVersion;
344
+ this.folderName = values.folderName;
345
+ this.fileName = values.fileName;
346
+ this.hash = values.hash;
347
+ this.time = new Date(values.time || 0);
348
+ this.hit300k = values.hit300k || 0;
349
+ this.hit100k = values.hit100k || 0;
350
+ this.score = values.score || 0;
351
+ this.maxCombo = values.maxCombo || 0;
352
+ this.accuracy = values.accuracy || new osuBase.Accuracy({});
353
+ this.isFullCombo = values.isFullCombo || false;
354
+ this.playerName = values.playerName || "";
355
+ this.rawMods = values.rawMods || "";
356
+ this.rank = values.rank || "";
357
+ this.convertedMods = values.convertedMods || [];
358
+ this.cursorMovement = values.cursorMovement;
359
+ this.hitObjectData = values.hitObjectData;
360
+ this.speedModification = values.speedModification || 1;
361
+ this.forcedAR = values.forcedAR;
362
+ }
155
363
  }
156
364
 
157
- /**
158
- * A beatmap section generator that generates beatmap section based on aim/speed strain.
159
- */
160
- class BeatmapSectionGenerator {
161
- /**
162
- * Generates `BeatmapSection`s for the specified beatmap.
163
- *
164
- * @param calculator The difficulty calculator to generate for.
165
- * @param minSectionObjectCount The maximum delta time allowed between two beatmap sections.
166
- * Increasing this number decreases the amount of beatmap sections in general. Note that this value does not account for the speed multiplier of
167
- * the play, similar to the way replay object data is stored.
168
- * @param maxSectionDeltaTime The minimum object count required to make a beatmap section. Increasing this number decreases the amount of beatmap sections.
169
- */
170
- static generateSections(calculator, minSectionObjectCount, maxSectionDeltaTime) {
171
- const beatmapSections = [];
172
- let firstObjectIndex = 0;
173
- for (let i = 0; i < calculator.objects.length - 1; ++i) {
174
- const current = calculator.objects[i];
175
- const next = calculator.objects[i + 1];
176
- const realDeltaTime = next.object.startTime - current.object.endTime;
177
- if (realDeltaTime >= maxSectionDeltaTime) {
178
- // Ignore sections that don't meet object count requirement.
179
- if (i - firstObjectIndex < minSectionObjectCount) {
180
- firstObjectIndex = i + 1;
181
- continue;
182
- }
183
- beatmapSections.push({
184
- firstObjectIndex,
185
- lastObjectIndex: i,
186
- });
187
- firstObjectIndex = i + 1;
188
- }
189
- }
190
- // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
191
- if (calculator.objects.length - firstObjectIndex >
192
- minSectionObjectCount) {
193
- beatmapSections.push({
194
- firstObjectIndex,
195
- lastObjectIndex: calculator.objects.length - 1,
196
- });
197
- }
198
- return beatmapSections;
199
- }
365
+ /**
366
+ * A beatmap section generator that generates beatmap section based on aim/speed strain.
367
+ */
368
+ class BeatmapSectionGenerator {
369
+ /**
370
+ * Generates `BeatmapSection`s for the specified beatmap.
371
+ *
372
+ * @param calculator The difficulty calculator to generate for.
373
+ * @param minSectionObjectCount The maximum delta time allowed between two beatmap sections.
374
+ * Increasing this number decreases the amount of beatmap sections in general. Note that this value does not account for the speed multiplier of
375
+ * the play, similar to the way replay object data is stored.
376
+ * @param maxSectionDeltaTime The minimum object count required to make a beatmap section. Increasing this number decreases the amount of beatmap sections.
377
+ */
378
+ static generateSections(calculator, minSectionObjectCount, maxSectionDeltaTime) {
379
+ const beatmapSections = [];
380
+ let firstObjectIndex = 0;
381
+ for (let i = 0; i < calculator.objects.length - 1; ++i) {
382
+ const current = calculator.objects[i];
383
+ const next = calculator.objects[i + 1];
384
+ const realDeltaTime = next.object.startTime - current.object.endTime;
385
+ if (realDeltaTime >= maxSectionDeltaTime) {
386
+ // Ignore sections that don't meet object count requirement.
387
+ if (i - firstObjectIndex < minSectionObjectCount) {
388
+ firstObjectIndex = i + 1;
389
+ continue;
390
+ }
391
+ beatmapSections.push({
392
+ firstObjectIndex,
393
+ lastObjectIndex: i,
394
+ });
395
+ firstObjectIndex = i + 1;
396
+ }
397
+ }
398
+ // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
399
+ if (calculator.objects.length - firstObjectIndex >
400
+ minSectionObjectCount) {
401
+ beatmapSections.push({
402
+ firstObjectIndex,
403
+ lastObjectIndex: calculator.objects.length - 1,
404
+ });
405
+ }
406
+ return beatmapSections;
407
+ }
200
408
  }
201
409
 
202
- /**
203
- * Represents a section of a beatmap.
204
- */
205
- class BeatmapSection {
206
- /**
207
- * The index of the first `DifficultyHitObject` of this beatmap section.
208
- */
209
- firstObjectIndex;
210
- /**
211
- * The index of the last `DifficultyHitObject` of this beatmap section.
212
- */
213
- lastObjectIndex;
214
- /**
215
- * @param firstObjectIndex The index of the first `DifficultyHitObject` of this beatmap section.
216
- * @param lastObjectIndex The index of the last `DifficultyHitObject` of this beatmap section.
217
- */
218
- constructor(firstObjectIndex, lastObjectIndex) {
219
- this.firstObjectIndex = firstObjectIndex;
220
- this.lastObjectIndex = lastObjectIndex;
221
- }
410
+ /**
411
+ * Represents a section of a beatmap.
412
+ */
413
+ class BeatmapSection {
414
+ /**
415
+ * The index of the first `DifficultyHitObject` of this beatmap section.
416
+ */
417
+ firstObjectIndex;
418
+ /**
419
+ * The index of the last `DifficultyHitObject` of this beatmap section.
420
+ */
421
+ lastObjectIndex;
422
+ /**
423
+ * @param firstObjectIndex The index of the first `DifficultyHitObject` of this beatmap section.
424
+ * @param lastObjectIndex The index of the last `DifficultyHitObject` of this beatmap section.
425
+ */
426
+ constructor(firstObjectIndex, lastObjectIndex) {
427
+ this.firstObjectIndex = firstObjectIndex;
428
+ this.lastObjectIndex = lastObjectIndex;
429
+ }
222
430
  }
223
431
 
224
- /**
225
- * A section of a beatmap with extra information used for detecting three-finger usage.
226
- */
227
- class ThreeFingerBeatmapSection extends BeatmapSection {
228
- /**
229
- * Whether or not this beatmap section is dragged.
230
- */
231
- isDragged;
232
- /**
233
- * The index of the cursor that is dragging this section.
234
- */
235
- dragFingerIndex;
236
- constructor(values) {
237
- super(values.firstObjectIndex, values.lastObjectIndex);
238
- this.isDragged = values.isDragged;
239
- this.dragFingerIndex = values.dragFingerIndex;
240
- }
432
+ /**
433
+ * A section of a beatmap with extra information used for detecting three-finger usage.
434
+ */
435
+ class ThreeFingerBeatmapSection extends BeatmapSection {
436
+ /**
437
+ * Whether or not this beatmap section is dragged.
438
+ */
439
+ isDragged;
440
+ /**
441
+ * The index of the cursor that is dragging this section.
442
+ */
443
+ dragFingerIndex;
444
+ constructor(values) {
445
+ super(values.firstObjectIndex, values.lastObjectIndex);
446
+ this.isDragged = values.isDragged;
447
+ this.dragFingerIndex = values.dragFingerIndex;
448
+ }
241
449
  }
242
450
 
243
- /**
244
- * Utility to check whether or not a beatmap is three-fingered.
245
- */
246
- class ThreeFingerChecker {
247
- /**
248
- * The difficulty calculator that is being analyzed.
249
- */
250
- calculator;
251
- /**
252
- * The data of the replay.
253
- */
254
- data;
255
- /**
256
- * The strain threshold to start detecting for 3-fingered section.
257
- *
258
- * Increasing this number will result in less sections being flagged.
259
- */
260
- static strainThreshold = 175;
261
- /**
262
- * The distance threshold between cursors to assume that two cursors are
263
- * actually pressed with 1 finger in osu!pixels.
264
- *
265
- * This is used to prevent cases where a player would lift their finger
266
- * too fast to the point where the 4th cursor instance or beyond is recorded
267
- * as 1st, 2nd, or 3rd cursor instance.
268
- */
269
- cursorDistancingDistanceThreshold = 60;
270
- /**
271
- * The threshold for the amount of cursors that are assumed to be pressed
272
- * by a single finger.
273
- */
274
- cursorDistancingCountThreshold = 10;
275
- /**
276
- * The threshold for the time difference of cursors that are assumed to be pressed
277
- * by a single finger, in milliseconds.
278
- */
279
- cursorDistancingTimeThreshold = 1000;
280
- /**
281
- * The amount of notes that has a tap strain exceeding `strainThreshold`.
282
- */
283
- strainNoteCount;
284
- /**
285
- * The ratio threshold between non-3 finger cursors and 3-finger cursors.
286
- *
287
- * Increasing this number will increase detection accuracy, however
288
- * it also increases the chance of falsely flagged plays.
289
- */
290
- threeFingerRatioThreshold = 0.01;
291
- /**
292
- * The maximum delta time allowed between two beatmap sections.
293
- *
294
- * Increasing this number decreases the amount of beatmap sections in general.
295
- *
296
- * Note that this value does not account for the speed multiplier of
297
- * the play, similar to the way replay object data is stored.
298
- */
299
- maxSectionDeltaTime = 2000;
300
- /**
301
- * The minimum object count required to make a beatmap section.
302
- *
303
- * Increasing this number decreases the amount of beatmap sections.
304
- */
305
- minSectionObjectCount = 5;
306
- /**
307
- * The sections of the beatmap that was cut based on `maxSectionDeltaTime` and `minSectionObjectCount`.
308
- */
309
- beatmapSections = [];
310
- /**
311
- * This threshold is used to filter out accidental taps.
312
- *
313
- * Increasing this number makes the filtration more sensitive, however it
314
- * will also increase the chance of 3-fingered plays getting out from
315
- * being flagged.
316
- */
317
- accidentalTapThreshold = 400;
318
- /**
319
- * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
320
- */
321
- hitWindow;
322
- /**
323
- * A reprocessed break points to match right on object time.
324
- *
325
- * This is used to increase detection accuracy since break points do not start right at the
326
- * start of the hitobject before it and do not end right at the first hitobject after it.
327
- */
328
- breakPointAccurateTimes = [];
329
- /**
330
- * A cursor data array that only contains `movementType.DOWN` movement ID occurrences.
331
- */
332
- downCursorInstances = [];
333
- /**
334
- * Nerf factors from all sections that were three-fingered.
335
- */
336
- nerfFactors = [];
337
- /**
338
- * @param calculator The difficulty calculator to analyze.
339
- * @param data The data of the replay.
340
- */
341
- constructor(calculator, data) {
342
- this.calculator = calculator;
343
- this.data = data;
344
- const stats = new osuBase.MapStats({
345
- od: this.calculator.beatmap.difficulty.od,
346
- mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
347
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym) &&
348
- !(m instanceof osuBase.ModPrecise)),
349
- }).calculate();
350
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
351
- const strainNotes = calculator.objects.filter(
352
- //@ts-expect-error: No overloads match, but this is fine.
353
- (v) => v.originalTapStrain >= ThreeFingerChecker.strainThreshold);
354
- this.strainNoteCount = strainNotes.length;
355
- }
356
- /**
357
- * Checks whether a beatmap is eligible to be detected for 3-finger.
358
- */
359
- static isEligibleToDetect(map) {
360
- return map.objects.some((v) => v.originalTapStrain >= this.strainThreshold);
361
- }
362
- /**
363
- * Checks if the given beatmap is 3-fingered and also returns the final penalty.
364
- *
365
- * The beatmap will be separated into sections and each section will be determined
366
- * whether or not it is dragged.
367
- *
368
- * After that, each section will be assigned a nerf factor based on whether or not
369
- * the section is 3-fingered. These nerf factors will be summed up into a final
370
- * nerf factor, taking beatmap difficulty into account.
371
- */
372
- check() {
373
- if (this.strainNoteCount === 0) {
374
- return { is3Finger: false, penalty: 1 };
375
- }
376
- this.getAccurateBreakPoints();
377
- this.filterCursorInstances();
378
- if (this.downCursorInstances.filter((v) => v.occurrences.length > 0)
379
- .length <= 3) {
380
- return { is3Finger: false, penalty: 1 };
381
- }
382
- this.getBeatmapSections();
383
- this.detectDragPlay();
384
- this.getDetailedBeatmapSections();
385
- this.preventAccidentalTaps();
386
- if (this.downCursorInstances.filter((v) => v.occurrences.length > 0)
387
- .length <= 3) {
388
- return { is3Finger: false, penalty: 1 };
389
- }
390
- this.calculateNerfFactors();
391
- const finalPenalty = this.calculateFinalPenalty();
392
- return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
393
- }
394
- /**
395
- * Generates a new set of "accurate break points".
396
- *
397
- * This is done to increase detection accuracy since break points do not start right at the
398
- * start of the hitobject before it and do not end right at the first hitobject after it.
399
- */
400
- getAccurateBreakPoints() {
401
- const objects = this.calculator.objects;
402
- const objectData = this.data.hitObjectData;
403
- const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
404
- for (const breakPoint of this.calculator.beatmap.events.breaks) {
405
- const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.object.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
406
- let timeBefore = objects[beforeIndex].object.endTime;
407
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
408
- let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
409
- switch (objectData[beforeIndex].result) {
410
- case exports.hitResult.RESULT_300:
411
- beforeIndexHitWindowLength =
412
- this.hitWindow.hitWindowFor300(isPrecise);
413
- break;
414
- case exports.hitResult.RESULT_100:
415
- beforeIndexHitWindowLength =
416
- this.hitWindow.hitWindowFor100(isPrecise);
417
- break;
418
- default:
419
- beforeIndexHitWindowLength =
420
- this.hitWindow.hitWindowFor50(isPrecise);
421
- }
422
- timeBefore += beforeIndexHitWindowLength;
423
- const afterIndex = beforeIndex + 1;
424
- let timeAfter = objects[afterIndex].object.startTime;
425
- // For sliders and spinners, automatically set hit window length to be as lenient as possible.
426
- let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
427
- switch (objectData[afterIndex].result) {
428
- case exports.hitResult.RESULT_300:
429
- afterIndexHitWindowLength =
430
- this.hitWindow.hitWindowFor300(isPrecise);
431
- break;
432
- case exports.hitResult.RESULT_100:
433
- afterIndexHitWindowLength =
434
- this.hitWindow.hitWindowFor100(isPrecise);
435
- break;
436
- default:
437
- afterIndexHitWindowLength =
438
- this.hitWindow.hitWindowFor50(isPrecise);
439
- }
440
- timeAfter += afterIndexHitWindowLength;
441
- this.breakPointAccurateTimes.push({
442
- startTime: timeBefore,
443
- endTime: timeAfter,
444
- });
445
- }
446
- }
447
- /**
448
- * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
449
- *
450
- * This also filters cursors that are in break period or happen before start/after end of the beatmap.
451
- */
452
- filterCursorInstances() {
453
- const objects = this.calculator.objects;
454
- const objectData = this.data.hitObjectData;
455
- const firstObjectResult = objectData[0].result;
456
- const lastObjectResult = objectData.at(-1).result;
457
- const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
458
- // For sliders, automatically set hit window length to be as lenient as possible.
459
- let firstObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
460
- if (objects[0].object instanceof osuBase.Circle) {
461
- switch (firstObjectResult) {
462
- case exports.hitResult.RESULT_300:
463
- firstObjectHitWindow =
464
- this.hitWindow.hitWindowFor300(isPrecise);
465
- break;
466
- case exports.hitResult.RESULT_100:
467
- firstObjectHitWindow =
468
- this.hitWindow.hitWindowFor100(isPrecise);
469
- break;
470
- default:
471
- firstObjectHitWindow =
472
- this.hitWindow.hitWindowFor50(isPrecise);
473
- }
474
- }
475
- // For sliders, automatically set hit window length to be as lenient as possible.
476
- let lastObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
477
- if (objects.at(-1).object instanceof osuBase.Circle) {
478
- switch (lastObjectResult) {
479
- case exports.hitResult.RESULT_300:
480
- lastObjectHitWindow =
481
- this.hitWindow.hitWindowFor300(isPrecise);
482
- break;
483
- case exports.hitResult.RESULT_100:
484
- lastObjectHitWindow =
485
- this.hitWindow.hitWindowFor100(isPrecise);
486
- break;
487
- default:
488
- lastObjectHitWindow =
489
- this.hitWindow.hitWindowFor50(isPrecise);
490
- }
491
- }
492
- // These hit time uses hit window length as threshold.
493
- // This is because cursors aren't recorded exactly at hit time,
494
- // probably due to the game's behavior.
495
- const firstObjectHitTime = objects[0].object.startTime - firstObjectHitWindow;
496
- const lastObjectHitTime = objects.at(-1).object.startTime + lastObjectHitWindow;
497
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
498
- const cursorInstance = this.data.cursorMovement[i];
499
- const newCursorData = new CursorData({
500
- size: 0,
501
- time: [],
502
- x: [],
503
- y: [],
504
- id: [],
505
- });
506
- for (let j = 0; j < cursorInstance.occurrences.length; ++j) {
507
- if (cursorInstance.occurrences[j].id !== exports.movementType.DOWN) {
508
- continue;
509
- }
510
- const time = cursorInstance.occurrences[j].time;
511
- if (time < firstObjectHitTime || time > lastObjectHitTime) {
512
- continue;
513
- }
514
- if (this.breakPointAccurateTimes.some((v) => time >= v.startTime && time <= v.endTime)) {
515
- continue;
516
- }
517
- newCursorData.occurrences.push(new CursorOccurrence(time, cursorInstance.occurrences[j].position.x, cursorInstance.occurrences[j].position.y, cursorInstance.occurrences[j].id));
518
- }
519
- this.downCursorInstances.push(newCursorData);
520
- }
521
- }
522
- /**
523
- * Divides the beatmap into sections, which will be used to
524
- * detect dragged sections and improve detection speed.
525
- */
526
- getBeatmapSections() {
527
- const beatmapSections = BeatmapSectionGenerator.generateSections(this.calculator, this.minSectionObjectCount, this.maxSectionDeltaTime);
528
- for (const beatmapSection of beatmapSections) {
529
- this.beatmapSections.push(new ThreeFingerBeatmapSection({
530
- firstObjectIndex: beatmapSection.firstObjectIndex,
531
- lastObjectIndex: beatmapSection.lastObjectIndex,
532
- isDragged: false,
533
- dragFingerIndex: -1,
534
- }));
535
- }
536
- }
537
- /**
538
- * Checks whether or not each beatmap sections is dragged.
539
- */
540
- detectDragPlay() {
541
- for (let i = 0; i < this.beatmapSections.length; ++i) {
542
- const dragIndex = this.checkDrag(this.beatmapSections[i]);
543
- this.beatmapSections[i].dragFingerIndex = dragIndex;
544
- this.beatmapSections[i].isDragged = dragIndex !== -1;
545
- }
546
- }
547
- /**
548
- * Checks if a section is dragged and returns the index of the drag finger.
549
- *
550
- * If the section is not dragged, -1 will be returned.
551
- *
552
- * @param section The section to check.
553
- */
554
- checkDrag(section) {
555
- const objects = this.calculator.objects;
556
- const objectData = this.data.hitObjectData;
557
- const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
558
- const firstObject = objects[section.firstObjectIndex];
559
- const lastObject = objects[section.lastObjectIndex];
560
- let firstObjectMinHitTime = firstObject.object.startTime;
561
- if (firstObject.object instanceof osuBase.Circle) {
562
- switch (objectData[section.firstObjectIndex].result) {
563
- case exports.hitResult.RESULT_300:
564
- firstObjectMinHitTime -=
565
- this.hitWindow.hitWindowFor300(isPrecise);
566
- break;
567
- case exports.hitResult.RESULT_100:
568
- firstObjectMinHitTime -=
569
- this.hitWindow.hitWindowFor100(isPrecise);
570
- break;
571
- default:
572
- firstObjectMinHitTime -=
573
- this.hitWindow.hitWindowFor50(isPrecise);
574
- }
575
- }
576
- else {
577
- firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(isPrecise);
578
- }
579
- let lastObjectMaxHitTime = lastObject.object.startTime;
580
- if (lastObject.object instanceof osuBase.Circle) {
581
- switch (objectData[section.lastObjectIndex].result) {
582
- case exports.hitResult.RESULT_300:
583
- lastObjectMaxHitTime +=
584
- this.hitWindow.hitWindowFor300(isPrecise);
585
- break;
586
- case exports.hitResult.RESULT_100:
587
- lastObjectMaxHitTime +=
588
- this.hitWindow.hitWindowFor100(isPrecise);
589
- break;
590
- default:
591
- lastObjectMaxHitTime +=
592
- this.hitWindow.hitWindowFor50(isPrecise);
593
- }
594
- }
595
- else {
596
- lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(isPrecise);
597
- }
598
- // Since there may be more than 1 cursor instance index,
599
- // we check which cursor instance follows hitobjects all over.
600
- const cursorIndexes = [];
601
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
602
- const c = this.data.cursorMovement[i];
603
- if (c.occurrences.length === 0) {
604
- continue;
605
- }
606
- // Do not include cursors that don't have an occurence in this section
607
- // this speeds up checking process.
608
- if (c.occurrences.filter((v) => v.time >= firstObjectMinHitTime &&
609
- v.time <= lastObjectMaxHitTime).length === 0) {
610
- continue;
611
- }
612
- // If this cursor instance doesn't move, it's not the cursor instance we want.
613
- if (c.occurrences.filter((v) => v.id === exports.movementType.MOVE)
614
- .length === 0) {
615
- continue;
616
- }
617
- cursorIndexes.push(i);
618
- }
619
- return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
620
- }
621
- /**
622
- * Finds the drag index of the section.
623
- *
624
- * @param sectionObjects The objects in the section.
625
- * @param sectionReplayObjectData The hitobject data of all objects in the section.
626
- * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
627
- */
628
- findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
629
- let objectIndex = sectionObjects.findIndex((v, i) => !(v.object instanceof osuBase.Spinner) &&
630
- sectionReplayObjectData[i].result !== exports.hitResult.RESULT_0);
631
- if (objectIndex === -1) {
632
- return -1;
633
- }
634
- while (cursorIndexes.length > 0) {
635
- if (objectIndex === sectionObjects.length) {
636
- break;
637
- }
638
- const o = sectionObjects[objectIndex];
639
- const s = sectionReplayObjectData[objectIndex];
640
- ++objectIndex;
641
- if (s.result === exports.hitResult.RESULT_0) {
642
- continue;
643
- }
644
- // Get the cursor instance that is closest to the object's hit time.
645
- for (let j = 0; j < cursorIndexes.length; ++j) {
646
- const c = this.data.cursorMovement[cursorIndexes[j]];
647
- // Cursor instances aren't always recorded at all times,
648
- // therefore the game emulates the movement between
649
- // movementType.MOVE cursors.
650
- const hitTime = o.object.startTime + s.accuracy;
651
- const nextHitIndex = c.occurrences.findIndex((v) => v.time >= hitTime);
652
- const hitIndex = nextHitIndex - 1;
653
- if (hitIndex <= -1) {
654
- cursorIndexes[j] = -1;
655
- continue;
656
- }
657
- if (c.occurrences[hitIndex].id === exports.movementType.UP) {
658
- cursorIndexes[j] = -1;
659
- continue;
660
- }
661
- const cursorPosition = new osuBase.Vector2(c.occurrences[hitIndex].position.x, c.occurrences[hitIndex].position.y);
662
- let isInObject = false;
663
- if (c.occurrences[nextHitIndex].id === exports.movementType.MOVE ||
664
- c.occurrences[hitIndex].id === exports.movementType.MOVE) {
665
- // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
666
- // This minimizes rounding error.
667
- for (let mSecPassed = c.occurrences[hitIndex].time; mSecPassed <= c.occurrences[nextHitIndex].time; ++mSecPassed) {
668
- const t = (mSecPassed - c.occurrences[nextHitIndex].time) /
669
- (c.occurrences[hitIndex].time -
670
- c.occurrences[nextHitIndex].time);
671
- cursorPosition.x = osuBase.Interpolation.lerp(c.occurrences[hitIndex].position.x, c.occurrences[nextHitIndex].position.x, t);
672
- cursorPosition.y = osuBase.Interpolation.lerp(c.occurrences[hitIndex].position.y, c.occurrences[nextHitIndex].position.y, t);
673
- if (o.object
674
- .getStackedPosition(osuBase.modes.droid)
675
- .getDistance(cursorPosition) <=
676
- o.object.getRadius(osuBase.modes.droid)) {
677
- isInObject = true;
678
- break;
679
- }
680
- }
681
- }
682
- else {
683
- isInObject =
684
- o.object
685
- .getStackedPosition(osuBase.modes.droid)
686
- .getDistance(cursorPosition) <=
687
- o.object.getRadius(osuBase.modes.droid);
688
- }
689
- if (!isInObject) {
690
- cursorIndexes[j] = -1;
691
- }
692
- }
693
- cursorIndexes = cursorIndexes.filter((v) => v !== -1);
694
- }
695
- return cursorIndexes.shift() ?? -1;
696
- }
697
- /**
698
- * Redivides the beatmap into sections.
699
- *
700
- * The result will be used to detect for three-fingered
701
- * sections.
702
- */
703
- getDetailedBeatmapSections() {
704
- const objects = this.calculator.objects;
705
- const newBeatmapSections = [];
706
- for (const beatmapSection of this.beatmapSections) {
707
- let inSpeedSection = false;
708
- let newFirstObjectIndex = beatmapSection.firstObjectIndex;
709
- for (let i = beatmapSection.firstObjectIndex; i <= beatmapSection.lastObjectIndex; ++i) {
710
- if (!inSpeedSection &&
711
- objects[i].originalTapStrain >=
712
- ThreeFingerChecker.strainThreshold) {
713
- inSpeedSection = true;
714
- newFirstObjectIndex = i;
715
- continue;
716
- }
717
- if (inSpeedSection &&
718
- objects[i].originalTapStrain <
719
- ThreeFingerChecker.strainThreshold) {
720
- inSpeedSection = false;
721
- newBeatmapSections.push({
722
- firstObjectIndex: newFirstObjectIndex,
723
- lastObjectIndex: i,
724
- isDragged: beatmapSection.isDragged,
725
- dragFingerIndex: beatmapSection.dragFingerIndex,
726
- });
727
- }
728
- }
729
- // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
730
- if (inSpeedSection) {
731
- newBeatmapSections.push({
732
- firstObjectIndex: newFirstObjectIndex,
733
- lastObjectIndex: beatmapSection.lastObjectIndex,
734
- isDragged: beatmapSection.isDragged,
735
- dragFingerIndex: beatmapSection.dragFingerIndex,
736
- });
737
- }
738
- }
739
- this.beatmapSections.length = 0;
740
- this.beatmapSections.push(...newBeatmapSections);
741
- }
742
- /**
743
- * Attempts to prevent accidental taps from being flagged.
744
- *
745
- * This detection will filter cursors that don't hit
746
- * any object in beatmap sections, thus eliminating any
747
- * unnecessary taps.
748
- */
749
- preventAccidentalTaps() {
750
- let filledCursorAmount = this.downCursorInstances.filter((v) => v.occurrences.length > 0).length;
751
- if (filledCursorAmount <= 3) {
752
- return;
753
- }
754
- const objects = this.calculator.objects;
755
- const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.occurrences.length, 0);
756
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
757
- if (filledCursorAmount <= 3) {
758
- break;
759
- }
760
- const cursorInstance = this.downCursorInstances[i];
761
- // Use an estimation for accidental tap threshold.
762
- if (cursorInstance.occurrences.length <=
763
- Math.ceil(objects.length / this.accidentalTapThreshold) &&
764
- cursorInstance.occurrences.length / totalCursorAmount <
765
- this.threeFingerRatioThreshold * 2) {
766
- --filledCursorAmount;
767
- cursorInstance.occurrences.length = 0;
768
- }
769
- this.downCursorInstances[i] = cursorInstance;
770
- }
771
- }
772
- /**
773
- * Creates nerf factors by scanning through objects.
774
- *
775
- * This check will ignore all objects with speed strain below `strainThreshold`.
776
- */
777
- calculateNerfFactors() {
778
- const objects = this.calculator.objects;
779
- const objectData = this.data.hitObjectData;
780
- const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
781
- // We only filter cursor instances that are above the strain threshold.
782
- // This minimalizes the amount of cursor instances to analyze.
783
- for (const beatmapSection of this.beatmapSections) {
784
- const dragIndex = beatmapSection.dragFingerIndex;
785
- const startTime = objects[beatmapSection.firstObjectIndex].object.startTime +
786
- (objectData[beatmapSection.firstObjectIndex].result !==
787
- exports.hitResult.RESULT_0
788
- ? objectData[beatmapSection.firstObjectIndex].accuracy
789
- : -this.hitWindow.hitWindowFor50(isPrecise));
790
- const endTime = objects[beatmapSection.lastObjectIndex].object.endTime +
791
- (objectData[beatmapSection.lastObjectIndex].result !==
792
- exports.hitResult.RESULT_0
793
- ? objectData[beatmapSection.lastObjectIndex].accuracy
794
- : this.hitWindow.hitWindowFor50(isPrecise));
795
- // Filter cursor instances during section.
796
- this.downCursorInstances.forEach((c) => {
797
- const i = c.occurrences.findIndex((t) => t.time >= startTime);
798
- if (i !== -1) {
799
- c.occurrences = c.occurrences.slice(i);
800
- }
801
- });
802
- const cursorAmounts = [];
803
- const cursorVectorTimes = [];
804
- for (let i = 0; i < this.downCursorInstances.length; ++i) {
805
- // Do not include drag cursor instance.
806
- if (i === dragIndex) {
807
- continue;
808
- }
809
- const cursorData = this.downCursorInstances[i];
810
- let amount = 0;
811
- for (let j = 0; j < cursorData.occurrences.length; ++j) {
812
- if (cursorData.occurrences[j].time >= startTime &&
813
- cursorData.occurrences[j].time <= endTime) {
814
- ++amount;
815
- cursorVectorTimes.push({
816
- vector: new osuBase.Vector2(cursorData.occurrences[j].position.x, cursorData.occurrences[j].position.y),
817
- time: cursorData.occurrences[j].time,
818
- });
819
- }
820
- }
821
- cursorAmounts.push(amount);
822
- }
823
- // This index will be used to detect if a section is 3-fingered.
824
- // If the section is dragged, the dragged instance will be ignored,
825
- // hence why the index is 1 less than nondragged section.
826
- const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
827
- // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
828
- // to check if the section is 3-fingered.
829
- const threeFingerRatio = cursorAmounts
830
- .slice(fingerSplitIndex)
831
- .reduce((acc, value) => acc + value, 0) /
832
- cursorAmounts
833
- .slice(0, fingerSplitIndex)
834
- .reduce((acc, value) => acc + value, 0);
835
- const similarPresses = [];
836
- for (const cursorVectorTime of cursorVectorTimes) {
837
- const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
838
- this.cursorDistancingDistanceThreshold);
839
- if (pressIndex !== -1) {
840
- if (cursorVectorTime.time -
841
- similarPresses[pressIndex].lastTime >=
842
- this.cursorDistancingTimeThreshold) {
843
- similarPresses.splice(pressIndex, 1);
844
- similarPresses.push({
845
- vector: cursorVectorTime.vector,
846
- count: 1,
847
- lastTime: cursorVectorTime.time,
848
- });
849
- continue;
850
- }
851
- similarPresses[pressIndex].vector = cursorVectorTime.vector;
852
- similarPresses[pressIndex].lastTime = cursorVectorTime.time;
853
- ++similarPresses[pressIndex].count;
854
- }
855
- else {
856
- similarPresses.push({
857
- vector: cursorVectorTime.vector,
858
- count: 1,
859
- lastTime: cursorVectorTime.time,
860
- });
861
- }
862
- }
863
- // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
864
- const validPresses = similarPresses
865
- .filter((v) => v.count >= this.cursorDistancingCountThreshold)
866
- .sort((a, b) => b.count - a.count)
867
- .slice(2);
868
- // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
869
- if ((threeFingerRatio > this.threeFingerRatioThreshold &&
870
- cursorAmounts.filter((v) => v > 1).length > 3) ||
871
- validPresses.length > 0) {
872
- // Strain factor
873
- const objectCount = beatmapSection.lastObjectIndex -
874
- beatmapSection.firstObjectIndex +
875
- 1;
876
- const strainFactor = Math.pow(objects
877
- .slice(beatmapSection.firstObjectIndex, beatmapSection.lastObjectIndex)
878
- .reduce((acc, value) => acc +
879
- value.originalTapStrain /
880
- ThreeFingerChecker.strainThreshold, 0), 0.75);
881
- // We can ignore the first 3 (2 for drag) filled cursor instances
882
- // since they are guaranteed not 3 finger.
883
- const threeFingerCursorAmounts = cursorAmounts
884
- .slice(fingerSplitIndex)
885
- .filter((amount) => amount > 0);
886
- // Finger factor applies more penalty if more fingers were used.
887
- const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
888
- ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
889
- Math.pow(((index + 1) * value * objectCount) /
890
- this.strainNoteCount, 0.9), 1)
891
- : Math.pow(validPresses.reduce((acc, value, index) => acc +
892
- Math.pow(((index + 1) *
893
- (value.count /
894
- (this
895
- .cursorDistancingCountThreshold *
896
- 2)) *
897
- objectCount) /
898
- this.strainNoteCount, 0.2), 1), 0.2);
899
- // Length factor applies more penalty if there are more 3-fingered object.
900
- const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
901
- this.nerfFactors.push({
902
- strainFactor: Math.max(1, strainFactor),
903
- fingerFactor,
904
- lengthFactor,
905
- });
906
- }
907
- }
908
- }
909
- /**
910
- * Calculates the final penalty.
911
- */
912
- calculateFinalPenalty() {
913
- return (1 +
914
- this.nerfFactors.reduce((a, n) => a +
915
- 0.015 *
916
- Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
917
- }
451
+ /**
452
+ * Utility to check whether or not a beatmap is three-fingered.
453
+ */
454
+ class ThreeFingerChecker {
455
+ /**
456
+ * The difficulty calculator that is being analyzed.
457
+ */
458
+ calculator;
459
+ /**
460
+ * The data of the replay.
461
+ */
462
+ data;
463
+ /**
464
+ * The strain threshold to start detecting for 3-fingered section.
465
+ *
466
+ * Increasing this number will result in less sections being flagged.
467
+ */
468
+ static strainThreshold = 175;
469
+ /**
470
+ * The distance threshold between cursors to assume that two cursors are
471
+ * actually pressed with 1 finger in osu!pixels.
472
+ *
473
+ * This is used to prevent cases where a player would lift their finger
474
+ * too fast to the point where the 4th cursor instance or beyond is recorded
475
+ * as 1st, 2nd, or 3rd cursor instance.
476
+ */
477
+ cursorDistancingDistanceThreshold = 60;
478
+ /**
479
+ * The threshold for the amount of cursors that are assumed to be pressed
480
+ * by a single finger.
481
+ */
482
+ cursorDistancingCountThreshold = 10;
483
+ /**
484
+ * The threshold for the time difference of cursors that are assumed to be pressed
485
+ * by a single finger, in milliseconds.
486
+ */
487
+ cursorDistancingTimeThreshold = 1000;
488
+ /**
489
+ * The amount of notes that has a tap strain exceeding `strainThreshold`.
490
+ */
491
+ strainNoteCount;
492
+ /**
493
+ * The ratio threshold between non-3 finger cursors and 3-finger cursors.
494
+ *
495
+ * Increasing this number will increase detection accuracy, however
496
+ * it also increases the chance of falsely flagged plays.
497
+ */
498
+ threeFingerRatioThreshold = 0.01;
499
+ /**
500
+ * The maximum delta time allowed between two beatmap sections.
501
+ *
502
+ * Increasing this number decreases the amount of beatmap sections in general.
503
+ *
504
+ * Note that this value does not account for the speed multiplier of
505
+ * the play, similar to the way replay object data is stored.
506
+ */
507
+ maxSectionDeltaTime = 2000;
508
+ /**
509
+ * The minimum object count required to make a beatmap section.
510
+ *
511
+ * Increasing this number decreases the amount of beatmap sections.
512
+ */
513
+ minSectionObjectCount = 5;
514
+ /**
515
+ * The sections of the beatmap that was cut based on `maxSectionDeltaTime` and `minSectionObjectCount`.
516
+ */
517
+ beatmapSections = [];
518
+ /**
519
+ * This threshold is used to filter out accidental taps.
520
+ *
521
+ * Increasing this number makes the filtration more sensitive, however it
522
+ * will also increase the chance of 3-fingered plays getting out from
523
+ * being flagged.
524
+ */
525
+ accidentalTapThreshold = 400;
526
+ /**
527
+ * The hit window of this beatmap. Keep in mind that speed-changing mods do not change hit window length in game logic.
528
+ */
529
+ hitWindow;
530
+ /**
531
+ * A reprocessed break points to match right on object time.
532
+ *
533
+ * This is used to increase detection accuracy since break points do not start right at the
534
+ * start of the hitobject before it and do not end right at the first hitobject after it.
535
+ */
536
+ breakPointAccurateTimes = [];
537
+ /**
538
+ * A cursor occurrence nested array that only contains `movementType.DOWN` movement ID occurrences.
539
+ *
540
+ * Each index represents the cursor index.
541
+ */
542
+ downCursorInstances = [];
543
+ /**
544
+ * All cursor occurrences in the replay.
545
+ *
546
+ * Each index represents the cursor index.
547
+ */
548
+ allCursorInstances;
549
+ /**
550
+ * Nerf factors from all sections that were three-fingered.
551
+ */
552
+ nerfFactors = [];
553
+ /**
554
+ * @param calculator The difficulty calculator to analyze.
555
+ * @param data The data of the replay.
556
+ */
557
+ constructor(calculator, data) {
558
+ this.calculator = calculator;
559
+ this.data = data;
560
+ this.allCursorInstances = data.cursorMovement.map((v) => v.allOccurrences);
561
+ const stats = new osuBase.MapStats({
562
+ od: this.calculator.beatmap.difficulty.od,
563
+ mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
564
+ !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym) &&
565
+ !(m instanceof osuBase.ModPrecise)),
566
+ }).calculate();
567
+ this.hitWindow = new osuBase.DroidHitWindow(stats.od);
568
+ const strainNotes = calculator.objects.filter(
569
+ //@ts-expect-error: No overloads match, but this is fine.
570
+ (v) => v.originalTapStrain >= ThreeFingerChecker.strainThreshold);
571
+ this.strainNoteCount = strainNotes.length;
572
+ }
573
+ /**
574
+ * Checks whether a beatmap is eligible to be detected for 3-finger.
575
+ */
576
+ static isEligibleToDetect(map) {
577
+ return map.objects.some((v) => v.originalTapStrain >= this.strainThreshold);
578
+ }
579
+ /**
580
+ * Checks if the given beatmap is 3-fingered and also returns the final penalty.
581
+ *
582
+ * The beatmap will be separated into sections and each section will be determined
583
+ * whether or not it is dragged.
584
+ *
585
+ * After that, each section will be assigned a nerf factor based on whether or not
586
+ * the section is 3-fingered. These nerf factors will be summed up into a final
587
+ * nerf factor, taking beatmap difficulty into account.
588
+ */
589
+ check() {
590
+ if (this.strainNoteCount === 0) {
591
+ return { is3Finger: false, penalty: 1 };
592
+ }
593
+ this.getAccurateBreakPoints();
594
+ this.filterCursorInstances();
595
+ if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
596
+ return { is3Finger: false, penalty: 1 };
597
+ }
598
+ this.getBeatmapSections();
599
+ this.detectDragPlay();
600
+ this.getDetailedBeatmapSections();
601
+ this.preventAccidentalTaps();
602
+ if (this.downCursorInstances.filter((v) => v.length > 0).length <= 3) {
603
+ return { is3Finger: false, penalty: 1 };
604
+ }
605
+ this.calculateNerfFactors();
606
+ const finalPenalty = this.calculateFinalPenalty();
607
+ return { is3Finger: finalPenalty > 1, penalty: finalPenalty };
608
+ }
609
+ /**
610
+ * Generates a new set of "accurate break points".
611
+ *
612
+ * This is done to increase detection accuracy since break points do not start right at the
613
+ * start of the hitobject before it and do not end right at the first hitobject after it.
614
+ */
615
+ getAccurateBreakPoints() {
616
+ const objects = this.calculator.objects;
617
+ const objectData = this.data.hitObjectData;
618
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
619
+ for (const breakPoint of this.calculator.beatmap.events.breaks) {
620
+ const beforeIndex = osuBase.MathUtils.clamp(objects.findIndex((o) => o.object.endTime >= breakPoint.startTime) - 1, 0, objects.length - 2);
621
+ let timeBefore = objects[beforeIndex].object.endTime;
622
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
623
+ let beforeIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
624
+ switch (objectData[beforeIndex].result) {
625
+ case exports.HitResult.great:
626
+ beforeIndexHitWindowLength =
627
+ this.hitWindow.hitWindowFor300(isPrecise);
628
+ break;
629
+ case exports.HitResult.good:
630
+ beforeIndexHitWindowLength =
631
+ this.hitWindow.hitWindowFor100(isPrecise);
632
+ break;
633
+ default:
634
+ beforeIndexHitWindowLength =
635
+ this.hitWindow.hitWindowFor50(isPrecise);
636
+ }
637
+ timeBefore += beforeIndexHitWindowLength;
638
+ const afterIndex = beforeIndex + 1;
639
+ let timeAfter = objects[afterIndex].object.startTime;
640
+ // For sliders and spinners, automatically set hit window length to be as lenient as possible.
641
+ let afterIndexHitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
642
+ switch (objectData[afterIndex].result) {
643
+ case exports.HitResult.great:
644
+ afterIndexHitWindowLength =
645
+ this.hitWindow.hitWindowFor300(isPrecise);
646
+ break;
647
+ case exports.HitResult.good:
648
+ afterIndexHitWindowLength =
649
+ this.hitWindow.hitWindowFor100(isPrecise);
650
+ break;
651
+ default:
652
+ afterIndexHitWindowLength =
653
+ this.hitWindow.hitWindowFor50(isPrecise);
654
+ }
655
+ timeAfter += afterIndexHitWindowLength;
656
+ this.breakPointAccurateTimes.push({
657
+ startTime: timeBefore,
658
+ endTime: timeAfter,
659
+ });
660
+ }
661
+ }
662
+ /**
663
+ * Filters the original cursor instances, returning only those with `movementType.DOWN` movement ID.
664
+ *
665
+ * This also filters cursors that are in break period or happen before start/after end of the beatmap.
666
+ */
667
+ filterCursorInstances() {
668
+ const objects = this.calculator.objects;
669
+ const objectData = this.data.hitObjectData;
670
+ const firstObjectResult = objectData[0].result;
671
+ const lastObjectResult = objectData.at(-1).result;
672
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
673
+ // For sliders, automatically set hit window length to be as lenient as possible.
674
+ let firstObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
675
+ if (objects[0].object instanceof osuBase.Circle) {
676
+ switch (firstObjectResult) {
677
+ case exports.HitResult.great:
678
+ firstObjectHitWindow =
679
+ this.hitWindow.hitWindowFor300(isPrecise);
680
+ break;
681
+ case exports.HitResult.good:
682
+ firstObjectHitWindow =
683
+ this.hitWindow.hitWindowFor100(isPrecise);
684
+ break;
685
+ default:
686
+ firstObjectHitWindow =
687
+ this.hitWindow.hitWindowFor50(isPrecise);
688
+ }
689
+ }
690
+ // For sliders, automatically set hit window length to be as lenient as possible.
691
+ let lastObjectHitWindow = this.hitWindow.hitWindowFor50(isPrecise);
692
+ if (objects.at(-1).object instanceof osuBase.Circle) {
693
+ switch (lastObjectResult) {
694
+ case exports.HitResult.great:
695
+ lastObjectHitWindow =
696
+ this.hitWindow.hitWindowFor300(isPrecise);
697
+ break;
698
+ case exports.HitResult.good:
699
+ lastObjectHitWindow =
700
+ this.hitWindow.hitWindowFor100(isPrecise);
701
+ break;
702
+ default:
703
+ lastObjectHitWindow =
704
+ this.hitWindow.hitWindowFor50(isPrecise);
705
+ }
706
+ }
707
+ // These hit time uses hit window length as threshold.
708
+ // This is because cursors aren't recorded exactly at hit time,
709
+ // probably due to the game's behavior.
710
+ const firstObjectHitTime = objects[0].object.startTime - firstObjectHitWindow;
711
+ const lastObjectHitTime = objects.at(-1).object.startTime + lastObjectHitWindow;
712
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
713
+ const cursorInstance = this.data.cursorMovement[i];
714
+ const validOccurrences = [];
715
+ for (let j = 0; j < cursorInstance.occurrenceGroups.length; ++j) {
716
+ const group = cursorInstance.occurrenceGroups[j];
717
+ if (group.startTime < firstObjectHitTime ||
718
+ group.endTime > lastObjectHitTime) {
719
+ continue;
720
+ }
721
+ if (this.breakPointAccurateTimes.some((v) => group.startTime >= v.startTime &&
722
+ group.endTime <= v.endTime)) {
723
+ continue;
724
+ }
725
+ validOccurrences.push(group.down);
726
+ }
727
+ this.downCursorInstances.push(validOccurrences);
728
+ }
729
+ }
730
+ /**
731
+ * Divides the beatmap into sections, which will be used to
732
+ * detect dragged sections and improve detection speed.
733
+ */
734
+ getBeatmapSections() {
735
+ const beatmapSections = BeatmapSectionGenerator.generateSections(this.calculator, this.minSectionObjectCount, this.maxSectionDeltaTime);
736
+ for (const beatmapSection of beatmapSections) {
737
+ this.beatmapSections.push(new ThreeFingerBeatmapSection({
738
+ firstObjectIndex: beatmapSection.firstObjectIndex,
739
+ lastObjectIndex: beatmapSection.lastObjectIndex,
740
+ isDragged: false,
741
+ dragFingerIndex: -1,
742
+ }));
743
+ }
744
+ }
745
+ /**
746
+ * Checks whether or not each beatmap sections is dragged.
747
+ */
748
+ detectDragPlay() {
749
+ for (let i = 0; i < this.beatmapSections.length; ++i) {
750
+ const dragIndex = this.checkDrag(this.beatmapSections[i]);
751
+ this.beatmapSections[i].dragFingerIndex = dragIndex;
752
+ this.beatmapSections[i].isDragged = dragIndex !== -1;
753
+ }
754
+ }
755
+ /**
756
+ * Checks if a section is dragged and returns the index of the drag finger.
757
+ *
758
+ * If the section is not dragged, -1 will be returned.
759
+ *
760
+ * @param section The section to check.
761
+ */
762
+ checkDrag(section) {
763
+ const objects = this.calculator.objects;
764
+ const objectData = this.data.hitObjectData;
765
+ const isPrecise = this.calculator.mods.some((m) => m instanceof osuBase.ModPrecise);
766
+ const firstObject = objects[section.firstObjectIndex];
767
+ const lastObject = objects[section.lastObjectIndex];
768
+ let firstObjectMinHitTime = firstObject.object.startTime;
769
+ if (firstObject.object instanceof osuBase.Circle) {
770
+ switch (objectData[section.firstObjectIndex].result) {
771
+ case exports.HitResult.great:
772
+ firstObjectMinHitTime -=
773
+ this.hitWindow.hitWindowFor300(isPrecise);
774
+ break;
775
+ case exports.HitResult.good:
776
+ firstObjectMinHitTime -=
777
+ this.hitWindow.hitWindowFor100(isPrecise);
778
+ break;
779
+ default:
780
+ firstObjectMinHitTime -=
781
+ this.hitWindow.hitWindowFor50(isPrecise);
782
+ }
783
+ }
784
+ else {
785
+ firstObjectMinHitTime -= this.hitWindow.hitWindowFor50(isPrecise);
786
+ }
787
+ let lastObjectMaxHitTime = lastObject.object.startTime;
788
+ if (lastObject.object instanceof osuBase.Circle) {
789
+ switch (objectData[section.lastObjectIndex].result) {
790
+ case exports.HitResult.great:
791
+ lastObjectMaxHitTime +=
792
+ this.hitWindow.hitWindowFor300(isPrecise);
793
+ break;
794
+ case exports.HitResult.good:
795
+ lastObjectMaxHitTime +=
796
+ this.hitWindow.hitWindowFor100(isPrecise);
797
+ break;
798
+ default:
799
+ lastObjectMaxHitTime +=
800
+ this.hitWindow.hitWindowFor50(isPrecise);
801
+ }
802
+ }
803
+ else {
804
+ lastObjectMaxHitTime += this.hitWindow.hitWindowFor50(isPrecise);
805
+ }
806
+ // Since there may be more than 1 cursor instance index,
807
+ // we check which cursor instance follows hitobjects all over.
808
+ const cursorIndexes = [];
809
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
810
+ const c = this.data.cursorMovement[i];
811
+ if (c.occurrenceGroups.length === 0) {
812
+ continue;
813
+ }
814
+ // Do not include cursors that don't have an occurence in this section
815
+ // this speeds up checking process.
816
+ if (c.occurrenceGroups.filter((v) => v.startTime >= firstObjectMinHitTime &&
817
+ v.endTime <= lastObjectMaxHitTime).length === 0) {
818
+ continue;
819
+ }
820
+ // If this cursor instance doesn't move, it's not the cursor instance we want.
821
+ if (c.occurrenceGroups.filter((v) => v.moves.length > 0).length ===
822
+ 0) {
823
+ continue;
824
+ }
825
+ cursorIndexes.push(i);
826
+ }
827
+ return this.findDragIndex(objects.slice(section.firstObjectIndex, section.lastObjectIndex + 1), objectData.slice(section.firstObjectIndex, section.lastObjectIndex + 1), cursorIndexes);
828
+ }
829
+ /**
830
+ * Finds the drag index of the section.
831
+ *
832
+ * @param sectionObjects The objects in the section.
833
+ * @param sectionReplayObjectData The hitobject data of all objects in the section.
834
+ * @param cursorIndexes The indexes of the cursor instance that has at least an occurrence in the section.
835
+ */
836
+ findDragIndex(sectionObjects, sectionReplayObjectData, cursorIndexes) {
837
+ let objectIndex = sectionObjects.findIndex((v, i) => !(v.object instanceof osuBase.Spinner) &&
838
+ sectionReplayObjectData[i].result !== exports.HitResult.miss);
839
+ if (objectIndex === -1) {
840
+ return -1;
841
+ }
842
+ while (cursorIndexes.length > 0) {
843
+ if (objectIndex === sectionObjects.length) {
844
+ break;
845
+ }
846
+ const o = sectionObjects[objectIndex];
847
+ const s = sectionReplayObjectData[objectIndex];
848
+ ++objectIndex;
849
+ if (s.result === exports.HitResult.miss) {
850
+ continue;
851
+ }
852
+ // Get the cursor instance that is closest to the object's hit time.
853
+ for (let j = 0; j < cursorIndexes.length; ++j) {
854
+ const c = this.data.cursorMovement[cursorIndexes[j]];
855
+ // Cursor instances aren't always recorded at all times,
856
+ // therefore the game emulates the movement between
857
+ // movementType.MOVE cursors.
858
+ const hitTime = o.object.startTime + s.accuracy;
859
+ const cursorGroup = c.occurrenceGroups.find((v) => v.isActiveAt(hitTime));
860
+ if (!cursorGroup) {
861
+ continue;
862
+ }
863
+ const cursors = cursorGroup.allOccurrences;
864
+ const nextHitIndex = cursors.findIndex((v) => v.time >= hitTime);
865
+ const hitIndex = nextHitIndex - 1;
866
+ if (hitIndex <= -1) {
867
+ cursorIndexes[j] = -1;
868
+ continue;
869
+ }
870
+ const cursorPosition = new osuBase.Vector2(cursors[hitIndex].position.x, cursors[hitIndex].position.y);
871
+ let isInObject = false;
872
+ if (cursors[nextHitIndex].id === exports.MovementType.move) {
873
+ // Try to interpolate movement between two movementType.MOVE cursor every 1ms.
874
+ // This minimizes rounding error.
875
+ for (let mSecPassed = cursors[hitIndex].time; mSecPassed <= cursors[nextHitIndex].time; ++mSecPassed) {
876
+ const t = (mSecPassed - cursors[nextHitIndex].time) /
877
+ (cursors[hitIndex].time -
878
+ cursors[nextHitIndex].time);
879
+ cursorPosition.x = osuBase.Interpolation.lerp(cursors[hitIndex].position.x, cursors[nextHitIndex].position.x, t);
880
+ cursorPosition.y = osuBase.Interpolation.lerp(cursors[hitIndex].position.y, cursors[nextHitIndex].position.y, t);
881
+ if (o.object
882
+ .getStackedPosition(osuBase.Modes.droid)
883
+ .getDistance(cursorPosition) <=
884
+ o.object.getRadius(osuBase.Modes.droid)) {
885
+ isInObject = true;
886
+ break;
887
+ }
888
+ }
889
+ }
890
+ else {
891
+ isInObject =
892
+ o.object
893
+ .getStackedPosition(osuBase.Modes.droid)
894
+ .getDistance(cursorPosition) <=
895
+ o.object.getRadius(osuBase.Modes.droid);
896
+ }
897
+ if (!isInObject) {
898
+ cursorIndexes[j] = -1;
899
+ }
900
+ }
901
+ cursorIndexes = cursorIndexes.filter((v) => v !== -1);
902
+ }
903
+ return cursorIndexes.shift() ?? -1;
904
+ }
905
+ /**
906
+ * Redivides the beatmap into sections.
907
+ *
908
+ * The result will be used to detect for three-fingered
909
+ * sections.
910
+ */
911
+ getDetailedBeatmapSections() {
912
+ const objects = this.calculator.objects;
913
+ const newBeatmapSections = [];
914
+ for (const beatmapSection of this.beatmapSections) {
915
+ let inSpeedSection = false;
916
+ let newFirstObjectIndex = beatmapSection.firstObjectIndex;
917
+ for (let i = beatmapSection.firstObjectIndex; i <= beatmapSection.lastObjectIndex; ++i) {
918
+ if (!inSpeedSection &&
919
+ objects[i].originalTapStrain >=
920
+ ThreeFingerChecker.strainThreshold) {
921
+ inSpeedSection = true;
922
+ newFirstObjectIndex = i;
923
+ continue;
924
+ }
925
+ if (inSpeedSection &&
926
+ objects[i].originalTapStrain <
927
+ ThreeFingerChecker.strainThreshold) {
928
+ inSpeedSection = false;
929
+ newBeatmapSections.push({
930
+ firstObjectIndex: newFirstObjectIndex,
931
+ lastObjectIndex: i,
932
+ isDragged: beatmapSection.isDragged,
933
+ dragFingerIndex: beatmapSection.dragFingerIndex,
934
+ });
935
+ }
936
+ }
937
+ // Don't forget to manually add the last beatmap section, which would otherwise be ignored.
938
+ if (inSpeedSection) {
939
+ newBeatmapSections.push({
940
+ firstObjectIndex: newFirstObjectIndex,
941
+ lastObjectIndex: beatmapSection.lastObjectIndex,
942
+ isDragged: beatmapSection.isDragged,
943
+ dragFingerIndex: beatmapSection.dragFingerIndex,
944
+ });
945
+ }
946
+ }
947
+ this.beatmapSections.length = 0;
948
+ this.beatmapSections.push(...newBeatmapSections);
949
+ }
950
+ /**
951
+ * Attempts to prevent accidental taps from being flagged.
952
+ *
953
+ * This detection will filter cursors that don't hit
954
+ * any object in beatmap sections, thus eliminating any
955
+ * unnecessary taps.
956
+ */
957
+ preventAccidentalTaps() {
958
+ let filledCursorAmount = this.downCursorInstances.filter((v) => v.length > 0).length;
959
+ if (filledCursorAmount <= 3) {
960
+ return;
961
+ }
962
+ const objects = this.calculator.objects;
963
+ const totalCursorAmount = this.downCursorInstances.reduce((acc, value) => acc + value.length, 0);
964
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
965
+ if (filledCursorAmount <= 3) {
966
+ break;
967
+ }
968
+ const cursorInstances = this.downCursorInstances[i];
969
+ // Use an estimation for accidental tap threshold.
970
+ if (cursorInstances.length <=
971
+ Math.ceil(objects.length / this.accidentalTapThreshold) &&
972
+ cursorInstances.length / totalCursorAmount <
973
+ this.threeFingerRatioThreshold * 2) {
974
+ --filledCursorAmount;
975
+ cursorInstances.length = 0;
976
+ }
977
+ this.downCursorInstances[i] = cursorInstances;
978
+ }
979
+ }
980
+ /**
981
+ * Creates nerf factors by scanning through objects.
982
+ *
983
+ * This check will ignore all objects with speed strain below `strainThreshold`.
984
+ */
985
+ calculateNerfFactors() {
986
+ const objects = this.calculator.objects;
987
+ const objectData = this.data.hitObjectData;
988
+ const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
989
+ // We only filter cursor instances that are above the strain threshold.
990
+ // This minimalizes the amount of cursor instances to analyze.
991
+ for (const beatmapSection of this.beatmapSections) {
992
+ const dragIndex = beatmapSection.dragFingerIndex;
993
+ const startTime = objects[beatmapSection.firstObjectIndex].object.startTime +
994
+ (objectData[beatmapSection.firstObjectIndex].result !==
995
+ exports.HitResult.miss
996
+ ? objectData[beatmapSection.firstObjectIndex].accuracy
997
+ : -this.hitWindow.hitWindowFor50(isPrecise));
998
+ const endTime = objects[beatmapSection.lastObjectIndex].object.endTime +
999
+ (objectData[beatmapSection.lastObjectIndex].result !==
1000
+ exports.HitResult.miss
1001
+ ? objectData[beatmapSection.lastObjectIndex].accuracy
1002
+ : this.hitWindow.hitWindowFor50(isPrecise));
1003
+ // Filter cursor instances during section.
1004
+ this.downCursorInstances.forEach((c) => {
1005
+ const i = c.findIndex((t) => t.time >= startTime);
1006
+ if (i !== -1) {
1007
+ c = c.slice(i);
1008
+ }
1009
+ });
1010
+ const cursorAmounts = [];
1011
+ const cursorVectorTimes = [];
1012
+ for (let i = 0; i < this.downCursorInstances.length; ++i) {
1013
+ // Do not include drag cursor instance.
1014
+ if (i === dragIndex) {
1015
+ continue;
1016
+ }
1017
+ const cursors = this.downCursorInstances[i];
1018
+ let amount = 0;
1019
+ for (let j = 0; j < cursors.length; ++j) {
1020
+ if (cursors[j].time >= startTime &&
1021
+ cursors[j].time <= endTime) {
1022
+ ++amount;
1023
+ cursorVectorTimes.push({
1024
+ vector: new osuBase.Vector2(cursors[j].position.x, cursors[j].position.y),
1025
+ time: cursors[j].time,
1026
+ });
1027
+ }
1028
+ }
1029
+ cursorAmounts.push(amount);
1030
+ }
1031
+ // This index will be used to detect if a section is 3-fingered.
1032
+ // If the section is dragged, the dragged instance will be ignored,
1033
+ // hence why the index is 1 less than nondragged section.
1034
+ const fingerSplitIndex = dragIndex !== -1 ? 2 : 3;
1035
+ // Divide >=4th (3rd for drag) cursor instances with 1st + 2nd (+ 3rd for nondrag)
1036
+ // to check if the section is 3-fingered.
1037
+ const threeFingerRatio = cursorAmounts
1038
+ .slice(fingerSplitIndex)
1039
+ .reduce((acc, value) => acc + value, 0) /
1040
+ cursorAmounts
1041
+ .slice(0, fingerSplitIndex)
1042
+ .reduce((acc, value) => acc + value, 0);
1043
+ const similarPresses = [];
1044
+ for (const cursorVectorTime of cursorVectorTimes) {
1045
+ const pressIndex = similarPresses.findIndex((v) => v.vector.getDistance(cursorVectorTime.vector) <=
1046
+ this.cursorDistancingDistanceThreshold);
1047
+ if (pressIndex !== -1) {
1048
+ if (cursorVectorTime.time -
1049
+ similarPresses[pressIndex].lastTime >=
1050
+ this.cursorDistancingTimeThreshold) {
1051
+ similarPresses.splice(pressIndex, 1);
1052
+ similarPresses.push({
1053
+ vector: cursorVectorTime.vector,
1054
+ count: 1,
1055
+ lastTime: cursorVectorTime.time,
1056
+ });
1057
+ continue;
1058
+ }
1059
+ similarPresses[pressIndex].vector = cursorVectorTime.vector;
1060
+ similarPresses[pressIndex].lastTime = cursorVectorTime.time;
1061
+ ++similarPresses[pressIndex].count;
1062
+ }
1063
+ else {
1064
+ similarPresses.push({
1065
+ vector: cursorVectorTime.vector,
1066
+ count: 1,
1067
+ lastTime: cursorVectorTime.time,
1068
+ });
1069
+ }
1070
+ }
1071
+ // Sort by highest count; assume the order is 3rd, 4th, 5th, ... finger
1072
+ const validPresses = similarPresses
1073
+ .filter((v) => v.count >= this.cursorDistancingCountThreshold)
1074
+ .sort((a, b) => b.count - a.count)
1075
+ .slice(2);
1076
+ // Ignore cursor presses that are only 1 for now since they are very likely to be accidental
1077
+ if ((threeFingerRatio > this.threeFingerRatioThreshold &&
1078
+ cursorAmounts.filter((v) => v > 1).length > 3) ||
1079
+ validPresses.length > 0) {
1080
+ // Strain factor
1081
+ const objectCount = beatmapSection.lastObjectIndex -
1082
+ beatmapSection.firstObjectIndex +
1083
+ 1;
1084
+ const strainFactor = Math.pow(objects
1085
+ .slice(beatmapSection.firstObjectIndex, beatmapSection.lastObjectIndex)
1086
+ .reduce((acc, value) => acc +
1087
+ value.originalTapStrain /
1088
+ ThreeFingerChecker.strainThreshold, 0), 0.75);
1089
+ // We can ignore the first 3 (2 for drag) filled cursor instances
1090
+ // since they are guaranteed not 3 finger.
1091
+ const threeFingerCursorAmounts = cursorAmounts
1092
+ .slice(fingerSplitIndex)
1093
+ .filter((amount) => amount > 0);
1094
+ // Finger factor applies more penalty if more fingers were used.
1095
+ const fingerFactor = threeFingerRatio > this.threeFingerRatioThreshold
1096
+ ? threeFingerCursorAmounts.reduce((acc, value, index) => acc +
1097
+ Math.pow(((index + 1) * value * objectCount) /
1098
+ this.strainNoteCount, 0.9), 1)
1099
+ : Math.pow(validPresses.reduce((acc, value, index) => acc +
1100
+ Math.pow(((index + 1) *
1101
+ (value.count /
1102
+ (this
1103
+ .cursorDistancingCountThreshold *
1104
+ 2)) *
1105
+ objectCount) /
1106
+ this.strainNoteCount, 0.2), 1), 0.2);
1107
+ // Length factor applies more penalty if there are more 3-fingered object.
1108
+ const lengthFactor = 1 + Math.pow(objectCount / this.strainNoteCount, 1.2);
1109
+ this.nerfFactors.push({
1110
+ strainFactor: Math.max(1, strainFactor),
1111
+ fingerFactor,
1112
+ lengthFactor,
1113
+ });
1114
+ }
1115
+ }
1116
+ }
1117
+ /**
1118
+ * Calculates the final penalty.
1119
+ */
1120
+ calculateFinalPenalty() {
1121
+ return (1 +
1122
+ this.nerfFactors.reduce((a, n) => a +
1123
+ 0.015 *
1124
+ Math.pow(n.strainFactor * n.fingerFactor * n.lengthFactor, 1.05), 0));
1125
+ }
918
1126
  }
919
1127
 
920
- /**
921
- * Contains information about which cursor index hits a hitobject.
922
- */
923
- class IndexedHitObject {
924
- /**
925
- * The accepted index of the cursor that hits the hitobject.
926
- */
927
- acceptedCursorIndex;
928
- /**
929
- * The actual index of the cursor that hits the hitobject.
930
- */
931
- actualCursorIndex;
932
- /**
933
- * The occurrence index of the cursor that hits the hitobject.
934
- */
935
- occurrenceIndex;
936
- /**
937
- * If this is a slider, whether the slider was cheesed.
938
- */
939
- sliderCheesed = false;
940
- /**
941
- * The underlying difficulty hitobject.
942
- */
943
- object;
944
- /**
945
- * @param object The underlying difficulty hitobject.
946
- * @param acceptedCursorIndex The accepted index of the cursor that hits the hitobject.
947
- * @param actualCursorIndex The actual index of the cursor that hits the hitobject.
948
- * @param occurrenceIndex The occurrence index of the cursor that hits the hitobject.
949
- */
950
- constructor(object, acceptedCursorIndex, actualCursorIndex, occurrenceIndex) {
951
- this.object = object;
952
- this.acceptedCursorIndex = acceptedCursorIndex;
953
- this.actualCursorIndex = actualCursorIndex;
954
- this.occurrenceIndex = occurrenceIndex;
955
- }
1128
+ /**
1129
+ * Contains information about which cursor index hits a hitobject.
1130
+ */
1131
+ class IndexedHitObject {
1132
+ /**
1133
+ * The accepted index of the cursor that hits the hitobject.
1134
+ */
1135
+ acceptedCursorIndex;
1136
+ /**
1137
+ * The actual index of the cursor that hits the hitobject.
1138
+ */
1139
+ actualCursorIndex;
1140
+ /**
1141
+ * The occurrence index of the cursor that hits the hitobject.
1142
+ */
1143
+ occurrenceIndex;
1144
+ /**
1145
+ * If this is a slider, whether the slider was cheesed.
1146
+ */
1147
+ sliderCheesed = false;
1148
+ /**
1149
+ * The underlying difficulty hitobject.
1150
+ */
1151
+ object;
1152
+ /**
1153
+ * @param object The underlying difficulty hitobject.
1154
+ * @param acceptedCursorIndex The accepted index of the cursor that hits the hitobject.
1155
+ * @param actualCursorIndex The actual index of the cursor that hits the hitobject.
1156
+ * @param occurrenceIndex The occurrence index of the cursor that hits the hitobject.
1157
+ */
1158
+ constructor(object, acceptedCursorIndex, actualCursorIndex, occurrenceIndex) {
1159
+ this.object = object;
1160
+ this.acceptedCursorIndex = acceptedCursorIndex;
1161
+ this.actualCursorIndex = actualCursorIndex;
1162
+ this.occurrenceIndex = occurrenceIndex;
1163
+ }
956
1164
  }
957
1165
 
958
- /**
959
- * Utility to check whether or not a beatmap is two-handed.
960
- */
961
- class TwoHandChecker {
962
- /**
963
- * The difficulty calculator that is being analyzed.
964
- */
965
- calculator;
966
- /**
967
- * The data of the replay.
968
- */
969
- data;
970
- /**
971
- * The hitobjects of the beatmap that have been assigned with their respective cursor index.
972
- */
973
- indexedHitObjects = [];
974
- /**
975
- * The osu!droid hitwindow of the analyzed beatmap.
976
- */
977
- hitWindow;
978
- /**
979
- * The minimum count of a cursor index occurrence to be valid.
980
- *
981
- * This is used to prevent excessive penalty by splitting the beatmap into
982
- * those that do not worth any strain.
983
- */
984
- minCursorIndexCount = 5;
985
- /**
986
- * @param calculator The difficulty calculator to analyze.
987
- * @param data The data of the replay.
988
- */
989
- constructor(calculator, data) {
990
- this.calculator = calculator;
991
- this.data = data;
992
- const stats = new osuBase.MapStats({
993
- od: this.calculator.beatmap.difficulty.od,
994
- mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
995
- !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym) &&
996
- !(m instanceof osuBase.ModPrecise)),
997
- }).calculate();
998
- this.hitWindow = new osuBase.DroidHitWindow(stats.od);
999
- }
1000
- /**
1001
- * Checks if a beatmap is two-handed.
1002
- */
1003
- check() {
1004
- if (this.data.cursorMovement.filter((v) => v.occurrences.length > 0)
1005
- .length <= 1) {
1006
- return { is2Hand: false, cursorIndexes: [] };
1007
- }
1008
- this.indexHitObjects();
1009
- this.applyPenalty();
1010
- const indexes = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1011
- for (const object of this.indexedHitObjects) {
1012
- ++indexes[object.acceptedCursorIndex];
1013
- }
1014
- return {
1015
- is2Hand: indexes.filter((v) => v > 0).length !== 1,
1016
- cursorIndexes: this.indexedHitObjects.map((v) => v.acceptedCursorIndex),
1017
- };
1018
- }
1019
- /**
1020
- * Converts hitobjects into indexed hit objects.
1021
- */
1022
- indexHitObjects() {
1023
- const hitWindowOffset = this.getHitWindowOffset();
1024
- const indexes = [];
1025
- for (let i = 0; i < this.calculator.objects.length; ++i) {
1026
- const indexedHitObject = this.getIndexedHitObject(i, hitWindowOffset);
1027
- indexes.push(indexedHitObject.acceptedCursorIndex);
1028
- this.indexedHitObjects.push(indexedHitObject);
1029
- }
1030
- const indexCounts = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1031
- for (const index of indexes) {
1032
- if (index === -1) {
1033
- continue;
1034
- }
1035
- ++indexCounts[index];
1036
- }
1037
- const mainCursorIndex = indexCounts.length > 0
1038
- ? indexCounts.indexOf(Math.max(...indexCounts))
1039
- : 0;
1040
- const ignoredCursorIndexes = [];
1041
- for (let i = 0; i < indexCounts.length; ++i) {
1042
- if (indexCounts[i] < this.minCursorIndexCount &&
1043
- i !== mainCursorIndex) {
1044
- ignoredCursorIndexes.push(i);
1045
- }
1046
- }
1047
- // Add cursor presses that don't fulfill minimum cursor
1048
- // count to the farthest cursor index that isn't 0.
1049
- let defaultMinCursorCountIndex = this.data.cursorMovement.length - 1;
1050
- for (; defaultMinCursorCountIndex > 0; --defaultMinCursorCountIndex) {
1051
- if (indexCounts[defaultMinCursorCountIndex] >=
1052
- this.minCursorIndexCount &&
1053
- !ignoredCursorIndexes.includes(defaultMinCursorCountIndex)) {
1054
- break;
1055
- }
1056
- }
1057
- this.indexedHitObjects.forEach((indexedHitObject, i) => {
1058
- if (indexedHitObject.acceptedCursorIndex === -1 ||
1059
- indexedHitObject.actualCursorIndex === -1) {
1060
- indexedHitObject.acceptedCursorIndex = mainCursorIndex;
1061
- indexedHitObject.actualCursorIndex = mainCursorIndex;
1062
- }
1063
- if (ignoredCursorIndexes.includes(indexedHitObject.acceptedCursorIndex)) {
1064
- indexedHitObject.acceptedCursorIndex =
1065
- defaultMinCursorCountIndex;
1066
- }
1067
- // For sliders, we need to consider two cases. The first case is when the player doesn't drag
1068
- // the slider. The second case is when the player drags the slider.
1069
- if (indexedHitObject.object.object instanceof osuBase.Slider) {
1070
- indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i], hitWindowOffset);
1071
- }
1072
- });
1073
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1074
- console.log("Index", i, "count:", this.indexedHitObjects.filter((v) => v.acceptedCursorIndex === i).length);
1075
- }
1076
- }
1077
- /**
1078
- * Gets the hit window offset to be applied to `getCursorIndex`.
1079
- */
1080
- getHitWindowOffset() {
1081
- const deltaTimes = [];
1082
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1083
- const c = this.data.cursorMovement[i];
1084
- for (let j = 0; j < c.occurrences.length; ++j) {
1085
- if (c.occurrences[j].time <
1086
- this.calculator.beatmap.hitObjects.objects[0].startTime -
1087
- this.hitWindow.hitWindowFor50()) {
1088
- continue;
1089
- }
1090
- if (c.occurrences[j].id !== exports.movementType.MOVE) {
1091
- continue;
1092
- }
1093
- const deltaTime = c.occurrences[j]?.time - c.occurrences[j - 1]?.time || 0;
1094
- if (deltaTime > 0) {
1095
- deltaTimes.push(deltaTime);
1096
- }
1097
- }
1098
- }
1099
- return Math.min(...deltaTimes);
1100
- }
1101
- /**
1102
- * Gets the cursor index that hits the given object.
1103
- *
1104
- * @param index The index of the object to check.
1105
- * @param hitWindowOffset The offset for hit window to compensate for replay hit inaccuracies.
1106
- * @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
1107
- */
1108
- getIndexedHitObject(index, hitWindowOffset) {
1109
- const object = this.calculator.objects[index];
1110
- const data = this.data.hitObjectData[index];
1111
- if (object.object instanceof osuBase.Spinner ||
1112
- data.result === exports.hitResult.RESULT_0) {
1113
- return new IndexedHitObject(object, -1, -1, -1);
1114
- }
1115
- const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1116
- // For sliders, automatically set hit window to be as lenient as possible.
1117
- let hitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
1118
- if (!(object.object instanceof osuBase.Slider)) {
1119
- switch (data.result) {
1120
- case exports.hitResult.RESULT_300:
1121
- hitWindowLength = this.hitWindow.hitWindowFor300(isPrecise);
1122
- break;
1123
- case exports.hitResult.RESULT_100:
1124
- hitWindowLength = this.hitWindow.hitWindowFor100(isPrecise);
1125
- break;
1126
- }
1127
- }
1128
- const startTime = object.object.startTime;
1129
- const hitTime = startTime + data.accuracy;
1130
- const minimumHitTime = startTime - hitWindowLength - hitWindowOffset;
1131
- const maximumHitTime = startTime + hitWindowLength + hitWindowOffset;
1132
- const cursorInformations = [];
1133
- for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1134
- const c = this.data.cursorMovement[i];
1135
- if (c.occurrences.length === 0) {
1136
- continue;
1137
- }
1138
- let hitTimeBeforeIndex = osuBase.MathUtils.clamp(c.occurrences.findIndex((v) => v.time >= minimumHitTime), 1, c.occurrences.length - 1) - 1;
1139
- let hitTimeAfterIndex = c.occurrences.findIndex(
1140
- // There is a special case for sliders where the time leniency in droid is a lot bigger compared to PC.
1141
- // To prevent slider end time from ending earlier than hit window leniency, we use the maximum value between both.
1142
- (v) => v.time >= Math.max(object.object.endTime, maximumHitTime));
1143
- if (hitTimeAfterIndex === -1) {
1144
- // Maximum hit time or object end time may be out of bounds for every presses.
1145
- // We set the index to the latest cursor occurrence if that happens.
1146
- hitTimeAfterIndex = c.occurrences.length;
1147
- }
1148
- --hitTimeAfterIndex;
1149
- // Sometimes a `movementType.UP` instance occurs at the same time as a `movementType.MOVE`
1150
- // or a cursor is recorded twice in one time, therefore this check is required.
1151
- while (c.occurrences[hitTimeBeforeIndex]?.time ===
1152
- c.occurrences[hitTimeBeforeIndex - 1]?.time &&
1153
- hitTimeBeforeIndex > 0) {
1154
- --hitTimeBeforeIndex;
1155
- }
1156
- // We track the cursor movement along those indexes.
1157
- // Current cursor position is in `hitTimeBeforeIndex`.
1158
- let distance = Number.POSITIVE_INFINITY;
1159
- let j = hitTimeBeforeIndex;
1160
- for (j; j <= hitTimeAfterIndex; ++j) {
1161
- const occurrence = c.occurrences[j];
1162
- const nextOccurrence = c.occurrences[j + 1];
1163
- const cursorPosition = occurrence.position;
1164
- if (occurrence.time < minimumHitTime &&
1165
- nextOccurrence?.id !== exports.movementType.MOVE) {
1166
- continue;
1167
- }
1168
- if (occurrence.time > hitTime + hitWindowOffset) {
1169
- // Set distance to minimum just for the last.
1170
- if (occurrence.id !== exports.movementType.UP) {
1171
- distance = Math.min(distance, object.object
1172
- .getStackedPosition(osuBase.modes.droid)
1173
- .getDistance(cursorPosition));
1174
- }
1175
- break;
1176
- }
1177
- if (occurrence.id === exports.movementType.UP) {
1178
- continue;
1179
- }
1180
- distance = object.object
1181
- .getStackedPosition(osuBase.modes.droid)
1182
- .getDistance(cursorPosition);
1183
- if (nextOccurrence?.id === exports.movementType.MOVE &&
1184
- occurrence.time !== nextOccurrence.time &&
1185
- !occurrence.position.equals(nextOccurrence.position)) {
1186
- // If next cursor is a `move` instance and it doesn't go out of time
1187
- // range, we interpolate cursor position between two occurrences.
1188
- const nextPosition = nextOccurrence.position;
1189
- const displacement = nextPosition.subtract(cursorPosition);
1190
- for (let mSecPassed = Math.max(minimumHitTime, occurrence.time); mSecPassed <= Math.min(hitTime, nextOccurrence.time); ++mSecPassed) {
1191
- const progress = (mSecPassed - occurrence.time) /
1192
- (nextOccurrence.time - occurrence.time);
1193
- distance = object.object
1194
- .getStackedPosition(osuBase.modes.droid)
1195
- .getDistance(cursorPosition.add(displacement.scale(progress)));
1196
- }
1197
- }
1198
- }
1199
- if (distance > object.object.getRadius(osuBase.modes.droid)) {
1200
- continue;
1201
- }
1202
- // The case for a one-handed object is that there will be a slight movement in the cursor towards
1203
- // the next object in fast patterns. We should not be worried about slow patterns as they will only
1204
- // make a minimal difference and aim strain threshold should filter them out.
1205
- // In order to verify if the player does that, we check if the movement towards the next
1206
- // object is sufficient enough to be two-handed. This is done by checking if the movement
1207
- // from the current object to the next object and the movement from the current object
1208
- // to the significant move cursor occurrence produces an angle that is acute enough.
1209
- // let isAngleFulfilled: boolean = false;
1210
- // Aside of angles, we need to consider if the player dragged from the previous object to the current object.
1211
- let isDragged = false;
1212
- // Get the latest down or movement cursor occurrence.
1213
- while (c.occurrences[j]?.id === exports.movementType.UP &&
1214
- j > hitTimeBeforeIndex) {
1215
- --j;
1216
- }
1217
- if (object.object instanceof osuBase.Circle) {
1218
- // For circles, we only need to consider the actual press on the circle.
1219
- // Therefore, we need to get the latest down cursor occurrence instead.
1220
- while (c.occurrences[j]?.id !== exports.movementType.DOWN &&
1221
- j > hitTimeBeforeIndex) {
1222
- --j;
1223
- }
1224
- }
1225
- // Theoretically there can only be 1 up occurrence, but this is a
1226
- // consideration if the user manually adds cursor occurrences.
1227
- if (c.occurrences[j]?.id === exports.movementType.UP) {
1228
- ++j;
1229
- }
1230
- // Some move instances move in the exact same place. Not sure why, most likely
1231
- // because the position is recorded as int in the game and the movement is too small to
1232
- // convert into +1 or -1.
1233
- // let nextSignificantOccurrenceIndex: number = j + 1;
1234
- // while (
1235
- // c.occurrences[j] &&
1236
- // c.occurrences[nextSignificantOccurrenceIndex] &&
1237
- // c.occurrences[j].position.equals(
1238
- // c.occurrences[nextSignificantOccurrenceIndex].position
1239
- // )
1240
- // ) {
1241
- // ++nextSignificantOccurrenceIndex;
1242
- // }
1243
- // const nextSignificantOccurrence: CursorOccurrence =
1244
- // c.occurrences[nextSignificantOccurrenceIndex];
1245
- // const next: DifficultyHitObject | RebalanceDifficultyHitObject =
1246
- // this.map.objects[index + 1];
1247
- // Angle detection.
1248
- /* if (nextSignificantOccurrence?.id === movementType.MOVE && next) {
1249
- // Get the object's actual end position.
1250
- let actualEndPosition: Vector2 =
1251
- object.object.stackedEndPosition;
1252
-
1253
- if (
1254
- object.object instanceof Slider &&
1255
- object.object.lazyEndPosition
1256
- ) {
1257
- // For sliders, we take the closest distance between the lazy end position
1258
- // and stacked end position towards the next significant cursor occurrence.
1259
- // This assumes that the player takes the simpler movement.
1260
- const lazyEndDistance: number =
1261
- object.object.lazyEndPosition.getDistance(
1262
- nextSignificantOccurrence.position
1263
- );
1264
- const actualEndDistance: number =
1265
- object.object.stackedEndPosition.getDistance(
1266
- nextSignificantOccurrence.position
1267
- );
1268
-
1269
- if (lazyEndDistance < actualEndDistance) {
1270
- actualEndPosition = object.object.lazyEndPosition;
1271
- }
1272
- }
1273
-
1274
- // Get the movement vector towards the next cursor occurrence by subtracting
1275
- // the next cursor occurrence with the object's position.
1276
- const movementVec: Vector2 =
1277
- nextSignificantOccurrence.position.subtract(
1278
- actualEndPosition
1279
- );
1280
-
1281
- const currentToNext: Vector2 =
1282
- next.object.getStackedPosition(modes.droid).subtract(actualEndPosition);
1283
-
1284
- const dot: number = currentToNext.dot(movementVec);
1285
- const det: number =
1286
- currentToNext.x * movementVec.y -
1287
- currentToNext.y * movementVec.x;
1288
-
1289
- const movementToNextAngle: number = Math.abs(
1290
- Math.atan2(det, dot)
1291
- );
1292
-
1293
- isAngleFulfilled = movementToNextAngle < Math.PI / 6;
1294
- } */
1295
- // Dragging detection.
1296
- let dragTimeThreshold = 0;
1297
- const prev = this.calculator.objects[index - 1];
1298
- if (prev) {
1299
- // The previous object might be a slider, so we need to get
1300
- // the hit data of it to get an accurate time threshold.
1301
- const prevData = this.data.hitObjectData[index - 1];
1302
- dragTimeThreshold = prev.object.startTime;
1303
- if (!(prev.object instanceof osuBase.Spinner)) {
1304
- dragTimeThreshold += prevData.accuracy;
1305
- }
1306
- if (prev.object instanceof osuBase.Slider) {
1307
- dragTimeThreshold = Math.max(dragTimeThreshold, prev.object.endTime);
1308
- }
1309
- }
1310
- let occurrenceStartIndex = hitTimeAfterIndex;
1311
- while (c.occurrences[occurrenceStartIndex]?.time >=
1312
- dragTimeThreshold &&
1313
- occurrenceStartIndex > 0) {
1314
- --occurrenceStartIndex;
1315
- }
1316
- // The above loop will make the start index before or right when
1317
- // the previous object was hit or ended, but we want the index after it.
1318
- ++occurrenceStartIndex;
1319
- const dragOccurrences = c.occurrences.slice(occurrenceStartIndex, hitTimeAfterIndex);
1320
- isDragged =
1321
- dragOccurrences.length > 0 &&
1322
- // We only care when the cursor approaches the current object. It doesn't
1323
- // matter whether the previous object was pressed or dragged.
1324
- dragOccurrences
1325
- .at(-1)
1326
- .position.getDistance(object.object.getStackedPosition(osuBase.modes.droid)) <= object.object.getRadius(osuBase.modes.droid) &&
1327
- dragOccurrences.every((v) => v.id === exports.movementType.MOVE);
1328
- cursorInformations.push({
1329
- // If the angle is fulfilled or the player dragged,
1330
- // we set the cursor index to the main cursor index.
1331
- acceptedCursorIndex: /* isAngleFulfilled || */ isDragged
1332
- ? -1
1333
- : i,
1334
- actualCursorIndex: i,
1335
- occurrenceIndex: j,
1336
- distanceDiff: distance,
1337
- });
1338
- }
1339
- // Cursors have been filtered to see which of them is inside the object.
1340
- // Now we look at which cursor is closest to the center of the object.
1341
- const minDistanceDiff = Math.min(...cursorInformations.map((v) => v.distanceDiff));
1342
- const acceptedCursorInformation = cursorInformations.find((c) => c.distanceDiff === minDistanceDiff);
1343
- return new IndexedHitObject(object, acceptedCursorInformation?.acceptedCursorIndex ?? -1, acceptedCursorInformation?.actualCursorIndex ?? -1, acceptedCursorInformation?.occurrenceIndex ?? -1);
1344
- }
1345
- /**
1346
- * Checks whether a slider was cheesed.
1347
- *
1348
- * This is done by checking if a cursor follows a slider all the way to its end position.
1349
- *
1350
- * @param indexedHitObject The indexed slider.
1351
- * @param hitData The hit data of the slider.
1352
- * @param actualCursorIndex The actual cursor index that hit the slider.
1353
- * @param hitWindowOffset The offset that was calculated by `getHitWindowOffset()`
1354
- * @returns Whether the slider was cheesed.
1355
- */
1356
- checkSliderCheesing(indexedHitObject, hitData, hitWindowOffset) {
1357
- if (!(indexedHitObject.object.object instanceof osuBase.Slider) ||
1358
- hitData.result === exports.hitResult.RESULT_0) {
1359
- return false;
1360
- }
1361
- let cursorLoopIndex = Math.max(0, indexedHitObject.occurrenceIndex);
1362
- const c = this.data.cursorMovement[indexedHitObject.actualCursorIndex];
1363
- const acceptableRadius = indexedHitObject.object.object.getRadius(osuBase.modes.droid) * 2.4;
1364
- for (let i = 1; i < indexedHitObject.object.object.nestedHitObjects.length; ++i) {
1365
- const tickWasHit = hitData.tickset[i - 1];
1366
- if (!tickWasHit) {
1367
- continue;
1368
- }
1369
- const object = indexedHitObject.object.object.nestedHitObjects[i];
1370
- let j = cursorLoopIndex;
1371
- let cursorHitTick = false;
1372
- for (j; j < c.occurrences.length; ++j) {
1373
- if (c.occurrences[j].time <
1374
- object.startTime - hitWindowOffset) {
1375
- continue;
1376
- }
1377
- if (c.occurrences[j].time >
1378
- object.startTime + hitWindowOffset) {
1379
- break;
1380
- }
1381
- if (c.occurrences[j].position.getDistance(object.getStackedPosition(osuBase.modes.droid)) <= acceptableRadius) {
1382
- cursorHitTick = true;
1383
- break;
1384
- }
1385
- }
1386
- if (!cursorHitTick) {
1387
- return true;
1388
- }
1389
- cursorLoopIndex = j;
1390
- }
1391
- return false;
1392
- }
1393
- /**
1394
- * Applies penalty to the original star rating instance.
1395
- */
1396
- applyPenalty() {
1397
- const beatmaps = new Array(this.data.cursorMovement.length);
1398
- this.indexedHitObjects.forEach((o) => {
1399
- if (!beatmaps[o.acceptedCursorIndex]) {
1400
- const map = osuBase.Utils.deepCopy(this.calculator.beatmap);
1401
- map.hitObjects.clear();
1402
- beatmaps[o.acceptedCursorIndex] = map;
1403
- }
1404
- beatmaps[o.acceptedCursorIndex].hitObjects.add(o.object.object);
1405
- });
1406
- // Preserve some values that aren't reasonable for them to be changed.
1407
- const preservedValues = this.calculator.objects.map((v) => {
1408
- return {
1409
- noteDensity: v.noteDensity,
1410
- overlappingFactor: v.overlappingFactor,
1411
- rhythmStrain: v.rhythmStrain,
1412
- rhythmMultiplier: v.rhythmMultiplier,
1413
- };
1414
- });
1415
- this.calculator.objects.length = 0;
1416
- beatmaps.forEach((beatmap) => {
1417
- if (!beatmap) {
1418
- return;
1419
- }
1420
- const difficultyCalculator = Object.assign(osuBase.Utils.deepCopy(this.calculator), { beatmap: beatmap });
1421
- difficultyCalculator.generateDifficultyHitObjects();
1422
- difficultyCalculator.objects[0].deltaTime =
1423
- difficultyCalculator.objects[0].startTime -
1424
- this.indexedHitObjects[0].object.startTime;
1425
- difficultyCalculator.objects[0].strainTime = Math.max(25, difficultyCalculator.objects[0].deltaTime);
1426
- (this.calculator.objects).push(...difficultyCalculator.objects);
1427
- });
1428
- this.calculator.objects.sort((a, b) => a.startTime - b.startTime);
1429
- // Reassign preserved values before calculating.
1430
- for (let i = 0; i < this.calculator.objects.length; ++i) {
1431
- const diffObject = this.calculator.objects[i];
1432
- const indexedHitObject = this.indexedHitObjects[i];
1433
- const preservedValue = preservedValues[i];
1434
- diffObject.noteDensity = preservedValue.noteDensity;
1435
- diffObject.overlappingFactor = preservedValue.overlappingFactor;
1436
- diffObject.rhythmStrain = preservedValue.rhythmStrain;
1437
- diffObject.rhythmMultiplier = preservedValue.rhythmMultiplier;
1438
- // Set slider travel distance to 0 if the slider was cheesed.
1439
- if (indexedHitObject.sliderCheesed) {
1440
- diffObject.travelDistance = 0;
1441
- }
1442
- }
1443
- // Do not include rhythm skill.
1444
- this.calculator.calculateAim();
1445
- this.calculator.calculateTap();
1446
- this.calculator.calculateFlashlight();
1447
- this.calculator.calculateVisual();
1448
- this.calculator.calculateTotal();
1449
- }
1166
+ /**
1167
+ * Utility to check whether or not a beatmap is two-handed.
1168
+ */
1169
+ class TwoHandChecker {
1170
+ /**
1171
+ * The difficulty calculator that is being analyzed.
1172
+ */
1173
+ calculator;
1174
+ /**
1175
+ * The data of the replay.
1176
+ */
1177
+ data;
1178
+ /**
1179
+ * The hitobjects of the beatmap that have been assigned with their respective cursor index.
1180
+ */
1181
+ indexedHitObjects = [];
1182
+ /**
1183
+ * The osu!droid hitwindow of the analyzed beatmap.
1184
+ */
1185
+ hitWindow;
1186
+ /**
1187
+ * The minimum count of a cursor index occurrence to be valid.
1188
+ *
1189
+ * This is used to prevent excessive penalty by splitting the beatmap into
1190
+ * those that do not worth any strain.
1191
+ */
1192
+ minCursorIndexCount = 5;
1193
+ /**
1194
+ * A cursor occurrence nested array containing all cursor occurrences.
1195
+ *
1196
+ * Each index represents the cursor index.
1197
+ */
1198
+ // TODO: replace with group at some point
1199
+ allCursorOccurrences = [];
1200
+ /**
1201
+ * @param calculator The difficulty calculator to analyze.
1202
+ * @param data The data of the replay.
1203
+ */
1204
+ constructor(calculator, data) {
1205
+ this.calculator = calculator;
1206
+ this.data = data;
1207
+ this.allCursorOccurrences = data.cursorMovement.map((v) => v.allOccurrences);
1208
+ const stats = new osuBase.MapStats({
1209
+ od: this.calculator.beatmap.difficulty.od,
1210
+ mods: this.calculator.mods.filter((m) => m.isApplicableToDroid() &&
1211
+ !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym) &&
1212
+ !(m instanceof osuBase.ModPrecise)),
1213
+ }).calculate();
1214
+ this.hitWindow = new osuBase.DroidHitWindow(stats.od);
1215
+ }
1216
+ /**
1217
+ * Checks if a beatmap is two-handed.
1218
+ */
1219
+ check() {
1220
+ if (this.data.cursorMovement.filter((v) => v.occurrenceGroups.length > 0).length <= 1) {
1221
+ return { is2Hand: false, cursorIndexes: [] };
1222
+ }
1223
+ this.indexHitObjects();
1224
+ this.applyPenalty();
1225
+ const indexes = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1226
+ for (const object of this.indexedHitObjects) {
1227
+ ++indexes[object.acceptedCursorIndex];
1228
+ }
1229
+ return {
1230
+ is2Hand: indexes.filter((v) => v > 0).length !== 1,
1231
+ cursorIndexes: this.indexedHitObjects.map((v) => v.acceptedCursorIndex),
1232
+ };
1233
+ }
1234
+ /**
1235
+ * Converts hitobjects into indexed hit objects.
1236
+ */
1237
+ indexHitObjects() {
1238
+ const hitWindowOffset = this.getHitWindowOffset();
1239
+ const indexes = [];
1240
+ for (let i = 0; i < this.calculator.objects.length; ++i) {
1241
+ const indexedHitObject = this.getIndexedHitObject(i, hitWindowOffset);
1242
+ indexes.push(indexedHitObject.acceptedCursorIndex);
1243
+ this.indexedHitObjects.push(indexedHitObject);
1244
+ }
1245
+ const indexCounts = osuBase.Utils.initializeArray(this.data.cursorMovement.length, 0);
1246
+ for (const index of indexes) {
1247
+ if (index === -1) {
1248
+ continue;
1249
+ }
1250
+ ++indexCounts[index];
1251
+ }
1252
+ const mainCursorIndex = indexCounts.length > 0
1253
+ ? indexCounts.indexOf(Math.max(...indexCounts))
1254
+ : 0;
1255
+ const ignoredCursorIndexes = [];
1256
+ for (let i = 0; i < indexCounts.length; ++i) {
1257
+ if (indexCounts[i] < this.minCursorIndexCount &&
1258
+ i !== mainCursorIndex) {
1259
+ ignoredCursorIndexes.push(i);
1260
+ }
1261
+ }
1262
+ // Add cursor presses that don't fulfill minimum cursor
1263
+ // count to the farthest cursor index that isn't 0.
1264
+ let defaultMinCursorCountIndex = this.data.cursorMovement.length - 1;
1265
+ for (; defaultMinCursorCountIndex > 0; --defaultMinCursorCountIndex) {
1266
+ if (indexCounts[defaultMinCursorCountIndex] >=
1267
+ this.minCursorIndexCount &&
1268
+ !ignoredCursorIndexes.includes(defaultMinCursorCountIndex)) {
1269
+ break;
1270
+ }
1271
+ }
1272
+ this.indexedHitObjects.forEach((indexedHitObject, i) => {
1273
+ if (indexedHitObject.acceptedCursorIndex === -1 ||
1274
+ indexedHitObject.actualCursorIndex === -1) {
1275
+ indexedHitObject.acceptedCursorIndex = mainCursorIndex;
1276
+ indexedHitObject.actualCursorIndex = mainCursorIndex;
1277
+ }
1278
+ if (ignoredCursorIndexes.includes(indexedHitObject.acceptedCursorIndex)) {
1279
+ indexedHitObject.acceptedCursorIndex =
1280
+ defaultMinCursorCountIndex;
1281
+ }
1282
+ // For sliders, we need to consider two cases. The first case is when the player doesn't drag
1283
+ // the slider. The second case is when the player drags the slider.
1284
+ if (indexedHitObject.object.object instanceof osuBase.Slider) {
1285
+ indexedHitObject.sliderCheesed = this.checkSliderCheesing(indexedHitObject, this.data.hitObjectData[i], hitWindowOffset);
1286
+ }
1287
+ });
1288
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1289
+ console.log("Index", i, "count:", this.indexedHitObjects.filter((v) => v.acceptedCursorIndex === i).length);
1290
+ }
1291
+ }
1292
+ /**
1293
+ * Gets the hit window offset to be applied to `getCursorIndex`.
1294
+ */
1295
+ getHitWindowOffset() {
1296
+ const deltaTimes = [];
1297
+ for (let i = 0; i < this.data.cursorMovement.length; ++i) {
1298
+ const c = this.data.cursorMovement[i];
1299
+ for (let j = 0; j < c.occurrenceGroups.length; ++j) {
1300
+ const group = c.occurrenceGroups[j];
1301
+ const previousGroup = c.occurrenceGroups[j - 1];
1302
+ if (group.startTime <
1303
+ this.calculator.beatmap.hitObjects.objects[0].startTime -
1304
+ this.hitWindow.hitWindowFor50()) {
1305
+ continue;
1306
+ }
1307
+ const deltaTime = group.startTime - previousGroup.endTime;
1308
+ if (deltaTime > 0) {
1309
+ deltaTimes.push(deltaTime);
1310
+ }
1311
+ }
1312
+ }
1313
+ return Math.min(...deltaTimes);
1314
+ }
1315
+ /**
1316
+ * Gets the cursor index that hits the given object.
1317
+ *
1318
+ * @param index The index of the object to check.
1319
+ * @param hitWindowOffset The offset for hit window to compensate for replay hit inaccuracies.
1320
+ * @returns The cursor index that hits the given object, -1 if the index is not found, the object is a spinner, or the object was missed.
1321
+ */
1322
+ getIndexedHitObject(index, hitWindowOffset) {
1323
+ const object = this.calculator.objects[index];
1324
+ const data = this.data.hitObjectData[index];
1325
+ if (object.object instanceof osuBase.Spinner ||
1326
+ data.result === exports.HitResult.miss) {
1327
+ return new IndexedHitObject(object, -1, -1, -1);
1328
+ }
1329
+ const isPrecise = this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise);
1330
+ // For sliders, automatically set hit window to be as lenient as possible.
1331
+ let hitWindowLength = this.hitWindow.hitWindowFor50(isPrecise);
1332
+ if (!(object.object instanceof osuBase.Slider)) {
1333
+ switch (data.result) {
1334
+ case exports.HitResult.great:
1335
+ hitWindowLength = this.hitWindow.hitWindowFor300(isPrecise);
1336
+ break;
1337
+ case exports.HitResult.good:
1338
+ hitWindowLength = this.hitWindow.hitWindowFor100(isPrecise);
1339
+ break;
1340
+ }
1341
+ }
1342
+ const startTime = object.object.startTime;
1343
+ const hitTime = startTime + data.accuracy;
1344
+ const minimumHitTime = startTime - hitWindowLength - hitWindowOffset;
1345
+ const maximumHitTime = startTime + hitWindowLength + hitWindowOffset;
1346
+ const cursorInformations = [];
1347
+ for (let i = 0; i < this.allCursorOccurrences.length; ++i) {
1348
+ const c = this.allCursorOccurrences[i];
1349
+ if (c.length === 0) {
1350
+ continue;
1351
+ }
1352
+ let hitTimeBeforeIndex = osuBase.MathUtils.clamp(c.findIndex((v) => v.time >= minimumHitTime), 1, c.length - 1) - 1;
1353
+ let hitTimeAfterIndex = c.findIndex(
1354
+ // There is a special case for sliders where the time leniency in droid is a lot bigger compared to PC.
1355
+ // To prevent slider end time from ending earlier than hit window leniency, we use the maximum value between both.
1356
+ (v) => v.time >= Math.max(object.object.endTime, maximumHitTime));
1357
+ if (hitTimeAfterIndex === -1) {
1358
+ // Maximum hit time or object end time may be out of bounds for every presses.
1359
+ // We set the index to the latest cursor occurrence if that happens.
1360
+ hitTimeAfterIndex = c.length;
1361
+ }
1362
+ --hitTimeAfterIndex;
1363
+ // Sometimes a `movementType.UP` instance occurs at the same time as a `movementType.MOVE`
1364
+ // or a cursor is recorded twice in one time, therefore this check is required.
1365
+ while (c[hitTimeBeforeIndex]?.time ===
1366
+ c[hitTimeBeforeIndex - 1]?.time &&
1367
+ hitTimeBeforeIndex > 0) {
1368
+ --hitTimeBeforeIndex;
1369
+ }
1370
+ // We track the cursor movement along those indexes.
1371
+ // Current cursor position is in `hitTimeBeforeIndex`.
1372
+ let distance = Number.POSITIVE_INFINITY;
1373
+ let j = hitTimeBeforeIndex;
1374
+ for (j; j <= hitTimeAfterIndex; ++j) {
1375
+ const occurrence = c[j];
1376
+ const nextOccurrence = c[j + 1];
1377
+ const cursorPosition = occurrence.position;
1378
+ if (occurrence.time < minimumHitTime &&
1379
+ nextOccurrence?.id !== exports.MovementType.move) {
1380
+ continue;
1381
+ }
1382
+ if (occurrence.time > hitTime + hitWindowOffset) {
1383
+ // Set distance to minimum just for the last.
1384
+ if (occurrence.id !== exports.MovementType.up) {
1385
+ distance = Math.min(distance, object.object
1386
+ .getStackedPosition(osuBase.Modes.droid)
1387
+ .getDistance(cursorPosition));
1388
+ }
1389
+ break;
1390
+ }
1391
+ if (occurrence.id === exports.MovementType.up) {
1392
+ continue;
1393
+ }
1394
+ distance = object.object
1395
+ .getStackedPosition(osuBase.Modes.droid)
1396
+ .getDistance(cursorPosition);
1397
+ if (nextOccurrence?.id === exports.MovementType.move &&
1398
+ occurrence.time !== nextOccurrence.time &&
1399
+ !occurrence.position.equals(nextOccurrence.position)) {
1400
+ // If next cursor is a `move` instance and it doesn't go out of time
1401
+ // range, we interpolate cursor position between two occurrences.
1402
+ const nextPosition = nextOccurrence.position;
1403
+ const displacement = nextPosition.subtract(cursorPosition);
1404
+ for (let mSecPassed = Math.max(minimumHitTime, occurrence.time); mSecPassed <= Math.min(hitTime, nextOccurrence.time); ++mSecPassed) {
1405
+ const progress = (mSecPassed - occurrence.time) /
1406
+ (nextOccurrence.time - occurrence.time);
1407
+ distance = object.object
1408
+ .getStackedPosition(osuBase.Modes.droid)
1409
+ .getDistance(cursorPosition.add(displacement.scale(progress)));
1410
+ }
1411
+ }
1412
+ }
1413
+ if (distance > object.object.getRadius(osuBase.Modes.droid)) {
1414
+ continue;
1415
+ }
1416
+ // The case for a one-handed object is that there will be a slight movement in the cursor towards
1417
+ // the next object in fast patterns. We should not be worried about slow patterns as they will only
1418
+ // make a minimal difference and aim strain threshold should filter them out.
1419
+ // In order to verify if the player does that, we check if the movement towards the next
1420
+ // object is sufficient enough to be two-handed. This is done by checking if the movement
1421
+ // from the current object to the next object and the movement from the current object
1422
+ // to the significant move cursor occurrence produces an angle that is acute enough.
1423
+ // let isAngleFulfilled: boolean = false;
1424
+ // Aside of angles, we need to consider if the player dragged from the previous object to the current object.
1425
+ let isDragged = false;
1426
+ // Get the latest down or movement cursor occurrence.
1427
+ while (c[j]?.id === exports.MovementType.up && j > hitTimeBeforeIndex) {
1428
+ --j;
1429
+ }
1430
+ if (object.object instanceof osuBase.Circle) {
1431
+ // For circles, we only need to consider the actual press on the circle.
1432
+ // Therefore, we need to get the latest down cursor occurrence instead.
1433
+ while (c[j]?.id !== exports.MovementType.down &&
1434
+ j > hitTimeBeforeIndex) {
1435
+ --j;
1436
+ }
1437
+ }
1438
+ // Theoretically there can only be 1 up occurrence, but this is a
1439
+ // consideration if the user manually adds cursor occurrences.
1440
+ if (c[j]?.id === exports.MovementType.up) {
1441
+ ++j;
1442
+ }
1443
+ // Some move instances move in the exact same place. Not sure why, most likely
1444
+ // because the position is recorded as int in the game and the movement is too small to
1445
+ // convert into +1 or -1.
1446
+ // let nextSignificantOccurrenceIndex: number = j + 1;
1447
+ // while (
1448
+ // c[j] &&
1449
+ // c[nextSignificantOccurrenceIndex] &&
1450
+ // c[j].position.equals(
1451
+ // c[nextSignificantOccurrenceIndex].position
1452
+ // )
1453
+ // ) {
1454
+ // ++nextSignificantOccurrenceIndex;
1455
+ // }
1456
+ // const nextSignificantOccurrence: CursorOccurrence =
1457
+ // c[nextSignificantOccurrenceIndex];
1458
+ // const next: DifficultyHitObject | RebalanceDifficultyHitObject =
1459
+ // this.map.objects[index + 1];
1460
+ // Angle detection.
1461
+ /* if (nextSignificantOccurrence?.id === movementType.MOVE && next) {
1462
+ // Get the object's actual end position.
1463
+ let actualEndPosition: Vector2 =
1464
+ object.object.stackedEndPosition;
1465
+
1466
+ if (
1467
+ object.object instanceof Slider &&
1468
+ object.object.lazyEndPosition
1469
+ ) {
1470
+ // For sliders, we take the closest distance between the lazy end position
1471
+ // and stacked end position towards the next significant cursor occurrence.
1472
+ // This assumes that the player takes the simpler movement.
1473
+ const lazyEndDistance: number =
1474
+ object.object.lazyEndPosition.getDistance(
1475
+ nextSignificantOccurrence.position
1476
+ );
1477
+ const actualEndDistance: number =
1478
+ object.object.stackedEndPosition.getDistance(
1479
+ nextSignificantOccurrence.position
1480
+ );
1481
+
1482
+ if (lazyEndDistance < actualEndDistance) {
1483
+ actualEndPosition = object.object.lazyEndPosition;
1484
+ }
1485
+ }
1486
+
1487
+ // Get the movement vector towards the next cursor occurrence by subtracting
1488
+ // the next cursor occurrence with the object's position.
1489
+ const movementVec: Vector2 =
1490
+ nextSignificantOccurrence.position.subtract(
1491
+ actualEndPosition
1492
+ );
1493
+
1494
+ const currentToNext: Vector2 =
1495
+ next.object.getStackedPosition(Modes.droid).subtract(actualEndPosition);
1496
+
1497
+ const dot: number = currentToNext.dot(movementVec);
1498
+ const det: number =
1499
+ currentToNext.x * movementVec.y -
1500
+ currentToNext.y * movementVec.x;
1501
+
1502
+ const movementToNextAngle: number = Math.abs(
1503
+ Math.atan2(det, dot)
1504
+ );
1505
+
1506
+ isAngleFulfilled = movementToNextAngle < Math.PI / 6;
1507
+ } */
1508
+ // Dragging detection.
1509
+ let dragTimeThreshold = 0;
1510
+ const prev = this.calculator.objects[index - 1];
1511
+ if (prev) {
1512
+ // The previous object might be a slider, so we need to get
1513
+ // the hit data of it to get an accurate time threshold.
1514
+ const prevData = this.data.hitObjectData[index - 1];
1515
+ dragTimeThreshold = prev.object.startTime;
1516
+ if (!(prev.object instanceof osuBase.Spinner)) {
1517
+ dragTimeThreshold += prevData.accuracy;
1518
+ }
1519
+ if (prev.object instanceof osuBase.Slider) {
1520
+ dragTimeThreshold = Math.max(dragTimeThreshold, prev.object.endTime);
1521
+ }
1522
+ }
1523
+ let occurrenceStartIndex = hitTimeAfterIndex;
1524
+ while (c[occurrenceStartIndex]?.time >= dragTimeThreshold &&
1525
+ occurrenceStartIndex > 0) {
1526
+ --occurrenceStartIndex;
1527
+ }
1528
+ // The above loop will make the start index before or right when
1529
+ // the previous object was hit or ended, but we want the index after it.
1530
+ ++occurrenceStartIndex;
1531
+ const dragOccurrences = c.slice(occurrenceStartIndex, hitTimeAfterIndex);
1532
+ isDragged =
1533
+ dragOccurrences.length > 0 &&
1534
+ // We only care when the cursor approaches the current object. It doesn't
1535
+ // matter whether the previous object was pressed or dragged.
1536
+ dragOccurrences
1537
+ .at(-1)
1538
+ .position.getDistance(object.object.getStackedPosition(osuBase.Modes.droid)) <= object.object.getRadius(osuBase.Modes.droid) &&
1539
+ dragOccurrences.every((v) => v.id === exports.MovementType.move);
1540
+ cursorInformations.push({
1541
+ // If the angle is fulfilled or the player dragged,
1542
+ // we set the cursor index to the main cursor index.
1543
+ acceptedCursorIndex: /* isAngleFulfilled || */ isDragged
1544
+ ? -1
1545
+ : i,
1546
+ actualCursorIndex: i,
1547
+ occurrenceIndex: j,
1548
+ distanceDiff: distance,
1549
+ });
1550
+ }
1551
+ // Cursors have been filtered to see which of them is inside the object.
1552
+ // Now we look at which cursor is closest to the center of the object.
1553
+ const minDistanceDiff = Math.min(...cursorInformations.map((v) => v.distanceDiff));
1554
+ const acceptedCursorInformation = cursorInformations.find((c) => c.distanceDiff === minDistanceDiff);
1555
+ return new IndexedHitObject(object, acceptedCursorInformation?.acceptedCursorIndex ?? -1, acceptedCursorInformation?.actualCursorIndex ?? -1, acceptedCursorInformation?.occurrenceIndex ?? -1);
1556
+ }
1557
+ /**
1558
+ * Checks whether a slider was cheesed.
1559
+ *
1560
+ * This is done by checking if a cursor follows a slider all the way to its end position.
1561
+ *
1562
+ * @param indexedHitObject The indexed slider.
1563
+ * @param hitData The hit data of the slider.
1564
+ * @param actualCursorIndex The actual cursor index that hit the slider.
1565
+ * @param hitWindowOffset The offset that was calculated by `getHitWindowOffset()`
1566
+ * @returns Whether the slider was cheesed.
1567
+ */
1568
+ checkSliderCheesing(indexedHitObject, hitData, hitWindowOffset) {
1569
+ if (!(indexedHitObject.object.object instanceof osuBase.Slider) ||
1570
+ hitData.result === exports.HitResult.miss) {
1571
+ return false;
1572
+ }
1573
+ let cursorLoopIndex = Math.max(0, indexedHitObject.occurrenceIndex);
1574
+ const c = this.allCursorOccurrences[indexedHitObject.actualCursorIndex];
1575
+ const acceptableRadius = indexedHitObject.object.object.getRadius(osuBase.Modes.droid) * 2.4;
1576
+ for (let i = 1; i < indexedHitObject.object.object.nestedHitObjects.length; ++i) {
1577
+ const tickWasHit = hitData.tickset[i - 1];
1578
+ if (!tickWasHit) {
1579
+ continue;
1580
+ }
1581
+ const object = indexedHitObject.object.object.nestedHitObjects[i];
1582
+ let j = cursorLoopIndex;
1583
+ let cursorHitTick = false;
1584
+ for (j; j < c.length; ++j) {
1585
+ if (c[j].time < object.startTime - hitWindowOffset) {
1586
+ continue;
1587
+ }
1588
+ if (c[j].time > object.startTime + hitWindowOffset) {
1589
+ break;
1590
+ }
1591
+ if (c[j].position.getDistance(object.getStackedPosition(osuBase.Modes.droid)) <= acceptableRadius) {
1592
+ cursorHitTick = true;
1593
+ break;
1594
+ }
1595
+ }
1596
+ if (!cursorHitTick) {
1597
+ return true;
1598
+ }
1599
+ cursorLoopIndex = j;
1600
+ }
1601
+ return false;
1602
+ }
1603
+ /**
1604
+ * Applies penalty to the original star rating instance.
1605
+ */
1606
+ applyPenalty() {
1607
+ const beatmaps = new Array(this.data.cursorMovement.length);
1608
+ this.indexedHitObjects.forEach((o) => {
1609
+ if (!beatmaps[o.acceptedCursorIndex]) {
1610
+ const map = osuBase.Utils.deepCopy(this.calculator.beatmap);
1611
+ map.hitObjects.clear();
1612
+ beatmaps[o.acceptedCursorIndex] = map;
1613
+ }
1614
+ beatmaps[o.acceptedCursorIndex].hitObjects.add(o.object.object);
1615
+ });
1616
+ // Preserve some values that aren't reasonable for them to be changed.
1617
+ const preservedValues = this.calculator.objects.map((v) => {
1618
+ return {
1619
+ noteDensity: v.noteDensity,
1620
+ overlappingFactor: v.overlappingFactor,
1621
+ rhythmStrain: v.rhythmStrain,
1622
+ rhythmMultiplier: v.rhythmMultiplier,
1623
+ };
1624
+ });
1625
+ this.calculator.objects.length = 0;
1626
+ beatmaps.forEach((beatmap) => {
1627
+ if (!beatmap) {
1628
+ return;
1629
+ }
1630
+ const difficultyCalculator = Object.assign(osuBase.Utils.deepCopy(this.calculator), { beatmap: beatmap });
1631
+ difficultyCalculator.generateDifficultyHitObjects();
1632
+ difficultyCalculator.objects[0].deltaTime =
1633
+ difficultyCalculator.objects[0].startTime -
1634
+ this.indexedHitObjects[0].object.startTime;
1635
+ difficultyCalculator.objects[0].strainTime = Math.max(25, difficultyCalculator.objects[0].deltaTime);
1636
+ (this.calculator.objects).push(...difficultyCalculator.objects);
1637
+ });
1638
+ this.calculator.objects.sort((a, b) => a.startTime - b.startTime);
1639
+ // Reassign preserved values before calculating.
1640
+ for (let i = 0; i < this.calculator.objects.length; ++i) {
1641
+ const diffObject = this.calculator.objects[i];
1642
+ const indexedHitObject = this.indexedHitObjects[i];
1643
+ const preservedValue = preservedValues[i];
1644
+ diffObject.noteDensity = preservedValue.noteDensity;
1645
+ diffObject.overlappingFactor = preservedValue.overlappingFactor;
1646
+ diffObject.rhythmStrain = preservedValue.rhythmStrain;
1647
+ diffObject.rhythmMultiplier = preservedValue.rhythmMultiplier;
1648
+ // Set slider travel distance to 0 if the slider was cheesed.
1649
+ if (indexedHitObject.sliderCheesed) {
1650
+ diffObject.travelDistance = 0;
1651
+ }
1652
+ }
1653
+ // Do not include rhythm skill.
1654
+ this.calculator.calculateAim();
1655
+ this.calculator.calculateTap();
1656
+ this.calculator.calculateFlashlight();
1657
+ this.calculator.calculateVisual();
1658
+ this.calculator.calculateTotal();
1659
+ }
1450
1660
  }
1451
1661
 
1452
- /**
1453
- * A replay analyzer that analyzes a replay from osu!droid.
1454
- *
1455
- * Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
1456
- *
1457
- * Once analyzed, the result can be accessed via the `data` property.
1458
- */
1459
- class ReplayAnalyzer {
1460
- /**
1461
- * The score ID of the replay.
1462
- */
1463
- scoreID;
1464
- /**
1465
- * The original odr file of the replay.
1466
- */
1467
- originalODR = null;
1468
- /**
1469
- * The fixed odr file of the replay.
1470
- */
1471
- fixedODR = null;
1472
- /**
1473
- * Whether or not the play is considered using >=3 finger abuse.
1474
- */
1475
- is3Finger;
1476
- /**
1477
- * Whether or not the play is considered 2-handed.
1478
- */
1479
- is2Hand;
1480
- /**
1481
- * The beatmap that is being analyzed. `DroidStarRating` or `RebalanceDroidStarRating` is required for three finger or two hand analyzing.
1482
- */
1483
- beatmap;
1484
- /**
1485
- * The results of the analyzer. `null` when initialized.
1486
- */
1487
- data = null;
1488
- /**
1489
- * Penalty value used to penalize dpp for 2-hand.
1490
- */
1491
- aimPenalty = 1;
1492
- /**
1493
- * Penalty value used to penalize dpp for 3 finger abuse.
1494
- */
1495
- tapPenalty = 1;
1496
- /**
1497
- * Whether this replay has been checked against 3 finger usage.
1498
- */
1499
- hasBeenCheckedFor3Finger = false;
1500
- /**
1501
- * Whether this replay has been checked against 2 hand usage.
1502
- */
1503
- hasBeenCheckedFor2Hand = false;
1504
- /**
1505
- * The cursor indexes at which each object was hit.
1506
- *
1507
- * This is filled after 2 hand usage has been checked.
1508
- */
1509
- twoHandCursorIndexes = [];
1510
- // Sizes of primitive data types in Java (in bytes)
1511
- BYTE_LENGTH = 1;
1512
- SHORT_LENGTH = 2;
1513
- INT_LENGTH = 4;
1514
- FLOAT_LENGTH = 4;
1515
- LONG_LENGTH = 8;
1516
- constructor(values) {
1517
- this.scoreID = values.scoreID;
1518
- this.beatmap = values.map;
1519
- }
1520
- /**
1521
- * Analyzes a replay.
1522
- */
1523
- async analyze() {
1524
- if (!this.originalODR && !this.fixedODR) {
1525
- this.originalODR = await this.downloadReplay();
1526
- }
1527
- if (!this.originalODR) {
1528
- return this;
1529
- }
1530
- if (!this.fixedODR) {
1531
- this.fixedODR = await this.decompress().catch(() => null);
1532
- }
1533
- if (!this.fixedODR) {
1534
- return this;
1535
- }
1536
- this.parseReplay();
1537
- return this;
1538
- }
1539
- /**
1540
- * Downloads the given score ID's replay.
1541
- */
1542
- async downloadReplay() {
1543
- const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1544
- .setRequireAPIkey(false)
1545
- .setEndpoint("upload")
1546
- .addParameter("", `${this.scoreID}.odr`);
1547
- const result = await apiRequestBuilder.sendRequest();
1548
- if (result.statusCode !== 200) {
1549
- return null;
1550
- }
1551
- return result.data;
1552
- }
1553
- /**
1554
- * Decompresses a replay.
1555
- *
1556
- * The decompressed replay is in a form of Java object. This will be converted to a buffer and deserialized to read data from the replay.
1557
- */
1558
- decompress() {
1559
- return new Promise((resolve, reject) => {
1560
- const stream$1 = new stream.Readable();
1561
- stream$1.push(this.originalODR);
1562
- stream$1.push(null);
1563
- stream$1
1564
- .pipe(unzipper.Parse())
1565
- .on("entry", async (entry) => {
1566
- const fileName = entry.path;
1567
- if (fileName === "data") {
1568
- return resolve(await entry.buffer());
1569
- }
1570
- else {
1571
- entry.autodrain();
1572
- }
1573
- })
1574
- .on("error", (e) => {
1575
- setTimeout(() => reject(e), 2000);
1576
- });
1577
- });
1578
- }
1579
- /**
1580
- * Parses a replay after being downloaded and converted to a buffer.
1581
- */
1582
- parseReplay() {
1583
- // javaDeserialization can only somewhat parse some string field
1584
- // the rest will be a buffer that we need to manually parse
1585
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
1586
- let rawObject;
1587
- try {
1588
- rawObject = javaDeserialization__namespace.parse(this.fixedODR);
1589
- }
1590
- catch {
1591
- return;
1592
- }
1593
- const resultObject = {
1594
- replayVersion: rawObject[0].version,
1595
- folderName: rawObject[1],
1596
- fileName: rawObject[2],
1597
- hash: rawObject[3],
1598
- cursorMovement: [],
1599
- hitObjectData: [],
1600
- };
1601
- if (resultObject.replayVersion >= 3) {
1602
- resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
1603
- resultObject.hit300k = rawObject[4].readInt32BE(8);
1604
- resultObject.hit100k = rawObject[4].readInt32BE(16);
1605
- resultObject.score = rawObject[4].readInt32BE(32);
1606
- resultObject.maxCombo = rawObject[4].readInt32BE(36);
1607
- resultObject.accuracy = new osuBase.Accuracy({
1608
- n300: rawObject[4].readInt32BE(12),
1609
- n100: rawObject[4].readInt32BE(20),
1610
- n50: rawObject[4].readInt32BE(24),
1611
- nmiss: rawObject[4].readInt32BE(28),
1612
- });
1613
- resultObject.isFullCombo = !!rawObject[4][44];
1614
- resultObject.playerName = rawObject[5];
1615
- resultObject.rawMods = rawObject[6].elements;
1616
- resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
1617
- // Determine rank
1618
- const totalHits = resultObject.accuracy.n300 +
1619
- resultObject.accuracy.n100 +
1620
- resultObject.accuracy.n50 +
1621
- resultObject.accuracy.nmiss;
1622
- const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
1623
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
1624
- switch (true) {
1625
- case resultObject.accuracy.value() === 1:
1626
- if (isHidden) {
1627
- resultObject.rank = "XH";
1628
- }
1629
- else {
1630
- resultObject.rank = "X";
1631
- }
1632
- break;
1633
- case hit300Ratio > 0.9 &&
1634
- resultObject.accuracy.n50 / totalHits < 0.01 &&
1635
- !resultObject.accuracy.nmiss:
1636
- if (isHidden) {
1637
- resultObject.rank = "SH";
1638
- }
1639
- else {
1640
- resultObject.rank = "S";
1641
- }
1642
- break;
1643
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
1644
- hit300Ratio > 0.9:
1645
- resultObject.rank = "A";
1646
- break;
1647
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
1648
- hit300Ratio > 0.8:
1649
- resultObject.rank = "B";
1650
- break;
1651
- case hit300Ratio > 0.6:
1652
- resultObject.rank = "C";
1653
- break;
1654
- default:
1655
- resultObject.rank = "D";
1656
- }
1657
- }
1658
- if (resultObject.replayVersion >= 4) {
1659
- const s = rawObject[7].split("|");
1660
- resultObject.speedModification =
1661
- parseFloat(s[0].replace("x", "")) || 1;
1662
- if (s.length > 1) {
1663
- resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
1664
- }
1665
- }
1666
- let bufferIndex;
1667
- switch (true) {
1668
- // replay v4 and above
1669
- case resultObject.replayVersion >= 4:
1670
- bufferIndex = 8;
1671
- break;
1672
- // replay v3
1673
- case resultObject.replayVersion === 3:
1674
- bufferIndex = 7;
1675
- break;
1676
- // replay v1 and v2
1677
- default:
1678
- bufferIndex = 4;
1679
- }
1680
- const replayDataBufferArray = [];
1681
- while (bufferIndex < rawObject.length) {
1682
- replayDataBufferArray.push(rawObject[bufferIndex++]);
1683
- }
1684
- // Merge all cursor movement and hit object data section into one for better control when parsing
1685
- const replayDataBuffer = Buffer.concat(replayDataBufferArray);
1686
- let bufferCounter = 0;
1687
- const size = replayDataBuffer.readInt32BE(bufferCounter);
1688
- bufferCounter += this.INT_LENGTH;
1689
- // Parse movement data
1690
- for (let x = 0; x < size; x++) {
1691
- const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
1692
- bufferCounter += this.INT_LENGTH;
1693
- const time = [];
1694
- const x = [];
1695
- const y = [];
1696
- const id = [];
1697
- for (let i = 0; i < moveSize; i++) {
1698
- time[i] = replayDataBuffer.readInt32BE(bufferCounter);
1699
- bufferCounter += this.INT_LENGTH;
1700
- id[i] = time[i] & 3;
1701
- time[i] >>= 2;
1702
- if (id[i] !== exports.movementType.UP) {
1703
- if (resultObject.replayVersion >= 5) {
1704
- x[i] = replayDataBuffer.readFloatBE(bufferCounter);
1705
- bufferCounter += this.FLOAT_LENGTH;
1706
- y[i] = replayDataBuffer.readFloatBE(bufferCounter);
1707
- bufferCounter += this.FLOAT_LENGTH;
1708
- }
1709
- else {
1710
- x[i] = replayDataBuffer.readInt16BE(bufferCounter);
1711
- bufferCounter += this.SHORT_LENGTH;
1712
- y[i] = replayDataBuffer.readInt16BE(bufferCounter);
1713
- bufferCounter += this.SHORT_LENGTH;
1714
- }
1715
- }
1716
- else {
1717
- x[i] = -1;
1718
- y[i] = -1;
1719
- }
1720
- }
1721
- resultObject.cursorMovement.push(new CursorData({
1722
- size: moveSize,
1723
- time: time,
1724
- x: x,
1725
- y: y,
1726
- id: id,
1727
- }));
1728
- }
1729
- const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
1730
- bufferCounter += this.INT_LENGTH;
1731
- // Parse result data
1732
- for (let i = 0; i < replayObjectLength; i++) {
1733
- const replayObjectData = {
1734
- accuracy: 0,
1735
- tickset: [],
1736
- result: 0,
1737
- };
1738
- replayObjectData.accuracy =
1739
- replayDataBuffer.readInt16BE(bufferCounter);
1740
- bufferCounter += this.SHORT_LENGTH;
1741
- const len = replayDataBuffer.readInt8(bufferCounter);
1742
- bufferCounter += this.BYTE_LENGTH;
1743
- if (len > 0) {
1744
- const bytes = [];
1745
- for (let j = 0; j < len; j++) {
1746
- bytes.push(replayDataBuffer.readInt8(bufferCounter));
1747
- bufferCounter += this.BYTE_LENGTH;
1748
- }
1749
- // Int/int division in Java; numbers must be truncated to get actual number
1750
- for (let j = 0; j < len * 8; j++) {
1751
- replayObjectData.tickset[j] =
1752
- (bytes[len - Math.trunc(j / 8) - 1] &
1753
- (1 << Math.trunc(j % 8))) !==
1754
- 0;
1755
- }
1756
- }
1757
- if (resultObject.replayVersion >= 1) {
1758
- replayObjectData.result =
1759
- replayDataBuffer.readInt8(bufferCounter);
1760
- bufferCounter += this.BYTE_LENGTH;
1761
- }
1762
- resultObject.hitObjectData.push(replayObjectData);
1763
- }
1764
- // Parse max combo, hit results, and accuracy in old replay version
1765
- if (resultObject.replayVersion < 3 && this.beatmap) {
1766
- let hit300 = 0;
1767
- let hit300k = 0;
1768
- let hit100 = 0;
1769
- let hit100k = 0;
1770
- let hit50 = 0;
1771
- let hit0 = 0;
1772
- let grantsGekiOrKatu = true;
1773
- const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
1774
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
1775
- ? this.beatmap.beatmap
1776
- : this.beatmap).hitObjects.objects;
1777
- for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
1778
- // Hit result
1779
- const hitObjectData = resultObject.hitObjectData[i];
1780
- const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
1781
- switch (hitObjectData.result) {
1782
- case exports.hitResult.RESULT_0:
1783
- ++hit0;
1784
- grantsGekiOrKatu = false;
1785
- break;
1786
- case exports.hitResult.RESULT_50:
1787
- ++hit50;
1788
- grantsGekiOrKatu = false;
1789
- break;
1790
- case exports.hitResult.RESULT_100:
1791
- ++hit100;
1792
- if (grantsGekiOrKatu && isNextNewCombo) {
1793
- ++hit100k;
1794
- }
1795
- break;
1796
- case exports.hitResult.RESULT_300:
1797
- ++hit300;
1798
- if (grantsGekiOrKatu && isNextNewCombo) {
1799
- ++hit300k;
1800
- }
1801
- break;
1802
- }
1803
- if (isNextNewCombo) {
1804
- grantsGekiOrKatu = true;
1805
- }
1806
- }
1807
- resultObject.hit300k = hit300k;
1808
- resultObject.hit100k = hit100k;
1809
- resultObject.accuracy = new osuBase.Accuracy({
1810
- n300: hit300,
1811
- n100: hit100,
1812
- n50: hit50,
1813
- nmiss: hit0,
1814
- nobjects: hit300 + hit100 + hit50 + hit0,
1815
- });
1816
- // Determine rank
1817
- const totalHits = resultObject.accuracy.n300 +
1818
- resultObject.accuracy.n100 +
1819
- resultObject.accuracy.n50 +
1820
- resultObject.accuracy.nmiss;
1821
- const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
1822
- const hit300Ratio = resultObject.accuracy.n300 / totalHits;
1823
- switch (true) {
1824
- case resultObject.accuracy.value() === 1:
1825
- if (isHidden) {
1826
- resultObject.rank = "XH";
1827
- }
1828
- else {
1829
- resultObject.rank = "X";
1830
- }
1831
- break;
1832
- case hit300Ratio > 0.9 &&
1833
- resultObject.accuracy.n50 / totalHits < 0.01 &&
1834
- !resultObject.accuracy.nmiss:
1835
- if (isHidden) {
1836
- resultObject.rank = "SH";
1837
- }
1838
- else {
1839
- resultObject.rank = "S";
1840
- }
1841
- break;
1842
- case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
1843
- hit300Ratio > 0.9:
1844
- resultObject.rank = "A";
1845
- break;
1846
- case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
1847
- hit300Ratio > 0.8:
1848
- resultObject.rank = "B";
1849
- break;
1850
- case hit300Ratio > 0.6:
1851
- resultObject.rank = "C";
1852
- break;
1853
- default:
1854
- resultObject.rank = "D";
1855
- }
1856
- }
1857
- this.data = new ReplayData(resultObject);
1858
- }
1859
- /**
1860
- * Gets hit error information of the replay.
1861
- *
1862
- * `analyze()` must be called before calling this.
1863
- */
1864
- calculateHitError() {
1865
- if (!this.data || !this.beatmap) {
1866
- return null;
1867
- }
1868
- const hitObjectData = this.data.hitObjectData;
1869
- let positiveCount = 0;
1870
- let negativeCount = 0;
1871
- let positiveTotal = 0;
1872
- let negativeTotal = 0;
1873
- const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
1874
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
1875
- ? this.beatmap.beatmap
1876
- : this.beatmap).hitObjects.objects;
1877
- for (let i = 0; i < hitObjectData.length; ++i) {
1878
- const v = hitObjectData[i];
1879
- const o = objects[i];
1880
- if (o instanceof osuBase.Spinner || v.result === exports.hitResult.RESULT_0) {
1881
- continue;
1882
- }
1883
- const accuracy = v.accuracy;
1884
- if (accuracy >= 0) {
1885
- positiveTotal += accuracy;
1886
- ++positiveCount;
1887
- }
1888
- else {
1889
- negativeTotal += accuracy;
1890
- ++negativeCount;
1891
- }
1892
- }
1893
- return {
1894
- positiveAvg: positiveTotal / positiveCount || 0,
1895
- negativeAvg: negativeTotal / negativeCount || 0,
1896
- unstableRate: osuBase.MathUtils.calculateStandardDeviation(hitObjectData.map((v, i) => v.result !== exports.hitResult.RESULT_0 &&
1897
- !(objects[i] instanceof osuBase.Spinner)
1898
- ? v.accuracy
1899
- : 0)) * 10,
1900
- };
1901
- }
1902
- /**
1903
- * Converts replay mods to droid mod string.
1904
- */
1905
- convertDroidMods(replayMods) {
1906
- const replayModsConstants = {
1907
- MOD_NOFAIL: "n",
1908
- MOD_EASY: "e",
1909
- MOD_HIDDEN: "h",
1910
- MOD_HARDROCK: "r",
1911
- MOD_DOUBLETIME: "d",
1912
- MOD_HALFTIME: "t",
1913
- MOD_NIGHTCORE: "c",
1914
- MOD_PRECISE: "s",
1915
- MOD_SMALLCIRCLE: "m",
1916
- MOD_SPEEDUP: "b",
1917
- MOD_REALLYEASY: "l",
1918
- MOD_PERFECT: "f",
1919
- MOD_SUDDENDEATH: "u",
1920
- MOD_SCOREV2: "v",
1921
- };
1922
- let modString = "";
1923
- for (const mod of replayMods) {
1924
- for (const property in replayModsConstants) {
1925
- if (!(property in replayModsConstants)) {
1926
- continue;
1927
- }
1928
- if (!mod.includes(property)) {
1929
- continue;
1930
- }
1931
- modString +=
1932
- replayModsConstants[property];
1933
- break;
1934
- }
1935
- }
1936
- return modString;
1937
- }
1938
- /**
1939
- * Checks if a play is using 3 fingers.
1940
- *
1941
- * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
1942
- */
1943
- checkFor3Finger() {
1944
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
1945
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
1946
- !this.data) {
1947
- return;
1948
- }
1949
- const threeFingerChecker = new ThreeFingerChecker(this.beatmap, this.data);
1950
- const result = threeFingerChecker.check();
1951
- this.is3Finger = result.is3Finger;
1952
- this.tapPenalty = result.penalty;
1953
- this.hasBeenCheckedFor3Finger = true;
1954
- }
1955
- /**
1956
- * Checks if a play is using 2 hands.
1957
- *
1958
- * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
1959
- */
1960
- checkFor2Hand() {
1961
- if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
1962
- this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
1963
- !this.data) {
1964
- return;
1965
- }
1966
- const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
1967
- const result = twoHandChecker.check();
1968
- this.is2Hand = result.is2Hand;
1969
- this.twoHandCursorIndexes = result.cursorIndexes;
1970
- this.hasBeenCheckedFor2Hand = true;
1971
- }
1662
+ /**
1663
+ * A replay analyzer that analyzes a replay from osu!droid.
1664
+ *
1665
+ * Created by reverse engineering the replay parser from the game itself, which can be found {@link https://github.com/osudroid/osu-droid/blob/master/src/ru/nsu/ccfit/zuev/osu/scoring/Replay.java here}.
1666
+ *
1667
+ * Once analyzed, the result can be accessed via the `data` property.
1668
+ */
1669
+ class ReplayAnalyzer {
1670
+ /**
1671
+ * The score ID of the replay.
1672
+ */
1673
+ scoreID;
1674
+ /**
1675
+ * The original odr file of the replay.
1676
+ */
1677
+ originalODR = null;
1678
+ /**
1679
+ * The fixed odr file of the replay.
1680
+ */
1681
+ fixedODR = null;
1682
+ /**
1683
+ * Whether or not the play is considered using >=3 finger abuse.
1684
+ */
1685
+ is3Finger;
1686
+ /**
1687
+ * Whether or not the play is considered 2-handed.
1688
+ */
1689
+ is2Hand;
1690
+ /**
1691
+ * The beatmap that is being analyzed. `DroidStarRating` or `RebalanceDroidStarRating` is required for three finger or two hand analyzing.
1692
+ */
1693
+ beatmap;
1694
+ /**
1695
+ * The results of the analyzer. `null` when initialized.
1696
+ */
1697
+ data = null;
1698
+ /**
1699
+ * Penalty value used to penalize dpp for 2-hand.
1700
+ */
1701
+ aimPenalty = 1;
1702
+ /**
1703
+ * Penalty value used to penalize dpp for 3 finger abuse.
1704
+ */
1705
+ tapPenalty = 1;
1706
+ /**
1707
+ * Whether this replay has been checked against 3 finger usage.
1708
+ */
1709
+ hasBeenCheckedFor3Finger = false;
1710
+ /**
1711
+ * Whether this replay has been checked against 2 hand usage.
1712
+ */
1713
+ hasBeenCheckedFor2Hand = false;
1714
+ /**
1715
+ * The cursor indexes at which each object was hit.
1716
+ *
1717
+ * This is filled after 2 hand usage has been checked.
1718
+ */
1719
+ twoHandCursorIndexes = [];
1720
+ // Sizes of primitive data types in Java (in bytes)
1721
+ BYTE_LENGTH = 1;
1722
+ SHORT_LENGTH = 2;
1723
+ INT_LENGTH = 4;
1724
+ FLOAT_LENGTH = 4;
1725
+ LONG_LENGTH = 8;
1726
+ constructor(values) {
1727
+ this.scoreID = values.scoreID;
1728
+ this.beatmap = values.map;
1729
+ }
1730
+ /**
1731
+ * Analyzes a replay.
1732
+ */
1733
+ async analyze() {
1734
+ if (!this.originalODR && !this.fixedODR) {
1735
+ this.originalODR = await this.downloadReplay();
1736
+ }
1737
+ if (!this.originalODR) {
1738
+ return this;
1739
+ }
1740
+ if (!this.fixedODR) {
1741
+ this.fixedODR = await this.decompress().catch(() => null);
1742
+ }
1743
+ if (!this.fixedODR) {
1744
+ return this;
1745
+ }
1746
+ this.parseReplay();
1747
+ return this;
1748
+ }
1749
+ /**
1750
+ * Downloads the given score ID's replay.
1751
+ */
1752
+ async downloadReplay() {
1753
+ const apiRequestBuilder = new osuBase.DroidAPIRequestBuilder()
1754
+ .setRequireAPIkey(false)
1755
+ .setEndpoint("upload")
1756
+ .addParameter("", `${this.scoreID}.odr`);
1757
+ const result = await apiRequestBuilder.sendRequest();
1758
+ if (result.statusCode !== 200) {
1759
+ return null;
1760
+ }
1761
+ return result.data;
1762
+ }
1763
+ /**
1764
+ * Decompresses a replay.
1765
+ *
1766
+ * The decompressed replay is in a form of Java object. This will be converted to a buffer and deserialized to read data from the replay.
1767
+ */
1768
+ decompress() {
1769
+ return new Promise((resolve, reject) => {
1770
+ const stream$1 = new stream.Readable();
1771
+ stream$1.push(this.originalODR);
1772
+ stream$1.push(null);
1773
+ stream$1
1774
+ .pipe(unzipper.Parse())
1775
+ .on("entry", async (entry) => {
1776
+ const fileName = entry.path;
1777
+ if (fileName === "data") {
1778
+ return resolve(await entry.buffer());
1779
+ }
1780
+ else {
1781
+ entry.autodrain();
1782
+ }
1783
+ })
1784
+ .on("error", (e) => {
1785
+ setTimeout(() => reject(e), 2000);
1786
+ });
1787
+ });
1788
+ }
1789
+ /**
1790
+ * Parses a replay after being downloaded and converted to a buffer.
1791
+ */
1792
+ parseReplay() {
1793
+ // javaDeserialization can only somewhat parse some string field
1794
+ // the rest will be a buffer that we need to manually parse
1795
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1796
+ let rawObject;
1797
+ try {
1798
+ rawObject = javaDeserialization__namespace.parse(this.fixedODR);
1799
+ }
1800
+ catch {
1801
+ return;
1802
+ }
1803
+ const resultObject = {
1804
+ replayVersion: rawObject[0].version,
1805
+ folderName: rawObject[1],
1806
+ fileName: rawObject[2],
1807
+ hash: rawObject[3],
1808
+ cursorMovement: [],
1809
+ hitObjectData: [],
1810
+ };
1811
+ if (resultObject.replayVersion >= 3) {
1812
+ resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
1813
+ resultObject.hit300k = rawObject[4].readInt32BE(8);
1814
+ resultObject.hit100k = rawObject[4].readInt32BE(16);
1815
+ resultObject.score = rawObject[4].readInt32BE(32);
1816
+ resultObject.maxCombo = rawObject[4].readInt32BE(36);
1817
+ resultObject.accuracy = new osuBase.Accuracy({
1818
+ n300: rawObject[4].readInt32BE(12),
1819
+ n100: rawObject[4].readInt32BE(20),
1820
+ n50: rawObject[4].readInt32BE(24),
1821
+ nmiss: rawObject[4].readInt32BE(28),
1822
+ });
1823
+ resultObject.isFullCombo = !!rawObject[4][44];
1824
+ resultObject.playerName = rawObject[5];
1825
+ resultObject.rawMods = rawObject[6].elements;
1826
+ resultObject.convertedMods = osuBase.ModUtil.droidStringToMods(this.convertDroidMods(rawObject[6].elements));
1827
+ // Determine rank
1828
+ const totalHits = resultObject.accuracy.n300 +
1829
+ resultObject.accuracy.n100 +
1830
+ resultObject.accuracy.n50 +
1831
+ resultObject.accuracy.nmiss;
1832
+ const isHidden = resultObject.convertedMods.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight);
1833
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
1834
+ switch (true) {
1835
+ case resultObject.accuracy.value() === 1:
1836
+ if (isHidden) {
1837
+ resultObject.rank = "XH";
1838
+ }
1839
+ else {
1840
+ resultObject.rank = "X";
1841
+ }
1842
+ break;
1843
+ case hit300Ratio > 0.9 &&
1844
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
1845
+ !resultObject.accuracy.nmiss:
1846
+ if (isHidden) {
1847
+ resultObject.rank = "SH";
1848
+ }
1849
+ else {
1850
+ resultObject.rank = "S";
1851
+ }
1852
+ break;
1853
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
1854
+ hit300Ratio > 0.9:
1855
+ resultObject.rank = "A";
1856
+ break;
1857
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
1858
+ hit300Ratio > 0.8:
1859
+ resultObject.rank = "B";
1860
+ break;
1861
+ case hit300Ratio > 0.6:
1862
+ resultObject.rank = "C";
1863
+ break;
1864
+ default:
1865
+ resultObject.rank = "D";
1866
+ }
1867
+ }
1868
+ if (resultObject.replayVersion >= 4) {
1869
+ const s = rawObject[7].split("|");
1870
+ resultObject.speedModification =
1871
+ parseFloat(s[0].replace("x", "")) || 1;
1872
+ if (s.length > 1) {
1873
+ resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
1874
+ }
1875
+ }
1876
+ let bufferIndex;
1877
+ switch (true) {
1878
+ // replay v4 and above
1879
+ case resultObject.replayVersion >= 4:
1880
+ bufferIndex = 8;
1881
+ break;
1882
+ // replay v3
1883
+ case resultObject.replayVersion === 3:
1884
+ bufferIndex = 7;
1885
+ break;
1886
+ // replay v1 and v2
1887
+ default:
1888
+ bufferIndex = 4;
1889
+ }
1890
+ const replayDataBufferArray = [];
1891
+ while (bufferIndex < rawObject.length) {
1892
+ replayDataBufferArray.push(rawObject[bufferIndex++]);
1893
+ }
1894
+ // Merge all cursor movement and hit object data section into one for better control when parsing
1895
+ const replayDataBuffer = Buffer.concat(replayDataBufferArray);
1896
+ let bufferCounter = 0;
1897
+ const size = replayDataBuffer.readInt32BE(bufferCounter);
1898
+ bufferCounter += this.INT_LENGTH;
1899
+ // Parse movement data
1900
+ for (let x = 0; x < size; x++) {
1901
+ const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
1902
+ bufferCounter += this.INT_LENGTH;
1903
+ const time = [];
1904
+ const x = [];
1905
+ const y = [];
1906
+ const id = [];
1907
+ for (let i = 0; i < moveSize; i++) {
1908
+ time[i] = replayDataBuffer.readInt32BE(bufferCounter);
1909
+ bufferCounter += this.INT_LENGTH;
1910
+ id[i] = time[i] & 3;
1911
+ time[i] >>= 2;
1912
+ if (id[i] !== exports.MovementType.up) {
1913
+ if (resultObject.replayVersion >= 5) {
1914
+ x[i] = replayDataBuffer.readFloatBE(bufferCounter);
1915
+ bufferCounter += this.FLOAT_LENGTH;
1916
+ y[i] = replayDataBuffer.readFloatBE(bufferCounter);
1917
+ bufferCounter += this.FLOAT_LENGTH;
1918
+ }
1919
+ else {
1920
+ x[i] = replayDataBuffer.readInt16BE(bufferCounter);
1921
+ bufferCounter += this.SHORT_LENGTH;
1922
+ y[i] = replayDataBuffer.readInt16BE(bufferCounter);
1923
+ bufferCounter += this.SHORT_LENGTH;
1924
+ }
1925
+ }
1926
+ else {
1927
+ x[i] = -1;
1928
+ y[i] = -1;
1929
+ }
1930
+ }
1931
+ resultObject.cursorMovement.push(new CursorData({
1932
+ size: moveSize,
1933
+ time: time,
1934
+ x: x,
1935
+ y: y,
1936
+ id: id,
1937
+ }));
1938
+ }
1939
+ const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
1940
+ bufferCounter += this.INT_LENGTH;
1941
+ // Parse result data
1942
+ for (let i = 0; i < replayObjectLength; i++) {
1943
+ const replayObjectData = {
1944
+ accuracy: 0,
1945
+ tickset: [],
1946
+ result: 0,
1947
+ };
1948
+ replayObjectData.accuracy =
1949
+ replayDataBuffer.readInt16BE(bufferCounter);
1950
+ bufferCounter += this.SHORT_LENGTH;
1951
+ const len = replayDataBuffer.readInt8(bufferCounter);
1952
+ bufferCounter += this.BYTE_LENGTH;
1953
+ if (len > 0) {
1954
+ const bytes = [];
1955
+ for (let j = 0; j < len; j++) {
1956
+ bytes.push(replayDataBuffer.readInt8(bufferCounter));
1957
+ bufferCounter += this.BYTE_LENGTH;
1958
+ }
1959
+ // Int/int division in Java; numbers must be truncated to get actual number
1960
+ for (let j = 0; j < len * 8; j++) {
1961
+ replayObjectData.tickset.push((bytes[len - Math.trunc(j / 8) - 1] &
1962
+ (1 << Math.trunc(j % 8))) !==
1963
+ 0);
1964
+ }
1965
+ }
1966
+ if (resultObject.replayVersion >= 1) {
1967
+ replayObjectData.result =
1968
+ replayDataBuffer.readInt8(bufferCounter);
1969
+ bufferCounter += this.BYTE_LENGTH;
1970
+ }
1971
+ resultObject.hitObjectData.push(replayObjectData);
1972
+ }
1973
+ // Parse max combo, hit results, and accuracy in old replay version
1974
+ if (resultObject.replayVersion < 3 && this.beatmap) {
1975
+ let hit300 = 0;
1976
+ let hit300k = 0;
1977
+ let hit100 = 0;
1978
+ let hit100k = 0;
1979
+ let hit50 = 0;
1980
+ let hit0 = 0;
1981
+ let grantsGekiOrKatu = true;
1982
+ const objects = (this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
1983
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
1984
+ ? this.beatmap.beatmap
1985
+ : this.beatmap).hitObjects.objects;
1986
+ for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
1987
+ // Hit result
1988
+ const hitObjectData = resultObject.hitObjectData[i];
1989
+ const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
1990
+ switch (hitObjectData.result) {
1991
+ case exports.HitResult.miss:
1992
+ ++hit0;
1993
+ grantsGekiOrKatu = false;
1994
+ break;
1995
+ case exports.HitResult.meh:
1996
+ ++hit50;
1997
+ grantsGekiOrKatu = false;
1998
+ break;
1999
+ case exports.HitResult.good:
2000
+ ++hit100;
2001
+ if (grantsGekiOrKatu && isNextNewCombo) {
2002
+ ++hit100k;
2003
+ }
2004
+ break;
2005
+ case exports.HitResult.great:
2006
+ ++hit300;
2007
+ if (grantsGekiOrKatu && isNextNewCombo) {
2008
+ ++hit300k;
2009
+ }
2010
+ break;
2011
+ }
2012
+ if (isNextNewCombo) {
2013
+ grantsGekiOrKatu = true;
2014
+ }
2015
+ }
2016
+ resultObject.hit300k = hit300k;
2017
+ resultObject.hit100k = hit100k;
2018
+ resultObject.accuracy = new osuBase.Accuracy({
2019
+ n300: hit300,
2020
+ n100: hit100,
2021
+ n50: hit50,
2022
+ nmiss: hit0,
2023
+ nobjects: hit300 + hit100 + hit50 + hit0,
2024
+ });
2025
+ // Determine rank
2026
+ const totalHits = resultObject.accuracy.n300 +
2027
+ resultObject.accuracy.n100 +
2028
+ resultObject.accuracy.n50 +
2029
+ resultObject.accuracy.nmiss;
2030
+ const isHidden = resultObject.convertedMods?.some((m) => m instanceof osuBase.ModHidden || m instanceof osuBase.ModFlashlight) ?? false;
2031
+ const hit300Ratio = resultObject.accuracy.n300 / totalHits;
2032
+ switch (true) {
2033
+ case resultObject.accuracy.value() === 1:
2034
+ if (isHidden) {
2035
+ resultObject.rank = "XH";
2036
+ }
2037
+ else {
2038
+ resultObject.rank = "X";
2039
+ }
2040
+ break;
2041
+ case hit300Ratio > 0.9 &&
2042
+ resultObject.accuracy.n50 / totalHits < 0.01 &&
2043
+ !resultObject.accuracy.nmiss:
2044
+ if (isHidden) {
2045
+ resultObject.rank = "SH";
2046
+ }
2047
+ else {
2048
+ resultObject.rank = "S";
2049
+ }
2050
+ break;
2051
+ case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
2052
+ hit300Ratio > 0.9:
2053
+ resultObject.rank = "A";
2054
+ break;
2055
+ case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
2056
+ hit300Ratio > 0.8:
2057
+ resultObject.rank = "B";
2058
+ break;
2059
+ case hit300Ratio > 0.6:
2060
+ resultObject.rank = "C";
2061
+ break;
2062
+ default:
2063
+ resultObject.rank = "D";
2064
+ }
2065
+ }
2066
+ this.data = new ReplayData(resultObject);
2067
+ }
2068
+ /**
2069
+ * Gets hit error information of the replay.
2070
+ *
2071
+ * `analyze()` must be called before calling this.
2072
+ */
2073
+ calculateHitError() {
2074
+ if (!this.data || !this.beatmap) {
2075
+ return null;
2076
+ }
2077
+ const hitObjectData = this.data.hitObjectData;
2078
+ let positiveCount = 0;
2079
+ let negativeCount = 0;
2080
+ let positiveTotal = 0;
2081
+ let negativeTotal = 0;
2082
+ const beatmap = this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2083
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator
2084
+ ? this.beatmap.beatmap
2085
+ : this.beatmap;
2086
+ const objects = beatmap.hitObjects.objects;
2087
+ const stats = new osuBase.MapStats({
2088
+ od: beatmap.difficulty.od,
2089
+ mods: this.data.convertedMods.filter((m) => !osuBase.ModUtil.speedChangingMods.some((v) => v.acronym === m.acronym)),
2090
+ }).calculate();
2091
+ const hitWindow50 = new osuBase.DroidHitWindow(stats.od).hitWindowFor50(this.data.convertedMods.some((m) => m instanceof osuBase.ModPrecise));
2092
+ // The accuracy of sliders is set to (50 hit window)ms + 13ms if their head was not hit:
2093
+ // https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821
2094
+ //
2095
+ // In such cases, the slider is skipped.
2096
+ const sliderbreakHitOffset = Math.floor(hitWindow50) + 13;
2097
+ const accuracies = [];
2098
+ for (let i = 0; i < hitObjectData.length; ++i) {
2099
+ const v = hitObjectData[i];
2100
+ const o = objects[i];
2101
+ if (o instanceof osuBase.Spinner || v.result === exports.HitResult.miss) {
2102
+ accuracies.push(0);
2103
+ continue;
2104
+ }
2105
+ const accuracy = v.accuracy;
2106
+ if (o instanceof osuBase.Slider && v.accuracy === sliderbreakHitOffset) {
2107
+ accuracies.push(0);
2108
+ continue;
2109
+ }
2110
+ accuracies.push(accuracy);
2111
+ if (accuracy >= 0) {
2112
+ positiveTotal += accuracy;
2113
+ ++positiveCount;
2114
+ }
2115
+ else {
2116
+ negativeTotal += accuracy;
2117
+ ++negativeCount;
2118
+ }
2119
+ }
2120
+ return {
2121
+ positiveAvg: positiveTotal / positiveCount || 0,
2122
+ negativeAvg: negativeTotal / negativeCount || 0,
2123
+ unstableRate: osuBase.MathUtils.calculateStandardDeviation(accuracies) * 10,
2124
+ };
2125
+ }
2126
+ /**
2127
+ * Converts replay mods to droid mod string.
2128
+ */
2129
+ convertDroidMods(replayMods) {
2130
+ const replayModsConstants = {
2131
+ MOD_NOFAIL: "n",
2132
+ MOD_EASY: "e",
2133
+ MOD_HIDDEN: "h",
2134
+ MOD_HARDROCK: "r",
2135
+ MOD_DOUBLETIME: "d",
2136
+ MOD_HALFTIME: "t",
2137
+ MOD_NIGHTCORE: "c",
2138
+ MOD_PRECISE: "s",
2139
+ MOD_SMALLCIRCLE: "m",
2140
+ MOD_SPEEDUP: "b",
2141
+ MOD_REALLYEASY: "l",
2142
+ MOD_PERFECT: "f",
2143
+ MOD_SUDDENDEATH: "u",
2144
+ MOD_SCOREV2: "v",
2145
+ };
2146
+ let modString = "";
2147
+ for (const mod of replayMods) {
2148
+ for (const property in replayModsConstants) {
2149
+ if (!(property in replayModsConstants)) {
2150
+ continue;
2151
+ }
2152
+ if (!mod.includes(property)) {
2153
+ continue;
2154
+ }
2155
+ modString +=
2156
+ replayModsConstants[property];
2157
+ break;
2158
+ }
2159
+ }
2160
+ return modString;
2161
+ }
2162
+ /**
2163
+ * Checks if a play is using 3 fingers.
2164
+ *
2165
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
2166
+ */
2167
+ checkFor3Finger() {
2168
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2169
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2170
+ !this.data) {
2171
+ return;
2172
+ }
2173
+ const threeFingerChecker = new ThreeFingerChecker(this.beatmap, this.data);
2174
+ const result = threeFingerChecker.check();
2175
+ this.is3Finger = result.is3Finger;
2176
+ this.tapPenalty = result.penalty;
2177
+ this.hasBeenCheckedFor3Finger = true;
2178
+ }
2179
+ /**
2180
+ * Checks if a play is using 2 hands.
2181
+ *
2182
+ * Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
2183
+ */
2184
+ checkFor2Hand() {
2185
+ if (!(this.beatmap instanceof osuDifficultyCalculator.DroidDifficultyCalculator ||
2186
+ this.beatmap instanceof osuRebalanceDifficultyCalculator.DroidDifficultyCalculator) ||
2187
+ !this.data) {
2188
+ return;
2189
+ }
2190
+ const twoHandChecker = new TwoHandChecker(this.beatmap, this.data);
2191
+ const result = twoHandChecker.check();
2192
+ this.is2Hand = result.is2Hand;
2193
+ this.twoHandCursorIndexes = result.cursorIndexes;
2194
+ this.hasBeenCheckedFor2Hand = true;
2195
+ }
1972
2196
  }
1973
2197
 
1974
- /**
1975
- * Represents a hitobject in an osu!droid replay.
1976
- *
1977
- * Stores information about hitobjects in an osu!droid replay such as hit offset, tickset, and hit result.
1978
- *
1979
- * This is used when analyzing replays using replay analyzer.
1980
- */
1981
- class ReplayObjectData {
1982
- /**
1983
- * The offset of which the hitobject was hit in milliseconds.
1984
- */
1985
- accuracy;
1986
- /**
1987
- * The tickset of the hitobject.
1988
- *
1989
- * This is used to determine whether or not a slider event (tick/repeat/end) is hit based on the order they appear.
1990
- */
1991
- tickset;
1992
- /**
1993
- * The bitwise hit result of the hitobject.
1994
- */
1995
- result;
1996
- constructor(values) {
1997
- this.accuracy = values.accuracy;
1998
- this.tickset = values.tickset;
1999
- this.result = values.result;
2000
- }
2198
+ /**
2199
+ * Represents a hitobject in an osu!droid replay.
2200
+ *
2201
+ * Stores information about hitobjects in an osu!droid replay such as hit offset, tickset, and hit result.
2202
+ *
2203
+ * This is used when analyzing replays using replay analyzer.
2204
+ */
2205
+ class ReplayObjectData {
2206
+ /**
2207
+ * For circles, this is the offset at which the circle was hit. If the hit accuracy is 10000, it means the circle was tapped too late ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/HitCircle.java#L298-L306)).
2208
+ *
2209
+ * For sliders, this is the offset at which the slider head was hit. For
2210
+ * sliderbreaks, the accuracy would be `(hit window 50)ms + 13ms` ([game source code](https://github.com/osudroid/osu-droid/blob/6306c68e3ffaf671eac794bf45cc95c0f3313a82/src/ru/nsu/ccfit/zuev/osu/game/Slider.java#L821)).
2211
+ *
2212
+ * For spinners, this is the total amount at which the spinner was spinned:
2213
+ * ```js
2214
+ * const rotations = Math.floor(data.accuracy / 4);
2215
+ * ```
2216
+ * The remainder of the division denotes the hit result of the spinner:
2217
+ * - `HitResult.great`: 3
2218
+ * - `HitResult.good`: 2
2219
+ * - `HitResult.meh`: 1
2220
+ * - `HitResult.miss`: 0
2221
+ */
2222
+ accuracy;
2223
+ /**
2224
+ * The tickset of the hitobject.
2225
+ *
2226
+ * This is used to determine whether or not a slider event (tick, repeat, and end) is hit based on the order they appear.
2227
+ */
2228
+ tickset;
2229
+ /**
2230
+ * The bitwise hit result of the hitobject.
2231
+ */
2232
+ result;
2233
+ constructor(values) {
2234
+ this.accuracy = values.accuracy;
2235
+ this.tickset = values.tickset;
2236
+ this.result = values.result;
2237
+ }
2001
2238
  }
2002
2239
 
2003
2240
  exports.CursorData = CursorData;
2004
2241
  exports.CursorOccurrence = CursorOccurrence;
2242
+ exports.CursorOccurrenceGroup = CursorOccurrenceGroup;
2005
2243
  exports.ReplayAnalyzer = ReplayAnalyzer;
2006
2244
  exports.ReplayData = ReplayData;
2007
2245
  exports.ReplayObjectData = ReplayObjectData;