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

71 Commits

Author SHA1 Message Date
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
fanbook-wangdage
cb6d728c35 提升版本号、解决CI报错 2026-02-07 13:17:31 +08:00
fanbook-wangdage
f87b80cc9e Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2026-02-07 13:08:35 +08:00
fanbook-wangdage
4b313b134e 新增msi安装界面,修复WebView2权限问题,修复切换服务器时会显示等待进程退出的问题,为页面添加缓存来提示频繁切换页面时的性能 2026-02-07 13:07:51 +08:00
wangdage12
0c775a5d3d Revise README for injection features and dependencies
Updated README to clarify injection functionality and added links to required repositories.
2026-01-29 11:54:32 +08:00
wangdage12
00cd5a8c07 Revise README for installation and server status
Updated installation instructions and server status information.
2026-01-27 14:00:53 +08:00
wangdage12
d93ae2bb83 Update README.md 2026-01-20 11:40:34 +08:00
fanbook-wangdage
2f148488f4 修复工具异步加载问题、添加武器和角色id、提示版本号 2026-01-16 12:33:26 +08:00
fanbook-wangdage
df92894307 支持公告中的发行版字段 2026-01-16 11:39:28 +08:00
fanbook-wangdage
5fad9ad855 提升版本号 2026-01-13 16:49:20 +08:00
fanbook-wangdage
1ed2f4f29e 支持注入时传命令行参数 2026-01-13 16:33:17 +08:00
fanbook-wangdage
db6df72791 添加using 2026-01-13 15:30:51 +08:00
fanbook-wangdage
bd9f188ac1 添加第三方工具功能 2026-01-13 15:17:20 +08:00
fanbook-wangdage
56c36a01ae Merge branches 'main' and 'main' of https://github.com/wangdage12/Snap.Hutao 2026-01-08 13:58:14 +08:00
fanbook-wangdage
da6f248509 支持调用genshin-fps-unlock项目来调整帧率、修复武器id问题 2026-01-08 13:57:17 +08:00
wangdage12
068eb65fef Add Discord server link to README
Added Discord server link for community engagement.
2026-01-01 12:57:42 +08:00
fanbook-wangdage
09a8cded2f Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2025-12-26 18:36:50 +08:00
fanbook-wangdage
c38fdf30d0 修复以管理员权限重启问题 2025-12-26 18:33:20 +08:00
wangdage12
bc1ff03d0a Update README with metadata progress and synchronization
Removed outdated metadata progress details and added synchronization note.
2025-12-22 21:05:40 +08:00
wangdage12
b288860c3b Update README.md 2025-12-19 23:13:19 +08:00
wangdage12
1e40a6e576 Revise status indicators and update links
Updated status indicators and URLs in the README.
2025-12-19 22:59:02 +08:00
wangdage12
d342b37dc0 Update HarvestDirectory path in wixproj file 2025-12-19 21:28:55 +08:00
fanbook-wangdage
179177a77c Merge branch 'main' of https://github.com/wangdage12/Snap.Hutao 2025-12-19 21:20:14 +08:00
fanbook-wangdage
6c68a55d81 解决元数据导致的问题 2025-12-19 21:19:47 +08:00
wangdage12
7bd61c8035 Merge pull request #14 from wangdage12/dependabot/github_actions/dot-github/workflows/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6 in /.github/workflows
2025-12-17 20:20:52 +08:00
wangdage12
c19b71e2c4 Merge pull request #15 from wangdage12/dependabot/github_actions/dot-github/workflows/actions/cache-5
Bump actions/cache from 4 to 5 in /.github/workflows
2025-12-17 20:20:38 +08:00
wangdage12
45b7383fc1 Merge pull request #16 from wangdage12/dependabot/github_actions/dot-github/workflows/dessant/lock-threads-6
Bump dessant/lock-threads from 5 to 6 in /.github/workflows
2025-12-17 20:20:23 +08:00
dependabot[bot]
c83a2f3e9d Bump dessant/lock-threads from 5 to 6 in /.github/workflows
Bumps [dessant/lock-threads](https://github.com/dessant/lock-threads) from 5 to 6.
- [Release notes](https://github.com/dessant/lock-threads/releases)
- [Changelog](https://github.com/dessant/lock-threads/blob/main/CHANGELOG.md)
- [Commits](https://github.com/dessant/lock-threads/compare/v5...v6)

---
updated-dependencies:
- dependency-name: dessant/lock-threads
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 02:30:01 +00:00
dependabot[bot]
2bab0baf69 Bump actions/cache from 4 to 5 in /.github/workflows
Bumps [actions/cache](https://github.com/actions/cache) from 4 to 5.
- [Release notes](https://github.com/actions/cache/releases)
- [Changelog](https://github.com/actions/cache/blob/main/RELEASES.md)
- [Commits](https://github.com/actions/cache/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/cache
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 02:29:57 +00:00
dependabot[bot]
2726e74731 Bump actions/upload-artifact from 5 to 6 in /.github/workflows
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 02:29:52 +00:00
127 changed files with 6225 additions and 368 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

@@ -95,7 +95,7 @@ jobs:
- name: Cache NuGet packages - name: Cache NuGet packages
if: ${{ needs.select-runner.outputs.runner == 'windows-latest' }} if: ${{ needs.select-runner.outputs.runner == 'windows-latest' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.nuget/packages path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }} key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }}
@@ -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@v5 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

@@ -59,7 +59,7 @@ jobs:
- name: Cache NuGet packages - name: Cache NuGet packages
if: ${{ steps.merge.outputs.continue == 'true' }} if: ${{ steps.merge.outputs.continue == 'true' }}
uses: actions/cache@v4 uses: actions/cache@v5
with: with:
path: ~/.nuget/packages path: ~/.nuget/packages
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }} key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }}
@@ -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@v5 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

@@ -17,7 +17,7 @@ jobs:
action: action:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: dessant/lock-threads@v5 - uses: dessant/lock-threads@v6
with: with:
issue-inactive-days: '30' issue-inactive-days: '30'
issue-comment: 'This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related topic.' issue-comment: 'This issue has been automatically locked since there has not been any recent activity after it was closed. Please open a new issue for related topic.'

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@v5 uses: actions/upload-artifact@v7
with: with:
name: Snap.Hutao-MSI name: Snap.Hutao-MSI
path: | path: |

109
README.md
View File

@@ -4,7 +4,17 @@
**中文** **中文**
胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。 胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。
该版本注入功能暂不可用,并且由于缺失资源和开发能力,不建议长期使用 自带的注入功能只有FPS调整只保证FPS调整长期可用你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
官网https://htserver.wdg12.work/
**该版本的特点:**
- 尽量保留原版功能,少重写功能,稳定性强
- 只集成没有争议的安全的注入功能
- 大部分注入功能以第三方工具形式提供,点击即用
- 永久免费的云抽卡日志
有条件的话可以加入discord服务器https://discord.gg/ucH3mgeWpQ
**English** **English**
Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed for modern Windows platform to improve the gaming experience for desktop players. Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed for modern Windows platform to improve the gaming experience for desktop players.
@@ -13,40 +23,28 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
## 🚀 安装 / Installation ## 🚀 安装 / Installation
> 如果你的设备不支持ipv6请下载末尾带有`ipv4`的压缩包,正常情况下请尽量下载普通包(服务器速度快)
目前 Sanp.Hutao.Rev 更新了打包方式,并采用了标准现代的 msi 安装,方便程序获取管理员权限和更多的功能设置,不再需要原 Depolyment 目前 Sanp.Hutao.Rev 更新了打包方式,并采用了标准现代的 msi 安装,方便程序获取管理员权限和更多的功能设置,不再需要原 Depolyment
只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据 只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据
有时候我们在对某些功能有重大更改时发布测试版可在官网的下载可加入discord服务器报告功能使用情况和获取测试通知
--- ---
## 开发 ## 开发
项目启动位置已升级为 VS2026 的 slnx 格式 Snap.Hutao\src\Snap.Hutao\Snap.Hutao.slnx 项目启动位置已升级为 VS2026 的 slnx 格式 Snap.Hutao\src\Snap.Hutao\Snap.Hutao.slnx
> [!WARNING] > [!WARNING]
> 要使该项目可以长期运行,我们需要以下资源 > 要使该项目可以长期运行,我们需要以下资源
> 1. `src/Snap.Hutao/Snap.Hutao/Web/Hoyolab/DataSigning/SaltConstants.cs`中的新签名值 > 1. 元数据的编写
> 2. 元数据的编写 > 2. 图片资源
> 3. 图片资源
已同步原作者的元数据
V6.2的元数据已在编写中
测试仓库位置http://server.wdg.cloudns.ch:3000/wdg1122/Snap.Metadata.Test
**目前元数据的编写进度:** **目前元数据的编写进度:**
| 项目V6.2 | 是否完成 | | 项目V6.4 | 是否完成 |
| ----------- | ----------- | | ----------- | ----------- |
| 新角色的基本数据 | ✔️ | | 总体数据 | ✔️ |
| 新版本角色/怪物基础数值 | ❔ |
| 新角色的详细资料、名片等 | ❌ |
| 新武器 | ✔️ |
| 新材料 | ❇️ |
| 新怪物 | ❇️ |
| 新圣遗物 | / |
| 新卡池 | ❇️ |
| 新成就 | ✔️ |
| 深境螺旋 | 💠 |
| 幻想真境剧诗 | 💠 |
| 幽境危战 | ✔️ |
✔️:已完成 ✔️:已完成
❌:未编写 ❌:未编写
@@ -62,49 +60,66 @@ V6.2的元数据已在编写中
https://deepwiki.com/DGP-Studio/Snap.Hutao https://deepwiki.com/DGP-Studio/Snap.Hutao
https://deepwiki.com/DGP-Studio/Snap.Hutao.Server https://deepwiki.com/DGP-Studio/Snap.Hutao.Server
**该项目所需的其他仓库,欢迎贡献或者自部署**
- 元数据:[Snap.Metadata](https://github.com/wangdage12/Snap.Metadata)
- 服务端:[Snap.Server](https://github.com/wangdage12/Snap.Server)
- 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
### 资源 ## 资源和服务器状态
> 注意普通包的资源服务器只能使用ipv6连接也就是说你的电脑必须有ipv6并且建议你手动配置DNS为`223.5.5.5`
> 如果你的设备不支持ipv6请下载末尾带有`ipv4`的压缩包
> 由于数据文件夹中有元数据的仓库和图片缓存,才得以恢复资源文件
> 如果你发现之前版本可以显示的图片不能显示了,请查找旧数据文件夹
> `C:\Users\<用户名>\AppData\Local\Packages\xxxDGPStudio.SnapHutao_xxx\LocalCache\ImageCache`
> 并将`ImageCache`文件夹提供给我,我会尽力恢复资源
[服务器状态页面](http://serverjp.wdg.cloudns.ch:3001/status/hts) <a href="https://uptimerobot.com" target="_blank" rel="noopener">
<picture>
<source media="(prefers-color-scheme: dark)"
srcset="https://raw.githubusercontent.com/wangdage12/wangdage12/main/assets/uptimerobot-logo.svg">
<img alt="logo"
src="https://raw.githubusercontent.com/wangdage12/wangdage12/main/assets/uptimerobot-logo-dark.svg" width="300">
</picture>
</a>
我们将使用[UptimeRobot](https://uptimerobot.com)赞助的监控服务作为新的服务器状态页面,它有更多的功能
[服务器状态页面](https://stats.uptimerobot.com/fHxWxdxK61)
---
**元数据仓库:** **元数据仓库:**
https://github.com/wangdage12/Snap.Metadata https://github.com/wangdage12/Snap.Metadata
镜像: 仓库镜像:
![http://serverjp.wdg.cloudns.ch:3001/api/badge/6/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/6/status?style=flat-square)
http://server.wdg.cloudns.ch:3000/wdg1122/Snap.Metadata http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
![http://serverjp.wdg.cloudns.ch:3001/api/badge/7/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/7/status?style=flat-square)
http://serverjp.wdg.cloudns.ch:3000/wdg1122/Snap.Metadata
--- ---
**临时API** **API**
![http://serverjp.wdg.cloudns.ch:3001/api/badge/8/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/8/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://server.wdg.cloudns.ch:5222/ https://htserver.wdg12.work/api/
![http://serverjp.wdg.cloudns.ch:3001/api/badge/9/status?style=flat-square](http://serverjp.wdg.cloudns.ch:3001/api/badge/9/status?style=flat-square)
http://serverjp.wdg.cloudns.ch:5222/
--- ---
**临时资源站:** **图片资源站:**
http://server.wdg.cloudns.ch:8007/
http://serverjp.wdg.cloudns.ch:8001/ https://htserver.wdg12.work/
# 赞助
如果你想要为我分摊经济压力,可以在下方链接中为我赞助(支持多个预设方案,你也可以在页面下方自定义金额)
赞助的资金将全部用于服务器、域名等若有剩余资金将升级CDN或者服务器来提升使用体验我们的服务是完全免费的该赞助并不会解锁额外特权但是>=10元时将在官网新的“赞助者页面”上添加你的信息
https://ifdian.net/a/wdg12

BIN
bin/unlockfps.exe Normal file

Binary file not shown.

View File

@@ -4,5 +4,8 @@ This file contains the declaration of all the localizable strings.
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="en-US"> <WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="en-US">
<String Id="DowngradeError" Value="A newer version of [ProductName] is already installed." /> <String Id="DowngradeError" Value="A newer version of [ProductName] is already installed." />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="Desktop Shortcut" />
<String Id="StartMenuShortcutTitle" Value="Start Menu Shortcut" />
</WixLocalization> </WixLocalization>

View File

@@ -1,21 +1,32 @@
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"> <Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
<Package <Package
Name="Snap.Hutao" Name="Snap.Hutao"
Manufacturer="Millennium Science Technology R-D Inst" Manufacturer="Millennium Science Technology R-D Inst"
Version="1.0.0.0" Version="1.18.6.0"
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68" UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
Language="2052"
Scope="perMachine"> Scope="perMachine">
<MajorUpgrade DowngradeErrorMessage="A newer version of [ProductName] is already installed." /> <Property Id="ApplicationFolderName" Value="Snap.Hutao" />
<Property Id="WixAppFolder" Value="WixPerMachineFolder" />
<MajorUpgrade DowngradeErrorMessage="!(loc.DowngradeError)" />
<MediaTemplate EmbedCab="yes" /> <MediaTemplate EmbedCab="yes" />
<Feature Id="ProductFeature" Title="Snap.Hutao" Level="1"> <ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
<ComponentGroupRef Id="MainAppComponents" />
<!-- 快捷方式组件 --> <Feature Id="MainApp" Title="!(loc.MainAppTitle)" Level="1">
<ComponentRef Id="ApplicationShortcut" /> <ComponentGroupRef Id="MainAppComponents" />
</Feature>
<Feature Id="DesktopShortcutFeature" Title="!(loc.DesktopShortcutTitle)" Level="1">
<ComponentRef Id="DesktopShortcut" /> <ComponentRef Id="DesktopShortcut" />
</Feature> </Feature>
<Feature Id="StartMenuShortcutFeature" Title="!(loc.StartMenuShortcutTitle)" Level="1">
<ComponentRef Id="ApplicationShortcut" />
</Feature>
</Package> </Package>
<!-- 安装目录 --> <!-- 安装目录 -->

View File

@@ -0,0 +1,11 @@
<!--
This file contains the declaration of all the localizable strings.
-->
<WixLocalization xmlns="http://wixtoolset.org/schemas/v4/wxl" Culture="zh-CN">
<String Id="DowngradeError" Value="已安装更新版本的 [ProductName]。" />
<String Id="MainAppTitle" Value="Snap.Hutao" />
<String Id="DesktopShortcutTitle" Value="桌面快捷方式" />
<String Id="StartMenuShortcutTitle" Value="开始菜单快捷方式" />
</WixLocalization>

View File

@@ -4,6 +4,8 @@
<Platform>x64</Platform> <Platform>x64</Platform>
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework> <TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
<Configuration>Release</Configuration> <Configuration>Release</Configuration>
<DefaultCulture>zh-CN</DefaultCulture>
<Cultures>zh-CN;en-US</Cultures>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
@@ -19,6 +21,13 @@
<SuppressRootDirectory>true</SuppressRootDirectory> <SuppressRootDirectory>true</SuppressRootDirectory>
</HarvestDirectory> </HarvestDirectory>
<PackageReference Include="WixToolset.Heat" Version="4.0.1" /> <PackageReference Include="WixToolset.Heat" Version="6.0.2" />
<PackageReference Include="WixToolset.UI.wixext" Version="6.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<WixLocalization Include="Package.zh-cn.wxl" />
<WixLocalization Include="Package.en-us.wxl" />
</ItemGroup>
</Project> </Project>

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()
@@ -39,6 +45,7 @@ internal static class DependencyInjection
.AddJsonOptions() .AddJsonOptions()
.AddDatabase() .AddDatabase()
.AddServices() .AddServices()
.AddThirdPartyToolService()
.AddResponseValidation() .AddResponseValidation()
.AddConfiguredHttpClients() .AddConfiguredHttpClients()

View File

@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
using Snap.Hutao.Core.Text.Json; using Snap.Hutao.Core.Text.Json;
using Snap.Hutao.Factory.Process; using Snap.Hutao.Factory.Process;
using Snap.Hutao.Model.Entity.Database; using Snap.Hutao.Model.Entity.Database;
using Snap.Hutao.Service.ThirdPartyTool;
using Snap.Hutao.Win32; using Snap.Hutao.Win32;
using System.Data.Common; using System.Data.Common;
@@ -66,5 +67,10 @@ internal static partial class ServiceCollectionExtension
.UseSqlite(sqlConnectionString); .UseSqlite(sqlConnectionString);
} }
} }
public IServiceCollection AddThirdPartyToolService()
{
return services.AddSingleton<IThirdPartyToolService, ThirdPartyToolService>();
}
} }
} }

View File

@@ -33,6 +33,8 @@ internal static class HutaoRuntime
public static WebView2Version WebView2Version { get; } = InitializeWebView2(); public static WebView2Version WebView2Version { get; } = InitializeWebView2();
public static string WebView2UserDataDirectory { get; } = InitializeWebView2UserDataDirectory();
// ⚠️ 延迟初始化以避免循环依赖 // ⚠️ 延迟初始化以避免循环依赖
private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated); private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated);
@@ -144,6 +146,13 @@ internal static class HutaoRuntime
return cacheDir; return cacheDir;
} }
private static string InitializeWebView2UserDataDirectory()
{
string directory = Path.Combine(LocalCacheDirectory, "WebView2");
Directory.CreateDirectory(directory);
return directory;
}
private static bool CheckAppNotificationEnabled() private static bool CheckAppNotificationEnabled()
{ {
try try

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);
@@ -115,8 +117,11 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
} }
case YaeCommandKind.RequestResumeThread: case YaeCommandKind.RequestResumeThread:
{
if (supportsResumeMainThread)
{ {
gameProcess.ResumeMainThread(); 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,45 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Win32.Foundation;
namespace Snap.Hutao.Factory.Process;
internal sealed class NullProcess : IProcess
{
public int Id => 0;
public nint Handle => 0;
public HWND MainWindowHandle => default;
public bool HasExited => true;
public int ExitCode => 0;
public void Start()
{
// Do nothing
}
public void ResumeMainThread()
{
// Do nothing
}
public void WaitForExit()
{
// Do nothing
}
public void Kill()
{
// Do nothing
}
public void Dispose()
{
// Do nothing
}
}

View File

@@ -193,6 +193,32 @@ internal sealed class ProcessFactory
public static void StartUsingShellExecuteRunAs(string fileName) public static void StartUsingShellExecuteRunAs(string fileName)
{ {
// 尝试从app包中启动
try
{
global::System.Diagnostics.Process.Start(new global::System.Diagnostics.ProcessStartInfo
{
FileName = fileName,
UseShellExecute = true,
Verb = "runas",
});
}catch
{
// 如果失败且filename含有Snap.Hutao.Unpackaged就直接用Snap.Hutao.exe重启
if (fileName.Contains("Snap.Hutao.Unpackaged"))
{
string currentDirectory = Directory.GetCurrentDirectory();
string unpackagedPath = Path.Combine(currentDirectory, "Snap.Hutao.exe");
if (File.Exists(unpackagedPath))
{
fileName = unpackagedPath;
}
// 否则抛出异常
else
{
throw;
}
// 重新尝试启动
global::System.Diagnostics.Process.Start(new global::System.Diagnostics.ProcessStartInfo global::System.Diagnostics.Process.Start(new global::System.Diagnostics.ProcessStartInfo
{ {
FileName = fileName, FileName = fileName,
@@ -201,3 +227,5 @@ internal sealed class ProcessFactory
}); });
} }
} }
}
}

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

@@ -126,6 +126,9 @@ internal static class AvatarIds
public static readonly AvatarId Nefer = 10000122; public static readonly AvatarId Nefer = 10000122;
public static readonly AvatarId Durin = 10000123; public static readonly AvatarId Durin = 10000123;
public static readonly AvatarId Jahoda = 10000124; public static readonly AvatarId Jahoda = 10000124;
public static readonly AvatarId Columbina = 10000125;
public static readonly AvatarId Zibai = 10000126;
public static readonly AvatarId Illuga = 10000127;
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

@@ -22,9 +22,8 @@ internal static class WeaponIds
11401U, 11402U, 11403U, 11405U, 11401U, 11402U, 11403U, 11405U,
12401U, 12402U, 12403U, 12405U, 12401U, 12402U, 12403U, 12405U,
13401U, 13407U, 13401U, 13407U,
14401U, 14402U, 14403U, 14409U, 14401U, 14402U, 14403U, 14409U, 14433U, 14434U,
15401U, 15402U, 15403U, 15405U, 15401U, 15402U, 15403U, 15405U, 15434U
15434U
]; ];
public static readonly FrozenSet<WeaponId> OrangeStandardWishIds = public static readonly FrozenSet<WeaponId> OrangeStandardWishIds =
@@ -34,7 +33,8 @@ internal static class WeaponIds
13502U, 13505U, 13502U, 13505U,
14501U, 14502U, 14501U, 14502U,
15501U, 15502U, 15501U, 15502U,
15515U, 15518U 15515U, 11518U,
14522U, 11519U
]; ];
public static bool IsOrangeStandardWish(in WeaponId weaponId) public static bool IsOrangeStandardWish(in WeaponId weaponId)

