From 960197354a636f23174fad4075a403c08e4ba29e Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Fri, 3 Apr 2026 00:58:07 +0200 Subject: [PATCH] Fix Rolemaster low-end open roll math --- README.md | 2 +- RpgRoller.Tests/Api/RolemasterApiTests.cs | 8 +++--- .../Services/ServiceRolemasterRollTests.cs | 8 +++--- .../Pages/HomeControls/RollDiceStrip.razor.cs | 5 +++- RpgRoller/Services/GameService.cs | 26 ++++++++++++++++--- 5 files changed, 35 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index d33ceed..94669ad 100644 --- a/README.md +++ b/README.md @@ -64,7 +64,7 @@ Gameplay capabilities now include: - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) - Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15` - Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged -- Rolemaster roll execution now supports initiative (`2d10+x`), standard percentile (`d100+x`), and open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange` +- Rolemaster roll execution now supports initiative (`2d10+x`), standard percentile (`d100+x`), and open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange`; low-end trigger rolls are shown for auditability but do not count toward the total - Compact campaign-log summaries stay dense for Rolemaster rolls, while lazy-loaded roll detail includes ordered die metadata for each open-ended follow-up step - Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete diff --git a/RpgRoller.Tests/Api/RolemasterApiTests.cs b/RpgRoller.Tests/Api/RolemasterApiTests.cs index bf0918e..1eee6e9 100644 --- a/RpgRoller.Tests/Api/RolemasterApiTests.cs +++ b/RpgRoller.Tests/Api/RolemasterApiTests.cs @@ -54,9 +54,9 @@ public sealed class RolemasterApiTests : ApiTestBase var logPage = await GetAsync(client, $"/api/campaigns/{campaign.Id}/log/page?limit=5"); var detail = await GetAsync(client, $"/api/rolls/{roll.RollId}"); - Assert.Equal(-119, roll.Result); - Assert.Equal("5-(97+100+12)+85=-119", roll.Breakdown); - Assert.Equal("5 - (97 + 100 + 12) | open-ended low", Assert.Single(logPage.Entries).SummaryText); + Assert.Equal(-124, roll.Result); + Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown); + Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText); Assert.Equal(roll.Breakdown, detail.Breakdown); Assert.Collection( detail.Dice, @@ -64,7 +64,7 @@ public sealed class RolemasterApiTests : ApiTestBase { Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); Assert.Equal(1, die.Sequence); - Assert.Equal(5, die.SignedContribution); + Assert.Null(die.SignedContribution); }, die => { diff --git a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs index 8256f91..2911dcb 100644 --- a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs @@ -127,9 +127,9 @@ public sealed class ServiceRolemasterRollTests var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)); - Assert.Equal(-119, roll.Result); - Assert.Equal("5-(97+100+12)+85=-119", roll.Breakdown); - Assert.Equal("5 - (97 + 100 + 12) | open-ended low", Assert.Single(logPage.Entries).SummaryText); + Assert.Equal(-124, roll.Result); + Assert.Equal("(05) -97 -100 -12 +85 = -124", roll.Breakdown); + Assert.Equal("(05) -97 -100 -12 | open-ended low", Assert.Single(logPage.Entries).SummaryText); Assert.Collection( roll.Dice, die => @@ -137,7 +137,7 @@ public sealed class ServiceRolemasterRollTests Assert.Equal(5, die.Roll); Assert.Equal(1, die.Sequence); Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); - Assert.Equal(5, die.SignedContribution); + Assert.Null(die.SignedContribution); }, die => { diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs index 5cc40ac..0f12a21 100644 --- a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs @@ -23,6 +23,9 @@ public partial class RollDiceStrip private static string RollDieDisplay(RollDieResult die) { + if (string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue) + return $"({die.Roll:00})"; + return die.Kind switch { RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll}", @@ -101,7 +104,7 @@ public partial class RollDiceStrip labels.Add("Rolemaster percentile"); break; case RollDieKinds.RolemasterOpenEndedInitial: - labels.Add("Rolemaster open-ended initial"); + labels.Add(die.SignedContribution.HasValue ? "Rolemaster open-ended initial" : "Rolemaster low-end trigger (ignored in total)"); break; case RollDieKinds.RolemasterOpenEndedHigh: labels.Add($"Rolemaster high follow-up (+{die.Roll})"); diff --git a/RpgRoller/Services/GameService.cs b/RpgRoller/Services/GameService.cs index ee773e5..47a914f 100644 --- a/RpgRoller/Services/GameService.cs +++ b/RpgRoller/Services/GameService.cs @@ -1007,12 +1007,13 @@ public sealed class GameService : IGameService { var initialRoll = m_DiceRoller.Roll(expression.Sides); var followUpRolls = new List(); + int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; var dice = new List { - CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialRoll) + CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) }; - var baseTotal = initialRoll; + var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; var subtractFollowUps = false; if (initialRoll >= 96) { @@ -1134,7 +1135,7 @@ public sealed class GameService : IGameService return followUpRolls; } - private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int signedContribution) + private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) { return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); } @@ -1150,6 +1151,18 @@ public sealed class GameService : IGameService private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList followUpRolls, bool subtractFollowUps, int modifier, int total) { + if (subtractFollowUps) + { + var segments = new List { $"({FormatRolemasterTriggerRoll(initialRoll)})" }; + segments.AddRange(followUpRolls.Select(roll => $"-{roll}")); + if (modifier > 0) + segments.Add($"+{modifier}"); + else if (modifier < 0) + segments.Add(modifier.ToString()); + + return $"{string.Join(" ", segments)} = {total}"; + } + var core = initialRoll.ToString(); if (followUpRolls.Count > 0) { @@ -1160,6 +1173,11 @@ public sealed class GameService : IGameService return BuildModifierBreakdown(core, modifier, total); } + private static string FormatRolemasterTriggerRoll(int roll) + { + return roll.ToString("00"); + } + private static string BuildModifierBreakdown(string core, int modifier, int total) { return modifier switch @@ -1404,7 +1422,7 @@ public sealed class GameService : IGameService .Select(die => die.Roll.ToString()) .ToArray(); if (lowFollowUps.Length > 0) - return $"{openEndedInitial.Roll} - ({string.Join(" + ", lowFollowUps)}) | open-ended low"; + return $"({FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; return $"{openEndedInitial.Roll} | open-ended"; }