From 8117621ee7f186080853100327b50098bbd55916 Mon Sep 17 00:00:00 2001 From: Measurity Date: Mon, 2 Oct 2023 14:49:50 +0200 Subject: [PATCH] Added capture of server console input for hotkeys --- .../DataStructures/CircularBufferTest.cs | 111 ++++++++ NitroxModel/DataStructures/CircularBuffer.cs | 110 ++++++++ NitroxServer-Subnautica/Program.cs | 262 +++++++++++++----- 3 files changed, 421 insertions(+), 62 deletions(-) create mode 100644 Nitrox.Test/Model/DataStructures/CircularBufferTest.cs create mode 100644 NitroxModel/DataStructures/CircularBuffer.cs diff --git a/Nitrox.Test/Model/DataStructures/CircularBufferTest.cs b/Nitrox.Test/Model/DataStructures/CircularBufferTest.cs new file mode 100644 index 000000000..ec878957f --- /dev/null +++ b/Nitrox.Test/Model/DataStructures/CircularBufferTest.cs @@ -0,0 +1,111 @@ +using FluentAssertions; +using Microsoft.VisualStudio.TestTools.UnitTesting; + +namespace NitroxModel.DataStructures; + +[TestClass] +public class CircularBufferTest +{ + [TestMethod] + public void ShouldLimitSizeToMaxSize() + { + CircularBuffer buffer = new(1); + buffer.Count.Should().Be(0); + buffer.Add("1"); + buffer.Count.Should().Be(1); + buffer.Add("2"); + buffer.Count.Should().Be(1); + + buffer = new CircularBuffer(5); + buffer.Count.Should().Be(0); + buffer.Add("1"); + buffer.Count.Should().Be(1); + buffer.Add("2"); + buffer.Count.Should().Be(2); + buffer.Add("3"); + buffer.Count.Should().Be(3); + buffer.Add("4"); + buffer.Count.Should().Be(4); + buffer.Add("5"); + buffer.Count.Should().Be(5); + buffer.Add("6"); + buffer.Count.Should().Be(5); + } + + [TestMethod] + public void ShouldOverwriteOldestItemInBufferWhenCapped() + { + CircularBuffer buffer = new(3); + buffer.Add("1"); + buffer[0].Should().Be("1"); + buffer.Add("2"); + buffer[1].Should().Be("2"); + buffer.Add("3"); + buffer[2].Should().Be("3"); + buffer.Add("4"); + buffer[0].Should().Be("4"); + buffer.Add("5"); + buffer[1].Should().Be("5"); + buffer[2].Should().Be("3"); + buffer.Add("6"); + buffer[2].Should().Be("6"); + } + + [TestMethod] + public void ShouldDiscardAddIfCapacityReached() + { + CircularBuffer buffer = new(0); + buffer.Count.Should().Be(0); + buffer.Add("1"); + buffer.Count.Should().Be(0); + } + + [TestMethod] + public void ShouldBeEmptyWhenCleared() + { + CircularBuffer buffer = new(10); + buffer.Count.Should().Be(0); + buffer.Add("1"); + buffer.Add("1"); + buffer.Add("1"); + buffer.Count.Should().Be(3); + buffer.Clear(); + buffer.Count.Should().Be(0); + } + + [TestMethod] + public void ShouldGiveLastChanged() + { + CircularBuffer buffer = new(3); + buffer.LastChangedIndex.Should().Be(-1); + buffer.Add(1); + buffer.LastChangedIndex.Should().Be(0); + buffer.Add(2); + buffer.LastChangedIndex.Should().Be(1); + buffer.Add(3); + buffer.LastChangedIndex.Should().Be(2); + buffer.Add(4); + buffer.LastChangedIndex.Should().Be(0); + buffer.Add(5); + buffer.LastChangedIndex.Should().Be(1); + buffer.Add(6); + buffer.LastChangedIndex.Should().Be(2); + buffer.Add(7); + buffer.Should().ContainInOrder(7, 5, 6); + } + + [TestMethod] + public void ShouldReverseOrderWithNegativeIndex() + { + CircularBuffer buffer = new(6); + buffer.AddRange(1, 2, 3, 4, 5, 6); + buffer[-1].Should().Be(6); + buffer[-2].Should().Be(5); + buffer[-3].Should().Be(4); + buffer[-4].Should().Be(3); + buffer[-5].Should().Be(2); + buffer[-6].Should().Be(1); + buffer[-7].Should().Be(6); + buffer[-8].Should().Be(5); + } +} diff --git a/NitroxModel/DataStructures/CircularBuffer.cs b/NitroxModel/DataStructures/CircularBuffer.cs new file mode 100644 index 000000000..3f2438391 --- /dev/null +++ b/NitroxModel/DataStructures/CircularBuffer.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections; +using System.Collections.Generic; + +namespace NitroxModel.DataStructures; + +/// +/// Given a fixed size, fills to capacity and then overwrites earliest item. +/// +public class CircularBuffer : IList +{ + private readonly List data; + private readonly int maxSize; + + /// + /// Returns the index last changed. If is empty, returns -1. + /// + public int LastChangedIndex { get; protected set; } = -1; + + /// + /// Gets the item at the index, wrapping around the buffer if out-of-range. + /// + public T this[int index] + { + // Proper modulus operator which C# doesn't have. % = remainder operator and doesn't work in reverse for negative numbers. + get => data[Math.Abs((index % data.Count + data.Count) % data.Count)]; + set => throw new NotSupportedException(); + } + + public int Count => data.Count; + public bool IsReadOnly => false; + + public CircularBuffer(int maxSize, int initialCapacity = 0) + { + if (maxSize < 0) throw new ArgumentOutOfRangeException(nameof(maxSize), "Max size must be larger than -1"); + + this.maxSize = maxSize; + data = new List(Math.Max(0, Math.Min(initialCapacity, maxSize))); + } + + public int IndexOf(T item) + { + return data.IndexOf(item); + } + + public void Insert(int index, T item) + { + throw new NotImplementedException(); + } + + public void RemoveAt(int index) + { + data.RemoveAt(index); + } + + public bool Remove(T item) + { + return data.Remove(item); + } + + public void Add(T item) + { + if (maxSize == 0) return; + if (data.Count < maxSize) + { + data.Add(item); + LastChangedIndex++; + return; + } + + LastChangedIndex = (LastChangedIndex + 1) % maxSize; + data[LastChangedIndex] = item; + } + + public void AddRange(IEnumerable items) + { + foreach (T item in items) Add(item); + } + + public void AddRange(params T[] items) + { + foreach (T item in items) Add(item); + } + + public void Clear() + { + data.Clear(); + LastChangedIndex = 0; + } + + public bool Contains(T item) + { + return data.Contains(item); + } + + public void CopyTo(T[] array, int arrayIndex) + { + data.CopyTo(array, arrayIndex); + } + + public IEnumerator GetEnumerator() + { + return data.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/NitroxServer-Subnautica/Program.cs b/NitroxServer-Subnautica/Program.cs index 9e9e64816..b85206be0 100644 --- a/NitroxServer-Subnautica/Program.cs +++ b/NitroxServer-Subnautica/Program.cs @@ -9,10 +9,11 @@ using System.Net.NetworkInformation; using System.Reflection; using System.Runtime.CompilerServices; -using System.Runtime.InteropServices; +using System.Text; using System.Threading; using System.Threading.Tasks; using NitroxModel.Core; +using NitroxModel.DataStructures; using NitroxModel.DataStructures.GameLogic; using NitroxModel.DataStructures.Util; using NitroxModel.Helper; @@ -26,9 +27,8 @@ public class Program { private static readonly Dictionary resolvedAssemblyCache = new(); private static Lazy gameInstallDir; - - // Prevents Garbage Collection freeing this callback's memory. Causing an exception to occur for this handle. - private static readonly ConsoleEventDelegate consoleCtrlCheckDelegate = ConsoleEventCallback; + private static readonly CircularBuffer inputHistory = new(1000); + private static int currentHistoryIndex; private static async Task Main(string[] args) { @@ -48,18 +48,42 @@ private static async Task Main(string[] args) [MethodImpl(MethodImplOptions.NoInlining)] private static async Task StartServer(string[] args) { + Action ConsoleCommandHandler() + { + ConsoleCommandProcessor commandProcessor = null; + return submit => + { + try + { + commandProcessor ??= NitroxServiceLocator.LocateService(); + } + catch (Exception) + { + // ignored + } + commandProcessor?.ProcessCommand(submit, Optional.Empty, Perms.CONSOLE); + }; + } + // The thread that writers to console is paused while selecting text in console. So console writer needs to be async. Log.Setup(true, isConsoleApp: true); AppDomain.CurrentDomain.UnhandledException += CurrentDomainOnUnhandledException; ConfigureCultureInfo(); + if (!Console.IsInputRedirected) + { + Console.TreatControlCAsInput = true; + } Log.Info($"Starting NitroxServer {NitroxEnvironment.ReleasePhase} v{NitroxEnvironment.Version} for Subnautica"); - AppMutex.Hold(() => { Log.Info("Waiting on other Nitrox servers to initialize before starting.."); }, 120000); Server server; - Task listenForCommands; + Task handleConsoleInputTask; + CancellationTokenSource cancellationToken = new(); try { + handleConsoleInputTask = HandleConsoleInputAsync(ConsoleCommandHandler(), cancellationToken); + AppMutex.Hold(() => Log.Info("Waiting on other Nitrox servers to initialize before starting.."), 120000); + Stopwatch watch = Stopwatch.StartNew(); // Allow game path to be given as command argument @@ -85,10 +109,7 @@ private static async Task StartServer(string[] args) server = NitroxServiceLocator.LocateService(); await WaitForAvailablePortAsync(server.Port); - CatchExitEvent(); - listenForCommands = ListenForCommandsAsync(server); - CancellationTokenSource cancellationToken = new(); if (!server.Start(cancellationToken) && !cancellationToken.IsCancellationRequested) { throw new Exception("Unable to start server."); @@ -110,20 +131,183 @@ private static async Task StartServer(string[] args) AppMutex.Release(); } - await listenForCommands; + await handleConsoleInputTask; + + Console.WriteLine($"{Environment.NewLine}Server is closing.."); } - private static async Task ListenForCommandsAsync(Server server) + /// + /// Handles per-key input of the console and passes input submit to . + /// + private static async Task HandleConsoleInputAsync(Action submitHandler, CancellationTokenSource cancellation = default) { - while (!server.IsRunning) + if (Console.IsInputRedirected) + { + while (!cancellation?.IsCancellationRequested ?? false) + { + submitHandler(await Task.Run(Console.ReadLine)); + } + return; + } + + StringBuilder inputLineBuilder = new(); + + void ClearInputLine() { - await Task.Delay(100); + currentHistoryIndex = 0; + inputLineBuilder.Clear(); + Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); } - ConsoleCommandProcessor cmdProcessor = NitroxServiceLocator.LocateService(); - while (server.IsRunning) + void RedrawInput(int start = 0, int end = 0) { - cmdProcessor.ProcessCommand(Console.ReadLine(), Optional.Empty, Perms.CONSOLE); + int lastPosition = Console.CursorLeft; + // Expand range to end if end value is -1 + if (start > -1 && end == -1) + { + end = Math.Max(inputLineBuilder.Length - start, 0); + } + + if (start == 0 && end == 0) + { + // Redraw entire line + Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r{inputLineBuilder}"); + } + else + { + // Redraw part of line + string changedInputSegment = inputLineBuilder.ToString(start, end); + Console.CursorVisible = false; + Console.Write($"{changedInputSegment}{new string(' ', inputLineBuilder.Length - changedInputSegment.Length - Console.CursorLeft + 1)}"); + Console.CursorVisible = true; + } + Console.CursorLeft = lastPosition; + } + + while (!cancellation?.IsCancellationRequested ?? false) + { + if (!Console.KeyAvailable) + { + await Task.Delay(10, cancellation.Token); + continue; + } + + ConsoleKeyInfo keyInfo = Console.ReadKey(true); + // Handle (ctrl) hotkeys + if ((keyInfo.Modifiers & ConsoleModifiers.Control) != 0) + { + switch (keyInfo.Key) + { + case ConsoleKey.C: + if (inputLineBuilder.Length > 0) + { + ClearInputLine(); + continue; + } + + cancellation.Cancel(); + return; + case ConsoleKey.D: + cancellation.Cancel(); + return; + default: + // Unhandled modifier key + continue; + } + } + + if (keyInfo.Modifiers == 0) + { + switch (keyInfo.Key) + { + case ConsoleKey.LeftArrow when Console.CursorLeft > 0: + Console.CursorLeft--; + continue; + case ConsoleKey.RightArrow when Console.CursorLeft < inputLineBuilder.Length: + Console.CursorLeft++; + continue; + case ConsoleKey.Backspace: + if (inputLineBuilder.Length > Console.CursorLeft - 1 && Console.CursorLeft > 0) + { + inputLineBuilder.Remove(Console.CursorLeft - 1, 1); + Console.CursorLeft--; + Console.Write(' '); + Console.CursorLeft--; + RedrawInput(); + } + continue; + case ConsoleKey.Delete: + if (inputLineBuilder.Length > 0 && Console.CursorLeft < inputLineBuilder.Length) + { + inputLineBuilder.Remove(Console.CursorLeft, 1); + RedrawInput(Console.CursorLeft, inputLineBuilder.Length - Console.CursorLeft); + } + continue; + case ConsoleKey.Home: + Console.CursorLeft = 0; + continue; + case ConsoleKey.End: + Console.CursorLeft = inputLineBuilder.Length; + continue; + case ConsoleKey.Escape: + ClearInputLine(); + continue; + case ConsoleKey.Tab: + if (Console.CursorLeft + 4 < Console.WindowWidth) + { + inputLineBuilder.Insert(Console.CursorLeft, " "); + RedrawInput(Console.CursorLeft, -1); + Console.CursorLeft += 4; + } + continue; + case ConsoleKey.UpArrow when inputHistory.Count > 0 && currentHistoryIndex > -inputHistory.Count: + inputLineBuilder.Clear(); + inputLineBuilder.Append(inputHistory[--currentHistoryIndex]); + RedrawInput(); + Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); + continue; + case ConsoleKey.DownArrow when inputHistory.Count > 0 && currentHistoryIndex < 0: + if (currentHistoryIndex == -1) + { + ClearInputLine(); + continue; + } + inputLineBuilder.Clear(); + inputLineBuilder.Append(inputHistory[++currentHistoryIndex]); + RedrawInput(); + Console.CursorLeft = Math.Min(inputLineBuilder.Length, Console.WindowWidth); + continue; + } + } + // Handle input submit to submit handler + if (keyInfo.Key == ConsoleKey.Enter) + { + string submit = inputLineBuilder.ToString(); + if (inputHistory.Count == 0 || inputHistory[inputHistory.LastChangedIndex] != submit) + { + inputHistory.Add(submit); + } + currentHistoryIndex = 0; + submitHandler?.Invoke(submit); + inputLineBuilder.Clear(); + Console.WriteLine(); + continue; + } + + // If unhandled key, append as input. + if (keyInfo.KeyChar != 0) + { + Console.Write(keyInfo.KeyChar); + if (Console.CursorLeft - 1 < inputLineBuilder.Length) + { + inputLineBuilder.Insert(Console.CursorLeft - 1, keyInfo.KeyChar); + RedrawInput(Console.CursorLeft, -1); + } + else + { + inputLineBuilder.Append(keyInfo.KeyChar); + } + } } } @@ -252,50 +436,4 @@ private static void ConfigureCultureInfo() CultureInfo.DefaultThreadCurrentCulture = cultureInfo; CultureInfo.DefaultThreadCurrentUICulture = cultureInfo; } - - private static void CatchExitEvent() - { - // Catch Exit Event - PlatformID platid = Environment.OSVersion.Platform; - - // using *nix signal system to catch Ctrl+C - if (platid == PlatformID.Unix || platid == PlatformID.MacOSX || platid == PlatformID.Win32NT || (int)platid == 128) // mono = 128 - { - Console.CancelKeyPress += OnCtrlCPressed; - } - - // better catch using WinAPI. This will handled process kill - if (platid == PlatformID.Win32NT) - { - SetConsoleCtrlHandler(consoleCtrlCheckDelegate, true); - } - } - - [DllImport("kernel32.dll", SetLastError = true)] - private static extern bool SetConsoleCtrlHandler(ConsoleEventDelegate callback, bool add); - - private static bool ConsoleEventCallback(int eventType) - { - if (eventType >= 2) // close, logoff, or shutdown - { - StopServer(); - } - - return false; - } - - private static void OnCtrlCPressed(object sender, ConsoleCancelEventArgs e) - { - e.Cancel = true; // Prevents process from terminating - Console.Write($"\r{new string(' ', Console.WindowWidth - 1)}\r"); // Clears current line - } - - private static void StopServer() - { - Log.Info("Exiting ..."); - Server.Instance.Stop(); - } - - // See: https://docs.microsoft.com/en-us/windows/console/setconsolectrlhandler - private delegate bool ConsoleEventDelegate(int eventType); }