View File

@@ -1,4 +1,4 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Package <Package
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
@@ -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.17.1.0" /> Version="1.18.6.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>
@@ -1208,6 +1217,12 @@
<data name="ServiceYaeWaitForGameResponseMessage" xml:space="preserve"> <data name="ServiceYaeWaitForGameResponseMessage" xml:space="preserve">
<value>正在等待游戏数据</value> <value>正在等待游戏数据</value>
</data> </data>
<data name="ServiceThirdPartyToolNoExecutableFound" xml:space="preserve">
<value>未找到可执行文件</value>
</data>
<data name="ServiceThirdPartyToolFileNotFound" xml:space="preserve">
<value>文件不存在:{0}</value>
</data>
<data name="UIViewMainTitleBarBackgroundActivityAction" xml:space="preserve"> <data name="UIViewMainTitleBarBackgroundActivityAction" xml:space="preserve">
<value>后台任务</value> <value>后台任务</value>
</data> </data>
@@ -1376,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>
@@ -1586,6 +1604,15 @@
<data name="ViewDialogLaunchGamePackageConvertTitle" xml:space="preserve"> <data name="ViewDialogLaunchGamePackageConvertTitle" xml:space="preserve">
<value>正在转换客户端</value> <value>正在转换客户端</value>
</data> </data>
<data name="ViewDialogThirdPartyToolDescription" xml:space="preserve">
<value>工具描述:</value>
</data>
<data name="ViewDialogThirdPartyToolLaunch" xml:space="preserve">
<value>启动</value>
</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>
@@ -1907,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>
@@ -2447,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>
@@ -2456,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>
@@ -2468,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>
@@ -2916,6 +2967,9 @@
<data name="ViewPageLaunchGameInjectionHeader" xml:space="preserve"> <data name="ViewPageLaunchGameInjectionHeader" xml:space="preserve">
<value>注入</value> <value>注入</value>
</data> </data>
<data name="ViewPageLaunchGameThirdPartyTools" xml:space="preserve">
<value>第三方注入工具:</value>
</data>
<data name="ViewPageLaunchGameIslandConnected" xml:space="preserve"> <data name="ViewPageLaunchGameIslandConnected" 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

