Compare commits

..

3 Commits

Author SHA1 Message Date
SacredFloof 9f6105b970 Show champs ingame 2026-04-29 18:15:21 +02:00
SacredFloof 06d5113711 moved connection state to status bar 2026-04-29 04:05:16 +02:00
SacredFloof b6a4903847 Update ARAMBalanceService.cs 2026-04-29 01:23:42 +02:00
11 changed files with 308 additions and 79 deletions
+1 -1
View File
@@ -15,7 +15,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.0" /> <PackageReference Include="CommunityToolkit.Mvvm" Version="8.4.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+5 -21
View File
@@ -7,7 +7,7 @@
xmlns:vm="clr-namespace:ARAMUtility.ViewModel" xmlns:vm="clr-namespace:ARAMUtility.ViewModel"
mc:Ignorable="d" mc:Ignorable="d"
Title="{Binding Title}" Title="{Binding Title}"
Height="480" Height="500"
Width="700" Width="700"
Background="#0a1a2a" Background="#0a1a2a"
Topmost="True" Topmost="True"
@@ -67,27 +67,11 @@
<Separator /> <Separator />
<MenuItem Header="Quit" Command="{Binding QuitCommand}" /> <MenuItem Header="Quit" Command="{Binding QuitCommand}" />
</MenuItem> </MenuItem>
<MenuItem Header="{Binding ConnectionStatus}" Command="{Binding ConnectCommand}" /> <MenuItem Header="Reconnect" Command="{Binding ConnectCommand}" Visibility="{Binding ReconnectButtonVisibility, Mode=OneWay}" />
<MenuItem Header="Update ARAM">
<StackPanel Orientation="Vertical">
<ComboBox ItemsSource="{Binding .}"
SelectedIndex="0"
SelectedItem="{Binding .}"
/>
<ItemsControl ItemsSource="{Binding .}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<Label Content="{Binding .}" />
<TextBox Text="{Binding .}" />
</StackPanel>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Content="Save" />
</StackPanel>
</MenuItem>
</Menu> </Menu>
<StatusBar DockPanel.Dock="Bottom">
<StatusBarItem Content="{Binding Status}" />
</StatusBar>
<Viewbox Stretch="Uniform" StretchDirection="DownOnly"> <Viewbox Stretch="Uniform" StretchDirection="DownOnly">
<Grid Margin="10"> <Grid Margin="10">
<Grid.RowDefinitions> <Grid.RowDefinitions>
+4
View File
@@ -11,6 +11,10 @@ public partial class MainWindow : Window
{ {
InitializeComponent(); InitializeComponent();
#if DEBUG
Topmost = false;
#endif
if (DataContext is MainViewModel viewModel) if (DataContext is MainViewModel viewModel)
{ {
Loaded += viewModel.OnInit; Loaded += viewModel.OnInit;
+88 -14
View File
@@ -9,7 +9,9 @@ using LeagueAPI;
using LeagueAPI.ARAM; using LeagueAPI.ARAM;
using LeagueAPI.Models.ChampSelect; using LeagueAPI.Models.ChampSelect;
using LeagueAPI.Models.DDragon.Champions; using LeagueAPI.Models.DDragon.Champions;
using LeagueAPI.Models.GameClient;
using LeagueAPI.Models.ReadyCheck; using LeagueAPI.Models.ReadyCheck;
using LeagueAPI.Utils;
namespace ARAMUtility.ViewModel; namespace ARAMUtility.ViewModel;
@@ -18,6 +20,13 @@ public partial class MainViewModel : ObservableObject, IDisposable
private const int TEAM_CHAMPIONS_MAX = 5; private const int TEAM_CHAMPIONS_MAX = 5;
private const int BENCH_CHAMPIONS_MAX = 10; private const int BENCH_CHAMPIONS_MAX = 10;
public enum ConnectionStatus
{
Disconnected,
Connecting,
Connected,
}
private bool _isDisposed; private bool _isDisposed;
private readonly Lock _syncRoot = new(); private readonly Lock _syncRoot = new();
private readonly SemaphoreSlim _championUpdateSemaphore = new(1, 1); private readonly SemaphoreSlim _championUpdateSemaphore = new(1, 1);
@@ -36,8 +45,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
[ObservableProperty] [ObservableProperty]
public partial bool IsDisconnected { get; private set; } = false; public partial bool IsDisconnected { get; private set; } = false;
[ObservableProperty] [ObservableProperty, NotifyPropertyChangedFor(nameof(ReconnectButtonVisibility))]
public partial string ConnectionStatus { get; private set; } = "Not connected."; public partial ConnectionStatus Status { get; private set; } = ConnectionStatus.Disconnected;
public Visibility ReconnectButtonVisibility => Status == ConnectionStatus.Disconnected ? Visibility.Visible : Visibility.Collapsed;
[ObservableProperty] [ObservableProperty]
public partial bool AutoAccept { get; set; } = true; public partial bool AutoAccept { get; set; } = true;
@@ -48,12 +58,14 @@ public partial class MainViewModel : ObservableObject, IDisposable
private readonly LcuWebsocket _lcuWebsocket = new(); private readonly LcuWebsocket _lcuWebsocket = new();
private Task _lcuWebsocketTask; private Task _lcuWebsocketTask;
private readonly ARAMBalanceService _aramBalanceService = new(); private readonly ARAMBalanceService _aramBalanceService = new();
private readonly ProcessWatcher _leagueWatcher = new();
private readonly CachingHttpClient _localCachingClient = new(insecure: true);
public MainViewModel() public MainViewModel()
{ {
_lcuWebsocket.Connecting += (_, _) => UpdateConnectionStatus(false, "Connecting ..."); _lcuWebsocket.Connecting += (_, _) => UpdateConnectionStatus(false, ConnectionStatus.Connecting);
_lcuWebsocket.Connected += (_, _) => UpdateConnectionStatus(false, "Connected"); _lcuWebsocket.Connected += (_, _) => UpdateConnectionStatus(false, ConnectionStatus.Connected);
_lcuWebsocket.Disconnected += (_, _) => UpdateConnectionStatus(true, "Reconnect?"); _lcuWebsocket.Disconnected += (_, _) => UpdateConnectionStatus(true, ConnectionStatus.Disconnected);
_lcuWebsocket.LcuApiEvent += OnLcuApiEvent; _lcuWebsocket.LcuApiEvent += OnLcuApiEvent;
#if DEBUG #if DEBUG
@@ -63,6 +75,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
}; };
#endif #endif
_lcuWebsocketTask = _lcuWebsocket.Connect(); _lcuWebsocketTask = _lcuWebsocket.Connect();
_leagueWatcher.LeagueOfLegendsExeFound += UpdateChampionListsFromGame;
} }
internal async void OnInit(object? sender, RoutedEventArgs e) internal async void OnInit(object? sender, RoutedEventArgs e)
@@ -75,16 +89,27 @@ public partial class MainViewModel : ObservableObject, IDisposable
{ {
_allChampions = championDictionary; _allChampions = championDictionary;
} }
try
{
await UpdateNeedChampionIdsAsync(); await UpdateNeedChampionIdsAsync();
}
catch (InvalidOperationException)
{
UpdateConnectionStatus(false, ConnectionStatus.Disconnected);
return;
}
finally
{
await FillChampionLists(); await FillChampionLists();
} }
}
private void UpdateConnectionStatus(bool isConnected, string statusMessage) private void UpdateConnectionStatus(bool isConnected, ConnectionStatus status)
{ {
Dispatcher.Invoke(() => Dispatcher.Invoke(() =>
{ {
IsDisconnected = isConnected; IsDisconnected = isConnected;
ConnectionStatus = statusMessage; Status = status;
}); });
} }
@@ -133,6 +158,8 @@ public partial class MainViewModel : ObservableObject, IDisposable
private async Task FillChampionLists() private async Task FillChampionLists()
{ {
string defaultImagePath = await ResourceService.GetChampionIconPathAsync(-1); string defaultImagePath = await ResourceService.GetChampionIconPathAsync(-1);
Dispatcher.Invoke(() =>
{
while (TeamChampions.Count < TEAM_CHAMPIONS_MAX) while (TeamChampions.Count < TEAM_CHAMPIONS_MAX)
{ {
TeamChampions.Add(GetEmptyChampionViewModel()); TeamChampions.Add(GetEmptyChampionViewModel());
@@ -141,6 +168,7 @@ public partial class MainViewModel : ObservableObject, IDisposable
{ {
BenchChampions.Add(GetEmptyChampionViewModel()); BenchChampions.Add(GetEmptyChampionViewModel());
} }
});
ChampionViewModel GetEmptyChampionViewModel() ChampionViewModel GetEmptyChampionViewModel()
{ {
@@ -166,10 +194,24 @@ public partial class MainViewModel : ObservableObject, IDisposable
await FillChampionLists(); await FillChampionLists();
_championUpdateSemaphore.Release(); _championUpdateSemaphore.Release();
}
async Task UpdateChampions(ObservableCollection<ChampionViewModel> viewModel, IEnumerable<int> championIds) private async Task UpdateChampions(ObservableCollection<ChampionViewModel> viewModel, IEnumerable<int> championIds)
{ {
viewModel.Clear(); try
{
Dispatcher.Invoke(() => viewModel.Clear());
}
catch (NotSupportedException)
{
Dispatcher.Invoke(() =>
{
while (viewModel.Count > 0)
{
viewModel.RemoveAt(0);
}
});
}
await _aramBalanceService.EnsureIsLoadedAsync(); await _aramBalanceService.EnsureIsLoadedAsync();
foreach (int championId in championIds) foreach (int championId in championIds)
@@ -186,12 +228,43 @@ public partial class MainViewModel : ObservableObject, IDisposable
IsNeededForChallenge = _needChampionIds.Contains(championData.Id), IsNeededForChallenge = _needChampionIds.Contains(championData.Id),
ImagePath = imagePath, ImagePath = imagePath,
}; };
viewModel.Add(vm); Dispatcher.Invoke(() => { viewModel.Add(vm); });
}
} }
} }
[RelayCommand(AllowConcurrentExecutions = false, CanExecute = nameof(IsDisconnected))] private async void UpdateChampionListsFromGame(object? sender, EventArgs e)
{
await Task.Delay(TimeSpan.FromSeconds(5));
string playerlistJson = await _localCachingClient.GetStringAsync("https://127.0.0.1:2999/liveclientdata/playerlist", TimeSpan.FromSeconds(30));
PlayerResponse[]? allPlayerResponse = JsonSerializer.Deserialize<PlayerResponse[]>(playerlistJson);
if (allPlayerResponse is not { Length: > 0 })
{
return;
}
List<int> blueTeam = [];
List<int> redTeam = [];
foreach (PlayerResponse player in allPlayerResponse)
{
if (player.Team != TeamId.ORDER && player.Team != TeamId.CHAOS)
{
continue;
}
int championId = await _client.GetChampionIdByNameAsync(player.ChampionName);
if (player.Team == TeamId.ORDER)
{
blueTeam.Add(championId);
}
if (player.Team == TeamId.CHAOS)
{
redTeam.Add(championId);
}
}
await UpdateChampions(TeamChampions, blueTeam);
await UpdateChampions(BenchChampions, redTeam);
await FillChampionLists();
}
[RelayCommand(AllowConcurrentExecutions = false)]
private async Task Connect() private async Task Connect()
{ {
if (_lcuWebsocketTask is null || _lcuWebsocketTask.IsCompleted) if (_lcuWebsocketTask is null || _lcuWebsocketTask.IsCompleted)
@@ -234,8 +307,9 @@ public partial class MainViewModel : ObservableObject, IDisposable
if (disposing) if (disposing)
{ {
_allChampions = []; _allChampions = [];
_client.Dispose(); _client?.Dispose();
_lcuWebsocket.Dispose(); _lcuWebsocket?.Dispose();
_localCachingClient?.Dispose();
} }
_isDisposed = true; _isDisposed = true;
+17 -3
View File
@@ -24,6 +24,8 @@ public class APIClient : IDisposable
private readonly Dictionary<string, ChampionResponse> _championResponseCache = []; private readonly Dictionary<string, ChampionResponse> _championResponseCache = [];
private readonly CachingHttpClient _ddragonClient = new();
public APIClient() public APIClient()
{ {
_lcuHttpClient = new(new LcuHttpClientHandler()); _lcuHttpClient = new(new LcuHttpClientHandler());
@@ -81,12 +83,12 @@ public class APIClient : IDisposable
} }
#region DDragon #region DDragon
private static async Task<string> DDragonGetAsync(string path) private async Task<string> DDragonGetAsync(string path)
{ {
return await CachingHttpClient.GetStringAsync($"{DDRAGON_BASE_URL}{path}"); return await _ddragonClient.GetStringAsync($"{DDRAGON_BASE_URL}{path}");
} }
private static async Task<T?> DDragonGetAsync<T>(string path) private async Task<T?> DDragonGetAsync<T>(string path)
{ {
string json = await DDragonGetAsync(path); string json = await DDragonGetAsync(path);
return JsonSerializer.Deserialize<T>(json); return JsonSerializer.Deserialize<T>(json);
@@ -135,6 +137,17 @@ public class APIClient : IDisposable
return championData.FirstOrDefault(c => c.Id == id); return championData.FirstOrDefault(c => c.Id == id);
} }
public async Task<int> GetChampionIdByNameAsync(string name)
{
ChampionData[] championData = await GetAllChampionsAsync();
if (championData is not { Length: > 0 })
{
return -1;
}
return championData.FirstOrDefault(c => c.Name == name)?.Id ?? -1;
}
#endregion #endregion
#region IDisposable #region IDisposable
@@ -145,6 +158,7 @@ public class APIClient : IDisposable
if (disposing) if (disposing)
{ {
_lcuHttpClient?.Dispose(); _lcuHttpClient?.Dispose();
_ddragonClient?.Dispose();
} }
_championResponseCache?.Clear(); _championResponseCache?.Clear();
+2 -2
View File
@@ -29,10 +29,10 @@ public class ARAMBalanceService
return; return;
} }
await FetchFromAramonly(); await FetchFromAramonlyAsync();
} }
private async Task<bool> FetchFromAramonly() private async Task<bool> FetchFromAramonlyAsync()
{ {
using HttpClient _client = new(); using HttpClient _client = new();
using HttpResponseMessage response = await _client.GetAsync(ARAMONLY_URL); using HttpResponseMessage response = await _client.GetAsync(ARAMONLY_URL);
+71 -12
View File
@@ -1,18 +1,77 @@
namespace LeagueAPI; namespace LeagueAPI;
public class HttpCache : Dictionary<string, string> { } public class HttpCache : Dictionary<string, InvalidatableCacheObject<string>> { }
public record struct InvalidatableCacheObject<T>(
public static class CachingHttpClient T Value,
DateTime? InvalidateTime = null
)
{ {
private static HttpCache _cache = ResourceService.GetHttpCache() ?? []; public readonly bool IsValid()
private static HttpClient _client = new();
public static async Task<string> GetStringAsync(string requestUri)
{ {
if (_cache.TryGetValue(requestUri, out string? response)) return !InvalidateTime.HasValue || InvalidateTime < DateTime.UtcNow;
{
return response;
}
return await _client.GetStringAsync(requestUri);
} }
} }
public class CachingHttpClient : IDisposable
{
private bool _isDisposed;
private readonly HttpCache _cache = ResourceService.GetHttpCache() ?? [];
private readonly HttpClient _client = new();
public CachingHttpClient(bool insecure = false)
{
if (insecure)
{
HttpClientHandler handler = new()
{
ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator
};
_client = new(handler);
}
else
{
_client = new();
}
}
public async Task<string> GetStringAsync(string requestUri, TimeSpan? invalidateAfter = null)
{
if (_cache.TryGetValue(requestUri, out InvalidatableCacheObject<string> response) && response.IsValid())
{
return response.Value;
}
string result = await _client.GetStringAsync(requestUri);
if (invalidateAfter is not null && invalidateAfter > TimeSpan.Zero)
{
_cache[requestUri] = new(result, DateTime.UtcNow + invalidateAfter);
}
else
{
_cache[requestUri] = new(result);
}
return result;
}
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
_client?.Dispose();
}
_cache?.Clear();
_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
}
+5 -2
View File
@@ -51,10 +51,13 @@ public class LcuWebsocket : IDisposable
} }
catch catch
{ {
await Task.Delay(TimeSpan.FromMilliseconds(500));
Disconnected?.Invoke(this, EventArgs.Empty);
return; return;
} }
if (!ProcessFinder.IsPortOpen(ProcessInfo)) if (!ProcessFinder.IsPortOpen(ProcessInfo))
{ {
Disconnected?.Invoke(this, EventArgs.Empty);
throw new InvalidOperationException("Failed to connect to LCUx process port."); throw new InvalidOperationException("Failed to connect to LCUx process port.");
} }
@@ -67,8 +70,6 @@ public class LcuWebsocket : IDisposable
{ {
await _socket.ConnectAsync(uri, CancellationToken.None); await _socket.ConnectAsync(uri, CancellationToken.None);
Connected?.Invoke(this, EventArgs.Empty);
foreach (string eventName in SUBSCRIBE_EVENTS) foreach (string eventName in SUBSCRIBE_EVENTS)
{ {
string message = $"[{OPCODE_SUBSCRIBE}, \"{eventName}\"]"; string message = $"[{OPCODE_SUBSCRIBE}, \"{eventName}\"]";
@@ -77,6 +78,8 @@ public class LcuWebsocket : IDisposable
await _socket.SendAsync(memory, WebSocketMessageType.Text, true, CancellationToken.None); await _socket.SendAsync(memory, WebSocketMessageType.Text, true, CancellationToken.None);
} }
Connected?.Invoke(this, EventArgs.Empty);
while (_socket.State is WebSocketState.Open) while (_socket.State is WebSocketState.Open)
{ {
try try
+1 -2
View File
@@ -7,8 +7,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.0" /> <PackageReference Include="CliWrap" Version="3.10.1" />
<PackageReference Include="MoonSharp" Version="2.0.0" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.GameClient;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum TeamId
{
ALL,
UNKNOWN,
ORDER,
CHAOS,
NEUTRAL,
}
public record class PlayerResponse(
[property: JsonPropertyName("championName")] string ChampionName,
[property: JsonPropertyName("isBot")] bool IsBot,
[property: JsonPropertyName("rawChampionName")] string RawChampionName,
[property: JsonPropertyName("team")] TeamId Team,
[property: JsonPropertyName("riotId")] string RiotId,
[property: JsonPropertyName("riotIdGameName")] string RiotIdGameName,
[property: JsonPropertyName("riotIdTagLine")] string RiotIdTagLine
);
+69
View File
@@ -0,0 +1,69 @@
using System.Diagnostics;
namespace LeagueAPI.Utils;
public class ProcessWatcher : IDisposable
{
private bool _isDisposed;
private Timer _timer;
public bool IsGameRunning { get; private set; } = false;
public event EventHandler? LeagueOfLegendsExeFound;
public TimeSpan Interval
{
get => field;
set
{
if (field != value)
{
field = value;
_timer?.Change(TimeSpan.Zero, value);
}
}
} = TimeSpan.FromSeconds(10);
public ProcessWatcher()
{
_timer = new(OnTimerTick, null, TimeSpan.Zero, Interval);
}
private void OnTimerTick(object? state)
{
if (Process.GetProcessesByName("League of Legends").Length != 0)
{
if (!IsGameRunning)
{
LeagueOfLegendsExeFound?.Invoke(this, EventArgs.Empty);
}
IsGameRunning = true;
}
else
{
IsGameRunning = false;
}
}
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
_timer?.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
}