151 lines
6.8 KiB
C#
151 lines
6.8 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")
|
|
];
|
|
} |