@@ -24,8 +24,10 @@ internal static class AvatarViewBuilderExtension
{ {
if (detailedCharacter.Costumes is [{ Id: { } id }, ..]) if (detailedCharacter.Costumes is [{ Id: { } id }, ..])
{ {
MetadataCostume costume = avatar.Costumes.Single(c => c.Id == id); MetadataCostume? costume = avatar.Costumes.SingleOrDefault(c => c.Id == id);
if (costume != null)
{
ArgumentNullException.ThrowIfNull(costume.FrontIcon); ArgumentNullException.ThrowIfNull(costume.FrontIcon);
ArgumentNullException.ThrowIfNull(costume.SideIcon); ArgumentNullException.ThrowIfNull(costume.SideIcon);
@@ -34,6 +36,13 @@ internal static class AvatarViewBuilderExtension
builder.View.SideIcon = AvatarIconConverter.IconNameToUri(costume.SideIcon); builder.View.SideIcon = AvatarIconConverter.IconNameToUri(costume.SideIcon);
} }
else else
{
// Costume not found in metadata, fallback to default avatar icon
builder.View.Icon = AvatarIconConverter.IconNameToUri(avatar.Icon);
builder.View.SideIcon = AvatarIconConverter.IconNameToUri(avatar.SideIcon);
}
}
else
{ {
builder.View.Icon = AvatarIconConverter.IconNameToUri(avatar.Icon); builder.View.Icon = AvatarIconConverter.IconNameToUri(avatar.Icon);
builder.View.SideIcon = AvatarIconConverter.IconNameToUri(avatar.SideIcon); builder.View.SideIcon = AvatarIconConverter.IconNameToUri(avatar.SideIcon);

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,18 +240,60 @@ 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);
@@ -165,7 +303,7 @@ internal sealed partial class CultivationService : ICultivationService
// 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
@@ -178,7 +316,7 @@ internal sealed partial class CultivationService : ICultivationService
{ {
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 +330,7 @@ internal sealed partial class CultivationService : ICultivationService
if (inputConsumption.Items is []) if (inputConsumption.Items is [])
{ {
return ConsumptionSaveResultKind.Removed; return new(ConsumptionSaveResultKind.Removed);
} }
} }
} }
@@ -200,13 +338,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(projects.CurrentItem.InnerId, 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);
@@ -218,9 +357,51 @@ 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(projects.CurrentItem.InnerId, out _);
return new(ConsumptionSaveResultKind.Added, entry.InnerId);
}
} }
return ConsumptionSaveResultKind.Added; public async ValueTask<Guid?> TryGetAvatarCultivateEntryInnerIdAsync(uint avatarId)
{
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
{
return null;
}
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 +425,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 +505,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 +524,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

@@ -0,0 +1,55 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core.Setting;
using System.IO;
namespace Snap.Hutao.Service.Game.Island;
internal static class FpsConfigTest
{
// 测试用手动更新FPS配置文件
public static void TestConfigUpdate()
{
// 直接从LocalSetting读取当前FPS设置
int currentFps = LocalSetting.Get(SettingKeys.LaunchTargetFps, 60);
// 配置文件路径
string configPath = Path.Combine(AppContext.BaseDirectory, "fps_config.ini");
// 读取当前配置
if (File.Exists(configPath))
{
string[] lines = File.ReadAllLines(configPath);
int configFps = 60;
foreach (string line in lines)
{
if (line.StartsWith("FPS="))
{
configFps = int.Parse(line.Substring(4));
break;
}
}
System.Diagnostics.Debug.WriteLine($"Current FPS from LocalSetting: {currentFps}");
System.Diagnostics.Debug.WriteLine($"Current FPS from config file: {configFps}");
if (currentFps != configFps)
{
// 更新配置文件
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].StartsWith("FPS="))
{
lines[i] = $"FPS={currentFps}";
break;
}
}
File.WriteAllLines(configPath, lines);
System.Diagnostics.Debug.WriteLine($"Updated config file with FPS: {currentFps}");
}
}
}
}

