Add rolemaster situational roll modifier backend

This commit is contained in:
2026-04-14 23:42:25 +02:00
parent 9e91fb2719
commit 368a9a4960
14 changed files with 185 additions and 33 deletions

View File

@@ -45,7 +45,7 @@ internal static class SkillEndpoints
group.MapPost("/skills/{skillId:guid}/roll", (Guid skillId, RollSkillRequest request, HttpContext context, IGameService game) =>
{
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility);
var result = game.RollSkill(context.GetRequiredSessionToken(), skillId, request.Visibility, request.SituationalModifier);
return ApiResultMapper.ToApiResult(result);
});

View File

@@ -48,7 +48,7 @@ public sealed record UpdateSkillGroupRequest(string Name, string DiceRollDefinit
public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId, string Name, string DiceRollDefinition, int WildDice, bool AllowFumble, int? FumbleRange, bool RolemasterAutoRetry);
public sealed record RollSkillRequest(string Visibility);
public sealed record RollSkillRequest(string Visibility, int SituationalModifier = 0);
public sealed record CustomRollRequest(string Expression, string Visibility);

View File

@@ -6,7 +6,7 @@ namespace RpgRoller.Services;
public sealed class GameRollService(GameStateStore stateStore, GamePersistenceService persistenceService, IDiceRoller diceRoller)
{
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{
lock (stateStore.Gate)
{
@@ -28,11 +28,17 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
if (!parsedExpression.Succeeded)
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
if (situationalModifier != 0 && campaign.Ruleset != RulesetKind.Rolemaster)
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", "Situational modifiers are supported only for Rolemaster skill rolls.");
if (campaign.Ruleset == RulesetKind.Rolemaster && (situationalModifier < -MaxSituationalModifier || situationalModifier > MaxSituationalModifier))
return ServiceResult<RollResult>.Failure("invalid_situational_modifier", $"Situational modifier must be between {-MaxSituationalModifier} and {MaxSituationalModifier}.");
var parsedVisibility = RollVisibilityParser.Parse(visibility);
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, skill.RolemasterAutoRetry);
var roll = m_RollEngine.Roll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange, skill.RolemasterAutoRetry, situationalModifier);
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
}
}
@@ -287,6 +293,7 @@ public sealed class GameRollService(GameStateStore stateStore, GamePersistenceSe
private const int CampaignLogHistoryWindowSize = 100;
private const int CampaignLogLivePageSize = 25;
private const int MaxSituationalModifier = 1000;
private const string CustomRollBreakdownSeparator = " => ";
private const string CustomRollLabel = "Custom roll";
private static readonly Guid CustomRollSkillId = Guid.Empty;

View File

@@ -160,9 +160,9 @@ public sealed class GameService : IGameService
return m_SkillService.GetCharacterSheet(sessionToken, characterId);
}
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility)
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0)
{
return m_RollService.RollSkill(sessionToken, skillId, visibility);
return m_RollService.RollSkill(sessionToken, skillId, visibility, situationalModifier);
}
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)

View File

@@ -36,7 +36,7 @@ public interface IGameService
ServiceResult<bool> DeleteSkill(string sessionToken, Guid skillId);
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility, int situationalModifier = 0);
ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility);
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);

View File

