Merge branch 'master' into beatmap-card-basics

This commit is contained in:
Bartłomiej Dach 2021-11-03 00:11:03 +01:00
commit cf7545e36c
No known key found for this signature in database
GPG Key ID: BCECCD4FA41F6497
46 changed files with 340 additions and 142 deletions

View File

@ -16,7 +16,7 @@
<EmbeddedResource Include="Resources\**\*.*" />
</ItemGroup>
<ItemGroup Label="Code Analysis">
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.2" PrivateAssets="All" />
<PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.3" PrivateAssets="All" />
<AdditionalFiles Include="$(MSBuildThisFileDirectory)CodeAnalysis\BannedSymbols.txt" />
<PackageReference Include="Microsoft.CodeAnalysis.NetAnalyzers" Version="5.0.3" PrivateAssets="All" />
</ItemGroup>

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -10,7 +10,7 @@
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -20,6 +20,7 @@ namespace osu.Android
[Activity(Theme = "@android:style/Theme.NoTitleBar", MainLauncher = true, ScreenOrientation = ScreenOrientation.FullUser, SupportsPictureInPicture = false, ConfigurationChanges = ConfigChanges.Orientation | ConfigChanges.ScreenSize, HardwareAccelerated = false, LaunchMode = LaunchMode.SingleInstance, Exported = true)]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osz", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osk", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataPathPattern = ".*\\\\.osr", DataHost = "*", DataMimeType = "*/*")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-beatmap-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-skin-archive")]
[IntentFilter(new[] { Intent.ActionView }, Categories = new[] { Intent.CategoryDefault }, DataScheme = "content", DataMimeType = "application/x-osu-replay")]

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -14,7 +14,7 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods) => new Score
{
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } },
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad" } },
Replay = new CatchAutoGenerator(beatmap).Generate(),
};
}

View File

@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Catch.Mods
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods) => new Score
{
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad!" } },
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!salad" } },
Replay = new CatchAutoGenerator(beatmap).Generate(),
};
}

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -15,7 +15,7 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods) => new Score
{
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } },
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus" } },
Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(),
};
}

View File

@ -16,7 +16,7 @@ namespace osu.Game.Rulesets.Mania.Mods
{
public override Score CreateReplayScore(IBeatmap beatmap, IReadOnlyList<Mod> mods) => new Score
{
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus!" } },
ScoreInfo = new ScoreInfo { User = new User { Username = "osu!topus" } },
Replay = new ManiaAutoGenerator((ManiaBeatmap)beatmap).Generate(),
};
}

View File

