diff --git a/sources/critical-import-manifest.json b/sources/critical-import-manifest.json index 9721b5a..1882795 100644 --- a/sources/critical-import-manifest.json +++ b/sources/critical-import-manifest.json @@ -167,6 +167,15 @@ "extractionMethod": "xml", "pdfPath": "sources/Unbalance.pdf", "enabled": true + }, + { + "slug": "void", + "displayName": "Void Critical Strike Table", + "family": "standard", + "extractionMethod": "ocr", + "axisTemplateSlug": "mana-standard-19", + "pdfPath": "sources/Void.pdf", + "enabled": true } ] } diff --git a/src/RolemasterDb.App/rolemaster.db b/src/RolemasterDb.App/rolemaster.db index ec19177..f7279f0 100644 Binary files a/src/RolemasterDb.App/rolemaster.db and b/src/RolemasterDb.App/rolemaster.db differ diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs index 76c2994..e20ee3f 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalCellReparseIntegrationTests.cs @@ -478,12 +478,12 @@ public sealed class CriticalCellReparseIntegrationTests initialResponse.Branches)); Assert.NotNull(saveResponse); - Assert.Contains(saveResponse!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-16"); + Assert.Contains(saveResponse!.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "+2d10-16"); var reopenedResponse = await lookupService.GetCriticalCellEditorAsync("mana", resultId); Assert.NotNull(reopenedResponse); Assert.Contains("-2d10-16pp", reopenedResponse!.QuickParseInput, StringComparison.Ordinal); - Assert.Contains(reopenedResponse.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "2d10-16"); + Assert.Contains(reopenedResponse.Effects, effect => effect.EffectCode == AppCriticalEffectCodes.PowerPointModifier && effect.ValueExpression == "+2d10-16"); var reparsed = await lookupService.ReparseCriticalCellAsync( "mana", @@ -643,20 +643,5 @@ public sealed class CriticalCellReparseIntegrationTests await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext); } - private static string GetRepositoryRoot() - { - var probe = new DirectoryInfo(AppContext.BaseDirectory); - - while (probe is not null) - { - if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) - { - return probe.FullName; - } - - probe = probe.Parent; - } - - throw new InvalidOperationException("Could not find the repository root for integration tests."); - } + private static string GetRepositoryRoot() => TestRepositoryPaths.GetRepositoryRoot(); } diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalImportArtifactGenerationIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalImportArtifactGenerationIntegrationTests.cs index 5e79d8c..b3bb7b4 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalImportArtifactGenerationIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalImportArtifactGenerationIntegrationTests.cs @@ -6,6 +6,7 @@ public sealed class CriticalImportArtifactGenerationIntegrationTests { private static readonly PdfXmlExtractor Extractor = new(); private static readonly StandardCriticalTableParser StandardParser = new(); + private static readonly StandardOcrBootstrapper StandardOcrBootstrapper = new(); [Fact] public async Task Generated_artifacts_include_page_and_cell_source_images() @@ -32,6 +33,34 @@ public sealed class CriticalImportArtifactGenerationIntegrationTests Assert.True(File.Exists(artifactPaths.ResolveRelativePath(result.SourceImagePath!))); } + [Fact] + public async Task Generated_ocr_artifacts_preserve_pixel_space_crop_metadata() + { + var (parseResult, artifactPaths) = await LoadPreparedVoidParseResultAsync(); + var result = FindResult(parseResult, "96-99", "D"); + var cellArtifact = parseResult.Cells.Single(item => + item.GroupKey is null && + item.RollBandLabel == "96-99" && + item.ColumnKey == "D"); + + Assert.True(result.SourceBounds.PageNumber > 0); + Assert.True(result.SourceBounds.Width > 0); + Assert.True(result.SourceBounds.Height > 0); + + Assert.NotNull(result.SourceImagePath); + Assert.NotNull(result.SourceImageCrop); + Assert.Equal(1, result.SourceImageCrop!.ScaleFactor); + Assert.Equal(PdfXmlExtractor.ScaledRenderDpi, result.SourceImageCrop.RenderDpi); + Assert.Equal(3600, result.SourceImageCrop.PageWidth); + Assert.Equal(5070, result.SourceImageCrop.PageHeight); + Assert.Equal(result.SourceBounds.Width, result.SourceImageCrop.BoundsWidth); + Assert.Equal(result.SourceBounds.Height, result.SourceImageCrop.BoundsHeight); + Assert.Equal(result.SourceImagePath, cellArtifact.SourceImagePath); + Assert.NotNull(cellArtifact.SourceImageCrop); + Assert.True(File.Exists(artifactPaths.GetPageImagePath(result.SourceBounds.PageNumber))); + Assert.True(File.Exists(artifactPaths.ResolveRelativePath(result.SourceImagePath!))); + } + private static async Task<(CriticalTableParseResult ParseResult, ImportArtifactPaths ArtifactPaths)> LoadPreparedSlashParseResultAsync() { var entry = LoadManifest().Tables.Single(item => item.Slug == "slash"); @@ -51,6 +80,25 @@ public sealed class CriticalImportArtifactGenerationIntegrationTests return (parseResult, artifactPaths); } + private static async Task<(CriticalTableParseResult ParseResult, ImportArtifactPaths ArtifactPaths)> LoadPreparedVoidParseResultAsync() + { + var entry = LoadManifest().Tables.Single(item => item.Slug == "void"); + var source = new ExtractedCriticalSource( + "ocr", + "Imported from PDF OCR extraction.", + SourceRenderProfile.OcrPixels(PdfXmlExtractor.ScaledRenderDpi), + [new ParsedPdfPageGeometry(1, 3600, 5070)], + OcrCriticalSourceExtractor.ParseTsv(await File.ReadAllTextAsync(GetVoidFixturePath()))); + var layout = StandardOcrBootstrapper.Bootstrap(source, StandardTableAxisTemplateCatalog.Resolve(entry.AxisTemplateSlug)); + var parseResult = StandardParser.Parse(entry, source, layout); + var artifactRoot = Path.Combine(GetArtifactCacheRoot(), Guid.NewGuid().ToString("N")); + var artifactPaths = ImportArtifactPaths.Create(artifactRoot, entry.Slug); + var generator = new CriticalSourceImageArtifactGenerator(new PdfXmlExtractor()); + + await generator.GenerateAsync(Path.Combine(GetRepositoryRoot(), entry.PdfPath), artifactPaths, parseResult); + return (parseResult, artifactPaths); + } + private static ParsedCriticalResult FindResult(CriticalTableParseResult parseResult, string rollBandLabel, string columnKey) => parseResult.Table.Results.Single(item => item.GroupKey is null && @@ -60,6 +108,9 @@ public sealed class CriticalImportArtifactGenerationIntegrationTests private static CriticalImportManifest LoadManifest() => new CriticalImportManifestLoader().Load(Path.Combine(GetRepositoryRoot(), "sources", "critical-import-manifest.json")); + private static string GetVoidFixturePath() => + Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.ImportTool.Tests", "Fixtures", "Void", "source.ocr.tsv"); + private static string GetArtifactCacheRoot() { var cacheRoot = Path.Combine(Path.GetTempPath(), "RolemasterDb.ImportTool.MergeTests"); @@ -67,20 +118,5 @@ public sealed class CriticalImportArtifactGenerationIntegrationTests return cacheRoot; } - private static string GetRepositoryRoot() - { - var probe = new DirectoryInfo(AppContext.BaseDirectory); - - while (probe is not null) - { - if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) - { - return probe.FullName; - } - - probe = probe.Parent; - } - - throw new InvalidOperationException("Could not find the repository root for integration tests."); - } + private static string GetRepositoryRoot() => TestRepositoryPaths.GetRepositoryRoot(); } diff --git a/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs b/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs index 5293b72..014bbfe 100644 --- a/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/CriticalImportMergeIntegrationTests.cs @@ -315,20 +315,5 @@ public sealed class CriticalImportMergeIntegrationTests return cacheRoot; } - private static string GetRepositoryRoot() - { - var probe = new DirectoryInfo(AppContext.BaseDirectory); - - while (probe is not null) - { - if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) - { - return probe.FullName; - } - - probe = probe.Parent; - } - - throw new InvalidOperationException("Could not find the repository root for integration tests."); - } + private static string GetRepositoryRoot() => TestRepositoryPaths.GetRepositoryRoot(); } diff --git a/src/RolemasterDb.ImportTool.Tests/Fixtures/Void/source.ocr.tsv b/src/RolemasterDb.ImportTool.Tests/Fixtures/Void/source.ocr.tsv new file mode 100644 index 0000000..7032cd0 --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/Fixtures/Void/source.ocr.tsv @@ -0,0 +1,3317 @@ +level page_num block_num par_num line_num word_num left top width height conf text +1 1 0 0 0 0 0 0 3600 5070 -1 +2 1 1 0 0 0 1005 86 1420 58 -1 +3 1 1 1 0 0 1005 86 1420 58 -1 +4 1 1 1 1 0 1005 86 1420 58 -1 +5 1 1 1 1 1 1005 86 179 53 95 14.4.1 +5 1 1 1 1 2 1217 88 197 52 96 VOID +5 1 1 1 1 3 1442 88 384 54 95 CRITICAL +5 1 1 1 1 4 1849 91 290 51 95 STRIKE +5 1 1 1 1 5 2166 91 259 53 95 TABLE +2 1 2 0 0 0 569 204 59 54 -1 +3 1 2 1 0 0 569 204 59 54 -1 +4 1 2 1 1 0 569 204 59 54 -1 +5 1 2 1 1 1 569 204 59 54 95 A +2 1 3 0 0 0 1166 206 42 55 -1 +3 1 3 1 0 0 1166 206 42 55 -1 +4 1 3 1 1 0 1166 206 42 55 -1 +5 1 3 1 1 1 1166 206 42 55 93 B +2 1 4 0 0 0 1750 204 54 59 -1 +3 1 4 1 0 0 1750 204 54 59 -1 +4 1 4 1 1 0 1750 204 54 59 -1 +5 1 4 1 1 1 1750 204 54 59 58 Cc +2 1 5 0 0 0 2336 212 62 53 -1 +3 1 5 1 0 0 2336 212 62 53 -1 +4 1 5 1 1 0 2336 212 62 53 -1 +5 1 5 1 1 1 2336 212 62 53 93 D +2 1 6 0 0 0 2951 210 39 62 -1 +3 1 6 1 0 0 2951 210 39 62 -1 +4 1 6 1 1 0 2951 210 39 62 -1 +5 1 6 1 1 1 2951 210 39 62 91 E +2 1 7 0 0 0 324 297 210 31 -1 +3 1 7 1 0 0 324 297 210 31 -1 +4 1 7 1 1 0 324 297 210 31 -1 +5 1 7 1 1 1 324 297 55 27 96 Lots +5 1 7 1 1 2 390 298 26 25 97 of +5 1 7 1 1 3 426 298 108 30 96 nothing. +2 1 8 0 0 0 911 299 276 32 -1 +3 1 8 1 0 0 911 299 276 32 -1 +4 1 8 1 1 0 911 299 276 32 -1 +5 1 8 1 1 1 911 299 47 26 96 Not +5 1 8 1 1 2 966 306 58 25 96 very +5 1 8 1 1 3 1034 301 153 30 95 impressive. +2 1 9 0 0 0 1499 302 226 32 -1 +3 1 9 1 0 0 1499 302 226 32 -1 +4 1 9 1 1 0 1499 302 226 32 -1 +5 1 9 1 1 1 1499 302 81 32 96 Barely +5 1 9 1 1 2 1590 308 13 20 96 a +5 1 9 1 1 3 1614 302 111 28 96 shadow. +2 1 10 0 0 0 126 319 142 41 -1 +3 1 10 1 0 0 126 319 142 41 -1 +4 1 10 1 1 0 126 319 142 41 -1 +5 1 10 1 1 1 126 319 142 41 96 01-05 +2 1 11 0 0 0 2094 305 362 32 -1 +3 1 11 1 0 0 2094 305 362 32 -1 +4 1 11 1 1 0 2094 305 362 32 -1 +5 1 11 1 1 1 2094 305 46 26 96 Foe +5 1 11 1 1 2 2150 305 56 32 81 only +5 1 11 1 1 3 2218 305 81 32 52 mildly +5 1 11 1 1 4 2309 305 147 32 96 impressed. +2 1 12 0 0 0 2692 306 273 34 -1 +3 1 12 1 0 0 2692 306 273 34 -1 +4 1 12 1 1 0 2692 306 273 34 -1 +5 1 12 1 1 1 2692 306 73 28 96 Serve +5 1 12 1 1 2 2777 307 49 25 96 him +5 1 12 1 1 3 2837 314 32 24 96 up +5 1 12 1 1 4 2879 308 86 32 96 frosty! +2 1 13 0 0 0 1411 371 49 26 -1 +3 1 13 1 0 0 1411 371 49 26 -1 +4 1 13 1 1 0 1411 371 49 26 -1 +5 1 13 1 1 1 1411 371 49 26 91 +0H +2 1 14 0 0 0 2006 373 51 26 -1 +3 1 14 1 0 0 2006 373 51 26 -1 +4 1 14 1 1 0 2006 373 51 26 -1 +5 1 14 1 1 1 2006 373 51 26 83 +0H +2 1 15 0 0 0 2604 376 50 24 -1 +3 1 15 1 0 0 2604 376 50 24 -1 +4 1 15 1 1 0 2604 376 50 24 -1 +5 1 15 1 1 1 2604 376 50 24 83 +1H +2 1 16 0 0 0 3190 377 50 26 -1 +3 1 16 1 0 0 3190 377 50 26 -1 +4 1 16 1 1 0 3190 377 50 26 -1 +5 1 16 1 1 1 3190 377 50 26 87 +2H +2 1 17 0 0 0 321 423 246 26 -1 +3 1 17 1 0 0 321 423 246 26 -1 +4 1 17 1 1 0 321 423 246 26 -1 +5 1 17 1 1 1 321 423 56 26 68 dust +5 1 17 1 1 2 386 429 14 20 95 a +5 1 17 1 1 3 410 423 55 26 95 cool +5 1 17 1 1 4 476 423 91 26 96 breeze. +2 1 18 0 0 0 910 424 160 28 -1 +3 1 18 1 0 0 910 424 160 28 -1 +4 1 18 1 1 0 910 424 160 28 -1 +5 1 18 1 1 1 910 424 74 28 66 Small +5 1 18 1 1 2 994 426 76 26 79 chiils. +2 1 19 0 0 0 1496 427 540 32 -1 +3 1 19 1 0 0 1496 427 540 32 -1 +4 1 19 1 1 0 1496 427 540 32 -1 +5 1 19 1 1 1 1496 427 48 28 96 The +5 1 19 1 1 2 1555 429 117 26 96 shadows +5 1 19 1 1 3 1683 429 96 30 96 deepen +5 1 19 1 1 4 1789 429 93 26 96 around +5 1 19 1 1 5 1892 430 49 29 96 foe, +5 1 19 1 1 6 1954 430 41 26 96 but +5 1 19 1 1 7 2005 430 31 26 96 he +2 1 20 0 0 0 2094 430 504 31 -1 +3 1 20 1 0 0 2094 430 504 31 -1 +4 1 20 1 1 0 2094 430 504 31 -1 +5 1 20 1 1 1 2094 430 45 26 96 Foe +5 1 20 1 1 2 2149 436 125 25 95 conquers +5 1 20 1 1 3 2286 430 38 26 96 his +5 1 20 1 1 4 2333 432 53 26 96 fear +5 1 20 1 1 5 2395 432 26 26 96 of +5 1 20 1 1 6 2430 432 41 26 96 the +5 1 20 1 1 7 2481 432 60 26 95 dark +5 1 20 1 1 8 2549 432 49 26 96 and +2 1 21 0 0 0 2692 432 487 31 -1 +3 1 21 1 0 0 2692 432 487 31 -1 +4 1 21 1 1 0 2692 432 487 31 -1 +5 1 21 1 1 1 2692 432 44 26 96 Foe +5 1 21 1 1 2 2748 432 20 27 88 is +5 1 21 1 1 3 2778 432 105 31 96 dancing +5 1 21 1 1 4 2893 433 95 26 96 around +5 1 21 1 1 5 2998 433 41 26 97 the +5 1 21 1 1 6 3049 433 66 26 96 dark. +5 1 21 1 1 7 3127 433 52 27 95 You +2 1 22 0 0 0 126 465 142 40 -1 +3 1 22 1 0 0 126 465 142 40 -1 +4 1 22 1 1 0 126 465 142 40 -1 +5 1 22 1 1 1 126 465 142 40 96 06-10 +2 1 23 0 0 0 1496 465 228 30 -1 +3 1 23 1 0 0 1496 465 228 30 -1 +4 1 23 1 1 0 1496 465 228 30 -1 +5 1 23 1 1 1 1496 465 20 26 95 is +5 1 23 1 1 2 1528 466 42 25 96 not +5 1 23 1 1 3 1580 465 144 30 96 impressed. +2 1 24 0 0 0 2093 468 327 30 -1 +3 1 24 1 0 0 2093 468 327 30 -1 +4 1 24 1 1 0 2093 468 327 30 -1 +5 1 24 1 1 1 2093 473 60 25 92 your +5 1 24 1 1 2 2162 468 80 26 96 attack +5 1 24 1 1 3 2251 468 22 26 96 is +5 1 24 1 1 4 2283 468 137 26 96 off-center. +2 1 25 0 0 0 2690 469 398 27 -1 +3 1 25 1 0 0 2690 469 398 27 -1 +4 1 25 1 1 0 2690 469 398 27 -1 +5 1 25 1 1 1 2690 469 62 26 96 have +5 1 25 1 1 2 2761 469 42 26 97 the +5 1 25 1 1 3 2813 469 111 26 96 initiative +5 1 25 1 1 4 2935 472 56 24 96 next +5 1 25 1 1 5 3001 471 87 25 96 round. +2 1 26 0 0 0 821 530 50 25 -1 +3 1 26 1 0 0 821 530 50 25 -1 +4 1 26 1 1 0 821 530 50 25 -1 +5 1 26 1 1 1 821 530 50 25 89 +0H +2 1 27 0 0 0 1410 532 50 25 -1 +3 1 27 1 0 0 1410 532 50 25 -1 +4 1 27 1 1 0 1410 532 50 25 -1 +5 1 27 1 1 1 1410 532 50 25 87 +1H +2 1 28 0 0 0 2006 534 49 26 -1 +3 1 28 1 0 0 2006 534 49 26 -1 +4 1 28 1 1 0 2006 534 49 26 -1 +5 1 28 1 1 1 2006 534 49 26 90 +2H +2 1 29 0 0 0 2604 537 49 26 -1 +3 1 29 1 0 0 2604 537 49 26 -1 +4 1 29 1 1 0 2604 537 49 26 -1 +5 1 29 1 1 1 2604 537 49 26 88 +3H +2 1 30 0 0 0 3190 538 49 26 -1 +3 1 30 1 0 0 3190 538 49 26 -1 +4 1 30 1 1 0 3190 538 49 26 -1 +5 1 30 1 1 1 3190 538 49 26 85 +3H +2 1 31 0 0 0 3366 479 99 107 -1 +3 1 31 1 0 0 3366 479 99 107 -1 +4 1 31 1 1 0 3366 479 99 107 -1 +5 1 31 1 1 1 3366 479 99 107 23 % +2 1 32 0 0 0 322 578 506 32 -1 +3 1 32 1 0 0 322 578 506 32 -1 +4 1 32 1 1 0 322 578 506 32 -1 +5 1 32 1 1 1 322 578 45 26 96 Foe +5 1 32 1 1 2 377 580 74 30 80 deftly +5 1 32 1 1 3 461 581 70 29 96 steps +5 1 32 1 1 4 542 581 27 25 96 to +5 1 32 1 1 5 577 581 42 25 96 the +5 1 32 1 1 6 631 581 60 25 96 side. +5 1 32 1 1 7 703 581 52 25 96 You +5 1 32 1 1 8 766 581 62 26 96 have +2 1 33 0 0 0 910 581 529 29 -1 +3 1 33 1 0 0 910 581 529 29 -1 +4 1 33 1 1 0 910 581 529 29 -1 +5 1 33 1 1 1 910 581 45 26 96 Foe +5 1 33 1 1 2 965 583 93 26 96 evades +5 1 33 1 1 3 1070 584 66 25 96 most +5 1 33 1 1 4 1146 584 26 25 96 of +5 1 33 1 1 5 1181 583 42 26 96 the +5 1 33 1 1 6 1234 583 69 26 93 blast. +5 1 33 1 1 7 1315 584 52 26 96 You +5 1 33 1 1 8 1378 584 61 26 96 have +2 1 34 0 0 0 1496 584 506 32 -1 +3 1 34 1 0 0 1496 584 506 32 -1 +4 1 34 1 1 0 1496 584 506 32 -1 +5 1 34 1 1 1 1496 584 48 26 96 The +5 1 34 1 1 2 1555 584 72 32 96 lights +5 1 34 1 1 3 1637 591 34 25 96 go +5 1 34 1 1 4 1681 587 43 25 96 out +5 1 34 1 1 5 1732 587 41 25 93 for +5 1 34 1 1 6 1781 591 13 21 93 a +5 1 34 1 1 7 1807 587 118 25 96 moment. +5 1 34 1 1 8 1936 586 66 27 96 Your +2 1 35 0 0 0 2093 587 486 27 -1 +3 1 35 1 0 0 2093 587 486 27 -1 +4 1 35 1 1 0 2093 587 486 27 -1 +5 1 35 1 1 1 2093 587 47 26 96 The +5 1 35 1 1 2 2150 587 59 26 96 dark +5 1 35 1 1 3 2218 587 82 26 96 attack +5 1 35 1 1 4 2309 589 77 24 96 frosts +5 1 35 1 1 5 2396 589 65 25 33 foe's +5 1 35 1 1 6 2471 587 52 27 96 hair +5 1 35 1 1 7 2532 589 47 25 96 and +2 1 36 0 0 0 2690 589 471 27 -1 +3 1 36 1 0 0 2690 589 471 27 -1 +4 1 36 1 1 0 2690 589 471 27 -1 +5 1 36 1 1 1 2690 589 48 25 96 The +5 1 36 1 1 2 2748 589 121 27 96 darkness +5 1 36 1 1 3 2880 590 21 26 96 is +5 1 36 1 1 4 2911 590 63 26 96 cold. +5 1 36 1 1 5 2985 590 52 26 96 You +5 1 36 1 1 6 3049 590 62 26 96 have +5 1 36 1 1 7 3120 590 41 26 96 the +2 1 37 0 0 0 320 616 322 27 -1 +3 1 37 1 0 0 320 616 322 27 -1 +4 1 37 1 1 0 320 616 322 27 -1 +5 1 37 1 1 1 320 616 40 26 97 the +5 1 37 1 1 2 371 616 110 26 95 initiative +5 1 37 1 1 3 492 619 55 23 96 next +5 1 37 1 1 4 559 617 83 26 96 round. +2 1 38 0 0 0 907 619 324 27 -1 +3 1 38 1 0 0 907 619 324 27 -1 +4 1 38 1 1 0 907 619 324 27 -1 +5 1 38 1 1 1 907 619 41 26 97 the +5 1 38 1 1 2 959 619 111 26 96 initiative +5 1 38 1 1 3 1080 622 55 23 96 next +5 1 38 1 1 4 1146 620 85 26 95 round. +2 1 39 0 0 0 1495 622 488 27 -1 +3 1 39 1 0 0 1495 622 488 27 -1 +4 1 39 1 1 0 1495 622 488 27 -1 +5 1 39 1 1 1 1495 622 40 26 96 foe +5 1 39 1 1 2 1547 622 128 26 96 stumbles. +5 1 39 1 1 3 1687 622 51 26 96 You +5 1 39 1 1 4 1750 622 60 26 97 have +5 1 39 1 1 5 1820 623 41 25 96 the +5 1 39 1 1 6 1872 623 111 26 96 initiative +2 1 40 0 0 0 2093 623 286 27 -1 +3 1 40 1 0 0 2093 623 286 27 -1 +4 1 40 1 1 0 2093 623 286 27 -1 +5 1 40 1 1 1 2093 623 98 26 91 cheeks. +5 1 40 1 1 2 2205 624 33 25 96 He +5 1 40 1 1 3 2248 624 20 25 96 is +5 1 40 1 1 4 2281 630 98 20 94 unsure. +2 1 41 0 0 0 2690 625 275 27 -1 +3 1 41 1 0 0 2690 625 275 27 -1 +4 1 41 1 1 0 2690 625 275 27 -1 +5 1 41 1 1 1 2690 625 111 27 96 initiative +5 1 41 1 1 2 2811 627 58 25 96 next +5 1 41 1 1 3 2879 626 86 26 94 round. +2 1 42 0 0 0 128 637 137 39 -1 +3 1 42 1 0 0 128 637 137 39 -1 +4 1 42 1 1 0 128 637 137 39 -1 +5 1 42 1 1 1 128 637 137 39 89 11-15 +2 1 43 0 0 0 1496 659 147 25 -1 +3 1 43 1 0 0 1496 659 147 25 -1 +4 1 43 1 1 0 1496 659 147 25 -1 +5 1 43 1 1 1 1485 655 68 40 94 next +5 1 43 1 1 2 1552 679 91 3 96 round. +2 1 44 0 0 0 821 722 49 25 -1 +3 1 44 1 0 0 821 722 49 25 -1 +4 1 44 1 1 0 821 722 49 25 -1 +5 1 44 1 1 1 821 722 49 25 26 +1H +2 1 45 0 0 0 1408 725 51 26 -1 +3 1 45 1 0 0 1408 725 51 26 -1 +4 1 45 1 1 0 1408 725 51 26 -1 +5 1 45 1 1 1 1408 725 51 26 89 +2H +2 1 46 0 0 0 2005 728 50 26 -1 +3 1 46 1 0 0 2005 728 50 26 -1 +4 1 46 1 1 0 2005 728 50 26 -1 +5 1 46 1 1 1 2005 728 50 26 90 +3H +2 1 47 0 0 0 2532 730 119 24 -1 +3 1 47 1 0 0 2532 730 119 24 -1 +4 1 47 1 1 0 2532 730 119 24 -1 +5 1 47 1 1 1 2532 730 119 24 63 +4H +5 1 47 1 1 2 2588 726 27 40 41 ~ +5 1 47 1 1 3 2614 730 37 24 74 & +2 1 48 0 0 0 2904 731 335 30 -1 +3 1 48 1 0 0 2904 731 335 30 -1 +4 1 48 1 1 0 2904 731 335 30 -1 +5 1 48 1 1 1 2904 731 53 24 86 +5H +5 1 48 1 1 2 2967 742 17 5 38 — +5 1 48 1 1 3 2996 731 31 23 74 & +5 1 48 1 1 4 3045 742 17 6 88 ~ +5 1 48 1 1 5 3072 731 167 30 79 -(4d10-32)P +2 1 49 0 0 0 321 777 441 32 -1 +3 1 49 1 0 0 321 777 441 32 -1 +4 1 49 1 1 0 321 777 441 32 -1 +5 1 49 1 1 1 321 777 64 26 96 Blast +5 1 49 1 1 2 396 777 81 32 96 stings +5 1 49 1 1 3 470 773 22 41 96 a +5 1 49 1 1 4 512 777 62 27 86 little. +5 1 49 1 1 5 586 778 52 26 96 You +5 1 49 1 1 6 649 778 62 26 96 have +5 1 49 1 1 7 721 778 41 26 96 the +2 1 50 0 0 0 909 778 396 35 -1 +3 1 50 1 0 0 909 778 396 35 -1 +4 1 50 1 1 0 909 778 396 35 -1 +5 1 50 1 1 1 909 778 44 28 95 Foe +5 1 50 1 1 2 965 780 20 26 95 is +5 1 50 1 1 3 996 780 107 32 95 partially +5 1 50 1 1 4 1115 780 96 27 90 blinded +5 1 50 1 1 5 1224 781 30 32 96 by +5 1 50 1 1 6 1263 781 42 26 96 the +2 1 51 0 0 0 321 814 274 26 -1 +3 1 51 1 0 0 321 814 274 26 -1 +4 1 51 1 1 0 321 814 274 26 -1 +5 1 51 1 1 1 321 814 109 26 96 initiative +5 1 51 1 1 2 442 816 56 24 96 next +5 1 51 1 1 3 510 816 85 24 94 round. +2 1 52 0 0 0 907 817 486 28 -1 +3 1 52 1 0 0 907 817 486 28 -1 +4 1 52 1 1 0 907 817 486 28 -1 +5 1 52 1 1 1 907 817 128 26 95 darkness. +5 1 52 1 1 2 1047 817 52 26 95 You +5 1 52 1 1 3 1110 817 62 26 96 have +5 1 52 1 1 4 1181 817 42 27 96 the +5 1 52 1 1 5 1234 819 110 26 96 initiative +5 1 52 1 1 6 1354 819 39 26 96 for +2 1 53 0 0 0 1495 783 544 33 -1 +3 1 53 1 0 0 1495 783 544 33 -1 +4 1 53 1 1 0 1495 783 544 33 -1 +5 1 53 1 1 1 1495 783 19 24 95 A +5 1 53 1 1 2 1522 783 130 26 77 whirlwind +5 1 53 1 1 3 1663 784 26 25 96 of +5 1 53 1 1 4 1698 783 40 26 96 the +5 1 53 1 1 5 1748 783 57 27 96 void +5 1 53 1 1 6 1816 784 94 26 96 attacks +5 1 53 1 1 7 1920 790 62 26 96 your +5 1 53 1 1 8 1990 784 49 26 96 foe. +2 1 54 0 0 0 2093 784 481 32 -1 +3 1 54 1 0 0 2093 784 481 32 -1 +4 1 54 1 1 0 2093 784 481 32 -1 +5 1 54 1 1 1 2093 784 23 26 70 in +5 1 54 1 1 2 2126 790 31 20 87 an +5 1 54 1 1 3 2167 786 93 30 92 attemp +5 1 54 1 1 4 2270 787 27 25 96 to +5 1 54 1 1 5 2307 786 82 30 96 dodge +5 1 54 1 1 6 2399 786 41 26 96 the +5 1 54 1 1 7 2451 786 71 30 86 blast, +5 1 54 1 1 8 2532 787 42 25 96 foe +2 1 55 0 0 0 2690 786 517 33 -1 +3 1 55 1 0 0 2690 786 517 33 -1 +4 1 55 1 1 0 2690 786 517 33 -1 +5 1 55 1 1 1 2690 786 45 27 96 Foe +5 1 55 1 1 2 2745 787 124 26 96 stumbles +5 1 55 1 1 3 2878 787 49 26 96 and +5 1 55 1 1 4 2938 787 82 32 94 nearly +5 1 55 1 1 5 3029 787 55 27 94 falls +5 1 55 1 1 6 3094 787 76 32 95 trying +5 1 55 1 1 7 3180 789 27 25 96 to +2 1 56 0 0 0 126 836 139 40 -1 +3 1 56 1 0 0 126 836 139 40 -1 +4 1 56 1 1 0 126 836 139 40 -1 +5 1 56 1 1 1 126 836 139 40 96 16-20 +2 1 57 0 0 0 1494 819 448 28 -1 +3 1 57 1 0 0 1494 819 448 28 -1 +4 1 57 1 1 0 1494 819 448 28 -1 +5 1 57 1 1 1 1494 819 51 27 96 You +5 1 57 1 1 2 1557 820 60 26 96 have +5 1 57 1 1 3 1627 822 49 24 96 two +5 1 57 1 1 4 1686 820 93 26 86 rounds +5 1 57 1 1 5 1790 822 26 24 96 of +5 1 57 1 1 6 1826 820 116 27 92 initiative. +2 1 58 0 0 0 2091 822 473 31 -1 +3 1 58 1 0 0 2091 822 473 31 -1 +4 1 58 1 1 0 2091 822 473 31 -1 +5 1 58 1 1 1 2091 822 121 31 96 damages +5 1 58 1 1 2 2222 822 105 27 96 himself. +5 1 58 1 1 3 2339 822 69 27 96 Quite +5 1 58 1 1 4 2418 823 146 26 96 humorous. +2 1 59 0 0 0 2687 823 202 27 -1 +3 1 59 1 0 0 2687 823 202 27 -1 +4 1 59 1 1 0 2687 823 202 27 -1 +5 1 59 1 1 1 2687 823 71 26 95 avoid +5 1 59 1 1 2 2768 823 40 26 93 the +5 1 59 1 1 3 2820 824 69 26 90 biast. +2 1 60 0 0 0 906 855 160 26 -1 +3 1 60 1 0 0 906 855 160 26 -1 +4 1 60 1 1 0 906 855 160 26 -1 +5 1 60 1 1 1 906 855 49 24 96 two +5 1 60 1 1 2 966 855 100 26 96 rounds. +2 1 61 0 0 0 819 921 52 26 -1 +3 1 61 1 0 0 819 921 52 26 -1 +4 1 61 1 1 0 819 921 52 26 -1 +5 1 61 1 1 1 819 921 52 26 92 +2H +2 1 62 0 0 0 1407 924 50 26 -1 +3 1 62 1 0 0 1407 924 50 26 -1 +4 1 62 1 1 0 1407 924 50 26 -1 +5 1 62 1 1 1 1407 924 50 26 91 +3H +2 1 63 0 0 0 2003 927 51 24 -1 +3 1 63 1 0 0 2003 927 51 24 -1 +4 1 63 1 1 0 2003 927 51 24 -1 +5 1 63 1 1 1 2003 927 51 24 77 +4H +2 1 64 0 0 0 2317 928 333 30 -1 +3 1 64 1 0 0 2317 928 333 30 -1 +4 1 64 1 1 0 2317 928 333 30 -1 +5 1 64 1 1 1 2317 928 79 26 74 +5H~ +5 1 64 1 1 2 2417 928 30 23 11 %% +5 1 64 1 1 3 2457 940 17 5 67 ~ +5 1 64 1 1 4 2484 928 166 30 78 -(3d10-24)P +2 1 65 0 0 0 2915 930 325 31 -1 +3 1 65 1 0 0 2915 930 325 31 -1 +4 1 65 1 1 0 2915 930 325 31 -1 +5 1 65 1 1 1 2915 930 53 25 91 +6H +5 1 65 1 1 2 2978 942 18 5 21 — +5 1 65 1 1 3 3007 931 30 23 55 4 +5 1 65 1 1 4 3047 942 18 5 75 - +5 1 65 1 1 5 3075 930 165 31 68 -(4d10-30)P +2 1 66 0 0 0 318 954 461 59 -1 +3 1 66 1 0 0 318 954 461 59 -1 +4 1 66 1 1 0 318 954 461 59 -1 +5 1 66 1 1 1 318 977 84 26 96 Attack +5 1 66 1 1 2 413 977 62 26 92 does +5 1 66 1 1 3 487 977 56 26 66 fittle +5 1 66 1 1 4 553 954 114 59 96 damage, +5 1 66 1 1 5 680 978 41 25 95 but +5 1 66 1 1 6 731 984 48 25 96 any +2 1 67 0 0 0 907 978 490 34 -1 +3 1 67 1 0 0 907 978 490 34 -1 +4 1 67 1 1 0 907 978 490 34 -1 +5 1 67 1 1 1 907 978 58 26 96 Cold +5 1 67 1 1 2 978 980 132 26 92 blackness +5 1 67 1 1 3 1120 981 82 25 97 forces +5 1 67 1 1 4 1212 981 42 26 96 foe +5 1 67 1 1 5 1266 981 68 26 96 back. +5 1 67 1 1 6 1345 981 52 31 96 Any +2 1 68 0 0 0 1493 981 499 32 -1 +3 1 68 1 0 0 1493 981 499 32 -1 +4 1 68 1 1 0 1493 981 499 32 -1 +5 1 68 1 1 1 1493 981 82 32 96 Tricky +5 1 68 1 1 2 1586 983 57 26 96 shot +5 1 68 1 1 3 1653 983 48 26 96 hits +5 1 68 1 1 4 1711 984 40 25 96 foe +5 1 68 1 1 5 1761 984 64 25 96 from +5 1 68 1 1 6 1836 983 23 26 94 in +5 1 68 1 1 7 1869 984 65 26 94 front +5 1 68 1 1 8 1944 984 48 26 96 and +2 1 69 0 0 0 2090 984 471 32 -1 +3 1 69 1 0 0 2090 984 471 32 -1 +4 1 69 1 1 0 2090 984 471 32 -1 +5 1 69 1 1 1 2090 984 49 26 96 The +5 1 69 1 1 2 2150 984 69 26 96 burst +5 1 69 1 1 3 2229 986 25 24 96 of +5 1 69 1 1 4 2265 986 59 24 96 dark +5 1 69 1 1 5 2334 991 84 25 96 power +5 1 69 1 1 6 2428 986 82 26 96 leaves +5 1 69 1 1 7 2520 986 41 26 96 foe +2 1 70 0 0 0 2687 986 471 33 -1 +3 1 70 1 0 0 2687 986 471 33 -1 +4 1 70 1 1 0 2687 986 471 33 -1 +5 1 70 1 1 1 2687 986 66 26 96 After +5 1 70 1 1 2 2761 987 42 26 96 the +5 1 70 1 1 3 2813 993 67 20 96 wave +5 1 70 1 1 4 2890 987 26 26 96 of +5 1 70 1 1 5 2926 987 123 26 96 darkness +5 1 70 1 1 6 3059 993 99 26 96 passes, +2 1 71 0 0 0 319 1014 450 31 -1 +3 1 71 1 0 0 319 1014 450 31 -1 +4 1 71 1 1 0 319 1014 450 31 -1 +5 1 71 1 1 1 319 1014 106 31 96 charged +5 1 71 1 1 2 438 1014 79 31 96 magic +5 1 71 1 1 3 528 1014 72 26 96 items +5 1 71 1 1 4 610 1020 41 20 95 are +5 1 71 1 1 5 661 1014 108 26 95 drained. +2 1 72 0 0 0 906 1016 505 30 -1 +3 1 72 1 0 0 906 1016 505 30 -1 +4 1 72 1 1 0 906 1016 505 30 -1 +5 1 72 1 1 1 906 1016 69 30 96 glass +5 1 72 1 1 2 986 1016 72 26 96 items +5 1 72 1 1 3 1070 1023 33 19 96 on +5 1 72 1 1 4 1113 1017 40 26 96 foe +5 1 72 1 1 5 1164 1023 41 20 96 are +5 1 72 1 1 6 1217 1017 132 26 96 shattered. +5 1 72 1 1 7 1361 1019 50 26 96 You +2 1 73 0 0 0 1495 1019 396 31 -1 +3 1 73 1 0 0 1495 1019 396 31 -1 +4 1 73 1 1 0 1495 1019 396 31 -1 +5 1 73 1 1 1 1495 1019 95 26 94 behind. +5 1 73 1 1 2 1603 1019 59 26 96 How +5 1 73 1 1 3 1672 1019 39 26 96 did +5 1 73 1 1 4 1721 1026 49 24 96 you +5 1 73 1 1 5 1780 1020 33 26 96 do +5 1 73 1 1 6 1822 1020 69 26 96 that? +2 1 74 0 0 0 2090 1022 305 30 -1 +3 1 74 1 0 0 2090 1022 305 30 -1 +4 1 74 1 1 0 2090 1022 305 30 -1 +5 1 74 1 1 1 2090 1022 103 26 96 covered +5 1 74 1 1 2 2205 1022 23 24 95 in +5 1 74 1 1 3 2238 1022 36 26 95 ice +5 1 74 1 1 4 2284 1022 111 30 96 crystals. +2 1 75 0 0 0 2687 1023 473 32 -1 +3 1 75 1 0 0 2687 1023 473 32 -1 +4 1 75 1 1 0 2687 1023 473 32 -1 +5 1 75 1 1 1 2687 1023 64 26 35 foe's +5 1 75 1 1 2 2762 1023 52 26 96 hair +5 1 75 1 1 3 2823 1023 44 26 96 has +5 1 75 1 1 4 2879 1025 91 24 95 frozen. +5 1 75 1 1 5 2983 1025 16 24 84 ff +5 1 75 1 1 6 3010 1023 14 26 94 it +5 1 75 1 1 7 3036 1025 20 25 94 is +5 1 75 1 1 8 3068 1025 64 30 83 tong, +5 1 75 1 1 9 3145 1025 15 24 96 it +2 1 76 0 0 0 124 1056 139 41 -1 +3 1 76 1 0 0 124 1056 139 41 -1 +4 1 76 1 1 0 124 1056 139 41 -1 +5 1 76 1 1 1 124 1056 139 41 96 21-35 +2 1 77 0 0 0 907 1052 449 29 -1 +3 1 77 1 0 0 907 1052 449 29 -1 +4 1 77 1 1 0 907 1052 449 29 -1 +5 1 77 1 1 1 907 1052 61 26 96 have +5 1 77 1 1 2 976 1052 42 26 96 the +5 1 77 1 1 3 1028 1053 111 26 96 initiative +5 1 77 1 1 4 1149 1055 39 24 96 for +5 1 77 1 1 5 1195 1056 49 23 96 two +5 1 77 1 1 6 1256 1055 100 26 96 rounds. +2 1 78 0 0 0 2687 1059 496 33 -1 +3 1 78 1 0 0 2687 1059 496 33 -1 +4 1 78 1 1 0 2687 1059 496 33 -1 +5 1 78 1 1 1 2687 1065 80 26 91 Snaps +5 1 78 1 1 2 2777 1059 76 32 95 easily +5 1 78 1 1 3 2865 1060 89 31 96 (giving +5 1 78 1 1 4 2965 1060 51 25 95 him +5 1 78 1 1 5 3026 1066 14 20 96 a +5 1 78 1 1 6 3050 1060 72 32 96 quick +5 1 78 1 1 7 3132 1060 51 26 96 hair +2 1 79 0 0 0 2687 1096 55 31 -1 +3 1 79 1 0 0 2687 1096 55 31 -1 +4 1 79 1 1 0 2687 1096 55 31 -1 +5 1 79 1 1 1 2687 1096 55 31 86 cut). +2 1 80 0 0 0 95 1193 80 3 -1 +3 1 80 1 0 0 95 1193 80 3 -1 +4 1 80 1 1 0 95 1193 80 3 -1 +5 1 80 1 1 1 95 1193 80 3 7 —— +2 1 81 0 0 0 819 1155 52 26 -1 +3 1 81 1 0 0 819 1155 52 26 -1 +4 1 81 1 1 0 819 1155 52 26 -1 +5 1 81 1 1 1 819 1155 52 26 90 +3H +2 1 82 0 0 0 1406 1160 51 24 -1 +3 1 82 1 0 0 1406 1160 51 24 -1 +4 1 82 1 1 0 1406 1160 51 24 -1 +5 1 82 1 1 1 1406 1160 51 24 87 +4H +2 1 83 0 0 0 1730 1160 322 31 -1 +3 1 83 1 0 0 1730 1160 322 31 -1 +4 1 83 1 1 0 1730 1160 322 31 -1 +5 1 83 1 1 1 1730 1160 53 26 92 +5H +5 1 83 1 1 2 1793 1173 17 4 81 — +5 1 83 1 1 3 1820 1161 30 23 13 <4 +5 1 83 1 1 4 1861 1174 17 3 85 — +5 1 83 1 1 5 1888 1161 164 30 77 -(2d10-18)P +2 1 84 0 0 0 2206 1161 444 33 -1 +3 1 84 1 0 0 2206 1161 444 33 -1 +4 1 84 1 1 0 2206 1161 444 33 -1 +5 1 84 1 1 1 2206 1161 52 26 90 +6H +5 1 84 1 1 2 2268 1174 18 6 50 — +5 1 84 1 1 3 2296 1163 49 24 19 2%2 +5 1 84 1 1 4 2355 1176 17 4 53 ~ +5 1 84 1 1 5 2382 1163 65 30 92 (-20) +5 1 84 1 1 6 2458 1176 18 4 77 - +5 1 84 1 1 7 2484 1163 166 31 74 -(3d10-22)P +2 1 85 0 0 0 2885 1164 353 32 -1 +3 1 85 1 0 0 2885 1164 353 32 -1 +4 1 85 1 1 0 2885 1164 353 32 -1 +5 1 85 1 1 1 2885 1164 53 25 63 +7H +5 1 85 1 1 2 2957 1177 17 4 28 — +5 1 85 1 1 3 2984 1164 49 26 45 26% +5 1 85 1 1 4 3043 1177 17 4 62 - +5 1 85 1 1 5 3071 1164 167 32 11 -(4d10-28)P +2 1 86 0 0 0 319 1206 401 33 -1 +3 1 86 1 0 0 319 1206 401 33 -1 +4 1 86 1 1 0 319 1206 401 33 -1 +5 1 86 1 1 1 319 1206 51 27 96 One +5 1 86 1 1 2 381 1207 81 31 95 magic +5 1 86 1 1 3 472 1207 58 26 95 item +5 1 86 1 1 4 541 1207 21 26 96 is +5 1 86 1 1 5 573 1208 147 31 52 completely +2 1 87 0 0 0 907 1209 487 33 -1 +3 1 87 1 0 0 907 1209 487 33 -1 +4 1 87 1 1 0 907 1209 487 33 -1 +5 1 87 1 1 1 907 1209 66 27 53 Foe's +5 1 87 1 1 2 985 1212 104 28 96 attempt +5 1 87 1 1 3 1097 1212 26 24 96 to +5 1 87 1 1 4 1135 1216 67 26 96 parry +5 1 87 1 1 5 1213 1212 41 24 97 the +5 1 87 1 1 6 1264 1212 81 26 96 attack +5 1 87 1 1 7 1354 1217 40 21 97 are +2 1 88 0 0 0 1493 1212 528 31 -1 +3 1 88 1 0 0 1493 1212 528 31 -1 +4 1 88 1 1 0 1493 1212 528 31 -1 +5 1 88 1 1 1 1493 1212 45 26 96 Foe +5 1 88 1 1 2 1550 1212 87 31 96 begins +5 1 88 1 1 3 1646 1214 26 25 93 to +5 1 88 1 1 4 1683 1213 54 30 91 siip. +5 1 88 1 1 5 1747 1213 52 26 96 You +5 1 88 1 1 6 1810 1213 56 30 96 gain +5 1 88 1 1 7 1875 1213 40 26 93 the +5 1 88 1 1 8 1927 1213 94 27 93 iniative +2 1 89 0 0 0 2088 1214 455 32 -1 +3 1 89 1 0 0 2088 1214 455 32 -1 +4 1 89 1 1 0 2088 1214 455 32 -1 +5 1 89 1 1 1 2088 1214 49 26 96 The +5 1 89 1 1 2 2147 1220 85 25 96 power +5 1 89 1 1 3 2239 1216 26 24 96 of +5 1 89 1 1 4 2274 1214 42 26 96 the +5 1 89 1 1 5 2326 1214 55 26 96 void +5 1 89 1 1 6 2391 1216 95 26 96 freezes +5 1 89 1 1 7 2496 1220 47 26 96 any +2 1 90 0 0 0 2687 1216 536 33 -1 +3 1 90 1 0 0 2687 1216 536 33 -1 +4 1 90 1 1 0 2687 1216 536 33 -1 +5 1 90 1 1 1 2687 1216 47 26 96 Foe +5 1 90 1 1 2 2744 1216 21 26 95 is +5 1 90 1 1 3 2775 1216 107 27 95 stricken +5 1 90 1 1 4 2892 1216 24 26 96 in +5 1 90 1 1 5 2925 1217 42 26 96 the +5 1 90 1 1 6 2978 1217 62 31 96 legs. +5 1 90 1 1 7 3052 1217 34 26 96 He +5 1 90 1 1 8 3098 1217 125 32 96 struggles +2 1 91 0 0 0 318 1245 339 30 -1 +3 1 91 1 0 0 318 1245 339 30 -1 +4 1 91 1 1 0 318 1245 339 30 -1 +5 1 91 1 1 1 318 1245 138 30 95 destroyed. +5 1 91 1 1 2 469 1245 45 24 96 Foe +5 1 91 1 1 3 524 1245 22 26 96 is +5 1 91 1 1 4 557 1245 100 26 96 shaken. +2 1 92 0 0 0 906 1246 505 30 -1 +3 1 92 1 0 0 906 1246 505 30 -1 +4 1 92 1 1 0 906 1246 505 30 -1 +5 1 92 1 1 1 906 1246 115 30 96 basically +5 1 92 1 1 2 1030 1246 73 26 96 futile. +5 1 92 1 1 3 1115 1246 51 26 96 You +5 1 92 1 1 4 1178 1248 62 24 96 have +5 1 92 1 1 5 1248 1248 42 26 96 the +5 1 92 1 1 6 1300 1248 111 26 96 initiative +2 1 93 0 0 0 1492 1249 379 32 -1 +3 1 93 1 0 0 1492 1249 379 32 -1 +4 1 93 1 1 0 1492 1249 379 32 -1 +5 1 93 1 1 1 1492 1249 69 25 96 while +5 1 93 1 1 2 1571 1249 30 26 96 he +5 1 93 1 1 3 1613 1249 98 30 96 regains +5 1 93 1 1 4 1721 1249 37 26 96 his +5 1 93 1 1 5 1768 1250 103 31 96 footing. +2 1 94 0 0 0 2088 1252 490 32 -1 +3 1 94 1 0 0 2088 1252 490 32 -1 +4 1 94 1 1 0 2088 1252 490 32 -1 +5 1 94 1 1 1 2088 1252 113 30 96 exposed +5 1 94 1 1 2 2211 1252 62 27 96 skin, +5 1 94 1 1 3 2284 1252 81 30 96 giving +5 1 94 1 1 4 2375 1258 14 20 96 a +5 1 94 1 1 5 2399 1254 72 30 96 nasty +5 1 94 1 1 6 2481 1258 61 20 96 case +5 1 94 1 1 7 2552 1253 26 25 96 of +2 1 95 0 0 0 2686 1253 328 31 -1 +3 1 95 1 0 0 2686 1253 328 31 -1 +4 1 95 1 1 0 2686 1253 328 31 -1 +5 1 95 1 1 1 2686 1255 26 23 96 to +5 1 95 1 1 2 2722 1253 60 31 96 fight +5 1 95 1 1 3 2792 1253 35 26 96 off +5 1 95 1 1 4 2837 1253 177 31 95 hypothermia. +2 1 96 0 0 0 122 1286 141 39 -1 +3 1 96 1 0 0 122 1286 141 39 -1 +4 1 96 1 1 0 122 1286 141 39 -1 +5 1 96 1 1 1 122 1286 141 39 96 36-45 +2 1 97 0 0 0 904 1278 224 50 -1 +3 1 97 1 0 0 904 1278 224 50 -1 +4 1 97 1 1 0 904 1278 224 50 -1 +5 1 97 1 1 1 904 1282 38 26 96 for +5 1 97 1 1 2 950 1278 67 50 96 three +5 1 97 1 1 3 1030 1282 98 26 96 rounds. +2 1 98 0 0 0 2088 1286 120 26 -1 +3 1 98 1 0 0 2088 1286 120 26 -1 +4 1 98 1 1 0 2088 1286 120 26 -1 +5 1 98 1 1 1 2088 1286 120 26 96 frostbite. +2 1 99 0 0 0 2107 1355 543 32 -1 +3 1 99 1 0 0 2107 1355 543 32 -1 +4 1 99 1 1 0 2107 1355 543 32 -1 +5 1 99 1 1 1 2107 1355 17 25 96 if +5 1 99 1 1 2 2133 1357 55 23 92 wet: +5 1 99 1 1 3 2199 1355 72 26 77 +19H +5 1 99 1 1 4 2280 1368 19 5 77 — +5 1 99 1 1 5 2310 1357 30 22 40 &¥ +5 1 99 1 1 6 2350 1368 14 5 21 — +5 1 99 1 1 7 2378 1355 66 32 82 (-30) +5 1 99 1 1 8 2454 1368 19 5 74 — +5 1 99 1 1 9 2481 1356 169 31 73 -(3d10-20)P +2 1 100 0 0 0 736 1384 131 26 -1 +3 1 100 1 0 0 736 1384 131 26 -1 +4 1 100 1 1 0 736 1384 131 26 -1 +5 1 100 1 1 1 736 1384 80 26 70 +4H— +5 1 100 1 1 2 837 1386 30 23 60 x +2 1 101 0 0 0 1132 1387 323 32 -1 +3 1 101 1 0 0 1132 1387 323 32 -1 +4 1 101 1 1 0 1132 1387 323 32 -1 +5 1 101 1 1 1 1132 1387 53 25 75 +5H +5 1 101 1 1 2 1194 1400 17 4 74 — +5 1 101 1 1 3 1224 1389 29 21 41 £4 +5 1 101 1 1 4 1263 1387 192 32 49 --(2d10-18)P +2 1 102 0 0 0 1711 1389 340 31 -1 +3 1 102 1 0 0 1711 1389 340 31 -1 +4 1 102 1 1 0 1711 1389 340 31 -1 +5 1 102 1 1 1 1711 1389 53 26 89 +6H +5 1 102 1 1 2 1773 1402 19 4 84 — +5 1 102 1 1 3 1800 1390 50 25 39 2&4 +5 1 102 1 1 4 1859 1403 18 4 75 — +5 1 102 1 1 5 1885 1390 166 30 85 -(2d10-16)P +2 1 103 0 0 0 2113 1390 536 33 -1 +3 1 103 1 0 0 2113 1390 536 33 -1 +4 1 103 1 1 0 2113 1390 536 33 -1 +5 1 103 1 1 1 2113 1391 16 25 60 If +5 1 103 1 1 2 2139 1390 50 32 92 dry: +5 1 103 1 1 3 2201 1391 54 25 92 +8H +5 1 103 1 1 4 2264 1403 19 4 46 — +5 1 103 1 1 5 2290 1391 50 26 43 3%% +5 1 103 1 1 6 2350 1403 19 6 39 — +5 1 103 1 1 7 2379 1391 65 31 93 (-20) +5 1 103 1 1 8 2454 1404 19 5 49 — +5 1 103 1 1 9 2481 1391 168 32 90 -(3d10-20)P +2 1 104 0 0 0 2877 1393 359 32 -1 +3 1 104 1 0 0 2877 1393 359 32 -1 +4 1 104 1 1 0 2877 1393 359 32 -1 +5 1 104 1 1 1 2877 1393 51 26 32 22 +5 1 104 1 1 2 2937 1406 18 4 80 — +5 1 104 1 1 3 2965 1393 65 31 91 (-20) +5 1 104 1 1 4 3041 1406 18 4 27 — +5 1 104 1 1 5 3069 1393 167 32 89 -(4d10-26)P +2 1 105 0 0 0 318 1436 494 26 -1 +3 1 105 1 0 0 318 1436 494 26 -1 +4 1 105 1 1 0 318 1436 494 26 -1 +5 1 105 1 1 1 318 1436 71 26 93 Black +5 1 105 1 1 2 400 1436 63 26 91 biast +5 1 105 1 1 3 472 1437 69 25 96 casts +5 1 105 1 1 4 553 1437 40 25 96 foe +5 1 105 1 1 5 605 1436 23 26 95 in +5 1 105 1 1 6 638 1436 127 26 96 shadows. +5 1 105 1 1 7 778 1437 34 25 96 He +2 1 106 0 0 0 906 1437 515 32 -1 +3 1 106 1 0 0 906 1437 515 32 -1 +4 1 106 1 1 0 906 1437 515 32 -1 +5 1 106 1 1 1 906 1437 65 26 92 Biast +5 1 106 1 1 2 979 1439 100 30 91 target's +5 1 106 1 1 3 1089 1439 41 26 95 the +5 1 106 1 1 4 1140 1440 84 25 96 center +5 1 106 1 1 5 1234 1440 26 25 93 of +5 1 106 1 1 6 1269 1440 63 26 88 foe’s +5 1 106 1 1 7 1342 1440 79 26 96 chest. +2 1 107 0 0 0 1490 1440 512 29 -1 +3 1 107 1 0 0 1490 1440 512 29 -1 +4 1 107 1 1 0 1490 1440 512 29 -1 +5 1 107 1 1 1 1490 1440 35 26 96 An +5 1 107 1 1 2 1537 1440 95 26 96 intense +5 1 107 1 1 3 1643 1440 64 28 96 blast +5 1 107 1 1 4 1717 1442 25 26 96 of +5 1 107 1 1 5 1751 1442 56 26 96 void +5 1 107 1 1 6 1817 1442 49 26 96 hits +5 1 107 1 1 7 1875 1442 42 26 96 foe +5 1 107 1 1 8 1928 1442 23 26 96 in +5 1 107 1 1 9 1960 1443 42 26 96 the +2 1 108 0 0 0 2087 1442 524 33 -1 +3 1 108 1 0 0 2087 1442 524 33 -1 +4 1 108 1 1 0 2087 1442 524 33 -1 +5 1 108 1 1 1 2087 1442 49 27 96 The +5 1 108 1 1 2 2146 1443 59 26 96 dark +5 1 108 1 1 3 2215 1443 55 26 96 cold +5 1 108 1 1 4 2281 1443 67 32 96 grips +5 1 108 1 1 5 2358 1443 64 28 47 foe's +5 1 108 1 1 6 2432 1443 80 28 96 shield +5 1 108 1 1 7 2522 1450 60 21 95 arm. +5 1 108 1 1 8 2595 1445 16 24 88 If +2 1 109 0 0 0 2684 1436 490 40 -1 +3 1 109 1 0 0 2684 1436 490 40 -1 +4 1 109 1 1 0 2684 1436 490 40 -1 +5 1 109 1 1 1 2684 1445 50 26 96 The +5 1 109 1 1 2 2730 1432 78 50 96 blast +5 1 109 1 1 3 2813 1432 97 50 96 knocks +5 1 109 1 1 4 2921 1445 65 27 79 foe's +5 1 109 1 1 5 2996 1452 105 24 96 weapon +5 1 109 1 1 6 3109 1446 65 26 96 from +2 1 110 0 0 0 318 1472 187 27 -1 +3 1 110 1 0 0 318 1472 187 27 -1 +4 1 110 1 1 0 318 1472 187 27 -1 +5 1 110 1 1 1 318 1472 69 26 96 looks +5 1 110 1 1 2 396 1473 109 26 96 worried. +2 1 111 0 0 0 904 1473 477 32 -1 +3 1 111 1 0 0 904 1473 477 32 -1 +4 1 111 1 1 0 904 1473 477 32 -1 +5 1 111 1 1 1 904 1473 64 32 96 They +5 1 111 1 1 2 978 1481 56 20 96 now +5 1 111 1 1 3 1045 1475 61 26 96 have +5 1 111 1 1 4 1116 1481 14 20 95 a +5 1 111 1 1 5 1142 1482 89 20 96 reason +5 1 111 1 1 6 1241 1478 26 24 96 to +5 1 111 1 1 7 1276 1476 45 26 57 call +5 1 111 1 1 8 1332 1476 49 26 96 him +2 1 112 0 0 0 1490 1476 518 32 -1 +3 1 112 1 0 0 1490 1476 518 32 -1 +4 1 112 1 1 0 1490 1476 518 32 -1 +5 1 112 1 1 1 1490 1476 80 28 96 chest. +5 1 112 1 1 2 1580 1478 33 24 95 All +5 1 112 1 1 3 1624 1478 110 30 95 exposed +5 1 112 1 1 4 1745 1478 55 26 96 skin +5 1 112 1 1 5 1810 1479 22 26 96 is +5 1 112 1 1 6 1842 1479 108 26 93 scarred. +5 1 112 1 1 7 1961 1479 47 26 96 Foe +2 1 113 0 0 0 2088 1479 417 33 -1 +3 1 113 1 0 0 2088 1479 417 33 -1 +4 1 113 1 1 0 2088 1479 417 33 -1 +5 1 113 1 1 1 2088 1479 30 26 96 he +5 1 113 1 1 2 2129 1479 46 26 95 has +5 1 113 1 1 3 2185 1485 14 20 96 a +5 1 113 1 1 4 2209 1479 87 30 96 shield, +5 1 113 1 1 5 2307 1481 16 24 96 it +5 1 113 1 1 6 2333 1481 22 26 96 is +5 1 113 1 1 7 2365 1481 140 31 96 destroyed. +2 1 114 0 0 0 2686 1481 397 33 -1 +3 1 114 1 0 0 2686 1481 397 33 -1 +4 1 114 1 1 0 2686 1481 397 33 -1 +5 1 114 1 1 1 2686 1481 37 27 97 his +5 1 114 1 1 2 2733 1482 74 26 70 hand. +5 1 114 1 1 3 2818 1481 45 27 96 Foe +5 1 114 1 1 4 2875 1482 20 26 96 is +5 1 114 1 1 5 2905 1488 66 26 95 spun +5 1 114 1 1 6 2981 1482 102 26 95 around. +2 1 115 0 0 0 121 1494 141 40 -1 +3 1 115 1 0 0 121 1494 141 40 -1 +4 1 115 1 1 0 121 1494 141 40 -1 +5 1 115 1 1 1 121 1494 141 40 96 46-50 +2 1 116 0 0 0 906 1511 158 26 -1 +3 1 116 1 0 0 906 1511 158 26 -1 +4 1 116 1 1 0 906 1511 158 26 -1 +5 1 116 1 1 1 906 1511 69 26 71 Btack +5 1 116 1 1 2 986 1511 78 26 96 Heart. +2 1 117 0 0 0 1490 1514 199 26 -1 +3 1 117 1 0 0 1490 1514 199 26 -1 +4 1 117 1 1 0 1490 1514 199 26 -1 +5 1 117 1 1 1 1490 1514 71 26 63 looks +5 1 117 1 1 2 1571 1514 118 26 96 shocked! +2 1 118 0 0 0 2087 1515 403 30 -1 +3 1 118 1 0 0 2087 1515 403 30 -1 +4 1 118 1 1 0 2087 1515 403 30 -1 +5 1 118 1 1 1 2087 1515 142 30 96 Otherwise, +5 1 118 1 1 2 2238 1517 42 26 96 the +5 1 118 1 1 3 2289 1522 53 21 96 arm +5 1 118 1 1 4 2353 1517 20 26 96 is +5 1 118 1 1 5 2385 1517 105 26 96 useless. +2 1 119 0 0 0 579 1578 288 31 -1 +3 1 119 1 0 0 579 1578 288 31 -1 +4 1 119 1 1 0 579 1578 288 31 -1 +5 1 119 1 1 1 579 1578 52 26 91 +5H +5 1 119 1 1 2 641 1591 17 5 90 — +5 1 119 1 1 3 670 1580 30 21 0 £# +5 1 119 1 1 4 708 1591 19 5 53 — +5 1 119 1 1 5 736 1578 131 31 53 -(d40-9)P +2 1 120 0 0 0 1113 1580 343 32 -1 +3 1 120 1 0 0 1113 1580 343 32 -1 +4 1 120 1 1 0 1113 1580 343 32 -1 +5 1 120 1 1 1 1113 1580 53 26 88 +6H +5 1 120 1 1 2 1176 1594 18 3 80 — +5 1 120 1 1 3 1202 1581 51 26 56 3&4 +5 1 120 1 1 4 1263 1594 17 5 73 - +5 1 120 1 1 5 1289 1581 167 31 42 -(2410-16)P +2 1 121 0 0 0 1709 1583 342 32 -1 +3 1 121 1 0 0 1709 1583 342 32 -1 +4 1 121 1 1 0 1709 1583 342 32 -1 +5 1 121 1 1 1 1709 1583 55 26 91 +7H +5 1 121 1 1 2 1773 1596 17 4 37 — +5 1 121 1 1 3 1799 1584 50 25 0 2% +5 1 121 1 1 4 1858 1597 19 5 82 — +5 1 121 1 1 5 1885 1584 166 31 64 -(2d10-14)P +2 1 122 0 0 0 2203 1584 445 33 -1 +3 1 122 1 0 0 2203 1584 445 33 -1 +4 1 122 1 1 0 2203 1584 445 33 -1 +5 1 122 1 1 1 2203 1584 54 28 89 +8H +5 1 122 1 1 2 2265 1597 19 6 83 — +5 1 122 1 1 3 2291 1586 51 24 47 454 +5 1 122 1 1 4 2352 1599 17 4 11 — +5 1 122 1 1 5 2381 1586 64 30 91 (-10) +5 1 122 1 1 6 2454 1599 19 4 68 — +5 1 122 1 1 7 2481 1586 167 31 74 -(3d10-18)P +2 1 123 0 0 0 2893 1587 341 32 -1 +3 1 123 1 0 0 2893 1587 341 32 -1 +4 1 123 1 1 0 2893 1587 341 32 -1 +5 1 123 1 1 1 2893 1587 54 26 81 +9H +5 1 123 1 1 2 2955 1600 19 4 64 — +5 1 123 1 1 3 2983 1587 50 26 28 2%% +5 1 123 1 1 4 3042 1600 18 4 78 — +5 1 123 1 1 5 3069 1587 165 32 73 -(4d10-24)P +2 1 124 0 0 0 317 1633 455 29 -1 +3 1 124 1 0 0 317 1633 455 29 -1 +4 1 124 1 1 0 317 1633 455 29 -1 +5 1 124 1 1 1 317 1633 70 28 96 Good +5 1 124 1 1 2 397 1635 59 26 96 shot +5 1 124 1 1 3 466 1640 93 21 96 causes +5 1 124 1 1 4 569 1636 42 26 96 foe +5 1 124 1 1 5 619 1637 28 25 92 to +5 1 124 1 1 6 657 1636 115 26 92 stumbie. +2 1 125 0 0 0 903 1636 505 33 -1 +3 1 125 1 0 0 903 1636 505 33 -1 +4 1 125 1 1 0 903 1636 505 33 -1 +5 1 125 1 1 1 903 1636 49 26 95 The +5 1 125 1 1 2 963 1639 85 29 96 strong +5 1 125 1 1 3 1060 1637 63 26 93 blast +5 1 125 1 1 4 1132 1639 85 26 96 forces +5 1 125 1 1 5 1226 1639 41 26 96 foe +5 1 125 1 1 6 1279 1639 129 30 96 sideways. +2 1 126 0 0 0 1489 1639 494 32 -1 +3 1 126 1 0 0 1489 1639 494 32 -1 +4 1 126 1 1 0 1489 1639 494 32 -1 +5 1 126 1 1 1 1489 1639 88 26 96 Armor +5 1 126 1 1 2 1584 1640 65 26 96 does +5 1 126 1 1 3 1660 1642 42 24 96 not +5 1 126 1 1 4 1712 1642 95 29 96 protect +5 1 126 1 1 5 1816 1642 42 24 96 foe +5 1 126 1 1 6 1867 1642 64 26 96 from +5 1 126 1 1 7 1941 1642 42 26 96 the +2 1 127 0 0 0 2088 1642 481 27 -1 +3 1 127 1 0 0 2088 1642 481 27 -1 +4 1 127 1 1 0 2088 1642 481 27 -1 +5 1 127 1 1 1 2088 1642 45 26 96 Foe +5 1 127 1 1 2 2143 1642 22 26 96 is +5 1 127 1 1 3 2175 1642 112 27 96 knocked +5 1 127 1 1 4 2298 1642 62 26 96 back +5 1 127 1 1 5 2369 1643 16 26 96 5 +5 1 127 1 1 6 2395 1643 49 26 96 feet +5 1 127 1 1 7 2453 1643 64 26 96 from +5 1 127 1 1 8 2527 1643 42 26 96 the +2 1 128 0 0 0 2683 1643 473 32 -1 +3 1 128 1 0 0 2683 1643 473 32 -1 +4 1 128 1 1 0 2683 1643 473 32 -1 +5 1 128 1 1 1 2683 1643 49 26 96 The +5 1 128 1 1 2 2742 1643 55 28 96 cold +5 1 128 1 1 3 2810 1644 63 27 88 blast +5 1 128 1 1 4 2883 1645 48 26 96 hits +5 1 128 1 1 5 2941 1645 42 26 96 foe +5 1 128 1 1 6 2993 1645 23 24 96 in +5 1 128 1 1 7 3026 1645 40 26 95 the +5 1 128 1 1 8 3076 1646 80 29 95 torso, +2 1 129 0 0 0 903 1673 246 32 -1 +3 1 129 1 0 0 903 1673 246 32 -1 +4 1 129 1 1 0 903 1673 246 32 -1 +5 1 129 1 1 1 903 1673 50 32 96 Any +5 1 129 1 1 2 963 1673 78 26 95 shield +5 1 129 1 1 3 1054 1674 20 25 96 is +5 1 129 1 1 4 1086 1675 63 26 96 bent. +2 1 130 0 0 0 1489 1676 523 28 -1 +3 1 130 1 0 0 1489 1676 523 28 -1 +4 1 130 1 1 0 1489 1676 523 28 -1 +5 1 130 1 1 1 1489 1676 56 26 96 void +5 1 130 1 1 2 1554 1676 53 26 96 that +5 1 130 1 1 3 1617 1676 48 28 96 hits +5 1 130 1 1 4 1675 1676 39 28 96 his +5 1 130 1 1 5 1724 1678 78 26 96 chest. +5 1 130 1 1 6 1815 1678 46 26 96 Foe +5 1 130 1 1 7 1871 1678 44 26 95 will +5 1 130 1 1 8 1926 1678 61 26 94 have +5 1 130 1 1 9 1997 1684 15 20 94 a +2 1 131 0 0 0 2085 1678 502 29 -1 +3 1 131 1 0 0 2085 1678 502 29 -1 +4 1 131 1 1 0 2085 1678 502 29 -1 +5 1 131 1 1 1 2085 1678 59 27 96 dark +5 1 131 1 1 2 2156 1678 71 27 96 blast. +5 1 131 1 1 3 2239 1679 41 26 96 His +5 1 131 1 1 4 2291 1679 41 26 93 left +5 1 131 1 1 5 2342 1679 65 26 95 hand +5 1 131 1 1 6 2418 1679 22 27 95 is +5 1 131 1 1 7 2450 1679 102 28 96 cloaked +5 1 131 1 1 8 2563 1679 24 26 96 in +2 1 132 0 0 0 121 1694 140 39 -1 +3 1 132 1 0 0 121 1694 140 39 -1 +4 1 132 1 1 0 121 1694 140 39 -1 +5 1 132 1 1 1 121 1694 140 39 93 51-55 +2 1 133 0 0 0 2684 1681 404 31 -1 +3 1 133 1 0 0 2684 1681 404 31 -1 +4 1 133 1 1 0 2684 1681 404 31 -1 +5 1 133 1 1 1 2684 1681 120 31 96 knocking +5 1 133 1 1 2 2814 1681 42 26 96 the +5 1 133 1 1 3 2866 1681 63 26 96 wind +5 1 133 1 1 4 2941 1682 43 26 96 out +5 1 133 1 1 5 2994 1682 26 26 96 of +5 1 133 1 1 6 3030 1681 58 26 96 him. +2 1 134 0 0 0 1489 1715 218 29 -1 +3 1 134 1 0 0 1489 1715 218 29 -1 +4 1 134 1 1 0 1489 1715 218 29 -1 +5 1 134 1 1 1 1489 1715 144 29 96 permanent +5 1 134 1 1 2 1643 1720 64 20 95 scar. +2 1 135 0 0 0 2085 1715 306 28 -1 +3 1 135 1 0 0 2085 1715 306 28 -1 +4 1 135 1 1 0 2085 1715 306 28 -1 +5 1 135 1 1 1 2085 1715 121 28 96 darkness +5 1 135 1 1 2 2215 1717 40 26 96 for +5 1 135 1 1 3 2263 1717 15 26 96 2 +5 1 135 1 1 4 2290 1717 101 26 95 rounds. +2 1 136 0 0 0 560 1777 305 32 -1 +3 1 136 1 0 0 560 1777 305 32 -1 +4 1 136 1 1 0 560 1777 305 32 -1 +5 1 136 1 1 1 560 1777 52 26 91 +6H +5 1 136 1 1 2 622 1790 17 4 87 ~ +5 1 136 1 1 3 648 1777 49 26 37 28% +5 1 136 1 1 4 708 1790 18 4 46 — +5 1 136 1 1 5 734 1777 131 32 0 -(410-8)P +2 1 137 0 0 0 1112 1780 341 32 -1 +3 1 137 1 0 0 1112 1780 341 32 -1 +4 1 137 1 1 0 1112 1780 341 32 -1 +5 1 137 1 1 1 1112 1780 53 24 92 +7H +5 1 137 1 1 2 1175 1793 17 4 66 — +5 1 137 1 1 3 1201 1780 49 26 44 584 +5 1 137 1 1 4 1260 1793 17 4 28 - +5 1 137 1 1 5 1287 1780 166 32 35 -(2d10-14)P +2 1 138 0 0 0 1606 1781 443 33 -1 +3 1 138 1 0 0 1606 1781 443 33 -1 +4 1 138 1 1 0 1606 1781 443 33 -1 +5 1 138 1 1 1 1606 1781 52 26 82 +4H +5 1 138 1 1 2 1668 1794 17 5 39 — +5 1 138 1 1 3 1694 1781 50 29 45 3x4 +5 1 138 1 1 4 1753 1796 18 3 63 — +5 1 138 1 1 5 1781 1783 65 30 83 (-10) +5 1 138 1 1 6 1856 1796 17 4 59 - +5 1 138 1 1 7 1882 1783 167 31 65 -(2d10-12)P +2 1 139 0 0 0 2307 1784 340 32 -1 +3 1 139 1 0 0 2307 1784 340 32 -1 +4 1 139 1 1 0 2307 1784 340 32 -1 +5 1 139 1 1 1 2307 1784 52 26 89 +9H +5 1 139 1 1 2 2369 1797 17 5 45 — +5 1 139 1 1 3 2395 1784 50 26 44 68+ +5 1 139 1 1 4 2454 1797 19 5 82 — +5 1 139 1 1 5 2481 1784 166 32 87 -(3d10-16)P +2 1 140 0 0 0 2794 1786 438 31 -1 +3 1 140 1 0 0 2794 1786 438 31 -1 +4 1 140 1 1 0 2794 1786 438 31 -1 +5 1 140 1 1 1 2794 1786 71 26 91 +10H +5 1 140 1 1 2 2873 1799 19 4 47 -— +5 1 140 1 1 3 2899 1786 51 26 5 6% +5 1 140 1 1 4 2960 1799 18 4 74 — +5 1 140 1 1 5 2987 1786 45 27 75 2@ +5 1 140 1 1 6 3039 1799 19 5 27 - +5 1 140 1 1 7 3066 1787 166 30 72 -(4d10-22)P +2 1 141 0 0 0 317 1833 528 32 -1 +3 1 141 1 0 0 317 1833 528 32 -1 +4 1 141 1 1 0 317 1833 528 32 -1 +5 1 141 1 1 1 317 1833 44 26 96 Foe +5 1 141 1 1 2 371 1833 21 26 96 is +5 1 141 1 1 3 403 1835 94 24 96 shaken +5 1 141 1 1 4 507 1835 47 26 96 and +5 1 141 1 1 5 566 1835 98 30 96 pushed +5 1 141 1 1 6 677 1835 67 26 95 back. +5 1 141 1 1 7 757 1836 34 25 95 He +5 1 141 1 1 8 801 1836 44 25 88 will +2 1 142 0 0 0 904 1836 481 33 -1 +3 1 142 1 0 0 904 1836 481 33 -1 +4 1 142 1 1 0 904 1836 481 33 -1 +5 1 142 1 1 1 904 1836 45 26 96 Foe +5 1 142 1 1 2 959 1837 71 30 96 spins +5 1 142 1 1 3 1040 1839 26 23 96 to +5 1 142 1 1 4 1076 1839 41 29 96 get +5 1 142 1 1 5 1128 1839 41 24 96 out +5 1 142 1 1 6 1179 1837 26 26 96 of +5 1 142 1 1 7 1214 1837 42 26 96 the +5 1 142 1 1 8 1266 1843 59 26 96 way. +5 1 142 1 1 9 1336 1837 49 28 96 The +2 1 143 0 0 0 317 1871 348 27 -1 +3 1 143 1 0 0 317 1871 348 27 -1 +4 1 143 1 1 0 317 1871 348 27 -1 +5 1 143 1 1 1 317 1871 28 24 96 be +5 1 143 1 1 2 357 1876 68 21 96 more +5 1 143 1 1 3 435 1872 89 25 92 careful +5 1 143 1 1 4 536 1873 56 24 96 next +5 1 143 1 1 5 601 1872 64 26 95 time. +2 1 144 0 0 0 901 1873 494 28 -1 +3 1 144 1 0 0 901 1873 494 28 -1 +4 1 144 1 1 0 901 1873 494 28 -1 +5 1 144 1 1 1 901 1873 56 26 96 cold +5 1 144 1 1 2 969 1873 120 26 96 darkness +5 1 144 1 1 3 1099 1875 101 26 96 washes +5 1 144 1 1 4 1210 1881 59 20 96 over +5 1 144 1 1 5 1279 1875 37 26 96 his +5 1 144 1 1 6 1328 1875 67 26 96 back. +2 1 145 0 0 0 1489 1839 510 33 -1 +3 1 145 1 0 0 1489 1839 510 33 -1 +4 1 145 1 1 0 1489 1839 510 33 -1 +5 1 145 1 1 1 1489 1839 89 30 96 Strong +5 1 145 1 1 2 1591 1839 62 26 96 blast +5 1 145 1 1 3 1663 1840 48 26 96 hits +5 1 145 1 1 4 1720 1840 41 26 96 foe +5 1 145 1 1 5 1773 1840 23 25 95 in +5 1 145 1 1 6 1806 1840 60 31 95 legs, +5 1 145 1 1 7 1879 1840 120 32 96 knocking +2 1 146 0 0 0 2085 1842 496 32 -1 +3 1 146 1 0 0 2085 1842 496 32 -1 +4 1 146 1 1 0 2085 1842 496 32 -1 +5 1 146 1 1 1 2085 1842 48 26 96 The +5 1 146 1 1 2 2143 1842 73 26 96 strike +5 1 146 1 1 3 2228 1842 92 26 96 misses +5 1 146 1 1 4 2330 1843 41 25 96 foe +5 1 146 1 1 5 2381 1848 30 21 96 as +5 1 146 1 1 6 2421 1842 30 26 96 he +5 1 146 1 1 7 2460 1843 85 31 95 jumps +5 1 146 1 1 8 2553 1845 28 24 95 to +2 1 147 0 0 0 2682 1843 478 32 -1 +3 1 147 1 0 0 2682 1843 478 32 -1 +4 1 147 1 1 0 2682 1843 478 32 -1 +5 1 147 1 1 1 2682 1843 49 26 97 The +5 1 147 1 1 2 2742 1843 63 26 95 blast +5 1 147 1 1 3 2814 1845 26 24 97 of +5 1 147 1 1 4 2850 1844 164 31 96 nothingness +5 1 147 1 1 5 3025 1845 135 30 96 envelopes +2 1 148 0 0 0 1489 1875 520 34 -1 +3 1 148 1 0 0 1489 1875 520 34 -1 +4 1 148 1 1 0 1489 1875 520 34 -1 +5 1 148 1 1 1 1489 1875 49 26 96 him +5 1 148 1 1 2 1550 1876 62 26 96 back +5 1 148 1 1 3 1607 1871 22 42 96 5 +5 1 148 1 1 4 1646 1878 56 24 96 feet. +5 1 148 1 1 5 1715 1876 42 26 96 His +5 1 148 1 1 6 1768 1876 75 28 96 knees +5 1 148 1 1 7 1853 1884 42 20 96 are +5 1 148 1 1 8 1905 1878 104 31 94 wobbly. +2 1 149 0 0 0 2084 1878 523 30 -1 +3 1 149 1 0 0 2084 1878 523 30 -1 +4 1 149 1 1 0 2084 1878 523 30 -1 +5 1 149 1 1 1 2084 1878 42 26 96 the +5 1 149 1 1 2 2136 1878 62 30 96 side, +5 1 149 1 1 3 2209 1878 42 26 96 but +5 1 149 1 1 4 2260 1879 41 26 96 the +5 1 149 1 1 5 2313 1879 63 26 96 blast +5 1 149 1 1 6 2386 1879 103 26 96 catches +5 1 149 1 1 7 2499 1879 37 26 95 his +5 1 149 1 1 8 2546 1885 61 22 95 arm. +2 1 150 0 0 0 2682 1881 484 30 -1 +3 1 150 1 0 0 2682 1881 484 30 -1 +4 1 150 1 1 0 2682 1881 484 30 -1 +5 1 150 1 1 1 2682 1881 64 26 91 foe's +5 1 150 1 1 2 2756 1881 70 26 96 neck. +5 1 150 1 1 3 2837 1881 35 26 96 He +5 1 150 1 1 4 2882 1881 78 30 96 drops +5 1 150 1 1 5 2970 1881 124 27 96 whatever +5 1 150 1 1 6 3102 1881 30 27 96 he +5 1 150 1 1 7 3144 1881 22 27 92 is +2 1 151 0 0 0 903 1909 350 31 -1 +3 1 151 1 0 0 903 1909 350 31 -1 +4 1 151 1 1 0 903 1909 350 31 -1 +5 1 151 1 1 1 903 1909 33 26 96 He +5 1 151 1 1 2 947 1909 21 26 96 is +5 1 151 1 1 3 979 1911 95 29 96 pushes +5 1 151 1 1 4 1084 1912 26 23 96 to +5 1 151 1 1 5 1120 1911 38 26 96 his +5 1 151 1 1 6 1169 1911 84 26 96 knees. +2 1 152 0 0 0 2085 1914 453 27 -1 +3 1 152 1 0 0 2085 1914 453 27 -1 +4 1 152 1 1 0 2085 1914 453 27 -1 +5 1 152 1 1 1 2085 1914 41 27 95 His +5 1 152 1 1 2 2136 1921 53 19 95 arm +5 1 152 1 1 3 2199 1921 58 20 96 now +5 1 152 1 1 4 2267 1915 91 26 95 suffers +5 1 152 1 1 5 2368 1917 64 24 93 from +5 1 152 1 1 6 2444 1916 94 25 53 muscle +2 1 153 0 0 0 2682 1917 456 31 -1 +3 1 153 1 0 0 2682 1917 456 31 -1 +4 1 153 1 1 0 2682 1917 456 31 -1 +5 1 153 1 1 1 2682 1917 109 30 92 carrying +5 1 153 1 1 2 2800 1918 27 26 95 to +5 1 153 1 1 3 2837 1918 42 30 95 get +5 1 153 1 1 4 2888 1917 51 26 91 free +5 1 153 1 1 5 2949 1918 26 26 95 of +5 1 153 1 1 6 2984 1917 42 27 95 the +5 1 153 1 1 7 3034 1918 104 26 96 assault. +2 1 154 0 0 0 121 1928 141 40 -1 +3 1 154 1 0 0 121 1928 141 40 -1 +4 1 154 1 1 0 121 1928 141 40 -1 +5 1 154 1 1 1 121 1928 141 40 96 56-60 +2 1 155 0 0 0 2084 1951 114 30 -1 +3 1 155 1 0 0 2084 1951 114 30 -1 +4 1 155 1 1 0 2084 1951 114 30 -1 +5 1 155 1 1 1 2084 1951 114 30 96 damage. +2 1 156 0 0 0 1505 2012 543 33 -1 +3 1 156 1 0 0 1505 2012 543 33 -1 +4 1 156 1 1 0 1505 2012 543 33 -1 +5 1 156 1 1 1 1505 2012 56 26 96 with +5 1 156 1 1 2 1571 2013 38 30 94 leg +5 1 156 1 1 3 1620 2017 88 22 93 armor: +5 1 156 1 1 4 1721 2013 53 25 91 +4H +5 1 156 1 1 5 1784 2026 18 4 51 - +5 1 156 1 1 6 1815 2014 28 22 18 < +5 1 156 1 1 7 1853 2026 18 4 68 - +5 1 156 1 1 8 1879 2013 169 32 44 -(2d10-10)P +2 1 157 0 0 0 559 2045 305 31 -1 +3 1 157 1 0 0 559 2045 305 31 -1 +4 1 157 1 1 0 559 2045 305 31 -1 +5 1 157 1 1 1 559 2045 52 24 88 +7H +5 1 157 1 1 2 621 2058 18 4 31 -— +5 1 157 1 1 3 647 2045 48 26 66 38% +5 1 157 1 1 4 707 2058 17 4 41 - +5 1 157 1 1 5 733 2045 131 31 41 -(d10-7)P +2 1 158 0 0 0 1099 2048 351 31 -1 +3 1 158 1 0 0 1099 2048 351 31 -1 +4 1 158 1 1 0 1099 2048 351 31 -1 +5 1 158 1 1 1 1099 2048 53 24 71 +8H +5 1 158 1 1 2 1162 2061 17 2 83 — +5 1 158 1 1 3 1189 2048 49 26 25 5x4 +5 1 158 1 1 4 1248 2061 18 4 71 - +5 1 158 1 1 5 1285 2048 165 31 44 -(2d10-12)P +2 1 159 0 0 0 1516 2049 530 32 -1 +3 1 159 1 0 0 1516 2049 530 32 -1 +4 1 159 1 1 0 1516 2049 530 32 -1 +5 1 159 1 1 1 1516 2049 49 26 96 w/o +5 1 159 1 1 2 1575 2049 38 31 95 leg +5 1 159 1 1 3 1623 2055 88 20 93 armor: +5 1 159 1 1 4 1722 2049 54 26 91 +8H +5 1 159 1 1 5 1785 2062 18 4 89 — +5 1 159 1 1 6 1815 2051 30 23 0 <¢ +5 1 159 1 1 7 1853 2063 19 3 85 — +5 1 159 1 1 8 1881 2050 165 31 74 -(2d10-10)P +2 1 160 0 0 0 2287 2052 356 32 -1 +3 1 160 1 0 0 2287 2052 356 32 -1 +4 1 160 1 1 0 2287 2052 356 32 -1 +5 1 160 1 1 1 2287 2052 49 26 76 5&2 +5 1 160 1 1 2 2346 2065 19 4 83 — +5 1 160 1 1 3 2373 2052 65 31 85 (-15) +5 1 160 1 1 4 2449 2065 18 4 80 — +5 1 160 1 1 5 2476 2052 167 32 85 -(3d10-14)P +2 1 161 0 0 0 2705 2053 525 32 -1 +3 1 161 1 0 0 2705 2053 525 32 -1 +4 1 161 1 1 0 2705 2053 525 32 -1 +5 1 161 1 1 1 2705 2053 70 26 92 +10H +5 1 161 1 1 2 2784 2066 19 5 73 — +5 1 161 1 1 3 2811 2053 51 26 42 6X4 +5 1 161 1 1 4 2870 2066 19 5 74 — +5 1 161 1 1 5 2896 2053 29 26 74 @ +5 1 161 1 1 6 2934 2066 17 5 75 — +5 1 161 1 1 7 2961 2053 65 32 92 (-20) +5 1 161 1 1 8 3036 2066 19 5 71 — +5 1 161 1 1 9 3065 2055 165 30 73 -(4d10-20)P +2 1 162 0 0 0 315 2101 512 32 -1 +3 1 162 1 0 0 315 2101 512 32 -1 +4 1 162 1 1 0 315 2101 512 32 -1 +5 1 162 1 1 1 315 2101 45 26 96 Foe +5 1 162 1 1 2 371 2101 71 26 96 looks +5 1 162 1 1 3 452 2101 45 26 69 like +5 1 162 1 1 4 507 2108 13 19 96 a +5 1 162 1 1 5 530 2102 80 25 96 clown +5 1 162 1 1 6 619 2102 78 31 96 trying +5 1 162 1 1 7 707 2104 26 24 96 to +5 1 162 1 1 8 743 2102 84 31 96 dodge +2 1 163 0 0 0 901 2104 499 27 -1 +3 1 163 1 0 0 901 2104 499 27 -1 +4 1 163 1 1 0 901 2104 499 27 -1 +5 1 163 1 1 1 901 2104 48 26 96 The +5 1 163 1 1 2 960 2104 123 26 96 immense +5 1 163 1 1 3 1093 2105 55 25 96 cold +5 1 163 1 1 4 1159 2111 92 20 96 causes +5 1 163 1 1 5 1261 2105 64 26 85 foe's +5 1 163 1 1 6 1336 2107 64 24 96 hand +2 1 164 0 0 0 1489 2107 485 31 -1 +3 1 164 1 0 0 1489 2107 485 31 -1 +4 1 164 1 1 0 1489 2107 485 31 -1 +5 1 164 1 1 1 1489 2107 55 30 96 Inky +5 1 164 1 1 2 1552 2107 121 26 96 darkness +5 1 164 1 1 3 1682 2108 79 25 96 freeze +5 1 164 1 1 4 1773 2107 53 26 96 dies +5 1 164 1 1 5 1836 2108 28 25 96 all +5 1 164 1 1 6 1875 2108 99 30 96 organic +2 1 165 0 0 0 2084 2108 494 32 -1 +3 1 165 1 0 0 2084 2108 494 32 -1 +4 1 165 1 1 0 2084 2108 494 32 -1 +5 1 165 1 1 1 2084 2108 47 26 96 The +5 1 165 1 1 2 2141 2109 122 26 96 darkness +5 1 165 1 1 3 2274 2109 147 31 49 completely +5 1 165 1 1 4 2431 2109 72 26 86 melts +5 1 165 1 1 5 2513 2109 65 26 78 foe's +2 1 166 0 0 0 2680 2109 478 29 -1 +3 1 166 1 0 0 2680 2109 478 29 -1 +4 1 166 1 1 0 2680 2109 478 29 -1 +5 1 166 1 1 1 2680 2109 49 28 96 The +5 1 166 1 1 2 2739 2109 55 28 96 void +5 1 166 1 1 3 2807 2111 78 26 93 blasts +5 1 166 1 1 4 2893 2111 65 26 86 foe’s +5 1 166 1 1 5 2967 2111 78 26 96 waist. +5 1 166 1 1 6 3058 2111 41 27 96 His +5 1 166 1 1 7 3111 2111 47 26 33 belt +2 1 167 0 0 0 314 2138 488 31 -1 +3 1 167 1 0 0 314 2138 488 31 -1 +4 1 167 1 1 0 314 2138 488 31 -1 +5 1 167 1 1 1 314 2143 62 24 96 your +5 1 167 1 1 2 384 2138 152 31 96 well-placed +5 1 167 1 1 3 549 2138 70 26 96 blast. +5 1 167 1 1 4 632 2138 33 26 96 He +5 1 167 1 1 5 675 2138 78 31 95 drops +5 1 167 1 1 6 765 2138 37 26 96 his +2 1 168 0 0 0 900 2140 311 27 -1 +3 1 168 1 0 0 900 2140 311 27 -1 +4 1 168 1 1 0 900 2140 311 27 -1 +5 1 168 1 1 1 900 2141 26 25 95 to +5 1 168 1 1 2 937 2140 54 26 95 lock +5 1 168 1 1 3 1001 2141 37 25 92 for +5 1 168 1 1 4 1048 2141 51 25 90 d10 +5 1 168 1 1 5 1110 2141 101 26 96 rounds. +2 1 169 0 0 0 1488 2143 482 31 -1 +3 1 169 1 0 0 1488 2143 482 31 -1 +4 1 169 1 1 0 1488 2143 482 31 -1 +5 1 169 1 1 1 1488 2143 106 26 77 material +5 1 169 1 1 2 1606 2148 31 21 92 on +5 1 169 1 1 3 1647 2143 64 26 89 foe’s +5 1 169 1 1 4 1722 2143 68 26 96 back. +5 1 169 1 1 5 1803 2144 33 26 97 He +5 1 169 1 1 6 1848 2145 122 29 96 staggers. +2 1 170 0 0 0 2084 2144 546 32 -1 +3 1 170 1 0 0 2084 2144 546 32 -1 +4 1 170 1 1 0 2084 2144 546 32 -1 +5 1 170 1 1 1 2084 2144 85 26 95 shield. +5 1 170 1 1 2 2183 2145 16 25 92 If +5 1 170 1 1 3 2209 2147 43 24 96 not +5 1 170 1 1 4 2262 2145 100 31 91 holding +5 1 170 1 1 5 2372 2151 14 20 96 a +5 1 170 1 1 6 2396 2145 87 29 93 shield, +5 1 170 1 1 7 2494 2145 41 26 96 the +5 1 170 1 1 8 2545 2151 52 20 96 arm +5 1 170 1 1 9 2608 2145 22 28 77 ts +2 1 171 0 0 0 2680 2147 529 30 -1 +3 1 171 1 0 0 2680 2147 529 30 -1 +4 1 171 1 1 0 2680 2147 529 30 -1 +5 1 171 1 1 1 2680 2147 48 26 96 and +5 1 171 1 1 2 2738 2153 49 24 96 any +5 1 171 1 1 3 2795 2147 71 26 96 other +5 1 171 1 1 4 2876 2147 143 30 96 equipment +5 1 171 1 1 5 3027 2147 69 27 96 there +5 1 171 1 1 6 3106 2148 103 26 93 freezes. +2 1 172 0 0 0 119 2176 140 39 -1 +3 1 172 1 0 0 119 2176 140 39 -1 +4 1 172 1 1 0 119 2176 140 39 -1 +5 1 172 1 1 1 119 2176 140 39 96 61-65 +2 1 173 0 0 0 314 2180 101 24 -1 +3 1 173 1 0 0 314 2180 101 24 -1 +4 1 173 1 1 0 314 2180 101 24 -1 +5 1 173 1 1 1 314 2180 101 24 64 Weapon. +2 1 174 0 0 0 2084 2181 379 32 -1 +3 1 174 1 0 0 2084 2181 379 32 -1 +4 1 174 1 1 0 2084 2181 379 32 -1 +5 1 174 1 1 1 2084 2181 98 26 96 useless +5 1 174 1 1 2 2192 2181 63 26 96 from +5 1 174 1 1 3 2267 2187 73 20 95 nerve +5 1 174 1 1 4 2349 2181 114 32 94 damage. +2 1 175 0 0 0 2682 2183 419 29 -1 +3 1 175 1 0 0 2682 2183 419 29 -1 +4 1 175 1 1 0 2682 2183 419 29 -1 +5 1 175 1 1 1 2682 2183 16 24 78 It +5 1 175 1 1 2 2706 2183 42 26 95 foe +5 1 175 1 1 3 2759 2189 95 23 95 moves, +5 1 175 1 1 4 2865 2183 30 26 96 all +5 1 175 1 1 5 2905 2183 45 26 90 will +5 1 175 1 1 6 2961 2184 30 26 96 be +5 1 175 1 1 7 3001 2184 100 26 92 broken. +2 1 176 0 0 0 371 2236 493 33 -1 +3 1 176 1 0 0 371 2236 493 33 -1 +4 1 176 1 1 0 371 2236 493 33 -1 +5 1 176 1 1 1 371 2236 55 25 93 with +5 1 176 1 1 2 438 2242 111 24 90 greaves: +5 1 176 1 1 3 562 2238 53 24 36 +1H +5 1 176 1 1 4 625 2250 17 3 36 — +5 1 176 1 1 5 662 2239 30 21 0 <2 +5 1 176 1 1 6 703 2250 17 3 40 — +5 1 176 1 1 7 730 2238 134 31 91 -(d10-6)P +2 1 177 0 0 0 2134 2244 509 32 -1 +3 1 177 1 0 0 2134 2244 509 32 -1 +4 1 177 1 1 0 2134 2244 509 32 -1 +5 1 177 1 1 1 2134 2244 57 25 96 with +5 1 177 1 1 2 2201 2245 86 25 93 shield: +5 1 177 1 1 3 2300 2245 71 24 89 +11H +5 1 177 1 1 4 2381 2256 17 5 34 — +5 1 177 1 1 5 2409 2245 29 23 42 <¢ +5 1 177 1 1 6 2450 2258 17 4 46 ~ +5 1 177 1 1 7 2476 2245 167 31 89 -(3d10-12)P +2 1 178 0 0 0 371 2273 493 32 -1 +3 1 178 1 0 0 371 2273 493 32 -1 +4 1 178 1 1 0 371 2273 493 32 -1 +5 1 178 1 1 1 371 2273 49 25 93 w/o +5 1 178 1 1 2 429 2279 112 25 92 greaves: +5 1 178 1 1 3 554 2273 52 26 62 +8H +5 1 178 1 1 4 616 2286 18 5 33 - +5 1 178 1 1 5 644 2273 49 26 7 2x% +5 1 178 1 1 6 703 2286 17 5 61 — +5 1 178 1 1 7 730 2273 134 32 91 -(d10-6)P +2 1 179 0 0 0 959 2275 491 33 -1 +3 1 179 1 0 0 959 2275 491 33 -1 +4 1 179 1 1 0 959 2275 491 33 -1 +5 1 179 1 1 1 959 2275 53 26 92 +9H +5 1 179 1 1 2 1021 2288 18 4 13 — +5 1 179 1 1 3 1048 2276 49 25 46 5X2 +5 1 179 1 1 4 1109 2288 17 4 52 ~ +5 1 179 1 1 5 1133 2275 20 27 52 @ +5 1 179 1 1 6 1171 2289 16 5 64 ~ +5 1 179 1 1 7 1198 2276 48 31 83 (-5) +5 1 179 1 1 8 1256 2289 17 5 32 - +5 1 179 1 1 9 1283 2276 167 32 61 -(2d10-10)P +2 1 180 0 0 0 1707 2279 339 32 -1 +3 1 180 1 0 0 1707 2279 339 32 -1 +4 1 180 1 1 0 1707 2279 339 32 -1 +5 1 180 1 1 1 1707 2279 69 26 87 +10H +5 1 180 1 1 2 1786 2291 17 4 46 — +5 1 180 1 1 3 1813 2279 48 26 0 54 +5 1 180 1 1 4 1872 2292 17 5 61 - +5 1 180 1 1 5 1898 2279 148 32 72 -(2d10-8)P +2 1 181 0 0 0 2262 2281 381 30 -1 +3 1 181 1 0 0 2262 2281 381 30 -1 +4 1 181 1 1 0 2262 2281 381 30 -1 +5 1 181 1 1 1 2262 2281 48 26 91 w/o +5 1 181 1 1 2 2320 2281 87 26 96 shield: +5 1 181 1 1 3 2417 2281 50 26 6 6+ +5 1 181 1 1 4 2477 2281 166 30 87 -(3d10-12)P +2 1 182 0 0 0 2767 2282 462 32 -1 +3 1 182 1 0 0 2767 2282 462 32 -1 +4 1 182 1 1 0 2767 2282 462 32 -1 +5 1 182 1 1 1 2767 2282 70 26 89 +12H +5 1 182 1 1 2 2847 2294 18 5 40 - +5 1 182 1 1 3 2873 2282 49 26 65 4&4 +5 1 182 1 1 4 2932 2295 19 4 82 — +5 1 182 1 1 5 2961 2282 65 32 89 (-30) +5 1 182 1 1 6 3036 2295 17 4 59 — +5 1 182 1 1 7 3062 2282 167 32 86 -(4d10-16)P +2 1 183 0 0 0 315 2330 470 31 -1 +3 1 183 1 0 0 315 2330 470 31 -1 +4 1 183 1 1 0 315 2330 470 31 -1 +5 1 183 1 1 1 315 2330 64 26 97 Blast +5 1 183 1 1 2 390 2331 88 26 97 strikes +5 1 183 1 1 3 488 2331 65 26 93 foe's +5 1 183 1 1 4 563 2331 86 26 96 throat. +5 1 183 1 1 5 662 2331 35 26 96 He +5 1 183 1 1 6 707 2331 78 30 96 drops +2 1 184 0 0 0 901 2332 503 34 -1 +3 1 184 1 0 0 901 2332 503 34 -1 +4 1 184 1 1 0 901 2332 503 34 -1 +5 1 184 1 1 1 901 2332 58 26 96 Cold +5 1 184 1 1 2 971 2332 121 26 96 darkness +5 1 184 1 1 3 1103 2334 78 26 96 blasts +5 1 184 1 1 4 1191 2334 47 26 96 foe. +5 1 184 1 1 5 1250 2334 33 26 93 All +5 1 184 1 1 6 1295 2335 109 31 96 exposed +2 1 185 0 0 0 1488 2334 462 33 -1 +3 1 185 1 0 0 1488 2334 462 33 -1 +4 1 185 1 1 0 1488 2334 462 33 -1 +5 1 185 1 1 1 1488 2334 44 27 96 Foe +5 1 185 1 1 2 1542 2335 21 26 96 is +5 1 185 1 1 3 1573 2335 95 26 96 thrown +5 1 185 1 1 4 1676 2337 26 24 96 to +5 1 185 1 1 5 1712 2337 41 24 97 the +5 1 185 1 1 6 1763 2337 95 30 93 ground +5 1 185 1 1 7 1869 2337 31 30 96 by +5 1 185 1 1 8 1908 2337 42 26 96 the +2 1 186 0 0 0 2082 2337 487 33 -1 +3 1 186 1 0 0 2082 2337 487 33 -1 +4 1 186 1 1 0 2082 2337 487 33 -1 +5 1 186 1 1 1 2082 2337 70 27 58 Foe's +5 1 186 1 1 2 2160 2338 68 26 96 teeth +5 1 186 1 1 3 2237 2338 80 26 96 freeze +5 1 186 1 1 4 2328 2338 63 28 96 solid +5 1 186 1 1 5 2401 2338 49 26 95 and +5 1 186 1 1 6 2461 2338 39 26 95 his +5 1 186 1 1 7 2510 2344 59 26 96 eyes +2 1 187 0 0 0 2679 2338 488 32 -1 +3 1 187 1 0 0 2679 2338 488 32 -1 +4 1 187 1 1 0 2679 2338 488 32 -1 +5 1 187 1 1 1 2679 2338 49 28 96 The +5 1 187 1 1 2 2739 2340 62 26 96 blast +5 1 187 1 1 3 2810 2341 93 29 96 targets +5 1 187 1 1 4 2913 2340 65 26 74 foe's +5 1 187 1 1 5 2988 2341 62 25 96 face. +5 1 187 1 1 6 3063 2340 33 27 96 He +5 1 187 1 1 7 3108 2345 59 22 96 sees +2 1 188 0 0 0 312 2366 520 31 -1 +3 1 188 1 0 0 312 2366 520 31 -1 +4 1 188 1 1 0 312 2366 520 31 -1 +5 1 188 1 1 1 312 2366 116 31 96 anything +5 1 188 1 1 2 439 2367 30 25 96 he +5 1 188 1 1 3 479 2367 21 25 96 is +5 1 188 1 1 4 511 2367 98 30 73 holding +5 1 188 1 1 5 621 2369 26 24 96 to +5 1 188 1 1 6 655 2369 36 28 96 try +5 1 188 1 1 7 701 2367 48 26 96 and +5 1 188 1 1 8 762 2368 70 25 96 block +2 1 189 0 0 0 900 2368 523 33 -1 +3 1 189 1 0 0 900 2368 523 33 -1 +4 1 189 1 1 0 900 2368 523 33 -1 +5 1 189 1 1 1 900 2368 63 26 92 flesh +5 1 189 1 1 2 973 2370 93 24 96 suffers +5 1 189 1 1 3 1076 2370 63 24 96 from +5 1 189 1 1 4 1149 2370 120 26 95 frostbite. +5 1 189 1 1 5 1282 2370 34 26 95 He +5 1 189 1 1 6 1326 2370 20 26 96 is +5 1 189 1 1 7 1358 2371 65 30 96 quite +2 1 190 0 0 0 1488 2371 422 28 -1 +3 1 190 1 0 0 1488 2371 422 28 -1 +4 1 190 1 1 0 1488 2371 422 28 -1 +5 1 190 1 1 1 1488 2371 67 26 92 blast. +5 1 190 1 1 2 1568 2371 33 26 96 He +5 1 190 1 1 3 1613 2372 89 25 96 strains +5 1 190 1 1 4 1712 2371 38 26 96 his +5 1 190 1 1 5 1760 2373 66 24 95 wrist +5 1 190 1 1 6 1836 2373 23 24 96 in +5 1 190 1 1 7 1868 2373 42 26 96 the +2 1 191 0 0 0 2081 2374 436 32 -1 +3 1 191 1 0 0 2081 2374 436 32 -1 +4 1 191 1 1 0 2081 2374 436 32 -1 +5 1 191 1 1 1 2081 2374 79 26 96 freeze +5 1 191 1 1 2 2171 2374 58 26 96 shut +5 1 191 1 1 3 2241 2374 47 30 90 (for +5 1 191 1 1 4 2297 2374 52 26 89 d10 +5 1 191 1 1 5 2359 2374 112 32 96 rounds). +5 1 191 1 1 6 2484 2374 33 26 96 He +2 1 192 0 0 0 2677 2376 502 30 -1 +3 1 192 1 0 0 2677 2376 502 30 -1 +4 1 192 1 1 0 2677 2376 502 30 -1 +5 1 192 1 1 1 2677 2376 42 26 96 the +5 1 192 1 1 2 2729 2376 56 26 97 cold +5 1 192 1 1 3 2795 2376 94 30 96 fingers +5 1 192 1 1 4 2899 2376 26 26 97 of +5 1 192 1 1 5 2934 2376 82 26 96 death. +5 1 192 1 1 6 3029 2376 91 27 96 Nerves +5 1 192 1 1 7 3130 2377 49 26 95 and +2 1 193 0 0 0 312 2403 147 26 -1 +3 1 193 1 0 0 312 2403 147 26 -1 +4 1 193 1 1 0 312 2403 147 26 -1 +5 1 193 1 1 1 312 2403 41 25 96 the +5 1 193 1 1 2 361 2409 75 20 92 assault. +2 1 194 0 0 0 900 2406 84 26 -1 +3 1 194 1 0 0 900 2406 84 26 -1 +4 1 194 1 1 0 900 2406 84 26 -1 +5 1 194 1 1 1 900 2406 84 26 92 dazed. +2 1 195 0 0 0 1486 2413 113 26 -1 +3 1 195 1 0 0 1486 2413 113 26 -1 +4 1 195 1 1 0 1486 2413 113 26 -1 +5 1 195 1 1 1 1486 2413 113 26 96 process. +2 1 196 0 0 0 2081 2410 454 30 -1 +3 1 196 1 0 0 2081 2410 454 30 -1 +4 1 196 1 1 0 2081 2410 454 30 -1 +5 1 196 1 1 1 2081 2410 121 30 92 instinctly +5 1 196 1 1 2 2213 2410 62 26 96 bites +5 1 196 1 1 3 2286 2410 73 26 91 down +5 1 196 1 1 4 2369 2410 48 28 96 and +5 1 196 1 1 5 2428 2412 107 26 96 shatters +2 1 197 0 0 0 2680 2412 303 31 -1 +3 1 197 1 0 0 2680 2412 303 31 -1 +4 1 197 1 1 0 2680 2412 303 31 -1 +5 1 197 1 1 1 2680 2412 110 26 72 muscies +5 1 197 1 1 2 2800 2417 42 22 96 are +5 1 197 1 1 3 2852 2412 131 31 96 damaged. +2 1 198 0 0 0 158 2409 59 40 -1 +3 1 198 1 0 0 158 2409 59 40 -1 +4 1 198 1 1 0 158 2409 59 40 -1 +5 1 198 1 1 1 158 2409 59 40 96 66 +2 1 199 0 0 0 2081 2446 258 28 -1 +3 1 199 1 0 0 2081 2446 258 28 -1 +4 1 199 1 1 0 2081 2446 258 28 -1 +5 1 199 1 1 1 2081 2446 89 27 95 almost +5 1 199 1 1 2 2180 2448 28 25 96 all +5 1 199 1 1 3 2219 2448 38 24 96 his +5 1 199 1 1 4 2265 2448 74 26 96 teeth. +2 1 200 0 0 0 367 2508 497 32 -1 +3 1 200 1 0 0 367 2508 497 32 -1 +4 1 200 1 1 0 367 2508 497 32 -1 +5 1 200 1 1 1 367 2508 52 25 92 +9H +5 1 200 1 1 2 429 2521 17 3 81 — +5 1 200 1 1 3 456 2508 49 25 5 4&% +5 1 200 1 1 4 517 2521 17 4 56 — +5 1 200 1 1 5 543 2508 44 26 77 3@ +5 1 200 1 1 6 603 2509 94 29 27 —(-15) +5 1 200 1 1 7 716 2521 17 4 46 — +5 1 200 1 1 8 743 2509 121 31 46 -(2d10)P +2 1 201 0 0 0 1161 2511 288 32 -1 +3 1 201 1 0 0 1161 2511 288 32 -1 +4 1 201 1 1 0 1161 2511 288 32 -1 +5 1 201 1 1 1 1161 2511 49 26 0 BSt +5 1 201 1 1 2 1220 2524 18 4 48 — +5 1 201 1 1 3 1247 2511 45 26 86 4@ +5 1 201 1 1 4 1300 2524 17 4 64 — +5 1 201 1 1 5 1328 2512 121 31 35 -(2010)P +2 1 202 0 0 0 1717 2514 328 31 -1 +3 1 202 1 0 0 1717 2514 328 31 -1 +4 1 202 1 1 0 1717 2514 328 31 -1 +5 1 202 1 1 1 1717 2514 66 24 32 118 +5 1 202 1 1 2 1793 2527 17 4 44 ~ +5 1 202 1 1 3 1822 2514 64 30 88 (-15) +5 1 202 1 1 4 1897 2527 17 4 41 - +5 1 202 1 1 5 1923 2514 122 31 29 -(3410)P +2 1 203 0 0 0 2353 2515 287 32 -1 +3 1 203 1 0 0 2353 2515 287 32 -1 +4 1 203 1 1 0 2353 2515 287 32 -1 +5 1 203 1 1 1 2353 2515 49 26 0 Q&x +5 1 203 1 1 2 2412 2528 18 5 72 - +5 1 203 1 1 3 2440 2515 63 32 87 (-30) +5 1 203 1 1 4 2515 2528 17 5 87 - +5 1 203 1 1 5 2541 2517 99 30 69 -(4d10) +2 1 204 0 0 0 2805 2517 422 31 -1 +3 1 204 1 0 0 2805 2517 422 31 -1 +4 1 204 1 1 0 2805 2517 422 31 -1 +5 1 204 1 1 1 2805 2517 71 26 0 +14H +5 1 204 1 1 2 2895 2530 17 5 21 — +5 1 204 1 1 3 2922 2517 66 27 54 132% +5 1 204 1 1 4 2999 2530 18 5 82 — +5 1 204 1 1 5 3026 2517 23 27 0 8 +5 1 204 1 1 6 3079 2530 17 5 28 - +5 1 204 1 1 7 3105 2518 122 30 75 -(5d10)P +2 1 205 0 0 0 317 2564 462 33 -1 +3 1 205 1 0 0 317 2564 462 33 -1 +4 1 205 1 1 0 317 2564 462 33 -1 +5 1 205 1 1 1 317 2564 63 28 90 Blast +5 1 205 1 1 2 390 2566 100 30 93 engulfs +5 1 205 1 1 3 498 2567 65 26 74 foe’s +5 1 205 1 1 4 574 2567 60 26 96 side. +5 1 205 1 1 5 645 2567 52 30 96 Any +5 1 205 1 1 6 708 2567 71 26 64 metal +2 1 206 0 0 0 901 2567 486 33 -1 +3 1 206 1 0 0 901 2567 486 33 -1 +4 1 206 1 1 0 901 2567 486 33 -1 +5 1 206 1 1 1 901 2567 45 27 96 Foe +5 1 206 1 1 2 958 2568 122 31 96 struggles +5 1 206 1 1 3 1090 2570 27 24 96 to +5 1 206 1 1 4 1128 2569 60 31 96 keep +5 1 206 1 1 5 1198 2570 42 26 93 the +5 1 206 1 1 6 1251 2570 62 26 90 biast +5 1 206 1 1 7 1323 2570 64 26 96 from +2 1 207 0 0 0 1486 2570 474 33 -1 +3 1 207 1 0 0 1486 2570 474 33 -1 +4 1 207 1 1 0 1486 2570 474 33 -1 +5 1 207 1 1 1 1486 2570 68 27 57 Foe’s +5 1 207 1 1 2 1564 2577 104 25 95 weapon +5 1 207 1 1 3 1678 2577 52 20 93 arm +5 1 207 1 1 4 1743 2571 20 26 96 is +5 1 207 1 1 5 1774 2571 100 32 96 gripped +5 1 207 1 1 6 1885 2573 23 24 96 in +5 1 207 1 1 7 1918 2573 42 26 96 the +2 1 208 0 0 0 2081 2573 470 32 -1 +3 1 208 1 0 0 2081 2573 470 32 -1 +4 1 208 1 1 0 2081 2573 470 32 -1 +5 1 208 1 1 1 2081 2573 48 26 96 The +5 1 208 1 1 2 2139 2573 121 27 96 darkness +5 1 208 1 1 3 2270 2573 66 27 96 finds +5 1 208 1 1 4 2347 2573 29 27 96 its +5 1 208 1 1 5 2386 2580 55 25 96 way +5 1 208 1 1 6 2451 2574 49 26 96 into +5 1 208 1 1 7 2510 2574 41 26 96 the +2 1 209 0 0 0 2679 2574 527 32 -1 +3 1 209 1 0 0 2679 2574 527 32 -1 +4 1 209 1 1 0 2679 2574 527 32 -1 +5 1 209 1 1 1 2679 2574 44 28 95 Foe +5 1 209 1 1 2 2733 2576 117 30 96 attempts +5 1 209 1 1 3 2860 2577 26 25 96 to +5 1 209 1 1 4 2898 2576 70 26 96 block +5 1 209 1 1 5 2977 2576 42 26 96 the +5 1 209 1 1 6 3029 2576 63 26 76 blast +5 1 209 1 1 7 3101 2576 56 26 96 with +5 1 209 1 1 8 3168 2576 38 27 96 his +2 1 210 0 0 0 315 2603 292 26 -1 +3 1 210 1 0 0 315 2603 292 26 -1 +4 1 210 1 1 0 315 2603 292 26 -1 +5 1 210 1 1 1 315 2603 66 25 96 there +5 1 210 1 1 2 394 2603 119 26 96 becomes +5 1 210 1 1 3 525 2603 82 26 95 brittle. +2 1 211 0 0 0 901 2604 445 32 -1 +3 1 211 1 0 0 901 2604 445 32 -1 +4 1 211 1 1 0 901 2604 445 32 -1 +5 1 211 1 1 1 901 2604 57 26 92 him. +5 1 211 1 1 2 971 2605 40 25 96 His +5 1 211 1 1 3 1022 2612 67 20 92 arms +5 1 211 1 1 4 1099 2606 47 26 92 flait +5 1 211 1 1 5 1156 2606 85 30 96 wildly, +5 1 211 1 1 6 1254 2607 41 25 96 but +5 1 211 1 1 7 1306 2607 40 26 96 the +2 1 212 0 0 0 1483 2607 527 28 -1 +3 1 212 1 0 0 1483 2607 527 28 -1 +4 1 212 1 1 0 1483 2607 527 28 -1 +5 1 212 1 1 1 1483 2607 64 26 91 void. +5 1 212 1 1 2 1561 2607 16 26 90 It +5 1 212 1 1 3 1586 2609 95 26 96 freezes +5 1 212 1 1 4 1689 2609 49 24 96 and +5 1 212 1 1 5 1748 2609 44 24 96 will +5 1 212 1 1 6 1804 2609 29 26 96 be +5 1 212 1 1 7 1845 2609 98 26 89 useless +5 1 212 1 1 8 1956 2609 54 26 91 until +2 1 213 0 0 0 900 2642 349 26 -1 +3 1 213 1 0 0 900 2642 349 26 -1 +4 1 213 1 1 0 900 2642 349 26 -1 +5 1 213 1 1 1 900 2642 79 24 96 attack +5 1 213 1 1 2 988 2642 46 24 96 has +5 1 213 1 1 3 1044 2642 137 26 96 connected +5 1 213 1 1 4 1191 2642 58 26 95 well. +2 1 214 0 0 0 2080 2610 528 32 -1 +3 1 214 1 0 0 2080 2610 528 32 -1 +4 1 214 1 1 0 2080 2610 528 32 -1 +5 1 214 1 1 1 2080 2610 72 26 96 chest +5 1 214 1 1 2 2162 2610 26 26 96 of +5 1 214 1 1 3 2196 2610 49 29 96 foe, +5 1 214 1 1 4 2257 2610 115 32 96 spinning +5 1 214 1 1 5 2383 2610 49 26 96 him +5 1 214 1 1 6 2443 2612 102 26 96 around. +5 1 214 1 1 7 2556 2612 52 26 96 You +2 1 215 0 0 0 2677 2612 529 27 -1 +3 1 215 1 0 0 2677 2612 529 27 -1 +4 1 215 1 1 0 2677 2612 529 27 -1 +5 1 215 1 1 1 2677 2612 85 26 94 shield. +5 1 215 1 1 2 2774 2612 49 26 97 The +5 1 215 1 1 3 2831 2613 68 25 96 force +5 1 215 1 1 4 2909 2612 95 27 96 knocks +5 1 215 1 1 5 3014 2613 49 25 96 him +5 1 215 1 1 6 3075 2613 73 26 96 down +5 1 215 1 1 7 3157 2613 49 26 96 and +2 1 216 0 0 0 124 2661 139 40 -1 +3 1 216 1 0 0 124 2661 139 40 -1 +4 1 216 1 1 0 124 2661 139 40 -1 +5 1 216 1 1 1 124 2661 139 40 96 67-70 +2 1 217 0 0 0 1485 2643 475 33 -1 +3 1 217 1 0 0 1485 2643 475 33 -1 +4 1 217 1 1 0 1485 2643 475 33 -1 +5 1 217 1 1 1 1485 2643 93 26 91 heated. +5 1 217 1 1 2 1590 2643 46 26 96 Foe +5 1 217 1 1 3 1646 2645 124 30 78 struggles +5 1 217 1 1 4 1780 2645 56 24 96 with +5 1 217 1 1 5 1846 2645 41 26 96 the +5 1 217 1 1 6 1898 2645 62 31 96 pain. +2 1 218 0 0 0 2080 2646 292 26 -1 +3 1 218 1 0 0 2080 2646 292 26 -1 +4 1 218 1 1 0 2080 2646 292 26 -1 +5 1 218 1 1 1 2080 2650 40 22 96 are +5 1 218 1 1 2 2130 2646 91 26 96 almost +5 1 218 1 1 3 2231 2646 141 26 96 victorious! +2 1 219 0 0 0 2676 2648 511 30 -1 +3 1 219 1 0 0 2676 2648 511 30 -1 +4 1 219 1 1 0 2676 2648 511 30 -1 +5 1 219 1 1 1 2676 2648 42 26 97 the +5 1 219 1 1 2 2728 2648 78 26 97 shield +5 1 219 1 1 3 2817 2648 20 26 96 is +5 1 219 1 1 4 2849 2648 99 26 95 broken. +5 1 219 1 1 5 2960 2648 106 27 96 Without +5 1 219 1 1 6 3076 2653 15 21 95 a +5 1 219 1 1 7 3101 2649 86 29 88 shield, +2 1 220 0 0 0 2676 2685 194 26 -1 +3 1 220 1 0 0 2676 2685 194 26 -1 +4 1 220 1 1 0 2676 2685 194 26 -1 +5 1 220 1 1 1 2676 2691 52 20 96 arm +5 1 220 1 1 2 2739 2685 20 26 96 is +5 1 220 1 1 3 2771 2685 99 26 95 broken. +2 1 221 0 0 0 2114 2747 526 32 -1 +3 1 221 1 0 0 2114 2747 526 32 -1 +4 1 221 1 1 0 2114 2747 526 32 -1 +5 1 221 1 1 1 2114 2747 55 24 96 with +5 1 221 1 1 2 2179 2753 89 20 93 armor: +5 1 221 1 1 3 2280 2747 70 24 90 +14H +5 1 221 1 1 4 2359 2760 19 4 90 — +5 1 221 1 1 5 2386 2747 49 26 54 2% +5 1 221 1 1 6 2446 2760 18 4 37 - +5 1 221 1 1 7 2473 2747 167 32 61 -(3d10-11)P +2 1 222 0 0 0 2808 2748 417 33 -1 +3 1 222 1 0 0 2808 2748 417 33 -1 +4 1 222 1 1 0 2808 2748 417 33 -1 +5 1 222 1 1 1 2808 2748 57 26 96 with +5 1 222 1 1 2 2875 2748 86 26 95 shield: +5 1 222 1 1 3 2971 2748 51 28 68 3% +5 1 222 1 1 4 3032 2761 18 5 82 — +5 1 222 1 1 5 3059 2749 166 32 82 -(4d10-14)P +2 1 223 0 0 0 410 2776 454 31 -1 +3 1 223 1 0 0 410 2776 454 31 -1 +4 1 223 1 1 0 410 2776 454 31 -1 +5 1 223 1 1 1 410 2776 52 26 89 +9H +5 1 223 1 1 2 472 2789 18 3 81 — +5 1 223 1 1 3 498 2776 49 24 75 2%% +5 1 223 1 1 4 557 2789 19 4 71 — +5 1 223 1 1 5 582 2776 54 26 71 @ +5 1 223 1 1 6 610 2772 22 43 75 ~ +5 1 223 1 1 7 648 2777 46 30 88 (-5) +5 1 223 1 1 8 704 2790 17 4 67 — +5 1 223 1 1 9 731 2777 133 30 85 -(d10-6)P +2 1 224 0 0 0 942 2779 505 31 -1 +3 1 224 1 0 0 942 2779 505 31 -1 +4 1 224 1 1 0 942 2779 505 31 -1 +5 1 224 1 1 1 942 2779 69 26 88 +10H +5 1 224 1 1 2 1021 2791 17 5 7 — +5 1 224 1 1 3 1047 2779 49 26 19 3%2 +5 1 224 1 1 4 1106 2791 19 5 41 — +5 1 224 1 1 5 1132 2779 53 26 81 @ +5 1 224 1 1 6 1156 2775 23 43 68 — +5 1 224 1 1 7 1197 2780 63 30 90 (-10) +5 1 224 1 1 8 1270 2791 19 6 89 ~ +5 1 224 1 1 9 1297 2780 150 30 82 -(2d10-9)P +2 1 225 0 0 0 1537 2781 505 32 -1 +3 1 225 1 0 0 1537 2781 505 32 -1 +4 1 225 1 1 0 1537 2781 505 32 -1 +5 1 225 1 1 1 1537 2781 69 25 53 +14H +5 1 225 1 1 2 1616 2794 17 4 85 — +5 1 225 1 1 3 1643 2781 48 26 53 3x4 +5 1 225 1 1 4 1702 2794 18 5 34 - +5 1 225 1 1 5 1729 2781 21 26 71 @ +5 1 225 1 1 6 1763 2794 15 5 60 — +5 1 225 1 1 7 1791 2782 65 30 77 (-15) +5 1 225 1 1 8 1866 2794 18 5 67 — +5 1 225 1 1 9 1894 2783 148 30 30 -(2d10-7)P +2 1 226 0 0 0 2120 2783 520 32 -1 +3 1 226 1 0 0 2120 2783 520 32 -1 +4 1 226 1 1 0 2120 2783 520 32 -1 +5 1 226 1 1 1 2120 2783 47 26 85 w/o +5 1 226 1 1 2 2177 2789 90 20 92 armor: +5 1 226 1 1 3 2278 2783 71 26 91 +12H +5 1 226 1 1 4 2359 2796 17 4 60 — +5 1 226 1 1 5 2385 2784 50 25 68 4&2 +5 1 226 1 1 6 2445 2796 18 4 62 — +5 1 226 1 1 7 2473 2784 167 31 30 -(3010-11)P +2 1 227 0 0 0 2814 2786 411 30 -1 +3 1 227 1 0 0 2814 2786 411 30 -1 +4 1 227 1 1 0 2814 2786 411 30 -1 +5 1 227 1 1 1 2814 2786 49 26 85 w/o +5 1 227 1 1 2 2873 2786 87 26 75 shield: +5 1 227 1 1 3 2971 2786 49 24 0 7%2 +5 1 227 1 1 4 3032 2799 17 4 69 - +5 1 227 1 1 5 3058 2786 167 30 89 -(4d10-14)P +2 1 228 0 0 0 318 2832 421 33 -1 +3 1 228 1 0 0 318 2832 421 33 -1 +4 1 228 1 1 0 318 2832 421 33 -1 +5 1 228 1 1 1 318 2832 45 26 96 Foe +5 1 228 1 1 2 373 2833 21 26 96 is +5 1 228 1 1 3 404 2833 86 26 96 chilled +5 1 228 1 1 4 501 2833 30 32 96 by +5 1 228 1 1 5 540 2835 40 24 96 the +5 1 228 1 1 6 592 2835 69 26 96 blast. +5 1 228 1 1 7 674 2835 33 26 96 He +5 1 228 1 1 8 717 2835 22 26 96 is +2 1 229 0 0 0 901 2835 512 33 -1 +3 1 229 1 0 0 901 2835 512 33 -1 +4 1 229 1 1 0 901 2835 512 33 -1 +5 1 229 1 1 1 901 2835 45 27 96 Foe +5 1 229 1 1 2 956 2836 68 26 96 sinks +5 1 229 1 1 3 1035 2836 23 26 96 in +5 1 229 1 1 4 1070 2836 130 28 96 blackness +5 1 229 1 1 5 1211 2843 32 25 96 up +5 1 229 1 1 6 1251 2839 28 25 95 to +5 1 229 1 1 7 1289 2838 37 26 95 his +5 1 229 1 1 8 1336 2838 77 26 96 waist. +2 1 230 0 0 0 1483 2838 516 33 -1 +3 1 230 1 0 0 1483 2838 516 33 -1 +4 1 230 1 1 0 1483 2838 516 33 -1 +5 1 230 1 1 1 1483 2838 48 27 96 The +5 1 230 1 1 2 1541 2839 55 26 96 void +5 1 230 1 1 3 1607 2839 66 30 96 grips +5 1 230 1 1 4 1683 2840 41 25 96 foe +5 1 230 1 1 5 1735 2845 32 20 96 on +5 1 230 1 1 6 1779 2839 37 26 96 his +5 1 230 1 1 7 1826 2839 40 27 81 left +5 1 230 1 1 8 1876 2840 61 26 96 side. +5 1 230 1 1 9 1948 2840 51 31 96 Any +2 1 231 0 0 0 2080 2840 537 32 -1 +3 1 231 1 0 0 2080 2840 537 32 -1 +4 1 231 1 1 0 2080 2840 537 32 -1 +5 1 231 1 1 1 2080 2840 67 26 44 Foe's +5 1 231 1 1 2 2157 2846 106 26 96 weapon +5 1 231 1 1 3 2273 2846 53 22 95 arm +5 1 231 1 1 4 2336 2840 22 28 57 Is +5 1 231 1 1 5 2368 2842 99 26 96 drained +5 1 231 1 1 6 2479 2842 26 26 96 of +5 1 231 1 1 7 2513 2842 30 26 90 all +5 1 231 1 1 8 2553 2842 64 26 94 heat. +2 1 232 0 0 0 2675 2842 528 27 -1 +3 1 232 1 0 0 2675 2842 528 27 -1 +4 1 232 1 1 0 2675 2842 528 27 -1 +5 1 232 1 1 1 2675 2842 48 27 96 The +5 1 232 1 1 2 2733 2843 55 26 96 void +5 1 232 1 1 3 2800 2843 99 26 96 washes +5 1 232 1 1 4 2911 2849 59 20 96 over +5 1 232 1 1 5 2978 2845 41 24 96 foe +5 1 232 1 1 6 3029 2843 56 26 96 with +5 1 232 1 1 7 3095 2845 108 24 96 extreme +2 1 233 0 0 0 318 2869 462 30 -1 +3 1 233 1 0 0 318 2869 462 30 -1 +4 1 233 1 1 0 318 2869 462 30 -1 +5 1 233 1 1 1 318 2869 133 30 95 struggling +5 1 233 1 1 2 462 2871 26 24 96 to +5 1 233 1 1 3 500 2871 112 25 96 maintain +5 1 233 1 1 4 623 2871 38 26 96 his +5 1 233 1 1 5 672 2871 108 26 95 balance. +2 1 234 0 0 0 899 2872 528 30 -1 +3 1 234 1 0 0 899 2872 528 30 -1 +4 1 234 1 1 0 899 2872 528 30 -1 +5 1 234 1 1 1 899 2872 48 26 96 The +5 1 234 1 1 2 958 2872 56 30 95 pain +5 1 234 1 1 3 1024 2872 47 26 96 and +5 1 234 1 1 4 1081 2872 80 26 96 shock +5 1 234 1 1 5 1171 2879 76 20 96 cause +5 1 234 1 1 6 1259 2874 49 25 96 him +5 1 234 1 1 7 1316 2875 26 24 96 to +5 1 234 1 1 8 1352 2874 75 25 96 falter. +2 1 235 0 0 0 1483 2875 488 27 -1 +3 1 235 1 0 0 1483 2875 488 27 -1 +4 1 235 1 1 0 1483 2875 488 27 -1 +5 1 235 1 1 1 1483 2875 91 26 96 leather +5 1 235 1 1 2 1583 2881 29 20 96 or +5 1 235 1 1 3 1620 2875 65 26 96 cloth +5 1 235 1 1 4 1694 2876 95 26 96 freezes +5 1 235 1 1 5 1799 2876 47 26 96 and +5 1 235 1 1 6 1856 2876 115 26 96 shatters. +2 1 236 0 0 0 2080 2877 543 30 -1 +3 1 236 1 0 0 2080 2877 543 30 -1 +4 1 236 1 1 0 2080 2877 543 30 -1 +5 1 236 1 1 1 2080 2877 66 25 96 Hand +5 1 236 1 1 2 2157 2878 48 24 96 and +5 1 236 1 1 3 2216 2882 52 20 96 arm +5 1 236 1 1 4 2278 2884 42 20 95 are +5 1 236 1 1 5 2330 2878 107 29 95 useless, +5 1 236 1 1 6 2448 2878 48 26 96 and +5 1 236 1 1 7 2506 2878 42 26 96 the +5 1 236 1 1 8 2558 2878 65 26 96 hand +2 1 237 0 0 0 2674 2879 494 31 -1 +3 1 237 1 0 0 2674 2879 494 31 -1 +4 1 237 1 1 0 2674 2879 494 31 -1 +5 1 237 1 1 1 2674 2879 75 26 96 force. +5 1 237 1 1 2 2761 2879 33 25 96 All +5 1 237 1 1 3 2804 2879 112 31 96 exposed +5 1 237 1 1 4 2926 2879 65 26 96 flesh +5 1 237 1 1 5 3001 2881 94 24 96 suffers +5 1 237 1 1 6 3104 2881 64 26 96 from +2 1 238 0 0 0 124 2891 138 40 -1 +3 1 238 1 0 0 124 2891 138 40 -1 +4 1 238 1 1 0 124 2891 138 40 -1 +5 1 238 1 1 1 124 2891 138 40 91 71-75 +2 1 239 0 0 0 2078 2914 217 26 -1 +3 1 239 1 0 0 2078 2914 217 26 -1 +4 1 239 1 1 0 2078 2914 217 26 -1 +5 1 239 1 1 1 2078 2914 20 26 96 is +5 1 239 1 1 2 2108 2914 83 26 96 frozen +5 1 239 1 1 3 2202 2914 93 26 96 closed. +2 1 240 0 0 0 2673 2915 533 32 -1 +3 1 240 1 0 0 2673 2915 533 32 -1 +4 1 240 1 1 0 2673 2915 533 32 -1 +5 1 240 1 1 1 2673 2915 121 26 96 frostbite. +5 1 240 1 1 2 2807 2915 59 26 96 How +5 1 240 1 1 3 2876 2921 46 20 96 can +5 1 240 1 1 4 2932 2917 32 26 96 he +5 1 240 1 1 5 2974 2917 46 26 96 still +5 1 240 1 1 6 3032 2917 30 26 63 be +5 1 240 1 1 7 3072 2917 134 30 96 standing? +2 1 241 0 0 0 436 2973 425 33 -1 +3 1 241 1 0 0 436 2973 425 33 -1 +4 1 241 1 1 0 436 2973 425 33 -1 +5 1 241 1 1 1 436 2973 71 26 85 +10H +5 1 241 1 1 2 516 2986 18 4 69 — +5 1 241 1 1 3 543 2974 49 25 23 3%% +5 1 241 1 1 4 602 2987 17 3 89 — +5 1 241 1 1 5 629 2974 65 31 86 (-10) +5 1 241 1 1 6 703 2987 18 5 11 — +5 1 241 1 1 7 730 2974 131 32 88 -(d10-5)P +2 1 242 0 0 0 1025 2976 421 33 -1 +3 1 242 1 0 0 1025 2976 421 33 -1 +4 1 242 1 1 0 1025 2976 421 33 -1 +5 1 242 1 1 1 1025 2976 71 26 50 +11H +5 1 242 1 1 2 1104 2989 19 4 80 — +5 1 242 1 1 3 1132 2977 50 25 39 6&4 +5 1 242 1 1 4 1191 2990 17 3 89 — +5 1 242 1 1 5 1210 2972 30 43 36 2 +5 1 242 1 1 6 1218 2977 69 26 30 - +5 1 242 1 1 7 1297 2977 149 32 77 -(2d10-8)P +2 1 243 0 0 0 1622 2979 419 31 -1 +3 1 243 1 0 0 1622 2979 419 31 -1 +4 1 243 1 1 0 1622 2979 419 31 -1 +5 1 243 1 1 1 1622 2979 49 26 29 6X% +5 1 243 1 1 2 1681 2992 17 4 18 — +5 1 243 1 1 3 1707 2979 70 26 42 2G +5 1 243 1 1 4 1750 2975 22 44 59 — +5 1 243 1 1 5 1789 2980 64 30 91 (-20) +5 1 243 1 1 6 1864 2993 17 4 42 - +5 1 243 1 1 7 1891 2980 150 30 66 -(2d10-6)P +2 1 244 0 0 0 2147 2980 490 33 -1 +3 1 244 1 0 0 2147 2980 490 33 -1 +4 1 244 1 1 0 2147 2980 490 33 -1 +5 1 244 1 1 1 2147 2981 71 25 85 +12H +5 1 244 1 1 2 2227 2993 18 6 75 — +5 1 244 1 1 3 2254 2980 79 27 76 4£2@ +5 1 244 1 1 4 2342 2994 17 5 82 ~ +5 1 244 1 1 5 2369 2981 65 31 92 (-50) +5 1 244 1 1 6 2444 2994 19 5 28 — +5 1 244 1 1 7 2471 2981 166 32 45 -(3d10-10)P +2 1 245 0 0 0 2854 2983 369 32 -1 +3 1 245 1 0 0 2854 2983 369 32 -1 +4 1 245 1 1 0 2854 2983 369 32 -1 +5 1 245 1 1 1 2854 2983 71 24 76 +14H +5 1 245 1 1 2 2934 2996 17 4 40 — +5 1 245 1 1 3 2960 2983 50 26 0 4&% +5 1 245 1 1 4 3019 2996 18 4 84 — +5 1 245 1 1 5 3046 2983 177 32 83 -(-4d10-12)P +2 1 246 0 0 0 315 3025 513 31 -1 +3 1 246 1 0 0 315 3025 513 31 -1 +4 1 246 1 1 0 315 3025 513 31 -1 +5 1 246 1 1 1 315 3025 48 24 96 The +5 1 246 1 1 2 373 3025 131 26 64 blackness +5 1 246 1 1 3 514 3026 75 26 96 seeks +5 1 246 1 1 4 599 3027 43 25 93 out +5 1 246 1 1 5 651 3026 63 26 87 foe’s +5 1 246 1 1 6 724 3032 104 24 95 weapon +2 1 247 0 0 0 898 3027 510 28 -1 +3 1 247 1 0 0 898 3027 510 28 -1 +4 1 247 1 1 0 898 3027 510 28 -1 +5 1 247 1 1 1 898 3027 58 26 91 Cold +5 1 247 1 1 2 966 3028 101 25 96 tendrils +5 1 247 1 1 3 1077 3029 26 24 96 of +5 1 247 1 1 4 1113 3029 131 26 94 blackness +5 1 247 1 1 5 1254 3030 80 25 96 freeze +5 1 247 1 1 6 1344 3030 64 25 96 what +2 1 248 0 0 0 1482 3030 468 32 -1 +3 1 248 1 0 0 1482 3030 468 32 -1 +4 1 248 1 1 0 1482 3030 468 32 -1 +5 1 248 1 1 1 1482 3030 69 26 91 Foe’s +5 1 248 1 1 2 1560 3036 67 20 96 arms +5 1 248 1 1 3 1637 3036 41 20 96 are +5 1 248 1 1 4 1688 3032 89 29 96 pinned +5 1 248 1 1 5 1790 3032 29 30 96 by +5 1 248 1 1 6 1828 3032 41 26 92 the +5 1 248 1 1 7 1881 3032 69 26 89 biast. +2 1 249 0 0 0 2078 3032 475 32 -1 +3 1 249 1 0 0 2078 3032 475 32 -1 +4 1 249 1 1 0 2078 3032 475 32 -1 +5 1 249 1 1 1 2078 3032 45 26 96 Foe +5 1 249 1 1 2 2134 3033 21 25 96 is +5 1 249 1 1 3 2165 3033 110 31 96 dropped +5 1 249 1 1 4 2287 3033 32 31 96 by +5 1 249 1 1 5 2327 3033 41 26 96 the +5 1 249 1 1 6 2378 3033 89 31 96 weight +5 1 249 1 1 7 2477 3035 26 24 96 of +5 1 249 1 1 8 2512 3033 41 26 96 the +2 1 250 0 0 0 2674 3033 505 33 -1 +3 1 250 1 0 0 2674 3033 505 33 -1 +4 1 250 1 1 0 2674 3033 505 33 -1 +5 1 250 1 1 1 2674 3033 65 28 92 Blast +5 1 250 1 1 2 2749 3035 88 26 95 strikes +5 1 250 1 1 3 2846 3035 42 26 96 foe +5 1 250 1 1 4 2898 3035 88 31 95 solidly +5 1 250 1 1 5 2994 3040 33 21 94 on +5 1 250 1 1 6 3036 3035 42 26 94 the +5 1 250 1 1 7 3089 3035 90 27 84 middle +2 1 251 0 0 0 314 3061 515 31 -1 +3 1 251 1 0 0 314 3061 515 31 -1 +4 1 251 1 1 0 314 3061 515 31 -1 +5 1 251 1 1 1 314 3066 59 21 96 arm. +5 1 251 1 1 2 386 3061 44 26 96 Foe +5 1 251 1 1 3 441 3061 20 26 96 is +5 1 251 1 1 4 472 3063 56 24 96 sent +5 1 251 1 1 5 540 3062 86 30 96 reeling +5 1 251 1 1 6 638 3063 61 25 96 after +5 1 251 1 1 7 707 3062 42 26 96 the +5 1 251 1 1 8 760 3063 69 25 83 blast. +2 1 252 0 0 0 897 3063 533 32 -1 +3 1 252 1 0 0 897 3063 533 32 -1 +4 1 252 1 1 0 897 3063 533 32 -1 +5 1 252 1 1 1 897 3063 16 25 96 it +5 1 252 1 1 2 922 3064 113 25 94 touches. +5 1 252 1 1 3 1047 3065 33 24 94 All +5 1 252 1 1 4 1091 3065 143 30 95 equipment +5 1 252 1 1 5 1244 3065 23 24 95 in +5 1 252 1 1 6 1276 3065 49 26 95 and +5 1 252 1 1 7 1335 3066 95 25 96 around +2 1 253 0 0 0 1480 3066 510 28 -1 +3 1 253 1 0 0 1480 3066 510 28 -1 +4 1 253 1 1 0 1480 3066 510 28 -1 +5 1 253 1 1 1 1480 3066 88 26 94 Armor +5 1 253 1 1 2 1575 3066 88 26 95 and/or +5 1 253 1 1 3 1672 3068 95 26 96 clothes +5 1 253 1 1 4 1777 3074 40 20 96 are +5 1 253 1 1 5 1829 3068 95 26 94 melded +5 1 253 1 1 6 1936 3068 54 26 97 with +2 1 254 0 0 0 2078 3069 516 30 -1 +3 1 254 1 0 0 2078 3069 516 30 -1 +4 1 254 1 1 0 2078 3069 516 30 -1 +5 1 254 1 1 1 2078 3069 69 25 92 blast. +5 1 254 1 1 2 2160 3069 111 26 96 Muscles +5 1 254 1 1 3 2281 3075 42 20 96 are +5 1 254 1 1 4 2332 3069 124 30 96 damaged +5 1 254 1 1 5 2466 3069 49 26 95 and +5 1 254 1 1 6 2525 3071 69 24 95 don't +2 1 255 0 0 0 2673 3071 487 31 -1 +3 1 255 1 0 0 2673 3071 487 31 -1 +4 1 255 1 1 0 2673 3071 487 31 -1 +5 1 255 1 1 1 2673 3071 26 26 97 of +5 1 255 1 1 2 2709 3071 39 26 96 his +5 1 255 1 1 3 2758 3071 79 26 96 chest. +5 1 255 1 1 4 2849 3071 44 26 96 Foe +5 1 255 1 1 5 2903 3071 58 31 92 flips +5 1 255 1 1 6 2971 3074 61 24 96 onto +5 1 255 1 1 7 3042 3071 37 27 96 his +5 1 255 1 1 8 3092 3072 68 29 96 back, +2 1 256 0 0 0 896 3099 511 32 -1 +3 1 256 1 0 0 896 3099 511 32 -1 +4 1 256 1 1 0 896 3099 511 32 -1 +5 1 256 1 1 1 896 3099 63 26 24 foe's +5 1 256 1 1 2 969 3099 72 26 94 chest +5 1 256 1 1 3 1051 3099 20 26 94 is +5 1 256 1 1 4 1081 3101 131 30 94 damaged. +5 1 256 1 1 5 1223 3101 64 26 96 Your +5 1 256 1 1 6 1296 3102 42 25 96 foe +5 1 256 1 1 7 1348 3107 59 20 96 sees +2 1 257 0 0 0 1480 3102 304 26 -1 +3 1 257 1 0 0 1480 3102 304 26 -1 +4 1 257 1 1 0 1480 3102 304 26 -1 +5 1 257 1 1 1 1480 3102 71 26 96 flesh. +5 1 257 1 1 2 1565 3102 16 25 94 It +5 1 257 1 1 3 1591 3102 68 26 96 hurts +5 1 257 1 1 4 1669 3104 26 24 96 to +5 1 257 1 1 5 1705 3108 79 20 96 move. +2 1 258 0 0 0 2077 3105 295 30 -1 +3 1 258 1 0 0 2077 3105 295 30 -1 +4 1 258 1 1 0 2077 3105 295 30 -1 +5 1 258 1 1 1 2077 3110 70 20 95 seem +5 1 258 1 1 2 2156 3105 27 26 87 to +5 1 258 1 1 3 2195 3105 106 30 96 respond +5 1 258 1 1 4 2313 3105 59 26 89 well. +2 1 259 0 0 0 2672 3107 528 31 -1 +3 1 259 1 0 0 2672 3107 528 31 -1 +4 1 259 1 1 0 2672 3107 528 31 -1 +5 1 259 1 1 1 2672 3107 70 26 96 while +5 1 259 1 1 2 2752 3107 39 26 96 his +5 1 259 1 1 3 2800 3112 105 25 95 weapon +5 1 259 1 1 4 2914 3107 56 26 90 flies +5 1 259 1 1 5 2980 3108 43 25 96 out +5 1 259 1 1 6 3033 3108 26 25 96 of +5 1 259 1 1 7 3069 3107 38 26 96 his +5 1 259 1 1 8 3117 3112 83 26 94 grasp. +2 1 260 0 0 0 118 3118 141 39 -1 +3 1 260 1 0 0 118 3118 141 39 -1 +4 1 260 1 1 0 118 3118 141 39 -1 +5 1 260 1 1 1 118 3118 141 39 96 76-80 +2 1 261 0 0 0 896 3135 252 26 -1 +3 1 261 1 0 0 896 3135 252 26 -1 +4 1 261 1 1 0 896 3135 252 26 -1 +5 1 261 1 1 1 896 3135 72 26 96 death +5 1 261 1 1 2 978 3136 47 25 94 and +5 1 261 1 1 3 1037 3136 20 25 94 is +5 1 261 1 1 4 1066 3137 82 24 95 afraid. +2 1 262 0 0 0 2673 3141 356 33 -1 +3 1 262 1 0 0 2673 3141 356 33 -1 +4 1 262 1 1 0 2673 3141 356 33 -1 +5 1 262 1 1 1 2673 3141 36 28 94 Do +5 1 262 1 1 2 2719 3143 63 26 94 what +5 1 262 1 1 3 2791 3148 49 26 96 you +5 1 262 1 1 4 2850 3143 45 26 96 will +5 1 262 1 1 5 2905 3143 56 26 96 with +5 1 262 1 1 6 2971 3143 58 26 93 him. +2 1 263 0 0 0 386 3200 472 33 -1 +3 1 263 1 0 0 386 3200 472 33 -1 +4 1 263 1 1 0 386 3200 472 33 -1 +5 1 263 1 1 1 386 3200 56 25 96 with +5 1 263 1 1 2 452 3200 85 26 91 shield: +5 1 263 1 1 3 550 3202 53 24 88 +1H +5 1 263 1 1 4 612 3213 17 4 41 — +5 1 263 1 1 5 639 3202 49 24 49 2<% +5 1 263 1 1 6 698 3215 19 2 74 — +5 1 263 1 1 7 726 3202 132 31 84 -(d10-4)P +2 1 264 0 0 0 1509 3206 530 33 -1 +3 1 264 1 0 0 1509 3206 530 33 -1 +4 1 264 1 1 0 1509 3206 530 33 -1 +5 1 264 1 1 1 1509 3206 56 24 93 with +5 1 264 1 1 2 1575 3212 113 24 88 greaves: +5 1 264 1 1 3 1699 3206 49 26 12 8+ +5 1 264 1 1 4 1758 3219 18 4 49 — +5 1 264 1 1 5 1786 3206 65 32 51 {-30) +5 1 264 1 1 6 1862 3219 17 4 63 - +5 1 264 1 1 7 1889 3207 150 32 91 -(2d10-4)P +2 1 265 0 0 0 376 3236 482 33 -1 +3 1 265 1 0 0 376 3236 482 33 -1 +4 1 265 1 1 0 376 3236 482 33 -1 +5 1 265 1 1 1 376 3236 49 26 95 w/o +5 1 265 1 1 2 435 3236 86 26 93 shield: +5 1 265 1 1 3 533 3238 70 24 88 +11H +5 1 265 1 1 4 612 3251 19 2 86 — +5 1 265 1 1 5 638 3238 50 26 43 3&¢ +5 1 265 1 1 6 698 3251 18 4 39 - +5 1 265 1 1 7 726 3238 132 31 84 -(d10-4)P +2 1 266 0 0 0 995 3239 449 33 -1 +3 1 266 1 0 0 995 3239 449 33 -1 +4 1 266 1 1 0 995 3239 449 33 -1 +5 1 266 1 1 1 995 3239 69 26 90 +12H +5 1 266 1 1 2 1073 3252 18 4 76 — +5 1 266 1 1 3 1100 3240 49 25 35 8X2 +5 1 266 1 1 4 1159 3253 19 5 86 — +5 1 266 1 1 5 1187 3240 70 31 92 (+15) +5 1 266 1 1 6 1267 3253 19 5 76 — +5 1 266 1 1 7 1295 3240 149 32 65 -(2d10-6)P +2 1 267 0 0 0 1521 3242 518 33 -1 +3 1 267 1 0 0 1521 3242 518 33 -1 +4 1 267 1 1 0 1521 3242 518 33 -1 +5 1 267 1 1 1 1521 3242 49 26 93 w/o +5 1 267 1 1 2 1578 3248 111 24 92 greaves: +5 1 267 1 1 3 1701 3243 50 25 0 5+ +5 1 267 1 1 4 1760 3255 17 4 30 — +5 1 267 1 1 5 1789 3243 63 31 92 (-25) +5 1 267 1 1 6 1864 3256 17 5 53 - +5 1 267 1 1 7 1889 3243 150 32 90 -(2d10-4)P +2 1 268 0 0 0 2152 3243 484 33 -1 +3 1 268 1 0 0 2152 3243 484 33 -1 +4 1 268 1 1 0 2152 3243 484 33 -1 +5 1 268 1 1 1 2152 3244 69 25 87 +14H +5 1 268 1 1 2 2231 3256 19 6 19 - +5 1 268 1 1 3 2257 3243 80 28 16 422@ +5 1 268 1 1 4 2353 3258 19 4 68 — +5 1 268 1 1 5 2382 3245 66 31 87 (-25) +5 1 268 1 1 6 2457 3258 19 4 45 — +5 1 268 1 1 7 2484 3245 152 31 70 -(3d10-9)P +2 1 269 0 0 0 2846 3246 374 32 -1 +3 1 269 1 0 0 2846 3246 374 32 -1 +4 1 269 1 1 0 2846 3246 374 32 -1 +5 1 269 1 1 1 2846 3246 66 26 47 13%¢ +5 1 269 1 1 2 2922 3259 17 6 87 — +5 1 269 1 1 3 2949 3246 67 32 89 (-30) +5 1 269 1 1 4 3026 3259 17 5 57 - +5 1 269 1 1 5 3053 3246 167 32 75 -(4d10-10)P +2 1 270 0 0 0 311 3294 491 33 -1 +3 1 270 1 0 0 311 3294 491 33 -1 +4 1 270 1 1 0 311 3294 491 33 -1 +5 1 270 1 1 1 311 3294 45 26 96 Foe +5 1 270 1 1 2 366 3294 21 26 96 is +5 1 270 1 1 3 397 3294 147 31 96 completely +5 1 270 1 1 4 554 3295 124 30 95 engulfed. +5 1 270 1 1 5 690 3295 31 26 64 Ail +5 1 270 1 1 6 733 3295 69 32 96 glass +2 1 271 0 0 0 896 3297 462 31 -1 +3 1 271 1 0 0 896 3297 462 31 -1 +4 1 271 1 1 0 896 3297 462 31 -1 +5 1 271 1 1 1 896 3297 106 26 96 Forceful +5 1 271 1 1 2 1014 3298 63 26 96 blast +5 1 271 1 1 3 1087 3298 95 30 96 pushes +5 1 271 1 1 4 1192 3298 41 26 96 foe +5 1 271 1 1 5 1244 3304 59 20 96 over +5 1 271 1 1 6 1310 3299 48 26 96 and +2 1 272 0 0 0 1480 3299 520 29 -1 +3 1 272 1 0 0 1480 3299 520 29 -1 +4 1 272 1 1 0 1480 3299 520 29 -1 +5 1 272 1 1 1 1480 3299 77 26 96 Strike +5 1 272 1 1 2 1565 3301 27 26 93 to +5 1 272 1 1 3 1601 3301 64 26 84 foe’s +5 1 272 1 1 4 1674 3302 70 25 96 torso +5 1 272 1 1 5 1755 3301 86 26 96 makes +5 1 272 1 1 6 1851 3301 49 26 96 him +5 1 272 1 1 7 1910 3301 90 27 96 double +2 1 273 0 0 0 2074 3300 515 33 -1 +3 1 273 1 0 0 2074 3300 515 33 -1 +4 1 273 1 1 0 2074 3300 515 33 -1 +5 1 273 1 1 1 2074 3301 49 27 95 The +5 1 273 1 1 2 2134 3302 36 31 90 icy +5 1 273 1 1 3 2182 3300 131 28 96 blackness +5 1 273 1 1 4 2322 3304 95 26 96 freezes +5 1 273 1 1 5 2427 3302 65 28 85 foe's +5 1 273 1 1 6 2502 3304 87 26 96 hands. +2 1 274 0 0 0 2672 3304 475 31 -1 +3 1 274 1 0 0 2672 3304 475 31 -1 +4 1 274 1 1 0 2672 3304 475 31 -1 +5 1 274 1 1 1 2672 3304 46 26 96 Foe +5 1 274 1 1 2 2726 3304 71 26 96 takes +5 1 274 1 1 3 2807 3310 62 25 96 your +5 1 274 1 1 4 2877 3304 81 27 93 attack +5 1 274 1 1 5 2967 3304 134 27 92 full-faced. +5 1 274 1 1 6 3114 3305 33 26 96 He +2 1 275 0 0 0 309 3331 273 30 -1 +3 1 275 1 0 0 309 3331 273 30 -1 +4 1 275 1 1 0 309 3331 273 30 -1 +5 1 275 1 1 1 309 3335 58 26 96 gear +5 1 275 1 1 2 377 3331 114 26 95 shatters. +5 1 275 1 1 3 504 3331 78 26 92 Ouch! +2 1 276 0 0 0 894 3334 489 32 -1 +3 1 276 1 0 0 894 3334 489 32 -1 +4 1 276 1 1 0 894 3334 489 32 -1 +5 1 276 1 1 1 894 3334 87 26 94 cracks +5 1 276 1 1 2 994 3334 47 26 96 ribs +5 1 276 1 1 3 1053 3334 23 24 96 in +5 1 276 1 1 4 1086 3334 40 26 96 the +5 1 276 1 1 5 1138 3341 113 25 96 process. +5 1 276 1 1 6 1263 3335 35 26 96 He +5 1 276 1 1 7 1309 3335 20 26 96 is +5 1 276 1 1 8 1341 3338 42 23 96 not +2 1 277 0 0 0 1479 3337 481 30 -1 +3 1 277 1 0 0 1479 3337 481 30 -1 +4 1 277 1 1 0 1479 3337 481 30 -1 +5 1 277 1 1 1 1479 3343 65 20 91 over. +5 1 277 1 1 2 1555 3337 33 24 96 All +5 1 277 1 1 3 1598 3343 60 24 97 gear +5 1 277 1 1 4 1666 3343 33 20 94 on +5 1 277 1 1 5 1708 3337 42 26 96 the +5 1 277 1 1 6 1760 3340 69 24 96 torso +5 1 277 1 1 7 1840 3338 120 26 96 becomes +2 1 278 0 0 0 2075 3338 470 33 -1 +3 1 278 1 0 0 2075 3338 470 33 -1 +4 1 278 1 1 0 2075 3338 470 33 -1 +5 1 278 1 1 1 2075 3338 42 26 97 His +5 1 278 1 1 2 2127 3344 68 20 96 arms +5 1 278 1 1 3 2205 3344 40 20 97 are +5 1 278 1 1 4 2257 3340 100 26 72 useless +5 1 278 1 1 5 2366 3340 48 26 96 and +5 1 278 1 1 6 2427 3340 30 26 96 he +5 1 278 1 1 7 2467 3340 78 31 96 drops +2 1 279 0 0 0 2670 3341 500 29 -1 +3 1 279 1 0 0 2670 3341 500 29 -1 +4 1 279 1 1 0 2670 3341 500 29 -1 +5 1 279 1 1 1 2670 3345 87 22 79 seems +5 1 279 1 1 2 2767 3341 49 26 95 fine +5 1 279 1 1 3 2826 3341 39 26 96 for +5 1 279 1 1 4 2873 3347 65 23 96 now, +5 1 279 1 1 5 2949 3341 43 26 97 but +5 1 279 1 1 6 3001 3341 45 26 95 will +5 1 279 1 1 7 3056 3341 40 26 96 die +5 1 279 1 1 8 3105 3342 65 27 94 from +2 1 280 0 0 0 114 3351 139 41 -1 +3 1 280 1 0 0 114 3351 139 41 -1 +4 1 280 1 1 0 114 3351 139 41 -1 +5 1 280 1 1 1 114 3351 139 41 96 81-85 +2 1 281 0 0 0 894 3370 113 30 -1 +3 1 281 1 0 0 894 3370 113 30 -1 +4 1 281 1 1 0 894 3370 113 30 -1 +5 1 281 1 1 1 894 3370 113 30 93 graceful. +2 1 282 0 0 0 1479 3373 233 26 -1 +3 1 282 1 0 0 1479 3373 233 26 -1 +4 1 282 1 1 0 1479 3373 233 26 -1 +5 1 282 1 1 1 1479 3373 82 26 95 frozen +5 1 282 1 1 2 1571 3373 48 26 92 and +5 1 282 1 1 3 1630 3373 82 26 76 brittte. +2 1 283 0 0 0 2074 3374 350 33 -1 +3 1 283 1 0 0 2074 3374 350 33 -1 +4 1 283 1 1 0 2074 3374 350 33 -1 +5 1 283 1 1 1 2074 3374 122 28 96 whatever +5 1 283 1 1 2 2205 3375 30 27 96 he +5 1 283 1 1 3 2245 3381 52 21 96 was +5 1 283 1 1 4 2309 3376 115 31 96 carrying. +2 1 284 0 0 0 2670 3377 490 32 -1 +3 1 284 1 0 0 2670 3377 490 32 -1 +4 1 284 1 1 0 2670 3377 490 32 -1 +5 1 284 1 1 1 2670 3377 100 26 95 internal +5 1 284 1 1 2 2781 3383 73 20 96 nerve +5 1 284 1 1 3 2863 3377 107 32 96 damage +5 1 284 1 1 4 2981 3377 23 26 96 in +5 1 284 1 1 5 3016 3377 31 26 96 12 +5 1 284 1 1 6 3059 3377 101 28 96 rounds. +2 1 285 0 0 0 366 3436 493 32 -1 +3 1 285 1 0 0 366 3436 493 32 -1 +4 1 285 1 1 0 366 3436 493 32 -1 +5 1 285 1 1 1 366 3436 70 25 87 +12H +5 1 285 1 1 2 446 3449 18 4 63 - +5 1 285 1 1 3 474 3436 49 26 25 344 +5 1 285 1 1 4 533 3449 18 4 53 - +5 1 285 1 1 5 559 3436 28 26 72 @ +5 1 285 1 1 6 596 3449 17 4 86 ~ +5 1 285 1 1 7 623 3438 65 30 81 (-20) +5 1 285 1 1 8 700 3451 17 4 79 — +5 1 285 1 1 9 727 3438 132 30 89 -(d10-3)P +2 1 286 0 0 0 917 3439 526 32 -1 +3 1 286 1 0 0 917 3439 526 32 -1 +4 1 286 1 1 0 917 3439 526 32 -1 +5 1 286 1 1 1 917 3439 71 26 88 +12H +5 1 286 1 1 2 998 3452 17 4 63 — +5 1 286 1 1 3 1025 3439 48 25 23 75% +5 1 286 1 1 4 1085 3452 17 4 59 — +5 1 286 1 1 5 1110 3439 40 26 84 2@ +5 1 286 1 1 6 1162 3452 17 4 77 — +5 1 286 1 1 7 1191 3439 65 32 91 (-15) +5 1 286 1 1 8 1266 3453 19 5 69 — +5 1 286 1 1 9 1293 3440 150 31 81 -(2d10-4)P +2 1 287 0 0 0 1682 3442 356 32 -1 +3 1 287 1 0 0 1682 3442 356 32 -1 +4 1 287 1 1 0 1682 3442 356 32 -1 +5 1 287 1 1 1 1682 3442 66 26 6 122 +5 1 287 1 1 2 1757 3455 19 4 76 — +5 1 287 1 1 3 1786 3442 65 32 90 (-40) +5 1 287 1 1 4 1861 3455 18 6 92 ~ +5 1 287 1 1 5 1888 3443 150 31 6 -(24610-3)P +2 1 288 0 0 0 2381 3445 252 30 -1 +3 1 288 1 0 0 2381 3445 252 30 -1 +4 1 288 1 1 0 2381 3445 252 30 -1 +5 1 288 1 1 1 2381 3445 66 26 0 16% +5 1 288 1 1 2 2457 3456 19 6 72 ~ +5 1 288 1 1 3 2483 3445 150 30 47 -(3410-8)P +2 1 289 0 0 0 2896 3446 323 32 -1 +3 1 289 1 0 0 2896 3446 323 32 -1 +4 1 289 1 1 0 2896 3446 323 32 -1 +5 1 289 1 1 1 2896 3446 69 26 91 +16H +5 1 289 1 1 2 2975 3459 19 5 83 — +5 1 289 1 1 3 3003 3448 32 23 5 item.Slug)); Assert.All(enabledTables, entry => { - Assert.Equal("xml", entry.ExtractionMethod); + Assert.True( + new[] { "xml", "ocr" }.Contains(entry.ExtractionMethod, StringComparer.Ordinal), + $"Unexpected extraction method '{entry.ExtractionMethod}' for '{entry.Slug}'."); Assert.True(File.Exists(Path.Combine(GetRepositoryRoot(), entry.PdfPath)), $"Missing source PDF for '{entry.Slug}'."); }); Assert.Equal("variant_column", enabledTables.Single(item => item.Slug == "large_creature_weapon").Family); Assert.Equal("variant_column", enabledTables.Single(item => item.Slug == "super_large_creature_weapon").Family); Assert.Equal("grouped_variant", enabledTables.Single(item => item.Slug == "large_creature_magic").Family); + Assert.Equal("ocr", enabledTables.Single(item => item.Slug == "void").ExtractionMethod); } [Theory] @@ -604,6 +610,25 @@ public sealed class StandardCriticalTableParserIntegrationTests Assert.StartsWith("Strike to foe's hip.", result.RawCellText, StringComparison.Ordinal); } + [Fact] + public async Task Loader_persists_void_table_from_fixture() + { + var entry = LoadManifest().Tables.Single(item => string.Equals(item.Slug, "void", StringComparison.Ordinal)); + var parseResult = await LoadParseResultAsync(entry); + var databasePath = CreateTemporaryDatabaseCopy(); + var loader = new CriticalImportLoader(databasePath); + + await loader.LoadAsync(parseResult.Table); + + await using var dbContext = CreateDbContext(databasePath); + var results = await dbContext.CriticalResults + .Include(item => item.CriticalTable) + .Where(item => item.CriticalTable.Slug == "void") + .CountAsync(); + + Assert.Equal(95, results); + } + [Fact] public async Task Lookup_service_returns_effects_for_results_and_branches() { @@ -632,6 +657,25 @@ public sealed class StandardCriticalTableParserIntegrationTests private static async Task LoadParseResultAsync(CriticalImportManifestEntry entry) { + if (string.Equals(entry.ExtractionMethod, "ocr", StringComparison.OrdinalIgnoreCase)) + { + var tsvContent = await File.ReadAllTextAsync(Path.Combine(GetRepositoryRoot(), "src", "RolemasterDb.ImportTool.Tests", "Fixtures", "Void", "source.ocr.tsv")); + var source = new ExtractedCriticalSource( + "ocr", + "Imported from PDF OCR extraction.", + SourceRenderProfile.OcrPixels(PdfXmlExtractor.ScaledRenderDpi), + [new ParsedPdfPageGeometry(1, 3600, 5070)], + OcrCriticalSourceExtractor.ParseTsv(tsvContent)); + + return entry.Family switch + { + "standard" => StandardParser.Parse(entry, source, StandardOcrBootstrapper.Bootstrap(source, StandardTableAxisTemplateCatalog.Resolve(entry.AxisTemplateSlug))), + "variant_column" => VariantColumnParser.Parse(entry, source), + "grouped_variant" => GroupedVariantParser.Parse(entry, source), + _ => throw new InvalidOperationException($"Unsupported manifest family '{entry.Family}'.") + }; + } + var xmlPath = Path.Combine(GetArtifactCacheRoot(), $"{entry.Slug}.xml"); if (!File.Exists(xmlPath)) @@ -701,20 +745,5 @@ public sealed class StandardCriticalTableParserIntegrationTests await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext); } - private static string GetRepositoryRoot() - { - var probe = new DirectoryInfo(AppContext.BaseDirectory); - - while (probe is not null) - { - if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx"))) - { - return probe.FullName; - } - - probe = probe.Parent; - } - - throw new InvalidOperationException("Could not find the repository root for integration tests."); - } + private static string GetRepositoryRoot() => TestRepositoryPaths.GetRepositoryRoot(); } diff --git a/src/RolemasterDb.ImportTool.Tests/TestRepositoryPaths.cs b/src/RolemasterDb.ImportTool.Tests/TestRepositoryPaths.cs new file mode 100644 index 0000000..b2fe4c5 --- /dev/null +++ b/src/RolemasterDb.ImportTool.Tests/TestRepositoryPaths.cs @@ -0,0 +1,39 @@ +namespace RolemasterDb.ImportTool.Tests; + +internal static class TestRepositoryPaths +{ + private const string RepositoryRootEnvironmentVariable = "ROLEMASTERDB_REPOSITORY_ROOT"; + + public static string GetRepositoryRoot() + { + var configuredRoot = Environment.GetEnvironmentVariable(RepositoryRootEnvironmentVariable); + if (!string.IsNullOrWhiteSpace(configuredRoot)) + { + var fullPath = Path.GetFullPath(configuredRoot); + if (File.Exists(Path.Combine(fullPath, "RolemasterDB.slnx"))) + { + return fullPath; + } + } + + var probes = new[] + { + new DirectoryInfo(AppContext.BaseDirectory), + new DirectoryInfo(Directory.GetCurrentDirectory()) + }; + + foreach (var probe in probes) + { + for (var current = probe; current is not null; current = current.Parent) + { + if (File.Exists(Path.Combine(current.FullName, "RolemasterDB.slnx"))) + { + return current.FullName; + } + } + } + + throw new InvalidOperationException( + $"Could not find the repository root for integration tests. Set {RepositoryRootEnvironmentVariable} to the repository path."); + } +} diff --git a/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs index 4fc0f90..3bcee7c 100644 --- a/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs +++ b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs @@ -7,6 +7,7 @@ public sealed class CriticalImportCommandRunner private readonly CriticalImportManifestLoader manifestLoader = new(); private readonly ImportArtifactWriter artifactWriter = new(); private readonly PdfXmlExtractor pdfXmlExtractor = new(); + private readonly StandardOcrBootstrapper standardOcrBootstrapper = new(); private readonly CriticalSourceImageArtifactGenerator sourceImageArtifactGenerator; private readonly StandardCriticalTableParser standardParser = new(); private readonly VariantColumnCriticalTableParser variantColumnParser = new(); @@ -35,8 +36,9 @@ public sealed class CriticalImportCommandRunner { var entry = GetManifestEntry(options.Table); var artifactPaths = CreateArtifactPaths(entry.Slug); - await pdfXmlExtractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths.XmlPath); - Console.WriteLine($"Extracted {entry.Slug} to {artifactPaths.XmlPath}"); + var extractor = CreateSourceExtractor(entry); + await extractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths, CancellationToken.None); + Console.WriteLine($"Extracted {entry.Slug} to {artifactPaths.GetSourceArtifactPath(entry.ExtractionMethod)}"); return 0; } @@ -44,15 +46,8 @@ public sealed class CriticalImportCommandRunner { var entry = GetManifestEntry(options.Table); var artifactPaths = CreateArtifactPaths(entry.Slug); - - if (!File.Exists(artifactPaths.XmlPath)) - { - Console.Error.WriteLine($"Missing XML artifact: {artifactPaths.XmlPath}"); - return 1; - } - - var xmlContent = await File.ReadAllTextAsync(artifactPaths.XmlPath); - var parseResult = Parse(entry, xmlContent); + var extractedSource = await LoadExtractedSourceAsync(entry, artifactPaths); + var parseResult = Parse(entry, extractedSource); await sourceImageArtifactGenerator.GenerateAsync( ResolveRepositoryPath(entry.PdfPath), artifactPaths, @@ -104,14 +99,14 @@ public sealed class CriticalImportCommandRunner { var entry = GetManifestEntry(options.Table); var artifactPaths = CreateArtifactPaths(entry.Slug); - - if (!File.Exists(artifactPaths.XmlPath)) + var extractor = CreateSourceExtractor(entry); + if (!File.Exists(artifactPaths.GetSourceArtifactPath(entry.ExtractionMethod))) { - await pdfXmlExtractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths.XmlPath); + await extractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths, CancellationToken.None); } - var xmlContent = await File.ReadAllTextAsync(artifactPaths.XmlPath); - var parseResult = Parse(entry, xmlContent); + var extractedSource = await extractor.LoadAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths, CancellationToken.None); + var parseResult = Parse(entry, extractedSource); await sourceImageArtifactGenerator.GenerateAsync( ResolveRepositoryPath(entry.PdfPath), artifactPaths, @@ -143,26 +138,61 @@ public sealed class CriticalImportCommandRunner ?? throw new InvalidOperationException($"No enabled manifest entry was found for '{tableSlug}'."); } - private CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + private async Task LoadExtractedSourceAsync(CriticalImportManifestEntry entry, ImportArtifactPaths artifactPaths) + { + var extractor = CreateSourceExtractor(entry); + var sourceArtifactPath = artifactPaths.GetSourceArtifactPath(entry.ExtractionMethod); + if (!File.Exists(sourceArtifactPath)) + { + Console.Error.WriteLine($"Missing source artifact: {sourceArtifactPath}"); + throw new FileNotFoundException($"Missing source artifact: {sourceArtifactPath}", sourceArtifactPath); + } + + return await extractor.LoadAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths, CancellationToken.None); + } + + private CriticalTableParseResult Parse(CriticalImportManifestEntry entry, ExtractedCriticalSource source) { if (string.Equals(entry.Family, "standard", StringComparison.OrdinalIgnoreCase)) { - return standardParser.Parse(entry, xmlContent); + if (string.Equals(entry.ExtractionMethod, "ocr", StringComparison.OrdinalIgnoreCase)) + { + var template = StandardTableAxisTemplateCatalog.Resolve(entry.AxisTemplateSlug); + var layout = standardOcrBootstrapper.Bootstrap(source, template); + return standardParser.Parse(entry, source, layout); + } + + return standardParser.Parse(entry, source); } if (string.Equals(entry.Family, "variant_column", StringComparison.OrdinalIgnoreCase)) { - return variantColumnParser.Parse(entry, xmlContent); + return variantColumnParser.Parse(entry, source); } if (string.Equals(entry.Family, "grouped_variant", StringComparison.OrdinalIgnoreCase)) { - return groupedVariantParser.Parse(entry, xmlContent); + return groupedVariantParser.Parse(entry, source); } throw new InvalidOperationException($"Family '{entry.Family}' is not supported by the importer."); } + private ICriticalSourceExtractor CreateSourceExtractor(CriticalImportManifestEntry entry) + { + if (string.Equals(entry.ExtractionMethod, "xml", StringComparison.OrdinalIgnoreCase)) + { + return new XmlCriticalSourceExtractor(pdfXmlExtractor); + } + + if (string.Equals(entry.ExtractionMethod, "ocr", StringComparison.OrdinalIgnoreCase)) + { + return new OcrCriticalSourceExtractor(pdfXmlExtractor); + } + + throw new InvalidOperationException($"Extraction method '{entry.ExtractionMethod}' is not supported by the importer."); + } + private static ImportArtifactPaths CreateArtifactPaths(string slug) => ImportArtifactPaths.Create(RepositoryPaths.Discover().ArtifactsRootPath, slug); diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs index 0b5f3b6..49af10b 100644 --- a/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs +++ b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs @@ -6,6 +6,7 @@ public sealed class CriticalImportManifestEntry public string DisplayName { get; set; } = string.Empty; public string Family { get; set; } = string.Empty; public string ExtractionMethod { get; set; } = string.Empty; + public string? AxisTemplateSlug { get; set; } public string PdfPath { get; set; } = string.Empty; public bool Enabled { get; set; } = true; } diff --git a/src/RolemasterDb.ImportTool/CriticalSourceImageArtifactGenerator.cs b/src/RolemasterDb.ImportTool/CriticalSourceImageArtifactGenerator.cs index b6caaad..abca583 100644 --- a/src/RolemasterDb.ImportTool/CriticalSourceImageArtifactGenerator.cs +++ b/src/RolemasterDb.ImportTool/CriticalSourceImageArtifactGenerator.cs @@ -23,6 +23,7 @@ public sealed class CriticalSourceImageArtifactGenerator(PdfXmlExtractor pdfXmlE pdfPath, pageGeometry.PageNumber, artifactPaths.GetPageImagePath(pageGeometry.PageNumber), + parseResult.RenderProfile.RenderDpi, cancellationToken); } @@ -38,7 +39,7 @@ public sealed class CriticalSourceImageArtifactGenerator(PdfXmlExtractor pdfXmlE $"Missing page geometry for page {result.SourceBounds.PageNumber} in table '{parseResult.Table.Slug}'."); } - var crop = CreateCrop(result.SourceBounds, pageGeometry); + var crop = CreateCrop(result.SourceBounds, pageGeometry, parseResult.RenderProfile); var relativePath = artifactPaths.GetRelativeCellImagePath(result.GroupKey, result.ColumnKey, result.RollBandLabel); var fullPath = artifactPaths.ResolveRelativePath(relativePath); @@ -50,6 +51,7 @@ public sealed class CriticalSourceImageArtifactGenerator(PdfXmlExtractor pdfXmlE crop.CropWidth, crop.CropHeight, fullPath, + parseResult.RenderProfile.RenderDpi, cancellationToken); result.SourceImagePath = relativePath; @@ -66,7 +68,8 @@ public sealed class CriticalSourceImageArtifactGenerator(PdfXmlExtractor pdfXmlE private static CriticalSourceImageCrop CreateCrop( ParsedCriticalSourceRect sourceBounds, - ParsedPdfPageGeometry pageGeometry) + ParsedPdfPageGeometry pageGeometry, + SourceRenderProfile renderProfile) { var cropLeft = Math.Max(0, sourceBounds.Left - CropPaddingX); var cropTop = Math.Max(0, sourceBounds.Top - CropPaddingY); @@ -75,18 +78,18 @@ public sealed class CriticalSourceImageArtifactGenerator(PdfXmlExtractor pdfXmlE return new CriticalSourceImageCrop( sourceBounds.PageNumber, - PdfXmlExtractor.ScaleCoordinate(pageGeometry.Width), - PdfXmlExtractor.ScaleCoordinate(pageGeometry.Height), - PdfXmlExtractor.ScaleCoordinate(sourceBounds.Left), - PdfXmlExtractor.ScaleCoordinate(sourceBounds.Top), - PdfXmlExtractor.ScaleCoordinate(sourceBounds.Width), - PdfXmlExtractor.ScaleCoordinate(sourceBounds.Height), - PdfXmlExtractor.ScaleCoordinate(cropLeft), - PdfXmlExtractor.ScaleCoordinate(cropTop), - PdfXmlExtractor.ScaleCoordinate(Math.Max(1, cropRight - cropLeft)), - PdfXmlExtractor.ScaleCoordinate(Math.Max(1, cropBottom - cropTop)), - PdfXmlExtractor.ScaledRenderDpi, - PdfXmlExtractor.RenderScaleFactor); + renderProfile.ScaleCoordinate(pageGeometry.Width), + renderProfile.ScaleCoordinate(pageGeometry.Height), + renderProfile.ScaleCoordinate(sourceBounds.Left), + renderProfile.ScaleCoordinate(sourceBounds.Top), + renderProfile.ScaleCoordinate(sourceBounds.Width), + renderProfile.ScaleCoordinate(sourceBounds.Height), + renderProfile.ScaleCoordinate(cropLeft), + renderProfile.ScaleCoordinate(cropTop), + renderProfile.ScaleCoordinate(Math.Max(1, cropRight - cropLeft)), + renderProfile.ScaleCoordinate(Math.Max(1, cropBottom - cropTop)), + renderProfile.RenderDpi, + renderProfile.ScaleFactor); } private static string CreateCellKey(string? groupKey, string rollBandLabel, string columnKey) => diff --git a/src/RolemasterDb.ImportTool/ExtractOptions.cs b/src/RolemasterDb.ImportTool/ExtractOptions.cs index 481849c..08e5bf6 100644 --- a/src/RolemasterDb.ImportTool/ExtractOptions.cs +++ b/src/RolemasterDb.ImportTool/ExtractOptions.cs @@ -2,7 +2,7 @@ using CommandLine; namespace RolemasterDb.ImportTool; -[Verb("extract", HelpText = "Extract a critical table PDF into a text artifact.")] +[Verb("extract", HelpText = "Extract a critical table PDF into its source artifact.")] public sealed class ExtractOptions { [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to extract.")] diff --git a/src/RolemasterDb.ImportTool/ExtractedCriticalSource.cs b/src/RolemasterDb.ImportTool/ExtractedCriticalSource.cs new file mode 100644 index 0000000..7fbd84d --- /dev/null +++ b/src/RolemasterDb.ImportTool/ExtractedCriticalSource.cs @@ -0,0 +1,17 @@ +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool; + +public sealed class ExtractedCriticalSource( + string extractionMethod, + string importNotes, + SourceRenderProfile renderProfile, + IReadOnlyList pageGeometries, + IReadOnlyList fragments) +{ + public string ExtractionMethod { get; } = extractionMethod; + public string ImportNotes { get; } = importNotes; + public SourceRenderProfile RenderProfile { get; } = renderProfile; + public IReadOnlyList PageGeometries { get; } = pageGeometries; + public IReadOnlyList Fragments { get; } = fragments; +} diff --git a/src/RolemasterDb.ImportTool/ICriticalSourceExtractor.cs b/src/RolemasterDb.ImportTool/ICriticalSourceExtractor.cs new file mode 100644 index 0000000..0d7339b --- /dev/null +++ b/src/RolemasterDb.ImportTool/ICriticalSourceExtractor.cs @@ -0,0 +1,11 @@ +namespace RolemasterDb.ImportTool; + +public interface ICriticalSourceExtractor +{ + Task ExtractAsync(string pdfPath, ImportArtifactPaths artifactPaths, CancellationToken cancellationToken = default); + + Task LoadAsync( + string pdfPath, + ImportArtifactPaths artifactPaths, + CancellationToken cancellationToken = default); +} diff --git a/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs index cf6ee6b..d4ea8eb 100644 --- a/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs +++ b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs @@ -9,9 +9,11 @@ public sealed class ImportArtifactPaths string tableSlug, string directoryPath, string xmlPath, + string ocrTsvPath, string fragmentsJsonPath, string parsedCellsJsonPath, string validationReportPath, + string ocrPagesDirectoryPath, string pagesDirectoryPath, string cellsDirectoryPath) { @@ -19,9 +21,11 @@ public sealed class ImportArtifactPaths TableSlug = tableSlug; DirectoryPath = directoryPath; XmlPath = xmlPath; + OcrTsvPath = ocrTsvPath; FragmentsJsonPath = fragmentsJsonPath; ParsedCellsJsonPath = parsedCellsJsonPath; ValidationReportPath = validationReportPath; + OcrPagesDirectoryPath = ocrPagesDirectoryPath; PagesDirectoryPath = pagesDirectoryPath; CellsDirectoryPath = cellsDirectoryPath; } @@ -30,15 +34,18 @@ public sealed class ImportArtifactPaths public string TableSlug { get; } public string DirectoryPath { get; } public string XmlPath { get; } + public string OcrTsvPath { get; } public string FragmentsJsonPath { get; } public string ParsedCellsJsonPath { get; } public string ValidationReportPath { get; } + public string OcrPagesDirectoryPath { get; } public string PagesDirectoryPath { get; } public string CellsDirectoryPath { get; } public static ImportArtifactPaths Create(string artifactsRootPath, string tableSlug) { var directoryPath = Path.Combine(artifactsRootPath, tableSlug); + var ocrPagesDirectoryPath = Path.Combine(directoryPath, "ocr-pages"); var pagesDirectoryPath = Path.Combine(directoryPath, "pages"); var cellsDirectoryPath = Path.Combine(directoryPath, "cells"); @@ -47,13 +54,23 @@ public sealed class ImportArtifactPaths tableSlug, directoryPath, Path.Combine(directoryPath, "source.xml"), + Path.Combine(directoryPath, "source.ocr.tsv"), Path.Combine(directoryPath, "fragments.json"), Path.Combine(directoryPath, "parsed-cells.json"), Path.Combine(directoryPath, "validation-report.json"), + ocrPagesDirectoryPath, pagesDirectoryPath, cellsDirectoryPath); } + public string GetSourceArtifactPath(string extractionMethod) => + string.Equals(extractionMethod, "ocr", StringComparison.OrdinalIgnoreCase) + ? OcrTsvPath + : XmlPath; + + public string GetOcrPageImagePath(int pageNumber) => + Path.Combine(OcrPagesDirectoryPath, $"page-{pageNumber:000}.png"); + public string GetPageImagePath(int pageNumber) => Path.Combine(PagesDirectoryPath, $"page-{pageNumber:000}.png"); diff --git a/src/RolemasterDb.ImportTool/LoadOptions.cs b/src/RolemasterDb.ImportTool/LoadOptions.cs index 64a0f17..e9343ff 100644 --- a/src/RolemasterDb.ImportTool/LoadOptions.cs +++ b/src/RolemasterDb.ImportTool/LoadOptions.cs @@ -2,7 +2,7 @@ using CommandLine; namespace RolemasterDb.ImportTool; -[Verb("load", HelpText = "Load a parsed critical table from its extracted text artifact.")] +[Verb("load", HelpText = "Load a parsed critical table from its extracted source artifact.")] public sealed class LoadOptions { [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to load.")] diff --git a/src/RolemasterDb.ImportTool/OcrCriticalSourceExtractor.cs b/src/RolemasterDb.ImportTool/OcrCriticalSourceExtractor.cs new file mode 100644 index 0000000..a1897df --- /dev/null +++ b/src/RolemasterDb.ImportTool/OcrCriticalSourceExtractor.cs @@ -0,0 +1,204 @@ +using System.Globalization; +using System.Text; + +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool; + +public sealed class OcrCriticalSourceExtractor(PdfXmlExtractor pdfXmlExtractor) : ICriticalSourceExtractor +{ + private const int OcrRenderDpi = PdfXmlExtractor.ScaledRenderDpi; + private const string TesseractExeDefaultPath = @"C:\Program Files\Sejda PDF Desktop\resources\vendor\tesseract-windows-x64\tesseract.exe"; + private const string TessdataDefaultPath = @"C:\Program Files\Sejda PDF Desktop\resources\vendor\tessdata"; + + public async Task ExtractAsync(string pdfPath, ImportArtifactPaths artifactPaths, CancellationToken cancellationToken = default) + { + Directory.CreateDirectory(artifactPaths.DirectoryPath); + Directory.CreateDirectory(artifactPaths.OcrPagesDirectoryPath); + + var info = await pdfXmlExtractor.ReadDocumentInfoAsync(pdfPath, cancellationToken); + if (info.PageCount != 1) + { + throw new InvalidOperationException("The OCR extractor currently supports only single-page critical tables."); + } + + var pageImagePath = artifactPaths.GetOcrPageImagePath(1); + await pdfXmlExtractor.RenderPagePngAsync(pdfPath, 1, pageImagePath, OcrRenderDpi, cancellationToken); + + var tsvContent = await RunTesseractAsync(pageImagePath, cancellationToken); + await File.WriteAllTextAsync(artifactPaths.OcrTsvPath, tsvContent, cancellationToken); + } + + public async Task LoadAsync( + string pdfPath, + ImportArtifactPaths artifactPaths, + CancellationToken cancellationToken = default) + { + if (!File.Exists(artifactPaths.OcrTsvPath)) + { + throw new FileNotFoundException($"Missing OCR artifact: {artifactPaths.OcrTsvPath}", artifactPaths.OcrTsvPath); + } + + var pageImagePath = artifactPaths.GetOcrPageImagePath(1); + if (!File.Exists(pageImagePath)) + { + throw new FileNotFoundException($"Missing OCR page image artifact: {pageImagePath}", pageImagePath); + } + + var tsvContent = await File.ReadAllTextAsync(artifactPaths.OcrTsvPath, cancellationToken); + var (pageWidth, pageHeight) = ReadPngDimensions(pageImagePath); + + return new ExtractedCriticalSource( + "ocr", + "Imported from PDF OCR extraction.", + SourceRenderProfile.OcrPixels(OcrRenderDpi), + [new ParsedPdfPageGeometry(1, pageWidth, pageHeight)], + ParseTsv(tsvContent)); + } + + internal static IReadOnlyList ParseTsv(string tsvContent) + { + var lines = tsvContent + .Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries) + .ToList(); + if (lines.Count == 0) + { + return []; + } + + var fragments = new List(); + foreach (var line in lines.Skip(1)) + { + var columns = line.Split('\t'); + if (columns.Length < 12 || columns[0] != "5") + { + continue; + } + + var text = CriticalTableParserSupport.NormalizeText(string.Join('\t', columns.Skip(11))); + if (string.IsNullOrWhiteSpace(text)) + { + continue; + } + + fragments.Add(new PositionedTextFragment( + int.Parse(columns[1], CultureInfo.InvariantCulture), + int.Parse(columns[7], CultureInfo.InvariantCulture), + int.Parse(columns[6], CultureInfo.InvariantCulture), + int.Parse(columns[8], CultureInfo.InvariantCulture), + int.Parse(columns[9], CultureInfo.InvariantCulture), + text, + ParseConfidence(columns[10]))); + } + + return fragments; + } + + private static int? ParseConfidence(string value) => + int.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var confidence) && confidence >= 0 + ? confidence + : null; + + private static (int Width, int Height) ReadPngDimensions(string path) + { + using var stream = File.OpenRead(path); + using var reader = new BinaryReader(stream, Encoding.UTF8, leaveOpen: false); + var signature = reader.ReadBytes(8); + var expectedSignature = new byte[] { 137, 80, 78, 71, 13, 10, 26, 10 }; + if (!signature.SequenceEqual(expectedSignature)) + { + throw new InvalidOperationException($"'{path}' is not a PNG file."); + } + + _ = ReadBigEndianInt32(reader); + var chunkType = Encoding.ASCII.GetString(reader.ReadBytes(4)); + if (!string.Equals(chunkType, "IHDR", StringComparison.Ordinal)) + { + throw new InvalidOperationException($"'{path}' is missing a PNG IHDR header."); + } + + var width = ReadBigEndianInt32(reader); + var height = ReadBigEndianInt32(reader); + return (width, height); + } + + private static int ReadBigEndianInt32(BinaryReader reader) + { + var bytes = reader.ReadBytes(4); + if (bytes.Length != 4) + { + throw new EndOfStreamException("Unexpected end of stream."); + } + + if (BitConverter.IsLittleEndian) + { + Array.Reverse(bytes); + } + + return BitConverter.ToInt32(bytes, 0); + } + + private static async Task RunTesseractAsync(string imagePath, CancellationToken cancellationToken) + { + var startInfo = new System.Diagnostics.ProcessStartInfo + { + FileName = ResolveTesseractExecutable(), + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.Environment["TESSDATA_PREFIX"] = ResolveTessdataPath(); + startInfo.ArgumentList.Add(imagePath); + startInfo.ArgumentList.Add("stdout"); + startInfo.ArgumentList.Add("--psm"); + startInfo.ArgumentList.Add("11"); + startInfo.ArgumentList.Add("tsv"); + + using var process = new System.Diagnostics.Process { StartInfo = startInfo }; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException($"tesseract failed for '{imagePath}': {error}"); + } + + return output; + } + + private static string ResolveTesseractExecutable() + { + var configuredPath = Environment.GetEnvironmentVariable("ROLEMASTERDB_TESSERACT_PATH"); + if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath)) + { + return configuredPath; + } + + if (File.Exists(TesseractExeDefaultPath)) + { + return TesseractExeDefaultPath; + } + + return "tesseract"; + } + + private static string ResolveTessdataPath() + { + var configuredPath = Environment.GetEnvironmentVariable("ROLEMASTERDB_TESSDATA_PREFIX"); + if (!string.IsNullOrWhiteSpace(configuredPath) && Directory.Exists(configuredPath)) + { + return configuredPath; + } + + if (Directory.Exists(TessdataDefaultPath)) + { + return TessdataDefaultPath; + } + + return string.Empty; + } +} diff --git a/src/RolemasterDb.ImportTool/Parsing/ColumnarCellLine.cs b/src/RolemasterDb.ImportTool/Parsing/ColumnarCellLine.cs index b0f7914..2a9c546 100644 --- a/src/RolemasterDb.ImportTool/Parsing/ColumnarCellLine.cs +++ b/src/RolemasterDb.ImportTool/Parsing/ColumnarCellLine.cs @@ -1,7 +1,7 @@ namespace RolemasterDb.ImportTool.Parsing; -internal sealed class ColumnarCellLine(string text, List fragments) +internal sealed class ColumnarCellLine(string text, List fragments) { public string Text { get; } = text; - public List Fragments { get; } = fragments; + public List Fragments { get; } = fragments; } diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParseResult.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParseResult.cs index 1d45e98..8181edd 100644 --- a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParseResult.cs +++ b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParseResult.cs @@ -3,13 +3,15 @@ namespace RolemasterDb.ImportTool.Parsing; public sealed class CriticalTableParseResult( ParsedCriticalTable table, IReadOnlyList pageGeometries, - IReadOnlyList fragments, + IReadOnlyList fragments, + SourceRenderProfile renderProfile, IReadOnlyList cells, ImportValidationReport validationReport) { public ParsedCriticalTable Table { get; } = table; public IReadOnlyList PageGeometries { get; } = pageGeometries; - public IReadOnlyList Fragments { get; } = fragments; + public IReadOnlyList Fragments { get; } = fragments; + public SourceRenderProfile RenderProfile { get; } = renderProfile; public IReadOnlyList Cells { get; } = cells; public ImportValidationReport ValidationReport { get; } = validationReport; } diff --git a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs index 4143554..32e296e 100644 --- a/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs +++ b/src/RolemasterDb.ImportTool/Parsing/CriticalTableParserSupport.cs @@ -22,7 +22,7 @@ internal static class CriticalTableParserSupport private static readonly Regex StandaloneModifierAffixLineRegex = new(@"^(?:\d+)?\((?:\+|-|–)\d+\)$", RegexOptions.Compiled); private static readonly Regex BoundaryBonusLineRegex = new(@"^(?:all allies|all foe's allies|all foes|all opponents)\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - internal static List LoadFragments(string xmlContent) + internal static List LoadFragments(string xmlContent) { using var stringReader = new StringReader(xmlContent); using var xmlReader = XmlReader.Create( @@ -39,7 +39,7 @@ internal static class CriticalTableParserSupport { var pageNumber = int.Parse(page.Attribute("number")?.Value ?? "1"); return page.Elements("text") - .Select(item => new XmlTextFragment( + .Select(item => new PositionedTextFragment( pageNumber, int.Parse(item.Attribute("top")?.Value ?? throw new InvalidOperationException("Missing text top attribute.")), int.Parse(item.Attribute("left")?.Value ?? throw new InvalidOperationException("Missing text left attribute.")), @@ -73,8 +73,8 @@ internal static class CriticalTableParserSupport .ToList(); } - internal static List FindRowLabelFragments( - IReadOnlyList fragments, + internal static List FindRowLabelFragments( + IReadOnlyList fragments, int leftCutoff, int bodyStartTop, int keyTop) @@ -89,7 +89,7 @@ internal static class CriticalTableParserSupport .ThenBy(item => item.Left) .ToList(); - var merged = new List(); + var merged = new List(); for (var index = 0; index < candidates.Count; index++) { @@ -107,7 +107,7 @@ internal static class CriticalTableParserSupport } } - var deduped = new List(); + var deduped = new List(); foreach (var candidate in merged) { @@ -128,7 +128,7 @@ internal static class CriticalTableParserSupport internal static bool IsRollBandLabel(string value) => Regex.IsMatch(value.Trim(), @"^\d{2,3}(?:\s*-\s*\d{2,3})?$|^\d{2,3}\+$"); - internal static bool IsPotentialRowLabelFragment(XmlTextFragment fragment, int leftCutoff) => + internal static bool IsPotentialRowLabelFragment(PositionedTextFragment fragment, int leftCutoff) => fragment.Left < leftCutoff && (IsRollBandLabel(fragment.Text) || LooksLikeSplitRollBandStart(fragment.Text)); @@ -163,9 +163,9 @@ internal static class CriticalTableParserSupport return columns[^1].Key; } - internal static IReadOnlyList BuildLines(IReadOnlyList fragments) + internal static IReadOnlyList BuildLines(IReadOnlyList fragments) { - var lines = new List>(); + var lines = new List>(); foreach (var fragment in fragments.OrderBy(item => item.Top).ThenBy(item => item.Left)) { @@ -292,9 +292,9 @@ internal static class CriticalTableParserSupport .Replace('’', '\'') .Trim(); - private static List RemoveRedundantContainedFragments(IReadOnlyList fragments) + private static List RemoveRedundantContainedFragments(IReadOnlyList fragments) { - var redundant = new HashSet(); + var redundant = new HashSet(); foreach (var group in fragments.GroupBy(item => (item.PageNumber, item.Top, item.Height))) { @@ -331,7 +331,7 @@ internal static class CriticalTableParserSupport .ToList(); } - private static bool IsHorizontallyContained(XmlTextFragment candidate, XmlTextFragment container) + private static bool IsHorizontallyContained(PositionedTextFragment candidate, PositionedTextFragment container) { const int containmentTolerance = 1; @@ -353,7 +353,7 @@ internal static class CriticalTableParserSupport return normalized.Length == 0 ? null : normalized; } - internal static int FindKeyTop(IReadOnlyList fragments) => + internal static int FindKeyTop(IReadOnlyList fragments) => fragments .Where(item => string.Equals(item.Text, "Key:", StringComparison.OrdinalIgnoreCase) || @@ -362,7 +362,7 @@ internal static class CriticalTableParserSupport .Select(item => (int?)item.Top) .Min() ?? int.MaxValue; - internal static AffixLegend ParseAffixLegend(IReadOnlyList fragments, int keyTop) + internal static AffixLegend ParseAffixLegend(IReadOnlyList fragments, int keyTop) { if (keyTop == int.MaxValue) { @@ -401,12 +401,12 @@ internal static class CriticalTableParserSupport supportsPowerPointModifier: footerText.Contains("powerpoint modification", StringComparison.OrdinalIgnoreCase)); } - internal static List SplitBoundaryCrossingFragments( - IReadOnlyList bodyFragments, + internal static List SplitBoundaryCrossingFragments( + IReadOnlyList bodyFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet affixLegendSymbols) { - var splitFragments = new List(bodyFragments.Count); + var splitFragments = new List(bodyFragments.Count); foreach (var fragment in bodyFragments) { @@ -417,7 +417,7 @@ internal static class CriticalTableParserSupport } internal static List<(int Top, bool IsAffixLike)> BuildBodyLines( - IReadOnlyList bodyFragments, + IReadOnlyList bodyFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet affixLegendSymbols) { @@ -440,7 +440,7 @@ internal static class CriticalTableParserSupport return bodyLines; } - internal static bool IsFooterPageNumberFragment(XmlTextFragment fragment, int keyTop) + internal static bool IsFooterPageNumberFragment(PositionedTextFragment fragment, int keyTop) { if (keyTop == int.MaxValue) { @@ -451,9 +451,9 @@ internal static class CriticalTableParserSupport Regex.IsMatch(fragment.Text, @"^\d{2,3}$"); } - internal static IEnumerable> GroupByTop(IReadOnlyList fragments) + internal static IEnumerable> GroupByTop(IReadOnlyList fragments) { - var groups = new List>(); + var groups = new List>(); foreach (var fragment in fragments) { @@ -469,7 +469,7 @@ internal static class CriticalTableParserSupport return groups; } - internal static List CreateRowAnchors(IReadOnlyList rowLabelFragments) => + internal static List CreateRowAnchors(IReadOnlyList rowLabelFragments) => rowLabelFragments .OrderBy(item => item.Top) .Select((item, index) => new RowAnchor(NormalizeRollBandLabel(item.Text), item.Top, index + 1)) @@ -489,13 +489,13 @@ internal static class CriticalTableParserSupport rowAnchors[0].Top - HeaderToRowLabelMinimumGap - TopGroupingTolerance)); } - internal static List BuildBodyFragments( - IReadOnlyList fragments, + internal static List BuildBodyFragments( + IReadOnlyList fragments, int bodyStartTop, int keyTop, int leftCutoff, IReadOnlyList rowAnchors, - IReadOnlyCollection excludedFragments, + IReadOnlyCollection excludedFragments, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet affixLegendSymbols) { @@ -580,7 +580,9 @@ internal static class CriticalTableParserSupport AffixLegend affixLegend, List parsedCells, List parsedResults, - List validationErrors) + List validationErrors, + List? validationWarnings = null, + bool downgradeCellContentValidationToWarnings = false) { var sharedLegend = ToSharedAffixLegend(affixLegend); @@ -589,8 +591,16 @@ internal static class CriticalTableParserSupport var lineTexts = cellEntry.Lines.Select(line => line.Text).ToList(); var content = SharedParsing.CriticalCellTextParser.Parse(lineTexts, sharedLegend); var sourceBounds = BuildSourceBounds(cellEntry.Lines.SelectMany(line => line.Fragments).ToList()); - validationErrors.AddRange(content.ValidationErrors.Select(error => - $"Cell '{BuildCellIdentifier(cellEntry)}': {error}")); + var contentIssues = content.ValidationErrors.Select(error => + $"Cell '{BuildCellIdentifier(cellEntry)}': {error}"); + if (downgradeCellContentValidationToWarnings) + { + validationWarnings?.AddRange(contentIssues); + } + else + { + validationErrors.AddRange(contentIssues); + } var effects = content.Effects.Select(ToImportToolEffect).ToList(); var branches = content.Branches.Select(ToImportToolBranch).ToList(); @@ -621,7 +631,7 @@ internal static class CriticalTableParserSupport } } - private static ParsedCriticalSourceRect BuildSourceBounds(IReadOnlyList fragments) + private static ParsedCriticalSourceRect BuildSourceBounds(IReadOnlyList fragments) { if (fragments.Count == 0) { @@ -688,7 +698,7 @@ internal static class CriticalTableParserSupport private static bool LooksLikeSplitRollBandStart(string value) => Regex.IsMatch(value.Trim(), @"^\d{2,3}\s*-$"); - private static bool TryMergeSplitRollBand(IReadOnlyList candidates, int index, out XmlTextFragment mergedCandidate) + private static bool TryMergeSplitRollBand(IReadOnlyList candidates, int index, out PositionedTextFragment mergedCandidate) { var current = candidates[index]; if (!LooksLikeSplitRollBandStart(current.Text) || index + 1 >= candidates.Count) @@ -712,7 +722,7 @@ internal static class CriticalTableParserSupport var mergedLabel = $"{startDigits}-{next.Text.Trim()}"; var right = Math.Max(current.Left + current.Width, next.Left + next.Width); - mergedCandidate = new XmlTextFragment( + mergedCandidate = new PositionedTextFragment( current.PageNumber, current.Top, Math.Min(current.Left, next.Left), @@ -722,8 +732,8 @@ internal static class CriticalTableParserSupport return true; } - private static IReadOnlyList SplitBoundaryCrossingFragment( - XmlTextFragment fragment, + private static IReadOnlyList SplitBoundaryCrossingFragment( + PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters, IReadOnlySet affixLegendSymbols) { @@ -746,8 +756,8 @@ internal static class CriticalTableParserSupport return [fragment]; } - private static IReadOnlyList BuildSplitFragmentsFromMatches( - XmlTextFragment fragment, + private static IReadOnlyList BuildSplitFragmentsFromMatches( + PositionedTextFragment fragment, MatchCollection matches, IReadOnlyList<(string Key, double CenterX)> columnCenters) { @@ -757,7 +767,7 @@ internal static class CriticalTableParserSupport } var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1); - var splitFragments = new List(matches.Count); + var splitFragments = new List(matches.Count); foreach (Match match in matches) { @@ -770,7 +780,7 @@ internal static class CriticalTableParserSupport var segmentLeft = fragment.Left + (int)Math.Round(characterWidth * match.Index); var segmentWidth = Math.Max(1, (int)Math.Round(characterWidth * match.Length)); - splitFragments.Add(new XmlTextFragment( + splitFragments.Add(new PositionedTextFragment( fragment.PageNumber, fragment.Top, segmentLeft, @@ -796,9 +806,9 @@ internal static class CriticalTableParserSupport } private static bool TrySplitProseFragmentAtBoundaries( - XmlTextFragment fragment, + PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters, - out IReadOnlyList splitFragments) + out IReadOnlyList splitFragments) { splitFragments = null!; @@ -808,7 +818,7 @@ internal static class CriticalTableParserSupport return false; } - var segments = new List(); + var segments = new List(); var segmentStart = 0; var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1); @@ -839,7 +849,7 @@ internal static class CriticalTableParserSupport } private static List FindBoundarySplitIndexes( - XmlTextFragment fragment, + PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters) { var characterWidth = fragment.Width / (double)Math.Max(fragment.Text.Length, 1); @@ -907,8 +917,8 @@ internal static class CriticalTableParserSupport return bestIndex; } - private static XmlTextFragment? CreateFragmentSegment( - XmlTextFragment fragment, + private static PositionedTextFragment? CreateFragmentSegment( + PositionedTextFragment fragment, int startIndex, int length, double characterWidth) @@ -940,7 +950,7 @@ internal static class CriticalTableParserSupport var actualLength = trimmedEnd - trimmedStart + 1; var segmentText = CollapseWhitespace(fragment.Text.Substring(actualStart, actualLength)); - return new XmlTextFragment( + return new PositionedTextFragment( fragment.PageNumber, fragment.Top, fragment.Left + (int)Math.Round(characterWidth * actualStart), @@ -950,7 +960,7 @@ internal static class CriticalTableParserSupport } private static bool CrossesColumnBoundary( - XmlTextFragment fragment, + PositionedTextFragment fragment, IReadOnlyList<(string Key, double CenterX)> columnCenters) { var fragmentRight = fragment.Left + fragment.Width; diff --git a/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs index 9ad7e43..e4b0cfe 100644 --- a/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs +++ b/src/RolemasterDb.ImportTool/Parsing/GroupedVariantCriticalTableParser.cs @@ -14,10 +14,10 @@ public sealed class GroupedVariantCriticalTableParser new("SLAYING", "Slaying", "variant", 2) ]; - public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, ExtractedCriticalSource source) { - var fragments = CriticalTableParserSupport.LoadFragments(xmlContent); - var pageGeometries = CriticalTableParserSupport.LoadPageGeometries(xmlContent); + var fragments = source.Fragments; + var pageGeometries = source.PageGeometries; var groupHeaders = FindGroupHeaders(fragments); var columnHeaders = FindColumnHeaders(fragments); var validationErrors = new List(); @@ -50,7 +50,7 @@ public sealed class GroupedVariantCriticalTableParser if (rowAnchors.Count == 0) { - validationErrors.Add("No roll-band labels were found in the XML artifact."); + validationErrors.Add("No roll-band labels were found in the source artifact."); } var columnCenters = combinedColumnAnchors @@ -136,16 +136,28 @@ public sealed class GroupedVariantCriticalTableParser entry.DisplayName, entry.Family, Path.GetFileName(entry.PdfPath), - "Imported from PDF XML extraction.", + source.ImportNotes, ExpectedGroups, ExpectedColumns, parsedRollBands, parsedResults); - return new CriticalTableParseResult(table, pageGeometries, fragments, parsedCells, validationReport); + return new CriticalTableParseResult(table, pageGeometries, fragments, source.RenderProfile, parsedCells, validationReport); } - private static List FindGroupHeaders(IReadOnlyList fragments) + public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + { + return Parse( + entry, + new ExtractedCriticalSource( + "xml", + "Imported from PDF XML extraction.", + SourceRenderProfile.XmlAligned(), + CriticalTableParserSupport.LoadPageGeometries(xmlContent), + CriticalTableParserSupport.LoadFragments(xmlContent))); + } + + private static List FindGroupHeaders(IReadOnlyList fragments) { var expectedLabels = ExpectedGroups.Select(item => item.Label).ToList(); var headerCandidates = fragments @@ -164,10 +176,10 @@ public sealed class GroupedVariantCriticalTableParser } } - throw new InvalidOperationException("Could not find the grouped-variant section headers in the XML artifact."); + throw new InvalidOperationException("Could not find the grouped-variant section headers in the source artifact."); } - private static List FindColumnHeaders(IReadOnlyList fragments) + private static List FindColumnHeaders(IReadOnlyList fragments) { var expectedLabels = new[] { "normal", "slaying", "normal", "slaying" }; var headerCandidates = fragments @@ -190,6 +202,6 @@ public sealed class GroupedVariantCriticalTableParser } } - throw new InvalidOperationException("Could not find the grouped-variant column header row in the XML artifact."); + throw new InvalidOperationException("Could not find the grouped-variant column header row in the source artifact."); } } diff --git a/src/RolemasterDb.ImportTool/Parsing/PositionedTextFragment.cs b/src/RolemasterDb.ImportTool/Parsing/PositionedTextFragment.cs new file mode 100644 index 0000000..918474b --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/PositionedTextFragment.cs @@ -0,0 +1,20 @@ +namespace RolemasterDb.ImportTool.Parsing; + +public class PositionedTextFragment( + int pageNumber, + int top, + int left, + int width, + int height, + string text, + int? confidence = null) +{ + public int PageNumber { get; } = pageNumber; + public int Top { get; } = top; + public int Left { get; } = left; + public int Width { get; } = width; + public int Height { get; } = height; + public string Text { get; } = text; + public int? Confidence { get; } = confidence; + public double CenterX => Left + (Width / 2.0); +} diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParseResult.cs b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParseResult.cs index 0b5182a..d0f5f7e 100644 --- a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParseResult.cs +++ b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParseResult.cs @@ -2,12 +2,14 @@ namespace RolemasterDb.ImportTool.Parsing; public sealed class StandardCriticalTableParseResult( ParsedCriticalTable table, - IReadOnlyList fragments, + IReadOnlyList fragments, + SourceRenderProfile renderProfile, IReadOnlyList cells, ImportValidationReport validationReport) { public ParsedCriticalTable Table { get; } = table; - public IReadOnlyList Fragments { get; } = fragments; + public IReadOnlyList Fragments { get; } = fragments; + public SourceRenderProfile RenderProfile { get; } = renderProfile; public IReadOnlyList Cells { get; } = cells; public ImportValidationReport ValidationReport { get; } = validationReport; } diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs index 8560cac..e8ac01b 100644 --- a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs +++ b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs @@ -2,23 +2,140 @@ namespace RolemasterDb.ImportTool.Parsing; public sealed class StandardCriticalTableParser { - public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + internal CriticalTableParseResult Parse(CriticalImportManifestEntry entry, ExtractedCriticalSource source, StandardTableLayout? layout = null) { - var fragments = CriticalTableParserSupport.LoadFragments(xmlContent); - var pageGeometries = CriticalTableParserSupport.LoadPageGeometries(xmlContent); - var headerFragments = FindHeaderFragments(fragments); + var fragments = source.Fragments; + var pageGeometries = source.PageGeometries; var validationErrors = new List(); var validationWarnings = new List(); + layout ??= BuildLayout(fragments, validationErrors); + validationWarnings.AddRange(layout.Warnings); + + var affixLegend = CriticalTableParserSupport.ParseAffixLegend(fragments, layout.KeyTop); + var affixLegendSymbols = affixLegend.ClassificationSymbols; + var bodyFragments = CriticalTableParserSupport.BuildBodyFragments( + fragments, + layout.BodyStartTop, + layout.KeyTop, + layout.LeftCutoff, + layout.RowAnchors, + layout.ExcludedFragments, + layout.ColumnCenters, + affixLegendSymbols); + var bodyLines = CriticalTableParserSupport.BuildBodyLines(bodyFragments, layout.ColumnCenters, affixLegendSymbols); + + var parsedRollBands = layout.RowAnchors + .Select(anchor => CriticalTableParserSupport.CreateRollBand(anchor.Label, anchor.SortOrder)) + .ToList(); + + var cellEntries = new List(); + + for (var rowIndex = 0; rowIndex < layout.RowAnchors.Count; rowIndex++) + { + var rowStart = rowIndex == 0 + ? layout.BodyStartTop + : CriticalTableParserSupport.ResolveRowBoundaryTop(layout.RowAnchors[rowIndex - 1], layout.RowAnchors[rowIndex], bodyLines); + + var rowEnd = rowIndex == layout.RowAnchors.Count - 1 + ? layout.KeyTop - 1 + : CriticalTableParserSupport.ResolveRowBoundaryTop(layout.RowAnchors[rowIndex], layout.RowAnchors[rowIndex + 1], bodyLines); + + var rowFragments = bodyFragments + .Where(item => item.Top >= rowStart && item.Top < rowEnd) + .ToList(); + + foreach (var columnAnchor in layout.ColumnCenters) + { + var cellFragments = rowFragments + .Where(item => CriticalTableParserSupport.ResolveColumn(item.CenterX, layout.ColumnCenters) == columnAnchor.Key) + .OrderBy(item => item.Top) + .ThenBy(item => item.Left) + .ToList(); + + if (cellFragments.Count == 0) + { + validationErrors.Add($"Missing content for roll band '{layout.RowAnchors[rowIndex].Label}', column '{columnAnchor.Key}'."); + continue; + } + + cellEntries.Add(new ColumnarCellEntry( + null, + layout.RowAnchors[rowIndex].Label, + rowIndex, + columnAnchor.Key, + CriticalTableParserSupport.BuildLines(cellFragments).ToList())); + } + } + + CriticalTableParserSupport.RepairLeadingAffixLeakage(cellEntries, affixLegendSymbols); + + var parsedCells = new List(); + var parsedResults = new List(); + CriticalTableParserSupport.BuildParsedArtifacts( + cellEntries, + affixLegend, + parsedCells, + parsedResults, + validationErrors, + validationWarnings, + downgradeCellContentValidationToWarnings: string.Equals(source.ExtractionMethod, "ocr", StringComparison.OrdinalIgnoreCase)); + + if (layout.ColumnCenters.Count != 5) + { + validationErrors.Add($"Expected 5 standard-table columns but found {layout.ColumnCenters.Count}."); + } + + if (parsedCells.Count != layout.RowAnchors.Count * layout.ColumnCenters.Count) + { + validationErrors.Add( + $"Expected {layout.RowAnchors.Count * layout.ColumnCenters.Count} parsed cells but produced {parsedCells.Count}."); + } + + var validationReport = new ImportValidationReport( + validationErrors.Count == 0, + validationErrors, + validationWarnings, + layout.RowAnchors.Count, + parsedCells.Count); + + var table = new ParsedCriticalTable( + entry.Slug, + entry.DisplayName, + entry.Family, + Path.GetFileName(entry.PdfPath), + source.ImportNotes, + [], + layout.ColumnCenters.Select((item, index) => new ParsedCriticalColumn(item.Key, item.Key, "severity", index + 1)).ToList(), + parsedRollBands, + parsedResults); + + return new CriticalTableParseResult(table, pageGeometries, fragments, source.RenderProfile, parsedCells, validationReport); + } + + public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + { + return Parse( + entry, + new ExtractedCriticalSource( + "xml", + "Imported from PDF XML extraction.", + SourceRenderProfile.XmlAligned(), + CriticalTableParserSupport.LoadPageGeometries(xmlContent), + CriticalTableParserSupport.LoadFragments(xmlContent))); + } + + private static StandardTableLayout BuildLayout( + IReadOnlyList fragments, + ICollection validationErrors) + { + var headerFragments = FindHeaderFragments(fragments); var columnCenters = headerFragments .OrderBy(item => item.Left) .Select(item => (Key: item.Text.ToUpperInvariant(), CenterX: item.CenterX)) .ToList(); - var headerTop = headerFragments.Max(item => item.Top); var keyTop = CriticalTableParserSupport.FindKeyTop(fragments); - var affixLegend = CriticalTableParserSupport.ParseAffixLegend(fragments, keyTop); - var affixLegendSymbols = affixLegend.ClassificationSymbols; var leftCutoff = headerFragments.Min(item => item.Left) - 10; var rowLabelFragments = CriticalTableParserSupport.FindRowLabelFragments( fragments, @@ -30,102 +147,13 @@ public sealed class StandardCriticalTableParser if (rowAnchors.Count == 0) { - validationErrors.Add("No roll-band labels were found in the XML artifact."); + validationErrors.Add("No roll-band labels were found in the source artifact."); } - var bodyFragments = CriticalTableParserSupport.BuildBodyFragments( - fragments, - bodyStartTop, - keyTop, - leftCutoff, - rowAnchors, - headerFragments, - columnCenters, - affixLegendSymbols); - var bodyLines = CriticalTableParserSupport.BuildBodyLines(bodyFragments, columnCenters, affixLegendSymbols); - - var parsedRollBands = rowAnchors - .Select(anchor => CriticalTableParserSupport.CreateRollBand(anchor.Label, anchor.SortOrder)) - .ToList(); - - var cellEntries = new List(); - - for (var rowIndex = 0; rowIndex < rowAnchors.Count; rowIndex++) - { - var rowStart = rowIndex == 0 - ? bodyStartTop - : CriticalTableParserSupport.ResolveRowBoundaryTop(rowAnchors[rowIndex - 1], rowAnchors[rowIndex], bodyLines); - - var rowEnd = rowIndex == rowAnchors.Count - 1 - ? keyTop - 1 - : CriticalTableParserSupport.ResolveRowBoundaryTop(rowAnchors[rowIndex], rowAnchors[rowIndex + 1], bodyLines); - - var rowFragments = bodyFragments - .Where(item => item.Top >= rowStart && item.Top < rowEnd) - .ToList(); - - foreach (var columnAnchor in columnCenters) - { - var cellFragments = rowFragments - .Where(item => CriticalTableParserSupport.ResolveColumn(item.CenterX, columnCenters) == columnAnchor.Key) - .OrderBy(item => item.Top) - .ThenBy(item => item.Left) - .ToList(); - - if (cellFragments.Count == 0) - { - validationErrors.Add($"Missing content for roll band '{rowAnchors[rowIndex].Label}', column '{columnAnchor.Key}'."); - continue; - } - - cellEntries.Add(new ColumnarCellEntry( - null, - rowAnchors[rowIndex].Label, - rowIndex, - columnAnchor.Key, - CriticalTableParserSupport.BuildLines(cellFragments).ToList())); - } - } - - CriticalTableParserSupport.RepairLeadingAffixLeakage(cellEntries, affixLegendSymbols); - - var parsedCells = new List(); - var parsedResults = new List(); - CriticalTableParserSupport.BuildParsedArtifacts(cellEntries, affixLegend, parsedCells, parsedResults, validationErrors); - - if (columnCenters.Count != 5) - { - validationErrors.Add($"Expected 5 standard-table columns but found {columnCenters.Count}."); - } - - if (parsedCells.Count != rowAnchors.Count * columnCenters.Count) - { - validationErrors.Add( - $"Expected {rowAnchors.Count * columnCenters.Count} parsed cells but produced {parsedCells.Count}."); - } - - var validationReport = new ImportValidationReport( - validationErrors.Count == 0, - validationErrors, - validationWarnings, - rowAnchors.Count, - parsedCells.Count); - - var table = new ParsedCriticalTable( - entry.Slug, - entry.DisplayName, - entry.Family, - Path.GetFileName(entry.PdfPath), - "Imported from PDF XML extraction.", - [], - columnCenters.Select((item, index) => new ParsedCriticalColumn(item.Key, item.Key, "severity", index + 1)).ToList(), - parsedRollBands, - parsedResults); - - return new CriticalTableParseResult(table, pageGeometries, fragments, parsedCells, validationReport); + return new StandardTableLayout(headerFragments, columnCenters, rowAnchors, headerTop, bodyStartTop, keyTop, leftCutoff); } - private static List FindHeaderFragments(IReadOnlyList fragments) + private static List FindHeaderFragments(IReadOnlyList fragments) { var headerCandidates = fragments .Where(item => item.Text.Length == 1 && char.IsLetter(item.Text[0])) @@ -143,6 +171,6 @@ public sealed class StandardCriticalTableParser } } - throw new InvalidOperationException("Could not find the standard-table A-E header row in the XML artifact."); + throw new InvalidOperationException("Could not find the standard-table A-E header row in the source artifact."); } } diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardOcrBootstrapper.cs b/src/RolemasterDb.ImportTool/Parsing/StandardOcrBootstrapper.cs new file mode 100644 index 0000000..ff523cb --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/StandardOcrBootstrapper.cs @@ -0,0 +1,150 @@ +namespace RolemasterDb.ImportTool.Parsing; + +internal sealed class StandardOcrBootstrapper +{ + private const int AnchorConfidenceWarningThreshold = 85; + private const int HeaderTopTolerance = 12; + + public StandardTableLayout Bootstrap(ExtractedCriticalSource source, StandardTableAxisTemplate template) + { + var fragments = source.Fragments; + var headerFragments = FindHeaderFragments(fragments, template); + var columnCenters = headerFragments + .OrderBy(item => item.Left) + .Select(item => (Key: NormalizeHeaderText(item.Text), CenterX: item.CenterX)) + .ToList(); + var headerTop = headerFragments.Max(item => item.Top); + var keyTop = CriticalTableParserSupport.FindKeyTop(fragments); + var leftCutoff = ResolveRowLabelLeftCutoff(headerFragments); + var rowLabelFragments = CriticalTableParserSupport.FindRowLabelFragments( + fragments, + leftCutoff, + headerTop + CriticalTableParserSupport.HeaderToRowLabelMinimumGap, + keyTop); + var rowAnchors = CriticalTableParserSupport.CreateRowAnchors(rowLabelFragments); + var bodyStartTop = CriticalTableParserSupport.ResolveBodyStartTop(headerTop, rowAnchors); + var warnings = new List(); + + if (rowAnchors.Count != template.RollBandLabels.Count) + { + throw new InvalidOperationException( + $"OCR bootstrap found {rowAnchors.Count} row anchors but template '{template.Slug}' expects {template.RollBandLabels.Count}."); + } + + var actualLabels = rowAnchors.Select(item => item.Label).ToList(); + if (!actualLabels.SequenceEqual(template.RollBandLabels, StringComparer.Ordinal)) + { + throw new InvalidOperationException( + $"OCR bootstrap row anchors do not match template '{template.Slug}'."); + } + + var fuzzyHeaders = headerFragments + .Where(item => !string.Equals(item.Text, NormalizeHeaderText(item.Text), StringComparison.Ordinal)) + .ToList(); + if (fuzzyHeaders.Count > 0) + { + warnings.Add( + $"OCR header normalization was applied for: {string.Join(", ", fuzzyHeaders.Select(item => $"'{item.Text}' -> '{NormalizeHeaderText(item.Text)}'"))}."); + } + + var lowConfidenceAnchors = headerFragments + .Concat(rowLabelFragments) + .Where(item => item.Confidence is int confidence && confidence < AnchorConfidenceWarningThreshold) + .Select(item => $"'{item.Text}' ({item.Confidence})") + .ToList(); + if (lowConfidenceAnchors.Count > 0) + { + warnings.Add($"Low-confidence OCR anchors: {string.Join(", ", lowConfidenceAnchors)}."); + } + + return new StandardTableLayout( + headerFragments, + columnCenters, + rowAnchors, + headerTop, + bodyStartTop, + keyTop, + leftCutoff, + warnings); + } + + private static List FindHeaderFragments( + IReadOnlyList fragments, + StandardTableAxisTemplate template) + { + var headerCandidates = fragments + .Where(item => TryNormalizeHeaderText(item.Text, out _)) + .OrderBy(item => item.Top) + .ThenBy(item => item.Left) + .ToList(); + + foreach (var group in GroupHeaderCandidates(headerCandidates)) + { + var ordered = group.OrderBy(item => item.Left).ToList(); + var labels = ordered.Select(item => NormalizeHeaderText(item.Text)).ToList(); + if (labels.SequenceEqual(template.ColumnKeys, StringComparer.Ordinal)) + { + return ordered; + } + } + + throw new InvalidOperationException("Could not find the OCR standard-table A-E header row."); + } + + private static string NormalizeHeaderText(string value) + { + if (!TryNormalizeHeaderText(value, out var normalized)) + { + throw new InvalidOperationException($"Unsupported OCR header fragment '{value}'."); + } + + return normalized; + } + + private static bool TryNormalizeHeaderText(string value, out string normalized) + { + normalized = value.Trim().ToUpperInvariant(); + if (normalized is "A" or "B" or "D" or "E") + { + return true; + } + + if (normalized is "C" or "CC") + { + normalized = "C"; + return true; + } + + return false; + } + + private static IEnumerable> GroupHeaderCandidates(IReadOnlyList fragments) + { + var groups = new List>(); + + foreach (var fragment in fragments) + { + if (groups.Count == 0 || Math.Abs(groups[^1][0].Top - fragment.Top) > HeaderTopTolerance) + { + groups.Add([fragment]); + continue; + } + + groups[^1].Add(fragment); + } + + return groups; + } + + private static int ResolveRowLabelLeftCutoff(IReadOnlyList headerFragments) + { + var ordered = headerFragments.OrderBy(item => item.Left).ToList(); + if (ordered.Count < 2) + { + return Math.Max(0, ordered[0].Left - 10); + } + + var firstColumnGap = ordered[1].Left - ordered[0].Left; + return Math.Max(0, ordered[0].Left - (firstColumnGap / 2)); + } +} diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplate.cs b/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplate.cs new file mode 100644 index 0000000..d5de067 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplate.cs @@ -0,0 +1,11 @@ +namespace RolemasterDb.ImportTool.Parsing; + +internal sealed class StandardTableAxisTemplate( + string slug, + IReadOnlyList columnKeys, + IReadOnlyList rollBandLabels) +{ + public string Slug { get; } = slug; + public IReadOnlyList ColumnKeys { get; } = columnKeys; + public IReadOnlyList RollBandLabels { get; } = rollBandLabels; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplateCatalog.cs b/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplateCatalog.cs new file mode 100644 index 0000000..04d7652 --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/StandardTableAxisTemplateCatalog.cs @@ -0,0 +1,17 @@ +namespace RolemasterDb.ImportTool.Parsing; + +internal static class StandardTableAxisTemplateCatalog +{ + internal static StandardTableAxisTemplate Resolve(string? slug) + { + if (string.Equals(slug, "mana-standard-19", StringComparison.OrdinalIgnoreCase)) + { + return new StandardTableAxisTemplate( + "mana-standard-19", + ["A", "B", "C", "D", "E"], + ["01-05", "06-10", "11-15", "16-20", "21-35", "36-45", "46-50", "51-55", "56-60", "61-65", "66", "67-70", "71-75", "76-80", "81-85", "86-90", "91-95", "96-99", "100"]); + } + + throw new InvalidOperationException($"Unsupported standard-table axis template '{slug ?? ""}'."); + } +} diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardTableLayout.cs b/src/RolemasterDb.ImportTool/Parsing/StandardTableLayout.cs new file mode 100644 index 0000000..517422a --- /dev/null +++ b/src/RolemasterDb.ImportTool/Parsing/StandardTableLayout.cs @@ -0,0 +1,21 @@ +namespace RolemasterDb.ImportTool.Parsing; + +internal sealed class StandardTableLayout( + IReadOnlyList excludedFragments, + IReadOnlyList<(string Key, double CenterX)> columnCenters, + IReadOnlyList rowAnchors, + int headerTop, + int bodyStartTop, + int keyTop, + int leftCutoff, + IReadOnlyList? warnings = null) +{ + public IReadOnlyList ExcludedFragments { get; } = excludedFragments; + public IReadOnlyList<(string Key, double CenterX)> ColumnCenters { get; } = columnCenters; + public IReadOnlyList RowAnchors { get; } = rowAnchors; + public int HeaderTop { get; } = headerTop; + public int BodyStartTop { get; } = bodyStartTop; + public int KeyTop { get; } = keyTop; + public int LeftCutoff { get; } = leftCutoff; + public IReadOnlyList Warnings { get; } = warnings ?? []; +} diff --git a/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs index ba9e3b0..c31fd3e 100644 --- a/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs +++ b/src/RolemasterDb.ImportTool/Parsing/VariantColumnCriticalTableParser.cs @@ -11,10 +11,10 @@ public sealed class VariantColumnCriticalTableParser new("SLAYING", "Slaying") ]; - public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, ExtractedCriticalSource source) { - var fragments = CriticalTableParserSupport.LoadFragments(xmlContent); - var pageGeometries = CriticalTableParserSupport.LoadPageGeometries(xmlContent); + var fragments = source.Fragments; + var pageGeometries = source.PageGeometries; var headerFragments = FindHeaderFragments(fragments); var validationErrors = new List(); var validationWarnings = new List(); @@ -43,7 +43,7 @@ public sealed class VariantColumnCriticalTableParser if (rowAnchors.Count == 0) { - validationErrors.Add("No roll-band labels were found in the XML artifact."); + validationErrors.Add("No roll-band labels were found in the source artifact."); } var columnCenters = columnAnchors @@ -132,16 +132,28 @@ public sealed class VariantColumnCriticalTableParser entry.DisplayName, entry.Family, Path.GetFileName(entry.PdfPath), - "Imported from PDF XML extraction.", + source.ImportNotes, [], ExpectedColumns.Select((item, index) => new ParsedCriticalColumn(item.Key, item.Label, "variant", index + 1)).ToList(), parsedRollBands, parsedResults); - return new CriticalTableParseResult(table, pageGeometries, fragments, parsedCells, validationReport); + return new CriticalTableParseResult(table, pageGeometries, fragments, source.RenderProfile, parsedCells, validationReport); } - private static List FindHeaderFragments(IReadOnlyList fragments) + public CriticalTableParseResult Parse(CriticalImportManifestEntry entry, string xmlContent) + { + return Parse( + entry, + new ExtractedCriticalSource( + "xml", + "Imported from PDF XML extraction.", + SourceRenderProfile.XmlAligned(), + CriticalTableParserSupport.LoadPageGeometries(xmlContent), + CriticalTableParserSupport.LoadFragments(xmlContent))); + } + + private static List FindHeaderFragments(IReadOnlyList fragments) { var expectedLabels = ExpectedColumns .Select(item => item.Label.ToLowerInvariant()) @@ -163,7 +175,7 @@ public sealed class VariantColumnCriticalTableParser } } - throw new InvalidOperationException("Could not find the variant-column header row in the XML artifact."); + throw new InvalidOperationException("Could not find the variant-column header row in the source artifact."); } private static ColumnDefinition ResolveColumnDefinition(string value) => diff --git a/src/RolemasterDb.ImportTool/Parsing/XmlTextFragment.cs b/src/RolemasterDb.ImportTool/Parsing/XmlTextFragment.cs index e3d771a..5eb5514 100644 --- a/src/RolemasterDb.ImportTool/Parsing/XmlTextFragment.cs +++ b/src/RolemasterDb.ImportTool/Parsing/XmlTextFragment.cs @@ -7,12 +7,6 @@ public sealed class XmlTextFragment( int width, int height, string text) + : PositionedTextFragment(pageNumber, top, left, width, height, text) { - public int PageNumber { get; } = pageNumber; - public int Top { get; } = top; - public int Left { get; } = left; - public int Width { get; } = width; - public int Height { get; } = height; - public string Text { get; } = text; - public double CenterX => Left + (Width / 2.0); } diff --git a/src/RolemasterDb.ImportTool/PdfDocumentInfo.cs b/src/RolemasterDb.ImportTool/PdfDocumentInfo.cs new file mode 100644 index 0000000..8e87189 --- /dev/null +++ b/src/RolemasterDb.ImportTool/PdfDocumentInfo.cs @@ -0,0 +1,8 @@ +namespace RolemasterDb.ImportTool; + +public sealed class PdfDocumentInfo(int pageCount, double pageWidthPoints, double pageHeightPoints) +{ + public int PageCount { get; } = pageCount; + public double PageWidthPoints { get; } = pageWidthPoints; + public double PageHeightPoints { get; } = pageHeightPoints; +} diff --git a/src/RolemasterDb.ImportTool/PdfXmlExtractor.cs b/src/RolemasterDb.ImportTool/PdfXmlExtractor.cs index b81a330..bcc0f63 100644 --- a/src/RolemasterDb.ImportTool/PdfXmlExtractor.cs +++ b/src/RolemasterDb.ImportTool/PdfXmlExtractor.cs @@ -1,4 +1,6 @@ using System.Diagnostics; +using System.Globalization; +using System.Text.RegularExpressions; namespace RolemasterDb.ImportTool; @@ -7,6 +9,7 @@ public sealed class PdfXmlExtractor public const int RenderScaleFactor = 4; public const int XmlAlignedRenderDpi = 108; public const int ScaledRenderDpi = XmlAlignedRenderDpi * RenderScaleFactor; + private const string PortableMiKTeXPath = @"D:\Code\miktex-portable\texmfs\install\miktex\bin\x64"; public static int ScaleCoordinate(int value) => checked(value * RenderScaleFactor); @@ -16,7 +19,7 @@ public sealed class PdfXmlExtractor var startInfo = new ProcessStartInfo { - FileName = "pdftohtml", + FileName = ResolveExecutable("ROLEMASTERDB_PDFTOHTML_PATH", "pdftohtml.exe"), RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, @@ -40,12 +43,57 @@ public sealed class PdfXmlExtractor } } + public async Task ReadDocumentInfoAsync(string pdfPath, CancellationToken cancellationToken = default) + { + var startInfo = new ProcessStartInfo + { + FileName = ResolveExecutable("ROLEMASTERDB_PDFINFO_PATH", "pdfinfo.exe"), + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + CreateNoWindow = true + }; + + startInfo.ArgumentList.Add(pdfPath); + + using var process = new Process { StartInfo = startInfo }; + process.Start(); + var output = await process.StandardOutput.ReadToEndAsync(cancellationToken); + await process.WaitForExitAsync(cancellationToken); + + if (process.ExitCode != 0) + { + var error = await process.StandardError.ReadToEndAsync(cancellationToken); + throw new InvalidOperationException($"pdfinfo failed for '{pdfPath}': {error}"); + } + + var pageCountMatch = Regex.Match(output, @"Pages:\s*(\d+)", RegexOptions.Multiline); + var sizeMatch = Regex.Match(output, @"Page size:\s*([0-9.]+)\s*x\s*([0-9.]+)\s*pts", RegexOptions.Multiline); + if (!pageCountMatch.Success || !sizeMatch.Success) + { + throw new InvalidOperationException($"pdfinfo output for '{pdfPath}' could not be parsed."); + } + + return new PdfDocumentInfo( + int.Parse(pageCountMatch.Groups[1].Value, CultureInfo.InvariantCulture), + double.Parse(sizeMatch.Groups[1].Value, CultureInfo.InvariantCulture), + double.Parse(sizeMatch.Groups[2].Value, CultureInfo.InvariantCulture)); + } + public Task RenderPagePngAsync( string pdfPath, int pageNumber, string outputPath, CancellationToken cancellationToken = default) => - RenderPngAsync(pdfPath, pageNumber, outputPath, null, null, null, null, cancellationToken); + RenderPagePngAsync(pdfPath, pageNumber, outputPath, ScaledRenderDpi, cancellationToken); + + public Task RenderPagePngAsync( + string pdfPath, + int pageNumber, + string outputPath, + int renderDpi, + CancellationToken cancellationToken = default) => + RenderPngAsync(pdfPath, pageNumber, outputPath, renderDpi, null, null, null, null, cancellationToken); public Task RenderCropPngAsync( string pdfPath, @@ -56,12 +104,25 @@ public sealed class PdfXmlExtractor int height, string outputPath, CancellationToken cancellationToken = default) => - RenderPngAsync(pdfPath, pageNumber, outputPath, left, top, width, height, cancellationToken); + RenderCropPngAsync(pdfPath, pageNumber, left, top, width, height, outputPath, ScaledRenderDpi, cancellationToken); + + public Task RenderCropPngAsync( + string pdfPath, + int pageNumber, + int left, + int top, + int width, + int height, + string outputPath, + int renderDpi, + CancellationToken cancellationToken = default) => + RenderPngAsync(pdfPath, pageNumber, outputPath, renderDpi, left, top, width, height, cancellationToken); private static async Task RenderPngAsync( string pdfPath, int pageNumber, string outputPath, + int renderDpi, int? left, int? top, int? width, @@ -72,7 +133,7 @@ public sealed class PdfXmlExtractor var startInfo = new ProcessStartInfo { - FileName = "pdftoppm", + FileName = ResolveExecutable("ROLEMASTERDB_PDFTOPPM_PATH", "pdftoppm.exe"), RedirectStandardError = true, RedirectStandardOutput = true, UseShellExecute = false, @@ -81,7 +142,7 @@ public sealed class PdfXmlExtractor startInfo.ArgumentList.Add("-png"); startInfo.ArgumentList.Add("-r"); - startInfo.ArgumentList.Add(ScaledRenderDpi.ToString()); + startInfo.ArgumentList.Add(renderDpi.ToString(CultureInfo.InvariantCulture)); startInfo.ArgumentList.Add("-f"); startInfo.ArgumentList.Add(pageNumber.ToString()); startInfo.ArgumentList.Add("-l"); @@ -118,4 +179,21 @@ public sealed class PdfXmlExtractor throw new InvalidOperationException($"pdftoppm completed but did not create '{outputPath}'."); } } + + private static string ResolveExecutable(string environmentVariableName, string executableName) + { + var configuredPath = Environment.GetEnvironmentVariable(environmentVariableName); + if (!string.IsNullOrWhiteSpace(configuredPath) && File.Exists(configuredPath)) + { + return configuredPath; + } + + var portablePath = Path.Combine(PortableMiKTeXPath, executableName); + if (File.Exists(portablePath)) + { + return portablePath; + } + + return Path.GetFileNameWithoutExtension(executableName); + } } diff --git a/src/RolemasterDb.ImportTool/Properties/AssemblyInfo.cs b/src/RolemasterDb.ImportTool/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..d8fca8c --- /dev/null +++ b/src/RolemasterDb.ImportTool/Properties/AssemblyInfo.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("RolemasterDb.ImportTool.Tests")] diff --git a/src/RolemasterDb.ImportTool/SourceRenderProfile.cs b/src/RolemasterDb.ImportTool/SourceRenderProfile.cs new file mode 100644 index 0000000..d87ad32 --- /dev/null +++ b/src/RolemasterDb.ImportTool/SourceRenderProfile.cs @@ -0,0 +1,15 @@ +namespace RolemasterDb.ImportTool; + +public sealed class SourceRenderProfile(int renderDpi, int scaleFactor) +{ + public int RenderDpi { get; } = renderDpi; + public int ScaleFactor { get; } = scaleFactor; + + public int ScaleCoordinate(int value) => checked(value * ScaleFactor); + + public static SourceRenderProfile XmlAligned() => + new(PdfXmlExtractor.ScaledRenderDpi, PdfXmlExtractor.RenderScaleFactor); + + public static SourceRenderProfile OcrPixels(int renderDpi) => + new(renderDpi, 1); +} diff --git a/src/RolemasterDb.ImportTool/XmlCriticalSourceExtractor.cs b/src/RolemasterDb.ImportTool/XmlCriticalSourceExtractor.cs new file mode 100644 index 0000000..7560375 --- /dev/null +++ b/src/RolemasterDb.ImportTool/XmlCriticalSourceExtractor.cs @@ -0,0 +1,28 @@ +using RolemasterDb.ImportTool.Parsing; + +namespace RolemasterDb.ImportTool; + +public sealed class XmlCriticalSourceExtractor(PdfXmlExtractor pdfXmlExtractor) : ICriticalSourceExtractor +{ + public async Task ExtractAsync(string pdfPath, ImportArtifactPaths artifactPaths, CancellationToken cancellationToken = default) => + await pdfXmlExtractor.ExtractAsync(pdfPath, artifactPaths.XmlPath, cancellationToken); + + public async Task LoadAsync( + string pdfPath, + ImportArtifactPaths artifactPaths, + CancellationToken cancellationToken = default) + { + if (!File.Exists(artifactPaths.XmlPath)) + { + throw new FileNotFoundException($"Missing XML artifact: {artifactPaths.XmlPath}", artifactPaths.XmlPath); + } + + var xmlContent = await File.ReadAllTextAsync(artifactPaths.XmlPath, cancellationToken); + return new ExtractedCriticalSource( + "xml", + "Imported from PDF XML extraction.", + SourceRenderProfile.XmlAligned(), + CriticalTableParserSupport.LoadPageGeometries(xmlContent), + CriticalTableParserSupport.LoadFragments(xmlContent)); + } +}