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
|
### 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.
|
||||||
|
|||||||
@@ -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": [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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."
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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; } = [];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|
||||||
|
|||||||
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
|
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
|
||||||
|
|||||||
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;
|
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;
|
||||||
|
|||||||
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