diff --git a/README.md b/README.md index 94669ad..988dbcb 100644 --- a/README.md +++ b/README.md @@ -64,6 +64,7 @@ Gameplay capabilities now include: - 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 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 - 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 diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index 4f828f1..306c3a4 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -42,20 +42,26 @@ public sealed class CharacterFormModel public sealed class SkillFormModel { public string Name { get; set; } = string.Empty; + public string RulesetId { get; set; } = string.Empty; public string DiceRollDefinition { get; set; } = string.Empty; public string SkillGroupId { get; set; } = string.Empty; 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 { public string Name { get; set; } = string.Empty; + public string RulesetId { get; set; } = string.Empty; public string DiceRollDefinition { get; set; } = string.Empty; 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 diff --git a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor index 2923d5a..510a13a 100644 --- a/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CharacterPanel.razor @@ -152,13 +152,14 @@ } - + +

@SkillGroupExpressionHelpText

@if (SkillGroupState.Errors.TryGetValue("diceRollDefinition", out var expressionError)) {

@expressionError

} - @if (IsD6) + @if (IsD6Ruleset) { @@ -170,6 +171,29 @@ } + else if (IsRolemasterRuleset) + { + + + + + + + @if (IsSkillGroupRolemasterOpenEnded) + { + + +

Used only for open-ended percentile skills created from this group.

+ @if (SkillGroupState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError)) + { +

@fumbleRangeError

+ } + } + } @if (SkillGroupState.Errors.TryGetValue("character", out var characterError)) { @@ -192,7 +216,7 @@ RulesetFormHelpers.IsD6(SelectedCampaignRulesetId); + private bool IsRolemasterRuleset => RulesetFormHelpers.IsRolemaster(SelectedCampaignRulesetId); + private bool IsSkillGroupRolemasterOpenEnded => string.Equals(SkillGroupState.Model.RolemasterRollType, RulesetFormHelpers.RolemasterRollTypes.OpenEndedPercentile, StringComparison.OrdinalIgnoreCase); + private string SkillGroupExpressionHelpText => IsRolemasterRuleset + ? $"{RulesetFormHelpers.RolemasterExampleText(SkillGroupState.Model.RolemasterRollType)}. Negative modifiers are allowed." + : "Enter the default expression for skills created in this group."; + private bool ShowCreateSkillModal { get; set; } private bool ShowEditSkillModal { get; set; } private bool ShowCreateSkillGroupModal { get; set; } @@ -309,7 +420,7 @@ public partial class CharacterPanel public IReadOnlyList SelectedCharacterSkillGroups { get; set; } = []; [Parameter] - public bool IsD6 { get; set; } + public string SelectedCampaignRulesetId { get; set; } = string.Empty; [Parameter] public string RollVisibility { get; set; } = "public"; diff --git a/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs b/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs new file mode 100644 index 0000000..0802885 --- /dev/null +++ b/RpgRoller/Components/Pages/HomeControls/RulesetFormHelpers.cs @@ -0,0 +1,103 @@ +using System.Diagnostics.CodeAnalysis; +using System.Globalization; +using RpgRoller.Domain; +using RpgRoller.Services; + +namespace RpgRoller.Components.Pages.HomeControls; + +[ExcludeFromCodeCoverage] +internal static class RulesetFormHelpers +{ + internal static class RulesetIds + { + public const string D6 = "d6"; + public const string Dnd5e = "dnd5e"; + 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); + } + + public static bool IsRolemaster(string? rulesetId) + { + return string.Equals(rulesetId, RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase); + } + + public static string InferRolemasterRollType(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)}" + }; + } + + public static string DescribeRolemasterExpression(string expression, int? fumbleRange) + { + var parseResult = TryParseRolemasterExpression(expression); + if (!parseResult.Succeeded || parseResult.Value is null) + return expression; + + 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}" + }; + } + + public static string RolemasterExampleText(string? rollType) + { + return rollType switch + { + RolemasterRollTypes.Percentile => "Example: d100+48", + RolemasterRollTypes.OpenEndedPercentile => "Example: d100!+85", + _ => "Example: 2d10+48" + }; + } + + private static ServiceResult TryParseRolemasterExpression(string? expression) + { + if (string.IsNullOrWhiteSpace(expression)) + return ServiceResult.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

} - + +

@ExpressionHelpText

@if (FormState.Errors.TryGetValue("diceRollDefinition", out var expressionError)) {

@expressionError

@@ -32,7 +33,7 @@ {

@skillGroupError

} - @if (IsD6) + @if (IsD6Ruleset) { @@ -44,6 +45,29 @@ } + else if (IsRolemasterRuleset) + { + + + + + + + @if (IsRolemasterOpenEndedSelected) + { + + +

Used only for low-end open-ended rolls. Allowed range: 0 to 95.

+ @if (FormState.Errors.TryGetValue("fumbleRange", out var fumbleRangeError)) + { +

@fumbleRangeError

+ } + } + }
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(); +});