Add custom campaign roll composer
This commit is contained in:
@@ -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 <MigrationName> --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`.
|
||||
|
||||
|
||||
@@ -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<CustomRollRequest, RollResult>(playerClient, $"/api/characters/{playerCharacter.Id}/custom-rolls", new("1D+2", "public"));
|
||||
Assert.Equal(Guid.Empty, customRoll.SkillId);
|
||||
|
||||
var customRollLogPage = await GetAsync<CampaignLogPage>(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<ApiError>();
|
||||
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);
|
||||
|
||||
@@ -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<string[]>(entry.EventBadges));
|
||||
|
||||
var log = ServiceTestSupport.GetValue(service.GetCampaignLog(ownerSession, campaign.Id));
|
||||
Assert.Equal("Custom roll", Assert.Single(log).SkillName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<bool> DeleteSkill(string sessionToken, Guid skillId) => throw new NotSupportedException();
|
||||
public ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId) => throw new NotSupportedException();
|
||||
public ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility) => throw new NotSupportedException();
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility) => throw new NotSupportedException();
|
||||
public ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null) => throw new NotSupportedException();
|
||||
public ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId) => throw new NotSupportedException();
|
||||
|
||||
@@ -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<ApiError> ToBadRequest(ServiceError error)
|
||||
{
|
||||
return TypedResults.BadRequest(new ApiError(error.Message));
|
||||
return TypedResults.BadRequest(new ApiError(error.Message, error.Code));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -73,4 +73,30 @@
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
<form class="custom-roll-composer" @onsubmit="SubmitCustomRollAsync" @onsubmit:preventDefault>
|
||||
<div class="custom-roll-composer-head">
|
||||
<label for="custom-roll-expression" class="custom-roll-label">Custom roll</label>
|
||||
<span class="muted">@CustomRollStatusText</span>
|
||||
</div>
|
||||
<div class="custom-roll-composer-row">
|
||||
<input id="custom-roll-expression"
|
||||
@key="CustomRollInputVersion"
|
||||
@ref="CustomRollInputRef"
|
||||
class="@CustomRollInputCssClass"
|
||||
@bind="CustomRollExpression"
|
||||
@bind:event="oninput"
|
||||
placeholder="@CustomRollPlaceholder"
|
||||
title="@CustomRollInputTitle"
|
||||
aria-invalid="@HasCustomRollError"
|
||||
aria-describedby="@CustomRollInputDescribedBy"
|
||||
disabled="@IsCustomRollDisabled"/>
|
||||
<button type="submit" disabled="@IsCustomRollDisabled">Roll</button>
|
||||
</div>
|
||||
<p class="field-help">@CustomRollHelpText</p>
|
||||
@if (HasCustomRollError)
|
||||
{
|
||||
<p id="@CustomRollErrorElementId" class="field-error" role="alert">@CustomRollErrorMessage</p>
|
||||
}
|
||||
</form>
|
||||
</aside>
|
||||
|
||||
@@ -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<CustomRollFormModel> 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<Guid, string?> 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<RollResult> CustomRollCreated { get; set; }
|
||||
|
||||
[Parameter]
|
||||
public EventCallback<string> 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<RollResult>(
|
||||
"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<EventBadgeView> 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"/>
|
||||
</main>
|
||||
<nav class="mobile-bottom-nav" aria-label="Play panel selector">
|
||||
<button type="button" class="switch @(MobilePanel == "character" ? "active" : string.Empty)"
|
||||
|
||||
@@ -706,6 +706,12 @@ public partial class Workspace : IAsyncDisposable
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private Task OnCampaignLogPanelErrorAsync(string message)
|
||||
{
|
||||
SetStatus(message, true);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task RollSkillAsync(Guid skillId)
|
||||
{
|
||||
if (SelectedCampaign is null)
|
||||
@@ -717,15 +723,8 @@ public partial class Workspace : IAsyncDisposable
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
LastRoll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
|
||||
CampaignLogDetails[LastRoll.RollId] = ToCampaignRollDetail(LastRoll);
|
||||
CampaignLogDetailErrors.Remove(LastRoll.RollId);
|
||||
|
||||
await RefreshCampaignLogAsync(CampaignLogCursor);
|
||||
PromoteFreshRoll(LastRoll.RollId);
|
||||
ResetCampaignStateTracking();
|
||||
SetStatus("Roll recorded.", false);
|
||||
Announce("Roll result updated.");
|
||||
var roll = await ApiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(RollVisibility));
|
||||
await HandleRecordedRollAsync(roll);
|
||||
}
|
||||
catch (ApiRequestException ex)
|
||||
{
|
||||
@@ -737,6 +736,32 @@ public partial class Workspace : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
private async Task OnCustomRollCreatedAsync(RollResult roll)
|
||||
{
|
||||
IsMutating = true;
|
||||
try
|
||||
{
|
||||
await HandleRecordedRollAsync(roll);
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsMutating = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task HandleRecordedRollAsync(RollResult roll)
|
||||
{
|
||||
LastRoll = roll;
|
||||
CampaignLogDetails[roll.RollId] = ToCampaignRollDetail(roll);
|
||||
CampaignLogDetailErrors.Remove(roll.RollId);
|
||||
|
||||
await RefreshCampaignLogAsync(CampaignLogCursor);
|
||||
PromoteFreshRoll(roll.RollId);
|
||||
ResetCampaignStateTracking();
|
||||
SetStatus("Roll recorded.", false);
|
||||
Announce("Roll result updated.");
|
||||
}
|
||||
|
||||
private async Task OnRollVisibilityChanged(string visibility)
|
||||
{
|
||||
RollVisibility = NormalizeRollVisibility(visibility);
|
||||
|
||||
@@ -11,6 +11,7 @@ public sealed class RpgRollerApiClient
|
||||
public bool Ok { get; set; }
|
||||
public int Status { get; set; }
|
||||
public string? Error { get; set; }
|
||||
public string? Code { get; set; }
|
||||
public JsonElement Data { get; set; }
|
||||
}
|
||||
|
||||
@@ -23,7 +24,7 @@ public sealed class RpgRollerApiClient
|
||||
{
|
||||
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, payload);
|
||||
if (!response.Ok)
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
|
||||
|
||||
if (response.Data.ValueKind is JsonValueKind.Undefined or JsonValueKind.Null)
|
||||
return default!;
|
||||
@@ -35,7 +36,7 @@ public sealed class RpgRollerApiClient
|
||||
{
|
||||
var response = await m_Js.InvokeAsync<JsApiResponse>("rpgRollerApi.request", method, path, null);
|
||||
if (!response.Ok)
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.");
|
||||
throw new ApiRequestException(response.Status, response.Error ?? "Request failed.", response.Code);
|
||||
}
|
||||
|
||||
private static readonly JsonSerializerOptions JsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
@@ -44,10 +45,12 @@ public sealed class RpgRollerApiClient
|
||||
|
||||
public sealed class ApiRequestException : Exception
|
||||
{
|
||||
public ApiRequestException(int statusCode, string message) : base(message)
|
||||
public ApiRequestException(int statusCode, string message, string? errorCode = null) : base(message)
|
||||
{
|
||||
StatusCode = statusCode;
|
||||
ErrorCode = errorCode;
|
||||
}
|
||||
|
||||
public int StatusCode { get; }
|
||||
public string? ErrorCode { get; }
|
||||
}
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed class WorkspaceQueryService
|
||||
private static ApiRequestException ToApiRequestException(ServiceError error)
|
||||
{
|
||||
var statusCode = error.Code == "unauthorized" ? 401 : 400;
|
||||
return new ApiRequestException(statusCode, error.Message);
|
||||
return new ApiRequestException(statusCode, error.Message, error.Code);
|
||||
}
|
||||
|
||||
private readonly IGameService m_GameService;
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace RpgRoller.Contracts;
|
||||
|
||||
public sealed record HealthResponse(string Status);
|
||||
|
||||
public sealed record ApiError(string Error);
|
||||
public sealed record ApiError(string Error, string? Code = null);
|
||||
|
||||
public sealed record RegisterRequest(string Username, string Password, string DisplayName);
|
||||
|
||||
@@ -50,6 +50,8 @@ public sealed record SkillSummary(Guid Id, Guid CharacterId, Guid? SkillGroupId,
|
||||
|
||||
public sealed record RollSkillRequest(string Visibility);
|
||||
|
||||
public sealed record CustomRollRequest(string Expression, string Visibility);
|
||||
|
||||
public static class RollDieKinds
|
||||
{
|
||||
public const string RolemasterStandard = "rolemaster-standard";
|
||||
|
||||
@@ -29,6 +29,7 @@ namespace RpgRoller.Contracts;
|
||||
[JsonSerializable(typeof(CharacterSummary[]))]
|
||||
[JsonSerializable(typeof(CreateCampaignRequest))]
|
||||
[JsonSerializable(typeof(CreateCharacterRequest))]
|
||||
[JsonSerializable(typeof(CustomRollRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillGroupRequest))]
|
||||
[JsonSerializable(typeof(CreateSkillRequest))]
|
||||
[JsonSerializable(typeof(HealthResponse))]
|
||||
|
||||
@@ -768,26 +768,39 @@ public sealed class GameService : IGameService
|
||||
if (!parsedVisibility.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||
|
||||
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill);
|
||||
var entry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaign.Id,
|
||||
CharacterId = character.Id,
|
||||
SkillId = skill.Id,
|
||||
RollerUserId = user.Id,
|
||||
Visibility = parsedVisibility.Value,
|
||||
Result = roll.Total,
|
||||
Breakdown = roll.Breakdown,
|
||||
Dice = SerializeDice(roll.Dice),
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, skill.WildDice, skill.AllowFumble, skill.FumbleRange);
|
||||
return RecordRollLocked(user, campaign, character, skill.Id, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
|
||||
}
|
||||
}
|
||||
|
||||
m_RollLog.Add(entry);
|
||||
TouchLogLocked(campaign.Id);
|
||||
public ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility)
|
||||
{
|
||||
lock (m_Gate)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
if (user is null)
|
||||
return ServiceResult<RollResult>.Failure("unauthorized", "You must be logged in.");
|
||||
|
||||
PersistStateLocked();
|
||||
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
|
||||
if (!m_CharactersById.TryGetValue(characterId, out var character))
|
||||
return ServiceResult<RollResult>.Failure("character_not_found", "Character was not found.");
|
||||
|
||||
if (!TryResolveCharacterCampaignLocked(character, out var campaign, out var campaignError))
|
||||
return ServiceResult<RollResult>.Failure(campaignError!.Code, campaignError.Message);
|
||||
|
||||
if (!CanEditCharacterLocked(user.Id, character, campaign))
|
||||
return ServiceResult<RollResult>.Failure("forbidden", "Only the owner or GM can make a custom roll for this character.");
|
||||
|
||||
var parsedExpression = DiceRules.ParseExpression(campaign.Ruleset, expression);
|
||||
if (!parsedExpression.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedExpression.Error!.Code, parsedExpression.Error.Message);
|
||||
|
||||
var parsedVisibility = ParseVisibility(visibility);
|
||||
if (!parsedVisibility.Succeeded)
|
||||
return ServiceResult<RollResult>.Failure(parsedVisibility.Error!.Code, parsedVisibility.Error.Message);
|
||||
|
||||
var (wildDice, allowFumble, fumbleRange) = ResolveCustomRollOptions(campaign.Ruleset);
|
||||
var roll = ComputeRoll(campaign.Ruleset, parsedExpression.Value!, wildDice, allowFumble, fumbleRange);
|
||||
return RecordRollLocked(user, campaign, character, CustomRollSkillId, parsedVisibility.Value, roll, parsedExpression.Value!.Canonical);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -940,16 +953,16 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<(int WildDice, bool AllowFumble, int? FumbleRange)>.Success((0, false, null));
|
||||
}
|
||||
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, Skill skill)
|
||||
private (int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) ComputeRoll(RulesetKind ruleset, DiceExpression expression, int wildDice, bool allowFumble, int? fumbleRange)
|
||||
{
|
||||
if (ruleset == RulesetKind.D6)
|
||||
return ComputeD6Roll(expression, skill.WildDice, skill.AllowFumble);
|
||||
return ComputeD6Roll(expression, wildDice, allowFumble);
|
||||
|
||||
if (ruleset == RulesetKind.Rolemaster)
|
||||
{
|
||||
return expression.Kind switch
|
||||
{
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, skill.FumbleRange.GetValueOrDefault()),
|
||||
DiceExpressionKind.RolemasterOpenEndedPercentile => ComputeRolemasterOpenEndedRoll(expression, fumbleRange.GetValueOrDefault()),
|
||||
_ => ComputeRolemasterStandardRoll(expression)
|
||||
};
|
||||
}
|
||||
@@ -1199,6 +1212,52 @@ public sealed class GameService : IGameService
|
||||
return ServiceResult<RollVisibility>.Failure("invalid_visibility", "Visibility must be 'public' or 'private'.");
|
||||
}
|
||||
|
||||
private ServiceResult<RollResult> RecordRollLocked(
|
||||
UserAccount user,
|
||||
Campaign campaign,
|
||||
Character character,
|
||||
Guid skillId,
|
||||
RollVisibility visibility,
|
||||
(int Total, string Breakdown, IReadOnlyList<RollDieResult> Dice) roll,
|
||||
string canonicalExpression)
|
||||
{
|
||||
var entry = new RollLogEntry
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
CampaignId = campaign.Id,
|
||||
CharacterId = character.Id,
|
||||
SkillId = skillId,
|
||||
RollerUserId = user.Id,
|
||||
Visibility = visibility,
|
||||
Result = roll.Total,
|
||||
Breakdown = FormatLoggedBreakdown(skillId, canonicalExpression, roll.Breakdown),
|
||||
Dice = SerializeDice(roll.Dice),
|
||||
TimestampUtc = DateTimeOffset.UtcNow
|
||||
};
|
||||
|
||||
m_RollLog.Add(entry);
|
||||
TouchLogLocked(campaign.Id);
|
||||
|
||||
PersistStateLocked();
|
||||
return ServiceResult<RollResult>.Success(ToRollResult(entry, roll.Dice));
|
||||
}
|
||||
|
||||
private static (int WildDice, bool AllowFumble, int? FumbleRange) ResolveCustomRollOptions(RulesetKind ruleset)
|
||||
{
|
||||
return ruleset switch
|
||||
{
|
||||
RulesetKind.D6 => (DefaultCustomD6WildDice, DefaultCustomD6AllowFumble, null),
|
||||
_ => (0, false, null)
|
||||
};
|
||||
}
|
||||
|
||||
private static string FormatLoggedBreakdown(Guid skillId, string canonicalExpression, string breakdown)
|
||||
{
|
||||
return skillId == CustomRollSkillId
|
||||
? $"{canonicalExpression}{CustomRollBreakdownSeparator}{breakdown}"
|
||||
: breakdown;
|
||||
}
|
||||
|
||||
private ServiceResult<(UserAccount User, Campaign Campaign)> ResolveContextLocked(string sessionToken, Guid campaignId)
|
||||
{
|
||||
var user = ResolveUserLocked(sessionToken);
|
||||
@@ -1324,7 +1383,7 @@ public sealed class GameService : IGameService
|
||||
{
|
||||
var dice = DeserializeDice(entry.Dice);
|
||||
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
|
||||
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
|
||||
var skillName = ResolveLoggedSkillName(entry);
|
||||
var rollerDisplayName = ResolveOwnerDisplayName(entry.RollerUserId);
|
||||
|
||||
return new(
|
||||
@@ -1347,8 +1406,9 @@ public sealed class GameService : IGameService
|
||||
{
|
||||
var dice = DeserializeDice(entry.Dice);
|
||||
var characterName = m_CharactersById.TryGetValue(entry.CharacterId, out var character) ? character.Name : "Unknown character";
|
||||
var skillName = m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
|
||||
var eventBadges = BuildCompactLogEventBadges(campaign, skill, dice);
|
||||
var skillName = ResolveLoggedSkillName(entry);
|
||||
var loggedExpression = ResolveLoggedExpression(entry);
|
||||
var eventBadges = BuildCompactLogEventBadges(campaign, loggedExpression, dice);
|
||||
|
||||
return new(
|
||||
entry.Id,
|
||||
@@ -1418,7 +1478,32 @@ public sealed class GameService : IGameService
|
||||
RollDieKinds.RolemasterOpenEndedLowSubtract;
|
||||
}
|
||||
|
||||
private static string[]? BuildCompactLogEventBadges(Campaign campaign, Skill? skill, IReadOnlyList<RollDieResult> dice)
|
||||
private string ResolveLoggedSkillName(RollLogEntry entry)
|
||||
{
|
||||
if (entry.SkillId == CustomRollSkillId)
|
||||
return CustomRollLabel;
|
||||
|
||||
return m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.Name : "Unknown skill";
|
||||
}
|
||||
|
||||
private string? ResolveLoggedExpression(RollLogEntry entry)
|
||||
{
|
||||
if (entry.SkillId == CustomRollSkillId)
|
||||
return ExtractCustomRollExpression(entry.Breakdown);
|
||||
|
||||
return m_SkillsById.TryGetValue(entry.SkillId, out var skill) ? skill.DiceRollDefinition : null;
|
||||
}
|
||||
|
||||
private static string? ExtractCustomRollExpression(string breakdown)
|
||||
{
|
||||
var separatorIndex = breakdown.IndexOf(CustomRollBreakdownSeparator, StringComparison.Ordinal);
|
||||
if (separatorIndex <= 0)
|
||||
return null;
|
||||
|
||||
return breakdown[..separatorIndex];
|
||||
}
|
||||
|
||||
private static string[]? BuildCompactLogEventBadges(Campaign campaign, string? expression, IReadOnlyList<RollDieResult> dice)
|
||||
{
|
||||
var badges = new List<string>();
|
||||
|
||||
@@ -1429,7 +1514,7 @@ public sealed class GameService : IGameService
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Wild && die.Roll == 1), "w1");
|
||||
break;
|
||||
case RulesetKind.Dnd5e:
|
||||
if (skill is not null && IsSingleD20Expression(skill.DiceRollDefinition))
|
||||
if (!string.IsNullOrWhiteSpace(expression) && IsSingleD20Expression(expression))
|
||||
{
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 20), "n20");
|
||||
AddBadgeIfMissing(badges, dice.Any(die => die.Roll == 1), "n1");
|
||||
@@ -1933,6 +2018,11 @@ public sealed class GameService : IGameService
|
||||
|
||||
private const int CampaignLogHistoryWindowSize = 100;
|
||||
private const int CampaignLogLivePageSize = 25;
|
||||
private const int DefaultCustomD6WildDice = 1;
|
||||
private const bool DefaultCustomD6AllowFumble = true;
|
||||
private const string CustomRollBreakdownSeparator = " => ";
|
||||
private static readonly Guid CustomRollSkillId = Guid.Empty;
|
||||
private const string CustomRollLabel = "Custom roll";
|
||||
private static readonly JsonSerializerOptions DiceJsonOptions = RpgRollerJson.CreateSerializerOptions();
|
||||
private readonly Dictionary<Guid, Campaign> m_CampaignsById = [];
|
||||
private readonly Dictionary<Guid, CampaignStateTracker> m_CampaignStateById = [];
|
||||
|
||||
@@ -37,6 +37,7 @@ public interface IGameService
|
||||
ServiceResult<CharacterSheet> GetCharacterSheet(string sessionToken, Guid characterId);
|
||||
|
||||
ServiceResult<RollResult> RollSkill(string sessionToken, Guid skillId, string visibility);
|
||||
ServiceResult<RollResult> RollCustom(string sessionToken, Guid characterId, string expression, string visibility);
|
||||
ServiceResult<IReadOnlyList<CampaignLogEntry>> GetCampaignLog(string sessionToken, Guid campaignId);
|
||||
ServiceResult<CampaignLogPage> GetCampaignLogPage(string sessionToken, Guid campaignId, Guid? afterRollId = null, int? limit = null);
|
||||
ServiceResult<CampaignRollDetail> GetRollDetail(string sessionToken, Guid rollId);
|
||||
|
||||
@@ -163,7 +163,8 @@ window.rpgRollerApi = (() => {
|
||||
return {
|
||||
ok: false,
|
||||
status: response.status,
|
||||
error: parsed && typeof parsed.error === "string" ? parsed.error : "Request failed."
|
||||
error: parsed && typeof parsed.error === "string" ? parsed.error : "Request failed.",
|
||||
code: parsed && typeof parsed.code === "string" ? parsed.code : null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,12 +206,21 @@ window.rpgRollerApi = (() => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
}
|
||||
|
||||
function clearInputValue(element) {
|
||||
if (!element) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.value = "";
|
||||
}
|
||||
|
||||
return {
|
||||
request,
|
||||
getSessionValue,
|
||||
setSessionValue,
|
||||
startStateEvents,
|
||||
stopStateEvents,
|
||||
scrollElementToBottom
|
||||
scrollElementToBottom,
|
||||
clearInputValue
|
||||
};
|
||||
})();
|
||||
|
||||
@@ -334,6 +334,9 @@ select:focus-visible {
|
||||
|
||||
.log-panel {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.app-play .log-panel {
|
||||
@@ -615,6 +618,63 @@ select:focus-visible {
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.custom-roll-composer {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding-top: 0.2rem;
|
||||
border-top: 1px solid color-mix(in srgb, var(--card-border) 72%, #ffffff 28%);
|
||||
}
|
||||
|
||||
.custom-roll-composer-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.custom-roll-label {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.custom-roll-composer-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.custom-roll-input {
|
||||
min-width: 0;
|
||||
padding: 0.72rem 0.9rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid color-mix(in srgb, var(--card-border) 78%, #ffffff 22%);
|
||||
background: color-mix(in srgb, var(--card) 90%, #ffffff 10%);
|
||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
|
||||
transition: border-color 180ms ease, box-shadow 180ms ease, background-color 180ms ease;
|
||||
}
|
||||
|
||||
.custom-roll-input::placeholder {
|
||||
color: color-mix(in srgb, var(--muted) 80%, #ffffff 20%);
|
||||
}
|
||||
|
||||
.custom-roll-input:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 26%, var(--card-border) 74%);
|
||||
}
|
||||
|
||||
.custom-roll-input.error {
|
||||
border-color: color-mix(in srgb, var(--danger) 74%, #6b2015 26%);
|
||||
background: color-mix(in srgb, #fff0ee 84%, var(--card) 16%);
|
||||
box-shadow: 0 0 0 3px rgba(181, 58, 35, 0.12);
|
||||
}
|
||||
|
||||
.custom-roll-composer-row button {
|
||||
min-width: 5.2rem;
|
||||
border-radius: 999px;
|
||||
padding-inline: 1rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
border: 1px solid color-mix(in srgb, var(--card-border) 84%, #ffffff 16%);
|
||||
border-radius: 0.85rem;
|
||||
@@ -835,6 +895,14 @@ select:focus-visible {
|
||||
margin: 0 0.5rem 0.5rem;
|
||||
padding: 0.65rem 0.7rem 0.7rem;
|
||||
}
|
||||
|
||||
.custom-roll-composer-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.custom-roll-composer-row button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.connection {
|
||||
|
||||
@@ -98,6 +98,38 @@ test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
await expect(expandedEntry.locator(".log-detail .roll-dice-strip")).toBeVisible();
|
||||
});
|
||||
|
||||
test("custom roll composer keeps parse errors inline and records successful rolls", async ({ page, context }) => {
|
||||
const username = `custom-roll-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Custom Roller");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Custom Roll Campaign",
|
||||
rulesetId: "dnd5e"
|
||||
});
|
||||
await postJson(context.request, "/api/characters", {
|
||||
name: "Improviser",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const composer = page.locator(".custom-roll-composer");
|
||||
const input = page.locator("#custom-roll-expression");
|
||||
await input.fill("bad");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).toHaveClass(/error/);
|
||||
await expect(input).toHaveAttribute("title", /Expected dnd5e format like 2d12\+2\./);
|
||||
await expect(page.locator(".toast.error")).toHaveCount(0);
|
||||
|
||||
await input.fill("1d20+5");
|
||||
await composer.getByRole("button", { name: "Roll" }).click();
|
||||
|
||||
await expect(input).not.toHaveClass(/error/);
|
||||
await expect(page.locator(".log-panel .log-entry").first()).toContainText("Custom roll");
|
||||
});
|
||||
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user