Generalize Rolemaster standard dice parsing

This commit is contained in:
2026-04-03 01:33:32 +02:00
parent e5f00fa693
commit f0dd79e589
17 changed files with 121 additions and 275 deletions

View File

@@ -62,10 +62,10 @@ 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`
- Rolemaster expression validation now accepts generic standard expressions such as `d10`, `2d10+48`, `15d10`, and `d100-15`; `d100!+85` remains the special open-ended percentile form
- Rolemaster open-ended percentile skills and skill-group defaults now persist a nullable `FumbleRange` field, while D6 and D&D rows migrate forward unchanged
- Rolemaster create/edit forms now expose a ruleset-aware roll-type selector with synchronized canonical expressions and conditional `FumbleRange` inputs for open-ended percentile skills and skill groups
- Rolemaster roll execution now supports initiative (`2d10+x`), standard percentile (`d100+x`), and open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange`; low-end trigger rolls are shown for auditability but do not count toward the total
- Rolemaster create/edit forms now keep the expression authoritative, show generic Rolemaster syntax help, and reveal `FumbleRange` only when the expression is an open-ended percentile roll
- Rolemaster roll execution now supports generic standard Rolemaster rolls (`NdS+x`, with implicit count `1` for `dS`) plus open-ended percentile (`d100!+x`) with recursive high-end chaining and low-end subtraction based on `FumbleRange`; low-end trigger rolls are shown for auditability but do not count toward the total
- Compact campaign-log summaries stay dense for Rolemaster rolls, while lazy-loaded roll detail includes ordered die metadata for each open-ended follow-up step
- Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete

View File

@@ -7,7 +7,7 @@ public sealed class RolemasterApiTests : ApiTestBase
}
[Fact]
public async Task RolemasterRollEndpoints_ExecuteInitiativeAndPercentile()
public async Task RolemasterRollEndpoints_ExecuteGenericRolemasterExpressions()
{
using var factory = CreateFactory(8, 6, 74);
using var client = factory.CreateClient(new() { AllowAutoRedirect = false });
@@ -17,7 +17,7 @@ public sealed class RolemasterApiTests : ApiTestBase
var campaign = await PostAsync<CreateCampaignRequest, CampaignSummary>(client, "/api/campaigns", new("Rolemaster", "rolemaster"));
var character = await PostAsync<CreateCharacterRequest, CharacterSummary>(client, "/api/characters", new("Hero", campaign.Id));
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Initiative", "2d10+48", 0, false));
var initiative = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Healing", "2d10+48", 0, false));
var perception = await PostAsync<CreateSkillRequest, SkillSummary>(client, $"/api/characters/{character.Id}/skills", new("Perception", "d100-15", 0, false));
var initiativeRoll = await PostAsync<RollSkillRequest, RollResult>(client, $"/api/skills/{initiative.Id}/roll", new("public"));
@@ -26,15 +26,15 @@ public sealed class RolemasterApiTests : ApiTestBase
Assert.Equal(62, initiativeRoll.Result);
Assert.Equal("8+6+48=62", initiativeRoll.Breakdown);
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind));
Assert.All(initiativeRoll.Dice, die => Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind));
Assert.Equal(59, percentileRoll.Result);
Assert.Equal("74-15=59", percentileRoll.Breakdown);
Assert.Equal(RollDieKinds.RolemasterPercentile, Assert.Single(percentileRoll.Dice).Kind);
Assert.Equal(RollDieKinds.RolemasterStandard, Assert.Single(percentileRoll.Dice).Kind);
Assert.Equal(2, logPage.Entries.Length);
Assert.Equal("8 + 6 | initiative", logPage.Entries[0].SummaryText);
Assert.Equal("74 | percentile", logPage.Entries[1].SummaryText);
Assert.Equal("8 + 6 | rolemaster", logPage.Entries[0].SummaryText);
Assert.Equal("74 | rolemaster", logPage.Entries[1].SummaryText);
}
[Fact]

View File

