diff --git a/README.md b/README.md index f03889c..e816360 100644 --- a/README.md +++ b/README.md @@ -81,8 +81,9 @@ Rolemaster support: - Open-ended percentile expressions such as `d100!+85` - Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults - Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it +- Automatic retry windows for eligible open-ended skills: results `77-90` retry once with `+5`, and results `91-110` retry once with `+10` - Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail -- Compact log badges and summaries for open-ended/fumble-related events +- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10` ## Local Development diff --git a/RpgRoller.Tests/Api/RolemasterApiTests.cs b/RpgRoller.Tests/Api/RolemasterApiTests.cs index af7021a..f1f209a 100644 --- a/RpgRoller.Tests/Api/RolemasterApiTests.cs +++ b/RpgRoller.Tests/Api/RolemasterApiTests.cs @@ -81,4 +81,64 @@ public sealed class RolemasterApiTests(WebApplicationFactory factory) : Assert.Equal(-12, die.SignedContribution); }); } + + [Fact] + public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail() + { + using var factory = CreateFactory(68, 42, 90, 32, 68); + using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api"); + await LoginAsync(client, "rolemaster-retry-api", "Password123"); + + var campaign = await PostAsync(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster")); + var character = await PostAsync(client, "/api/characters", new("Hero", campaign.Id)); + var retryFiveSkill = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true)); + var retryTenSkill = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true)); + var disabledSkill = await PostAsync(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5)); + + var retryFiveRoll = await PostAsync(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public")); + var retryTenRoll = await PostAsync(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public")); + var disabledRoll = await PostAsync(client, $"/api/skills/{disabledSkill.Id}/roll", new("public")); + var logPage = await GetAsync(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10"); + var detail = await GetAsync(client, $"/api/rolls/{retryFiveRoll.RollId}"); + + Assert.Equal(57, retryFiveRoll.Result); + Assert.Equal("68+10=78; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown); + Assert.Collection(retryFiveRoll.Dice, die => + { + Assert.Equal(1, die.Attempt); + Assert.Equal(1, die.Sequence); + }, die => + { + Assert.Equal(2, die.Attempt); + Assert.Equal(1, die.Sequence); + }); + + Assert.Equal(43, retryTenRoll.Result); + Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", retryTenRoll.Breakdown); + + Assert.Equal(78, disabledRoll.Result); + Assert.Equal("68+10=78", disabledRoll.Breakdown); + Assert.All(disabledRoll.Dice, die => Assert.Null(die.Attempt)); + + Assert.Equal(3, logPage.Entries.Length); + Assert.Equal("68 | open-ended | retry +5", logPage.Entries[0].SummaryText); + Assert.Equal(["rs5"], Assert.IsType(logPage.Entries[0].EventBadges)); + Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText); + Assert.Equal(["rs10"], Assert.IsType(logPage.Entries[1].EventBadges)); + Assert.Equal("68 | open-ended", logPage.Entries[2].SummaryText); + Assert.Null(logPage.Entries[2].EventBadges); + + Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown); + Assert.Collection(detail.Dice, die => + { + Assert.Equal(1, die.Attempt); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + }, die => + { + Assert.Equal(2, die.Attempt); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + }); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/PayloadBudgetTests.cs b/RpgRoller.Tests/PayloadBudgetTests.cs index a0af4ea..9a6eab2 100644 --- a/RpgRoller.Tests/PayloadBudgetTests.cs +++ b/RpgRoller.Tests/PayloadBudgetTests.cs @@ -130,9 +130,9 @@ public sealed class PayloadBudgetTests } [Fact] - public void RolemasterRollDetailPayload_StaysWithinBudget_AndRolemasterMetadataRemainsLazy() + public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy() { - using var harness = ServiceTestSupport.CreateHarness(96, 100, 100, 100, 100, 97, 12); + using var harness = ServiceTestSupport.CreateHarness(68, 42); var service = harness.Service; service.Register("gm-rm-detail-budget", "Password123", "GM"); @@ -143,11 +143,12 @@ public sealed class PayloadBudgetTests var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster")); var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id)); - var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+85", 0, false, null, 5)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, character.Id, "Sense", "d100!+10", 0, false, null, 5, true)); var roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public")); var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5)); var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId)); + Assert.Equal("68 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText); AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail"); @@ -160,8 +161,9 @@ public sealed class PayloadBudgetTests Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal); Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal); Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal); - Assert.Contains("\"signedContribution\":96", detailJson, StringComparison.Ordinal); - Assert.Contains("\"sequence\":6", detailJson, StringComparison.Ordinal); + Assert.Contains("\"signedContribution\":68", detailJson, StringComparison.Ordinal); + Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal); + Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal); } private static void AssertPayloadWithinBudget(T payload, int maxBytes, string label) diff --git a/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs index af43714..926711d 100644 --- a/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs +++ b/RpgRoller.Tests/Services/ServiceHelperExtractionTests.cs @@ -68,4 +68,18 @@ public sealed class ServiceHelperExtractionTests Assert.False(invalidRetry.Succeeded); Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code); } + + [Fact] + public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers() + { + Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(77)); + Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(90)); + Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(91)); + Assert.Equal(10, RolemasterRetryPolicy.ResolveAutoRetryBonus(110)); + Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(76)); + Assert.Null(RolemasterRetryPolicy.ResolveAutoRetryBonus(111)); + Assert.Equal(5, RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78; retry(+5): 42+10=52; final=57")); + Assert.Equal(10, RolemasterRetryPolicy.TryExtractRetryBonus("90+1=91; retry(+10): 32+1=33; final=43")); + Assert.Null(RolemasterRetryPolicy.TryExtractRetryBonus("68+10=78")); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs index fca01ad..549d497 100644 --- a/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceRolemasterRollTests.cs @@ -174,4 +174,86 @@ public sealed class ServiceRolemasterRollTests Assert.Equal("r66", badge); Assert.Equal("66 | rolemaster", logEntry.SummaryText); } + + [Fact] + public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts() + { + using var harness = ServiceTestSupport.CreateHarness(68, 42); + var service = harness.Service; + + service.Register("gm-retry-five", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-retry-five", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5, true)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var detail = ServiceTestSupport.GetValue(service.GetRollDetail(session, roll.RollId)); + var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries); + + Assert.Equal(57, roll.Result); + Assert.Equal("68+10=78; retry(+5): 42+10=52; final=57", roll.Breakdown); + Assert.Equal("68 | open-ended | retry +5", logEntry.SummaryText); + Assert.Equal(["rs5"], Assert.IsType(logEntry.EventBadges)); + Assert.Equal(roll.Breakdown, detail.Breakdown); + Assert.Collection(detail.Dice, die => + { + Assert.Equal(68, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(1, die.Attempt); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + Assert.Equal(68, die.SignedContribution); + }, die => + { + Assert.Equal(42, die.Roll); + Assert.Equal(1, die.Sequence); + Assert.Equal(2, die.Attempt); + Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind); + Assert.Equal(42, die.SignedContribution); + }); + } + + [Fact] + public void RollSkill_RolemasterAutoRetryPlusTen_UsesRetryResultAndMarksAttempts() + { + using var harness = ServiceTestSupport.CreateHarness(90, 32); + var service = harness.Service; + + service.Register("gm-retry-ten", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-retry-ten", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+1", 0, false, null, 5, true)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries); + + Assert.Equal(43, roll.Result); + Assert.Equal("90+1=91; retry(+10): 32+1=33; final=43", roll.Breakdown); + Assert.Equal("90 | open-ended | retry +10", logEntry.SummaryText); + Assert.Equal(["rs10"], Assert.IsType(logEntry.EventBadges)); + Assert.All(roll.Dice, die => Assert.True(die.Attempt is 1 or 2)); + } + + [Fact] + public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult() + { + using var harness = ServiceTestSupport.CreateHarness(68); + var service = harness.Service; + + service.Register("gm-retry-off", "Password123", "GM"); + var session = ServiceTestSupport.GetValue(service.Login("gm-retry-off", "Password123")).SessionToken; + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster Retry", "rolemaster")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id)); + var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Awareness", "d100!+10", 0, false, null, 5)); + + var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public")); + var logEntry = Assert.Single(ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5)).Entries); + + Assert.Equal(78, roll.Result); + Assert.Equal("68+10=78", roll.Breakdown); + Assert.Equal("68 | open-ended", logEntry.SummaryText); + Assert.Null(logEntry.EventBadges); + Assert.All(roll.Dice, die => Assert.Null(die.Attempt)); + } } \ No newline at end of file diff --git a/RpgRoller.Tests/Services/ServiceRollHelperTests.cs b/RpgRoller.Tests/Services/ServiceRollHelperTests.cs index 77cb926..ab0727d 100644 --- a/RpgRoller.Tests/Services/ServiceRollHelperTests.cs +++ b/RpgRoller.Tests/Services/ServiceRollHelperTests.cs @@ -20,6 +20,7 @@ public sealed class ServiceRollHelperTests Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0)); Assert.Equal("97+96+45+85=323", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(97, [96, 45], false, 85, 323)); Assert.Equal("(05) -97 -100 -12 +85 = -124", RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(5, [97, 100, 12], true, 85, -124)); + Assert.Equal("68+10=78; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("68+10=78", 5, "42+10=52", 57)); Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5)); } @@ -28,15 +29,19 @@ public sealed class ServiceRollHelperTests { var d6Dice = new[] { new RollDieResult(6, true, false, true, false, false), new RollDieResult(1, false, true, true, false, false) }; var rolemasterDice = new[] { new RollDieResult(5, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial), new RollDieResult(97, false, false, false, false, false, 2, RollDieKinds.RolemasterOpenEndedLowSubtract, -97), new RollDieResult(100, false, false, false, false, false, 3, RollDieKinds.RolemasterOpenEndedLowSubtract, -100) }; + var retryDice = new[] { new RollDieResult(68, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 68, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) }; + const string retryBreakdown = "68+10=78; retry(+5): 42+10=52; final=57"; Assert.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => ")); Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => ")); Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice)); Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice)); + Assert.Equal("68 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown)); Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([])); Assert.Equal(["w6", "w1"], Assert.IsType(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice))); Assert.Equal(["n20"], Assert.IsType(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)]))); Assert.Equal(["rf", "r100"], Assert.IsType(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice))); + Assert.Equal(["rs5"], Assert.IsType(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown))); } [Fact] diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 03b48ca..b7e9a6f 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -112,6 +112,8 @@ public partial class CampaignLogPanel "rf" => new("Fumble", "danger"), "r100" => new("100", "rare"), "r66" => new("66", "rare"), + "rs5" => new("Retry +5", "rare"), + "rs10" => new("Retry +10", "rare"), _ => null }; } diff --git a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs index 9f21cec..7980f6d 100644 --- a/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/RollDiceStrip.razor.cs @@ -90,6 +90,9 @@ public partial class RollDiceStrip if (die.Sequence.HasValue) labels.Add($"step {die.Sequence.Value}"); + if (die.Attempt.HasValue) + labels.Add(die.Attempt.Value == 1 ? "attempt 1" : $"retry attempt {die.Attempt.Value}"); + if (die.Wild) labels.Add("wild"); diff --git a/RpgRoller/Contracts/ApiContracts.cs b/RpgRoller/Contracts/ApiContracts.cs index 3c761b4..cec53be 100644 --- a/RpgRoller/Contracts/ApiContracts.cs +++ b/RpgRoller/Contracts/ApiContracts.cs @@ -66,7 +66,7 @@ public sealed record RollDieResult { } - public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null) + public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null, int? attempt = null) { Roll = roll; Crit = crit; @@ -77,6 +77,7 @@ public sealed record RollDieResult Sequence = sequence; Kind = kind; SignedContribution = signedContribution; + Attempt = attempt; } public int Roll { get; init; } @@ -94,6 +95,9 @@ public sealed record RollDieResult [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public int? SignedContribution { get; init; } + + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public int? Attempt { get; init; } } public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList Dice, DateTimeOffset TimestampUtc); diff --git a/RpgRoller/Services/CampaignLogSummaryBuilder.cs b/RpgRoller/Services/CampaignLogSummaryBuilder.cs index 84aeab4..773091d 100644 --- a/RpgRoller/Services/CampaignLogSummaryBuilder.cs +++ b/RpgRoller/Services/CampaignLogSummaryBuilder.cs @@ -5,18 +5,18 @@ namespace RpgRoller.Services; public static class CampaignLogSummaryBuilder { - public static string BuildCompactLogSummary(IReadOnlyList dice) + public static string BuildCompactLogSummary(IReadOnlyList dice, string? breakdown = null) { if (dice.Count == 0) return "No detail available."; if (dice.Any(die => IsRolemasterDieKind(die.Kind))) - return BuildRolemasterCompactLogSummary(dice); + return BuildRolemasterCompactLogSummary(dice, breakdown); return string.Join(", ", dice.Select(die => die.Roll.ToString())); } - public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList dice) + public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList dice, string? breakdown = null) { var badges = new List(); @@ -38,6 +38,7 @@ public static class CampaignLogSummaryBuilder AddBadgeIfMissing(badges, dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal) && !die.SignedContribution.HasValue), "rf"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 100), "r100"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); + AddRetryBadgeIfPresent(badges, breakdown); break; } @@ -53,29 +54,31 @@ public static class CampaignLogSummaryBuilder return breakdown[..separatorIndex]; } - private static string BuildRolemasterCompactLogSummary(IReadOnlyList dice) + private static string BuildRolemasterCompactLogSummary(IReadOnlyList dice, string? breakdown) { - var openEndedInitial = dice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); + var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown); + var summaryDice = retryBonus.HasValue ? dice.Where(die => die.Attempt != 2).ToArray() : dice; + var openEndedInitial = summaryDice.FirstOrDefault(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedInitial, StringComparison.Ordinal)); if (openEndedInitial is not null) { - var highFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); + var highFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedHigh, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); if (highFollowUps.Length > 0) - return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high"; + return AppendRetryNote($"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high", retryBonus); - var lowFollowUps = dice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); + var lowFollowUps = summaryDice.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal)).Select(die => die.Roll.ToString()).ToArray(); if (lowFollowUps.Length > 0) - return $"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low"; + return AppendRetryNote($"({RollBreakdownFormatter.FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low", retryBonus); - return $"{openEndedInitial.Roll} | open-ended"; + return AppendRetryNote($"{openEndedInitial.Roll} | open-ended", retryBonus); } - if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal))) + if (summaryDice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal))) { - var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString())); - return $"{preview} | rolemaster"; + var preview = string.Join(" + ", summaryDice.Select(die => die.Roll.ToString())); + return AppendRetryNote($"{preview} | rolemaster", retryBonus); } - return string.Join(", ", dice.Select(die => die.Roll.ToString())); + return AppendRetryNote(string.Join(", ", summaryDice.Select(die => die.Roll.ToString())), retryBonus); } private static bool IsRolemasterDieKind(string? kind) @@ -91,6 +94,20 @@ public static class CampaignLogSummaryBuilder badges.Add(code); } + private static void AddRetryBadgeIfPresent(List badges, string? breakdown) + { + var retryBonus = RolemasterRetryPolicy.TryExtractRetryBonus(breakdown); + if (!retryBonus.HasValue) + return; + + AddBadgeIfMissing(badges, true, retryBonus.Value == 5 ? "rs5" : "rs10"); + } + + private static string AppendRetryNote(string summary, int? retryBonus) + { + return retryBonus.HasValue ? $"{summary} | retry +{retryBonus.Value}" : summary; + } + private static bool IsSingleD20Expression(string expression) { var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); diff --git a/RpgRoller/Services/GameRollService.cs b/RpgRoller/Services/GameRollService.cs index 617f5ba..255811d 100644 --- a/RpgRoller/Services/GameRollService.cs +++ b/RpgRoller/Services/GameRollService.cs @@ -32,7 +32,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe if (!parsedVisibility.Succeeded) return ServiceResult.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); - var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange); + var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry); return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical); } } @@ -203,9 +203,9 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe var characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character"; var skillName = ResolveLoggedSkillName(entry); var loggedExpression = ResolveLoggedExpression(entry); - var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice); + var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice, entry.Breakdown); - return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice), eventBadges); + return GameDtoMapper.ToCampaignLogListEntry(entry, characterName, skillName, ResolveLogRollerLabel(user, campaign, entry), ResolveLogVisibilityLabel(user, campaign, entry), ResolveLogVisibilityStyle(user, campaign, entry), CampaignLogSummaryBuilder.BuildCompactLogSummary(dice, entry.Breakdown), eventBadges); } private static string SerializeDice(IReadOnlyList dice) diff --git a/RpgRoller/Services/RolemasterRetryPolicy.cs b/RpgRoller/Services/RolemasterRetryPolicy.cs new file mode 100644 index 0000000..169ccbb --- /dev/null +++ b/RpgRoller/Services/RolemasterRetryPolicy.cs @@ -0,0 +1,32 @@ +namespace RpgRoller.Services; + +public static class RolemasterRetryPolicy +{ + public static int? ResolveAutoRetryBonus(int firstResult) + { + if (firstResult is >= 77 and <= 90) + return 5; + + if (firstResult is >= 91 and <= 110) + return 10; + + return null; + } + + public static int? TryExtractRetryBonus(string? breakdown) + { + if (string.IsNullOrWhiteSpace(breakdown)) + return null; + + if (breakdown.Contains(RetryPlusFiveMarker, StringComparison.Ordinal)) + return 5; + + if (breakdown.Contains(RetryPlusTenMarker, StringComparison.Ordinal)) + return 10; + + return null; + } + + public const string RetryPlusFiveMarker = "; retry(+5):"; + public const string RetryPlusTenMarker = "; retry(+10):"; +} \ No newline at end of file diff --git a/RpgRoller/Services/RolemasterRollEngine.cs b/RpgRoller/Services/RolemasterRollEngine.cs index 9bdca4a..0c7416c 100644 --- a/RpgRoller/Services/RolemasterRollEngine.cs +++ b/RpgRoller/Services/RolemasterRollEngine.cs @@ -5,16 +5,16 @@ namespace RpgRoller.Services; public sealed class RolemasterRollEngine(IDiceRoller diceRoller) { - public (int Total, string Breakdown, IReadOnlyList Dice) Roll(DiceExpression expression, int? fumbleRange) + public (int Total, string Breakdown, IReadOnlyList Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry) { return expression.Kind switch { - DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()), + DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry), _ => RollStandard(expression) }; } - private (int Total, string Breakdown, IReadOnlyList Dice) RollStandard(DiceExpression expression) + private (int Total, string Breakdown, IReadOnlyList Dice) RollStandard(DiceExpression expression, int? attempt = null) { var diceValues = new int[expression.DiceCount]; var dice = new RollDieResult[expression.DiceCount]; @@ -23,31 +23,46 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) { var value = diceRoller.Roll(expression.Sides); diceValues[i] = value; - dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); + dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value, attempt); total += value; } return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice); } - private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEnded(DiceExpression expression, int fumbleRange) + private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry) + { + var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange); + var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null; + if (!retryBonus.HasValue) + return firstAttempt; + + var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, 2); + var finalTotal = retryAttempt.Total + retryBonus.Value; + var breakdown = RollBreakdownFormatter.BuildRolemasterRetryBreakdown(firstAttempt.Breakdown, retryBonus.Value, retryAttempt.Breakdown, finalTotal); + var dice = AddAttemptMarker(firstAttempt.Dice, 1).Concat(retryAttempt.Dice).ToArray(); + + return (finalTotal, breakdown, dice); + } + + private (int Total, string Breakdown, IReadOnlyList Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int? attempt = null) { var initialRoll = diceRoller.Roll(expression.Sides); var followUpRolls = new List(); int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; - var dice = new List { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) }; + var dice = new List { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution, attempt) }; var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; var subtractFollowUps = false; if (initialRoll >= 96) { - followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false)); + followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false, attempt)); baseTotal += followUpRolls.Sum(); } else if (initialRoll <= fumbleRange) { subtractFollowUps = true; - followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true)); + followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true, attempt)); baseTotal -= followUpRolls.Sum(); } @@ -56,7 +71,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) return (total, breakdown, dice); } - private IEnumerable RollHighOpenEndedChain(List dice, int sequenceStart, bool subtract) + private IEnumerable RollHighOpenEndedChain(List dice, int sequenceStart, bool subtract, int? attempt) { var followUpRolls = new List(); var sequence = sequenceStart; @@ -65,7 +80,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) { var roll = diceRoller.Roll(100); followUpRolls.Add(roll); - dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll)); + dice.Add(CreateRolemasterDie(roll, sequence, subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh, subtract ? -roll : roll, attempt)); sequence += 1; if (roll < 96) @@ -75,8 +90,13 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller) return followUpRolls; } - private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) + private static IReadOnlyList AddAttemptMarker(IReadOnlyList dice, int attempt) { - return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution); + return dice.Select(die => die with { Attempt = attempt }).ToArray(); + } + + private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution, int? attempt) + { + return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution, attempt); } } \ No newline at end of file diff --git a/RpgRoller/Services/RollBreakdownFormatter.cs b/RpgRoller/Services/RollBreakdownFormatter.cs index cdcb5ff..b8c727e 100644 --- a/RpgRoller/Services/RollBreakdownFormatter.cs +++ b/RpgRoller/Services/RollBreakdownFormatter.cs @@ -35,6 +35,11 @@ public static class RollBreakdownFormatter return BuildModifierBreakdown(core, modifier, total); } + public static string BuildRolemasterRetryBreakdown(string firstAttemptBreakdown, int retryBonus, string retryAttemptBreakdown, int finalTotal) + { + return $"{firstAttemptBreakdown}; retry(+{retryBonus}): {retryAttemptBreakdown}; final={finalTotal}"; + } + public static string FormatRolemasterTriggerRoll(int roll) { return roll.ToString("00"); diff --git a/RpgRoller/Services/RollEngine.cs b/RpgRoller/Services/RollEngine.cs index 420c25b..b6e0518 100644 --- a/RpgRoller/Services/RollEngine.cs +++ b/RpgRoller/Services/RollEngine.cs @@ -5,13 +5,13 @@ namespace RpgRoller.Services; public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine) { - public (int Total, string Breakdown, IReadOnlyList Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) + public (int Total, string Breakdown, IReadOnlyList Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false) { if (ruleset == RulesetKind.D6) return d6RollEngine.Roll(expression, wildDice, allowFumble); if (ruleset == RulesetKind.Rolemaster) - return rolemasterRollEngine.Roll(expression, fumbleRange); + return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry); return standardRollEngine.Roll(expression); } diff --git a/TASKS.md b/TASKS.md index da2188a..87d125b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -15,7 +15,7 @@ For this feature, an eligible retry result means a Rolemaster open-ended percent - [x] (2026-04-04 23:52Z) Reviewed `PLANS.md` and the current Rolemaster roll, skill-form, API, and log-card code paths. - [x] (2026-04-04 23:52Z) Authored this ExecPlan in `TASKS.md`. - [x] (2026-04-14 20:45Z) Added persisted `RolemasterAutoRetry` wiring through the skill model, API contracts, DTOs, in-memory state, clone helpers, EF mapping, and the `20260414204309_AddRolemasterAutoRetry` migration. -- [ ] Implement retry-aware Rolemaster roll execution, readable breakdown formatting, and compact log badge/summary output. +- [x] (2026-04-14 21:20Z) Implemented one-shot Rolemaster automatic retry execution, persisted retry-aware breakdown text, attempt-tagged dice detail, and compact `rs5`/`rs10` log badges plus retry summary text. - [x] (2026-04-14 20:45Z) Updated the Blazor skill create/edit flows so the automatic retry toggle appears only for Rolemaster open-ended skills and is cleared when the expression stops qualifying. - [ ] Add or update unit, API, persistence, payload-budget, and browser tests that prove the feature end to end. - [ ] Update `README.md`, run `pwsh ./scripts/ci-local.ps1`, and commit the finished implementation. @@ -59,7 +59,7 @@ For this feature, an eligible retry result means a Rolemaster open-ended percent ## Outcomes & Retrospective -Milestone 1 is complete. The repo now persists and validates a per-skill `RolemasterAutoRetry` toggle, exposes it in the skill create/edit UI only for Rolemaster open-ended percentile expressions, and round-trips it through service, API, and persistence tests. Roll execution, breakdown formatting, and log surfacing still need to be implemented before the feature is complete end to end. +Milestones 1 and 2 are complete. The repo now persists and validates a per-skill `RolemasterAutoRetry` toggle, executes one automatic retry for eligible Rolemaster open-ended percentile results, records retry-aware breakdown text and attempt-tagged dice detail, and surfaces retry summaries plus `rs5` or `rs10` badges in the compact log. Browser coverage and final end-to-end polish still remain before the feature is complete. ## Context and Orientation