View File

@@ -0,0 +1,550 @@
// Copyright (c) DGP Studio. All rights reserved.
// Licensed under the MIT license.
using Snap.Hutao.Core;
using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Core.Setting;
using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.Launching.Context;
using Snap.Hutao.Web.Hutao;
using System.Diagnostics;
using System.IO;
using System.Text;
namespace Snap.Hutao.Service.Game.Island;
internal sealed class GameFpsUnlockInterop : IGameIslandInterop, IDisposable
{
private const string UnlockerExecutableName = "unlockfps.exe";
private const string UnlockerConfigName = "fps_config.ini";
private readonly bool resume;
private string? unlockerPath;
private string? gamePath;
private Process? unlockerProcess;
public GameFpsUnlockInterop(bool resume)
{
this.resume = resume;
}
public async ValueTask BeforeAsync(BeforeLaunchExecutionContext context)
{
if (resume)
{
return;
}
// 准备 unlocker.exe 到可写的应用数据目录
await PrepareUnlockerToDataDirectoryAsync().ConfigureAwait(false);
// 从应用数据目录获取 unlocker.exe 路径
unlockerPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerExecutableName);
if (!File.Exists(unlockerPath))
{
throw HutaoException.InvalidOperation("未找到unlockfps.exe文件请将genshin-fps-unlock-master编译后的unlockfps.exe放置在Snap.Hutao同目录下");
}
// 添加到 Windows Defender 排除项(需要管理员权限)
await AddToDefenderExclusionAsync(unlockerPath).ConfigureAwait(false);
// 获取游戏路径
gamePath = context.FileSystem.GameFilePath;
// 验证游戏路径
SentrySdk.AddBreadcrumb(
$"Game path from Snap.Hutao: {gamePath}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
if (!File.Exists(gamePath))
{
throw HutaoException.InvalidOperation($"游戏文件不存在: {gamePath}");
}
// 创建配置文件
await CreateUnlockerConfigAsync(context).ConfigureAwait(false);
// 启动解锁器进程
await StartUnlockerProcessAsync(context, CancellationToken.None).ConfigureAwait(false);
}
public async ValueTask WaitForExitAsync(LaunchExecutionContext context, CancellationToken token = default)
{
if (resume)
{
// 恢复模式下,尝试连接已存在的解锁器进程
await MonitorExistingUnlockerAsync(context, token).ConfigureAwait(false);
return;
}
// 监控解锁器进程状态(解锁器会自动启动并监控游戏)
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)
{
if (string.IsNullOrEmpty(gamePath))
{
throw HutaoException.NotSupported("游戏路径未初始化");
}
// 在应用数据目录中创建配置文件
string unlockerConfigPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
int targetFps = context.LaunchOptions.TargetFps.Value;
string configContent = $"[Setting]\nPath={gamePath}\nFPS={targetFps}";
// 添加重试机制处理可能的权限问题
for (int i = 0; i < 3; i++)
{
try
{
await File.WriteAllTextAsync(unlockerConfigPath, configContent).ConfigureAwait(false);
break; // 成功写入,退出循环
}
catch (UnauthorizedAccessException)
{
if (i == 2)
{
throw HutaoException.InvalidOperation($"无法写入配置文件 {unlockerConfigPath},请检查权限");
}
await Task.Delay(500).ConfigureAwait(false);
}
catch (IOException)
{
if (i == 2)
{
throw HutaoException.InvalidOperation($"无法写入配置文件 {unlockerConfigPath},文件可能被占用");
}
await Task.Delay(500).ConfigureAwait(false);
}
}
}
private async ValueTask StartUnlockerProcessAsync(BeforeLaunchExecutionContext context, CancellationToken token)
{
try
{
string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (!File.Exists(configPath))
{
throw HutaoException.InvalidOperation($"配置文件不存在: {configPath}");
}
string configContent = await File.ReadAllTextAsync(configPath).ConfigureAwait(false);
SentrySdk.AddBreadcrumb(
$"Starting unlocker with config: {configContent}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
// 构建游戏启动参数,传递给 unlockfps.exe
string gameArguments = BuildGameArguments(context);
SentrySdk.AddBreadcrumb(
$"Game arguments for unlocker: {gameArguments}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
ProcessStartInfo startInfo = new()
{
FileName = unlockerPath,
WorkingDirectory = Path.GetDirectoryName(unlockerPath),
UseShellExecute = false,
CreateNoWindow = true,
RedirectStandardOutput = true,
RedirectStandardError = true,
WindowStyle = ProcessWindowStyle.Normal,
Arguments = gameArguments,
};
unlockerProcess = new Process { StartInfo = startInfo };
unlockerProcess.Start();
Task outputTask = Task.Run(async () =>
{
while (!unlockerProcess.StandardOutput.EndOfStream)
{
string line = await unlockerProcess.StandardOutput.ReadLineAsync().ConfigureAwait(false);
if (line != null)
{
SentrySdk.AddBreadcrumb(
$"Unlocker output: {line}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
}
}
});
Task errorTask = Task.Run(async () =>
{
while (!unlockerProcess.StandardError.EndOfStream)
{
string line = await unlockerProcess.StandardError.ReadLineAsync().ConfigureAwait(false);
if (line != null)
{
SentrySdk.AddBreadcrumb(
$"Unlocker error: {line}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Error);
}
}
});
// 等待解锁器初始化
await Task.Delay(5000).ConfigureAwait(false);
}
catch (Exception ex)
{
throw HutaoException.Throw($"启动FPS解锁器失败: {ex.Message}", ex);
}
}
private string BuildGameArguments(BeforeLaunchExecutionContext context)
{
LaunchOptions launchOptions = context.LaunchOptions;
if (!launchOptions.AreCommandLineArgumentsEnabled.Value)
{
return string.Empty;
}
// 获取米游社登录Ticket
string? authTicket = default;
bool useAuthTicket = launchOptions.UsingHoyolabAccount.Value
&& context.TryGetOption(LaunchExecutionOptionsKey.LoginAuthTicket, out authTicket)
&& !string.IsNullOrEmpty(authTicket);
StringBuilder arguments = new();
// 构建与 GameProcessFactory.CreateForDefault 相同的命令行参数
if (launchOptions.IsBorderless.Value)
{
arguments.Append(" -popupwindow");
}
if (launchOptions.IsExclusive.Value)
{
arguments.Append(" -window-mode exclusive");
}
arguments.Append($" -screen-fullscreen {(launchOptions.IsFullScreen.Value ? "1" : "0")}");
if (launchOptions.IsScreenWidthEnabled.Value)
{
arguments.Append($" -screen-width {launchOptions.ScreenWidth.Value}");
}
if (launchOptions.IsScreenHeightEnabled.Value)
{
arguments.Append($" -screen-height {launchOptions.ScreenHeight.Value}");
}
if (launchOptions.IsMonitorEnabled.Value)
{
arguments.Append($" -monitor {launchOptions.Monitor.Value?.Value ?? 1}");
}
if (launchOptions.IsPlatformTypeEnabled.Value)
{
arguments.Append($" -platform_type {launchOptions.PlatformType.Value:G}");
}
// 添加米游社登录参数
if (useAuthTicket)
{
arguments.Append($" login_auth_ticket={authTicket}");
}
return arguments.ToString();
}
private async ValueTask MonitorExistingUnlockerAsync(LaunchExecutionContext context, CancellationToken token)
{
// 恢复模式下,检查是否有解锁器进程在运行
Process[] processes = Process.GetProcessesByName(Path.GetFileNameWithoutExtension(UnlockerExecutableName));
if (processes.Length == 0)
{
// 没有找到解锁器进程,但游戏在运行,这是正常情况
return;
}
unlockerProcess = processes[0];
await MonitorUnlockerProcessAsync(context, token).ConfigureAwait(false);
}
private async ValueTask MonitorUnlockerProcessAsync(LaunchExecutionContext context, CancellationToken token)
{
if (unlockerProcess is null)
{
return;
}
using (PeriodicTimer timer = new(TimeSpan.FromSeconds(2)))
{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false))
{
// 检查解锁器进程状态
if (unlockerProcess.HasExited)
{
// 解锁器已退出,这意味着游戏也已退出
break;
}
// 同步FPS设置如果用户在运行时修改了
await SyncFpsSettingsAsync(context.LaunchOptions).ConfigureAwait(false);
}
}
// 确保解锁器进程已清理
CleanupUnlockerProcess();
}
private async ValueTask SyncFpsSettingsAsync(LaunchOptions launchOptions)
{
if (unlockerProcess is null || unlockerProcess.HasExited)
{
return;
}
try
{
string configPath = Path.Combine(HutaoRuntime.DataDirectory, UnlockerConfigName);
if (File.Exists(configPath))
{
string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false);
int currentFps = launchOptions.TargetFps.Value;
bool needsUpdate = false;
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].StartsWith("FPS="))
{
int configFps = int.Parse(lines[i].Substring(4));
if (configFps != currentFps)
{
lines[i] = $"FPS={currentFps}";
needsUpdate = true;
}
break;
}
}
if (needsUpdate)
{
// 添加重试机制处理可能的权限问题
for (int i = 0; i < 3; i++)
{
try
{
await File.WriteAllLinesAsync(configPath, lines).ConfigureAwait(false);
break; // 成功写入,退出循环
}
catch (UnauthorizedAccessException)
{
if (i == 2) // 最后一次尝试
{
SentrySdk.AddBreadcrumb(
$"无法写入配置文件 {configPath},请检查权限",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Error);
return;
}
await Task.Delay(500).ConfigureAwait(false); // 等待500ms后重试
}
catch (IOException)
{
if (i == 2) // 最后一次尝试
{
SentrySdk.AddBreadcrumb(
$"无法写入配置文件 {configPath},文件可能被占用",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Error);
return;
}
await Task.Delay(500).ConfigureAwait(false); // 等待500ms后重试
}
}
}
}
}
catch (Exception ex)
{
// 同步配置失败,记录但不影响主流程
SentrySdk.AddBreadcrumb(
$"Failed to sync FPS settings: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
}
private void CleanupUnlockerProcess()
{
if (unlockerProcess is not null && !unlockerProcess.HasExited)
{
try
{
unlockerProcess.Kill();
unlockerProcess.WaitForExit();
}
catch (Exception ex)
{
// 忽略清理过程中的错误
SentrySdk.AddBreadcrumb(
$"Failed to cleanup unlocker process: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
finally
{
unlockerProcess.Dispose();
unlockerProcess = null;
}
}
}
public void Dispose()
{
CleanupUnlockerProcess();
}
}

View File

@@ -12,6 +12,8 @@ using Snap.Hutao.Service.Game.FileSystem;
using Snap.Hutao.Service.Game.PathAbstraction; using Snap.Hutao.Service.Game.PathAbstraction;
using Snap.Hutao.Win32; using Snap.Hutao.Win32;
using System.Collections.Immutable; using System.Collections.Immutable;
using System.IO;
using System.Threading.Tasks;
namespace Snap.Hutao.Service.Game; namespace Snap.Hutao.Service.Game;
@@ -106,7 +108,7 @@ internal sealed partial class LaunchOptions : DbStoreOptions, IRestrictedGamePat
public IObservableProperty<bool> IsSetTargetFrameRateEnabled { get => field ??= CreateProperty(SettingKeys.LaunchIsSetTargetFrameRateEnabled, true); } public IObservableProperty<bool> IsSetTargetFrameRateEnabled { get => field ??= CreateProperty(SettingKeys.LaunchIsSetTargetFrameRateEnabled, true); }
[field: MaybeNull] [field: MaybeNull]
public IObservableProperty<int> TargetFps { get => field ??= CreateProperty(SettingKeys.LaunchTargetFps, InitializeTargetFpsWithScreenFps); } public IObservableProperty<int> TargetFps { get => field ??= CreateProperty(SettingKeys.LaunchTargetFps, InitializeTargetFpsWithScreenFps).WithValueChangedCallback(OnTargetFpsChanged); }
[field: MaybeNull] [field: MaybeNull]
public IObservableProperty<bool> RemoveOpenTeamProgress { get => field ??= CreateProperty(SettingKeys.LaunchRemoveOpenTeamProgress, false); } public IObservableProperty<bool> RemoveOpenTeamProgress { get => field ??= CreateProperty(SettingKeys.LaunchRemoveOpenTeamProgress, false); }
@@ -165,6 +167,98 @@ internal sealed partial class LaunchOptions : DbStoreOptions, IRestrictedGamePat
return HutaoNative.Instance.MakeDeviceCapabilities().GetPrimaryScreenVerticalRefreshRate(); return HutaoNative.Instance.MakeDeviceCapabilities().GetPrimaryScreenVerticalRefreshRate();
} }
private static void OnTargetFpsChanged(int newFps)
{
// 异步更新配置文件避免阻塞UI线程
Task.Run(async () =>
{
try
{
string configPath = Path.Combine(AppContext.BaseDirectory, "fps_config.ini");
if (File.Exists(configPath))
{
string[] lines = await File.ReadAllLinesAsync(configPath).ConfigureAwait(false);
bool needsUpdate = true;
foreach (string line in lines)
{
if (line.StartsWith("FPS="))
{
int configFps = int.Parse(line.Substring(4));
if (configFps == newFps)
{
needsUpdate = false;
}
break;
}
}
// 更新配置文件
if (needsUpdate)
{
for (int i = 0; i < lines.Length; i++)
{
if (lines[i].StartsWith("FPS="))
{
lines[i] = $"FPS={newFps}";
break;
}
}
for (int i = 0; i < 3; i++)
{
try
{
await File.WriteAllLinesAsync(configPath, lines).ConfigureAwait(false);
SentrySdk.AddBreadcrumb(
$"Updated fps_config.ini with new FPS: {newFps}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Info);
break;
}
catch (UnauthorizedAccessException)
{
if (i == 2)
{
SentrySdk.AddBreadcrumb(
$"无法写入配置文件 {configPath},请检查权限",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Error);
return;
}
await Task.Delay(500).ConfigureAwait(false);
}
catch (IOException)
{
if (i == 2)
{
SentrySdk.AddBreadcrumb(
$"无法写入配置文件 {configPath},文件可能被占用",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Error);
return;
}
await Task.Delay(500).ConfigureAwait(false);
}
}
}
}
}
catch (Exception ex)
{
// 记录错误
SentrySdk.AddBreadcrumb(
$"Failed to update fps_config.ini: {ex.Message}",
category: "fps.unlocker",
level: Sentry.BreadcrumbLevel.Warning);
}
});
}
private static ImmutableArray<NameValue<int>> InitializeMonitors() private static ImmutableArray<NameValue<int>> InitializeMonitors()
{ {
ImmutableArray<NameValue<int>>.Builder monitors = ImmutableArray.CreateBuilder<NameValue<int>>(); ImmutableArray<NameValue<int>>.Builder monitors = ImmutableArray.CreateBuilder<NameValue<int>>();

View File

@@ -8,10 +8,10 @@ using Snap.Hutao.Service.Notification;
namespace Snap.Hutao.Service.Game.Launching.Handler; namespace Snap.Hutao.Service.Game.Launching.Handler;
internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecutionHandler internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecutionHandler, IDisposable
{ {
private readonly bool resume; private readonly bool resume;
private GameIslandInterop? interop; private GameFpsUnlockInterop? interop;
public LaunchExecutionGameIslandHandler(bool resume) public LaunchExecutionGameIslandHandler(bool resume)
{ {
@@ -63,4 +63,9 @@ internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecution
GameLifeCycle.IsIslandConnected.Value = false; GameLifeCycle.IsIslandConnected.Value = false;
} }
} }
public void Dispose()
{
interop?.Dispose();
}
} }

View File

@@ -28,7 +28,20 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
{ {
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

@@ -4,6 +4,7 @@
using Snap.Hutao.Core.Diagnostics; using Snap.Hutao.Core.Diagnostics;
using Snap.Hutao.Core.ExceptionService; using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Factory.Progress; using Snap.Hutao.Factory.Progress;
using Snap.Hutao.Factory.Process;
using Snap.Hutao.Service.Game.FileSystem; 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;
@@ -20,6 +21,8 @@ internal abstract class AbstractLaunchExecutionInvoker
private bool invoked; private bool invoked;
protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; } protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; }
protected virtual bool ShouldWaitForProcessExit { get => true; }
protected virtual bool ShouldSpinWaitGameExitAfterInvoke { get => true; }
public static bool Invoking() public static bool Invoking()
{ {
@@ -39,7 +42,7 @@ internal abstract class AbstractLaunchExecutionInvoker
finally finally
{ {
Invokers.TryRemove(this, out _); Invokers.TryRemove(this, out _);
if (!Invoking()) if (!Invoking() && ShouldSpinWaitGameExitAfterInvoke)
{ {
await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false); await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false);
} }
@@ -100,9 +103,16 @@ internal abstract class AbstractLaunchExecutionInvoker
fileSystemReference.Exchange(beforeContext.FileSystem); fileSystemReference.Exchange(beforeContext.FileSystem);
using (IProcess? process = CreateProcess(beforeContext)) // unlockfps.exe会负责启动游戏
IProcess? process = null;
if (!beforeContext.LaunchOptions.IsIslandEnabled.Value)
{ {
if (process is null) process = CreateProcess(beforeContext);
}
using (process)
{
if (process is null && !beforeContext.LaunchOptions.IsIslandEnabled.Value)
{ {
return; return;
} }
@@ -114,7 +124,7 @@ internal abstract class AbstractLaunchExecutionInvoker
TaskContext = taskContext, TaskContext = taskContext,
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(), Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
LaunchOptions = context.LaunchOptions, LaunchOptions = context.LaunchOptions,
Process = process, Process = process ?? new NullProcess(),
IsOversea = targetScheme.IsOversea, IsOversea = targetScheme.IsOversea,
}; };
@@ -123,7 +133,8 @@ internal abstract class AbstractLaunchExecutionInvoker
await handler.ExecuteAsync(executionContext).ConfigureAwait(false); await handler.ExecuteAsync(executionContext).ConfigureAwait(false);
} }
if (process.IsRunning) // 只有在没有启用Island且进程存在时才等待退出
if (ShouldWaitForProcessExit && process is { IsRunning: true })
{ {
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit)); progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
try try
@@ -139,6 +150,12 @@ internal abstract class AbstractLaunchExecutionInvoker
return; return;
} }
} }
else if (ShouldWaitForProcessExit && beforeContext.LaunchOptions.IsIslandEnabled.Value)
{
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
await taskContext.SwitchToBackgroundAsync();
await Task.Delay(30000).ConfigureAwait(false);
}
} }
progress.Report(new(SH.ServiceGameLaunchPhaseProcessExited)); progress.Report(new(SH.ServiceGameLaunchPhaseProcessExited));

