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

@@ -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": [
{

View File

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

View File

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

View File

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

View File

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

View File

@@ -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; } = [];
}

View File

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

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
.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

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

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