@@ -5,20 +5,20 @@ namespace RpgRoller.Services;
public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
{
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry)
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(DiceExpression expression, int? fumbleRange, bool rolemasterAutoRetry, int situationalModifier = 0)
{
return expression.Kind switch
{
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry),
_ => RollStandard(expression)
DiceExpressionKind.RolemasterOpenEndedPercentile => RollOpenEnded(expression, fumbleRange.GetValueOrDefault(), rolemasterAutoRetry, situationalModifier),
_ => RollStandard(expression, situationalModifier)
};
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int? attempt = null)
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollStandard(DiceExpression expression, int situationalModifier, int? attempt = null)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
var total = expression.Modifier;
var total = expression.Modifier + situationalModifier;
for (var i = 0; i < expression.DiceCount; i += 1)
{
var value = diceRoller.Roll(expression.Sides);
@@ -27,17 +27,17 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
total += value;
}
return (total, RollBreakdownFormatter.BuildBreakdown(diceValues, expression.Modifier, total), dice);
return (total, RollBreakdownFormatter.BuildRolemasterModifierBreakdown(diceValues, expression.Modifier, situationalModifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry)
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEnded(DiceExpression expression, int fumbleRange, bool rolemasterAutoRetry, int situationalModifier)
{
var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange);
var firstAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier);
var retryBonus = rolemasterAutoRetry ? RolemasterRetryPolicy.ResolveAutoRetryBonus(firstAttempt.Total) : null;
if (!retryBonus.HasValue)
return firstAttempt;
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, 2);
var retryAttempt = RollOpenEndedAttempt(expression, fumbleRange, situationalModifier, 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();
@@ -45,7 +45,7 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
return (finalTotal, breakdown, dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int? attempt = null)
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) RollOpenEndedAttempt(DiceExpression expression, int fumbleRange, int situationalModifier, int? attempt = null)
{
var initialRoll = diceRoller.Roll(expression.Sides);
var followUpRolls = new List<int>();
@@ -66,8 +66,8 @@ public sealed class RolemasterRollEngine(IDiceRoller diceRoller)
baseTotal -= followUpRolls.Sum();
}
var total = baseTotal + expression.Modifier;
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total);
var total = baseTotal + expression.Modifier + situationalModifier;
var breakdown = RollBreakdownFormatter.BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, expression.Modifier, total, situationalModifier);
return (total, breakdown, dice);
}

View File

@@ -12,15 +12,17 @@ public static class RollBreakdownFormatter
}
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total)
{
return BuildRolemasterOpenEndedBreakdown(initialRoll, followUpRolls, subtractFollowUps, modifier, total, 0);
}
public static string BuildRolemasterOpenEndedBreakdown(int initialRoll, IReadOnlyList<int> followUpRolls, bool subtractFollowUps, int modifier, int total, int situationalModifier)
{
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());
AddRolemasterModifierSegments(segments, modifier, situationalModifier);
return $"{string.Join(" ", segments)} = {total}";
}
@@ -32,7 +34,7 @@ public static class RollBreakdownFormatter
core = $"{core}+{followUpBreakdown}";
}
return BuildModifierBreakdown(core, modifier, total);
return BuildRolemasterModifierBreakdown(core, modifier, situationalModifier, total);
}
public static string BuildRolemasterRetryBreakdown(string firstAttemptBreakdown, int retryBonus, string retryAttemptBreakdown, int finalTotal)
@@ -54,4 +56,40 @@ public static class RollBreakdownFormatter
_ => $"{core}={total}"
};
}
public static string BuildRolemasterModifierBreakdown(IEnumerable<int> diceValues, int modifier, int situationalModifier, int total)
{
var dicePart = string.Join("+", diceValues);
if (string.IsNullOrWhiteSpace(dicePart))
dicePart = "0";
return BuildRolemasterModifierBreakdown(dicePart, modifier, situationalModifier, total);
}
public static string BuildRolemasterModifierBreakdown(string core, int modifier, int situationalModifier, int total)
{
if (situationalModifier == 0)
return BuildModifierBreakdown(core, modifier, total);
return $"{core}{FormatSignedModifier(modifier)}{FormatSignedModifier(situationalModifier)}={total}";
}
private static void AddRolemasterModifierSegments(List<string> segments, int modifier, int situationalModifier)
{
if (modifier != 0)
segments.Add(FormatSignedModifier(modifier));
if (situationalModifier != 0)
segments.Add(FormatSignedModifier(situationalModifier));
}
private static string FormatSignedModifier(int modifier)
{
return modifier switch
{
> 0 => $"+{modifier}",
< 0 => modifier.ToString(),
_ => string.Empty
};
}
}

View File

@@ -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, bool rolemasterAutoRetry = false)
public (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) Roll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange, bool rolemasterAutoRetry = false, int situationalModifier = 0)
{
if (ruleset == RulesetKind.D6)
return d6RollEngine.Roll(expression, wildDice, allowFumble);
if (ruleset == RulesetKind.Rolemaster)
return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry);
return rolemasterRollEngine.Roll(expression, fumbleRange, rolemasterAutoRetry, situationalModifier);
return standardRollEngine.Roll(expression);
}