Add OB and DB to attack lookup
This commit is contained in:
@@ -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";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
12
src/RolemasterDb.App/Features/AttackResolutionCalculator.cs
Normal file
12
src/RolemasterDb.App/Features/AttackResolutionCalculator.cs
Normal 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;
|
||||||
|
}
|
||||||
7
src/RolemasterDb.App/Features/AttackResolutionSummary.cs
Normal file
7
src/RolemasterDb.App/Features/AttackResolutionSummary.cs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
namespace RolemasterDb.App.Features;
|
||||||
|
|
||||||
|
public sealed record AttackResolutionSummary(
|
||||||
|
int RawRoll,
|
||||||
|
int OffensiveBonus,
|
||||||
|
int DefensiveBonus,
|
||||||
|
int EffectiveTotal);
|
||||||
@@ -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()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user