Implement critical editor override state

This commit is contained in:
2026-03-15 11:40:12 +01:00
parent e9e386aa6c
commit 8b345a7c37
16 changed files with 650 additions and 141 deletions

View File

@@ -551,6 +551,17 @@ Acceptance criteria:
### Phase 3: Generated versus overridden state model ### 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: Scope:
- explicitly track which values are parser-generated and which values were manually overridden - explicitly track which values are parser-generated and which values were manually overridden

View File

@@ -65,7 +65,11 @@
"descriptionText": "Current curated prose", "descriptionText": "Current curated prose",
"rawAffixText": "+8H - 2S", "rawAffixText": "+8H - 2S",
"parseStatus": "verified", "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": [], "validationMessages": [],
"effects": [], "effects": [],
"branches": [] "branches": []
@@ -77,9 +81,21 @@
<h2 class="panel-title">Cell re-parse</h2> <h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p> <p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
<pre class="code-block">{ <pre class="code-block">{
"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": []
}
}</pre> }</pre>
<p class="panel-copy">Re-runs the shared single-cell parser and returns a refreshed editor payload without saving changes.</p> <p class="panel-copy">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.</p>
</section> </section>
<section class="panel"> <section class="panel">
@@ -91,6 +107,10 @@
"rawAffixText": "+10H - must parry 2 rnds", "rawAffixText": "+10H - must parry 2 rnds",
"parseStatus": "manually_curated", "parseStatus": "manually_curated",
"parsedJson": "{\"reviewed\":true}", "parsedJson": "{\"reviewed\":true}",
"isDescriptionOverridden": true,
"isRawAffixTextOverridden": false,
"areEffectsOverridden": false,
"areBranchesOverridden": false,
"effects": [ "effects": [
{ {
"effectCode": "direct_hits", "effectCode": "direct_hits",
@@ -104,7 +124,9 @@
"bodyPart": null, "bodyPart": null,
"isPermanent": false, "isPermanent": false,
"sourceType": "symbol", "sourceType": "symbol",
"sourceText": "+10H" "sourceText": "+10H",
"originKey": "base:direct_hits:1",
"isOverridden": true
} }
], ],
"branches": [] "branches": []

View File

@@ -391,7 +391,7 @@
try 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) if (response is null)
{ {
editorReparseError = "The selected cell could not be re-parsed."; editorReparseError = "The selected cell could not be re-parsed.";

View File

@@ -14,6 +14,9 @@ public sealed class CriticalBranchEditorModel
public string? RawAffixText { get; set; } public string? RawAffixText { get; set; }
public string ParsedJson { get; set; } = "{}"; public string ParsedJson { get; set; } = "{}";
public int SortOrder { get; set; } public int SortOrder { get; set; }
public string? OriginKey { get; set; }
public bool IsOverridden { get; set; }
public bool AreEffectsOverridden { get; set; }
public List<CriticalEffectEditorModel> Effects { get; set; } = []; public List<CriticalEffectEditorModel> Effects { get; set; } = [];
public static CriticalBranchEditorModel FromItem(CriticalBranchEditorItem item) => public static CriticalBranchEditorModel FromItem(CriticalBranchEditorItem item) =>
@@ -28,6 +31,9 @@ public sealed class CriticalBranchEditorModel
RawAffixText = item.RawAffixText, RawAffixText = item.RawAffixText,
ParsedJson = item.ParsedJson, ParsedJson = item.ParsedJson,
SortOrder = item.SortOrder, SortOrder = item.SortOrder,
OriginKey = item.OriginKey,
IsOverridden = item.IsOverridden,
AreEffectsOverridden = item.AreEffectsOverridden,
Effects = item.Effects.Select(CriticalEffectEditorModel.FromItem).ToList() Effects = item.Effects.Select(CriticalEffectEditorModel.FromItem).ToList()
}; };
@@ -42,6 +48,9 @@ public sealed class CriticalBranchEditorModel
RawAffixText, RawAffixText,
SerializeParsedEffects(Effects), SerializeParsedEffects(Effects),
SortOrder, SortOrder,
OriginKey,
IsOverridden,
AreEffectsOverridden,
Effects.Select(effect => effect.ToItem()).ToList()); Effects.Select(effect => effect.ToItem()).ToList());
private string BuildRawText() private string BuildRawText()

