diff --git a/docs/player_gm_ux_redesign_plan.md b/docs/player_gm_ux_redesign_plan.md index 1d10640..7e4fbf1 100644 --- a/docs/player_gm_ux_redesign_plan.md +++ b/docs/player_gm_ux_redesign_plan.md @@ -574,6 +574,7 @@ The following groundwork is already implemented in the web app as of March 15, 2 - critical cells support re-parse from shared parser logic - advanced diagnostics have been separated from the primary editing flow - advanced curation now includes a generated-versus-current comparison view for re-parse review +- critical cells now open with quick parse input derived from the imported result instead of requiring OCR-era symbol typing These changes are real and complete, but they are no longer the active roadmap because the detailed acceptance checklist still has unfinished items. @@ -680,6 +681,18 @@ Acceptance criteria: ### Phase 6: Friendly quick parse input +Status: + +- implemented in the web app on March 15, 2026 + +Implemented model: + +- the editor now treats quick parse input as the primary generation surface +- existing imported results are translated into quick parse notation on the fly when the editor opens +- the popup shows a supported shorthand legend directly under the quick input field +- generation supports all currently available effect types, including power-point modifiers such as `+2d10-3pp` +- OCR glyph tokens remain accepted as compatibility aliases, but they are no longer the primary editing language + Scope: - add a dedicated `Quick parse input` field distinct from OCR provenance @@ -752,4 +765,4 @@ Mitigation: ## Recommended Next Step -Implement Phase 6 next. The current structural gap is the editor input model itself: curators still need a friendly quick parse grammar that treats the first line as prose, all following lines as affix or conditional-affix lines, and comma-separated aliases as the normal typing language instead of OCR-era symbols. +Implement Phase 7 next. The remaining parser gap is trustworthiness under imperfect input: unknown tokens still need stronger surfaced review, and no-silent-loss behavior should be enforced comprehensively across compare output and regression coverage. diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor index 27c222e..ec210f2 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -71,16 +71,26 @@
-

Raw Text

-

Update the source text, then correct the visible result rows below.

+

Quick Parse Input

+

First line is the result prose. Later lines are base affixes or condition: ... lines with comma-separated shorthand.

- - + + +
+

Example: Foe brings his guard up, frightened by your display. then +5, 1mp or w/o shield: glancing blow, +15, 3s, 3np.

+
+ @foreach (var entry in QuickParseLegendEntries) + { +
+ @entry.Token + @entry.Meaning +
+ }
@if (Model.ValidationMessages.Count > 0) { @@ -88,7 +98,7 @@ } @if (HasComparisonDifferences(Model, ComparisonBaseline)) { -

Fresh parsing differs from the current edited card. Review Generated Compare before saving if you want to keep the overrides.

+

Fresh generation differs from the current edited card. Review Generated Compare before saving if you want to keep the overrides.

}
@@ -203,7 +213,7 @@
Generated Compare -

Compare the current edited card against the fresh parser output from the raw text.

+

Compare the current edited card against the fresh generated result from the quick parse input.

