Files
Shigure/Modules/ModuleStore.cs
2026-06-14 13:30:00 +08:00

1331 lines
44 KiB
C#

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
{
public string Id { get; set; } = string.Empty;
public string Name { get; set; } = "新模块";
public bool Enabled { get; set; } = true;
public ModuleMatch Match { get; set; } = new();
public List<ModuleUnit> Units { get; set; } = new();
public List<ModuleCountField> Counts { get; set; } = new();
public List<ModuleValueAdjustment> ValueAdjustments { get; set; } = new();
public List<ModuleRule> Rules { get; set; } = new();
[JsonIgnore]
public string? FilePath { get; set; }
public ModuleDefinition Clone()
{
return new ModuleDefinition
{
Id = Id,
Name = Name,
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()
};
}
public static ModuleDefinition CreateDefault(string name = "新模块")
{
return new ModuleDefinition
{
Id = ModuleStore.CreateModuleId(name),
Name = name,
Enabled = true,
Rules =
[
new ModuleRule
{
Enabled = true,
Condition = "一键辅助 == 10",
Unit = 0,
Spell = "一键辅助",
Step = "施放 一键辅助"
}
]
};
}
}
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 int? Unit { get; set; }
public string? UnitName { get; set; }
public string Spell { get; set; } = string.Empty;
public string Hotkey { get; set; } = string.Empty;
public string Step { get; set; } = string.Empty;
public ModuleRule Clone()
{
return new ModuleRule
{
Enabled = Enabled,
Condition = Condition,
Unit = Unit,
UnitName = UnitName,
Spell = Spell,
Hotkey = Hotkey,
Step = Step
};
}
}
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<ModuleDefinition> _modules = new();
public ModuleStore(string moduleDirectory)
{
ModuleDirectory = moduleDirectory;
Directory.CreateDirectory(ModuleDirectory);
Reload();
}
public string ModuleDirectory { get; }
public static string ResolveModuleDirectory(string baseDirectory)
{
var currentModuleDirectory = Path.Combine(Environment.CurrentDirectory, "module");
if (Directory.Exists(currentModuleDirectory) || File.Exists(Path.Combine(Environment.CurrentDirectory, "Shigure.csproj")))
{
return currentModuleDirectory;
}
return Path.Combine(baseDirectory, "module");
}
public IReadOnlyList<ModuleDefinition> GetModules()
{
lock (_gate)
{
return _modules.Select(module => module.Clone()).ToList();
}
}
public void Reload()
{
Directory.CreateDirectory(ModuleDirectory);
var loaded = new List<ModuleDefinition>();
foreach (var file in Directory.EnumerateFiles(ModuleDirectory, "*.json", SearchOption.AllDirectories))
{
try
{
var module = JsonSerializer.Deserialize<ModuleDefinition>(File.ReadAllText(file), JsonOptions);
if (module is null)
{
continue;
}
Normalize(module);
module.FilePath = file;
loaded.Add(module);
}
catch
{
// 单个模块损坏时跳过,避免影响其它模块加载。
}
}
lock (_gate)
{
_modules = SortModules(loaded).ToList();
}
}
public ModuleDefinition? FindBestMatch(int? classId, int? specId, int? partyType, int? heroTalent)
{
lock (_gate)
{
return SortMatches(_modules, classId, specId, partyType, heroTalent)
.FirstOrDefault()
?.Clone();
}
}
public ModuleDefinition? FindSelectedOrBestMatch(string? selectedModuleId, int? classId, int? specId, int? partyType, int? heroTalent)
{
lock (_gate)
{
var matches = SortMatches(_modules, 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<ModuleDefinition> FindMatches(int? classId, int? specId, int? partyType, int? heroTalent)
{
lock (_gate)
{
return SortMatches(_modules, 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)}”已存在,请使用其他名称。");
}
if (!string.IsNullOrWhiteSpace(oldPath)
&& IsInsideModuleDirectory(oldPath)
&& !PathsEqual(oldPath, path)
&& File.Exists(oldPath))
{
File.Delete(oldPath);
}
Directory.CreateDirectory(Path.GetDirectoryName(path)!);
File.WriteAllText(path, JsonSerializer.Serialize(module, JsonOptions));
module.FilePath = path;
lock (_gate)
{
_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();
}
return module.Clone();
}
public void Delete(ModuleDefinition module)
{
if (!string.IsNullOrWhiteSpace(module.FilePath) && IsInsideModuleDirectory(module.FilePath) && File.Exists(module.FilePath))
{
File.Delete(module.FilePath);
}
lock (_gate)
{
_modules.RemoveAll(existing =>
string.Equals(existing.Id, module.Id, StringComparison.OrdinalIgnoreCase)
|| string.Equals(existing.FilePath, module.FilePath, StringComparison.OrdinalIgnoreCase));
}
}
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 IEnumerable<ModuleDefinition> SortModules(IEnumerable<ModuleDefinition> 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<ModuleDefinition> SortMatches(
IEnumerable<ModuleDefinition> 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<ModuleUnit>();
module.Counts ??= new List<ModuleCountField>();
module.ValueAdjustments ??= new List<ModuleValueAdjustment>();
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<ModuleRule>();
}
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<string?>
{
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, KeymapService keymap)
{
var info = CreateInfo(module, state);
var unitSlots = ResolveDynamicFields(module, state);
var failedSpells = keymap.GetCurrentFailedSpells();
var oneKeySpells = keymap.GetCurrentOneKeySpells();
var oneKeySpell = ModuleSpecialActions.GetOneKeySpell(state, oneKeySpells);
if (!string.IsNullOrWhiteSpace(oneKeySpell))
{
var hotkey = keymap.GetHotkey(0, oneKeySpell);
info["命中条件"] = $"一键辅助 == {state.GetInt("")}";
info["动作技能"] = oneKeySpell;
info["动作按键"] = string.IsNullOrWhiteSpace(hotkey) ? "-" : hotkey;
info["动作单位"] = 0;
var step = string.IsNullOrWhiteSpace(hotkey)
? $"{module.Name}: 未找到按键 {oneKeySpell}"
: $"{module.Name}: 施放 {oneKeySpell}";
return new LogicDecision(hotkey, step, info, module.Name);
}
foreach (var rule in module.Rules.Where(rule => rule.Enabled))
{
if (!ModuleConditionEvaluator.TryEvaluate(rule.Condition, state, out var conditionMatched, out var error, failedSpells))
{
info["条件错误"] = error;
info["规则条件"] = rule.Condition;
return new LogicDecision(null, $"{module.Name}: 条件错误", info, module.Name);
}
if (!conditionMatched)
{
continue;
}
if (ModuleSpecialActions.IsPauseSpell(rule.Spell))
{
info["命中条件"] = string.IsNullOrWhiteSpace(rule.Condition) ? "始终" : rule.Condition;
info["动作技能"] = ModuleSpecialActions.PauseSpell;
info["动作按键"] = "-";
info["动作单位"] = "-";
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;
}
var actionSpell = rule.Spell;
if (ModuleSpecialActions.IsFailedSpell(actionSpell))
{
actionSpell = ModuleSpecialActions.GetFailedSpell(state, failedSpells);
if (string.IsNullOrWhiteSpace(actionSpell))
{
continue;
}
}
else if (ModuleSpecialActions.IsOneKeySpell(actionSpell))
{
actionSpell = ModuleSpecialActions.GetOneKeySpell(state, oneKeySpells);
if (string.IsNullOrWhiteSpace(actionSpell))
{
continue;
}
resolvedUnit = 0;
}
var hotkey = string.IsNullOrWhiteSpace(rule.Hotkey)
? string.IsNullOrWhiteSpace(actionSpell) ? null : keymap.GetHotkey(resolvedUnit, actionSpell)
: rule.Hotkey.Trim();
var step = BuildStep(module, rule, hotkey, actionSpell);
info["命中条件"] = string.IsNullOrWhiteSpace(rule.Condition) ? "始终" : rule.Condition;
info["动作技能"] = string.IsNullOrWhiteSpace(actionSpell) ? "-" : actionSpell;
info["动作按键"] = string.IsNullOrWhiteSpace(hotkey) ? "-" : hotkey;
info["动作单位"] = string.IsNullOrWhiteSpace(rule.UnitName)
? resolvedUnit.GetValueOrDefault()
: $"{rule.UnitName} → {resolvedUnit.GetValueOrDefault()}";
return new LogicDecision(hotkey, step, info, module.Name);
}
info["命中条件"] = "-";
return new LogicDecision(null, $"{module.Name}: 无匹配规则", info, module.Name);
}
// 把模块定义的动态单位/数量各解析一次, 写入当前帧 state.Values 供条件求值与目标解析使用。
public static Dictionary<string, string?> ResolveDynamicFields(ModuleDefinition module, GameState state)
{
if (IsDynamicFieldsResolved(module, state)
&& state.Values.TryGetValue("$units", out var existingUnitsObj)
&& existingUnitsObj is Dictionary<string, string?> existingUnits)
{
return existingUnits;
}
var earlyAppliedAdjustments = ApplyValueAdjustments(
module,
state,
adjustment => IsEarlyThresholdAdjustment(module, state, adjustment));
var unitSlots = ResolveUnits(module, state);
ResolveCounts(module, state);
ApplyValueAdjustments(module, state, 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<string, string?> ResolveUnits(ModuleDefinition module, GameState state)
{
var unitSlots = new Dictionary<string, string?>(StringComparer.Ordinal);
// 生命值名 → 该单位槽位的 生命值 值(未解析则为 null), 供条件直接按名引用。
var unitHealth = new Dictionary<string, object?>(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<string, int>(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<ModuleValueAdjustment> ApplyValueAdjustments(
ModuleDefinition module,
GameState state,
Func<ModuleValueAdjustment, bool>? include = null)
{
var applied = new HashSet<ModuleValueAdjustment>();
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 _)
|| !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<string> DynamicThresholdFields(ModuleDefinition module)
{
var fields = new HashSet<string>(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;
}
GetOrCreateDynamicValues(state)[key] = value;
}
private static Dictionary<string, object?> GetOrCreateDynamicValues(GameState state)
{
if (state.Values.TryGetValue("$dynamicvalues", out var dynamicObj))
{
if (dynamicObj is Dictionary<string, object?> dynamicValues)
{
return dynamicValues;
}
if (dynamicObj is IReadOnlyDictionary<string, object?> existingValues)
{
var copiedValues = new Dictionary<string, object?>(existingValues, StringComparer.Ordinal);
state.Values["$dynamicvalues"] = copiedValues;
return copiedValues;
}
}
var values = new Dictionary<string, object?>(StringComparer.Ordinal);
state.Values["$dynamicvalues"] = values;
return values;
}
private static void ApplyValueDelta(GameState state, string field, int delta)
{
var key = field.Trim();
if (key.Length == 0)
{
return;
}
if (state.Values.TryGetValue("$counts", out var countsObj)
&& countsObj is Dictionary<string, int> counts
&& counts.TryGetValue(key, out var countValue))
{
counts[key] = countValue + delta;
return;
}
if (state.Values.TryGetValue("$unithealth", out var healthObj)
&& healthObj is Dictionary<string, object?> 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 ToInt(object? value)
{
return TryToInt(value, out var number) ? number : 0;
}
private static int AddDelta(object? value, int delta)
{
return TryToInt(value, out var number) ? number + delta : delta;
}
private static Dictionary<string, object?> CreateInfo(ModuleDefinition module, GameState state)
{
return new Dictionary<string, object?>
{
["模块"] = 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))
{
if (ModuleSpecialActions.IsPauseSpell(rule.Spell))
{
return $"{module.Name}: 暂停";
}
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*(?<field>.+?)\s+(?<op>not\s+in|in)\s*\((?<value>.*?)\)\s*$",
RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex ComparisonRegex = new(
@"^\s*(?<field>.+?)\s*(?<op>==|!=|>=|<=|>|<)\s*(?<value>.+?)\s*$",
RegexOptions.Compiled);
public static bool TryEvaluate(
string? expression,
GameState state,
out bool matched,
out string? error,
IReadOnlyDictionary<int, string>? failedSpells = 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))
{
return false;
}
if (!termMatched)
{
allAndMatched = false;
break;
}
}
if (allAndMatched)
{
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<int, string>? failedSpells)
{
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 inLeft = ResolveValue(state, inMatch.Groups["field"].Value.Trim(), failedSpells);
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);
matched = invert ? !IsTruthy(value) : IsTruthy(value);
return true;
}
var left = ResolveValue(state, comparison.Groups["field"].Value.Trim(), failedSpells);
var op = comparison.Groups["op"].Value;
var right = ParseLiteral(comparison.Groups["value"].Value.Trim());
return TryCompare(left, op, right, out matched, out error);
}
private static object? ResolveValue(
GameState state,
string fieldName,
IReadOnlyDictionary<int, string>? failedSpells = 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 (ModuleSpecialActions.IsFailedSpell(key))
{
return ModuleSpecialActions.GetFailedSpell(state, failedSpells);
}
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<string, int> counts
&& counts.TryGetValue(key, out var countValue))
{
return countValue;
}
// 生命值名(整名匹配): 动态单位的 生命值 直接命名, 如 最低血量 < 50。未解析返回 null。
if (state.Values.TryGetValue("$unithealth", out var healthObj)
&& healthObj is Dictionary<string, object?> 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<string, string?> 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<string, string?> units2
&& units2.TryGetValue(key, out var bareSlot))
{
return bareSlot is not null;
}
if (state.Values.TryGetValue("$dynamicvalues", out var dynamicObj)
&& dynamicObj is IReadOnlyDictionary<string, object?> 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<object?> 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<object?> ParseListLiterals(string value)
{
var values = new List<object?>();
foreach (var item in SplitList(value))
{
var trimmed = item.Trim();
if (trimmed.Length > 0)
{
values.Add(ParseLiteral(trimmed));
}
}
return values;
}
private static IEnumerable<string> 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
};
}
}