Extract roll engines and log helpers

This commit is contained in:
2026-04-05 01:12:47 +02:00
parent 6f9acdc165
commit 6cdd29ed93
10 changed files with 528 additions and 360 deletions

View File

@@ -0,0 +1,115 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static class CampaignLogSummaryBuilder
{
public static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
if (dice.Count == 0)
return "No detail available.";
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
return BuildRolemasterCompactLogSummary(dice);
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
}
public static string[]? BuildCompactLogEventBadges(RulesetKind ruleset, string? expression, IReadOnlyList<RollDieResult> dice)
{
var badges = new List<string>();
switch (ruleset)
{
case RulesetKind.D6:
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6");
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
break;
case RulesetKind.Dnd5e:
if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression))
{
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
}
break;
case RulesetKind.Rolemaster:
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");
break;
}
return badges.Count == 0 ? null : badges.ToArray();
}
public static string? ExtractCustomRollExpression(string breakdown, string separator)
{
var separatorIndex = breakdown.IndexOf(separator, StringComparison.Ordinal);
if (separatorIndex <= 0)
return null;
return breakdown[..separatorIndex];
}
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
var openEndedInitial = dice.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();
if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
var lowFollowUps = dice
.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 $"{openEndedInitial.Roll} | open-ended";
}
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
{
var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString()));
return $"{preview} | rolemaster";
}
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
}
private static bool IsRolemasterDieKind(string? kind)
{
return kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
{
if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal)))
return;
badges.Add(code);
}
private static bool IsSingleD20Expression(string expression)
{
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
return parsedExpression.Succeeded &&
parsedExpression.Value!.DiceCount == 1 &&
parsedExpression.Value.Sides == 20;
}
}

View File

@@ -0,0 +1,94 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class D6RollEngine
{
public D6RollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int wildDice, bool allowFumble)
{
var initialDice = expression.DiceCount;
var currentDice = initialDice;
var pendingExplodingDice = 0;
var pendingFumbles = 0;
var dieResults = new List<RollDieResult>(initialDice);
for (var i = 0; i < currentDice; i += 1)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var isWild = i < wildDice;
var isCrit = false;
var isFumble = false;
var isAdded = false;
if (isWild)
{
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
isCrit = true;
}
else if (allowFumble && roll == 1)
{
pendingFumbles += 1;
isFumble = true;
}
}
if (pendingExplodingDice > 0 && i >= initialDice)
{
pendingExplodingDice -= 1;
isAdded = true;
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
}
}
dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
}
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
continue;
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count);
foreach (var die in dieResults)
{
if (die.Fumble)
{
total += 1;
includedDice.Add(1);
}
else if (!die.Removed)
{
total += die.Roll;
includedDice.Add(die.Roll);
}
}
return (total, RollBreakdownFormatter.BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
}
private readonly IDiceRoller m_DiceRoller;
}

View File

