Add rolemaster automatic retry rolls

This commit is contained in:
2026-04-14 23:03:38 +02:00
parent 0c638e8ebe
commit 2997247eeb
16 changed files with 287 additions and 40 deletions

View File

@@ -81,8 +81,9 @@ Rolemaster support:
- Open-ended percentile expressions such as `d100!+85` - Open-ended percentile expressions such as `d100!+85`
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults - 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 - 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 - 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 ## Local Development

View File

@@ -81,4 +81,64 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
Assert.Equal(-12, die.SignedContribution); 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<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster Retry", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var retryFiveSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +5", "d100!+10", 0, false, null, 5, true));
var retryTenSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness +10", "d100!+1", 0, false, null, 5, true));
var disabledSkill = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Awareness Off", "d100!+10", 0, false, null, 5));
var retryFiveRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryFiveSkill.Id}/roll", new("public"));
var retryTenRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{retryTenSkill.Id}/roll", new("public"));
var disabledRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{disabledSkill.Id}/roll", new("public"));
var logPage = await GetAsync<CampaignLogPage>(client, $"/api/campaigns/{campaign.Id}/log/page?limit=10");
var detail = await GetAsync<CampaignRollDetail>(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<string[]>(logPage.Entries[0].EventBadges));
Assert.Equal("90 | open-ended | retry +10", logPage.Entries[1].SummaryText);
Assert.Equal(["rs10"], Assert.IsType<string[]>(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);
});
}
} }

View File

@@ -130,9 +130,9 @@ public sealed class PayloadBudgetTests
} }
[Fact] [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; var service = harness.Service;
service.Register("gm-rm-detail-budget", "Password123", "GM"); 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 campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Payload Detail", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Detail Hero", campaign.Id)); 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 roll = ServiceTestSupport.GetValue(service.RollSkill(ownerSession, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5)); var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5));
var detail = ServiceTestSupport.GetValue(service.GetRollDetail(gmSession, roll.RollId)); 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"); AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
@@ -160,8 +161,9 @@ public sealed class PayloadBudgetTests
Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal); Assert.DoesNotContain("\"sequence\"", logPageJson, StringComparison.Ordinal);
Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal); Assert.DoesNotContain("\"breakdown\"", logPageJson, StringComparison.Ordinal);
Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal); Assert.Contains("\"kind\":\"rolemaster-open-ended-initial\"", detailJson, StringComparison.Ordinal);
Assert.Contains("\"signedContribution\":96", detailJson, StringComparison.Ordinal); Assert.Contains("\"signedContribution\":68", detailJson, StringComparison.Ordinal);
Assert.Contains("\"sequence\":6", detailJson, StringComparison.Ordinal); Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
} }
private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label) private static void AssertPayloadWithinBudget<T>(T payload, int maxBytes, string label)

View File

@@ -68,4 +68,18 @@ public sealed class ServiceHelperExtractionTests
Assert.False(invalidRetry.Succeeded); Assert.False(invalidRetry.Succeeded);
Assert.Equal("invalid_rolemaster_retry", invalidRetry.Error!.Code); 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"));
}
} }

View File

@@ -174,4 +174,86 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal("r66", badge); Assert.Equal("r66", badge);
Assert.Equal("66 | rolemaster", logEntry.SummaryText); 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<string[]>(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<string[]>(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));
}
} }

View File

