diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..58184c2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,86 @@
+# AGENTS.md — Metadata 仓库指引
+
+面向自动化 Agent 与人类贡献者。产品决策细节见 [PLAN.md](./PLAN.md),使用方解析见 [README.md](./README.md)。
+
+## 仓库职责
+
+- **源数据**:`Metadata/**/*.xml` + 图片/Markdown/叶子 Manifest
+- **核心库/CLI**:`Ra3.BattleNet.Metadata` — 展平、变量、XSD、语义校验、查询 API(**纯 managed,无 SkiaSharp**);**Desktop NuGet 只引此包**
+- **Imaging CLI**:`Ra3.BattleNet.Metadata.Imaging` — 只转图并 stdout MD5,**不读 XML**;**不进主 NuGet**;`Build(convertImages:true)` / `build --webp` 在展平后按图调用
+- **发布**:Cloudflare Pages 静态托管 `Output/`
+
+## 常用命令
+
+```bash
+# 测试(MSTest)
+dotnet test Metadata.sln
+
+# 核心构建(默认;Desktop 本地调试同此)
+dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output
+
+# 发布构建(核心 + WebP)
+npm run build:release
+# 或
+bash build.sh --webp
+```
+
+- **不要**再引入 `RUN_STAGE_B` / `StageB` 控制面。
+- 本地默认 **不** 跑 Imaging;CF:`wrangler.toml` → `bash build.sh --webp`。
+
+## Schema
+
+| 文件 | 校验对象 | 时机 |
+|---|---|---|
+| `Metadata/MetadataSchema.xsd` | 源树全部 `.xml` | 核心构建开始时硬失败 |
+| `Metadata/MetadataPublishSchema.xsd` | 展平 `metadata.xml` | 变量替换后硬失败 |
+
+实现:`SchemaValidator` + `MetadataBuilder.Build`。
+
+改结构时:**同步改 XSD + README 属性清单 + 示例 + 测试**。
+
+源树 `Base` / `InheritFrom` 仅构建期合并(`MetadataInheritance`);**发布物禁止**带继承痕迹。`Defines` 不做对象继承。
+
+## 使用方(Desktop)解析要点
+
+1. 只消费**展平** `metadata.xml` + 相对资源,不自己展开 Include。
+2. `MetadataBuilder.Load(path|url)` → `Catalog` / `Applications` / `Mods`。
+3. Package.Manifest / Icon / Changelog / Content 等是 **限定 ID**(`路径前缀:localId`),不是路径、也不是源树短名。
+4. 登记节点 `Manifest[@Source]` 指向叶子清单 XML(含 File 表)。
+5. 资源 URL = `BaseUrl` + `Source`;本地 = `BasePath` + `Source`。
+6. 调试:本地 `Build` → 同一 `Load`;勿写死 `.png`。
+7. 同名资源靠展平前缀隔离;勿在客户端再实现 public/private Include 语义。
+
+完整步骤与属性表:README「使用方如何解析 Metadata」「模块属性清单」。
+
+## 代码布局
+
+```
+Metadata/metadata.xml 入口
+Metadata/Templates/ 脚手架(不 Include)
+Metadata/** 分模块 Include 源
+Ra3.BattleNet.Metadata/
+ MetadataBuilder.cs 核心 Build / Load
+ MetadataFlattener.cs Include 展平
+ MetadataInheritance.cs Base + InheritFrom 合并
+ SchemaValidator.cs XSD
+ VariableResolver.cs ${...}
+ Metadata.cs / MetadataParser.cs 树与加载
+ MetadataQueryExtensions.cs Catalog/Mods/...
+Ra3.BattleNet.Metadata.Imaging/ WebP
+Ra3.BattleNet.Metadata.Tests/ MSTest
+```
+
+## 修改约束
+
+- FAILFAST:校验失败即停,不留半残 `Output`。
+- 测试只许 **MSTest**,禁止 xunit。
+- 主库禁止加入 SkiaSharp / 原生图片依赖。
+- Manifest **权威在 Updater**;本仓示例 Hash 可为占位。
+- 中文注释与文档;代码标识符 ASCII。
+- git 访问外网仓库时用代理 `http://localhost:7890`(项目惯例)。
+
+## 不要做的事
+
+- 在 Desktop 仓以外擅自改 Desktop UI(联调另迭代)。
+- 把 File 表内联进发布 `metadata.xml` 的 Manifest stub。
+- 默认开启 WebP 或恢复 `StageB` 命名作为 API。
diff --git a/Metadata.sln b/Metadata.sln
index e850bad..0b32bd2 100644
--- a/Metadata.sln
+++ b/Metadata.sln
@@ -1,4 +1,4 @@
-
+
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.12.35514.174 d17.12
@@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata", "R
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.Tests", "Ra3.BattleNet.Metadata.Tests\Ra3.BattleNet.Metadata.Tests.csproj", "{F9429D39-9063-45C4-9EC5-84A34A4829D8}"
EndProject
+Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Ra3.BattleNet.Metadata.Imaging", "Ra3.BattleNet.Metadata.Imaging\Ra3.BattleNet.Metadata.Imaging.csproj", "{A9429D39-9063-45C4-9EC5-84A34A4829D9}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -21,6 +23,10 @@ Global
{F9429D39-9063-45C4-9EC5-84A34A4829D8}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F9429D39-9063-45C4-9EC5-84A34A4829D8}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F9429D39-9063-45C4-9EC5-84A34A4829D8}.Release|Any CPU.Build.0 = Release|Any CPU
+ {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {A9429D39-9063-45C4-9EC5-84A34A4829D9}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
diff --git a/Metadata/MetadataPublishSchema.xsd b/Metadata/MetadataPublishSchema.xsd
new file mode 100644
index 0000000..f664352
--- /dev/null
+++ b/Metadata/MetadataPublishSchema.xsd
@@ -0,0 +1,150 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Metadata/MetadataSchema.xsd b/Metadata/MetadataSchema.xsd
index 7681bbf..20fe762 100644
--- a/Metadata/MetadataSchema.xsd
+++ b/Metadata/MetadataSchema.xsd
@@ -1,291 +1,238 @@
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Metadata/Templates/Application.xml b/Metadata/Templates/Application.xml
new file mode 100644
index 0000000..c806523
--- /dev/null
+++ b/Metadata/Templates/Application.xml
@@ -0,0 +1,21 @@
+
+
+
+
+ 1.0.0
+
+
+ 2026-01-01
+ manifest-100
+
+
+
+
+
+
+ placeholder.bin
+ /
+ APP;
+
+
+
diff --git a/Metadata/Templates/Mod.xml b/Metadata/Templates/Mod.xml
new file mode 100644
index 0000000..c9f55f7
--- /dev/null
+++ b/Metadata/Templates/Mod.xml
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+ 1.0.0
+ mod-icon-64px
+
+
+
+ 2026-01-01
+ manifest-100
+
+
+
+
+
+
+ placeholder.bin
+ /
+ MOD;
+
+
+
diff --git a/Metadata/Templates/README.md b/Metadata/Templates/README.md
new file mode 100644
index 0000000..f3cebec
--- /dev/null
+++ b/Metadata/Templates/README.md
@@ -0,0 +1,21 @@
+# Templates 脚手架
+
+本目录**不**被 `metadata.xml` Include,仅供复制新建。
+
+## 新建 Mod
+
+1. 复制 `Mod.xml` 到 `mods//.xml`
+2. 改 `Mod/@ID`、资源路径与版本
+3. 在 `mods/mods.xml` 增加 ``
+4. 准备 `images/`、`manifests/` 等实际文件
+
+## 新建 Application
+
+1. 复制 `Application.xml` 到 `apps//.xml`
+2. 改 `Application/@ID` 与 Packages
+3. 在 `apps/apps.xml` 增加 Include
+
+## 使用 Base 继承(可选)
+
+见仓库 README「源树继承(Base / InheritFrom)」。
+公共样式可放在独立 XML 的 ``,Mod 写 `InheritFrom` 只填差异。
diff --git a/Metadata/apps/ra3battlenet/changelogs/changelogs.xml b/Metadata/apps/ra3battlenet/changelogs/changelogs.xml
index 55e0cbd..1da439d 100644
--- a/Metadata/apps/ra3battlenet/changelogs/changelogs.xml
+++ b/Metadata/apps/ra3battlenet/changelogs/changelogs.xml
@@ -1,17 +1,13 @@
-
${TIMESTAMP}
-
-
-
+
-
-
\ No newline at end of file
+
diff --git a/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md b/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md
new file mode 100644
index 0000000..9cf04ae
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/changelogs/en-1.5.1.0.md
@@ -0,0 +1,3 @@
+# Sample content for en-1.5.1.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md b/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md
new file mode 100644
index 0000000..019b50b
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/changelogs/en-1.5.2.0.md
@@ -0,0 +1,3 @@
+# Sample content for en-1.5.2.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md
new file mode 100644
index 0000000..03c7aad
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.1.0.md
@@ -0,0 +1,3 @@
+# Sample content for zh-1.5.1.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md
new file mode 100644
index 0000000..ff92130
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/changelogs/zh-1.5.2.0.md
@@ -0,0 +1,3 @@
+# Sample content for zh-1.5.2.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md b/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md
new file mode 100644
index 0000000..09715fc
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/posts/news-en-1.5.1.0.md
@@ -0,0 +1,3 @@
+# Sample content for news-en-1.5.1.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md b/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md
new file mode 100644
index 0000000..e90678d
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/posts/news-en-1.5.2.0.md
@@ -0,0 +1,3 @@
+# Sample content for news-en-1.5.2.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md
new file mode 100644
index 0000000..e07a10a
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.1.0.md
@@ -0,0 +1,3 @@
+# Sample content for news-zh-1.5.1.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md
new file mode 100644
index 0000000..e8c80c6
--- /dev/null
+++ b/Metadata/apps/ra3battlenet/posts/news-zh-1.5.2.0.md
@@ -0,0 +1,3 @@
+# Sample content for news-zh-1.5.2.0.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/apps/ra3battlenet/posts/posts.xml b/Metadata/apps/ra3battlenet/posts/posts.xml
index 531a49c..becc84d 100644
--- a/Metadata/apps/ra3battlenet/posts/posts.xml
+++ b/Metadata/apps/ra3battlenet/posts/posts.xml
@@ -1,15 +1,13 @@
-
${TIMESTAMP}
-
-
-
+
+
-
-
\ No newline at end of file
+
+
diff --git a/Metadata/apps/ra3battlenet/ra3battlenet.xml b/Metadata/apps/ra3battlenet/ra3battlenet.xml
index b75dd0e..d6e8154 100644
--- a/Metadata/apps/ra3battlenet/ra3battlenet.xml
+++ b/Metadata/apps/ra3battlenet/ra3battlenet.xml
@@ -1,21 +1,16 @@
-
${TIMESTAMP}
-
-
-
1.5.2.0
-
2025-03-29
@@ -34,7 +29,6 @@
manifest-1.5.1.0
-
@@ -48,5 +42,4 @@
-
-
\ No newline at end of file
+
diff --git a/Metadata/mods/corona/changelogs/changelogs.xml b/Metadata/mods/corona/changelogs/changelogs.xml
index 55e0cbd..4cf9cd7 100644
--- a/Metadata/mods/corona/changelogs/changelogs.xml
+++ b/Metadata/mods/corona/changelogs/changelogs.xml
@@ -1,17 +1,13 @@
-
${TIMESTAMP}
-
-
-
-
-
-
-
-
\ No newline at end of file
+
+
+
+
+
diff --git a/Metadata/mods/corona/changelogs/en-3228.md b/Metadata/mods/corona/changelogs/en-3228.md
new file mode 100644
index 0000000..fcefc1f
--- /dev/null
+++ b/Metadata/mods/corona/changelogs/en-3228.md
@@ -0,0 +1,3 @@
+# Sample content for en-3228.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/changelogs/en-3229.md b/Metadata/mods/corona/changelogs/en-3229.md
new file mode 100644
index 0000000..5c3d8f7
--- /dev/null
+++ b/Metadata/mods/corona/changelogs/en-3229.md
@@ -0,0 +1,3 @@
+# Sample content for en-3229.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/changelogs/zh-3228.md b/Metadata/mods/corona/changelogs/zh-3228.md
new file mode 100644
index 0000000..a5e3e02
--- /dev/null
+++ b/Metadata/mods/corona/changelogs/zh-3228.md
@@ -0,0 +1,3 @@
+# Sample content for zh-3228.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/changelogs/zh-3229.md b/Metadata/mods/corona/changelogs/zh-3229.md
new file mode 100644
index 0000000..7e196c5
--- /dev/null
+++ b/Metadata/mods/corona/changelogs/zh-3229.md
@@ -0,0 +1,3 @@
+# Sample content for zh-3229.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/corona.xml b/Metadata/mods/corona/corona.xml
index 139b1cf..460ab14 100644
--- a/Metadata/mods/corona/corona.xml
+++ b/Metadata/mods/corona/corona.xml
@@ -1,35 +1,28 @@
-
${TIMESTAMP}
-
-
-
-
-
+
+
+
+
+
-
-
-
3.229
corona-icon-64px
-
-
-
-
2025-04-01
@@ -68,8 +58,6 @@
manifest-3228
-
-
@@ -83,5 +71,4 @@
-
-
\ No newline at end of file
+
diff --git a/Metadata/mods/corona/images/background-1.png b/Metadata/mods/corona/images/background-1.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/background-1.png differ
diff --git a/Metadata/mods/corona/images/background-2.png b/Metadata/mods/corona/images/background-2.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/background-2.png differ
diff --git a/Metadata/mods/corona/images/background-3.png b/Metadata/mods/corona/images/background-3.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/background-3.png differ
diff --git a/Metadata/mods/corona/images/background-4.png b/Metadata/mods/corona/images/background-4.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/background-4.png differ
diff --git a/Metadata/mods/corona/images/background-5.png b/Metadata/mods/corona/images/background-5.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/background-5.png differ
diff --git a/Metadata/mods/corona/images/icon-64px.png b/Metadata/mods/corona/images/icon-64px.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/icon-64px.png differ
diff --git a/Metadata/mods/corona/images/logo-colored.png b/Metadata/mods/corona/images/logo-colored.png
new file mode 100644
index 0000000..f37764b
Binary files /dev/null and b/Metadata/mods/corona/images/logo-colored.png differ
diff --git a/Metadata/mods/corona/manifests/3.228.xml b/Metadata/mods/corona/manifests/3.228.xml
index 5e6d6c5..2899841 100644
--- a/Metadata/mods/corona/manifests/3.228.xml
+++ b/Metadata/mods/corona/manifests/3.228.xml
@@ -1,29 +1,15 @@
-
${TIMESTAMP}
-
-
-
+
corona_3.228.lyi
/
MOD; ENCRYPTED_FILE; CAN_PATCH;
-
-
-
-
-
- coronaBGM_3.228.lyi
- /
- MOD; ENCRYPTED_FILE; CAN_PATCH;
-
-
-
-
\ No newline at end of file
+
diff --git a/Metadata/mods/corona/manifests/3.229.xml b/Metadata/mods/corona/manifests/3.229.xml
new file mode 100644
index 0000000..1f27005
--- /dev/null
+++ b/Metadata/mods/corona/manifests/3.229.xml
@@ -0,0 +1,15 @@
+
+
+
+ ${TIMESTAMP}
+
+
+
+
+
+ corona_3.229.lyi
+ /
+ MOD; ENCRYPTED_FILE; CAN_PATCH;
+
+
+
diff --git a/Metadata/mods/corona/manifests/manifests.xml b/Metadata/mods/corona/manifests/manifests.xml
index 803964b..7c8d545 100644
--- a/Metadata/mods/corona/manifests/manifests.xml
+++ b/Metadata/mods/corona/manifests/manifests.xml
@@ -1,12 +1,10 @@
-
${TIMESTAMP}
-
-
+
-
\ No newline at end of file
+
diff --git a/Metadata/mods/corona/posts/news-en-3229.md b/Metadata/mods/corona/posts/news-en-3229.md
new file mode 100644
index 0000000..89fa1fb
--- /dev/null
+++ b/Metadata/mods/corona/posts/news-en-3229.md
@@ -0,0 +1,3 @@
+# Sample content for news-en-3229.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/posts/news-zh-3229.md b/Metadata/mods/corona/posts/news-zh-3229.md
new file mode 100644
index 0000000..63b2ba8
--- /dev/null
+++ b/Metadata/mods/corona/posts/news-zh-3229.md
@@ -0,0 +1,3 @@
+# Sample content for news-zh-3229.md
+
+Placeholder changelog/news body for Stage A samples.
diff --git a/Metadata/mods/corona/posts/posts.xml b/Metadata/mods/corona/posts/posts.xml
index a34637f..42a3746 100644
--- a/Metadata/mods/corona/posts/posts.xml
+++ b/Metadata/mods/corona/posts/posts.xml
@@ -1,14 +1,11 @@
-
${TIMESTAMP}
-
-
-
-
-
\ No newline at end of file
+
+
+
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..cea8710
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,279 @@
+# Metadata 改进与完善计划
+
+> 状态:**本期代码已落地**(`dev` 分支)
+> 范围:本期仅 **Metadata 仓**;Desktop 联调另迭代
+> 优先级:**2 客户端契约 → 5 库产品化 → 1 示例内容**
+
+---
+
+## 0. 已锁定决策
+
+| 项 | 结论 |
+|---|---|
+| 优先级 | **2 客户端契约 → 5 库产品化 → 1 示例内容** |
+| 主消费端 | Desktop(本期不改 Desktop 仓) |
+| 发布数据 | **XML 展平为单一 `metadata.xml`** |
+| 发布资源 | Manifest / 图片 / Markdown **独立文件**,XML 内 **ID 引用** |
+| public/private | 仅构建期可见性;线上无 `Include` |
+| 用户默认 | Cloudflare Pages 展平产物(可配置默认 URL) |
+| 开发者(下期 Desktop) | 调试页指本地仓 → 核心构建 → 读缓存 |
+| 库形态 | **单一纯 managed 库**:解析 + 编译;支持 **path / URL** |
+| 图片 | 库不做 WebP;**仅发布路径 Imaging**(`build.sh --webp`) |
+| 校验 | **硬失败**,不产出半残发布物 |
+| Manifest | **Updater 生成**,本仓只引用;Updater 改造 **记入路线图** |
+| MVP 数据 | App `RA3BattleNet` + Mod `Corona` **示例**;其余自行补充 |
+| 本期边界 | **仅 Metadata 仓** + Desktop 对接说明 |
+| 测试框架 | **MSTest**;移除 xunit / xunit.v3 / xunit.runner 等 |
+
+---
+
+## 1. 目标架构
+
+```
+源仓 Metadata/ 核心 build (库/CLI, 纯 managed) Imaging (仅发布 --webp)
+ xml 树 + 资源 → 校验 → 变量 → XML 展平 → Output/ → WebP + 改写引用 → 部署 Pages
+ ↓
+ metadata.xml (数据, 含 SchemaVersion/ContentRevision)
+ + manifests/ images/ *.md ... (资源, ID 可解析)
+```
+
+### 1.1 发布契约(Desktop 将依赖)
+
+- `GET {BaseUrl}/metadata.xml` — 展平后的业务数据树
+- `{BaseUrl}/{相对路径}` — 资源文件
+- 根节点字段:`SchemaVersion`、`ContentRevision`
+- 资源通过 ID 引用(如 `manifest-1.5.2.0`、`corona-icon-64px`),**不写死域名**
+- 展平树内存在对应 `` / `` 等登记节点,携带 `Source` / `Url` 等定位信息
+
+### 1.2 数据 vs 资源
+
+| 类型 | 处理 |
+|---|---|
+| **数据(XML)** | 构建期展平为**单一** `metadata.xml`(去掉 Include,合并业务树) |
+| **资源** | Manifest / 图片 / Markdown 等**不进入**展平 XML 正文;独立存放,XML 只保留 ID/路径引用 |
+
+### 1.3 本地调试 vs 线上(下期 Desktop,本期写清对接点)
+
+- **最终用户**:默认读 Cloudflare Pages 展平产物(默认 BaseUrl)
+- **开发者**:Desktop 调试页可选择指向**本地 Metadata 仓库** → 调用 Stage A 编译到缓存目录 → 与线上同形解析
+- 默认:**手动「重新编译」**;可选目录监视自动编译
+- 生产与调试共用同一套「展平 XML + 资源」解析逻辑,只换内容来源
+
+---
+
+## 2. 分期落地
+
+### 阶段 A — 客户端契约与构建语义(优先级 2)
+
+#### A1. 发布契约文档
+
+- 默认 URL 占位、路径规则、ID 引用、版本字段
+- 核心 build vs Imaging 产物差异(业务树同形,图片扩展名可能不同)
+- 硬失败错误约定
+
+#### A2. XML 展平(核心)
+
+- 入口 `metadata.xml` 递归处理 Include
+- public/private 按规则合并进树,输出**去掉 Include**
+- **不内联** Manifest 文件表、图片字节、Markdown 文件正文
+- 产出单一数据文件 + 资源目录
+
+#### A3. 变量与校验硬失败
+
+- 支持:`${TIMESTAMP}` / `${ENV:}` / `${MD5:}` / `${META:}` / `${this:}`
+- 以下情况 **非 0 退出**,禁止发布:
+ - XSD 失败
+ - 循环 Include
+ - 缺失资源文件
+ - 断开的 Image / Markdown / Manifest ID
+ - 未解析完的 `${...}` 残留
+- 取消「Markdown 只警告仍成功」等软失败行为
+
+#### A4. 输出布局
+
+```
+Output/
+ metadata.xml # 仅展平后的数据
+ apps/... # 资源(md / 图 / manifest xml 等)
+ mods/...
+```
+
+- 推荐最终只复制**被引用**资源以减小发布面
+- 示例阶段可先全量复制,再收紧
+
+#### A5. 契约自测
+
+- Include 展平、ID 可解析、硬失败用例
+- CLI 对示例源 `build` → 断言 Output 形状
+- 测试框架见 **§5**
+
+### 阶段 B — 库产品化(优先级 5)
+
+#### B1. 包边界
+
+- `Ra3.BattleNet.Metadata`:类库(解析 + Stage A 构建)
+- 可执行入口:同解决方案 CLI(`dotnet run` / 日后 `dotnet tool`)
+- **主库无 SkiaSharp 等原生硬依赖**(WebP 仅 Imaging 项目)
+
+#### B2. 公共 API(最小面)
+
+```text
+MetadataDocument.Load(path | url) // 解析已展平 XML
+MetadataBuilder.Build(sourcePath | sourceUrl, outputDir) // Stage A
+// 查询:Applications / Mods / 按 ID 取 Image|Manifest|Markdown 描述
+// BasePath / BaseUrl 用于拼资源位置
+```
+
+- `Load(url)`:HTTP(S) 拉取展平后的 `metadata.xml`
+- `Build(path)`:本地源仓
+- `Build(url)`:远程源(zip 或静态源树)→ 缓存 → 同本地 Build
+ - 若首期仅实现 path,URL Build 预留 API,列入紧随迭代
+
+#### B3. 版本字段
+
+- `SchemaVersion`:契约版本(人工维护)
+- `ContentRevision`:构建注入(UTC 时间或 git commit)
+
+#### B4. 验收
+
+- 无 SkiaSharp 引用下 `dotnet test` + `dotnet pack` 通过
+- 示例:`Build(本地)` → `Load(Output/metadata.xml)` → 列出 App/Mod/资源 ID
+
+### 阶段 C — 发布管线
+
+#### C1. `build.sh`:默认核心,发布 opt-in WebP
+
+1. 安装/使用 dotnet
+2. **默认**:核心 build(纯 managed:校验、变量、XML 展平)
+3. **`--webp` / `--publish`**(CF `wrangler` 使用):Imaging WebP + 改写 Image Source
+4. 任一步失败即 fail;**无** `RUN_STAGE_B` 环境变量
+
+#### C2. Cloudflare Pages
+
+- 部署 `Output/`
+- 根路径 → `metadata.xml`(`_redirects` 或 Pages 配置)
+- Worker `src/index.js`:删除无效假逻辑或明确仅资产透传
+
+#### C3. 文档
+
+- `npm run build` / `deploy` / 默认 URL 配置
+- **Desktop 对接说明**(下期):默认 BaseUrl;调试指本地仓;调 Stage A;Load 缓存
+
+### 阶段 D — 示例数据(优先级 1,代码稳定后)
+
+#### D1. 最小合法示例
+
+- `Application ID=RA3BattleNet`:Version、Packages、Posts;Changelog/Manifest **ID 齐全**
+- `Mod ID=Corona`:CurrentVersion、Icon、Packages、必要 Style/资源 ID
+- 各 ID 对应真实存在的资源文件(占位 md / 小图 / 示意 manifest xml 即可)
+
+#### D2. 自行补充(非本计划实现范围)
+
+- 更多版本、Mod、文案、真实 Hash/清单
+- 正式 Manifest **以 Updater 生成为准**
+
+#### D3. 不进本期
+
+- 地图下载、50+ Mod 填满
+- Updater Manifest 格式改造(仅路线图)
+- Desktop 调试页实现
+
+---
+
+## 3. 建议实施顺序(代码优先)
+
+1. **测试栈切换为 MSTest**(§5),保证后续改动在统一测试框架上落地
+2. **契约文档**(README 一节或本文件 §1 固化到用户文档)
+3. **拆构建管线**:Stage A API;SkiaSharp 仅 Stage B
+4. **实现 XML 展平 + 硬失败校验 + 版本字段**
+5. **公共 Load/Build API + MSTest 测试**
+6. **修 `build.sh` / wrangler / Worker**
+7. **示例数据打到 Stage A 全绿**
+8. **Desktop 对接说明写入 README**
+
+---
+
+## 4. 路线图(本期不做)
+
+| 项 | 说明 |
+|---|---|
+| Desktop 调试页 | 选本地仓、触发 Build、切换默认 CF URL |
+| Updater ↔ Manifest 生成/格式 | 生成器权威、禁止手改 Hash 等(**先保留在计划中**) |
+| `Build(url)` 完整远程源 | 若首期只 path,下期补 |
+| 仅复制被引用资源 | 发布瘦身 |
+| Gitea + Preview URL | 本地 Stage A 已覆盖开发;远端预览可选 |
+| 地图元数据、多 Mod 生产内容 | 内容扩展 |
+
+---
+
+## 5. 测试库:MSTest(强制)
+
+### 5.1 决策
+
+- **唯一测试框架:MSTest**
+- **移除**:`xunit`、`xunit.v3`、`xunit.runner.visualstudio` 及全局 `Using Include="Xunit"`
+- **保留/采用**:
+ - `MSTest` 元包或 `MSTest.TestFramework` + `MSTest.TestAdapter`
+ - `Microsoft.NET.Test.Sdk`
+ - 断言:优先 MSTest 自带 `Assert`;若需更流畅断言可继续评估 FluentAssertions(**非 xunit 依赖**)
+ - 覆盖率:`coverlet.collector` 可保留
+
+### 5.2 迁移动作
+
+1. 修改 `Ra3.BattleNet.Metadata.Tests.csproj`:去掉全部 xunit 包,改为 MSTest 包引用
+2. 测试类:`[Fact]` → `[TestMethod]`,类上加 `[TestClass]`;构造/初始化改为 MSTest 生命周期(`[TestInitialize]` 等)
+3. 删除 `Xunit` 全局 using;按需 `using Microsoft.VisualStudio.TestTools.UnitTesting;`
+4. 全量 `dotnet test` 通过后再合入功能改动
+
+### 5.3 约定
+
+- 新建测试一律 MSTest,禁止再引入 xunit 系包
+- 契约/硬失败/展平等核心路径必须有 MSTest 覆盖
+
+---
+
+## 6. 验收标准(本期完成时)
+
+- [x] 测试项目为 **MSTest**,无 xunit 系依赖;`dotnet test` 全绿
+- [x] 硬失败用例覆盖(schema / 缺文件 / 坏引用 / 变量残留 / 循环引用)
+- [x] 类库无 SkiaSharp 等原生硬依赖
+- [x] `metadata build --src Metadata --dst Output` 得到展平 `metadata.xml` + 资源
+- [x] 展平 XML 无 Include;含 SchemaVersion/ContentRevision;资源 ID 可解析(Manifest Source 指向叶子清单文件)
+- [x] CF 脚本含 Imaging WebP(`build.sh --webp`);本地默认仅核心 build
+- [x] 示例:RA3BattleNet + Corona 可通过完整核心 build
+- [x] README:契约、默认 URL、Desktop 下期对接、Manifest 归属 Updater、测试用 MSTest
+
+### 已实现说明(相对原文的偏差)
+
+- Imaging 独立项目 `Ra3.BattleNet.Metadata.Imaging`(SkiaSharp);默认不转 WebP,发布显式 `--webp` / `npm run build:release`
+- `Load(url)` 已支持;`Build(url)` 远程源构建延后(见 §4)
+- 用户可见面不再使用 StageA/StageB、`RUN_STAGE_B`
+
+---
+
+## 7. 风险与约定
+
+- **本地 vs 线上图片扩展名**:客户端按 ID 读节点上的 `Source`,不写死 `.png`
+- **展平体积**:只展平 XML **数据**;Manifest 等资源再大也独立文件
+- **与「单文件」表述的关系**:展平的是数据 XML,不是把所有资源塞进一个文件
+- **单库 + 纯 managed**:发布期 WebP 不得把原生依赖带回 Desktop 引用图
+- **Updater**:Manifest 权威在 Updater;其格式/生成改造另项推进,本期 Metadata 只预留 ID 引用与校验
+
+---
+
+## 8. 决策记录(grill-me 摘要)
+
+1. 优先级:契约 → 库 → 示例内容
+2. 第一消费端:Desktop
+3. 数据 XML 展平;资源外置 + ID 引用
+4. public/private 仅构建期
+5. 用户默认 CF URL;开发者本地仓 + 编译(Desktop 下期)
+6. 单库纯 managed;Parse/Build 支持 path 与 URL
+7. 本地:编译到缓存再读(与线上同形)
+8. URL 契约:单入口 + 相对资源 + SchemaVersion/ContentRevision
+9. WebP 仅发布 Imaging(opt-in `--webp`);核心 build 与 Imaging 分离
+10. 校验硬失败
+11. Manifest 由 Updater 生成;Updater 改动进路线图
+12. MVP 示例:RA3BattleNet + Corona;代码优先,数据示例后自行补
+13. 本期边界:Metadata 库 + CLI + 示例 + 发布管线 + 对接文档
+14. **测试框架:MSTest,去掉 xunit**
diff --git a/README.md b/README.md
index 5cd0063..f3d481f 100644
--- a/README.md
+++ b/README.md
@@ -1,61 +1,324 @@
-# 红警3战网元数据文件
+# 红警3战网元数据
-此仓库存储的是战网客户端需要的客户端、数据包、模组、地图的描述信息。
+本仓库存储战网客户端所需的**应用 / Mod 版本、清单引用、新闻与资源索引**,经核心构建后由 Cloudflare Pages 发布。
-【其他说明待编写】
+- 产品决策:[PLAN.md](./PLAN.md)
+- 贡献者 / Agent 指引:[AGENTS.md](./AGENTS.md)
-## 模块说明
+---
-``` xml
-
-
-
-
-
+## 使用方如何解析 Metadata(Desktop 视角)
-
-
-
-
-
-
+发布物是**静态文件树**,不是 RPC。客户端只依赖:
+
+1. 展平后的 **`metadata.xml`**(业务数据)
+2. 资源文件(图片 / Markdown / 叶子 Manifest XML)
+3. 可选:本库 `Ra3.BattleNet.Metadata`(C# 解析与查询)
+
+### 1. 入口与版本
+
+| 项 | 说明 |
+|---|---|
+| 默认 BaseUrl | `https://metadata.ra3battle.net`(占位,部署后替换) |
+| 数据入口 | `{BaseUrl}/metadata.xml` |
+| 根属性 | `SchemaVersion`(契约版本)、`ContentRevision`(内容修订) |
+| 资源 | `{BaseUrl}/{相对路径}`,路径来自登记节点的 `Source` |
+
+发布物 **无 `Include`**。源仓多文件仅供作者维护,使用方只读展平结果。
+
+### 2. Desktop 推荐流程
+
+```
+┌─────────────────────────────────────────────────────────────┐
+│ 生产路径 │
+│ BaseUrl = CDN │
+│ Load(BaseUrl + "/metadata.xml") │
+│ → Catalog / Applications / Mods │
+│ → 按 ID 找 Image|Markdown|Manifest 登记节点 │
+│ → 拼 BaseUrl + Source 下载资源 │
+└─────────────────────────────────────────────────────────────┘
+
+┌─────────────────────────────────────────────────────────────┐
+│ 开发调试路径 │
+│ 指定本地 Metadata 仓库 → MetadataBuilder.Build(src, cache) │
+│ → Load(cache/metadata.xml) (同一套解析) │
+│ 本地一般不跑 Imaging;线上发布才 --webp │
+└─────────────────────────────────────────────────────────────┘
```
-大部分XML文件都包含Includes字段,本质上Metadata文件可以合并为一个大的XML。但是为了方便编辑和维护进行拆分
-Type分为 public 和 private,public指的是可以被展平(暴露给上级引用者,别的文件可以访问),private代表私有引用仅在此文件中可用,别的文件无法访问
+**步骤(生产):**
+
+1. 配置 `BaseUrl`(用户默认 CDN;调试页可改本地/预览)。
+2. `GET {BaseUrl}/metadata.xml`(或 `MetadataBuilder.Load(url|path)`)。
+3. 读取 `SchemaVersion`:不兼容则提示升级客户端。
+4. 读取 `ContentRevision`:与本地缓存比较,决定是否整树刷新。
+5. `doc.Catalog().Application("RA3BattleNet")` / `doc.Mods()` 取业务实体。
+6. 对 Package 的 `Manifest` 文本、Icon、Changelog、Post Content 等:**先当 ID**(发布物中为**已限定**的全局 ID),在展平树中找同 ID 的登记节点。
+7. 用登记节点的 `Source` 或 `Url` 取资源:
+ - 本地文件:`Path.Combine(basePath, Source)`
+ - 远端:`new Uri(new Uri(BaseUrl.TrimEnd('/') + "/"), Source)`
+8. 叶子 Manifest XML(含 `` 表)用 `Source` 再拉一次并解析,**不要**假设 File 表在 `metadata.xml` 内。
+9. 图片扩展名以节点 `Source` 为准(发布后可能是 `.webp`)。
+
+**步骤(开发):**
-### 元数据需求
+1. 调试页选择本地仓路径。
+2. `MetadataBuilder.Build(repo/Metadata, cacheDir)`(核心构建,硬失败则展示错误)。
+3. 后续与生产相同:`Load(cacheDir/metadata.xml)`,`basePath = cacheDir`。
+
+### 3. 库 API 示例
+
+```csharp
+// 生产:URL
+var doc = MetadataBuilder.Load("https://metadata.ra3battle.net/metadata.xml");
+
+// 开发:本地展平文件
+var doc = MetadataBuilder.Load(@"D:\cache\metadata.xml");
+
+var app = doc.Catalog().Application("RA3BattleNet");
+var corona = doc.Mods().Single(m => m.Id == "Corona");
+
+// 解析 Manifest ID → 资源路径
+var package = app!.Packages[0];
+var reg = doc.GetAllElements("Manifest")
+ .First(m => m.Get("ID") == package.ManifestId);
+var relative = reg.Get("Source"); // e.g. apps/ra3battlenet/manifests/1.5.2.0.xml
+// 再用 BaseUrl/BasePath + relative 取文件
+```
+
+核心构建(作者/CI/Desktop 调试编译):
+
+```csharp
+MetadataBuilder.Build(sourceDir, outputDir, schemaVersion: "1.0", contentRevision: gitSha);
+```
-Metadata 需要存储的数据有:
+主库 **无 SkiaSharp**。WebP 仅 `Ra3.BattleNet.Metadata.Imaging` / `build.sh --webp`。
-1. 客户端本身的版本信息和元数据信息,包括:客户端版本号,数据包最新版本号,客户端更新列表,数据包更新列表,多语言翻译文件等
-2. Mod下载功能(预计50+Mod)需要包含:Mod列表,每个Mod都需要包含ModID,Mod版本号,Mod介绍(图文混合,可能是Markdown),Mod更新列表(更新列表要支持多个文件,并且要支持对不同文件定义哈希值和下载方式),新闻列表,新闻信息(图文混合)
-3. 地图下载功能:预留
+### 4. 校验与失败
-于是分为:Application, Markdown, Image,
+核心构建 **硬失败**(非 0、清理半残输出):
+- 源树 XSD(`MetadataSchema.xsd`)
+- 发布物 XSD(`MetadataPublishSchema.xsd`)
+- 循环 Include、缺资源、断 ID、残留 `${...}`
-存储信息的主要格式为XML,因为数据会引用包含图片素材和HTML/MarkDown素材,所以可能会有文件和XML放在一起。以ID的方法引用,每个松散数据都需要验证哈希(在引用时同时声明MD5,但此MD5需要进行计算)
+---
-于是,XML数据需要支持变量,比如 ${MD5:} 是对指定ID的松散文件进行哈希值计算,并在Build时替换
-${MD5::}指的是对当前文件进行哈希计算
-需要编写XSD文件,方便对XML进行验证
+## 模块属性清单
-### XML的 ${} 内联变量规定:
-ENV: 系统环境变量(CF Pages)
+以下字段来自**真实示例 + 展平产物**,供 Desktop 建模对照。
-MD5: 对文件进行md5 ,${MD5::} 指的是计算当前标签文件路径的md5
+### 根 `Metadata`(发布物)
-或者 ${MD5:xxxx.txt} 代表对指定文件进行哈希
+| 成员 | 位置 | 说明 |
+|---|---|---|
+| `SchemaVersion` | 属性 | 契约版本,如 `1.0` |
+| `ContentRevision` | 属性 | 构建注入的修订号 |
+| `Tags/Commit` | 子元素 | 构建时间戳等 |
+| 子节点 | 子元素 | `Application` / `Mod` / 登记用 `Image`·`Markdown`·`Manifest` |
-META: 绝对路径引用,冒号是此处规定的成员运算符
+源树另可有 `Includes/Include`(`Source`、`Type=public|private`)、`Base`、`InheritFrom`,**发布物中不存在**。
-其他:相对路径引用(当前module或者depot下)
+### 源树继承(Base / InheritFrom)
-**举例:**
+构建期合并,Desktop **不必**实现继承。
-${META:CoronaLauncher:LauncherVersion}代表
+| 节点 | 说明 |
+|---|---|
+| `` | 源树专用骨架;合并后删除 |
+| `Mod`/`Application` 的 `@InheritFrom` | 指向 Base 的 ID;合并后删除 |
+| `Defines` | **不是**继承,仅服务 `${this:}` 等变量 |
+
+**合并规则(子优先):**
+
+| 区域 | 规则 |
+|---|---|
+| 属性 | 子覆盖;实体 `ID` 只用子节点的;不从 Base 继承 ID |
+| 标量(`Version`/`CurrentVersion`/`Icon` 等) | 子有则整元素替换 |
+| `Style/Logo`、`Style/Background` | 子有则整段替换 |
+| `Style/Controls` | 按控件名合并;控件内按字段名覆盖 |
+| `Packages`/`Posts`/`Defines` | 子有则**整段替换**(不做列表 Append) |
+
+脚手架:`Metadata/Templates/`(复制用,默认不 Include)。
+
+硬失败:找不到 Base、Kind 不符、Base ID 与实体 ID 冲突、Base ID 重复。
+
+### `Application`(应用,如战网客户端)
+
+| 成员 | 说明 |
+|---|---|
+| `@ID` | 应用 ID,如 `RA3BattleNet` |
+| `@InheritFrom` | 源树可选,指向 `Base`(发布物无) |
+| `Version` | 当前版本号 |
+| `Packages/Package` | 历史版本包列表 |
+| `Posts/Post` | 新闻 |
+
+### `Mod`
+
+| 成员 | 说明 |
+|---|---|
+| `@ID` | Mod ID,如 `Corona` |
+| `@InheritFrom` | 源树可选,指向 `Base`(发布物无) |
+| `CurrentVersion` | 当前版本 |
+| `Icon` | **ID 引用** → `Image` 登记节点 |
+| `Style` | UI 样式(见下) |
+| `Packages` / `Posts` | 同 Application |
+
+### `Package`
+
+| 成员 | 说明 |
+|---|---|
+| `@Version` | 包版本 |
+| `ReleaseDate` | 发布日期字符串 |
+| `Changelogs/Changelog` | `@Language` + 文本为 Markdown **ID** |
+| `Manifest` | 文本为 Manifest **ID**(不是路径) |
+
+### `Post`
+
+| 成员 | 说明 |
+|---|---|
+| `@DateTime` | 时间 |
+| `Titles/Title` | `@Language` + 标题文本 |
+| `Contents/Content` | `@Language` + 文本为 Markdown **ID** |
+
+### `Style`(Mod)
+
+| 成员 | 说明 |
+|---|---|
+| `Logo` | `@Width` `@Height`,文本为 Image **ID** |
+| `Controls/*` | 控件样式(如 `LaunchButton/BorderBrush`、`Label/FontSize`) |
+| `Background` | `@Random`;子 `Image` 文本为 Image **ID**(不是登记节点) |
+
+### 资源 ID 限定(public/private 与局部同名)
+
+源树里可写短 ID(如 `shared-icon`),不同 Mod 可重名。展平时按**定义文件**生成全局唯一 ID:
+
+```text
+{路径前缀}:{localId}
+```
-${ENV:WINVER} 代表获取系统的winver变量值
+- **路径前缀**:定义该登记节点的 XML 相对 `Metadata/` 根、去掉 `.xml` 的路径,`/` 分隔。
+ 例:`mods/corona/corona.xml` → 前缀 `mods/corona/corona`
+ → `mods/corona/corona:corona-icon-64px`
+- **引用**(Icon / Logo / Background 内 Image 文本 / Changelog / Content / Package.Manifest)在同一作用域(本文件 + 其 Include 展开结果)内按短名解析,并改写为限定 ID。
+- `Include Type=public|private` 仍控制源树合并范围;**发布物不再保留 Type**,靠限定 ID 隔离同名资源。
+- Application / Mod 的 `@ID`(业务实体 ID)**不**加路径前缀,须全局唯一。
-${this:Version} (client.xml中可以找到相同例子),代表同一个Module,也就是Client下的Defines的Version变量值,即1.5.0.0
+Desktop:始终用发布物中的**完整限定 ID** 查表;不要假设短名在全树唯一。
+
+### 登记节点 `Image`
+
+| 成员 | 说明 |
+|---|---|
+| `@ID` | 限定全局 ID:`{路径前缀}:{localId}` |
+| `@Source` | 相对路径(本地资源) |
+| `@Url` | 外链(可与 Source 二选一) |
+
+### 登记节点 `Markdown`
+
+| 成员 | 说明 |
+|---|---|
+| `@ID` | 限定全局 ID |
+| `@Source` | 相对路径(`.md`) |
+| `@Hash` | 资源 MD5(构建时替换) |
+
+### 登记节点 `Manifest`(**发布物 stub**)
+
+| 成员 | 说明 |
+|---|---|
+| `@ID` | 限定全局 ID |
+| `@Source` | 指向叶子清单 XML 的相对路径 |
+
+### 叶子 Manifest 资源文件(独立 XML,Updater 生成)
+
+根仍为 `Metadata`,内含完整:
+
+| 成员 | 说明 |
+|---|---|
+| `Manifest/@ID` | 与 stub 一致 |
+| `File/@Hash` | 文件哈希 |
+| `File/FileName` | 文件名 |
+| `File/RelativePath` | 相对路径 |
+| `File/KindOf` | 类型标记 |
+| `File/PatchInfo/Patch` | `@From` `@Method` `@Encryption` |
+| `SubManifest` | 子清单引用 |
+
+---
+
+## 核心构建 vs Imaging
+
+| 路径 | 做什么 | 何时用 |
+|---|---|---|
+| **核心构建** | XSD + 展平 + 变量 + 资源复制 | 本地、测试、Desktop 调试;**NuGet 主包** |
+| **Imaging CLI** | 只转单张图并 **stdout 输出 MD5**(不读 XML) | 仓库编译工具,**不进主 NuGet** |
+
+```bash
+# 仅展平(Desktop 调试:MetadataBuilder.Build)
+npm run build
+
+# 展平阶段按图调 Imaging,再由主程序写回 Source/Hash
+dotnet run --project Ra3.BattleNet.Metadata -- build --webp --src=./Metadata --dst=./Output
+npm run build:release
+bash build.sh --webp
+```
+
+流程:`展平 → 变量 →(--webp)对每张图 Imaging convert → 主程序改 XML → 发布 XSD/语义校验`。
+
+- 主 NuGet:`Ra3.BattleNet.Metadata`(解析 + Build,无 SkiaSharp)
+- Imaging:`convert --input=a.png --output=a.webp` → stdout 一行 hash
+- `wrangler.toml`:`bash build.sh --webp`
+
+---
+
+## Schema 文件
+
+| 文件 | 用途 |
+|---|---|
+| `Metadata/MetadataSchema.xsd` | **源树**全部 `.xml` |
+| `Metadata/MetadataPublishSchema.xsd` | **展平** `metadata.xml` |
+
+构建顺序:源 XSD → 展平 → 变量 → 发布 XSD → 语义硬校验。
+
+---
+
+## 变量(源树 / 构建时)
+
+| 语法 | 含义 |
+|---|---|
+| `${TIMESTAMP}` | UTC 时间 |
+| `${ENV:NAME}` | 环境变量 |
+| `${MD5:}` / `${MD5:rel}` | 文件 MD5;Markdown `Hash="${MD5::}"` 按 `Source` |
+| `${META:...}` / `${this:...}` | 树内引用 |
+
+---
+
+## 本地命令
+
+```bash
+dotnet test Metadata.sln
+dotnet run --project Ra3.BattleNet.Metadata -- build --src=./Metadata --dst=./Output
+npm run preview
+npm run deploy
+```
+
+## 示例数据
+
+- Application:`RA3BattleNet`
+- Mod:`Corona`
+正式 Manifest Hash 以 **Updater** 生成为准。
+
+## 测试
+
+- **MSTest** only(禁止 xunit)
+- 覆盖:展平、XSD 硬失败、ID/资源、消费端 Catalog 解析
+
+## 目录
+
+```
+Metadata/ 源数据 + XSD
+Ra3.BattleNet.Metadata/ 纯 managed 库 + 核心 CLI
+Ra3.BattleNet.Metadata.Imaging/ 发布 WebP
+Ra3.BattleNet.Metadata.Tests/ MSTest
+AGENTS.md Agent/贡献者
+PLAN.md 计划与决策
+```
diff --git a/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs b/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs
new file mode 100644
index 0000000..a98c722
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Imaging/ImageConverter.cs
@@ -0,0 +1,56 @@
+using System.Security.Cryptography;
+using SkiaSharp;
+
+namespace Ra3.BattleNet.Metadata.Imaging;
+
+///
+/// 纯图片处理:转 WebP 并计算输出文件 MD5。不读 XML。
+///
+public static class ImageConverter
+{
+ private static readonly HashSet Convertible = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif"
+ };
+
+ ///
+ /// 将 input 转为 WebP 写入 output,返回 output 的 MD5(小写 hex)。
+ ///
+ public static string ConvertToWebP(string inputPath, string outputPath)
+ {
+ inputPath = Path.GetFullPath(inputPath);
+ outputPath = Path.GetFullPath(outputPath);
+ if (!File.Exists(inputPath))
+ throw new FileNotFoundException($"输入图片不存在: {inputPath}");
+
+ var ext = Path.GetExtension(inputPath);
+ if (!Convertible.Contains(ext))
+ throw new InvalidOperationException($"不支持的输入格式: {ext}");
+
+ var dir = Path.GetDirectoryName(outputPath);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+
+ using var input = File.OpenRead(inputPath);
+ using var codec = SKCodec.Create(input)
+ ?? throw new InvalidOperationException($"无法解码: {inputPath}");
+ using var bitmap = SKBitmap.Decode(codec)
+ ?? throw new InvalidOperationException($"无法解码位图: {inputPath}");
+ using var image = SKImage.FromBitmap(bitmap);
+ using var data = image.Encode(SKEncodedImageFormat.Webp, 90);
+ using (var output = File.Open(outputPath, FileMode.Create, FileAccess.Write))
+ data.SaveTo(output);
+
+ return ComputeMd5(outputPath);
+ }
+
+ public static string ComputeMd5(string filePath)
+ {
+ using var md5 = MD5.Create();
+ using var stream = File.OpenRead(filePath);
+ return Convert.ToHexString(md5.ComputeHash(stream)).ToLowerInvariant();
+ }
+
+ public static bool IsConvertible(string path) =>
+ Convertible.Contains(Path.GetExtension(path));
+}
diff --git a/Ra3.BattleNet.Metadata.Imaging/Program.cs b/Ra3.BattleNet.Metadata.Imaging/Program.cs
new file mode 100644
index 0000000..fc11f0e
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Imaging/Program.cs
@@ -0,0 +1,72 @@
+namespace Ra3.BattleNet.Metadata.Imaging;
+
+///
+/// Imaging CLI:只处理图片,stdout 输出 MD5。不读/改 XML。
+///
+internal static class Program
+{
+ private static int Main(string[] args)
+ {
+ if (args.Length == 0 || args.Any(a => a is "--help" or "-h"))
+ {
+ PrintHelp();
+ return args.Length == 0 ? 1 : 0;
+ }
+
+ try
+ {
+ // convert --input=a.png --output=a.webp
+ // convert a.png a.webp
+ string? input = null;
+ string? output = null;
+ var i = 0;
+ if (args[0] is "convert" or "webp")
+ i = 1;
+
+ for (; i < args.Length; i++)
+ {
+ var a = args[i];
+ if (a.StartsWith("--input=", StringComparison.Ordinal))
+ input = a["--input=".Length..];
+ else if (a.StartsWith("--output=", StringComparison.Ordinal))
+ output = a["--output=".Length..];
+ else if (input == null)
+ input = a;
+ else if (output == null)
+ output = a;
+ }
+
+ if (string.IsNullOrWhiteSpace(input) || string.IsNullOrWhiteSpace(output))
+ {
+ Console.Error.WriteLine("需要 --input 与 --output(或两个位置参数)");
+ return 1;
+ }
+
+ var hash = ImageConverter.ConvertToWebP(input, output);
+ // 机器可读:stdout 仅一行 hash,供展平管线解析
+ Console.WriteLine(hash);
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ Console.Error.WriteLine(ex.Message);
+ return 1;
+ }
+ }
+
+ private static void PrintHelp()
+ {
+ Console.Error.WriteLine("""
+ Ra3.BattleNet.Metadata.Imaging — 图片 CLI(不读 XML)
+
+ 用法:
+ convert --input=in.png --output=out.webp
+ convert in.png out.webp
+
+ 成功: exit 0,stdout 一行 MD5(小写 hex)
+ 失败: exit != 0,错误在 stderr
+
+ 由核心 Build --webp 在展平阶段按图调用;不进 Desktop NuGet。
+ """);
+ }
+}
diff --git a/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj
new file mode 100644
index 0000000..d1dc9b4
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Imaging/Ra3.BattleNet.Metadata.Imaging.csproj
@@ -0,0 +1,17 @@
+
+
+
+ Exe
+ net10.0
+ enable
+ enable
+ Ra3.BattleNet.Metadata.Imaging
+
+ false
+
+
+
+
+
+
+
diff --git a/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs b/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs
new file mode 100644
index 0000000..f59ee1f
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Tests/IdScopeTests.cs
@@ -0,0 +1,92 @@
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Ra3.BattleNet.Metadata.Tests;
+
+[TestClass]
+public class IdScopeTests
+{
+ private static string RepoMetadataDir =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata"));
+
+ private static void SeedSchemas(string dir)
+ {
+ Directory.CreateDirectory(dir);
+ foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName })
+ File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(dir, name), overwrite: true);
+ }
+
+ [TestMethod]
+ public void QualifyId_UsesPathPrefixColonLocal()
+ {
+ MetadataFlattener.QualifyId("mods/corona/corona", "icon").Should().Be("mods/corona/corona:icon");
+ MetadataFlattener.LocalId("mods/corona/corona:icon").Should().Be("icon");
+ MetadataFlattener.PathPrefix(
+ Path.Combine(RepoMetadataDir, "mods", "corona", "corona.xml"),
+ RepoMetadataDir)
+ .Replace('\\', '/').Should().Be("mods/corona/corona");
+ }
+
+ [TestMethod]
+ public void Build_SameLocalIdInDifferentMods_BothSurviveAsQualified()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"id-scope-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ SeedSchemas(src);
+ Directory.CreateDirectory(Path.Combine(src, "a"));
+ Directory.CreateDirectory(Path.Combine(src, "b"));
+ try
+ {
+ File.WriteAllText(Path.Combine(src, "a", "mod.xml"), """
+
+
+
+
+ shared-icon
+
+
+""");
+ File.WriteAllText(Path.Combine(src, "b", "mod.xml"), """
+
+
+
+
+ shared-icon
+
+
+""");
+ // minimal png
+ var png = Convert.FromBase64String("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==");
+ File.WriteAllBytes(Path.Combine(src, "a", "icon.png"), png);
+ File.WriteAllBytes(Path.Combine(src, "b", "icon.png"), png);
+
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+
+
+
+
+""");
+
+ var dst = Path.Combine(temp, "out");
+ MetadataBuilder.Build(src, dst, contentRevision: "scope");
+ var doc = MetadataBuilder.Load(Path.Combine(dst, "metadata.xml"));
+
+ var images = doc.GetAllElements("Image").Where(i => MetadataFlattener.LocalId(i.Get("ID")!) == "shared-icon").ToList();
+ images.Should().HaveCount(2);
+ images.Select(i => i.Get("ID")).Should().OnlyHaveUniqueItems();
+
+ var modA = doc.Mods().Single(m => m.Id == "ModA");
+ var modB = doc.Mods().Single(m => m.Id == "ModB");
+ modA.Icon.Should().Be("a/mod:shared-icon");
+ modB.Icon.Should().Be("b/mod:shared-icon");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+}
diff --git a/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs
new file mode 100644
index 0000000..f643966
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Tests/ImagingTests.cs
@@ -0,0 +1,79 @@
+using System.Xml.Linq;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Ra3.BattleNet.Metadata.Imaging;
+
+namespace Ra3.BattleNet.Metadata.Tests;
+
+[TestClass]
+public class ImagingTests
+{
+ private static string RepoMetadataDir =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata"));
+
+ [TestMethod]
+ public void ImageConverter_ReturnsHashOfWebpFile()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"img-cli-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ try
+ {
+ var png = Path.Combine(RepoMetadataDir, "mods", "corona", "images", "icon-64px.png");
+ var webp = Path.Combine(dir, "icon.webp");
+ var hash = ImageConverter.ConvertToWebP(png, webp);
+ hash.Should().HaveLength(32);
+ File.Exists(webp).Should().BeTrue();
+ ImageConverter.ComputeMd5(webp).Should().Be(hash);
+ }
+ finally
+ {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_WithConvertImages_UpdatesXmlViaImagingCli()
+ {
+ var dst = Path.Combine(Path.GetTempPath(), $"build-webp-{Guid.NewGuid():N}");
+ try
+ {
+ MetadataBuilder.Build(RepoMetadataDir, dst, contentRevision: "webp", convertImages: true);
+ var flat = Path.Combine(dst, "metadata.xml");
+ var doc = XDocument.Load(flat);
+ var icon = doc.Descendants("Image")
+ .First(e => e.Attribute("ID")?.Value?.EndsWith(":corona-icon-64px", StringComparison.Ordinal) == true);
+ icon.Attribute("Source")!.Value.Replace('\\', '/').Should().EndWith(".webp");
+ var hash = icon.Attribute("Hash")!.Value;
+ hash.Should().HaveLength(32);
+ var webpPath = Path.Combine(dst, icon.Attribute("Source")!.Value.Replace('/', Path.DirectorySeparatorChar));
+ File.Exists(webpPath).Should().BeTrue();
+ ImageConverter.ComputeMd5(webpPath).Should().Be(hash);
+ }
+ finally
+ {
+ if (Directory.Exists(dst))
+ Directory.Delete(dst, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void ImagingInvoker_ConvertToWebP_ReturnsHash()
+ {
+ var dir = Path.Combine(Path.GetTempPath(), $"invoker-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(dir);
+ try
+ {
+ var png = Path.Combine(RepoMetadataDir, "mods", "corona", "images", "icon-64px.png");
+ var webp = Path.Combine(dir, "out.webp");
+ var hash = ImagingInvoker.ConvertToWebP(png, webp);
+ hash.Should().HaveLength(32);
+ File.Exists(webp).Should().BeTrue();
+ }
+ finally
+ {
+ if (Directory.Exists(dir))
+ Directory.Delete(dir, recursive: true);
+ }
+ }
+}
diff --git a/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs b/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs
index 7eb6795..959d89d 100644
--- a/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs
+++ b/Ra3.BattleNet.Metadata.Tests/IncludeTests.cs
@@ -1,105 +1,71 @@
using FluentAssertions;
-using Xunit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
-namespace Ra3.BattleNet.Metadata.Tests
-{
- public class IncludeTests
- {
- private readonly string _testDataPath;
-
- public IncludeTests()
- {
- _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData");
- }
-
- [Fact]
- public void Include_PublicType_VisibleToParent()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var publicElement = metadata.GetElementById("public-element");
-
- // Assert
- publicElement.Should().NotBeNull("public类型的Include应该对父节点可见");
- publicElement!.Get("ID").Should().Be("public-element");
- }
-
- [Fact]
- public void Include_PrivateType_OnlyVisibleToChildren()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+namespace Ra3.BattleNet.Metadata.Tests;
- // Act
- var privateElement = metadata.GetElementById("private-element");
-
- // Assert
- // private元素应该在子树中可见,但当前实现可能需要调整
- // 这个测试验证了private元素的存在性
- privateElement.Should().NotBeNull();
- }
-
- [Fact]
- public void Include_ParentElement_Accessible()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var parentElement = metadata.GetElementById("parent-element");
-
- // Assert
- parentElement.Should().NotBeNull();
- parentElement!.Get("ID").Should().Be("parent-element");
- }
-
- [Fact]
- public void Include_CircularReference_ThrowsException()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "circular-a.xml");
+[TestClass]
+public class IncludeTests
+{
+ private string _testDataPath = null!;
- // Act & Assert
- var act = () => Metadata.LoadFromFile(filePath);
- act.Should().Throw()
- .WithMessage("*循环引用*");
- }
+ [TestInitialize]
+ public void Init()
+ {
+ _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData");
+ }
- [Fact]
- public void Include_HasParentReference()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+ [TestMethod]
+ public void Include_PublicType_VisibleToParent()
+ {
+ var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var publicElement = metadata.GetElementById("public-element");
+ publicElement.Should().NotBeNull();
+ publicElement!.Get("ID").Should().Be("public-element");
+ }
- // Act
- var child = metadata.Children.FirstOrDefault();
+ [TestMethod]
+ public void Include_PrivateType_ExistsInTree()
+ {
+ var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.GetElementById("private-element").Should().NotBeNull();
+ }
- // Assert
- child.Should().NotBeNull();
- child!.Parent.Should().NotBeNull();
- child.Parent.Should().Be(metadata);
- }
+ [TestMethod]
+ public void Include_ParentElement_Accessible()
+ {
+ var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var parentElement = metadata.GetElementById("parent-element");
+ parentElement.Should().NotBeNull();
+ parentElement!.Get("ID").Should().Be("parent-element");
+ }
- [Fact]
- public void Include_TypeAttribute_IsPreserved()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+ [TestMethod]
+ public void Include_CircularReference_ThrowsException()
+ {
+ var filePath = Path.Combine(_testDataPath, "circular-a.xml");
+ var act = () => Metadata.LoadFromFile(filePath);
+ act.Should().Throw().WithMessage("*循环引用*");
+ }
- // Act
- var publicChild = metadata.Children.FirstOrDefault(c => c.IncludeType == "public");
- var privateChild = metadata.Children.FirstOrDefault(c => c.IncludeType == "private");
+ [TestMethod]
+ public void Include_HasParentReference()
+ {
+ var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var child = metadata.Children.FirstOrDefault();
+ child.Should().NotBeNull();
+ child!.Parent.Should().Be(metadata);
+ }
- // Assert
- publicChild.Should().NotBeNull("应该有public类型的Include");
- privateChild.Should().NotBeNull("应该有private类型的Include");
- }
+ [TestMethod]
+ public void Include_TypeAttribute_IsPreserved()
+ {
+ var filePath = Path.Combine(_testDataPath, "access-control-test.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.Children.Any(c => c.IncludeType == "public").Should().BeTrue();
+ metadata.Children.Any(c => c.IncludeType == "private").Should().BeTrue();
}
}
diff --git a/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs b/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs
new file mode 100644
index 0000000..5ad1bfa
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Tests/InheritanceTests.cs
@@ -0,0 +1,308 @@
+using System.Xml.Linq;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Ra3.BattleNet.Metadata.Tests;
+
+[TestClass]
+public class InheritanceTests
+{
+ private static string RepoMetadataDir =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata"));
+
+ private static void SeedSchemas(string targetDir)
+ {
+ Directory.CreateDirectory(targetDir);
+ foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName })
+ {
+ File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(targetDir, name), overwrite: true);
+ }
+ }
+
+ [TestMethod]
+ public void Merge_Controls_ChildOverridesOneField()
+ {
+ var root = XElement.Parse("""
+
+
+
+
+
+
+
+
+ """);
+
+ MetadataInheritance.Resolve(root);
+
+ root.Elements("Base").Should().BeEmpty();
+ var mod = root.Element("Mod")!;
+ mod.Attribute("InheritFrom").Should().BeNull();
+ mod.Element("Style")!.Element("Controls")!.Element("Label")!.Element("FontSize")!.Value.Should().Be("16");
+ mod.Element("Style")!.Element("Controls")!.Element("Label")!.Element("ForegroundBrush")!.Value
+ .Should().Be("#FFFFFFFF");
+ }
+
+ [TestMethod]
+ public void Merge_Background_ChildReplacesList()
+ {
+ var root = XElement.Parse("""
+
+
+
+
+
+
+
+
+ """);
+
+ MetadataInheritance.Resolve(root);
+
+ var bg = root.Element("Mod")!.Element("Style")!.Element("Background")!;
+ bg.Attribute("Random")!.Value.Should().Be("false");
+ bg.Elements("Image").Select(e => e.Value).Should().Equal("child-only");
+ }
+
+ [TestMethod]
+ public void Merge_Packages_ChildReplacesAll()
+ {
+ var root = XElement.Parse("""
+
+
+
+
+ old
+
+
+
+
+
+
+ new
+
+
+
+
+ """);
+
+ MetadataInheritance.Resolve(root);
+
+ var packages = root.Element("Mod")!.Element("Packages")!.Elements("Package").ToList();
+ packages.Should().HaveCount(1);
+ packages[0].Attribute("Version")!.Value.Should().Be("2.0");
+ packages[0].Element("Manifest")!.Value.Should().Be("new");
+ }
+
+ [TestMethod]
+ public void Fail_MissingBase()
+ {
+ var root = XElement.Parse("""
+
+
+ 1
+
+
+ """);
+
+ var act = () => MetadataInheritance.Resolve(root);
+ act.Should().Throw().WithMessage("*找不到 Base*");
+ }
+
+ [TestMethod]
+ public void Fail_KindMismatch()
+ {
+ var root = XElement.Parse("""
+
+
+ 1.0
+
+
+ 1
+
+
+ """);
+
+ var act = () => MetadataInheritance.Resolve(root);
+ act.Should().Throw().WithMessage("*不能继承*");
+ }
+
+ [TestMethod]
+ public void Fail_BaseIdConflictsWithMod()
+ {
+ var root = XElement.Parse("""
+
+
+ 0
+
+
+ 1
+
+
+ """);
+
+ var act = () => MetadataInheritance.Resolve(root);
+ act.Should().Throw().WithMessage("*冲突*");
+ }
+
+ [TestMethod]
+ public void Flatten_Publish_NoBaseNoInheritFrom()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"inh-flat-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ Directory.CreateDirectory(src);
+ try
+ {
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+
+
+
+ 9
+ icon-a
+
+
+
+""");
+ File.WriteAllText(Path.Combine(src, "base.xml"), """
+
+
+
+
+
+
+""");
+
+ var doc = MetadataFlattener.Flatten(
+ Path.Combine(src, "metadata.xml"),
+ src,
+ "1.0",
+ "rev");
+
+ var text = doc.ToString();
+ text.Should().NotContain("
+
+
+
+
+
+
+
+ 3.0
+ icon-a
+
+
+ 2026-01-01
+ manifest-1
+
+
+
+
+""");
+ File.WriteAllText(Path.Combine(src, "base.xml"), """
+
+
+
+
+
+
+""");
+ File.WriteAllText(Path.Combine(src, "manifest-1.xml"), """
+
+
+
+
+ x.bin
+ /
+ MOD;
+
+
+
+""");
+
+ MetadataBuilder.Build(src, dst, schemaVersion: "1.0", contentRevision: "inh-test");
+
+ var flat = File.ReadAllText(Path.Combine(dst, "metadata.xml"));
+ flat.Should().NotContain(" m.Id == "Demo");
+ mod.Version.Should().Be("3.0");
+ mod.Icon.Should().Contain("icon-a");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+}
diff --git a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs
index 15c3d76..3edbf7b 100644
--- a/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs
+++ b/Ra3.BattleNet.Metadata.Tests/MetadataTests.cs
@@ -1,176 +1,113 @@
using FluentAssertions;
-using Xunit;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
-namespace Ra3.BattleNet.Metadata.Tests
-{
- public class MetadataTests
- {
- private readonly string _testDataPath;
-
- public MetadataTests()
- {
- _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData");
- }
-
- [Fact]
- public void LoadFromFile_ValidXml_Success()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
-
- // Act
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Assert
- metadata.Should().NotBeNull();
- metadata.Name.Should().Be("Metadata");
- metadata.Children.Should().HaveCountGreaterThan(0);
- }
-
- [Fact]
- public void LoadFromFile_InvalidRoot_ThrowsException()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "invalid-root.xml");
-
- // Act & Assert
- var act = () => Metadata.LoadFromFile(filePath);
- act.Should().Throw();
- }
-
- [Fact]
- public void LoadFromFile_MissingFile_ThrowsFileNotFoundException()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "nonexistent.xml");
-
- // Act & Assert
- var act = () => Metadata.LoadFromFile(filePath);
- act.Should().Throw();
- }
-
- [Fact]
- public void LoadFromFile_CircularReference_ThrowsException()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "circular-a.xml");
+namespace Ra3.BattleNet.Metadata.Tests;
- // Act & Assert
- var act = () => Metadata.LoadFromFile(filePath);
- act.Should().Throw()
- .WithMessage("*循环引用*");
- }
-
- [Fact]
- public void Get_ExistingVariable_ReturnsValue()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var tags = metadata.Find("Tags");
- var versionElement = tags?.Find("Version");
-
- // Assert
- // 由于XML结构,Version是一个子元素而不是属性
- // 我们应该检查元素的存在性
- tags.Should().NotBeNull();
- versionElement.Should().NotBeNull();
- }
-
- [Fact]
- public void Get_MissingVariable_ReturnsDefault()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var missing = metadata.Get("NonExistent", "default");
-
- // Assert
- missing.Should().Be("default");
- }
-
- [Fact]
- public void Find_ValidPath_ReturnsMetadata()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var tags = metadata.Find("Tags");
-
- // Assert
- tags.Should().NotBeNull();
- tags!.Name.Should().Be("Tags");
- }
+[TestClass]
+public class MetadataTests
+{
+ private string _testDataPath = null!;
- [Fact]
- public void GetElementById_ExistingId_ReturnsElement()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+ [TestInitialize]
+ public void Init()
+ {
+ _testDataPath = Path.Combine(AppContext.BaseDirectory, "TestData");
+ }
- // Act
- var app = metadata.GetElementById("TestApp");
+ [TestMethod]
+ public void LoadFromFile_ValidXml_Success()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.Should().NotBeNull();
+ metadata.Name.Should().Be("Metadata");
+ metadata.Children.Should().HaveCountGreaterThan(0);
+ }
- // Assert
- app.Should().NotBeNull();
- app!.Get("ID").Should().Be("TestApp");
- // Name是子元素,不是属性
- var nameElement = app.Find("Name");
- nameElement.Should().NotBeNull();
- }
+ [TestMethod]
+ public void LoadFromFile_InvalidRoot_ThrowsException()
+ {
+ var filePath = Path.Combine(_testDataPath, "invalid-root.xml");
+ var act = () => Metadata.LoadFromFile(filePath);
+ act.Should().Throw();
+ }
- [Fact]
- public void GetAllElements_ByName_ReturnsMatchingElements()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+ [TestMethod]
+ public void LoadFromFile_MissingFile_ThrowsFileNotFoundException()
+ {
+ var filePath = Path.Combine(_testDataPath, "nonexistent.xml");
+ var act = () => Metadata.LoadFromFile(filePath);
+ act.Should().Throw();
+ }
- // Act
- var applications = metadata.GetAllElements("Application");
+ [TestMethod]
+ public void LoadFromFile_CircularReference_ThrowsException()
+ {
+ var filePath = Path.Combine(_testDataPath, "circular-a.xml");
+ var act = () => Metadata.LoadFromFile(filePath);
+ act.Should().Throw()
+ .WithMessage("*循环引用*");
+ }
- // Assert
- applications.Should().HaveCount(1);
- applications[0].Get("ID").Should().Be("TestApp");
- }
+ [TestMethod]
+ public void Get_MissingVariable_ReturnsDefault()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.Get("NonExistent", "default").Should().Be("default");
+ }
- [Fact]
- public void GetElementPath_ReturnsFullPath()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
- var app = metadata.Find("Application");
+ [TestMethod]
+ public void Find_ValidPath_ReturnsMetadata()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var tags = metadata.Find("Tags");
+ tags.Should().NotBeNull();
+ tags!.Name.Should().Be("Tags");
+ }
- // Act
- var path = app?.GetElementPath();
+ [TestMethod]
+ public void GetElementById_ExistingId_ReturnsElement()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var app = metadata.GetElementById("TestApp");
+ app.Should().NotBeNull();
+ app!.Get("ID").Should().Be("TestApp");
+ }
- // Assert
- path.Should().Contain("Metadata");
- path.Should().Contain("Application");
- }
+ [TestMethod]
+ public void GetAllElements_ByName_ReturnsMatchingElements()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var applications = metadata.GetAllElements("Application");
+ applications.Should().HaveCount(1);
+ applications[0].Get("ID").Should().Be("TestApp");
+ }
+ [TestMethod]
+ public void GetElementPath_ReturnsFullPath()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var app = metadata.Find("Application");
+ var path = app?.GetElementPath();
+ path.Should().Contain("Metadata");
+ path.Should().Contain("Application");
+ }
- [Fact]
- public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles()
+ [TestMethod]
+ public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles()
+ {
+ var tempDir = Path.Combine(Path.GetTempPath(), $"metadata-test-{Guid.NewGuid():N}");
+ Directory.CreateDirectory(tempDir);
+ try
{
- // Arrange
- var tempDir = Path.Combine(Path.GetTempPath(), $"metadata-test-{Guid.NewGuid():N}");
- Directory.CreateDirectory(tempDir);
-
- try
- {
- var rootPath = Path.Combine(tempDir, "metadata.xml");
- var includePath = Path.Combine(tempDir, "included.xml");
-
- File.WriteAllText(rootPath, """
+ var rootPath = Path.Combine(tempDir, "metadata.xml");
+ var includePath = Path.Combine(tempDir, "included.xml");
+ File.WriteAllText(rootPath, """
@@ -179,8 +116,7 @@ public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles()
""");
-
- File.WriteAllText(includePath, """
+ File.WriteAllText(includePath, """
@@ -188,111 +124,64 @@ public void ReplaceVariablesInFile_RecursivelyProcessesIncludeFiles()
""");
-
- Environment.SetEnvironmentVariable("TEST_COMMIT", "abc123", EnvironmentVariableTarget.Process);
- var metadata = Metadata.LoadFromFile(rootPath);
-
- // Act
- metadata.ReplaceVariablesInFile(rootPath);
-
- // Assert
- var rootResult = File.ReadAllText(rootPath);
- var includeResult = File.ReadAllText(includePath);
-
- rootResult.Should().Contain("abc123");
- rootResult.Should().NotContain("${ENV:TEST_COMMIT}");
-
- includeResult.Should().Contain("abc123");
- includeResult.Should().NotContain("${ENV:TEST_COMMIT}");
- }
- finally
- {
- if (Directory.Exists(tempDir))
- {
- Directory.Delete(tempDir, recursive: true);
- }
- }
+ Environment.SetEnvironmentVariable("TEST_COMMIT", "abc123", EnvironmentVariableTarget.Process);
+ var metadata = Metadata.LoadFromFile(rootPath);
+ metadata.ReplaceVariablesInFile(rootPath);
+ File.ReadAllText(rootPath).Should().Contain("abc123").And.NotContain("${ENV:TEST_COMMIT}");
+ File.ReadAllText(includePath).Should().Contain("abc123").And.NotContain("${ENV:TEST_COMMIT}");
}
-
- [Fact]
- public void GetIncludeTree_ReturnsTreeStructure()
+ finally
{
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var tree = metadata.GetIncludeTree();
-
- // Assert
- tree.Should().NotBeNullOrEmpty();
- tree.Should().Contain("Metadata");
+ if (Directory.Exists(tempDir))
+ Directory.Delete(tempDir, recursive: true);
}
+ }
- [Fact]
- public void ToNodeTree_ShouldKeepLeafValue()
- {
- // Arrange
- var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var root = metadata.ToNodeTree();
- var app = root.Children.Single(c => c.Name == "Application");
- var appName = app.Children.Single(c => c.Name == "Name");
-
- // Assert
- appName.Value.Should().Be("Test Application");
- }
-
- [Fact]
- public void GetBusinessEntities_ShouldReturnTypedEntities()
- {
- // Arrange
- var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var entities = metadata.GetBusinessEntities();
-
- // Assert
- entities.Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet");
- entities.Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona");
- entities.Should().Contain(e => e.EntityType == "Markdown");
- entities.Should().Contain(e => e.EntityType == "Manifest");
- }
-
-
- [Fact]
- public void Mods_ShouldExposeVersionAndPackages()
- {
- // Arrange
- var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
-
- // Act
- var corona = metadata.Mods().Single(m => m.Id == "Corona");
-
- // Assert
- corona.Version.Should().Be("3.229");
- corona.Packages.Should().NotBeEmpty();
- }
+ [TestMethod]
+ public void GetIncludeTree_ReturnsTreeStructure()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.GetIncludeTree().Should().Contain("Metadata");
+ }
- [Fact]
- public void Catalog_ShouldProvideConvenientLookup()
- {
- // Arrange
- var filePath = Path.Combine(Environment.CurrentDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml");
- var metadata = Metadata.LoadFromFile(filePath);
+ [TestMethod]
+ public void ToNodeTree_ShouldKeepLeafValue()
+ {
+ var filePath = Path.Combine(_testDataPath, "valid-metadata.xml");
+ var metadata = Metadata.LoadFromFile(filePath);
+ var root = metadata.ToNodeTree();
+ var app = root.Children.Single(c => c.Name == "Application");
+ var appName = app.Children.Single(c => c.Name == "Name");
+ appName.Value.Should().Be("Test Application");
+ }
- // Act
- var catalog = metadata.Catalog();
- var app = catalog.Application("RA3BattleNet");
+ [TestMethod]
+ public void RepoSample_LoadSource_HasAppAndMod()
+ {
+ var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"));
+ var metadata = Metadata.LoadFromFile(filePath);
+ metadata.GetBusinessEntities().Should().Contain(e => e.EntityType == "Application" && e.Id == "RA3BattleNet");
+ metadata.GetBusinessEntities().Should().Contain(e => e.EntityType == "Mod" && e.Id == "Corona");
+ }
- // Assert
- app.Should().NotBeNull();
- app!.Version.Should().Be("1.5.2.0");
- }
+ [TestMethod]
+ public void Mods_ShouldExposeVersionAndPackages()
+ {
+ var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"));
+ var metadata = Metadata.LoadFromFile(filePath);
+ var corona = metadata.Mods().Single(m => m.Id == "Corona");
+ corona.Version.Should().Be("3.229");
+ corona.Packages.Should().NotBeEmpty();
+ }
+ [TestMethod]
+ public void Catalog_ShouldProvideConvenientLookup()
+ {
+ var filePath = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata", "metadata.xml"));
+ var metadata = Metadata.LoadFromFile(filePath);
+ var app = metadata.Catalog().Application("RA3BattleNet");
+ app.Should().NotBeNull();
+ app!.Version.Should().Be("1.5.2.0");
}
}
diff --git a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj
index 01d50de..964b4bb 100644
--- a/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj
+++ b/Ra3.BattleNet.Metadata.Tests/Ra3.BattleNet.Metadata.Tests.csproj
@@ -1,4 +1,4 @@
-
+
net10.0
@@ -11,16 +11,13 @@
-
-
-
-
-
-
+
+
+
@@ -29,4 +26,4 @@
-
\ No newline at end of file
+
diff --git a/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs b/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs
new file mode 100644
index 0000000..fc36d66
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Tests/SchemaTests.cs
@@ -0,0 +1,127 @@
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Ra3.BattleNet.Metadata.Tests;
+
+[TestClass]
+public class SchemaTests
+{
+ private static string RepoRoot =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", ".."));
+
+ [TestMethod]
+ public void Build_SampleTree_PassesSourceAndPublishSchema()
+ {
+ var src = Path.Combine(RepoRoot, "Metadata");
+ var dst = Path.Combine(Path.GetTempPath(), $"schema-ok-{Guid.NewGuid():N}");
+ try
+ {
+ MetadataBuilder.Build(src, dst, contentRevision: "schema-ok");
+ var flat = Path.Combine(dst, "metadata.xml");
+ File.Exists(flat).Should().BeTrue();
+
+ var publishSchema = Path.Combine(src, SchemaValidator.PublishSchemaFileName);
+ SchemaValidator.ValidateFile(flat, publishSchema).Should().BeEmpty();
+
+ var loaded = MetadataBuilder.Load(flat);
+ loaded.Applications().Should().Contain(a => a.Id == "RA3BattleNet");
+ loaded.Mods().Should().Contain(m => m.Id == "Corona");
+ loaded.Get("SchemaVersion").Should().NotBeNullOrWhiteSpace();
+ }
+ finally
+ {
+ if (Directory.Exists(dst))
+ Directory.Delete(dst, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_ApplicationMissingId_FailsSourceSchema()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"schema-bad-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ var dst = Path.Combine(temp, "out");
+ Directory.CreateDirectory(src);
+ try
+ {
+ // 最小可拷贝 schema
+ File.Copy(
+ Path.Combine(RepoRoot, "Metadata", SchemaValidator.SourceSchemaFileName),
+ Path.Combine(src, SchemaValidator.SourceSchemaFileName));
+ File.Copy(
+ Path.Combine(RepoRoot, "Metadata", SchemaValidator.PublishSchemaFileName),
+ Path.Combine(src, SchemaValidator.PublishSchemaFileName));
+
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+ 1.0
+
+
+""");
+
+ var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x");
+ act.Should().Throw()
+ .WithMessage("*XSD*");
+ Directory.Exists(dst).Should().BeFalse();
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void PublishSchema_RejectsManifestWithoutSource()
+ {
+ var schema = Path.Combine(RepoRoot, "Metadata", SchemaValidator.PublishSchemaFileName);
+ var temp = Path.Combine(Path.GetTempPath(), $"pub-bad-{Guid.NewGuid():N}.xml");
+ try
+ {
+ File.WriteAllText(temp, """
+
+
+
+
+""");
+ var errors = SchemaValidator.ValidateFile(temp, schema);
+ errors.Should().NotBeEmpty();
+ string.Join(" ", errors).Should().Contain("Source");
+ }
+ finally
+ {
+ if (File.Exists(temp)) File.Delete(temp);
+ }
+ }
+
+ [TestMethod]
+ public void ConsumerParse_CatalogResolvesIdsAndResources()
+ {
+ var src = Path.Combine(RepoRoot, "Metadata");
+ var dst = Path.Combine(Path.GetTempPath(), $"parse-{Guid.NewGuid():N}");
+ try
+ {
+ MetadataBuilder.Build(src, dst, contentRevision: "parse");
+ var doc = MetadataBuilder.Load(Path.Combine(dst, "metadata.xml"));
+
+ var app = doc.Catalog().Application("RA3BattleNet");
+ app.Should().NotBeNull();
+ app!.Version.Should().Be("1.5.2.0");
+ app.Packages.Should().NotBeEmpty();
+ var manifestId = app.Packages[0].ManifestId;
+ manifestId.Should().NotBeNullOrWhiteSpace();
+
+ var reg = doc.GetAllElements("Manifest").First(m => m.Get("ID") == manifestId);
+ var rel = reg.Get("Source")!;
+ rel.Replace('\\', '/').Should().Contain("manifests/");
+ File.Exists(Path.Combine(dst, rel.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue();
+ }
+ finally
+ {
+ if (Directory.Exists(dst))
+ Directory.Delete(dst, recursive: true);
+ }
+ }
+}
diff --git a/Ra3.BattleNet.Metadata.Tests/StageATests.cs b/Ra3.BattleNet.Metadata.Tests/StageATests.cs
new file mode 100644
index 0000000..bffbd07
--- /dev/null
+++ b/Ra3.BattleNet.Metadata.Tests/StageATests.cs
@@ -0,0 +1,227 @@
+using System.Xml.Linq;
+using FluentAssertions;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+
+namespace Ra3.BattleNet.Metadata.Tests;
+
+[TestClass]
+public class StageATests
+{
+ private static string RepoMetadataDir =>
+ Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "..", "..", "..", "..", "Metadata"));
+
+ private static void SeedSchemas(string targetDir)
+ {
+ Directory.CreateDirectory(targetDir);
+ foreach (var name in new[] { SchemaValidator.SourceSchemaFileName, SchemaValidator.PublishSchemaFileName })
+ {
+ File.Copy(Path.Combine(RepoMetadataDir, name), Path.Combine(targetDir, name), overwrite: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_RepoSample_FlattensWithoutInclude_AndResolvesIds()
+ {
+ var src = RepoMetadataDir;
+ var dst = Path.Combine(Path.GetTempPath(), $"stage-a-{Guid.NewGuid():N}");
+
+ try
+ {
+ MetadataBuilder.Build(src, dst, schemaVersion: "1.0", contentRevision: "test-rev");
+
+ var flatPath = Path.Combine(dst, "metadata.xml");
+ File.Exists(flatPath).Should().BeTrue();
+
+ var text = File.ReadAllText(flatPath);
+ text.Should().NotContain(" a.Id == "RA3BattleNet");
+ loaded.Mods().Should().Contain(m => m.Id == "Corona");
+
+ // 发布 ID = {路径前缀}:{localId}
+ var registries = loaded.GetAllElements("Manifest")
+ .Where(m => !string.IsNullOrEmpty(m.Get("ID"))).ToList();
+ var manifest = registries
+ .First(m => MetadataFlattener.LocalId(m.Get("ID")) == "manifest-1.5.2.0");
+ manifest.Get("ID")!.Should().Contain(":");
+ manifest.Get("ID")!.Should().EndWith(":manifest-1.5.2.0");
+ var source = manifest.Get("Source");
+ source.Should().NotBeNullOrWhiteSpace();
+ source!.Replace('\\', '/').Should().EndWith("manifests/1.5.2.0.xml");
+ source.Should().NotContain("apps.xml");
+ var manifestPath = Path.Combine(dst, source.Replace('/', Path.DirectorySeparatorChar));
+ File.Exists(manifestPath).Should().BeTrue();
+ File.ReadAllText(manifestPath).Should().Contain(" MetadataFlattener.LocalId(m.Get("ID")!) == "manifest-3229");
+ coronaManifest.Get("Source")!.Replace('\\', '/').Should().EndWith("manifests/3.229.xml");
+
+ var md = loaded.GetAllElements("Markdown")
+ .First(m => MetadataFlattener.LocalId(m.Get("ID")!) == "changelog-zh-1.5.2.0");
+ md.Get("ID")!.Should().Contain("changelogs");
+ var mdSource = md.Get("Source");
+ mdSource!.Replace('\\', '/').Should().EndWith("changelogs/zh-1.5.2.0.md");
+ File.Exists(Path.Combine(dst, mdSource.Replace('/', Path.DirectorySeparatorChar))).Should().BeTrue();
+
+ // 引用已改写为限定 ID
+ var app = loaded.Applications().Single(a => a.Id == "RA3BattleNet");
+ app.Packages[0].ManifestId.Should().Be(manifest.Get("ID"));
+ var corona = loaded.Mods().Single(m => m.Id == "Corona");
+ corona.Icon.Should().EndWith(":corona-icon-64px");
+ corona.Icon.Should().Contain(":");
+ }
+ finally
+ {
+ if (Directory.Exists(dst))
+ Directory.Delete(dst, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_MissingMarkdown_HardFails_AndCleansOutput()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"stage-a-bad-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ var dst = Path.Combine(temp, "out");
+ SeedSchemas(src);
+ try
+ {
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+
+""");
+
+ var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x");
+ act.Should().Throw();
+ Directory.Exists(dst).Should().BeFalse("半残输出应被清理");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_BadIdReference_HardFails()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"stage-a-id-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ var dst = Path.Combine(temp, "out");
+ SeedSchemas(src);
+ try
+ {
+ File.WriteAllText(Path.Combine(src, "note.md"), "# hello sample markdown body\n");
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+
+ 1.0
+
+
+ no-such-manifest
+
+
+
+
+""");
+
+ var act = () => MetadataBuilder.Build(src, dst, contentRevision: "x");
+ act.Should().Throw().WithMessage("*no-such-manifest*");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Flatten_CircularInclude_Throws()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"stage-a-circ-{Guid.NewGuid():N}");
+ SeedSchemas(temp);
+ try
+ {
+ File.WriteAllText(Path.Combine(temp, "a.xml"), """
+
+
+
+
+
+
+""");
+ File.WriteAllText(Path.Combine(temp, "b.xml"), """
+
+
+
+
+
+
+""");
+ File.WriteAllText(Path.Combine(temp, "metadata.xml"), """
+
+
+
+
+
+
+""");
+
+ var act = () => MetadataBuilder.Build(temp, Path.Combine(temp, "out"), contentRevision: "x");
+ act.Should().Throw().WithMessage("*循环引用*");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Build_LeftoverVariable_HardFails()
+ {
+ var temp = Path.Combine(Path.GetTempPath(), $"stage-a-var-{Guid.NewGuid():N}");
+ var src = Path.Combine(temp, "src");
+ SeedSchemas(src);
+ try
+ {
+ File.WriteAllText(Path.Combine(src, "metadata.xml"), """
+
+
+
+ ${ENV:THIS_ENV_SHOULD_NOT_EXIST_ZZZ}
+
+
+""");
+ var act = () => MetadataBuilder.Build(src, Path.Combine(temp, "out"), contentRevision: "x");
+ act.Should().Throw().WithMessage("*残留*");
+ }
+ finally
+ {
+ if (Directory.Exists(temp))
+ Directory.Delete(temp, recursive: true);
+ }
+ }
+
+ [TestMethod]
+ public void Flatten_Document_HasNoIncludeNodes()
+ {
+ var entry = Path.Combine(RepoMetadataDir, "metadata.xml");
+ var doc = MetadataFlattener.Flatten(entry, RepoMetadataDir, "1.0", "rev");
+ doc.Descendants().Any(e => e.Name.LocalName is "Include" or "Includes").Should().BeFalse();
+ doc.Root!.Attribute("SchemaVersion")!.Value.Should().Be("1.0");
+ XDocument.Parse(doc.ToString()).Root!.Attribute("ContentRevision")!.Value.Should().Be("rev");
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/ImagePostProcessor.cs b/Ra3.BattleNet.Metadata/ImagePostProcessor.cs
new file mode 100644
index 0000000..46db3ca
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/ImagePostProcessor.cs
@@ -0,0 +1,62 @@
+using System.Xml.Linq;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// 展平后:扫描 Image 登记节点,调用 Imaging CLI 转 WebP,由本类改写 XML 的 Source/Hash。
+///
+public static class ImagePostProcessor
+{
+ private static readonly HashSet Convertible = new(StringComparer.OrdinalIgnoreCase)
+ {
+ ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif"
+ };
+
+ ///
+ /// 处理 outputDir 下 metadata.xml;返回转换张数。
+ ///
+ public static int ApplyWebP(string outputDir, TextWriter? log = null)
+ {
+ log ??= Console.Out;
+ outputDir = Path.GetFullPath(outputDir);
+ var flatPath = Path.Combine(outputDir, "metadata.xml");
+ if (!File.Exists(flatPath))
+ throw new FileNotFoundException($"找不到展平文件: {flatPath}");
+
+ var doc = XDocument.Load(flatPath);
+ var converted = 0;
+
+ foreach (var image in doc.Descendants("Image"))
+ {
+ if (image.Attribute("ID") == null)
+ continue;
+
+ var sourceAttr = image.Attribute("Source");
+ if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value))
+ continue;
+ if (!string.IsNullOrWhiteSpace(image.Attribute("Url")?.Value))
+ continue;
+
+ var rel = sourceAttr.Value.Replace('\\', '/');
+ var abs = Path.Combine(outputDir, rel);
+ if (!File.Exists(abs))
+ continue;
+
+ var ext = Path.GetExtension(abs);
+ if (!Convertible.Contains(ext))
+ continue;
+
+ var webpAbs = Path.ChangeExtension(abs, ".webp")!;
+ var webpRel = Path.ChangeExtension(rel, ".webp")!.Replace('\\', '/');
+
+ log.WriteLine($" convert {rel} -> {webpRel}");
+ var hash = ImagingInvoker.ConvertToWebP(abs, webpAbs);
+ sourceAttr.Value = webpRel;
+ image.SetAttributeValue("Hash", hash);
+ converted++;
+ }
+
+ doc.Save(flatPath);
+ return converted;
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/ImageProcessor.cs b/Ra3.BattleNet.Metadata/ImageProcessor.cs
deleted file mode 100644
index 3273169..0000000
--- a/Ra3.BattleNet.Metadata/ImageProcessor.cs
+++ /dev/null
@@ -1,141 +0,0 @@
-using SkiaSharp;
-
-namespace Ra3.BattleNet.Metadata;
-
-///
-/// 图片处理工具:将图片转换为 WebP 格式。
-///
-public static class ImageProcessor
-{
- private static readonly HashSet SupportedExtensions = new(StringComparer.OrdinalIgnoreCase)
- {
- ".png", ".jpg", ".jpeg", ".bmp", ".gif", ".tiff", ".tif"
- };
-
- ///
- /// 扫描输出目录中所有 元素引用的本地图片,转换为 WebP 格式。
- ///
- /// 已加载的元数据根节点。
- /// 输出目录路径。
- /// 转换的文件数量。
- public static int ConvertImagesToWebP(Metadata metadata, string outputDir)
- {
- int convertedCount = 0;
- var images = metadata.GetAllElements("Image");
-
- foreach (var image in images)
- {
- var source = image.Get("Source");
- if (string.IsNullOrWhiteSpace(source))
- continue;
-
- var sourcePath = Path.Combine(outputDir, source.Replace('\\', '/'));
- if (!File.Exists(sourcePath))
- continue;
-
- var ext = Path.GetExtension(sourcePath);
- if (!SupportedExtensions.Contains(ext))
- continue;
-
- var webpPath = Path.ChangeExtension(sourcePath, ".webp");
- if (ConvertToWebP(sourcePath, webpPath))
- {
- // 更新 Source 引用为 WebP 文件
- var webpRelative = Path.ChangeExtension(source, ".webp").Replace('\\', '/');
- // 通过反射设置变量(因为 _variables 是私有字段)
- // 使用公共 API:重新加载时 Source 会指向 webp
- // 这里我们直接操作文件,元数据中的 Source 会在后续变量替换时更新
- Console.WriteLine($" WebP: {source} -> {webpRelative}");
- convertedCount++;
- }
- }
-
- return convertedCount;
- }
-
- ///
- /// 将单张图片转换为 WebP 格式。
- ///
- private static bool ConvertToWebP(string sourcePath, string webpPath)
- {
- try
- {
- // 如果 WebP 已存在且比源文件新,跳过
- if (File.Exists(webpPath))
- {
- var sourceTime = File.GetLastWriteTimeUtc(sourcePath);
- var webpTime = File.GetLastWriteTimeUtc(webpPath);
- if (webpTime >= sourceTime)
- return true;
- }
-
- using var input = File.OpenRead(sourcePath);
- using var stream = new SKManagedStream(input);
- using var codec = SKCodec.Create(stream);
- if (codec == null)
- {
- Console.WriteLine($" 警告: 无法解码图片 {sourcePath}");
- return false;
- }
-
- var info = codec.Info;
- using var bitmap = SKBitmap.Decode(codec);
- if (bitmap == null)
- {
- Console.WriteLine($" 警告: 无法解码图片 {sourcePath}");
- return false;
- }
-
- using var image = SKImage.FromBitmap(bitmap);
- using var data = image.Encode(SKEncodedImageFormat.Webp, 90);
-
- using var output = File.Open(webpPath, FileMode.Create, FileAccess.Write);
- data.SaveTo(output);
-
- Console.WriteLine($" 转换: {Path.GetFileName(sourcePath)} -> {Path.GetFileName(webpPath)} " +
- $"({data.Size / 1024} KB)");
- return true;
- }
- catch (Exception ex)
- {
- Console.WriteLine($" 警告: 转换图片失败 {sourcePath}: {ex.Message}");
- return false;
- }
- }
-
- ///
- /// 更新 XML 中 Image 元素的 Source 引用为 .webp 路径。
- ///
- public static void UpdateImageSourceReferences(string outputDir)
- {
- var xmlFiles = Directory.GetFiles(outputDir, "*.xml", SearchOption.AllDirectories);
- foreach (var xmlFile in xmlFiles)
- {
- var content = File.ReadAllText(xmlFile);
- if (!content.Contains(" {sourceAttr.Value}");
- }
- }
-
- if (modified)
- doc.Save(xmlFile);
- }
- }
-}
diff --git a/Ra3.BattleNet.Metadata/ImagingInvoker.cs b/Ra3.BattleNet.Metadata/ImagingInvoker.cs
new file mode 100644
index 0000000..7ce44bc
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/ImagingInvoker.cs
@@ -0,0 +1,140 @@
+using System.Diagnostics;
+using System.Text;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// 调用 Imaging CLI:单张图片转 WebP,返回 MD5。主包不引用 SkiaSharp。
+///
+public static class ImagingInvoker
+{
+ private static string? _cachedDll;
+ private static readonly object Gate = new();
+
+ ///
+ /// 将 input 转为 WebP 写到 output,返回 hash(小写 hex)。
+ ///
+ public static string ConvertToWebP(string inputPath, string outputPath)
+ {
+ var dll = EnsureImagingDll();
+ var psi = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ ArgumentList =
+ {
+ "exec", dll,
+ "convert",
+ $"--input={Path.GetFullPath(inputPath)}",
+ $"--output={Path.GetFullPath(outputPath)}"
+ },
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+
+ using var proc = Process.Start(psi)
+ ?? throw new InvalidOperationException("无法启动 Imaging CLI");
+
+ var stdout = new StringBuilder();
+ var stderr = new StringBuilder();
+ proc.OutputDataReceived += (_, e) => { if (e.Data != null) stdout.AppendLine(e.Data); };
+ proc.ErrorDataReceived += (_, e) => { if (e.Data != null) stderr.AppendLine(e.Data); };
+ proc.BeginOutputReadLine();
+ proc.BeginErrorReadLine();
+ proc.WaitForExit();
+
+ if (proc.ExitCode != 0)
+ {
+ var err = stderr.ToString().Trim();
+ throw new InvalidOperationException(
+ string.IsNullOrEmpty(err)
+ ? $"Imaging CLI 失败 (exit {proc.ExitCode})"
+ : $"Imaging CLI 失败: {err}");
+ }
+
+ var hash = stdout.ToString().Trim()
+ .Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
+ .LastOrDefault();
+ if (string.IsNullOrWhiteSpace(hash) || hash.Length != 32)
+ throw new InvalidOperationException($"Imaging CLI 未返回有效 MD5: '{stdout}'");
+
+ return hash.ToLowerInvariant();
+ }
+
+ public static string? FindImagingProject()
+ {
+ foreach (var start in new[]
+ {
+ Environment.CurrentDirectory,
+ AppContext.BaseDirectory,
+ Path.GetDirectoryName(typeof(ImagingInvoker).Assembly.Location) ?? ""
+ })
+ {
+ var dir = start;
+ while (!string.IsNullOrEmpty(dir))
+ {
+ var candidate = Path.Combine(dir, "Ra3.BattleNet.Metadata.Imaging",
+ "Ra3.BattleNet.Metadata.Imaging.csproj");
+ if (File.Exists(candidate))
+ return candidate;
+
+ var parent = Path.GetDirectoryName(dir);
+ if (parent == dir) break;
+ dir = parent;
+ }
+ }
+
+ return null;
+ }
+
+ private static string EnsureImagingDll()
+ {
+ lock (Gate)
+ {
+ if (_cachedDll != null && File.Exists(_cachedDll))
+ return _cachedDll;
+
+ var project = FindImagingProject()
+ ?? throw new InvalidOperationException(
+ "找不到 Ra3.BattleNet.Metadata.Imaging 项目(仓库编译环境需要)。");
+
+ var projectDir = Path.GetDirectoryName(project)!;
+ foreach (var config in new[] { "Debug", "Release" })
+ {
+ var dll = Path.Combine(projectDir, "bin", config, "net10.0",
+ "Ra3.BattleNet.Metadata.Imaging.dll");
+ if (File.Exists(dll))
+ {
+ _cachedDll = dll;
+ return dll;
+ }
+ }
+
+ // 先编译一次再 exec
+ var build = new ProcessStartInfo
+ {
+ FileName = "dotnet",
+ ArgumentList = { "build", project, "-c", "Debug", "--nologo", "-v", "q" },
+ UseShellExecute = false,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ CreateNoWindow = true
+ };
+ using (var p = Process.Start(build) ?? throw new InvalidOperationException("无法 build Imaging"))
+ {
+ p.WaitForExit();
+ if (p.ExitCode != 0)
+ throw new InvalidOperationException("编译 Imaging CLI 失败");
+ }
+
+ var built = Path.Combine(projectDir, "bin", "Debug", "net10.0",
+ "Ra3.BattleNet.Metadata.Imaging.dll");
+ if (!File.Exists(built))
+ throw new FileNotFoundException($"Imaging 编译后未找到: {built}");
+
+ _cachedDll = built;
+ return built;
+ }
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/MetadataBuilder.cs b/Ra3.BattleNet.Metadata/MetadataBuilder.cs
new file mode 100644
index 0000000..3bbb9bd
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/MetadataBuilder.cs
@@ -0,0 +1,257 @@
+using System.Text.RegularExpressions;
+using System.Xml.Linq;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// 核心构建:校验、变量替换、XML 数据展平、复制资源。纯 managed。
+///
+public static class MetadataBuilder
+{
+ public const string DefaultSchemaVersion = "1.0";
+
+ private static readonly Regex LeftoverVariablePattern = new(@"\$\{[^}]+\}", RegexOptions.Compiled);
+
+ ///
+ /// 从本地源目录执行核心构建。
+ ///
+ /// true 时在展平后调用 Imaging CLI 转 WebP,并由本管线改写 Source/Hash。
+ public static void Build(
+ string sourceDir,
+ string outputDir,
+ string? schemaVersion = null,
+ string? contentRevision = null,
+ bool convertImages = false)
+ {
+ if (string.IsNullOrWhiteSpace(sourceDir))
+ throw new ArgumentException("源目录不能为空", nameof(sourceDir));
+ if (string.IsNullOrWhiteSpace(outputDir))
+ throw new ArgumentException("输出目录不能为空", nameof(outputDir));
+
+ var src = Path.GetFullPath(sourceDir);
+ var dst = Path.GetFullPath(outputDir);
+ var entry = Path.Combine(src, "metadata.xml");
+ if (!File.Exists(entry))
+ throw new FileNotFoundException($"找不到入口文件: {entry}");
+
+ if (Directory.Exists(dst))
+ Directory.Delete(dst, recursive: true);
+ Directory.CreateDirectory(dst);
+
+ try
+ {
+ var sourceSchema = SchemaValidator.FindSchema(src, SchemaValidator.SourceSchemaFileName)
+ ?? throw new FileNotFoundException($"找不到源树 XSD: {SchemaValidator.SourceSchemaFileName}");
+ SchemaValidator.EnsureDirectoryValid(src, sourceSchema, "源树");
+
+ CopyAll(src, dst);
+
+ var revision = string.IsNullOrWhiteSpace(contentRevision)
+ ? DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ")
+ : contentRevision;
+ var version = string.IsNullOrWhiteSpace(schemaVersion) ? DefaultSchemaVersion : schemaVersion;
+
+ var flattened = MetadataFlattener.Flatten(entry, src, version, revision);
+ var flatPath = Path.Combine(dst, "metadata.xml");
+ flattened.Save(flatPath);
+
+ var resolver = new VariableResolver();
+ resolver.ReplaceInFile(flatPath);
+
+ var leftover = FindLeftoverVariables(flatPath);
+ if (leftover.Count > 0)
+ throw new InvalidOperationException("变量替换后仍有残留: " + string.Join("; ", leftover));
+
+ // 展平后:按图调用 Imaging(只产 webp+hash),本管线写回 XML
+ if (convertImages)
+ {
+ var n = ImagePostProcessor.ApplyWebP(dst);
+ Console.WriteLine($" Imaging: 转换 {n} 张图片并写回 Source/Hash");
+ }
+
+ var publishSchema = SchemaValidator.FindSchema(src, SchemaValidator.PublishSchemaFileName)
+ ?? SchemaValidator.FindSchema(dst, SchemaValidator.PublishSchemaFileName)
+ ?? throw new FileNotFoundException($"找不到发布 XSD: {SchemaValidator.PublishSchemaFileName}");
+ SchemaValidator.EnsureValid(flatPath, publishSchema, "发布物");
+
+ ValidateHard(flatPath, dst);
+ }
+ catch
+ {
+ if (Directory.Exists(dst))
+ {
+ try { Directory.Delete(dst, recursive: true); } catch { /* 尽力清理半残产物 */ }
+ }
+ throw;
+ }
+ }
+
+ ///
+ /// 从本地路径或 HTTP(S) URL 加载已展平的 metadata.xml。
+ ///
+ public static Metadata Load(string pathOrUrl)
+ {
+ if (string.IsNullOrWhiteSpace(pathOrUrl))
+ throw new ArgumentException("路径或 URL 不能为空", nameof(pathOrUrl));
+
+ if (Uri.TryCreate(pathOrUrl, UriKind.Absolute, out var uri)
+ && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps))
+ {
+ return LoadFromUrl(uri);
+ }
+
+ return Metadata.LoadFromFile(pathOrUrl);
+ }
+
+ private static Metadata LoadFromUrl(Uri uri)
+ {
+ using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(60) };
+ using var stream = client.GetStreamAsync(uri).GetAwaiter().GetResult();
+ var temp = Path.Combine(Path.GetTempPath(), $"metadata-url-{Guid.NewGuid():N}.xml");
+ try
+ {
+ using (var fs = File.Create(temp))
+ stream.CopyTo(fs);
+ return Metadata.LoadFromFile(temp);
+ }
+ finally
+ {
+ try { File.Delete(temp); } catch { /* ignore */ }
+ }
+ }
+
+ private static void CopyAll(string src, string dst)
+ {
+ foreach (var file in Directory.GetFiles(src, "*", SearchOption.AllDirectories))
+ {
+ var rel = Path.GetRelativePath(src, file);
+ var target = Path.Combine(dst, rel);
+ var dir = Path.GetDirectoryName(target);
+ if (!string.IsNullOrEmpty(dir))
+ Directory.CreateDirectory(dir);
+ File.Copy(file, target, overwrite: true);
+ }
+ }
+
+ private static List FindLeftoverVariables(string xmlPath)
+ {
+ var text = File.ReadAllText(xmlPath);
+ return LeftoverVariablePattern.Matches(text).Select(m => m.Value).Distinct().ToList();
+ }
+
+ private static void ValidateHard(string flatPath, string outputDir)
+ {
+ var metadata = Metadata.LoadFromFile(flatPath);
+ var errors = new List();
+
+ // 仅「登记节点」(带 ID 属性)进入索引;Style 内 id 引用不算登记
+ var idIndex = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var registryImages = metadata.GetAllElements("Image").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList();
+ var registryMarkdowns = metadata.GetAllElements("Markdown").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList();
+ var registryManifests = metadata.GetAllElements("Manifest").Where(n => !string.IsNullOrWhiteSpace(n.Get("ID"))).ToList();
+
+ foreach (var node in registryImages.Concat(registryMarkdowns).Concat(registryManifests))
+ idIndex.Add(node.Get("ID")!);
+
+ foreach (var md in registryMarkdowns)
+ {
+ var id = md.Get("ID")!;
+ var source = md.Get("Source");
+ if (string.IsNullOrWhiteSpace(source))
+ {
+ errors.Add($"Markdown '{id}' 缺少 Source");
+ continue;
+ }
+ var path = Path.Combine(outputDir, source.Replace('\\', '/'));
+ if (!File.Exists(path))
+ errors.Add($"Markdown 资源不存在: {source} (ID: {id})");
+ }
+
+ foreach (var image in registryImages)
+ {
+ var id = image.Get("ID")!;
+ var url = image.Get("Url");
+ var source = image.Get("Source");
+ if (!string.IsNullOrWhiteSpace(url))
+ continue;
+ if (string.IsNullOrWhiteSpace(source))
+ {
+ errors.Add($"Image '{id}' 缺少 Source 或 Url");
+ continue;
+ }
+ var path = Path.Combine(outputDir, source.Replace('\\', '/'));
+ if (!File.Exists(path))
+ errors.Add($"Image 资源不存在: {source} (ID: {id})");
+ }
+
+ foreach (var manifest in registryManifests)
+ {
+ var id = manifest.Get("ID")!;
+ var source = manifest.Get("Source");
+ if (!string.IsNullOrWhiteSpace(source))
+ {
+ var path = Path.Combine(outputDir, source.Replace('\\', '/'));
+ if (!File.Exists(path))
+ errors.Add($"Manifest 资源不存在: {source} (ID: {id})");
+ }
+ }
+
+ foreach (var app in metadata.GetAllElements("Application").Concat(metadata.GetAllElements("Mod")))
+ {
+ ValidateIdRef(app.Find("Icon")?.Value, "Icon", idIndex, errors);
+ var packages = app.Find("Packages");
+ if (packages == null) continue;
+ foreach (var package in packages.Children.Where(c => c.Name == "Package"))
+ {
+ ValidateIdRef(package.Find("Manifest")?.Value, "Manifest", idIndex, errors);
+ var changelogs = package.Find("Changelogs");
+ if (changelogs == null) continue;
+ foreach (var cl in changelogs.Children.Where(c => c.Name == "Changelog"))
+ ValidateIdRef(cl.Value, "Changelog", idIndex, errors);
+ }
+
+ var posts = app.Find("Posts");
+ if (posts == null) continue;
+ foreach (var post in posts.Children.Where(c => c.Name == "Post"))
+ {
+ var contents = post.Find("Contents");
+ if (contents == null) continue;
+ foreach (var content in contents.Children.Where(c => c.Name == "Content"))
+ ValidateIdRef(content.Value, "Post Content", idIndex, errors);
+ }
+
+ var style = app.Find("Style");
+ if (style == null) continue;
+ var logo = style.Find("Logo");
+ if (logo?.Value != null)
+ ValidateIdRef(logo.Value.Trim(), "Logo", idIndex, errors);
+ var background = style.Find("Background");
+ if (background != null)
+ {
+ foreach (var img in background.Children.Where(c => c.Name == "Image"))
+ ValidateIdRef(img.Value?.Trim(), "Background Image", idIndex, errors);
+ }
+ }
+
+ if (string.IsNullOrWhiteSpace(metadata.Get("SchemaVersion"))
+ && metadata.Find("SchemaVersion") == null)
+ {
+ // 属性写在根上
+ if (!metadata.Variables.ContainsKey("SchemaVersion"))
+ errors.Add("缺少 SchemaVersion");
+ }
+
+ if (!metadata.Variables.ContainsKey("ContentRevision") && metadata.Find("ContentRevision") == null)
+ errors.Add("缺少 ContentRevision");
+
+ if (errors.Count > 0)
+ throw new InvalidOperationException("核心构建校验失败:\n- " + string.Join("\n- ", errors));
+ }
+
+ private static void ValidateIdRef(string? id, string kind, HashSet idIndex, List errors)
+ {
+ if (string.IsNullOrWhiteSpace(id)) return;
+ if (!idIndex.Contains(id))
+ errors.Add($"{kind} 引用未找到 ID: {id}");
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/MetadataFlattener.cs b/Ra3.BattleNet.Metadata/MetadataFlattener.cs
new file mode 100644
index 0000000..5b1f136
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/MetadataFlattener.cs
@@ -0,0 +1,280 @@
+using System.Xml.Linq;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// 将带 Include 的源 XML 展平为单一数据树(不内联资源文件正文)。
+/// 资源登记 ID 展平为全局唯一:{路径前缀}:{localId},路径前缀为定义文件相对源根、去 .xml 的路径。
+///
+public static class MetadataFlattener
+{
+ ///
+ /// 从入口 metadata.xml 展平,Source 路径改写为相对 sourceRoot,ID 按定义文件加前缀。
+ ///
+ public static XDocument Flatten(string entryPath, string sourceRoot, string schemaVersion, string contentRevision)
+ {
+ var fullEntry = Path.GetFullPath(entryPath);
+ var rootFull = Path.GetFullPath(sourceRoot);
+ var processing = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var root = LoadAndExpand(fullEntry, rootFull, processing);
+
+ // Include 展开后、写出版本前:合并 Base/InheritFrom,发布物无继承痕迹
+ MetadataInheritance.Resolve(root);
+
+ root.SetAttributeValue("SchemaVersion", schemaVersion);
+ root.SetAttributeValue("ContentRevision", contentRevision);
+
+ root.Descendants().Where(e => e.Name.LocalName is "Includes" or "Include" or "Module").ToList()
+ .ForEach(e => e.Remove());
+
+ return new XDocument(new XDeclaration("1.0", "utf-8", null), root);
+ }
+
+ /// 由定义文件绝对路径生成路径前缀(相对 sourceRoot、/ 分隔、无扩展名)。
+ public static string PathPrefix(string absoluteFilePath, string sourceRoot)
+ {
+ var rel = ToRootRelative(Path.GetFullPath(absoluteFilePath), Path.GetFullPath(sourceRoot));
+ if (rel.EndsWith(".xml", StringComparison.OrdinalIgnoreCase))
+ rel = rel[..^4];
+ return rel.Replace('\\', '/');
+ }
+
+ /// 将 localId 限定为 {prefix}:{localId};已含 ':' 则原样返回。
+ public static string QualifyId(string pathPrefix, string localId)
+ {
+ if (string.IsNullOrWhiteSpace(localId))
+ throw new InvalidOperationException("ID 不能为空");
+ if (localId.Contains(':', StringComparison.Ordinal))
+ return localId;
+ if (string.IsNullOrWhiteSpace(pathPrefix))
+ return localId;
+ return $"{pathPrefix}:{localId}";
+ }
+
+ /// 取限定 ID 的局部名(最后一个 ':' 之后);无冒号则整串。
+ public static string LocalId(string? id)
+ {
+ if (string.IsNullOrEmpty(id))
+ return string.Empty;
+ var i = id.LastIndexOf(':');
+ return i < 0 ? id : id[(i + 1)..];
+ }
+
+ private static XElement LoadAndExpand(string filePath, string sourceRoot, HashSet processing)
+ {
+ var full = Path.GetFullPath(filePath);
+ if (!processing.Add(full))
+ throw new InvalidOperationException($"检测到循环引用: {full}");
+
+ if (!File.Exists(full))
+ throw new FileNotFoundException($"找不到文件: {full}");
+
+ var doc = XDocument.Load(full);
+ var root = doc.Root ?? throw new InvalidOperationException($"无效 XML: {full}");
+ if (root.Name.LocalName != "Metadata")
+ throw new InvalidOperationException($"根节点必须是 Metadata: {full}");
+
+ var baseDir = Path.GetDirectoryName(full)
+ ?? throw new InvalidOperationException($"无法确定目录: {full}");
+ var prefix = PathPrefix(full, sourceRoot);
+
+ ExpandElement(root, baseDir, sourceRoot, processing);
+ RewriteSources(root, baseDir, sourceRoot);
+ QualifyRegistrationIds(root, prefix);
+ var scope = BuildScopeMap(root);
+ RewriteIdReferences(root, scope);
+
+ processing.Remove(full);
+ return root;
+ }
+
+ private static void ExpandElement(XElement element, string baseDir, string sourceRoot, HashSet processing)
+ {
+ var includeHosts = element.Elements()
+ .Where(e => e.Name.LocalName is "Includes" or "Include" or "Module")
+ .ToList();
+
+ foreach (var host in includeHosts)
+ {
+ if (host.Name.LocalName is "Include" or "Module")
+ {
+ InsertInclude(host, baseDir, sourceRoot, processing);
+ host.Remove();
+ }
+ else
+ {
+ foreach (var inc in host.Elements().Where(e => e.Name.LocalName is "Include" or "Module").ToList())
+ {
+ InsertInclude(inc, baseDir, sourceRoot, processing);
+ inc.Remove();
+ }
+ if (!host.HasElements)
+ host.Remove();
+ }
+ }
+
+ foreach (var child in element.Elements().ToList())
+ {
+ if (child.Name.LocalName is "Includes" or "Include" or "Module")
+ continue;
+ ExpandElement(child, baseDir, sourceRoot, processing);
+ }
+ }
+
+ private static void InsertInclude(XElement includeEl, string baseDir, string sourceRoot, HashSet processing)
+ {
+ var rel = includeEl.Attribute("Source")?.Value ?? includeEl.Attribute("Path")?.Value;
+ if (string.IsNullOrWhiteSpace(rel))
+ throw new InvalidOperationException("Include/Module 缺少 Source/Path");
+
+ var target = Path.GetFullPath(Path.Combine(baseDir, rel.Replace('\\', '/')));
+ var includedRoot = LoadAndExpand(target, sourceRoot, processing);
+
+ var anchor = includeEl.Parent is { Name.LocalName: "Includes" } host
+ ? host
+ : includeEl;
+
+ foreach (var child in includedRoot.Elements().ToList())
+ {
+ if (child.Name.LocalName is "Tags" or "Includes")
+ continue;
+
+ if (child.Name.LocalName == "Manifest")
+ {
+ var id = child.Attribute("ID")?.Value;
+ if (string.IsNullOrWhiteSpace(id))
+ throw new InvalidOperationException($"Manifest 缺少 ID: {target}");
+
+ var existingSource = child.Attribute("Source")?.Value;
+ if (!string.IsNullOrWhiteSpace(existingSource))
+ {
+ // 已是下层 stub,ID 应已限定
+ anchor.AddBeforeSelf(new XElement(child));
+ continue;
+ }
+
+ var sourceRel = ToRootRelative(target, sourceRoot);
+ var qualified = id.Contains(':', StringComparison.Ordinal)
+ ? id
+ : QualifyId(PathPrefix(target, sourceRoot), id);
+ var stub = new XElement("Manifest",
+ new XAttribute("ID", qualified),
+ new XAttribute("Source", sourceRel));
+ anchor.AddBeforeSelf(stub);
+ continue;
+ }
+
+ anchor.AddBeforeSelf(new XElement(child));
+ }
+ }
+
+ /// 仅限定本文件定义、尚未带 ':' 的登记节点 ID(Image/Markdown/Manifest)。
+ private static void QualifyRegistrationIds(XElement root, string pathPrefix)
+ {
+ foreach (var el in root.DescendantsAndSelf()
+ .Where(e => e.Name.LocalName is "Image" or "Markdown" or "Manifest"))
+ {
+ var idAttr = el.Attribute("ID");
+ if (idAttr == null || string.IsNullOrWhiteSpace(idAttr.Value))
+ continue;
+ // Background 内无 ID 的 Image 引用跳过;登记节点必有 ID
+ if (!idAttr.Value.Contains(':', StringComparison.Ordinal))
+ idAttr.Value = QualifyId(pathPrefix, idAttr.Value);
+ }
+ }
+
+ private static Dictionary BuildScopeMap(XElement root)
+ {
+ // short localId → qualifiedId;同文件作用域内短名冲突则失败
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var el in root.DescendantsAndSelf()
+ .Where(e => e.Name.LocalName is "Image" or "Markdown" or "Manifest"))
+ {
+ var id = el.Attribute("ID")?.Value;
+ if (string.IsNullOrWhiteSpace(id))
+ continue;
+
+ // 完整限定 ID 始终可查
+ map[id] = id;
+
+ var local = LocalId(id);
+ if (map.TryGetValue(local, out var existing) &&
+ !string.Equals(existing, id, StringComparison.Ordinal))
+ {
+ // 跨 Include 同短名:短名不可再解析(应在各自文件内已改写引用),移出短名键
+ map.Remove(local);
+ continue;
+ }
+ map[local] = id;
+ }
+ return map;
+ }
+
+ private static void RewriteIdReferences(XElement root, Dictionary scope)
+ {
+ foreach (var el in root.DescendantsAndSelf())
+ {
+ switch (el.Name.LocalName)
+ {
+ case "Icon":
+ case "Manifest" when el.Attribute("ID") == null && !el.HasElements:
+ case "Changelog":
+ case "Content":
+ RewriteTextRef(el, scope);
+ break;
+ case "Logo":
+ RewriteTextRef(el, scope);
+ break;
+ case "Image" when el.Attribute("ID") == null:
+ // Background 等处的 ID 文本引用
+ RewriteTextRef(el, scope);
+ break;
+ }
+ }
+ }
+
+ private static void RewriteTextRef(XElement el, Dictionary scope)
+ {
+ var text = el.Value?.Trim();
+ if (string.IsNullOrEmpty(text))
+ return;
+ if (scope.TryGetValue(text, out var qualified))
+ el.Value = qualified;
+ // 找不到:留给 ValidateHard 报断引用(可能是笔误)
+ }
+
+ private static void RewriteSources(XElement element, string baseDir, string sourceRoot)
+ {
+ foreach (var el in element.DescendantsAndSelf().Where(e => e.Name.LocalName is "Image" or "Markdown"))
+ {
+ var sourceAttr = el.Attribute("Source");
+ if (sourceAttr == null || string.IsNullOrWhiteSpace(sourceAttr.Value))
+ continue;
+ if (!string.IsNullOrWhiteSpace(el.Attribute("Url")?.Value))
+ continue;
+ // 仅处理登记节点(有 ID)
+ if (el.Attribute("ID") == null)
+ continue;
+
+ var raw = sourceAttr.Value.Replace('\\', '/');
+ var fromBase = Path.GetFullPath(Path.Combine(baseDir, raw));
+ if (File.Exists(fromBase))
+ {
+ sourceAttr.Value = ToRootRelative(fromBase, sourceRoot);
+ continue;
+ }
+
+ var fromRoot = Path.GetFullPath(Path.Combine(sourceRoot, raw));
+ if (File.Exists(fromRoot))
+ sourceAttr.Value = ToRootRelative(fromRoot, sourceRoot);
+ }
+ }
+
+ private static string ToRootRelative(string absolutePath, string sourceRoot)
+ {
+ var rel = Path.GetRelativePath(sourceRoot, absolutePath).Replace('\\', '/');
+ if (rel.StartsWith("..", StringComparison.Ordinal))
+ throw new InvalidOperationException($"资源不在源目录内: {absolutePath}");
+ return rel;
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/MetadataInheritance.cs b/Ra3.BattleNet.Metadata/MetadataInheritance.cs
new file mode 100644
index 0000000..6ab2b40
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/MetadataInheritance.cs
@@ -0,0 +1,244 @@
+using System.Xml.Linq;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// 源树 Base + InheritFrom 合并。仅构建期;发布物无 Base/InheritFrom。
+///
+public static class MetadataInheritance
+{
+ private static readonly string[] ModChildOrder =
+ ["CurrentVersion", "Icon", "Style", "Packages", "Posts", "Defines"];
+
+ private static readonly string[] ApplicationChildOrder =
+ ["Version", "Packages", "Posts", "Defines"];
+
+ private static readonly string[] StyleChildOrder = ["Logo", "Controls", "Background"];
+
+ ///
+ /// 在已展开的根树上解析继承:合并后去掉 InheritFrom,并删除全部 Base。
+ ///
+ public static void Resolve(XElement root)
+ {
+ if (root.Name.LocalName != "Metadata")
+ throw new InvalidOperationException("继承解析要求根节点为 Metadata");
+
+ var bases = root.Elements().Where(e => e.Name.LocalName == "Base").ToList();
+ var baseMap = new Dictionary(StringComparer.Ordinal);
+
+ foreach (var baseEl in bases)
+ {
+ var id = baseEl.Attribute("ID")?.Value;
+ var kind = baseEl.Attribute("Kind")?.Value;
+ if (string.IsNullOrWhiteSpace(id))
+ throw new InvalidOperationException("Base 缺少 ID");
+ if (kind is not ("Mod" or "Application"))
+ throw new InvalidOperationException($"Base '{id}' 的 Kind 必须是 Mod 或 Application");
+ if (!baseMap.TryAdd(id, baseEl))
+ throw new InvalidOperationException($"Base ID 重复: {id}");
+ }
+
+ foreach (var entity in root.Elements().Where(e => e.Name.LocalName is "Mod" or "Application"))
+ {
+ var id = entity.Attribute("ID")?.Value;
+ if (string.IsNullOrWhiteSpace(id))
+ continue;
+ if (baseMap.ContainsKey(id))
+ throw new InvalidOperationException($"Base ID 与 {entity.Name.LocalName} ID 冲突: {id}");
+ }
+
+ foreach (var entity in root.Elements().Where(e => e.Name.LocalName is "Mod" or "Application").ToList())
+ {
+ var inherit = entity.Attribute("InheritFrom")?.Value;
+ if (string.IsNullOrWhiteSpace(inherit))
+ continue;
+
+ if (!baseMap.TryGetValue(inherit, out var baseEl))
+ throw new InvalidOperationException(
+ $"{entity.Name.LocalName} '{entity.Attribute("ID")?.Value}' 的 InheritFrom 找不到 Base: {inherit}");
+
+ var kind = baseEl.Attribute("Kind")!.Value;
+ if (!string.Equals(kind, entity.Name.LocalName, StringComparison.Ordinal))
+ {
+ throw new InvalidOperationException(
+ $"{entity.Name.LocalName} '{entity.Attribute("ID")?.Value}' 不能继承 Kind={kind} 的 Base '{inherit}'");
+ }
+
+ var merged = MergeEntity(baseEl, entity);
+ entity.ReplaceWith(merged);
+ }
+
+ foreach (var baseEl in root.Elements().Where(e => e.Name.LocalName == "Base").ToList())
+ baseEl.Remove();
+
+ var leftover = root.DescendantsAndSelf()
+ .Where(e => e.Attribute("InheritFrom") != null)
+ .Select(e => $"{e.Name.LocalName}:{e.Attribute("ID")?.Value}")
+ .ToList();
+ if (leftover.Count > 0)
+ throw new InvalidOperationException("仍有未解析的 InheritFrom: " + string.Join(", ", leftover));
+ }
+
+ internal static XElement MergeEntity(XElement baseEl, XElement child)
+ {
+ var result = new XElement(child.Name);
+
+ foreach (var attr in baseEl.Attributes())
+ {
+ if (attr.Name.LocalName is "ID" or "Kind" or "InheritFrom")
+ continue;
+ result.SetAttributeValue(attr.Name, attr.Value);
+ }
+
+ foreach (var attr in child.Attributes())
+ {
+ if (attr.Name.LocalName == "InheritFrom")
+ continue;
+ result.SetAttributeValue(attr.Name, attr.Value);
+ }
+
+ if (string.IsNullOrWhiteSpace(result.Attribute("ID")?.Value))
+ throw new InvalidOperationException($"{child.Name.LocalName} 合并后缺少 ID");
+
+ var order = child.Name.LocalName == "Mod" ? ModChildOrder : ApplicationChildOrder;
+ var known = new HashSet(order, StringComparer.Ordinal);
+
+ foreach (var name in order)
+ {
+ var fromBase = baseEl.Element(name);
+ var fromChild = child.Element(name);
+ if (fromBase == null && fromChild == null)
+ continue;
+
+ if (fromChild == null)
+ {
+ result.Add(new XElement(fromBase!));
+ continue;
+ }
+
+ if (fromBase == null)
+ {
+ result.Add(new XElement(fromChild));
+ continue;
+ }
+
+ if (name == "Style")
+ result.Add(MergeStyle(fromBase, fromChild));
+ else
+ result.Add(new XElement(fromChild));
+ }
+
+ // 子节点独有、不在白名单顺序表中的元素(扩展口)
+ foreach (var el in child.Elements())
+ {
+ if (known.Contains(el.Name.LocalName))
+ continue;
+ result.Add(new XElement(el));
+ }
+
+ foreach (var el in baseEl.Elements())
+ {
+ if (known.Contains(el.Name.LocalName))
+ continue;
+ if (result.Element(el.Name) != null)
+ continue;
+ result.Add(new XElement(el));
+ }
+
+ return result;
+ }
+
+ internal static XElement MergeStyle(XElement baseStyle, XElement childStyle)
+ {
+ var result = new XElement("Style");
+ foreach (var attr in baseStyle.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+ foreach (var attr in childStyle.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+
+ foreach (var name in StyleChildOrder)
+ {
+ var fromBase = baseStyle.Element(name);
+ var fromChild = childStyle.Element(name);
+ if (fromBase == null && fromChild == null)
+ continue;
+
+ if (fromChild == null)
+ {
+ result.Add(new XElement(fromBase!));
+ continue;
+ }
+
+ if (fromBase == null || name is "Logo" or "Background")
+ {
+ result.Add(new XElement(fromChild));
+ continue;
+ }
+
+ // Controls 深合并
+ result.Add(MergeControls(fromBase, fromChild));
+ }
+
+ return result;
+ }
+
+ internal static XElement MergeControls(XElement baseControls, XElement childControls)
+ {
+ var result = new XElement("Controls");
+ foreach (var attr in baseControls.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+ foreach (var attr in childControls.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+
+ var map = new Dictionary(StringComparer.Ordinal);
+ foreach (var control in baseControls.Elements())
+ map[control.Name.LocalName] = new XElement(control);
+
+ foreach (var control in childControls.Elements())
+ {
+ var name = control.Name.LocalName;
+ if (!map.TryGetValue(name, out var existing))
+ {
+ map[name] = new XElement(control);
+ continue;
+ }
+
+ map[name] = MergeNamedBag(existing, control);
+ }
+
+ foreach (var control in map.Values)
+ result.Add(control);
+
+ return result;
+ }
+
+ /// 同名控件:按子元素名覆盖(如 FontSize),子缺省保留 Base。
+ internal static XElement MergeNamedBag(XElement baseControl, XElement childControl)
+ {
+ var result = new XElement(baseControl.Name);
+ foreach (var attr in baseControl.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+ foreach (var attr in childControl.Attributes())
+ result.SetAttributeValue(attr.Name, attr.Value);
+
+ var fields = new Dictionary(StringComparer.Ordinal);
+ foreach (var field in baseControl.Elements())
+ fields[field.Name.LocalName] = new XElement(field);
+ foreach (var field in childControl.Elements())
+ fields[field.Name.LocalName] = new XElement(field);
+
+ foreach (var field in fields.Values)
+ result.Add(field);
+
+ if (!baseControl.HasElements && !childControl.HasElements)
+ {
+ var text = childControl.Value;
+ if (string.IsNullOrEmpty(text))
+ text = baseControl.Value;
+ if (!string.IsNullOrEmpty(text))
+ result.Value = text;
+ }
+
+ return result;
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/MetadataParser.cs b/Ra3.BattleNet.Metadata/MetadataParser.cs
index 2194df5..6c3e591 100644
--- a/Ra3.BattleNet.Metadata/MetadataParser.cs
+++ b/Ra3.BattleNet.Metadata/MetadataParser.cs
@@ -70,33 +70,11 @@ public static Metadata LoadFromFileWithSchema(string path, string? schemaPath =
///
public static List ValidateWithSchema(string xmlPath, string? schemaPath = null)
{
- var errors = new List();
-
schemaPath ??= FindSchemaFile(xmlPath);
if (schemaPath == null || !File.Exists(schemaPath))
- {
- errors.Add($"找不到 XSD Schema 文件");
- return errors;
- }
-
- try
- {
- var schemas = new XmlSchemaSet();
- using var schemaReader = XmlReader.Create(schemaPath);
- schemas.Add(null, schemaReader);
-
- var doc = XDocument.Load(xmlPath);
- doc.Validate(schemas, (_, e) =>
- {
- errors.Add(e.Message);
- });
- }
- catch (Exception ex)
- {
- errors.Add($"Schema 验证失败: {ex.Message}");
- }
+ return ["找不到 XSD Schema 文件"];
- return errors;
+ return SchemaValidator.ValidateFile(xmlPath, schemaPath).ToList();
}
///
@@ -192,9 +170,8 @@ private static XDocument LoadXDocumentWithSchema(string fullPath, string schemaP
if (validationErrors.Count > 0)
{
- Console.WriteLine($" Schema 验证警告 ({Path.GetFileName(fullPath)}):");
- foreach (var err in validationErrors)
- Console.WriteLine($" - {err}");
+ throw new InvalidOperationException(
+ $"XSD 校验失败 ({Path.GetFileName(fullPath)}):\n- " + string.Join("\n- ", validationErrors));
}
return doc;
diff --git a/Ra3.BattleNet.Metadata/Program.cs b/Ra3.BattleNet.Metadata/Program.cs
index ed88ab0..217f300 100644
--- a/Ra3.BattleNet.Metadata/Program.cs
+++ b/Ra3.BattleNet.Metadata/Program.cs
@@ -1,187 +1,93 @@
-using System;
-using System.IO;
+namespace Ra3.BattleNet.Metadata;
-namespace Ra3.BattleNet.Metadata
+internal static class Program
{
- internal class Program
- {
- private const string DefaultSrcFolder = "./Metadata";
- private const string DefaultDstFolder = "./Output";
+ private const string DefaultSrcFolder = "./Metadata";
+ private const string DefaultDstFolder = "./Output";
- static void Main(string[] args)
+ private static int Main(string[] args)
+ {
+ var srcFolder = DefaultSrcFolder;
+ var dstFolder = DefaultDstFolder;
+ string? schemaVersion = null;
+ string? contentRevision = null;
+ var command = "build";
+ var withWebp = false;
+
+ foreach (var arg in args)
{
- // 解析命令行参数
- var srcFolder = DefaultSrcFolder;
- var dstFolder = DefaultDstFolder;
- var skipSchema = false;
- var skipWebp = false;
-
- foreach (string arg in args)
+ if (arg is "build" or "stage-a")
+ command = "build";
+ else if (arg is "--webp" or "--with-webp" or "--publish")
+ withWebp = true;
+ else if (arg.StartsWith("--src=", StringComparison.Ordinal))
+ srcFolder = arg["--src=".Length..];
+ else if (arg.StartsWith("--dst=", StringComparison.Ordinal))
+ dstFolder = arg["--dst=".Length..];
+ else if (arg.StartsWith("--schema-version=", StringComparison.Ordinal))
+ schemaVersion = arg["--schema-version=".Length..];
+ else if (arg.StartsWith("--content-revision=", StringComparison.Ordinal))
+ contentRevision = arg["--content-revision=".Length..];
+ else if (arg is "--help" or "-h")
{
- if (arg.StartsWith("--src="))
- srcFolder = arg.Split('=')[1];
- else if (arg.StartsWith("--dst="))
- dstFolder = arg.Split('=')[1];
- else if (arg == "--skip-schema")
- skipSchema = true;
- else if (arg == "--skip-webp")
- skipWebp = true;
+ PrintHelp();
+ return 0;
}
+ }
- Console.WriteLine($"工作目录: {Environment.CurrentDirectory}");
- Console.WriteLine($"元数据源目录: {srcFolder}");
- Console.WriteLine($"输出目录: {dstFolder}");
- Console.WriteLine();
+ Console.WriteLine($"工作目录: {Environment.CurrentDirectory}");
+ Console.WriteLine($"源目录: {srcFolder}");
+ Console.WriteLine($"输出目录: {dstFolder}");
+ Console.WriteLine($"WebP: {(withWebp ? "开(展平后按图调 Imaging CLI)" : "关")}");
+ Console.WriteLine();
- try
+ try
+ {
+ if (command != "build")
{
- // 创建输出目录
- if (!Directory.Exists(dstFolder))
- Directory.CreateDirectory(dstFolder);
-
- // 1. 加载元数据(带 XSD Schema 验证)
- string metadataPath = Path.Combine(srcFolder, "metadata.xml");
- Console.WriteLine($"加载元数据: {metadataPath}");
-
- Metadata metadata;
- if (!skipSchema)
- {
- var schemaPath = Path.Combine(srcFolder, "MetadataSchema.xsd");
- if (File.Exists(schemaPath))
- {
- Console.WriteLine(" 使用 XSD Schema 验证...");
- metadata = Metadata.LoadFromFileWithSchema(metadataPath, schemaPath);
- }
- else
- {
- metadata = Metadata.LoadFromFile(metadataPath);
- }
- }
- else
- {
- metadata = Metadata.LoadFromFile(metadataPath);
- }
- Console.WriteLine($"✓ 成功加载元数据({metadata.Children.Count} 个子节点)");
-
- // 2. 验证 Markdown 引用
- Console.WriteLine("验证 Markdown 文件引用...");
- var mdErrors = MarkdownValidator.ValidateAll(metadata, srcFolder);
- if (!mdErrors.IsValid)
- {
- Console.WriteLine($" ⚠ Markdown 验证发现 {mdErrors.Errors.Count} 个问题:");
- foreach (var err in mdErrors.Errors)
- Console.WriteLine($" - {err}");
- }
- else
- {
- Console.WriteLine(" ✓ 所有 Markdown 引用有效");
- }
-
- // 3. 复制所有文件到输出目录
- Console.WriteLine("复制文件到输出目录...");
- CopyFiles(srcFolder, dstFolder);
- Console.WriteLine("✓ 文件复制完成");
-
- // 4. 图片 WebP 转换
- if (!skipWebp)
- {
- Console.WriteLine("转换图片为 WebP 格式...");
- int converted = ImageProcessor.ConvertImagesToWebP(metadata, dstFolder);
- if (converted > 0)
- {
- Console.WriteLine($" ✓ 转换了 {converted} 张图片");
- Console.WriteLine(" 更新 XML 中的图片引用...");
- ImageProcessor.UpdateImageSourceReferences(dstFolder);
- }
- else
- {
- Console.WriteLine(" - 没有需要转换的图片");
- }
- }
-
- // 5. 替换变量(${TIMESTAMP}, ${ENV:...}, ${MD5::})在输出目录中
- string outputMetadataPath = Path.Combine(dstFolder, "metadata.xml");
- Console.WriteLine("替换变量和计算哈希...");
- var outputMetadata = Metadata.LoadFromFile(outputMetadataPath);
- outputMetadata.ReplaceVariablesInFile(outputMetadataPath);
- Console.WriteLine("✓ 变量替换完成");
-
- // 6. 验证处理后的元数据
- Console.WriteLine("验证处理后的元数据...");
- var verifiedMetadata = Metadata.LoadFromFile(outputMetadataPath);
- Console.WriteLine("✓ 验证成功");
- Console.WriteLine();
-
- // 显示元数据信息
- Console.WriteLine("=== 元数据信息 ===");
- Console.WriteLine($"根节点: {verifiedMetadata.Name}");
- Console.WriteLine($"子节点数: {verifiedMetadata.Children.Count}");
-
- var commit = verifiedMetadata.Get("Commit");
- if (!string.IsNullOrEmpty(commit))
- Console.WriteLine($"Commit: {commit}");
-
- // 显示 Include 树结构
- Console.WriteLine();
- Console.WriteLine("=== Include 引用树 ===");
- Console.WriteLine(verifiedMetadata.GetIncludeTree());
-
- Console.WriteLine("=== 业务实体概览 ===");
- var entities = verifiedMetadata.GetBusinessEntities();
- foreach (var entity in entities)
- {
- var id = string.IsNullOrWhiteSpace(entity.Id) ? "" : entity.Id;
- Console.WriteLine($"- [{entity.EntityType}] {id} @ {entity.Path}");
- }
+ Console.Error.WriteLine($"未知命令: {command}");
+ return 1;
+ }
- Console.WriteLine();
- Console.WriteLine("=== 查询 API 示例(metadata.Mods()) ===");
- foreach (var mod in verifiedMetadata.Mods())
- {
- Console.WriteLine($"Mod={mod.Id}, Version={mod.Version}, Packages={mod.Packages.Count}");
- }
+ Console.WriteLine(">>> 构建(展平 / 校验" + (withWebp ? " / Imaging" : "") + ")");
+ MetadataBuilder.Build(srcFolder, dstFolder, schemaVersion, contentRevision, convertImages: withWebp);
+ Console.WriteLine("✓ 构建完成");
- Console.WriteLine();
- Console.WriteLine("处理完成!");
- }
- catch (InvalidOperationException ex) when (ex.Message.Contains("循环引用"))
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine($"错误: {ex.Message}");
- Console.ResetColor();
- Environment.Exit(1);
- }
- catch (Exception ex)
- {
- Console.ForegroundColor = ConsoleColor.Red;
- Console.WriteLine($"处理过程中发生错误: {ex.Message}");
- Console.ResetColor();
- Environment.Exit(1);
- }
+ var flatPath = Path.Combine(dstFolder, "metadata.xml");
+ var loaded = MetadataBuilder.Load(flatPath);
+ Console.WriteLine();
+ Console.WriteLine("=== 展平结果 ===");
+ Console.WriteLine($"SchemaVersion={loaded.Get("SchemaVersion")}");
+ Console.WriteLine($"ContentRevision={loaded.Get("ContentRevision")}");
+ foreach (var app in loaded.Applications())
+ Console.WriteLine($"Application id={app.Id} version={app.Version}");
+ foreach (var mod in loaded.Mods())
+ Console.WriteLine($"Mod id={mod.Id} version={mod.Version}");
+
+ return 0;
}
-
- ///
- /// 复制源目录的所有文件到目标目录,保持目录结构。
- ///
- private static void CopyFiles(string srcFolder, string dstFolder)
+ catch (Exception ex)
{
- var files = Directory.GetFiles(srcFolder, "*.*", SearchOption.AllDirectories);
- int copiedCount = 0;
+ Console.ForegroundColor = ConsoleColor.Red;
+ Console.Error.WriteLine($"错误: {ex.Message}");
+ Console.ResetColor();
+ return 1;
+ }
+ }
- foreach (string file in files)
- {
- string relativePath = Path.GetRelativePath(srcFolder, file);
- string targetFilePath = Path.Combine(dstFolder, relativePath);
+ private static void PrintHelp()
+ {
+ Console.WriteLine("""
+ Ra3.BattleNet.Metadata — 核心构建
- string? targetDir = Path.GetDirectoryName(targetFilePath);
- if (!string.IsNullOrEmpty(targetDir) && !Directory.Exists(targetDir))
- Directory.CreateDirectory(targetDir);
+ 用法:
+ build --src=./Metadata --dst=./Output
+ build --webp --src=./Metadata --dst=./Output
- File.Copy(file, targetFilePath, overwrite: true);
- copiedCount++;
- }
+ --webp: 展平后对每张本地图调用 Imaging CLI(只产 webp+hash),
+ 由本程序改写 metadata.xml 的 Source/Hash。
- Console.WriteLine($" 已复制 {copiedCount} 个文件");
- }
+ NuGet 主包不含 Imaging;Imaging 为仓库编译 CLI。
+ """);
}
}
diff --git a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj
index c6b30e6..daf577d 100644
--- a/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj
+++ b/Ra3.BattleNet.Metadata/Ra3.BattleNet.Metadata.csproj
@@ -5,10 +5,12 @@
net10.0
enable
enable
+
+ true
+ Ra3.BattleNet.Metadata
+ 1.0.0
+ RA3 BattleNet metadata parse/query and pure-managed core build (no Imaging/WebP).
+ RA3BattleNet
-
-
-
-
diff --git a/Ra3.BattleNet.Metadata/SchemaValidator.cs b/Ra3.BattleNet.Metadata/SchemaValidator.cs
new file mode 100644
index 0000000..44a3f67
--- /dev/null
+++ b/Ra3.BattleNet.Metadata/SchemaValidator.cs
@@ -0,0 +1,112 @@
+using System.Xml;
+using System.Xml.Linq;
+using System.Xml.Schema;
+
+namespace Ra3.BattleNet.Metadata;
+
+///
+/// XSD 硬校验。源树用 MetadataSchema.xsd,发布展平物用 MetadataPublishSchema.xsd。
+///
+public static class SchemaValidator
+{
+ public const string SourceSchemaFileName = "MetadataSchema.xsd";
+ public const string PublishSchemaFileName = "MetadataPublishSchema.xsd";
+
+ ///
+ /// 校验单个 XML 文件;返回错误列表(空表示通过)。
+ ///
+ public static IReadOnlyList ValidateFile(string xmlPath, string schemaPath)
+ {
+ if (!File.Exists(xmlPath))
+ return [$"找不到 XML: {xmlPath}"];
+ if (!File.Exists(schemaPath))
+ return [$"找不到 XSD: {schemaPath}"];
+
+ var errors = new List();
+ try
+ {
+ var schemas = new XmlSchemaSet();
+ using (var schemaReader = XmlReader.Create(schemaPath))
+ schemas.Add(null, schemaReader);
+ schemas.Compile();
+
+ var settings = new XmlReaderSettings
+ {
+ DtdProcessing = DtdProcessing.Prohibit,
+ XmlResolver = null,
+ ValidationType = ValidationType.Schema,
+ Schemas = schemas,
+ IgnoreWhitespace = true
+ };
+ settings.ValidationEventHandler += (_, e) =>
+ {
+ errors.Add($"{Path.GetFileName(xmlPath)}: {e.Message}");
+ };
+
+ using var reader = XmlReader.Create(xmlPath, settings);
+ while (reader.Read()) { }
+ }
+ catch (Exception ex)
+ {
+ errors.Add($"{Path.GetFileName(xmlPath)}: Schema 校验异常: {ex.Message}");
+ }
+
+ return errors;
+ }
+
+ ///
+ /// 校验目录下全部 .xml(跳过 .xsd)。
+ ///
+ public static IReadOnlyList ValidateDirectory(string directory, string schemaPath)
+ {
+ var errors = new List();
+ foreach (var xml in Directory.GetFiles(directory, "*.xml", SearchOption.AllDirectories))
+ {
+ errors.AddRange(ValidateFile(xml, schemaPath));
+ }
+ return errors;
+ }
+
+ ///
+ /// 在源目录或输出目录中查找 schema 文件。
+ ///
+ public static string? FindSchema(string startDir, string fileName)
+ {
+ var dir = Path.GetFullPath(startDir);
+ while (true)
+ {
+ var candidate = Path.Combine(dir, fileName);
+ if (File.Exists(candidate))
+ return candidate;
+
+ var nested = Path.Combine(dir, "Metadata", fileName);
+ if (File.Exists(nested))
+ return nested;
+
+ var parent = Path.GetDirectoryName(dir);
+ if (parent == null || parent == dir)
+ break;
+ dir = parent;
+ }
+
+ var cwd = Path.Combine(Environment.CurrentDirectory, "Metadata", fileName);
+ return File.Exists(cwd) ? cwd : null;
+ }
+
+ ///
+ /// 有错误则抛 InvalidOperationException。
+ ///
+ public static void EnsureValid(string xmlPath, string schemaPath, string context)
+ {
+ var errors = ValidateFile(xmlPath, schemaPath);
+ if (errors.Count > 0)
+ throw new InvalidOperationException($"{context} XSD 校验失败:\n- " + string.Join("\n- ", errors));
+ }
+
+ public static void EnsureDirectoryValid(string directory, string schemaPath, string context)
+ {
+ var errors = ValidateDirectory(directory, schemaPath);
+ if (errors.Count > 0)
+ throw new InvalidOperationException($"{context} XSD 校验失败:\n- " + string.Join("\n- ", errors));
+ }
+}
diff --git a/Ra3.BattleNet.Metadata/VariableResolver.cs b/Ra3.BattleNet.Metadata/VariableResolver.cs
index d6462fc..cf65e36 100644
--- a/Ra3.BattleNet.Metadata/VariableResolver.cs
+++ b/Ra3.BattleNet.Metadata/VariableResolver.cs
@@ -26,11 +26,17 @@ public VariableResolver()
public void ReplaceInFile(string filePath)
{
var fullPath = Path.GetFullPath(filePath);
- ReplaceInFileRecursive(fullPath, new HashSet(StringComparer.OrdinalIgnoreCase));
+ ReplaceInFileRecursive(
+ fullPath,
+ new HashSet(StringComparer.OrdinalIgnoreCase),
+ new HashSet(StringComparer.OrdinalIgnoreCase));
}
- private void ReplaceInFileRecursive(string filePath, HashSet processingPaths)
+ private void ReplaceInFileRecursive(string filePath, HashSet processingPaths, HashSet processedPaths)
{
+ if (processedPaths.Contains(filePath))
+ return;
+
if (!processingPaths.Add(filePath))
throw new InvalidOperationException($"检测到循环引用: {filePath}");
@@ -39,10 +45,8 @@ private void ReplaceInFileRecursive(string filePath, HashSet processingP
var basePath = Path.GetDirectoryName(filePath)
?? throw new InvalidOperationException($"无法确定文件目录: {filePath}");
- // 验证所有 Include/Module 引用的资源存在
ValidateResources(root, basePath);
- // 递归处理所有引用的文件
foreach (var include in root.Descendants().Where(e => e.Name.LocalName is "Include" or "Module"))
{
var path = include.Attribute("Path")?.Value ?? include.Attribute("Source")?.Value;
@@ -50,12 +54,14 @@ private void ReplaceInFileRecursive(string filePath, HashSet processingP
var referencedFile = Path.GetFullPath(Path.Combine(basePath, path.Replace('\\', '/')));
if (File.Exists(referencedFile))
- ReplaceInFileRecursive(referencedFile, processingPaths);
+ ReplaceInFileRecursive(referencedFile, processingPaths, processedPaths);
}
- // 替换当前文件中的变量
ReplaceInElement(root, filePath);
doc.Save(filePath);
+
+ processingPaths.Remove(filePath);
+ processedPaths.Add(filePath);
}
///
@@ -65,6 +71,19 @@ public void ReplaceInElement(XElement element, string currentFilePath)
{
foreach (var attr in element.Attributes())
{
+ // Markdown/Image 的 Hash=${MD5::} 按 Source 资源文件计算
+ if (attr.Name.LocalName == "Hash"
+ && attr.Value.Contains("${MD5::}", StringComparison.Ordinal)
+ && element.Attribute("Source") is { Value: { Length: > 0 } source})
+ {
+ var dir = Path.GetDirectoryName(currentFilePath)
+ ?? throw new InvalidOperationException($"无法确定文件目录: {currentFilePath}");
+ var resourcePath = Path.GetFullPath(Path.Combine(dir, source.Replace('\\', '/')));
+ if (!File.Exists(resourcePath))
+ throw new InvalidOperationException($"MD5 目标资源不存在: {source}");
+ attr.Value = attr.Value.Replace("${MD5::}", ComputeFileHash(resourcePath), StringComparison.Ordinal);
+ }
+
attr.Value = Resolve(attr.Value, element, currentFilePath);
}
diff --git a/build.sh b/build.sh
index d0777d7..64ad759 100644
--- a/build.sh
+++ b/build.sh
@@ -1,5 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+# 默认仅核心;--webp 时核心 CLI 内部串联 Imaging
+
+WEBP_FLAG=()
+for arg in "$@"; do
+ case "$arg" in
+ --webp|--publish|--with-webp) WEBP_FLAG=(--webp) ;;
+ --help|-h)
+ cat <<'EOF'
+用法: bash build.sh [--webp]
+
+ (无参数) 仅核心构建 → ./Output
+ --webp 核心 + Imaging(WebP 与 Hash 更新)
+ --publish 同 --webp
+EOF
+ exit 0
+ ;;
+ esac
+done
+
curl -sSL https://dot.net/v1/dotnet-install.sh > dotnet-install.sh
chmod +x dotnet-install.sh
./dotnet-install.sh -c 10.0 -InstallDir ./dotnet
./dotnet/dotnet --version
-./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --src="./Metadata" --dst="./Output"
+
+./dotnet/dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- \
+ build --src="./Metadata" --dst="./Output" "${WEBP_FLAG[@]+"${WEBP_FLAG[@]}"}"
diff --git a/package.json b/package.json
index a9c744b..d54e7f8 100644
--- a/package.json
+++ b/package.json
@@ -3,10 +3,13 @@
"version": "1.0.0",
"private": true,
"scripts": {
- "build": "bash build.sh",
- "deploy": "npm run build && npx wrangler pages deploy Output",
+ "build": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --src=./Metadata --dst=./Output",
+ "build:webp": "dotnet run --project ./Ra3.BattleNet.Metadata --no-launch-profile -- build --webp --src=./Metadata --dst=./Output",
+ "build:release": "npm run build:webp",
+ "deploy": "npm run build:release && npx wrangler pages deploy Output",
"dev": "npx wrangler pages dev Output",
- "preview": "npm run build && npx wrangler pages dev Output"
+ "preview": "npm run build && npx wrangler pages dev Output",
+ "test": "dotnet test Metadata.sln --nologo"
},
"devDependencies": {
"wrangler": "^4.68.0"
diff --git a/src/index.js b/src/index.js
index 9f24063..45d346d 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,25 +1,9 @@
+/**
+ * Cloudflare Pages / Workers 静态资源透传。
+ * 业务数据入口为 /metadata.xml(见 _redirects:/ -> /metadata.xml)。
+ */
export default {
- async fetch(request, env, ctx) {
- // 获取请求的 URL
- const url = new URL(request.url);
- let path = url.pathname;
-
- // 处理根路径
- if (path === '/') {
- path = '/metadata.xml';
- }
-
- // 移除开头的斜杠
- if (path.startsWith('/')) {
- path = path.slice(1);
- }
-
- try {
- // 从 KV 或静态资源获取文件
- // 注意:Cloudflare Workers Sites 会自动处理静态文件
- return await env.ASSETS.fetch(request);
- } catch (e) {
- return new Response('Not Found', { status: 404 });
- }
+ async fetch(request, env) {
+ return env.ASSETS.fetch(request);
},
};
diff --git a/wrangler.toml b/wrangler.toml
index 0d35fb8..3300717 100644
--- a/wrangler.toml
+++ b/wrangler.toml
@@ -5,9 +5,9 @@ compatibility_date = "2026-02-20"
[assets]
directory = "./Output"
-# 构建配置
+# 发布构建:核心展平 + WebP(与本地默认 npm run build 区分)
[build]
-command = "bash build.sh"
+command = "bash build.sh --webp"
# 环境变量配置(如果需要)
# [vars]