diff --git a/Infrastructure/ModuleDependencyService.cs b/Infrastructure/ModuleDependencyService.cs index 436c4d40..d928a836 100644 --- a/Infrastructure/ModuleDependencyService.cs +++ b/Infrastructure/ModuleDependencyService.cs @@ -687,16 +687,10 @@ internal sealed class ModuleDependencyService 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) + if (byId 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 冲突,已保留本地。"); - } + // spellId 是跨模块稳定标识;索引是当前职业文件的本地编码。 + // 同一 spellId 已存在时保留本地索引/名称,不因来源索引不同产生冲突。 continue; } diff --git a/Input/KeymapService.cs b/Input/KeymapService.cs index c3677727..88cbb67d 100644 --- a/Input/KeymapService.cs +++ b/Input/KeymapService.cs @@ -9,6 +9,7 @@ public sealed class KeymapService : IKeymapResolver private readonly ConfigService _config; private readonly Dictionary<(int Unit, string Spell, string MacroCondition), string> _hotkeys = new(); private readonly Dictionary<(int Unit, string Spell), string> _fallbackHotkeys = new(); + private readonly Dictionary _spellIndices = new(); private int? _currentClassId; private int? _currentSpecId; @@ -34,6 +35,9 @@ public sealed class KeymapService : IKeymapResolver _currentSpecId = specId; _hotkeys.Clear(); _fallbackHotkeys.Clear(); + _spellIndices.Clear(); + + LoadSpellIndices(classId); var path = KeymapCatalog.ResolveKeymapFilePath(_baseDirectory, _config.GetKeymapName(classId)); if (!File.Exists(path)) @@ -116,4 +120,36 @@ public sealed class KeymapService : IKeymapResolver { return _config.GetOneKeySpells(_currentClassId); } + + public IReadOnlyDictionary GetCurrentSpellIndices() + { + return _spellIndices; + } + + private void LoadSpellIndices(int? classId) + { + if (classId is null) + { + return; + } + + var classPath = Path.Combine( + _baseDirectory, + "Fuyutsui", + "class", + $"{ClassNames.GetConfigFileName(classId.Value)}.lua"); + try + { + var document = ClassBlocksStore.Load(classPath); + foreach (var spell in document.SpellsList.Where(spell => spell.SpellId > 0)) + { + _spellIndices.TryAdd(spell.SpellId, spell.Index); + } + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException + or InvalidDataException or ArgumentException) + { + // 职业技能列表不可用时保持空映射;引用 spellId 的条件会安全地按不命中处理。 + } + } } diff --git a/Modules/ConditionFieldCatalog.cs b/Modules/ConditionFieldCatalog.cs index dd5a8a55..a06b3c9d 100644 --- a/Modules/ConditionFieldCatalog.cs +++ b/Modules/ConditionFieldCatalog.cs @@ -36,6 +36,36 @@ public sealed record ConditionField( public override string ToString() => DisplayName; } +public sealed record ConditionSpell(long SpellId, int Index, string Name) +{ + public string DisplayName => $"{Name} / {SpellId}"; +} + +public static class SpellIdConditionFields +{ + public const string OneKeyAssist = "一键辅助"; + public const string InsertSpell = "插入法术"; + public const string CastingSpell = "施法技能"; + + private static readonly HashSet Names = new(StringComparer.Ordinal) + { + OneKeyAssist, + InsertSpell, + CastingSpell + }; + + public static bool Contains(string? fieldName) + { + var normalized = fieldName?.Trim() ?? string.Empty; + if (normalized.StartsWith("state.", StringComparison.OrdinalIgnoreCase)) + { + normalized = normalized["state.".Length..]; + } + + return Names.Contains(normalized); + } +} + /// /// 从 config 目录构建可在条件编辑器中选择的字段目录。 /// 字段按模块当前选中的职业/专精过滤;group 队伍字段暂不收录。 diff --git a/Modules/IKeymapResolver.cs b/Modules/IKeymapResolver.cs index 19adbddb..1be38509 100644 --- a/Modules/IKeymapResolver.cs +++ b/Modules/IKeymapResolver.cs @@ -9,4 +9,6 @@ public interface IKeymapResolver IReadOnlyDictionary GetCurrentFailedSpells(); IReadOnlyDictionary GetCurrentOneKeySpells(); + + IReadOnlyDictionary GetCurrentSpellIndices(); } diff --git a/Modules/LogicRegistry.cs b/Modules/LogicRegistry.cs index b0003930..6a18d16c 100644 --- a/Modules/LogicRegistry.cs +++ b/Modules/LogicRegistry.cs @@ -40,7 +40,7 @@ public sealed class LogicRegistry : IRuntimeLogic var module = FindModule(classId, specId, state); if (module is not null) { - ModuleLogic.ResolveDynamicFields(module, state); + ModuleLogic.ResolveDynamicFields(module, state, _keymap.GetCurrentSpellIndices()); return new LogicEvaluation( module.Name, runLogic ? ModuleLogic.Run(module, state, _keymap) : null); diff --git a/Modules/ModuleStore.cs b/Modules/ModuleStore.cs index 70340db9..69cf2958 100644 --- a/Modules/ModuleStore.cs +++ b/Modules/ModuleStore.cs @@ -56,19 +56,10 @@ public sealed class ModuleDefinition { Id = ModuleStore.CreateModuleId(name), Name = name, + Version = AppInfo.Version, UnitMappingVersion = CurrentUnitMappingVersion, Enabled = true, - Rules = - [ - new ModuleRule - { - Enabled = true, - Condition = "一键辅助 == 10", - Unit = 0, - Spell = "一键辅助", - Step = "施放 一键辅助" - } - ] + Rules = [] }; } } @@ -281,6 +272,7 @@ public sealed class ModuleStore private readonly object _gate = new(); private List _modules = new(); + private readonly HashSet _incompatibleVersionModuleIds = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _rejectedModuleIds = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _importIssueModuleIds = new(StringComparer.OrdinalIgnoreCase); @@ -303,7 +295,8 @@ public sealed class ModuleStore lock (_gate) { return _modules - .Where(module => !_rejectedModuleIds.Contains(module.Id)) + .Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) + && !_rejectedModuleIds.Contains(module.Id)) .Select(module => module.Clone()) .ToList(); } @@ -336,16 +329,21 @@ public sealed class ModuleStore { lock (_gate) { - return _importIssueModuleIds.Contains(moduleId); + return _incompatibleVersionModuleIds.Contains(moduleId) + || _importIssueModuleIds.Contains(moduleId); } } + public static bool HasCompatibleVersion(ModuleDefinition module) + => string.Equals(module.Version?.Trim(), AppInfo.Version.Trim(), StringComparison.Ordinal); + public void Reload() { lock (_gate) { Directory.CreateDirectory(ModuleDirectory); var loaded = new List(); + _incompatibleVersionModuleIds.Clear(); foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories)) { try @@ -359,6 +357,10 @@ public sealed class ModuleStore Normalize(module); module.FilePath = file; loaded.Add(module); + if (!HasCompatibleVersion(module)) + { + _incompatibleVersionModuleIds.Add(module.Id); + } } catch { @@ -377,7 +379,8 @@ public sealed class ModuleStore lock (_gate) { var matches = SortMatches( - _modules.Where(module => !_rejectedModuleIds.Contains(module.Id)), + _modules.Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) + && !_rejectedModuleIds.Contains(module.Id)), classId, specId, partyType, @@ -402,7 +405,8 @@ public sealed class ModuleStore lock (_gate) { return SortMatches( - _modules.Where(module => !_rejectedModuleIds.Contains(module.Id)), + _modules.Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) + && !_rejectedModuleIds.Contains(module.Id)), classId, specId, partyType, @@ -468,6 +472,7 @@ public sealed class ModuleStore || string.Equals(existing.FilePath, path, StringComparison.OrdinalIgnoreCase)); _modules.Add(module.Clone()); _modules = SortModules(_modules).ToList(); + UpdateVersionCompatibility(module); _rejectedModuleIds.Remove(module.Id); _importIssueModuleIds.Remove(module.Id); @@ -501,6 +506,7 @@ public sealed class ModuleStore _modules.Remove(existing); _modules.Add(module.Clone()); _modules = SortModules(_modules).ToList(); + UpdateVersionCompatibility(module); _rejectedModuleIds.Remove(module.Id); _importIssueModuleIds.Remove(module.Id); return module.Clone(); @@ -521,11 +527,24 @@ public sealed class ModuleStore _modules.RemoveAll(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) || string.Equals(existing.FilePath, module.FilePath, StringComparison.OrdinalIgnoreCase)); + _incompatibleVersionModuleIds.Remove(module.Id); _rejectedModuleIds.Remove(module.Id); _importIssueModuleIds.Remove(module.Id); } } + private void UpdateVersionCompatibility(ModuleDefinition module) + { + if (HasCompatibleVersion(module)) + { + _incompatibleVersionModuleIds.Remove(module.Id); + } + else + { + _incompatibleVersionModuleIds.Add(module.Id); + } + } + public static string CreateModuleId(string name) { return $"{SanitizeFileName(name)}-{DateTimeOffset.Now:yyyyMMddHHmmssfff}"; @@ -781,7 +800,8 @@ public static class ModuleLogic public static LogicDecision Run(ModuleDefinition module, GameState state, IKeymapResolver keymap) { var info = CreateInfo(module, state); - var unitSlots = ResolveDynamicFields(module, state); + var spellIndices = keymap.GetCurrentSpellIndices(); + var unitSlots = ResolveDynamicFields(module, state, spellIndices); var failedSpells = keymap.GetCurrentFailedSpells(); var oneKeySpells = keymap.GetCurrentOneKeySpells(); @@ -794,7 +814,13 @@ public static class ModuleLogic continue; } - if (!ModuleConditionEvaluator.TryEvaluateRule(rule, state, out var conditionMatched, out var error, failedSpells)) + if (!ModuleConditionEvaluator.TryEvaluateRule( + rule, + state, + out var conditionMatched, + out var error, + failedSpells, + spellIndices)) { info["条件错误"] = error; info["规则条件"] = rule.DescribeCondition(); @@ -909,7 +935,10 @@ public static class ModuleLogic } // 把模块定义的动态单位/数量各解析一次, 写入当前帧 state.Values 供条件求值与目标解析使用。 - public static Dictionary ResolveDynamicFields(ModuleDefinition module, GameState state) + public static Dictionary ResolveDynamicFields( + ModuleDefinition module, + GameState state, + IReadOnlyDictionary? spellIndices = null) { if (IsDynamicFieldsResolved(module, state) && state.Values.TryGetValue("$units", out var existingUnitsObj) @@ -921,10 +950,15 @@ public static class ModuleLogic var earlyAppliedAdjustments = ApplyValueAdjustments( module, state, + spellIndices, adjustment => IsEarlyThresholdAdjustment(module, state, adjustment)); var unitSlots = ResolveUnits(module, state); ResolveCounts(module, state); - ApplyValueAdjustments(module, state, adjustment => !earlyAppliedAdjustments.Contains(adjustment)); + ApplyValueAdjustments( + module, + state, + spellIndices, + adjustment => !earlyAppliedAdjustments.Contains(adjustment)); state.Values["$dynamicModuleId"] = module.Id; return unitSlots; } @@ -982,6 +1016,7 @@ public static class ModuleLogic private static HashSet ApplyValueAdjustments( ModuleDefinition module, GameState state, + IReadOnlyDictionary? spellIndices, Func? include = null) { var applied = new HashSet(); @@ -998,7 +1033,12 @@ public static class ModuleLogic continue; } - if (!ModuleConditionEvaluator.TryEvaluate(adjustment.Condition, state, out var matched, out _) + if (!ModuleConditionEvaluator.TryEvaluate( + adjustment.Condition, + state, + out var matched, + out _, + spellIndices: spellIndices) || !matched) { continue; @@ -1261,7 +1301,8 @@ public static class ModuleConditionEvaluator GameState state, out bool matched, out string? error, - IReadOnlyDictionary? failedSpells = null) + IReadOnlyDictionary? failedSpells = null, + IReadOnlyDictionary? spellIndices = null) { matched = false; error = null; @@ -1277,7 +1318,13 @@ public static class ModuleConditionEvaluator var allAndMatched = true; foreach (var andPart in Regex.Split(orPart, @"\s*&&\s*")) { - if (!TryEvaluateTerm(andPart, state, out var termMatched, out error, failedSpells)) + if (!TryEvaluateTerm( + andPart, + state, + out var termMatched, + out error, + failedSpells, + spellIndices)) { return false; } @@ -1307,9 +1354,10 @@ public static class ModuleConditionEvaluator GameState state, out bool matched, out string? error, - IReadOnlyDictionary? failedSpells = null) + IReadOnlyDictionary? failedSpells = null, + IReadOnlyDictionary? spellIndices = null) { - if (!TryEvaluate(rule.Condition, state, out matched, out error, failedSpells)) + if (!TryEvaluate(rule.Condition, state, out matched, out error, failedSpells, spellIndices)) { return false; } @@ -1326,7 +1374,7 @@ public static class ModuleConditionEvaluator continue; } - if (!TryEvaluate(sub, state, out var subMatched, out error, failedSpells)) + if (!TryEvaluate(sub, state, out var subMatched, out error, failedSpells, spellIndices)) { matched = false; return false; @@ -1363,7 +1411,8 @@ public static class ModuleConditionEvaluator GameState state, out bool matched, out string? error, - IReadOnlyDictionary? failedSpells) + IReadOnlyDictionary? failedSpells, + IReadOnlyDictionary? spellIndices) { matched = false; error = null; @@ -1377,7 +1426,14 @@ public static class ModuleConditionEvaluator var inMatch = InRegex.Match(trimmed); if (inMatch.Success) { - var inLeft = ResolveValue(state, inMatch.Groups["field"].Value.Trim(), failedSpells); + var inField = inMatch.Groups["field"].Value.Trim(); + if (SpellIdConditionFields.Contains(inField)) + { + error = $"{inField} 仅支持 == 或 != 判断。"; + return false; + } + + var inLeft = ResolveValue(state, inField, failedSpells); var inOp = NormalizeOperator(inMatch.Groups["op"].Value); var values = ParseListLiterals(inMatch.Groups["value"].Value); return TryCompareIn(inLeft, inOp, values, out matched, out error); @@ -1393,12 +1449,56 @@ public static class ModuleConditionEvaluator return true; } - var left = ResolveValue(state, comparison.Groups["field"].Value.Trim(), failedSpells); + var comparisonField = comparison.Groups["field"].Value.Trim(); + var left = ResolveValue(state, comparisonField, failedSpells); var op = comparison.Groups["op"].Value; var right = ParseLiteral(comparison.Groups["value"].Value.Trim()); + if (SpellIdConditionFields.Contains(comparisonField)) + { + if (op is not ("==" or "!=")) + { + error = $"{comparisonField} 仅支持 == 或 != 判断。"; + return false; + } + + if (!TryToInt64(right, out var spellId) + || spellIndices is null + || !spellIndices.TryGetValue(spellId, out var localIndex)) + { + matched = false; + return true; + } + + right = localIndex; + } + return TryCompare(left, op, right, out matched, out error); } + private static bool TryToInt64(object? value, out long number) + { + switch (value) + { + case long longValue: + number = longValue; + return true; + case int intValue: + number = intValue; + return true; + case double doubleValue when doubleValue >= long.MinValue + && doubleValue <= long.MaxValue + && Math.Abs(doubleValue % 1) < double.Epsilon: + number = (long)doubleValue; + return true; + case string text when long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed): + number = parsed; + return true; + default: + number = 0; + return false; + } + } + private static object? ResolveValue( GameState state, string fieldName, diff --git a/README.md b/README.md index 0104e4f0..8a59a2d8 100644 --- a/README.md +++ b/README.md @@ -165,9 +165,9 @@ Tools\ 辅助脚本 ## 模块系统 -模块以 `模块名.json` 保存在我的文档目录 `{MyDocuments}/Shigure/module`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。职业和专精均已指定时,模块还会携带该专精的 `ClassBlocks`、职业 `spellsList`,以及该职业的通用/专精动态宏、静态宏和特殊宏;旧模块没有这些依赖信息时仍可正常使用。 +模块以 `模块名.json` 保存在我的文档目录 `{MyDocuments}/Shigure/module`。名称不能重复;加载时会递归扫描子目录,以兼容旧版布局。模块页保存时会写入当前 Shigure 版本。模块的 `Version` 必须与当前软件版本完整一致才会参与依赖导入、模块选择和运行;版本不一致或为空的模块仍在编辑器列表中显示为红色,可在检查并显式保存后升级到当前版本。职业和专精均已指定时,模块还会携带该专精的 `ClassBlocks`、职业 `spellsList`,以及该职业的通用/专精动态宏、静态宏和特殊宏。 -启动和“刷新模块”会把模块携带而本地缺少的配置与宏追加到项目 `Fuyutsui/`,不会覆盖或删除本地已有条目。发生新增后会自动重建 `config/keymap`、同步游戏插件并按需重启运行。导入前会按每项动态宏 30 个槽位、静态/特殊宏各 1 个槽位检查该职业所有受影响专精;合并结果超过 273 个槽位时,整个模块不会进入模块列表或运行时,本地 Lua 也不会被修改。模块文件仍保留在模块目录,清理宏后可刷新重试。 +启动和“刷新模块”会把模块携带而本地缺少的配置与宏追加到项目 `Fuyutsui/`,不会覆盖或删除本地已有条目。技能列表仅以 `spellId` 判断是否已存在;同一 spellId 使用本地索引和名称,缺失 spellId 按模块快照追加,允许多个 spellId 共用同一索引。发生新增后会自动重建 `config/keymap`、同步游戏插件并按需重启运行。导入前会按每项动态宏 30 个槽位、静态/特殊宏各 1 个槽位检查该职业所有受影响专精;合并结果超过 273 个槽位时,整个模块不会进入模块列表或运行时,本地 Lua 也不会被修改。模块文件仍保留在模块目录,清理宏后可刷新重试。 模块匹配字段: @@ -186,7 +186,7 @@ Tools\ 辅助脚本 "Name": "戒律", "Author": "模块作者", "RecommendedTalent": "推荐天赋代码或说明", - "Version": "1.2.1", + "Version": "1.2.1.16", "UnitMappingVersion": 3, "Enabled": true, "Match": { @@ -230,6 +230,8 @@ Tools\ 辅助脚本 - 布尔简写:`移动` 表示为真,`!移动` 表示为假。 - 空条件:始终命中。 +`一键辅助`、`插入法术`、`施法技能` 是技能列表索引状态。可视化编辑器为这三个字段提供“技能图标 / 技能名称 / spellId”下拉,并且只允许 `==`、`!=`;模块条件保存 spellId,运行时按当前职业的 `spellsList` 把 spellId 转换为本地索引后再比较,因此模块不依赖来源职业文件中的索引分配。找不到 spellId 时,该条件行和所属规则行显示为红色,条件按不命中处理。 + 字符串可以使用单引号或双引号。`true/yes/是`、`false/no/否`、`null/nil/空` 会转换为对应字面量。`&&` 的优先级高于 `||`,主表达式不支持括号嵌套。 规则还可添加多个“子条件”:主条件与子条件组是“且”关系,子条件之间是“或”关系,即 `主条件 && (子条件 1 || 子条件 2)`。这用于表达主编辑器无法用括号表示的逻辑。 diff --git a/Shigure.csproj b/Shigure.csproj index 3e38fb75..db9b6796 100644 --- a/Shigure.csproj +++ b/Shigure.csproj @@ -9,9 +9,9 @@ Shigure Shigure Arasaka Corporation - 1.2.1.15 - 1.2.1.15 - 1.2.1.15 + 1.2.1.16 + 1.2.1.16 + 1.2.1.16 Assets\arasaka-icon.ico PerMonitorV2 diff --git a/UI/ConditionEditorForm.cs b/UI/ConditionEditorForm.cs index 0098d50a..8417066f 100644 --- a/UI/ConditionEditorForm.cs +++ b/UI/ConditionEditorForm.cs @@ -151,6 +151,7 @@ public sealed class ConditionEditorForm : Form private static readonly string[] AllOperators = ["==", "!=", ">", ">=", "<", "<=", "in", "not in"]; private static readonly string[] TextOperators = ["==", "!=", "in", "not in"]; private static readonly string[] BoolOperators = ["==", "!="]; + private static readonly string[] SpellOperators = ["==", "!="]; private static readonly string[] DelayOperators = ["=="]; private static readonly CategoryItem[] CategoryItems = [ @@ -163,7 +164,9 @@ public sealed class ConditionEditorForm : Form ]; private readonly IReadOnlyList _fields; + private readonly IReadOnlyList _spells; private readonly Func>? _conditionFieldsProvider; + private readonly Func>? _conditionSpellsProvider; private readonly string _originalCondition; private readonly bool _allowSubConditions; private readonly bool _allowRuleSettings; @@ -191,11 +194,15 @@ public sealed class ConditionEditorForm : Form int? delayMs = null, int? logicDelayMs = null, bool allowRuleSettings = false, - Func>? conditionFieldsProvider = null) + Func>? conditionFieldsProvider = null, + IReadOnlyList? spells = null, + Func>? conditionSpellsProvider = null) { // 保存独立快照,避免父级字段集合后续刷新时影响当前弹窗;子弹窗则通过 provider 取得最新目录。 _fields = fields.ToArray(); + _spells = (spells ?? []).ToArray(); _conditionFieldsProvider = conditionFieldsProvider; + _conditionSpellsProvider = conditionSpellsProvider; _originalCondition = condition ?? string.Empty; _allowSubConditions = allowSubConditions; _allowRuleSettings = allowRuleSettings; @@ -509,10 +516,13 @@ public sealed class ConditionEditorForm : Form private string? PromptSubCondition(string current) { var fields = _conditionFieldsProvider?.Invoke() ?? _fields; + var spells = _conditionSpellsProvider?.Invoke() ?? _spells; using var editor = new ConditionEditorForm( fields, current, - conditionFieldsProvider: _conditionFieldsProvider); + conditionFieldsProvider: _conditionFieldsProvider, + spells: spells, + conditionSpellsProvider: _conditionSpellsProvider); return editor.ShowDialog(this) == DialogResult.OK ? editor.ConditionText : null; } @@ -633,12 +643,25 @@ public sealed class ConditionEditorForm : Form } var cell = _conditionsGrid.Rows[e.RowIndex].Cells[e.ColumnIndex]; + cell.ToolTipText = string.Empty; + var missingSpell = GetMissingSpellMessage(_conditionsGrid.Rows[e.RowIndex]); + if (missingSpell is not null) + { + e.CellStyle.BackColor = UiTheme.DangerSoft; + e.CellStyle.ForeColor = UiTheme.Danger; + e.CellStyle.SelectionBackColor = UiTheme.Danger; + e.CellStyle.SelectionForeColor = UiTheme.Background; + cell.ToolTipText = missingSpell; + return; + } + if (e.ColumnIndex == _conditionsGrid.Columns[FieldColumn]!.Index) { cell.ToolTipText = cell.Value?.ToString() switch { - "一键辅助" => "一键辅助, 数值参考技能列表", - "插入法术" => "插入法术, 数值参考技能列表", + "一键辅助" => "一键辅助,条件保存技能列表中的 spellId", + "插入法术" => "插入法术,条件保存技能列表中的 spellId", + "施法技能" => "施法技能,条件保存技能列表中的 spellId", _ => e.Value?.ToString() ?? string.Empty }; return; @@ -747,9 +770,15 @@ public sealed class ConditionEditorForm : Form } var items = sourceItems - .Select(item => item is FieldItem field - ? new UiDropDownOption(field.Name, field.Display) - : new UiDropDownOption(item.ToString() ?? string.Empty, item.ToString() ?? string.Empty)) + .Select(item => item switch + { + FieldItem field => new UiDropDownOption(field.Name, field.Display), + SpellValueItem spell => new UiDropDownOption( + spell.Value, + spell.Display, + spell.Missing ? null : SpellIconCatalog.Get(spell.SpellId)), + _ => new UiDropDownOption(item.ToString() ?? string.Empty, item.ToString() ?? string.Empty) + }) .DistinctBy(item => item.Value?.ToString(), StringComparer.Ordinal) .ToList(); var currentValue = cell.Value is FieldItem field @@ -1012,6 +1041,7 @@ public sealed class ConditionEditorForm : Form RefreshConnectors(); UpdatePreview(); + _conditionsGrid.InvalidateRow(e.RowIndex); } private void RefreshConnectors() @@ -1235,11 +1265,13 @@ public sealed class ConditionEditorForm : Form var isRuleSetting = IsRuleSettingField(field); var ops = isRuleSetting ? DelayOperators - : field is { IsCustom: false, Type: ConditionFieldType.Bool } - ? BoolOperators - : field is { IsCustom: false, Type: ConditionFieldType.String } - ? TextOperators - : AllOperators; + : SpellIdConditionFields.Contains(field?.Name) + ? SpellOperators + : field is { IsCustom: false, Type: ConditionFieldType.Bool } + ? BoolOperators + : field is { IsCustom: false, Type: ConditionFieldType.String } + ? TextOperators + : AllOperators; var cell = (DataGridViewComboBoxCell)row.Cells[OperatorColumn]; cell.Items.Clear(); @@ -1253,9 +1285,15 @@ public sealed class ConditionEditorForm : Form : DataGridViewComboBoxDisplayStyle.DropDownButton; } - private static void ConfigureValueCell(DataGridViewRow row, string? rawValue, bool preserveRaw) + private void ConfigureValueCell(DataGridViewRow row, string? rawValue, bool preserveRaw) { var field = SelectedField(row); + if (SpellIdConditionFields.Contains(field?.Name)) + { + ConfigureSpellValueCell(row, rawValue, preserveRaw); + return; + } + if (field is { IsCustom: false, Type: ConditionFieldType.Bool }) { var combo = new DataGridViewComboBoxCell @@ -1288,6 +1326,71 @@ public sealed class ConditionEditorForm : Form row.Cells[ValueColumn] = new DataGridViewTextBoxCell { Value = text }; } + private void ConfigureSpellValueCell(DataGridViewRow row, string? rawValue, bool preserveRaw) + { + var combo = new DataGridViewComboBoxCell + { + DisplayStyle = DataGridViewComboBoxDisplayStyle.DropDownButton, + FlatStyle = FlatStyle.Flat, + DisplayMember = nameof(SpellValueItem.Display), + ValueMember = nameof(SpellValueItem.Value), + ValueType = typeof(string) + }; + foreach (var spell in _spells + .Where(spell => spell.SpellId > 0) + .DistinctBy(spell => spell.SpellId) + .OrderBy(spell => spell.Index) + .ThenBy(spell => spell.SpellId)) + { + combo.Items.Add(new SpellValueItem( + spell.SpellId.ToString(CultureInfo.InvariantCulture), + spell.DisplayName, + spell.SpellId)); + } + + var value = preserveRaw ? rawValue?.Trim() ?? string.Empty : string.Empty; + if (value.Length == 0 && combo.Items.Count > 0) + { + value = ((SpellValueItem)combo.Items[0]!).Value; + } + + if (value.Length > 0 + && !combo.Items.Cast().Any(item => + string.Equals(item.Value, value, StringComparison.Ordinal))) + { + var parsed = long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var spellId) + ? spellId + : 0; + combo.Items.Insert(0, new SpellValueItem( + value, + $"spellId {value}(不存在)", + parsed, + Missing: true)); + } + + combo.Value = value; + row.Cells[ValueColumn] = combo; + } + + private string? GetMissingSpellMessage(DataGridViewRow row) + { + var field = SelectedField(row)?.Name; + if (!SpellIdConditionFields.Contains(field)) + { + return null; + } + + var value = row.Cells[ValueColumn].Value?.ToString()?.Trim() ?? string.Empty; + if (!long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var spellId) + || spellId <= 0 + || !_spells.Any(spell => spell.SpellId == spellId)) + { + return $"{field}不存在 spellId 为 {value} 的法术"; + } + + return null; + } + private List CollectTerms() { var terms = new List(); @@ -1555,4 +1658,13 @@ public sealed class ConditionEditorForm : Form { public override string ToString() => Display; } + + private sealed record SpellValueItem( + string Value, + string Display, + long SpellId, + bool Missing = false) + { + public override string ToString() => Display; + } } diff --git a/UI/MainForm.cs b/UI/MainForm.cs index fcfbfab5..21348007 100644 --- a/UI/MainForm.cs +++ b/UI/MainForm.cs @@ -1521,7 +1521,8 @@ public sealed class MainForm : Form, IMessageFilter private ClassModuleSaveResult SaveModulesForClass(int classId) { var modules = _moduleStore.GetModulesForDisplay() - .Where(module => module.Match.ClassId == classId) + .Where(module => module.Match.ClassId == classId + && ModuleStore.HasCompatibleVersion(module)) .ToList(); var warnings = new List(); var errors = new List(); diff --git a/UI/ModuleEditorControl.cs b/UI/ModuleEditorControl.cs index 009440c9..6deb60be 100644 --- a/UI/ModuleEditorControl.cs +++ b/UI/ModuleEditorControl.cs @@ -59,6 +59,7 @@ public sealed class ModuleEditorControl : UserControl private readonly List _valueAdjustments = new(); private readonly Dictionary> _currentClassSpellIdsByName = new(StringComparer.Ordinal); + private readonly List _currentClassConditionSpells = new(); private HashSet? _availableConditionFields; private HashSet? _availableGroupConditionFields; // 载入时程序化写入"类型"单元格会触发 CellValueChanged; 置真以跳过"按类型清空数值"的联动。 @@ -585,11 +586,15 @@ public sealed class ModuleEditorControl : UserControl RefreshKeymapColumns(); RefreshAdjustmentFieldColumn(); RefreshRuleSpellIcons(); + InvalidateConditionFieldValidation(); + _rulesGrid.Invalidate(); }; _specBox.SelectedIndexChanged += (_, _) => { ResetHeroTalentOptions(_heroTalentBox, ReadMatchCombo(_classBox), ReadMatchCombo(_specBox)); RefreshAdjustmentFieldColumn(); + InvalidateConditionFieldValidation(); + _rulesGrid.Invalidate(); }; AddMatchField(row, "职业:", _classBox, 0); @@ -1193,6 +1198,7 @@ public sealed class ModuleEditorControl : UserControl private void ReloadCurrentClassSpellIds() { _currentClassSpellIdsByName.Clear(); + _currentClassConditionSpells.Clear(); var classId = ReadMatchCombo(_classBox); if (classId is null) { @@ -1213,6 +1219,11 @@ public sealed class ModuleEditorControl : UserControl .ThenBy(spell => spell.SpellId)) { var name = spell.Name.Trim(); + if (_currentClassConditionSpells.All(item => item.SpellId != spell.SpellId)) + { + _currentClassConditionSpells.Add(new ConditionSpell(spell.SpellId, spell.Index, name)); + } + if (!_currentClassSpellIdsByName.TryGetValue(name, out var spellIds)) { spellIds = []; @@ -1896,9 +1907,11 @@ public sealed class ModuleEditorControl : UserControl return; } - if (!row.IsNewRow && GetMissingConditionFields(row).Count > 0) + if (!row.IsNewRow + && (GetMissingConditionFields(row).Count > 0 + || GetMissingConditionSpells(row).Count > 0)) { - // 缺失字段会让条件静默不命中;用整行红色状态在保存前就提醒用户修复配置。 + // 缺失字段或 spellId 会让条件不命中;用整行红色状态提醒用户修复配置。 e.CellStyle.BackColor = UiTheme.DangerSoft; e.CellStyle.ForeColor = UiTheme.Danger; e.CellStyle.SelectionBackColor = UiTheme.Danger; @@ -1965,6 +1978,42 @@ public sealed class ModuleEditorControl : UserControl return missing; } + private IReadOnlyList GetMissingConditionSpells(DataGridViewRow row) + { + var availableSpellIds = _currentClassConditionSpells + .Select(spell => spell.SpellId) + .ToHashSet(); + var missing = new List(); + var seen = new HashSet(StringComparer.Ordinal); + var metadata = GetRuleMetadata(row); + foreach (var expression in new[] { CellText(row, "Condition") }.Concat(metadata.SubConditions)) + { + foreach (var term in ConditionExpression.Parse(expression)) + { + if (!SpellIdConditionFields.Contains(term.Field)) + { + continue; + } + + var value = term.Value.Trim(); + if (long.TryParse(value, out var spellId) + && spellId > 0 + && availableSpellIds.Contains(spellId)) + { + continue; + } + + var message = $"{term.Field}不存在 spellId 为 {value} 的法术"; + if (seen.Add(message)) + { + missing.Add(message); + } + } + } + + return missing; + } + // 字段目录的构造会读取职业配置和动态数值表;缓存后避免每个可见单元格重复做同一份工作。 private void EnsureConditionFieldValidationCatalog() { @@ -2197,9 +2246,18 @@ public sealed class ModuleEditorControl : UserControl } var missingFields = GetMissingConditionFields(_rulesGrid.Rows[rowIndex]); - if (missingFields.Count > 0) + var missingSpells = GetMissingConditionSpells(_rulesGrid.Rows[rowIndex]); + if (missingFields.Count > 0 || missingSpells.Count > 0) { - return $"条件字段不存在:{string.Join("、", missingFields)}\n请先添加对应字段。"; + var messages = new List(); + if (missingFields.Count > 0) + { + messages.Add($"条件字段不存在:{string.Join("、", missingFields)}"); + messages.Add("请先添加对应字段。"); + } + + messages.AddRange(missingSpells); + return string.Join('\n', messages); } if (columnName is "MoveUp" or "MoveDown" or "Copy" or "InsertBlank" or "Delete") @@ -2558,7 +2616,9 @@ public sealed class ModuleEditorControl : UserControl using var editor = new ConditionEditorForm( RefreshAndBuildConditionFields(), current, - conditionFieldsProvider: () => RefreshAndBuildConditionFields()); + conditionFieldsProvider: () => RefreshAndBuildConditionFields(), + spells: RefreshAndBuildConditionSpells(), + conditionSpellsProvider: () => RefreshAndBuildConditionSpells()); if (editor.ShowDialog(FindForm()) != DialogResult.OK) { return; @@ -2909,6 +2969,7 @@ public sealed class ModuleEditorControl : UserControl var current = row.IsNewRow ? string.Empty : CellText(row, "Condition"); var currentMetadata = row.IsNewRow ? new RuleRowMetadata() : GetRuleMetadata(row); var fields = RefreshAndBuildConditionFields(includeRuleSettings: true); + var spells = RefreshAndBuildConditionSpells(); using var editor = new ConditionEditorForm( fields, @@ -2918,7 +2979,9 @@ public sealed class ModuleEditorControl : UserControl delayMs: currentMetadata.DelayMs, logicDelayMs: currentMetadata.LogicDelayMs, allowRuleSettings: true, - conditionFieldsProvider: () => RefreshAndBuildConditionFields(includeRuleSettings: true)); + conditionFieldsProvider: () => RefreshAndBuildConditionFields(includeRuleSettings: true), + spells: spells, + conditionSpellsProvider: () => RefreshAndBuildConditionSpells()); if (editor.ShowDialog(FindForm()) != DialogResult.OK) { return; @@ -2976,6 +3039,12 @@ public sealed class ModuleEditorControl : UserControl return BuildConditionFields(includeRuleSettings); } + private IReadOnlyList RefreshAndBuildConditionSpells() + { + ReloadCurrentClassSpellIds(); + return _currentClassConditionSpells.ToArray(); + } + private IReadOnlyList BuildConditionFields(bool includeRuleSettings = false) { var classId = ReadMatchCombo(_classBox); diff --git a/UI/UiDropDown.cs b/UI/UiDropDown.cs index e668be43..4f0a310d 100644 --- a/UI/UiDropDown.cs +++ b/UI/UiDropDown.cs @@ -3,7 +3,7 @@ using System.Drawing.Drawing2D; namespace Shigure; -internal sealed record UiDropDownOption(object? Value, string Display) +internal sealed record UiDropDownOption(object? Value, string Display, Image? Icon = null) { public override string ToString() => Display; } @@ -32,6 +32,10 @@ internal static class UiDropDownPopup var itemHeight = Math.Max( (int)Math.Round(32 * scale), owner.Font.Height + (int)Math.Round(12 * scale)); + if (items.Any(item => item.Icon is not null)) + { + itemHeight = Math.Max(itemHeight, (int)Math.Round(38 * scale)); + } var visibleItems = Math.Clamp(items.Count, 1, maximumVisibleItems); var measuredWidth = items.Max(item => TextRenderer.MeasureText(DisplayText(item.Display), owner.Font).Width); @@ -181,10 +185,25 @@ internal static class UiDropDownPopup e.Graphics.FillRectangle(background, e.Bounds); } + var textLeft = e.Bounds.Left + 10; + if (item.Icon is not null) + { + var iconSize = Math.Min( + e.Bounds.Height - UiTheme.Scale(listBox, 8), + UiTheme.Scale(listBox, 28)); + var iconBounds = new Rectangle( + textLeft, + e.Bounds.Top + (e.Bounds.Height - iconSize) / 2, + iconSize, + iconSize); + e.Graphics.DrawImage(item.Icon, iconBounds); + textLeft = iconBounds.Right + 8; + } + var textBounds = new Rectangle( - e.Bounds.Left + 10, + textLeft, e.Bounds.Top, - Math.Max(0, e.Bounds.Width - 20), + Math.Max(0, e.Bounds.Right - textLeft - 10), e.Bounds.Height); TextRenderer.DrawText( e.Graphics,