Skip to content
15 changes: 15 additions & 0 deletions config/packer/namespace-discriminator.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
[
{
"namespace": "ae2stuff",
"operator": "displayName",
"mappingRule": {
"ae2-stuff": "AE2 Stuff",
"ae2-stuff-unofficial": "AE2 Stuff Unofficial"
},
"versionScope": {
"forge": [
"1.12.2"
]
}
}
]
26 changes: 26 additions & 0 deletions src/Packer/Helpers/ConfigHelpers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,32 @@ public static Config RetrieveConfig(string configTemplate, string version)
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })!;
}

/// <summary>
/// 从仓库根目录获取命名空间区分配置
/// </summary>
/// <param name="path">配置文件位置</param>
/// <returns>文件不存在时返回空列表;文件存在但内容非法时抛出异常,宁可打包失败也不发出错误的包</returns>
public static List<NamespaceDiscriminator> RetrieveNamespaceDiscriminators(
string path = "./config/packer/namespace-discriminator.json")
{
var file = new FileInfo(path);
if (!file.Exists)
{
Log.Information("未找到命名空间区分配置({0}),跳过命名空间区分。", path);
return new List<NamespaceDiscriminator>();
}

file.FullName.LogToDebug("读取文件:{0}");

using var stream = file.OpenRead();
var result = JsonSerializer.Deserialize<List<NamespaceDiscriminator>>(
stream,
new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase });
if (result is null)
throw new InvalidDataException($"The discriminator file {path} cannot have null content.");
return result;
}

/// <summary>
/// 从给定的命名空间获取策略内容
/// </summary>
Expand Down
40 changes: 37 additions & 3 deletions src/Packer/Helpers/EnumerationHelper.cs
Original file line number Diff line number Diff line change
@@ -1,17 +1,20 @@
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
{
internal static class EnumerationHelper
{
public static IEnumerable<IResourceFileProvider> EnumerateUnmerged(IEnumerable<string> targetModIdentifiers,
Config config,
IEnumerable<string> acceptableVersions)
IEnumerable<string> acceptableVersions,
IReadOnlyCollection<NamespaceDiscriminator>? namespaceDiscriminators = null)
{
return
// ./projects/assets/<projectSlug>...
Expand All @@ -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
// .../<namespace>-CFPA-<author>
// .../<namespace>
from namespaceDirectory in versionedDirectory.EnumerateDirectories()
let namespaceName = namespaceDirectory.Name
where !config.Base.ExclusionNamespaces.Contains(namespaceName) // 没有被明确排除
where namespaceName.ValidateNamespace() // 不是非法名称
// 命名空间区分:若命中规则,将 assets/<namespace>/ 改写为 assets/<namespace>-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("$", "$$")}");
}

/// <summary>
/// 解析(命名空间, 项目)应使用的区分后名称。传入的规则应已按当前打包平台过滤。
/// </summary>
/// <param name="namespaceDiscriminators">适用于当前平台的命名空间区分规则</param>
/// <param name="namespaceName">原始命名空间</param>
/// <param name="modIdentifier">CurseForge 项目 slug</param>
/// <returns>命中规则时返回 <c>&lt;namespace&gt;-CFPA-&lt;区分名&gt;</c>;否则返回 <see langword="null"/>(保持原名)</returns>
private static string? ResolveDiscriminatedNamespaceName(IReadOnlyCollection<NamespaceDiscriminator>? 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<IResourceFileProvider> PostProcess(this IEnumerable<IResourceFileProvider> providers, Config config)
Expand Down
94 changes: 94 additions & 0 deletions src/Packer/Helpers/ManifestHelpers.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// 组合包 Manifest.json 的生成工具
/// </summary>
internal static class ManifestHelpers
{
private static readonly JsonSerializerOptions manifestSerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
WriteIndented = true,
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
};

/// <summary>
/// 生成组合包根目录的 Manifest.json 提供器
/// </summary>
/// <param name="config">当前版本的全局配置</param>
/// <param name="applicableDiscriminators">已按当前目标平台过滤的命名空间区分规则</param>
/// <param name="acceptableVersions">可接受版本(当前版本 + 回退版本),用于解析被排除模组的命名空间</param>
/// <exception cref="InvalidDataException">同一命名空间存在多条适用规则</exception>
public static TextFile BuildGroupedPackManifest(Config config,
IReadOnlyCollection<NamespaceDiscriminator> applicableDiscriminators,
IEnumerable<string> acceptableVersions)
{
var blackList = config.Base.ExclusionNamespaces
.Concat(EnumerateNamespacesOfExcludedMods(config.Base.ExclusionMods, acceptableVersions))
.Distinct()
.OrderBy(namespaceName => namespaceName, StringComparer.Ordinal)
.ToList();

var rules = new Dictionary<string, string>();
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");
}

/// <summary>
/// 将被排除模组(CurseForge slug)解析为其在可接受版本下提供的全部命名空间。<br />
/// 取各版本目录的并集:客户端缓存可能包含由旧包(含回退版本内容)安装的命名空间,
/// 多删缓存是无害的空操作,漏删则会残留脏数据。
/// </summary>
private static IEnumerable<string> EnumerateNamespacesOfExcludedMods(
IEnumerable<string> exclusionMods,
IEnumerable<string> 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;
}
}
}
}
25 changes: 25 additions & 0 deletions src/Packer/Models/GroupedPackManifest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using System.Collections.Generic;

