This commit is contained in:
2026-03-13 01:22:28 +01:00
parent 695e4560b5
commit 86641919f8
27 changed files with 755 additions and 344 deletions

1
.gitignore vendored
View File

@@ -361,3 +361,4 @@ MigrationBackup/
# Fody - auto-generated XML schema
FodyWeavers.xsd
/assets

View File

@@ -3,10 +3,10 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<LangVersion>preview</LangVersion>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<ImplicitUsings>enable</ImplicitUsings>
<UseWPF>true</UseWPF>
<ApplicationIcon>Resource\arac_master.ico</ApplicationIcon>
</PropertyGroup>
@@ -14,22 +14,26 @@
<Content Include="Resource\arac_master.ico" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LeagueAPI\LeagueAPI.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Resources.Designer.cs">
<DependentUpon>Resources.resx</DependentUpon>
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
<Generator>ResXFileCodeGenerator</Generator>
</EmbeddedResource>
</ItemGroup>

9
ARAMUtility/App.xaml Normal file
View File

@@ -0,0 +1,9 @@
<Application x:Class="ARAMUtility.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="clr-namespace:ARAMUtility"
StartupUri="View/MainWindow.xaml">
<Application.Resources>
</Application.Resources>
</Application>

12
ARAMUtility/App.xaml.cs Normal file
View File

@@ -0,0 +1,12 @@
using System.Configuration;
using System.Data;
using System.Windows;
namespace ARAMUtility;
/// <summary>
/// Interaction logic for App.xaml
/// </summary>
public partial class App : Application
{
}

View File

@@ -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)
)]

View File

Before

Width:  |  Height:  |  Size: 264 KiB

After

Width:  |  Height:  |  Size: 264 KiB

View File

@@ -8,7 +8,7 @@
// </auto-generated>
//------------------------------------------------------------------------------
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;

View File

@@ -0,0 +1,99 @@
<Window x:Class="ARAMUtility.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:ARAMUtility.ViewModel"
mc:Ignorable="d"
Title="{Binding Title}"
Height="480"
Width="700"
Background="#0a1a2a"
>
<Window.DataContext>
<vm:MainViewModel />
</Window.DataContext>
<Window.Resources>
<Style TargetType="Button">
<Setter Property="Margin" Value="5" />
<Setter Property="Padding" Value="10,5,10,5" />
</Style>
<DataTemplate x:Key="ChampionItemTemplate" DataType="vm:ChampionViewModel">
<Border x:Name="OuterBorder"
BorderBrush="Black"
BorderThickness="3"
Margin="5"
Width="256"
Height="256">
<Grid ToolTip="{Binding Name}">
<Image Source="{Binding ImagePath}"
Stretch="UniformToFill" />
<TextBlock Text="{Binding AramBalanceText}"
Foreground="White"
Background="#99000000"
HorizontalAlignment="Left"
VerticalAlignment="Top"
Padding="15,10"
TextWrapping="Wrap"
TextTrimming="CharacterEllipsis"
FontSize="24"
FontWeight="Bold"
Height="256"
Width="256" />
</Grid>
</Border>
<DataTemplate.Triggers>
<DataTrigger Binding="{Binding IsNeededForChallenge}" Value="True">
<Setter TargetName="OuterBorder"
Property="BorderBrush"
Value="Green" />
</DataTrigger>
</DataTemplate.Triggers>
</DataTemplate>
</Window.Resources>
<DockPanel LastChildFill="True">
<Menu DockPanel.Dock="Top">
<MenuItem Header="Actions">
<MenuItem Header="Open Mayhem Lobby" Command="{Binding OpenLobbyCommand}" />
<MenuItem Header="Start Queueing" Command="{Binding StartQueueingCommand}" />
<MenuItem Header="Reconnect" Command="{Binding ConnectCommand}" />
<Separator />
<MenuItem Header="Reload ARAM Balance" Command="{Binding ReloadARAMBalanceCommand}" />
<Separator />
<MenuItem Header="Quit" Command="{Binding QuitCommand}" />
</MenuItem>
<MenuItem Header="{Binding ConnectionStatus}" IsEnabled="False" />
</Menu>
<Viewbox Stretch="Uniform" StretchDirection="DownOnly">
<Grid Margin="10">
<Grid.RowDefinitions>
<RowDefinition Height="1*" />
<RowDefinition Height="2*" />
</Grid.RowDefinitions>
<ItemsControl x:Name="TeamChampionGrid"
Grid.Row="0"
Margin="5"
ItemsSource="{Binding TeamChampions}"
ItemTemplate="{StaticResource ChampionItemTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="5" Rows="1"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
<ItemsControl x:Name="BenchChampionGrid"
Grid.Row="1"
Margin="5"
ItemsSource="{Binding BenchChampions}"
ItemTemplate="{StaticResource ChampionItemTemplate}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<UniformGrid Columns="5" Rows="2"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Grid>
</Viewbox>
</DockPanel>
</Window>

