Implement phase 6 critical effect normalization

This commit is contained in:
2026-03-14 11:31:13 +01:00
parent 35c250666f
commit 521f0ff8d5
29 changed files with 932 additions and 55 deletions

View File

@@ -49,6 +49,43 @@
<div class="callout">
<h4>Affix Text</h4>
<p class="stacked-copy">@Result.AffixText</p>
@if (Result.Effects.Count > 0)
{
<div class="effect-stack">
<h5>Parsed Affixes</h5>
<ul class="effect-list">
@foreach (var effect in Result.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
</div>
}
</div>
}
else if (Result.Effects.Count > 0)
{
<div class="callout">
<h4>Parsed Affixes</h4>
<ul class="effect-list">
@foreach (var effect in Result.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
</div>
}
@@ -71,6 +108,22 @@
{
<p class="stacked-copy branch-affix">@branch.AffixText</p>
}
@if (branch.Effects.Count > 0)
{
<ul class="effect-list branch-effects">
@foreach (var effect in branch.Effects)
{
<li class="effect-item">
@if (!string.IsNullOrWhiteSpace(effect.SourceText))
{
<code class="effect-token">@effect.SourceText</code>
}
<span>@FormatEffect(effect)</span>
</li>
}
</ul>
}
</section>
}
</div>
@@ -116,4 +169,21 @@
return value;
}
}
private static string FormatEffect(CriticalEffectLookupResponse effect) =>
effect.EffectCode switch
{
"direct_hits" when effect.ValueInteger is not null => $"{effect.ValueInteger} direct hits",
"must_parry_rounds" when effect.DurationRounds is not null => $"Must parry for {FormatRounds(effect.DurationRounds.Value)}",
"no_parry_rounds" when effect.DurationRounds is not null => $"No parry for {FormatRounds(effect.DurationRounds.Value)}",
"stunned_rounds" when effect.DurationRounds is not null => $"Stunned for {FormatRounds(effect.DurationRounds.Value)}",
"bleed_per_round" when effect.PerRound is not null => $"Bleeds {effect.PerRound} hits per round",
"foe_penalty" when effect.Modifier is not null => $"Foe penalty {effect.Modifier:+#;-#;0}",
"attacker_bonus_next_round" when effect.Modifier is not null => $"Attacker bonus next round {effect.Modifier:+#;-#;0}",
"power_point_modifier" when !string.IsNullOrWhiteSpace(effect.ValueExpression) => $"Foe power-point modifier {effect.ValueExpression}",
_ => effect.EffectCode.Replace('_', ' ')
};
private static string FormatRounds(int value) =>
value == 1 ? "1 round" : $"{value} rounds";
}

View File

@@ -15,6 +15,7 @@ public sealed class RolemasterDbContext(DbContextOptions<RolemasterDbContext> op
public DbSet<CriticalRollBand> CriticalRollBands => Set<CriticalRollBand>();
public DbSet<CriticalResult> CriticalResults => Set<CriticalResult>();
public DbSet<CriticalBranch> CriticalBranches => Set<CriticalBranch>();
public DbSet<CriticalEffect> CriticalEffects => Set<CriticalEffect>();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -87,5 +88,17 @@ public sealed class RolemasterDbContext(DbContextOptions<RolemasterDbContext> op
entity.Property(item => item.BranchKind).HasMaxLength(32);
entity.Property(item => item.ConditionKey).HasMaxLength(128);
});
modelBuilder.Entity<CriticalEffect>(entity =>
{
entity.HasIndex(item => item.EffectCode);
entity.HasIndex(item => item.CriticalResultId);
entity.HasIndex(item => item.CriticalBranchId);
entity.Property(item => item.EffectCode).HasMaxLength(64);
entity.Property(item => item.Target).HasMaxLength(32);
entity.Property(item => item.ValueExpression).HasMaxLength(128);
entity.Property(item => item.BodyPart).HasMaxLength(64);
entity.Property(item => item.SourceType).HasMaxLength(32);
});
}
}

View File

@@ -39,5 +39,52 @@ public static class RolemasterDbSchemaUpgrader
ON "CriticalBranches" ("CriticalResultId", "SortOrder");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "CriticalEffects" (
"Id" INTEGER NOT NULL CONSTRAINT "PK_CriticalEffects" PRIMARY KEY AUTOINCREMENT,
"CriticalResultId" INTEGER NULL,
"CriticalBranchId" INTEGER NULL,
"EffectCode" TEXT NOT NULL,
"Target" TEXT NULL,
"ValueInteger" INTEGER NULL,
"ValueDecimal" TEXT NULL,
"ValueExpression" TEXT NULL,
"DurationRounds" INTEGER NULL,
"PerRound" INTEGER NULL,
"Modifier" INTEGER NULL,
"BodyPart" TEXT NULL,
"IsPermanent" INTEGER NOT NULL,
"SourceType" TEXT NOT NULL,
"SourceText" TEXT NULL,
CONSTRAINT "FK_CriticalEffects_CriticalResults_CriticalResultId"
FOREIGN KEY ("CriticalResultId") REFERENCES "CriticalResults" ("Id") ON DELETE CASCADE,
CONSTRAINT "FK_CriticalEffects_CriticalBranches_CriticalBranchId"
FOREIGN KEY ("CriticalBranchId") REFERENCES "CriticalBranches" ("Id") ON DELETE CASCADE
);
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_EffectCode"
ON "CriticalEffects" ("EffectCode");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_CriticalResultId"
ON "CriticalEffects" ("CriticalResultId");
""",
cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE INDEX IF NOT EXISTS "IX_CriticalEffects_CriticalBranchId"
ON "CriticalEffects" ("CriticalBranchId");
""",
cancellationToken);
}
}

View File

