// Copyright (c) ppy Pty Ltd . 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.IO; using System.Linq; using System.Runtime.CompilerServices; using System.Text; using NUnit.Framework; using osu.Framework.Extensions; using osu.Framework.Extensions.TypeExtensions; using osu.Framework.Screens; using osu.Framework.Testing; using osu.Game.Beatmaps; using osu.Game.Beatmaps.ControlPoints; using osu.Game.Beatmaps.Formats; using osu.Game.Replays; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Objects.Drawables; using osu.Game.Rulesets.Objects.Types; using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Osu.Objects; using osu.Game.Rulesets.Osu.Replays; using osu.Game.Rulesets.Osu.Scoring; using osu.Game.Rulesets.Osu.UI; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Game.Scoring.Legacy; using osu.Game.Screens.Play; using osu.Game.Tests.Visual; using osuTK; namespace osu.Game.Rulesets.Osu.Tests { public partial class TestSceneObjectOrderedHitPolicy : RateAdjustedBeatmapTestScene { private readonly OsuHitWindows referenceHitWindows; /// /// This is provided as a convenience for testing note lock behaviour against osu!stable. /// Setting this field to a non-null path will cause beatmap files and replays used in all test cases /// to be exported to disk so that they can be cross-checked against stable. /// private readonly string? exportLocation = null; public TestSceneObjectOrderedHitPolicy() { referenceHitWindows = new OsuHitWindows(); referenceHitWindows.SetDifficulty(0); } /// /// Tests clicking a future circle before the first circle's start time, while the first circle HAS NOT been judged. /// [Test] public void TestClickSecondCircleBeforeFirstCircleTime() { const double time_first_circle = 1500; const double time_second_circle = 1600; Vector2 positionFirstCircle = Vector2.Zero; Vector2 positionSecondCircle = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_first_circle, Position = positionFirstCircle }, new HitCircle { StartTime = time_second_circle, Position = positionSecondCircle } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_circle - 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } }); addJudgementAssert(hitObjects[0], HitResult.Miss); addJudgementAssert(hitObjects[1], HitResult.Miss); // note lock prevented the object from being hit, so the judgement offset should be very late. addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh)); addClickActionAssert(0, ClickAction.Shake); } /// /// Tests clicking a future circle at the first circle's start time, while the first circle HAS NOT been judged. /// [Test] public void TestClickSecondCircleAtFirstCircleTime() { const double time_first_circle = 1500; const double time_second_circle = 1600; Vector2 positionFirstCircle = Vector2.Zero; Vector2 positionSecondCircle = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_first_circle, Position = positionFirstCircle }, new HitCircle { StartTime = time_second_circle, Position = positionSecondCircle } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } }); addJudgementAssert(hitObjects[0], HitResult.Miss); addJudgementAssert(hitObjects[1], HitResult.Miss); // note lock prevented the object from being hit, so the judgement offset should be very late. addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh)); addClickActionAssert(0, ClickAction.Shake); } /// /// Tests clicking a future circle after the first circle's start time, while the first circle HAS NOT been judged. /// [Test] public void TestClickSecondCircleAfterFirstCircleTime() { const double time_first_circle = 1500; const double time_second_circle = 1600; Vector2 positionFirstCircle = Vector2.Zero; Vector2 positionSecondCircle = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_first_circle, Position = positionFirstCircle }, new HitCircle { StartTime = time_second_circle, Position = positionSecondCircle } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_circle + 100, Position = positionSecondCircle, Actions = { OsuAction.LeftButton } } }); addJudgementAssert(hitObjects[0], HitResult.Miss); addJudgementAssert(hitObjects[1], HitResult.Miss); // note lock prevented the object from being hit, so the judgement offset should be very late. addJudgementOffsetAssert(hitObjects[0], referenceHitWindows.WindowFor(HitResult.Meh)); addClickActionAssert(0, ClickAction.Shake); } /// /// Tests clicking a future circle before the first circle's start time, while the first circle HAS been judged. /// [Test] public void TestClickSecondCircleBeforeFirstCircleTimeWithFirstCircleJudged() { const double time_first_circle = 1500; const double time_second_circle = 1600; Vector2 positionFirstCircle = Vector2.Zero; Vector2 positionSecondCircle = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_first_circle, Position = positionFirstCircle }, new HitCircle { StartTime = time_second_circle, Position = positionSecondCircle } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_circle - 190, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_first_circle - 90, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } }); addJudgementAssert(hitObjects[0], HitResult.Meh); addJudgementAssert(hitObjects[1], HitResult.Meh); addJudgementOffsetAssert(hitObjects[0], -190); // time_first_circle - 190 addJudgementOffsetAssert(hitObjects[0], -90); // time_second_circle - first_circle_time - 90 addClickActionAssert(0, ClickAction.Hit); addClickActionAssert(1, ClickAction.Hit); } /// /// Tests clicking a future circle after the first circle's start time, while the first circle HAS been judged. /// [Test] public void TestClickSecondCircleAfterFirstCircleTimeWithFirstCircleJudged() { const double time_first_circle = 1500; const double time_second_circle = 1600; Vector2 positionFirstCircle = Vector2.Zero; Vector2 positionSecondCircle = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_first_circle, Position = positionFirstCircle }, new HitCircle { StartTime = time_second_circle, Position = positionSecondCircle } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_circle - 190, Position = positionFirstCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_first_circle, Position = positionSecondCircle, Actions = { OsuAction.RightButton } } }); addJudgementAssert(hitObjects[0], HitResult.Meh); addJudgementAssert(hitObjects[1], HitResult.Ok); addJudgementOffsetAssert(hitObjects[0], -190); // time_first_circle - 190 addJudgementOffsetAssert(hitObjects[1], -100); // time_second_circle - first_circle_time addClickActionAssert(0, ClickAction.Hit); addClickActionAssert(1, ClickAction.Hit); } /// /// Tests clicking a future circle after a slider's start time, but hitting the slider head and all slider ticks. /// [Test] public void TestHitCircleBeforeSliderHead() { const double time_slider = 1500; const double time_circle = 1510; Vector2 positionCircle = Vector2.Zero; Vector2 positionSlider = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_circle, Position = positionCircle }, new Slider { StartTime = time_slider, Position = positionSlider, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(50, 0), }) } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_slider, Position = positionCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_slider + 10, Position = positionSlider, Actions = { OsuAction.RightButton } } }); addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Great); addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); addClickActionAssert(0, ClickAction.Hit); addClickActionAssert(1, ClickAction.Hit); } /// /// Tests clicking hitting future slider ticks before a circle. /// [Test] public void TestHitSliderTicksBeforeCircle() { const double time_slider = 1500; const double time_circle = 1510; Vector2 positionCircle = Vector2.Zero; Vector2 positionSlider = new Vector2(30); var hitObjects = new List { new HitCircle { StartTime = time_circle, Position = positionCircle }, new Slider { StartTime = time_slider, Position = positionSlider, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(50, 0), }) } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_circle + referenceHitWindows.WindowFor(HitResult.Meh) - 100, Position = positionCircle, Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_circle + referenceHitWindows.WindowFor(HitResult.Meh) - 90, Position = positionSlider, Actions = { OsuAction.LeftButton } }, }); addJudgementAssert(hitObjects[0], HitResult.Ok); addJudgementAssert(hitObjects[1], HitResult.Great); addJudgementAssert("slider head", () => ((Slider)hitObjects[1]).HeadCircle, HitResult.LargeTickHit); addJudgementAssert("slider tick", () => ((Slider)hitObjects[1]).NestedHitObjects[1] as SliderTick, HitResult.LargeTickHit); addClickActionAssert(0, ClickAction.Hit); addClickActionAssert(1, ClickAction.Hit); } /// /// Tests clicking a future circle before a spinner. /// [Test] public void TestHitCircleBeforeSpinner() { const double time_spinner = 1500; const double time_circle = 1600; Vector2 positionCircle = Vector2.Zero; var hitObjects = new List { new TestSpinner { StartTime = time_spinner, Position = new Vector2(256, 192), EndTime = time_spinner + 1000, }, new HitCircle { StartTime = time_circle, Position = positionCircle }, }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_spinner - 90, Position = positionCircle, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_spinner + 10, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_spinner + 20, Position = new Vector2(256, 172), Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_spinner + 30, Position = new Vector2(276, 192), Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_spinner + 40, Position = new Vector2(256, 212), Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_spinner + 50, Position = new Vector2(236, 192), Actions = { OsuAction.RightButton } }, }); addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Meh); addClickActionAssert(0, ClickAction.Hit); } [Test] public void TestHitSliderHeadBeforeHitCircle() { const double time_circle = 1000; const double time_slider = 1200; Vector2 positionCircle = Vector2.Zero; Vector2 positionSlider = new Vector2(80); var hitObjects = new List { new HitCircle { StartTime = time_circle, Position = positionCircle }, new Slider { StartTime = time_slider, Position = positionSlider, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(25, 0), }) } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_circle - 100, Position = positionSlider, Actions = { OsuAction.LeftButton } }, new OsuReplayFrame { Time = time_circle, Position = positionCircle, Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_slider, Position = positionSlider, Actions = { OsuAction.LeftButton } }, }); addJudgementAssert(hitObjects[0], HitResult.Great); addJudgementAssert(hitObjects[1], HitResult.Great); addClickActionAssert(0, ClickAction.Shake); addClickActionAssert(1, ClickAction.Hit); addClickActionAssert(2, ClickAction.Hit); } [Test] public void TestOverlappingSliders() { const double time_first_slider = 1000; const double time_second_slider = 1200; Vector2 positionFirstSlider = new Vector2(100, 50); Vector2 positionSecondSlider = new Vector2(100, 80); var midpoint = (positionFirstSlider + positionSecondSlider) / 2; var hitObjects = new List { new Slider { StartTime = time_first_slider, Position = positionFirstSlider, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(25, 0), }) }, new Slider { StartTime = time_second_slider, Position = positionSecondSlider, Path = new SliderPath(PathType.Linear, new[] { Vector2.Zero, new Vector2(25, 0), }) } }; performTest(hitObjects, new List { new OsuReplayFrame { Time = time_first_slider, Position = midpoint, Actions = { OsuAction.RightButton } }, new OsuReplayFrame { Time = time_first_slider + 25, Position = midpoint, Actions = { OsuAction.LeftButton, OsuAction.RightButton } }, new OsuReplayFrame { Time = time_first_slider + 50, Position = midpoint }, new OsuReplayFrame { Time = time_second_slider, Position = positionSecondSlider + new Vector2(0, 10), Actions = { OsuAction.LeftButton } }, }); addJudgementAssert(hitObjects[0], HitResult.Ok); addJudgementAssert(hitObjects[1], HitResult.Great); addClickActionAssert(0, ClickAction.Hit); addClickActionAssert(1, ClickAction.Hit); } [Test] public void TestStacksDoNotShake() { const double time_stack_start = 1000; Vector2 position = new Vector2(80); var hitObjects = Enumerable.Range(0, 20).Select(i => new HitCircle { StartTime = time_stack_start + i * 100, Position = position }).Cast().ToList(); performTest(hitObjects, new List { new OsuReplayFrame { Time = time_stack_start - 450, Position = new Vector2(55), Actions = { OsuAction.LeftButton } }, }); addClickActionAssert(0, ClickAction.Ignore); } private void addJudgementAssert(OsuHitObject hitObject, HitResult result) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judgement is {result}", () => judgementResults.Single(r => r.HitObject == hitObject).Type, () => Is.EqualTo(result)); } private void addJudgementAssert(string name, Func hitObject, HitResult result) { AddAssert($"{name} judgement is {result}", () => judgementResults.Single(r => r.HitObject == hitObject()).Type, () => Is.EqualTo(result)); } private void addJudgementOffsetAssert(OsuHitObject hitObject, double offset) { AddAssert($"({hitObject.GetType().ReadableName()} @ {hitObject.StartTime}) judged at {offset}", () => judgementResults.Single(r => r.HitObject == hitObject).TimeOffset, () => Is.EqualTo(offset).Within(100)); } private void addClickActionAssert(int inputIndex, ClickAction action) => AddAssert($"input #{inputIndex} resulted in {action}", () => testPolicy.ClickActions[inputIndex], () => Is.EqualTo(action)); private ScoreAccessibleReplayPlayer currentPlayer = null!; private List judgementResults = null!; private TestLegacyHitPolicy testPolicy = null!; private void performTest(List hitObjects, List frames, [CallerMemberName] string testCaseName = "") { IBeatmap playableBeatmap = null!; Score score = null!; AddStep("create beatmap", () => { var cpi = new ControlPointInfo(); cpi.Add(0, new TimingControlPoint { BeatLength = 1000 }); Beatmap.Value = CreateWorkingBeatmap(new Beatmap { Metadata = { Title = testCaseName }, HitObjects = hitObjects, Difficulty = new BeatmapDifficulty { OverallDifficulty = 0, SliderTickRate = 3 }, BeatmapInfo = { Ruleset = new OsuRuleset().RulesetInfo, BeatmapVersion = LegacyBeatmapEncoder.FIRST_LAZER_VERSION // for correct offset treatment by score encoder }, ControlPointInfo = cpi }); playableBeatmap = Beatmap.Value.GetPlayableBeatmap(new OsuRuleset().RulesetInfo); }); AddStep("create score", () => { score = new Score { Replay = new Replay { Frames = new List { // required for correct playback in stable new OsuReplayFrame(0, new Vector2(256, -500)), new OsuReplayFrame(0, new Vector2(256, -500)) }.Concat(frames).ToList() }, ScoreInfo = { Ruleset = new OsuRuleset().RulesetInfo, BeatmapInfo = playableBeatmap.BeatmapInfo } }; }); if (exportLocation != null) { AddStep("export beatmap", () => { var beatmapEncoder = new LegacyBeatmapEncoder(playableBeatmap, null); using (var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osu"), FileMode.Create)) { var memoryStream = new MemoryStream(); using (var writer = new StreamWriter(memoryStream, Encoding.UTF8, leaveOpen: true)) beatmapEncoder.Encode(writer); memoryStream.Seek(0, SeekOrigin.Begin); memoryStream.CopyTo(stream); memoryStream.Seek(0, SeekOrigin.Begin); playableBeatmap.BeatmapInfo.MD5Hash = memoryStream.ComputeMD5Hash(); } }); AddStep("export score", () => { using var stream = File.Open(Path.Combine(exportLocation, $"{testCaseName}.osr"), FileMode.Create); var encoder = new LegacyScoreEncoder(score, playableBeatmap); encoder.Encode(stream); }); } AddStep("load player", () => { SelectedMods.Value = new[] { new OsuModClassic() }; var p = new ScoreAccessibleReplayPlayer(score); p.OnLoadComplete += _ => { p.ScoreProcessor.NewJudgement += result => { if (currentPlayer == p) judgementResults.Add(result); }; }; LoadScreen(currentPlayer = p); judgementResults = new List(); }); AddUntilStep("Beatmap at 0", () => Beatmap.Value.Track.CurrentTime == 0); AddUntilStep("Wait until player is loaded", () => currentPlayer.IsCurrentScreen()); AddStep("Substitute hit policy", () => currentPlayer.ChildrenOfType().Single().HitPolicy = testPolicy = new TestLegacyHitPolicy()); AddUntilStep("Wait for completion", () => currentPlayer.ScoreProcessor.HasCompleted.Value); } private class TestSpinner : Spinner { protected override void ApplyDefaultsToSelf(ControlPointInfo controlPointInfo, IBeatmapDifficultyInfo difficulty) { base.ApplyDefaultsToSelf(controlPointInfo, difficulty); SpinsRequired = 1; } } 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, }) { } } private class TestLegacyHitPolicy : LegacyHitPolicy { public List ClickActions { get; } = new List(); public override ClickAction CheckHittable(DrawableHitObject hitObject, double time) { var action = base.CheckHittable(hitObject, time); ClickActions.Add(action); return action; } } } }