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;