public void TestTeamDisplay() { AddStep("start players", () => { var player1 = OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_1_ID }, true); player1.MatchState = new TeamVersusUserState { TeamID = 0, }; var player2 = OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_2_ID }, true); player2.MatchState = new TeamVersusUserState { TeamID = 1, }; SpectatorClient.StartPlay(player1.UserID, importedBeatmapId); SpectatorClient.StartPlay(player2.UserID, importedBeatmapId); playingUsers.Add(player1); playingUsers.Add(player2); }); loadSpectateScreen(); sendFrames(PLAYER_1_ID, 1000); sendFrames(PLAYER_2_ID, 1000); AddWaitStep("wait a bit", 20); }
public void TestDelayedStart() { AddStep("start players silently", () => { OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_1_ID }, true); OnlinePlayDependencies.Client.AddUser(new User { Id = PLAYER_2_ID }, true); playingUsers.Add(new MultiplayerRoomUser(PLAYER_1_ID)); playingUsers.Add(new MultiplayerRoomUser(PLAYER_2_ID)); }); loadSpectateScreen(false); AddWaitStep("wait a bit", 10); AddStep("load player first_player_id", () => SpectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType <Player>().Count() == 1); AddWaitStep("wait a bit", 10); AddStep("load player second_player_id", () => SpectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType <Player>().Count() == 2); }
private void sendFrames(int[] userIds, int count = 10) { AddStep("send frames", () => { foreach (int id in userIds) { SpectatorClient.SendFrames(id, count); } }); }
private void end(int userId) { AddStep($"end play for {userId}", () => { var user = playingUsers.Single(u => u.UserID == userId); OnlinePlayDependencies.Client.RemoveUser(user.User.AsNonNull()); SpectatorClient.EndPlay(userId); playingUsers.Remove(user); }); }
private void start(int[] userIds, int?beatmapId = null) { AddStep("start play", () => { foreach (int id in userIds) { Client.CurrentMatchPlayingUserIds.Add(id); SpectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); playingUserIds.Add(id); } }); }
/// <summary> /// </summary> public MapLoadingScreen(List <Score> scores, Replay replay = null, SpectatorClient spectatorClient = null) { Scores = scores; Replay = replay; SpectatorClient = spectatorClient; var game = GameBase.Game as QuaverGame; var cursor = game?.GlobalUserInterface.Cursor; cursor.Alpha = 0; View = new MapLoadingScreenView(this); AudioTrack.AllowPlayback = false; }
private void start(int[] userIds, int?beatmapId = null) { AddStep("start play", () => { foreach (int id in userIds) { OnlinePlayDependencies.Client.AddUser(new User { Id = id }, true); SpectatorClient.StartPlay(id, beatmapId ?? importedBeatmapId); playingUsers.Add(new MultiplayerRoomUser(id)); } }); }
public new void SetUpSteps() { AddStep("reset", () => { Clear(); clocks = new Dictionary <int, ManualClock> { { PLAYER_1_ID, new ManualClock() }, { PLAYER_2_ID, new ManualClock() } }; foreach (var(userId, _) in clocks) { SpectatorClient.StartPlay(userId, 0); } });
public new void SetUpSteps() { AddStep("reset", () => { Clear(); clocks = new Dictionary <int, ManualClock> { { PLAYER_1_ID, new ManualClock() }, { PLAYER_2_ID, new ManualClock() } }; foreach ((int userId, var _) in clocks) { SpectatorClient.SendStartPlay(userId, 0); OnlinePlayDependencies.MultiplayerClient.AddUser(new APIUser { Id = userId }); } }); AddStep("create leaderboard", () => { Beatmap.Value = CreateWorkingBeatmap(Ruleset.Value); var playable = Beatmap.Value.GetPlayableBeatmap(Ruleset.Value); var scoreProcessor = new OsuScoreProcessor(); scoreProcessor.ApplyBeatmap(playable); LoadComponentAsync(leaderboard = new MultiSpectatorLeaderboard(Ruleset.Value, scoreProcessor, clocks.Keys.Select(id => new MultiplayerRoomUser(id)).ToArray()) { Expanded = { Value = true } }, Add); }); AddUntilStep("wait for load", () => leaderboard.IsLoaded); AddUntilStep("wait for user population", () => leaderboard.ChildrenOfType <GameplayLeaderboardScore>().Count() == 2); AddStep("add clock sources", () => { foreach ((int userId, var clock) in clocks) { leaderboard.AddClock(userId, clock); } }); }
public void TestDelayedStart() { AddStep("start players silently", () => { Client.CurrentMatchPlayingUserIds.Add(PLAYER_1_ID); Client.CurrentMatchPlayingUserIds.Add(PLAYER_2_ID); playingUserIds.Add(PLAYER_1_ID); playingUserIds.Add(PLAYER_2_ID); }); loadSpectateScreen(false); AddWaitStep("wait a bit", 10); AddStep("load player first_player_id", () => SpectatorClient.StartPlay(PLAYER_1_ID, importedBeatmapId)); AddUntilStep("one player added", () => spectatorScreen.ChildrenOfType <Player>().Count() == 1); AddWaitStep("wait a bit", 10); AddStep("load player second_player_id", () => SpectatorClient.StartPlay(PLAYER_2_ID, importedBeatmapId)); AddUntilStep("two players added", () => spectatorScreen.ChildrenOfType <Player>().Count() == 2); }
public void TestLeaderboardTracksCurrentTime() { AddStep("send frames", () => { // For player 1, send frames in sets of 1. // For player 2, send frames in sets of 10. for (int i = 0; i < 100; i++) { SpectatorClient.SendFramesFromUser(PLAYER_1_ID, 1); if (i % 10 == 0) { SpectatorClient.SendFramesFromUser(PLAYER_2_ID, 10); } } }); assertCombo(PLAYER_1_ID, 1); assertCombo(PLAYER_2_ID, 10); // Advance to a point where only user player 1's frame changes. setTime(500); assertCombo(PLAYER_1_ID, 5); assertCombo(PLAYER_2_ID, 10); // Advance to a point where both user's frame changes. setTime(1100); assertCombo(PLAYER_1_ID, 11); assertCombo(PLAYER_2_ID, 20); // Advance user player 2 only to a point where its frame changes. setTime(PLAYER_2_ID, 2100); assertCombo(PLAYER_1_ID, 11); assertCombo(PLAYER_2_ID, 30); // Advance both users beyond their last frame setTime(101 * 100); assertCombo(PLAYER_1_ID, 100); assertCombo(PLAYER_2_ID, 100); }
public SpectatorDialog(SpectatorClient client) { SpectatorClient = client; Image = UserInterface.WaitingPanel; Size = new ScalableVector2(450, 134); Alpha = 0; SetChildrenAlpha = true; Icon = new Sprite { Parent = this, Alignment = Alignment.TopCenter, Image = FontAwesome.Get(FontAwesomeIcon.fa_information_button), Y = 18, Size = new ScalableVector2(24, 24) }; // ReSharper disable once ObjectCreationAsStatement Text = new SpriteTextBitmap(FontsBitmap.AllerRegular, "Waiting for host!") { Parent = this, FontSize = 20, Y = Icon.Y + Icon.Height + 10, Alignment = Alignment.TopCenter }; LoadingWheel = new Sprite() { Parent = this, Size = new ScalableVector2(40, 40), Image = UserInterface.LoadingWheel, Alignment = Alignment.TopCenter, Y = Text.Y + Text.Height + 10 }; OnlineManager.Client.OnUserStatusReceived += OnClientStatusReceived; }
private void load(ReadableKeyCombinationProvider keyCombinationProvider) { try { using (var str = File.OpenRead(typeof(OsuGameBase).Assembly.Location)) VersionHash = str.ComputeMD5Hash(); } catch { // special case for android builds, which can't read DLLs from a packed apk. // should eventually be handled in a better way. VersionHash = $"{Version}-{RuntimeInfo.OS}".ComputeMD5Hash(); } Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); if (Storage.Exists(DatabaseContextFactory.DATABASE_NAME)) { dependencies.Cache(EFContextFactory = new DatabaseContextFactory(Storage)); } dependencies.Cache(realm = new RealmAccess(Storage, "client", EFContextFactory)); dependencies.CacheAs <RulesetStore>(RulesetStore = new RealmRulesetStore(realm, Storage)); dependencies.CacheAs <IRulesetStore>(RulesetStore); Decoder.RegisterDependencies(RulesetStore); // Backup is taken here rather than in EFToRealmMigrator to avoid recycling realm contexts // after initial usages below. It can be moved once a direction is established for handling re-subscription. // See https://github.com/ppy/osu/pull/16547 for more discussion. if (EFContextFactory != null) { const string backup_folder = "backups"; string migration = $"before_final_migration_{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; EFContextFactory.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.db")); realm.CreateBackup(Path.Combine(backup_folder, $"client.{migration}.realm")); using (var source = Storage.GetStream("collection.db")) { if (source != null) { using (var destination = Storage.GetStream(Path.Combine(backup_folder, $"collection.{migration}.db"), FileAccess.Write, FileMode.CreateNew)) source.CopyTo(destination); } } } dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore <byte[]>(Resources, @"Textures"))); largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); InitialiseFonts(); Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; dependencies.Cache(SkinManager = new SkinManager(Storage, realm, Host, Resources, Audio, Scheduler)); dependencies.CacheAs <ISkinSource>(SkinManager); EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration) new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, realm, Scheduler, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, realm, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, performOnlineLookups: true)); dependencies.Cache(BeatmapDownloader = new BeatmapModelDownloader(BeatmapManager, API)); dependencies.Cache(ScoreDownloader = new ScoreModelDownloader(ScoreManager, API)); dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); AddInternal(difficultyCache); dependencies.Cache(userCache = new UserLookupCache()); AddInternal(userCache); dependencies.Cache(beatmapCache = new BeatmapLookupCache()); AddInternal(beatmapCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); dependencies.CacheAs <IRulesetConfigCache>(rulesetConfigCache = new RulesetConfigCache(realm, RulesetStore)); var powerStatus = CreateBatteryInfo(); if (powerStatus != null) { dependencies.CacheAs(powerStatus); } dependencies.Cache(SessionStatics = new SessionStatics()); dependencies.Cache(new OsuColour()); RegisterImportHandler(BeatmapManager); RegisterImportHandler(ScoreManager); RegisterImportHandler(SkinManager); // drop track volume game-wide to leave some head-room for UI effects / samples. // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); Beatmap = new NonNullableBindable <WorkingBeatmap>(defaultBeatmap); dependencies.CacheAs <IBindable <WorkingBeatmap> >(Beatmap); dependencies.CacheAs(Beatmap); // add api components to hierarchy. if (API is APIAccess apiAccess) { AddInternal(apiAccess); } AddInternal(spectatorClient); AddInternal(multiplayerClient); AddInternal(rulesetConfigCache); GlobalActionContainer globalBindings; base.Content.Add(new SafeAreaContainer { SafeAreaOverrideEdges = SafeAreaOverrideEdges, RelativeSizeAxes = Axes.Both, Child = CreateScalingContainer().WithChildren(new Drawable[] { (MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }).WithChild(content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }), // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything. globalBindings = new GlobalActionContainer(this) }) }); KeyBindingStore = new RealmKeyBindingStore(realm, keyCombinationProvider); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; dependencies.Cache(previewTrackManager = new PreviewTrackManager(BeatmapManager.BeatmapTrackStore)); Add(previewTrackManager); AddInternal(MusicController = new MusicController()); dependencies.CacheAs(MusicController); Ruleset.BindValueChanged(onRulesetChanged); Beatmap.BindValueChanged(onBeatmapChanged); }
public void TestNegativeGameplayStartTime() { start(PLAYER_1_ID); loadSpectateScreen(false, -500); // to ensure negative gameplay start time does not affect spectator, send frames exactly after StartGameplay(). // (similar to real spectating sessions in which the first frames get sent between StartGameplay() and player load complete) AddStep("send frames at gameplay start", () => getInstance(PLAYER_1_ID).OnGameplayStarted += () => SpectatorClient.SendFrames(PLAYER_1_ID, 100)); AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded); AddWaitStep("wait for progression", 3); assertNotCatchingUp(PLAYER_1_ID); assertRunning(PLAYER_1_ID); }
/// <summary> /// Ctor - /// </summary> /// <param name="map"></param> /// <param name="md5"></param> /// <param name="scores"></param> /// <param name="replay"></param> /// <param name="isPlayTesting"></param> /// <param name="playTestTime"></param> /// <param name="isCalibratingOffset"></param> /// <param name="spectatorClient"></param> public GameplayScreen(Qua map, string md5, List <Score> scores, Replay replay = null, bool isPlayTesting = false, double playTestTime = 0, bool isCalibratingOffset = false, SpectatorClient spectatorClient = null) { TimePlayed = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); if (isPlayTesting) { var testingQua = ObjectHelper.DeepClone(map); testingQua.HitObjects.RemoveAll(x => x.StartTime < playTestTime); Qua.RestoreDefaultValues(testingQua); Map = testingQua; OriginalEditorMap = map; } else { Map = map; } LocalScores = scores; MapHash = md5; LoadedReplay = replay; IsPlayTesting = isPlayTesting; PlayTestAudioTime = playTestTime; IsCalibratingOffset = isCalibratingOffset; IsMultiplayerGame = OnlineManager.CurrentGame != null; SpectatorClient = spectatorClient; if (SpectatorClient != null) { LoadedReplay = SpectatorClient.Replay; } if (IsMultiplayerGame) { OnlineManager.Client.OnUserJoinedGame += OnUserJoinedGame; OnlineManager.Client.OnUserLeftGame += OnUserLeftGame; OnlineManager.Client.OnAllPlayersLoaded += OnAllPlayersLoaded; OnlineManager.Client.OnAllPlayersSkipped += OnAllPlayersSkipped; } Timing = new GameplayAudioTiming(this); // Initialize the custom audio sample cache and the sound effect index. if (!IsCalibratingOffset) { CustomAudioSampleCache.LoadSamples(MapManager.Selected.Value, MapHash); } NextSoundEffectIndex = 0; UpdateNextSoundEffectIndex(); // Remove paused modifier if enabled. if (ModManager.IsActivated(ModIdentifier.Paused)) { ModManager.RemoveMod(ModIdentifier.Paused); } // Handle autoplay replays. if (ModManager.IsActivated(ModIdentifier.Autoplay)) { LoadedReplay = ReplayHelper.GeneratePerfectReplay(map, MapHash); } // Determine if we're in replay mode. if (LoadedReplay != null) { InReplayMode = true; } // Create the current replay that will be captured. ReplayCapturer = new ReplayCapturer(this); SetRuleset(); SetRichPresence(); AudioTrack.AllowPlayback = true; if (IsCalibratingOffset) { Metronome = new Metronome(map); } View = new GameplayScreenView(this); }
private void load() { try { using (var str = File.OpenRead(typeof(OsuGameBase).Assembly.Location)) VersionHash = str.ComputeMD5Hash(); } catch { // special case for android builds, which can't read DLLs from a packed apk. // should eventually be handled in a better way. VersionHash = $"{Version}-{RuntimeInfo.OS}".ComputeMD5Hash(); } Resources.AddStore(new DllResourceStore(OsuResources.ResourceAssembly)); dependencies.Cache(contextFactory = new DatabaseContextFactory(Storage)); dependencies.Cache(realmFactory = new RealmContextFactory(Storage)); updateThreadState = Host.UpdateThread.State.GetBoundCopy(); updateThreadState.BindValueChanged(updateThreadStateChanged); AddInternal(realmFactory); dependencies.CacheAs(Storage); var largeStore = new LargeTextureStore(Host.CreateTextureLoaderStore(new NamespacedResourceStore <byte[]>(Resources, @"Textures"))); largeStore.AddStore(Host.CreateTextureLoaderStore(new OnlineStore())); dependencies.Cache(largeStore); dependencies.CacheAs(this); dependencies.CacheAs(LocalConfig); InitialiseFonts(); Audio.Samples.PlaybackConcurrency = SAMPLE_CONCURRENCY; runMigrations(); dependencies.Cache(SkinManager = new SkinManager(Storage, contextFactory, Host, Resources, Audio)); dependencies.CacheAs <ISkinSource>(SkinManager); // needs to be done here rather than inside SkinManager to ensure thread safety of CurrentSkinInfo. SkinManager.ItemRemoved.BindValueChanged(weakRemovedInfo => { if (weakRemovedInfo.NewValue.TryGetTarget(out var removedInfo)) { Schedule(() => { // check the removed skin is not the current user choice. if it is, switch back to default. if (removedInfo.ID == SkinManager.CurrentSkinInfo.Value.ID) { SkinManager.CurrentSkinInfo.Value = SkinInfo.Default; } }); } }); EndpointConfiguration endpoints = UseDevelopmentServer ? (EndpointConfiguration) new DevelopmentEndpointConfiguration() : new ProductionEndpointConfiguration(); MessageFormatter.WebsiteRootUrl = endpoints.WebsiteRootUrl; dependencies.CacheAs(API ??= new APIAccess(LocalConfig, endpoints, VersionHash)); dependencies.CacheAs(spectatorClient = new OnlineSpectatorClient(endpoints)); dependencies.CacheAs(multiplayerClient = new OnlineMultiplayerClient(endpoints)); var defaultBeatmap = new DummyWorkingBeatmap(Audio, Textures); dependencies.Cache(RulesetStore = new RulesetStore(contextFactory, Storage)); dependencies.Cache(fileStore = new FileStore(contextFactory, Storage)); // ordering is important here to ensure foreign keys rules are not broken in ModelStore.Cleanup() dependencies.Cache(ScoreManager = new ScoreManager(RulesetStore, () => BeatmapManager, Storage, API, contextFactory, Scheduler, Host, () => difficultyCache, LocalConfig)); dependencies.Cache(BeatmapManager = new BeatmapManager(Storage, contextFactory, RulesetStore, API, Audio, Resources, Host, defaultBeatmap, true)); // this should likely be moved to ArchiveModelManager when another case appears where it is necessary // to have inter-dependent model managers. this could be obtained with an IHasForeign<T> interface to // allow lookups to be done on the child (ScoreManager in this case) to perform the cascading delete. List <ScoreInfo> getBeatmapScores(BeatmapSetInfo set) { var beatmapIds = BeatmapManager.QueryBeatmaps(b => b.BeatmapSetInfoID == set.ID).Select(b => b.ID).ToList(); return(ScoreManager.QueryScores(s => beatmapIds.Contains(s.Beatmap.ID)).ToList()); } BeatmapManager.ItemRemoved.BindValueChanged(i => { if (i.NewValue.TryGetTarget(out var item)) { ScoreManager.Delete(getBeatmapScores(item), true); } }); BeatmapManager.ItemUpdated.BindValueChanged(i => { if (i.NewValue.TryGetTarget(out var item)) { ScoreManager.Undelete(getBeatmapScores(item), true); } }); dependencies.Cache(difficultyCache = new BeatmapDifficultyCache()); AddInternal(difficultyCache); dependencies.Cache(userCache = new UserLookupCache()); AddInternal(userCache); var scorePerformanceManager = new ScorePerformanceCache(); dependencies.Cache(scorePerformanceManager); AddInternal(scorePerformanceManager); migrateDataToRealm(); dependencies.Cache(rulesetConfigCache = new RulesetConfigCache(realmFactory, RulesetStore)); var powerStatus = CreateBatteryInfo(); if (powerStatus != null) { dependencies.CacheAs(powerStatus); } dependencies.Cache(SessionStatics = new SessionStatics()); dependencies.Cache(new OsuColour()); RegisterImportHandler(BeatmapManager); RegisterImportHandler(ScoreManager); RegisterImportHandler(SkinManager); // drop track volume game-wide to leave some head-room for UI effects / samples. // this means that for the time being, gameplay sample playback is louder relative to the audio track, compared to stable. // we may want to revisit this if users notice or complain about the difference (consider this a bit of a trial). Audio.Tracks.AddAdjustment(AdjustableProperty.Volume, globalTrackVolumeAdjust); Beatmap = new NonNullableBindable <WorkingBeatmap>(defaultBeatmap); dependencies.CacheAs <IBindable <WorkingBeatmap> >(Beatmap); dependencies.CacheAs(Beatmap); fileStore.Cleanup(); // add api components to hierarchy. if (API is APIAccess apiAccess) { AddInternal(apiAccess); } AddInternal(spectatorClient); AddInternal(multiplayerClient); AddInternal(rulesetConfigCache); GlobalActionContainer globalBindings; var mainContent = new Drawable[] { MenuCursorContainer = new MenuCursorContainer { RelativeSizeAxes = Axes.Both }, // to avoid positional input being blocked by children, ensure the GlobalActionContainer is above everything. globalBindings = new GlobalActionContainer(this) }; MenuCursorContainer.Child = content = new OsuTooltipContainer(MenuCursorContainer.Cursor) { RelativeSizeAxes = Axes.Both }; base.Content.Add(CreateScalingContainer().WithChildren(mainContent)); KeyBindingStore = new RealmKeyBindingStore(realmFactory); KeyBindingStore.Register(globalBindings, RulesetStore.AvailableRulesets); dependencies.Cache(globalBindings); PreviewTrackManager previewTrackManager; dependencies.Cache(previewTrackManager = new PreviewTrackManager()); Add(previewTrackManager); AddInternal(MusicController = new MusicController()); dependencies.CacheAs(MusicController); Ruleset.BindValueChanged(onRulesetChanged); }
private void testLeadIn(Action <WorkingBeatmap> applyToBeatmap = null) { start(PLAYER_1_ID); loadSpectateScreen(false, applyToBeatmap); // to ensure negative gameplay start time does not affect spectator, send frames exactly after StartGameplay(). // (similar to real spectating sessions in which the first frames get sent between StartGameplay() and player load complete) AddStep("send frames at gameplay start", () => getInstance(PLAYER_1_ID).OnGameplayStarted += () => SpectatorClient.SendFramesFromUser(PLAYER_1_ID, 100)); AddUntilStep("wait for player load", () => spectatorScreen.AllPlayersLoaded); AddWaitStep("wait for progression", 3); assertNotCatchingUp(PLAYER_1_ID); assertRunning(PLAYER_1_ID); }