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();