Add rules-aware lookup dice rolling

This commit is contained in:
2026-03-15 02:47:10 +01:00
parent 74613724bc
commit cada74c7ac
14 changed files with 540 additions and 8 deletions

View File

@@ -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 ### Phase 1: Complete live lookup flow ergonomics
Status:
- implemented in the web app on March 15, 2026
Scope: Scope:
- add dice buttons to the attack and direct critical lookup inputs - add dice buttons to the attack and direct critical lookup inputs
@@ -622,4 +626,4 @@ Mitigation:
## Recommended Next Step ## 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.

View File

@@ -8,7 +8,13 @@
<p class="panel-copy"><code>GET /api/reference-data</code></p> <p class="panel-copy"><code>GET /api/reference-data</code></p>
<pre class="code-block">{ <pre class="code-block">{
"attackTables": [ "attackTables": [
{ "key": "broadsword", "label": "Broadsword" } {
"key": "broadsword",
"label": "Broadsword",
"attackKind": "melee",
"fumbleMinRoll": 1,
"fumbleMaxRoll": 2
}
], ],
"criticalTables": [ "criticalTables": [
{ {

View File

@@ -1,5 +1,6 @@
@page "/" @page "/"
@rendermode InteractiveServer @rendermode InteractiveServer
@using System
@inject LookupService LookupService @inject LookupService LookupService
<PageTitle>Lookup Desk</PageTitle> <PageTitle>Lookup Desk</PageTitle>
@@ -21,7 +22,7 @@ else
<div class="form-grid"> <div class="form-grid">
<div class="field-shell"> <div class="field-shell">
<label for="attack-table">Attack table</label> <label for="attack-table">Attack table</label>
<select id="attack-table" class="input-shell" @bind="attackInput.AttackTable"> <select id="attack-table" class="input-shell" value="@attackInput.AttackTable" @onchange="HandleAttackTableChanged">
@foreach (var attackTable in referenceData.AttackTables) @foreach (var attackTable in referenceData.AttackTables)
{ {
<option value="@attackTable.Key">@attackTable.Label</option> <option value="@attackTable.Key">@attackTable.Label</option>
@@ -41,12 +42,34 @@ else
<div class="field-shell"> <div class="field-shell">
<label for="attack-roll">Attack roll</label> <label for="attack-roll">Attack roll</label>
<input id="attack-roll" class="input-shell" type="number" min="1" max="300" @bind="attackInput.AttackRoll" /> <div class="roll-input-row">
<input id="attack-roll" class="input-shell" type="number" min="1" @bind="attackInput.AttackRoll" />
<button type="button" class="roll-button" @onclick="RollAttack">Roll</button>
</div>
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax })
{
<p class="muted lookup-roll-note">Fumble range: @FormatRange(attackFumbleMin, attackFumbleMax)</p>
}
@if (!string.IsNullOrWhiteSpace(attackRollSummary))
{
<p class="muted lookup-roll-note">@attackRollSummary</p>
}
@if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
<p class="lookup-roll-note is-warning">@attackFumbleMessage</p>
}
</div> </div>
<div class="field-shell"> <div class="field-shell">
<label for="critical-roll">Critical roll</label> <label for="critical-roll">Critical roll</label>
<input id="critical-roll" class="input-shell" type="number" min="1" max="100" @bind="attackInput.CriticalRollText" /> <div class="roll-input-row">
<input id="critical-roll" class="input-shell" type="number" min="1" @bind="attackInput.CriticalRollText" />
<button type="button" class="roll-button" @onclick="RollAttackCriticalAsync">Roll</button>
</div>
@if (!string.IsNullOrWhiteSpace(attackCriticalRollSummary))
{
<p class="muted lookup-roll-note">@attackCriticalRollSummary</p>
}
</div> </div>
</div> </div>
@@ -140,7 +163,14 @@ else
<div class="field-shell"> <div class="field-shell">
<label for="critical-roll-direct">Critical roll</label> <label for="critical-roll-direct">Critical roll</label>
<input id="critical-roll-direct" class="input-shell" type="number" min="1" max="100" @bind="criticalInput.Roll" /> <div class="roll-input-row">
<input id="critical-roll-direct" class="input-shell" type="number" min="1" @bind="criticalInput.Roll" />
<button type="button" class="roll-button" @onclick="RollDirectCritical">Roll</button>
</div>
@if (!string.IsNullOrWhiteSpace(directCriticalRollSummary))
{
<p class="muted lookup-roll-note">@directCriticalRollSummary</p>
}
</div> </div>
</div> </div>
@@ -172,6 +202,13 @@ else
private CriticalLookupResponse? criticalResult; private CriticalLookupResponse? criticalResult;
private string? attackError; private string? attackError;
private string? criticalError; 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 => private CriticalTableReference? SelectedCriticalTable =>
referenceData?.CriticalTables.FirstOrDefault(table => table.Key == criticalInput.CriticalType); referenceData?.CriticalTables.FirstOrDefault(table => table.Key == criticalInput.CriticalType);
@@ -193,6 +230,12 @@ else
{ {
attackError = null; attackError = null;
attackResult = null; attackResult = null;
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
return;
}
if (!int.TryParse(attackInput.CriticalRollText, out var criticalRoll) && !string.IsNullOrWhiteSpace(attackInput.CriticalRollText)) 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; criticalInput.Group = table?.Groups.FirstOrDefault()?.Key ?? string.Empty;
criticalResult = null; criticalResult = null;
criticalError = 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 private sealed class AttackLookupForm
{ {
public string AttackTable { get; set; } = string.Empty; public string AttackTable { get; set; } = string.Empty;

View File

@@ -12,9 +12,11 @@ public static class RolemasterDbInitializer
await dbContext.Database.EnsureCreatedAsync(cancellationToken); await dbContext.Database.EnsureCreatedAsync(cancellationToken);
await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken); await RolemasterDbSchemaUpgrader.EnsureLatestAsync(dbContext, cancellationToken);
RolemasterSeedData.BackfillAttackTableMetadata(dbContext);
if (await dbContext.AttackTables.AnyAsync(cancellationToken)) if (await dbContext.AttackTables.AnyAsync(cancellationToken))
{ {
await dbContext.SaveChangesAsync(cancellationToken);
return; return;
} }

View File

@@ -1,3 +1,4 @@
using System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace RolemasterDb.App.Data; namespace RolemasterDb.App.Data;
@@ -6,6 +7,8 @@ public static class RolemasterDbSchemaUpgrader
{ {
public static async Task EnsureLatestAsync(RolemasterDbContext dbContext, CancellationToken cancellationToken = default) public static async Task EnsureLatestAsync(RolemasterDbContext dbContext, CancellationToken cancellationToken = default)
{ {
await EnsureAttackTableFumbleColumnsAsync(dbContext, cancellationToken);
await dbContext.Database.ExecuteSqlRawAsync( await dbContext.Database.ExecuteSqlRawAsync(
""" """
CREATE TABLE IF NOT EXISTS "CriticalBranches" ( CREATE TABLE IF NOT EXISTS "CriticalBranches" (
@@ -87,4 +90,65 @@ public static class RolemasterDbSchemaUpgrader
""", """,
cancellationToken); 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<bool> 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();
}
}
}
} }

View File

@@ -4,6 +4,13 @@ namespace RolemasterDb.App.Data;
public static class RolemasterSeedData public static class RolemasterSeedData
{ {
private static readonly IReadOnlyDictionary<string, (int? FumbleMinRoll, int? FumbleMaxRoll)> AttackTableMetadata =
new Dictionary<string, (int? FumbleMinRoll, int? FumbleMaxRoll)>(StringComparer.OrdinalIgnoreCase)
{
["broadsword"] = (1, 2),
["short_bow"] = (1, 3)
};
public static void SeedAttackStarterData(RolemasterDbContext dbContext) public static void SeedAttackStarterData(RolemasterDbContext dbContext)
{ {
List<ArmorType> armorTypes; List<ArmorType> armorTypes;
@@ -28,6 +35,27 @@ public static class RolemasterSeedData
dbContext.AttackTables.AddRange(attackTables); 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<ArmorType> CreateArmorTypes() => private static List<ArmorType> CreateArmorTypes() =>
[ [
new ArmorType { Code = "AT1", Label = "AT1 - Robes", SortOrder = 1 }, new ArmorType { Code = "AT1", Label = "AT1 - Robes", SortOrder = 1 },
@@ -44,6 +72,8 @@ public static class RolemasterSeedData
Slug = "broadsword", Slug = "broadsword",
DisplayName = "Broadsword", DisplayName = "Broadsword",
AttackKind = "melee", AttackKind = "melee",
FumbleMinRoll = 1,
FumbleMaxRoll = 2,
Notes = "Starter subset with slash criticals to prove the end-to-end lookup flow." 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", Slug = "short_bow",
DisplayName = "Short Bow", DisplayName = "Short Bow",
AttackKind = "missile", AttackKind = "missile",
FumbleMinRoll = 1,
FumbleMaxRoll = 3,
Notes = "Starter subset with puncture criticals." Notes = "Starter subset with puncture criticals."
}; };

View File

@@ -7,6 +7,8 @@ public sealed class AttackTable
public string DisplayName { get; set; } = string.Empty; public string DisplayName { get; set; } = string.Empty;
public string AttackKind { get; set; } = string.Empty; public string AttackKind { get; set; } = string.Empty;
public string? Notes { get; set; } public string? Notes { get; set; }
public int? FumbleMinRoll { get; set; }
public int? FumbleMaxRoll { get; set; }
public List<AttackRollBand> RollBands { get; set; } = []; public List<AttackRollBand> RollBands { get; set; } = [];
public List<AttackResult> Results { get; set; } = []; public List<AttackResult> Results { get; set; } = [];
} }

View File

@@ -4,6 +4,13 @@ namespace RolemasterDb.App.Features;
public sealed record LookupOption(string Key, string Label); 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( public sealed record CriticalColumnReference(
string Key, string Key,
string Label, string Label,
@@ -32,7 +39,7 @@ public sealed record CriticalTableReference(
IReadOnlyList<CriticalRollBandReference> RollBands); IReadOnlyList<CriticalRollBandReference> RollBands);
public sealed record LookupReferenceData( public sealed record LookupReferenceData(
IReadOnlyList<LookupOption> AttackTables, IReadOnlyList<AttackTableReference> AttackTables,
IReadOnlyList<LookupOption> ArmorTypes, IReadOnlyList<LookupOption> ArmorTypes,
IReadOnlyList<CriticalTableReference> CriticalTables); IReadOnlyList<CriticalTableReference> CriticalTables);

View File

@@ -0,0 +1,10 @@
using System.Collections.Generic;
namespace RolemasterDb.App.Features;
public sealed record LookupRollResult(
int Total,
IReadOnlyList<int> Rolls)
{
public bool IsOpenEnded => Rolls.Count > 1;
}

View File

@@ -18,7 +18,12 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> dbConte
var attackTables = await dbContext.AttackTables var attackTables = await dbContext.AttackTables
.AsNoTracking() .AsNoTracking()
.OrderBy(item => item.DisplayName) .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); .ToListAsync(cancellationToken);
var armorTypes = await dbContext.ArmorTypes var armorTypes = await dbContext.ArmorTypes

View File

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

View File

@@ -115,6 +115,16 @@ textarea {
gap: 0.35rem; 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 { .field-shell label {
font-size: 0.85rem; font-size: 0.85rem;
letter-spacing: 0.08em; letter-spacing: 0.08em;
@@ -158,6 +168,29 @@ textarea {
background: linear-gradient(135deg, #c38a4d, #8f5a2f); 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 { .tag-row {
display: flex; display: flex;
gap: 0.6rem; gap: 0.6rem;

View File

@@ -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<string>();
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<CriticalRollBandReference> rollBands) =>
new(
key,
label,
"standard",
$"{label}.pdf",
null,
[],
[],
rollBands);
private static RolemasterDbContext CreateDbContext(string databasePath)
{
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new RolemasterDbContext(options);
}
private static IDbContextFactory<RolemasterDbContext> CreateDbContextFactory(string databasePath)
{
var options = new DbContextOptionsBuilder<RolemasterDbContext>()
.UseSqlite($"Data Source={databasePath}")
.Options;
return new TestRolemasterDbContextFactory(options);
}
}

View File

@@ -0,0 +1,22 @@
namespace RolemasterDb.ImportTool.Tests;
internal sealed class SequenceRandom(params int[] values) : Random
{
private readonly Queue<int> 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;
}
}