@@ -14,4 +14,5 @@ public sealed class CriticalBranch
public string ParsedJson { get; set; } = "{}";
public int SortOrder { get; set; }
public CriticalResult CriticalResult { get; set; } = null!;
public List<CriticalEffect> Effects { get; set; } = [];
}

View File

@@ -0,0 +1,22 @@
namespace RolemasterDb.App.Domain;
public sealed class CriticalEffect
{
public int Id { get; set; }
public int? CriticalResultId { get; set; }
public int? CriticalBranchId { get; set; }
public string EffectCode { get; set; } = string.Empty;
public string? Target { get; set; }
public int? ValueInteger { get; set; }
public decimal? ValueDecimal { get; set; }
public string? ValueExpression { get; set; }
public int? DurationRounds { get; set; }
public int? PerRound { get; set; }
public int? Modifier { get; set; }
public string? BodyPart { get; set; }
public bool IsPermanent { get; set; }
public string SourceType { get; set; } = "symbol";
public string? SourceText { get; set; }
public CriticalResult? CriticalResult { get; set; }
public CriticalBranch? CriticalBranch { get; set; }
}

View File

@@ -0,0 +1,13 @@
namespace RolemasterDb.App.Domain;
public static class CriticalEffectCodes
{
public const string DirectHits = "direct_hits";
public const string MustParryRounds = "must_parry_rounds";
public const string NoParryRounds = "no_parry_rounds";
public const string StunnedRounds = "stunned_rounds";
public const string BleedPerRound = "bleed_per_round";
public const string FoePenalty = "foe_penalty";
public const string AttackerBonusNextRound = "attacker_bonus_next_round";
public const string PowerPointModifier = "power_point_modifier";
}

View File

@@ -17,4 +17,5 @@ public sealed class CriticalResult
public CriticalColumn CriticalColumn { get; set; } = null!;
public CriticalRollBand CriticalRollBand { get; set; } = null!;
public List<CriticalBranch> Branches { get; set; } = [];
public List<CriticalEffect> Effects { get; set; } = [];
}

View File

@@ -0,0 +1,14 @@
namespace RolemasterDb.App.Features;
public sealed record CriticalEffectLookupResponse(
string EffectCode,
string? Target,
int? ValueInteger,
string? ValueExpression,
int? DurationRounds,
int? PerRound,
int? Modifier,
string? BodyPart,
bool IsPermanent,
string SourceType,
string? SourceText);

View File

@@ -52,6 +52,7 @@ public sealed record CriticalBranchLookupResponse(
string ConditionText,
string Description,
string? AffixText,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
string RawText,
int SortOrder);
@@ -73,6 +74,7 @@ public sealed record CriticalLookupResponse(
string RawCellText,
string Description,
string? AffixText,
IReadOnlyList<CriticalEffectLookupResponse> Effects,
IReadOnlyList<CriticalBranchLookupResponse> Branches,
string ParseStatus,
string ParsedJson);

View File

