1
0
mirror of https://github.com/wangdage12/Snap.Hutao.git synced 2026-06-18 00:34:50 +08:00

43 Commits

Author SHA1 Message Date
fanbook-wangdage
9da2c2eb8b 提升版本号
修复部分操作下添加养成计划时,养成计划页面缓存未刷新的问题
添加部分角色和武器id
2026-05-23 16:38:44 +08:00
wangdage12
ed3f2270b3 Merge pull request #35 from wangdage12/dependabot/github_actions/dot-github/workflows/benc-uk/workflow-dispatch-1.3.2
Bump benc-uk/workflow-dispatch from 1.3.1 to 1.3.2 in /.github/workflows
2026-05-21 18:57:25 +08:00
dependabot[bot]
0eb8b58711 Bump benc-uk/workflow-dispatch from 1.3.1 to 1.3.2 in /.github/workflows
Bumps [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) from 1.3.1 to 1.3.2.
- [Release notes](https://github.com/benc-uk/workflow-dispatch/releases)
- [Commits](https://github.com/benc-uk/workflow-dispatch/compare/v1.3.1...v1.3.2)

---
updated-dependencies:
- dependency-name: benc-uk/workflow-dispatch
  dependency-version: 1.3.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-05-18 03:24:24 +00:00
wangdage12
0777180df5 Merge pull request #34 from mfkvfhpdx/dev
增强 养成计划 材料统计
2026-05-15 12:33:12 +08:00
mfkvfhpdx
f29ddd62a7 修正机器人审查的一些问题 2026-05-04 18:08:41 +08:00
mfkvfhpdx
8588f7736a 修复 rowid 翻译失败导致的崩溃 2026-05-04 17:45:10 +08:00
mfkvfhpdx
f66d712a1b 修正机器人审查的一些项 2026-05-04 17:34:21 +08:00
mfkvfhpdx
a02d7e533f 修复审查问题 2026-05-04 17:05:54 +08:00
mfkvfhpdx
57461a06dd 修复某系周本没有纳入转换范围 2026-05-04 17:01:48 +08:00
mfkvfhpdx
82f1820ca9 增加了 背包同步影响所有计划 选项,选择后背包同步将后将更新所有计划的背包数据 2026-05-04 16:25:53 +08:00
mfkvfhpdx
9ea2bb1a6e 在养成计划,同步所有角色与武器 弹出的 批量添加或更新到当前养成计划 中增加 同步背包物品 ,同步角色信息 (同我的角色,同步角色信息) 两个勾选项 2026-05-04 14:23:41 +08:00
mfkvfhpdx
7a3f125846 在养成计划,同步背包物品前面,加个 同步所有角色与武器 。 效果同 我的角色 养成计划 所有角色与武器 。 2026-05-04 12:14:44 +08:00
mfkvfhpdx
94592c8498 我的角色 同步角色 批量添加或更新到当前养成计划时,该界面的选项和值(角色目标等级、普通攻击目标等级等,保存方式,是否勾选更新计划前先清空已有角色与武器条目),存入该养成计划中。下次点击该项目时,(如果有保留的数据)根据养成计划默认自动勾选。 2026-05-04 11:42:40 +08:00
mfkvfhpdx
ed4626a74c 在 我的角色 同步角色 批量添加或更新到当前养成计划时,增加 清空已经角色和武器信息 勾选框。如勾选该项目,在更新计划前会清空 培养计划中,角色和武器的信息。另外,当点 确认,更新完数据后,刷新现 养成计划。 2026-05-04 11:16:01 +08:00
mfkvfhpdx
c6a2212caa 当角色满,而武器未满时。使武器也能关联上角色 2026-05-04 10:43:20 +08:00
mfkvfhpdx
18afbffbcc 材料统计支持周本材料转化后的结果 2026-05-04 08:03:06 +08:00
mfkvfhpdx
64628b50b5 升级材料合并支持元素石 2026-05-04 07:38:10 +08:00
mfkvfhpdx
11a5efa488 修正CodeRabbit 审查问题 2026-05-03 23:07:13 +08:00
mfkvfhpdx
726c4203d2 调整右键显示缺项分隔符 2026-05-03 22:24:04 +08:00
mfkvfhpdx
ace17fcc7b 右键显示缺项带图片 2026-05-03 22:18:52 +08:00
mfkvfhpdx
8f5532819a 右键缺少材料统计处武器增加绑定的角色 2026-05-03 22:05:08 +08:00
mfkvfhpdx
70c0970e2e 往养成计划里面添加角色和武器时,使之存在关联 2026-05-03 21:57:53 +08:00
mfkvfhpdx
a988a65190 材料统计右键反推 2026-05-03 20:53:22 +08:00
mfkvfhpdx
dead0166a1 材料统计,右键 材料数字处,反推缺少人和数量 2026-05-03 20:40:25 +08:00
mfkvfhpdx
f19dbf78a0 对角色培养素材 内部按照 稀有度排序(区分 世界boss 和 周本怪物) 2026-05-03 19:07:59 +08:00
mfkvfhpdx
a41b7e4a01 调整增量减量材料颜色 2026-05-03 18:34:04 +08:00
mfkvfhpdx
8bb177d08f 调整合并增量颜色 2026-05-03 18:24:19 +08:00
mfkvfhpdx
1e600db869 角色天赋加成材料 2026-05-03 17:58:11 +08:00
mfkvfhpdx
be9f3f6a25 增加材料显示时的材料合并 2026-05-03 17:20:00 +08:00
fanbook-wangdage
d3865b7992 Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-03-24 20:19:13 +08:00
fanbook-wangdage
66263a82f2 更换新域名
支持打开debug控制台
修复一个可能导致元数据出问题的小问题
修改多处url
2026-03-24 20:03:45 +08:00
wangdage12
a2ddeadd0a Revise server status links and add sponsorship info
Updated server status page links and added sponsorship section.
2026-03-23 21:21:07 +08:00
wangdage12
246109227d Merge pull request #30 from wangdage12/dependabot/github_actions/dot-github/workflows/actions/upload-artifact-7
Bump actions/upload-artifact from 6 to 7 in /.github/workflows
2026-03-17 13:15:28 +08:00
dependabot[bot]
515fa004bb Bump actions/upload-artifact from 6 to 7 in /.github/workflows
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-02 02:57:32 +00:00
wangdage12
7c706f8dc7 Merge pull request #28 from wangdage12/dependabot/github_actions/dot-github/workflows/benc-uk/workflow-dispatch-1.3.1
Bump benc-uk/workflow-dispatch from 1.2.4 to 1.3.1 in /.github/workflows
2026-02-28 15:58:57 +08:00
fanbook-wangdage
9de07754c7 提升版本号 2026-02-28 15:25:31 +08:00
fanbook-wangdage
8e86ccc560 优化第三方工具功能(#25) 2026-02-28 15:19:16 +08:00
fanbook-wangdage
e1df07ac21 在没有本地化key时返回api的状态消息(fix #27) 2026-02-28 13:46:24 +08:00
dependabot[bot]
1dabff07a1 Bump benc-uk/workflow-dispatch from 1.2.4 to 1.3.1 in /.github/workflows
Bumps [benc-uk/workflow-dispatch](https://github.com/benc-uk/workflow-dispatch) from 1.2.4 to 1.3.1.
- [Release notes](https://github.com/benc-uk/workflow-dispatch/releases)
- [Commits](https://github.com/benc-uk/workflow-dispatch/compare/v1.2.4...v1.3.1)

---
updated-dependencies:
- dependency-name: benc-uk/workflow-dispatch
  dependency-version: 1.3.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-02-23 02:57:49 +00:00
fanbook-wangdage
640686a837 fix #26 2026-02-20 13:21:48 +08:00
fanbook-wangdage
e9ed7928d6 Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-15 11:28:29 +08:00
fanbook-wangdage
4d2943d1c9 新增yae注入获取成就功能、修改yae逻辑
修复msix没有打包解锁器的问题
修复注入时米游社账号登录不起作用的问题
2026-02-15 11:28:17 +08:00
wangdage12
74e9427451 Update README with website and version highlights
Added official website link and highlighted version features.
2026-02-09 20:14:52 +08:00
104 changed files with 4885 additions and 286 deletions

View File

@@ -64,7 +64,7 @@ jobs:
-Body $r2Data ` -Body $r2Data `
-ContentType "application/json" -ContentType "application/json"
Write-Output $response2.Content Write-Output $response2.Content
- uses: benc-uk/workflow-dispatch@v1.2.4 - uses: benc-uk/workflow-dispatch@v1.3.2
with: with:
workflow: Build workflow: Build
repo: DGP-Studio/hutao-installer repo: DGP-Studio/hutao-installer

View File

@@ -113,7 +113,7 @@ jobs:
- name: Upload signed msix - name: Upload signed msix
if: success() && github.event_name != 'pull_request' if: success() && github.event_name != 'pull_request'
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: Snap.Hutao.Alpha-${{ steps.cake.outputs.version }} name: Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}
path: ${{ github.workspace }}/src/output/Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}.msix path: ${{ github.workspace }}/src/output/Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}.msix

View File

@@ -77,7 +77,7 @@ jobs:
- name: Upload signed msix - name: Upload signed msix
if: ${{ success() && steps.merge.outputs.continue == 'true' }} if: ${{ success() && steps.merge.outputs.continue == 'true' }}
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: Snap.Hutao.Canary-${{ steps.cake.outputs.version }} name: Snap.Hutao.Canary-${{ steps.cake.outputs.version }}
path: ${{ github.workspace }}/src/output/Snap.Hutao.Canary-${{ steps.cake.outputs.version }}.msix path: ${{ github.workspace }}/src/output/Snap.Hutao.Canary-${{ steps.cake.outputs.version }}.msix

View File

@@ -31,7 +31,7 @@ jobs:
run: dotnet build src/Snap.Hutao/Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj -c Release run: dotnet build src/Snap.Hutao/Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj -c Release
- name: Upload MSI Artifact - name: Upload MSI Artifact
uses: actions/upload-artifact@v6 uses: actions/upload-artifact@v7
with: with:
name: Snap.Hutao-MSI name: Snap.Hutao-MSI
path: | path: |

View File

@@ -6,6 +6,14 @@
自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。 自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
官网https://htserver.wdg12.work/
**该版本的特点:**
- 尽量保留原版功能,少重写功能,稳定性强
- 只集成没有争议的安全的注入功能
- 大部分注入功能以第三方工具形式提供,点击即用
- 永久免费的云抽卡日志
有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ 有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ
**English** **English**
@@ -19,6 +27,8 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据 只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据
有时候我们在对某些功能有重大更改时发布测试版可在官网的下载可加入discord服务器报告功能使用情况和获取测试通知
--- ---
## 开发 ## 开发
@@ -32,7 +42,7 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
**目前元数据的编写进度:** **目前元数据的编写进度:**
| 项目V6.3 | 是否完成 | | 项目V6.4 | 是否完成 |
| ----------- | ----------- | | ----------- | ----------- |
| 总体数据 | ✔️ | | 总体数据 | ✔️ |
@@ -57,6 +67,14 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
- 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server) - 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server)
- Web管理后台和官网[Snap.Server.Web](https://github.com/wangdage12/Snap.Server.Web) - Web管理后台和官网[Snap.Server.Web](https://github.com/wangdage12/Snap.Server.Web)
**第三方工具**
如果你想要添加你自己开发的工具到第三方工具列表中,请确保:
1. 工具应该提供源码或者开源,并且可以成功编译
2. 工具不应提供任何可能影响游戏公平性的功能
工具不限于注入功能,若满足以上条件,请提 issue或者在 discord 服务器中联系管理员
## 打包测试 ## 打包测试
由于采用了 wix 进行打包程序VS 需要安装 **HeatWave for VS2022**2026兼容。需要 msi 安装包时,右键选中 Snap.Hutao.Installer 生成后即可在目标目录找到。默认目录Snap.Hutao.Installer\bin\x64\Release\en-US\Snap.Hutao.Installer.msi 由于采用了 wix 进行打包程序VS 需要安装 **HeatWave for VS2022**2026兼容。需要 msi 安装包时,右键选中 Snap.Hutao.Installer 生成后即可在目标目录找到。默认目录Snap.Hutao.Installer\bin\x64\Release\en-US\Snap.Hutao.Installer.msi
@@ -75,9 +93,7 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能 我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能
[服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61) [服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61)
[旧服务器状态页面](http://serverjp.wdg.cloudns.ch:3001/status/hts)
--- ---
@@ -85,7 +101,6 @@ https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
https://github.com/wangdage12/Snap.Metadata https://github.com/wangdage12/Snap.Metadata
仓库镜像: 仓库镜像:
![http://serverjp.wdg.cloudns.ch:3001/api/badge/11/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/11/status?style=flat-square)
http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
@@ -95,10 +110,16 @@ http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
![http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square) ![http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/10/status?style=flat-square)
https://htserver.wdg.cloudns.ch/api/ https://htserver.wdg12.work/api/
--- ---
**图片资源站:** **图片资源站:**
https://htserver.wdg.cloudns.ch/ https://htserver.wdg12.work/
# 赞助
如果你想要为我分摊经济压力,可以在下方链接中为我赞助(支持多个预设方案,你也可以在页面下方自定义金额)
赞助的资金将全部用于服务器、域名等若有剩余资金将升级CDN或者服务器来提升使用体验我们的服务是完全免费的该赞助并不会解锁额外特权但是>=10元时将在官网新的“赞助者页面”上添加你的信息
https://ifdian.net/a/wdg12

View File

@@ -3,7 +3,7 @@
<Package <Package
Name="Snap.Hutao" Name="Snap.Hutao"
Manufacturer="Millennium Science Technology R-D Inst" Manufacturer="Millennium Science Technology R-D Inst"
Version="1.18.3.0" Version="1.18.7.0"
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68" UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
Language="2052" Language="2052"
Scope="perMachine"> Scope="perMachine">

View File

@@ -1,4 +1,4 @@
<Project Sdk="WixToolset.Sdk/6.0.2"> <Project Sdk="WixToolset.Sdk/6.0.2">
<PropertyGroup> <PropertyGroup>
<SuppressIces>ICE03;ICE60</SuppressIces> <SuppressIces>ICE03;ICE60</SuppressIces>
<Platform>x64</Platform> <Platform>x64</Platform>
@@ -9,11 +9,11 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\Snap.Hutao\Snap.Hutao.csproj" /> <ProjectReference Include="..\Snap.Hutao\Snap.Hutao.csproj" SetPlatform="Platform=x64" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<HarvestDirectory Include="..\Snap.Hutao\bin\Release\net10.0-windows10.0.26100.0\win-x64"> <HarvestDirectory Include="..\Snap.Hutao\bin\x64\Release\net10.0-windows10.0.26100.0\win-x64">
<ComponentGroupName>MainAppComponents</ComponentGroupName> <ComponentGroupName>MainAppComponents</ComponentGroupName>
<DirectoryRefId>INSTALLFOLDER</DirectoryRefId> <DirectoryRefId>INSTALLFOLDER</DirectoryRefId>
<SuppressCom>true</SuppressCom> <SuppressCom>true</SuppressCom>

View File

@@ -22,6 +22,11 @@ public static partial class Bootstrap
private static readonly ApplicationInitializationCallback AppInitializationCallback = InitializeApp; private static readonly ApplicationInitializationCallback AppInitializationCallback = InitializeApp;
private static Mutex? mutex; private static Mutex? mutex;
/// <summary>
/// Gets a value indicating whether console output is enabled.
/// </summary>
public static bool ConsoleEnabled { get; private set; }
internal static void UseNamedPipeRedirection() internal static void UseNamedPipeRedirection()
{ {
Debug.Assert(mutex is not null); Debug.Assert(mutex is not null);
@@ -31,6 +36,19 @@ public static partial class Bootstrap
[STAThread] [STAThread]
private static void Main(string[] args) private static void Main(string[] args)
{ {
// Check for console flag
ConsoleEnabled = args.Contains("--console") || args.Contains("--debug") ||
Environment.GetEnvironmentVariable("HUTAO_DEBUG") == "1";
if (ConsoleEnabled)
{
// Allocate a console window for debug output
HutaoNativeMethods.AllocConsole();
Console.WriteLine("[Bootstrap] Console allocated for debug output");
Console.WriteLine($"[Bootstrap] Arguments: {string.Join(" ", args)}");
Console.WriteLine($"[Bootstrap] Working Directory: {Environment.CurrentDirectory}");
}
#if DEBUG #if DEBUG
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Starting..."); System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Starting...");
#endif #endif
@@ -124,6 +142,12 @@ public static partial class Bootstrap
#if DEBUG #if DEBUG
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Exiting"); System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Exiting");
#endif #endif
if (ConsoleEnabled)
{
Console.WriteLine("[Bootstrap] Application exiting...");
HutaoNativeMethods.FreeConsole();
}
} }
private static void InitializeApp(ApplicationInitializationCallbackParams param) private static void InitializeApp(ApplicationInitializationCallbackParams param)

View File

@@ -29,6 +29,12 @@ internal static class DependencyInjection
.AddFilter(DbLoggerCategory.Query.Name, level => level >= LogLevel.Information) .AddFilter(DbLoggerCategory.Query.Name, level => level >= LogLevel.Information)
.AddDebug() .AddDebug()
.AddSentryTelemetry(); .AddSentryTelemetry();
// Add console logging if console is enabled
if (Bootstrap.ConsoleEnabled)
{
builder.AddConsole();
}
}) })
.AddMemoryCache() .AddMemoryCache()

View File

@@ -23,12 +23,13 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
private readonly TargetNativeConfiguration config; private readonly TargetNativeConfiguration config;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IProcess gameProcess; private readonly IProcess gameProcess;
private readonly bool supportsResumeMainThread;
private readonly NamedPipeServerStream serverStream; private readonly NamedPipeServerStream serverStream;
private volatile bool disposed; private volatile bool disposed;
public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config) public YaeNamedPipeServer(IServiceProvider serviceProvider, IProcess gameProcess, TargetNativeConfiguration config, bool supportsResumeMainThread = true)
{ {
Verify.Operation(HutaoRuntime.IsProcessElevated, "Snap Hutao must be elevated to use Yae."); Verify.Operation(HutaoRuntime.IsProcessElevated, "Snap Hutao must be elevated to use Yae.");
@@ -36,6 +37,7 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
this.gameProcess = gameProcess; this.gameProcess = gameProcess;
this.config = config; this.config = config;
this.supportsResumeMainThread = supportsResumeMainThread;
// Yae is always running elevated, so we don't need to use ACL method. // Yae is always running elevated, so we don't need to use ACL method.
serverStream = new(PipeName); serverStream = new(PipeName);
@@ -116,7 +118,10 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
case YaeCommandKind.RequestResumeThread: case YaeCommandKind.RequestResumeThread:
{ {
gameProcess.ResumeMainThread(); if (supportsResumeMainThread)
{
gameProcess.ResumeMainThread();
}
return default; return default;
} }

View File

@@ -74,6 +74,10 @@ internal static class SettingKeys
public const string CultivationWeapon90LevelCurrent = "Snap::Hutao::Cultivation::Weapon90::Level::Current"; public const string CultivationWeapon90LevelCurrent = "Snap::Hutao::Cultivation::Weapon90::Level::Current";
public const string CultivationWeapon90LevelTarget = "Snap::Hutao::Cultivation::Weapon90::Level::Target"; public const string CultivationWeapon90LevelTarget = "Snap::Hutao::Cultivation::Weapon90::Level::Target";
public const string ResinStatisticsSelectedDropDistribution = "Snap::Hutao::Cultivation::ResinStatistics::DropDistribution"; public const string ResinStatisticsSelectedDropDistribution = "Snap::Hutao::Cultivation::ResinStatistics::DropDistribution";
public const string CultivationStatisticsMergeUpgradeMaterials = "Snap::Hutao::Cultivation::Statistics::MergeUpgradeMaterials";
public const string CultivationStatisticsTalentSynthCritTenPercent = "Snap::Hutao::Cultivation::Statistics::TalentSynthCritTenPercent";
public const string CultivationStatisticsWeeklyBossMaterialInterchange = "Snap::Hutao::Cultivation::Statistics::WeeklyBossMaterialInterchange";
public const string CultivationRefreshInventoryByCalculatorToAllProjects = "Snap::Hutao::Cultivation::RefreshInventory::ByCalculator::ToAllProjects";
// GachaLog // GachaLog
public const string IsEmptyHistoryWishVisible = "Snap::Hutao::GachaLog::HistoryWish::EmptyVisible"; public const string IsEmptyHistoryWishVisible = "Snap::Hutao::GachaLog::HistoryWish::EmptyVisible";

View File

@@ -0,0 +1,668 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Snap.Hutao.Model.Entity.Database;
#nullable disable
namespace Snap.Hutao.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260503131124_AddCultivateEntryRelatedEntryId")]
partial class AddCultivateEntryRelatedEntryId
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ArchiveId")
.HasColumnType("TEXT");
b.Property<uint>("Current")
.HasColumnType("INTEGER");
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ArchiveId");
b.ToTable("achievements");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AchievementArchive", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("achievement_archives");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AvatarInfo", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Info2")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("avatar_infos");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AvatarStrategy", b =>
{
b.Property<uint>("AvatarId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChineseStrategyId")
.HasColumnType("INTEGER");
b.Property<int>("OverseaStrategyId")
.HasColumnType("INTEGER");
b.HasKey("AvatarId");
b.ToTable("avatar_strategies");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<Guid?>("RelatedEntryId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("ProjectId");
b.HasIndex("RelatedEntryId");
b.ToTable("cultivate_entries");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AvatarIsPromoting")
.HasColumnType("INTEGER");
b.Property<uint>("AvatarLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("AvatarLevelTo")
.HasColumnType("INTEGER");
b.Property<Guid>("EntryId")
.HasColumnType("TEXT");
b.Property<uint>("SkillALevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillALevelTo")
.HasColumnType("INTEGER");
b.Property<uint>("SkillELevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillELevelTo")
.HasColumnType("INTEGER");
b.Property<uint>("SkillQLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillQLevelTo")
.HasColumnType("INTEGER");
b.Property<bool>("WeaponIsPromoting")
.HasColumnType("INTEGER");
b.Property<uint>("WeaponLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("WeaponLevelTo")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("EntryId")
.IsUnique();
b.ToTable("cultivate_entry_level_informations");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Count")
.HasColumnType("INTEGER");
b.Property<Guid>("EntryId")
.HasColumnType("TEXT");
b.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("EntryId");
b.ToTable("cultivate_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateProject", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<TimeSpan>("ServerTimeZoneOffset")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("cultivate_projects");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("DailyNote")
.HasColumnType("TEXT");
b.Property<bool>("DailyTaskDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("DailyTaskNotify")
.HasColumnType("INTEGER");
b.Property<bool>("DailyTaskNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionNotify")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<bool>("HomeCoinDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("HomeCoinNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<int>("HomeCoinNotifyThreshold")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<bool>("ResinDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("ResinNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<int>("ResinNotifyThreshold")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerNotify")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("UserId");
b.ToTable("daily_notes");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaArchive", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("gacha_archives");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ArchiveId")
.HasColumnType("TEXT");
b.Property<int>("GachaType")
.HasColumnType("INTEGER");
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("QueryType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ArchiveId");
b.ToTable("gacha_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GameAccount", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<string>("MacAddress")
.HasColumnType("TEXT");
b.Property<string>("Mid")
.HasColumnType("TEXT");
b.Property<string>("MihoyoSDK")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.ToTable("game_accounts");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.HardChallengeEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("HardChallengeData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("hard_challenges");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Count")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ProjectId");
b.ToTable("inventory_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.ObjectCacheEntry", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpireTime")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("object_cache");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.RoleCombatEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("RoleCombatData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("role_combats");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.SettingEntry", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("settings");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.SpiralAbyssEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("SpiralAbyss")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("spiral_abysses");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.UidProfilePicture", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("AvatarId")
.HasColumnType("INTEGER");
b.Property<uint>("CostumeId")
.HasColumnType("INTEGER");
b.Property<uint>("ProfilePictureId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("uid_profile_pictures");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.User", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Aid")
.HasColumnType("TEXT");
b.Property<string>("CookieToken")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CookieTokenLastUpdateTime")
.HasColumnType("TEXT");
b.Property<string>("Fingerprint")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("FingerprintLastUpdateTime")
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsOversea")
.HasColumnType("INTEGER");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("LToken")
.HasColumnType("TEXT")
.HasColumnName("Ltoken");
b.Property<string>("Mid")
.HasColumnType("TEXT");
b.Property<string>("PreferredUid")
.HasColumnType("TEXT");
b.Property<string>("SToken")
.HasColumnType("TEXT")
.HasColumnName("Stoken");
b.HasKey("InnerId");
b.ToTable("users");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.AchievementArchive", "Archive")
.WithMany()
.HasForeignKey("ArchiveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Archive");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "RelatedEntry")
.WithMany()
.HasForeignKey("RelatedEntryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project");
b.Navigation("RelatedEntry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "Entry")
.WithOne("LevelInformation")
.HasForeignKey("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", "EntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "Entry")
.WithMany()
.HasForeignKey("EntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.GachaArchive", "Archive")
.WithMany()
.HasForeignKey("ArchiveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Archive");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.Navigation("LevelInformation");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,50 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Snap.Hutao.Migrations
{
/// <inheritdoc />
public partial class AddCultivateEntryRelatedEntryId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<Guid>(
name: "RelatedEntryId",
table: "cultivate_entries",
type: "TEXT",
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.DropIndex(
name: "IX_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.DropColumn(
name: "RelatedEntryId",
table: "cultivate_entries");
}
}
}

View File

@@ -0,0 +1,42 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Snap.Hutao.Migrations
{
/// <inheritdoc />
public partial class AddCultivateEntryAssociationOnly : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId",
onDelete: ReferentialAction.SetNull);
}
}
}

View File

@@ -0,0 +1,670 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Snap.Hutao.Model.Entity.Database;
#nullable disable
namespace Snap.Hutao.Migrations
{
[DbContext(typeof(AppDbContext))]
[Migration("20260504033049_AddCultivateProjectAvatarPropertyBatchPreferences")]
partial class AddCultivateProjectAvatarPropertyBatchPreferences
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ArchiveId")
.HasColumnType("TEXT");
b.Property<uint>("Current")
.HasColumnType("INTEGER");
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<int>("Status")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ArchiveId");
b.ToTable("achievements");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AchievementArchive", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("achievement_archives");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AvatarInfo", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Info2")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("avatar_infos");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.AvatarStrategy", b =>
{
b.Property<uint>("AvatarId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("ChineseStrategyId")
.HasColumnType("INTEGER");
b.Property<int>("OverseaStrategyId")
.HasColumnType("INTEGER");
b.HasKey("AvatarId");
b.ToTable("avatar_strategies");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Id")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.Property<Guid?>("RelatedEntryId")
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("ProjectId");
b.HasIndex("RelatedEntryId");
b.ToTable("cultivate_entries");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("AvatarIsPromoting")
.HasColumnType("INTEGER");
b.Property<uint>("AvatarLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("AvatarLevelTo")
.HasColumnType("INTEGER");
b.Property<Guid>("EntryId")
.HasColumnType("TEXT");
b.Property<uint>("SkillALevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillALevelTo")
.HasColumnType("INTEGER");
b.Property<uint>("SkillELevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillELevelTo")
.HasColumnType("INTEGER");
b.Property<uint>("SkillQLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("SkillQLevelTo")
.HasColumnType("INTEGER");
b.Property<bool>("WeaponIsPromoting")
.HasColumnType("INTEGER");
b.Property<uint>("WeaponLevelFrom")
.HasColumnType("INTEGER");
b.Property<uint>("WeaponLevelTo")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("EntryId")
.IsUnique();
b.ToTable("cultivate_entry_level_informations");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Count")
.HasColumnType("INTEGER");
b.Property<Guid>("EntryId")
.HasColumnType("TEXT");
b.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.HasIndex("EntryId");
b.ToTable("cultivate_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateProject", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("AvatarPropertyBatchCultivatePreferencesJson")
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<TimeSpan>("ServerTimeZoneOffset")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("cultivate_projects");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("DailyNote")
.HasColumnType("TEXT");
b.Property<bool>("DailyTaskDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("DailyTaskNotify")
.HasColumnType("INTEGER");
b.Property<bool>("DailyTaskNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionNotify")
.HasColumnType("INTEGER");
b.Property<bool>("ExpeditionNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<bool>("HomeCoinDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("HomeCoinNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<int>("HomeCoinNotifyThreshold")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<bool>("ResinDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("ResinNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<int>("ResinNotifyThreshold")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerDotVisible")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerNotify")
.HasColumnType("INTEGER");
b.Property<bool>("TransformerNotifySuppressed")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.Property<Guid>("UserId")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("UserId");
b.ToTable("daily_notes");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaArchive", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("gacha_archives");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<Guid>("ArchiveId")
.HasColumnType("TEXT");
b.Property<int>("GachaType")
.HasColumnType("INTEGER");
b.Property<long>("Id")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.Property<int>("QueryType")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Time")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ArchiveId");
b.ToTable("gacha_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GameAccount", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<string>("MacAddress")
.HasColumnType("TEXT");
b.Property<string>("Mid")
.HasColumnType("TEXT");
b.Property<string>("MihoyoSDK")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("Type")
.HasColumnType("INTEGER");
b.HasKey("InnerId");
b.ToTable("game_accounts");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.HardChallengeEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("HardChallengeData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("hard_challenges");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("Count")
.HasColumnType("INTEGER");
b.Property<uint>("ItemId")
.HasColumnType("INTEGER");
b.Property<Guid>("ProjectId")
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.HasIndex("ProjectId");
b.ToTable("inventory_items");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.ObjectCacheEntry", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("ExpireTime")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("object_cache");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.RoleCombatEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("RoleCombatData")
.IsRequired()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("role_combats");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.SettingEntry", b =>
{
b.Property<string>("Key")
.HasColumnType("TEXT");
b.Property<string>("Value")
.HasColumnType("TEXT");
b.HasKey("Key");
b.ToTable("settings");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.SpiralAbyssEntry", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("ScheduleId")
.HasColumnType("INTEGER");
b.Property<string>("SpiralAbyss")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("spiral_abysses");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.UidProfilePicture", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<uint>("AvatarId")
.HasColumnType("INTEGER");
b.Property<uint>("CostumeId")
.HasColumnType("INTEGER");
b.Property<uint>("ProfilePictureId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("RefreshTime")
.HasColumnType("TEXT");
b.Property<string>("Uid")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("InnerId");
b.ToTable("uid_profile_pictures");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.User", b =>
{
b.Property<Guid>("InnerId")
.ValueGeneratedOnAdd()
.HasColumnType("TEXT");
b.Property<string>("Aid")
.HasColumnType("TEXT");
b.Property<string>("CookieToken")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CookieTokenLastUpdateTime")
.HasColumnType("TEXT");
b.Property<string>("Fingerprint")
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("FingerprintLastUpdateTime")
.HasColumnType("TEXT");
b.Property<int>("Index")
.HasColumnType("INTEGER");
b.Property<bool>("IsOversea")
.HasColumnType("INTEGER");
b.Property<bool>("IsSelected")
.HasColumnType("INTEGER");
b.Property<string>("LToken")
.HasColumnType("TEXT")
.HasColumnName("Ltoken");
b.Property<string>("Mid")
.HasColumnType("TEXT");
b.Property<string>("PreferredUid")
.HasColumnType("TEXT");
b.Property<string>("SToken")
.HasColumnType("TEXT")
.HasColumnName("Stoken");
b.HasKey("InnerId");
b.ToTable("users");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.AchievementArchive", "Archive")
.WithMany()
.HasForeignKey("ArchiveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Archive");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "RelatedEntry")
.WithMany()
.HasForeignKey("RelatedEntryId");
b.Navigation("Project");
b.Navigation("RelatedEntry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "Entry")
.WithOne("LevelInformation")
.HasForeignKey("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", "EntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "Entry")
.WithMany()
.HasForeignKey("EntryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Entry");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.DailyNoteEntry", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.User", "User")
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("User");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.GachaItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.GachaArchive", "Archive")
.WithMany()
.HasForeignKey("ArchiveId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Archive");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.InventoryItem", b =>
{
b.HasOne("Snap.Hutao.Model.Entity.CultivateProject", "Project")
.WithMany()
.HasForeignKey("ProjectId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Project");
});
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntry", b =>
{
b.Navigation("LevelInformation");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,51 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Snap.Hutao.Migrations
{
/// <inheritdoc />
public partial class AddCultivateProjectAvatarPropertyBatchPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.AddColumn<string>(
name: "AvatarPropertyBatchCultivatePreferencesJson",
table: "cultivate_projects",
type: "TEXT",
nullable: true);
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.DropColumn(
name: "AvatarPropertyBatchCultivatePreferencesJson",
table: "cultivate_projects");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId",
onDelete: ReferentialAction.SetNull);
}
}
}

View File

@@ -0,0 +1,41 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Snap.Hutao.Migrations
{
/// <inheritdoc />
public partial class FixCultivateEntryRelatedEntryOnDeleteSetNull : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId",
onDelete: ReferentialAction.SetNull);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries");
migrationBuilder.AddForeignKey(
name: "FK_cultivate_entries_cultivate_entries_RelatedEntryId",
table: "cultivate_entries",
column: "RelatedEntryId",
principalTable: "cultivate_entries",
principalColumn: "InnerId");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace Snap.Hutao.Migrations
protected override void BuildModel(ModelBuilder modelBuilder) protected override void BuildModel(ModelBuilder modelBuilder)
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.9"); modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b => modelBuilder.Entity("Snap.Hutao.Model.Entity.Achievement", b =>
{ {
@@ -113,6 +113,9 @@ namespace Snap.Hutao.Migrations
b.Property<Guid>("ProjectId") b.Property<Guid>("ProjectId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<Guid?>("RelatedEntryId")
.HasColumnType("TEXT");
b.Property<int>("Type") b.Property<int>("Type")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -120,6 +123,8 @@ namespace Snap.Hutao.Migrations
b.HasIndex("ProjectId"); b.HasIndex("ProjectId");
b.HasIndex("RelatedEntryId");
b.ToTable("cultivate_entries"); b.ToTable("cultivate_entries");
}); });
@@ -207,6 +212,9 @@ namespace Snap.Hutao.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<string>("AvatarPropertyBatchCultivatePreferencesJson")
.HasColumnType("TEXT");
b.Property<bool>("IsSelected") b.Property<bool>("IsSelected")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -585,7 +593,14 @@ namespace Snap.Hutao.Migrations
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("Snap.Hutao.Model.Entity.CultivateEntry", "RelatedEntry")
.WithMany()
.HasForeignKey("RelatedEntryId")
.OnDelete(DeleteBehavior.SetNull);
b.Navigation("Project"); b.Navigation("Project");
b.Navigation("RelatedEntry");
}); });
modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b => modelBuilder.Entity("Snap.Hutao.Model.Entity.CultivateEntryLevelInformation", b =>

View File

@@ -0,0 +1,34 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Model.Cultivation;
/// <summary>
/// 「我的角色」批量同步到当前养成计划时,在批量对话框中使用的目标等级、保存策略等;按 <see cref="Entity.CultivateProject"/> 持久化。
/// </summary>
internal sealed class CultivateProjectAvatarPropertyBatchPreferences
{
public uint AvatarLevelTarget { get; set; }
public uint SkillATarget { get; set; }
public uint SkillETarget { get; set; }
public uint SkillQTarget { get; set; }
public uint WeaponLevelTarget { get; set; }
public int ConsumptionSaveStrategyIndex { get; set; }
public bool ClearAvatarAndWeaponEntriesBeforeSync { get; set; }
/// <summary>
/// 执行批量前是否先通过养成计算器将游戏背包同步到当前计划(与养成计划「同步背包物品」一致)。
/// </summary>
public bool SyncInventoryItems { get; set; }
/// <summary>
/// 执行批量前是否先从米游社原神战绩同步「我的角色」数据(与我的角色「同步角色信息」一致)。
/// </summary>
public bool SyncCharacterInfo { get; set; }
}

View File

@@ -0,0 +1,18 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Metadata.Builders;
namespace Snap.Hutao.Model.Entity.Configuration;
internal sealed class CultivateEntryConfiguration : IEntityTypeConfiguration<CultivateEntry>
{
public void Configure(EntityTypeBuilder<CultivateEntry> builder)
{
builder.HasOne(e => e.RelatedEntry)
.WithMany()
.HasForeignKey(e => e.RelatedEntryId)
.OnDelete(DeleteBehavior.SetNull);
}
}

View File

@@ -26,6 +26,11 @@ internal sealed class CultivateEntry : IAppDbEntity
public uint Id { get; set; } public uint Id { get; set; }
public Guid? RelatedEntryId { get; set; }
[ForeignKey(nameof(RelatedEntryId))]
public CultivateEntry? RelatedEntry { get; set; }
public static CultivateEntry From(Guid projectId, CultivateType type, uint id) public static CultivateEntry From(Guid projectId, CultivateType type, uint id)
{ {
return new() return new()

View File

@@ -22,6 +22,11 @@ internal sealed partial class CultivateProject : ISelectable,
public TimeSpan ServerTimeZoneOffset { get; set; } public TimeSpan ServerTimeZoneOffset { get; set; }
/// <summary>
/// <see cref="Model.Cultivation.CultivateProjectAvatarPropertyBatchPreferences"/> 的 JSON按项目记忆批量同步养成选项。
/// </summary>
public string? AvatarPropertyBatchCultivatePreferencesJson { get; set; }
public static CultivateProject From(string name, in TimeSpan serverTimeOffset) public static CultivateProject From(string name, in TimeSpan serverTimeOffset)
{ {
return new() return new()

View File

@@ -90,6 +90,7 @@ internal sealed partial class AppDbContext : DbContext
{ {
modelBuilder modelBuilder
.ApplyConfiguration(new AvatarInfoConfiguration()) .ApplyConfiguration(new AvatarInfoConfiguration())
.ApplyConfiguration(new CultivateEntryConfiguration())
.ApplyConfiguration(new DailyNoteEntryConfiguration()) .ApplyConfiguration(new DailyNoteEntryConfiguration())
.ApplyConfiguration(new SpiralAbyssEntryConfiguration()) .ApplyConfiguration(new SpiralAbyssEntryConfiguration())
.ApplyConfiguration(new RoleCombatEntryConfiguration()) .ApplyConfiguration(new RoleCombatEntryConfiguration())

View File

@@ -129,6 +129,11 @@ internal static class AvatarIds
public static readonly AvatarId Columbina = 10000125; public static readonly AvatarId Columbina = 10000125;
public static readonly AvatarId Zibai = 10000126; public static readonly AvatarId Zibai = 10000126;
public static readonly AvatarId Illuga = 10000127; public static readonly AvatarId Illuga = 10000127;
public static readonly AvatarId Varka = 10000128;
public static readonly AvatarId Lohen = 10000129;
public static readonly AvatarId Linnea = 10000130;
public static readonly AvatarId Nicole = 10000131;
public static readonly AvatarId Prune = 10000132;
private static readonly FrozenSet<AvatarId> StandardWishIds = private static readonly FrozenSet<AvatarId> StandardWishIds =
[ [

View File

@@ -10,6 +10,18 @@ namespace Snap.Hutao.Model.Metadata.Item;
internal static class MaterialIds internal static class MaterialIds
{ {
/// <summary>
/// 材料统计右键「未完成条目」不追溯的材料(摩拉、经验书、精锻用魔矿)。
/// </summary>
public static bool IsExcludedFromStatisticsConsumerMenu(uint materialId)
{
return materialId is Mora
or WanderersAdvice
or AdventurersExperience
or HeroesWit
or MysticEnhancementOre;
}
public const uint Mora = 202U; // 摩拉 public const uint Mora = 202U; // 摩拉
public const uint WanderersAdvice = 104001U; // 流浪者的经验 public const uint WanderersAdvice = 104001U; // 流浪者的经验
public const uint AdventurersExperience = 104002U; // 冒险家的经验 public const uint AdventurersExperience = 104002U; // 冒险家的经验

View File

@@ -28,13 +28,11 @@ internal static class WeaponIds
public static readonly FrozenSet<WeaponId> OrangeStandardWishIds = public static readonly FrozenSet<WeaponId> OrangeStandardWishIds =
[ [
11501U, 11502U, 11501U, 11502U, 11518U, 11519U,
12501U, 12502U, 12501U, 12502U,
13502U, 13505U, 13502U, 13505U, 13517U,
14501U, 14502U, 14501U, 14502U, 14522U, 14523U,
15501U, 15502U, 15501U, 15502U, 15515U
15515U, 11518U,
14522U, 11519U
]; ];
public static bool IsOrangeStandardWish(in WeaponId weaponId) public static bool IsOrangeStandardWish(in WeaponId weaponId)

View File

@@ -13,7 +13,7 @@
<Identity <Identity
Name="60568DGPStudio.SnapHutao" Name="60568DGPStudio.SnapHutao"
Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52" Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52"
Version="1.18.3.0" /> Version="1.18.7.0" />
<Properties> <Properties>
<DisplayName>Snap Hutao</DisplayName> <DisplayName>Snap Hutao</DisplayName>

View File

@@ -1349,6 +1349,9 @@ Space Available: {2}</value>
<data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve"> <data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve">
<value>Weapon Target Level</value> <value>Weapon Target Level</value>
</data> </data>
<data name="ViewDialogCultivateBatchClearAvatarAndWeaponEntries" xml:space="preserve">
<value>Clear existing character and weapon entries before updating</value>
</data>
<data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve"> <data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve">
<value>Enter the plan name here</value> <value>Enter the plan name here</value>
</data> </data>
@@ -1880,6 +1883,9 @@ Space Available: {2}</value>
<data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve"> <data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve">
<value>Ascending only</value> <value>Ascending only</value>
</data> </data>
<data name="ViewModelCultivationEntryRelatedAvatar" xml:space="preserve">
<value>Linked character: {0}</value>
</data>
<data name="ViewModelCultivationProjectAdded" xml:space="preserve"> <data name="ViewModelCultivationProjectAdded" xml:space="preserve">
<value>Added successfully</value> <value>Added successfully</value>
</data> </data>
@@ -2420,6 +2426,15 @@ Space Available: {2}</value>
<data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve"> <data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve">
<value>Uncollected First</value> <value>Uncollected First</value>
</data> </data>
<data name="ViewPageCultivationMergeUpgradeMaterialsLabel" xml:space="preserve">
<value>Merge upgrade materials</value>
</data>
<data name="ViewPageCultivationTalentSynthCritTenPercentLabel" xml:space="preserve">
<value>Character talent (10%)</value>
</data>
<data name="ViewPageCultivationWeeklyBossMaterialInterchangeLabel" xml:space="preserve">
<value>Weekly boss material interchange</value>
</data>
<data name="ViewPageCultivationMaterialListResinStatisticsLabel" xml:space="preserve"> <data name="ViewPageCultivationMaterialListResinStatisticsLabel" xml:space="preserve">
<value>Resin Estimation</value> <value>Resin Estimation</value>
</data> </data>
@@ -2429,9 +2444,18 @@ Space Available: {2}</value>
<data name="ViewPageCultivationMaterialStatistics" xml:space="preserve"> <data name="ViewPageCultivationMaterialStatistics" xml:space="preserve">
<value>Material Statistics</value> <value>Material Statistics</value>
</data> </data>
<data name="ViewPageCultivationStatisticsUnfinishedConsumersEmptyList" xml:space="preserve">
<value>No unchecked entries</value>
</data>
<data name="ViewPageCultivationStatisticsConsumerMenuExcluded" xml:space="preserve">
<value>Mora, EXP books, and Mystic Enhancement Ore have no per-entry breakdown.</value>
</data>
<data name="ViewPageCultivationNavigateAction" xml:space="preserve"> <data name="ViewPageCultivationNavigateAction" xml:space="preserve">
<value>Go</value> <value>Go</value>
</data> </data>
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
<value>Sync All Characters and Weapons</value>
</data>
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve"> <data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
<value>Sync Inventory Items</value> <value>Sync Inventory Items</value>
</data> </data>
@@ -2441,6 +2465,9 @@ Space Available: {2}</value>
<data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve"> <data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve">
<value>Sync by Embedded Yae</value> <value>Sync by Embedded Yae</value>
</data> </data>
<data name="ViewPageCultivationRefreshInventoryAllPlansShortLabel" xml:space="preserve">
<value>Inventory sync affects all plans</value>
</data>
<data name="ViewPageCultivationRemoveEntry" xml:space="preserve"> <data name="ViewPageCultivationRemoveEntry" xml:space="preserve">
<value>Delete list</value> <value>Delete list</value>
</data> </data>

View File

@@ -1127,12 +1127,21 @@
<data name="ServiceGameSetMultiChannelUnauthorizedAccess" xml:space="preserve"> <data name="ServiceGameSetMultiChannelUnauthorizedAccess" xml:space="preserve">
<value>无法读取或保存配置文件,请以管理员模式重试</value> <value>无法读取或保存配置文件,请以管理员模式重试</value>
</data> </data>
<data name="ServiceGitRepositoryCloneReasonForceInvalid" xml:space="preserve">
<value>现有元数据仓库更新失败,正在重新下载</value>
</data>
<data name="ServiceGitRepositoryCloneReasonInvalidRepo" xml:space="preserve">
<value>首次下载或仓库损坏,正在下载元数据</value>
</data>
<data name="ServiceGitRepositoryOperationCompleted" xml:space="preserve"> <data name="ServiceGitRepositoryOperationCompleted" xml:space="preserve">
<value>操作完成</value> <value>操作完成</value>
</data> </data>
<data name="ServiceGitRepositoryOperationFailed" xml:space="preserve"> <data name="ServiceGitRepositoryOperationFailed" xml:space="preserve">
<value>操作失败</value> <value>操作失败</value>
</data> </data>
<data name="ServiceGitRepositoryUpdatingExisting" xml:space="preserve">
<value>检查元数据更新</value>
</data>
<data name="ServiceHutaoUserGachaLogExpiredAt" xml:space="preserve"> <data name="ServiceHutaoUserGachaLogExpiredAt" xml:space="preserve">
<value>祈愿记录上传服务有效期至</value> <value>祈愿记录上传服务有效期至</value>
</data> </data>
@@ -1382,6 +1391,9 @@
<data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve"> <data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve">
<value>武器目标等级</value> <value>武器目标等级</value>
</data> </data>
<data name="ViewDialogCultivateBatchClearAvatarAndWeaponEntries" xml:space="preserve">
<value>更新计划前先清空已有角色与武器条目</value>
</data>
<data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve"> <data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve">
<value>在此处输入计划名称</value> <value>在此处输入计划名称</value>
</data> </data>
@@ -1598,6 +1610,9 @@
<data name="ViewDialogThirdPartyToolLaunch" xml:space="preserve"> <data name="ViewDialogThirdPartyToolLaunch" xml:space="preserve">
<value>启动</value> <value>启动</value>
</data> </data>
<data name="ViewDialogThirdPartyToolVersion" xml:space="preserve">
<value>版本:</value>
</data>
<data name="ViewDialogQRCodeTitle" xml:space="preserve"> <data name="ViewDialogQRCodeTitle" xml:space="preserve">
<value>使用米游社扫描二维码</value> <value>使用米游社扫描二维码</value>
</data> </data>
@@ -1919,6 +1934,9 @@
<data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve"> <data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve">
<value>仅突破</value> <value>仅突破</value>
</data> </data>
<data name="ViewModelCultivationEntryRelatedAvatar" xml:space="preserve">
<value>关联角色:{0}</value>
</data>
<data name="ViewModelCultivationProjectAdded" xml:space="preserve"> <data name="ViewModelCultivationProjectAdded" xml:space="preserve">
<value>添加成功</value> <value>添加成功</value>
</data> </data>
@@ -2459,6 +2477,15 @@
<data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve"> <data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve">
<value>未集齐优先</value> <value>未集齐优先</value>
</data> </data>
<data name="ViewPageCultivationMergeUpgradeMaterialsLabel" xml:space="preserve">
<value>升级材料合并</value>
</data>
<data name="ViewPageCultivationTalentSynthCritTenPercentLabel" xml:space="preserve">
<value>角色天赋10%</value>
</data>
<data name="ViewPageCultivationWeeklyBossMaterialInterchangeLabel" xml:space="preserve">
<value>周本材料转化</value>
</data>
<data name="ViewPageCultivationMaterialListResinStatisticsLabel" xml:space="preserve"> <data name="ViewPageCultivationMaterialListResinStatisticsLabel" xml:space="preserve">
<value>树脂预估</value> <value>树脂预估</value>
</data> </data>
@@ -2468,9 +2495,18 @@
<data name="ViewPageCultivationMaterialStatistics" xml:space="preserve"> <data name="ViewPageCultivationMaterialStatistics" xml:space="preserve">
<value>材料统计</value> <value>材料统计</value>
</data> </data>
<data name="ViewPageCultivationStatisticsUnfinishedConsumersEmptyList" xml:space="preserve">
<value>无未勾选条目</value>
</data>
<data name="ViewPageCultivationStatisticsConsumerMenuExcluded" xml:space="preserve">
<value>摩拉、经验书与魔矿不显示未完成条目。</value>
</data>
<data name="ViewPageCultivationNavigateAction" xml:space="preserve"> <data name="ViewPageCultivationNavigateAction" xml:space="preserve">
<value>前往</value> <value>前往</value>
</data> </data>
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
<value>同步所有角色与武器</value>
</data>
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve"> <data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
<value>同步背包物品</value> <value>同步背包物品</value>
</data> </data>
@@ -2480,6 +2516,9 @@
<data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve"> <data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve">
<value>通过 Embedded Yae 同步</value> <value>通过 Embedded Yae 同步</value>
</data> </data>
<data name="ViewPageCultivationRefreshInventoryAllPlansShortLabel" xml:space="preserve">
<value>背包同步影响所有计划</value>
</data>
<data name="ViewPageCultivationRemoveEntry" xml:space="preserve"> <data name="ViewPageCultivationRemoveEntry" xml:space="preserve">
<value>删除清单</value> <value>删除清单</value>
</data> </data>

View File

@@ -2429,6 +2429,9 @@
<data name="ViewPageCultivationNavigateAction" xml:space="preserve"> <data name="ViewPageCultivationNavigateAction" xml:space="preserve">
<value>前往</value> <value>前往</value>
</data> </data>
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
<value>同步所有角色與武器</value>
</data>
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve"> <data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
<value>同步背包物品</value> <value>同步背包物品</value>
</data> </data>

View File

@@ -0,0 +1,248 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Microsoft.UI.Xaml.Controls;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.Calculable;
using Snap.Hutao.Model.Cultivation;
using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.AvatarInfo;
using Snap.Hutao.Service.AvatarInfo.Factory;
using Snap.Hutao.Service.Cultivation.Consumption;
using Snap.Hutao.Service.Cultivation.Offline;
using Snap.Hutao.Service.Inventory;
using Snap.Hutao.Service.Metadata;
using Snap.Hutao.Service.Metadata.ContextAbstraction;
using Snap.Hutao.Service.User;
using Snap.Hutao.UI.Xaml.View.Dialog;
using Snap.Hutao.ViewModel.AvatarProperty;
using Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate;
using System.Collections.Immutable;
using CalculatorAvatarPromotionDelta = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.AvatarPromotionDelta;
using CalculatorBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption;
using CalculatorConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.Consumption;
using CalculatorItemHelper = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.ItemHelper;
namespace Snap.Hutao.Service.Cultivation;
[Service(ServiceLifetime.Singleton, typeof(IAvatarPropertyBatchCultivateService))]
internal sealed partial class AvatarPropertyBatchCultivateService : IAvatarPropertyBatchCultivateService
{
private readonly IContentDialogFactory contentDialogFactory;
private readonly ICultivationService cultivationService;
private readonly IServiceProvider serviceProvider;
[GeneratedConstructor]
public partial AvatarPropertyBatchCultivateService(IServiceProvider serviceProvider);
public async ValueTask<BatchCultivateResult?> ExecuteAsync(SummaryFactoryMetadataContext metadataContext, ImmutableArray<AvatarView> targetAvatars, CancellationToken cancellationToken)
{
CultivateProjectAvatarPropertyBatchPreferences? batchPrefs = await cultivationService
.GetAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync()
.ConfigureAwait(false);
CultivatePromotionDeltaBatchDialog dialog = batchPrefs is null
? await contentDialogFactory
.CreateInstanceAsync<CultivatePromotionDeltaBatchDialog>(serviceProvider)
.ConfigureAwait(false)
: await contentDialogFactory
.CreateInstanceAsync<CultivatePromotionDeltaBatchDialog>(serviceProvider, batchPrefs)
.ConfigureAwait(false);
if (await dialog.GetPromotionDeltaBaselineAsync().ConfigureAwait(false) is not (true, { } baseline))
{
return null;
}
await cultivationService
.SaveAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync(ToAvatarPropertyBatchPreferences(baseline))
.ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(baseline.Delta.Weapon);
ContentDialog progressDialog = await contentDialogFactory
.CreateForIndeterminateProgressAsync(SH.ViewModelAvatarPropertyBatchCultivateProgressTitle)
.ConfigureAwait(false);
BatchCultivateResult result = default;
using (await contentDialogFactory.BlockAsync(progressDialog).ConfigureAwait(false))
{
ImmutableArray<AvatarView> avatarsToProcess = targetAvatars;
if (baseline.SyncCharacterInfo)
{
IUserService userService = serviceProvider.GetRequiredService<IUserService>();
if (await userService.GetCurrentUserAndUidAsync().ConfigureAwait(false) is { } userAndUid)
{
IServiceScopeFactory scopeFactory = serviceProvider.GetRequiredService<IServiceScopeFactory>();
using (IServiceScope scope = scopeFactory.CreateScope())
{
IAvatarInfoService avatarInfoService = scope.ServiceProvider.GetRequiredService<IAvatarInfoService>();
Summary? refreshed = await avatarInfoService
.GetSummaryAsync(metadataContext, userAndUid, global::Snap.Hutao.Service.AvatarInfo.RefreshOptionKind.RequestFromHoyolabGameRecord, cancellationToken)
.ConfigureAwait(false);
if (refreshed?.Avatars.Source is { Count: > 0 } sourceAvatars)
{
HashSet<AvatarId> wanted = [.. targetAvatars.Select(static a => a.Id)];
ImmutableArray<AvatarView>.Builder filtered = ImmutableArray.CreateBuilder<AvatarView>();
foreach (AvatarView avatar in sourceAvatars)
{
if (wanted.Contains(avatar.Id))
{
filtered.Add(avatar);
}
}
if (filtered.Count > 0)
{
avatarsToProcess = filtered.ToImmutable();
}
}
}
}
}
if (baseline.SyncInventoryItems)
{
Snap.Hutao.Core.Database.IAdvancedDbCollectionView<CultivateProject> projects = await cultivationService.GetProjectCollectionAsync().ConfigureAwait(false);
await cultivationService.EnsureCurrentProjectAsync(projects).ConfigureAwait(false);
if (projects.CurrentItem is { } project)
{
IMetadataService metadataService = serviceProvider.GetRequiredService<IMetadataService>();
ICultivationMetadataContext cultivationContext = await metadataService
.GetContextAsync<CultivationMetadataContext>(cancellationToken)
.ConfigureAwait(false);
IInventoryService inventoryService = serviceProvider.GetRequiredService<IInventoryService>();
await inventoryService
.RefreshInventoryAsync(RefreshOptions.CreateForWebCalculator(
project,
cultivationContext,
LocalSetting.Get(SettingKeys.CultivationRefreshInventoryByCalculatorToAllProjects, false)))
.ConfigureAwait(false);
}
}
if (baseline.ClearAvatarAndWeaponEntriesBeforeSync)
{
await cultivationService.RemoveAvatarAndWeaponEntriesForCurrentProjectAsync().ConfigureAwait(false);
}
ImmutableArray<CalculatorAvatarPromotionDelta>.Builder deltasBuilder = ImmutableArray.CreateBuilder<CalculatorAvatarPromotionDelta>();
foreach (AvatarView avatar in avatarsToProcess)
{
if (!baseline.Delta.TryGetNonErrorCopy(avatar, out CalculatorAvatarPromotionDelta? copy))
{
++result.SkippedCount;
continue;
}
deltasBuilder.Add(copy);
}
ImmutableArray<CalculatorAvatarPromotionDelta> deltas = deltasBuilder.ToImmutable();
CalculatorBatchConsumption batchConsumption = OfflineCalculator.CalculateBatchConsumption(deltas, metadataContext);
foreach ((CalculatorConsumption consumption, CalculatorAvatarPromotionDelta delta) in batchConsumption.Items.Zip(deltas))
{
cancellationToken.ThrowIfCancellationRequested();
if (!await SaveCultivationAsync(consumption, new CultivatePromotionDeltaOptions(delta, baseline.Strategy)).ConfigureAwait(false))
{
result.StopReason = BatchCultivateStopReason.NoProject;
break;
}
++result.SucceedCount;
}
}
return result;
}
private static CultivateProjectAvatarPropertyBatchPreferences ToAvatarPropertyBatchPreferences(CultivatePromotionDeltaOptions baseline)
{
CalculatorAvatarPromotionDelta d = baseline.Delta;
uint sa = 10;
uint se = 10;
uint sq = 10;
if (d.SkillList is [{ } a, { } e, { } q, ..])
{
sa = a.LevelTarget;
se = e.LevelTarget;
sq = q.LevelTarget;
}
return new CultivateProjectAvatarPropertyBatchPreferences
{
AvatarLevelTarget = d.AvatarLevelTarget,
SkillATarget = sa,
SkillETarget = se,
SkillQTarget = sq,
WeaponLevelTarget = d.Weapon?.LevelTarget ?? 90U,
ConsumptionSaveStrategyIndex = (int)baseline.Strategy,
ClearAvatarAndWeaponEntriesBeforeSync = baseline.ClearAvatarAndWeaponEntriesBeforeSync,
SyncInventoryItems = baseline.SyncInventoryItems,
SyncCharacterInfo = baseline.SyncCharacterInfo,
};
}
private async ValueTask<bool> SaveCultivationAsync(CalculatorConsumption consumption, CultivatePromotionDeltaOptions options)
{
LevelInformation levelInformation = LevelInformation.From(options.Delta);
InputConsumption avatarInput = new()
{
Type = CultivateType.AvatarAndSkill,
ItemId = options.Delta.AvatarId,
Items = CalculatorItemHelper.Merge(consumption.AvatarConsume, consumption.AvatarSkillConsume),
LevelInformation = levelInformation,
Strategy = options.Strategy,
};
ConsumptionSaveResult avatarSave = await cultivationService.SaveConsumptionAsync(avatarInput).ConfigureAwait(false);
if (avatarSave.Kind is ConsumptionSaveResultKind.NoProject)
{
return false;
}
ArgumentNullException.ThrowIfNull(options.Delta.Weapon);
Guid? relatedAvatarEntryId = avatarSave.CreatedEntryInnerId;
if (relatedAvatarEntryId is null && avatarSave.Kind is ConsumptionSaveResultKind.Skipped)
{
relatedAvatarEntryId = await cultivationService.TryGetAvatarCultivateEntryInnerIdAsync(options.Delta.AvatarId).ConfigureAwait(false);
}
if (relatedAvatarEntryId is null && avatarSave.Kind is ConsumptionSaveResultKind.NoItem)
{
relatedAvatarEntryId = await cultivationService.TryGetAvatarCultivateEntryInnerIdAsync(options.Delta.AvatarId).ConfigureAwait(false);
}
if (relatedAvatarEntryId is null && !consumption.WeaponConsume.IsEmpty)
{
relatedAvatarEntryId = await cultivationService.EnsureAvatarAssociationStubAsync(options.Delta.AvatarId, levelInformation).ConfigureAwait(false);
}
InputConsumption weaponInput = new()
{
Type = CultivateType.Weapon,
ItemId = options.Delta.Weapon.Id,
Items = consumption.WeaponConsume,
LevelInformation = levelInformation,
Strategy = options.Strategy,
RelatedEntryId = relatedAvatarEntryId,
};
ConsumptionSaveResult weaponSave = await cultivationService.SaveConsumptionAsync(weaponInput).ConfigureAwait(false);
return weaponSave.Kind is not ConsumptionSaveResultKind.NoProject;
}
}

View File

@@ -1,10 +1,17 @@
// Copyright (c) DGP Studio. All rights reserved. // Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license. // Licensed under the MIT license.
namespace Snap.Hutao.ViewModel.AvatarProperty; namespace Snap.Hutao.Service.Cultivation;
internal struct BatchCultivateResult internal struct BatchCultivateResult
{ {
public int SucceedCount; public int SucceedCount;
public int SkippedCount; public int SkippedCount;
public BatchCultivateStopReason StopReason;
}
internal enum BatchCultivateStopReason
{
None = 0,
NoProject = 1,
} }

View File

@@ -0,0 +1,8 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Cultivation.Consumption;
internal readonly record struct ConsumptionSaveResult(
ConsumptionSaveResultKind Kind,
Guid? CreatedEntryInnerId = null);

View File

@@ -23,6 +23,9 @@ internal class CultivationMetadataContext : ICultivationMetadataContext
public ImmutableDictionary<MaterialId, Combine> ResultMaterialIdCombineMap { get; set; } = default!; public ImmutableDictionary<MaterialId, Combine> ResultMaterialIdCombineMap { get; set; } = default!;
public ImmutableArray<ImmutableArray<MaterialId>> WeeklyBossMaterialInterchangeGroups { get; set; }
= ImmutableArray<ImmutableArray<MaterialId>>.Empty;
public Item GetAvatarItem(AvatarId avatarId) public Item GetAvatarItem(AvatarId avatarId)
{ {
return this.GetAvatar(avatarId).GetOrCreateItem(); return this.GetAvatar(avatarId).GetOrCreateItem();

View File

@@ -2,10 +2,15 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Snap.Hutao.Core.Database;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Database;
using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Service.Abstraction; using Snap.Hutao.Service.Abstraction;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
namespace Snap.Hutao.Service.Cultivation; namespace Snap.Hutao.Service.Cultivation;
@@ -47,6 +52,29 @@ internal sealed partial class CultivationRepository : ICultivationRepository
return this.ImmutableArray<CultivateEntry>(e => e.ProjectId == projectId && e.Id == itemId); return this.ImmutableArray<CultivateEntry>(e => e.ProjectId == projectId && e.Id == itemId);
} }
public Guid? TryGetAvatarCultivateEntryInnerId(Guid projectId, uint avatarId)
{
// NOTE: InnerId(Guid) 的大小不代表插入顺序;这里使用 SQLite 的 rowid 选择最新插入的一条。
using (IServiceScope scope = ServiceProvider.CreateScope())
{
AppDbContext db = scope.GetAppDbContext();
// EF Core 不会为 SQLite 的隐式 rowid 自动建模;这里用原生 SQL 取最新插入的一条。
return db.Set<CultivateEntry>()
.FromSqlInterpolated($"""
SELECT *
FROM cultivate_entries
WHERE ProjectId = {projectId}
AND Id = {avatarId}
AND Type = {(int)CultivateType.AvatarAndSkill}
ORDER BY rowid DESC
LIMIT 1
""")
.AsNoTracking()
.Select(e => (Guid?)e.InnerId)
.FirstOrDefault();
}
}
public void AddCultivateEntry(CultivateEntry entry) public void AddCultivateEntry(CultivateEntry entry)
{ {
this.Add(entry); this.Add(entry);
@@ -77,6 +105,11 @@ internal sealed partial class CultivationRepository : ICultivationRepository
return this.ObservableCollection<CultivateProject>(); return this.ObservableCollection<CultivateProject>();
} }
public ImmutableArray<Guid> GetCultivateProjectInnerIds()
{
return this.ImmutableArray<CultivateProject, Guid>(query => query.Select(p => p.InnerId));
}
public void RemoveLevelInformationByEntryId(Guid entryId) public void RemoveLevelInformationByEntryId(Guid entryId)
{ {
this.Delete<CultivateEntryLevelInformation>(l => l.EntryId == entryId); this.Delete<CultivateEntryLevelInformation>(l => l.EntryId == entryId);
@@ -96,4 +129,23 @@ internal sealed partial class CultivationRepository : ICultivationRepository
{ {
return this.Single<CultivateEntry, Guid>(query => query.Where(entry => entry.InnerId == entryId).Select(entry => entry.InnerId)); return this.Single<CultivateEntry, Guid>(query => query.Where(entry => entry.InnerId == entryId).Select(entry => entry.InnerId));
} }
public ImmutableArray<(CultivateEntry Entry, CultivateItem Item)> GetCultivateEntryItemPairsByProjectId(Guid projectId)
{
using (IServiceScope scope = ServiceProvider.CreateScope())
{
AppDbContext db = scope.GetAppDbContext();
IQueryable<CultivateEntry> entries = db.Set<CultivateEntry>().AsNoTracking().Where(e => e.ProjectId == projectId);
return [.. db.Set<CultivateItem>().AsNoTracking()
.Join(
entries,
item => item.EntryId,
entry => entry.InnerId,
(item, entry) => new { Entry = entry, Item = item })
.OrderBy(t => t.Entry.InnerId)
.ThenBy(t => t.Item.ItemId)
.ToList()
.Select(t => (t.Entry, t.Item))];
}
}
} }

View File

@@ -124,6 +124,6 @@ internal sealed partial class CultivationResinStatisticsService : ICultivationRe
private static double GetStatisticsCultivateItemTimes(StatisticsCultivateItem item) private static double GetStatisticsCultivateItemTimes(StatisticsCultivateItem item)
{ {
return item.Count - (long)item.Current; return item.Count - (long)item.DisplayCurrent;
} }
} }

View File

@@ -2,20 +2,28 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core.Database; using Snap.Hutao.Core.Database;
using Snap.Hutao.Core.Text.Json;
using Snap.Hutao.Model;
using Snap.Hutao.Model.Cultivation;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.Entity.Primitive; using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Model.Intrinsic; using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Model.Metadata; using Snap.Hutao.Model.Metadata;
using Snap.Hutao.Model.Metadata.Item;
using Snap.Hutao.Model.Primitive; using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Abstraction;
using Snap.Hutao.Service.Cultivation.Consumption; using Snap.Hutao.Service.Cultivation.Consumption;
using Snap.Hutao.Service.Inventory; using Snap.Hutao.Service.Inventory;
using Snap.Hutao.Service.Metadata.ContextAbstraction; using Snap.Hutao.Service.Metadata.ContextAbstraction;
using Snap.Hutao.ViewModel.Cultivation; using Snap.Hutao.ViewModel.Cultivation;
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.Linq;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text.Json;
using ModelItem = Snap.Hutao.Model.Item; using ModelItem = Snap.Hutao.Model.Item;
namespace Snap.Hutao.Service.Cultivation; namespace Snap.Hutao.Service.Cultivation;
@@ -63,10 +71,21 @@ internal sealed partial class CultivationService : ICultivationService
{ {
ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayIncludingLevelInformationByProjectId(cultivateProject.InnerId); ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayIncludingLevelInformationByProjectId(cultivateProject.InnerId);
Dictionary<Guid, CultivateEntry> entryByInnerId = new(entries.Length);
foreach (ref readonly CultivateEntry entry in entries.AsSpan())
{
entryByInnerId[entry.InnerId] = entry;
}
List<CultivateEntryView> resultEntries = new(entries.Length); List<CultivateEntryView> resultEntries = new(entries.Length);
foreach (ref readonly CultivateEntry entry in entries.AsSpan()) foreach (ref readonly CultivateEntry entry in entries.AsSpan())
{ {
ImmutableArray<CultivateItem> items = cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId); ImmutableArray<CultivateItem> items = cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId);
if (IsHiddenAssociationOnlyAvatarEntry(entry, items.Length))
{
continue;
}
ImmutableArray<CultivateItemView>.Builder entryItems = ImmutableArray.CreateBuilder<CultivateItemView>(items.Length); ImmutableArray<CultivateItemView>.Builder entryItems = ImmutableArray.CreateBuilder<CultivateItemView>(items.Length);
foreach (ref readonly CultivateItem cultivateItem in items.AsSpan()) foreach (ref readonly CultivateItem cultivateItem in items.AsSpan())
@@ -83,7 +102,13 @@ internal sealed partial class CultivationService : ICultivationService
_ => default!, _ => default!,
}; };
resultEntries.Add(CultivateEntryView.Create(entry, item, entryItems.ToImmutable())); string? relatedAvatarName = null;
if (entry.Type is CultivateType.Weapon && entry.RelatedEntryId is Guid relatedId && entryByInnerId.TryGetValue(relatedId, out CultivateEntry? relatedEntry) && relatedEntry.Type is CultivateType.AvatarAndSkill)
{
relatedAvatarName = context.GetAvatarItem(relatedEntry.Id).Name;
}
resultEntries.Add(CultivateEntryView.Create(entry, item, entryItems.ToImmutable(), relatedAvatarName));
} }
ObservableCollection<CultivateEntryView> result = resultEntries.SortByDescending(e => e.IsToday).ToObservableCollection(); ObservableCollection<CultivateEntryView> result = resultEntries.SortByDescending(e => e.IsToday).ToObservableCollection();
@@ -92,20 +117,26 @@ internal sealed partial class CultivationService : ICultivationService
} }
} }
public async ValueTask<StatisticsCultivateItemCollection> GetStatisticsCultivateItemCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context, CancellationToken token) public async ValueTask<StatisticsCultivateItemCollection> GetStatisticsCultivateItemCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context, CultivationStatisticsMergeOptions mergeOptions, CancellationToken token)
{ {
token.ThrowIfCancellationRequested();
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();
return SynchronizedGetStatisticsCultivateItemCollection(cultivateProject, context); token.ThrowIfCancellationRequested();
return SynchronizedGetStatisticsCultivateItemCollection(cultivateProject, context, mergeOptions, token);
StatisticsCultivateItemCollection SynchronizedGetStatisticsCultivateItemCollection(CultivateProject cultivateProject, ICultivationMetadataContext context) StatisticsCultivateItemCollection SynchronizedGetStatisticsCultivateItemCollection(CultivateProject cultivateProject, ICultivationMetadataContext context, CultivationStatisticsMergeOptions mergeOptions, CancellationToken token)
{ {
token.ThrowIfCancellationRequested();
Dictionary</* ItemId */ uint, StatisticsCultivateItem> resultItems = []; Dictionary</* ItemId */ uint, StatisticsCultivateItem> resultItems = [];
Guid projectId = cultivateProject.InnerId; Guid projectId = cultivateProject.InnerId;
Dictionary<uint, uint> inventoryCounts = [];
foreach (ref readonly CultivateEntry entry in cultivationRepository.GetCultivateEntryImmutableArrayByProjectId(projectId).AsSpan()) foreach (ref readonly CultivateEntry entry in cultivationRepository.GetCultivateEntryImmutableArrayByProjectId(projectId).AsSpan())
{ {
token.ThrowIfCancellationRequested();
foreach (ref readonly CultivateItem item in cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId).AsSpan()) foreach (ref readonly CultivateItem item in cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId).AsSpan())
{ {
token.ThrowIfCancellationRequested();
ref StatisticsCultivateItem? existedItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, item.ItemId, out _); ref StatisticsCultivateItem? existedItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, item.ItemId, out _);
if (existedItem is null || existedItem.ExcludedFromPresentation) if (existedItem is null || existedItem.ExcludedFromPresentation)
{ {
@@ -116,12 +147,14 @@ internal sealed partial class CultivationService : ICultivationService
existedItem.Count += item.Count; existedItem.Count += item.Count;
} }
RecursiveAddMaterialIngredientsByMaterialId(cultivateProject, context, resultItems, item.ItemId); RecursiveAddMaterialIngredientsByMaterialId(cultivateProject, context, resultItems, item.ItemId, token);
} }
} }
foreach (ref readonly InventoryItem inventoryItem in inventoryRepository.GetInventoryItemImmutableArrayByProjectId(projectId).AsSpan()) foreach (ref readonly InventoryItem inventoryItem in inventoryRepository.GetInventoryItemImmutableArrayByProjectId(projectId).AsSpan())
{ {
token.ThrowIfCancellationRequested();
inventoryCounts[inventoryItem.ItemId] = inventoryItem.Count;
ref StatisticsCultivateItem existedItem = ref CollectionsMarshal.GetValueRefOrNullRef(resultItems, inventoryItem.ItemId); ref StatisticsCultivateItem existedItem = ref CollectionsMarshal.GetValueRefOrNullRef(resultItems, inventoryItem.ItemId);
if (!Unsafe.IsNullRef(in existedItem)) if (!Unsafe.IsNullRef(in existedItem))
{ {
@@ -129,10 +162,73 @@ internal sealed partial class CultivationService : ICultivationService
} }
} }
AddWeeklyBossGroupInventoryDonors(
resultItems,
inventoryCounts,
context,
cultivateProject.ServerTimeZoneOffset,
mergeOptions.WeeklyBossMaterialInterchange);
CultivationStatisticsSurplusMerge.Apply(resultItems, context, mergeOptions);
CultivationStatisticsWeeklyBossInterchange.Apply(
resultItems,
context.WeeklyBossMaterialInterchangeGroups,
mergeOptions.WeeklyBossMaterialInterchange);
ApplyStatisticsConsumerMenuLines(resultItems, projectId, context, cultivationRepository, token);
return new(resultItems); return new(resultItems);
} }
} }
private static void AddWeeklyBossGroupInventoryDonors(
Dictionary<uint, StatisticsCultivateItem> items,
Dictionary<uint, uint> inventoryCounts,
ICultivationMetadataContext context,
TimeSpan offset,
bool enabled)
{
if (!enabled || context.WeeklyBossMaterialInterchangeGroups.IsDefaultOrEmpty)
{
return;
}
foreach (ImmutableArray<MaterialId> group in context.WeeklyBossMaterialInterchangeGroups)
{
bool anyPlannedInGroup = false;
foreach (MaterialId mid in group)
{
if (items.ContainsKey(mid))
{
anyPlannedInGroup = true;
break;
}
}
if (!anyPlannedInGroup)
{
continue;
}
foreach (MaterialId mid in group)
{
uint id = mid;
if (items.ContainsKey(id))
{
continue;
}
if (!inventoryCounts.TryGetValue(id, out uint inv) || inv is 0U)
{
continue;
}
StatisticsCultivateItem donor = StatisticsCultivateItem.Create(context.GetMaterial(id), offset);
donor.Current = inv;
items[id] = donor;
}
}
}
public ValueTask<ResinStatistics> GetResinStatisticsAsync(StatisticsCultivateItemCollection statisticsCultivateItems, CancellationToken token) public ValueTask<ResinStatistics> GetResinStatisticsAsync(StatisticsCultivateItemCollection statisticsCultivateItems, CancellationToken token)
{ {
return cultivationResinStatisticsService.GetResinStatisticsAsync(statisticsCultivateItems, token); return cultivationResinStatisticsService.GetResinStatisticsAsync(statisticsCultivateItems, token);
@@ -144,41 +240,84 @@ internal sealed partial class CultivationService : ICultivationService
cultivationRepository.RemoveCultivateEntryById(entryId); cultivationRepository.RemoveCultivateEntryById(entryId);
} }
public async ValueTask RemoveAvatarAndWeaponEntriesForCurrentProjectAsync()
{
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return;
}
ArgumentNullException.ThrowIfNull(projects.CurrentItem);
Guid projectId = projects.CurrentItem.InnerId;
await taskContext.SwitchToBackgroundAsync();
ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectId(projectId);
List<Guid> weaponEntryIds = new(entries.Length);
List<Guid> avatarEntryIds = new(entries.Length);
foreach (ref readonly CultivateEntry entry in entries.AsSpan())
{
switch (entry.Type)
{
case CultivateType.Weapon:
weaponEntryIds.Add(entry.InnerId);
break;
case CultivateType.AvatarAndSkill:
avatarEntryIds.Add(entry.InnerId);
break;
}
}
foreach (Guid id in weaponEntryIds)
{
cultivationRepository.RemoveCultivateEntryById(id);
}
foreach (Guid id in avatarEntryIds)
{
cultivationRepository.RemoveCultivateEntryById(id);
}
entryCollectionCache.TryRemove(projectId, out _);
}
public void SaveCultivateItem(CultivateItemView item) public void SaveCultivateItem(CultivateItemView item)
{ {
cultivationRepository.UpdateCultivateItem(item.Entity); cultivationRepository.UpdateCultivateItem(item.Entity);
} }
public async ValueTask<ConsumptionSaveResultKind> SaveConsumptionAsync(InputConsumption inputConsumption) public async ValueTask<ConsumptionSaveResult> SaveConsumptionAsync(InputConsumption inputConsumption)
{ {
// No selected project // No selected project
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false); IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false)) if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{ {
return ConsumptionSaveResultKind.NoProject; return new(ConsumptionSaveResultKind.NoProject);
} }
ArgumentNullException.ThrowIfNull(projects.CurrentItem); ArgumentNullException.ThrowIfNull(projects.CurrentItem);
Guid projectId = projects.CurrentItem.InnerId;
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();
// PreserveExisting or CreateNewEntry, but no item // PreserveExisting or CreateNewEntry, but no item
if (inputConsumption is { Strategy: not ConsumptionSaveStrategyKind.OverwriteExisting, Items: [] }) if (inputConsumption is { Strategy: not ConsumptionSaveStrategyKind.OverwriteExisting, Items: [] })
{ {
return ConsumptionSaveResultKind.NoItem; return new(ConsumptionSaveResultKind.NoItem);
} }
// PreserveExisting or OverwriteExisting // PreserveExisting or OverwriteExisting
if (inputConsumption.Strategy is not ConsumptionSaveStrategyKind.CreateNewEntry) if (inputConsumption.Strategy is not ConsumptionSaveStrategyKind.CreateNewEntry)
{ {
// Check for existing entries // Check for existing entries
ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectIdAndItemId(projects.CurrentItem.InnerId, inputConsumption.ItemId); ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectIdAndItemId(projectId, inputConsumption.ItemId);
if (entries.Length > 0) if (entries.Length > 0)
{ {
if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.PreserveExisting) if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.PreserveExisting)
{ {
return ConsumptionSaveResultKind.Skipped; return new(ConsumptionSaveResultKind.Skipped);
} }
if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.OverwriteExisting) if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.OverwriteExisting)
@@ -192,7 +331,8 @@ internal sealed partial class CultivationService : ICultivationService
if (inputConsumption.Items is []) if (inputConsumption.Items is [])
{ {
return ConsumptionSaveResultKind.Removed; entryCollectionCache.TryRemove(projectId, out _);
return new(ConsumptionSaveResultKind.Removed);
} }
} }
} }
@@ -200,13 +340,14 @@ internal sealed partial class CultivationService : ICultivationService
{ {
if (inputConsumption.Items is []) if (inputConsumption.Items is [])
{ {
return ConsumptionSaveResultKind.NoItem; return new(ConsumptionSaveResultKind.NoItem);
} }
} }
} }
{ {
CultivateEntry entry = CultivateEntry.From(projects.CurrentItem.InnerId, inputConsumption.Type, inputConsumption.ItemId); CultivateEntry entry = CultivateEntry.From(projectId, inputConsumption.Type, inputConsumption.ItemId);
entry.RelatedEntryId = inputConsumption.RelatedEntryId;
cultivationRepository.AddCultivateEntry(entry); cultivationRepository.AddCultivateEntry(entry);
CultivateEntryLevelInformation entryLevelInformation = CultivateEntryLevelInformation.From(entry.InnerId, inputConsumption.Type, inputConsumption.LevelInformation); CultivateEntryLevelInformation entryLevelInformation = CultivateEntryLevelInformation.From(entry.InnerId, inputConsumption.Type, inputConsumption.LevelInformation);
@@ -217,10 +358,52 @@ internal sealed partial class CultivationService : ICultivationService
// The consumption save operation is always performed outside cultivation page // The consumption save operation is always performed outside cultivation page
// and without touching the cache. So we have to invalidate the cache manually. // and without touching the cache. So we have to invalidate the cache manually.
entryCollectionCache.TryRemove(projects.CurrentItem.InnerId, out _); entryCollectionCache.TryRemove(projectId, out _);
return new(ConsumptionSaveResultKind.Added, entry.InnerId);
}
}
public async ValueTask<Guid?> TryGetAvatarCultivateEntryInnerIdAsync(uint avatarId)
{
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return null;
} }
return ConsumptionSaveResultKind.Added; ArgumentNullException.ThrowIfNull(projects.CurrentItem);
await taskContext.SwitchToBackgroundAsync();
return cultivationRepository.TryGetAvatarCultivateEntryInnerId(projects.CurrentItem.InnerId, avatarId);
}
public async ValueTask<Guid?> EnsureAvatarAssociationStubAsync(uint avatarId, LevelInformation levelInformation)
{
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return null;
}
ArgumentNullException.ThrowIfNull(projects.CurrentItem);
await taskContext.SwitchToBackgroundAsync();
Guid projectId = projects.CurrentItem.InnerId;
if (cultivationRepository.TryGetAvatarCultivateEntryInnerId(projectId, avatarId) is Guid existing)
{
return existing;
}
CultivateEntry entry = CultivateEntry.From(projectId, CultivateType.AvatarAndSkill, avatarId);
cultivationRepository.AddCultivateEntry(entry);
CultivateEntryLevelInformation level = CultivateEntryLevelInformation.From(entry.InnerId, CultivateType.AvatarAndSkill, levelInformation);
cultivationRepository.AddLevelInformation(level);
entryCollectionCache.TryRemove(projectId, out _);
return entry.InnerId;
} }
public async ValueTask<ProjectAddResultKind> TryAddProjectAsync(CultivateProject project) public async ValueTask<ProjectAddResultKind> TryAddProjectAsync(CultivateProject project)
@@ -244,6 +427,50 @@ internal sealed partial class CultivationService : ICultivationService
return ProjectAddResultKind.Added; return ProjectAddResultKind.Added;
} }
public async ValueTask<CultivateProjectAvatarPropertyBatchPreferences?> GetAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync()
{
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return null;
}
string? json = projects.CurrentItem?.AvatarPropertyBatchCultivatePreferencesJson;
if (string.IsNullOrWhiteSpace(json))
{
return null;
}
try
{
return JsonSerializer.Deserialize<CultivateProjectAvatarPropertyBatchPreferences>(json, JsonOptions.Default);
}
catch (JsonException)
{
return null;
}
}
public async ValueTask SaveAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync(CultivateProjectAvatarPropertyBatchPreferences preferences)
{
ArgumentNullException.ThrowIfNull(preferences);
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return;
}
ArgumentNullException.ThrowIfNull(projects.CurrentItem);
CultivateProject project = projects.CurrentItem;
await taskContext.SwitchToBackgroundAsync();
project.AvatarPropertyBatchCultivatePreferencesJson = JsonSerializer.Serialize(preferences, JsonOptions.Default);
cultivationRepository.Update(project);
}
public async ValueTask RemoveProjectAsync(CultivateProject project) public async ValueTask RemoveProjectAsync(CultivateProject project)
{ {
ArgumentNullException.ThrowIfNull(projects); ArgumentNullException.ThrowIfNull(projects);
@@ -280,8 +507,10 @@ internal sealed partial class CultivationService : ICultivationService
return true; return true;
} }
private static void RecursiveAddMaterialIngredientsByMaterialId(CultivateProject cultivateProject, ICultivationMetadataContext context, Dictionary<uint, StatisticsCultivateItem> resultItems, MaterialId materialId) private static void RecursiveAddMaterialIngredientsByMaterialId(CultivateProject cultivateProject, ICultivationMetadataContext context, Dictionary<uint, StatisticsCultivateItem> resultItems, MaterialId materialId, CancellationToken token)
{ {
token.ThrowIfCancellationRequested();
if (materialId == 104003U) if (materialId == 104003U)
{ {
foreach (ref readonly MaterialId xpBookId in (ReadOnlySpan<MaterialId>)[104001U, 104002U]) foreach (ref readonly MaterialId xpBookId in (ReadOnlySpan<MaterialId>)[104001U, 104002U])
@@ -297,10 +526,165 @@ internal sealed partial class CultivationService : ICultivationService
{ {
foreach (ref readonly IdCount ingredient in combine.Materials.AsSpan()) foreach (ref readonly IdCount ingredient in combine.Materials.AsSpan())
{ {
token.ThrowIfCancellationRequested();
ref StatisticsCultivateItem? ingredientItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, ingredient.Id, out _); ref StatisticsCultivateItem? ingredientItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, ingredient.Id, out _);
ingredientItem ??= StatisticsCultivateItem.Create(context.GetMaterial(ingredient.Id), cultivateProject.ServerTimeZoneOffset); ingredientItem ??= StatisticsCultivateItem.Create(context.GetMaterial(ingredient.Id), cultivateProject.ServerTimeZoneOffset);
RecursiveAddMaterialIngredientsByMaterialId(cultivateProject, context, resultItems, ingredient.Id); RecursiveAddMaterialIngredientsByMaterialId(cultivateProject, context, resultItems, ingredient.Id, token);
} }
} }
} }
private static void ApplyStatisticsConsumerMenuLines(
Dictionary<uint, StatisticsCultivateItem> resultItems,
Guid projectId,
ICultivationMetadataContext context,
ICultivationRepository cultivationRepository,
CancellationToken token)
{
token.ThrowIfCancellationRequested();
ImmutableArray<(CultivateEntry Entry, CultivateItem Item)> pairs = cultivationRepository.GetCultivateEntryItemPairsByProjectId(projectId);
ImmutableArray<CultivateEntry> projectEntries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectId(projectId);
Dictionary<Guid, CultivateEntry> entryByInnerId = new(projectEntries.Length);
foreach (ref readonly CultivateEntry e in projectEntries.AsSpan())
{
entryByInnerId[e.InnerId] = e;
}
Dictionary<uint, List<(string SortKey, StatisticsConsumerMenuLine Line)>> unfinishedRowsByMaterial = [];
foreach ((CultivateEntry entry, CultivateItem item) in pairs.AsSpan())
{
token.ThrowIfCancellationRequested();
if (item.IsFinished)
{
continue;
}
string sortKey = $"{FormatStatisticsConsumerEntryName(entry, context, entryByInnerId)}×{item.Count}";
StatisticsConsumerMenuLine line = CreateStatisticsConsumerMenuLine(entry, item, context, entryByInnerId);
ref List<(string SortKey, StatisticsConsumerMenuLine Line)>? list = ref CollectionsMarshal.GetValueRefOrAddDefault(unfinishedRowsByMaterial, item.ItemId, out _);
list ??= [];
list.Add((sortKey, line));
}
foreach ((uint materialId, StatisticsCultivateItem stat) in resultItems)
{
token.ThrowIfCancellationRequested();
if (MaterialIds.IsExcludedFromStatisticsConsumerMenu(materialId))
{
stat.StatisticsConsumerMenuLines = ImmutableArray.Create(StatisticsConsumerMenuLine.Plain(SH.ViewPageCultivationStatisticsConsumerMenuExcluded));
continue;
}
if (unfinishedRowsByMaterial.TryGetValue(materialId, out List<(string SortKey, StatisticsConsumerMenuLine Line)>? rows))
{
rows.Sort(static (a, b) => StringComparer.Ordinal.Compare(a.SortKey, b.SortKey));
stat.StatisticsConsumerMenuLines = ImmutableArray.CreateRange(rows.ConvertAll(static r => r.Line));
}
else
{
stat.StatisticsConsumerMenuLines = ImmutableArray.Create(StatisticsConsumerMenuLine.Plain(SH.ViewPageCultivationStatisticsUnfinishedConsumersEmptyList));
}
}
}
private static StatisticsConsumerMenuLine CreateStatisticsConsumerMenuLine(
CultivateEntry entry,
CultivateItem item,
ICultivationMetadataContext context,
Dictionary<Guid, CultivateEntry> entryByInnerId)
{
// 展示用量:全角括号比「×数量」更利落,且与中文混排更协调。
string countSuffix = $"\uFF08{item.Count}\uFF09";
switch (entry.Type)
{
case CultivateType.AvatarAndSkill:
{
ModelItem avatarItem = context.GetAvatarItem(entry.Id);
return StatisticsConsumerMenuLine.SingleIcon(avatarItem.Icon, avatarItem.Quality, avatarItem.Name, countSuffix);
}
case CultivateType.Weapon:
{
ModelItem weaponItem = context.GetWeaponItem(entry.Id);
if (entry.RelatedEntryId is Guid relatedId
&& entryByInnerId.TryGetValue(relatedId, out CultivateEntry? related)
&& related.Type is CultivateType.AvatarAndSkill)
{
ModelItem avatarItem = context.GetAvatarItem(related.Id);
return StatisticsConsumerMenuLine.AvatarAndWeapon(
avatarItem.Icon,
avatarItem.Quality,
avatarItem.Name,
weaponItem.Icon,
weaponItem.Quality,
weaponItem.Name,
countSuffix);
}
return StatisticsConsumerMenuLine.SingleIcon(weaponItem.Icon, weaponItem.Quality, weaponItem.Name, countSuffix);
}
default:
return StatisticsConsumerMenuLine.Plain($"{Material.Default.Name}{countSuffix}");
}
}
private static string FormatStatisticsConsumerEntryName(
CultivateEntry entry,
ICultivationMetadataContext context,
Dictionary<Guid, CultivateEntry> entryByInnerId)
{
return entry.Type switch
{
CultivateType.AvatarAndSkill => context.GetAvatarItem(entry.Id).Name,
CultivateType.Weapon => FormatStatisticsConsumerWeaponEntryName(entry, context, entryByInnerId),
_ => Material.Default.Name,
};
}
/// <summary>
/// 材料统计右键「未完成」:武器条目在有关联角色条目时展示「角色名·武器名」,否则(含历史无 RelatedEntryId仅武器名。
/// </summary>
private static string FormatStatisticsConsumerWeaponEntryName(
CultivateEntry entry,
ICultivationMetadataContext context,
Dictionary<Guid, CultivateEntry> entryByInnerId)
{
string weaponName = context.GetWeaponItem(entry.Id).Name;
if (entry.RelatedEntryId is not Guid relatedId)
{
return weaponName;
}
if (!entryByInnerId.TryGetValue(relatedId, out CultivateEntry? related) || related.Type is not CultivateType.AvatarAndSkill)
{
return weaponName;
}
string avatarName = context.GetAvatarItem(related.Id).Name;
return $"{avatarName}·{weaponName}";
}
/// <summary>
/// 无材料的「已满配」角色占位行不在养成列表展示(仍为武器的 RelatedEntryId 解析目标)。
/// </summary>
private static bool IsHiddenAssociationOnlyAvatarEntry(CultivateEntry entry, int cultivateItemCount)
{
if (entry.Type is not CultivateType.AvatarAndSkill || cultivateItemCount != 0)
{
return false;
}
if (entry.LevelInformation is not CultivateEntryLevelInformation li)
{
return false;
}
return li.AvatarLevelFrom == li.AvatarLevelTo
&& li.SkillALevelFrom == li.SkillALevelTo
&& li.SkillELevelFrom == li.SkillELevelTo
&& li.SkillQLevelFrom == li.SkillQLevelTo;
}
} }

