@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()
{