1
0
mirror of https://github.com/ppy/osu.git synced 2024-12-13 08:32:57 +08:00

Merge branch 'decouple-notification-websocket-from-chat' into 2fa

This commit is contained in:
Bartłomiej Dach 2024-01-25 14:48:31 +01:00
commit 3d3506b906
No known key found for this signature in database
41 changed files with 941 additions and 585 deletions

View File

@ -89,7 +89,7 @@ namespace osu.Game.Rulesets.Catch.Scoring
return baseIncrease * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(combo_cap, combo_base));
}
public override ScoreRank RankFromAccuracy(double accuracy)
public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
if (accuracy == accuracy_cutoff_x)
return ScoreRank.X;

View File

@ -1,9 +1,11 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Osu.Scoring
{
@ -14,6 +16,22 @@ namespace osu.Game.Rulesets.Osu.Scoring
{
}
public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
ScoreRank rank = base.RankFromScore(accuracy, results);
switch (rank)
{
case ScoreRank.S:
case ScoreRank.X:
if (results.GetValueOrDefault(HitResult.Miss) > 0)
rank = ScoreRank.A;
break;
}
return rank;
}
protected override HitEvent CreateHitEvent(JudgementResult result)
=> base.CreateHitEvent(result).With((result as OsuHitCircleJudgementResult)?.CursorPositionAtHit);
}

View File

@ -2,9 +2,11 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Scoring;
using osu.Game.Rulesets.Taiko.Objects;
using osu.Game.Scoring;
namespace osu.Game.Rulesets.Taiko.Scoring
{
@ -33,6 +35,22 @@ namespace osu.Game.Rulesets.Taiko.Scoring
* strongScaleValue(result);
}
public override ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
ScoreRank rank = base.RankFromScore(accuracy, results);
switch (rank)
{
case ScoreRank.S:
case ScoreRank.X:
if (results.GetValueOrDefault(HitResult.Miss) > 0)
rank = ScoreRank.A;
break;
}
return rank;
}
public override int GetBaseScoreForResult(HitResult result)
{
switch (result)

View File

@ -10,7 +10,6 @@ using System.IO;
using System.Linq;
using NUnit.Framework;
using osu.Framework.Extensions;
using osu.Framework.Utils;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.Formats;
using osu.Game.Beatmaps.Legacy;
@ -23,6 +22,7 @@ using osu.Game.Rulesets.Mania.Mods;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Osu.Mods;
using osu.Game.Rulesets.Osu.Objects;
using osu.Game.Rulesets.Osu.Replays;
using osu.Game.Rulesets.Osu.UI;
using osu.Game.Rulesets.Replays;
@ -59,14 +59,14 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.AreEqual(2, score.ScoreInfo.Statistics[HitResult.Great]);
Assert.AreEqual(1, score.ScoreInfo.Statistics[HitResult.Good]);
Assert.AreEqual(829_931, score.ScoreInfo.TotalScore);
Assert.AreEqual(829_931, score.ScoreInfo.LegacyTotalScore);
Assert.AreEqual(3, score.ScoreInfo.MaxCombo);
Assert.IsTrue(score.ScoreInfo.Mods.Any(m => m is ManiaModClassic));
Assert.IsTrue(score.ScoreInfo.APIMods.Any(m => m.Acronym == "CL"));
Assert.IsTrue(score.ScoreInfo.ModsJson.Contains("CL"));
Assert.IsTrue(Precision.AlmostEquals(0.8889, score.ScoreInfo.Accuracy, 0.0001));
Assert.That((2 * 300d + 1 * 200) / (3 * 305d), Is.EqualTo(score.ScoreInfo.Accuracy).Within(0.0001));
Assert.AreEqual(ScoreRank.B, score.ScoreInfo.Rank);
Assert.That(score.Replay.Frames, Is.Not.Empty);
@ -252,7 +252,7 @@ namespace osu.Game.Tests.Beatmaps.Formats
}
[Test]
public void AccuracyAndRankOfStableScorePreserved()
public void AccuracyOfStableScoreRecomputed()
{
var memoryStream = new MemoryStream();
@ -261,15 +261,16 @@ namespace osu.Game.Tests.Beatmaps.Formats
// and we want to emulate a stable score here
using (var sw = new SerializationWriter(memoryStream, true))
{
sw.Write((byte)0); // ruleset id (osu!)
sw.Write((byte)3); // ruleset id (mania).
// mania is used intentionally as it is the only ruleset wherein default accuracy calculation is changed in lazer
sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable)
sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
sw.Write("username"); // irrelevant to this test
sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
sw.Write((ushort)198); // count300
sw.Write((ushort)1); // count100
sw.Write((ushort)1); // count300
sw.Write((ushort)0); // count100
sw.Write((ushort)0); // count50
sw.Write((ushort)0); // countGeki
sw.Write((ushort)198); // countGeki (perfects / "rainbow 300s" in mania)
sw.Write((ushort)0); // countKatu
sw.Write((ushort)1); // countMiss
sw.Write(12345678); // total score, irrelevant to this test
@ -287,13 +288,54 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.Multiple(() =>
{
Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 300 + 100) / (200 * 300)));
Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A));
Assert.That(decoded.ScoreInfo.Accuracy, Is.EqualTo((double)(198 * 305 + 300) / (200 * 305)));
Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
});
}
[Test]
public void AccuracyAndRankOfLazerScorePreserved()
public void RankOfStableScoreUsesLazerDefinitions()
{
var memoryStream = new MemoryStream();
// local partial implementation of legacy score encoder
// this is done half for readability, half because `LegacyScoreEncoder` forces `LATEST_VERSION`
// and we want to emulate a stable score here
using (var sw = new SerializationWriter(memoryStream, true))
{
sw.Write((byte)0); // ruleset id (osu!)
sw.Write(20240116); // version (anything below `LegacyScoreEncoder.FIRST_LAZER_VERSION` is stable)
sw.Write(string.Empty.ComputeMD5Hash()); // beatmap hash, irrelevant to this test
sw.Write("username"); // irrelevant to this test
sw.Write(string.Empty.ComputeMD5Hash()); // score hash, irrelevant to this test
sw.Write((ushort)195); // count300
sw.Write((ushort)1); // count100
sw.Write((ushort)4); // count50
sw.Write((ushort)0); // countGeki
sw.Write((ushort)0); // countKatu
sw.Write((ushort)0); // countMiss
sw.Write(12345678); // total score, irrelevant to this test
sw.Write((ushort)1000); // max combo, irrelevant to this test
sw.Write(false); // full combo, irrelevant to this test
sw.Write((int)LegacyMods.Hidden); // mods
sw.Write(string.Empty); // hp graph, irrelevant
sw.Write(DateTime.Now); // date, irrelevant
sw.Write(Array.Empty<byte>()); // replay data, irrelevant
sw.Write((long)1234); // legacy online ID, irrelevant
}
memoryStream.Seek(0, SeekOrigin.Begin);
var decoded = new TestLegacyScoreDecoder().Parse(memoryStream);
Assert.Multiple(() =>
{
// In stable this would be an A because there are over 1% 50s. But that's not a thing in lazer.
Assert.That(decoded.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
});
}
[Test]
public void AccuracyRankAndTotalScoreOfLazerScorePreserved()
{
var ruleset = new OsuRuleset().RulesetInfo;
@ -321,8 +363,10 @@ namespace osu.Game.Tests.Beatmaps.Formats
Assert.Multiple(() =>
{
Assert.That(decodedAfterEncode.ScoreInfo.TotalScore, Is.EqualTo(284_537));
Assert.That(decodedAfterEncode.ScoreInfo.LegacyTotalScore, Is.Null);
Assert.That(decodedAfterEncode.ScoreInfo.Accuracy, Is.EqualTo((double)(199 * 300 + 30) / (200 * 300 + 30)));
Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.SH));
Assert.That(decodedAfterEncode.ScoreInfo.Rank, Is.EqualTo(ScoreRank.A));
});
}
@ -415,6 +459,12 @@ namespace osu.Game.Tests.Beatmaps.Formats
Ruleset = new OsuRuleset().RulesetInfo,
Difficulty = new BeatmapDifficulty(),
BeatmapVersion = beatmapVersion,
},
// needs to have at least one objects so that `StandardisedScoreMigrationTools` doesn't die
// when trying to recompute total score.
HitObjects =
{
new HitCircle()
}
});
}

View File

@ -75,8 +75,6 @@ namespace osu.Game.Tests.Chat
return false;
};
});
AddUntilStep("wait for notifications client", () => channelManager.NotificationsConnected);
}
[Test]

View File

