From 9e6e6fe8c7467649744d0a4fcfa1b3c9e746a6ef Mon Sep 17 00:00:00 2001 From: Frank Tovar Date: Sat, 4 Apr 2026 19:58:00 +0200 Subject: [PATCH] Add custom campaign roll composer --- README.md | 2 + RpgRoller.Tests/Api/RollVisibilityApiTests.cs | 13 ++ .../Services/ServiceSkillRollTests.cs | 39 +++++ .../Services/WorkspaceQueryServiceTests.cs | 2 + RpgRoller/Api/ApiResultMapper.cs | 6 +- RpgRoller/Api/SkillEndpoints.cs | 6 + RpgRoller/Api/StateEventEndpoints.cs | 4 +- RpgRoller/Components/Pages/Home.Models.cs | 5 + .../Pages/HomeControls/CampaignLogPanel.razor | 26 ++++ .../HomeControls/CampaignLogPanel.razor.cs | 121 +++++++++++++++ RpgRoller/Components/Pages/Workspace.razor | 9 +- RpgRoller/Components/Pages/Workspace.razor.cs | 43 ++++-- RpgRoller/Components/RpgRollerApiClient.cs | 9 +- RpgRoller/Components/WorkspaceQueryService.cs | 2 +- RpgRoller/Contracts/ApiContracts.cs | 4 +- .../RpgRollerJsonSerializerContext.cs | 1 + RpgRoller/Services/GameService.cs | 142 ++++++++++++++---- RpgRoller/Services/IGameService.cs | 1 + RpgRoller/wwwroot/js/rpgroller-api.js | 14 +- RpgRoller/wwwroot/styles.css | 68 +++++++++ tests/e2e/smoke.spec.js | 32 ++++ 21 files changed, 502 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 4aed18d..e3c92d5 100644 --- a/README.md +++ b/README.md @@ -67,6 +67,7 @@ Gameplay capabilities now include: - 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 +- Play screen campaign logs now include a bottom-mounted custom roll composer that records arbitrary expressions against the selected character without creating a skill; invalid expressions stay inline on the field with tooltip/error styling instead of using error toasts - Startup migration coverage is validated against a copied temp-file instance of `RpgRoller/App_Data/rpgroller.development.db` before feature work is considered complete ## Prerequisites @@ -130,6 +131,7 @@ dotnet dotnet-ef migrations add --project RpgRoller/RpgRoller.cs - Workspace campaign data is loaded in bounded slices: visible campaign summaries, a selected campaign roster, a selected character sheet, and a 25-row incremental log window backed by `/api/campaigns/{campaignId}/log/page`. - Campaign log rows now ship compact summary data first, including structured special-event badges for wild dice spikes, natural 1/20 results, and Rolemaster rare/fumble triggers, and lazy-load dice + breakdown detail through `/api/rolls/{rollId}` only when a row is expanded. - Newly appended campaign-log rolls auto-expand in the play workspace, with the roll response reused as the initial detail payload for the local roller to avoid an extra detail fetch. +- The campaign log footer supports custom roll submission for the currently selected character; D6 custom rolls use the baseline one-wild-die/fumble behavior, while D&D 5e and Rolemaster rely only on the submitted expression. - Hot API contracts share a source-generated `System.Text.Json` context, and HTTP JSON responses are gzip-compressed when the client advertises support. - OpenAPI contract source remains at `openapi/RpgRoller.json`. diff --git a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs index ecf2859..1eaf1ba 100644 --- a/RpgRoller.Tests/Api/RollVisibilityApiTests.cs +++ b/RpgRoller.Tests/Api/RollVisibilityApiTests.cs @@ -75,6 +75,19 @@ public sealed class RollVisibilityApiTests : ApiTestBase var invalidVisibility = await playerClient.PostAsJsonAsync($"/api/skills/{skill.Id}/roll", new RollSkillRequest("hidden")); Assert.Equal(HttpStatusCode.BadRequest, invalidVisibility.StatusCode); + var customRoll = await PostAsync(playerClient, $"/api/characters/{playerCharacter.Id}/custom-rolls", new("1D+2", "public")); + Assert.Equal(Guid.Empty, customRoll.SkillId); + + var customRollLogPage = await GetAsync(observerClient, $"/api/campaigns/{campaign.Id}/log/page"); + Assert.Equal(2, customRollLogPage.Entries.Length); + Assert.Equal("Custom roll", customRollLogPage.Entries[1].SkillName); + + var invalidCustomRollResponse = await playerClient.PostAsJsonAsync($"/api/characters/{playerCharacter.Id}/custom-rolls", new CustomRollRequest("bad", "public")); + Assert.Equal(HttpStatusCode.BadRequest, invalidCustomRollResponse.StatusCode); + var invalidCustomRoll = await invalidCustomRollResponse.Content.ReadFromJsonAsync(); + Assert.NotNull(invalidCustomRoll); + Assert.Equal("invalid_expression", invalidCustomRoll.Code); + using var anonymousClient = factory.CreateClient(new() { AllowAutoRedirect = false }); var unauthorizedCampaignCreate = await anonymousClient.PostAsJsonAsync("/api/campaigns", new CreateCampaignRequest("Nope", "d6")); Assert.Equal(HttpStatusCode.Unauthorized, unauthorizedCampaignCreate.StatusCode); diff --git a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs index 3795240..c2797ca 100644 --- a/RpgRoller.Tests/Services/ServiceSkillRollTests.cs +++ b/RpgRoller.Tests/Services/ServiceSkillRollTests.cs @@ -225,4 +225,43 @@ public sealed class ServiceSkillRollTests Assert.False(outsiderPublicDetail.Succeeded); Assert.Equal("roll_not_found", outsiderPublicDetail.Error!.Code); } + + [Fact] + public void CustomRoll_UsesCampaignRuleset_AndAppearsAsCustomRollInLog() + { + using var harness = ServiceTestSupport.CreateHarness(20); + var service = harness.Service; + + service.Register("gm-custom", "Password123", "GM"); + service.Register("owner-custom", "Password123", "Owner"); + service.Register("other-custom", "Password123", "Other"); + + var gmSession = ServiceTestSupport.GetValue(service.Login("gm-custom", "Password123")).SessionToken; + var ownerSession = ServiceTestSupport.GetValue(service.Login("owner-custom", "Password123")).SessionToken; + var otherSession = ServiceTestSupport.GetValue(service.Login("other-custom", "Password123")).SessionToken; + + var campaign = ServiceTestSupport.GetValue(service.CreateCampaign(gmSession, "Custom", "dnd5e")); + var character = ServiceTestSupport.GetValue(service.CreateCharacter(ownerSession, "Hero", campaign.Id)); + + var invalidExpression = service.RollCustom(ownerSession, character.Id, "bad", "public"); + Assert.False(invalidExpression.Succeeded); + Assert.Equal("invalid_expression", invalidExpression.Error!.Code); + + var forbiddenRoll = service.RollCustom(otherSession, character.Id, "1d20+5", "public"); + Assert.False(forbiddenRoll.Succeeded); + Assert.Equal("forbidden", forbiddenRoll.Error!.Code); + + var customRoll = ServiceTestSupport.GetValue(service.RollCustom(ownerSession, character.Id, "1d20+5", "private")); + Assert.Equal(Guid.Empty, customRoll.SkillId); + Assert.StartsWith("1d20+5 => ", customRoll.Breakdown, StringComparison.Ordinal); + + var logPage = ServiceTestSupport.GetValue(service.GetCampaignLogPage(gmSession, campaign.Id, limit: 5)); + var entry = Assert.Single(logPage.Entries); + Assert.Equal("Custom roll", entry.SkillName); + Assert.Equal("Private (GM view)", entry.VisibilityLabel); + Assert.Contains("n20", Assert.IsType(entry.EventBadges)); + + var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id)); + Assert.Equal("Custom roll", Assert.Single(log).SkillName); + } } diff --git a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs index 3f2fa2b..cb01781 100644 --- a/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs +++ b/RpgRoller.Tests/Services/WorkspaceQueryServiceTests.cs @@ -50,6 +50,7 @@ public sealed class WorkspaceQueryServiceTests Assert.Equal(401, exception.StatusCode); Assert.Equal("You must be logged in.", exception.Message); + Assert.Equal("unauthorized", exception.ErrorCode); } private static WorkspaceSessionTokenAccessor CreateSessionTokenAccessor(string sessionToken) @@ -95,6 +96,7 @@ public sealed class WorkspaceQueryServiceTests public ServiceResult DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException(); public ServiceResult GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException(); public ServiceResult RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException(); + public ServiceResult RollCustom(string sessionToken, Guid characterId, string expression, string visibility) => throw new NotSupportedException(); public ServiceResult> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException(); public ServiceResult GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException(); public ServiceResult GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException(); diff --git a/RpgRoller/Api/ApiResultMapper.cs b/RpgRoller/Api/ApiResultMapper.cs index 2d05e1a..48a2d36 100644 --- a/RpgRoller/Api/ApiResultMapper.cs +++ b/RpgRoller/Api/ApiResultMapper.cs @@ -14,11 +14,11 @@ internal static class ApiResultMapper if (result.Error!.Code == "unauthorized") return TypedResults.Unauthorized(); - return TypedResults.BadRequest(new ApiError(result.Error.Message)); + return TypedResults.BadRequest(new ApiError(result.Error.Message, result.Error.Code)); } public static BadRequest ToBadRequest(ServiceError error) { - return TypedResults.BadRequest(new ApiError(error.Message)); + return TypedResults.BadRequest(new ApiError(error.Message, error.Code)); } -} \ No newline at end of file +} diff --git a/RpgRoller/Api/SkillEndpoints.cs b/RpgRoller/Api/SkillEndpoints.cs index d10440f..10824d7 100644 --- a/RpgRoller/Api/SkillEndpoints.cs +++ b/RpgRoller/Api/SkillEndpoints.cs @@ -49,6 +49,12 @@ internal static class SkillEndpoints return ApiResultMapper.ToApiResult(result); }); + group.MapPost("/characters/{characterId:guid}/custom-rolls", (Guid characterId, CustomRollRequest request, HttpContext context, IGameService game) => + { + var result = game.RollCustom(context.GetRequiredSessionToken(), characterId, request.Expression, request.Visibility); + return ApiResultMapper.ToApiResult(result); + }); + return group; } } diff --git a/RpgRoller/Api/StateEventEndpoints.cs b/RpgRoller/Api/StateEventEndpoints.cs index b682f53..8ccae98 100644 --- a/RpgRoller/Api/StateEventEndpoints.cs +++ b/RpgRoller/Api/StateEventEndpoints.cs @@ -13,7 +13,9 @@ internal static class StateEventEndpoints var stateResult = game.GetCampaignStateSnapshot(sessionToken, campaignId); if (!stateResult.Succeeded) { - return stateResult.Error!.Code == "unauthorized" ? TypedResults.Unauthorized() : TypedResults.BadRequest(new ApiError(stateResult.Error.Message)); + return stateResult.Error!.Code == "unauthorized" + ? TypedResults.Unauthorized() + : TypedResults.BadRequest(new ApiError(stateResult.Error.Message, stateResult.Error.Code)); } context.Response.Headers.CacheControl = "no-cache"; diff --git a/RpgRoller/Components/Pages/Home.Models.cs b/RpgRoller/Components/Pages/Home.Models.cs index a88ca5d..0b08ad3 100644 --- a/RpgRoller/Components/Pages/Home.Models.cs +++ b/RpgRoller/Components/Pages/Home.Models.cs @@ -60,6 +60,11 @@ public sealed class SkillGroupFormModel public int? FumbleRange { get; set; } } +public sealed class CustomRollFormModel +{ + public string Expression { get; set; } = string.Empty; +} + public enum HomeViewMode { Loading, diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor index 28b9829..adc2f75 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor @@ -73,4 +73,30 @@ } } + +
+
+ + @CustomRollStatusText +
+
+ + +
+

