Add critical cell reparse comparison review

This commit is contained in:
2026-03-15 12:07:50 +01:00
parent 203fed6315
commit b002a94523
11 changed files with 451 additions and 18 deletions

View File

@@ -206,6 +206,7 @@
{
<CriticalCellEditorDialog
Model="editorModel"
ComparisonBaseline="editorComparisonBaselineModel"
IsLoading="isEditorLoading"
IsReparsing="isEditorReparsing"
IsSaving="isEditorSaving"
@@ -237,6 +238,7 @@
private string? editorSaveError;
private int? editingResultId;
private CriticalCellEditorModel? editorModel;
private CriticalCellEditorModel? editorComparisonBaselineModel;
protected override async Task OnInitializedAsync()
{
@@ -336,6 +338,7 @@
editorReparseError = null;
editorSaveError = null;
editorModel = null;
editorComparisonBaselineModel = null;
editingResultId = resultId;
isEditorReparsing = false;
isEditorSaving = false;
@@ -376,6 +379,7 @@
editorSaveError = null;
editingResultId = null;
editorModel = null;
editorComparisonBaselineModel = null;
await InvokeAsync(StateHasChanged);
}
@@ -391,6 +395,7 @@
try
{
var comparisonBaseline = editorModel.Clone();
var response = await LookupService.ReparseCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.ToRequest());
if (response is null)
{
@@ -398,6 +403,7 @@
return;
}
editorComparisonBaselineModel = comparisonBaseline;
editorModel = CriticalCellEditorModel.FromResponse(response);
}
catch (Exception exception)

View File

@@ -73,4 +73,22 @@ public sealed class CriticalBranchEditorModel
effects.Count == 0
? "{}"
: JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() });
public CriticalBranchEditorModel Clone() =>
new()
{
BranchKind = BranchKind,
ConditionKey = ConditionKey,
ConditionText = ConditionText,
ConditionJson = ConditionJson,
RawText = RawText,
DescriptionText = DescriptionText,
RawAffixText = RawAffixText,
ParsedJson = ParsedJson,
SortOrder = SortOrder,
OriginKey = OriginKey,
IsOverridden = IsOverridden,
AreEffectsOverridden = AreEffectsOverridden,
Effects = Effects.Select(effect => effect.Clone()).ToList()
};
}

View File