@ -9,6 +9,7 @@ using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Testing;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Rulesets;
using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
@ -210,31 +211,6 @@ namespace osu.Game.Tests.Database
AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000001));
}
[Test]
public void TestNonLegacyScoreNotSubjectToUpgrades()
{
ScoreInfo scoreInfo = null!;
TestBackgroundDataStoreProcessor processor = null!;
AddStep("Add score which requires upgrade (and has beatmap)", () =>
{
Realm.Write(r =>
{
r.Add(scoreInfo = new ScoreInfo(ruleset: r.All<RulesetInfo>().First(), beatmap: r.All<BeatmapInfo>().First())
{
TotalScoreVersion = 30000005,
LegacyTotalScore = 123456,
});
});
});
AddStep("Run background processor", () => Add(processor = new TestBackgroundDataStoreProcessor()));
AddUntilStep("Wait for completion", () => processor.Completed);
AddAssert("Score not marked as failed", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.BackgroundReprocessingFailed), () => Is.False);
AddAssert("Score version not upgraded", () => Realm.Run(r => r.Find<ScoreInfo>(scoreInfo.ID)!.TotalScoreVersion), () => Is.EqualTo(30000005));
}
public partial class TestBackgroundDataStoreProcessor : BackgroundDataStoreProcessor
{
protected override int TimeToSleepDuringGameplay => 10;

View File

@ -210,6 +210,13 @@ namespace osu.Game.Tests.Visual.Editing
switchPresets(-1);
assertPreset(BeatDivisorType.Custom, 15);
assertBeatSnap(15);
setDivisorViaInput(24);
assertPreset(BeatDivisorType.Custom, 24);
switchPresets(1);
assertPreset(BeatDivisorType.Common);
switchPresets(-2);
assertPreset(BeatDivisorType.Triplets);
}
private void switchBeatSnap(int direction) => AddRepeatStep($"move snap {(direction > 0 ? "forward" : "backward")}", () =>

View File

@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
using NUnit.Framework;
using osu.Framework.Extensions.Color4Extensions;
using osu.Framework.Graphics;
@ -45,6 +46,16 @@ namespace osu.Game.Tests.Visual.Ranking
addCircleStep(createScore(1, new OsuRuleset()));
}
[Test]
public void TestOsuRankHidden()
{
addCircleStep(createScore(0, new OsuRuleset(), 20, true));
addCircleStep(createScore(0.8, new OsuRuleset(), 5, true));
addCircleStep(createScore(0.95, new OsuRuleset(), 0, true));
addCircleStep(createScore(0.97, new OsuRuleset(), 1, true));
addCircleStep(createScore(1, new OsuRuleset(), 0, true));
}
[Test]
public void TestCatchRank()
{
@ -65,7 +76,7 @@ namespace osu.Game.Tests.Visual.Ranking
addCircleStep(createScore(1, new CatchRuleset()));
}
private void addCircleStep(ScoreInfo score) => AddStep($"add panel ({score.DisplayAccuracy})", () =>
private void addCircleStep(ScoreInfo score) => AddStep($"add panel ({score.DisplayAccuracy}, {score.Statistics.GetValueOrDefault(HitResult.Miss)} miss)", () =>
{
Children = new Drawable[]
{
@ -92,10 +103,22 @@ namespace osu.Game.Tests.Visual.Ranking
};
});
private ScoreInfo createScore(double accuracy, Ruleset ruleset)
private ScoreInfo createScore(double accuracy, Ruleset ruleset, int missCount = 0, bool hidden = false)
{
var scoreProcessor = ruleset.CreateScoreProcessor();
var statistics = new Dictionary<HitResult, int>
{
{ HitResult.Miss, missCount },
{ HitResult.Meh, 50 },
{ HitResult.Good, 100 },
{ HitResult.Great, 300 },
};
var mods = hidden
? new[] { new OsuModHidden() }
: new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() };
return new ScoreInfo
{
User = new APIUser
@ -105,19 +128,13 @@ namespace osu.Game.Tests.Visual.Ranking
},
BeatmapInfo = new TestBeatmap(new OsuRuleset().RulesetInfo).BeatmapInfo,
Ruleset = ruleset.RulesetInfo,
Mods = new Mod[] { new OsuModHardRock(), new OsuModDoubleTime() },
Mods = mods,
TotalScore = 2845370,
Accuracy = accuracy,
MaxCombo = 999,
Rank = scoreProcessor.RankFromAccuracy(accuracy),
Rank = scoreProcessor.RankFromScore(accuracy, statistics),
Date = DateTimeOffset.Now,
Statistics =
{
{ HitResult.Miss, 1 },
{ HitResult.Meh, 50 },
{ HitResult.Good, 100 },
{ HitResult.Great, 300 },
}
Statistics = statistics,
};
}
}

View File

@ -21,6 +21,7 @@ using osu.Game.Online.API;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Difficulty;
using osu.Game.Rulesets.Osu;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens;
using osu.Game.Screens.Play;
@ -71,15 +72,16 @@ namespace osu.Game.Tests.Visual.Ranking
private int onlineScoreID = 1;
[TestCase(1, ScoreRank.X)]
[TestCase(0.9999, ScoreRank.S)]
[TestCase(0.975, ScoreRank.S)]
[TestCase(0.925, ScoreRank.A)]
[TestCase(0.85, ScoreRank.B)]
[TestCase(0.75, ScoreRank.C)]
[TestCase(0.5, ScoreRank.D)]
[TestCase(0.2, ScoreRank.D)]
public void TestResultsWithPlayer(double accuracy, ScoreRank rank)
[TestCase(1, ScoreRank.X, 0)]
[TestCase(0.9999, ScoreRank.S, 0)]
[TestCase(0.975, ScoreRank.S, 0)]
[TestCase(0.975, ScoreRank.A, 1)]
[TestCase(0.925, ScoreRank.A, 5)]
[TestCase(0.85, ScoreRank.B, 9)]
[TestCase(0.75, ScoreRank.C, 11)]
[TestCase(0.5, ScoreRank.D, 21)]
[TestCase(0.2, ScoreRank.D, 51)]
public void TestResultsWithPlayer(double accuracy, ScoreRank rank, int missCount)
{
TestResultsScreen screen = null;
@ -91,6 +93,7 @@ namespace osu.Game.Tests.Visual.Ranking
score.HitEvents = TestSceneStatisticsPanel.CreatePositionDistributedHitEvents();
score.Accuracy = accuracy;
score.Rank = rank;
score.Statistics[HitResult.Miss] = missCount;
return screen = createResultsScreen(score);
});

View File