@@ -12,7 +12,8 @@ public sealed class DiceRulesTests
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 rolemasterImplicitSingle = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d10");
var rolemasterManyDice = DiceRules.ParseExpression(RulesetKind.Rolemaster, "15d10-15");
var rolemasterPercentile = DiceRules.ParseExpression(RulesetKind.Rolemaster, "d100+4");
var rolemasterOpenEnded = DiceRules.ParseExpression(RulesetKind.Rolemaster, "1d100!+85");
var emptyExpression = DiceRules.ParseExpression(RulesetKind.Dnd5e, "");
@@ -21,13 +22,14 @@ public sealed class DiceRulesTests
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 invalidRolemasterOpenEndedFormat = DiceRules.ParseExpression(RulesetKind.Rolemaster, "2d10!+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(rolemasterImplicitSingle.Succeeded);
Assert.True(rolemasterManyDice.Succeeded);
Assert.True(rolemasterPercentile.Succeeded);
Assert.True(rolemasterOpenEnded.Succeeded);
Assert.False(emptyExpression.Succeeded);
@@ -36,14 +38,16 @@ public sealed class DiceRulesTests
Assert.False(tooManySides.Succeeded);
Assert.False(tooLargeModifier.Succeeded);
Assert.False(negativeDndModifier.Succeeded);
Assert.False(invalidRolemasterFormat.Succeeded);
Assert.False(invalidRolemasterOpenEndedFormat.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("d10", rolemasterImplicitSingle.Value!.Canonical);
Assert.Equal(DiceExpressionKind.RolemasterStandard, rolemasterImplicitSingle.Value.Kind);
Assert.Equal("15d10-15", rolemasterManyDice.Value!.Canonical);
Assert.Equal(DiceExpressionKind.RolemasterStandard, rolemasterManyDice.Value.Kind);
Assert.Equal("d100+4", rolemasterPercentile.Value!.Canonical);
Assert.Equal(DiceExpressionKind.RolemasterPercentile, rolemasterPercentile.Value.Kind);
Assert.Equal(DiceExpressionKind.RolemasterStandard, rolemasterPercentile.Value.Kind);
Assert.Equal("d100!+85", rolemasterOpenEnded.Value!.Canonical);
Assert.Equal(DiceExpressionKind.RolemasterOpenEndedPercentile, rolemasterOpenEnded.Value.Kind);

View File

@@ -3,7 +3,7 @@ namespace RpgRoller.Tests;
public sealed class ServiceRolemasterRollTests
{
[Fact]
public void RollSkill_RolemasterInitiative_ComputesTotalAndTagsDice()
public void RollSkill_RolemasterStandardMultiDie_ComputesTotalAndTagsDice()
{
using var harness = ServiceTestSupport.CreateHarness(7, 10);
var service = harness.Service;
@@ -12,34 +12,34 @@ public sealed class ServiceRolemasterRollTests
var session = ServiceTestSupport.GetValue(service.Login("gm-init", "Password123")).SessionToken;
var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(session, "Rolemaster", "rolemaster"));
var character = ServiceTestSupport.GetValue(service.CreateCharacter(session, "Hero", campaign.Id));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Initiative", "2d10+48", 0, false));
var skill = ServiceTestSupport.GetValue(service.CreateSkill(session, character.Id, "Healing", "2d10+48", 0, false));
var roll = ServiceTestSupport.GetValue(service.RollSkill(session, skill.Id, "public"));
var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(session, campaign.Id, limit: 5));
Assert.Equal(65, roll.Result);
Assert.Equal("7+10+48=65", roll.Breakdown);
Assert.Equal("7 + 10 | initiative", Assert.Single(logPage.Entries).SummaryText);
Assert.Equal("7 + 10 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
Assert.Collection(
roll.Dice,
die =>
{
Assert.Equal(7, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(7, die.SignedContribution);
},
die =>
{
Assert.Equal(10, die.Roll);
Assert.Equal(2, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterInitiative, die.Kind);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(10, die.SignedContribution);
});
}
[Fact]
public void RollSkill_RolemasterPercentile_ComputesTotalAndTagsDice()
public void RollSkill_RolemasterStandardSingleDie_ComputesTotalAndTagsDice()
{
using var harness = ServiceTestSupport.CreateHarness(73);
var service = harness.Service;
@@ -55,12 +55,12 @@ public sealed class ServiceRolemasterRollTests
Assert.Equal(58, roll.Result);
Assert.Equal("73-15=58", roll.Breakdown);
Assert.Equal("73 | percentile", Assert.Single(logPage.Entries).SummaryText);
Assert.Equal("73 | rolemaster", Assert.Single(logPage.Entries).SummaryText);
var die = Assert.Single(roll.Dice);
Assert.Equal(73, die.Roll);
Assert.Equal(1, die.Sequence);
Assert.Equal(RollDieKinds.RolemasterPercentile, die.Kind);
Assert.Equal(RollDieKinds.RolemasterStandard, die.Kind);
Assert.Equal(73, die.SignedContribution);
}

View File

@@ -48,8 +48,6 @@ public sealed class SkillFormModel
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
public string RolemasterRollType { get; set; } = HomeControls.RulesetFormHelpers.RolemasterRollTypes.Initiative;
public int RolemasterModifier { get; set; }
}
public sealed class SkillGroupFormModel
@@ -60,8 +58,6 @@ public sealed class SkillGroupFormModel
public int WildDice { get; set; }
public bool AllowFumble { get; set; }
public int? FumbleRange { get; set; }
public string RolemasterRollType { get; set; } = HomeControls.RulesetFormHelpers.RolemasterRollTypes.Initiative;
public int RolemasterModifier { get; set; }
}
public enum HomeViewMode

View File

@@ -173,16 +173,6 @@
}
else if (IsRolemasterRuleset)
{
<label for="skill-group-rolemaster-roll-type">Prototype roll type</label>
<select id="skill-group-rolemaster-roll-type" value="@SkillGroupState.Model.RolemasterRollType" @onchange="OnSkillGroupRolemasterRollTypeChanged">
<option value="@RulesetFormHelpers.RolemasterRollTypes.Initiative">Initiative</option>
<option value="@RulesetFormHelpers.RolemasterRollTypes.Percentile">Percentile</option>
<option value="@RulesetFormHelpers.RolemasterRollTypes.OpenEndedPercentile">Open-ended percentile</option>
</select>
<label for="skill-group-rolemaster-modifier">Prototype modifier</label>
<input id="skill-group-rolemaster-modifier" type="number" step="1" value="@SkillGroupState.Model.RolemasterModifier" @oninput="OnSkillGroupRolemasterModifierChanged"/>
@if (IsSkillGroupRolemasterOpenEnded)
{
<label for="skill-group-fumble-range">Prototype fumble range</label>
@@ -224,8 +214,6 @@
SkillGroupInputId="skill-create-group"
WildDiceInputId="skill-create-wild-dice"
AllowFumbleInputId="skill-create-allow-fumble"
RolemasterRollTypeInputId="skill-create-rolemaster-roll-type"
RolemasterModifierInputId="skill-create-rolemaster-modifier"
FumbleRangeInputId="skill-create-fumble-range"
InitialModel="CreateSkillInitialModel"
FormVersion="CreateSkillFormVersion"
@@ -246,8 +234,6 @@
SkillGroupInputId="skill-edit-group"
WildDiceInputId="skill-edit-wild-dice"
AllowFumbleInputId="skill-edit-allow-fumble"
RolemasterRollTypeInputId="skill-edit-rolemaster-roll-type"
RolemasterModifierInputId="skill-edit-rolemaster-modifier"
FumbleRangeInputId="skill-edit-fumble-range"
InitialModel="EditSkillInitialModel"
FormVersion="EditSkillFormVersion"

View File

@@ -22,13 +22,11 @@ public partial class CharacterPanel
SkillGroupId = selectedGroup?.Id.ToString() ?? string.Empty,
WildDice = selectedGroup?.WildDice ?? (IsD6Ruleset ? 1 : 0),
AllowFumble = selectedGroup?.AllowFumble ?? IsD6Ruleset,
FumbleRange = selectedGroup?.FumbleRange,
RolemasterRollType = RulesetFormHelpers.InferRolemasterRollType(selectedGroup?.DiceRollDefinition),
RolemasterModifier = RulesetFormHelpers.InferRolemasterModifier(selectedGroup?.DiceRollDefinition)
FumbleRange = selectedGroup?.FumbleRange
};
if (IsRolemasterRuleset && string.IsNullOrWhiteSpace(CreateSkillInitialModel.DiceRollDefinition))
CreateSkillInitialModel.DiceRollDefinition = RulesetFormHelpers.BuildRolemasterExpression(CreateSkillInitialModel.RolemasterRollType, CreateSkillInitialModel.RolemasterModifier);
CreateSkillInitialModel.DiceRollDefinition = "d100";
CreateSkillFormVersion++;
ShowCreateSkillModal = true;
@@ -45,9 +43,7 @@ public partial class CharacterPanel
SkillGroupId = skill.SkillGroupId?.ToString() ?? string.Empty,
WildDice = skill.WildDice,
AllowFumble = skill.AllowFumble,
FumbleRange = skill.FumbleRange,
RolemasterRollType = RulesetFormHelpers.InferRolemasterRollType(skill.DiceRollDefinition),
RolemasterModifier = RulesetFormHelpers.InferRolemasterModifier(skill.DiceRollDefinition)
FumbleRange = skill.FumbleRange
};
EditSkillFormVersion++;
@@ -113,10 +109,8 @@ public partial class CharacterPanel
SkillGroupState.Model.WildDice = IsD6Ruleset ? 1 : 0;
SkillGroupState.Model.AllowFumble = IsD6Ruleset;
SkillGroupState.Model.FumbleRange = null;
SkillGroupState.Model.RolemasterRollType = RulesetFormHelpers.RolemasterRollTypes.Initiative;
SkillGroupState.Model.RolemasterModifier = 0;
if (IsRolemasterRuleset)
SynchronizeSkillGroupExpression();
SkillGroupState.Model.DiceRollDefinition = "d100";
SkillGroupState.ResetValidation();
ShowCreateSkillGroupModal = true;
}
@@ -130,8 +124,6 @@ public partial class CharacterPanel
SkillGroupState.Model.WildDice = skillGroup.WildDice;
SkillGroupState.Model.AllowFumble = skillGroup.AllowFumble;
SkillGroupState.Model.FumbleRange = skillGroup.FumbleRange;
SkillGroupState.Model.RolemasterRollType = RulesetFormHelpers.InferRolemasterRollType(skillGroup.DiceRollDefinition);
SkillGroupState.Model.RolemasterModifier = RulesetFormHelpers.InferRolemasterModifier(skillGroup.DiceRollDefinition);
NormalizeSkillGroupFumbleRange();
SkillGroupState.ResetValidation();
ShowEditSkillGroupModal = true;
@@ -325,37 +317,8 @@ public partial class CharacterPanel
{
SkillGroupState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
if (IsRolemasterRuleset)
{
SkillGroupState.Model.RolemasterRollType = RulesetFormHelpers.InferRolemasterRollType(SkillGroupState.Model.DiceRollDefinition);
SkillGroupState.Model.RolemasterModifier = RulesetFormHelpers.InferRolemasterModifier(SkillGroupState.Model.DiceRollDefinition);
NormalizeSkillGroupFumbleRange();
}
}
private void OnSkillGroupRolemasterRollTypeChanged(ChangeEventArgs args)
{
SkillGroupState.Model.RolemasterRollType = args.Value?.ToString() ?? RulesetFormHelpers.RolemasterRollTypes.Initiative;
NormalizeSkillGroupFumbleRange();
SynchronizeSkillGroupExpression();
}
private void OnSkillGroupRolemasterModifierChanged(ChangeEventArgs args)
{
var rawValue = args.Value?.ToString();
if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var modifier))
modifier = 0;
SkillGroupState.Model.RolemasterModifier = modifier;
SynchronizeSkillGroupExpression();
}
private void SynchronizeSkillGroupExpression()
{
if (!IsRolemasterRuleset)
return;
SkillGroupState.Model.DiceRollDefinition = RulesetFormHelpers.BuildRolemasterExpression(SkillGroupState.Model.RolemasterRollType, SkillGroupState.Model.RolemasterModifier);
}
private void NormalizeSkillGroupFumbleRange()
{
@@ -376,9 +339,9 @@ public partial class CharacterPanel
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(SelectedCampaignRulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId);
private bool IsSkillGroupRolemasterOpenEnded => string.Equals(SkillGroupState.Model.RolemasterRollType, RulesetFormHelpers.RolemasterRollTypes.OpenEndedPercentile, StringComparison.OrdinalIgnoreCase);
private bool IsSkillGroupRolemasterOpenEnded => RulesetFormHelpers.IsRolemasterOpenEndedExpression(SkillGroupState.Model.DiceRollDefinition);
private string SkillGroupExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText(SkillGroupState.Model.RolemasterRollType)}. Negative modifiers are allowed."
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the default expression for skills created in this group.";
private bool ShowCreateSkillModal { get; set; }

