Add quick parse notation for critical editor
This commit is contained in:
@@ -71,16 +71,26 @@
|
||||
<section class="critical-editor-section">
|
||||
<div class="critical-editor-section-header">
|
||||
<div>
|
||||
<h4>Raw Text</h4>
|
||||
<p class="muted">Update the source text, then correct the visible result rows below.</p>
|
||||
<h4>Quick Parse Input</h4>
|
||||
<p class="muted">First line is the result prose. Later lines are base affixes or <code>condition: ...</code> lines with comma-separated shorthand.</p>
|
||||
</div>
|
||||
<button type="button" class="btn-ritual" @onclick="OnReparse" disabled="@IsSaving || IsReparsing">
|
||||
@(IsReparsing ? "Re-Parsing..." : "Re-Parse Raw Text")
|
||||
@(IsReparsing ? "Generating..." : "Generate From Quick Input")
|
||||
</button>
|
||||
</div>
|
||||
<div class="field-shell">
|
||||
<label>Raw Cell Text</label>
|
||||
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
|
||||
<label>Quick Parse Input</label>
|
||||
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.QuickParseInput" />
|
||||
</div>
|
||||
<p class="muted critical-editor-advanced-hint">Example: <code>Foe brings his guard up, frightened by your display.</code> then <code>+5, 1mp</code> or <code>w/o shield: glancing blow, +15, 3s, 3np</code>.</p>
|
||||
<div class="critical-editor-quick-legend">
|
||||
@foreach (var entry in QuickParseLegendEntries)
|
||||
{
|
||||
<div class="critical-editor-quick-legend-item">
|
||||
<code>@entry.Token</code>
|
||||
<span class="muted">@entry.Meaning</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (Model.ValidationMessages.Count > 0)
|
||||
{
|
||||
@@ -88,7 +98,7 @@
|
||||
}
|
||||
@if (HasComparisonDifferences(Model, ComparisonBaseline))
|
||||
{
|
||||
<p class="muted critical-editor-advanced-hint">Fresh parsing differs from the current edited card. Review Generated Compare before saving if you want to keep the overrides.</p>
|
||||
<p class="muted critical-editor-advanced-hint">Fresh generation differs from the current edited card. Review Generated Compare before saving if you want to keep the overrides.</p>
|
||||
}
|
||||
<div class="field-shell">
|
||||
<label>Result Text</label>
|
||||
@@ -203,7 +213,7 @@
|
||||
<div class="critical-editor-card-header">
|
||||
<div>
|
||||
<strong>Generated Compare</strong>
|
||||
<p class="muted critical-editor-inline-copy">Compare the current edited card against the fresh parser output from the raw text.</p>
|
||||
<p class="muted critical-editor-inline-copy">Compare the current edited card against the fresh generated result from the quick parse input.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -222,8 +232,8 @@
|
||||
Effects="@BuildPreviewEffects(GetComparisonSourceModel(Model, ComparisonBaseline))"
|
||||
Branches="@BuildPreviewBranches(GetComparisonSourceModel(Model, ComparisonBaseline))" />
|
||||
<CriticalResultPreviewCard
|
||||
Title="Generated From Raw Text"
|
||||
Caption="This is the fresh parser output before override preservation."
|
||||
Title="Generated From Quick Input"
|
||||
Caption="This is the fresh generated output before override preservation."
|
||||
Description="@Model.GeneratedState.DescriptionText"
|
||||
Effects="@Model.GeneratedState.Effects"
|
||||
Branches="@Model.GeneratedState.Branches"
|
||||
@@ -250,6 +260,10 @@
|
||||
</div>
|
||||
|
||||
<dl class="critical-editor-diagnostic-grid">
|
||||
<div>
|
||||
<dt>OCR Source</dt>
|
||||
<dd>@(string.IsNullOrWhiteSpace(Model.RawCellText) ? "—" : Model.RawCellText)</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Parse Status</dt>
|
||||
<dd>@Model.ParseStatus</dd>
|
||||
@@ -371,6 +385,18 @@
|
||||
{
|
||||
WriteIndented = true
|
||||
};
|
||||
private static readonly IReadOnlyList<(string Token, string Meaning)> QuickParseLegendEntries =
|
||||
[
|
||||
("+15", "Direct hits"),
|
||||
("3s", "Stunned 3 rounds"),
|
||||
("1mp", "Must parry 1 round"),
|
||||
("3np", "No parry 3 rounds"),
|
||||
("1hpr", "Bleed 1 hit per round"),
|
||||
("-20", "Foe penalty"),
|
||||
("+20b", "Attacker bonus next round"),
|
||||
("+2d10-3pp", "Power-point modifier"),
|
||||
("w/o shield: +15, 3s", "Conditional line")
|
||||
];
|
||||
private IJSObjectReference? jsModule;
|
||||
private bool isBackdropPointerDown;
|
||||
|
||||
@@ -610,8 +636,8 @@
|
||||
|
||||
private static string GetParserNoteSummary(int noteCount) =>
|
||||
noteCount == 1
|
||||
? "1 parser note is available under Advanced Diagnostics."
|
||||
: $"{noteCount} parser notes are available under Advanced Diagnostics.";
|
||||
? "1 parser note is available under Advanced Review & Diagnostics."
|
||||
: $"{noteCount} parser notes are available under Advanced Review & Diagnostics.";
|
||||
|
||||
private static string FormatJson(string json)
|
||||
{
|
||||
|
||||
@@ -16,6 +16,7 @@ public sealed class CriticalCellEditorModel
|
||||
public string ColumnLabel { get; set; } = string.Empty;
|
||||
public string ColumnRole { get; set; } = string.Empty;
|
||||
public string RawCellText { get; set; } = string.Empty;
|
||||
public string QuickParseInput { get; set; } = string.Empty;
|
||||
public string DescriptionText { get; set; } = string.Empty;
|
||||
public string? RawAffixText { get; set; }
|
||||
public string ParseStatus { get; set; } = string.Empty;
|
||||
@@ -43,6 +44,7 @@ public sealed class CriticalCellEditorModel
|
||||
ColumnLabel = response.ColumnLabel,
|
||||
ColumnRole = response.ColumnRole,
|
||||
RawCellText = response.RawCellText,
|
||||
QuickParseInput = response.QuickParseInput,
|
||||
DescriptionText = response.DescriptionText,
|
||||
RawAffixText = response.RawAffixText,
|
||||
ParseStatus = response.ParseStatus,
|
||||
@@ -61,6 +63,7 @@ public sealed class CriticalCellEditorModel
|
||||
{
|
||||
var request = new CriticalCellUpdateRequest(
|
||||
RawCellText,
|
||||
QuickParseInput,
|
||||
DescriptionText,
|
||||
RawAffixText,
|
||||
ResolveParseStatus(Effects, Branches),
|
||||
@@ -99,6 +102,7 @@ public sealed class CriticalCellEditorModel
|
||||
ColumnLabel = ColumnLabel,
|
||||
ColumnRole = ColumnRole,
|
||||
RawCellText = RawCellText,
|
||||
QuickParseInput = QuickParseInput,
|
||||
DescriptionText = DescriptionText,
|
||||
RawAffixText = RawAffixText,
|
||||
ParseStatus = ParseStatus,
|
||||
|
||||
@@ -14,6 +14,7 @@ public sealed record CriticalCellEditorResponse(
|
||||
string ColumnLabel,
|
||||
string ColumnRole,
|
||||
string RawCellText,
|
||||
string QuickParseInput,
|
||||
string DescriptionText,
|
||||
string? RawAffixText,
|
||||
string ParseStatus,
|
||||
|
||||
@@ -9,7 +9,8 @@ public sealed record CriticalCellEditorSnapshot(
|
||||
bool AreEffectsOverridden,
|
||||
bool AreBranchesOverridden,
|
||||
IReadOnlyList<CriticalEffectEditorItem> Effects,
|
||||
IReadOnlyList<CriticalBranchEditorItem> Branches)
|
||||
IReadOnlyList<CriticalBranchEditorItem> Branches,
|
||||
string? QuickParseInput)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
@@ -21,7 +22,8 @@ public sealed record CriticalCellEditorSnapshot(
|
||||
request.AreEffectsOverridden,
|
||||
request.AreBranchesOverridden,
|
||||
request.Effects,
|
||||
request.Branches);
|
||||
request.Branches,
|
||||
request.QuickParseInput);
|
||||
|
||||
public string ToJson() =>
|
||||
JsonSerializer.Serialize(this, JsonOptions);
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace RolemasterDb.App.Features;
|
||||
|
||||
public sealed record CriticalCellUpdateRequest(
|
||||
string RawCellText,
|
||||
string QuickParseInput,
|
||||
string DescriptionText,
|
||||
string? RawAffixText,
|
||||
string ParseStatus,
|
||||
|
||||
100
src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs
Normal file
100
src/RolemasterDb.App/Features/CriticalQuickNotationFormatter.cs
Normal file
@@ -0,0 +1,100 @@
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace RolemasterDb.App.Features;
|
||||
|
||||
public static class CriticalQuickNotationFormatter
|
||||
{
|
||||
public static string Format(
|
||||
string descriptionText,
|
||||
IReadOnlyList<CriticalEffectEditorItem> effects,
|
||||
IReadOnlyList<CriticalBranchEditorItem> branches)
|
||||
{
|
||||
var lines = new List<string>
|
||||
{
|
||||
CollapseWhitespace(descriptionText)
|
||||
};
|
||||
|
||||
var baseAffixLine = FormatEffects(effects);
|
||||
if (!string.IsNullOrWhiteSpace(baseAffixLine))
|
||||
{
|
||||
lines.Add(baseAffixLine);
|
||||
}
|
||||
|
||||
foreach (var branch in branches.OrderBy(item => item.SortOrder))
|
||||
{
|
||||
var payloadParts = new List<string>();
|
||||
var branchDescription = CollapseWhitespace(branch.DescriptionText);
|
||||
if (!string.IsNullOrWhiteSpace(branchDescription))
|
||||
{
|
||||
payloadParts.Add(branchDescription);
|
||||
}
|
||||
|
||||
var branchAffixLine = FormatEffects(branch.Effects);
|
||||
if (!string.IsNullOrWhiteSpace(branchAffixLine))
|
||||
{
|
||||
payloadParts.Add(branchAffixLine);
|
||||
}
|
||||
|
||||
if (payloadParts.Count == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
lines.Add($"{CollapseWhitespace(branch.ConditionText)}: {string.Join(", ", payloadParts)}");
|
||||
}
|
||||
|
||||
return string.Join(Environment.NewLine, lines.Where(line => !string.IsNullOrWhiteSpace(line)));
|
||||
}
|
||||
|
||||
private static string? FormatEffects(IReadOnlyList<CriticalEffectEditorItem> effects)
|
||||
{
|
||||
var tokens = effects
|
||||
.Select(FormatEffect)
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToList();
|
||||
|
||||
return tokens.Count == 0 ? null : string.Join(", ", tokens);
|
||||
}
|
||||
|
||||
private static string? FormatEffect(CriticalEffectEditorItem effect) =>
|
||||
effect.EffectCode switch
|
||||
{
|
||||
Domain.CriticalEffectCodes.DirectHits => effect.ValueInteger is int hits ? $"+{hits}" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.StunnedRounds => effect.DurationRounds is int rounds ? $"{rounds}s" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.MustParryRounds => effect.DurationRounds is int rounds ? $"{rounds}mp" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.NoParryRounds => effect.DurationRounds is int rounds ? $"{rounds}np" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.BleedPerRound => effect.PerRound is int bleed ? $"{bleed}hpr" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.FoePenalty => effect.Modifier is int penalty ? penalty.ToString(System.Globalization.CultureInfo.InvariantCulture) : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.AttackerBonusNextRound => effect.Modifier is int bonus ? $"+{bonus}b" : effect.SourceText,
|
||||
Domain.CriticalEffectCodes.PowerPointModifier => FormatPowerPointModifier(effect.ValueExpression, effect.SourceText),
|
||||
_ => effect.SourceText ?? effect.EffectCode
|
||||
};
|
||||
|
||||
private static string? FormatPowerPointModifier(string? valueExpression, string? sourceText)
|
||||
{
|
||||
var expression = CollapseWhitespace(valueExpression);
|
||||
if (string.IsNullOrWhiteSpace(expression))
|
||||
{
|
||||
return sourceText;
|
||||
}
|
||||
|
||||
expression = expression.Trim();
|
||||
if (expression.StartsWith('+'))
|
||||
{
|
||||
expression = expression[1..];
|
||||
}
|
||||
|
||||
if (expression.StartsWith('(') && expression.EndsWith(')') && expression.Length > 2)
|
||||
{
|
||||
expression = expression[1..^1].Trim();
|
||||
}
|
||||
|
||||
expression = Regex.Replace(expression, @"\s+", string.Empty);
|
||||
return $"+{expression}pp";
|
||||
}
|
||||
|
||||
private static string CollapseWhitespace(string? value) =>
|
||||
string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: Regex.Replace(value.Trim(), @"\s+", " ");
|
||||
}
|
||||
@@ -296,7 +296,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
}
|
||||
|
||||
var currentState = CreateCurrentEditorState(result);
|
||||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.RawCellText, cancellationToken);
|
||||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
|
||||
return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
|
||||
}
|
||||
|
||||
@@ -325,8 +325,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
return null;
|
||||
}
|
||||
|
||||
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken);
|
||||
var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend);
|
||||
var content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
|
||||
var generatedState = CreateGeneratedEditorState(content);
|
||||
var mergedState = MergeGeneratedState(currentState, generatedState);
|
||||
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
|
||||
@@ -370,7 +369,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.RawCellText, cancellationToken);
|
||||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken);
|
||||
return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
|
||||
}
|
||||
|
||||
@@ -452,6 +451,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
result.CriticalColumn.Label,
|
||||
result.CriticalColumn.Role,
|
||||
state.RawCellText,
|
||||
state.QuickParseInput,
|
||||
state.DescriptionText,
|
||||
state.RawAffixText,
|
||||
state.ParseStatus,
|
||||
@@ -575,8 +575,13 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
{
|
||||
if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null)
|
||||
{
|
||||
var snapshotQuickParseInput = string.IsNullOrWhiteSpace(snapshot.QuickParseInput)
|
||||
? CriticalQuickNotationFormatter.Format(result.DescriptionText, snapshot.Effects, snapshot.Branches)
|
||||
: snapshot.QuickParseInput;
|
||||
|
||||
return new CriticalCellUpdateRequest(
|
||||
result.RawCellText,
|
||||
snapshotQuickParseInput,
|
||||
result.DescriptionText,
|
||||
result.RawAffixText,
|
||||
result.ParseStatus,
|
||||
@@ -600,6 +605,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
|
||||
return new CriticalCellUpdateRequest(
|
||||
result.RawCellText,
|
||||
CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches),
|
||||
result.DescriptionText,
|
||||
result.RawAffixText,
|
||||
result.ParseStatus,
|
||||
@@ -623,17 +629,18 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
.ToList();
|
||||
|
||||
return new CriticalCellUpdateRequest(
|
||||
content.RawCellText,
|
||||
content.DescriptionText,
|
||||
content.RawAffixText,
|
||||
ResolveParseStatus(content.Effects, content.Branches),
|
||||
SerializeParsedEffects(content.Effects),
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
effects,
|
||||
branches);
|
||||
RawCellText: string.Empty,
|
||||
QuickParseInput: content.RawCellText,
|
||||
DescriptionText: content.DescriptionText,
|
||||
RawAffixText: content.RawAffixText,
|
||||
ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
|
||||
ParsedJson: SerializeParsedEffects(content.Effects),
|
||||
IsDescriptionOverridden: false,
|
||||
IsRawAffixTextOverridden: false,
|
||||
AreEffectsOverridden: false,
|
||||
AreBranchesOverridden: false,
|
||||
Effects: effects,
|
||||
Branches: branches);
|
||||
}
|
||||
|
||||
private static CriticalCellUpdateRequest MergeGeneratedState(
|
||||
@@ -641,6 +648,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
CriticalCellUpdateRequest generatedState) =>
|
||||
new(
|
||||
currentState.RawCellText,
|
||||
currentState.QuickParseInput,
|
||||
currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText,
|
||||
currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
|
||||
generatedState.ParseStatus,
|
||||
@@ -922,11 +930,11 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
||||
private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(
|
||||
RolemasterDbContext dbContext,
|
||||
int tableId,
|
||||
string rawCellText,
|
||||
string quickParseInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken);
|
||||
return SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend);
|
||||
return SharedParsing.CriticalQuickNotationParser.Parse(quickParseInput, affixLegend);
|
||||
}
|
||||
|
||||
private static bool IsLegendSymbolEffectCode(string effectCode) =>
|
||||
|
||||
@@ -843,6 +843,26 @@ textarea {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.critical-editor-quick-legend {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
}
|
||||
|
||||
.critical-editor-quick-legend-item {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(127, 96, 55, 0.1);
|
||||
}
|
||||
|
||||
.critical-editor-quick-legend-item code {
|
||||
font-size: 0.9rem;
|
||||
color: #5b4327;
|
||||
}
|
||||
|
||||
.critical-editor-compare-grid {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
|
||||
@@ -9,6 +9,14 @@ public static class AffixEffectParser
|
||||
private static readonly Regex DirectHitsRegex = new(@"[+-]\s*\d+\s*H\b", RegexOptions.Compiled);
|
||||
private static readonly Regex PowerPointModifierRegex = new(@"\+\s*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
|
||||
private static readonly Regex ModifierRegex = new(@"\((?<noise>[^0-9+\-)]*)(?<sign>[+-])\s*(?<value>\d+)\)", RegexOptions.Compiled);
|
||||
private static readonly Regex QuickDirectHitsRegex = new(@"^\+(?<value>\d+)(?:H)?$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickStunnedRegex = new(@"^(?<value>\d+)s$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickMustParryRegex = new(@"^(?<value>\d+)mp$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickNoParryRegex = new(@"^(?<value>\d+)np$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickBleedRegex = new(@"^(?<value>\d+)hpr$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickFoePenaltyRegex = new(@"^-(?<value>\d+)$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickAttackerBonusRegex = new(@"^\+(?<value>\d+)b$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static readonly Regex QuickPowerPointRegex = new(@"^\+(?<expression>.+)pp$", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
public static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
|
||||
{
|
||||
@@ -27,6 +35,31 @@ public static class AffixEffectParser
|
||||
return effects;
|
||||
}
|
||||
|
||||
public static bool TryParseAffixToken(
|
||||
string token,
|
||||
AffixLegend affixLegend,
|
||||
out IReadOnlyList<ParsedCriticalEffect> effects,
|
||||
out string normalizedToken)
|
||||
{
|
||||
normalizedToken = CriticalCellParserSupport.CollapseWhitespace(token);
|
||||
if (TryParseQuickToken(normalizedToken, out var quickEffects, out var canonicalToken))
|
||||
{
|
||||
normalizedToken = canonicalToken;
|
||||
effects = quickEffects;
|
||||
return true;
|
||||
}
|
||||
|
||||
var legacyEffects = Parse(normalizedToken, affixLegend);
|
||||
if (legacyEffects.Count > 0)
|
||||
{
|
||||
effects = legacyEffects;
|
||||
return true;
|
||||
}
|
||||
|
||||
effects = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line is "-" or "–" or "—")
|
||||
@@ -163,6 +196,174 @@ public static class AffixEffectParser
|
||||
}
|
||||
}
|
||||
|
||||
private static bool TryParseQuickToken(
|
||||
string token,
|
||||
out IReadOnlyList<ParsedCriticalEffect> effects,
|
||||
out string canonicalToken)
|
||||
{
|
||||
canonicalToken = token;
|
||||
|
||||
if (TryMatchSingle(QuickDirectHitsRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.DirectHits,
|
||||
FoeTarget,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"+{match.Groups["value"].Value}"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickStunnedRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.StunnedRounds,
|
||||
FoeTarget,
|
||||
null,
|
||||
null,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"{match.Groups["value"].Value}s"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickMustParryRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.MustParryRounds,
|
||||
FoeTarget,
|
||||
null,
|
||||
null,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"{match.Groups["value"].Value}mp"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickNoParryRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.NoParryRounds,
|
||||
FoeTarget,
|
||||
null,
|
||||
null,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"{match.Groups["value"].Value}np"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickBleedRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.BleedPerRound,
|
||||
FoeTarget,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"{match.Groups["value"].Value}hpr"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickFoePenaltyRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.FoePenalty,
|
||||
FoeTarget,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
-int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"-{match.Groups["value"].Value}"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickAttackerBonusRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.AttackerBonusNextRound,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
int.Parse(match.Groups["value"].Value),
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"+{match.Groups["value"].Value}b"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (TryMatchSingle(QuickPowerPointRegex, token, match =>
|
||||
new ParsedCriticalEffect(
|
||||
CriticalEffectCodes.PowerPointModifier,
|
||||
FoeTarget,
|
||||
null,
|
||||
NormalizePowerPointExpression(match.Groups["expression"].Value),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
false,
|
||||
"quick",
|
||||
$"+{NormalizePowerPointExpression(match.Groups["expression"].Value)}pp"), out effects, out canonicalToken))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
effects = [];
|
||||
return false;
|
||||
}
|
||||
|
||||
private static bool TryMatchSingle(
|
||||
Regex regex,
|
||||
string token,
|
||||
Func<Match, ParsedCriticalEffect> createEffect,
|
||||
out IReadOnlyList<ParsedCriticalEffect> effects,
|
||||
out string canonicalToken)
|
||||
{
|
||||
var match = regex.Match(token);
|
||||
if (!match.Success)
|
||||
{
|
||||
effects = [];
|
||||
canonicalToken = token;
|
||||
return false;
|
||||
}
|
||||
|
||||
var effect = createEffect(match);
|
||||
effects = [effect];
|
||||
canonicalToken = effect.SourceText ?? token;
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) =>
|
||||
effectCode switch
|
||||
{
|
||||
@@ -276,4 +477,22 @@ public static class AffixEffectParser
|
||||
.Replace(" +", "+", StringComparison.Ordinal)
|
||||
.Replace("( ", "(", StringComparison.Ordinal)
|
||||
.Replace(" )", ")", StringComparison.Ordinal);
|
||||
|
||||
private static string NormalizePowerPointExpression(string value)
|
||||
{
|
||||
var normalized = CriticalCellParserSupport.CollapseWhitespace(value)
|
||||
.Replace(" ", string.Empty, StringComparison.Ordinal);
|
||||
|
||||
if (normalized.StartsWith('+'))
|
||||
{
|
||||
normalized = normalized[1..];
|
||||
}
|
||||
|
||||
if (normalized.StartsWith('(') && normalized.EndsWith(')') && normalized.Length > 2)
|
||||
{
|
||||
normalized = normalized[1..^1];
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
}
|
||||
|
||||
131
src/RolemasterDb.CriticalParsing/CriticalQuickNotationParser.cs
Normal file
131
src/RolemasterDb.CriticalParsing/CriticalQuickNotationParser.cs
Normal file
@@ -0,0 +1,131 @@
|
||||
namespace RolemasterDb.CriticalParsing;
|
||||
|
||||
public static class CriticalQuickNotationParser
|
||||
{
|
||||
public static CriticalCellParseContent Parse(string quickParseInput, AffixLegend affixLegend)
|
||||
{
|
||||
var lines = quickParseInput
|
||||
.Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(CriticalCellParserSupport.CollapseWhitespace)
|
||||
.Where(line => !string.IsNullOrWhiteSpace(line))
|
||||
.ToList();
|
||||
|
||||
if (lines.Count == 0)
|
||||
{
|
||||
return new CriticalCellParseContent([], string.Empty, string.Empty, null, [], [], []);
|
||||
}
|
||||
|
||||
var validationErrors = new List<string>();
|
||||
var descriptionText = lines[0];
|
||||
var rawAffixLines = new List<string>();
|
||||
var effects = new List<ParsedCriticalEffect>();
|
||||
var branches = new List<ParsedCriticalBranch>();
|
||||
|
||||
foreach (var line in lines.Skip(1))
|
||||
{
|
||||
if (line.Contains(':', StringComparison.Ordinal))
|
||||
{
|
||||
branches.Add(ParseBranch(line, branches.Count + 1, affixLegend, validationErrors));
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedAffixLine = ParseBaseAffixLine(line, affixLegend, validationErrors, effects);
|
||||
if (!string.IsNullOrWhiteSpace(normalizedAffixLine))
|
||||
{
|
||||
rawAffixLines.Add(normalizedAffixLine);
|
||||
}
|
||||
}
|
||||
|
||||
return new CriticalCellParseContent(
|
||||
lines,
|
||||
string.Join(Environment.NewLine, lines),
|
||||
descriptionText,
|
||||
rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines),
|
||||
effects,
|
||||
branches,
|
||||
validationErrors);
|
||||
}
|
||||
|
||||
private static string? ParseBaseAffixLine(
|
||||
string line,
|
||||
AffixLegend affixLegend,
|
||||
List<string> validationErrors,
|
||||
List<ParsedCriticalEffect> effects)
|
||||
{
|
||||
var tokens = SplitPayload(line);
|
||||
var normalizedTokens = new List<string>();
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (!AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken))
|
||||
{
|
||||
validationErrors.Add($"Base content has unrecognized affix token '{token}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
normalizedTokens.Add(normalizedToken);
|
||||
effects.AddRange(parsedEffects);
|
||||
}
|
||||
|
||||
return normalizedTokens.Count == 0 ? null : string.Join(", ", normalizedTokens);
|
||||
}
|
||||
|
||||
private static ParsedCriticalBranch ParseBranch(
|
||||
string line,
|
||||
int sortOrder,
|
||||
AffixLegend affixLegend,
|
||||
List<string> validationErrors)
|
||||
{
|
||||
var separatorIndex = line.IndexOf(':', StringComparison.Ordinal);
|
||||
var conditionText = CriticalCellParserSupport.CollapseWhitespace(line[..separatorIndex]);
|
||||
var payload = line[(separatorIndex + 1)..];
|
||||
var tokens = SplitPayload(payload);
|
||||
var descriptionTokens = new List<string>();
|
||||
var normalizedAffixTokens = new List<string>();
|
||||
var effects = new List<ParsedCriticalEffect>();
|
||||
var seenAffix = false;
|
||||
|
||||
foreach (var token in tokens)
|
||||
{
|
||||
if (AffixEffectParser.TryParseAffixToken(token, affixLegend, out var parsedEffects, out var normalizedToken))
|
||||
{
|
||||
seenAffix = true;
|
||||
normalizedAffixTokens.Add(normalizedToken);
|
||||
effects.AddRange(parsedEffects);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (seenAffix)
|
||||
{
|
||||
validationErrors.Add($"Branch '{conditionText}' has unrecognized affix token '{token}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
descriptionTokens.Add(token);
|
||||
}
|
||||
|
||||
var descriptionText = descriptionTokens.Count == 0
|
||||
? string.Empty
|
||||
: CriticalCellParserSupport.CollapseWhitespace(string.Join(", ", descriptionTokens));
|
||||
var rawAffixText = normalizedAffixTokens.Count == 0
|
||||
? null
|
||||
: string.Join(", ", normalizedAffixTokens);
|
||||
|
||||
return new ParsedCriticalBranch(
|
||||
"conditional",
|
||||
CriticalCellParserSupport.NormalizeConditionKey(conditionText),
|
||||
conditionText,
|
||||
line,
|
||||
descriptionText,
|
||||
rawAffixText,
|
||||
effects,
|
||||
sortOrder);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> SplitPayload(string payload) =>
|
||||
payload
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Select(CriticalCellParserSupport.CollapseWhitespace)
|
||||
.Where(token => !string.IsNullOrWhiteSpace(token))
|
||||
.ToList();
|
||||
}
|
||||
@@ -10,6 +10,42 @@ namespace RolemasterDb.ImportTool.Tests;
|
||||
|
||||
public sealed class CriticalCellReparseIntegrationTests
|
||||
{
|
||||
[Fact]
|
||||
public void Shared_quick_notation_parser_supports_all_available_affix_shorthand()
|
||||
{
|
||||
var legend = new AffixLegend(
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["π"] = AppCriticalEffectCodes.MustParryRounds,
|
||||
["∑"] = AppCriticalEffectCodes.StunnedRounds,
|
||||
["∏"] = AppCriticalEffectCodes.NoParryRounds,
|
||||
["∫"] = AppCriticalEffectCodes.BleedPerRound
|
||||
},
|
||||
["P"],
|
||||
supportsFoePenalty: true,
|
||||
supportsAttackerBonus: true,
|
||||
supportsPowerPointModifier: true);
|
||||
|
||||
var content = CriticalQuickNotationParser.Parse(
|
||||
"Mana tears through the target.\r\n+15, 3s, 1mp, 2np, 1hpr, -20, +20b, +2d10-3pp\r\nw/o shield: glancing blow, +5, π",
|
||||
legend);
|
||||
|
||||
Assert.Equal("Mana tears through the target.", content.DescriptionText);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 15);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 3);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.NoParryRounds && effect.DurationRounds == 2);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.BleedPerRound && effect.PerRound == 1);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.FoePenalty && effect.Modifier == -20);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.AttackerBonusNextRound && effect.Modifier == 20);
|
||||
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-3");
|
||||
Assert.Single(content.Branches);
|
||||
Assert.Equal("glancing blow", content.Branches[0].DescriptionText);
|
||||
Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 5);
|
||||
Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
|
||||
Assert.Empty(content.ValidationErrors);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Shared_cell_parser_extracts_base_effects_and_condition_branches()
|
||||
{
|
||||
@@ -49,7 +85,10 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
var response = await lookupService.ReparseCriticalCellAsync(
|
||||
"slash",
|
||||
resultId,
|
||||
CreateEditorRequest("Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫", "Old description"));
|
||||
CreateEditorRequest(
|
||||
"Imported OCR source",
|
||||
"Old description",
|
||||
quickParseInput: "Strike to thigh.\n+10\nWith greaves: glancing blow., 2∫"));
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal("Strike to thigh.", response!.DescriptionText);
|
||||
@@ -74,8 +113,9 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
"slash",
|
||||
resultId,
|
||||
CreateEditorRequest(
|
||||
"Strike to thigh.\n+10H",
|
||||
"Imported OCR source",
|
||||
"Curated thigh strike.",
|
||||
quickParseInput: "Strike to thigh.\n+10",
|
||||
isDescriptionOverridden: true,
|
||||
effects:
|
||||
[
|
||||
@@ -117,8 +157,9 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
"slash",
|
||||
resultId,
|
||||
CreateEditorRequest(
|
||||
"Strike to thigh.\n+10H",
|
||||
"Imported OCR source",
|
||||
"Strike to thigh.",
|
||||
quickParseInput: "Strike to thigh.\n+10",
|
||||
areBranchesOverridden: true,
|
||||
branches:
|
||||
[
|
||||
@@ -153,8 +194,9 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
var lookupService = new LookupService(CreateDbContextFactory(databasePath));
|
||||
var resultId = await GetSlashResultIdAsync(databasePath);
|
||||
var request = CreateEditorRequest(
|
||||
"Strike to thigh.\n+10H",
|
||||
"Imported OCR source",
|
||||
"Curated thigh strike.",
|
||||
quickParseInput: "Strike to thigh.\n+10",
|
||||
isDescriptionOverridden: true,
|
||||
effects:
|
||||
[
|
||||
@@ -187,9 +229,36 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
Assert.Contains("\"version\":1", loadResponse.ParsedJson, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Lookup_service_formats_existing_results_into_quick_notation_and_keeps_arcane_aether_b16_must_parry()
|
||||
{
|
||||
var databasePath = CreateTemporaryDatabaseCopy();
|
||||
var lookupService = new LookupService(CreateDbContextFactory(databasePath));
|
||||
var resultId = await GetResultIdAsync(databasePath, "arcane-aether", "B", "16-20");
|
||||
|
||||
var response = await lookupService.GetCriticalCellEditorAsync("arcane-aether", resultId);
|
||||
|
||||
Assert.NotNull(response);
|
||||
Assert.Equal(
|
||||
"Foe brings his guard up, frightened by your display.\n+5, 1mp",
|
||||
response!.QuickParseInput.Replace("\r\n", "\n", StringComparison.Ordinal));
|
||||
Assert.NotNull(response.GeneratedState);
|
||||
Assert.Contains(response.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
|
||||
|
||||
var reparsed = await lookupService.ReparseCriticalCellAsync(
|
||||
"arcane-aether",
|
||||
resultId,
|
||||
CreateEditorRequest(response.RawCellText, response.DescriptionText, response.QuickParseInput));
|
||||
|
||||
Assert.NotNull(reparsed);
|
||||
Assert.NotNull(reparsed!.GeneratedState);
|
||||
Assert.Contains(reparsed.GeneratedState!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.MustParryRounds && effect.DurationRounds == 1);
|
||||
}
|
||||
|
||||
private static CriticalCellUpdateRequest CreateEditorRequest(
|
||||
string rawCellText,
|
||||
string descriptionText,
|
||||
string? quickParseInput = null,
|
||||
bool isDescriptionOverridden = false,
|
||||
bool isRawAffixTextOverridden = false,
|
||||
bool areEffectsOverridden = false,
|
||||
@@ -199,6 +268,7 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
IReadOnlyList<CriticalBranchEditorItem>? branches = null) =>
|
||||
new(
|
||||
rawCellText,
|
||||
quickParseInput ?? rawCellText,
|
||||
descriptionText,
|
||||
rawAffixText,
|
||||
"partial",
|
||||
@@ -274,6 +344,18 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
private static async Task<int> GetResultIdAsync(string databasePath, string slug, string columnLabel, string rollBandLabel)
|
||||
{
|
||||
await using var verifyContext = CreateDbContext(databasePath);
|
||||
return await verifyContext.CriticalResults
|
||||
.Where(item =>
|
||||
item.CriticalTable.Slug == slug &&
|
||||
item.CriticalColumn.Label == columnLabel &&
|
||||
item.CriticalRollBand.Label == rollBandLabel)
|
||||
.Select(item => item.Id)
|
||||
.SingleAsync();
|
||||
}
|
||||
|
||||
private static RolemasterDbContext CreateDbContext(string databasePath)
|
||||
{
|
||||
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
|
||||
@@ -291,4 +373,28 @@ public sealed class CriticalCellReparseIntegrationTests
|
||||
|
||||
return new TestRolemasterDbContextFactory(options);
|
||||
}
|
||||
|
||||
private static string CreateTemporaryDatabaseCopy()
|
||||
{
|
||||
var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-app-{Guid.NewGuid():N}.db");
|
||||
File.Copy(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.App", "rolemaster.db"), databasePath, true);
|
||||
return databasePath;
|
||||
}
|
||||
|
||||
private static string GetRepositoryRoot()
|
||||
{
|
||||
var probe = new DirectoryInfo(AppContext.BaseDirectory);
|
||||
|
||||
while (probe is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx")))
|
||||
{
|
||||
return probe.FullName;
|
||||
}
|
||||
|
||||
probe = probe.Parent;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException("Could not find the repository root for integration tests.");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user