Files
RpgRoller/RpgRoller/Services/DiceRules.cs
2026-04-05 01:32:52 +02:00

153 lines
6.9 KiB
C#

using System.Text.RegularExpressions;
using RpgRoller.Domain;
namespace RpgRoller.Services;
public static partial class DiceRules
{
public static RulesetKind? TryParseRulesetId(string rulesetId)
{
if (string.Equals(rulesetId, "d6", StringComparison.OrdinalIgnoreCase))
return RulesetKind.D6;
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Dnd5e;
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Rolemaster;
return null;
}
public static string ToRulesetId(RulesetKind ruleset)
{
return ruleset switch
{
RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e",
RulesetKind.Rolemaster => "rolemaster",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
};
}
public static ServiceResult<DiceExpression> ParseExpression(RulesetKind ruleset, string expression)
{
if (string.IsNullOrWhiteSpace(expression))
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Dice expression is required.");
var trimmed = expression.Trim();
return ruleset switch
{
RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
RulesetKind.Rolemaster => ParseRolemaster(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
};
}
private static ServiceResult<DiceExpression> ParseD6(string expression)
{
var match = D6Regex().Match(expression);
if (!match.Success)
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected d6 format like 5D+4.");
var diceCount = int.Parse(match.Groups["count"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, 6, modifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
return ServiceResult<DiceExpression>.Success(new(diceCount, 6, modifier, $"{diceCount}D{FormatModifier(modifier)}"));
}
private static ServiceResult<DiceExpression> ParseDnd5e(string expression)
{
var match = Dnd5eRegex().Match(expression);
if (!match.Success)
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected dnd5e format like 2d12+2.");
var diceCount = int.Parse(match.Groups["count"].Value);
var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
}
private static ServiceResult<DiceExpression> ParseRolemaster(string expression)
{
var match = RolemasterRegex().Match(expression);
if (!match.Success)
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected Rolemaster format like d10+4, 15d10, d100-15, or d100!+85.");
var countValue = match.Groups["count"].Value;
var diceCount = string.IsNullOrEmpty(countValue) ? 1 : int.Parse(countValue);
var sides = int.Parse(match.Groups["sides"].Value);
var modifier = ParseModifier(match.Groups["modifier"].Value);
var validation = ValidateDiceParts(diceCount, sides, modifier, -MaxModifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
var isOpenEnded = match.Groups["openEnded"].Success;
if (isOpenEnded && (diceCount != 1 || sides != 100))
{
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
}
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
var canonical = $"{countPrefix}d{sides}{(isOpenEnded ? "!" : string.Empty)}{FormatModifier(modifier)}";
var kind = isOpenEnded ? DiceExpressionKind.RolemasterOpenEndedPercentile : DiceExpressionKind.Standard;
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, canonical, kind));
}
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier, int minModifier = 0, int maxModifier = MaxModifier)
{
if (diceCount < 1 || diceCount > MaxDiceCount)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {MaxDiceCount}.");
if (sides < 2 || sides > MaxSides)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
if (modifier < minModifier || modifier > maxModifier)
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
return ServiceResult<bool>.Success(true);
}
private static int ParseModifier(string value)
{
return string.IsNullOrEmpty(value) ? 0 : int.Parse(value);
}
private static string FormatModifier(int modifier)
{
return modifier switch
{
> 0 => $"+{modifier}",
< 0 => modifier.ToString(),
_ => string.Empty
};
}
[GeneratedRegex("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex D6Regex();
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex();
[GeneratedRegex("^(?<count>\\d+)?d(?<sides>\\d+)(?<openEnded>!)?(?<modifier>[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex RolemasterRegex();
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
public static readonly IReadOnlyList<(RulesetKind Kind, string Id, string Name, string DiceSyntax)> SupportedRulesets =
[
(RulesetKind.D6, "d6", "D6 System", "countD(+modifier), e.g. 5D+4"),
(RulesetKind.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
];
}