Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Broadcast gameplay events for third-party use #263

Draft
wants to merge 41 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
36e51ba
Initial attempts at IPC
LumpBloom7 Aug 27, 2021
ed99cab
Create a transmission protocol to be used by the broadcaster
LumpBloom7 Sep 10, 2021
fa840b4
Use created protocol in broadcaster
LumpBloom7 Sep 10, 2021
6328b15
Wait for client connection in test
LumpBloom7 Sep 10, 2021
614b86b
Fix typo in TransmissionData
LumpBloom7 Sep 10, 2021
32073a0
Make protocol equatable
LumpBloom7 Sep 12, 2021
4f116ff
Store data into a buffer before use
LumpBloom7 Sep 12, 2021
7d178e1
Minor cleaning
LumpBloom7 Sep 12, 2021
8a2332d
Make broadcaster disposable
LumpBloom7 Sep 13, 2021
c080f60
Integrate broadcaster into gameplay
LumpBloom7 Sep 13, 2021
4df36d9
Make things readonly
LumpBloom7 Sep 13, 2021
3e70006
Merge branch 'master' into pipe-dreams
LumpBloom7 Sep 20, 2021
0fe99a0
Add Kill InfoType
LumpBloom7 Sep 21, 2021
436a927
Use immediately form a TransmissionData
LumpBloom7 Sep 21, 2021
40f26ba
Add a kill static
LumpBloom7 Sep 21, 2021
a19f35e
Ensure that test read thread doesn't stay alive
LumpBloom7 Sep 21, 2021
a6967d1
Fire lane press event before anything else
LumpBloom7 Sep 21, 2021
eaa8cd5
Disallow attempts to use disposed broadcaster
LumpBloom7 Sep 21, 2021
0cb36a2
Some cleanups
LumpBloom7 Sep 21, 2021
5b8c17c
Properly name the client
LumpBloom7 Sep 21, 2021
5a96b2a
Fix tests not working on non-windows
LumpBloom7 Sep 24, 2021
a113d3f
More comprehensive testing
LumpBloom7 Sep 24, 2021
bfbf728
Dispose test broadcaster properly
LumpBloom7 Sep 24, 2021
fe12121
Recreate pipe client when server dies
LumpBloom7 Sep 24, 2021
37e0a81
Run test methods together in ctor
LumpBloom7 Sep 25, 2021
2c132ab
Fix non-windows exception not being handled
LumpBloom7 Sep 25, 2021
4e82a60
Appease CodeFactor
LumpBloom7 Sep 25, 2021
4b2ba9a
Make use of async operations
LumpBloom7 Sep 26, 2021
16b8115
Add resend test
LumpBloom7 Sep 26, 2021
491f56d
Account for OperationCanceledException
LumpBloom7 Sep 26, 2021
82c079d
New check for server disconnect
LumpBloom7 Sep 26, 2021
9938493
Be specific in client exception handling
LumpBloom7 Sep 26, 2021
8b52769
Remove unused isDisposed flag
LumpBloom7 Sep 26, 2021
0901b8c
Run tests on all platforms
LumpBloom7 Sep 26, 2021
b921fb5
Merge branch 'master' into pipe-dreams
LumpBloom7 Nov 17, 2021
8bedbc1
Merge branch 'master' into pipe-dreams
LumpBloom7 Feb 4, 2022
fc87a3f
Use broadcast method provided by DrawableSentakkiRuleset
LumpBloom7 Feb 4, 2022
d07418b
Add ruleset option to turn on/off IPC
LumpBloom7 Feb 4, 2022
ba0075f
Remove debug code
LumpBloom7 Feb 4, 2022
93d827b
Send startplay event asap
LumpBloom7 Feb 8, 2022
3a0fa4d
Merge branch 'master' into pipe-dreams
LumpBloom7 Mar 11, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .github/workflows/dotnetcore.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,10 @@ on:
jobs:
build:
name: Build and Test
runs-on: ubuntu-latest
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ ubuntu-latest, macos-latest, windows-latest ]

steps:
- name: Checkout repository
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
using System;
using System.IO.Pipes;
using System.Security.Principal;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using osu.Framework.Graphics.Sprites;
using osu.Game.Rulesets.Sentakki.IO;
using osu.Game.Tests.Visual;