@@ -11,6 +11,10 @@ public sealed class GameRollService
m_StateStore = stateStore;
m_PersistenceService = persistenceService;
m_DiceRoller = diceRoller;
m_RollEngine = new(
new StandardRollEngine(diceRoller),
new D6RollEngine(diceRoller),
new RolemasterRollEngine(diceRoller));
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
@@ -39,7 +43,7 @@ public sealed class GameRollService
if (!parsedVisibility.Succeeded)
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
var roll = ComputeRoll(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);
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
}
}
@@ -70,7 +74,7 @@ public sealed class GameRollService
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
var (wildDice, allowFumble, fumbleRange) = CustomRollOptionsResolver.Resolve(campaign.Ruleset);
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange);
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange);
return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
}
}
@@ -164,240 +168,6 @@ public sealed class GameRollService
}
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{
if (ruleset == RulesetKind.D6)
return ComputeD6Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster)
{
return expression.Kind switch
{
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, fumbleRange.GetValueOrDefault()),
_ => ComputeRolemasterStandardRoll(expression)
};
}
return ComputeStandardRoll(expression);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = new(value, false, false, false, false, false);
total += value;
}
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterStandardRoll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value;
}
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_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 baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false;
if (initialRoll >= 96)
{
followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: false));
baseTotal += followUpRolls.Sum();
}
else if (initialRoll <= fumbleRange)
{
subtractFollowUps = true;
followUpRolls.AddRange(RollRolemasterHighOpenEndedChain(dice, sequenceStart: 2, subtract: true));
baseTotal -= followUpRolls.Sum();
}
var total = baseTotal + expression.Modifier;
var breakdown = BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
return (total, breakdown, dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeD6Roll(DiceExpression expression, int wildDice, bool allowFumble)
{
var initialDice = expression.DiceCount;
var currentDice = initialDice;
var pendingExplodingDice = 0;
var pendingFumbles = 0;
var dieResults = new List<RollDieResult>(initialDice);
for (var i = 0; i < currentDice; i += 1)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var isWild = i < wildDice;
var isCrit = false;
var isFumble = false;
var isAdded = false;
if (isWild)
{
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
isCrit = true;
}
else if (allowFumble && roll == 1)
{
pendingFumbles += 1;
isFumble = true;
}
}
if (pendingExplodingDice > 0 && i >= initialDice)
{
pendingExplodingDice -= 1;
isAdded = true;
if (roll == expression.Sides)
{
pendingExplodingDice += 1;
currentDice += 1;
}
}
dieResults.Add(new(roll, isCrit, isFumble, isWild, false, isAdded));
}
for (var roll = expression.Sides; roll >= 1 && pendingFumbles > 0; roll -= 1)
for (var i = currentDice - 1; i >= 0 && pendingFumbles > 0; i -= 1)
{
if (dieResults[i].Roll != roll)
continue;
dieResults[i] = dieResults[i] with
{
Removed = true,
Added = false,
Crit = false,
Fumble = false
};
pendingFumbles -= 1;
}
var total = expression.Modifier;
var includedDice = new List<int>(dieResults.Count);
foreach (var die in dieResults)
{
if (die.Fumble)
{
total += 1;
includedDice.Add(1);
}
else if (!die.Removed)
{
total += die.Roll;
includedDice.Add(die.Roll);
}
}
return (total, BuildBreakdown(includedDice, expression.Modifier, total), dieResults);
}
private IEnumerable<int> RollRolemasterHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
{
var followUpRolls = new List<int>();
var sequence = sequenceStart;
while (true)
{
var roll = m_DiceRoller.Roll(100);
followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(
roll,
sequence,
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
subtract ? -roll : roll));
sequence += 1;
if (roll < 96)
break;
}
return followUpRolls;
}
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution)
{
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
}
private static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0";
return BuildModifierBreakdown(dicePart, modifier, total);
}
private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
if (subtractFollowUps)
{
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
if (modifier > 0)
segments.Add($"+{modifier}");
else if (modifier < 0)
segments.Add(modifier.ToString());
return $"{string.Join(" ", segments)} = {total}";
}
var core = initialRoll.ToString();
if (followUpRolls.Count > 0)
{
var followUpBreakdown = string.Join("+", followUpRolls);
core = subtractFollowUps ? $"{core}-({followUpBreakdown})" : $"{core}+{followUpBreakdown}";
}
return BuildModifierBreakdown(core, modifier, total);
}
private static string FormatRolemasterTriggerRoll(int roll)
{
return roll.ToString("00");
}
private static string BuildModifierBreakdown(string core, int modifier, int total)
{
return modifier switch
{
> 0 => $"{core}+{modifier}={total}",
< 0 => $"{core}{modifier}={total}",
_ => $"{core}={total}"
};
}
private ServiceResult<RollResult> RecordRollLocked(
UserAccount user,
Campaign campaign,
@@ -460,7 +230,7 @@ public sealed class GameRollService
var characterName = m_StateStore.CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
var skillName = ResolveLoggedSkillName(entry);
var loggedExpression = ResolveLoggedExpression(entry);
var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice);
var eventBadges = CampaignLogSummaryBuilder.BuildCompactLogEventBadges(campaign.Ruleset, loggedExpression, dice);
return GameDtoMapper.ToCampaignLogListEntry(
entry,
@@ -469,7 +239,7 @@ public sealed class GameRollService
ResolveLogRollerLabel(user, campaign, entry),
ResolveLogVisibilityLabel(user, campaign, entry),
ResolveLogVisibilityStyle(user, campaign, entry),
BuildCompactLogSummary(dice),
CampaignLogSummaryBuilder.BuildCompactLogSummary(dice),
eventBadges);
}
@@ -478,56 +248,6 @@ public sealed class GameRollService
return JsonSerializer.Serialize(dice, DiceJsonOptions);
}
private static string BuildCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
if (dice.Count == 0)
return "No detail available.";
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
return BuildRolemasterCompactLogSummary(dice);
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
}
private static string BuildRolemasterCompactLogSummary(IReadOnlyList<RollDieResult> dice)
{
var openEndedInitial = dice.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();
if (highFollowUps.Length > 0)
return $"{openEndedInitial.Roll} + {string.Join(" + ", highFollowUps)} | open-ended high";
var lowFollowUps = dice
.Where(die => string.Equals(die.Kind, RollDieKinds.RolemasterOpenEndedLowSubtract, StringComparison.Ordinal))
.Select(die => die.Roll.ToString())
.ToArray();
if (lowFollowUps.Length > 0)
return $"({FormatRolemasterTriggerRoll(openEndedInitial.Roll)}) {string.Join(" ", lowFollowUps.Select(roll => $"-{roll}"))} | open-ended low";
return $"{openEndedInitial.Roll} | open-ended";
}
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
{
var preview = string.Join(" + ", dice.Select(die => die.Roll.ToString()));
return $"{preview} | rolemaster";
}
return string.Join(", ", dice.Select(die => die.Roll.ToString()));
}
private static bool IsRolemasterDieKind(string? kind)
{
return kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or
RollDieKinds.RolemasterOpenEndedLowSubtract;
}
private string ResolveLoggedSkillName(RollLogEntry entry)
{
if (entry.SkillId == CustomRollSkillId)
@@ -539,69 +259,11 @@ public sealed class GameRollService
private string? ResolveLoggedExpression(RollLogEntry entry)
{
if (entry.SkillId == CustomRollSkillId)
return ExtractCustomRollExpression(entry.Breakdown);
return CampaignLogSummaryBuilder.ExtractCustomRollExpression(entry.Breakdown, CustomRollBreakdownSeparator);
return m_StateStore.SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null;
}
private static string? ExtractCustomRollExpression(string breakdown)
{
var separatorIndex = breakdown.IndexOf(CustomRollBreakdownSeparator, StringComparison.Ordinal);
if (separatorIndex <= 0)
return null;
return breakdown[..separatorIndex];
}
private static string[]? BuildCompactLogEventBadges(Campaign campaign, string? expression, IReadOnlyList<RollDieResult> dice)
{
var badges = new List<string>();
switch (campaign.Ruleset)
{
case RulesetKind.D6:
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 6), "w6");
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
break;
case RulesetKind.Dnd5e:
if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression))
{
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
}
break;
case RulesetKind.Rolemaster:
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");
break;
}
return badges.Count == 0 ? null : badges.ToArray();
}
private static void AddBadgeIfMissing(List<string> badges, bool condition, string code)
{
if (!condition || badges.Any(badge => string.Equals(badge, code, StringComparison.Ordinal)))
return;
badges.Add(code);
}
private static bool IsSingleD20Expression(string expression)
{
var parsedExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, expression);
return parsedExpression.Succeeded &&
parsedExpression.Value!.DiceCount == 1 &&
parsedExpression.Value.Sides == 20;
}
private string ResolveLogRollerLabel(UserAccount user, Campaign campaign, RollLogEntry entry)
{
if (entry.RollerUserId == user.Id)
@@ -666,5 +328,6 @@ public sealed class GameRollService
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
private readonly IDiceRoller m_DiceRoller;
private readonly GamePersistenceService m_PersistenceService;
private readonly RollEngine m_RollEngine;
private readonly GameStateStore m_StateStore;
}

