Add project files.

This commit is contained in:
2026-03-08 20:45:01 +01:00
commit 053a4052dd
45 changed files with 2578 additions and 0 deletions

63
.gitattributes vendored Normal file
View File

@@ -0,0 +1,63 @@
###############################################################################
# Set default behavior to automatically normalize line endings.
###############################################################################
* text=auto
###############################################################################
# Set default behavior for command prompt diff.
#
# This is need for earlier builds of msysgit that does not have it on by
# default for csharp files.
# Note: This is only used by command line
###############################################################################
#*.cs diff=csharp
###############################################################################
# Set the merge driver for project and solution files
#
# Merging from the command prompt will add diff markers to the files if there
# are conflicts (Merging from VS is not affected by the settings below, in VS
# the diff markers are never inserted). Diff markers may cause the following
# file extensions to fail to load in VS. An alternative would be to treat
# these files as binary and thus will always conflict and require user
# intervention with every merge. To do so, just uncomment the entries below
###############################################################################
#*.sln merge=binary
#*.csproj merge=binary
#*.vbproj merge=binary
#*.vcxproj merge=binary
#*.vcproj merge=binary
#*.dbproj merge=binary
#*.fsproj merge=binary
#*.lsproj merge=binary
#*.wixproj merge=binary
#*.modelproj merge=binary
#*.sqlproj merge=binary
#*.wwaproj merge=binary
###############################################################################
# behavior for image files
#
# image files are treated as binary by default.
###############################################################################
#*.jpg binary
#*.png binary
#*.gif binary
###############################################################################
# diff behavior for common document formats
#
# Convert binary document formats to text before diffing them. This feature
# is only available from the command line. Turn it on by uncommenting the
# entries below.
###############################################################################
#*.doc diff=astextplain
#*.DOC diff=astextplain
#*.docx diff=astextplain
#*.DOCX diff=astextplain
#*.dot diff=astextplain
#*.DOT diff=astextplain
#*.pdf diff=astextplain
#*.PDF diff=astextplain
#*.rtf diff=astextplain
#*.RTF diff=astextplain

363
.gitignore vendored Normal file
View File

