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

@@ -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
};
}

View File

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

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;
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);

View File

@@ -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);

View File

@@ -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)

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 (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);
}
}

View File

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

View File

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