View File

@@ -76,7 +76,7 @@
} }
<div class="field-shell"> <div class="field-shell">
<label>Result Text</label> <label>Result Text</label>
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" /> <InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" @bind-Value:after="MarkDescriptionOverridden" />
</div> </div>
</section> </section>
@@ -134,11 +134,11 @@
<div class="critical-editor-branch-line"> <div class="critical-editor-branch-line">
<div class="field-shell"> <div class="field-shell">
<label>Condition</label> <label>Condition</label>
<InputText class="input-shell" @bind-Value="branch.ConditionText" /> <InputText class="input-shell" @bind-Value="branch.ConditionText" @bind-Value:after="() => MarkBranchOverridden(branch)" />
</div> </div>
<div class="field-shell critical-editor-branch-outcome"> <div class="field-shell critical-editor-branch-outcome">
<label>Outcome Text</label> <label>Outcome Text</label>
<InputText class="input-shell" @bind-Value="branch.DescriptionText" /> <InputText class="input-shell" @bind-Value="branch.DescriptionText" @bind-Value:after="() => MarkBranchOverridden(branch)" />
</div> </div>
</div> </div>
@@ -321,7 +321,13 @@
private void AddBaseEffect() private void AddBaseEffect()
{ {
Model?.Effects.Add(CreateDefaultEffectModel()); if (Model is null)
{
return;
}
Model.AreEffectsOverridden = true;
Model.Effects.Add(CreateDefaultEffectModel());
} }
private void RemoveBaseEffect(int index) private void RemoveBaseEffect(int index)
@@ -331,6 +337,7 @@
return; return;
} }
Model.AreEffectsOverridden = true;
Model.Effects.RemoveAt(index); Model.Effects.RemoveAt(index);
} }
@@ -344,8 +351,10 @@
Model.Branches.Add(new CriticalBranchEditorModel Model.Branches.Add(new CriticalBranchEditorModel
{ {
ConditionText = $"Condition {Model.Branches.Count + 1}", 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) private void RemoveBranch(int index)
@@ -355,11 +364,13 @@
return; return;
} }
Model.AreBranchesOverridden = true;
Model.Branches.RemoveAt(index); Model.Branches.RemoveAt(index);
} }
private static void AddBranchEffect(CriticalBranchEditorModel branch) private static void AddBranchEffect(CriticalBranchEditorModel branch)
{ {
branch.AreEffectsOverridden = true;
branch.Effects.Add(CreateDefaultEffectModel()); branch.Effects.Add(CreateDefaultEffectModel());
} }
@@ -370,6 +381,7 @@
return; return;
} }
branch.AreEffectsOverridden = true;
branch.Effects.RemoveAt(index); branch.Effects.RemoveAt(index);
} }
@@ -377,7 +389,8 @@
new() new()
{ {
EffectCode = CriticalEffectCodes.DirectHits, EffectCode = CriticalEffectCodes.DirectHits,
SourceType = "symbol" SourceType = "symbol",
IsOverridden = true
}; };
private static string GetBranchTitle(CriticalBranchEditorModel branch, int index) => private static string GetBranchTitle(CriticalBranchEditorModel branch, int index) =>
@@ -428,6 +441,25 @@
effect.IsPermanent = false; effect.IsPermanent = false;
effect.SourceText = null; effect.SourceText = null;
effect.SourceType = AffixDisplayMap.TryGet(effect.EffectCode, out _) ? "symbol" : "manual"; 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) private static string GetAdvancedSummary(CriticalCellEditorModel model)
@@ -531,7 +563,7 @@
{ {
<div class="field-shell critical-editor-effect-extra"> <div class="field-shell critical-editor-effect-extra">
<label>Body Part</label> <label>Body Part</label>
<InputText class="input-shell" @bind-Value="effect.BodyPart" /> <InputText class="input-shell" @bind-Value="effect.BodyPart" @bind-Value:after="() => MarkEffectOverridden(effect)" />
</div> </div>
} }
</div> </div>
@@ -543,30 +575,30 @@
{ {
case CriticalEffectCodes.DirectHits: case CriticalEffectCodes.DirectHits:
<label>Hits</label> <label>Hits</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.ValueInteger" /> <InputNumber TValue="int?" class="input-shell" @bind-Value="effect.ValueInteger" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
case CriticalEffectCodes.StunnedRounds: case CriticalEffectCodes.StunnedRounds:
case CriticalEffectCodes.MustParryRounds: case CriticalEffectCodes.MustParryRounds:
case CriticalEffectCodes.NoParryRounds: case CriticalEffectCodes.NoParryRounds:
<label>Rounds</label> <label>Rounds</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.DurationRounds" /> <InputNumber TValue="int?" class="input-shell" @bind-Value="effect.DurationRounds" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
case CriticalEffectCodes.BleedPerRound: case CriticalEffectCodes.BleedPerRound:
<label>Bleed / Round</label> <label>Bleed / Round</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.PerRound" /> <InputNumber TValue="int?" class="input-shell" @bind-Value="effect.PerRound" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
case CriticalEffectCodes.FoePenalty: case CriticalEffectCodes.FoePenalty:
case CriticalEffectCodes.AttackerBonusNextRound: case CriticalEffectCodes.AttackerBonusNextRound:
<label>Modifier</label> <label>Modifier</label>
<InputNumber TValue="int?" class="input-shell" @bind-Value="effect.Modifier" /> <InputNumber TValue="int?" class="input-shell" @bind-Value="effect.Modifier" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
case CriticalEffectCodes.PowerPointModifier: case CriticalEffectCodes.PowerPointModifier:
<label>Expression</label> <label>Expression</label>
<InputText class="input-shell" @bind-Value="effect.ValueExpression" /> <InputText class="input-shell" @bind-Value="effect.ValueExpression" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
default: default:
<label>Display Text</label> <label>Display Text</label>
<InputText class="input-shell" @bind-Value="effect.SourceText" /> <InputText class="input-shell" @bind-Value="effect.SourceText" @bind-Value:after="() => MarkEffectOverridden(effect)" />
break; break;
} }
</div>; </div>;