View File

@@ -9,6 +9,9 @@ namespace Snap.Hutao.Service.Game.Launching.Invoker;
internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
{ {
protected override bool ShouldWaitForProcessExit { get => false; }
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
public ConvertOnlyLaunchExecutionInvoker() public ConvertOnlyLaunchExecutionInvoker()
{ {
Handlers = Handlers =

View File

@@ -16,8 +16,8 @@ internal sealed class DefaultLaunchExecutionInvoker : AbstractLaunchExecutionInv
new LaunchExecutionGameResourceHandler(convertOnly: false), new LaunchExecutionGameResourceHandler(convertOnly: false),
new LaunchExecutionGameIdentityHandler(), new LaunchExecutionGameIdentityHandler(),
new LaunchExecutionWindowsHDRHandler(), new LaunchExecutionWindowsHDRHandler(),
new LaunchExecutionGameProcessStartHandler(),
new LaunchExecutionGameIslandHandler(resume: false), new LaunchExecutionGameIslandHandler(resume: false),
new LaunchExecutionGameProcessStartHandler(),
new LaunchExecutionOverlayHandler(), new LaunchExecutionOverlayHandler(),
new LaunchExecutionStarwardPlayTimeStatisticsHandler(), new LaunchExecutionStarwardPlayTimeStatisticsHandler(),
new LaunchExecutionBetterGenshinImpactAutomationHandler() new LaunchExecutionBetterGenshinImpactAutomationHandler()

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

@@ -47,6 +47,9 @@ internal sealed partial class HutaoAsAService : IHutaoAsAService
} }
} }
// Filter announcements by Distribution
array = [.. array.Where(a => string.IsNullOrEmpty(a.Distribution) || a.Distribution == "Snap Hutao")]; // 请自行修改发行版名称
foreach (HutaoAnnouncement item in array) foreach (HutaoAnnouncement item in array)
{ {
item.DismissCommand = dismissCommand; item.DismissCommand = dismissCommand;

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));
@@ -97,9 +102,37 @@ internal sealed partial class InventoryService : IInventoryService
} }
if (batchConsumption is { OverallConsume: { IsDefault: false } items }) if (batchConsumption is { OverallConsume: { IsDefault: false } items })
{
static IEnumerable<InventoryItem> ToInventoryItems(ImmutableArray<Item> consumeItems, Guid projectId)
{
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.RemoveInventoryItemRangeByProjectId(project.InnerId);
inventoryRepository.AddInventoryItemRangeByProjectId(items.SelectAsArray(static (item, project) => InventoryItem.From(project.InnerId, item.Id, (uint)((int)item.Num - item.LackNum)), project)); 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

@@ -0,0 +1,51 @@
using Snap.Hutao.Web.ThirdPartyTool;
using System.Collections.Immutable;
namespace Snap.Hutao.Service.ThirdPartyTool;
internal interface IThirdPartyToolService
{
/// <summary>
/// 获取第三方工具列表
/// </summary>
/// <param name="token">取消令牌</param>
/// <returns>工具列表</returns>
ValueTask<ImmutableArray<ToolInfo>> GetToolsAsync(CancellationToken token = default);
/// <summary>
/// 下载工具文件
/// </summary>
/// <param name="tool">工具信息</param>
/// <param name="progress">进度报告</param>
/// <param name="token">取消令牌</param>
/// <returns>是否下载成功</returns>
ValueTask<bool> DownloadToolAsync(ToolInfo tool, IProgress<double>? progress = null, CancellationToken token = default);
/// <summary>
/// 启动工具
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>是否启动成功</returns>
ValueTask<bool> LaunchToolAsync(ToolInfo tool);
/// <summary>
/// 检查工具是否已下载
/// </summary>
/// <param name="tool">工具信息</param>
/// <returns>是否已下载</returns>
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

@@ -0,0 +1,447 @@
using Snap.Hutao.Core;
using Snap.Hutao.Core.DependencyInjection.Annotation.HttpClient;
using Snap.Hutao.Core.ExceptionService;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Web.Request.Builder;
using Snap.Hutao.Web.Request.Builder.Abstraction;
using Snap.Hutao.Web.ThirdPartyTool;
using System.Collections.Generic;
using System.Collections.Immutable;
using System.Diagnostics;
using System.IO;
using System.IO.Compression;
using System.Net.Http;
using System.Text.Json;
namespace Snap.Hutao.Service.ThirdPartyTool;
[HttpClient(HttpClientConfiguration.Default)]
[Service(ServiceLifetime.Singleton, typeof(IThirdPartyToolService))]
internal sealed partial class ThirdPartyToolService : IThirdPartyToolService
{
private const string ApiBaseUrl = "https://htserver.wdg12.work/api";
private const string ToolsEndpoint = "/tools";
private const string ToolInfoFileName = "tool_info.json";
private readonly IHttpClientFactory httpClientFactory;
private readonly IHttpRequestMessageBuilderFactory httpRequestMessageBuilderFactory;
private readonly IMessenger messenger;
[GeneratedConstructor]
public partial ThirdPartyToolService(IServiceProvider serviceProvider, HttpClient httpClient);
public async ValueTask<ImmutableArray<ToolInfo>> GetToolsAsync(CancellationToken token = default)
{
try
{
HttpClient httpClient = httpClientFactory.CreateClient();
// 添加日志
SentrySdk.AddBreadcrumb($"Creating request to: {ApiBaseUrl}{ToolsEndpoint}", category: "ThirdPartyTool");
HttpRequestMessageBuilder builder = httpRequestMessageBuilderFactory.Create()
.SetRequestUri($"{ApiBaseUrl}{ToolsEndpoint}")
.Get();
SentrySdk.AddBreadcrumb($"Sending HTTP request", category: "ThirdPartyTool");
ToolApiResponse? response = await builder
.SendAsync<ToolApiResponse>(httpClient, token)
.ConfigureAwait(false);
SentrySdk.AddBreadcrumb($"Request completed", category: "ThirdPartyTool");
if (response is null)
{
SentrySdk.AddBreadcrumb("Response is null", category: "ThirdPartyTool");
return ImmutableArray<ToolInfo>.Empty;
}
SentrySdk.AddBreadcrumb($"Response received: Code={response.Code}, Message={response.Message}, Data.Length={response.Data.Length}", category: "ThirdPartyTool");
if (response.Code != 0)
{
SentrySdk.AddBreadcrumb($"API returned error code: {response.Code}, Message: {response.Message}", category: "ThirdPartyTool");
return ImmutableArray<ToolInfo>.Empty;
}
return response.Data;
}
catch (HttpRequestException ex)
{
SentrySdk.AddBreadcrumb($"HTTP request failed: {ex.Message}", category: "ThirdPartyTool");
SentrySdk.CaptureException(ex);
return ImmutableArray<ToolInfo>.Empty;
}
catch (TaskCanceledException ex)
{
SentrySdk.AddBreadcrumb($"Request timed out or was cancelled: {ex.Message}", category: "ThirdPartyTool");
SentrySdk.CaptureException(ex);
return ImmutableArray<ToolInfo>.Empty;
}
catch (Exception ex)
{
SentrySdk.AddBreadcrumb($"Failed to get third party tools: {ex.Message}", category: "ThirdPartyTool");
SentrySdk.CaptureException(ex);
return ImmutableArray<ToolInfo>.Empty;
}
}
public async ValueTask<bool> DownloadToolAsync(ToolInfo tool, IProgress<double>? progress = null, CancellationToken token = default)
{
try
{
string toolDirectory = GetToolDirectory(tool);
// 如果需要更新,先清理旧文件
if (NeedsUpdate(tool) && Directory.Exists(toolDirectory))
{
Directory.Delete(toolDirectory, true);
}
Directory.CreateDirectory(toolDirectory);
using (HttpClient httpClient = httpClientFactory.CreateClient())
{
if (tool.IsCompressed)
{
// 压缩包模式:下载并解压
await DownloadAndExtractCompressedToolAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
}
else
{
// 非压缩包模式:直接下载所有文件
await DownloadFilesAsync(httpClient, tool, toolDirectory, progress, token).ConfigureAwait(false);
}
}
// 保存本地工具信息
SaveLocalToolInfo(tool);
return true;
}
catch (Exception ex)
{
messenger.Send(InfoBarMessage.Error(ex));
return false;
}
}
public async ValueTask<bool> LaunchToolAsync(ToolInfo tool)
{
try
{
string toolDirectory = GetToolDirectory(tool);
// 优先使用 main_exe如果没有则查找可执行文件
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))
{
messenger.Send(InfoBarMessage.Warning(SH.ServiceThirdPartyToolNoExecutableFound));
return false;
}
// 如果 executablePath 是完整路径,直接使用;否则拼接目录
string fullPath = Path.IsPathRooted(executablePath)
? executablePath
: Path.Combine(toolDirectory, Path.GetFileName(executablePath));
if (!File.Exists(fullPath))
{
messenger.Send(InfoBarMessage.Warning(SH.FormatServiceThirdPartyToolFileNotFound(fullPath)));
return false;
}
// 尝试以管理员权限启动
ProcessStartInfo startInfo = new()
{
FileName = fullPath,
WorkingDirectory = toolDirectory,
UseShellExecute = true,
Verb = "runas", // 请求管理员权限
};
try
{
Process.Start(startInfo);
}
catch (System.ComponentModel.Win32Exception)
{
// 用户拒绝了管理员权限,尝试以普通权限启动
startInfo.Verb = string.Empty;
startInfo.UseShellExecute = false;
Process.Start(startInfo);
}
return true;
}
catch (Exception ex)
{
messenger.Send(InfoBarMessage.Error(ex));
return false;
}
}
public bool IsToolDownloaded(ToolInfo tool)
{
string toolDirectory = GetToolDirectory(tool);
if (!Directory.Exists(toolDirectory))
{
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)
{
string filePath = Path.Combine(toolDirectory, fileName);
if (!File.Exists(filePath))
{
return false;
}
}
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)
{
// 使用数据目录/工具名作为存储路径
return Path.Combine(HutaoRuntime.DataDirectory, "Tools", tool.Name);
}
}

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; return true;
} }
catch (IOException)
{
hash = default;
return false;
}
}
} }