View File

@@ -0,0 +1,9 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.Service.Cultivation;
internal readonly record struct CultivationStatisticsMergeOptions(
bool MergeUpgradeMaterials,
bool TalentSynthCritTenPercent,
bool WeeklyBossMaterialInterchange);

View File

@@ -0,0 +1,126 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Model.Metadata;
using Snap.Hutao.Service.Metadata.ContextAbstraction;
using Snap.Hutao.ViewModel.Cultivation;
namespace Snap.Hutao.Service.Cultivation;
internal static class CultivationStatisticsSurplusMerge
{
/// <summary>
/// 元数据 <c>Combine.Type</c>1 角色与武器培养素材野怪等、2 武器突破、3 角色天赋;与此三类对应的合成均支持 10% 暴击期望。
/// </summary>
private static bool CombineTypeSupportsSynthCritTenPercent(uint combineType)
{
return combineType is 1U or 2U or 3U;
}
public static void Apply(Dictionary<uint, StatisticsCultivateItem> items, ICultivationMetadataContext context, CultivationStatisticsMergeOptions options)
{
if (!options.MergeUpgradeMaterials || items.Count is 0)
{
return;
}
bool talentCrit = options.TalentSynthCritTenPercent;
List<Combine> eligibleCombines = [];
foreach (Combine combine in context.ResultMaterialIdCombineMap.Values)
{
if (combine.RecipeType is not RecipeType.RECIPE_TYPE_COMBINE)
{
continue;
}
if (combine.Materials.Length is not 1 || combine.Materials[0].Count is not 3 || combine.Result.Count is not 1)
{
continue;
}
eligibleCombines.Add(combine);
}
Dictionary<uint, double> virtualAmount = new(items.Count);
foreach ((uint id, StatisticsCultivateItem item) in items)
{
virtualAmount[id] = item.Current;
}
foreach (IGrouping<uint, Combine> group in eligibleCombines.GroupBy(static c => c.SubType))
{
List<Combine> groupCombines = [.. group];
HashSet<uint> ids = [];
foreach (Combine combine in groupCombines)
{
_ = ids.Add(combine.Result.Id);
_ = ids.Add(combine.Materials[0].Id);
}
uint[] sortedIds = [.. ids.OrderBy(id => GetRankLevel(context, id)).ThenBy(id => id)];
foreach (uint id in sortedIds)
{
Combine? upward = FindUpwardCombine(groupCombines, id);
if (upward is null)
{
continue;
}
if (!virtualAmount.TryGetValue(id, out double virt))
{
continue;
}
uint need = items.TryGetValue(id, out StatisticsCultivateItem? row) ? row.Count : 0U;
double surplus = Math.Max(0D, virt - need);
long crafts = (long)(surplus / 3D);
if (crafts <= 0L)
{
continue;
}
double multiplier = talentCrit && CombineTypeSupportsSynthCritTenPercent(upward.Type) ? 1.1D : 1D;
double produced = crafts * multiplier;
uint resultId = upward.Result.Id;
virtualAmount[id] = virt - (crafts * 3L);
if (!virtualAmount.TryGetValue(resultId, out double atResult))
{
atResult = items.TryGetValue(resultId, out StatisticsCultivateItem? r) ? r.Current : 0D;
}
virtualAmount[resultId] = atResult + produced;
}
}
foreach ((uint id, StatisticsCultivateItem item) in items)
{
if (virtualAmount.TryGetValue(id, out double v))
{
item.MergeAdjustedCurrent = (uint)Math.Clamp(Math.Floor(v + 1e-6), 0D, uint.MaxValue);
}
}
}
private static Combine? FindUpwardCombine(List<Combine> groupCombines, uint ingredientId)
{
foreach (Combine combine in groupCombines)
{
if (combine.Materials is [{ Id: var mid, Count: 3 }] && mid == ingredientId)
{
return combine;
}
}
return null;
}
private static QualityType GetRankLevel(ICultivationMetadataContext context, uint materialId)
{
return context.GetMaterial(materialId).RankLevel;
}
}