@ -12,7 +12,6 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Logging;
using osu.Game.Beatmaps;
using osu.Game.Database;
using osu.Game.Extensions;
using osu.Game.Online.API;
using osu.Game.Overlays;
@ -22,7 +21,7 @@ using osu.Game.Scoring;
using osu.Game.Scoring.Legacy;
using osu.Game.Screens.Play;
namespace osu.Game
namespace osu.Game.Database
{
/// <summary>
/// Performs background updating of data stores at startup.
@ -74,6 +73,7 @@ namespace osu.Game
processBeatmapsWithMissingObjectCounts();
processScoresWithMissingStatistics();
convertLegacyTotalScoreToStandardised();
upgradeScoreRanks();
}, TaskCreationOptions.LongRunning).ContinueWith(t =>
{
if (t.Exception?.InnerException is ObjectDisposedException)
@ -355,7 +355,7 @@ namespace osu.Game
realmAccess.Write(r =>
{
ScoreInfo s = r.Find<ScoreInfo>(id)!;
StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager);
StandardisedScoreMigrationTools.UpdateFromLegacy(s, beatmapManager.GetWorkingBeatmap(s.BeatmapInfo));
s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
});
@ -376,6 +376,66 @@ namespace osu.Game
completeNotification(notification, processedCount, scoreIds.Count, failedCount);
}
private void upgradeScoreRanks()
{
Logger.Log("Querying for scores that need rank upgrades...");
HashSet<Guid> scoreIds = realmAccess.Run(r => new HashSet<Guid>(
r.All<ScoreInfo>()
.Where(s => s.TotalScoreVersion < LegacyScoreEncoder.LATEST_VERSION)
.AsEnumerable()
// must be done after materialisation, as realm doesn't support
// filtering on nested property predicates or projection via `.Select()`
.Where(s => s.Ruleset.IsLegacyRuleset())
.Select(s => s.ID)));
Logger.Log($"Found {scoreIds.Count} scores which require rank upgrades.");
if (scoreIds.Count == 0)
return;
var notification = showProgressNotification(scoreIds.Count, "Adjusting ranks of scores", "scores now have more correct ranks");
int processedCount = 0;
int failedCount = 0;
foreach (var id in scoreIds)
{
if (notification?.State == ProgressNotificationState.Cancelled)
break;
updateNotificationProgress(notification, processedCount, scoreIds.Count);
sleepIfRequired();
try
{
// Can't use async overload because we're not on the update thread.
// ReSharper disable once MethodHasAsyncOverload
realmAccess.Write(r =>
{
ScoreInfo s = r.Find<ScoreInfo>(id)!;
s.Rank = StandardisedScoreMigrationTools.ComputeRank(s);
s.TotalScoreVersion = LegacyScoreEncoder.LATEST_VERSION;
});
++processedCount;
}
catch (ObjectDisposedException)
{
throw;
}
catch (Exception e)
{
Logger.Log($"Failed to update rank score {id}: {e}");
realmAccess.Write(r => r.Find<ScoreInfo>(id)!.BackgroundReprocessingFailed = true);
++failedCount;
}
}
completeNotification(notification, processedCount, scoreIds.Count, failedCount);
}
private void updateNotificationProgress(ProgressNotification? notification, int processedCount, int totalCount)
{
if (notification == null)

View File

@ -232,42 +232,60 @@ namespace osu.Game.Database
}
/// <summary>
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
/// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompite the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
/// </summary>
/// <param name="score">The score to update.</param>
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
public static void UpdateFromLegacy(ScoreInfo score, BeatmapManager beatmaps)
/// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
public static void UpdateFromLegacy(ScoreInfo score, WorkingBeatmap beatmap)
{
score.TotalScore = convertFromLegacyTotalScore(score, beatmaps);
score.Accuracy = ComputeAccuracy(score);
var ruleset = score.Ruleset.CreateInstance();
var scoreProcessor = ruleset.CreateScoreProcessor();
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
score.Accuracy = computeAccuracy(score, scoreProcessor);
score.Rank = computeRank(score, scoreProcessor);
score.TotalScore = convertFromLegacyTotalScore(score, ruleset, beatmap);
}
/// <summary>
/// Updates a legacy <see cref="ScoreInfo"/> to standardised scoring.
/// Updates a <see cref="ScoreInfo"/> to standardised scoring.
/// This will recompute the score's <see cref="ScoreInfo.Accuracy"/> (always), <see cref="ScoreInfo.Rank"/> (always),
/// and <see cref="ScoreInfo.TotalScore"/> (if the score comes from stable).
/// The total score from stable - if any applicable - will be stored to <see cref="ScoreInfo.LegacyTotalScore"/>.
/// </summary>
/// <remarks>
/// This overload is intended for server-side flows.
/// See: https://github.com/ppy/osu-queue-score-statistics/blob/3681e92ac91c6c61922094bdbc7e92e6217dd0fc/osu.Server.Queues.ScoreStatisticsProcessor/Commands/Queue/BatchInserter.cs
/// </remarks>
/// <param name="score">The score to update.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
public static void UpdateFromLegacy(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
public static void UpdateFromLegacy(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
{
score.TotalScore = convertFromLegacyTotalScore(score, difficulty, attributes);
score.Accuracy = ComputeAccuracy(score);
var scoreProcessor = ruleset.CreateScoreProcessor();
// warning: ordering is important here - both total score and ranks are dependent on accuracy!
score.Accuracy = computeAccuracy(score, scoreProcessor);
score.Rank = computeRank(score, scoreProcessor);
score.TotalScore = convertFromLegacyTotalScore(score, ruleset, difficulty, attributes);
}
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
/// <param name="beatmaps">A <see cref="BeatmapManager"/> used for <see cref="WorkingBeatmap"/> lookups.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="beatmap">The <see cref="WorkingBeatmap"/> applicable for this score.</param>
/// <returns>The standardised total score.</returns>
private static long convertFromLegacyTotalScore(ScoreInfo score, BeatmapManager beatmaps)
private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, WorkingBeatmap beatmap)
{
if (!score.IsLegacyScore)
return score.TotalScore;
WorkingBeatmap beatmap = beatmaps.GetWorkingBeatmap(score.BeatmapInfo);
Ruleset ruleset = score.Ruleset.CreateInstance();
if (ruleset is not ILegacyRuleset legacyRuleset)
return score.TotalScore;
@ -283,24 +301,24 @@ namespace osu.Game.Database
ILegacyScoreSimulator sv1Simulator = legacyRuleset.CreateLegacyScoreSimulator();
LegacyScoreAttributes attributes = sv1Simulator.Simulate(beatmap, playableBeatmap);
return convertFromLegacyTotalScore(score, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes);
return convertFromLegacyTotalScore(score, ruleset, LegacyBeatmapConversionDifficultyInfo.FromBeatmap(beatmap.Beatmap), attributes);
}
/// <summary>
/// Converts from <see cref="ScoreInfo.LegacyTotalScore"/> to the new standardised scoring of <see cref="ScoreProcessor"/>.
/// </summary>
/// <param name="score">The score to convert the total score of.</param>
/// <param name="ruleset">The <see cref="Ruleset"/> in which the score was set.</param>
/// <param name="difficulty">The beatmap difficulty.</param>
/// <param name="attributes">The legacy scoring attributes for the beatmap which the score was set on.</param>
/// <returns>The standardised total score.</returns>
private static long convertFromLegacyTotalScore(ScoreInfo score, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
private static long convertFromLegacyTotalScore(ScoreInfo score, Ruleset ruleset, LegacyBeatmapConversionDifficultyInfo difficulty, LegacyScoreAttributes attributes)
{
if (!score.IsLegacyScore)
return score.TotalScore;
Debug.Assert(score.LegacyTotalScore != null);
Ruleset ruleset = score.Ruleset.CreateInstance();
if (ruleset is not ILegacyRuleset legacyRuleset)
return score.TotalScore;
@ -474,14 +492,9 @@ namespace osu.Game.Database
break;
case 3:
// in the mania case accuracy actually changes between score V1 and score V2 / standardised
// (PERFECT weighting changes from 300 to 305),
// so for better accuracy recompute accuracy locally based on hit statistics and use that instead,
double scoreV2Accuracy = ComputeAccuracy(score);
convertedTotalScore = (long)Math.Round((
850000 * comboProportion
+ 150000 * Math.Pow(scoreV2Accuracy, 2 + 2 * scoreV2Accuracy)
+ 150000 * Math.Pow(score.Accuracy, 2 + 2 * score.Accuracy)
+ bonusProportion) * modMultiplier);
break;
@ -584,11 +597,8 @@ namespace osu.Game.Database
}
}
public static double ComputeAccuracy(ScoreInfo scoreInfo)
private static double computeAccuracy(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
{
Ruleset ruleset = scoreInfo.Ruleset.CreateInstance();
ScoreProcessor scoreProcessor = ruleset.CreateScoreProcessor();
int baseScore = scoreInfo.Statistics.Where(kvp => kvp.Key.AffectsAccuracy())
.Sum(kvp => kvp.Value * scoreProcessor.GetBaseScoreForResult(kvp.Key));
int maxBaseScore = scoreInfo.MaximumStatistics.Where(kvp => kvp.Key.AffectsAccuracy())
@ -597,6 +607,18 @@ namespace osu.Game.Database
return maxBaseScore == 0 ? 1 : baseScore / (double)maxBaseScore;
}
public static ScoreRank ComputeRank(ScoreInfo scoreInfo) => computeRank(scoreInfo, scoreInfo.Ruleset.CreateInstance().CreateScoreProcessor());
private static ScoreRank computeRank(ScoreInfo scoreInfo, ScoreProcessor scoreProcessor)
{
var rank = scoreProcessor.RankFromScore(scoreInfo.Accuracy, scoreInfo.Statistics);
foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>())
rank = mod.AdjustRank(rank, scoreInfo.Accuracy);
return rank;
}
/// <summary>
/// Used to populate the <paramref name="score"/> model using data parsed from its corresponding replay file.
/// </summary>

View File

@ -1,8 +1,6 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
#nullable disable
using System;
using System.IO;
using System.Threading;
@ -24,6 +22,8 @@ using osu.Game.Overlays;
using osu.Game.Overlays.Notifications;
using SixLabors.ImageSharp;
using SixLabors.ImageSharp.Formats.Jpeg;
using SixLabors.ImageSharp.PixelFormats;
using SixLabors.ImageSharp.Processing;
namespace osu.Game.Graphics
{
@ -37,30 +37,26 @@ namespace osu.Game.Graphics
/// </summary>
public IBindable<bool> CursorVisibility => cursorVisibility;
private Bindable<ScreenshotFormat> screenshotFormat;
private Bindable<bool> captureMenuCursor;
[Resolved]
private GameHost host { get; set; } = null!;
[Resolved]
private GameHost host { get; set; }
private Clipboard clipboard { get; set; } = null!;
[Resolved]
private Clipboard clipboard { get; set; }
private Storage storage;
private INotificationOverlay notificationOverlay { get; set; } = null!;
[Resolved]
private INotificationOverlay notificationOverlay { get; set; }
private OsuConfigManager config { get; set; } = null!;
private Sample shutter;
private Storage storage = null!;
private Sample? shutter;
[BackgroundDependencyLoader]
private void load(OsuConfigManager config, Storage storage, AudioManager audio)
private void load(Storage storage, AudioManager audio)
{
this.storage = storage.GetStorageForDirectory(@"screenshots");
screenshotFormat = config.GetBindable<ScreenshotFormat>(OsuSetting.ScreenshotFormat);
captureMenuCursor = config.GetBindable<bool>(OsuSetting.ScreenshotCaptureMenuCursor);
shutter = audio.Samples.Get("UI/shutter");
}
@ -72,7 +68,7 @@ namespace osu.Game.Graphics
switch (e.Action)
{
case GlobalAction.TakeScreenshot:
shutter.Play();
shutter?.Play();
TakeScreenshotAsync().FireAndForget();
return true;
}
@ -90,9 +86,12 @@ namespace osu.Game.Graphics
{
Interlocked.Increment(ref screenShotTasks);
ScreenshotFormat screenshotFormat = config.Get<ScreenshotFormat>(OsuSetting.ScreenshotFormat);
bool captureMenuCursor = config.Get<bool>(OsuSetting.ScreenshotCaptureMenuCursor);
try
{
if (!captureMenuCursor.Value)
if (!captureMenuCursor)
{
cursorVisibility.Value = false;
@ -101,7 +100,7 @@ namespace osu.Game.Graphics
int framesWaited = 0;
using (var framesWaitedEvent = new ManualResetEventSlim(false))
using (ManualResetEventSlim framesWaitedEvent = new ManualResetEventSlim(false))
{
ScheduledDelegate waitDelegate = host.DrawThread.Scheduler.AddDelayed(() =>
{
@ -117,17 +116,41 @@ namespace osu.Game.Graphics
}
}
using (var image = await host.TakeScreenshotAsync().ConfigureAwait(false))
using (Image<Rgba32>? image = await host.TakeScreenshotAsync().ConfigureAwait(false))
{
if (config.Get<ScalingMode>(OsuSetting.Scaling) == ScalingMode.Everything)
{
float posX = config.Get<float>(OsuSetting.ScalingPositionX);
float posY = config.Get<float>(OsuSetting.ScalingPositionY);
float sizeX = config.Get<float>(OsuSetting.ScalingSizeX);
float sizeY = config.Get<float>(OsuSetting.ScalingSizeY);
image.Mutate(m =>
{
Rectangle rect = new Rectangle(Point.Empty, m.GetCurrentSize());
// Reduce size by user scale settings...
int sx = (rect.Width - (int)(rect.Width * sizeX)) / 2;
int sy = (rect.Height - (int)(rect.Height * sizeY)) / 2;
rect.Inflate(-sx, -sy);
// ...then adjust the region based on their positional offset.
rect.X = (int)(rect.X * posX) * 2;
rect.Y = (int)(rect.Y * posY) * 2;
m.Crop(rect);
});
}
clipboard.SetImage(image);
(string filename, var stream) = getWritableStream();
(string? filename, Stream? stream) = getWritableStream(screenshotFormat);
if (filename == null) return;
using (stream)
{
switch (screenshotFormat.Value)
switch (screenshotFormat)
{
case ScreenshotFormat.Png:
await image.SaveAsPngAsync(stream).ConfigureAwait(false);
@ -140,7 +163,7 @@ namespace osu.Game.Graphics
break;
default:
throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat.Value}.");
throw new InvalidOperationException($"Unknown enum member {nameof(ScreenshotFormat)} {screenshotFormat}.");
}
}
@ -164,12 +187,12 @@ namespace osu.Game.Graphics
private static readonly object filename_reservation_lock = new object();
private (string filename, Stream stream) getWritableStream()
private (string? filename, Stream? stream) getWritableStream(ScreenshotFormat format)
{
lock (filename_reservation_lock)
{
var dt = DateTime.Now;
string fileExt = screenshotFormat.ToString().ToLowerInvariant();
DateTime dt = DateTime.Now;
string fileExt = format.ToString().ToLowerInvariant();
string withoutIndex = $"osu_{dt:yyyy-MM-dd_HH-mm-ss}.{fileExt}";
if (!storage.Exists(withoutIndex))

View File

@ -4,7 +4,6 @@
#nullable disable
using osu.Framework.Bindables;
using osu.Framework.Extensions.LocalisationExtensions;
using osu.Framework.Graphics;
using osu.Framework.Localisation;
using osu.Game.Graphics.Sprites;
@ -39,7 +38,7 @@ namespace osu.Game.Graphics.UserInterface
protected override double GetProportionalDuration(long currentValue, long newValue) =>
currentValue > newValue ? currentValue - newValue : newValue - currentValue;
protected override LocalisableString FormatCount(long count) => count.ToLocalisableString(formatString);
protected override LocalisableString FormatCount(long count) => count.ToString(formatString);
protected override OsuSpriteText CreateSpriteText()
=> base.CreateSpriteText().With(s => s.Font = s.Font.With(fixedWidth: true));

View File

@ -6,8 +6,8 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using osu.Framework.Platform;
using osu.Game.Utils;
namespace osu.Game.IO
{
@ -81,7 +81,7 @@ namespace osu.Game.IO
if (IgnoreSuffixes.Any(suffix => fi.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
allFilesDeleted &= AttemptOperation(() => fi.Delete(), throwOnFailure: false);
allFilesDeleted &= FileUtils.AttemptOperation(() => fi.Delete(), throwOnFailure: false);
}
foreach (DirectoryInfo dir in target.GetDirectories())
@ -92,11 +92,11 @@ namespace osu.Game.IO
if (IgnoreSuffixes.Any(suffix => dir.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
allFilesDeleted &= AttemptOperation(() => dir.Delete(true), throwOnFailure: false);
allFilesDeleted &= FileUtils.AttemptOperation(() => dir.Delete(true), throwOnFailure: false);
}
if (target.GetFiles().Length == 0 && target.GetDirectories().Length == 0)
allFilesDeleted &= AttemptOperation(target.Delete, throwOnFailure: false);
allFilesDeleted &= FileUtils.AttemptOperation(target.Delete, throwOnFailure: false);
return allFilesDeleted;
}
@ -115,7 +115,7 @@ namespace osu.Game.IO
if (IgnoreSuffixes.Any(suffix => fileInfo.Name.EndsWith(suffix, StringComparison.Ordinal)))
continue;
AttemptOperation(() =>
FileUtils.AttemptOperation(() =>
{
fileInfo.Refresh();
@ -139,35 +139,5 @@ namespace osu.Game.IO
CopyRecursive(dir, destination.CreateSubdirectory(dir.Name), false);
}
}
/// <summary>
/// Attempt an IO operation multiple times and only throw if none of the attempts succeed.
/// </summary>
/// <param name="action">The action to perform.</param>
/// <param name="attempts">The number of attempts (250ms wait between each).</param>
/// <param name="throwOnFailure">Whether to throw an exception on failure. If <c>false</c>, will silently fail.</param>
protected static bool AttemptOperation(Action action, int attempts = 10, bool throwOnFailure = true)
{
while (true)
{
try
{
action();
return true;
}
catch (Exception)
{
if (attempts-- == 0)
{
if (throwOnFailure)
throw;
return false;
}
}
Thread.Sleep(250);
}
}
}
}

View File

@ -11,10 +11,8 @@ using System.Net.Http;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using JetBrains.Annotations;
using Newtonsoft.Json.Linq;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Extensions.ExceptionExtensions;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Graphics;
@ -23,7 +21,7 @@ using osu.Game.Configuration;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users;
@ -59,6 +57,8 @@ namespace osu.Game.Online.API
public IBindable<UserActivity> Activity => activity;
public IBindable<UserStatistics> Statistics => statistics;
public INotificationsClient NotificationsClient { get; }
public Language Language => game.CurrentLanguage.Value;
private Bindable<APIUser> localUser { get; } = new Bindable<APIUser>(createGuestUser());
@ -78,11 +78,6 @@ namespace osu.Game.Online.API
private readonly Logger log;
private string webSocketEndpointUrl;
[CanBeNull]
private OsuClientWebSocket webSocket;
public APIAccess(OsuGameBase game, OsuConfigManager config, EndpointConfiguration endpointConfiguration, string versionHash)
{
this.game = game;
@ -91,7 +86,7 @@ namespace osu.Game.Online.API
APIEndpointUrl = endpointConfiguration.APIEndpointUrl;
WebsiteRootUrl = endpointConfiguration.WebsiteRootUrl;
webSocketEndpointUrl = endpointConfiguration.NotificationsWebSocketEndpointUrl;
NotificationsClient = new WebSocketNotificationsClientConnector(this);
authentication = new OAuth(endpointConfiguration.APIClientID, endpointConfiguration.APIClientSecret, APIEndpointUrl);
log = Logger.GetLogger(LoggingTarget.Network);
@ -368,33 +363,11 @@ namespace osu.Game.Online.API
state.Value = APIState.RequiresSecondFactorAuth;
try
NotificationsClient.MessageReceived += msg =>
{
webSocket?.DisposeAsync().AsTask().WaitSafely();
var newSocket = new OsuClientWebSocket(this, webSocketEndpointUrl);
newSocket.MessageReceived += async msg =>
{
if (msg.Event == @"verified")
{
state.Value = APIState.Online;
await newSocket.DisposeAsync().ConfigureAwait(false);
if (webSocket == newSocket)
webSocket = null;
}
};
newSocket.Closed += ex =>
{
Logger.Error(ex, "Connection with account verification endpoint closed unexpectedly. Please supply account verification code manually.", LoggingTarget.Network);
return Task.CompletedTask;
};
webSocket = newSocket;
webSocket.ConnectAsync(cancellationToken.Token).WaitSafely();
}
catch (Exception ex)
{
Logger.Error(ex, "Failed to set up connection with account verification endpoint. Please supply account verification code manually.", LoggingTarget.Network);
}
if (msg.Event == @"verified")
state.Value = APIState.Online;
};
}
public void AuthenticateSecondFactor(string code)
@ -407,8 +380,7 @@ namespace osu.Game.Online.API
public IHubClientConnector GetHubConnector(string clientName, string endpoint, bool preferMessagePack) =>
new HubClientConnector(clientName, endpoint, this, versionHash, preferMessagePack);
public NotificationsClientConnector GetNotificationsConnector() =>
new WebSocketNotificationsClientConnector(this);
public IChatClient GetChatClient() => new WebSocketChatClient(this);
public RegistrationRequest.RegistrationRequestErrors CreateAccount(string email, string username, string password)
{
@ -626,7 +598,6 @@ namespace osu.Game.Online.API
flushQueue();
cancellationToken.Cancel();
webSocket?.DisposeAsync().AsTask().WaitSafely();
}
}

View File

@ -8,7 +8,8 @@ using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Tests;
using osu.Game.Users;
@ -30,6 +31,9 @@ namespace osu.Game.Online.API
public Bindable<UserStatistics?> Statistics { get; } = new Bindable<UserStatistics?>();
public DummyNotificationsClient NotificationsClient { get; } = new DummyNotificationsClient();
INotificationsClient IAPIProvider.NotificationsClient => NotificationsClient;
public Language Language => Language.en;
public string AccessToken => "token";
@ -159,7 +163,7 @@ namespace osu.Game.Online.API
public IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack) => null;
public NotificationsClientConnector GetNotificationsConnector() => new PollingNotificationsClientConnector(this);
public IChatClient GetChatClient() => new PollingChatClientConnector(this);
public RegistrationRequest.RegistrationRequestErrors? CreateAccount(string email, string username, string password)
{

View File

@ -6,7 +6,8 @@ using System.Threading.Tasks;
using osu.Framework.Bindables;
using osu.Game.Localisation;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Online.Chat;
using osu.Game.Online.Notifications.WebSocket;
using osu.Game.Users;
namespace osu.Game.Online.API
@ -136,9 +137,14 @@ namespace osu.Game.Online.API
IHubClientConnector? GetHubConnector(string clientName, string endpoint, bool preferMessagePack = true);
/// <summary>
/// Constructs a new <see cref="NotificationsClientConnector"/>.
/// Accesses the <see cref="INotificationsClient"/> used to receive asynchronous notifications from web.
/// </summary>
NotificationsClientConnector GetNotificationsConnector();
INotificationsClient NotificationsClient { get; }
/// <summary>
/// Creates a <see cref="IChatClient"/> instance to use in order to chat.
/// </summary>
IChatClient GetChatClient();
/// <summary>
/// Create a new user account. This is a blocking operation.

View File

@ -16,7 +16,6 @@ using osu.Game.Database;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Online.Notifications;
using osu.Game.Overlays.Chat.Listing;
namespace osu.Game.Online.Chat
@ -64,13 +63,8 @@ namespace osu.Game.Online.Chat
/// </summary>
public IBindableList<Channel> AvailableChannels => availableChannels;
/// <summary>
/// Whether the client responsible for channel notifications is connected.
/// </summary>
public bool NotificationsConnected => connector.IsConnected.Value;
private readonly IAPIProvider api;
private readonly NotificationsClientConnector connector;
private readonly IChatClient chatClient;
[Resolved]
private UserLookupCache users { get; set; }
@ -85,7 +79,7 @@ namespace osu.Game.Online.Chat
{
this.api = api;
connector = api.GetNotificationsConnector();
chatClient = api.GetChatClient();
CurrentChannel.ValueChanged += currentChannelChanged;
}
@ -93,15 +87,11 @@ namespace osu.Game.Online.Chat
[BackgroundDependencyLoader]
private void load()
{
connector.ChannelJoined += ch => Schedule(() => joinChannel(ch));
connector.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
connector.NewMessages += msgs => Schedule(() => addMessages(msgs));
connector.PresenceReceived += () => Schedule(initializeChannels);
connector.Start();
chatClient.ChannelJoined += ch => Schedule(() => joinChannel(ch));
chatClient.ChannelParted += ch => Schedule(() => leaveChannel(getChannel(ch), false));
chatClient.NewMessages += msgs => Schedule(() => addMessages(msgs));
chatClient.PresenceReceived += () => Schedule(initializeChannels);
chatClient.RequestPresence();
apiState.BindTo(api.State);
apiState.BindValueChanged(_ => SendAck(), true);
@ -655,7 +645,7 @@ namespace osu.Game.Online.Chat
protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
connector?.Dispose();
chatClient?.Dispose();
}
}

View File

@ -0,0 +1,39 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Generic;
namespace osu.Game.Online.Chat
{
/// <summary>
/// Interface for consuming online chat.
/// </summary>
public interface IChatClient : IDisposable
{
/// <summary>
/// Fired when a <see cref="Channel"/> has been joined.
/// </summary>
event Action<Channel>? ChannelJoined;
/// <summary>
/// Fired when a <see cref="Channel"/> has been parted.
/// </summary>
event Action<Channel>? ChannelParted;
/// <summary>
/// Fired when new <see cref="Message"/>s have arrived from the server.
/// </summary>
event Action<List<Message>>? NewMessages;
/// <summary>
/// Requests presence information from the server.
/// </summary>
void RequestPresence();
/// <summary>
/// Fired when the initial user presence information has been received.
/// </summary>
event Action? PresenceReceived;
}
}

View File

@ -0,0 +1,144 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using Newtonsoft.Json;
using osu.Framework.Bindables;
using osu.Framework.Extensions;
using osu.Framework.Logging;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Notifications.WebSocket;
namespace osu.Game.Online.Chat
{
public class WebSocketChatClient : IChatClient
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
private readonly IAPIProvider api;
private readonly INotificationsClient client;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
public WebSocketChatClient(IAPIProvider api)
{
this.api = api;
client = api.NotificationsClient;
client.IsConnected.BindValueChanged(start, true);
}
private void start(ValueChangedEvent<bool> connected)
{
if (!connected.NewValue)
return;
client.MessageReceived += onMessageReceived;
client.SendAsync(new StartChatRequest()).WaitSafely();
RequestPresence();
}
public void RequestPresence()
{
var fetchReq = new GetUpdatesRequest(0);
fetchReq.Success += updates =>
{
if (updates?.Presence != null)
{
foreach (var channel in updates.Presence)
joinChannel(channel);
handleMessages(updates.Messages);
}
PresenceReceived?.Invoke();
};
api.Queue(fetchReq);
}
private void onMessageReceived(SocketMessage message)
{
switch (message.Event)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
joinChannel(joinedChannel);
break;
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
partChannel(partedChannel);
break;
case @"chat.message.new":
Debug.Assert(message.Data != null);
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
foreach (var msg in messageData.Messages)
postToChannel(msg);
break;
}
}
private void postToChannel(Message message)
{
if (channelsMap.TryGetValue(message.ChannelId, out Channel? channel))
{
joinChannel(channel);
NewMessages?.Invoke(new List<Message> { message });
return;
}
var req = new GetChannelRequest(message.ChannelId);
req.Success += response =>
{
joinChannel(channelsMap[message.ChannelId] = response.Channel);
NewMessages?.Invoke(new List<Message> { message });
};
req.Failure += ex => Logger.Error(ex, "Failed to join channel");
api.Queue(req);
}
private void joinChannel(Channel ch)
{
ch.Joined.Value = true;
ChannelJoined?.Invoke(ch);
}
private void partChannel(Channel channel) => ChannelParted?.Invoke(channel);
private void handleMessages(List<Message>? messages)
{
if (messages == null)
return;
NewMessages?.Invoke(messages);
}
public void Dispose()
{
client.IsConnected.ValueChanged -= start;
client.MessageReceived -= onMessageReceived;
}
}
}

View File

@ -1,42 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications
{
/// <summary>
/// An abstract connector or <see cref="NotificationsClient"/>s.
/// </summary>
public abstract class NotificationsClientConnector : PersistentEndpointClientConnector
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
protected NotificationsClientConnector(IAPIProvider api)
: base(api)
{
}
protected sealed override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var client = await BuildNotificationClientAsync(cancellationToken).ConfigureAwait(false);
client.ChannelJoined = c => ChannelJoined?.Invoke(c);
client.ChannelParted = c => ChannelParted?.Invoke(c);
client.NewMessages = m => NewMessages?.Invoke(m);
client.PresenceReceived = () => PresenceReceived?.Invoke();
return client;
}
protected abstract Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken);
}
}

View File

@ -0,0 +1,29 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
public class DummyNotificationsClient : INotificationsClient
{
public IBindable<bool> IsConnected => new BindableBool(true);
public event Action<SocketMessage>? MessageReceived;
public Func<SocketMessage, bool>? HandleMessage;
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (HandleMessage?.Invoke(message) != true)
throw new InvalidOperationException($@"{nameof(DummyNotificationsClient)} cannot process this message.");
return Task.CompletedTask;
}
public void Receive(SocketMessage message) => MessageReceived?.Invoke(message);
}
}

