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

@@ -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 @@
<h2 class="panel-title">Cell re-parse</h2>
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
<pre class="code-block">{
"rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside."
"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>
<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 class="panel">
@@ -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": []

View File

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

View File

@@ -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<CriticalEffectEditorModel> 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()

View File

@@ -76,7 +76,7 @@
}
<div class="field-shell">
<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>
</section>
@@ -134,11 +134,11 @@
<div class="critical-editor-branch-line">
<div class="field-shell">
<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 class="field-shell critical-editor-branch-outcome">
<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>
@@ -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 @@
{
<div class="field-shell critical-editor-effect-extra">
<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>
@@ -543,30 +575,30 @@
{
case CriticalEffectCodes.DirectHits:
<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;
case CriticalEffectCodes.StunnedRounds:
case CriticalEffectCodes.MustParryRounds:
case CriticalEffectCodes.NoParryRounds:
<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;
case CriticalEffectCodes.BleedPerRound:
<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;
case CriticalEffectCodes.FoePenalty:
case CriticalEffectCodes.AttackerBonusNextRound:
<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;
case CriticalEffectCodes.PowerPointModifier:
<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;
default:
<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;
}
</div>;

View File

@@ -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<string> ValidationMessages { get; set; } = [];
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
public List<CriticalBranchEditorModel> 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<CriticalEffectEditorModel> effects,
IReadOnlyList<CriticalBranchEditorModel> branches) =>

View File

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

View File

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

View File

@@ -18,6 +18,10 @@ public sealed record CriticalCellEditorResponse(
string? RawAffixText,
string ParseStatus,
string ParsedJson,
bool IsDescriptionOverridden,
bool IsRawAffixTextOverridden,
bool AreEffectsOverridden,
bool AreBranchesOverridden,
IReadOnlyList<string> ValidationMessages,
IReadOnlyList<CriticalEffectEditorItem> Effects,
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;
public sealed record CriticalCellReparseRequest(
string RawCellText);
CriticalCellUpdateRequest CurrentState);

View File

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

View File

@@ -12,4 +12,6 @@ public sealed record CriticalEffectEditorItem(
string? BodyPart,
bool IsPermanent,
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(
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<RolemasterDbContext> 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<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
@@ -354,14 +356,14 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> 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<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
@@ -422,37 +424,20 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> 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<string> 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<RolemasterDbContext> 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<RolemasterDbContext> 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<RolemasterDbContext> dbConte
branch.RawAffixText,
branch.ParsedJson,
branch.SortOrder,
originKey,
false,
false,
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
.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<RolemasterDbContext> 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<RolemasterDbContext> 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<RolemasterDbContext> 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<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(
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) =>
{
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) =>

View File

@@ -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<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)
{
var options = new DbContextOptionsBuilder<RolemasterDbContext>()