Add phase 1 critical import tool

This commit is contained in:
2026-03-14 01:10:44 +01:00
parent 44af81cc38
commit f70d610c92
25 changed files with 851 additions and 166 deletions

View File

@@ -1,5 +1,6 @@
<Solution> <Solution>
<Folder Name="/src/"> <Folder Name="/src/">
<Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" /> <Project Path="src/RolemasterDb.App/RolemasterDb.App.csproj" />
<Project Path="src/RolemasterDb.ImportTool/RolemasterDb.ImportTool.csproj" />
</Folder> </Folder>
</Solution> </Solution>

View File

@@ -0,0 +1,12 @@
{
"tables": [
{
"slug": "slash",
"displayName": "Slash Critical Strike Table",
"family": "standard",
"extractionMethod": "text",
"pdfPath": "sources/Slash.pdf",
"enabled": true
}
]
}

View File

@@ -12,12 +12,12 @@ public static class RolemasterDbInitializer
await dbContext.Database.EnsureCreatedAsync(cancellationToken); await dbContext.Database.EnsureCreatedAsync(cancellationToken);
if (await dbContext.AttackTables.AnyAsync(cancellationToken) || await dbContext.CriticalTables.AnyAsync(cancellationToken)) if (await dbContext.AttackTables.AnyAsync(cancellationToken))
{ {
return; return;
} }
RolemasterSeedData.Seed(dbContext); RolemasterSeedData.SeedAttackStarterData(dbContext);
await dbContext.SaveChangesAsync(cancellationToken); await dbContext.SaveChangesAsync(cancellationToken);
} }
} }

View File

@@ -4,17 +4,28 @@ namespace RolemasterDb.App.Data;
public static class RolemasterSeedData public static class RolemasterSeedData
{ {
public static void Seed(RolemasterDbContext dbContext) public static void SeedAttackStarterData(RolemasterDbContext dbContext)
{ {
var armorTypes = CreateArmorTypes(); List<ArmorType> armorTypes;
dbContext.ArmorTypes.AddRange(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 armorLookup = armorTypes.ToDictionary(item => item.Code, StringComparer.OrdinalIgnoreCase);
var attackTables = CreateAttackTables(armorLookup); var attackTables = CreateAttackTables(armorLookup);
dbContext.AttackTables.AddRange(attackTables); dbContext.AttackTables.AddRange(attackTables);
var criticalTables = CreateCriticalTables();
dbContext.CriticalTables.AddRange(criticalTables);
} }
private static List<ArmorType> CreateArmorTypes() => private static List<ArmorType> CreateArmorTypes() =>
@@ -131,164 +142,6 @@ public static class RolemasterSeedData
} }
} }
private static List<CriticalTable> CreateCriticalTables()
{
return
[
CreateCriticalTable(
slug: "slash",
displayName: "Slash Critical",
notes: "Starter subset derived from the critical-table schema design notes.",
matrix: new Dictionary<string, (string Description, string Affix)[]>
{
["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<string, (string Description, string Affix)[]>
{
["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<string, (string Description, string Affix)[]> 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) private static string BuildAttackNotation(int hits, string? severity, string criticalType)
{ {
return severity is null return severity is null

Binary file not shown.

View File

@@ -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<int> 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<int> 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<int> 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<int> 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));
}

View File

@@ -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<int> 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<ImportCommandResult> 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<RolemasterDbContext>()
.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);
}
}

View File

@@ -0,0 +1,6 @@
namespace RolemasterDb.ImportTool;
public sealed class CriticalImportManifest
{
public List<CriticalImportManifestEntry> Tables { get; set; } = [];
}

View File

@@ -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;
}

View File

@@ -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<CriticalImportManifest>(
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;
}
}

View File

@@ -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; }
}

View File

@@ -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"));
}
}

View File

