From 8b345a7c377c8c9df71440af45d6fba4847831da Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 15 Mar 2026 11:40:12 +0100 Subject: [PATCH] Implement critical editor override state --- docs/player_gm_ux_redesign_plan.md | 11 + .../Components/Pages/Api.razor | 30 +- .../Components/Pages/Tables.razor | 2 +- .../Shared/CriticalBranchEditorModel.cs | 9 + .../Shared/CriticalCellEditorDialog.razor | 58 +++- .../Shared/CriticalCellEditorModel.cs | 23 +- .../Shared/CriticalEffectEditorModel.cs | 10 +- .../Features/CriticalBranchEditorItem.cs | 3 + .../Features/CriticalCellEditorResponse.cs | 4 + .../Features/CriticalCellEditorSnapshot.cs | 55 ++++ .../Features/CriticalCellReparseRequest.cs | 2 +- .../Features/CriticalCellUpdateRequest.cs | 4 + .../Features/CriticalEffectEditorItem.cs | 4 +- .../Features/LookupService.cs | 298 ++++++++++++++---- src/RolemasterDb.App/Program.cs | 2 +- .../CriticalCellReparseIntegrationTests.cs | 276 ++++++++++++---- 16 files changed, 650 insertions(+), 141 deletions(-) create mode 100644 src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs diff --git a/docs/player_gm_ux_redesign_plan.md b/docs/player_gm_ux_redesign_plan.md index c69bef7..b4e67bd 100644 --- a/docs/player_gm_ux_redesign_plan.md +++ b/docs/player_gm_ux_redesign_plan.md @@ -551,6 +551,17 @@ Acceptance criteria: ### Phase 3: Generated versus overridden state model +Status: + +- implemented in the web app on March 15, 2026 + +Implemented model: + +- result-level override flags separate generated description and collection state from manual edits +- effect rows and condition rows carry explicit origin keys plus override markers +- re-parse now merges generated parser output with the current override state instead of replacing the whole editor payload +- saved editor state is persisted so later edit sessions keep the same generated-versus-overridden boundaries + Scope: - explicitly track which values are parser-generated and which values were manually overridden diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index 12868c9..77f222f 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -65,7 +65,11 @@ "descriptionText": "Current curated prose", "rawAffixText": "+8H - 2S", "parseStatus": "verified", - "parsedJson": "{}", + "parsedJson": "{\"version\":1,\"isDescriptionOverridden\":false,\"isRawAffixTextOverridden\":false,\"areEffectsOverridden\":false,\"areBranchesOverridden\":false,\"effects\":[],\"branches\":[]}", + "isDescriptionOverridden": false, + "isRawAffixTextOverridden": false, + "areEffectsOverridden": false, + "areBranchesOverridden": false, "validationMessages": [], "effects": [], "branches": [] @@ -77,9 +81,21 @@

Cell re-parse

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

{
-  "rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside."
+  "currentState": {
+    "rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside.",
+    "descriptionText": "Curated prose",
+    "rawAffixText": "+8H",
+    "parseStatus": "partial",
+    "parsedJson": "{}",
+    "isDescriptionOverridden": true,
+    "isRawAffixTextOverridden": false,
+    "areEffectsOverridden": false,
+    "areBranchesOverridden": false,
+    "effects": [],
+    "branches": []
+  }
 }
-

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

+

Re-runs the shared single-cell parser, merges the generated result with the current override state, and returns the refreshed editor payload without saving changes.

