diff --git a/App/ShigureRuntimeFactory.cs b/App/ShigureRuntimeFactory.cs index 20a513da..73ba2180 100644 --- a/App/ShigureRuntimeFactory.cs +++ b/App/ShigureRuntimeFactory.cs @@ -29,7 +29,8 @@ internal sealed class ShigureRuntimeFactory : IShigureRuntimeFactory public ShigureRuntime Create(AppOptions options) { - _moduleStore.Reload(); + // 模块目录由启动/刷新时的依赖导入流程统一重载并过滤;这里直接使用已验证快照, + // 避免把因宏容量超限而拒绝的磁盘模块重新带回运行时。 var config = ConfigService.LoadFromBaseDirectory(_baseDirectory); var keymap = new KeymapService(_baseDirectory, config); diff --git a/CLAUDE.md b/CLAUDE.md index 5ba8b847..b372245a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -60,7 +60,8 @@ wow_process.txt 目标游戏进程名列表;构建时复制,运行期间每 ## 模块解析(改逻辑前必读) - 模块以 `module/模块名.json` 平铺保存,**文件名取自模块名,故模块名不可重复**;加载递归扫描子目录。模型在 [Modules/ModuleStore.cs](Modules/ModuleStore.cs)(`ModuleDefinition`/`ModuleMatch`/`ModuleRule`/`ModuleUnit`/`ModuleCountField`/`ModuleValueAdjustment`)。`RecommendedTalent` 是 `ModuleDefinition` 上的纯展示字段,不参与匹配(`ModuleMatch.Specificity` 不计入)。 -- `ModuleStore` 的 `Reload`/`Save`/`Delete` 会在同一个门锁内串行完整文件事务与内存快照更新;`Save` 通过同目录临时文件提交,重命名失败会回滚新文件。编辑器写入不要绕过它,避免运行时重载读到半次操作。 +- `ModuleStore` 的 `Reload`/`Save`/`Delete` 会在同一个门锁内串行完整文件事务与内存快照更新;`Save` 通过同目录临时文件提交,重命名失败会回滚新文件。编辑器写入不要绕过它,避免运行时读到半次操作。运行时工厂不再自行 `Reload`:启动和模块刷新先由 `ModuleDependencyService` 导入依赖、拒绝宏容量超限模块,再把已验证的内存快照交给运行时。 +- 职业/专精明确的模块保存时会写入 `Dependencies` 快照。`ModuleDependencyService` 以本地为优先追加缺失的 ClassBlocks/spellsList/ClassMacros,按动态宏 30 槽、其它宏 1 槽检查所有受影响专精;任一专精超过 keymap 容量就拒绝整个模块且不写 Lua。依赖提交失败会恢复配置和宏原文。 - 选择优先级:`ModuleStore.FindSelectedOrBestMatch` —— 先用 UI/参数选定的 `ModuleId`;否则取 `Match` 命中字段最多者(`ModuleMatch.Specificity` 越大越优先),并列按名称。`Match` 字段留空 = 任意。`PartyType` 数字会归一化为 `"1-40"`。 - 动态单位/数量/动态数值的语义见 [README.md](README.md#动态单位与数量字段);列表与编辑器的人类可读摘要统一走 [UI/UnitSummary.cs](UI/UnitSummary.cs)`.Describe(...)`(单一来源,勿再复制一份描述逻辑)。 diff --git a/Infrastructure/AtomicFile.cs b/Infrastructure/AtomicFile.cs new file mode 100644 index 00000000..83eadad3 --- /dev/null +++ b/Infrastructure/AtomicFile.cs @@ -0,0 +1,26 @@ +using System.Text; + +namespace Shigure; + +internal static class AtomicFile +{ + public static void WriteAllText(string path, string contents, Encoding encoding) + { + var directory = Path.GetDirectoryName(Path.GetFullPath(path)) + ?? throw new InvalidOperationException($"无法确定文件目录: {path}"); + Directory.CreateDirectory(directory); + var tempPath = Path.Combine(directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); + try + { + File.WriteAllText(tempPath, contents, encoding); + File.Move(tempPath, path, overwrite: true); + } + finally + { + if (File.Exists(tempPath)) + { + File.Delete(tempPath); + } + } + } +} diff --git a/Infrastructure/ClassBlocksStore.cs b/Infrastructure/ClassBlocksStore.cs index e5f00a98..d263c1bc 100644 --- a/Infrastructure/ClassBlocksStore.cs +++ b/Infrastructure/ClassBlocksStore.cs @@ -195,7 +195,7 @@ internal static class ClassBlocksStore updated = updated[..classBlocksStart] + serialized + updated[classBlocksEnd..]; - File.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + AtomicFile.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var start, out var end)) { diff --git a/Infrastructure/ClassMacrosStore.cs b/Infrastructure/ClassMacrosStore.cs index c5206cc6..09c2db8a 100644 --- a/Infrastructure/ClassMacrosStore.cs +++ b/Infrastructure/ClassMacrosStore.cs @@ -97,7 +97,7 @@ internal static class ClassMacrosStore var updated = document.SourceText[..document.TableStart] + serialized + document.SourceText[document.TableEndExclusive..]; - File.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); + AtomicFile.WriteAllText(document.FilePath, updated, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false)); if (!TryExtractAssignedTable(updated, AssignmentName, out _, out var start, out var end)) { diff --git a/Infrastructure/ModuleDependencyService.cs b/Infrastructure/ModuleDependencyService.cs new file mode 100644 index 00000000..6ab3406f --- /dev/null +++ b/Infrastructure/ModuleDependencyService.cs @@ -0,0 +1,715 @@ +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 + ]; + + 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 + { + 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!; + ValidateSnapshot(module, snapshot); + + 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.ConfigAdded == 0 && counters.MacrosAdded == 0) + { + result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); + return; + } + + CommitDocuments(configDocument, macrosDocument, counters.ConfigAdded > 0, counters.MacrosAdded > 0); + result.ConfigAdded += counters.ConfigAdded; + result.MacrosAdded += counters.MacrosAdded; + result.ChangedModules.Add(module.Name); + result.Conflicts.AddRange(counters.Conflicts.Select(message => $"{module.Name}: {message}")); + } + + 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 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) + { + foreach (var entry in incoming) + { + var existing = local.FirstOrDefault(item => string.Equals(item.Name, entry.Name, StringComparison.Ordinal)); + if (existing is not null) + { + if (!AuraEquals(existing, entry)) + { + counters.Conflicts.Add($"{label}“{entry.Name}”与本地内容不同,已保留本地。"); + } + 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 MergeSpells( + List local, + IEnumerable incoming, + MergeCounters counters) + { + foreach (var entry in incoming) + { + var name = string.IsNullOrWhiteSpace(entry.Name) ? entry.SpellId.ToString() : entry.Name; + var existing = local.FirstOrDefault(item => + string.Equals(string.IsNullOrWhiteSpace(item.Name) ? item.SpellId.ToString() : item.Name, name, StringComparison.Ordinal)); + 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 MergeSpellsList( + List local, + IEnumerable incoming, + MergeCounters 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 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 + }; + foreach (var aura in incoming.Auras) + { + group.Auras.Add(ToGroupAura(aura, aura.Offset)); + } + localSpec.Group = group; + counters.ConfigAdded += 1 + incoming.Auras.Count; + return; + } + + var local = localSpec.Group; + 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 => string.Equals(item.Name, aura.Name, StringComparison.Ordinal)); + if (existing is not null) + { + if (!GroupAuraEquals(existing, aura)) + { + counters.Conflicts.Add($"队伍光环“{aura.Name}”与本地内容不同,已保留本地。"); + } + 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 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(parsed.Unit, spell, parsed.Condition) + : new MacroIdentity(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 AuraEquals(ClassBlocksStore.AuraEntry left, ModuleAuraSnapshot right) + => left.SpellId == right.SpellId + && left.MaxApps == right.MaxApps + && left.SpellIds.SequenceEqual(right.SpellIds); + + 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 GroupAuraEquals(ClassBlocksStore.GroupAuraEntry left, ModuleGroupAuraSnapshot right) + => left.SpellId == right.SpellId && left.SpellIds.SequenceEqual(right.SpellIds); + + 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 MacrosAdded { get; set; } + public List Conflicts { get; } = new(); + } + + private readonly record struct MacroIdentity(int Unit, string Spell, string Condition); +} + +internal sealed class ModuleDependencyImportResult +{ + public int ConfigAdded { get; set; } + public int MacrosAdded { get; set; } + public List ChangedModules { get; } = new(); + public List Conflicts { get; } = new(); + public List Rejected { get; } = new(); + public bool HasChanges => ConfigAdded > 0 || MacrosAdded > 0; +} + +internal sealed record RejectedModuleDependency(string ModuleId, string ModuleName, string Reason); diff --git a/Modules/ModuleDependencies.cs b/Modules/ModuleDependencies.cs new file mode 100644 index 00000000..087dd19e --- /dev/null +++ b/Modules/ModuleDependencies.cs @@ -0,0 +1,166 @@ +namespace Shigure; + +/// 随模块分发的职业配置与宏快照。 +public sealed class ModuleDependencySnapshot +{ + public const int CurrentSchemaVersion = 1; + + public int SchemaVersion { get; set; } = CurrentSchemaVersion; + public int ClassId { get; set; } + public int SpecId { get; set; } + public ModuleConfigSnapshot Config { get; set; } = new(); + public ModuleMacrosSnapshot Macros { get; set; } = new(); + + public ModuleDependencySnapshot Clone() => new() + { + SchemaVersion = SchemaVersion, + ClassId = ClassId, + SpecId = SpecId, + Config = Config?.Clone() ?? new ModuleConfigSnapshot(), + Macros = Macros?.Clone() ?? new ModuleMacrosSnapshot() + }; +} + +public sealed class ModuleConfigSnapshot +{ + public ModuleSpecSnapshot Spec { get; set; } = new(); + public List SpellsList { get; set; } = new(); + + public ModuleConfigSnapshot Clone() => new() + { + Spec = Spec?.Clone() ?? new ModuleSpecSnapshot(), + SpellsList = (SpellsList ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList() + }; +} + +public sealed class ModuleSpecSnapshot +{ + public bool NestedStates { get; set; } = true; + public List FlatStates { get; set; } = new(); + public Dictionary> CategorizedStates { get; set; } = new(StringComparer.Ordinal); + public List PlayerAuras { get; set; } = new(); + public List TargetHarmfulAuras { get; set; } = new(); + public List TargetHelpfulAuras { get; set; } = new(); + public List FocusHarmfulAuras { get; set; } = new(); + public List FocusHelpfulAuras { get; set; } = new(); + public List Spells { get; set; } = new(); + public ModuleGroupSnapshot? Group { get; set; } + + public ModuleSpecSnapshot Clone() => new() + { + NestedStates = NestedStates, + FlatStates = new List(FlatStates ?? []), + CategorizedStates = (CategorizedStates ?? new Dictionary>()).ToDictionary( + pair => pair.Key, + pair => new List(pair.Value ?? []), + StringComparer.Ordinal), + PlayerAuras = CloneEntries(PlayerAuras), + TargetHarmfulAuras = CloneEntries(TargetHarmfulAuras), + TargetHelpfulAuras = CloneEntries(TargetHelpfulAuras), + FocusHarmfulAuras = CloneEntries(FocusHarmfulAuras), + FocusHelpfulAuras = CloneEntries(FocusHelpfulAuras), + Spells = (Spells ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList(), + Group = Group?.Clone() + }; + + private static List CloneEntries(IEnumerable? entries) + => (entries ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList(); +} + +public sealed class ModuleAuraSnapshot +{ + public string Name { get; set; } = string.Empty; + public long? SpellId { get; set; } + public List SpellIds { get; set; } = new(); + public int? MaxApps { get; set; } + + public ModuleAuraSnapshot Clone() => new() + { + Name = Name, + SpellId = SpellId, + SpellIds = new List(SpellIds ?? []), + MaxApps = MaxApps + }; +} + +public sealed class ModuleSpellSnapshot +{ + public string Name { get; set; } = string.Empty; + public long SpellId { get; set; } + public bool Charge { get; set; } + public int? MaxCharge { get; set; } + public int? CastCount { get; set; } + public bool ForcedKnown { get; set; } + public bool InSpellBook { get; set; } + + public ModuleSpellSnapshot Clone() => (ModuleSpellSnapshot)MemberwiseClone(); +} + +public sealed class ModuleSpellListEntrySnapshot +{ + public long SpellId { get; set; } + public int Index { get; set; } + public string Name { get; set; } = string.Empty; + + public ModuleSpellListEntrySnapshot Clone() => (ModuleSpellListEntrySnapshot)MemberwiseClone(); +} + +public sealed class ModuleGroupSnapshot +{ + public int Num { get; set; } = 5; + public int? HealthPercent { get; set; } + public int? Role { get; set; } + public int? Dispel { get; set; } + public List Auras { get; set; } = new(); + + public ModuleGroupSnapshot Clone() => new() + { + Num = Num, + HealthPercent = HealthPercent, + Role = Role, + Dispel = Dispel, + Auras = (Auras ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList() + }; +} + +public sealed class ModuleGroupAuraSnapshot +{ + public int Offset { get; set; } + public string Name { get; set; } = string.Empty; + public long? SpellId { get; set; } + public List SpellIds { get; set; } = new(); + + public ModuleGroupAuraSnapshot Clone() => new() + { + Offset = Offset, + Name = Name, + SpellId = SpellId, + SpellIds = new List(SpellIds ?? []) + }; +} + +public sealed class ModuleMacrosSnapshot +{ + public bool UsesSpecDynamicSpells { get; set; } + public List DynamicCommon { get; set; } = new(); + public List DynamicForSpec { get; set; } = new(); + public List StaticSpells { get; set; } = new(); + public List SpecialSpells { get; set; } = new(); + + public ModuleMacrosSnapshot Clone() => new() + { + UsesSpecDynamicSpells = UsesSpecDynamicSpells, + DynamicCommon = new List(DynamicCommon ?? []), + DynamicForSpec = new List(DynamicForSpec ?? []), + StaticSpells = (StaticSpells ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList(), + SpecialSpells = (SpecialSpells ?? []).Where(entry => entry is not null).Select(entry => entry.Clone()).ToList() + }; +} + +public sealed class ModuleMacroEntrySnapshot +{ + public string Text { get; set; } = string.Empty; + public string? Comment { get; set; } + + public ModuleMacroEntrySnapshot Clone() => (ModuleMacroEntrySnapshot)MemberwiseClone(); +} diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs index 07ee808a..9fd52f42 100644 --- a/Modules/ModuleStore.cs +++ b/Modules/ModuleStore.cs @@ -24,6 +24,7 @@ public sealed class ModuleDefinition public List Counts { get; set; } = new(); public List ValueAdjustments { get; set; } = new(); public List Rules { get; set; } = new(); + public ModuleDependencySnapshot? Dependencies { get; set; } [JsonIgnore] public string? FilePath { get; set; } @@ -44,7 +45,8 @@ public sealed class ModuleDefinition Units = Units.Select(unit => unit.Clone()).ToList(), Counts = Counts.Select(count => count.Clone()).ToList(), ValueAdjustments = ValueAdjustments.Select(adjustment => adjustment.Clone()).ToList(), - Rules = Rules.Select(rule => rule.Clone()).ToList() + Rules = Rules.Select(rule => rule.Clone()).ToList(), + Dependencies = Dependencies?.Clone() }; } @@ -306,6 +308,20 @@ public sealed class ModuleStore } } + public void RejectModules(IEnumerable moduleIds) + { + var rejected = new HashSet(moduleIds, StringComparer.OrdinalIgnoreCase); + if (rejected.Count == 0) + { + return; + } + + lock (_gate) + { + _modules.RemoveAll(module => rejected.Contains(module.Id)); + } + } + public void Reload() { lock (_gate) diff --git a/README.md b/README.md index 649cbaa2..05b7a5c8 100644 --- a/README.md +++ b/README.md @@ -159,7 +159,9 @@ Tools\ 辅助脚本 ## 模块系统 -模块以 `模块名.json` 保存在 `module/`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。 +模块以 `模块名.json` 保存在 `module/`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。职业和专精均已指定时,模块还会携带该专精的 `ClassBlocks`、职业 `spellsList`,以及该职业的通用/专精动态宏、静态宏和特殊宏;旧模块没有这些依赖信息时仍可正常使用。 + +启动和“刷新模块”会把模块携带而本地缺少的配置与宏追加到项目 `Fuyutsui/`,不会覆盖或删除本地已有条目。发生新增后会自动重建 `config/keymap`、同步游戏插件并按需重启运行。导入前会按每项动态宏 30 个槽位、静态/特殊宏各 1 个槽位检查该职业所有受影响专精;合并结果超过 273 个槽位时,整个模块不会进入模块列表或运行时,本地 Lua 也不会被修改。模块文件仍保留在 `module/`,清理宏后可刷新重试。 模块匹配字段: diff --git a/UI/ClassConfigEditorControl.cs b/UI/ClassConfigEditorControl.cs index 9359a9ef..d4fb97aa 100644 --- a/UI/ClassConfigEditorControl.cs +++ b/UI/ClassConfigEditorControl.cs @@ -47,6 +47,7 @@ public sealed class ClassConfigEditorControl : UserControl private bool _dirty; internal event Action? DirtyStateChanged; + internal bool HasUnsavedChanges => _dirty; private string _selectedStateCategory = ClassStateCatalog.CategoryState; private string _lastStateCategory = ClassStateCatalog.CategoryState; private string _lastAuraBucket = "player"; diff --git a/UI/ClassMacrosEditorControl.cs b/UI/ClassMacrosEditorControl.cs index 5c11e188..c74bde55 100644 --- a/UI/ClassMacrosEditorControl.cs +++ b/UI/ClassMacrosEditorControl.cs @@ -35,6 +35,7 @@ public sealed class ClassMacrosEditorControl : UserControl private bool _dirty; internal event Action? DirtyStateChanged; + internal bool HasUnsavedChanges => _dirty; public ClassMacrosEditorControl( Func resolveClassMacrosPath, diff --git a/UI/MainForm.cs b/UI/MainForm.cs index c623a8bb..aa051cda 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -56,6 +56,7 @@ public sealed class MainForm : Form, IMessageFilter private readonly ITriggerKeyState _triggerKeyState; private readonly WowProcessLocator _processLocator; private readonly FuyutsuiAddonSyncService _addonSyncService; + private readonly ModuleDependencyService _moduleDependencyService; private readonly RuntimeSessionCoordinator _runtimeSession; private readonly ModuleEditorControl _moduleEditor; private readonly ClassConfigEditorControl _classConfigEditor; @@ -71,6 +72,7 @@ public sealed class MainForm : Form, IMessageFilter private string? _lastLoggedModule; private bool? _lastLoggedEnabled; private readonly object _configUpdateSync = new(); + private readonly SemaphoreSlim _moduleImportGate = new(1, 1); private Task _configUpdateTail = Task.CompletedTask; private long _runtimeRequestVersion; private bool _shutdownStarted; @@ -96,6 +98,7 @@ public sealed class MainForm : Form, IMessageFilter _processLocator = processLocator; var localAddonRoot = Path.Combine(_baseDirectory, "Fuyutsui"); _addonSyncService = new FuyutsuiAddonSyncService(localAddonRoot, _processLocator); + _moduleDependencyService = new ModuleDependencyService(_baseDirectory); _runtimeSession = runtimeSession; _uiCache = UiCacheStore.Load(); _statusForm = new StatusForm(); @@ -114,7 +117,12 @@ public sealed class MainForm : Form, IMessageFilter Application.AddMessageFilter(this); InitializeComponent(); _statusForm.AttachSettingsPanel(BuildSettingsPanel()); - _moduleEditor = new ModuleEditorControl(_moduleStore, RestartRuntimeFromEditorAsync, _baseDirectory); + _moduleEditor = new ModuleEditorControl( + _moduleStore, + RestartRuntimeFromEditorAsync, + _moduleDependencyService.Capture, + ReloadModulesWithDependenciesAsync, + _baseDirectory); _statusForm.AttachModuleEditor(_moduleEditor); _classConfigEditor = new ClassConfigEditorControl( () => Path.Combine(_addonSyncService.SourceRoot, "class"), @@ -153,10 +161,128 @@ public sealed class MainForm : Form, IMessageFilter protected override async void OnShown(EventArgs e) { base.OnShown(e); - await SynchronizeAddonAtStartupAsync(); + var dependenciesUpdated = await ImportModuleDependenciesAsync(reloadStore: true, showFeedback: true); + if (!dependenciesUpdated) + { + await SynchronizeAddonAtStartupAsync(); + } await StartRuntimeAsync(); } + private Task ReloadModulesWithDependenciesAsync() + => ImportModuleDependenciesAsync(reloadStore: true, showFeedback: true); + + private async Task ImportModuleDependenciesAsync(bool reloadStore, bool showFeedback) + { + await _moduleImportGate.WaitAsync(); + try + { + return await ImportModuleDependenciesCoreAsync(reloadStore, showFeedback); + } + finally + { + _moduleImportGate.Release(); + } + } + + private async Task ImportModuleDependenciesCoreAsync(bool reloadStore, bool showFeedback) + { + if (_classConfigEditor.HasUnsavedChanges || _classMacrosEditor.HasUnsavedChanges) + { + if (showFeedback) + { + MessageBox.Show( + "配置或宏页面存在未保存修改。请先保存或放弃修改,再刷新模块。", + "模块依赖未导入", + MessageBoxButtons.OK, + MessageBoxIcon.Warning); + } + return false; + } + + if (reloadStore) + { + _moduleStore.Reload(); + } + + ModuleDependencyImportResult result; + try + { + // 合并阶段保持在 UI 线程,避免配置/宏编辑器在检查脏状态后又并发写同一 Lua。 + result = _moduleDependencyService.Import(_moduleStore.GetModules()); + } + catch (Exception ex) + { + AppendLog($"模块依赖导入失败: {ex.Message}"); + if (showFeedback) + { + MessageBox.Show(ex.Message, "模块依赖导入失败", MessageBoxButtons.OK, MessageBoxIcon.Error); + } + return false; + } + + _moduleStore.RejectModules(result.Rejected.Select(item => item.ModuleId)); + _moduleEditor.ReloadModulesFromStore(reloadStore: false); + RefreshModuleSelector(_lastSnapshot, forceRefresh: false); + + foreach (var rejected in result.Rejected) + { + AppendLog($"模块“{rejected.ModuleName}”未导入: {rejected.Reason}"); + } + foreach (var conflict in result.Conflicts.Take(50)) + { + AppendLog($"模块依赖冲突: {conflict}"); + } + + string? postUpdateError = null; + if (result.HasChanges) + { + AppendLog( + $"已从模块补充本地依赖: 配置 {result.ConfigAdded} 项,宏 {result.MacrosAdded} 项;模块 {string.Join("、", result.ChangedModules)}"); + _classConfigEditor.ReloadFromAddon(); + _classMacrosEditor.ReloadFromAddon(); + try + { + await QueueProjectConfigUpdateAsync(savedAddonFilePath: null); + } + catch (Exception ex) + { + postUpdateError = ex.Message; + AppendLog($"模块依赖已写入,但后续配置更新失败: {ex.Message}"); + } + } + + if (showFeedback && (result.HasChanges || result.Rejected.Count > 0 || result.Conflicts.Count > 0)) + { + var lines = new List(); + if (result.HasChanges) + { + lines.Add($"成功补充配置 {result.ConfigAdded} 项、宏 {result.MacrosAdded} 项。"); + } + if (result.Rejected.Count > 0) + { + lines.Add("未导入模块:"); + lines.AddRange(result.Rejected.Select(item => $"- {item.ModuleName}: {item.Reason}")); + } + if (result.Conflicts.Count > 0) + { + lines.Add($"发现 {result.Conflicts.Count} 项冲突,均已保留本地内容;详情见日志。"); + } + if (!string.IsNullOrWhiteSpace(postUpdateError)) + { + lines.Add($"本地依赖已写入,但 config/keymap 或游戏同步更新失败:{postUpdateError}"); + } + var hasWarning = result.Rejected.Count > 0 || postUpdateError is not null; + MessageBox.Show( + string.Join(Environment.NewLine, lines), + hasWarning ? "模块导入完成(有警告)" : "模块导入完成", + MessageBoxButtons.OK, + hasWarning ? MessageBoxIcon.Warning : MessageBoxIcon.Information); + } + + return result.HasChanges; + } + protected override void OnFormClosing(FormClosingEventArgs e) { if (!_shutdownCompleted) @@ -604,7 +730,11 @@ public sealed class MainForm : Form, IMessageFilter refreshModulesButton.AutoSize = false; refreshModulesButton.Dock = DockStyle.Fill; refreshModulesButton.Margin = new Padding(0); - refreshModulesButton.Click += (_, _) => RefreshModuleSelector(_lastSnapshot, reloadModules: true); + refreshModulesButton.Click += async (_, _) => + { + await ReloadModulesWithDependenciesAsync(); + RefreshModuleSelector(_lastSnapshot, forceRefresh: false); + }; moduleCard.Controls.Add(refreshModulesButton, 1, 2); var moduleInfoText = new FlowLayoutPanel { @@ -1052,7 +1182,7 @@ public sealed class MainForm : Form, IMessageFilter SendMode.Hold => 2, _ => 0 }; - RefreshModuleSelector(_lastSnapshot, reloadModules: false); + RefreshModuleSelector(_lastSnapshot, forceRefresh: false); } private void WireSettingEvents() @@ -1173,10 +1303,9 @@ public sealed class MainForm : Form, IMessageFilter private async Task RestartRuntimeFromEditorAsync() { - RefreshModuleSelector(_lastSnapshot, reloadModules: true); + RefreshModuleSelector(_lastSnapshot, forceRefresh: false); if (!_runtimeSession.HasSession) { - _moduleStore.Reload(); return; } @@ -1258,23 +1387,18 @@ public sealed class MainForm : Form, IMessageFilter UpdateLogicStatusLabel(snapshot.Enabled); _enableButton.Text = snapshot.Enabled ? "关闭" : "开启"; - RefreshModuleSelector(snapshot, reloadModules: false); + RefreshModuleSelector(snapshot, forceRefresh: false); _statusForm.ApplySnapshot(snapshot); WriteSnapshotLog(snapshot); } - private void RefreshModuleSelector(RenderSnapshot? snapshot, bool reloadModules) + private void RefreshModuleSelector(RenderSnapshot? snapshot, bool forceRefresh) { if (_moduleComboBox is null) { return; } - if (reloadModules) - { - _moduleStore.Reload(); - } - var hasValidState = snapshot?.State?.GetBool("有效性") == true; var (classId, specId, partyType, heroTalent, filterText) = GetModuleFilter(snapshot, hasValidState); var modules = !hasValidState @@ -1287,7 +1411,7 @@ public sealed class MainForm : Form, IMessageFilter partyType, heroTalent, modules); - if (!reloadModules && signature == _lastModuleSelectorSignature) + if (!forceRefresh && signature == _lastModuleSelectorSignature) { return; } @@ -1693,7 +1817,7 @@ public sealed class MainForm : Form, IMessageFilter private void ShowSettingsView() { - RefreshModuleSelector(_lastSnapshot, reloadModules: true); + RefreshModuleSelector(_lastSnapshot, forceRefresh: false); _statusForm.ShowSettings(_lastSnapshot); } diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 04cf182a..6b569665 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -10,6 +10,8 @@ public sealed class ModuleEditorControl : UserControl private readonly ModuleStore _moduleStore; private readonly Func _runtimeRestartRequested; + private readonly Func _captureDependencies; + private readonly Func _modulesReloadRequested; private readonly string _baseDirectory; private ConditionFieldCatalog _fieldCatalog; private KeymapCatalog _keymapCatalog; @@ -90,10 +92,17 @@ public sealed class ModuleEditorControl : UserControl ("动态数值", ConditionFieldCategory.DynamicValue) ]; - public ModuleEditorControl(ModuleStore moduleStore, Func runtimeRestartRequested, string baseDirectory) + public ModuleEditorControl( + ModuleStore moduleStore, + Func runtimeRestartRequested, + Func captureDependencies, + Func modulesReloadRequested, + string baseDirectory) { _moduleStore = moduleStore; _runtimeRestartRequested = runtimeRestartRequested; + _captureDependencies = captureDependencies; + _modulesReloadRequested = modulesReloadRequested; _baseDirectory = baseDirectory; _fieldCatalog = ConditionFieldCatalog.Load(baseDirectory); _keymapCatalog = KeymapCatalog.Load(baseDirectory); @@ -185,7 +194,7 @@ public sealed class ModuleEditorControl : UserControl var reloadButton = UiTheme.CreateButton("刷新", UiTheme.ButtonKind.Secondary); StyleModuleFooterButton(reloadButton); reloadButton.Dock = DockStyle.Fill; - reloadButton.Click += (_, _) => LoadModules(); + reloadButton.Click += async (_, _) => await RunModuleCommandAsync(_modulesReloadRequested); var getModulesButton = UiTheme.CreateButton( "获取模块", @@ -3071,9 +3080,14 @@ public sealed class ModuleEditorControl : UserControl private static void StyleModuleActionButton(Button button) => StyleModuleFooterButton(button); - private void LoadModules() + public void ReloadModulesFromStore(bool reloadStore = true) => LoadModules(reloadStore); + + private void LoadModules(bool reloadStore = true) { - _moduleStore.Reload(); + if (reloadStore) + { + _moduleStore.Reload(); + } _modules = _moduleStore.GetModules().ToList(); _moduleList.Items.Clear(); foreach (var module in _modules) @@ -3247,7 +3261,7 @@ public sealed class ModuleEditorControl : UserControl return; } - LoadModules(); + LoadModules(reloadStore: false); var index = _modules.FindIndex(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase)); if (index >= 0) { @@ -3270,8 +3284,10 @@ public sealed class ModuleEditorControl : UserControl } ModuleDefinition saved; + string? dependencyWarning; try { + dependencyWarning = _captureDependencies(module); saved = _moduleStore.Save(module); } catch (InvalidOperationException ex) @@ -3280,13 +3296,18 @@ public sealed class ModuleEditorControl : UserControl return; } - LoadModules(); + LoadModules(reloadStore: false); var index = _modules.FindIndex(existing => string.Equals(existing.Id, saved.Id, StringComparison.OrdinalIgnoreCase)); if (index >= 0) { _moduleList.SelectedIndex = index; } + if (!string.IsNullOrWhiteSpace(dependencyWarning)) + { + MessageBox.Show(dependencyWarning, "模块已保存", MessageBoxButtons.OK, MessageBoxIcon.Information); + } + await _runtimeRestartRequested(); } @@ -3308,7 +3329,7 @@ public sealed class ModuleEditorControl : UserControl } _moduleStore.Delete(_selectedModule); - LoadModules(); + LoadModules(reloadStore: false); await _runtimeRestartRequested(); }