Share critical cell parsing across app and importer

This commit is contained in:
2026-03-15 02:10:17 +01:00
parent c5800d6878
commit 641e33f811
27 changed files with 1207 additions and 19 deletions

View File

@@ -1,6 +1,7 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" /> <Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" />
<Project Path="src/RolemasterDb.CriticalParsing/RolemasterDb.CriticalParsing.csproj" />
<Project Path="src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj" /> <Project Path="src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj" />
<Project Path="src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj" /> <Project Path="src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj" />
</Folder> </Folder>

View File

@@ -550,6 +550,10 @@ Acceptance criteria:
### Phase 4: Shared parsing assembly ### Phase 4: Shared parsing assembly
Status:
- implemented in the web app on March 15, 2026
Scope: Scope:
- extract shared parser/normalizer logic from the import tool - extract shared parser/normalizer logic from the import tool

View File

@@ -60,12 +60,22 @@
"rawAffixText": "+8H - 2S", "rawAffixText": "+8H - 2S",
"parseStatus": "verified", "parseStatus": "verified",
"parsedJson": "{}", "parsedJson": "{}",
"validationMessages": [],
"effects": [], "effects": [],
"branches": [] "branches": []
}</pre> }</pre>
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p> <p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p>
</section> </section>
<section class="panel">
<h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
<pre class="code-block">{
"rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside."
}</pre>
<p class="panel-copy">Re-runs the shared single-cell parser and returns a refreshed editor payload without saving changes.</p>
</section>
<section class="panel"> <section class="panel">
<h2 class="panel-title">Cell editor save</h2> <h2 class="panel-title">Cell editor save</h2>
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p> <p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>

View File

@@ -207,10 +207,13 @@
<CriticalCellEditorDialog <CriticalCellEditorDialog
Model="editorModel" Model="editorModel"
IsLoading="isEditorLoading" IsLoading="isEditorLoading"
IsReparsing="isEditorReparsing"
IsSaving="isEditorSaving" IsSaving="isEditorSaving"
LoadErrorMessage="@editorLoadError" LoadErrorMessage="@editorLoadError"
ReparseErrorMessage="@editorReparseError"
SaveErrorMessage="@editorSaveError" SaveErrorMessage="@editorSaveError"
OnClose="CloseCellEditorAsync" OnClose="CloseCellEditorAsync"
OnReparse="ReparseCellEditorAsync"
OnSave="SaveCellEditorAsync" /> OnSave="SaveCellEditorAsync" />
} }
@@ -227,8 +230,10 @@
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0; private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
private bool isEditorOpen; private bool isEditorOpen;
private bool isEditorLoading; private bool isEditorLoading;
private bool isEditorReparsing;
private bool isEditorSaving; private bool isEditorSaving;
private string? editorLoadError; private string? editorLoadError;
private string? editorReparseError;
private string? editorSaveError; private string? editorSaveError;
private int? editingResultId; private int? editingResultId;
private CriticalCellEditorModel? editorModel; private CriticalCellEditorModel? editorModel;
@@ -328,9 +333,11 @@
} }
editorLoadError = null; editorLoadError = null;
editorReparseError = null;
editorSaveError = null; editorSaveError = null;
editorModel = null; editorModel = null;
editingResultId = resultId; editingResultId = resultId;
isEditorReparsing = false;
isEditorSaving = false; isEditorSaving = false;
isEditorLoading = true; isEditorLoading = true;
isEditorOpen = true; isEditorOpen = true;
@@ -362,14 +369,47 @@
{ {
isEditorOpen = false; isEditorOpen = false;
isEditorLoading = false; isEditorLoading = false;
isEditorReparsing = false;
isEditorSaving = false; isEditorSaving = false;
editorLoadError = null; editorLoadError = null;
editorReparseError = null;
editorSaveError = null; editorSaveError = null;
editingResultId = null; editingResultId = null;
editorModel = null; editorModel = null;
await InvokeAsync(StateHasChanged); 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() private async Task SaveCellEditorAsync()
{ {
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null) if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using RolemasterDb.App.Features; using RolemasterDb.App.Features;
namespace RolemasterDb.App.Components.Shared; namespace RolemasterDb.App.Components.Shared;
@@ -35,11 +36,32 @@ public sealed class CriticalBranchEditorModel
BranchKind, BranchKind,
ConditionKey, ConditionKey,
ConditionText, ConditionText,
ConditionJson, "{}",
RawText, BuildRawText(),
DescriptionText, DescriptionText,
RawAffixText, RawAffixText,
ParsedJson, SerializeParsedEffects(Effects),
SortOrder, SortOrder,
Effects.Select(effect => effect.ToItem()).ToList()); 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<CriticalEffectEditorModel> effects) =>
effects.Count == 0
? "{}"
: JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() });
} }

