From 521f0ff8d52b0cd297823a7a58bfae62bd5eac4d Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 14 Mar 2026 11:31:13 +0100 Subject: [PATCH] Implement phase 6 critical effect normalization --- docs/critical_import_tool.md | 34 ++- docs/critical_tables_db_model.md | 10 +- docs/critical_tables_schema.sql | 2 +- .../Shared/CriticalLookupResultCard.razor | 70 +++++ .../Data/RolemasterDbContext.cs | 13 + .../Data/RolemasterDbSchemaUpgrader.cs | 47 +++ src/RolemasterDb.App/Domain/CriticalBranch.cs | 1 + src/RolemasterDb.App/Domain/CriticalEffect.cs | 22 ++ .../Domain/CriticalEffectCodes.cs | 13 + src/RolemasterDb.App/Domain/CriticalResult.cs | 1 + .../Features/CriticalEffectLookupResponse.cs | 14 + .../Features/LookupContracts.cs | 2 + .../Features/LookupService.cs | 30 ++ src/RolemasterDb.App/wwwroot/app.css | 43 +++ ...dardCriticalTableParserIntegrationTests.cs | 147 ++++++++- .../TestRolemasterDbContextFactory.cs | 13 + .../CriticalImportLoader.cs | 57 +++- .../Parsing/AffixEffectParser.cs | 281 ++++++++++++++++++ .../Parsing/AffixLegend.cs | 50 ++++ .../Parsing/CriticalCellParseContent.cs | 2 + .../Parsing/CriticalCellTextParser.cs | 17 +- .../Parsing/CriticalTableParserSupport.cs | 70 +++-- .../GroupedVariantCriticalTableParser.cs | 5 +- .../Parsing/ParsedCriticalBranch.cs | 2 + .../Parsing/ParsedCriticalCellArtifact.cs | 2 + .../Parsing/ParsedCriticalEffect.cs | 27 ++ .../Parsing/ParsedCriticalResult.cs | 2 + .../Parsing/StandardCriticalTableParser.cs | 5 +- .../VariantColumnCriticalTableParser.cs | 5 +- 29 files changed, 932 insertions(+), 55 deletions(-) create mode 100644 src/RolemasterDb.App/Domain/CriticalEffect.cs create mode 100644 src/RolemasterDb.App/Domain/CriticalEffectCodes.cs create mode 100644 src/RolemasterDb.App/Features/CriticalEffectLookupResponse.cs create mode 100644 src/RolemasterDb.ImportTool.Tests/TestRolemasterDbContextFactory.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/AffixEffectParser.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/AffixLegend.cs create mode 100644 src/RolemasterDb.ImportTool/Parsing/ParsedCriticalEffect.cs 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

+
} @@ -71,6 +108,22 @@ {

@branch.AffixText

} + + @if (branch.Effects.Count > 0) + { + + } } @@ -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) {