Add rolemaster situational modifier modal

This commit is contained in:
2026-04-14 23:53:07 +02:00
parent 368a9a4960
commit 3e1d3746dd
12 changed files with 314 additions and 13 deletions

View File

@@ -74,9 +74,9 @@ public partial class CharacterPanel
await RollVisibilityChanged.InvokeAsync(selectedVisibility);
}
private async Task RollSkillAsync(Guid skillId)
private async Task RollSkillAsync(CharacterSheetSkill skill)
{
await RollRequested.InvokeAsync(skillId);
await RollRequested.InvokeAsync(skill);
}
private Task OnAddSkillRequestedAsync(Guid? skillGroupId)
@@ -408,5 +408,5 @@ public partial class CharacterPanel
public EventCallback<string> ErrorOccurred { get; set; }
[Parameter]
public EventCallback<Guid> RollRequested { get; set; }
public EventCallback<CharacterSheetSkill> RollRequested { get; set; }
}

View File

@@ -0,0 +1,34 @@
@if (Visible)
{
<div class="modal-overlay" role="presentation" @onclick="HandleOverlayClickAsync">
<section class="modal-card rolemaster-roll-modal"
role="dialog"
aria-modal="true"
aria-label="Rolemaster situational modifier"
tabindex="-1"
@onclick:stopPropagation="true"
@onkeydown="HandleKeyDownAsync">
<h2>Rolemaster skill roll</h2>
<p class="muted">Roll <strong>@SkillName</strong> using <code>@Expression</code>.</p>
@if (!string.IsNullOrWhiteSpace(ErrorMessage))
{
<p class="form-error">@ErrorMessage</p>
}
<form class="form-grid" @onsubmit="SubmitAsync" @onsubmit:preventDefault>
<label for="@ModifierInputId">Situational modifier</label>
<input id="@ModifierInputId"
@ref="ModifierInputElement"
value="@CurrentModifierText"
@oninput="OnModifierInput"
placeholder="Blank = 0"
inputmode="numeric"
autocomplete="off"/>
<p class="field-help">Optional one-shot bonus or penalty. Examples: <code>20</code>, <code>-15</code>, or blank for <code>0</code>.</p>
<div class="inline-actions">
<button type="submit" disabled="@(IsMutating || IsSubmitting)">Roll</button>
<button type="button" class="ghost" disabled="@(IsMutating || IsSubmitting)" @onclick="CancelRequested">Cancel</button>
</div>
</form>
</section>
</div>
}

View File

@@ -0,0 +1,96 @@
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Web;
namespace RpgRoller.Components.Pages.HomeControls;
[ExcludeFromCodeCoverage]
public partial class RolemasterSkillRollModal
{
protected override void OnParametersSet()
{
CurrentModifierText = ModifierText;
if (!Visible || WasVisible)
{
WasVisible = Visible;
return;
}
PendingFocus = true;
WasVisible = true;
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (!Visible || !PendingFocus)
return;
PendingFocus = false;
await ModifierInputElement.FocusAsync();
}
private Task OnModifierInput(ChangeEventArgs args)
{
CurrentModifierText = args.Value?.ToString() ?? string.Empty;
return ModifierTextChanged.InvokeAsync(CurrentModifierText);
}
private Task SubmitAsync()
{
return ConfirmRequested.InvokeAsync(CurrentModifierText);
}
private Task HandleOverlayClickAsync()
{
if (IsMutating || IsSubmitting)
return Task.CompletedTask;
return CancelRequested.InvokeAsync();
}
private Task HandleKeyDownAsync(KeyboardEventArgs args)
{
if ((IsMutating || IsSubmitting) || !string.Equals(args.Key, "Escape", StringComparison.Ordinal))
return Task.CompletedTask;
return CancelRequested.InvokeAsync();
}
private bool PendingFocus { get; set; }
private bool WasVisible { get; set; }
private string CurrentModifierText { get; set; } = string.Empty;
private ElementReference ModifierInputElement { get; set; }
[Parameter]
public bool Visible { get; set; }
[Parameter]
public string SkillName { get; set; } = string.Empty;
[Parameter]
public string Expression { get; set; } = string.Empty;
[Parameter]
public string ModifierText { get; set; } = string.Empty;
[Parameter]
public EventCallback<string> ModifierTextChanged { get; set; }
[Parameter]
public string? ErrorMessage { get; set; }
[Parameter]
public bool IsMutating { get; set; }
[Parameter]
public bool IsSubmitting { get; set; }
[Parameter]
public string ModifierInputId { get; set; } = "rolemaster-situational-modifier";
[Parameter]
public EventCallback<string> ConfirmRequested { get; set; }
[Parameter]
public EventCallback CancelRequested { get; set; }
}

