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 @@
}
Result Text
-
+
@@ -134,11 +134,11 @@
Condition
-
+ MarkBranchOverridden(branch)" />
Outcome Text
-
+ MarkBranchOverridden(branch)" />
@@ -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:
Hits
-
+ MarkEffectOverridden(effect)" />
break;
case CriticalEffectCodes.StunnedRounds:
case CriticalEffectCodes.MustParryRounds:
case CriticalEffectCodes.NoParryRounds:
Rounds
-
+ MarkEffectOverridden(effect)" />
break;
case CriticalEffectCodes.BleedPerRound:
Bleed / Round
-
+ MarkEffectOverridden(effect)" />
break;
case CriticalEffectCodes.FoePenalty:
case CriticalEffectCodes.AttackerBonusNextRound:
Modifier
-
+ MarkEffectOverridden(effect)" />
break;
case CriticalEffectCodes.PowerPointModifier:
Expression
-
+ MarkEffectOverridden(effect)" />
break;
default:
Display Text
-
+ MarkEffectOverridden(effect)" />
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()