Add rules-aware lookup dice rolling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -8,7 +8,13 @@
|
||||
<p class="panel-copy"><code>GET /api/reference-data</code></p>
|
||||
<pre class="code-block">{
|
||||
"attackTables": [
|
||||
{ "key": "broadsword", "label": "Broadsword" }
|
||||
{
|
||||
"key": "broadsword",
|
||||
"label": "Broadsword",
|
||||
"attackKind": "melee",
|
||||
"fumbleMinRoll": 1,
|
||||
"fumbleMaxRoll": 2
|
||||
}
|
||||
],
|
||||
"criticalTables": [
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
@page "/"
|
||||
@rendermode InteractiveServer
|
||||
@using System
|
||||
@inject LookupService LookupService
|
||||
|
||||
<PageTitle>Lookup Desk</PageTitle>
|
||||
@@ -21,7 +22,7 @@ else
|
||||
<div class="form-grid">
|
||||
<div class="field-shell">
|
||||
<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)
|
||||
{
|
||||
<option value="@attackTable.Key">@attackTable.Label</option>
|
||||
@@ -41,12 +42,34 @@ else
|
||||
|
||||
<div class="field-shell">
|
||||
<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 class="field-shell">
|
||||
<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>
|
||||
|
||||
@@ -140,7 +163,14 @@ else
|
||||
|
||||
<div class="field-shell">
|
||||
<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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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<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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,13 @@ namespace RolemasterDb.App.Data;
|
||||
|
||||
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)
|
||||
{
|
||||
List<ArmorType> 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<ArmorType> 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."
|
||||
};
|
||||
|
||||
|
||||
@@ -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<AttackRollBand> RollBands { get; set; } = [];
|
||||
public List<AttackResult> Results { get; set; } = [];
|
||||
}
|
||||
|
||||
@@ -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<CriticalRollBandReference> RollBands);
|
||||
|
||||
public sealed record LookupReferenceData(
|
||||
IReadOnlyList<LookupOption> AttackTables,
|
||||
IReadOnlyList<AttackTableReference> AttackTables,
|
||||
IReadOnlyList<LookupOption> ArmorTypes,
|
||||
IReadOnlyList<CriticalTableReference> CriticalTables);
|
||||
|
||||
|
||||
10
src/RolemasterDb.App/Features/LookupRollResult.cs
Normal file
10
src/RolemasterDb.App/Features/LookupRollResult.cs
Normal 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;
|
||||
}
|
||||
@@ -18,7 +18,12 @@ public sealed class LookupService(IDbContextFactory<RolemasterDbContext> 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
|
||||
|
||||
46
src/RolemasterDb.App/Features/RolemasterRoller.cs
Normal file
46
src/RolemasterDb.App/Features/RolemasterRoller.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
150
src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs
Normal file
150
src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs
Normal file
22
src/RolemasterDb.ImportTool.Tests/SequenceRandom.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user