@@ -74,6 +74,10 @@
{
<p class="muted critical-editor-advanced-hint">@GetParserNoteSummary(Model.ValidationMessages.Count)</p>
}
@if (HasComparisonDifferences(Model, ComparisonBaseline))
{
<p class="muted critical-editor-advanced-hint">Fresh parsing differs from the current edited card. Review Generated Compare before saving if you want to keep the overrides.</p>
}
<div class="field-shell">
<label>Result Text</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" @bind-Value:after="MarkDescriptionOverridden" />
@@ -176,11 +180,55 @@
<section class="critical-editor-section critical-editor-diagnostics-section">
<details class="critical-editor-advanced">
<summary class="critical-editor-advanced-summary">
<span>Advanced Diagnostics</span>
<span class="critical-editor-advanced-meta">@GetAdvancedSummary(Model)</span>
<span>Advanced Review & Diagnostics</span>
<span class="critical-editor-advanced-meta">@GetAdvancedSummary(Model, ComparisonBaseline)</span>
</summary>
<div class="critical-editor-advanced-body">
@if (Model.GeneratedState is not null)
{
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
<strong>Generated Compare</strong>
<p class="muted critical-editor-inline-copy">Compare the current edited card against the fresh parser output from the raw text.</p>
</div>
</div>
<div class="chip-row">
@foreach (var item in GetComparisonSummaryItems(Model, ComparisonBaseline))
{
<span class="chip">@item</span>
}
</div>
<div class="critical-editor-compare-grid">
<CriticalResultPreviewCard
Title="Current Edited Card"
Caption="@GetCurrentComparisonCaption(ComparisonBaseline)"
Description="@GetComparisonSourceModel(Model, ComparisonBaseline).DescriptionText"
Effects="@BuildPreviewEffects(GetComparisonSourceModel(Model, ComparisonBaseline))"
Branches="@BuildPreviewBranches(GetComparisonSourceModel(Model, ComparisonBaseline))" />
<CriticalResultPreviewCard
Title="Generated From Raw Text"
Caption="This is the fresh parser output before override preservation."
Description="@Model.GeneratedState.DescriptionText"
Effects="@Model.GeneratedState.Effects"
Branches="@Model.GeneratedState.Branches"
Notes="@Model.GeneratedState.ValidationMessages" />
@if (ComparisonBaseline is not null)
{
<CriticalResultPreviewCard
Title="Current After Re-Parse"
Caption="This is the merged editor state after keeping the existing overrides."
Description="@Model.DescriptionText"
Effects="@BuildPreviewEffects(Model)"
Branches="@BuildPreviewBranches(Model)" />
}
</div>
</div>
}
<div class="critical-editor-card nested">
<div class="critical-editor-card-header">
<div>
@@ -277,6 +325,9 @@
[Parameter, EditorRequired]
public CriticalCellEditorModel? Model { get; set; }
[Parameter]
public CriticalCellEditorModel? ComparisonBaseline { get; set; }
[Parameter]
public bool IsLoading { get; set; }
@@ -462,10 +513,23 @@
effect.IsOverridden = true;
}
private static string GetAdvancedSummary(CriticalCellEditorModel model)
private static string GetAdvancedSummary(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline)
{
var differenceCount = GetComparisonDifferenceCount(model, comparisonBaseline);
var noteCount = model.ValidationMessages.Count;
return noteCount == 0 ? "Parser metadata and save payload" : $"{noteCount} parser note{(noteCount == 1 ? string.Empty : "s")}";
var segments = new List<string>();
if (differenceCount > 0)
{
segments.Add($"{differenceCount} review change{(differenceCount == 1 ? string.Empty : "s")}");
}
if (noteCount > 0)
{
segments.Add($"{noteCount} parser note{(noteCount == 1 ? string.Empty : "s")}");
}
return segments.Count == 0 ? "Generated compare and diagnostics" : string.Join(" · ", segments);
}
private static string GetParserNoteSummary(int noteCount) =>
@@ -494,6 +558,135 @@
private static string BuildCurrentSavePayloadJson(CriticalCellEditorModel model) =>
JsonSerializer.Serialize(model.ToRequest(), DiagnosticJsonOptions);
private static IReadOnlyList<string> GetComparisonSummaryItems(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline)
{
if (model.GeneratedState is null)
{
return ["Generated comparison is unavailable."];
}
var items = new List<string>();
if (DescriptionDiffers(model, comparisonBaseline))
{
items.Add("Result text differs");
}
if (EffectsDiffer(model, comparisonBaseline))
{
items.Add("Base effects differ");
}
if (BranchesDiffer(model, comparisonBaseline))
{
items.Add("Conditions differ");
}
if (items.Count == 0)
{
items.Add("Current card matches the fresh parse");
}
if (model.GeneratedState.ValidationMessages.Count > 0)
{
items.Add($"{model.GeneratedState.ValidationMessages.Count} parser note{(model.GeneratedState.ValidationMessages.Count == 1 ? string.Empty : "s")}");
}
return items;
}
private static int GetComparisonDifferenceCount(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline)
{
var count = 0;
if (DescriptionDiffers(model, comparisonBaseline))
{
count++;
}
if (EffectsDiffer(model, comparisonBaseline))
{
count++;
}
if (BranchesDiffer(model, comparisonBaseline))
{
count++;
}
return count;
}
private static bool HasComparisonDifferences(CriticalCellEditorModel? model, CriticalCellEditorModel? comparisonBaseline) =>
model is not null && GetComparisonDifferenceCount(model, comparisonBaseline) > 0;
private static bool DescriptionDiffers(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) =>
model.GeneratedState is not null &&
!string.Equals(
NormalizeDisplayText(GetComparisonSourceModel(model, comparisonBaseline).DescriptionText),
NormalizeDisplayText(model.GeneratedState.DescriptionText),
StringComparison.Ordinal);
private static bool EffectsDiffer(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) =>
model.GeneratedState is not null &&
SerializeComparisonValue(BuildPreviewEffects(GetComparisonSourceModel(model, comparisonBaseline)).Select(ProjectEffectForComparison).ToList()) !=
SerializeComparisonValue(model.GeneratedState.Effects.Select(ProjectEffectForComparison).ToList());
private static bool BranchesDiffer(CriticalCellEditorModel model, CriticalCellEditorModel? comparisonBaseline) =>
model.GeneratedState is not null &&
SerializeComparisonValue(BuildPreviewBranches(GetComparisonSourceModel(model, comparisonBaseline)).Select(ProjectBranchForComparison).ToList()) !=
SerializeComparisonValue(model.GeneratedState.Branches.Select(ProjectBranchForComparison).ToList());
private static string NormalizeDisplayText(string? value) =>
value?.Trim() ?? string.Empty;
private static string SerializeComparisonValue<TValue>(TValue value) =>
JsonSerializer.Serialize(value, DiagnosticJsonOptions);
private static object ProjectEffectForComparison(CriticalEffectLookupResponse effect) => new
{
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
SourceText = NormalizeDisplayText(effect.SourceText)
};
private static object ProjectBranchForComparison(CriticalBranchLookupResponse branch) => new
{
Condition = NormalizeDisplayText(branch.ConditionText),
Description = NormalizeDisplayText(branch.Description),
Effects = branch.Effects
.Select(ProjectEffectForComparison)
.ToList()
};
private static CriticalCellEditorModel GetComparisonSourceModel(
CriticalCellEditorModel model,
CriticalCellEditorModel? comparisonBaseline) =>
comparisonBaseline ?? model;
private static string GetCurrentComparisonCaption(CriticalCellEditorModel? comparisonBaseline) =>
comparisonBaseline is null
? "This is the result that will be saved."
: "This is the edited card before the last re-parse.";
private static IReadOnlyList<CriticalEffectLookupResponse> BuildPreviewEffects(CriticalCellEditorModel model) =>
model.Effects
.Select(CreatePreviewEffect)
.ToList();
private static IReadOnlyList<CriticalBranchLookupResponse> BuildPreviewBranches(CriticalCellEditorModel model) =>
model.Branches
.OrderBy(branch => branch.SortOrder)
.Select(CreatePreviewBranch)
.ToList();
private static List<(string Scope, string EffectLabel, string SourceType, string? SourceText)> GetEffectMetadataRows(CriticalCellEditorModel model)
{
var rows = new List<(string Scope, string EffectLabel, string SourceType, string? SourceText)>();
@@ -541,6 +734,22 @@
effect.IsPermanent,
effect.SourceType,
effect.SourceText);
private static CriticalEffectLookupResponse CreatePreviewEffect(CriticalEffectEditorModel effect) =>
CreateBadgeEffect(effect);
private static CriticalBranchLookupResponse CreatePreviewBranch(CriticalBranchEditorModel branch) =>
new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
branch.Effects
.Select(CreatePreviewEffect)
.ToList(),
branch.RawText,
branch.SortOrder);
}
@functions {

View File

@@ -27,6 +27,7 @@ public sealed class CriticalCellEditorModel
public List<string> ValidationMessages { get; set; } = [];
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
public List<CriticalBranchEditorModel> Branches { get; set; } = [];
public CriticalCellComparisonState? GeneratedState { get; set; }
public static CriticalCellEditorModel FromResponse(CriticalCellEditorResponse response) =>
new()
@@ -52,7 +53,8 @@ public sealed class CriticalCellEditorModel
AreBranchesOverridden = response.AreBranchesOverridden,
ValidationMessages = response.ValidationMessages.ToList(),
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList(),
GeneratedState = response.GeneratedState
};
public CriticalCellUpdateRequest ToRequest()
@@ -83,6 +85,34 @@ public sealed class CriticalCellEditorModel
};
}
public CriticalCellEditorModel Clone() =>
new()
{
ResultId = ResultId,
TableSlug = TableSlug,
TableName = TableName,
SourceDocument = SourceDocument,
RollBand = RollBand,
GroupKey = GroupKey,
GroupLabel = GroupLabel,
ColumnKey = ColumnKey,
ColumnLabel = ColumnLabel,
ColumnRole = ColumnRole,
RawCellText = RawCellText,
DescriptionText = DescriptionText,
RawAffixText = RawAffixText,
ParseStatus = ParseStatus,
ParsedJson = ParsedJson,
IsDescriptionOverridden = IsDescriptionOverridden,
IsRawAffixTextOverridden = IsRawAffixTextOverridden,
AreEffectsOverridden = AreEffectsOverridden,
AreBranchesOverridden = AreBranchesOverridden,
ValidationMessages = ValidationMessages.ToList(),
Effects = Effects.Select(effect => effect.Clone()).ToList(),
Branches = Branches.Select(branch => branch.Clone()).ToList(),
GeneratedState = GeneratedState
};
private static string ResolveParseStatus(
IReadOnlyList<CriticalEffectEditorModel> effects,
IReadOnlyList<CriticalBranchEditorModel> branches) =>

