diff --git a/docs/critical_import_tool.md b/docs/critical_import_tool.md
index 2ba0b44..3971e3e 100644
--- a/docs/critical_import_tool.md
+++ b/docs/critical_import_tool.md
@@ -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
diff --git a/docs/critical_tables_db_model.md b/docs/critical_tables_db_model.md
index adcdad7..c217833 100644
--- a/docs/critical_tables_db_model.md
+++ b/docs/critical_tables_db_model.md
@@ -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.
diff --git a/docs/critical_tables_schema.sql b/docs/critical_tables_schema.sql
index 82329ad..fae90ab 100644
--- a/docs/critical_tables_schema.sql
+++ b/docs/critical_tables_schema.sql
@@ -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);
-
diff --git a/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor b/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor
index 3b6672f..ab5ce00 100644
--- a/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor
+++ b/src/RolemasterDb.App/Components/Shared/CriticalLookupResultCard.razor
@@ -49,6 +49,43 @@
Affix Text
@Result.AffixText
+
+ @if (Result.Effects.Count > 0)
+ {
+
+
Parsed Affixes
+
+ @foreach (var effect in Result.Effects)
+ {
+ -
+ @if (!string.IsNullOrWhiteSpace(effect.SourceText))
+ {
+
@effect.SourceText
+ }
+ @FormatEffect(effect)
+
+ }
+
+
+ }
+
+ }
+ else if (Result.Effects.Count > 0)
+ {
+
+
Parsed Affixes
+
+ @foreach (var effect in Result.Effects)
+ {
+ -
+ @if (!string.IsNullOrWhiteSpace(effect.SourceText))
+ {
+
@effect.SourceText
+ }
+ @FormatEffect(effect)
+
+ }
+
}
@@ -71,6 +108,22 @@
{
@branch.AffixText
}
+
+ @if (branch.Effects.Count > 0)
+ {
+
+ @foreach (var effect in branch.Effects)
+ {
+ -
+ @if (!string.IsNullOrWhiteSpace(effect.SourceText))
+ {
+
@effect.SourceText
+ }
+ @FormatEffect(effect)
+
+ }
+
+ }
}
@@ -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";
}
diff --git a/src/RolemasterDb.App/Data/RolemasterDbContext.cs b/src/RolemasterDb.App/Data/RolemasterDbContext.cs
index 8452ee0..be730aa 100644
--- a/src/RolemasterDb.App/Data/RolemasterDbContext.cs
+++ b/src/RolemasterDb.App/Data/RolemasterDbContext.cs
@@ -15,6 +15,7 @@ public sealed class RolemasterDbContext(DbContextOptions op
public DbSet CriticalRollBands => Set();
public DbSet CriticalResults => Set();
public DbSet CriticalBranches => Set();
+ public DbSet CriticalEffects => Set();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -87,5 +88,17 @@ public sealed class RolemasterDbContext(DbContextOptions op
entity.Property(item => item.BranchKind).HasMaxLength(32);
entity.Property(item => item.ConditionKey).HasMaxLength(128);
});
+
+ modelBuilder.Entity(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);
+ });
}
}
diff --git a/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs b/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
index 5b415df..b26f552 100644
--- a/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
+++ b/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
@@ -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);
}
}
diff --git a/src/RolemasterDb.App/Domain/CriticalBranch.cs b/src/RolemasterDb.App/Domain/CriticalBranch.cs
index c202e5a..e4fa3e5 100644
--- a/src/RolemasterDb.App/Domain/CriticalBranch.cs
+++ b/src/RolemasterDb.App/Domain/CriticalBranch.cs
@@ -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 Effects { get; set; } = [];
}
diff --git a/src/RolemasterDb.App/Domain/CriticalEffect.cs b/src/RolemasterDb.App/Domain/CriticalEffect.cs
new file mode 100644
index 0000000..adcf41d
--- /dev/null
+++ b/src/RolemasterDb.App/Domain/CriticalEffect.cs
@@ -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; }
+}
diff --git a/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs b/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs
new file mode 100644
index 0000000..da11430
--- /dev/null
+++ b/src/RolemasterDb.App/Domain/CriticalEffectCodes.cs
@@ -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";
+}
diff --git a/src/RolemasterDb.App/Domain/CriticalResult.cs b/src/RolemasterDb.App/Domain/CriticalResult.cs
index 1596425..1bcb3ec 100644
--- a/src/RolemasterDb.App/Domain/CriticalResult.cs
+++ b/src/RolemasterDb.App/Domain/CriticalResult.cs
@@ -17,4 +17,5 @@ public sealed class CriticalResult
public CriticalColumn CriticalColumn { get; set; } = null!;
public CriticalRollBand CriticalRollBand { get; set; } = null!;
public List Branches { get; set; } = [];
+ public List Effects { get; set; } = [];
}
diff --git a/src/RolemasterDb.App/Features/CriticalEffectLookupResponse.cs b/src/RolemasterDb.App/Features/CriticalEffectLookupResponse.cs
new file mode 100644
index 0000000..cc5d98d
--- /dev/null
+++ b/src/RolemasterDb.App/Features/CriticalEffectLookupResponse.cs
@@ -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);
diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs
index 657f8db..bfd153f 100644
--- a/src/RolemasterDb.App/Features/LookupContracts.cs
+++ b/src/RolemasterDb.App/Features/LookupContracts.cs
@@ -52,6 +52,7 @@ public sealed record CriticalBranchLookupResponse(
string ConditionText,
string Description,
string? AffixText,
+ IReadOnlyList Effects,
string RawText,
int SortOrder);
@@ -73,6 +74,7 @@ public sealed record CriticalLookupResponse(
string RawCellText,
string Description,
string? AffixText,
+ IReadOnlyList Effects,
IReadOnlyList Branches,
string ParseStatus,
string ParsedJson);
diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs
index 8bb2217..52b05f7 100644
--- a/src/RolemasterDb.App/Features/LookupService.cs
+++ b/src/RolemasterDb.App/Features/LookupService.cs
@@ -132,6 +132,21 @@ public sealed class LookupService(IDbContextFactory 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 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(),
diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css
index 9d4d3a9..ffc3f0a 100644
--- a/src/RolemasterDb.App/wwwroot/app.css
+++ b/src/RolemasterDb.App/wwwroot/app.css
@@ -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;
}
diff --git a/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs
index 13d807e..362c49f 100644
--- a/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs
+++ b/src/RolemasterDb.ImportTool.Tests/StandardCriticalTableParserIntegrationTests.cs
@@ -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 LoadParseResultAsync(CriticalImportManifestEntry entry)
@@ -367,6 +503,15 @@ public sealed class StandardCriticalTableParserIntegrationTests
return new RolemasterDbContext(options);
}
+ private static IDbContextFactory CreateDbContextFactory(string databasePath)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={databasePath}")
+ .Options;
+
+ return new TestRolemasterDbContextFactory(options);
+ }
+
private static string CreateTemporaryDatabaseCopy()
{
var databasePath = Path.Combine(GetArtifactCacheRoot(), $"rolemaster-{Guid.NewGuid():N}.db");
diff --git a/src/RolemasterDb.ImportTool.Tests/TestRolemasterDbContextFactory.cs b/src/RolemasterDb.ImportTool.Tests/TestRolemasterDbContextFactory.cs
new file mode 100644
index 0000000..f7c8f4e
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/TestRolemasterDbContextFactory.cs
@@ -0,0 +1,13 @@
+using Microsoft.EntityFrameworkCore;
+
+using RolemasterDb.App.Data;
+
+namespace RolemasterDb.ImportTool.Tests;
+
+internal sealed class TestRolemasterDbContextFactory(DbContextOptions options) : IDbContextFactory
+{
+ public RolemasterDbContext CreateDbContext() => new(options);
+
+ public Task CreateDbContextAsync(CancellationToken cancellationToken = default) =>
+ Task.FromResult(CreateDbContext());
+}
diff --git a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs
index 5a6133b..3c05c98 100644
--- a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs
+++ b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs
@@ -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 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 effects) =>
+ effects.Count == 0
+ ? "{}"
+ : JsonSerializer.Serialize(new { effects }, JsonOptions);
+
+ private static string ResolveParseStatus(
+ IReadOnlyList effects,
+ IReadOnlyList branches) =>
+ effects.Count > 0 || branches.Any(branch => branch.Effects.Count > 0)
+ ? "partial"
+ : "raw";
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs b/src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs
new file mode 100644
index 0000000..4752e82
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs
@@ -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*\((?[^)]+)\)\s*P\b", RegexOptions.Compiled);
+ private static readonly Regex ModifierRegex = new(@"\((?[^0-9+\-)]*)(?[+-])\s*(?\d+)\)", RegexOptions.Compiled);
+
+ internal static IReadOnlyList Parse(string? rawAffixText, AffixLegend affixLegend)
+ {
+ if (string.IsNullOrWhiteSpace(rawAffixText))
+ {
+ return [];
+ }
+
+ var effects = new List();
+
+ 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 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 symbols)
+ {
+ if (symbols.Count == 0)
+ {
+ return null;
+ }
+
+ var escapedSymbols = string.Concat(symbols.Select(Regex.Escape));
+ return new Regex(
+ $@"(?\d+)\s*)?(?[{escapedSymbols}]+)",
+ RegexOptions.Compiled);
+ }
+
+ private static void AddMatches(
+ MatchCollection matches,
+ List<(int Index, ParsedCriticalEffect Effect)> matchedEffects,
+ List<(int Start, int End)> consumedRanges,
+ Func 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);
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs b/src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs
new file mode 100644
index 0000000..3c145be
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs
@@ -0,0 +1,50 @@
+namespace RolemasterDb.ImportTool.Parsing;
+
+internal sealed class AffixLegend
+{
+ public static AffixLegend Empty { get; } = new(
+ new Dictionary(StringComparer.Ordinal),
+ [],
+ supportsFoePenalty: false,
+ supportsAttackerBonus: false,
+ supportsPowerPointModifier: false);
+
+ public AffixLegend(
+ IReadOnlyDictionary symbolEffects,
+ IReadOnlyCollection classificationOnlySymbols,
+ bool supportsFoePenalty,
+ bool supportsAttackerBonus,
+ bool supportsPowerPointModifier)
+ {
+ SymbolEffects = new Dictionary(symbolEffects, StringComparer.Ordinal);
+ EffectSymbols = new HashSet(SymbolEffects.Keys, StringComparer.Ordinal);
+
+ var classificationSymbols = new HashSet(EffectSymbols, StringComparer.Ordinal);
+ foreach (var symbol in classificationOnlySymbols)
+ {
+ classificationSymbols.Add(symbol);
+ }
+
+ ClassificationSymbols = classificationSymbols;
+ SupportsFoePenalty = supportsFoePenalty;
+ SupportsAttackerBonus = supportsAttackerBonus;
+ SupportsPowerPointModifier = supportsPowerPointModifier;
+ }
+
+ public IReadOnlyDictionary SymbolEffects { get; }
+
+ public IReadOnlySet EffectSymbols { get; }
+
+ public IReadOnlySet 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;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalCellParseContent.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalCellParseContent.cs
index e5ea315..f31c3d4 100644
--- a/src/RolemasterDb.ImportTool/Parsing/CriticalCellParseContent.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/CriticalCellParseContent.cs
@@ -5,6 +5,7 @@ internal sealed class CriticalCellParseContent(
string rawCellText,
string descriptionText,
string? rawAffixText,
+ IReadOnlyList effects,
IReadOnlyList branches,
IReadOnlyList 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 Effects { get; } = effects;
public IReadOnlyList Branches { get; } = branches;
public IReadOnlyList ValidationErrors { get; } = validationErrors;
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalCellTextParser.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalCellTextParser.cs
index 0a561cf..b9d88ee 100644
--- a/src/RolemasterDb.ImportTool/Parsing/CriticalCellTextParser.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/CriticalCellTextParser.cs
@@ -2,13 +2,14 @@ namespace RolemasterDb.ImportTool.Parsing;
internal static class CriticalCellTextParser
{
- internal static CriticalCellParseContent Parse(IReadOnlyList lines, ISet affixLegendSymbols)
+ internal static CriticalCellParseContent Parse(IReadOnlyList lines, AffixLegend affixLegend)
{
var validationErrors = new List();
var branchStartIndexes = FindBranchStartIndexes(lines);
var baseLineCount = branchStartIndexes.Count == 0 ? lines.Count : branchStartIndexes[0];
var baseLines = lines.Take(baseLineCount).ToList();
var branches = new List();
+ 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 branchLines,
int sortOrder,
- ISet affixLegendSymbols,
+ AffixLegend affixLegend,
List 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 ValidateSegmentCount(
IReadOnlyList lines,
- ISet affixLegendSymbols,
+ IReadOnlySet 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 lines,
- ISet affixLegendSymbols)
+ IReadOnlySet affixLegendSymbols)
{
var rawText = string.Join(Environment.NewLine, lines);
var rawAffixLines = lines.Where(line => CriticalTableParserSupport.IsAffixLikeLine(line, affixLegendSymbols)).ToList();
diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs
index e7218e6..088fb15 100644
--- a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs
@@ -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 affixLegendSymbols)
+ internal static bool IsAffixLikeLine(string line, IReadOnlySet 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 lines, ISet affixLegendSymbols)
+ internal static int CountLineTypeSegments(IReadOnlyList lines, IReadOnlySet 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 DetectAffixLegendSymbols(IReadOnlyList fragments, int keyTop)
+ internal static AffixLegend ParseAffixLegend(IReadOnlyList 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(StringComparer.Ordinal);
+ var footerText = string.Join(' ', footerLines);
+ var symbolEffects = new Dictionary(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 SplitBoundaryCrossingAffixFragments(
IReadOnlyList bodyFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
- ISet affixLegendSymbols)
+ IReadOnlySet affixLegendSymbols)
{
var splitFragments = new List(bodyFragments.Count);
@@ -327,7 +339,7 @@ internal static class CriticalTableParserSupport
internal static List<(int Top, bool IsAffixLike)> BuildBodyLines(
IReadOnlyList bodyFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
- ISet affixLegendSymbols)
+ IReadOnlySet affixLegendSymbols)
{
var bodyLines = new List<(int Top, bool IsAffixLike)>();
@@ -391,7 +403,7 @@ internal static class CriticalTableParserSupport
IReadOnlyList rowAnchors,
IReadOnlyCollection excludedFragments,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
- ISet affixLegendSymbols)
+ IReadOnlySet affixLegendSymbols)
{
var bodyFragments = fragments
.Where(item =>
@@ -406,7 +418,7 @@ internal static class CriticalTableParserSupport
return SplitBoundaryCrossingAffixFragments(bodyFragments, columnCenters, affixLegendSymbols);
}
- internal static void RepairLeadingAffixLeakage(List cellEntries, ISet affixLegendSymbols)
+ internal static void RepairLeadingAffixLeakage(List cellEntries, IReadOnlySet 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 cellEntries,
- ISet affixLegendSymbols,
+ AffixLegend affixLegend,
List parsedCells,
List parsedResults,
List 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 SplitBoundaryCrossingAffixFragment(
XmlTextFragment fragment,
IReadOnlyList<(string Key, double CenterX)> columnCenters,
- ISet affixLegendSymbols)
+ IReadOnlySet 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 affixLegendSymbols)
+ IReadOnlySet 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 symbols, string value, string pattern)
+ private static void AddLegendMatch(
+ IDictionary 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;
+ }
}
}
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs
index b0fcf76..fb759e2 100644
--- a/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs
@@ -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();
var parsedResults = new List();
- 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)
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalBranch.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalBranch.cs
index 869d4fd..1521c38 100644
--- a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalBranch.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalBranch.cs
@@ -7,6 +7,7 @@ public sealed class ParsedCriticalBranch(
string rawText,
string descriptionText,
string? rawAffixText,
+ IReadOnlyList 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 Effects { get; } = effects;
public int SortOrder { get; } = sortOrder;
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalCellArtifact.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalCellArtifact.cs
index 9377d7a..c96fe20 100644
--- a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalCellArtifact.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalCellArtifact.cs
@@ -9,6 +9,7 @@ public sealed class ParsedCriticalCellArtifact(
string rawCellText,
string descriptionText,
string? rawAffixText,
+ IReadOnlyList effects,
IReadOnlyList 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 Effects { get; } = effects;
public IReadOnlyList Branches { get; } = branches;
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs
new file mode 100644
index 0000000..1a0a5ee
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs
@@ -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;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs
index 0132f4d..78e31bd 100644
--- a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs
@@ -7,6 +7,7 @@ public sealed class ParsedCriticalResult(
string rawCellText,
string descriptionText,
string? rawAffixText,
+ IReadOnlyList effects,
IReadOnlyList 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 Effects { get; } = effects;
public IReadOnlyList Branches { get; } = branches;
}
diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs
index df9aa97..df85985 100644
--- a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs
@@ -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();
var parsedResults = new List();
- CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegendSymbols, parsedCells, parsedResults, validationErrors);
+ CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors);
if (columnCenters.Count != 5)
{
diff --git a/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs
index 91f8dff..8ceec30 100644
--- a/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs
+++ b/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs
@@ -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();
var parsedResults = new List();
- CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegendSymbols, parsedCells, parsedResults, validationErrors);
+ CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors);
if (columnAnchors.Count != ExpectedColumns.Length)
{