mirror of
https://github.com/chylex/Minecraft-Phantom-Panel.git
synced 2024-11-25 07:42:58 +01:00
Compare commits
6 Commits
1855ff8062
...
7bac21c3a4
Author | SHA1 | Date | |
---|---|---|---|
7bac21c3a4 | |||
9873f8779f | |||
31e101b21e | |||
398bb14742 | |||
4e89d7b12f | |||
3b313bf0a8 |
@ -18,4 +18,5 @@ static class MinecraftServerProperties {
|
|||||||
public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
|
public static readonly MinecraftServerProperty<ushort> ServerPort = new UnsignedShort("server-port");
|
||||||
public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
|
public static readonly MinecraftServerProperty<ushort> RconPort = new UnsignedShort("rcon.port");
|
||||||
public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
|
public static readonly MinecraftServerProperty<bool> EnableRcon = new Boolean("enable-rcon");
|
||||||
|
public static readonly MinecraftServerProperty<bool> SyncChunkWrites = new Boolean("sync-chunk-writes");
|
||||||
}
|
}
|
||||||
|
@ -5,11 +5,13 @@ namespace Phantom.Agent.Minecraft.Properties;
|
|||||||
public sealed record ServerProperties(
|
public sealed record ServerProperties(
|
||||||
ushort ServerPort,
|
ushort ServerPort,
|
||||||
ushort RconPort,
|
ushort RconPort,
|
||||||
bool EnableRcon = true
|
bool EnableRcon = true,
|
||||||
|
bool SyncChunkWrites = false
|
||||||
) {
|
) {
|
||||||
internal void SetTo(JavaPropertiesFileEditor properties) {
|
internal void SetTo(JavaPropertiesFileEditor properties) {
|
||||||
MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
|
MinecraftServerProperties.ServerPort.Set(properties, ServerPort);
|
||||||
MinecraftServerProperties.RconPort.Set(properties, RconPort);
|
MinecraftServerProperties.RconPort.Set(properties, RconPort);
|
||||||
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
|
MinecraftServerProperties.EnableRcon.Set(properties, EnableRcon);
|
||||||
|
MinecraftServerProperties.SyncChunkWrites.Set(properties, SyncChunkWrites);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -3,28 +3,12 @@ using System.Buffers.Binary;
|
|||||||
using System.Net;
|
using System.Net;
|
||||||
using System.Net.Sockets;
|
using System.Net.Sockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using Phantom.Utils.Logging;
|
using Phantom.Common.Data.Instance;
|
||||||
using Serilog;
|
|
||||||
|
|
||||||
namespace Phantom.Agent.Minecraft.Server;
|
namespace Phantom.Agent.Minecraft.Server;
|
||||||
|
|
||||||
public sealed class ServerStatusProtocol {
|
public static class ServerStatusProtocol {
|
||||||
private readonly ILogger logger;
|
public static async Task<InstancePlayerCounts> GetPlayerCounts(ushort serverPort, CancellationToken cancellationToken) {
|
||||||
|
|
||||||
public ServerStatusProtocol(string loggerName) {
|
|
||||||
this.logger = PhantomLogger.Create<ServerStatusProtocol>(loggerName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public async Task<int?> GetOnlinePlayerCount(int serverPort, CancellationToken cancellationToken) {
|
|
||||||
try {
|
|
||||||
return await GetOnlinePlayerCountOrThrow(serverPort, cancellationToken);
|
|
||||||
} catch (Exception e) {
|
|
||||||
logger.Error(e, "Caught exception while checking if players are online.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<int?> GetOnlinePlayerCountOrThrow(int serverPort, CancellationToken cancellationToken) {
|
|
||||||
using var tcpClient = new TcpClient();
|
using var tcpClient = new TcpClient();
|
||||||
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
|
await tcpClient.ConnectAsync(IPAddress.Loopback, serverPort, cancellationToken);
|
||||||
var tcpStream = tcpClient.GetStream();
|
var tcpStream = tcpClient.GetStream();
|
||||||
@ -33,24 +17,22 @@ public sealed class ServerStatusProtocol {
|
|||||||
tcpStream.WriteByte(0xFE);
|
tcpStream.WriteByte(0xFE);
|
||||||
await tcpStream.FlushAsync(cancellationToken);
|
await tcpStream.FlushAsync(cancellationToken);
|
||||||
|
|
||||||
short? messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
|
short messageLength = await ReadStreamHeader(tcpStream, cancellationToken);
|
||||||
return messageLength == null ? null : await ReadOnlinePlayerCount(tcpStream, messageLength.Value * 2, cancellationToken);
|
return await ReadPlayerCounts(tcpStream, messageLength * 2, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<short?> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
|
private static async Task<short> ReadStreamHeader(NetworkStream tcpStream, CancellationToken cancellationToken) {
|
||||||
var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
|
var headerBuffer = ArrayPool<byte>.Shared.Rent(3);
|
||||||
try {
|
try {
|
||||||
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
|
await tcpStream.ReadExactlyAsync(headerBuffer, 0, 3, cancellationToken);
|
||||||
|
|
||||||
if (headerBuffer[0] != 0xFF) {
|
if (headerBuffer[0] != 0xFF) {
|
||||||
logger.Error("Unexpected first byte in response from server: {FirstByte}.", headerBuffer[0]);
|
throw new ProtocolException("Unexpected first byte in response from server: " + headerBuffer[0]);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
|
short messageLength = BinaryPrimitives.ReadInt16BigEndian(headerBuffer.AsSpan(1));
|
||||||
if (messageLength <= 0) {
|
if (messageLength <= 0) {
|
||||||
logger.Error("Unexpected message length in response from server: {MessageLength}.", messageLength);
|
throw new ProtocolException("Unexpected message length in response from server: " + messageLength);
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return messageLength;
|
return messageLength;
|
||||||
@ -59,35 +41,54 @@ public sealed class ServerStatusProtocol {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<int?> ReadOnlinePlayerCount(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);
|
var messageBuffer = ArrayPool<byte>.Shared.Rent(messageLength);
|
||||||
try {
|
try {
|
||||||
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
|
await tcpStream.ReadExactlyAsync(messageBuffer, 0, messageLength, cancellationToken);
|
||||||
|
return ReadPlayerCountsFromResponse(messageBuffer.AsSpan(0, messageLength));
|
||||||
// Valid response separator encoded in UTF-16BE is 0x00 0xA7 (§).
|
|
||||||
const byte SeparatorSecondByte = 0xA7;
|
|
||||||
|
|
||||||
static bool IsValidSeparator(ReadOnlySpan<byte> buffer, int index) {
|
|
||||||
return index > 0 && buffer[index - 1] == 0x00;
|
|
||||||
}
|
|
||||||
|
|
||||||
int separator2 = Array.LastIndexOf(messageBuffer, SeparatorSecondByte);
|
|
||||||
int separator1 = separator2 == -1 ? -1 : Array.LastIndexOf(messageBuffer, SeparatorSecondByte, separator2 - 1);
|
|
||||||
if (!IsValidSeparator(messageBuffer, separator1) || !IsValidSeparator(messageBuffer, separator2)) {
|
|
||||||
logger.Error("Could not find message separators in response from server.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
string onlinePlayerCountStr = Encoding.BigEndianUnicode.GetString(messageBuffer.AsSpan((separator1 + 1)..(separator2 - 1)));
|
|
||||||
if (!int.TryParse(onlinePlayerCountStr, out int onlinePlayerCount)) {
|
|
||||||
logger.Error("Could not parse online player count in response from server: {OnlinePlayerCount}.", onlinePlayerCountStr);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.Debug("Detected {OnlinePlayerCount} online player(s).", onlinePlayerCount);
|
|
||||||
return onlinePlayerCount;
|
|
||||||
} finally {
|
} finally {
|
||||||
ArrayPool<byte>.Shared.Return(messageBuffer);
|
ArrayPool<byte>.Shared.Return(messageBuffer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Legacy query protocol uses the paragraph symbol (§) as separator encoded in UTF-16BE.
|
||||||
|
/// </summary>
|
||||||
|
private static readonly byte[] Separator = { 0x00, 0xA7 };
|
||||||
|
|
||||||
|
private static InstancePlayerCounts ReadPlayerCountsFromResponse(ReadOnlySpan<byte> messageBuffer) {
|
||||||
|
int lastSeparator = messageBuffer.LastIndexOf(Separator);
|
||||||
|
int middleSeparator = messageBuffer[..lastSeparator].LastIndexOf(Separator);
|
||||||
|
|
||||||
|
if (lastSeparator == -1 || middleSeparator == -1) {
|
||||||
|
throw new ProtocolException("Could not find message separators in response from server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var onlinePlayerCountBuffer = messageBuffer[(middleSeparator + Separator.Length)..lastSeparator];
|
||||||
|
var maximumPlayerCountBuffer = messageBuffer[(lastSeparator + Separator.Length)..];
|
||||||
|
|
||||||
|
// Player counts are integers, whose maximum string length is 10 characters.
|
||||||
|
Span<char> integerStringBuffer = stackalloc char[10];
|
||||||
|
|
||||||
|
return new InstancePlayerCounts(
|
||||||
|
DecodeAndParsePlayerCount(onlinePlayerCountBuffer, integerStringBuffer, "online"),
|
||||||
|
DecodeAndParsePlayerCount(maximumPlayerCountBuffer, integerStringBuffer, "maximum")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int DecodeAndParsePlayerCount(ReadOnlySpan<byte> inputBuffer, Span<char> tempCharBuffer, string countType) {
|
||||||
|
if (!Encoding.BigEndianUnicode.TryGetChars(inputBuffer, tempCharBuffer, out int charCount)) {
|
||||||
|
throw new ProtocolException("Could not decode " + countType + " player count in response from server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!int.TryParse(tempCharBuffer, out int playerCount)) {
|
||||||
|
throw new ProtocolException("Could not parse " + countType + " player count in response from server: " + tempCharBuffer[..charCount].ToString());
|
||||||
|
}
|
||||||
|
|
||||||
|
return playerCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class ProtocolException : Exception {
|
||||||
|
internal ProtocolException(string message) : base(message) {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,10 +1,8 @@
|
|||||||
using Phantom.Agent.Minecraft.Instance;
|
using Phantom.Agent.Services.Instances;
|
||||||
using Phantom.Agent.Minecraft.Server;
|
using Phantom.Agent.Services.Instances.State;
|
||||||
using Phantom.Agent.Services.Instances;
|
|
||||||
using Phantom.Common.Data.Backups;
|
using Phantom.Common.Data.Backups;
|
||||||
using Phantom.Utils.Logging;
|
using Phantom.Utils.Logging;
|
||||||
using Phantom.Utils.Tasks;
|
using Phantom.Utils.Tasks;
|
||||||
using Phantom.Utils.Threading;
|
|
||||||
|
|
||||||
namespace Phantom.Agent.Services.Backups;
|
namespace Phantom.Agent.Services.Backups;
|
||||||
|
|
||||||
@ -16,27 +14,23 @@ sealed class BackupScheduler : CancellableBackgroundTask {
|
|||||||
|
|
||||||
private readonly BackupManager backupManager;
|
private readonly BackupManager backupManager;
|
||||||
private readonly InstanceContext context;
|
private readonly InstanceContext context;
|
||||||
private readonly InstanceProcess process;
|
|
||||||
private readonly SemaphoreSlim backupSemaphore = new (1, 1);
|
private readonly SemaphoreSlim backupSemaphore = new (1, 1);
|
||||||
private readonly int serverPort;
|
|
||||||
private readonly ServerStatusProtocol serverStatusProtocol;
|
|
||||||
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
|
private readonly ManualResetEventSlim serverOutputWhileWaitingForOnlinePlayers = new ();
|
||||||
|
private readonly InstancePlayerCountTracker playerCountTracker;
|
||||||
|
|
||||||
public event EventHandler<BackupCreationResult>? BackupCompleted;
|
public event EventHandler<BackupCreationResult>? BackupCompleted;
|
||||||
|
|
||||||
public BackupScheduler(InstanceContext context, InstanceProcess process, int serverPort) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
|
public BackupScheduler(InstanceContext context, InstancePlayerCountTracker playerCountTracker) : base(PhantomLogger.Create<BackupScheduler>(context.ShortName)) {
|
||||||
this.backupManager = context.Services.BackupManager;
|
this.backupManager = context.Services.BackupManager;
|
||||||
this.context = context;
|
this.context = context;
|
||||||
this.process = process;
|
this.playerCountTracker = playerCountTracker;
|
||||||
this.serverPort = serverPort;
|
|
||||||
this.serverStatusProtocol = new ServerStatusProtocol(context.ShortName);
|
|
||||||
Start();
|
Start();
|
||||||
}
|
}
|
||||||
|
|
||||||
protected override async Task RunTask() {
|
protected override async Task RunTask() {
|
||||||
await Task.Delay(InitialDelay, CancellationToken);
|
await Task.Delay(InitialDelay, CancellationToken);
|
||||||
Logger.Information("Starting a new backup after server launched.");
|
Logger.Information("Starting a new backup after server launched.");
|
||||||
|
|
||||||
while (!CancellationToken.IsCancellationRequested) {
|
while (!CancellationToken.IsCancellationRequested) {
|
||||||
var result = await CreateBackup();
|
var result = await CreateBackup();
|
||||||
BackupCompleted?.Invoke(this, result);
|
BackupCompleted?.Invoke(this, result);
|
||||||
@ -69,43 +63,18 @@ sealed class BackupScheduler : CancellableBackgroundTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task WaitForOnlinePlayers() {
|
private async Task WaitForOnlinePlayers() {
|
||||||
bool needsToLogOfflinePlayersMessage = true;
|
var task = playerCountTracker.WaitForOnlinePlayers(CancellationToken);
|
||||||
|
if (!task.IsCompleted) {
|
||||||
process.AddOutputListener(ServerOutputListener, maxLinesToReadFromHistory: 0);
|
Logger.Information("Waiting for someone to join before starting a new backup.");
|
||||||
try {
|
|
||||||
while (!CancellationToken.IsCancellationRequested) {
|
|
||||||
serverOutputWhileWaitingForOnlinePlayers.Reset();
|
|
||||||
|
|
||||||
var onlinePlayerCount = await serverStatusProtocol.GetOnlinePlayerCount(serverPort, CancellationToken);
|
|
||||||
if (onlinePlayerCount == null) {
|
|
||||||
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (onlinePlayerCount > 0) {
|
|
||||||
Logger.Information("Players are online, starting a new backup.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsToLogOfflinePlayersMessage) {
|
|
||||||
needsToLogOfflinePlayersMessage = false;
|
|
||||||
Logger.Information("No players are online, waiting for someone to join before starting a new backup.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
|
|
||||||
|
|
||||||
Logger.Debug("Waiting for server output before checking for online players again...");
|
|
||||||
await serverOutputWhileWaitingForOnlinePlayers.WaitHandle.WaitOneAsync(CancellationToken);
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
process.RemoveOutputListener(ServerOutputListener);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
try {
|
||||||
private void ServerOutputListener(object? sender, string line) {
|
await task;
|
||||||
if (!serverOutputWhileWaitingForOnlinePlayers.IsSet) {
|
Logger.Information("Players are online, starting a new backup.");
|
||||||
serverOutputWhileWaitingForOnlinePlayers.Set();
|
} catch (OperationCanceledException) {
|
||||||
Logger.Debug("Detected server output, signalling to check for online players again.");
|
throw;
|
||||||
|
} catch (Exception) {
|
||||||
|
Logger.Warning("Could not detect whether any players are online, starting a new backup.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -55,7 +55,6 @@ sealed partial class BackupServerCommandDispatcher : IDisposable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public async Task SaveAllChunks() {
|
public async Task SaveAllChunks() {
|
||||||
// TODO Try if not flushing and waiting a few seconds before flushing reduces lag.
|
|
||||||
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
|
await process.SendCommand(MinecraftCommand.SaveAll(flush: true), cancellationToken);
|
||||||
await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
|
await savedTheGame.Task.WaitAsync(TimeSpan.FromMinutes(1), cancellationToken);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,140 @@
|
|||||||
|
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;
|
||||||
|
|
||||||
|
namespace Phantom.Agent.Services.Instances.State;
|
||||||
|
|
||||||
|
sealed class InstancePlayerCountTracker : CancellableBackgroundTask {
|
||||||
|
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 InstancePlayerCounts? playerCounts;
|
||||||
|
|
||||||
|
public InstancePlayerCounts? PlayerCounts {
|
||||||
|
get {
|
||||||
|
lock (this) {
|
||||||
|
return playerCounts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
private set {
|
||||||
|
EventHandler<int?>? onlinePlayerCountChanged;
|
||||||
|
lock (this) {
|
||||||
|
if (playerCounts == value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playerCounts = value;
|
||||||
|
onlinePlayerCountChanged = OnlinePlayerCountChanged;
|
||||||
|
}
|
||||||
|
|
||||||
|
onlinePlayerCountChanged?.Invoke(this, value?.Online);
|
||||||
|
controllerConnection.Send(new ReportInstancePlayerCountsMessage(instanceGuid, value));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private event EventHandler<int?>? OnlinePlayerCountChanged;
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override async Task RunTask() {
|
||||||
|
// Give the server time to start accepting connections.
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
|
||||||
|
|
||||||
|
serverOutputEvent.Set();
|
||||||
|
process.AddOutputListener(OnOutput, maxLinesToReadFromHistory: 0);
|
||||||
|
|
||||||
|
while (!CancellationToken.IsCancellationRequested) {
|
||||||
|
serverOutputEvent.Reset();
|
||||||
|
|
||||||
|
PlayerCounts = await TryGetPlayerCounts();
|
||||||
|
|
||||||
|
if (!firstDetection.Task.IsCompleted) {
|
||||||
|
firstDetection.SetResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(10), CancellationToken);
|
||||||
|
await serverOutputEvent.WaitHandle.WaitOneAsync(CancellationToken);
|
||||||
|
await Task.Delay(TimeSpan.FromSeconds(1), CancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<InstancePlayerCounts?> TryGetPlayerCounts() {
|
||||||
|
try {
|
||||||
|
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;
|
||||||
|
} catch (Exception e) {
|
||||||
|
Logger.Error(e, "Caught exception while checking online player count.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task WaitForOnlinePlayers(CancellationToken cancellationToken) {
|
||||||
|
await firstDetection.Task.WaitAsync(cancellationToken);
|
||||||
|
|
||||||
|
var onlinePlayersDetected = AsyncTasks.CreateCompletionSource();
|
||||||
|
|
||||||
|
lock (this) {
|
||||||
|
if (playerCounts is { Online: > 0 }) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
else if (playerCounts == null) {
|
||||||
|
throw new InvalidOperationException();
|
||||||
|
}
|
||||||
|
|
||||||
|
OnlinePlayerCountChanged += OnOnlinePlayerCountChanged;
|
||||||
|
|
||||||
|
void OnOnlinePlayerCountChanged(object? sender, int? newPlayerCount) {
|
||||||
|
if (newPlayerCount == null) {
|
||||||
|
onlinePlayersDetected.TrySetException(new InvalidOperationException());
|
||||||
|
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
|
||||||
|
}
|
||||||
|
else if (newPlayerCount > 0) {
|
||||||
|
onlinePlayersDetected.TrySetResult();
|
||||||
|
OnlinePlayerCountChanged -= OnOnlinePlayerCountChanged;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await onlinePlayersDetected.Task;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnOutput(object? sender, string? line) {
|
||||||
|
lock (this) {
|
||||||
|
if (!isDisposed) {
|
||||||
|
serverOutputEvent.Set();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Dispose() {
|
||||||
|
lock (this) {
|
||||||
|
isDisposed = true;
|
||||||
|
playerCounts = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
process.RemoveOutputListener(OnOutput);
|
||||||
|
serverOutputEvent.Dispose();
|
||||||
|
}
|
||||||
|
}
|
@ -19,6 +19,7 @@ sealed class InstanceRunningState : IDisposable {
|
|||||||
private readonly CancellationToken cancellationToken;
|
private readonly CancellationToken cancellationToken;
|
||||||
|
|
||||||
private readonly InstanceLogSender logSender;
|
private readonly InstanceLogSender logSender;
|
||||||
|
private readonly InstancePlayerCountTracker playerCountTracker;
|
||||||
private readonly BackupScheduler backupScheduler;
|
private readonly BackupScheduler backupScheduler;
|
||||||
|
|
||||||
private bool isDisposed;
|
private bool isDisposed;
|
||||||
@ -32,8 +33,9 @@ sealed class InstanceRunningState : IDisposable {
|
|||||||
this.cancellationToken = cancellationToken;
|
this.cancellationToken = cancellationToken;
|
||||||
|
|
||||||
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
|
this.logSender = new InstanceLogSender(context.Services.ControllerConnection, context.InstanceGuid, context.ShortName);
|
||||||
|
this.playerCountTracker = new InstancePlayerCountTracker(context, process, configuration.ServerPort);
|
||||||
|
|
||||||
this.backupScheduler = new BackupScheduler(context, process, configuration.ServerPort);
|
this.backupScheduler = new BackupScheduler(context, playerCountTracker);
|
||||||
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
|
this.backupScheduler.BackupCompleted += OnScheduledBackupCompleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -93,6 +95,11 @@ sealed class InstanceRunningState : IDisposable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void OnStopInitiated() {
|
||||||
|
backupScheduler.Stop();
|
||||||
|
playerCountTracker.Stop();
|
||||||
|
}
|
||||||
|
|
||||||
private bool TryDispose() {
|
private bool TryDispose() {
|
||||||
lock (this) {
|
lock (this) {
|
||||||
if (isDisposed) {
|
if (isDisposed) {
|
||||||
@ -102,8 +109,8 @@ sealed class InstanceRunningState : IDisposable {
|
|||||||
isDisposed = true;
|
isDisposed = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
OnStopInitiated();
|
||||||
logSender.Stop();
|
logSender.Stop();
|
||||||
backupScheduler.Stop();
|
|
||||||
|
|
||||||
Process.Dispose();
|
Process.Dispose();
|
||||||
|
|
||||||
|
@ -25,6 +25,8 @@ static class InstanceStopProcedure {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// Too late to cancel the stop procedure now.
|
// Too late to cancel the stop procedure now.
|
||||||
|
runningState.OnStopInitiated();
|
||||||
|
|
||||||
if (!process.HasEnded) {
|
if (!process.HasEnded) {
|
||||||
context.Logger.Information("Session stopping now.");
|
context.Logger.Information("Session stopping now.");
|
||||||
await DoStop(context, process);
|
await DoStop(context, process);
|
||||||
@ -85,7 +87,7 @@ static class InstanceStopProcedure {
|
|||||||
private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
|
private static async Task WaitForSessionToEnd(InstanceContext context, InstanceProcess process) {
|
||||||
try {
|
try {
|
||||||
await process.WaitForExit(TimeSpan.FromSeconds(55));
|
await process.WaitForExit(TimeSpan.FromSeconds(55));
|
||||||
} catch (OperationCanceledException) {
|
} catch (TimeoutException) {
|
||||||
try {
|
try {
|
||||||
context.Logger.Warning("Waiting timed out, killing session...");
|
context.Logger.Warning("Waiting timed out, killing session...");
|
||||||
process.Kill();
|
process.Kill();
|
||||||
|
@ -8,9 +8,10 @@ public sealed partial record Instance(
|
|||||||
[property: MemoryPackOrder(0)] Guid InstanceGuid,
|
[property: MemoryPackOrder(0)] Guid InstanceGuid,
|
||||||
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
|
[property: MemoryPackOrder(1)] InstanceConfiguration Configuration,
|
||||||
[property: MemoryPackOrder(2)] IInstanceStatus Status,
|
[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) {
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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
|
||||||
|
);
|
@ -31,6 +31,7 @@ public static class AgentMessageRegistries {
|
|||||||
ToController.Add<InstanceOutputMessage>(5);
|
ToController.Add<InstanceOutputMessage>(5);
|
||||||
ToController.Add<ReportAgentStatusMessage>(6);
|
ToController.Add<ReportAgentStatusMessage>(6);
|
||||||
ToController.Add<ReportInstanceEventMessage>(7);
|
ToController.Add<ReportInstanceEventMessage>(7);
|
||||||
|
ToController.Add<ReportInstancePlayerCountsMessage>(8);
|
||||||
ToController.Add<ReplyMessage>(127);
|
ToController.Add<ReplyMessage>(127);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
@ -96,6 +96,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
|
|||||||
Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
|
Receive<UpdateJavaRuntimesCommand>(UpdateJavaRuntimes);
|
||||||
ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
|
ReceiveAndReplyLater<CreateOrUpdateInstanceCommand, Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>(CreateOrUpdateInstance);
|
||||||
Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
|
Receive<UpdateInstanceStatusCommand>(UpdateInstanceStatus);
|
||||||
|
Receive<UpdateInstancePlayerCountsCommand>(UpdateInstancePlayerCounts);
|
||||||
ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
|
ReceiveAndReplyLater<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
|
||||||
ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
|
ReceiveAndReplyLater<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
|
||||||
ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
|
ReceiveAndReplyLater<SendCommandToInstanceCommand, Result<SendCommandToInstanceResult, InstanceActionFailure>>(SendMinecraftCommand);
|
||||||
@ -159,7 +160,7 @@ sealed class AgentActor : ReceiveActor<AgentActor.ICommand> {
|
|||||||
private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
|
private async Task<ImmutableArray<ConfigureInstanceMessage>> PrepareInitialConfigurationMessages() {
|
||||||
var configurationMessages = ImmutableArray.CreateBuilder<ConfigureInstanceMessage>();
|
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);
|
var serverExecutableInfo = await minecraftVersions.GetServerExecutableInfo(instanceConfiguration.MinecraftVersion, cancellationToken);
|
||||||
configurationMessages.Add(new ConfigureInstanceMessage(instanceGuid, instanceConfiguration, new InstanceLaunchProperties(serverExecutableInfo), launchAutomatically));
|
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 CreateOrUpdateInstanceCommand(Guid LoggedInUserGuid, Guid InstanceGuid, InstanceConfiguration Configuration) : ICommand, ICanReply<Result<CreateOrUpdateInstanceResult, InstanceActionFailure>>;
|
||||||
|
|
||||||
public sealed record UpdateInstanceStatusCommand(Guid InstanceGuid, IInstanceStatus Status) : ICommand;
|
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>>;
|
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) {
|
private void UpdateInstanceStatus(UpdateInstanceStatusCommand command) {
|
||||||
TellInstance(command.InstanceGuid, new InstanceActor.SetStatusCommand(command.Status));
|
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) {
|
private Task<Result<LaunchInstanceResult, InstanceActionFailure>> LaunchInstance(LaunchInstanceCommand command) {
|
||||||
return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));
|
return RequestInstance<InstanceActor.LaunchInstanceCommand, LaunchInstanceResult>(command.InstanceGuid, new InstanceActor.LaunchInstanceCommand(command.LoggedInUserGuid));
|
||||||
|
@ -26,6 +26,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
|
|||||||
|
|
||||||
private InstanceConfiguration configuration;
|
private InstanceConfiguration configuration;
|
||||||
private IInstanceStatus status;
|
private IInstanceStatus status;
|
||||||
|
private InstancePlayerCounts? playerCounts;
|
||||||
private bool launchAutomatically;
|
private bool launchAutomatically;
|
||||||
|
|
||||||
private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
|
private readonly ActorRef<InstanceDatabaseStorageActor.ICommand> databaseStorageActor;
|
||||||
@ -35,11 +36,12 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
|
|||||||
this.agentConnection = init.AgentConnection;
|
this.agentConnection = init.AgentConnection;
|
||||||
this.cancellationToken = init.CancellationToken;
|
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");
|
this.databaseStorageActor = Context.ActorOf(InstanceDatabaseStorageActor.Factory(new InstanceDatabaseStorageActor.Init(instanceGuid, init.DbProvider, init.CancellationToken)), "DatabaseStorage");
|
||||||
|
|
||||||
Receive<SetStatusCommand>(SetStatus);
|
Receive<SetStatusCommand>(SetStatus);
|
||||||
|
Receive<SetPlayerCountsCommand>(SetPlayerCounts);
|
||||||
ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
|
ReceiveAsyncAndReply<ConfigureInstanceCommand, Result<ConfigureInstanceResult, InstanceActionFailure>>(ConfigureInstance);
|
||||||
ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
|
ReceiveAsyncAndReply<LaunchInstanceCommand, Result<LaunchInstanceResult, InstanceActionFailure>>(LaunchInstance);
|
||||||
ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
|
ReceiveAsyncAndReply<StopInstanceCommand, Result<StopInstanceResult, InstanceActionFailure>>(StopInstance);
|
||||||
@ -47,7 +49,7 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void NotifyInstanceUpdated() {
|
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) {
|
private void SetLaunchAutomatically(bool newValue) {
|
||||||
@ -65,6 +67,8 @@ sealed class InstanceActor : ReceiveActor<InstanceActor.ICommand> {
|
|||||||
public interface ICommand {}
|
public interface ICommand {}
|
||||||
|
|
||||||
public sealed record SetStatusCommand(IInstanceStatus Status) : 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>>;
|
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) {
|
private void SetStatus(SetStatusCommand command) {
|
||||||
status = command.Status;
|
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();
|
NotifyInstanceUpdated();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -39,6 +39,7 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
|
|||||||
Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
|
Receive<AdvertiseJavaRuntimesMessage>(HandleAdvertiseJavaRuntimes);
|
||||||
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
|
Receive<ReportAgentStatusMessage>(HandleReportAgentStatus);
|
||||||
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
|
Receive<ReportInstanceStatusMessage>(HandleReportInstanceStatus);
|
||||||
|
Receive<ReportInstancePlayerCountsMessage>(HandleReportInstancePlayerCounts);
|
||||||
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
|
Receive<ReportInstanceEventMessage>(HandleReportInstanceEvent);
|
||||||
Receive<InstanceOutputMessage>(HandleInstanceOutput);
|
Receive<InstanceOutputMessage>(HandleInstanceOutput);
|
||||||
Receive<ReplyMessage>(HandleReply);
|
Receive<ReplyMessage>(HandleReply);
|
||||||
@ -74,6 +75,10 @@ sealed class AgentMessageHandlerActor : ReceiveActor<IMessageToController> {
|
|||||||
agentManager.TellAgent(agentGuid, new AgentActor.UpdateInstanceStatusCommand(message.InstanceGuid, message.InstanceStatus));
|
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) {
|
private void HandleReportInstanceEvent(ReportInstanceEventMessage message) {
|
||||||
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
|
message.Event.Accept(eventLogManager.CreateInstanceEventVisitor(message.EventGuid, message.UtcTime, agentGuid, message.InstanceGuid));
|
||||||
}
|
}
|
||||||
|
@ -21,6 +21,7 @@
|
|||||||
<Column Width="40%">Agent</Column>
|
<Column Width="40%">Agent</Column>
|
||||||
<Column Width="40%">Name</Column>
|
<Column Width="40%">Name</Column>
|
||||||
<Column MinWidth="215px">Status</Column>
|
<Column MinWidth="215px">Status</Column>
|
||||||
|
<Column Class="text-center" MinWidth="120px">Players</Column>
|
||||||
<Column Width="20%">Version</Column>
|
<Column Width="20%">Version</Column>
|
||||||
<Column Class="text-center" MinWidth="110px">Server Port</Column>
|
<Column Class="text-center" MinWidth="110px">Server Port</Column>
|
||||||
<Column Class="text-center" MinWidth="110px">Rcon Port</Column>
|
<Column Class="text-center" MinWidth="110px">Rcon Port</Column>
|
||||||
@ -40,6 +41,14 @@
|
|||||||
<Cell>
|
<Cell>
|
||||||
<InstanceStatusText Status="instance.Status" />
|
<InstanceStatusText Status="instance.Status" />
|
||||||
</Cell>
|
</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>@configuration.MinecraftServerKind @configuration.MinecraftVersion</Cell>
|
||||||
<Cell class="text-center">
|
<Cell class="text-center">
|
||||||
<p class="font-monospace">@configuration.ServerPort.ToString()</p>
|
<p class="font-monospace">@configuration.ServerPort.ToString()</p>
|
||||||
|
Loading…
Reference in New Issue
Block a user