Add custom campaign roll composer

This commit is contained in:
2026-04-04 19:58:00 +02:00
parent 7248b60395
commit 9e6e6fe8c7
21 changed files with 502 additions and 47 deletions

View File

@@ -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`.

View File

@@ -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);

View File

@@ -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);
}
}

View File

@@ -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();

View File

@@ -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));
}
}
}

View File

@@ -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;
}
}

View File

@@ -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";

View File

@@ -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,

View File

@@ -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>

View File

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

View File

@@ -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)"

View File

@@ -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);

View File

@@ -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; }
}

View File

@@ -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;

View File

@@ -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";

View File

@@ -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))]

View File

@@ -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 = [];

View File

@@ -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);

View File

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

View File

@@ -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 {

View File

@@ -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");