Support signed power point affixes

This commit is contained in:
2026-03-15 13:30:16 +01:00
parent 1c03ca4586
commit cc6df978ef
6 changed files with 158 additions and 47 deletions

View File

@@ -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

View File

@@ -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) =>

View File

@@ -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*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
private static readonly Regex PowerPointModifierRegex = new(@"(?<sign>[+-])\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);
@@ -16,7 +16,7 @@ public static class AffixEffectParser
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);
private static readonly Regex QuickPowerPointRegex = new(@"^(?<sign>[+-])(?<expression>.+)pp$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
public static IReadOnlyList<ParsedCriticalEffect> 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;
}
}

View File

@@ -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}";
}
}

View File

@@ -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<string, string>(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<string, string>(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()
{

View File

@@ -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<CriticalTableParseResult> LoadParseResultAsync(CriticalImportManifestEntry entry)