View File

@@ -0,0 +1,109 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Primitive;
using Snap.Hutao.ViewModel.Cultivation;
using System.Collections.Generic;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Cultivation;
/// <summary>
/// 材料统计:同一周本 Boss 材料池内,将超出需求的虚拟持有量 1:1 调配给池内缺口(不计异梦溶媒消耗)。
/// </summary>
internal static class CultivationStatisticsWeeklyBossInterchange
{
public static void Apply(
Dictionary<uint, StatisticsCultivateItem> items,
ImmutableArray<ImmutableArray<MaterialId>> interchangeGroups,
bool enabled)
{
if (!enabled || interchangeGroups.IsDefault || interchangeGroups.IsEmpty)
{
return;
}
foreach (ImmutableArray<MaterialId> group in interchangeGroups)
{
ApplySingleGroup(items, group);
}
}
private static void ApplySingleGroup(Dictionary<uint, StatisticsCultivateItem> items, ImmutableArray<MaterialId> group)
{
List<uint> poolIds = [];
foreach (MaterialId mid in group)
{
uint id = mid;
if (items.ContainsKey(id))
{
poolIds.Add(id);
}
}
if (poolIds.Count < 2)
{
return;
}
Dictionary<uint, uint> virt = new(poolIds.Count);
foreach (uint id in poolIds)
{
StatisticsCultivateItem it = items[id];
virt[id] = it.MergeAdjustedCurrent ?? it.Current;
}
while (true)
{
uint? donor = null;
uint maxSurplus = 0U;
foreach (uint id in poolIds)
{
uint v = virt[id];
uint need = items[id].Count;
if (v > need && v - need > maxSurplus)
{
maxSurplus = v - need;
donor = id;
}
}
uint? receiver = null;
uint maxDeficit = 0U;
foreach (uint id in poolIds)
{
if (id == donor)
{
continue;
}
uint v = virt[id];
uint need = items[id].Count;
if (v < need && need - v > maxDeficit)
{
maxDeficit = need - v;
receiver = id;
}
}
if (donor is null || receiver is null || maxSurplus is 0U || maxDeficit is 0U)
{
break;
}
virt[donor.Value]--;
virt[receiver.Value]++;
}
foreach (uint id in poolIds)
{
StatisticsCultivateItem it = items[id];
uint baseline = it.MergeAdjustedCurrent ?? it.Current;
uint finalV = virt[id];
if (finalV != baseline)
{
it.WeeklyBossInterchangeAdjustedCurrent = finalV;
}
}
}
}

