mirror of
https://github.com/wangdage12/Snap.Hutao.git
synced 2026-06-18 00:34:50 +08:00
Compare commits
134 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9da2c2eb8b | ||
|
|
ed3f2270b3 | ||
|
|
0eb8b58711 | ||
|
|
0777180df5 | ||
|
|
f29ddd62a7 | ||
|
|
8588f7736a | ||
|
|
f66d712a1b | ||
|
|
a02d7e533f | ||
|
|
57461a06dd | ||
|
|
82f1820ca9 | ||
|
|
9ea2bb1a6e | ||
|
|
7a3f125846 | ||
|
|
94592c8498 | ||
|
|
ed4626a74c | ||
|
|
c6a2212caa | ||
|
|
18afbffbcc | ||
|
|
64628b50b5 | ||
|
|
11a5efa488 | ||
|
|
726c4203d2 | ||
|
|
ace17fcc7b | ||
|
|
8f5532819a | ||
|
|
70c0970e2e | ||
|
|
a988a65190 | ||
|
|
dead0166a1 | ||
|
|
f19dbf78a0 | ||
|
|
a41b7e4a01 | ||
|
|
8bb177d08f | ||
|
|
1e600db869 | ||
|
|
be9f3f6a25 | ||
|
|
d3865b7992 | ||
|
|
66263a82f2 | ||
|
|
a2ddeadd0a | ||
|
|
246109227d | ||
|
|
515fa004bb | ||
|
|
7c706f8dc7 | ||
|
|
9de07754c7 | ||
|
|
8e86ccc560 | ||
|
|
e1df07ac21 | ||
|
|
1dabff07a1 | ||
|
|
640686a837 | ||
|
|
e9ed7928d6 | ||
|
|
4d2943d1c9 | ||
|
|
74e9427451 | ||
|
|
cb6d728c35 | ||
|
|
f87b80cc9e | ||
|
|
4b313b134e | ||
|
|
0c775a5d3d | ||
|
|
00cd5a8c07 | ||
|
|
d93ae2bb83 | ||
|
|
2f148488f4 | ||
|
|
df92894307 | ||
|
|
5fad9ad855 | ||
|
|
1ed2f4f29e | ||
|
|
db6df72791 | ||
|
|
bd9f188ac1 | ||
|
|
56c36a01ae | ||
|
|
da6f248509 | ||
|
|
068eb65fef | ||
|
|
09a8cded2f | ||
|
|
c38fdf30d0 | ||
|
|
bc1ff03d0a | ||
|
|
b288860c3b | ||
|
|
1e40a6e576 | ||
|
|
d342b37dc0 | ||
|
|
179177a77c | ||
|
|
6c68a55d81 | ||
|
|
7bd61c8035 | ||
|
|
c19b71e2c4 | ||
|
|
45b7383fc1 | ||
|
|
c83a2f3e9d | ||
|
|
2bab0baf69 | ||
|
|
2726e74731 | ||
|
|
6d08f669e7 | ||
|
|
84b9b97059 | ||
|
|
0b846f11b7 | ||
|
|
c9adc06210 | ||
|
|
6c9f50b055 | ||
|
|
6c515caa88 | ||
|
|
b834ae5425 | ||
|
|
49ae21e02c | ||
|
|
88f81c5582 | ||
|
|
274b2766ce | ||
|
|
b9130979c1 | ||
|
|
5019987c08 | ||
|
|
adaf0972ce | ||
|
|
b9169ad41a | ||
|
|
00af738520 | ||
|
|
3538665bc6 | ||
|
|
c1a1871284 | ||
|
|
01f965d260 | ||
|
|
cadd35a93e | ||
|
|
0de6d4b71c | ||
|
|
a83f119e8a | ||
|
|
2278c83dd2 | ||
|
|
867f8b6558 | ||
|
|
7e6a6a509c | ||
|
|
7aaa61dcf3 | ||
|
|
1fe5b4969e | ||
|
|
e52ed5470e | ||
|
|
4434d76e35 | ||
|
|
4a9c3229a0 | ||
|
|
1090dfa7c6 | ||
|
|
a4eb3d6f8a | ||
|
|
d9c43844b7 | ||
|
|
2cc2cc80ac | ||
|
|
cc926e4352 | ||
|
|
fa4b975f46 | ||
|
|
299cdee329 | ||
|
|
f49f40b5fd | ||
|
|
ff21a91cc6 | ||
|
|
cc47e050d1 | ||
|
|
c2d0156a9d | ||
|
|
eac314f7d9 | ||
|
|
f1132b79f0 | ||
|
|
63c4792e00 | ||
|
|
5f196253b3 | ||
|
|
a7bb931ea5 | ||
|
|
d318dcbfd0 | ||
|
|
e045413ac1 | ||
|
|
1039623cbf | ||
|
|
3f50507490 | ||
|
|
e55d6ea1c5 | ||
|
|
e96c9f1f49 | ||
|
|
f3d47aeffd | ||
|
|
7ee92db156 | ||
|
|
7c25951950 | ||
|
|
2ad17f60bf | ||
|
|
4fdea542b0 | ||
|
|
4d5e5342f2 | ||
|
|
fde960b7cc | ||
|
|
cfc7ba6274 | ||
|
|
c463f1809c | ||
|
|
3d352c1c12 | ||
|
|
fbf3c0c695 |
13
.github/FUNDING.yml
vendored
13
.github/FUNDING.yml
vendored
@@ -1,13 +0,0 @@
|
||||
# These are supported funding model platforms
|
||||
|
||||
github: [DGP-Studio]
|
||||
patreon: # Replace with a single Patreon username
|
||||
open_collective: snaphutao
|
||||
ko_fi: # Replace with a single Ko-fi username
|
||||
tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel
|
||||
community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry
|
||||
liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
otechie: # Replace with a single Otechie username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
custom: https://afdian.com/a/DismissedLight
|
||||
93
.github/ISSUE_TEMPLATE/CHS-bug-report.yml
vendored
93
.github/ISSUE_TEMPLATE/CHS-bug-report.yml
vendored
@@ -1,93 +0,0 @@
|
||||
name: 问题反馈
|
||||
description: 通过这个议题向开发团队反馈你发现的程序中的问题
|
||||
title: "[Bug]: 在这里填写一个合适的标题"
|
||||
type: "Bug"
|
||||
labels: ["priority:none"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **请在上方以一句话简短地概括你的问题作为标题**
|
||||
> 请按下方的要求填写完整的问题表单,以便我们更快的定位问题。
|
||||
|
||||
- type: input
|
||||
id: winver
|
||||
attributes:
|
||||
label: Windows 版本
|
||||
description: |
|
||||
`Win+R` 输入 `winver` 回车后在打开的窗口第二行可以找到
|
||||
placeholder: 例:22000.556
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: shver
|
||||
attributes:
|
||||
label: Snap Hutao 版本
|
||||
description: 在应用标题,应用程序的反馈中心界面中可以找到
|
||||
placeholder: 例:1.9.9.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: deviceid
|
||||
attributes:
|
||||
label: 设备 ID
|
||||
description: |
|
||||
> 在胡桃工具箱的反馈中心界面,你可以找到并复制你的设备 ID
|
||||
> 如果你的问题涉及程序崩溃,请填写该项,这将有助于我们定位问题
|
||||
> 如果你的程序已经无法启动,请下载并运行[诊断工具](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.Diagnostic.Tooling.exe),它将显示你的设备 ID
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-set-category
|
||||
attributes:
|
||||
label: 问题分类
|
||||
description: 请设置一个你认为合适的分类,这将帮助我们快速定位问题
|
||||
options:
|
||||
- 安装和环境
|
||||
- 游戏启动器
|
||||
- 祈愿记录
|
||||
- 成就管理
|
||||
- 我的角色
|
||||
- 实时便笺
|
||||
- 养成计算
|
||||
- 深境螺旋/胡桃数据库
|
||||
- Wiki
|
||||
- 米游社账号面板
|
||||
- 每日签到奖励
|
||||
- 胡桃通行证/胡桃云
|
||||
- 用户界面
|
||||
- 文件缓存
|
||||
- 公告
|
||||
- 其它
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 发生了什么?
|
||||
description: |
|
||||
详细的描述问题发生前后的行为,以便我们解决问题。**如果你的问题涉及程序崩溃,你应当检查 Windows 事件查看器,并将相关的 `.Net 错误`详情附上**
|
||||
如果你无法找到该日志,请下载并运行[诊断工具](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.Diagnostic.Tooling.exe),它将转储问题日志至工具运行目录中的 `Snap.Hutao Error Log.txt`
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-expected
|
||||
attributes:
|
||||
label: 你期望发生的行为?
|
||||
description: 详细的描述你期望发生的行为,突出与目前(可能不正确的)行为的不同
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist-final
|
||||
attributes:
|
||||
label: 最后一步
|
||||
description: 回顾你的回答
|
||||
options:
|
||||
- label: 我认为上述的描述已经足以详细,以允许开发人员能复现该问题
|
||||
required: true
|
||||
26
.github/ISSUE_TEMPLATE/CHS-feature-request.yml
vendored
26
.github/ISSUE_TEMPLATE/CHS-feature-request.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: 功能请求
|
||||
description: 通过这个议题来向开发团队分享你的想法
|
||||
title: "[Feat]: 在这里填写一个合适的标题"
|
||||
type: "Feature"
|
||||
labels: ["needs-triage", "priority:none"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
请按下方的要求填写完整的问题表单。
|
||||
|
||||
- type: textarea
|
||||
id: back
|
||||
attributes:
|
||||
label: 背景与动机
|
||||
description: 添加此功能的理由,如果你想要实现多个功能,请分别发起多个单独的议题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: req
|
||||
attributes:
|
||||
label: 想要实现或优化的功能
|
||||
description: 详细的描述一下你想要的功能,描述的越具体,采纳的可能性越高
|
||||
validations:
|
||||
required: true
|
||||
84
.github/ISSUE_TEMPLATE/CHS-network-issue.yml
vendored
84
.github/ISSUE_TEMPLATE/CHS-network-issue.yml
vendored
@@ -1,84 +0,0 @@
|
||||
name: 网络问题
|
||||
description: 通过这个议题来反馈网络问题
|
||||
title: "[Network]: 在这里填写一个合适的标题"
|
||||
type: "Bug"
|
||||
labels: ["area-Network"]
|
||||
assignees:
|
||||
- Lightczx
|
||||
- Masterain98
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**请先在上方为工单设置一个合适的标题**
|
||||
**请按下方的要求填写完整的问题表单,以便我们更快的定位问题。**
|
||||
|
||||
- type: textarea
|
||||
id: network-diagnosis-report
|
||||
attributes:
|
||||
label: 提交你的网络诊断报告
|
||||
description: |
|
||||
停下!
|
||||
**在填写下面的问题之前请先使用我们的网络诊断工具**
|
||||
**这个工具将会生成一份报告并加密压缩,请将这份报告拖入下面的框中,让其与你的工单一起被上传提交**
|
||||
- 你可以点击下面的链接以下载网络诊断工具:
|
||||
- [GitHub](https://github.com/Masterain98/network-diagnosis-tool/releases/latest/download/SH-Network-Diagnosis.exe)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: user-geo-location
|
||||
attributes:
|
||||
label: 你的地理位置
|
||||
description: |
|
||||
中国用户请精确到省级行政区
|
||||
海外用户请精确到国家
|
||||
placeholder: 北京
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-isp
|
||||
attributes:
|
||||
label: 你的运营商
|
||||
description: 海外用户请选其它
|
||||
options:
|
||||
- 中国电信
|
||||
- 中国联通
|
||||
- 中国移动
|
||||
- 中国广电
|
||||
- 其它
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-issue-category
|
||||
attributes:
|
||||
label: 你的问题
|
||||
description: 选择一个问题类别
|
||||
options:
|
||||
- 完全无法连接服务器
|
||||
- 连接速度慢
|
||||
- 获取到了不正确的页面或数据
|
||||
- 客户端图片下载错误
|
||||
- 客户端图片预下载错误
|
||||
- 其它
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: 你的问题(补充)
|
||||
description: 如果你在上一项中选择了`其它`或者你有更多信息需要提供,请在这里写下来
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist-final
|
||||
attributes:
|
||||
label: 最后一步
|
||||
description: 检查你提交的议题
|
||||
options:
|
||||
- label: 我已经在该议题中上传了包含网络诊断报告的加密压缩包
|
||||
required: true
|
||||
93
.github/ISSUE_TEMPLATE/ENG-bug-report.yml
vendored
93
.github/ISSUE_TEMPLATE/ENG-bug-report.yml
vendored
@@ -1,93 +0,0 @@
|
||||
name: BUG Report [English Form]
|
||||
description: Tell us what issue you get
|
||||
title: "[ENG][Bug]: Place your Issue Title Here"
|
||||
type: "Bug"
|
||||
labels: ["priority:none"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
> **Please use one sentence to briefly describe your issue as title above**
|
||||
> Please follow the instruction below to fill the form, so we can locate the issue quickly
|
||||
|
||||
- type: input
|
||||
id: winver
|
||||
attributes:
|
||||
label: Windows Version
|
||||
description: |
|
||||
Use `Win+R` and input `winver`, Windows build version is usually at the second line
|
||||
placeholder: e.g. 22000.556
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: shver
|
||||
attributes:
|
||||
label: Snap Hutao Version
|
||||
description: You can find the version in application's title bar
|
||||
placeholder: e.g. 1.9.9.0
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: deviceid
|
||||
attributes:
|
||||
label: Device ID
|
||||
description: |
|
||||
> In Snap Hutao's Feedback Center, you can find and copy your device ID
|
||||
> If your issue is about program crash, please fill this so we can dump the log and locate the source easier
|
||||
> If your program cannot startup, please download and run [Diagnostic Tooling](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.Diagnostic.Tooling.exe), it will shows your device ID.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-set-category
|
||||
attributes:
|
||||
label: Issue Category
|
||||
description: Please select the most associated category of your issue
|
||||
options:
|
||||
- Installation and Environment
|
||||
- Game Launcher
|
||||
- Wish Export
|
||||
- Achievement
|
||||
- My Character
|
||||
- Realtime Note
|
||||
- Develop Plan
|
||||
- Spiral Abyss
|
||||
- Wiki
|
||||
- MiHoYo Account Panel
|
||||
- Daily Checkin Reward
|
||||
- Hutao Passport/Hutao Cloud
|
||||
- User Interface
|
||||
- File Cache
|
||||
- Announcement
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: What Happened?
|
||||
description: |
|
||||
Describe your issue in detail to help us identify the issue. **If your issue is about program crash, you should check Windows Event Viewer, and attach associated `.Net Error` details here**If your program cannot startup, please download and run [this PowerShell script](https://github.com/DGP-Studio/ISSUE_TEMPLATES/releases/download/get_device_id/GetHutaoDeviceId.ps1), it will shows your device ID.
|
||||
If you cannot find it, please download and run [Diagnosis Tool](https://github.com/DGP-Automation/ISSUE_TEMPLATES/releases/download/diagnosis_tools/Snap.Hutao.Diagnostic.Tooling.exe), it will dump the error log to `Snap.Hutao Error Log.txt` in the working directory of the tool.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-expected
|
||||
attributes:
|
||||
label: What is expected?
|
||||
description: Describe expected outcome, highlight the difference with current outcome
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist-final
|
||||
attributes:
|
||||
label: Last Step
|
||||
description: Review your Form
|
||||
options:
|
||||
- label: I believe the description above is detail enough to allow developers to reproduce the issue
|
||||
required: true
|
||||
26
.github/ISSUE_TEMPLATE/ENG-feature-request.yml
vendored
26
.github/ISSUE_TEMPLATE/ENG-feature-request.yml
vendored
@@ -1,26 +0,0 @@
|
||||
name: Feature Request [English Form]
|
||||
description: Tell us about your thought
|
||||
title: "[Feat]: Place your title here"
|
||||
type: "Feature"
|
||||
labels: ["needs-triage", "priority:none"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please fill the form below
|
||||
|
||||
- type: textarea
|
||||
id: back
|
||||
attributes:
|
||||
label: Background & Motivation
|
||||
description: Reason why this feature is needed. If multiple features is requested, please open multiple issues for each of them.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: req
|
||||
attributes:
|
||||
label: Detail of the Feature
|
||||
description: Descripbe the feaure in detail. The more detailed and convincing the desciprtion the more likyly feature will be accepted.
|
||||
validations:
|
||||
required: true
|
||||
79
.github/ISSUE_TEMPLATE/ENG-network-issue.yml
vendored
79
.github/ISSUE_TEMPLATE/ENG-network-issue.yml
vendored
@@ -1,79 +0,0 @@
|
||||
name: Network Issue [English Form]
|
||||
description: Submit this issue form when network issue affect your client experience
|
||||
title: "[Network]: Place your title here"
|
||||
type: "Bug"
|
||||
labels: ["area-Network"]
|
||||
assignees:
|
||||
- Lightczx
|
||||
- Masterain98
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
**Please use one sentence to briefly describe your issue as title above**
|
||||
**Please follow the instruction below to fill the form, so we can locate the issue quickly**
|
||||
|
||||
- type: textarea
|
||||
id: network-diagnosis-report
|
||||
attributes:
|
||||
label: Submit Your Network Diagnosis Report
|
||||
description: |
|
||||
STOP HERE!
|
||||
**Please run our network diagnosis tool before filling this form**
|
||||
**The diagnosis tool will generate a report and add it into a password-protected archive. Drag the `.zip` archive to the box below so it can be uploaded.**
|
||||
- Use the following link to download the Network Diagnosis Tool:
|
||||
- [GitHub](https://github.com/Masterain98/network-diagnosis-tool/releases/latest/download/SH-Network-Diagnosis.exe)
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: user-geo-location
|
||||
attributes:
|
||||
label: Your Geographical Location
|
||||
description: |
|
||||
Description accurate to country
|
||||
placeholder: USA
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: user-isp
|
||||
attributes:
|
||||
label: Your ISP Name
|
||||
description: |
|
||||
Name of your Internet service provider
|
||||
placeholder: AT&T
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-issue-category
|
||||
attributes:
|
||||
label: Issue Category
|
||||
description: Select an issue category
|
||||
options:
|
||||
- Cannot connect to server completely
|
||||
- Slow spped
|
||||
- Fetched wrong page or data
|
||||
- Image download error in the client
|
||||
- Image set pre-download error (client welcome wizard process)
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: what-happened
|
||||
attributes:
|
||||
label: Your Issue (cont.)
|
||||
description: If you selected `Other` in previous dropdown, please explain your issue in detail here.
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist-final
|
||||
attributes:
|
||||
label: One Last Step
|
||||
description: Check your issue form
|
||||
options:
|
||||
- label: I confirm I have attached the network diagnosis report archive in the issue
|
||||
required: true
|
||||
31
.github/ISSUE_TEMPLATE/MGMT-publish.yml
vendored
31
.github/ISSUE_TEMPLATE/MGMT-publish.yml
vendored
@@ -1,31 +0,0 @@
|
||||
name: Publish Process
|
||||
description: FOR ADMIN USE ONLY. WILL CAUSE A BAN IF NO PERMISSION.
|
||||
title: "[Publish]: Version 1.9.98"
|
||||
labels: ["Publish"]
|
||||
assignees:
|
||||
- Lightczx
|
||||
body:
|
||||
- type: textarea
|
||||
id: main-body
|
||||
attributes:
|
||||
label: Publish Process
|
||||
value: |
|
||||
|
||||
## 创建版本
|
||||
|
||||
- [ ] 同步一次 [Crowdin](https://crowdin.com/project/snap-hutao) 翻译
|
||||
- [ ] 发布 RC 版本(Optional)
|
||||
- [ ] 合并入主分支
|
||||
- [ ] 整理更新内容,等待翻译
|
||||
- [ ] 在 [Snap.Hutao.Docs@next-patch](https://github.com/DGP-Studio/Snap.Hutao.Docs/tree/next-patch) 分支更新文档并直接开 PR
|
||||
- [ ] 更新日志
|
||||
- [ ] 功能文档更新
|
||||
|
||||
- type: checkboxes
|
||||
id: checklist-final
|
||||
attributes:
|
||||
label: Final Check
|
||||
description: Understand what you are doing
|
||||
options:
|
||||
- label: I understand that I will get banned from repository if I don't have permission to use this template
|
||||
required: true
|
||||
14
.github/ISSUE_TEMPLATE/config.yml
vendored
14
.github/ISSUE_TEMPLATE/config.yml
vendored
@@ -1,14 +0,0 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Snap Hutao 官方文档 / Snap Hutao Document
|
||||
url: https://hut.ao
|
||||
about: 请在提出问题前阅读文档 / Read the document before submit the issue
|
||||
|
||||
- name: 常见问题 / FAQ
|
||||
url: https://hut.ao/advanced/FAQ.html
|
||||
about: 常见的用户提出的问题 / Common questions asked by users
|
||||
|
||||
- name: 常见程序异常 / Common Program Exceptions
|
||||
url: https://hut.ao/advanced/exceptions.html
|
||||
about: 用户通常能自行解决这些问题 / Users may solve these problems by themselves
|
||||
|
||||
13
.github/ISSUE_TEMPLATE/task.yml
vendored
13
.github/ISSUE_TEMPLATE/task.yml
vendored
@@ -1,13 +0,0 @@
|
||||
name: 内部任务
|
||||
description: 此Issue模板仅用于创建内部任务,非 DGP Studio 成员请勿使用
|
||||
title: "[Task]: 在这里填写一个合适的标题"
|
||||
type: "Task"
|
||||
labels: ["priority:none"]
|
||||
body:
|
||||
- type: textarea
|
||||
id: content
|
||||
attributes:
|
||||
label: 背景与动机
|
||||
description: 添加相关的说明
|
||||
validations:
|
||||
required: true
|
||||
2
.github/workflows/PublishDistribution.yml
vendored
2
.github/workflows/PublishDistribution.yml
vendored
@@ -64,7 +64,7 @@ jobs:
|
||||
-Body $r2Data `
|
||||
-ContentType "application/json"
|
||||
Write-Output $response2.Content
|
||||
- uses: benc-uk/workflow-dispatch@v1.2.4
|
||||
- uses: benc-uk/workflow-dispatch@v1.3.2
|
||||
with:
|
||||
workflow: Build
|
||||
repo: DGP-Studio/hutao-installer
|
||||
|
||||
6
.github/workflows/alpha.yml
vendored
6
.github/workflows/alpha.yml
vendored
@@ -73,7 +73,7 @@ jobs:
|
||||
runs-on: ${{ needs.select-runner.outputs.runner }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET (self-hosted)
|
||||
if: ${{ needs.select-runner.outputs.runner == 'sjc1' }}
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: ${{ needs.select-runner.outputs.runner == 'windows-latest' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }}
|
||||
@@ -113,7 +113,7 @@ jobs:
|
||||
|
||||
- name: Upload signed msix
|
||||
if: success() && github.event_name != 'pull_request'
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}
|
||||
path: ${{ github.workspace }}/src/output/Snap.Hutao.Alpha-${{ steps.cake.outputs.version }}.msix
|
||||
|
||||
6
.github/workflows/canary.yml
vendored
6
.github/workflows/canary.yml
vendored
@@ -21,7 +21,7 @@ jobs:
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: develop
|
||||
fetch-depth: 0
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
|
||||
- name: Cache NuGet packages
|
||||
if: ${{ steps.merge.outputs.continue == 'true' }}
|
||||
uses: actions/cache@v4
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: ~/.nuget/packages
|
||||
key: ${{ runner.os }}-nuget-${{ hashFiles('**/Snap.Hutao.csproj') }}
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
|
||||
- name: Upload signed msix
|
||||
if: ${{ success() && steps.merge.outputs.continue == 'true' }}
|
||||
uses: actions/upload-artifact@v5
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Snap.Hutao.Canary-${{ steps.cake.outputs.version }}
|
||||
path: ${{ github.workspace }}/src/output/Snap.Hutao.Canary-${{ steps.cake.outputs.version }}.msix
|
||||
|
||||
2
.github/workflows/lock_closed_issues.yml
vendored
2
.github/workflows/lock_closed_issues.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
action:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: dessant/lock-threads@v5
|
||||
- uses: dessant/lock-threads@v6
|
||||
with:
|
||||
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.'
|
||||
|
||||
38
.github/workflows/msi-build.yml
vendored
Normal file
38
.github/workflows/msi-build.yml
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
name: Build MSI Installer
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout source
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup .NET SDK
|
||||
uses: actions/setup-dotnet@v5
|
||||
with:
|
||||
dotnet-version: 10.0.x
|
||||
|
||||
- name: Install Wix Toolset 4
|
||||
run: dotnet tool install --global wix --version 4.0.1
|
||||
|
||||
- name: Restore NuGet packages
|
||||
run: dotnet restore src/Snap.Hutao/Snap.Hutao.slnx
|
||||
|
||||
- name: Build WinUI 3 project (self-contained)
|
||||
run: dotnet build src/Snap.Hutao/Snap.Hutao/Snap.Hutao.csproj -c Release
|
||||
|
||||
- name: Build MSI installer
|
||||
run: dotnet build src/Snap.Hutao/Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj -c Release
|
||||
|
||||
- name: Upload MSI Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: Snap.Hutao-MSI
|
||||
path: |
|
||||
src/Snap.Hutao/Snap.Hutao.Installer/bin/x64/Release/en-US/*.msi
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -1,4 +1,4 @@
|
||||
desktop.ini
|
||||
desktop.ini
|
||||
|
||||
*.csproj.user
|
||||
*.DotSettings.user
|
||||
@@ -25,3 +25,6 @@ src/Snap.Hutao/Snap.Hutao/Generated Files/
|
||||
tools/
|
||||
|
||||
src/Snap.Hutao/Snap.Hutao/AppPackages
|
||||
/src/Snap.Hutao/Snap.Hutao.Installer/obj
|
||||
/src/Snap.Hutao/Snap.Hutao.Installer/bin/x64/Release/en-US
|
||||
/src/Snap.Hutao/Snap.Hutao.Installer/bin
|
||||
|
||||
199
README.md
199
README.md
@@ -1,54 +1,21 @@
|
||||
<p align="center">
|
||||
<img src="https://github.com/user-attachments/assets/976e057c-f01e-486b-9fa0-04744ae96f99" alt="Snap Hutao Banner" width="600"/>
|
||||
</p>
|
||||
|
||||
<h1 align="center">Snap Hutao</h1>
|
||||
|
||||
<p align="center">
|
||||
🎮 开源的原神工具箱,专为 Windows 平台设计,改善桌面端玩家的游戏体验
|
||||
<br/>
|
||||
🎮 An open-source Genshin Impact toolkit for Windows, designed to improve the desktop gaming experience
|
||||
</p>
|
||||
|
||||
<div align="center">
|
||||
<table>
|
||||
<tr>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<b>Latest CI/CD Build</sub>
|
||||
</td>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<b>Latest Release</sub>
|
||||
</td>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<b>Downloads</sub>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<a href="https://ci.appveyor.com/project/DGP-Studio/snap-hutao">
|
||||
<img src="https://ci.appveyor.com/api/projects/status/n4s40t9llru4si9y?svg=true" alt="Build Status"/>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<a href="https://github.com/DGP-Studio/Snap.Hutao/releases/latest">
|
||||
<img src="https://img.shields.io/github/release/DGP-Studio/Snap.Hutao?style=flat" alt="Release"/>
|
||||
</a>
|
||||
</td>
|
||||
<td align="center" style="padding:0 10px;">
|
||||
<img src="https://img.shields.io/github/downloads/DGP-Studio/Snap.Hutao/total.svg?style=flat" alt="Downloads"/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
|
||||
---
|
||||
|
||||
## 📖 简介 / Introduction
|
||||
|
||||
**中文**
|
||||
胡桃工具箱是一款以 MIT 协议开源的原神工具箱,专为现代化 Windows 平台设计,旨在改善桌面端玩家的游戏体验。
|
||||
|
||||
自带的注入功能只有FPS调整,只保证FPS调整长期可用,你可以使用`注入选项`下方的第三方工具来使用更多功能,本项目提供的所有注入功能都不会影响游戏的公平性。
|
||||
|
||||
官网:https://htserver.wdg12.work/
|
||||
|
||||
**该版本的特点:**
|
||||
- 尽量保留原版功能,少重写功能,稳定性强
|
||||
- 只集成没有争议的安全的注入功能
|
||||
- 大部分注入功能以第三方工具形式提供,点击即用
|
||||
- 永久免费的云抽卡日志
|
||||
|
||||
有条件的话可以加入discord服务器:https://discord.gg/ucH3mgeWpQ
|
||||
|
||||
**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.
|
||||
|
||||
@@ -56,89 +23,103 @@ Snap Hutao is an open-source Genshin Impact toolkit under MIT license, designed
|
||||
|
||||
## 🚀 安装 / Installation
|
||||
|
||||
**中文**
|
||||
你可以按照 [快速开始](https://hut.ao/zh/quick-start.html) 文档中提供的流程安装并设置 Snap Hutao。
|
||||
目前 Sanp.Hutao.Rev 更新了打包方式,并采用了标准现代的 msi 安装,方便程序获取管理员权限和更多的功能设置,不再需要原 Depolyment
|
||||
|
||||
**English**
|
||||
You can follow the instructions in the [Quick Start](https://hut.ao/en/quick-start.html) document to install and set up Snap Hutao.
|
||||
只有`.msi`安装包安装的可以和之前的版本共存,如果通过`.msix`安装包安装则可能出现`0x80073CF3`,备份旧版本数据文件夹后卸载旧版本即可继续安装,将旧版本数据文件夹里面的文件复制到该版本的数据文件夹中即可恢复数据
|
||||
|
||||
有时候我们在对某些功能有重大更改时发布测试版,可在官网的下载,可加入discord服务器报告功能使用情况和获取测试通知
|
||||
|
||||
---
|
||||
|
||||
## 🌍 本地化翻译 / Localization
|
||||
## 开发
|
||||
项目启动位置已升级为 VS2026 的 slnx 格式 Snap.Hutao\src\Snap.Hutao\Snap.Hutao.slnx
|
||||
> [!WARNING]
|
||||
> 要使该项目可以长期运行,我们需要以下资源
|
||||
> 1. 元数据的编写
|
||||
> 2. 图片资源
|
||||
|
||||
Snap Hutao 使用 [Crowdin](https://translate.hut.ao/) 作为客户端文本翻译平台,在该平台上你可以为你熟悉的语言提交翻译文本。我们感谢每一个为 Snap Hutao 做出贡献的社区成员,并且欢迎更多的朋友能参与到这个项目中。
|
||||
已同步原作者的元数据
|
||||
|
||||
Snap Hutao uses [Crowdin](https://translate.hut.ao/) as a client text translation platform where you can submit translated text for languages you are familiar with. We are grateful to every community member who has contributed to Snap Hutao and welcome more friends to participate in this project.
|
||||
**目前元数据的编写进度:**
|
||||
|
||||
| Language | Status |
|
||||
|----------|--------|
|
||||
| zh-TW | [](https://crowdin.com/project/snap-hutao) |
|
||||
| en | [](https://crowdin.com/project/snap-hutao) |
|
||||
| fr | [](https://crowdin.com/project/snap-hutao) |
|
||||
| id | [](https://crowdin.com/project/snap-hutao) |
|
||||
| ja | [](https://crowdin.com/project/snap-hutao) |
|
||||
| ko | [](https://crowdin.com/project/snap-hutao) |
|
||||
| pt-PT | [](https://crowdin.com/project/snap-hutao) |
|
||||
| ru | [](https://crowdin.com/project/snap-hutao) |
|
||||
| vi | [](https://crowdin.com/project/snap-hutao) |
|
||||
| 项目(V6.4) | 是否完成 |
|
||||
| ----------- | ----------- |
|
||||
| 总体数据 | ✔️ |
|
||||
|
||||
✔️:已完成
|
||||
❌:未编写
|
||||
❇️:编写中
|
||||
❔:数据暂时无法得到
|
||||
/ :似乎不需要变动
|
||||
💠:低优先级,以后编写
|
||||
|
||||
**若需编译项目,请使用[Visual Studio 2026](https://visualstudio.microsoft.com/zh-hans/)**
|
||||
调试选项请选择unpackaged(不打包)
|
||||
**原开发文档现在还可使用(其中的AI功能很好用),以下是开发文档链接:**
|
||||
|
||||
https://deepwiki.com/DGP-Studio/Snap.Hutao
|
||||
|
||||
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
|
||||
|
||||
## 资源和服务器状态
|
||||
|
||||
|
||||
<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)
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ 贡献 / Contribute
|
||||
**元数据仓库:**
|
||||
https://github.com/wangdage12/Snap.Metadata
|
||||
|
||||
- [向我们提交 PR / Make Pull Requests](https://hut.ao/development/contribute.html)
|
||||
- [为我们更新文档 / Enhance our Document](https://github.com/DGP-Studio/Snap.Hutao.Docs)
|
||||
- [通过 DeepWiKi 了解项目结构 / Understand Project Structure with DeepWiKi](https://deepwiki.com/DGP-Studio/Snap.Hutao)
|
||||
- [](https://deepwiki.com/DGP-Studio/Snap.Hutao)
|
||||
仓库镜像:
|
||||
|
||||
http://htgit.wdg.cloudns.ch/wdg1122/Snap.Metadata
|
||||
|
||||
---
|
||||
|
||||
## 🙏 特别感谢 / Special Thanks
|
||||
**API:**
|
||||
|
||||
- [HolographicHat](https://github.com/HolographicHat)
|
||||
- [UIGF organization](https://uigf.org)
|
||||

|
||||
|
||||
**特定的原神项目 / Specific Genshin-related Projects**
|
||||
- [Scighost/Starward](https://github.com/Scighost/Starward)
|
||||
https://htserver.wdg12.work/api/
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ 使用的技术栈 / Tech Stack
|
||||
**图片资源站:**
|
||||
|
||||
- [CommunityToolkit/dotnet](https://github.com/CommunityToolkit/dotnet)
|
||||
- [CommunityToolkit/Labs-Windows](https://github.com/CommunityToolkit/Labs-Windows)
|
||||
- [CommunityToolkit/Windows](https://github.com/CommunityToolkit/Windows)
|
||||
- [dotnet/efcore](https://github.com/dotnet/efcore)
|
||||
- [dotnet/runtime](https://github.com/dotnet/runtime)
|
||||
- [microsoft/vs-validation](https://github.com/microsoft/vs-validation)
|
||||
- [microsoft/WindowsAppSDK](https://github.com/microsoft/WindowsAppSDK)
|
||||
- [microsoft/microsoft-ui-xaml](https://github.com/microsoft/microsoft-ui-xaml)
|
||||
- [quartznet/quartznet](https://github.com/quartznet/quartznet)
|
||||
https://htserver.wdg12.work/
|
||||
|
||||
---
|
||||
# 赞助
|
||||
|
||||
## ❤️ 赞助商 / Sponsorship
|
||||
|
||||
Snap Hutao is currently using sponsored software from the following service providers.
|
||||
|
||||
<img src="./res/assets/readmeSponsors.svg" alt="Readme Sponsors" />
|
||||
|
||||
- 🏠 [Netlify](https://www.netlify.com/) provides document and home page hosting service for Snap Hutao
|
||||
- 🌍 [Crowdin](https://crowdin.com/) provides its SaaS platform to help Snap Hutao's localization
|
||||
- 🗄️ [Navicat](https://navicat.com/) provides Snap Hutao with advanced database management tools
|
||||
- 🔒 Free code signing provided by [SignPath.io](https://signpath.io/), certificate by [SignPath Foundation](https://signpath.org/)
|
||||
- 🔑 [1Password](https://1password.com/) provides Snap Hutao development team with their amazing password management software
|
||||
- 🐳 [DigitalOcean](https://www.digitalocean.com) provides reliable cloud database and container service for Snap Hutao database backup
|
||||
- 📊 [Ducalis.io](https://hi.ducalis.io/) provides Snap Hutao project with a complete decision-making toolkit for project management
|
||||
- ☁️ [Cloudflare](https://www.cloudflare.com/) sponsors Snap Hutao with their Business Plan, ensuring secure, fast, and reliable worldwide connection to our infrastructure
|
||||
- 🔐 [Termius](https://termius.com) provides a secure, reliable, and collaborative SSH client
|
||||
|
||||
---
|
||||
|
||||
## 📈 开发 / Development
|
||||
|
||||

|
||||
|
||||
[](https://star-history.com/#DGP-Studio/Snap.Hutao&Date)
|
||||
|
||||
[](https://github.com/DGP-Studio/Snap.Hutao)
|
||||
如果你想要为我分摊经济压力,可以在下方链接中为我赞助(支持多个预设方案,你也可以在页面下方自定义金额)
|
||||
赞助的资金将全部用于服务器、域名等,若有剩余资金将升级CDN或者服务器来提升使用体验,我们的服务是完全免费的,该赞助并不会解锁额外特权,但是>=10元时将在官网新的“赞助者页面”上添加你的信息
|
||||
https://ifdian.net/a/wdg12
|
||||
|
||||
BIN
bin/unlockfps.exe
Normal file
BIN
bin/unlockfps.exe
Normal file
Binary file not shown.
3
src/Snap.Hutao/Snap.Hutao.Installer/Folders.wxs
Normal file
3
src/Snap.Hutao/Snap.Hutao.Installer/Folders.wxs
Normal file
@@ -0,0 +1,3 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs">
|
||||
|
||||
</Wix>
|
||||
11
src/Snap.Hutao/Snap.Hutao.Installer/Package.en-us.wxl
Normal file
11
src/Snap.Hutao/Snap.Hutao.Installer/Package.en-us.wxl
Normal file
@@ -0,0 +1,11 @@
|
||||
<!--
|
||||
This file contains the declaration of all the localizable strings.
|
||||
-->
|
||||
<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="MainAppTitle" Value="Snap.Hutao" />
|
||||
<String Id="DesktopShortcutTitle" Value="Desktop Shortcut" />
|
||||
<String Id="StartMenuShortcutTitle" Value="Start Menu Shortcut" />
|
||||
|
||||
</WixLocalization>
|
||||
90
src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs
Normal file
90
src/Snap.Hutao/Snap.Hutao.Installer/Package.wxs
Normal file
@@ -0,0 +1,90 @@
|
||||
<Wix xmlns="http://wixtoolset.org/schemas/v4/wxs"
|
||||
xmlns:ui="http://wixtoolset.org/schemas/v4/wxs/ui">
|
||||
<Package
|
||||
Name="Snap.Hutao"
|
||||
Manufacturer="Millennium Science Technology R-D Inst"
|
||||
Version="1.18.7.0"
|
||||
UpgradeCode="121203be-60cb-408f-92cc-7080f6598e68"
|
||||
Language="2052"
|
||||
Scope="perMachine">
|
||||
|
||||
<Property Id="ApplicationFolderName" Value="Snap.Hutao" />
|
||||
<Property Id="WixAppFolder" Value="WixPerMachineFolder" />
|
||||
|
||||
<MajorUpgrade DowngradeErrorMessage="!(loc.DowngradeError)" />
|
||||
<MediaTemplate EmbedCab="yes" />
|
||||
|
||||
<ui:WixUI Id="WixUI_InstallDir" InstallDirectory="INSTALLFOLDER" />
|
||||
|
||||
<Feature Id="MainApp" Title="!(loc.MainAppTitle)" Level="1">
|
||||
<ComponentGroupRef Id="MainAppComponents" />
|
||||
</Feature>
|
||||
|
||||
<Feature Id="DesktopShortcutFeature" Title="!(loc.DesktopShortcutTitle)" Level="1">
|
||||
<ComponentRef Id="DesktopShortcut" />
|
||||
</Feature>
|
||||
|
||||
<Feature Id="StartMenuShortcutFeature" Title="!(loc.StartMenuShortcutTitle)" Level="1">
|
||||
<ComponentRef Id="ApplicationShortcut" />
|
||||
</Feature>
|
||||
</Package>
|
||||
|
||||
<!-- 安装目录 -->
|
||||
<Fragment>
|
||||
<StandardDirectory Id="ProgramFiles64Folder">
|
||||
<Directory Id="INSTALLFOLDER" Name="Snap.Hutao" />
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="ProgramMenuFolder">
|
||||
<Directory Id="ApplicationProgramsFolder" Name="Snap Hutao" />
|
||||
</StandardDirectory>
|
||||
|
||||
<StandardDirectory Id="DesktopFolder" />
|
||||
</Fragment>
|
||||
|
||||
<!-- 桌面快捷方式 -->
|
||||
<Fragment>
|
||||
<Component Id="DesktopShortcut" Directory="DesktopFolder" Guid="*">
|
||||
|
||||
<Shortcut
|
||||
Id="DesktopShortcut_Normal"
|
||||
Name="Snap Hutao"
|
||||
Description="Snap Hutao Client"
|
||||
Target="[INSTALLFOLDER]Snap.Hutao.exe"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
|
||||
<!-- KeyPath 必须是 HKCU,因为快捷方式安装到用户目录 -->
|
||||
<RegistryValue
|
||||
Root="HKCU"
|
||||
Key="Software\Snap.Hutao"
|
||||
Name="DesktopShortcutInstalled"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</Fragment>
|
||||
|
||||
<!-- 开始菜单快捷方式 -->
|
||||
<Fragment>
|
||||
<Component Id="ApplicationShortcut" Directory="ApplicationProgramsFolder" Guid="*">
|
||||
|
||||
<Shortcut
|
||||
Id="ApplicationStartMenuShortcut"
|
||||
Name="Snap Hutao"
|
||||
Description="Snap Hutao Client"
|
||||
Target="[INSTALLFOLDER]Snap.Hutao.exe"
|
||||
WorkingDirectory="INSTALLFOLDER" />
|
||||
|
||||
<RemoveFolder Id="CleanUpShortCut" Directory="ApplicationProgramsFolder" On="uninstall" />
|
||||
|
||||
<!-- KeyPath 依然必须改为 HKCU -->
|
||||
<RegistryValue
|
||||
Root="HKCU"
|
||||
Key="Software\Snap.Hutao"
|
||||
Name="StartMenuShortcutInstalled"
|
||||
Type="integer"
|
||||
Value="1"
|
||||
KeyPath="yes" />
|
||||
</Component>
|
||||
</Fragment>
|
||||
</Wix>
|
||||
11
src/Snap.Hutao/Snap.Hutao.Installer/Package.zh-cn.wxl
Normal file
11
src/Snap.Hutao/Snap.Hutao.Installer/Package.zh-cn.wxl
Normal 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>
|
||||
@@ -0,0 +1,33 @@
|
||||
<Project Sdk="WixToolset.Sdk/6.0.2">
|
||||
<PropertyGroup>
|
||||
<SuppressIces>ICE03;ICE60</SuppressIces>
|
||||
<Platform>x64</Platform>
|
||||
<TargetFramework>net10.0-windows10.0.26100.0</TargetFramework>
|
||||
<Configuration>Release</Configuration>
|
||||
<DefaultCulture>zh-CN</DefaultCulture>
|
||||
<Cultures>zh-CN;en-US</Cultures>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Snap.Hutao\Snap.Hutao.csproj" SetPlatform="Platform=x64" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<HarvestDirectory Include="..\Snap.Hutao\bin\x64\Release\net10.0-windows10.0.26100.0\win-x64">
|
||||
<ComponentGroupName>MainAppComponents</ComponentGroupName>
|
||||
<DirectoryRefId>INSTALLFOLDER</DirectoryRefId>
|
||||
<SuppressCom>true</SuppressCom>
|
||||
<SuppressRegistry>true</SuppressRegistry>
|
||||
<SuppressRootDirectory>true</SuppressRootDirectory>
|
||||
</HarvestDirectory>
|
||||
|
||||
<PackageReference Include="WixToolset.Heat" Version="6.0.2" />
|
||||
<PackageReference Include="WixToolset.UI.wixext" Version="6.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<WixLocalization Include="Package.zh-cn.wxl" />
|
||||
<WixLocalization Include="Package.en-us.wxl" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,4 +1,8 @@
|
||||
# Snap.Hutao.SourceGeneration
|
||||
|
||||
> 生成器包的备份,目前还可以从nuget上获取,所以暂时不需要使用该目录
|
||||
> https://www.nuget.org/packages/Snap.Hutao.SourceGeneration/1.3.14
|
||||
|
||||
Source Code Generator for Snap.Hutao
|
||||
|
||||
# Development Guideline
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<Solution>
|
||||
<Configurations>
|
||||
<Platform Name="Any CPU" />
|
||||
<Platform Name="ARM64" />
|
||||
<Platform Name="x64" />
|
||||
<Platform Name="x86" />
|
||||
</Configurations>
|
||||
<Folder Name="/Solution Items/">
|
||||
<File Path=".editorconfig" />
|
||||
<File Path=".vsconfig" />
|
||||
</Folder>
|
||||
<Project Path="Snap.Hutao.Test\Snap.Hutao.Test.csproj" />
|
||||
<Project Path="Snap.Hutao\Snap.Hutao.csproj">
|
||||
<!-- For Rider -->
|
||||
<Configuration Solution="Debug|Any CPU" Project="Debug|x64|Deploy" />
|
||||
<Configuration Solution="Debug|x64" Project="Debug|x64|Deploy" />
|
||||
<Configuration Solution="Release|Any CPU" Project="Release|x64|Deploy" />
|
||||
<Configuration Solution="Release|x64" Project="Release|x64|Deploy" />
|
||||
<!-- For Visual Studio -->
|
||||
<Project Path="Snap.Hutao.Installer/Snap.Hutao.Installer.wixproj" Type="b7dd6f7e-def8-4e67-b5b7-07ef123db6f0" Id="91a04cd0-28cc-4562-92e1-202bc163edd7">
|
||||
<Platform Solution="*|Any CPU" Project="x64" />
|
||||
<Platform Solution="*|arm64" Project="arm64" />
|
||||
<Platform Solution="*|x64" Project="x64" />
|
||||
<Platform Solution="*|x86" Project="x86" />
|
||||
<Deploy />
|
||||
</Project>
|
||||
<Project Path="Snap.Hutao.Test\Snap.Hutao.Test.csproj">
|
||||
<Build Solution="*|ARM64" Project="false" />
|
||||
<Build Solution="*|x86" Project="false" />
|
||||
</Project>
|
||||
<Project Path="Snap.Hutao\Snap.Hutao.csproj">
|
||||
<Platform Project="x64" />
|
||||
<Deploy Solution="*|Any CPU" />
|
||||
<Deploy Solution="*|x64" />
|
||||
</Project>
|
||||
</Solution>
|
||||
@@ -13,6 +13,7 @@ using Snap.Hutao.Service;
|
||||
using Snap.Hutao.UI.Xaml;
|
||||
using Snap.Hutao.UI.Xaml.Control.Theme;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace Snap.Hutao;
|
||||
|
||||
@@ -64,6 +65,11 @@ public sealed partial class App : Application
|
||||
|
||||
protected override void OnLaunched(LaunchActivatedEventArgs args)
|
||||
{
|
||||
// ⚠️ 添加启动诊断
|
||||
#if DEBUG
|
||||
Core.ApplicationModel.PackageIdentityDiagnostics.LogDiagnostics();
|
||||
#endif
|
||||
|
||||
DebugPatchXamlDiagnosticsRemoveRootObjectFromLVT();
|
||||
|
||||
try
|
||||
@@ -71,18 +77,41 @@ public sealed partial class App : Application
|
||||
// Important: You must call AppNotificationManager::Default().Register
|
||||
// before calling AppInstance.GetCurrent.GetActivatedEventArgs.
|
||||
AppNotificationManager.Default.NotificationInvoked += activation.NotificationInvoked;
|
||||
|
||||
try
|
||||
{
|
||||
AppNotificationManager.Default.Register();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// In unpackaged mode, this might fail - continue anyway
|
||||
}
|
||||
|
||||
// E_INVALIDARG E_OUTOFMEMORY
|
||||
AppActivationArguments activatedEventArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
|
||||
AppActivationArguments? activatedEventArgs = null;
|
||||
PrivateNamedPipeClient? namedPipeClient = null;
|
||||
|
||||
if (serviceProvider.GetRequiredService<PrivateNamedPipeClient>().TryRedirectActivationTo(activatedEventArgs))
|
||||
try
|
||||
{
|
||||
activatedEventArgs = AppInstance.GetCurrent().GetActivatedEventArgs();
|
||||
namedPipeClient = serviceProvider.GetRequiredService<PrivateNamedPipeClient>();
|
||||
}
|
||||
catch
|
||||
{
|
||||
// In unpackaged mode, AppInstance might not work
|
||||
// Create a default activation argument for launch
|
||||
}
|
||||
|
||||
if (activatedEventArgs is not null && namedPipeClient is not null)
|
||||
{
|
||||
if (namedPipeClient.TryRedirectActivationTo(activatedEventArgs))
|
||||
{
|
||||
SentrySdk.AddBreadcrumb(BreadcrumbFactory.CreateInfo("Application exiting on RedirectActivationTo", "Hutao"));
|
||||
XamlApplicationLifetime.ActivationAndInitializationCompleted = true;
|
||||
Exit();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
logger.LogInformation($"{ConsoleBanner}");
|
||||
|
||||
@@ -90,10 +119,30 @@ public sealed partial class App : Application
|
||||
|
||||
// Manually invoke
|
||||
SentrySdk.AddBreadcrumb(BreadcrumbFactory.CreateInfo("Activate and Initialize", "Application"));
|
||||
activation.ActivateAndInitialize(HutaoActivationArguments.FromAppActivationArguments(activatedEventArgs));
|
||||
|
||||
HutaoActivationArguments hutaoArgs = activatedEventArgs is not null
|
||||
? HutaoActivationArguments.FromAppActivationArguments(activatedEventArgs)
|
||||
: HutaoActivationArguments.CreateDefaultLaunchArguments();
|
||||
|
||||
activation.ActivateAndInitialize(hutaoArgs);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// ⚠️ 添加更详细的异常日志
|
||||
try
|
||||
{
|
||||
string errorPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Hutao",
|
||||
"startup_error.txt");
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(errorPath)!);
|
||||
File.WriteAllText(errorPath, $"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Startup Error:\n{ex}");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Ignore
|
||||
}
|
||||
|
||||
SentrySdk.CaptureException(ex);
|
||||
SentrySdk.Flush();
|
||||
|
||||
|
||||
@@ -22,6 +22,11 @@ public static partial class Bootstrap
|
||||
private static readonly ApplicationInitializationCallback AppInitializationCallback = InitializeApp;
|
||||
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()
|
||||
{
|
||||
Debug.Assert(mutex is not null);
|
||||
@@ -31,8 +36,28 @@ public static partial class Bootstrap
|
||||
[STAThread]
|
||||
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
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Starting...");
|
||||
#endif
|
||||
|
||||
if (Mutex.TryOpenExisting(LockName, out _))
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Another instance is running");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,9 +67,16 @@ public static partial class Bootstrap
|
||||
mutexSecurity.AddAccessRule(new(SecurityIdentifiers.Everyone, MutexRights.FullControl, AccessControlType.Allow));
|
||||
mutex = MutexAcl.Create(true, LockName, out bool created, mutexSecurity);
|
||||
Debug.Assert(created);
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Mutex created");
|
||||
#endif
|
||||
}
|
||||
catch (WaitHandleCannotBeOpenedException)
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] WaitHandleCannotBeOpenedException");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -54,34 +86,76 @@ public static partial class Bootstrap
|
||||
{
|
||||
if (!OSPlatformSupported())
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] OS not supported");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Setting environment variables");
|
||||
#endif
|
||||
|
||||
Environment.SetEnvironmentVariable("WEBVIEW2_DEFAULT_BACKGROUND_COLOR", "00000000");
|
||||
Environment.SetEnvironmentVariable("DOTNET_SYSTEM_BUFFERS_SHAREDARRAYPOOL_MAXARRAYSPERPARTITION", "128");
|
||||
AppContext.SetData("MVVMTOOLKIT_ENABLE_INOTIFYPROPERTYCHANGING_SUPPORT", false);
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Initializing COM wrappers");
|
||||
#endif
|
||||
|
||||
ComWrappersSupport.InitializeComWrappers();
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Initializing DI container");
|
||||
#endif
|
||||
|
||||
// By adding the using statement, we can dispose the injected services when closing
|
||||
using (ServiceProvider serviceProvider = DependencyInjection.Initialize())
|
||||
{
|
||||
Thread.CurrentThread.Name = "Snap Hutao Application Main Thread";
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Calling Application.Start()");
|
||||
#endif
|
||||
|
||||
// If you hit a COMException REGDB_E_CLASSNOTREG (0x80040154) during debugging
|
||||
// You can delete bin and obj folder and then rebuild.
|
||||
// In a Desktop app this runs a message pump internally,
|
||||
// and does not return until the application shuts down.
|
||||
Application.Start(AppInitializationCallback);
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Application.Start() returned");
|
||||
#endif
|
||||
|
||||
XamlApplicationLifetime.Exited = true;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Flushing Sentry");
|
||||
#endif
|
||||
|
||||
SentrySdk.Flush();
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.Main] Exiting");
|
||||
#endif
|
||||
|
||||
if (ConsoleEnabled)
|
||||
{
|
||||
Console.WriteLine("[Bootstrap] Application exiting...");
|
||||
HutaoNativeMethods.FreeConsole();
|
||||
}
|
||||
}
|
||||
|
||||
private static void InitializeApp(ApplicationInitializationCallbackParams param)
|
||||
{
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.InitializeApp] Callback invoked");
|
||||
#endif
|
||||
|
||||
Gen2GcCallback.Register(() =>
|
||||
{
|
||||
SentrySdk.AddBreadcrumb(BreadcrumbFactory.CreateDebug("Gen2 GC triggered.", "Runtime"));
|
||||
@@ -90,8 +164,17 @@ public static partial class Bootstrap
|
||||
|
||||
IServiceProvider serviceProvider = Ioc.Default;
|
||||
|
||||
_ = serviceProvider.GetRequiredService<ITaskContext>();
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.InitializeApp] Creating App instance");
|
||||
#endif
|
||||
|
||||
// ⚠️ 只创建 App
|
||||
// TaskContext 将在第一次被需要时自动创建(延迟初始化)
|
||||
_ = serviceProvider.GetRequiredService<App>();
|
||||
|
||||
#if DEBUG
|
||||
System.Diagnostics.Debug.WriteLine("[Bootstrap.InitializeApp] Initialization complete (TaskContext will be lazily created)");
|
||||
#endif
|
||||
}
|
||||
|
||||
private static bool OSPlatformSupported()
|
||||
|
||||
@@ -4,14 +4,13 @@
|
||||
using System.Collections.Frozen;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Windows.ApplicationModel;
|
||||
|
||||
namespace Snap.Hutao.Core.ApplicationModel;
|
||||
|
||||
internal static class LimitedAccessFeatures
|
||||
{
|
||||
private static readonly string PackagePublisherId = Package.Current.Id.PublisherId;
|
||||
private static readonly string PackageFamilyName = Package.Current.Id.FamilyName;
|
||||
private static readonly string PackagePublisherId = PackageIdentityAdapter.PublisherId;
|
||||
private static readonly string PackageFamilyName = PackageIdentityAdapter.FamilyName;
|
||||
|
||||
private static readonly FrozenDictionary<string, string> Features = WinRTAdaptive.ToFrozenDictionary(
|
||||
[
|
||||
@@ -67,8 +66,15 @@ internal static class LimitedAccessFeatures
|
||||
KeyValuePair.Create("com.microsoft.windows.windowdecorations", "425261a8-7f73-4319-8a53-fc13f87e1717")
|
||||
]);
|
||||
|
||||
public static LimitedAccessFeatureRequestResult TryUnlockFeature(string featureId)
|
||||
public static Windows.ApplicationModel.LimitedAccessFeatureRequestResult TryUnlockFeature(string featureId)
|
||||
{
|
||||
if (!PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
// In unpackaged mode, we can't unlock limited access features
|
||||
// Create a dummy result - actual implementation will handle the failure
|
||||
return default;
|
||||
}
|
||||
|
||||
return Windows.ApplicationModel.LimitedAccessFeatures.TryUnlockFeature(featureId, GetToken(featureId), GetAttestation(featureId));
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) Millennium-Science-Technology-R-D-Inst. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
|
||||
namespace Snap.Hutao.Core.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// Adapter to handle both packaged and unpackaged app scenarios
|
||||
/// </summary>
|
||||
internal static class PackageIdentityAdapter
|
||||
{
|
||||
private static readonly Lazy<bool> LazyHasPackageIdentity = new(CheckPackageIdentity);
|
||||
private static readonly Lazy<string> LazyAppDirectory = new(GetAppDirectoryPath);
|
||||
private static readonly Lazy<Version> LazyAppVersion = new(GetAppVersionInternal);
|
||||
private static readonly Lazy<string> LazyFamilyName = new(GetFamilyNameInternal);
|
||||
private static readonly Lazy<string> LazyPublisherId = new(GetPublisherIdInternal);
|
||||
|
||||
/// <summary>
|
||||
/// Check if the app has package identity
|
||||
/// </summary>
|
||||
public static bool HasPackageIdentity => LazyHasPackageIdentity.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get application installation directory
|
||||
/// </summary>
|
||||
public static string AppDirectory => LazyAppDirectory.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get application version
|
||||
/// </summary>
|
||||
public static Version AppVersion => LazyAppVersion.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get package family name (or fallback for unpackaged)
|
||||
/// </summary>
|
||||
public static string FamilyName => LazyFamilyName.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Get publisher ID (or fallback for unpackaged)
|
||||
/// </summary>
|
||||
public static string PublisherId => LazyPublisherId.Value;
|
||||
|
||||
private static bool CheckPackageIdentity()
|
||||
{
|
||||
try
|
||||
{
|
||||
// Try to access Package.Current - if it throws, we don't have package identity
|
||||
_ = Windows.ApplicationModel.Package.Current.Id;
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetAppDirectoryPath()
|
||||
{
|
||||
if (HasPackageIdentity)
|
||||
{
|
||||
return Windows.ApplicationModel.Package.Current.InstalledLocation.Path;
|
||||
}
|
||||
|
||||
// Unpackaged: use the exe directory
|
||||
string? exePath = Process.GetCurrentProcess().MainModule?.FileName;
|
||||
ArgumentException.ThrowIfNullOrEmpty(exePath);
|
||||
string? directory = Path.GetDirectoryName(exePath);
|
||||
ArgumentException.ThrowIfNullOrEmpty(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static Version GetAppVersionInternal()
|
||||
{
|
||||
if (HasPackageIdentity)
|
||||
{
|
||||
return Windows.ApplicationModel.Package.Current.Id.Version.ToVersion();
|
||||
}
|
||||
|
||||
// Unpackaged: use assembly version
|
||||
Assembly assembly = Assembly.GetExecutingAssembly();
|
||||
Version? version = assembly.GetName().Version;
|
||||
return version ?? new Version(1, 0, 0, 0);
|
||||
}
|
||||
|
||||
private static string GetFamilyNameInternal()
|
||||
{
|
||||
if (HasPackageIdentity)
|
||||
{
|
||||
return Windows.ApplicationModel.Package.Current.Id.FamilyName;
|
||||
}
|
||||
|
||||
// Unpackaged: use a deterministic fallback
|
||||
return "Snap.Hutao.Unpackaged";
|
||||
}
|
||||
|
||||
private static string GetPublisherIdInternal()
|
||||
{
|
||||
if (HasPackageIdentity)
|
||||
{
|
||||
return Windows.ApplicationModel.Package.Current.Id.PublisherId;
|
||||
}
|
||||
|
||||
// Unpackaged: use a fallback
|
||||
return "CN=Millennium-Science-Technology-R-D-Inst";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
// Copyright (c) Millennium-Science-Technology-R-D-Inst. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
|
||||
namespace Snap.Hutao.Core.ApplicationModel;
|
||||
|
||||
/// <summary>
|
||||
/// Diagnostic helper for PackageIdentityAdapter
|
||||
/// </summary>
|
||||
internal static class PackageIdentityDiagnostics
|
||||
{
|
||||
public static void LogDiagnostics()
|
||||
{
|
||||
try
|
||||
{
|
||||
string logPath = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Hutao",
|
||||
"startup_diagnostics.txt");
|
||||
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(logPath)!);
|
||||
|
||||
using (StreamWriter writer = File.CreateText(logPath))
|
||||
{
|
||||
writer.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] Startup Diagnostics");
|
||||
writer.WriteLine($"HasPackageIdentity: {PackageIdentityAdapter.HasPackageIdentity}");
|
||||
writer.WriteLine($"AppVersion: {PackageIdentityAdapter.AppVersion}");
|
||||
writer.WriteLine($"AppDirectory: {PackageIdentityAdapter.AppDirectory}");
|
||||
writer.WriteLine($"FamilyName: {PackageIdentityAdapter.FamilyName}");
|
||||
writer.WriteLine($"PublisherId: {PackageIdentityAdapter.PublisherId}");
|
||||
writer.WriteLine("---");
|
||||
}
|
||||
|
||||
Debug.WriteLine($"Diagnostics written to: {logPath}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Failed to write diagnostics: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,12 @@ internal static class DependencyInjection
|
||||
.AddFilter(DbLoggerCategory.Query.Name, level => level >= LogLevel.Information)
|
||||
.AddDebug()
|
||||
.AddSentryTelemetry();
|
||||
|
||||
// Add console logging if console is enabled
|
||||
if (Bootstrap.ConsoleEnabled)
|
||||
{
|
||||
builder.AddConsole();
|
||||
}
|
||||
})
|
||||
.AddMemoryCache()
|
||||
|
||||
@@ -39,6 +45,7 @@ internal static class DependencyInjection
|
||||
.AddJsonOptions()
|
||||
.AddDatabase()
|
||||
.AddServices()
|
||||
.AddThirdPartyToolService()
|
||||
.AddResponseValidation()
|
||||
.AddConfiguredHttpClients()
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Snap.Hutao.Core.Text.Json;
|
||||
using Snap.Hutao.Factory.Process;
|
||||
using Snap.Hutao.Model.Entity.Database;
|
||||
using Snap.Hutao.Service.ThirdPartyTool;
|
||||
using Snap.Hutao.Win32;
|
||||
using System.Data.Common;
|
||||
|
||||
@@ -66,5 +67,10 @@ internal static partial class ServiceCollectionExtension
|
||||
.UseSqlite(sqlConnectionString);
|
||||
}
|
||||
}
|
||||
|
||||
public IServiceCollection AddThirdPartyToolService()
|
||||
{
|
||||
return services.AddSingleton<IThirdPartyToolService, ThirdPartyToolService>();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Snap.Hutao.Core.ApplicationModel;
|
||||
using Snap.Hutao.Model.Entity.Database;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
@@ -17,7 +18,20 @@ internal sealed partial class HutaoDiagnostics : IHutaoDiagnostics
|
||||
[GeneratedConstructor]
|
||||
public partial HutaoDiagnostics(IServiceProvider serviceProvider);
|
||||
|
||||
public ApplicationDataContainer LocalSettings { get => ApplicationData.Current.LocalSettings; }
|
||||
public ApplicationDataContainer? LocalSettings
|
||||
{
|
||||
get
|
||||
{
|
||||
if (PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
return ApplicationData.Current.LocalSettings;
|
||||
}
|
||||
|
||||
// In unpackaged mode, ApplicationDataContainer is not available
|
||||
// Return null - scripting/diagnostics code should handle this gracefully
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
public async ValueTask<int> ExecuteSqlAsync(string sql)
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace Snap.Hutao.Core.Diagnostics;
|
||||
[SuppressMessage("", "SH001", Justification = "IHutaoDiagnostics must be public in order to be exposed to the scripting environment")]
|
||||
public interface IHutaoDiagnostics
|
||||
{
|
||||
ApplicationDataContainer LocalSettings { get; }
|
||||
ApplicationDataContainer? LocalSettings { get; }
|
||||
|
||||
ValueTask<int> ExecuteSqlAsync(string sql);
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Win32;
|
||||
using Microsoft.Windows.AppNotifications;
|
||||
using Snap.Hutao.Core.ApplicationModel;
|
||||
using Snap.Hutao.Core.ExceptionService;
|
||||
using Snap.Hutao.Core.IO;
|
||||
using Snap.Hutao.Core.IO.Hashing;
|
||||
@@ -13,31 +14,34 @@ using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Snap.Hutao.Core;
|
||||
|
||||
internal static class HutaoRuntime
|
||||
{
|
||||
public static Version Version { get; } = Package.Current.Id.Version.ToVersion();
|
||||
public static Version Version { get; } = PackageIdentityAdapter.AppVersion;
|
||||
|
||||
public static string UserAgent { get; } = $"Snap Hutao/{Version}";
|
||||
|
||||
public static string DataDirectory { get; } = InitializeDataDirectory();
|
||||
|
||||
public static string LocalCacheDirectory { get; } = ApplicationData.Current.LocalCacheFolder.Path;
|
||||
public static string LocalCacheDirectory { get; } = InitializeLocalCacheDirectory();
|
||||
|
||||
public static string FamilyName { get; } = Package.Current.Id.FamilyName;
|
||||
public static string FamilyName { get; } = PackageIdentityAdapter.FamilyName;
|
||||
|
||||
public static string DeviceId { get; } = InitializeDeviceId();
|
||||
|
||||
public static WebView2Version WebView2Version { get; } = InitializeWebView2();
|
||||
|
||||
public static bool IsProcessElevated { get; } = LocalSetting.Get(SettingKeys.OverrideElevationRequirement, false) || Environment.IsPrivilegedProcess;
|
||||
public static string WebView2UserDataDirectory { get; } = InitializeWebView2UserDataDirectory();
|
||||
|
||||
// ⚠️ 延迟初始化以避免循环依赖
|
||||
private static readonly Lazy<bool> LazyIsProcessElevated = new(GetIsProcessElevated);
|
||||
|
||||
public static bool IsProcessElevated => LazyIsProcessElevated.Value;
|
||||
|
||||
// Requires main thread
|
||||
public static bool IsAppNotificationEnabled { get; } = AppNotificationManager.Default.Setting is AppNotificationSetting.Enabled;
|
||||
public static bool IsAppNotificationEnabled { get; } = CheckAppNotificationEnabled();
|
||||
|
||||
public static string? GetDisplayName()
|
||||
{
|
||||
@@ -106,32 +110,64 @@ internal static class HutaoRuntime
|
||||
return string.Intern(directory);
|
||||
}
|
||||
|
||||
private static string InitializeDataDirectory()
|
||||
private static bool GetIsProcessElevated()
|
||||
{
|
||||
// Delete the previous data folder if it exists
|
||||
// ⚠️ 这里调用 LocalSetting 时,确保 DataDirectory 已经初始化完成
|
||||
try
|
||||
{
|
||||
string previousDirectory = LocalSetting.Get(SettingKeys.PreviousDataDirectoryToDelete, string.Empty);
|
||||
if (!string.IsNullOrEmpty(previousDirectory) && Directory.Exists(previousDirectory))
|
||||
{
|
||||
Directory.SetReadOnly(previousDirectory, false);
|
||||
Directory.Delete(previousDirectory, true);
|
||||
return LocalSetting.Get(SettingKeys.OverrideElevationRequirement, false) || Environment.IsPrivilegedProcess;
|
||||
}
|
||||
}
|
||||
finally
|
||||
catch
|
||||
{
|
||||
LocalSetting.Set(SettingKeys.PreviousDataDirectoryToDelete, string.Empty);
|
||||
// 如果读取失败,使用默认值
|
||||
return Environment.IsPrivilegedProcess;
|
||||
}
|
||||
}
|
||||
|
||||
// Check if the preferred path is set
|
||||
string currentDirectory = LocalSetting.Get(SettingKeys.DataDirectory, string.Empty);
|
||||
|
||||
if (!string.IsNullOrEmpty(currentDirectory))
|
||||
private static string InitializeLocalCacheDirectory()
|
||||
{
|
||||
Directory.CreateDirectory(currentDirectory);
|
||||
return currentDirectory;
|
||||
if (PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
return Windows.Storage.ApplicationData.Current.LocalCacheFolder.Path;
|
||||
}
|
||||
|
||||
// Unpackaged: use %LOCALAPPDATA%\Snap.Hutao\Cache
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
const string FolderName
|
||||
#if IS_ALPHA_BUILD
|
||||
= "HutaoAlpha";
|
||||
#elif IS_CANARY_BUILD
|
||||
= "HutaoCanary";
|
||||
#else
|
||||
= "Hutao";
|
||||
#endif
|
||||
string cacheDir = Path.Combine(localAppData, FolderName, "Cache");
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
return cacheDir;
|
||||
}
|
||||
|
||||
private static string InitializeWebView2UserDataDirectory()
|
||||
{
|
||||
string directory = Path.Combine(LocalCacheDirectory, "WebView2");
|
||||
Directory.CreateDirectory(directory);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private static bool CheckAppNotificationEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
return AppNotificationManager.Default.Setting is AppNotificationSetting.Enabled;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// In unpackaged mode, this might fail - return false
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string InitializeDataDirectory()
|
||||
{
|
||||
const string FolderName
|
||||
#if IS_ALPHA_BUILD
|
||||
= "HutaoAlpha";
|
||||
@@ -141,30 +177,43 @@ internal static class HutaoRuntime
|
||||
= "Hutao";
|
||||
#endif
|
||||
|
||||
// ⚠️ 不要在这里调用 LocalSetting - 会导致循环依赖
|
||||
// 先确定默认的数据目录位置
|
||||
|
||||
// Check if the old documents path exists
|
||||
string myDocumentsHutaoDirectory = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), FolderName));
|
||||
if (Directory.Exists(myDocumentsHutaoDirectory))
|
||||
{
|
||||
LocalSetting.Set(SettingKeys.DataDirectory, myDocumentsHutaoDirectory);
|
||||
return myDocumentsHutaoDirectory;
|
||||
}
|
||||
|
||||
// Prefer LocalApplicationData
|
||||
string localApplicationData = ApplicationData.Current.LocalFolder.Path;
|
||||
string path = Path.GetFullPath(Path.Combine(localApplicationData, FolderName));
|
||||
// Use LocalApplicationData
|
||||
string localApplicationData;
|
||||
if (PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
localApplicationData = Windows.Storage.ApplicationData.Current.LocalFolder.Path;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unpackaged: use %LOCALAPPDATA%
|
||||
localApplicationData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
}
|
||||
|
||||
string defaultPath = Path.GetFullPath(Path.Combine(localApplicationData, FolderName));
|
||||
|
||||
// ⚠️ 延迟处理:在第一次使用 LocalSetting 后再检查是否有自定义路径
|
||||
// 这里返回默认路径,后续通过 LocalSetting 可能会更新
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(path);
|
||||
Directory.CreateDirectory(defaultPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// FileNotFoundException | UnauthorizedAccessException
|
||||
// We don't have enough permission
|
||||
HutaoException.InvalidOperation($"Failed to create data folder: {path}", ex);
|
||||
HutaoException.InvalidOperation($"Failed to create data folder: {defaultPath}", ex);
|
||||
}
|
||||
|
||||
LocalSetting.Set(SettingKeys.DataDirectory, path);
|
||||
return path;
|
||||
return defaultPath;
|
||||
}
|
||||
|
||||
private static string InitializeDeviceId()
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.ApplicationModel;
|
||||
using System.IO;
|
||||
using System.Security.AccessControl;
|
||||
using System.Security.Principal;
|
||||
using Windows.ApplicationModel;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Snap.Hutao.Core;
|
||||
|
||||
@@ -13,7 +12,7 @@ internal static class InstalledLocation
|
||||
{
|
||||
public static string GetAbsolutePath(string relativePath)
|
||||
{
|
||||
return Path.Combine(Package.Current.InstalledLocation.Path, relativePath);
|
||||
return Path.Combine(PackageIdentityAdapter.AppDirectory, relativePath);
|
||||
}
|
||||
|
||||
public static void CopyFileFromApplicationUri(string url, string path)
|
||||
@@ -23,8 +22,26 @@ internal static class InstalledLocation
|
||||
static async Task CopyApplicationUriFileCoreAsync(string url, string path)
|
||||
{
|
||||
await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding);
|
||||
StorageFile file = await StorageFile.GetFileFromApplicationUriAsync(url.ToUri());
|
||||
using (Stream outputStream = (await file.OpenReadAsync()).AsStreamForRead())
|
||||
|
||||
Uri uri = url.ToUri();
|
||||
Stream outputStream;
|
||||
|
||||
if (PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
// Packaged: use StorageFile
|
||||
Windows.Storage.StorageFile file = await Windows.Storage.StorageFile.GetFileFromApplicationUriAsync(uri);
|
||||
outputStream = (await file.OpenReadAsync()).AsStreamForRead();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Unpackaged: read from file system directly
|
||||
// Assume ms-appx:/// points to the app directory
|
||||
string localPath = uri.LocalPath.TrimStart('/');
|
||||
string fullPath = Path.Combine(PackageIdentityAdapter.AppDirectory, localPath);
|
||||
outputStream = File.OpenRead(fullPath);
|
||||
}
|
||||
|
||||
using (outputStream)
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
|
||||
@@ -74,8 +74,15 @@ internal sealed partial class AppActivation : IAppActivation, IAppActivationActi
|
||||
|
||||
public void ActivateAndInitialize(HutaoActivationArguments args)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] ActivateAndInitialize called");
|
||||
#endif
|
||||
|
||||
if (Volatile.Read(ref isActivating) is 1)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Already activating, returning");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -85,21 +92,52 @@ internal sealed partial class AppActivation : IAppActivation, IAppActivationActi
|
||||
{
|
||||
try
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Starting activation process");
|
||||
#endif
|
||||
|
||||
using (await activateLock.LockAsync().ConfigureAwait(false))
|
||||
{
|
||||
if (Interlocked.CompareExchange(ref isActivating, 1, 0) is not 0)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Race condition detected, returning");
|
||||
#endif
|
||||
return;
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Calling UnsynchronizedHandleActivationAsync");
|
||||
#endif
|
||||
|
||||
await UnsynchronizedHandleActivationAsync(args).ConfigureAwait(false);
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Calling UnsynchronizedHandleInitializationAsync");
|
||||
#endif
|
||||
|
||||
await UnsynchronizedHandleInitializationAsync().ConfigureAwait(false);
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] Initialization completed successfully");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation] Exception during activation: {ex}");
|
||||
#endif
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
XamlApplicationLifetime.ActivationAndInitializationCompleted = true;
|
||||
Interlocked.Exchange(ref isActivating, 0);
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation] ActivationAndInitializationCompleted set to true");
|
||||
#endif
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -313,16 +351,36 @@ internal sealed partial class AppActivation : IAppActivation, IAppActivationActi
|
||||
private async ValueTask<Window?> WaitWindowAsync<TWindow>()
|
||||
where TWindow : Window
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation.WaitWindowAsync] Waiting for window type: {typeof(TWindow).Name}");
|
||||
#endif
|
||||
|
||||
await taskContext.SwitchToMainThreadAsync();
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation.WaitWindowAsync] Switched to main thread");
|
||||
#endif
|
||||
|
||||
if (currentXamlWindowReference.Window is not { } window)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation.WaitWindowAsync] Creating new window instance");
|
||||
#endif
|
||||
|
||||
try
|
||||
{
|
||||
window = serviceProvider.GetRequiredService<TWindow>();
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation.WaitWindowAsync] Window created successfully: {window.GetType().Name}");
|
||||
#endif
|
||||
}
|
||||
catch (COMException)
|
||||
catch (COMException ex)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation.WaitWindowAsync] COMException: {ex}");
|
||||
#endif
|
||||
|
||||
if (XamlApplicationLifetime.Exiting)
|
||||
{
|
||||
return default;
|
||||
@@ -330,11 +388,33 @@ internal sealed partial class AppActivation : IAppActivation, IAppActivationActi
|
||||
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation.WaitWindowAsync] Exception creating window: {ex}");
|
||||
#endif
|
||||
throw;
|
||||
}
|
||||
|
||||
currentXamlWindowReference.Window = window;
|
||||
}
|
||||
else
|
||||
{
|
||||
#if DEBUG
|
||||
Debug.WriteLine($"[AppActivation.WaitWindowAsync] Using existing window: {window.GetType().Name}");
|
||||
#endif
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation.WaitWindowAsync] Calling window.SwitchTo()");
|
||||
#endif
|
||||
|
||||
window.SwitchTo();
|
||||
|
||||
#if DEBUG
|
||||
Debug.WriteLine("[AppActivation.WaitWindowAsync] Window activated");
|
||||
#endif
|
||||
|
||||
window.AppWindow?.MoveInZOrderAtTop();
|
||||
return window;
|
||||
}
|
||||
|
||||
@@ -66,4 +66,14 @@ internal sealed class HutaoActivationArguments
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
public static HutaoActivationArguments CreateDefaultLaunchArguments()
|
||||
{
|
||||
return new HutaoActivationArguments
|
||||
{
|
||||
IsRedirectTo = false,
|
||||
Kind = HutaoActivationKind.Launch,
|
||||
LaunchActivatedArguments = string.Empty
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,13 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
|
||||
private readonly TargetNativeConfiguration config;
|
||||
private readonly ITaskContext taskContext;
|
||||
private readonly IProcess gameProcess;
|
||||
private readonly bool supportsResumeMainThread;
|
||||
|
||||
private readonly NamedPipeServerStream serverStream;
|
||||
|
||||
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.");
|
||||
|
||||
@@ -36,6 +37,7 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
|
||||
|
||||
this.gameProcess = gameProcess;
|
||||
this.config = config;
|
||||
this.supportsResumeMainThread = supportsResumeMainThread;
|
||||
|
||||
// Yae is always running elevated, so we don't need to use ACL method.
|
||||
serverStream = new(PipeName);
|
||||
@@ -115,8 +117,11 @@ internal sealed class YaeNamedPipeServer : IAsyncDisposable
|
||||
}
|
||||
|
||||
case YaeCommandKind.RequestResumeThread:
|
||||
{
|
||||
if (supportsResumeMainThread)
|
||||
{
|
||||
gameProcess.ResumeMainThread();
|
||||
}
|
||||
return default;
|
||||
}
|
||||
|
||||
|
||||
@@ -20,9 +20,9 @@ internal static class LoggerFactoryExtension
|
||||
|
||||
#if DEBUG || IS_ALPHA_BUILD || IS_CANARY_BUILD
|
||||
// Alpha and Canary produces noisy events
|
||||
options.Dsn = "https://ec3799184191c344ca06c592cb97a464@sentry.snapgenshin.com/4";
|
||||
options.Dsn = "https://2d3047ff2d451986bc7ef395d1f1fe63@o4507525750521856.ingest.us.sentry.io/4510413123682304";
|
||||
#else
|
||||
options.Dsn = "https://1a1151ce5ac4e7f1536edf085bd483ec@sentry.snapgenshin.com/2";
|
||||
options.Dsn = "https://2d3047ff2d451986bc7ef395d1f1fe63@o4507525750521856.ingest.us.sentry.io/4510413123682304";
|
||||
#endif
|
||||
|
||||
#if DEBUG
|
||||
@@ -36,8 +36,8 @@ internal static class LoggerFactoryExtension
|
||||
options.Environment = GetBuildEnvironment();
|
||||
|
||||
// Suppress logs to generate events and breadcrumbs
|
||||
options.MinimumBreadcrumbLevel = LogLevel.None;
|
||||
options.MinimumEventLevel = LogLevel.None;
|
||||
options.MinimumBreadcrumbLevel = LogLevel.Information;
|
||||
options.MinimumEventLevel = LogLevel.Error;
|
||||
|
||||
options.ProfilesSampleRate = 1.0D;
|
||||
options.TracesSampleRate = 1.0D;
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.ApplicationModel;
|
||||
using Snap.Hutao.Core.ExceptionService;
|
||||
using Snap.Hutao.Factory.Process;
|
||||
using Snap.Hutao.Win32;
|
||||
using Snap.Hutao.Win32.Foundation;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Frozen;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Snap.Hutao.Core.Setting;
|
||||
@@ -36,40 +40,20 @@ internal static class LocalSetting
|
||||
typeof(ApplicationDataCompositeValue)
|
||||
];
|
||||
|
||||
private static readonly ApplicationDataContainer Container = ApplicationData.Current.LocalSettings;
|
||||
private static readonly Lazy<ISettingStorage> LazyStorage = new(CreateStorage);
|
||||
|
||||
private static ISettingStorage Storage => LazyStorage.Value;
|
||||
|
||||
public static T Get<T>(string key, T defaultValue = default!)
|
||||
{
|
||||
Debug.Assert(SupportedTypes.Contains(typeof(T)));
|
||||
if (Container.Values.TryGetValue(key, out object? value))
|
||||
{
|
||||
// unbox the value
|
||||
return value is null ? defaultValue : (T)value;
|
||||
}
|
||||
|
||||
Set(key, defaultValue);
|
||||
return defaultValue;
|
||||
return Storage.Get(key, defaultValue);
|
||||
}
|
||||
|
||||
public static void Set<T>(string key, T value)
|
||||
{
|
||||
Debug.Assert(SupportedTypes.Contains(typeof(T)));
|
||||
|
||||
try
|
||||
{
|
||||
Container.Values[key] = value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 状态管理器无法写入设置
|
||||
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_STATE_WRITE_SETTING_FAILED))
|
||||
{
|
||||
HutaoNative.Instance.ShowErrorMessage(ex.Message, ExceptionFormat.Format(ex));
|
||||
ProcessFactory.KillCurrent();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
Storage.Set(key, value);
|
||||
}
|
||||
|
||||
public static void SetIf<T>(bool condition, string key, T value)
|
||||
@@ -103,4 +87,299 @@ internal static class LocalSetting
|
||||
Set(key, newValue);
|
||||
return oldValue;
|
||||
}
|
||||
|
||||
private static ISettingStorage CreateStorage()
|
||||
{
|
||||
if (PackageIdentityAdapter.HasPackageIdentity)
|
||||
{
|
||||
return new PackagedSettingStorage();
|
||||
}
|
||||
|
||||
return new UnpackagedSettingStorage();
|
||||
}
|
||||
|
||||
private interface ISettingStorage
|
||||
{
|
||||
T Get<T>(string key, T defaultValue);
|
||||
void Set<T>(string key, T value);
|
||||
}
|
||||
|
||||
private sealed class PackagedSettingStorage : ISettingStorage
|
||||
{
|
||||
private readonly ApplicationDataContainer container = ApplicationData.Current.LocalSettings;
|
||||
|
||||
public T Get<T>(string key, T defaultValue)
|
||||
{
|
||||
if (container.Values.TryGetValue(key, out object? value))
|
||||
{
|
||||
// unbox the value
|
||||
return value is null ? defaultValue : (T)value;
|
||||
}
|
||||
|
||||
Set(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public void Set<T>(string key, T value)
|
||||
{
|
||||
try
|
||||
{
|
||||
container.Values[key] = value;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 状态管理器无法写入设置
|
||||
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_STATE_WRITE_SETTING_FAILED))
|
||||
{
|
||||
HutaoNative.Instance.ShowErrorMessage(ex.Message, ExceptionFormat.Format(ex));
|
||||
ProcessFactory.KillCurrent();
|
||||
}
|
||||
|
||||
throw;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class UnpackagedSettingStorage : ISettingStorage
|
||||
{
|
||||
private readonly string settingsFilePath;
|
||||
private readonly ConcurrentDictionary<string, object?> cache = new();
|
||||
private readonly object fileLock = new();
|
||||
private readonly JsonSerializerOptions jsonOptions = new()
|
||||
{
|
||||
WriteIndented = true,
|
||||
Converters =
|
||||
{
|
||||
new ApplicationDataCompositeValueJsonConverter(),
|
||||
}
|
||||
};
|
||||
|
||||
public UnpackagedSettingStorage()
|
||||
{
|
||||
string localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
|
||||
const string FolderName
|
||||
#if IS_ALPHA_BUILD
|
||||
= "HutaoAlpha";
|
||||
#elif IS_CANARY_BUILD
|
||||
= "HutaoCanary";
|
||||
#else
|
||||
= "Hutao";
|
||||
#endif
|
||||
string settingsDir = Path.Combine(localAppData, FolderName, "Settings");
|
||||
Directory.CreateDirectory(settingsDir);
|
||||
settingsFilePath = Path.Combine(settingsDir, "LocalSettings.json");
|
||||
|
||||
LoadFromFile();
|
||||
}
|
||||
|
||||
public T Get<T>(string key, T defaultValue)
|
||||
{
|
||||
if (cache.TryGetValue(key, out object? value))
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Handle JSON deserialization for complex types
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
try
|
||||
{
|
||||
// ⚠️ 特殊处理:JSON 数字类型转换
|
||||
Type targetType = typeof(T);
|
||||
|
||||
if (jsonElement.ValueKind == JsonValueKind.Number)
|
||||
{
|
||||
if (targetType == typeof(int))
|
||||
{
|
||||
return (T)(object)jsonElement.GetInt32();
|
||||
}
|
||||
if (targetType == typeof(long))
|
||||
{
|
||||
return (T)(object)jsonElement.GetInt64();
|
||||
}
|
||||
if (targetType == typeof(short))
|
||||
{
|
||||
return (T)(object)jsonElement.GetInt16();
|
||||
}
|
||||
if (targetType == typeof(byte))
|
||||
{
|
||||
return (T)(object)jsonElement.GetByte();
|
||||
}
|
||||
if (targetType == typeof(uint))
|
||||
{
|
||||
return (T)(object)jsonElement.GetUInt32();
|
||||
}
|
||||
if (targetType == typeof(ulong))
|
||||
{
|
||||
return (T)(object)jsonElement.GetUInt64();
|
||||
}
|
||||
if (targetType == typeof(ushort))
|
||||
{
|
||||
return (T)(object)jsonElement.GetUInt16();
|
||||
}
|
||||
if (targetType == typeof(float))
|
||||
{
|
||||
return (T)(object)jsonElement.GetSingle();
|
||||
}
|
||||
if (targetType == typeof(double))
|
||||
{
|
||||
return (T)(object)jsonElement.GetDouble();
|
||||
}
|
||||
}
|
||||
|
||||
// 其他类型使用标准反序列化
|
||||
return jsonElement.Deserialize<T>(jsonOptions) ?? defaultValue;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return defaultValue;
|
||||
}
|
||||
}
|
||||
|
||||
// ⚠️ 如果是直接从 cache 读取的值,也可能需要类型转换
|
||||
// 例如:double -> int
|
||||
if (value is double doubleValue)
|
||||
{
|
||||
Type targetType = typeof(T);
|
||||
if (targetType == typeof(int))
|
||||
{
|
||||
return (T)(object)(int)doubleValue;
|
||||
}
|
||||
if (targetType == typeof(long))
|
||||
{
|
||||
return (T)(object)(long)doubleValue;
|
||||
}
|
||||
if (targetType == typeof(short))
|
||||
{
|
||||
return (T)(object)(short)doubleValue;
|
||||
}
|
||||
if (targetType == typeof(byte))
|
||||
{
|
||||
return (T)(object)(byte)doubleValue;
|
||||
}
|
||||
}
|
||||
|
||||
return (T)value;
|
||||
}
|
||||
|
||||
Set(key, defaultValue);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
public void Set<T>(string key, T value)
|
||||
{
|
||||
cache[key] = value;
|
||||
SaveToFile();
|
||||
}
|
||||
|
||||
private void LoadFromFile()
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
if (!File.Exists(settingsFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
string json = File.ReadAllText(settingsFilePath);
|
||||
Dictionary<string, JsonElement>? data = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(json, jsonOptions);
|
||||
if (data is not null)
|
||||
{
|
||||
foreach ((string key, JsonElement value) in data)
|
||||
{
|
||||
cache[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// If file is corrupted, start fresh
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void SaveToFile()
|
||||
{
|
||||
lock (fileLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Convert cache to serializable dictionary
|
||||
Dictionary<string, object?> serializableData = new(cache);
|
||||
string json = JsonSerializer.Serialize(serializableData, jsonOptions);
|
||||
File.WriteAllText(settingsFilePath, json);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"Failed to save settings: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Converter for ApplicationDataCompositeValue
|
||||
private sealed class ApplicationDataCompositeValueJsonConverter : System.Text.Json.Serialization.JsonConverter<ApplicationDataCompositeValue>
|
||||
{
|
||||
public override ApplicationDataCompositeValue? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType != JsonTokenType.StartObject)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
ApplicationDataCompositeValue composite = new();
|
||||
while (reader.Read())
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.EndObject)
|
||||
{
|
||||
return composite;
|
||||
}
|
||||
|
||||
if (reader.TokenType != JsonTokenType.PropertyName)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
string? key = reader.GetString();
|
||||
reader.Read();
|
||||
|
||||
if (key is not null)
|
||||
{
|
||||
composite[key] = reader.TokenType switch
|
||||
{
|
||||
JsonTokenType.String => reader.GetString(),
|
||||
JsonTokenType.Number => reader.TryGetInt64(out long l) ? l : reader.GetDouble(),
|
||||
JsonTokenType.True => true,
|
||||
JsonTokenType.False => false,
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return composite;
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, ApplicationDataCompositeValue value, JsonSerializerOptions options)
|
||||
{
|
||||
writer.WriteStartObject();
|
||||
foreach ((string key, object? val) in value)
|
||||
{
|
||||
writer.WritePropertyName(key);
|
||||
if (val is null)
|
||||
{
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
else
|
||||
{
|
||||
JsonSerializer.Serialize(writer, val, val.GetType(), options);
|
||||
}
|
||||
}
|
||||
|
||||
writer.WriteEndObject();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,6 +74,10 @@ internal static class SettingKeys
|
||||
public const string CultivationWeapon90LevelCurrent = "Snap::Hutao::Cultivation::Weapon90::Level::Current";
|
||||
public const string CultivationWeapon90LevelTarget = "Snap::Hutao::Cultivation::Weapon90::Level::Target";
|
||||
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
|
||||
public const string IsEmptyHistoryWishVisible = "Snap::Hutao::GachaLog::HistoryWish::EmptyVisible";
|
||||
|
||||
45
src/Snap.Hutao/Snap.Hutao/Factory/Process/NullProcess.cs
Normal file
45
src/Snap.Hutao/Snap.Hutao/Factory/Process/NullProcess.cs
Normal 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
|
||||
}
|
||||
}
|
||||
@@ -147,6 +147,27 @@ internal sealed class ProcessFactory
|
||||
{
|
||||
string repoDirectory = HutaoRuntime.GetDataRepositoryDirectory();
|
||||
string fullTrustFilePath = Path.Combine(repoDirectory, "Snap.ContentDelivery", "Snap.Hutao.FullTrust.exe");
|
||||
|
||||
// Check if FullTrust executable exists - if not, fallback to normal admin mode
|
||||
if (!File.Exists(fullTrustFilePath))
|
||||
{
|
||||
string errorMessage = $"""
|
||||
Island 功能需要的 FullTrust 进程文件不存在,将使用普通管理员模式启动游戏。
|
||||
预期路径:{fullTrustFilePath}
|
||||
|
||||
原因:ContentDelivery 仓库尚未下载或初始化失败(常见于非打包模式首次运行)
|
||||
|
||||
Island 功能将不可用,但游戏可以正常启动。
|
||||
等待仓库下载完成后可重新尝试使用 Island 功能。
|
||||
""";
|
||||
|
||||
// Capture as breadcrumb instead of exception
|
||||
SentrySdk.AddBreadcrumb(errorMessage, category: "process.fulltrust", level: Sentry.BreadcrumbLevel.Warning);
|
||||
|
||||
// Fallback to normal admin mode - Island features will not work but game can launch
|
||||
return CreateUsingShellExecuteRunAs(arguments, fileName, workingDirectory);
|
||||
}
|
||||
|
||||
StartUsingShellExecuteRunAs(fullTrustFilePath);
|
||||
|
||||
FullTrustProcessStartInfoRequest request = new()
|
||||
@@ -172,6 +193,32 @@ internal sealed class ProcessFactory
|
||||
|
||||
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
|
||||
{
|
||||
FileName = fileName,
|
||||
@@ -179,4 +226,6 @@ internal sealed class ProcessFactory
|
||||
Verb = "runas",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
668
src/Snap.Hutao/Snap.Hutao/Migrations/20260503131124_AddCultivateEntryRelatedEntryId.Designer.cs
generated
Normal file
668
src/Snap.Hutao/Snap.Hutao/Migrations/20260503131124_AddCultivateEntryRelatedEntryId.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace Snap.Hutao.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#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 =>
|
||||
{
|
||||
@@ -113,6 +113,9 @@ namespace Snap.Hutao.Migrations
|
||||
b.Property<Guid>("ProjectId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<Guid?>("RelatedEntryId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -120,6 +123,8 @@ namespace Snap.Hutao.Migrations
|
||||
|
||||
b.HasIndex("ProjectId");
|
||||
|
||||
b.HasIndex("RelatedEntryId");
|
||||
|
||||
b.ToTable("cultivate_entries");
|
||||
});
|
||||
|
||||
@@ -207,6 +212,9 @@ namespace Snap.Hutao.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("AvatarPropertyBatchCultivatePreferencesJson")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsSelected")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
@@ -585,7 +593,14 @@ namespace Snap.Hutao.Migrations
|
||||
.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 =>
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,11 @@ internal sealed class CultivateEntry : IAppDbEntity
|
||||
|
||||
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)
|
||||
{
|
||||
return new()
|
||||
|
||||
@@ -22,6 +22,11 @@ internal sealed partial class CultivateProject : ISelectable,
|
||||
|
||||
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)
|
||||
{
|
||||
return new()
|
||||
|
||||
@@ -90,6 +90,7 @@ internal sealed partial class AppDbContext : DbContext
|
||||
{
|
||||
modelBuilder
|
||||
.ApplyConfiguration(new AvatarInfoConfiguration())
|
||||
.ApplyConfiguration(new CultivateEntryConfiguration())
|
||||
.ApplyConfiguration(new DailyNoteEntryConfiguration())
|
||||
.ApplyConfiguration(new SpiralAbyssEntryConfiguration())
|
||||
.ApplyConfiguration(new RoleCombatEntryConfiguration())
|
||||
|
||||
@@ -124,6 +124,16 @@ internal static class AvatarIds
|
||||
public static readonly AvatarId Flins = 10000120;
|
||||
public static readonly AvatarId Aino = 10000121;
|
||||
public static readonly AvatarId Nefer = 10000122;
|
||||
public static readonly AvatarId Durin = 10000123;
|
||||
public static readonly AvatarId Jahoda = 10000124;
|
||||
public static readonly AvatarId Columbina = 10000125;
|
||||
public static readonly AvatarId Zibai = 10000126;
|
||||
public static readonly AvatarId Illuga = 10000127;
|
||||
public static readonly AvatarId Varka = 10000128;
|
||||
public static readonly AvatarId Lohen = 10000129;
|
||||
public static readonly AvatarId Linnea = 10000130;
|
||||
public static readonly AvatarId Nicole = 10000131;
|
||||
public static readonly AvatarId Prune = 10000132;
|
||||
|
||||
private static readonly FrozenSet<AvatarId> StandardWishIds =
|
||||
[
|
||||
|
||||
@@ -10,6 +10,18 @@ namespace Snap.Hutao.Model.Metadata.Item;
|
||||
|
||||
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 WanderersAdvice = 104001U; // 流浪者的经验
|
||||
public const uint AdventurersExperience = 104002U; // 冒险家的经验
|
||||
|
||||
@@ -22,17 +22,17 @@ internal static class WeaponIds
|
||||
11401U, 11402U, 11403U, 11405U,
|
||||
12401U, 12402U, 12403U, 12405U,
|
||||
13401U, 13407U,
|
||||
14401U, 14402U, 14403U, 14409U,
|
||||
15401U, 15402U, 15403U, 15405U
|
||||
14401U, 14402U, 14403U, 14409U, 14433U, 14434U,
|
||||
15401U, 15402U, 15403U, 15405U, 15434U
|
||||
];
|
||||
|
||||
public static readonly FrozenSet<WeaponId> OrangeStandardWishIds =
|
||||
[
|
||||
11501U, 11502U,
|
||||
11501U, 11502U, 11518U, 11519U,
|
||||
12501U, 12502U,
|
||||
13502U, 13505U,
|
||||
14501U, 14502U,
|
||||
15501U, 15502U,
|
||||
13502U, 13505U, 13517U,
|
||||
14501U, 14502U, 14522U, 14523U,
|
||||
15501U, 15502U, 15515U
|
||||
];
|
||||
|
||||
public static bool IsOrangeStandardWish(in WeaponId weaponId)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
|
||||
<Package
|
||||
xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10"
|
||||
@@ -13,7 +13,7 @@
|
||||
<Identity
|
||||
Name="60568DGPStudio.SnapHutao"
|
||||
Publisher="CN=35C8E923-85DF-49A7-9172-B39DC6312C52"
|
||||
Version="1.17.1.0" />
|
||||
Version="1.18.7.0" />
|
||||
|
||||
<Properties>
|
||||
<DisplayName>Snap Hutao</DisplayName>
|
||||
|
||||
@@ -1181,6 +1181,9 @@ Space Available: {2}</value>
|
||||
<data name="ServiceYaeWaitForGameResponseMessage" xml:space="preserve">
|
||||
<value>Waiting for game data</value>
|
||||
</data>
|
||||
<data name="UIViewMainTitleBarBackgroundActivityAction" xml:space="preserve">
|
||||
<value>Background task</value>
|
||||
</data>
|
||||
<data name="UIViewPageAvatarPropertyRecommendedAppendProperties" xml:space="preserve">
|
||||
<value>Additional Property Recommendation</value>
|
||||
</data>
|
||||
@@ -1346,6 +1349,9 @@ Space Available: {2}</value>
|
||||
<data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve">
|
||||
<value>Weapon Target Level</value>
|
||||
</data>
|
||||
<data name="ViewDialogCultivateBatchClearAvatarAndWeaponEntries" xml:space="preserve">
|
||||
<value>Clear existing character and weapon entries before updating</value>
|
||||
</data>
|
||||
<data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve">
|
||||
<value>Enter the plan name here</value>
|
||||
</data>
|
||||
@@ -1877,6 +1883,9 @@ Space Available: {2}</value>
|
||||
<data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve">
|
||||
<value>Ascending only</value>
|
||||
</data>
|
||||
<data name="ViewModelCultivationEntryRelatedAvatar" xml:space="preserve">
|
||||
<value>Linked character: {0}</value>
|
||||
</data>
|
||||
<data name="ViewModelCultivationProjectAdded" xml:space="preserve">
|
||||
<value>Added successfully</value>
|
||||
</data>
|
||||
@@ -2417,6 +2426,15 @@ Space Available: {2}</value>
|
||||
<data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve">
|
||||
<value>Uncollected First</value>
|
||||
</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">
|
||||
<value>Resin Estimation</value>
|
||||
</data>
|
||||
@@ -2426,9 +2444,18 @@ Space Available: {2}</value>
|
||||
<data name="ViewPageCultivationMaterialStatistics" xml:space="preserve">
|
||||
<value>Material Statistics</value>
|
||||
</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">
|
||||
<value>Go</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
|
||||
<value>Sync All Characters and Weapons</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
|
||||
<value>Sync Inventory Items</value>
|
||||
</data>
|
||||
@@ -2438,6 +2465,9 @@ Space Available: {2}</value>
|
||||
<data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve">
|
||||
<value>Sync by Embedded Yae</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRefreshInventoryAllPlansShortLabel" xml:space="preserve">
|
||||
<value>Inventory sync affects all plans</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRemoveEntry" xml:space="preserve">
|
||||
<value>Delete list</value>
|
||||
</data>
|
||||
@@ -3989,4 +4019,7 @@ Space Available: {2}</value>
|
||||
<data name="WindowIdentifyMonitorHeader" xml:space="preserve">
|
||||
<value>Monitor ID</value>
|
||||
</data>
|
||||
<data name="UIViewMainTitleBarInvertTheme" xml:space="preserve">
|
||||
<value>Invert Theme</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -1127,12 +1127,21 @@
|
||||
<data name="ServiceGameSetMultiChannelUnauthorizedAccess" xml:space="preserve">
|
||||
<value>无法读取或保存配置文件,请以管理员模式重试</value>
|
||||
</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">
|
||||
<value>操作完成</value>
|
||||
</data>
|
||||
<data name="ServiceGitRepositoryOperationFailed" xml:space="preserve">
|
||||
<value>操作失败</value>
|
||||
</data>
|
||||
<data name="ServiceGitRepositoryUpdatingExisting" xml:space="preserve">
|
||||
<value>检查元数据更新</value>
|
||||
</data>
|
||||
<data name="ServiceHutaoUserGachaLogExpiredAt" xml:space="preserve">
|
||||
<value>祈愿记录上传服务有效期至</value>
|
||||
</data>
|
||||
@@ -1208,6 +1217,12 @@
|
||||
<data name="ServiceYaeWaitForGameResponseMessage" xml:space="preserve">
|
||||
<value>正在等待游戏数据</value>
|
||||
</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">
|
||||
<value>后台任务</value>
|
||||
</data>
|
||||
@@ -1376,6 +1391,9 @@
|
||||
<data name="ViewDialogCultivateBatchWeaponLevelTarget" xml:space="preserve">
|
||||
<value>武器目标等级</value>
|
||||
</data>
|
||||
<data name="ViewDialogCultivateBatchClearAvatarAndWeaponEntries" xml:space="preserve">
|
||||
<value>更新计划前先清空已有角色与武器条目</value>
|
||||
</data>
|
||||
<data name="ViewDialogCultivateProjectInputPlaceholder" xml:space="preserve">
|
||||
<value>在此处输入计划名称</value>
|
||||
</data>
|
||||
@@ -1586,6 +1604,15 @@
|
||||
<data name="ViewDialogLaunchGamePackageConvertTitle" xml:space="preserve">
|
||||
<value>正在转换客户端</value>
|
||||
</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">
|
||||
<value>使用米游社扫描二维码</value>
|
||||
</data>
|
||||
@@ -1907,6 +1934,9 @@
|
||||
<data name="ViewModelCultivationEntryViewPromoteOnlyHint" xml:space="preserve">
|
||||
<value>仅突破</value>
|
||||
</data>
|
||||
<data name="ViewModelCultivationEntryRelatedAvatar" xml:space="preserve">
|
||||
<value>关联角色:{0}</value>
|
||||
</data>
|
||||
<data name="ViewModelCultivationProjectAdded" xml:space="preserve">
|
||||
<value>添加成功</value>
|
||||
</data>
|
||||
@@ -2447,6 +2477,15 @@
|
||||
<data name="ViewPageCultivationMaterialListIncompleteFirstLabel" xml:space="preserve">
|
||||
<value>未集齐优先</value>
|
||||
</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">
|
||||
<value>树脂预估</value>
|
||||
</data>
|
||||
@@ -2456,9 +2495,18 @@
|
||||
<data name="ViewPageCultivationMaterialStatistics" xml:space="preserve">
|
||||
<value>材料统计</value>
|
||||
</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">
|
||||
<value>前往</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
|
||||
<value>同步所有角色与武器</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
|
||||
<value>同步背包物品</value>
|
||||
</data>
|
||||
@@ -2468,6 +2516,9 @@
|
||||
<data name="ViewPageCultivationRefreshInventoryByEmbeddedYae" xml:space="preserve">
|
||||
<value>通过 Embedded Yae 同步</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRefreshInventoryAllPlansShortLabel" xml:space="preserve">
|
||||
<value>背包同步影响所有计划</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRemoveEntry" xml:space="preserve">
|
||||
<value>删除清单</value>
|
||||
</data>
|
||||
@@ -2916,6 +2967,9 @@
|
||||
<data name="ViewPageLaunchGameInjectionHeader" xml:space="preserve">
|
||||
<value>注入</value>
|
||||
</data>
|
||||
<data name="ViewPageLaunchGameThirdPartyTools" xml:space="preserve">
|
||||
<value>第三方注入工具:</value>
|
||||
</data>
|
||||
<data name="ViewPageLaunchGameIslandConnected" xml:space="preserve">
|
||||
<value>已连接到游戏,更改设置将会动态反映到游戏中</value>
|
||||
</data>
|
||||
@@ -4020,4 +4074,7 @@
|
||||
<data name="WindowIdentifyMonitorHeader" xml:space="preserve">
|
||||
<value>显示器编号</value>
|
||||
</data>
|
||||
<data name="UIViewMainTitleBarInvertTheme" xml:space="preserve">
|
||||
<value>主题切换</value>
|
||||
</data>
|
||||
</root>
|
||||
@@ -2429,6 +2429,9 @@
|
||||
<data name="ViewPageCultivationNavigateAction" xml:space="preserve">
|
||||
<value>前往</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationSyncAllAvatarsAndWeapons" xml:space="preserve">
|
||||
<value>同步所有角色與武器</value>
|
||||
</data>
|
||||
<data name="ViewPageCultivationRefreshInventory" xml:space="preserve">
|
||||
<value>同步背包物品</value>
|
||||
</data>
|
||||
|
||||
@@ -24,8 +24,10 @@ internal static class AvatarViewBuilderExtension
|
||||
{
|
||||
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.SideIcon);
|
||||
|
||||
@@ -34,6 +36,13 @@ internal static class AvatarViewBuilderExtension
|
||||
builder.View.SideIcon = AvatarIconConverter.IconNameToUri(costume.SideIcon);
|
||||
}
|
||||
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.SideIcon = AvatarIconConverter.IconNameToUri(avatar.SideIcon);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,17 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
namespace Snap.Hutao.ViewModel.AvatarProperty;
|
||||
namespace Snap.Hutao.Service.Cultivation;
|
||||
|
||||
internal struct BatchCultivateResult
|
||||
{
|
||||
public int SucceedCount;
|
||||
public int SkippedCount;
|
||||
public BatchCultivateStopReason StopReason;
|
||||
}
|
||||
|
||||
internal enum BatchCultivateStopReason
|
||||
{
|
||||
None = 0,
|
||||
NoProject = 1,
|
||||
}
|
||||
@@ -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);
|
||||
@@ -23,6 +23,9 @@ internal class CultivationMetadataContext : ICultivationMetadataContext
|
||||
|
||||
public ImmutableDictionary<MaterialId, Combine> ResultMaterialIdCombineMap { get; set; } = default!;
|
||||
|
||||
public ImmutableArray<ImmutableArray<MaterialId>> WeeklyBossMaterialInterchangeGroups { get; set; }
|
||||
= ImmutableArray<ImmutableArray<MaterialId>>.Empty;
|
||||
|
||||
public Item GetAvatarItem(AvatarId avatarId)
|
||||
{
|
||||
return this.GetAvatar(avatarId).GetOrCreateItem();
|
||||
|
||||
@@ -2,10 +2,15 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Snap.Hutao.Core.Database;
|
||||
using Snap.Hutao.Model.Entity;
|
||||
using Snap.Hutao.Model.Entity.Database;
|
||||
using Snap.Hutao.Model.Entity.Primitive;
|
||||
using Snap.Hutao.Service.Abstraction;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
this.Add(entry);
|
||||
@@ -77,6 +105,11 @@ internal sealed partial class CultivationRepository : ICultivationRepository
|
||||
return this.ObservableCollection<CultivateProject>();
|
||||
}
|
||||
|
||||
public ImmutableArray<Guid> GetCultivateProjectInnerIds()
|
||||
{
|
||||
return this.ImmutableArray<CultivateProject, Guid>(query => query.Select(p => p.InnerId));
|
||||
}
|
||||
|
||||
public void RemoveLevelInformationByEntryId(Guid 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));
|
||||
}
|
||||
|
||||
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))];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,6 @@ internal sealed partial class CultivationResinStatisticsService : ICultivationRe
|
||||
|
||||
private static double GetStatisticsCultivateItemTimes(StatisticsCultivateItem item)
|
||||
{
|
||||
return item.Count - (long)item.Current;
|
||||
return item.Count - (long)item.DisplayCurrent;
|
||||
}
|
||||
}
|
||||
@@ -2,20 +2,28 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
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.Primitive;
|
||||
using Snap.Hutao.Model.Intrinsic;
|
||||
using Snap.Hutao.Model.Metadata;
|
||||
using Snap.Hutao.Model.Metadata.Item;
|
||||
using Snap.Hutao.Model.Primitive;
|
||||
using Snap.Hutao.Service.Abstraction;
|
||||
using Snap.Hutao.Service.Cultivation.Consumption;
|
||||
using Snap.Hutao.Service.Inventory;
|
||||
using Snap.Hutao.Service.Metadata.ContextAbstraction;
|
||||
using Snap.Hutao.ViewModel.Cultivation;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.Immutable;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Text.Json;
|
||||
using ModelItem = Snap.Hutao.Model.Item;
|
||||
|
||||
namespace Snap.Hutao.Service.Cultivation;
|
||||
@@ -63,10 +71,21 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
{
|
||||
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);
|
||||
foreach (ref readonly CultivateEntry entry in entries.AsSpan())
|
||||
{
|
||||
ImmutableArray<CultivateItem> items = cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId);
|
||||
if (IsHiddenAssociationOnlyAvatarEntry(entry, items.Length))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
ImmutableArray<CultivateItemView>.Builder entryItems = ImmutableArray.CreateBuilder<CultivateItemView>(items.Length);
|
||||
|
||||
foreach (ref readonly CultivateItem cultivateItem in items.AsSpan())
|
||||
@@ -83,7 +102,13 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
_ => 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();
|
||||
@@ -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();
|
||||
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 = [];
|
||||
Guid projectId = cultivateProject.InnerId;
|
||||
Dictionary<uint, uint> inventoryCounts = [];
|
||||
|
||||
foreach (ref readonly CultivateEntry entry in cultivationRepository.GetCultivateEntryImmutableArrayByProjectId(projectId).AsSpan())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
foreach (ref readonly CultivateItem item in cultivationRepository.GetCultivateItemImmutableArrayByEntryId(entry.InnerId).AsSpan())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ref StatisticsCultivateItem? existedItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, item.ItemId, out _);
|
||||
if (existedItem is null || existedItem.ExcludedFromPresentation)
|
||||
{
|
||||
@@ -116,12 +147,14 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
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())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
inventoryCounts[inventoryItem.ItemId] = inventoryItem.Count;
|
||||
ref StatisticsCultivateItem existedItem = ref CollectionsMarshal.GetValueRefOrNullRef(resultItems, inventoryItem.ItemId);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
return cultivationResinStatisticsService.GetResinStatisticsAsync(statisticsCultivateItems, token);
|
||||
@@ -144,41 +240,84 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
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)
|
||||
{
|
||||
cultivationRepository.UpdateCultivateItem(item.Entity);
|
||||
}
|
||||
|
||||
public async ValueTask<ConsumptionSaveResultKind> SaveConsumptionAsync(InputConsumption inputConsumption)
|
||||
public async ValueTask<ConsumptionSaveResult> SaveConsumptionAsync(InputConsumption inputConsumption)
|
||||
{
|
||||
// No selected project
|
||||
IAdvancedDbCollectionView<CultivateProject> projects = await GetProjectCollectionAsync().ConfigureAwait(false);
|
||||
if (!await EnsureCurrentProjectAsync(projects).ConfigureAwait(false))
|
||||
{
|
||||
return ConsumptionSaveResultKind.NoProject;
|
||||
return new(ConsumptionSaveResultKind.NoProject);
|
||||
}
|
||||
|
||||
ArgumentNullException.ThrowIfNull(projects.CurrentItem);
|
||||
Guid projectId = projects.CurrentItem.InnerId;
|
||||
|
||||
await taskContext.SwitchToBackgroundAsync();
|
||||
|
||||
// PreserveExisting or CreateNewEntry, but no item
|
||||
if (inputConsumption is { Strategy: not ConsumptionSaveStrategyKind.OverwriteExisting, Items: [] })
|
||||
{
|
||||
return ConsumptionSaveResultKind.NoItem;
|
||||
return new(ConsumptionSaveResultKind.NoItem);
|
||||
}
|
||||
|
||||
// PreserveExisting or OverwriteExisting
|
||||
if (inputConsumption.Strategy is not ConsumptionSaveStrategyKind.CreateNewEntry)
|
||||
{
|
||||
// Check for existing entries
|
||||
ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectIdAndItemId(projects.CurrentItem.InnerId, inputConsumption.ItemId);
|
||||
ImmutableArray<CultivateEntry> entries = cultivationRepository.GetCultivateEntryImmutableArrayByProjectIdAndItemId(projectId, inputConsumption.ItemId);
|
||||
|
||||
if (entries.Length > 0)
|
||||
{
|
||||
if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.PreserveExisting)
|
||||
{
|
||||
return ConsumptionSaveResultKind.Skipped;
|
||||
return new(ConsumptionSaveResultKind.Skipped);
|
||||
}
|
||||
|
||||
if (inputConsumption.Strategy is ConsumptionSaveStrategyKind.OverwriteExisting)
|
||||
@@ -192,7 +331,8 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
|
||||
if (inputConsumption.Items is [])
|
||||
{
|
||||
return ConsumptionSaveResultKind.Removed;
|
||||
entryCollectionCache.TryRemove(projectId, out _);
|
||||
return new(ConsumptionSaveResultKind.Removed);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -200,13 +340,14 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
{
|
||||
if (inputConsumption.Items is [])
|
||||
{
|
||||
return ConsumptionSaveResultKind.NoItem;
|
||||
return new(ConsumptionSaveResultKind.NoItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
CultivateEntry entry = CultivateEntry.From(projects.CurrentItem.InnerId, inputConsumption.Type, inputConsumption.ItemId);
|
||||
CultivateEntry entry = CultivateEntry.From(projectId, inputConsumption.Type, inputConsumption.ItemId);
|
||||
entry.RelatedEntryId = inputConsumption.RelatedEntryId;
|
||||
cultivationRepository.AddCultivateEntry(entry);
|
||||
|
||||
CultivateEntryLevelInformation entryLevelInformation = CultivateEntryLevelInformation.From(entry.InnerId, inputConsumption.Type, inputConsumption.LevelInformation);
|
||||
@@ -217,10 +358,52 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
|
||||
// The consumption save operation is always performed outside cultivation page
|
||||
// and without touching the cache. So we have to invalidate the cache manually.
|
||||
entryCollectionCache.TryRemove(projects.CurrentItem.InnerId, out _);
|
||||
entryCollectionCache.TryRemove(projectId, out _);
|
||||
|
||||
return new(ConsumptionSaveResultKind.Added, entry.InnerId);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -244,6 +427,50 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
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)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(projects);
|
||||
@@ -280,8 +507,10 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
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)
|
||||
{
|
||||
foreach (ref readonly MaterialId xpBookId in (ReadOnlySpan<MaterialId>)[104001U, 104002U])
|
||||
@@ -297,10 +526,165 @@ internal sealed partial class CultivationService : ICultivationService
|
||||
{
|
||||
foreach (ref readonly IdCount ingredient in combine.Materials.AsSpan())
|
||||
{
|
||||
token.ThrowIfCancellationRequested();
|
||||
ref StatisticsCultivateItem? ingredientItem = ref CollectionsMarshal.GetValueRefOrAddDefault(resultItems, ingredient.Id, out _);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -14,7 +14,8 @@ internal interface ICultivationMetadataContext : IMetadataContext,
|
||||
IMetadataDictionaryIdMaterialSource,
|
||||
IMetadataDictionaryIdAvatarSource,
|
||||
IMetadataDictionaryIdWeaponSource,
|
||||
IMetadataDictionaryResultMaterialIdCombineSource
|
||||
IMetadataDictionaryResultMaterialIdCombineSource,
|
||||
IMetadataWeeklyBossMaterialInterchangeGroupsSource
|
||||
{
|
||||
Item GetAvatarItem(AvatarId avatarId);
|
||||
|
||||
|
||||
@@ -27,6 +27,8 @@ internal interface ICultivationRepository : IRepository<CultivateEntryLevelInfor
|
||||
|
||||
ObservableCollection<CultivateProject> GetCultivateProjectCollection();
|
||||
|
||||
ImmutableArray<Guid> GetCultivateProjectInnerIds();
|
||||
|
||||
CultivateProject? GetCultivateProjectById(Guid projectId);
|
||||
|
||||
void AddCultivateEntry(CultivateEntry entry);
|
||||
@@ -43,5 +45,16 @@ internal interface ICultivationRepository : IRepository<CultivateEntryLevelInfor
|
||||
|
||||
ImmutableArray<CultivateEntry> GetCultivateEntryImmutableArrayByProjectIdAndItemId(Guid projectId, uint itemId);
|
||||
|
||||
/// <summary>
|
||||
/// 解析当前计划中指定角色的养成条目(类型为 CultivateType.AvatarAndSkill)。
|
||||
/// 若存在多条历史记录,取最近插入数据库的一条(按 SQLite rowid 倒序)。
|
||||
/// </summary>
|
||||
Guid? TryGetAvatarCultivateEntryInnerId(Guid projectId, uint avatarId);
|
||||
|
||||
Guid GetCultivateProjectIdByEntryId(Guid entryId);
|
||||
|
||||
/// <summary>
|
||||
/// 联表查询某计划下所有养成物品及其所属条目(用于材料统计未勾选条目等)。
|
||||
/// </summary>
|
||||
ImmutableArray<(CultivateEntry Entry, CultivateItem Item)> GetCultivateEntryItemPairsByProjectId(Guid projectId);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.Database;
|
||||
using Snap.Hutao.Model.Cultivation;
|
||||
using Snap.Hutao.Model.Entity;
|
||||
using Snap.Hutao.Service.Cultivation.Consumption;
|
||||
using Snap.Hutao.ViewModel.Cultivation;
|
||||
@@ -17,17 +18,40 @@ internal interface ICultivationService
|
||||
|
||||
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 RemoveCultivateEntryAsync(Guid entryId);
|
||||
|
||||
/// <summary>
|
||||
/// 移除当前选中养成计划中角色与武器类型的全部养成条目。
|
||||
/// </summary>
|
||||
ValueTask RemoveAvatarAndWeaponEntriesForCurrentProjectAsync();
|
||||
|
||||
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);
|
||||
|
||||
ValueTask<ProjectAddResultKind> TryAddProjectAsync(CultivateProject project);
|
||||
|
||||
/// <summary>
|
||||
/// 读取当前选中养成计划下「我的角色」批量同步对话框的已保存选项;未保存时返回 <see langword="null"/>。
|
||||
/// </summary>
|
||||
ValueTask<CultivateProjectAvatarPropertyBatchPreferences?> GetAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync();
|
||||
|
||||
/// <summary>
|
||||
/// 将批量同步对话框的选项写入当前选中养成计划。
|
||||
/// </summary>
|
||||
ValueTask SaveAvatarPropertyBatchCultivatePreferencesForCurrentProjectAsync(CultivateProjectAvatarPropertyBatchPreferences preferences);
|
||||
}
|
||||
@@ -19,4 +19,9 @@ internal sealed class InputConsumption
|
||||
public required LevelInformation LevelInformation { get; init; }
|
||||
|
||||
public required ConsumptionSaveStrategyKind Strategy { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 武器条目关联的养成角色条目的主键(自引用外键,可为空)。
|
||||
/// </summary>
|
||||
public Guid? RelatedEntryId { get; init; }
|
||||
}
|
||||
@@ -50,7 +50,7 @@ internal sealed partial class StatisticsCultivateItemCollection : ICollection<St
|
||||
return result;
|
||||
}
|
||||
|
||||
return MaterialIdComparer.Shared.Compare(x.Inner.Id, y.Inner.Id);
|
||||
return StatisticsCultivateItemComparer.CompareCore(x, y);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,53 @@
|
||||
// Copyright (c) DGP Studio. All rights reserved.
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.Collection.Generic;
|
||||
using Snap.Hutao.Model.Primitive;
|
||||
using Snap.Hutao.ViewModel.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 StatisticsCultivateItemComparer()
|
||||
: base(static i => i.Inner.Id, MaterialIdComparer.Shared)
|
||||
{
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
using (ValueStopwatch.MeasureExecution(logger))
|
||||
|
||||
@@ -22,4 +22,6 @@ internal interface IGachaLogService
|
||||
ValueTask RemoveArchiveAsync(GachaArchive archive);
|
||||
|
||||
ValueTask<IAdvancedDbCollectionView<GachaArchive>> GetArchiveCollectionAsync();
|
||||
|
||||
ValueTask<IAdvancedDbCollectionView<GachaArchive>> RefreshArchiveCollectionAsync();
|
||||
}
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -77,22 +77,49 @@ internal sealed class GameIslandInterop : IGameIslandInterop
|
||||
{
|
||||
nint handle = accessor.SafeMemoryMappedViewHandle.DangerousGetHandle();
|
||||
InitializeIslandEnvironment(handle, context.LaunchOptions, context.IsOversea);
|
||||
|
||||
if (!resume)
|
||||
{
|
||||
if (context.Process is not FullTrustProcess fullTrustProcess)
|
||||
{
|
||||
throw HutaoException.InvalidOperation("Process is not full trust");
|
||||
}
|
||||
|
||||
ArgumentException.ThrowIfNullOrEmpty(islandPath);
|
||||
if (!File.Exists(islandPath))
|
||||
{
|
||||
throw HutaoException.InvalidOperation(SH.ServiceGameIslandTargetVersionFileNotExists);
|
||||
}
|
||||
|
||||
// Support both FullTrust and normal admin mode
|
||||
if (context.Process is FullTrustProcess fullTrustProcess)
|
||||
{
|
||||
// Use FullTrust process for injection (suspended process)
|
||||
fullTrustProcess.LoadLibrary(FullTrustLoadLibraryRequest.Create("Island", islandPath));
|
||||
fullTrustProcess.ResumeMainThread();
|
||||
}
|
||||
else
|
||||
{
|
||||
// Use native injection for normal admin mode
|
||||
// The process was already started by CreateUsingShellExecuteRunAs
|
||||
// Just inject the DLL into the running process
|
||||
try
|
||||
{
|
||||
// Wait a bit for process to initialize
|
||||
// await Task.Delay(500, token).ConfigureAwait(false);
|
||||
// ⚠️此处需要更多调查
|
||||
|
||||
// Inject using RemoteThread
|
||||
DllInjectionUtilities.InjectUsingRemoteThread(islandPath, context.Process.Id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Log the injection failure but don't crash - game can still run
|
||||
SentrySdk.AddBreadcrumb(
|
||||
$"Island DLL injection failed: {ex.Message}",
|
||||
category: "island.injection",
|
||||
level: Sentry.BreadcrumbLevel.Error);
|
||||
|
||||
// Re-throw to let the caller handle it
|
||||
throw HutaoException.Throw($"Island DLL 注入失败: {ex.Message}", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await PeriodicUpdateIslandEnvironmentAsync(context, handle, token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,8 @@ using Snap.Hutao.Service.Game.FileSystem;
|
||||
using Snap.Hutao.Service.Game.PathAbstraction;
|
||||
using Snap.Hutao.Win32;
|
||||
using System.Collections.Immutable;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
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); }
|
||||
|
||||
[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]
|
||||
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();
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
ImmutableArray<NameValue<int>>.Builder monitors = ImmutableArray.CreateBuilder<NameValue<int>>();
|
||||
|
||||
@@ -45,6 +45,7 @@ internal sealed class GameProcessFactory
|
||||
string gameFilePath = context.FileSystem.GameFilePath;
|
||||
string gameDirectory = context.FileSystem.GameDirectory;
|
||||
|
||||
// ProcessFactory.CreateUsingFullTrustSuspended will automatically fallback to normal mode if FullTrust.exe is missing
|
||||
return launchOptions.IsIslandEnabled.Value
|
||||
? ProcessFactory.CreateUsingFullTrustSuspended(commandLine, gameFilePath, gameDirectory)
|
||||
: ProcessFactory.CreateUsingShellExecuteRunAs(commandLine, gameFilePath, gameDirectory);
|
||||
|
||||
@@ -8,10 +8,10 @@ using Snap.Hutao.Service.Notification;
|
||||
|
||||
namespace Snap.Hutao.Service.Game.Launching.Handler;
|
||||
|
||||
internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecutionHandler
|
||||
internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecutionHandler, IDisposable
|
||||
{
|
||||
private readonly bool resume;
|
||||
private GameIslandInterop? interop;
|
||||
private GameFpsUnlockInterop? interop;
|
||||
|
||||
public LaunchExecutionGameIslandHandler(bool resume)
|
||||
{
|
||||
@@ -63,4 +63,9 @@ internal sealed class LaunchExecutionGameIslandHandler : AbstractLaunchExecution
|
||||
GameLifeCycle.IsIslandConnected.Value = false;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
interop?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,20 @@ internal sealed class LaunchExecutionGameProcessStartHandler : AbstractLaunchExe
|
||||
{
|
||||
try
|
||||
{
|
||||
// 对于suspended进程(Yae注入模式、Island模式),需要先Start()创建进程,然后ResumeMainThread()恢复主线程
|
||||
// 对于正常启动的进程(ShellExecute、DiagnosticsProcess),只调用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();
|
||||
GameLifeCycle.IsGameRunningProperty.Value = true;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,11 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core;
|
||||
using Snap.Hutao.Core.Diagnostics;
|
||||
using Snap.Hutao.Core.ExceptionService;
|
||||
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.Launching.Context;
|
||||
using Snap.Hutao.Service.Yae.Achievement;
|
||||
@@ -32,36 +35,57 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
|
||||
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");
|
||||
InstalledLocation.CopyFileFromApplicationUri("ms-appx:///YaeAchievementLib.dll", dataFolderYaePath);
|
||||
|
||||
// 直接使用创建的游戏进程
|
||||
int actualProcessId = context.Process.Id;
|
||||
if (actualProcessId == 0)
|
||||
{
|
||||
throw HutaoException.Throw("游戏进程未正确创建");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", context.Process.Id);
|
||||
DllInjectionUtilities.InjectUsingRemoteThread(dataFolderYaePath, "YaeMain", actualProcessId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Windows Defender Application Control
|
||||
if (HutaoNative.IsWin32(ex.HResult, WIN32_ERROR.ERROR_SYSTEM_INTEGRITY_POLICY_VIOLATION))
|
||||
{
|
||||
context.Process.Kill();
|
||||
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
|
||||
{
|
||||
// 获取游戏进程用于命名管道服务器
|
||||
IProcess actualProcess = ProcessFactory.TryGetById(actualProcessId, out IProcess? process)
|
||||
? process
|
||||
: throw HutaoException.Throw($"无法获取进程 ID {actualProcessId}");
|
||||
|
||||
// 已经是运行状态,不需要恢复主线程
|
||||
#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
|
||||
{
|
||||
receiver.Array = await server.GetDataArrayAsync().ConfigureAwait(false);
|
||||
@@ -69,7 +93,6 @@ internal sealed class LaunchExecutionYaeNamedPipeHandler : AbstractLaunchExecuti
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
context.Process.Kill();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
using Snap.Hutao.Core.Diagnostics;
|
||||
using Snap.Hutao.Core.ExceptionService;
|
||||
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.Handler;
|
||||
@@ -20,6 +21,8 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
private bool invoked;
|
||||
|
||||
protected ImmutableArray<ILaunchExecutionHandler> Handlers { get; init; }
|
||||
protected virtual bool ShouldWaitForProcessExit { get => true; }
|
||||
protected virtual bool ShouldSpinWaitGameExitAfterInvoke { get => true; }
|
||||
|
||||
public static bool Invoking()
|
||||
{
|
||||
@@ -39,7 +42,7 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
finally
|
||||
{
|
||||
Invokers.TryRemove(this, out _);
|
||||
if (!Invoking())
|
||||
if (!Invoking() && ShouldSpinWaitGameExitAfterInvoke)
|
||||
{
|
||||
await GameLifeCycle.SpinWaitGameExitAsync(taskContext).ConfigureAwait(false);
|
||||
}
|
||||
@@ -100,9 +103,16 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
|
||||
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;
|
||||
}
|
||||
@@ -114,7 +124,7 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
TaskContext = taskContext,
|
||||
Messenger = context.ServiceProvider.GetRequiredService<IMessenger>(),
|
||||
LaunchOptions = context.LaunchOptions,
|
||||
Process = process,
|
||||
Process = process ?? new NullProcess(),
|
||||
IsOversea = targetScheme.IsOversea,
|
||||
};
|
||||
|
||||
@@ -123,7 +133,8 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
await handler.ExecuteAsync(executionContext).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (process.IsRunning)
|
||||
// 只有在没有启用Island且进程存在时才等待退出
|
||||
if (ShouldWaitForProcessExit && process is { IsRunning: true })
|
||||
{
|
||||
progress.Report(new(SH.ServiceGameLaunchPhaseWaitingProcessExit));
|
||||
try
|
||||
@@ -139,6 +150,12 @@ internal abstract class AbstractLaunchExecutionInvoker
|
||||
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));
|
||||
|
||||
@@ -9,6 +9,9 @@ namespace Snap.Hutao.Service.Game.Launching.Invoker;
|
||||
|
||||
internal sealed class ConvertOnlyLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
|
||||
{
|
||||
protected override bool ShouldWaitForProcessExit { get => false; }
|
||||
protected override bool ShouldSpinWaitGameExitAfterInvoke { get => false; }
|
||||
|
||||
public ConvertOnlyLaunchExecutionInvoker()
|
||||
{
|
||||
Handlers =
|
||||
|
||||
@@ -16,8 +16,8 @@ internal sealed class DefaultLaunchExecutionInvoker : AbstractLaunchExecutionInv
|
||||
new LaunchExecutionGameResourceHandler(convertOnly: false),
|
||||
new LaunchExecutionGameIdentityHandler(),
|
||||
new LaunchExecutionWindowsHDRHandler(),
|
||||
new LaunchExecutionGameProcessStartHandler(),
|
||||
new LaunchExecutionGameIslandHandler(resume: false),
|
||||
new LaunchExecutionGameProcessStartHandler(),
|
||||
new LaunchExecutionOverlayHandler(),
|
||||
new LaunchExecutionStarwardPlayTimeStatisticsHandler(),
|
||||
new LaunchExecutionBetterGenshinImpactAutomationHandler()
|
||||
|
||||
@@ -2,9 +2,14 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.Diagnostics;
|
||||
using Snap.Hutao.Core.ExceptionService;
|
||||
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.Handler;
|
||||
using Snap.Hutao.Service.Game.Package;
|
||||
using Snap.Hutao.Service.Yae.Achievement;
|
||||
|
||||
namespace Snap.Hutao.Service.Game.Launching.Invoker;
|
||||
@@ -28,4 +33,99 @@ internal sealed class YaeLaunchExecutionInvoker : AbstractLaunchExecutionInvoker
|
||||
{
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
||||
{
|
||||
private readonly AsyncKeyedLock<string> repoLock = new();
|
||||
private readonly BackgroundActivityOptions backgroundActivityOptions;
|
||||
private readonly ILogger<GitRepositoryService> logger;
|
||||
private readonly IServiceProvider serviceProvider;
|
||||
private readonly ITaskContext taskContext;
|
||||
|
||||
@@ -76,6 +77,7 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
||||
}
|
||||
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);
|
||||
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.
|
||||
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()
|
||||
{
|
||||
Depth = 1,
|
||||
@@ -142,10 +152,18 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
||||
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.Delete(directory, true);
|
||||
}
|
||||
@@ -154,9 +172,15 @@ internal sealed partial class GitRepositoryService : IGitRepositoryService
|
||||
{
|
||||
Checkout = true,
|
||||
});
|
||||
|
||||
logger.LogInformation("[Metadata] Clone completed successfully");
|
||||
}
|
||||
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
|
||||
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.RemoveUntrackedFiles();
|
||||
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
|
||||
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 localBranch = repo.Branches["main"] ?? repo.CreateBranch("main", remoteBranch.Tip);
|
||||
repo.Branches.Update(localBranch, b => b.TrackedBranch = remoteBranch.CanonicalName);
|
||||
Commands.Checkout(repo, localBranch);
|
||||
repo.Reset(ResetMode.Hard, remoteBranch.Tip);
|
||||
repo.RemoveUntrackedFiles();
|
||||
}
|
||||
|
||||
logger.LogInformation("[Metadata] Update completed successfully");
|
||||
}
|
||||
|
||||
RepositoryAffinity.DecreaseFailure(info);
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
// Licensed under the MIT license.
|
||||
|
||||
using Snap.Hutao.Core.IO.Hashing;
|
||||
using Snap.Hutao.Core.Setting;
|
||||
using Snap.Hutao.Web.Hutao;
|
||||
using System.Collections.Immutable;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Security.Cryptography;
|
||||
using Windows.Storage;
|
||||
|
||||
namespace Snap.Hutao.Service.Git;
|
||||
|
||||
internal static class RepositoryAffinity
|
||||
{
|
||||
private static readonly ApplicationDataContainer RepositoryContainer = ApplicationData.Current.LocalSettings.CreateContainer("RepositoryAffinity", ApplicationDataCreateDisposition.Always);
|
||||
private const string RepositoryAffinityPrefix = "RepositoryAffinity::";
|
||||
private static readonly Lock SyncRoot = new();
|
||||
|
||||
public static ImmutableArray<GitRepository> Sort(ImmutableArray<GitRepository> repositories)
|
||||
@@ -23,9 +23,11 @@ internal static class RepositoryAffinity
|
||||
for (int i = 0; i < repositories.Length; i++)
|
||||
{
|
||||
GitRepository repository = repositories[i];
|
||||
ApplicationDataContainer container = RepositoryContainer.CreateContainer(repository.Name, ApplicationDataCreateDisposition.Always);
|
||||
string key = Hash.ToHexString(HashAlgorithmName.SHA256, repository.HttpsUrl.OriginalString.ToUpperInvariant());
|
||||
counts[i] = container.Values[key] is int c ? c : 0;
|
||||
string key = GetSettingKey(repository.Name, repository.HttpsUrl.OriginalString);
|
||||
|
||||
// 对读取值做下限保护,确保排序使用的是非负失败计数
|
||||
int raw = LocalSetting.Get(key, 0);
|
||||
counts[i] = Math.Max(0, raw);
|
||||
}
|
||||
|
||||
Array.Sort(counts, ImmutableCollectionsMarshal.AsArray(repositories));
|
||||
@@ -42,10 +44,14 @@ internal static class RepositoryAffinity
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
ApplicationDataContainer container = RepositoryContainer.CreateContainer(name, ApplicationDataCreateDisposition.Always);
|
||||
string key = Hash.ToHexString(HashAlgorithmName.SHA256, url.ToUpperInvariant());
|
||||
object box = container.Values[key];
|
||||
container.Values[key] = box is int count ? unchecked(count + 1) : 1;
|
||||
string key = GetSettingKey(name, url);
|
||||
int currentCount = LocalSetting.Get(key, 0);
|
||||
|
||||
// 防止整数上溢:当已到达 int.MaxValue 时不再自增
|
||||
if (currentCount < int.MaxValue)
|
||||
{
|
||||
LocalSetting.Set(key, currentCount + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,10 +64,20 @@ internal static class RepositoryAffinity
|
||||
{
|
||||
lock (SyncRoot)
|
||||
{
|
||||
ApplicationDataContainer container = RepositoryContainer.CreateContainer(name, ApplicationDataCreateDisposition.Always);
|
||||
string key = Hash.ToHexString(HashAlgorithmName.SHA256, url.ToUpperInvariant());
|
||||
object box = container.Values[key];
|
||||
container.Values[key] = box is int count ? unchecked(count - 1) : 0;
|
||||
string key = GetSettingKey(name, url);
|
||||
int currentCount = LocalSetting.Get(key, 0);
|
||||
|
||||
// 失败次数不允许小于 0,避免出现负数或整型下溢
|
||||
if (currentCount > 0)
|
||||
{
|
||||
LocalSetting.Set(key, currentCount - 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetSettingKey(string name, string url)
|
||||
{
|
||||
string urlHash = Hash.ToHexString(HashAlgorithmName.SHA256, url.ToUpperInvariant());
|
||||
return $"{RepositoryAffinityPrefix}{name}::{urlHash}";
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
{
|
||||
item.DismissCommand = dismissCommand;
|
||||
|
||||
@@ -23,6 +23,7 @@ internal sealed partial class InventoryService : IInventoryService
|
||||
private readonly PromotionDeltaFactory promotionDeltaFactory;
|
||||
private readonly IServiceScopeFactory serviceScopeFactory;
|
||||
private readonly IInventoryRepository inventoryRepository;
|
||||
private readonly ICultivationRepository cultivationRepository;
|
||||
private readonly IUserService userService;
|
||||
private readonly IMessenger messenger;
|
||||
|
||||
@@ -55,7 +56,7 @@ internal sealed partial class InventoryService : IInventoryService
|
||||
{
|
||||
case RefreshOptionKind.WebCalculator:
|
||||
ArgumentNullException.ThrowIfNull(refreshOptions.MetadataContext);
|
||||
return RefreshInventoryByCalculatorAsync(refreshOptions.MetadataContext, refreshOptions.Project);
|
||||
return RefreshInventoryByCalculatorAsync(refreshOptions);
|
||||
case RefreshOptionKind.EmbeddedYae:
|
||||
ArgumentNullException.ThrowIfNull(refreshOptions.YaeService);
|
||||
ArgumentNullException.ThrowIfNull(refreshOptions.ViewModelSupportLaunchExecution);
|
||||
@@ -71,8 +72,12 @@ internal sealed partial class InventoryService : IInventoryService
|
||||
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)
|
||||
{
|
||||
messenger.Send(InfoBarMessage.Warning(SH.MustSelectUserAndUid));
|
||||
@@ -97,9 +102,37 @@ internal sealed partial class InventoryService : IInventoryService
|
||||
}
|
||||
|
||||
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.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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user