namespace osu.Game.Rulesets.Sentakki.Tests.IO
{
public class TestSceneGameplayEventBroadcaster : OsuTestScene
{
private GameplayEventBroadcaster broadcaster;
private TestBroadcastClient client;
private readonly SpriteText text;

public TestSceneGameplayEventBroadcaster()
{
Add(text = new SpriteText()
{
Text = "Nothing here yet"
});
}

[Test]
public void TestNormalOperation()
{
AddStep("Start broadcaster", () => createBroadcaster());
AddStep("Create Client", () => createTestClient());
AddUntilStep("Client connected", () => client.IsClientConnected);
AddStep("Send message 1", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.HitPerfect, 3)));
AddUntilStep("Client received message 1", () => text.Text == new TransmissionData(TransmissionData.InfoType.HitPerfect, 3).ToString());
AddStep("Send message 2", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.MetaEndPlay, 3)));
AddUntilStep("Client received message 2", () => text.Text == new TransmissionData(TransmissionData.InfoType.MetaEndPlay, 3).ToString());
AddStep("Send message 3", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.Miss, 3)));
AddUntilStep("Client received message 3", () => text.Text == new TransmissionData(TransmissionData.InfoType.Miss, 3).ToString());
AddStep("Dispose client", () => client?.Dispose());
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
}

[Test]
public void TestOperationWithoutClient()
{
AddStep("Start broadcaster", () => createBroadcaster());
AddStep("Send message 1", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.HitPerfect, 3)));
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
}

[Test]
public void TestOperationWithClientDisconnect()
{
AddStep("Start broadcaster", () => createBroadcaster());
AddStep("Create Client", () => createTestClient());
AddUntilStep("Client connected", () => client.IsClientConnected);
AddStep("Client disconnect", () => client?.Dispose());
AddStep("Send message 1", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.HitPerfect, 3)));
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
}

[Test]
public void TestRetryBroadcastOnClientReconnect()
{
AddStep("Start broadcaster", () => createBroadcaster());
AddStep("Send message 1", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.HitPerfect, 3)));
AddStep("Create Client", () => createTestClient());
AddUntilStep("Client connected", () => client.IsClientConnected);
AddUntilStep("Client received message 1", () => text.Text == new TransmissionData(TransmissionData.InfoType.HitPerfect, 3).ToString());
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
AddStep("Dispose client", () => client?.Dispose());
}

// This is just to ensure my sample client implementation holds up
// So others can be confident they aren't getting a sample that doesn't work
[Test]
public void TestClientOperationWithServerReconnect()
{
AddStep("Start broadcaster", () => createBroadcaster());
AddStep("Create Client", () => createTestClient());
AddUntilStep("Client connected", () => client.IsClientConnected);
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
AddStep("Start new broadcaster", () => broadcaster = new GameplayEventBroadcaster());
AddUntilStep("Client connected", () => client.IsClientConnected);
AddStep("Send message 1", () => broadcaster.Broadcast(new TransmissionData(TransmissionData.InfoType.HitPerfect, 3)));
AddUntilStep("Client received message 1", () => text.Text == new TransmissionData(TransmissionData.InfoType.HitPerfect, 3).ToString());
AddStep("Dispose broadcaster", () => broadcaster.Dispose());
AddStep("Dispose client", () => client?.Dispose());
}

private void createBroadcaster()
{
broadcaster?.Dispose();
broadcaster = new GameplayEventBroadcaster();
}

private void createTestClient()
{
client?.Dispose();
client = new TestBroadcastClient(text);
}

protected override void Dispose(bool isDisposing)
{
base.Dispose(isDisposing);
client?.Dispose();
broadcaster?.Dispose();
}

