using System.Text.RegularExpressions; namespace RolemasterDb.CriticalParsing; public static class AffixEffectParser { private const string FoeTarget = "foe"; private static readonly Regex DirectHitsRegex = new(@"[+-]\s*\d+\s*H\b", RegexOptions.Compiled); private static readonly Regex PowerPointModifierRegex = new(@"\+\s*\((?[^)]+)\)\s*P\b", RegexOptions.Compiled); private static readonly Regex ModifierRegex = new(@"\((?[^0-9+\-)]*)(?[+-])\s*(?\d+)\)", RegexOptions.Compiled); private static readonly Regex QuickDirectHitsRegex = new(@"^\+(?\d+)(?:H)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickStunnedRegex = new(@"^(?\d+)s$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickMustParryRegex = new(@"^(?\d+)mp$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickNoParryRegex = new(@"^(?\d+)np$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickBleedRegex = new(@"^(?\d+)hpr$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickFoePenaltyRegex = new(@"^-(?\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickAttackerBonusRegex = new(@"^\+(?\d+)b$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private static readonly Regex QuickPowerPointRegex = new(@"^\+(?.+)pp$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static IReadOnlyList Parse(string? rawAffixText, AffixLegend affixLegend) { if (string.IsNullOrWhiteSpace(rawAffixText)) { return []; } var effects = new List(); foreach (var rawLine in rawAffixText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) { ParseLine(CriticalCellParserSupport.CollapseWhitespace(rawLine), affixLegend, effects); } return effects; } public static bool TryParseAffixToken( string token, AffixLegend affixLegend, out IReadOnlyList effects, out string normalizedToken) { normalizedToken = CriticalCellParserSupport.CollapseWhitespace(token); if (TryParseQuickToken(normalizedToken, out var quickEffects, out var canonicalToken)) { normalizedToken = canonicalToken; effects = quickEffects; return true; } var legacyEffects = Parse(normalizedToken, affixLegend); if (legacyEffects.Count > 0) { effects = legacyEffects; return true; } effects = []; return false; } private static void ParseLine(string line, AffixLegend affixLegend, List effects) { if (string.IsNullOrWhiteSpace(line) || line is "-" or "–" or "—") { return; } var consumedRanges = new List<(int Start, int End)>(); var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>(); AddMatches( DirectHitsRegex.Matches(line), matchedEffects, consumedRanges, match => { var hits = ParseSignedInteger(match.Value); return new ParsedCriticalEffect( CriticalEffectCodes.DirectHits, FoeTarget, hits, null, null, null, null, null, false, "symbol", NormalizeToken(match.Value)); }); if (affixLegend.SupportsPowerPointModifier) { AddMatches( PowerPointModifierRegex.Matches(line), matchedEffects, consumedRanges, match => new ParsedCriticalEffect( CriticalEffectCodes.PowerPointModifier, FoeTarget, null, CriticalCellParserSupport.CollapseWhitespace(match.Groups["expression"].Value), null, null, null, null, false, "symbol", NormalizeToken(match.Value))); } AddMatches( ModifierRegex.Matches(line), matchedEffects, consumedRanges, match => { var modifier = BuildModifier(match); if (modifier is null) { return null; } if (modifier.Value < 0 && affixLegend.SupportsFoePenalty) { return new ParsedCriticalEffect( CriticalEffectCodes.FoePenalty, FoeTarget, null, null, null, null, modifier.Value, null, false, "symbol", NormalizeToken(match.Value)); } if (modifier.Value > 0 && affixLegend.SupportsAttackerBonus) { return new ParsedCriticalEffect( CriticalEffectCodes.AttackerBonusNextRound, null, null, null, null, null, modifier.Value, null, false, "symbol", NormalizeToken(match.Value)); } return null; }); var symbolClusterRegex = CreateSymbolClusterRegex(affixLegend.EffectSymbols); if (symbolClusterRegex is null) { return; } foreach (Match match in symbolClusterRegex.Matches(line)) { if (!match.Success || OverlapsConsumedRange(match, consumedRanges)) { continue; } var magnitude = match.Groups["count"].Success ? int.Parse(match.Groups["count"].Value) : 1; var matchedText = NormalizeToken(match.Value); foreach (var symbol in match.Groups["symbols"].Value.Select(character => character.ToString())) { var effectCode = affixLegend.ResolveEffectCode(symbol); if (effectCode is null) { continue; } matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText))); } } if (matchedEffects.Count > 0) { effects.AddRange(matchedEffects .OrderBy(item => item.Index) .Select(item => item.Effect)); } } private static bool TryParseQuickToken( string token, out IReadOnlyList effects, out string canonicalToken) { canonicalToken = token; if (TryMatchSingle(QuickDirectHitsRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.DirectHits, FoeTarget, int.Parse(match.Groups["value"].Value), null, null, null, null, null, false, "quick", $"+{match.Groups["value"].Value}"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickStunnedRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.StunnedRounds, FoeTarget, null, null, int.Parse(match.Groups["value"].Value), null, null, null, false, "quick", $"{match.Groups["value"].Value}s"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickMustParryRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.MustParryRounds, FoeTarget, null, null, int.Parse(match.Groups["value"].Value), null, null, null, false, "quick", $"{match.Groups["value"].Value}mp"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickNoParryRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.NoParryRounds, FoeTarget, null, null, int.Parse(match.Groups["value"].Value), null, null, null, false, "quick", $"{match.Groups["value"].Value}np"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickBleedRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.BleedPerRound, FoeTarget, null, null, null, int.Parse(match.Groups["value"].Value), null, null, false, "quick", $"{match.Groups["value"].Value}hpr"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickFoePenaltyRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.FoePenalty, FoeTarget, null, null, null, null, -int.Parse(match.Groups["value"].Value), null, false, "quick", $"-{match.Groups["value"].Value}"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickAttackerBonusRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.AttackerBonusNextRound, null, null, null, null, null, int.Parse(match.Groups["value"].Value), null, false, "quick", $"+{match.Groups["value"].Value}b"), out effects, out canonicalToken)) { return true; } if (TryMatchSingle(QuickPowerPointRegex, token, match => new ParsedCriticalEffect( CriticalEffectCodes.PowerPointModifier, FoeTarget, null, NormalizePowerPointExpression(match.Groups["expression"].Value), null, null, null, null, false, "quick", $"+{NormalizePowerPointExpression(match.Groups["expression"].Value)}pp"), out effects, out canonicalToken)) { return true; } effects = []; return false; } private static bool TryMatchSingle( Regex regex, string token, Func createEffect, out IReadOnlyList effects, out string canonicalToken) { var match = regex.Match(token); if (!match.Success) { effects = []; canonicalToken = token; return false; } var effect = createEffect(match); effects = [effect]; canonicalToken = effect.SourceText ?? token; return true; } private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) => effectCode switch { CriticalEffectCodes.MustParryRounds => new ParsedCriticalEffect( effectCode, FoeTarget, null, null, magnitude, null, null, null, false, "symbol", sourceText), CriticalEffectCodes.NoParryRounds => new ParsedCriticalEffect( effectCode, FoeTarget, null, null, magnitude, null, null, null, false, "symbol", sourceText), CriticalEffectCodes.StunnedRounds => new ParsedCriticalEffect( effectCode, FoeTarget, null, null, magnitude, null, null, null, false, "symbol", sourceText), CriticalEffectCodes.BleedPerRound => new ParsedCriticalEffect( effectCode, FoeTarget, null, null, null, magnitude, null, null, false, "symbol", sourceText), _ => throw new InvalidOperationException($"Unsupported symbol effect code '{effectCode}'.") }; private static Regex? CreateSymbolClusterRegex(IReadOnlySet symbols) { if (symbols.Count == 0) { return null; } var escapedSymbols = string.Concat(symbols.Select(Regex.Escape)); return new Regex( $@"(?\d+)\s*)?(?[{escapedSymbols}]+)", RegexOptions.Compiled); } private static void AddMatches( MatchCollection matches, List<(int Index, ParsedCriticalEffect Effect)> matchedEffects, List<(int Start, int End)> consumedRanges, Func createEffect) { foreach (Match match in matches) { if (!match.Success || OverlapsConsumedRange(match, consumedRanges)) { continue; } consumedRanges.Add((match.Index, match.Index + match.Length)); var effect = createEffect(match); if (effect is not null) { matchedEffects.Add((match.Index, effect)); } } } private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) => consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length); private static int ParseSignedInteger(string value) => int.Parse(value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("H", string.Empty, StringComparison.OrdinalIgnoreCase)); private static int? BuildModifier(Match match) { if (!int.TryParse(match.Groups["value"].Value, out var absoluteValue)) { return null; } return string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal) ? -absoluteValue : absoluteValue; } private static string NormalizeToken(string value) => CriticalCellParserSupport.CollapseWhitespace(value) .Replace(" +", "+", StringComparison.Ordinal) .Replace("( ", "(", StringComparison.Ordinal) .Replace(" )", ")", StringComparison.Ordinal); private static string NormalizePowerPointExpression(string value) { var normalized = CriticalCellParserSupport.CollapseWhitespace(value) .Replace(" ", string.Empty, StringComparison.Ordinal); if (normalized.StartsWith('+')) { normalized = normalized[1..]; } if (normalized.StartsWith('(') && normalized.EndsWith(')') && normalized.Length > 2) { normalized = normalized[1..^1]; } return normalized; } }