// 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.Diagnostics; using System.Linq; using System.Threading.Tasks; using osu.Framework.Allocation; using osu.Framework.Bindables; using osu.Framework.Utils; using osu.Game.Online.API; using osu.Game.Online.Spectator; using osu.Game.Replays.Legacy; using osu.Game.Rulesets; using osu.Game.Rulesets.Judgements; using osu.Game.Rulesets.Objects; using osu.Game.Rulesets.Replays; using osu.Game.Rulesets.Scoring; using osu.Game.Scoring; namespace osu.Game.Tests.Visual.Spectator { public partial class TestSpectatorClient : SpectatorClient { /// /// Maximum number of frames sent per bundle via . /// public const int FRAME_BUNDLE_SIZE = 10; /// /// Whether to force send operations to fail (simulating a network issue). /// public bool ShouldFailSendingFrames { get; set; } public int FrameSendAttempts { get; private set; } public override IBindable IsConnected => isConnected; private readonly BindableBool isConnected = new BindableBool(true); public IReadOnlyDictionary LastReceivedUserFrames => lastReceivedUserFrames; private readonly Dictionary lastReceivedUserFrames = new Dictionary(); private readonly Dictionary userBeatmapDictionary = new Dictionary(); private readonly Dictionary userModsDictionary = new Dictionary(); private readonly Dictionary userNextFrameDictionary = new Dictionary(); [Resolved] private IAPIProvider api { get; set; } = null!; [Resolved] private RulesetStore rulesetStore { get; set; } = null!; public TestSpectatorClient() { OnNewFrames += (i, bundle) => lastReceivedUserFrames[i] = bundle.Frames[^1]; } /// /// Starts play for an arbitrary user. /// /// The user to start play for. /// The playing beatmap id. /// The mods the user has applied. public void SendStartPlay(int userId, int beatmapId, APIMod[]? mods = null) { userBeatmapDictionary[userId] = beatmapId; userModsDictionary[userId] = mods ?? Array.Empty(); userNextFrameDictionary[userId] = 0; sendPlayingState(userId); } /// /// Ends play for an arbitrary user. /// /// The user to end play for. /// The spectator state to end play with. public void SendEndPlay(int userId, SpectatedUserState state = SpectatedUserState.Quit) { if (!userBeatmapDictionary.ContainsKey(userId)) return; ((ISpectatorClient)this).UserFinishedPlaying(userId, new SpectatorState { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, Mods = userModsDictionary[userId], State = state }); userBeatmapDictionary.Remove(userId); userModsDictionary.Remove(userId); } /// /// Sends frames for an arbitrary user, in bundles containing 10 frames each. /// This bypasses the standard queueing mechanism completely and should only be used to test cases where multiple users need to be sending data. /// Importantly, will have no effect. /// /// The user to send frames for. /// The total number of frames to send. /// The time to start gameplay frames from. /// Add a number of misses to frame header data for testing purposes. public void SendFramesFromUser(int userId, int count, double startTime = 0, int initialResultCount = 0) { var frames = new List(); int currentFrameIndex = userNextFrameDictionary[userId]; int lastFrameIndex = currentFrameIndex + count - 1; var scoreProcessor = new ScoreProcessor(rulesetStore.GetRuleset(0)!.CreateInstance()); for (int i = 0; i < initialResultCount; i++) { scoreProcessor.ApplyResult(new JudgementResult(new HitObject(), new Judgement()) { Type = HitResult.Miss, }); } for (; currentFrameIndex <= lastFrameIndex; currentFrameIndex++) { // This is done in the next frame so that currentFrameIndex is updated to the correct value. if (frames.Count == FRAME_BUNDLE_SIZE) flush(); var buttonState = currentFrameIndex == lastFrameIndex ? ReplayButtonState.None : ReplayButtonState.Left1; frames.Add(new LegacyReplayFrame(currentFrameIndex * 100 + startTime, RNG.Next(0, 512), RNG.Next(0, 512), buttonState)); } flush(); userNextFrameDictionary[userId] = currentFrameIndex; void flush() { if (frames.Count == 0) return; var bundle = new FrameDataBundle(new ScoreInfo { Combo = currentFrameIndex, TotalScore = (long)(currentFrameIndex * 123478 * RNG.NextDouble(0.99, 1.01)), Accuracy = RNG.NextDouble(0.98, 1), Statistics = scoreProcessor.Statistics.ToDictionary(), }, scoreProcessor, frames.ToArray()); if (initialResultCount > 0) { foreach (var f in frames) f.Header = bundle.Header; } scoreProcessor.ResetFromReplayFrame(frames.Last()); ((ISpectatorClient)this).UserSentFrames(userId, bundle); frames.Clear(); } } protected override Task BeginPlayingInternal(long? scoreToken, SpectatorState state) { // Track the local user's playing beatmap ID. Debug.Assert(state.BeatmapID != null); userBeatmapDictionary[api.LocalUser.Value.Id] = state.BeatmapID.Value; userModsDictionary[api.LocalUser.Value.Id] = state.Mods.ToArray(); return ((ISpectatorClient)this).UserBeganPlaying(api.LocalUser.Value.Id, state); } protected override Task SendFramesInternal(FrameDataBundle bundle) { FrameSendAttempts++; if (ShouldFailSendingFrames) return Task.FromException(new InvalidOperationException($"Intentional fail via {nameof(ShouldFailSendingFrames)}")); return ((ISpectatorClient)this).UserSentFrames(api.LocalUser.Value.Id, bundle); } protected override Task EndPlayingInternal(SpectatorState state) => ((ISpectatorClient)this).UserFinishedPlaying(api.LocalUser.Value.Id, state); protected override Task WatchUserInternal(int userId) { // When newly watching a user, the server sends the playing state immediately. if (userBeatmapDictionary.ContainsKey(userId)) sendPlayingState(userId); return Task.CompletedTask; } protected override Task StopWatchingUserInternal(int userId) => Task.CompletedTask; private void sendPlayingState(int userId) { ((ISpectatorClient)this).UserBeganPlaying(userId, new SpectatorState { BeatmapID = userBeatmapDictionary[userId], RulesetID = 0, Mods = userModsDictionary[userId], State = SpectatedUserState.Playing }); } protected override async Task DisconnectInternal() { await base.DisconnectInternal().ConfigureAwait(false); isConnected.Value = false; } } }