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 dbContextFactory, CriticalImportArtifactLocator? artifactLocator = null) { public async Task 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 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 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 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 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 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 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 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 BuildLegend(IReadOnlyList cells) { var seenCodes = new HashSet(StringComparer.Ordinal); var legend = new List(); foreach (var cell in cells) { var baseEffects = cell.Effects ?? Array.Empty(); foreach (var effect in baseEffects) { TryAddLegendEntry(effect.EffectCode); } var branches = cell.Branches ?? Array.Empty(); foreach (var branch in branches) { var branchEffects = branch.Effects ?? Array.Empty(); 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 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()) .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()) .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 MergeBranchItems( IReadOnlyList currentBranches, IReadOnlyList generatedBranches) { var currentByOrigin = currentBranches .Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey)) .ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal); var merged = new List(generatedBranches.Count); var matchedOrigins = new HashSet(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 MergeEffectItems( IReadOnlyList currentEffects, IReadOnlyList generatedEffects) { var currentByOrigin = currentEffects .Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey)) .ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal); var merged = new List(generatedEffects.Count); var matchedOrigins = new HashSet(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? effects) { dbContext.CriticalEffects.RemoveRange(result.Effects); result.Effects.Clear(); foreach (var effect in effects ?? Array.Empty()) { result.Effects.Add(CreateEffectEntity(effect)); } } private static void ReplaceBranches( RolemasterDbContext dbContext, CriticalResult result, IReadOnlyList? 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()) { 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()) { 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 effects, IReadOnlyList branches) => effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0) ? "partial" : "raw"; private static string SerializeParsedEffects(IReadOnlyList 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 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(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 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"; }