From cada74c7ac11139955ab487439a184b4f6717a9a Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 15 Mar 2026 02:47:10 +0100 Subject: [PATCH] Add rules-aware lookup dice rolling --- docs/player_gm_ux_redesign_plan.md | 6 +- .../Components/Pages/Api.razor | 8 +- .../Components/Pages/Home.razor | 157 +++++++++++++++++- .../Data/RolemasterDbInitializer.cs | 2 + .../Data/RolemasterDbSchemaUpgrader.cs | 64 +++++++ .../Data/RolemasterSeedData.cs | 32 ++++ src/RolemasterDb.App/Domain/AttackTable.cs | 2 + .../Features/LookupContracts.cs | 9 +- .../Features/LookupRollResult.cs | 10 ++ .../Features/LookupService.cs | 7 +- .../Features/RolemasterRoller.cs | 46 +++++ src/RolemasterDb.App/wwwroot/app.css | 33 ++++ .../LookupRollingTests.cs | 150 +++++++++++++++++ .../SequenceRandom.cs | 22 +++ 14 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 src/RolemasterDb.App/Features/LookupRollResult.cs create mode 100644 src/RolemasterDb.App/Features/RolemasterRoller.cs create mode 100644 src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs create mode 100644 src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs 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
                 
- @foreach (var attackTable in referenceData.AttackTables) { @@ -41,12 +42,34 @@ else
- +
+ + +
+ @if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax }) + { +

Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)

+ } + @if (!string.IsNullOrWhiteSpace(attackRollSummary)) + { +

@attackRollSummary

+ } + @if (!string.IsNullOrWhiteSpace(attackFumbleMessage)) + { +

@attackFumbleMessage

+ }
- +
+ + +
+ @if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary)) + { +

@attackCriticalRollSummary

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