View File

@@ -10,6 +10,9 @@
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<UseWinUI>true</UseWinUI> <UseWinUI>true</UseWinUI>
<UseWPF>False</UseWPF> <UseWPF>False</UseWPF>
<!-- 配置版本号 -->
<Version>1.18.6.0</Version>
<UseWindowsForms>False</UseWindowsForms> <UseWindowsForms>False</UseWindowsForms>
<ImplicitUsings>False</ImplicitUsings> <ImplicitUsings>False</ImplicitUsings>
<Nullable>enable</Nullable> <Nullable>enable</Nullable>
@@ -68,6 +71,23 @@
<Delete Files="@(LibFiles)" /> <Delete Files="@(LibFiles)" />
</Target> </Target>
<!-- 复制unlockfps.exe到输出目录 -->
<Target Name="CopyUnlockFpsExe" AfterTargets="Build">
<ItemGroup>
<UnlockFpsExeSource Include="$(MSBuildThisFileDirectory)..\..\..\bin\unlockfps.exe" />
</ItemGroup>
<Copy SourceFiles="@(UnlockFpsExeSource)" DestinationFolder="$(OutputPath)" ContinueOnError="true" />
</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" />
@@ -288,7 +308,7 @@
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Snap.Hutao.SourceGeneration" Version="1.3.14"> <PackageReference Include="Snap.Hutao.SourceGeneration" Version="1.3.15">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>