View File

@@ -0,0 +1,16 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Service.AvatarInfo.Factory;
using Snap.Hutao.ViewModel.AvatarProperty;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Cultivation;
internal interface IAvatarPropertyBatchCultivateService
{
/// <summary>
/// <see langword="null"/> when the baseline dialog is dismissed without confirmation.
/// </summary>
ValueTask<BatchCultivateResult?> ExecuteAsync(SummaryFactoryMetadataContext metadataContext, ImmutableArray<AvatarView> targetAvatars, CancellationToken cancellationToken);
}

View File

@@ -14,7 +14,8 @@ internal interface ICultivationMetadataContext : IMetadataContext,
IMetadataDictionaryIdMaterialSource, IMetadataDictionaryIdMaterialSource,
IMetadataDictionaryIdAvatarSource, IMetadataDictionaryIdAvatarSource,
IMetadataDictionaryIdWeaponSource, IMetadataDictionaryIdWeaponSource,
IMetadataDictionaryResultMaterialIdCombineSource IMetadataDictionaryResultMaterialIdCombineSource,
IMetadataWeeklyBossMaterialInterchangeGroupsSource
{ {
Item GetAvatarItem(AvatarId avatarId); Item GetAvatarItem(AvatarId avatarId);

View File

@@ -27,6 +27,8 @@ internal interface ICultivationRepository : IRepository<CultivateEntryLevelInfor
ObservableCollection<CultivateProject> GetCultivateProjectCollection(); ObservableCollection<CultivateProject> GetCultivateProjectCollection();
ImmutableArray<Guid> GetCultivateProjectInnerIds();
CultivateProject? GetCultivateProjectById(Guid projectId); CultivateProject? GetCultivateProjectById(Guid projectId);
void AddCultivateEntry(CultivateEntry entry); void AddCultivateEntry(CultivateEntry entry);
@@ -43,5 +45,16 @@ internal interface ICultivationRepository : IRepository<CultivateEntryLevelInfor
ImmutableArray<CultivateEntry> GetCultivateEntryImmutableArrayByProjectIdAndItemId(Guid projectId, uint itemId); ImmutableArray<CultivateEntry> GetCultivateEntryImmutableArrayByProjectIdAndItemId(Guid projectId, uint itemId);
/// <summary>
/// 解析当前计划中指定角色的养成条目(类型为 CultivateType.AvatarAndSkill
/// 若存在多条历史记录,取最近插入数据库的一条(按 SQLite rowid 倒序)。
/// </summary>
Guid? TryGetAvatarCultivateEntryInnerId(Guid projectId, uint avatarId);
Guid GetCultivateProjectIdByEntryId(Guid entryId); Guid GetCultivateProjectIdByEntryId(Guid entryId);
/// <summary>
/// 联表查询某计划下所有养成物品及其所属条目(用于材料统计未勾选条目等)。
/// </summary>
ImmutableArray<(CultivateEntry Entry, CultivateItem Item)> GetCultivateEntryItemPairsByProjectId(Guid projectId);
} }

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core.Database; using Snap.Hutao.Core.Database;
using Snap.Hutao.Model.Cultivation;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.Cultivation.Consumption; using Snap.Hutao.Service.Cultivation.Consumption;
using Snap.Hutao.ViewModel.Cultivation; using Snap.Hutao.ViewModel.Cultivation;
@@ -17,17 +18,40 @@ internal interface ICultivationService
ValueTask<ObservableCollection<CultivateEntryView>> GetCultivateEntryCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context); ValueTask<ObservableCollection<CultivateEntryView>> GetCultivateEntryCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context);
ValueTask<StatisticsCultivateItemCollection> GetStatisticsCultivateItemCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context, CancellationToken token); ValueTask<StatisticsCultivateItemCollection> GetStatisticsCultivateItemCollectionAsync(CultivateProject cultivateProject, ICultivationMetadataContext context, CultivationStatisticsMergeOptions mergeOptions, CancellationToken token);
ValueTask<ResinStatistics> GetResinStatisticsAsync(StatisticsCultivateItemCollection statisticsCultivateItems, CancellationToken token); ValueTask<ResinStatistics> GetResinStatisticsAsync(StatisticsCultivateItemCollection statisticsCultivateItems, CancellationToken token);
ValueTask RemoveCultivateEntryAsync(Guid entryId); ValueTask RemoveCultivateEntryAsync(Guid entryId);
/// <summary>
/// 移除当前选中养成计划中角色与武器类型的全部养成条目。
/// </summary>
ValueTask RemoveAvatarAndWeaponEntriesForCurrentProjectAsync();
ValueTask RemoveProjectAsync(CultivateProject project); ValueTask RemoveProjectAsync(CultivateProject project);
ValueTask<ConsumptionSaveResultKind> SaveConsumptionAsync(InputConsumption inputConsumption); ValueTask<ConsumptionSaveResult> SaveConsumptionAsync(InputConsumption inputConsumption);
ValueTask<Guid?> TryGetAvatarCultivateEntryInnerIdAsync(uint avatarId);
/// <summary>
/// 当前计划中尚无该角色的养成条目时,插入一条无材料行的角色占位条目(等级信息与 delta 一致),供武器 RelatedEntryId 关联。
/// 若已存在角色条目则返回其 InnerId。
/// </summary>
ValueTask<Guid?> EnsureAvatarAssociationStubAsync(uint avatarId, LevelInformation levelInformation);
void SaveCultivateItem(CultivateItemView item); void SaveCultivateItem(CultivateItemView item);
ValueTask<ProjectAddResultKind> TryAddProjectAsync(CultivateProject project); ValueTask<ProjectAddResultKind> TryAddProjectAsync(CultivateProject project);
/// <summary>
/// 读取当前选中养成计划下「我的角色」批量同步对话框的已保存选项;未保存时返回 <see langword="null"/>。
/// </summary>
ValueTask<CultivateProjectAvatarPropertyBatchPreferences?> GetAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync();
/// <summary>
/// 将批量同步对话框的选项写入当前选中养成计划。
/// </summary>
ValueTask SaveAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync(CultivateProjectAvatarPropertyBatchPreferences preferences);
} }

View File

@@ -19,4 +19,9 @@ internal sealed class InputConsumption
public required LevelInformation LevelInformation { get; init; } public required LevelInformation LevelInformation { get; init; }
public required ConsumptionSaveStrategyKind Strategy { get; init; } public required ConsumptionSaveStrategyKind Strategy { get; init; }
/// <summary>
/// 武器条目关联的养成角色条目的主键(自引用外键,可为空)。
/// </summary>
public Guid? RelatedEntryId { get; init; }
} }

View File

@@ -50,7 +50,7 @@ internal sealed partial class StatisticsCultivateItemCollection : ICollection<St
return result; return result;
} }
return MaterialIdComparer.Shared.Compare(x.Inner.Id, y.Inner.Id); return StatisticsCultivateItemComparer.CompareCore(x, y);
}); });
} }

View File

@@ -1,20 +1,53 @@
// Copyright (c) DGP Studio. All rights reserved. // Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core.Collection.Generic;
using Snap.Hutao.Model.Primitive; using Snap.Hutao.Model.Primitive;
using Snap.Hutao.ViewModel.Cultivation; using Snap.Hutao.ViewModel.Cultivation;
namespace Snap.Hutao.Service.Cultivation; namespace Snap.Hutao.Service.Cultivation;
internal sealed class StatisticsCultivateItemComparer : DelegatingPropertyComparer<StatisticsCultivateItem, MaterialId> /// <summary>
/// 材料统计默认顺序:与原先一致按 <see cref="MaterialIdComparer"/>,但当两项均为「角色培养素材」时先按 <see cref="Snap.Hutao.Model.Metadata.Item.Material.RankLevel"/> 再按物品 Id。
/// </summary>
internal sealed class StatisticsCultivateItemComparer : IComparer<StatisticsCultivateItem>
{ {
private static readonly LazySlim<StatisticsCultivateItemComparer> LazyShared = new(() => new()); private static readonly LazySlim<StatisticsCultivateItemComparer> LazyShared = new(() => new());
private StatisticsCultivateItemComparer() private StatisticsCultivateItemComparer()
: base(static i => i.Inner.Id, MaterialIdComparer.Shared)
{ {
} }
public static StatisticsCultivateItemComparer Shared { get => LazyShared.Value; } public static StatisticsCultivateItemComparer Shared { get => LazyShared.Value; }
public int Compare(StatisticsCultivateItem? x, StatisticsCultivateItem? y)
{
return (x, y) switch
{
(null, not null) => -1,
(not null, null) => 1,
(null, null) => 0,
(not null, not null) => CompareCore(x, y),
};
}
internal static int CompareCore(StatisticsCultivateItem x, StatisticsCultivateItem y)
{
string? tx = x.Inner.TypeDescription;
string? ty = y.Inner.TypeDescription;
if (IsCharacterLevelUpMaterial(tx) && IsCharacterLevelUpMaterial(ty))
{
int rank = x.Inner.RankLevel.CompareTo(y.Inner.RankLevel);
if (rank is not 0)
{
return rank;
}
}
return MaterialIdComparer.Shared.Compare(x.Inner.Id, y.Inner.Id);
}
private static bool IsCharacterLevelUpMaterial(string? typeDescription)
{
return typeDescription == SH.ModelMetadataMaterialCharacterLevelUpMaterial;
}
} }

View File

@@ -0,0 +1,138 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Model.Metadata;
using Snap.Hutao.Model.Primitive;
using System.Collections.Generic;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Cultivation;
/// <summary>
/// 自 Combine 列表解析周本材料异梦转化互通组:配方为 Type=9、CONVERT、产物×1、材料为「异梦溶媒」+ 另一周本材料各×1。
/// </summary>
internal static class WeeklyBossMaterialInterchangeGroupsBuilder
{
/// <summary>异梦溶媒 Id转化消耗统计虚拟调配时不扣溶媒仅利用池内 1:1 等价)。</summary>
private const uint DreamSolventMaterialId = 113021U;
public static ImmutableArray<ImmutableArray<MaterialId>> Build(ImmutableArray<Combine> combines)
{
Dictionary<MaterialId, HashSet<MaterialId>> adjacency = [];
foreach (Combine combine in combines)
{
if (combine.Type is not 9U)
{
continue;
}
if (combine.RecipeType is not RecipeType.RECIPE_TYPE_CONVERT)
{
continue;
}
if (combine.Materials.Length is not 2 || combine.Result.Count is not 1)
{
continue;
}
MaterialId resultId = combine.Result.Id;
MaterialId? otherMaterial = null;
foreach (ref readonly IdCount m in combine.Materials.AsSpan())
{
if (m.Id == DreamSolventMaterialId)
{
continue;
}
if (m.Count is not 1)
{
otherMaterial = null;
break;
}
otherMaterial = m.Id;
}
if (otherMaterial is null || otherMaterial.Value == resultId)
{
continue;
}
AddUndirectedEdge(adjacency, resultId, otherMaterial.Value);
}
return ToComponents(adjacency);
}
private static void AddUndirectedEdge(Dictionary<MaterialId, HashSet<MaterialId>> adjacency, MaterialId a, MaterialId b)
{
if (!adjacency.TryGetValue(a, out HashSet<MaterialId>? setA))
{
setA = [];
adjacency[a] = setA;
}
if (!adjacency.TryGetValue(b, out HashSet<MaterialId>? setB))
{
setB = [];
adjacency[b] = setB;
}
_ = setA.Add(b);
_ = setB.Add(a);
}
private static ImmutableArray<ImmutableArray<MaterialId>> ToComponents(Dictionary<MaterialId, HashSet<MaterialId>> adjacency)
{
if (adjacency.Count is 0)
{
return ImmutableArray<ImmutableArray<MaterialId>>.Empty;
}
HashSet<MaterialId> visited = [];
ImmutableArray<ImmutableArray<MaterialId>>.Builder groups = ImmutableArray.CreateBuilder<ImmutableArray<MaterialId>>();
foreach (MaterialId start in adjacency.Keys)
{
if (visited.Contains(start))
{
continue;
}
List<MaterialId> component = [];
Queue<MaterialId> queue = new();
queue.Enqueue(start);
visited.Add(start);
while (queue.Count > 0)
{
MaterialId id = queue.Dequeue();
component.Add(id);
if (!adjacency.TryGetValue(id, out HashSet<MaterialId>? neighbors))
{
continue;
}
foreach (MaterialId n in neighbors)
{
if (visited.Add(n))
{
queue.Enqueue(n);
}
}
}
if (component.Count >= 2)
{
component.Sort(MaterialIdComparer.Shared);
groups.Add(component.ToImmutableArray());
}
}
return groups.ToImmutable();
}
}

View File

@@ -38,6 +38,15 @@ internal sealed partial class GachaLogService : IGachaLogService
} }
} }
public async ValueTask<IAdvancedDbCollectionView<GachaArchive>> RefreshArchiveCollectionAsync()
{
using (await archivesLock.LockAsync().ConfigureAwait(false))
{
archives = null;
return archives = gachaLogRepository.GetGachaArchiveCollection().ToAdvancedDbCollectionView(serviceProvider);
}
}
public async ValueTask<GachaStatistics> GetStatisticsAsync(GachaLogServiceMetadataContext context, GachaArchive archive) public async ValueTask<GachaStatistics> GetStatisticsAsync(GachaLogServiceMetadataContext context, GachaArchive archive)
{ {
using (ValueStopwatch.MeasureExecution(logger)) using (ValueStopwatch.MeasureExecution(logger))

View File

@@ -22,4 +22,6 @@ internal interface IGachaLogService
ValueTask RemoveArchiveAsync(GachaArchive archive); ValueTask RemoveArchiveAsync(GachaArchive archive);
ValueTask<IAdvancedDbCollectionView<GachaArchive>> GetArchiveCollectionAsync(); ValueTask<IAdvancedDbCollectionView<GachaArchive>> GetArchiveCollectionAsync();
ValueTask<IAdvancedDbCollectionView<GachaArchive>> RefreshArchiveCollectionAsync();
} }

View File

@@ -37,15 +37,20 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
return; return;
} }
// 获取unlocker.exe路径放在Snap.Hutao同一目录 // 准备 unlocker.exe 到可写的应用数据目录
string hutaoDirectory = AppContext.BaseDirectory; await PrepareUnlockerToDataDirectoryAsync().ConfigureAwait(false);
unlockerPath = Path.Combine(hutaoDirectory, UnlockerExecutableName);
// 从应用数据目录获取 unlocker.exe 路径
unlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
if (!File.Exists(unlockerPath)) if (!File.Exists(unlockerPath))
{ {
throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下"); throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下");
} }
// 添加到 Windows Defender 排除项(需要管理员权限)
await AddToDefenderExclusionAsync(unlockerPath).ConfigureAwait(false);
// 获取游戏路径 // 获取游戏路径
gamePath = context.FileSystem.GameFilePath; gamePath = context.FileSystem.GameFilePath;
@@ -81,6 +86,134 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false); await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false);
} }
private async ValueTask PrepareUnlockerToDataDirectoryAsync()
{
// 数据目录中的目标路径
string dataDirectoryUnlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
// 安装目录中的源路径
string installDirectoryUnlockerPath = Path.Combine(AppContext.BaseDirectory, UnlockerExecutableName);
// 检查是否需要复制
bool needsCopy = false;
if (!File.Exists(dataDirectoryUnlockerPath))
{
needsCopy = true;
}
else
{
// 比较文件大小和修改时间,如果不同则更新
var sourceInfo = new FileInfo(installDirectoryUnlockerPath);
var targetInfo = new FileInfo(dataDirectoryUnlockerPath);
if (sourceInfo.Length != targetInfo.Length || sourceInfo.LastWriteTime > targetInfo.LastWriteTime)
{
needsCopy = true;
}
}
// 如果需要复制,执行复制操作
if (needsCopy)
{
try
{
Directory.CreateDirectory(HutaoRuntime.DataDirectory);
File.Copy(installDirectoryUnlockerPath, dataDirectoryUnlockerPath, true);
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已复制到数据目录: {dataDirectoryUnlockerPath}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
catch (Exception ex)
{
throw HutaoException.InvalidOperation($"复制 unlockfps.exe 到数据目录失败: {ex.Message}", ex);
}
}
}
private async ValueTask AddToDefenderExclusionAsync(string executablePath)
{
try
{
// 检查是否已经在排除项中
ProcessStartInfo checkInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"(Get-MpPreference).ExclusionPath -split '\"' | Where-Object {{ $_ -eq '{executablePath}' }}\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
WindowStyle = ProcessWindowStyle.Hidden,
};
using (Process checkProcess = new() { StartInfo = checkInfo })
{
checkProcess.Start();
string output = await checkProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
await checkProcess.WaitForExitAsync().ConfigureAwait(false);
// 如果输出包含路径,说明已经在排除项中
if (!string.IsNullOrWhiteSpace(output) && output.Trim().Contains(executablePath))
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已在 Windows Defender 排除项中",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
return;
}
}
// 不在排除项中,尝试添加
ProcessStartInfo addInfo = new()
{
FileName = "powershell.exe",
Arguments = $"-Command \"Add-MpPreference -ExclusionPath '{executablePath}'\"",
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Hidden,
Verb = "runas", // 请求管理员权限
};
using (Process addProcess = new() { StartInfo = addInfo })
{
addProcess.Start();
string output = await addProcess.StandardOutput.ReadToEndAsync().ConfigureAwait(false);
string error = await addProcess.StandardError.ReadToEndAsync().ConfigureAwait(false);
await addProcess.WaitForExitAsync().ConfigureAwait(false);
if (addProcess.ExitCode == 0)
{
SentrySdk.AddBreadcrumb(
$"unlockfps.exe 已成功添加到 Windows Defender 排除项",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
else
{
SentrySdk.AddBreadcrumb(
$"无法添加到 Windows Defender 排除项(需要管理员权限): {error ?? output}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb(
$"添加 Windows Defender 排除项失败: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
private async ValueTask CreateUnlockerConfigAsync(BeforeLaunchExecutionContext context) private async ValueTask CreateUnlockerConfigAsync(BeforeLaunchExecutionContext context)
{ {
if (string.IsNullOrEmpty(gamePath)) if (string.IsNullOrEmpty(gamePath))
@@ -88,8 +221,8 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
throw HutaoException.NotSupported("游戏路径未初始化"); throw HutaoException.NotSupported("游戏路径未初始化");
} }
// 直接在unlocker同目录创建配置文件 // 在应用数据目录创建配置文件
string unlockerConfigPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string unlockerConfigPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
int targetFps = context.LaunchOptions.TargetFps.Value; int targetFps = context.LaunchOptions.TargetFps.Value;
string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}"; string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}";
@@ -126,7 +259,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try try
{ {
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (!File.Exists(configPath)) if (!File.Exists(configPath))
{ {
throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}"); throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}");
@@ -214,6 +347,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
return string.Empty; return string.Empty;
} }
// 获取米游社登录Ticket
string? authTicket = default;
bool useAuthTicket = launchOptions.UsingHoyolabAccount.Value
&& context.TryGetOption(LaunchExecutionOptionsKey.LoginAuthTicket, out authTicket)
&& !string.IsNullOrEmpty(authTicket);
StringBuilder arguments = new(); StringBuilder arguments = new();
// 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数 // 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数
@@ -249,6 +388,12 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}"); arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}");
} }
// 添加米游社登录参数
if (useAuthTicket)
{
arguments.Append($" login_auth_ticket={authTicket}");
}
return arguments.ToString(); return arguments.ToString();
} }
@@ -302,7 +447,7 @@ internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
try try
{ {
string configPath = Path.Combine(Path.GetDirectoryName(unlockerPath)!, UnlockerConfigName); string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (File.Exists(configPath)) if (File.Exists(configPath))
{ {
string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false); string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false);

View File

@@ -26,17 +26,22 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
public override async ValueTask ExecuteAsync(LaunchExecutionContext context) public override async ValueTask ExecuteAsync(LaunchExecutionContext context)
{ {
// 如果启用了IslandFPS解锁则跳过启动游戏进程
// 因为unlockfps.exe会负责启动游戏
if (context.LaunchOptions.IsIslandEnabled.Value)
{
context.Progress.Report(new(SH.ServiceGameLaunchPhaseProcessStarted));
return;
}
try try
{ {
// 对于suspended进程Yae注入模式、Island模式需要先Start()创建进程然后ResumeMainThread()恢复主线程
// 对于正常启动的进程ShellExecute、DiagnosticsProcess只调用Start()
context.Process.Start(); context.Process.Start();
// 尝试恢复主线程适用于suspended进程
try
{
context.Process.ResumeMainThread();
}
catch (HutaoException ex) when (ex.Message.Contains("ResumeMainThread is not supported"))
{
// ResumeMainThread不支持说明是正常启动的进程DiagnosticsProcess忽略此错误
}
await context.TaskContext.SwitchToMainThreadAsync(); await context.TaskContext.SwitchToMainThreadAsync();
GameLifeCycle.IsGameRunningProperty.Value = true; GameLifeCycle.IsGameRunningProperty.Value = true;
} }

View File

@@ -2,8 +2,11 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core; using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae; using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.Island; using Snap.Hutao.Service.Game.Island;
using Snap.Hutao.Service.Game.Launching.Context; using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
@@ -32,36 +35,57 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeClientNotElevated); HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeClientNotElevated);
} }
if (!context.LaunchOptions.IsIslandEnabled.Value)
{
context.Process.Kill();
HutaoException.NotSupported(SH.ServiceGameLaunchingHandlerEmbeddedYaeIslandNotEnabled);
return;
}
string dataFolderYaePath = Path.Combine(HutaoRuntime.DataDirectory, "YaeAchievementLib.dll"); string dataFolderYaePath = Path.Combine(HutaoRuntime.DataDirectory, "YaeAchievementLib.dll");
InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath); InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath);
// 直接使用创建的游戏进程
int actualProcessId = context.Process.Id;
if (actualProcessId == 0)
{
throw HutaoException.Throw("游戏进程未正确创建");
}
try try
{ {
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", context.Process.Id); DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", actualProcessId);
} }
catch (Exception ex) catch (Exception ex)
{ {
// Windows Defender Application Control // Windows Defender Application Control
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION)) if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION))
{ {
context.Process.Kill();
throw HutaoException.Throw(SH.ServiceGameLaunchingHandlerEmbeddedYaeErrorSystemIntegrityPolicyViolation); throw HutaoException.Throw(SH.ServiceGameLaunchingHandlerEmbeddedYaeErrorSystemIntegrityPolicyViolation);
} }
throw; // Access Denied (0x80070005) - 权限不足,无法在远程进程中分配内存
if (ex.HResult == unchecked((int)0x80070005))
{
throw HutaoException.Throw($"无法在游戏进程中注入 DLL (访问被拒绝)。\n\n" +
$"可能的原因:\n" +
$"1. 游戏进程的完整性级别高于 Snap Hutao\n" +
$"2. Windows Defender 或其他安全软件阻止了注入\n" +
$"解决方法:\n" +
$"1. 检查 Windows Defender 设置,将 Snap Hutao 添加到排除列表\n" +
$"2. 以管理员身份运行 Snap Hutao\n" +
$"3. 检查是否有其他安全软件(如 360、火绒等干扰");
}
// 游戏进程由直接启动,已经是运行状态
// InjectUsingWindowsHook2 需要手动恢复主线程,但 DiagnosticsProcess 不支持 ResumeMainThread
// 这里不使用 InjectUsingWindowsHook2
throw new InvalidOperationException($"无法注入 DLL: {ex.Message}. 请确保没有启用 Windows Defender Application Control 或其他安全限制。", ex);
} }
try try
{ {
// 获取游戏进程用于命名管道服务器
IProcess actualProcess = ProcessFactory.TryGetById(actualProcessId, out IProcess? process)
? process
: throw HutaoException.Throw($"无法获取进程 ID {actualProcessId}");
// 已经是运行状态,不需要恢复主线程
#pragma warning disable CA2007 #pragma warning disable CA2007
await using (YaeNamedPipeServer server = new(context.ServiceProvider, context.Process, config)) await using (YaeNamedPipeServer server = new(context.ServiceProvider, actualProcess, config, supportsResumeMainThread: false))
#pragma warning restore CA2007 #pragma warning restore CA2007
{ {
receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false); receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false);
@@ -69,7 +93,6 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
} }
catch (Exception) catch (Exception)
{ {
context.Process.Kill();
throw; throw;
} }
} }