View File

@@ -54,6 +54,9 @@ public partial class RollDiceStrip
switch (die.Kind)
{
case RollDieKinds.RolemasterStandard:
classes.Add("rolemaster-standard");
break;
case RollDieKinds.RolemasterInitiative:
classes.Add("rolemaster-initiative");
break;
@@ -97,6 +100,9 @@ public partial class RollDiceStrip
switch (die.Kind)
{
case RollDieKinds.RolemasterStandard:
labels.Add("Rolemaster roll");
break;
case RollDieKinds.RolemasterInitiative:
labels.Add("Rolemaster initiative");
break;

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using RpgRoller.Domain;
using RpgRoller.Services;
@@ -15,13 +14,6 @@ internal static class RulesetFormHelpers
public const string Rolemaster = "rolemaster";
}
internal static class RolemasterRollTypes
{
public const string Initiative = "initiative";
public const string Percentile = "percentile";
public const string OpenEndedPercentile = "open-ended-percentile";
}
public static bool IsD6(string? rulesetId)
{
return string.Equals(rulesetId, RulesetIds.D6, StringComparison.OrdinalIgnoreCase);
@@ -32,34 +24,12 @@ internal static class RulesetFormHelpers
return string.Equals(rulesetId, RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase);
}
public static string InferRolemasterRollType(string? expression)
public static bool IsRolemasterOpenEndedExpression(string? expression)
{
var parseResult = TryParseRolemasterExpression(expression);
if (!parseResult.Succeeded || parseResult.Value is null)
return RolemasterRollTypes.Initiative;
return parseResult.Value.Kind switch
{
DiceExpressionKind.RolemasterPercentile => RolemasterRollTypes.Percentile,
DiceExpressionKind.RolemasterOpenEndedPercentile => RolemasterRollTypes.OpenEndedPercentile,
_ => RolemasterRollTypes.Initiative
};
}
public static int InferRolemasterModifier(string? expression)
{
var parseResult = TryParseRolemasterExpression(expression);
return parseResult.Succeeded && parseResult.Value is not null ? parseResult.Value.Modifier : 0;
}
public static string BuildRolemasterExpression(string? rollType, int modifier)
{
return rollType switch
{
RolemasterRollTypes.Percentile => $"d100{FormatModifier(modifier)}",
RolemasterRollTypes.OpenEndedPercentile => $"d100!{FormatModifier(modifier)}",
_ => $"2d10{FormatModifier(modifier)}"
};
return parseResult.Succeeded &&
parseResult.Value is not null &&
parseResult.Value.Kind == DiceExpressionKind.RolemasterOpenEndedPercentile;
}
public static string DescribeRolemasterExpression(string expression, int? fumbleRange)
@@ -70,22 +40,16 @@ internal static class RulesetFormHelpers
return parseResult.Value.Kind switch
{
DiceExpressionKind.RolemasterPercentile => $"Percentile: {parseResult.Value.Canonical}",
DiceExpressionKind.RolemasterOpenEndedPercentile => fumbleRange.HasValue
? $"Open-ended percentile: {parseResult.Value.Canonical}, fumble <= {fumbleRange.Value}"
: $"Open-ended percentile: {parseResult.Value.Canonical}",
_ => $"Initiative: {parseResult.Value.Canonical}"
_ => $"Rolemaster: {parseResult.Value.Canonical}"
};
}
public static string RolemasterExampleText(string? rollType)
public static string RolemasterExampleText()
{
return rollType switch
{
RolemasterRollTypes.Percentile => "Example: d100+48",
RolemasterRollTypes.OpenEndedPercentile => "Example: d100!+85",
_ => "Example: 2d10+48"
};
return "Examples: d10, 15d10, d100-15, d100!+85";
}
private static ServiceResult<DiceExpression> TryParseRolemasterExpression(string? expression)
@@ -95,9 +59,4 @@ internal static class RulesetFormHelpers
return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
}
private static string FormatModifier(int modifier)
{
return modifier >= 0 ? $"+{modifier}" : modifier.ToString(CultureInfo.InvariantCulture);
}
}

