introducing game service facade

This commit is contained in:
DismissedLight
2023-11-04 16:53:08 +08:00
parent 24086ee4d0
commit 749ef0e138
34 changed files with 703 additions and 596 deletions

View File

@@ -62,6 +62,7 @@ internal sealed class UniformStaggeredLayoutState
}
}
[SuppressMessage("", "SH007")]
internal UniformStaggeredColumnLayout GetColumnLayout(int columnIndex)
{
this.columnLayout.TryGetValue(columnIndex, out UniformStaggeredColumnLayout? columnLayout);
@@ -116,7 +117,7 @@ internal sealed class UniformStaggeredLayoutState
desiredHeight = estimatedHeight;
}
if (Math.Abs(desiredHeight - lastAverageHeight) < 5) // Why 5?
if (Math.Abs(desiredHeight - lastAverageHeight) < 5)
{
return lastAverageHeight;
}

View File

@@ -15,22 +15,7 @@ internal abstract class ValueConverter<TFrom, TTo> : IValueConverter
/// <inheritdoc/>
public object? Convert(object value, Type targetType, object parameter, string language)
{
#if DEBUG
try
{
return Convert((TFrom)value);
}
catch (Exception ex)
{
Ioc.Default
.GetRequiredService<ILogger<ValueConverter<TFrom, TTo>>>()
.LogError(ex, "值转换器异常");
throw;
}
#else
return Convert((TFrom)value);
#endif
}
/// <inheritdoc/>

View File

@@ -24,7 +24,7 @@ internal sealed partial class DailyNoteNotificationOperation
private const string ToastAttributionUnknown = "Unknown";
private readonly ITaskContext taskContext;
private readonly IGameService gameService;
private readonly IGameServiceFacade gameService;
private readonly BindingClient bindingClient;
private readonly DailyNoteOptions options;

View File

@@ -19,7 +19,7 @@ namespace Snap.Hutao.Service.GachaLog.QueryProvider;
[Injection(InjectAs.Transient)]
internal sealed partial class GachaLogQueryWebCacheProvider : IGachaLogQueryProvider
{
private readonly IGameService gameService;
private readonly IGameServiceFacade gameService;
private readonly MetadataOptions metadataOptions;
/// <summary>

View File

@@ -0,0 +1,135 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.View.Dialog;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game.Account;
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameAccountService))]
internal sealed partial class GameAccountService : IGameAccountService
{
private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider;
private readonly IGameDbService gameDbService;
private readonly ITaskContext taskContext;
private readonly AppOptions appOptions;
private ObservableCollection<GameAccount>? gameAccounts;
public ObservableCollection<GameAccount> GameAccountCollection
{
get => gameAccounts ??= gameDbService.GetGameAccountCollection();
}
public async ValueTask<GameAccount?> DetectGameAccountAsync()
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
{
GameAccount? account = null;
try
{
account = gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
if (account is null)
{
// ContentDialog must be created by main thread.
await taskContext.SwitchToMainThreadAsync();
LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGameAccountNameDialog>().ConfigureAwait(false);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false);
if (isOk)
{
account = GameAccount.From(name, registrySdk);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false);
// sync cache
await taskContext.SwitchToMainThreadAsync();
gameAccounts.Add(account);
}
}
return account;
}
return default;
}
public GameAccount? DetectCurrentGameAccount()
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
{
try
{
return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
}
return null;
}
public bool SetGameAccount(GameAccount account)
{
if (string.IsNullOrEmpty(appOptions.PowerShellPath))
{
ThrowHelper.RuntimeEnvironment(SH.ServiceGameRegisteryInteropPowershellNotFound, default!);
}
return RegistryInterop.Set(account, appOptions.PowerShellPath);
}
public void AttachGameAccountToUid(GameAccount gameAccount, string uid)
{
gameAccount.UpdateAttachUid(uid);
gameDbService.UpdateGameAccount(gameAccount);
}
public async ValueTask ModifyGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
LaunchGameAccountNameDialog dialog = serviceProvider.CreateInstance<LaunchGameAccountNameDialog>();
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(true);
if (isOk)
{
gameAccount.UpdateName(name);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.UpdateGameAccountAsync(gameAccount).ConfigureAwait(false);
}
}
public async ValueTask RemoveGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
ArgumentNullException.ThrowIfNull(gameAccounts);
gameAccounts.Remove(gameAccount);
await taskContext.SwitchToBackgroundAsync();
await gameDbService.RemoveGameAccountByIdAsync(gameAccount.InnerId).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,24 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game.Account;
internal interface IGameAccountService
{
ObservableCollection<GameAccount> GameAccountCollection { get; }
void AttachGameAccountToUid(GameAccount gameAccount, string uid);
GameAccount? DetectCurrentGameAccount();
ValueTask<GameAccount?> DetectGameAccountAsync();
ValueTask ModifyGameAccountAsync(GameAccount gameAccount);
ValueTask RemoveGameAccountAsync(GameAccount gameAccount);
bool SetGameAccount(GameAccount account);
}

