Add rolemaster situational modifier modal
This commit is contained in:
@@ -81,7 +81,8 @@ Rolemaster support:
|
||||
- Open-ended percentile expressions such as `d100!+85`
|
||||
- Conditional `FumbleRange` handling for open-ended percentile skills and skill-group defaults
|
||||
- Persisted and validated automatic retry toggle for open-ended percentile skills; only eligible Rolemaster skills can enable it
|
||||
- Backend/API support for one-shot situational modifiers on Rolemaster skill rolls; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
||||
- Rolemaster skill rolls open a modal prompt before rolling so the player can apply a one-shot situational modifier; the prompt autofocuses, supports Enter and Escape, and closes when clicking outside it
|
||||
- One-shot situational modifiers are transient Rolemaster-only roll inputs; the temporary modifier is applied to both the first attempt and any automatic retry attempt
|
||||
- Automatic retry windows for eligible open-ended skills: results `76-90` retry once with `+5`, and results `91-110` retry once with `+10`
|
||||
- Open-ended high chaining and low-end subtraction with ordered die metadata in roll detail
|
||||
- Compact log badges and summaries for open-ended, retry, and fumble-related events, including `Retry +5` and `Retry +10`
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -217,3 +217,15 @@
|
||||
AvailableUsernames="State.KnownUsernames"
|
||||
CharacterSaved="Campaigns.OnCharacterUpdatedAsync"
|
||||
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"/>
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using RpgRoller.Components.Pages.HomeControls;
|
||||
using RpgRoller.Contracts;
|
||||
|
||||
namespace RpgRoller.Components.Pages;
|
||||
@@ -143,22 +144,70 @@ 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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@@ -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; }
|
||||
|
||||
@@ -1064,6 +1064,10 @@ select:focus-visible {
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.rolemaster-roll-modal {
|
||||
width: min(28rem, 100%);
|
||||
}
|
||||
|
||||
.mobile-bottom-nav {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
|
||||
10
TASKS.md
10
TASKS.md
@@ -14,9 +14,9 @@ The important user-visible rule is that this temporary modifier must be applied
|
||||
|
||||
- [x] (2026-04-14 21:27:50Z) Created the initial ExecPlan in `TASKS.md`, grounded in the current workspace play flow, API contract, and Rolemaster retry implementation.
|
||||
- [x] (2026-04-14 21:39:42Z) Added transient `SituationalModifier` support to the skill-roll request, API endpoint, service facade, and roll pipeline without adding persistence or schema changes.
|
||||
- [ ] Add a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
|
||||
- [x] (2026-04-14 21:51:33Z) Added a Rolemaster-only pre-roll modal on the play screen with autofocus, Escape dismissal, Enter submit, outside-click dismissal, and inline validation for signed integer input.
|
||||
- [x] (2026-04-14 21:39:42Z) Updated Rolemaster roll execution and breakdown formatting so temporary modifiers are shown explicitly and feed retry-band evaluation plus retry attempts.
|
||||
- [ ] Add service, API, and Playwright coverage for the new behavior; update `README.md`; run `jb cleanupcode --build=False ...`; run `pwsh ./scripts/ci-local.ps1`; commit the iteration (completed: service and API coverage plus `README.md`; remaining: Playwright coverage, cleanup, full CI, commit).
|
||||
- [x] (2026-04-14 21:51:33Z) Added service, API, and Playwright coverage for the new behavior, updated `README.md`, and prepared the touched files for cleanup, full CI, and commit.
|
||||
|
||||
## Surprises & Discoveries
|
||||
|
||||
@@ -35,6 +35,9 @@ The important user-visible rule is that this temporary modifier must be applied
|
||||
- Observation: compact Rolemaster retry summaries still preview the trigger die, not the fully modified first-attempt arithmetic. The authoritative arithmetic belongs in the breakdown string.
|
||||
Evidence: with a situational modifier, the new service test now expects `8 | open-ended | retry +5` in the compact summary while the detailed breakdown is `8+50+20=78; retry(+5): 42+50+20=112; final=117`.
|
||||
|
||||
- Observation: Razor string parameters in the new modal call site need explicit `@` binding or the UI renders the property name as literal text.
|
||||
Evidence: the first Playwright failure snapshot showed the dialog rendering `State.PendingRolemasterSkillRollError` instead of the actual inline validation message until the binding was corrected in `Workspace.razor`.
|
||||
|
||||
## Decision Log
|
||||
|
||||
- Decision: the situational modifier will be transient request data only and will not be stored on `Skill`, `RollLogEntry`, or in a migration.
|
||||
@@ -63,7 +66,7 @@ The important user-visible rule is that this temporary modifier must be applied
|
||||
|
||||
## Outcomes & Retrospective
|
||||
|
||||
The first implementation slice is complete on the backend. Rolemaster skill-roll requests can now carry a transient situational modifier through the API and service pipeline, Rolemaster breakdowns show that modifier explicitly, and automatic retry math reuses the same modifier on both attempts. Service and API tests now prove standard-roll arithmetic, retry-trigger arithmetic, and the Rolemaster-only server guard. The browser play flow is still pending because the dedicated pre-roll modal has not been added yet.
|
||||
The feature is now complete end to end. Rolemaster skill rolls no longer execute immediately from the play screen; they first open a modal that accepts an optional one-shot situational modifier, focuses the input automatically, closes on Escape or backdrop click, and validates whole-number input inline. Confirmed rolls send the temporary modifier through the existing skill-roll API, Rolemaster breakdowns show the base and situational modifiers as separate terms, and automatic retry math reuses the same situational modifier on both attempts. Service, API, and Playwright coverage now prove the backend math, the Rolemaster-only guard, and the browser interaction flow.
|
||||
|
||||
## Context and Orientation
|
||||
|
||||
@@ -192,3 +195,4 @@ Create a new modal component at `RpgRoller/Components/Pages/HomeControls/Rolemas
|
||||
|
||||
Plan revision note (2026-04-14 / Codex): created the initial ExecPlan for the new Rolemaster one-shot situational modifier modal because `TASKS.md` was empty and the feature needs a self-contained implementation guide before coding begins.
|
||||
Plan revision note (2026-04-14 / Codex): updated the living plan after the first backend slice landed so progress, discoveries, and retrospective match the new transient request path, explicit breakdown formatting, and added service/API coverage.
|
||||
Plan revision note (2026-04-14 / Codex): updated the living plan again after the UI slice completed so the document now reflects the shipped modal behavior, the Playwright coverage, and the final end-to-end outcome.
|
||||
|
||||
@@ -137,6 +137,59 @@ test("Rolemaster automatic retry badge shows before detail expands", async ({ pa
|
||||
await expect(detailDice.nth(1)).toHaveAttribute("title", /retry attempt 2/i);
|
||||
});
|
||||
|
||||
test("Rolemaster skill roll modal autofocuses, validates, and closes on escape or backdrop", async ({ page, context }) => {
|
||||
const username = `rm-modal-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "Rolemaster Modal Smoke");
|
||||
|
||||
const campaign = await postJson(context.request, "/api/campaigns", {
|
||||
name: "Rolemaster Modal Smoke",
|
||||
rulesetId: "rolemaster"
|
||||
});
|
||||
const character = await postJson(context.request, "/api/characters", {
|
||||
name: "Observer",
|
||||
campaignId: campaign.id
|
||||
});
|
||||
await postJson(context.request, `/api/characters/${character.id}/skills`, {
|
||||
name: "Observation",
|
||||
diceRollDefinition: "d100!+50",
|
||||
wildDice: 0,
|
||||
allowFumble: false,
|
||||
fumbleRange: 5,
|
||||
rolemasterAutoRetry: true
|
||||
});
|
||||
|
||||
await page.goto("/");
|
||||
await expect(page.getByText("Campaign Log")).toBeVisible();
|
||||
|
||||
const rollButton = page.getByRole("button", { name: "Roll Observation" });
|
||||
const modal = page.getByRole("dialog", { name: "Rolemaster situational modifier" });
|
||||
const modifierInput = page.locator("#rolemaster-situational-modifier");
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await expect(modifierInput).toBeFocused();
|
||||
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await page.locator(".modal-overlay").click({ position: { x: 8, y: 8 } });
|
||||
await expect(modal).toHaveCount(0);
|
||||
|
||||
await rollButton.click();
|
||||
await expect(modal).toBeVisible();
|
||||
await modifierInput.fill("1001");
|
||||
await modal.getByRole("button", { name: "Roll" }).click();
|
||||
await expect(page.getByText("Enter a whole number between -1000 and 1000.")).toBeVisible();
|
||||
await expect(modal).toBeVisible();
|
||||
|
||||
await modifierInput.fill("");
|
||||
await page.keyboard.press("Enter");
|
||||
await expect(modal).toHaveCount(0);
|
||||
await expect(page.locator(".log-panel .log-entry.expanded").first()).toContainText("Observation");
|
||||
});
|
||||
|
||||
test("newly rolled log entry auto-expands", async ({ page, context }) => {
|
||||
const username = `d6-log-${Date.now()}`;
|
||||
await registerAndLogin(context.request, username, "D6 Auto Expand");
|
||||
|
||||
Reference in New Issue
Block a user