@@ -132,6 +132,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
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(
@@ -140,6 +155,21 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
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(),

View File

@@ -255,6 +255,49 @@ textarea {
margin-bottom: 0;
}
.effect-stack {
margin-top: 0.85rem;
}
.effect-stack h5 {
margin: 0 0 0.5rem;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.08em;
color: #75562f;
}
.effect-list {
list-style: none;
padding: 0;
margin: 0.7rem 0 0;
display: grid;
gap: 0.45rem;
}
.effect-item {
display: flex;
gap: 0.65rem;
align-items: baseline;
flex-wrap: wrap;
padding: 0.55rem 0.7rem;
border-radius: 12px;
background: rgba(255, 252, 244, 0.72);
border: 1px solid rgba(127, 96, 55, 0.12);
}
.effect-token {
padding: 0.12rem 0.38rem;
border-radius: 999px;
background: rgba(238, 223, 193, 0.72);
color: #5b4327;
font-size: 0.82rem;
}
.branch-effects {
margin-top: 0.75rem;
}
.error-text {
color: #8d2b1e;
}

View File

@@ -1,6 +1,8 @@
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data;
using RolemasterDb.App.Domain;
using RolemasterDb.App.Features;
using RolemasterDb.ImportTool.Parsing;
namespace RolemasterDb.ImportTool.Tests;
@@ -303,7 +305,112 @@ public sealed class StandardCriticalTableParserIntegrationTests
}
[Fact]
public async Task Loader_upgrades_existing_sqlite_and_persists_branch_rows()
public async Task Slash_base_affixes_are_normalized_into_effects()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
var result = parseResult.Table.Results.Single(item =>
item.GroupKey is null &&
string.Equals(item.RollBandLabel, "51-55", StringComparison.Ordinal) &&
string.Equals(item.ColumnKey, "D", StringComparison.Ordinal));
Assert.Equal("+5H π 3∫ (-15)", result.RawAffixText);
Assert.Collection(
result.Effects,
effect =>
{
Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
Assert.Equal(5, effect.ValueInteger);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.MustParryRounds, effect.EffectCode);
Assert.Equal(1, effect.DurationRounds);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.BleedPerRound, effect.EffectCode);
Assert.Equal(3, effect.PerRound);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.FoePenalty, effect.EffectCode);
Assert.Equal(-15, effect.Modifier);
});
}
[Fact]
public async Task Slash_branch_affixes_are_normalized_into_effects()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
var result = parseResult.Table.Results.Single(item =>
item.GroupKey is null &&
string.Equals(item.RollBandLabel, "36-45", StringComparison.Ordinal) &&
string.Equals(item.ColumnKey, "B", StringComparison.Ordinal));
var withGreaves = result.Branches.Single(item => string.Equals(item.ConditionKey, "with_leg_greaves", StringComparison.Ordinal));
var withoutGreaves = result.Branches.Single(item => string.Equals(item.ConditionKey, "without_leg_greaves", StringComparison.Ordinal));
Assert.Collection(
withGreaves.Effects,
effect =>
{
Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
Assert.Equal(2, effect.ValueInteger);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.MustParryRounds, effect.EffectCode);
Assert.Equal(1, effect.DurationRounds);
});
Assert.Collection(
withoutGreaves.Effects,
effect =>
{
Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
Assert.Equal(2, effect.ValueInteger);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.BleedPerRound, effect.EffectCode);
Assert.Equal(1, effect.PerRound);
});
}
[Fact]
public async Task Mana_affixes_use_footer_legend_for_stun_and_powerpoint_modification()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
var result = parseResult.Table.Results.Single(item =>
item.GroupKey is null &&
string.Equals(item.RollBandLabel, "21-35", StringComparison.Ordinal) &&
string.Equals(item.ColumnKey, "C", StringComparison.Ordinal));
Assert.Equal("+8H -  - +(2d10-18)P", result.RawAffixText);
Assert.Collection(
result.Effects,
effect =>
{
Assert.Equal(CriticalEffectCodes.DirectHits, effect.EffectCode);
Assert.Equal(8, effect.ValueInteger);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.StunnedRounds, effect.EffectCode);
Assert.Equal(1, effect.DurationRounds);
},
effect =>
{
Assert.Equal(CriticalEffectCodes.PowerPointModifier, effect.EffectCode);
Assert.Equal("2d10-18", effect.ValueExpression);
});
}
[Fact]
public async Task Loader_upgrades_existing_sqlite_and_persists_branch_rows_and_effects()
{
var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
var parseResult = await LoadParseResultAsync(entry);
@@ -318,6 +425,7 @@ public sealed class StandardCriticalTableParserIntegrationTests
.Include(item => item.CriticalColumn)
.Include(item => item.CriticalRollBand)
.Include(item => item.Branches)
.ThenInclude(item => item.Effects)
.SingleAsync(item =>
item.CriticalTable.Slug == "slash" &&
item.CriticalColumn.ColumnKey == "B" &&
@@ -327,6 +435,34 @@ public sealed class StandardCriticalTableParserIntegrationTests
Assert.Equal(2, result.Branches.Count);
Assert.Contains(result.Branches, item => item.ConditionKey == "with_leg_greaves" && item.RawAffixText == "+2H π");
Assert.Contains(result.Branches, item => item.ConditionKey == "without_leg_greaves" && item.RawAffixText == "+2H ∫");
Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.MustParryRounds);
Assert.Contains(result.Branches.SelectMany(item => item.Effects), item => item.EffectCode == CriticalEffectCodes.BleedPerRound);
}
[Fact]
public async Task Lookup_service_returns_effects_for_results_and_branches()
{
var slashEntry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "slash", StringComparison.Ordinal));
var manaEntry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "mana", StringComparison.Ordinal));
var slashParseResult = await LoadParseResultAsync(slashEntry);
var manaParseResult = await LoadParseResultAsync(manaEntry);
var databasePath = CreateTemporaryDatabaseCopy();
var loader = new CriticalImportLoader(databasePath);
await loader.LoadAsync(slashParseResult.Table);
await loader.LoadAsync(manaParseResult.Table);
var factory = CreateDbContextFactory(databasePath);
var lookupService = new LookupService(factory);
var slashResponse = await lookupService.LookupCriticalAsync(new CriticalLookupRequest("slash", "B", 40, null));
var manaResponse = await lookupService.LookupCriticalAsync(new CriticalLookupRequest("mana", "C", 30, null));
Assert.NotNull(slashResponse);
Assert.NotNull(manaResponse);
Assert.Contains(slashResponse!.Branches, branch => branch.ConditionKey == "with_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.MustParryRounds));
Assert.Contains(slashResponse.Branches, branch => branch.ConditionKey == "without_leg_greaves" && branch.Effects.Any(effect => effect.EffectCode == CriticalEffectCodes.BleedPerRound));
Assert.Contains(manaResponse!.Effects, effect => effect.EffectCode == CriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-18");
}
private static async Task<CriticalTableParseResult> LoadParseResultAsync(CriticalImportManifestEntry entry)
@@ -367,6 +503,15 @@ public sealed class StandardCriticalTableParserIntegrationTests
return new RolemasterDbContext(options);
}
private static IDbContextFactory<RolemasterDbContext> CreateDbContextFactory(string databasePath)
{
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new TestRolemasterDbContextFactory(options);
}
private static string CreateTemporaryDatabaseCopy()
{
var databasePath = Path.Combine(GetArtifactCacheRoot(), $"rolemaster-{Guid.NewGuid():N}.db");

View File

@@ -0,0 +1,13 @@
using Microsoft.EntityFrameworkCore;
using RolemasterDb.App.Data;
namespace RolemasterDb.ImportTool.Tests;
internal sealed class TestRolemasterDbContextFactory(DbContextOptions<RolemasterDbContext> options) : IDbContextFactory<RolemasterDbContext>
{
public RolemasterDbContext CreateDbContext() => new(options);
public Task<RolemasterDbContext> CreateDbContextAsync(CancellationToken cancellationToken = default) =>
Task.FromResult(CreateDbContext());
}

View File

@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using System.Text.Json;
using RolemasterDb.App.Data;
using RolemasterDb.App.Domain;
@@ -8,6 +9,11 @@ namespace RolemasterDb.ImportTool;
public sealed class CriticalImportLoader(string databasePath)
{
private static readonly JsonSerializerOptions JsonOptions = new()
{
WriteIndented = true
};
public async Task<int> ResetCriticalsAsync(CancellationToken cancellationToken = default)
{
await using var dbContext = CreateDbContext();
@@ -17,6 +23,7 @@ public sealed class CriticalImportLoader(string databasePath)
var removedTableCount = await dbContext.CriticalTables.CountAsync(cancellationToken);
await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
await dbContext.CriticalEffects.ExecuteDeleteAsync(cancellationToken);
await dbContext.CriticalBranches.ExecuteDeleteAsync(cancellationToken);
await dbContext.CriticalResults.ExecuteDeleteAsync(cancellationToken);
await dbContext.CriticalGroups.ExecuteDeleteAsync(cancellationToken);
@@ -88,8 +95,11 @@ public sealed class CriticalImportLoader(string databasePath)
RawCellText = item.RawCellText,
DescriptionText = item.DescriptionText,
RawAffixText = item.RawAffixText,
ParsedJson = "{}",
ParseStatus = "raw",
ParsedJson = SerializeParsedEffects(item.Effects),
ParseStatus = ResolveParseStatus(item.Effects, item.Branches),
Effects = item.Effects
.Select(CreateEffectEntity)
.ToList(),
Branches = item.Branches
.Select(branch => new CriticalBranch
{
@@ -100,8 +110,11 @@ public sealed class CriticalImportLoader(string databasePath)
RawText = branch.RawText,
DescriptionText = branch.DescriptionText,
RawAffixText = branch.RawAffixText,
ParsedJson = "{}",
SortOrder = branch.SortOrder
ParsedJson = SerializeParsedEffects(branch.Effects),
SortOrder = branch.SortOrder,
Effects = branch.Effects
.Select(CreateEffectEntity)
.ToList()
})
.ToList()
})
@@ -138,6 +151,14 @@ public sealed class CriticalImportLoader(string databasePath)
return;
}
await dbContext.CriticalEffects
.Where(item => item.CriticalBranch != null && item.CriticalBranch.CriticalResult.CriticalTableId == tableId.Value)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.CriticalEffects
.Where(item => item.CriticalResult != null && item.CriticalResult.CriticalTableId == tableId.Value)
.ExecuteDeleteAsync(cancellationToken);
await dbContext.CriticalBranches
.Where(item => item.CriticalResult.CriticalTableId == tableId.Value)
.ExecuteDeleteAsync(cancellationToken);
@@ -162,4 +183,32 @@ public sealed class CriticalImportLoader(string databasePath)
.Where(item => item.Id == tableId.Value)
.ExecuteDeleteAsync(cancellationToken);
}
private static CriticalEffect CreateEffectEntity(ParsedCriticalEffect effect) =>
new()
{
EffectCode = effect.EffectCode,
Target = effect.Target,
ValueInteger = effect.ValueInteger,
ValueExpression = effect.ValueExpression,
DurationRounds = effect.DurationRounds,
PerRound = effect.PerRound,
Modifier = effect.Modifier,
BodyPart = effect.BodyPart,
IsPermanent = effect.IsPermanent,
SourceType = effect.SourceType,
SourceText = effect.SourceText
};
private static string SerializeParsedEffects(IReadOnlyList<ParsedCriticalEffect> effects) =>
effects.Count == 0
? "{}"
: JsonSerializer.Serialize(new { effects }, JsonOptions);
private static string ResolveParseStatus(
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches) =>
effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
? "partial"
: "raw";
}