View File

@@ -9,7 +9,7 @@ using System.IO;
using System.Runtime.InteropServices;
using System.Text;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Account;
/// <summary>
/// 注册表操作

View File

@@ -3,7 +3,7 @@
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Configuration;
/// <summary>
/// 多通道

View File

@@ -0,0 +1,95 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.IO.Ini;
using Snap.Hutao.Service.Game.Scheme;
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Configuration;
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameChannelOptionsService))]
internal sealed partial class GameChannelOptionsService : IGameChannelOptionsService
{
private readonly AppOptions appOptions;
public ChannelOptions GetChannelOptions()
{
string gamePath = appOptions.GamePath;
string configPath = Path.Combine(Path.GetDirectoryName(gamePath) ?? string.Empty, ConfigFileName);
bool isOversea = string.Equals(Path.GetFileName(gamePath), GenshinImpactFileName, StringComparison.OrdinalIgnoreCase);
if (!File.Exists(configPath))
{
return ChannelOptions.FileNotFound(isOversea, configPath);
}
using (FileStream stream = File.OpenRead(configPath))
{
List<IniParameter> parameters = IniSerializer.Deserialize(stream).OfType<IniParameter>().ToList();
string? channel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.ChannelName)?.Value;
string? subChannel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.SubChannelName)?.Value;
return new(channel, subChannel, isOversea);
}
}
public bool SetChannelOptions(LaunchScheme scheme)
{
string gamePath = appOptions.GamePath;
string? directory = Path.GetDirectoryName(gamePath);
ArgumentException.ThrowIfNullOrEmpty(directory);
string configPath = Path.Combine(directory, ConfigFileName);
List<IniElement> elements = default!;
try
{
using (FileStream readStream = File.OpenRead(configPath))
{
elements = IniSerializer.Deserialize(readStream).ToList();
}
}
catch (FileNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex);
}
catch (DirectoryNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex);
}
catch (UnauthorizedAccessException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelUnauthorizedAccess, ex);
}
bool changed = false;
foreach (IniElement element in elements)
{
if (element is IniParameter parameter)
{
if (parameter.Key == "channel")
{
changed = parameter.Set(scheme.Channel.ToString("D")) || changed;
}
if (parameter.Key == "sub_channel")
{
changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed;
}
}
}
if (changed)
{
using (FileStream writeStream = File.Create(configPath))
{
IniSerializer.Serialize(writeStream, elements);
}
}
return changed;
}
}

View File

@@ -0,0 +1,13 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Scheme;
namespace Snap.Hutao.Service.Game.Configuration;
internal interface IGameChannelOptionsService
{
ChannelOptions GetChannelOptions();
bool SetChannelOptions(LaunchScheme scheme);
}

View File

@@ -4,7 +4,7 @@
using Snap.Hutao.Model.Intrinsic;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Configuration;
internal static class IgnoredInvalidChannelOptions
{

View File

@@ -0,0 +1,60 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Locator;
namespace Snap.Hutao.Service.Game;
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGamePathService))]
internal sealed partial class GamePathService : IGamePathService
{
private readonly IServiceProvider serviceProvider;
private readonly AppOptions appOptions;
public async ValueTask<ValueResult<bool, string>> SilentGetGamePathAsync()
{
// Cannot find in setting
if (string.IsNullOrEmpty(appOptions.GamePath))
{
IGameLocatorFactory locatorFactory = serviceProvider.GetRequiredService<IGameLocatorFactory>();
bool isOk;
string path;
// Try locate by unity log
(isOk, path) = await locatorFactory
.Create(GameLocationSource.UnityLog)
.LocateGamePathAsync()
.ConfigureAwait(false);
if (!isOk)
{
// Try locate by registry
(isOk, path) = await locatorFactory
.Create(GameLocationSource.Registry)
.LocateGamePathAsync()
.ConfigureAwait(false);
}
if (isOk)
{
// Save result.
appOptions.GamePath = path;
}
else
{
return new(false, SH.ServiceGamePathLocateFailed);
}
}
if (!string.IsNullOrEmpty(appOptions.GamePath))
{
return new(true, appOptions.GamePath);
}
else
{
return new(false, default!);
}
}
}

View File

