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

@@ -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

View File

@@ -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.

View File

@@ -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);

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)
{