Add Rolemaster ruleset parsing scaffolding

This commit is contained in:
2026-04-03 00:15:02 +02:00
parent 13c6215c89
commit 90afe3b06b
8 changed files with 149 additions and 16 deletions

View File

@@ -3,7 +3,8 @@ namespace RpgRoller.Domain;
public enum RulesetKind
{
D6,
Dnd5e
Dnd5e,
Rolemaster
}
public enum RollVisibility
@@ -87,4 +88,12 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; }
}
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical);
public enum DiceExpressionKind
{
Standard,
RolemasterInitiative,
RolemasterPercentile,
RolemasterOpenEndedPercentile
}
public sealed record DiceExpression(int DiceCount, int Sides, int Modifier, string Canonical, DiceExpressionKind Kind = DiceExpressionKind.Standard);

View File

@@ -13,6 +13,9 @@ public static partial class DiceRules
if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Dnd5e;
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Rolemaster;
return null;
}
@@ -20,9 +23,10 @@ public static partial class DiceRules
{
return ruleset switch
{
RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e",
RulesetKind.Rolemaster => "rolemaster",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
};
}
@@ -34,9 +38,10 @@ public static partial class DiceRules
var trimmed = expression.Trim();
return ruleset switch
{
RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed),
RulesetKind.Rolemaster => ParseRolemaster(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.")
};
}
@@ -71,7 +76,37 @@ public static partial class DiceRules
return ServiceResult<DiceExpression>.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}"));
}
private static ServiceResult<bool> ValidateDiceParts(int diceCount, int sides, int modifier)
private static ServiceResult<DiceExpression> ParseRolemaster(string expression)
{
var initiativeMatch = RolemasterInitiativeRegex().Match(expression);
if (initiativeMatch.Success)
{
var modifier = ParseModifier(initiativeMatch.Groups["modifier"].Value);
var validation = ValidateDiceParts(2, 10, modifier, -MaxModifier, MaxModifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
return ServiceResult<DiceExpression>.Success(new(2, 10, modifier, $"2d10{FormatModifier(modifier)}", DiceExpressionKind.RolemasterInitiative));
}
var percentileMatch = RolemasterPercentileRegex().Match(expression);
if (percentileMatch.Success)
{
var modifier = ParseModifier(percentileMatch.Groups["modifier"].Value);
var validation = ValidateDiceParts(1, 100, modifier, -MaxModifier, MaxModifier);
if (!validation.Succeeded)
return ServiceResult<DiceExpression>.Failure(validation.Error!.Code, validation.Error.Message);
var isOpenEnded = percentileMatch.Groups["openEnded"].Success;
var canonical = isOpenEnded ? $"d100!{FormatModifier(modifier)}" : $"d100{FormatModifier(modifier)}";
var kind = isOpenEnded ? DiceExpressionKind.RolemasterOpenEndedPercentile : DiceExpressionKind.RolemasterPercentile;
return ServiceResult<DiceExpression>.Success(new(1, 100, modifier, canonical, kind));
}
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected Rolemaster format like 2d10+48, d100+4, or d100!+85.");
}
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}.");
@@ -79,8 +114,8 @@ public static partial class DiceRules
if (sides < 2 || sides > MaxSides)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
if (modifier < 0 || modifier > MaxModifier)
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}.");
if (modifier < minModifier || modifier > maxModifier)
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
return ServiceResult<bool>.Success(true);
}
@@ -92,7 +127,12 @@ public static partial class DiceRules
private static string FormatModifier(int modifier)
{
return modifier > 0 ? $"+{modifier}" : string.Empty;
return modifier switch
{
> 0 => $"+{modifier}",
< 0 => modifier.ToString(),
_ => string.Empty
};
}
[GeneratedRegex("^(?<count>\\d+)D(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
@@ -101,6 +141,12 @@ public static partial class DiceRules
[GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex();
[GeneratedRegex("^2d10(?<modifier>[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex RolemasterInitiativeRegex();
[GeneratedRegex("^(?:1)?d100(?<openEnded>!)?(?<modifier>[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex RolemasterPercentileRegex();
private const int MaxDiceCount = 50;
private const int MaxSides = 1000;
private const int MaxModifier = 1000;
@@ -108,6 +154,7 @@ public static partial class DiceRules
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.Dnd5e, "dnd5e", "D&D 5e", "countdSides(+modifier), e.g. 2d12+2"),
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "2d10+48, d100+4, d100!+85")
];
}
}