View File

@@ -45,6 +45,11 @@
{ {
<EditForm Model="Model" OnSubmit="HandleSubmitAsync" class="critical-editor-form"> <EditForm Model="Model" OnSubmit="HandleSubmitAsync" class="critical-editor-form">
<div class="critical-editor-body"> <div class="critical-editor-body">
@if (!string.IsNullOrWhiteSpace(ReparseErrorMessage))
{
<p class="error-text critical-editor-error">@ReparseErrorMessage</p>
}
@if (!string.IsNullOrWhiteSpace(SaveErrorMessage)) @if (!string.IsNullOrWhiteSpace(SaveErrorMessage))
{ {
<p class="error-text critical-editor-error">@SaveErrorMessage</p> <p class="error-text critical-editor-error">@SaveErrorMessage</p>
@@ -79,11 +84,23 @@
<h4>Raw Text</h4> <h4>Raw Text</h4>
<p class="muted">Update the source text, then adjust the visible card fields below.</p> <p class="muted">Update the source text, then adjust the visible card fields below.</p>
</div> </div>
<button type="button" class="btn-ritual" @onclick="OnReparse" disabled="@IsSaving || IsReparsing">
@(IsReparsing ? "Re-Parsing..." : "Re-Parse Raw Text")
</button>
</div> </div>
<div class="field-shell"> <div class="field-shell">
<label>Raw Cell Text</label> <label>Raw Cell Text</label>
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" /> <InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
</div> </div>
@if (Model.ValidationMessages.Count > 0)
{
<div class="critical-editor-validation-list">
@foreach (var message in Model.ValidationMessages)
{
<p class="critical-editor-validation-item">@message</p>
}
</div>
}
<div class="field-shell"> <div class="field-shell">
<label>Result Text Override</label> <label>Result Text Override</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" /> <InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" />
@@ -231,18 +248,27 @@
[Parameter] [Parameter]
public bool IsLoading { get; set; } public bool IsLoading { get; set; }
[Parameter]
public bool IsReparsing { get; set; }
[Parameter] [Parameter]
public bool IsSaving { get; set; } public bool IsSaving { get; set; }
[Parameter] [Parameter]
public string? LoadErrorMessage { get; set; } public string? LoadErrorMessage { get; set; }
[Parameter]
public string? ReparseErrorMessage { get; set; }
[Parameter] [Parameter]
public string? SaveErrorMessage { get; set; } public string? SaveErrorMessage { get; set; }
[Parameter, EditorRequired] [Parameter, EditorRequired]
public EventCallback OnClose { get; set; } public EventCallback OnClose { get; set; }
[Parameter, EditorRequired]
public EventCallback OnReparse { get; set; }
[Parameter, EditorRequired] [Parameter, EditorRequired]
public EventCallback OnSave { get; set; } public EventCallback OnSave { get; set; }

View File

@@ -1,3 +1,4 @@
using System.Text.Json;
using RolemasterDb.App.Features; using RolemasterDb.App.Features;
namespace RolemasterDb.App.Components.Shared; namespace RolemasterDb.App.Components.Shared;
@@ -19,6 +20,7 @@ public sealed class CriticalCellEditorModel
public string? RawAffixText { get; set; } public string? RawAffixText { get; set; }
public string ParseStatus { get; set; } = string.Empty; public string ParseStatus { get; set; } = string.Empty;
public string ParsedJson { get; set; } = "{}"; public string ParsedJson { get; set; } = "{}";
public List<string> ValidationMessages { get; set; } = [];
public List<CriticalEffectEditorModel> Effects { get; set; } = []; public List<CriticalEffectEditorModel> Effects { get; set; } = [];
public List<CriticalBranchEditorModel> Branches { get; set; } = []; public List<CriticalBranchEditorModel> Branches { get; set; } = [];
@@ -40,6 +42,7 @@ public sealed class CriticalCellEditorModel
RawAffixText = response.RawAffixText, RawAffixText = response.RawAffixText,
ParseStatus = response.ParseStatus, ParseStatus = response.ParseStatus,
ParsedJson = response.ParsedJson, ParsedJson = response.ParsedJson,
ValidationMessages = response.ValidationMessages.ToList(),
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(), Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList() Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
}; };
@@ -49,8 +52,8 @@ public sealed class CriticalCellEditorModel
RawCellText, RawCellText,
DescriptionText, DescriptionText,
RawAffixText, RawAffixText,
ParseStatus, ResolveParseStatus(Effects, Branches),
ParsedJson, SerializeParsedEffects(Effects),
Effects.Select(effect => effect.ToItem()).ToList(), Effects.Select(effect => effect.ToItem()).ToList(),
Branches Branches
.OrderBy(branch => branch.SortOrder) .OrderBy(branch => branch.SortOrder)
@@ -60,4 +63,16 @@ public sealed class CriticalCellEditorModel
return branch.ToItem(); return branch.ToItem();
}) })
.ToList()); .ToList());
private static string ResolveParseStatus(
IReadOnlyList<CriticalEffectEditorModel> effects,
IReadOnlyList<CriticalBranchEditorModel> branches) =>
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
? "partial"
: "raw";
private static string SerializeParsedEffects(IReadOnlyList<CriticalEffectEditorModel> effects) =>
effects.Count == 0
? "{}"
: JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() });
} }

