From dea0f97e91ac531b43a85ef2a26ced02f28c94a2 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sun, 15 Mar 2026 02:54:30 +0100 Subject: [PATCH] Add OB and DB to attack lookup --- .../Components/Pages/Home.razor | 113 ++++++++++++++---- .../Features/AttackResolutionCalculator.cs | 12 ++ .../Features/AttackResolutionSummary.cs | 7 ++ .../LookupRollingTests.cs | 29 +++++ 4 files changed, 138 insertions(+), 23 deletions(-) create mode 100644 src/RolemasterDb.App/Features/AttackResolutionCalculator.cs create mode 100644 src/RolemasterDb.App/Features/AttackResolutionSummary.cs diff --git a/src/RolemasterDb.App/Components/Pages/Home.razor b/src/RolemasterDb.App/Components/Pages/Home.razor index 37d3d05..8683914 100644 --- a/src/RolemasterDb.App/Components/Pages/Home.razor +++ b/src/RolemasterDb.App/Components/Pages/Home.razor @@ -32,7 +32,7 @@ else
- @foreach (var armorType in referenceData.ArmorTypes) { @@ -43,7 +43,7 @@ else
- +
@if (SelectedAttackTable is { FumbleMinRoll: int attackFumbleMin, FumbleMaxRoll: int attackFumbleMax }) @@ -60,6 +60,16 @@ else }
+
+ + +
+ +
+ + +
+
@@ -90,7 +100,23 @@ else

@attackResult.AttackTableName vs @attackResult.ArmorTypeLabel

- Attack roll: @attackResult.Roll + @if (lastAttackResolution is not null) + { + Rolled: @lastAttackResolution.RawRoll + @if (lastAttackResolution.OffensiveBonus != 0) + { + OB: +@lastAttackResolution.OffensiveBonus + } + @if (lastAttackResolution.DefensiveBonus != 0) + { + DB: -@lastAttackResolution.DefensiveBonus + } + Total used: @lastAttackResolution.EffectiveTotal + } + else + { + Attack total: @attackResult.Roll + } Hits: @attackResult.Hits @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(); + 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"; } diff --git a/src/RolemasterDb.App/Features/AttackResolutionCalculator.cs b/src/RolemasterDb.App/Features/AttackResolutionCalculator.cs new file mode 100644 index 0000000..d861e86 --- /dev/null +++ b/src/RolemasterDb.App/Features/AttackResolutionCalculator.cs @@ -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; +} diff --git a/src/RolemasterDb.App/Features/AttackResolutionSummary.cs b/src/RolemasterDb.App/Features/AttackResolutionSummary.cs new file mode 100644 index 0000000..e1d10f3 --- /dev/null +++ b/src/RolemasterDb.App/Features/AttackResolutionSummary.cs @@ -0,0 +1,7 @@ +namespace RolemasterDb.App.Features; + +public sealed record AttackResolutionSummary( + int RawRoll, + int OffensiveBonus, + int DefensiveBonus, + int EffectiveTotal); diff --git a/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs index 4fe9f9d..df9cc57 100644 --- a/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs +++ b/src/RolemasterDb.ImportTool.Tests/LookupRollingTests.cs @@ -18,6 +18,35 @@ public sealed class LookupRollingTests 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] public void Standard_critical_rolls_are_capped_at_one_hundred() {