@@ -0,0 +1,128 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Service.Game.Unlocker;
using System.Diagnostics;
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game;
/// <summary>
/// 进程互操作
/// </summary>
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameProcessService))]
internal sealed partial class GameProcessService : IGameProcessService
{
private readonly IServiceProvider serviceProvider;
private readonly RuntimeOptions runtimeOptions;
private readonly LaunchOptions launchOptions;
private readonly AppOptions appOptions;
private volatile int runningGamesCounter;
public bool IsGameRunning()
{
if (runningGamesCounter == 0)
{
return false;
}
return Process.GetProcessesByName(YuanShenProcessName).Any()
|| Process.GetProcessesByName(GenshinImpactProcessName).Any();
}
public async ValueTask LaunchAsync(IProgress<LaunchStatus> progress)
{
if (IsGameRunning())
{
return;
}
string gamePath = appOptions.GamePath;
ArgumentException.ThrowIfNullOrEmpty(gamePath);
progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (Process game = InitializeGameProcess(launchOptions, gamePath))
{
try
{
Interlocked.Increment(ref runningGamesCounter);
game.Start();
progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted));
if (runtimeOptions.IsElevated && appOptions.IsAdvancedLaunchOptionsEnabled && launchOptions.UnlockFps)
{
progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps));
try
{
await UnlockFpsAsync(serviceProvider, game, progress).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
// The Unlocker can't unlock the process
game.Kill();
throw;
}
finally
{
progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited));
}
}
else
{
progress.Report(new(LaunchPhase.WaitingForExit, SH.ServiceGameLaunchPhaseWaitingProcessExit));
await game.WaitForExitAsync().ConfigureAwait(false);
progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited));
}
}
finally
{
Interlocked.Decrement(ref runningGamesCounter);
}
}
}
private static Process InitializeGameProcess(LaunchOptions options, string gamePath)
{
string commandLine = string.Empty;
if (options.IsEnabled)
{
Must.Argument(!(options.IsBorderless && options.IsExclusive), "无边框与独占全屏选项无法同时生效");
// https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html
// https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html
commandLine = new CommandLineBuilder()
.AppendIf("-popupwindow", options.IsBorderless)
.AppendIf("-window-mode", options.IsExclusive, "exclusive")
.Append("-screen-fullscreen", options.IsFullScreen ? 1 : 0)
.AppendIf("-screen-width", options.IsScreenWidthEnabled, options.ScreenWidth)
.AppendIf("-screen-height", options.IsScreenHeightEnabled, options.ScreenHeight)
.AppendIf("-monitor", options.IsMonitorEnabled, options.Monitor.Value)
.ToString();
}
return new()
{
StartInfo = new()
{
Arguments = commandLine,
FileName = gamePath,
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = Path.GetDirectoryName(gamePath),
},
};
}
private static ValueTask UnlockFpsAsync(IServiceProvider serviceProvider, Process game, IProgress<LaunchStatus> progress, CancellationToken token = default)
{
IGameFpsUnlocker unlocker = serviceProvider.CreateInstance<GameFpsUnlocker>(game);
UnlockTimingOptions options = new(100, 20000, 3000);
Progress<UnlockerStatus> lockerProgress = new(unlockStatus => progress.Report(LaunchStatus.FromUnlockStatus(unlockStatus)));
return unlocker.UnlockAsync(options, lockerProgress, token);
}
}

View File