View File

@@ -2,12 +2,12 @@ namespace RolemasterDb.App.Domain;
public static class CriticalEffectCodes public static class CriticalEffectCodes
{ {
public const string DirectHits = "direct_hits"; public const string DirectHits = RolemasterDb.CriticalParsing.CriticalEffectCodes.DirectHits;
public const string MustParryRounds = "must_parry_rounds"; public const string MustParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.MustParryRounds;
public const string NoParryRounds = "no_parry_rounds"; public const string NoParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.NoParryRounds;
public const string StunnedRounds = "stunned_rounds"; public const string StunnedRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.StunnedRounds;
public const string BleedPerRound = "bleed_per_round"; public const string BleedPerRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.BleedPerRound;
public const string FoePenalty = "foe_penalty"; public const string FoePenalty = RolemasterDb.CriticalParsing.CriticalEffectCodes.FoePenalty;
public const string AttackerBonusNextRound = "attacker_bonus_next_round"; public const string AttackerBonusNextRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.AttackerBonusNextRound;
public const string PowerPointModifier = "power_point_modifier"; public const string PowerPointModifier = RolemasterDb.CriticalParsing.CriticalEffectCodes.PowerPointModifier;
} }

View File

@@ -18,5 +18,6 @@ public sealed record CriticalCellEditorResponse(
string? RawAffixText, string? RawAffixText,
string ParseStatus, string ParseStatus,
string ParsedJson, string ParsedJson,
IReadOnlyList<string> ValidationMessages,
IReadOnlyList<CriticalEffectEditorItem> Effects, IReadOnlyList<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches); IReadOnlyList<CriticalBranchEditorItem> Branches);

View File

@@ -0,0 +1,4 @@
namespace RolemasterDb.App.Features;
public sealed record CriticalCellReparseRequest(
string RawCellText);

View File

@@ -1,9 +1,11 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Text.Json;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data; using RolemasterDb.App.Data;
using RolemasterDb.App.Domain; using RolemasterDb.App.Domain;
using SharedParsing = RolemasterDb.CriticalParsing;
namespace RolemasterDb.App.Features; namespace RolemasterDb.App.Features;
@@ -286,6 +288,36 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
return result is null ? null : CreateCellEditorResponse(result); return result is null ? null : CreateCellEditorResponse(result);
} }
public async Task<CriticalCellEditorResponse?> 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<CriticalCellEditorResponse?> UpdateCriticalCellAsync( public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
string slug, string slug,
int resultId, int resultId,
@@ -402,6 +434,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.RawAffixText, result.RawAffixText,
result.ParseStatus, result.ParseStatus,
result.ParsedJson, result.ParsedJson,
[],
result.Effects result.Effects
.OrderBy(effect => effect.Id) .OrderBy(effect => effect.Id)
.Select(CreateEffectEditorItem) .Select(CreateEffectEditorItem)
@@ -411,6 +444,34 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.Select(CreateBranchEditorItem) .Select(CreateBranchEditorItem)
.ToList()); .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) => private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
new( new(
branch.BranchKind, branch.BranchKind,
@@ -441,6 +502,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.Select(CreateEffectEditorItem) .Select(CreateEffectEditorItem)
.ToList()); .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) => private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) =>
new( new(
effect.EffectCode, effect.EffectCode,
@@ -456,6 +532,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.SourceType, effect.SourceType,
effect.SourceText); 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( private static void ReplaceBaseEffects(
RolemasterDbContext dbContext, RolemasterDbContext dbContext,
CriticalResult result, CriticalResult result,
@@ -520,10 +611,120 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
Modifier = effect.Modifier, Modifier = effect.Modifier,
BodyPart = NormalizeOptionalText(effect.BodyPart), BodyPart = NormalizeOptionalText(effect.BodyPart),
IsPermanent = effect.IsPermanent, IsPermanent = effect.IsPermanent,
SourceType = effect.SourceType.Trim(), SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
SourceText = NormalizeOptionalText(effect.SourceText) SourceText = NormalizeOptionalText(effect.SourceText)
}; };
private static string ResolveParseStatus(
IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects,
IReadOnlyList<SharedParsing.ParsedCriticalBranch> branches) =>
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
? "partial"
: "raw";
private static string SerializeParsedEffects(IReadOnlyList<SharedParsing.ParsedCriticalEffect> 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<SharedParsing.AffixLegend> 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<string, string>(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) => private static string? NormalizeOptionalText(string? value) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim(); string.IsNullOrWhiteSpace(value) ? null : value.Trim();

View File

@@ -40,6 +40,11 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken); var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
return result is null ? Results.NotFound() : Results.Ok(result); 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) => 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); var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);

