Share critical cell parsing across app and importer

This commit is contained in:
2026-03-15 02:10:17 +01:00
parent c5800d6878
commit 641e33f811
27 changed files with 1207 additions and 19 deletions

View File

@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data;
using RolemasterDb.App.Domain;
using SharedParsing = RolemasterDb.CriticalParsing;
namespace RolemasterDb.App.Features;
@@ -286,6 +288,36 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
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(
string slug,
int resultId,
@@ -402,6 +434,7 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
result.RawAffixText,
result.ParseStatus,
result.ParsedJson,
[],
result.Effects
.OrderBy(effect => effect.Id)
.Select(CreateEffectEditorItem)
@@ -411,6 +444,34 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.Select(CreateBranchEditorItem)
.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) =>
new(
branch.BranchKind,
@@ -441,6 +502,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
.Select(CreateEffectEditorItem)
.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) =>
new(
effect.EffectCode,
@@ -456,6 +532,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
effect.SourceType,
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(
RolemasterDbContext dbContext,
CriticalResult result,
@@ -520,10 +611,120 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
Modifier = effect.Modifier,
BodyPart = NormalizeOptionalText(effect.BodyPart),
IsPermanent = effect.IsPermanent,
SourceType = effect.SourceType.Trim(),
SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
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) =>
string.IsNullOrWhiteSpace(value) ? null : value.Trim();