diff --git a/RolemasterDB.slnx b/RolemasterDB.slnx index 04b786c..a2e0211 100644 --- a/RolemasterDB.slnx +++ b/RolemasterDB.slnx @@ -1,6 +1,7 @@ + diff --git a/docs/player_gm_ux_redesign_plan.md b/docs/player_gm_ux_redesign_plan.md index d906041..7d883f7 100644 --- a/docs/player_gm_ux_redesign_plan.md +++ b/docs/player_gm_ux_redesign_plan.md @@ -550,6 +550,10 @@ Acceptance criteria: ### Phase 4: Shared parsing assembly +Status: + +- implemented in the web app on March 15, 2026 + Scope: - extract shared parser/normalizer logic from the import tool diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index a58e80b..b773958 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -60,12 +60,22 @@ "rawAffixText": "+8H - 2S", "parseStatus": "verified", "parsedJson": "{}", + "validationMessages": [], "effects": [], "branches": [] }

Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.

+
+

Cell re-parse

+

POST /api/tables/critical/{slug}/cells/{resultId}/reparse

+
{
+  "rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside."
+}
+

Re-runs the shared single-cell parser and returns a refreshed editor payload without saving changes.

+
+

Cell editor save

PUT /api/tables/critical/{slug}/cells/{resultId}

diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index 715158b..c45e160 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -207,10 +207,13 @@ } @@ -227,8 +230,10 @@ private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0; private bool isEditorOpen; private bool isEditorLoading; + private bool isEditorReparsing; private bool isEditorSaving; private string? editorLoadError; + private string? editorReparseError; private string? editorSaveError; private int? editingResultId; private CriticalCellEditorModel? editorModel; @@ -328,9 +333,11 @@ } editorLoadError = null; + editorReparseError = null; editorSaveError = null; editorModel = null; editingResultId = resultId; + isEditorReparsing = false; isEditorSaving = false; isEditorLoading = true; isEditorOpen = true; @@ -362,14 +369,47 @@ { isEditorOpen = false; isEditorLoading = false; + isEditorReparsing = false; isEditorSaving = false; editorLoadError = null; + editorReparseError = null; editorSaveError = null; editingResultId = null; editorModel = null; await InvokeAsync(StateHasChanged); } + private async Task ReparseCellEditorAsync() + { + if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null) + { + return; + } + + isEditorReparsing = true; + editorReparseError = null; + + try + { + var response = await LookupService.ReparseCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.RawCellText); + if (response is null) + { + editorReparseError = "The selected cell could not be re-parsed."; + return; + } + + editorModel = CriticalCellEditorModel.FromResponse(response); + } + catch (Exception exception) + { + editorReparseError = exception.Message; + } + finally + { + isEditorReparsing = false; + } + } + private async Task SaveCellEditorAsync() { if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null) diff --git a/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs index 20a8f31..f1f9cfe 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using RolemasterDb.App.Features; namespace RolemasterDb.App.Components.Shared; @@ -35,11 +36,32 @@ public sealed class CriticalBranchEditorModel BranchKind, ConditionKey, ConditionText, - ConditionJson, - RawText, + "{}", + BuildRawText(), DescriptionText, RawAffixText, - ParsedJson, + SerializeParsedEffects(Effects), SortOrder, Effects.Select(effect => effect.ToItem()).ToList()); + + private string BuildRawText() + { + var condition = ConditionText.Trim(); + var description = DescriptionText.Trim(); + var firstLine = string.IsNullOrWhiteSpace(description) + ? $"{condition}:" + : $"{condition}: {description}"; + + if (string.IsNullOrWhiteSpace(RawAffixText)) + { + return firstLine; + } + + return $"{firstLine}{Environment.NewLine}{RawAffixText.Trim()}"; + } + + private static string SerializeParsedEffects(IReadOnlyList effects) => + effects.Count == 0 + ? "{}" + : JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() }); } diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor index 3ef634b..85de648 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -45,6 +45,11 @@ {
+ @if (!string.IsNullOrWhiteSpace(ReparseErrorMessage)) + { +

@ReparseErrorMessage

+ } + @if (!string.IsNullOrWhiteSpace(SaveErrorMessage)) {

@SaveErrorMessage

@@ -79,11 +84,23 @@

Raw Text

Update the source text, then adjust the visible card fields below.

+
+ @if (Model.ValidationMessages.Count > 0) + { +
+ @foreach (var message in Model.ValidationMessages) + { +

@message

+ } +
+ }
@@ -231,18 +248,27 @@ [Parameter] public bool IsLoading { get; set; } + [Parameter] + public bool IsReparsing { get; set; } + [Parameter] public bool IsSaving { get; set; } [Parameter] public string? LoadErrorMessage { get; set; } + [Parameter] + public string? ReparseErrorMessage { get; set; } + [Parameter] public string? SaveErrorMessage { get; set; } [Parameter, EditorRequired] public EventCallback OnClose { get; set; } + [Parameter, EditorRequired] + public EventCallback OnReparse { get; set; } + [Parameter, EditorRequired] public EventCallback OnSave { get; set; } diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs index b6859ba..184f852 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs @@ -1,3 +1,4 @@ +using System.Text.Json; using RolemasterDb.App.Features; namespace RolemasterDb.App.Components.Shared; @@ -19,6 +20,7 @@ public sealed class CriticalCellEditorModel public string? RawAffixText { get; set; } public string ParseStatus { get; set; } = string.Empty; public string ParsedJson { get; set; } = "{}"; + public List ValidationMessages { get; set; } = []; public List Effects { get; set; } = []; public List Branches { get; set; } = []; @@ -40,6 +42,7 @@ public sealed class CriticalCellEditorModel RawAffixText = response.RawAffixText, ParseStatus = response.ParseStatus, ParsedJson = response.ParsedJson, + ValidationMessages = response.ValidationMessages.ToList(), Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(), Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList() }; @@ -49,8 +52,8 @@ public sealed class CriticalCellEditorModel RawCellText, DescriptionText, RawAffixText, - ParseStatus, - ParsedJson, + ResolveParseStatus(Effects, Branches), + SerializeParsedEffects(Effects), Effects.Select(effect => effect.ToItem()).ToList(), Branches .OrderBy(branch => branch.SortOrder) @@ -60,4 +63,16 @@ public sealed class CriticalCellEditorModel return branch.ToItem(); }) .ToList()); + + private static string ResolveParseStatus( + IReadOnlyList effects, + IReadOnlyList branches) => + effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) + ? "partial" + : "raw"; + + private static string SerializeParsedEffects(IReadOnlyList effects) => + effects.Count == 0 + ? "{}" + : JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() }); } diff --git a/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs b/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs index da11430..7cf8da3 100644 --- a/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs +++ b/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs @@ -2,12 +2,12 @@ namespace RolemasterDb.App.Domain; public static class CriticalEffectCodes { - public const string DirectHits = "direct_hits"; - public const string MustParryRounds = "must_parry_rounds"; - public const string NoParryRounds = "no_parry_rounds"; - public const string StunnedRounds = "stunned_rounds"; - public const string BleedPerRound = "bleed_per_round"; - public const string FoePenalty = "foe_penalty"; - public const string AttackerBonusNextRound = "attacker_bonus_next_round"; - public const string PowerPointModifier = "power_point_modifier"; + public const string DirectHits = RolemasterDb.CriticalParsing.CriticalEffectCodes.DirectHits; + public const string MustParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.MustParryRounds; + public const string NoParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.NoParryRounds; + public const string StunnedRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.StunnedRounds; + public const string BleedPerRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.BleedPerRound; + public const string FoePenalty = RolemasterDb.CriticalParsing.CriticalEffectCodes.FoePenalty; + public const string AttackerBonusNextRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.AttackerBonusNextRound; + public const string PowerPointModifier = RolemasterDb.CriticalParsing.CriticalEffectCodes.PowerPointModifier; } diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs index c9c9775..598a00c 100644 --- a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs +++ b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs @@ -18,5 +18,6 @@ public sealed record CriticalCellEditorResponse( string? RawAffixText, string ParseStatus, string ParsedJson, + IReadOnlyList ValidationMessages, IReadOnlyList Effects, IReadOnlyList Branches); diff --git a/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs b/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs new file mode 100644 index 0000000..9a5f991 --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs @@ -0,0 +1,4 @@ +namespace RolemasterDb.App.Features; + +public sealed record CriticalCellReparseRequest( + string RawCellText); diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index e660692..f726e3f 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -1,9 +1,11 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Text.Json; using Microsoft.EntityFrameworkCore; using RolemasterDb.App.Data; using RolemasterDb.App.Domain; +using SharedParsing = RolemasterDb.CriticalParsing; namespace RolemasterDb.App.Features; @@ -286,6 +288,36 @@ public sealed class LookupService(IDbContextFactory dbConte return result is null ? null : CreateCellEditorResponse(result); } + public async Task ReparseCriticalCellAsync( + string slug, + int resultId, + string rawCellText, + CancellationToken cancellationToken = default) + { + await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); + + var normalizedSlug = NormalizeSlug(slug); + var result = await dbContext.CriticalResults + .AsNoTracking() + .AsSplitQuery() + .Include(item => item.CriticalTable) + .Include(item => item.CriticalColumn) + .Include(item => item.CriticalGroup) + .Include(item => item.CriticalRollBand) + .SingleOrDefaultAsync( + item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug, + cancellationToken); + + if (result is null) + { + return null; + } + + var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken); + var content = SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend); + return CreateCellEditorResponse(result, content); + } + public async Task UpdateCriticalCellAsync( string slug, int resultId, @@ -402,6 +434,7 @@ public sealed class LookupService(IDbContextFactory dbConte result.RawAffixText, result.ParseStatus, result.ParsedJson, + [], result.Effects .OrderBy(effect => effect.Id) .Select(CreateEffectEditorItem) @@ -411,6 +444,34 @@ public sealed class LookupService(IDbContextFactory dbConte .Select(CreateBranchEditorItem) .ToList()); + private static CriticalCellEditorResponse CreateCellEditorResponse( + CriticalResult result, + SharedParsing.CriticalCellParseContent content) => + new( + result.Id, + result.CriticalTable.Slug, + result.CriticalTable.DisplayName, + result.CriticalTable.SourceDocument, + result.CriticalRollBand.Label, + result.CriticalGroup?.GroupKey, + result.CriticalGroup?.Label, + result.CriticalColumn.ColumnKey, + result.CriticalColumn.Label, + result.CriticalColumn.Role, + content.RawCellText, + content.DescriptionText, + content.RawAffixText, + ResolveParseStatus(content.Effects, content.Branches), + SerializeParsedEffects(content.Effects), + content.ValidationErrors.ToList(), + content.Effects + .Select(CreateEffectEditorItem) + .ToList(), + content.Branches + .OrderBy(branch => branch.SortOrder) + .Select(CreateBranchEditorItem) + .ToList()); + private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) => new( branch.BranchKind, @@ -441,6 +502,21 @@ public sealed class LookupService(IDbContextFactory dbConte .Select(CreateEffectEditorItem) .ToList()); + private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch) => + new( + branch.BranchKind, + branch.ConditionKey, + branch.ConditionText, + "{}", + branch.RawText, + branch.DescriptionText, + branch.RawAffixText, + SerializeParsedEffects(branch.Effects), + branch.SortOrder, + branch.Effects + .Select(CreateEffectEditorItem) + .ToList()); + private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) => new( effect.EffectCode, @@ -456,6 +532,21 @@ public sealed class LookupService(IDbContextFactory dbConte effect.SourceType, effect.SourceText); + private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect) => + new( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + null, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText); + private static void ReplaceBaseEffects( RolemasterDbContext dbContext, CriticalResult result, @@ -520,10 +611,120 @@ public sealed class LookupService(IDbContextFactory dbConte Modifier = effect.Modifier, BodyPart = NormalizeOptionalText(effect.BodyPart), IsPermanent = effect.IsPermanent, - SourceType = effect.SourceType.Trim(), + SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(), SourceText = NormalizeOptionalText(effect.SourceText) }; + private static string ResolveParseStatus( + IReadOnlyList effects, + IReadOnlyList branches) => + effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) + ? "partial" + : "raw"; + + private static string SerializeParsedEffects(IReadOnlyList effects) => + effects.Count == 0 + ? "{}" + : JsonSerializer.Serialize(new + { + effects = effects.Select(effect => new + { + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText + }).ToList() + }); + + private static async Task BuildSharedAffixLegendAsync( + RolemasterDbContext dbContext, + int tableId, + CancellationToken cancellationToken) + { + var effectRows = await dbContext.CriticalEffects + .AsNoTracking() + .Where(item => + (item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId) || + (item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId)) + .Select(item => new { item.EffectCode, item.SourceType, item.SourceText }) + .ToListAsync(cancellationToken); + + var symbolEffects = new Dictionary(StringComparer.Ordinal); + var supportsFoePenalty = false; + var supportsAttackerBonus = false; + var supportsPowerPointModifier = false; + + foreach (var effectRow in effectRows) + { + supportsFoePenalty |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.FoePenalty, StringComparison.Ordinal); + supportsAttackerBonus |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.AttackerBonusNextRound, StringComparison.Ordinal); + supportsPowerPointModifier |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.PowerPointModifier, StringComparison.Ordinal); + + if (!string.Equals(effectRow.SourceType, "symbol", StringComparison.OrdinalIgnoreCase) || + !IsLegendSymbolEffectCode(effectRow.EffectCode) || + !TryExtractLegendSymbol(effectRow.SourceText, out var symbol)) + { + continue; + } + + symbolEffects.TryAdd(symbol, effectRow.EffectCode); + } + + return new SharedParsing.AffixLegend( + symbolEffects, + supportsPowerPointModifier ? ["P"] : [], + supportsFoePenalty, + supportsAttackerBonus, + supportsPowerPointModifier); + } + + private static bool IsLegendSymbolEffectCode(string effectCode) => + effectCode is CriticalEffectCodes.MustParryRounds + or CriticalEffectCodes.NoParryRounds + or CriticalEffectCodes.StunnedRounds + or CriticalEffectCodes.BleedPerRound; + + private static bool TryExtractLegendSymbol(string? sourceText, out string symbol) + { + symbol = string.Empty; + if (string.IsNullOrWhiteSpace(sourceText)) + { + return false; + } + + var candidate = new string(sourceText + .Where(character => + !char.IsWhiteSpace(character) && + !char.IsDigit(character) && + character is not ('+' or '-' or '–' or '(' or ')' or '/') && + !char.IsLetter(character)) + .ToArray()); + + if (string.IsNullOrWhiteSpace(candidate)) + { + return false; + } + + var distinctSymbols = candidate + .Distinct() + .ToList(); + + if (distinctSymbols.Count != 1) + { + return false; + } + + symbol = distinctSymbols[0].ToString(); + return true; + } + private static string? NormalizeOptionalText(string? value) => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index b25ba8d..3cb6512 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -40,6 +40,11 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }); +api.MapPost("/tables/critical/{slug}/cells/{resultId:int}/reparse", async (string slug, int resultId, CriticalCellReparseRequest request, LookupService lookupService, CancellationToken cancellationToken) => +{ + var result = await lookupService.ReparseCriticalCellAsync(slug, resultId, request.RawCellText, cancellationToken); + return result is null ? Results.NotFound() : Results.Ok(result); +}); api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) => { var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken); diff --git a/src/RolemasterDb.App/RolemasterDb.App.csproj b/src/RolemasterDb.App/RolemasterDb.App.csproj index 6d4ac29..b1215d1 100644 --- a/src/RolemasterDb.App/RolemasterDb.App.csproj +++ b/src/RolemasterDb.App/RolemasterDb.App.csproj @@ -16,4 +16,8 @@ + + + + diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css index 38034db..1973c18 100644 --- a/src/RolemasterDb.App/wwwroot/app.css +++ b/src/RolemasterDb.App/wwwroot/app.css @@ -830,6 +830,19 @@ textarea { gap: 0.75rem; } +.critical-editor-validation-list { + display: grid; + gap: 0.45rem; +} + +.critical-editor-validation-item { + margin: 0; + padding: 0.65rem 0.8rem; + border-radius: 12px; + background: rgba(184, 121, 59, 0.12); + color: #6b4c29; +} + .critical-editor-effect-grid { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); } diff --git a/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs new file mode 100644 index 0000000..a1ec7b0 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/AffixEffectParser.cs @@ -0,0 +1,279 @@ +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*\((?[^)]+)\)\s*P\b", RegexOptions.Compiled); + private static readonly Regex ModifierRegex = new(@"\((?[^0-9+\-)]*)(?[+-])\s*(?\d+)\)", RegexOptions.Compiled); + + public static IReadOnlyList Parse(string? rawAffixText, AffixLegend affixLegend) + { + if (string.IsNullOrWhiteSpace(rawAffixText)) + { + return []; + } + + var effects = new List(); + + foreach (var rawLine in rawAffixText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + ParseLine(CriticalCellParserSupport.CollapseWhitespace(rawLine), affixLegend, effects); + } + + return effects; + } + + private static void ParseLine(string line, AffixLegend affixLegend, List 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 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 symbols) + { + if (symbols.Count == 0) + { + return null; + } + + var escapedSymbols = string.Concat(symbols.Select(Regex.Escape)); + return new Regex( + $@"(?\d+)\s*)?(?[{escapedSymbols}]+)", + RegexOptions.Compiled); + } + + private static void AddMatches( + MatchCollection matches, + List<(int Index, ParsedCriticalEffect Effect)> matchedEffects, + List<(int Start, int End)> consumedRanges, + Func 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); +} diff --git a/src/RolemasterDb.CriticalParsing/AffixLegend.cs b/src/RolemasterDb.CriticalParsing/AffixLegend.cs new file mode 100644 index 0000000..7839e0e --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/AffixLegend.cs @@ -0,0 +1,50 @@ +namespace RolemasterDb.CriticalParsing; + +public sealed class AffixLegend +{ + public static AffixLegend Empty { get; } = new( + new Dictionary(StringComparer.Ordinal), + [], + supportsFoePenalty: false, + supportsAttackerBonus: false, + supportsPowerPointModifier: false); + + public AffixLegend( + IReadOnlyDictionary symbolEffects, + IReadOnlyCollection classificationOnlySymbols, + bool supportsFoePenalty, + bool supportsAttackerBonus, + bool supportsPowerPointModifier) + { + SymbolEffects = new Dictionary(symbolEffects, StringComparer.Ordinal); + EffectSymbols = new HashSet(SymbolEffects.Keys, StringComparer.Ordinal); + + var classificationSymbols = new HashSet(EffectSymbols, StringComparer.Ordinal); + foreach (var symbol in classificationOnlySymbols) + { + classificationSymbols.Add(symbol); + } + + ClassificationSymbols = classificationSymbols; + SupportsFoePenalty = supportsFoePenalty; + SupportsAttackerBonus = supportsAttackerBonus; + SupportsPowerPointModifier = supportsPowerPointModifier; + } + + public IReadOnlyDictionary SymbolEffects { get; } + + public IReadOnlySet EffectSymbols { get; } + + public IReadOnlySet ClassificationSymbols { get; } + + public bool SupportsFoePenalty { get; } + + public bool SupportsAttackerBonus { get; } + + public bool SupportsPowerPointModifier { get; } + + public string? ResolveEffectCode(string symbol) => + SymbolEffects.TryGetValue(symbol, out var effectCode) + ? effectCode + : null; +} diff --git a/src/RolemasterDb.CriticalParsing/CriticalCellParseContent.cs b/src/RolemasterDb.CriticalParsing/CriticalCellParseContent.cs new file mode 100644 index 0000000..153042c --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/CriticalCellParseContent.cs @@ -0,0 +1,19 @@ +namespace RolemasterDb.CriticalParsing; + +public sealed class CriticalCellParseContent( + IReadOnlyList baseLines, + string rawCellText, + string descriptionText, + string? rawAffixText, + IReadOnlyList effects, + IReadOnlyList branches, + IReadOnlyList validationErrors) +{ + public IReadOnlyList BaseLines { get; } = baseLines; + public string RawCellText { get; } = rawCellText; + public string DescriptionText { get; } = descriptionText; + public string? RawAffixText { get; } = rawAffixText; + public IReadOnlyList Effects { get; } = effects; + public IReadOnlyList Branches { get; } = branches; + public IReadOnlyList ValidationErrors { get; } = validationErrors; +} diff --git a/src/RolemasterDb.CriticalParsing/CriticalCellParserSupport.cs b/src/RolemasterDb.CriticalParsing/CriticalCellParserSupport.cs new file mode 100644 index 0000000..6ad951f --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/CriticalCellParserSupport.cs @@ -0,0 +1,118 @@ +using System.Text.RegularExpressions; + +namespace RolemasterDb.CriticalParsing; + +public static class CriticalCellParserSupport +{ + private static readonly Regex NumericAffixLineRegex = new(@"^\d+(?:H|∑|∏|π|∫|\s*[–-])", RegexOptions.Compiled); + private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|–)\d+\)$", RegexOptions.Compiled); + + public static bool IsAffixLikeLine(string line, IReadOnlySet affixLegendSymbols) + { + var value = line.Trim(); + if (value.Length == 0) + { + return false; + } + + if (value is "-" or "\u2013" or "\u2014") + { + return true; + } + + if (IsConditionalBranchStartLine(value)) + { + return true; + } + + if (affixLegendSymbols.Count > 0 && + affixLegendSymbols.Any(symbol => value.Contains(symbol, StringComparison.Ordinal))) + { + if (value.Any(char.IsDigit)) + { + return true; + } + + var remainder = value; + foreach (var symbol in affixLegendSymbols.OrderByDescending(item => item.Length)) + { + remainder = remainder.Replace(symbol, string.Empty, StringComparison.Ordinal); + } + + remainder = remainder + .Replace("+", string.Empty, StringComparison.Ordinal) + .Replace("-", string.Empty, StringComparison.Ordinal) + .Replace("–", string.Empty, StringComparison.Ordinal) + .Replace("(", string.Empty, StringComparison.Ordinal) + .Replace(")", string.Empty, StringComparison.Ordinal) + .Replace("/", string.Empty, StringComparison.Ordinal); + + if (string.IsNullOrWhiteSpace(remainder)) + { + return true; + } + } + + return value.StartsWith("+", StringComparison.Ordinal) || + value.StartsWith("\u2211", StringComparison.Ordinal) || + value.StartsWith("\u220F", StringComparison.Ordinal) || + value.StartsWith("\u03C0", StringComparison.Ordinal) || + value.StartsWith("\u222B", StringComparison.Ordinal) || + StandaloneModifierAffixLineRegex.IsMatch(value) || + NumericAffixLineRegex.IsMatch(value) || + value.Contains(" - ", StringComparison.Ordinal) || + value.Contains(" – ", StringComparison.Ordinal); + } + + public static int CountLineTypeSegments(IReadOnlyList lines, IReadOnlySet affixLegendSymbols) + { + var segmentCount = 0; + bool? previousIsAffix = null; + + foreach (var line in lines) + { + var currentIsAffix = IsAffixLikeLine(line, affixLegendSymbols); + if (previousIsAffix == currentIsAffix) + { + continue; + } + + segmentCount++; + previousIsAffix = currentIsAffix; + } + + return segmentCount; + } + + public static string CollapseWhitespace(string value) => + Regex.Replace(value.Trim(), @"\s+", " "); + + public static bool IsConditionalBranchStartLine(string value) + { + var normalized = value.Trim(); + if (!normalized.Contains(':', StringComparison.Ordinal)) + { + return false; + } + + return normalized.StartsWith("with ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("w/ ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("w/o ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("without ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("if ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("while ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("until ", StringComparison.OrdinalIgnoreCase) || + normalized.StartsWith("unless ", StringComparison.OrdinalIgnoreCase); + } + + public static string? NormalizeConditionKey(string conditionText) + { + var normalized = CollapseWhitespace(conditionText) + .ToLowerInvariant() + .Replace("w/o", "without", StringComparison.Ordinal) + .Replace("w/", "with", StringComparison.Ordinal); + normalized = Regex.Replace(normalized, @"[^a-z0-9]+", "_"); + normalized = normalized.Trim('_'); + return normalized.Length == 0 ? null : normalized; + } +} diff --git a/src/RolemasterDb.CriticalParsing/CriticalCellTextParser.cs b/src/RolemasterDb.CriticalParsing/CriticalCellTextParser.cs new file mode 100644 index 0000000..0c66261 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/CriticalCellTextParser.cs @@ -0,0 +1,127 @@ +namespace RolemasterDb.CriticalParsing; + +public static class CriticalCellTextParser +{ + public static CriticalCellParseContent Parse(string rawCellText, AffixLegend affixLegend) + { + var lines = rawCellText + .Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .ToList(); + return Parse(lines, affixLegend); + } + + public static CriticalCellParseContent Parse(IReadOnlyList lines, AffixLegend affixLegend) + { + var validationErrors = new List(); + var branchStartIndexes = FindBranchStartIndexes(lines); + var baseLineCount = branchStartIndexes.Count == 0 ? lines.Count : branchStartIndexes[0]; + var baseLines = lines.Take(baseLineCount).ToList(); + var branches = new List(); + var affixLegendSymbols = affixLegend.ClassificationSymbols; + + validationErrors.AddRange(ValidateSegmentCount(baseLines, affixLegendSymbols, "Base content")); + + for (var branchIndex = 0; branchIndex < branchStartIndexes.Count; branchIndex++) + { + var startIndex = branchStartIndexes[branchIndex]; + var endIndex = branchIndex == branchStartIndexes.Count - 1 + ? lines.Count + : branchStartIndexes[branchIndex + 1]; + + branches.Add(ParseBranch( + lines.Skip(startIndex).Take(endIndex - startIndex).ToList(), + branchIndex + 1, + affixLegend, + validationErrors)); + } + + var (rawText, descriptionText, rawAffixText) = BuildTextSections(baseLines, affixLegendSymbols); + var effects = AffixEffectParser.Parse(rawAffixText, affixLegend); + return new CriticalCellParseContent(baseLines, rawText, descriptionText, rawAffixText, effects, branches, validationErrors); + } + + private static ParsedCriticalBranch ParseBranch( + IReadOnlyList branchLines, + int sortOrder, + AffixLegend affixLegend, + List validationErrors) + { + var firstLine = branchLines[0]; + var separatorIndex = firstLine.IndexOf(':', StringComparison.Ordinal); + var conditionText = CriticalCellParserSupport.CollapseWhitespace(firstLine[..separatorIndex]); + var firstPayloadLine = CriticalCellParserSupport.CollapseWhitespace(firstLine[(separatorIndex + 1)..]); + var payloadLines = new List(); + + if (!string.IsNullOrWhiteSpace(firstPayloadLine)) + { + payloadLines.Add(firstPayloadLine); + } + + foreach (var continuationLine in branchLines.Skip(1)) + { + var normalized = CriticalCellParserSupport.CollapseWhitespace(continuationLine); + if (!string.IsNullOrWhiteSpace(normalized)) + { + payloadLines.Add(normalized); + } + } + + var affixLegendSymbols = affixLegend.ClassificationSymbols; + validationErrors.AddRange(ValidateSegmentCount(payloadLines, affixLegendSymbols, $"Branch '{conditionText}'")); + + var (_, descriptionText, rawAffixText) = BuildTextSections(payloadLines, affixLegendSymbols); + var effects = AffixEffectParser.Parse(rawAffixText, affixLegend); + return new ParsedCriticalBranch( + "conditional", + CriticalCellParserSupport.NormalizeConditionKey(conditionText), + conditionText, + string.Join(Environment.NewLine, branchLines), + descriptionText, + rawAffixText, + effects, + sortOrder); + } + + private static List FindBranchStartIndexes(IReadOnlyList lines) + { + var branchStartIndexes = new List(); + + for (var index = 0; index < lines.Count; index++) + { + if (CriticalCellParserSupport.IsConditionalBranchStartLine(lines[index])) + { + branchStartIndexes.Add(index); + } + } + + return branchStartIndexes; + } + + private static IReadOnlyList ValidateSegmentCount( + IReadOnlyList lines, + IReadOnlySet affixLegendSymbols, + string scope) + { + if (lines.Count == 0) + { + return []; + } + + var segmentCount = CriticalCellParserSupport.CountLineTypeSegments(lines, affixLegendSymbols); + return segmentCount > 2 + ? [$"{scope} interleaves prose and affix lines."] + : []; + } + + private static (string RawText, string DescriptionText, string? RawAffixText) BuildTextSections( + IReadOnlyList lines, + IReadOnlySet affixLegendSymbols) + { + var rawText = string.Join(Environment.NewLine, lines); + var rawAffixLines = lines.Where(line => CriticalCellParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList(); + var descriptionLines = lines.Where(line => !CriticalCellParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList(); + var descriptionText = CriticalCellParserSupport.CollapseWhitespace(string.Join(' ', descriptionLines)); + var rawAffixText = rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines); + return (rawText, descriptionText, rawAffixText); + } +} diff --git a/src/RolemasterDb.CriticalParsing/CriticalEffectCodes.cs b/src/RolemasterDb.CriticalParsing/CriticalEffectCodes.cs new file mode 100644 index 0000000..3e548aa --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/CriticalEffectCodes.cs @@ -0,0 +1,13 @@ +namespace RolemasterDb.CriticalParsing; + +public static class CriticalEffectCodes +{ + public const string DirectHits = "direct_hits"; + public const string MustParryRounds = "must_parry_rounds"; + public const string NoParryRounds = "no_parry_rounds"; + public const string StunnedRounds = "stunned_rounds"; + public const string BleedPerRound = "bleed_per_round"; + public const string FoePenalty = "foe_penalty"; + public const string AttackerBonusNextRound = "attacker_bonus_next_round"; + public const string PowerPointModifier = "power_point_modifier"; +} diff --git a/src/RolemasterDb.CriticalParsing/ParsedCriticalBranch.cs b/src/RolemasterDb.CriticalParsing/ParsedCriticalBranch.cs new file mode 100644 index 0000000..b47c0c1 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/ParsedCriticalBranch.cs @@ -0,0 +1,21 @@ +namespace RolemasterDb.CriticalParsing; + +public sealed class ParsedCriticalBranch( + string branchKind, + string? conditionKey, + string conditionText, + string rawText, + string descriptionText, + string? rawAffixText, + IReadOnlyList effects, + int sortOrder) +{ + public string BranchKind { get; } = branchKind; + public string? ConditionKey { get; } = conditionKey; + public string ConditionText { get; } = conditionText; + public string RawText { get; } = rawText; + public string DescriptionText { get; } = descriptionText; + public string? RawAffixText { get; } = rawAffixText; + public IReadOnlyList Effects { get; } = effects; + public int SortOrder { get; } = sortOrder; +} diff --git a/src/RolemasterDb.CriticalParsing/ParsedCriticalEffect.cs b/src/RolemasterDb.CriticalParsing/ParsedCriticalEffect.cs new file mode 100644 index 0000000..5548e39 --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/ParsedCriticalEffect.cs @@ -0,0 +1,27 @@ +namespace RolemasterDb.CriticalParsing; + +public sealed class ParsedCriticalEffect( + string effectCode, + string? target, + int? valueInteger, + string? valueExpression, + int? durationRounds, + int? perRound, + int? modifier, + string? bodyPart, + bool isPermanent, + string sourceType, + string sourceText) +{ + public string EffectCode { get; } = effectCode; + public string? Target { get; } = target; + public int? ValueInteger { get; } = valueInteger; + public string? ValueExpression { get; } = valueExpression; + public int? DurationRounds { get; } = durationRounds; + public int? PerRound { get; } = perRound; + public int? Modifier { get; } = modifier; + public string? BodyPart { get; } = bodyPart; + public bool IsPermanent { get; } = isPermanent; + public string SourceType { get; } = sourceType; + public string SourceText { get; } = sourceText; +} diff --git a/src/RolemasterDb.CriticalParsing/RolemasterDb.CriticalParsing.csproj b/src/RolemasterDb.CriticalParsing/RolemasterDb.CriticalParsing.csproj new file mode 100644 index 0000000..9ed914b --- /dev/null +++ b/src/RolemasterDb.CriticalParsing/RolemasterDb.CriticalParsing.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs new file mode 100644 index 0000000..495c5a3 --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs @@ -0,0 +1,138 @@ +using Microsoft.EntityFrameworkCore; + +using RolemasterDb.App.Data; +using RolemasterDb.App.Domain; +using RolemasterDb.App.Features; +using RolemasterDb.CriticalParsing; +using AppCriticalEffectCodes = RolemasterDb.App.Domain.CriticalEffectCodes; + +namespace RolemasterDb.ImportTool.Tests; + +public sealed class CriticalCellReparseIntegrationTests +{ + [Fact] + public void Shared_cell_parser_extracts_base_effects_and_condition_branches() + { + var legend = new AffixLegend( + new Dictionary(StringComparer.Ordinal) + { + ["∫"] = AppCriticalEffectCodes.StunnedRounds + }, + [], + supportsFoePenalty: false, + supportsAttackerBonus: false, + supportsPowerPointModifier: false); + + var content = CriticalCellTextParser.Parse( + "Strike to thigh.\r\n+10H\r\nWith greaves: glancing blow.\r\n2∫", + legend); + + Assert.Equal("Strike to thigh.", content.DescriptionText); + Assert.Equal("+10H", content.RawAffixText); + Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 10); + Assert.Single(content.Branches); + Assert.Equal("with_greaves", content.Branches[0].ConditionKey); + Assert.Equal("glancing blow.", content.Branches[0].DescriptionText); + Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2); + Assert.Empty(content.ValidationErrors); + } + + [Fact] + public async Task Lookup_service_reparse_uses_shared_parser_and_table_legend_data() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-{Guid.NewGuid():N}.db"); + + await using (var seedContext = CreateDbContext(databasePath)) + { + await seedContext.Database.EnsureCreatedAsync(); + + var table = new CriticalTable + { + Slug = "slash", + DisplayName = "Slash Critical Strike Table", + Family = "standard", + SourceDocument = "Slash.pdf", + Notes = null + }; + var column = new CriticalColumn + { + CriticalTable = table, + ColumnKey = "B", + Label = "B", + Role = "severity", + SortOrder = 2 + }; + var rollBand = new CriticalRollBand + { + CriticalTable = table, + Label = "36-40", + MinRoll = 36, + MaxRoll = 40, + SortOrder = 8 + }; + var result = new CriticalResult + { + CriticalTable = table, + CriticalColumn = column, + CriticalRollBand = rollBand, + RawCellText = "Old text", + DescriptionText = "Old description", + ParseStatus = "verified", + ParsedJson = "{}" + }; + + result.Effects.Add(new CriticalEffect + { + EffectCode = AppCriticalEffectCodes.StunnedRounds, + Target = "foe", + DurationRounds = 1, + IsPermanent = false, + SourceType = "symbol", + SourceText = "∫" + }); + + seedContext.CriticalTables.Add(table); + seedContext.CriticalResults.Add(result); + await seedContext.SaveChangesAsync(); + } + + var lookupService = new LookupService(CreateDbContextFactory(databasePath)); + await using var verifyContext = CreateDbContext(databasePath); + var resultId = await verifyContext.CriticalResults + .Where(item => item.CriticalTable.Slug == "slash") + .Select(item => item.Id) + .SingleAsync(); + + var response = await lookupService.ReparseCriticalCellAsync( + "slash", + resultId, + "Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫"); + + Assert.NotNull(response); + Assert.Equal("Strike to thigh.", response!.DescriptionText); + Assert.Contains(response.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 10); + Assert.Contains(response.Branches, branch => + branch.ConditionKey == "with_greaves" && + branch.DescriptionText == "glancing blow." && + branch.Effects.Any(effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2)); + Assert.Empty(response.ValidationMessages); + } + + private static RolemasterDbContext CreateDbContext(string databasePath) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + + return new RolemasterDbContext(options); + } + + private static IDbContextFactory CreateDbContextFactory(string databasePath) + { + var options = new DbContextOptionsBuilder() + .UseSqlite($"Data Source={databasePath}") + .Options; + + return new TestRolemasterDbContextFactory(options); + } +} diff --git a/src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj b/src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj index cf9043b..975741f 100644 --- a/src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj +++ b/src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj @@ -19,6 +19,7 @@ + diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs index f217ed4..f3e24b8 100644 --- a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs +++ b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs @@ -3,6 +3,7 @@ using System.Xml; using System.Xml.Linq; using RolemasterDb.App.Domain; +using SharedParsing = RolemasterDb.CriticalParsing; namespace RolemasterDb.ImportTool.Parsing; @@ -559,12 +560,17 @@ internal static class CriticalTableParserSupport List parsedResults, List validationErrors) { + var sharedLegend = ToSharedAffixLegend(affixLegend); + foreach (var cellEntry in cellEntries) { - var content = CriticalCellTextParser.Parse(cellEntry.Lines, affixLegend); + var content = SharedParsing.CriticalCellTextParser.Parse(cellEntry.Lines, sharedLegend); validationErrors.AddRange(content.ValidationErrors.Select(error => $"Cell '{BuildCellIdentifier(cellEntry)}': {error}")); + var effects = content.Effects.Select(ToImportToolEffect).ToList(); + var branches = content.Branches.Select(ToImportToolBranch).ToList(); + parsedCells.Add(new ParsedCriticalCellArtifact( cellEntry.GroupKey, cellEntry.RollBandLabel, @@ -574,8 +580,8 @@ internal static class CriticalTableParserSupport content.RawCellText, content.DescriptionText, content.RawAffixText, - content.Effects, - content.Branches)); + effects, + branches)); parsedResults.Add(new ParsedCriticalResult( cellEntry.GroupKey, @@ -584,11 +590,44 @@ internal static class CriticalTableParserSupport content.RawCellText, content.DescriptionText, content.RawAffixText, - content.Effects, - content.Branches)); + effects, + branches)); } } + private static SharedParsing.AffixLegend ToSharedAffixLegend(AffixLegend affixLegend) => + new( + affixLegend.SymbolEffects, + affixLegend.ClassificationSymbols.Except(affixLegend.EffectSymbols).ToList(), + affixLegend.SupportsFoePenalty, + affixLegend.SupportsAttackerBonus, + affixLegend.SupportsPowerPointModifier); + + private static ParsedCriticalEffect ToImportToolEffect(SharedParsing.ParsedCriticalEffect effect) => + new( + effect.EffectCode, + effect.Target, + effect.ValueInteger, + effect.ValueExpression, + effect.DurationRounds, + effect.PerRound, + effect.Modifier, + effect.BodyPart, + effect.IsPermanent, + effect.SourceType, + effect.SourceText); + + private static ParsedCriticalBranch ToImportToolBranch(SharedParsing.ParsedCriticalBranch branch) => + new( + branch.BranchKind, + branch.ConditionKey, + branch.ConditionText, + branch.RawText, + branch.DescriptionText, + branch.RawAffixText, + branch.Effects.Select(ToImportToolEffect).ToList(), + branch.SortOrder); + private static string BuildCellIdentifier(ColumnarCellEntry cellEntry) => cellEntry.GroupKey is null ? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}" diff --git a/src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj b/src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj index 4640ad7..4ca256b 100644 --- a/src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj +++ b/src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj @@ -12,6 +12,7 @@ +