Extract roll engines and log helpers
This commit is contained in:
115
RpgRoller/Services/CampaignLogSummaryBuilder.cs
Normal file
115
RpgRoller/Services/CampaignLogSummaryBuilder.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
94
RpgRoller/Services/D6RollEngine.cs
Normal file
94
RpgRoller/Services/D6RollEngine.cs
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
96
RpgRoller/Services/RolemasterRollEngine.cs
Normal file
96
RpgRoller/Services/RolemasterRollEngine.cs
Normal 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;
|
||||
}
|
||||
52
RpgRoller/Services/RollBreakdownFormatter.cs
Normal file
52
RpgRoller/Services/RollBreakdownFormatter.cs
Normal 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}"
|
||||
};
|
||||
}
|
||||
}
|
||||
29
RpgRoller/Services/RollEngine.cs
Normal file
29
RpgRoller/Services/RollEngine.cs
Normal 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;
|
||||
}
|
||||
30
RpgRoller/Services/StandardRollEngine.cs
Normal file
30
RpgRoller/Services/StandardRollEngine.cs
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user