Add rolemaster situational roll modifier backend
This commit is contained in:
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user