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;
|
||||
|
||||
Reference in New Issue
Block a user