View File

@@ -16,4 +16,8 @@
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
</ItemGroup>
</Project> </Project>

View File

@@ -830,6 +830,19 @@ textarea {
gap: 0.75rem; 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 { .critical-editor-effect-grid {
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
} }

View File

@@ -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*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
private static readonly Regex ModifierRegex = new(@"\((?<noise>[^0-9+\-)]*)(?<sign>[+-])\s*(?<value>\d+)\)", RegexOptions.Compiled);
public static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
{
if (string.IsNullOrWhiteSpace(rawAffixText))
{
return [];
}
var effects = new List<ParsedCriticalEffect>();
foreach (var rawLine in rawAffixText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
ParseLine(CriticalCellParserSupport.CollapseWhitespace(rawLine), affixLegend, effects);
}
return effects;
}
private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
{
if (string.IsNullOrWhiteSpace(line) || line is "-" or "" or "—")
{
return;
}
var consumedRanges = new List<(int Start, int End)>();
var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>();
AddMatches(
DirectHitsRegex.Matches(line),
matchedEffects,
consumedRanges,
match =>
{
var hits = ParseSignedInteger(match.Value);
return new ParsedCriticalEffect(
CriticalEffectCodes.DirectHits,
FoeTarget,
hits,
null,
null,
null,
null,
null,
false,
"symbol",
NormalizeToken(match.Value));
});
if (affixLegend.SupportsPowerPointModifier)
{
AddMatches(
PowerPointModifierRegex.Matches(line),
matchedEffects,
consumedRanges,
match => new ParsedCriticalEffect(
CriticalEffectCodes.PowerPointModifier,
FoeTarget,
null,
CriticalCellParserSupport.CollapseWhitespace(match.Groups["expression"].Value),
null,
null,
null,
null,
false,
"symbol",
NormalizeToken(match.Value)));
}
AddMatches(
ModifierRegex.Matches(line),
matchedEffects,
consumedRanges,
match =>
{
var modifier = BuildModifier(match);
if (modifier is null)
{
return null;
}
if (modifier.Value < 0 && affixLegend.SupportsFoePenalty)
{
return new ParsedCriticalEffect(
CriticalEffectCodes.FoePenalty,
FoeTarget,
null,
null,
null,
null,
modifier.Value,
null,
false,
"symbol",
NormalizeToken(match.Value));
}
if (modifier.Value > 0 && affixLegend.SupportsAttackerBonus)
{
return new ParsedCriticalEffect(
CriticalEffectCodes.AttackerBonusNextRound,
null,
null,
null,
null,
null,
modifier.Value,
null,
false,
"symbol",
NormalizeToken(match.Value));
}
return null;
});
var symbolClusterRegex = CreateSymbolClusterRegex(affixLegend.EffectSymbols);
if (symbolClusterRegex is null)
{
return;
}
foreach (Match match in symbolClusterRegex.Matches(line))
{
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
{
continue;
}
var magnitude = match.Groups["count"].Success
? int.Parse(match.Groups["count"].Value)
: 1;
var matchedText = NormalizeToken(match.Value);
foreach (var symbol in match.Groups["symbols"].Value.Select(character => character.ToString()))
{
var effectCode = affixLegend.ResolveEffectCode(symbol);
if (effectCode is null)
{
continue;
}
matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText)));
}
}
if (matchedEffects.Count > 0)
{
effects.AddRange(matchedEffects
.OrderBy(item => item.Index)
.Select(item => item.Effect));
}
}
private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) =>
effectCode switch
{
CriticalEffectCodes.MustParryRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.NoParryRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.StunnedRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.BleedPerRound => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
null,
magnitude,
null,
null,
false,
"symbol",
sourceText),
_ => throw new InvalidOperationException($"Unsupported symbol effect code '{effectCode}'.")
};
private static Regex? CreateSymbolClusterRegex(IReadOnlySet<string> symbols)
{
if (symbols.Count == 0)
{
return null;
}
var escapedSymbols = string.Concat(symbols.Select(Regex.Escape));
return new Regex(
$@"(?<![A-Za-z0-9])(?:(?<count>\d+)\s*)?(?<symbols>[{escapedSymbols}]+)",
RegexOptions.Compiled);
}
private static void AddMatches(
MatchCollection matches,
List<(int Index, ParsedCriticalEffect Effect)> matchedEffects,
List<(int Start, int End)> consumedRanges,
Func<Match, ParsedCriticalEffect?> createEffect)
{
foreach (Match match in matches)
{
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
{
continue;
}
consumedRanges.Add((match.Index, match.Index + match.Length));
var effect = createEffect(match);
if (effect is not null)
{
matchedEffects.Add((match.Index, effect));
}
}
}
private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) =>
consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length);
private static int ParseSignedInteger(string value) =>
int.Parse(value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("H", string.Empty, StringComparison.OrdinalIgnoreCase));
private static int? BuildModifier(Match match)
{
if (!int.TryParse(match.Groups["value"].Value, out var absoluteValue))
{
return null;
}
return string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal)
? -absoluteValue
: absoluteValue;
}
private static string NormalizeToken(string value) =>
CriticalCellParserSupport.CollapseWhitespace(value)
.Replace(" +", "+", StringComparison.Ordinal)
.Replace("( ", "(", StringComparison.Ordinal)
.Replace(" )", ")", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,50 @@
namespace RolemasterDb.CriticalParsing;
public sealed class AffixLegend
{
public static AffixLegend Empty { get; } = new(
new Dictionary<string, string>(StringComparer.Ordinal),
[],
supportsFoePenalty: false,
supportsAttackerBonus: false,
supportsPowerPointModifier: false);
public AffixLegend(
IReadOnlyDictionary<string, string> symbolEffects,
IReadOnlyCollection<string> classificationOnlySymbols,
bool supportsFoePenalty,
bool supportsAttackerBonus,
bool supportsPowerPointModifier)
{
SymbolEffects = new Dictionary<string, string>(symbolEffects, StringComparer.Ordinal);
EffectSymbols = new HashSet<string>(SymbolEffects.Keys, StringComparer.Ordinal);
var classificationSymbols = new HashSet<string>(EffectSymbols, StringComparer.Ordinal);
foreach (var symbol in classificationOnlySymbols)
{
classificationSymbols.Add(symbol);
}
ClassificationSymbols = classificationSymbols;
SupportsFoePenalty = supportsFoePenalty;
SupportsAttackerBonus = supportsAttackerBonus;
SupportsPowerPointModifier = supportsPowerPointModifier;
}
public IReadOnlyDictionary<string, string> SymbolEffects { get; }
public IReadOnlySet<string> EffectSymbols { get; }
public IReadOnlySet<string> 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;
}

View File

@@ -0,0 +1,19 @@
namespace RolemasterDb.CriticalParsing;
public sealed class CriticalCellParseContent(
IReadOnlyList<string> baseLines,
string rawCellText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches,
IReadOnlyList<string> validationErrors)
{
public IReadOnlyList<string> BaseLines { get; } = baseLines;
public string RawCellText { get; } = rawCellText;
public string DescriptionText { get; } = descriptionText;
public string? RawAffixText { get; } = rawAffixText;
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
public IReadOnlyList<string> ValidationErrors { get; } = validationErrors;
}

View File

@@ -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<string> 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<string> lines, IReadOnlySet<string> 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;
}
}