@@ -20,6 +20,7 @@ public sealed class ServiceRollHelperTests
Assert.Equal("0=0", RollBreakdownFormatter.BuildBreakdown([], 0, 0)); 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("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("(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)); 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 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 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.Equal("1d20+5", CampaignLogSummaryBuilder.ExtractCustomRollExpression("1d20+5 => 20+5=25", " => "));
Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => ")); Assert.Null(CampaignLogSummaryBuilder.ExtractCustomRollExpression("20+5=25", " => "));
Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice)); Assert.Equal("6, 1", CampaignLogSummaryBuilder.BuildCompactLogSummary(d6Dice));
Assert.Equal("(05) -97 -100 | open-ended low", CampaignLogSummaryBuilder.BuildCompactLogSummary(rolemasterDice)); 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("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice))); Assert.Equal(["w6", "w1"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.D6, null, d6Dice)));
Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)]))); Assert.Equal(["n20"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Dnd5e, "1d20+5", [new(20, false, false, false, false, false)])));
Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice))); Assert.Equal(["rf", "r100"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+85", rolemasterDice)));
Assert.Equal(["rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
} }
[Fact] [Fact]

View File

@@ -112,6 +112,8 @@ public partial class CampaignLogPanel
"rf" => new("Fumble", "danger"), "rf" => new("Fumble", "danger"),
"r100" => new("100", "rare"), "r100" => new("100", "rare"),
"r66" => new("66", "rare"), "r66" => new("66", "rare"),
"rs5" => new("Retry +5", "rare"),
"rs10" => new("Retry +10", "rare"),
_ => null _ => null
}; };
} }

View File

@@ -90,6 +90,9 @@ public partial class RollDiceStrip
if (die.Sequence.HasValue) if (die.Sequence.HasValue)
labels.Add($"step {die.Sequence.Value}"); 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) if (die.Wild)
labels.Add("wild"); labels.Add("wild");

View File

@@ -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; Roll = roll;
Crit = crit; Crit = crit;
@@ -77,6 +77,7 @@ public sealed record RollDieResult
Sequence = sequence; Sequence = sequence;
Kind = kind; Kind = kind;
SignedContribution = signedContribution; SignedContribution = signedContribution;
Attempt = attempt;
} }
public int Roll { get; init; } public int Roll { get; init; }
@@ -94,6 +95,9 @@ public sealed record RollDieResult
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public int? SignedContribution { get; init; } 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<RollDieResult> Dice, DateTimeOffset TimestampUtc); public sealed record RollResult(Guid RollId, Guid CampaignId, Guid CharacterId, Guid SkillId, Guid RollerUserId, string Visibility, int Result, string Breakdown, IReadOnlyList<RollDieResult> Dice, DateTimeOffset TimestampUtc);

View File

@@ -5,18 +5,18 @@ namespace RpgRoller.Services;
public static class CampaignLogSummaryBuilder public static class CampaignLogSummaryBuilder
{ {
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice) public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice, string? breakdown = null)
{ {
if (dice.Count == 0) if (dice.Count == 0)
return "No detail available."; return "No detail available.";
if (dice.Any(die => IsRolemasterDieKind(die.Kind))) if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
return BuildRolemasterCompactLogSummary(dice); return BuildRolemasterCompactLogSummary(dice, breakdown);
return string.Join(", ", dice.Select(die => die.Roll.ToString())); return string.Join(", ", dice.Select(die => die.Roll.ToString()));
} }
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice) public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice, string? breakdown = null)
{ {
var badges = new List<string>(); var badges = new List<string>();
@@ -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 => 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 == 100), "r100");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66"); AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 66), "r66");
AddRetryBadgeIfPresent(badges, breakdown);
break; break;
} }
@@ -53,29 +54,31 @@ public static class CampaignLogSummaryBuilder
return breakdown[..separatorIndex]; return breakdown[..separatorIndex];
} }
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice) private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> 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) 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) 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) 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())); var preview = string.Join(" + ", summaryDice.Select(die => die.Roll.ToString()));
return $"{preview} | rolemaster"; 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) private static bool IsRolemasterDieKind(string? kind)
@@ -91,6 +94,20 @@ public static class CampaignLogSummaryBuilder
badges.Add(code); badges.Add(code);
} }
private static void AddRetryBadgeIfPresent(List<string> 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) private static bool IsSingleD20Expression(string expression)
{ {
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression); var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);

View File

