Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix 1 million score being unachievable on some mania beatmaps #23917

Merged
merged 7 commits into from
Jul 13, 2023
Merged
147 changes: 147 additions & 0 deletions osu.Game.Rulesets.Mania.Tests/TestSceneMaximumScore.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// 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 System.Linq;
using NUnit.Framework;
using osu.Framework.Screens;
using osu.Game.Beatmaps;
using osu.Game.Beatmaps.ControlPoints;
using osu.Game.Replays;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Mania.Replays;
using osu.Game.Rulesets.Replays;
using osu.Game.Rulesets.Scoring;
using osu.Game.Scoring;
using osu.Game.Screens.Play;
using osu.Game.Tests.Visual;

namespace osu.Game.Rulesets.Mania.Tests
{
public partial class TestSceneMaximumScore : RateAdjustedBeatmapTestScene
{
private ScoreAccessibleReplayPlayer currentPlayer = null!;

private List<JudgementResult> judgementResults = new List<JudgementResult>();

[Test]
public void TestSimultaneousTickAndNote()
{
performTest(
new List<ManiaHitObject>
{
new HoldNote
{
StartTime = 1000,
Duration = 2000,
Column = 0,
},
new Note
{
StartTime = 2000,
Column = 1
}
},
new List<ReplayFrame>
{
new ManiaReplayFrame(1000, ManiaAction.Key1),
new ManiaReplayFrame(2000, ManiaAction.Key1, ManiaAction.Key2),
new ManiaReplayFrame(2001, ManiaAction.Key1),
new ManiaReplayFrame(3000)
});

AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
}

[Test]
public void TestSimultaneousLongNotes()
{
performTest(
new List<ManiaHitObject>
{
new HoldNote
{
StartTime = 1000,
Duration = 2000,
Column = 0,
},
new HoldNote
{
StartTime = 2000,
Duration = 2000,
Column = 1
}
},
new List<ReplayFrame>
{
new ManiaReplayFrame(1000, ManiaAction.Key1),
new ManiaReplayFrame(2000, ManiaAction.Key1, ManiaAction.Key2),
new ManiaReplayFrame(3000, ManiaAction.Key2),
new ManiaReplayFrame(4000)
});

AddAssert("all objects perfectly judged",
() => judgementResults.Select(result => result.Type),
() => Is.EquivalentTo(judgementResults.Select(result => result.Judgement.MaxResult)));
AddAssert("score is 1 million", () => currentPlayer.ScoreProcessor.TotalScore.Value, () => Is.EqualTo(1_000_000));
}

private void performTest(List<ManiaHitObject> hitObjects, List<ReplayFrame> frames)
{
var beatmap = new Beatmap<ManiaHitObject>
{
HitObjects = hitObjects,
BeatmapInfo =
{
Difficulty = new BeatmapDifficulty { SliderTickRate = 4 },
Ruleset = new ManiaRuleset().RulesetInfo
},
};

beatmap.ControlPointInfo.Add(0, new EffectControlPoint { ScrollSpeed = 0.1f });

AddStep("load player", () =>
{
Beatmap.Value = CreateWorkingBeatmap(beatmap);

var p = new ScoreAccessibleReplayPlayer(new Score { Replay = new Replay { Frames = frames } });

p.OnLoadComplete += _ =>
{
p.ScoreProcessor.NewJudgement += result =>
{
if (currentPlayer == p) judgementResults.Add(result);
};
};

LoadScreen(currentPlayer = p);
judgementResults = new List<JudgementResult>();
});

AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0);
AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen());

AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value);
}

private partial class ScoreAccessibleReplayPlayer : ReplayPlayer
{
public new ScoreProcessor ScoreProcessor => base.ScoreProcessor;

protected override bool PauseOnFocusLost => false;

public ScoreAccessibleReplayPlayer(Score score)
: base(score, new PlayerConfiguration
{
AllowPause = false,
ShowResults = false,
})
{
}
}
}
}
30 changes: 30 additions & 0 deletions osu.Game.Rulesets.Mania/Scoring/ManiaScoreProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using System.Linq;
using osu.Game.Beatmaps;
using osu.Game.Rulesets.Judgements;
using osu.Game.Rulesets.Mania.Objects;
using osu.Game.Rulesets.Objects;
using osu.Game.Rulesets.Scoring;

namespace osu.Game.Rulesets.Mania.Scoring
Expand All @@ -16,6 +21,9 @@ public ManiaScoreProcessor()
{
}

protected override IEnumerable<HitObject> EnumerateHitObjects(IBeatmap beatmap)
=> base.EnumerateHitObjects(beatmap).OrderBy(ho => (ManiaHitObject)ho, JudgementOrderComparer.DEFAULT);

