Add OB and DB to attack lookup

This commit is contained in:
2026-03-15 02:54:30 +01:00
parent cada74c7ac
commit dea0f97e91
4 changed files with 138 additions and 23 deletions

View File

@@ -32,7 +32,7 @@ else
<div class="field-shell">
<label for="armor-type">Armor type</label>
<select id="armor-type" class="input-shell" @bind="attackInput.ArmorType">
<select id="armor-type" class="input-shell" @bind="attackInput.ArmorType" @bind:after="HandleAttackInputsChanged">
@foreach (var armorType in referenceData.ArmorTypes)
{
<option value="@armorType.Key">@armorType.Label</option>
@@ -43,7 +43,7 @@ else
<div class="field-shell">
<label for="attack-roll">Attack roll</label>
<div class="roll-input-row">
<input id="attack-roll" class="input-shell" type="number" min="1" @bind="attackInput.AttackRoll" />
<input id="attack-roll" class="input-shell" type="number" min="1" @bind="attackInput.AttackRoll" @bind:after="HandleAttackInputsChanged" />
<button type="button" class="roll-button" @onclick="RollAttack">Roll</button>
</div>
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax })
@@ -60,6 +60,16 @@ else
}
</div>
<div class="field-shell">
<label for="attack-ob">OB</label>
<input id="attack-ob" class="input-shell" type="number" @bind="attackInput.OffensiveBonus" @bind:after="HandleAttackInputsChanged" />
</div>
<div class="field-shell">
<label for="attack-db">DB</label>
<input id="attack-db" class="input-shell" type="number" @bind="attackInput.DefensiveBonus" @bind:after="HandleAttackInputsChanged" />
</div>
<div class="field-shell">
<label for="critical-roll">Critical roll</label>
<div class="roll-input-row">
@@ -90,7 +100,23 @@ else
<div class="result-card">
<h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3>
<div class="result-stats">
<span class="stat-pill">Attack roll: @attackResult.Roll</span>
@if (lastAttackResolution is not null)
{
<span class="stat-pill">Rolled: @lastAttackResolution.RawRoll</span>
@if (lastAttackResolution.OffensiveBonus != 0)
{
<span class="stat-pill">OB: +@lastAttackResolution.OffensiveBonus</span>
}
@if (lastAttackResolution.DefensiveBonus != 0)
{
<span class="stat-pill">DB: -@lastAttackResolution.DefensiveBonus</span>
}
<span class="stat-pill">Total used: @lastAttackResolution.EffectiveTotal</span>
}
else
{
<span class="stat-pill">Attack total: @attackResult.Roll</span>
}
<span class="stat-pill">Hits: @attackResult.Hits</span>
@if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{
@@ -206,6 +232,7 @@ else
private string? attackCriticalRollSummary;
private string? directCriticalRollSummary;
private string? attackFumbleMessage;
private AttackResolutionSummary? lastAttackResolution;
private AttackTableReference? SelectedAttackTable =>
referenceData?.AttackTables.FirstOrDefault(table => table.Key == attackInput.AttackTable);
@@ -230,7 +257,8 @@ else
{
attackError = null;
attackResult = null;
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
var attackResolution = BuildAttackResolution();
attackFumbleMessage = BuildAttackFumbleMessage(attackResolution);
if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
@@ -246,7 +274,7 @@ else
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(
attackInput.AttackTable,
attackInput.ArmorType,
attackInput.AttackRoll,
attackResolution.EffectiveTotal,
string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
if (response is null)
@@ -256,6 +284,7 @@ else
}
attackResult = response;
lastAttackResolution = attackResolution;
}
private async Task RunCriticalLookupAsync()
@@ -293,28 +322,23 @@ else
private void HandleAttackTableChanged(ChangeEventArgs args)
{
attackInput.AttackTable = args.Value?.ToString() ?? string.Empty;
attackResult = null;
attackError = null;
attackCriticalRollSummary = null;
RefreshAttackRollState();
HandleAttackInputsChanged();
}
private void RollAttack()
{
var result = RolemasterRoller.RollAttack(Random.Shared);
attackInput.AttackRoll = result.Total;
attackRollSummary = BuildRollSummary(result, "Attack");
attackCriticalRollSummary = null;
attackResult = null;
attackError = null;
RefreshAttackRollState();
attackRollSummary = BuildAttackRollSummary(result, BuildAttackResolution());
HandleAttackInputsChanged(clearRollSummary: false);
}
private async Task RollAttackCriticalAsync()
{
attackError = null;
attackCriticalRollSummary = null;
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
var attackResolution = BuildAttackResolution();
attackFumbleMessage = BuildAttackFumbleMessage(attackResolution);
if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{
@@ -326,7 +350,7 @@ else
var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(
attackInput.AttackTable,
attackInput.ArmorType,
attackInput.AttackRoll,
attackResolution.EffectiveTotal,
null));
if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType))
@@ -350,21 +374,40 @@ else
criticalError = null;
}
private void RefreshAttackRollState()
private void HandleAttackInputsChanged()
{
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll);
HandleAttackInputsChanged(clearRollSummary: true);
}
private string? BuildAttackFumbleMessage(int attackRoll)
private void HandleAttackInputsChanged(bool clearRollSummary)
{
if (SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll } ||
attackRoll < fumbleMinRoll ||
attackRoll > fumbleMaxRoll)
if (clearRollSummary)
{
attackRollSummary = null;
}
attackResult = null;
attackError = null;
attackCriticalRollSummary = null;
lastAttackResolution = null;
attackFumbleMessage = BuildAttackFumbleMessage(BuildAttackResolution());
}
private AttackResolutionSummary BuildAttackResolution() =>
AttackResolutionCalculator.Resolve(
attackInput.AttackRoll,
attackInput.OffensiveBonus,
attackInput.DefensiveBonus);
private string? BuildAttackFumbleMessage(AttackResolutionSummary resolution)
{
if (!AttackResolutionCalculator.IsFumble(resolution, SelectedAttackTable) ||
SelectedAttackTable is not { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll })
{
return null;
}
return $"{SelectedAttackTable.Label} fumble on {FormatRange(fumbleMinRoll, fumbleMaxRoll)}. Do not run a normal attack lookup.";
return $"{SelectedAttackTable.Label} fumble on {FormatRange(fumbleMinRoll, fumbleMaxRoll)}. Raw roll {resolution.RawRoll:00} triggers the fumble before OB or DB is applied.";
}
private static string BuildRollSummary(LookupRollResult result, string label)
@@ -377,6 +420,28 @@ else
return $"{label} roll: {string.Join(" + ", result.Rolls)} = {result.Total}.";
}
private static string BuildAttackRollSummary(LookupRollResult result, AttackResolutionSummary resolution)
{
var summary = BuildRollSummary(result, "Attack");
if (resolution.OffensiveBonus == 0 && resolution.DefensiveBonus == 0)
{
return summary;
}
var modifiers = new List<string>();
if (resolution.OffensiveBonus != 0)
{
modifiers.Add($"+{resolution.OffensiveBonus} OB");
}
if (resolution.DefensiveBonus != 0)
{
modifiers.Add($"-{resolution.DefensiveBonus} DB");
}
return $"{summary} {string.Join(' ', modifiers)} = {resolution.EffectiveTotal}.";
}
private static string BuildCriticalRollSummary(
LookupRollResult result,
CriticalTableReference? criticalTable)
@@ -400,6 +465,8 @@ else
public string AttackTable { get; set; } = string.Empty;
public string ArmorType { get; set; } = string.Empty;
public int AttackRoll { get; set; } = 66;
public int OffensiveBonus { get; set; }
public int DefensiveBonus { get; set; }
public string? CriticalRollText { get; set; } = "72";
}

View File

@@ -0,0 +1,12 @@
namespace RolemasterDb.App.Features;
public static class AttackResolutionCalculator
{
public static AttackResolutionSummary Resolve(int rawRoll, int offensiveBonus, int defensiveBonus) =>
new(rawRoll, offensiveBonus, defensiveBonus, rawRoll + offensiveBonus - defensiveBonus);
public static bool IsFumble(AttackResolutionSummary resolution, AttackTableReference? table) =>
table is { FumbleMinRoll: int fumbleMinRoll, FumbleMaxRoll: int fumbleMaxRoll } &&
resolution.RawRoll >= fumbleMinRoll &&
resolution.RawRoll <= fumbleMaxRoll;
}

View File

@@ -0,0 +1,7 @@
namespace RolemasterDb.App.Features;
public sealed record AttackResolutionSummary(
int RawRoll,
int OffensiveBonus,
int DefensiveBonus,
int EffectiveTotal);