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

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

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);