View File

@@ -20,6 +20,10 @@ public sealed class CriticalCellEditorModel
public string? RawAffixText { get; set; } public string? RawAffixText { get; set; }
public string ParseStatus { get; set; } = string.Empty; public string ParseStatus { get; set; } = string.Empty;
public string ParsedJson { get; set; } = "{}"; public string ParsedJson { get; set; } = "{}";
public bool IsDescriptionOverridden { get; set; }
public bool IsRawAffixTextOverridden { get; set; }
public bool AreEffectsOverridden { get; set; }
public bool AreBranchesOverridden { get; set; }
public List<string> ValidationMessages { get; set; } = []; public List<string> ValidationMessages { get; set; } = [];
public List<CriticalEffectEditorModel> Effects { get; set; } = []; public List<CriticalEffectEditorModel> Effects { get; set; } = [];
public List<CriticalBranchEditorModel> Branches { get; set; } = []; public List<CriticalBranchEditorModel> Branches { get; set; } = [];
@@ -42,18 +46,27 @@ public sealed class CriticalCellEditorModel
RawAffixText = response.RawAffixText, RawAffixText = response.RawAffixText,
ParseStatus = response.ParseStatus, ParseStatus = response.ParseStatus,
ParsedJson = response.ParsedJson, ParsedJson = response.ParsedJson,
IsDescriptionOverridden = response.IsDescriptionOverridden,
IsRawAffixTextOverridden = response.IsRawAffixTextOverridden,
AreEffectsOverridden = response.AreEffectsOverridden,
AreBranchesOverridden = response.AreBranchesOverridden,
ValidationMessages = response.ValidationMessages.ToList(), ValidationMessages = response.ValidationMessages.ToList(),
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(), Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList() Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
}; };
public CriticalCellUpdateRequest ToRequest() => public CriticalCellUpdateRequest ToRequest()
new( {
var request = new CriticalCellUpdateRequest(
RawCellText, RawCellText,
DescriptionText, DescriptionText,
RawAffixText, RawAffixText,
ResolveParseStatus(Effects, Branches), ResolveParseStatus(Effects, Branches),
SerializeParsedEffects(Effects), SerializeParsedEffects(Effects),
IsDescriptionOverridden,
IsRawAffixTextOverridden,
AreEffectsOverridden,
AreBranchesOverridden,
Effects.Select(effect => effect.ToItem()).ToList(), Effects.Select(effect => effect.ToItem()).ToList(),
Branches Branches
.OrderBy(branch => branch.SortOrder) .OrderBy(branch => branch.SortOrder)
@@ -64,6 +77,12 @@ public sealed class CriticalCellEditorModel
}) })
.ToList()); .ToList());
return request with
{
ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson()
};
}
private static string ResolveParseStatus( private static string ResolveParseStatus(
IReadOnlyList<CriticalEffectEditorModel> effects, IReadOnlyList<CriticalEffectEditorModel> effects,
IReadOnlyList<CriticalBranchEditorModel> branches) => IReadOnlyList<CriticalBranchEditorModel> branches) =>

