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

@@ -47,6 +47,7 @@ Backend state persistence:
Gameplay capabilities now include: Gameplay capabilities now include:
- Instant skill filtering in the character panel (filters live while typing and hides non-matching skills/groups) - 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 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 - Skill and skill-group deletion flows
- GM-driven character owner transfer within campaign management 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 - Campaign management supports character deletion by character owner or admin
- Shared top header control across all authenticated workspace screens (play, campaign management, 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`) - 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 ## Prerequisites

View File

@@ -65,6 +65,20 @@ public sealed class CampaignApiTests : ApiTestBase
Assert.Equal(otherCampaign.Id, updatedCharacter.CampaignId); 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<CreateCampaignRequest, CampaignSummary>(gmClient, "/api/campaigns", new("Shadow World", "rolemaster"));
Assert.Equal("rolemaster", campaign.RulesetId);
}
[Fact] [Fact]
public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi() public async Task SkillGroupsAndOwnerTransfer_WorkThroughApi()
{ {

View File

@@ -13,7 +13,9 @@ public sealed class SystemApiTests : ApiTestBase
using var client = factory.CreateClient(new() { AllowAutoRedirect = false }); using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(client, "/api/rulesets"); var rulesets = await GetAsync<IReadOnlyList<RulesetDefinition>>(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 RegisterAsync(client, "sse", "Password123", "Sse User");
await LoginAsync(client, "sse", "Password123"); await LoginAsync(client, "sse", "Password123");

View File

@@ -7,28 +7,49 @@ public sealed class DiceRulesTests
{ {
Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6")); Assert.Equal(RulesetKind.D6, DiceRules.TryParseRulesetId("d6"));
Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e")); Assert.Equal(RulesetKind.Dnd5e, DiceRules.TryParseRulesetId("dnd5e"));
Assert.Equal(RulesetKind.Rolemaster, DiceRules.TryParseRulesetId("rolemaster"));
Assert.Null(DiceRules.TryParseRulesetId("unknown")); Assert.Null(DiceRules.TryParseRulesetId("unknown"));
var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4"); var d6 = DiceRules.ParseExpression(RulesetKind.D6, "5D+4");
var dnd = DiceRules.ParseExpression(RulesetKind.Dnd5e, "2d12+2"); 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 emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc"); var badFormat = DiceRules.ParseExpression(RulesetKind.Dnd5e, "abc");
var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1"); var tooManyDice = DiceRules.ParseExpression(RulesetKind.D6, "51D+1");
var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001"); var tooManySides = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d1001");
var tooLargeModifier = DiceRules.ParseExpression(RulesetKind.Dnd5e, "1d20+1001"); 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"); var unknownRulesetExpression = DiceRules.ParseExpression((RulesetKind)99, "1d20+1");
Assert.True(d6.Succeeded); Assert.True(d6.Succeeded);
Assert.True(dnd.Succeeded); Assert.True(dnd.Succeeded);
Assert.True(rolemasterInitiative.Succeeded);
Assert.True(rolemasterPercentile.Succeeded);
Assert.True(rolemasterOpenEnded.Succeeded);
Assert.False(emptyExpression.Succeeded); Assert.False(emptyExpression.Succeeded);
Assert.False(badFormat.Succeeded); Assert.False(badFormat.Succeeded);
Assert.False(tooManyDice.Succeeded); Assert.False(tooManyDice.Succeeded);
Assert.False(tooManySides.Succeeded); Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.Succeeded); Assert.False(tooLargeModifier.Succeeded);
Assert.False(negativeDndModifier.Succeeded);
Assert.False(invalidRolemasterFormat.Succeeded);
Assert.False(tooNegativeRolemasterModifier.Succeeded);
Assert.False(unknownRulesetExpression.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("d6", DiceRules.ToRulesetId(RulesetKind.D6));
Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e)); Assert.Equal("dnd5e", DiceRules.ToRulesetId(RulesetKind.Dnd5e));
Assert.Equal("rolemaster", DiceRules.ToRulesetId(RulesetKind.Rolemaster));
Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99)); Assert.Throws<ArgumentOutOfRangeException>(() => DiceRules.ToRulesetId((RulesetKind)99));
} }
} }

View File

@@ -14,9 +14,11 @@ public sealed class ServiceCampaignTests
service.Register("gm", "Password123", "GM"); service.Register("gm", "Password123", "GM");
var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken; var gmSession = ServiceTestSupport.GetValue(service.Login("gm", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Name", "d6")); 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"); var invalidRuleset = service.CreateCampaign(gmSession, "Name 2", "unknown");
Assert.False(invalidRuleset.Succeeded); Assert.False(invalidRuleset.Succeeded);
Assert.Equal("rolemaster", rolemasterCampaign.RulesetId);
var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid()); var noCampaignCharacter = service.CreateCharacter(gmSession, "Hero", Guid.NewGuid());
Assert.False(noCampaignCharacter.Succeeded); Assert.False(noCampaignCharacter.Succeeded);

View File

@@ -174,4 +174,40 @@ public sealed class ServiceSkillGroupAndOwnershipTests
var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id)); var campaignAfterDeletes = ServiceTestSupport.GetValue(service.GetCampaign(gmSession, campaign.Id));
Assert.Empty(campaignAfterDeletes.Characters); 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);
}
} }

View File

@@ -3,7 +3,8 @@ namespace RpgRoller.Domain;
public enum RulesetKind public enum RulesetKind
{ {
D6, D6,
Dnd5e Dnd5e,
Rolemaster
} }
public enum RollVisibility public enum RollVisibility
@@ -87,4 +88,12 @@ public sealed class RollLogEntry
public required DateTimeOffset TimestampUtc { get; init; } 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)) if (string.Equals(rulesetId, "dnd5e", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Dnd5e; return RulesetKind.Dnd5e;
if (string.Equals(rulesetId, "rolemaster", StringComparison.OrdinalIgnoreCase))
return RulesetKind.Rolemaster;
return null; return null;
} }
@@ -20,9 +23,10 @@ public static partial class DiceRules
{ {
return ruleset switch return ruleset switch
{ {
RulesetKind.D6 => "d6", RulesetKind.D6 => "d6",
RulesetKind.Dnd5e => "dnd5e", RulesetKind.Dnd5e => "dnd5e",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.") RulesetKind.Rolemaster => "rolemaster",
_ => throw new ArgumentOutOfRangeException(nameof(ruleset), ruleset, "Unknown ruleset.")
}; };
} }
@@ -34,9 +38,10 @@ public static partial class DiceRules
var trimmed = expression.Trim(); var trimmed = expression.Trim();
return ruleset switch return ruleset switch
{ {
RulesetKind.D6 => ParseD6(trimmed), RulesetKind.D6 => ParseD6(trimmed),
RulesetKind.Dnd5e => ParseDnd5e(trimmed), RulesetKind.Dnd5e => ParseDnd5e(trimmed),
_ => ServiceResult<DiceExpression>.Failure("invalid_ruleset", "Unknown ruleset.") 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)}")); 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) if (diceCount < 1 || diceCount > MaxDiceCount)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice count must be between 1 and {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) if (sides < 2 || sides > MaxSides)
return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}."); return ServiceResult<bool>.Failure("invalid_expression", $"Dice sides must be between 2 and {MaxSides}.");
if (modifier < 0 || modifier > MaxModifier) if (modifier < minModifier || modifier > maxModifier)
return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between 0 and {MaxModifier}."); return ServiceResult<bool>.Failure("invalid_expression", $"Modifier must be between {minModifier} and {maxModifier}.");
return ServiceResult<bool>.Success(true); return ServiceResult<bool>.Success(true);
} }
@@ -92,7 +127,12 @@ public static partial class DiceRules
private static string FormatModifier(int modifier) 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)] [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)] [GeneratedRegex("^(?<count>\\d+)d(?<sides>\\d+)(?:\\+(?<modifier>\\d+))?$", RegexOptions.CultureInvariant | RegexOptions.IgnoreCase)]
private static partial Regex Dnd5eRegex(); 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 MaxDiceCount = 50;
private const int MaxSides = 1000; private const int MaxSides = 1000;
private const int MaxModifier = 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 = 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.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")
]; ];
} }