View File

@@ -0,0 +1,96 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class RolemasterRollEngine
{
public RolemasterRollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange)
{
return expression.Kind switch
{
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault()),
_ => RollStandard(expression)
};
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value;
}
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_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 baseTotal = initialRoll <= fumbleRange ? 0 : initialRoll;
var subtractFollowUps = false;
if (initialRoll >= 96)
{
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: false));
baseTotal += followUpRolls.Sum();
}
else if (initialRoll <= fumbleRange)
{
subtractFollowUps = true;
followUpRolls.AddRange(RollHighOpenEndedChain(dice, sequenceStart: 2, subtract: true));
baseTotal -= followUpRolls.Sum();
}
var total = baseTotal + expression.Modifier;
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
return (total, breakdown, dice);
}
private IEnumerable<int> RollHighOpenEndedChain(List<RollDieResult> dice, int sequenceStart, bool subtract)
{
var followUpRolls = new List<int>();
var sequence = sequenceStart;
while (true)
{
var roll = m_DiceRoller.Roll(100);
followUpRolls.Add(roll);
dice.Add(CreateRolemasterDie(
roll,
sequence,
subtract ? RollDieKinds.RolemasterOpenEndedLowSubtract : RollDieKinds.RolemasterOpenEndedHigh,
subtract ? -roll : roll));
sequence += 1;
if (roll < 96)
break;
}
return followUpRolls;
}
private static RollDieResult CreateRolemasterDie(int roll, int sequence, string kind, int? signedContribution)
{
return new(roll, false, false, false, false, kind == RollDieKinds.RolemasterOpenEndedHigh, sequence, kind, signedContribution);
}
private readonly IDiceRoller m_DiceRoller;
}

