Implement critical editor override state
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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": []
|
||||||
|
|||||||
@@ -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.";
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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>;
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
55
src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs
Normal file
55
src/RolemasterDb.App/Features/CriticalCellEditorSnapshot.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace RolemasterDb.App.Features;
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
public sealed record CriticalCellReparseRequest(
|
public sealed record CriticalCellReparseRequest(
|
||||||
string RawCellText);
|
CriticalCellUpdateRequest CurrentState);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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>()
|
||||||
|
|||||||
Reference in New Issue
Block a user