diff --git a/docs/player_gm_ux_redesign_plan.md b/docs/player_gm_ux_redesign_plan.md index 3d6d67f..2f8ccb6 100644 --- a/docs/player_gm_ux_redesign_plan.md +++ b/docs/player_gm_ux_redesign_plan.md @@ -514,6 +514,10 @@ These changes are real and complete, but they are no longer the active roadmap b ### Phase 1: Complete live lookup flow ergonomics +Status: + +- implemented in the web app on March 15, 2026 + Scope: - add dice buttons to the attack and direct critical lookup inputs @@ -622,4 +626,4 @@ Mitigation: ## Recommended Next Step -Implement the new Phase 1 and Phase 2 first. Dice-button support and the compact inline popup editor close the most visible gaps in the detailed acceptance checklist and should be completed before the override and compare workflow phases. +Implement the new Phase 2 next. The compact inline popup editor is now the most visible remaining UX gap after the lookup flow ergonomics pass. diff --git a/src/RolemasterDb.App/Components/Pages/Api.razor b/src/RolemasterDb.App/Components/Pages/Api.razor index b773958..12868c9 100644 --- a/src/RolemasterDb.App/Components/Pages/Api.razor +++ b/src/RolemasterDb.App/Components/Pages/Api.razor @@ -8,7 +8,13 @@
GET /api/reference-data
{
"attackTables": [
- { "key": "broadsword", "label": "Broadsword" }
+ {
+ "key": "broadsword",
+ "label": "Broadsword",
+ "attackKind": "melee",
+ "fumbleMinRoll": 1,
+ "fumbleMaxRoll": 2
+ }
],
"criticalTables": [
{
diff --git a/src/RolemasterDb.App/Components/Pages/Home.razor b/src/RolemasterDb.App/Components/Pages/Home.razor
index 052b8d7..37d3d05 100644
--- a/src/RolemasterDb.App/Components/Pages/Home.razor
+++ b/src/RolemasterDb.App/Components/Pages/Home.razor
@@ -1,5 +1,6 @@
@page "/"
@rendermode InteractiveServer
+@using System
@inject LookupService LookupService
Lookup Desk
@@ -21,7 +22,7 @@ else
-
@@ -140,7 +163,14 @@ else
-
+
+
+
+
+ @if (!string.IsNullOrWhiteSpace(directCriticalRollSummary))
+ {
+ @directCriticalRollSummary
+ }
@@ -172,6 +202,13 @@ else
private CriticalLookupResponse? criticalResult;
private string? attackError;
private string? criticalError;
+ private string? attackRollSummary;
+ private string? attackCriticalRollSummary;
+ private string? directCriticalRollSummary;
+ private string? attackFumbleMessage;
+
+ private AttackTableReference? SelectedAttackTable =>
+ referenceData?.AttackTables.FirstOrDefault(table => table.Key == attackInput.AttackTable);
private CriticalTableReference? SelectedCriticalTable =>
referenceData?.CriticalTables.FirstOrDefault(table => table.Key == criticalInput.CriticalType);
@@ -193,6 +230,12 @@ else
{
attackError = null;
attackResult = null;
+ attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
+
+ if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
+ {
+ return;
+ }
if (!int.TryParse(attackInput.CriticalRollText, out var criticalRoll) && !string.IsNullOrWhiteSpace(attackInput.CriticalRollText))
{
@@ -244,8 +287,114 @@ else
criticalInput.Group = table?.Groups.FirstOrDefault()?.Key ?? string.Empty;
criticalResult = null;
criticalError = null;
+ directCriticalRollSummary = null;
}
+ private void HandleAttackTableChanged(ChangeEventArgs args)
+ {
+ attackInput.AttackTable = args.Value?.ToString() ?? string.Empty;
+ attackResult = null;
+ attackError = null;
+ attackCriticalRollSummary = null;
+ RefreshAttackRollState();
+ }
+
+ private void RollAttack()
+ {
+ var result = RolemasterRoller.RollAttack(Random.Shared);
+ attackInput.AttackRoll = result.Total;
+ attackRollSummary = BuildRollSummary(result, "Attack");
+ attackCriticalRollSummary = null;
+ attackResult = null;
+ attackError = null;
+ RefreshAttackRollState();
+ }
+
+ private async Task RollAttackCriticalAsync()
+ {
+ attackError = null;
+ attackCriticalRollSummary = null;
+ attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
+
+ if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
+ {
+ attackCriticalRollSummary = "No critical roll is needed because the current attack roll is a fumble.";
+ return;
+ }
+
+ CriticalTableReference? criticalTable = null;
+ var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(
+ attackInput.AttackTable,
+ attackInput.ArmorType,
+ attackInput.AttackRoll,
+ null));
+
+ if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType))
+ {
+ criticalTable = referenceData?.CriticalTables.FirstOrDefault(item =>
+ string.Equals(item.Key, pendingAttack.CriticalType, StringComparison.Ordinal));
+ }
+
+ var result = RolemasterRoller.RollCritical(Random.Shared, criticalTable);
+ attackInput.CriticalRollText = result.Total.ToString();
+ attackCriticalRollSummary = BuildCriticalRollSummary(result, criticalTable);
+ attackResult = null;
+ }
+
+ private void RollDirectCritical()
+ {
+ var result = RolemasterRoller.RollCritical(Random.Shared, SelectedCriticalTable);
+ criticalInput.Roll = result.Total;
+ directCriticalRollSummary = BuildCriticalRollSummary(result, SelectedCriticalTable);
+ criticalResult = null;
+ criticalError = null;
+ }
+
+ private void RefreshAttackRollState()
+ {
+ attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
+ }
+
+ private string? BuildAttackFumbleMessage(int attackRoll)
+ {
+ if (SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll } ||
+ attackRoll < fumbleMinRoll ||
+ attackRoll > fumbleMaxRoll)
+ {
+ return null;
+ }
+
+ return $"{SelectedAttackTable.Label} fumble on {FormatRange(fumbleMinRoll, fumbleMaxRoll)}. Do not run a normal attack lookup.";
+ }
+
+ private static string BuildRollSummary(LookupRollResult result, string label)
+ {
+ if (!result.IsOpenEnded)
+ {
+ return $"{label} roll: {result.Total}.";
+ }
+
+ return $"{label} roll: {string.Join(" + ", result.Rolls)} = {result.Total}.";
+ }
+
+ private static string BuildCriticalRollSummary(
+ LookupRollResult result,
+ CriticalTableReference? criticalTable)
+ {
+ var summary = BuildRollSummary(result, "Critical");
+ if (criticalTable is null)
+ {
+ return $"{summary} Standard 1-100 roll used because no critical table is currently resolved from the attack result.";
+ }
+
+ return RolemasterRoller.AllowsOpenEndedCritical(criticalTable)
+ ? $"{summary} {criticalTable.Label} uses open-ended rolls."
+ : $"{summary} {criticalTable.Label} is capped at 1-100.";
+ }
+
+ private static string FormatRange(int minRoll, int maxRoll) =>
+ $"{minRoll:00}-{maxRoll:00}";
+
private sealed class AttackLookupForm
{
public string AttackTable { get; set; } = string.Empty;
diff --git a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
index aa3ef6c..3a799d8 100644
--- a/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
+++ b/src/RolemasterDb.App/Data/RolemasterDbInitializer.cs
@@ -12,9 +12,11 @@ public static class RolemasterDbInitializer
await dbContext.Database.EnsureCreatedAsync(cancellationToken);
await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken);
+ RolemasterSeedData.BackfillAttackTableMetadata(dbContext);
if (await dbContext.AttackTables.AnyAsync(cancellationToken))
{
+ await dbContext.SaveChangesAsync(cancellationToken);
return;
}
diff --git a/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs b/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
index b26f552..096de14 100644
--- a/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
+++ b/src/RolemasterDb.App/Data/RolemasterDbSchemaUpgrader.cs
@@ -1,3 +1,4 @@
+using System;
using Microsoft.EntityFrameworkCore;
namespace RolemasterDb.App.Data;
@@ -6,6 +7,8 @@ public static class RolemasterDbSchemaUpgrader
{
public static async Task EnsureLatestAsync(RolemasterDbContext dbContext, CancellationToken cancellationToken = default)
{
+ await EnsureAttackTableFumbleColumnsAsync(dbContext, cancellationToken);
+
await dbContext.Database.ExecuteSqlRawAsync(
"""
CREATE TABLE IF NOT EXISTS "CriticalBranches" (
@@ -87,4 +90,65 @@ public static class RolemasterDbSchemaUpgrader
""",
cancellationToken);
}
+
+ private static async Task EnsureAttackTableFumbleColumnsAsync(RolemasterDbContext dbContext, CancellationToken cancellationToken)
+ {
+ if (!await ColumnExistsAsync(dbContext, "AttackTables", "FumbleMinRoll", cancellationToken))
+ {
+ await dbContext.Database.ExecuteSqlRawAsync(
+ """
+ ALTER TABLE "AttackTables"
+ ADD COLUMN "FumbleMinRoll" INTEGER NULL;
+ """,
+ cancellationToken);
+ }
+
+ if (!await ColumnExistsAsync(dbContext, "AttackTables", "FumbleMaxRoll", cancellationToken))
+ {
+ await dbContext.Database.ExecuteSqlRawAsync(
+ """
+ ALTER TABLE "AttackTables"
+ ADD COLUMN "FumbleMaxRoll" INTEGER NULL;
+ """,
+ cancellationToken);
+ }
+ }
+
+ private static async Task ColumnExistsAsync(
+ RolemasterDbContext dbContext,
+ string tableName,
+ string columnName,
+ CancellationToken cancellationToken)
+ {
+ var connection = dbContext.Database.GetDbConnection();
+ var shouldClose = connection.State != System.Data.ConnectionState.Open;
+ if (shouldClose)
+ {
+ await connection.OpenAsync(cancellationToken);
+ }
+
+ try
+ {
+ await using var command = connection.CreateCommand();
+ command.CommandText = $"PRAGMA table_info(\"{tableName}\");";
+
+ await using var reader = await command.ExecuteReaderAsync(cancellationToken);
+ while (await reader.ReadAsync(cancellationToken))
+ {
+ if (string.Equals(reader["name"]?.ToString(), columnName, StringComparison.Ordinal))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+ finally
+ {
+ if (shouldClose)
+ {
+ await connection.CloseAsync();
+ }
+ }
+ }
}
diff --git a/src/RolemasterDb.App/Data/RolemasterSeedData.cs b/src/RolemasterDb.App/Data/RolemasterSeedData.cs
index 487f573..fb6f3d2 100644
--- a/src/RolemasterDb.App/Data/RolemasterSeedData.cs
+++ b/src/RolemasterDb.App/Data/RolemasterSeedData.cs
@@ -4,6 +4,13 @@ namespace RolemasterDb.App.Data;
public static class RolemasterSeedData
{
+ private static readonly IReadOnlyDictionary AttackTableMetadata =
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["broadsword"] = (1, 2),
+ ["short_bow"] = (1, 3)
+ };
+
public static void SeedAttackStarterData(RolemasterDbContext dbContext)
{
List armorTypes;
@@ -28,6 +35,27 @@ public static class RolemasterSeedData
dbContext.AttackTables.AddRange(attackTables);
}
+ public static void BackfillAttackTableMetadata(RolemasterDbContext dbContext)
+ {
+ foreach (var table in dbContext.AttackTables)
+ {
+ if (!AttackTableMetadata.TryGetValue(table.Slug, out var metadata))
+ {
+ continue;
+ }
+
+ if (table.FumbleMinRoll is null)
+ {
+ table.FumbleMinRoll = metadata.FumbleMinRoll;
+ }
+
+ if (table.FumbleMaxRoll is null)
+ {
+ table.FumbleMaxRoll = metadata.FumbleMaxRoll;
+ }
+ }
+ }
+
private static List CreateArmorTypes() =>
[
new ArmorType { Code = "AT1", Label = "AT1 - Robes", SortOrder = 1 },
@@ -44,6 +72,8 @@ public static class RolemasterSeedData
Slug = "broadsword",
DisplayName = "Broadsword",
AttackKind = "melee",
+ FumbleMinRoll = 1,
+ FumbleMaxRoll = 2,
Notes = "Starter subset with slash criticals to prove the end-to-end lookup flow."
};
@@ -52,6 +82,8 @@ public static class RolemasterSeedData
Slug = "short_bow",
DisplayName = "Short Bow",
AttackKind = "missile",
+ FumbleMinRoll = 1,
+ FumbleMaxRoll = 3,
Notes = "Starter subset with puncture criticals."
};
diff --git a/src/RolemasterDb.App/Domain/AttackTable.cs b/src/RolemasterDb.App/Domain/AttackTable.cs
index 4f2e07c..c499d20 100644
--- a/src/RolemasterDb.App/Domain/AttackTable.cs
+++ b/src/RolemasterDb.App/Domain/AttackTable.cs
@@ -7,6 +7,8 @@ public sealed class AttackTable
public string DisplayName { get; set; } = string.Empty;
public string AttackKind { get; set; } = string.Empty;
public string? Notes { get; set; }
+ public int? FumbleMinRoll { get; set; }
+ public int? FumbleMaxRoll { get; set; }
public List RollBands { get; set; } = [];
public List Results { get; set; } = [];
}
diff --git a/src/RolemasterDb.App/Features/LookupContracts.cs b/src/RolemasterDb.App/Features/LookupContracts.cs
index f655907..4083375 100644
--- a/src/RolemasterDb.App/Features/LookupContracts.cs
+++ b/src/RolemasterDb.App/Features/LookupContracts.cs
@@ -4,6 +4,13 @@ namespace RolemasterDb.App.Features;
public sealed record LookupOption(string Key, string Label);
+public sealed record AttackTableReference(
+ string Key,
+ string Label,
+ string AttackKind,
+ int? FumbleMinRoll,
+ int? FumbleMaxRoll);
+
public sealed record CriticalColumnReference(
string Key,
string Label,
@@ -32,7 +39,7 @@ public sealed record CriticalTableReference(
IReadOnlyList RollBands);
public sealed record LookupReferenceData(
- IReadOnlyList AttackTables,
+ IReadOnlyList AttackTables,
IReadOnlyList ArmorTypes,
IReadOnlyList CriticalTables);
diff --git a/src/RolemasterDb.App/Features/LookupRollResult.cs b/src/RolemasterDb.App/Features/LookupRollResult.cs
new file mode 100644
index 0000000..99fd908
--- /dev/null
+++ b/src/RolemasterDb.App/Features/LookupRollResult.cs
@@ -0,0 +1,10 @@
+using System.Collections.Generic;
+
+namespace RolemasterDb.App.Features;
+
+public sealed record LookupRollResult(
+ int Total,
+ IReadOnlyList Rolls)
+{
+ public bool IsOpenEnded => Rolls.Count > 1;
+}
diff --git a/src/RolemasterDb.App/Features/LookupService.cs b/src/RolemasterDb.App/Features/LookupService.cs
index f726e3f..05333be 100644
--- a/src/RolemasterDb.App/Features/LookupService.cs
+++ b/src/RolemasterDb.App/Features/LookupService.cs
@@ -18,7 +18,12 @@ public sealed class LookupService(IDbContextFactory dbConte
var attackTables = await dbContext.AttackTables
.AsNoTracking()
.OrderBy(item => item.DisplayName)
- .Select(item => new LookupOption(item.Slug, item.DisplayName))
+ .Select(item => new AttackTableReference(
+ item.Slug,
+ item.DisplayName,
+ item.AttackKind,
+ item.FumbleMinRoll,
+ item.FumbleMaxRoll))
.ToListAsync(cancellationToken);
var armorTypes = await dbContext.ArmorTypes
diff --git a/src/RolemasterDb.App/Features/RolemasterRoller.cs b/src/RolemasterDb.App/Features/RolemasterRoller.cs
new file mode 100644
index 0000000..413b016
--- /dev/null
+++ b/src/RolemasterDb.App/Features/RolemasterRoller.cs
@@ -0,0 +1,46 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+
+namespace RolemasterDb.App.Features;
+
+public static class RolemasterRoller
+{
+ public static LookupRollResult RollAttack(Random random) =>
+ RollOpenEndedHigh(random);
+
+ public static LookupRollResult RollCritical(Random random, CriticalTableReference? table) =>
+ AllowsOpenEndedCritical(table)
+ ? RollOpenEndedHigh(random)
+ : RollStandard(random);
+
+ public static bool AllowsOpenEndedCritical(CriticalTableReference? table) =>
+ table is not null &&
+ table.RollBands.Any(item => item.MinRoll > 100 || item.MaxRoll is null || item.MaxRoll > 100);
+
+ public static LookupRollResult RollStandard(Random random)
+ {
+ var roll = random.Next(1, 101);
+ return new LookupRollResult(roll, [roll]);
+ }
+
+ public static LookupRollResult RollOpenEndedHigh(Random random)
+ {
+ var rolls = new List();
+ var total = 0;
+
+ while (true)
+ {
+ var roll = random.Next(1, 101);
+ rolls.Add(roll);
+ total += roll;
+
+ if (roll < 96)
+ {
+ break;
+ }
+ }
+
+ return new LookupRollResult(total, rolls);
+ }
+}
diff --git a/src/RolemasterDb.App/wwwroot/app.css b/src/RolemasterDb.App/wwwroot/app.css
index 4ae9c64..240062d 100644
--- a/src/RolemasterDb.App/wwwroot/app.css
+++ b/src/RolemasterDb.App/wwwroot/app.css
@@ -115,6 +115,16 @@ textarea {
gap: 0.35rem;
}
+.roll-input-row {
+ display: flex;
+ gap: 0.55rem;
+ align-items: stretch;
+}
+
+.roll-input-row .input-shell {
+ flex: 1 1 auto;
+}
+
.field-shell label {
font-size: 0.85rem;
letter-spacing: 0.08em;
@@ -158,6 +168,29 @@ textarea {
background: linear-gradient(135deg, #c38a4d, #8f5a2f);
}
+.roll-button {
+ flex: 0 0 auto;
+ min-width: 4.5rem;
+ border-radius: 14px;
+ border: 1px solid rgba(127, 96, 55, 0.18);
+ background: rgba(255, 248, 236, 0.9);
+ color: #6a4b28;
+ padding: 0.8rem 0.95rem;
+}
+
+.roll-button:hover {
+ background: rgba(250, 236, 210, 0.95);
+}
+
+.lookup-roll-note {
+ margin: 0;
+ font-size: 0.88rem;
+}
+
+.lookup-roll-note.is-warning {
+ color: #8d2b1e;
+}
+
.tag-row {
display: flex;
gap: 0.6rem;
diff --git a/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs
new file mode 100644
index 0000000..4fe9f9d
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs
@@ -0,0 +1,150 @@
+using Microsoft.Data.Sqlite;
+using Microsoft.EntityFrameworkCore;
+
+using RolemasterDb.App.Data;
+using RolemasterDb.App.Features;
+
+namespace RolemasterDb.ImportTool.Tests;
+
+public sealed class LookupRollingTests
+{
+ [Fact]
+ public void Attack_rolls_open_end_on_high_results()
+ {
+ var result = RolemasterRoller.RollAttack(new SequenceRandom(96, 100, 43));
+
+ Assert.Equal(239, result.Total);
+ Assert.Equal([96, 100, 43], result.Rolls);
+ Assert.True(result.IsOpenEnded);
+ }
+
+ [Fact]
+ public void Standard_critical_rolls_are_capped_at_one_hundred()
+ {
+ var table = CreateCriticalTableReference(
+ "slash",
+ "Slash Critical Strike Table",
+ [new CriticalRollBandReference("01-100", 1, 100, 1)]);
+
+ var result = RolemasterRoller.RollCritical(new SequenceRandom(99), table);
+
+ Assert.Equal(99, result.Total);
+ Assert.Equal([99], result.Rolls);
+ Assert.False(result.IsOpenEnded);
+ Assert.False(RolemasterRoller.AllowsOpenEndedCritical(table));
+ }
+
+ [Fact]
+ public void Extended_critical_rolls_use_open_ended_high_rules()
+ {
+ var table = CreateCriticalTableReference(
+ "large_creature",
+ "Large Creature Critical Strike Table",
+ [new CriticalRollBandReference("101+", 101, null, 1)]);
+
+ var result = RolemasterRoller.RollCritical(new SequenceRandom(97, 45), table);
+
+ Assert.Equal(142, result.Total);
+ Assert.Equal([97, 45], result.Rolls);
+ Assert.True(result.IsOpenEnded);
+ Assert.True(RolemasterRoller.AllowsOpenEndedCritical(table));
+ }
+
+ [Fact]
+ public async Task Schema_upgrader_adds_attack_table_fumble_columns()
+ {
+ var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-schema-{Guid.NewGuid():N}.db");
+ await using (var connection = new SqliteConnection($"Data Source={databasePath}"))
+ {
+ await connection.OpenAsync();
+
+ await using var command = connection.CreateCommand();
+ command.CommandText =
+ """
+ CREATE TABLE "AttackTables" (
+ "Id" INTEGER NOT NULL CONSTRAINT "PK_AttackTables" PRIMARY KEY AUTOINCREMENT,
+ "Slug" TEXT NOT NULL,
+ "DisplayName" TEXT NOT NULL,
+ "AttackKind" TEXT NOT NULL,
+ "Notes" TEXT NULL
+ );
+ """;
+ await command.ExecuteNonQueryAsync();
+ }
+
+ await using (var dbContext = CreateDbContext(databasePath))
+ {
+ await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext);
+ }
+
+ await using var verifyConnection = new SqliteConnection($"Data Source={databasePath}");
+ await verifyConnection.OpenAsync();
+
+ var columns = new List();
+ await using (var command = verifyConnection.CreateCommand())
+ {
+ command.CommandText = "PRAGMA table_info(\"AttackTables\");";
+ await using var reader = await command.ExecuteReaderAsync();
+ while (await reader.ReadAsync())
+ {
+ columns.Add(reader["name"]!.ToString()!);
+ }
+ }
+
+ Assert.Contains("FumbleMinRoll", columns);
+ Assert.Contains("FumbleMaxRoll", columns);
+ }
+
+ [Fact]
+ public async Task Reference_data_includes_attack_table_fumble_ranges()
+ {
+ var databasePath = Path.Combine(Path.GetTempPath(), $"rolemaster-reference-{Guid.NewGuid():N}.db");
+
+ await using (var dbContext = CreateDbContext(databasePath))
+ {
+ await dbContext.Database.EnsureCreatedAsync();
+ await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext);
+ RolemasterSeedData.SeedAttackStarterData(dbContext);
+ await dbContext.SaveChangesAsync();
+ }
+
+ var lookupService = new LookupService(CreateDbContextFactory(databasePath));
+ var referenceData = await lookupService.GetReferenceDataAsync();
+
+ var broadsword = Assert.Single(referenceData.AttackTables, item => item.Key == "broadsword");
+ Assert.Equal(1, broadsword.FumbleMinRoll);
+ Assert.Equal(2, broadsword.FumbleMaxRoll);
+ }
+
+ private static CriticalTableReference CreateCriticalTableReference(
+ string key,
+ string label,
+ IReadOnlyList rollBands) =>
+ new(
+ key,
+ label,
+ "standard",
+ $"{label}.pdf",
+ null,
+ [],
+ [],
+ rollBands);
+
+ private static RolemasterDbContext CreateDbContext(string databasePath)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={databasePath}")
+ .Options;
+
+ return new RolemasterDbContext(options);
+ }
+
+ private static IDbContextFactory CreateDbContextFactory(string databasePath)
+ {
+ var options = new DbContextOptionsBuilder()
+ .UseSqlite($"Data Source={databasePath}")
+ .Options;
+
+ return new TestRolemasterDbContextFactory(options);
+ }
+}
diff --git a/src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs b/src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs
new file mode 100644
index 0000000..30f500f
--- /dev/null
+++ b/src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs
@@ -0,0 +1,22 @@
+namespace RolemasterDb.ImportTool.Tests;
+
+internal sealed class SequenceRandom(params int[] values) : Random
+{
+ private readonly Queue values = new(values);
+
+ public override int Next(int minValue, int maxValue)
+ {
+ if (values.Count == 0)
+ {
+ throw new InvalidOperationException("No queued random values remain.");
+ }
+
+ var value = values.Dequeue();
+ if (value < minValue || value >= maxValue)
+ {
+ throw new InvalidOperationException($"Queued random value {value} is outside the requested range [{minValue}, {maxValue}).");
+ }
+
+ return value;
+ }
+}