View File

@@ -16,6 +16,8 @@ public sealed class CriticalEffectEditorModel
public bool IsPermanent { get; set; } public bool IsPermanent { get; set; }
public string SourceType { get; set; } = "symbol"; public string SourceType { get; set; } = "symbol";
public string? SourceText { get; set; } public string? SourceText { get; set; }
public string? OriginKey { get; set; }
public bool IsOverridden { get; set; }
public static CriticalEffectEditorModel FromItem(CriticalEffectEditorItem item) => public static CriticalEffectEditorModel FromItem(CriticalEffectEditorItem item) =>
new() new()
@@ -31,7 +33,9 @@ public sealed class CriticalEffectEditorModel
BodyPart = item.BodyPart, BodyPart = item.BodyPart,
IsPermanent = item.IsPermanent, IsPermanent = item.IsPermanent,
SourceType = item.SourceType, SourceType = item.SourceType,
SourceText = item.SourceText SourceText = item.SourceText,
OriginKey = item.OriginKey,
IsOverridden = item.IsOverridden
}; };
public CriticalEffectEditorItem ToItem() => public CriticalEffectEditorItem ToItem() =>
@@ -47,5 +51,7 @@ public sealed class CriticalEffectEditorModel
BodyPart, BodyPart,
IsPermanent, IsPermanent,
SourceType, SourceType,
SourceText); SourceText,
OriginKey,
IsOverridden);
} }

View File

@@ -12,4 +12,7 @@ public sealed record CriticalBranchEditorItem(
string? RawAffixText, string? RawAffixText,
string ParsedJson, string ParsedJson,
int SortOrder, int SortOrder,
string? OriginKey,
bool IsOverridden,
bool AreEffectsOverridden,
IReadOnlyList<CriticalEffectEditorItem> Effects); IReadOnlyList<CriticalEffectEditorItem> Effects);

View File

@@ -18,6 +18,10 @@ public sealed record CriticalCellEditorResponse(
string? RawAffixText, string? RawAffixText,
string ParseStatus, string ParseStatus,
string ParsedJson, string ParsedJson,
bool IsDescriptionOverridden,
bool IsRawAffixTextOverridden,
bool AreEffectsOverridden,
bool AreBranchesOverridden,
IReadOnlyList<string> ValidationMessages, IReadOnlyList<string> ValidationMessages,
IReadOnlyList<CriticalEffectEditorItem> Effects, IReadOnlyList<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches); IReadOnlyList<CriticalBranchEditorItem> Branches);

View File

@@ -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<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> 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<CriticalCellEditorSnapshot>(json, JsonOptions);
return snapshot is not null;
}
catch (JsonException)
{
return false;
}
}
}

View File

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

View File

