using System.Text; namespace Shigure; internal sealed class ModuleDependencyService { private static readonly string[] StateCategories = [ ClassStateCatalog.CategoryState, ClassStateCatalog.CategoryResource, ClassStateCatalog.CategoryItem, ClassStateCatalog.CategoryConfig, ClassStateCatalog.CategoryTarget, ClassStateCatalog.CategoryFocus, ClassStateCatalog.CategoryMouseover, ClassStateCatalog.CategoryPet, ClassStateCatalog.CategoryBoss1, ClassStateCatalog.CategoryBoss2, ClassStateCatalog.CategoryBoss3, ClassStateCatalog.CategoryBoss4, ClassStateCatalog.CategoryBoss5 ]; // “锚点”是配置协议使用但不在可编辑字段目录中展示的固定字段。 private static readonly HashSet HiddenFixedStateFields = new(StringComparer.Ordinal) { "锚点" }; private readonly string _classDirectory; private readonly string _classMacrosPath; private readonly object _gate = new(); public ModuleDependencyService(string baseDirectory) { var addonRoot = Path.Combine(baseDirectory, "Fuyutsui"); _classDirectory = Path.Combine(addonRoot, "class"); _classMacrosPath = Path.Combine(addonRoot, "core", "classmacros.lua"); } public string? Capture(ModuleDefinition module) { lock (_gate) { return CaptureCore(module); } } private string? CaptureCore(ModuleDefinition module) { var classId = module.Match.ClassId; var specId = module.Match.SpecId; if (classId is null || specId is null) { module.Dependencies = null; return "模块未同时指定职业和专精,已保存模块逻辑,但未携带配置和宏。"; } var classPath = ResolveClassPath(classId.Value); var configDocument = ClassBlocksStore.Load(classPath); if (!configDocument.IsModernFormat) { throw new InvalidOperationException($"{Path.GetFileName(classPath)} 仍是旧版配置格式,无法随模块保存。"); } if (!configDocument.Specs.TryGetValue(specId.Value, out var spec)) { throw new InvalidOperationException($"职业 {classId} 中不存在专精 {specId} 的配置。"); } var macrosDocument = ClassMacrosStore.Load(_classMacrosPath); var classKey = ClassMacrosStore.ToClassFileKey(classId.Value); if (!macrosDocument.Classes.TryGetValue(classKey, out var macros)) { throw new InvalidOperationException($"classmacros.lua 中不存在职业 {classKey} 的宏配置。"); } EnsureMacroCapacity(classId.Value, macros); module.Dependencies = new ModuleDependencySnapshot { ClassId = classId.Value, SpecId = specId.Value, Config = new ModuleConfigSnapshot { Spec = CaptureSpec(spec), SpellsList = configDocument.SpellsList.Select(entry => new ModuleSpellListEntrySnapshot { SpellId = entry.SpellId, Index = entry.Index, Name = entry.Name }).ToList() }, Macros = new ModuleMacrosSnapshot { UsesSpecDynamicSpells = macros.UsesSpecDynamicSpells, DynamicCommon = new List(macros.DynamicCommon), DynamicForSpec = macros.UsesSpecDynamicSpells ? new List(macros.DynamicBySpec.GetValueOrDefault(specId.Value) ?? []) : [], StaticSpells = macros.StaticSpells.Select(CaptureMacro).ToList(), SpecialSpells = macros.SpecialSpells.Select(CaptureMacro).ToList() } }; return null; } public ModuleDependencyImportResult Import(IReadOnlyList modules) { lock (_gate) { return ImportCore(modules); } } private ModuleDependencyImportResult ImportCore(IReadOnlyList modules) { var result = new ModuleDependencyImportResult(); foreach (var module in modules .OrderBy(item => item.Name, StringComparer.CurrentCultureIgnoreCase) .ThenBy(item => item.FilePath, StringComparer.OrdinalIgnoreCase)) { if (module.Dependencies is null) { continue; } try { ValidateSnapshot(module, module.Dependencies); var removedFields = RemoveUnknownStateFields(module.Dependencies.Config.Spec); if (removedFields.Count > 0) { result.SanitizedModules.Add(module.Clone()); result.RemovedStateFields.AddRange(removedFields.Select(field => new RemovedModuleStateField(module.Name, field.Category, field.Name))); } ImportOne(module, result); } catch (Exception ex) { result.Rejected.Add(new RejectedModuleDependency(module.Id, module.Name, ex.Message)); } } return result; } private void ImportOne(ModuleDefinition module, ModuleDependencyImportResult result) { var snapshot = module.Dependencies!; var classPath = ResolveClassPath(snapshot.ClassId); var configDocument = ClassBlocksStore.Load(classPath); if (!configDocument.IsModernFormat) { throw new InvalidOperationException($"{Path.GetFileName(classPath)} 仍是旧版配置格式。"); } if (!configDocument.Specs.TryGetValue(snapshot.SpecId, out var localSpec)) { localSpec = new ClassBlocksStore.SpecBlocks(); configDocument.Specs[snapshot.SpecId] = localSpec; } var macrosDocument = ClassMacrosStore.Load(_classMacrosPath); var classKey = ClassMacrosStore.ToClassFileKey(snapshot.ClassId); if (!macrosDocument.Classes.TryGetValue(classKey, out var localMacros)) { throw new InvalidOperationException($"classmacros.lua 中不存在职业 {classKey} 的宏配置。"); } var counters = new MergeCounters(); MergeSpec(localSpec, snapshot.Config.Spec, counters); MergeSpellsList(configDocument.SpellsList, snapshot.Config.SpellsList, counters); MergeMacros(localMacros, snapshot.SpecId, snapshot.Macros, counters); EnsureMacroCapacity(snapshot.ClassId, localMacros); if (!counters.HasConfigChanges && counters.MacrosAdded == 0) { result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); if (counters.Conflicts.Count > 0) { result.ConflictedModuleIds.Add(module.Id); } return; } CommitDocuments(configDocument, macrosDocument, counters.HasConfigChanges, counters.MacrosAdded > 0); result.ConfigAdded += counters.ConfigAdded; result.ConfigUpdated += counters.ConfigUpdated; result.MacrosAdded += counters.MacrosAdded; result.ChangedModules.Add(module.Name); result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); if (counters.Conflicts.Count > 0) { result.ConflictedModuleIds.Add(module.Id); } } private static void ValidateSnapshot(ModuleDefinition module, ModuleDependencySnapshot snapshot) { if (snapshot.SchemaVersion != ModuleDependencySnapshot.CurrentSchemaVersion) { throw new InvalidDataException($"不支持依赖快照版本 {snapshot.SchemaVersion}。"); } if (module.Match.ClassId != snapshot.ClassId || module.Match.SpecId != snapshot.SpecId) { throw new InvalidDataException("依赖快照的职业/专精与模块匹配条件不一致。"); } var unknownCategory = snapshot.Config.Spec.CategorizedStates.Keys .FirstOrDefault(key => !StateCategories.Contains(key, StringComparer.Ordinal)); if (unknownCategory is not null) { throw new InvalidDataException($"依赖快照包含未知状态分类“{unknownCategory}”。"); } } private static List RemoveUnknownStateFields(ModuleSpecSnapshot spec) { var removed = new List(); if (spec.NestedStates) { foreach (var (category, fields) in spec.CategorizedStates) { if (fields is null) { continue; } for (var index = fields.Count - 1; index >= 0; index--) { var field = fields[index]?.Trim() ?? string.Empty; if (IsRecognizedStateField(category, field)) { continue; } fields.RemoveAt(index); if (field.Length > 0) { removed.Add(new RemovedStateField(category, field)); } } } } else { for (var index = spec.FlatStates.Count - 1; index >= 0; index--) { var field = spec.FlatStates[index]?.Trim() ?? string.Empty; if (HiddenFixedStateFields.Contains(field) || ClassStateCatalog.FindCategory(field) is not null) { continue; } spec.FlatStates.RemoveAt(index); if (field.Length > 0) { removed.Add(new RemovedStateField(ClassStateCatalog.CategoryState, field)); } } } removed.Reverse(); return removed; } private static bool IsRecognizedStateField(string category, string field) => field.Length > 0 && (ClassStateCatalog.IsKnown(category, field) || string.Equals(category, ClassStateCatalog.CategoryState, StringComparison.Ordinal) && HiddenFixedStateFields.Contains(field)); private string ResolveClassPath(int classId) { var path = Path.Combine(_classDirectory, ClassNames.GetConfigFileName(classId) + ".lua"); if (!File.Exists(path)) { throw new FileNotFoundException($"找不到职业配置文件: {path}", path); } if (!File.Exists(_classMacrosPath)) { throw new FileNotFoundException($"找不到职业宏文件: {_classMacrosPath}", _classMacrosPath); } return path; } private static ModuleSpecSnapshot CaptureSpec(ClassBlocksStore.SpecBlocks spec) => new() { NestedStates = spec.NestedStates, FlatStates = new List(spec.FlatStates), CategorizedStates = spec.CategorizedStates.ToDictionary( pair => pair.Key, pair => new List(pair.Value), StringComparer.Ordinal), PlayerAuras = spec.PlayerAuras.Select(CaptureAura).ToList(), TargetHarmfulAuras = spec.TargetHarmfulAuras.Select(CaptureAura).ToList(), TargetHelpfulAuras = spec.TargetHelpfulAuras.Select(CaptureAura).ToList(), FocusHarmfulAuras = spec.FocusHarmfulAuras.Select(CaptureAura).ToList(), FocusHelpfulAuras = spec.FocusHelpfulAuras.Select(CaptureAura).ToList(), Spells = spec.Spells.Select(entry => new ModuleSpellSnapshot { Name = entry.Name, SpellId = entry.SpellId, Charge = entry.Charge, MaxCharge = entry.MaxCharge, CastCount = entry.CastCount, ForcedKnown = entry.ForcedKnown, InSpellBook = entry.InSpellBook }).ToList(), Group = spec.Group is null ? null : new ModuleGroupSnapshot { Num = spec.Group.Num, HealthPercent = spec.Group.HealthPercent, Role = spec.Group.Role, Dispel = spec.Group.Dispel, Auras = spec.Group.Auras.Select(entry => new ModuleGroupAuraSnapshot { Offset = entry.Offset, Name = entry.Name, SpellId = entry.SpellId, SpellIds = new List(entry.SpellIds) }).ToList() } }; private static ModuleAuraSnapshot CaptureAura(ClassBlocksStore.AuraEntry entry) => new() { Name = entry.Name, SpellId = entry.SpellId, SpellIds = new List(entry.SpellIds), MaxApps = entry.MaxApps }; private static ModuleMacroEntrySnapshot CaptureMacro(ClassMacrosStore.ArrayEntry entry) => new() { Text = entry.Text, Comment = entry.Comment }; private static void MergeSpec( ClassBlocksStore.SpecBlocks local, ModuleSpecSnapshot incoming, MergeCounters counters) { if (local.NestedStates) { if (incoming.NestedStates) { foreach (var category in StateCategories) { MergeStrings(local.CategorizedStates[category], incoming.CategorizedStates.GetValueOrDefault(category) ?? [], counters); } } else { MergeStrings(local.CategorizedStates[ClassStateCatalog.CategoryState], incoming.FlatStates, counters); } } else { if (incoming.NestedStates) { foreach (var category in StateCategories) { MergeStrings(local.FlatStates, incoming.CategorizedStates.GetValueOrDefault(category) ?? [], counters); } } else { MergeStrings(local.FlatStates, incoming.FlatStates, counters); } } MergeAuras(local.PlayerAuras, incoming.PlayerAuras, "玩家光环", counters); MergeAuras(local.TargetHarmfulAuras, incoming.TargetHarmfulAuras, "目标减益", counters); MergeAuras(local.TargetHelpfulAuras, incoming.TargetHelpfulAuras, "目标增益", counters); MergeAuras(local.FocusHarmfulAuras, incoming.FocusHarmfulAuras, "焦点减益", counters); MergeAuras(local.FocusHelpfulAuras, incoming.FocusHelpfulAuras, "焦点增益", counters); MergeSpells(local.Spells, incoming.Spells, counters); MergeGroup(local, incoming.Group, counters); } private static void MergeStrings(List local, IEnumerable incoming, MergeCounters counters) { var existing = new HashSet(local, StringComparer.Ordinal); foreach (var value in incoming.Select(item => item?.Trim() ?? string.Empty).Where(item => item.Length > 0)) { if (existing.Add(value)) { local.Add(value); counters.ConfigAdded++; } } } private static void MergeAuras( List local, IEnumerable incoming, string label, MergeCounters counters) { CompactLocalAuras(local, label, counters); foreach (var entry in incoming) { var existing = local.FirstOrDefault(item => AuraIdentityMatches(item, entry)); if (existing is not null) { MergeAuraMetadata(existing, entry, label, counters); continue; } var added = new ClassBlocksStore.AuraEntry { Name = entry.Name, SpellId = entry.SpellId, MaxApps = entry.MaxApps }; added.SpellIds.AddRange(entry.SpellIds); local.Add(added); counters.ConfigAdded++; } } private static void CompactLocalAuras( List local, string label, MergeCounters counters) { for (var index = 0; index < local.Count; index++) { var current = local[index]; var existing = local.Take(index).FirstOrDefault(item => AuraIdentityMatches(item, current)); if (existing is null) { continue; } MergeAuraMetadata(existing, current, label, counters); local.RemoveAt(index--); counters.ConfigUpdated++; } } private static bool AuraIdentityMatches( ClassBlocksStore.AuraEntry left, ClassBlocksStore.AuraEntry right) { return SpellIdentityMatches( GetAuraSpellIds(left.SpellId, left.SpellIds), left.Name, GetAuraSpellIds(right.SpellId, right.SpellIds), right.Name); } private static bool AuraIdentityMatches( ClassBlocksStore.AuraEntry left, ModuleAuraSnapshot right) { return SpellIdentityMatches( GetAuraSpellIds(left.SpellId, left.SpellIds), left.Name, GetAuraSpellIds(right.SpellId, right.SpellIds), right.Name); } private static void MergeAuraMetadata( ClassBlocksStore.AuraEntry target, ClassBlocksStore.AuraEntry incoming, string label, MergeCounters counters) { MergeAuraMetadataCore( target, incoming.Name, incoming.SpellId, incoming.SpellIds, incoming.MaxApps, label, counters); } private static void MergeAuraMetadata( ClassBlocksStore.AuraEntry target, ModuleAuraSnapshot incoming, string label, MergeCounters counters) { MergeAuraMetadataCore( target, incoming.Name, incoming.SpellId, incoming.SpellIds, incoming.MaxApps, label, counters); } private static void MergeAuraMetadataCore( ClassBlocksStore.AuraEntry target, string? incomingName, long? incomingSpellId, IEnumerable? incomingSpellIds, int? incomingMaxApps, string label, MergeCounters counters) { var changed = false; if (target.SpellId is null && incomingSpellId is > 0) { target.SpellId = incomingSpellId; changed = true; } foreach (var spellId in GetAuraSpellIds(incomingSpellId, incomingSpellIds)) { if (target.SpellId != spellId && !target.SpellIds.Contains(spellId)) { target.SpellIds.Add(spellId); changed = true; } } if (target.MaxApps is null && incomingMaxApps is not null) { target.MaxApps = incomingMaxApps; changed = true; } else if (target.MaxApps is not null && incomingMaxApps is not null && target.MaxApps != incomingMaxApps) { var mergedMaxApps = Math.Max(target.MaxApps.Value, incomingMaxApps.Value); counters.Conflicts.Add( $"{label}“{DisplayName(incomingName, incomingSpellId)}”的 maxApps 存在差异:" + $"{target.MaxApps}、{incomingMaxApps},已采用 {mergedMaxApps}。"); if (target.MaxApps != mergedMaxApps) { target.MaxApps = mergedMaxApps; changed = true; } } if (string.IsNullOrWhiteSpace(target.Name) && !string.IsNullOrWhiteSpace(incomingName)) { target.Name = incomingName; changed = true; } if (changed) { counters.ConfigUpdated++; } } private static void MergeSpells( List local, IEnumerable incoming, MergeCounters counters) { CompactLocalSpells(local, counters); foreach (var entry in incoming) { var name = DisplayName(entry.Name, entry.SpellId); var existing = local.FirstOrDefault(item => SpellIdentityMatches(item, entry)); if (existing is not null) { if (!SpellEquals(existing, entry)) { counters.Conflicts.Add($"法术“{name}”与本地内容不同,已保留本地。"); } continue; } local.Add(new ClassBlocksStore.SpellEntry { Name = entry.Name, SpellId = entry.SpellId, Charge = entry.Charge, MaxCharge = entry.MaxCharge, CastCount = entry.CastCount, ForcedKnown = entry.ForcedKnown, InSpellBook = entry.InSpellBook }); counters.ConfigAdded++; } } private static void CompactLocalSpells( List local, MergeCounters counters) { for (var index = 0; index < local.Count; index++) { var current = local[index]; var existing = local.Take(index).FirstOrDefault(item => SpellIdentityMatches(item, current)); if (existing is null) { continue; } local.RemoveAt(index--); counters.ConfigUpdated++; } } private static bool SpellIdentityMatches( ClassBlocksStore.SpellEntry left, ClassBlocksStore.SpellEntry right) { return SpellIdentityMatches( GetSpellIds(left.SpellId), left.Name, GetSpellIds(right.SpellId), right.Name); } private static bool SpellIdentityMatches( ClassBlocksStore.SpellEntry left, ModuleSpellSnapshot right) { return SpellIdentityMatches( GetSpellIds(left.SpellId), left.Name, GetSpellIds(right.SpellId), right.Name); } private static bool SpellIdentityMatches( IEnumerable leftIds, string? leftName, IEnumerable rightIds, string? rightName) { var leftIdSet = leftIds.Where(IsValidSpellId).ToHashSet(); var rightIdSet = rightIds.Where(IsValidSpellId).ToHashSet(); if (leftIdSet.Count > 0 || rightIdSet.Count > 0) { return leftIdSet.Count > 0 && rightIdSet.Count > 0 && leftIdSet.Overlaps(rightIdSet); } return string.Equals(leftName?.Trim(), rightName?.Trim(), StringComparison.Ordinal); } private static IEnumerable GetSpellIds(long spellId) => IsValidSpellId(spellId) ? [spellId] : []; private static IEnumerable GetAuraSpellIds(long? spellId, IEnumerable? spellIds) { var result = new HashSet(); if (spellId is { } primary && IsValidSpellId(primary)) { result.Add(primary); } foreach (var id in spellIds ?? []) { if (IsValidSpellId(id)) { result.Add(id); } } return result; } private static bool IsValidSpellId(long spellId) => spellId > 0; private static string DisplayName(string? name, long? spellId) => string.IsNullOrWhiteSpace(name) ? spellId?.ToString() ?? "未命名" : name.Trim(); private static void MergeSpellsList( List local, IEnumerable incoming, MergeCounters counters) { CompactLocalSpellsList(local, counters); foreach (var entry in incoming) { var byId = local.FirstOrDefault(item => item.SpellId == entry.SpellId); var byIndex = local.FirstOrDefault(item => item.Index == entry.Index); if (byId is not null || byIndex is not null) { var existing = byId ?? byIndex!; if (existing.SpellId != entry.SpellId || existing.Index != entry.Index || !string.Equals(existing.Name, entry.Name, StringComparison.Ordinal)) { counters.Conflicts.Add($"一键法术 {entry.Index}/{entry.SpellId} 与本地索引或 SpellId 冲突,已保留本地。"); } continue; } local.Add(new ClassBlocksStore.SpellsListEntry { SpellId = entry.SpellId, Index = entry.Index, Name = entry.Name, OriginalSpellId = 0 }); counters.ConfigAdded++; } } private static void CompactLocalSpellsList( List local, MergeCounters counters) { var seen = new HashSet(); for (var index = 0; index < local.Count; index++) { if (seen.Add(local[index].SpellId)) { continue; } local.RemoveAt(index--); counters.ConfigUpdated++; } } private static void MergeGroup( ClassBlocksStore.SpecBlocks localSpec, ModuleGroupSnapshot? incoming, MergeCounters counters) { if (incoming is null) { return; } if (localSpec.Group is null) { var group = new ClassBlocksStore.GroupBlocks { Num = incoming.Num, HealthPercent = incoming.HealthPercent, Role = incoming.Role, Dispel = incoming.Dispel }; counters.ConfigAdded++; foreach (var aura in incoming.Auras) { var existing = group.Auras.FirstOrDefault(item => GroupAuraIdentityMatches(item, aura)); if (existing is not null) { MergeGroupAuraMetadata(existing, aura, counters); continue; } group.Auras.Add(ToGroupAura(aura, aura.Offset)); counters.ConfigAdded++; } localSpec.Group = group; return; } var local = localSpec.Group; CompactLocalGroupAuras(local.Auras, counters); var occupied = new HashSet(local.Auras.Select(aura => aura.Offset)); AddOffset(local.HealthPercent); AddOffset(local.Role); AddOffset(local.Dispel); if (local.HealthPercent is null && incoming.HealthPercent is not null) { local.HealthPercent = AllocateOffset(incoming.HealthPercent.Value, occupied); counters.ConfigAdded++; } if (local.Role is null && incoming.Role is not null) { local.Role = AllocateOffset(incoming.Role.Value, occupied); counters.ConfigAdded++; } if (local.Dispel is null && incoming.Dispel is not null) { local.Dispel = AllocateOffset(incoming.Dispel.Value, occupied); counters.ConfigAdded++; } foreach (var aura in incoming.Auras) { var existing = local.Auras.FirstOrDefault(item => GroupAuraIdentityMatches(item, aura)); if (existing is not null) { MergeGroupAuraMetadata(existing, aura, counters); continue; } var offset = AllocateOffset(aura.Offset, occupied); local.Auras.Add(ToGroupAura(aura, offset)); counters.ConfigAdded++; } if (occupied.Count > 0) { local.Num = Math.Max(local.Num, occupied.Max()); } void AddOffset(int? offset) { if (offset is > 0) { occupied.Add(offset.Value); } } } private static bool GroupAuraIdentityMatches( ClassBlocksStore.GroupAuraEntry left, ModuleGroupAuraSnapshot right) { // offset 是队伍光环的槽位,同一 spellId 出现在不同槽位时仍然是两个槽位。 return left.Offset == right.Offset && SpellIdentityMatches( GetAuraSpellIds(left.SpellId, left.SpellIds), left.Name, GetAuraSpellIds(right.SpellId, right.SpellIds), right.Name); } private static bool GroupAuraIdentityMatches( ClassBlocksStore.GroupAuraEntry left, ClassBlocksStore.GroupAuraEntry right) { return left.Offset == right.Offset && SpellIdentityMatches( GetAuraSpellIds(left.SpellId, left.SpellIds), left.Name, GetAuraSpellIds(right.SpellId, right.SpellIds), right.Name); } private static void CompactLocalGroupAuras( List local, MergeCounters counters) { for (var index = 0; index < local.Count; index++) { var current = local[index]; var existing = local.Take(index).FirstOrDefault(item => GroupAuraIdentityMatches(item, current)); if (existing is null) { continue; } if (existing.SpellId is null && current.SpellId is > 0) { existing.SpellId = current.SpellId; } foreach (var spellId in GetAuraSpellIds(null, current.SpellIds)) { if (!existing.SpellIds.Contains(spellId)) { existing.SpellIds.Add(spellId); } } if (string.IsNullOrWhiteSpace(existing.Name) && !string.IsNullOrWhiteSpace(current.Name)) { existing.Name = current.Name; } local.RemoveAt(index--); counters.ConfigUpdated++; } } private static void MergeGroupAuraMetadata( ClassBlocksStore.GroupAuraEntry target, ModuleGroupAuraSnapshot incoming, MergeCounters counters) { var changed = false; if (target.SpellId is null && incoming.SpellId is > 0) { target.SpellId = incoming.SpellId; changed = true; } foreach (var spellId in GetAuraSpellIds(null, incoming.SpellIds)) { if (!target.SpellIds.Contains(spellId)) { target.SpellIds.Add(spellId); changed = true; } } if (string.IsNullOrWhiteSpace(target.Name) && !string.IsNullOrWhiteSpace(incoming.Name)) { target.Name = incoming.Name; changed = true; } if (changed) { counters.ConfigUpdated++; } } private static int AllocateOffset(int desired, ISet occupied) { var offset = desired > 0 && !occupied.Contains(desired) ? desired : 1; while (occupied.Contains(offset)) { offset++; } occupied.Add(offset); return offset; } private static ClassBlocksStore.GroupAuraEntry ToGroupAura(ModuleGroupAuraSnapshot entry, int offset) { var aura = new ClassBlocksStore.GroupAuraEntry { Offset = offset, Name = entry.Name, SpellId = entry.SpellId }; aura.SpellIds.AddRange(entry.SpellIds); return aura; } private static void MergeMacros( ClassMacrosStore.ClassMacros local, int specId, ModuleMacrosSnapshot incoming, MergeCounters counters) { var commonNames = new HashSet(local.DynamicCommon.Select(NormalizeMacroText), StringComparer.Ordinal); foreach (var value in incoming.DynamicCommon) { var normalized = NormalizeMacroText(value); if (normalized.Length > 0 && commonNames.Add(normalized)) { local.DynamicCommon.Add(value.Trim()); counters.MacrosAdded++; } } if (incoming.UsesSpecDynamicSpells && incoming.DynamicForSpec.Count > 0) { local.UsesSpecDynamicSpells = true; if (!local.DynamicBySpec.TryGetValue(specId, out var specMacros)) { specMacros = new List(); local.DynamicBySpec[specId] = specMacros; } var resolved = new HashSet(local.DynamicCommon.Select(NormalizeMacroText), StringComparer.Ordinal); resolved.UnionWith(specMacros.Select(NormalizeMacroText)); foreach (var value in incoming.DynamicForSpec) { var normalized = NormalizeMacroText(value); if (normalized.Length > 0 && resolved.Add(normalized)) { specMacros.Add(value.Trim()); counters.MacrosAdded++; } } } var identities = BuildMacroIdentities(local); MergeMacroEntries(local.StaticSpells, incoming.StaticSpells, isSpecial: false, identities, counters); MergeMacroEntries(local.SpecialSpells, incoming.SpecialSpells, isSpecial: true, identities, counters); } private static Dictionary BuildMacroIdentities(ClassMacrosStore.ClassMacros macros) { var result = new Dictionary(); foreach (var entry in macros.StaticSpells) { result.TryAdd(GetMacroIdentity(entry.Text, entry.Comment, isSpecial: false), CaptureMacro(entry)); } foreach (var entry in macros.SpecialSpells) { result.TryAdd(GetMacroIdentity(entry.Text, entry.Comment, isSpecial: true), CaptureMacro(entry)); } return result; } private static void MergeMacroEntries( List local, IEnumerable incoming, bool isSpecial, IDictionary identities, MergeCounters counters) { foreach (var entry in incoming) { var identity = GetMacroIdentity(entry.Text, entry.Comment, isSpecial); if (identities.TryGetValue(identity, out var existing)) { if (!MacroEntryEquals(existing, entry)) { counters.Conflicts.Add($"宏“{identity.Spell}”与本地内容不同,已保留本地。"); } continue; } local.Add(new ClassMacrosStore.ArrayEntry { Text = entry.Text, Comment = entry.Comment }); identities[identity] = entry; counters.MacrosAdded++; } } private static MacroIdentity GetMacroIdentity(string text, string? comment, bool isSpecial) { var parsed = isSpecial ? FuyutsuiKeymapConverter.ParseSpecialMacro(text, comment) : FuyutsuiKeymapConverter.ParseStaticMacro(text, comment); var spell = parsed.Spell.Trim(); return spell.Length > 0 ? new MacroIdentity(isSpecial, parsed.Unit, spell, parsed.Condition) : new MacroIdentity(isSpecial, parsed.Unit, NormalizeMacroText(text), parsed.Condition); } private static string NormalizeMacroText(string value) => value.Replace("\r\n", "\n", StringComparison.Ordinal).Trim(); private static void EnsureMacroCapacity(int classId, ClassMacrosStore.ClassMacros macros) { foreach (var (specId, specName) in ClassNames.GetSpecs(classId)) { var dynamicCount = macros.UsesSpecDynamicSpells ? macros.DynamicCommon.Count + (macros.DynamicBySpec.GetValueOrDefault(specId)?.Count ?? 0) : macros.DynamicCommon.Count; var slots = checked(dynamicCount * 30 + macros.StaticSpells.Count + macros.SpecialSpells.Count); if (slots > FuyutsuiKeymapConverter.MacroSlotCapacity) { throw new InvalidOperationException( $"宏容量超限:{ClassNames.GetClassAndSpecName(classId, specId).ClassName} {specName} 合并后 {slots} 个槽位,最大 {FuyutsuiKeymapConverter.MacroSlotCapacity}。模块未导入。"); } } } private static void CommitDocuments( ClassBlocksStore.ClassFileDocument config, ClassMacrosStore.MacrosDocument macros, bool saveConfig, bool saveMacros) { var originalConfig = saveConfig ? File.ReadAllText(config.FilePath, Encoding.UTF8) : null; var originalMacros = saveMacros ? File.ReadAllText(macros.FilePath, Encoding.UTF8) : null; try { if (saveConfig) { ClassBlocksStore.Save(config); } if (saveMacros) { ClassMacrosStore.Save(macros); } } catch (Exception saveError) { var rollbackErrors = new List(); TryRestore(config.FilePath, originalConfig, rollbackErrors); TryRestore(macros.FilePath, originalMacros, rollbackErrors); if (rollbackErrors.Count > 0) { throw new AggregateException("模块依赖写入失败,且回滚未完全成功。", [saveError, .. rollbackErrors]); } throw; } } private static void TryRestore(string path, string? contents, ICollection errors) { if (contents is null) { return; } try { AtomicFile.WriteAllText(path, contents, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); } catch (Exception ex) { errors.Add(ex); } } private static bool SpellEquals(ClassBlocksStore.SpellEntry left, ModuleSpellSnapshot right) => left.SpellId == right.SpellId && left.Charge == right.Charge && left.MaxCharge == right.MaxCharge && left.CastCount == right.CastCount && left.ForcedKnown == right.ForcedKnown && left.InSpellBook == right.InSpellBook; private static bool MacroEntryEquals(ModuleMacroEntrySnapshot left, ModuleMacroEntrySnapshot right) => string.Equals(NormalizeMacroText(left.Text), NormalizeMacroText(right.Text), StringComparison.Ordinal) && string.Equals(left.Comment?.Trim(), right.Comment?.Trim(), StringComparison.Ordinal); private sealed class MergeCounters { public int ConfigAdded { get; set; } public int ConfigUpdated { get; set; } public int MacrosAdded { get; set; } public List Conflicts { get; } = new(); public bool HasConfigChanges => ConfigAdded > 0 || ConfigUpdated > 0; } // 静态宏和特殊宏可以解析成相同的目标/技能/条件,但仍是两个独立槽位。 // 例如“恶魔变形”和“/castsequence reset=0.5 恶魔变形,x”必须分别去重。 private readonly record struct MacroIdentity(bool IsSpecial, int Unit, string Spell, string Condition); } internal sealed class ModuleDependencyImportResult { public int ConfigAdded { get; set; } public int ConfigUpdated { get; set; } public int MacrosAdded { get; set; } public List ChangedModules { get; } = new(); public List Conflicts { get; } = new(); public HashSet ConflictedModuleIds { get; } = new(StringComparer.OrdinalIgnoreCase); public List Rejected { get; } = new(); public List SanitizedModules { get; } = new(); public List RemovedStateFields { get; } = new(); public bool HasChanges => ConfigAdded > 0 || ConfigUpdated > 0 || MacrosAdded > 0; } internal sealed record RejectedModuleDependency(string ModuleId, string ModuleName, string Reason); internal sealed record RemovedModuleStateField(string ModuleName, string Category, string Name); internal sealed record RemovedStateField(string Category, string Name);