View File

@@ -54,4 +54,23 @@ public sealed class CriticalEffectEditorModel
SourceText,
OriginKey,
IsOverridden);
public CriticalEffectEditorModel Clone() =>
new()
{
EffectCode = EffectCode,
Target = Target,
ValueInteger = ValueInteger,
ValueDecimal = ValueDecimal,
ValueExpression = ValueExpression,
DurationRounds = DurationRounds,
PerRound = PerRound,
Modifier = Modifier,
BodyPart = BodyPart,
IsPermanent = IsPermanent,
SourceType = SourceType,
SourceText = SourceText,
OriginKey = OriginKey,
IsOverridden = IsOverridden
};
}

View File

@@ -0,0 +1,57 @@
@using System
@using System.Collections.Generic
@using RolemasterDb.App.Features
<div class="critical-editor-compare-card">
<div class="critical-editor-compare-card-header">
<div>
<strong>@Title</strong>
@if (!string.IsNullOrWhiteSpace(Caption))
{
<p class="muted critical-editor-inline-copy">@Caption</p>
}
</div>
</div>
@if ((Effects?.Count ?? 0) == 0 && (Branches?.Count ?? 0) == 0 && string.IsNullOrWhiteSpace(Description))
{
<p class="muted">No visible result is available for this card.</p>
}
else
{
<CompactCriticalCell
Description="@Description"
Effects="Effects"
Branches="Branches" />
}
@if ((Notes?.Count ?? 0) > 0)
{
<div class="critical-editor-validation-list">
@foreach (var note in Notes!)
{
<p class="critical-editor-validation-item">@note</p>
}
</div>
}
</div>
@code {
[Parameter, EditorRequired]
public string Title { get; set; } = string.Empty;
[Parameter]
public string? Caption { get; set; }
[Parameter, EditorRequired]
public string Description { get; set; } = string.Empty;
[Parameter]
public IReadOnlyList<CriticalEffectLookupResponse>? Effects { get; set; }
[Parameter]
public IReadOnlyList<CriticalBranchLookupResponse>? Branches { get; set; }
[Parameter]
public IReadOnlyList<string>? Notes { get; set; }
}