@ -15,13 +15,13 @@ namespace osu.Game.Rulesets.Osu.Tests
{
protected override string ResourceAssembly => "osu.Game.Rulesets.Osu";
[TestCase(6.5867229481955389d, "diffcalc-test")]
[TestCase(1.0416315570967911d, "zero-length-sliders")]
[TestCase(6.5295339534769958d, "diffcalc-test")]
[TestCase(1.1514260533755143d, "zero-length-sliders")]
public void Test(double expected, string name)
=> base.Test(expected, name);
[TestCase(8.2730989071947896d, "diffcalc-test")]
[TestCase(1.2726413186221039d, "zero-length-sliders")]
[TestCase(9.047752485219954d, "diffcalc-test")]
[TestCase(1.3985711787077566d, "zero-length-sliders")]
public void TestClockRateAdjusted(double expected, string name)
=> Test(expected, name, new OsuModDoubleTime());

View File

@ -0,0 +1,34 @@
// 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 NUnit.Framework;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Osu.Objects;
using osuTK;
namespace osu.Game.Rulesets.Osu.Tests
{
[TestFixture]
public class TestSceneNoSpinnerStacking : TestSceneOsuPlayer
{
protected override IBeatmap CreateBeatmap(RulesetInfo ruleset)
{
var beatmap = new Beatmap
{
BeatmapInfo = new BeatmapInfo
{
BaseDifficulty = new BeatmapDifficulty { OverallDifficulty = 10 },
Ruleset = ruleset
}
};
for (int i = 0; i < 512; i++)
{
if (i % 32 < 20)
beatmap.HitObjects.Add(new Spinner { Position = new Vector2(256, 192), StartTime = i * 200, EndTime = (i * 200) + 100 });
}
return beatmap;
}
}
}

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Moq" Version="4.16.1" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />

View File

@ -12,20 +12,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
{
public class OsuDifficultyHitObject : DifficultyHitObject
{
private const int normalized_radius = 52;
private const int normalized_radius = 50; // Change radius to 50 to make 100 the diameter. Easier for mental maths.
private const int min_delta_time = 25;
protected new OsuHitObject BaseObject => (OsuHitObject)base.BaseObject;
/// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms to account for simultaneous <see cref="OsuDifficultyHitObject"/>s.
/// </summary>
public double StrainTime { get; private set; }
/// <summary>
/// Normalized distance from the end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double JumpDistance { get; private set; }
/// <summary>
/// Minimum distance from the end position of the previous <see cref="OsuDifficultyHitObject"/> to the start position of this <see cref="OsuDifficultyHitObject"/>.
/// </summary>
public double MovementDistance { get; private set; }
/// <summary>
/// Normalized distance between the start and end position of the previous <see cref="OsuDifficultyHitObject"/>.
/// </summary>
@ -37,6 +38,21 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
/// </summary>
public double? Angle { get; private set; }
/// <summary>
/// Milliseconds elapsed since the end time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public double MovementTime { get; private set; }
/// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/> to the end time of the same previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public double TravelTime { get; private set; }
/// <summary>
/// Milliseconds elapsed since the start time of the previous <see cref="OsuDifficultyHitObject"/>, with a minimum of 25ms.
/// </summary>
public readonly double StrainTime;
private readonly OsuHitObject lastLastObject;
private readonly OsuHitObject lastObject;
@ -46,13 +62,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
this.lastLastObject = (OsuHitObject)lastLastObject;
this.lastObject = (OsuHitObject)lastObject;
setDistances();
// Capped to 25ms to prevent difficulty calculation breaking from simultaneous objects.
StrainTime = Math.Max(DeltaTime, min_delta_time);
// Capped to 25ms to prevent difficulty calculation breaking from simulatenous objects.
StrainTime = Math.Max(DeltaTime, 25);
setDistances(clockRate);
}
private void setDistances()
private void setDistances(double clockRate)
{
// We don't need to calculate either angle or distance when one of the last->curr objects is a spinner
if (BaseObject is Spinner || lastObject is Spinner)
@ -67,15 +83,29 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
scalingFactor *= 1 + smallCircleBonus;
}
Vector2 lastCursorPosition = getEndCursorPosition(lastObject);
JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
if (lastObject is Slider lastSlider)
{
computeSliderCursorPosition(lastSlider);
TravelDistance = lastSlider.LazyTravelDistance * scalingFactor;
TravelTime = Math.Max(lastSlider.LazyTravelTime / clockRate, min_delta_time);
MovementTime = Math.Max(StrainTime - TravelTime, min_delta_time);
// Jump distance from the slider tail to the next object, as opposed to the lazy position of JumpDistance.
float tailJumpDistance = Vector2.Subtract(lastSlider.TailCircle.StackedPosition, BaseObject.StackedPosition).Length * scalingFactor;
// For hitobjects which continue in the direction of the slider, the player will normally follow through the slider,
// such that they're not jumping from the lazy position but rather from very close to (or the end of) the slider.
// In such cases, a leniency is applied by also considering the jump distance from the tail of the slider, and taking the minimum jump distance.
MovementDistance = Math.Min(JumpDistance, tailJumpDistance);
}
else
{
MovementTime = StrainTime;
MovementDistance = JumpDistance;
}
Vector2 lastCursorPosition = getEndCursorPosition(lastObject);
JumpDistance = (BaseObject.StackedPosition * scalingFactor - lastCursorPosition * scalingFactor).Length;
if (lastLastObject != null && !(lastLastObject is Spinner))
{
@ -98,7 +128,7 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
slider.LazyEndPosition = slider.StackedPosition;
float approxFollowCircleRadius = (float)(slider.Radius * 3);
float followCircleRadius = (float)(slider.Radius * 2.4);
var computeVertex = new Action<double>(t =>
{
double progress = (t - slider.StartTime) / slider.SpanDuration;
@ -111,11 +141,13 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Preprocessing
var diff = slider.StackedPosition + slider.Path.PositionAt(progress) - slider.LazyEndPosition.Value;
float dist = diff.Length;
if (dist > approxFollowCircleRadius)
slider.LazyTravelTime = t - slider.StartTime;
if (dist > followCircleRadius)
{
// The cursor would be outside the follow circle, we need to move it
diff.Normalize(); // Obtain direction of diff
dist -= approxFollowCircleRadius;
dist -= followCircleRadius;
slider.LazyEndPosition += diff * dist;
slider.LazyTravelDistance += dist;
}

View File

@ -14,53 +14,96 @@ namespace osu.Game.Rulesets.Osu.Difficulty.Skills
/// </summary>
public class Aim : OsuStrainSkill
{
private const double angle_bonus_begin = Math.PI / 3;
private const double timing_threshold = 107;
public Aim(Mod[] mods)
: base(mods)
{
}
protected override int HistoryLength => 2;
private const double wide_angle_multiplier = 1.5;
private const double acute_angle_multiplier = 2.0;
private double currentStrain = 1;
private double skillMultiplier => 26.25;
private double skillMultiplier => 23.25;
private double strainDecayBase => 0.15;
private double strainValueOf(DifficultyHitObject current)
{
if (current.BaseObject is Spinner)
if (current.BaseObject is Spinner || Previous.Count <= 1 || Previous[0].BaseObject is Spinner)
return 0;
var osuCurrent = (OsuDifficultyHitObject)current;
var osuCurrObj = (OsuDifficultyHitObject)current;
var osuLastObj = (OsuDifficultyHitObject)Previous[0];
var osuLastLastObj = (OsuDifficultyHitObject)Previous[1];
double aimStrain = 0;
// Calculate the velocity to the current hitobject, which starts with a base distance / time assuming the last object is a hitcircle.
double currVelocity = osuCurrObj.JumpDistance / osuCurrObj.StrainTime;
if (Previous.Count > 0)
// But if the last object is a slider, then we extend the travel velocity through the slider into the current object.
if (osuLastObj.BaseObject is Slider)
{
var osuPrevious = (OsuDifficultyHitObject)Previous[0];
double movementVelocity = osuCurrObj.MovementDistance / osuCurrObj.MovementTime; // calculate the movement velocity from slider end to current object
double travelVelocity = osuCurrObj.TravelDistance / osuCurrObj.TravelTime; // calculate the slider velocity from slider head to slider end.
if (osuCurrent.Angle != null && osuCurrent.Angle.Value > angle_bonus_begin)
currVelocity = Math.Max(currVelocity, movementVelocity + travelVelocity); // take the larger total combined velocity.
}
// As above, do the same for the previous hitobject.
double prevVelocity = osuLastObj.JumpDistance / osuLastObj.StrainTime;
if (osuLastLastObj.BaseObject is Slider)
{
double movementVelocity = osuLastObj.MovementDistance / osuLastObj.MovementTime;
double travelVelocity = osuLastObj.TravelDistance / osuLastObj.TravelTime;
prevVelocity = Math.Max(prevVelocity, movementVelocity + travelVelocity);
}
double angleBonus = 0;
double aimStrain = currVelocity; // Start strain with regular velocity.
if (Math.Max(osuCurrObj.StrainTime, osuLastObj.StrainTime) < 1.25 * Math.Min(osuCurrObj.StrainTime, osuLastObj.StrainTime)) // If rhythms are the same.
{
if (osuCurrObj.Angle != null && osuLastObj.Angle != null && osuLastLastObj.Angle != null)
{
const double scale = 90;
double currAngle = osuCurrObj.Angle.Value;
double lastAngle = osuLastObj.Angle.Value;
double lastLastAngle = osuLastLastObj.Angle.Value;
double angleBonus = Math.Sqrt(
Math.Max(osuPrevious.JumpDistance - scale, 0)
* Math.Pow(Math.Sin(osuCurrent.Angle.Value - angle_bonus_begin), 2)
* Math.Max(osuCurrent.JumpDistance - scale, 0));
aimStrain = 1.4 * applyDiminishingExp(Math.Max(0, angleBonus)) / Math.Max(timing_threshold, osuPrevious.StrainTime);
// Rewarding angles, take the smaller velocity as base.
angleBonus = Math.Min(currVelocity, prevVelocity);
double wideAngleBonus = calcWideAngleBonus(currAngle);
double acuteAngleBonus = calcAcuteAngleBonus(currAngle);
if (osuCurrObj.StrainTime > 100) // Only buff deltaTime exceeding 300 bpm 1/2.
acuteAngleBonus = 0;
else
{
acuteAngleBonus *= calcAcuteAngleBonus(lastAngle) // Multiply by previous angle, we don't want to buff unless this is a wiggle type pattern.
* Math.Min(angleBonus, 125 / osuCurrObj.StrainTime) // The maximum velocity we buff is equal to 125 / strainTime
* Math.Pow(Math.Sin(Math.PI / 2 * Math.Min(1, (100 - osuCurrObj.StrainTime) / 25)), 2) // scale buff from 150 bpm 1/4 to 200 bpm 1/4
* Math.Pow(Math.Sin(Math.PI / 2 * (Math.Clamp(osuCurrObj.JumpDistance, 50, 100) - 50) / 50), 2); // Buff distance exceeding 50 (radius) up to 100 (diameter).
}
wideAngleBonus *= angleBonus * (1 - Math.Min(wideAngleBonus, Math.Pow(calcWideAngleBonus(lastAngle), 3))); // Penalize wide angles if they're repeated, reducing the penalty as the lastAngle gets more acute.
acuteAngleBonus *= 0.5 + 0.5 * (1 - Math.Min(acuteAngleBonus, Math.Pow(calcAcuteAngleBonus(lastLastAngle), 3))); // Penalize acute angles if they're repeated, reducing the penalty as the lastLastAngle gets more obtuse.
angleBonus = acuteAngleBonus * acute_angle_multiplier + wideAngleBonus * wide_angle_multiplier; // add the angle buffs together.
}
}
double jumpDistanceExp = applyDiminishingExp(osuCurrent.JumpDistance);
double travelDistanceExp = applyDiminishingExp(osuCurrent.TravelDistance);
aimStrain += angleBonus; // Add in angle bonus.
return Math.Max(
aimStrain + (jumpDistanceExp + travelDistanceExp + Math.Sqrt(travelDistanceExp * jumpDistanceExp)) / Math.Max(osuCurrent.StrainTime, timing_threshold),
(Math.Sqrt(travelDistanceExp * jumpDistanceExp) + jumpDistanceExp + travelDistanceExp) / osuCurrent.StrainTime
);
return aimStrain;
}
private double calcWideAngleBonus(double angle) => Math.Pow(Math.Sin(3.0 / 4 * (Math.Min(5.0 / 6 * Math.PI, Math.Max(Math.PI / 6, angle)) - Math.PI / 6)), 2);
private double calcAcuteAngleBonus(double angle) => 1 - calcWideAngleBonus(angle);
private double applyDiminishingExp(double val) => Math.Pow(val, 0.99);
private double strainDecay(double ms) => Math.Pow(strainDecayBase, ms / 1000);

View File

@ -59,7 +59,7 @@ namespace osu.Game.Rulesets.Osu.Objects
set => StackHeightBindable.Value = value;
}
public Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f);
public virtual Vector2 StackOffset => new Vector2(StackHeight * Scale * -6.4f);
public double Radius => OBJECT_RADIUS * Scale;

View File

@ -79,6 +79,12 @@ namespace osu.Game.Rulesets.Osu.Objects
/// </summary>
internal float LazyTravelDistance;
/// <summary>
/// The time taken by the cursor upon completion of this <see cref="Slider"/> if it was hit
/// with as few movements as possible. This is set and used by difficulty calculation.
/// </summary>
internal double LazyTravelTime;
public IList<IList<HitSampleInfo>> NodeSamples { get; set; } = new List<IList<HitSampleInfo>>();
[JsonIgnore]

View File

@ -8,6 +8,7 @@ using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Objects.Types;
using osu.Game.Rulesets.Osu.Judgements;
using osu.Game.Rulesets.Scoring;
using osuTK;
namespace osu.Game.Rulesets.Osu.Objects
{
@ -31,6 +32,8 @@ namespace osu.Game.Rulesets.Osu.Objects
/// </summary>
public int MaximumBonusSpins { get; protected set; } = 1;
public override Vector2 StackOffset => Vector2.Zero;
protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty)
{
base.ApplyDefaultsToSelf(controlPointInfo, difficulty);

View File

@ -2,7 +2,7 @@
<Import Project="..\osu.TestProject.props" />
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
<PackageReference Update="Microsoft.EntityFrameworkCore.Sqlite" Version="2.1.4" />

View File

@ -38,6 +38,15 @@ namespace osu.Game.Tests.Skins.IO
assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu);
});
[Test]
public Task TestSingleImportMissingSectionHeader() => runSkinTest(async osu =>
{
var import1 = await loadSkinIntoOsu(osu, new ZipArchiveReader(createOskWithIni("test skin", "skinner", includeSectionHeader: false), "skin.osk"));
// When the import filename doesn't match, it should be appended (and update the skin.ini).
assertCorrectMetadata(import1, "test skin [skin]", "skinner", osu);
});
[Test]
public Task TestSingleImportMatchingFilename() => runSkinTest(async osu =>
{
@ -199,21 +208,23 @@ namespace osu.Game.Tests.Skins.IO
return zipStream;
}
private MemoryStream createOskWithIni(string name, string author, bool makeUnique = false, string iniFilename = @"skin.ini")
private MemoryStream createOskWithIni(string name, string author, bool makeUnique = false, string iniFilename = @"skin.ini", bool includeSectionHeader = true)
{
var zipStream = new MemoryStream();
using var zip = ZipArchive.Create();
zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique));
zip.AddEntry(iniFilename, generateSkinIni(name, author, makeUnique, includeSectionHeader));
zip.SaveTo(zipStream);
return zipStream;
}
private MemoryStream generateSkinIni(string name, string author, bool makeUnique = true)
private MemoryStream generateSkinIni(string name, string author, bool makeUnique = true, bool includeSectionHeader = true)
{
var stream = new MemoryStream();
var writer = new StreamWriter(stream);
writer.WriteLine("[General]");
if (includeSectionHeader)
writer.WriteLine("[General]");
writer.WriteLine($"Name: {name}");
writer.WriteLine($"Author: {author}");

View File

@ -231,6 +231,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
}
});
AddAssert("Check participant count correct", () => client.APIRoom?.ParticipantCount.Value == 1);
AddAssert("Check participant list contains user", () => client.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1);
}
[Test]
@ -290,6 +293,9 @@ namespace osu.Game.Tests.Visual.Multiplayer
AddUntilStep("wait for room open", () => this.ChildrenOfType<MultiplayerMatchSubScreen>().FirstOrDefault()?.IsLoaded == true);
AddUntilStep("wait for join", () => client.Room != null);
AddAssert("Check participant count correct", () => client.APIRoom?.ParticipantCount.Value == 1);
AddAssert("Check participant list contains user", () => client.APIRoom?.RecentParticipants.Count(u => u.Id == API.LocalUser.Value.Id) == 1);
}
[Test]