View File

@@ -2,9 +2,14 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics; using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.LifeCycle.InterProcess.Yae; using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching.Context; using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Service.Game.Launching.Handler; using Snap.Hutao.Service.Game.Launching.Handler;
using Snap.Hutao.Service.Game.Package;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Game.Launching.Invoker; namespace Snap.Hutao.Service.Game.Launching.Invoker;
@@ -28,4 +33,99 @@ internal sealed class YaeLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{ {
return GameProcessFactory.CreateForEmbeddedYae(beforeContext); return GameProcessFactory.CreateForEmbeddedYae(beforeContext);
} }
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public async ValueTask InvokeAsync(LaunchExecutionInvocationContext context)
{
ITaskContext taskContext = context.ServiceProvider.GetRequiredService<ITaskContext>();
string lockTrace = $"{GetType().Name}.{nameof(InvokeAsync)}";
context.LaunchOptions.TryGetGameFileSystem(lockTrace, out IGameFileSystem? gameFileSystem);
ArgumentNullException.ThrowIfNull(gameFileSystem);
using (GameFileSystemReference fileSystemReference = new(gameFileSystem))
{
if (context.ViewModel.TargetScheme is not { } targetScheme)
{
throw HutaoException.InvalidOperation(SH.ViewModelLaunchGameSchemeNotSelected);
}
if (context.ViewModel.CurrentScheme is not { } currentScheme)
{
throw HutaoException.InvalidOperation(SH.ServiceGameLaunchExecutionCurrentSchemeNull);
}
IProgress<LaunchStatus?> progress = CreateStatusProgress(context.ServiceProvider);
BeforeLaunchExecutionContext beforeContext = new()
{
ViewModel = context.ViewModel,
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
FileSystem = fileSystemReference,
HoyoPlay = context.ServiceProvider.GetRequiredService<IHoyoPlayService>(),
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
CurrentScheme = currentScheme,
TargetScheme = targetScheme,
Identity = context.Identity,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.BeforeAsync(beforeContext).ConfigureAwait(false);
}
fileSystemReference.Exchange(beforeContext.FileSystem);
// Yae注入功能不依赖unlockfps.exe总是创建游戏进程
IProcess? process = CreateProcess(beforeContext);
using (process)
{
if (process is null)
{
return;
}
LaunchExecutionContext executionContext = new()
{
Progress = progress,
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions,
Process = process,
IsOversea = targetScheme.IsOversea,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.ExecuteAsync(executionContext).ConfigureAwait(false);
}
}
AfterLaunchExecutionContext afterContext = new()
{
ServiceProvider = context.ServiceProvider,
TaskContext = taskContext,
};
foreach (ILaunchExecutionHandler handler in Handlers)
{
await handler.AfterAsync(afterContext).ConfigureAwait(false);
}
}
}
private static IProgress<LaunchStatus?> CreateStatusProgress(IServiceProvider serviceProvider)
{
IProgressFactory progressFactory = serviceProvider.GetRequiredService<IProgressFactory>();
LaunchStatusOptions options = serviceProvider.GetRequiredService<LaunchStatusOptions>();
return progressFactory.CreateForMainThread<LaunchStatus?, LaunchStatusOptions>(static (status, options) => options.LaunchStatus = status, options);
}
} }

View File

@@ -21,6 +21,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
{ {
private readonly AsyncKeyedLock<string> repoLock = new(); private readonly AsyncKeyedLock<string> repoLock = new();
private readonly BackgroundActivityOptions backgroundActivityOptions; private readonly BackgroundActivityOptions backgroundActivityOptions;
private readonly ILogger<GitRepositoryService> logger;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
@@ -76,6 +77,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
} }
catch (Exception first) catch (Exception first)
{ {
logger.LogWarning(first, "[Metadata] Failed to update existing repository, fallback to reclone: Directory={Directory}, Url={Url}", directory, info.HttpsUrl.OriginalString);
exceptions.Add(first); exceptions.Add(first);
return EnsureRepository(activity, directory, info, true); return EnsureRepository(activity, directory, info, true);
} }
@@ -110,6 +112,14 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
{ {
// Increase & decrease count in the same method, so that crash in the middle can correctly count as failure. // Increase & decrease count in the same method, so that crash in the middle can correctly count as failure.
RepositoryAffinity.IncreaseFailure(info); RepositoryAffinity.IncreaseFailure(info);
// Debug: Log the initial state
bool isRepoValid = Repository.IsValid(directory);
bool directoryExists = Directory.Exists(directory);
logger.LogInformation("[Metadata] Checking repository: Directory={Directory}, Exists={Exists}, IsValid={IsValid}, ForceInvalid={ForceInvalid}",
directory, directoryExists, isRepoValid, forceInvalid);
FetchOptions fetchOptions = new() FetchOptions fetchOptions = new()
{ {
Depth = 1, Depth = 1,
@@ -142,10 +152,18 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
CertificateCheck = static (cert, valid, host) => true, CertificateCheck = static (cert, valid, host) => true,
}; };
if (forceInvalid || !Repository.IsValid(directory)) if (forceInvalid || !isRepoValid)
{ {
if (Directory.Exists(directory)) // Debug: Log why we're cloning
string reason = forceInvalid
? SH.ServiceGitRepositoryCloneReasonForceInvalid
: SH.ServiceGitRepositoryCloneReasonInvalidRepo;
logger.LogInformation("[Metadata] Cloning repository: Reason={Reason}, Url={Url}", reason, info.HttpsUrl.OriginalString);
activity.Update(taskContext, reason, false, false, false, false);
if (directoryExists)
{ {
logger.LogInformation("[Metadata] Deleting existing directory before clone");
Directory.SetReadOnly(directory, false); Directory.SetReadOnly(directory, false);
Directory.Delete(directory, true); Directory.Delete(directory, true);
} }
@@ -154,9 +172,15 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
{ {
Checkout = true, Checkout = true,
}); });
logger.LogInformation("[Metadata] Clone completed successfully");
} }
else else
{ {
// Debug: Log that we're updating
logger.LogInformation("[Metadata] Updating existing repository");
activity.Update(taskContext, SH.ServiceGitRepositoryUpdatingExisting, false, false, false, false);
// We need to ensure local repo is up to date // We need to ensure local repo is up to date
using (Repository repo = new(directory)) using (Repository repo = new(directory))
{ {
@@ -177,17 +201,20 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
repo.Network.Remotes.Update("origin", remote => remote.Url = info.HttpsUrl.OriginalString); repo.Network.Remotes.Update("origin", remote => remote.Url = info.HttpsUrl.OriginalString);
repo.RemoveUntrackedFiles(); repo.RemoveUntrackedFiles();
fetchOptions.UpdateFetchHead = false; fetchOptions.UpdateFetchHead = false;
Commands.Fetch(repo, repo.Head.RemoteName, Array.Empty<string>(), fetchOptions, default); Commands.Fetch(repo, "origin", Array.Empty<string>(), fetchOptions, default);
// Manually patch .git/shallow file // Manually patch .git/shallow file
File.WriteAllText(Path.Combine(directory, ".git//shallow"), string.Join("", repo.Branches.Where(static branch => branch.IsRemote).Select(static branch => $"{branch.Tip.Sha}\n"))); File.WriteAllText(Path.Combine(directory, ".git", "shallow"), string.Join("", repo.Branches.Where(static branch => branch.IsRemote).Select(static branch => $"{branch.Tip.Sha}\n")));
Branch remoteBranch = repo.Branches["origin/main"]; Branch remoteBranch = repo.Branches["origin/main"];
Branch localBranch = repo.Branches["main"] ?? repo.CreateBranch("main", remoteBranch.Tip); Branch localBranch = repo.Branches["main"] ?? repo.CreateBranch("main", remoteBranch.Tip);
repo.Branches.Update(localBranch, b => b.TrackedBranch = remoteBranch.CanonicalName); repo.Branches.Update(localBranch, b => b.TrackedBranch = remoteBranch.CanonicalName);
Commands.Checkout(repo, localBranch);
repo.Reset(ResetMode.Hard, remoteBranch.Tip); repo.Reset(ResetMode.Hard, remoteBranch.Tip);
repo.RemoveUntrackedFiles(); repo.RemoveUntrackedFiles();
} }
logger.LogInformation("[Metadata] Update completed successfully");
} }
RepositoryAffinity.DecreaseFailure(info); RepositoryAffinity.DecreaseFailure(info);

View File

@@ -23,6 +23,7 @@ internal sealed partial class InventoryService : IInventoryService
private readonly PromotionDeltaFactory promotionDeltaFactory; private readonly PromotionDeltaFactory promotionDeltaFactory;
private readonly IServiceScopeFactory serviceScopeFactory; private readonly IServiceScopeFactory serviceScopeFactory;
private readonly IInventoryRepository inventoryRepository; private readonly IInventoryRepository inventoryRepository;
private readonly ICultivationRepository cultivationRepository;
private readonly IUserService userService; private readonly IUserService userService;
private readonly IMessenger messenger; private readonly IMessenger messenger;
@@ -55,7 +56,7 @@ internal sealed partial class InventoryService : IInventoryService
{ {
case RefreshOptionKind.WebCalculator: case RefreshOptionKind.WebCalculator:
ArgumentNullException.ThrowIfNull(refreshOptions.MetadataContext); ArgumentNullException.ThrowIfNull(refreshOptions.MetadataContext);
return RefreshInventoryByCalculatorAsync(refreshOptions.MetadataContext, refreshOptions.Project); return RefreshInventoryByCalculatorAsync(refreshOptions);
case RefreshOptionKind.EmbeddedYae: case RefreshOptionKind.EmbeddedYae:
ArgumentNullException.ThrowIfNull(refreshOptions.YaeService); ArgumentNullException.ThrowIfNull(refreshOptions.YaeService);
ArgumentNullException.ThrowIfNull(refreshOptions.ViewModelSupportLaunchExecution); ArgumentNullException.ThrowIfNull(refreshOptions.ViewModelSupportLaunchExecution);
@@ -71,8 +72,12 @@ internal sealed partial class InventoryService : IInventoryService
inventoryRepository.RemoveInventoryItemRangeByProjectId(projectId); inventoryRepository.RemoveInventoryItemRangeByProjectId(projectId);
} }
private async ValueTask RefreshInventoryByCalculatorAsync(ICultivationMetadataContext context, CultivateProject project) private async ValueTask RefreshInventoryByCalculatorAsync(RefreshOptions options)
{ {
ICultivationMetadataContext context = options.MetadataContext!;
CultivateProject project = options.Project;
bool syncToAllProjects = options.SyncCalculatorInventoryToAllProjects;
if (await userService.GetCurrentUserAndUidAsync().ConfigureAwait(false) is not { } userAndUid) if (await userService.GetCurrentUserAndUidAsync().ConfigureAwait(false) is not { } userAndUid)
{ {
messenger.Send(InfoBarMessage.Warning(SH.MustSelectUserAndUid)); messenger.Send(InfoBarMessage.Warning(SH.MustSelectUserAndUid));
@@ -98,8 +103,36 @@ internal sealed partial class InventoryService : IInventoryService
if (batchConsumption is { OverallConsume: { IsDefault: false } items }) if (batchConsumption is { OverallConsume: { IsDefault: false } items })
{ {
inventoryRepository.RemoveInventoryItemRangeByProjectId(project.InnerId); static IEnumerable<InventoryItem> ToInventoryItems(ImmutableArray<Item> consumeItems, Guid projectId)
inventoryRepository.AddInventoryItemRangeByProjectId(items.SelectAsArray(static (item, project) => InventoryItem.From(project.InnerId, item.Id, (uint)((int)item.Num - item.LackNum)), project)); {
static uint ToSafeCount(Item item)
{
long delta = (long)item.Num - item.LackNum;
if (delta <= 0)
{
return 0U;
}
return delta >= uint.MaxValue ? uint.MaxValue : (uint)delta;
}
return consumeItems.SelectAsArray(static (item, pid) => InventoryItem.From(pid, item.Id, ToSafeCount(item)), projectId);
}
if (syncToAllProjects)
{
ImmutableArray<Guid> projectIds = cultivationRepository.GetCultivateProjectInnerIds();
foreach (Guid projectId in projectIds.AsSpan())
{
inventoryRepository.RemoveInventoryItemRangeByProjectId(projectId);
inventoryRepository.AddInventoryItemRangeByProjectId(ToInventoryItems(items, projectId));
}
}
else
{
inventoryRepository.RemoveInventoryItemRangeByProjectId(project.InnerId);
inventoryRepository.AddInventoryItemRangeByProjectId(ToInventoryItems(items, project.InnerId));
}
} }
} }

View File

@@ -24,7 +24,12 @@ internal sealed class RefreshOptions
public required IViewModelSupportLaunchExecution? ViewModelSupportLaunchExecution { get; init; } public required IViewModelSupportLaunchExecution? ViewModelSupportLaunchExecution { get; init; }
public static RefreshOptions CreateForWebCalculator(CultivateProject project, ICultivationMetadataContext context) /// <summary>
/// 通过养成计算器同步背包时,是否将结果写入所有养成计划(否则仅当前计划)。
/// </summary>
public bool SyncCalculatorInventoryToAllProjects { get; init; }
public static RefreshOptions CreateForWebCalculator(CultivateProject project, ICultivationMetadataContext context, bool syncCalculatorInventoryToAllProjects = false)
{ {
return new() return new()
{ {
@@ -33,6 +38,7 @@ internal sealed class RefreshOptions
MetadataContext = context, MetadataContext = context,
YaeService = default, YaeService = default,
ViewModelSupportLaunchExecution = default, ViewModelSupportLaunchExecution = default,
SyncCalculatorInventoryToAllProjects = syncCalculatorInventoryToAllProjects,
}; };
} }

View File

@@ -0,0 +1,64 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Intrinsic;
using Snap.Hutao.Model.Metadata;
using Snap.Hutao.Model.Primitive;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Metadata;
/// <summary>
/// Combine 元数据中多条配方可共享同一产物 Id例如元素断片既有 3×碎屑合成也有用其他元素石与尘的转化
/// 构建 MaterialId→Combine 映射时必须优先保留「单材料×3→产物×1」的合成台主链否则后项覆盖前项会导致养成统计无法向下展开原料。
/// </summary>
internal static class CombineResultMaterialIdMapFactory
{
public static ImmutableDictionary<MaterialId, Combine> ToImmutableDictionary(ImmutableArray<Combine> combines)
{
Dictionary<MaterialId, Combine> map = [];
foreach (Combine current in combines)
{
MaterialId key = current.Result.Id;
if (!map.TryGetValue(key, out Combine? existing))
{
map[key] = current;
continue;
}
if (ComparePreference(current, existing) > 0)
{
map[key] = current;
}
}
return map.ToImmutableDictionary();
}
/// <summary>正数表示 <paramref name="incoming"/> 应取代已有项。</summary>
private static int ComparePreference(Combine incoming, Combine existing)
{
return Score(incoming).CompareTo(Score(existing));
}
private static int Score(Combine c)
{
if (c.Materials.Length is 1 && c.Materials[0].Count is 3 && c.Result.Count is 1)
{
return c.RecipeType switch
{
RecipeType.RECIPE_TYPE_COMBINE => 300,
RecipeType.RECIPE_TYPE_CONVERT => 200,
_ => 150,
};
}
if (c.RecipeType is RecipeType.RECIPE_TYPE_COMBINE)
{
return 100;
}
return 0;
}
}

View File

@@ -0,0 +1,15 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Primitive;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.Metadata.ContextAbstraction;
/// <summary>
/// 周本 Boss 掉落材料「异梦转化」互通组(由 Combine 元数据解析)。
/// </summary>
internal interface IMetadataWeeklyBossMaterialInterchangeGroupsSource : IMetadataContext
{
ImmutableArray<ImmutableArray<MaterialId>> WeeklyBossMaterialInterchangeGroups { get; set; }
}

View File

@@ -211,6 +211,11 @@ internal static class MetadataServiceContextExtension
{ {
dictionaryResultMaterialIdCombineSource.ResultMaterialIdCombineMap = await metadataService.GetResultMaterialIdToCombineMapAsync(token).ConfigureAwait(false); dictionaryResultMaterialIdCombineSource.ResultMaterialIdCombineMap = await metadataService.GetResultMaterialIdToCombineMapAsync(token).ConfigureAwait(false);
} }
if (context is IMetadataWeeklyBossMaterialInterchangeGroupsSource weeklyBossInterchange)
{
weeklyBossInterchange.WeeklyBossMaterialInterchangeGroups = await metadataService.GetWeeklyBossMaterialInterchangeGroupsAsync(token).ConfigureAwait(false);
}
} }
if (context is IMetadataSupportInitialization supportInitialization) if (context is IMetadataSupportInitialization supportInitialization)

View File

@@ -13,6 +13,7 @@ using Snap.Hutao.Model.Metadata.Reliquary;
using Snap.Hutao.Model.Metadata.Tower; using Snap.Hutao.Model.Metadata.Tower;
using Snap.Hutao.Model.Metadata.Weapon; using Snap.Hutao.Model.Metadata.Weapon;
using Snap.Hutao.Model.Primitive; using Snap.Hutao.Model.Primitive;
using Snap.Hutao.Service.Cultivation;
using System.Collections.Immutable; using System.Collections.Immutable;
namespace Snap.Hutao.Service.Metadata; namespace Snap.Hutao.Service.Metadata;
@@ -216,12 +217,29 @@ internal static class MetadataServiceImmutableDictionaryExtension
token); token);
} }
public ValueTask<ImmutableDictionary<MaterialId, Combine>> GetResultMaterialIdToCombineMapAsync(CancellationToken token = default) public async ValueTask<ImmutableDictionary<MaterialId, Combine>> GetResultMaterialIdToCombineMapAsync(CancellationToken token = default)
{ {
return metadataService.FromCacheAsDictionaryAsync<MaterialId, Combine>( string cacheKey = $"{nameof(MetadataService)}.Cache.{MetadataFileStrategies.Combine.Name}.Map.{nameof(MaterialId)}.{nameof(Combine)}.PreferThreeToOne";
MetadataFileStrategies.Combine, ImmutableDictionary<MaterialId, Combine>? result = await metadataService.MemoryCache.GetOrCreateAsync(cacheKey, async entry =>
c => c.Result.Id, {
token); ImmutableArray<Combine> array = await metadataService.FromCacheOrFileAsync<Combine>(MetadataFileStrategies.Combine, token).ConfigureAwait(false);
return CombineResultMaterialIdMapFactory.ToImmutableDictionary(array);
}).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(result);
return result;
}
public async ValueTask<ImmutableArray<ImmutableArray<MaterialId>>> GetWeeklyBossMaterialInterchangeGroupsAsync(CancellationToken token = default)
{
string cacheKey = $"{nameof(MetadataService)}.Cache.{MetadataFileStrategies.Combine.Name}.WeeklyBossMaterialInterchangeGroups";
ImmutableArray<ImmutableArray<MaterialId>>? result = await metadataService.MemoryCache.GetOrCreateAsync(cacheKey, async entry =>
{
ImmutableArray<Combine> array = await metadataService.FromCacheOrFileAsync<Combine>(MetadataFileStrategies.Combine, token).ConfigureAwait(false);
return WeeklyBossMaterialInterchangeGroupsBuilder.Build(array);
}).ConfigureAwait(false);
return result ?? ImmutableArray<ImmutableArray<MaterialId>>.Empty;
} }
private ValueTask<ImmutableDictionary<TKey, TValue>> FromCacheAsDictionaryAsync<TKey, TValue>(MetadataFileStrategy strategy, CancellationToken token) private ValueTask<ImmutableDictionary<TKey, TValue>> FromCacheAsDictionaryAsync<TKey, TValue>(MetadataFileStrategy strategy, CancellationToken token)

View File

@@ -34,4 +34,18 @@ internal interface IThirdPartyToolService
/// <param name="tool">工具信息</param> /// <param name="tool">工具信息</param>
/// <returns>是否已下载</returns> /// <returns>是否已下载</returns>
bool IsToolDownloaded(ToolInfo tool); bool IsToolDownloaded(ToolInfo tool);
/// <summary>
/// 获取本地工具信息
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>本地工具信息如果不存在则返回null</returns>
LocalToolInfo? GetLocalToolInfo(ToolInfo tool);
/// <summary>
/// 检查工具是否需要更新
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>是否需要更新</returns>
bool NeedsUpdate(ToolInfo tool);
} }

View File

