From 053a4052dd7882361fe1751b805fcfaa52830d09 Mon Sep 17 00:00:00 2001 From: SacredFloof Date: Sun, 8 Mar 2026 20:45:01 +0100 Subject: [PATCH] Add project files. --- .gitattributes | 63 +++ .gitignore | 363 ++++++++++++++++++ LICENSE.txt | 21 + LeagueAPI/APIClient.cs | 177 +++++++++ LeagueAPI/Extensions.cs | 82 ++++ LeagueAPI/LcuWebsocket.cs | 140 +++++++ LeagueAPI/LeagueAPI.csproj | 14 + LeagueAPI/LeagueEvent.cs | 16 + .../Challenges/LolChallengesChallengeData.cs | 69 ++++ .../LolChallengesFriendLevelsData.cs | 12 + .../Challenges/LolChallengesUIChallenge.cs | 123 ++++++ LeagueAPI/Models/ChampSelect/BenchChampion.cs | 12 + .../ChampSelect/ChampSelectBannedChampions.cs | 15 + .../ChampSelect/ChampSelectChatRoomDetails.cs | 15 + .../ChampSelect/ChampSelectPlayerSelection.cs | 75 ++++ .../Models/ChampSelect/ChampSelectSession.cs | 108 ++++++ .../ChampSelect/ChampSelectSwapContract.cs | 15 + .../ChampSelect/ChampSelectSwapState.cs | 16 + .../Models/ChampSelect/ChampSelectTimer.cs | 21 + LeagueAPI/Models/ChampSelect/MucJwtDto.cs | 18 + LeagueAPI/Models/DDragon/ChampionResponse.cs | 19 + .../Models/DDragon/Champions/ChampionData.cs | 42 ++ .../DDragon/Champions/ChampionDataImage.cs | 27 ++ .../DDragon/Champions/ChampionDataInfo.cs | 18 + .../DDragon/Champions/ChampionDataStats.cs | 66 ++++ .../LolMatchmakingMatchmakingDodgeWarning.cs | 12 + ...atchmakingMatchmakingReadyCheckResource.cs | 24 ++ ...atchmakingMatchmakingReadyCheckResponse.cs | 11 + ...olMatchmakingMatchmakingReadyCheckState.cs | 14 + LeagueAPI/QueueId.cs | 66 ++++ LeagueAPI/RiotAuthentication.cs | 48 +++ LeagueAPI/Utils/IPortTokenBehavior.cs | 24 ++ LeagueAPI/Utils/LcuHttpClient.cs | 42 ++ LeagueAPI/Utils/LcuHttpClientHandler.cs | 151 ++++++++ LeagueAPI/Utils/PortTokenWithLockfile.cs | 43 +++ LeagueAPI/Utils/PortTokenWithProcessList.cs | 80 ++++ LeagueAPI/Utils/ProcessFinder.cs | 59 +++ LeagueAPI/Utils/ProcessInfo.cs | 33 ++ LeagueARAMTracker.slnx | 4 + LeagueARAMTracker/LeagueARAMTracker.csproj | 16 + LeagueARAMTracker/MainForm.cs | 235 ++++++++++++ LeagueARAMTracker/MainForm.resx | 120 ++++++ LeagueARAMTracker/Program.cs | 11 + LeagueARAMTracker/ResourceManager.cs | 37 ++ README.md | 1 + 45 files changed, 2578 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 LeagueAPI/APIClient.cs create mode 100644 LeagueAPI/Extensions.cs create mode 100644 LeagueAPI/LcuWebsocket.cs create mode 100644 LeagueAPI/LeagueAPI.csproj create mode 100644 LeagueAPI/LeagueEvent.cs create mode 100644 LeagueAPI/Models/Challenges/LolChallengesChallengeData.cs create mode 100644 LeagueAPI/Models/Challenges/LolChallengesFriendLevelsData.cs create mode 100644 LeagueAPI/Models/Challenges/LolChallengesUIChallenge.cs create mode 100644 LeagueAPI/Models/ChampSelect/BenchChampion.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectBannedChampions.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectChatRoomDetails.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectPlayerSelection.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectSession.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectSwapContract.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectSwapState.cs create mode 100644 LeagueAPI/Models/ChampSelect/ChampSelectTimer.cs create mode 100644 LeagueAPI/Models/ChampSelect/MucJwtDto.cs create mode 100644 LeagueAPI/Models/DDragon/ChampionResponse.cs create mode 100644 LeagueAPI/Models/DDragon/Champions/ChampionData.cs create mode 100644 LeagueAPI/Models/DDragon/Champions/ChampionDataImage.cs create mode 100644 LeagueAPI/Models/DDragon/Champions/ChampionDataInfo.cs create mode 100644 LeagueAPI/Models/DDragon/Champions/ChampionDataStats.cs create mode 100644 LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingDodgeWarning.cs create mode 100644 LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResource.cs create mode 100644 LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResponse.cs create mode 100644 LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckState.cs create mode 100644 LeagueAPI/QueueId.cs create mode 100644 LeagueAPI/RiotAuthentication.cs create mode 100644 LeagueAPI/Utils/IPortTokenBehavior.cs create mode 100644 LeagueAPI/Utils/LcuHttpClient.cs create mode 100644 LeagueAPI/Utils/LcuHttpClientHandler.cs create mode 100644 LeagueAPI/Utils/PortTokenWithLockfile.cs create mode 100644 LeagueAPI/Utils/PortTokenWithProcessList.cs create mode 100644 LeagueAPI/Utils/ProcessFinder.cs create mode 100644 LeagueAPI/Utils/ProcessInfo.cs create mode 100644 LeagueARAMTracker.slnx create mode 100644 LeagueARAMTracker/LeagueARAMTracker.csproj create mode 100644 LeagueARAMTracker/MainForm.cs create mode 100644 LeagueARAMTracker/MainForm.resx create mode 100644 LeagueARAMTracker/Program.cs create mode 100644 LeagueARAMTracker/ResourceManager.cs create mode 100644 README.md diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..1ff0c42 --- /dev/null +++ b/.gitattributes @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9491a2f --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..8aa2645 --- /dev/null +++ b/LICENSE.txt @@ -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. diff --git a/LeagueAPI/APIClient.cs b/LeagueAPI/APIClient.cs new file mode 100644 index 0000000..d13f100 --- /dev/null +++ b/LeagueAPI/APIClient.cs @@ -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 _championResponseCache = []; + + public APIClient() + { + _lcuHttpClient = new(new LcuHttpClientHandler()); + _dDragonHttpClient = new HttpClient() { BaseAddress = new Uri(DDRAGON_BASE_URL) }; + } + + public async Task> GetAllChallengesAsync() + { + return await _lcuHttpClient.GetContentAsync>("/lol-challenges/v1/challenges/local-player") ?? []; + } + + public async Task GetAllRandomAllChampionsCompletedChampionsAsync() + { + Dictionary challenges = await GetAllChallengesAsync(); + + if (!challenges.TryGetValue(ALL_RANDOM_ALL_CHAMPIONS, out LolChallengesUIChallenge? allRandomAllChampions)) + { + return []; + } + return allRandomAllChampions.CompletedIds ?? []; + } + + public async Task GetSelectableChampionIdsAsync() + { + ChampSelectSession? session = await _lcuHttpClient.GetContentAsync("/lol-champ-select/v1/session"); + return GetSelectableChampionIds(session); + } + + public int[] GetSelectableChampionIds(ChampSelectSession? session) + { + if (session is null || !session.BenchEnabled) + { + return []; + } + + IEnumerable benchChampions = session.BenchChampions.Select(b => b.ChampionId); + IEnumerable 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 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 DDragonGetAsync(string path) + { + string json = await DDragonGetAsync(path); + return JsonSerializer.Deserialize(json); + } + + private async Task GetPatch(string patch = "latest") + { + if (patch is "latest") + { + return _latestVersion ??= (await DDragonGetAsync("/api/versions.json") ?? []).FirstOrDefault() ?? throw new Exception($"Failed to fetch version: {patch}"); + } + else + { + return patch; + } + } + + public async Task 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(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 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 +} diff --git a/LeagueAPI/Extensions.cs b/LeagueAPI/Extensions.cs new file mode 100644 index 0000000..037b325 --- /dev/null +++ b/LeagueAPI/Extensions.cs @@ -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().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(Task task) + { + public T WaitForResult() + { + return task.GetAwaiter().GetResult(); + } + } + + extension(WebSocket socket) + { + public async Task ReadFullMessage(CancellationToken cancellationToken) + { + byte[] buffer = new byte[1024 * 8]; + using MemoryStream memoryStream = new(); + ValueWebSocketReceiveResult receiveResult; + + do + { + Memory 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); + } + } + +} diff --git a/LeagueAPI/LcuWebsocket.cs b/LeagueAPI/LcuWebsocket.cs new file mode 100644 index 0000000..915bc53 --- /dev/null +++ b/LeagueAPI/LcuWebsocket.cs @@ -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 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; + public event EventHandler? 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 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(); + 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 +} diff --git a/LeagueAPI/LeagueAPI.csproj b/LeagueAPI/LeagueAPI.csproj new file mode 100644 index 0000000..b3d3adb --- /dev/null +++ b/LeagueAPI/LeagueAPI.csproj @@ -0,0 +1,14 @@ + + + + net10.0 + enable + enable + + + + + + + + diff --git a/LeagueAPI/LeagueEvent.cs b/LeagueAPI/LeagueEvent.cs new file mode 100644 index 0000000..e94019e --- /dev/null +++ b/LeagueAPI/LeagueEvent.cs @@ -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; } +} diff --git a/LeagueAPI/Models/Challenges/LolChallengesChallengeData.cs b/LeagueAPI/Models/Challenges/LolChallengesChallengeData.cs new file mode 100644 index 0000000..7726da6 --- /dev/null +++ b/LeagueAPI/Models/Challenges/LolChallengesChallengeData.cs @@ -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; } +} diff --git a/LeagueAPI/Models/Challenges/LolChallengesFriendLevelsData.cs b/LeagueAPI/Models/Challenges/LolChallengesFriendLevelsData.cs new file mode 100644 index 0000000..78e23e2 --- /dev/null +++ b/LeagueAPI/Models/Challenges/LolChallengesFriendLevelsData.cs @@ -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; } +} diff --git a/LeagueAPI/Models/Challenges/LolChallengesUIChallenge.cs b/LeagueAPI/Models/Challenges/LolChallengesUIChallenge.cs new file mode 100644 index 0000000..3b6929c --- /dev/null +++ b/LeagueAPI/Models/Challenges/LolChallengesUIChallenge.cs @@ -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? 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; } +} diff --git a/LeagueAPI/Models/ChampSelect/BenchChampion.cs b/LeagueAPI/Models/ChampSelect/BenchChampion.cs new file mode 100644 index 0000000..f0af8e3 --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/BenchChampion.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectBannedChampions.cs b/LeagueAPI/Models/ChampSelect/ChampSelectBannedChampions.cs new file mode 100644 index 0000000..61cf64e --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectBannedChampions.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectChatRoomDetails.cs b/LeagueAPI/Models/ChampSelect/ChampSelectChatRoomDetails.cs new file mode 100644 index 0000000..4ac58f5 --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectChatRoomDetails.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectPlayerSelection.cs b/LeagueAPI/Models/ChampSelect/ChampSelectPlayerSelection.cs new file mode 100644 index 0000000..def842f --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectPlayerSelection.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectSession.cs b/LeagueAPI/Models/ChampSelect/ChampSelectSession.cs new file mode 100644 index 0000000..1faa6e4 --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectSession.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectSwapContract.cs b/LeagueAPI/Models/ChampSelect/ChampSelectSwapContract.cs new file mode 100644 index 0000000..e05ce4a --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectSwapContract.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectSwapState.cs b/LeagueAPI/Models/ChampSelect/ChampSelectSwapState.cs new file mode 100644 index 0000000..ed8667e --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectSwapState.cs @@ -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 +} diff --git a/LeagueAPI/Models/ChampSelect/ChampSelectTimer.cs b/LeagueAPI/Models/ChampSelect/ChampSelectTimer.cs new file mode 100644 index 0000000..0caf67f --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/ChampSelectTimer.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ChampSelect/MucJwtDto.cs b/LeagueAPI/Models/ChampSelect/MucJwtDto.cs new file mode 100644 index 0000000..a210df7 --- /dev/null +++ b/LeagueAPI/Models/ChampSelect/MucJwtDto.cs @@ -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; } +} diff --git a/LeagueAPI/Models/DDragon/ChampionResponse.cs b/LeagueAPI/Models/DDragon/ChampionResponse.cs new file mode 100644 index 0000000..c5e92f6 --- /dev/null +++ b/LeagueAPI/Models/DDragon/ChampionResponse.cs @@ -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? Data { get; init; } +} diff --git a/LeagueAPI/Models/DDragon/Champions/ChampionData.cs b/LeagueAPI/Models/DDragon/Champions/ChampionData.cs new file mode 100644 index 0000000..163f5a8 --- /dev/null +++ b/LeagueAPI/Models/DDragon/Champions/ChampionData.cs @@ -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; } +} diff --git a/LeagueAPI/Models/DDragon/Champions/ChampionDataImage.cs b/LeagueAPI/Models/DDragon/Champions/ChampionDataImage.cs new file mode 100644 index 0000000..bb86e17 --- /dev/null +++ b/LeagueAPI/Models/DDragon/Champions/ChampionDataImage.cs @@ -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; } +} diff --git a/LeagueAPI/Models/DDragon/Champions/ChampionDataInfo.cs b/LeagueAPI/Models/DDragon/Champions/ChampionDataInfo.cs new file mode 100644 index 0000000..f9a9878 --- /dev/null +++ b/LeagueAPI/Models/DDragon/Champions/ChampionDataInfo.cs @@ -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; } +} diff --git a/LeagueAPI/Models/DDragon/Champions/ChampionDataStats.cs b/LeagueAPI/Models/DDragon/Champions/ChampionDataStats.cs new file mode 100644 index 0000000..4c805dc --- /dev/null +++ b/LeagueAPI/Models/DDragon/Champions/ChampionDataStats.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingDodgeWarning.cs b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingDodgeWarning.cs new file mode 100644 index 0000000..96fd059 --- /dev/null +++ b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingDodgeWarning.cs @@ -0,0 +1,12 @@ +using System.Text.Json.Serialization; + +namespace LeagueAPI.Models.ReadyCheck; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LolMatchmakingMatchmakingDodgeWarning +{ + ConnectionWarning, + Penalty, + Warning, + None, +} diff --git a/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResource.cs b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResource.cs new file mode 100644 index 0000000..b30ff37 --- /dev/null +++ b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResource.cs @@ -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; } +} diff --git a/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResponse.cs b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResponse.cs new file mode 100644 index 0000000..c60b9b5 --- /dev/null +++ b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckResponse.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace LeagueAPI.Models.ReadyCheck; + +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum LolMatchmakingMatchmakingReadyCheckResponse +{ + Declined, + Accepted, + None, +} diff --git a/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckState.cs b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckState.cs new file mode 100644 index 0000000..cdb7f6d --- /dev/null +++ b/LeagueAPI/Models/ReadyCheck/LolMatchmakingMatchmakingReadyCheckState.cs @@ -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, +} diff --git a/LeagueAPI/QueueId.cs b/LeagueAPI/QueueId.cs new file mode 100644 index 0000000..193a376 --- /dev/null +++ b/LeagueAPI/QueueId.cs @@ -0,0 +1,66 @@ +namespace LeagueAPI; + +/// +/// Removed some old entries +/// https://static.developer.riotgames.com/docs/lol/queues.json +/// +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 () +} diff --git a/LeagueAPI/RiotAuthentication.cs b/LeagueAPI/RiotAuthentication.cs new file mode 100644 index 0000000..6de3b17 --- /dev/null +++ b/LeagueAPI/RiotAuthentication.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Net.Http.Headers; +using System.Text; + +namespace LeagueAPI; + +/// +/// Repesents authentication for the League Client. +/// +/// +public class RiotAuthentication(string RemotingAuthToken) +{ + /// + /// Username component of the authentication; + /// + public string Username { get; } = "riot"; + + /// + /// Password component of the authentication. + /// + public string Password { get; } = RemotingAuthToken; + + /// + /// Authentication value before Base64 conversion. + /// + public string RawValue => Username + ":" + Password; + + /// + /// Authentication value in Base64 format. + /// + public string Value => Convert.ToBase64String(Encoding.UTF8.GetBytes(RawValue)); + + /// + /// Get an AuthenticationHeaderValue instance. + /// + public AuthenticationHeaderValue ToAuthenticationHeaderValue() + { + return new AuthenticationHeaderValue("Basic", Value); + } + + /// + /// Get an NetworkCredential instance. + /// + public NetworkCredential ToNetworkCredential() + { + return new NetworkCredential(Username, Password); + } +} diff --git a/LeagueAPI/Utils/IPortTokenBehavior.cs b/LeagueAPI/Utils/IPortTokenBehavior.cs new file mode 100644 index 0000000..f67b084 --- /dev/null +++ b/LeagueAPI/Utils/IPortTokenBehavior.cs @@ -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 args, Dictionary _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; + } + } + } +} diff --git a/LeagueAPI/Utils/LcuHttpClient.cs b/LeagueAPI/Utils/LcuHttpClient.cs new file mode 100644 index 0000000..8690315 --- /dev/null +++ b/LeagueAPI/Utils/LcuHttpClient.cs @@ -0,0 +1,42 @@ +using System.Text.Json; + +namespace LeagueAPI.Utils; + +public class LcuHttpClient : HttpClient +{ + private static readonly Lazy LazyInstance = new(() => new LcuHttpClient(new LcuHttpClientHandler())); + internal static LcuHttpClient Instance => LazyInstance.Value; + + private LcuHttpClientHandler Handler { get; } + public ProcessInfo? ProcessInfo + { + 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 GetContentAsync(string requestUri) where T : class + { + HttpResponseMessage response = await GetAsync(requestUri); + if (!response.IsSuccessStatusCode) + { + return null; + } + string content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content); + } +} diff --git a/LeagueAPI/Utils/LcuHttpClientHandler.cs b/LeagueAPI/Utils/LcuHttpClientHandler.cs new file mode 100644 index 0000000..cbaf4e2 --- /dev/null +++ b/LeagueAPI/Utils/LcuHttpClientHandler.cs @@ -0,0 +1,151 @@ +namespace LeagueAPI.Utils; + +internal class LcuHttpClientHandler : HttpClientHandler +{ + private Lazy _isFirstRequest = new(() => true); + + private Lazy _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 SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + HttpResponseMessage result; + int num; + try + { + if (_isFirstRequest.Value) + { + _isFirstRequest = new Lazy(() => false); + SetProcessInfo(); + } + + if (_isFailing.Value) + { + _isFailing = new Lazy(() => false); + SetProcessInfo(); + } + + PrepareRequestMessage(request); + result = await base.SendAsync(request, cancellationToken).ConfigureAwait(continueOnCapturedContext: false); + return result; + } + catch (InvalidOperationException) + { + _isFailing = new Lazy(() => 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(() => true); + throw; + } + } + + throw new Exception("Failed to send request"); + } + + protected override HttpResponseMessage Send(HttpRequestMessage request, CancellationToken cancellationToken) + { + try + { + if (_isFirstRequest.Value) + { + _isFirstRequest = new Lazy(() => false); + SetProcessInfo(); + } + + if (_isFailing.Value) + { + _isFailing = new Lazy(() => false); + SetProcessInfo(); + } + + PrepareRequestMessage(request); + return base.Send(request, cancellationToken); + } + catch (InvalidOperationException) + { + _isFailing = new Lazy(() => true); + throw; + } + catch (HttpRequestException) + { + try + { + SetProcessInfo(); + PrepareRequestMessage(request); + return base.Send(request, cancellationToken); + } + catch (InvalidOperationException) + { + _isFailing = new Lazy(() => 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(); + } +} diff --git a/LeagueAPI/Utils/PortTokenWithLockfile.cs b/LeagueAPI/Utils/PortTokenWithLockfile.cs new file mode 100644 index 0000000..6570c98 --- /dev/null +++ b/LeagueAPI/Utils/PortTokenWithLockfile.cs @@ -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; + } + } +} diff --git a/LeagueAPI/Utils/PortTokenWithProcessList.cs b/LeagueAPI/Utils/PortTokenWithProcessList.cs new file mode 100644 index 0000000..8b766c4 --- /dev/null +++ b/LeagueAPI/Utils/PortTokenWithProcessList.cs @@ -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; + } + } +} diff --git a/LeagueAPI/Utils/ProcessFinder.cs b/LeagueAPI/Utils/ProcessFinder.cs new file mode 100644 index 0000000..25c85cf --- /dev/null +++ b/LeagueAPI/Utils/ProcessFinder.cs @@ -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; + } + } +} diff --git a/LeagueAPI/Utils/ProcessInfo.cs b/LeagueAPI/Utils/ProcessInfo.cs new file mode 100644 index 0000000..60a5149 --- /dev/null +++ b/LeagueAPI/Utils/ProcessInfo.cs @@ -0,0 +1,33 @@ +using System.Diagnostics; + +namespace LeagueAPI.Utils; + +public class ProcessInfo +{ + private static readonly List Behaviors = + [ + new PortTokenWithLockfile(), + new PortTokenWithProcessList(), + ]; + + public int AppPort { get; } + public string RemotingAuthToken { get; } + + internal ProcessInfo(Process process) + { + List 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); + } +} diff --git a/LeagueARAMTracker.slnx b/LeagueARAMTracker.slnx new file mode 100644 index 0000000..e779014 --- /dev/null +++ b/LeagueARAMTracker.slnx @@ -0,0 +1,4 @@ + + + + diff --git a/LeagueARAMTracker/LeagueARAMTracker.csproj b/LeagueARAMTracker/LeagueARAMTracker.csproj new file mode 100644 index 0000000..318d0eb --- /dev/null +++ b/LeagueARAMTracker/LeagueARAMTracker.csproj @@ -0,0 +1,16 @@ + + + + WinExe + net10.0-windows + enable + true + enable + true + + + + + + + diff --git a/LeagueARAMTracker/MainForm.cs b/LeagueARAMTracker/MainForm.cs new file mode 100644 index 0000000..70d9247 --- /dev/null +++ b/LeagueARAMTracker/MainForm.cs @@ -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 _champions = []; + private List _needChampionIds = []; + + public MainForm() + { + Task championLoadTask = Task.Run(async () => + { + ChampionData[] champions = await _client.GetAllChampionsAsync(); + Dictionary championDictionary = champions.Where(c => c.Id != -1).ToDictionary(key => key.Id); + lock (_syncRoot) + { + _champions = championDictionary; + } + await UpdateNeedChampionIdsAsync(); + }); + + _socket.LcuApiEvent += OnLcuApiEvent; +#if DEBUG + _socket.LcuApiException += (sender, e) => + { + File.AppendAllText("socketerror.log", $"[{DateTime.Now:s}] {e.Message}\n{e.StackTrace}\n\n"); + }; +#endif + _socketTask = _socket.Connect(); + + Text = "League ARAM Tracker"; + AutoSize = true; + MaximizeBox = false; + FormBorderStyle = FormBorderStyle.FixedSingle; + BackColor = Color.FromArgb(10, 26, 42); + Font = new(Font.FontFamily, 12); + TopMost = true; + + _toolTip = new(); + + const int PADDING_WIDTH = 10; + int currentOffset = PADDING_WIDTH; + _openLobbyButton = new() + { + Text = "Open Mayhem Lobby", + Location = new(currentOffset, 10), + TextAlign = ContentAlignment.MiddleCenter, + AutoSize = true, + BackColor = Color.FromArgb(33, 37, 44), + ForeColor = Color.FromArgb(193, 155, 65), + }; + _openLobbyButton.Click += OnOpenLobbyClicked; + Controls.Add(_openLobbyButton); + currentOffset += _openLobbyButton.Width + PADDING_WIDTH; + + _startQueueingButton = new() + { + Text = "Start Queueing", + Location = new(currentOffset, 10), + TextAlign = ContentAlignment.MiddleCenter, + AutoSize = true, + BackColor = Color.FromArgb(33, 37, 44), + ForeColor = Color.FromArgb(193, 155, 65), + }; + _startQueueingButton.Click += OnStartQueueingClicked; + Controls.Add(_startQueueingButton); + currentOffset += _startQueueingButton.Width + PADDING_WIDTH; + + _infoLabel = new() + { + Text = "", + Location = new(currentOffset, 10), + TextAlign = ContentAlignment.MiddleLeft, + BackColor = Color.Transparent, + ForeColor = Color.White, + AutoSize = true, + }; + Controls.Add(_infoLabel); + currentOffset += _infoLabel.Width + PADDING_WIDTH; + + _imageGrid = new TableLayoutPanel() + { + Location = new(10, 50), + ColumnCount = 5, + RowCount = 3, + AutoSize = true, + AutoSizeMode = AutoSizeMode.GrowAndShrink, + }; + Controls.Add(_imageGrid); + + string emptyChampionIconPath = Task.Run(async () => { return await ResourceManager.Instance.GetChampionIconPathAsync(); }).WaitForResult(); + List pictureBoxes = []; + for (int i = 0; i < 15; i++) + { + PictureBox box = new() + { + Size = new(128, 128), + SizeMode = PictureBoxSizeMode.Zoom, + BorderStyle = BorderStyle.Fixed3D, + Margin = new(5), + ImageLocation = emptyChampionIconPath, + }; + box.MouseEnter += (sender, e) => + { + if (sender is Control self && self.Tag is ChampionData champ) + { + _toolTip.SetToolTip(self, champ.Name); + } + }; + box.MouseLeave += (sender, e) => + { + if (sender is Control self) + { + _toolTip.SetToolTip(self, null); + } + }; + pictureBoxes.Add(box); + _imageGrid.Controls.Add(box); + } + _pictureBoxes = [.. pictureBoxes]; + + championLoadTask.Wait(); + } + + private async Task UpdateNeedChampionIdsAsync() + { + IEnumerable completedChampionIds = await _client.GetAllRandomAllChampionsCompletedChampionsAsync(); + IEnumerable needChampionIds = _champions.Keys.Except(completedChampionIds); + lock (_syncRoot) + { + _needChampionIds = [.. needChampionIds]; + } + } + + private async Task ShowSelectableNeedChampionsAsync(ChampSelectSession? session) + { + int[] selectableChampionIds = _client.GetSelectableChampionIds(session); + if (selectableChampionIds.Length == 0) + { + return; + } + + Queue targetChampionIds = new(selectableChampionIds.Intersect(_needChampionIds)); + if (targetChampionIds.Count == 0) + { + _infoLabel.Text = $"S-Grade achieved for all champions"; + return; + } + _infoLabel.Text = $"{targetChampionIds.Count} Champions found:"; + foreach (PictureBox pictureBox in _pictureBoxes) + { + if (!targetChampionIds.TryDequeue(out int championId)) + { + championId = -1; + } + string filepath = await ResourceManager.Instance.GetChampionIconPathAsync(championId); + pictureBox.ImageLocation = filepath; + ChampionData? champion = await _client.GetChampionByIdAsync(championId); + pictureBox.Tag = champion; + } + } + + private async void OnOpenLobbyClicked(object? sender, EventArgs e) + { + await _client.CreateMayhemLobbyAsync(); + } + + private async void OnStartQueueingClicked(object? sender, EventArgs e) + { + await _client.StartMatchmakingQueueAsync(); + } + + private async void OnLcuApiEvent(object? sender, LcuApiEvent apiEvent) + { +#if DEBUG + File.AppendAllText("socket.log", $"{apiEvent.Uri}: {apiEvent.EventType} - {apiEvent.Data?.ToJsonString()}\n"); +#endif + + if (apiEvent.Uri == "/lol-champ-select/v1/session") + { + ChampSelectSession? session = apiEvent.Data.Deserialize(); + await ShowSelectableNeedChampionsAsync(session); + return; + } + + if (apiEvent.Uri == "/lol-matchmaking/v1/ready-check") + { + LolMatchmakingMatchmakingReadyCheckResource? readyCheck = apiEvent.Data.Deserialize(); + if (readyCheck is not null && readyCheck.PlayerResponse == LolMatchmakingMatchmakingReadyCheckResponse.None) + { + await _client.MatchmakingAcceptAsync(); + await UpdateNeedChampionIdsAsync(); + } + } + } + + protected override void Dispose(bool disposing) + { + if (disposing) + { + _client.Dispose(); + _socket.Dispose(); + _imageGrid.Dispose(); + foreach (PictureBox pictureBox in _pictureBoxes) + { + pictureBox.Dispose(); + } + Array.Clear(_pictureBoxes); + } + + base.Dispose(disposing); + } +} diff --git a/LeagueARAMTracker/MainForm.resx b/LeagueARAMTracker/MainForm.resx new file mode 100644 index 0000000..1af7de1 --- /dev/null +++ b/LeagueARAMTracker/MainForm.resx @@ -0,0 +1,120 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + \ No newline at end of file diff --git a/LeagueARAMTracker/Program.cs b/LeagueARAMTracker/Program.cs new file mode 100644 index 0000000..5f712c2 --- /dev/null +++ b/LeagueARAMTracker/Program.cs @@ -0,0 +1,11 @@ +namespace LeagueARAMTracker; + +public class Program +{ + [STAThread] + public static void Main() + { + ApplicationConfiguration.Initialize(); + Application.Run(new MainForm()); + } +} diff --git a/LeagueARAMTracker/ResourceManager.cs b/LeagueARAMTracker/ResourceManager.cs new file mode 100644 index 0000000..f117971 --- /dev/null +++ b/LeagueARAMTracker/ResourceManager.cs @@ -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 GetChampionIconPathAsync(int championId = -1) + { + FileInfo assetFile = new(Path.Combine(_assetDirectory.FullName, CHAMPION_FILENAME_FORMAT.AsFormatWith(championId))); + if (assetFile.Exists) + { + return assetFile.FullName; + } + + Command cmd = Cli.Wrap("curl") + .WithArguments($"-o \"{assetFile.FullName}\" {DDRAGON_CHAMPION_URL.AsFormatWith(championId)}") + .WithValidation(CommandResultValidation.None); + await cmd.ExecuteAsync(); + return assetFile.FullName; + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..de76a86 --- /dev/null +++ b/README.md @@ -0,0 +1 @@ +# LeagueARAMTracker \ No newline at end of file