View File

@@ -0,0 +1,52 @@
namespace RpgRoller.Services;
public static class RollBreakdownFormatter
{
public static string BuildBreakdown(IEnumerable<int> diceValues, int modifier, int total)
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0";
return BuildModifierBreakdown(dicePart, modifier, total);
}
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
if (subtractFollowUps)
{
var segments = new List<string> { $"({FormatRolemasterTriggerRoll(initialRoll)})" };
segments.AddRange(followUpRolls.Select(roll => $"-{roll}"));
if (modifier > 0)
segments.Add($"+{modifier}");
else if (modifier < 0)
segments.Add(modifier.ToString());
return $"{string.Join(" ", segments)} = {total}";
}
var core = initialRoll.ToString();
if (followUpRolls.Count > 0)
{
var followUpBreakdown = string.Join("+", followUpRolls);
core = $"{core}+{followUpBreakdown}";
}
return BuildModifierBreakdown(core, modifier, total);
}
public static string FormatRolemasterTriggerRoll(int roll)
{
return roll.ToString("00");
}
public static string BuildModifierBreakdown(string core, int modifier, int total)
{
return modifier switch
{
> 0 => $"{core}+{modifier}={total}",
< 0 => $"{core}{modifier}={total}",
_ => $"{core}={total}"
};
}
}

View File

@@ -0,0 +1,29 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class RollEngine
{
public RollEngine(StandardRollEngine standardRollEngine, D6RollEngine d6RollEngine, RolemasterRollEngine rolemasterRollEngine)
{
m_StandardRollEngine = standardRollEngine;
m_D6RollEngine = d6RollEngine;
m_RolemasterRollEngine = rolemasterRollEngine;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
{
if (ruleset == RulesetKind.D6)
return m_D6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster)
return m_RolemasterRollEngine.Roll(expression, fumbleRange);
return m_StandardRollEngine.Roll(expression);
}
private readonly D6RollEngine m_D6RollEngine;
private readonly RolemasterRollEngine m_RolemasterRollEngine;
private readonly StandardRollEngine m_StandardRollEngine;
}

View File

@@ -0,0 +1,30 @@
using RpgRoller.Contracts;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public sealed class StandardRollEngine
{
public StandardRollEngine(IDiceRoller diceRoller)
{
m_DiceRoller = diceRoller;
}
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = new(value, false, false, false, false, false);
total += value;
}
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private readonly IDiceRoller m_DiceRoller;
}