@@ -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;
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -0,0 +1,21 @@
namespace RolemasterDb.ImportTool.Parsing;
public sealed class ParsedCriticalTable(
string slug,
string displayName,
string family,
string sourceDocument,
string? notes,
IReadOnlyList<ParsedCriticalColumn> columns,
IReadOnlyList<ParsedCriticalRollBand> rollBands,
IReadOnlyList<ParsedCriticalResult> 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<ParsedCriticalColumn> Columns { get; } = columns;
public IReadOnlyList<ParsedCriticalRollBand> RollBands { get; } = rollBands;
public IReadOnlyList<ParsedCriticalResult> Results { get; } = results;
}

View File

@@ -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*(?<label>\d{2,3}(?:-\d{2,3})?|\d{2,3}\+)\s*$", RegexOptions.Compiled);
private static readonly Regex RollBandLineRegex = new(@"^\s*(?<label>\d{2,3}(?:-\d{2,3})?|\d{2,3}\+)(?<rest>\s+.*)?$", RegexOptions.Compiled);
public ParsedCriticalTable Parse(CriticalImportManifestEntry entry, string extractedText)
{
var lines = extractedText.Replace("\r\n", "\n", StringComparison.Ordinal)
.Replace('\f', '\n')
.Split('\n');
var headerIndex = Array.FindIndex(lines, IsColumnHeaderLine);
if (headerIndex < 0)
{
throw new InvalidOperationException("The standard table header could not be found in the extracted text.");
}
var columnStarts = GetColumnStarts(lines[headerIndex]);
var boundaries = GetColumnBoundaries(columnStarts);
var columns = columnStarts
.Select((item, index) => new ParsedCriticalColumn(item.Label, item.Label, "severity", index + 1))
.ToList();
var firstRollBandIndex = FindNextRollBandIndex(lines, headerIndex + 1);
if (firstRollBandIndex < 0)
{
throw new InvalidOperationException("No roll bands were found in the extracted text.");
}
var keyLineIndex = Array.FindIndex(lines, firstRollBandIndex, item => item.TrimStart().StartsWith("Key:", StringComparison.OrdinalIgnoreCase));
if (keyLineIndex < 0)
{
keyLineIndex = lines.Length;
}
var leadingLines = lines[(headerIndex + 1)..firstRollBandIndex]
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
var rollBands = new List<ParsedCriticalRollBand>();
var results = new List<ParsedCriticalResult>();
var currentLabel = string.Empty;
var currentRowLines = new List<string>();
var rowIndex = 0;
void FlushCurrentRow()
{
if (string.IsNullOrEmpty(currentLabel))
{
return;
}
rowIndex++;
var rollBand = CreateRollBand(currentLabel, rowIndex);
rollBands.Add(rollBand);
var cellLines = SplitRowLines(currentRowLines, boundaries, columns.Count);
for (var columnIndex = 0; columnIndex < columns.Count; columnIndex++)
{
var rawCellLines = cellLines[columnIndex]
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
var rawAffixLines = rawCellLines
.Where(IsAffixLikeLine)
.ToList();
var descriptionLines = rawCellLines
.Where(item => !IsAffixLikeLine(item))
.ToList();
results.Add(new ParsedCriticalResult(
columns[columnIndex].ColumnKey,
rollBand.Label,
string.Join(Environment.NewLine, rawCellLines),
CollapseWhitespace(string.Join(' ', descriptionLines)),
rawAffixLines.Count == 0 ? null : string.Join(Environment.NewLine, rawAffixLines)));
}
currentLabel = string.Empty;
currentRowLines = new List<string>();
}
for (var lineIndex = firstRollBandIndex; lineIndex < keyLineIndex; lineIndex++)
{
if (TryParseRollBandLine(lines[lineIndex], out var label, out var trailingText))
{
var trailingTextBelongsToCurrentRow = IsAffixLikeLine(trailingText);
if (!string.IsNullOrWhiteSpace(trailingText) &&
!string.IsNullOrEmpty(currentLabel) &&
!trailingTextBelongsToCurrentRow)
{
currentRowLines.Add(trailingText);
}
FlushCurrentRow();
currentLabel = label;
if (rowIndex == 0)
{
currentRowLines.AddRange(leadingLines);
}
if (!string.IsNullOrWhiteSpace(trailingText) && trailingTextBelongsToCurrentRow)
{
currentRowLines.Add(trailingText);
}
continue;
}
if (!string.IsNullOrWhiteSpace(lines[lineIndex]))
{
currentRowLines.Add(lines[lineIndex]);
}
}
FlushCurrentRow();
return new ParsedCriticalTable(
entry.Slug,
entry.DisplayName,
entry.Family,
Path.GetFileName(entry.PdfPath),
"Imported from PDF text extraction.",
columns,
rollBands,
results);
}
private static bool IsColumnHeaderLine(string line)
{
var matches = ColumnRegex.Matches(line);
return matches.Count == 5;
}
private static List<(string Label, int Start)> GetColumnStarts(string headerLine)
{
var matches = ColumnRegex.Matches(headerLine);
return matches
.Select(match => (match.Groups[1].Value.ToUpperInvariant(), match.Index))
.ToList();
}
private static int[] GetColumnBoundaries(IReadOnlyList<(string Label, int Start)> columns)
{
var boundaries = new int[columns.Count - 1];
for (var index = 0; index < boundaries.Length; index++)
{
boundaries[index] = (columns[index].Start + columns[index + 1].Start) / 2;
}
return boundaries;
}
private static int FindNextRollBandIndex(IReadOnlyList<string> lines, int startIndex)
{
for (var index = startIndex; index < lines.Count; index++)
{
if (TryParseRollBandLine(lines[index], out _, out _))
{
return index;
}
}
return -1;
}
private static bool TryParseRollBandLabel(string line, out string label)
{
var match = RollBandRegex.Match(line);
if (!match.Success)
{
label = string.Empty;
return false;
}
label = match.Groups[1].Value.Replace(" ", string.Empty, StringComparison.Ordinal);
return true;
}
private static bool TryParseRollBandLine(string line, out string label, out string trailingText)
{
var match = RollBandLineRegex.Match(line);
if (!match.Success)
{
label = string.Empty;
trailingText = string.Empty;
return false;
}
label = match.Groups["label"].Value.Replace(" ", string.Empty, StringComparison.Ordinal);
var restGroup = match.Groups["rest"];
trailingText = restGroup.Success
? string.Concat(new string(' ', restGroup.Index), restGroup.Value.TrimEnd())
: string.Empty;
return true;
}
private static ParsedCriticalRollBand CreateRollBand(string label, int sortOrder)
{
if (label.EndsWith('+'))
{
return new ParsedCriticalRollBand(label, int.Parse(label[..^1]), null, sortOrder);
}
var parts = label.Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
return parts.Length == 1
? new ParsedCriticalRollBand(label, int.Parse(parts[0]), int.Parse(parts[0]), sortOrder)
: new ParsedCriticalRollBand(label, int.Parse(parts[0]), int.Parse(parts[1]), sortOrder);
}
private static List<string>[] SplitRowLines(IReadOnlyList<string> rowLines, int[] boundaries, int columnCount)
{
var result = Enumerable.Range(0, columnCount)
.Select(_ => new List<string>())
.ToArray();
foreach (var line in rowLines)
{
for (var columnIndex = 0; columnIndex < columnCount; columnIndex++)
{
var start = columnIndex == 0 ? 0 : boundaries[columnIndex - 1];
var end = columnIndex == columnCount - 1
? line.Length
: Math.Min(boundaries[columnIndex], line.Length);
if (start >= line.Length || end <= start)
{
continue;
}
var segment = line[start..end].Trim();
if (!string.IsNullOrWhiteSpace(segment))
{
result[columnIndex].Add(segment);
}
}
}
return result;
}
private static bool IsAffixLikeLine(string line)
{
var value = line.Trim();
if (value.Length == 0)
{
return false;
}
if (value == "—")
{
return true;
}
if (value.StartsWith("with ", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("w/o ", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("without ", StringComparison.OrdinalIgnoreCase) ||
value.StartsWith("if ", StringComparison.OrdinalIgnoreCase))
{
return value.Contains(':', StringComparison.Ordinal);
}
return value.StartsWith("+", StringComparison.Ordinal) ||
value.StartsWith('∑') ||
value.StartsWith('∏') ||
value.StartsWith('π') ||
value.StartsWith('∫') ||
char.IsDigit(value[0]) ||
value.Contains(" ", StringComparison.Ordinal) ||
value.Contains("(-", StringComparison.Ordinal) ||
value.Contains("(+", StringComparison.Ordinal);
}
private static string CollapseWhitespace(string value) =>
Regex.Replace(value.Trim(), @"\s+", " ");
}

View File

@@ -0,0 +1,34 @@
using System.Diagnostics;
namespace RolemasterDb.ImportTool;
public sealed class PdfTextExtractor
{
public async Task ExtractAsync(string pdfPath, string outputPath, CancellationToken cancellationToken = default)
{
Directory.CreateDirectory(Path.GetDirectoryName(outputPath)!);
var startInfo = new ProcessStartInfo
{
FileName = "pdftotext",
RedirectStandardError = true,
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true
};
startInfo.ArgumentList.Add("-layout");
startInfo.ArgumentList.Add(pdfPath);
startInfo.ArgumentList.Add(outputPath);
using var process = new Process { StartInfo = startInfo };
process.Start();
await process.WaitForExitAsync(cancellationToken);
if (process.ExitCode != 0)
{
var error = await process.StandardError.ReadToEndAsync(cancellationToken);
throw new InvalidOperationException($"pdftotext failed for '{pdfPath}': {error}");
}
}
}

View File

@@ -0,0 +1,28 @@
using CommandLine;
using RolemasterDb.ImportTool;
var runner = new CriticalImportCommandRunner();
var exitCode = await Parser.Default.ParseArguments<ResetOptions, ExtractOptions, LoadOptions, ImportOptions>(args)
.MapResult(
(ResetOptions options) => ExecuteAsync(() => runner.RunAsync(options)),
(ExtractOptions options) => ExecuteAsync(() => runner.RunAsync(options)),
(LoadOptions options) => ExecuteAsync(() => runner.RunAsync(options)),
(ImportOptions options) => ExecuteAsync(() => runner.RunAsync(options)),
_ => Task.FromResult(1));
return exitCode;
static async Task<int> ExecuteAsync(Func<Task<int>> command)
{
try
{
return await command();
}
catch (Exception exception)
{
Console.Error.WriteLine(exception.Message);
return 1;
}
}

View File

@@ -0,0 +1,34 @@
namespace RolemasterDb.ImportTool;
public sealed class RepositoryPaths
{
private RepositoryPaths(string rootPath)
{
RootPath = rootPath;
ManifestPath = Path.Combine(rootPath, "sources", "critical-import-manifest.json");
DefaultDatabasePath = Path.Combine(rootPath, "src", "RolemasterDb.App", "rolemaster.db");
ArtifactsRootPath = Path.Combine(rootPath, "artifacts", "import", "critical");
}
public string RootPath { get; }
public string ManifestPath { get; }
public string DefaultDatabasePath { get; }
public string ArtifactsRootPath { get; }
public static RepositoryPaths Discover()
{
var probe = new DirectoryInfo(Directory.GetCurrentDirectory());
while (probe is not null)
{
if (File.Exists(Path.Combine(probe.FullName, "RolemasterDB.slnx")))
{
return new RepositoryPaths(probe.FullName);
}
probe = probe.Parent;
}
throw new InvalidOperationException("Could not discover the repository root from the current directory.");
}
}

View File

@@ -0,0 +1,13 @@
using CommandLine;
namespace RolemasterDb.ImportTool;
[Verb("reset", HelpText = "Remove importer-managed critical data from SQLite.")]
public sealed class ResetOptions
{
[Value(0, MetaName = "target", Required = true, HelpText = "The reset target. Phase 1 supports only 'criticals'.")]
public string Target { get; set; } = string.Empty;
[Option('d', "db", HelpText = "Optional SQLite database path.")]
public string? DatabasePath { get; set; }
}

View File

@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.9.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\RolemasterDb.App\RolemasterDb.App.csproj" />
</ItemGroup>
</Project>