Add custom campaign roll composer
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user