diff --git a/RolemasterDB.slnx b/RolemasterDB.slnx
index 6b0b200..17ff5d5 100644
--- a/RolemasterDB.slnx
+++ b/RolemasterDB.slnx
@@ -1,5 +1,6 @@
+
diff --git a/sources/critical-import-manifest.json b/sources/critical-import-manifest.json
new file mode 100644
index 0000000..696030f
--- /dev/null
+++ b/sources/critical-import-manifest.json
@@ -0,0 +1,12 @@
+{
+ "tables": [
+ {
+ "slug": "slash",
+ "displayName": "Slash Critical Strike Table",
+ "family": "standard",
+ "extractionMethod": "text",
+ "pdfPath": "sources/Slash.pdf",
+ "enabled": true
+ }
+ ]
+}
diff --git a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
index 573fa5c..63eb0d5 100644
--- a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
+++ b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
@@ -12,12 +12,12 @@ public static class RolemasterDbInitializer
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
- if (await dbContext.AttackTables.AnyAsync(cancellationToken) || await dbContext.CriticalTables.AnyAsync(cancellationToken))
+ if (await dbContext.AttackTables.AnyAsync(cancellationToken))
{
return;
}
- RolemasterSeedData.Seed(dbContext);
+ RolemasterSeedData.SeedAttackStarterData(dbContext);
await dbContext.SaveChangesAsync(cancellationToken);
}
}
diff --git a/src/RolemasterDb.App/Data/RolemasterSeedData.cs b/src/RolemasterDb.App/Data/RolemasterSeedData.cs
index edf677b..487f573 100644
--- a/src/RolemasterDb.App/Data/RolemasterSeedData.cs
+++ b/src/RolemasterDb.App/Data/RolemasterSeedData.cs
@@ -4,17 +4,28 @@ namespace RolemasterDb.App.Data;
public static class RolemasterSeedData
{
- public static void Seed(RolemasterDbContext dbContext)
+ public static void SeedAttackStarterData(RolemasterDbContext dbContext)
{
- var armorTypes = CreateArmorTypes();
- dbContext.ArmorTypes.AddRange(armorTypes);
+ List armorTypes;
+
+ if (dbContext.ArmorTypes.Any())
+ {
+ armorTypes = dbContext.ArmorTypes.ToList();
+ }
+ else
+ {
+ armorTypes = CreateArmorTypes();
+ dbContext.ArmorTypes.AddRange(armorTypes);
+ }
+
+ if (dbContext.AttackTables.Any())
+ {
+ return;
+ }
var armorLookup = armorTypes.ToDictionary(item => item.Code, StringComparer.OrdinalIgnoreCase);
var attackTables = CreateAttackTables(armorLookup);
dbContext.AttackTables.AddRange(attackTables);
-
- var criticalTables = CreateCriticalTables();
- dbContext.CriticalTables.AddRange(criticalTables);
}
private static List CreateArmorTypes() =>
@@ -131,164 +142,6 @@ public static class RolemasterSeedData
}
}
- private static List CreateCriticalTables()
- {
- return
- [
- CreateCriticalTable(
- slug: "slash",
- displayName: "Slash Critical",
- notes: "Starter subset derived from the critical-table schema design notes.",
- matrix: new Dictionary
- {
- ["A"] =
- [
- ("Glancing slash across the forearm.", "+2 hits, 1 bleed"),
- ("Rib-level cut knocks the foe back a step.", "+4 hits, 1 stun, 2 bleed"),
- ("Deep cut to the thigh unbalances the target.", "+6 hits, 2 stun, 3 bleed"),
- ("Shoulder opened; guard collapses.", "+8 hits, 3 stun, 4 bleed"),
- ("Neck line opened. The fight is over.", "Instant death")
- ],
- ["B"] =
- [
- ("Slash cuts through the hand and weapon grip wavers.", "+4 hits, 1 stun, 2 bleed"),
- ("Diagonal cut across the chest steals breath.", "+6 hits, 2 stun, 3 bleed"),
- ("Hip strike drives the foe to one knee.", "+8 hits, 3 stun, 4 bleed"),
- ("Sword arm carved deeply; weapon drops.", "+10 hits, 4 stun, 5 bleed"),
- ("Throat opened in a full red arc.", "Instant death")
- ],
- ["C"] =
- [
- ("Forearm split to the bone; parry ruined.", "+6 hits, 2 stun, 3 bleed"),
- ("Wide cut over the ribs spins the foe.", "+8 hits, 3 stun, 4 bleed"),
- ("Hamstring sliced; prone unless magically supported.", "+10 hits, 4 stun, 6 bleed"),
- ("Slash rips across face and collar. Vision swims.", "+12 hits, 5 stun, 7 bleed"),
- ("Head nearly severed.", "Instant death")
- ],
- ["D"] =
- [
- ("Deep abdominal slice spills momentum and blood together.", "+8 hits, 3 stun, 5 bleed"),
- ("Sword tracks from shoulder to sternum. Collapse likely.", "+10 hits, 4 stun, 6 bleed"),
- ("Leg cut through muscle; foe falls hard.", "+12 hits, 5 stun, 8 bleed"),
- ("Upper arm nearly removed; weapon arm useless.", "+14 hits, 6 stun, 10 bleed"),
- ("Killing cut through the neck.", "Instant death")
- ],
- ["E"] =
- [
- ("Massive slash opens chest and the foe staggers blindly.", "+10 hits, 4 stun, 6 bleed"),
- ("Spine-grazing cut destroys posture and control.", "+12 hits, 5 stun, 8 bleed"),
- ("Leg severed below the knee.", "+14 hits, 6 stun, 10 bleed"),
- ("Torso opened from clavicle to hip.", "+16 hits, 8 stun, 12 bleed"),
- ("Clean decapitation.", "Instant death")
- ]
- }),
- CreateCriticalTable(
- slug: "puncture",
- displayName: "Puncture Critical",
- notes: "Starter subset seeded for missile and thrust attacks.",
- matrix: new Dictionary
- {
- ["A"] =
- [
- ("Point slips into the shoulder.", "+2 hits, 1 bleed"),
- ("Stab bites into the upper arm.", "+4 hits, 1 stun, 2 bleed"),
- ("Short thrust catches the ribs.", "+5 hits, 2 stun, 2 bleed"),
- ("Point pierces the thigh. Movement slows.", "+7 hits, 2 stun, 3 bleed"),
- ("Exact thrust to the throat.", "Instant death")
- ],
- ["B"] =
- [
- ("Puncture through the palm forces a drop check.", "+4 hits, 1 stun, 2 bleed"),
- ("Arrow buries in the flank.", "+6 hits, 2 stun, 3 bleed"),
- ("Deep thrust into the belly doubles the foe over.", "+8 hits, 3 stun, 4 bleed"),
- ("Point through the shoulder locks the arm.", "+10 hits, 4 stun, 5 bleed"),
- ("Eye socket struck cleanly.", "Instant death")
- ],
- ["C"] =
- [
- ("Stab through the bicep ruins the next parry.", "+6 hits, 2 stun, 3 bleed"),
- ("Arrow punches through lung tissue.", "+8 hits, 3 stun, 5 bleed"),
- ("Thrust under the ribs steals breath and footing.", "+10 hits, 4 stun, 6 bleed"),
- ("Deep puncture pins the foe in place.", "+12 hits, 5 stun, 7 bleed"),
- ("Heart strike.", "Instant death")
- ],
- ["D"] =
- [
- ("Weapon point tears through the abdomen.", "+8 hits, 3 stun, 5 bleed"),
- ("Arrow lodges near the spine; movement nearly gone.", "+10 hits, 4 stun, 6 bleed"),
- ("Lance-like thrust through the torso.", "+12 hits, 5 stun, 8 bleed"),
- ("Point enters the neck and exits behind the shoulder.", "+14 hits, 6 stun, 10 bleed"),
- ("Brain pierced.", "Instant death")
- ],
- ["E"] =
- [
- ("Massive puncture opens the chest cavity.", "+10 hits, 4 stun, 6 bleed"),
- ("Shaft buries to the fletching; foe drops.", "+12 hits, 5 stun, 8 bleed"),
- ("Groin-to-spine thrust destroys the body line.", "+14 hits, 6 stun, 10 bleed"),
- ("Perfect impalement through the throat and neck.", "+16 hits, 8 stun, 12 bleed"),
- ("Instantly fatal puncture.", "Instant death")
- ]
- })
- ];
- }
-
- private static CriticalTable CreateCriticalTable(
- string slug,
- string displayName,
- string notes,
- IReadOnlyDictionary matrix)
- {
- var table = new CriticalTable
- {
- Slug = slug,
- DisplayName = displayName,
- Family = "standard",
- SourceDocument = "Seeded starter data",
- Notes = notes
- };
-
- table.Columns =
- [
- new CriticalColumn { ColumnKey = "A", Label = "A", SortOrder = 1 },
- new CriticalColumn { ColumnKey = "B", Label = "B", SortOrder = 2 },
- new CriticalColumn { ColumnKey = "C", Label = "C", SortOrder = 3 },
- new CriticalColumn { ColumnKey = "D", Label = "D", SortOrder = 4 },
- new CriticalColumn { ColumnKey = "E", Label = "E", SortOrder = 5 }
- ];
-
- table.RollBands =
- [
- new CriticalRollBand { Label = "01-20", MinRoll = 1, MaxRoll = 20, SortOrder = 1 },
- new CriticalRollBand { Label = "21-40", MinRoll = 21, MaxRoll = 40, SortOrder = 2 },
- new CriticalRollBand { Label = "41-60", MinRoll = 41, MaxRoll = 60, SortOrder = 3 },
- new CriticalRollBand { Label = "61-80", MinRoll = 61, MaxRoll = 80, SortOrder = 4 },
- new CriticalRollBand { Label = "81-100", MinRoll = 81, MaxRoll = 100, SortOrder = 5 }
- ];
-
- var columnsByKey = table.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase);
- var bandsByOrder = table.RollBands.ToDictionary(item => item.SortOrder);
-
- foreach (var (columnKey, rows) in matrix)
- {
- for (var index = 0; index < rows.Length; index++)
- {
- var row = rows[index];
- table.Results.Add(new CriticalResult
- {
- CriticalColumn = columnsByKey[columnKey],
- CriticalRollBand = bandsByOrder[index + 1],
- DescriptionText = row.Description,
- RawAffixText = row.Affix,
- RawCellText = $"{row.Description} {row.Affix}",
- ParsedJson = "{}",
- ParseStatus = "verified"
- });
- }
- }
-
- return table;
- }
-
private static string BuildAttackNotation(int hits, string? severity, string criticalType)
{
return severity is null
diff --git a/src/RolemasterDb.App/rolemaster.db b/src/RolemasterDb.App/rolemaster.db
index a03bdaf..8b07266 100644
Binary files a/src/RolemasterDb.App/rolemaster.db and b/src/RolemasterDb.App/rolemaster.db differ
diff --git a/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs
new file mode 100644
index 0000000..238775f
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/CriticalImportCommandRunner.cs
@@ -0,0 +1,103 @@
+using RolemasterDb.ImportTool.Parsing;
+
+namespace RolemasterDb.ImportTool;
+
+public sealed class CriticalImportCommandRunner
+{
+ private readonly CriticalImportManifestLoader manifestLoader = new();
+ private readonly PdfTextExtractor pdfTextExtractor = new();
+ private readonly StandardCriticalTableParser standardParser = new();
+
+ public async Task RunAsync(ResetOptions options)
+ {
+ if (!string.Equals(options.Target, "criticals", StringComparison.OrdinalIgnoreCase))
+ {
+ Console.Error.WriteLine("Only 'criticals' is supported by phase 1.");
+ return 1;
+ }
+
+ var loader = new CriticalImportLoader(ResolveDatabasePath(options.DatabasePath));
+ var removedTableCount = await loader.ResetCriticalsAsync();
+ Console.WriteLine($"Removed {removedTableCount} critical table records.");
+ return 0;
+ }
+
+ public async Task RunAsync(ExtractOptions options)
+ {
+ var entry = GetManifestEntry(options.Table);
+ var artifactPaths = CreateArtifactPaths(entry.Slug);
+ await pdfTextExtractor.ExtractAsync(ResolveRepositoryPath(entry.PdfPath), artifactPaths.ExtractedTextPath);
+ Console.WriteLine($"Extracted {entry.Slug} to {artifactPaths.ExtractedTextPath}");
+ return 0;
+ }
+
+ public async Task RunAsync(LoadOptions options)
+ {
+ var entry = GetManifestEntry(options.Table);
+ var artifactPaths = CreateArtifactPaths(entry.Slug);
+
+ if (!File.Exists(artifactPaths.ExtractedTextPath))
+ {
+ Console.Error.WriteLine($"Missing extracted text artifact: {artifactPaths.ExtractedTextPath}");
+ return 1;
+ }
+
+ var extractedText = await File.ReadAllTextAsync(artifactPaths.ExtractedTextPath);
+ var parsedTable = Parse(entry, extractedText);
+ var loader = new CriticalImportLoader(ResolveDatabasePath(options.DatabasePath));
+ var result = await loader.LoadAsync(parsedTable);
+
+ Console.WriteLine(
+ $"Loaded {result.TableSlug}: {result.ColumnCount} columns, {result.RollBandCount} roll bands, {result.ResultCount} results.");
+
+ return 0;
+ }
+
+ public async Task RunAsync(ImportOptions options)
+ {
+ var extractExitCode = await RunAsync(new ExtractOptions
+ {
+ DatabasePath = options.DatabasePath,
+ Table = options.Table
+ });
+
+ if (extractExitCode != 0)
+ {
+ return extractExitCode;
+ }
+
+ return await RunAsync(new LoadOptions
+ {
+ DatabasePath = options.DatabasePath,
+ Table = options.Table
+ });
+ }
+
+ private CriticalImportManifestEntry GetManifestEntry(string tableSlug)
+ {
+ var manifest = manifestLoader.Load(RepositoryPaths.Discover().ManifestPath);
+ return manifest.Tables
+ .Where(item => item.Enabled)
+ .SingleOrDefault(item => string.Equals(item.Slug, tableSlug.Trim(), StringComparison.OrdinalIgnoreCase))
+ ?? throw new InvalidOperationException($"No enabled manifest entry was found for '{tableSlug}'.");
+ }
+
+ private ParsedCriticalTable Parse(CriticalImportManifestEntry entry, string extractedText)
+ {
+ if (!string.Equals(entry.Family, "standard", StringComparison.OrdinalIgnoreCase))
+ {
+ throw new InvalidOperationException($"Family '{entry.Family}' is not supported by phase 1.");
+ }
+
+ return standardParser.Parse(entry, extractedText);
+ }
+
+ private static ImportArtifactPaths CreateArtifactPaths(string slug) =>
+ ImportArtifactPaths.Create(RepositoryPaths.Discover().ArtifactsRootPath, slug);
+
+ private static string ResolveDatabasePath(string? databasePath) =>
+ Path.GetFullPath(databasePath ?? RepositoryPaths.Discover().DefaultDatabasePath);
+
+ private static string ResolveRepositoryPath(string path) =>
+ Path.GetFullPath(Path.Combine(RepositoryPaths.Discover().RootPath, path));
+}
diff --git a/src/RolemasterDb.ImportTool/CriticalImportLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs
new file mode 100644
index 0000000..080e0f6
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/CriticalImportLoader.cs
@@ -0,0 +1,133 @@
+using Microsoft.EntityFrameworkCore;
+
+using RolemasterDb.App.Data;
+using RolemasterDb.App.Domain;
+using RolemasterDb.ImportTool.Parsing;
+
+namespace RolemasterDb.ImportTool;
+
+public sealed class CriticalImportLoader(string databasePath)
+{
+ public async Task ResetCriticalsAsync(CancellationToken cancellationToken = default)
+ {
+ await using var dbContext = CreateDbContext();
+ await dbContext.Database.EnsureCreatedAsync(cancellationToken);
+
+ var removedTableCount = await dbContext.CriticalTables.CountAsync(cancellationToken);
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
+
+ await dbContext.CriticalResults.ExecuteDeleteAsync(cancellationToken);
+ await dbContext.CriticalGroups.ExecuteDeleteAsync(cancellationToken);
+ await dbContext.CriticalColumns.ExecuteDeleteAsync(cancellationToken);
+ await dbContext.CriticalRollBands.ExecuteDeleteAsync(cancellationToken);
+ await dbContext.CriticalTables.ExecuteDeleteAsync(cancellationToken);
+
+ await transaction.CommitAsync(cancellationToken);
+ return removedTableCount;
+ }
+
+ public async Task LoadAsync(ParsedCriticalTable table, CancellationToken cancellationToken = default)
+ {
+ await using var dbContext = CreateDbContext();
+ await dbContext.Database.EnsureCreatedAsync(cancellationToken);
+ await using var transaction = await dbContext.Database.BeginTransactionAsync(cancellationToken);
+
+ await DeleteTableAsync(dbContext, table.Slug, cancellationToken);
+
+ var entity = new CriticalTable
+ {
+ Slug = table.Slug,
+ DisplayName = table.DisplayName,
+ Family = table.Family,
+ SourceDocument = table.SourceDocument,
+ Notes = table.Notes
+ };
+
+ entity.Columns = table.Columns
+ .Select(item => new CriticalColumn
+ {
+ ColumnKey = item.ColumnKey,
+ Label = item.Label,
+ Role = item.Role,
+ SortOrder = item.SortOrder
+ })
+ .ToList();
+
+ entity.RollBands = table.RollBands
+ .Select(item => new CriticalRollBand
+ {
+ Label = item.Label,
+ MinRoll = item.MinRoll,
+ MaxRoll = item.MaxRoll,
+ SortOrder = item.SortOrder
+ })
+ .ToList();
+
+ var columnsByKey = entity.Columns.ToDictionary(item => item.ColumnKey, StringComparer.OrdinalIgnoreCase);
+ var rollBandsByLabel = entity.RollBands.ToDictionary(item => item.Label, StringComparer.OrdinalIgnoreCase);
+
+ entity.Results = table.Results
+ .Select(item => new CriticalResult
+ {
+ CriticalColumn = columnsByKey[item.ColumnKey],
+ CriticalRollBand = rollBandsByLabel[item.RollBandLabel],
+ RawCellText = item.RawCellText,
+ DescriptionText = item.DescriptionText,
+ RawAffixText = item.RawAffixText,
+ ParsedJson = "{}",
+ ParseStatus = "raw"
+ })
+ .ToList();
+
+ dbContext.CriticalTables.Add(entity);
+ await dbContext.SaveChangesAsync(cancellationToken);
+ await transaction.CommitAsync(cancellationToken);
+
+ return new ImportCommandResult(entity.Slug, entity.Columns.Count, entity.RollBands.Count, entity.Results.Count);
+ }
+
+ private RolemasterDbContext CreateDbContext()
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={databasePath}")
+ .Options;
+
+ return new RolemasterDbContext(options);
+ }
+
+ private static async Task DeleteTableAsync(
+ RolemasterDbContext dbContext,
+ string slug,
+ CancellationToken cancellationToken)
+ {
+ var tableId = await dbContext.CriticalTables
+ .Where(item => item.Slug == slug)
+ .Select(item => (int?)item.Id)
+ .SingleOrDefaultAsync(cancellationToken);
+
+ if (tableId is null)
+ {
+ return;
+ }
+
+ await dbContext.CriticalResults
+ .Where(item => item.CriticalTableId == tableId.Value)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ await dbContext.CriticalGroups
+ .Where(item => item.CriticalTableId == tableId.Value)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ await dbContext.CriticalColumns
+ .Where(item => item.CriticalTableId == tableId.Value)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ await dbContext.CriticalRollBands
+ .Where(item => item.CriticalTableId == tableId.Value)
+ .ExecuteDeleteAsync(cancellationToken);
+
+ await dbContext.CriticalTables
+ .Where(item => item.Id == tableId.Value)
+ .ExecuteDeleteAsync(cancellationToken);
+ }
+}
diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifest.cs b/src/RolemasterDb.ImportTool/CriticalImportManifest.cs
new file mode 100644
index 0000000..cf53b76
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/CriticalImportManifest.cs
@@ -0,0 +1,6 @@
+namespace RolemasterDb.ImportTool;
+
+public sealed class CriticalImportManifest
+{
+ public List Tables { get; set; } = [];
+}
diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs
new file mode 100644
index 0000000..0b5f3b6
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/CriticalImportManifestEntry.cs
@@ -0,0 +1,11 @@
+namespace RolemasterDb.ImportTool;
+
+public sealed class CriticalImportManifestEntry
+{
+ public string Slug { get; set; } = string.Empty;
+ public string DisplayName { get; set; } = string.Empty;
+ public string Family { get; set; } = string.Empty;
+ public string ExtractionMethod { get; set; } = string.Empty;
+ public string PdfPath { get; set; } = string.Empty;
+ public bool Enabled { get; set; } = true;
+}
diff --git a/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs b/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs
new file mode 100644
index 0000000..f093074
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/CriticalImportManifestLoader.cs
@@ -0,0 +1,33 @@
+using System.Text.Json;
+
+namespace RolemasterDb.ImportTool;
+
+public sealed class CriticalImportManifestLoader
+{
+ public CriticalImportManifest Load(string manifestPath)
+ {
+ if (!File.Exists(manifestPath))
+ {
+ throw new FileNotFoundException("The critical import manifest could not be found.", manifestPath);
+ }
+
+ var manifest = JsonSerializer.Deserialize(
+ File.ReadAllText(manifestPath),
+ new JsonSerializerOptions
+ {
+ PropertyNameCaseInsensitive = true
+ });
+
+ if (manifest is null || manifest.Tables.Count == 0)
+ {
+ throw new InvalidOperationException("The critical import manifest is empty.");
+ }
+
+ if (manifest.Tables.Any(item => string.IsNullOrWhiteSpace(item.Slug)))
+ {
+ throw new InvalidOperationException("Each manifest entry must declare a slug.");
+ }
+
+ return manifest;
+ }
+}
diff --git a/src/RolemasterDb.ImportTool/ExtractOptions.cs b/src/RolemasterDb.ImportTool/ExtractOptions.cs
new file mode 100644
index 0000000..481849c
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/ExtractOptions.cs
@@ -0,0 +1,13 @@
+using CommandLine;
+
+namespace RolemasterDb.ImportTool;
+
+[Verb("extract", HelpText = "Extract a critical table PDF into a text artifact.")]
+public sealed class ExtractOptions
+{
+ [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to extract.")]
+ public string Table { get; set; } = string.Empty;
+
+ [Option('d', "db", HelpText = "Optional SQLite database path. Accepted for command consistency.")]
+ public string? DatabasePath { get; set; }
+}
diff --git a/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs
new file mode 100644
index 0000000..7965855
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/ImportArtifactPaths.cs
@@ -0,0 +1,19 @@
+namespace RolemasterDb.ImportTool;
+
+public sealed class ImportArtifactPaths
+{
+ private ImportArtifactPaths(string directoryPath, string extractedTextPath)
+ {
+ DirectoryPath = directoryPath;
+ ExtractedTextPath = extractedTextPath;
+ }
+
+ public string DirectoryPath { get; }
+ public string ExtractedTextPath { get; }
+
+ public static ImportArtifactPaths Create(string artifactsRootPath, string tableSlug)
+ {
+ var directoryPath = Path.Combine(artifactsRootPath, tableSlug);
+ return new ImportArtifactPaths(directoryPath, Path.Combine(directoryPath, "extracted.txt"));
+ }
+}
diff --git a/src/RolemasterDb.ImportTool/ImportCommandResult.cs b/src/RolemasterDb.ImportTool/ImportCommandResult.cs
new file mode 100644
index 0000000..41610a1
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/ImportCommandResult.cs
@@ -0,0 +1,9 @@
+namespace RolemasterDb.ImportTool;
+
+public sealed class ImportCommandResult(string tableSlug, int columnCount, int rollBandCount, int resultCount)
+{
+ public string TableSlug { get; } = tableSlug;
+ public int ColumnCount { get; } = columnCount;
+ public int RollBandCount { get; } = rollBandCount;
+ public int ResultCount { get; } = resultCount;
+}
diff --git a/src/RolemasterDb.ImportTool/ImportOptions.cs b/src/RolemasterDb.ImportTool/ImportOptions.cs
new file mode 100644
index 0000000..2d48043
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/ImportOptions.cs
@@ -0,0 +1,13 @@
+using CommandLine;
+
+namespace RolemasterDb.ImportTool;
+
+[Verb("import", HelpText = "Extract and load a critical table in one step.")]
+public sealed class ImportOptions
+{
+ [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to import.")]
+ public string Table { get; set; } = string.Empty;
+
+ [Option('d', "db", HelpText = "Optional SQLite database path.")]
+ public string? DatabasePath { get; set; }
+}
diff --git a/src/RolemasterDb.ImportTool/LoadOptions.cs b/src/RolemasterDb.ImportTool/LoadOptions.cs
new file mode 100644
index 0000000..64a0f17
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/LoadOptions.cs
@@ -0,0 +1,13 @@
+using CommandLine;
+
+namespace RolemasterDb.ImportTool;
+
+[Verb("load", HelpText = "Load a parsed critical table from its extracted text artifact.")]
+public sealed class LoadOptions
+{
+ [Value(0, MetaName = "table", Required = true, HelpText = "The manifest slug of the critical table to load.")]
+ public string Table { get; set; } = string.Empty;
+
+ [Option('d', "db", HelpText = "Optional SQLite database path.")]
+ public string? DatabasePath { get; set; }
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs
new file mode 100644
index 0000000..c351c3c
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalColumn.cs
@@ -0,0 +1,9 @@
+namespace RolemasterDb.ImportTool.Parsing;
+
+public sealed class ParsedCriticalColumn(string columnKey, string label, string role, int sortOrder)
+{
+ public string ColumnKey { get; } = columnKey;
+ public string Label { get; } = label;
+ public string Role { get; } = role;
+ public int SortOrder { get; } = sortOrder;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs
new file mode 100644
index 0000000..a8dbb09
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalResult.cs
@@ -0,0 +1,15 @@
+namespace RolemasterDb.ImportTool.Parsing;
+
+public sealed class ParsedCriticalResult(
+ string columnKey,
+ string rollBandLabel,
+ string rawCellText,
+ string descriptionText,
+ string? rawAffixText)
+{
+ public string ColumnKey { get; } = columnKey;
+ public string RollBandLabel { get; } = rollBandLabel;
+ public string RawCellText { get; } = rawCellText;
+ public string DescriptionText { get; } = descriptionText;
+ public string? RawAffixText { get; } = rawAffixText;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs
new file mode 100644
index 0000000..db26453
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalRollBand.cs
@@ -0,0 +1,9 @@
+namespace RolemasterDb.ImportTool.Parsing;
+
+public sealed class ParsedCriticalRollBand(string label, int minRoll, int? maxRoll, int sortOrder)
+{
+ public string Label { get; } = label;
+ public int MinRoll { get; } = minRoll;
+ public int? MaxRoll { get; } = maxRoll;
+ public int SortOrder { get; } = sortOrder;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs
new file mode 100644
index 0000000..cf9d7f2
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/ParsedCriticalTable.cs
@@ -0,0 +1,21 @@
+namespace RolemasterDb.ImportTool.Parsing;
+
+public sealed class ParsedCriticalTable(
+ string slug,
+ string displayName,
+ string family,
+ string sourceDocument,
+ string? notes,
+ IReadOnlyList columns,
+ IReadOnlyList rollBands,
+ IReadOnlyList results)
+{
+ public string Slug { get; } = slug;
+ public string DisplayName { get; } = displayName;
+ public string Family { get; } = family;
+ public string SourceDocument { get; } = sourceDocument;
+ public string? Notes { get; } = notes;
+ public IReadOnlyList Columns { get; } = columns;
+ public IReadOnlyList RollBands { get; } = rollBands;
+ public IReadOnlyList Results { get; } = results;
+}
diff --git a/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs
new file mode 100644
index 0000000..7e52e80
--- /dev/null
+++ b/src/RolemasterDb.ImportTool/Parsing/StandardCriticalTableParser.cs
@@ -0,0 +1,285 @@
+using System.Text.RegularExpressions;
+
+namespace RolemasterDb.ImportTool.Parsing;
+
+public sealed class StandardCriticalTableParser
+{
+ private static readonly Regex ColumnRegex = new(@"\b([A-E])\b", RegexOptions.IgnoreCase | RegexOptions.Compiled);
+ private static readonly Regex RollBandRegex = new(@"^\s*(?