@@ -9,7 +9,9 @@ using System.Collections.Generic;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Diagnostics; using System.Diagnostics;
using System.IO; using System.IO;
using System.IO.Compression;
using System.Net.Http; using System.Net.Http;
using System.Text.Json;
namespace Snap.Hutao.Service.ThirdPartyTool; namespace Snap.Hutao.Service.ThirdPartyTool;
@@ -17,8 +19,9 @@ namespace Snap.Hutao.Service.ThirdPartyTool;
[Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))] [Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))]
internal sealed partial class ThirdPartyToolService : IThirdPartyToolService internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
{ {
private const string ApiBaseUrl = "https://htserver.wdg.cloudns.ch/api"; private const string ApiBaseUrl = "https://htserver.wdg12.work/api";
private const string ToolsEndpoint = "/tools"; private const string ToolsEndpoint = "/tools";
private const string ToolInfoFileName = "tool_info.json";
private readonly IHttpClientFactory httpClientFactory; private readonly IHttpClientFactory httpClientFactory;
private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory; private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory;
@@ -89,41 +92,32 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
try try
{ {
string toolDirectory = GetToolDirectory(tool); string toolDirectory = GetToolDirectory(tool);
Directory.CreateDirectory(toolDirectory);
int totalFiles = tool.Files.Count; // 如果需要更新,先清理旧文件
int downloadedFiles = 0; if (NeedsUpdate(tool) && Directory.Exists(toolDirectory))
{
Directory.Delete(toolDirectory, true);
}
Directory.CreateDirectory(toolDirectory);
using (HttpClient httpClient = httpClientFactory.CreateClient()) using (HttpClient httpClient = httpClientFactory.CreateClient())
{ {
foreach (string fileName in tool.Files) if (tool.IsCompressed)
{ {
string fileUrl = $"{tool.Url}{fileName}"; // 压缩包模式:下载并解压
string localFilePath = Path.Combine(toolDirectory, fileName); await DownloadAndExtractCompressedToolAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
}
// 如果文件已存在,跳过下载 else
if (File.Exists(localFilePath)) {
{ // 非压缩包模式:直接下载所有文件
downloadedFiles++; await DownloadFilesAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
progress?.Report((double)downloadedFiles / totalFiles * 100);
continue;
}
// 下载文件
HttpResponseMessage response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false))
using (FileStream fileStream = new(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false);
}
downloadedFiles++;
progress?.Report((double)downloadedFiles / totalFiles * 100);
} }
} }
// 保存本地工具信息
SaveLocalToolInfo(tool);
return true; return true;
} }
catch (Exception ex) catch (Exception ex)
@@ -139,15 +133,31 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
{ {
string toolDirectory = GetToolDirectory(tool); string toolDirectory = GetToolDirectory(tool);
// 查找可执行文件.exe // 优先使用 main_exe如果没有则查找可执行文件
string? executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)); string? executablePath = tool.MainExe;
if (string.IsNullOrEmpty(executablePath))
{
executablePath = tool.Files.FirstOrDefault(f => f.EndsWith(".exe", StringComparison.OrdinalIgnoreCase));
}
// 如果还是没有,尝试从目录中查找
if (string.IsNullOrEmpty(executablePath))
{
string[] exeFiles = Directory.GetFiles(toolDirectory, "*.exe", SearchOption.TopDirectoryOnly);
executablePath = exeFiles.FirstOrDefault();
}
if (string.IsNullOrEmpty(executablePath)) if (string.IsNullOrEmpty(executablePath))
{ {
messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound)); messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound));
return false; return false;
} }
string fullPath = Path.Combine(toolDirectory, executablePath); // 如果 executablePath 是完整路径,直接使用;否则拼接目录
string fullPath = Path.IsPathRooted(executablePath)
? executablePath
: Path.Combine(toolDirectory, Path.GetFileName(executablePath));
if (!File.Exists(fullPath)) if (!File.Exists(fullPath))
{ {
messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath))); messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath)));
@@ -192,7 +202,20 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
return false; return false;
} }
// 检查所有文件是否存在 // 检查工具信息文件是否存在
LocalToolInfo? localInfo = GetLocalToolInfo(tool);
if (localInfo is null)
{
return false;
}
// 对于压缩包,检查目录是否有内容
if (tool.IsCompressed)
{
return Directory.GetFiles(toolDirectory, "*", SearchOption.AllDirectories).Length > 0;
}
// 对于非压缩包,检查所有文件是否存在
foreach (string fileName in tool.Files) foreach (string fileName in tool.Files)
{ {
string filePath = Path.Combine(toolDirectory, fileName); string filePath = Path.Combine(toolDirectory, fileName);
@@ -205,6 +228,217 @@ internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
return true; return true;
} }
public LocalToolInfo? GetLocalToolInfo(ToolInfo tool)
{
string toolDirectory = GetToolDirectory(tool);
string infoFilePath = Path.Combine(toolDirectory, ToolInfoFileName);
if (!File.Exists(infoFilePath))
{
return null;
}
try
{
string json = File.ReadAllText(infoFilePath);
return JsonSerializer.Deserialize<LocalToolInfo>(json);
}
catch
{
return null;
}
}
public bool NeedsUpdate(ToolInfo tool)
{
LocalToolInfo? localInfo = GetLocalToolInfo(tool);
if (localInfo is null)
{
return true; // 没有本地信息,需要下载
}
// 比较版本号
return IsNewerVersion(tool.Version, localInfo.Version);
}
private static async Task DownloadAndExtractCompressedToolAsync(
HttpClient httpClient,
ToolInfo tool,
string toolDirectory,
IProgress<double>? progress,
CancellationToken token)
{
// 压缩包模式通常只有一个 zip 文件
string zipFileName = tool.Files.FirstOrDefault(f => f.EndsWith(".zip", StringComparison.OrdinalIgnoreCase))
?? tool.Files[0];
string zipUrl = $"{tool.Url}{zipFileName}";
string zipFilePath = Path.Combine(toolDirectory, zipFileName);
// 下载 zip 文件
progress?.Report(0);
HttpResponseMessage response = await httpClient.GetAsync(zipUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false))
using (FileStream fileStream = new(zipFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false);
}
progress?.Report(50);
// 解压 zip 文件
using (ZipArchive archive = ZipFile.OpenRead(zipFilePath))
{
// 检查是否有根目录需要处理
bool hasRootFolder = HasSingleRootFolder(archive);
foreach (ZipArchiveEntry entry in archive.Entries)
{
if (string.IsNullOrEmpty(entry.Name))
{
// 这是一个目录,创建它
string? destinationPath = GetDestinationPath(entry.FullName, toolDirectory, hasRootFolder);
if (destinationPath is not null)
{
Directory.CreateDirectory(destinationPath);
}
continue;
}
string? destFilePath = GetDestinationPath(entry.FullName, toolDirectory, hasRootFolder);
if (destFilePath is null)
{
continue;
}
// 确保目录存在
string? destDir = Path.GetDirectoryName(destFilePath);
if (!string.IsNullOrEmpty(destDir))
{
Directory.CreateDirectory(destDir);
}
entry.ExtractToFile(destFilePath, true);
}
}
progress?.Report(90);
// 删除 zip 文件
File.Delete(zipFilePath);
progress?.Report(100);
}
private static async Task DownloadFilesAsync(
HttpClient httpClient,
ToolInfo tool,
string toolDirectory,
IProgress<double>? progress,
CancellationToken token)
{
int totalFiles = tool.Files.Count;
int downloadedFiles = 0;
foreach (string fileName in tool.Files)
{
string fileUrl = $"{tool.Url}{fileName}";
string localFilePath = Path.Combine(toolDirectory, fileName);
// 如果文件已存在,跳过下载
if (File.Exists(localFilePath))
{
downloadedFiles++;
progress?.Report((double)downloadedFiles / totalFiles * 100);
continue;
}
// 下载文件
HttpResponseMessage response = await httpClient.GetAsync(fileUrl, HttpCompletionOption.ResponseHeadersRead, token).ConfigureAwait(false);
response.EnsureSuccessStatusCode();
using (Stream contentStream = await response.Content.ReadAsStreamAsync(token).ConfigureAwait(false))
using (FileStream fileStream = new(localFilePath, FileMode.Create, FileAccess.Write, FileShare.None))
{
await contentStream.CopyToAsync(fileStream, token).ConfigureAwait(false);
}
downloadedFiles++;
progress?.Report((double)downloadedFiles / totalFiles * 100);
}
}
private void SaveLocalToolInfo(ToolInfo tool)
{
string toolDirectory = GetToolDirectory(tool);
string infoFilePath = Path.Combine(toolDirectory, ToolInfoFileName);
LocalToolInfo localInfo = LocalToolInfo.FromToolInfo(tool);
string json = JsonSerializer.Serialize(localInfo, new JsonSerializerOptions { WriteIndented = true });
File.WriteAllText(infoFilePath, json);
}
private static bool IsNewerVersion(string remoteVersion, string localVersion)
{
// 使用 Version 类进行比较
if (Version.TryParse(remoteVersion, out Version? remote) && Version.TryParse(localVersion, out Version? local))
{
return remote > local;
}
// 如果无法解析为版本号,进行字符串比较
return !string.Equals(remoteVersion, localVersion, StringComparison.OrdinalIgnoreCase);
}
private static bool HasSingleRootFolder(ZipArchive archive)
{
// 检查是否所有条目都在同一个根目录下
HashSet<string> rootFolders = [];
foreach (ZipArchiveEntry entry in archive.Entries)
{
int separatorIndex = entry.FullName.IndexOf('/');
if (separatorIndex > 0)
{
rootFolders.Add(entry.FullName[..separatorIndex]);
}
else if (separatorIndex < 0 && !string.IsNullOrEmpty(entry.Name))
{
// 直接在根目录下的文件
return false;
}
}
return rootFolders.Count == 1;
}
private static string? GetDestinationPath(string entryPath, string toolDirectory, bool hasRootFolder)
{
if (string.IsNullOrEmpty(entryPath))
{
return null;
}
if (hasRootFolder)
{
// 移除根目录前缀
int separatorIndex = entryPath.IndexOf('/');
if (separatorIndex >= 0 && separatorIndex < entryPath.Length - 1)
{
return Path.Combine(toolDirectory, entryPath[(separatorIndex + 1)..]);
}
else if (separatorIndex >= 0)
{
// 这是根目录本身
return null;
}
}
return Path.Combine(toolDirectory, entryPath);
}
private static string GetToolDirectory(ToolInfo tool) private static string GetToolDirectory(ToolInfo tool)
{ {
// 使用数据目录/工具名作为存储路径 // 使用数据目录/工具名作为存储路径

View File

@@ -4,6 +4,7 @@ using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Model.InterChange.GachaLog; using Snap.Hutao.Model.InterChange.GachaLog;
using Snap.Hutao.Service.GachaLog; using Snap.Hutao.Service.GachaLog;
using Snap.Hutao.ViewModel.GachaLog;
using Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo; using Snap.Hutao.Web.Hoyolab.Hk4e.Event.GachaInfo;
using System.Collections.Immutable; using System.Collections.Immutable;
@@ -13,6 +14,7 @@ internal abstract partial class AbstractUIGF40ImportService : IUIGFImportService
{ {
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IMessenger messenger;
[GeneratedConstructor] [GeneratedConstructor]
public partial AbstractUIGF40ImportService(IServiceProvider serviceProvider); public partial AbstractUIGF40ImportService(IServiceProvider serviceProvider);
@@ -21,6 +23,7 @@ internal abstract partial class AbstractUIGF40ImportService : IUIGFImportService
{ {
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();
ImportGachaArchives(importOptions.UIGF.Hk4e, importOptions.GachaArchiveUids); ImportGachaArchives(importOptions.UIGF.Hk4e, importOptions.GachaArchiveUids);
messenger.Send(GachaLogImportedMessage.Empty);
} }
private void ImportGachaArchives(ImmutableArray<UIGFEntry<Hk4eItem>> entries, HashSet<uint> uids) private void ImportGachaArchives(ImmutableArray<UIGFEntry<Hk4eItem>> entries, HashSet<uint> uids)

View File

@@ -29,6 +29,26 @@ internal sealed class TargetNativeConfiguration
public required uint Decompress { get; init; } public required uint Decompress { get; init; }
public static TargetNativeConfiguration Create(uint storeCmdId, uint achievementCmdId, MethodRva methodRva)
{
return new()
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
DoCmd = methodRva.DoCmd,
UpdateNormalProperty = methodRva.UpdateNormalProperty,
NewString = methodRva.NewString,
FindGameObject = methodRva.FindGameObject,
EventSystemUpdate = methodRva.EventSystemUpdate,
SimulatePointerClick = methodRva.SimulatePointerClick,
ToInt32 = methodRva.ToInt32,
TcpStatePtr = methodRva.TcpStatePtr,
SharedInfoPtr = methodRva.SharedInfoPtr,
Decompress = methodRva.Decompress,
};
}
public static TargetNativeConfiguration Create(NativeConfiguration config, bool isOversea) public static TargetNativeConfiguration Create(NativeConfiguration config, bool isOversea)
{ {
MethodRva methodRva = isOversea ? config.MethodRva.Oversea : config.MethodRva.Chinese; MethodRva methodRva = isOversea ? config.MethodRva.Oversea : config.MethodRva.Chinese;

View File

@@ -0,0 +1,32 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class Crc32
{
private const uint Polynomial = 0xEDB88320;
private static readonly uint[] Crc32Table = new uint[256];
static Crc32()
{
for (uint i = 0; i < Crc32Table.Length; i++)
{
uint value = i;
for (int j = 0; j < 8; j++)
{
value = (value >> 1) ^ ((value & 1) * Polynomial);
}
Crc32Table[i] = value;
}
}
public static uint Compute(Span<byte> buffer)
{
uint checksum = 0xFFFFFFFF;
foreach (byte b in buffer)
{
checksum = (checksum >> 8) ^ Crc32Table[(b ^ checksum) & 0xFF];
}
return ~checksum;
}
}

View File

@@ -0,0 +1,6 @@
namespace Snap.Hutao.Service.Yae.Metadata;
internal interface IYaeMetadataService
{
ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default);
}

View File

@@ -0,0 +1,188 @@
using Google.Protobuf;
using Snap.Hutao.Core.Protobuf;
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal static class YaeMetadataParser
{
private const uint AchievementInfoNativeConfigTag = 42; // field 5, wire type length-delimited
private const uint NativeConfigStoreCmdIdTag = 8; // field 1, varint
private const uint NativeConfigAchievementCmdIdTag = 16; // field 2, varint
private const uint NativeConfigMethodRvaTag = 82; // field 10, length-delimited
private const uint MapEntryKeyTag = 8; // field 1, varint
private const uint MapEntryValueTag = 18; // field 2, length-delimited
private const uint MethodRvaDoCmdTag = 8;
private const uint MethodRvaUpdateNormalPropTag = 24;
private const uint MethodRvaNewStringTag = 32;
private const uint MethodRvaFindGameObjectTag = 40;
private const uint MethodRvaEventSystemUpdateTag = 48;
private const uint MethodRvaSimulatePointerClickTag = 56;
private const uint MethodRvaToInt32Tag = 64;
private const uint MethodRvaTcpStatePtrTag = 72;
private const uint MethodRvaSharedInfoPtrTag = 80;
private const uint MethodRvaDecompressTag = 88;
public static YaeNativeLibConfig? ParseNativeLibConfig(byte[] data)
{
uint storeCmdId = 0;
uint achievementCmdId = 0;
Dictionary<uint, MethodRva> methodRva = [];
bool hasNativeConfig = false;
CodedInputStream input = new(data);
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case AchievementInfoNativeConfigTag:
hasNativeConfig = true;
using (CodedInputStream nativeConfigStream = input.UnsafeReadLengthDelimitedStream())
{
ParseNativeConfig(nativeConfigStream, ref storeCmdId, ref achievementCmdId, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
if (!hasNativeConfig || methodRva.Count == 0)
{
return null;
}
return new YaeNativeLibConfig
{
StoreCmdId = storeCmdId,
AchievementCmdId = achievementCmdId,
MethodRva = methodRva,
};
}
private static void ParseNativeConfig(CodedInputStream input, ref uint storeCmdId, ref uint achievementCmdId, Dictionary<uint, MethodRva> methodRva)
{
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case NativeConfigStoreCmdIdTag:
storeCmdId = input.ReadUInt32();
break;
case NativeConfigAchievementCmdIdTag:
achievementCmdId = input.ReadUInt32();
break;
case NativeConfigMethodRvaTag:
using (CodedInputStream entryStream = input.UnsafeReadLengthDelimitedStream())
{
ParseMethodRvaEntry(entryStream, methodRva);
}
break;
default:
input.SkipLastField();
break;
}
}
}
private static void ParseMethodRvaEntry(CodedInputStream input, Dictionary<uint, MethodRva> methodRva)
{
uint key = 0;
MethodRva? value = null;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MapEntryKeyTag:
key = input.ReadUInt32();
break;
case MapEntryValueTag:
using (CodedInputStream valueStream = input.UnsafeReadLengthDelimitedStream())
{
value = ParseMethodRvaConfig(valueStream);
}
break;
default:
input.SkipLastField();
break;
}
}
if (value is not null)
{
methodRva[key] = value;
}
}
private static MethodRva ParseMethodRvaConfig(CodedInputStream input)
{
uint doCmd = 0;
uint updateNormalProp = 0;
uint newString = 0;
uint findGameObject = 0;
uint eventSystemUpdate = 0;
uint simulatePointerClick = 0;
uint toInt32 = 0;
uint tcpStatePtr = 0;
uint sharedInfoPtr = 0;
uint decompress = 0;
while (input.TryReadTag(out uint tag))
{
switch (tag)
{
case MethodRvaDoCmdTag:
doCmd = input.ReadUInt32();
break;
case MethodRvaUpdateNormalPropTag:
updateNormalProp = input.ReadUInt32();
break;
case MethodRvaNewStringTag:
newString = input.ReadUInt32();
break;
case MethodRvaFindGameObjectTag:
findGameObject = input.ReadUInt32();
break;
case MethodRvaEventSystemUpdateTag:
eventSystemUpdate = input.ReadUInt32();
break;
case MethodRvaSimulatePointerClickTag:
simulatePointerClick = input.ReadUInt32();
break;
case MethodRvaToInt32Tag:
toInt32 = input.ReadUInt32();
break;
case MethodRvaTcpStatePtrTag:
tcpStatePtr = input.ReadUInt32();
break;
case MethodRvaSharedInfoPtrTag:
sharedInfoPtr = input.ReadUInt32();
break;
case MethodRvaDecompressTag:
decompress = input.ReadUInt32();
break;
default:
input.SkipLastField();
break;
}
}
return new MethodRva
{
DoCmd = doCmd,
UpdateNormalProperty = updateNormalProp,
NewString = newString,
FindGameObject = findGameObject,
EventSystemUpdate = eventSystemUpdate,
SimulatePointerClick = simulatePointerClick,
ToInt32 = toInt32,
TcpStatePtr = tcpStatePtr,
SharedInfoPtr = sharedInfoPtr,
Decompress = decompress,
};
}
}

View File

@@ -0,0 +1,65 @@
using Microsoft.Extensions.Caching.Memory;
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using System.IO;
using System.Net.Http;
namespace Snap.Hutao.Service.Yae.Metadata;
[Service(ServiceLifetime.Singleton, typeof(IYaeMetadataService))]
[HttpClient(HttpClientConfiguration.Default)]
internal sealed partial class YaeMetadataService : IYaeMetadataService
{
private const string MetadataUrl = "https://rin.holohat.work/schicksal/metadata";
private static readonly TimeSpan CacheDuration = TimeSpan.FromHours(6);
private static readonly string? LocalMetadataPath = TryGetLocalMetadataPath();
private readonly IHttpClientFactory httpClientFactory;
private readonly IMemoryCache memoryCache;
[GeneratedConstructor]
public partial YaeMetadataService(IServiceProvider serviceProvider);
public ValueTask<YaeNativeLibConfig?> GetNativeLibConfigAsync(CancellationToken token = default)
{
Task<YaeNativeLibConfig?> task = memoryCache.GetOrCreateAsync($"{nameof(YaeMetadataService)}.NativeLibConfig", async entry =>
{
entry.SetSlidingExpiration(CacheDuration);
byte[] data;
if (!string.IsNullOrEmpty(LocalMetadataPath) && File.Exists(LocalMetadataPath))
{
data = await File.ReadAllBytesAsync(LocalMetadataPath, token).ConfigureAwait(false);
if (data.Length > 0)
{
return YaeMetadataParser.ParseNativeLibConfig(data);
}
}
using HttpClient httpClient = httpClientFactory.CreateClient(nameof(YaeMetadataService));
data = await httpClient.GetByteArrayAsync(MetadataUrl, token).ConfigureAwait(false);
return YaeMetadataParser.ParseNativeLibConfig(data);
});
return new ValueTask<YaeNativeLibConfig?>(task);
}
private static string? TryGetLocalMetadataPath()
{
try
{
// 尝试获取用户下载目录下的metadata文件本地测试和排查问题时使用
string userProfile = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
if (string.IsNullOrEmpty(userProfile))
{
return default;
}
string localPath = Path.Combine(userProfile, "Downloads", "metadata");
return localPath;
}
catch
{
return default;
}
}
}

View File

@@ -0,0 +1,12 @@
using Snap.Hutao.Service.Yae.Achievement;
namespace Snap.Hutao.Service.Yae.Metadata;
internal sealed class YaeNativeLibConfig
{
public required uint StoreCmdId { get; init; }
public required uint AchievementCmdId { get; init; }
public required IReadOnlyDictionary<uint, MethodRva> MethodRva { get; init; }
}

View File

@@ -6,7 +6,6 @@ using Snap.Hutao.Core.LifeCycle.InterProcess.Yae;
using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.InterChange.Achievement; using Snap.Hutao.Model.InterChange.Achievement;
using Snap.Hutao.Model.InterChange.Inventory; using Snap.Hutao.Model.InterChange.Inventory;
using Snap.Hutao.Service.Feature;
using Snap.Hutao.Service.Game; using Snap.Hutao.Service.Game;
using Snap.Hutao.Service.Game.FileSystem; using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching; using Snap.Hutao.Service.Game.Launching;
@@ -15,10 +14,12 @@ using Snap.Hutao.Service.Game.Launching.Invoker;
using Snap.Hutao.Service.Notification; using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User; using Snap.Hutao.Service.User;
using Snap.Hutao.Service.Yae.Achievement; using Snap.Hutao.Service.Yae.Achievement;
using Snap.Hutao.Service.Yae.Metadata;
using Snap.Hutao.Service.Yae.PlayerStore; using Snap.Hutao.Service.Yae.PlayerStore;
using Snap.Hutao.ViewModel.Game; using Snap.Hutao.ViewModel.Game;
using Snap.Hutao.ViewModel.User; using Snap.Hutao.ViewModel.User;
using System.Diagnostics; using System.Diagnostics;
using System.IO;
namespace Snap.Hutao.Service.Yae; namespace Snap.Hutao.Service.Yae;
@@ -27,7 +28,7 @@ internal sealed partial class YaeService : IYaeService
{ {
private readonly IContentDialogFactory contentDialogFactory; private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly IFeatureService featureService; private readonly IYaeMetadataService yaeMetadataService;
private readonly IUserService userService; private readonly IUserService userService;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
private readonly IMessenger messenger; private readonly IMessenger messenger;
@@ -57,15 +58,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount), Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
}; };
if (!TryGetGameVersion(context, out string? version, out bool isOversea)) TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{ {
return default; return default;
} }
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false); await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
UIAF? uiaf = default; UIAF? uiaf = default;
@@ -76,7 +74,7 @@ internal sealed partial class YaeService : IYaeService
if (data.Kind is YaeCommandKind.ResponseAchievement) if (data.Kind is YaeCommandKind.ResponseAchievement)
{ {
Debug.Assert(uiaf is null); Debug.Assert(uiaf is null);
uiaf = AchievementParser.Parse(data.Bytes, fieldId); uiaf = AchievementParser.Parse(data.Bytes);
} }
} }
} }
@@ -116,15 +114,12 @@ internal sealed partial class YaeService : IYaeService
Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount), Identity = GameIdentity.Create(userAndUid, viewModel.GameAccount),
}; };
if (!TryGetGameVersion(context, out string? version, out bool isOversea)) TargetNativeConfiguration? config = await TryGetTargetNativeConfigurationAsync(context).ConfigureAwait(false);
if (config is null)
{ {
return default; return default;
} }
AchievementFieldId? fieldId = await featureService.GetAchievementFieldIdAsync(version).ConfigureAwait(false);
ArgumentNullException.ThrowIfNull(fieldId);
TargetNativeConfiguration config = TargetNativeConfiguration.Create(fieldId.NativeConfig, isOversea);
await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false); await new YaeLaunchExecutionInvoker(config, receiver).InvokeAsync(context).ConfigureAwait(false);
} }
catch (Exception ex) catch (Exception ex)
@@ -167,9 +162,9 @@ internal sealed partial class YaeService : IYaeService
} }
} }
private bool TryGetGameVersion(LaunchExecutionInvocationContext context, [NotNullWhen(true)] out string? version, out bool isOversea) private async ValueTask<TargetNativeConfiguration?> TryGetTargetNativeConfigurationAsync(LaunchExecutionInvocationContext context)
{ {
const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetGameVersion)}"; const string LockTrace = $"{nameof(YaeService)}.{nameof(TryGetTargetNativeConfigurationAsync)}";
if (context.LaunchOptions.TryGetGameFileSystem(LockTrace, out IGameFileSystem? gameFileSystem) is not GameFileSystemErrorKind.None) if (context.LaunchOptions.TryGetGameFileSystem(LockTrace, out IGameFileSystem? gameFileSystem) is not GameFileSystemErrorKind.None)
{ {
@@ -178,23 +173,54 @@ internal sealed partial class YaeService : IYaeService
if (gameFileSystem is null) if (gameFileSystem is null)
{ {
version = default; return default;
isOversea = false;
return false;
} }
using (gameFileSystem) using (gameFileSystem)
{ {
if (!gameFileSystem.TryGetGameVersion(out version) || string.IsNullOrEmpty(version)) if (!TryGetGameExecutableHash(gameFileSystem.GameFilePath, out uint hash))
{ {
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed)); messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
isOversea = false; return default;
}
YaeNativeLibConfig? nativeConfig = await yaeMetadataService.GetNativeLibConfigAsync().ConfigureAwait(false);
if (nativeConfig is null)
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
if (!nativeConfig.MethodRva.TryGetValue(hash, out MethodRva? methodRva))
{
messenger.Send(InfoBarMessage.Error(SH.ServiceYaeGetGameVersionFailed));
return default;
}
return TargetNativeConfiguration.Create(nativeConfig.StoreCmdId, nativeConfig.AchievementCmdId, methodRva);
}
}
private static bool TryGetGameExecutableHash(string gameFilePath, out uint hash)
{
try
{
Span<byte> buffer = stackalloc byte[0x10000];
using FileStream stream = File.OpenRead(gameFilePath);
int read = stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false);
if (read < buffer.Length)
{
hash = default;
return false; return false;
} }
isOversea = gameFileSystem.IsExecutableOversea; hash = Crc32.Compute(buffer);
return true;
}
catch (IOException)
{
hash = default;
return false;
} }
return true;
} }
} }

View File

@@ -11,7 +11,7 @@
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<UseWPF>False</UseWPF> <UseWPF>False</UseWPF>
<!-- 配置版本号 --> <!-- 配置版本号 -->
<Version>1.18.3.0</Version> <Version>1.18.7.0</Version>
<UseWindowsForms>False</UseWindowsForms> <UseWindowsForms>False</UseWindowsForms>
<ImplicitUsings>False</ImplicitUsings> <ImplicitUsings>False</ImplicitUsings>
@@ -79,6 +79,15 @@
<Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" /> <Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" />
</Target> </Target>
<!-- 声明unlockfps.exe为项目内容确保MSIX打包时包含此文件 -->
<ItemGroup>
<Content Include="$(MSBuildThisFileDirectory)..\..\..\bin\unlockfps.exe">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<Pack>true</Pack>
<PackagePath>unlockfps.exe</PackagePath>
</Content>
</ItemGroup>
<!-- Analyzer Files --> <!-- Analyzer Files -->
<ItemGroup> <ItemGroup>
<AdditionalFiles Include="ApiEndpoints.csv" /> <AdditionalFiles Include="ApiEndpoints.csv" />

View File

@@ -117,10 +117,17 @@ internal partial class ScopedPage : Page
if (DataContext is IViewModel viewModel) if (DataContext is IViewModel viewModel)
{ {
// Wait to ensure critical viewmodel operation is completed try
using (viewModel.CriticalSection.Enter()) {
// Wait to ensure critical viewmodel operation is completed.
// The view model might already be disposed when window shutdown and page unload race.
using (viewModel.CriticalSection.Enter())
{
viewModel.Uninitialize();
}
}
catch (OperationCanceledException)
{ {
viewModel.Uninitialize();
} }
try try

View File

@@ -1,7 +1,7 @@
<ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> <ResourceDictionary xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<!-- DocumentLink --> <!-- DocumentLink -->
<x:String x:Key="DocumentLink_BugReport">https://hut.ao/statements/bug-report.html</x:String> <x:String x:Key="DocumentLink_BugReport">https://hut.ao/statements/bug-report.html</x:String>
<x:String x:Key="DocumentLink_Home">https://hut.ao</x:String> <x:String x:Key="DocumentLink_Home">https://htserver.wdg12.work</x:String>
<x:String x:Key="DocumentLink_MhyAccountSwitch">https://hut.ao/features/mhy-account-switch.html</x:String> <x:String x:Key="DocumentLink_MhyAccountSwitch">https://hut.ao/features/mhy-account-switch.html</x:String>
<x:String x:Key="DocumentLink_Translate">https://translate.hut.ao</x:String> <x:String x:Key="DocumentLink_Translate">https://translate.hut.ao</x:String>
<x:String x:Key="DocumentLink_Loopback">https://hut.ao/advanced/loopback.html</x:String> <x:String x:Key="DocumentLink_Loopback">https://hut.ao/advanced/loopback.html</x:String>
@@ -13,32 +13,32 @@
<x:String x:Key="IconGame">https://launcher-webstatic.mihoyo.com/launcher-public/2024/04/15/9ebf1bc5af2d83ca5fca21adb49cf341_2571779162329842818.png</x:String> <x:String x:Key="IconGame">https://launcher-webstatic.mihoyo.com/launcher-public/2024/04/15/9ebf1bc5af2d83ca5fca21adb49cf341_2571779162329842818.png</x:String>
<!-- AvatarCard --> <!-- AvatarCard -->
<x:String x:Key="UI_AvatarIcon_Costume_Card">https://htserver.wdg.cloudns.ch/static/raw/AvatarCard/UI_AvatarIcon_Costume_Card.png</x:String> <x:String x:Key="UI_AvatarIcon_Costume_Card">https://htserver.wdg12.work/static/raw/AvatarCard/UI_AvatarIcon_Costume_Card.png</x:String>
<!-- Bg --> <!-- Bg -->
<x:String x:Key="UI_ItemIcon_None">https://htserver.wdg.cloudns.ch/static/raw/Bg/UI_ItemIcon_None.png</x:String> <x:String x:Key="UI_ItemIcon_None">https://htserver.wdg12.work/static/raw/Bg/UI_ItemIcon_None.png</x:String>
<!-- Mark --> <!-- Mark -->
<x:String x:Key="UI_MarkQuest_Events_Proce">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Events_Proce.png</x:String> <x:String x:Key="UI_MarkQuest_Events_Proce">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Events_Proce.png</x:String>
<x:String x:Key="UI_MarkQuest_Events_Start">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Events_Start.png</x:String> <x:String x:Key="UI_MarkQuest_Events_Start">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Events_Start.png</x:String>
<x:String x:Key="UI_MarkQuest_Main_Proce">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Main_Proce.png</x:String> <x:String x:Key="UI_MarkQuest_Main_Proce">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Main_Proce.png</x:String>
<x:String x:Key="UI_MarkQuest_Main_Start">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkQuest_Main_Start.png</x:String> <x:String x:Key="UI_MarkQuest_Main_Start">https://htserver.wdg12.work/static/raw/Mark/UI_MarkQuest_Main_Start.png</x:String>
<x:String x:Key="UI_MarkTower">https://htserver.wdg.cloudns.ch/static/raw/Mark/UI_MarkTower.png</x:String> <x:String x:Key="UI_MarkTower">https://htserver.wdg12.work/static/raw/Mark/UI_MarkTower.png</x:String>
<!-- ItemIcon --> <!-- ItemIcon -->
<x:String x:Key="UI_ItemIcon_106">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_106.png</x:String> <x:String x:Key="UI_ItemIcon_106">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_106.png</x:String>
<x:String x:Key="UI_ItemIcon_204">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_204.png</x:String> <x:String x:Key="UI_ItemIcon_204">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_204.png</x:String>
<x:String x:Key="UI_ItemIcon_210">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_210.png</x:String> <x:String x:Key="UI_ItemIcon_210">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_210.png</x:String>
<x:String x:Key="UI_ItemIcon_220021">https://htserver.wdg.cloudns.ch/static/raw/ItemIcon/UI_ItemIcon_220021.png</x:String> <x:String x:Key="UI_ItemIcon_220021">https://htserver.wdg12.work/static/raw/ItemIcon/UI_ItemIcon_220021.png</x:String>
<!-- EmotionIcon --> <!-- EmotionIcon -->
<x:String x:Key="UI_EmotionIcon52">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon52.png</x:String> <x:String x:Key="UI_EmotionIcon52">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon52.png</x:String>
<x:String x:Key="UI_EmotionIcon71">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon71.png</x:String> <x:String x:Key="UI_EmotionIcon71">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon71.png</x:String>
<x:String x:Key="UI_EmotionIcon89">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon89.png</x:String> <x:String x:Key="UI_EmotionIcon89">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon89.png</x:String>
<x:String x:Key="UI_EmotionIcon250">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon250.png</x:String> <x:String x:Key="UI_EmotionIcon250">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon250.png</x:String>
<x:String x:Key="UI_EmotionIcon271">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon271.png</x:String> <x:String x:Key="UI_EmotionIcon271">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon271.png</x:String>
<x:String x:Key="UI_EmotionIcon272">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon272.png</x:String> <x:String x:Key="UI_EmotionIcon272">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon272.png</x:String>
<x:String x:Key="UI_EmotionIcon293">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon293.png</x:String> <x:String x:Key="UI_EmotionIcon293">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon293.png</x:String>
<x:String x:Key="UI_EmotionIcon433">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon433.png</x:String> <x:String x:Key="UI_EmotionIcon433">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon433.png</x:String>
<x:String x:Key="UI_EmotionIcon445">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon445.png</x:String> <x:String x:Key="UI_EmotionIcon445">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon445.png</x:String>
<x:String x:Key="UI_EmotionIcon585">https://htserver.wdg.cloudns.ch/static/raw/EmotionIcon/UI_EmotionIcon585.png</x:String> <x:String x:Key="UI_EmotionIcon585">https://htserver.wdg12.work/static/raw/EmotionIcon/UI_EmotionIcon585.png</x:String>
</ResourceDictionary> </ResourceDictionary>

View File

@@ -9,13 +9,18 @@ namespace Snap.Hutao.UI.Xaml.Markup;
[MarkupExtensionReturnType(ReturnType = typeof(string))] [MarkupExtensionReturnType(ReturnType = typeof(string))]
internal sealed partial class ResourceStringExtension : MarkupExtension internal sealed partial class ResourceStringExtension : MarkupExtension
{ {
public SHName Name { get; set; } public string? Name { get; set; }
public string? CultureName { get; set; } public string? CultureName { get; set; }
protected override object ProvideValue() protected override object ProvideValue()
{ {
if (string.IsNullOrEmpty(Name))
{
return string.Empty;
}
CultureInfo cultureInfo = CultureName is not null ? CultureInfo.GetCultureInfo(CultureName) : CultureInfo.CurrentCulture; CultureInfo cultureInfo = CultureName is not null ? CultureInfo.GetCultureInfo(CultureName) : CultureInfo.CurrentCulture;
return SH.ResourceManager.GetString(string.Intern(Name.ToString()), cultureInfo) ?? string.Empty; return SH.ResourceManager.GetString(string.Intern(Name), cultureInfo) ?? string.Empty;
} }
} }

View File

@@ -20,7 +20,9 @@
<x:Double x:Key="SettingsCardWrapThreshold">0</x:Double> <x:Double x:Key="SettingsCardWrapThreshold">0</x:Double>
<x:Double x:Key="SettingsCardWrapNoIconThreshold">0</x:Double> <x:Double x:Key="SettingsCardWrapNoIconThreshold">0</x:Double>
</ContentDialog.Resources> </ContentDialog.Resources>
<StackPanel Spacing="{StaticResource SettingsCardSpacing}"> <!-- ScrollViewer随 ContentDialog 主题高度上限自适应;内容过高时纵向滚动,避免裁切或写死 MaxHeight -->
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<cwc:SettingsCard Header="{shuxm:ResourceString Name=ViewDialogCultivateBatchAvatarLevelTarget}"> <cwc:SettingsCard Header="{shuxm:ResourceString Name=ViewDialogCultivateBatchAvatarLevelTarget}">
<NumberBox <NumberBox
MinWidth="{StaticResource NumberBoxMinWidth}" MinWidth="{StaticResource NumberBoxMinWidth}"
@@ -77,5 +79,19 @@
<RadioButton Content="{shuxm:ResourceString Name=ViewDialogCultivationConsumptionSaveStrategyOverwriteExisting}"/> <RadioButton Content="{shuxm:ResourceString Name=ViewDialogCultivationConsumptionSaveStrategyOverwriteExisting}"/>
<RadioButton Content="{shuxm:ResourceString Name=ViewDialogCultivationConsumptionSaveStrategyCreateNewEntry}"/> <RadioButton Content="{shuxm:ResourceString Name=ViewDialogCultivationConsumptionSaveStrategyCreateNewEntry}"/>
</RadioButtons> </RadioButtons>
</StackPanel>
<CheckBox
Margin="0,13,0,0"
Content="{shuxm:ResourceString Name=ViewDialogCultivateBatchClearAvatarAndWeaponEntries}"
IsChecked="{x:Bind ClearAvatarAndWeaponEntriesBeforeSync, Mode=TwoWay}"/>
<CheckBox
Margin="0,6,0,0"
Content="{shuxm:ResourceString Name=ViewPageCultivationRefreshInventory}"
IsChecked="{x:Bind SyncInventoryItems, Mode=TwoWay}"/>
<CheckBox
Margin="0,6,0,0"
Content="{shuxm:ResourceString Name=ViewAvatarPropertySyncDataButtonLabel}"
IsChecked="{x:Bind SyncCharacterInfo, Mode=TwoWay}"/>
</StackPanel>
</ScrollViewer>
</ContentDialog> </ContentDialog>

View File

@@ -4,18 +4,31 @@
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Snap.Hutao.Core.Setting; using Snap.Hutao.Core.Setting;
using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.Cultivation;
using Snap.Hutao.Service.Cultivation.Consumption; using Snap.Hutao.Service.Cultivation.Consumption;
using Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate; using Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate;
namespace Snap.Hutao.UI.Xaml.View.Dialog; namespace Snap.Hutao.UI.Xaml.View.Dialog;
[DependencyProperty<AvatarPromotionDelta>("PromotionDelta", NotNull = true, CreateDefaultValueCallbackName = nameof(CreatePromotionDeltaDefaultValue))] [DependencyProperty<AvatarPromotionDelta>("PromotionDelta", NotNull = true, CreateDefaultValueCallbackName = nameof(CreatePromotionDeltaDefaultValue))]
[DependencyProperty<bool>("ClearAvatarAndWeaponEntriesBeforeSync", DefaultValue = false, NotNull = true)]
[DependencyProperty<bool>("SyncInventoryItems", DefaultValue = false, NotNull = true)]
[DependencyProperty<bool>("SyncCharacterInfo", DefaultValue = false, NotNull = true)]
internal sealed partial class CultivatePromotionDeltaBatchDialog : ContentDialog internal sealed partial class CultivatePromotionDeltaBatchDialog : ContentDialog
{ {
private readonly IContentDialogFactory contentDialogFactory; private readonly IContentDialogFactory contentDialogFactory;
[GeneratedConstructor(InitializeComponent = true)] public CultivatePromotionDeltaBatchDialog(IServiceProvider serviceProvider, CultivateProjectAvatarPropertyBatchPreferences? initialPreferences = null)
public partial CultivatePromotionDeltaBatchDialog(IServiceProvider serviceProvider); {
InitializeComponent();
contentDialogFactory = serviceProvider.GetRequiredService<IContentDialogFactory>();
if (initialPreferences is not null)
{
ApplyInitialPreferences(initialPreferences);
}
}
public async ValueTask<ValueResult<bool, CultivatePromotionDeltaOptions>> GetPromotionDeltaBaselineAsync() public async ValueTask<ValueResult<bool, CultivatePromotionDeltaOptions>> GetPromotionDeltaBaselineAsync()
{ {
@@ -45,7 +58,29 @@ internal sealed partial class CultivatePromotionDeltaBatchDialog : ContentDialog
LocalSetting.Set(SettingKeys.CultivationWeapon90LevelTarget, weapon.LevelTarget); LocalSetting.Set(SettingKeys.CultivationWeapon90LevelTarget, weapon.LevelTarget);
} }
return new(true, new(PromotionDelta, (ConsumptionSaveStrategyKind)SaveModeSelector.SelectedIndex)); return new(true, new CultivatePromotionDeltaOptions(PromotionDelta, (ConsumptionSaveStrategyKind)SaveModeSelector.SelectedIndex, ClearAvatarAndWeaponEntriesBeforeSync, SyncInventoryItems, SyncCharacterInfo));
}
private void ApplyInitialPreferences(CultivateProjectAvatarPropertyBatchPreferences p)
{
PromotionDelta.AvatarLevelTarget = p.AvatarLevelTarget;
if (PromotionDelta.SkillList is [{ } a, { } e, { } q, ..])
{
a.LevelTarget = p.SkillATarget;
e.LevelTarget = p.SkillETarget;
q.LevelTarget = p.SkillQTarget;
}
if (PromotionDelta.Weapon is { } w)
{
w.LevelTarget = p.WeaponLevelTarget;
}
SaveModeSelector.SelectedIndex = int.Clamp(p.ConsumptionSaveStrategyIndex, 0, 2);
ClearAvatarAndWeaponEntriesBeforeSync = p.ClearAvatarAndWeaponEntriesBeforeSync;
SyncInventoryItems = p.SyncInventoryItems;
SyncCharacterInfo = p.SyncCharacterInfo;
} }
private static object CreatePromotionDeltaDefaultValue() private static object CreatePromotionDeltaDefaultValue()

View File

@@ -8,7 +8,7 @@ namespace Snap.Hutao.UI.Xaml.View.Dialog;
internal sealed class CultivatePromotionDeltaOptions internal sealed class CultivatePromotionDeltaOptions
{ {
public CultivatePromotionDeltaOptions(AvatarPromotionDelta delta, ConsumptionSaveStrategyKind strategy) public CultivatePromotionDeltaOptions(AvatarPromotionDelta delta, ConsumptionSaveStrategyKind strategy, bool clearAvatarAndWeaponEntriesBeforeSync = false, bool syncInventoryItems = false, bool syncCharacterInfo = false)
{ {
delta.AvatarLevelTarget = delta.AvatarLevelTarget switch delta.AvatarLevelTarget = delta.AvatarLevelTarget switch
{ {
@@ -19,9 +19,21 @@ internal sealed class CultivatePromotionDeltaOptions
Delta = delta; Delta = delta;
Strategy = strategy; Strategy = strategy;
ClearAvatarAndWeaponEntriesBeforeSync = clearAvatarAndWeaponEntriesBeforeSync;
SyncInventoryItems = syncInventoryItems;
SyncCharacterInfo = syncCharacterInfo;
} }
public AvatarPromotionDelta Delta { get; } public AvatarPromotionDelta Delta { get; }
public ConsumptionSaveStrategyKind Strategy { get; } public ConsumptionSaveStrategyKind Strategy { get; }
/// <summary>
/// 批量同步前是否清空当前计划中已有的角色与武器养成条目(不含家具等其它类型)。
/// </summary>
public bool ClearAvatarAndWeaponEntriesBeforeSync { get; }
public bool SyncInventoryItems { get; }
public bool SyncCharacterInfo { get; }
} }

View File

@@ -57,7 +57,7 @@ internal sealed partial class HutaoPassportRegisterDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }
} }

View File

@@ -58,7 +58,7 @@ internal sealed partial class HutaoPassportResetPasswordDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -70,7 +70,7 @@ internal sealed partial class HutaoPassportResetUsernameDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -60,7 +60,7 @@ internal sealed partial class HutaoPassportUnregisterDialog : ContentDialog
return; return;
} }
messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessage())); messenger.Send(InfoBarMessage.Information(response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -17,6 +17,7 @@
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/> <RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions> </Grid.RowDefinitions>
<TextBlock <TextBlock
@@ -31,8 +32,17 @@
Text="{x:Bind Tool.Description, Mode=OneWay}" Text="{x:Bind Tool.Description, Mode=OneWay}"
TextWrapping="Wrap"/> TextWrapping="Wrap"/>
<StackPanel Grid.Row="2" Orientation="Horizontal" Spacing="8">
<TextBlock
Style="{StaticResource BodyTextBlockStyle}"
Text="{shuxm:ResourceString Name=ViewDialogThirdPartyToolVersion}"/>
<TextBlock
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind Tool.Version, Mode=OneWay}"/>
</StackPanel>
<ProgressBar <ProgressBar
Grid.Row="2" Grid.Row="3"
Height="4" Height="4"
IsIndeterminate="{x:Bind IsDownloading.Value, Mode=OneWay, FallbackValue=False}" IsIndeterminate="{x:Bind IsDownloading.Value, Mode=OneWay, FallbackValue=False}"
Visibility="{x:Bind IsDownloading.Value, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/> Visibility="{x:Bind IsDownloading.Value, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/>

View File

@@ -39,8 +39,15 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog
{ {
IsDownloading = true; IsDownloading = true;
// 检查工具是否已下载 if (tool is null)
if (tool is not null && !thirdPartyToolService.IsToolDownloaded(tool)) {
return;
}
// 检查工具是否需要下载或更新
bool needDownload = !thirdPartyToolService.IsToolDownloaded(tool) || thirdPartyToolService.NeedsUpdate(tool);
if (needDownload)
{ {
// 下载工具 // 下载工具
bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false); bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false);
@@ -53,15 +60,12 @@ internal sealed partial class ThirdPartyToolDialog : ContentDialog
} }
// 启动工具 // 启动工具
if (tool is not null) bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false);
if (launchSuccess)
{ {
bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false); await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
if (launchSuccess) Hide();
{ return;
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
Hide();
return;
}
} }
} }
catch (Exception ex) catch (Exception ex)

