Launcher milestone 2: player list, kick/ban, live stats

Adds an admin-protocol client (token-authed TCP JSON on 127.0.0.1:AdminPort,
reusing the server's .admin-token) that polls connected players and status
every 3s, plus Kick/Ban on the selected player and a players/uptime readout.
This commit is contained in:
NomadsReach
2026-08-16 18:22:03 -04:00
parent 1f01e68794
commit 16c101a4bc
4 changed files with 276 additions and 19 deletions
+148 -2
View File
@@ -1,5 +1,8 @@
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Text.Json.Nodes;
using System.Threading.Tasks;
using Avalonia.Threading;
using CommonwealthOnline.Host.Services;
using CommunityToolkit.Mvvm.ComponentModel;
@@ -12,9 +15,11 @@ public partial class MainWindowViewModel : ObservableObject
private const int MaxLogLines = 2000;
private readonly ServerController _controller = new();
private readonly string _serverDir = System.IO.Directory.GetCurrentDirectory();
private readonly string _serverDir = Directory.GetCurrentDirectory();
private readonly string _configPath =
Path.Combine(System.IO.Directory.GetCurrentDirectory(), "commonwealth-server.json");
Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json");
private readonly DispatcherTimer _pollTimer;
private bool _polling;
[ObservableProperty] private string _serverName;
[ObservableProperty] private string _host;
@@ -25,8 +30,15 @@ public partial class MainWindowViewModel : ObservableObject
[ObservableProperty] private bool _enableGnsTransport;
[ObservableProperty] private bool _isRunning;
[ObservableProperty] private string _statusText = "Stopped";
[ObservableProperty] private string _statsText = string.Empty;
[ObservableProperty]
[NotifyCanExecuteChangedFor(nameof(KickCommand))]
[NotifyCanExecuteChangedFor(nameof(BanCommand))]
private PlayerRow? _selectedPlayer;
public ObservableCollection<string> Log { get; } = new();
public ObservableCollection<PlayerRow> Players { get; } = new();
public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" };
@@ -50,7 +62,100 @@ public partial class MainWindowViewModel : ObservableObject
StatusText = running ? "Running" : "Stopped";
StartCommand.NotifyCanExecuteChanged();
StopCommand.NotifyCanExecuteChanged();
if (!running)
{
Players.Clear();
StatsText = string.Empty;
}
});
_pollTimer = new DispatcherTimer { Interval = TimeSpan.FromSeconds(3) };
_pollTimer.Tick += async (_, _) => await PollAsync();
_pollTimer.Start();
}
private AdminClient CreateAdminClient()
{
var tokenPath = Path.Combine(Path.GetDirectoryName(_configPath) ?? _serverDir, ".admin-token");
return new AdminClient(AdminPort, tokenPath);
}
private async Task PollAsync()
{
if (!IsRunning || _polling)
{
return;
}
_polling = true;
try
{
var admin = CreateAdminClient();
var clients = await admin.ClientsAsync().ConfigureAwait(true);
ApplyClients(clients);
var status = await admin.StatusAsync().ConfigureAwait(true);
ApplyStatus(status);
}
catch (Exception)
{
// Server still starting, admin port not up yet, or token not written — ignore this tick.
}
finally
{
_polling = false;
}
}
private void ApplyClients(JsonObject? response)
{
if (response?["clients"] is not JsonArray array)
{
return;
}
var previouslySelected = SelectedPlayer?.PlayerId;
Players.Clear();
foreach (var node in array)
{
if (node is not JsonObject client)
{
continue;
}
Players.Add(new PlayerRow
{
PlayerId = (uint)(client["player_id"]?.GetValue<long>() ?? 0),
Label = client["label"]?.GetValue<string>() ?? string.Empty,
Address = client["address"]?.GetValue<string>() ?? string.Empty,
PacketsReceived = client["packets_received"]?.GetValue<long>() ?? 0,
PacketsSent = client["packets_sent"]?.GetValue<long>() ?? 0,
});
}
if (previouslySelected is { } id)
{
foreach (var row in Players)
{
if (row.PlayerId == id)
{
SelectedPlayer = row;
break;
}
}
}
}
private void ApplyStatus(JsonObject? response)
{
if (response is null)
{
return;
}
var connected = response["connected_clients"]?.GetValue<long>() ?? Players.Count;
var uptime = response["uptime_seconds"]?.GetValue<long>() ?? 0;
StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s";
}
private void Append(string line)
@@ -94,4 +199,45 @@ public partial class MainWindowViewModel : ObservableObject
}
private bool CanStop() => IsRunning;
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
private async Task Kick()
{
if (SelectedPlayer is not { } player)
{
return;
}
await RunAdminAction(admin => admin.KickAsync(player.PlayerId, "Kicked by host"),
$"[host] kick #{player.PlayerId}").ConfigureAwait(true);
}
[RelayCommand(CanExecute = nameof(CanActOnPlayer))]
private async Task Ban()
{
if (SelectedPlayer is not { } player)
{
return;
}
await RunAdminAction(admin => admin.BanAsync(player.PlayerId, "Banned by host"),
$"[host] ban #{player.PlayerId}").ConfigureAwait(true);
}
private bool CanActOnPlayer() => IsRunning && SelectedPlayer is not null;
private async Task RunAdminAction(Func<AdminClient, Task<JsonObject?>> action, string label)
{
try
{
var response = await action(CreateAdminClient()).ConfigureAwait(true);
var message = response?["message"]?.GetValue<string>();
Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}");
await PollAsync().ConfigureAwait(true);
}
catch (Exception ex)
{
Append($"{label} failed: {ex.Message}");
}
}
}
+13
View File
@@ -0,0 +1,13 @@
namespace CommonwealthOnline.Host.ViewModels;
public sealed class PlayerRow
{
public uint PlayerId { get; init; }
public string Label { get; init; } = string.Empty;
public string Address { get; init; } = string.Empty;
public long PacketsReceived { get; init; }
public long PacketsSent { get; init; }
public string Display =>
$"#{PlayerId} {(string.IsNullOrEmpty(Label) ? "player" : Label)} {Address} ↓{PacketsReceived} ↑{PacketsSent}";
}