@@ -0,0 +1,363 @@
## Ignore Visual Studio temporary files, build results, and
## files generated by popular Visual Studio add-ons.
##
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
*.sln.docstates
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
[Rr]elease/
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
# Uncomment if you have tasks that create the project's static files in wwwroot
#wwwroot/
# Visual Studio 2017 auto generated files
Generated\ Files/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
[Rr]eleasePS/
dlldata.c
# Benchmark Results
BenchmarkDotNet.Artifacts/
# .NET Core
project.lock.json
project.fragment.lock.json
artifacts/
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_h.h
*.ilk
*.meta
*.obj
*.iobj
*.pch
*.pdb
*.ipdb
*.pgc
*.pgd
*.rsp
*.sbr
*.tlb
*.tli
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
.builds
*.pidb
*.svclog
*.scc
# Chutzpah Test files
_Chutzpah*
# Visual C++ cache files
ipch/
*.aps
*.ncb
*.opendb
*.opensdf
*.sdf
*.cachefile
*.VC.db
*.VC.VC.opendb
# Visual Studio profiler
*.psess
*.vsp
*.vspx
*.sap
# Visual Studio Trace Files
*.e2e
# TFS 2012 Local Workspace
$tf/
# Guidance Automation Toolkit
*.gpState
# ReSharper is a .NET coding add-in
_ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# TeamCity is a build add-in
_TeamCity*
# DotCover is a Code Coverage Tool
*.dotCover
# AxoCover is a Code Coverage Tool
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
# NCrunch
_NCrunch_*
.*crunch*.local.xml
nCrunchTemp_*
# MightyMoose
*.mm.*
AutoTest.Net/
# Web workbench (sass)
.sass-cache/
# Installshield output folder
[Ee]xpress/
# DocProject is a documentation generator add-in
DocProject/buildhelp/
DocProject/Help/*.HxT
DocProject/Help/*.HxC
DocProject/Help/*.hhc
DocProject/Help/*.hhk
DocProject/Help/*.hhp
DocProject/Help/Html2
DocProject/Help/html
# Click-Once directory
publish/
# Publish Web Output
*.[Pp]ublish.xml
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
# checkin your Azure Web App publish settings, but sensitive information contained
# in these scripts will be unencrypted
PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
!**/[Pp]ackages/build/
# Uncomment if necessary however generally it will be regenerated when needed
#!**/[Pp]ackages/repositories.config
# NuGet v3's project.json files produces more ignorable files
*.nuget.props
*.nuget.targets
# Microsoft Azure Build Output
csx/
*.build.csdef
# Microsoft Azure Emulator
ecf/
rcf/
# Windows Store app package directories and files
AppPackages/
BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!?*.[Cc]ache/
# Others
ClientBin/
~$*
*~
*.dbmdl
*.dbproj.schemaview
*.jfm
*.pfx
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
# Since there are multiple workflows, uncomment next line to ignore bower_components
# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622)
#bower_components/
# RIA/Silverlight projects
Generated_Code/
# Backup & report files from converting an old project file
# to a newer Visual Studio version. Backup files are not needed,
# because we have git ;-)
_UpgradeReport_Files/
Backup*/
UpgradeLog*.XML
UpgradeLog*.htm
ServiceFabricBackup/
*.rptproj.bak
# SQL Server files
*.mdf
*.ldf
*.ndf
# Business Intelligence projects
*.rdl.data
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
# GhostDoc plugin setting file
*.GhostDoc.xml
# Node.js Tools for Visual Studio
.ntvs_analysis.dat
node_modules/
# Visual Studio 6 build log
*.plg
# Visual Studio 6 workspace options file
*.opt
# Visual Studio 6 auto-generated workspace file (contains which files were open etc.)
*.vbw
# Visual Studio LightSwitch build output
**/*.HTMLClient/GeneratedArtifacts
**/*.DesktopClient/GeneratedArtifacts
**/*.DesktopClient/ModelManifest.xml
**/*.Server/GeneratedArtifacts
**/*.Server/ModelManifest.xml
_Pvt_Extensions
# Paket dependency manager
.paket/paket.exe
paket-files/
# FAKE - F# Make
.fake/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
*.pyc
# Cake - Uncomment if you are using it
# tools/**
# !tools/packages.config
# Tabs Studio
*.tss
# Telerik's JustMock configuration file
*.jmconfig
# BizTalk build output
*.btp.cs
*.btm.cs
*.odx.cs
*.xsd.cs
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
*.binlog
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd

21
LICENSE.txt Normal file
View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

177
LeagueAPI/APIClient.cs Normal file
View File

@@ -0,0 +1,177 @@
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;
public class APIClient : IDisposable
{
private const string ALL_RANDOM_ALL_CHAMPIONS = "101301";
private const string DDRAGON_BASE_URL = "https://ddragon.leagueoflegends.com";
private const int MAYHEM_QUEUE_ID = 2400;
private bool _isDisposed;
private readonly LcuHttpClient _lcuHttpClient;
private readonly HttpClient _dDragonHttpClient;
private string? _latestVersion;
private readonly Dictionary<string, ChampionResponse> _championResponseCache = [];
public APIClient()
{
_lcuHttpClient = new(new LcuHttpClientHandler());
_dDragonHttpClient = new HttpClient() { BaseAddress = new Uri(DDRAGON_BASE_URL) };
}
public async Task<Dictionary<string, LolChallengesUIChallenge>> GetAllChallengesAsync()
{
return await _lcuHttpClient.GetContentAsync<Dictionary<string, LolChallengesUIChallenge>>("/lol-challenges/v1/challenges/local-player") ?? [];
}
public async Task<int[]> GetAllRandomAllChampionsCompletedChampionsAsync()
{
Dictionary<string, LolChallengesUIChallenge> challenges = await GetAllChallengesAsync();
if (!challenges.TryGetValue(ALL_RANDOM_ALL_CHAMPIONS, out LolChallengesUIChallenge? allRandomAllChampions))
{
return [];
}
return allRandomAllChampions.CompletedIds ?? [];
}
public async Task<int[]> GetSelectableChampionIdsAsync()
{
ChampSelectSession? session = await _lcuHttpClient.GetContentAsync<ChampSelectSession>("/lol-champ-select/v1/session");
return GetSelectableChampionIds(session);
}
public int[] GetSelectableChampionIds(ChampSelectSession? session)
{
if (session is null || !session.BenchEnabled)
{
return [];
}
IEnumerable<int> benchChampions = session.BenchChampions.Select(b => b.ChampionId);
IEnumerable<int> teamChampions = session.MyTeam.Select(c => c.ChampionId);
return [.. benchChampions, .. teamChampions];
}
private record struct LobbyChangeGame([property: JsonPropertyName("queueId")] int QueueId);
public async Task CreateMayhemLobbyAsync()
{
await _lcuHttpClient.PostAsJsonAsync("/lol-lobby/v2/lobby", new LobbyChangeGame(MAYHEM_QUEUE_ID));
}
public async Task StartMatchmakingQueueAsync()
{
await _lcuHttpClient.PostAsJsonAsync("/lol-lobby/v2/lobby/matchmaking/search", "");
}
public async Task MatchmakingAcceptAsync()
{
await _lcuHttpClient.PostAsJsonAsync("/lol-matchmaking/v1/ready-check/accept", "");
}
#region DDragon
private 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();
}
private async Task<T?> DDragonGetAsync<T>(string path)
{
string json = await DDragonGetAsync(path);
return JsonSerializer.Deserialize<T>(json);
}
private async Task<string> GetPatch(string patch = "latest")
{
if (patch is "latest")
{
return _latestVersion ??= (await DDragonGetAsync<string[]>("/api/versions.json") ?? []).FirstOrDefault() ?? throw new Exception($"Failed to fetch version: {patch}");
}
else
{
return patch;
}
}
public async Task<ChampionData[]> GetAllChampionsAsync(string patch = "latest")
{
patch = await GetPatch(patch);
if (!_championResponseCache.TryGetValue(patch, out ChampionResponse? championResponse) || championResponse is null || championResponse.Data is null)
{
string apiUrl = $"/cdn/{patch}/data/en_US/champion.json";
championResponse = await DDragonGetAsync<ChampionResponse>(apiUrl);
if (championResponse is not null)
{
_championResponseCache[patch] = championResponse;
}
}
if (championResponse is null || championResponse.Data is null)
{
return [];
}
return [.. championResponse.Data.Values];
}
public async Task<ChampionData?> GetChampionByIdAsync(int id)
{
ChampionData[] championData = await GetAllChampionsAsync();
if (championData is not { Length: > 0 })
{
return null;
}
return championData.FirstOrDefault(c => c.Id == id);
}
#endregion
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
_lcuHttpClient?.Dispose();
_dDragonHttpClient?.Dispose();
}
_championResponseCache?.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
}

82
LeagueAPI/Extensions.cs Normal file
View File

@@ -0,0 +1,82 @@
using System.Diagnostics;
using System.Management;
using System.Net.WebSockets;
using System.Text;
namespace LeagueAPI;
public record WebsocketMessageResult(byte[] Message, WebSocketMessageType MessageType);
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)
{
if (s is null || !int.TryParse(s, out int i))
{
return defaultValue;
}
return i;
}
public string AsFormatWith(params object?[] args)
{
if (s is null)
{
return string.Empty;
}
return string.Format(s, args);
}
}
extension<T>(Task<T> task)
{
public T WaitForResult()
{
return task.GetAwaiter().GetResult();
}
}
extension(WebSocket socket)
{
public async Task<WebsocketMessageResult> ReadFullMessage(CancellationToken cancellationToken)
{
byte[] buffer = new byte[1024 * 8];
using MemoryStream memoryStream = new();
ValueWebSocketReceiveResult receiveResult;
do
{
Memory<byte> memoryBuffer = new(buffer);
receiveResult = await socket.ReceiveAsync(memoryBuffer, cancellationToken);
memoryStream.Write(buffer, 0, receiveResult.Count);
}
while (!receiveResult.EndOfMessage);
if (receiveResult.MessageType is WebSocketMessageType.Close)
{
return new([], receiveResult.MessageType);
}
byte[] fullMessageBytes = memoryStream.ToArray();
return new(fullMessageBytes, receiveResult.MessageType);
}
}
}

140
LeagueAPI/LcuWebsocket.cs Normal file
View File

@@ -0,0 +1,140 @@
using System.Diagnostics.CodeAnalysis;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using LeagueAPI.Utils;
namespace LeagueAPI;
public class LcuWebsocket : IDisposable
{
private const int OPCODE_SUBSCRIBE = 5;
private const int OPCODE_UNSUBSCRIBE = 6;
private const int OPCODE_SEND = 8;
private static readonly IReadOnlyList<string> SUBSCRIBE_EVENTS =
[
"OnJsonApiEvent_lol-champ-select_v1_session",
"OnJsonApiEvent_lol-matchmaking_v1_search",
"OnJsonApiEvent_lol-matchmaking_v1_ready-check",
];
private bool _isDisposed;
private readonly ClientWebSocket _socket = new();
public event EventHandler<LcuApiEvent>? LcuApiEvent;
public event EventHandler<Exception>? LcuApiException;
[MemberNotNull(nameof(RiotAuthentication))]
public ProcessInfo? ProcessInfo { get; internal set; }
public RiotAuthentication? RiotAuthentication
{
get
{
if (ProcessInfo is not null)
{
return new RiotAuthentication(ProcessInfo.RemotingAuthToken);
}
return null;
}
}
public async Task Connect()
{
ProcessInfo = ProcessFinder.GetProcessInfo();
if (!ProcessFinder.IsPortOpen(ProcessInfo))
{
throw new InvalidOperationException("Failed to connect to LCUx process port.");
}
// ignore SSL certificate errors
_socket.Options.RemoteCertificateValidationCallback = (_, _, _, _) => true;
_socket.Options.Credentials = RiotAuthentication.ToNetworkCredential();
Uri uri = new($"wss://127.0.0.1:{ProcessInfo.AppPort}/");
try
{
await _socket.ConnectAsync(uri, CancellationToken.None);
foreach (string eventName in SUBSCRIBE_EVENTS)
{
string message = $"[{OPCODE_SUBSCRIBE}, \"{eventName}\"]";
byte[] buffer = Encoding.UTF8.GetBytes(message);
Memory<byte> memory = new(buffer);
await _socket.SendAsync(memory, WebSocketMessageType.Text, true, CancellationToken.None);
}
while (_socket.State is WebSocketState.Open)
{
try
{
WebsocketMessageResult result = await _socket.ReadFullMessage(CancellationToken.None);
if (result.MessageType is WebSocketMessageType.Close)
{
await _socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None);
}
else
{
if (result.Message.Length == 0)
{
continue;
}
JsonElement response = JsonElement.Parse(result.Message);
if (response.ValueKind != JsonValueKind.Array
|| response.GetArrayLength() != 3
|| !response[0].TryGetInt32(out int value)
|| value != OPCODE_SEND)
{
continue;
}
LcuApiEvent? apiEvent = response[2].Deserialize<LcuApiEvent>();
if (apiEvent is null)
{
continue;
}
LcuApiEvent?.Invoke(this, apiEvent);
}
}
catch (Exception ex)
{
LcuApiException?.Invoke(this, ex);
}
}
}
catch (Exception ex) when (ex is not WebSocketException)
{
LcuApiException?.Invoke(this, ex);
}
}
#region IDisposable
protected virtual void Dispose(bool disposing)
{
if (!_isDisposed)
{
if (disposing)
{
if (_socket.State == WebSocketState.Open)
{
_socket.CloseAsync(WebSocketCloseStatus.NormalClosure, string.Empty, CancellationToken.None)
.Wait();
}
_socket?.Dispose();
ProcessInfo = null;
}
_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

@@ -0,0 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CliWrap" Version="3.10.0" />
<PackageReference Include="System.Management" Version="10.0.3" />
</ItemGroup>
</Project>

16
LeagueAPI/LeagueEvent.cs Normal file
View File

@@ -0,0 +1,16 @@
using System.Text.Json.Nodes;
using System.Text.Json.Serialization;
namespace LeagueAPI;
public record class LcuApiEvent
{
[JsonPropertyName("data")]
public JsonNode? Data { get; init; }
[JsonPropertyName("eventType")]
public string? EventType { get; init; }
[JsonPropertyName("uri")]
public string? Uri { get; init; }
}

View File

@@ -0,0 +1,69 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.Challenges;
public record class LolChallengesChallengeData
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("legacy")]
public bool Legacy { get; init; }
[JsonPropertyName("percentile")]
public double Percentile { get; init; }
[JsonPropertyName("position")]
public int Position { get; init; }
[JsonPropertyName("playersInLevel")]
public int PlayersInLevel { get; init; }
[JsonPropertyName("initValue")]
public double InitValue { get; init; }
[JsonPropertyName("previousLevel")]
public string? PreviousLevel { get; init; }
[JsonPropertyName("previousValue")]
public double PreviousValue { get; init; }
[JsonPropertyName("previousThreshold")]
public double PreviousThreshold { get; init; }
[JsonPropertyName("newLevels")]
public string[]? NewLevels { get; init; }
[JsonPropertyName("currentLevel")]
public string? CurrentLevel { get; init; }
[JsonPropertyName("currentValue")]
public double CurrentValue { get; init; }
[JsonPropertyName("currentThreshold")]
public double CurrentThreshold { get; init; }
[JsonPropertyName("currentLevelAchievedTime")]
public long CurrentLevelAchievedTime { get; init; }
[JsonPropertyName("nextLevel")]
public string? NextLevel { get; init; }
[JsonPropertyName("nextThreshold")]
public double NextThreshold { get; init; }
[JsonPropertyName("friendsAtLevels")]
public LolChallengesFriendLevelsData[]? FriendsAtLevels { get; init; }
[JsonPropertyName("idListType")]
public string? IdListType { get; init; }
[JsonPropertyName("availableIds")]
public int[]? AvailableIds { get; init; }
[JsonPropertyName("completedIds")]
public int[]? CompletedIds { get; init; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.Challenges;
public record class LolChallengesFriendLevelsData
{
[JsonPropertyName("level")]
public string? Level { get; init; }
[JsonPropertyName("friends")]
public string[]? Friends { get; init; }
}

View File

@@ -0,0 +1,123 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.Challenges;
public record class LolChallengesUIChallenge
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("description")]
public string? Description { get; init; }
[JsonPropertyName("descriptionShort")]
public string? DescriptionShort { get; init; }
[JsonPropertyName("iconPath")]
public string? IconPath { get; init; }
[JsonPropertyName("category")]
public string? Category { get; init; }
[JsonPropertyName("nextLevelIconPath")]
public string? NextLevelIconPath { get; init; }
[JsonPropertyName("currentLevel")]
public string? CurrentLevel { get; init; }
[JsonPropertyName("nextLevel")]
public string? NextLevel { get; init; }
[JsonPropertyName("previousLevel")]
public string? PreviousLevel { get; init; }
[JsonPropertyName("previousValue")]
public double PreviousValue { get; init; }
[JsonPropertyName("currentValue")]
public double CurrentValue { get; init; }
[JsonPropertyName("currentThreshold")]
public double CurrentThreshold { get; init; }
[JsonPropertyName("nextThreshold")]
public double NextThreshold { get; init; }
[JsonPropertyName("pointsAwarded")]
public long PointsAwarded { get; init; }
[JsonPropertyName("percentile")]
public double Percentile { get; init; }
[JsonPropertyName("currentLevelAchievedTime")]
public long CurrentLevelAchievedTime { get; init; }
[JsonPropertyName("position")]
public int Position { get; init; }
[JsonPropertyName("playersInLevel")]
public int PlayersInLevel { get; init; }
[JsonPropertyName("isApex")]
public bool IsApex { get; init; }
[JsonPropertyName("isCapstone")]
public bool IsCapstone { get; init; }
[JsonPropertyName("gameModes")]
public string[]? GameModes { get; init; }
[JsonPropertyName("friendsAtLevels")]
public LolChallengesFriendLevelsData[]? FriendsAtLevels { get; init; }
[JsonPropertyName("parentId")]
public long ParentId { get; init; }
[JsonPropertyName("parentName")]
public string? ParentName { get; init; }
[JsonPropertyName("childrenIds")]
public long[]? ChildrenIds { get; init; }
[JsonPropertyName("capstoneGroupId")]
public long CapstoneGroupId { get; init; }
[JsonPropertyName("capstoneGroupName")]
public string? CapstoneGroupName { get; init; }
[JsonPropertyName("source")]
public string? Source { get; init; }
[JsonPropertyName("thresholds")]
public object? Thresholds { get; init; }
[JsonPropertyName("levelToIconPath")]
public Dictionary<string, string>? LevelToIconPath { get; init; }
[JsonPropertyName("valueMapping")]
public string? ValueMapping { get; init; }
[JsonPropertyName("hasLeaderboard")]
public bool HasLeaderboard { get; init; }
[JsonPropertyName("isReverseDirection")]
public bool IsReverseDirection { get; init; }
[JsonPropertyName("priority")]
public double Priority { get; init; }
[JsonPropertyName("idListType")]
public string? IdListType { get; init; }
[JsonPropertyName("availableIds")]
public int[]? AvailableIds { get; init; }
[JsonPropertyName("completedIds")]
public int[]? CompletedIds { get; init; }
[JsonPropertyName("retireTimestamp")]
public long RetireTimestamp { get; init; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class BenchChampion
{
[JsonPropertyName("championId")]
public int ChampionId { get; init; }
[JsonPropertyName("isPriority")]
public bool IsPriority { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectBannedChampions
{
[JsonPropertyName("myTeamBans")]
public int[] MyTeamBans { get; init; } = [];
[JsonPropertyName("theirTeamBans")]
public int[] TheirTeamBans { get; init; } = [];
[JsonPropertyName("numBans")]
public int NumBans { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectChatRoomDetails
{
[JsonPropertyName("multiUserChatId")]
public string? MultiUserChatId { get; init; }
[JsonPropertyName("multiUserChatPassword")]
public string? MultiUserChatPassword { get; init; }
[JsonPropertyName("mucJwtDto")]
public MucJwtDto? MucJwtDto { get; init; }
}

View File

@@ -0,0 +1,75 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectPlayerSelection
{
[JsonPropertyName("cellId")]
public long CellId { get; init; }
[JsonPropertyName("championId")]
public int ChampionId { get; init; }
[JsonPropertyName("selectedSkinId")]
public int SelectedSkinId { get; init; }
[JsonPropertyName("wardSkinId")]
public long WardSkinId { get; init; }
[JsonPropertyName("spell1Id")]
public ulong Spell1Id { get; init; }
[JsonPropertyName("spell2Id")]
public ulong Spell2Id { get; init; }
[JsonPropertyName("team")]
public int Team { get; init; }
[JsonPropertyName("assignedPosition")]
public string? AssignedPosition { get; init; }
[JsonPropertyName("championPickIntent")]
public int ChampionPickIntent { get; init; }
[JsonPropertyName("playerType")]
public string? PlayerType { get; init; }
[JsonPropertyName("summonerId")]
public ulong SummonerId { get; init; }
[JsonPropertyName("gameName")]
public string? GameName { get; init; }
[JsonPropertyName("tagLine")]
public string? TagLine { get; init; }
[JsonPropertyName("puuid")]
public string? Puuid { get; init; }
[JsonPropertyName("isHumanoid")]
public bool IsHumanoid { get; init; }
[JsonPropertyName("nameVisibilityType")]
public string? NameVisibilityType { get; init; }
[JsonPropertyName("playerAlias")]
public string? PlayerAlias { get; init; }
[JsonPropertyName("obfuscatedSummonerId")]
public ulong ObfuscatedSummonerId { get; init; }
[JsonPropertyName("obfuscatedPuuid")]
public string? ObfuscatedPuuid { get; init; }
[JsonPropertyName("isAutofilled")]
public bool IsAutofilled { get; init; }
[JsonPropertyName("internalName")]
public string? InternalName { get; init; }
[JsonPropertyName("pickMode")]
public int PickMode { get; init; }
[JsonPropertyName("pickTurn")]
public int PickTurn { get; init; }
}

View File

@@ -0,0 +1,108 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectSession
{
[JsonPropertyName("id")]
public string? Id { get; init; }
[JsonPropertyName("gameId")]
public ulong GameId { get; init; }
[JsonPropertyName("queueId")]
public int QueueId { get; init; }
[JsonPropertyName("timer")]
public ChampSelectTimer? Timer { get; init; }
[JsonPropertyName("chatDetails")]
public ChampSelectChatRoomDetails? ChatDetails { get; init; }
[JsonPropertyName("myTeam")]
public ChampSelectPlayerSelection[] MyTeam { get; init; } = [];
[JsonPropertyName("theirTeam")]
public ChampSelectPlayerSelection[] TheirTeam { get; init; } = [];
[JsonPropertyName("trades")]
public ChampSelectSwapContract[] Trades { get; init; } = [];
[JsonPropertyName("pickOrderSwaps")]
public ChampSelectSwapContract[] PickOrderSwaps { get; init; } = [];
[JsonPropertyName("positionSwaps")]
public ChampSelectSwapContract[] PositionSwaps { get; init; } = [];
[JsonPropertyName("actions")]
public object[] Actions { get; init; } = [];
[JsonPropertyName("bans")]
public ChampSelectBannedChampions? Bans { get; init; }
[JsonPropertyName("localPlayerCellId")]
public long LocalPlayerCellId { get; init; }
[JsonPropertyName("isSpectating")]
public bool IsSpectating { get; init; }
[JsonPropertyName("allowSkinSelection")]
public bool AllowSkinSelection { get; init; }
[JsonPropertyName("allowSubsetChampionPicks")]
public bool AllowSubsetChampionPicks { get; init; }
[JsonPropertyName("allowDuplicatePicks")]
public bool AllowDuplicatePicks { get; init; }
[JsonPropertyName("allowPlayerPickSameChampion")]
public bool AllowPlayerPickSameChampion { get; init; }
[JsonPropertyName("disallowBanningTeammateHoveredChampions")]
public bool DisallowBanningTeammateHoveredChampions { get; init; }
[JsonPropertyName("allowBattleBoost")]
public bool AllowBattleBoost { get; init; }
[JsonPropertyName("boostableSkinCount")]
public int BoostableSkinCount { get; init; }
[JsonPropertyName("allowRerolling")]
public bool AllowRerolling { get; init; }
[JsonPropertyName("rerollsRemaining")]
public ulong RerollsRemaining { get; init; }
[JsonPropertyName("allowLockedEvents")]
public bool AllowLockedEvents { get; init; }
[JsonPropertyName("lockedEventIndex")]
public int LockedEventIndex { get; init; }
[JsonPropertyName("benchEnabled")]
public bool BenchEnabled { get; init; }
[JsonPropertyName("benchChampions")]
public BenchChampion[] BenchChampions { get; init; } = [];
[JsonPropertyName("counter")]
public long Counter { get; init; }
[JsonPropertyName("skipChampionSelect")]
public bool SkipChampionSelect { get; init; }
[JsonPropertyName("hasSimultaneousBans")]
public bool HasSimultaneousBans { get; init; }
[JsonPropertyName("hasSimultaneousPicks")]
public bool HasSimultaneousPicks { get; init; }
[JsonPropertyName("showQuitButton")]
public bool ShowQuitButton { get; init; }
[JsonPropertyName("isLegacyChampSelect")]
public bool IsLegacyChampSelect { get; init; }
[JsonPropertyName("isCustomGame")]
public bool IsCustomGame { get; init; }
}

View File

@@ -0,0 +1,15 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectSwapContract
{
[JsonPropertyName("id")]
public long Id { get; init; }
[JsonPropertyName("cellId")]
public long CellId { get; init; }
[JsonPropertyName("state")]
public ChampSelectSwapState State { get; init; }
}

View File

@@ -0,0 +1,16 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum ChampSelectSwapState
{
ACCEPTED,
CANCELLED,
DECLINED,
SENT,
RECEIVED,
INVALID,
BUSY,
AVAILABLE
}

View File

@@ -0,0 +1,21 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class ChampSelectTimer
{
[JsonPropertyName("adjustedTimeLeftInPhase")]
public long AdjustedTimeLeftInPhase { get; init; }
[JsonPropertyName("totalTimeInPhase")]
public long TotalTimeInPhase { get; init; }
[JsonPropertyName("phase")]
public string? Phase { get; init; }
[JsonPropertyName("isInfinite")]
public bool IsInfinite { get; init; }
[JsonPropertyName("internalNowInEpochMs")]
public ulong InternalNowInEpochMs { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ChampSelect;
public record class MucJwtDto
{
[JsonPropertyName("jwt")]
public string? Jwt { get; init; }
[JsonPropertyName("channelClaim")]
public string? ChannelClaim { get; init; }
[JsonPropertyName("domain")]
public string? Domain { get; init; }
[JsonPropertyName("targetRegion")]
public string? TargetRegion { get; init; }
}

View File

@@ -0,0 +1,19 @@
using System.Text.Json.Serialization;
using LeagueAPI.Models.DDragon.Champions;
namespace LeagueAPI.Models.DDragon;
public record class ChampionResponse
{
[JsonPropertyName("type")]
public string? Type { get; init; }
[JsonPropertyName("format")]
public string? Format { get; init; }
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("data")]
public Dictionary<string, ChampionData>? Data { get; init; }
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.DDragon.Champions;
public record ChampionData
{
[JsonPropertyName("version")]
public string? Version { get; init; }
[JsonPropertyName("id")]
public string? IdName { get; init; }
[JsonIgnore]
public int Id => Key?.ParseInt(-1) ?? -1;
[JsonPropertyName("key")]
public string? Key { get; init; }
[JsonPropertyName("name")]
public string? Name { get; init; }
[JsonPropertyName("title")]
public string? Title { get; init; }
[JsonPropertyName("blurb")]
public string? Blurb { get; init; }
[JsonPropertyName("info")]
public ChampionDataInfo? Info { get; init; }
[JsonPropertyName("image")]
public ChampionDataImage? Image { get; init; }
[JsonPropertyName("tags")]
public string[]? Tags { get; init; }
[JsonPropertyName("partype")]
public string? Partype { get; init; }
[JsonPropertyName("stats")]
public ChampionDataStats? Stats { get; init; }
}

View File

@@ -0,0 +1,27 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.DDragon.Champions;
public record ChampionDataImage
{
[JsonPropertyName("full")]
public string? Full { get; init; }
[JsonPropertyName("sprite")]
public string? Sprite { get; init; }
[JsonPropertyName("group")]
public string? Group { get; init; }
[JsonPropertyName("x")]
public int X { get; init; }
[JsonPropertyName("y")]
public int Y { get; init; }
[JsonPropertyName("w")]
public int W { get; init; }
[JsonPropertyName("h")]
public int H { get; init; }
}

View File

@@ -0,0 +1,18 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.DDragon.Champions;
public record ChampionDataInfo
{
[JsonPropertyName("attack")]
public int Attack { get; init; }
[JsonPropertyName("defense")]
public int Defense { get; init; }
[JsonPropertyName("magic")]
public int Magic { get; init; }
[JsonPropertyName("difficulty")]
public int Difficulty { get; init; }
}

View File

@@ -0,0 +1,66 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.DDragon.Champions;
public record ChampionDataStats
{
[JsonPropertyName("hp")]
public float Hp { get; init; }
[JsonPropertyName("hpperlevel")]
public float HpPerLevel { get; init; }
[JsonPropertyName("mp")]
public float Mp { get; init; }
[JsonPropertyName("mpperlevel")]
public float MpPerLevel { get; init; }
[JsonPropertyName("movespeed")]
public float Movespeed { get; init; }
[JsonPropertyName("armor")]
public float Armor { get; init; }
[JsonPropertyName("armorperlevel")]
public float ArmorPerLevel { get; init; }
[JsonPropertyName("spellblock")]
public float Spellblock { get; init; }
[JsonPropertyName("spellblockperlevel")]
public float SpellblockPerLevel { get; init; }
[JsonPropertyName("attackrange")]
public float AttackRange { get; init; }
[JsonPropertyName("hpregen")]
public float HpRegen { get; init; }
[JsonPropertyName("hpregenperlevel")]
public float HpRegenPerLevel { get; init; }
[JsonPropertyName("mpregen")]
public float MpRegen { get; init; }
[JsonPropertyName("mpregenperlevel")]
public float MpRegenPerLevel { get; init; }
[JsonPropertyName("crit")]
public float Crit { get; init; }
[JsonPropertyName("critperlevel")]
public float CritPerLevel { get; init; }
[JsonPropertyName("attackdamage")]
public float AttackDamage { get; init; }
[JsonPropertyName("attackdamageperlevel")]
public float AttackDamagePerLevel { get; init; }
[JsonPropertyName("attackspeedperlevel")]
public float AttackspeedPerLevel { get; init; }
[JsonPropertyName("attackspeed")]
public float Attackspeed { get; init; }
}

View File

@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ReadyCheck;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum LolMatchmakingMatchmakingDodgeWarning
{
ConnectionWarning,
Penalty,
Warning,
None,
}

View File

@@ -0,0 +1,24 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ReadyCheck;
public record class LolMatchmakingMatchmakingReadyCheckResource
{
[JsonPropertyName("state")]
public LolMatchmakingMatchmakingReadyCheckState State { get; init; }
[JsonPropertyName("playerResponse")]
public LolMatchmakingMatchmakingReadyCheckResponse PlayerResponse { get; init; }
[JsonPropertyName("dodgeWarning")]
public LolMatchmakingMatchmakingDodgeWarning DodgeWarning { get; init; }
[JsonPropertyName("timer")]
public float Timer { get; init; }
[JsonPropertyName("declinerIds")]
public ulong[]? DeclinerIds { get; init; }
[JsonPropertyName("suppressUx")]
public bool SuppressUx { get; init; }
}

View File

@@ -0,0 +1,11 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ReadyCheck;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum LolMatchmakingMatchmakingReadyCheckResponse
{
Declined,
Accepted,
None,
}

View File

@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace LeagueAPI.Models.ReadyCheck;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum LolMatchmakingMatchmakingReadyCheckState
{
Error,
PartyNotReady,
StrangerNotReady,
EveryoneReady,
InProgress,
Invalid,
}

66
LeagueAPI/QueueId.cs Normal file
View File

@@ -0,0 +1,66 @@
namespace LeagueAPI;
/// <summary>
/// Removed some old entries
/// https://static.developer.riotgames.com/docs/lol/queues.json
/// </summary>
public enum QueueId
{
Customgames = 0, // ()
Old1v1Snowdown = 72, // 1v1 Snowdown Showdown games ()
Old2v2Snowdown = 73, // 2v2 Snowdown Showdown games ()
Old6v6Hexakill = 75, // 6v6 Hexakill games ()
UltraRapidFire = 76, // Ultra Rapid Fire games ()
OneForAllMirrorMode = 78, // One For All: Mirror Mode games ()
UltraRapidFireCoopVsAi = 83, // Co-op vs AI Ultra Rapid Fire games ()
Old6v6HexakillTwistedTreeline = 98, // 6v6 Hexakill games ()
ARAMButchersBridge = 100, // 5v5 ARAM games ()
OldNemesis = 310, // Nemesis games ()
OldBlackMarketBrawlers = 313, // Black Market Brawlers games ()
OldDefinitelyNotDominion = 317, // Definitely Not Dominion games ()
OldAllRandom = 325, // All Random games ()
DraftPick = 400, // 5v5 Draft Pick games ()
RankedSolo = 420, // 5v5 Ranked Solo games ()
BlindPick = 430, // 5v5 Blind Pick games ()
RankedFlex = 440, // 5v5 Ranked Flex games ()
ARAM = 450, // 5v5 ARAM games ()
Swiftplay = 480, // Swiftplay Games ()
NormalQuickplay = 490, // Normal (Quickplay) ()
OldBloodHuntAssassin = 600, // Blood Hunt Assassin games ()
OldDarkStarSingularity = 610, // Dark Star: Singularity games ()
ClashSummonersRift = 700, // Summoner's Rift Clash games ()
ClashARAM = 720, // ARAM Clash games ()
CoopVsAiIntro = 870, // Co-op vs. AI Intro Bot games ()
CoopVsAiBeginner = 880, // Co-op vs. AI Beginner Bot games ()
CoopVsAiIntermediate = 890, // Co-op vs. AI Intermediate Bot games ()
ARURF = 900, // ARURF games ()
Ascension = 910, // Ascension games ()
LegendOfThePoroKing = 920, // Legend of the Poro King games ()
NexusSiege = 940, // Nexus Siege games ()
DoomBotsVoting = 950, // Doom Bots Voting games ()
DoomBotsStandard = 960, // Doom Bots Standard games ()
StarGuardianNormal = 980, // Star Guardian Invasion: Normal games ()
StarGuardianOnslaught = 990, // Star Guardian Invasion: Onslaught games ()
ProjectHunters = 1000, // PROJECT: Hunters games ()
SnowARURF = 1010, // Snow ARURF games ()
OneForAll = 1020, // One for All games ()
TFTNormal = 1090, // Teamfight Tactics games ()
TFTRanked = 1100, // Ranked Teamfight Tactics games ()
TFTTutorial = 1110, // Teamfight Tactics Tutorial games ()
TFTTest = 1111, // Teamfight Tactics test games ()
TFTChonccsTreasure = 1210, // Teamfight Tactics Choncc's Treasure Mode (null)
NexusBlitz = 1300, // Nexus Blitz games ()
UltimateSpellbook = 1400, // Ultimate Spellbook games ()
Arena = 1700, // Arena ()
Arena16Players = 1710, // Arena (16 player lobby)
Swarm1Player = 1810, // Swarm Mode Games (Swarm Mode 1 player)
Swarm2Players = 1820, // Swarm (Swarm Mode 2 players)
Swarm3Players = 1830, // Swarm (Swarm Mode 3 players)
Swarm4Players = 1840, // Swarm (Swarm Mode 4 players)
URFPick = 1900, // Pick URF games ()
Tutorial1 = 2000, // Tutorial 1 ()
Tutorial2 = 2010, // Tutorial 2 ()
Tutorial3 = 2020, // Tutorial 3 ()
Brawl = 2300, // Brawl ()
ARAMMayhem = 2400, // ARAM: Mayhem ()
}

View File

@@ -0,0 +1,48 @@
using System.Net;
using System.Net.Http.Headers;
using System.Text;
namespace LeagueAPI;
/// <summary>
/// Repesents authentication for the League Client.
/// </summary>
/// <param name="RemotingAuthToken"></param>
public class RiotAuthentication(string RemotingAuthToken)
{
/// <summary>
/// Username component of the authentication;
/// </summary>
public string Username { get; } = "riot";
/// <summary>
/// Password component of the authentication.
/// </summary>
public string Password { get; } = RemotingAuthToken;
/// <summary>
/// Authentication value before Base64 conversion.
/// </summary>
public string RawValue => Username + ":" + Password;
/// <summary>
/// Authentication value in Base64 format.
/// </summary>
public string Value => Convert.ToBase64String(Encoding.UTF8.GetBytes(RawValue));
/// <summary>
/// Get an AuthenticationHeaderValue instance.
/// </summary>
public AuthenticationHeaderValue ToAuthenticationHeaderValue()
{
return new AuthenticationHeaderValue("Basic", Value);
}
/// <summary>
/// Get an NetworkCredential instance.
/// </summary>
public NetworkCredential ToNetworkCredential()
{
return new NetworkCredential(Username, Password);
}
}

View File

@@ -0,0 +1,24 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace LeagueAPI.Utils;
public interface IPortTokenBehavior
{
bool TryGet(Process process, out string remotingAuthToken, out int appPort, [NotNullWhen(false)] out Exception? exception);
void MapArguments(IReadOnlyList<string> args, Dictionary<string, string> _args)
{
foreach (string arg in args)
{
if (arg.Length > 0 && arg.Contains('='))
{
string text = arg;
string[] array = text[2..].Split("=");
string key = array[0];
string value = array[1];
_args[key] = value;
}
}
}
}

View File

@@ -0,0 +1,42 @@
using System.Text.Json;
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
{
get
{
return Handler.ProcessInfo;
}
internal set
{
Handler.ProcessInfo = value;
}
}
public RiotAuthentication? RiotAuthentication => Handler.RiotAuthentication;
internal LcuHttpClient(LcuHttpClientHandler lcuHttpClientHandler)
: base(lcuHttpClientHandler)
{
Handler = lcuHttpClientHandler;
base.BaseAddress = new Uri("https://127.0.0.1");
}
public async Task<T?> GetContentAsync<T>(string requestUri) where T : class
{
HttpResponseMessage response = await GetAsync(requestUri);
if (!response.IsSuccessStatusCode)
{
return null;
}
string content = await response.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<T>(content);
}
}

View File

@@ -0,0 +1,151 @@
namespace LeagueAPI.Utils;
internal class LcuHttpClientHandler : HttpClientHandler
{
private Lazy<bool> _isFirstRequest = new(() => true);
private Lazy<bool> _isFailing = new(() => false);
public ProcessInfo? ProcessInfo { get; internal set; }
public RiotAuthentication? RiotAuthentication
{
get
{
if (ProcessInfo is not null)
{
return new RiotAuthentication(ProcessInfo.RemotingAuthToken);
}
return null;
}
}
public string? BaseAddress
{
get
{
if (ProcessInfo != null)
{
return $"https://127.0.0.1:{ProcessInfo.AppPort}";
}
return null;
}
}
internal LcuHttpClientHandler()
{
base.ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
}
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
HttpResponseMessage result;
int num;
try
{
if (_isFirstRequest.Value)
{
_isFirstRequest = new Lazy<bool>(() => false);
SetProcessInfo();
}
if (_isFailing.Value)
{
_isFailing = new Lazy<bool>(() => false);
SetProcessInfo();
}
PrepareRequestMessage(request);
result = await base.SendAsync(request, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
return result;
}
catch (InvalidOperationException)
{
_isFailing = new Lazy<bool>(() => true);
throw;
}
catch (HttpRequestException)
{
num = 1;
}
if (num == 1)
{
try
{
SetProcessInfo();
PrepareRequestMessage(request);
return await base.SendAsync(request, cancellationToken).ConfigureAwait(continueOnCapturedContext: false);
}
catch (InvalidOperationException)
{
_isFailing = new Lazy<bool>(() => true);
throw;
}
}
throw new Exception("Failed to send request");
}
protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken)
{
try
{
if (_isFirstRequest.Value)
{
_isFirstRequest = new Lazy<bool>(() => false);
SetProcessInfo();
}
if (_isFailing.Value)
{
_isFailing = new Lazy<bool>(() => false);
SetProcessInfo();
}
PrepareRequestMessage(request);
return base.Send(request, cancellationToken);
}
catch (InvalidOperationException)
{
_isFailing = new Lazy<bool>(() => true);
throw;
}
catch (HttpRequestException)
{
try
{
SetProcessInfo();
PrepareRequestMessage(request);
return base.Send(request, cancellationToken);
}
catch (InvalidOperationException)
{
_isFailing = new Lazy<bool>(() => true);
throw;
}
}
}
private void SetProcessInfo()
{
ProcessInfo = ProcessFinder.GetProcessInfo();
if (!ProcessFinder.IsPortOpen(ProcessInfo))
{
throw new InvalidOperationException("Failed to connect to LCUx process port.");
}
}
private void PrepareRequestMessage(HttpRequestMessage request)
{
if (BaseAddress != null)
{
Uri requestUri = new(BaseAddress + (request.RequestUri?.PathAndQuery ?? "/"));
request.RequestUri = requestUri;
}
request.Headers.Authorization = RiotAuthentication?.ToAuthenticationHeaderValue();
}
}

View File

@@ -0,0 +1,43 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
namespace LeagueAPI.Utils;
internal class PortTokenWithLockfile : IPortTokenBehavior
{
public bool TryGet(Process process, out string remotingAuthToken, out int appPort, [NotNullWhen(false)] out Exception? exception)
{
try
{
DirectoryInfo directory = new(Path.GetDirectoryName(process.MainModule?.FileName) ?? throw new FileNotFoundException("Lockfile not found.", "lockfile"));
FileInfo lockfile = new(Path.Join(directory.FullName, "lockfile"));
while (!lockfile.Exists)
{
if (directory.Root == directory)
{
break;
}
directory = directory.Parent ?? directory.Root;
lockfile = new(Path.Join(directory.FullName, "lockfile"));
}
using FileStream stream = lockfile.Open(FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
using StreamReader streamReader = new(stream);
string[] array = streamReader.ReadToEnd().Split(":");
if (array is not [string clientName, string pid, string port, string secret, string protocol] || !int.TryParse(port, out appPort))
{
throw new Exception("Failed to parse lockfile.");
}
remotingAuthToken = secret;
exception = null;
return true;
}
catch (Exception ex)
{
remotingAuthToken = string.Empty;
appPort = 0;
exception = ex;
return false;
}
}
}

View File

@@ -0,0 +1,80 @@
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace LeagueAPI.Utils;
internal partial class PortTokenWithProcessList : IPortTokenBehavior
{
[GeneratedRegex("--remoting-auth-token=([\\w-]*)")]
private static partial Regex AuthTokenRegex();
[GeneratedRegex("--app-port=([0-9]*)")]
private static partial Regex AppPortRegex();
public bool TryGet(Process process, out string remotingAuthToken, out int appPort, [NotNullWhen(false)] out Exception? exception)
{
try
{
ProcessStartInfo startInfo;
if (OperatingSystem.IsWindows())
{
startInfo = new ProcessStartInfo
{
FileName = "powershell.exe",
Arguments = "Get-CimInstance Win32_Process -Filter \"\"\"name = 'LeagueClientUx.exe'\"\"\" | Select-Object -ExpandProperty CommandLine",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
}
else
{
if (!OperatingSystem.IsMacOS())
{
throw new PlatformNotSupportedException("This behavior is only supported on Windows or MacOS.");
}
startInfo = new ProcessStartInfo
{
FileName = "/bin/bash",
Arguments = "-c \"ps x -o args | grep 'LeagueClientUx'\"",
WindowStyle = ProcessWindowStyle.Hidden,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true
};
}
using Process process2 = new()
{
StartInfo = startInfo
};
process2.Start();
process2.WaitForExit();
string text = process2.StandardOutput.ReadToEnd();
string text2 = process2.StandardError.ReadToEnd();
if (string.IsNullOrEmpty(text))
{
if (!string.IsNullOrEmpty(text2))
{
throw new Exception(text2);
}
throw new FormatException("The output string is empty.");
}
remotingAuthToken = AuthTokenRegex().Match(text).Groups[1].Value;
appPort = int.Parse(AppPortRegex().Match(text).Groups[1].Value);
exception = null;
return true;
}
catch (Exception ex)
{
remotingAuthToken = string.Empty;
appPort = 0;
exception = ex;
return false;
}
}
}

View File

@@ -0,0 +1,59 @@
using System.Diagnostics;
using System.Net.Sockets;
namespace LeagueAPI.Utils;
public static class ProcessFinder
{
public static Process GetProcess()
{
Process[] processesByName = Process.GetProcessesByName("LeagueClientUx");
return processesByName.FirstOrDefault(p => p.ProcessName == "LeagueClientUx")
?? throw new InvalidOperationException("Failed to find LCUx process.");
}
public static ProcessInfo GetProcessInfo()
{
return new ProcessInfo(GetProcess());
}
public static bool IsActive()
{
try
{
GetProcessInfo();
return true;
}
catch (InvalidOperationException)
{
return false;
}
}
public static bool IsPortOpen()
{
try
{
return IsPortOpen(GetProcessInfo());
}
catch (InvalidOperationException)
{
return false;
}
}
public static bool IsPortOpen(ProcessInfo processInfo)
{
try
{
using TcpClient tcpClient = new();
tcpClient.Connect("127.0.0.1", processInfo.AppPort);
tcpClient.Close();
return true;
}
catch (SocketException)
{
return false;
}
}
}

View File

@@ -0,0 +1,33 @@
using System.Diagnostics;
namespace LeagueAPI.Utils;
public class ProcessInfo
{
private static readonly List<IPortTokenBehavior> Behaviors =
[
new PortTokenWithLockfile(),
new PortTokenWithProcessList(),
];
public int AppPort { get; }
public string RemotingAuthToken { get; }
internal ProcessInfo(Process process)
{
List<Exception> exceptions = [];
foreach (IPortTokenBehavior behavior in Behaviors)
{
if (behavior.TryGet(process, out string remotingAuthToken, out int appPort, out Exception? exception))
{
RemotingAuthToken = remotingAuthToken;
AppPort = appPort;
return;
}
exceptions.Add(exception);
}
throw new AggregateException("Unable to obtain process information.", exceptions);
}
}

4
LeagueARAMTracker.slnx Normal file
View File

@@ -0,0 +1,4 @@
<Solution>
<Project Path="LeagueAPI/LeagueAPI.csproj" />
<Project Path="LeagueARAMTracker/LeagueARAMTracker.csproj" />
</Solution>

View File

@@ -0,0 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net10.0-windows</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<UseWindowsForms>true</UseWindowsForms>
<Nullable>enable</Nullable>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LeagueAPI\LeagueAPI.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,235 @@
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;
_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

@@ -0,0 +1,120 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

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

View File

@@ -0,0 +1,37 @@
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;
}
}

1
README.md Normal file
View File

@@ -0,0 +1 @@
# LeagueARAMTracker