@@ -1,395 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.IO.Ini;
using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game.Locator;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.View.Dialog;
using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using Snap.Hutao.Web.Response;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game;
/// <summary>
/// 游戏服务
/// </summary>
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameService))]
internal sealed partial class GameService : IGameService
{
private readonly IContentDialogFactory contentDialogFactory;
private readonly PackageConverter packageConverter;
private readonly IServiceProvider serviceProvider;
private readonly IGameDbService gameDbService;
private readonly LaunchOptions launchOptions;
private readonly RuntimeOptions runtimeOptions;
private readonly ITaskContext taskContext;
private readonly AppOptions appOptions;
private volatile int runningGamesCounter;
private ObservableCollection<GameAccount>? gameAccounts;
/// <inheritdoc/>
public ObservableCollection<GameAccount> GameAccountCollection
{
get => gameAccounts ??= gameDbService.GetGameAccountCollection();
}
/// <inheritdoc/>
public async ValueTask<ValueResult<bool, string>> GetGamePathAsync()
{
// Cannot find in setting
if (string.IsNullOrEmpty(appOptions.GamePath))
{
IGameLocatorFactory locatorFactory = serviceProvider.GetRequiredService<IGameLocatorFactory>();
// Try locate by unity log
ValueResult<bool, string> result = await locatorFactory
.Create(GameLocationSource.UnityLog)
.LocateGamePathAsync()
.ConfigureAwait(false);
if (!result.IsOk)
{
// Try locate by registry
result = await locatorFactory
.Create(GameLocationSource.Registry)
.LocateGamePathAsync()
.ConfigureAwait(false);
}
if (result.IsOk)
{
// Save result.
appOptions.GamePath = result.Value;
}
else
{
return new(false, SH.ServiceGamePathLocateFailed);
}
}
if (!string.IsNullOrEmpty(appOptions.GamePath))
{
return new(true, appOptions.GamePath);
}
else
{
return new(false, default!);
}
}
/// <inheritdoc/>
public ChannelOptions GetChannelOptions()
{
string gamePath = appOptions.GamePath;
string configPath = Path.Combine(Path.GetDirectoryName(gamePath) ?? string.Empty, ConfigFileName);
bool isOversea = string.Equals(Path.GetFileName(gamePath), GenshinImpactFileName, StringComparison.OrdinalIgnoreCase);
if (!File.Exists(configPath))
{
return ChannelOptions.FileNotFound(isOversea, configPath);
}
using (FileStream stream = File.OpenRead(configPath))
{
List<IniParameter> parameters = IniSerializer.Deserialize(stream).OfType<IniParameter>().ToList();
string? channel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.ChannelName)?.Value;
string? subChannel = parameters.FirstOrDefault(p => p.Key == ChannelOptions.SubChannelName)?.Value;
return new(channel, subChannel, isOversea);
}
}
/// <inheritdoc/>
public bool SetChannelOptions(LaunchScheme scheme)
{
string gamePath = appOptions.GamePath;
string? directory = Path.GetDirectoryName(gamePath);
ArgumentException.ThrowIfNullOrEmpty(directory);
string configPath = Path.Combine(directory, ConfigFileName);
List<IniElement> elements = default!;
try
{
using (FileStream readStream = File.OpenRead(configPath))
{
elements = IniSerializer.Deserialize(readStream).ToList();
}
}
catch (FileNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex);
}
catch (DirectoryNotFoundException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelConfigFileNotFound.Format(configPath), ex);
}
catch (UnauthorizedAccessException ex)
{
ThrowHelper.GameFileOperation(SH.ServiceGameSetMultiChannelUnauthorizedAccess, ex);
}
bool changed = false;
foreach (IniElement element in elements)
{
if (element is IniParameter parameter)
{
if (parameter.Key == "channel")
{
changed = parameter.Set(scheme.Channel.ToString("D")) || changed;
}
if (parameter.Key == "sub_channel")
{
changed = parameter.Set(scheme.SubChannel.ToString("D")) || changed;
}
}
}
if (changed)
{
using (FileStream writeStream = File.Create(configPath))
{
IniSerializer.Serialize(writeStream, elements);
}
}
return changed;
}
/// <inheritdoc/>
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
string gamePath = appOptions.GamePath;
string? gameFolder = Path.GetDirectoryName(gamePath);
ArgumentException.ThrowIfNullOrEmpty(gameFolder);
string gameFileName = Path.GetFileName(gamePath);
progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation));
Response<GameResource> response = await serviceProvider
.GetRequiredService<ResourceClient>()
.GetResourceAsync(launchScheme)
.ConfigureAwait(false);
if (response.IsOk())
{
GameResource resource = response.Data;
if (!launchScheme.ExecutableMatches(gameFileName))
{
bool replaced = await packageConverter
.EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress)
.ConfigureAwait(false);
if (replaced)
{
// We need to change the gamePath if we switched.
string exeName = launchScheme.IsOversea ? GenshinImpactFileName : YuanShenFileName;
await taskContext.SwitchToMainThreadAsync();
appOptions.GamePath = Path.Combine(gameFolder, exeName);
}
else
{
// We can't start the game
// when we failed to convert game
return false;
}
}
await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false);
return true;
}
return false;
}
/// <inheritdoc/>
public bool IsGameRunning()
{
if (runningGamesCounter == 0)
{
return false;
}
return Process.GetProcessesByName(YuanShenProcessName).Any()
|| Process.GetProcessesByName(GenshinImpactProcessName).Any();
}
/// <inheritdoc/>
public async ValueTask LaunchAsync(IProgress<LaunchStatus> progress)
{
if (IsGameRunning())
{
return;
}
string gamePath = appOptions.GamePath;
ArgumentException.ThrowIfNullOrEmpty(gamePath);
progress.Report(new(LaunchPhase.ProcessInitializing, SH.ServiceGameLaunchPhaseProcessInitializing));
using (Process game = ProcessInterop.InitializeGameProcess(launchOptions, gamePath))
{
try
{
Interlocked.Increment(ref runningGamesCounter);
game.Start();
progress.Report(new(LaunchPhase.ProcessStarted, SH.ServiceGameLaunchPhaseProcessStarted));
if (runtimeOptions.IsElevated && appOptions.IsAdvancedLaunchOptionsEnabled && launchOptions.UnlockFps)
{
progress.Report(new(LaunchPhase.UnlockingFps, SH.ServiceGameLaunchPhaseUnlockingFps));
try
{
await ProcessInterop.UnlockFpsAsync(serviceProvider, game, progress).ConfigureAwait(false);
}
catch (InvalidOperationException)
{
// The Unlocker can't unlock the process
game.Kill();
throw;
}
finally
{
progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited));
}
}
else
{
progress.Report(new(LaunchPhase.WaitingForExit, SH.ServiceGameLaunchPhaseWaitingProcessExit));
await game.WaitForExitAsync().ConfigureAwait(false);
progress.Report(new(LaunchPhase.ProcessExited, SH.ServiceGameLaunchPhaseProcessExited));
}
}
finally
{
Interlocked.Decrement(ref runningGamesCounter);
}
}
}
/// <inheritdoc/>
public async ValueTask<GameAccount?> DetectGameAccountAsync()
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
{
GameAccount? account = null;
try
{
account = gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
if (account is null)
{
// ContentDialog must be created by main thread.
await taskContext.SwitchToMainThreadAsync();
LaunchGameAccountNameDialog dialog = await contentDialogFactory.CreateInstanceAsync<LaunchGameAccountNameDialog>().ConfigureAwait(false);
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(false);
if (isOk)
{
account = GameAccount.From(name, registrySdk);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.AddGameAccountAsync(account).ConfigureAwait(false);
// sync cache
await taskContext.SwitchToMainThreadAsync();
gameAccounts.Add(account);
}
}
return account;
}
return default;
}
/// <inheritdoc/>
public GameAccount? DetectCurrentGameAccount()
{
ArgumentNullException.ThrowIfNull(gameAccounts);
string? registrySdk = RegistryInterop.Get();
if (!string.IsNullOrEmpty(registrySdk))
{
try
{
return gameAccounts.SingleOrDefault(a => a.MihoyoSDK == registrySdk);
}
catch (InvalidOperationException ex)
{
ThrowHelper.UserdataCorrupted(SH.ServiceGameDetectGameAccountMultiMatched, ex);
}
}
return null;
}
/// <inheritdoc/>
public bool SetGameAccount(GameAccount account)
{
if (string.IsNullOrEmpty(appOptions.PowerShellPath))
{
ThrowHelper.RuntimeEnvironment(SH.ServiceGameRegisteryInteropPowershellNotFound, default!);
}
return RegistryInterop.Set(account, appOptions.PowerShellPath);
}
/// <inheritdoc/>
public void AttachGameAccountToUid(GameAccount gameAccount, string uid)
{
gameAccount.UpdateAttachUid(uid);
gameDbService.UpdateGameAccount(gameAccount);
}
/// <inheritdoc/>
public async ValueTask ModifyGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
LaunchGameAccountNameDialog dialog = serviceProvider.CreateInstance<LaunchGameAccountNameDialog>();
(bool isOk, string name) = await dialog.GetInputNameAsync().ConfigureAwait(true);
if (isOk)
{
gameAccount.UpdateName(name);
// sync database
await taskContext.SwitchToBackgroundAsync();
await gameDbService.UpdateGameAccountAsync(gameAccount).ConfigureAwait(false);
}
}
/// <inheritdoc/>
public async ValueTask RemoveGameAccountAsync(GameAccount gameAccount)
{
await taskContext.SwitchToMainThreadAsync();
ArgumentNullException.ThrowIfNull(gameAccounts);
gameAccounts.Remove(gameAccount);
await taskContext.SwitchToBackgroundAsync();
await gameDbService.RemoveGameAccountByIdAsync(gameAccount.InnerId).ConfigureAwait(false);
}
}

