-
-
Notifications
You must be signed in to change notification settings - Fork 2.3k
/
ScoreImporter.cs
230 lines (187 loc) · 9.87 KB
/
ScoreImporter.cs
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
// Copyright (c) ppy Pty Ltd <[email protected]>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using Newtonsoft.Json;
using osu.Framework.Logging;
using osu.Framework.Platform;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.IO.Archives;
using osu.Game.Rulesets;
using osu.Game.Scoring.Legacy;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using Realms;
namespace osu.Game.Scoring
{
public class ScoreImporter : RealmArchiveModelImporter<ScoreInfo>
{
public override IEnumerable<string> HandledExtensions => new[] { ".osr" };
protected override string[] HashableFileTypes => new[] { ".osr" };
private readonly RulesetStore rulesets;
private readonly Func<BeatmapManager> beatmaps;
private readonly IAPIProvider api;
public ScoreImporter(RulesetStore rulesets, Func<BeatmapManager> beatmaps, Storage storage, RealmAccess realm, IAPIProvider api)
: base(storage, realm)
{
this.rulesets = rulesets;
this.beatmaps = beatmaps;
this.api = api;
}
protected override ScoreInfo? CreateModel(ArchiveReader archive, ImportParameters parameters)
{
string name = archive.Filenames.First(f => f.EndsWith(".osr", StringComparison.OrdinalIgnoreCase));
using (var stream = archive.GetStream(name))
{
try
{
return new DatabasedLegacyScoreDecoder(rulesets, beatmaps()).Parse(stream).ScoreInfo;
}
catch (LegacyScoreDecoder.BeatmapNotFoundException notFound)
{
Logger.Log($@"Score '{archive.Name}' failed to import: no corresponding beatmap with the hash '{notFound.Hash}' could be found.", LoggingTarget.Database);
if (!parameters.Batch)
{
// In the case of a missing beatmap, let's attempt to resolve it and show a prompt to the user to download the required beatmap.
var req = new GetBeatmapRequest(new BeatmapInfo { MD5Hash = notFound.Hash });
req.Success += res => PostNotification?.Invoke(new MissingBeatmapNotification(res, archive, notFound.Hash));
api.Queue(req);
}
return null;
}
catch (Exception e)
{
Logger.Log($@"Failed to parse headers of score '{archive.Name}': {e}.", LoggingTarget.Database);
return null;
}
}
}
public Score GetScore(ScoreInfo score) => new LegacyDatabasedScore(score, rulesets, beatmaps(), Files.Store);
protected override void Populate(ScoreInfo model, ArchiveReader? archive, Realm realm, CancellationToken cancellationToken = default)
{
Debug.Assert(model.BeatmapInfo != null);
// Ensure the beatmap is not detached.
if (!model.BeatmapInfo.IsManaged)
model.BeatmapInfo = realm.Find<BeatmapInfo>(model.BeatmapInfo.ID)!;
if (!model.Ruleset.IsManaged)
model.Ruleset = realm.Find<RulesetInfo>(model.Ruleset.ShortName)!;
// These properties are known to be non-null, but these final checks ensure a null hasn't come from somewhere (or the refetch has failed).
// Under no circumstance do we want these to be written to realm as null.
ArgumentNullException.ThrowIfNull(model.BeatmapInfo);
ArgumentNullException.ThrowIfNull(model.Ruleset);
PopulateMaximumStatistics(model);
if (string.IsNullOrEmpty(model.StatisticsJson))
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
if (string.IsNullOrEmpty(model.MaximumStatisticsJson))
model.MaximumStatisticsJson = JsonConvert.SerializeObject(model.MaximumStatistics);
// for pre-ScoreV2 lazer scores, apply a best-effort conversion of total score to ScoreV2.
// this requires: max combo, statistics, max statistics (where available), and mods to already be populated on the score.
if (StandardisedScoreMigrationTools.ShouldMigrateToNewStandardised(model))
model.TotalScore = StandardisedScoreMigrationTools.GetNewStandardised(model);
else if (model.IsLegacyScore)
{
model.LegacyTotalScore = model.TotalScore;
model.TotalScore = StandardisedScoreMigrationTools.ConvertFromLegacyTotalScore(model, beatmaps());
}
}
/// <summary>
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary>
/// <param name="score">The score to populate the statistics of.</param>
public void PopulateMaximumStatistics(ScoreInfo score)
{
Debug.Assert(score.BeatmapInfo != null);
if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
return;
var beatmap = score.BeatmapInfo!.Detach();
var ruleset = score.Ruleset.Detach();
var rulesetInstance = ruleset.CreateInstance();
Debug.Assert(rulesetInstance != null);
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResults()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(Judgement.ToNumericResult);
foreach ((HitResult result, int count) in score.Statistics)
{
switch (result)
{
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
break;
case HitResult.SmallTickHit:
case HitResult.SmallTickMiss:
score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
break;
case HitResult.IgnoreHit:
case HitResult.IgnoreMiss:
case HitResult.SmallBonus:
case HitResult.LargeBonus:
break;
default:
score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
break;
}
}
if (!score.IsLegacyScore)
return;
#pragma warning disable CS0618
// In osu! and osu!mania, some judgements affect combo but aren't stored to scores.
// A special hit result is used to pad out the combo value to match, based on the max combo from the difficulty attributes.
var calculator = rulesetInstance.CreateDifficultyCalculator(beatmaps().GetWorkingBeatmap(beatmap));
var attributes = calculator.Calculate(score.Mods);
int maxComboFromStatistics = score.MaximumStatistics.Where(kvp => kvp.Key.AffectsCombo()).Select(kvp => kvp.Value).DefaultIfEmpty(0).Sum();
if (attributes.MaxCombo > maxComboFromStatistics)
score.MaximumStatistics[HitResult.LegacyComboIncrease] = attributes.MaxCombo - maxComboFromStatistics;
#pragma warning restore CS0618
}
// Very naive local caching to improve performance of large score imports (where the username is usually the same for most or all scores).
private readonly Dictionary<string, APIUser> usernameLookupCache = new Dictionary<string, APIUser>();
protected override void PostImport(ScoreInfo model, Realm realm, ImportParameters parameters)
{
base.PostImport(model, realm, parameters);
populateUserDetails(model);
Debug.Assert(model.BeatmapInfo != null);
// This needs to be run after user detail population to ensure we have a valid user id.
if (api.IsLoggedIn && api.LocalUser.Value.OnlineID == model.UserID && (model.BeatmapInfo.LastPlayed == null || model.Date > model.BeatmapInfo.LastPlayed))
model.BeatmapInfo.LastPlayed = model.Date;
}
/// <summary>
/// Legacy replays only store a username.
/// This will populate a user ID during import.
/// </summary>
private void populateUserDetails(ScoreInfo model)
{
if (model.RealmUser.OnlineID == APIUser.SYSTEM_USER_ID)
return;
string username = model.RealmUser.Username;
if (usernameLookupCache.TryGetValue(username, out var existing))
{
model.User = existing;
return;
}
var userRequest = new GetUserRequest(username);
api.Perform(userRequest);
if (userRequest.Response is APIUser user)
{
usernameLookupCache.TryAdd(username, new APIUser
{
// Because this is a permanent cache, let's only store the pieces we're interested in,
// rather than the full API response. If we start to store more than these three fields
// in realm, this should be undone.
Id = user.Id,
Username = user.Username,
CountryCode = user.CountryCode,
});
model.User = user;
}
}
}
}