@@ -32,7 +32,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
if (!parsedVisibility.Succeeded) if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message); return ServiceResult<RollResult>.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); 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 characterName = stateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry); var skillName = ResolveLoggedSkillName(entry);
var loggedExpression = ResolveLoggedExpression(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<RollDieResult> dice) private static string SerializeDice(IReadOnlyList<RollDieResult> dice)

View File

@@ -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):";
}

View File

@@ -5,16 +5,16 @@ namespace RpgRoller.Services;
public sealed class RolemasterRollEngine(IDiceRoller diceRoller) public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{ {
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry)
{ {
return expression.Kind switch return expression.Kind switch
{ {
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()), DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry),
_ => RollStandard(expression) _ => RollStandard(expression)
}; };
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int? attempt = null)
{ {
var diceValues = new int[expression.DiceCount]; var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount]; var dice = new RollDieResult[expression.DiceCount];
@@ -23,31 +23,46 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{ {
var value = diceRoller.Roll(expression.Sides); var value = diceRoller.Roll(expression.Sides);
diceValues[i] = value; diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value); dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value, attempt);
total += value; total += value;
} }
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice); return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
} }
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange) private (int Total, string Breakdown, IReadOnlyList<RollDieResult> 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<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int? attempt = null)
{ {
var initialRoll = diceRoller.Roll(expression.Sides); var initialRoll = diceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>(); var followUpRolls = new List<int>();
int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll; int? initialContribution = initialRoll <= fumbleRange ? null : initialRoll;
var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution) }; var dice = new List<RollDieResult> { CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialContribution, attempt) };
var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll; var baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false; var subtractFollowUps = false;
if (initialRoll >= 96) if (initialRoll >= 96)
{ {
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false)); followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, false, attempt));
baseTotal += followUpRolls.Sum(); baseTotal += followUpRolls.Sum();
} }
else if (initialRoll <= fumbleRange) else if (initialRoll <= fumbleRange)
{ {
subtractFollowUps = true; subtractFollowUps = true;
followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true)); followUpRolls.AddRange(RollHighOpenEndedChain(dice, 2, true, attempt));
baseTotal -= followUpRolls.Sum(); baseTotal -= followUpRolls.Sum();
} }
@@ -56,7 +71,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
return (total, breakdown, dice); return (total, breakdown, dice);
} }
private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract) private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract, int? attempt)
{ {
var followUpRolls = new List<int>(); var followUpRolls = new List<int>();
var sequence = sequenceStart; var sequence = sequenceStart;
@@ -65,7 +80,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{ {
var roll = diceRoller.Roll(100); var roll = diceRoller.Roll(100);
followUpRolls.Add(roll); 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; sequence += 1;
if (roll < 96) if (roll < 96)
@@ -75,8 +90,13 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
return followUpRolls; return followUpRolls;
} }
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution) private static IReadOnlyList<RollDieResult> AddAttemptMarker(IReadOnlyList<RollDieResult> 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);
} }
} }

View File

@@ -35,6 +35,11 @@ public static class RollBreakdownFormatter
return BuildModifierBreakdown(core, modifier, total); 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) public static string FormatRolemasterTriggerRoll(int roll)
{ {
return roll.ToString("00"); return roll.ToString("00");

View File

@@ -5,13 +5,13 @@ namespace RpgRoller.Services;
public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine) public sealed class RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
{ {
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange) public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false)
{ {
if (ruleset == RulesetKind.D6) if (ruleset == RulesetKind.D6)
return d6RollEngine.Roll(expression, wildDice, allowFumble); return d6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster) if (ruleset == RulesetKind.Rolemaster)
return rolemasterRollEngine.Roll(expression, fumbleRange); return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry);
return standardRollEngine.Roll(expression); return standardRollEngine.Roll(expression);
} }

View File

@@ -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) 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-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. - [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. - [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. - [ ] 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. - [ ] 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 ## 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 ## Context and Orientation