View File

@@ -0,0 +1,104 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.Scheme;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game;
/// <summary>
/// 游戏服务
/// </summary>
[HighQuality]
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGameServiceFacade))]
internal sealed partial class GameServiceFacade : IGameServiceFacade
{
private readonly IGameChannelOptionsService gameChannelOptionsService;
private readonly IGameAccountService gameAccountService;
private readonly IGameProcessService gameProcessService;
private readonly IGamePackageService gamePackageService;
private readonly IGamePathService gamePathService;
/// <inheritdoc/>
public ObservableCollection<GameAccount> GameAccountCollection
{
get => gameAccountService.GameAccountCollection;
}
/// <inheritdoc/>
public ValueTask<ValueResult<bool, string>> GetGamePathAsync()
{
return gamePathService.SilentGetGamePathAsync();
}
/// <inheritdoc/>
public ChannelOptions GetChannelOptions()
{
return gameChannelOptionsService.GetChannelOptions();
}
/// <inheritdoc/>
public bool SetChannelOptions(LaunchScheme scheme)
{
return gameChannelOptionsService.SetChannelOptions(scheme);
}
/// <inheritdoc/>
public ValueTask<GameAccount?> DetectGameAccountAsync()
{
return gameAccountService.DetectGameAccountAsync();
}
/// <inheritdoc/>
public GameAccount? DetectCurrentGameAccount()
{
return gameAccountService.DetectCurrentGameAccount();
}
/// <inheritdoc/>
public bool SetGameAccount(GameAccount account)
{
return gameAccountService.SetGameAccount(account);
}
/// <inheritdoc/>
public void AttachGameAccountToUid(GameAccount gameAccount, string uid)
{
gameAccountService.AttachGameAccountToUid(gameAccount, uid);
}
/// <inheritdoc/>
public ValueTask ModifyGameAccountAsync(GameAccount gameAccount)
{
return gameAccountService.ModifyGameAccountAsync(gameAccount);
}
/// <inheritdoc/>
public ValueTask RemoveGameAccountAsync(GameAccount gameAccount)
{
return gameAccountService.RemoveGameAccountAsync(gameAccount);
}
/// <inheritdoc/>
public bool IsGameRunning()
{
return gameProcessService.IsGameRunning();
}
/// <inheritdoc/>
public ValueTask LaunchAsync(IProgress<LaunchStatus> progress)
{
return gameProcessService.LaunchAsync(progress);
}
/// <inheritdoc/>
public ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
return gamePackageService.EnsureGameResourceAsync(launchScheme, progress);
}
}