View File

@@ -0,0 +1,9 @@
using System.Collections.Generic;
namespace RolemasterDb.App.Features;
public sealed record CriticalCellComparisonState(
string DescriptionText,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
IReadOnlyList<CriticalBranchLookupResponse> Branches,
IReadOnlyList<string> ValidationMessages);

View File

@@ -24,4 +24,5 @@ public sealed record CriticalCellEditorResponse(
bool AreBranchesOverridden,
IReadOnlyList<string> ValidationMessages,
IReadOnlyList<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches);
IReadOnlyList<CriticalBranchEditorItem> Branches,
CriticalCellComparisonState? GeneratedState);

View File

@@ -290,7 +290,14 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
cancellationToken);
return result is null ? null : CreateCellEditorResponse(result);
if (result is null)
{
return null;
}
var currentState = CreateCurrentEditorState(result);
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.RawCellText, cancellationToken);
return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
}
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
@@ -322,7 +329,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend);
var generatedState = CreateGeneratedEditorState(content);
var mergedState = MergeGeneratedState(currentState, generatedState);
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors);
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
}
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
@@ -363,7 +370,8 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
await dbContext.SaveChangesAsync(cancellationToken);
return CreateCellEditorResponse(result, request, []);
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.RawCellText, cancellationToken);
return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
}
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
@@ -424,16 +432,11 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.SourceType,
effect.SourceText);
private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result)
{
var state = CreateCurrentEditorState(result);
return CreateCellEditorResponse(result, state, []);
}
private static CriticalCellEditorResponse CreateCellEditorResponse(
CriticalResult result,
CriticalCellUpdateRequest state,
IReadOnlyList<string> validationMessages)
IReadOnlyList<string> validationMessages,
CriticalCellComparisonState? generatedState)
{
var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson();
@@ -459,7 +462,8 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
state.AreBranchesOverridden,
validationMessages.ToList(),
state.Effects.ToList(),
state.Branches.ToList());
state.Branches.ToList(),
generatedState);
}
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
@@ -521,6 +525,18 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.ToList());
}
private static CriticalCellComparisonState CreateComparisonState(SharedParsing.CriticalCellParseContent content) =>
new(
content.DescriptionText,
content.Effects
.Select(CreateEffectLookupResponse)
.ToList(),
content.Branches
.OrderBy(branch => branch.SortOrder)
.Select(CreateBranchLookupResponse)
.ToList(),
content.ValidationErrors.ToList());
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
new(
effect.EffectCode,
@@ -806,6 +822,33 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
SourceText = NormalizeOptionalText(effect.SourceText)
};
private static CriticalEffectLookupResponse CreateEffectLookupResponse(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 CriticalBranchLookupResponse CreateBranchLookupResponse(SharedParsing.ParsedCriticalBranch branch) =>
new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
branch.Effects
.Select(CreateEffectLookupResponse)
.ToList(),
branch.RawText,
branch.SortOrder);
private static string ResolveParseStatus(
IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects,
IReadOnlyList<SharedParsing.ParsedCriticalBranch> branches) =>
@@ -876,6 +919,16 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
supportsPowerPointModifier);
}
private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(
RolemasterDbContext dbContext,
int tableId,
string rawCellText,
CancellationToken cancellationToken)
{
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken);
return SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend);
}
private static bool IsLegendSymbolEffectCode(string effectCode) =>
effectCode is CriticalEffectCodes.MustParryRounds
or CriticalEffectCodes.NoParryRounds

View File

@@ -834,6 +834,25 @@ textarea {
margin: 0;
}
.critical-editor-compare-grid {
display: grid;
gap: 0.85rem;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
}
.critical-editor-compare-card {
display: grid;
gap: 0.75rem;
padding: 0.9rem;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(127, 96, 55, 0.12);
}
.critical-editor-compare-card .critical-cell {
min-height: 100%;
}
.critical-editor-inline-list {
display: grid;
gap: 0.55rem;