Editor-side PlayRoster class that tracks who is currently hosting a collaborative Play session. It validates scene and name inputs, starts a run with a generated run id, refuses concurrent requests, and clears state when the player stops or leaves.
using System;
using System.Text;
namespace TeamCreate;
public enum PlayStartVerdict
{
/// <summary>The request became the run; the host broadcasts it.</summary>
Started,
/// <summary>Someone else is playing. The requester keeps its own, local run and is told so.</summary>
Refused,
/// <summary>The request itself was malformed (a scene outside the project, no peer). It is dropped without a run.</summary>
Invalid
}
public sealed record PlayStartResult( PlayStartVerdict Verdict, string RunId, string Notice, string Player );
/// <summary>
/// Who is playing, kept by the host and nowhere else. At most ONE run exists at a time; the host reads requests in the order they arrive, so two
/// people pressing Play together are ordered by that read and the second is refused, with no tie to break. The roster is pure state: the
/// session server turns its verdicts into packets, and every rule here is tested without a socket.
/// </summary>
/// <remarks>
/// A refused person's own Play still works in the engine, as a purely local run; only the collaboration part is refused, and the notice says so
/// because the person would otherwise believe the others can see them. A run belongs to the connection that started it: when that peer leaves
/// or its connection drops the run ends for everyone, because nobody is left to sample it. A reconnecting player is a new request with a new run
/// id, so frames of the old run can never be mistaken for the new one.
/// </remarks>
public sealed class PlayRoster
{
/// <summary>The same bound a scene name gets everywhere else on the wire (a project path is short).</summary>
public const int MaxSceneLength = 260;
public const int MaxNameLength = 64;
public bool IsPlaying => Player != null;
public string Player { get; private set; }
public string PlayerName { get; private set; }
public string RunId { get; private set; }
public string Scene { get; private set; }
/// <summary>The formal sentence a refused person reads. <paramref name="name"/> is cleaned first: it came from a peer and is shown to people.</summary>
public static string AlreadyPlayingNotice( string name ) =>
CleanName( name ) + " is already playing. Wait until they stop, or ask them to stop. Your run is local and is not shown to the others.";
/// <summary>Control characters removed, bounded, and a blank name replaced by a neutral one, so a name can never break a sentence or a line.</summary>
public static string CleanName( string name )
{
var builder = new StringBuilder();
foreach ( char c in name ?? string.Empty )
{
if ( !char.IsControl( c ) )
{
builder.Append( c );
}
if ( builder.Length >= MaxNameLength )
{
break;
}
}
string cleaned = builder.ToString().Trim();
return cleaned.Length == 0 ? "A collaborator" : cleaned;
}
/// <summary>The scene must be a project scene, by the rule a subscribe is held to: under scenes/, no parent hops, bounded.</summary>
public static bool IsPlayableScene( string scene ) =>
!string.IsNullOrWhiteSpace( scene ) && scene.Length <= MaxSceneLength && scene.StartsWith( "scenes/", StringComparison.Ordinal ) && !scene.Contains( ".." ) && scene.IndexOf( '\\' ) < 0;
public PlayStartResult TryStart( string peer, string name, string scene, Func<string> newRunId )
{
if ( string.IsNullOrEmpty( peer ) || !IsPlayableScene( scene ) )
{
return new PlayStartResult( PlayStartVerdict.Invalid, null, null, Player );
}
if ( Player != null && !string.Equals( Player, peer, StringComparison.Ordinal ) )
{
return new PlayStartResult( PlayStartVerdict.Refused, null, AlreadyPlayingNotice( PlayerName ), Player );
}
Player = peer;
PlayerName = CleanName( name );
Scene = scene;
RunId = newRunId();
return new PlayStartResult( PlayStartVerdict.Started, RunId, null, Player );
}
/// <summary>True when the run ended. Only the player may end it, and only by naming the run that is current.</summary>
public bool Stop( string peer, string runId )
{
if ( !IsPlayer( peer, runId ) )
{
return false;
}
Clear();
return true;
}
/// <summary>True when the leaver was the player, so the host must tell everyone the run is over.</summary>
public bool PeerLeft( string peer )
{
if ( Player == null || !string.Equals( Player, peer, StringComparison.Ordinal ) )
{
return false;
}
Clear();
return true;
}
public bool IsPlayer( string peer, string runId ) =>
Player != null && peer != null && runId != null && string.Equals( Player, peer, StringComparison.Ordinal ) && string.Equals( RunId, runId, StringComparison.Ordinal );
private void Clear()
{
Player = null;
PlayerName = null;
RunId = null;
Scene = null;
}
}