View File

@@ -0,0 +1,281 @@
using System.Text.RegularExpressions;
using RolemasterDb.App.Domain;
namespace RolemasterDb.ImportTool.Parsing;
internal static class AffixEffectParser
{
private const string FoeTarget = "foe";
private static readonly Regex DirectHitsRegex = new(@"[+-]\s*\d+\s*H\b", RegexOptions.Compiled);
private static readonly Regex PowerPointModifierRegex = new(@"\+\s*\((?<expression>[^)]+)\)\s*P\b", RegexOptions.Compiled);
private static readonly Regex ModifierRegex = new(@"\((?<noise>[^0-9+\-)]*)(?<sign>[+-])\s*(?<value>\d+)\)", RegexOptions.Compiled);
internal static IReadOnlyList<ParsedCriticalEffect> Parse(string? rawAffixText, AffixLegend affixLegend)
{
if (string.IsNullOrWhiteSpace(rawAffixText))
{
return [];
}
var effects = new List<ParsedCriticalEffect>();
foreach (var rawLine in rawAffixText.Split(Environment.NewLine, StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
{
ParseLine(CriticalTableParserSupport.CollapseWhitespace(rawLine), affixLegend, effects);
}
return effects;
}
private static void ParseLine(string line, AffixLegend affixLegend, List<ParsedCriticalEffect> effects)
{
if (string.IsNullOrWhiteSpace(line) || line is "-" or "" or "—")
{
return;
}
var consumedRanges = new List<(int Start, int End)>();
var matchedEffects = new List<(int Index, ParsedCriticalEffect Effect)>();
AddMatches(
DirectHitsRegex.Matches(line),
matchedEffects,
consumedRanges,
match =>
{
var hits = ParseSignedInteger(match.Value);
return new ParsedCriticalEffect(
CriticalEffectCodes.DirectHits,
FoeTarget,
hits,
null,
null,
null,
null,
null,
false,
"symbol",
NormalizeToken(match.Value));
});
if (affixLegend.SupportsPowerPointModifier)
{
AddMatches(
PowerPointModifierRegex.Matches(line),
matchedEffects,
consumedRanges,
match => new ParsedCriticalEffect(
CriticalEffectCodes.PowerPointModifier,
FoeTarget,
null,
CriticalTableParserSupport.CollapseWhitespace(match.Groups["expression"].Value),
null,
null,
null,
null,
false,
"symbol",
NormalizeToken(match.Value)));
}
AddMatches(
ModifierRegex.Matches(line),
matchedEffects,
consumedRanges,
match =>
{
var modifier = BuildModifier(match);
if (modifier is null)
{
return null;
}
if (modifier.Value < 0 && affixLegend.SupportsFoePenalty)
{
return new ParsedCriticalEffect(
CriticalEffectCodes.FoePenalty,
FoeTarget,
null,
null,
null,
null,
modifier.Value,
null,
false,
"symbol",
NormalizeToken(match.Value));
}
if (modifier.Value > 0 && affixLegend.SupportsAttackerBonus)
{
return new ParsedCriticalEffect(
CriticalEffectCodes.AttackerBonusNextRound,
null,
null,
null,
null,
null,
modifier.Value,
null,
false,
"symbol",
NormalizeToken(match.Value));
}
return null;
});
var symbolClusterRegex = CreateSymbolClusterRegex(affixLegend.EffectSymbols);
if (symbolClusterRegex is null)
{
return;
}
foreach (Match match in symbolClusterRegex.Matches(line))
{
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
{
continue;
}
var magnitude = match.Groups["count"].Success
? int.Parse(match.Groups["count"].Value)
: 1;
var matchedText = NormalizeToken(match.Value);
foreach (var symbol in match.Groups["symbols"].Value.Select(character => character.ToString()))
{
var effectCode = affixLegend.ResolveEffectCode(symbol);
if (effectCode is null)
{
continue;
}
matchedEffects.Add((match.Index, CreateSymbolEffect(effectCode, magnitude, matchedText)));
}
}
if (matchedEffects.Count > 0)
{
effects.AddRange(matchedEffects
.OrderBy(item => item.Index)
.Select(item => item.Effect));
}
}
private static ParsedCriticalEffect CreateSymbolEffect(string effectCode, int magnitude, string sourceText) =>
effectCode switch
{
CriticalEffectCodes.MustParryRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.NoParryRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.StunnedRounds => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
magnitude,
null,
null,
null,
false,
"symbol",
sourceText),
CriticalEffectCodes.BleedPerRound => new ParsedCriticalEffect(
effectCode,
FoeTarget,
null,
null,
null,
magnitude,
null,
null,
false,
"symbol",
sourceText),
_ => throw new InvalidOperationException($"Unsupported symbol effect code '{effectCode}'.")
};
private static Regex? CreateSymbolClusterRegex(IReadOnlySet<string> symbols)
{
if (symbols.Count == 0)
{
return null;
}
var escapedSymbols = string.Concat(symbols.Select(Regex.Escape));
return new Regex(
$@"(?<![A-Za-z0-9])(?:(?<count>\d+)\s*)?(?<symbols>[{escapedSymbols}]+)",
RegexOptions.Compiled);
}
private static void AddMatches(
MatchCollection matches,
List<(int Index, ParsedCriticalEffect Effect)> matchedEffects,
List<(int Start, int End)> consumedRanges,
Func<Match, ParsedCriticalEffect?> createEffect)
{
foreach (Match match in matches)
{
if (!match.Success || OverlapsConsumedRange(match, consumedRanges))
{
continue;
}
consumedRanges.Add((match.Index, match.Index + match.Length));
var effect = createEffect(match);
if (effect is not null)
{
matchedEffects.Add((match.Index, effect));
}
}
}
private static bool OverlapsConsumedRange(Match match, IReadOnlyList<(int Start, int End)> consumedRanges) =>
consumedRanges.Any(range => match.Index < range.End && range.Start < match.Index + match.Length);
private static int ParseSignedInteger(string value) =>
int.Parse(value.Replace(" ", string.Empty, StringComparison.Ordinal).Replace("H", string.Empty, StringComparison.OrdinalIgnoreCase));
private static int? BuildModifier(Match match)
{
if (!int.TryParse(match.Groups["value"].Value, out var absoluteValue))
{
return null;
}
return string.Equals(match.Groups["sign"].Value, "-", StringComparison.Ordinal)
? -absoluteValue
: absoluteValue;
}
private static string NormalizeToken(string value) =>
CriticalTableParserSupport.CollapseWhitespace(value)
.Replace(" +", "+", StringComparison.Ordinal)
.Replace("( ", "(", StringComparison.Ordinal)
.Replace(" )", ")", StringComparison.Ordinal);
}

View File

@@ -0,0 +1,50 @@
namespace RolemasterDb.ImportTool.Parsing;
internal sealed class AffixLegend
{
public static AffixLegend Empty { get; } = new(
new Dictionary<string, string>(StringComparer.Ordinal),
[],
supportsFoePenalty: false,
supportsAttackerBonus: false,
supportsPowerPointModifier: false);
public AffixLegend(
IReadOnlyDictionary<string, string> symbolEffects,
IReadOnlyCollection<string> classificationOnlySymbols,
bool supportsFoePenalty,
bool supportsAttackerBonus,
bool supportsPowerPointModifier)
{
SymbolEffects = new Dictionary<string, string>(symbolEffects, StringComparer.Ordinal);
EffectSymbols = new HashSet<string>(SymbolEffects.Keys, StringComparer.Ordinal);
var classificationSymbols = new HashSet<string>(EffectSymbols, StringComparer.Ordinal);
foreach (var symbol in classificationOnlySymbols)
{
classificationSymbols.Add(symbol);
}
ClassificationSymbols = classificationSymbols;
SupportsFoePenalty = supportsFoePenalty;
SupportsAttackerBonus = supportsAttackerBonus;
SupportsPowerPointModifier = supportsPowerPointModifier;
}
public IReadOnlyDictionary<string, string> SymbolEffects { get; }
public IReadOnlySet<string> EffectSymbols { get; }
public IReadOnlySet<string> ClassificationSymbols { get; }
public bool SupportsFoePenalty { get; }
public bool SupportsAttackerBonus { get; }
public bool SupportsPowerPointModifier { get; }
public string? ResolveEffectCode(string symbol) =>
SymbolEffects.TryGetValue(symbol, out var effectCode)
? effectCode
: null;
}

View File

@@ -5,6 +5,7 @@ internal sealed class CriticalCellParseContent(
string rawCellText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches,
IReadOnlyList<string> validationErrors)
{
@@ -12,6 +13,7 @@ internal sealed class CriticalCellParseContent(
public string RawCellText { get; } = rawCellText;
public string DescriptionText { get; } = descriptionText;
public string? RawAffixText { get; } = rawAffixText;
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
public IReadOnlyList<string> ValidationErrors { get; } = validationErrors;
}

View File

@@ -2,13 +2,14 @@ namespace RolemasterDb.ImportTool.Parsing;
internal static class CriticalCellTextParser
{
internal static CriticalCellParseContent Parse(IReadOnlyList<string> lines, ISet<string> affixLegendSymbols)
internal static CriticalCellParseContent Parse(IReadOnlyList<string> lines, AffixLegend affixLegend)
{
var validationErrors = new List<string>();
var branchStartIndexes = FindBranchStartIndexes(lines);
var baseLineCount = branchStartIndexes.Count == 0 ? lines.Count : branchStartIndexes[0];
var baseLines = lines.Take(baseLineCount).ToList();
var branches = new List<ParsedCriticalBranch>();
var affixLegendSymbols = affixLegend.ClassificationSymbols;
validationErrors.AddRange(ValidateSegmentCount(baseLines, affixLegendSymbols, "Base content"));
@@ -22,18 +23,19 @@ internal static class CriticalCellTextParser
branches.Add(ParseBranch(
lines.Skip(startIndex).Take(endIndex - startIndex).ToList(),
branchIndex + 1,
affixLegendSymbols,
affixLegend,
validationErrors));
}
var (rawCellText, descriptionText, rawAffixText) = BuildTextSections(baseLines, affixLegendSymbols);
return new CriticalCellParseContent(baseLines, rawCellText, descriptionText, rawAffixText, branches, validationErrors);
var effects = AffixEffectParser.Parse(rawAffixText, affixLegend);
return new CriticalCellParseContent(baseLines, rawCellText, descriptionText, rawAffixText, effects, branches, validationErrors);
}
private static ParsedCriticalBranch ParseBranch(
IReadOnlyList<string> branchLines,
int sortOrder,
ISet<string> affixLegendSymbols,
AffixLegend affixLegend,
List<string> validationErrors)
{
var firstLine = branchLines[0];
@@ -56,9 +58,11 @@ internal static class CriticalCellTextParser
}
}
var affixLegendSymbols = affixLegend.ClassificationSymbols;
validationErrors.AddRange(ValidateSegmentCount(payloadLines, affixLegendSymbols, $"Branch '{conditionText}'"));
var (_, descriptionText, rawAffixText) = BuildTextSections(payloadLines, affixLegendSymbols);
var effects = AffixEffectParser.Parse(rawAffixText, affixLegend);
return new ParsedCriticalBranch(
"conditional",
CriticalTableParserSupport.NormalizeConditionKey(conditionText),
@@ -66,6 +70,7 @@ internal static class CriticalCellTextParser
string.Join(Environment.NewLine, branchLines),
descriptionText,
rawAffixText,
effects,
sortOrder);
}
@@ -86,7 +91,7 @@ internal static class CriticalCellTextParser
private static IReadOnlyList<string> ValidateSegmentCount(
IReadOnlyList<string> lines,
ISet<string> affixLegendSymbols,
IReadOnlySet<string> affixLegendSymbols,
string scope)
{
if (lines.Count == 0)
@@ -102,7 +107,7 @@ internal static class CriticalCellTextParser
private static (string RawText, string DescriptionText, string? RawAffixText) BuildTextSections(
IReadOnlyList<string> lines,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
var rawText = string.Join(Environment.NewLine, lines);
var rawAffixLines = lines.Where(line => CriticalTableParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList();

View File

@@ -2,6 +2,8 @@ using System.Text.RegularExpressions;
using System.Xml;
using System.Xml.Linq;
using RolemasterDb.App.Domain;
namespace RolemasterDb.ImportTool.Parsing;
internal static class CriticalTableParserSupport
@@ -156,7 +158,7 @@ internal static class CriticalTableParserSupport
.ToList();
}
internal static bool IsAffixLikeLine(string line, ISet<string> affixLegendSymbols)
internal static bool IsAffixLikeLine(string line, IReadOnlySet<string> affixLegendSymbols)
{
var value = line.Trim();
if (value.Length == 0)
@@ -213,7 +215,7 @@ internal static class CriticalTableParserSupport
value.Contains(" ", StringComparison.Ordinal);
}
internal static int CountLineTypeSegments(IReadOnlyList<string> lines, ISet<string> affixLegendSymbols)
internal static int CountLineTypeSegments(IReadOnlyList<string> lines, IReadOnlySet<string> affixLegendSymbols)
{
var segmentCount = 0;
bool? previousIsAffix = null;
@@ -280,11 +282,11 @@ internal static class CriticalTableParserSupport
.Select(item => (int?)item.Top)
.Min() ?? int.MaxValue;
internal static HashSet<string> DetectAffixLegendSymbols(IReadOnlyList<XmlTextFragment> fragments, int keyTop)
internal static AffixLegend ParseAffixLegend(IReadOnlyList<XmlTextFragment> fragments, int keyTop)
{
if (keyTop == int.MaxValue)
{
return [];
return AffixLegend.Empty;
}
var footerLines = GroupByTop(fragments
@@ -295,24 +297,34 @@ internal static class CriticalTableParserSupport
.Select(line => CollapseWhitespace(string.Join(' ', line.OrderBy(item => item.Left).Select(item => item.Text))))
.ToList();
var symbols = new HashSet<string>(StringComparer.Ordinal);
var footerText = string.Join(' ', footerLines);
var symbolEffects = new Dictionary<string, string>(StringComparer.Ordinal);
foreach (var footerLine in footerLines)
{
AddLegendMatch(symbols, footerLine, @"must parry\s*=\s*(\S)");
AddLegendMatch(symbols, footerLine, @"no parry\s*=\s*(\S)");
AddLegendMatch(symbols, footerLine, @"stun(?:ned)?\s*=\s*(\S)");
AddLegendMatch(symbols, footerLine, @"bleed\s*=\s*(\S)");
AddLegendMatch(symbols, footerLine, @"powerpoint modification.*=\s*(\S)");
}
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.MustParryRounds, @"must parry\s*=\s*(\S)");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.MustParryRounds, @"(\S)\s*=\s*must parry");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.NoParryRounds, @"no parry\s*=\s*(\S)");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.NoParryRounds, @"(\S)\s*=\s*no parry");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.StunnedRounds, @"stun(?:ned)?\s*=\s*(\S)");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.StunnedRounds, @"(\S)\s*=\s*stun(?:ned)?");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.BleedPerRound, @"bleed\s*=\s*(\S)");
AddLegendMatch(symbolEffects, footerText, CriticalEffectCodes.BleedPerRound, @"(\S)\s*=\s*bleed");
return symbols;
return new AffixLegend(
symbolEffects,
footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase)
? ["P"]
: [],
supportsFoePenalty: footerText.Contains("foe has", StringComparison.OrdinalIgnoreCase) &&
footerText.Contains("penalty", StringComparison.OrdinalIgnoreCase),
supportsAttackerBonus: footerText.Contains("attacker gets", StringComparison.OrdinalIgnoreCase) &&
footerText.Contains("next round", StringComparison.OrdinalIgnoreCase),
supportsPowerPointModifier: footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase));
}
internal static List<XmlTextFragment> SplitBoundaryCrossingAffixFragments(
IReadOnlyList<XmlTextFragment> bodyFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
var splitFragments = new List<XmlTextFragment>(bodyFragments.Count);
@@ -327,7 +339,7 @@ internal static class CriticalTableParserSupport
internal static List<(int Top, bool IsAffixLike)> BuildBodyLines(
IReadOnlyList<XmlTextFragment> bodyFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
var bodyLines = new List<(int Top, bool IsAffixLike)>();
@@ -391,7 +403,7 @@ internal static class CriticalTableParserSupport
IReadOnlyList<RowAnchor> rowAnchors,
IReadOnlyCollection<XmlTextFragment> excludedFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
var bodyFragments = fragments
.Where(item =>
@@ -406,7 +418,7 @@ internal static class CriticalTableParserSupport
return SplitBoundaryCrossingAffixFragments(bodyFragments, columnCenters, affixLegendSymbols);
}
internal static void RepairLeadingAffixLeakage(List<ColumnarCellEntry> cellEntries, ISet<string> affixLegendSymbols)
internal static void RepairLeadingAffixLeakage(List<ColumnarCellEntry> cellEntries, IReadOnlySet<string> affixLegendSymbols)
{
var maxRowIndex = cellEntries.Count == 0 ? -1 : cellEntries.Max(item => item.RowIndex);
var axes = cellEntries
@@ -471,14 +483,14 @@ internal static class CriticalTableParserSupport
internal static void BuildParsedArtifacts(
IReadOnlyList<ColumnarCellEntry> cellEntries,
ISet<string> affixLegendSymbols,
AffixLegend affixLegend,
List<ParsedCriticalCellArtifact> parsedCells,
List<ParsedCriticalResult> parsedResults,
List<string> validationErrors)
{
foreach (var cellEntry in cellEntries)
{
var content = CriticalCellTextParser.Parse(cellEntry.Lines, affixLegendSymbols);
var content = CriticalCellTextParser.Parse(cellEntry.Lines, affixLegend);
validationErrors.AddRange(content.ValidationErrors.Select(error =>
$"Cell '{BuildCellIdentifier(cellEntry)}': {error}"));
@@ -491,6 +503,7 @@ internal static class CriticalTableParserSupport
content.RawCellText,
content.DescriptionText,
content.RawAffixText,
content.Effects,
content.Branches));
parsedResults.Add(new ParsedCriticalResult(
@@ -500,6 +513,7 @@ internal static class CriticalTableParserSupport
content.RawCellText,
content.DescriptionText,
content.RawAffixText,
content.Effects,
content.Branches));
}
}
@@ -549,7 +563,7 @@ internal static class CriticalTableParserSupport
private static IReadOnlyList<XmlTextFragment> SplitBoundaryCrossingAffixFragment(
XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
if (!LooksLikeBoundaryCrossingAffixFragment(fragment, columnCenters, affixLegendSymbols))
{
@@ -604,7 +618,7 @@ internal static class CriticalTableParserSupport
private static bool LooksLikeBoundaryCrossingAffixFragment(
XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
ISet<string> affixLegendSymbols)
IReadOnlySet<string> affixLegendSymbols)
{
if (!IsAffixLikeLine(fragment.Text, affixLegendSymbols) ||
!fragment.Text.Contains(" ", StringComparison.Ordinal))
@@ -626,13 +640,21 @@ internal static class CriticalTableParserSupport
return false;
}
private static void AddLegendMatch(HashSet<string> symbols, string value, string pattern)
private static void AddLegendMatch(
IDictionary<string, string> symbolEffects,
string value,
string effectCode,
string pattern)
{
foreach (Match match in Regex.Matches(value, pattern, RegexOptions.IgnoreCase))
{
if (match.Groups.Count > 1)
{
symbols.Add(match.Groups[1].Value);
var symbol = match.Groups[1].Value.Trim();
if (symbol.Length == 1)
{
symbolEffects[symbol] = effectCode;
}
}
}
}

View File

@@ -37,7 +37,8 @@ public sealed class GroupedVariantCriticalTableParser
columnHeaders.Max(item => item.Top))
+ CriticalTableParserSupport.HeaderToBodyMinimumGap;
var keyTop = CriticalTableParserSupport.FindKeyTop(fragments);
var affixLegendSymbols = CriticalTableParserSupport.DetectAffixLegendSymbols(fragments, keyTop);
var affixLegend = CriticalTableParserSupport.ParseAffixLegend(fragments, keyTop);
var affixLegendSymbols = affixLegend.ClassificationSymbols;
var leftCutoff = columnHeaders.Min(item => item.Left) - 10;
var rowLabelFragments = CriticalTableParserSupport.FindRowLabelFragments(
fragments,
@@ -114,7 +115,7 @@ public sealed class GroupedVariantCriticalTableParser
var parsedCells = new List<ParsedCriticalCellArtifact>();
var parsedResults = new List<ParsedCriticalResult>();
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegendSymbols, parsedCells, parsedResults, validationErrors);
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors);
var expectedCellCount = rowAnchors.Count * ExpectedGroups.Length * ExpectedColumns.Length;
if (parsedCells.Count != expectedCellCount)

