Add quick parse notation for critical editor
This commit is contained in:
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
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 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) =>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
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.");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user