View File

@@ -20,6 +20,8 @@ internal partial class ScopedPage : Page
protected ScopedPage() protected ScopedPage()
{ {
// Allow a small set of recent pages to be cached to reduce navigation stutter.
NavigationCacheMode = NavigationCacheMode.Enabled;
// Events/Override Methods order // Events/Override Methods order
// ---------------------------------------------------------------------- // ----------------------------------------------------------------------
// Page Navigation methods: // Page Navigation methods:
@@ -103,16 +105,30 @@ internal partial class ScopedPage : Page
private void OnUnloaded(object sender, RoutedEventArgs e) private void OnUnloaded(object sender, RoutedEventArgs e)
{ {
// When navigation cache is enabled, the page instance is reused.
// Do not tear down DataContext/scope here to avoid invalid state on return.
if (NavigationCacheMode != NavigationCacheMode.Disabled)
{
return;
}
// Cancel all tasks executed by the view model // Cancel all tasks executed by the view model
viewCts.Cancel(); viewCts.Cancel();
if (DataContext is IViewModel viewModel) if (DataContext is IViewModel viewModel)
{ {
// Wait to ensure critical viewmodel operation is completed try
{
// 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()) using (viewModel.CriticalSection.Enter())
{ {
viewModel.Uninitialize(); viewModel.Uninitialize();
} }
}
catch (OperationCanceledException)
{
}
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

@@ -20,6 +20,8 @@
<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>
<!-- ScrollViewer随 ContentDialog 主题高度上限自适应;内容过高时纵向滚动,避免裁切或写死 MaxHeight -->
<ScrollViewer HorizontalScrollBarVisibility="Disabled" VerticalScrollBarVisibility="Auto">
<StackPanel Spacing="{StaticResource SettingsCardSpacing}"> <StackPanel Spacing="{StaticResource SettingsCardSpacing}">
<cwc:SettingsCard Header="{shuxm:ResourceString Name=ViewDialogCultivateBatchAvatarLevelTarget}"> <cwc:SettingsCard Header="{shuxm:ResourceString Name=ViewDialogCultivateBatchAvatarLevelTarget}">
<NumberBox <NumberBox
@@ -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>
<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> </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

@@ -0,0 +1,50 @@
<ContentDialog
x:Class="Snap.Hutao.UI.Xaml.View.Dialog.ThirdPartyToolDialog"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:shuxm="using:Snap.Hutao.UI.Xaml.Markup"
Title="{x:Bind Tool.Name, Mode=OneWay}"
CloseButtonText="{shuxm:ResourceString Name=ContentDialogCancelCloseButtonText}"
DefaultButton="Primary"
PrimaryButtonText="{shuxm:ResourceString Name=ViewDialogThirdPartyToolLaunch}"
Style="{StaticResource DefaultContentDialogStyle}"
mc:Ignorable="d">
<Grid RowSpacing="12">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<TextBlock
Grid.Row="0"
Style="{StaticResource BodyTextBlockStyle}"
Text="{shuxm:ResourceString Name=ViewDialogThirdPartyToolDescription}"
TextWrapping="Wrap"/>
<TextBlock
Grid.Row="1"
Style="{StaticResource BodyStrongTextBlockStyle}"
Text="{x:Bind Tool.Description, Mode=OneWay}"
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
Grid.Row="3"
Height="4"
IsIndeterminate="{x:Bind IsDownloading.Value, Mode=OneWay, FallbackValue=False}"
Visibility="{x:Bind IsDownloading.Value, Mode=OneWay, Converter={StaticResource BoolToVisibilityConverter}}"/>
</Grid>
</ContentDialog>

View File

@@ -0,0 +1,81 @@
using Microsoft.UI.Xaml.Controls;
using Snap.Hutao.Factory.ContentDialog;
using Snap.Hutao.Service.Notification;
using Snap.Hutao.Service.ThirdPartyTool;
using Snap.Hutao.Web.ThirdPartyTool;
namespace Snap.Hutao.UI.Xaml.View.Dialog;
[DependencyProperty<ToolInfo>("Tool")]
[DependencyProperty<bool>("IsDownloading", DefaultValue = false)]
internal sealed partial class ThirdPartyToolDialog : ContentDialog
{
private readonly IContentDialogFactory contentDialogFactory;
private readonly IThirdPartyToolService thirdPartyToolService;
private readonly IMessenger messenger;
[GeneratedConstructor(InitializeComponent = true)]
public partial ThirdPartyToolDialog(IServiceProvider serviceProvider);
public ThirdPartyToolDialog(IServiceProvider serviceProvider, ToolInfo tool)
: this(serviceProvider)
{
Tool = tool;
PrimaryButtonClick += OnPrimaryButtonClick;
}
private void OnPrimaryButtonClick(ContentDialog sender, ContentDialogButtonClickEventArgs args)
{
args.Cancel = true;
HandleLaunchAsync().SafeForget();
}
private async Task HandleLaunchAsync()
{
// 在 UI 线程上获取 Tool 的引用,避免后续跨线程访问依赖属性
ToolInfo? tool = Tool;
try
{
IsDownloading = true;
if (tool is null)
{
return;
}
// 检查工具是否需要下载或更新
bool needDownload = !thirdPartyToolService.IsToolDownloaded(tool) || thirdPartyToolService.NeedsUpdate(tool);
if (needDownload)
{
// 下载工具
bool downloadSuccess = await thirdPartyToolService.DownloadToolAsync(tool, null).ConfigureAwait(false);
if (!downloadSuccess)
{
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
IsDownloading = false;
return;
}
}
// 启动工具
bool launchSuccess = await thirdPartyToolService.LaunchToolAsync(tool).ConfigureAwait(false);
if (launchSuccess)
{
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
Hide();
return;
}
}
catch (Exception ex)
{
messenger.Send(InfoBarMessage.Error(ex));
}
finally
{
await contentDialogFactory.TaskContext.SwitchToMainThreadAsync();
IsDownloading = false;
}
}
}

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>

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