View File

@@ -268,7 +268,7 @@
<TextBlock> <TextBlock>
<TextBlock.Inlines> <TextBlock.Inlines>
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementIHaveReadText}"/> <Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementIHaveReadText}"/>
<Hyperlink NavigateUri="https://github.com/DGP-Studio/Snap.Hutao/blob/main/LICENSE"> <Hyperlink NavigateUri="https://github.com/wangdage12/Snap.Hutao/blob/main/LICENSE">
<Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementOpenSourceLicense}"/> <Run Text="{shuxm:ResourceString Name=ViewGuideStepAgreementOpenSourceLicense}"/>
</Hyperlink> </Hyperlink>
</TextBlock.Inlines> </TextBlock.Inlines>

View File

@@ -198,6 +198,11 @@
Opacity="0.7" Opacity="0.7"
Style="{StaticResource CaptionTextBlockStyle}" Style="{StaticResource CaptionTextBlockStyle}"
Text="{Binding Description}"/> Text="{Binding Description}"/>
<TextBlock
Opacity="0.7"
Style="{StaticResource CaptionTextBlockStyle}"
Text="{Binding RelatedAvatarLine}"
Visibility="{Binding RelatedAvatarLine, Converter={StaticResource EmptyObjectToVisibilityConverter}}"/>
</StackPanel> </StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal"> <StackPanel Grid.Column="2" Orientation="Horizontal">
@@ -227,7 +232,69 @@
</DataTemplate> </DataTemplate>
<DataTemplate x:Key="StatisticsItemTemplate" x:DataType="shvcu:StatisticsCultivateItem"> <DataTemplate x:Key="StatisticsItemTemplate" x:DataType="shvcu:StatisticsCultivateItem">
<shuxcc:HorizontalCard Background="{Binding IsToday, Converter={ThemeResource BoolToStatisticsBrushSelector}}"> <Border Background="Transparent">
<shuxcc:HorizontalCard
Background="{Binding IsToday, Converter={ThemeResource BoolToStatisticsBrushSelector}}"
IsRightTapEnabled="True"
IsTabStop="True"
UseSystemFocusVisuals="True">
<FrameworkElement.ContextFlyout>
<Flyout FlyoutPresenterStyle="{ThemeResource FlyoutPresenterPadding0Style}">
<ScrollViewer MaxHeight="320" MinWidth="220" Padding="12,10">
<ItemsControl ItemsSource="{Binding StatisticsConsumerMenuLines, Mode=OneWay}">
<ItemsControl.ItemTemplate>
<DataTemplate x:DataType="shvcu:StatisticsConsumerMenuLine">
<Grid Margin="0,3">
<TextBlock
Style="{ThemeResource BodyTextBlockStyle}"
Text="{x:Bind PlainMessage, Mode=OneWay}"
TextWrapping="WrapWholeWords"
Visibility="{x:Bind IsPlainMessage, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/>
<StackPanel
Orientation="Horizontal"
Spacing="4"
VerticalAlignment="Center"
Visibility="{x:Bind ShowRichRow, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<shuxc:ItemIcon
Width="28"
Height="28"
Icon="{x:Bind LeadingIcon, Mode=OneWay}"
Quality="{x:Bind LeadingQuality, Mode=OneWay}"/>
<TextBlock
VerticalAlignment="Center"
Style="{ThemeResource BodyTextBlockStyle}"
Text="{x:Bind FirstName, Mode=OneWay}"/>
<StackPanel
Orientation="Horizontal"
Spacing="4"
VerticalAlignment="Center"
Visibility="{x:Bind HasSecondIcon, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
VerticalAlignment="Center"
Style="{ThemeResource BodyTextBlockStyle}"
Text="{x:Bind BetweenSeparator, Mode=OneWay}"/>
<shuxc:ItemIcon
Width="28"
Height="28"
Icon="{x:Bind SecondIcon, Mode=OneWay}"
Quality="{x:Bind SecondQuality, Mode=OneWay}"/>
<TextBlock
VerticalAlignment="Center"
Style="{ThemeResource BodyTextBlockStyle}"
Text="{x:Bind SecondName, Mode=OneWay}"/>
</StackPanel>
<TextBlock
VerticalAlignment="Center"
Style="{ThemeResource BodyTextBlockStyle}"
Text="{x:Bind CountSuffix, Mode=OneWay}"/>
</StackPanel>
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Flyout>
</FrameworkElement.ContextFlyout>
<shuxcc:HorizontalCard.Left> <shuxcc:HorizontalCard.Left>
<Grid Grid.Column="0"> <Grid Grid.Column="0">
<shuxc:ItemIcon <shuxc:ItemIcon
@@ -256,14 +323,44 @@
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{Binding Inner.Name}" Text="{Binding Inner.Name}"
TextTrimming="CharacterEllipsis"/> TextTrimming="CharacterEllipsis"/>
<TextBlock <StackPanel
Grid.Column="1" Grid.Column="1"
Margin="0,0,0,0"
VerticalAlignment="Center" VerticalAlignment="Center"
Text="{Binding FormattedCount}"/> Orientation="Horizontal"
Spacing="0">
<TextBlock
VerticalAlignment="Center"
Text="{Binding FormattedCount}"
Visibility="{Binding ShowNonMergeCompactCount, Converter={StaticResource BoolToVisibilityConverter}}"/>
<StackPanel
Orientation="Horizontal"
Spacing="0"
Visibility="{Binding ShowMergeSpacedWithoutParen, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock VerticalAlignment="Center" Text="{Binding DisplayCurrent, Mode=OneWay}"/>
<TextBlock VerticalAlignment="Center" Text="{Binding SlashCountSuffix, Mode=OneWay}"/>
</StackPanel>
<StackPanel
Orientation="Horizontal"
Spacing="0"
Visibility="{Binding ShowMergeInventoryParen, Converter={StaticResource BoolToVisibilityConverter}}">
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource SystemFillColorCriticalBrush}"
Text="{Binding DisplayCurrent, Mode=OneWay}"
Visibility="{Binding MergeDisplayLeadUseRed, Converter={StaticResource BoolToVisibilityConverter}}"/>
<TextBlock
VerticalAlignment="Center"
Foreground="{ThemeResource SystemFillColorSuccessBrush}"
Text="{Binding DisplayCurrent, Mode=OneWay}"
Visibility="{Binding MergeDisplayLeadUseGreen, Converter={StaticResource BoolToVisibilityConverter}}"/>
<TextBlock VerticalAlignment="Center" Text="{Binding RawInventoryParenthetical, Mode=OneWay}"/>
<TextBlock VerticalAlignment="Center" Text="{Binding SlashCountSuffixForParen, Mode=OneWay}"/>
</StackPanel>
</StackPanel>
</Grid> </Grid>
</shuxcc:HorizontalCard.Right> </shuxcc:HorizontalCard.Right>
</shuxcc:HorizontalCard> </shuxcc:HorizontalCard>
</Border>
</DataTemplate> </DataTemplate>
<DataTemplate x:Key="InventoryItemTemplate" x:DataType="shvcu:InventoryItemView"> <DataTemplate x:Key="InventoryItemTemplate" x:DataType="shvcu:InventoryItemView">
@@ -374,7 +471,13 @@
CommandParameter="{Binding Projects.CurrentItem, Mode=OneWay}" CommandParameter="{Binding Projects.CurrentItem, Mode=OneWay}"
Icon="{shuxm:FontIcon Glyph=&#xE894;}" Icon="{shuxm:FontIcon Glyph=&#xE894;}"
Label="{shuxm:ResourceString Name=ViewPageCultivationClearInventory}"/> Label="{shuxm:ResourceString Name=ViewPageCultivationClearInventory}"/>
<AppBarButton Icon="{shuxm:FontIcon Glyph=&#xE895;}" Label="{shuxm:ResourceString Name=ViewPageCultivationRefreshInventory}"> <AppBarButton
Command="{Binding SyncAllAvatarsAndWeaponsCommand}"
Icon="{shuxm:FontIcon Glyph=&#xE72C;}"
Label="{shuxm:ResourceString Name=ViewPageCultivationSyncAllAvatarsAndWeapons}"/>
<AppBarButton
Icon="{shuxm:FontIcon Glyph=&#xE895;}"
Label="{shuxm:ResourceString Name=ViewPageCultivationRefreshInventory}">
<AppBarButton.Flyout> <AppBarButton.Flyout>
<MenuFlyout Placement="BottomEdgeAlignedRight"> <MenuFlyout Placement="BottomEdgeAlignedRight">
<MenuFlyoutItem <MenuFlyoutItem
@@ -388,6 +491,10 @@
</MenuFlyout> </MenuFlyout>
</AppBarButton.Flyout> </AppBarButton.Flyout>
</AppBarButton> </AppBarButton>
<AppBarToggleButton
Icon="{shuxm:FontIcon Glyph=&#xE8B7;}"
IsChecked="{Binding SyncInventoryByCalculatorToAllProjects, Mode=TwoWay}"
Label="{shuxm:ResourceString Name=ViewPageCultivationRefreshInventoryAllPlansShortLabel}"/>
<AppBarButton <AppBarButton
Command="{Binding AddProjectCommand}" Command="{Binding AddProjectCommand}"
Icon="{shuxm:FontIcon Glyph=&#xE710;}" Icon="{shuxm:FontIcon Glyph=&#xE710;}"
@@ -482,6 +589,25 @@
IsChecked="{Binding IncompleteFirst, Mode=TwoWay}" IsChecked="{Binding IncompleteFirst, Mode=TwoWay}"
Label="{shuxm:ResourceString Name=ViewPageCultivationMaterialListIncompleteFirstLabel}" Label="{shuxm:ResourceString Name=ViewPageCultivationMaterialListIncompleteFirstLabel}"
Visibility="{Binding ElementName=MaterialListPivot, Path=SelectedItem.Tag, Converter={StaticResource MaterialListSelectedItemIsStatisticsViewConverter}}"/> Visibility="{Binding ElementName=MaterialListPivot, Path=SelectedItem.Tag, Converter={StaticResource MaterialListSelectedItemIsStatisticsViewConverter}}"/>
<AppBarToggleButton
Command="{Binding RefreshStatisticsItemsCommand}"
Icon="{shuxm:FontIcon Glyph=&#xE7C3;}"
IsChecked="{Binding MergeUpgradeMaterials, Mode=TwoWay}"
Label="{shuxm:ResourceString Name=ViewPageCultivationMergeUpgradeMaterialsLabel}"
Visibility="{Binding ElementName=MaterialListPivot, Path=SelectedItem.Tag, Converter={StaticResource MaterialListSelectedItemIsStatisticsViewConverter}}"/>
<AppBarToggleButton
Command="{Binding RefreshStatisticsItemsCommand}"
Icon="{shuxm:FontIcon Glyph=&#xE9A1;}"
IsChecked="{Binding TalentSynthCritTenPercent, Mode=TwoWay}"
IsEnabled="{Binding MergeUpgradeMaterials}"
Label="{shuxm:ResourceString Name=ViewPageCultivationTalentSynthCritTenPercentLabel}"
Visibility="{Binding ElementName=MaterialListPivot, Path=SelectedItem.Tag, Converter={StaticResource MaterialListSelectedItemIsStatisticsViewConverter}}"/>
<AppBarToggleButton
Command="{Binding RefreshStatisticsItemsCommand}"
Icon="{shuxm:FontIcon Glyph=&#xE8FD;}"
IsChecked="{Binding WeeklyBossMaterialInterchange, Mode=TwoWay}"
Label="{shuxm:ResourceString Name=ViewPageCultivationWeeklyBossMaterialInterchangeLabel}"
Visibility="{Binding ElementName=MaterialListPivot, Path=SelectedItem.Tag, Converter={StaticResource MaterialListSelectedItemIsStatisticsViewConverter}}"/>
<AppBarButton Icon="{shuxm:FontIcon Glyph=&#xE946;}" Label="{shuxm:ResourceString Name=ViewPageCultivationMaterialListResinStatisticsLabel}"> <AppBarButton Icon="{shuxm:FontIcon Glyph=&#xE946;}" Label="{shuxm:ResourceString Name=ViewPageCultivationMaterialListResinStatisticsLabel}">
<AppBarButton.Flyout> <AppBarButton.Flyout>
<Flyout FlyoutPresenterStyle="{ThemeResource FlyoutPresenterPadding0Style}" Placement="BottomEdgeAlignedRight"> <Flyout FlyoutPresenterStyle="{ThemeResource FlyoutPresenterPadding0Style}" Placement="BottomEdgeAlignedRight">
@@ -617,7 +743,6 @@
</Style> </Style>
</GridView.ItemContainerStyle> </GridView.ItemContainerStyle>
</GridView> </GridView>
</Border> </Border>
</Border> </Border>
</PivotItem> </PivotItem>

View File

@@ -93,13 +93,13 @@
<cwcont:SettingsExpander.Items> <cwcont:SettingsExpander.Items>
<cwcont:SettingsCard <cwcont:SettingsCard
Command="{Binding NavigateToUriCommand}" Command="{Binding NavigateToUriCommand}"
CommandParameter="https://github.com/DGP-Studio/Snap.Hutao/issues/new/choose" CommandParameter="https://github.com/wangdage12/Snap.Hutao"
Description="{shuxm:ResourceString Name=ViewPageFeedbackGithubIssuesDescription}" Description="{shuxm:ResourceString Name=ViewPageFeedbackGithubIssuesDescription}"
Header="GitHub Issues" Header="GitHub Issues"
IsClickEnabled="True"/> IsClickEnabled="True"/>
<cwcont:SettingsCard <cwcont:SettingsCard
Command="{Binding NavigateToUriCommand}" Command="{Binding NavigateToUriCommand}"
CommandParameter="https://status.snapgenshin.cn/status" CommandParameter="https://stats.uptimerobot.com/fHxWxdxK61"
Description="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusDescription}" Description="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusDescription}"
Header="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusHeader}" Header="{shuxm:ResourceString Name=ViewPageFeedbackServerStatusHeader}"
IsClickEnabled="True"/> IsClickEnabled="True"/>

View File

@@ -9,24 +9,26 @@ using Snap.Hutao.Core.Setting;
using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model; using Snap.Hutao.Model;
using Snap.Hutao.Model.Calculable; using Snap.Hutao.Model.Calculable;
using Snap.Hutao.Model.Cultivation;
using Snap.Hutao.Model.Entity.Primitive; using Snap.Hutao.Model.Entity.Primitive;
using Snap.Hutao.Service; using Snap.Hutao.Service;
using Snap.Hutao.Service.AvatarInfo; using Snap.Hutao.Service.AvatarInfo;
using Snap.Hutao.Service.AvatarInfo.Factory; using Snap.Hutao.Service.AvatarInfo.Factory;
using Snap.Hutao.Service.Cultivation; using Snap.Hutao.Service.Cultivation;
using Snap.Hutao.Service.Cultivation.Consumption; using Snap.Hutao.Service.Cultivation.Consumption;
using BatchCultivateResult = Snap.Hutao.Service.Cultivation.BatchCultivateResult;
using Snap.Hutao.Service.Cultivation.Offline; using Snap.Hutao.Service.Cultivation.Offline;
using Snap.Hutao.Service.Metadata.ContextAbstraction; using Snap.Hutao.Service.Metadata.ContextAbstraction;
using Snap.Hutao.Service.Notification; using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User; using Snap.Hutao.Service.User;
using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox; using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox;
using Snap.Hutao.UI.Xaml.View.Dialog; using Snap.Hutao.UI.Xaml.View.Dialog;
using Snap.Hutao.ViewModel.Cultivation;
using Snap.Hutao.ViewModel.User; using Snap.Hutao.ViewModel.User;
using Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate; using Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Globalization; using System.Globalization;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using CalculatorAvatarPromotionDelta = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.AvatarPromotionDelta;
using CalculatorBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption; using CalculatorBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption;
using CalculatorConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.Consumption; using CalculatorConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.Consumption;
using CalculatorItemHelper = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.ItemHelper; using CalculatorItemHelper = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.ItemHelper;
@@ -39,6 +41,7 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
{ {
private readonly ExclusiveTokenProvider refreshTokenProvider = new(); private readonly ExclusiveTokenProvider refreshTokenProvider = new();
private readonly AvatarPropertyViewModelScopeContext scopeContext; private readonly AvatarPropertyViewModelScopeContext scopeContext;
private readonly IAvatarPropertyBatchCultivateService avatarPropertyBatchCultivateService;
private SummaryFactoryMetadataContext? metadataContext; private SummaryFactoryMetadataContext? metadataContext;
@@ -227,7 +230,10 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
if (!await SaveCultivationAsync(batchConsumption.Items.Single(), deltaOptions).ConfigureAwait(false)) if (!await SaveCultivationAsync(batchConsumption.Items.Single(), deltaOptions).ConfigureAwait(false))
{ {
scopeContext.Messenger.Send(InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning)); scopeContext.Messenger.Send(InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning));
return;
} }
scopeContext.Messenger.Send(CultivationProjectEntriesChangedMessage.Empty);
} }
[Command("BatchCultivateCommand")] [Command("BatchCultivateCommand")]
@@ -268,52 +274,24 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
targetAvatars = [.. avatars.Source]; targetAvatars = [.. avatars.Source];
} }
CultivatePromotionDeltaBatchDialog dialog = await scopeContext.ContentDialogFactory ArgumentNullException.ThrowIfNull(metadataContext);
.CreateInstanceAsync<CultivatePromotionDeltaBatchDialog>(scopeContext.ServiceProvider) BatchCultivateResult? batchResult = await avatarPropertyBatchCultivateService
.ExecuteAsync(metadataContext, targetAvatars, CancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (await dialog.GetPromotionDeltaBaselineAsync().ConfigureAwait(false) is not (true, { } baseline)) if (batchResult is not { } result)
{ {
return; return;
} }
ArgumentNullException.ThrowIfNull(baseline.Delta.Weapon); if (result.StopReason is not BatchCultivateStopReason.None)
ContentDialog progressDialog = await scopeContext.ContentDialogFactory
.CreateForIndeterminateProgressAsync(SH.ViewModelAvatarPropertyBatchCultivateProgressTitle)
.ConfigureAwait(false);
BatchCultivateResult result = default;
using (await scopeContext.ContentDialogFactory.BlockAsync(progressDialog).ConfigureAwait(false))
{ {
ImmutableArray<CalculatorAvatarPromotionDelta>.Builder deltasBuilder = ImmutableArray.CreateBuilder<CalculatorAvatarPromotionDelta>(); scopeContext.Messenger.Send(InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning));
foreach (AvatarView avatar in targetAvatars) return;
{
if (!baseline.Delta.TryGetNonErrorCopy(avatar, out CalculatorAvatarPromotionDelta? copy))
{
++result.SkippedCount;
continue;
}
deltasBuilder.Add(copy);
}
ImmutableArray<CalculatorAvatarPromotionDelta> deltas = deltasBuilder.ToImmutable();
ArgumentNullException.ThrowIfNull(metadataContext);
CalculatorBatchConsumption batchConsumption = OfflineCalculator.CalculateBatchConsumption(deltas, metadataContext);
foreach ((CalculatorConsumption consumption, CalculatorAvatarPromotionDelta delta) in batchConsumption.Items.Zip(deltas))
{
if (!await SaveCultivationAsync(consumption, new(delta, baseline.Strategy), true).ConfigureAwait(false))
{
break;
}
++result.SucceedCount;
}
} }
scopeContext.Messenger.Send(CultivationProjectEntriesChangedMessage.Empty);
InfoBarMessage message = result.SkippedCount > 0 InfoBarMessage message = result.SkippedCount > 0
? InfoBarMessage.Warning(SH.FormatViewModelCultivationBatchAddIncompleted(result.SucceedCount, result.SkippedCount)) ? InfoBarMessage.Warning(SH.FormatViewModelCultivationBatchAddIncompleted(result.SucceedCount, result.SkippedCount))
: InfoBarMessage.Success(SH.FormatViewModelCultivationBatchAddCompleted(result.SucceedCount, result.SkippedCount)); : InfoBarMessage.Success(SH.FormatViewModelCultivationBatchAddCompleted(result.SucceedCount, result.SkippedCount));
@@ -335,9 +313,9 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
Strategy = options.Strategy, Strategy = options.Strategy,
}; };
ConsumptionSaveResultKind avatarSaveKind = await scopeContext.CultivationService.SaveConsumptionAsync(avatarInput).ConfigureAwait(false); ConsumptionSaveResult avatarSave = await scopeContext.CultivationService.SaveConsumptionAsync(avatarInput).ConfigureAwait(false);
InfoBarMessage? avatarMessage = avatarSaveKind switch InfoBarMessage? avatarMessage = avatarSave.Kind switch
{ {
ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning), ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning),
ConsumptionSaveResultKind.Skipped => isBatch ? default : InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint), ConsumptionSaveResultKind.Skipped => isBatch ? default : InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint),
@@ -351,13 +329,29 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
scopeContext.Messenger.Send(avatarMessage); scopeContext.Messenger.Send(avatarMessage);
} }
if (avatarSaveKind is ConsumptionSaveResultKind.NoProject) if (avatarSave.Kind is ConsumptionSaveResultKind.NoProject)
{ {
return false; return false;
} }
ArgumentNullException.ThrowIfNull(options.Delta.Weapon); ArgumentNullException.ThrowIfNull(options.Delta.Weapon);
Guid? relatedAvatarEntryId = avatarSave.CreatedEntryInnerId;
if (relatedAvatarEntryId is null && avatarSave.Kind is ConsumptionSaveResultKind.Skipped)
{
relatedAvatarEntryId = await scopeContext.CultivationService.TryGetAvatarCultivateEntryInnerIdAsync(options.Delta.AvatarId).ConfigureAwait(false);
}
if (relatedAvatarEntryId is null && avatarSave.Kind is ConsumptionSaveResultKind.NoItem)
{
relatedAvatarEntryId = await scopeContext.CultivationService.TryGetAvatarCultivateEntryInnerIdAsync(options.Delta.AvatarId).ConfigureAwait(false);
}
if (relatedAvatarEntryId is null && !consumption.WeaponConsume.IsEmpty)
{
relatedAvatarEntryId = await scopeContext.CultivationService.EnsureAvatarAssociationStubAsync(options.Delta.AvatarId, levelInformation).ConfigureAwait(false);
}
InputConsumption weaponInput = new() InputConsumption weaponInput = new()
{ {
Type = CultivateType.Weapon, Type = CultivateType.Weapon,
@@ -365,10 +359,11 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
Items = consumption.WeaponConsume, Items = consumption.WeaponConsume,
LevelInformation = levelInformation, LevelInformation = levelInformation,
Strategy = options.Strategy, Strategy = options.Strategy,
RelatedEntryId = relatedAvatarEntryId,
}; };
ConsumptionSaveResultKind weaponSaveKind = await scopeContext.CultivationService.SaveConsumptionAsync(weaponInput).ConfigureAwait(false); ConsumptionSaveResult weaponSave = await scopeContext.CultivationService.SaveConsumptionAsync(weaponInput).ConfigureAwait(false);
InfoBarMessage? weaponMessage = weaponSaveKind switch InfoBarMessage? weaponMessage = weaponSave.Kind switch
{ {
ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning), ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning),
ConsumptionSaveResultKind.Skipped => isBatch ? default : InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint), ConsumptionSaveResultKind.Skipped => isBatch ? default : InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint),
@@ -382,7 +377,7 @@ internal sealed partial class AvatarPropertyViewModel : Abstraction.ViewModel, I
scopeContext.Messenger.Send(weaponMessage); scopeContext.Messenger.Send(weaponMessage);
} }
return weaponSaveKind is not ConsumptionSaveResultKind.NoProject; return weaponSave.Kind is not ConsumptionSaveResultKind.NoProject;
} }
[Command("ExportToTextCommand")] [Command("ExportToTextCommand")]

