Implement Rolemaster roll execution

This commit is contained in:
2026-04-03 00:51:36 +02:00
parent 9b9927084b
commit 0059fde74f
10 changed files with 619 additions and 8 deletions

View File

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

View File

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

View File

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

View File

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

View File

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