diff --git a/osu.Server.Spectator.Tests/SpectatorHubTest.cs b/osu.Server.Spectator.Tests/SpectatorHubTest.cs index dc0c5f45..1893f184 100644 --- a/osu.Server.Spectator.Tests/SpectatorHubTest.cs +++ b/osu.Server.Spectator.Tests/SpectatorHubTest.cs @@ -3,14 +3,17 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Microsoft.AspNetCore.SignalR; using Microsoft.Extensions.Logging; using Moq; using osu.Game.Beatmaps; +using osu.Game.Online.API; using osu.Game.Online.API.Requests.Responses; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; +using osu.Game.Rulesets.Osu.Mods; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; using osu.Server.Spectator.Database; @@ -208,6 +211,111 @@ await hub.EndPlaySession(new SpectatorState mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is(m => m.State == SpectatedUserState.Quit)), Times.Once()); } + [Fact] + public async Task ModChangesDuringPlayAreHandled() + { + scoreUploader.SaveReplays = true; + + Mock> mockClients = new Mock>(); + Mock mockReceiver = new Mock(); + mockClients.Setup(clients => clients.All).Returns(mockReceiver.Object); + mockClients.Setup(clients => clients.Group(SpectatorHub.GetGroupId(streamer_id))).Returns(mockReceiver.Object); + + Mock mockContext = new Mock(); + + mockContext.Setup(context => context.UserIdentifier).Returns(streamer_id.ToString()); + hub.Context = mockContext.Object; + hub.Clients = mockClients.Object; + + mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult(new SoloScore + { + id = 456, + passed = true + })); + + await hub.BeginPlaySession(1234, new SpectatorState + { + BeatmapID = beatmap_id, + RulesetID = 0, + State = SpectatedUserState.Playing, + }); + + await hub.SendFrameData(new FrameDataBundle( + new FrameHeader(new ScoreInfo + { + Mods = [new OsuModTouchDevice()], + Statistics = new Dictionary { [HitResult.Great] = 1 } + }, new ScoreProcessorStatistics()), + new[] { new LegacyReplayFrame(1234, 0, 0, ReplayButtonState.None) })); + + await hub.EndPlaySession(new SpectatorState + { + BeatmapID = beatmap_id, + RulesetID = 0, + State = SpectatedUserState.Quit, + }); + + await uploadsCompleteAsync(); + + mockScoreStorage.Verify(s => s.WriteAsync(It.Is(s => s.ScoreInfo.Mods.Any(m => m is OsuModTouchDevice))), Times.Once); + mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is(m => m.State == SpectatedUserState.Quit)), Times.Once()); + } + + [Fact] + public async Task FrameBundlesFromOldClientsWithoutModsHandledCorrectly() + { + scoreUploader.SaveReplays = true; + + Mock> mockClients = new Mock>(); + Mock mockReceiver = new Mock(); + mockClients.Setup(clients => clients.All).Returns(mockReceiver.Object); + mockClients.Setup(clients => clients.Group(SpectatorHub.GetGroupId(streamer_id))).Returns(mockReceiver.Object); + + Mock mockContext = new Mock(); + + mockContext.Setup(context => context.UserIdentifier).Returns(streamer_id.ToString()); + hub.Context = mockContext.Object; + hub.Clients = mockClients.Object; + + mockDatabase.Setup(db => db.GetScoreFromTokenAsync(1234)).Returns(Task.FromResult(new SoloScore + { + id = 456, + passed = true + })); + + await hub.BeginPlaySession(1234, new SpectatorState + { + BeatmapID = beatmap_id, + RulesetID = 0, + State = SpectatedUserState.Playing, + Mods = [new APIMod(new OsuModDoubleTime())] + }); + + var frameHeader = new FrameHeader(new ScoreInfo + { + Statistics = new Dictionary { [HitResult.Great] = 1 } + }, new ScoreProcessorStatistics()) + { + Mods = null, // simulate older client that did not send this property over wire + }; + + await hub.SendFrameData(new FrameDataBundle( + frameHeader, + new[] { new LegacyReplayFrame(1234, 0, 0, ReplayButtonState.None) })); + + await hub.EndPlaySession(new SpectatorState + { + BeatmapID = beatmap_id, + RulesetID = 0, + State = SpectatedUserState.Quit, + }); + + await uploadsCompleteAsync(); + + mockScoreStorage.Verify(s => s.WriteAsync(It.Is(s => s.ScoreInfo.Mods.Any(m => m is OsuModDoubleTime))), Times.Once); + mockReceiver.Verify(clients => clients.UserFinishedPlaying(streamer_id, It.Is(m => m.State == SpectatedUserState.Quit)), Times.Once()); + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs index 7b076418..43c42b56 100644 --- a/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs +++ b/osu.Server.Spectator/Hubs/Spectator/SpectatorHub.cs @@ -124,6 +124,11 @@ public async Task SendFrameData(FrameDataBundle data) score.ScoreInfo.Combo = data.Header.Combo; score.ScoreInfo.TotalScore = data.Header.TotalScore; + // null here means the frame bundle is from an old client that can't send mod data + // can be removed (along with making property non-nullable on `FrameDataBundle`) 20250407 + if (data.Header.Mods != null) + score.ScoreInfo.APIMods = data.Header.Mods; + score.Replay.Frames.AddRange(data.Frames); await Clients.Group(GetGroupId(Context.GetUserId())).UserSentFrames(Context.GetUserId(), data);