From cc6df978ef210ea14c1e1ebb348d2f895a7dce8a Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 15 Mar 2026 13:30:16 +0100 Subject: [PATCH] Support signed power point affixes --- .../Components/Shared/AffixBadgeList.razor | 3 +- .../CriticalQuickNotationFormatter.cs | 24 ++---- .../AffixEffectParser.cs | 45 +++++------ .../PowerPointModifierNotation.cs | 49 ++++++++++++ .../CriticalCellReparseIntegrationTests.cs | 80 ++++++++++++++++++- ...dardCriticalTableParserIntegrationTests.cs | 4 +- 6 files changed, 158 insertions(+), 47 deletions(-) create mode 100644 src/RolemasterDb.CriticalParsing/PowerPointModifierNotation.cs diff --git a/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor b/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor index a10cffd..add5679 100644 --- a/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor +++ b/src/RolemasterDb.App/Components/Shared/AffixBadgeList.razor @@ -3,6 +3,7 @@ @using System.Diagnostics.CodeAnalysis @using RolemasterDb.App.Domain @using RolemasterDb.App.Features +@using PowerPointModifierNotation = RolemasterDb.CriticalParsing.PowerPointModifierNotation @if (EffectiveEffects.Count > 0) { @@ -61,7 +62,7 @@ CriticalEffectCodes.DirectHits => $"+{effect.ValueInteger?.ToString() ?? string.Empty}", CriticalEffectCodes.FoePenalty or CriticalEffectCodes.AttackerBonusNextRound => effect.Modifier?.ToString() ?? string.Empty, - CriticalEffectCodes.PowerPointModifier => effect.ValueExpression ?? string.Empty, + CriticalEffectCodes.PowerPointModifier => PowerPointModifierNotation.FormatSignedExpression(effect.ValueExpression) ?? string.Empty, _ => effect.ValueInteger?.ToString() ?? effect.Modifier?.ToString() ?? effect.ValueExpression diff --git a/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs b/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs index bb9b43a..0fe3794 100644 --- a/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs +++ b/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs @@ -1,4 +1,5 @@ using System.Text.RegularExpressions; +using RolemasterDb.CriticalParsing; namespace RolemasterDb.App.Features; @@ -72,25 +73,10 @@ public static class CriticalQuickNotationFormatter private static string? FormatPowerPointModifier(string? valueExpression, string? sourceText) { - var expression = CollapseWhitespace(valueExpression); - if (string.IsNullOrWhiteSpace(expression)) - { - return sourceText; - } - - expression = expression.Trim(); - if (expression.StartsWith('+')) - { - expression = expression[1..]; - } - - if (expression.StartsWith('(') && expression.EndsWith(')') && expression.Length > 2) - { - expression = expression[1..^1].Trim(); - } - - expression = Regex.Replace(expression, @"\s+", string.Empty); - return $"+{expression}pp"; + var expression = PowerPointModifierNotation.FormatSignedExpression(valueExpression); + return string.IsNullOrWhiteSpace(expression) + ? sourceText + : $"{expression}pp"; } private static string CollapseWhitespace(string? value) => diff --git a/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs index 13ee31c..7bf0e08 100644 --- a/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs +++ b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs @@ -7,7 +7,7 @@ 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 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); @@ -16,7 +16,7 @@ public static class AffixEffectParser 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); + private static readonly Regex QuickPowerPointRegex = new(@"^(?[+-])(?.+)pp$", RegexOptions.Compiled | RegexOptions.IgnoreCase); public static IReadOnlyList Parse(string? rawAffixText, AffixLegend affixLegend) { @@ -101,7 +101,9 @@ public static class AffixEffectParser CriticalEffectCodes.PowerPointModifier, FoeTarget, null, - CriticalCellParserSupport.CollapseWhitespace(match.Groups["expression"].Value), + PowerPointModifierNotation.NormalizeExpression( + match.Groups["expression"].Value, + match.Groups["sign"].Value[0]), null, null, null, @@ -161,6 +163,13 @@ public static class AffixEffectParser var symbolClusterRegex = CreateSymbolClusterRegex(affixLegend.EffectSymbols); if (symbolClusterRegex is null) { + if (matchedEffects.Count > 0) + { + effects.AddRange(matchedEffects + .OrderBy(item => item.Index) + .Select(item => item.Effect)); + } + return; } @@ -323,18 +332,24 @@ public static class AffixEffectParser } if (TryMatchSingle(QuickPowerPointRegex, token, match => - new ParsedCriticalEffect( + { + var normalizedExpression = PowerPointModifierNotation.NormalizeExpression( + match.Groups["expression"].Value, + match.Groups["sign"].Value[0])!; + + return new ParsedCriticalEffect( CriticalEffectCodes.PowerPointModifier, FoeTarget, null, - NormalizePowerPointExpression(match.Groups["expression"].Value), + normalizedExpression, null, null, null, null, false, "quick", - $"+{NormalizePowerPointExpression(match.Groups["expression"].Value)}pp"), out effects, out canonicalToken)) + $"{normalizedExpression}pp"); + }, out effects, out canonicalToken)) { return true; } @@ -477,22 +492,4 @@ public static class AffixEffectParser .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; - } } diff --git a/src/RolemasterDb.CriticalParsing/PowerPointModifierNotation.cs b/src/RolemasterDb.CriticalParsing/PowerPointModifierNotation.cs new file mode 100644 index 0000000..db66f16 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/PowerPointModifierNotation.cs @@ -0,0 +1,49 @@ +namespace RolemasterDb.CriticalParsing; + +public static class PowerPointModifierNotation +{ + public static string? NormalizeExpression(string? expression, char? forcedSign = null) + { + if (string.IsNullOrWhiteSpace(expression)) + { + return null; + } + + var normalized = CriticalCellParserSupport.CollapseWhitespace(expression) + .Replace(" ", string.Empty, StringComparison.Ordinal); + + if (normalized.StartsWith('(') && normalized.EndsWith(')') && normalized.Length > 2) + { + normalized = normalized[1..^1]; + } + + char? sign = forcedSign; + if (normalized.Length > 0 && (normalized[0] == '+' || normalized[0] == '-')) + { + sign ??= normalized[0]; + normalized = normalized[1..]; + } + + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + return sign.HasValue + ? $"{sign}{normalized}" + : normalized; + } + + public static string? FormatSignedExpression(string? expression) + { + var normalized = NormalizeExpression(expression); + if (string.IsNullOrWhiteSpace(normalized)) + { + return null; + } + + return normalized[0] is '+' or '-' + ? normalized + : $"+{normalized}"; + } +} diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs index f755f27..679f1ee 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs @@ -38,7 +38,7 @@ public sealed class CriticalCellReparseIntegrationTests Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.BleedPerRound && effect.PerRound == 1); Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.FoePenalty && effect.Modifier == -20); Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.AttackerBonusNextRound && effect.Modifier == 20); - Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-3"); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "+2d10-3"); Assert.Single(content.Branches); Assert.Equal("glancing blow", content.Branches[0].DescriptionText); Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 5); @@ -46,6 +46,84 @@ public sealed class CriticalCellReparseIntegrationTests Assert.Empty(content.ValidationErrors); } + [Fact] + public void Shared_quick_notation_parser_supports_negative_power_point_shorthand() + { + var legend = new AffixLegend( + new Dictionary(StringComparer.Ordinal), + ["P"], + supportsFoePenalty: false, + supportsAttackerBonus: false, + supportsPowerPointModifier: true); + + var content = CriticalQuickNotationParser.Parse( + "Void drains the foe.\r\n-2d10+4pp", + legend); + + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "-2d10+4"); + Assert.Equal("-2d10+4pp", content.RawAffixText); + Assert.Empty(content.ValidationErrors); + } + + [Fact] + public void Shared_symbol_affix_parser_supports_negative_power_point_notation() + { + var legend = new AffixLegend( + new Dictionary(StringComparer.Ordinal), + ["P"], + supportsFoePenalty: false, + supportsAttackerBonus: false, + supportsPowerPointModifier: true); + + var effects = AffixEffectParser.Parse("-(2d10+4)P", legend); + + Assert.Contains(effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "-2d10+4"); + } + + [Fact] + public void Quick_notation_formatter_keeps_power_point_modifier_signs() + { + var formatted = CriticalQuickNotationFormatter.Format( + "Arcane force twists around the foe.", + [ + new CriticalEffectEditorItem( + AppCriticalEffectCodes.PowerPointModifier, + "foe", + null, + null, + "2d10-3", + null, + null, + null, + null, + false, + "import", + null, + null, + false), + new CriticalEffectEditorItem( + AppCriticalEffectCodes.PowerPointModifier, + "foe", + null, + null, + "-1d10+4", + null, + null, + null, + null, + false, + "import", + null, + null, + false) + ], + []); + + Assert.Equal( + "Arcane force twists around the foe.\n+2d10-3pp, -1d10+4pp", + formatted.Replace("\r\n", "\n", StringComparison.Ordinal)); + } + [Fact] public void Shared_cell_parser_extracts_base_effects_and_condition_branches() { diff --git a/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs index 8cdc803..d8c29dc 100644 --- a/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs @@ -546,7 +546,7 @@ public sealed class StandardCriticalTableParserIntegrationTests effect => { Assert.Equal(CriticalEffectCodes.PowerPointModifier, effect.EffectCode); - Assert.Equal("2d10-18", effect.ValueExpression); + Assert.Equal("+2d10-18", effect.ValueExpression); }); } @@ -627,7 +627,7 @@ public sealed class StandardCriticalTableParserIntegrationTests Assert.NotNull(manaResponse); Assert.Contains(slashResponse!.Branches, branch => branch.ConditionKey == "with_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.MustParryRounds)); Assert.Contains(slashResponse.Branches, branch => branch.ConditionKey == "without_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.BleedPerRound)); - Assert.Contains(manaResponse!.Effects, effect => effect.EffectCode == CriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-18"); + Assert.Contains(manaResponse!.Effects, effect => effect.EffectCode == CriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "+2d10-18"); } private static async Task LoadParseResultAsync(CriticalImportManifestEntry entry)