View File

@@ -0,0 +1,9 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game;
internal interface IGamePathService
{
ValueTask<ValueResult<bool, string>> SilentGetGamePathAsync();
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Game;
internal interface IGameProcessService
{
bool IsGameRunning();
ValueTask LaunchAsync(IProgress<LaunchStatus> progress);
}

View File

@@ -2,7 +2,10 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Game.Account;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.Scheme;
using System.Collections.ObjectModel;
namespace Snap.Hutao.Service.Game;
@@ -11,7 +14,7 @@ namespace Snap.Hutao.Service.Game;
/// 游戏服务
/// </summary>
[HighQuality]
internal interface IGameService
internal interface IGameServiceFacade
{
/// <summary>
/// 游戏内账号集合

View File

@@ -1,6 +1,8 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Unlocker;
namespace Snap.Hutao.Service.Game;
internal sealed class LaunchStatus
@@ -14,4 +16,16 @@ internal sealed class LaunchStatus
public LaunchPhase Phase { get; set; }
public string Description { get; set; }
}
public static LaunchStatus FromUnlockStatus(UnlockerStatus unlockerStatus)
{
if (unlockerStatus.FindModuleState == FindModuleResult.Ok)
{
return new(LaunchPhase.UnlockFpsSucceed, SH.ServiceGameLaunchPhaseUnlockFpsSucceed);
}
else
{
return new(LaunchPhase.UnlockFpsFailed, SH.ServiceGameLaunchPhaseUnlockFpsFailed);
}
}
}

View File

@@ -0,0 +1,67 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using Snap.Hutao.Web.Response;
using System.IO;
using static Snap.Hutao.Service.Game.GameConstants;
namespace Snap.Hutao.Service.Game.Package;
[ConstructorGenerated]
[Injection(InjectAs.Singleton, typeof(IGamePackageService))]
internal sealed partial class GamePackageService : IGamePackageService
{
private readonly PackageConverter packageConverter;
private readonly IServiceProvider serviceProvider;
private readonly ITaskContext taskContext;
private readonly AppOptions appOptions;
public async ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress)
{
string gamePath = appOptions.GamePath;
string? gameFolder = Path.GetDirectoryName(gamePath);
ArgumentException.ThrowIfNullOrEmpty(gameFolder);
string gameFileName = Path.GetFileName(gamePath);
progress.Report(new(SH.ServiceGameEnsureGameResourceQueryResourceInformation));
Response<GameResource> response = await serviceProvider
.GetRequiredService<ResourceClient>()
.GetResourceAsync(launchScheme)
.ConfigureAwait(false);
if (response.IsOk())
{
GameResource resource = response.Data;
if (!launchScheme.ExecutableMatches(gameFileName))
{
bool replaced = await packageConverter
.EnsureGameResourceAsync(launchScheme, resource, gameFolder, progress)
.ConfigureAwait(false);
if (replaced)
{
// We need to change the gamePath if we switched.
string exeName = launchScheme.IsOversea ? GenshinImpactFileName : YuanShenFileName;
await taskContext.SwitchToMainThreadAsync();
appOptions.GamePath = Path.Combine(gameFolder, exeName);
}
else
{
// We can't start the game
// when we failed to convert game
return false;
}
}
await packageConverter.EnsureDeprecatedFilesAndSdkAsync(resource, gameFolder).ConfigureAwait(false);
return true;
}
return false;
}
}

View File

@@ -0,0 +1,11 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.Game.Scheme;
namespace Snap.Hutao.Service.Game.Package;
internal interface IGamePackageService
{
ValueTask<bool> EnsureGameResourceAsync(LaunchScheme launchScheme, IProgress<PackageReplaceStatus> progress);
}

View File

@@ -6,6 +6,7 @@ using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.IO;
using Snap.Hutao.Core.IO.Hashing;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Web.Hoyolab.SdkStatic.Hk4e.Launcher;
using System.IO;
using System.IO.Compression;

View File