View File

@ -0,0 +1,31 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
using System.Threading.Tasks;
using osu.Framework.Bindables;
namespace osu.Game.Online.Notifications.WebSocket
{
/// <summary>
/// A client for asynchronous notifications sent by osu-web.
/// </summary>
public interface INotificationsClient
{
/// <summary>
/// Whether this <see cref="INotificationsClient"/> is currently connected to a server.
/// </summary>
IBindable<bool> IsConnected { get; }
/// <summary>
/// Invoked when a new <see cref="SocketMessage"/> arrives for this client.
/// </summary>
event Action<SocketMessage>? MessageReceived;
/// <summary>
/// Sends a <see cref="SocketMessage"/> to the notification server.
/// </summary>
Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default);
}
}

View File

@ -1,102 +1,115 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Collections.Concurrent;
using System;
using System.Diagnostics;
using System.Net;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
using osu.Framework.Extensions.TypeExtensions;
using osu.Framework.Logging;
namespace osu.Game.Online.Notifications.WebSocket
{
/// <summary>
/// A notifications client which receives events via a websocket.
/// </summary>
public class WebSocketNotificationsClient : NotificationsClient
public class WebSocketNotificationsClient : PersistentEndpointClient
{
private readonly OsuClientWebSocket socket;
private readonly ConcurrentDictionary<long, Channel> channelsMap = new ConcurrentDictionary<long, Channel>();
public event Action<SocketMessage>? MessageReceived;
public WebSocketNotificationsClient(IAPIProvider api, string endpoint)
: base(api)
private readonly ClientWebSocket socket;
private readonly string endpoint;
public WebSocketNotificationsClient(ClientWebSocket socket, string endpoint)
{
socket = new OsuClientWebSocket(api, endpoint);
socket.MessageReceived += onMessageReceivedAsync;
socket.Closed += InvokeClosed;
this.socket = socket;
this.endpoint = endpoint;
}
public override async Task ConnectAsync(CancellationToken cancellationToken)
{
await socket.ConnectAsync(cancellationToken).ConfigureAwait(false);
await socket.SendMessage(new StartChatRequest(), CancellationToken.None).ConfigureAwait(false);
await base.ConnectAsync(cancellationToken).ConfigureAwait(false);
await socket.ConnectAsync(new Uri(endpoint), cancellationToken).ConfigureAwait(false);
runReadLoop(cancellationToken);
}
private async Task onMessageReceivedAsync(SocketMessage message)
private void runReadLoop(CancellationToken cancellationToken) => Task.Run(async () =>
{
switch (message.Event)
byte[] buffer = new byte[1024];
StringBuilder messageResult = new StringBuilder();
while (!cancellationToken.IsCancellationRequested)
{
case @"chat.channel.join":
Debug.Assert(message.Data != null);
try
{
WebSocketReceiveResult result = await socket.ReceiveAsync(buffer, cancellationToken).ConfigureAwait(false);
Channel? joinedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(joinedChannel != null);
switch (result.MessageType)
{
case WebSocketMessageType.Text:
messageResult.Append(Encoding.UTF8.GetString(buffer[..result.Count]));
HandleChannelJoined(joinedChannel);
break;
if (result.EndOfMessage)
{
SocketMessage? message = JsonConvert.DeserializeObject<SocketMessage>(messageResult.ToString());
messageResult.Clear();
case @"chat.channel.part":
Debug.Assert(message.Data != null);
Debug.Assert(message != null);
Channel? partedChannel = JsonConvert.DeserializeObject<Channel>(message.Data.ToString());
Debug.Assert(partedChannel != null);
if (message.Error != null)
{
Logger.Log($"{GetType().ReadableName()} error: {message.Error}", LoggingTarget.Network);
break;
}
HandleChannelParted(partedChannel);
break;
MessageReceived?.Invoke(message);
}
case @"chat.message.new":
Debug.Assert(message.Data != null);
break;
NewChatMessageData? messageData = JsonConvert.DeserializeObject<NewChatMessageData>(message.Data.ToString());
Debug.Assert(messageData != null);
case WebSocketMessageType.Binary:
throw new NotImplementedException("Binary message type not supported.");
foreach (var msg in messageData.Messages)
HandleChannelJoined(await getChannel(msg.ChannelId).ConfigureAwait(false));
case WebSocketMessageType.Close:
throw new WebException("Connection closed by remote host.");
}
}
catch (Exception ex)
{
await InvokeClosed(ex).ConfigureAwait(false);
return;
}
}
}, cancellationToken);
HandleMessages(messageData.Messages);
break;
private async Task closeAsync()
{
try
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, @"Disconnecting", CancellationToken.None).ConfigureAwait(false);
}
catch
{
// Closure can fail if the connection is aborted. Don't really care since it's disposed anyway.
}
}
private async Task<Channel> getChannel(long channelId)
public async Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (channelsMap.TryGetValue(channelId, out Channel? channel))
return channel;
if (socket.State != WebSocketState.Open)
return;
var tsc = new TaskCompletionSource<Channel>();
var req = new GetChannelRequest(channelId);
req.Success += response =>
{
channelsMap[channelId] = response.Channel;
tsc.SetResult(response.Channel);
};
req.Failure += ex => tsc.SetException(ex);
API.Queue(req);
return await tsc.Task.ConfigureAwait(false);
await socket.SendAsync(Encoding.UTF8.GetBytes(JsonConvert.SerializeObject(message)), WebSocketMessageType.Text, true, cancellationToken ?? CancellationToken.None).ConfigureAwait(false);
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync().ConfigureAwait(false);
await socket.DisposeAsync().ConfigureAwait(false);
await closeAsync().ConfigureAwait(false);
socket.Dispose();
}
}
}

