Add rolemaster automatic retry rolls
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user