diff --git a/AGENTS.md b/AGENTS.md index 1a414c1..de69391 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md ## App Overview -The app is a Windows/Linux desktop application designed to manage access to global CS2 and Deadlock servers by blocking +A Windows/Linux desktop application designed to manage access to global CS2, Deadlock and other configured game servers by blocking or unblocking specific servers based on their geographic location. The primary function of this tool is server filtering for distributed gaming networks. ## Repository Overview @@ -30,7 +30,7 @@ The output binary is located under `ServerPickerX/bin/Release/net10.0/ **Note**: The repository currently contains no automated tests. When adding -> tests, follow these guidelines: +> **Note**: When adding tests, follow these guidelines: > > * Place test projects in a sibling folder named `Tests`. > * Target the same framework (`net10.0`). @@ -69,7 +68,7 @@ dotnet test --filter "FullyQualifiedName=ServerPickerX.Models.ServerModelTests.P ## Code Style Guidelines | Area | Guideline | |------|-----------| -| **Imports** | System namespaces first, then project namespaces. Keep `using` statements sorted alphabetically and grouped by scope. Remove unused usings with the built‑in IDE refactor. +| **Imports** | System namespaces first, then project namespaces. Keep `using` statements sorted alphabetically and grouped by scope. | **Formatting** | 4 spaces per indentation level; no tabs. End each file with a single newline. Do not leave trailing whitespace on any line. | **Naming** | |   *Public members (classes, methods, properties)* | PascalCase (e.g., `LoadServers`, `ClusterUnclusterServers`). @@ -81,7 +80,7 @@ dotnet test --filter "FullyQualifiedName=ServerPickerX.Models.ServerModelTests.P • Log errors with `FileLoggerService` and use `MessageBoxService` to display errors inside a catch block. | **MVVM Conventions** | |   *ViewModels* | Inherit from `ObservableObject` (CommunityToolkit.Mvvm). Use `[ObservableProperty]` for properties that should notify UI changes. Keep commands as `ICommand` or `RelayCommand`. -|   *Views* | Prefer code‑behind only for view logic that cannot be expressed in XAML, such as dynamic tooltips. Keep view models free of UI references. +|   *Views* | Prefer code‑behind only for view logic that cannot be expressed in XAML, such as dynamic tooltips. Keep view models free of UI references except for displaying errors using messageboxes. | **Resources** | |   *Images* | Store in `Assets/` and reference via pack URIs (`/Assets/...`). |   *Styles* | Define reusable styles in `Styles/*.axaml`. Register new style files by appending inside App.axaml inside ``. @@ -89,7 +88,7 @@ dotnet test --filter "FullyQualifiedName=ServerPickerX.Models.ServerModelTests.P ## Build & CI Checklist - [ ] All tests pass (`dotnet test ServerPickerX.Tests.slnx`). - [ ] Code passes linting (`dotnet format ServerPickerX.slnx --verify-no-changes`). -- [ ] Publish output contains a single executable without unnecessary dependencies except for files `libHarfBuzzSharp.so` and `libSkiaSharp.so`. +- [ ] Publish output contains an executable and other dependencies. ## Other Instructions - If you are unsure how to do something, use `gh_grep` tools to search code examples from GitHub or use `context7` tools to search for project/code documentations diff --git a/ServerPickerX/App.axaml.cs b/ServerPickerX/App.axaml.cs index fef1f92..c427192 100644 --- a/ServerPickerX/App.axaml.cs +++ b/ServerPickerX/App.axaml.cs @@ -6,7 +6,6 @@ using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Microsoft.Extensions.DependencyInjection; -using ServerPickerX.Constants; using ServerPickerX.Services.Localizations; using ServerPickerX.Services.Loggers; using ServerPickerX.Services.MessageBoxes; @@ -44,34 +43,38 @@ public override void OnFrameworkInitializationCompleted() serviceCollection.AddSingleton(); serviceCollection.AddSingleton(); - // Register concrete services and contionally provide these services through parent interface service resolver - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); - serviceCollection.AddTransient(); + // Register a provider that loads definitions once and expose it as a singleton + serviceCollection.AddSingleton(); + + // Register a factory for IServerDataService using JSON server definitions serviceCollection.AddTransient(serviceProvider => { - JsonSetting jsonSetting = serviceProvider.GetRequiredService(); ILoggerService loggerService = serviceProvider.GetRequiredService(); + JsonSetting jsonSetting = serviceProvider.GetRequiredService(); try { - // Factory method may be suitable if more entries are added in the future - return jsonSetting.game_mode switch + // Get server definition by current game mode that contains app related metadata + var serverDefinitionProvider = serviceProvider.GetRequiredService(); + var serverDefinition = serverDefinitionProvider.GetServerDefinitionByGameMode(jsonSetting.game_mode); + + if (serverDefinition != null) { - GameModes.CounterStrike2 => serviceProvider.GetRequiredService(), - GameModes.CounterStrike2PerfectWorld => serviceProvider.GetRequiredService(), - GameModes.Deadlock => serviceProvider.GetRequiredService(), - GameModes.Marathon => serviceProvider.GetRequiredService(), - _ => throw new NotSupportedException($"Unsupported game mode: {jsonSetting.game_mode}") - }; - } catch (NotSupportedException ex) + // ActivatorUtilities will instantiate a given type and injects dependencies from existing DI container + // while missing dependencies are supplied as manual argument (serverDefinition) + IServerDataService? obj = ActivatorUtilities.CreateInstance(serviceProvider, serverDefinition); + + if (obj != null) return obj; + } + + throw new InvalidOperationException("Failed to register service [IServerDataService]"); + } + catch (InvalidOperationException ex) { loggerService.LogErrorAsync(ex.Message); throw; } - }); serviceCollection.AddTransient(); serviceCollection.AddTransient(); @@ -130,4 +133,4 @@ private void DisableAvaloniaDataAnnotationValidation() } } } -} \ No newline at end of file +} diff --git a/ServerPickerX/Constants/GameModes.cs b/ServerPickerX/Constants/GameModes.cs deleted file mode 100644 index 0b92bdd..0000000 --- a/ServerPickerX/Constants/GameModes.cs +++ /dev/null @@ -1,15 +0,0 @@ -using System.Collections.Generic; - -namespace ServerPickerX.Constants -{ - public static class GameModes - { - public const string CounterStrike2 = "Counter Strike 2"; - public const string CounterStrike2PerfectWorld = "Counter Strike 2 (Perfect World)"; - public const string Deadlock = "Deadlock"; - public const string Marathon = "Marathon"; - - // Read‑only list used as ItemsSource for the Game Mode ComboBox - public static readonly IReadOnlyList All = [ CounterStrike2, CounterStrike2PerfectWorld, Deadlock, Marathon ]; - } -} diff --git a/ServerPickerX/ServerDefinitions.json b/ServerPickerX/ServerDefinitions.json new file mode 100644 index 0000000..6ee6bac --- /dev/null +++ b/ServerPickerX/ServerDefinitions.json @@ -0,0 +1,47 @@ +[ + { + "gameMode": "Counter Strike 2", + "id": "cs2", + "displayName": "CS2", + "appId": 730, + "keywordFilterMode": "exclude", + "keywords": [ "China" ], + "clusterKeywords": [ "Hong Kong", "Sweden", "India", "Netherlands" ] + }, + { + "gameMode": "Counter Strike 2 (Perfect World)", + "id": "cs2_perfect_world", + "displayName": "CS2 Perfect World", + "appId": 730, + "keywordFilterMode": "include", + "keywords": [ "China" ], + "clusterKeywords": [ "Tencent", "Alibaba", "Perfect World" ] + }, + { + "gameMode": "Deadlock", + "id": "deadlock", + "displayName": "Deadlock", + "appId": 1422450, + "keywordFilterMode": "none", + "keywords": [], + "clusterKeywords": [ "China", "Hong Kong", "Sweden", "India", "Netherlands" ] + }, + { + "gameMode": "Marathon", + "id": "marathon", + "displayName": "Marathon", + "appId": 3065800, + "keywordFilterMode": "none", + "keywords": [], + "clusterKeywords": [ "Hong Kong", "Sweden", "India", "Netherlands" ] + }, + { + "gameMode": "THE FINALS", + "id": "the_finals", + "displayName": "THE FINALS", + "appId": 2073850, + "keywordFilterMode": "none", + "keywords": [], + "clusterKeywords": [ "Sweden", "India", "Washington" ] + } +] \ No newline at end of file diff --git a/ServerPickerX/ServerPickerX.csproj b/ServerPickerX/ServerPickerX.csproj index e30a521..cd1f1d1 100644 --- a/ServerPickerX/ServerPickerX.csproj +++ b/ServerPickerX/ServerPickerX.csproj @@ -31,7 +31,6 @@ - libs\Interop.NetFwTypeLib.dll diff --git a/ServerPickerX/Services/Servers/CS2ServerDataService.cs b/ServerPickerX/Services/Servers/CS2ServerDataService.cs deleted file mode 100644 index e29bc45..0000000 --- a/ServerPickerX/Services/Servers/CS2ServerDataService.cs +++ /dev/null @@ -1,150 +0,0 @@ -using ServerPickerX.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using ServerPickerX.Services.Loggers; -using ServerPickerX.Services.MessageBoxes; - -namespace ServerPickerX.Services.Servers -{ - public class CS2ServerDataService( - ILoggerService _logger, - IMessageBoxService _messageBoxService, - HttpClient _httpClient - ) : IServerDataService - { - private ServerData _serverData = new(); - - public async Task LoadServersAsync() - { - try - { - var response = await _httpClient.GetAsync("https://api.steampowered.com/ISteamApps/GetSDRConfig/v1/?appid=730"); - - if (!response.IsSuccessStatusCode) - { - throw new Exception( - "Failed to load servers!" + Environment.NewLine + Environment.NewLine + - "- Verify your internet connection or firewall are working and enabled" + Environment.NewLine + - "- Make sure to run the app as admin or with sudo level execution" - ); - } - - using var stream = await response.Content.ReadAsStreamAsync(); - var mainJson = await JsonNode.ParseAsync(stream) as JsonObject; - - if (mainJson?["revision"] == null || mainJson?["pops"] == null) - { - throw new Exception("Server relay data unavailable. Please try again later."); - } - - string revision = mainJson["revision"]!.ToString(); - - _serverData.Revision = revision; - - ProcessServers(mainJson, _serverData); - } - catch (Exception ex) - { - await _logger.LogErrorAsync("Failed to load cs2 servers", ex.Message); - - await _messageBoxService.ShowMessageBoxAsync("Error", ex.Message); - - return false; - } - - return true; - } - - private void ProcessServers(JsonObject mainJson, ServerData serverData) - { - var unclusteredServers = new List(); - var clusteredServers = new List(); - - foreach (KeyValuePair server in (JsonObject)mainJson["pops"]!) - { - if (server.Value?["relays"] == null) - { - continue; - } - - string serverDescription = server.Value["desc"]!.ToString(); - var excludedServerKeyword = GetExcludedServerKeywords().FirstOrDefault(keyword => serverDescription.Contains(keyword), ""); - - // Skip processing the server that contains the excluded keyword - if (!string.IsNullOrEmpty(excludedServerKeyword)) - { - continue; - } - - var serverModel = new ServerModel - { - Flag = "/Assets/flags/" + serverDescription + $" ({server.Key}).png", - Name = server.Key, - Description = serverDescription, - }; - - foreach (JsonObject? relay in (JsonArray)server.Value["relays"]!) - { - serverModel.RelayModels.Add(new RelayModel - { - IPv4 = relay!["ipv4"]?.ToString() ?? "" - }); - } - - unclusteredServers.Add(serverModel); - - string clusterName = GetClusterKeywords().FirstOrDefault(keyword => serverDescription.Contains(keyword), ""); - if (!string.IsNullOrEmpty(clusterName)) - { - var clusteredServer = clusteredServers.FirstOrDefault(s => s.Description == clusterName, new ServerModel()); - - clusteredServer.RelayModels.AddRange(serverModel.RelayModels); - - // Initialize a server cluster where relay addresses will be appended - if (string.IsNullOrEmpty(clusteredServer.Description)) - { - clusteredServer.Flag = serverModel.Flag; - clusteredServer.Name = "cluster"; - clusteredServer.Description = clusterName; - - clusteredServers.Add(clusteredServer); - } - } - else - { - clusteredServers.Add(serverModel); - } - } - - serverData.UnclusteredServers = unclusteredServers; - serverData.ClusteredServers = clusteredServers; - } - - public string GetFetchedRevision() - { - return _serverData.Revision; - } - - public ServerData GetServerData() - { - return _serverData; - } - - public List GetClusterKeywords() - { - return - [ - "Hong Kong", "Sweden", "India", "Netherlands" - ]; - } - - private List GetExcludedServerKeywords() - { - return ["China"]; - } - } -} diff --git a/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs b/ServerPickerX/Services/Servers/ConfiguredServerDataService.cs similarity index 81% rename from ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs rename to ServerPickerX/Services/Servers/ConfiguredServerDataService.cs index b4a56b3..47d571d 100644 --- a/ServerPickerX/Services/Servers/CS2PerfectWorldServerDataService.cs +++ b/ServerPickerX/Services/Servers/ConfiguredServerDataService.cs @@ -1,16 +1,17 @@ using ServerPickerX.Models; +using ServerPickerX.Services.Loggers; +using ServerPickerX.Services.MessageBoxes; using System; using System.Collections.Generic; using System.Linq; using System.Net.Http; using System.Text.Json.Nodes; using System.Threading.Tasks; -using ServerPickerX.Services.Loggers; -using ServerPickerX.Services.MessageBoxes; namespace ServerPickerX.Services.Servers { - public class CS2PerfectWorldServerDataService( + public class ConfiguredServerDataService( + ServerDefinition _serverDefinition, ILoggerService _logger, IMessageBoxService _messageBoxService, HttpClient _httpClient @@ -22,7 +23,7 @@ public async Task LoadServersAsync() { try { - var response = await _httpClient.GetAsync("https://api.steampowered.com/ISteamApps/GetSDRConfig/v1/?appid=730"); + var response = await _httpClient.GetAsync(string.Format(_serverDefinition.ResponseUrlTemplate, _serverDefinition.AppId)); if (!response.IsSuccessStatusCode) { @@ -42,15 +43,14 @@ public async Task LoadServersAsync() } string revision = mainJson["revision"]!.ToString(); - + _serverData.Revision = revision; ProcessServers(mainJson, _serverData); } catch (Exception ex) { - await _logger.LogErrorAsync("Failed to load cs2 perfect world servers", ex.Message); - + await _logger.LogErrorAsync($"Failed to load {_serverDefinition.DisplayName ?? _serverDefinition.Id} servers", ex.Message); await _messageBoxService.ShowMessageBoxAsync("Error", ex.Message); return false; @@ -72,11 +72,8 @@ private void ProcessServers(JsonObject mainJson, ServerData serverData) } string serverDescription = server.Value["desc"]!.ToString(); - var whiteListedServerKeyword = GetWhiteListedServerKeywords() - .FirstOrDefault(keyword => serverDescription.Contains(keyword), ""); - // Skip processing the server that doesn't contain the whitelisted keyword - if (string.IsNullOrEmpty(whiteListedServerKeyword)) + if (!IsServerAccepted(serverDescription)) { continue; } @@ -105,7 +102,6 @@ private void ProcessServers(JsonObject mainJson, ServerData serverData) clusteredServer.RelayModels.AddRange(serverModel.RelayModels); - // Initialize a server cluster where relay addresses will be appended if (string.IsNullOrEmpty(clusteredServer.Description)) { clusteredServer.Flag = serverModel.Flag; @@ -135,17 +131,24 @@ public ServerData GetServerData() return _serverData; } - public List GetClusterKeywords() + public bool IsServerAccepted(string serverDescription) { - return - [ - "Tencent", "Alibaba", "Perfect World" - ]; + if (_serverDefinition.KeywordFilterMode?.ToLower() == "include") + { + return _serverDefinition.Keywords.Any(k => serverDescription.Contains(k)); + } + + if (_serverDefinition.KeywordFilterMode?.ToLower() == "exclude") + { + return !_serverDefinition.Keywords.Any(k => serverDescription.Contains(k)); + } + + return true; } - private List GetWhiteListedServerKeywords() + public List GetClusterKeywords() { - return ["China"]; + return _serverDefinition.ClusterKeywords ?? []; } } } diff --git a/ServerPickerX/Services/Servers/DeadLockServerDataService.cs b/ServerPickerX/Services/Servers/DeadLockServerDataService.cs deleted file mode 100644 index d17beaf..0000000 --- a/ServerPickerX/Services/Servers/DeadLockServerDataService.cs +++ /dev/null @@ -1,137 +0,0 @@ -using ServerPickerX.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using ServerPickerX.Services.Loggers; -using ServerPickerX.Services.MessageBoxes; - -namespace ServerPickerX.Services.Servers -{ - public class DeadLockServerDataService( - ILoggerService _logger, - IMessageBoxService _messageBoxService, - HttpClient _httpClient - ) : IServerDataService - { - private ServerData _serverData = new(); - - public async Task LoadServersAsync() - { - try - { - var response = await _httpClient.GetAsync("https://api.steampowered.com/ISteamApps/GetSDRConfig/v1/?appid=1422450"); - - if (!response.IsSuccessStatusCode) - { - throw new Exception( - "Failed to load servers!" + Environment.NewLine + Environment.NewLine + - "- Verify your internet connection or firewall is enabled and working properly" + Environment.NewLine + - "- Make sure to run the app as admin or with sudo access" - ); - } - - using var stream = await response.Content.ReadAsStreamAsync(); - var mainJson = await JsonNode.ParseAsync(stream) as JsonObject; - - if (mainJson?["revision"] == null || mainJson?["pops"] == null) - { - throw new Exception("Server relay data unavailable. Please try again later."); - } - - string revision = mainJson["revision"]!.ToString(); - - _serverData.Revision = revision; - - ProcessServers(mainJson, _serverData); - } - catch (Exception ex) - { - await _logger.LogErrorAsync("Failed to load deadlock servers", ex.Message); - - await _messageBoxService.ShowMessageBoxAsync("Error", ex.Message); - - return false; - } - - return true; - } - - private void ProcessServers(JsonObject mainJson, ServerData serverData) - { - var unclusteredServers = new List(); - var clusteredServers = new List(); - - foreach (KeyValuePair server in (JsonObject)mainJson["pops"]!) - { - if (server.Value?["relays"] == null) - { - continue; - } - - string serverDescription = server.Value["desc"]!.ToString(); - - var serverModel = new ServerModel - { - Flag = "/Assets/flags/" + serverDescription + $" ({server.Key}).png", - Name = server.Key, - Description = serverDescription, - }; - - foreach (JsonObject? relay in (JsonArray)server.Value["relays"]!) - { - serverModel.RelayModels.Add(new RelayModel - { - IPv4 = relay!["ipv4"]?.ToString() ?? "" - }); - } - - unclusteredServers.Add(serverModel); - - string clusterName = GetClusterKeywords().FirstOrDefault(keyword => serverDescription.Contains(keyword), ""); - if (!string.IsNullOrEmpty(clusterName)) - { - var clusteredServer = clusteredServers.FirstOrDefault(s => s.Description == clusterName, new ServerModel()); - - clusteredServer.RelayModels.AddRange(serverModel.RelayModels); - - if (string.IsNullOrEmpty(clusteredServer.Description)) - { - clusteredServer.Flag = serverModel.Flag; - clusteredServer.Name = "cluster"; - clusteredServer.Description = clusterName; - - clusteredServers.Add(clusteredServer); - } - } - else - { - clusteredServers.Add(serverModel); - } - } - - serverData.UnclusteredServers = unclusteredServers; - serverData.ClusteredServers = clusteredServers; - } - - public string GetFetchedRevision() - { - return _serverData.Revision; - } - - public ServerData GetServerData() - { - return _serverData; - } - - public List GetClusterKeywords() - { - return new List - { - "China", "Hong Kong", "Sweden", "India", "Netherlands" - }; - } - } -} diff --git a/ServerPickerX/Services/Servers/MarathonServerDataService.cs b/ServerPickerX/Services/Servers/MarathonServerDataService.cs deleted file mode 100644 index a8cb480..0000000 --- a/ServerPickerX/Services/Servers/MarathonServerDataService.cs +++ /dev/null @@ -1,138 +0,0 @@ -using ServerPickerX.Models; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Net.Http; -using System.Text.Json.Nodes; -using System.Threading.Tasks; -using ServerPickerX.Services.Loggers; -using ServerPickerX.Services.MessageBoxes; - -namespace ServerPickerX.Services.Servers -{ - public class MarathonServerDataService( - ILoggerService _logger, - IMessageBoxService _messageBoxService, - HttpClient _httpClient - ) : IServerDataService - { - private ServerData _serverData = new(); - - public async Task LoadServersAsync() - { - try - { - var response = await _httpClient.GetAsync("https://api.steampowered.com/ISteamApps/GetSDRConfig/v1/?appid=3065800"); - - if (!response.IsSuccessStatusCode) - { - throw new Exception( - "Failed to load servers!" + Environment.NewLine + Environment.NewLine + - "- Verify your internet connection or firewall are working and enabled" + Environment.NewLine + - "- Make sure to run the app as admin or with sudo level execution" - ); - } - - using var stream = await response.Content.ReadAsStreamAsync(); - var mainJson = await JsonNode.ParseAsync(stream) as JsonObject; - - if (mainJson?["revision"] == null || mainJson?["pops"] == null) - { - throw new Exception("Server relay data unavailable. Please try again later."); - } - - string revision = mainJson["revision"]!.ToString(); - - _serverData.Revision = revision; - - ProcessServers(mainJson, _serverData); - } - catch (Exception ex) - { - await _logger.LogErrorAsync("Failed to load marathon servers", ex.Message); - - await _messageBoxService.ShowMessageBoxAsync("Error", ex.Message); - - return false; - } - - return true; - } - - private void ProcessServers(JsonObject mainJson, ServerData serverData) - { - var unclusteredServers = new List(); - var clusteredServers = new List(); - - foreach (KeyValuePair server in (JsonObject)mainJson["pops"]!) - { - if (server.Value?["relays"] == null) - { - continue; - } - - string serverDescription = server.Value["desc"]!.ToString(); - - var serverModel = new ServerModel - { - Flag = "/Assets/flags/" + serverDescription + $" ({server.Key}).png", - Name = server.Key, - Description = serverDescription, - }; - - foreach (JsonObject? relay in (JsonArray)server.Value["relays"]!) - { - serverModel.RelayModels.Add(new RelayModel - { - IPv4 = relay!["ipv4"]?.ToString() ?? "" - }); - } - - unclusteredServers.Add(serverModel); - - string clusterName = GetClusterKeywords().FirstOrDefault(keyword => serverDescription.Contains(keyword), ""); - if (!string.IsNullOrEmpty(clusterName)) - { - var clusteredServer = clusteredServers.FirstOrDefault(s => s.Description == clusterName, new ServerModel()); - - clusteredServer.RelayModels.AddRange(serverModel.RelayModels); - - // Initialize a server cluster where relay addresses will be appended - if (string.IsNullOrEmpty(clusteredServer.Description)) - { - clusteredServer.Flag = serverModel.Flag; - clusteredServer.Name = "cluster"; - clusteredServer.Description = clusterName; - - clusteredServers.Add(clusteredServer); - } - } - else - { - clusteredServers.Add(serverModel); - } - } - - serverData.UnclusteredServers = unclusteredServers; - serverData.ClusteredServers = clusteredServers; - } - - public string GetFetchedRevision() - { - return _serverData.Revision; - } - - public ServerData GetServerData() - { - return _serverData; - } - - public List GetClusterKeywords() - { - return - [ - "Hong Kong", "Sweden", "India", "Netherlands" - ]; - } - } -} diff --git a/ServerPickerX/Services/Servers/ServerData.cs b/ServerPickerX/Services/Servers/ServerData.cs deleted file mode 100644 index 7d4807d..0000000 --- a/ServerPickerX/Services/Servers/ServerData.cs +++ /dev/null @@ -1,12 +0,0 @@ -using ServerPickerX.Models; -using System.Collections.Generic; - -namespace ServerPickerX.Services.Servers -{ - public class ServerData - { - public string Revision { get; set; } = string.Empty; - public List UnclusteredServers { get; set; } = []; - public List ClusteredServers { get; set; } = []; - } -} \ No newline at end of file diff --git a/ServerPickerX/Services/Servers/ServerDefinition.cs b/ServerPickerX/Services/Servers/ServerDefinition.cs new file mode 100644 index 0000000..80a442a --- /dev/null +++ b/ServerPickerX/Services/Servers/ServerDefinition.cs @@ -0,0 +1,24 @@ +using ServerPickerX.Models; +using System.Collections.Generic; + +namespace ServerPickerX.Services.Servers +{ + public class ServerDefinition + { + public string GameMode { get; set; } = ""; + public string Id { get; set; } = ""; + public string DisplayName { get; set; } = ""; + public int AppId { get; set; } + public string KeywordFilterMode { get; set; } = "none"; + public List Keywords { get; set; } = []; + public List ClusterKeywords { get; set; } = []; + public string ResponseUrlTemplate { get; set; } = "https://api.steampowered.com/ISteamApps/GetSDRConfig/v1/?appid={0}"; + } + public class ServerData + { + public string Revision { get; set; } = string.Empty; + public List UnclusteredServers { get; set; } = []; + public List ClusteredServers { get; set; } = []; + } + +} \ No newline at end of file diff --git a/ServerPickerX/Services/Servers/ServerDefinitionProvider.cs b/ServerPickerX/Services/Servers/ServerDefinitionProvider.cs new file mode 100644 index 0000000..6feef3f --- /dev/null +++ b/ServerPickerX/Services/Servers/ServerDefinitionProvider.cs @@ -0,0 +1,144 @@ +using ServerPickerX.Services.Loggers; +using ServerPickerX.Services.MessageBoxes; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.IO; +using System.Linq; +using System.Text.Json; + +namespace ServerPickerX.Services.Servers +{ + public class ServerDefinitionProvider + { + private readonly List _serverDefinitions = []; + private readonly JsonSerializerOptions _jsonDeserializerOptions = new() + { + PropertyNameCaseInsensitive = true, + AllowTrailingCommas = true, + }; + private readonly JsonSerializerOptions _jsonSerializerOptions = new() + { + WriteIndented = true, + AllowTrailingCommas = true, + }; + + [RequiresUnreferencedCode(message: "Deserialization involves reflection which may be trimmed away.")] + public ServerDefinitionProvider(ILoggerService _loggerService) + { + var path = ""; + try + { + path = Path.Combine(AppContext.BaseDirectory, "ServerDefinitions.json"); + + if (!File.Exists(path)) + { + var defaults = CreateDefaultServerDefinitions(); + string serializedJson = JsonSerializer.Serialize(defaults, _jsonSerializerOptions); + + File.WriteAllText(path, serializedJson); + } + } + catch (Exception ex) + { + _loggerService.LogErrorAsync(ex.Message); + + throw; + } + + var json = File.ReadAllText(path); + + var serverDefinitions = JsonSerializer.Deserialize>(json, _jsonDeserializerOptions); + + _serverDefinitions = serverDefinitions ?? []; + } + + public IReadOnlyList GetDefinitions() => _serverDefinitions.AsReadOnly(); + + public IReadOnlyList GetGameModes() + { + return _serverDefinitions + .Select(definition => definition.GameMode) + .Where(gameMode => !string.IsNullOrWhiteSpace(gameMode)) + .ToList() + .AsReadOnly(); + } + + public ServerDefinition? GetServerDefinitionByGameMode(string gameMode) + { + return _serverDefinitions + .FirstOrDefault( + definition => definition.GameMode.Equals(gameMode, StringComparison.OrdinalIgnoreCase) + ); + } + + public string GetAppIdByGameMode(string gameMode) + { + ServerDefinition? definition = GetServerDefinitionByGameMode(gameMode); + + if (definition == null) + { + throw new InvalidOperationException($"Unsupported game mode: {gameMode}"); + } + + return definition.AppId.ToString(); + } + + public IReadOnlyList GetGameModesByAppId(string appId) + { + return _serverDefinitions + .Where(definition => definition.AppId.ToString() == appId) + .Select(definition => definition.GameMode) + .Where(gameMode => !string.IsNullOrWhiteSpace(gameMode)) + .ToList() + .AsReadOnly(); + } + + private List CreateDefaultServerDefinitions() + { + return + [ + new ServerDefinition + { + GameMode = "Counter Strike 2", + Id = "cs2", + DisplayName = "CS2", + AppId = 730, + KeywordFilterMode = "exclude", + Keywords = ["China"], + ClusterKeywords = ["Hong Kong", "Sweden", "India", "Netherlands"] + }, + new ServerDefinition + { + GameMode = "Counter Strike 2 (Perfect World)", + Id = "cs2_perfect_world", + DisplayName = "CS2 Perfect World", + AppId = 730, + KeywordFilterMode = "include", + Keywords = ["China"], + ClusterKeywords = ["Tencent", "Alibaba", "Perfect World"] + }, + new ServerDefinition + { + GameMode = "Deadlock", + Id = "deadlock", + DisplayName = "Deadlock", + AppId = 1422450, + KeywordFilterMode = "none", + Keywords = [], + ClusterKeywords = ["China", "Hong Kong", "Sweden", "India", "Netherlands"] + }, + new ServerDefinition + { + GameMode = "Marathon", + Id = "marathon", + DisplayName = "Marathon", + AppId = 3065800, + KeywordFilterMode = "none", + Keywords = [], + ClusterKeywords = ["Hong Kong", "Sweden", "India", "Netherlands"] + } + ]; + } + } +} diff --git a/ServerPickerX/Services/Settings/JsonSetting.cs b/ServerPickerX/Services/Settings/JsonSetting.cs index 589dc86..c01d2a5 100644 --- a/ServerPickerX/Services/Settings/JsonSetting.cs +++ b/ServerPickerX/Services/Settings/JsonSetting.cs @@ -1,25 +1,21 @@ -using ServerPickerX.Constants; -using ServerPickerX.Helpers; using ServerPickerX.Models; using ServerPickerX.Services.DependencyInjection; using ServerPickerX.Services.Loggers; using ServerPickerX.Services.MessageBoxes; +using ServerPickerX.Services.Servers; using ServerPickerX.Services.Settings; using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.IO; using System.Linq; -using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using System.Threading.Tasks; namespace ServerPickerX.Settings { - // Publishing an app with trimmed assemblies or using AOT compilation for reduced - // build size can limit the serialization functionality since it requires reflection - // to determine dynamic types on runtime which is not possible with trimmed or AOT apps. + // Publishing an app with trimmed assemblies or using AOT compilation for reduced build size + // can break serialization due to limitations when using Reflection which analyzes dynamic types on runtime. // JsonSerializerContext preserves the types and provides serialization metadata on compile-time. [JsonSerializable(typeof(JsonSetting))] internal partial class SourceGenerationContext : JsonSerializerContext { } @@ -33,11 +29,7 @@ public class JsonSetting : ISetting public virtual string language { set; get; } = "English | en-us"; - public virtual string cs2_server_revision { get; set; } = "-1"; - - public virtual string deadlock_server_revision { get; set; } = "-1"; - - public virtual string marathon_server_revision { get; set; } = "-1"; + public virtual Dictionary server_revisions { get; set; } = new(StringComparer.OrdinalIgnoreCase); public virtual bool is_clustered { get; set; } = false; @@ -97,9 +89,9 @@ public async Task LoadSettingsAsync() game_mode = localSettings.game_mode; language = localSettings.language; - cs2_server_revision = localSettings.cs2_server_revision; - deadlock_server_revision = localSettings.deadlock_server_revision; - marathon_server_revision = localSettings.marathon_server_revision; + server_revisions = localSettings.server_revisions != null + ? new Dictionary(localSettings.server_revisions, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); is_clustered = localSettings.is_clustered; version_check_on_startup = localSettings.version_check_on_startup; server_presets = localSettings.server_presets ?? []; @@ -146,14 +138,12 @@ public async Task GetRevisionByGameModeAsync() { try { - return this.game_mode switch - { - GameModes.CounterStrike2 or GameModes.CounterStrike2PerfectWorld => this.cs2_server_revision, - GameModes.Deadlock => this.deadlock_server_revision, - GameModes.Marathon => this.marathon_server_revision, - _ => throw new NotSupportedException($"Unsupported game mode: {this.game_mode}"), - }; - } catch (NotSupportedException ex) { + string appId = GetCurrentAppId(); + + return server_revisions.TryGetValue(appId, out string? revision) + ? revision + : "-1"; + } catch (InvalidOperationException ex) { await _loggerService.LogErrorAsync("An error has occured while getting server revision by current game mode", ex.Message); throw; @@ -164,24 +154,12 @@ public async Task SetRevisionByGameModeAsync(string revision) { try { - switch (this.game_mode) - { - case GameModes.CounterStrike2 or GameModes.CounterStrike2PerfectWorld: - this.cs2_server_revision = revision; - break; - case GameModes.Deadlock: - this.deadlock_server_revision = revision; - break; - case GameModes.Marathon: - this.marathon_server_revision = revision; - break; - default: - throw new NotSupportedException($"Unsupported game mode: {this.game_mode}"); - }; + string appId = GetCurrentAppId(); + server_revisions[appId] = revision; await this.SaveSettingsAsync(); } - catch (NotSupportedException ex) + catch (InvalidOperationException ex) { await _loggerService.LogErrorAsync("An error has occured while setting server revision by current game mode", ex.Message); @@ -189,6 +167,14 @@ public async Task SetRevisionByGameModeAsync(string revision) } } + private string GetCurrentAppId() + { + ServerDefinitionProvider serverDefinitionProvider = + ServiceLocator.GetRequiredService(); + + return serverDefinitionProvider.GetAppIdByGameMode(this.game_mode); + } + public async Task SetGameModeAsync(string gameMode) { this.game_mode = gameMode; diff --git a/ServerPickerX/ViewModels/MainWindowViewModel.cs b/ServerPickerX/ViewModels/MainWindowViewModel.cs index d5f63d6..26fe4fa 100644 --- a/ServerPickerX/ViewModels/MainWindowViewModel.cs +++ b/ServerPickerX/ViewModels/MainWindowViewModel.cs @@ -1,7 +1,6 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using MsBox.Avalonia.Enums; -using ServerPickerX.Constants; using ServerPickerX.Extensions; using ServerPickerX.Models; using ServerPickerX.Services.DependencyInjection; @@ -16,6 +15,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.Linq; +using System.Net.Http; using System.Threading.Tasks; namespace ServerPickerX.ViewModels @@ -437,42 +437,50 @@ public async Task PruneCurrentGamePresetEntriesAsync() return await PrunePresetEntriesAsync(_jsonSetting.game_mode, _serverDataService.GetServerData()); } - public async Task PruneCounterStrikeFamilyPresetEntriesAsync() + public async Task PruneRelatedGamePresetEntriesAsync() { - // CS2 and Perfect World share one revision bucket, but their filtered server sets differ. - // When either mode syncs, prune the sibling mode too before marking the shared revision current. - if (_jsonSetting.game_mode == GameModes.CounterStrike2) + ServerDefinitionProvider serverDefinitionProvider = + ServiceLocator.GetRequiredService(); + + string appId = serverDefinitionProvider.GetAppIdByGameMode(_jsonSetting.game_mode); + + IReadOnlyList relatedGameModes = serverDefinitionProvider.GetGameModesByAppId(appId); + + foreach (string relatedGameMode in relatedGameModes.Where(gameMode => + !gameMode.Equals(_jsonSetting.game_mode, StringComparison.OrdinalIgnoreCase))) { - CS2PerfectWorldServerDataService perfectWorldServerDataService = - ServiceLocator.GetRequiredService(); + IServerDataService relatedServerDataService = CreateConfiguredServerDataService(relatedGameMode); - if (!await perfectWorldServerDataService.LoadServersAsync()) + if (!await relatedServerDataService.LoadServersAsync()) { return false; } - await PrunePresetEntriesAsync( - GameModes.CounterStrike2PerfectWorld, - perfectWorldServerDataService.GetServerData() - ); - - return true; + await PrunePresetEntriesAsync(relatedGameMode, relatedServerDataService.GetServerData()); } - CS2ServerDataService counterStrikeServerDataService = - ServiceLocator.GetRequiredService(); + return true; + } + + private IServerDataService CreateConfiguredServerDataService(string gameMode) + { + ServerDefinitionProvider serverDefinitionProvider = + ServiceLocator.GetRequiredService(); + ServerDefinition? serverDefinition = serverDefinitionProvider.GetServerDefinitionByGameMode(gameMode); - if (!await counterStrikeServerDataService.LoadServersAsync()) + if (serverDefinition == null) { - return false; + throw new InvalidOperationException($"Unsupported game mode: {gameMode}"); } - await PrunePresetEntriesAsync( - GameModes.CounterStrike2, - counterStrikeServerDataService.GetServerData() - ); + HttpClient httpClient = ServiceLocator.GetRequiredService(); - return true; + return new ConfiguredServerDataService( + serverDefinition, + _loggerService, + _messageBoxService, + httpClient + ); } public async Task PrunePresetEntriesAsync(string gameMode, ServerData serverData) diff --git a/ServerPickerX/Views/MainWindow.axaml b/ServerPickerX/Views/MainWindow.axaml index ccb08d9..3e747da 100644 --- a/ServerPickerX/Views/MainWindow.axaml +++ b/ServerPickerX/Views/MainWindow.axaml @@ -5,7 +5,6 @@ xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:converters="clr-namespace:ServerPickerX.Converters" xmlns:userControls="using:ServerPickerX" - xmlns:constants="clr-namespace:ServerPickerX.Constants" xmlns:i="https://github.com/projektanker/icons.avalonia" mc:Ignorable="d" x:Class="ServerPickerX.Views.MainWindow" @@ -97,11 +96,9 @@ x:Name="GameModeComboBox" Classes="game-combobox" SelectionChanged="GameModeComboBox_SelectionChanged" - ItemsSource="{Binding Source={x:Static constants:GameModes.All}}" IsEnabled="{Binding IsOperationAllowed}" ToolTip.Tip="{DynamicResource GameModeToolTip}" ToolTip.ShowDelay="20" - SelectedIndex="0" FontSize="12" VerticalAlignment="Center" Width="158" diff --git a/ServerPickerX/Views/MainWindow.axaml.cs b/ServerPickerX/Views/MainWindow.axaml.cs index e45d5ef..8a7a69a 100644 --- a/ServerPickerX/Views/MainWindow.axaml.cs +++ b/ServerPickerX/Views/MainWindow.axaml.cs @@ -2,17 +2,19 @@ using Avalonia.Controls; using Avalonia.Markup.Xaml; using ServerPickerX.Comparers; -using ServerPickerX.Constants; using ServerPickerX.Models; using ServerPickerX.Services.DependencyInjection; using ServerPickerX.Services.Localizations; using ServerPickerX.Services.Loggers; using ServerPickerX.Services.MessageBoxes; +using ServerPickerX.Services.Servers; using ServerPickerX.Services.Versions; using ServerPickerX.Settings; using ServerPickerX.ViewModels; using System; +using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Threading.Tasks; namespace ServerPickerX.Views @@ -43,6 +45,7 @@ public static bool IsDebugBuild private readonly IMessageBoxService _messageBoxService; private readonly IVersionService _versionService; private readonly ILocalizationService _localizationService; + private readonly ServerDefinitionProvider _serverDefinitionProvider; // Parameterless constructor, allows design previewer to create its own instance since it doesn't support DI public MainWindow() @@ -55,6 +58,7 @@ public MainWindow() _messageBoxService = ServiceLocator.GetRequiredService(); _versionService = ServiceLocator.GetRequiredService(); _localizationService = ServiceLocator.GetRequiredService(); + _serverDefinitionProvider = ServiceLocator.GetRequiredService(); } // DI constructor, allows inversion of control and unit tests mocking @@ -74,6 +78,7 @@ ILocalizationService localizationService _versionService = versionService; _jsonSetting = jsonSetting; _localizationService = localizationService; + _serverDefinitionProvider = ServiceLocator.GetRequiredService(); } private async void Window_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -210,22 +215,24 @@ private async Task ConfigureControls() { try { - switch (_jsonSetting.game_mode) + IReadOnlyList gameModes = _serverDefinitionProvider.GetGameModes(); + + if (gameModes.Count == 0) { - case GameModes.CounterStrike2 or GameModes.CounterStrike2PerfectWorld: - GameModeComboBox.SelectedIndex = !_jsonSetting.game_mode.Contains("Perfect") ? 0 : 1; - break; - case GameModes.Deadlock: - GameModeComboBox.SelectedIndex = 2; - break; - case GameModes.Marathon: - GameModeComboBox.SelectedIndex = 3; - break; - default: - throw new NotSupportedException($"Unsupported game mode: {_jsonSetting.game_mode}"); - }; + throw new InvalidOperationException("No server definitions were found."); + } + + if (!gameModes.Contains(_jsonSetting.game_mode, StringComparer.OrdinalIgnoreCase)) + { + await _jsonSetting.SetGameModeAsync(gameModes[0]); + } + + GameModeComboBox.SelectionChanged -= GameModeComboBox_SelectionChanged; + GameModeComboBox.ItemsSource = gameModes; + GameModeComboBox.SelectedItem = _jsonSetting.game_mode; + GameModeComboBox.SelectionChanged += GameModeComboBox_SelectionChanged; } - catch (NotSupportedException ex) + catch (InvalidOperationException ex) { await _loggerService.LogErrorAsync("An error has occured while setting game mode combo box", ex.Message); @@ -246,12 +253,13 @@ private async Task SyncServersAsync(MainWindowViewModel vm) var fetchedRevision = vm.GetServerDataService().GetFetchedRevision(); - bool isCounterStrikeFamilyGame = _jsonSetting.game_mode is - GameModes.CounterStrike2 or GameModes.CounterStrike2PerfectWorld; - bool hasAffectedPresets = isCounterStrikeFamilyGame - ? _jsonSetting.GetPresetsByGameMode(GameModes.CounterStrike2).Count > 0 || - _jsonSetting.GetPresetsByGameMode(GameModes.CounterStrike2PerfectWorld).Count > 0 - : _jsonSetting.GetPresetsByGameMode(_jsonSetting.game_mode).Count > 0; + string appId = _serverDefinitionProvider.GetAppIdByGameMode(_jsonSetting.game_mode); + + IReadOnlyList affectedGameModes = _serverDefinitionProvider.GetGameModesByAppId(appId); + + bool hasAffectedPresets = affectedGameModes.Any( + gameMode => _jsonSetting.GetPresetsByGameMode(gameMode).Count > 0 + ); // Store the initial revision without a reset when this game has no saved presets yet. if (localRevision == "-1" && !hasAffectedPresets) @@ -282,9 +290,9 @@ await _messageBoxService.ShowMessageBoxAsync( await vm.PruneCurrentGamePresetEntriesAsync(); - if (isCounterStrikeFamilyGame) + if (affectedGameModes.Count > 1) { - if (!await vm.PruneCounterStrikeFamilyPresetEntriesAsync()) + if (!await vm.PruneRelatedGamePresetEntriesAsync()) { return; }