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"> <div class="field-shell">
<label for="armor-type">Armor type</label> <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) @foreach (var armorType in referenceData.ArmorTypes)
{ {
<option value="@armorType.Key">@armorType.Label</option> <option value="@armorType.Key">@armorType.Label</option>
@@ -43,7 +43,7 @@ else
<div class="field-shell"> <div class="field-shell">
<label for="attack-roll">Attack roll</label> <label for="attack-roll">Attack roll</label>
<div class="roll-input-row"> <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> <button type="button" class="roll-button" @onclick="RollAttack">Roll</button>
</div> </div>
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax }) @if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax })
@@ -60,6 +60,16 @@ else
} }
</div> </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"> <div class="field-shell">
<label for="critical-roll">Critical roll</label> <label for="critical-roll">Critical roll</label>
<div class="roll-input-row"> <div class="roll-input-row">
@@ -90,7 +100,23 @@ else
<div class="result-card"> <div class="result-card">
<h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3> <h3>@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel</h3>
<div class="result-stats"> <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> <span class="stat-pill">Hits: @attackResult.Hits</span>
@if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity)) @if (!string.IsNullOrWhiteSpace(attackResult.CriticalSeverity))
{ {
@@ -206,6 +232,7 @@ else
private string? attackCriticalRollSummary; private string? attackCriticalRollSummary;
private string? directCriticalRollSummary; private string? directCriticalRollSummary;
private string? attackFumbleMessage; private string? attackFumbleMessage;
private AttackResolutionSummary? lastAttackResolution;
private AttackTableReference? SelectedAttackTable => private AttackTableReference? SelectedAttackTable =>
referenceData?.AttackTables.FirstOrDefault(table => table.Key == attackInput.AttackTable); referenceData?.AttackTables.FirstOrDefault(table => table.Key == attackInput.AttackTable);
@@ -230,7 +257,8 @@ else
{ {
attackError = null; attackError = null;
attackResult = null; attackResult = null;
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll); var attackResolution = BuildAttackResolution();
attackFumbleMessage = BuildAttackFumbleMessage(attackResolution);
if (!string.IsNullOrWhiteSpace(attackFumbleMessage)) if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{ {
@@ -246,7 +274,7 @@ else
var response = await LookupService.LookupAttackAsync(new AttackLookupRequest( var response = await LookupService.LookupAttackAsync(new AttackLookupRequest(
attackInput.AttackTable, attackInput.AttackTable,
attackInput.ArmorType, attackInput.ArmorType,
attackInput.AttackRoll, attackResolution.EffectiveTotal,
string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll)); string.IsNullOrWhiteSpace(attackInput.CriticalRollText) ? null : criticalRoll));
if (response is null) if (response is null)
@@ -256,6 +284,7 @@ else
} }
attackResult = response; attackResult = response;
lastAttackResolution = attackResolution;
} }
private async Task RunCriticalLookupAsync() private async Task RunCriticalLookupAsync()
@@ -293,28 +322,23 @@ else
private void HandleAttackTableChanged(ChangeEventArgs args) private void HandleAttackTableChanged(ChangeEventArgs args)
{ {
attackInput.AttackTable = args.Value?.ToString() ?? string.Empty; attackInput.AttackTable = args.Value?.ToString() ?? string.Empty;
attackResult = null; HandleAttackInputsChanged();
attackError = null;
attackCriticalRollSummary = null;
RefreshAttackRollState();
} }
private void RollAttack() private void RollAttack()
{ {
var result = RolemasterRoller.RollAttack(Random.Shared); var result = RolemasterRoller.RollAttack(Random.Shared);
attackInput.AttackRoll = result.Total; attackInput.AttackRoll = result.Total;
attackRollSummary = BuildRollSummary(result, "Attack"); attackRollSummary = BuildAttackRollSummary(result, BuildAttackResolution());
attackCriticalRollSummary = null; HandleAttackInputsChanged(clearRollSummary: false);
attackResult = null;
attackError = null;
RefreshAttackRollState();
} }
private async Task RollAttackCriticalAsync() private async Task RollAttackCriticalAsync()
{ {
attackError = null; attackError = null;
attackCriticalRollSummary = null; attackCriticalRollSummary = null;
attackFumbleMessage = BuildAttackFumbleMessage(attackInput.AttackRoll); var attackResolution = BuildAttackResolution();
attackFumbleMessage = BuildAttackFumbleMessage(attackResolution);
if (!string.IsNullOrWhiteSpace(attackFumbleMessage)) if (!string.IsNullOrWhiteSpace(attackFumbleMessage))
{ {
@@ -326,7 +350,7 @@ else
var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest( var pendingAttack = await LookupService.LookupAttackAsync(new AttackLookupRequest(
attackInput.AttackTable, attackInput.AttackTable,
attackInput.ArmorType, attackInput.ArmorType,
attackInput.AttackRoll, attackResolution.EffectiveTotal,
null)); null));
if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType)) if (!string.IsNullOrWhiteSpace(pendingAttack?.CriticalType))
@@ -350,21 +374,40 @@ else
criticalError = null; 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 } || if (clearRollSummary)
attackRoll < fumbleMinRoll || {
attackRoll > fumbleMaxRoll) 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 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) private static string BuildRollSummary(LookupRollResult result, string label)
@@ -377,6 +420,28 @@ else
return $"{label} roll: {string.Join(" + ", result.Rolls)} = {result.Total}."; 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( private static string BuildCriticalRollSummary(
LookupRollResult result, LookupRollResult result,
CriticalTableReference? criticalTable) CriticalTableReference? criticalTable)
@@ -400,6 +465,8 @@ else
public string AttackTable { get; set; } = string.Empty; public string AttackTable { get; set; } = string.Empty;
public string ArmorType { get; set; } = string.Empty; public string ArmorType { get; set; } = string.Empty;
public int AttackRoll { get; set; } = 66; public int AttackRoll { get; set; } = 66;
public int OffensiveBonus { get; set; }
public int DefensiveBonus { get; set; }
public string? CriticalRollText { get; set; } = "72"; 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);

View File

@@ -18,6 +18,35 @@ public sealed class LookupRollingTests
Assert.True(result.IsOpenEnded); Assert.True(result.IsOpenEnded);
} }
[Fact]
public void Attack_resolution_applies_ob_and_db_to_raw_roll()
{
var resolution = AttackResolutionCalculator.Resolve(120, 35, 15);
Assert.Equal(120, resolution.RawRoll);
Assert.Equal(35, resolution.OffensiveBonus);
Assert.Equal(15, resolution.DefensiveBonus);
Assert.Equal(140, resolution.EffectiveTotal);
}
[Fact]
public void Fumble_detection_uses_raw_attack_roll()
{
var table = new AttackTableReference("club", "Club", "melee", 1, 4);
var resolution = AttackResolutionCalculator.Resolve(2, 3, 1);
Assert.True(AttackResolutionCalculator.IsFumble(resolution, table));
}
[Fact]
public void Offensive_bonus_does_not_prevent_a_weapon_fumble()
{
var table = new AttackTableReference("club", "Club", "melee", 1, 4);
var resolution = AttackResolutionCalculator.Resolve(2, 5, 0);
Assert.True(AttackResolutionCalculator.IsFumble(resolution, table));
}
[Fact] [Fact]
public void Standard_critical_rolls_are_capped_at_one_hundred() public void Standard_critical_rolls_are_capped_at_one_hundred()
{ {