View File

@@ -0,0 +1,20 @@
using System.Windows;
using ARAMUtility.ViewModel;
namespace ARAMUtility;
/// <summary>
/// Interaction logic for MainWindow.xaml
/// </summary>
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
if (DataContext is MainViewModel viewModel)
{
Loaded += viewModel.OnInit;
Closing += (sender, e) => { viewModel?.Dispose(); };
}
}
}

View File

@@ -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<string, double> 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();
}
}

View File

@@ -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<ChampionViewModel> TeamChampions { get; private set; } = [];
[ObservableProperty]
public partial ObservableCollection<ChampionViewModel> 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<int, ChampionData> _allChampions = [];
private List<int> _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<int, ChampionData> 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<LolMatchmakingMatchmakingReadyCheckResource>();
if (readyCheck is not null && readyCheck.PlayerResponse == LolMatchmakingMatchmakingReadyCheckResponse.Accepted)
{
await UpdateNeedChampionIdsAsync();
}
break;
case "/lol-champ-select/v1/session":
ChampSelectSession? session = apiEvent.Data.Deserialize<ChampSelectSession>();
await ShowChampionsAsync(session);
break;
default:
File.AppendAllText("socket.log", $"{apiEvent.Uri}: {apiEvent.EventType} - {apiEvent.Data?.ToJsonString()}\n");
break;
}
}
private async Task UpdateNeedChampionIdsAsync()
{
IEnumerable<int> completedChampionIds = await _client.GetAllRandomAllChampionsCompletedChampionsAsync();
IEnumerable<int> 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<int> teamChampions, IEnumerable<int> 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<ChampionViewModel> viewModel, IEnumerable<int> 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
}

View File

