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; using CommunityToolkit.Mvvm.Input; namespace CommonwealthOnline.Host.ViewModels; public partial class MainWindowViewModel : ObservableObject { private const int MaxLogLines = 2000; private readonly ServerController _controller = new(); private readonly string _serverDir = Directory.GetCurrentDirectory(); private readonly string _configPath = Path.Combine(Directory.GetCurrentDirectory(), "commonwealth-server.json"); private readonly DispatcherTimer _pollTimer; private bool _polling; [ObservableProperty] private string _serverName; [ObservableProperty] private string _host; [ObservableProperty] private int _port; [ObservableProperty] private int _maxPlayers; [ObservableProperty] private int _adminPort; [ObservableProperty] private string _logVerbosity; [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 Log { get; } = new(); public ObservableCollection Players { get; } = new(); public string[] VerbosityOptions { get; } = { "error", "warning", "info", "debug" }; public MainWindowViewModel() { var config = ServerConfig.Load(_configPath); _serverName = config.ServerName; _host = config.Host; _port = config.Port; _maxPlayers = config.MaxPlayers; _adminPort = config.AdminPort; _logVerbosity = config.LogVerbosity; _enableGnsTransport = config.EnableGnsTransport; _controller.LogReceived += line => Dispatcher.UIThread.Post(() => Append(line)); _controller.RunningChanged += running => Dispatcher.UIThread.Post(() => { IsRunning = running; 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() ?? 0), Label = client["label"]?.GetValue() ?? string.Empty, Address = client["address"]?.GetValue() ?? string.Empty, PacketsReceived = client["packets_received"]?.GetValue() ?? 0, PacketsSent = client["packets_sent"]?.GetValue() ?? 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() ?? Players.Count; var uptime = response["uptime_seconds"]?.GetValue() ?? 0; StatsText = $"{connected}/{MaxPlayers} players · up {uptime}s"; } private void Append(string line) { Log.Add(line); while (Log.Count > MaxLogLines) { Log.RemoveAt(0); } } private ServerConfig CurrentConfig() => new() { ServerName = ServerName, Host = Host, Port = Port, MaxPlayers = MaxPlayers, AdminPort = AdminPort, LogVerbosity = LogVerbosity, EnableGnsTransport = EnableGnsTransport, }; [RelayCommand] private void Save() => CurrentConfig().Save(_configPath); [RelayCommand(CanExecute = nameof(CanStart))] private void Start() { Save(); Append($"[host] starting server on {Host}:{Port}..."); _controller.Start(_serverDir, _configPath); } private bool CanStart() => !IsRunning; [RelayCommand(CanExecute = nameof(CanStop))] private void Stop() { Append("[host] stopping server..."); _controller.Stop(); } 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> action, string label) { try { var response = await action(CreateAdminClient()).ConfigureAwait(true); var message = response?["message"]?.GetValue(); Append(string.IsNullOrEmpty(message) ? $"{label} sent" : $"{label}: {message}"); await PollAsync().ConfigureAwait(true); } catch (Exception ex) { Append($"{label} failed: {ex.Message}"); } } }