Add quick parse notation for critical editor

This commit is contained in:
2026-03-15 12:58:26 +01:00
parent d1ad95e861
commit 7b07477133
12 changed files with 666 additions and 35 deletions

View File

@@ -574,6 +574,7 @@ The following groundwork is already implemented in the web app as of March 15, 2
- critical cells support re-parse from shared parser logic - critical cells support re-parse from shared parser logic
- advanced diagnostics have been separated from the primary editing flow - advanced diagnostics have been separated from the primary editing flow
- advanced curation now includes a generated-versus-current comparison view for re-parse review - advanced curation now includes a generated-versus-current comparison view for re-parse review
- critical cells now open with quick parse input derived from the imported result instead of requiring OCR-era symbol typing
These changes are real and complete, but they are no longer the active roadmap because the detailed acceptance checklist still has unfinished items. These changes are real and complete, but they are no longer the active roadmap because the detailed acceptance checklist still has unfinished items.
@@ -680,6 +681,18 @@ Acceptance criteria:
### Phase 6: Friendly quick parse input ### Phase 6: Friendly quick parse input
Status:
- implemented in the web app on March 15, 2026
Implemented model:
- the editor now treats quick parse input as the primary generation surface
- existing imported results are translated into quick parse notation on the fly when the editor opens
- the popup shows a supported shorthand legend directly under the quick input field
- generation supports all currently available effect types, including power-point modifiers such as `+2d10-3pp`
- OCR glyph tokens remain accepted as compatibility aliases, but they are no longer the primary editing language
Scope: Scope:
- add a dedicated `Quick parse input` field distinct from OCR provenance - add a dedicated `Quick parse input` field distinct from OCR provenance
@@ -752,4 +765,4 @@ Mitigation:
## Recommended Next Step ## Recommended Next Step
Implement Phase 6 next. The current structural gap is the editor input model itself: curators still need a friendly quick parse grammar that treats the first line as prose, all following lines as affix or conditional-affix lines, and comma-separated aliases as the normal typing language instead of OCR-era symbols. Implement Phase 7 next. The remaining parser gap is trustworthiness under imperfect input: unknown tokens still need stronger surfaced review, and no-silent-loss behavior should be enforced comprehensively across compare output and regression coverage.

View File