View File

@ -1,6 +1,9 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Net;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
@ -11,17 +14,20 @@ namespace osu.Game.Online.Notifications.WebSocket
/// <summary>
/// A connector for <see cref="WebSocketNotificationsClient"/>s that receive events via a websocket.
/// </summary>
public class WebSocketNotificationsClientConnector : NotificationsClientConnector
public class WebSocketNotificationsClientConnector : PersistentEndpointClientConnector, INotificationsClient
{
public event Action<SocketMessage>? MessageReceived;
private readonly IAPIProvider api;
public WebSocketNotificationsClientConnector(IAPIProvider api)
: base(api)
{
this.api = api;
Start();
}
protected override async Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken)
protected override async Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var tcs = new TaskCompletionSource<string>();
@ -31,7 +37,24 @@ namespace osu.Game.Online.Notifications.WebSocket
api.Queue(req);
string endpoint = await tcs.Task.ConfigureAwait(false);
return new WebSocketNotificationsClient(api, endpoint);
ClientWebSocket socket = new ClientWebSocket();
socket.Options.SetRequestHeader(@"Authorization", @$"Bearer {api.AccessToken}");
socket.Options.Proxy = WebRequest.DefaultWebProxy;
if (socket.Options.Proxy != null)
socket.Options.Proxy.Credentials = CredentialCache.DefaultCredentials;
var client = new WebSocketNotificationsClient(socket, endpoint);
client.MessageReceived += msg => MessageReceived?.Invoke(msg);
return client;
}
public Task SendAsync(SocketMessage message, CancellationToken? cancellationToken = default)
{
if (CurrentConnection is not WebSocketNotificationsClient webSocketClient)
return Task.CompletedTask;
return webSocketClient.SendAsync(message, cancellationToken);
}
}
}