View File

@@ -14,7 +14,7 @@ namespace Snap.Hutao.ViewModel.Cultivation;
internal sealed partial class CultivateEntryView : Item, IPropertyValuesProvider internal sealed partial class CultivateEntryView : Item, IPropertyValuesProvider
{ {
private CultivateEntryView(CultivateEntry entry, Item item, ImmutableArray<CultivateItemView> items) private CultivateEntryView(CultivateEntry entry, Item item, ImmutableArray<CultivateItemView> items, string? relatedAvatarName)
{ {
Id = entry.Id; Id = entry.Id;
EntryId = entry.InnerId; EntryId = entry.InnerId;
@@ -25,6 +25,8 @@ internal sealed partial class CultivateEntryView : Item, IPropertyValuesProvider
Items = items; Items = items;
Type = entry.Type; Type = entry.Type;
RelatedAvatarLine = relatedAvatarName is { } name ? SH.FormatViewModelCultivationEntryRelatedAvatar(name) : null;
Description = ParseDescription(entry); Description = ParseDescription(entry);
IsToday = items.Any(i => i.IsToday); IsToday = items.Any(i => i.IsToday);
RotationalItemIds = [.. items.Where(i => i.DaysOfWeek is not DaysOfWeek.Any).Select(i => i.Inner.Id)]; RotationalItemIds = [.. items.Where(i => i.DaysOfWeek is not DaysOfWeek.Any).Select(i => i.Inner.Id)];
@@ -110,12 +112,17 @@ internal sealed partial class CultivateEntryView : Item, IPropertyValuesProvider
public string Description { get; } public string Description { get; }
/// <summary>
/// 武器条目在养成计算中与角色条目关联时的展示文案(含本地化前缀)。
/// </summary>
public string? RelatedAvatarLine { get; }
internal Guid EntryId { get; } internal Guid EntryId { get; }
internal CultivateType Type { get; } internal CultivateType Type { get; }
public static CultivateEntryView Create(CultivateEntry entry, Item item, ImmutableArray<CultivateItemView> items) public static CultivateEntryView Create(CultivateEntry entry, Item item, ImmutableArray<CultivateItemView> items, string? relatedAvatarName = null)
{ {
return new(entry, item, items); return new(entry, item, items, relatedAvatarName);
} }
} }

View File

@@ -0,0 +1,16 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.ViewModel.Cultivation;
/// <summary>
/// 当前养成计划的条目在 UI 外被批量变更(例如从「我的角色」同步)后,通知养成页刷新列表与统计。
/// </summary>
internal sealed class CultivationProjectEntriesChangedMessage
{
public static readonly CultivationProjectEntriesChangedMessage Empty = new();
private CultivationProjectEntriesChangedMessage()
{
}
}

View File

@@ -2,33 +2,40 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Messaging;
using Microsoft.UI.Xaml.Controls; using Microsoft.UI.Xaml.Controls;
using Snap.Hutao.Core; using Snap.Hutao.Core;
using Snap.Hutao.Core.Database; using Snap.Hutao.Core.Database;
using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.Logging; using Snap.Hutao.Core.Logging;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.Factory.ContentDialog; using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Model.Entity; using Snap.Hutao.Model.Entity;
using Snap.Hutao.Service.AvatarInfo;
using Snap.Hutao.Service.AvatarInfo.Factory;
using Snap.Hutao.Service.Cultivation; using Snap.Hutao.Service.Cultivation;
using Snap.Hutao.Service.Inventory; using Snap.Hutao.Service.Inventory;
using Snap.Hutao.Service.Metadata; using Snap.Hutao.Service.Metadata;
using Snap.Hutao.Service.Metadata.ContextAbstraction; using Snap.Hutao.Service.Metadata.ContextAbstraction;
using Snap.Hutao.Service.Navigation; using Snap.Hutao.Service.Navigation;
using Snap.Hutao.Service.Notification; using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.User;
using Snap.Hutao.Service.Yae; using Snap.Hutao.Service.Yae;
using Snap.Hutao.ViewModel.AvatarProperty;
using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox; using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox;
using Snap.Hutao.UI.Xaml.Data; using Snap.Hutao.UI.Xaml.Data;
using Snap.Hutao.UI.Xaml.View.Dialog; using Snap.Hutao.UI.Xaml.View.Dialog;
using Snap.Hutao.ViewModel.Game; using Snap.Hutao.ViewModel.Game;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using BatchCultivateResult = Snap.Hutao.Service.Cultivation.BatchCultivateResult;
namespace Snap.Hutao.ViewModel.Cultivation; namespace Snap.Hutao.ViewModel.Cultivation;
[SuppressMessage("", "CA1001")] [SuppressMessage("", "CA1001")]
[BindableCustomPropertyProvider] [BindableCustomPropertyProvider]
[Service(ServiceLifetime.Scoped)] [Service(ServiceLifetime.Scoped)]
internal sealed partial class CultivationViewModel : Abstraction.ViewModel internal sealed partial class CultivationViewModel : Abstraction.ViewModel, IRecipient<CultivationProjectEntriesChangedMessage>
{ {
private readonly ExclusiveTokenProvider exclusiveTokenProvider = new(); private readonly ExclusiveTokenProvider exclusiveTokenProvider = new();
@@ -36,6 +43,9 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
private readonly ICultivationService cultivationService; private readonly ICultivationService cultivationService;
private readonly INavigationService navigationService; private readonly INavigationService navigationService;
private readonly IInventoryService inventoryService; private readonly IInventoryService inventoryService;
private readonly IAvatarInfoService avatarInfoService;
private readonly IAvatarPropertyBatchCultivateService avatarPropertyBatchCultivateService;
private readonly IUserService userService;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
private readonly IMetadataService metadataService; private readonly IMetadataService metadataService;
private readonly ITaskContext taskContext; private readonly ITaskContext taskContext;
@@ -70,6 +80,18 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
[ObservableProperty] [ObservableProperty]
public partial bool IncompleteFirst { get; set; } public partial bool IncompleteFirst { get; set; }
[ObservableProperty]
public partial bool MergeUpgradeMaterials { get; set; } = LocalSetting.Get(SettingKeys.CultivationStatisticsMergeUpgradeMaterials, false);
[ObservableProperty]
public partial bool TalentSynthCritTenPercent { get; set; } = LocalSetting.Get(SettingKeys.CultivationStatisticsTalentSynthCritTenPercent, false);
[ObservableProperty]
public partial bool WeeklyBossMaterialInterchange { get; set; } = LocalSetting.Get(SettingKeys.CultivationStatisticsWeeklyBossMaterialInterchange, false);
[ObservableProperty]
public partial bool SyncInventoryByCalculatorToAllProjects { get; set; } = LocalSetting.Get(SettingKeys.CultivationRefreshInventoryByCalculatorToAllProjects, false);
[ObservableProperty] [ObservableProperty]
public partial ObservableCollection<StatisticsCultivateItem>? StatisticsItems { get; set; } public partial ObservableCollection<StatisticsCultivateItem>? StatisticsItems { get; set; }
@@ -81,6 +103,9 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
protected override async ValueTask<bool> LoadOverrideAsync(CancellationToken token) protected override async ValueTask<bool> LoadOverrideAsync(CancellationToken token)
{ {
messenger.UnregisterAll(this);
messenger.Register<CultivationProjectEntriesChangedMessage>(this);
if (!await metadataService.InitializeAsync().ConfigureAwait(false)) if (!await metadataService.InitializeAsync().ConfigureAwait(false))
{ {
return false; return false;
@@ -111,6 +136,7 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
protected override void UninitializeOverride() protected override void UninitializeOverride()
{ {
messenger.UnregisterAll(this);
using (Projects?.SuppressChangeCurrentItem()) using (Projects?.SuppressChangeCurrentItem())
{ {
Projects = default; Projects = default;
@@ -122,6 +148,27 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
UpdateEntryCollectionAsync(Projects?.CurrentItem).SafeForget(); UpdateEntryCollectionAsync(Projects?.CurrentItem).SafeForget();
} }
partial void OnSyncInventoryByCalculatorToAllProjectsChanged(bool value)
{
LocalSetting.Set(SettingKeys.CultivationRefreshInventoryByCalculatorToAllProjects, value);
}
public void Receive(CultivationProjectEntriesChangedMessage _)
{
ReceiveProjectEntriesChangedAsync().SafeForget();
}
private async ValueTask ReceiveProjectEntriesChangedAsync()
{
await taskContext.SwitchToMainThreadAsync();
if (Projects?.CurrentItem is null)
{
return;
}
await UpdateEntryCollectionAsync(Projects.CurrentItem).ConfigureAwait(false);
}
[Command("AddProjectCommand")] [Command("AddProjectCommand")]
private async Task AddProjectAsync() private async Task AddProjectAsync()
{ {
@@ -292,7 +339,9 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
using (await contentDialogFactory.BlockAsync(dialog).ConfigureAwait(false)) using (await contentDialogFactory.BlockAsync(dialog).ConfigureAwait(false))
{ {
await inventoryService.RefreshInventoryAsync(RefreshOptions.CreateForWebCalculator(Projects.CurrentItem, metadataContext)).ConfigureAwait(false); await inventoryService
.RefreshInventoryAsync(RefreshOptions.CreateForWebCalculator(Projects.CurrentItem, metadataContext, SyncInventoryByCalculatorToAllProjects))
.ConfigureAwait(false);
await UpdateInventoryItemsAsync().ConfigureAwait(false); await UpdateInventoryItemsAsync().ConfigureAwait(false);
await UpdateStatisticsItemsAsync().ConfigureAwait(false); await UpdateStatisticsItemsAsync().ConfigureAwait(false);
@@ -300,6 +349,63 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
} }
} }
[Command("SyncAllAvatarsAndWeaponsCommand")]
private async Task SyncAllAvatarsAndWeaponsAsync()
{
SentrySdk.AddBreadcrumb(BreadcrumbFactory2.CreateUI("Sync all avatars and weapons to cultivation", "CultivationViewModel.Command", []));
if (Projects?.CurrentItem is null)
{
return;
}
if (await userService.GetCurrentUserAndUidAsync().ConfigureAwait(false) is not { } userAndUid)
{
messenger.Send(InfoBarMessage.Warning(SH.MustSelectUserAndUid));
return;
}
SummaryFactoryMetadataContext summaryContext = await metadataService.GetContextAsync<SummaryFactoryMetadataContext>(CancellationToken).ConfigureAwait(false);
Summary? summary = await avatarInfoService.GetSummaryAsync(summaryContext, userAndUid, global::Snap.Hutao.Service.AvatarInfo.RefreshOptionKind.None, CancellationToken).ConfigureAwait(false);
if (summary is not { Avatars: { } avatars })
{
messenger.Send(InfoBarMessage.Warning(SH.ViewPageAvatarPropertyDefaultDescription));
return;
}
if (avatars.Source.Count < 1)
{
messenger.Send(InfoBarMessage.Warning(SH.ViewPageAvatarPropertyDefaultDescription));
return;
}
ImmutableArray<AvatarView> targetAvatars = [.. avatars.Source];
BatchCultivateResult? batchResult = await avatarPropertyBatchCultivateService
.ExecuteAsync(summaryContext, targetAvatars, CancellationToken)
.ConfigureAwait(false);
if (batchResult is not { } result)
{
return;
}
if (result.StopReason is not BatchCultivateStopReason.None)
{
messenger.Send(InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning));
return;
}
messenger.Send(CultivationProjectEntriesChangedMessage.Empty);
InfoBarMessage message = result.SkippedCount > 0
? InfoBarMessage.Warning(SH.FormatViewModelCultivationBatchAddIncompleted(result.SucceedCount, result.SkippedCount))
: InfoBarMessage.Success(SH.FormatViewModelCultivationBatchAddCompleted(result.SucceedCount, result.SkippedCount));
messenger.Send(message);
}
[Command("ClearInventoryCommand")] [Command("ClearInventoryCommand")]
private async Task ClearInventoryAsync(CultivateProject? project) private async Task ClearInventoryAsync(CultivateProject? project)
{ {
@@ -356,12 +462,21 @@ internal sealed partial class CultivationViewModel : Abstraction.ViewModel
await taskContext.SwitchToBackgroundAsync(); await taskContext.SwitchToBackgroundAsync();
bool merge = MergeUpgradeMaterials;
bool talentCrit = merge && TalentSynthCritTenPercent;
bool weeklyBoss = WeeklyBossMaterialInterchange;
LocalSetting.Set(SettingKeys.CultivationStatisticsMergeUpgradeMaterials, merge);
LocalSetting.Set(SettingKeys.CultivationStatisticsTalentSynthCritTenPercent, talentCrit);
LocalSetting.Set(SettingKeys.CultivationStatisticsWeeklyBossMaterialInterchange, weeklyBoss);
CancellationToken token = exclusiveTokenProvider.GetNewToken(); CancellationToken token = exclusiveTokenProvider.GetNewToken();
StatisticsCultivateItemCollection statistics; StatisticsCultivateItemCollection statistics;
ResinStatistics resinStatistics; ResinStatistics resinStatistics;
try try
{ {
statistics = await cultivationService.GetStatisticsCultivateItemCollectionAsync(Projects.CurrentItem, metadataContext, token).ConfigureAwait(false); statistics = await cultivationService
.GetStatisticsCultivateItemCollectionAsync(Projects.CurrentItem, metadataContext, new CultivationStatisticsMergeOptions(merge, talentCrit, weeklyBoss), token)
.ConfigureAwait(false);
resinStatistics = await cultivationService.GetResinStatisticsAsync(statistics, token).ConfigureAwait(false); resinStatistics = await cultivationService.GetResinStatisticsAsync(statistics, token).ConfigureAwait(false);
} }
catch (OperationCanceledException) catch (OperationCanceledException)

View File

@@ -0,0 +1,87 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Model.Intrinsic;
namespace Snap.Hutao.ViewModel.Cultivation;
/// <summary>
/// 材料统计右键浮层中一行「未完成」养成条目:名称前展示角色/武器图标。
/// </summary>
internal sealed class StatisticsConsumerMenuLine
{
private StatisticsConsumerMenuLine()
{
}
public bool IsPlainMessage { get; private init; }
public string? PlainMessage { get; private init; }
public bool ShowRichRow => !IsPlainMessage;
public Uri LeadingIcon { get; private init; } = default!;
public QualityType LeadingQuality { get; private init; }
public bool HasSecondIcon { get; private init; }
public Uri SecondIcon { get; private init; } = default!;
public QualityType SecondQuality { get; private init; }
public string FirstName { get; private init; } = string.Empty;
/// <summary>双名称行中间分隔(与武器关联条目的「角色·武器」展示一致,使用间隔号)。</summary>
public string BetweenSeparator { get; private init; } = "\u00B7";
public string? SecondName { get; private init; }
/// <summary>计划需求量后缀,一般为全角括号包裹的数量 <c>12</c>。</summary>
public string CountSuffix { get; private init; } = string.Empty;
public static StatisticsConsumerMenuLine Plain(string message)
{
return new()
{
IsPlainMessage = true,
PlainMessage = message,
};
}
public static StatisticsConsumerMenuLine SingleIcon(Uri icon, QualityType quality, string name, string countSuffix)
{
return new()
{
LeadingIcon = icon,
LeadingQuality = quality,
FirstName = name,
CountSuffix = countSuffix,
HasSecondIcon = false,
SecondIcon = default!,
SecondQuality = QualityType.QUALITY_NONE,
};
}
public static StatisticsConsumerMenuLine AvatarAndWeapon(
Uri avatarIcon,
QualityType avatarQuality,
string avatarName,
Uri weaponIcon,
QualityType weaponQuality,
string weaponName,
string countSuffix)
{
return new()
{
LeadingIcon = avatarIcon,
LeadingQuality = avatarQuality,
FirstName = avatarName,
HasSecondIcon = true,
SecondIcon = weaponIcon,
SecondQuality = weaponQuality,
SecondName = weaponName,
CountSuffix = countSuffix,
};
}
}

View File

@@ -2,6 +2,7 @@
// Licensed under the MIT license. // Licensed under the MIT license.
using Snap.Hutao.Model.Metadata.Item; using Snap.Hutao.Model.Metadata.Item;
using System.Collections.Immutable;
namespace Snap.Hutao.ViewModel.Cultivation; namespace Snap.Hutao.ViewModel.Cultivation;
@@ -29,12 +30,55 @@ internal sealed class StatisticsCultivateItem
public uint Current { get; set; } public uint Current { get; set; }
public bool IsFinished { get => Current >= Count; } /// <summary>
/// 升级材料合并后的展示用持有量;未启用合并时为 <see langword="null"/>。
/// </summary>
public uint? MergeAdjustedCurrent { get; set; }
public string FormattedCount { get => $"{Current}/{Count}"; } /// <summary>
/// 周本材料异梦转化池内调配后的展示用持有量;未启用时为 <see langword="null"/>。在 <see cref="MergeAdjustedCurrent"/> 之后应用。
/// </summary>
public uint? WeeklyBossInterchangeAdjustedCurrent { get; set; }
public uint DisplayCurrent { get => WeeklyBossInterchangeAdjustedCurrent ?? MergeAdjustedCurrent ?? Current; }
public bool IsFinished { get => DisplayCurrent >= Count; }
public string FormattedCount { get => $"{DisplayCurrent}/{Count}"; }
private bool HasStatisticsAdjustedDisplay { get => MergeAdjustedCurrent.HasValue || WeeklyBossInterchangeAdjustedCurrent.HasValue; }
/// <summary>未启用合并展示链时,使用紧凑 <see cref="FormattedCount"/>。</summary>
public bool ShowNonMergeCompactCount { get => !HasStatisticsAdjustedDisplay; }
/// <summary>已合并但合成前后有效持有量与背包数一致,不显示括号,仅「合并后 / 需求」加空格。</summary>
public bool ShowMergeSpacedWithoutParen { get => HasStatisticsAdjustedDisplay && DisplayCurrent == Current; }
/// <summary>合并后有效持有与背包原数不同,显示「合并后 (背包原数)」。</summary>
public bool ShowMergeInventoryParen { get => HasStatisticsAdjustedDisplay && DisplayCurrent != Current; }
/// <summary>首位合并显示量 &gt; 背包原数时着红色(相对原库存变多)。</summary>
public bool MergeDisplayLeadUseRed { get => HasStatisticsAdjustedDisplay && DisplayCurrent > Current; }
/// <summary>首位合并显示量 &lt; 背包原数时着绿色(相对原库存变少,如低档被向上消耗)。</summary>
public bool MergeDisplayLeadUseGreen { get => HasStatisticsAdjustedDisplay && DisplayCurrent < Current; }
/// <summary>背包原数,紧接在合并后数字后,如 <c>(44)</c>。</summary>
public string RawInventoryParenthetical { get => $"({Current})"; }
/// <summary>有括号行:「/需求」紧接括号,如 <c>/55</c>。</summary>
public string SlashCountSuffixForParen { get => $"/{Count}"; }
/// <summary>无括号行:「 / 需求」含空格。</summary>
public string SlashCountSuffix { get => $" / {Count}"; }
public bool IsToday { get => Inner.IsItemOfToday(offset, true); } public bool IsToday { get => Inner.IsItemOfToday(offset, true); }
/// <summary>
/// 材料统计右键浮层中按行展示的「未完成」养成条目(每人一行,名称前为角色/武器图标,需求量以括号标注)。
/// </summary>
public ImmutableArray<StatisticsConsumerMenuLine> StatisticsConsumerMenuLines { get; set; }
internal bool ExcludedFromPresentation { get; set; } internal bool ExcludedFromPresentation { get; set; }
public static StatisticsCultivateItem Create(Material inner, TimeSpan offset) public static StatisticsCultivateItem Create(Material inner, TimeSpan offset)

View File

@@ -0,0 +1,13 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
namespace Snap.Hutao.ViewModel.GachaLog;
internal sealed class GachaLogImportedMessage
{
public static readonly GachaLogImportedMessage Empty = new();
private GachaLogImportedMessage()
{
}
}

View File

@@ -26,7 +26,7 @@ namespace Snap.Hutao.ViewModel.GachaLog;
[BindableCustomPropertyProvider] [BindableCustomPropertyProvider]
[Service(ServiceLifetime.Scoped)] [Service(ServiceLifetime.Scoped)]
internal sealed partial class GachaLogViewModel : Abstraction.ViewModel internal sealed partial class GachaLogViewModel : Abstraction.ViewModel, IRecipient<GachaLogImportedMessage>
{ {
private readonly IContentDialogFactory contentDialogFactory; private readonly IContentDialogFactory contentDialogFactory;
private readonly IServiceProvider serviceProvider; private readonly IServiceProvider serviceProvider;
@@ -127,6 +127,22 @@ internal sealed partial class GachaLogViewModel : Abstraction.ViewModel
UpdateStatisticsAsync(Archives?.CurrentItem).SafeForget(); UpdateStatisticsAsync(Archives?.CurrentItem).SafeForget();
} }
public async void Receive(GachaLogImportedMessage message)
{
await RefreshArchiveCollectionAsync().ConfigureAwait(false);
}
private async ValueTask RefreshArchiveCollectionAsync()
{
using (await EnterCriticalSectionAsync().ConfigureAwait(false))
{
IAdvancedDbCollectionView<GachaArchive> archives = await gachaLogService.RefreshArchiveCollectionAsync().ConfigureAwait(false);
await taskContext.SwitchToMainThreadAsync();
Archives = archives;
Archives.MoveCurrentTo(Archives.Source.SelectedOrFirstOrDefault());
}
}
[Command("RefreshByWebCacheCommand")] [Command("RefreshByWebCacheCommand")]
private async Task RefreshByWebCacheAsync() private async Task RefreshByWebCacheAsync()
{ {

View File

@@ -193,11 +193,11 @@ internal sealed partial class SpiralAbyssViewModel : Abstraction.ViewModel, IRec
{ {
HutaoResponse response = await spiralAbyssClient.UploadRecordAsync(record).ConfigureAwait(false); HutaoResponse response = await spiralAbyssClient.UploadRecordAsync(record).ConfigureAwait(false);
if (response is ILocalizableResponse localizableResponse) if (response is ILocalizableResponse)
{ {
messenger.Send(InfoBarMessage.Any( messenger.Send(InfoBarMessage.Any(
response is { ReturnCode: 0 } ? InfoBarSeverity.Success : InfoBarSeverity.Warning, response is { ReturnCode: 0 } ? InfoBarSeverity.Success : InfoBarSeverity.Warning,
localizableResponse.GetLocalizationMessage())); response.GetLocalizationMessageOrMessage()));
} }
} }

View File

@@ -20,6 +20,7 @@ using Snap.Hutao.Service.Notification;
using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox; using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox;
using Snap.Hutao.UI.Xaml.Data; using Snap.Hutao.UI.Xaml.Data;
using Snap.Hutao.UI.Xaml.View.Dialog; using Snap.Hutao.UI.Xaml.View.Dialog;
using Snap.Hutao.ViewModel.Cultivation;
using System.Collections.Immutable; using System.Collections.Immutable;
using CalculateBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption; using CalculateBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption;
@@ -155,7 +156,8 @@ internal sealed partial class WikiAvatarViewModel : Abstraction.ViewModel
Strategy = deltaOptions.Strategy, Strategy = deltaOptions.Strategy,
}; };
InfoBarMessage? message = await cultivationService.SaveConsumptionAsync(input).ConfigureAwait(false) switch ConsumptionSaveResult result = await cultivationService.SaveConsumptionAsync(input).ConfigureAwait(false);
InfoBarMessage? message = result.Kind switch
{ {
ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning), ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning),
ConsumptionSaveResultKind.Skipped => InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint), ConsumptionSaveResultKind.Skipped => InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint),
@@ -168,6 +170,11 @@ internal sealed partial class WikiAvatarViewModel : Abstraction.ViewModel
{ {
messenger.Send(message); messenger.Send(message);
} }
if (result.Kind is not ConsumptionSaveResultKind.NoProject)
{
messenger.Send(CultivationProjectEntriesChangedMessage.Empty);
}
} }
catch (HutaoException ex) catch (HutaoException ex)
{ {

View File

@@ -19,6 +19,7 @@ using Snap.Hutao.Service.Notification;
using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox; using Snap.Hutao.UI.Xaml.Control.AutoSuggestBox;
using Snap.Hutao.UI.Xaml.Data; using Snap.Hutao.UI.Xaml.Data;
using Snap.Hutao.UI.Xaml.View.Dialog; using Snap.Hutao.UI.Xaml.View.Dialog;
using Snap.Hutao.ViewModel.Cultivation;
using System.Collections.Immutable; using System.Collections.Immutable;
using CalculateBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption; using CalculateBatchConsumption = Snap.Hutao.Web.Hoyolab.Takumi.Event.Calculate.BatchConsumption;
@@ -146,7 +147,8 @@ internal sealed partial class WikiWeaponViewModel : Abstraction.ViewModel
Strategy = deltaOptions.Strategy, Strategy = deltaOptions.Strategy,
}; };
InfoBarMessage? message = await cultivationService.SaveConsumptionAsync(input).ConfigureAwait(false) switch ConsumptionSaveResult result = await cultivationService.SaveConsumptionAsync(input).ConfigureAwait(false);
InfoBarMessage? message = result.Kind switch
{ {
ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning), ConsumptionSaveResultKind.NoProject => InfoBarMessage.Warning(SH.ViewModelCultivationEntryAddWarning),
ConsumptionSaveResultKind.Skipped => InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint), ConsumptionSaveResultKind.Skipped => InfoBarMessage.Information(SH.ViewModelCultivationConsumptionSaveSkippedHint),
@@ -159,6 +161,11 @@ internal sealed partial class WikiWeaponViewModel : Abstraction.ViewModel
{ {
messenger.Send(message); messenger.Send(message);
} }
if (result.Kind is not ConsumptionSaveResultKind.NoProject)
{
messenger.Send(CultivationProjectEntriesChangedMessage.Empty);
}
} }
catch (HutaoException ex) catch (HutaoException ex)
{ {

View File

@@ -6,9 +6,9 @@ namespace Snap.Hutao.Web.Endpoint.Hutao;
[Service(ServiceLifetime.Singleton, typeof(IHutaoEndpoints), Key = HutaoEndpointsKind.Release)] [Service(ServiceLifetime.Singleton, typeof(IHutaoEndpoints), Key = HutaoEndpointsKind.Release)]
internal sealed class HutaoEndpointsForRelease : IHutaoEndpoints internal sealed class HutaoEndpointsForRelease : IHutaoEndpoints
{ {
string IHomaRootAccess.Root { get => "https://htserver.wdg.cloudns.ch/api"; } string IHomaRootAccess.Root { get => "https://htserver.wdg12.work/api"; }
string IInfrastructureRootAccess.Root { get => "https://htserver.wdg.cloudns.ch/api"; } string IInfrastructureRootAccess.Root { get => "https://htserver.wdg12.work/api"; }
string IInfrastructureRawRootAccess.RawRoot { get => "https://htserver.wdg.cloudns.ch/api"; } string IInfrastructureRawRootAccess.RawRoot { get => "https://htserver.wdg12.work/api"; }
} }

View File

@@ -5,7 +5,7 @@ namespace Snap.Hutao.Web.Endpoint.Hutao;
internal static class StaticResourcesEndpoints internal static class StaticResourcesEndpoints
{ {
public static string Root { get => "https://htserver.wdg.cloudns.ch"; } public static string Root { get => "https://htserver.wdg12.work"; }
public static Uri UIIconNone { get; } = StaticRaw("Bg", "UI_Icon_None.png").ToUri(); public static Uri UIIconNone { get; } = StaticRaw("Bg", "UI_Icon_None.png").ToUri();

Some files were not shown because too many files have changed in this diff Show More