Files
RolemasterDB/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs

499 lines
16 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
private static readonly Regex ModifierRegex = new(@"\((?<noise>[^0-9+\-)]*)(?<sign>[+-])\s*(?<value>\d+)\)", RegexOptions.Compiled);
private static readonly Regex QuickDirectHitsRegex = new(@"^\+(?<value>\d+)(?:H)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickStunnedRegex = new(@"^(?<value>\d+)s$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickMustParryRegex = new(@"^(?<value>\d+)mp$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickNoParryRegex = new(@"^(?<value>\d+)np$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickBleedRegex = new(@"^(?<value>\d+)hpr$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickFoePenaltyRegex = new(@"^-(?<value>\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickAttackerBonusRegex = new(@"^\+(?<value>\d+)b$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static readonly Regex QuickPowerPointRegex = new(@"^\+(?<expression>.+)pp$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
{
if (string.IsNullOrWhiteSpace(rawAffixText))
{
return [];
}
var effects = new List<ParsedCriticalEffect>();
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<ParsedCriticalEffect> 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<ParsedCriticalEffect> 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<ParsedCriticalEffect> 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<Match, ParsedCriticalEffect> createEffect,
out IReadOnlyList<ParsedCriticalEffect> 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<string> symbols)
{
if (symbols.Count == 0)
{
return null;
}
var escapedSymbols = string.Concat(symbols.Select(Regex.Escape));
return new Regex(
$@"(?<![A-Za-z0-9])(?:(?<count>\d+)\s*)?(?<symbols>[{escapedSymbols}]+)",
RegexOptions.Compiled);
}
private static void AddMatches(
MatchCollection matches,
List<(int Index, ParsedCriticalEffect Effect)> matchedEffects,
List<(int Start, int End)> consumedRanges,
Func<Match, ParsedCriticalEffect?> 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;
}
}