499 lines
16 KiB
C#
499 lines
16 KiB
C#
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;
|
||
}
|
||
}
|