diff --git a/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs b/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs index 947b399..75b4b31 100644 --- a/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs +++ b/Agent/Phantom.Agent.Minecraft/Server/ServerStatusProtocol.cs @@ -3,13 +3,12 @@ using System.Buffers.Binary; using System.Net; using System.Net.Sockets; using System.Text; +using Phantom.Common.Data.Instance; namespace Phantom.Agent.Minecraft.Server; public static class ServerStatusProtocol { - public readonly record struct PlayerCounts(int Online, int Maximum); - - public static async Task<PlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) { + public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) { using var tcpClient = new TcpClient(); await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken); var tcpStream = tcpClient.GetStream(); @@ -42,7 +41,7 @@ public static class ServerStatusProtocol { } } - private static async Task<PlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { + private static async Task<InstancePlayerCounts> ReadPlayerCounts(NetworkStream tcpStream, int messageLength, CancellationToken cancellationToken) { var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength); try { await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken); @@ -57,7 +56,7 @@ public static class ServerStatusProtocol { /// </summary> private static readonly byte[] Separator = { 0x00, 0xA7 }; - private static PlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) { + private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) { int lastSeparator = messageBuffer.LastIndexOf(Separator); int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator); @@ -71,7 +70,7 @@ public static class ServerStatusProtocol { // Player counts are integers, whose maximum string length is 10 characters. Span<char> integerStringBuffer = stackalloc char[10]; - return new PlayerCounts( + return new InstancePlayerCounts( DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"), DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum") ); diff --git a/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs b/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs index e7289b6..22c1357 100644 --- a/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs +++ b/Agent/Phantom.Agent.Services/Instances/State/InstancePlayerCountTracker.cs @@ -1,5 +1,8 @@ using Phantom.Agent.Minecraft.Instance; using Phantom.Agent.Minecraft.Server; +using Phantom.Agent.Rpc; +using Phantom.Common.Data.Instance; +using Phantom.Common.Messages.Agent.ToController; using Phantom.Utils.Logging; using Phantom.Utils.Tasks; using Phantom.Utils.Threading; @@ -7,32 +10,35 @@ using Phantom.Utils.Threading; namespace Phantom.Agent.Services.Instances.State; sealed class InstancePlayerCountTracker : CancellableBackgroundTask { - private readonly InstanceProcess process; + private readonly ControllerConnection controllerConnection; + private readonly Guid instanceGuid; private readonly ushort serverPort; + private readonly InstanceProcess process; private readonly TaskCompletionSource firstDetection = AsyncTasks.CreateCompletionSource(); private readonly ManualResetEventSlim serverOutputEvent = new (); - private int? onlinePlayerCount; + private InstancePlayerCounts? playerCounts; - public int? OnlinePlayerCount { + public InstancePlayerCounts? PlayerCounts { get { lock (this) { - return onlinePlayerCount; + return playerCounts; } } private set { EventHandler<int?>? onlinePlayerCountChanged; lock (this) { - if (onlinePlayerCount == value) { + if (playerCounts == value) { return; } - onlinePlayerCount = value; + playerCounts = value; onlinePlayerCountChanged = OnlinePlayerCountChanged; } - onlinePlayerCountChanged?.Invoke(this, value); + onlinePlayerCountChanged?.Invoke(this, value?.Online); + controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value)); } } @@ -41,6 +47,8 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { private bool isDisposed = false; public InstancePlayerCountTracker(InstanceContext context, InstanceProcess process, ushort serverPort) : base(PhantomLogger.Create<InstancePlayerCountTracker>(context.ShortName)) { + this.controllerConnection = context.Services.ControllerConnection; + this.instanceGuid = context.InstanceGuid; this.process = process; this.serverPort = serverPort; Start(); @@ -56,7 +64,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { while (!CancellationToken.IsCancellationRequested) { serverOutputEvent.Reset(); - OnlinePlayerCount = await TryGetOnlinePlayerCount(); + PlayerCounts = await TryGetPlayerCounts(); if (!firstDetection.Task.IsCompleted) { firstDetection.SetResult(); @@ -68,11 +76,11 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { } } - private async Task<int?> TryGetOnlinePlayerCount() { + private async Task<InstancePlayerCounts?> TryGetPlayerCounts() { try { - var (online, maximum) = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken); - Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", online, maximum); - return online; + var result = await ServerStatusProtocol.GetPlayerCounts(serverPort, CancellationToken); + Logger.Debug("Detected {OnlinePlayerCount} / {MaximumPlayerCount} online player(s).", result.Online, result.Maximum); + return result; } catch (ServerStatusProtocol.ProtocolException e) { Logger.Error(e.Message); return null; @@ -88,12 +96,12 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { var onlinePlayersDetected = AsyncTasks.CreateCompletionSource(); lock (this) { - if (onlinePlayerCount == null) { - throw new InvalidOperationException(); - } - else if (onlinePlayerCount > 0) { + if (playerCounts is { Online: > 0 }) { return; } + else if (playerCounts == null) { + throw new InvalidOperationException(); + } OnlinePlayerCountChanged += OnOnlinePlayerCountChanged; @@ -123,7 +131,7 @@ sealed class InstancePlayerCountTracker : CancellableBackgroundTask { protected override void Dispose() { lock (this) { isDisposed = true; - onlinePlayerCount = null; + playerCounts = null; } process.RemoveOutputListener(OnOutput); diff --git a/Common/Phantom.Common.Data.Web/Instance/Instance.cs b/Common/Phantom.Common.Data.Web/Instance/Instance.cs index 908580d..9fd25fb 100644 --- a/Common/Phantom.Common.Data.Web/Instance/Instance.cs +++ b/Common/Phantom.Common.Data.Web/Instance/Instance.cs @@ -8,9 +8,10 @@ public sealed partial record Instance( [property: MemoryPackOrder(0)] Guid InstanceGuid, [property: MemoryPackOrder(1)] InstanceConfiguration Configuration, [property: MemoryPackOrder(2)] IInstanceStatus Status, - [property: MemoryPackOrder(3)] bool LaunchAutomatically + [property: MemoryPackOrder(3)] InstancePlayerCounts? PlayerCounts, + [property: MemoryPackOrder(4)] bool LaunchAutomatically ) { public static Instance Offline(Guid instanceGuid, InstanceConfiguration configuration, bool launchAutomatically = false) { - return new Instance(instanceGuid, configuration, InstanceStatus.Offline, launchAutomatically); + return new Instance(instanceGuid, configuration, InstanceStatus.Offline, PlayerCounts: null, launchAutomatically); } } diff --git a/Common/Phantom.Common.Data/Instance/InstancePlayerCounts.cs b/Common/Phantom.Common.Data/Instance/InstancePlayerCounts.cs new file mode 100644 index 0000000..1350138 --- /dev/null +++ b/Common/Phantom.Common.Data/Instance/InstancePlayerCounts.cs @@ -0,0 +1,9 @@ +using MemoryPack; + +namespace Phantom.Common.Data.Instance; + +[MemoryPackable(GenerateType.VersionTolerant)] +public readonly partial record struct InstancePlayerCounts( + [property: MemoryPackOrder(0)] int Online, + [property: MemoryPackOrder(1)] int Maximum +); diff --git a/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs b/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs index c3b9c3a..305f2cd 100644 --- a/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs +++ b/Common/Phantom.Common.Messages.Agent/AgentMessageRegistries.cs @@ -31,6 +31,7 @@ public static class AgentMessageRegistries { ToController.Add<InstanceOutputMessage>(5); ToController.Add<ReportAgentStatusMessage>(6); ToController.Add<ReportInstanceEventMessage>(7); + ToController.Add<ReportInstancePlayerCountsMessage>(8); ToController.Add<ReplyMessage>(127); } diff --git a/Common/Phantom.Common.Messages.Agent/ToController/ReportInstancePlayerCountsMessage.cs b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstancePlayerCountsMessage.cs new file mode 100644 index 0000000..e71efd5 --- /dev/null +++ b/Common/Phantom.Common.Messages.Agent/ToController/ReportInstancePlayerCountsMessage.cs @@ -0,0 +1,10 @@ +using MemoryPack; +using Phantom.Common.Data.Instance; + +namespace Phantom.Common.Messages.Agent.ToController; + +[MemoryPackable(GenerateType.VersionTolerant)] +public sealed partial record ReportInstancePlayerCountsMessage( + [property: MemoryPackOrder(0)] Guid InstanceGuid, + [property: MemoryPackOrder(1)] InstancePlayerCounts? PlayerCounts +) : IMessageToController; diff --git a/Controller/Phantom.Controller.Services/Agents/AgentActor.cs b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs index 46c17fa..ed92d02 100644 --- a/Controller/Phantom.Controller.Services/Agents/AgentActor.cs +++ b/Controller/Phantom.Controller.Services/Agents/AgentActor.cs @@ -96,6 +96,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes); ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance); Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus); + Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts); ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand); @@ -159,7 +160,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() { var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>(); - foreach (var (instanceGuid, instanceConfiguration, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) { + foreach (var (instanceGuid, instanceConfiguration, _, _, launchAutomatically) in instanceDataByGuid.Values.ToImmutableArray()) { var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken); configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically)); } @@ -186,6 +187,8 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { public sealed record CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>; public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand; + + public sealed record UpdateInstancePlayerCountsCommand(Guid InstanceGuid, InstancePlayerCounts? PlayerCounts) : ICommand; public sealed record LaunchInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid) : ICommand, ICanReply<Result<LaunchInstanceResult, InstanceActionFailure>>; @@ -341,6 +344,10 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> { private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) { TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status)); } + + private void UpdateInstancePlayerCounts(UpdateInstancePlayerCountsCommand command) { + TellInstance(command.InstanceGuid, new InstanceActor.SetPlayerCountsCommand(command.PlayerCounts)); + } private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) { return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid)); diff --git a/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs b/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs index 267ebce..8e7f5aa 100644 --- a/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs +++ b/Controller/Phantom.Controller.Services/Instances/InstanceActor.cs @@ -26,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { private InstanceConfiguration configuration; private IInstanceStatus status; + private InstancePlayerCounts? playerCounts; private bool launchAutomatically; private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor; @@ -35,11 +36,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { this.agentConnection = init.AgentConnection; this.cancellationToken = init.CancellationToken; - (this.instanceGuid, this.configuration, this.status, this.launchAutomatically) = init.Instance; + (this.instanceGuid, this.configuration, this.status, this.playerCounts, this.launchAutomatically) = init.Instance; this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage"); Receive<SetStatusCommand>(SetStatus); + Receive<SetPlayerCountsCommand>(SetPlayerCounts); ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance); ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance); ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance); @@ -47,7 +49,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { } private void NotifyInstanceUpdated() { - agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, launchAutomatically))); + agentActor.Tell(new AgentActor.ReceiveInstanceDataCommand(new Instance(instanceGuid, configuration, status, playerCounts, launchAutomatically))); } private void SetLaunchAutomatically(bool newValue) { @@ -65,6 +67,8 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { public interface ICommand {} public sealed record SetStatusCommand(IInstanceStatus Status) : ICommand; + + public sealed record SetPlayerCountsCommand(InstancePlayerCounts? PlayerCounts) : ICommand; public sealed record ConfigureInstanceCommand(Guid AuditLogUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration, InstanceLaunchProperties LaunchProperties, bool IsCreatingInstance) : ICommand, ICanReply<Result<ConfigureInstanceResult, InstanceActionFailure>>; @@ -76,6 +80,16 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> { private void SetStatus(SetStatusCommand command) { status = command.Status; + + if (!status.IsRunning() && status != InstanceStatus.Offline /* Guard against temporary disconnects */) { + playerCounts = null; + } + + NotifyInstanceUpdated(); + } + + private void SetPlayerCounts(SetPlayerCountsCommand command) { + playerCounts = command.PlayerCounts; NotifyInstanceUpdated(); } diff --git a/Controller/Phantom.Controller.Services/Rpc/AgentMessageHandlerActor.cs b/Controller/Phantom.Controller.Services/Rpc/AgentMessageHandlerActor.cs index 19225c3..d6c6d43 100644 --- a/Controller/Phantom.Controller.Services/Rpc/AgentMessageHandlerActor.cs +++ b/Controller/Phantom.Controller.Services/Rpc/AgentMessageHandlerActor.cs @@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> { Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes); Receive<ReportAgentStatusMessage>(HandleReportAgentStatus); Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus); + Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts); Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent); Receive<InstanceOutputMessage>(HandleInstanceOutput); Receive<ReplyMessage>(HandleReply); @@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> { agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus)); } + private void HandleReportInstancePlayerCounts(ReportInstancePlayerCountsMessage message) { + agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstancePlayerCountsCommand(message.InstanceGuid, message.PlayerCounts)); + } + private void HandleReportInstanceEvent(ReportInstanceEventMessage message) { message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid)); } diff --git a/Web/Phantom.Web/Pages/Instances.razor b/Web/Phantom.Web/Pages/Instances.razor index 0e4bdbb..69507ea 100644 --- a/Web/Phantom.Web/Pages/Instances.razor +++ b/Web/Phantom.Web/Pages/Instances.razor @@ -21,6 +21,7 @@ <Column Width="40%">Agent</Column> <Column Width="40%">Name</Column> <Column MinWidth="215px">Status</Column> + <Column Class="text-center" MinWidth="120px">Players</Column> <Column Width="20%">Version</Column> <Column Class="text-center" MinWidth="110px">Server Port</Column> <Column Class="text-center" MinWidth="110px">Rcon Port</Column> @@ -40,6 +41,14 @@ <Cell> <InstanceStatusText Status="instance.Status" /> </Cell> + <Cell class="text-center"> + @if (instance.PlayerCounts is var (online, maximum)) { + <p class="font-monospace">@online.ToString() / @maximum.ToString()</p> + } + else { + <p class="font-monospace">-</p> + } + </Cell> <Cell>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell> <Cell class="text-center"> <p class="font-monospace">@configuration.ServerPort.ToString()</p>