View File

@@ -52,7 +52,7 @@
class="chip-button"
title="Roll skill"
disabled="@(IsMutating)"
@onclick="() => RollSkillRequested.InvokeAsync(skill.Id)">
@onclick="() => RollSkillRequested.InvokeAsync(skill)">
<span aria-hidden="true" class="emoji">🎲</span>
<span class="sr-only">Roll @skill.Name</span>
</button>

View File

@@ -47,7 +47,7 @@ public partial class SkillGroupBlock
public EventCallback<CharacterSheetSkill> EditSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> RollSkillRequested { get; set; }
public EventCallback<CharacterSheetSkill> RollSkillRequested { get; set; }
[Parameter]
public EventCallback<Guid> DeleteSkillRequested { get; set; }

View File

@@ -216,4 +216,16 @@
AllowOwnerEdit="State.CanEditCharacterOwner"
AvailableUsernames="State.KnownUsernames"
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
CancelRequested="Campaigns.CloseCharacterModals"/>
CancelRequested="Campaigns.CloseCharacterModals"/>
<RolemasterSkillRollModal
Visible="State.ShowRolemasterSkillRollModal"
SkillName="@(State.PendingRolemasterSkillRoll?.Name ?? string.Empty)"
Expression="@(State.PendingRolemasterSkillRoll?.DiceRollDefinition ?? string.Empty)"
ModifierText="@State.PendingRolemasterSituationalModifier"
ModifierTextChanged="@(text => State.PendingRolemasterSituationalModifier = text)"
ErrorMessage="@State.PendingRolemasterSkillRollError"
IsMutating="State.IsMutating"
IsSubmitting="State.IsSubmittingRolemasterSkillRoll"
ConfirmRequested="Play.SubmitRolemasterSkillRollAsync"
CancelRequested="Play.CancelRolemasterSkillRollAsync"/>

View File

