diff --git a/osu.Game.Tests/OnlinePlay/MultiplayerSyncManagerTest.cs b/osu.Game.Tests/OnlinePlay/MultiplayerSyncManagerTest.cs new file mode 100644 index 0000000000..2a6dfa9c8d --- /dev/null +++ b/osu.Game.Tests/OnlinePlay/MultiplayerSyncManagerTest.cs @@ -0,0 +1,213 @@ +// 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 NUnit.Framework; +using osu.Framework.Bindables; +using osu.Framework.Testing; +using osu.Framework.Timing; +using osu.Game.Screens.OnlinePlay.Multiplayer.Spectate; +using osu.Game.Tests.Visual; + +namespace osu.Game.Tests.OnlinePlay +{ + [HeadlessTest] + public class MultiplayerSyncManagerTest : OsuTestScene + { + private TestManualClock master; + private MultiplayerSyncManager syncManager; + + private TestSlaveClock slave1; + private TestSlaveClock slave2; + + [SetUp] + public void Setup() + { + syncManager = new MultiplayerSyncManager(master = new TestManualClock()); + syncManager.AddSlave(slave1 = new TestSlaveClock(1)); + syncManager.AddSlave(slave2 = new TestSlaveClock(2)); + + Schedule(() => Child = syncManager); + } + + [Test] + public void TestMasterClockStartsWhenAllSlavesHaveFrames() + { + setWaiting(() => slave1, false); + assertMasterState(false); + assertSlaveState(() => slave1, false); + assertSlaveState(() => slave2, false); + + setWaiting(() => slave2, false); + assertMasterState(true); + assertSlaveState(() => slave1, true); + assertSlaveState(() => slave2, true); + } + + [Test] + public void TestMasterClockDoesNotStartWhenNoneReadyForMaximumDelayTime() + { + AddWaitStep($"wait {MultiplayerSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(MultiplayerSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(false); + } + + [Test] + public void TestMasterClockStartsWhenAnyReadyForMaximumDelayTime() + { + setWaiting(() => slave1, false); + AddWaitStep($"wait {MultiplayerSyncManager.MAXIMUM_START_DELAY} milliseconds", (int)Math.Ceiling(MultiplayerSyncManager.MAXIMUM_START_DELAY / TimePerAction)); + assertMasterState(true); + } + + [Test] + public void TestSlaveDoesNotCatchUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(MultiplayerSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => slave1, false); + } + + [Test] + public void TestSlaveStartsCatchingUpWhenTooFarBehind() + { + setAllWaiting(false); + + setMasterTime(MultiplayerSyncManager.MAX_SYNC_OFFSET + 1); + assertCatchingUp(() => slave1, true); + assertCatchingUp(() => slave2, true); + } + + [Test] + public void TestSlaveKeepsCatchingUpWhenSlightlyOutOfSync() + { + setAllWaiting(false); + + setMasterTime(MultiplayerSyncManager.MAX_SYNC_OFFSET + 1); + setSlaveTime(() => slave1, MultiplayerSyncManager.SYNC_TARGET + 1); + assertCatchingUp(() => slave1, true); + } + + [Test] + public void TestSlaveStopsCatchingUpWhenInSync() + { + setAllWaiting(false); + + setMasterTime(MultiplayerSyncManager.MAX_SYNC_OFFSET + 2); + setSlaveTime(() => slave1, MultiplayerSyncManager.SYNC_TARGET); + assertCatchingUp(() => slave1, false); + assertCatchingUp(() => slave2, true); + } + + [Test] + public void TestSlaveDoesNotStopWhenSlightlyAhead() + { + setAllWaiting(false); + + setSlaveTime(() => slave1, -MultiplayerSyncManager.SYNC_TARGET); + assertCatchingUp(() => slave1, false); + assertSlaveState(() => slave1, true); + } + + [Test] + public void TestSlaveStopsWhenTooFarAheadAndStartsWhenBackInSync() + { + setAllWaiting(false); + + setSlaveTime(() => slave1, -MultiplayerSyncManager.SYNC_TARGET - 1); + + // This is a silent catchup, where IsCatchingUp = false but IsRunning = false also. + assertCatchingUp(() => slave1, false); + assertSlaveState(() => slave1, false); + + setMasterTime(1); + assertCatchingUp(() => slave1, false); + assertSlaveState(() => slave1, true); + } + + [Test] + public void TestInSyncSlaveDoesNotStartIfWaitingOnFrames() + { + setAllWaiting(false); + + assertSlaveState(() => slave1, true); + setWaiting(() => slave1, true); + assertSlaveState(() => slave1, false); + } + + private void setWaiting(Func slave, bool waiting) + => AddStep($"set slave {slave().Id} waiting = {waiting}", () => slave().WaitingOnFrames.Value = waiting); + + private void setAllWaiting(bool waiting) => AddStep($"set all slaves waiting = {waiting}", () => + { + slave1.WaitingOnFrames.Value = waiting; + slave2.WaitingOnFrames.Value = waiting; + }); + + private void setMasterTime(double time) + => AddStep($"set master = {time}", () => master.Seek(time)); + + /// + /// slave.Time = master.Time - offsetFromMaster + /// + private void setSlaveTime(Func slave, double offsetFromMaster) + => AddStep($"set slave {slave().Id} = master - {offsetFromMaster}", () => slave().Seek(master.CurrentTime - offsetFromMaster)); + + private void assertMasterState(bool running) + => AddAssert($"master clock {(running ? "is" : "is not")} running", () => master.IsRunning == running); + + private void assertCatchingUp(Func slave, bool catchingUp) => + AddAssert($"slave {slave().Id} {(catchingUp ? "is" : "is not")} catching up", () => slave().IsCatchingUp == catchingUp); + + private void assertSlaveState(Func slave, bool running) + => AddAssert($"slave {slave().Id} {(running ? "is" : "is not")} running", () => slave().IsRunning == running); + + private class TestSlaveClock : TestManualClock, IMultiplayerSlaveClock + { + public readonly Bindable WaitingOnFrames = new Bindable(true); + IBindable IMultiplayerSlaveClock.WaitingOnFrames => WaitingOnFrames; + + public double LastFrameTime => 0; + + double IMultiplayerSlaveClock.LastFrameTime => LastFrameTime; + + public bool IsCatchingUp { get; set; } + + public readonly int Id; + + public TestSlaveClock(int id) + { + Id = id; + + WaitingOnFrames.BindValueChanged(waiting => + { + if (waiting.NewValue) + Stop(); + else + Start(); + }); + } + } + + private class TestManualClock : ManualClock, IAdjustableClock + { + public void Start() => IsRunning = true; + + public void Stop() => IsRunning = false; + + public bool Seek(double position) + { + CurrentTime = position; + return true; + } + + public void Reset() + { + } + + public void ResetSpeedAdjustments() + { + } + } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSlaveClock.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSlaveClock.cs new file mode 100644 index 0000000000..e0fca45bdd --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSlaveClock.cs @@ -0,0 +1,17 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Bindables; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public interface IMultiplayerSlaveClock : IAdjustableClock + { + IBindable WaitingOnFrames { get; } + + double LastFrameTime { get; } + + bool IsCatchingUp { get; set; } + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSyncManager.cs new file mode 100644 index 0000000000..2c50596823 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/IMultiplayerSyncManager.cs @@ -0,0 +1,16 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public interface IMultiplayerSyncManager + { + IAdjustableClock Master { get; } + + void AddSlave(IMultiplayerSlaveClock clock); + + void RemoveSlave(IMultiplayerSlaveClock clock); + } +} diff --git a/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSyncManager.cs b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSyncManager.cs new file mode 100644 index 0000000000..42f6536e90 --- /dev/null +++ b/osu.Game/Screens/OnlinePlay/Multiplayer/Spectate/MultiplayerSyncManager.cs @@ -0,0 +1,162 @@ +// Copyright (c) ppy Pty Ltd . 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 osu.Framework.Graphics; +using osu.Framework.Logging; +using osu.Framework.Timing; + +namespace osu.Game.Screens.OnlinePlay.Multiplayer.Spectate +{ + public class MultiplayerSyncManager : Component, IMultiplayerSyncManager + { + /// + /// The offset from the master clock to which slaves should be synchronised to. + /// + public const double SYNC_TARGET = 16; + + /// + /// The offset from the master clock at which slaves begin resynchronising. + /// + public const double MAX_SYNC_OFFSET = 50; + + /// + /// The maximum delay to start gameplay, if any (but not all) slaves are ready. + /// + public const double MAXIMUM_START_DELAY = 15000; + + /// + /// The catchup rate. + /// + public const double CATCHUP_RATE = 2; + + /// + /// The master clock which is used to control the timing of all slave clocks. + /// + public IAdjustableClock Master { get; } + + /// + /// The slave clocks. + /// + private readonly List slaves = new List(); + + private bool hasStarted; + private double? firstStartAttemptTime; + + public MultiplayerSyncManager(IAdjustableClock master) + { + Master = master; + } + + public void AddSlave(IMultiplayerSlaveClock clock) => slaves.Add(clock); + + public void RemoveSlave(IMultiplayerSlaveClock clock) => slaves.Remove(clock); + + protected override void Update() + { + base.Update(); + + if (!attemptStart()) + { + // Ensure all slaves are stopped until the start succeeds. + foreach (var slave in slaves) + slave.Stop(); + return; + } + + updateCatchup(); + updateMasterClock(); + } + + /// + /// Attempts to start playback. Awaits for all slaves to have available frames for up to milliseconds. + /// + /// Whether playback was started and syncing should occur. + private bool attemptStart() + { + if (hasStarted) + return true; + + if (slaves.Count == 0) + return false; + + firstStartAttemptTime ??= Time.Current; + + int readyCount = slaves.Count(s => !s.WaitingOnFrames.Value); + + if (readyCount == slaves.Count) + { + Logger.Log("Gameplay started (all ready)."); + return hasStarted = true; + } + + if (readyCount > 0 && (Time.Current - firstStartAttemptTime) > MAXIMUM_START_DELAY) + { + Logger.Log($"Gameplay started (maximum delay exceeded, {readyCount}/{slaves.Count} ready)."); + return hasStarted = true; + } + + return false; + } + + /// + /// Updates the catchup states of all slave clocks. + /// + private void updateCatchup() + { + for (int i = 0; i < slaves.Count; i++) + { + var slave = slaves[i]; + double timeDelta = Master.CurrentTime - slave.CurrentTime; + + // Check that the slave isn't too far ahead. + // This is a quiet case in which the catchup is done by the master clock, so IsCatchingUp is not set on the slave. + if (timeDelta < -SYNC_TARGET) + { + slave.Stop(); + continue; + } + + // Make sure the slave is running if it can. + if (!slave.WaitingOnFrames.Value) + slave.Start(); + + if (slave.IsCatchingUp) + { + // Stop the slave from catching up if it's within the sync target. + if (timeDelta <= SYNC_TARGET) + { + slave.IsCatchingUp = false; + Logger.Log($"Slave {i} catchup finished (delta = {timeDelta})"); + } + } + else + { + // Make the slave start catching up if it's exceeded the maximum allowable sync offset. + if (timeDelta > MAX_SYNC_OFFSET) + { + slave.IsCatchingUp = true; + Logger.Log($"Slave {i} catchup started (too far behind, delta = {timeDelta})"); + } + } + } + } + + /// + /// Updates the master clock's running state. + /// + private void updateMasterClock() + { + bool anyInSync = slaves.Any(s => !s.IsCatchingUp); + + if (Master.IsRunning != anyInSync) + { + if (anyInSync) + Master.Start(); + else + Master.Stop(); + } + } + } +}