@@ -91,6 +107,10 @@ "rawAffixText": "+10H - must parry 2 rnds", "parseStatus": "manually_curated", "parsedJson": "{\"reviewed\":true}", + "isDescriptionOverridden": true, + "isRawAffixTextOverridden": false, + "areEffectsOverridden": false, + "areBranchesOverridden": false, "effects": [ { "effectCode": "direct_hits", @@ -104,7 +124,9 @@ "bodyPart": null, "isPermanent": false, "sourceType": "symbol", - "sourceText": "+10H" + "sourceText": "+10H", + "originKey": "base:direct_hits:1", + "isOverridden": true } ], "branches": [] diff --git a/src/RolemasterDb.App/Components/Pages/Tables.razor b/src/RolemasterDb.App/Components/Pages/Tables.razor index c45e160..4a53635 100644 --- a/src/RolemasterDb.App/Components/Pages/Tables.razor +++ b/src/RolemasterDb.App/Components/Pages/Tables.razor @@ -391,7 +391,7 @@ try { - var response = await LookupService.ReparseCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.RawCellText); + var response = await LookupService.ReparseCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.ToRequest()); if (response is null) { editorReparseError = "The selected cell could not be re-parsed."; diff --git a/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs index f1f9cfe..13c461f 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalBranchEditorModel.cs @@ -14,6 +14,9 @@ public sealed class CriticalBranchEditorModel public string? RawAffixText { get; set; } public string ParsedJson { get; set; } = "{}"; public int SortOrder { get; set; } + public string? OriginKey { get; set; } + public bool IsOverridden { get; set; } + public bool AreEffectsOverridden { get; set; } public List Effects { get; set; } = []; public static CriticalBranchEditorModel FromItem(CriticalBranchEditorItem item) => @@ -28,6 +31,9 @@ public sealed class CriticalBranchEditorModel RawAffixText = item.RawAffixText, ParsedJson = item.ParsedJson, SortOrder = item.SortOrder, + OriginKey = item.OriginKey, + IsOverridden = item.IsOverridden, + AreEffectsOverridden = item.AreEffectsOverridden, Effects = item.Effects.Select(CriticalEffectEditorModel.FromItem).ToList() }; @@ -42,6 +48,9 @@ public sealed class CriticalBranchEditorModel RawAffixText, SerializeParsedEffects(Effects), SortOrder, + OriginKey, + IsOverridden, + AreEffectsOverridden, Effects.Select(effect => effect.ToItem()).ToList()); private string BuildRawText() diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor index 106d175..d276dff 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorDialog.razor @@ -76,7 +76,7 @@ }
- +
@@ -134,11 +134,11 @@
- +
- +
@@ -321,7 +321,13 @@ private void AddBaseEffect() { - Model?.Effects.Add(CreateDefaultEffectModel()); + if (Model is null) + { + return; + } + + Model.AreEffectsOverridden = true; + Model.Effects.Add(CreateDefaultEffectModel()); } private void RemoveBaseEffect(int index) @@ -331,6 +337,7 @@ return; } + Model.AreEffectsOverridden = true; Model.Effects.RemoveAt(index); } @@ -344,8 +351,10 @@ Model.Branches.Add(new CriticalBranchEditorModel { ConditionText = $"Condition {Model.Branches.Count + 1}", - SortOrder = Model.Branches.Count + 1 + SortOrder = Model.Branches.Count + 1, + IsOverridden = true }); + Model.AreBranchesOverridden = true; } private void RemoveBranch(int index) @@ -355,11 +364,13 @@ return; } + Model.AreBranchesOverridden = true; Model.Branches.RemoveAt(index); } private static void AddBranchEffect(CriticalBranchEditorModel branch) { + branch.AreEffectsOverridden = true; branch.Effects.Add(CreateDefaultEffectModel()); } @@ -370,6 +381,7 @@ return; } + branch.AreEffectsOverridden = true; branch.Effects.RemoveAt(index); } @@ -377,7 +389,8 @@ new() { EffectCode = CriticalEffectCodes.DirectHits, - SourceType = "symbol" + SourceType = "symbol", + IsOverridden = true }; private static string GetBranchTitle(CriticalBranchEditorModel branch, int index) => @@ -428,6 +441,25 @@ effect.IsPermanent = false; effect.SourceText = null; effect.SourceType = AffixDisplayMap.TryGet(effect.EffectCode, out _) ? "symbol" : "manual"; + effect.IsOverridden = true; + } + + private void MarkDescriptionOverridden() + { + if (Model is not null) + { + Model.IsDescriptionOverridden = true; + } + } + + private static void MarkBranchOverridden(CriticalBranchEditorModel branch) + { + branch.IsOverridden = true; + } + + private static void MarkEffectOverridden(CriticalEffectEditorModel effect) + { + effect.IsOverridden = true; } private static string GetAdvancedSummary(CriticalCellEditorModel model) @@ -531,7 +563,7 @@ {
- +
} @@ -543,30 +575,30 @@ { case CriticalEffectCodes.DirectHits: - + break; case CriticalEffectCodes.StunnedRounds: case CriticalEffectCodes.MustParryRounds: case CriticalEffectCodes.NoParryRounds: - + break; case CriticalEffectCodes.BleedPerRound: - + break; case CriticalEffectCodes.FoePenalty: case CriticalEffectCodes.AttackerBonusNextRound: - + break; case CriticalEffectCodes.PowerPointModifier: - + break; default: - + break; } ; diff --git a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs index 184f852..2f895a4 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalCellEditorModel.cs @@ -20,6 +20,10 @@ public sealed class CriticalCellEditorModel public string? RawAffixText { get; set; } public string ParseStatus { get; set; } = string.Empty; public string ParsedJson { get; set; } = "{}"; + public bool IsDescriptionOverridden { get; set; } + public bool IsRawAffixTextOverridden { get; set; } + public bool AreEffectsOverridden { get; set; } + public bool AreBranchesOverridden { get; set; } public List ValidationMessages { get; set; } = []; public List Effects { get; set; } = []; public List Branches { get; set; } = []; @@ -42,18 +46,27 @@ public sealed class CriticalCellEditorModel RawAffixText = response.RawAffixText, ParseStatus = response.ParseStatus, ParsedJson = response.ParsedJson, + IsDescriptionOverridden = response.IsDescriptionOverridden, + IsRawAffixTextOverridden = response.IsRawAffixTextOverridden, + AreEffectsOverridden = response.AreEffectsOverridden, + AreBranchesOverridden = response.AreBranchesOverridden, ValidationMessages = response.ValidationMessages.ToList(), Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(), Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList() }; - public CriticalCellUpdateRequest ToRequest() => - new( + public CriticalCellUpdateRequest ToRequest() + { + var request = new CriticalCellUpdateRequest( RawCellText, DescriptionText, RawAffixText, ResolveParseStatus(Effects, Branches), SerializeParsedEffects(Effects), + IsDescriptionOverridden, + IsRawAffixTextOverridden, + AreEffectsOverridden, + AreBranchesOverridden, Effects.Select(effect => effect.ToItem()).ToList(), Branches .OrderBy(branch => branch.SortOrder) @@ -64,6 +77,12 @@ public sealed class CriticalCellEditorModel }) .ToList()); + return request with + { + ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson() + }; + } + private static string ResolveParseStatus( IReadOnlyList effects, IReadOnlyList branches) => diff --git a/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs b/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs index e402100..ebc0396 100644 --- a/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs +++ b/src/RolemasterDb.App/Components/Shared/CriticalEffectEditorModel.cs @@ -16,6 +16,8 @@ public sealed class CriticalEffectEditorModel public bool IsPermanent { get; set; } public string SourceType { get; set; } = "symbol"; public string? SourceText { get; set; } + public string? OriginKey { get; set; } + public bool IsOverridden { get; set; } public static CriticalEffectEditorModel FromItem(CriticalEffectEditorItem item) => new() @@ -31,7 +33,9 @@ public sealed class CriticalEffectEditorModel BodyPart = item.BodyPart, IsPermanent = item.IsPermanent, SourceType = item.SourceType, - SourceText = item.SourceText + SourceText = item.SourceText, + OriginKey = item.OriginKey, + IsOverridden = item.IsOverridden }; public CriticalEffectEditorItem ToItem() => @@ -47,5 +51,7 @@ public sealed class CriticalEffectEditorModel BodyPart, IsPermanent, SourceType, - SourceText); + SourceText, + OriginKey, + IsOverridden); } diff --git a/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs b/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs index 5b7c457..75e2052 100644 --- a/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs +++ b/src/RolemasterDb.App/Features/CriticalBranchEditorItem.cs @@ -12,4 +12,7 @@ public sealed record CriticalBranchEditorItem( string? RawAffixText, string ParsedJson, int SortOrder, + string? OriginKey, + bool IsOverridden, + bool AreEffectsOverridden, IReadOnlyList Effects); diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs index 598a00c..ff893c6 100644 --- a/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs +++ b/src/RolemasterDb.App/Features/CriticalCellEditorResponse.cs @@ -18,6 +18,10 @@ public sealed record CriticalCellEditorResponse( string? RawAffixText, string ParseStatus, string ParsedJson, + bool IsDescriptionOverridden, + bool IsRawAffixTextOverridden, + bool AreEffectsOverridden, + bool AreBranchesOverridden, IReadOnlyList ValidationMessages, IReadOnlyList Effects, IReadOnlyList Branches); diff --git a/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs b/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs new file mode 100644 index 0000000..146d0ae --- /dev/null +++ b/src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs @@ -0,0 +1,55 @@ +using System.Text.Json; + +namespace RolemasterDb.App.Features; + +public sealed record CriticalCellEditorSnapshot( + int Version, + bool IsDescriptionOverridden, + bool IsRawAffixTextOverridden, + bool AreEffectsOverridden, + bool AreBranchesOverridden, + IReadOnlyList Effects, + IReadOnlyList Branches) +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public static CriticalCellEditorSnapshot FromRequest(CriticalCellUpdateRequest request) => + new( + 1, + request.IsDescriptionOverridden, + request.IsRawAffixTextOverridden, + request.AreEffectsOverridden, + request.AreBranchesOverridden, + request.Effects, + request.Branches); + + public string ToJson() => + JsonSerializer.Serialize(this, JsonOptions); + + public static bool TryParse(string json, out CriticalCellEditorSnapshot? snapshot) + { + snapshot = null; + if (string.IsNullOrWhiteSpace(json)) + { + return false; + } + + try + { + using var document = JsonDocument.Parse(json); + if (!document.RootElement.TryGetProperty("version", out var versionElement) || + versionElement.ValueKind != JsonValueKind.Number || + versionElement.GetInt32() != 1) + { + return false; + } + + snapshot = JsonSerializer.Deserialize(json, JsonOptions); + return snapshot is not null; + } + catch (JsonException) + { + return false; + } + } +} diff --git a/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs b/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs index 9a5f991..55f2473 100644 --- a/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs +++ b/src/RolemasterDb.App/Features/CriticalCellReparseRequest.cs @@ -1,4 +1,4 @@ namespace RolemasterDb.App.Features; public sealed record CriticalCellReparseRequest( - string RawCellText); + CriticalCellUpdateRequest CurrentState); diff --git a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs index ffd63bf..cc05fd9 100644 --- a/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs +++ b/src/RolemasterDb.App/Features/CriticalCellUpdateRequest.cs @@ -8,5 +8,9 @@ public sealed record CriticalCellUpdateRequest( string? RawAffixText, string ParseStatus, string ParsedJson, + bool IsDescriptionOverridden, + bool IsRawAffixTextOverridden, + bool AreEffectsOverridden, + bool AreBranchesOverridden, IReadOnlyList Effects, IReadOnlyList Branches); diff --git a/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs b/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs index 6195312..4460b3c 100644 --- a/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs +++ b/src/RolemasterDb.App/Features/CriticalEffectEditorItem.cs @@ -12,4 +12,6 @@ public sealed record CriticalEffectEditorItem( string? BodyPart, bool IsPermanent, string SourceType, - string? SourceText); + string? SourceText, + string? OriginKey, + bool IsOverridden); diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index 05333be..72d1c57 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -296,7 +296,7 @@ public sealed class LookupService(IDbContextFactory dbConte public async Task ReparseCriticalCellAsync( string slug, int resultId, - string rawCellText, + CriticalCellUpdateRequest currentState, CancellationToken cancellationToken = default) { await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); @@ -319,8 +319,10 @@ public sealed class LookupService(IDbContextFactory dbConte } var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken); - var content = SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend); - return CreateCellEditorResponse(result, content); + var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend); + var generatedState = CreateGeneratedEditorState(content); + var mergedState = MergeGeneratedState(currentState, generatedState); + return CreateCellEditorResponse(result, mergedState, content.ValidationErrors); } public async Task UpdateCriticalCellAsync( @@ -354,14 +356,14 @@ public sealed class LookupService(IDbContextFactory dbConte result.DescriptionText = request.DescriptionText.Trim(); result.RawAffixText = NormalizeOptionalText(request.RawAffixText); result.ParseStatus = request.ParseStatus.Trim(); - result.ParsedJson = string.IsNullOrWhiteSpace(request.ParsedJson) ? "{}" : request.ParsedJson.Trim(); + result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson(); ReplaceBaseEffects(dbContext, result, request.Effects); ReplaceBranches(dbContext, result, request.Branches); await dbContext.SaveChangesAsync(cancellationToken); - return CreateCellEditorResponse(result); + return CreateCellEditorResponse(result, request, []); } private static IReadOnlyList BuildLegend(IReadOnlyList cells) @@ -422,37 +424,20 @@ public sealed class LookupService(IDbContextFactory dbConte effect.SourceType, effect.SourceText); - private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result) => - 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, - result.RawCellText, - result.DescriptionText, - result.RawAffixText, - result.ParseStatus, - result.ParsedJson, - [], - result.Effects - .OrderBy(effect => effect.Id) - .Select(CreateEffectEditorItem) - .ToList(), - result.Branches - .OrderBy(branch => branch.SortOrder) - .Select(CreateBranchEditorItem) - .ToList()); + private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result) + { + var state = CreateCurrentEditorState(result); + return CreateCellEditorResponse(result, state, []); + } private static CriticalCellEditorResponse CreateCellEditorResponse( CriticalResult result, - SharedParsing.CriticalCellParseContent content) => - new( + CriticalCellUpdateRequest state, + IReadOnlyList validationMessages) + { + var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson(); + + return new( result.Id, result.CriticalTable.Slug, result.CriticalTable.DisplayName, @@ -463,19 +448,19 @@ public sealed class LookupService(IDbContextFactory dbConte 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()); + state.RawCellText, + state.DescriptionText, + state.RawAffixText, + state.ParseStatus, + snapshotJson, + state.IsDescriptionOverridden, + state.IsRawAffixTextOverridden, + state.AreEffectsOverridden, + state.AreBranchesOverridden, + validationMessages.ToList(), + state.Effects.ToList(), + state.Branches.ToList()); + } private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) => new( @@ -491,8 +476,11 @@ public sealed class LookupService(IDbContextFactory dbConte branch.RawText, branch.SortOrder); - private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch) => - new( + private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch, int branchIndex) + { + var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText); + + return new( branch.BranchKind, branch.ConditionKey, branch.ConditionText, @@ -502,13 +490,20 @@ public sealed class LookupService(IDbContextFactory dbConte branch.RawAffixText, branch.ParsedJson, branch.SortOrder, + originKey, + false, + false, (branch.Effects ?? Enumerable.Empty()) .OrderBy(effect => effect.Id) - .Select(CreateEffectEditorItem) + .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode))) .ToList()); + } - private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch) => - new( + private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch, int branchIndex) + { + var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText); + + return new( branch.BranchKind, branch.ConditionKey, branch.ConditionText, @@ -518,11 +513,15 @@ public sealed class LookupService(IDbContextFactory dbConte branch.RawAffixText, SerializeParsedEffects(branch.Effects), branch.SortOrder, + originKey, + false, + false, branch.Effects - .Select(CreateEffectEditorItem) + .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode))) .ToList()); + } - private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) => + private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) => new( effect.EffectCode, effect.Target, @@ -535,9 +534,11 @@ public sealed class LookupService(IDbContextFactory dbConte effect.BodyPart, effect.IsPermanent, effect.SourceType, - effect.SourceText); + effect.SourceText, + originKey, + false); - private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect) => + private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect, string originKey) => new( effect.EffectCode, effect.Target, @@ -550,7 +551,192 @@ public sealed class LookupService(IDbContextFactory dbConte effect.BodyPart, effect.IsPermanent, effect.SourceType, - effect.SourceText); + effect.SourceText, + originKey, + false); + + private static CriticalCellUpdateRequest CreateCurrentEditorState(CriticalResult result) + { + if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null) + { + return new CriticalCellUpdateRequest( + result.RawCellText, + result.DescriptionText, + result.RawAffixText, + result.ParseStatus, + result.ParsedJson, + snapshot.IsDescriptionOverridden, + snapshot.IsRawAffixTextOverridden, + snapshot.AreEffectsOverridden, + snapshot.AreBranchesOverridden, + snapshot.Effects.ToList(), + snapshot.Branches.ToList()); + } + + var effects = result.Effects + .OrderBy(effect => effect.Id) + .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode))) + .ToList(); + var branches = result.Branches + .OrderBy(branch => branch.SortOrder) + .Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex)) + .ToList(); + + return new CriticalCellUpdateRequest( + result.RawCellText, + result.DescriptionText, + result.RawAffixText, + result.ParseStatus, + result.ParsedJson, + false, + false, + false, + false, + effects, + branches); + } + + private static CriticalCellUpdateRequest CreateGeneratedEditorState(SharedParsing.CriticalCellParseContent content) + { + var effects = content.Effects + .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode))) + .ToList(); + var branches = content.Branches + .OrderBy(branch => branch.SortOrder) + .Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex)) + .ToList(); + + return new CriticalCellUpdateRequest( + content.RawCellText, + content.DescriptionText, + content.RawAffixText, + ResolveParseStatus(content.Effects, content.Branches), + SerializeParsedEffects(content.Effects), + false, + false, + false, + false, + effects, + branches); + } + + private static CriticalCellUpdateRequest MergeGeneratedState( + CriticalCellUpdateRequest currentState, + CriticalCellUpdateRequest generatedState) => + new( + currentState.RawCellText, + currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText, + currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText, + generatedState.ParseStatus, + generatedState.ParsedJson, + currentState.IsDescriptionOverridden, + currentState.IsRawAffixTextOverridden, + currentState.AreEffectsOverridden, + currentState.AreBranchesOverridden, + currentState.AreEffectsOverridden + ? currentState.Effects.ToList() + : MergeEffectItems(currentState.Effects, generatedState.Effects), + currentState.AreBranchesOverridden + ? currentState.Branches + .OrderBy(branch => branch.SortOrder) + .ToList() + : MergeBranchItems(currentState.Branches, generatedState.Branches)); + + private static List MergeBranchItems( + IReadOnlyList currentBranches, + IReadOnlyList generatedBranches) + { + var currentByOrigin = currentBranches + .Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey)) + .ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal); + var merged = new List(generatedBranches.Count); + var matchedOrigins = new HashSet(StringComparer.Ordinal); + + foreach (var generatedBranch in generatedBranches) + { + if (generatedBranch.OriginKey is not null && + currentByOrigin.TryGetValue(generatedBranch.OriginKey, out var currentBranch)) + { + matchedOrigins.Add(generatedBranch.OriginKey); + + if (currentBranch.IsOverridden) + { + merged.Add(currentBranch); + continue; + } + + merged.Add(generatedBranch with + { + AreEffectsOverridden = currentBranch.AreEffectsOverridden, + Effects = currentBranch.AreEffectsOverridden + ? currentBranch.Effects.ToList() + : MergeEffectItems(currentBranch.Effects, generatedBranch.Effects) + }); + continue; + } + + merged.Add(generatedBranch); + } + + merged.AddRange(currentBranches.Where(branch => + branch.IsOverridden && + branch.OriginKey is not null && + !matchedOrigins.Contains(branch.OriginKey))); + + return merged; + } + + private static List MergeEffectItems( + IReadOnlyList currentEffects, + IReadOnlyList generatedEffects) + { + var currentByOrigin = currentEffects + .Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey)) + .ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal); + var merged = new List(generatedEffects.Count); + var matchedOrigins = new HashSet(StringComparer.Ordinal); + + foreach (var generatedEffect in generatedEffects) + { + if (generatedEffect.OriginKey is not null && + currentByOrigin.TryGetValue(generatedEffect.OriginKey, out var currentEffect)) + { + matchedOrigins.Add(generatedEffect.OriginKey); + merged.Add(currentEffect.IsOverridden ? currentEffect : generatedEffect); + continue; + } + + merged.Add(generatedEffect); + } + + merged.AddRange(currentEffects.Where(effect => + effect.IsOverridden && + effect.OriginKey is not null && + !matchedOrigins.Contains(effect.OriginKey))); + + return merged; + } + + private static string CreateBaseEffectOriginKey(int effectIndex, string effectCode) => + $"base:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}"; + + private static string CreateBranchOriginKey(int branchIndex, string branchKind, string? conditionKey, string conditionText) => + $"branch:{NormalizeOriginSegment(branchKind)}:{NormalizeOriginSegment(conditionKey ?? conditionText)}:{branchIndex + 1}"; + + private static string CreateBranchEffectOriginKey(string branchOriginKey, int effectIndex, string effectCode) => + $"{branchOriginKey}:effect:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}"; + + private static string NormalizeOriginSegment(string value) + { + var normalized = new string(value + .Trim() + .ToLowerInvariant() + .Select(character => char.IsLetterOrDigit(character) ? character : '_') + .ToArray()) + .Trim('_'); + + return string.IsNullOrWhiteSpace(normalized) ? "empty" : normalized; + } private static void ReplaceBaseEffects( RolemasterDbContext dbContext, diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index 3cb6512..2184265 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -42,7 +42,7 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i }); 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); + var result = await lookupService.ReparseCriticalCellAsync(slug, resultId, request.CurrentState, 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) => diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs index 495c5a3..46fbdcf 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs @@ -41,72 +41,15 @@ public sealed class CriticalCellReparseIntegrationTests 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(); - } + await SeedSlashResultAsync(databasePath); 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 resultId = await GetSlashResultIdAsync(databasePath); var response = await lookupService.ReparseCriticalCellAsync( "slash", resultId, - "Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫"); + CreateEditorRequest("Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫", "Old description")); Assert.NotNull(response); Assert.Equal("Strike to thigh.", response!.DescriptionText); @@ -118,6 +61,219 @@ public sealed class CriticalCellReparseIntegrationTests Assert.Empty(response.ValidationMessages); } + [Fact] + public async Task Lookup_service_reparse_preserves_overridden_description_and_effect_value() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-overrides-{Guid.NewGuid():N}.db"); + await SeedSlashResultAsync(databasePath); + + var lookupService = new LookupService(CreateDbContextFactory(databasePath)); + var resultId = await GetSlashResultIdAsync(databasePath); + + var response = await lookupService.ReparseCriticalCellAsync( + "slash", + resultId, + CreateEditorRequest( + "Strike to thigh.\n+10H", + "Curated thigh strike.", + isDescriptionOverridden: true, + effects: + [ + new CriticalEffectEditorItem( + AppCriticalEffectCodes.DirectHits, + null, + 12, + null, + null, + null, + null, + null, + null, + false, + "manual", + "+12H", + "base:direct_hits:1", + true) + ])); + + Assert.NotNull(response); + Assert.Equal("Curated thigh strike.", response!.DescriptionText); + Assert.Single(response.Effects); + Assert.Equal(12, response.Effects[0].ValueInteger); + Assert.True(response.IsDescriptionOverridden); + Assert.True(response.Effects[0].IsOverridden); + } + + [Fact] + public async Task Lookup_service_reparse_preserves_manual_branch_collection_when_overridden() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-branches-{Guid.NewGuid():N}.db"); + await SeedSlashResultAsync(databasePath); + + var lookupService = new LookupService(CreateDbContextFactory(databasePath)); + var resultId = await GetSlashResultIdAsync(databasePath); + + var response = await lookupService.ReparseCriticalCellAsync( + "slash", + resultId, + CreateEditorRequest( + "Strike to thigh.\n+10H", + "Strike to thigh.", + areBranchesOverridden: true, + branches: + [ + new CriticalBranchEditorItem( + "conditional", + "with_shield", + "With shield", + "{}", + "With shield: glancing blow.", + "Glancing blow.", + null, + "{}", + 1, + null, + true, + false, + []) + ])); + + Assert.NotNull(response); + Assert.Single(response!.Branches); + Assert.Equal("with_shield", response.Branches[0].ConditionKey); + Assert.True(response.AreBranchesOverridden); + } + + [Fact] + public async Task Lookup_service_save_and_load_preserves_override_metadata() + { + var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-save-load-{Guid.NewGuid():N}.db"); + await SeedSlashResultAsync(databasePath); + + var lookupService = new LookupService(CreateDbContextFactory(databasePath)); + var resultId = await GetSlashResultIdAsync(databasePath); + var request = CreateEditorRequest( + "Strike to thigh.\n+10H", + "Curated thigh strike.", + isDescriptionOverridden: true, + effects: + [ + new CriticalEffectEditorItem( + AppCriticalEffectCodes.DirectHits, + null, + 12, + null, + null, + null, + null, + null, + null, + false, + "manual", + "+12H", + "base:direct_hits:1", + true) + ]); + + var saveResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, request); + var loadResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); + + Assert.NotNull(saveResponse); + Assert.NotNull(loadResponse); + Assert.True(loadResponse!.IsDescriptionOverridden); + Assert.Single(loadResponse.Effects); + Assert.Equal("base:direct_hits:1", loadResponse.Effects[0].OriginKey); + Assert.True(loadResponse.Effects[0].IsOverridden); + Assert.Contains("\"version\":1", loadResponse.ParsedJson, StringComparison.Ordinal); + } + + private static CriticalCellUpdateRequest CreateEditorRequest( + string rawCellText, + string descriptionText, + bool isDescriptionOverridden = false, + bool isRawAffixTextOverridden = false, + bool areEffectsOverridden = false, + bool areBranchesOverridden = false, + string? rawAffixText = null, + IReadOnlyList? effects = null, + IReadOnlyList? branches = null) => + new( + rawCellText, + descriptionText, + rawAffixText, + "partial", + "{}", + isDescriptionOverridden, + isRawAffixTextOverridden, + areEffectsOverridden, + areBranchesOverridden, + effects ?? [], + branches ?? []); + + private static async Task SeedSlashResultAsync(string databasePath) + { + 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(); + } + + private static async Task GetSlashResultIdAsync(string databasePath) + { + await using var verifyContext = CreateDbContext(databasePath); + return await verifyContext.CriticalResults + .Where(item => item.CriticalTable.Slug == "slash") + .Select(item => item.Id) + .SingleAsync(); + } + private static RolemasterDbContext CreateDbContext(string databasePath) { var options = new DbContextOptionsBuilder()