diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs index 22b4c81..ab0a193 100644 --- a/src/RolemasterDb.App/Features/LookupService.cs +++ b/src/RolemasterDb.App/Features/LookupService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.Json; using Microsoft.EntityFrameworkCore; using RolemasterDb.App.Data; @@ -9,79 +11,26 @@ using SharedParsing = RolemasterDb.CriticalParsing; namespace RolemasterDb.App.Features; -public sealed class LookupService( - IDbContextFactory dbContextFactory, - CriticalImportArtifactLocator? artifactLocator = null) +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 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 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); + 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); - var criticalResultCounts = await dbContext.CriticalResults - .AsNoTracking() - .GroupBy(item => item.CriticalTableId) - .Select(group => new - { - CriticalTableId = group.Key, - TotalCount = group.Count(), - CuratedCount = group.Count(item => item.IsCurated) - }) - .ToDictionaryAsync( - item => item.CriticalTableId, - item => (item.CuratedCount, item.TotalCount), - cancellationToken); + var criticalResultCounts = await dbContext.CriticalResults.AsNoTracking().GroupBy(item => item.CriticalTableId).Select(group => new + { + CriticalTableId = group.Key, + TotalCount = group.Count(), + CuratedCount = group.Count(item => item.IsCurated) + }).ToDictionaryAsync(item => item.CriticalTableId, item => (item.CuratedCount, item.TotalCount), cancellationToken); - return new LookupReferenceData( - attackTables, - armorTypes, - criticalTables.Select(item => new CriticalTableReference( - item.Slug, - item.DisplayName, - item.Family, - item.SourceDocument, - item.Notes, - GetCuratedCount(item.Id), - GetTotalCount(item.Id), - GetCurationPercentage(item.Id), - 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()); + return new LookupReferenceData(attackTables, armorTypes, criticalTables.Select(item => new CriticalTableReference(item.Slug, item.DisplayName, item.Family, item.SourceDocument, item.Notes, GetCuratedCount(item.Id), GetTotalCount(item.Id), GetCurationPercentage(item.Id), 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()); int GetCuratedCount(int tableId) => criticalResultCounts.TryGetValue(tableId, out var counts) ? counts.CuratedCount : 0; @@ -107,36 +56,14 @@ public sealed class LookupService( 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); + 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); + var autoCritical = await LookupCriticalAsync(new CriticalLookupRequest(attackResult.CriticalType, attackResult.CriticalSeverity, request.CriticalRoll.Value, null), cancellationToken); return attackResult with { AutoCritical = autoCritical }; } @@ -149,162 +76,31 @@ public sealed class LookupService( 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); + 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); + 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 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 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 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 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); + 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) @@ -312,19 +108,7 @@ public sealed class LookupService( 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); + 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) { @@ -346,37 +130,18 @@ public sealed class LookupService( 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 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; + return fullPath is not null && File.Exists(fullPath) ? fullPath : null; } - public async Task ReparseCriticalCellAsync( - string slug, - int resultId, - CriticalCellUpdateRequest currentState, - CancellationToken cancellationToken = default) + 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); + 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) { @@ -389,27 +154,12 @@ public sealed class LookupService( return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content)); } - public async Task UpdateCriticalCellAsync( - string slug, - int resultId, - CriticalCellUpdateRequest request, - CancellationToken cancellationToken = default) + 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); + 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) { @@ -424,9 +174,7 @@ public sealed class LookupService( result.RawAffixText = NormalizeOptionalText(request.RawAffixText); result.ParseStatus = request.ParseStatus.Trim(); result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson(); - result.IsCurated = hasEdits && result.IsCurated - ? false - : request.IsCurated; + result.IsCurated = hasEdits && result.IsCurated ? false : request.IsCurated; ReplaceBaseEffects(dbContext, result, request.Effects); ReplaceBranches(dbContext, result, request.Branches); @@ -434,10 +182,7 @@ public sealed class LookupService( await dbContext.SaveChangesAsync(cancellationToken); var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken); - var persistedState = request with - { - IsCurated = result.IsCurated - }; + var persistedState = request with { IsCurated = result.IsCurated }; return CreateCellEditorResponse(result, persistedState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent)); } @@ -465,9 +210,7 @@ public sealed class LookupService( } } - return legend - .OrderBy(item => item.Label, StringComparer.Ordinal) - .ToList(); + return legend.OrderBy(item => item.Label, StringComparer.Ordinal).ToList(); void TryAddLegendEntry(string effectCode) { @@ -486,285 +229,79 @@ public sealed class LookupService( } 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); + 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) + 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); + 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); + 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()); + 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()); + 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()); + 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); + 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); + 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); + 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; + 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()); + 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(); + 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); + 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(); + 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); + 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 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) + private static List MergeBranchItems(IReadOnlyList currentBranches, IReadOnlyList generatedBranches) { - var currentByOrigin = currentBranches - .Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey)) - .ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal); + 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)) + if (generatedBranch.OriginKey is not null && currentByOrigin.TryGetValue(generatedBranch.OriginKey, out var currentBranch)) { matchedOrigins.Add(generatedBranch.OriginKey); @@ -777,9 +314,7 @@ public sealed class LookupService( merged.Add(generatedBranch with { AreEffectsOverridden = currentBranch.AreEffectsOverridden, - Effects = currentBranch.AreEffectsOverridden - ? currentBranch.Effects.ToList() - : MergeEffectItems(currentBranch.Effects, generatedBranch.Effects) + Effects = currentBranch.AreEffectsOverridden ? currentBranch.Effects.ToList() : MergeEffectItems(currentBranch.Effects, generatedBranch.Effects) }); continue; } @@ -787,28 +322,20 @@ public sealed class LookupService( merged.Add(generatedBranch); } - merged.AddRange(currentBranches.Where(branch => - branch.IsOverridden && - branch.OriginKey is not null && - !matchedOrigins.Contains(branch.OriginKey))); + 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) + private static List MergeEffectItems(IReadOnlyList currentEffects, IReadOnlyList generatedEffects) { - var currentByOrigin = currentEffects - .Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey)) - .ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal); + 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)) + if (generatedEffect.OriginKey is not null && currentByOrigin.TryGetValue(generatedEffect.OriginKey, out var currentEffect)) { matchedOrigins.Add(generatedEffect.OriginKey); merged.Add(currentEffect.IsOverridden ? currentEffect : generatedEffect); @@ -818,10 +345,7 @@ public sealed class LookupService( merged.Add(generatedEffect); } - merged.AddRange(currentEffects.Where(effect => - effect.IsOverridden && - effect.OriginKey is not null && - !matchedOrigins.Contains(effect.OriginKey))); + merged.AddRange(currentEffects.Where(effect => effect.IsOverridden && effect.OriginKey is not null && !matchedOrigins.Contains(effect.OriginKey))); return merged; } @@ -837,20 +361,12 @@ public sealed class LookupService( private static string NormalizeOriginSegment(string value) { - var normalized = new string(value - .Trim() - .ToLowerInvariant() - .Select(character => char.IsLetterOrDigit(character) ? character : '_') - .ToArray()) - .Trim('_'); + 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) + private static void ReplaceBaseEffects(RolemasterDbContext dbContext, CriticalResult result, IReadOnlyList? effects) { dbContext.CriticalEffects.RemoveRange(result.Effects); result.Effects.Clear(); @@ -861,10 +377,7 @@ public sealed class LookupService( } } - private static void ReplaceBranches( - RolemasterDbContext dbContext, - CriticalResult result, - IReadOnlyList? branches) + private static void ReplaceBranches(RolemasterDbContext dbContext, CriticalResult result, IReadOnlyList? branches) { foreach (var branch in result.Branches) { @@ -916,38 +429,13 @@ public sealed class LookupService( }; 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); + 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); + 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 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 @@ -970,18 +458,14 @@ public sealed class LookupService( }).ToList() }); - private static async Task BuildSharedAffixLegendAsync( - RolemasterDbContext dbContext, - int tableId, - CancellationToken cancellationToken) + 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 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; @@ -994,9 +478,7 @@ public sealed class LookupService( 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)) + if (!string.Equals(effectRow.SourceType, "symbol", StringComparison.OrdinalIgnoreCase) || !IsLegendSymbolEffectCode(effectRow.EffectCode) || !TryExtractLegendSymbol(effectRow.SourceText, out var symbol)) { continue; } @@ -1004,29 +486,17 @@ public sealed class LookupService( symbolEffects.TryAdd(symbol, effectRow.EffectCode); } - return new SharedParsing.AffixLegend( - symbolEffects, - supportsPowerPointModifier ? ["P"] : [], - supportsFoePenalty, - supportsAttackerBonus, - supportsPowerPointModifier); + return new SharedParsing.AffixLegend(symbolEffects, supportsPowerPointModifier ? ["P"] : [], supportsFoePenalty, supportsAttackerBonus, supportsPowerPointModifier); } - private static async Task ParseCriticalCellContentAsync( - RolemasterDbContext dbContext, - int tableId, - string quickParseInput, - CancellationToken cancellationToken) + 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; + effectCode is CriticalEffectCodes.MustParryRounds or CriticalEffectCodes.NoParryRounds or CriticalEffectCodes.StunnedRounds or CriticalEffectCodes.BleedPerRound; private static bool TryExtractLegendSymbol(string? sourceText, out string symbol) { @@ -1036,22 +506,14 @@ public sealed class LookupService( 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()); + 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(); + var distinctSymbols = candidate.Distinct().ToList(); if (distinctSymbols.Count != 1) { @@ -1067,26 +529,15 @@ public sealed class LookupService( private static bool HasCriticalCellEdits(CriticalCellUpdateRequest currentState, CriticalCellUpdateRequest request) { - if (!string.Equals(currentState.RawCellText.Trim(), request.RawCellText.Trim(), StringComparison.Ordinal) || - !string.Equals(currentState.QuickParseInput.Trim(), request.QuickParseInput.Trim(), StringComparison.Ordinal) || - !string.Equals(currentState.DescriptionText.Trim(), request.DescriptionText.Trim(), StringComparison.Ordinal) || - !string.Equals(NormalizeOptionalText(currentState.RawAffixText), NormalizeOptionalText(request.RawAffixText), StringComparison.Ordinal) || - !string.Equals(currentState.ParseStatus.Trim(), request.ParseStatus.Trim(), StringComparison.Ordinal) || - currentState.IsDescriptionOverridden != request.IsDescriptionOverridden || - currentState.IsRawAffixTextOverridden != request.IsRawAffixTextOverridden || - currentState.AreEffectsOverridden != request.AreEffectsOverridden || - currentState.AreBranchesOverridden != request.AreBranchesOverridden) + if (!string.Equals(currentState.RawCellText.Trim(), request.RawCellText.Trim(), StringComparison.Ordinal) || !string.Equals(currentState.QuickParseInput.Trim(), request.QuickParseInput.Trim(), StringComparison.Ordinal) || !string.Equals(currentState.DescriptionText.Trim(), request.DescriptionText.Trim(), StringComparison.Ordinal) || !string.Equals(NormalizeOptionalText(currentState.RawAffixText), NormalizeOptionalText(request.RawAffixText), StringComparison.Ordinal) || !string.Equals(currentState.ParseStatus.Trim(), request.ParseStatus.Trim(), StringComparison.Ordinal) || currentState.IsDescriptionOverridden != request.IsDescriptionOverridden || currentState.IsRawAffixTextOverridden != request.IsRawAffixTextOverridden || currentState.AreEffectsOverridden != request.AreEffectsOverridden || currentState.AreBranchesOverridden != request.AreBranchesOverridden) { return true; } - return !EffectListsEqual(currentState.Effects, request.Effects) || - !BranchListsEqual(currentState.Branches, request.Branches); + return !EffectListsEqual(currentState.Effects, request.Effects) || !BranchListsEqual(currentState.Branches, request.Branches); } - private static bool EffectListsEqual( - IReadOnlyList left, - IReadOnlyList right) + private static bool EffectListsEqual(IReadOnlyList left, IReadOnlyList right) { if (left.Count != right.Count) { @@ -1104,9 +555,7 @@ public sealed class LookupService( return true; } - private static bool BranchListsEqual( - IReadOnlyList left, - IReadOnlyList right) + private static bool BranchListsEqual(IReadOnlyList left, IReadOnlyList right) { if (left.Count != right.Count) { @@ -1125,41 +574,21 @@ public sealed class LookupService( } private static bool EffectsEqual(CriticalEffectEditorItem left, CriticalEffectEditorItem right) => - string.Equals(left.EffectCode.Trim(), right.EffectCode.Trim(), StringComparison.Ordinal) && - string.Equals(NormalizeOptionalText(left.Target), NormalizeOptionalText(right.Target), StringComparison.Ordinal) && - left.ValueInteger == right.ValueInteger && - left.ValueDecimal == right.ValueDecimal && - string.Equals(NormalizeOptionalText(left.ValueExpression), NormalizeOptionalText(right.ValueExpression), StringComparison.Ordinal) && - left.DurationRounds == right.DurationRounds && - left.PerRound == right.PerRound && - left.Modifier == right.Modifier && - string.Equals(NormalizeOptionalText(left.BodyPart), NormalizeOptionalText(right.BodyPart), StringComparison.Ordinal) && - left.IsPermanent == right.IsPermanent && - string.Equals(left.SourceType.Trim(), right.SourceType.Trim(), StringComparison.Ordinal) && - string.Equals(NormalizeOptionalText(left.SourceText), NormalizeOptionalText(right.SourceText), StringComparison.Ordinal) && - string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && - left.IsOverridden == right.IsOverridden; + string.Equals(left.EffectCode.Trim(), right.EffectCode.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.Target), NormalizeOptionalText(right.Target), StringComparison.Ordinal) && left.ValueInteger == right.ValueInteger && left.ValueDecimal == right.ValueDecimal && string.Equals(NormalizeOptionalText(left.ValueExpression), NormalizeOptionalText(right.ValueExpression), StringComparison.Ordinal) && left.DurationRounds == right.DurationRounds && left.PerRound == right.PerRound && left.Modifier == right.Modifier && string.Equals(NormalizeOptionalText(left.BodyPart), NormalizeOptionalText(right.BodyPart), StringComparison.Ordinal) && left.IsPermanent == right.IsPermanent && string.Equals(left.SourceType.Trim(), right.SourceType.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.SourceText), NormalizeOptionalText(right.SourceText), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && left.IsOverridden == right.IsOverridden; private static bool BranchesEqual(CriticalBranchEditorItem left, CriticalBranchEditorItem right) => - string.Equals(left.BranchKind.Trim(), right.BranchKind.Trim(), StringComparison.Ordinal) && - string.Equals(NormalizeOptionalText(left.ConditionKey), NormalizeOptionalText(right.ConditionKey), StringComparison.Ordinal) && - string.Equals(left.ConditionText.Trim(), right.ConditionText.Trim(), StringComparison.Ordinal) && - string.Equals(left.ConditionJson.Trim(), right.ConditionJson.Trim(), StringComparison.Ordinal) && - string.Equals(left.RawText.Trim(), right.RawText.Trim(), StringComparison.Ordinal) && - string.Equals(left.DescriptionText.Trim(), right.DescriptionText.Trim(), StringComparison.Ordinal) && - string.Equals(NormalizeOptionalText(left.RawAffixText), NormalizeOptionalText(right.RawAffixText), StringComparison.Ordinal) && - string.Equals(left.ParsedJson.Trim(), right.ParsedJson.Trim(), StringComparison.Ordinal) && - left.SortOrder == right.SortOrder && - string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && - left.IsOverridden == right.IsOverridden && - left.AreEffectsOverridden == right.AreEffectsOverridden && - EffectListsEqual(left.Effects, right.Effects); + string.Equals(left.BranchKind.Trim(), right.BranchKind.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.ConditionKey), NormalizeOptionalText(right.ConditionKey), StringComparison.Ordinal) && string.Equals(left.ConditionText.Trim(), right.ConditionText.Trim(), StringComparison.Ordinal) && string.Equals(left.ConditionJson.Trim(), right.ConditionJson.Trim(), StringComparison.Ordinal) && string.Equals(left.RawText.Trim(), right.RawText.Trim(), StringComparison.Ordinal) && string.Equals(left.DescriptionText.Trim(), right.DescriptionText.Trim(), StringComparison.Ordinal) && string.Equals(NormalizeOptionalText(left.RawAffixText), NormalizeOptionalText(right.RawAffixText), StringComparison.Ordinal) && string.Equals(left.ParsedJson.Trim(), right.ParsedJson.Trim(), StringComparison.Ordinal) && left.SortOrder == right.SortOrder && string.Equals(NormalizeOptionalText(left.OriginKey), NormalizeOptionalText(right.OriginKey), StringComparison.Ordinal) && left.IsOverridden == right.IsOverridden && left.AreEffectsOverridden == right.AreEffectsOverridden && EffectListsEqual(left.Effects, right.Effects); 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"; -} + string.IsNullOrWhiteSpace(result.SourceImagePath) ? null : $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image?v={CreateSourceImageVersion(result)}"; + + private static string CreateSourceImageVersion(CriticalResult result) + { + var provenance = string.Join('|', result.SourcePageNumber?.ToString() ?? string.Empty, result.SourceImagePath ?? string.Empty, result.SourceImageCropJson ?? string.Empty); + var hash = SHA256.HashData(Encoding.UTF8.GetBytes(provenance)); + return Convert.ToHexStringLower(hash[..8]); + } +} \ No newline at end of file diff --git a/src/RolemasterDb.App/Program.cs b/src/RolemasterDb.App/Program.cs index bc4ab11..5cb07fe 100644 --- a/src/RolemasterDb.App/Program.cs +++ b/src/RolemasterDb.App/Program.cs @@ -49,10 +49,18 @@ api.MapGet("/tables/critical/{slug}/cells/{resultId:int}", async (string slug, i var result = await lookupService.GetCriticalCellEditorAsync(slug, resultId, cancellationToken); return result is null ? Results.NotFound() : Results.Ok(result); }); -api.MapGet("/tables/critical/{slug}/cells/{resultId:int}/source-image", async (string slug, int resultId, LookupService lookupService, CancellationToken cancellationToken) => +api.MapGet("/tables/critical/{slug}/cells/{resultId:int}/source-image", async (string slug, int resultId, LookupService lookupService, HttpContext httpContext, CancellationToken cancellationToken) => { var filePath = await lookupService.GetCriticalSourceImagePathAsync(slug, resultId, cancellationToken); - return filePath is null ? Results.NotFound() : Results.File(filePath, "image/png"); + if (filePath is null) + { + return Results.NotFound(); + } + + httpContext.Response.Headers.CacheControl = "no-store, no-cache, max-age=0"; + httpContext.Response.Headers.Pragma = "no-cache"; + httpContext.Response.Headers.Expires = "0"; + return Results.File(filePath, "image/png"); }); api.MapPost("/tables/critical/{slug}/cells/{resultId:int}/reparse", async (string slug, int resultId, CriticalCellReparseRequest request, LookupService lookupService, CancellationToken cancellationToken) => { diff --git a/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs index 75ecc12..81d61bf 100644 --- a/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/LookupServiceCurationIntegrationTests.cs @@ -1,5 +1,4 @@ using Microsoft.EntityFrameworkCore; - using RolemasterDb.App.Data; using RolemasterDb.App.Domain; using RolemasterDb.App.Features; @@ -25,22 +24,9 @@ public sealed class LookupServiceCurationIntegrationTests Assert.NotNull(initialResponse); Assert.False(initialResponse!.IsCurated); Assert.Equal(2, initialResponse.SourcePageNumber); - Assert.Equal($"/api/tables/critical/slash/cells/{resultId}/source-image", initialResponse.SourceImageUrl); + Assert.StartsWith($"/api/tables/critical/slash/cells/{resultId}/source-image?v=", initialResponse.SourceImageUrl, StringComparison.Ordinal); - var updateRequest = new CriticalCellUpdateRequest( - initialResponse.RawCellText, - initialResponse.QuickParseInput, - initialResponse.DescriptionText, - initialResponse.RawAffixText, - initialResponse.ParseStatus, - initialResponse.ParsedJson, - true, - initialResponse.IsDescriptionOverridden, - initialResponse.IsRawAffixTextOverridden, - initialResponse.AreEffectsOverridden, - initialResponse.AreBranchesOverridden, - initialResponse.Effects, - initialResponse.Branches); + var updateRequest = new CriticalCellUpdateRequest(initialResponse.RawCellText, initialResponse.QuickParseInput, initialResponse.DescriptionText, initialResponse.RawAffixText, initialResponse.ParseStatus, initialResponse.ParsedJson, true, initialResponse.IsDescriptionOverridden, initialResponse.IsRawAffixTextOverridden, initialResponse.AreEffectsOverridden, initialResponse.AreBranchesOverridden, initialResponse.Effects, initialResponse.Branches); var updatedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, updateRequest); Assert.NotNull(updatedResponse); @@ -56,6 +42,32 @@ public sealed class LookupServiceCurationIntegrationTests Assert.Equal(initialResponse.SourceImageUrl, reopenedResponse.SourceImageUrl); } + [Fact] + public async Task Lookup_service_changes_source_image_url_when_source_provenance_changes() + { + var databasePath = CreateEmptyDatabasePath(); + var repositoryRoot = CreateTemporaryRepositoryRoot(); + var locator = new CriticalImportArtifactLocator(new TestHostEnvironment(Path.Combine(repositoryRoot, "src", "RolemasterDb.App"))); + + await SeedCriticalResultAsync(databasePath, "slash/cells/source-cell.png", 2); + WriteSourceImage(repositoryRoot, "slash/cells/source-cell.png"); + WriteSourceImage(repositoryRoot, "slash/cells/source-cell-v2.png"); + + var lookupService = new LookupService(CreateDbContextFactory(databasePath), locator); + var resultId = await GetResultIdAsync(databasePath); + + var initialResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); + Assert.NotNull(initialResponse); + Assert.StartsWith($"/api/tables/critical/slash/cells/{resultId}/source-image?v=", initialResponse!.SourceImageUrl, StringComparison.Ordinal); + + await UpdateSourceMetadataAsync(databasePath, resultId, sourcePageNumber: 4, sourceImagePath: "slash/cells/source-cell-v2.png", sourceImageCropJson: "{\"pageNumber\":4,\"boundsLeft\":12}"); + + var reopenedResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); + Assert.NotNull(reopenedResponse); + Assert.StartsWith($"/api/tables/critical/slash/cells/{resultId}/source-image?v=", reopenedResponse!.SourceImageUrl, StringComparison.Ordinal); + Assert.NotEqual(initialResponse.SourceImageUrl, reopenedResponse.SourceImageUrl); + } + [Fact] public async Task Lookup_service_clears_curated_state_when_any_content_is_edited() { @@ -72,29 +84,13 @@ public sealed class LookupServiceCurationIntegrationTests var initialResponse = await lookupService.GetCriticalCellEditorAsync("slash", resultId); Assert.NotNull(initialResponse); - var markCuratedRequest = new CriticalCellUpdateRequest( - initialResponse!.RawCellText, - initialResponse.QuickParseInput, - initialResponse.DescriptionText, - initialResponse.RawAffixText, - initialResponse.ParseStatus, - initialResponse.ParsedJson, - true, - initialResponse.IsDescriptionOverridden, - initialResponse.IsRawAffixTextOverridden, - initialResponse.AreEffectsOverridden, - initialResponse.AreBranchesOverridden, - initialResponse.Effects, - initialResponse.Branches); + var markCuratedRequest = new CriticalCellUpdateRequest(initialResponse!.RawCellText, initialResponse.QuickParseInput, initialResponse.DescriptionText, initialResponse.RawAffixText, initialResponse.ParseStatus, initialResponse.ParsedJson, true, initialResponse.IsDescriptionOverridden, initialResponse.IsRawAffixTextOverridden, initialResponse.AreEffectsOverridden, initialResponse.AreBranchesOverridden, initialResponse.Effects, initialResponse.Branches); var curatedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, markCuratedRequest); Assert.NotNull(curatedResponse); Assert.True(curatedResponse!.IsCurated); - var editedRequest = markCuratedRequest with - { - DescriptionText = "Edited description after curation." - }; + var editedRequest = markCuratedRequest with { DescriptionText = "Edited description after curation." }; var editedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, editedRequest); Assert.NotNull(editedResponse); @@ -123,44 +119,12 @@ public sealed class LookupServiceCurationIntegrationTests Assert.NotNull(initialResponse); Assert.False(initialResponse!.IsCurated); - var reparsedResponse = await lookupService.ReparseCriticalCellAsync( - "slash", - resultId, - new CriticalCellUpdateRequest( - initialResponse.RawCellText, - "Edited quick parse input.", - initialResponse.DescriptionText, - initialResponse.RawAffixText, - initialResponse.ParseStatus, - initialResponse.ParsedJson, - initialResponse.IsCurated, - initialResponse.IsDescriptionOverridden, - initialResponse.IsRawAffixTextOverridden, - initialResponse.AreEffectsOverridden, - initialResponse.AreBranchesOverridden, - initialResponse.Effects, - initialResponse.Branches)); + var reparsedResponse = await lookupService.ReparseCriticalCellAsync("slash", resultId, new CriticalCellUpdateRequest(initialResponse.RawCellText, "Edited quick parse input.", initialResponse.DescriptionText, initialResponse.RawAffixText, initialResponse.ParseStatus, initialResponse.ParsedJson, initialResponse.IsCurated, initialResponse.IsDescriptionOverridden, initialResponse.IsRawAffixTextOverridden, initialResponse.AreEffectsOverridden, initialResponse.AreBranchesOverridden, initialResponse.Effects, initialResponse.Branches)); Assert.NotNull(reparsedResponse); Assert.False(reparsedResponse!.IsCurated); - var curatedResponse = await lookupService.UpdateCriticalCellAsync( - "slash", - resultId, - new CriticalCellUpdateRequest( - reparsedResponse.RawCellText, - reparsedResponse.QuickParseInput, - reparsedResponse.DescriptionText, - reparsedResponse.RawAffixText, - reparsedResponse.ParseStatus, - reparsedResponse.ParsedJson, - true, - reparsedResponse.IsDescriptionOverridden, - reparsedResponse.IsRawAffixTextOverridden, - reparsedResponse.AreEffectsOverridden, - reparsedResponse.AreBranchesOverridden, - reparsedResponse.Effects, - reparsedResponse.Branches)); + var curatedResponse = await lookupService.UpdateCriticalCellAsync("slash", resultId, new CriticalCellUpdateRequest(reparsedResponse.RawCellText, reparsedResponse.QuickParseInput, reparsedResponse.DescriptionText, reparsedResponse.RawAffixText, reparsedResponse.ParseStatus, reparsedResponse.ParsedJson, true, reparsedResponse.IsDescriptionOverridden, reparsedResponse.IsRawAffixTextOverridden, reparsedResponse.AreEffectsOverridden, reparsedResponse.AreBranchesOverridden, reparsedResponse.Effects, reparsedResponse.Branches)); Assert.NotNull(curatedResponse); Assert.True(curatedResponse!.IsCurated); @@ -263,26 +227,29 @@ public sealed class LookupServiceCurationIntegrationTests private static async Task GetResultIdAsync(string databasePath) { await using var dbContext = CreateDbContext(databasePath); - return await dbContext.CriticalResults - .Where(item => item.CriticalTable.Slug == "slash") - .Select(item => item.Id) - .SingleAsync(); + return await dbContext.CriticalResults.Where(item => item.CriticalTable.Slug == "slash").Select(item => item.Id).SingleAsync(); + } + + private static async Task UpdateSourceMetadataAsync(string databasePath, int resultId, int sourcePageNumber, string sourceImagePath, string sourceImageCropJson) + { + await using var dbContext = CreateDbContext(databasePath); + var result = await dbContext.CriticalResults.SingleAsync(item => item.Id == resultId); + result.SourcePageNumber = sourcePageNumber; + result.SourceImagePath = sourceImagePath; + result.SourceImageCropJson = sourceImageCropJson; + await dbContext.SaveChangesAsync(); } private static RolemasterDbContext CreateDbContext(string databasePath) { - var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={databasePath}") - .Options; + var options = new DbContextOptionsBuilder().UseSqlite($"Data Source={databasePath}").Options; return new RolemasterDbContext(options); } private static IDbContextFactory CreateDbContextFactory(string databasePath) { - var options = new DbContextOptionsBuilder() - .UseSqlite($"Data Source={databasePath}") - .Options; + var options = new DbContextOptionsBuilder().UseSqlite($"Data Source={databasePath}").Options; return new TestRolemasterDbContextFactory(options); } @@ -297,4 +264,4 @@ public sealed class LookupServiceCurationIntegrationTests File.WriteAllText(Path.Combine(repositoryRoot, "RolemasterDB.slnx"), string.Empty); return repositoryRoot; } -} +} \ No newline at end of file