@@ -71,16 +71,26 @@
<section class="critical-editor-section"> <section class="critical-editor-section">
<div class="critical-editor-section-header"> <div class="critical-editor-section-header">
<div> <div>
<h4>Raw Text</h4> <h4>Quick Parse Input</h4>
<p class="muted">Update the source text, then correct the visible result rows below.</p> <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> </div>
<button type="button" class="btn-ritual" @onclick="OnReparse" disabled="@IsSaving || IsReparsing"> <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> </button>
</div> </div>
<div class="field-shell"> <div class="field-shell">
<label>Raw Cell Text</label> <label>Quick Parse Input</label>
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" /> <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> </div>
@if (Model.ValidationMessages.Count > 0) @if (Model.ValidationMessages.Count > 0)
{ {
@@ -88,7 +98,7 @@
} }
@if (HasComparisonDifferences(Model, ComparisonBaseline)) @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"> <div class="field-shell">
<label>Result Text</label> <label>Result Text</label>
@@ -203,7 +213,7 @@
<div class="critical-editor-card-header"> <div class="critical-editor-card-header">
<div> <div>
<strong>Generated Compare</strong> <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>
</div> </div>
@@ -222,8 +232,8 @@
Effects="@BuildPreviewEffects(GetComparisonSourceModel(Model, ComparisonBaseline))" Effects="@BuildPreviewEffects(GetComparisonSourceModel(Model, ComparisonBaseline))"
Branches="@BuildPreviewBranches(GetComparisonSourceModel(Model, ComparisonBaseline))" /> Branches="@BuildPreviewBranches(GetComparisonSourceModel(Model, ComparisonBaseline))" />
<CriticalResultPreviewCard <CriticalResultPreviewCard
Title="Generated From Raw Text" Title="Generated From Quick Input"
Caption="This is the fresh parser output before override preservation." Caption="This is the fresh generated output before override preservation."
Description="@Model.GeneratedState.DescriptionText" Description="@Model.GeneratedState.DescriptionText"
Effects="@Model.GeneratedState.Effects" Effects="@Model.GeneratedState.Effects"
Branches="@Model.GeneratedState.Branches" Branches="@Model.GeneratedState.Branches"
@@ -250,6 +260,10 @@
</div> </div>
<dl class="critical-editor-diagnostic-grid"> <dl class="critical-editor-diagnostic-grid">
<div>
<dt>OCR Source</dt>
<dd>@(string.IsNullOrWhiteSpace(Model.RawCellText) ? "—" : Model.RawCellText)</dd>
</div>
<div> <div>
<dt>Parse Status</dt> <dt>Parse Status</dt>
<dd>@Model.ParseStatus</dd> <dd>@Model.ParseStatus</dd>
@@ -371,6 +385,18 @@
{ {
WriteIndented = true 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 IJSObjectReference? jsModule;
private bool isBackdropPointerDown; private bool isBackdropPointerDown;
@@ -610,8 +636,8 @@
private static string GetParserNoteSummary(int noteCount) => private static string GetParserNoteSummary(int noteCount) =>
noteCount == 1 noteCount == 1
? "1 parser note is available under Advanced Diagnostics." ? "1 parser note is available under Advanced Review & Diagnostics."
: $"{noteCount} parser notes are available under Advanced Diagnostics."; : $"{noteCount} parser notes are available under Advanced Review & Diagnostics.";
private static string FormatJson(string json) private static string FormatJson(string json)
{ {

View File

@@ -16,6 +16,7 @@ public sealed class CriticalCellEditorModel
public string ColumnLabel { get; set; } = string.Empty; public string ColumnLabel { get; set; } = string.Empty;
public string ColumnRole { get; set; } = string.Empty; public string ColumnRole { get; set; } = string.Empty;
public string RawCellText { 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 DescriptionText { get; set; } = string.Empty;
public string? RawAffixText { get; set; } public string? RawAffixText { get; set; }
public string ParseStatus { get; set; } = string.Empty; public string ParseStatus { get; set; } = string.Empty;
@@ -43,6 +44,7 @@ public sealed class CriticalCellEditorModel
ColumnLabel = response.ColumnLabel, ColumnLabel = response.ColumnLabel,
ColumnRole = response.ColumnRole, ColumnRole = response.ColumnRole,
RawCellText = response.RawCellText, RawCellText = response.RawCellText,
QuickParseInput = response.QuickParseInput,
DescriptionText = response.DescriptionText, DescriptionText = response.DescriptionText,
RawAffixText = response.RawAffixText, RawAffixText = response.RawAffixText,
ParseStatus = response.ParseStatus, ParseStatus = response.ParseStatus,
@@ -61,6 +63,7 @@ public sealed class CriticalCellEditorModel
{ {
var request = new CriticalCellUpdateRequest( var request = new CriticalCellUpdateRequest(
RawCellText, RawCellText,
QuickParseInput,
DescriptionText, DescriptionText,
RawAffixText, RawAffixText,
ResolveParseStatus(Effects, Branches), ResolveParseStatus(Effects, Branches),
@@ -99,6 +102,7 @@ public sealed class CriticalCellEditorModel
ColumnLabel = ColumnLabel, ColumnLabel = ColumnLabel,
ColumnRole = ColumnRole, ColumnRole = ColumnRole,
RawCellText = RawCellText, RawCellText = RawCellText,
QuickParseInput = QuickParseInput,
DescriptionText = DescriptionText, DescriptionText = DescriptionText,
RawAffixText = RawAffixText, RawAffixText = RawAffixText,
ParseStatus = ParseStatus, ParseStatus = ParseStatus,

View File

@@ -14,6 +14,7 @@ public sealed record CriticalCellEditorResponse(
string ColumnLabel, string ColumnLabel,
string ColumnRole, string ColumnRole,
string RawCellText, string RawCellText,
string QuickParseInput,
string DescriptionText, string DescriptionText,
string? RawAffixText, string? RawAffixText,
string ParseStatus, string ParseStatus,

View File

@@ -9,7 +9,8 @@ public sealed record CriticalCellEditorSnapshot(
bool AreEffectsOverridden, bool AreEffectsOverridden,
bool AreBranchesOverridden, bool AreBranchesOverridden,
IReadOnlyList<CriticalEffectEditorItem> Effects, IReadOnlyList<CriticalEffectEditorItem> Effects,
IReadOnlyList<CriticalBranchEditorItem> Branches) IReadOnlyList<CriticalBranchEditorItem> Branches,
string? QuickParseInput)
{ {
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -21,7 +22,8 @@ public sealed record CriticalCellEditorSnapshot(
request.AreEffectsOverridden, request.AreEffectsOverridden,
request.AreBranchesOverridden, request.AreBranchesOverridden,
request.Effects, request.Effects,
request.Branches); request.Branches,
request.QuickParseInput);
public string ToJson() => public string ToJson() =>
JsonSerializer.Serialize(this, JsonOptions); JsonSerializer.Serialize(this, JsonOptions);

View File

@@ -4,6 +4,7 @@ namespace RolemasterDb.App.Features;
public sealed record CriticalCellUpdateRequest( public sealed record CriticalCellUpdateRequest(
string RawCellText, string RawCellText,
string QuickParseInput,
string DescriptionText, string DescriptionText,
string? RawAffixText, string? RawAffixText,
string ParseStatus, string ParseStatus,

View 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+", " ");
}

View File

@@ -296,7 +296,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
} }
var currentState = CreateCurrentEditorState(result); 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)); return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
} }
@@ -325,8 +325,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
return null; return null;
} }
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken); var content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
var content = SharedParsing.CriticalCellTextParser.Parse(currentState.RawCellText, affixLegend);
var generatedState = CreateGeneratedEditorState(content); var generatedState = CreateGeneratedEditorState(content);
var mergedState = MergeGeneratedState(currentState, generatedState); var mergedState = MergeGeneratedState(currentState, generatedState);
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content)); return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
@@ -370,7 +369,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
await dbContext.SaveChangesAsync(cancellationToken); 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)); return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
} }
@@ -452,6 +451,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.CriticalColumn.Label, result.CriticalColumn.Label,
result.CriticalColumn.Role, result.CriticalColumn.Role,
state.RawCellText, state.RawCellText,
state.QuickParseInput,
state.DescriptionText, state.DescriptionText,
state.RawAffixText, state.RawAffixText,
state.ParseStatus, 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) 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( return new CriticalCellUpdateRequest(
result.RawCellText, result.RawCellText,
snapshotQuickParseInput,
result.DescriptionText, result.DescriptionText,
result.RawAffixText, result.RawAffixText,
result.ParseStatus, result.ParseStatus,
@@ -600,6 +605,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
return new CriticalCellUpdateRequest( return new CriticalCellUpdateRequest(
result.RawCellText, result.RawCellText,
CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches),
result.DescriptionText, result.DescriptionText,
result.RawAffixText, result.RawAffixText,
result.ParseStatus, result.ParseStatus,
@@ -623,17 +629,18 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.ToList(); .ToList();
return new CriticalCellUpdateRequest( return new CriticalCellUpdateRequest(
content.RawCellText, RawCellText: string.Empty,
content.DescriptionText, QuickParseInput: content.RawCellText,
content.RawAffixText, DescriptionText: content.DescriptionText,
ResolveParseStatus(content.Effects, content.Branches), RawAffixText: content.RawAffixText,
SerializeParsedEffects(content.Effects), ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
false, ParsedJson: SerializeParsedEffects(content.Effects),
false, IsDescriptionOverridden: false,
false, IsRawAffixTextOverridden: false,
false, AreEffectsOverridden: false,
effects, AreBranchesOverridden: false,
branches); Effects: effects,
Branches: branches);
} }
private static CriticalCellUpdateRequest MergeGeneratedState( private static CriticalCellUpdateRequest MergeGeneratedState(
@@ -641,6 +648,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
CriticalCellUpdateRequest generatedState) => CriticalCellUpdateRequest generatedState) =>
new( new(
currentState.RawCellText, currentState.RawCellText,
currentState.QuickParseInput,
currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText, currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText,
currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText, currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
generatedState.ParseStatus, generatedState.ParseStatus,
@@ -922,11 +930,11 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync( private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(
RolemasterDbContext dbContext, RolemasterDbContext dbContext,
int tableId, int tableId,
string rawCellText, string quickParseInput,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, 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) => private static bool IsLegendSymbolEffectCode(string effectCode) =>

View File

@@ -843,6 +843,26 @@ textarea {
margin: 0; 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 { .critical-editor-compare-grid {
display: grid; display: grid;
gap: 0.85rem; gap: 0.85rem;

View File

@@ -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 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 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 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) public static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
{ {
@@ -27,6 +35,31 @@ public static class AffixEffectParser
return effects; 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) private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
{ {
if (string.IsNullOrWhiteSpace(line) || line is "-" or "" or "—") 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) => private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) =>
effectCode switch effectCode switch
{ {
@@ -276,4 +477,22 @@ public static class AffixEffectParser
.Replace(" +", "+", StringComparison.Ordinal) .Replace(" +", "+", StringComparison.Ordinal)
.Replace("( ", "(", StringComparison.Ordinal) .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;
}
} }

View 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();
}

View File

@@ -10,6 +10,42 @@ namespace RolemasterDb.ImportTool.Tests;
public sealed class CriticalCellReparseIntegrationTests 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] [Fact]
public void Shared_cell_parser_extracts_base_effects_and_condition_branches() public void Shared_cell_parser_extracts_base_effects_and_condition_branches()
{ {
@@ -49,7 +85,10 @@ public sealed class CriticalCellReparseIntegrationTests
var response = await lookupService.ReparseCriticalCellAsync( var response = await lookupService.ReparseCriticalCellAsync(
"slash", "slash",
resultId, 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.NotNull(response);
Assert.Equal("Strike to thigh.", response!.DescriptionText); Assert.Equal("Strike to thigh.", response!.DescriptionText);
@@ -74,8 +113,9 @@ public sealed class CriticalCellReparseIntegrationTests
"slash", "slash",
resultId, resultId,
CreateEditorRequest( CreateEditorRequest(
"Strike to thigh.\n+10H", "Imported OCR source",
"Curated thigh strike.", "Curated thigh strike.",
quickParseInput: "Strike to thigh.\n+10",
isDescriptionOverridden: true, isDescriptionOverridden: true,
effects: effects:
[ [
@@ -117,8 +157,9 @@ public sealed class CriticalCellReparseIntegrationTests
"slash", "slash",
resultId, resultId,
CreateEditorRequest( CreateEditorRequest(
"Strike to thigh.\n+10H", "Imported OCR source",
"Strike to thigh.", "Strike to thigh.",
quickParseInput: "Strike to thigh.\n+10",
areBranchesOverridden: true, areBranchesOverridden: true,
branches: branches:
[ [
@@ -153,8 +194,9 @@ public sealed class CriticalCellReparseIntegrationTests
var lookupService = new LookupService(CreateDbContextFactory(databasePath)); var lookupService = new LookupService(CreateDbContextFactory(databasePath));
var resultId = await GetSlashResultIdAsync(databasePath); var resultId = await GetSlashResultIdAsync(databasePath);
var request = CreateEditorRequest( var request = CreateEditorRequest(
"Strike to thigh.\n+10H", "Imported OCR source",
"Curated thigh strike.", "Curated thigh strike.",
quickParseInput: "Strike to thigh.\n+10",
isDescriptionOverridden: true, isDescriptionOverridden: true,
effects: effects:
[ [
@@ -187,9 +229,36 @@ public sealed class CriticalCellReparseIntegrationTests
Assert.Contains("\"version\":1", loadResponse.ParsedJson, StringComparison.Ordinal); 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( private static CriticalCellUpdateRequest CreateEditorRequest(
string rawCellText, string rawCellText,
string descriptionText, string descriptionText,
string? quickParseInput = null,
bool isDescriptionOverridden = false, bool isDescriptionOverridden = false,
bool isRawAffixTextOverridden = false, bool isRawAffixTextOverridden = false,
bool areEffectsOverridden = false, bool areEffectsOverridden = false,
@@ -199,6 +268,7 @@ public sealed class CriticalCellReparseIntegrationTests
IReadOnlyList<CriticalBranchEditorItem>? branches = null) => IReadOnlyList<CriticalBranchEditorItem>? branches = null) =>
new( new(
rawCellText, rawCellText,
quickParseInput ?? rawCellText,
descriptionText, descriptionText,
rawAffixText, rawAffixText,
"partial", "partial",
@@ -274,6 +344,18 @@ public sealed class CriticalCellReparseIntegrationTests
.SingleAsync(); .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) private static RolemasterDbContext CreateDbContext(string databasePath)
{ {
var options = new DbContextOptionsBuilder<RolemasterDbContext>() var options = new DbContextOptionsBuilder<RolemasterDbContext>()
@@ -291,4 +373,28 @@ public sealed class CriticalCellReparseIntegrationTests
return new TestRolemasterDbContextFactory(options); 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.");
}
} }