From 90afe3b06bbf22bd3601d8952d614794e19530a4 Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Fri, 3 Apr 2026 00:15:02 +0200 Subject: [PATCH] Add Rolemaster ruleset parsing scaffolding --- README.md | 2 + RpgRoller.Tests/Api/CampaignApiTests.cs | 14 ++++ RpgRoller.Tests/Api/SystemApiTests.cs | 4 +- RpgRoller.Tests/Services/DiceRulesTests.cs | 23 +++++- .../Services/ServiceCampaignTests.cs | 2 + .../ServiceSkillGroupAndOwnershipTests.cs | 36 ++++++++++ RpgRoller/Domain/GameModels.cs | 13 +++- RpgRoller/Services/DiceRules.cs | 71 +++++++++++++++---- 8 files changed, 149 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 26ba892..734c47f 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ Backend state persistence: Gameplay capabilities now include: - Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups) +- Supported campaign rulesets include D6 System, D&D 5e, and Rolemaster - Skill groups per character with skill prototypes (create/edit/delete groups, assign/reassign skills, and prefill new skill forms from group defaults) - Skill and skill-group deletion flows - GM-driven character owner transfer within campaign management flows @@ -61,6 +62,7 @@ Gameplay capabilities now include: - Campaign management supports character deletion by character owner or admin - Shared top header control across all authenticated workspace screens (play, campaign management, admin) - Admin user management is integrated into workspace screen toggles (`Play`, `Campaign Management`, `Admin`) +- Rolemaster expression validation currently recognizes `2d10+48`, `d100+4`, and `d100!+85`, including Rolemaster-only negative modifiers such as `d100-15` ## Prerequisites diff --git a/RpgRoller.Tests/Api/CampaignApiTests.cs b/RpgRoller.Tests/Api/CampaignApiTests.cs index 3659c6b..dd6b8ce 100644 --- a/RpgRoller.Tests/Api/CampaignApiTests.cs +++ b/RpgRoller.Tests/Api/CampaignApiTests.cs @@ -65,6 +65,20 @@ public sealed class CampaignApiTests : ApiTestBase Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); } + [Fact] + public async Task CampaignCreation_AcceptsRolemasterRuleset() + { + using var factory = CreateFactory(2, 2, 2); + using var gmClient = factory.CreateClient(new() { AllowAutoRedirect = false }); + + await RegisterAsync(gmClient, "gm-rm-api", "Password123", "Game Master"); + await LoginAsync(gmClient, "gm-rm-api", "Password123"); + + var campaign = await PostAsync(gmClient, "/api/campaigns", new("Shadow World", "rolemaster")); + + Assert.Equal("rolemaster", campaign.RulesetId); + } + [Fact] public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() { diff --git a/RpgRoller.Tests/Api/SystemApiTests.cs b/RpgRoller.Tests/Api/SystemApiTests.cs index db14a29..569e122 100644 --- a/RpgRoller.Tests/Api/SystemApiTests.cs +++ b/RpgRoller.Tests/Api/SystemApiTests.cs @@ -13,7 +13,9 @@ public sealed class SystemApiTests : ApiTestBase using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); var rulesets = await GetAsync>(client, "/api/rulesets"); - Assert.Equal(2, rulesets.Count); + Assert.Equal(3, rulesets.Count); + var rolemaster = Assert.Single(rulesets, ruleset => ruleset.Id == "rolemaster"); + Assert.Equal("Rolemaster", rolemaster.Name); await RegisterAsync(client, "sse", "Password123", "Sse User"); await LoginAsync(client, "sse", "Password123"); diff --git a/RpgRoller.Tests/Services/DiceRulesTests.cs b/RpgRoller.Tests/Services/DiceRulesTests.cs index 824d49f..b946fca 100644 --- a/RpgRoller.Tests/Services/DiceRulesTests.cs +++ b/RpgRoller.Tests/Services/DiceRulesTests.cs @@ -7,28 +7,49 @@ public sealed class DiceRulesTests { Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); + Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster")); Assert.Null(DiceRules.TryParseRulesetId("unknown")); var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); + var rolemasterInitiative = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d10-15"); + var rolemasterPercentile = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100+4"); + var rolemasterOpenEnded = DiceRules.ParseExpression(RulesetKind.Rolemaster, "1d100!+85"); var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, ""); var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); + var negativeDndModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20-1"); + var invalidRolemasterFormat = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d12+1"); + var tooNegativeRolemasterModifier = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100-1001"); var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1"); Assert.True(d6.Succeeded); Assert.True(dnd.Succeeded); + Assert.True(rolemasterInitiative.Succeeded); + Assert.True(rolemasterPercentile.Succeeded); + Assert.True(rolemasterOpenEnded.Succeeded); Assert.False(emptyExpression.Succeeded); Assert.False(badFormat.Succeeded); Assert.False(tooManyDice.Succeeded); Assert.False(tooManySides.Succeeded); Assert.False(tooLargeModifier.Succeeded); + Assert.False(negativeDndModifier.Succeeded); + Assert.False(invalidRolemasterFormat.Succeeded); + Assert.False(tooNegativeRolemasterModifier.Succeeded); Assert.False(unknownRulesetExpression.Succeeded); + Assert.Equal("2d10-15", rolemasterInitiative.Value!.Canonical); + Assert.Equal(DiceExpressionKind.RolemasterInitiative, rolemasterInitiative.Value.Kind); + Assert.Equal("d100+4", rolemasterPercentile.Value!.Canonical); + Assert.Equal(DiceExpressionKind.RolemasterPercentile, rolemasterPercentile.Value.Kind); + Assert.Equal("d100!+85", rolemasterOpenEnded.Value!.Canonical); + Assert.Equal(DiceExpressionKind.RolemasterOpenEndedPercentile, rolemasterOpenEnded.Value.Kind); + Assert.Equal("d6", DiceRules.ToRulesetId(RulesetKind.D6)); Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); + Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster)); Assert.Throws(() => DiceRules.ToRulesetId((RulesetKind)99)); } -} \ No newline at end of file +} diff --git a/RpgRoller.Tests/Services/ServiceCampaignTests.cs b/RpgRoller.Tests/Services/ServiceCampaignTests.cs index e18a127..2c102da 100644 --- a/RpgRoller.Tests/Services/ServiceCampaignTests.cs +++ b/RpgRoller.Tests/Services/ServiceCampaignTests.cs @@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests service.Register("gm", "Password123", "GM"); var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6")); + var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Rolemaster Name", "rolemaster")); var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown"); Assert.False(invalidRuleset.Succeeded); + Assert.Equal("rolemaster", rolemasterCampaign.RulesetId); var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid()); Assert.False(noCampaignCharacter.Succeeded); diff --git a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs index d43b673..31cb0ea 100644 --- a/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillGroupAndOwnershipTests.cs @@ -174,4 +174,40 @@ public sealed class ServiceSkillGroupAndOwnershipTests var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id)); Assert.Empty(campaignAfterDeletes.Characters); } + + [Fact] + public void RolemasterSkillDefinitions_CanonicalizeAndKeepLegacyNegativeModifierRules() + { + using var harness = ServiceTestSupport.CreateHarness(); + var service = harness.Service; + + service.Register("gm-rm", "Password123", "GM"); + service.Register("owner-rm", "Password123", "Owner"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-rm", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-rm", "Password123")).SessionToken; + + var rolemasterCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Shadow World", "rolemaster")); + var dndCampaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Forgotten Realms", "dnd5e")); + var rolemasterCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Harn", rolemasterCampaign.Id)); + var dndCharacter = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Mage", dndCampaign.Id)); + + var negativeDndSkill = service.CreateSkill(ownerSession, dndCharacter.Id, "Invalid", "1d20-1", 0, false); + Assert.False(negativeDndSkill.Succeeded); + + var rolemasterGroup = ServiceTestSupport.GetValue(service.CreateSkillGroup(ownerSession, rolemasterCharacter.Id, "Initiative", "2d10-15", 3, true)); + Assert.Equal("2d10-15", rolemasterGroup.DiceRollDefinition); + Assert.Equal(0, rolemasterGroup.WildDice); + Assert.False(rolemasterGroup.AllowFumble); + + var percentileSkill = ServiceTestSupport.GetValue(service.CreateSkill(ownerSession, rolemasterCharacter.Id, "Perception", "1d100-20", 4, true)); + Assert.Equal("d100-20", percentileSkill.DiceRollDefinition); + Assert.Equal(0, percentileSkill.WildDice); + Assert.False(percentileSkill.AllowFumble); + + var openEndedSkill = ServiceTestSupport.GetValue(service.UpdateSkill(ownerSession, percentileSkill.Id, "Perception", "1d100!+85", 5, true)); + Assert.Equal("d100!+85", openEndedSkill.DiceRollDefinition); + Assert.Equal(0, openEndedSkill.WildDice); + Assert.False(openEndedSkill.AllowFumble); + } } diff --git a/RpgRoller/Domain/GameModels.cs b/RpgRoller/Domain/GameModels.cs index ff79bfa..9319684 100644 --- a/RpgRoller/Domain/GameModels.cs +++ b/RpgRoller/Domain/GameModels.cs @@ -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); diff --git a/RpgRoller/Services/DiceRules.cs b/RpgRoller/Services/DiceRules.cs index 82d0206..0415e7a 100644 --- a/RpgRoller/Services/DiceRules.cs +++ b/RpgRoller/Services/DiceRules.cs @@ -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.Failure("invalid_ruleset", "Unknown ruleset.") + RulesetKind.D6 => ParseD6(trimmed), + RulesetKind.Dnd5e => ParseDnd5e(trimmed), + RulesetKind.Rolemaster => ParseRolemaster(trimmed), + _ => ServiceResult.Failure("invalid_ruleset", "Unknown ruleset.") }; } @@ -71,7 +76,37 @@ public static partial class DiceRules return ServiceResult.Success(new(diceCount, sides, modifier, $"{diceCount}d{sides}{FormatModifier(modifier)}")); } - private static ServiceResult ValidateDiceParts(int diceCount, int sides, int modifier) + private static ServiceResult 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.Failure(validation.Error!.Code, validation.Error.Message); + + return ServiceResult.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.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.Success(new(1, 100, modifier, canonical, kind)); + } + + return ServiceResult.Failure("invalid_expression", "Expected Rolemaster format like 2d10+48, d100+4, or d100!+85."); + } + + private static ServiceResult ValidateDiceParts(int diceCount, int sides, int modifier, int minModifier = 0, int maxModifier = MaxModifier) { if (diceCount < 1 || diceCount > MaxDiceCount) return ServiceResult.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.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}."); - if (modifier < 0 || modifier > MaxModifier) - return ServiceResult.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}."); + if (modifier < minModifier || modifier > maxModifier) + return ServiceResult.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}."); return ServiceResult.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("^(?\\d+)D(?:\\+(?\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] @@ -101,6 +141,12 @@ public static partial class DiceRules [GeneratedRegex("^(?\\d+)d(?\\d+)(?:\\+(?\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] private static partial Regex Dnd5eRegex(); + [GeneratedRegex("^2d10(?[+-]\\d+)?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)] + private static partial Regex RolemasterInitiativeRegex(); + + [GeneratedRegex("^(?:1)?d100(?!)?(?[+-]\\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") ]; -} \ No newline at end of file +}