View File

@ -45,11 +45,11 @@ namespace osu.Game.Tests.Visual.Multiplayer
}
[Test]
public void TestAddNullUser()
public void TestAddUnresolvedUser()
{
AddAssert("one unique panel", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.User).Distinct().Count() == 1);
AddStep("add non-resolvable user", () => Client.AddNullUser());
AddStep("add non-resolvable user", () => Client.TestAddUnresolvedUser());
AddAssert("null user added", () => Client.Room.AsNonNull().Users.Count(u => u.User == null) == 1);
AddUntilStep("two unique panels", () => this.ChildrenOfType<ParticipantPanel>().Select(p => p.User).Distinct().Count() == 2);

View File

@ -3,7 +3,7 @@
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="DeepEqual" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="Nito.AsyncEx" Version="5.1.2" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />

View File

@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup Label="Package References">
<PackageReference Include="Appveyor.TestLogger" Version="2.0.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="16.11.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.0.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="NUnit3TestAdapter" Version="4.0.0" />
</ItemGroup>

View File

@ -131,7 +131,7 @@ namespace osu.Game.Beatmaps
var localRulesetInfo = rulesetInfo as RulesetInfo;
// Difficulty can only be computed if the beatmap and ruleset are locally available.
if (localBeatmapInfo == null || localRulesetInfo == null)
if (localBeatmapInfo == null || localBeatmapInfo.ID == 0 || localRulesetInfo == null)
{
// If not, fall back to the existing star difficulty (e.g. from an online source).
return Task.FromResult(new StarDifficulty(beatmapInfo.StarRating, (beatmapInfo as IBeatmapOnlineInfo)?.MaxCombo ?? 0));

View File

@ -249,6 +249,23 @@ namespace osu.Game.Beatmaps
public IBindable<WeakReference<ArchiveDownloadRequest<BeatmapSetInfo>>> DownloadFailed => beatmapModelDownloader.DownloadFailed;
// Temporary method until this class supports IBeatmapSetInfo or otherwise.
public bool Download(IBeatmapSetInfo model, bool minimiseDownloadSize = false)
{
return beatmapModelDownloader.Download(new BeatmapSetInfo
{
OnlineBeatmapSetID = model.OnlineID,
Metadata = new BeatmapMetadata
{
Title = model.Metadata?.Title,
Artist = model.Metadata?.Artist,
TitleUnicode = model.Metadata?.TitleUnicode,
ArtistUnicode = model.Metadata?.ArtistUnicode,
Author = new User { Username = model.Metadata?.Author },
}
}, minimiseDownloadSize);
}
public bool Download(BeatmapSetInfo model, bool minimiseDownloadSize = false)
{
return beatmapModelDownloader.Download(model, minimiseDownloadSize);

View File

@ -60,8 +60,9 @@ namespace osu.Game.Beatmaps.Drawables
/// <param name="ruleset">The ruleset to show the difficulty with.</param>
/// <param name="mods">The mods to show the difficulty with.</param>
/// <param name="shouldShowTooltip">Whether to display a tooltip when hovered.</param>
public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo ruleset, [CanBeNull] IReadOnlyList<Mod> mods, bool shouldShowTooltip = true)
: this(beatmapInfo, shouldShowTooltip)
/// <param name="performBackgroundDifficultyLookup">Whether to perform difficulty lookup (including calculation if necessary).</param>
public DifficultyIcon([NotNull] IBeatmapInfo beatmapInfo, [CanBeNull] IRulesetInfo ruleset, [CanBeNull] IReadOnlyList<Mod> mods, bool shouldShowTooltip = true, bool performBackgroundDifficultyLookup = true)
: this(beatmapInfo, shouldShowTooltip, performBackgroundDifficultyLookup)
{
this.ruleset = ruleset ?? beatmapInfo.Ruleset;
this.mods = mods ?? Array.Empty<Mod>();

View File

@ -27,7 +27,7 @@ namespace osu.Game.Beatmaps.Formats
protected override void ParseStreamInto(LineBufferedReader stream, T output)
{
Section section = Section.None;
Section section = Section.General;
string line;
@ -47,10 +47,7 @@ namespace osu.Game.Beatmaps.Formats
if (line.StartsWith('[') && line.EndsWith(']'))
{
if (!Enum.TryParse(line[1..^1], out section))
{
Logger.Log($"Unknown section \"{line}\" in \"{output}\"");
section = Section.None;
}
OnBeginNewSection(section);
continue;
@ -148,7 +145,6 @@ namespace osu.Game.Beatmaps.Formats
protected enum Section
{
None,
General,
Editor,
Metadata,

View File

@ -107,7 +107,8 @@ namespace osu.Game.Online.API
WebRequest = CreateWebRequest();
WebRequest.Failed += Fail;
WebRequest.AllowRetryOnTimeout = false;
WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}");
if (!string.IsNullOrEmpty(API.AccessToken))
WebRequest.AddHeader("Authorization", $"Bearer {API.AccessToken}");
if (isFailing) return;

View File

@ -37,6 +37,7 @@ namespace osu.Game.Online.API.Requests.Responses
public DateTimeOffset Date { get; set; }
[JsonProperty(@"beatmap")]
[CanBeNull]
public APIBeatmap Beatmap { get; set; }
[JsonProperty("accuracy")]
@ -46,6 +47,7 @@ namespace osu.Game.Online.API.Requests.Responses
public double? PP { get; set; }
[JsonProperty(@"beatmapset")]
[CanBeNull]
public APIBeatmapSet BeatmapSet
{
set
@ -96,7 +98,7 @@ namespace osu.Game.Online.API.Requests.Responses
{
TotalScore = TotalScore,
MaxCombo = MaxCombo,
BeatmapInfo = Beatmap.ToBeatmapInfo(rulesets),
BeatmapInfo = Beatmap?.ToBeatmapInfo(rulesets),
User = User,
Accuracy = Accuracy,
OnlineScoreID = OnlineID,

View File

@ -148,6 +148,10 @@ namespace osu.Game.Online.Multiplayer
{
Room = joinedRoom;
APIRoom = room;
Debug.Assert(LocalUser != null);
addUserToAPIRoom(LocalUser);
foreach (var user in joinedRoom.Users)
updateUserPlayingState(user.UserID, user.State);
@ -372,6 +376,8 @@ namespace osu.Game.Online.Multiplayer
Room.Users.Add(user);
addUserToAPIRoom(user);
UserJoined?.Invoke(user);
RoomUpdated?.Invoke();
});
@ -391,6 +397,18 @@ namespace osu.Game.Online.Multiplayer
return handleUserLeft(user, UserKicked);
}
private void addUserToAPIRoom(MultiplayerRoomUser user)
{
Debug.Assert(APIRoom != null);
APIRoom.RecentParticipants.Add(user.User ?? new User
{
Id = user.UserID,
Username = "[Unresolved]"
});
APIRoom.ParticipantCount.Value++;
}
private Task handleUserLeft(MultiplayerRoomUser user, Action<MultiplayerRoomUser>? callback)
{
if (Room == null)
@ -404,6 +422,10 @@ namespace osu.Game.Online.Multiplayer
Room.Users.Remove(user);
PlayingUserIds.Remove(user.UserID);
Debug.Assert(APIRoom != null);
APIRoom.RecentParticipants.RemoveAll(u => u.Id == user.UserID);
APIRoom.ParticipantCount.Value--;
callback?.Invoke(user);
RoomUpdated?.Invoke();
}, false);

View File

@ -53,7 +53,10 @@ namespace osu.Game.Online.Rooms
downloadTracker?.RemoveAndDisposeImmediately();
downloadTracker = new BeatmapDownloadTracker(item.NewValue.Beatmap.Value.BeatmapSet);
downloadTracker.State.BindValueChanged(_ => updateAvailability());
AddInternal(downloadTracker);
downloadTracker.State.BindValueChanged(_ => updateAvailability(), true);
downloadTracker.Progress.BindValueChanged(_ =>
{
if (downloadTracker.State.Value != DownloadState.Downloading)
@ -63,9 +66,7 @@ namespace osu.Game.Online.Rooms
// we don't want to flood the network with this, so rate limit how often we send progress updates.
if (progressUpdate?.Completed != false)
progressUpdate = Scheduler.AddDelayed(updateAvailability, progressUpdate == null ? 0 : 500);
});
AddInternal(downloadTracker);
}, true);
}, true);
}

