mirror of
https://github.com/osukey/osukey.git
synced 2025-08-02 22:26:41 +09:00
Reimplement syncing logic as a new component
This commit is contained in:
213
osu.Game.Tests/OnlinePlay/MultiplayerSyncManagerTest.cs
Normal file
213
osu.Game.Tests/OnlinePlay/MultiplayerSyncManagerTest.cs
Normal file
@ -0,0 +1,213 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<TestSlaveClock> 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));
|
||||
|
||||
/// <summary>
|
||||
/// slave.Time = master.Time - offsetFromMaster
|
||||
/// </summary>
|
||||
private void setSlaveTime(Func<TestSlaveClock> 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<TestSlaveClock> slave, bool catchingUp) =>
|
||||
AddAssert($"slave {slave().Id} {(catchingUp ? "is" : "is not")} catching up", () => slave().IsCatchingUp == catchingUp);
|
||||
|
||||
private void assertSlaveState(Func<TestSlaveClock> slave, bool running)
|
||||
=> AddAssert($"slave {slave().Id} {(running ? "is" : "is not")} running", () => slave().IsRunning == running);
|
||||
|
||||
private class TestSlaveClock : TestManualClock, IMultiplayerSlaveClock
|
||||
{
|
||||
public readonly Bindable<bool> WaitingOnFrames = new Bindable<bool>(true);
|
||||
IBindable<bool> 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()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,17 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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<bool> WaitingOnFrames { get; }
|
||||
|
||||
double LastFrameTime { get; }
|
||||
|
||||
bool IsCatchingUp { get; set; }
|
||||
}
|
||||
}
|
@ -0,0 +1,16 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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);
|
||||
}
|
||||
}
|
@ -0,0 +1,162 @@
|
||||
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
|
||||
{
|
||||
/// <summary>
|
||||
/// The offset from the master clock to which slaves should be synchronised to.
|
||||
/// </summary>
|
||||
public const double SYNC_TARGET = 16;
|
||||
|
||||
/// <summary>
|
||||
/// The offset from the master clock at which slaves begin resynchronising.
|
||||
/// </summary>
|
||||
public const double MAX_SYNC_OFFSET = 50;
|
||||
|
||||
/// <summary>
|
||||
/// The maximum delay to start gameplay, if any (but not all) slaves are ready.
|
||||
/// </summary>
|
||||
public const double MAXIMUM_START_DELAY = 15000;
|
||||
|
||||
/// <summary>
|
||||
/// The catchup rate.
|
||||
/// </summary>
|
||||
public const double CATCHUP_RATE = 2;
|
||||
|
||||
/// <summary>
|
||||
/// The master clock which is used to control the timing of all slave clocks.
|
||||
/// </summary>
|
||||
public IAdjustableClock Master { get; }
|
||||
|
||||
/// <summary>
|
||||
/// The slave clocks.
|
||||
/// </summary>
|
||||
private readonly List<IMultiplayerSlaveClock> slaves = new List<IMultiplayerSlaveClock>();
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to start playback. Awaits for all slaves to have available frames for up to <see cref="MAXIMUM_START_DELAY"/> milliseconds.
|
||||
/// </summary>
|
||||
/// <returns>Whether playback was started and syncing should occur.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the catchup states of all slave clocks.
|
||||
/// </summary>
|
||||
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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates the master clock's running state.
|
||||
/// </summary>
|
||||
private void updateMasterClock()
|
||||
{
|
||||
bool anyInSync = slaves.Any(s => !s.IsCatchingUp);
|
||||
|
||||
if (Master.IsRunning != anyInSync)
|
||||
{
|
||||
if (anyInSync)
|
||||
Master.Start();
|
||||
else
|
||||
Master.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user