View File

@ -15,6 +15,7 @@ using osu.Game.Localisation;
using osu.Game.Overlays.Notifications;
using osu.Game.Overlays.Settings.Sections.Maintenance;
using osu.Game.Updater;
using osu.Game.Utils;
using SharpCompress.Archives.Zip;
namespace osu.Game.Overlays.Settings.Sections.General
@ -111,7 +112,8 @@ namespace osu.Game.Overlays.Settings.Sections.General
using (var outStream = storage.CreateFileSafely(archive_filename))
using (var zip = ZipArchive.Create())
{
foreach (string? f in logStorage.GetFiles(string.Empty, "*.log")) zip.AddEntry(f, logStorage.GetStream(f), true);
foreach (string? f in logStorage.GetFiles(string.Empty, "*.log"))
FileUtils.AttemptOperation(z => z.AddEntry(f, logStorage.GetStream(f), true), zip);
zip.SaveTo(outStream);
}

View File

@ -370,7 +370,7 @@ namespace osu.Game.Rulesets.Scoring
if (rank.Value == ScoreRank.F)
return;
rank.Value = RankFromAccuracy(Accuracy.Value);
rank.Value = RankFromScore(Accuracy.Value, ScoreResultCounts);
foreach (var mod in Mods.Value.OfType<IApplicableToScoreProcessor>())
rank.Value = mod.AdjustRank(Rank.Value, Accuracy.Value);
}
@ -505,7 +505,7 @@ namespace osu.Game.Rulesets.Scoring
/// <summary>
/// Given an accuracy (0..1), return the correct <see cref="ScoreRank"/>.
/// </summary>
public virtual ScoreRank RankFromAccuracy(double accuracy)
public virtual ScoreRank RankFromScore(double accuracy, IReadOnlyDictionary<HitResult, int> results)
{
if (accuracy == accuracy_cutoff_x)
return ScoreRank.X;

View File

@ -4,6 +4,7 @@
#nullable disable
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
@ -19,6 +20,7 @@ using osu.Game.Replays.Legacy;
using osu.Game.Rulesets;
using osu.Game.Rulesets.Mods;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using SharpCompress.Compressors.LZMA;
namespace osu.Game.Scoring.Legacy
@ -38,7 +40,6 @@ namespace osu.Game.Scoring.Legacy
};
WorkingBeatmap workingBeatmap;
byte[] compressedScoreInfo = null;
using (SerializationReader sr = new SerializationReader(stream))
{
@ -107,6 +108,8 @@ namespace osu.Game.Scoring.Legacy
else if (version >= 20121008)
scoreInfo.LegacyOnlineID = sr.ReadInt32();
byte[] compressedScoreInfo = null;
if (version >= 30000001)
compressedScoreInfo = sr.ReadByteArray();
@ -130,10 +133,12 @@ namespace osu.Game.Scoring.Legacy
}
}
if (score.ScoreInfo.IsLegacyScore || compressedScoreInfo == null)
PopulateLegacyAccuracyAndRank(score.ScoreInfo);
else
populateLazerAccuracyAndRank(score.ScoreInfo);
PopulateMaximumStatistics(score.ScoreInfo, workingBeatmap);
if (score.ScoreInfo.IsLegacyScore)
score.ScoreInfo.LegacyTotalScore = score.ScoreInfo.TotalScore;
StandardisedScoreMigrationTools.UpdateFromLegacy(score.ScoreInfo, workingBeatmap);
// before returning for database import, we must restore the database-sourced BeatmapInfo.
// if not, the clone operation in GetPlayableBeatmap will cause a dereference and subsequent database exception.
@ -171,121 +176,65 @@ namespace osu.Game.Scoring.Legacy
}
/// <summary>
/// Populates the accuracy of a given <see cref="ScoreInfo"/> from its contained statistics.
/// Populates the <see cref="ScoreInfo.MaximumStatistics"/> for a given <see cref="ScoreInfo"/>.
/// </summary>
/// <remarks>
/// Legacy use only.
/// </remarks>
/// <param name="score">The <see cref="ScoreInfo"/> to populate.</param>
public static void PopulateLegacyAccuracyAndRank(ScoreInfo score)
/// <param name="score">The score to populate the statistics of.</param>
/// <param name="workingBeatmap">The corresponding <see cref="WorkingBeatmap"/>.</param>
internal static void PopulateMaximumStatistics(ScoreInfo score, WorkingBeatmap workingBeatmap)
{
int countMiss = score.GetCountMiss() ?? 0;
int count50 = score.GetCount50() ?? 0;
int count100 = score.GetCount100() ?? 0;
int count300 = score.GetCount300() ?? 0;
int countGeki = score.GetCountGeki() ?? 0;
int countKatu = score.GetCountKatu() ?? 0;
Debug.Assert(score.BeatmapInfo != null);
switch (score.Ruleset.OnlineID)
if (score.MaximumStatistics.Select(kvp => kvp.Value).Sum() > 0)
return;
var ruleset = score.Ruleset.Detach();
var rulesetInstance = ruleset.CreateInstance();
var scoreProcessor = rulesetInstance.CreateScoreProcessor();
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResults()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
foreach ((HitResult result, int count) in score.Statistics)
{
case 0:
switch (result)
{
int totalHits = count50 + count100 + count300 + countMiss;
score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + count300 * 300) / (totalHits * 300) : 1;
case HitResult.LargeTickHit:
case HitResult.LargeTickMiss:
score.MaximumStatistics[HitResult.LargeTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.LargeTickHit) + count;
break;
float ratio300 = (float)count300 / totalHits;
float ratio50 = (float)count50 / totalHits;
case HitResult.SmallTickHit:
case HitResult.SmallTickMiss:
score.MaximumStatistics[HitResult.SmallTickHit] = score.MaximumStatistics.GetValueOrDefault(HitResult.SmallTickHit) + count;
break;
if (ratio300 == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
score.Rank = ScoreRank.A;
else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
score.Rank = ScoreRank.B;
else if (ratio300 > 0.6)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break;
}
case HitResult.IgnoreHit:
case HitResult.IgnoreMiss:
case HitResult.SmallBonus:
case HitResult.LargeBonus:
break;
case 1:
{
int totalHits = count50 + count100 + count300 + countMiss;
score.Accuracy = totalHits > 0 ? (double)(count100 * 150 + count300 * 300) / (totalHits * 300) : 1;
float ratio300 = (float)count300 / totalHits;
float ratio50 = (float)count50 / totalHits;
if (ratio300 == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (ratio300 > 0.9 && ratio50 <= 0.01 && countMiss == 0)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if ((ratio300 > 0.8 && countMiss == 0) || ratio300 > 0.9)
score.Rank = ScoreRank.A;
else if ((ratio300 > 0.7 && countMiss == 0) || ratio300 > 0.8)
score.Rank = ScoreRank.B;
else if (ratio300 > 0.6)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break;
}
case 2:
{
int totalHits = count50 + count100 + count300 + countMiss + countKatu;
score.Accuracy = totalHits > 0 ? (double)(count50 + count100 + count300) / totalHits : 1;
if (score.Accuracy == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (score.Accuracy > 0.98)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if (score.Accuracy > 0.94)
score.Rank = ScoreRank.A;
else if (score.Accuracy > 0.9)
score.Rank = ScoreRank.B;
else if (score.Accuracy > 0.85)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break;
}
case 3:
{
int totalHits = count50 + count100 + count300 + countMiss + countGeki + countKatu;
score.Accuracy = totalHits > 0 ? (double)(count50 * 50 + count100 * 100 + countKatu * 200 + (count300 + countGeki) * 300) / (totalHits * 300) : 1;
if (score.Accuracy == 1)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.XH : ScoreRank.X;
else if (score.Accuracy > 0.95)
score.Rank = score.Mods.Any(m => m is ModHidden || m is ModFlashlight) ? ScoreRank.SH : ScoreRank.S;
else if (score.Accuracy > 0.9)
score.Rank = ScoreRank.A;
else if (score.Accuracy > 0.8)
score.Rank = ScoreRank.B;
else if (score.Accuracy > 0.7)
score.Rank = ScoreRank.C;
else
score.Rank = ScoreRank.D;
break;
default:
score.MaximumStatistics[maxBasicResult] = score.MaximumStatistics.GetValueOrDefault(maxBasicResult) + count;
break;
}
}
}
private void populateLazerAccuracyAndRank(ScoreInfo scoreInfo)
{
scoreInfo.Accuracy = StandardisedScoreMigrationTools.ComputeAccuracy(scoreInfo);
if (!score.IsLegacyScore)
return;
var rank = currentRuleset.CreateScoreProcessor().RankFromAccuracy(scoreInfo.Accuracy);
#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(workingBeatmap);
var attributes = calculator.Calculate(score.Mods);
foreach (var mod in scoreInfo.Mods.OfType<IApplicableToScoreProcessor>())
rank = mod.AdjustRank(rank, scoreInfo.Accuracy);
scoreInfo.Rank = rank;
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
}
private void readLegacyReplay(Replay replay, StreamReader reader)

View File

@ -43,9 +43,10 @@ namespace osu.Game.Scoring.Legacy
/// 30000012: Fix incorrect total score conversion on selected beatmaps after implementing the more correct
/// <see cref="LegacyRulesetExtensions.CalculateDifficultyPeppyStars"/> method. Reconvert all scores.
/// </description></item>
/// <item><description>30000013: All local scores will use lazer definitions of ranks for consistency. Recalculates the rank of all scores.</description></item>
/// </list>
/// </remarks>
public const int LATEST_VERSION = 30000012;
public const int LATEST_VERSION = 30000013;
/// <summary>
/// The first stable-compatible YYYYMMDD format version given to lazer usage of replays.

View File

@ -17,7 +17,6 @@ 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.Scoring;
using Realms;
namespace osu.Game.Scoring
@ -91,8 +90,6 @@ namespace osu.Game.Scoring
ArgumentNullException.ThrowIfNull(model.BeatmapInfo);
ArgumentNullException.ThrowIfNull(model.Ruleset);
PopulateMaximumStatistics(model);
if (string.IsNullOrEmpty(model.StatisticsJson))
model.StatisticsJson = JsonConvert.SerializeObject(model.Statistics);
@ -103,75 +100,6 @@ namespace osu.Game.Scoring
// 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;
StandardisedScoreMigrationTools.UpdateFromLegacy(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();
var scoreProcessor = rulesetInstance.CreateScoreProcessor();
Debug.Assert(rulesetInstance != null);
// Populate the maximum statistics.
HitResult maxBasicResult = rulesetInstance.GetHitResults()
.Select(h => h.result)
.Where(h => h.IsBasic()).MaxBy(scoreProcessor.GetBaseScoreForResult);
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).

View File

@ -5,6 +5,7 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Linq.Expressions;
using System.Threading;
@ -26,6 +27,7 @@ namespace osu.Game.Scoring
{
public class ScoreManager : ModelManager<ScoreInfo>, IModelImporter<ScoreInfo>
{
private readonly Func<BeatmapManager> beatmaps;
private readonly OsuConfigManager configManager;
private readonly ScoreImporter scoreImporter;
private readonly LegacyScoreExporter scoreExporter;
@ -44,6 +46,7 @@ namespace osu.Game.Scoring
OsuConfigManager configManager = null)
: base(storage, realm)
{
this.beatmaps = beatmaps;
this.configManager = configManager;
scoreImporter = new ScoreImporter(rulesets, beatmaps, storage, realm, api)
@ -171,7 +174,11 @@ namespace osu.Game.Scoring
/// 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) => scoreImporter.PopulateMaximumStatistics(score);
public void PopulateMaximumStatistics(ScoreInfo score)
{
Debug.Assert(score.BeatmapInfo != null);
LegacyScoreDecoder.PopulateMaximumStatistics(score, beatmaps().GetWorkingBeatmap(score.BeatmapInfo.Detach()));
}
#region Implementation of IPresentImports<ScoreInfo>

View File

@ -29,10 +29,11 @@ namespace osu.Game.Screens.Edit
/// Set a divisor, updating the valid divisor range appropriately.
/// </summary>
/// <param name="divisor">The intended divisor.</param>
public void SetArbitraryDivisor(int divisor)
/// <param name="preferKnownPresets">Forces changing the valid divisors to a known preset.</param>
public void SetArbitraryDivisor(int divisor, bool preferKnownPresets = false)
{
// If the current valid divisor range doesn't contain the proposed value, attempt to find one which does.
if (!ValidDivisors.Value.Presets.Contains(divisor))
if (preferKnownPresets || !ValidDivisors.Value.Presets.Contains(divisor))
{
if (BeatDivisorPresetCollection.COMMON.Presets.Contains(divisor))
ValidDivisors.Value = BeatDivisorPresetCollection.COMMON;

View File

@ -208,11 +208,11 @@ namespace osu.Game.Screens.Edit.Compose.Components
switch (currentType)
{
case BeatDivisorType.Common:
beatDivisor.SetArbitraryDivisor(4);
beatDivisor.SetArbitraryDivisor(4, true);
break;
case BeatDivisorType.Triplets:
beatDivisor.SetArbitraryDivisor(6);
beatDivisor.SetArbitraryDivisor(6, true);
break;
case BeatDivisorType.Custom:

View File

@ -33,7 +33,7 @@ namespace osu.Game.Screens.Play.HUD
public bool UsesFixedAnchor { get; set; }
protected override LocalisableString FormatCount(long count) => count.ToLocalisableString();
protected override LocalisableString FormatCount(long count) => count.ToString();
protected override IHasText CreateText() => scoreText = new ArgonScoreTextComponent(Anchor.TopRight, BeatmapsetsStrings.ShowScoreboardHeadersScore.ToUpper())
{

View File

@ -21,6 +21,7 @@ using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Skinning;
using osuTK;
using osuTK.Graphics;
namespace osu.Game.Screens.Ranking.Expanded.Accuracy
{
@ -111,6 +112,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
private readonly double accuracyD;
private readonly bool withFlair;
private readonly bool isFailedSDueToMisses;
private RankText failedSRankText;
public AccuracyCircle(ScoreInfo score, bool withFlair = false)
{
this.score = score;
@ -119,10 +123,13 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
ScoreProcessor scoreProcessor = score.Ruleset.CreateInstance().CreateScoreProcessor();
accuracyX = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.X);
accuracyS = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.S);
accuracyA = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.A);
accuracyB = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.B);
accuracyC = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.C);
accuracyD = scoreProcessor.AccuracyCutoffFromRank(ScoreRank.D);
isFailedSDueToMisses = score.Accuracy >= accuracyS && score.Rank == ScoreRank.A;
}
[BackgroundDependencyLoader]
@ -249,6 +256,9 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
if (withFlair)
{
if (isFailedSDueToMisses)
AddInternal(failedSRankText = new RankText(ScoreRank.S));
AddRangeInternal(new Drawable[]
{
rankImpactSound = new PoolableSkinnableSample(new SampleInfo(impactSampleName)),
@ -387,6 +397,31 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
});
}
}
if (isFailedSDueToMisses)
{
const double adjust_duration = 200;
using (BeginDelayedSequence(TEXT_APPEAR_DELAY - adjust_duration))
{
failedSRankText.FadeIn(adjust_duration);
using (BeginDelayedSequence(adjust_duration))
{
failedSRankText
.FadeColour(Color4.Red, 800, Easing.Out)
.RotateTo(10, 1000, Easing.Out)
.MoveToY(100, 1000, Easing.In)
.FadeOut(800, Easing.Out);
accuracyCircle
.FillTo(accuracyS - NOTCH_WIDTH_PERCENTAGE / 2 - visual_alignment_offset, 70, Easing.OutQuint);
badges.Single(b => b.Rank == getRank(ScoreRank.S))
.FadeOut(70, Easing.OutQuint);
}
}
}
}
}

View File

@ -32,7 +32,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
/// </summary>
private readonly double displayPosition;
private readonly ScoreRank rank;
public readonly ScoreRank Rank;
private Drawable rankContainer;
private Drawable overlay;
@ -47,7 +47,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
{
Accuracy = accuracy;
displayPosition = position;
this.rank = rank;
Rank = rank;
RelativeSizeAxes = Axes.Both;
Alpha = 0;
@ -62,7 +62,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
Size = new Vector2(28, 14),
Children = new[]
{
new DrawableRank(rank),
new DrawableRank(Rank),
overlay = new CircularContainer
{
RelativeSizeAxes = Axes.Both,
@ -71,7 +71,7 @@ namespace osu.Game.Screens.Ranking.Expanded.Accuracy
EdgeEffect = new EdgeEffectParameters
{
Type = EdgeEffectType.Glow,
Colour = OsuColour.ForRank(rank).Opacity(0.2f),
Colour = OsuColour.ForRank(Rank).Opacity(0.2f),
Radius = 10,
},
Child = new Box

View File

@ -6,34 +6,39 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests;
using osu.Game.Online.Chat;
namespace osu.Game.Online.Notifications
namespace osu.Game.Tests
{
/// <summary>
/// An abstract client which receives notification-related events (chat/notifications).
/// </summary>
public abstract class NotificationsClient : PersistentEndpointClient
public class PollingChatClient : PersistentEndpointClient
{
public Action<Channel>? ChannelJoined;
public Action<Channel>? ChannelParted;
public Action<List<Message>>? NewMessages;
public Action? PresenceReceived;
public event Action<Channel>? ChannelJoined;
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
protected readonly IAPIProvider API;
private readonly IAPIProvider api;
private long lastMessageId;
protected NotificationsClient(IAPIProvider api)
public PollingChatClient(IAPIProvider api)
{
API = api;
this.api = api;
}
public override Task ConnectAsync(CancellationToken cancellationToken)
{
API.Queue(CreateInitialFetchRequest(0));
Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
await api.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true);
await Task.Delay(1000, cancellationToken).ConfigureAwait(true);
}
}, cancellationToken);
return Task.CompletedTask;
}
@ -46,11 +51,11 @@ namespace osu.Game.Online.Notifications
if (updates?.Presence != null)
{
foreach (var channel in updates.Presence)
HandleChannelJoined(channel);
handleChannelJoined(channel);
//todo: handle left channels
HandleMessages(updates.Messages);
handleMessages(updates.Messages);
}
PresenceReceived?.Invoke();
@ -59,15 +64,13 @@ namespace osu.Game.Online.Notifications
return fetchReq;
}
protected void HandleChannelJoined(Channel channel)
private void handleChannelJoined(Channel channel)
{
channel.Joined.Value = true;
ChannelJoined?.Invoke(channel);
}
protected void HandleChannelParted(Channel channel) => ChannelParted?.Invoke(channel);
protected void HandleMessages(List<Message>? messages)
private void handleMessages(List<Message>? messages)
{
if (messages == null)
return;

View File

@ -0,0 +1,48 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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.Threading;
using System.Threading.Tasks;
using osu.Game.Online;
using osu.Game.Online.API;
using osu.Game.Online.Chat;
namespace osu.Game.Tests
{
public class PollingChatClientConnector : PersistentEndpointClientConnector, IChatClient
{
public event Action<Channel>? ChannelJoined;
public event Action<Channel>? ChannelParted
{
add { }
remove { }
}
public event Action<List<Message>>? NewMessages;
public event Action? PresenceReceived;
public void RequestPresence()
{
// don't really need to do anything special if we poll every second anyway.
}
public PollingChatClientConnector(IAPIProvider api)
: base(api)
{
}
protected sealed override Task<PersistentEndpointClient> BuildConnectionAsync(CancellationToken cancellationToken)
{
var client = new PollingChatClient(API);
client.ChannelJoined += c => ChannelJoined?.Invoke(c);
client.NewMessages += m => NewMessages?.Invoke(m);
client.PresenceReceived += () => PresenceReceived?.Invoke();
return Task.FromResult<PersistentEndpointClient>(client);
}
}
}

View File

@ -1,35 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Notifications;
namespace osu.Game.Tests
{
/// <summary>
/// A notifications client which polls for new messages every second.
/// </summary>
public class PollingNotificationsClient : NotificationsClient
{
public PollingNotificationsClient(IAPIProvider api)
: base(api)
{
}
public override Task ConnectAsync(CancellationToken cancellationToken)
{
Task.Run(async () =>
{
while (!cancellationToken.IsCancellationRequested)
{
await API.PerformAsync(CreateInitialFetchRequest()).ConfigureAwait(true);
await Task.Delay(1000, cancellationToken).ConfigureAwait(true);
}
}, cancellationToken);
return Task.CompletedTask;
}
}
}

View File

@ -1,24 +0,0 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System.Threading;
using System.Threading.Tasks;
using osu.Game.Online.API;
using osu.Game.Online.Notifications;
namespace osu.Game.Tests
{
/// <summary>
/// A connector for <see cref="PollingNotificationsClient"/>s that poll for new messages.
/// </summary>
public class PollingNotificationsClientConnector : NotificationsClientConnector
{
public PollingNotificationsClientConnector(IAPIProvider api)
: base(api)
{
}
protected override Task<NotificationsClient> BuildNotificationClientAsync(CancellationToken cancellationToken)
=> Task.FromResult((NotificationsClient)new PollingNotificationsClient(API));
}
}

View File

@ -0,0 +1,72 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
// See the LICENCE file in the repository root for full licence text.
using System;
using System.Threading;
namespace osu.Game.Utils
{
public static class FileUtils
{
/// <summary>
/// Attempt an IO operation multiple times and only throw if none of the attempts succeed.
/// </summary>
/// <param name="action">The action to perform.</param>
/// <param name="state">The provided state.</param>
/// <param name="attempts">The number of attempts (250ms wait between each).</param>
/// <param name="throwOnFailure">Whether to throw an exception on failure. If <c>false</c>, will silently fail.</param>
public static bool AttemptOperation<T>(Action<T> action, T state, int attempts = 10, bool throwOnFailure = true)
{
while (true)
{
try
{
action(state);
return true;
}
catch (Exception)
{
if (attempts-- == 0)
{
if (throwOnFailure)
throw;
return false;
}
}
Thread.Sleep(250);
}
}
/// <summary>
/// Attempt an IO operation multiple times and only throw if none of the attempts succeed.
/// </summary>
/// <param name="action">The action to perform.</param>
/// <param name="attempts">The number of attempts (250ms wait between each).</param>
/// <param name="throwOnFailure">Whether to throw an exception on failure. If <c>false</c>, will silently fail.</param>
public static bool AttemptOperation(Action action, int attempts = 10, bool throwOnFailure = true)
{
while (true)
{
try
{
action();
return true;
}
catch (Exception)
{
if (attempts-- == 0)
{
if (throwOnFailure)
throw;
return false;
}
}
Thread.Sleep(250);
}
}
}
}