@CustomRollHelpText

+ @if (HasCustomRollError) + { + + } +
diff --git a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs index 2c24c74..d818dde 100644 --- a/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs +++ b/RpgRoller/Components/Pages/HomeControls/CampaignLogPanel.razor.cs @@ -1,6 +1,7 @@ using System.Diagnostics.CodeAnalysis; using Microsoft.AspNetCore.Components; using Microsoft.JSInterop; +using RpgRoller.Components; using RpgRoller.Contracts; namespace RpgRoller.Components.Pages.HomeControls; @@ -39,9 +40,16 @@ public partial class CampaignLogPanel [Inject] private IJSRuntime JS { get; set; } = null!; + [Inject] + private RpgRollerApiClient ApiClient { get; set; } = null!; + private ElementReference LogPanelRef { get; set; } + private ElementReference CustomRollInputRef { get; set; } private int LastRenderedLogCount { get; set; } private Guid? LastRenderedLogRollId { get; set; } + private int CustomRollInputVersion { get; set; } + private FormState CustomRollState { get; } = new(); + private bool IsSubmittingCustomRoll { get; set; } [Parameter] public bool IsCampaignDataLoading { get; set; } @@ -67,6 +75,83 @@ public partial class CampaignLogPanel [Parameter] public Func GetRollDetailError { get; set; } = _ => null; + [Parameter] + public Guid? SelectedCharacterId { get; set; } + + [Parameter] + public string? SelectedCharacterName { get; set; } + + [Parameter] + public string SelectedCampaignRulesetId { get; set; } = string.Empty; + + [Parameter] + public string RollVisibility { get; set; } = "public"; + + [Parameter] + public bool IsMutating { get; set; } + + [Parameter] + public EventCallback CustomRollCreated { get; set; } + + [Parameter] + public EventCallback ErrorOccurred { get; set; } + + private async Task SubmitCustomRollAsync() + { + CustomRollState.ResetValidation(); + + var expression = CustomRollState.Model.Expression.Trim(); + if (string.IsNullOrWhiteSpace(expression)) + { + SetCustomRollError("Enter a roll expression first."); + return; + } + + if (!SelectedCharacterId.HasValue) + { + SetCustomRollError("Select a character to make a custom roll."); + return; + } + + IsSubmittingCustomRoll = true; + try + { + var roll = await ApiClient.RequestAsync( + "POST", + $"/api/characters/{SelectedCharacterId.Value}/custom-rolls", + new + { + expression, + visibility = NormalizedRollVisibility + }); + + CustomRollState.Model.Expression = string.Empty; + CustomRollState.ResetValidation(); + CustomRollInputVersion += 1; + await CustomRollCreated.InvokeAsync(roll); + await JS.InvokeVoidAsync("rpgRollerApi.clearInputValue", CustomRollInputRef); + await InvokeAsync(StateHasChanged); + } + catch (ApiRequestException ex) when (string.Equals(ex.ErrorCode, "invalid_expression", StringComparison.Ordinal)) + { + SetCustomRollError(ex.Message); + await InvokeAsync(StateHasChanged); + } + catch (ApiRequestException ex) + { + await ErrorOccurred.InvokeAsync(ex.Message); + } + finally + { + IsSubmittingCustomRoll = false; + } + } + + private void SetCustomRollError(string message) + { + CustomRollState.Errors["expression"] = message; + } + private static IReadOnlyList GetEventBadges(CampaignLogListEntry entry) { return (entry.EventBadges ?? []) @@ -109,4 +194,40 @@ public partial class CampaignLogPanel } private sealed record EventBadgeView(string Label, string Tone); + + private bool HasCustomRollError => CustomRollState.Errors.ContainsKey("expression"); + private string? CustomRollErrorMessage => CustomRollState.Errors.GetValueOrDefault("expression"); + private bool IsCustomRollDisabled => IsCampaignDataLoading || IsMutating || IsSubmittingCustomRoll || !SelectedCharacterId.HasValue; + private string CustomRollInputCssClass => HasCustomRollError ? "custom-roll-input error" : "custom-roll-input"; + private string? CustomRollInputTitle => HasCustomRollError ? CustomRollErrorMessage : null; + private string CustomRollErrorElementId => "custom-roll-expression-error"; + private string? CustomRollInputDescribedBy => HasCustomRollError ? CustomRollErrorElementId : null; + private string CustomRollPlaceholder => SelectedCampaignRulesetId.ToLowerInvariant() switch + { + RulesetFormHelpers.RulesetIds.D6 => "e.g. 5D+4", + RulesetFormHelpers.RulesetIds.Dnd5e => "e.g. 2d12+2", + RulesetFormHelpers.RulesetIds.Rolemaster => "e.g. d10, 15d10, d100!+85", + _ => "Enter a roll expression" + }; + private string CustomRollStatusText => SelectedCharacterId.HasValue && !string.IsNullOrWhiteSpace(SelectedCharacterName) + ? $"For {SelectedCharacterName} • {RollVisibilityLabel}" + : "Select a character to enable"; + private string CustomRollHelpText => SelectedCampaignRulesetId.ToLowerInvariant() switch + { + RulesetFormHelpers.RulesetIds.D6 => "Uses the campaign ruleset. D6 custom rolls use one wild die and allow fumbles.", + RulesetFormHelpers.RulesetIds.Rolemaster => $"{RulesetFormHelpers.RolemasterExampleText()}. Logged for the selected character.", + _ => "Uses the selected campaign ruleset and current visibility." + }; + private string RollVisibilityLabel => string.Equals(NormalizedRollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "Private" : "Public"; + private string NormalizedRollVisibility => string.Equals(RollVisibility, "private", StringComparison.OrdinalIgnoreCase) ? "private" : "public"; + private string CustomRollExpression + { + get => CustomRollState.Model.Expression; + set + { + CustomRollState.Model.Expression = value; + if (HasCustomRollError) + CustomRollState.ResetValidation(); + } + } } diff --git a/RpgRoller/Components/Pages/Workspace.razor b/RpgRoller/Components/Pages/Workspace.razor index fef1106..05a461b 100644 --- a/RpgRoller/Components/Pages/Workspace.razor +++ b/RpgRoller/Components/Pages/Workspace.razor @@ -62,10 +62,17 @@ CampaignLog="PlayVisibleCampaignLog" ExpandedRollId="ExpandedCampaignLogRollId" FreshRollId="FreshCampaignLogRollId" + SelectedCharacterId="PlaySelectedCharacterId" + SelectedCharacterName="@(PlaySelectedCharacter?.Name)" + SelectedCampaignRulesetId="@(PlaySelectedCampaign?.RulesetId ?? string.Empty)" + RollVisibility="RollVisibility" + IsMutating="IsMutating" ToggleRollDetailRequested="ToggleRollDetailAsync" ResolveRollDetail="ResolveRollDetail" IsRollDetailLoading="IsRollDetailLoading" - GetRollDetailError="GetRollDetailError"/> + GetRollDetailError="GetRollDetailError" + CustomRollCreated="OnCustomRollCreatedAsync" + ErrorOccurred="OnCampaignLogPanelErrorAsync"/>