protected override double ComputeTotalScore(double comboProgress, double accuracyProgress, double bonusPortion)
{
return 10000 * comboProgress
Expand All @@ -25,5 +33,27 @@ protected override double ComputeTotalScore(double comboProgress, double accurac

protected override double GetComboScoreChange(JudgementResult result)
=> Judgement.ToNumericResult(result.Type) * Math.Min(Math.Max(0.5, Math.Log(result.ComboAfterJudgement, combo_base)), Math.Log(400, combo_base));

private class JudgementOrderComparer : IComparer<ManiaHitObject>
{
public static readonly JudgementOrderComparer DEFAULT = new JudgementOrderComparer();

public int Compare(ManiaHitObject? x, ManiaHitObject? y)
{
if (ReferenceEquals(x, y)) return 0;
if (ReferenceEquals(x, null)) return -1;
if (ReferenceEquals(y, null)) return 1;

int result = x.GetEndTime().CompareTo(y.GetEndTime());
if (result != 0)
return result;

// due to the way input is handled in mania, notes take precedence over ticks in judging order.
if (x is Note && y is not Note) return -1;
if (x is not Note && y is Note) return 1;

return x.Column.CompareTo(y.Column);
}
}
}
}
62 changes: 40 additions & 22 deletions osu.Game/Rulesets/Scoring/JudgementProcessor.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// See the LICENCE file in the repository root for full licence text.

using System;
using System.Collections.Generic;
using osu.Framework.Bindables;
using osu.Framework.Extensions.ObjectExtensions;
using osu.Framework.Extensions.TypeExtensions;
Expand Down Expand Up @@ -137,30 +138,17 @@ public virtual void ResetFromReplayFrame(ReplayFrame frame)
JudgedHits += count;
}

/// <summary>
/// Creates the <see cref="JudgementResult"/> that represents the scoring result for a <see cref="HitObject"/>.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> which was judged.</param>
/// <param name="judgement">The <see cref="Judgement"/> that provides the scoring information.</param>
protected virtual JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new JudgementResult(hitObject, judgement);

/// <summary>
/// Simulates an autoplay of the <see cref="IBeatmap"/> to determine scoring values.
/// </summary>
/// <remarks>This provided temporarily. DO NOT USE.</remarks>
/// <param name="beatmap">The <see cref="IBeatmap"/> to simulate.</param>
protected virtual void SimulateAutoplay(IBeatmap beatmap)
protected void SimulateAutoplay(IBeatmap beatmap)
{
IsSimulating = true;

foreach (var obj in beatmap.HitObjects)
simulate(obj);

void simulate(HitObject obj)
foreach (var obj in EnumerateHitObjects(beatmap))
{
foreach (var nested in obj.NestedHitObjects)
simulate(nested);

var judgement = obj.CreateJudgement();

var result = CreateResult(obj, judgement);
Expand All @@ -174,22 +162,52 @@ void simulate(HitObject obj)
IsSimulating = false;
}

protected override void Update()
/// <summary>
/// Enumerates all <see cref="HitObject"/>s in the given <paramref name="beatmap"/> in the order in which they are to be judged.
/// Used in <see cref="SimulateAutoplay"/>.
/// </summary>
/// <remarks>
/// In Score V2, the score awarded for each object includes a component based on the combo value after the judgement of that object.
/// This means that the score is dependent on the order of evaluation of judgements.
/// This method is provided so that rulesets can specify custom ordering that is correct for them and matches processing order during actual gameplay.
/// </remarks>
protected virtual IEnumerable<HitObject> EnumerateHitObjects(IBeatmap beatmap)
=> enumerateRecursively(beatmap.HitObjects);
Comment on lines +174 to +175
Copy link
Collaborator Author

@bdach bdach Jun 15, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Someone reported on discord that a graved osu! map also has an issue where 1M score is unachievable, and it's because this implementation is still naive (relies on no concurrent objects to work correctly). I could go and fix that case here by partially mirroring the mania implementation (flatten all objects, sort by end time) but honestly not sure it's worth the performance hit for a graved map.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wouldn't be against this since it's to guarantee correctness. The performance hit shouldn't be noticeable. But the current changeset is also fine for now.


private IEnumerable<HitObject> enumerateRecursively(IEnumerable<HitObject> hitObjects)
{
base.Update();
foreach (var hitObject in hitObjects)
{
foreach (var nested in enumerateRecursively(hitObject.NestedHitObjects))
yield return nested;

hasCompleted.Value =
JudgedHits == MaxHits
&& (JudgedHits == 0
// Last applied result is guaranteed to be non-null when JudgedHits > 0.
|| lastAppliedResult.AsNonNull().TimeAbsolute < Clock.CurrentTime);
yield return hitObject;
}
}

/// <summary>
/// Creates the <see cref="JudgementResult"/> that represents the scoring result for a <see cref="HitObject"/>.
/// </summary>
/// <param name="hitObject">The <see cref="HitObject"/> which was judged.</param>
/// <param name="judgement">The <see cref="Judgement"/> that provides the scoring information.</param>
protected virtual JudgementResult CreateResult(HitObject hitObject, Judgement judgement) => new JudgementResult(hitObject, judgement);

/// <summary>
/// Gets a simulated <see cref="HitResult"/> for a judgement. Used during <see cref="SimulateAutoplay"/> to simulate a "perfect" play.
/// </summary>
/// <param name="judgement">The judgement to simulate a <see cref="HitResult"/> for.</param>
/// <returns>The simulated <see cref="HitResult"/> for the judgement.</returns>
protected virtual HitResult GetSimulatedHitResult(Judgement judgement) => judgement.MaxResult;

protected override void Update()
{
base.Update();

hasCompleted.Value =
JudgedHits == MaxHits
&& (JudgedHits == 0
// Last applied result is guaranteed to be non-null when JudgedHits > 0.
|| lastAppliedResult.AsNonNull().TimeAbsolute < Clock.CurrentTime);
}
}
}