@@ -222,8 +232,8 @@ Effects="@BuildPreviewEffects(GetComparisonSourceModel(Model, ComparisonBaseline))" Branches="@BuildPreviewBranches(GetComparisonSourceModel(Model, ComparisonBaseline))" />
+
+
OCR Source
+
@(string.IsNullOrWhiteSpace(Model.RawCellText) ? "—" : Model.RawCellText)
+
Parse Status
@Model.ParseStatus
@@ -371,6 +385,18 @@ { WriteIndented = true }; + private static readonly IReadOnlyList<(string Token, string Meaning)> QuickParseLegendEntries = + [ + ("+15", "Direct hits"), + ("3s", "Stunned 3 rounds"), + ("1mp", "Must parry 1 round"), + ("3np", "No parry 3 rounds"), + ("1hpr", "Bleed 1 hit per round"), + ("-20", "Foe penalty"), + ("+20b", "Attacker bonus next round"), + ("+2d10-3pp", "Power-point modifier"), + ("w/o shield: +15, 3s", "Conditional line") + ]; private IJSObjectReference? jsModule; private bool isBackdropPointerDown; @@ -610,8 +636,8 @@ private static string GetParserNoteSummary(int noteCount) => noteCount == 1 - ? "1 parser note is available under Advanced Diagnostics." - : $"{noteCount} parser notes are available under Advanced Diagnostics."; + ? "1 parser note is available under Advanced Review & Diagnostics." + : $"{noteCount} parser notes are available under Advanced Review & Diagnostics."; private static string FormatJson(string json) { diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs index d908048..ab02124 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs @@ -16,6 +16,7 @@ public sealed class CriticalCellEditorModel public string ColumnLabel { get; set; } = string.Empty; public string ColumnRole { get; set; } = string.Empty; public string RawCellText { get; set; } = string.Empty; + public string QuickParseInput { get; set; } = string.Empty; public string DescriptionText { get; set; } = string.Empty; public string? RawAffixText { get; set; } public string ParseStatus { get; set; } = string.Empty; @@ -43,6 +44,7 @@ public sealed class CriticalCellEditorModel ColumnLabel = response.ColumnLabel, ColumnRole = response.ColumnRole, RawCellText = response.RawCellText, + QuickParseInput = response.QuickParseInput, DescriptionText = response.DescriptionText, RawAffixText = response.RawAffixText, ParseStatus = response.ParseStatus, @@ -61,6 +63,7 @@ public sealed class CriticalCellEditorModel { var request = new CriticalCellUpdateRequest( RawCellText, + QuickParseInput, DescriptionText, RawAffixText, ResolveParseStatus(Effects, Branches), @@ -99,6 +102,7 @@ public sealed class CriticalCellEditorModel ColumnLabel = ColumnLabel, ColumnRole = ColumnRole, RawCellText = RawCellText, + QuickParseInput = QuickParseInput, DescriptionText = DescriptionText, RawAffixText = RawAffixText, ParseStatus = ParseStatus, diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs index f162e39..93de1a1 100644 --- a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs +++ b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs @@ -14,6 +14,7 @@ public sealed record CriticalCellEditorResponse( string ColumnLabel, string ColumnRole, string RawCellText, + string QuickParseInput, string DescriptionText, string? RawAffixText, string ParseStatus, diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs b/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs index 146d0ae..b840db2 100644 --- a/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs +++ b/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs @@ -9,7 +9,8 @@ public sealed record CriticalCellEditorSnapshot( bool AreEffectsOverridden, bool AreBranchesOverridden, IReadOnlyList Effects, - IReadOnlyList Branches) + IReadOnlyList Branches, + string? QuickParseInput) { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); @@ -21,7 +22,8 @@ public sealed record CriticalCellEditorSnapshot( request.AreEffectsOverridden, request.AreBranchesOverridden, request.Effects, - request.Branches); + request.Branches, + request.QuickParseInput); public string ToJson() => JsonSerializer.Serialize(this, JsonOptions); diff --git a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs index cc05fd9..bedf0bf 100644 --- a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs +++ b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs @@ -4,6 +4,7 @@ namespace RolemasterDb.App.Features; public sealed record CriticalCellUpdateRequest( string RawCellText, + string QuickParseInput, string DescriptionText, string? RawAffixText, string ParseStatus, diff --git a/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs b/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs new file mode 100644 index 0000000..bb9b43a --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs @@ -0,0 +1,100 @@ +using System.Text.RegularExpressions; + +namespace RolemasterDb.App.Features; + +public static class CriticalQuickNotationFormatter +{ + public static string Format( + string descriptionText, + IReadOnlyList effects, + IReadOnlyList branches) + { + var lines = new List + { + CollapseWhitespace(descriptionText) + }; + + var baseAffixLine = FormatEffects(effects); + if (!string.IsNullOrWhiteSpace(baseAffixLine)) + { + lines.Add(baseAffixLine); + } + + foreach (var branch in branches.OrderBy(item => item.SortOrder)) + { + var payloadParts = new List(); + var branchDescription = CollapseWhitespace(branch.DescriptionText); + if (!string.IsNullOrWhiteSpace(branchDescription)) + { + payloadParts.Add(branchDescription); + } + + var branchAffixLine = FormatEffects(branch.Effects); + if (!string.IsNullOrWhiteSpace(branchAffixLine)) + { + payloadParts.Add(branchAffixLine); + } + + if (payloadParts.Count == 0) + { + continue; + } + + lines.Add($"{CollapseWhitespace(branch.ConditionText)}: {string.Join(", ", payloadParts)}"); + } + + return string.Join(Environment.NewLine, lines.Where(line => !string.IsNullOrWhiteSpace(line))); + } + + private static string? FormatEffects(IReadOnlyList effects) + { + var tokens = effects + .Select(FormatEffect) + .Where(token => !string.IsNullOrWhiteSpace(token)) + .ToList(); + + return tokens.Count == 0 ? null : string.Join(", ", tokens); + } + + private static string? FormatEffect(CriticalEffectEditorItem effect) => + effect.EffectCode switch + { + Domain.CriticalEffectCodes.DirectHits => effect.ValueInteger is int hits ? $"+{hits}" : effect.SourceText, + Domain.CriticalEffectCodes.StunnedRounds => effect.DurationRounds is int rounds ? $"{rounds}s" : effect.SourceText, + Domain.CriticalEffectCodes.MustParryRounds => effect.DurationRounds is int rounds ? $"{rounds}mp" : effect.SourceText, + Domain.CriticalEffectCodes.NoParryRounds => effect.DurationRounds is int rounds ? $"{rounds}np" : effect.SourceText, + Domain.CriticalEffectCodes.BleedPerRound => effect.PerRound is int bleed ? $"{bleed}hpr" : effect.SourceText, + Domain.CriticalEffectCodes.FoePenalty => effect.Modifier is int penalty ? penalty.ToString(System.Globalization.CultureInfo.InvariantCulture) : effect.SourceText, + Domain.CriticalEffectCodes.AttackerBonusNextRound => effect.Modifier is int bonus ? $"+{bonus}b" : effect.SourceText, + Domain.CriticalEffectCodes.PowerPointModifier => FormatPowerPointModifier(effect.ValueExpression, effect.SourceText), + _ => effect.SourceText ?? effect.EffectCode + }; + + 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"; + } + + private static string CollapseWhitespace(string? value) => + string.IsNullOrWhiteSpace(value) + ? string.Empty + : Regex.Replace(value.Trim(), @"\s+", " "); +} diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index 49a4066..77b5cb3 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -296,7 +296,7 @@ public sealed class LookupService(IDbContextFactory dbConte } var currentState = CreateCurrentEditorState(result); - var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.RawCellText, cancellationToken); + var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken); return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent)); } @@ -325,8 +325,7 @@ public sealed class LookupService(IDbContextFactory dbConte return null; } - var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken); - var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend); + var content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken); var generatedState = CreateGeneratedEditorState(content); var mergedState = MergeGeneratedState(currentState, generatedState); return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content)); @@ -370,7 +369,7 @@ public sealed class LookupService(IDbContextFactory dbConte await dbContext.SaveChangesAsync(cancellationToken); - var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.RawCellText, cancellationToken); + var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken); return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent)); } @@ -452,6 +451,7 @@ public sealed class LookupService(IDbContextFactory dbConte result.CriticalColumn.Label, result.CriticalColumn.Role, state.RawCellText, + state.QuickParseInput, state.DescriptionText, state.RawAffixText, state.ParseStatus, @@ -575,8 +575,13 @@ public sealed class LookupService(IDbContextFactory dbConte { if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null) { + var snapshotQuickParseInput = string.IsNullOrWhiteSpace(snapshot.QuickParseInput) + ? CriticalQuickNotationFormatter.Format(result.DescriptionText, snapshot.Effects, snapshot.Branches) + : snapshot.QuickParseInput; + return new CriticalCellUpdateRequest( result.RawCellText, + snapshotQuickParseInput, result.DescriptionText, result.RawAffixText, result.ParseStatus, @@ -600,6 +605,7 @@ public sealed class LookupService(IDbContextFactory dbConte return new CriticalCellUpdateRequest( result.RawCellText, + CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches), result.DescriptionText, result.RawAffixText, result.ParseStatus, @@ -623,17 +629,18 @@ public sealed class LookupService(IDbContextFactory dbConte .ToList(); return new CriticalCellUpdateRequest( - content.RawCellText, - content.DescriptionText, - content.RawAffixText, - ResolveParseStatus(content.Effects, content.Branches), - SerializeParsedEffects(content.Effects), - false, - false, - false, - false, - effects, - branches); + RawCellText: string.Empty, + QuickParseInput: content.RawCellText, + DescriptionText: content.DescriptionText, + RawAffixText: content.RawAffixText, + ParseStatus: ResolveParseStatus(content.Effects, content.Branches), + ParsedJson: SerializeParsedEffects(content.Effects), + IsDescriptionOverridden: false, + IsRawAffixTextOverridden: false, + AreEffectsOverridden: false, + AreBranchesOverridden: false, + Effects: effects, + Branches: branches); } private static CriticalCellUpdateRequest MergeGeneratedState( @@ -641,6 +648,7 @@ public sealed class LookupService(IDbContextFactory dbConte CriticalCellUpdateRequest generatedState) => new( currentState.RawCellText, + currentState.QuickParseInput, currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText, currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText, generatedState.ParseStatus, @@ -922,11 +930,11 @@ public sealed class LookupService(IDbContextFactory dbConte private static async Task ParseCriticalCellContentAsync( RolemasterDbContext dbContext, int tableId, - string rawCellText, + string quickParseInput, CancellationToken cancellationToken) { var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken); - return SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend); + return SharedParsing.CriticalQuickNotationParser.Parse(quickParseInput, affixLegend); } private static bool IsLegendSymbolEffectCode(string effectCode) => diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 56c1b9b..cd53f6f 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -843,6 +843,26 @@ textarea { margin: 0; } +.critical-editor-quick-legend { + display: grid; + gap: 0.55rem; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); +} + +.critical-editor-quick-legend-item { + display: grid; + gap: 0.2rem; + padding: 0.65rem 0.75rem; + border-radius: 12px; + background: rgba(255, 255, 255, 0.72); + border: 1px solid rgba(127, 96, 55, 0.1); +} + +.critical-editor-quick-legend-item code { + font-size: 0.9rem; + color: #5b4327; +} + .critical-editor-compare-grid { display: grid; gap: 0.85rem; diff --git a/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs index a1ec7b0..13ee31c 100644 --- a/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs +++ b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs @@ -9,6 +9,14 @@ public static class AffixEffectParser 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) { @@ -27,6 +35,31 @@ public static class AffixEffectParser 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 "—") @@ -163,6 +196,174 @@ public static class AffixEffectParser } } + 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 { @@ -276,4 +477,22 @@ 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/CriticalQuickNotationParser.cs b/src/RolemasterDb.CriticalParsing/CriticalQuickNotationParser.cs new file mode 100644 index 0000000..cc10b00 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/CriticalQuickNotationParser.cs @@ -0,0 +1,131 @@ +namespace RolemasterDb.CriticalParsing; + +public static class CriticalQuickNotationParser +{ + public static CriticalCellParseContent Parse(string quickParseInput, AffixLegend affixLegend) + { + var lines = quickParseInput + .Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(CriticalCellParserSupport.CollapseWhitespace) + .Where(line => !string.IsNullOrWhiteSpace(line)) + .ToList(); + + if (lines.Count == 0) + { + return new CriticalCellParseContent([], string.Empty, string.Empty, null, [], [], []); + } + + var validationErrors = new List(); + var descriptionText = lines[0]; + var rawAffixLines = new List(); + var effects = new List(); + var branches = new List(); + + foreach (var line in lines.Skip(1)) + { + if (line.Contains(':', StringComparison.Ordinal)) + { + branches.Add(ParseBranch(line, branches.Count + 1, affixLegend, validationErrors)); + continue; + } + + var normalizedAffixLine = ParseBaseAffixLine(line, affixLegend, validationErrors, effects); + if (!string.IsNullOrWhiteSpace(normalizedAffixLine)) + { + rawAffixLines.Add(normalizedAffixLine); + } + } + + return new CriticalCellParseContent( + lines, + string.Join(Environment.NewLine, lines), + descriptionText, + rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines), + effects, + branches, + validationErrors); + } + + private static string? ParseBaseAffixLine( + string line, + AffixLegend affixLegend, + List validationErrors, + List effects) + { + var tokens = SplitPayload(line); + var normalizedTokens = new List(); + + foreach (var token in tokens) + { + if (!AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken)) + { + validationErrors.Add($"Base content has unrecognized affix token '{token}'."); + continue; + } + + normalizedTokens.Add(normalizedToken); + effects.AddRange(parsedEffects); + } + + return normalizedTokens.Count == 0 ? null : string.Join(", ", normalizedTokens); + } + + private static ParsedCriticalBranch ParseBranch( + string line, + int sortOrder, + AffixLegend affixLegend, + List validationErrors) + { + var separatorIndex = line.IndexOf(':', StringComparison.Ordinal); + var conditionText = CriticalCellParserSupport.CollapseWhitespace(line[..separatorIndex]); + var payload = line[(separatorIndex + 1)..]; + var tokens = SplitPayload(payload); + var descriptionTokens = new List(); + var normalizedAffixTokens = new List(); + var effects = new List(); + var seenAffix = false; + + foreach (var token in tokens) + { + if (AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken)) + { + seenAffix = true; + normalizedAffixTokens.Add(normalizedToken); + effects.AddRange(parsedEffects); + continue; + } + + if (seenAffix) + { + validationErrors.Add($"Branch '{conditionText}' has unrecognized affix token '{token}'."); + continue; + } + + descriptionTokens.Add(token); + } + + var descriptionText = descriptionTokens.Count == 0 + ? string.Empty + : CriticalCellParserSupport.CollapseWhitespace(string.Join(", ", descriptionTokens)); + var rawAffixText = normalizedAffixTokens.Count == 0 + ? null + : string.Join(", ", normalizedAffixTokens); + + return new ParsedCriticalBranch( + "conditional", + CriticalCellParserSupport.NormalizeConditionKey(conditionText), + conditionText, + line, + descriptionText, + rawAffixText, + effects, + sortOrder); + } + + private static IReadOnlyList SplitPayload(string payload) => + payload + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(CriticalCellParserSupport.CollapseWhitespace) + .Where(token => !string.IsNullOrWhiteSpace(token)) + .ToList(); +} diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs index 46fbdcf..f755f27 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs @@ -10,6 +10,42 @@ namespace RolemasterDb.ImportTool.Tests; public sealed class CriticalCellReparseIntegrationTests { + [Fact] + public void Shared_quick_notation_parser_supports_all_available_affix_shorthand() + { + var legend = new AffixLegend( + new Dictionary(StringComparer.Ordinal) + { + ["π"] = AppCriticalEffectCodes.MustParryRounds, + ["∑"] = AppCriticalEffectCodes.StunnedRounds, + ["∏"] = AppCriticalEffectCodes.NoParryRounds, + ["∫"] = AppCriticalEffectCodes.BleedPerRound + }, + ["P"], + supportsFoePenalty: true, + supportsAttackerBonus: true, + supportsPowerPointModifier: true); + + var content = CriticalQuickNotationParser.Parse( + "Mana tears through the target.\r\n+15, 3s, 1mp, 2np, 1hpr, -20, +20b, +2d10-3pp\r\nw/o shield: glancing blow, +5, π", + legend); + + Assert.Equal("Mana tears through the target.", content.DescriptionText); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 15); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 3); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.NoParryRounds && effect.DurationRounds == 2); + 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.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); + Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); + Assert.Empty(content.ValidationErrors); + } + [Fact] public void Shared_cell_parser_extracts_base_effects_and_condition_branches() { @@ -49,7 +85,10 @@ public sealed class CriticalCellReparseIntegrationTests var response = await lookupService.ReparseCriticalCellAsync( "slash", resultId, - CreateEditorRequest("Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫", "Old description")); + CreateEditorRequest( + "Imported OCR source", + "Old description", + quickParseInput: "Strike to thigh.\n+10\nWith greaves: glancing blow., 2∫")); Assert.NotNull(response); Assert.Equal("Strike to thigh.", response!.DescriptionText); @@ -74,8 +113,9 @@ public sealed class CriticalCellReparseIntegrationTests "slash", resultId, CreateEditorRequest( - "Strike to thigh.\n+10H", + "Imported OCR source", "Curated thigh strike.", + quickParseInput: "Strike to thigh.\n+10", isDescriptionOverridden: true, effects: [ @@ -117,8 +157,9 @@ public sealed class CriticalCellReparseIntegrationTests "slash", resultId, CreateEditorRequest( - "Strike to thigh.\n+10H", + "Imported OCR source", "Strike to thigh.", + quickParseInput: "Strike to thigh.\n+10", areBranchesOverridden: true, branches: [ @@ -153,8 +194,9 @@ public sealed class CriticalCellReparseIntegrationTests var lookupService = new LookupService(CreateDbContextFactory(databasePath)); var resultId = await GetSlashResultIdAsync(databasePath); var request = CreateEditorRequest( - "Strike to thigh.\n+10H", + "Imported OCR source", "Curated thigh strike.", + quickParseInput: "Strike to thigh.\n+10", isDescriptionOverridden: true, effects: [ @@ -187,9 +229,36 @@ public sealed class CriticalCellReparseIntegrationTests Assert.Contains("\"version\":1", loadResponse.ParsedJson, StringComparison.Ordinal); } + [Fact] + public async Task Lookup_service_formats_existing_results_into_quick_notation_and_keeps_arcane_aether_b16_must_parry() + { + var databasePath = CreateTemporaryDatabaseCopy(); + var lookupService = new LookupService(CreateDbContextFactory(databasePath)); + var resultId = await GetResultIdAsync(databasePath, "arcane-aether", "B", "16-20"); + + var response = await lookupService.GetCriticalCellEditorAsync("arcane-aether", resultId); + + Assert.NotNull(response); + Assert.Equal( + "Foe brings his guard up, frightened by your display.\n+5, 1mp", + response!.QuickParseInput.Replace("\r\n", "\n", StringComparison.Ordinal)); + Assert.NotNull(response.GeneratedState); + Assert.Contains(response.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); + + var reparsed = await lookupService.ReparseCriticalCellAsync( + "arcane-aether", + resultId, + CreateEditorRequest(response.RawCellText, response.DescriptionText, response.QuickParseInput)); + + Assert.NotNull(reparsed); + Assert.NotNull(reparsed!.GeneratedState); + Assert.Contains(reparsed.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1); + } + private static CriticalCellUpdateRequest CreateEditorRequest( string rawCellText, string descriptionText, + string? quickParseInput = null, bool isDescriptionOverridden = false, bool isRawAffixTextOverridden = false, bool areEffectsOverridden = false, @@ -199,6 +268,7 @@ public sealed class CriticalCellReparseIntegrationTests IReadOnlyList? branches = null) => new( rawCellText, + quickParseInput ?? rawCellText, descriptionText, rawAffixText, "partial", @@ -274,6 +344,18 @@ public sealed class CriticalCellReparseIntegrationTests .SingleAsync(); } + private static async Task GetResultIdAsync(string databasePath, string slug, string columnLabel, string rollBandLabel) + { + await using var verifyContext = CreateDbContext(databasePath); + return await verifyContext.CriticalResults + .Where(item => + item.CriticalTable.Slug == slug && + item.CriticalColumn.Label == columnLabel && + item.CriticalRollBand.Label == rollBandLabel) + .Select(item => item.Id) + .SingleAsync(); + } + private static RolemasterDbContext CreateDbContext(string databasePath) { var options = new DbContextOptionsBuilder() @@ -291,4 +373,28 @@ public sealed class CriticalCellReparseIntegrationTests return new TestRolemasterDbContextFactory(options); } + + private static string CreateTemporaryDatabaseCopy() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-app-{Guid.NewGuid():N}.db"); + File.Copy(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.App", "rolemaster.db"), databasePath, true); + return databasePath; + } + + private static string GetRepositoryRoot() + { + var probe = new DirectoryInfo(AppContext.BaseDirectory); + + while (probe is not null) + { + if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) + { + return probe.FullName; + } + + probe = probe.Parent; + } + + throw new InvalidOperationException("Could not find the repository root for integration tests."); + } }