View File

@@ -7,6 +7,7 @@ public sealed class ParsedCriticalBranch(
string rawText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects,
int sortOrder)
{
public string BranchKind { get; } = branchKind;
@@ -15,5 +16,6 @@ public sealed class ParsedCriticalBranch(
public string RawText { get; } = rawText;
public string DescriptionText { get; } = descriptionText;
public string? RawAffixText { get; } = rawAffixText;
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public int SortOrder { get; } = sortOrder;
}

View File

@@ -9,6 +9,7 @@ public sealed class ParsedCriticalCellArtifact(
string rawCellText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches)
{
public string? GroupKey { get; } = groupKey;
@@ -19,5 +20,6 @@ public sealed class ParsedCriticalCellArtifact(
public string RawCellText { get; } = rawCellText;
public string DescriptionText { get; } = descriptionText;
public string? RawAffixText { get; } = rawAffixText;
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
}

View File

@@ -0,0 +1,27 @@
namespace RolemasterDb.ImportTool.Parsing;
public sealed class ParsedCriticalEffect(
string effectCode,
string? target,
int? valueInteger,
string? valueExpression,
int? durationRounds,
int? perRound,
int? modifier,
string? bodyPart,
bool isPermanent,
string sourceType,
string sourceText)
{
public string EffectCode { get; } = effectCode;
public string? Target { get; } = target;
public int? ValueInteger { get; } = valueInteger;
public string? ValueExpression { get; } = valueExpression;
public int? DurationRounds { get; } = durationRounds;
public int? PerRound { get; } = perRound;
public int? Modifier { get; } = modifier;
public string? BodyPart { get; } = bodyPart;
public bool IsPermanent { get; } = isPermanent;
public string SourceType { get; } = sourceType;
public string SourceText { get; } = sourceText;
}

View File

@@ -7,6 +7,7 @@ public sealed class ParsedCriticalResult(
string rawCellText,
string descriptionText,
string? rawAffixText,
IReadOnlyList<ParsedCriticalEffect> effects,
IReadOnlyList<ParsedCriticalBranch> branches)
{
public string? GroupKey { get; } = groupKey;
@@ -15,5 +16,6 @@ public sealed class ParsedCriticalResult(
public string RawCellText { get; } = rawCellText;
public string DescriptionText { get; } = descriptionText;
public string? RawAffixText { get; } = rawAffixText;
public IReadOnlyList<ParsedCriticalEffect> Effects { get; } = effects;
public IReadOnlyList<ParsedCriticalBranch> Branches { get; } = branches;
}

View File

@@ -16,7 +16,8 @@ public sealed class StandardCriticalTableParser
var bodyStartTop = headerFragments.Max(item => item.Top) + CriticalTableParserSupport.HeaderToBodyMinimumGap;
var keyTop = CriticalTableParserSupport.FindKeyTop(fragments);
var affixLegendSymbols = CriticalTableParserSupport.DetectAffixLegendSymbols(fragments, keyTop);
var affixLegend = CriticalTableParserSupport.ParseAffixLegend(fragments, keyTop);
var affixLegendSymbols = affixLegend.ClassificationSymbols;
var leftCutoff = headerFragments.Min(item => item.Left) - 10;
var rowLabelFragments = CriticalTableParserSupport.FindRowLabelFragments(
fragments,
@@ -88,7 +89,7 @@ public sealed class StandardCriticalTableParser
var parsedCells = new List<ParsedCriticalCellArtifact>();
var parsedResults = new List<ParsedCriticalResult>();
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegendSymbols, parsedCells, parsedResults, validationErrors);
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors);
if (columnCenters.Count != 5)
{

View File

@@ -29,7 +29,8 @@ public sealed class VariantColumnCriticalTableParser
var bodyStartTop = headerFragments.Max(item => item.Top) + CriticalTableParserSupport.HeaderToBodyMinimumGap;
var keyTop = CriticalTableParserSupport.FindKeyTop(fragments);
var affixLegendSymbols = CriticalTableParserSupport.DetectAffixLegendSymbols(fragments, keyTop);
var affixLegend = CriticalTableParserSupport.ParseAffixLegend(fragments, keyTop);
var affixLegendSymbols = affixLegend.ClassificationSymbols;
var leftCutoff = headerFragments.Min(item => item.Left) - 10;
var rowLabelFragments = CriticalTableParserSupport.FindRowLabelFragments(
fragments,
@@ -105,7 +106,7 @@ public sealed class VariantColumnCriticalTableParser
var parsedCells = new List<ParsedCriticalCellArtifact>();
var parsedResults = new List<ParsedCriticalResult>();
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegendSymbols, parsedCells, parsedResults, validationErrors);
CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors);
if (columnAnchors.Count != ExpectedColumns.Length)
{