#nullable disable

namespace Packer.Models
{
/// <summary>
/// 组合包根目录的 <c>Manifest.json</c>,供客户端增量下载、维护本地缓存时使用。
/// </summary>
public class GroupedPackManifest
{
/// <summary>
/// 客户端应从本地缓存清除的命名空间(原始名),
/// 来自 <see cref="BaseConfig.ExclusionNamespaces"/> 与
/// <see cref="BaseConfig.ExclusionMods"/> 解析出的命名空间
/// </summary>
public List<string> BlackList { get; set; }

/// <summary>
/// 从原始命名空间到区分标识符(author / displayName / 文件路径)的映射,
/// 即 <see cref="NamespaceDiscriminator.Operator"/> 的透传
/// </summary>
public Dictionary<string, string> Rules { get; set; }
}
}
48 changes: 48 additions & 0 deletions src/Packer/Models/NamespaceDiscriminator.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
using System.Collections.Generic;

// 与 Config.cs 一致:要null就抛异常吧(逃)
#nullable disable

namespace Packer.Models
{
/// <summary>
/// 命名空间区分规则。当多个 CurseForge 项目共用同一命名空间时,
/// 组合包依据该规则将命名空间改写为 <c>&lt;namespace&gt;-CFPA-&lt;区分名&gt;</c>,
/// 使各变体分别打包;客户端按 <see cref="Operator"/> 从本地模组元数据计算区分名。<br />
/// 对应 <c>config/packer/namespace-discriminator.json</c> 中的一个条目。
/// </summary>
public class NamespaceDiscriminator
{
/// <summary>
/// 被区分的原始命名空间(不含识别段)
/// </summary>
public string Namespace { get; set; }

/// <summary>
/// 客户端计算区分名所用的标识符:
/// <c>"author"</c>(authors 字典序第一个)、<c>"displayName"</c>,
/// 或一个文件路径(表示对模组内该文件取 MD5)。
/// 原样写入 Manifest.json 的 rules。
/// </summary>
public string Operator { get; set; }

/// <summary>
/// 从 CurseForge 项目 slug 到区分名的映射;仅打包器重命名时使用,不写入 Manifest.json
/// </summary>
public Dictionary<string, string> MappingRule { get; set; }

/// <summary>
/// 规则生效范围:键为加载器名(forge / fabric),值为游戏版本列表
/// </summary>
public Dictionary<string, List<string>> VersionScope { get; set; }

/// <summary>
/// 判断该规则是否适用于给定的打包目标平台
/// </summary>
/// <param name="targetPlatform">当前打包的目标平台</param>
public bool AppliesTo(PackTargetPlatform targetPlatform)
=> VersionScope is not null
&& VersionScope.TryGetValue(targetPlatform.Loader, out var gameVersions)
&& gameVersions.Contains(targetPlatform.GameVersion);
}
}
23 changes: 23 additions & 0 deletions src/Packer/Models/PackTargetPlatform.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
namespace Packer.Models
{
/// <summary>
/// 打包目标平台:加载器 + 游戏版本,从打包版本字符串(即配置文件名)解析。<br />
/// 例:<c>"1.16-fabric"</c> → (fabric, 1.16);<c>"1.12.2"</c> → (forge, 1.12.2)。
/// </summary>
/// <param name="Loader">加载器名,与 namespace-discriminator.json 中 versionScope 的键对应</param>
/// <param name="GameVersion">游戏版本,与 versionScope 值列表中的项对应</param>
public record PackTargetPlatform(string Loader, string GameVersion)
{
private const string FabricSuffix = "-fabric";

/// <summary>
/// 从打包版本字符串解析目标平台。
/// 以 <c>-fabric</c> 结尾视为 fabric,其余视为 forge。
/// </summary>
/// <param name="packVersion">打包版本字符串,即 <see cref="BaseConfig.Version"/></param>
public static PackTargetPlatform FromPackVersion(string packVersion)
=> packVersion.EndsWith(FabricSuffix)
? new PackTargetPlatform("fabric", packVersion[..^FabricSuffix.Length])
: new PackTargetPlatform("forge", packVersion);
}
}
16 changes: 14 additions & 2 deletions src/Packer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Loading