1034 lines
41 KiB
C#
1034 lines
41 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.Linq;
|
||
using System.Text.Json;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using RolemasterDb.App.Data;
|
||
using RolemasterDb.App.Domain;
|
||
using SharedParsing = RolemasterDb.CriticalParsing;
|
||
|
||
namespace RolemasterDb.App.Features;
|
||
|
||
public sealed class LookupService(
|
||
IDbContextFactory<RolemasterDbContext> dbContextFactory,
|
||
CriticalImportArtifactLocator? artifactLocator = null)
|
||
{
|
||
public async Task<LookupReferenceData> GetReferenceDataAsync(CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var attackTables = await dbContext.AttackTables
|
||
.AsNoTracking()
|
||
.OrderBy(item => item.DisplayName)
|
||
.Select(item => new AttackTableReference(
|
||
item.Slug,
|
||
item.DisplayName,
|
||
item.AttackKind,
|
||
item.FumbleMinRoll,
|
||
item.FumbleMaxRoll))
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var armorTypes = await dbContext.ArmorTypes
|
||
.AsNoTracking()
|
||
.OrderBy(item => item.SortOrder)
|
||
.Select(item => new LookupOption(item.Code, item.Label))
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var criticalTables = await dbContext.CriticalTables
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Include(item => item.Columns)
|
||
.Include(item => item.Groups)
|
||
.Include(item => item.RollBands)
|
||
.OrderBy(item => item.DisplayName)
|
||
.ToListAsync(cancellationToken);
|
||
|
||
return new LookupReferenceData(
|
||
attackTables,
|
||
armorTypes,
|
||
criticalTables.Select(item => new CriticalTableReference(
|
||
item.Slug,
|
||
item.DisplayName,
|
||
item.Family,
|
||
item.SourceDocument,
|
||
item.Notes,
|
||
item.Columns
|
||
.OrderBy(column => column.SortOrder)
|
||
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
|
||
.ToList(),
|
||
item.Groups
|
||
.OrderBy(group => group.SortOrder)
|
||
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
|
||
.ToList(),
|
||
item.RollBands
|
||
.OrderBy(rollBand => rollBand.SortOrder)
|
||
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
|
||
.ToList()))
|
||
.ToList());
|
||
}
|
||
|
||
public async Task<AttackLookupResponse?> LookupAttackAsync(AttackLookupRequest request, CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var attackTable = NormalizeSlug(request.AttackTable);
|
||
var armorType = request.ArmorType.Trim().ToUpperInvariant();
|
||
|
||
var attackResult = await dbContext.AttackResults
|
||
.AsNoTracking()
|
||
.Where(item =>
|
||
item.AttackTable.Slug == attackTable &&
|
||
item.ArmorType.Code == armorType &&
|
||
request.Roll >= item.AttackRollBand.MinRoll &&
|
||
(item.AttackRollBand.MaxRoll == null || request.Roll <= item.AttackRollBand.MaxRoll))
|
||
.Select(item => new AttackLookupResponse(
|
||
item.AttackTable.Slug,
|
||
item.AttackTable.DisplayName,
|
||
item.ArmorType.Code,
|
||
item.ArmorType.Label,
|
||
request.Roll,
|
||
item.AttackRollBand.Label,
|
||
item.Hits,
|
||
item.CriticalType,
|
||
item.CriticalSeverity,
|
||
item.RawNotation,
|
||
item.Notes,
|
||
null))
|
||
.SingleOrDefaultAsync(cancellationToken);
|
||
|
||
if (attackResult is null || attackResult.CriticalType is null || attackResult.CriticalSeverity is null || request.CriticalRoll is null)
|
||
{
|
||
return attackResult;
|
||
}
|
||
|
||
var autoCritical = await LookupCriticalAsync(
|
||
new CriticalLookupRequest(attackResult.CriticalType, attackResult.CriticalSeverity, request.CriticalRoll.Value, null),
|
||
cancellationToken);
|
||
|
||
return attackResult with { AutoCritical = autoCritical };
|
||
}
|
||
|
||
public async Task<CriticalLookupResponse?> LookupCriticalAsync(CriticalLookupRequest request, CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var criticalType = NormalizeSlug(request.CriticalType);
|
||
var column = request.Column.Trim().ToUpperInvariant();
|
||
var group = string.IsNullOrWhiteSpace(request.Group) ? null : NormalizeSlug(request.Group);
|
||
|
||
return await dbContext.CriticalResults
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Where(item =>
|
||
item.CriticalTable.Slug == criticalType &&
|
||
item.CriticalColumn.ColumnKey == column &&
|
||
(group == null
|
||
? item.CriticalGroupId == null
|
||
: item.CriticalGroup != null && item.CriticalGroup.GroupKey == group) &&
|
||
request.Roll >= item.CriticalRollBand.MinRoll &&
|
||
(item.CriticalRollBand.MaxRoll == null || request.Roll <= item.CriticalRollBand.MaxRoll))
|
||
.Select(item => new CriticalLookupResponse(
|
||
item.CriticalTable.Slug,
|
||
item.CriticalTable.DisplayName,
|
||
item.CriticalTable.Family,
|
||
item.CriticalTable.SourceDocument,
|
||
item.CriticalTable.Notes,
|
||
item.CriticalGroup != null ? item.CriticalGroup.GroupKey : null,
|
||
item.CriticalGroup != null ? item.CriticalGroup.Label : null,
|
||
item.CriticalColumn.ColumnKey,
|
||
item.CriticalColumn.Label,
|
||
item.CriticalColumn.Role,
|
||
request.Roll,
|
||
item.CriticalRollBand.Label,
|
||
item.CriticalRollBand.MinRoll,
|
||
item.CriticalRollBand.MaxRoll,
|
||
item.RawCellText,
|
||
item.DescriptionText,
|
||
item.RawAffixText,
|
||
item.Effects
|
||
.OrderBy(effect => effect.Id)
|
||
.Select(effect => new CriticalEffectLookupResponse(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText))
|
||
.ToList(),
|
||
item.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.Select(branch => new CriticalBranchLookupResponse(
|
||
branch.BranchKind,
|
||
branch.ConditionKey,
|
||
branch.ConditionText,
|
||
branch.DescriptionText,
|
||
branch.RawAffixText,
|
||
branch.Effects
|
||
.OrderBy(effect => effect.Id)
|
||
.Select(effect => new CriticalEffectLookupResponse(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText))
|
||
.ToList(),
|
||
branch.RawText,
|
||
branch.SortOrder))
|
||
.ToList(),
|
||
item.ParseStatus,
|
||
item.ParsedJson))
|
||
.SingleOrDefaultAsync(cancellationToken);
|
||
}
|
||
|
||
public async Task<CriticalTableDetail?> GetCriticalTableAsync(string slug, CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var table = await dbContext.CriticalTables
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Include(item => item.Columns)
|
||
.Include(item => item.Groups)
|
||
.Include(item => item.RollBands)
|
||
.Include(item => item.Results)
|
||
.ThenInclude(result => result.CriticalColumn)
|
||
.Include(item => item.Results)
|
||
.ThenInclude(result => result.CriticalGroup)
|
||
.Include(item => item.Results)
|
||
.ThenInclude(result => result.CriticalRollBand)
|
||
.Include(item => item.Results)
|
||
.ThenInclude(result => result.Effects)
|
||
.Include(item => item.Results)
|
||
.ThenInclude(result => result.Branches)
|
||
.ThenInclude(branch => branch.Effects)
|
||
.Where(item => item.Slug == slug)
|
||
.SingleOrDefaultAsync(cancellationToken);
|
||
|
||
if (table is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var columns = table.Columns
|
||
.OrderBy(column => column.SortOrder)
|
||
.Select(column => new CriticalColumnReference(column.ColumnKey, column.Label, column.Role, column.SortOrder))
|
||
.ToList();
|
||
|
||
var groups = table.Groups
|
||
.OrderBy(group => group.SortOrder)
|
||
.Select(group => new CriticalGroupReference(group.GroupKey, group.Label, group.SortOrder))
|
||
.ToList();
|
||
|
||
var rollBands = table.RollBands
|
||
.OrderBy(rollBand => rollBand.SortOrder)
|
||
.Select(rollBand => new CriticalRollBandReference(rollBand.Label, rollBand.MinRoll, rollBand.MaxRoll, rollBand.SortOrder))
|
||
.ToList();
|
||
|
||
var cells = table.Results
|
||
.OrderBy(result => result.CriticalRollBand.SortOrder)
|
||
.ThenBy(result => result.CriticalGroup?.SortOrder ?? 0)
|
||
.ThenBy(result => result.CriticalColumn.SortOrder)
|
||
.Select(result => new CriticalTableCellDetail(
|
||
result.Id,
|
||
result.CriticalRollBand.Label,
|
||
result.CriticalColumn.ColumnKey,
|
||
result.CriticalColumn.Label,
|
||
result.CriticalColumn.Role,
|
||
result.CriticalGroup?.GroupKey,
|
||
result.CriticalGroup?.Label,
|
||
result.IsCurated,
|
||
result.DescriptionText,
|
||
result.Effects
|
||
.OrderBy(effect => effect.Id)
|
||
.Select(effect => CreateEffectLookupResponse(effect))
|
||
.ToList(),
|
||
result.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.Select(branch => CreateBranchLookupResponse(branch))
|
||
.ToList()))
|
||
.ToList();
|
||
|
||
var legend = BuildLegend(cells);
|
||
|
||
return new CriticalTableDetail(
|
||
table.Slug,
|
||
table.DisplayName,
|
||
table.Family,
|
||
table.SourceDocument,
|
||
table.Notes,
|
||
columns,
|
||
groups,
|
||
rollBands,
|
||
cells,
|
||
legend);
|
||
}
|
||
|
||
public async Task<CriticalCellEditorResponse?> GetCriticalCellEditorAsync(string slug, int resultId, CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var normalizedSlug = NormalizeSlug(slug);
|
||
var result = await dbContext.CriticalResults
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Include(item => item.CriticalTable)
|
||
.Include(item => item.CriticalColumn)
|
||
.Include(item => item.CriticalGroup)
|
||
.Include(item => item.CriticalRollBand)
|
||
.Include(item => item.Effects)
|
||
.Include(item => item.Branches)
|
||
.ThenInclude(branch => branch.Effects)
|
||
.SingleOrDefaultAsync(
|
||
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||
cancellationToken);
|
||
|
||
if (result is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var currentState = CreateCurrentEditorState(result);
|
||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
|
||
return CreateCellEditorResponse(result, currentState, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
|
||
}
|
||
|
||
public async Task<string?> GetCriticalSourceImagePathAsync(string slug, int resultId, CancellationToken cancellationToken = default)
|
||
{
|
||
if (artifactLocator is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var normalizedSlug = NormalizeSlug(slug);
|
||
var relativePath = await dbContext.CriticalResults
|
||
.AsNoTracking()
|
||
.Where(item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug)
|
||
.Select(item => item.SourceImagePath)
|
||
.SingleOrDefaultAsync(cancellationToken);
|
||
|
||
var fullPath = artifactLocator.ResolveStoredPath(relativePath);
|
||
return fullPath is not null && File.Exists(fullPath)
|
||
? fullPath
|
||
: null;
|
||
}
|
||
|
||
public async Task<CriticalCellEditorResponse?> ReparseCriticalCellAsync(
|
||
string slug,
|
||
int resultId,
|
||
CriticalCellUpdateRequest currentState,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var normalizedSlug = NormalizeSlug(slug);
|
||
var result = await dbContext.CriticalResults
|
||
.AsNoTracking()
|
||
.AsSplitQuery()
|
||
.Include(item => item.CriticalTable)
|
||
.Include(item => item.CriticalColumn)
|
||
.Include(item => item.CriticalGroup)
|
||
.Include(item => item.CriticalRollBand)
|
||
.SingleOrDefaultAsync(
|
||
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||
cancellationToken);
|
||
|
||
if (result is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
var content = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, currentState.QuickParseInput, cancellationToken);
|
||
var generatedState = CreateGeneratedEditorState(content);
|
||
var mergedState = MergeGeneratedState(currentState, generatedState);
|
||
return CreateCellEditorResponse(result, mergedState, content.ValidationErrors, CreateComparisonState(content));
|
||
}
|
||
|
||
public async Task<CriticalCellEditorResponse?> UpdateCriticalCellAsync(
|
||
string slug,
|
||
int resultId,
|
||
CriticalCellUpdateRequest request,
|
||
CancellationToken cancellationToken = default)
|
||
{
|
||
await using var dbContext = await dbContextFactory.CreateDbContextAsync(cancellationToken);
|
||
|
||
var normalizedSlug = NormalizeSlug(slug);
|
||
var result = await dbContext.CriticalResults
|
||
.AsSplitQuery()
|
||
.Include(item => item.CriticalTable)
|
||
.Include(item => item.CriticalColumn)
|
||
.Include(item => item.CriticalGroup)
|
||
.Include(item => item.CriticalRollBand)
|
||
.Include(item => item.Effects)
|
||
.Include(item => item.Branches)
|
||
.ThenInclude(branch => branch.Effects)
|
||
.SingleOrDefaultAsync(
|
||
item => item.Id == resultId && item.CriticalTable.Slug == normalizedSlug,
|
||
cancellationToken);
|
||
|
||
if (result is null)
|
||
{
|
||
return null;
|
||
}
|
||
|
||
result.RawCellText = request.RawCellText.Trim();
|
||
result.DescriptionText = request.DescriptionText.Trim();
|
||
result.RawAffixText = NormalizeOptionalText(request.RawAffixText);
|
||
result.ParseStatus = request.ParseStatus.Trim();
|
||
result.ParsedJson = CriticalCellEditorSnapshot.FromRequest(request).ToJson();
|
||
result.IsCurated = request.IsCurated;
|
||
|
||
ReplaceBaseEffects(dbContext, result, request.Effects);
|
||
ReplaceBranches(dbContext, result, request.Branches);
|
||
|
||
await dbContext.SaveChangesAsync(cancellationToken);
|
||
|
||
var generatedContent = await ParseCriticalCellContentAsync(dbContext, result.CriticalTableId, request.QuickParseInput, cancellationToken);
|
||
return CreateCellEditorResponse(result, request, generatedContent.ValidationErrors, CreateComparisonState(generatedContent));
|
||
}
|
||
|
||
private static IReadOnlyList<CriticalTableLegendEntry> BuildLegend(IReadOnlyList<CriticalTableCellDetail> cells)
|
||
{
|
||
var seenCodes = new HashSet<string>(StringComparer.Ordinal);
|
||
var legend = new List<CriticalTableLegendEntry>();
|
||
|
||
foreach (var cell in cells)
|
||
{
|
||
var baseEffects = cell.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
|
||
foreach (var effect in baseEffects)
|
||
{
|
||
TryAddLegendEntry(effect.EffectCode);
|
||
}
|
||
|
||
var branches = cell.Branches ?? Array.Empty<CriticalBranchLookupResponse>();
|
||
foreach (var branch in branches)
|
||
{
|
||
var branchEffects = branch.Effects ?? Array.Empty<CriticalEffectLookupResponse>();
|
||
foreach (var effect in branchEffects)
|
||
{
|
||
TryAddLegendEntry(effect.EffectCode);
|
||
}
|
||
}
|
||
}
|
||
|
||
return legend
|
||
.OrderBy(item => item.Label, StringComparer.Ordinal)
|
||
.ToList();
|
||
|
||
void TryAddLegendEntry(string effectCode)
|
||
{
|
||
if (!seenCodes.Add(effectCode))
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (!AffixDisplayMap.TryGet(effectCode, out var info))
|
||
{
|
||
return;
|
||
}
|
||
|
||
legend.Add(new CriticalTableLegendEntry(effectCode, info.Symbol, info.Label, info.Description, info.Tooltip));
|
||
}
|
||
}
|
||
|
||
private static CriticalEffectLookupResponse CreateEffectLookupResponse(CriticalEffect effect) =>
|
||
new(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText);
|
||
|
||
private static CriticalCellEditorResponse CreateCellEditorResponse(
|
||
CriticalResult result,
|
||
CriticalCellUpdateRequest state,
|
||
IReadOnlyList<string> validationMessages,
|
||
CriticalCellComparisonState? generatedState)
|
||
{
|
||
var snapshotJson = CriticalCellEditorSnapshot.FromRequest(state).ToJson();
|
||
|
||
return new(
|
||
result.Id,
|
||
result.CriticalTable.Slug,
|
||
result.CriticalTable.DisplayName,
|
||
result.CriticalTable.SourceDocument,
|
||
result.CriticalRollBand.Label,
|
||
result.CriticalGroup?.GroupKey,
|
||
result.CriticalGroup?.Label,
|
||
result.CriticalColumn.ColumnKey,
|
||
result.CriticalColumn.Label,
|
||
result.CriticalColumn.Role,
|
||
state.IsCurated,
|
||
result.SourcePageNumber,
|
||
CreateSourceImageUrl(result),
|
||
state.RawCellText,
|
||
state.QuickParseInput,
|
||
state.DescriptionText,
|
||
state.RawAffixText,
|
||
state.ParseStatus,
|
||
snapshotJson,
|
||
state.IsDescriptionOverridden,
|
||
state.IsRawAffixTextOverridden,
|
||
state.AreEffectsOverridden,
|
||
state.AreBranchesOverridden,
|
||
validationMessages.ToList(),
|
||
state.Effects.ToList(),
|
||
state.Branches.ToList(),
|
||
generatedState);
|
||
}
|
||
|
||
private static CriticalBranchLookupResponse CreateBranchLookupResponse(CriticalBranch branch) =>
|
||
new(
|
||
branch.BranchKind,
|
||
branch.ConditionKey,
|
||
branch.ConditionText,
|
||
branch.DescriptionText,
|
||
branch.RawAffixText,
|
||
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
|
||
.OrderBy(effect => effect.Id)
|
||
.Select(effect => CreateEffectLookupResponse(effect))
|
||
.ToList(),
|
||
branch.RawText,
|
||
branch.SortOrder);
|
||
|
||
private static CriticalBranchEditorItem CreateBranchEditorItem(CriticalBranch branch, int branchIndex)
|
||
{
|
||
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
|
||
|
||
return new(
|
||
branch.BranchKind,
|
||
branch.ConditionKey,
|
||
branch.ConditionText,
|
||
branch.ConditionJson,
|
||
branch.RawText,
|
||
branch.DescriptionText,
|
||
branch.RawAffixText,
|
||
branch.ParsedJson,
|
||
branch.SortOrder,
|
||
originKey,
|
||
false,
|
||
false,
|
||
(branch.Effects ?? Enumerable.Empty<CriticalEffect>())
|
||
.OrderBy(effect => effect.Id)
|
||
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
|
||
.ToList());
|
||
}
|
||
|
||
private static CriticalBranchEditorItem CreateBranchEditorItem(SharedParsing.ParsedCriticalBranch branch, int branchIndex)
|
||
{
|
||
var originKey = CreateBranchOriginKey(branchIndex, branch.BranchKind, branch.ConditionKey, branch.ConditionText);
|
||
|
||
return new(
|
||
branch.BranchKind,
|
||
branch.ConditionKey,
|
||
branch.ConditionText,
|
||
"{}",
|
||
branch.RawText,
|
||
branch.DescriptionText,
|
||
branch.RawAffixText,
|
||
SerializeParsedEffects(branch.Effects),
|
||
branch.SortOrder,
|
||
originKey,
|
||
false,
|
||
false,
|
||
branch.Effects
|
||
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBranchEffectOriginKey(originKey, effectIndex, effect.EffectCode)))
|
||
.ToList());
|
||
}
|
||
|
||
private static CriticalCellComparisonState CreateComparisonState(SharedParsing.CriticalCellParseContent content) =>
|
||
new(
|
||
content.DescriptionText,
|
||
content.Effects
|
||
.Select(CreateEffectLookupResponse)
|
||
.ToList(),
|
||
content.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.Select(CreateBranchLookupResponse)
|
||
.ToList(),
|
||
content.ValidationErrors.ToList(),
|
||
content.TokenReviewIssues
|
||
.Select(CreateTokenReviewItem)
|
||
.ToList());
|
||
|
||
private static CriticalTokenReviewItem CreateTokenReviewItem(SharedParsing.CriticalTokenReviewIssue issue) =>
|
||
new(
|
||
issue.Scope,
|
||
issue.ConditionText,
|
||
issue.Token,
|
||
issue.ReviewText);
|
||
|
||
private static CriticalEffectEditorItem CreateEffectEditorItem(CriticalEffect effect, string originKey) =>
|
||
new(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueDecimal,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText,
|
||
originKey,
|
||
false);
|
||
|
||
private static CriticalEffectEditorItem CreateEffectEditorItem(SharedParsing.ParsedCriticalEffect effect, string originKey) =>
|
||
new(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
null,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText,
|
||
originKey,
|
||
false);
|
||
|
||
private static CriticalCellUpdateRequest CreateCurrentEditorState(CriticalResult result)
|
||
{
|
||
if (CriticalCellEditorSnapshot.TryParse(result.ParsedJson, out var snapshot) && snapshot is not null)
|
||
{
|
||
var snapshotQuickParseInput = string.IsNullOrWhiteSpace(snapshot.QuickParseInput)
|
||
? CriticalQuickNotationFormatter.Format(result.DescriptionText, snapshot.Effects, snapshot.Branches)
|
||
: snapshot.QuickParseInput;
|
||
|
||
return new CriticalCellUpdateRequest(
|
||
result.RawCellText,
|
||
snapshotQuickParseInput,
|
||
result.DescriptionText,
|
||
result.RawAffixText,
|
||
result.ParseStatus,
|
||
result.ParsedJson,
|
||
result.IsCurated,
|
||
snapshot.IsDescriptionOverridden,
|
||
snapshot.IsRawAffixTextOverridden,
|
||
snapshot.AreEffectsOverridden,
|
||
snapshot.AreBranchesOverridden,
|
||
snapshot.Effects.ToList(),
|
||
snapshot.Branches.ToList());
|
||
}
|
||
|
||
var effects = result.Effects
|
||
.OrderBy(effect => effect.Id)
|
||
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode)))
|
||
.ToList();
|
||
var branches = result.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex))
|
||
.ToList();
|
||
|
||
return new CriticalCellUpdateRequest(
|
||
result.RawCellText,
|
||
CriticalQuickNotationFormatter.Format(result.DescriptionText, effects, branches),
|
||
result.DescriptionText,
|
||
result.RawAffixText,
|
||
result.ParseStatus,
|
||
result.ParsedJson,
|
||
result.IsCurated,
|
||
false,
|
||
false,
|
||
false,
|
||
false,
|
||
effects,
|
||
branches);
|
||
}
|
||
|
||
private static CriticalCellUpdateRequest CreateGeneratedEditorState(SharedParsing.CriticalCellParseContent content)
|
||
{
|
||
var effects = content.Effects
|
||
.Select((effect, effectIndex) => CreateEffectEditorItem(effect, CreateBaseEffectOriginKey(effectIndex, effect.EffectCode)))
|
||
.ToList();
|
||
var branches = content.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.Select((branch, branchIndex) => CreateBranchEditorItem(branch, branchIndex))
|
||
.ToList();
|
||
|
||
return new CriticalCellUpdateRequest(
|
||
RawCellText: string.Empty,
|
||
QuickParseInput: content.RawCellText,
|
||
DescriptionText: content.DescriptionText,
|
||
RawAffixText: content.RawAffixText,
|
||
ParseStatus: ResolveParseStatus(content.Effects, content.Branches),
|
||
ParsedJson: SerializeParsedEffects(content.Effects),
|
||
IsCurated: false,
|
||
IsDescriptionOverridden: false,
|
||
IsRawAffixTextOverridden: false,
|
||
AreEffectsOverridden: false,
|
||
AreBranchesOverridden: false,
|
||
Effects: effects,
|
||
Branches: branches);
|
||
}
|
||
|
||
private static CriticalCellUpdateRequest MergeGeneratedState(
|
||
CriticalCellUpdateRequest currentState,
|
||
CriticalCellUpdateRequest generatedState) =>
|
||
new(
|
||
currentState.RawCellText,
|
||
currentState.QuickParseInput,
|
||
currentState.IsDescriptionOverridden ? currentState.DescriptionText : generatedState.DescriptionText,
|
||
currentState.IsRawAffixTextOverridden ? currentState.RawAffixText : generatedState.RawAffixText,
|
||
generatedState.ParseStatus,
|
||
generatedState.ParsedJson,
|
||
currentState.IsCurated,
|
||
currentState.IsDescriptionOverridden,
|
||
currentState.IsRawAffixTextOverridden,
|
||
currentState.AreEffectsOverridden,
|
||
currentState.AreBranchesOverridden,
|
||
currentState.AreEffectsOverridden
|
||
? currentState.Effects.ToList()
|
||
: MergeEffectItems(currentState.Effects, generatedState.Effects),
|
||
currentState.AreBranchesOverridden
|
||
? currentState.Branches
|
||
.OrderBy(branch => branch.SortOrder)
|
||
.ToList()
|
||
: MergeBranchItems(currentState.Branches, generatedState.Branches));
|
||
|
||
private static List<CriticalBranchEditorItem> MergeBranchItems(
|
||
IReadOnlyList<CriticalBranchEditorItem> currentBranches,
|
||
IReadOnlyList<CriticalBranchEditorItem> generatedBranches)
|
||
{
|
||
var currentByOrigin = currentBranches
|
||
.Where(branch => !string.IsNullOrWhiteSpace(branch.OriginKey))
|
||
.ToDictionary(branch => branch.OriginKey!, StringComparer.Ordinal);
|
||
var merged = new List<CriticalBranchEditorItem>(generatedBranches.Count);
|
||
var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);
|
||
|
||
foreach (var generatedBranch in generatedBranches)
|
||
{
|
||
if (generatedBranch.OriginKey is not null &&
|
||
currentByOrigin.TryGetValue(generatedBranch.OriginKey, out var currentBranch))
|
||
{
|
||
matchedOrigins.Add(generatedBranch.OriginKey);
|
||
|
||
if (currentBranch.IsOverridden)
|
||
{
|
||
merged.Add(currentBranch);
|
||
continue;
|
||
}
|
||
|
||
merged.Add(generatedBranch with
|
||
{
|
||
AreEffectsOverridden = currentBranch.AreEffectsOverridden,
|
||
Effects = currentBranch.AreEffectsOverridden
|
||
? currentBranch.Effects.ToList()
|
||
: MergeEffectItems(currentBranch.Effects, generatedBranch.Effects)
|
||
});
|
||
continue;
|
||
}
|
||
|
||
merged.Add(generatedBranch);
|
||
}
|
||
|
||
merged.AddRange(currentBranches.Where(branch =>
|
||
branch.IsOverridden &&
|
||
branch.OriginKey is not null &&
|
||
!matchedOrigins.Contains(branch.OriginKey)));
|
||
|
||
return merged;
|
||
}
|
||
|
||
private static List<CriticalEffectEditorItem> MergeEffectItems(
|
||
IReadOnlyList<CriticalEffectEditorItem> currentEffects,
|
||
IReadOnlyList<CriticalEffectEditorItem> generatedEffects)
|
||
{
|
||
var currentByOrigin = currentEffects
|
||
.Where(effect => !string.IsNullOrWhiteSpace(effect.OriginKey))
|
||
.ToDictionary(effect => effect.OriginKey!, StringComparer.Ordinal);
|
||
var merged = new List<CriticalEffectEditorItem>(generatedEffects.Count);
|
||
var matchedOrigins = new HashSet<string>(StringComparer.Ordinal);
|
||
|
||
foreach (var generatedEffect in generatedEffects)
|
||
{
|
||
if (generatedEffect.OriginKey is not null &&
|
||
currentByOrigin.TryGetValue(generatedEffect.OriginKey, out var currentEffect))
|
||
{
|
||
matchedOrigins.Add(generatedEffect.OriginKey);
|
||
merged.Add(currentEffect.IsOverridden ? currentEffect : generatedEffect);
|
||
continue;
|
||
}
|
||
|
||
merged.Add(generatedEffect);
|
||
}
|
||
|
||
merged.AddRange(currentEffects.Where(effect =>
|
||
effect.IsOverridden &&
|
||
effect.OriginKey is not null &&
|
||
!matchedOrigins.Contains(effect.OriginKey)));
|
||
|
||
return merged;
|
||
}
|
||
|
||
private static string CreateBaseEffectOriginKey(int effectIndex, string effectCode) =>
|
||
$"base:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";
|
||
|
||
private static string CreateBranchOriginKey(int branchIndex, string branchKind, string? conditionKey, string conditionText) =>
|
||
$"branch:{NormalizeOriginSegment(branchKind)}:{NormalizeOriginSegment(conditionKey ?? conditionText)}:{branchIndex + 1}";
|
||
|
||
private static string CreateBranchEffectOriginKey(string branchOriginKey, int effectIndex, string effectCode) =>
|
||
$"{branchOriginKey}:effect:{NormalizeOriginSegment(effectCode)}:{effectIndex + 1}";
|
||
|
||
private static string NormalizeOriginSegment(string value)
|
||
{
|
||
var normalized = new string(value
|
||
.Trim()
|
||
.ToLowerInvariant()
|
||
.Select(character => char.IsLetterOrDigit(character) ? character : '_')
|
||
.ToArray())
|
||
.Trim('_');
|
||
|
||
return string.IsNullOrWhiteSpace(normalized) ? "empty" : normalized;
|
||
}
|
||
|
||
private static void ReplaceBaseEffects(
|
||
RolemasterDbContext dbContext,
|
||
CriticalResult result,
|
||
IReadOnlyList<CriticalEffectEditorItem>? effects)
|
||
{
|
||
dbContext.CriticalEffects.RemoveRange(result.Effects);
|
||
result.Effects.Clear();
|
||
|
||
foreach (var effect in effects ?? Array.Empty<CriticalEffectEditorItem>())
|
||
{
|
||
result.Effects.Add(CreateEffectEntity(effect));
|
||
}
|
||
}
|
||
|
||
private static void ReplaceBranches(
|
||
RolemasterDbContext dbContext,
|
||
CriticalResult result,
|
||
IReadOnlyList<CriticalBranchEditorItem>? branches)
|
||
{
|
||
foreach (var branch in result.Branches)
|
||
{
|
||
dbContext.CriticalEffects.RemoveRange(branch.Effects);
|
||
}
|
||
|
||
dbContext.CriticalBranches.RemoveRange(result.Branches);
|
||
result.Branches.Clear();
|
||
|
||
foreach (var branch in branches ?? Array.Empty<CriticalBranchEditorItem>())
|
||
{
|
||
var branchEntity = new CriticalBranch
|
||
{
|
||
BranchKind = branch.BranchKind.Trim(),
|
||
ConditionKey = NormalizeOptionalText(branch.ConditionKey),
|
||
ConditionText = branch.ConditionText.Trim(),
|
||
ConditionJson = string.IsNullOrWhiteSpace(branch.ConditionJson) ? "{}" : branch.ConditionJson.Trim(),
|
||
RawText = branch.RawText.Trim(),
|
||
DescriptionText = branch.DescriptionText.Trim(),
|
||
RawAffixText = NormalizeOptionalText(branch.RawAffixText),
|
||
ParsedJson = string.IsNullOrWhiteSpace(branch.ParsedJson) ? "{}" : branch.ParsedJson.Trim(),
|
||
SortOrder = branch.SortOrder
|
||
};
|
||
|
||
foreach (var effect in branch.Effects ?? Array.Empty<CriticalEffectEditorItem>())
|
||
{
|
||
branchEntity.Effects.Add(CreateEffectEntity(effect));
|
||
}
|
||
|
||
result.Branches.Add(branchEntity);
|
||
}
|
||
}
|
||
|
||
private static CriticalEffect CreateEffectEntity(CriticalEffectEditorItem effect) =>
|
||
new()
|
||
{
|
||
EffectCode = effect.EffectCode.Trim(),
|
||
Target = NormalizeOptionalText(effect.Target),
|
||
ValueInteger = effect.ValueInteger,
|
||
ValueDecimal = effect.ValueDecimal,
|
||
ValueExpression = NormalizeOptionalText(effect.ValueExpression),
|
||
DurationRounds = effect.DurationRounds,
|
||
PerRound = effect.PerRound,
|
||
Modifier = effect.Modifier,
|
||
BodyPart = NormalizeOptionalText(effect.BodyPart),
|
||
IsPermanent = effect.IsPermanent,
|
||
SourceType = string.IsNullOrWhiteSpace(effect.SourceType) ? "manual" : effect.SourceType.Trim(),
|
||
SourceText = NormalizeOptionalText(effect.SourceText)
|
||
};
|
||
|
||
private static CriticalEffectLookupResponse CreateEffectLookupResponse(SharedParsing.ParsedCriticalEffect effect) =>
|
||
new(
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText);
|
||
|
||
private static CriticalBranchLookupResponse CreateBranchLookupResponse(SharedParsing.ParsedCriticalBranch branch) =>
|
||
new(
|
||
branch.BranchKind,
|
||
branch.ConditionKey,
|
||
branch.ConditionText,
|
||
branch.DescriptionText,
|
||
branch.RawAffixText,
|
||
branch.Effects
|
||
.Select(CreateEffectLookupResponse)
|
||
.ToList(),
|
||
branch.RawText,
|
||
branch.SortOrder);
|
||
|
||
private static string ResolveParseStatus(
|
||
IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects,
|
||
IReadOnlyList<SharedParsing.ParsedCriticalBranch> branches) =>
|
||
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
|
||
? "partial"
|
||
: "raw";
|
||
|
||
private static string SerializeParsedEffects(IReadOnlyList<SharedParsing.ParsedCriticalEffect> effects) =>
|
||
effects.Count == 0
|
||
? "{}"
|
||
: JsonSerializer.Serialize(new
|
||
{
|
||
effects = effects.Select(effect => new
|
||
{
|
||
effect.EffectCode,
|
||
effect.Target,
|
||
effect.ValueInteger,
|
||
effect.ValueExpression,
|
||
effect.DurationRounds,
|
||
effect.PerRound,
|
||
effect.Modifier,
|
||
effect.BodyPart,
|
||
effect.IsPermanent,
|
||
effect.SourceType,
|
||
effect.SourceText
|
||
}).ToList()
|
||
});
|
||
|
||
private static async Task<SharedParsing.AffixLegend> BuildSharedAffixLegendAsync(
|
||
RolemasterDbContext dbContext,
|
||
int tableId,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var effectRows = await dbContext.CriticalEffects
|
||
.AsNoTracking()
|
||
.Where(item =>
|
||
(item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId) ||
|
||
(item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId))
|
||
.Select(item => new { item.EffectCode, item.SourceType, item.SourceText })
|
||
.ToListAsync(cancellationToken);
|
||
|
||
var symbolEffects = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
var supportsFoePenalty = false;
|
||
var supportsAttackerBonus = false;
|
||
var supportsPowerPointModifier = false;
|
||
|
||
foreach (var effectRow in effectRows)
|
||
{
|
||
supportsFoePenalty |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.FoePenalty, StringComparison.Ordinal);
|
||
supportsAttackerBonus |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.AttackerBonusNextRound, StringComparison.Ordinal);
|
||
supportsPowerPointModifier |= string.Equals(effectRow.EffectCode, CriticalEffectCodes.PowerPointModifier, StringComparison.Ordinal);
|
||
|
||
if (!string.Equals(effectRow.SourceType, "symbol", StringComparison.OrdinalIgnoreCase) ||
|
||
!IsLegendSymbolEffectCode(effectRow.EffectCode) ||
|
||
!TryExtractLegendSymbol(effectRow.SourceText, out var symbol))
|
||
{
|
||
continue;
|
||
}
|
||
|
||
symbolEffects.TryAdd(symbol, effectRow.EffectCode);
|
||
}
|
||
|
||
return new SharedParsing.AffixLegend(
|
||
symbolEffects,
|
||
supportsPowerPointModifier ? ["P"] : [],
|
||
supportsFoePenalty,
|
||
supportsAttackerBonus,
|
||
supportsPowerPointModifier);
|
||
}
|
||
|
||
private static async Task<SharedParsing.CriticalCellParseContent> ParseCriticalCellContentAsync(
|
||
RolemasterDbContext dbContext,
|
||
int tableId,
|
||
string quickParseInput,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
var affixLegend = await BuildSharedAffixLegendAsync(dbContext, tableId, cancellationToken);
|
||
return SharedParsing.CriticalQuickNotationParser.Parse(quickParseInput, affixLegend);
|
||
}
|
||
|
||
private static bool IsLegendSymbolEffectCode(string effectCode) =>
|
||
effectCode is CriticalEffectCodes.MustParryRounds
|
||
or CriticalEffectCodes.NoParryRounds
|
||
or CriticalEffectCodes.StunnedRounds
|
||
or CriticalEffectCodes.BleedPerRound;
|
||
|
||
private static bool TryExtractLegendSymbol(string? sourceText, out string symbol)
|
||
{
|
||
symbol = string.Empty;
|
||
if (string.IsNullOrWhiteSpace(sourceText))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var candidate = new string(sourceText
|
||
.Where(character =>
|
||
!char.IsWhiteSpace(character) &&
|
||
!char.IsDigit(character) &&
|
||
character is not ('+' or '-' or '–' or '(' or ')' or '/') &&
|
||
!char.IsLetter(character))
|
||
.ToArray());
|
||
|
||
if (string.IsNullOrWhiteSpace(candidate))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
var distinctSymbols = candidate
|
||
.Distinct()
|
||
.ToList();
|
||
|
||
if (distinctSymbols.Count != 1)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
symbol = distinctSymbols[0].ToString();
|
||
return true;
|
||
}
|
||
|
||
private static string? NormalizeOptionalText(string? value) =>
|
||
string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||
|
||
private static string NormalizeSlug(string value) =>
|
||
value.Trim().Replace(' ', '_').ToLowerInvariant();
|
||
|
||
private static string? CreateSourceImageUrl(CriticalResult result) =>
|
||
string.IsNullOrWhiteSpace(result.SourceImagePath)
|
||
? null
|
||
: $"/api/tables/critical/{result.CriticalTable.Slug}/cells/{result.Id}/source-image";
|
||
}
|