Files
RolemasterDB/src/RolemasterDb.App/Features/LookupService.cs

1034 lines
41 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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;
public sealed class LookupService(
IDbContextFactory<RolemasterDbContext> dbContextFactory,
CriticalImportArtifactLocator? artifactLocator = null)
{
public async Task<LookupReferenceData> GetReferenceDataAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var attackTables = await dbContext.AttackTables
.AsNoTracking()
.OrderBy(item => item.DisplayName)
.Select(item => new AttackTableReference(
item.Slug,
item.DisplayName,
item.AttackKind,
item.FumbleMinRoll,
item.FumbleMaxRoll))
.ToListAsync(cancellationToken);
var armorTypes = await dbContext.ArmorTypes
.AsNoTracking()
.OrderBy(item => item.SortOrder)
.Select(item => new LookupOption(item.Code, item.Label))
.ToListAsync(cancellationToken);
var criticalTables = await dbContext.CriticalTables
.AsNoTracking()
.AsSplitQuery()
.Include(item => item.Columns)
.Include(item => item.Groups)
.Include(item => item.RollBands)
.OrderBy(item => item.DisplayName)
.ToListAsync(cancellationToken);
return new LookupReferenceData(
attackTables,
armorTypes,
criticalTables.Select(item => new CriticalTableReference(
item.Slug,
item.DisplayName,
item.Family,
item.SourceDocument,
item.Notes,
item.Columns
.OrderBy(column => column.SortOrder)
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
.ToList(),
item.Groups
.OrderBy(group => group.SortOrder)
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
.ToList(),
item.RollBands
.OrderBy(rollBand => rollBand.SortOrder)
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
.ToList()))
.ToList());
}
public async Task<AttackLookupResponse?> LookupAttackAsync(AttackLookupRequest request, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var attackTable = NormalizeSlug(request.AttackTable);
var armorType = request.ArmorType.Trim().ToUpperInvariant();
var attackResult = await dbContext.AttackResults
.AsNoTracking()
.Where(item =>
item.AttackTable.Slug == attackTable &&
item.ArmorType.Code == armorType &&
request.Roll >= item.AttackRollBand.MinRoll &&
(item.AttackRollBand.MaxRoll == null || request.Roll <= item.AttackRollBand.MaxRoll))
.Select(item => new AttackLookupResponse(
item.AttackTable.Slug,
item.AttackTable.DisplayName,
item.ArmorType.Code,
item.ArmorType.Label,
request.Roll,
item.AttackRollBand.Label,
item.Hits,
item.CriticalType,
item.CriticalSeverity,
item.RawNotation,
item.Notes,
null))
.SingleOrDefaultAsync(cancellationToken);
if (attackResult is null || attackResult.CriticalType is null || attackResult.CriticalSeverity is null || request.CriticalRoll is null)
{
return attackResult;
}
var autoCritical = await LookupCriticalAsync(
new CriticalLookupRequest(attackResult.CriticalType, attackResult.CriticalSeverity, request.CriticalRoll.Value, null),
cancellationToken);
return attackResult with { AutoCritical = autoCritical };
}
public async Task<CriticalLookupResponse?> LookupCriticalAsync(CriticalLookupRequest request, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var criticalType = NormalizeSlug(request.CriticalType);
var column = request.Column.Trim().ToUpperInvariant();
var group = string.IsNullOrWhiteSpace(request.Group) ? null : NormalizeSlug(request.Group);
return await dbContext.CriticalResults
.AsNoTracking()
.AsSplitQuery()
.Where(item =>
item.CriticalTable.Slug == criticalType &&
item.CriticalColumn.ColumnKey == column &&
(group == null
? item.CriticalGroupId == null
: item.CriticalGroup != null && item.CriticalGroup.GroupKey == group) &&
request.Roll >= item.CriticalRollBand.MinRoll &&
(item.CriticalRollBand.MaxRoll == null || request.Roll <= item.CriticalRollBand.MaxRoll))
.Select(item => new CriticalLookupResponse(
item.CriticalTable.Slug,
item.CriticalTable.DisplayName,
item.CriticalTable.Family,
item.CriticalTable.SourceDocument,
item.CriticalTable.Notes,
item.CriticalGroup != null ? item.CriticalGroup.GroupKey : null,
item.CriticalGroup != null ? item.CriticalGroup.Label : null,
item.CriticalColumn.ColumnKey,
item.CriticalColumn.Label,
item.CriticalColumn.Role,
request.Roll,
item.CriticalRollBand.Label,
item.CriticalRollBand.MinRoll,
item.CriticalRollBand.MaxRoll,
item.RawCellText,
item.DescriptionText,
item.RawAffixText,
item.Effects
.OrderBy(effect => effect.Id)
.Select(effect => new CriticalEffectLookupResponse(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText))
.ToList(),
item.Branches
.OrderBy(branch => branch.SortOrder)
.Select(branch => new CriticalBranchLookupResponse(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
branch.Effects
.OrderBy(effect => effect.Id)
.Select(effect => new CriticalEffectLookupResponse(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText))
.ToList(),
branch.RawText,
branch.SortOrder))
.ToList(),
item.ParseStatus,
item.ParsedJson))
.SingleOrDefaultAsync(cancellationToken);
}
public async Task<CriticalTableDetail?> GetCriticalTableAsync(string slug, CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var table = await dbContext.CriticalTables
.AsNoTracking()
.AsSplitQuery()
.Include(item => item.Columns)
.Include(item => item.Groups)
.Include(item => item.RollBands)
.Include(item => item.Results)
.ThenInclude(result => result.CriticalColumn)
.Include(item => item.Results)
.ThenInclude(result => result.CriticalGroup)
.Include(item => item.Results)
.ThenInclude(result => result.CriticalRollBand)
.Include(item => item.Results)
.ThenInclude(result => result.Effects)
.Include(item => item.Results)
.ThenInclude(result => result.Branches)
.ThenInclude(branch => branch.Effects)
.Where(item => item.Slug == slug)
.SingleOrDefaultAsync(cancellationToken);
if (table is null)
{
return null;
}
var columns = table.Columns
.OrderBy(column => column.SortOrder)
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
.ToList();
var groups = table.Groups
.OrderBy(group => group.SortOrder)
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
.ToList();
var rollBands = table.RollBands
.OrderBy(rollBand => rollBand.SortOrder)
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
.ToList();
var cells = table.Results
.OrderBy(result => result.CriticalRollBand.SortOrder)
.ThenBy(result => result.CriticalGroup?.SortOrder ?? 0)
.ThenBy(result => result.CriticalColumn.SortOrder)
.Select(result => new CriticalTableCellDetail(
result.Id,
result.CriticalRollBand.Label,
result.CriticalColumn.ColumnKey,
result.CriticalColumn.Label,
result.CriticalColumn.Role,
result.CriticalGroup?.GroupKey,
result.CriticalGroup?.Label,
result.IsCurated,
result.DescriptionText,
result.Effects
.OrderBy(effect => effect.Id)
.Select(effect => CreateEffectLookupResponse(effect))
.ToList(),
result.Branches
.OrderBy(branch => branch.SortOrder)
.Select(branch => CreateBranchLookupResponse(branch))
.ToList()))
.ToList();
var legend = BuildLegend(cells);
return new CriticalTableDetail(
table.Slug,
table.DisplayName,
table.Family,
table.SourceDocument,
table.Notes,
columns,
groups,
rollBands,
cells,
legend);
}
public async Task<CriticalCellEditorResponse?> GetCriticalCellEditorAsync(string slug, int resultId, 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)
.Include(item => item.Effects)
.Include(item => item.Branches)
.ThenInclude(branch => branch.Effects)
.SingleOrDefaultAsync(
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
cancellationToken);
if (result is null)
{
return null;
}
var currentState = CreateCurrentEditorState(result);
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
}
public async Task<string?> GetCriticalSourceImagePathAsync(string slug, int resultId, CancellationToken cancellationToken = default)
{
if (artifactLocator is null)
{
return null;
}
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedSlug = NormalizeSlug(slug);
var relativePath = await dbContext.CriticalResults
.AsNoTracking()
.Where(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug)
.Select(item => item.SourceImagePath)
.SingleOrDefaultAsync(cancellationToken);
var fullPath = artifactLocator.ResolveStoredPath(relativePath);
return fullPath is not null && File.Exists(fullPath)
? fullPath
: null;
}
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
string slug,
int resultId,
CriticalCellUpdateRequest currentState,
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 content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
var generatedState = CreateGeneratedEditorState(content);
var mergedState = MergeGeneratedState(currentState, generatedState);
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
}
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
string slug,
int resultId,
CriticalCellUpdateRequest request,
CancellationToken cancellationToken = default)
{
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
var normalizedSlug = NormalizeSlug(slug);
var result = await dbContext.CriticalResults
.AsSplitQuery()
.Include(item => item.CriticalTable)
.Include(item => item.CriticalColumn)
.Include(item => item.CriticalGroup)
.Include(item => item.CriticalRollBand)
.Include(item => item.Effects)
.Include(item => item.Branches)
.ThenInclude(branch => branch.Effects)
.SingleOrDefaultAsync(
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
cancellationToken);
if (result is null)
{
return null;
}
result.RawCellText = request.RawCellText.Trim();
result.DescriptionText = request.DescriptionText.Trim();
result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
result.ParseStatus = request.ParseStatus.Trim();
result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson();
result.IsCurated = request.IsCurated;
ReplaceBaseEffects(dbContext, result, request.Effects);
ReplaceBranches(dbContext, result, request.Branches);
await dbContext.SaveChangesAsync(cancellationToken);
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken);
return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
}
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
{
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
var legend = new List<CriticalTableLegendEntry>();
foreach (var cell in cells)
{
var baseEffects = cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
foreach (var effect in baseEffects)
{
TryAddLegendEntry(effect.EffectCode);
}
var branches = cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>();
foreach (var branch in branches)
{
var branchEffects = branch.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
foreach (var effect in branchEffects)
{
TryAddLegendEntry(effect.EffectCode);
}
}
}
return legend
.OrderBy(item => item.Label, StringComparer.Ordinal)
.ToList();
void TryAddLegendEntry(string effectCode)
{
if (!seenCodes.Add(effectCode))
{
return;
}
if (!AffixDisplayMap.TryGet(effectCode, out var info))
{
return;
}
legend.Add(new CriticalTableLegendEntry(effectCode, info.Symbol, info.Label, info.Description, info.Tooltip));
}
}
private static CriticalEffectLookupResponse CreateEffectLookupResponse(CriticalEffect 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 CriticalCellEditorResponse CreateCellEditorResponse(
CriticalResult result,
CriticalCellUpdateRequest state,
IReadOnlyList<string> validationMessages,
CriticalCellComparisonState? generatedState)
{
var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson();
return 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,
state.IsCurated,
result.SourcePageNumber,
CreateSourceImageUrl(result),
state.RawCellText,
state.QuickParseInput,
state.DescriptionText,
state.RawAffixText,
state.ParseStatus,
snapshotJson,
state.IsDescriptionOverridden,
state.IsRawAffixTextOverridden,
state.AreEffectsOverridden,
state.AreBranchesOverridden,
validationMessages.ToList(),
state.Effects.ToList(),
state.Branches.ToList(),
generatedState);
}
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
.OrderBy(effect => effect.Id)
.Select(effect => CreateEffectLookupResponse(effect))
.ToList(),
branch.RawText,
branch.SortOrder);
private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch, int branchIndex)
{
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
return new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.ConditionJson,
branch.RawText,
branch.DescriptionText,
branch.RawAffixText,
branch.ParsedJson,
branch.SortOrder,
originKey,
false,
false,
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
.OrderBy(effect => effect.Id)
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
.ToList());
}
private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch, int branchIndex)
{
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
return new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
"{}",
branch.RawText,
branch.DescriptionText,
branch.RawAffixText,
SerializeParsedEffects(branch.Effects),
branch.SortOrder,
originKey,
false,
false,
branch.Effects
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
.ToList());
}
private static CriticalCellComparisonState CreateComparisonState(SharedParsing.CriticalCellParseContent content) =>
new(
content.DescriptionText,
content.Effects
.Select(CreateEffectLookupResponse)
.ToList(),
content.Branches
.OrderBy(branch => branch.SortOrder)
.Select(CreateBranchLookupResponse)
.ToList(),
content.ValidationErrors.ToList(),
content.TokenReviewIssues
.Select(CreateTokenReviewItem)
.ToList());
private static CriticalTokenReviewItem CreateTokenReviewItem(SharedParsing.CriticalTokenReviewIssue issue) =>
new(
issue.Scope,
issue.ConditionText,
issue.Token,
issue.ReviewText);
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
new(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
effect.ValueDecimal,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText,
originKey,
false);
private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect, string originKey) =>
new(
effect.EffectCode,
effect.Target,
effect.ValueInteger,
null,
effect.ValueExpression,
effect.DurationRounds,
effect.PerRound,
effect.Modifier,
effect.BodyPart,
effect.IsPermanent,
effect.SourceType,
effect.SourceText,
originKey,
false);
private static CriticalCellUpdateRequest CreateCurrentEditorState(CriticalResult result)
{
if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null)
{
var snapshotQuickParseInput = string.IsNullOrWhiteSpace(snapshot.QuickParseInput)
? CriticalQuickNotationFormatter.Format(result.DescriptionText, snapshot.Effects, snapshot.Branches)
: snapshot.QuickParseInput;
return new CriticalCellUpdateRequest(
result.RawCellText,
snapshotQuickParseInput,
result.DescriptionText,
result.RawAffixText,
result.ParseStatus,
result.ParsedJson,
result.IsCurated,
snapshot.IsDescriptionOverridden,
snapshot.IsRawAffixTextOverridden,
snapshot.AreEffectsOverridden,
snapshot.AreBranchesOverridden,
snapshot.Effects.ToList(),
snapshot.Branches.ToList());
}
var effects = result.Effects
.OrderBy(effect => effect.Id)
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode)))
.ToList();
var branches = result.Branches
.OrderBy(branch => branch.SortOrder)
.Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex))
.ToList();
return new CriticalCellUpdateRequest(
result.RawCellText,
CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches),
result.DescriptionText,
result.RawAffixText,
result.ParseStatus,
result.ParsedJson,
result.IsCurated,
false,
false,
false,
false,
effects,
branches);
}
private static CriticalCellUpdateRequest CreateGeneratedEditorState(SharedParsing.CriticalCellParseContent content)
{
var effects = content.Effects
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode)))
.ToList();
var branches = content.Branches
.OrderBy(branch => branch.SortOrder)
.Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex))
.ToList();
return new CriticalCellUpdateRequest(
RawCellText: string.Empty,
QuickParseInput: content.RawCellText,
DescriptionText: content.DescriptionText,
RawAffixText: content.RawAffixText,
ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
ParsedJson: SerializeParsedEffects(content.Effects),
IsCurated: false,
IsDescriptionOverridden: false,
IsRawAffixTextOverridden: false,
AreEffectsOverridden: false,
AreBranchesOverridden: false,
Effects: effects,
Branches: branches);
}
private static CriticalCellUpdateRequest MergeGeneratedState(
CriticalCellUpdateRequest currentState,
CriticalCellUpdateRequest generatedState) =>
new(
currentState.RawCellText,
currentState.QuickParseInput,
currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText,
currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
generatedState.ParseStatus,
generatedState.ParsedJson,
currentState.IsCurated,
currentState.IsDescriptionOverridden,
currentState.IsRawAffixTextOverridden,
currentState.AreEffectsOverridden,
currentState.AreBranchesOverridden,
currentState.AreEffectsOverridden
? currentState.Effects.ToList()
: MergeEffectItems(currentState.Effects, generatedState.Effects),
currentState.AreBranchesOverridden
? currentState.Branches
.OrderBy(branch => branch.SortOrder)
.ToList()
: MergeBranchItems(currentState.Branches, generatedState.Branches));
private static List<CriticalBranchEditorItem> MergeBranchItems(
IReadOnlyList<CriticalBranchEditorItem> currentBranches,
IReadOnlyList<CriticalBranchEditorItem> generatedBranches)
{
var currentByOrigin = currentBranches
.Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey))
.ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal);
var merged = new List<CriticalBranchEditorItem>(generatedBranches.Count);
var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);
foreach (var generatedBranch in generatedBranches)
{
if (generatedBranch.OriginKey is not null &&
currentByOrigin.TryGetValue(generatedBranch.OriginKey, out var currentBranch))
{
matchedOrigins.Add(generatedBranch.OriginKey);
if (currentBranch.IsOverridden)
{
merged.Add(currentBranch);
continue;
}
merged.Add(generatedBranch with
{
AreEffectsOverridden = currentBranch.AreEffectsOverridden,
Effects = currentBranch.AreEffectsOverridden
? currentBranch.Effects.ToList()
: MergeEffectItems(currentBranch.Effects, generatedBranch.Effects)
});
continue;
}
merged.Add(generatedBranch);
}
merged.AddRange(currentBranches.Where(branch =>
branch.IsOverridden &&
branch.OriginKey is not null &&
!matchedOrigins.Contains(branch.OriginKey)));
return merged;
}
private static List<CriticalEffectEditorItem> MergeEffectItems(
IReadOnlyList<CriticalEffectEditorItem> currentEffects,
IReadOnlyList<CriticalEffectEditorItem> generatedEffects)
{
var currentByOrigin = currentEffects
.Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey))
.ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal);
var merged = new List<CriticalEffectEditorItem>(generatedEffects.Count);
var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);
foreach (var generatedEffect in generatedEffects)
{
if (generatedEffect.OriginKey is not null &&
currentByOrigin.TryGetValue(generatedEffect.OriginKey, out var currentEffect))
{
matchedOrigins.Add(generatedEffect.OriginKey);
merged.Add(currentEffect.IsOverridden ? currentEffect : generatedEffect);
continue;
}
merged.Add(generatedEffect);
}
merged.AddRange(currentEffects.Where(effect =>
effect.IsOverridden &&
effect.OriginKey is not null &&
!matchedOrigins.Contains(effect.OriginKey)));
return merged;
}
private static string CreateBaseEffectOriginKey(int effectIndex, string effectCode) =>
$"base:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";
private static string CreateBranchOriginKey(int branchIndex, string branchKind, string? conditionKey, string conditionText) =>
$"branch:{NormalizeOriginSegment(branchKind)}:{NormalizeOriginSegment(conditionKey ?? conditionText)}:{branchIndex + 1}";
private static string CreateBranchEffectOriginKey(string branchOriginKey, int effectIndex, string effectCode) =>
$"{branchOriginKey}:effect:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";
private static string NormalizeOriginSegment(string value)
{
var normalized = new string(value
.Trim()
.ToLowerInvariant()
.Select(character => char.IsLetterOrDigit(character) ? character : '_')
.ToArray())
.Trim('_');
return string.IsNullOrWhiteSpace(normalized) ? "empty" : normalized;
}
private static void ReplaceBaseEffects(
RolemasterDbContext dbContext,
CriticalResult result,
IReadOnlyList<CriticalEffectEditorItem>? effects)
{
dbContext.CriticalEffects.RemoveRange(result.Effects);
result.Effects.Clear();
foreach (var effect in effects ?? Array.Empty<CriticalEffectEditorItem>())
{
result.Effects.Add(CreateEffectEntity(effect));
}
}
private static void ReplaceBranches(
RolemasterDbContext dbContext,
CriticalResult result,
IReadOnlyList<CriticalBranchEditorItem>? branches)
{
foreach (var branch in result.Branches)
{
dbContext.CriticalEffects.RemoveRange(branch.Effects);
}
dbContext.CriticalBranches.RemoveRange(result.Branches);
result.Branches.Clear();
foreach (var branch in branches ?? Array.Empty<CriticalBranchEditorItem>())
{
var branchEntity = new CriticalBranch
{
BranchKind = branch.BranchKind.Trim(),
ConditionKey = NormalizeOptionalText(branch.ConditionKey),
ConditionText = branch.ConditionText.Trim(),
ConditionJson = string.IsNullOrWhiteSpace(branch.ConditionJson) ? "{}" : branch.ConditionJson.Trim(),
RawText = branch.RawText.Trim(),
DescriptionText = branch.DescriptionText.Trim(),
RawAffixText = NormalizeOptionalText(branch.RawAffixText),
ParsedJson = string.IsNullOrWhiteSpace(branch.ParsedJson) ? "{}" : branch.ParsedJson.Trim(),
SortOrder = branch.SortOrder
};
foreach (var effect in branch.Effects ?? Array.Empty<CriticalEffectEditorItem>())
{
branchEntity.Effects.Add(CreateEffectEntity(effect));
}
result.Branches.Add(branchEntity);
}
}
private static CriticalEffect CreateEffectEntity(CriticalEffectEditorItem effect) =>
new()
{
EffectCode = effect.EffectCode.Trim(),
Target = NormalizeOptionalText(effect.Target),
ValueInteger = effect.ValueInteger,
ValueDecimal = effect.ValueDecimal,
ValueExpression = NormalizeOptionalText(effect.ValueExpression),
DurationRounds = effect.DurationRounds,
PerRound = effect.PerRound,
Modifier = effect.Modifier,
BodyPart = NormalizeOptionalText(effect.BodyPart),
IsPermanent = effect.IsPermanent,
SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
SourceText = NormalizeOptionalText(effect.SourceText)
};
private static CriticalEffectLookupResponse CreateEffectLookupResponse(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 CriticalBranchLookupResponse CreateBranchLookupResponse(SharedParsing.ParsedCriticalBranch branch) =>
new(
branch.BranchKind,
branch.ConditionKey,
branch.ConditionText,
branch.DescriptionText,
branch.RawAffixText,
branch.Effects
.Select(CreateEffectLookupResponse)
.ToList(),
branch.RawText,
branch.SortOrder);
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 async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(
RolemasterDbContext dbContext,
int tableId,
string quickParseInput,
CancellationToken cancellationToken)
{
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken);
return SharedParsing.CriticalQuickNotationParser.Parse(quickParseInput, affixLegend);
}
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();
private static string NormalizeSlug(string value) =>
value.Trim().Replace(' ', '_').ToLowerInvariant();
private static string? CreateSourceImageUrl(CriticalResult result) =>
string.IsNullOrWhiteSpace(result.SourceImagePath)
? null
: $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image";
}