Adjust rolemaster retry lower bound

This commit is contained in:
2026-04-14 23:10:50 +02:00
parent 2997247eeb
commit d4e72fe5bb
8 changed files with 31 additions and 31 deletions

View File

@@ -81,7 +81,7 @@ 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`
- Automatic retry windows for eligible open-ended skills: results `76-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, retry, and fumble-related events, including `Retry +5` and `Retry +10`

View File

@@ -85,7 +85,7 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
[Fact]
public async Task RolemasterAutoRetryRolls_AppearInLogPageAndDetail()
{
using var factory = CreateFactory(68, 42, 90, 32, 68);
using var factory = CreateFactory(66, 42, 90, 32, 65);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
await RegisterAsync(client, "rolemaster-retry-api", "Password123", "Rolemaster Retry Api");
@@ -104,7 +104,7 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
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.Equal("66+10=76; retry(+5): 42+10=52; final=57", retryFiveRoll.Breakdown);
Assert.Collection(retryFiveRoll.Dice, die =>
{
Assert.Equal(1, die.Attempt);
@@ -118,16 +118,16 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> factory) :
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.Equal(75, disabledRoll.Result);
Assert.Equal("65+10=75", 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("66 | open-ended | retry +5", logPage.Entries[0].SummaryText);
Assert.Equal(["r66", "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.Equal("65 | open-ended", logPage.Entries[2].SummaryText);
Assert.Null(logPage.Entries[2].EventBadges);
Assert.Equal(retryFiveRoll.Breakdown, detail.Breakdown);

View File

@@ -132,7 +132,7 @@ public sealed class PayloadBudgetTests
[Fact]
public void RolemasterRollDetailPayload_StaysWithinBudget_AndRetryMetadataRemainsLazy()
{
using var harness = ServiceTestSupport.CreateHarness(68, 42);
using var harness = ServiceTestSupport.CreateHarness(66, 42);
var service = harness.Service;
service.Register("gm-rm-detail-budget", "Password123", "GM");
@@ -148,7 +148,7 @@ public sealed class PayloadBudgetTests
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);
Assert.Equal("66 | open-ended | retry +5", Assert.Single(logPage.Entries).SummaryText);
AssertPayloadWithinBudget(detail, 4 * 1024, "rolemaster roll detail");
@@ -161,7 +161,7 @@ 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\":68", detailJson, StringComparison.Ordinal);
Assert.Contains("\"signedContribution\":66", detailJson, StringComparison.Ordinal);
Assert.Contains("\"attempt\":1", detailJson, StringComparison.Ordinal);
Assert.Contains("\"attempt\":2", detailJson, StringComparison.Ordinal);
}

View File

@@ -72,11 +72,11 @@ public sealed class ServiceHelperExtractionTests
[Fact]
public void RolemasterRetryPolicy_ResolvesRetryBandsAndMarkers()
{
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(77));
Assert.Equal(5, RolemasterRetryPolicy.ResolveAutoRetryBonus(76));
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(75));
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"));

View File

@@ -178,7 +178,7 @@ public sealed class ServiceRolemasterRollTests
[Fact]
public void RollSkill_RolemasterAutoRetryPlusFive_UsesRetryResultAndMarksAttempts()
{
using var harness = ServiceTestSupport.CreateHarness(68, 42);
using var harness = ServiceTestSupport.CreateHarness(66, 42);
var service = harness.Service;
service.Register("gm-retry-five", "Password123", "GM");
@@ -192,17 +192,17 @@ public sealed class ServiceRolemasterRollTests
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("66+10=76; retry(+5): 42+10=52; final=57", roll.Breakdown);
Assert.Equal("66 | open-ended | retry +5", logEntry.SummaryText);
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(logEntry.EventBadges));
Assert.Equal(roll.Breakdown, detail.Breakdown);
Assert.Collection(detail.Dice, die =>
{
Assert.Equal(68, die.Roll);
Assert.Equal(66, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(1, die.Attempt);
Assert.Equal(RollDieKinds.RolemasterOpenEndedInitial, die.Kind);
Assert.Equal(68, die.SignedContribution);
Assert.Equal(66, die.SignedContribution);
}, die =>
{
Assert.Equal(42, die.Roll);
@@ -238,7 +238,7 @@ public sealed class ServiceRolemasterRollTests
[Fact]
public void RollSkill_RolemasterAutoRetryDisabled_KeepsOriginalResult()
{
using var harness = ServiceTestSupport.CreateHarness(68);
using var harness = ServiceTestSupport.CreateHarness(65);
var service = harness.Service;
service.Register("gm-retry-off", "Password123", "GM");
@@ -250,9 +250,9 @@ public sealed class ServiceRolemasterRollTests
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.Equal(75, roll.Result);
Assert.Equal("65+10=75", roll.Breakdown);
Assert.Equal("65 | open-ended", logEntry.SummaryText);
Assert.Null(logEntry.EventBadges);
Assert.All(roll.Dice, die => Assert.Null(die.Attempt));
}

View File

@@ -20,7 +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("66+10=76; retry(+5): 42+10=52; final=57", RollBreakdownFormatter.BuildRolemasterRetryBreakdown("66+10=76", 5, "42+10=52", 57));
Assert.Equal("05", RollBreakdownFormatter.FormatRolemasterTriggerRoll(5));
}
@@ -29,19 +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";
var retryDice = new[] { new RollDieResult(66, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 66, 1), new RollDieResult(42, false, false, false, false, false, 1, RollDieKinds.RolemasterOpenEndedInitial, 42, 2) };
const string retryBreakdown = "66+10=76; 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("66 | open-ended | retry +5", CampaignLogSummaryBuilder.BuildCompactLogSummary(retryDice, retryBreakdown));
Assert.Equal("No detail available.", CampaignLogSummaryBuilder.BuildCompactLogSummary([]));
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(["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)));
Assert.Equal(["r66", "rs5"], Assert.IsType<string[]>(CampaignLogSummaryBuilder.BuildCompactLogEventBadges(RulesetKind.Rolemaster, "d100!+10", retryDice, retryBreakdown)));
}
[Fact]

View File

@@ -4,7 +4,7 @@ public static class RolemasterRetryPolicy
{
public static int? ResolveAutoRetryBonus(int firstResult)
{
if (firstResult is >= 77 and <= 90)
if (firstResult is >= 76 and <= 90)
return 5;
if (firstResult is >= 91 and <= 110)

View File

@@ -8,7 +8,7 @@ This ExecPlan is a living document. The sections `Progress`, `Surprises & Discov
After this change, a Rolemaster skill can opt into an automatic retry when its first result lands in specific retry bands. The player will be able to toggle that behavior while creating or editing a Rolemaster open-ended skill, roll the skill, and then see the retry clearly in the campaign log card through a special badge and readable summary text. The detailed roll view will still show enough information to explain why the retry happened and what final result was recorded.
For this feature, an eligible retry result means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `77` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`.
For this feature, an eligible retry result means a Rolemaster open-ended percentile skill roll whose first fully evaluated result, including the skill expression modifier and any low-end subtraction chain, lands in one of the retry windows before any retry bonus is applied. This plan preserves the user-provided thresholds exactly: results `76` through `90` grant a retry with `+5`; results `91` through `110` grant a retry with `+10`.
## Progress
@@ -37,7 +37,7 @@ For this feature, an eligible retry result means a Rolemaster open-ended percent
Rationale: The request explicitly asks for “a toggle for a rolemaster skill.” A skill-group default would widen scope into template inheritance and create unclear behavior for ad hoc custom rolls.
Date/Author: 2026-04-04 / Codex
- Decision: The retry windows are interpreted literally from the request: `77-90 => +5`, `91-110 => +10`. A result of `111` counts as success and doesn't need to be retried.
- Decision: The retry windows are interpreted literally from the request: `76-90 => +5`, `91-110 => +10`. A result of `111` counts as success and doesn't need to be retried.
Rationale: The user gave concrete inclusive and exclusive bounds. Preserving those exact bounds avoids silently changing game rules inside the plan.
Date/Author: 2026-04-04 / Codex