.Failure("invalid_expression", "Expression is required.");
+
+ return DiceRules.ParseExpression(RulesetKind.Rolemaster, expression);
+ }
+
+ private static string FormatModifier(int modifier)
+ {
+ return modifier >= 0 ? $"+{modifier}" : modifier.ToString(CultureInfo.InvariantCulture);
+ }
+}
diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
index 8292e9e..f538591 100644
--- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
+++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor
@@ -15,7 +15,8 @@
@skillNameError
}
Expression
-
+
+ @ExpressionHelpText
@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError))
{
@expressionError
@@ -32,7 +33,7 @@
{
@skillGroupError
}
- @if (IsD6)
+ @if (IsD6Ruleset)
{
Wild dice
@@ -44,6 +45,29 @@
Allow fumble
}
+ else if (IsRolemasterRuleset)
+ {
+ Roll type
+
+ Initiative
+ Percentile
+ Open-ended percentile
+
+
+ Modifier
+
+
+ @if (IsRolemasterOpenEndedSelected)
+ {
+ Fumble range
+
+ Used only for low-end open-ended rolls. Allowed range: 0 to 95.
+ @if (FormState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError))
+ {
+ @fumbleRangeError
+ }
+ }
+ }
@SubmitLabel
Cancel
diff --git a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
index 3de3427..d99572b 100644
--- a/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
+++ b/RpgRoller/Components/Pages/HomeControls/SkillFormModal.razor.cs
@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
+using System.Globalization;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
using RpgRoller.Contracts;
@@ -14,11 +15,15 @@ public partial class SkillFormModal
return;
FormState.Model.Name = InitialModel.Name;
+ FormState.Model.RulesetId = RulesetId;
FormState.Model.DiceRollDefinition = InitialModel.DiceRollDefinition;
FormState.Model.SkillGroupId = InitialModel.SkillGroupId;
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;
PendingNameFocus = AutoFocusName;
@@ -43,9 +48,25 @@ public partial class SkillFormModal
if (string.IsNullOrWhiteSpace(FormState.Model.DiceRollDefinition))
FormState.Errors["diceRollDefinition"] = "Expression is required.";
- if (IsD6 && FormState.Model.WildDice < 1)
+ if (IsD6Ruleset && FormState.Model.WildDice < 1)
FormState.Errors["wildDice"] = "D6 skills require at least one wild die.";
+ if (IsRolemasterRuleset)
+ {
+ if (IsRolemasterOpenEndedSelected && !FormState.Model.FumbleRange.HasValue)
+ FormState.Errors["fumbleRange"] = "Open-ended Rolemaster skills require a fumble range.";
+ }
+ else
+ {
+ FormState.Model.FumbleRange = null;
+ }
+
+ if (!IsD6Ruleset)
+ {
+ FormState.Model.WildDice = 0;
+ FormState.Model.AllowFumble = false;
+ }
+
Guid? skillGroupId = null;
if (!string.IsNullOrWhiteSpace(FormState.Model.SkillGroupId))
{
@@ -92,6 +113,77 @@ public partial class SkillFormModal
}
}
+ private void OnExpressionChanged(ChangeEventArgs args)
+ {
+ 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 string ExpressionHelpText => IsRolemasterRuleset
+ ? $"{RulesetFormHelpers.RolemasterExampleText(FormState.Model.RolemasterRollType)}. Negative modifiers are allowed."
+ : "Enter the dice expression used for this skill.";
+
+ private void SynchronizeRulesetSpecificFields()
+ {
+ 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)
+ {
+ FormState.Model.FumbleRange = null;
+ return;
+ }
+
+ if (IsRolemasterOpenEndedSelected)
+ {
+ FormState.Model.FumbleRange ??= 5;
+ return;
+ }
+
+ FormState.Model.FumbleRange = null;
+ }
+
[Inject]
private RpgRollerApiClient ApiClient { get; set; } = null!;
@@ -105,7 +197,7 @@ public partial class SkillFormModal
public bool Visible { get; set; }
[Parameter]
- public bool IsD6 { get; set; }
+ public string RulesetId { get; set; } = string.Empty;
[Parameter]
public string Title { get; set; } = "Skill";
@@ -128,6 +220,15 @@ 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";
+
[Parameter]
public SkillFormModel InitialModel { get; set; } = new();
diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor
index 603cad5..0558269 100644
--- a/RpgRoller/Components/Pages/Workspace.razor
+++ b/RpgRoller/Components/Pages/Workspace.razor
@@ -39,7 +39,7 @@
IsMutating="IsMutating"
SelectedCharacterSkills="PlaySelectedCharacterSkills"
SelectedCharacterSkillGroups="PlaySelectedCharacterSkillGroups"
- IsD6="IsSelectedCampaignD6"
+ SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)"
RollVisibility="RollVisibility"
RollVisibilityChanged="OnRollVisibilityChanged"
OwnerLabel="OwnerLabel"
diff --git a/RpgRoller/Components/Pages/Workspace.razor.cs b/RpgRoller/Components/Pages/Workspace.razor.cs
index 037eabe..7c9071a 100644
--- a/RpgRoller/Components/Pages/Workspace.razor.cs
+++ b/RpgRoller/Components/Pages/Workspace.razor.cs
@@ -920,7 +920,12 @@ public partial class Workspace : IAsyncDisposable
private string SkillDefinitionLabel(CharacterSheetSkill skill)
{
if (!string.Equals(SelectedCampaign?.RulesetId, "d6", StringComparison.OrdinalIgnoreCase))
+ {
+ if (string.Equals(SelectedCampaign?.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
+ return RulesetFormHelpers.DescribeRolemasterExpression(skill.DiceRollDefinition, skill.FumbleRange);
+
return skill.DiceRollDefinition;
+ }
var fumbleLabel = skill.AllowFumble ? "fumble on" : "fumble off";
return $"{skill.DiceRollDefinition}, wild {skill.WildDice}, {fumbleLabel}";
diff --git a/RpgRoller/wwwroot/styles.css b/RpgRoller/wwwroot/styles.css
index afd7927..1b03fa6 100644
--- a/RpgRoller/wwwroot/styles.css
+++ b/RpgRoller/wwwroot/styles.css
@@ -222,6 +222,12 @@ select:focus-visible {
margin: 0;
}
+.field-help {
+ margin: -0.1rem 0 0;
+ color: var(--muted);
+ font-size: 0.85rem;
+}
+
.status-message {
font-weight: 700;
}
diff --git a/tests/e2e/smoke.spec.js b/tests/e2e/smoke.spec.js
index 7b92fd3..895c155 100644
--- a/tests/e2e/smoke.spec.js
+++ b/tests/e2e/smoke.spec.js
@@ -6,6 +6,22 @@ async function postJson(request, url, data) {
return await response.json();
}
+async function registerAndLogin(request, username, displayName) {
+ await postJson(request, "/api/auth/register", {
+ username,
+ password: "Password123",
+ displayName
+ });
+
+ const loginResponse = await request.post("/api/auth/login", {
+ data: {
+ username,
+ password: "Password123"
+ }
+ });
+ expect(loginResponse.ok()).toBeTruthy();
+}
+
test("home page loads auth entry points", async ({ page }) => {
await page.goto("/");
@@ -20,19 +36,7 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
const username = `rm-${Date.now()}`;
const displayName = "Rolemaster Smoke";
- await postJson(context.request, "/api/auth/register", {
- username,
- password: "Password123",
- displayName
- });
-
- const loginResponse = await context.request.post("/api/auth/login", {
- data: {
- username,
- password: "Password123"
- }
- });
- expect(loginResponse.ok()).toBeTruthy();
+ await registerAndLogin(context.request, username, displayName);
const campaign = await postJson(context.request, "/api/campaigns", {
name: "Rolemaster Smoke",
@@ -63,3 +67,67 @@ test("Rolemaster open-ended roll detail renders specialized dice chips", async (
await expect(rolemasterFollowUpDice.first()).toBeVisible();
await expect(page.locator(".log-detail .roll-dice-strip")).toBeVisible();
});
+
+test("Rolemaster UI exposes conditional create and edit fields", async ({ page, context }) => {
+ const username = `rm-ui-${Date.now()}`;
+ await registerAndLogin(context.request, username, "Rolemaster UI");
+
+ const campaign = await postJson(context.request, "/api/campaigns", {
+ name: "Rolemaster UI Campaign",
+ rulesetId: "rolemaster"
+ });
+ const character = await postJson(context.request, "/api/characters", {
+ name: "UI Character",
+ campaignId: campaign.id
+ });
+ await postJson(context.request, `/api/characters/${character.id}/skill-groups`, {
+ name: "Awareness",
+ diceRollDefinition: "d100!+15",
+ wildDice: 0,
+ allowFumble: false,
+ fumbleRange: 5
+ });
+ await postJson(context.request, `/api/characters/${character.id}/skills`, {
+ name: "Perception",
+ diceRollDefinition: "d100!+25",
+ wildDice: 0,
+ allowFumble: false,
+ fumbleRange: 5
+ });
+
+ await page.goto("/");
+ await expect(page.locator("#workspace-screen-menu-button")).toBeVisible();
+
+ await page.locator("#workspace-screen-menu-button").click();
+ await page.getByRole("menuitem", { name: "Campaign Management" }).click();
+ await page.getByRole("button", { name: "Add campaign" }).click();
+ await expect(page.locator("#campaign-ruleset option[value='rolemaster']")).toHaveText("Rolemaster");
+ await page.getByRole("button", { name: "Cancel" }).click();
+
+ await page.locator("#workspace-screen-menu-button").click();
+ 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-fumble-range")).toBeVisible();
+ await page.locator("#skill-group-fumble-range").fill("");
+ await page.getByRole("button", { name: "Create Group" }).click();
+ await expect(page.getByText("Open-ended Rolemaster groups require a fumble range.")).toBeVisible();
+ 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-fumble-range")).toHaveCount(0);
+ await page.locator("#skill-create-rolemaster-roll-type").selectOption("open-ended-percentile");
+ 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.getByRole("button", { name: "Cancel" }).click();
+});