View File

@ -5,6 +5,7 @@ using System.Net.Http;
using Newtonsoft.Json;
using osu.Framework.IO.Network;
using osu.Game.Online.API;
using osu.Game.Online.Solo;
using osu.Game.Scoring;
namespace osu.Game.Online.Rooms
@ -14,14 +15,14 @@ namespace osu.Game.Online.Rooms
private readonly long scoreId;
private readonly long roomId;
private readonly long playlistItemId;
private readonly ScoreInfo scoreInfo;
private readonly SubmittableScore score;
public SubmitRoomScoreRequest(long scoreId, long roomId, long playlistItemId, ScoreInfo scoreInfo)
{
this.scoreId = scoreId;
this.roomId = roomId;
this.playlistItemId = playlistItemId;
this.scoreInfo = scoreInfo;
score = new SubmittableScore(scoreInfo);
}
protected override WebRequest CreateWebRequest()
@ -31,7 +32,7 @@ namespace osu.Game.Online.Rooms
req.ContentType = "application/json";
req.Method = HttpMethod.Put;
req.AddRaw(JsonConvert.SerializeObject(scoreInfo, new JsonSerializerSettings
req.AddRaw(JsonConvert.SerializeObject(score, new JsonSerializerSettings
{
ReferenceLoopHandling = ReferenceLoopHandling.Ignore
}));

View File

@ -86,7 +86,7 @@ namespace osu.Game.Overlays.BeatmapListing.Panels
break;
default:
beatmaps.Download(new BeatmapSetInfo { OnlineBeatmapSetID = beatmapSet.OnlineID }, noVideoSetting.Value);
beatmaps.Download(beatmapSet, noVideoSetting.Value);
break;
}
};