@@ -1,162 +0,0 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Service.Game.Unlocker;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using Windows.Win32.Foundation;
using Windows.Win32.System.Memory;
using Windows.Win32.System.Threading;
using static Windows.Win32.PInvoke;
namespace Snap.Hutao.Service.Game;
/// <summary>
/// 进程互操作
/// </summary>
internal static class ProcessInterop
{
/// <summary>
/// 获取初始化后的游戏进程
/// </summary>
/// <param name="options">启动选项</param>
/// <param name="gamePath">游戏路径</param>
/// <returns>初始化后的游戏进程</returns>
public static Process InitializeGameProcess(LaunchOptions options, string gamePath)
{
string commandLine = string.Empty;
if (options.IsEnabled)
{
Must.Argument(!(options.IsBorderless && options.IsExclusive), "无边框与独占全屏选项无法同时生效");
// https://docs.unity.cn/cn/current/Manual/PlayerCommandLineArguments.html
// https://docs.unity3d.com/2017.4/Documentation/Manual/CommandLineArguments.html
commandLine = new CommandLineBuilder()
.AppendIf("-popupwindow", options.IsBorderless)
.AppendIf("-window-mode", options.IsExclusive, "exclusive")
.Append("-screen-fullscreen", options.IsFullScreen ? 1 : 0)
.AppendIf("-screen-width", options.IsScreenWidthEnabled, options.ScreenWidth)
.AppendIf("-screen-height", options.IsScreenHeightEnabled, options.ScreenHeight)
.AppendIf("-monitor", options.IsMonitorEnabled, options.Monitor.Value)
.ToString();
}
return new()
{
StartInfo = new()
{
Arguments = commandLine,
FileName = gamePath,
UseShellExecute = true,
Verb = "runas",
WorkingDirectory = Path.GetDirectoryName(gamePath),
},
};
}
public static ValueTask UnlockFpsAsync(IServiceProvider serviceProvider, Process game, IProgress<LaunchStatus> progress, CancellationToken token = default)
{
IGameFpsUnlocker unlocker = serviceProvider.CreateInstance<GameFpsUnlocker>(game);
UnlockTimingOptions options = new(100, 20000, 3000);
Progress<UnlockerStatus> lockerProgress = new(unlockStatus => progress.Report(FromUnlockStatus(unlockStatus)));
return unlocker.UnlockAsync(options, lockerProgress, token);
}
/// <summary>
/// 尝试禁用mhypbase
/// </summary>
/// <param name="game">游戏进程</param>
/// <param name="gamePath">游戏路径</param>
/// <returns>是否禁用成功</returns>
public static bool DisableProtection(Process game, string gamePath)
{
string? gameFolder = Path.GetDirectoryName(gamePath);
string mhypbaseDll = Path.Combine(gameFolder ?? string.Empty, "mhypbase.dll");
if (File.Exists(mhypbaseDll))
{
using (File.OpenHandle(mhypbaseDll, share: FileShare.None))
{
SpinWait.SpinUntil(() => game.MainWindowHandle != 0);
return true;
}
}
return false;
}
/// <summary>
/// 加载并注入指定路径的库
/// </summary>
/// <param name="hProcess">进程句柄</param>
/// <param name="libraryPathu8">库的路径,不包含'\0'</param>
public static unsafe void LoadLibraryAndInject(in HANDLE hProcess, in ReadOnlySpan<byte> libraryPathu8)
{
HINSTANCE hKernelDll = GetModuleHandle("kernel32.dll");
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
FARPROC pLoadLibraryA = GetProcAddress(hKernelDll, "LoadLibraryA"u8);
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
void* pNativeLibraryPath = default;
try
{
VIRTUAL_ALLOCATION_TYPE allocType = VIRTUAL_ALLOCATION_TYPE.MEM_RESERVE | VIRTUAL_ALLOCATION_TYPE.MEM_COMMIT;
pNativeLibraryPath = VirtualAllocEx(hProcess, default, unchecked((uint)libraryPathu8.Length + 1), allocType, PAGE_PROTECTION_FLAGS.PAGE_READWRITE);
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
WriteProcessMemory(hProcess, pNativeLibraryPath, libraryPathu8);
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
LPTHREAD_START_ROUTINE lpThreadLoadLibraryA = pLoadLibraryA.CreateDelegate<LPTHREAD_START_ROUTINE>();
HANDLE hLoadLibraryAThread = default;
try
{
hLoadLibraryAThread = CreateRemoteThread(hProcess, default, 0, lpThreadLoadLibraryA, pNativeLibraryPath, 0);
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
WaitForSingleObject(hLoadLibraryAThread, 2000);
Marshal.ThrowExceptionForHR(Marshal.GetLastPInvokeError());
}
finally
{
CloseHandle(hLoadLibraryAThread);
}
}
finally
{
VirtualFreeEx(hProcess, pNativeLibraryPath, 0, VIRTUAL_FREE_TYPE.MEM_RELEASE);
}
}
private static unsafe FARPROC GetProcAddress(in HINSTANCE hModule, in ReadOnlySpan<byte> lpProcName)
{
fixed (byte* lpProcNameLocal = lpProcName)
{
return Windows.Win32.PInvoke.GetProcAddress(hModule, new PCSTR(lpProcNameLocal));
}
}
private static unsafe BOOL WriteProcessMemory(in HANDLE hProcess, void* lpBaseAddress, in ReadOnlySpan<byte> buffer)
{
fixed (void* lpBuffer = buffer)
{
return Windows.Win32.PInvoke.WriteProcessMemory(hProcess, lpBaseAddress, lpBuffer, unchecked((uint)buffer.Length));
}
}
private static LaunchStatus FromUnlockStatus(UnlockerStatus unlockerStatus)
{
if (unlockerStatus.FindModuleState == FindModuleResult.Ok)
{
return new(LaunchPhase.UnlockFpsSucceed, SH.ServiceGameLaunchPhaseUnlockFpsSucceed);
}
else
{
return new(LaunchPhase.UnlockFpsFailed, SH.ServiceGameLaunchPhaseUnlockFpsFailed);
}
}
}

