Add rolemaster automatic retry rolls
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -81,4 +81,64 @@ public sealed class RolemasterApiTests(WebApplicationFactory<Program> 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<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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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>(T payload, int maxBytes, string label)
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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<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));
|
||||
}
|
||||
}
|
||||
@@ -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<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)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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<RollDieResult> Dice, DateTimeOffset TimestampUtc);
|
||||
|
||||
@@ -5,18 +5,18 @@ namespace RpgRoller.Services;
|
||||
|
||||
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)
|
||||
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<RollDieResult> dice)
|
||||
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice, string? breakdown = null)
|
||||
{
|
||||
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 => 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<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)
|
||||
{
|
||||
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<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)
|
||||
{
|
||||
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
|
||||
|
||||
@@ -32,7 +32,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
|
||||
if (!parsedVisibility.Succeeded)
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<RollDieResult> dice)
|
||||
|
||||
32
RpgRoller/Services/RolemasterRetryPolicy.cs
Normal file
32
RpgRoller/Services/RolemasterRetryPolicy.cs
Normal 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):";
|
||||
}
|
||||
@@ -5,16 +5,16 @@ namespace RpgRoller.Services;
|
||||
|
||||
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
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()),
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry),
|
||||
_ => 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 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<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 followUpRolls = new List<int>();
|
||||
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 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<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 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<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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -5,13 +5,13 @@ namespace RpgRoller.Services;
|
||||
|
||||
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)
|
||||
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);
|
||||
}
|
||||
|
||||
4
TASKS.md
4
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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user