diff --git a/.gitignore b/.gitignore index 6f6f0e95e2f9..f199207afe09 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -# CFPA-specifics +# CFPA-specifics Minecraft-Mod-Language-Package-*.zip *.md5 build/ @@ -389,3 +389,4 @@ MigrationBackup/ # Fody - auto-generated XML schema FodyWeavers.xsd +/.reasonix/ diff --git a/Minecraft-Mod-Language-Package.sln b/Minecraft-Mod-Language-Package.sln index 2af01de9be60..34f1495783aa 100644 --- a/Minecraft-Mod-Language-Package.sln +++ b/Minecraft-Mod-Language-Package.sln @@ -9,6 +9,10 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Uploader", "src\Uploader\Up EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Language.Core", "src\Language.Core\Language.Core.csproj", "{D59EE21C-875E-4B31-8A97-813DC4C2DB68}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packer.Core", "src\Packer.Core\Packer.Core.csproj", "{0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Packer.Core.Tests", "src\Packer.Core.Tests\Packer.Core.Tests.csproj", "{C1362B4E-DC54-1918-71D7-10198B04FA50}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -27,6 +31,14 @@ Global {D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Debug|Any CPU.Build.0 = Debug|Any CPU {D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Release|Any CPU.ActiveCfg = Release|Any CPU {D59EE21C-875E-4B31-8A97-813DC4C2DB68}.Release|Any CPU.Build.0 = Release|Any CPU + {0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0ACFBD7A-2AE9-D5B9-1B5A-3D8A6B4E7A50}.Release|Any CPU.Build.0 = Release|Any CPU + {C1362B4E-DC54-1918-71D7-10198B04FA50}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C1362B4E-DC54-1918-71D7-10198B04FA50}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C1362B4E-DC54-1918-71D7-10198B04FA50}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C1362B4E-DC54-1918-71D7-10198B04FA50}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/diag_mc.txt b/diag_mc.txt new file mode 100644 index 000000000000..e1a94572fc0d --- /dev/null +++ b/diag_mc.txt @@ -0,0 +1,4 @@ +Target dir exists: True +Has font/: True +Policy types: [DirectPolicy, IndirectPolicy] +Providers: 0 \ No newline at end of file diff --git a/src/Packer.Core.Tests/ConfigurationTests/DeserializationTests.cs b/src/Packer.Core.Tests/ConfigurationTests/DeserializationTests.cs new file mode 100644 index 000000000000..d656b5e6e6fd --- /dev/null +++ b/src/Packer.Core.Tests/ConfigurationTests/DeserializationTests.cs @@ -0,0 +1,178 @@ +using Packer.Core.Model.Configuration; +using Packer.Core.Model.PackerPolicys; +using Packer.Core.Model.ResourceFile; +using System.Text.Json; + +namespace Packer.Core.Tests.ConfigurationTests; + +/// 测试 1/2/3:配置与策略的解析与组合行为 +public class DeserializationTests +{ + static readonly FloatingConfig EmptyConfig = new([], [], [], [], + new Dictionary(), new Dictionary()); + static readonly Config EmptyGlobal = new( + new BaseConfig("", ["zh_cn"], "", [], "", [], [], []), EmptyConfig); + + static readonly JsonSerializerOptions Opts = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + TypeInfoResolver = new DefaultJsonTypeInfoResolver() + }; + + // ── 测试 1:浮动配置和打包策略在命名空间文件夹里时可以解析 ── + + [Fact] + public void NamespaceFolder_Loads_LocalConfig_And_Policy() + { + using var tmp = new TempDir(); + // 造一个命名空间目录,带 local-config.json 和 packer-policy.json + tmp.Write("local-config.json", """{ "inclusionDomains": ["font"] }"""); + tmp.Write("packer-policy.json", """[{ "type": "singleton", "source": "a.json", "relativePath": "lang/zh_cn.json" }]"""); + + var ns = new AssetsNamespaceResource(tmp.Path, "myns", "mymod", "1.21"); + + // 验证 local-config 被加载 + Assert.Contains("font", ns.LocalConfig.InclusionDomains); + + // 验证策略被加载(不会是 Shared) + Assert.NotSame(PackerPolicy.Shared, ns.PackerPolicies); + + // 验证策略类型正确 + var policy = Assert.Single(ns.PackerPolicies); + Assert.IsType(policy); + } + + // ── 测试 2:全局配置和浮动配置组合正确 ── + + [Fact] + public void GlobalConfig_Merges_With_LocalConfig() + { + var global = new FloatingConfig( + ["font"], [], [], ["README.md"], + new Dictionary { ["旧"] = "新" }, + new Dictionary()); + + var local = new FloatingConfig( + ["gui"], [], [], ["packer-policy.json"], + new Dictionary(), + new Dictionary()); + + var merged = global.Merge(local); + + // domain 取并集 + Assert.Contains("font", merged.InclusionDomains); + Assert.Contains("gui", merged.InclusionDomains); + + // exclusionPaths 取并集 + Assert.Contains("README.md", merged.ExclusionPaths); + Assert.Contains("packer-policy.json", merged.ExclusionPaths); + + // 全局的 replacement 保留 + Assert.Equal("新", merged.CharacterReplacement["旧"]); + } + + // ── 测试 3:没有浮动配置和打包策略时结果正确 ── + + [Fact] + public void NoLocalConfig_Returns_Shared() + { + using var tmp = new TempDir(); + // 空目录,没有 local-config.json + var ns = new AssetsNamespaceResource(tmp.Path, "ns", "mod", "1.21"); + + Assert.Same(FloatingConfig.Shared, ns.LocalConfig); + Assert.Same(PackerPolicy.Shared, ns.PackerPolicies); + } + + // ── 测试 8:TryAdd 和 ModifyOnly ── + + [Fact] + public void JsonFile_Merge_TryAdd_FirstWins() + { + var a = new JsonFile(new() { ["a"] = "1", ["b"] = "2" }, "x.json"); + var b = new JsonFile(new() { ["a"] = "x", ["c"] = "3" }, "x.json"); + + var r = a.Merge(b); + Assert.Equal("1", r.Entries["a"]); // 第一个赢 + Assert.Equal("2", r.Entries["b"]); + Assert.Equal("3", r.Entries["c"]); + } + + [Fact] + public void JsonFile_Merge_ModifyOnly_OverwritesExisting() + { + var a = new JsonFile(new() { ["a"] = "1", ["b"] = "2" }, "x.json"); + var b = new JsonFile(new() { ["a"] = "x", ["c"] = "3" }, "x.json") + { + PolicyItem = new DirectPolicy { ModifyOnly = true } + }; + + var r = a.Merge(b); + Assert.Equal("x", r.Entries["a"]); // 覆盖已有 + Assert.Equal("2", r.Entries["b"]); // 不动 + Assert.False(r.Entries.ContainsKey("c")); // 不添加新键 + } + + [Fact] + public void LangFile_Merge_TryAdd_FirstWins() + { + var a = new LangFile(new() { ["k1"] = "v1", ["k2"] = "v2" }, "x.lang"); + var b = new LangFile(new() { ["k1"] = "x", ["k3"] = "v3" }, "x.lang"); + + var r = a.Merge(b); + Assert.Equal("v1", r.Entries["k1"]); + Assert.Equal("v3", r.Entries["k3"]); + } + + // ── 测试 11:全局配置的过滤 ── + // 此测试需要在真实目录中放置文件,用 AssetsNamespaceResource + DirectPolicy + // 验证 inclusionDomains / exclusionDomains / exclusionPaths 生效 + + [Fact] + public void GlobalFilter_InclusionDomain_ForceIncludes() + { + using var dir = new TempDir(); + dir.Write("font/extra.txt", "should be included via domain"); + dir.Write("sounds/ambient.ogg", "should NOT be included"); + + var ns = new AssetsNamespaceResource(dir.Path, "testns", "testmod", "1.21"); + var config = new FloatingConfig( + InclusionDomains: ["font"], // font 之下无条件进包 + ExclusionDomains: [], + InclusionPaths: [], + ExclusionPaths: ["packer-policy.json", "local-config.json"], + CharacterReplacement: new Dictionary(), + DestinationReplacement: new Dictionary()); + + // DirectPolicy 默认共享 + var providers = new DirectPolicy().CreateProviders(ns, EmptyGlobal, config).ToList(); + + Assert.Contains(providers, p => p.Destination.EndsWith("font/extra.txt")); + Assert.DoesNotContain(providers, p => p.Destination.EndsWith("sounds/ambient.ogg")); + } +} + +/// 临时目录辅助类 +public class TempDir : IDisposable +{ + public string Path { get; } = System.IO.Path.Combine(System.IO.Path.GetTempPath(), Guid.NewGuid().ToString()); + + public TempDir() + { + Directory.CreateDirectory(Path); + } + + /// 在临时目录下创建一个文件(自动创建父目录) + public void Write(string relativePath, string content) + { + var full = System.IO.Path.Combine(Path, relativePath); + Directory.CreateDirectory(System.IO.Path.GetDirectoryName(full)!); + File.WriteAllText(full, content); + } + + public void Dispose() + { + try { Directory.Delete(Path, recursive: true); } + catch { /* 清理失败忽略 */ } + } +} diff --git a/src/Packer.Core.Tests/GlobalUsings.cs b/src/Packer.Core.Tests/GlobalUsings.cs new file mode 100644 index 000000000000..ae52d3ca3cf9 --- /dev/null +++ b/src/Packer.Core.Tests/GlobalUsings.cs @@ -0,0 +1,8 @@ +global using Packer.Core.Model; +global using Packer.Core.Model.Abstract; +global using Packer.Core.Model.Configuration; +global using Packer.Core.Model.ModProvider; +global using Packer.Core.Model.ResourceFile; +global using System.Text.Json; +global using System.Text.Json.Serialization.Metadata; +global using Packer.Core.Tests.ConfigurationTests; diff --git a/src/Packer.Core.Tests/InMemoryFileProvider.cs b/src/Packer.Core.Tests/InMemoryFileProvider.cs new file mode 100644 index 000000000000..d49cb40c6316 --- /dev/null +++ b/src/Packer.Core.Tests/InMemoryFileProvider.cs @@ -0,0 +1,81 @@ +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Primitives; + +namespace Packer.Core.Tests; + +/// +/// 内存虚拟文件系统,实现 。 +/// 用于不依赖真实磁盘的单元测试。 +/// +public class InMemoryFileProvider : IFileProvider +{ + private readonly Dictionary _files = new(StringComparer.OrdinalIgnoreCase); + private readonly HashSet _dirs = new(StringComparer.OrdinalIgnoreCase); + + /// 添加一个虚拟文件 + public void AddFile(string path, string content) + { + path = Normalize(path); + _files[path] = System.Text.Encoding.UTF8.GetBytes(content); + // 注册所有父目录 + var parts = path.Split('/'); + for (int i = 1; i < parts.Length; i++) + { + var dir = string.Join("/", parts, 0, i); + _dirs.Add(dir); + } + } + + public IFileInfo GetFileInfo(string subpath) + { + subpath = Normalize(subpath); + if (_files.TryGetValue(subpath, out var data)) + return new InMemoryFile(Path.GetFileName(subpath), data, false); + return new InMemoryFile(Path.GetFileName(subpath), null, true); + } + + public IDirectoryContents GetDirectoryContents(string subpath) + { + subpath = Normalize(subpath); + var prefix = string.IsNullOrEmpty(subpath) ? "" : subpath + "/"; + var files = _files.Keys + .Where(k => k.StartsWith(prefix) && k.LastIndexOf('/') == prefix.Length - 1) + .Select(k => new InMemoryFile(Path.GetFileName(k), _files[k], false)); + var dirs = _dirs + .Where(d => d.StartsWith(prefix) && d.Length > prefix.Length && !d[prefix.Length..].Contains('/')) + .Select(d => new InMemoryFile(d[(prefix.Length)..], null, false) { IsDirectory = true }); + return new InMemoryDirectoryContents(files.Concat(dirs)); + } + + public IChangeToken Watch(string filter) => NullChangeToken.Singleton; + + private static string Normalize(string path) + => path.Replace('\\', '/').TrimStart('/'); +} + +internal class InMemoryFile : IFileInfo +{ + private readonly byte[]? _data; + public bool Exists => _data is not null; + public long Length => _data?.Length ?? -1; + public string? PhysicalPath => null; + public string Name { get; } + public DateTimeOffset LastModified => DateTimeOffset.MinValue; + public bool IsDirectory { get; init; } + + public InMemoryFile(string name, byte[]? data, bool notExists) + { + Name = name; + _data = notExists ? null : data; + } + + public Stream CreateReadStream() + => _data is not null ? new MemoryStream(_data) : Stream.Null; +} + +internal class InMemoryDirectoryContents(IEnumerable contents) : IDirectoryContents +{ + public bool Exists => true; + public IEnumerator GetEnumerator() => contents.GetEnumerator(); + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); +} diff --git a/src/Packer.Core.Tests/IntegrationTests/PackerExampleTests.cs b/src/Packer.Core.Tests/IntegrationTests/PackerExampleTests.cs new file mode 100644 index 000000000000..07b8fb1ab014 --- /dev/null +++ b/src/Packer.Core.Tests/IntegrationTests/PackerExampleTests.cs @@ -0,0 +1,190 @@ +using Packer.Core.Model; +using Packer.Core.Model.Abstract; +using Packer.Core.Model.Configuration; +using Packer.Core.Model.PackerPolicys; + +namespace Packer.Core.Tests.IntegrationTests; + +/// +/// 使用 projects/packer-example 的数据做真实集成测试。 +/// 在临时目录重建项目结构(CWD 已切换到临时项目根),跑完整打包流程。 +/// +public class PackerExampleTests : IDisposable +{ + private readonly string _originalDir = Environment.CurrentDirectory; + private readonly TempDir _tmp = new(); + + public PackerExampleTests() + { + Environment.CurrentDirectory = _tmp.Path; + } + + public void Dispose() + { + Environment.CurrentDirectory = _originalDir; + _tmp.Dispose(); + } + + // 从 AppContext.BaseDirectory(bin/Debug/net10.0/)回溯到仓库根目录 + static readonly string _exampleRoot = Path.GetFullPath( + Path.Combine(AppContext.BaseDirectory, + "..", "..", "..", "..", "..", + "projects", "packer-example", "assets")); + + [Fact] + public void DirectPolicy_IncludesZhCn_ExcludesEnUs() + { + CreateProject("example-direct"); + var config = LoadTestConfig(); + var ns = GetNamespace("example-direct", "direct"); + + var providers = ns.PackerPolicies + .CreateProviders(ns, config) + .ToList(); + + Assert.Contains(providers, p => p.Destination.EndsWith("direct/lang/zh_cn.json")); + Assert.DoesNotContain(providers, p => p.Destination.EndsWith("direct/lang/en_us.json")); + Assert.DoesNotContain(providers, p => p.Destination.EndsWith("direct/other.txt")); + } + + [Fact] + public void IndirectPolicy_RewritesNamespace() + { + CreateProject("example-direct", "example-indirect"); + var config = LoadTestConfig(); + var ns = GetNamespace("example-indirect", "indirect"); + + var providers = ns.PackerPolicies + .CreateProviders(ns, config) + .ToList(); + + Assert.NotEmpty(providers); + foreach (var p in providers) + Assert.Contains("indirect", p.Destination); + Assert.Contains(providers, p => p.Destination.EndsWith("lang/zh_cn.json")); + } + + [Fact] + public void CompositePolicy_MergesMultipleSources() + { + CreateProject("example-direct", "example-indirect", "example-composition", "example-composite"); + var config = LoadTestConfig(); + var ns = GetNamespace("example-composite", "composite"); + + var providers = ns.PackerPolicies + .CreateProviders(ns, config) + .ToList(); + + var zhCn = providers.Where(p => p.Destination.Contains("composite") && p.Destination.EndsWith("lang/zh_cn.json")) + .Cast().ToList(); + Assert.Equal(2, zhCn.Count); + + // 第一个来自 composite 自身(Direct),key3 被覆盖为新值 + // 第二个来自 indirect(→direct),包含 direct 的全部 18 个 key + var compositeEntries = zhCn[0].Entries; + var directEntries = zhCn[1].Entries; + Assert.Equal("new value3", compositeEntries["key3"]); + Assert.Equal("value1", directEntries["key1"]); + } + + [Fact] + public void CompositionPolicy_ExpandsCartesianProduct() + { + CreateProject("example-composition"); + var config = LoadTestConfig(); + var ns = GetNamespace("example-composition", "composition"); + + var providers = ns.PackerPolicies + .CreateProviders(ns, config) + .ToList(); + + Assert.Single(providers); + var provider = providers[0] as KVPFile; + Assert.NotNull(provider); + + Assert.Equal(200, provider.Entries.Count); + Assert.Equal("value_0_00", provider.Entries["key_0_00"]); + Assert.Equal("altvalue_9_09", provider.Entries["altkey_9_09"]); + } + + [Fact] + public void FullPipeline_ProducesAllExpectedDestinations() + { + CreateProject("example-direct", "example-indirect", "example-composite", "example-composition"); + var config = LoadTestConfig(); + + var mods = new[] { "example-direct", "example-indirect", "example-composite", "example-composition" }; + var allNamespaces = mods.Select(m => GetNamespace(m, m.Split('-').Last())); + + var allProviders = allNamespaces + .SelectMany(ns => ns.PackerPolicies.CreateProviders(ns, config)) + .ToLookup(p => p.Destination); + + var dests = allProviders.Select(g => g.Key).ToList(); + Assert.Contains(dests, d => d.Contains("direct/lang/zh_cn.json")); + Assert.Contains(dests, d => d.Contains("indirect/lang/zh_cn.json")); + Assert.Contains(dests, d => d.Contains("composite/lang/zh_cn.json")); + Assert.Contains(dests, d => d.Contains("composition/lang/zh_cn.json")); + } + + // ── 辅助方法 ── + + private void CreateProject(params string[] modNames) + { + foreach (var mod in modNames) + { + var src = Path.Combine(_exampleRoot, mod); + foreach (var nsDir in Directory.EnumerateDirectories(src)) + { + var ns = Path.GetFileName(nsDir); + var dest = Path.Combine(_tmp.Path, "projects", "assets", mod, "packer-example", ns); + CopyDirectory(nsDir, dest); + } + } + } + + private static Config LoadTestConfig() + { + return new Config( + new BaseConfig( + Version: "packer-example", + TargetLanguages: ["zh_cn"], + McMetaTemplate: "", + McMetaParameters: [], + ReadmeTemplate: "", + ReadmeParameters: [], + ExclusionMods: [], + ExclusionNamespaces: [] + ), + new FloatingConfig( + InclusionDomains: [], + ExclusionDomains: [], + ExclusionPaths: ["local-config.json", "packer-policy.json"], + InclusionPaths: [], + CharacterReplacement: new Dictionary + { + ["REPLACEMENT"] = "被替换的内容" + }, + DestinationReplacement: new Dictionary() + ) + ); + } + + private INamespaceResource GetNamespace(string modName, string nsName) + { + var nsDir = Path.Combine(_tmp.Path, "projects", "assets", modName, "packer-example", nsName); + return new AssetsNamespaceResource(nsDir, nsName, modName, "packer-example"); + } + + private static void CopyDirectory(string src, string dest) + { + Directory.CreateDirectory(dest); + foreach (var file in Directory.EnumerateFiles(src, "*", SearchOption.AllDirectories)) + { + var relative = Path.GetRelativePath(src, file); + var target = Path.Combine(dest, relative); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + } +} diff --git a/src/Packer.Core.Tests/MergeTests/CompositionEntryTests.cs b/src/Packer.Core.Tests/MergeTests/CompositionEntryTests.cs new file mode 100644 index 000000000000..b0d1a69f111a --- /dev/null +++ b/src/Packer.Core.Tests/MergeTests/CompositionEntryTests.cs @@ -0,0 +1,70 @@ +using Packer.Core.Model.ResourceFile; +using System.Text.Json; + +namespace Packer.Core.Tests.MergeTests; + +/// 测试 10:CompositionEntry 笛卡尔积 +public class CompositionEntryTests +{ + static CompositionEntry ParseEntry(string json) + { + using var doc = JsonDocument.Parse(json); + var root = doc.RootElement; + var templates = new Dictionary(); + foreach (var t in root.GetProperty("templates").EnumerateObject()) + templates[t.Name] = t.Value.GetString()!; + + var parameters = new List>(); + foreach (var p in root.GetProperty("parameters").EnumerateArray()) + { + var dict = new Dictionary(); + foreach (var kv in p.EnumerateObject()) + dict[kv.Name] = kv.Value.GetString()!; + parameters.Add(dict); + } + return new CompositionEntry(templates, parameters); + } + + [Fact] + public void SingleParam_SingleTemplate() + { + var entry = ParseEntry(""" + { "templates": { "item.{0}.name": "{0}" }, "parameters": [{ "apple": "苹果", "banana": "香蕉" }] } + """); + var r = entry.BuildDictionary(); + Assert.Equal(2, r.Count); + Assert.Equal("苹果", r["item.apple.name"]); + } + + [Fact] + public void TwoParams_CartesianProduct() + { + var entry = ParseEntry(""" + { "templates": { "{0}.{1}": "{1}.{0}" }, "parameters": [{ "a": "1", "b": "2" }, { "x": "!", "y": "?" }] } + """); + var r = entry.BuildDictionary(); + Assert.Equal(4, r.Count); + Assert.Equal("!.1", r["a.x"]); + } + + [Fact] + public void ThreeParams_FullProduct() + { + var entry = ParseEntry(""" + {"templates":{"{0}{1}{2}":"{0}{1}{2}"},"parameters":[{"A":"a"},{"1":"1"},{"@":"@"}]} + """); + var r = entry.BuildDictionary(); + Assert.Single(r); + Assert.Equal("a1@", r["A1@"]); + } + + [Fact] + public void TwoTemplates_FourEntriesEach() + { + var entry = ParseEntry(""" + {"templates":{"{0}.name":"{1}","{0}.desc":"{1}描述"},"parameters":[{"sword":"剑","pickaxe":"镐"},{"iron":"铁"}]} + """); + var r = entry.BuildDictionary(); + Assert.Equal(4, r.Count); // 2×1 参数 × 2 模板 + } +} diff --git a/src/Packer.Core.Tests/Packer.Core.Tests.csproj b/src/Packer.Core.Tests/Packer.Core.Tests.csproj new file mode 100644 index 000000000000..ae889ac5e517 --- /dev/null +++ b/src/Packer.Core.Tests/Packer.Core.Tests.csproj @@ -0,0 +1,30 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Packer.Core.Tests/PolicyTests/PolicyTypeTests.cs b/src/Packer.Core.Tests/PolicyTests/PolicyTypeTests.cs new file mode 100644 index 000000000000..df70cad5a096 --- /dev/null +++ b/src/Packer.Core.Tests/PolicyTests/PolicyTypeTests.cs @@ -0,0 +1,140 @@ +using Packer.Core.Model; +using Packer.Core.Model.Configuration; +using Packer.Core.Model.PackerPolicys; +using Packer.Core.Model.ResourceFile; + +namespace Packer.Core.Tests.PolicyTests; + +/// 测试 4~7:每种策略的执行结果 +public class PolicyTypeTests +{ + static readonly FloatingConfig EmptyConfig = new([], [], [], [], + new Dictionary(), new Dictionary()); + + static readonly Config EmptyGlobal = new( + new BaseConfig("", ["zh_cn"], "", [], "", [], [], []), EmptyConfig); + + // ── 测试 4:默认打包策略(Direct)与浮动配置组合时过滤正确 ── + + [Fact] + public void DirectPolicy_WithFilters_ExcludesCorrectly() + { + using var dir = new TempDir(); + dir.Write("lang/zh_cn.json", """{"key":"val"}"""); + dir.Write("lang/en_us.json", """{"key":"val"}"""); + dir.Write("packer-policy.json", "ignored"); + + var ns = new AssetsNamespaceResource(dir.Path, "ns", "mod", "1.21"); + var config = new FloatingConfig([], [], [], ["packer-policy.json", "local-config.json"], + new Dictionary(), new Dictionary()); + + var providers = new DirectPolicy().CreateProviders(ns, EmptyGlobal, config).ToList(); + Assert.Contains(providers, p => p.Destination.Contains("zh_cn")); + Assert.DoesNotContain(providers, p => p.Destination.Contains("en_us")); + } + + // ── 测试 5:多个打包策略时执行正确 ── + + [Fact] + public void MultiplePolicies_ExecuteAll() + { + using var dir = new TempDir(); + dir.Write("lang/base.json", """{"a":"1"}"""); + dir.Write("lang/comp.json", """{"target":"lang/zh_cn.json","entries":[{"templates":{"{0}.n":"{0}"},"parameters":[{"x":"y"}]}]}"""); + + var policyList = new PackerPolicy(); + policyList.Add(new SingletonPolicy( + System.IO.Path.Combine(dir.Path, "lang/base.json"), "lang/zh_cn.json")); + policyList.Add(new CompositionPolicy( + System.IO.Path.Combine(dir.Path, "lang/comp.json"), "json")); + + var ns = new AssetsNamespaceResource(dir.Path, "ns", "mod", "1.21"); + var providers = policyList.CreateProviders(ns, EmptyGlobal).ToList(); + + Assert.Equal(2, providers.Count); + Assert.IsType(providers[0]); + Assert.IsType(providers[1]); + } + + // ── 测试 6:每一种打包方式的结果预期 ── + + [Fact] + public void DirectPolicy_Produces_ExpectedProviders() + { + using var dir = new TempDir(); + dir.Write("lang/zh_cn.json", """{"k":"v"}"""); + dir.Write("lang/zh_cn.lang", "k=v"); + + var ns = new AssetsNamespaceResource(dir.Path, "ns", "mod", "1.21"); + var config = new FloatingConfig([], [], [], ["packer-policy.json", "local-config.json"], + new Dictionary(), new Dictionary()); + + var providers = new DirectPolicy().CreateProviders(ns, EmptyGlobal, config).ToList(); + + Assert.Contains(providers, p => p is JsonFile); + Assert.Contains(providers, p => p is LangFile); + } + + // ── 测试 7:重定向多递归 ── + + [Fact] + public void IndirectPolicy_Recursion_Works() + { + using var target = new TempDir(); + target.Write("lang/zh_cn.json", """{"key":"来自目标"}"""); + + using var source = new TempDir(); + source.Write("packer-policy.json", + $$"""[{"type":"indirect","source":"{{target.Path.Replace("\\","/")}}"}]"""); + + var ns = new AssetsNamespaceResource(source.Path, "srcns", "srcmod", "1.21"); + var config = new FloatingConfig([], [], [], ["packer-policy.json", "local-config.json"], + new Dictionary(), new Dictionary()); + + var providers = ns.PackerPolicies.CreateProviders(ns, EmptyGlobal).ToList(); + + var dests = providers.Select(p => p.Destination).ToList(); + Assert.Contains(dests, d => d.Contains("srcns")); + } + + // ── 测试 12:字符串替换 ── + + [Fact] + public void TextFile_CharacterReplacement_Works() + { + var file = new TextFile("[[旧]]", "x.txt") + { + EffectiveConfig = new FloatingConfig( + [], [], [], [], + new Dictionary { ["\\[\\[旧\\]\\]"] = "新" }, + new Dictionary()) + }; + + using var stream = file.GetContentStream(); + using var reader = new StreamReader(stream); + var content = reader.ReadToEnd(); + + Assert.Equal("新", content); + } + + [Fact] + public void JsonFile_Replaces_Values_Only() + { + var file = new JsonFile( + new() { ["a"] = "[[旧]]", ["b"] = "不变" }, "x.json") + { + EffectiveConfig = new FloatingConfig( + [], [], [], [], + new Dictionary { ["\\[\\[旧\\]\\]"] = "新" }, + new Dictionary()) + }; + + using var stream = file.GetContentStream(); + using var reader = new StreamReader(stream); + var json = reader.ReadToEnd(); + + Assert.Contains("新", json); + Assert.Contains("不变", json); + Assert.DoesNotContain("旧", json); + } +} diff --git a/src/Packer.Core.Tests/ProviderTests/NamespaceProviderTests.cs b/src/Packer.Core.Tests/ProviderTests/NamespaceProviderTests.cs new file mode 100644 index 000000000000..b63a9ad6aaa0 --- /dev/null +++ b/src/Packer.Core.Tests/ProviderTests/NamespaceProviderTests.cs @@ -0,0 +1,59 @@ +using Packer.Core.Model.Abstract; +using Packer.Core.Model.Configuration; +using Packer.Core.Model.ModProvider; +using Packer.Core.Model.PackerPolicys; + +namespace Packer.Core.Tests.ProviderTests; + +/// 测试 9/13:命名空间提供程序和命名空间资源 +public class NamespaceProviderTests +{ + static readonly FloatingConfig EmptyFloating = new([], [], [], [], + new Dictionary(), new Dictionary()); + static readonly Config EmptyGlobal = new( + new BaseConfig("", ["zh_cn"], "", [], "", [], [], []), EmptyFloating); + // ── 测试 13:错误参数的命名空间类 ── + + [Fact] + public void NamespaceResource_WithInvalidPath_HasNoProviders() + { + var ns = new AssetsNamespaceResource(@"Z:\nonexistent\ns", "ns", "mod", "1.21"); + + // LocalConfig 应为 Shared(文件不存在) + Assert.Same(FloatingConfig.Shared, ns.LocalConfig); + + // PackerPolicies 应为 Shared + Assert.Same(PackerPolicy.Shared, ns.PackerPolicies); + } + + [Fact] + public void NamespaceResource_WithEmptyLocalPath_DoesNotCrash() + { + var ns = new AssetsNamespaceResource("", "ns", "mod", "1.21"); + Assert.NotNull(ns); + Assert.Equal("", ns.LocalPath); + } + + [Fact] + public void NamespaceResource_Equality_Is_ValueBased() + { + var a = new AssetsNamespaceResource("/path/a", "ns1", "mod1", "1.21"); + var b = new AssetsNamespaceResource("/path/a", "ns1", "mod1", "1.21"); + var c = new AssetsNamespaceResource("/path/b", "ns2", "mod2", "1.21"); + + Assert.Equal(a, b); + Assert.NotEqual(a, c); + } + + // ── 测试 3(补充):没有文件和目录时行为 ── + + [Fact] + public void EmptyNamespace_Directory_Returns_NoFileProviders() + { + using var dir = new TempDir(); + var ns = new AssetsNamespaceResource(dir.Path, "ns", "mod", "1.21"); + + var providers = ns.PackerPolicies.CreateProviders(ns, EmptyGlobal).ToList(); + Assert.Empty(providers); + } +} diff --git a/src/Packer.Core.Tests/ResolverTests.cs b/src/Packer.Core.Tests/ResolverTests.cs new file mode 100644 index 000000000000..357754f4592a --- /dev/null +++ b/src/Packer.Core.Tests/ResolverTests.cs @@ -0,0 +1,32 @@ +using Packer.Core.Model.PackerPolicys; +using System.Text.Json; + +namespace Packer.Core.Tests; + +public class ResolverTests +{ + [Fact] + public void JsonOptions_Can_Deserialize_PackerPolicy() + { + var json = """[{ "type": "direct" }]"""; + var result = JsonSerializer.Deserialize(json, SourceGenerationContext.JsonOptions); + Assert.NotNull(result); + Assert.Single(result); + } + + [Fact] + public void UnregisteredType_FallsBack_To_Reflection() + { + var json = """{ "name": "张三", "age": 18 }"""; + var result = JsonSerializer.Deserialize(json, SourceGenerationContext.JsonOptions); + Assert.NotNull(result); + Assert.Equal("张三", result.Name); + Assert.Equal(18, result.Age); + } + + class Student + { + public string Name { get; set; } = ""; + public int Age { get; set; } + } +} diff --git a/src/Packer.Core/Model/Abstract/INamespaceProvider.cs b/src/Packer.Core/Model/Abstract/INamespaceProvider.cs new file mode 100644 index 000000000000..8ba5daba9771 --- /dev/null +++ b/src/Packer.Core/Model/Abstract/INamespaceProvider.cs @@ -0,0 +1,36 @@ +namespace Packer.Core.Model.Abstract; + +/// +/// 模组资源提供者。以 作为契约, +/// 不暴露目录树结构和中间模型。 +/// +public interface INamespaceProvider +{ + /// + /// 获取指定版本下的所有命名空间,按模组名分组。 + /// 返回的 是延迟的: + /// 在 SelectMany 展开 时才扫描对应目录。 + /// + /// Minecraft 版本号 + /// + /// Key = 模组标识(如 "jei", "minecraft") + /// Value = 该模组在该版本下的所有命名空间 + /// + ILookup GetModsByVersion(string version); + + /// + /// 获取指定模组的所有版本下的命名空间,按版本分组。 + /// + /// 模组标识 + /// + /// Key = 版本号 + /// Value = 该模组在该版本下的所有命名空间 + /// + ILookup GetVersionsByMod(string modName); + + /// + /// 直接获取指定模组 + 版本的命名空间枚举。 + /// 等价于 GetModsByVersion(version)[modName],但不构造分组结构。 + /// + IEnumerable GetNamespaces(string modName, string version); +} diff --git a/src/Packer.Core/Model/Abstract/INamespaceResource.cs b/src/Packer.Core/Model/Abstract/INamespaceResource.cs new file mode 100644 index 000000000000..610958789a07 --- /dev/null +++ b/src/Packer.Core/Model/Abstract/INamespaceResource.cs @@ -0,0 +1,26 @@ +namespace Packer.Core.Model.Abstract; + +/// +/// 命名空间资源。是资源包产出物的基本单元。 +/// +public interface INamespaceResource +{ + /// 所属模组标识(如 "jei", "minecraft") + string ModName { get; } + + /// 命名空间名称(如 "minecraft", "cfpa") + string NamespaceName { get; } + + /// 目标 Minecraft 版本(如 "1.21") + string ModVersion { get; } + + /// 文件系统上的完整路径 + string LocalPath { get; } + + /// 命名空间下的局域浮动配置(local-config.json),无则为 + FloatingConfig LocalConfig { get; } + + /// 打包策略集合,无则为 + PackerPolicy PackerPolicies { get; } + +} diff --git a/src/Packer.Core/Model/Abstract/IResourceFileProvider.cs b/src/Packer.Core/Model/Abstract/IResourceFileProvider.cs new file mode 100644 index 000000000000..7d2831090b81 --- /dev/null +++ b/src/Packer.Core/Model/Abstract/IResourceFileProvider.cs @@ -0,0 +1,30 @@ +namespace Packer.Core.Model.Abstract; + +/// +/// 表示可以被添加到资源包中的内容 +/// +public interface IResourceFileProvider +{ + /// + /// 目标在资源包中的相对位置 + /// + string Destination => string.IsNullOrEmpty(Namespace?.NamespaceName) + ? RelativePath + : Path.Combine("assets", Namespace.NamespaceName, RelativePath); + + /// + /// 命名空间 + /// + /// 若无,生成的路径不会携带"assets" + INamespaceResource? Namespace { get; } + + /// + /// 相对于命名空间的路径(如 "lang/zh_cn.json")。若没有命名空间,则相对于根目录的路径 + /// + string RelativePath { get; } + // + /// 获取文件内容流 + /// + /// 内容流,调用者负责释放 + Stream GetContentStream(); +} diff --git a/src/Packer.Core/Model/Configuration/BaseConfig.cs b/src/Packer.Core/Model/Configuration/BaseConfig.cs new file mode 100644 index 000000000000..b1e9ebb1efab --- /dev/null +++ b/src/Packer.Core/Model/Configuration/BaseConfig.cs @@ -0,0 +1,38 @@ +namespace Packer.Core.Model.Configuration; + +/// +/// 基础配置,版本唯一 +/// +/// 打包的目标版本 +/// 打包的目标语言 +/// 将用于生成pack.mcmeta的模板位置 +/// 将用于生成pack.mcmeta的参数列表;会自动在前面附加一个时间戳 +/// 将用于生成readme.txt的模板位置 +/// 将用于生成readme.txt的参数列表 +/// 不进行打包的mod(按[cursforge-]name) +/// 不进行打包的namespace +public record BaseConfig( + string Version, + string[] TargetLanguages, + string McMetaTemplate, + object[] McMetaParameters, + string ReadmeTemplate, + object[] ReadmeParameters, + IEnumerable ExclusionMods, + IEnumerable ExclusionNamespaces +) +{ + public IResourceFileProvider LoadMetaTemp() + { + var template = File.ReadAllText(McMetaTemplate); + object[] parameters = [DateTime.UtcNow.AddHours(8), .. McMetaParameters]; + var file = new TextFile(string.Format(template, parameters), "pack.mcmeta"); + return file; + } + public IResourceFileProvider LoadReadmeTemp() + { + var template = File.ReadAllText(ReadmeTemplate); + var file = new TextFile(string.Format(template, ReadmeParameters), "README.txt"); + return file; + } +} diff --git a/src/Packer.Core/Model/Configuration/Config.cs b/src/Packer.Core/Model/Configuration/Config.cs new file mode 100644 index 000000000000..b2e91d0483be --- /dev/null +++ b/src/Packer.Core/Model/Configuration/Config.cs @@ -0,0 +1,17 @@ +namespace Packer.Core.Model.Configuration; + +/// +/// 配置项 +/// +/// 基础配置,版本唯一 +/// 浮动配置,可与命名空间下的文件合并 +public record Config(BaseConfig Base, FloatingConfig Floating) +{ + /// + /// 从命名空间下的局域配置加载内容。 + /// + public Config Modify(FloatingConfig? floatingConfig) + { + return this with { Floating = Floating.Merge(floatingConfig) }; + } +} diff --git a/src/Packer.Core/Model/Configuration/FloatingConfig.cs b/src/Packer.Core/Model/Configuration/FloatingConfig.cs new file mode 100644 index 000000000000..62566dc480cd --- /dev/null +++ b/src/Packer.Core/Model/Configuration/FloatingConfig.cs @@ -0,0 +1,93 @@ +namespace Packer.Core.Model.Configuration; + +/// +/// 浮动配置,可与命名空间下的文件合并 +/// +/// 强制包含的domain +/// 强制排除的domain +/// 强制包含的路径 +/// 强制排除的路径 +/// 文本字符替换表 +/// 目标地址替换表 +public record FloatingConfig( + IEnumerable InclusionDomains, + IEnumerable ExclusionDomains, + IEnumerable InclusionPaths, + IEnumerable ExclusionPaths, + IReadOnlyDictionary CharacterReplacement, + IReadOnlyDictionary DestinationReplacement +) +{ + /// 在命名空间文件夹中,叫local-config.json的文件 + public const string DefaultFileName = "local-config.json"; + public static FloatingConfig Shared { get; } = new FloatingConfig( + Array.Empty(), + Array.Empty(), + Array.Empty(), + Array.Empty(), + new Dictionary(), + new Dictionary() + ); + + /// + /// 从目录加载局域配置。文件不存在或解析失败时返回 。 + /// + public static FloatingConfig Load(string directoryPath) + { + var configFile = Path.Combine(directoryPath, DefaultFileName); + if (!File.Exists(configFile)) + { + return Shared; + } + + try + { + var json = File.ReadAllText(configFile); + var config = JsonSerializer.Deserialize(json, SourceGenerationContext.JsonOptions)!; + Log.Logger.Debug("加载局部配置: {ConfigFile}", configFile); + return config; + } + catch (Exception ex) + { + Log.Logger.Error(ex, "加载局部配置失败: {ConfigFile}", configFile); + return Shared; + } + } + + /// + /// 从命名空间资源加载局域配置。 + /// + public static FloatingConfig Load(INamespaceResource ns) + { + return Load(ns.LocalPath); + } + + /// + /// 从另一对象合并配置 + /// + public FloatingConfig Merge(FloatingConfig? other) + { + + if ((other ??= Shared) == Shared) + { + return this; + } + if (this == Shared) + { + return other; + } + + return new FloatingConfig( + InclusionDomains: InclusionDomains.Union(other.InclusionDomains).ToFrozenSet(), + ExclusionDomains: ExclusionDomains.Union(other.ExclusionDomains).ToFrozenSet(), + InclusionPaths: InclusionPaths.Union(other.InclusionPaths).ToFrozenSet(), + ExclusionPaths: ExclusionPaths.Union(other.ExclusionPaths).ToFrozenSet(), + CharacterReplacement: CharacterReplacement.Concat(other.CharacterReplacement) + .DistinctBy(kv => kv.Key) + .ToFrozenDictionary(), + DestinationReplacement: DestinationReplacement.Concat(other.DestinationReplacement) + .DistinctBy(kv => kv.Key) + .ToFrozenDictionary() + ); + } +} diff --git a/src/Packer.Core/Model/ModProvider/AssetsModProvider.cs b/src/Packer.Core/Model/ModProvider/AssetsModProvider.cs new file mode 100644 index 000000000000..58239719b12b --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/AssetsModProvider.cs @@ -0,0 +1,30 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 基于 ./projects/assets/{modName}/{version}/{namespace}/ 目录结构的模组提供者。 +/// +public class AssetsModProvider : INamespaceProvider +{ + private const string _assetsRoot = "./projects/assets"; + + public ILookup GetModsByVersion(string version) + { + return new ModsByVersionLookup(version); + } + + public ILookup GetVersionsByMod(string modName) + { + return new VersionsByModLookup(modName); + } + + public IEnumerable GetNamespaces(string modName, string version) + { + var modPath = Path.Combine(_assetsRoot, modName, version); + if (!Directory.Exists(modPath)) + { + Log.Logger.Debug("模组资源不存在: {ModName}/{Version}", modName, version); + return []; + } + return new NamespaceGroup(modPath, version, modName, GroupKey.ModName); + } +} diff --git a/src/Packer.Core/Model/ModProvider/AssetsNamespaceResource.cs b/src/Packer.Core/Model/ModProvider/AssetsNamespaceResource.cs new file mode 100644 index 000000000000..818131cb3de3 --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/AssetsNamespaceResource.cs @@ -0,0 +1,20 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 基于 ./projects/assets 目录的命名空间资源。 +/// +/// 文件系统上的完整路径 +/// 命名空间名称 +/// 所属模组标识 +/// 目标 Minecraft 版本 +public record AssetsNamespaceResource( + string LocalPath, + string NamespaceName, + string ModName, + string ModVersion +) : INamespaceResource +{ + public FloatingConfig LocalConfig => field ??= FloatingConfig.Load(this); + + public PackerPolicy PackerPolicies => field ??= PackerPolicy.Load(this); +} diff --git a/src/Packer.Core/Model/ModProvider/GitChangedModProvider.cs b/src/Packer.Core/Model/ModProvider/GitChangedModProvider.cs new file mode 100644 index 000000000000..1ec62441c4ef --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/GitChangedModProvider.cs @@ -0,0 +1,67 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 基于 git diff 的增量命名空间提供器。 +/// 只产出相对于 origin/main 有变更的命名空间。 +/// +public class GitChangedModProvider : INamespaceProvider +{ + private const string _assetsRoot = "./projects/assets"; + + public ILookup GetModsByVersion(string version) + { + return GetChangedNamespaces() + .Where(ns => ns.ModVersion == version) + .Cast() + .ToLookup(ns => ns.ModName); + } + + public ILookup GetVersionsByMod(string modName) + { + return GetChangedNamespaces() + .Where(ns => ns.ModName == modName) + .Cast() + .ToLookup(ns => ns.ModVersion); + } + + public IEnumerable GetNamespaces(string modName, string version) + { + return GetChangedNamespaces() + .Where(ns => ns.ModName == modName && ns.ModVersion == version) + .Cast(); + } + + /// + /// 枚举 origin/main 与当前 HEAD 之间,有变更的命名空间资源。 + /// 路径结构:projects/assets/{mod}/{version}/{ns}/... + /// + private static IEnumerable GetChangedNamespaces() + { + using var repo = new Repository("."); + var headTree = repo.Head.Tip?.Tree; + var baseTree = repo.Branches["origin/main"]?.Tip?.Tree; + if (headTree is null || baseTree is null) + { + return Array.Empty(); + } + + var seen = new HashSet(); + + foreach (var change in repo.Diff.Compare(baseTree, headTree)) + { + foreach (var path in new[] { change.Path, change.OldPath }) + { + var segments = path.Split(['\\', '/']); + if (segments is ["projects", "assets", var mod, var ver, var ns, ..]) + { + var dir = Path.Combine(_assetsRoot, mod, ver, ns); + if (!Directory.Exists(dir)) + continue; + seen.Add(new AssetsNamespaceResource(dir, ns, mod, ver)); + } + } + } + + return seen; + } +} diff --git a/src/Packer.Core/Model/ModProvider/ModsByVersionLookup.cs b/src/Packer.Core/Model/ModProvider/ModsByVersionLookup.cs new file mode 100644 index 000000000000..6b87531d45ff --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/ModsByVersionLookup.cs @@ -0,0 +1,53 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 遍历 ./projects/assets/*/{version}/ 目录,按模组名分组。 +/// +internal class ModsByVersionLookup(string version) : ILookup +{ + private const string _assetsRoot = "./projects/assets"; + private Dictionary Dictionary + { + get + { + if (field is null) + { + if (!Directory.Exists(_assetsRoot)) + { + Log.Logger.Warning("资源目录不存在: {AssetsRoot}", _assetsRoot); + field = []; + } + else + { + field = Directory.EnumerateDirectories(_assetsRoot) + .Select(dir => ( + modName: Path.GetFileName(dir), + versionDir: Path.Combine(dir, version))) + .Where(t => Directory.Exists(t.versionDir)) + .ToDictionary(t => t.modName, + t => new NamespaceGroup(t.versionDir, version, t.modName, GroupKey.ModName)); + } + } + return field; + } + } + + public IEnumerable this[string key] => Dictionary.GetValueOrDefault(key)!.AsEnumerable() ?? []; + + public int Count => Dictionary.Count; + + public bool Contains(string key) + { + return Dictionary.ContainsKey(key); + } + + public IEnumerator> GetEnumerator() + { + return Dictionary.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/src/Packer.Core/Model/ModProvider/NamespaceGroup.cs b/src/Packer.Core/Model/ModProvider/NamespaceGroup.cs new file mode 100644 index 000000000000..7c1fe2ccb399 --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/NamespaceGroup.cs @@ -0,0 +1,57 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 命名空间分组。实现 , +/// 延迟枚举第一级子目录下所有 。 +/// Key 由 决定是模组名还是版本名。 +/// +class NamespaceGroup( + string localPath, + string version, + string modName, + GroupKey groupKey +) : IGrouping, IReadOnlyCollection +{ + /// 命名空间在磁盘上的路径 + public string LocalPath { get; } = localPath; + + /// Minecraft 版本 + public string Version { get; } = version; + + /// 所属模组 + public string ModName { get; } = modName; + + /// 延迟加载:首次访问时扫描 下的子目录 + INamespaceResource[] NamespaceResource => field ??= Directory.EnumerateDirectories(LocalPath) + .Select(dir => new AssetsNamespaceResource(dir, Path.GetFileName(dir), ModName, Version)) + .ToArray(); + + string IGrouping.Key { get; } = groupKey switch + { + GroupKey.ModName => modName, + GroupKey.Version => version, + _ => throw new ArgumentOutOfRangeException(nameof(groupKey), groupKey, $"Unsupported group key: {groupKey}") + }; + + /// 命名空间数量(触发懒加载) + public int Count => NamespaceResource.Length; + + public IEnumerator GetEnumerator() + { + return NamespaceResource.AsEnumerable().GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} + +/// 指定 的 Key 使用哪个字段 +enum GroupKey +{ + /// Key = 模组名 + ModName, + /// Key = 版本号 + Version +} diff --git a/src/Packer.Core/Model/ModProvider/VersionsByModLookup.cs b/src/Packer.Core/Model/ModProvider/VersionsByModLookup.cs new file mode 100644 index 000000000000..df0965e43507 --- /dev/null +++ b/src/Packer.Core/Model/ModProvider/VersionsByModLookup.cs @@ -0,0 +1,51 @@ +namespace Packer.Core.Model.ModProvider; + +/// +/// 遍历 ./projects/assets/{modName}/*/ 目录,按版本分组。 +/// +internal class VersionsByModLookup(string modName) : ILookup +{ + private const string _assetsRoot = "./projects/assets"; + + private Dictionary Dictionary + { + get + { + if (field is null) + { + var modPath = Path.Combine(_assetsRoot, modName); + if (!Directory.Exists(modPath)) + { + Log.Logger.Debug("模组目录不存在: {ModPath}", modPath); + field = []; + } + else + { + field = Directory.EnumerateDirectories(modPath) + .Select(vec => new NamespaceGroup( + vec, Path.GetFileName(vec), modName, GroupKey.Version)) + .ToDictionary(g => g.Version, g => g); + } + } + return field; + } + } + public IEnumerable this[string key] => Dictionary.GetValueOrDefault(key)!.AsEnumerable() ?? []; + + public int Count => Dictionary.Count; + + public bool Contains(string key) + { + return Dictionary.ContainsKey(key); + } + + public IEnumerator> GetEnumerator() + { + return Dictionary.Values.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/CompositionPolicy.cs b/src/Packer.Core/Model/PackerPolicys/CompositionPolicy.cs new file mode 100644 index 000000000000..2c2fedf88601 --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/CompositionPolicy.cs @@ -0,0 +1,68 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 组合策略。从一个组合文件中读取模板和参数, +/// 笛卡尔积展开后生成语言文件条目。 +/// 适用于有大量重复格式的翻译(如颜色名、物品系列)。 +/// +/// 组合文件的完整路径 +/// 目标文件类型,支持 "json""lang" +/// +/// 组合文件格式参见 Packer-Doc.md 的「组合文件」章节。 +/// 组合文件本身不会被自动排除,需要在 local-config.json 中 +/// 将组合文件的路径加入 exclusionPaths。 +/// +public record CompositionPolicy(string Source, string DestType) : PackerPolicyItem +{ + /// + /// 从组合文件中读取模板和参数,笛卡尔积展开后生成语言文件条目。 + /// 组合文件本身不会被自动排除,需通过 local-config.jsonexclusionPaths 排除。 + /// + /// 当前命名空间 + /// 合并后的浮动配置,用于设置 + public IEnumerable CreateProviders( + INamespaceResource ns, FloatingConfig config) + { + var json = File.ReadAllText(Source); + var doc = JsonDocument.Parse(json); + var target = doc.RootElement.GetProperty("target").GetString()!; + var entries = new List(); + foreach (var e in doc.RootElement.GetProperty("entries").EnumerateArray()) + { + var templates = new Dictionary(); + foreach (var t in e.GetProperty("templates").EnumerateObject()) + templates[t.Name] = t.Value.GetString()!; + var parameters = new List>(); + foreach (var p in e.GetProperty("parameters").EnumerateArray()) + { + var dict = new Dictionary(); + foreach (var kv in p.EnumerateObject()) + dict[kv.Name] = kv.Value.GetString()!; + parameters.Add(dict); + } + entries.Add(new CompositionEntry(templates, parameters)); + } + + IResourceFileProvider provider = DestType switch + { + "json" => new CompositionJsonFile(target, entries) + { + Namespace = ns, + PolicyItem = this, + EffectiveConfig = config + }, + "lang" => CreateLangFile(target, entries, ns, config), + _ => throw new NotSupportedException($"不支持的目标类型: {DestType}") + }; + return [provider]; + } + + private CompositionLangFile CreateLangFile(string target, List entries, INamespaceResource ns, FloatingConfig config) + { + return new CompositionLangFile(target, entries) + { + PolicyItem = this, + EffectiveConfig = config + }; + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/DirectPolicy.cs b/src/Packer.Core/Model/PackerPolicys/DirectPolicy.cs new file mode 100644 index 000000000000..d3180b6f4d74 --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/DirectPolicy.cs @@ -0,0 +1,46 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 原位检索策略。直接扫描命名空间文件夹下的文件结构, +/// 按文档定义的文件容斥顺序(ExclusionDomains → ExclusionPaths +/// → InclusionPaths/InclusionDomains → targetLanguages)过滤。 +/// 这是默认策略,当 packer-policy.json 不存在时等效于 。 +/// +public record DirectPolicy : PackerPolicyItem +{ + /// + /// 原位检索命名空间目录,按文件容斥顺序过滤并产出 Provider。 + /// + /// 当前命名空间 + /// 全局配置,用于读取 Base.TargetLanguages 等基础配置 + /// 全局浮动配置与局域配置合并后的结果,用于 Domain/Path 过滤和字符替换 + public IEnumerable CreateProviders( + INamespaceResource ns, Config globalConfig, FloatingConfig mergedConfig) + { + foreach (var domainDir in Directory.EnumerateDirectories(ns.LocalPath)) + { + var domainName = Path.GetFileName(domainDir); + bool domainForceInclude = mergedConfig.InclusionDomains.Contains(domainName); + if (!domainForceInclude && mergedConfig.ExclusionDomains.Contains(domainName)) + continue; + + foreach (var file in Directory.EnumerateFiles(domainDir, "*", SearchOption.AllDirectories)) + { + var relativePath = Path.GetRelativePath(ns.LocalPath, file).Replace('\\', '/'); + if (mergedConfig.ExclusionPaths.Contains(relativePath)) + continue; + if (!domainForceInclude) + { + bool inTargetLang = globalConfig.Base.TargetLanguages.Any(lang => + relativePath.Contains(lang, StringComparison.OrdinalIgnoreCase)); + if (!mergedConfig.InclusionPaths.Contains(relativePath) && !inTargetLang) + continue; + } + var provider = CreateProvider(file, relativePath, ns); + if (provider is TextFile tf) + tf.EffectiveConfig = mergedConfig; + yield return provider; + } + } + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/IndirectPolicy.cs b/src/Packer.Core/Model/PackerPolicys/IndirectPolicy.cs new file mode 100644 index 000000000000..5a7b0193b60e --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/IndirectPolicy.cs @@ -0,0 +1,40 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 引用策略。将另一个命名空间的所有文件引用到当前命名空间下。 +/// 被引用的文件的目标路径中的命名空间会被自动替换为当前命名空间。 +/// 支持递归:被引用的命名空间若有自己的 packer-policy.json 也会被执行。 +/// +/// 被引用命名空间文件夹的完整路径 +/// +/// 典型用途:多版本同模组复用翻译文件。 +/// 示例:{ "type": "indirect", "source": "projects/assets/jei/1.20/jei" } +/// +public record IndirectPolicy(string Source) : PackerPolicyItem +{ + /// + /// 引用目标命名空间的所有文件,将其 改写为当前命名空间。 + /// 被引用目录的 packer-policy.json 会递归生效。 + /// + /// 当前命名空间(引用发起方),产出的文件路径会将命名空间改写为此值 + /// 全局配置(未合并局域配置),传递给目标的 由其自行合并 + public IEnumerable CreateProviders( + INamespaceResource ns, Config globalConfig) + { + var targetDir = new DirectoryInfo(Source); + if (!targetDir.Exists) + { + return Array.Empty(); + } + var targetNs = new AssetsNamespaceResource(targetDir.FullName, targetDir.Name, targetDir.Parent?.Name ?? "", ns.ModVersion); + return targetNs.PackerPolicies.CreateProviders(targetNs, globalConfig) + .Select(policy => + { + if (policy is ResourceFileProvider rfp) + { + rfp.Namespace = ns; + } + return policy; + }); + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/PackerPolicy.cs b/src/Packer.Core/Model/PackerPolicys/PackerPolicy.cs new file mode 100644 index 000000000000..ca0442b52da2 --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/PackerPolicy.cs @@ -0,0 +1,132 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 表示一个命名空间下的打包策略集合。 +/// 策略按添加顺序执行,冲突时前者优先。 +/// 当 packer-policy.json 不存在时,使用 (等效于 )。 +/// +/// +/// 实现 以支持集合表达式语法(如 [new DirectPolicy()])。 +/// 序列化时由 STJ 的 [JsonDerivedType] 多态分发到具体策略类型。 +/// +public sealed class PackerPolicy : ICollection +{ + private readonly List _items = new(); + + /// 策略文件的文件名 + public const string DefaultFileName = "packer-policy.json"; + + /// + /// 默认策略,等效于一条 。 + /// 当命名空间下无 packer-policy.json 时使用。 + /// + public static PackerPolicy Shared { get; } = [new DirectPolicy()]; + + /// 策略条数 + public int Count => _items.Count; + + /// 集合是否为只读。始终为 + public bool IsReadOnly => false; + + /// 添加一条策略。供 STJ 反序列化和集合表达式使用。 + [EditorBrowsable(EditorBrowsableState.Never)] + public void Add(PackerPolicyItem item) + { + _items.Add(item); + } + + /// 清空所有策略 + [EditorBrowsable(EditorBrowsableState.Never)] + public void Clear() + { + _items.Clear(); + } + + [EditorBrowsable(EditorBrowsableState.Never)] + /// 检查策略是否已存在 + public bool Contains(PackerPolicyItem item) + { + return _items.Contains(item); + } + + /// + public void CopyTo(PackerPolicyItem[] array, int arrayIndex) + { + _items.CopyTo(array, arrayIndex); + } + + /// 移除一条策略 + [EditorBrowsable(EditorBrowsableState.Never)] + public bool Remove(PackerPolicyItem item) + { + return _items.Remove(item); + } + + /// + /// 从命名空间文件夹加载 packer-policy.json。 + /// 文件不存在、内容为 null、或解析失败时返回 。 + /// + /// 命名空间的本地路径 + /// 解析后的策略集合,不会为 + public static PackerPolicy Load(string namespaceDirPath) + { + var policyFile = Path.Combine(namespaceDirPath, DefaultFileName); + if (!File.Exists(policyFile)) + return Shared; + try + { + var json = File.ReadAllText(policyFile); + Log.Debug("策略文件内容: {Json}", json); + var policies = JsonSerializer.Deserialize( + json, SourceGenerationContext.JsonOptions); + if (policies is null) + { Log.Warning("策略反序列化结果为 null: {File}", policyFile); return Shared; } + if (policies._items.Count == 0) + { Log.Warning("策略列表为空: {File}", policyFile); return Shared; } + Log.Debug("成功加载 {Count} 条策略: {File}", policies._items.Count, policyFile); + return policies; + } + catch (Exception ex) { Log.Warning(ex, "策略加载异常: {File}", policyFile); return Shared; } + } + + /// 重载。 + public static PackerPolicy Load(INamespaceResource ns) + { + return Load(ns.LocalPath); + } + + /// + /// 按顺序执行所有策略,产出 。 + /// + /// 当前命名空间资源 + /// 全局浮动配置,必须来自 Config.Floating(未与局域配置合并)。 + /// + /// 对于 引用策略,直接传入 (未合并), + /// 由引用策略内部自行与目标命名空间的局域配置合并。 + /// 对于其他策略(如 ), + /// 自动与 ns.LocalConfig 合并后再传入。 + /// + public IEnumerable CreateProviders(INamespaceResource ns, Config globalConfig) + { + var mergedConfig = globalConfig.Floating.Merge(ns.LocalConfig); + return _items.SelectMany(item => item switch + { + DirectPolicy d => d.CreateProviders(ns, globalConfig, mergedConfig), + IndirectPolicy i => i.CreateProviders(ns, globalConfig), + CompositionPolicy c => c.CreateProviders(ns, mergedConfig), + SingletonPolicy s => s.CreateProviders(ns, mergedConfig), + _ => throw new InvalidOperationException($"Unknown policy type: {item.GetType()}") + }); + } + + /// + public IEnumerator GetEnumerator() + { + return _items.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return _items.GetEnumerator(); + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/PackerPolicyItem.cs b/src/Packer.Core/Model/PackerPolicys/PackerPolicyItem.cs new file mode 100644 index 000000000000..8339af981265 --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/PackerPolicyItem.cs @@ -0,0 +1,82 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 打包策略项的基类。 +/// 通过 [JsonDerivedType] 实现 JSON type discriminator 多态反序列化, +/// packer-policy.json 中的 "type" 字段决定具体策略类型。 +/// +/// +/// 子类:、 +/// 。 +/// +[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")] +[JsonDerivedType(typeof(DirectPolicy), "direct")] +[JsonDerivedType(typeof(IndirectPolicy), "indirect")] +[JsonDerivedType(typeof(CompositionPolicy), "composition")] +[JsonDerivedType(typeof(SingletonPolicy), "singleton")] +public abstract record PackerPolicyItem +{ + /// + /// 对于语言文件的合并,是否只修改已有的键而不添加新键。 + /// 时,只覆盖已存在键的值,不引入本策略新增的键。 + /// 对其他文件类型无效。 + /// + public bool ModifyOnly { get; init; } + + /// + /// 对于文本文件,是否在已有内容后换行追加。 + /// 时,将本步的文本追加到上一步内容的末尾。 + /// 对其他文件类型无效。 + /// + public bool Append { get; init; } + + + /// + /// 根据文件扩展名和所在目录,创建对应类型的 。 + /// + /// 源文件的完整路径 + /// 相对于命名空间的路径 + /// 所属命名空间 + /// + /// lang/ 下的 .json + /// lang/ 下的 .lang + /// 其他文本文件(.txt .json .md)→ + /// 其余 → + /// + protected IResourceFileProvider CreateProvider(string filePath, string relativePath, INamespaceResource ns) + { + var ext = Path.GetExtension(filePath); + var parentDir = Path.GetFileName(Path.GetDirectoryName(filePath)); + + switch (parentDir, ext) + { + case { parentDir: "lang", ext: ".json" }: + var json = File.ReadAllText(filePath); + var entries = new Dictionary(); + foreach (var prop in JsonDocument.Parse(json).RootElement.EnumerateObject()) + if (prop.Value.ValueKind == JsonValueKind.String) + entries[prop.Name] = prop.Value.GetString()!; + return new JsonFile(entries, relativePath) + { + Namespace = ns, + PolicyItem = this + }; + + case { parentDir: "lang", ext: ".lang" }: + var content = File.ReadAllText(filePath); + entries = LangFile.DeserializeFromLang(content); + return new LangFile(entries, relativePath) + { + Namespace = ns, + PolicyItem = this + }; + + case { parentDir: "lang" }: + case { ext: ".txt" or ".json" or ".md" }: + return new TextFile(File.ReadAllText(filePath), relativePath) { Namespace = ns, PolicyItem = this }; + + default: + return new RawFile(filePath, relativePath) { Namespace = ns }; + } + } +} diff --git a/src/Packer.Core/Model/PackerPolicys/SingletonPolicy.cs b/src/Packer.Core/Model/PackerPolicys/SingletonPolicy.cs new file mode 100644 index 000000000000..ff50822c2153 --- /dev/null +++ b/src/Packer.Core/Model/PackerPolicys/SingletonPolicy.cs @@ -0,0 +1,34 @@ +namespace Packer.Core.Model.PackerPolicys; + +/// +/// 单文件引用策略。将任意位置的一个文件引用到当前命名空间下的指定路径。 +/// 不会读取目标文件的 packer-policy.json,只引用单个文件。 +/// +/// 源文件的完整路径 +/// 在当前命名空间下的相对路径 +/// +/// 典型用途:从另一个命名空间只取某个文件(而非整个 Indirect), +/// 或者作为 Indirect 的前置策略来覆盖个别文件。 +/// +public record SingletonPolicy(string Source, string RelativePath) : PackerPolicyItem +{ + /// + /// 引用单个文件到当前命名空间下的指定路径。 + /// 不读取目标文件的 packer-policy.json,只引用该文件本身。 + /// + /// 当前命名空间 + /// 合并后的浮动配置,用于设置 + public IEnumerable CreateProviders( + INamespaceResource ns, FloatingConfig config) + { + if (!File.Exists(Source)) + { + Log.Logger.Warning("Singleton 源文件不存在: {Source}", Source); + yield break; + } + var provider = CreateProvider(Source, RelativePath, ns); + if (provider is TextFile tf) + tf.EffectiveConfig = config; + yield return provider; + } +} diff --git a/src/Packer.Core/Model/ResourceFile/CompositionJsonFile.cs b/src/Packer.Core/Model/ResourceFile/CompositionJsonFile.cs new file mode 100644 index 000000000000..a7c3fffb3f8f --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/CompositionJsonFile.cs @@ -0,0 +1,84 @@ +namespace Packer.Core.Model.ResourceFile; + +public class CompositionJsonFile : JsonFile +{ + private readonly string _target; + + public CompositionJsonFile(string target, List entries) + : base(Path.GetFileName(target) ?? target) + { + _target = target; + foreach (var entry in entries) + { + entry.BuildDictionary(Entries); + } + } + + private CompositionJsonFile(string target, Dictionary entries) + : base(Path.GetFileName(target) ?? target) + { + _target = target; + Entries = entries; + } + + public override string Destination => _target; + + public override KVPFile Merge(KVPFile other) + { + var merged = new Dictionary(Entries); + if (other is JsonFile otherJson) + { + bool modifyOnly = other.PolicyItem?.ModifyOnly ?? false; + if (modifyOnly) + { + foreach (var kv in otherJson.Entries) + { + if (merged.ContainsKey(kv.Key)) + { + merged[kv.Key] = kv.Value; + } + } + } + else + { + foreach (var kv in otherJson.Entries) + { + merged.TryAdd(kv.Key, kv.Value); + } + } + } + return new CompositionJsonFile(_target, merged) { PolicyItem = PolicyItem, Namespace = Namespace }; + } + +} + +public record CompositionEntry( + Dictionary Templates, + List> Parameters +) +{ + public Dictionary BuildDictionary(Dictionary? dic = null) + { + dic ??= []; + foreach (var tpl in Templates) + { + foreach (var prm in CartesianProduct(Parameters)) + { + var arr = prm.ToArray(); + var k = string.Format(tpl.Key, arr.Select(s => s.Key).ToArray()); + var v = string.Format(tpl.Value, arr.Select(s => s.Value).ToArray()); + dic[k] = v; + } + } + return dic; + } + + private static IEnumerable> CartesianProduct(IEnumerable> sequences) + { + return sequences.Aggregate( + new[] { Enumerable.Empty() }.AsEnumerable(), + (accumulator, sequence) => accumulator + .SelectMany(prefix => sequence, (prefix, item) => prefix.Append(item)) + ); + } +} diff --git a/src/Packer.Core/Model/ResourceFile/CompositionLangFile.cs b/src/Packer.Core/Model/ResourceFile/CompositionLangFile.cs new file mode 100644 index 000000000000..0ce23ea203aa --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/CompositionLangFile.cs @@ -0,0 +1,48 @@ +namespace Packer.Core.Model.ResourceFile; + +/// +/// 由组合文件生成的 .lang 格式语言文件。与 对应。 +/// 直接使用原本的 target 值作为路径,不参与 Namespace 拼接。 +/// +public class CompositionLangFile : LangFile +{ + private readonly string _target; + + public CompositionLangFile(string target, List entries) + : base(Path.GetFileName(target) ?? target) + { + _target = target; + foreach (var entry in entries) + entry.BuildDictionary(Entries); + } + + private CompositionLangFile(string target, Dictionary entries) + : base(Path.GetFileName(target) ?? target) + { + _target = target; + Entries = entries; + } + + public override string Destination => _target; + + public override KVPFile Merge(KVPFile other) + { + var merged = new Dictionary(Entries); + if (other is LangFile otherLang) + { + bool modifyOnly = other.PolicyItem?.ModifyOnly ?? false; + if (modifyOnly) + { + foreach (var kv in otherLang.Entries) + if (merged.ContainsKey(kv.Key)) + merged[kv.Key] = kv.Value; + } + else + { + foreach (var kv in otherLang.Entries) + merged.TryAdd(kv.Key, kv.Value); + } + } + return new CompositionLangFile(_target, merged) { PolicyItem = PolicyItem, Namespace = Namespace }; + } +} diff --git a/src/Packer.Core/Model/ResourceFile/JsonFile.cs b/src/Packer.Core/Model/ResourceFile/JsonFile.cs new file mode 100644 index 000000000000..8d7446ade9c2 --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/JsonFile.cs @@ -0,0 +1,61 @@ +namespace Packer.Core.Model.ResourceFile; + +/// +/// JSON 格式语言文件提供器。写入时按键排序,保证输出确定性。 +/// +public class JsonFile : KVPFile +{ + public JsonFile(Dictionary entries, string relativePath) + : base(entries, relativePath) { } + + /// 供子类(如 )使用,不传字典 + protected JsonFile(string relativePath) + : base(relativePath) { } + + /// 合并另一个 JSON 语言文件 + public override KVPFile Merge(KVPFile other) + { + var merged = new Dictionary(Entries); + if (other is JsonFile otherJson) + { + bool modifyOnly = other.PolicyItem?.ModifyOnly ?? false; + if (modifyOnly) + { + foreach (var (k, v) in otherJson.Entries) + if (merged.ContainsKey(k)) + merged[k] = v; + } + else + { + foreach (var (k, v) in otherJson.Entries) + merged.TryAdd(k, v); + } + } + return new JsonFile(merged, RelativePath) { PolicyItem = PolicyItem }; + } + + /// 序列化为 JSON,按键排序,写入前执行字符替换 + public override Stream GetContentStream() + { + var entries = Entries; + if (EffectiveConfig is not null) + { + entries = new Dictionary(Entries.Count); + foreach (var kv in Entries) + { + var replaced = kv.Value; + foreach (var rep in EffectiveConfig.CharacterReplacement) + replaced = Regex.Replace(replaced, rep.Key, rep.Value); + entries[kv.Key] = replaced; + } + } + + var sorted = new SortedDictionary(entries); + var json = JsonSerializer.Serialize(sorted, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }); + return new MemoryStream(Encoding.UTF8.GetBytes(json)); + } +} diff --git a/src/Packer.Core/Model/ResourceFile/KVPFile.cs b/src/Packer.Core/Model/ResourceFile/KVPFile.cs new file mode 100644 index 000000000000..a4c771161898 --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/KVPFile.cs @@ -0,0 +1,30 @@ +namespace Packer.Core.Model.ResourceFile; + +/// +/// 键值对文件提供器基类。用于语言文件等由 Key → Value 映射组成的文件。 +/// 支持合并:TryAdd(先到先得)/ ModifyOnly(只覆盖已有键)。 +/// +public abstract class KVPFile : TextFile +{ + /// 键值对条目 + public Dictionary Entries { get; protected set; } = []; + + /// 在资源包中的相对路径 + protected KVPFile(string relativePath) : base(relativePath) + { + } + + /// + /// 从已有字典构造。 + /// + protected KVPFile(Dictionary entries, string relativePath) + : this(relativePath) + { + Entries = entries; + } + + /// + /// 与另一个 KVPFile 合并,返回新实例。 + /// + public abstract KVPFile Merge(KVPFile other); +} diff --git a/src/Packer.Core/Model/ResourceFile/LangFile.cs b/src/Packer.Core/Model/ResourceFile/LangFile.cs new file mode 100644 index 000000000000..d3399c1a8676 --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/LangFile.cs @@ -0,0 +1,133 @@ +namespace Packer.Core.Model.ResourceFile; + +/// +/// .lang 格式语言文件提供器。写入时按键排序,保证输出确定性。 +/// +public class LangFile : KVPFile +{ + public LangFile(Dictionary entries, string relativePath) + : base(entries, relativePath) { } + + protected LangFile(string relativePath) + : base(relativePath) { } + + /// 合并另一个 .lang 语言文件 + public override KVPFile Merge(KVPFile other) + { + var merged = new Dictionary(Entries); + if (other is LangFile o) + { + if (o.PolicyItem?.ModifyOnly ?? false) + { + foreach (var e in o.Entries) + if (merged.ContainsKey(e.Key)) + merged[e.Key] = e.Value; + } + else + { + foreach (var e in o.Entries) + merged.TryAdd(e.Key, e.Value); + } + } + return new LangFile(merged, RelativePath) { PolicyItem = PolicyItem }; + } + + /// 序列化为 key=value 格式,按键排序,写入前执行字符替换 + public override Stream GetContentStream() + { + var entries = Entries; + if (EffectiveConfig is not null) + { + entries = new Dictionary(Entries.Count); + foreach (var kv in Entries) + { + var replaced = kv.Value; + foreach (var rep in EffectiveConfig.CharacterReplacement) + replaced = Regex.Replace(replaced, rep.Key, rep.Value); + entries[kv.Key] = replaced; + } + } + + var sb = new StringBuilder(); + foreach (var kv in new SortedDictionary(entries)) + { + sb.Append(kv.Key); + sb.Append('='); + sb.AppendLine(kv.Value); + } + return new MemoryStream(Encoding.UTF8.GetBytes(sb.ToString())); + } + internal static Dictionary DeserializeFromLang(string content) + { + var result = new Dictionary(); + var escapeMode = false; + var state = LangParseState.Normal; + string pendingKey = ""; + StringBuilder pendingValue = new StringBuilder(); + + + foreach (var line in content.EnumerateLines()) + { + switch (state) + { + case LangParseState.LineContinuation when line.EndsWith("\\"): + pendingValue.Append(line.TrimStart()[..^1]); + continue; + + case LangParseState.LineContinuation: + pendingValue.Append(line.TrimStart()); + result.TryAdd(pendingKey, pendingValue.ToString()); + pendingValue.Clear(); + state = LangParseState.Normal; + continue; + + case LangParseState.MultiLineComment when line.Trim().EndsWith("*/"): + state = LangParseState.Normal; + continue; + + case LangParseState.MultiLineComment: + continue; + } + + // ── Normal 状态 ── + + switch (line) + { + case "#PARSE_ESCAPES": + escapeMode = true; + continue; + case ['/', '*', ..]: + state = LangParseState.MultiLineComment; + continue; + case ['#' or '<', ..] or ['/', '/', ..]: + continue; + } + + + + var eq = line.IndexOf('='); + if (eq == -1) + { + continue; + } + + var key = line[..eq]; + var value = eq + 1 < line.Length ? line[(eq + 1)..] : ""; + + if (escapeMode && value.EndsWith("\\")) + { + state = LangParseState.LineContinuation; + pendingKey = key.ToString(); + pendingValue.Append(value[..^1]); + } + else + { + result.TryAdd(key.ToString(), value.ToString()); + } + } + + return result; + } + + private enum LangParseState { Normal, MultiLineComment, LineContinuation } +} diff --git a/src/Packer.Core/Model/ResourceFile/RawFile.cs b/src/Packer.Core/Model/ResourceFile/RawFile.cs new file mode 100644 index 000000000000..3891eea28fd7 --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/RawFile.cs @@ -0,0 +1,13 @@ +namespace Packer.Core.Model.ResourceFile; + +/// 二进制文件提供器。不参与合并与字符替换。 +internal class RawFile(string filePath, string relativePath) : ResourceFileProvider(relativePath) +{ + /// 源文件路径 + public string FilePath { get; } = filePath; + + public override Stream GetContentStream() + { + return File.OpenRead(FilePath); + } +} diff --git a/src/Packer.Core/Model/ResourceFile/ResourceFileProvider.cs b/src/Packer.Core/Model/ResourceFile/ResourceFileProvider.cs new file mode 100644 index 000000000000..1521e3f35b39 --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/ResourceFileProvider.cs @@ -0,0 +1,23 @@ +namespace Packer.Core.Model.ResourceFile; + +/// +/// 资源文件提供器基类。持有命名空间引用和相对路径,自动拼接目标路径。 +/// +/// 相对于命名空间的路径(如 lang/zh_cn.json) +public abstract class ResourceFileProvider(string relativePath) : IResourceFileProvider +{ + /// 所属命名空间,用于拼接 + public INamespaceResource? Namespace { get; set; } + + /// 相对路径,构造时统一为 / 分隔符 + public string RelativePath { get; } = relativePath.Replace('\\', '/'); + + /// 资源包内的完整目标路径 + public virtual string Destination + => string.IsNullOrEmpty(Namespace?.NamespaceName) + ? RelativePath + : $"assets/{Namespace.NamespaceName}/{RelativePath}"; + + /// 获取文件内容流 + public abstract Stream GetContentStream(); +} diff --git a/src/Packer.Core/Model/ResourceFile/TextFile.cs b/src/Packer.Core/Model/ResourceFile/TextFile.cs new file mode 100644 index 000000000000..43d9a0a6dc7d --- /dev/null +++ b/src/Packer.Core/Model/ResourceFile/TextFile.cs @@ -0,0 +1,42 @@ +namespace Packer.Core.Model.ResourceFile; + +public class TextFile : ResourceFileProvider +{ + public string Content { get; protected set; } = string.Empty; + + /// 生效的浮动配置, 写入前根据此配置做字符替换。 + public FloatingConfig? EffectiveConfig { get; set; } + + /// 创建此 provider 的策略项,合并时读取 Append + public PackerPolicyItem? PolicyItem { get; set; } + + public TextFile(string content, string relativePath) + : base(relativePath) + { + Content = content; + } + + protected TextFile(string relativePath) + : base(relativePath) { } + + /// 与另一个 TextFile 合并。 + public virtual TextFile Merge(TextFile other) + { + if (this is KVPFile selfKvp && other is KVPFile otherKvp) + return selfKvp.Merge(otherKvp); + return other.PolicyItem?.Append == true + ? new TextFile(string.Concat(Content, Environment.NewLine, other.Content), RelativePath) + : this; + } + + public override Stream GetContentStream() + { + var text = Content; + if (EffectiveConfig is not null) + { + foreach (var (pattern, replacement) in EffectiveConfig.CharacterReplacement) + text = Regex.Replace(text, pattern, replacement); + } + return new MemoryStream(Encoding.UTF8.GetBytes(text)); + } +} diff --git a/src/Packer.Core/Packer.Core.csproj b/src/Packer.Core/Packer.Core.csproj new file mode 100644 index 000000000000..da5b67a94576 --- /dev/null +++ b/src/Packer.Core/Packer.Core.csproj @@ -0,0 +1,23 @@ + + + + Exe + net10.0 + enable + enable + + + + + + + + + + + + diff --git a/src/Packer.Core/Program.cs b/src/Packer.Core/Program.cs new file mode 100644 index 000000000000..07947e7abdad --- /dev/null +++ b/src/Packer.Core/Program.cs @@ -0,0 +1,106 @@ +// ============================================================ +// Packer.Core — CFPA 模组翻译资源包构建工具 +// 入口:解析参数 → 加载配置 → 获取命名空间(全量/增量) +// → 按策略展开 Provider → 合并 → 替换 → 写 ZIP → MD5 +// ============================================================ + +// -- 参数解析 ------------------------------------------------------------------ +var versionOpt = new Option("--version", "--v"); +var incrementOpt = new Option("--increment", "--i"); +var rootCmd = new Command("pack") { versionOpt, incrementOpt }; +var result = rootCmd.Parse(args); +var version = result.GetValue(versionOpt)!; +var increment = result.GetValue(incrementOpt); + +// -- 日志 --------------------------------------------------------------------- +Log.Logger = new LoggerConfiguration() + .Enrich.FromLogContext() + .WriteTo.Console() + .MinimumLevel.Debug() + .CreateLogger(); + +Log.Information("开始对版本 {0} 的打包", version); + +// -- 加载全局配置 ------------------------------------------------------------- +var config = JsonSerializer.Deserialize( + File.ReadAllText($"config/packer/{version}.json"), + SourceGenerationContext.JsonOptions)!; + +// -- 获取命名空间提供器(全量扫磁盘 / 增量 git diff) --------------------------- +INamespaceProvider nsProvider = increment + ? new GitChangedModProvider() + : new AssetsModProvider(); + +// -- 展开所有 Provider -------------------------------------------------------- +// 流 程:ILookup<模组, 命名空间> +// → 过滤 ExclusionMods / ExclusionNamespaces +// → 每个命名空间执行其 PackerPolicies +// → 每个 Policy 调用 CreateProviders 产出 IResourceFileProvider +// → 物化到 List 以便分组合并 +var allProviders = nsProvider.GetModsByVersion(version) + .SelectMany(g => g) + .Where(ns => !config.Base.ExclusionMods.Contains(ns.ModName)) + .Where(ns => !config.Base.ExclusionNamespaces.Contains(ns.NamespaceName)) + .SelectMany(ns => ns.PackerPolicies.CreateProviders(ns, config)) + .ToLookup(p => p.Destination); + +// -- 按 Destination 合并同名文件 ---------------------------------------------- +// 同类型 KVPFile(JsonFile/LangFile)走 Merge 方法合并词条 +// 其他类型冲突时保留第一个,打出警告 +var merged = allProviders.Select(group => +{ + if (!group.Skip(1).Any()) + return group.First(); + if (group.All(p => p is TextFile)) + { + TextFile result = group.All(p => p is KVPFile) + ? group.Cast().Aggregate((a, n) => a.Merge(n)) + : group.Cast().Aggregate((a, n) => a.Merge(n)); + result.Namespace = ((ResourceFileProvider)group.First()).Namespace; + return result; + } + Log.Warning("Destination 冲突但无法合并: {Dest}, 保留第一个", group.Key); + return group.First(); +}).ToList(); + +// -- 初始文件(pack.png / LICENSE / README.txt / pack.mcmeta)-------------------- +var initialFiles = new List +{ + new RawFile("./projects/templates/pack.png", "pack.png"), + new TextFile(File.ReadAllText("./projects/templates/LICENSE"), "LICENSE") { EffectiveConfig = config.Floating }, + config.Base.LoadReadmeTemp(), + config.Base.LoadMetaTemp() +}; + +foreach (var f in initialFiles.OfType()) + f.EffectiveConfig ??= config.Floating; + +// -- 写入 ZIP ----------------------------------------------------------------- +var packName = $"./Minecraft-Mod-Language-Modpack-{config.Base.Version}.zip"; +await using var stream = File.Create(packName); +var destReplacements = config.Floating.DestinationReplacement + .ToDictionary(kv => new Regex(kv.Key), kv => kv.Value); + +using (var archive = new ZipArchive(stream, ZipArchiveMode.Update, leaveOpen: true)) +{ + foreach (var p in merged.Concat(initialFiles)) + { + var dest = p.Destination; + foreach (var (pattern, replacement) in destReplacements) + { + dest = pattern.Replace(dest, replacement); + } + + using var content = p.GetContentStream(); + var entry = archive.CreateEntry(dest); + using var es = entry.Open(); + await content.CopyToAsync(es); + } +} + +// -- 计算 MD5 并写入校验文件 --------------------------------------------------- +stream.Seek(0, SeekOrigin.Begin); +var md5 = Convert.ToHexString(MD5.Create().ComputeHash(stream)); +Log.Information("打包文件的 MD5 值:{0}", md5); +File.WriteAllText($"./{config.Base.Version}.md5", md5); +Log.Information("对版本 {0} 的打包结束", version); diff --git a/src/Packer.Core/SourceGenerationContext.cs b/src/Packer.Core/SourceGenerationContext.cs new file mode 100644 index 000000000000..78a588f63472 --- /dev/null +++ b/src/Packer.Core/SourceGenerationContext.cs @@ -0,0 +1,20 @@ +using System.Text.Json.Serialization.Metadata; + +namespace Packer.Core; + + +[JsonSerializable(typeof(PackerPolicy))] +[JsonSerializable(typeof(Config))] +internal partial class SourceGenerationContext : JsonSerializerContext +{ + public static JsonSerializerOptions JsonOptions { get; } = new JsonSerializerOptions + { + WriteIndented = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + AllowOutOfOrderMetadataProperties = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + TypeInfoResolver = JsonTypeInfoResolver.Combine( + Default, + new DefaultJsonTypeInfoResolver()) + }; +} diff --git a/src/Packer.Core/Using.cs b/src/Packer.Core/Using.cs new file mode 100644 index 000000000000..9d5db633ff30 --- /dev/null +++ b/src/Packer.Core/Using.cs @@ -0,0 +1,23 @@ +//global using Packer.Core.Model.ModProvider; +global using LibGit2Sharp; +global using Packer.Core; +global using Packer.Core.Model.Abstract; +global using Packer.Core.Model.Configuration; +global using Packer.Core.Model.ModProvider; +global using Packer.Core.Model.PackerPolicys; +global using Packer.Core.Model.ResourceFile; +global using Serilog; +global using System; +global using System.Collections; +global using System.Collections.Frozen; +global using System.Collections.Immutable; +global using System.CommandLine; +global using System.ComponentModel; +global using System.IO.Compression; +global using System.Security.Cryptography; +global using System.Text; +global using System.Text.Encodings.Web; +global using System.Text.Json; +global using System.Text.Json.Serialization; +global using System.Text.Json.Serialization.Metadata; +global using System.Text.RegularExpressions;