mirror of
https://github.com/waynebian01/Shigure.git
synced 2026-09-03 07:15:24 +08:00
feat: Implement module dependency management and unsaved changes tracking
- Added `HasUnsavedChanges` property to `ClassConfigEditorControl` and `ClassMacrosEditorControl` to track unsaved changes. - Introduced `ModuleDependencyService` for handling module dependencies, including capturing and importing configurations and macros. - Implemented atomic file writing in `AtomicFile` to ensure safe file operations. - Enhanced `MainForm` to check for unsaved changes before importing module dependencies and added feedback mechanisms for users. - Updated `ModuleEditorControl` to support dependency capturing and module reloading. - Created data structures for module dependency snapshots, configurations, and macros. - Improved error handling and user notifications during module import processes.
This commit is contained in:
@@ -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);
|
||||
|
||||
|
||||
@@ -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(...)`(单一来源,勿再复制一份描述逻辑)。
|
||||
|
||||
|
||||
26
Infrastructure/AtomicFile.cs
Normal file
26
Infrastructure/AtomicFile.cs
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
|
||||
715
Infrastructure/ModuleDependencyService.cs
Normal file
715
Infrastructure/ModuleDependencyService.cs
Normal file
@@ -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<string>(macros.DynamicCommon),
|
||||
DynamicForSpec = macros.UsesSpecDynamicSpells
|
||||
? new List<string>(macros.DynamicBySpec.GetValueOrDefault(specId.Value) ?? [])
|
||||
: [],
|
||||
StaticSpells = macros.StaticSpells.Select(CaptureMacro).ToList(),
|
||||
SpecialSpells = macros.SpecialSpells.Select(CaptureMacro).ToList()
|
||||
}
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
public ModuleDependencyImportResult Import(IReadOnlyList<ModuleDefinition> modules)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
return ImportCore(modules);
|
||||
}
|
||||
}
|
||||
|
||||
private ModuleDependencyImportResult ImportCore(IReadOnlyList<ModuleDefinition> 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<string>(spec.FlatStates),
|
||||
CategorizedStates = spec.CategorizedStates.ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => new List<string>(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<long>(entry.SpellIds)
|
||||
}).ToList()
|
||||
}
|
||||
};
|
||||
|
||||
private static ModuleAuraSnapshot CaptureAura(ClassBlocksStore.AuraEntry entry) => new()
|
||||
{
|
||||
Name = entry.Name,
|
||||
SpellId = entry.SpellId,
|
||||
SpellIds = new List<long>(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<string> local, IEnumerable<string> incoming, MergeCounters counters)
|
||||
{
|
||||
var existing = new HashSet<string>(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<ClassBlocksStore.AuraEntry> local,
|
||||
IEnumerable<ModuleAuraSnapshot> 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<ClassBlocksStore.SpellEntry> local,
|
||||
IEnumerable<ModuleSpellSnapshot> 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<ClassBlocksStore.SpellsListEntry> local,
|
||||
IEnumerable<ModuleSpellListEntrySnapshot> 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<int>(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<int> 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<string>(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<string>();
|
||||
local.DynamicBySpec[specId] = specMacros;
|
||||
}
|
||||
|
||||
var resolved = new HashSet<string>(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<MacroIdentity, ModuleMacroEntrySnapshot> BuildMacroIdentities(ClassMacrosStore.ClassMacros macros)
|
||||
{
|
||||
var result = new Dictionary<MacroIdentity, ModuleMacroEntrySnapshot>();
|
||||
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<ClassMacrosStore.ArrayEntry> local,
|
||||
IEnumerable<ModuleMacroEntrySnapshot> incoming,
|
||||
bool isSpecial,
|
||||
IDictionary<MacroIdentity, ModuleMacroEntrySnapshot> 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<Exception>();
|
||||
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<Exception> 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<string> 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<string> ChangedModules { get; } = new();
|
||||
public List<string> Conflicts { get; } = new();
|
||||
public List<RejectedModuleDependency> Rejected { get; } = new();
|
||||
public bool HasChanges => ConfigAdded > 0 || MacrosAdded > 0;
|
||||
}
|
||||
|
||||
internal sealed record RejectedModuleDependency(string ModuleId, string ModuleName, string Reason);
|
||||
166
Modules/ModuleDependencies.cs
Normal file
166
Modules/ModuleDependencies.cs
Normal file
@@ -0,0 +1,166 @@
|
||||
namespace Shigure;
|
||||
|
||||
/// <summary>随模块分发的职业配置与宏快照。</summary>
|
||||
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<ModuleSpellListEntrySnapshot> 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<string> FlatStates { get; set; } = new();
|
||||
public Dictionary<string, List<string>> CategorizedStates { get; set; } = new(StringComparer.Ordinal);
|
||||
public List<ModuleAuraSnapshot> PlayerAuras { get; set; } = new();
|
||||
public List<ModuleAuraSnapshot> TargetHarmfulAuras { get; set; } = new();
|
||||
public List<ModuleAuraSnapshot> TargetHelpfulAuras { get; set; } = new();
|
||||
public List<ModuleAuraSnapshot> FocusHarmfulAuras { get; set; } = new();
|
||||
public List<ModuleAuraSnapshot> FocusHelpfulAuras { get; set; } = new();
|
||||
public List<ModuleSpellSnapshot> Spells { get; set; } = new();
|
||||
public ModuleGroupSnapshot? Group { get; set; }
|
||||
|
||||
public ModuleSpecSnapshot Clone() => new()
|
||||
{
|
||||
NestedStates = NestedStates,
|
||||
FlatStates = new List<string>(FlatStates ?? []),
|
||||
CategorizedStates = (CategorizedStates ?? new Dictionary<string, List<string>>()).ToDictionary(
|
||||
pair => pair.Key,
|
||||
pair => new List<string>(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<ModuleAuraSnapshot> CloneEntries(IEnumerable<ModuleAuraSnapshot>? 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<long> SpellIds { get; set; } = new();
|
||||
public int? MaxApps { get; set; }
|
||||
|
||||
public ModuleAuraSnapshot Clone() => new()
|
||||
{
|
||||
Name = Name,
|
||||
SpellId = SpellId,
|
||||
SpellIds = new List<long>(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<ModuleGroupAuraSnapshot> 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<long> SpellIds { get; set; } = new();
|
||||
|
||||
public ModuleGroupAuraSnapshot Clone() => new()
|
||||
{
|
||||
Offset = Offset,
|
||||
Name = Name,
|
||||
SpellId = SpellId,
|
||||
SpellIds = new List<long>(SpellIds ?? [])
|
||||
};
|
||||
}
|
||||
|
||||
public sealed class ModuleMacrosSnapshot
|
||||
{
|
||||
public bool UsesSpecDynamicSpells { get; set; }
|
||||
public List<string> DynamicCommon { get; set; } = new();
|
||||
public List<string> DynamicForSpec { get; set; } = new();
|
||||
public List<ModuleMacroEntrySnapshot> StaticSpells { get; set; } = new();
|
||||
public List<ModuleMacroEntrySnapshot> SpecialSpells { get; set; } = new();
|
||||
|
||||
public ModuleMacrosSnapshot Clone() => new()
|
||||
{
|
||||
UsesSpecDynamicSpells = UsesSpecDynamicSpells,
|
||||
DynamicCommon = new List<string>(DynamicCommon ?? []),
|
||||
DynamicForSpec = new List<string>(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();
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public sealed class ModuleDefinition
|
||||
public List<ModuleCountField> Counts { get; set; } = new();
|
||||
public List<ModuleValueAdjustment> ValueAdjustments { get; set; } = new();
|
||||
public List<ModuleRule> 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<string> moduleIds)
|
||||
{
|
||||
var rejected = new HashSet<string>(moduleIds, StringComparer.OrdinalIgnoreCase);
|
||||
if (rejected.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_modules.RemoveAll(module => rejected.Contains(module.Id));
|
||||
}
|
||||
}
|
||||
|
||||
public void Reload()
|
||||
{
|
||||
lock (_gate)
|
||||
|
||||
@@ -159,7 +159,9 @@ Tools\ 辅助脚本
|
||||
|
||||
## 模块系统
|
||||
|
||||
模块以 `模块名.json` 保存在 `module/`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。
|
||||
模块以 `模块名.json` 保存在 `module/`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。职业和专精均已指定时,模块还会携带该专精的 `ClassBlocks`、职业 `spellsList`,以及该职业的通用/专精动态宏、静态宏和特殊宏;旧模块没有这些依赖信息时仍可正常使用。
|
||||
|
||||
启动和“刷新模块”会把模块携带而本地缺少的配置与宏追加到项目 `Fuyutsui/`,不会覆盖或删除本地已有条目。发生新增后会自动重建 `config/keymap`、同步游戏插件并按需重启运行。导入前会按每项动态宏 30 个槽位、静态/特殊宏各 1 个槽位检查该职业所有受影响专精;合并结果超过 273 个槽位时,整个模块不会进入模块列表或运行时,本地 Lua 也不会被修改。模块文件仍保留在 `module/`,清理宏后可刷新重试。
|
||||
|
||||
模块匹配字段:
|
||||
|
||||
|
||||
@@ -47,6 +47,7 @@ public sealed class ClassConfigEditorControl : UserControl
|
||||
private bool _dirty;
|
||||
|
||||
internal event Action<bool>? DirtyStateChanged;
|
||||
internal bool HasUnsavedChanges => _dirty;
|
||||
private string _selectedStateCategory = ClassStateCatalog.CategoryState;
|
||||
private string _lastStateCategory = ClassStateCatalog.CategoryState;
|
||||
private string _lastAuraBucket = "player";
|
||||
|
||||
@@ -35,6 +35,7 @@ public sealed class ClassMacrosEditorControl : UserControl
|
||||
private bool _dirty;
|
||||
|
||||
internal event Action<bool>? DirtyStateChanged;
|
||||
internal bool HasUnsavedChanges => _dirty;
|
||||
|
||||
public ClassMacrosEditorControl(
|
||||
Func<string?> resolveClassMacrosPath,
|
||||
|
||||
154
UI/MainForm.cs
154
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<bool> ImportModuleDependenciesAsync(bool reloadStore, bool showFeedback)
|
||||
{
|
||||
await _moduleImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
return await ImportModuleDependenciesCoreAsync(reloadStore, showFeedback);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_moduleImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<bool> 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<string>();
|
||||
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);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ public sealed class ModuleEditorControl : UserControl
|
||||
|
||||
private readonly ModuleStore _moduleStore;
|
||||
private readonly Func<Task> _runtimeRestartRequested;
|
||||
private readonly Func<ModuleDefinition, string?> _captureDependencies;
|
||||
private readonly Func<Task> _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<Task> runtimeRestartRequested, string baseDirectory)
|
||||
public ModuleEditorControl(
|
||||
ModuleStore moduleStore,
|
||||
Func<Task> runtimeRestartRequested,
|
||||
Func<ModuleDefinition, string?> captureDependencies,
|
||||
Func<Task> 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();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user