using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics.Contracts; using System.IO; using System.Linq; using System.Net; using System.Reflection; using System.Runtime.ExceptionServices; using System.Threading; using System.Threading.Channels; using System.Threading.Tasks; using Moq; using NUnit.Framework; using Robust.Client; using Robust.Client.Console; using Robust.Client.Timing; using Robust.Client.UserInterface; using Robust.Client.UserInterface.XAML.Proxy; using Robust.Server; using Robust.Server.Console; using Robust.Server.GameStates; using Robust.Server.ServerStatus; using Robust.Shared; using Robust.Shared.Asynchronous; using Robust.Shared.Configuration; using Robust.Shared.Console; using Robust.Shared.ContentPack; using Robust.Shared.Enums; using Robust.Shared.GameObjects; using Robust.Shared.Input; using Robust.Shared.IoC; using Robust.Shared.Log; using Robust.Shared.Map; using Robust.Shared.Network; using Robust.Shared.Player; using Robust.Shared.Prototypes; using Robust.Shared.Reflection; using Robust.Shared.Serialization; using Robust.Shared.Testing; using Robust.Shared.Timing; using ServerProgram = Robust.Server.Program; namespace Robust.UnitTesting { /// /// Base class allowing you to implement integration tests. /// /// /// Integration tests allow you to act upon a running server as a whole, /// contrary to unit testing which tests, well, units. /// public abstract partial class RobustIntegrationTest { internal static readonly ConcurrentQueue ClientsReady = new(); internal static readonly ConcurrentQueue ServersReady = new(); internal static readonly ConcurrentQueue ClientsCreated = new(); internal static readonly ConcurrentQueue ClientsPooled = new(); internal static readonly ConcurrentQueue ClientsNotPooled = new(); internal static readonly ConcurrentQueue ServersCreated = new(); internal static readonly ConcurrentQueue ServersPooled = new(); internal static readonly ConcurrentQueue ServersNotPooled = new(); private readonly List _notPooledInstances = new(); private readonly ConcurrentDictionary _clientsRunning = new(); private readonly ConcurrentDictionary _serversRunning = new(); private string TestId => TestContext.CurrentContext.Test.FullName; private string GetTestsRanString(IntegrationInstance instance, string running) { var type = instance is ServerIntegrationInstance ? "Server " : "Client "; return $"{type} tests ran ({instance.TestsRan.Count}):\n" + $"{string.Join('\n', instance.TestsRan)}\n" + $"Currently running: {running}"; } /// /// Start an instance of the server and return an object that can be used to control it. /// protected virtual ServerIntegrationInstance StartServer(ServerIntegrationOptions? options = null) { options ??= new ServerIntegrationOptions(); options.TestAssembly = GetType().Assembly; ServerIntegrationInstance instance; if (ShouldPool(options)) { if (ServersReady.TryDequeue(out var server)) { server.PreviousOptions = server.ServerOptions; server.ServerOptions = options; OnServerReturn(server).Wait(); // Ensure the instance is properly idle to avoid inconsistencies in behavior // between pooled and non-pooled returns. server.MarkNonIdle(); _serversRunning[server] = 0; instance = server; } else { instance = new ServerIntegrationInstance(options); _serversRunning[instance] = 0; ServersCreated.Enqueue(TestId); } ServersPooled.Enqueue(TestId); } else { instance = new ServerIntegrationInstance(options); _notPooledInstances.Add(instance); ServersCreated.Enqueue(TestId); ServersNotPooled.Enqueue(TestId); } var currentTest = TestContext.CurrentContext.Test.FullName; TestContext.Out.WriteLine(GetTestsRanString(instance, currentTest)); instance.TestsRan.Add(currentTest); return instance; } /// /// Start a headless instance of the client and return an object that can be used to control it. /// protected virtual ClientIntegrationInstance StartClient(ClientIntegrationOptions? options = null) { options ??= new ClientIntegrationOptions(); options.TestAssembly = GetType().Assembly; ClientIntegrationInstance instance; if (ShouldPool(options)) { if (ClientsReady.TryDequeue(out var client)) { client.PreviousOptions = client.ClientOptions; client.ClientOptions = options; OnClientReturn(client).Wait(); // Ensure the instance is properly idle to avoid inconsistencies in behavior // between pooled and non-pooled returns. client.MarkNonIdle(); _clientsRunning[client] = 0; instance = client; } else { instance = new ClientIntegrationInstance(options); _clientsRunning[instance] = 0; ClientsCreated.Enqueue(TestId); } ClientsPooled.Enqueue(TestId); } else { instance = new ClientIntegrationInstance(options); _notPooledInstances.Add(instance); ClientsCreated.Enqueue(TestId); ClientsNotPooled.Enqueue(TestId); } var currentTest = TestContext.CurrentContext.Test.FullName; TestContext.Out.WriteLine(GetTestsRanString(instance, currentTest)); instance.TestsRan.Add(currentTest); return instance; } /// /// Connects a client integration instance to a server integration instance. /// protected static async Task ConnectClient( ServerIntegrationInstance server, ClientIntegrationInstance client, string? userName = null) { await Task.WhenAll(client.WaitIdleAsync(), server.WaitIdleAsync()); Assert.DoesNotThrow(() => client.SetConnectTarget(server)); await client.WaitPost(() => ((IClientNetManager) client.NetMan).ClientConnect(null!, 0, userName!)); } /// /// Starts a connected client/server pair. /// protected async Task StartConnectedPair( ServerIntegrationOptions? serverOptions = null, ClientIntegrationOptions? clientOptions = null, string? userName = null) { var server = StartServer(serverOptions); var client = StartClient(clientOptions); await ConnectClient(server, client, userName); return new ConnectedIntegrationPair(server, client); } /// /// Runs the server and client in lockstep. /// protected static async Task RunTicksSync( ServerIntegrationInstance server, ClientIntegrationInstance client, int ticks) { for (var i = 0; i < ticks; i++) { await server.WaitRunTicks(1); await client.WaitRunTicks(1); } } /// /// Disconnects a client integration instance from its server and runs both sides long enough to process it. /// protected static async Task DisconnectClient( ServerIntegrationInstance server, ClientIntegrationInstance client, string reason = "") { await client.WaitPost(() => ((IClientNetManager) client.NetMan).ClientDisconnect(reason)); await RunTicksSync(server, client, 5); } protected sealed class ConnectedIntegrationPair : IAsyncDisposable { public ServerIntegrationInstance Server { get; } public ClientIntegrationInstance Client { get; } private bool _disposed; public ConnectedIntegrationPair(ServerIntegrationInstance server, ClientIntegrationInstance client) { Server = server; Client = client; } public void Deconstruct(out ClientIntegrationInstance client, out ServerIntegrationInstance server) { client = Client; server = Server; } public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; await DisconnectClient(Server, Client); } } private bool ShouldPool(IntegrationOptions? options) { // If no options are provided, we assume we should pool if (options == null) return true; // If custom options are provided without explicitly setting pool=true, we assume we shouldn't pool. if (options is not {Pool: true}) return false; if (!options.Asynchronous) throw new Exception("Invalid options. Pooled instances must be asynchronous"); return true; } protected virtual async Task OnInstanceReturn(IntegrationInstance instance) { await instance.WaitPost(() => { var config = instance.InstanceDependencyCollection.Resolve(); var overrides = new[] { (RTCVars.FailureLogLevel.Name, (instance.Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString()) }; config.OverrideConVars(overrides); }); } protected virtual Task OnClientReturn(ClientIntegrationInstance client) { return OnInstanceReturn(client); } protected virtual Task OnServerReturn(ServerIntegrationInstance server) { return OnInstanceReturn(server); } [OneTimeTearDown] public async Task OneTimeTearDown() { foreach (var client in _clientsRunning.Keys) { await ReturnToPool(client); } _clientsRunning.Clear(); foreach (var server in _serversRunning.Keys) { await ReturnToPool(server); } _serversRunning.Clear(); _notPooledInstances.ForEach(p => p.Stop()); await Task.WhenAll(_notPooledInstances.Select(p => p.WaitIdleAsync())); _notPooledInstances.Clear(); } public async Task ReturnToPool(ClientIntegrationInstance client) { if (!_clientsRunning.Remove(client, out _)) return; var res = await ReturnToPoolInternal(client); if (res) ClientsReady.Enqueue(client); } public async Task ReturnToPool(ServerIntegrationInstance server) { if (!_serversRunning.Remove(server, out _)) return; var res = await ReturnToPoolInternal(server); if (res) ServersReady.Enqueue(server); } public async Task ReturnToPoolInternal(IntegrationInstance instance) { await instance.WaitIdleAsync(); if (instance.UnhandledException != null || !instance.IsAlive) return false; var netMan = instance.ResolveDependency(); Assert.That(netMan.IsConnected, Is.False); // TODO Validate cvars and whatnot // Or just move content's PoolManager & TestPair over to engine. await instance.WaitPost(() => instance.EntMan.FlushEntities()); await instance.WaitIdleAsync(); return instance.UnhandledException == null && instance.IsAlive; } /// /// Provides control over a running instance of the client or server. /// /// /// The instance executes in another thread. /// As such, sending commands to it purely queues them to be ran asynchronously. /// To ensure that the instance is idle, i.e. not executing code and finished all queued commands, /// you can use . /// This method must be used before trying to access any state like , /// to prevent race conditions. /// public abstract class IntegrationInstance : IIntegrationInstance { private protected Thread? InstanceThread; private protected IDependencyCollection DependencyCollection = default!; private protected IntegrationGameLoop GameLoop = default!; private protected readonly ChannelReader _toInstanceReader; private protected readonly ChannelWriter _toInstanceWriter; private protected readonly ChannelReader _fromInstanceReader; private protected readonly ChannelWriter _fromInstanceWriter; private protected TextWriter _testOut; private int _currentTicksId = 1; private int _ackTicksId; private bool _isSurelyIdle; private bool _isAlive = true; private Exception? _unhandledException; public IDependencyCollection InstanceDependencyCollection => DependencyCollection; public virtual IntegrationOptions? Options { get; internal set; } public EntityManager EntMan { get; private set; } = default!; public IPrototypeManager ProtoMan { get; private set; } = default!; public IConfigurationManager CfgMan { get; private set; } = default!; public ISharedPlayerManager PlayerMan { get; private set; } = default!; public INetManager NetMan { get; private set; } = default!; public IGameTiming Timing { get; private set; } = default!; public IConsoleHost ConsoleHost { get; private set; } = default!; public ISawmill Log { get; private set; } = default!; protected virtual void ResolveIoC(IDependencyCollection deps) { EntMan = deps.Resolve(); ProtoMan = deps.Resolve(); CfgMan = deps.Resolve(); PlayerMan = deps.Resolve(); Timing = deps.Resolve(); NetMan = deps.Resolve(); ConsoleHost = deps.Resolve(); Log = deps.Resolve().GetSawmill("test"); } [Pure] public T System() where T : IEntitySystem { CheckThreadOrIdle(); return EntMan.System(); } [Pure] public T Resolve() => ResolveDependency(); public TransformComponent Transform(EntityUid uid) { CheckThreadOrIdle(); return EntMan.GetComponent(uid); } public MetaDataComponent MetaData(EntityUid uid) { CheckThreadOrIdle(); return EntMan.GetComponent(uid); } public MetaDataComponent MetaData(NetEntity uid) => MetaData(EntMan.GetEntity(uid)); public TransformComponent Transform(NetEntity uid) => Transform(EntMan.GetEntity(uid)); public async Task ExecuteCommand(string cmd) { await WaitPost(() => ConsoleHost.ExecuteCommand(cmd)); } /// public bool IsAlive { get { CheckThreadOrIdle(); return _isAlive; } } /// /// If the server /// /// /// Thrown if you did not ensure that the instance is idle via first. /// public Exception? UnhandledException { get { CheckThreadOrIdle(); return _unhandledException; } } public List TestsRan { get; } = new(); private protected IntegrationInstance(IntegrationOptions? options) { Options = options; _testOut = TestContext.Out; var toInstance = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); _toInstanceReader = toInstance.Reader; _toInstanceWriter = toInstance.Writer; var fromInstance = Channel.CreateUnbounded(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true }); _fromInstanceReader = fromInstance.Reader; _fromInstanceWriter = fromInstance.Writer; } /// /// Resolve a dependency inside the instance. /// This works identical to . /// /// /// Thrown if you did not ensure that the instance is idle via first. /// [Pure] public T ResolveDependency() { CheckThreadOrIdle(); return DependencyCollection.Resolve(); } /// public Task WaitIdleAsync(bool throwOnUnhandled = true, CancellationToken cancellationToken = default) { if (Options?.Asynchronous != false) { return WaitIdleImplAsync(throwOnUnhandled, cancellationToken); } WaitIdleImplSync(throwOnUnhandled); return Task.CompletedTask; } private async Task WaitIdleImplAsync(bool throwOnUnhandled, CancellationToken cancellationToken) { while (_isAlive && _currentTicksId != _ackTicksId) { object msg = default!; try { msg = await _fromInstanceReader.ReadAsync(cancellationToken); } catch(OperationCanceledException ex) { _unhandledException = ex; _isAlive = false; break; } switch (msg) { case ShutDownMessage shutDownMessage: { _isAlive = false; _isSurelyIdle = true; _unhandledException = shutDownMessage.UnhandledException; if (throwOnUnhandled && _unhandledException != null) { ExceptionDispatchInfo.Capture(_unhandledException).Throw(); return; } break; } case AckTicksMessage ack: { _ackTicksId = ack.MessageId; break; } case AssertFailMessage assertFailMessage: { // Rethrow exception without losing stack trace. ExceptionDispatchInfo.Capture(assertFailMessage.Exception).Throw(); break; // Unreachable. } } } _isSurelyIdle = true; } private void WaitIdleImplSync(bool throwOnUnhandled) { var oldSyncContext = SynchronizationContext.Current; try { // Set up thread-local resources for instance switch. { var taskMgr = DependencyCollection.Resolve(); IoCManager.InitThread(DependencyCollection, replaceExisting: true); taskMgr.ResetSynchronizationContext(); } GameLoop.SingleThreadRunUntilEmpty(); _isSurelyIdle = true; while (_fromInstanceReader.TryRead(out var msg)) { switch (msg) { case ShutDownMessage shutDownMessage: { _isAlive = false; _unhandledException = shutDownMessage.UnhandledException; if (throwOnUnhandled && _unhandledException != null) { ExceptionDispatchInfo.Capture(_unhandledException).Throw(); return; } break; } case AssertFailMessage assertFailMessage: { // Rethrow exception without losing stack trace. ExceptionDispatchInfo.Capture(assertFailMessage.Exception).Throw(); break; // Unreachable. } } } _isSurelyIdle = true; } finally { // NUnit has its own synchronization context so let's *not* break everything. SynchronizationContext.SetSynchronizationContext(oldSyncContext); } } /// public void RunTicks(int ticks) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new RunTicksMessage(ticks, _currentTicksId)); } /// public async Task WaitRunTicks(int ticks) { RunTicks(ticks); await WaitIdleAsync(); } /// /// Queue for the server to be stopped. /// public void Stop() { _isSurelyIdle = false; // Won't get ack'd directly but the shutdown is convincing enough. _currentTicksId += 1; _toInstanceWriter.TryWrite(new StopMessage()); _toInstanceWriter.TryComplete(); } /// public void Post(Action post) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new PostMessage(post, _currentTicksId)); } public async Task WaitPost(Action post) { Post(post); await WaitIdleAsync(); } /// public void Assert(Action assertion) { _isSurelyIdle = false; _currentTicksId += 1; _toInstanceWriter.TryWrite(new AssertMessage(assertion, _currentTicksId)); } public async Task WaitAssertion(Action assertion) { Assert(assertion); await WaitIdleAsync(); } internal void MarkNonIdle() { Post(() => {}); } public virtual Task Cleanup() => Task.CompletedTask; public void Dispose() { Stop(); } protected void LoadExtraPrototypes(IDependencyCollection deps, IntegrationOptions options) { var resMan = deps.Resolve(); if (options.ExtraPrototypes != null) { resMan.MountString("/Prototypes/__integration_extra.yml", options.ExtraPrototypes); } if (options.ExtraPrototypeList is {} list) { for (var i = 0; i < list.Count; i++) { resMan.MountString($"/Prototypes/__integration_extra_{i}.yml", list[i]); } } } private void CheckThreadOrIdle() { if (_isSurelyIdle) return; if (Thread.CurrentThread == InstanceThread) return; throw new InvalidOperationException( "Cannot perform this operation without ensuring the instance is idle."); } } public sealed class ServerIntegrationInstance : IntegrationInstance, IServerIntegrationInstance { public ServerIntegrationInstance(ServerIntegrationOptions? options) : base(options) { ServerOptions = options; DependencyCollection = new DependencyCollection(); if (options?.Asynchronous != false) { InstanceThread = new Thread(_serverMain) { Name = "Server Instance Thread", IsBackground = true }; InstanceThread.Start(); } else { Init(); } } public override IntegrationOptions? Options { get => ServerOptions; internal set => ServerOptions = (ServerIntegrationOptions?) value; } public ServerIntegrationOptions? ServerOptions { get; internal set; } public ServerIntegrationOptions? PreviousOptions { get; internal set; } private void _serverMain() { try { var server = Init(); GameLoop.Run(); server.FinishMainLoop(); IoCManager.Clear(); } catch (Exception e) { _fromInstanceWriter.TryWrite(new ShutDownMessage(e)); return; } _fromInstanceWriter.TryWrite(new ShutDownMessage(null)); } private BaseServer Init() { var deps = DependencyCollection; IoCManager.InitThread(deps, replaceExisting: true); ServerIoC.RegisterIoC(deps); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.RegisterInstance(new Mock().Object, true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); Options?.InitIoC?.Invoke(); deps.BuildGraph(); //ServerProgram.SetupLogging(); ServerProgram.InitReflectionManager(deps); if (Options?.LoadTestAssembly != false && Options?.TestAssembly != null) deps.Resolve().LoadAssemblies(Options.TestAssembly); var server = DependencyCollection.Resolve(); var serverOptions = ServerOptions?.Options ?? new ServerOptions() { LoadConfigAndUserData = false, LoadContentResources = false, }; // Autoregister components if options are null or we're NOT starting from content, as in that case // components will get auto-registered later. But either way, we will still invoke // BeforeRegisterComponents here. Options?.BeforeRegisterComponents?.Invoke(); if (!Options?.ContentStart ?? true) { var componentFactory = deps.Resolve(); componentFactory.DoAutoRegistrations(); componentFactory.GenerateNetIds(); } if (Options?.ContentAssemblies != null) { deps.Resolve().Assemblies = Options.ContentAssemblies; } var cfg = deps.Resolve(); cfg.LoadCVarsFromAssembly(typeof(RobustIntegrationTest).Assembly); cfg.LoadCVarsFromAssembly(typeof(RTCVars).Assembly); if (Options != null) { Options.BeforeStart?.Invoke(); cfg.OverrideConVars(Options.CVarOverrides.Select(p => (p.Key, p.Value))); LoadExtraPrototypes(deps, Options); } cfg.OverrideConVars(new[] { ("log.runtimelog", "false"), (CVars.SysWinTickPeriod.Name, "-1"), (CVars.SysGCCollectStart.Name, "false"), (RTCVars.FailureLogLevel.Name, (Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString()), (CVars.ResCheckBadFileExtensions.Name, "false") }); cfg.SetVirtualConfig(); server.ContentStart = Options?.ContentStart ?? false; var logHandler = Options?.OverrideLogHandler ?? (() => new TestLogHandler(cfg, "SERVER", _testOut)); if (server.Start(serverOptions, logHandler)) { throw new Exception("Server failed to start."); } GameLoop = new IntegrationGameLoop( DependencyCollection.Resolve(), _fromInstanceWriter, _toInstanceReader); server.OverrideMainLoop(GameLoop); server.SetupMainLoop(); GameLoop.RunInit(); ResolveIoC(deps); return server; } /// /// Force a PVS update. This is mainly here to expose internal PVS methods to content benchmarks. /// public void PvsTick(ICommonSession[] players) { var pvs = EntMan.System(); pvs.SendGameStates(players); Timing.CurTick += 1; } /// /// Adds multiple dummy players to the server. /// public async Task AddDummySessions(int count) { var sessions = new ICommonSession[count]; for (var i = 0; i < sessions.Length; i++) { sessions[i] = await AddDummySession(); } return sessions; } /// /// Adds a dummy player to the server. /// public async Task AddDummySession(string? userName = null) { userName ??= $"integration_dummy_{DummyUsers.Count}"; Log.Info($"Adding dummy session {userName}"); if (!_dummyUsers.TryGetValue(userName, out var userId)) _dummyUsers[userName] = userId = new(Guid.NewGuid()); var man = (Robust.Server.Player.PlayerManager) PlayerMan; var session = man.AddDummySession(userId, userName); _dummySessions.Add(userId, session); session.ConnectedTime = DateTime.UtcNow; await WaitPost(() => { var userData = new NetUserData(userId, userName) { HWId = ImmutableArray.Empty, ModernHWIds = [] }; (NetMan as IntegrationNetManager)?.OnConnecting( new IPEndPoint(IPAddress.Loopback, 1212), userData, LoginType.GuestAssigned ); man.SetStatus(session, SessionStatus.Connected); }); return session; } /// /// Removes a dummy player from the server. /// public async Task RemoveDummySession(ICommonSession session, bool removeUser = false) { Log.Info($"Removing dummy session {session.Name}"); _dummySessions.Remove(session.UserId); var man = (Robust.Server.Player.PlayerManager) PlayerMan; await WaitPost(() => man.EndSession(session.UserId)); if (removeUser) _dummyUsers.Remove(session.Name); } /// /// Removes all dummy players from the server. /// public async Task RemoveAllDummySessions() { foreach (var session in _dummySessions.Values) { await RemoveDummySession(session); } } public override Task Cleanup() => RemoveAllDummySessions(); private Dictionary _dummyUsers = new(); private Dictionary _dummySessions = new(); public IReadOnlyDictionary DummyUsers => _dummyUsers; public IReadOnlyDictionary DummySessions => _dummySessions; } public sealed class ClientIntegrationInstance : IntegrationInstance, IClientIntegrationInstance { public ICommonSession? Session => PlayerMan.LocalSession; public NetUserId? User => Session?.UserId; public EntityUid? AttachedEntity => Session?.AttachedEntity; public ClientIntegrationInstance(ClientIntegrationOptions? options) : base(options) { ClientOptions = options; DependencyCollection = new DependencyCollection(); if (options?.Asynchronous != false) { InstanceThread = new Thread(ThreadMain) { Name = "Client Instance Thread", IsBackground = true }; InstanceThread.Start(); } else { Init(); } } public override IntegrationOptions? Options { get => ClientOptions; internal set => ClientOptions = (ClientIntegrationOptions?) value; } public ClientIntegrationOptions? ClientOptions { get; internal set; } public ClientIntegrationOptions? PreviousOptions { get; internal set; } /// /// Wire up the server to connect to when gets called. /// public void SetConnectTarget(IServerIntegrationInstance server) { var clientNetManager = ResolveDependency(); var serverNetManager = server.Resolve(); if (!serverNetManager.IsRunning) { throw new InvalidOperationException("Server net manager is not running!"); } clientNetManager.NextConnectChannel = serverNetManager.MessageChannelWriter; } public async Task Connect(IServerIntegrationInstance target) { await WaitIdleAsync(); await target.WaitIdleAsync(); SetConnectTarget(target); await WaitPost(() => ((IClientNetManager) NetMan).ClientConnect(null!, 0, null!)); } public async Task CheckSandboxed(Assembly assembly) { await WaitIdleAsync(); await WaitAssertion(() => { var modLoader = new ModLoader(); IoCManager.InjectDependencies(modLoader); var cast = (IPostInjectInit) modLoader; cast.PostInject(); modLoader.SetEnableSandboxing(true); modLoader.LoadGameAssembly(assembly.Location); }); } private void ThreadMain() { try { var client = Init(); GameLoop.Run(); client.CleanupGameThread(); client.CleanupWindowThread(); } catch (Exception e) { _fromInstanceWriter.TryWrite(new ShutDownMessage(e)); return; } _fromInstanceWriter.TryWrite(new ShutDownMessage(null)); } private GameController Init() { var deps = DependencyCollection; IoCManager.InitThread(deps, replaceExisting: true); ClientIoC.RegisterIoC(GameController.DisplayMode.Headless, deps); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); deps.Register(true); Options?.InitIoC?.Invoke(); deps.BuildGraph(); GameController.RegisterReflection(deps); if (Options?.LoadTestAssembly != false && Options?.TestAssembly != null) deps.Resolve().LoadAssemblies(Options.TestAssembly); var client = DependencyCollection.Resolve(); var clientOptions = ClientOptions?.Options ?? new GameControllerOptions() { LoadContentResources = false, LoadConfigAndUserData = false, }; // Autoregister components if options are null or we're NOT starting from content, as in that case // components will get auto-registered later. But either way, we will still invoke // BeforeRegisterComponents here. Options?.BeforeRegisterComponents?.Invoke(); if (!Options?.ContentStart ?? true) { var componentFactory = deps.Resolve(); componentFactory.DoAutoRegistrations(); componentFactory.GenerateNetIds(); } if (Options?.ContentAssemblies != null) { deps.Resolve().Assemblies = Options.ContentAssemblies; } var cfg = deps.Resolve(); cfg.LoadCVarsFromAssembly(typeof(RobustIntegrationTest).Assembly); cfg.LoadCVarsFromAssembly(typeof(RTCVars).Assembly); if (Options != null) { Options.BeforeStart?.Invoke(); cfg.OverrideConVars(Options.CVarOverrides.Select(p => (p.Key, p.Value))); LoadExtraPrototypes(deps, Options); } cfg.OverrideConVars(new[] { (CVars.NetPredictLagBias.Name, "0"), // Connecting to Discord is a massive waste of time. // Basically just makes the CI logs a mess. (CVars.DiscordEnabled.Name, "false"), // Avoid preloading textures. (CVars.ResTexturePreloadingEnabled.Name, "false"), (CVars.SysGCCollectStart.Name, "false"), (RTCVars.FailureLogLevel.Name, (Options?.FailureLogLevel ?? RTCVars.FailureLogLevel.DefaultValue).ToString()), (CVars.ResPrototypeReloadWatch.Name, "false"), (CVars.ResCheckBadFileExtensions.Name, "false") }); cfg.SetVirtualConfig(); GameLoop = new IntegrationGameLoop(DependencyCollection.Resolve(), _fromInstanceWriter, _toInstanceReader); client.OverrideMainLoop(GameLoop); client.ContentStart = Options?.ContentStart ?? false; client.StartupSystemSplash( clientOptions, Options?.OverrideLogHandler ?? (() => new TestLogHandler(cfg, "CLIENT", _testOut)), globalExceptionLog: false); client.StartupContinue(GameController.DisplayMode.Headless); GameLoop.RunInit(); ResolveIoC(deps); // Offset client generated Uids. // Not that we have client-server uid separation, there might be bugs where tests might accidentally // use server side uids on the client and vice versa. This can sometimes accidentally work if the // entities get created in the same order. For that reason we arbitrarily increment the queued Uid by // some arbitrary quantity. /* TODO: End my suffering and fix this because entmanager hasn't started up yet. for (var i = 0; i < 10; i++) { EntMan.SpawnEntity(null, MapCoordinates.Nullspace); } */ return client; } /// /// Directly pass a bound key event to a control. /// public async Task DoGuiEvent(Control control, GUIBoundKeyEventArgs args) { await WaitPost(() => { if (args.State == BoundKeyState.Down) control.KeyBindDown(args); else control.KeyBindUp(args); }); } } // Synchronization between the integration instance and the main loop is done purely through message passing. // The main thread sends commands like "run n ticks" and the main loop reports back the commands it has finished. // It also reports when it dies, of course. internal sealed class IntegrationGameLoop : IGameLoop { private readonly IGameTiming _gameTiming; private readonly ChannelWriter _channelWriter; private readonly ChannelReader _channelReader; #pragma warning disable 67 public event EventHandler? Input; public event EventHandler? Tick; public event EventHandler? Update; public event EventHandler? Render; #pragma warning restore 67 public bool SingleStep { get; set; } public bool Running { get; set; } public int MaxQueuedTicks { get; set; } public TimeSpan LimitMinFrameTime { get; set; } public SleepMode SleepMode { get; set; } public IntegrationGameLoop(IGameTiming gameTiming, ChannelWriter channelWriter, ChannelReader channelReader) { _gameTiming = gameTiming; _channelWriter = channelWriter; _channelReader = channelReader; } public void Run() { // Main run method is only used when running from asynchronous thread. // Ack tick message 1 is implied as "init done" _channelWriter.TryWrite(new AckTicksMessage(1)); while (Running) { var readerNotDone = _channelReader.WaitToReadAsync().AsTask().GetAwaiter().GetResult(); if (!readerNotDone) { Running = false; return; } SingleThreadRunUntilEmpty(); } } public void RunInit() { Running = true; _gameTiming.InSimulation = true; } public void SingleThreadRunUntilEmpty() { while (Running && _channelReader.TryRead(out var message)) { switch (message) { case RunTicksMessage msg: _gameTiming.InSimulation = true; var simFrameEvent = new FrameEventArgs((float) _gameTiming.TickPeriod.TotalSeconds); for (var i = 0; i < msg.Ticks && Running; i++) { Input?.Invoke(this, simFrameEvent); Tick?.Invoke(this, simFrameEvent); _gameTiming.CurTick = new GameTick(_gameTiming.CurTick.Value + 1); Update?.Invoke(this, simFrameEvent); } _channelWriter.TryWrite(new AckTicksMessage(msg.MessageId)); break; case StopMessage _: Running = false; break; case PostMessage postMessage: postMessage.Post(); _channelWriter.TryWrite(new AckTicksMessage(postMessage.MessageId)); break; case AssertMessage assertMessage: try { assertMessage.Assertion(); } catch (Exception e) { _channelWriter.TryWrite(new AssertFailMessage(e)); } _channelWriter.TryWrite(new AckTicksMessage(assertMessage.MessageId)); break; } } } } [Virtual] public class ServerIntegrationOptions : IntegrationOptions { public virtual ServerOptions Options { get; set; } = new() { LoadConfigAndUserData = false, LoadContentResources = false, }; } [Virtual] public class ClientIntegrationOptions : IntegrationOptions { public virtual GameControllerOptions Options { get; set; } = new() { LoadContentResources = false, LoadConfigAndUserData = false, }; } public abstract class IntegrationOptions { public Action? InitIoC { get; set; } public Action? BeforeRegisterComponents { get; set; } public Action? BeforeStart { get; set; } public Assembly[]? ContentAssemblies { get; set; } public bool LoadTestAssembly { get; set; } = true; public Assembly? TestAssembly { get; set; } /// /// String containing extra prototypes to load. Contents of the string are treated like a yaml file in the /// resources folder. /// public string? ExtraPrototypes { get; set; } /// /// List of strings containing extra prototypes to load. Contents of the strings are treated like yaml files /// in the resources folder. /// public List? ExtraPrototypeList; public LogLevel? FailureLogLevel { get; set; } = RTCVars.FailureLogLevel.DefaultValue; public bool ContentStart { get; set; } = false; public Dictionary CVarOverrides { get; } = new(); public bool Asynchronous { get; set; } = true; public bool? Pool { get; set; } public Func? OverrideLogHandler { get; set; } } /// /// Sent head -> instance to tell the instance to run a few simulation ticks. /// private sealed class RunTicksMessage { public RunTicksMessage(int ticks, int messageId) { Ticks = ticks; MessageId = messageId; } public int Ticks { get; } public int MessageId { get; } } /// /// Sent head -> instance to tell the instance to shut down cleanly. /// private sealed class StopMessage { } /// /// Sent instance -> head to confirm finishing of ticks message. /// private sealed class AckTicksMessage { public AckTicksMessage(int messageId) { MessageId = messageId; } public int MessageId { get; } } private sealed class AssertFailMessage { public Exception Exception { get; } public AssertFailMessage(Exception exception) { Exception = exception; } } /// /// Sent instance -> head when instance shuts down for whatever reason. /// private sealed class ShutDownMessage { public ShutDownMessage(Exception? unhandledException) { UnhandledException = unhandledException; } public Exception? UnhandledException { get; } } private sealed class PostMessage { public Action Post { get; } public int MessageId { get; } public PostMessage(Action post, int messageId) { Post = post; MessageId = messageId; } } private sealed class AssertMessage { public Action Assertion { get; } public int MessageId { get; } public AssertMessage(Action assertion, int messageId) { Assertion = assertion; MessageId = messageId; } } } }