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*(?