@@ -1,4 +1,5 @@
using System.Diagnostics.CodeAnalysis;
using RpgRoller.Components.Pages.HomeControls;
using RpgRoller.Contracts;
namespace RpgRoller.Components.Pages;
@@ -143,23 +144,71 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
return Task.CompletedTask;
}
public async Task RollSkillAsync(Guid skillId)
public Task RollSkillAsync(CharacterSheetSkill skill)
{
if (state.SelectedCampaign is null)
{
feedback.SetStatus("No campaign selected.", true);
return Task.CompletedTask;
}
if (string.Equals(state.SelectedCampaign.RulesetId, RulesetFormHelpers.RulesetIds.Rolemaster, StringComparison.OrdinalIgnoreCase))
{
OpenRolemasterSkillRollModal(skill);
return Task.CompletedTask;
}
return ExecuteSkillRollAsync(skill.Id, 0);
}
public async Task SubmitRolemasterSkillRollAsync(string situationalModifierText)
{
if (state.PendingRolemasterSkillRoll is null)
return;
if (!TryParseSituationalModifier(situationalModifierText, out var situationalModifier, out var errorMessage))
{
state.PendingRolemasterSkillRollError = errorMessage;
return;
}
state.PendingRolemasterSituationalModifier = situationalModifierText;
state.PendingRolemasterSkillRollError = null;
state.IsSubmittingRolemasterSkillRoll = true;
try
{
await ExecuteSkillRollAsync(state.PendingRolemasterSkillRoll.Id, situationalModifier, keepModalOpenOnError: true);
}
finally
{
state.IsSubmittingRolemasterSkillRoll = false;
}
}
public Task CancelRolemasterSkillRollAsync()
{
if (state.IsSubmittingRolemasterSkillRoll)
return Task.CompletedTask;
CloseRolemasterSkillRollModal();
return Task.CompletedTask;
}
private async Task ExecuteSkillRollAsync(Guid skillId, int situationalModifier, bool keepModalOpenOnError = false)
{
state.IsMutating = true;
try
{
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility));
var roll = await apiClient.RequestAsync<RollResult>("POST", $"/api/skills/{skillId}/roll", new RollSkillRequest(state.RollVisibility, situationalModifier));
CloseRolemasterSkillRollModal();
await HandleRecordedRollAsync(roll);
}
catch (ApiRequestException ex)
{
feedback.SetStatus(ex.Message, true);
if (keepModalOpenOnError)
state.PendingRolemasterSkillRollError = ex.Message;
else
feedback.SetStatus(ex.Message, true);
}
finally
{
@@ -217,6 +266,48 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
state.CurrentCampaignState = null;
}
private void OpenRolemasterSkillRollModal(CharacterSheetSkill skill)
{
state.PendingRolemasterSkillRoll = skill;
state.PendingRolemasterSituationalModifier = string.Empty;
state.PendingRolemasterSkillRollError = null;
state.ShowRolemasterSkillRollModal = true;
}
private void CloseRolemasterSkillRollModal()
{
state.ShowRolemasterSkillRollModal = false;
state.PendingRolemasterSkillRoll = null;
state.PendingRolemasterSituationalModifier = string.Empty;
state.PendingRolemasterSkillRollError = null;
state.IsSubmittingRolemasterSkillRoll = false;
}
private static bool TryParseSituationalModifier(string? text, out int situationalModifier, out string? errorMessage)
{
if (string.IsNullOrWhiteSpace(text))
{
situationalModifier = 0;
errorMessage = null;
return true;
}
if (!int.TryParse(text.Trim(), out situationalModifier))
{
errorMessage = "Enter a whole number like 20, -15, or leave blank for 0.";
return false;
}
if (situationalModifier is < -MaxSituationalModifier or > MaxSituationalModifier)
{
errorMessage = $"Enter a whole number between {-MaxSituationalModifier} and {MaxSituationalModifier}.";
return false;
}
errorMessage = null;
return true;
}
private async Task EnsureSelectedCharacterActiveCoreAsync()
{
if (!state.SelectedCharacterId.HasValue || state.SelectedCampaign is null)
@@ -312,4 +403,5 @@ public sealed class WorkspacePlayCoordinator(WorkspaceState state, WorkspaceFeed
}
private const int CampaignLogWindowSize = 25;
private const int MaxSituationalModifier = 1000;
}

View File

@@ -67,8 +67,13 @@ public sealed class WorkspaceState
public bool ShowCreateCharacterModal { get; set; }
public bool ShowEditCharacterModal { get; set; }
public bool ShowRolemasterSkillRollModal { get; set; }
public bool CanEditCharacterOwner { get; set; }
public Guid? EditingCharacterId { get; set; }
public CharacterSheetSkill? PendingRolemasterSkillRoll { get; set; }
public string PendingRolemasterSituationalModifier { get; set; } = string.Empty;
public string? PendingRolemasterSkillRollError { get; set; }
public bool IsSubmittingRolemasterSkillRoll { get; set; }
public CharacterFormModel CreateCharacterInitialModel { get; set; } = new();
public CharacterFormModel EditCharacterInitialModel { get; set; } = new();
public int CreateCharacterFormVersion { get; set; }

View File

@@ -1064,6 +1064,10 @@ select:focus-visible {
gap: 0.65rem;
}
.rolemaster-roll-modal {
width: min(28rem, 100%);
}
.mobile-bottom-nav {
position: fixed;
left: 0;