@rian8337/osu-droid-replay-analyzer 1.0.0
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/LICENSE +21 -0
- package/README.md +11 -0
- package/dist/ReplayAnalyzer.js +540 -0
- package/dist/analysis/BeatmapSectionGenerator.js +47 -0
- package/dist/analysis/ThreeFingerChecker.js +684 -0
- package/dist/analysis/TwoHandChecker.js +237 -0
- package/dist/analysis/data/BeatmapSection.js +17 -0
- package/dist/analysis/data/ThreeFingerBeatmapSection.js +15 -0
- package/dist/analysis/objects/IndexedHitObject.js +17 -0
- package/dist/constants/hitResult.js +25 -0
- package/dist/constants/movementType.js +12 -0
- package/dist/data/CursorData.js +20 -0
- package/dist/data/ReplayData.js +35 -0
- package/dist/data/ReplayObjectData.js +18 -0
- package/dist/index.js +19 -0
- package/package.json +47 -0
- package/typings/index.d.ts +565 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2021 Rian8337
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,540 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
Object.defineProperty(o, k2, { enumerable: true, get: function() { return m[k]; } });
|
|
5
|
+
}) : (function(o, m, k, k2) {
|
|
6
|
+
if (k2 === undefined) k2 = k;
|
|
7
|
+
o[k2] = m[k];
|
|
8
|
+
}));
|
|
9
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
10
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
11
|
+
}) : function(o, v) {
|
|
12
|
+
o["default"] = v;
|
|
13
|
+
});
|
|
14
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
15
|
+
if (mod && mod.__esModule) return mod;
|
|
16
|
+
var result = {};
|
|
17
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
18
|
+
__setModuleDefault(result, mod);
|
|
19
|
+
return result;
|
|
20
|
+
};
|
|
21
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
22
|
+
exports.ReplayAnalyzer = void 0;
|
|
23
|
+
const osu_base_1 = require("@rian8337/osu-base");
|
|
24
|
+
const osu_difficulty_calculator_1 = require("@rian8337/osu-difficulty-calculator");
|
|
25
|
+
const osu_rebalance_difficulty_calculator_1 = require("@rian8337/osu-rebalance-difficulty-calculator");
|
|
26
|
+
const unzipper_1 = require("unzipper");
|
|
27
|
+
const javaDeserialization = __importStar(require("java-deserialization"));
|
|
28
|
+
const stream_1 = require("stream");
|
|
29
|
+
const ReplayData_1 = require("./data/ReplayData");
|
|
30
|
+
const ThreeFingerChecker_1 = require("./analysis/ThreeFingerChecker");
|
|
31
|
+
const TwoHandChecker_1 = require("./analysis/TwoHandChecker");
|
|
32
|
+
const movementType_1 = require("./constants/movementType");
|
|
33
|
+
const hitResult_1 = require("./constants/hitResult");
|
|
34
|
+
/**
|
|
35
|
+
* A replay analyzer that analyzes a replay from osu!droid.
|
|
36
|
+
*
|
|
37
|
+
* 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}.
|
|
38
|
+
*
|
|
39
|
+
* Once analyzed, the result can be accessed via the `data` property.
|
|
40
|
+
*/
|
|
41
|
+
class ReplayAnalyzer {
|
|
42
|
+
constructor(values) {
|
|
43
|
+
/**
|
|
44
|
+
* The original odr file of the replay.
|
|
45
|
+
*/
|
|
46
|
+
this.originalODR = null;
|
|
47
|
+
/**
|
|
48
|
+
* The fixed odr file of the replay.
|
|
49
|
+
*/
|
|
50
|
+
this.fixedODR = null;
|
|
51
|
+
/**
|
|
52
|
+
* The results of the analyzer. `null` when initialized.
|
|
53
|
+
*/
|
|
54
|
+
this.data = null;
|
|
55
|
+
/**
|
|
56
|
+
* Penalty value used to penalize dpp for 2-hand.
|
|
57
|
+
*/
|
|
58
|
+
this.aimPenalty = 1;
|
|
59
|
+
/**
|
|
60
|
+
* Penalty value used to penalize dpp for 3 finger abuse.
|
|
61
|
+
*/
|
|
62
|
+
this.tapPenalty = 1;
|
|
63
|
+
/**
|
|
64
|
+
* Whether this replay has been checked against 3 finger usage.
|
|
65
|
+
*/
|
|
66
|
+
this.hasBeenCheckedFor3Finger = false;
|
|
67
|
+
/**
|
|
68
|
+
* Whether this replay has been checked against 2 hand usage.
|
|
69
|
+
*/
|
|
70
|
+
this.hasBeenCheckedFor2Hand = false;
|
|
71
|
+
// Sizes of primitive data types in Java (in bytes)
|
|
72
|
+
this.BYTE_LENGTH = 1;
|
|
73
|
+
this.SHORT_LENGTH = 2;
|
|
74
|
+
this.INT_LENGTH = 4;
|
|
75
|
+
this.FLOAT_LENGTH = 4;
|
|
76
|
+
this.LONG_LENGTH = 8;
|
|
77
|
+
this.scoreID = values.scoreID;
|
|
78
|
+
this.map = values.map;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Analyzes a replay.
|
|
82
|
+
*/
|
|
83
|
+
async analyze() {
|
|
84
|
+
if (!this.originalODR && !this.fixedODR) {
|
|
85
|
+
this.originalODR = await this.downloadReplay();
|
|
86
|
+
}
|
|
87
|
+
if (!this.originalODR) {
|
|
88
|
+
return this;
|
|
89
|
+
}
|
|
90
|
+
if (!this.fixedODR) {
|
|
91
|
+
this.fixedODR = await this.decompress().catch(() => {
|
|
92
|
+
return null;
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
if (!this.fixedODR) {
|
|
96
|
+
return this;
|
|
97
|
+
}
|
|
98
|
+
this.parseReplay();
|
|
99
|
+
return this;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Downloads the given score ID's replay.
|
|
103
|
+
*/
|
|
104
|
+
async downloadReplay() {
|
|
105
|
+
const apiRequestBuilder = new osu_base_1.DroidAPIRequestBuilder()
|
|
106
|
+
.setRequireAPIkey(false)
|
|
107
|
+
.setEndpoint("upload")
|
|
108
|
+
.addParameter("", `${this.scoreID}.odr`);
|
|
109
|
+
const result = await apiRequestBuilder.sendRequest();
|
|
110
|
+
if (result.statusCode !== 200) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
return result.data;
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Decompresses a replay.
|
|
117
|
+
*
|
|
118
|
+
* 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.
|
|
119
|
+
*/
|
|
120
|
+
decompress() {
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
const stream = new stream_1.Readable();
|
|
123
|
+
stream.push(this.originalODR);
|
|
124
|
+
stream.push(null);
|
|
125
|
+
stream
|
|
126
|
+
.pipe((0, unzipper_1.Parse)())
|
|
127
|
+
.on("entry", async (entry) => {
|
|
128
|
+
const fileName = entry.path;
|
|
129
|
+
if (fileName === "data") {
|
|
130
|
+
return resolve(await entry.buffer());
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
entry.autodrain();
|
|
134
|
+
}
|
|
135
|
+
})
|
|
136
|
+
.on("error", (e) => {
|
|
137
|
+
setTimeout(() => reject(e), 2000);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Parses a replay after being downloaded and converted to a buffer.
|
|
143
|
+
*/
|
|
144
|
+
parseReplay() {
|
|
145
|
+
// javaDeserialization can only somewhat parse some string field
|
|
146
|
+
// the rest will be a buffer that we need to manually parse
|
|
147
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
148
|
+
let rawObject;
|
|
149
|
+
try {
|
|
150
|
+
rawObject = javaDeserialization.parse(this.fixedODR);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
const resultObject = {
|
|
156
|
+
replayVersion: rawObject[0].version,
|
|
157
|
+
folderName: rawObject[1],
|
|
158
|
+
fileName: rawObject[2],
|
|
159
|
+
hash: rawObject[3],
|
|
160
|
+
cursorMovement: [],
|
|
161
|
+
hitObjectData: [],
|
|
162
|
+
};
|
|
163
|
+
if (resultObject.replayVersion >= 3) {
|
|
164
|
+
resultObject.time = new Date(Number(rawObject[4].readBigUInt64BE(0)));
|
|
165
|
+
resultObject.hit300k = rawObject[4].readInt32BE(8);
|
|
166
|
+
resultObject.hit100k = rawObject[4].readInt32BE(16);
|
|
167
|
+
resultObject.score = rawObject[4].readInt32BE(32);
|
|
168
|
+
resultObject.maxCombo = rawObject[4].readInt32BE(36);
|
|
169
|
+
resultObject.accuracy = new osu_base_1.Accuracy({
|
|
170
|
+
n300: rawObject[4].readInt32BE(12),
|
|
171
|
+
n100: rawObject[4].readInt32BE(20),
|
|
172
|
+
n50: rawObject[4].readInt32BE(24),
|
|
173
|
+
nmiss: rawObject[4].readInt32BE(28),
|
|
174
|
+
});
|
|
175
|
+
resultObject.isFullCombo = !!rawObject[4][44];
|
|
176
|
+
resultObject.playerName = rawObject[5];
|
|
177
|
+
resultObject.rawMods = rawObject[6].elements;
|
|
178
|
+
resultObject.convertedMods = this.convertMods(rawObject[6].elements);
|
|
179
|
+
// Determine rank
|
|
180
|
+
const totalHits = resultObject.accuracy.n300 +
|
|
181
|
+
resultObject.accuracy.n100 +
|
|
182
|
+
resultObject.accuracy.n50 +
|
|
183
|
+
resultObject.accuracy.nmiss;
|
|
184
|
+
const isHidden = resultObject.convertedMods.some((m) => m instanceof osu_base_1.ModHidden || m instanceof osu_base_1.ModFlashlight);
|
|
185
|
+
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
186
|
+
switch (true) {
|
|
187
|
+
case resultObject.accuracy.value() === 1:
|
|
188
|
+
if (isHidden) {
|
|
189
|
+
resultObject.rank = "XH";
|
|
190
|
+
}
|
|
191
|
+
else {
|
|
192
|
+
resultObject.rank = "X";
|
|
193
|
+
}
|
|
194
|
+
break;
|
|
195
|
+
case hit300Ratio > 0.9 &&
|
|
196
|
+
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
197
|
+
!resultObject.accuracy.nmiss:
|
|
198
|
+
if (isHidden) {
|
|
199
|
+
resultObject.rank = "SH";
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
resultObject.rank = "S";
|
|
203
|
+
}
|
|
204
|
+
break;
|
|
205
|
+
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
206
|
+
hit300Ratio > 0.9:
|
|
207
|
+
resultObject.rank = "A";
|
|
208
|
+
break;
|
|
209
|
+
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
210
|
+
hit300Ratio > 0.8:
|
|
211
|
+
resultObject.rank = "B";
|
|
212
|
+
break;
|
|
213
|
+
case hit300Ratio > 0.6:
|
|
214
|
+
resultObject.rank = "C";
|
|
215
|
+
break;
|
|
216
|
+
default:
|
|
217
|
+
resultObject.rank = "D";
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (resultObject.replayVersion >= 4) {
|
|
221
|
+
const s = rawObject[7].split("|");
|
|
222
|
+
resultObject.speedModification =
|
|
223
|
+
parseFloat(s[0].replace("x", "")) || 1;
|
|
224
|
+
if (s.length > 1) {
|
|
225
|
+
resultObject.forcedAR = parseFloat(s[1].replace("AR", ""));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
let bufferIndex;
|
|
229
|
+
switch (true) {
|
|
230
|
+
// replay v4 and above
|
|
231
|
+
case resultObject.replayVersion >= 4:
|
|
232
|
+
bufferIndex = 8;
|
|
233
|
+
break;
|
|
234
|
+
// replay v3
|
|
235
|
+
case resultObject.replayVersion === 3:
|
|
236
|
+
bufferIndex = 7;
|
|
237
|
+
break;
|
|
238
|
+
// replay v1 and v2
|
|
239
|
+
default:
|
|
240
|
+
bufferIndex = 4;
|
|
241
|
+
}
|
|
242
|
+
const replayDataBufferArray = [];
|
|
243
|
+
for (bufferIndex; bufferIndex < rawObject.length; ++bufferIndex) {
|
|
244
|
+
replayDataBufferArray.push(rawObject[bufferIndex]);
|
|
245
|
+
}
|
|
246
|
+
// Merge all cursor movement and hit object data section into one for better control when parsing
|
|
247
|
+
const replayDataBuffer = Buffer.concat(replayDataBufferArray);
|
|
248
|
+
let bufferCounter = 0;
|
|
249
|
+
const size = replayDataBuffer.readInt32BE(bufferCounter);
|
|
250
|
+
bufferCounter += this.INT_LENGTH;
|
|
251
|
+
// Parse movement data
|
|
252
|
+
for (let x = 0; x < size; x++) {
|
|
253
|
+
const moveSize = replayDataBuffer.readInt32BE(bufferCounter);
|
|
254
|
+
bufferCounter += this.INT_LENGTH;
|
|
255
|
+
const moveArray = {
|
|
256
|
+
size: moveSize,
|
|
257
|
+
time: [],
|
|
258
|
+
x: [],
|
|
259
|
+
y: [],
|
|
260
|
+
id: [],
|
|
261
|
+
};
|
|
262
|
+
for (let i = 0; i < moveSize; i++) {
|
|
263
|
+
moveArray.time[i] = replayDataBuffer.readInt32BE(bufferCounter);
|
|
264
|
+
bufferCounter += this.INT_LENGTH;
|
|
265
|
+
moveArray.id[i] = moveArray.time[i] & 3;
|
|
266
|
+
moveArray.time[i] >>= 2;
|
|
267
|
+
if (moveArray.id[i] !== movementType_1.movementType.UP) {
|
|
268
|
+
if (resultObject.replayVersion >= 5) {
|
|
269
|
+
moveArray.x[i] =
|
|
270
|
+
replayDataBuffer.readFloatBE(bufferCounter);
|
|
271
|
+
bufferCounter += this.FLOAT_LENGTH;
|
|
272
|
+
moveArray.y[i] =
|
|
273
|
+
replayDataBuffer.readFloatBE(bufferCounter);
|
|
274
|
+
bufferCounter += this.FLOAT_LENGTH;
|
|
275
|
+
}
|
|
276
|
+
else {
|
|
277
|
+
moveArray.x[i] =
|
|
278
|
+
replayDataBuffer.readInt16BE(bufferCounter);
|
|
279
|
+
bufferCounter += this.SHORT_LENGTH;
|
|
280
|
+
moveArray.y[i] =
|
|
281
|
+
replayDataBuffer.readInt16BE(bufferCounter);
|
|
282
|
+
bufferCounter += this.SHORT_LENGTH;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
moveArray.x[i] = -1;
|
|
287
|
+
moveArray.y[i] = -1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
resultObject.cursorMovement.push(moveArray);
|
|
291
|
+
}
|
|
292
|
+
const replayObjectLength = replayDataBuffer.readInt32BE(bufferCounter);
|
|
293
|
+
bufferCounter += this.INT_LENGTH;
|
|
294
|
+
// Parse result data
|
|
295
|
+
for (let i = 0; i < replayObjectLength; i++) {
|
|
296
|
+
const replayObjectData = {
|
|
297
|
+
accuracy: 0,
|
|
298
|
+
tickset: [],
|
|
299
|
+
result: 0,
|
|
300
|
+
};
|
|
301
|
+
replayObjectData.accuracy =
|
|
302
|
+
replayDataBuffer.readInt16BE(bufferCounter);
|
|
303
|
+
bufferCounter += this.SHORT_LENGTH;
|
|
304
|
+
const len = replayDataBuffer.readInt8(bufferCounter);
|
|
305
|
+
bufferCounter += this.BYTE_LENGTH;
|
|
306
|
+
if (len > 0) {
|
|
307
|
+
const bytes = [];
|
|
308
|
+
for (let j = 0; j < len; j++) {
|
|
309
|
+
bytes.push(replayDataBuffer.readInt8(bufferCounter));
|
|
310
|
+
bufferCounter += this.BYTE_LENGTH;
|
|
311
|
+
}
|
|
312
|
+
// Int/int division in Java; numbers must be truncated to get actual number
|
|
313
|
+
for (let j = 0; j < len * 8; j++) {
|
|
314
|
+
replayObjectData.tickset[j] =
|
|
315
|
+
(bytes[len - Math.trunc(j / 8) - 1] &
|
|
316
|
+
(1 << Math.trunc(j % 8))) !==
|
|
317
|
+
0;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (resultObject.replayVersion >= 1) {
|
|
321
|
+
replayObjectData.result =
|
|
322
|
+
replayDataBuffer.readInt8(bufferCounter);
|
|
323
|
+
bufferCounter += this.BYTE_LENGTH;
|
|
324
|
+
}
|
|
325
|
+
resultObject.hitObjectData.push(replayObjectData);
|
|
326
|
+
}
|
|
327
|
+
// Parse max combo, hit results, and accuracy in old replay version
|
|
328
|
+
if (resultObject.replayVersion < 3 && this.map) {
|
|
329
|
+
let hit300 = 0;
|
|
330
|
+
let hit300k = 0;
|
|
331
|
+
let hit100 = 0;
|
|
332
|
+
let hit100k = 0;
|
|
333
|
+
let hit50 = 0;
|
|
334
|
+
let hit0 = 0;
|
|
335
|
+
let grantsGekiOrKatu = true;
|
|
336
|
+
const objects = (this.map instanceof osu_difficulty_calculator_1.DroidStarRating ||
|
|
337
|
+
this.map instanceof osu_rebalance_difficulty_calculator_1.DroidStarRating
|
|
338
|
+
? this.map.map
|
|
339
|
+
: this.map).objects;
|
|
340
|
+
for (let i = 0; i < resultObject.hitObjectData.length; ++i) {
|
|
341
|
+
// Hit result
|
|
342
|
+
const hitObjectData = resultObject.hitObjectData[i];
|
|
343
|
+
const isNextNewCombo = i + 1 !== objects.length ? objects[i + 1].isNewCombo : true;
|
|
344
|
+
switch (hitObjectData.result) {
|
|
345
|
+
case hitResult_1.hitResult.RESULT_0:
|
|
346
|
+
++hit0;
|
|
347
|
+
grantsGekiOrKatu = false;
|
|
348
|
+
break;
|
|
349
|
+
case hitResult_1.hitResult.RESULT_50:
|
|
350
|
+
++hit50;
|
|
351
|
+
grantsGekiOrKatu = false;
|
|
352
|
+
break;
|
|
353
|
+
case hitResult_1.hitResult.RESULT_100:
|
|
354
|
+
++hit100;
|
|
355
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
356
|
+
++hit100k;
|
|
357
|
+
}
|
|
358
|
+
break;
|
|
359
|
+
case hitResult_1.hitResult.RESULT_300:
|
|
360
|
+
++hit300;
|
|
361
|
+
if (grantsGekiOrKatu && isNextNewCombo) {
|
|
362
|
+
++hit300k;
|
|
363
|
+
}
|
|
364
|
+
break;
|
|
365
|
+
}
|
|
366
|
+
if (isNextNewCombo) {
|
|
367
|
+
grantsGekiOrKatu = true;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
resultObject.hit300k = hit300k;
|
|
371
|
+
resultObject.hit100k = hit100k;
|
|
372
|
+
resultObject.accuracy = new osu_base_1.Accuracy({
|
|
373
|
+
n300: hit300,
|
|
374
|
+
n100: hit100,
|
|
375
|
+
n50: hit50,
|
|
376
|
+
nmiss: hit0,
|
|
377
|
+
nobjects: hit300 + hit100 + hit50 + hit0,
|
|
378
|
+
});
|
|
379
|
+
// Determine rank
|
|
380
|
+
const totalHits = resultObject.accuracy.n300 +
|
|
381
|
+
resultObject.accuracy.n100 +
|
|
382
|
+
resultObject.accuracy.n50 +
|
|
383
|
+
resultObject.accuracy.nmiss;
|
|
384
|
+
const isHidden = resultObject.convertedMods?.some((m) => m instanceof osu_base_1.ModHidden || m instanceof osu_base_1.ModFlashlight) ?? false;
|
|
385
|
+
const hit300Ratio = resultObject.accuracy.n300 / totalHits;
|
|
386
|
+
switch (true) {
|
|
387
|
+
case resultObject.accuracy.value() === 1:
|
|
388
|
+
if (isHidden) {
|
|
389
|
+
resultObject.rank = "XH";
|
|
390
|
+
}
|
|
391
|
+
else {
|
|
392
|
+
resultObject.rank = "X";
|
|
393
|
+
}
|
|
394
|
+
break;
|
|
395
|
+
case hit300Ratio > 0.9 &&
|
|
396
|
+
resultObject.accuracy.n50 / totalHits < 0.01 &&
|
|
397
|
+
!resultObject.accuracy.nmiss:
|
|
398
|
+
if (isHidden) {
|
|
399
|
+
resultObject.rank = "SH";
|
|
400
|
+
}
|
|
401
|
+
else {
|
|
402
|
+
resultObject.rank = "S";
|
|
403
|
+
}
|
|
404
|
+
break;
|
|
405
|
+
case (hit300Ratio > 0.8 && !resultObject.accuracy.nmiss) ||
|
|
406
|
+
hit300Ratio > 0.9:
|
|
407
|
+
resultObject.rank = "A";
|
|
408
|
+
break;
|
|
409
|
+
case (hit300Ratio > 0.7 && !resultObject.accuracy.nmiss) ||
|
|
410
|
+
hit300Ratio > 0.8:
|
|
411
|
+
resultObject.rank = "B";
|
|
412
|
+
break;
|
|
413
|
+
case hit300Ratio > 0.6:
|
|
414
|
+
resultObject.rank = "C";
|
|
415
|
+
break;
|
|
416
|
+
default:
|
|
417
|
+
resultObject.rank = "D";
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
this.data = new ReplayData_1.ReplayData(resultObject);
|
|
421
|
+
}
|
|
422
|
+
/**
|
|
423
|
+
* Gets hit error information of the replay.
|
|
424
|
+
*
|
|
425
|
+
* `analyze()` must be called before calling this.
|
|
426
|
+
*/
|
|
427
|
+
calculateHitError() {
|
|
428
|
+
if (!this.data || !this.map) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
const hitObjectData = this.data.hitObjectData;
|
|
432
|
+
let positiveCount = 0;
|
|
433
|
+
let negativeCount = 0;
|
|
434
|
+
let positiveTotal = 0;
|
|
435
|
+
let negativeTotal = 0;
|
|
436
|
+
const objects = (this.map instanceof osu_difficulty_calculator_1.DroidStarRating ||
|
|
437
|
+
this.map instanceof osu_rebalance_difficulty_calculator_1.DroidStarRating
|
|
438
|
+
? this.map.map
|
|
439
|
+
: this.map).objects;
|
|
440
|
+
for (let i = 0; i < hitObjectData.length; ++i) {
|
|
441
|
+
const v = hitObjectData[i];
|
|
442
|
+
const o = objects[i];
|
|
443
|
+
if (o instanceof osu_base_1.Spinner || v.result === hitResult_1.hitResult.RESULT_0) {
|
|
444
|
+
continue;
|
|
445
|
+
}
|
|
446
|
+
const accuracy = v.accuracy;
|
|
447
|
+
if (accuracy >= 0) {
|
|
448
|
+
positiveTotal += accuracy;
|
|
449
|
+
++positiveCount;
|
|
450
|
+
}
|
|
451
|
+
else {
|
|
452
|
+
negativeTotal += accuracy;
|
|
453
|
+
++negativeCount;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return {
|
|
457
|
+
positiveAvg: positiveTotal / positiveCount || 0,
|
|
458
|
+
negativeAvg: negativeTotal / negativeCount || 0,
|
|
459
|
+
unstableRate: osu_base_1.MathUtils.calculateStandardDeviation(hitObjectData.map((v, i) => v.result !== hitResult_1.hitResult.RESULT_0 &&
|
|
460
|
+
!(objects[i] instanceof osu_base_1.Spinner)
|
|
461
|
+
? v.accuracy
|
|
462
|
+
: 0)) * 10,
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Converts replay mods to droid mod string.
|
|
467
|
+
*/
|
|
468
|
+
convertDroidMods(replayMods) {
|
|
469
|
+
const replayModsConstants = {
|
|
470
|
+
MOD_NOFAIL: "n",
|
|
471
|
+
MOD_EASY: "e",
|
|
472
|
+
MOD_HIDDEN: "h",
|
|
473
|
+
MOD_HARDROCK: "r",
|
|
474
|
+
MOD_DOUBLETIME: "d",
|
|
475
|
+
MOD_HALFTIME: "t",
|
|
476
|
+
MOD_NIGHTCORE: "c",
|
|
477
|
+
MOD_PRECISE: "s",
|
|
478
|
+
MOD_SMALLCIRCLE: "m",
|
|
479
|
+
MOD_SPEEDUP: "b",
|
|
480
|
+
MOD_REALLYEASY: "l",
|
|
481
|
+
MOD_PERFECT: "f",
|
|
482
|
+
MOD_SUDDENDEATH: "u",
|
|
483
|
+
MOD_SCOREV2: "v",
|
|
484
|
+
};
|
|
485
|
+
let modString = "";
|
|
486
|
+
for (const mod of replayMods) {
|
|
487
|
+
for (const property in replayModsConstants) {
|
|
488
|
+
if (!(property in replayModsConstants)) {
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
if (!mod.includes(property)) {
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
modString +=
|
|
495
|
+
replayModsConstants[property];
|
|
496
|
+
break;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
return modString;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Converts replay mods to regular mod string.
|
|
503
|
+
*/
|
|
504
|
+
convertMods(replayMods) {
|
|
505
|
+
return osu_base_1.ModUtil.droidStringToMods(this.convertDroidMods(replayMods));
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* Checks if a play is using 3 fingers.
|
|
509
|
+
*
|
|
510
|
+
* Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
|
|
511
|
+
*/
|
|
512
|
+
checkFor3Finger() {
|
|
513
|
+
if (!(this.map instanceof osu_difficulty_calculator_1.DroidStarRating ||
|
|
514
|
+
this.map instanceof osu_rebalance_difficulty_calculator_1.DroidStarRating) ||
|
|
515
|
+
!this.data) {
|
|
516
|
+
return;
|
|
517
|
+
}
|
|
518
|
+
const threeFingerChecker = new ThreeFingerChecker_1.ThreeFingerChecker(this.map, this.data);
|
|
519
|
+
const result = threeFingerChecker.check();
|
|
520
|
+
this.is3Finger = result.is3Finger;
|
|
521
|
+
this.tapPenalty = result.penalty;
|
|
522
|
+
this.hasBeenCheckedFor3Finger = true;
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* Checks if a play is using 2 hands.
|
|
526
|
+
*
|
|
527
|
+
* Requires `analyze()` to be called first and `map` to be defined as `DroidStarRating` or `RebalanceDroidStarRating`.
|
|
528
|
+
*/
|
|
529
|
+
checkFor2Hand() {
|
|
530
|
+
if (!(this.map instanceof osu_difficulty_calculator_1.DroidStarRating ||
|
|
531
|
+
this.map instanceof osu_rebalance_difficulty_calculator_1.DroidStarRating) ||
|
|
532
|
+
!this.data) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const twoHandChecker = new TwoHandChecker_1.TwoHandChecker(this.map, this.data);
|
|
536
|
+
this.is2Hand = twoHandChecker.check();
|
|
537
|
+
this.hasBeenCheckedFor2Hand = true;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
exports.ReplayAnalyzer = ReplayAnalyzer;
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BeatmapSectionGenerator = void 0;
|
|
4
|
+
/**
|
|
5
|
+
* A beatmap section generator that generates beatmap section based on aim/speed strain.
|
|
6
|
+
*/
|
|
7
|
+
class BeatmapSectionGenerator {
|
|
8
|
+
/**
|
|
9
|
+
* Generates `BeatmapSection`s for the specified beatmap.
|
|
10
|
+
*
|
|
11
|
+
* @param map The beatmap to generate.
|
|
12
|
+
* @param minSectionObjectCount The maximum delta time allowed between two beatmap sections.
|
|
13
|
+
* Increasing this number decreases the amount of beatmap sections in general. Note that this value does not account for the speed multiplier of
|
|
14
|
+
* the play, similar to the way replay object data is stored.
|
|
15
|
+
* @param maxSectionDeltaTime The minimum object count required to make a beatmap section. Increasing this number decreases the amount of beatmap sections.
|
|
16
|
+
*/
|
|
17
|
+
static generateSections(map, minSectionObjectCount, maxSectionDeltaTime) {
|
|
18
|
+
const beatmapSections = [];
|
|
19
|
+
let firstObjectIndex = 0;
|
|
20
|
+
for (let i = 0; i < map.objects.length - 1; ++i) {
|
|
21
|
+
const current = map.objects[i];
|
|
22
|
+
const next = map.objects[i + 1];
|
|
23
|
+
const realDeltaTime = next.object.startTime - current.object.endTime;
|
|
24
|
+
if (realDeltaTime >= maxSectionDeltaTime) {
|
|
25
|
+
// Ignore sections that don't meet object count requirement.
|
|
26
|
+
if (i - firstObjectIndex < minSectionObjectCount) {
|
|
27
|
+
firstObjectIndex = i + 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
beatmapSections.push({
|
|
31
|
+
firstObjectIndex,
|
|
32
|
+
lastObjectIndex: i,
|
|
33
|
+
});
|
|
34
|
+
firstObjectIndex = i + 1;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
// Don't forget to manually add the last beatmap section, which would otherwise be ignored.
|
|
38
|
+
if (map.objects.length - firstObjectIndex > minSectionObjectCount) {
|
|
39
|
+
beatmapSections.push({
|
|
40
|
+
firstObjectIndex,
|
|
41
|
+
lastObjectIndex: map.objects.length - 1,
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
return beatmapSections;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
exports.BeatmapSectionGenerator = BeatmapSectionGenerator;
|