View File

@ -108,7 +108,7 @@ namespace osu.Game.Overlays.BeatmapSet.Buttons
return;
}
beatmaps.Download(new BeatmapSetInfo { OnlineBeatmapSetID = beatmapSet.OnlineID }, noVideo);
beatmaps.Download(beatmapSet, noVideo);
};
localUser.BindTo(api.LocalUser);

View File

@ -91,7 +91,7 @@ namespace osu.Game.Overlays.Profile.Sections.Ranks
{
new OsuSpriteText
{
Text = $"{Score.Beatmap.DifficultyName}",
Text = $"{Score.Beatmap?.DifficultyName}",
Font = OsuFont.GetFont(size: 12, weight: FontWeight.Regular),
Colour = colours.Yellow
},

View File

@ -58,6 +58,11 @@ namespace osu.Game.Rulesets.Difficulty
return CreateDifficultyAttributes(Beatmap, playableMods, skills, clockRate);
}
/// <summary>
/// Calculates the difficulty of the beatmap and returns a set of <see cref="TimedDifficultyAttributes"/> representing the difficulty at every relevant time value in the beatmap.
/// </summary>
/// <param name="mods">The mods that should be applied to the beatmap.</param>
/// <returns>The set of <see cref="TimedDifficultyAttributes"/>.</returns>
public List<TimedDifficultyAttributes> CalculateTimed(params Mod[] mods)
{
preProcess(mods);
@ -77,7 +82,7 @@ namespace osu.Game.Rulesets.Difficulty
foreach (var skill in skills)
skill.ProcessInternal(hitObject);
attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate)));
attribs.Add(new TimedDifficultyAttributes(hitObject.EndTime * clockRate, CreateDifficultyAttributes(progressiveBeatmap, playableMods, skills, clockRate)));
}
return attribs;

