Editor/Core/SoloBot.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
namespace TeamCreate;
public enum SoloBotState
{
Idle,
Connecting,
AwaitingWelcome,
Live,
Refused,
Failed,
Closed
}
/// <summary>What a collaborator says about itself in its hello. Everything except the identity fields is copied from the host.</summary>
public sealed class SoloHandshake
{
public string Project;
public string Baseline;
public string Manifest;
public string Org;
public string LibraryVersion;
public string SteamId;
public string SteamToken;
public string Name;
/// <summary>The session token the host expects: the hex of the key derived from the invite.</summary>
public string SessionToken;
}
/// <summary>
/// A scripted collaborator. It speaks the same wire protocol as a second editor (encrypted transport, hello, subscribe, edit, presence) but is
/// driven by code, so one person can watch a full session alone. It is polled from the frame tick and starts no threads. It lives in Core so
/// CoreTests can run it against the real host authority; nothing here can reach the editor's scene, only the wire.
/// </summary>
public sealed class SoloBot : IDisposable
{
public const int MaxPacketsPerPump = 256;
private Task<Wire> connecting;
private Wire wire;
private readonly Dictionary<string, Dictionary<string, ObjectState>> scenes = new Dictionary<string, Dictionary<string, ObjectState>>(StringComparer.Ordinal);
private readonly HashSet<string> pendingEdits = new HashSet<string>(StringComparer.Ordinal);
public string Peer { get; } = Guid.NewGuid().ToString();
public SoloHandshake Handshake { get; }
public SoloBotState State { get; private set; }
/// <summary>The host's own words when it refused the handshake, or the transport's when the connection failed.</summary>
public string Refusal { get; private set; }
public string HostSession { get; private set; }
public int AcceptedOwn { get; private set; }
public int AcceptedFromOthers { get; private set; }
public List<Packet> Rejects { get; } = new List<Packet>();
public Dictionary<string, Packet> Others { get; } = new Dictionary<string, Packet>(StringComparer.Ordinal);
public int PendingCount => pendingEdits.Count;
public bool IsLive => State == SoloBotState.Live;
public SoloBot(SoloHandshake handshake)
{
Handshake = handshake ?? throw new ArgumentNullException(nameof(handshake));
}
/// <summary>Starts a dial. The delegate is the route: a direct TCP connect, or the relay.</summary>
public void Connect(Func<Task<Wire>> dial)
{
if (State != SoloBotState.Idle)
{
throw new InvalidOperationException("This collaborator has already connected once; make a new one.");
}
connecting = dial();
State = SoloBotState.Connecting;
}
public IReadOnlyDictionary<string, ObjectState> Known(string scene)
{
return scenes.TryGetValue(scene, out Dictionary<string, ObjectState> state) ? state : new Dictionary<string, ObjectState>();
}
public bool HasScene(string scene)
{
return scenes.ContainsKey(scene);
}
public void Pump()
{
if (State == SoloBotState.Connecting && connecting != null && connecting.IsCompleted)
{
if (connecting.IsFaulted || connecting.IsCanceled)
{
State = SoloBotState.Failed;
Refusal = (connecting.Exception?.GetBaseException().Message) ?? "The connection was cancelled.";
return;
}
wire = connecting.Result;
connecting = null;
wire.Send(new Packet
{
Kind = "hello",
ProtocolVersion = Protocol.CurrentVersion,
Mode = CollaborationMode.Source,
Peer = Peer,
Project = Handshake.Project,
Baseline = Handshake.Baseline,
Name = Handshake.Name,
Token = Handshake.SessionToken,
SteamId = Handshake.SteamId,
SteamToken = Handshake.SteamToken,
OrgIdent = Handshake.Org,
LibraryVersion = Handshake.LibraryVersion,
ProjectManifest = Handshake.Manifest
});
State = SoloBotState.AwaitingWelcome;
}
if (wire == null)
{
return;
}
int budget = MaxPacketsPerPump;
while (budget-- > 0 && wire.Incoming.TryDequeue(out Packet packet))
{
Handle(packet);
}
if (wire != null && wire.StoredFailure != null && (State == SoloBotState.Live || State == SoloBotState.AwaitingWelcome))
{
Refusal = wire.StoredFailure;
State = State == SoloBotState.Live ? SoloBotState.Closed : SoloBotState.Failed;
}
}
private void Handle(Packet p)
{
switch (p.Kind)
{
case "welcome":
HostSession = p.Session;
State = SoloBotState.Live;
break;
case "fatal":
case "reject":
if (State == SoloBotState.AwaitingWelcome)
{
Refusal = p.Error ?? "The host refused this collaborator.";
State = SoloBotState.Refused;
}
else
{
Rejects.Add(p);
if (p.Id != null)
{
pendingEdits.Remove(p.Id);
}
}
break;
case "snapshot":
if (p.Scene != null)
{
scenes[p.Scene] = StateRules.Map(p.Objects ?? new List<ObjectState>());
}
break;
case "accepted":
if (p.Scene != null && scenes.TryGetValue(p.Scene, out Dictionary<string, ObjectState> known))
{
SoloScript.ApplyAccepted(known, p.Changes);
}
if (p.Peer == Peer)
{
AcceptedOwn++;
pendingEdits.Remove(p.Id);
}
else
{
AcceptedFromOthers++;
}
break;
case "presence":
if (p.Peer != null && p.Peer != Peer)
{
Others[p.Peer] = p;
}
break;
case "left":
if (p.Peer != null)
{
Others.Remove(p.Peer);
}
break;
}
}
/// <summary>
/// Subscribes to a scene the host already holds; it answers with its own state. Only the first subscriber to a scene ever seeds it, which is
/// what the host editor does, and what a test uses to stand in for one.
/// </summary>
public void Subscribe(string scene, List<ObjectState> seed = null)
{
Require();
wire.Send(new Packet { Kind = "subscribe", Scene = scene, Objects = seed ?? new List<ObjectState>() });
}
/// <summary>Sends one edit built against the state this collaborator last saw. Returns the edit's id, or null when the bot is not live.</summary>
public string SendEdit(string scene, IEnumerable<Change> changes, bool final = true)
{
Require();
Packet edit = new Packet { Kind = "edit", Scene = scene, Peer = Peer, Changes = changes.ToList(), Final = final };
pendingEdits.Add(edit.Id);
wire.Send(edit);
return edit.Id;
}
public void SendPresence(string scene, JsonObject presence)
{
Require();
wire.Send(new Packet { Kind = "presence", Scene = scene, Peer = Peer, Presence = presence });
wire.Send(new Packet { Kind = "ping" });
}
private void Require()
{
if (wire == null || State != SoloBotState.Live && State != SoloBotState.AwaitingWelcome)
{
throw new InvalidOperationException("The test collaborator is not connected.");
}
}
/// <summary>Says goodbye the way an editor does, so the host clears the roster row at once instead of after its timeout.</summary>
public void Leave()
{
if (wire != null && State == SoloBotState.Live)
{
try
{
wire.Send(new Packet { Kind = "leave", Peer = Peer });
}
catch (Exception)
{
}
}
Dispose();
}
public void Dispose()
{
wire?.Dispose();
wire = null;
if (State == SoloBotState.Live || State == SoloBotState.AwaitingWelcome || State == SoloBotState.Connecting)
{
State = SoloBotState.Closed;
}
}
}