View File

@@ -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<string> lines, AffixLegend affixLegend)
{
var validationErrors = new List<string>();
var branchStartIndexes = FindBranchStartIndexes(lines);
var baseLineCount = branchStartIndexes.Count == 0 ? lines.Count : branchStartIndexes[0];
var baseLines = lines.Take(baseLineCount).ToList();
var branches = new List<ParsedCriticalBranch>();
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<string> branchLines,
int sortOrder,
AffixLegend affixLegend,
List<string> 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<string>();
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<int> FindBranchStartIndexes(IReadOnlyList<string> lines)
{
var branchStartIndexes = new List<int>();
for (var index = 0; index < lines.Count; index++)
{
if (CriticalCellParserSupport.IsConditionalBranchStartLine(lines[index]))
{
branchStartIndexes.Add(index);
}
}
return branchStartIndexes;
}
private static IReadOnlyList<string> ValidateSegmentCount(
IReadOnlyList<string> lines,
IReadOnlySet<string> 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<string> lines,
IReadOnlySet<string> 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);
}
}

View File

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

View File

@@ -0,0 +1,21 @@
namespace RolemasterDb.CriticalParsing;
public sealed class ParsedCriticalBranch(
string branchKind,
string? conditionKey,
string conditionText,
string rawText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> 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<ParsedCriticalEffect> Effects { get; } = effects;
public int SortOrder { get; } = sortOrder;
}