View File

@ -11,9 +11,21 @@ namespace osu.Game.Rulesets.Difficulty
/// </summary>
public class TimedDifficultyAttributes : IComparable<TimedDifficultyAttributes>
{
/// <summary>
/// The non-clock-adjusted time value at which the attributes take effect.
/// </summary>
public readonly double Time;
/// <summary>
/// The attributes.
/// </summary>
public readonly DifficultyAttributes Attributes;
/// <summary>
/// Creates new <see cref="TimedDifficultyAttributes"/>.
/// </summary>
/// <param name="time">The non-clock-adjusted time value at which the attributes take effect.</param>
/// <param name="attributes">The attributes.</param>
public TimedDifficultyAttributes(double time, DifficultyAttributes attributes)
{
Time = time;

View File

@ -105,7 +105,7 @@ namespace osu.Game.Screens.OnlinePlay
private void refresh()
{
difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods) { Size = new Vector2(32) };
difficultyIconContainer.Child = new DifficultyIcon(beatmap.Value, ruleset.Value, requiredMods, performBackgroundDifficultyLookup: false) { Size = new Vector2(32) };
beatmapText.Clear();
beatmapText.AddLink(Item.Beatmap.Value.GetDisplayTitleRomanisable(), LinkAction.OpenBeatmap, Item.Beatmap.Value.OnlineBeatmapID.ToString(), null, text =>

View File

@ -194,68 +194,61 @@ namespace osu.Game.Skinning
string nameLine = @$"Name: {item.Name}";
string authorLine = @$"Author: {item.Creator}";
string[] newLines =
{
@"// The following content was automatically added by osu! during import, based on filename / folder metadata.",
@"[General]",
nameLine,
authorLine,
};
var existingFile = item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase));
if (existingFile != null)
if (existingFile == null)
{
List<string> outputLines = new List<string>();
// In the case a skin doesn't have a skin.ini yet, let's create one.
writeNewSkinIni();
return;
}
bool addedName = false;
bool addedAuthor = false;
using (var stream = Files.Storage.GetStream(existingFile.FileInfo.StoragePath))
using (var sr = new StreamReader(stream))
using (Stream stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
string line;
while ((line = sr.ReadLine()) != null)
using (var existingStream = Files.Storage.GetStream(existingFile.FileInfo.StoragePath))
using (var sr = new StreamReader(existingStream))
{
if (line.StartsWith(@"Name:", StringComparison.Ordinal))
{
outputLines.Add(nameLine);
addedName = true;
}
else if (line.StartsWith(@"Author:", StringComparison.Ordinal))
{
outputLines.Add(authorLine);
addedAuthor = true;
}
else
outputLines.Add(line);
}
}
if (!addedName || !addedAuthor)
{
outputLines.AddRange(new[]
{
@"[General]",
nameLine,
authorLine,
});
}
using (Stream stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
foreach (string line in outputLines)
string line;
while ((line = sr.ReadLine()) != null)
sw.WriteLine(line);
}
ReplaceFile(item, existingFile, stream);
sw.WriteLine();
foreach (string line in newLines)
sw.WriteLine(line);
}
ReplaceFile(item, existingFile, stream);
// can be removed 20220502.
if (!ensureIniWasUpdated(item))
{
Logger.Log($"Skin {item}'s skin.ini had issues and has been removed. Please report this and provide the problematic skin.", LoggingTarget.Database, LogLevel.Important);
DeleteFile(item, item.Files.SingleOrDefault(f => f.Filename.Equals(@"skin.ini", StringComparison.OrdinalIgnoreCase)));
writeNewSkinIni();
}
}
else
void writeNewSkinIni()
{
using (Stream stream = new MemoryStream())
{
using (var sw = new StreamWriter(stream, Encoding.UTF8, 1024, true))
{
sw.WriteLine(@"[General]");
sw.WriteLine(nameLine);
sw.WriteLine(authorLine);
sw.WriteLine(@"Version: latest");
foreach (string line in newLines)
sw.WriteLine(line);
}
AddFile(item, stream, @"skin.ini");
@ -263,6 +256,17 @@ namespace osu.Game.Skinning
}
}
private bool ensureIniWasUpdated(SkinInfo item)
{
// This is a final consistency check to ensure that hash computation doesn't enter an infinite loop.
// With other changes to the surrounding code this should never be hit, but until we are 101% sure that there
// are no other cases let's avoid a hard startup crash by bailing and alerting.
var instance = GetSkin(item);
return instance.Configuration.SkinInfo.Name == item.Name;
}
protected override Task Populate(SkinInfo model, ArchiveReader archive, CancellationToken cancellationToken = default)
{
var instance = GetSkin(model);

View File

@ -62,7 +62,7 @@ namespace osu.Game.Tests.Visual.Multiplayer
return roomUser;
}
public void AddNullUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.NULL_USER_ID));
public void TestAddUnresolvedUser() => addUser(new MultiplayerRoomUser(TestUserLookupCache.UNRESOLVED_USER_ID));
private void addUser(MultiplayerRoomUser user)
{

View File

@ -14,11 +14,11 @@ namespace osu.Game.Tests.Visual
/// A special user ID which <see cref="ComputeValueAsync"/> would return a <see langword="null"/> <see cref="User"/> for.
/// As a simulation to what a regular <see cref="UserLookupCache"/> would return in the case of failing to fetch the user.
/// </summary>
public const int NULL_USER_ID = -1;
public const int UNRESOLVED_USER_ID = -1;
protected override Task<User> ComputeValueAsync(int lookup, CancellationToken token = default)
{
if (lookup == NULL_USER_ID)
if (lookup == UNRESOLVED_USER_ID)
return Task.FromResult((User)null);
return Task.FromResult(new User

View File

@ -23,9 +23,9 @@
<PackageReference Include="HtmlAgilityPack" Version="1.11.37" />
<PackageReference Include="Humanizer" Version="2.11.10" />
<PackageReference Include="MessagePack" Version="2.3.85" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.10" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Client" Version="5.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.MessagePack" Version="5.0.11" />
<PackageReference Include="Microsoft.AspNetCore.SignalR.Protocols.NewtonsoftJson" Version="5.0.11" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="2.2.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="5.0.0" />
@ -38,8 +38,8 @@
<PackageReference Include="Realm" Version="10.6.0" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1029.0" />
<PackageReference Include="ppy.osu.Game.Resources" Version="2021.1026.0" />
<PackageReference Include="Sentry" Version="3.9.4" />
<PackageReference Include="SharpCompress" Version="0.29.0" />
<PackageReference Include="Sentry" Version="3.10.0" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />
<PackageReference Include="TagLibSharp" Version="2.2.0" />

View File

@ -94,7 +94,7 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite.Core" Version="2.2.6" />
<PackageReference Include="Newtonsoft.Json" Version="13.0.1" />
<PackageReference Include="ppy.osu.Framework" Version="2021.1029.0" />
<PackageReference Include="SharpCompress" Version="0.28.3" />
<PackageReference Include="SharpCompress" Version="0.30.0" />
<PackageReference Include="NUnit" Version="3.13.2" />
<PackageReference Include="SharpRaven" Version="2.4.0" />
<PackageReference Include="System.ComponentModel.Annotations" Version="5.0.0" />