@@ -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<Dictionary<string, LolChallengesUIChallenge>> GetAllChallengesAsync()
@@ -51,23 +45,23 @@ public class APIClient : IDisposable
return allRandomAllChampions.CompletedIds ?? [];
}
public async Task<int[]> GetSelectableChampionIdsAsync()
public async Task<(IEnumerable<int> teamChampions, IEnumerable<int> benchChampions)> GetSelectableChampionIdsAsync()
{
ChampSelectSession? session = await _lcuHttpClient.GetContentAsync<ChampSelectSession>("/lol-champ-select/v1/session");
return GetSelectableChampionIds(session);
}
public int[] GetSelectableChampionIds(ChampSelectSession? session)
public static (IEnumerable<int> teamChampions, IEnumerable<int> benchChampions) GetSelectableChampionIds(ChampSelectSession? session)
{
if (session is null || !session.BenchEnabled)
if (session is null || !session.BenchEnabled || string.IsNullOrEmpty(session.Id))
{
return [];
return ([], []);
}
IEnumerable<int> benchChampions = session.BenchChampions.Select(b => b.ChampionId);
IEnumerable<int> 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<string> DDragonGetAsync(string path)
private static async Task<string> 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<T?> DDragonGetAsync<T>(string path)
private static async Task<T?> DDragonGetAsync<T>(string path)
{
string json = await DDragonGetAsync(path);
return JsonSerializer.Deserialize<T>(json);
@@ -159,7 +145,6 @@ public class APIClient : IDisposable
if (disposing)
{
_lcuHttpClient?.Dispose();
_dDragonHttpClient?.Dispose();
}
_championResponseCache?.Clear();

View File

@@ -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<string, double> Aram);
public class ARAMBalanceLookup : Dictionary<int, Dictionary<string, double>> { }
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<WikiChampion>();
UserData.RegisterType<WikiChampionStats>();
}
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<string, WikiChampion> 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<string, double> 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<string, WikiChampion> kv in nameDictionary)
{
if (!_champions.TryGetValue(kv.Value.Id, out Dictionary<string, double>? 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<string, double> GetAramStats(int championId)
{
EnsureIsLoadedAsync().Wait();
return _champions.TryGetValue(championId, out Dictionary<string, double>? stats) ? stats : [];
}
}

View File

@@ -0,0 +1,18 @@
namespace LeagueAPI;
public class HttpCache : Dictionary<string, string> { }
public static class CachingHttpClient
{
private static HttpCache _cache = ResourceService.GetHttpCache() ?? [];
private static HttpClient _client = new();
public static async Task<string> GetStringAsync(string requestUri)
{
if (_cache.TryGetValue(requestUri, out string? response))
{
return response;
}
return await _client.GetStringAsync(requestUri);
}
}

View File

@@ -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<ManagementBaseObject>().SingleOrDefault()?["CommandLine"]?.ToString() ?? string.Empty;
}
}
extension(string? s)
{
public int ParseInt(int defaultValue = default)
@@ -44,6 +28,40 @@ public static class Extensions
}
}
extension<TSource>(IEnumerable<TSource?> source)
{
/// <summary>
/// Uses <see cref="Enumerable.Aggregate{TSource}(IEnumerable{TSource}, Func{TSource, TSource, TSource})"/>, evaluates immediately.
/// </summary>
public IEnumerable<TSource> WhereNotNull()
{
return source.Aggregate(Enumerable.Empty<TSource>(), Accumulate);
static IEnumerable<TSource> Accumulate(IEnumerable<TSource> accumulator, TSource? next)
{
if (next is not null)
{
return accumulator.Append(next);
}
return accumulator;
}
}
/// <summary>
/// Uses lazy-evaluation
/// </summary>
public IEnumerable<TSource> WhereNotNullLazy()
{
foreach (TSource? element in source)
{
if (element is not null)
{
yield return element;
}
}
}
}
extension<T>(Task<T> task)
{
public T WaitForResult()

View File

@@ -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>? LcuApiEvent;
public event EventHandler<Exception>? LcuApiException;
@@ -41,8 +44,16 @@ public class LcuWebsocket : IDisposable
}
public async Task Connect()
{
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

View File

@@ -8,7 +8,7 @@
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="System.Management" Version="10.0.3" />
<PackageReference Include="MoonSharp" Version="2.0.0" />
</ItemGroup>
</Project>

View File

@@ -39,4 +39,7 @@ public record ChampionData
[JsonPropertyName("stats")]
public ChampionDataStats? Stats { get; init; }
[JsonIgnore]
public Dictionary<string, double> AramBalance { get; set; } = [];
}

View File

@@ -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<string> 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<ARAMBalanceLookup>(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<HttpCache>(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);
}
}

View File

@@ -4,9 +4,6 @@ namespace LeagueAPI.Utils;
public class LcuHttpClient : HttpClient
{
private static readonly Lazy<LcuHttpClient> 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<T?> GetContentAsync<T>(string requestUri) where T : class

View File

@@ -1,6 +1,6 @@
namespace LeagueAPI.Utils;
internal class LcuHttpClientHandler : HttpClientHandler
public class LcuHttpClientHandler : HttpClientHandler
{
private Lazy<bool> _isFirstRequest = new(() => true);

View File

@@ -1,4 +1,4 @@
<Solution>
<Project Path="ARAMUtility/ARAMUtility.csproj" Id="7db76791-3507-4200-89b7-809d78015e77" />
<Project Path="LeagueAPI/LeagueAPI.csproj" />
<Project Path="LeagueARAMTracker/LeagueARAMTracker.csproj" />
</Solution>

View File

@@ -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<int, ChampionData> _champions = [];
private List<int> _needChampionIds = [];
public MainForm()
{
Task championLoadTask = Task.Run(async () =>
{
ChampionData[] champions = await _client.GetAllChampionsAsync();
Dictionary<int, ChampionData> 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<PictureBox> 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<int> completedChampionIds = await _client.GetAllRandomAllChampionsCompletedChampionsAsync();
IEnumerable<int> 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<int> 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<ChampSelectSession>();
await ShowSelectableNeedChampionsAsync(session);
return;
}
if (apiEvent.Uri == "/lol-matchmaking/v1/ready-check")
{
LolMatchmakingMatchmakingReadyCheckResource? readyCheck = apiEvent.Data.Deserialize<LolMatchmakingMatchmakingReadyCheckResource>();
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);
}
}

View File

@@ -1,11 +0,0 @@
namespace LeagueARAMTracker;
public class Program
{
[STAThread]
public static void Main()
{
ApplicationConfiguration.Initialize();
Application.Run(new MainForm());
}
}

View File

@@ -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<string> 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;
}
}

View File

@@ -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.