diff --git a/config/packer/namespace-discriminator.json b/config/packer/namespace-discriminator.json
new file mode 100644
index 000000000000..0f989d3b643f
--- /dev/null
+++ b/config/packer/namespace-discriminator.json
@@ -0,0 +1,15 @@
+[
+ {
+ "namespace": "ae2stuff",
+ "operator": "displayName",
+ "mappingRule": {
+ "ae2-stuff": "AE2 Stuff",
+ "ae2-stuff-unofficial": "AE2 Stuff Unofficial"
+ },
+ "versionScope": {
+ "forge": [
+ "1.12.2"
+ ]
+ }
+ }
+]
\ No newline at end of file
diff --git a/src/Packer/Helpers/ConfigHelpers.cs b/src/Packer/Helpers/ConfigHelpers.cs
index 08b15468bc18..8cef4c0f1080 100644
--- a/src/Packer/Helpers/ConfigHelpers.cs
+++ b/src/Packer/Helpers/ConfigHelpers.cs
@@ -55,6 +55,32 @@ public static Config RetrieveConfig(string configTemplate, string version)
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
}
+ ///
+ /// 从仓库根目录获取命名空间区分配置
+ ///
+ /// 配置文件位置
+ /// 文件不存在时返回空列表;文件存在但内容非法时抛出异常,宁可打包失败也不发出错误的包
+ public static List RetrieveNamespaceDiscriminators(
+ string path = "./config/packer/namespace-discriminator.json")
+ {
+ var file = new FileInfo(path);
+ if (!file.Exists)
+ {
+ Log.Information("未找到命名空间区分配置({0}),跳过命名空间区分。", path);
+ return new List();
+ }
+
+ file.FullName.LogToDebug("读取文件:{0}");
+
+ using var stream = file.OpenRead();
+ var result = JsonSerializer.Deserialize>(
+ stream,
+ new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
+ if (result is null)
+ throw new InvalidDataException($"The discriminator file {path} cannot have null content.");
+ return result;
+ }
+
///
/// 从给定的命名空间获取策略内容
///
diff --git a/src/Packer/Helpers/EnumerationHelper.cs b/src/Packer/Helpers/EnumerationHelper.cs
index 1736b21a19f6..c40b0fd9c452 100644
--- a/src/Packer/Helpers/EnumerationHelper.cs
+++ b/src/Packer/Helpers/EnumerationHelper.cs
@@ -1,9 +1,11 @@
using Packer.Extensions;
using Packer.Models;
+using Serilog;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
+using System.Text.RegularExpressions;
namespace Packer.Helpers
{
@@ -11,7 +13,8 @@ internal static class EnumerationHelper
{
public static IEnumerable EnumerateUnmerged(IEnumerable targetModIdentifiers,
Config config,
- IEnumerable acceptableVersions)
+ IEnumerable acceptableVersions,
+ IReadOnlyCollection? namespaceDiscriminators = null)
{
return
// ./projects/assets/...
@@ -25,14 +28,45 @@ where targetModIdentifiers.Count() == 0 // 未提供
let versionedDirectory = acceptableVersions.Select(version => modDirectory.GetDirectories(version).FirstOrDefault())
.FirstOrDefault(_ => _ is not null)
where versionedDirectory is not null
- // .../-CFPA-
+ // .../
from namespaceDirectory in versionedDirectory.EnumerateDirectories()
let namespaceName = namespaceDirectory.Name
where !config.Base.ExclusionNamespaces.Contains(namespaceName) // 没有被明确排除
where namespaceName.ValidateNamespace() // 不是非法名称
+ // 命名空间区分:若命中规则,将 assets// 改写为 assets/-CFPA-<区分名>/
+ let discriminatedNamespaceName = ResolveDiscriminatedNamespaceName(namespaceDiscriminators,
+ namespaceName,
+ modIdentifier)
// .../*
from provider in namespaceDirectory.EnumerateProviders(config)
- select provider;
+ select discriminatedNamespaceName is null
+ ? provider
+ : provider.ReplaceDestination($"^assets/{Regex.Escape(namespaceName)}(?=/)",
+ $"assets/{discriminatedNamespaceName.Replace("$", "$$")}");
+ }
+
+ ///
+ /// 解析(命名空间, 项目)应使用的区分后名称。传入的规则应已按当前打包平台过滤。
+ ///
+ /// 适用于当前平台的命名空间区分规则
+ /// 原始命名空间
+ /// CurseForge 项目 slug
+ /// 命中规则时返回 <namespace>-CFPA-<区分名>;否则返回 (保持原名)
+ private static string? ResolveDiscriminatedNamespaceName(IReadOnlyCollection? namespaceDiscriminators,
+ string namespaceName,
+ string modIdentifier)
+ {
+ var matchedDiscriminator = namespaceDiscriminators?
+ .FirstOrDefault(entry => entry.Namespace == namespaceName);
+ if (matchedDiscriminator is null) return null;
+
+ if (!matchedDiscriminator.MappingRule.TryGetValue(modIdentifier, out var discriminatedName))
+ {
+ Log.Warning("命名空间 {0}(项目 {1})命中了区分规则,但 mappingRule 未包含该项目;保留原始命名空间。",
+ namespaceName, modIdentifier);
+ return null;
+ }
+ return $"{namespaceName}-CFPA-{discriminatedName}";
}
public static IEnumerable PostProcess(this IEnumerable providers, Config config)
diff --git a/src/Packer/Helpers/ManifestHelpers.cs b/src/Packer/Helpers/ManifestHelpers.cs
new file mode 100644
index 000000000000..6a45dd4320a8
--- /dev/null
+++ b/src/Packer/Helpers/ManifestHelpers.cs
@@ -0,0 +1,94 @@
+using Packer.Models;
+using Packer.Models.Providers;
+using Serilog;
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Text.Encodings.Web;
+using System.Text.Json;
+
+namespace Packer.Helpers
+{
+ ///
+ /// 组合包 Manifest.json 的生成工具
+ ///
+ internal static class ManifestHelpers
+ {
+ private static readonly JsonSerializerOptions manifestSerializerOptions = new()
+ {
+ PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+ WriteIndented = true,
+ Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
+ };
+
+ ///
+ /// 生成组合包根目录的 Manifest.json 提供器
+ ///
+ /// 当前版本的全局配置
+ /// 已按当前目标平台过滤的命名空间区分规则
+ /// 可接受版本(当前版本 + 回退版本),用于解析被排除模组的命名空间
+ /// 同一命名空间存在多条适用规则
+ public static TextFile BuildGroupedPackManifest(Config config,
+ IReadOnlyCollection applicableDiscriminators,
+ IEnumerable acceptableVersions)
+ {
+ var blackList = config.Base.ExclusionNamespaces
+ .Concat(EnumerateNamespacesOfExcludedMods(config.Base.ExclusionMods, acceptableVersions))
+ .Distinct()
+ .OrderBy(namespaceName => namespaceName, StringComparer.Ordinal)
+ .ToList();
+
+ var rules = new Dictionary();
+ foreach (var discriminator in applicableDiscriminators
+ .OrderBy(entry => entry.Namespace, StringComparer.Ordinal))
+ {
+ if (!rules.TryAdd(discriminator.Namespace, discriminator.Operator))
+ throw new InvalidDataException(
+ $"Duplicate discriminator entries for namespace {discriminator.Namespace} " +
+ "matching the current pack version.");
+ }
+
+ var manifest = new GroupedPackManifest { BlackList = blackList, Rules = rules };
+ var manifestContent = JsonSerializer.Serialize(manifest, manifestSerializerOptions);
+
+ Log.Information("已生成 Manifest.json:{0} 个黑名单命名空间,{1} 条区分规则",
+ blackList.Count, rules.Count);
+
+ return new TextFile(manifestContent, "Manifest.json");
+ }
+
+ ///
+ /// 将被排除模组(CurseForge slug)解析为其在可接受版本下提供的全部命名空间。
+ /// 取各版本目录的并集:客户端缓存可能包含由旧包(含回退版本内容)安装的命名空间,
+ /// 多删缓存是无害的空操作,漏删则会残留脏数据。
+ ///
+ private static IEnumerable EnumerateNamespacesOfExcludedMods(
+ IEnumerable exclusionMods,
+ IEnumerable acceptableVersions)
+ {
+ foreach (var projectSlug in exclusionMods)
+ {
+ var projectDirectory = new DirectoryInfo(Path.Combine("./projects/assets", projectSlug));
+ if (!projectDirectory.Exists)
+ {
+ Log.Warning("被排除的模组 {0} 在 projects/assets 下不存在,跳过其黑名单解析。", projectSlug);
+ continue;
+ }
+
+ var namespaceNames = acceptableVersions
+ .SelectMany(version => projectDirectory.GetDirectories(version))
+ .SelectMany(versionedDirectory => versionedDirectory.EnumerateDirectories())
+ .Select(namespaceDirectory => namespaceDirectory.Name)
+ .Distinct()
+ .ToList();
+
+ if (namespaceNames.Count == 0)
+ Log.Warning("被排除的模组 {0} 在可接受版本下没有任何命名空间目录。", projectSlug);
+
+ foreach (var namespaceName in namespaceNames)
+ yield return namespaceName;
+ }
+ }
+ }
+}
diff --git a/src/Packer/Models/GroupedPackManifest.cs b/src/Packer/Models/GroupedPackManifest.cs
new file mode 100644
index 000000000000..655a4af99d02
--- /dev/null
+++ b/src/Packer/Models/GroupedPackManifest.cs
@@ -0,0 +1,25 @@
+using System.Collections.Generic;
+
+#nullable disable
+
+namespace Packer.Models
+{
+ ///
+ /// 组合包根目录的 Manifest.json,供客户端增量下载、维护本地缓存时使用。
+ ///
+ public class GroupedPackManifest
+ {
+ ///
+ /// 客户端应从本地缓存清除的命名空间(原始名),
+ /// 来自 与
+ /// 解析出的命名空间
+ ///
+ public List BlackList { get; set; }
+
+ ///
+ /// 从原始命名空间到区分标识符(author / displayName / 文件路径)的映射,
+ /// 即 的透传
+ ///
+ public Dictionary Rules { get; set; }
+ }
+}
diff --git a/src/Packer/Models/NamespaceDiscriminator.cs b/src/Packer/Models/NamespaceDiscriminator.cs
new file mode 100644
index 000000000000..c0e18c3bd6b4
--- /dev/null
+++ b/src/Packer/Models/NamespaceDiscriminator.cs
@@ -0,0 +1,48 @@
+using System.Collections.Generic;
+
+// 与 Config.cs 一致:要null就抛异常吧(逃)
+#nullable disable
+
+namespace Packer.Models
+{
+ ///
+ /// 命名空间区分规则。当多个 CurseForge 项目共用同一命名空间时,
+ /// 组合包依据该规则将命名空间改写为 <namespace>-CFPA-<区分名>,
+ /// 使各变体分别打包;客户端按 从本地模组元数据计算区分名。
+ /// 对应 config/packer/namespace-discriminator.json 中的一个条目。
+ ///
+ public class NamespaceDiscriminator
+ {
+ ///
+ /// 被区分的原始命名空间(不含识别段)
+ ///
+ public string Namespace { get; set; }
+
+ ///
+ /// 客户端计算区分名所用的标识符:
+ /// "author"(authors 字典序第一个)、"displayName",
+ /// 或一个文件路径(表示对模组内该文件取 MD5)。
+ /// 原样写入 Manifest.json 的 rules。
+ ///
+ public string Operator { get; set; }
+
+ ///
+ /// 从 CurseForge 项目 slug 到区分名的映射;仅打包器重命名时使用,不写入 Manifest.json
+ ///
+ public Dictionary MappingRule { get; set; }
+
+ ///
+ /// 规则生效范围:键为加载器名(forge / fabric),值为游戏版本列表
+ ///
+ public Dictionary> VersionScope { get; set; }
+
+ ///
+ /// 判断该规则是否适用于给定的打包目标平台
+ ///
+ /// 当前打包的目标平台
+ public bool AppliesTo(PackTargetPlatform targetPlatform)
+ => VersionScope is not null
+ && VersionScope.TryGetValue(targetPlatform.Loader, out var gameVersions)
+ && gameVersions.Contains(targetPlatform.GameVersion);
+ }
+}
diff --git a/src/Packer/Models/PackTargetPlatform.cs b/src/Packer/Models/PackTargetPlatform.cs
new file mode 100644
index 000000000000..ebca5f527539
--- /dev/null
+++ b/src/Packer/Models/PackTargetPlatform.cs
@@ -0,0 +1,23 @@
+namespace Packer.Models
+{
+ ///
+ /// 打包目标平台:加载器 + 游戏版本,从打包版本字符串(即配置文件名)解析。
+ /// 例:"1.16-fabric" → (fabric, 1.16);"1.12.2" → (forge, 1.12.2)。
+ ///
+ /// 加载器名,与 namespace-discriminator.json 中 versionScope 的键对应
+ /// 游戏版本,与 versionScope 值列表中的项对应
+ public record PackTargetPlatform(string Loader, string GameVersion)
+ {
+ private const string FabricSuffix = "-fabric";
+
+ ///
+ /// 从打包版本字符串解析目标平台。
+ /// 以 -fabric 结尾视为 fabric,其余视为 forge。
+ ///
+ /// 打包版本字符串,即
+ public static PackTargetPlatform FromPackVersion(string packVersion)
+ => packVersion.EndsWith(FabricSuffix)
+ ? new PackTargetPlatform("fabric", packVersion[..^FabricSuffix.Length])
+ : new PackTargetPlatform("forge", packVersion);
+ }
+}
diff --git a/src/Packer/Program.cs b/src/Packer/Program.cs
index 1d9e99c2555b..2efc0c97413d 100644
--- a/src/Packer/Program.cs
+++ b/src/Packer/Program.cs
@@ -72,16 +72,28 @@ public static async Task Main(string version, bool increment = false, bool group
{
string packName = $"./Minecraft-Mod-Language-Modpack-{config.Base.Version}-namespaced.zip";
Log.Information("组合包:{0}", packName);
+
+ var acceptableVersions = config.Base.FallbackVersions.Prepend(config.Base.Version).ToList();
+ var targetPlatform = PackTargetPlatform.FromPackVersion(config.Base.Version);
+ var applicableDiscriminators = ConfigHelpers.RetrieveNamespaceDiscriminators()
+ .Where(discriminator => discriminator.AppliesTo(targetPlatform))
+ .ToList();
+ Log.Information("目标平台 {0} {1},适用的命名空间区分规则:{2} 条",
+ targetPlatform.Loader, targetPlatform.GameVersion, applicableDiscriminators.Count);
+
var query =
- EnumerationHelper.EnumerateUnmerged(targetModIdentifiers, config, config.Base.FallbackVersions.Prepend(config.Base.Version))
+ EnumerationHelper.EnumerateUnmerged(targetModIdentifiers, config, acceptableVersions, applicableDiscriminators)
.MergeDeep()
.PostProcess(config);
-
+
+ var manifestFile = ManifestHelpers.BuildGroupedPackManifest(config, applicableDiscriminators, acceptableVersions);
+
await using var stream = File.Create(packName);
using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true))
{
await archive.WriteDirect(initialFiles);
+ await archive.WriteDirect([manifestFile]);
await archive.WriteGrouped(query);
}
//var md5 = stream.ComputeMD5();