@@ -8,5 +8,9 @@ public sealed record CriticalCellUpdateRequest(
string? RawAffixText, string? RawAffixText,
string ParseStatus, string ParseStatus,
string ParsedJson, string ParsedJson,
bool IsDescriptionOverridden,
bool IsRawAffixTextOverridden,
bool AreEffectsOverridden,
bool AreBranchesOverridden,
IReadOnlyList<CriticalEffectEditorItem> Effects, IReadOnlyList<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches); IReadOnlyList<CriticalBranchEditorItem> Branches);

View File

@@ -12,4 +12,6 @@ public sealed record CriticalEffectEditorItem(
string? BodyPart, string? BodyPart,
bool IsPermanent, bool IsPermanent,
string SourceType, string SourceType,
string? SourceText); string? SourceText,
string? OriginKey,
bool IsOverridden);

View File

@@ -296,7 +296,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync( public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
string slug, string slug,
int resultId, int resultId,
string rawCellText, CriticalCellUpdateRequest currentState,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken); await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
@@ -319,8 +319,10 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
} }
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken); var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken);
var content = SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend); var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend);
return CreateCellEditorResponse(result, content); var generatedState = CreateGeneratedEditorState(content);
var mergedState = MergeGeneratedState(currentState, generatedState);
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors);
} }
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync( public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
@@ -354,14 +356,14 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.DescriptionText = request.DescriptionText.Trim(); result.DescriptionText = request.DescriptionText.Trim();
result.RawAffixText = NormalizeOptionalText(request.RawAffixText); result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
result.ParseStatus = request.ParseStatus.Trim(); 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); ReplaceBaseEffects(dbContext, result, request.Effects);
ReplaceBranches(dbContext, result, request.Branches); ReplaceBranches(dbContext, result, request.Branches);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
return CreateCellEditorResponse(result); return CreateCellEditorResponse(result, request, []);
} }
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells) private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
@@ -422,37 +424,20 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.SourceType, effect.SourceType,
effect.SourceText); effect.SourceText);
private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result) => private static CriticalCellEditorResponse CreateCellEditorResponse(CriticalResult result)
new( {
result.Id, var state = CreateCurrentEditorState(result);
result.CriticalTable.Slug, return CreateCellEditorResponse(result, state, []);
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( private static CriticalCellEditorResponse CreateCellEditorResponse(
CriticalResult result, CriticalResult result,
SharedParsing.CriticalCellParseContent content) => CriticalCellUpdateRequest state,
new( IReadOnlyList<string> validationMessages)
{
var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson();
return new(
result.Id, result.Id,
result.CriticalTable.Slug, result.CriticalTable.Slug,
result.CriticalTable.DisplayName, result.CriticalTable.DisplayName,
@@ -463,19 +448,19 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.CriticalColumn.ColumnKey, result.CriticalColumn.ColumnKey,
result.CriticalColumn.Label, result.CriticalColumn.Label,
result.CriticalColumn.Role, result.CriticalColumn.Role,
content.RawCellText, state.RawCellText,
content.DescriptionText, state.DescriptionText,
content.RawAffixText, state.RawAffixText,
ResolveParseStatus(content.Effects, content.Branches), state.ParseStatus,
SerializeParsedEffects(content.Effects), snapshotJson,
content.ValidationErrors.ToList(), state.IsDescriptionOverridden,
content.Effects state.IsRawAffixTextOverridden,
.Select(CreateEffectEditorItem) state.AreEffectsOverridden,
.ToList(), state.AreBranchesOverridden,
content.Branches validationMessages.ToList(),
.OrderBy(branch => branch.SortOrder) state.Effects.ToList(),
.Select(CreateBranchEditorItem) state.Branches.ToList());
.ToList()); }
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) => private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
new( new(
@@ -491,8 +476,11 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
branch.RawText, branch.RawText,
branch.SortOrder); branch.SortOrder);
private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch) => private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch, int branchIndex)
new( {
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
return new(
branch.BranchKind, branch.BranchKind,
branch.ConditionKey, branch.ConditionKey,
branch.ConditionText, branch.ConditionText,
@@ -502,13 +490,20 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
branch.RawAffixText, branch.RawAffixText,
branch.ParsedJson, branch.ParsedJson,
branch.SortOrder, branch.SortOrder,
originKey,
false,
false,
(branch.Effects ?? Enumerable.Empty<CriticalEffect>()) (branch.Effects ?? Enumerable.Empty<CriticalEffect>())
.OrderBy(effect => effect.Id) .OrderBy(effect => effect.Id)
.Select(CreateEffectEditorItem) .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
.ToList()); .ToList());
}
private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch) => private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch, int branchIndex)
new( {
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
return new(
branch.BranchKind, branch.BranchKind,
branch.ConditionKey, branch.ConditionKey,
branch.ConditionText, branch.ConditionText,
@@ -518,11 +513,15 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
branch.RawAffixText, branch.RawAffixText,
SerializeParsedEffects(branch.Effects), SerializeParsedEffects(branch.Effects),
branch.SortOrder, branch.SortOrder,
originKey,
false,
false,
branch.Effects branch.Effects
.Select(CreateEffectEditorItem) .Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
.ToList()); .ToList());
}
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) => private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
new( new(
effect.EffectCode, effect.EffectCode,
effect.Target, effect.Target,
@@ -535,9 +534,11 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.BodyPart, effect.BodyPart,
effect.IsPermanent, effect.IsPermanent,
effect.SourceType, 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( new(
effect.EffectCode, effect.EffectCode,
effect.Target, effect.Target,
@@ -550,7 +551,192 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.BodyPart, effect.BodyPart,
effect.IsPermanent, effect.IsPermanent,
effect.SourceType, 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<CriticalBranchEditorItem> MergeBranchItems(
IReadOnlyList<CriticalBranchEditorItem> currentBranches,
IReadOnlyList<CriticalBranchEditorItem> generatedBranches)
{
var currentByOrigin = currentBranches
.Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey))
.ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal);
var merged = new List<CriticalBranchEditorItem>(generatedBranches.Count);
var matchedOrigins = new HashSet<string>(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<CriticalEffectEditorItem> MergeEffectItems(
IReadOnlyList<CriticalEffectEditorItem> currentEffects,
IReadOnlyList<CriticalEffectEditorItem> generatedEffects)
{
var currentByOrigin = currentEffects
.Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey))
.ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal);
var merged = new List<CriticalEffectEditorItem>(generatedEffects.Count);
var matchedOrigins = new HashSet<string>(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( private static void ReplaceBaseEffects(
RolemasterDbContext dbContext, RolemasterDbContext dbContext,

View File

@@ -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) => 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); return result is null ? Results.NotFound() : Results.Ok(result);
}); });
api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) => api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) =>