View File

@@ -3,7 +3,7 @@
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Scheme;
internal static class KnownLaunchSchemes
{

View File

@@ -2,14 +2,15 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Service.Game.Configuration;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Scheme;
/// <summary>
/// 启动方案
/// </summary>
[HighQuality]
internal partial class LaunchScheme
internal class LaunchScheme
{
/// <summary>
/// 显示名称

View File

@@ -3,7 +3,7 @@
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Scheme;
internal sealed class LaunchSchemeBilibili : LaunchScheme
{

View File

@@ -3,7 +3,7 @@
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Scheme;
internal sealed class LaunchSchemeChinese : LaunchScheme
{

View File

@@ -3,7 +3,7 @@
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.Service.Game;
namespace Snap.Hutao.Service.Game.Scheme;
internal sealed class LaunchSchemeOversea : LaunchScheme
{

View File

@@ -382,7 +382,7 @@
Style="{StaticResource BaseTextBlockStyle}"
Text="{shcm:ResourceString Name=ViewControlStatisticsCardOrangeText}"/>
<GridView
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemTemplate="{StaticResource HistoryWishGridTemplate}"
ItemsSource="{Binding Statistics.OrangeAvatars}"
SelectionMode="None"/>
@@ -391,7 +391,7 @@
Style="{StaticResource BaseTextBlockStyle}"
Text="{shcm:ResourceString Name=ViewControlStatisticsCardPurpleText}"/>
<GridView
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemTemplate="{StaticResource HistoryWishGridTemplate}"
ItemsSource="{Binding Statistics.PurpleAvatars}"
SelectionMode="None"/>
</StackPanel>
@@ -405,7 +405,7 @@
Style="{StaticResource BaseTextBlockStyle}"
Text="{shcm:ResourceString Name=ViewControlStatisticsCardOrangeText}"/>
<GridView
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemTemplate="{StaticResource HistoryWishGridTemplate}"
ItemsSource="{Binding Statistics.OrangeWeapons}"
SelectionMode="None"/>
@@ -414,7 +414,7 @@
Style="{StaticResource BaseTextBlockStyle}"
Text="{shcm:ResourceString Name=ViewControlStatisticsCardPurpleText}"/>
<GridView
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemTemplate="{StaticResource HistoryWishGridTemplate}"
ItemsSource="{Binding Statistics.PurpleWeapons}"
SelectionMode="None"/>
@@ -423,7 +423,7 @@
Style="{StaticResource BaseTextBlockStyle}"
Text="{shcm:ResourceString Name=ViewControlStatisticsCardBlueText}"/>
<GridView
ItemTemplate="{StaticResource HistoryWishItemTemplate}"
ItemTemplate="{StaticResource HistoryWishGridTemplate}"
ItemsSource="{Binding Statistics.BlueWeapons}"
SelectionMode="None"/>
</StackPanel>

View File

@@ -9,7 +9,9 @@ using Snap.Hutao.Factory.Abstraction;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Configuration;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Service.Navigation;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User;
@@ -42,7 +44,7 @@ internal sealed partial class LaunchGameViewModel : Abstraction.ViewModel
private readonly RuntimeOptions hutaoOptions;
private readonly IUserService userService;
private readonly ITaskContext taskContext;
private readonly IGameService gameService;
private readonly IGameServiceFacade gameService;
private readonly IMemoryCache memoryCache;
private readonly AppOptions appOptions;

View File

@@ -16,7 +16,7 @@ namespace Snap.Hutao.ViewModel.Game;
[ConstructorGenerated(CallBaseConstructor = true)]
internal sealed partial class LaunchGameViewModelSlim : Abstraction.ViewModelSlim<View.Page.LaunchGamePage>
{
private readonly IGameService gameService;
private readonly IGameServiceFacade gameService;
private readonly ITaskContext taskContext;
private readonly IInfoBarService infoBarService;

View File

@@ -2,7 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Web.Hoyolab;
namespace Snap.Hutao.Web;

View File

@@ -2,7 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Web.Hoyolab;
namespace Snap.Hutao.Web;

View File

@@ -2,7 +2,7 @@
// Licensed under the MIT license.
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Scheme;
using Snap.Hutao.Web.Request.Builder;
using Snap.Hutao.Web.Request.Builder.Abstraction;
using Snap.Hutao.Web.Response;