Share critical cell parsing across app and importer
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
<Solution>
|
<Solution>
|
||||||
<Folder Name="/src/">
|
<Folder Name="/src/">
|
||||||
<Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" />
|
<Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" />
|
||||||
|
<Project Path="src/RolemasterDb.CriticalParsing/RolemasterDb.CriticalParsing.csproj" />
|
||||||
<Project Path="src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj" />
|
<Project Path="src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj" />
|
||||||
<Project Path="src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj" />
|
<Project Path="src/RolemasterDb.ImportTool.Tests/RolemasterDb.ImportTool.Tests.csproj" />
|
||||||
</Folder>
|
</Folder>
|
||||||
|
|||||||
@@ -550,6 +550,10 @@ Acceptance criteria:
|
|||||||
|
|
||||||
### Phase 4: Shared parsing assembly
|
### Phase 4: Shared parsing assembly
|
||||||
|
|
||||||
|
Status:
|
||||||
|
|
||||||
|
- implemented in the web app on March 15, 2026
|
||||||
|
|
||||||
Scope:
|
Scope:
|
||||||
|
|
||||||
- extract shared parser/normalizer logic from the import tool
|
- extract shared parser/normalizer logic from the import tool
|
||||||
|
|||||||
@@ -60,12 +60,22 @@
|
|||||||
"rawAffixText": "+8H - 2S",
|
"rawAffixText": "+8H - 2S",
|
||||||
"parseStatus": "verified",
|
"parseStatus": "verified",
|
||||||
"parsedJson": "{}",
|
"parsedJson": "{}",
|
||||||
|
"validationMessages": [],
|
||||||
"effects": [],
|
"effects": [],
|
||||||
"branches": []
|
"branches": []
|
||||||
}</pre>
|
}</pre>
|
||||||
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p>
|
<p class="panel-copy">Use this to retrieve the full editable result graph for one critical-table cell, including nested branches and normalized effects.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="panel">
|
||||||
|
<h2 class="panel-title">Cell re-parse</h2>
|
||||||
|
<p class="panel-copy"><code>POST /api/tables/critical/{slug}/cells/{resultId}/reparse</code></p>
|
||||||
|
<pre class="code-block">{
|
||||||
|
"rawCellText": "Strike to thigh. +8H\nWith greaves: blow glances aside."
|
||||||
|
}</pre>
|
||||||
|
<p class="panel-copy">Re-runs the shared single-cell parser and returns a refreshed editor payload without saving changes.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel">
|
<section class="panel">
|
||||||
<h2 class="panel-title">Cell editor save</h2>
|
<h2 class="panel-title">Cell editor save</h2>
|
||||||
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
|
<p class="panel-copy"><code>PUT /api/tables/critical/{slug}/cells/{resultId}</code></p>
|
||||||
|
|||||||
@@ -207,10 +207,13 @@
|
|||||||
<CriticalCellEditorDialog
|
<CriticalCellEditorDialog
|
||||||
Model="editorModel"
|
Model="editorModel"
|
||||||
IsLoading="isEditorLoading"
|
IsLoading="isEditorLoading"
|
||||||
|
IsReparsing="isEditorReparsing"
|
||||||
IsSaving="isEditorSaving"
|
IsSaving="isEditorSaving"
|
||||||
LoadErrorMessage="@editorLoadError"
|
LoadErrorMessage="@editorLoadError"
|
||||||
|
ReparseErrorMessage="@editorReparseError"
|
||||||
SaveErrorMessage="@editorSaveError"
|
SaveErrorMessage="@editorSaveError"
|
||||||
OnClose="CloseCellEditorAsync"
|
OnClose="CloseCellEditorAsync"
|
||||||
|
OnReparse="ReparseCellEditorAsync"
|
||||||
OnSave="SaveCellEditorAsync" />
|
OnSave="SaveCellEditorAsync" />
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,8 +230,10 @@
|
|||||||
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
|
private bool IsTableSelectionDisabled => isReferenceDataLoading || (referenceData?.CriticalTables.Count ?? 0) == 0;
|
||||||
private bool isEditorOpen;
|
private bool isEditorOpen;
|
||||||
private bool isEditorLoading;
|
private bool isEditorLoading;
|
||||||
|
private bool isEditorReparsing;
|
||||||
private bool isEditorSaving;
|
private bool isEditorSaving;
|
||||||
private string? editorLoadError;
|
private string? editorLoadError;
|
||||||
|
private string? editorReparseError;
|
||||||
private string? editorSaveError;
|
private string? editorSaveError;
|
||||||
private int? editingResultId;
|
private int? editingResultId;
|
||||||
private CriticalCellEditorModel? editorModel;
|
private CriticalCellEditorModel? editorModel;
|
||||||
@@ -328,9 +333,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
editorLoadError = null;
|
editorLoadError = null;
|
||||||
|
editorReparseError = null;
|
||||||
editorSaveError = null;
|
editorSaveError = null;
|
||||||
editorModel = null;
|
editorModel = null;
|
||||||
editingResultId = resultId;
|
editingResultId = resultId;
|
||||||
|
isEditorReparsing = false;
|
||||||
isEditorSaving = false;
|
isEditorSaving = false;
|
||||||
isEditorLoading = true;
|
isEditorLoading = true;
|
||||||
isEditorOpen = true;
|
isEditorOpen = true;
|
||||||
@@ -362,14 +369,47 @@
|
|||||||
{
|
{
|
||||||
isEditorOpen = false;
|
isEditorOpen = false;
|
||||||
isEditorLoading = false;
|
isEditorLoading = false;
|
||||||
|
isEditorReparsing = false;
|
||||||
isEditorSaving = false;
|
isEditorSaving = false;
|
||||||
editorLoadError = null;
|
editorLoadError = null;
|
||||||
|
editorReparseError = null;
|
||||||
editorSaveError = null;
|
editorSaveError = null;
|
||||||
editingResultId = null;
|
editingResultId = null;
|
||||||
editorModel = null;
|
editorModel = null;
|
||||||
await InvokeAsync(StateHasChanged);
|
await InvokeAsync(StateHasChanged);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task ReparseCellEditorAsync()
|
||||||
|
{
|
||||||
|
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isEditorReparsing = true;
|
||||||
|
editorReparseError = null;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var response = await LookupService.ReparseCriticalCellAsync(selectedTableSlug, editingResultId.Value, editorModel.RawCellText);
|
||||||
|
if (response is null)
|
||||||
|
{
|
||||||
|
editorReparseError = "The selected cell could not be re-parsed.";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editorModel = CriticalCellEditorModel.FromResponse(response);
|
||||||
|
}
|
||||||
|
catch (Exception exception)
|
||||||
|
{
|
||||||
|
editorReparseError = exception.Message;
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
isEditorReparsing = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private async Task SaveCellEditorAsync()
|
private async Task SaveCellEditorAsync()
|
||||||
{
|
{
|
||||||
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
|
if (editorModel is null || string.IsNullOrWhiteSpace(selectedTableSlug) || editingResultId is null)
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using RolemasterDb.App.Features;
|
using RolemasterDb.App.Features;
|
||||||
|
|
||||||
namespace RolemasterDb.App.Components.Shared;
|
namespace RolemasterDb.App.Components.Shared;
|
||||||
@@ -35,11 +36,32 @@ public sealed class CriticalBranchEditorModel
|
|||||||
BranchKind,
|
BranchKind,
|
||||||
ConditionKey,
|
ConditionKey,
|
||||||
ConditionText,
|
ConditionText,
|
||||||
ConditionJson,
|
"{}",
|
||||||
RawText,
|
BuildRawText(),
|
||||||
DescriptionText,
|
DescriptionText,
|
||||||
RawAffixText,
|
RawAffixText,
|
||||||
ParsedJson,
|
SerializeParsedEffects(Effects),
|
||||||
SortOrder,
|
SortOrder,
|
||||||
Effects.Select(effect => effect.ToItem()).ToList());
|
Effects.Select(effect => effect.ToItem()).ToList());
|
||||||
|
|
||||||
|
private string BuildRawText()
|
||||||
|
{
|
||||||
|
var condition = ConditionText.Trim();
|
||||||
|
var description = DescriptionText.Trim();
|
||||||
|
var firstLine = string.IsNullOrWhiteSpace(description)
|
||||||
|
? $"{condition}:"
|
||||||
|
: $"{condition}: {description}";
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(RawAffixText))
|
||||||
|
{
|
||||||
|
return firstLine;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $"{firstLine}{Environment.NewLine}{RawAffixText.Trim()}";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string SerializeParsedEffects(IReadOnlyList<CriticalEffectEditorModel> effects) =>
|
||||||
|
effects.Count == 0
|
||||||
|
? "{}"
|
||||||
|
: JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,6 +45,11 @@
|
|||||||
{
|
{
|
||||||
<EditForm Model="Model" OnSubmit="HandleSubmitAsync" class="critical-editor-form">
|
<EditForm Model="Model" OnSubmit="HandleSubmitAsync" class="critical-editor-form">
|
||||||
<div class="critical-editor-body">
|
<div class="critical-editor-body">
|
||||||
|
@if (!string.IsNullOrWhiteSpace(ReparseErrorMessage))
|
||||||
|
{
|
||||||
|
<p class="error-text critical-editor-error">@ReparseErrorMessage</p>
|
||||||
|
}
|
||||||
|
|
||||||
@if (!string.IsNullOrWhiteSpace(SaveErrorMessage))
|
@if (!string.IsNullOrWhiteSpace(SaveErrorMessage))
|
||||||
{
|
{
|
||||||
<p class="error-text critical-editor-error">@SaveErrorMessage</p>
|
<p class="error-text critical-editor-error">@SaveErrorMessage</p>
|
||||||
@@ -79,11 +84,23 @@
|
|||||||
<h4>Raw Text</h4>
|
<h4>Raw Text</h4>
|
||||||
<p class="muted">Update the source text, then adjust the visible card fields below.</p>
|
<p class="muted">Update the source text, then adjust the visible card fields below.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<button type="button" class="btn-ritual" @onclick="OnReparse" disabled="@IsSaving || IsReparsing">
|
||||||
|
@(IsReparsing ? "Re-Parsing..." : "Re-Parse Raw Text")
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="field-shell">
|
<div class="field-shell">
|
||||||
<label>Raw Cell Text</label>
|
<label>Raw Cell Text</label>
|
||||||
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
|
<InputTextArea class="input-shell critical-editor-textarea tall" @bind-Value="Model.RawCellText" />
|
||||||
</div>
|
</div>
|
||||||
|
@if (Model.ValidationMessages.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="critical-editor-validation-list">
|
||||||
|
@foreach (var message in Model.ValidationMessages)
|
||||||
|
{
|
||||||
|
<p class="critical-editor-validation-item">@message</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
<div class="field-shell">
|
<div class="field-shell">
|
||||||
<label>Result Text Override</label>
|
<label>Result Text Override</label>
|
||||||
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" />
|
<InputTextArea class="input-shell critical-editor-textarea compact" @bind-Value="Model.DescriptionText" />
|
||||||
@@ -231,18 +248,27 @@
|
|||||||
[Parameter]
|
[Parameter]
|
||||||
public bool IsLoading { get; set; }
|
public bool IsLoading { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public bool IsReparsing { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public bool IsSaving { get; set; }
|
public bool IsSaving { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string? LoadErrorMessage { get; set; }
|
public string? LoadErrorMessage { get; set; }
|
||||||
|
|
||||||
|
[Parameter]
|
||||||
|
public string? ReparseErrorMessage { get; set; }
|
||||||
|
|
||||||
[Parameter]
|
[Parameter]
|
||||||
public string? SaveErrorMessage { get; set; }
|
public string? SaveErrorMessage { get; set; }
|
||||||
|
|
||||||
[Parameter, EditorRequired]
|
[Parameter, EditorRequired]
|
||||||
public EventCallback OnClose { get; set; }
|
public EventCallback OnClose { get; set; }
|
||||||
|
|
||||||
|
[Parameter, EditorRequired]
|
||||||
|
public EventCallback OnReparse { get; set; }
|
||||||
|
|
||||||
[Parameter, EditorRequired]
|
[Parameter, EditorRequired]
|
||||||
public EventCallback OnSave { get; set; }
|
public EventCallback OnSave { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json;
|
||||||
using RolemasterDb.App.Features;
|
using RolemasterDb.App.Features;
|
||||||
|
|
||||||
namespace RolemasterDb.App.Components.Shared;
|
namespace RolemasterDb.App.Components.Shared;
|
||||||
@@ -19,6 +20,7 @@ public sealed class CriticalCellEditorModel
|
|||||||
public string? RawAffixText { get; set; }
|
public string? RawAffixText { get; set; }
|
||||||
public string ParseStatus { get; set; } = string.Empty;
|
public string ParseStatus { get; set; } = string.Empty;
|
||||||
public string ParsedJson { get; set; } = "{}";
|
public string ParsedJson { get; set; } = "{}";
|
||||||
|
public List<string> ValidationMessages { get; set; } = [];
|
||||||
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
|
public List<CriticalEffectEditorModel> Effects { get; set; } = [];
|
||||||
public List<CriticalBranchEditorModel> Branches { get; set; } = [];
|
public List<CriticalBranchEditorModel> Branches { get; set; } = [];
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ public sealed class CriticalCellEditorModel
|
|||||||
RawAffixText = response.RawAffixText,
|
RawAffixText = response.RawAffixText,
|
||||||
ParseStatus = response.ParseStatus,
|
ParseStatus = response.ParseStatus,
|
||||||
ParsedJson = response.ParsedJson,
|
ParsedJson = response.ParsedJson,
|
||||||
|
ValidationMessages = response.ValidationMessages.ToList(),
|
||||||
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
|
Effects = response.Effects.Select(CriticalEffectEditorModel.FromItem).ToList(),
|
||||||
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
|
Branches = response.Branches.Select(CriticalBranchEditorModel.FromItem).ToList()
|
||||||
};
|
};
|
||||||
@@ -49,8 +52,8 @@ public sealed class CriticalCellEditorModel
|
|||||||
RawCellText,
|
RawCellText,
|
||||||
DescriptionText,
|
DescriptionText,
|
||||||
RawAffixText,
|
RawAffixText,
|
||||||
ParseStatus,
|
ResolveParseStatus(Effects, Branches),
|
||||||
ParsedJson,
|
SerializeParsedEffects(Effects),
|
||||||
Effects.Select(effect => effect.ToItem()).ToList(),
|
Effects.Select(effect => effect.ToItem()).ToList(),
|
||||||
Branches
|
Branches
|
||||||
.OrderBy(branch => branch.SortOrder)
|
.OrderBy(branch => branch.SortOrder)
|
||||||
@@ -60,4 +63,16 @@ public sealed class CriticalCellEditorModel
|
|||||||
return branch.ToItem();
|
return branch.ToItem();
|
||||||
})
|
})
|
||||||
.ToList());
|
.ToList());
|
||||||
|
|
||||||
|
private static string ResolveParseStatus(
|
||||||
|
IReadOnlyList<CriticalEffectEditorModel> effects,
|
||||||
|
IReadOnlyList<CriticalBranchEditorModel> branches) =>
|
||||||
|
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
|
||||||
|
? "partial"
|
||||||
|
: "raw";
|
||||||
|
|
||||||
|
private static string SerializeParsedEffects(IReadOnlyList<CriticalEffectEditorModel> effects) =>
|
||||||
|
effects.Count == 0
|
||||||
|
? "{}"
|
||||||
|
: JsonSerializer.Serialize(new { effects = effects.Select(effect => effect.ToItem()).ToList() });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ namespace RolemasterDb.App.Domain;
|
|||||||
|
|
||||||
public static class CriticalEffectCodes
|
public static class CriticalEffectCodes
|
||||||
{
|
{
|
||||||
public const string DirectHits = "direct_hits";
|
public const string DirectHits = RolemasterDb.CriticalParsing.CriticalEffectCodes.DirectHits;
|
||||||
public const string MustParryRounds = "must_parry_rounds";
|
public const string MustParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.MustParryRounds;
|
||||||
public const string NoParryRounds = "no_parry_rounds";
|
public const string NoParryRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.NoParryRounds;
|
||||||
public const string StunnedRounds = "stunned_rounds";
|
public const string StunnedRounds = RolemasterDb.CriticalParsing.CriticalEffectCodes.StunnedRounds;
|
||||||
public const string BleedPerRound = "bleed_per_round";
|
public const string BleedPerRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.BleedPerRound;
|
||||||
public const string FoePenalty = "foe_penalty";
|
public const string FoePenalty = RolemasterDb.CriticalParsing.CriticalEffectCodes.FoePenalty;
|
||||||
public const string AttackerBonusNextRound = "attacker_bonus_next_round";
|
public const string AttackerBonusNextRound = RolemasterDb.CriticalParsing.CriticalEffectCodes.AttackerBonusNextRound;
|
||||||
public const string PowerPointModifier = "power_point_modifier";
|
public const string PowerPointModifier = RolemasterDb.CriticalParsing.CriticalEffectCodes.PowerPointModifier;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,5 +18,6 @@ public sealed record CriticalCellEditorResponse(
|
|||||||
string? RawAffixText,
|
string? RawAffixText,
|
||||||
string ParseStatus,
|
string ParseStatus,
|
||||||
string ParsedJson,
|
string ParsedJson,
|
||||||
|
IReadOnlyList<string> ValidationMessages,
|
||||||
IReadOnlyList<CriticalEffectEditorItem> Effects,
|
IReadOnlyList<CriticalEffectEditorItem> Effects,
|
||||||
IReadOnlyList<CriticalBranchEditorItem> Branches);
|
IReadOnlyList<CriticalBranchEditorItem> Branches);
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record CriticalCellReparseRequest(
|
||||||
|
string RawCellText);
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
using System.Linq;
|
using System.Linq;
|
||||||
|
using System.Text.Json;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using RolemasterDb.App.Data;
|
using RolemasterDb.App.Data;
|
||||||
using RolemasterDb.App.Domain;
|
using RolemasterDb.App.Domain;
|
||||||
|
using SharedParsing = RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
namespace RolemasterDb.App.Features;
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
@@ -286,6 +288,36 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
return result is null ? null : CreateCellEditorResponse(result);
|
return result is null ? null : CreateCellEditorResponse(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
|
||||||
|
string slug,
|
||||||
|
int resultId,
|
||||||
|
string rawCellText,
|
||||||
|
CancellationToken cancellationToken = default)
|
||||||
|
{
|
||||||
|
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||||||
|
|
||||||
|
var normalizedSlug = NormalizeSlug(slug);
|
||||||
|
var result = await dbContext.CriticalResults
|
||||||
|
.AsNoTracking()
|
||||||
|
.AsSplitQuery()
|
||||||
|
.Include(item => item.CriticalTable)
|
||||||
|
.Include(item => item.CriticalColumn)
|
||||||
|
.Include(item => item.CriticalGroup)
|
||||||
|
.Include(item => item.CriticalRollBand)
|
||||||
|
.SingleOrDefaultAsync(
|
||||||
|
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
if (result is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, result.CriticalTableId, cancellationToken);
|
||||||
|
var content = SharedParsing.CriticalCellTextParser.Parse(rawCellText, affixLegend);
|
||||||
|
return CreateCellEditorResponse(result, content);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
|
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
|
||||||
string slug,
|
string slug,
|
||||||
int resultId,
|
int resultId,
|
||||||
@@ -402,6 +434,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
result.RawAffixText,
|
result.RawAffixText,
|
||||||
result.ParseStatus,
|
result.ParseStatus,
|
||||||
result.ParsedJson,
|
result.ParsedJson,
|
||||||
|
[],
|
||||||
result.Effects
|
result.Effects
|
||||||
.OrderBy(effect => effect.Id)
|
.OrderBy(effect => effect.Id)
|
||||||
.Select(CreateEffectEditorItem)
|
.Select(CreateEffectEditorItem)
|
||||||
@@ -411,6 +444,34 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
.Select(CreateBranchEditorItem)
|
.Select(CreateBranchEditorItem)
|
||||||
.ToList());
|
.ToList());
|
||||||
|
|
||||||
|
private static CriticalCellEditorResponse CreateCellEditorResponse(
|
||||||
|
CriticalResult result,
|
||||||
|
SharedParsing.CriticalCellParseContent content) =>
|
||||||
|
new(
|
||||||
|
result.Id,
|
||||||
|
result.CriticalTable.Slug,
|
||||||
|
result.CriticalTable.DisplayName,
|
||||||
|
result.CriticalTable.SourceDocument,
|
||||||
|
result.CriticalRollBand.Label,
|
||||||
|
result.CriticalGroup?.GroupKey,
|
||||||
|
result.CriticalGroup?.Label,
|
||||||
|
result.CriticalColumn.ColumnKey,
|
||||||
|
result.CriticalColumn.Label,
|
||||||
|
result.CriticalColumn.Role,
|
||||||
|
content.RawCellText,
|
||||||
|
content.DescriptionText,
|
||||||
|
content.RawAffixText,
|
||||||
|
ResolveParseStatus(content.Effects, content.Branches),
|
||||||
|
SerializeParsedEffects(content.Effects),
|
||||||
|
content.ValidationErrors.ToList(),
|
||||||
|
content.Effects
|
||||||
|
.Select(CreateEffectEditorItem)
|
||||||
|
.ToList(),
|
||||||
|
content.Branches
|
||||||
|
.OrderBy(branch => branch.SortOrder)
|
||||||
|
.Select(CreateBranchEditorItem)
|
||||||
|
.ToList());
|
||||||
|
|
||||||
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
||||||
new(
|
new(
|
||||||
branch.BranchKind,
|
branch.BranchKind,
|
||||||
@@ -441,6 +502,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
.Select(CreateEffectEditorItem)
|
.Select(CreateEffectEditorItem)
|
||||||
.ToList());
|
.ToList());
|
||||||
|
|
||||||
|
private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch) =>
|
||||||
|
new(
|
||||||
|
branch.BranchKind,
|
||||||
|
branch.ConditionKey,
|
||||||
|
branch.ConditionText,
|
||||||
|
"{}",
|
||||||
|
branch.RawText,
|
||||||
|
branch.DescriptionText,
|
||||||
|
branch.RawAffixText,
|
||||||
|
SerializeParsedEffects(branch.Effects),
|
||||||
|
branch.SortOrder,
|
||||||
|
branch.Effects
|
||||||
|
.Select(CreateEffectEditorItem)
|
||||||
|
.ToList());
|
||||||
|
|
||||||
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) =>
|
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect) =>
|
||||||
new(
|
new(
|
||||||
effect.EffectCode,
|
effect.EffectCode,
|
||||||
@@ -456,6 +532,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
effect.SourceType,
|
effect.SourceType,
|
||||||
effect.SourceText);
|
effect.SourceText);
|
||||||
|
|
||||||
|
private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect) =>
|
||||||
|
new(
|
||||||
|
effect.EffectCode,
|
||||||
|
effect.Target,
|
||||||
|
effect.ValueInteger,
|
||||||
|
null,
|
||||||
|
effect.ValueExpression,
|
||||||
|
effect.DurationRounds,
|
||||||
|
effect.PerRound,
|
||||||
|
effect.Modifier,
|
||||||
|
effect.BodyPart,
|
||||||
|
effect.IsPermanent,
|
||||||
|
effect.SourceType,
|
||||||
|
effect.SourceText);
|
||||||
|
|
||||||
private static void ReplaceBaseEffects(
|
private static void ReplaceBaseEffects(
|
||||||
RolemasterDbContext dbContext,
|
RolemasterDbContext dbContext,
|
||||||
CriticalResult result,
|
CriticalResult result,
|
||||||
@@ -520,10 +611,120 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
|
|||||||
Modifier = effect.Modifier,
|
Modifier = effect.Modifier,
|
||||||
BodyPart = NormalizeOptionalText(effect.BodyPart),
|
BodyPart = NormalizeOptionalText(effect.BodyPart),
|
||||||
IsPermanent = effect.IsPermanent,
|
IsPermanent = effect.IsPermanent,
|
||||||
SourceType = effect.SourceType.Trim(),
|
SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
|
||||||
SourceText = NormalizeOptionalText(effect.SourceText)
|
SourceText = NormalizeOptionalText(effect.SourceText)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private static string ResolveParseStatus(
|
||||||
|
IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects,
|
||||||
|
IReadOnlyList<SharedParsing.ParsedCriticalBranch> branches) =>
|
||||||
|
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
|
||||||
|
? "partial"
|
||||||
|
: "raw";
|
||||||
|
|
||||||
|
private static string SerializeParsedEffects(IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects) =>
|
||||||
|
effects.Count == 0
|
||||||
|
? "{}"
|
||||||
|
: JsonSerializer.Serialize(new
|
||||||
|
{
|
||||||
|
effects = effects.Select(effect => new
|
||||||
|
{
|
||||||
|
effect.EffectCode,
|
||||||
|
effect.Target,
|
||||||
|
effect.ValueInteger,
|
||||||
|
effect.ValueExpression,
|
||||||
|
effect.DurationRounds,
|
||||||
|
effect.PerRound,
|
||||||
|
effect.Modifier,
|
||||||
|
effect.BodyPart,
|
||||||
|
effect.IsPermanent,
|
||||||
|
effect.SourceType,
|
||||||
|
effect.SourceText
|
||||||
|
}).ToList()
|
||||||
|
});
|
||||||
|
|
||||||
|
private static async Task<SharedParsing.AffixLegend> BuildSharedAffixLegendAsync(
|
||||||
|
RolemasterDbContext dbContext,
|
||||||
|
int tableId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var effectRows = await dbContext.CriticalEffects
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(item =>
|
||||||
|
(item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId) ||
|
||||||
|
(item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId))
|
||||||
|
.Select(item => new { item.EffectCode, item.SourceType, item.SourceText })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var symbolEffects = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
var supportsFoePenalty = false;
|
||||||
|
var supportsAttackerBonus = false;
|
||||||
|
var supportsPowerPointModifier = false;
|
||||||
|
|
||||||
|
foreach (var effectRow in effectRows)
|
||||||
|
{
|
||||||
|
supportsFoePenalty |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.FoePenalty, StringComparison.Ordinal);
|
||||||
|
supportsAttackerBonus |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.AttackerBonusNextRound, StringComparison.Ordinal);
|
||||||
|
supportsPowerPointModifier |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.PowerPointModifier, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (!string.Equals(effectRow.SourceType, "symbol", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
!IsLegendSymbolEffectCode(effectRow.EffectCode) ||
|
||||||
|
!TryExtractLegendSymbol(effectRow.SourceText, out var symbol))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
symbolEffects.TryAdd(symbol, effectRow.EffectCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SharedParsing.AffixLegend(
|
||||||
|
symbolEffects,
|
||||||
|
supportsPowerPointModifier ? ["P"] : [],
|
||||||
|
supportsFoePenalty,
|
||||||
|
supportsAttackerBonus,
|
||||||
|
supportsPowerPointModifier);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool IsLegendSymbolEffectCode(string effectCode) =>
|
||||||
|
effectCode is CriticalEffectCodes.MustParryRounds
|
||||||
|
or CriticalEffectCodes.NoParryRounds
|
||||||
|
or CriticalEffectCodes.StunnedRounds
|
||||||
|
or CriticalEffectCodes.BleedPerRound;
|
||||||
|
|
||||||
|
private static bool TryExtractLegendSymbol(string? sourceText, out string symbol)
|
||||||
|
{
|
||||||
|
symbol = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(sourceText))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var candidate = new string(sourceText
|
||||||
|
.Where(character =>
|
||||||
|
!char.IsWhiteSpace(character) &&
|
||||||
|
!char.IsDigit(character) &&
|
||||||
|
character is not ('+' or '-' or '–' or '(' or ')' or '/') &&
|
||||||
|
!char.IsLetter(character))
|
||||||
|
.ToArray());
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var distinctSymbols = candidate
|
||||||
|
.Distinct()
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (distinctSymbols.Count != 1)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
symbol = distinctSymbols[0].ToString();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? NormalizeOptionalText(string? value) =>
|
private static string? NormalizeOptionalText(string? value) =>
|
||||||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i
|
|||||||
var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
|
var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken);
|
||||||
return result is null ? Results.NotFound() : Results.Ok(result);
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
});
|
});
|
||||||
|
api.MapPost("/tables/critical/{slug}/cells/{resultId:int}/reparse", async (string slug, int resultId, CriticalCellReparseRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
|
||||||
|
{
|
||||||
|
var result = await lookupService.ReparseCriticalCellAsync(slug, resultId, request.RawCellText, cancellationToken);
|
||||||
|
return result is null ? Results.NotFound() : Results.Ok(result);
|
||||||
|
});
|
||||||
api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
|
api.MapPut("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, int resultId, CriticalCellUpdateRequest request, LookupService lookupService, CancellationToken cancellationToken) =>
|
||||||
{
|
{
|
||||||
var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);
|
var result = await lookupService.UpdateCriticalCellAsync(slug, resultId, request, cancellationToken);
|
||||||
|
|||||||
@@ -16,4 +16,8 @@
|
|||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -830,6 +830,19 @@ textarea {
|
|||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.critical-editor-validation-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.45rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.critical-editor-validation-item {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.65rem 0.8rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: rgba(184, 121, 59, 0.12);
|
||||||
|
color: #6b4c29;
|
||||||
|
}
|
||||||
|
|
||||||
.critical-editor-effect-grid {
|
.critical-editor-effect-grid {
|
||||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||||
}
|
}
|
||||||
|
|||||||
279
src/RolemasterDb.CriticalParsing/AffixEffectParser.cs
Normal file
279
src/RolemasterDb.CriticalParsing/AffixEffectParser.cs
Normal file
@@ -0,0 +1,279 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public static class AffixEffectParser
|
||||||
|
{
|
||||||
|
private const string FoeTarget = "foe";
|
||||||
|
|
||||||
|
private static readonly Regex DirectHitsRegex = new(@"[+-]\s*\d+\s*H\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex PowerPointModifierRegex = new(@"\+\s*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex ModifierRegex = new(@"\((?<noise>[^0-9+\-)]*)(?<sign>[+-])\s*(?<value>\d+)\)", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawAffixText))
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var effects = new List<ParsedCriticalEffect>();
|
||||||
|
|
||||||
|
foreach (var rawLine in rawAffixText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||||
|
{
|
||||||
|
ParseLine(CriticalCellParserSupport.CollapseWhitespace(rawLine), affixLegend, effects);
|
||||||
|
}
|
||||||
|
|
||||||
|
return effects;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(line) || line is "-" or "–" or "—")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var consumedRanges = new List<(int Start, int End)>();
|
||||||
|
var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>();
|
||||||
|
|
||||||
|
AddMatches(
|
||||||
|
DirectHitsRegex.Matches(line),
|
||||||
|
matchedEffects,
|
||||||
|
consumedRanges,
|
||||||
|
match =>
|
||||||
|
{
|
||||||
|
var hits = ParseSignedInteger(match.Value);
|
||||||
|
return new ParsedCriticalEffect(
|
||||||
|
CriticalEffectCodes.DirectHits,
|
||||||
|
FoeTarget,
|
||||||
|
hits,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
NormalizeToken(match.Value));
|
||||||
|
});
|
||||||
|
|
||||||
|
if (affixLegend.SupportsPowerPointModifier)
|
||||||
|
{
|
||||||
|
AddMatches(
|
||||||
|
PowerPointModifierRegex.Matches(line),
|
||||||
|
matchedEffects,
|
||||||
|
consumedRanges,
|
||||||
|
match => new ParsedCriticalEffect(
|
||||||
|
CriticalEffectCodes.PowerPointModifier,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
CriticalCellParserSupport.CollapseWhitespace(match.Groups["expression"].Value),
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
NormalizeToken(match.Value)));
|
||||||
|
}
|
||||||
|
|
||||||
|
AddMatches(
|
||||||
|
ModifierRegex.Matches(line),
|
||||||
|
matchedEffects,
|
||||||
|
consumedRanges,
|
||||||
|
match =>
|
||||||
|
{
|
||||||
|
var modifier = BuildModifier(match);
|
||||||
|
if (modifier is null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifier.Value < 0 && affixLegend.SupportsFoePenalty)
|
||||||
|
{
|
||||||
|
return new ParsedCriticalEffect(
|
||||||
|
CriticalEffectCodes.FoePenalty,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
modifier.Value,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
NormalizeToken(match.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modifier.Value > 0 && affixLegend.SupportsAttackerBonus)
|
||||||
|
{
|
||||||
|
return new ParsedCriticalEffect(
|
||||||
|
CriticalEffectCodes.AttackerBonusNextRound,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
modifier.Value,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
NormalizeToken(match.Value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
var symbolClusterRegex = CreateSymbolClusterRegex(affixLegend.EffectSymbols);
|
||||||
|
if (symbolClusterRegex is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (Match match in symbolClusterRegex.Matches(line))
|
||||||
|
{
|
||||||
|
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var magnitude = match.Groups["count"].Success
|
||||||
|
? int.Parse(match.Groups["count"].Value)
|
||||||
|
: 1;
|
||||||
|
var matchedText = NormalizeToken(match.Value);
|
||||||
|
|
||||||
|
foreach (var symbol in match.Groups["symbols"].Value.Select(character => character.ToString()))
|
||||||
|
{
|
||||||
|
var effectCode = affixLegend.ResolveEffectCode(symbol);
|
||||||
|
if (effectCode is null)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (matchedEffects.Count > 0)
|
||||||
|
{
|
||||||
|
effects.AddRange(matchedEffects
|
||||||
|
.OrderBy(item => item.Index)
|
||||||
|
.Select(item => item.Effect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) =>
|
||||||
|
effectCode switch
|
||||||
|
{
|
||||||
|
CriticalEffectCodes.MustParryRounds => new ParsedCriticalEffect(
|
||||||
|
effectCode,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
magnitude,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
sourceText),
|
||||||
|
CriticalEffectCodes.NoParryRounds => new ParsedCriticalEffect(
|
||||||
|
effectCode,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
magnitude,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
sourceText),
|
||||||
|
CriticalEffectCodes.StunnedRounds => new ParsedCriticalEffect(
|
||||||
|
effectCode,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
magnitude,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
sourceText),
|
||||||
|
CriticalEffectCodes.BleedPerRound => new ParsedCriticalEffect(
|
||||||
|
effectCode,
|
||||||
|
FoeTarget,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
magnitude,
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
false,
|
||||||
|
"symbol",
|
||||||
|
sourceText),
|
||||||
|
_ => throw new InvalidOperationException($"Unsupported symbol effect code '{effectCode}'.")
|
||||||
|
};
|
||||||
|
|
||||||
|
private static Regex? CreateSymbolClusterRegex(IReadOnlySet<string> symbols)
|
||||||
|
{
|
||||||
|
if (symbols.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var escapedSymbols = string.Concat(symbols.Select(Regex.Escape));
|
||||||
|
return new Regex(
|
||||||
|
$@"(?<![A-Za-z0-9])(?:(?<count>\d+)\s*)?(?<symbols>[{escapedSymbols}]+)",
|
||||||
|
RegexOptions.Compiled);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void AddMatches(
|
||||||
|
MatchCollection matches,
|
||||||
|
List<(int Index, ParsedCriticalEffect Effect)> matchedEffects,
|
||||||
|
List<(int Start, int End)> consumedRanges,
|
||||||
|
Func<Match, ParsedCriticalEffect?> createEffect)
|
||||||
|
{
|
||||||
|
foreach (Match match in matches)
|
||||||
|
{
|
||||||
|
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
consumedRanges.Add((match.Index, match.Index + match.Length));
|
||||||
|
|
||||||
|
var effect = createEffect(match);
|
||||||
|
if (effect is not null)
|
||||||
|
{
|
||||||
|
matchedEffects.Add((match.Index, effect));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) =>
|
||||||
|
consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length);
|
||||||
|
|
||||||
|
private static int ParseSignedInteger(string value) =>
|
||||||
|
int.Parse(value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("H", string.Empty, StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
private static int? BuildModifier(Match match)
|
||||||
|
{
|
||||||
|
if (!int.TryParse(match.Groups["value"].Value, out var absoluteValue))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal)
|
||||||
|
? -absoluteValue
|
||||||
|
: absoluteValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeToken(string value) =>
|
||||||
|
CriticalCellParserSupport.CollapseWhitespace(value)
|
||||||
|
.Replace(" +", "+", StringComparison.Ordinal)
|
||||||
|
.Replace("( ", "(", StringComparison.Ordinal)
|
||||||
|
.Replace(" )", ")", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
50
src/RolemasterDb.CriticalParsing/AffixLegend.cs
Normal file
50
src/RolemasterDb.CriticalParsing/AffixLegend.cs
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public sealed class AffixLegend
|
||||||
|
{
|
||||||
|
public static AffixLegend Empty { get; } = new(
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal),
|
||||||
|
[],
|
||||||
|
supportsFoePenalty: false,
|
||||||
|
supportsAttackerBonus: false,
|
||||||
|
supportsPowerPointModifier: false);
|
||||||
|
|
||||||
|
public AffixLegend(
|
||||||
|
IReadOnlyDictionary<string, string> symbolEffects,
|
||||||
|
IReadOnlyCollection<string> classificationOnlySymbols,
|
||||||
|
bool supportsFoePenalty,
|
||||||
|
bool supportsAttackerBonus,
|
||||||
|
bool supportsPowerPointModifier)
|
||||||
|
{
|
||||||
|
SymbolEffects = new Dictionary<string, string>(symbolEffects, StringComparer.Ordinal);
|
||||||
|
EffectSymbols = new HashSet<string>(SymbolEffects.Keys, StringComparer.Ordinal);
|
||||||
|
|
||||||
|
var classificationSymbols = new HashSet<string>(EffectSymbols, StringComparer.Ordinal);
|
||||||
|
foreach (var symbol in classificationOnlySymbols)
|
||||||
|
{
|
||||||
|
classificationSymbols.Add(symbol);
|
||||||
|
}
|
||||||
|
|
||||||
|
ClassificationSymbols = classificationSymbols;
|
||||||
|
SupportsFoePenalty = supportsFoePenalty;
|
||||||
|
SupportsAttackerBonus = supportsAttackerBonus;
|
||||||
|
SupportsPowerPointModifier = supportsPowerPointModifier;
|
||||||
|
}
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string> SymbolEffects { get; }
|
||||||
|
|
||||||
|
public IReadOnlySet<string> EffectSymbols { get; }
|
||||||
|
|
||||||
|
public IReadOnlySet<string> ClassificationSymbols { get; }
|
||||||
|
|
||||||
|
public bool SupportsFoePenalty { get; }
|
||||||
|
|
||||||
|
public bool SupportsAttackerBonus { get; }
|
||||||
|
|
||||||
|
public bool SupportsPowerPointModifier { get; }
|
||||||
|
|
||||||
|
public string? ResolveEffectCode(string symbol) =>
|
||||||
|
SymbolEffects.TryGetValue(symbol, out var effectCode)
|
||||||
|
? effectCode
|
||||||
|
: null;
|
||||||
|
}
|
||||||
19
src/RolemasterDb.CriticalParsing/CriticalCellParseContent.cs
Normal file
19
src/RolemasterDb.CriticalParsing/CriticalCellParseContent.cs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public sealed class CriticalCellParseContent(
|
||||||
|
IReadOnlyList<string> baseLines,
|
||||||
|
string rawCellText,
|
||||||
|
string descriptionText,
|
||||||
|
string? rawAffixText,
|
||||||
|
IReadOnlyList<ParsedCriticalEffect> effects,
|
||||||
|
IReadOnlyList<ParsedCriticalBranch> branches,
|
||||||
|
IReadOnlyList<string> validationErrors)
|
||||||
|
{
|
||||||
|
public IReadOnlyList<string> BaseLines { get; } = baseLines;
|
||||||
|
public string RawCellText { get; } = rawCellText;
|
||||||
|
public string DescriptionText { get; } = descriptionText;
|
||||||
|
public string? RawAffixText { get; } = rawAffixText;
|
||||||
|
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
|
||||||
|
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
|
||||||
|
public IReadOnlyList<string> ValidationErrors { get; } = validationErrors;
|
||||||
|
}
|
||||||
118
src/RolemasterDb.CriticalParsing/CriticalCellParserSupport.cs
Normal file
118
src/RolemasterDb.CriticalParsing/CriticalCellParserSupport.cs
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public static class CriticalCellParserSupport
|
||||||
|
{
|
||||||
|
private static readonly Regex NumericAffixLineRegex = new(@"^\d+(?:H|∑|∏|π|∫|\s*[–-])", RegexOptions.Compiled);
|
||||||
|
private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|–)\d+\)$", RegexOptions.Compiled);
|
||||||
|
|
||||||
|
public static bool IsAffixLikeLine(string line, IReadOnlySet<string> affixLegendSymbols)
|
||||||
|
{
|
||||||
|
var value = line.Trim();
|
||||||
|
if (value.Length == 0)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is "-" or "\u2013" or "\u2014")
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (IsConditionalBranchStartLine(value))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (affixLegendSymbols.Count > 0 &&
|
||||||
|
affixLegendSymbols.Any(symbol => value.Contains(symbol, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
if (value.Any(char.IsDigit))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var remainder = value;
|
||||||
|
foreach (var symbol in affixLegendSymbols.OrderByDescending(item => item.Length))
|
||||||
|
{
|
||||||
|
remainder = remainder.Replace(symbol, string.Empty, StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
remainder = remainder
|
||||||
|
.Replace("+", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("-", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("–", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("(", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace(")", string.Empty, StringComparison.Ordinal)
|
||||||
|
.Replace("/", string.Empty, StringComparison.Ordinal);
|
||||||
|
|
||||||
|
if (string.IsNullOrWhiteSpace(remainder))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value.StartsWith("+", StringComparison.Ordinal) ||
|
||||||
|
value.StartsWith("\u2211", StringComparison.Ordinal) ||
|
||||||
|
value.StartsWith("\u220F", StringComparison.Ordinal) ||
|
||||||
|
value.StartsWith("\u03C0", StringComparison.Ordinal) ||
|
||||||
|
value.StartsWith("\u222B", StringComparison.Ordinal) ||
|
||||||
|
StandaloneModifierAffixLineRegex.IsMatch(value) ||
|
||||||
|
NumericAffixLineRegex.IsMatch(value) ||
|
||||||
|
value.Contains(" - ", StringComparison.Ordinal) ||
|
||||||
|
value.Contains(" – ", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static int CountLineTypeSegments(IReadOnlyList<string> lines, IReadOnlySet<string> affixLegendSymbols)
|
||||||
|
{
|
||||||
|
var segmentCount = 0;
|
||||||
|
bool? previousIsAffix = null;
|
||||||
|
|
||||||
|
foreach (var line in lines)
|
||||||
|
{
|
||||||
|
var currentIsAffix = IsAffixLikeLine(line, affixLegendSymbols);
|
||||||
|
if (previousIsAffix == currentIsAffix)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
segmentCount++;
|
||||||
|
previousIsAffix = currentIsAffix;
|
||||||
|
}
|
||||||
|
|
||||||
|
return segmentCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string CollapseWhitespace(string value) =>
|
||||||
|
Regex.Replace(value.Trim(), @"\s+", " ");
|
||||||
|
|
||||||
|
public static bool IsConditionalBranchStartLine(string value)
|
||||||
|
{
|
||||||
|
var normalized = value.Trim();
|
||||||
|
if (!normalized.Contains(':', StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalized.StartsWith("with ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("w/ ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("w/o ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("without ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("if ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("while ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("until ", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
normalized.StartsWith("unless ", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string? NormalizeConditionKey(string conditionText)
|
||||||
|
{
|
||||||
|
var normalized = CollapseWhitespace(conditionText)
|
||||||
|
.ToLowerInvariant()
|
||||||
|
.Replace("w/o", "without", StringComparison.Ordinal)
|
||||||
|
.Replace("w/", "with", StringComparison.Ordinal);
|
||||||
|
normalized = Regex.Replace(normalized, @"[^a-z0-9]+", "_");
|
||||||
|
normalized = normalized.Trim('_');
|
||||||
|
return normalized.Length == 0 ? null : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
127
src/RolemasterDb.CriticalParsing/CriticalCellTextParser.cs
Normal file
127
src/RolemasterDb.CriticalParsing/CriticalCellTextParser.cs
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public static class CriticalCellTextParser
|
||||||
|
{
|
||||||
|
public static CriticalCellParseContent Parse(string rawCellText, AffixLegend affixLegend)
|
||||||
|
{
|
||||||
|
var lines = rawCellText
|
||||||
|
.Split(["\r\n", "\n", "\r"], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.ToList();
|
||||||
|
return Parse(lines, affixLegend);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static CriticalCellParseContent Parse(IReadOnlyList<string> lines, AffixLegend affixLegend)
|
||||||
|
{
|
||||||
|
var validationErrors = new List<string>();
|
||||||
|
var branchStartIndexes = FindBranchStartIndexes(lines);
|
||||||
|
var baseLineCount = branchStartIndexes.Count == 0 ? lines.Count : branchStartIndexes[0];
|
||||||
|
var baseLines = lines.Take(baseLineCount).ToList();
|
||||||
|
var branches = new List<ParsedCriticalBranch>();
|
||||||
|
var affixLegendSymbols = affixLegend.ClassificationSymbols;
|
||||||
|
|
||||||
|
validationErrors.AddRange(ValidateSegmentCount(baseLines, affixLegendSymbols, "Base content"));
|
||||||
|
|
||||||
|
for (var branchIndex = 0; branchIndex < branchStartIndexes.Count; branchIndex++)
|
||||||
|
{
|
||||||
|
var startIndex = branchStartIndexes[branchIndex];
|
||||||
|
var endIndex = branchIndex == branchStartIndexes.Count - 1
|
||||||
|
? lines.Count
|
||||||
|
: branchStartIndexes[branchIndex + 1];
|
||||||
|
|
||||||
|
branches.Add(ParseBranch(
|
||||||
|
lines.Skip(startIndex).Take(endIndex - startIndex).ToList(),
|
||||||
|
branchIndex + 1,
|
||||||
|
affixLegend,
|
||||||
|
validationErrors));
|
||||||
|
}
|
||||||
|
|
||||||
|
var (rawText, descriptionText, rawAffixText) = BuildTextSections(baseLines, affixLegendSymbols);
|
||||||
|
var effects = AffixEffectParser.Parse(rawAffixText, affixLegend);
|
||||||
|
return new CriticalCellParseContent(baseLines, rawText, descriptionText, rawAffixText, effects, branches, validationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ParsedCriticalBranch ParseBranch(
|
||||||
|
IReadOnlyList<string> branchLines,
|
||||||
|
int sortOrder,
|
||||||
|
AffixLegend affixLegend,
|
||||||
|
List<string> validationErrors)
|
||||||
|
{
|
||||||
|
var firstLine = branchLines[0];
|
||||||
|
var separatorIndex = firstLine.IndexOf(':', StringComparison.Ordinal);
|
||||||
|
var conditionText = CriticalCellParserSupport.CollapseWhitespace(firstLine[..separatorIndex]);
|
||||||
|
var firstPayloadLine = CriticalCellParserSupport.CollapseWhitespace(firstLine[(separatorIndex + 1)..]);
|
||||||
|
var payloadLines = new List<string>();
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(firstPayloadLine))
|
||||||
|
{
|
||||||
|
payloadLines.Add(firstPayloadLine);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var continuationLine in branchLines.Skip(1))
|
||||||
|
{
|
||||||
|
var normalized = CriticalCellParserSupport.CollapseWhitespace(continuationLine);
|
||||||
|
if (!string.IsNullOrWhiteSpace(normalized))
|
||||||
|
{
|
||||||
|
payloadLines.Add(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var affixLegendSymbols = affixLegend.ClassificationSymbols;
|
||||||
|
validationErrors.AddRange(ValidateSegmentCount(payloadLines, affixLegendSymbols, $"Branch '{conditionText}'"));
|
||||||
|
|
||||||
|
var (_, descriptionText, rawAffixText) = BuildTextSections(payloadLines, affixLegendSymbols);
|
||||||
|
var effects = AffixEffectParser.Parse(rawAffixText, affixLegend);
|
||||||
|
return new ParsedCriticalBranch(
|
||||||
|
"conditional",
|
||||||
|
CriticalCellParserSupport.NormalizeConditionKey(conditionText),
|
||||||
|
conditionText,
|
||||||
|
string.Join(Environment.NewLine, branchLines),
|
||||||
|
descriptionText,
|
||||||
|
rawAffixText,
|
||||||
|
effects,
|
||||||
|
sortOrder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<int> FindBranchStartIndexes(IReadOnlyList<string> lines)
|
||||||
|
{
|
||||||
|
var branchStartIndexes = new List<int>();
|
||||||
|
|
||||||
|
for (var index = 0; index < lines.Count; index++)
|
||||||
|
{
|
||||||
|
if (CriticalCellParserSupport.IsConditionalBranchStartLine(lines[index]))
|
||||||
|
{
|
||||||
|
branchStartIndexes.Add(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return branchStartIndexes;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> ValidateSegmentCount(
|
||||||
|
IReadOnlyList<string> lines,
|
||||||
|
IReadOnlySet<string> affixLegendSymbols,
|
||||||
|
string scope)
|
||||||
|
{
|
||||||
|
if (lines.Count == 0)
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
var segmentCount = CriticalCellParserSupport.CountLineTypeSegments(lines, affixLegendSymbols);
|
||||||
|
return segmentCount > 2
|
||||||
|
? [$"{scope} interleaves prose and affix lines."]
|
||||||
|
: [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private static (string RawText, string DescriptionText, string? RawAffixText) BuildTextSections(
|
||||||
|
IReadOnlyList<string> lines,
|
||||||
|
IReadOnlySet<string> affixLegendSymbols)
|
||||||
|
{
|
||||||
|
var rawText = string.Join(Environment.NewLine, lines);
|
||||||
|
var rawAffixLines = lines.Where(line => CriticalCellParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList();
|
||||||
|
var descriptionLines = lines.Where(line => !CriticalCellParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList();
|
||||||
|
var descriptionText = CriticalCellParserSupport.CollapseWhitespace(string.Join(' ', descriptionLines));
|
||||||
|
var rawAffixText = rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines);
|
||||||
|
return (rawText, descriptionText, rawAffixText);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/RolemasterDb.CriticalParsing/CriticalEffectCodes.cs
Normal file
13
src/RolemasterDb.CriticalParsing/CriticalEffectCodes.cs
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public static class CriticalEffectCodes
|
||||||
|
{
|
||||||
|
public const string DirectHits = "direct_hits";
|
||||||
|
public const string MustParryRounds = "must_parry_rounds";
|
||||||
|
public const string NoParryRounds = "no_parry_rounds";
|
||||||
|
public const string StunnedRounds = "stunned_rounds";
|
||||||
|
public const string BleedPerRound = "bleed_per_round";
|
||||||
|
public const string FoePenalty = "foe_penalty";
|
||||||
|
public const string AttackerBonusNextRound = "attacker_bonus_next_round";
|
||||||
|
public const string PowerPointModifier = "power_point_modifier";
|
||||||
|
}
|
||||||
21
src/RolemasterDb.CriticalParsing/ParsedCriticalBranch.cs
Normal file
21
src/RolemasterDb.CriticalParsing/ParsedCriticalBranch.cs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public sealed class ParsedCriticalBranch(
|
||||||
|
string branchKind,
|
||||||
|
string? conditionKey,
|
||||||
|
string conditionText,
|
||||||
|
string rawText,
|
||||||
|
string descriptionText,
|
||||||
|
string? rawAffixText,
|
||||||
|
IReadOnlyList<ParsedCriticalEffect> effects,
|
||||||
|
int sortOrder)
|
||||||
|
{
|
||||||
|
public string BranchKind { get; } = branchKind;
|
||||||
|
public string? ConditionKey { get; } = conditionKey;
|
||||||
|
public string ConditionText { get; } = conditionText;
|
||||||
|
public string RawText { get; } = rawText;
|
||||||
|
public string DescriptionText { get; } = descriptionText;
|
||||||
|
public string? RawAffixText { get; } = rawAffixText;
|
||||||
|
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
|
||||||
|
public int SortOrder { get; } = sortOrder;
|
||||||
|
}
|
||||||
27
src/RolemasterDb.CriticalParsing/ParsedCriticalEffect.cs
Normal file
27
src/RolemasterDb.CriticalParsing/ParsedCriticalEffect.cs
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
namespace RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
|
public sealed class ParsedCriticalEffect(
|
||||||
|
string effectCode,
|
||||||
|
string? target,
|
||||||
|
int? valueInteger,
|
||||||
|
string? valueExpression,
|
||||||
|
int? durationRounds,
|
||||||
|
int? perRound,
|
||||||
|
int? modifier,
|
||||||
|
string? bodyPart,
|
||||||
|
bool isPermanent,
|
||||||
|
string sourceType,
|
||||||
|
string sourceText)
|
||||||
|
{
|
||||||
|
public string EffectCode { get; } = effectCode;
|
||||||
|
public string? Target { get; } = target;
|
||||||
|
public int? ValueInteger { get; } = valueInteger;
|
||||||
|
public string? ValueExpression { get; } = valueExpression;
|
||||||
|
public int? DurationRounds { get; } = durationRounds;
|
||||||
|
public int? PerRound { get; } = perRound;
|
||||||
|
public int? Modifier { get; } = modifier;
|
||||||
|
public string? BodyPart { get; } = bodyPart;
|
||||||
|
public bool IsPermanent { get; } = isPermanent;
|
||||||
|
public string SourceType { get; } = sourceType;
|
||||||
|
public string SourceText { get; } = sourceText;
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
using RolemasterDb.App.Data;
|
||||||
|
using RolemasterDb.App.Domain;
|
||||||
|
using RolemasterDb.App.Features;
|
||||||
|
using RolemasterDb.CriticalParsing;
|
||||||
|
using AppCriticalEffectCodes = RolemasterDb.App.Domain.CriticalEffectCodes;
|
||||||
|
|
||||||
|
namespace RolemasterDb.ImportTool.Tests;
|
||||||
|
|
||||||
|
public sealed class CriticalCellReparseIntegrationTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void Shared_cell_parser_extracts_base_effects_and_condition_branches()
|
||||||
|
{
|
||||||
|
var legend = new AffixLegend(
|
||||||
|
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||||
|
{
|
||||||
|
["∫"] = AppCriticalEffectCodes.StunnedRounds
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
supportsFoePenalty: false,
|
||||||
|
supportsAttackerBonus: false,
|
||||||
|
supportsPowerPointModifier: false);
|
||||||
|
|
||||||
|
var content = CriticalCellTextParser.Parse(
|
||||||
|
"Strike to thigh.\r\n+10H\r\nWith greaves: glancing blow.\r\n2∫",
|
||||||
|
legend);
|
||||||
|
|
||||||
|
Assert.Equal("Strike to thigh.", content.DescriptionText);
|
||||||
|
Assert.Equal("+10H", content.RawAffixText);
|
||||||
|
Assert.Contains(content.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 10);
|
||||||
|
Assert.Single(content.Branches);
|
||||||
|
Assert.Equal("with_greaves", content.Branches[0].ConditionKey);
|
||||||
|
Assert.Equal("glancing blow.", content.Branches[0].DescriptionText);
|
||||||
|
Assert.Contains(content.Branches[0].Effects, effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2);
|
||||||
|
Assert.Empty(content.ValidationErrors);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Lookup_service_reparse_uses_shared_parser_and_table_legend_data()
|
||||||
|
{
|
||||||
|
var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reparse-{Guid.NewGuid():N}.db");
|
||||||
|
|
||||||
|
await using (var seedContext = CreateDbContext(databasePath))
|
||||||
|
{
|
||||||
|
await seedContext.Database.EnsureCreatedAsync();
|
||||||
|
|
||||||
|
var table = new CriticalTable
|
||||||
|
{
|
||||||
|
Slug = "slash",
|
||||||
|
DisplayName = "Slash Critical Strike Table",
|
||||||
|
Family = "standard",
|
||||||
|
SourceDocument = "Slash.pdf",
|
||||||
|
Notes = null
|
||||||
|
};
|
||||||
|
var column = new CriticalColumn
|
||||||
|
{
|
||||||
|
CriticalTable = table,
|
||||||
|
ColumnKey = "B",
|
||||||
|
Label = "B",
|
||||||
|
Role = "severity",
|
||||||
|
SortOrder = 2
|
||||||
|
};
|
||||||
|
var rollBand = new CriticalRollBand
|
||||||
|
{
|
||||||
|
CriticalTable = table,
|
||||||
|
Label = "36-40",
|
||||||
|
MinRoll = 36,
|
||||||
|
MaxRoll = 40,
|
||||||
|
SortOrder = 8
|
||||||
|
};
|
||||||
|
var result = new CriticalResult
|
||||||
|
{
|
||||||
|
CriticalTable = table,
|
||||||
|
CriticalColumn = column,
|
||||||
|
CriticalRollBand = rollBand,
|
||||||
|
RawCellText = "Old text",
|
||||||
|
DescriptionText = "Old description",
|
||||||
|
ParseStatus = "verified",
|
||||||
|
ParsedJson = "{}"
|
||||||
|
};
|
||||||
|
|
||||||
|
result.Effects.Add(new CriticalEffect
|
||||||
|
{
|
||||||
|
EffectCode = AppCriticalEffectCodes.StunnedRounds,
|
||||||
|
Target = "foe",
|
||||||
|
DurationRounds = 1,
|
||||||
|
IsPermanent = false,
|
||||||
|
SourceType = "symbol",
|
||||||
|
SourceText = "∫"
|
||||||
|
});
|
||||||
|
|
||||||
|
seedContext.CriticalTables.Add(table);
|
||||||
|
seedContext.CriticalResults.Add(result);
|
||||||
|
await seedContext.SaveChangesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
var lookupService = new LookupService(CreateDbContextFactory(databasePath));
|
||||||
|
await using var verifyContext = CreateDbContext(databasePath);
|
||||||
|
var resultId = await verifyContext.CriticalResults
|
||||||
|
.Where(item => item.CriticalTable.Slug == "slash")
|
||||||
|
.Select(item => item.Id)
|
||||||
|
.SingleAsync();
|
||||||
|
|
||||||
|
var response = await lookupService.ReparseCriticalCellAsync(
|
||||||
|
"slash",
|
||||||
|
resultId,
|
||||||
|
"Strike to thigh.\n+10H\nWith greaves: glancing blow.\n2∫");
|
||||||
|
|
||||||
|
Assert.NotNull(response);
|
||||||
|
Assert.Equal("Strike to thigh.", response!.DescriptionText);
|
||||||
|
Assert.Contains(response.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.DirectHits && effect.ValueInteger == 10);
|
||||||
|
Assert.Contains(response.Branches, branch =>
|
||||||
|
branch.ConditionKey == "with_greaves" &&
|
||||||
|
branch.DescriptionText == "glancing blow." &&
|
||||||
|
branch.Effects.Any(effect => effect.EffectCode == AppCriticalEffectCodes.StunnedRounds && effect.DurationRounds == 2));
|
||||||
|
Assert.Empty(response.ValidationMessages);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static RolemasterDbContext CreateDbContext(string databasePath)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
|
||||||
|
.UseSqlite($"Data Source={databasePath}")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new RolemasterDbContext(options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IDbContextFactory<RolemasterDbContext> CreateDbContextFactory(string databasePath)
|
||||||
|
{
|
||||||
|
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
|
||||||
|
.UseSqlite($"Data Source={databasePath}")
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new TestRolemasterDbContextFactory(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
|
||||||
<ProjectReference Include="..\RolemasterDb.ImportTool\RolemasterDb.ImportTool.csproj" />
|
<ProjectReference Include="..\RolemasterDb.ImportTool\RolemasterDb.ImportTool.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using System.Xml;
|
|||||||
using System.Xml.Linq;
|
using System.Xml.Linq;
|
||||||
|
|
||||||
using RolemasterDb.App.Domain;
|
using RolemasterDb.App.Domain;
|
||||||
|
using SharedParsing = RolemasterDb.CriticalParsing;
|
||||||
|
|
||||||
namespace RolemasterDb.ImportTool.Parsing;
|
namespace RolemasterDb.ImportTool.Parsing;
|
||||||
|
|
||||||
@@ -559,12 +560,17 @@ internal static class CriticalTableParserSupport
|
|||||||
List<ParsedCriticalResult> parsedResults,
|
List<ParsedCriticalResult> parsedResults,
|
||||||
List<string> validationErrors)
|
List<string> validationErrors)
|
||||||
{
|
{
|
||||||
|
var sharedLegend = ToSharedAffixLegend(affixLegend);
|
||||||
|
|
||||||
foreach (var cellEntry in cellEntries)
|
foreach (var cellEntry in cellEntries)
|
||||||
{
|
{
|
||||||
var content = CriticalCellTextParser.Parse(cellEntry.Lines, affixLegend);
|
var content = SharedParsing.CriticalCellTextParser.Parse(cellEntry.Lines, sharedLegend);
|
||||||
validationErrors.AddRange(content.ValidationErrors.Select(error =>
|
validationErrors.AddRange(content.ValidationErrors.Select(error =>
|
||||||
$"Cell '{BuildCellIdentifier(cellEntry)}': {error}"));
|
$"Cell '{BuildCellIdentifier(cellEntry)}': {error}"));
|
||||||
|
|
||||||
|
var effects = content.Effects.Select(ToImportToolEffect).ToList();
|
||||||
|
var branches = content.Branches.Select(ToImportToolBranch).ToList();
|
||||||
|
|
||||||
parsedCells.Add(new ParsedCriticalCellArtifact(
|
parsedCells.Add(new ParsedCriticalCellArtifact(
|
||||||
cellEntry.GroupKey,
|
cellEntry.GroupKey,
|
||||||
cellEntry.RollBandLabel,
|
cellEntry.RollBandLabel,
|
||||||
@@ -574,8 +580,8 @@ internal static class CriticalTableParserSupport
|
|||||||
content.RawCellText,
|
content.RawCellText,
|
||||||
content.DescriptionText,
|
content.DescriptionText,
|
||||||
content.RawAffixText,
|
content.RawAffixText,
|
||||||
content.Effects,
|
effects,
|
||||||
content.Branches));
|
branches));
|
||||||
|
|
||||||
parsedResults.Add(new ParsedCriticalResult(
|
parsedResults.Add(new ParsedCriticalResult(
|
||||||
cellEntry.GroupKey,
|
cellEntry.GroupKey,
|
||||||
@@ -584,11 +590,44 @@ internal static class CriticalTableParserSupport
|
|||||||
content.RawCellText,
|
content.RawCellText,
|
||||||
content.DescriptionText,
|
content.DescriptionText,
|
||||||
content.RawAffixText,
|
content.RawAffixText,
|
||||||
content.Effects,
|
effects,
|
||||||
content.Branches));
|
branches));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static SharedParsing.AffixLegend ToSharedAffixLegend(AffixLegend affixLegend) =>
|
||||||
|
new(
|
||||||
|
affixLegend.SymbolEffects,
|
||||||
|
affixLegend.ClassificationSymbols.Except(affixLegend.EffectSymbols).ToList(),
|
||||||
|
affixLegend.SupportsFoePenalty,
|
||||||
|
affixLegend.SupportsAttackerBonus,
|
||||||
|
affixLegend.SupportsPowerPointModifier);
|
||||||
|
|
||||||
|
private static ParsedCriticalEffect ToImportToolEffect(SharedParsing.ParsedCriticalEffect effect) =>
|
||||||
|
new(
|
||||||
|
effect.EffectCode,
|
||||||
|
effect.Target,
|
||||||
|
effect.ValueInteger,
|
||||||
|
effect.ValueExpression,
|
||||||
|
effect.DurationRounds,
|
||||||
|
effect.PerRound,
|
||||||
|
effect.Modifier,
|
||||||
|
effect.BodyPart,
|
||||||
|
effect.IsPermanent,
|
||||||
|
effect.SourceType,
|
||||||
|
effect.SourceText);
|
||||||
|
|
||||||
|
private static ParsedCriticalBranch ToImportToolBranch(SharedParsing.ParsedCriticalBranch branch) =>
|
||||||
|
new(
|
||||||
|
branch.BranchKind,
|
||||||
|
branch.ConditionKey,
|
||||||
|
branch.ConditionText,
|
||||||
|
branch.RawText,
|
||||||
|
branch.DescriptionText,
|
||||||
|
branch.RawAffixText,
|
||||||
|
branch.Effects.Select(ToImportToolEffect).ToList(),
|
||||||
|
branch.SortOrder);
|
||||||
|
|
||||||
private static string BuildCellIdentifier(ColumnarCellEntry cellEntry) =>
|
private static string BuildCellIdentifier(ColumnarCellEntry cellEntry) =>
|
||||||
cellEntry.GroupKey is null
|
cellEntry.GroupKey is null
|
||||||
? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}"
|
? $"{cellEntry.RollBandLabel}/{cellEntry.ColumnKey}"
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="..\RolemasterDb.CriticalParsing\RolemasterDb.CriticalParsing.csproj" />
|
||||||
<ProjectReference Include="..\RolemasterDb.App\RolemasterDb.App.csproj" />
|
<ProjectReference Include="..\RolemasterDb.App\RolemasterDb.App.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user