diff --git a/.gitignore b/.gitignore index 9491a2f..384d286 100644 --- a/.gitignore +++ b/.gitignore @@ -360,4 +360,5 @@ MigrationBackup/ .ionide/ # Fody - auto-generated XML schema -FodyWeavers.xsd \ No newline at end of file +FodyWeavers.xsd +/assets diff --git a/LeagueARAMTracker/LeagueARAMTracker.csproj b/ARAMUtility/ARAMUtility.csproj similarity index 84% rename from LeagueARAMTracker/LeagueARAMTracker.csproj rename to ARAMUtility/ARAMUtility.csproj index 974f9ec..1e0554d 100644 --- a/LeagueARAMTracker/LeagueARAMTracker.csproj +++ b/ARAMUtility/ARAMUtility.csproj @@ -3,10 +3,10 @@ WinExe net10.0-windows - enable - true + preview enable - true + enable + true Resource\arac_master.ico @@ -14,22 +14,26 @@ + + + + + Resources.resx True True - Resources.resx - ResXFileCodeGenerator Resources.Designer.cs + ResXFileCodeGenerator diff --git a/ARAMUtility/App.xaml b/ARAMUtility/App.xaml new file mode 100644 index 0000000..4a00487 --- /dev/null +++ b/ARAMUtility/App.xaml @@ -0,0 +1,9 @@ + + + + + diff --git a/ARAMUtility/App.xaml.cs b/ARAMUtility/App.xaml.cs new file mode 100644 index 0000000..3dac228 --- /dev/null +++ b/ARAMUtility/App.xaml.cs @@ -0,0 +1,12 @@ +using System.Configuration; +using System.Data; +using System.Windows; + +namespace ARAMUtility; +/// +/// Interaction logic for App.xaml +/// +public partial class App : Application +{ +} + diff --git a/ARAMUtility/AssemblyInfo.cs b/ARAMUtility/AssemblyInfo.cs new file mode 100644 index 0000000..b0ec827 --- /dev/null +++ b/ARAMUtility/AssemblyInfo.cs @@ -0,0 +1,10 @@ +using System.Windows; + +[assembly: ThemeInfo( + ResourceDictionaryLocation.None, //where theme specific resource dictionaries are located + //(used if a resource is not found in the page, + // or application resource dictionaries) + ResourceDictionaryLocation.SourceAssembly //where the generic resource dictionary is located + //(used if a resource is not found in the page, + // app, or any theme specific resource dictionaries) +)] diff --git a/LeagueARAMTracker/Resource/arac_master.ico b/ARAMUtility/Resource/arac_master.ico similarity index 100% rename from LeagueARAMTracker/Resource/arac_master.ico rename to ARAMUtility/Resource/arac_master.ico diff --git a/LeagueARAMTracker/Resources.Designer.cs b/ARAMUtility/Resources.Designer.cs similarity index 95% rename from LeagueARAMTracker/Resources.Designer.cs rename to ARAMUtility/Resources.Designer.cs index 127fd63..30435ab 100644 --- a/LeagueARAMTracker/Resources.Designer.cs +++ b/ARAMUtility/Resources.Designer.cs @@ -8,7 +8,7 @@ // //------------------------------------------------------------------------------ -namespace LeagueARAMTracker { +namespace ARAMUtility { using System; @@ -39,7 +39,7 @@ namespace LeagueARAMTracker { internal static global::System.Resources.ResourceManager ResourceManager { get { if (object.ReferenceEquals(resourceMan, null)) { - global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LeagueARAMTracker.Resources", typeof(Resources).Assembly); + global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("ARAMUtility.Resources", typeof(Resources).Assembly); resourceMan = temp; } return resourceMan; diff --git a/LeagueARAMTracker/Resources.resx b/ARAMUtility/Resources.resx similarity index 100% rename from LeagueARAMTracker/Resources.resx rename to ARAMUtility/Resources.resx diff --git a/ARAMUtility/View/MainWindow.xaml b/ARAMUtility/View/MainWindow.xaml new file mode 100644 index 0000000..e643a50 --- /dev/null +++ b/ARAMUtility/View/MainWindow.xaml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ARAMUtility/View/MainWindow.xaml.cs b/ARAMUtility/View/MainWindow.xaml.cs new file mode 100644 index 0000000..4cda70c --- /dev/null +++ b/ARAMUtility/View/MainWindow.xaml.cs @@ -0,0 +1,20 @@ +using System.Windows; +using ARAMUtility.ViewModel; + +namespace ARAMUtility; +/// +/// Interaction logic for MainWindow.xaml +/// +public partial class MainWindow : Window +{ + public MainWindow() + { + InitializeComponent(); + + if (DataContext is MainViewModel viewModel) + { + Loaded += viewModel.OnInit; + Closing += (sender, e) => { viewModel?.Dispose(); }; + } + } +} diff --git a/ARAMUtility/ViewModel/ChampionViewModel.cs b/ARAMUtility/ViewModel/ChampionViewModel.cs new file mode 100644 index 0000000..e34fd36 --- /dev/null +++ b/ARAMUtility/ViewModel/ChampionViewModel.cs @@ -0,0 +1,70 @@ +using System.Text; +using CommunityToolkit.Mvvm.ComponentModel; +using LeagueAPI.Models.DDragon.Champions; + +namespace ARAMUtility.ViewModel; + +public partial class ChampionViewModel(ChampionData data) : ObservableObject +{ + [ObservableProperty] + public partial int Id { get; set; } = data.Id; + + [ObservableProperty] + public partial string? Name { get; set; } = data.Name; + + [ObservableProperty] + public required partial string ImagePath { get; set; } + + public string AramBalanceText { get; } = GetAramBalanceText(data); + + [ObservableProperty] + public required partial bool IsNeededForChallenge { get; set; } + + private static string GetAramBalanceText(ChampionData data) + { + StringBuilder sb = new(); + sb.AppendLine(data.Name); + foreach (KeyValuePair kv in data.AramBalance) + { + switch (kv.Key) + { + case "dmg_dealt": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Dmg Dealt: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "dmg_taken": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Dmg Taken: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "healing": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Healing: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "energyregen_mod": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Energy Regen: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "tenacity": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Tenacity: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "shielding": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Shielding: {0:+#0%;-#0%}", kv.Value - 1); + break; + case "ability_haste": + sb.AppendFormat("Ability Haste: {0}", kv.Value); + break; + case "total_as": + if (kv.Value == 1) { continue; } + sb.AppendFormat("Total AS: {0}", kv.Value); + break; + default: + sb.AppendFormat("{0}: {1}", kv.Key, kv.Value); + break; + } + sb.AppendLine(); + } + return sb.ToString(); + } +} diff --git a/ARAMUtility/ViewModel/MainViewModel.cs b/ARAMUtility/ViewModel/MainViewModel.cs new file mode 100644 index 0000000..6a4559b --- /dev/null +++ b/ARAMUtility/ViewModel/MainViewModel.cs @@ -0,0 +1,239 @@ +using System.Collections.ObjectModel; +using System.IO; +using System.Text.Json; +using System.Windows; +using System.Windows.Threading; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using LeagueAPI; +using LeagueAPI.ARAM; +using LeagueAPI.Models.ChampSelect; +using LeagueAPI.Models.DDragon.Champions; +using LeagueAPI.Models.ReadyCheck; + +namespace ARAMUtility.ViewModel; + +public partial class MainViewModel : ObservableObject, IDisposable +{ + private const int TEAM_CHAMPIONS_MAX = 5; + private const int BENCH_CHAMPIONS_MAX = 10; + + private bool _isDisposed; + private readonly Lock _syncRoot = new(); + private readonly SemaphoreSlim _championUpdateSemaphore = new(1, 1); + + private Dispatcher Dispatcher { get; } = Dispatcher.CurrentDispatcher; + + [ObservableProperty] + public partial string Title { get; set; } = "ARAM Utility"; + + [ObservableProperty] + public partial ObservableCollection TeamChampions { get; private set; } = []; + + [ObservableProperty] + public partial ObservableCollection BenchChampions { get; private set; } = []; + + [ObservableProperty] + public partial bool IsDisconnected { get; private set; } = false; + + [ObservableProperty] + public partial string ConnectionStatus { get; private set; } = "Not connected."; + + private Dictionary _allChampions = []; + private List _needChampionIds = []; + private readonly APIClient _client = new(); + private readonly LcuWebsocket _lcuWebsocket = new(); + private Task _lcuWebsocketTask; + private readonly ARAMBalanceService _aramBalanceService = new(); + + public MainViewModel() + { + _lcuWebsocket.Connecting += (_, _) => UpdateConnectionStatus(false, "Connecting ..."); + _lcuWebsocket.Connected += (_, _) => UpdateConnectionStatus(false, "Connected."); + _lcuWebsocket.Disconnected += (_, _) => UpdateConnectionStatus(true, "Disconnected."); + + _lcuWebsocket.LcuApiEvent += OnLcuApiEvent; +#if DEBUG + _lcuWebsocket.LcuApiException += (sender, e) => + { + File.AppendAllText("socketerror.log", $"[{DateTime.Now:s}] {e.Message}\n{e.StackTrace}\n\n"); + }; +#endif + _lcuWebsocketTask = _lcuWebsocket.Connect(); + } + + internal async void OnInit(object? sender, RoutedEventArgs e) + { + ChampionData[] champions = await _client.GetAllChampionsAsync(); + Dictionary championDictionary = champions + .Where(c => c.Id != -1) + .ToDictionary(key => key.Id); + lock (_syncRoot) + { + _allChampions = championDictionary; + } + await UpdateNeedChampionIdsAsync(); + await FillChampionLists(); + } + + private void UpdateConnectionStatus(bool isConnected, string statusMessage) + { + Dispatcher.Invoke(() => + { + IsDisconnected = isConnected; + ConnectionStatus = statusMessage; + }); + } + + private async void OnLcuApiEvent(object? sender, LcuApiEvent apiEvent) + { + switch (apiEvent.Uri) + { + case "/lol-matchmaking/v1/ready-check": + LolMatchmakingMatchmakingReadyCheckResource? readyCheck = apiEvent.Data.Deserialize(); + if (readyCheck is not null && readyCheck.PlayerResponse == LolMatchmakingMatchmakingReadyCheckResponse.Accepted) + { + await UpdateNeedChampionIdsAsync(); + } + break; + case "/lol-champ-select/v1/session": + ChampSelectSession? session = apiEvent.Data.Deserialize(); + await ShowChampionsAsync(session); + break; + default: + File.AppendAllText("socket.log", $"{apiEvent.Uri}: {apiEvent.EventType} - {apiEvent.Data?.ToJsonString()}\n"); + break; + } + + } + + private async Task UpdateNeedChampionIdsAsync() + { + IEnumerable completedChampionIds = await _client.GetAllRandomAllChampionsCompletedChampionsAsync(); + IEnumerable needChampionIds = _allChampions.Keys.Except(completedChampionIds); + lock (_syncRoot) + { + _needChampionIds = [.. needChampionIds]; + } + } + + private async Task FillChampionLists() + { + string defaultImagePath = await ResourceService.GetChampionIconPathAsync(-1); + while (TeamChampions.Count < TEAM_CHAMPIONS_MAX) + { + TeamChampions.Add(GetEmptyChampionViewModel()); + } + while (BenchChampions.Count < BENCH_CHAMPIONS_MAX) + { + BenchChampions.Add(GetEmptyChampionViewModel()); + } + + ChampionViewModel GetEmptyChampionViewModel() + { + return new ChampionViewModel(new ChampionData()) { ImagePath = defaultImagePath, IsNeededForChallenge = false }; + } + } + + private async Task ShowChampionsAsync(ChampSelectSession? session) + { + await _championUpdateSemaphore.WaitAsync(); + + (IEnumerable teamChampions, IEnumerable benchChampions) = APIClient.GetSelectableChampionIds(session); + if (!teamChampions.Any() && !benchChampions.Any()) + { + TeamChampions.Clear(); + BenchChampions.Clear(); + } + else + { + await UpdateChampions(TeamChampions, teamChampions); + await UpdateChampions(BenchChampions, benchChampions); + } + + await FillChampionLists(); + _championUpdateSemaphore.Release(); + + async Task UpdateChampions(ObservableCollection viewModel, IEnumerable championIds) + { + viewModel.Clear(); + await _aramBalanceService.EnsureIsLoadedAsync(); + + foreach (int championId in championIds) + { + ChampionData? championData = await _client.GetChampionByIdAsync(championId); + if (championData is null) + { + continue; + } + + string imagePath = await ResourceService.GetChampionIconPathAsync(championId); + ChampionViewModel vm = new(championData with { AramBalance = _aramBalanceService.GetAramStats(championData.Id) }) + { + IsNeededForChallenge = _needChampionIds.Contains(championData.Id), + ImagePath = imagePath, + }; + viewModel.Add(vm); + } + } + } + + [RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(IsDisconnected))] + private async Task Connect() + { + if (_lcuWebsocketTask is null || _lcuWebsocketTask.IsCompleted) + { + _lcuWebsocketTask?.Dispose(); + _lcuWebsocketTask = _lcuWebsocket.Connect(); + } + } + + [RelayCommand(AllowConcurrentExecutions = false)] + private async Task OpenLobby() + { + await _client.CreateMayhemLobbyAsync(); + } + + [RelayCommand(AllowConcurrentExecutions = false)] + private async Task StartQueueing() + { + await _client.StartMatchmakingQueueAsync(); + } + + [RelayCommand(AllowConcurrentExecutions = false)] + private async Task ReloadARAMBalance() + { + await _aramBalanceService.ReloadAsync(force: true); + MessageBox.Show("Reloaded ARAM balance data.", "Info", MessageBoxButton.OK, MessageBoxImage.Exclamation); + } + + [RelayCommand] + private async Task Quit() + { + Application.Current.Shutdown(); + } + + #region IDisposable + protected virtual void Dispose(bool disposing) + { + if (!_isDisposed) + { + if (disposing) + { + _allChampions = []; + _client.Dispose(); + _lcuWebsocket.Dispose(); + } + + _isDisposed = true; + } + } + + public void Dispose() + { + // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method + Dispose(disposing: true); + GC.SuppressFinalize(this); + } + #endregion +} diff --git a/LeagueAPI/APIClient.cs b/LeagueAPI/APIClient.cs index d13f100..d8c245e 100644 --- a/LeagueAPI/APIClient.cs +++ b/LeagueAPI/APIClient.cs @@ -1,15 +1,11 @@ using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Serialization; -using System.Text.RegularExpressions; -using CliWrap; -using CliWrap.Buffered; using LeagueAPI.Models.Challenges; using LeagueAPI.Models.ChampSelect; using LeagueAPI.Models.DDragon; using LeagueAPI.Models.DDragon.Champions; using LeagueAPI.Utils; -using static System.Runtime.InteropServices.JavaScript.JSType; namespace LeagueAPI; @@ -23,7 +19,6 @@ public class APIClient : IDisposable private bool _isDisposed; private readonly LcuHttpClient _lcuHttpClient; - private readonly HttpClient _dDragonHttpClient; private string? _latestVersion; @@ -32,7 +27,6 @@ public class APIClient : IDisposable public APIClient() { _lcuHttpClient = new(new LcuHttpClientHandler()); - _dDragonHttpClient = new HttpClient() { BaseAddress = new Uri(DDRAGON_BASE_URL) }; } public async Task> GetAllChallengesAsync() @@ -51,23 +45,23 @@ public class APIClient : IDisposable return allRandomAllChampions.CompletedIds ?? []; } - public async Task GetSelectableChampionIdsAsync() + public async Task<(IEnumerable teamChampions, IEnumerable benchChampions)> GetSelectableChampionIdsAsync() { ChampSelectSession? session = await _lcuHttpClient.GetContentAsync("/lol-champ-select/v1/session"); return GetSelectableChampionIds(session); } - public int[] GetSelectableChampionIds(ChampSelectSession? session) + public static (IEnumerable teamChampions, IEnumerable benchChampions) GetSelectableChampionIds(ChampSelectSession? session) { - if (session is null || !session.BenchEnabled) + if (session is null || !session.BenchEnabled || string.IsNullOrEmpty(session.Id)) { - return []; + return ([], []); } IEnumerable benchChampions = session.BenchChampions.Select(b => b.ChampionId); IEnumerable teamChampions = session.MyTeam.Select(c => c.ChampionId); - return [.. benchChampions, .. teamChampions]; + return (teamChampions, benchChampions); } private record struct LobbyChangeGame([property: JsonPropertyName("queueId")] int QueueId); @@ -87,20 +81,12 @@ public class APIClient : IDisposable } #region DDragon - private async Task DDragonGetAsync(string path) + private static async Task DDragonGetAsync(string path) { - HttpResponseMessage response = await _dDragonHttpClient.GetAsync(path); - if (!response.IsSuccessStatusCode) - { - Command cmd = Cli.Wrap("curl") - .WithArguments($"{DDRAGON_BASE_URL}{path}"); - BufferedCommandResult result = await cmd.ExecuteBufferedAsync(); - return result.IsSuccess ? result.StandardOutput : throw new Exception($"Failed to fetch from Datadragon: {path}"); - } - return await response.Content.ReadAsStringAsync(); + return await CachingHttpClient.GetStringAsync($"{DDRAGON_BASE_URL}{path}"); } - private async Task DDragonGetAsync(string path) + private static async Task DDragonGetAsync(string path) { string json = await DDragonGetAsync(path); return JsonSerializer.Deserialize(json); @@ -159,7 +145,6 @@ public class APIClient : IDisposable if (disposing) { _lcuHttpClient?.Dispose(); - _dDragonHttpClient?.Dispose(); } _championResponseCache?.Clear(); diff --git a/LeagueAPI/ARAM/ARAMBalanceService.cs b/LeagueAPI/ARAM/ARAMBalanceService.cs new file mode 100644 index 0000000..25f3f64 --- /dev/null +++ b/LeagueAPI/ARAM/ARAMBalanceService.cs @@ -0,0 +1,109 @@ +using System.Text.Json; +using CliWrap; +using CliWrap.Buffered; +using MoonSharp.Interpreter; + +namespace LeagueAPI.ARAM; + +public record class WikiChampion(int Id, WikiChampionStats Stats); +public record class WikiChampionStats(Dictionary Aram); + +public class ARAMBalanceLookup : Dictionary> { } + +public class ARAMBalanceService +{ + private static readonly string URL = "https://wiki.leagueoflegends.com/en-us/rest.php/v1/page/Module:ChampionData%2Fdata"; + + private ARAMBalanceLookup _champions = []; + + static ARAMBalanceService() + { + UserData.RegisterType(); + UserData.RegisterType(); + } + + public async Task EnsureIsLoadedAsync() + { + if (_champions is not { Count: > 0 }) + { + await ReloadAsync(); + } + } + + public async Task ReloadAsync(bool force = false) + { + ARAMBalanceLookup? champions = ResourceService.GetARAMBalanceLookup(); + if (!force && champions is { Count: > 0 }) + { + _champions = champions; + return; + } + + Command curl = Cli.Wrap("curl") + .WithArguments(URL); + BufferedCommandResult result = await curl.ExecuteBufferedAsync(); + + string json = result.StandardOutput; + JsonDocument jsonDocument = JsonDocument.Parse(json); + string lua = jsonDocument.RootElement.GetProperty("source").GetString() ?? string.Empty; + + DynValue champs = Script.RunString(lua); + if (champs.Type == DataType.Table) + { + Dictionary nameDictionary = []; + foreach (TablePair kv in champs.Table.Pairs) + { + if (kv.Key.Type is not DataType.String || kv.Value.Type is not DataType.Table) + { + continue; + } + string key = kv.Key.String; + Table championTable = kv.Value.Table; + DynValue idValue = championTable.Get("id"); + DynValue statsValue = championTable.Get("stats"); + + Dictionary aramStats = []; + + if (statsValue.Type is DataType.Table) + { + DynValue aramValue = statsValue.Table.Get("aram"); + + if (aramValue.Type is DataType.Table) + { + foreach (TablePair aramKv in aramValue.Table.Pairs) + { + if (aramKv.Key.Type is DataType.String && aramKv.Value.Type is DataType.Number) + { + aramStats[aramKv.Key.String] = aramKv.Value.Number; + } + } + } + } + + WikiChampion champ = new(idValue.Type is DataType.Number ? (int)idValue.Number : -1, new(aramStats)); + nameDictionary.Add(key, champ); + } + + _champions = []; + + foreach (KeyValuePair kv in nameDictionary) + { + if (!_champions.TryGetValue(kv.Value.Id, out Dictionary? value)) + { + _champions[kv.Value.Id] = new(kv.Value.Stats.Aram); + } + else + { + kv.Value.Stats.Aram.ToList().ForEach(kv => value.Add(kv.Key, kv.Value)); + } + } + ResourceService.SetARAMBalanceLookup(_champions); + } + } + + public Dictionary GetAramStats(int championId) + { + EnsureIsLoadedAsync().Wait(); + return _champions.TryGetValue(championId, out Dictionary? stats) ? stats : []; + } +} diff --git a/LeagueAPI/CachingHttpClient.cs b/LeagueAPI/CachingHttpClient.cs new file mode 100644 index 0000000..7afa787 --- /dev/null +++ b/LeagueAPI/CachingHttpClient.cs @@ -0,0 +1,18 @@ +namespace LeagueAPI; + +public class HttpCache : Dictionary { } + +public static class CachingHttpClient +{ + private static HttpCache _cache = ResourceService.GetHttpCache() ?? []; + private static HttpClient _client = new(); + + public static async Task GetStringAsync(string requestUri) + { + if (_cache.TryGetValue(requestUri, out string? response)) + { + return response; + } + return await _client.GetStringAsync(requestUri); + } +} diff --git a/LeagueAPI/Extensions.cs b/LeagueAPI/Extensions.cs index 037b325..0ae9cff 100644 --- a/LeagueAPI/Extensions.cs +++ b/LeagueAPI/Extensions.cs @@ -1,7 +1,5 @@ -using System.Diagnostics; -using System.Management; -using System.Net.WebSockets; -using System.Text; +using System.Net.WebSockets; +using System.Runtime.CompilerServices; namespace LeagueAPI; @@ -9,20 +7,6 @@ public record WebsocketMessageResult(byte[] Message, WebSocketMessageType Messag public static class Extensions { - extension(Process process) - { - public string GetCommandLine() - { - if (!OperatingSystem.IsWindows()) - { - throw new PlatformNotSupportedException("Only supported on Windows."); - } - using ManagementObjectSearcher searcher = new("SELECT CommandLine FROM Win32_Process WHERE ProcessId = " + process.Id); - using ManagementObjectCollection objects = searcher.Get(); - return objects.Cast().SingleOrDefault()?["CommandLine"]?.ToString() ?? string.Empty; - } - } - extension(string? s) { public int ParseInt(int defaultValue = default) @@ -44,6 +28,40 @@ public static class Extensions } } + extension(IEnumerable source) + { + /// + /// Uses , evaluates immediately. + /// + public IEnumerable WhereNotNull() + { + return source.Aggregate(Enumerable.Empty(), Accumulate); + + static IEnumerable Accumulate(IEnumerable accumulator, TSource? next) + { + if (next is not null) + { + return accumulator.Append(next); + } + return accumulator; + } + } + + /// + /// Uses lazy-evaluation + /// + public IEnumerable WhereNotNullLazy() + { + foreach (TSource? element in source) + { + if (element is not null) + { + yield return element; + } + } + } + } + extension(Task task) { public T WaitForResult() diff --git a/LeagueAPI/LcuWebsocket.cs b/LeagueAPI/LcuWebsocket.cs index 915bc53..4b6c471 100644 --- a/LeagueAPI/LcuWebsocket.cs +++ b/LeagueAPI/LcuWebsocket.cs @@ -22,6 +22,9 @@ public class LcuWebsocket : IDisposable private readonly ClientWebSocket _socket = new(); + public event EventHandler? Connecting; + public event EventHandler? Connected; + public event EventHandler? Disconnected; public event EventHandler? LcuApiEvent; public event EventHandler? LcuApiException; @@ -42,7 +45,15 @@ public class LcuWebsocket : IDisposable public async Task Connect() { - ProcessInfo = ProcessFinder.GetProcessInfo(); + Connecting?.Invoke(this, EventArgs.Empty); + try + { + ProcessInfo = ProcessFinder.GetProcessInfo(); + } + catch (Exception ex) + { + return; + } if (!ProcessFinder.IsPortOpen(ProcessInfo)) { throw new InvalidOperationException("Failed to connect to LCUx process port."); @@ -57,6 +68,8 @@ public class LcuWebsocket : IDisposable { await _socket.ConnectAsync(uri, CancellationToken.None); + Connected?.Invoke(this, EventArgs.Empty); + foreach (string eventName in SUBSCRIBE_EVENTS) { string message = $"[{OPCODE_SUBSCRIBE}, \"{eventName}\"]"; @@ -108,6 +121,10 @@ public class LcuWebsocket : IDisposable { LcuApiException?.Invoke(this, ex); } + finally + { + Disconnected?.Invoke(this, EventArgs.Empty); + } } #region IDisposable diff --git a/LeagueAPI/LeagueAPI.csproj b/LeagueAPI/LeagueAPI.csproj index b3d3adb..5303e13 100644 --- a/LeagueAPI/LeagueAPI.csproj +++ b/LeagueAPI/LeagueAPI.csproj @@ -8,7 +8,7 @@ - + diff --git a/LeagueAPI/Models/DDragon/Champions/ChampionData.cs b/LeagueAPI/Models/DDragon/Champions/ChampionData.cs index 163f5a8..7d767be 100644 --- a/LeagueAPI/Models/DDragon/Champions/ChampionData.cs +++ b/LeagueAPI/Models/DDragon/Champions/ChampionData.cs @@ -39,4 +39,7 @@ public record ChampionData [JsonPropertyName("stats")] public ChampionDataStats? Stats { get; init; } + + [JsonIgnore] + public Dictionary AramBalance { get; set; } = []; } diff --git a/LeagueAPI/ResourceService.cs b/LeagueAPI/ResourceService.cs new file mode 100644 index 0000000..c0150cf --- /dev/null +++ b/LeagueAPI/ResourceService.cs @@ -0,0 +1,85 @@ +using System.Text.Json; +using LeagueAPI.ARAM; + +namespace LeagueAPI; + +public static class ResourceService +{ + private const string DDRAGON_CHAMPION_URL = "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/{0}.png"; + private const string CHAMPION_FILENAME_FORMAT = "{0}.png"; + private const string ARAM_BALANCE_FILENAME = "aram.json"; + private const string HTTP_CLIENT_CACHE_FILENAME = "httpcache.json"; + + private static readonly DirectoryInfo AssetDirectory = new("./assets"); + + static ResourceService() + { + if (!AssetDirectory.Exists) + { + AssetDirectory.Create(); + } + } + + public static async Task GetChampionIconPathAsync(int championId = -1) + { + FileInfo assetFile = new(Path.Combine(AssetDirectory.FullName, CHAMPION_FILENAME_FORMAT.AsFormatWith(championId))); + if (assetFile.Exists) + { + return assetFile.FullName; + } + + using HttpClient client = new(); + HttpResponseMessage response = await client.GetAsync(DDRAGON_CHAMPION_URL.AsFormatWith(championId)); + if (!response.IsSuccessStatusCode) + { + return string.Empty; + } + byte[] buffer = await response.Content.ReadAsByteArrayAsync(); + try + { + if (!assetFile.Exists) + { + await File.WriteAllBytesAsync(assetFile.FullName, buffer); + } + } + catch { } + + return assetFile.FullName; + } + + public static ARAMBalanceLookup? GetARAMBalanceLookup() + { + FileInfo aramFile = new(Path.Combine(AssetDirectory.FullName, ARAM_BALANCE_FILENAME)); + if (!aramFile.Exists) + { + return null; + } + string json = File.ReadAllText(aramFile.FullName); + return JsonSerializer.Deserialize(json); + } + + public static void SetARAMBalanceLookup(ARAMBalanceLookup champions) + { + FileInfo aramFile = new(Path.Combine(AssetDirectory.FullName, ARAM_BALANCE_FILENAME)); + string json = JsonSerializer.Serialize(champions); + File.WriteAllText(aramFile.FullName, json); + } + + public static HttpCache? GetHttpCache() + { + FileInfo cacheFile = new(Path.Combine(AssetDirectory.FullName, HTTP_CLIENT_CACHE_FILENAME)); + if (!cacheFile.Exists) + { + return null; + } + string json = File.ReadAllText(cacheFile.FullName); + return JsonSerializer.Deserialize(json); + } + + public static void SetHttpCache(HttpCache cache) + { + FileInfo cacheFile = new(Path.Combine(AssetDirectory.FullName, HTTP_CLIENT_CACHE_FILENAME)); + string json = JsonSerializer.Serialize(cache); + File.WriteAllText(cacheFile.FullName, json); + } +} diff --git a/LeagueAPI/Utils/LcuHttpClient.cs b/LeagueAPI/Utils/LcuHttpClient.cs index 8690315..f79eac8 100644 --- a/LeagueAPI/Utils/LcuHttpClient.cs +++ b/LeagueAPI/Utils/LcuHttpClient.cs @@ -4,9 +4,6 @@ namespace LeagueAPI.Utils; public class LcuHttpClient : HttpClient { - private static readonly Lazy LazyInstance = new(() => new LcuHttpClient(new LcuHttpClientHandler())); - internal static LcuHttpClient Instance => LazyInstance.Value; - private LcuHttpClientHandler Handler { get; } public ProcessInfo? ProcessInfo { @@ -22,11 +19,11 @@ public class LcuHttpClient : HttpClient public RiotAuthentication? RiotAuthentication => Handler.RiotAuthentication; - internal LcuHttpClient(LcuHttpClientHandler lcuHttpClientHandler) + public LcuHttpClient(LcuHttpClientHandler lcuHttpClientHandler) : base(lcuHttpClientHandler) { Handler = lcuHttpClientHandler; - base.BaseAddress = new Uri("https://127.0.0.1"); + BaseAddress = new Uri("https://127.0.0.1"); } public async Task GetContentAsync(string requestUri) where T : class diff --git a/LeagueAPI/Utils/LcuHttpClientHandler.cs b/LeagueAPI/Utils/LcuHttpClientHandler.cs index cbaf4e2..f12359b 100644 --- a/LeagueAPI/Utils/LcuHttpClientHandler.cs +++ b/LeagueAPI/Utils/LcuHttpClientHandler.cs @@ -1,6 +1,6 @@ namespace LeagueAPI.Utils; -internal class LcuHttpClientHandler : HttpClientHandler +public class LcuHttpClientHandler : HttpClientHandler { private Lazy _isFirstRequest = new(() => true); diff --git a/LeagueARAMTracker.slnx b/LeagueARAMTracker.slnx index e779014..64d31e3 100644 --- a/LeagueARAMTracker.slnx +++ b/LeagueARAMTracker.slnx @@ -1,4 +1,4 @@ + - diff --git a/LeagueARAMTracker/MainForm.cs b/LeagueARAMTracker/MainForm.cs deleted file mode 100644 index 4cef46a..0000000 --- a/LeagueARAMTracker/MainForm.cs +++ /dev/null @@ -1,236 +0,0 @@ -using System.ComponentModel; -using System.Text.Json; -using LeagueAPI; -using LeagueAPI.Models.ChampSelect; -using LeagueAPI.Models.DDragon.Champions; -using LeagueAPI.Models.ReadyCheck; - -namespace LeagueARAMTracker; - -[DesignerCategory("")] // disable designer -internal class MainForm : Form -{ - private readonly Button _openLobbyButton; - private readonly Button _startQueueingButton; - - private readonly Label _infoLabel; - - private readonly TableLayoutPanel _imageGrid; - private readonly PictureBox[] _pictureBoxes; - - private readonly ToolTip _toolTip; - - private readonly APIClient _client = new(); - private readonly LcuWebsocket _socket = new(); - private readonly Task _socketTask; - - private readonly Lock _syncRoot = new(); - private Dictionary _champions = []; - private List _needChampionIds = []; - - public MainForm() - { - Task championLoadTask = Task.Run(async () => - { - ChampionData[] champions = await _client.GetAllChampionsAsync(); - Dictionary championDictionary = champions.Where(c => c.Id != -1).ToDictionary(key => key.Id); - lock (_syncRoot) - { - _champions = championDictionary; - } - await UpdateNeedChampionIdsAsync(); - }); - - _socket.LcuApiEvent += OnLcuApiEvent; -#if DEBUG - _socket.LcuApiException += (sender, e) => - { - File.AppendAllText("socketerror.log", $"[{DateTime.Now:s}] {e.Message}\n{e.StackTrace}\n\n"); - }; -#endif - _socketTask = _socket.Connect(); - - Text = "League ARAM Tracker"; - AutoSize = true; - MaximizeBox = false; - FormBorderStyle = FormBorderStyle.FixedSingle; - BackColor = Color.FromArgb(10, 26, 42); - Font = new(Font.FontFamily, 12); - TopMost = true; - Icon = Resources.AracMasterIcon; - - _toolTip = new(); - - const int PADDING_WIDTH = 10; - int currentOffset = PADDING_WIDTH; - _openLobbyButton = new() - { - Text = "Open Mayhem Lobby", - Location = new(currentOffset, 10), - TextAlign = ContentAlignment.MiddleCenter, - AutoSize = true, - BackColor = Color.FromArgb(33, 37, 44), - ForeColor = Color.FromArgb(193, 155, 65), - }; - _openLobbyButton.Click += OnOpenLobbyClicked; - Controls.Add(_openLobbyButton); - currentOffset += _openLobbyButton.Width + PADDING_WIDTH; - - _startQueueingButton = new() - { - Text = "Start Queueing", - Location = new(currentOffset, 10), - TextAlign = ContentAlignment.MiddleCenter, - AutoSize = true, - BackColor = Color.FromArgb(33, 37, 44), - ForeColor = Color.FromArgb(193, 155, 65), - }; - _startQueueingButton.Click += OnStartQueueingClicked; - Controls.Add(_startQueueingButton); - currentOffset += _startQueueingButton.Width + PADDING_WIDTH; - - _infoLabel = new() - { - Text = "", - Location = new(currentOffset, 10), - TextAlign = ContentAlignment.MiddleLeft, - BackColor = Color.Transparent, - ForeColor = Color.White, - AutoSize = true, - }; - Controls.Add(_infoLabel); - currentOffset += _infoLabel.Width + PADDING_WIDTH; - - _imageGrid = new TableLayoutPanel() - { - Location = new(10, 50), - ColumnCount = 5, - RowCount = 3, - AutoSize = true, - AutoSizeMode = AutoSizeMode.GrowAndShrink, - }; - Controls.Add(_imageGrid); - - string emptyChampionIconPath = Task.Run(async () => { return await ResourceManager.Instance.GetChampionIconPathAsync(); }).WaitForResult(); - List pictureBoxes = []; - for (int i = 0; i < 15; i++) - { - PictureBox box = new() - { - Size = new(128, 128), - SizeMode = PictureBoxSizeMode.Zoom, - BorderStyle = BorderStyle.Fixed3D, - Margin = new(5), - ImageLocation = emptyChampionIconPath, - }; - box.MouseEnter += (sender, e) => - { - if (sender is Control self && self.Tag is ChampionData champ) - { - _toolTip.SetToolTip(self, champ.Name); - } - }; - box.MouseLeave += (sender, e) => - { - if (sender is Control self) - { - _toolTip.SetToolTip(self, null); - } - }; - pictureBoxes.Add(box); - _imageGrid.Controls.Add(box); - } - _pictureBoxes = [.. pictureBoxes]; - - championLoadTask.Wait(); - } - - private async Task UpdateNeedChampionIdsAsync() - { - IEnumerable completedChampionIds = await _client.GetAllRandomAllChampionsCompletedChampionsAsync(); - IEnumerable needChampionIds = _champions.Keys.Except(completedChampionIds); - lock (_syncRoot) - { - _needChampionIds = [.. needChampionIds]; - } - } - - private async Task ShowSelectableNeedChampionsAsync(ChampSelectSession? session) - { - int[] selectableChampionIds = _client.GetSelectableChampionIds(session); - if (selectableChampionIds.Length == 0) - { - return; - } - - Queue targetChampionIds = new(selectableChampionIds.Intersect(_needChampionIds)); - if (targetChampionIds.Count == 0) - { - _infoLabel.Text = $"S-Grade achieved for all champions"; - return; - } - _infoLabel.Text = $"{targetChampionIds.Count} Champions found:"; - foreach (PictureBox pictureBox in _pictureBoxes) - { - if (!targetChampionIds.TryDequeue(out int championId)) - { - championId = -1; - } - string filepath = await ResourceManager.Instance.GetChampionIconPathAsync(championId); - pictureBox.ImageLocation = filepath; - ChampionData? champion = await _client.GetChampionByIdAsync(championId); - pictureBox.Tag = champion; - } - } - - private async void OnOpenLobbyClicked(object? sender, EventArgs e) - { - await _client.CreateMayhemLobbyAsync(); - } - - private async void OnStartQueueingClicked(object? sender, EventArgs e) - { - await _client.StartMatchmakingQueueAsync(); - } - - private async void OnLcuApiEvent(object? sender, LcuApiEvent apiEvent) - { -#if DEBUG - File.AppendAllText("socket.log", $"{apiEvent.Uri}: {apiEvent.EventType} - {apiEvent.Data?.ToJsonString()}\n"); -#endif - - if (apiEvent.Uri == "/lol-champ-select/v1/session") - { - ChampSelectSession? session = apiEvent.Data.Deserialize(); - await ShowSelectableNeedChampionsAsync(session); - return; - } - - if (apiEvent.Uri == "/lol-matchmaking/v1/ready-check") - { - LolMatchmakingMatchmakingReadyCheckResource? readyCheck = apiEvent.Data.Deserialize(); - if (readyCheck is not null && readyCheck.PlayerResponse == LolMatchmakingMatchmakingReadyCheckResponse.None) - { - await _client.MatchmakingAcceptAsync(); - await UpdateNeedChampionIdsAsync(); - } - } - } - - protected override void Dispose(bool disposing) - { - if (disposing) - { - _client.Dispose(); - _socket.Dispose(); - _imageGrid.Dispose(); - foreach (PictureBox pictureBox in _pictureBoxes) - { - pictureBox.Dispose(); - } - Array.Clear(_pictureBoxes); - } - - base.Dispose(disposing); - } -} diff --git a/LeagueARAMTracker/Program.cs b/LeagueARAMTracker/Program.cs deleted file mode 100644 index 5f712c2..0000000 --- a/LeagueARAMTracker/Program.cs +++ /dev/null @@ -1,11 +0,0 @@ -namespace LeagueARAMTracker; - -public class Program -{ - [STAThread] - public static void Main() - { - ApplicationConfiguration.Initialize(); - Application.Run(new MainForm()); - } -} diff --git a/LeagueARAMTracker/ResourceManager.cs b/LeagueARAMTracker/ResourceManager.cs deleted file mode 100644 index f117971..0000000 --- a/LeagueARAMTracker/ResourceManager.cs +++ /dev/null @@ -1,37 +0,0 @@ -using CliWrap; -using LeagueAPI; - -namespace LeagueARAMTracker; - -internal class ResourceManager -{ - private const string DDRAGON_CHAMPION_URL = "https://raw.communitydragon.org/latest/plugins/rcp-be-lol-game-data/global/default/v1/champion-icons/{0}.png"; - private const string CHAMPION_FILENAME_FORMAT = "{0}.png"; - - public static ResourceManager Instance => field ??= new ResourceManager(); - - private readonly DirectoryInfo _assetDirectory = new("./assets"); - - private ResourceManager() - { - if (!_assetDirectory.Exists) - { - _assetDirectory.Create(); - } - } - - public async Task GetChampionIconPathAsync(int championId = -1) - { - FileInfo assetFile = new(Path.Combine(_assetDirectory.FullName, CHAMPION_FILENAME_FORMAT.AsFormatWith(championId))); - if (assetFile.Exists) - { - return assetFile.FullName; - } - - Command cmd = Cli.Wrap("curl") - .WithArguments($"-o \"{assetFile.FullName}\" {DDRAGON_CHAMPION_URL.AsFormatWith(championId)}") - .WithValidation(CommandResultValidation.None); - await cmd.ExecuteAsync(); - return assetFile.FullName; - } -} diff --git a/README.md b/README.md index 156610a..4612395 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ # LeagueARAMTracker -This software interacts with Riot Games' internal LCU API. It is not endorsed or supported by Riot Games. -Use of this software may violate Riot Games' Terms of Service. The author is not responsible for any consequences of using this software. +This software isn't endorsed by Riot Games and doesn't reflect the views or opinions of Riot Games or anyone officially involved in producing or managing Riot Games properties. Riot Games, and all associated properties are trademarks or registered trademarks of Riot Games, Inc.