View File

@@ -41,72 +41,15 @@ public sealed class CriticalCellReparseIntegrationTests
public async Task Lookup_service_reparse_uses_shared_parser_and_table_legend_data() 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"); var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-{Guid.NewGuid():N}.db");
await SeedSlashResultAsync(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();
}
var lookupService = new LookupService(CreateDbContextFactory(databasePath)); var lookupService = new LookupService(CreateDbContextFactory(databasePath));
await using var verifyContext = CreateDbContext(databasePath); var resultId = await GetSlashResultIdAsync(databasePath);
var resultId = await verifyContext.CriticalResults
.Where(item => item.CriticalTable.Slug == "slash")
.Select(item => item.Id)
.SingleAsync();
var response = await lookupService.ReparseCriticalCellAsync( var response = await lookupService.ReparseCriticalCellAsync(
"slash", "slash",
resultId, 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.NotNull(response);
Assert.Equal("Strike to thigh.", response!.DescriptionText); Assert.Equal("Strike to thigh.", response!.DescriptionText);
@@ -118,6 +61,219 @@ public sealed class CriticalCellReparseIntegrationTests
Assert.Empty(response.ValidationMessages); 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<CriticalEffectEditorItem>? effects = null,
IReadOnlyList<CriticalBranchEditorItem>? 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<int> 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) private static RolemasterDbContext CreateDbContext(string databasePath)
{ {
var options = new DbContextOptionsBuilder<RolemasterDbContext>() var options = new DbContextOptionsBuilder<RolemasterDbContext>()