Implement Rolemaster roll execution
This commit is contained in:
@@ -3,7 +3,7 @@
|
||||
<div class="roll-dice-strip" aria-label="@AriaLabel">
|
||||
@foreach (var die in Dice)
|
||||
{
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieGlyph(die.Roll)</span>
|
||||
<span class="@RollDieCssClass(die)" title="@RollDieTitle(die)">@RollDieDisplay(die)</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -21,6 +21,16 @@ public partial class RollDiceStrip
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieDisplay(RollDieResult die)
|
||||
{
|
||||
return die.Kind switch
|
||||
{
|
||||
RollDieKinds.RolemasterOpenEndedHigh => $"+{die.Roll}",
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract => $"-{die.Roll}",
|
||||
_ => RollDieGlyph(die.Roll)
|
||||
};
|
||||
}
|
||||
|
||||
private static string RollDieCssClass(RollDieResult die)
|
||||
{
|
||||
var classes = new List<string> { "die-chip" };
|
||||
@@ -39,12 +49,34 @@ public partial class RollDiceStrip
|
||||
if (die.Added)
|
||||
classes.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterInitiative:
|
||||
classes.Add("rolemaster-initiative");
|
||||
break;
|
||||
case RollDieKinds.RolemasterPercentile:
|
||||
classes.Add("rolemaster-percentile");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
classes.Add("rolemaster-open-ended-initial");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
classes.Add("rolemaster-open-ended-high");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
classes.Add("rolemaster-open-ended-low-subtract");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(" ", classes);
|
||||
}
|
||||
|
||||
private static string RollDieTitle(RollDieResult die)
|
||||
{
|
||||
var labels = new List<string> { $"Roll {die.Roll}" };
|
||||
if (die.Sequence.HasValue)
|
||||
labels.Add($"step {die.Sequence.Value}");
|
||||
|
||||
if (die.Wild)
|
||||
labels.Add("wild");
|
||||
|
||||
@@ -60,6 +92,25 @@ public partial class RollDiceStrip
|
||||
if (die.Added)
|
||||
labels.Add("added");
|
||||
|
||||
switch (die.Kind)
|
||||
{
|
||||
case RollDieKinds.RolemasterInitiative:
|
||||
labels.Add("Rolemaster initiative");
|
||||
break;
|
||||
case RollDieKinds.RolemasterPercentile:
|
||||
labels.Add("Rolemaster percentile");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedInitial:
|
||||
labels.Add("Rolemaster open-ended initial");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedHigh:
|
||||
labels.Add($"Rolemaster high follow-up (+{die.Roll})");
|
||||
break;
|
||||
case RollDieKinds.RolemasterOpenEndedLowSubtract:
|
||||
labels.Add($"Rolemaster low-end subtraction (-{die.Roll})");
|
||||
break;
|
||||
}
|
||||
|
||||
return string.Join(", ", labels);
|
||||
}
|
||||
|
||||
@@ -68,4 +119,4 @@ public partial class RollDiceStrip
|
||||
|
||||
[Parameter]
|
||||
public string AriaLabel { get; set; } = "Rolled dice";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace RpgRoller.Contracts;
|
||||
|
||||
public sealed record HealthResponse(string Status);
|
||||
@@ -48,7 +50,50 @@ public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId,
|
||||
|
||||
public sealed record RollSkillRequest(string Visibility);
|
||||
|
||||
public sealed record RollDieResult(int Roll, bool Crit, bool Fumble, bool Wild, bool Removed, bool Added);
|
||||
public static class RollDieKinds
|
||||
{
|
||||
public const string RolemasterInitiative = "rolemaster-initiative";
|
||||
public const string RolemasterPercentile = "rolemaster-percentile";
|
||||
public const string RolemasterOpenEndedInitial = "rolemaster-open-ended-initial";
|
||||
public const string RolemasterOpenEndedHigh = "rolemaster-open-ended-high";
|
||||
public const string RolemasterOpenEndedLowSubtract = "rolemaster-open-ended-low-subtract";
|
||||
}
|
||||
|
||||
public sealed record RollDieResult
|
||||
{
|
||||
public RollDieResult()
|
||||
{
|
||||
}
|
||||
|
||||
public RollDieResult(int roll, bool crit, bool fumble, bool wild, bool removed, bool added, int? sequence = null, string? kind = null, int? signedContribution = null)
|
||||
{
|
||||
Roll = roll;
|
||||
Crit = crit;
|
||||
Fumble = fumble;
|
||||
Wild = wild;
|
||||
Removed = removed;
|
||||
Added = added;
|
||||
Sequence = sequence;
|
||||
Kind = kind;
|
||||
SignedContribution = signedContribution;
|
||||
}
|
||||
|
||||
public int Roll { get; init; }
|
||||
public bool Crit { get; init; }
|
||||
public bool Fumble { get; init; }
|
||||
public bool Wild { get; init; }
|
||||
public bool Removed { get; init; }
|
||||
public bool Added { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? Sequence { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public string? Kind { get; init; }
|
||||
|
||||
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
|
||||
public int? SignedContribution { 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);
|
||||
|
||||
|
||||
@@ -942,7 +942,21 @@ public sealed class GameService : IGameService
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
||||
{
|
||||
return ruleset == RulesetKind.D6 ? ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble) : ComputeStandardRoll(expression);
|
||||
if (ruleset == RulesetKind.D6)
|
||||
return ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble);
|
||||
|
||||
if (ruleset == RulesetKind.Rolemaster)
|
||||
{
|
||||
return expression.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterInitiative => ComputeRolemasterInitiativeRoll(expression),
|
||||
DiceExpressionKind.RolemasterPercentile => ComputeRolemasterPercentileRoll(expression),
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, skill.FumbleRange.GetValueOrDefault()),
|
||||
_ => ComputeStandardRoll(expression)
|
||||
};
|
||||
}
|
||||
|
||||
return ComputeStandardRoll(expression);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeStandardRoll(DiceExpression expression)
|
||||
@@ -961,6 +975,62 @@ public sealed class GameService : IGameService
|
||||
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterInitiativeRoll(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.RolemasterInitiative, value);
|
||||
total += value;
|
||||
}
|
||||
|
||||
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterPercentileRoll(DiceExpression expression)
|
||||
{
|
||||
var roll = m_DiceRoller.Roll(expression.Sides);
|
||||
var total = roll + expression.Modifier;
|
||||
var dice = new[]
|
||||
{
|
||||
CreateRolemasterDie(roll, 1, RollDieKinds.RolemasterPercentile, roll)
|
||||
};
|
||||
|
||||
return (total, BuildBreakdown([roll], 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>();
|
||||
var dice = new List<RollDieResult>
|
||||
{
|
||||
CreateRolemasterDie(initialRoll, 1, RollDieKinds.RolemasterOpenEndedInitial, initialRoll)
|
||||
};
|
||||
|
||||
var baseTotal = 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;
|
||||
@@ -1041,14 +1111,63 @@ public sealed class GameService : IGameService
|
||||
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";
|
||||
|
||||
var modifierPart = modifier > 0 ? $"+{modifier}" : string.Empty;
|
||||
return $"{dicePart}{modifierPart}={total}";
|
||||
return BuildModifierBreakdown(dicePart, modifier, total);
|
||||
}
|
||||
|
||||
private static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int 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 BuildModifierBreakdown(string core, int modifier, int total)
|
||||
{
|
||||
return modifier switch
|
||||
{
|
||||
> 0 => $"{core}+{modifier}={total}",
|
||||
< 0 => $"{core}{modifier}={total}",
|
||||
_ => $"{core}={total}"
|
||||
};
|
||||
}
|
||||
|
||||
private ServiceResult<Guid?> ResolveSkillGroupForSkillChangeLocked(Guid? requestedSkillGroupId, Guid characterId)
|
||||
@@ -1248,6 +1367,9 @@ public sealed class GameService : IGameService
|
||||
if (dice.Count == 0)
|
||||
return "No detail available.";
|
||||
|
||||
if (dice.Any(die => IsRolemasterDieKind(die.Kind)))
|
||||
return BuildRolemasterCompactLogSummary(dice);
|
||||
|
||||
var preview = string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
|
||||
if (dice.Count > 3)
|
||||
preview = $"{preview}, ...";
|
||||
@@ -1265,6 +1387,46 @@ public sealed class GameService : IGameService
|
||||
return tags.Count == 0 ? preview : $"{preview} | {string.Join(", ", tags)}";
|
||||
}
|
||||
|
||||
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 $"{openEndedInitial.Roll} - ({string.Join(" + ", lowFollowUps)}) | open-ended low";
|
||||
|
||||
return $"{openEndedInitial.Roll} | open-ended";
|
||||
}
|
||||
|
||||
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterInitiative, StringComparison.Ordinal)))
|
||||
return $"{string.Join(" + ", dice.Select(die => die.Roll.ToString()))} | initiative";
|
||||
|
||||
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterPercentile, StringComparison.Ordinal)))
|
||||
return $"{dice[0].Roll} | percentile";
|
||||
|
||||
return string.Join(", ", dice.Take(3).Select(die => die.Roll.ToString()));
|
||||
}
|
||||
|
||||
private static bool IsRolemasterDieKind(string? kind)
|
||||
{
|
||||
return kind is RollDieKinds.RolemasterInitiative or
|
||||
RollDieKinds.RolemasterPercentile or
|
||||
RollDieKinds.RolemasterOpenEndedInitial or
|
||||
RollDieKinds.RolemasterOpenEndedHigh or
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private bool CanViewRollLocked(UserAccount user, Campaign campaign, RollLogEntry entry)
|
||||
{
|
||||
return CanViewCampaignLocked(user.Id, campaign.Id) &&
|
||||
|
||||
@@ -528,15 +528,16 @@ select:focus-visible {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.1rem;
|
||||
min-width: 2.1rem;
|
||||
height: 2.1rem;
|
||||
padding-top: 4px;
|
||||
padding: 0.2rem 0.45rem 0;
|
||||
border: 2px solid #2a2418;
|
||||
border-radius: 0.45rem;
|
||||
background: #ffffff;
|
||||
color: #1f1a13;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.die-chip.wild {
|
||||
@@ -565,6 +566,36 @@ select:focus-visible {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.die-chip.rolemaster-initiative,
|
||||
.die-chip.rolemaster-percentile,
|
||||
.die-chip.rolemaster-open-ended-initial,
|
||||
.die-chip.rolemaster-open-ended-high,
|
||||
.die-chip.rolemaster-open-ended-low-subtract {
|
||||
padding-top: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.die-chip.rolemaster-initiative,
|
||||
.die-chip.rolemaster-percentile,
|
||||
.die-chip.rolemaster-open-ended-initial {
|
||||
background: #f8f1df;
|
||||
color: #3f2f12;
|
||||
}
|
||||
|
||||
.die-chip.rolemaster-open-ended-high {
|
||||
background: #dff6df;
|
||||
color: #1d5b26;
|
||||
border-color: #2a7c39;
|
||||
}
|
||||
|
||||
.die-chip.rolemaster-open-ended-low-subtract {
|
||||
background: #ffe1dc;
|
||||
color: #8a2217;
|
||||
border-color: #b74334;
|
||||
}
|
||||
|
||||
.empty,
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
|
||||
Reference in New Issue
Block a user