View File

@@ -47,16 +47,6 @@
}
else if (IsRolemasterRuleset)
{
<label for="@RolemasterRollTypeInputId">Roll type</label>
<select id="@RolemasterRollTypeInputId" value="@FormState.Model.RolemasterRollType" @onchange="OnRolemasterRollTypeChanged">
<option value="@RulesetFormHelpers.RolemasterRollTypes.Initiative">Initiative</option>
<option value="@RulesetFormHelpers.RolemasterRollTypes.Percentile">Percentile</option>
<option value="@RulesetFormHelpers.RolemasterRollTypes.OpenEndedPercentile">Open-ended percentile</option>
</select>
<label for="@RolemasterModifierInputId">Modifier</label>
<input id="@RolemasterModifierInputId" type="number" step="1" value="@FormState.Model.RolemasterModifier" @oninput="OnRolemasterModifierChanged"/>
@if (IsRolemasterOpenEndedSelected)
{
<label for="@FumbleRangeInputId">Fumble range</label>

View File

@@ -1,5 +1,4 @@
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using RpgRoller.Contracts;
@@ -21,8 +20,6 @@ public partial class SkillFormModal
FormState.Model.WildDice = InitialModel.WildDice;
FormState.Model.AllowFumble = InitialModel.AllowFumble;
FormState.Model.FumbleRange = InitialModel.FumbleRange;
FormState.Model.RolemasterRollType = InitialModel.RolemasterRollType;
FormState.Model.RolemasterModifier = InitialModel.RolemasterModifier;
SynchronizeRulesetSpecificFields();
FormState.ResetValidation();
AppliedFormVersion = FormVersion;
@@ -117,31 +114,14 @@ public partial class SkillFormModal
{
FormState.Model.DiceRollDefinition = args.Value?.ToString() ?? string.Empty;
if (IsRolemasterRuleset)
SynchronizeRolemasterInputsFromExpression();
}
private void OnRolemasterRollTypeChanged(ChangeEventArgs args)
{
FormState.Model.RolemasterRollType = args.Value?.ToString() ?? RulesetFormHelpers.RolemasterRollTypes.Initiative;
NormalizeRolemasterFumbleRange();
SynchronizeRolemasterExpression();
}
private void OnRolemasterModifierChanged(ChangeEventArgs args)
{
var rawValue = args.Value?.ToString();
if (!int.TryParse(rawValue, NumberStyles.Integer, CultureInfo.InvariantCulture, out var modifier))
modifier = 0;
FormState.Model.RolemasterModifier = modifier;
SynchronizeRolemasterExpression();
}
private bool IsD6Ruleset => RulesetFormHelpers.IsD6(RulesetId);
private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(RulesetId);
private bool IsRolemasterOpenEndedSelected => string.Equals(FormState.Model.RolemasterRollType, RulesetFormHelpers.RolemasterRollTypes.OpenEndedPercentile, StringComparison.OrdinalIgnoreCase);
private bool IsRolemasterOpenEndedSelected => RulesetFormHelpers.IsRolemasterOpenEndedExpression(FormState.Model.DiceRollDefinition);
private string ExpressionHelpText => IsRolemasterRuleset
? $"{RulesetFormHelpers.RolemasterExampleText(FormState.Model.RolemasterRollType)}. Negative modifiers are allowed."
? $"{RulesetFormHelpers.RolemasterExampleText()}. Negative modifiers are allowed."
: "Enter the dice expression used for this skill.";
private void SynchronizeRulesetSpecificFields()
@@ -149,24 +129,9 @@ public partial class SkillFormModal
if (!IsRolemasterRuleset)
return;
SynchronizeRolemasterInputsFromExpression();
NormalizeRolemasterFumbleRange();
}
private void SynchronizeRolemasterInputsFromExpression()
{
FormState.Model.RolemasterRollType = RulesetFormHelpers.InferRolemasterRollType(FormState.Model.DiceRollDefinition);
FormState.Model.RolemasterModifier = RulesetFormHelpers.InferRolemasterModifier(FormState.Model.DiceRollDefinition);
}
private void SynchronizeRolemasterExpression()
{
if (!IsRolemasterRuleset)
return;
FormState.Model.DiceRollDefinition = RulesetFormHelpers.BuildRolemasterExpression(FormState.Model.RolemasterRollType, FormState.Model.RolemasterModifier);
}
private void NormalizeRolemasterFumbleRange()
{
if (!IsRolemasterRuleset)
@@ -220,12 +185,6 @@ public partial class SkillFormModal
[Parameter]
public string AllowFumbleInputId { get; set; } = "skill-fumble";
[Parameter]
public string RolemasterRollTypeInputId { get; set; } = "skill-rolemaster-roll-type";
[Parameter]
public string RolemasterModifierInputId { get; set; } = "skill-rolemaster-modifier";
[Parameter]
public string FumbleRangeInputId { get; set; } = "skill-fumble-range";

View File

@@ -52,6 +52,7 @@ public sealed record RollSkillRequest(string Visibility);
public static class RollDieKinds
{
public const string RolemasterStandard = "rolemaster-standard";
public const string RolemasterInitiative = "rolemaster-initiative";
public const string RolemasterPercentile = "rolemaster-percentile";
public const string RolemasterOpenEndedInitial = "rolemaster-open-ended-initial";

View File

@@ -93,8 +93,7 @@ public sealed class RollLogEntry
public enum DiceExpressionKind
{
Standard,
RolemasterInitiative,
RolemasterPercentile,
RolemasterStandard,
RolemasterOpenEndedPercentile
}

View File

@@ -78,32 +78,30 @@ public static partial class DiceRules
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);
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, 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 isOpenEnded = match.Groups["openEnded"].Success;
if (isOpenEnded && (diceCount != 1 || sides != 100))
{
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",
"Open-ended Rolemaster rolls must use d100! with an implicit or explicit dice count of 1.");
}
return ServiceResult<DiceExpression>.Failure("invalid_expression", "Expected Rolemaster format like 2d10+48, d100+4, or d100!+85.");
var countPrefix = diceCount == 1 ? string.Empty : diceCount.ToString();
var canonical = $"{countPrefix}d{sides}{(isOpenEnded ? "!" : string.Empty)}{FormatModifier(modifier)}";
var kind = isOpenEnded ? DiceExpressionKind.RolemasterOpenEndedPercentile : DiceExpressionKind.RolemasterStandard;
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)
@@ -141,11 +139,8 @@ 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();
[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;
@@ -155,6 +150,6 @@ public static partial class DiceRules
[
(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", "2d10+48, d100+4, d100!+85")
(RulesetKind.Rolemaster, "rolemaster", "Rolemaster", "countdSides(+/-modifier), e.g. d10, 15d10, d100-15, d100!+85")
];
}

View File

@@ -949,8 +949,7 @@ public sealed class GameService : IGameService
{
return expression.Kind switch
{
DiceExpressionKind.RolemasterInitiative => ComputeRolemasterInitiativeRoll(expression),
DiceExpressionKind.RolemasterPercentile => ComputeRolemasterPercentileRoll(expression),
DiceExpressionKind.RolemasterStandard => ComputeRolemasterStandardRoll(expression),
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, skill.FumbleRange.GetValueOrDefault()),
_ => ComputeStandardRoll(expression)
};
@@ -975,7 +974,7 @@ public sealed class GameService : IGameService
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterInitiativeRoll(DiceExpression expression)
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterStandardRoll(DiceExpression expression)
{
var diceValues = new int[expression.DiceCount];
var dice = new RollDieResult[expression.DiceCount];
@@ -984,25 +983,13 @@ public sealed class GameService : IGameService
{
var value = m_DiceRoller.Roll(expression.Sides);
diceValues[i] = value;
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterInitiative, value);
dice[i] = CreateRolemasterDie(value, i + 1, RollDieKinds.RolemasterStandard, value);
total += value;
}
return (total, BuildBreakdown(diceValues, expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterPercentileRoll(DiceExpression expression)
{
var roll = m_DiceRoller.Roll(expression.Sides);
var total = roll + expression.Modifier;
var dice = new[]
{
CreateRolemasterDie(roll, 1, RollDieKinds.RolemasterPercentile, roll)
};
return (total, BuildBreakdown([roll], expression.Modifier, total), dice);
}
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRolemasterOpenEndedRoll(DiceExpression expression, int fumbleRange)
{
var initialRoll = m_DiceRoller.Roll(expression.Sides);
@@ -1427,6 +1414,15 @@ public sealed class GameService : IGameService
return $"{openEndedInitial.Roll} | open-ended";
}
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterStandard, StringComparison.Ordinal)))
{
var preview = string.Join(" + ", dice.Take(3).Select(die => die.Roll.ToString()));
if (dice.Count > 3)
preview = $"{preview} + ...";
return $"{preview} | rolemaster";
}
if (dice.Any(die => string.Equals(die.Kind, RollDieKinds.RolemasterInitiative, StringComparison.Ordinal)))
return $"{string.Join(" + ", dice.Select(die => die.Roll.ToString()))} | initiative";
@@ -1438,7 +1434,8 @@ public sealed class GameService : IGameService
private static bool IsRolemasterDieKind(string? kind)
{
return kind is RollDieKinds.RolemasterInitiative or
return kind is RollDieKinds.RolemasterStandard or
RollDieKinds.RolemasterInitiative or
RollDieKinds.RolemasterPercentile or
RollDieKinds.RolemasterOpenEndedInitial or
RollDieKinds.RolemasterOpenEndedHigh or

View File

@@ -2,13 +2,12 @@
## Goal
Extend the app with a new `rolemaster` ruleset that supports three Rolemaster-oriented skill roll types:
Extend the app with a new `rolemaster` ruleset that supports generic standard Rolemaster expressions plus the special open-ended percentile case:
1. Initiative: `2d10 + x`
2. Standard percentile: non-open-ended `1d100 + x`
3. Open-ended percentile: `1d100 + x` with Rolemaster low-end and high-end behavior
1. Standard expressions: `NdS + x`, with an implicit dice count of `1` when no digit appears before `d`
2. Open-ended percentile: `d100! + x` with Rolemaster low-end and high-end behavior
The initial scope is only roll definition, validation, execution, logging, and UI support for these three roll types. This does not include Rolemaster attack tables, critical tables, resistance rolls, maneuver tables, or character sheet math beyond the explicit roll modifier entered for a skill.
The initial scope is only roll definition, validation, execution, logging, and UI support for generic standard Rolemaster rolls and open-ended percentile rolls. This does not include Rolemaster attack tables, critical tables, resistance rolls, maneuver tables, or character sheet math beyond the explicit roll modifier entered for a skill.
## Scope Assumptions
@@ -19,8 +18,7 @@ The initial scope is only roll definition, validation, execution, logging, and U
- If the first roll is `96` or higher, roll another high-ended `d100` recursively and add it.
- If the first roll is less than or equal to the skill's configured fumble range, roll another high-ended `d100` recursively and subtract it.
- Recursive follow-up rolls are high-ended only.
- Standard percentile rolls do not open-end and do not use a fumble range.
- Initiative rolls are simple `2d10 + x` rolls with no open-ended behavior.
- Standard Rolemaster rolls do not open-end and do not use a fumble range.
- The modifier `x` should be an integer.
- Negative modifiers are supported for Rolemaster only.
- D6 and D&D 5e keep their current non-negative modifier rules unless separately re-scoped in a future change.
@@ -35,11 +33,12 @@ The initial scope is only roll definition, validation, execution, logging, and U
- Roll breakdown text format for low-ended subtraction: `12 (03) -97 -44 +15 = -114`
- Skill groups need to support Rolemaster defaults for all of these fields, to stay consistent with current prototype behavior
- Rolemaster uses expression parsing with canonical syntax:
- initiative: `2d10+48`
- standard roll with implicit count: `d10`
- standard roll with explicit count: `15d10`
- standard percentile: `d100+4`
- open-ended percentile: `d100!+85`
- negative modifiers are valid only for Rolemaster, for example `d100-15`
- `15d10` e.g. for spells indicating "heals 1d10/lvl hit points".
- open-ended syntax is only valid for `d100!`
## Architecture Guardrails
@@ -108,12 +107,9 @@ Affected areas:
Current validation is split between generic expression parsing and D6 option validation. Rolemaster needs its own validation layer.
Validation rules to add:
- `RolemasterInitiative`
- fixed base roll `2d10`
- integer modifier required
- no fumble range
- `RolemasterPercentile`
- fixed base roll `1d100`
- `RolemasterStandard`
- supports generic `NdS` syntax with any supported side count
- assumes a dice count of `1` when the count is omitted before `d`
- integer modifier required
- no fumble range
- `RolemasterOpenEndedPercentile`
@@ -130,13 +126,14 @@ Tasks:
- Replace or extend `ValidateSkillDefinition`.
- Extend `DiceRules.ParseExpression` with a Rolemaster-focused parser/validator path.
- Add canonical Rolemaster expression parsing rules:
- `2d10+48` and `2d10-15` for initiative
- `d10`, `2d10+48`, and `15d10-15` for generic standard rolls
- `d100+4` and `d100-20` for standard percentile
- `d100!+85` and `d100!-15` for open-ended percentile
- reject open-ended syntax for non-percentile or multi-die expressions such as `2d10!+1`
- Keep Rolemaster fumble range validation separate from the expression because it is configured independently.
- Add canonical display formatting for Rolemaster skills, for example:
- `Initiative: 2d10+15`
- `Percentile: 1d100+48`
- `Rolemaster: d10`
- `Rolemaster: 15d10+15`
- `Open-ended percentile: OE 1d100+48, fumble <= 5`
- Add explicit regression tests that D6 / D&D still reject negative modifiers after Rolemaster support lands.
@@ -154,8 +151,7 @@ Add a new Rolemaster execution path alongside the existing D6 and standard roll
Recommended internal shape:
- `ComputeRoll` dispatches by ruleset and roll kind.
- Add dedicated methods:
- `ComputeRolemasterInitiativeRoll`
- `ComputeRolemasterPercentileRoll`
- `ComputeRolemasterStandardRoll`
- `ComputeRolemasterOpenEndedRoll`
- helper `RollRolemasterHighOpenEndedChain`
@@ -231,14 +227,9 @@ Tasks:
Recommended frontend model changes:
- Introduce ruleset-aware form state rather than raw D6 toggles.
- Keep "Expression" as the primary input, but provide Rolemaster-specific help text, examples, and validation when the selected campaign ruleset is `rolemaster`.
- Add a "Roll type" selector with options:
- `Initiative`
- `Percentile`
- `Open-ended percentile`
- Keep the selector and expression input synchronized so users can either choose a type or see the parsed/canonical expression clearly.
- Show fumble range input only for open-ended percentile.
- Use progressive disclosure so only the fields relevant to the chosen Rolemaster roll type are shown.
- Keep "Expression" as the primary input, and provide Rolemaster-specific help text, examples, and validation when the selected campaign ruleset is `rolemaster`.
- Show fumble range input only when the current Rolemaster expression is an open-ended percentile roll.
- Use progressive disclosure so only the fields relevant to the current Rolemaster expression are shown.
- Add inline validation on blur and submit, visible required indicators, and `aria-live` / alert semantics for validation errors.
- Preserve the existing app visual language instead of introducing a separate Rolemaster-specific theme.
@@ -287,7 +278,7 @@ Service-level tests to add:
- rejection of D6-only options on Rolemaster skills
- rejection of negative modifiers on D6 / D&D skills
- acceptance of negative modifiers on Rolemaster expressions
- initiative roll math
- generic standard Rolemaster roll math
- standard percentile roll math
- open-ended high roll with one extra roll
- open-ended high roll with recursive extra rolls
@@ -350,7 +341,7 @@ Recommended implementation order:
- Keep existing D6 / D&D behavior green.
#### Iteration 3: roll engine
- Implement initiative and standard percentile execution.
- Implement generic standard and standard percentile execution.
- Implement open-ended percentile execution with recursive high-end chaining and low-end subtraction.
- Extend die-result metadata and breakdown formatting.
- Add focused service tests for roll math and payload/detail serialization compatibility.
@@ -382,10 +373,9 @@ Recommended implementation order:
- A campaign can be created with ruleset id `rolemaster`.
- Rolemaster skill groups and skills can be created and edited through API and UI.
- Supported Rolemaster roll types are limited to:
- initiative `2d10 + x`
- standard percentile `1d100 + x`
- open-ended percentile `1d100 + x` with fumble range
- Supported Rolemaster roll types are:
- generic standard rolls `NdS + x`, with implicit dice count `1` for `dS`
- open-ended percentile `d100! + x` with fumble range
- Open-ended percentile rolls correctly:
- add recursively on first-roll `96+`
- subtract a recursive high-end chain on first-roll `<= fumble range`

View File

@@ -108,9 +108,9 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
await page.getByRole("menuitem", { name: "Play" }).click();
await page.getByRole("button", { name: "Add group" }).click();
await expect(page.locator("#skill-group-rolemaster-roll-type")).toBeVisible();
await expect(page.locator("#skill-group-wild-dice")).toHaveCount(0);
await page.locator("#skill-group-rolemaster-roll-type").selectOption("open-ended-percentile");
await expect(page.locator("#skill-group-expression")).toHaveValue("d100");
await page.locator("#skill-group-expression").fill("d100!+15");
await expect(page.locator("#skill-group-fumble-range")).toBeVisible();
await page.locator("#skill-group-fumble-range").fill("");
await page.getByRole("button", { name: "Create Group" }).click();
@@ -118,16 +118,17 @@ test("Rolemaster UI exposes conditional create and edit fields", async ({ page,
await page.getByRole("button", { name: "Cancel" }).click();
await page.getByRole("button", { name: "Add skill" }).first().click();
await expect(page.locator("#skill-create-rolemaster-roll-type")).toBeVisible();
await page.locator("#skill-create-rolemaster-roll-type").selectOption("percentile");
await expect(page.locator("#skill-create-expression")).toHaveValue("d100!+15");
await page.locator("#skill-create-expression").fill("15d10");
await expect(page.locator("#skill-create-fumble-range")).toHaveCount(0);
await page.locator("#skill-create-rolemaster-roll-type").selectOption("open-ended-percentile");
await page.locator("#skill-create-expression").fill("d100!+25");
await expect(page.locator("#skill-create-fumble-range")).toBeVisible();
await page.getByRole("button", { name: "Cancel" }).click();
await page.locator("button[title='Edit skill']").first().click();
await expect(page.locator("#skill-edit-expression")).toHaveValue("d100!+25");
await expect(page.locator("#skill-edit-rolemaster-roll-type")).toHaveValue("open-ended-percentile");
await expect(page.locator("#skill-edit-fumble-range")).toHaveValue("5");
await page.locator("#skill-edit-expression").fill("d10");
await expect(page.locator("#skill-edit-fumble-range")).toHaveCount(0);
await page.getByRole("button", { name: "Cancel" }).click();
});