Add rules-aware lookup dice rolling
This commit is contained in:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user