using System.Globalization; using System.Text.Encodings.Web; using System.Text.Json; using System.Text.Json.Serialization; using System.Text.RegularExpressions; namespace Shigure; public sealed class ModuleDefinition { internal const int CurrentUnitMappingVersion = 3; public string Id { get; set; } = string.Empty; public string Name { get; set; } = "新模块"; public string Author { get; set; } = string.Empty; public string RecommendedTalent { get; set; } = string.Empty; // 保存时写入当时的 Shigure 版本(AppInfo.Version)。 public string Version { get; set; } = string.Empty; // v2: 31=玩家、32=目标、33=焦点、34=地面、35=鼠标;v3: 36/37 从目标迁移为引导/非引导宏条件。 public int? UnitMappingVersion { get; set; } public bool Enabled { get; set; } = true; public ModuleMatch Match { get; set; } = new(); public List Units { get; set; } = new(); 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; } public ModuleDefinition Clone() { return new ModuleDefinition { Id = Id, Name = Name, Author = Author, RecommendedTalent = RecommendedTalent, Version = Version, UnitMappingVersion = UnitMappingVersion, Enabled = Enabled, FilePath = FilePath, Match = Match.Clone(), 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(), Dependencies = Dependencies?.Clone() }; } public static ModuleDefinition CreateDefault(string name = "新模块") { return new ModuleDefinition { Id = ModuleStore.CreateModuleId(name), Name = name, Version = AppInfo.Version, UnitMappingVersion = CurrentUnitMappingVersion, Enabled = true, Rules = [] }; } } public sealed class ModuleMatch { public int? ClassId { get; set; } public int? SpecId { get; set; } public string? PartyType { get; set; } public int? HeroTalent { get; set; } [JsonIgnore] public int Specificity => Count(ClassId) + Count(SpecId) + Count(PartyType) + Count(HeroTalent); public bool Matches(int? classId, int? specId, int? partyType, int? heroTalent) { return MatchesOne(ClassId, classId) && MatchesOne(SpecId, specId) && MatchesPartyType(PartyType, partyType) && MatchesOne(HeroTalent, heroTalent); } public ModuleMatch Clone() { return new ModuleMatch { ClassId = ClassId, SpecId = SpecId, PartyType = NormalizePartyTypeValue(PartyType), HeroTalent = HeroTalent }; } public static string? NormalizePartyTypeValue(string? value) { var text = value?.Trim(); if (string.IsNullOrWhiteSpace(text) || text == "*" || string.Equals(text, "any", StringComparison.OrdinalIgnoreCase)) { return null; } if (string.Equals(text, "单人", StringComparison.OrdinalIgnoreCase)) { return "0"; } if (string.Equals(text, "团队", StringComparison.OrdinalIgnoreCase)) { return "1-40"; } if (string.Equals(text, "队伍", StringComparison.OrdinalIgnoreCase)) { return "46"; } if (int.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) || int.TryParse(text, NumberStyles.Integer, CultureInfo.CurrentCulture, out number)) { return number is >= 1 and <= 40 ? "1-40" : number.ToString(CultureInfo.InvariantCulture); } var rangeParts = text.Split('-', 2, StringSplitOptions.TrimEntries); if (rangeParts.Length == 2 && int.TryParse(rangeParts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var start) && int.TryParse(rangeParts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var end)) { return start <= end ? $"{start.ToString(CultureInfo.InvariantCulture)}-{end.ToString(CultureInfo.InvariantCulture)}" : $"{end.ToString(CultureInfo.InvariantCulture)}-{start.ToString(CultureInfo.InvariantCulture)}"; } return text; } private static bool MatchesOne(int? expected, int? actual) { return expected is null || actual == expected; } private static bool MatchesPartyType(string? expected, int? actual) { var normalized = NormalizePartyTypeValue(expected); if (normalized is null) { return true; } if (actual is null) { return false; } var rangeParts = normalized.Split('-', 2, StringSplitOptions.TrimEntries); if (rangeParts.Length == 2 && int.TryParse(rangeParts[0], NumberStyles.Integer, CultureInfo.InvariantCulture, out var start) && int.TryParse(rangeParts[1], NumberStyles.Integer, CultureInfo.InvariantCulture, out var end)) { return actual.Value >= start && actual.Value <= end; } return int.TryParse(normalized, NumberStyles.Integer, CultureInfo.InvariantCulture, out var exact) && actual.Value == exact; } private static int Count(int? value) { return value is null ? 0 : 1; } private static int Count(string? value) { return NormalizePartyTypeValue(value) is null ? 0 : 1; } } public sealed class ModuleRule { public bool Enabled { get; set; } = true; public string Condition { get; set; } = string.Empty; public string Comment { get; set; } = string.Empty; // 此规则命中后,两次实际发送之间的最小间隔(毫秒);null/0 表示不限制。 public int? DelayMs { get; set; } // 此规则实际发送按键后,暂停整个逻辑循环的时长(毫秒);null/0 表示不暂停。 public int? LogicDelayMs { get; set; } public int? Unit { get; set; } public string? UnitName { get; set; } public string Spell { get; set; } = string.Empty; // null 表示升级前的旧模块(运行时沿用原二元匹配);空字符串表示明确选择无宏条件。 public string? MacroCondition { get; set; } public string Hotkey { get; set; } = string.Empty; public string Step { get; set; } = string.Empty; // 子条件: 与主条件是「且」关系, 子条件之间是「或」关系。 // 命中 = 主条件成立 && (无子条件 || 任一子条件成立)。用于表达求值器写不出的 主 && (A || B)。 // 旧模块无此字段 → 反序列化为 null; 序列化时 null 会被 WhenWritingNull 忽略。 public List? SubConditions { get; set; } public ModuleRule Clone() { return new ModuleRule { Enabled = Enabled, Condition = Condition, Comment = Comment, DelayMs = DelayMs, LogicDelayMs = LogicDelayMs, Unit = Unit, UnitName = UnitName, Spell = Spell, MacroCondition = MacroCondition, Hotkey = Hotkey, Step = Step, SubConditions = SubConditions is null ? null : new List(SubConditions) }; } // 供求值日志与规则表显示复用的可读描述, 避免在 UI 里另写一份。 public string DescribeCondition() { if (SubConditions is not { Count: > 0 }) { return Condition; } var any = string.Join(" | ", SubConditions); return string.IsNullOrWhiteSpace(Condition) ? $"任一({any})" : $"{Condition} 且任一({any})"; } } public sealed class ModuleValueAdjustment { public bool Enabled { get; set; } = true; public string Condition { get; set; } = string.Empty; public string Field { get; set; } = string.Empty; public int Delta { get; set; } public string Formula { get; set; } = string.Empty; public ModuleValueAdjustment Clone() { return new ModuleValueAdjustment { Enabled = Enabled, Condition = Condition, Field = Field, Delta = Delta, Formula = Formula }; } } public sealed class ModuleStore { private static readonly JsonSerializerOptions JsonOptions = new() { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, Converters = { new JsonStringEnumConverter(), new StringOrNumberJsonConverter() } }; 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); public ModuleStore(string moduleDirectory) { ModuleDirectory = moduleDirectory; Directory.CreateDirectory(ModuleDirectory); Reload(); } public string ModuleDirectory { get; } public static string ResolveModuleDirectory() { return Path.Combine(AppPaths.UserDataDirectory, "module"); } public IReadOnlyList GetModules() { lock (_gate) { return _modules .Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) && !_rejectedModuleIds.Contains(module.Id)) .Select(module => module.Clone()) .ToList(); } } public IReadOnlyList GetModulesForDisplay() { lock (_gate) { return _modules.Select(module => module.Clone()).ToList(); } } public void SetImportIssues( IEnumerable rejectedModuleIds, IEnumerable conflictedModuleIds) { lock (_gate) { _rejectedModuleIds.Clear(); _rejectedModuleIds.UnionWith(rejectedModuleIds); _importIssueModuleIds.Clear(); _importIssueModuleIds.UnionWith(_rejectedModuleIds); _importIssueModuleIds.UnionWith(conflictedModuleIds); } } public bool HasImportIssue(string moduleId) { lock (_gate) { 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 { var module = JsonSerializer.Deserialize(File.ReadAllText(file), JsonOptions); if (module is null) { continue; } Normalize(module); module.FilePath = file; loaded.Add(module); if (!HasCompatibleVersion(module)) { _incompatibleVersionModuleIds.Add(module.Id); } } catch { // 单个模块损坏时跳过,避免影响其它模块加载。 } } _modules = SortModules(loaded).ToList(); _rejectedModuleIds.Clear(); _importIssueModuleIds.Clear(); } } public ModuleDefinition? FindSelectedOrBestMatch(string? selectedModuleId, int? classId, int? specId, int? partyType, int? heroTalent) { lock (_gate) { var matches = SortMatches( _modules.Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) && !_rejectedModuleIds.Contains(module.Id)), classId, specId, partyType, heroTalent) .ToList(); if (!string.IsNullOrWhiteSpace(selectedModuleId)) { var selected = matches.FirstOrDefault(module => string.Equals(module.Id, selectedModuleId, StringComparison.OrdinalIgnoreCase)); if (selected is not null) { return selected.Clone(); } } return matches.FirstOrDefault()?.Clone(); } } public IReadOnlyList FindMatches(int? classId, int? specId, int? partyType, int? heroTalent) { lock (_gate) { return SortMatches( _modules.Where(module => !_incompatibleVersionModuleIds.Contains(module.Id) && !_rejectedModuleIds.Contains(module.Id)), classId, specId, partyType, heroTalent) .Select(module => module.Clone()) .ToList(); } } public ModuleDefinition Save(ModuleDefinition module) { Normalize(module); var oldPath = module.FilePath; var path = BuildModulePath(module); lock (_gate) { if (_modules.Any(existing => !string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) && string.Equals(existing.Name, module.Name, StringComparison.CurrentCultureIgnoreCase))) { throw new InvalidOperationException($"模块名称“{module.Name}”已存在。"); } if (File.Exists(path) && (string.IsNullOrWhiteSpace(oldPath) || !PathsEqual(oldPath, path))) { throw new InvalidOperationException($"模块文件“{Path.GetFileName(path)}”已存在,请使用其他名称。"); } Directory.CreateDirectory(Path.GetDirectoryName(path)!); WriteFileAtomically(path, JsonSerializer.Serialize(module, JsonOptions)); if (!string.IsNullOrWhiteSpace(oldPath) && IsInsideModuleDirectory(oldPath) && !PathsEqual(oldPath, path) && File.Exists(oldPath)) { try { File.Delete(oldPath); } catch (Exception deleteError) { try { File.Delete(path); } catch (Exception rollbackError) { throw new AggregateException( "模块已写入新文件,但旧文件删除失败,且无法回滚新文件。", deleteError, rollbackError); } throw; } } module.FilePath = path; _modules.RemoveAll(existing => string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase) || 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); return module.Clone(); } } /// /// 回写已更新的模块依赖快照,并保留模块原有文件位置。 /// public ModuleDefinition SaveDependenciesInPlace(ModuleDefinition module) { Normalize(module); lock (_gate) { var existing = _modules.FirstOrDefault(item => string.Equals(item.Id, module.Id, StringComparison.OrdinalIgnoreCase)); if (existing is null || string.IsNullOrWhiteSpace(existing.FilePath)) { throw new InvalidOperationException($"找不到要清理的模块“{module.Name}”。"); } var path = existing.FilePath; if (!IsInsideModuleDirectory(path) || !File.Exists(path)) { throw new InvalidOperationException($"模块文件不在模块目录中或已不存在: {path}"); } module.FilePath = path; WriteFileAtomically(path, JsonSerializer.Serialize(module, JsonOptions)); _modules.Remove(existing); _modules.Add(module.Clone()); _modules = SortModules(_modules).ToList(); UpdateVersionCompatibility(module); _rejectedModuleIds.Remove(module.Id); _importIssueModuleIds.Remove(module.Id); return module.Clone(); } } public void Delete(ModuleDefinition module) { lock (_gate) { if (!string.IsNullOrWhiteSpace(module.FilePath) && IsInsideModuleDirectory(module.FilePath) && File.Exists(module.FilePath)) { File.Delete(module.FilePath); } _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}"; } public string CreateNextModuleName() { lock (_gate) { for (var index = 1; ; index++) { var name = $"新模块{index.ToString(CultureInfo.InvariantCulture)}"; if (!_modules.Any(module => string.Equals(module.Name, name, StringComparison.CurrentCultureIgnoreCase))) { return name; } } } } private static void WriteFileAtomically(string path, string content) { var directory = Path.GetDirectoryName(path)!; var tempPath = Path.Combine( directory, $".{Path.GetFileName(path)}.{Guid.NewGuid():N}.tmp"); try { File.WriteAllText(tempPath, content); if (File.Exists(path)) { File.Move(tempPath, path, overwrite: true); } else { File.Move(tempPath, path); } } catch { try { if (File.Exists(tempPath)) { File.Delete(tempPath); } } catch { // 保留原始写入异常;残留临时文件不会被模块扫描加载。 } throw; } } private static IEnumerable SortModules(IEnumerable modules) { return modules .OrderBy(module => module.Match.ClassId ?? int.MaxValue) .ThenBy(module => module.Match.SpecId ?? int.MaxValue) .ThenBy(module => PartyTypeSortKey(module.Match.PartyType)) .ThenBy(module => module.Match.HeroTalent ?? int.MaxValue) .ThenBy(module => module.Name, StringComparer.CurrentCultureIgnoreCase); } private static IEnumerable SortMatches( IEnumerable modules, int? classId, int? specId, int? partyType, int? heroTalent) { return modules .Where(module => module.Match.Matches(classId, specId, partyType, heroTalent)) .OrderByDescending(module => module.Match.Specificity) .ThenBy(module => module.Name, StringComparer.CurrentCultureIgnoreCase); } private string BuildModulePath(ModuleDefinition module) { var fileName = $"{SanitizeFileName(module.Name)}.json"; return Path.Combine(ModuleDirectory, fileName); } private static bool PathsEqual(string left, string right) { return string.Equals(Path.GetFullPath(left), Path.GetFullPath(right), StringComparison.OrdinalIgnoreCase); } private static void Normalize(ModuleDefinition module) { if (string.IsNullOrWhiteSpace(module.Name)) { module.Name = "新模块"; } if (string.IsNullOrWhiteSpace(module.Id)) { module.Id = CreateModuleId(module.Name); } module.Match ??= new ModuleMatch(); module.Match.PartyType = ModuleMatch.NormalizePartyTypeValue(module.Match.PartyType); module.Units ??= new List(); module.Counts ??= new List(); module.ValueAdjustments ??= new List(); module.Units.RemoveAll(unit => string.IsNullOrWhiteSpace(unit.Name)); module.Counts.RemoveAll(count => string.IsNullOrWhiteSpace(count.Name)); module.ValueAdjustments.RemoveAll(adjustment => string.IsNullOrWhiteSpace(adjustment.Field)); foreach (var unit in module.Units) { unit.Name = unit.Name.Trim(); unit.HealthName = string.IsNullOrWhiteSpace(unit.HealthName) ? null : unit.HealthName.Trim(); unit.HealthThresholdField = string.IsNullOrWhiteSpace(unit.HealthThresholdField) ? null : unit.HealthThresholdField.Trim(); } foreach (var count in module.Counts) { count.Name = count.Name.Trim(); count.HealthThresholdField = string.IsNullOrWhiteSpace(count.HealthThresholdField) ? null : count.HealthThresholdField.Trim(); } foreach (var adjustment in module.ValueAdjustments) { adjustment.Field = adjustment.Field.Trim(); adjustment.Condition = adjustment.Condition?.Trim() ?? string.Empty; adjustment.Formula = adjustment.Formula?.Trim() ?? string.Empty; } module.Rules ??= new List(); var unitMappingVersion = module.UnitMappingVersion.GetValueOrDefault(); if (unitMappingVersion < 2) { foreach (var rule in module.Rules) { rule.Unit = rule.Unit switch { 31 => ReservedUnit.Cursor, 34 => ReservedUnit.Player, _ => rule.Unit }; } } if (unitMappingVersion < 3) { foreach (var rule in module.Rules) { if (rule.Unit is 36 or 37) { var normalizedMacro = MacroConditionText.NormalizeLegacyUnit(rule.Unit.Value, rule.MacroCondition); rule.Unit = normalizedMacro.Unit; rule.MacroCondition = normalizedMacro.Condition; } } } module.UnitMappingVersion = ModuleDefinition.CurrentUnitMappingVersion; foreach (var rule in module.Rules) { rule.Comment = rule.Comment?.Trim() ?? string.Empty; rule.Spell = ModuleSpecialActions.NormalizeSpellAction(rule.Spell); if (rule.MacroCondition is not null) { rule.MacroCondition = MacroConditionText.Normalize(rule.MacroCondition); } rule.DelayMs = rule.DelayMs is > 0 ? rule.DelayMs : null; rule.LogicDelayMs = rule.LogicDelayMs is > 0 ? rule.LogicDelayMs : null; if (rule.SubConditions is null) { continue; } // 去空白、丢空项; 整组为空则回到 null, 保持文件干净且求值不见空子条件。 rule.SubConditions = rule.SubConditions .Select(sub => sub?.Trim() ?? string.Empty) .Where(sub => sub.Length > 0) .ToList(); if (rule.SubConditions.Count == 0) { rule.SubConditions = null; } } } private bool IsInsideModuleDirectory(string path) { var fullDirectory = Path.GetFullPath(ModuleDirectory).TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var fullPath = Path.GetFullPath(path); return fullPath.StartsWith(fullDirectory + Path.DirectorySeparatorChar, StringComparison.OrdinalIgnoreCase); } private static string SanitizeFileName(string value) { var text = string.IsNullOrWhiteSpace(value) ? "module" : value.Trim(); foreach (var invalid in Path.GetInvalidFileNameChars()) { text = text.Replace(invalid, '-'); } text = Regex.Replace(text, @"\s+", "-"); return text.Length > 64 ? text[..64] : text; } private static int PartyTypeSortKey(string? value) { return ModuleMatch.NormalizePartyTypeValue(value) switch { null => int.MaxValue, "0" => 0, "1-40" => 1, "46" => 46, var other when int.TryParse(other, NumberStyles.Integer, CultureInfo.InvariantCulture, out var number) => number, _ => int.MaxValue - 1 }; } private sealed class StringOrNumberJsonConverter : JsonConverter { public override string? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { return reader.TokenType switch { JsonTokenType.Null => null, JsonTokenType.String => reader.GetString(), JsonTokenType.Number when reader.TryGetInt64(out var integer) => integer.ToString(CultureInfo.InvariantCulture), JsonTokenType.Number when reader.TryGetDouble(out var number) => number.ToString(CultureInfo.InvariantCulture), JsonTokenType.True => "true", JsonTokenType.False => "false", _ => throw new JsonException($"无法将 {reader.TokenType} 转换为字符串。") }; } public override void Write(Utf8JsonWriter writer, string? value, JsonSerializerOptions options) { if (value is null) { writer.WriteNullValue(); return; } writer.WriteStringValue(value); } } } public static class ModuleLogic { public static LogicDecision Run(ModuleDefinition module, GameState state, IKeymapResolver keymap) { var info = CreateInfo(module, state); var spellIndices = keymap.GetCurrentSpellIndices(); var itemIndices = keymap.GetCurrentItemIndices(); var unitSlots = ResolveDynamicFields(module, state, spellIndices, itemIndices); var failedSpells = keymap.GetCurrentFailedSpells(); var oneKeySpells = keymap.GetCurrentOneKeySpells(); var insertItems = keymap.GetCurrentInsertItems(); var spellNames = keymap.GetCurrentSpellNames(); var itemNames = keymap.GetCurrentItemNames(); for (var ruleIndex = 0; ruleIndex < module.Rules.Count; ruleIndex++) { var rule = module.Rules[ruleIndex]; var rateLimitKey = $"{module.Id}:{ruleIndex}"; if (!rule.Enabled) { continue; } if (!ModuleConditionEvaluator.TryEvaluateRule( rule, state, out var conditionMatched, out var error, failedSpells, spellIndices, itemIndices, insertItems)) { info["条件错误"] = error; info["规则条件"] = rule.DescribeCondition(); AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, null); return new LogicDecision(null, $"{module.Name}: 条件错误", info, module.Name); } if (!conditionMatched) { continue; } if (ModuleSpecialActions.IsPauseSpell(rule.Spell)) { info["命中条件"] = string.IsNullOrWhiteSpace(rule.DescribeCondition()) ? "始终" : rule.DescribeCondition(); info["动作技能"] = ModuleSpecialActions.PauseSpell; info["动作按键"] = "-"; info["动作单位"] = "-"; AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, null); return new LogicDecision(null, $"{module.Name}: 暂停", info, module.Name); } var resolvedUnit = rule.Unit; if (!string.IsNullOrWhiteSpace(rule.UnitName)) { // 动态目标: 选择器没选中任何单位时跳过该规则(等同条件未命中)。 var slot = unitSlots.TryGetValue(rule.UnitName, out var s) ? s : null; if (slot is null) { continue; } resolvedUnit = int.TryParse(slot, out var slotUnit) ? slotUnit : 0; } long? actionSpellId = null; var actionSpellName = rule.Spell.Trim(); var isOneKeySpell = false; var isFailedItem = false; if (ModuleSpecialActions.IsFailedSpell(rule.Spell)) { actionSpellId = ModuleSpecialActions.GetFailedSpell(state, failedSpells); if (actionSpellId is null) { continue; } if (!spellNames.TryGetValue(actionSpellId.Value, out actionSpellName)) { continue; } } else if (ModuleSpecialActions.IsFailedItem(rule.Spell)) { isFailedItem = true; actionSpellId = ModuleSpecialActions.GetFailedItem(state, insertItems); if (actionSpellId is null) { continue; } if (!itemNames.TryGetValue(actionSpellId.Value, out actionSpellName)) { continue; } } else if (ModuleSpecialActions.IsOneKeySpell(rule.Spell)) { isOneKeySpell = true; actionSpellId = ModuleSpecialActions.GetOneKeySpell(state, oneKeySpells); if (actionSpellId is null) { continue; } if (!spellNames.TryGetValue(actionSpellId.Value, out actionSpellName)) { continue; } resolvedUnit = 0; } else if (long.TryParse(actionSpellName, NumberStyles.Integer, CultureInfo.InvariantCulture, out var legacySpellId) && legacySpellId > 0 && spellNames.TryGetValue(legacySpellId, out var legacySpellName)) { // 兼容曾以 spellId 保存的普通动作;编辑器再次保存时会落回技能名称。 actionSpellName = legacySpellName; } var resolvedMacroCondition = rule.MacroCondition; var hotkey = string.IsNullOrWhiteSpace(rule.Hotkey) ? actionSpellId is { } id && !isFailedItem ? keymap.GetHotkey(resolvedUnit, id, resolvedMacroCondition) : keymap.GetHotkey(resolvedUnit, actionSpellName, resolvedMacroCondition) : rule.Hotkey.Trim(); if (isOneKeySpell && string.IsNullOrWhiteSpace(rule.Hotkey) && string.IsNullOrWhiteSpace(hotkey) && actionSpellId is not null) { hotkey = keymap.GetHotkey(ReservedUnit.None, actionSpellId.Value, MacroConditionText.NoChanneling); if (!string.IsNullOrWhiteSpace(hotkey)) { resolvedUnit = ReservedUnit.None; resolvedMacroCondition = MacroConditionText.NoChanneling; } } var step = BuildStep(module, rule, hotkey, actionSpellName); info["命中条件"] = string.IsNullOrWhiteSpace(rule.Condition) ? "始终" : rule.Condition; info["动作技能"] = string.IsNullOrWhiteSpace(actionSpellName) ? "-" : actionSpellId is null ? actionSpellName : $"{actionSpellName} / {actionSpellId}"; info["宏条件"] = string.IsNullOrWhiteSpace(resolvedMacroCondition) ? "-" : MacroConditionText.ToDisplayText(resolvedMacroCondition); info["动作按键"] = string.IsNullOrWhiteSpace(hotkey) ? "-" : hotkey; info["动作单位"] = string.IsNullOrWhiteSpace(rule.UnitName) ? resolvedUnit.GetValueOrDefault() : $"{rule.UnitName} → {resolvedUnit.GetValueOrDefault()}"; AddRuleLogInfo(info, rule, ruleIndex, rateLimitKey, hotkey); return new LogicDecision( hotkey, step, info, module.Name, rule.DelayMs.GetValueOrDefault(), rateLimitKey, rule.LogicDelayMs.GetValueOrDefault()); } info["命中条件"] = "-"; return new LogicDecision(null, $"{module.Name}: 无匹配规则", info, module.Name); } private static void AddRuleLogInfo( IDictionary info, ModuleRule rule, int ruleIndex, string rateLimitKey, string? hotkey) { info["动作按键"] = string.IsNullOrWhiteSpace(hotkey) ? "-" : hotkey; info["动作延迟"] = rule.DelayMs is > 0 ? $"{rule.DelayMs.Value} ms" : "-"; info["逻辑延迟"] = rule.LogicDelayMs is > 0 ? $"{rule.LogicDelayMs.Value} ms" : "-"; info["规则编号"] = ruleIndex + 1; info["限流键"] = rateLimitKey; } // 把模块定义的动态单位/数量各解析一次, 写入当前帧 state.Values 供条件求值与目标解析使用。 public static Dictionary ResolveDynamicFields( ModuleDefinition module, GameState state, IReadOnlyDictionary? spellIndices = null, IReadOnlyDictionary? itemIndices = null) { if (IsDynamicFieldsResolved(module, state) && state.Values.TryGetValue("$units", out var existingUnitsObj) && existingUnitsObj is Dictionary existingUnits) { return existingUnits; } var earlyAppliedAdjustments = ApplyValueAdjustments( module, state, spellIndices, itemIndices, adjustment => IsEarlyThresholdAdjustment(module, state, adjustment)); var unitSlots = ResolveUnits(module, state); ResolveCounts(module, state); ApplyValueAdjustments( module, state, spellIndices, itemIndices, adjustment => !earlyAppliedAdjustments.Contains(adjustment)); state.Values["$dynamicModuleId"] = module.Id; return unitSlots; } private static bool IsDynamicFieldsResolved(ModuleDefinition module, GameState state) { return state.Values.TryGetValue("$dynamicModuleId", out var value) && string.Equals(value?.ToString(), module.Id, StringComparison.OrdinalIgnoreCase); } private static Dictionary ResolveUnits(ModuleDefinition module, GameState state) { var unitSlots = new Dictionary(StringComparer.Ordinal); // 生命值名 → 该单位槽位的 生命值 值(未解析则为 null), 供条件直接按名引用。 var unitHealth = new Dictionary(StringComparer.Ordinal); foreach (var unit in module.Units) { if (string.IsNullOrWhiteSpace(unit.Name)) { continue; } var slot = UnitSelector.Resolve(unit, state); unitSlots[unit.Name] = slot; if (!string.IsNullOrWhiteSpace(unit.HealthName)) { unitHealth[unit.HealthName] = slot is not null && state.Group.TryGetValue(slot, out var member) && member.TryGetValue("生命值", out var value) ? value : null; } } state.Values["$units"] = unitSlots; state.Values["$unithealth"] = unitHealth; return unitSlots; } private static void ResolveCounts(ModuleDefinition module, GameState state) { var counts = new Dictionary(StringComparer.Ordinal); foreach (var count in module.Counts) { if (!string.IsNullOrWhiteSpace(count.Name)) { counts[count.Name] = UnitSelector.Resolve(count, state); } } state.Values["$counts"] = counts; } private static HashSet ApplyValueAdjustments( ModuleDefinition module, GameState state, IReadOnlyDictionary? spellIndices, IReadOnlyDictionary? itemIndices = null, Func? include = null) { var applied = new HashSet(); foreach (var adjustment in module.ValueAdjustments.Where(adjustment => adjustment.Enabled)) { if (string.IsNullOrWhiteSpace(adjustment.Field) || (adjustment.Delta == 0 && string.IsNullOrWhiteSpace(adjustment.Formula))) { continue; } if (include is not null && !include(adjustment)) { continue; } if (!ModuleConditionEvaluator.TryEvaluate( adjustment.Condition, state, out var matched, out _, spellIndices: spellIndices, itemIndices: itemIndices) || !matched) { continue; } if (!ApplyValueAdjustment(state, adjustment)) { continue; } applied.Add(adjustment); } return applied; } private static bool IsEarlyThresholdAdjustment( ModuleDefinition module, GameState state, ModuleValueAdjustment adjustment) { var key = adjustment.Field.Trim(); return key.Length > 0 && !key.Contains('.') && !key.StartsWith('$') && DynamicThresholdFields(module).Contains(key); } private static HashSet DynamicThresholdFields(ModuleDefinition module) { var fields = new HashSet(StringComparer.Ordinal); foreach (var unit in module.Units) { if (!string.IsNullOrWhiteSpace(unit.HealthThresholdField)) { fields.Add(unit.HealthThresholdField.Trim()); } } foreach (var count in module.Counts) { if (!string.IsNullOrWhiteSpace(count.HealthThresholdField)) { fields.Add(count.HealthThresholdField.Trim()); } } return fields; } private static bool ApplyValueAdjustment(GameState state, ModuleValueAdjustment adjustment) { if (!string.IsNullOrWhiteSpace(adjustment.Formula)) { if (!FormulaEvaluator.TryEvaluateInt(adjustment.Formula, state, out var value, out _)) { return false; } SetDynamicValue(state, adjustment.Field, value); return true; } ApplyValueDelta(state, adjustment.Field, adjustment.Delta); return true; } private static void SetDynamicValue(GameState state, string field, object? value) { var key = field.Trim(); if (key.Length == 0) { return; } if (key.StartsWith("auras.", StringComparison.OrdinalIgnoreCase)) { GetOrCreateMutableDict(state, "auras")[key["auras.".Length..]] = value; return; } if (key.StartsWith("spells.", StringComparison.OrdinalIgnoreCase)) { GetOrCreateMutableDict(state, "spells")[key["spells.".Length..]] = value; return; } GetOrCreateDynamicValues(state)[key] = value; } private static Dictionary GetOrCreateDynamicValues(GameState state) { if (state.Values.TryGetValue("$dynamicvalues", out var dynamicObj)) { if (dynamicObj is Dictionary dynamicValues) { return dynamicValues; } if (dynamicObj is IReadOnlyDictionary existingValues) { var copiedValues = new Dictionary(existingValues, StringComparer.Ordinal); state.Values["$dynamicvalues"] = copiedValues; return copiedValues; } } var values = new Dictionary(StringComparer.Ordinal); state.Values["$dynamicvalues"] = values; return values; } private static Dictionary GetOrCreateMutableDict(GameState state, string dictKey) { if (state.Values.TryGetValue(dictKey, out var obj)) { if (obj is Dictionary mutable) { return mutable; } if (obj is IReadOnlyDictionary readOnly) { var copy = new Dictionary(readOnly, StringComparer.Ordinal); state.Values[dictKey] = copy; return copy; } } var dict = new Dictionary(StringComparer.Ordinal); state.Values[dictKey] = dict; return dict; } private static void ApplyValueDelta(GameState state, string field, int delta) { var key = field.Trim(); if (key.Length == 0) { return; } if (key.StartsWith("auras.", StringComparison.OrdinalIgnoreCase)) { var auraKey = key["auras.".Length..]; var dict = GetOrCreateMutableDict(state, "auras"); var current = dict.TryGetValue(auraKey, out var v) ? v : null; dict[auraKey] = AddDelta(current, delta); return; } if (key.StartsWith("spells.", StringComparison.OrdinalIgnoreCase)) { var spellKey = key["spells.".Length..]; var dict = GetOrCreateMutableDict(state, "spells"); var current = dict.TryGetValue(spellKey, out var v) ? v : null; dict[spellKey] = AddDelta(current, delta); return; } if (state.Values.TryGetValue("$counts", out var countsObj) && countsObj is Dictionary counts && counts.TryGetValue(key, out var countValue)) { counts[key] = countValue + delta; return; } if (state.Values.TryGetValue("$unithealth", out var healthObj) && healthObj is Dictionary unitHealth && unitHealth.ContainsKey(key)) { unitHealth[key] = AddDelta(unitHealth[key], delta); return; } state.Values[key] = AddDelta(state.Values.TryGetValue(key, out var value) ? value : null, delta); } private static int AddDelta(object? value, int delta) { return TryToInt(value, out var number) ? number + delta : delta; } private static Dictionary CreateInfo(ModuleDefinition module, GameState state) { return new Dictionary { ["模块"] = module.Name, ["职业"] = module.Match.ClassId?.ToString() ?? "*", ["专精"] = module.Match.SpecId?.ToString() ?? "*", ["队伍类型"] = state.GetInt("队伍类型"), ["英雄天赋"] = state.GetInt("英雄天赋"), ["规则数"] = module.Rules.Count }; } private static bool TryToInt(object? value, out int number) { switch (value) { case int i: number = i; return true; case long l: number = (int)l; return true; case double d: number = (int)d; return true; case decimal m: number = (int)m; return true; case bool b: number = b ? 1 : 0; return true; case string s when int.TryParse(s, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) || int.TryParse(s, NumberStyles.Integer, CultureInfo.CurrentCulture, out parsed): number = parsed; return true; default: number = 0; return false; } } private static string BuildStep(ModuleDefinition module, ModuleRule rule, string? hotkey, string? actionSpell) { if (!string.IsNullOrWhiteSpace(rule.Step)) { return $"{module.Name}: {rule.Step.Trim()}"; } if (!string.IsNullOrWhiteSpace(rule.Spell)) { var spell = string.IsNullOrWhiteSpace(actionSpell) ? rule.Spell.Trim() : actionSpell.Trim(); return string.IsNullOrWhiteSpace(hotkey) ? $"{module.Name}: 未找到按键 {spell}" : $"{module.Name}: 施放 {spell}"; } return string.IsNullOrWhiteSpace(hotkey) ? $"{module.Name}: 命中规则" : $"{module.Name}: 发送 {hotkey}"; } } public static class ModuleConditionEvaluator { private static readonly Regex InRegex = new( @"^\s*(?.+?)\s+(?not\s+in|in)\s*\((?.*?)\)\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex ComparisonRegex = new( @"^\s*(?.+?)\s*(?==|!=|>=|<=|>|<)\s*(?.+?)\s*$", RegexOptions.Compiled); public static bool TryEvaluate( string? expression, GameState state, out bool matched, out string? error, IReadOnlyDictionary? failedSpells = null, IReadOnlyDictionary? spellIndices = null, IReadOnlyDictionary? itemIndices = null, IReadOnlyDictionary? insertItems = null) { matched = false; error = null; if (string.IsNullOrWhiteSpace(expression)) { matched = true; return true; } foreach (var orPart in Regex.Split(expression, @"\s*\|\|\s*")) { var allAndMatched = true; foreach (var andPart in Regex.Split(orPart, @"\s*&&\s*")) { if (!TryEvaluateTerm( andPart, state, out var termMatched, out error, failedSpells, spellIndices, itemIndices, insertItems)) { return false; } if (!termMatched) { allAndMatched = false; break; } } if (allAndMatched) { matched = true; return true; } } matched = false; return true; } // 整条规则的命中判定: 主条件成立 且 (无子条件 || 任一子条件成立)。 // 子条件之间是「或」, 与主条件是「且」; 任一子条件求值出错都按错误返回。 public static bool TryEvaluateRule( ModuleRule rule, GameState state, out bool matched, out string? error, IReadOnlyDictionary? failedSpells = null, IReadOnlyDictionary? spellIndices = null, IReadOnlyDictionary? itemIndices = null, IReadOnlyDictionary? insertItems = null) { if (!TryEvaluate(rule.Condition, state, out matched, out error, failedSpells, spellIndices, itemIndices, insertItems)) { return false; } if (!matched || rule.SubConditions is not { Count: > 0 }) { return true; } foreach (var sub in rule.SubConditions) { if (string.IsNullOrWhiteSpace(sub)) { continue; } if (!TryEvaluate(sub, state, out var subMatched, out error, failedSpells, spellIndices, itemIndices, insertItems)) { matched = false; return false; } if (subMatched) { matched = true; return true; } } matched = false; return true; } public static bool TryResolveInt(GameState state, string fieldName, out int value) { if (TryResolveDouble(state, fieldName, out var number)) { value = (int)number; return true; } value = 0; return false; } public static bool TryResolveDouble(GameState state, string fieldName, out double value) => TryToDouble(ResolveValue(state, fieldName), out value); private static bool TryEvaluateTerm( string term, GameState state, out bool matched, out string? error, IReadOnlyDictionary? failedSpells, IReadOnlyDictionary? spellIndices, IReadOnlyDictionary? itemIndices, IReadOnlyDictionary? insertItems) { matched = false; error = null; var trimmed = term.Trim(); if (trimmed.Length == 0) { matched = true; return true; } var inMatch = InRegex.Match(trimmed); if (inMatch.Success) { var inField = inMatch.Groups["field"].Value.Trim(); if (SpellIdConditionFields.Contains(inField) || ItemIdConditionFields.Contains(inField)) { error = $"{inField} 仅支持 == 或 != 判断。"; return false; } var inLeft = ResolveValue(state, inField, failedSpells, insertItems); if (inLeft is null && IsStructuredSpellReference(inField)) { matched = false; return true; } var inOp = NormalizeOperator(inMatch.Groups["op"].Value); var values = ParseListLiterals(inMatch.Groups["value"].Value); return TryCompareIn(inLeft, inOp, values, out matched, out error); } var comparison = ComparisonRegex.Match(trimmed); if (!comparison.Success) { var invert = trimmed.StartsWith('!'); var fieldName = invert ? trimmed[1..].Trim() : trimmed; var value = ResolveValue(state, fieldName, failedSpells, insertItems); if (value is null && IsStructuredSpellReference(fieldName)) { matched = false; return true; } matched = invert ? !IsTruthy(value) : IsTruthy(value); return true; } var comparisonField = comparison.Groups["field"].Value.Trim(); var left = ResolveValue(state, comparisonField, failedSpells, insertItems); if (left is null && IsStructuredSpellReference(comparisonField)) { matched = false; return true; } 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; } else if (ItemIdConditionFields.Contains(comparisonField)) { if (op is not ("==" or "!=")) { error = $"{comparisonField} 仅支持 == 或 != 判断。"; return false; } if (!TryToInt64(right, out var itemId) || itemIndices is null || !itemIndices.TryGetValue(itemId, out var localItemIndex)) { matched = false; return true; } right = localItemIndex; } return TryCompare(left, op, right, out matched, out error); } private static bool IsStructuredSpellReference(string fieldName) { if (SpellFieldKey.TryParseSpell(fieldName, out _, out _) || SpellFieldKey.TryParseAura(fieldName, out _, out _, out _)) { return true; } var key = SpellFieldKey.StripRoot(fieldName); return Regex.IsMatch(key, @"(?:^|\.)auras\.\d+\.(?:value|apps)$", RegexOptions.IgnoreCase); } 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, IReadOnlyDictionary? failedSpells = null, IReadOnlyDictionary? insertItems = null) { var key = fieldName.Trim(); if (key.StartsWith("state.", StringComparison.OrdinalIgnoreCase)) { key = key["state.".Length..]; } if (key.StartsWith("spells.", StringComparison.OrdinalIgnoreCase)) { return state.Spells.TryGetValue(key["spells.".Length..], out var value) ? value : null; } if (key.StartsWith("spell.", StringComparison.OrdinalIgnoreCase)) { return state.Spells.TryGetValue(key["spell.".Length..], out var value) ? value : null; } if (key.StartsWith("auras.", StringComparison.OrdinalIgnoreCase)) { return state.Auras.TryGetValue(key["auras.".Length..], out var value) ? value : null; } if (key.StartsWith("aura.", StringComparison.OrdinalIgnoreCase)) { return state.Auras.TryGetValue(key["aura.".Length..], out var value) ? value : null; } if (ModuleSpecialActions.IsFailedSpell(key)) { return ModuleSpecialActions.GetFailedSpell(state, failedSpells); } if (ModuleSpecialActions.IsFailedItem(key)) { return ModuleSpecialActions.GetFailedItem(state, insertItems); } if (key.StartsWith("group.", StringComparison.OrdinalIgnoreCase)) { var parts = key.Split('.', 3); if (parts.Length == 3 && state.Group.TryGetValue(parts[1], out var unit) && unit.TryGetValue(parts[2], out var value)) { return value; } return null; } // 数量字段(整名匹配): 如 低血量人数。 if (state.Values.TryGetValue("$counts", out var countsObj) && countsObj is Dictionary counts && counts.TryGetValue(key, out var countValue)) { return countValue; } // 生命值名(整名匹配): 动态单位的 生命值 直接命名, 如 最低血量 < 50。未解析返回 null。 if (state.Values.TryGetValue("$unithealth", out var healthObj) && healthObj is Dictionary unitHealth && unitHealth.TryGetValue(key, out var healthValue)) { return healthValue; } // 动态单位字段引用: <单位名>.<字段>, 解析槽位后读 group[槽位][字段]; 单位未解析返回 null。 var dot = key.IndexOf('.'); if (dot > 0 && state.Values.TryGetValue("$units", out var unitsObj) && unitsObj is Dictionary units) { var unitName = key[..dot]; if (units.TryGetValue(unitName, out var slot)) { if (slot is null) { return null; } var field = key[(dot + 1)..]; return state.Group.TryGetValue(slot, out var member) && member.TryGetValue(field, out var value) ? value : null; } } // 裸单位名作为存在性布尔: 解析到槽位即 true。 if (state.Values.TryGetValue("$units", out var unitsObj2) && unitsObj2 is Dictionary units2 && units2.TryGetValue(key, out var bareSlot)) { return bareSlot is not null; } if (state.Values.TryGetValue("$dynamicvalues", out var dynamicObj) && dynamicObj is IReadOnlyDictionary dynamicValues && dynamicValues.TryGetValue(key, out var dynamicValue)) { return dynamicValue; } return state.GetValue(key); } private static object? ParseLiteral(string value) { var text = value.Trim(); if ((text.StartsWith('"') && text.EndsWith('"')) || (text.StartsWith('\'') && text.EndsWith('\''))) { return text[1..^1]; } return text.ToLowerInvariant() switch { "null" or "nil" or "空" => null, "true" or "yes" or "是" => true, "false" or "no" or "否" => false, _ => TryParseNumber(text, out var number) ? number : text }; } private static bool TryCompare(object? left, string op, object? right, out bool matched, out string? error) { matched = false; error = null; if (TryToDouble(left, out var leftNumber) && TryToDouble(right, out var rightNumber)) { matched = op switch { "==" => leftNumber == rightNumber, "!=" => leftNumber != rightNumber, ">" => leftNumber > rightNumber, ">=" => leftNumber >= rightNumber, "<" => leftNumber < rightNumber, "<=" => leftNumber <= rightNumber, _ => false }; return true; } if (op is "==" or "!=") { var equals = string.Equals(FormatComparable(left), FormatComparable(right), StringComparison.OrdinalIgnoreCase); matched = op == "==" ? equals : !equals; return true; } // 关系比较(> < >= <=)遇到非数字/缺失值(如未解析的动态单位字段)时不报错, 视为不命中, 继续判断下一条规则。 matched = false; return true; } private static bool TryCompareIn(object? left, string op, IReadOnlyList values, out bool matched, out string? error) { matched = false; error = null; foreach (var value in values) { if (!TryCompare(left, "==", value, out var equals, out error)) { return false; } if (equals) { matched = op == "in"; return true; } } matched = op == "not in"; return true; } private static IReadOnlyList ParseListLiterals(string value) { var values = new List(); foreach (var item in SplitList(value)) { var trimmed = item.Trim(); if (trimmed.Length > 0) { values.Add(ParseLiteral(trimmed)); } } return values; } private static IEnumerable SplitList(string value) { var start = 0; var quote = '\0'; for (var i = 0; i < value.Length; i++) { var ch = value[i]; if (quote != '\0') { if (ch == quote) { quote = '\0'; } continue; } if (ch is '"' or '\'') { quote = ch; continue; } if (ch == ',') { yield return value[start..i]; start = i + 1; } } yield return value[start..]; } private static string NormalizeOperator(string op) { return Regex.Replace(op.Trim().ToLowerInvariant(), @"\s+", " "); } private static bool IsTruthy(object? value) { return value switch { null => false, bool b => b, int i => i != 0, long l => l != 0, double d => Math.Abs(d) > double.Epsilon, string s => !string.IsNullOrWhiteSpace(s) && !string.Equals(s, "0", StringComparison.OrdinalIgnoreCase) && !string.Equals(s, "false", StringComparison.OrdinalIgnoreCase), _ => true }; } private static bool TryParseNumber(string text, out double value) { return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value) || double.TryParse(text, NumberStyles.Float, CultureInfo.CurrentCulture, out value); } private static bool TryToDouble(object? value, out double number) { switch (value) { case int i: number = i; return true; case long l: number = l; return true; case double d: number = d; return true; case bool b: number = b ? 1 : 0; return true; case string s: return TryParseNumber(s, out number); default: number = 0; return false; } } private static string FormatComparable(object? value) { return value switch { null => string.Empty, bool b => b ? "true" : "false", IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => value.ToString() ?? string.Empty }; } }