public class TestBroadcastClient : IDisposable
{
private NamedPipeClientStream pipeClient;

private readonly SpriteText text;

private bool running = true;

public bool IsClientConnected => pipeClient.IsConnected;

private readonly Thread readThread;

public TestBroadcastClient(SpriteText outputText)
{
text = outputText;
pipeClient = new NamedPipeClientStream(".", "senPipe",
PipeDirection.In, PipeOptions.Asynchronous,
TokenImpersonationLevel.Impersonation);

readThread = new Thread(clientLoop);
readThread.Start();
}

private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
private CancellationToken cancellationToken => cancellationTokenSource.Token;

private async void clientLoop()
{
byte[] buffer = new byte[1];
while (running)
{
try
{
if (!pipeClient.IsConnected)
await pipeClient.ConnectAsync(cancellationToken).ConfigureAwait(false);

int result = await pipeClient.ReadAsync(new Memory<byte>(buffer), cancellationToken).ConfigureAwait(false);

if (result > 0)
{
TransmissionData packet = new TransmissionData(buffer[0]);

if (packet != TransmissionData.Empty)
text.Text = packet.ToString();
}
else if (result == 0) // End of stream reached, meaning that the server disconnected
{
text.Text = TransmissionData.Kill.ToString();

// On non-Windows platforms, the client doesn't automatically reconnect
// So we must recreate the client to ensure safety;
pipeClient.Dispose();
pipeClient = new NamedPipeClientStream(".", "senPipe",
PipeDirection.In, PipeOptions.Asynchronous,
TokenImpersonationLevel.Impersonation);
}
}
catch (Exception e)
{
// The operation was canceled. Gracefully shutdown;
if (e is TaskCanceledException || e is OperationCanceledException)
return;

throw;
}
}
}

public void Dispose()
{
running = false;
cancellationTokenSource.Cancel();
readThread.Join();
pipeClient.Dispose();
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
using NUnit.Framework;
using osu.Framework.Testing;
using osu.Game.Rulesets.Sentakki.IO;

namespace osu.Game.Rulesets.Sentakki.Tests.IO
{
[HeadlessTest]
public class TestSceneTransmissionProtocol
{
private const TransmissionData.InfoType testtype = TransmissionData.InfoType.Miss;
private const int testvalue = 7;
private const byte testbyte = ((byte)testtype << 3) + 7;

[Test]
public void TestEncode()
{
var encoded = new TransmissionData(testtype, testvalue);
Assert.AreEqual(testbyte, encoded.RawData);
}

[Test]
public void TestDecode()
{
var encoded = new TransmissionData(testbyte);
Assert.AreEqual(testtype, encoded.Type);
Assert.AreEqual(testvalue, encoded.Value);
}

[Test]
public void TestKillEquality()
{
var packet1 = new TransmissionData(TransmissionData.InfoType.Kill, 7); // Baseline
var packet2 = new TransmissionData(TransmissionData.InfoType.Kill, 0); // Lightly altered, but should still be considered the same, because of the kill flag

Assert.AreEqual(packet1, packet2);
}
}
}
2 changes: 2 additions & 0 deletions osu.Game.Rulesets.Sentakki.Tests/TestSceneOsuGame.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
using osu.Framework.Bindables;
using osu.Framework.Graphics;
using osu.Framework.Graphics.Shapes;
using osu.Framework.Graphics.Sprites;
using osu.Game.Online.API;
using osu.Game.Online.API.Requests.Responses;
using osu.Game.Rulesets.Sentakki.Tests.IO;
using osu.Game.Tests.Visual;
using osu.Game.Users;
using osuTK.Graphics;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ protected override void InitialiseDefaults()
SetDefault(SentakkiRulesetSettings.LaneInputMode, LaneInputMode.Button);
SetDefault(SentakkiRulesetSettings.SnakingSlideBody, true);
SetDefault(SentakkiRulesetSettings.DetailedJudgements, false);
SetDefault(SentakkiRulesetSettings.GameplayIPC, false);
}
}

Expand Down Expand Up @@ -51,6 +52,7 @@ public enum SentakkiRulesetSettings
TouchAnimationDuration,
LaneInputMode,
SnakingSlideBody,
DetailedJudgements
DetailedJudgements,
GameplayIPC,
}
}
83 changes: 83 additions & 0 deletions osu.Game.Rulesets.Sentakki/IO/GameplayEventBroadcaster.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.IO;
using System.IO.Pipes;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;

namespace osu.Game.Rulesets.Sentakki.IO
{
public class GameplayEventBroadcaster : IDisposable
{
private readonly NamedPipeServerStream pipeServer;

// This is used to store the message that needs to be sent
// In the event that a broadcast fails, we can resend this message once a new connection is established.
private TransmissionData queuedData;

public GameplayEventBroadcaster()
{
pipeServer = new NamedPipeServerStream("senPipe", PipeDirection.Out, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous);
attemptConnection();
}

private bool isWaitingForClient;

private readonly CancellationTokenSource cancellationTokenSource = new CancellationTokenSource();
private CancellationToken cancellationToken => cancellationTokenSource.Token;

private async void attemptConnection()
{
if (isWaitingForClient) return;

isWaitingForClient = true;

try { await pipeServer.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false); }
catch (Exception e)
{
// The operation was canceled. Gracefully shutdown;
if (e is TaskCanceledException || (e is SocketException se && se.SocketErrorCode == SocketError.OperationAborted))
return;

throw;
}

isWaitingForClient = false;

if (queuedData != TransmissionData.Empty)
Broadcast(queuedData);
}

private readonly byte[] buffer = new byte[1];

public async void Broadcast(TransmissionData packet)
{
buffer[0] = packet.RawData;

queuedData = packet;

if (isWaitingForClient) return;

try
{
await pipeServer.WriteAsync(new Memory<byte>(buffer), cancellationToken).ConfigureAwait(false);
}
catch (IOException)
{
// The client has suddenly disconnected, we must disconnect on our end, and wait for a new connection.
pipeServer.Disconnect();
attemptConnection();

return;
}

queuedData = TransmissionData.Empty;
}

public void Dispose()
{
cancellationTokenSource.Cancel();
pipeServer.Dispose();
}
}
}
Loading