View File

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

View File

@@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@@ -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<string, string>(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<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new RolemasterDbContext(options);
}
private static IDbContextFactory<RolemasterDbContext> CreateDbContextFactory(string databasePath)
{
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new TestRolemasterDbContextFactory(options);
}
}

View File

@@ -19,6 +19,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
<ProjectReference Include="..\RolemasterDb.ImportTool\RolemasterDb.ImportTool.csproj" /> <ProjectReference Include="..\RolemasterDb.ImportTool\RolemasterDb.ImportTool.csproj" />
</ItemGroup> </ItemGroup>

View File

@@ -3,6 +3,7 @@ using System.Xml;
using System.Xml.Linq; using System.Xml.Linq;
using RolemasterDb.App.Domain; using RolemasterDb.App.Domain;
using SharedParsing = RolemasterDb.CriticalParsing;
namespace RolemasterDb.ImportTool.Parsing; namespace RolemasterDb.ImportTool.Parsing;
@@ -559,12 +560,17 @@ internal static class CriticalTableParserSupport
List<ParsedCriticalResult> parsedResults, List<ParsedCriticalResult> parsedResults,
List<string> validationErrors) List<string> validationErrors)
{ {
var sharedLegend = ToSharedAffixLegend(affixLegend);
foreach (var cellEntry in cellEntries) 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 => validationErrors.AddRange(content.ValidationErrors.Select(error =>
$"Cell '{BuildCellIdentifier(cellEntry)}': {error}")); $"Cell '{BuildCellIdentifier(cellEntry)}': {error}"));
var effects = content.Effects.Select(ToImportToolEffect).ToList();
var branches = content.Branches.Select(ToImportToolBranch).ToList();
parsedCells.Add(new ParsedCriticalCellArtifact( parsedCells.Add(new ParsedCriticalCellArtifact(
cellEntry.GroupKey, cellEntry.GroupKey,
cellEntry.RollBandLabel, cellEntry.RollBandLabel,
@@ -574,8 +580,8 @@ internal static class CriticalTableParserSupport
content.RawCellText, content.RawCellText,
content.DescriptionText, content.DescriptionText,
content.RawAffixText, content.RawAffixText,
content.Effects, effects,
content.Branches)); branches));
parsedResults.Add(new ParsedCriticalResult( parsedResults.Add(new ParsedCriticalResult(
cellEntry.GroupKey, cellEntry.GroupKey,
@@ -584,11 +590,44 @@ internal static class CriticalTableParserSupport
content.RawCellText, content.RawCellText,
content.DescriptionText, content.DescriptionText,
content.RawAffixText, content.RawAffixText,
content.Effects, effects,
content.Branches)); 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) => private static string BuildCellIdentifier(ColumnarCellEntry cellEntry) =>
cellEntry.GroupKey is null cellEntry.GroupKey is null
? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}" ? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}"

View File

@@ -12,6 +12,7 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
<ProjectReference Include="..\RolemasterDb.App\RolemasterDb.App.csproj" /> <ProjectReference Include="..\RolemasterDb.App\RolemasterDb.App.csproj" />
</ItemGroup> </ItemGroup>