Implement phase 6 critical effect normalization
This commit is contained in:
@@ -65,7 +65,6 @@ The current implementation supports:
|
||||
The current implementation does not yet support:
|
||||
|
||||
- OCR/image-based PDFs such as `Void.pdf`
|
||||
- normalized `critical_effect` population
|
||||
- automatic confidence scoring beyond validation errors
|
||||
|
||||
## High-Level Architecture
|
||||
@@ -285,9 +284,16 @@ Phase-5 notes:
|
||||
|
||||
### Phase 6: Effect Normalization
|
||||
|
||||
- parse symbolic affix lines into normalized effects
|
||||
- populate `critical_effect`
|
||||
- gradually enrich prose-derived effects over time
|
||||
Phase 6 is complete for symbol-driven affixes.
|
||||
|
||||
Phase-6 notes:
|
||||
|
||||
- footer legends are parsed into table-specific affix metadata before effect normalization
|
||||
- symbolic affix lines are normalized into `critical_effect` rows for both base results and conditional branches
|
||||
- the normalized pass currently covers direct hits, must-parry, no-parry, stun, bleed, foe penalties, attacker bonuses, and `Mana` power-point modifiers
|
||||
- result and branch `parsed_json` payloads now store the normalized symbol effects
|
||||
- the web critical lookup now returns and renders parsed affix effects alongside the raw affix text
|
||||
- prose-derived effects remain future work
|
||||
|
||||
### Phase 7: OCR and Manual Fallback
|
||||
|
||||
@@ -501,11 +507,10 @@ The current implementation stores:
|
||||
- base `RawCellText`
|
||||
- base `DescriptionText`
|
||||
- base `RawAffixText`
|
||||
- parsed conditional branches with condition text, branch prose, and branch affix text
|
||||
- normalized base affix effects in `critical_effect`
|
||||
- parsed conditional branches with condition text, branch prose, branch affix text, and normalized branch affix effects
|
||||
- parsed conditional branches in debug artifacts and persisted SQLite rows
|
||||
|
||||
It does not yet normalize effects into separate tables.
|
||||
|
||||
## Validation Rules
|
||||
|
||||
The current validation pass is intentionally strict.
|
||||
@@ -548,6 +553,7 @@ The current load path:
|
||||
- `critical_roll_band`
|
||||
- `critical_result`
|
||||
- `critical_branch`
|
||||
- `critical_effect`
|
||||
5. commits only after the full table is saved
|
||||
|
||||
This means importer iterations can target one table without resetting unrelated database content.
|
||||
@@ -575,7 +581,11 @@ Important files in the current implementation:
|
||||
- `src/RolemasterDb.ImportTool/CriticalImportLoader.cs`
|
||||
- transactional SQLite load/reset behavior
|
||||
- `src/RolemasterDb.ImportTool/Parsing/CriticalCellTextParser.cs`
|
||||
- shared base-vs-branch parsing for cell content
|
||||
- shared base-vs-branch parsing for cell content and affix extraction
|
||||
- `src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs`
|
||||
- footer-legend-aware symbol effect normalization
|
||||
- `src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs`
|
||||
- parsed footer legend model used for affix classification and effect mapping
|
||||
- `src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs`
|
||||
- manifest loading
|
||||
- `src/RolemasterDb.ImportTool/PdfXmlExtractor.cs`
|
||||
@@ -589,13 +599,15 @@ Important files in the current implementation:
|
||||
- `src/RolemasterDb.ImportTool/Parsing/ParsedCriticalCellArtifact.cs`
|
||||
- debug cell artifact model
|
||||
- `src/RolemasterDb.ImportTool/Parsing/ParsedCriticalBranch.cs`
|
||||
- parsed branch artifact model
|
||||
- parsed branch artifact model with normalized effects
|
||||
- `src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs`
|
||||
- parsed effect artifact model
|
||||
- `src/RolemasterDb.ImportTool/Parsing/ImportValidationReport.cs`
|
||||
- validation output model
|
||||
- `src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs`
|
||||
- SQLite upgrade hook for branch-table rollout
|
||||
- SQLite upgrade hook for branch/effect-table rollout
|
||||
- `src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor`
|
||||
- web rendering of base results and conditional branches
|
||||
- web rendering of base results, conditional branches, and parsed affix effects
|
||||
|
||||
## Adding a New Table
|
||||
|
||||
|
||||
@@ -145,6 +145,7 @@ Recommended canonical `effect_code` values:
|
||||
- `bleed_per_round`
|
||||
- `foe_penalty`
|
||||
- `attacker_bonus_next_round`
|
||||
- `power_point_modifier`
|
||||
- `initiative_gain`
|
||||
- `initiative_loss`
|
||||
- `drop_item`
|
||||
@@ -169,6 +170,11 @@ Each effect should point to either:
|
||||
|
||||
This lets you keep the raw text but still filter/query on effects.
|
||||
|
||||
Current implementation note:
|
||||
|
||||
- symbol-driven affixes are now normalized for both base results and conditional branch affixes
|
||||
- `value_expression` is used when the affix contains a formula instead of a flat integer, which is currently needed for `Mana` power-point adjustments such as `+(2d10-18)P`
|
||||
|
||||
## Why this works for your lookup
|
||||
|
||||
Your lookup target is mostly:
|
||||
@@ -258,8 +264,8 @@ Current import flow:
|
||||
1. Create `critical_table`, `critical_group`, `critical_column`, and `critical_roll_band` from each PDF's visible axes.
|
||||
2. Store each base cell in `critical_result` with base raw/description/affix text.
|
||||
3. Split explicit conditional branches into `critical_branch`.
|
||||
4. Return the base result plus ordered branches through the web critical lookup.
|
||||
5. Parse symbolic affixes into `critical_effect` in the next phase.
|
||||
4. Parse symbolic affixes for both the base result and any branch affix payloads into `critical_effect`.
|
||||
5. Return the base result plus ordered branches and parsed affix effects through the web critical lookup.
|
||||
6. Gradually enrich prose-derived effects such as death, blindness, paralysis, limb loss, initiative changes, and item breakage.
|
||||
7. Route image PDFs like `Void.pdf` through OCR before the same parser.
|
||||
|
||||
|
||||
@@ -91,6 +91,7 @@ create table critical_effect (
|
||||
target text,
|
||||
value_integer integer,
|
||||
value_decimal numeric(10, 2),
|
||||
value_expression text,
|
||||
duration_rounds integer,
|
||||
per_round integer,
|
||||
modifier integer,
|
||||
@@ -139,4 +140,3 @@ create index critical_branch_parsed_json_gin
|
||||
-- and c.column_key = 'C'
|
||||
-- and 38 >= rb.min_roll
|
||||
-- and (rb.max_roll is null or 38 <= rb.max_roll);
|
||||
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
22
src/RolemasterDb.App/Domain/CriticalEffect.cs
Normal file
22
src/RolemasterDb.App/Domain/CriticalEffect.cs
Normal 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; }
|
||||
}
|
||||
13
src/RolemasterDb.App/Domain/CriticalEffectCodes.cs
Normal file
13
src/RolemasterDb.App/Domain/CriticalEffectCodes.cs
Normal 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";
|
||||
}
|
||||
@@ -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; } = [];
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
281
src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs
Normal file
281
src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs
Normal 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);
|
||||
}
|
||||
50
src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs
Normal file
50
src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
27
src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs
Normal file
27
src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user